カメラで対戦する「MotionBoxing」


【更新履歴】

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


画像

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

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


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


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

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Motion Boxing 1.5</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; letter-spacing: 2px; }
    
    .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: 80px; right: 20px; width: auto; padding: 8px 16px; z-index: 30; pointer-events: auto; font-size: 0.95rem; }
    
    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; }
    /* ラウンドブレイクオーバーレイ */
    #round-break-overlay {
      position: absolute; top: 0; left: 0; width: 100%; height: 100%;
      background: rgba(0,0,0,0.75); display: none; flex-direction: column;
      align-items: center; justify-content: center; z-index: 50; pointer-events: none;
    }
    #round-break-overlay .rb-round {
      font-size: 2rem; color: #aaa; letter-spacing: 6px; text-transform: uppercase; margin-bottom: 10px;
    }
    #round-break-overlay .rb-title {
      font-size: 7rem; font-weight: 900; color: #fff; font-style: italic;
      text-shadow: 0 0 40px #ff0055, 0 0 80px #ff0055; line-height: 1;
    }
    #round-break-overlay .rb-sub {
      font-size: 1.4rem; color: #ffd700; margin-top: 18px; letter-spacing: 4px;
    }
    /* 部位ダメージUIバー */
    #limb-status {
      position: absolute; bottom: 20px; right: 20px; z-index: 10;
      pointer-events: none; text-align: right;
    }
    .limb-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; justify-content: flex-end; }
    .limb-label { font-size: 0.7rem; color: #aaa; width: 60px; text-align: right; }
    .limb-bar-bg { width: 80px; height: 8px; background: rgba(30,30,30,0.8); border-radius: 3px; border: 1px solid #333; overflow: hidden; }
    .limb-bar-fill { height: 100%; border-radius: 2px; transition: width 0.2s; }

    /* チャットログ用CSS */
    #chat-log {
      position: absolute; bottom: 20px; left: 20px; width: 320px; max-height: 250px;
      overflow-y: auto; z-index: 10; pointer-events: none; display: flex;
      flex-direction: column; gap: 6px;
    }
    .chat-msg {
      background: rgba(10, 10, 10, 0.7); border-left: 3px solid #00f3ff;
      padding: 6px 12px; border-radius: 4px; font-size: 0.95rem; color: #fff;
      text-shadow: 1px 1px 2px #000; animation: fadeOut 8s forwards; word-break: break-all;
    }
    .chat-msg.mine { border-left-color: #00ff88; text-align: right; }
    @keyframes fadeOut { 0% { opacity: 1; } 80% { opacity: 1; } 100% { 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 / 3</div>
  </div>
  <div class="stat-box" id="time-box">
    <div class="label">TIME</div>
    <div class="value" id="timer">3:00</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>
  <audio id="remote-audio" autoplay style="display:none;"></audio>
  
  <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 id="chat-log"></div>

  <div id="round-break-overlay">
    <div class="rb-round" id="rb-round-label">ROUND 1</div>
    <div class="rb-title" id="rb-title">FIGHT!</div>
    <div class="rb-sub" id="rb-sub"></div>
  </div>
  
  <div id="limb-status">
    <div class="limb-row">
      <span class="limb-label">L.ARM</span>
      <div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-larm" style="width:100%;background:#00f3ff;"></div></div>
    </div>
    <div class="limb-row">
      <span class="limb-label">R.ARM</span>
      <div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-rarm" style="width:100%;background:#00f3ff;"></div></div>
    </div>
    <div class="limb-row">
      <span class="limb-label">BODY</span>
      <div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-body" style="width:100%;background:#ffd700;"></div></div>
    </div>
    <div class="limb-row">
      <span class="limb-label">LEGS</span>
      <div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-legs" style="width:100%;background:#ffd700;"></div></div>
    </div>
  </div>
</div>

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

<div id="overlay">
  <h1>MotionBoxing</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')">👤 1 VS 1 (対NPC)</button>
    <button class="btn btn-red" onclick="showMenu('menu-multi')">🌐 マルチプレイ通信対戦</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>操作方法 (1 VS 1)</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>🌐 マルチプレイ 通信対戦</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>💬 コミュニケーション (Chat)</summary>
                <ul>
                    <li>
                        <label style="font-size:0.9rem;">送信モード (マルチプレイ用):</label><br>
                        <select id="comm-mode" onchange="changeCommMode(this.value)" style="width:100%; padding:8px; background:#111; color:#fff; border:1px solid #00f3ff; border-radius:4px; margin-top:5px; margin-bottom:10px;">
                            <option value="none">送信しない (Mute)</option>
                            <option value="text">🗣️ 音声認識テキスト (Speech-to-Text)</option>
                            <option value="voice">🎙️ ボイスチャット (Voice)</option>
                        </select>
                    </li>
                </ul>
            </details>
        </li>
        <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.3;
    const oscGain = audioCtx.createGain(); oscGain.gain.value = 0.5;
    lfo.connect(lfoGain);
    lfoGain.connect(oscGain.gain);
    osc.connect(oscGain); oscGain.connect(delay); oscGain.connect(bgmGain);
    osc.start(); lfo.start();
  });
}

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, 0x003344);
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 }),
  playerBody: new THREE.MeshStandardMaterial({ color: 0x0088ff, roughness: 0.5 }),
  playerArm: new THREE.MeshStandardMaterial({ color: 0x00b359, roughness: 0.5 }),
  playerGlove: new THREE.MeshStandardMaterial({ color: 0x00f3ff, roughness: 0.2, emissive: 0x00f3ff, emissiveIntensity: 0.2 }),
  playerPants: new THREE.MeshStandardMaterial({ color: 0x111133, roughness: 0.9 }),
  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;
const TOTAL_ROUNDS = 3;         
const ROUND_DURATION = 180;     
let roundTimeLeft = ROUND_DURATION; 
let roundTimerInterval = null;  
let roundInProgress = false;    
let comboTimer = null;
let playerHP = 100, playerStamina = 100;

let enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
let playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
let staminaDebuff = 1.0;

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 = -60;
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;
let mouseEventsRegistered = 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;
    requestAnimationFrame(gameLoop);
    if (commMode === 'text' && speechRecognition) {
      try { speechRecognition.start(); } catch(e) {}
    }
  } else {
    showMenu('menu-main');
  }
}

