カメラで対戦する「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.4</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: 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; }
  </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>
  <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="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>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;
const TOTAL_ROUNDS = 3;         // 全ラウンド数
const ROUND_DURATION = 180;     // 1ラウンド秒数(3分)
let roundTimeLeft = ROUND_DURATION; // 残り時間(秒)
let roundTimerInterval = null;  // setIntervalハンドル
let roundInProgress = false;    // ラウンド進行中フラグ(ブレイク中はfalse)
let comboTimer = null;
let playerHP = 100, playerStamina = 100;

// ── 部位ダメージ (0=無傷 100=完全損傷) ───────────────────────
// enemyの部位ダメージ
let enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
// プレイヤーの部位ダメージ → パフォーマンス低下に使用
let playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };

// ── スタミナ詳細 ─────────────────────────────────────────────
// staminaDebuff: スタミナ低下による速度・パンチ力低下係数(0.0~1.0, 1=通常)
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;
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;
    // gameLoopはrequestAnimationFrameベースなので再起動が必要
    requestAnimationFrame(gameLoop);
  } 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';
  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; roundInProgress = 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();
  }

  // ラウンドタイマー開始
  startRoundTimer();
  // 最初のラウンド開幕演出
  showRoundBreak(1, false, () => { requestAnimationFrame(gameLoop); });
}

// ─── ラウンド制システム ───────────────────────────────────────────
// ラウンドブレイク演出: 画面を暗転して「ROUND X FIGHT!」を表示し、終わったらcallbackを呼ぶ
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) {
    // ラウンド終了 → 次ラウンド開幕の2段階
    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...';
    // 2秒後に FIGHT! 表示
    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');
      // 1.5秒後にゲーム再開
      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')}`;
  // 残り30秒以下は赤く点滅
  const timerEl = document.getElementById('timer');
  timerEl.style.color = roundTimeLeft <= 30 ? '#ff0055' : '#fff';
}

// 時間切れ処理
function onRoundTimeUp() {
  if (!gameRunning) return;
  roundInProgress = false;
  // 判定: HPで勝敗を判断
  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();
  }
}

// KOによるラウンド終了(プレイヤーが敵をKO)
function onEnemyKO() {
  if (!gameRunning) return;
  roundInProgress = false;
  if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
  // KO演出
  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) {
    // 最終ラウンドでKO → YOU WIN
    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++;
  // 双方HP・スタミナ全回復、部位ダメージも回復
  playerHP = 100; playerStamina = 100; staminaDebuff = 1.0;
  playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  // 敵HP全回復・強化
  enemy.maxHP = 100 + (round - 1) * 30; // Round2=130, Round3=160
  enemy.hp = enemy.maxHP;
  enemy.attackInterval = Math.max(70, 110 - (round - 1) * 15); // R2=95f, R3=80f
  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, null);
}

