カメラで対戦する「MotionBoxing」


【更新履歴】

 ・2026/3/12 バージョン1.0公開。
          (中略)
 ・2026/3/12 バージョン1.4公開。


画像

【操作説明】(シングルモードでマウス操作の場合)

・マウスを移動させると、プレイヤーキャラの位置が移動します。
・左ボタンで左パンチ。
・右ボタンで右パンチ。
・左右を同時に押すとガード。


・ダウンロードされる方はこちら。↓


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Motion Boxing 1.3</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      background: #050505;
      color: white;
      font-family: 'Arial', sans-serif;
      overflow: hidden;
      display: flex;
      flex-direction: column;
      align-items: center;
      user-select: none;
    }
    #ui {
      position: absolute;
      top: 10px;
      width: 100%;
      display: flex;
      justify-content: space-between;
      padding: 0 30px;
      z-index: 10;
      pointer-events: none;
    }
    .stat-box {
      background: rgba(0,0,0,0.8);
      border: 2px solid #00f3ff;
      border-radius: 8px;
      padding: 10px 25px;
      text-align: center;
      box-shadow: 0 0 10px #00f3ff;
    }
    .stat-box .label { font-size: 0.8rem; color: #aaa; text-transform: uppercase; letter-spacing: 2px; }
    .stat-box .value { font-size: 1.8rem; font-weight: bold; color: #fff; text-shadow: 0 0 10px #00f3ff; }
    #health-bar-container {
      position: absolute;
      top: 100px;
      width: 80%;
      left: 10%;
      z-index: 10;
      pointer-events: none;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    .health-bar-wrapper { width: 100%; }
    .health-label { font-size: 0.8rem; color: #ddd; margin-bottom: 3px; font-weight: bold; text-shadow: 1px 1px 2px black; }
    .health-bar-bg {
      background: rgba(30,30,30,0.8);
      border-radius: 4px;
      height: 20px;
      overflow: hidden;
      border: 2px solid #444;
      box-shadow: inset 0 0 10px black;
    }
    .health-bar-fill { height: 100%; border-radius: 2px; transition: width 0.15s ease-out; }
    #enemy-health-fill { background: linear-gradient(90deg, #ff0055, #ff4444); width: 100%; box-shadow: 0 0 15px #ff0055; }
    #player-health-fill { background: linear-gradient(90deg, #00ff88, #00b359); width: 100%; box-shadow: 0 0 15px #00ff88; }
    #player-stamina-fill { background: linear-gradient(90deg, #00f3ff, #0088ff); width: 100%; box-shadow: 0 0 15px #00f3ff; transition: width 0.05s linear; }
    
    #canvas-container {
      position: relative;
      width: 100vw;
      height: 100vh;
    }
    #threejs-container {
      position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;
    }
    #gameCanvas {
      position: absolute; top: 0; left: 0; width: 100vw; height: 100vh;
      display: block; cursor: crosshair; z-index: 2; pointer-events: none;
    }
    #video { display: none; }
    
    #overlay {
      position: absolute; top: 0; left: 0; width: 100%; height: 100%;
      background: rgba(0,0,0,0.85); backdrop-filter: blur(5px);
      display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 20;
    }
    #overlay h1 { font-size: 4rem; color: #fff; text-shadow: 0 0 20px #00f3ff, 0 0 40px #00f3ff; font-style: italic; margin-bottom: 30px; }
    
    .menu-panel {
      background: rgba(20,20,20,0.9); border: 1px solid #00f3ff; padding: 30px;
      border-radius: 15px; width: 450px; text-align: center; box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);
    }
    .menu-panel h3 { margin-bottom: 20px; color: #ffd700; }
    .menu-panel p { margin-bottom: 15px; font-size: 0.95rem; color: #ccc; line-height: 1.5; }
    .key-hint { color: #00ff88; font-weight: bold; }
    
    .btn {
      display: block; width: 100%; padding: 15px; margin-bottom: 15px; font-size: 1.2rem;
      background: transparent; color: #00f3ff; border: 2px solid #00f3ff; border-radius: 8px;
      cursor: pointer; font-weight: bold; transition: all 0.2s;
    }
    .btn:hover { background: #00f3ff; color: #000; box-shadow: 0 0 20px #00f3ff; }
    .btn-red { border-color: #ff0055; color: #ff0055; }
    .btn-red:hover { background: #ff0055; color: #fff; box-shadow: 0 0 20px #ff0055; }
    .btn-icon { position: absolute; top: 20px; right: 20px; width: auto; padding: 10px 20px; z-index: 30; pointer-events: auto; }
    
    input[type="text"] { width: 100%; padding: 12px; background: #111; border: 1px solid #444; color: white; font-size: 1.1rem; border-radius: 5px; margin-bottom: 15px; text-align: center; }
    
    details > summary { cursor: pointer; font-weight: bold; color: #00f3ff; outline: none; padding: 5px 0; }
    details > ul { list-style: none; padding-left: 20px; border-left: 1px solid #444; margin-bottom: 15px; }
    details > ul > li { margin-bottom: 10px; font-size: 0.95rem; }
    
    #hit-effect { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 15; opacity: 0; transition: opacity 0.1s; }
    #combo-display { position: absolute; bottom: 50px; left: 50%; transform: translateX(-50%); font-size: 3.5rem; font-weight: bold; color: #fff; text-shadow: 0 0 20px #ff0055, 0 0 40px #ff0055; z-index: 10; pointer-events: none; opacity: 0; transition: opacity 0.2s; font-style: italic; }
    #msg-overlay { position: absolute; top: 40%; width: 100%; text-align: center; font-size: 6rem; font-weight: bold; color: white; text-shadow: 0 0 30px #ff0055; z-index: 100; pointer-events: none; opacity: 0; }
  </style>
</head>
<body>

<div id="ui">
  <div class="stat-box">
    <div class="label">SCORE</div>
    <div class="value" id="score">0</div>
  </div>
  <div class="stat-box" id="round-box">
    <div class="label">ROUND</div>
    <div class="value" id="round">1</div>
  </div>
  <div class="stat-box">
    <div class="label">COMBO</div>
    <div class="value" id="combo">x0</div>
  </div>
</div>

<div id="health-bar-container">
  <div class="health-bar-wrapper">
    <div class="health-label" id="enemy-label">ENEMY HP (頭部ダメージ大)</div>
    <div class="health-bar-bg"><div class="health-bar-fill" id="enemy-health-fill"></div></div>
  </div>
  <div class="health-bar-wrapper">
    <div class="health-label">PLAYER HP</div>
    <div class="health-bar-bg"><div class="health-bar-fill" id="player-health-fill"></div></div>
  </div>
  <div class="health-bar-wrapper">
    <div class="health-label">STAMINA</div>
    <div class="health-bar-bg" style="height: 12px;"><div class="health-bar-fill" id="player-stamina-fill"></div></div>
  </div>
</div>

<div id="canvas-container">
  <video id="video" autoplay playsinline></video>
  <div id="threejs-container"></div>
  <canvas id="gameCanvas"></canvas>
  <div id="hit-effect"></div>
  <div id="combo-display"></div>
  <div id="msg-overlay"></div>
</div>

<button class="btn btn-icon" onclick="openSettings()" style="display:none;" id="settings-btn">⚙️ 設定</button>

<div id="overlay">
  <h1>VIRTUAL BOUT</h1>
  
  <div class="menu-panel" id="menu-main">
    <h3>プレイスタイルを選択</h3>
    <p>踏み込んで打てば<span class="key-hint">大ダメージ(CLEAN HIT)</span>!<br>
       パンチを合わせて弾き返せ(PARRY)!</p>
    <button class="btn" onclick="showMenu('menu-single')">👤 シングルプレイ (対NPC)</button>
    <button class="btn btn-red" onclick="showMenu('menu-multi')">🌐 P2P通信対戦</button>
    <button class="btn" style="border-color:#aaa; color:#aaa;" onclick="showMenu('menu-settings')">⚙️ 設定 (Settings)</button>
  </div>
  
  <div class="menu-panel" id="menu-single" style="display:none;">
    <h3>操作方法</h3>
    <p>【マウス】ポインタで移動。左右クリックでパンチ。<span class="key-hint">同時押しでガード</span><br>
       【カメラ】自分の体の動きでゲーム内の体を移動・回避</p>
    <button class="btn" onclick="initGame('single', 'camera')">📷 カメラでプレイ (全身)</button>
    <button class="btn" onclick="initGame('single', 'mouse')">🖱️ マウスでプレイ</button>
    <button class="btn" style="border-color:#555;color:#aaa;" onclick="showMenu('menu-main')">戻る</button>
  </div>
  
  <div class="menu-panel" id="menu-multi" style="display:none;">
    <h3>P2P 通信対戦</h3>
    <p style="color:#00f3ff; font-weight:bold;">あなたのID: <span id="my-peer-id">接続中...</span></p>
    <input type="text" id="target-id-input" placeholder="相手のIDを入力して接続">
    <div style="display:flex; gap:10px; margin-bottom:15px;">
      <button class="btn btn-red" onclick="connectToPeer('camera')" style="margin:0;font-size:1rem;">📷 カメラ接続</button>
      <button class="btn btn-red" onclick="connectToPeer('mouse')" style="margin:0;font-size:1rem;">🖱️ マウス接続</button>
    </div>
    <button class="btn" style="border-color:#555;color:#aaa;" onclick="showMenu('menu-main')">戻る</button>
  </div>
  
  <div class="menu-panel" id="menu-settings" style="display:none; text-align:left; max-height:80vh; overflow-y:auto;">
    <h3 style="text-align:center;">⚙️ SETTINGS</h3>
    <ul style="list-style:none; padding:0; color:#ccc;">
        <li>
            <details open>
                <summary>🎮 ゲームシステム (System)</summary>
                <ul>
                    <li style="color:#00ff88;">AI長期記憶学習: 有効 (localStorage)</li>
                    <li>スタミナシステム: 有効 (攻撃時に消費)</li>
                    <li>部位ダメージ・相殺判定: 有効</li>
                </ul>
            </details>
        </li>
        <li>
            <details open>
                <summary>✨ グラフィックス (Graphics/WebGL)</summary>
                <ul>
                    <li>
                        <label>敵の質感シェーダー (Material):</label><br>
                        <select id="tex-select" onchange="changeTexture(this.value)" style="width:100%; padding:5px; background:#111; color:#fff; border:1px solid #00f3ff; border-radius:4px; margin-top:5px;">
                            <option value="normal">Normal (肌)</option>
                            <option value="slime">Slime (スライム・水滴風)</option>
                            <option value="metal">Fluid Metal (流体金属)</option>
                            <option value="crystal">Crystal (角ばったクリスタル)</option>
                        </select>
                    </li>
                </ul>
            </details>
        </li>
        <li>
            <details open>
                <summary>🎵 サウンド (Audio)</summary>
                <ul>
                    <li><label>BGM音量:</label> <input type="range" id="bgm-vol" min="0" max="100" value="60" onchange="updateAudioVol()"></li>
                    <li><label>SE音量:</label> <input type="range" id="se-vol" min="0" max="100" value="80"></li>
                </ul>
            </details>
        </li>
    </ul>
    <button class="btn" onclick="closeSettings()" style="margin-top:20px;">閉じる</button>
  </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose/pose.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>

<script>
// ─── オーディオシステム ───────────────────────────
const AudioContext = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
let bgmGain = null;
let seVol = 0.8;

function initAudio() {
  if (!audioCtx) audioCtx = new AudioContext();
  if (audioCtx.state === 'suspended') audioCtx.resume();
  updateAudioVol();
}

function updateAudioVol() {
  seVol = document.getElementById('se-vol').value / 100;
  if(bgmGain) bgmGain.gain.setTargetAtTime((document.getElementById('bgm-vol').value / 100) * 0.25, audioCtx.currentTime, 0.1);
}

function playTone(freq, type, duration, volMod) {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
  gain.gain.setValueAtTime(seVol * volMod, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
  osc.connect(gain); gain.connect(audioCtx.destination);
  osc.start(); osc.stop(audioCtx.currentTime + duration);
}

function playSound(type) {
  if (type === 'hit') { playTone(800, 'square', 0.1, 0.4); setTimeout(()=>playTone(1200, 'square', 0.15, 0.4), 50); }
  if (type === 'clean') { playTone(1000, 'square', 0.1, 0.7); setTimeout(()=>playTone(1500, 'square', 0.25, 0.7), 50); }
  if (type === 'guard') { playTone(400, 'sawtooth', 0.15, 0.3); }
  if (type === 'parry') { playTone(600, 'triangle', 0.1, 0.6); playTone(800, 'sine', 0.2, 0.6); } // ★相殺音
  if (type === 'swing') { playTone(150, 'sine', 0.2, 0.4); }
  if (type === 'damage') { playTone(100, 'sawtooth', 0.4, 0.8); }
}

function startEpicBGM() {
  if(!audioCtx) initAudio();
  if(bgmGain) return; 
  bgmGain = audioCtx.createGain();
  updateAudioVol();
  bgmGain.connect(audioCtx.destination);
  
  const delay = audioCtx.createDelay();
  delay.delayTime.value = 0.8;
  const feedback = audioCtx.createGain();
  feedback.gain.value = 0.5;
  delay.connect(feedback); feedback.connect(delay); delay.connect(bgmGain);
  
  const freqs = [55.00, 110.00, 164.81, 220.00]; 
  freqs.forEach(f => {
    const osc = audioCtx.createOscillator();
    osc.type = 'sine'; osc.frequency.value = f;
    const lfo = audioCtx.createOscillator();
    lfo.type = 'sine'; lfo.frequency.value = 0.05 + Math.random() * 0.05;
    const lfoGain = audioCtx.createGain(); lfoGain.gain.value = 0.6;
    lfo.connect(lfoGain.gain);
    const oscGain = audioCtx.createGain(); oscGain.gain.value = 0.5;
    lfoGain.connect(oscGain.gain);
    osc.connect(oscGain); oscGain.connect(delay); oscGain.connect(bgmGain);
    osc.start(); lfo.start();
  });
}

// ─── AI 長期記憶システム ─────────────────────────
let aiMemory = JSON.parse(localStorage.getItem('vrBout_memory')) || { leftStrikes: 0, rightStrikes: 0, dodges: 0, blocks: 0 };
function saveMemory() { localStorage.setItem('vrBout_memory', JSON.stringify(aiMemory)); }

// ─── Three.js WebGL セットアップ ───────────────────────────────
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.0004);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 15000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x050505, 1);
document.getElementById('threejs-container').appendChild(renderer.domElement);

const ambient = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(0, 1000, 500); scene.add(dirLight);
const pointLight = new THREE.PointLight(0x00f3ff, 1.2, 3000); scene.add(pointLight);

const grid = new THREE.GridHelper(20000, 80, 0x00f3ff, 0x00f3ff);
grid.position.y = -400; grid.material.opacity = 0.25; grid.material.transparent = true;
scene.add(grid);

const materials = {
  normal: new THREE.MeshStandardMaterial({ color: 0xe0ac69, roughness: 0.6 }),
  glove: new THREE.MeshStandardMaterial({ color: 0xff0055, roughness: 0.4 }),
  playerGlove: new THREE.MeshStandardMaterial({ color: 0x00f3ff, roughness: 0.2, emissive: 0x00f3ff, emissiveIntensity: 0.2 }),
  playerArm: new THREE.MeshStandardMaterial({ color: 0x00b359, roughness: 0.5 }),
  pants: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 }),
  shoe: new THREE.MeshStandardMaterial({ color: 0xdddddd }),
  slime: new THREE.MeshPhysicalMaterial({ color: 0x00ffcc, transmission: 0.9, opacity: 1, transparent: true, roughness: 0.05, ior: 1.5 }),
  metal: new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 1.0, roughness: 0.15 }),
  crystal: new THREE.MeshPhysicalMaterial({ color: 0xddddff, transmission: 0.6, roughness: 0.0, clearcoat: 1.0, flatShading: true })
};
let currentSkinMat = materials.normal;
function changeTexture(type) { if (materials[type]) currentSkinMat = materials[type]; }

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function resize() {
  canvas.width = window.innerWidth; canvas.height = window.innerHeight;
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', resize); resize();
const W = () => canvas.width; const H = () => canvas.height;

// ─── ゲーム状態・座標系 ─────────────────────────────────────────
let gameRunning = false;
let gameMode = 'single';
let controlMode = 'mouse';
let score = 0, round = 1, combo = 0;
let comboTimer = null; 
let playerHP = 100, playerStamina = 100;

let playerPos = { x: 0, z: 0 };
let targetPlayerPos = { x: 0, z: 0 };
let enemyPos = { x: 0, z: 600 }; 
let keys = { w:false, a:false, s:false, d:false, ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false };

let screenShake = 0, hitStop = 0, flashTimer = 0, hitChromatic = 0;
let particles = [], hitNumbers = [];
let walkBob = 0, playerHurtShake = 0, camHurtPitch = 0; // ★のけぞりカメラ角度

let viewOffsetX = 0, viewOffsetY = 0;
let targetViewOffsetX = 0, targetViewOffsetY = 0;
let aimY = 0; 
let mouseL = false, mouseR = false;
let playerAction = 'idle';
let playerActionTimer = null;
let playerBlocking = false;

let armL = { x: -140, y: -150, z: 200 }, armR = { x: 140, y: -150, z: 200 };
let targetArmL = { x: -140, y: -150, z: 200 }, targetArmR = { x: 140, y: -150, z: 200 };

let enemy = {
  hp: 100, maxHP: 100, state: 'idle', stateTimer: 0, nextPunch: 'R',
  attackTimer: 0, attackInterval: 120, targetX: 0, targetZ: 600,
  hurtShake: 0, poseData: null
};
let npcPhase = 0;

document.addEventListener('keydown', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = true; });
document.addEventListener('keyup', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = false; });

let wasRunning = false;
function showMenu(id) {
  document.querySelectorAll('.menu-panel').forEach(el => el.style.display = 'none');
  document.getElementById(id).style.display = 'block';
  document.getElementById('overlay').style.display = 'flex';
}
function openSettings() { wasRunning = gameRunning; gameRunning = false; showMenu('menu-settings'); }
function closeSettings() {
  updateAudioVol();
  if (wasRunning) {
    document.getElementById('overlay').style.display = 'none';
    gameRunning = true;
  } else {
    showMenu('menu-main');
  }
}

// ─── P2P通信 (PeerJS) ───────────────────────────────────────
let peer = null; let conn = null;
let p2pSyncInterval = null;

function initPeer() {
  try {
    // 既存のpeerがあれば破棄
    if (peer && !peer.destroyed) peer.destroy();
    peer = new Peer(undefined, { debug: 0 });
    peer.on('open', id => {
      document.getElementById('my-peer-id').textContent = id;
    });
    // 相手から接続を受けた側: open確認後にゲーム開始
    peer.on('connection', c => {
      conn = c;
      c.on('open', () => {
        setupP2PConnection(c);
        initP2PGame(controlMode || 'mouse');
      });
    });
    peer.on('error', err => {
      console.error('PeerJS error:', err);
      document.getElementById('my-peer-id').textContent = 'エラー: ' + err.type;
    });
    peer.on('disconnected', () => {
      document.getElementById('my-peer-id').textContent = '切断 (再接続試行中...)';
      peer.reconnect();
    });
  } catch(e) { console.error(e); }
}
initPeer();

function connectToPeer(ctrl) {
  const targetId = document.getElementById('target-id-input').value.trim();
  if (!targetId) return alert('IDを入力してください');
  if (!peer || peer.destroyed) return alert('PeerJSが初期化されていません。ページをリロードしてください。');
  initAudio();
  controlMode = ctrl;
  // 既存の接続があれば閉じる
  if (conn && conn.open) conn.close();
  conn = peer.connect(targetId, { reliable: true, serialization: 'json' });
  const statusEl = document.getElementById('my-peer-id');
  statusEl.textContent = statusEl.textContent + ' (接続中...)';
  // 接続が確立してからゲーム開始
  conn.on('open', () => {
    setupP2PConnection(conn);
    initP2PGame(ctrl);
  });
  conn.on('error', err => {
    console.error('Connection error:', err);
    alert('接続エラー: ' + err.message);
  });
}

function setupP2PConnection(c) {
  c.on('data', data => {
    if (!data || !data.type) return;
    if (data.type === 'sync') {
      // 相手のHPと状態を自分の敵(enemy)に反映
      if (typeof data.hp === 'number') enemy.hp = Math.max(0, Math.min(enemy.maxHP, data.hp));
      if (data.state) enemy.state = data.state;
      if (data.poseData) enemy.poseData = data.poseData;
      if (data.pos) {
        // 座標系を反転して鏡映しにする
        enemyPos.x = -data.pos.x;
        enemyPos.z = 600 - data.pos.z;
      }
      if (!gameRunning) return;
      updateUI();
    }
    else if (data.type === 'punch') {
      if (!gameRunning) return;
      // 相手がパンチを打ってきた → こちらで被弾判定
      const result = evaluateHit(enemyPos, playerPos, playerBlocking, data.isHeadshot);
      if (!result.hit) {
        showHitNumber(result.reason, '#aaa');
        c.send({ type: 'hit_confirm', hit: false, reason: result.reason });
      } else {
        const dmg = Math.floor((10 + (data.speed || 250) * 0.05) * result.multi);
        takeDamage(dmg, result.type.includes('CLEAN HIT'));
        c.send({ type: 'hit_confirm', hit: true, dmg: dmg, hitType: result.type });
      }
    }
    else if (data.type === 'hit_confirm') {
      if (!gameRunning) return;
      if (data.hit) hitEnemyVisual(data.dmg, 0, 0, data.hitType && data.hitType.includes('CLEAN HIT'));
      else { showHitNumber(data.reason || 'MISS', '#aaa'); combo = 0; updateUI(); }
    }
    else if (data.type === 'gameover') {
      showResult(true);
    }
  });
  c.on('close', () => {
    if (gameRunning) {
      gameRunning = false;
      alert('相手との接続が切れました。');
      location.reload();
    }
  });
  c.on('error', err => console.error('conn error:', err));
}

function sendP2PData() {
  if (!conn || !conn.open) return;
  // poseDataは現在のポーズ検知結果を含める
  const pd = (controlMode === 'camera' && latestPoseData) ? {
    lw: latestPoseData.lw, rw: latestPoseData.rw,
    ls: latestPoseData.ls, rs: latestPoseData.rs
  } : null;
  try {
    conn.send({
      type: 'sync',
      hp: playerHP,
      state: playerAction,
      poseData: pd,
      pos: { x: playerPos.x, z: playerPos.z }
    });
  } catch(e) { /* 送信失敗は無視 */ }
}

function startP2PSyncLoop() {
  if (p2pSyncInterval) clearInterval(p2pSyncInterval);
  // 約30fps でsyncデータを送信
  p2pSyncInterval = setInterval(() => {
    if (!gameRunning) { clearInterval(p2pSyncInterval); return; }
    sendP2PData();
  }, 33);
}

function stopP2PSyncLoop() {
  if (p2pSyncInterval) { clearInterval(p2pSyncInterval); p2pSyncInterval = null; }
}

function initGame(mode, ctrl) { initAudio(); gameMode = mode; controlMode = ctrl; startGameFlow(); }
function initP2PGame(ctrl) { gameMode = 'multi'; controlMode = ctrl; startGameFlow(); startP2PSyncLoop(); }

function startGameFlow() {
  startEpicBGM();
  document.getElementById('overlay').style.display = 'none';
  document.getElementById('settings-btn').style.display = 'block';
  document.getElementById('round-box').style.display = gameMode === 'multi' ? 'none' : 'block';
  
  score = 0; round = 1; combo = 0; playerHP = 100; playerStamina = 100; camHurtPitch = 0;
  enemy.maxHP = 100; enemy.hp = 100;
  playerPos = { x: 0, z: 0 }; targetPlayerPos = { x: 0, z: 0 }; enemyPos = { x: 0, z: 600 };
  updateUI(); gameRunning = true;
  
  if (controlMode === 'mouse') {
    document.addEventListener('mousemove', onMouseMove);
    document.addEventListener('mousedown', onMouseDown);
    document.addEventListener('mouseup', onMouseUp);
    document.addEventListener('contextmenu', e => e.preventDefault());
  } else if (controlMode === 'camera') {
    initCamera();
  }
  requestAnimationFrame(gameLoop);
}

// ─── カメラ・MediaPose検知 ───────────────────────────────────────
let latestPoseData = null;
let poseInstance = null;
let cameraInstance = null;
// ポーズ検知の前フレームデータ(速度計算用)
let prevWristL = null, prevWristR = null;
let prevShoulderL = null, prevShoulderR = null;
// カメラ動作→パンチ検知の状態
let camPunchCooldownL = 0, camPunchCooldownR = 0;
let camGuardFrames = 0;

function initCamera() {
  const videoEl = document.getElementById('video');
  videoEl.style.display = 'none';

  if (!window.Pose) {
    console.error('MediaPipe Pose が読み込まれていません');
    return;
  }

  poseInstance = new Pose({
    locateFile: file => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`
  });
  poseInstance.setOptions({
    modelComplexity: 1,
    smoothLandmarks: true,
    enableSegmentation: false,
    minDetectionConfidence: 0.5,
    minTrackingConfidence: 0.5
  });
  poseInstance.onResults(onPoseResults);

  if (window.Camera) {
    cameraInstance = new Camera(videoEl, {
      onFrame: async () => {
        if (poseInstance) await poseInstance.send({ image: videoEl });
      },
      width: 640, height: 480
    });
    cameraInstance.start().catch(err => {
      console.error('カメラ起動失敗:', err);
      alert('カメラへのアクセスが拒否されました。\nマウスモードに切り替えてください。');
    });
  } else {
    // camera_utils がなければ直接getUserMediaを使用
    navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: false })
      .then(stream => {
        videoEl.srcObject = stream;
        videoEl.onloadedmetadata = () => {
          videoEl.play();
          const sendFrame = async () => {
            if (!gameRunning) return;
            if (poseInstance) await poseInstance.send({ image: videoEl });
            requestAnimationFrame(sendFrame);
          };
          requestAnimationFrame(sendFrame);
        };
      })
      .catch(err => {
        console.error('カメラアクセス失敗:', err);
        alert('カメラへのアクセスが拒否されました。マウスモードをお使いください。');
      });
  }
}

function onPoseResults(results) {
  if (!gameRunning || !results.poseLandmarks) return;
  const lm = results.poseLandmarks;

  // MediaPipe Pose ランドマークインデックス
  // 11=左肩, 12=右肩, 13=左肘, 14=右肘, 15=左手首, 16=右手首
  // 23=左腰, 24=右腰, 0=鼻
  const ls = lm[11], rs = lm[12];
  const lw = lm[15], rw = lm[16];
  const lhip = lm[23], rhip = lm[24];
  const nose = lm[0];

  if (!ls || !rs || !lw || !rw) return;

  // 信頼度フィルタ
  if (ls.visibility < 0.4 || rs.visibility < 0.4) return;

  // カメラ座標 (0~1) → ゲーム座標変換
  // xは左右反転(カメラは鏡映し)
  const midShoulderX = 1.0 - (ls.x + rs.x) / 2;
  const midShoulderY = (ls.y + rs.y) / 2;
  const shoulderWidth = Math.abs(rs.x - ls.x);

  // プレイヤー位置: 肩の中心で決定
  targetPlayerPos.x = (midShoulderX - 0.5) * 1600;
  targetPlayerPos.z = (midShoulderY - 0.5) * 600 + 150;
  // カメラ視点オフセット: 鼻の位置で決定
  if (nose) {
    targetViewOffsetX = -(1.0 - nose.x - 0.5) * 200;
    targetViewOffsetY = -(nose.y - 0.5) * 200;
  }

  // 手首の3D位置を保存(ゲーム座標)
  const lwGameX = (1.0 - lw.x - 0.5) * 1600;
  const lwGameY = (lw.y - 0.5) * 800;
  const rwGameX = (1.0 - rw.x - 0.5) * 1600;
  const rwGameY = (rw.y - 0.5) * 800;

  latestPoseData = {
    ls: { x: ls.x, y: ls.y }, rs: { x: rs.x, y: rs.y },
    lw: { x: lw.x, y: lw.y, vis: lw.visibility },
    rw: { x: rw.x, y: rw.y, vis: rw.visibility }
  };

  // ── パンチ検知 ──────────────────────────────────────────────
  if (prevWristL && prevWristR) {
    // 手首のフレーム間速度を計算(肩幅で正規化)
    const refWidth = Math.max(shoulderWidth, 0.05);
    const velLX = (lw.x - prevWristL.x) / refWidth;
    const velLY = (lw.y - prevWristL.y) / refWidth;
    const velRX = (rw.x - prevWristR.x) / refWidth;
    const velRY = (rw.y - prevWristR.y) / refWidth;
    // 前方への速度: カメラ画像ではY軸上昇(y減少)=前方突き出し として検知
    // 実際は手首がカメラに近づく→画像上で手首が大きく見えるが単純ランドマークでは取れないため
    // 左右方向への素早い動き + 向き(相手方向)で判定
    const speedL = Math.sqrt(velLX*velLX + velLY*velLY);
    const speedR = Math.sqrt(velRX*velRX + velRY*velRY);
    const PUNCH_THRESHOLD = 0.18;

    // 左パンチ: 左手首が素早く右方向(相手方向)に動く、かつ肩より前(高め)
    if (camPunchCooldownL <= 0 && speedL > PUNCH_THRESHOLD && lw.visibility > 0.5 && playerAction === 'idle') {
      const isForward = velLX > 0.1; // 鏡映しなので右向き=前に出す
      if (isForward && playerStamina >= 15) {
        playerStamina -= 15;
        aimY = lw.y < 0.35 ? -0.5 : 0.1; // 手首高さでヘッドショット判定
        playerBlocking = false;
        playerAction = 'windupL';
        playerActionTimer = setTimeout(() => {
          if (playerAction === 'windupL') {
            playerAction = 'punchL';
            tryPunch('left', Math.min(600, speedL * 3000));
            setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300);
          }
        }, 80);
        camPunchCooldownL = 20; // 約0.33秒クールダウン
      }
    }
    // 右パンチ: 右手首が素早く左方向(相手方向)に動く
    if (camPunchCooldownR <= 0 && speedR > PUNCH_THRESHOLD && rw.visibility > 0.5 && playerAction === 'idle') {
      const isForward = velRX < -0.1; // 右手は左方向=前に出す
      if (isForward && playerStamina >= 15) {
        playerStamina -= 15;
        aimY = rw.y < 0.35 ? -0.5 : 0.1;
        playerBlocking = false;
        playerAction = 'windupR';
        playerActionTimer = setTimeout(() => {
          if (playerAction === 'windupR') {
            playerAction = 'punchR';
            tryPunch('right', Math.min(600, speedR * 3000));
            setTimeout(() => { if (playerAction === 'punchR') playerAction = 'idle'; }, 300);
          }
        }, 80);
        camPunchCooldownR = 20;
      }
    }
  }

  // ── ガード検知: 両手首を顔の前(高い位置)に上げる ──
  const lHandHigh = lw.y < ls.y - 0.05 && lw.visibility > 0.4;
  const rHandHigh = rw.y < rs.y - 0.05 && rw.visibility > 0.4;
  if (lHandHigh && rHandHigh && playerAction === 'idle') {
    camGuardFrames++;
    if (camGuardFrames > 3) {
      mouseL = true; mouseR = true;
      updatePlayerAction();
    }
  } else {
    if (camGuardFrames > 3 && playerAction === 'guard') {
      mouseL = false; mouseR = false;
      updatePlayerAction();
    }
    camGuardFrames = 0;
  }

  // クールダウン減算
  if (camPunchCooldownL > 0) camPunchCooldownL--;
  if (camPunchCooldownR > 0) camPunchCooldownR--;

  prevWristL = { x: lw.x, y: lw.y };
  prevWristR = { x: rw.x, y: rw.y };

  // カメラモードのアーム位置: 手首ランドマークからゲーム座標に変換
  if (controlMode === 'camera') {
    const lwArmX = (1.0 - lw.x - 0.5) * 800 - targetPlayerPos.x;
    const lwArmY = (lw.y - 0.5) * -600;
    const rwArmX = (1.0 - rw.x - 0.5) * 800 - targetPlayerPos.x;
    const rwArmY = (rw.y - 0.5) * -600;
    // アイドル時のみポーズに追従(パンチ中は既存アニメを優先)
    if (playerAction === 'idle' || playerAction === 'guard') {
      targetArmL.x = Math.max(-350, Math.min(350, lwArmX));
      targetArmL.y = Math.max(-400, Math.min(0, lwArmY));
      targetArmR.x = Math.max(-350, Math.min(350, rwArmX));
      targetArmR.y = Math.max(-400, Math.min(0, rwArmY));
    }
  }
}

// ─── 入力とアクション ─────────────────────────────────────────
function onMouseMove(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  targetViewOffsetX = -(e.clientX - W()/2) * 0.4;
  targetViewOffsetY = -(e.clientY - H()/2) * 0.4;
  aimY = (e.clientY / H()) * 2 - 1; 
  targetPlayerPos.x = ((e.clientX - W()/2) / (W()/2)) * 800; // ★左右移動範囲拡大
  targetPlayerPos.z = ((e.clientY - H()/2) / (H()/2)) * 400 + 150; 
}
function onMouseDown(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  if (e.button === 0) mouseL = true;
  if (e.button === 2) mouseR = true;
  updatePlayerAction();
}
function onMouseUp(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  if (e.button === 0) mouseL = false;
  if (e.button === 2) mouseR = false;
  updatePlayerAction();
}

function updatePlayerAction() {
  // すでに弾かれ中なら無効
  if (playerAction === 'recoil') return;

  if (mouseL && mouseR) {
    clearTimeout(playerActionTimer); playerAction = 'guard'; playerBlocking = true; playSound('swing');
  } 
  else if (mouseL && !mouseR && playerAction === 'idle') {
    if (playerStamina < 15) return; playerStamina -= 15; 
    playerBlocking = false; playerAction = 'windupL'; playSound('swing');
    playerActionTimer = setTimeout(() => {
      if (playerAction === 'windupL') { playerAction = 'punchL'; tryPunch('left', 250); setTimeout(() => { if(playerAction==='punchL') playerAction = 'idle'; }, 300); }
    }, 120);
  } 
  else if (mouseR && !mouseL && playerAction === 'idle') {
    if (playerStamina < 15) return; playerStamina -= 15;
    playerBlocking = false; playerAction = 'windupR'; playSound('swing');
    playerActionTimer = setTimeout(() => {
      if (playerAction === 'windupR') { playerAction = 'punchR'; tryPunch('right', 250); setTimeout(() => { if(playerAction==='punchR') playerAction = 'idle'; }, 300); }
    }, 120);
  } 
  else if (!mouseL && !mouseR) {
    playerBlocking = false;
    if (playerAction === 'guard' || playerAction === 'windupL' || playerAction === 'windupR') {
      clearTimeout(playerActionTimer); playerAction = 'idle';
    }
  }
}

function detectMovement() {
  let isMoving = false;
  if (controlMode === 'mouse') {
    let movedByKey = false; const speed = 25; // ★移動速度アップ
    if (keys.w || keys.ArrowUp) { playerPos.z += speed; movedByKey = true; }
    if (keys.s || keys.ArrowDown) { playerPos.z -= speed; movedByKey = true; }
    if (keys.a || keys.ArrowLeft) { playerPos.x -= speed; movedByKey = true; }
    if (keys.d || keys.ArrowRight) { playerPos.x += speed; movedByKey = true; }

    if (movedByKey) { targetPlayerPos.x = playerPos.x; targetPlayerPos.z = playerPos.z; isMoving = true; } 
    else {
       const dx = targetPlayerPos.x - playerPos.x, dz = targetPlayerPos.z - playerPos.z;
       if (Math.abs(dx) > 5 || Math.abs(dz) > 5) isMoving = true;
       playerPos.x += dx * 0.15; playerPos.z += dz * 0.15;
    }
  } 
  
  if (isMoving) walkBob += 0.2; else walkBob = 0;
  // ★リングの横幅を大きく拡大
  playerPos.x = Math.max(-1500, Math.min(1500, playerPos.x));
  playerPos.z = Math.max(-200, Math.min(1000, playerPos.z));

  if (playerAction === 'idle') {
    targetArmL = { x: -140, y: -150 + Math.sin(walkBob)*10, z: 200 };
    targetArmR = { x: 140, y: -150 + Math.sin(walkBob + Math.PI)*10, z: 200 };
  } else if (playerAction === 'guard') {
    targetArmL = { x: -60, y: -250, z: 300 }; 
    targetArmR = { x: 60, y: -250, z: 300 };
  } else if (playerAction === 'windupL') {
    targetArmL = { x: -200, y: -100, z: -100 }; 
    targetArmR = { x: 140, y: -150, z: 200 };
  } else if (playerAction === 'windupR') {
    targetArmL = { x: -140, y: -150, z: 200 };
    targetArmR = { x: 200, y: -100, z: -100 };
  } else if (playerAction === 'punchL') {
    targetArmL = { x: -40, y: -280, z: 1200 }; 
    targetArmR = { x: 140, y: -150, z: 200 };
  } else if (playerAction === 'punchR') {
    targetArmL = { x: -140, y: -150, z: 200 };
    targetArmR = { x: 40, y: -280, z: 1200 };
  } else if (playerAction === 'recoil') { // ★相殺されて弾かれたポーズ
    targetArmL = { x: -300, y: -100, z: 0 }; 
    targetArmR = { x: 300, y: -100, z: 0 };
  }

  armL.x += (targetArmL.x - armL.x) * 0.4; armL.y += (targetArmL.y - armL.y) * 0.4; armL.z += (targetArmL.z - armL.z) * 0.4;
  armR.x += (targetArmR.x - armR.x) * 0.4; armR.y += (targetArmR.y - armR.y) * 0.4; armR.z += (targetArmR.z - armR.z) * 0.4;
}

// ─── 当たり判定と戦闘ロジック ─────────────────────────
function evaluateHit(attackerPos, defenderPos, isDefenderBlocking, isHeadshotAim) {
  const dx = Math.abs(attackerPos.x - defenderPos.x);
  const dz = Math.abs(attackerPos.z - defenderPos.z);
  
  if (dx > 200) return { hit: false, reason: 'MISS (DODGED)' };
  if (dz > 450) return { hit: false, reason: 'MISS (TOO FAR)' };
  if (isDefenderBlocking) return { hit: false, reason: 'GUARD' };
  
  let isClean = (dz < 250 && dx < 100);
  if (isHeadshotAim) return { hit: true, type: isClean ? 'HEADSHOT! CLEAN HIT' : 'HEADSHOT', multi: isClean ? 2.0 : 1.5 };
  return { hit: true, type: isClean ? 'CLEAN HIT' : 'HIT', multi: isClean ? 1.5 : 1.0 };
}

function tryPunch(side, speed) {
  let isHead = aimY < -0.2; 
  if(side === 'left') aiMemory.leftStrikes++; else aiMemory.rightStrikes++;
  saveMemory();

  if (gameMode === 'multi') {
    if (conn && conn.open) conn.send({ type: 'punch', side: side, speed: speed, isHeadshot: isHead });
  } else {
    // ★パンチ同士の相殺(パリィ)判定
    const isEnemyAttacking = enemy.state.includes('punch');
    if (isEnemyAttacking) {
      // 距離が届く範囲でお互いパンチを出していたら相殺
      const dz = Math.abs(playerPos.z - enemyPos.z);
      if (dz < 450) {
        playSound('parry');
        showHitNumber('PARRY!', '#00f3ff', 1.5);
        screenShake = 20; hitStop = 10;
        enemy.state = 'recoil'; enemy.stateTimer = 25; // 敵も弾かれる
        playerAction = 'recoil'; clearTimeout(playerActionTimer); // 自分も弾かれる
        setTimeout(() => { if(playerAction==='recoil') playerAction = 'idle'; }, 400);
        return;
      }
    }

    const isEnemyBlocking = enemy.state === 'blocking'; 
    const result = evaluateHit(playerPos, enemyPos, isEnemyBlocking, isHead);
    
    if (!result.hit) {
      if (result.reason === 'GUARD') playSound('guard');
      showHitNumber(result.reason, '#aaa');
      combo = 0; updateUI();
    } else {
      const dmg = Math.floor((10 + speed * 0.05) * result.multi);
      enemy.hp = Math.max(0, enemy.hp - dmg);
      hitEnemyVisual(dmg, side === 'right' ? 50 : -50, isHead ? -150 : -50, result.type.includes('CLEAN HIT'));
      if (enemy.hp <= 0) setTimeout(nextRound, 1000);
    }
  }
}

function hitEnemyVisual(dmg, ox, oy, isCleanHit) {
  playSound(isCleanHit ? 'clean' : 'hit');
  combo++; score += dmg * combo * (isCleanHit ? 2 : 1);
  
  enemy.state = 'hurt'; enemy.stateTimer = 25; enemy.hurtShake = isCleanHit ? 70 : 35;
  hitStop = isCleanHit ? 12 : 6; screenShake = isCleanHit ? 35 : 18; flashTimer = isCleanHit ? 6 : 3;

  const color = isCleanHit ? '#ffd700' : '#ff0055';
  // ── パーティクルを種類分け ──────────────────────────
  // 通常ヒット: 汗の飛沫(白/青白)+ 衝撃火花
  spawnParticles(W()/2 + ox, H()/2 + oy, '#ffffff', isCleanHit ? 18 : 8);
  spawnParticles(W()/2 + ox, H()/2 + oy, color,     isCleanHit ? 30 : 14);
  // クリーンヒット時: 追加で大きな火花を上へ放射
  if (isCleanHit) {
    spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ffd700', 20, -1.0, 0.4);
    spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ff6600', 15, -1.2, 0.3);
    // HPが低い場合は赤い血しぶきも追加
    if (enemy.hp / enemy.maxHP < 0.4) {
      spawnParticlesDirected(W()/2 + ox + 20, H()/2 + oy, '#ff0000', 12, -0.8, 0.6);
    }
  }
  showHitNumber(isCleanHit ? `CLEAN HIT! ${dmg}` : `${dmg}`, color, isCleanHit ? 1.5 : 1);

  // ── コンボ表示 ──────────────────────────────────────
  if (combo >= 3) {
    const cd = document.getElementById('combo-display');
    cd.textContent = `${combo} HIT!!`; cd.style.opacity = '1';
    clearTimeout(comboTimer); comboTimer = setTimeout(() => cd.style.opacity = '0', 1000);
  }
  // ── エミッシブ点滅: クリーンヒット時に敵が一瞬黄色く光る ──
  if (isCleanHit && currentSkinMat.emissive !== undefined) {
    const origEmissive = currentSkinMat.emissive.getHex();
    currentSkinMat.emissive.setHex(0xffaa00);
    currentSkinMat.emissiveIntensity = 1.0;
    setTimeout(() => {
      currentSkinMat.emissive.setHex(origEmissive);
      currentSkinMat.emissiveIntensity = 0;
    }, 120);
  }
  updateUI();
}

// 方向付きパーティクル: vyBias で上方向に飛ばすなど
function spawnParticlesDirected(x, y, color, count, vyBias, spread) {
  for (let i = 0; i < count; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = 8 + Math.random() * 18;
    particles.push({
      x, y,
      vx: Math.cos(a) * s * spread,
      vy: Math.sin(a) * s * spread + vyBias * (8 + Math.random() * 10),
      alpha: 1,
      size: 4 + Math.random() * 9,
      color
    });
  }
}

function takeDamage(dmg, isCleanHit) {
  playSound('damage');
  playerHP = Math.max(0, playerHP - dmg);
  combo = 0; updateUI();
  
  // ── プレイヤー被弾: 激しいリアクション ──────────────────────
  screenShake     = isCleanHit ? 90  : 45;
  playerHurtShake = isCleanHit ? 110 : 55;
  camHurtPitch    = isCleanHit ? 1100 : 650;
  flashTimer      = isCleanHit ? 7 : 4;

  // ── ヒットエフェクト: 画面周囲の赤グロー ──────────────────
  const hitEl = document.getElementById('hit-effect');
  if (isCleanHit) {
    hitEl.style.background = 'radial-gradient(circle, transparent 20%, rgba(255,200,0,0.85) 90%)';
    showHitNumber('CRITICAL DANGER!', '#ff0000', 1.5);
    // 低体力なら追加の血の靄
    if (playerHP < 30) {
      hitEl.style.background = 'radial-gradient(circle, transparent 10%, rgba(220,0,0,0.9) 80%)';
    }
  } else {
    hitEl.style.background = 'radial-gradient(circle, transparent 25%, rgba(255,0,85,0.7) 100%)';
  }
  hitEl.style.opacity = '1';
  // フェードアウト: クリーンヒットは少し長め
  setTimeout(() => { hitEl.style.opacity = '0'; }, isCleanHit ? 220 : 150);

  // ── クロマティックアバレーション風の残像 (canvas) ─────────
  hitChromatic = isCleanHit ? 12 : 6;

  if (playerHP <= 0) {
    if (gameMode === 'multi' && conn && conn.open) conn.send({ type: 'gameover' });
    showResult(false);
  }
}

// ─── NPC AI ──────────────────────────────────────────
function updateEnemyAI() {
  if (enemy.stateTimer > 0) {
    enemy.stateTimer--;
    
    if (enemy.state.includes('windup') && enemy.stateTimer === 15) {
      enemy.state = 'punch' + enemy.nextPunch;
      const result = evaluateHit(enemyPos, playerPos, playerBlocking, false); 
      if (!result.hit) {
         if (result.reason === 'GUARD') playSound('guard');
         showHitNumber(result.reason, '#aaa');
      } else {
         takeDamage(Math.floor((10 + round * 2) * result.multi), result.type.includes('CLEAN HIT'));
      }
    }
    if (enemy.stateTimer <= 0) enemy.state = 'idle';
  }
  
  if (enemy.hurtShake > 0) enemy.hurtShake *= 0.8;
  
  let totalStrikes = aiMemory.leftStrikes + aiMemory.rightStrikes || 1;
  let leftRatio = aiMemory.leftStrikes / totalStrikes;
  let dodgeOffset = (leftRatio - 0.5) * 300; 
  
  const targetDist = 450; 
  if (enemy.state === 'idle') {
    enemy.targetX = playerPos.x + Math.sin(Date.now() * 0.001) * 200 + dodgeOffset;
    enemy.targetZ = playerPos.z + targetDist;
  } else if (enemy.state.includes('windup')) {
    enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + targetDist - 50; 
  } else if (enemy.state.includes('punch')) {
    enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + 150; 
  }

  // ★敵の追従速度(プレイヤーの左右移動への対応)を機敏に
  enemyPos.x += (enemy.targetX - enemyPos.x) * 0.15; 
  enemyPos.z += (enemy.targetZ - enemyPos.z) * 0.06;

  enemy.attackTimer++;
  if (enemy.attackTimer >= enemy.attackInterval && enemy.state === 'idle') {
    enemy.attackTimer = 0; 
    enemy.nextPunch = Math.random() > 0.5 ? 'R' : 'L'; 
    enemy.state = 'windup' + enemy.nextPunch; 
    enemy.stateTimer = 45; 
    playSound('swing');
  }
}

function nextRound() {
  if(!gameRunning) return; round++;
  enemy.maxHP = 100 + round * 30; enemy.hp = enemy.maxHP;
  enemy.attackInterval = Math.max(60, 120 - round * 10);
  playerHP = Math.min(100, playerHP + 30); updateUI();
}
function showResult(isWin) {
  gameRunning = false;
  const msg = document.getElementById('msg-overlay');
  msg.textContent = isWin ? 'YOU WIN!' : 'K.O.'; msg.style.color = isWin ? '#00ff88' : '#ff0055'; msg.style.opacity = '1';
  setTimeout(() => location.reload(), 4000);
}

// ─── 描画とエフェクト更新 ───────────────────────────────────────
function spawnParticles(x, y, color, count) {
  for (let i = 0; i < count; i++) {
    const a = Math.random() * Math.PI * 2, s = 10 + Math.random() * 20;
    particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s - 5, alpha: 1, size: 5 + Math.random() * 10, color });
  }
}
function showHitNumber(text, color, scaleMult = 1) {
  hitNumbers.push({ x: W()/2 + (Math.random()-0.5)*150, y: H()/2 - 100, text, color, alpha: 1, vy: -4, scale: 0.5 * scaleMult, targetScale: 1.5 * scaleMult });
}
function updateUI() {
  document.getElementById('score').textContent = score; document.getElementById('round').textContent = round;
  document.getElementById('combo').textContent = `x${combo}`;
  document.getElementById('enemy-health-fill').style.width = (enemy.hp / enemy.maxHP * 100) + '%';
  document.getElementById('player-health-fill').style.width = playerHP + '%';
}

// ─── Three.js メッシュ管理 ──────────────────────────────────────────
const meshes = {};
function updateMesh(id, type, p1, p2, radius, mat) {
  const tx = p1.x, ty = -p1.y, tz = -p1.z; 
  
  if (type === 'joint') {
    if(!meshes[id]) {
      let geo = mat === materials.crystal ? new THREE.IcosahedronGeometry(1, 1) : new THREE.SphereGeometry(1, 16, 16);
      meshes[id] = new THREE.Mesh(geo, mat); scene.add(meshes[id]);
    }
    if(meshes[id].material !== mat) {
      meshes[id].material = mat;
      meshes[id].geometry = mat === materials.crystal ? new THREE.IcosahedronGeometry(1, 1) : new THREE.SphereGeometry(1, 16, 16);
    }
    meshes[id].position.set(tx, ty, tz);
    meshes[id].scale.set(radius, radius, radius);
  } 
  else if (type === 'bone') {
    if(!meshes[id]) {
      meshes[id] = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 1, 8), mat); scene.add(meshes[id]);
    }
    if(meshes[id].material !== mat) meshes[id].material = mat;
    
    const tx2 = p2.x, ty2 = -p2.y, tz2 = -p2.z;
    const v1 = new THREE.Vector3(tx, ty, tz), v2 = new THREE.Vector3(tx2, ty2, tz2);
    const dist = v1.distanceTo(v2);
    if (dist < 0.1) return;
    meshes[id].position.copy(v1).lerp(v2, 0.5);
    meshes[id].quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), v2.clone().sub(v1).normalize());
    meshes[id].scale.set(radius, dist, radius);
  }
}

function buildEnemyPoly() {
  npcPhase += 0.08;
  const isWindup = enemy.state.includes('windup');
  const isAttacking = enemy.state.includes('punch') || enemy.state === 'attacking';
  const isHurt = enemy.state === 'hurt';
  const isRecoil = enemy.state === 'recoil';
  const isLeftPunch = enemy.state.includes('L');
  const isBlocking = enemy.state === 'blocking';

  // ── のけぞり・揺れ計算 ──────────────────────────────────────
  const hurtProgress = isHurt ? (1.0 - enemy.stateTimer / 25) : 0;
  // 被弾時: 上体が後ろへ倒れ、膝が少し曲がる
  let leanZ = isHurt ? 280 * Math.sin(hurtProgress * Math.PI) : 0;
  let torsoTiltZ = isHurt ? 180 * Math.sin(hurtProgress * Math.PI) : 0;
  let headLeanZ = isHurt ? 220 * Math.sin(hurtProgress * Math.PI) : 0;
  let kneeBend = isHurt ? 60 * Math.sin(hurtProgress * Math.PI) : 0;

  // 揺れシェイク (非線形: 被弾直後だけ大きい)
  const shake = enemy.hurtShake;
  const sx = (Math.random()-0.5) * shake;
  const sy = (Math.random()-0.5) * shake;
  const sz = (Math.random()-0.5) * shake;

  // ── ベース座標: ボクシングスタンス ──────────────────────────
  // ベースY: ボブ(上下の揺れ)
  const bobY = Math.sin(npcPhase) * 12;
  // アタック時に一歩踏み込む
  const stepZ = isAttacking ? -80 : 0;

  const ex = enemyPos.x + sx;
  const ey = -300 + bobY + sy + (isHurt ? 30 * Math.sin(hurtProgress * Math.PI) : 0);
  const ez = enemyPos.z + leanZ + sz + stepZ;

  // ── 各部位のY座標 ─────────────────────────────────────────
  const neckY  = ey - 20;           // 首
  const chestY = ey + 80;           // 胸
  const waistY = ey + 175;          // 腰
  const hipY   = ey + 220;          // 骨盤
  const kneeY  = hipY + 200 + kneeBend;
  const footY  = kneeY + 190;
  const footStep = Math.sin(npcPhase * 0.5) * 40;

  // ── ジョイント定義 ──────────────────────────────────────────
  // 頭: 被弾時は後方に傾く
  const headZ = ez + headLeanZ;
  const headY = ey - 10; // 頭は肩より上

  // 肩: ボクサーは左肩を前に出す (オーソドックス)
  const lsZ = ez + 20;  // 左肩はやや前
  const rsZ = ez - 20;  // 右肩はやや後ろ

  let joints = {
    head:  { x: ex,        y: headY,  z: headZ },
    neck:  { x: ex,        y: neckY,  z: ez + torsoTiltZ * 0.3 },
    chest: { x: ex,        y: chestY, z: ez + torsoTiltZ * 0.15 },
    waist: { x: ex,        y: waistY, z: ez },
    hip:   { x: ex,        y: hipY,   z: ez },
    ls:    { x: ex - 110,  y: chestY - 20, z: lsZ },
    rs:    { x: ex + 110,  y: chestY - 20, z: rsZ },
    lhip:  { x: ex - 65,   y: hipY,   z: ez },
    rhip:  { x: ex + 65,   y: hipY,   z: ez },
    lknee: { x: ex - 75,   y: kneeY,  z: ez - footStep },
    rknee: { x: ex + 75,   y: kneeY,  z: ez + footStep },
    lfoot: { x: ex - 80,   y: footY,  z: ez - footStep - 15 },
    rfoot: { x: ex + 80,   y: footY,  z: ez + footStep - 15 },
  };

  // ── 腕のポーズ: 状態ごと ───────────────────────────────────
  if (isRecoil) {
    // 相殺弾かれ: 腕がバンザイ状態
    joints.le = { x: ex - 160, y: chestY - 60, z: ez };
    joints.lw = { x: ex - 270, y: chestY - 130, z: ez - 60 };
    joints.re = { x: ex + 160, y: chestY - 60, z: ez };
    joints.rw = { x: ex + 270, y: chestY - 130, z: ez - 60 };
  } else if (isBlocking) {
    // ブロック: 両腕を顔前でクロス
    joints.le = { x: ex - 80,  y: chestY - 80, z: ez - 120 };
    joints.lw = { x: ex - 40,  y: headY + 30,  z: ez - 220 };
    joints.re = { x: ex + 80,  y: chestY - 80, z: ez - 120 };
    joints.rw = { x: ex + 40,  y: headY + 30,  z: ez - 220 };
  } else if (isWindup) {
    // 振りかぶり: 打つ側の肘を後ろへ引く
    if (isLeftPunch) {
      joints.le = { x: ex - 130, y: chestY - 30, z: ez + 190 }; // 左肘を後方へ
      joints.lw = { x: ex - 90,  y: chestY - 70, z: ez + 90 };  // 左拳は引いた位置
      joints.re = { x: ex + 130, y: chestY - 50, z: ez - 30 };
      joints.rw = { x: ex + 85,  y: headY + 40,  z: ez - 180 }; // 右は警護
    } else {
      joints.le = { x: ex - 130, y: chestY - 50, z: ez - 30 };
      joints.lw = { x: ex - 85,  y: headY + 40,  z: ez - 180 }; // 左は警護
      joints.re = { x: ex + 130, y: chestY - 30, z: ez + 190 }; // 右肘を後方へ
      joints.rw = { x: ex + 90,  y: chestY - 70, z: ez + 90 };
    }
  } else if (isAttacking) {
    // パンチ繰り出し
    const punchReach = playerBlocking ? ez - 230 : ez - 560;
    const punchOffX = isLeftPunch ? -55 : 55;
    if (isLeftPunch) {
      joints.le = { x: ex - 100, y: chestY - 20, z: ez - 80 };
      joints.lw = { x: ex + punchOffX, y: headY + 20, z: punchReach };
      joints.re = { x: ex + 130, y: chestY - 50, z: ez - 30 };
      joints.rw = { x: ex + 85,  y: headY + 40,  z: ez - 180 };
    } else {
      joints.le = { x: ex - 130, y: chestY - 50, z: ez - 30 };
      joints.lw = { x: ex - 85,  y: headY + 40,  z: ez - 180 };
      joints.re = { x: ex + 100, y: chestY - 20, z: ez - 80 };
      joints.rw = { x: ex + punchOffX, y: headY + 20, z: punchReach };
    }
  } else if (isHurt) {
    // 被弾のけぞり: 腕が下がる・開く
    const openAmt = 80 * Math.sin(hurtProgress * Math.PI);
    joints.le = { x: ex - 130, y: chestY + 20,  z: ez };
    joints.lw = { x: ex - 200 - openAmt, y: chestY + 60 + openAmt * 0.5, z: ez - 80 };
    joints.re = { x: ex + 130, y: chestY + 20,  z: ez };
    joints.rw = { x: ex + 200 + openAmt, y: chestY + 60 + openAmt * 0.5, z: ez - 80 };
  } else {
    // アイドル: 左ガード高め、右ガード低め (オーソドックス)
    const lwavL = Math.sin(npcPhase) * 20;
    const lwavR = Math.cos(npcPhase) * 20;
    joints.le = { x: ex - 120, y: chestY - 60 + lwavL, z: ez - 40 };
    joints.lw = { x: ex - 80,  y: headY + 50 + lwavL,  z: ez - 180 }; // 左拳は顔ガード
    joints.re = { x: ex + 130, y: chestY - 40 + lwavR, z: ez - 30 };
    joints.rw = { x: ex + 80,  y: chestY - 10 + lwavR, z: ez - 160 }; // 右拳はやや低め
  }

  // ── 描画 ─────────────────────────────────────────────────────
  const mSkin  = currentSkinMat;
  const mGlove = materials.glove;
  const mPants = materials.pants;
  const mShoe  = materials.shoe;

  // 頭部: 球 + 頸部
  updateMesh('e_head',   'joint', joints.head, null, 68, mSkin);
  updateMesh('e_neck',   'bone',  joints.neck, { x: joints.neck.x, y: joints.neck.y - 30, z: joints.neck.z }, 32, mSkin);

  // 胴体: 肩→胸(やや太め)→腰→骨盤 と分けてテーパーを表現
  updateMesh('e_shoulder', 'bone', joints.ls, joints.rs, 55, mSkin);           // 肩横連結
  updateMesh('e_chest',    'bone',
    { x: (joints.ls.x+joints.rs.x)/2, y: joints.ls.y, z: (joints.ls.z+joints.rs.z)/2 },
    joints.waist, 68, mSkin);                                                    // 胸→腰 (広め)
  updateMesh('e_abdomen',  'bone', joints.waist, joints.hip, 55, mSkin);        // 腹→骨盤 (絞り込み)
  updateMesh('e_pelvis',   'bone', joints.lhip, joints.rhip, 50, mPants);       // 骨盤幅

  // 腕: 上腕→肘→前腕
  updateMesh('e_uarm_l',  'bone',  joints.ls,  joints.le, 36, mSkin);
  updateMesh('e_elbow_l', 'joint', joints.le,  null,      31, mSkin);
  updateMesh('e_larm_l',  'bone',  joints.le,  joints.lw, 28, mSkin);
  updateMesh('e_uarm_r',  'bone',  joints.rs,  joints.re, 36, mSkin);
  updateMesh('e_elbow_r', 'joint', joints.re,  null,      31, mSkin);
  updateMesh('e_larm_r',  'bone',  joints.re,  joints.rw, 28, mSkin);
  updateMesh('e_glove_l', 'joint', joints.lw,  null,      57, mGlove);
  updateMesh('e_glove_r', 'joint', joints.rw,  null,      57, mGlove);

  // 脚: 太もも→膝→すね→足
  updateMesh('e_uleg_l', 'bone',  joints.lhip,  joints.lknee, 42, mSkin);
  updateMesh('e_knee_l', 'joint', joints.lknee, null,          32, mSkin);
  updateMesh('e_lleg_l', 'bone',  joints.lknee, joints.lfoot,  28, mSkin);
  updateMesh('e_foot_l', 'joint', joints.lfoot, null,          30, mShoe);
  updateMesh('e_uleg_r', 'bone',  joints.rhip,  joints.rknee, 42, mSkin);
  updateMesh('e_knee_r', 'joint', joints.rknee, null,          32, mSkin);
  updateMesh('e_lleg_r', 'bone',  joints.rknee, joints.rfoot,  28, mSkin);
  updateMesh('e_foot_r', 'joint', joints.rfoot, null,          30, mShoe);
}

function drawMyArms() {
  if (playerHurtShake > 0) playerHurtShake *= 0.8;
  let sx = (Math.random()-0.5) * playerHurtShake, sy = (Math.random()-0.5) * playerHurtShake;
  
  const lShoulder = {x: playerPos.x - 160 + sx, y: -80 + sy, z: playerPos.z};
  const rShoulder = {x: playerPos.x + 160 + sx, y: -80 + sy, z: playerPos.z};
  
  const lGlove = {x: playerPos.x + armL.x + sx, y: armL.y + sy, z: playerPos.z + armL.z};
  const rGlove = {x: playerPos.x + armR.x + sx, y: armR.y + sy, z: playerPos.z + armR.z};

  const elbowDrop = 80;
  const lElbow = { x: (lShoulder.x + lGlove.x) / 2 - 30, y: (lShoulder.y + lGlove.y) / 2 + elbowDrop, z: (lShoulder.z + lGlove.z) / 2 };
  const rElbow = { x: (rShoulder.x + rGlove.x) / 2 + 30, y: (rShoulder.y + rGlove.y) / 2 + elbowDrop, z: (rShoulder.z + rGlove.z) / 2 };

  const matArm = materials.playerArm, matGlove = materials.playerGlove;
  updateMesh('p_uarm_l', 'bone', lShoulder, lElbow, 45, matArm); updateMesh('p_elbow_l', 'joint', lElbow, null, 40, matArm); updateMesh('p_larm_l', 'bone', lElbow, lGlove, 40, matArm); updateMesh('p_glove_l', 'joint', lGlove, null, 70, matGlove);
  updateMesh('p_uarm_r', 'bone', rShoulder, rElbow, 45, matArm); updateMesh('p_elbow_r', 'joint', rElbow, null, 40, matArm); updateMesh('p_larm_r', 'bone', rElbow, rGlove, 40, matArm); updateMesh('p_glove_r', 'joint', rGlove, null, 70, matGlove);
}

// ─── メインループ ─────────────────────────────────────────────
function gameLoop() {
  if (!gameRunning) return;

  viewOffsetX += (targetViewOffsetX - viewOffsetX) * 0.15;
  viewOffsetY += (targetViewOffsetY - viewOffsetY) * 0.15;

  playerStamina = Math.min(100, playerStamina + 0.3); 
  document.getElementById('player-stamina-fill').style.width = playerStamina + '%';

  detectMovement();
  if (gameMode !== 'multi') updateEnemyAI();
  if (hitStop > 0) hitStop--;

  let camBobY = Math.sin(walkBob * 2) * 15; 
  let camPunchZ = 0;
  if (playerAction === 'punchL' || playerAction === 'punchR') camPunchZ = -80; 
  
  let roll = -viewOffsetX * 0.0005;
  if (keys.a || keys.ArrowLeft) roll += 0.05;
  if (keys.d || keys.ArrowRight) roll -= 0.05;

  // ★被弾時ののけぞりカメラ角を減衰させながら適用
  camHurtPitch *= 0.8;
  
  // 被弾時はカメラ自体もノイズで揺らす
  let shakeX = (Math.random()-0.5) * playerHurtShake * 0.5;
  let shakeY = (Math.random()-0.5) * playerHurtShake * 0.5;

  camera.position.set(playerPos.x + viewOffsetX + shakeX, 250 - viewOffsetY + camBobY + shakeY, -playerPos.z + 100 + camPunchZ);
  // ★見つめるターゲットの高さを一瞬引き上げる(天井を向くのけぞり表現)
  camera.lookAt(playerPos.x + viewOffsetX * 1.5, 250 - viewOffsetY * 1.5 + camHurtPitch, -playerPos.z - 1000);
  camera.rotation.z = roll; 

  pointLight.position.set(playerPos.x, 300, -playerPos.z);

  buildEnemyPoly();
  drawMyArms();
  
  renderer.render(scene, camera);

  ctx.clearRect(0, 0, W(), H());
  ctx.save();
  if (screenShake > 0 || playerHurtShake > 0) {
    let shake = Math.max(screenShake, playerHurtShake);
    ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
    if(screenShake > 0) screenShake *= 0.8;
  }

  // ── クロマティックアバレーション (被弾時の色収差) ───────────
  if (hitChromatic > 0) {
    const ca = hitChromatic;
    ctx.save();
    ctx.globalCompositeOperation = 'screen';
    ctx.globalAlpha = 0.15;
    ctx.drawImage(renderer.domElement, -ca, 0, W(), H());   // 赤チャンネル左ずれ
    ctx.globalAlpha = 0.10;
    ctx.drawImage(renderer.domElement,  ca, 0, W(), H());   // 青チャンネル右ずれ
    ctx.restore();
    hitChromatic = Math.max(0, hitChromatic - 1.5);
  }

  if (flashTimer > 0) {
    ctx.fillStyle = `rgba(255, 255, 255, ${flashTimer*0.2})`; ctx.fillRect(-100, -100, W()+200, H()+200);
    flashTimer--;
  }

  for (let i = particles.length - 1; i >= 0; i--) {
    let p = particles[i];
    p.x += p.vx; p.y += p.vy; p.vy += 0.8; p.alpha -= 0.03;
    if (p.alpha <= 0) { particles.splice(i, 1); continue; }
    ctx.globalAlpha = p.alpha;
    ctx.fillStyle = p.color;
    // 大きなパーティクルは楕円にして汗・破片っぽく
    ctx.beginPath();
    if (p.size > 10) {
      ctx.ellipse(p.x, p.y, p.size * 0.6, p.size, Math.atan2(p.vy, p.vx), 0, Math.PI * 2);
    } else {
      ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    }
    ctx.fill();
  }
  ctx.globalAlpha = 1.0;

  for (let i = hitNumbers.length - 1; i >= 0; i--) {
    let h = hitNumbers[i];
    h.y += h.vy; h.vy += 0.15; h.alpha -= 0.02; h.scale += (h.targetScale - h.scale) * 0.2;
    if (h.alpha <= 0) { hitNumbers.splice(i, 1); continue; }
    ctx.save(); ctx.globalAlpha = h.alpha; ctx.translate(h.x, h.y); ctx.scale(h.scale, h.scale);
    ctx.fillStyle = h.color; ctx.font = 'bold 40px Arial'; ctx.textAlign = 'center';
    ctx.shadowColor = 'black'; ctx.shadowBlur = 10; ctx.fillText(h.text, 0, 0); ctx.restore();
  }

  if (playerBlocking) {
    ctx.fillStyle = 'rgba(0, 243, 255, 0.5)'; ctx.font = 'bold 40px Arial'; ctx.textAlign = 'center'; ctx.fillText('GUARD', W()/2, H() - 200);
  }

  ctx.restore();
  requestAnimationFrame(gameLoop);
}
</script>
</body>
</html>


《似た感じのゲーム》


「The Thrill of the Fight」


いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
カメラで対戦する「MotionBoxing」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1