// ─── コミュニケーションシステム (STT / Voice) ────────────────────────
let commMode = 'none'; 
let localAudioStream = null;
let peerCall = null;
let speechRecognition = null;
let chatBubbles = []; 

function initCommSystem() {
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  if (SpeechRecognition) {
    speechRecognition = new SpeechRecognition();
    speechRecognition.continuous = true;
    speechRecognition.interimResults = false;
    speechRecognition.lang = 'ja-JP';
    
    speechRecognition.onresult = (e) => {
      if (commMode !== 'text') return;
      const text = e.results[e.results.length - 1][0].transcript.trim();
      if (text) {
        displayChat('You', text, true);
        if (conn && conn.open) {
          conn.send({ type: 'chat', text: text });
        }
      }
    };
    
    speechRecognition.onerror = (e) => console.log('Speech Recognition Error:', e.error);
    speechRecognition.onend = () => {
      if (commMode === 'text' && gameRunning) {
        try { speechRecognition.start(); } catch(err) {}
      }
    };
  }
}

async function changeCommMode(mode) {
  commMode = mode;
  if (speechRecognition) { try { speechRecognition.stop(); } catch(e){} }
  if (localAudioStream) { localAudioStream.getTracks().forEach(t => t.stop()); localAudioStream = null; }
  if (peerCall) { peerCall.close(); peerCall = null; }

  if (mode === 'text' && speechRecognition) {
    try { speechRecognition.start(); } catch(e){}
  } else if (mode === 'voice') {
    try {
      localAudioStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
      if (conn && conn.open) makeVoiceCall(conn.peer);
    } catch(err) {
      console.error('マイクの取得に失敗しました', err);
      alert('マイクへのアクセスが拒否されました。設定や権限を確認してください。');
      document.getElementById('comm-mode').value = 'none';
      commMode = 'none';
    }
  }
}

function makeVoiceCall(targetId) {
   if (!localAudioStream || !peer) return;
   if (peerCall) peerCall.close();
   peerCall = peer.call(targetId, localAudioStream);
   setupCallEvents(peerCall);
}

function setupCallEvents(call) {
   call.on('stream', remoteStream => {
      const audioEl = document.getElementById('remote-audio');
      audioEl.srcObject = remoteStream;
      audioEl.play().catch(e => console.error('Audio play error', e));
   });
   call.on('close', () => { document.getElementById('remote-audio').srcObject = null; });
}

function displayChat(sender, text, isMine) {
  const log = document.getElementById('chat-log');
  const msgDiv = document.createElement('div');
  msgDiv.className = 'chat-msg' + (isMine ? ' mine' : '');
  msgDiv.textContent = `${sender}: ${text}`;
  log.appendChild(msgDiv);
  log.scrollTop = log.scrollHeight;
  setTimeout(() => { if(msgDiv.parentNode) msgDiv.parentNode.removeChild(msgDiv); }, 8000);

  chatBubbles.push({
     text: text,
     x: W()/2,
     y: isMine ? H() - 120 : H()/2 - 150, 
     alpha: 1, vy: -0.8, isMine: isMine, life: 300 
  });
}
initCommSystem();

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

function initPeer() {
  try {
    if (peer && !peer.destroyed) peer.destroy();
    peer = new Peer(undefined, { debug: 0 });
    peer.on('open', id => { document.getElementById('my-peer-id').textContent = id; });
    peer.on('connection', c => {
      conn = c;
      c.on('open', () => {
        setupP2PConnection(c);
        initP2PGame(controlMode || 'mouse');
        if (commMode === 'voice' && localAudioStream) makeVoiceCall(c.peer);
      });
    });
    peer.on('call', call => {
      peerCall = call;
      call.answer(localAudioStream || undefined);
      setupCallEvents(call);
    });
    peer.on('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);
    if (commMode === 'voice' && localAudioStream) makeVoiceCall(targetId);
  });
  conn.on('error', err => { alert('接続エラー: ' + err.message); });
}

function setupP2PConnection(c) {
  c.on('data', data => {
    if (!data || !data.type) return;
    if (data.type === 'sync') {
      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.targetPart || 'body');
      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); }
    else if (data.type === 'chat') { displayChat('Enemy', data.text, false); }
  });
  c.on('close', () => {
    if (gameRunning) { gameRunning = false; alert('相手との接続が切れました。'); location.reload(); }
  });
}

function sendP2PData() {
  if (!conn || !conn.open) return;
  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);
  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';
  document.getElementById('time-box').style.display = gameMode === 'multi' ? 'none' : 'block';

  score = 0; round = 1; combo = 0;
  playerHP = 100; playerStamina = 100; staminaDebuff = 1.0; camHurtPitch = 0;
  enemy.maxHP = 100; enemy.hp = 100; enemy.attackInterval = 110;
  enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  playerPos = { x: 0, z: 0 }; targetPlayerPos = { x: 0, z: 0 }; enemyPos = { x: 0, z: 600 };
  
  updateUI();
  gameRunning = true;

  if (controlMode === 'mouse') {
    if (!mouseEventsRegistered) {
      document.addEventListener('mousemove', onMouseMove);
      document.addEventListener('mousedown', onMouseDown);
      document.addEventListener('mouseup', onMouseUp);
      document.addEventListener('contextmenu', e => e.preventDefault());
      mouseEventsRegistered = true;
    }
  } else if (controlMode === 'camera') {
    initCamera();
  }

  showRoundBreak(1, false, () => {
    startRoundTimer();
    requestAnimationFrame(gameLoop);
    if (commMode === 'text' && speechRecognition) { try { speechRecognition.start(); } catch(e) {} }
  });
}