// ─── カメラ・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 < 12) return; // スタミナ切れはパンチ不可
    playerStamina -= 15;
    playerBlocking = false; playerAction = 'windupL'; playSound('swing');
    playerActionTimer = setTimeout(() => {
      if (playerAction === 'windupL') {
        playerAction = 'punchL';
        tryPunch('left', 250 * staminaDebuff); // スタミナデバフをspeedに反映
        setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300);
      }
    }, 120);
  }
  else if (mouseR && !mouseL && playerAction === 'idle') {
    if (playerStamina < 12) 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;
  // 脚ダメージによる移動速度低下: 最大40%低下
  const legPenalty = 1.0 - (playerLimbDmg.legs / 250);
  if (controlMode === 'mouse') {
    let movedByKey = false;
    const speed = Math.round(25 * 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(-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;
}

// ─── 当たり判定と戦闘ロジック ─────────────────────────

// aimYから狙い部位を判定する
// aimY: -1(画面上端)〜+1(画面下端)
// 頭: aimY < -0.15
// 上体: -0.15 <= aimY < 0.25
// 腹/腰: 0.25 <= aimY < 0.55
// 脚: aimY >= 0.55
function getTargetPart(aimYVal) {
  if (aimYVal < -0.15) return 'head';
  if (aimYVal < 0.25)  return 'body';
  if (aimYVal < 0.55)  return 'body'; // 腹も 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) {
  // aimYから部位を決定
  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 isEnemyBlocking = enemy.state === 'blocking';
  const result = evaluateHit(playerPos, enemyPos, isEnemyBlocking, targetPart);

  if (!result.hit) {
    if (result.reason === 'GUARD!') playSound('guard');
    showHitNumber(result.reason, '#aaa');
    combo = 0; updateUI();
  } else {
    // 基礎ダメージ: speed×0.04 × スタミナデバフ
    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();
    }
  }
}

// 部位ダメージを蓄積し、UIと効果を更新
function applyLimbDamage(target, part, dmg) {
  if (target === 'enemy') {
    enemyLimbDmg[part] = Math.min(100, (enemyLimbDmg[part] || 0) + dmg * 0.8);
    // 敵の部位ダメージ効果(AI動作への影響は将来拡張用の記録のみ)
  } else {
    playerLimbDmg[part] = Math.min(100, (playerLimbDmg[part] || 0) + dmg * 0.8);
    // プレイヤー部位ダメージ効果
    // 腕へのダメージ → staminaDebuff経由でパンチ力低下
    const armDmg = (playerLimbDmg.larm + playerLimbDmg.rarm) / 200;
    // 脚へのダメージ → 移動速度低下(detectMovement内で参照)
    // 胴体→スタミナ回復速度低下(gameLoop内で参照)
    updateLimbUI();
  }
}

// 部位ダメージUIを更新
function updateLimbUI() {
  const fillColor = (pct) => pct > 60 ? '#ff0055' : pct > 30 ? '#ffaa00' : '#00f3ff';
  const set = (id, val) => {
    const el = document.getElementById(id);
    if (!el) return;
    const pct = val;
    const 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);
    }
  }
  // 部位名とダメージ数値を表示
  const label = isCleanHit ? `${cfg.label} CLEAN! ${dmg}` : `${cfg.label} ${dmg}`;
  showHitNumber(label, 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, hitPart) {
  hitPart = hitPart || 'body';
  playSound('damage');
  playerHP = Math.max(0, playerHP - dmg);
  combo = 0;

  // 部位ダメージ蓄積とUI更新
  applyLimbDamage('player', hitPart, dmg);

  // 頭への大ダメージ → ノックダウンしやすい(HPが残っていてもふらつき)
  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.');
  }
}

// ─── NPC AI ──────────────────────────────────────────
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 {
        // 敵ダメージ: 基礎 8 + round*2、部位は body または head でランダム
        const hitPart = Math.random() < 0.3 ? 'head' : 'body';
        const baseDmg = 8 + round * 2;
        takeDamage(Math.floor(baseDmg * result.multi), result.isClean, hitPart);
      }
    }
    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; }
  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);
}