function showRoundBreak(roundNum, isBreak, callback) {
  roundInProgress = false;
  const overlay = document.getElementById('round-break-overlay');
  const labelEl = document.getElementById('rb-round-label');
  const titleEl = document.getElementById('rb-title');
  const subEl   = document.getElementById('rb-sub');

  overlay.style.display = 'flex';

  if (isBreak) {
    labelEl.textContent = `END OF ROUND ${roundNum - 1}`;
    titleEl.textContent  = 'BREAK';
    titleEl.style.color  = '#00f3ff';
    titleEl.style.textShadow = '0 0 40px #00f3ff, 0 0 80px #00f3ff';
    subEl.textContent   = 'Both fighters recover...';
    setTimeout(() => {
      labelEl.textContent = `ROUND ${roundNum}`;
      titleEl.textContent  = 'FIGHT!';
      titleEl.style.color  = '#fff';
      titleEl.style.textShadow = '0 0 40px #ff0055, 0 0 80px #ff0055';
      subEl.textContent   = '';
      playSound('parry');
      setTimeout(() => {
        overlay.style.display = 'none';
        roundInProgress = true;
        startRoundTimer();
        if (callback) callback();
      }, 1500);
    }, 2000);
  } else {
    labelEl.textContent = `ROUND ${roundNum}`;
    titleEl.textContent  = 'FIGHT!';
    titleEl.style.color  = '#fff';
    titleEl.style.textShadow = '0 0 40px #ff0055, 0 0 80px #ff0055';
    subEl.textContent   = '';
    playSound('parry');
    setTimeout(() => {
      overlay.style.display = 'none';
      roundInProgress = true;
      if (callback) callback();
    }, 1800);
  }
}

function startRoundTimer() {
  if (roundTimerInterval) clearInterval(roundTimerInterval);
  roundTimeLeft = ROUND_DURATION;
  updateTimerUI();
  roundTimerInterval = setInterval(() => {
    if (!gameRunning || !roundInProgress) return;
    roundTimeLeft--;
    updateTimerUI();
    if (roundTimeLeft <= 0) {
      clearInterval(roundTimerInterval);
      roundTimerInterval = null;
      onRoundTimeUp();
    }
  }, 1000);
}

function updateTimerUI() {
  const m = Math.floor(roundTimeLeft / 60);
  const s = roundTimeLeft % 60;
  document.getElementById('timer').textContent = `${m}:${s.toString().padStart(2, '0')}`;
  document.getElementById('timer').style.color = roundTimeLeft <= 30 ? '#ff0055' : '#fff';
}

function onRoundTimeUp() {
  if (!gameRunning) return;
  roundInProgress = false;
  const playerPercent = playerHP / 100;
  const enemyPercent  = enemy.hp / enemy.maxHP;
  if (round >= TOTAL_ROUNDS) {
    if (playerPercent > enemyPercent) showResult(true, '判定勝ち! WIN BY DECISION');
    else if (playerPercent < enemyPercent) showResult(false, '判定負け... LOSS BY DECISION');
    else showResult(true, 'DRAW!');
  } else advanceRound();
}

function onEnemyKO() {
  if (!gameRunning) return;
  roundInProgress = false;
  if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
  const msg = document.getElementById('msg-overlay');
  msg.textContent = 'K.O.!!';
  msg.style.color = '#ffd700'; msg.style.textShadow = '0 0 30px #ffd700'; msg.style.opacity = '1';
  playSound('clean'); screenShake = 60;

  if (round >= TOTAL_ROUNDS) setTimeout(() => { msg.style.opacity = '0'; showResult(true, 'K.O. WIN!!'); }, 2000);
  else setTimeout(() => { msg.style.opacity = '0'; advanceRound(); }, 2200);
}

function advanceRound() {
  if (!gameRunning) return;
  round++;
  playerHP = 100; playerStamina = 100; staminaDebuff = 1.0;
  playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  enemy.maxHP = 100 + (round - 1) * 30; enemy.hp = enemy.maxHP;
  enemy.attackInterval = Math.max(70, 110 - (round - 1) * 15); 
  enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  combo = 0; updateUI(); updateLimbUI();
  document.getElementById('round').textContent = `${round} / ${TOTAL_ROUNDS}`;
  showRoundBreak(round, true, () => {
    requestAnimationFrame(gameLoop);
  });
}

let latestPoseData = null; let poseInstance = null; let cameraInstance = null;
let prevWristL = null, prevWristR = null;
let camPunchCooldownL = 0, camPunchCooldownR = 0; let camGuardFrames = 0;

function initCamera() {
  const videoEl = document.getElementById('video');
  videoEl.style.display = 'none';
  if (!window.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 => alert('カメラへのアクセスが拒否されました。\nマウスモードに切り替えてください。'));
  } else {
    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 => alert('カメラへのアクセスが拒否されました。マウスモードをお使いください。'));
  }
}

function onPoseResults(results) {
  if (!gameRunning || !results.poseLandmarks) return;
  const lm = results.poseLandmarks;
  const ls = lm[11], rs = lm[12], lw = lm[15], rw = lm[16], nose = lm[0];
  if (!ls || !rs || !lw || !rw || ls.visibility < 0.4 || rs.visibility < 0.4) return;

  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;
  }

  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, velLY = (lw.y - prevWristL.y) / refWidth;
    const velRX = (rw.x - prevWristR.x) / refWidth, velRY = (rw.y - prevWristR.y) / refWidth;
    const speedL = Math.sqrt(velLX*velLX + velLY*velLY), speedR = Math.sqrt(velRX*velRX + velRY*velRY);
    
    if (camPunchCooldownL <= 0 && speedL > 0.18 && lw.visibility > 0.5 && playerAction === 'idle') {
      if (velLX > 0.1 && 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; 
      }
    }
    if (camPunchCooldownR <= 0 && speedR > 0.18 && rw.visibility > 0.5 && playerAction === 'idle') {
      if (velRX < -0.1 && 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;
      }
    }
  }

  if (lw.y < ls.y - 0.05 && lw.visibility > 0.4 && rw.y < rs.y - 0.05 && rw.visibility > 0.4 && playerAction === 'idle') {
    camGuardFrames++; if (camGuardFrames > 3) { mouseL = true; mouseR = true; updatePlayerAction(); }
  } else {
    if (camGuardFrames > 3) { 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)) * 1400; 
  targetPlayerPos.z = ((e.clientY - H()/2) / (H()/2)) * 800 + 200; 
}
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 * staminaDebuff); 
        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 * staminaDebuff);
        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;
  const legPenalty = 1.0 - (playerLimbDmg.legs / 250);
  if (controlMode === 'mouse') {
    let movedByKey = false;
    const speed = Math.round(35 * legPenalty * staminaDebuff); // 移動エリア拡大に伴い速度も向上
    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(-400, Math.min(1200, playerPos.z));

  // カメラモードでの腕の追従のみ残す
  if (controlMode === 'camera') {
    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 getTargetPart(aimYVal) {
  if (aimYVal < -0.15) return 'head';
  if (aimYVal < 0.25)  return 'body';
  if (aimYVal < 0.55)  return 'body'; 
  return 'legs';
}

const PART_CONFIG = {
  head:  { multi: 2.0, label: 'HEAD',  color: '#ff0055' },
  body:  { multi: 1.2, label: 'BODY',  color: '#ff8800' },
  legs:  { multi: 0.8, label: 'LEGS',  color: '#ffcc00' },
  larm:  { multi: 0.7, label: 'L.ARM', color: '#00f3ff' },
  rarm:  { multi: 0.7, label: 'R.ARM', color: '#00f3ff' },
};

function evaluateHit(attackerPos, defenderPos, isDefenderBlocking, targetPart) {
  const dx = Math.abs(attackerPos.x - defenderPos.x);
  const dz = attackerPos.z - defenderPos.z;

  if (Math.abs(dz) > 480) return { hit: false, reason: 'MISS (TOO FAR)' };
  if (dx > 220)            return { hit: false, reason: 'MISS (DODGED)' };
  if (isDefenderBlocking)  return { hit: false, reason: 'GUARD!' };

  const isClean = (Math.abs(dz) < 280 && dx < 110);
  const cfg = PART_CONFIG[targetPart] || PART_CONFIG.body;
  const multi = cfg.multi * (isClean ? 1.4 : 1.0);
  return { hit: true, type: isClean ? `${cfg.label} CLEAN HIT` : `${cfg.label} HIT`, multi, part: targetPart, isClean };
}

function tryPunch(side, speed) {
  const targetPart = getTargetPart(aimY);
  if (side === 'left') aiMemory.leftStrikes++; else aiMemory.rightStrikes++;
  saveMemory();

  const effectiveSpeed = speed * staminaDebuff;

  if (gameMode === 'multi') {
    if (conn && conn.open) conn.send({ type: 'punch', side: side, speed: effectiveSpeed, targetPart: targetPart });
    return;
  }

  const isEnemyAttacking = enemy.state.includes('punch');
  if (isEnemyAttacking) {
    const dz = Math.abs(playerPos.z - enemyPos.z);
    if (dz < 480) {
      playSound('parry'); spawnParticles(W() / 2, H() / 2, '#00f3ff', 30); showHitNumber('PARRY!!', '#00f3ff', 1.8);
      screenShake = 25; hitStop = 12; enemy.state = 'recoil'; enemy.stateTimer = 28;
      playerAction = 'recoil'; clearTimeout(playerActionTimer);
      setTimeout(() => { if (playerAction === 'recoil') playerAction = 'idle'; }, 420);
      return;
    }
  }

  const result = evaluateHit(playerPos, enemyPos, enemy.state === 'blocking', targetPart);
  if (!result.hit) {
    if (result.reason === 'GUARD!') playSound('guard');
    showHitNumber(result.reason, '#aaa'); combo = 0; updateUI();
  } else {
    const baseDmg = Math.max(6, Math.floor(effectiveSpeed * 0.04));
    const dmg = Math.floor(baseDmg * result.multi);
    enemy.hp = Math.max(0, enemy.hp - dmg);
    applyLimbDamage('enemy', result.part, dmg);
    hitEnemyVisual(dmg, side === 'right' ? 50 : -50, result.part === 'head' ? -150 : -50, result.isClean, result.part);
    if (enemy.hp <= 0) onEnemyKO();
  }
}

function applyLimbDamage(target, part, dmg) {
  if (target === 'enemy') {
    enemyLimbDmg[part] = Math.min(100, (enemyLimbDmg[part] || 0) + dmg * 0.8);
  } else {
    playerLimbDmg[part] = Math.min(100, (playerLimbDmg[part] || 0) + dmg * 0.8);
    updateLimbUI();
  }
}

function updateLimbUI() {
  const set = (id, val) => {
    const el = document.getElementById(id);
    if (!el) return;
    const pct = val, remaining = 100 - pct;
    el.style.width = remaining + '%';
    el.style.background = remaining > 60 ? '#00f3ff' : remaining > 30 ? '#ffaa00' : '#ff0055';
  };
  set('limb-larm', playerLimbDmg.larm); set('limb-rarm', playerLimbDmg.rarm); set('limb-body', playerLimbDmg.body); set('limb-legs', playerLimbDmg.legs);
}

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

  const cfg = PART_CONFIG[part] || PART_CONFIG.body;
  const color = isCleanHit ? '#ffd700' : cfg.color;
  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);
    if (enemy.hp / enemy.maxHP < 0.4) spawnParticlesDirected(W()/2 + ox + 20, H()/2 + oy, '#ff0000', 12, -0.8, 0.6);
  }
  showHitNumber(isCleanHit ? `${cfg.label} CLEAN! ${dmg}` : `${cfg.label} ${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();
}

function spawnParticlesDirected(x, y, color, count, vyBias, spread) {
  for (let i = 0; i < count; i++) {
    const a = Math.random() * Math.PI * 2, 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, hitPart) {
  hitPart = hitPart || 'body'; playSound('damage');
  playerHP = Math.max(0, playerHP - dmg); combo = 0;
  applyLimbDamage('player', hitPart, dmg);

  if (hitPart === 'head' && dmg >= 12) { camHurtPitch = isCleanHit ? 1400 : 900; playerHurtShake = isCleanHit ? 130 : 70; } 
  else { camHurtPitch = isCleanHit ? 1100 : 650; playerHurtShake = isCleanHit ? 110  : 55; }

  updateUI(); screenShake = isCleanHit ? 90 : 45; 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); hitChromatic = isCleanHit ? 12 : 6;

  if (playerHP <= 0) {
    if (gameMode === 'multi' && conn && conn.open) conn.send({ type: 'gameover' });
    roundInProgress = false; if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
    showResult(false, 'K.O.');
  }
}

function updateEnemyAI() {
  if (!roundInProgress) return; 
  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, 'body');
      if (!result.hit) {
        if (result.reason === 'GUARD!') { playSound('guard'); spawnParticles(W() / 2 - 80, H() / 2 + 50, '#00f3ff', 14); spawnParticles(W() / 2 + 80, H() / 2 + 50, '#00f3ff', 14); screenShake = 10; }
        showHitNumber(result.reason, '#aaa');
      } else { takeDamage(Math.floor((8 + round * 2) * result.multi), result.isClean, Math.random() < 0.3 ? 'head' : 'body'); }
    }
    if (enemy.stateTimer <= 0) enemy.state = 'idle';
  }

  if (enemy.hurtShake > 0) enemy.hurtShake *= 0.78;
  let totalStrikes = aiMemory.leftStrikes + aiMemory.rightStrikes || 1;
  let leftRatio = aiMemory.leftStrikes / totalStrikes; let dodgeOffset = (leftRatio - 0.5) * 320;

  const targetDist = 440;
  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 - 40; } 
  else if (enemy.state.includes('punch')) { enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + 160; }

  enemyPos.x += (enemy.targetX - enemyPos.x) * 0.14; 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 = 48; playSound('swing');
  }
}

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} / ${TOTAL_ROUNDS}`;
  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 + '%';
}
function showResult(isWin, label) {
  gameRunning = false; roundInProgress = false; if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
  if (mouseEventsRegistered) {
    document.removeEventListener('mousemove', onMouseMove);
    document.removeEventListener('mousedown', onMouseDown);
    document.removeEventListener('mouseup', onMouseUp);
    mouseEventsRegistered = false;
  }
  const msg = document.getElementById('msg-overlay');
  msg.textContent = label || (isWin ? 'YOU WIN!' : 'K.O.');
  msg.style.color = isWin ? '#00ff88' : '#ff0055'; msg.style.textShadow = isWin ? '0 0 30px #00ff88' : '0 0 30px #ff0055'; msg.style.opacity = '1';
  setTimeout(() => location.reload(), 5000);
}

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'), isAttacking = enemy.state.includes('punch') || enemy.state === 'attacking';
  const isHurt = enemy.state === 'hurt', isRecoil = enemy.state === 'recoil', isLeftPunch = enemy.state.includes('L'), isBlocking = enemy.state === 'blocking';
  const hurtProgress = isHurt ? Math.sin((1.0 - enemy.stateTimer / 30) * Math.PI) : 0;
  const archAmount = isHurt ? 320 * hurtProgress : 0, headExtra = isHurt ? 160 * hurtProgress : 0, kneeBend = isHurt ? 80 * hurtProgress : 0, waistRiseZ = isHurt ? 120 * hurtProgress : 0; 
  const sx = (Math.random() - 0.5) * enemy.hurtShake, sy = (Math.random() - 0.5) * enemy.hurtShake, sz = (Math.random() - 0.5) * enemy.hurtShake;
  const bobY = Math.sin(npcPhase) * 12, stepZ = isAttacking ? -80 : 0; 
  const rootX = enemyPos.x + sx, rootZ = enemyPos.z + sz + stepZ;
  
  const footY = 600 + bobY, kneeY = 420 + bobY + kneeBend, hipY = 220 + bobY, waistY = 130 + bobY + (isHurt ? 10 * hurtProgress : 0);
  const chestY = 30 + bobY + sy + (isHurt ? -20 * hurtProgress : 0), shoulderY = -10 + bobY + sy, neckBaseY = -50 + bobY + sy, neckTopY = -100 + bobY + sy, headCenterY = -170 + bobY + sy + (isHurt ? -10 * hurtProgress : 0);
  const waistZ = rootZ + waistRiseZ * 0.3, chestZ = rootZ + archAmount * 0.6, shoulderZ = rootZ + archAmount * 0.85, neckBaseZ = rootZ + archAmount * 0.95, neckTopZ = rootZ + archAmount + headExtra * 0.3, headZ = rootZ + archAmount + headExtra; 
  const footStep = Math.sin(npcPhase * 0.5) * 40;

  let joints = {
    head: { x: rootX, y: headCenterY, z: headZ }, neckTop: { x: rootX, y: neckTopY, z: neckTopZ }, neckBase: { x: rootX, y: neckBaseY, z: neckBaseZ },
    ls: { x: rootX - 115, y: shoulderY, z: shoulderZ + 20 }, rs: { x: rootX + 115, y: shoulderY, z: shoulderZ - 20 }, 
    chest: { x: rootX, y: chestY, z: chestZ }, waist: { x: rootX, y: waistY, z: waistZ },
    hip: { x: rootX, y: hipY, z: rootZ }, lhip: { x: rootX - 65, y: hipY, z: rootZ }, rhip: { x: rootX + 65, y: hipY, z: rootZ },
    lknee: { x: rootX - 75, y: kneeY, z: rootZ - footStep + kneeBend * 1.2 }, rknee: { x: rootX + 75, y: kneeY, z: rootZ + footStep + kneeBend * 0.8 },
    lfoot: { x: rootX - 80, y: footY, z: rootZ - footStep - 15 }, rfoot: { x: rootX + 80, y: footY, z: rootZ + footStep - 15 },
  };
  const lsZ2 = joints.ls.z, rsZ2 = joints.rs.z;

  if (isRecoil) { joints.le={x:rootX-170,y:shoulderY-40,z:shoulderZ+20}; joints.lw={x:rootX-290,y:shoulderY-130,z:shoulderZ-50}; joints.re={x:rootX+170,y:shoulderY-40,z:shoulderZ+20}; joints.rw={x:rootX+290,y:shoulderY-130,z:shoulderZ-50}; } 
  else if (isBlocking) { joints.le={x:rootX-85,y:shoulderY+30,z:shoulderZ-100}; joints.lw={x:rootX-45,y:headCenterY+40,z:headZ-180}; joints.re={x:rootX+85,y:shoulderY+30,z:shoulderZ-100}; joints.rw={x:rootX+45,y:headCenterY+40,z:headZ-180}; } 
  else if (isWindup) {
    if (isLeftPunch) { joints.le={x:rootX-160,y:shoulderY+60,z:shoulderZ+260}; joints.lw={x:rootX-110,y:shoulderY+20,z:shoulderZ+140}; joints.re={x:rootX+130,y:shoulderY+10,z:rsZ2-30}; joints.rw={x:rootX+85,y:headCenterY+50,z:headZ-160}; } 
    else { joints.le={x:rootX-130,y:shoulderY+10,z:lsZ2-30}; joints.lw={x:rootX-85,y:headCenterY+50,z:headZ-160}; joints.re={x:rootX+160,y:shoulderY+60,z:shoulderZ+260}; joints.rw={x:rootX+110,y:shoulderY+20,z:shoulderZ+140}; }
  } else if (isAttacking) {
    const punchReach = playerBlocking ? rootZ - 200 : rootZ - 580; const punchOffX = isLeftPunch ? -60 : 60;
    if (isLeftPunch) { joints.le={x:rootX-105,y:shoulderY+20,z:shoulderZ-60}; joints.lw={x:rootX+punchOffX,y:headCenterY+25,z:punchReach}; joints.re={x:rootX+130,y:shoulderY+10,z:rsZ2-30}; joints.rw={x:rootX+85,y:headCenterY+50,z:headZ-160}; } 
    else { joints.le={x:rootX-130,y:shoulderY+10,z:lsZ2-30}; joints.lw={x:rootX-85,y:headCenterY+50,z:headZ-160}; joints.re={x:rootX+105,y:shoulderY+20,z:shoulderZ-60}; joints.rw={x:rootX+punchOffX,y:headCenterY+25,z:punchReach}; }
  } else if (isHurt) {
    const openAmt = 100 * hurtProgress;
    joints.le={x:rootX-140,y:chestY+40,z:chestZ+50}; joints.lw={x:rootX-220-openAmt,y:chestY+100+openAmt*0.4,z:chestZ+80}; joints.re={x:rootX+140,y:chestY+40,z:chestZ+50}; joints.rw={x:rootX+220+openAmt,y:chestY+100+openAmt*0.4,z:chestZ+80};
  } else {
    const wavL = Math.sin(npcPhase) * 18, wavR = Math.cos(npcPhase) * 18;
    joints.le={x:rootX-125,y:shoulderY+30+wavL,z:lsZ2-30}; joints.lw={x:rootX-80,y:headCenterY+55+wavL,z:headZ-170}; joints.re={x:rootX+135,y:shoulderY+40+wavR,z:rsZ2-20}; joints.rw={x:rootX+80,y:shoulderY+10+wavR,z:rsZ2-150}; 
  }

  const mSkin = currentSkinMat, mGlove = materials.glove, mPants = materials.pants, mShoe = materials.shoe;
  updateMesh('e_head', 'joint', joints.head, null, 68, mSkin);
  updateMesh('e_neck', 'bone', joints.neckTop, joints.neckBase, 30, mSkin);
  updateMesh('e_necktorso', 'bone', joints.neckBase, { x:(joints.ls.x+joints.rs.x)/2, y:joints.ls.y, z:(joints.ls.z+joints.rs.z)/2 }, 38, mSkin);
  updateMesh('e_shoulder', 'bone', joints.ls, joints.rs, 52, 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, 70, mSkin);
  updateMesh('e_abdomen', 'bone', joints.waist, joints.hip, 56, 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 buildPlayerPoly() {
  let pPhase = (Date.now() * 0.005) % (Math.PI * 2); 
  if (playerAction !== 'idle') pPhase = 0;

  const isWindup    = playerAction.includes('windup');
  const isAttacking = playerAction.includes('punch');
  const isLeftPunch = playerAction.includes('L');
  const isBlocking  = playerBlocking;
  const isRecoil    = playerAction === 'recoil';
  const isHurt      = playerHurtShake > 40;

  const sx = (Math.random() - 0.5) * playerHurtShake;
  const sy = (Math.random() - 0.5) * playerHurtShake;
  const sz = (Math.random() - 0.5) * playerHurtShake;

  const bobY = Math.sin(pPhase) * 8;
  const stepZ = isAttacking ? 80 : 0; // パンチ時は大きく踏み込む

  const rootX = playerPos.x + sx;
  const rootZ = playerPos.z + sz + stepZ;

  const footY       = 600 + bobY;          
  const kneeY       = 420 + bobY;
  const hipY        = 220 + bobY;          
  const waistY      = 130 + bobY;
  const chestY      =  30 + bobY + sy;
  const shoulderY   = -10 + bobY + sy;
  const neckBaseY   = -50 + bobY + sy;     
  const neckTopY    = -100 + bobY + sy;     
  const headCenterY = -170 + bobY + sy;

  const arch = isHurt ? -80 : 0; // ダメージ時は後ろに反る

  const waistZ      = rootZ + arch * 0.2;
  const chestZ      = rootZ + arch * 0.6;
  const shoulderZ   = rootZ + arch * 0.8;
  const neckBaseZ   = rootZ + arch * 0.9;
  const neckTopZ    = rootZ + arch;
  const headZ       = rootZ + arch;

  const footStep = Math.sin(pPhase * 0.5) * 20;

  // プレイヤーはカメラに背を向け、敵の方を向いている想定 (+Z方向)
  let joints = {
    head:     { x: rootX,        y: headCenterY, z: headZ    },
    neckTop:  { x: rootX,        y: neckTopY,    z: neckTopZ  },
    neckBase: { x: rootX,        y: neckBaseY,   z: neckBaseZ },
    ls:       { x: rootX - 110,  y: shoulderY,   z: shoulderZ }, 
    rs:       { x: rootX + 110,  y: shoulderY,   z: shoulderZ }, 
    chest:    { x: rootX,        y: chestY,      z: chestZ    },
    waist:    { x: rootX,        y: waistY,      z: waistZ    },
    hip:      { x: rootX,        y: hipY,        z: rootZ     },
    lhip:     { x: rootX - 60,   y: hipY,        z: rootZ     },
    rhip:     { x: rootX + 60,   y: hipY,        z: rootZ     },
    lknee:    { x: rootX - 70,   y: kneeY,       z: rootZ + footStep },
    rknee:    { x: rootX + 70,   y: kneeY,       z: rootZ - footStep },
    lfoot:    { x: rootX - 75,   y: footY,       z: rootZ + footStep },
    rfoot:    { x: rootX + 75,   y: footY,       z: rootZ - footStep },
  };

  if (isHurt) {
    joints.le = { x: rootX - 140, y: shoulderY + 60, z: shoulderZ - 50 };
    joints.lw = { x: rootX - 200, y: headCenterY + 80, z: shoulderZ - 80 };
    joints.re = { x: rootX + 140, y: shoulderY + 60, z: shoulderZ - 50 };
    joints.rw = { x: rootX + 200, y: headCenterY + 80, z: shoulderZ - 80 };
  } else if (isRecoil) {
    joints.le = { x: rootX - 160, y: shoulderY - 30,  z: shoulderZ - 20 };
    joints.lw = { x: rootX - 280, y: shoulderY - 120, z: shoulderZ - 50 };
    joints.re = { x: rootX + 160, y: shoulderY - 30,  z: shoulderZ - 20 };
    joints.rw = { x: rootX + 280, y: shoulderY - 120, z: shoulderZ - 50 };
  } else if (isBlocking) {
    joints.le = { x: rootX - 80,  y: shoulderY + 30,  z: shoulderZ + 80 };
    joints.lw = { x: rootX - 40,  y: headCenterY + 40, z: headZ + 150 };
    joints.re = { x: rootX + 80,  y: shoulderY + 30,  z: shoulderZ + 80 };
    joints.rw = { x: rootX + 40,  y: headCenterY + 40, z: headZ + 150 };
  } else if (isWindup) {
    if (isLeftPunch) {
      joints.le = { x: rootX - 150, y: shoulderY + 50,  z: shoulderZ - 100 }; 
      joints.lw = { x: rootX - 100, y: shoulderY + 20,  z: shoulderZ - 50 }; 
      joints.re = { x: rootX + 120, y: shoulderY + 10,  z: shoulderZ + 30 };
      joints.rw = { x: rootX +  80, y: headCenterY + 50, z: headZ + 140 };
    } else {
      joints.le = { x: rootX - 120, y: shoulderY + 10,  z: shoulderZ + 30 };
      joints.lw = { x: rootX -  80, y: headCenterY + 50, z: headZ + 140 };
      joints.re = { x: rootX + 150, y: shoulderY + 50,  z: shoulderZ - 100 };
      joints.rw = { x: rootX + 100, y: shoulderY + 20,  z: shoulderZ - 50 };
    }
  } else if (isAttacking) {
    const punchReach = rootZ + 550; // 前方への腕の伸び
    const punchOffX  = isLeftPunch ? -50 : 50;
    if (isLeftPunch) {
      joints.le = { x: rootX - 100, y: shoulderY + 20,  z: shoulderZ + 60 };
      joints.lw = { x: rootX + punchOffX, y: shoulderY + 30, z: punchReach };
      joints.re = { x: rootX + 120, y: shoulderY + 10,  z: shoulderZ + 30 };
      joints.rw = { x: rootX +  80, y: headCenterY + 50, z: headZ + 140 };
    } else {
      joints.le = { x: rootX - 120, y: shoulderY + 10,  z: shoulderZ + 30 };
      joints.lw = { x: rootX -  80, y: headCenterY + 50, z: headZ + 140 };
      joints.re = { x: rootX + 100, y: shoulderY + 20,  z: shoulderZ + 60 };
      joints.rw = { x: rootX + punchOffX, y: shoulderY + 30, z: punchReach };
    }
  } else {
    // 待機状態 (オーソドックス構え)
    const wavL = Math.sin(pPhase) * 15;
    const wavR = Math.cos(pPhase) * 15;
    joints.le = { x: rootX - 120, y: shoulderY + 30 + wavL, z: shoulderZ + 30 };
    joints.lw = { x: rootX -  80, y: headCenterY + 55 + wavL, z: headZ + 150 }; 
    joints.re = { x: rootX + 130, y: shoulderY + 40 + wavR, z: shoulderZ + 20 };
    joints.rw = { x: rootX +  80, y: shoulderY + 10 + wavR, z: shoulderZ + 140 }; 
  }

  // カメラモードの場合は腕の位置をMediaPipeのポーズに追従
  if (controlMode === 'camera' && !isAttacking && !isWindup && !isRecoil && !isHurt) {
    joints.lw.x = rootX + armL.x;
    joints.lw.y = chestY + armL.y;
    joints.lw.z = chestZ + 150;
    joints.rw.x = rootX + armR.x;
    joints.rw.y = chestY + armR.y;
    joints.rw.z = chestZ + 150;
    joints.le = { x: (joints.ls.x + joints.lw.x)/2 - 50, y: (joints.ls.y + joints.lw.y)/2 + 40, z: (joints.ls.z + joints.lw.z)/2 };
    joints.re = { x: (joints.rs.x + joints.rw.x)/2 + 50, y: (joints.rs.y + joints.rw.y)/2 + 40, z: (joints.rs.z + joints.rw.z)/2 };
  }

  const mArm   = materials.playerArm;
  const mGlove = materials.playerGlove;
  const mPants = materials.playerPants;
  const mShoe  = materials.shoe;
  const mBody  = materials.playerBody; 

  updateMesh('p_head', 'joint', joints.head, null, 65, mBody);
  updateMesh('p_neck', 'bone', joints.neckTop, joints.neckBase, 28, mBody);
  updateMesh('p_necktorso', 'bone', joints.neckBase, { x: (joints.ls.x + joints.rs.x)/2, y: joints.ls.y, z: (joints.ls.z + joints.rs.z)/2 }, 35, mBody);

  updateMesh('p_shoulder', 'bone', joints.ls, joints.rs, 50, mBody);
  updateMesh('p_chest', 'bone', { x: (joints.ls.x + joints.rs.x)/2, y: joints.ls.y, z: (joints.ls.z + joints.rs.z)/2 }, joints.waist, 65, mBody);
  updateMesh('p_abdomen', 'bone', joints.waist, joints.hip, 52, mBody);
  updateMesh('p_pelvis', 'bone', joints.lhip, joints.rhip, 48, mPants);

  updateMesh('p_uarm_l', 'bone', joints.ls, joints.le, 34, mArm);
  updateMesh('p_elbow_l', 'joint', joints.le, null, 29, mArm);
  updateMesh('p_larm_l', 'bone', joints.le, joints.lw, 26, mArm);
  updateMesh('p_uarm_r', 'bone', joints.rs, joints.re, 34, mArm);
  updateMesh('p_elbow_r', 'joint', joints.re, null, 29, mArm);
  updateMesh('p_larm_r', 'bone', joints.re, joints.rw, 26, mArm);
  
  updateMesh('p_glove_l', 'joint', joints.lw, null, 55, mGlove);
  updateMesh('p_glove_r', 'joint', joints.rw, null, 55, mGlove);

  updateMesh('p_uleg_l', 'bone', joints.lhip, joints.lknee, 40, mPants);
  updateMesh('p_knee_l', 'joint', joints.lknee, null, 30, mPants);
  updateMesh('p_lleg_l', 'bone', joints.lknee, joints.lfoot, 26, mPants);
  updateMesh('p_foot_l', 'joint', joints.lfoot, null, 28, mShoe);
  
  updateMesh('p_uleg_r', 'bone', joints.rhip, joints.rknee, 40, mPants);
  updateMesh('p_knee_r', 'joint', joints.rknee, null, 30, mPants);
  updateMesh('p_lleg_r', 'bone', joints.rknee, joints.rfoot, 26, mPants);
  updateMesh('p_foot_r', 'joint', joints.rfoot, null, 28, mShoe);
}

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

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

  const bodyDmgPenalty = 1.0 - (playerLimbDmg.body / 200); 
  let staminaRegen = 0;
  if (playerAction === 'guard') staminaRegen = 0.55 * bodyDmgPenalty;  
  else if (playerAction === 'idle') staminaRegen = 0.35 * bodyDmgPenalty;  
  else staminaRegen = 0.10 * bodyDmgPenalty;  
  
  playerStamina = Math.min(100, playerStamina + staminaRegen);

  if (playerStamina >= 50) staminaDebuff = 1.0;
  else if (playerStamina >= 20) staminaDebuff = 0.7 + (playerStamina - 20) / 30 * 0.3; 
  else staminaDebuff = 0.4 + playerStamina / 20 * 0.3;         
  
  const armPenalty = 1.0 - (playerLimbDmg.larm + playerLimbDmg.rarm) / 400;
  staminaDebuff *= armPenalty;

  document.getElementById('player-stamina-fill').style.width = playerStamina + '%';
  const staminaFill = document.getElementById('player-stamina-fill');
  if (playerStamina >= 50) { staminaFill.style.background = 'linear-gradient(90deg, #00f3ff, #0088ff)'; staminaFill.style.boxShadow = '0 0 15px #00f3ff'; } 
  else if (playerStamina >= 20) { staminaFill.style.background = 'linear-gradient(90deg, #ffcc00, #ff8800)'; staminaFill.style.boxShadow = '0 0 15px #ffcc00'; } 
  else { staminaFill.style.background = 'linear-gradient(90deg, #ff0055, #ff4444)'; staminaFill.style.boxShadow = '0 0 15px #ff0055'; }

  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.75;
  playerHurtShake *= 0.82;

  let shakeX = (Math.random() - 0.5) * playerHurtShake * 0.5;
  let shakeY = (Math.random() - 0.5) * playerHurtShake * 0.5;

  // ── 三人称視点カメラ配置 ──
  // プレイヤーの背後・斜め上にカメラを配置
  let camDist = 550; 
  let camHeight = 350; 
  camera.position.set(
    playerPos.x + viewOffsetX * 0.8 + shakeX,
    camHeight - viewOffsetY * 0.5 + camBobY + shakeY,
    -playerPos.z + camDist + camPunchZ
  );
  
  // プレイヤーと敵の中間よりやや前方を注視
  let lookX = (playerPos.x * 0.4 + enemyPos.x * 0.6) + viewOffsetX;
  let lookY = 150 + camHurtPitch;
  let lookZ = (-playerPos.z * 0.2 - enemyPos.z * 0.8);
  camera.lookAt(lookX, lookY, lookZ);
  camera.rotation.z = roll;

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

  buildEnemyPoly();
  buildPlayerPoly();

  renderer.render(scene, camera);

  // ── 2D Canvas エフェクト ─────────────────────────────────────
  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--; }

  if (playerStamina < 30) {
    const alpha = (30 - playerStamina) / 30 * 0.25;
    ctx.fillStyle = `rgba(50,0,0,${alpha})`; ctx.fillRect(0, 0, W(), H());
  }

  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();
  }

  for (let i = chatBubbles.length - 1; i >= 0; i--) {
    let b = chatBubbles[i]; b.y += b.vy; b.life--;
    if (b.life < 60) b.alpha = b.life / 60; 
    if (b.life <= 0) { chatBubbles.splice(i, 1); continue; }

    ctx.save(); ctx.globalAlpha = b.alpha; ctx.font = 'bold 22px Arial'; ctx.textAlign = 'center';
    const textWidth = ctx.measureText(b.text).width;
    ctx.fillStyle = b.isMine ? 'rgba(0, 255, 136, 0.4)' : 'rgba(0, 243, 255, 0.4)';
    ctx.beginPath(); ctx.rect(b.x - textWidth/2 - 15, b.y - 30, textWidth + 30, 40); ctx.fill();
    ctx.strokeStyle = b.isMine ? '#00ff88' : '#00f3ff'; ctx.lineWidth = 2; ctx.stroke();
    ctx.fillStyle = '#ffffff'; ctx.shadowColor = 'black'; ctx.shadowBlur = 4; ctx.fillText(b.text, b.x, b.y - 2); ctx.restore();
  }

  if (playerBlocking) {
    ctx.fillStyle = 'rgba(0,243,255,0.55)'; ctx.font = 'bold 42px Arial'; ctx.textAlign = 'center';
    ctx.shadowColor = '#00f3ff'; ctx.shadowBlur = 18; ctx.fillText('GUARD', W() / 2, H() - 200); ctx.shadowBlur = 0;
  }

  if (playerStamina < 20) {
    ctx.fillStyle = `rgba(255,80,0,${0.5 + Math.sin(Date.now() * 0.01) * 0.3})`;
    ctx.font = 'bold 22px Arial'; ctx.textAlign = 'center'; ctx.fillText('STAMINA LOW!', W() / 2, H() - 260);
  }

  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