// ─── 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';

  // ── のけぞり進行度 (0→1→0) ─────────────────────────────────
  // stateTimerは25から0へカウントダウン → 被弾直後ほどhurtProgress大
  const hurtMax = 30; // stateTimer初期値と合わせる
  const hurtProgress = isHurt
    ? Math.sin((1.0 - enemy.stateTimer / hurtMax) * Math.PI)
    : 0;

  // ── 海老反り: 上体を後ろへ弓なりに反らせる ──────────────────
  // leanZ: 足元基準でどれだけ後ろへ倒れるか(骨盤は動かさず上体だけ)
  const archAmount  = isHurt ? 320 * hurtProgress : 0; // 上体が後ろへ
  const headExtra   = isHurt ? 160 * hurtProgress : 0; // 頭はさらに後ろへ
  const kneeBend    = isHurt ?  80 * hurtProgress : 0; // 膝が前へ出る(踏ん張り)
  const waistRiseZ  = isHurt ? 120 * hurtProgress : 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;

  // ── ベース座標 ──────────────────────────────────────────────
  const bobY  = Math.sin(npcPhase) * 12;
  const stepZ = isAttacking ? -80 : 0; // パンチ時に一歩踏み込む

  // 骨盤ルート(ここは被弾時も動かさない=海老反りの支点)
  const rootX = enemyPos.x + sx;
  const rootZ = enemyPos.z + sz + stepZ;

  // ── Y軸オフセット(下から上へ積み上げる) ───────────────────
  // キャラ全高を整理:
  //   足元(footY) → 膝(kneeY) → 骨盤(hipY) → 腰(waistY) → 胸(chestY)
  //   → 肩(shoulderY) → 首(neckY) → 頭中心(headY)
  // Three.js座標 y軸: 上がプラス → ゲーム座標はy増加=下方向なので注意
  // updateMesh内で ty = -p1.y に変換されるため、ゲーム座標でy値が小さい=画面上

  const footY     = 600  + bobY;          // 足底(最下)
  const kneeY     = 420  + bobY + kneeBend;
  const hipY      = 220  + bobY;          // 骨盤(支点)
  const waistY    = 130  + bobY + (isHurt ? 10 * hurtProgress : 0);
  // 胸・肩は被弾時に後ろへ反る(Y座標も少し持ち上がる=y減少)
  const chestY    =  30  + bobY + sy + (isHurt ? -20 * hurtProgress : 0);
  const shoulderY = -10  + bobY + sy;
  // 首は肩の真上から頭への橋渡し
  const neckBaseY = -50  + bobY + sy;     // 首の根元
  const neckTopY  = -100 + bobY + sy;     // 首の先(頭との接合点)
  // 頭中心は首先端のさらに上(頭の球半径分)
  const headCenterY = -170 + bobY + sy + (isHurt ? -10 * hurtProgress : 0);

  // ── Z軸: 海老反りで上体だけ後退 ────────────────────────────
  // 骨盤(rootZ)を支点に、上へ行くほど後ろへ反る
  const waistZ   = rootZ + waistRiseZ * 0.3;
  const chestZ   = rootZ + archAmount * 0.6;
  const shoulderZ = rootZ + archAmount * 0.85;
  const neckBaseZ = rootZ + archAmount * 0.95;
  const neckTopZ  = rootZ + archAmount + headExtra * 0.3;
  const headZ     = rootZ + archAmount + headExtra; // 頭が最も後方

  // 足のX位置(ボクシングスタンス: 左足前)
  const footStep = Math.sin(npcPhase * 0.5) * 40;

  let joints = {
    // 頭・首(Z方向に分離して首が見えるようにする)
    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 lsX = joints.ls.x, lsY = joints.ls.y, lsZ2 = joints.ls.z;
  const rsX = joints.rs.x, rsY = joints.rs.y, 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) {
    // パンチ繰り出し: ガード中はプレイヤーの腕で止まる(すり抜けない)
    // playerBlocking=true の時はリーチを短く (腕の前あたりで停止)
    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;
    const 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;
  const mGlove = materials.glove;
  const mPants = materials.pants;
  const mShoe  = materials.shoe;

  // 頭部: 球体
  updateMesh('e_head', 'joint', joints.head, null, 68, mSkin);

  // 首: 頭の付け根(neckTop)→首の根元(neckBase) を1本のボーンで繋ぐ
  // これにより頭と首が離れなくなる(headZとneckTopZを同一にしているため)
  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 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;

  // ── スタミナ回復ロジック ───────────────────────────────────────
  // 胴体ダメージが多いほど回復が遅い
  // ガード中・待機中は回復速が速い
  // 移動中・アクション中は少し消耗
  const bodyDmgPenalty = 1.0 - (playerLimbDmg.body / 200); // 最大50%回復低下
  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);

  // スタミナデバフ計算: 50%以下から徐々に低下、20%以下で大幅低下
  if (playerStamina >= 50) {
    staminaDebuff = 1.0;
  } else if (playerStamina >= 20) {
    staminaDebuff = 0.7 + (playerStamina - 20) / 30 * 0.3; // 0.70〜1.00
  } else {
    staminaDebuff = 0.4 + playerStamina / 20 * 0.3;         // 0.40〜0.70
  }
  // 腕ダメージによるパンチ力低下を追加
  const armPenalty = 1.0 - (playerLimbDmg.larm + playerLimbDmg.rarm) / 400;
  staminaDebuff *= armPenalty;

  document.getElementById('player-stamina-fill').style.width = playerStamina + '%';
  // スタミナバーの色: 50%以上=青、20〜50%=黄、20%以下=赤
  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内で参照
  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;

  // ★初期カメラ高さ: y=250 から y=180 に下げ、初期から正しい視点にする
  //   viewOffsetY 初期値 -60 により lookatYが下がり敵の顔方向を向く
  camera.position.set(
    playerPos.x + viewOffsetX + shakeX,
    180 - viewOffsetY + camBobY + shakeY,
    -playerPos.z + 100 + camPunchZ
  );
  camera.lookAt(
    playerPos.x + viewOffsetX * 1.5,
    180 - 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);

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

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

  // スタミナ低下 HUD 警告
  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