カメラで対戦する「MotionBoxing」


【更新履歴】

 ・2026/3/12 バージョン1.0公開。
          (中略)
 ・2026/3/12 バージョン1.5公開。(複数人対戦を追加)
 ・2026/3/12 バージョン1.6公開。(必殺技を追加)

画像



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

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


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


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

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Motion Boxing 1.8</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; }

    /* 必殺技ゲージ */
    #special-gauge-container {
      position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
      z-index: 10; pointer-events: none; text-align: center; width: 260px;
    }
    #special-gauge-label {
      font-size: 0.75rem; color: #ffd700; letter-spacing: 3px; text-transform: uppercase;
      margin-bottom: 4px; text-shadow: 0 0 8px #ffd700;
    }
    #special-gauge-bg {
      background: rgba(20,20,20,0.85); border: 2px solid #ffd700;
      border-radius: 6px; height: 18px; overflow: hidden;
      box-shadow: 0 0 10px rgba(255,215,0,0.4);
    }
    #special-gauge-fill {
      height: 100%; width: 0%;
      background: linear-gradient(90deg, #ff8800, #ffd700, #ffffff);
      box-shadow: 0 0 12px #ffd700;
      transition: width 0.1s linear;
      border-radius: 4px;
    }
    /* 必殺技発動ボタン(ゲージMAX時) */
    #special-ready-btn {
      position: absolute; bottom: 52px; left: 50%; transform: translateX(-50%);
      z-index: 30; pointer-events: none; display: none;
      padding: 12px 36px; font-size: 1.4rem; font-weight: 900;
      background: linear-gradient(135deg, #ff8800, #ffd700);
      color: #000; border: 3px solid #fff; border-radius: 12px;
      cursor: default; letter-spacing: 3px;
      box-shadow: 0 0 30px #ffd700, 0 0 60px #ff8800;
      animation: specialPulse 0.6s ease-in-out infinite alternate;
    }
    @keyframes specialPulse {
      from { box-shadow: 0 0 20px #ffd700, 0 0 40px #ff8800; transform: translateX(-50%) scale(1.0); }
      to   { box-shadow: 0 0 40px #fff,    0 0 80px #ffd700; transform: translateX(-50%) scale(1.06); }
    }
    /* 必殺技エフェクトオーバーレイ */
    #special-effect-overlay {
      position: absolute; top: 0; left: 0; width: 100%; height: 100%;
      pointer-events: none; z-index: 25; opacity: 0;
      background: radial-gradient(ellipse at center, rgba(255,215,0,0.0) 20%, rgba(255,140,0,0.7) 80%, rgba(255,0,85,0.9) 100%);
      transition: opacity 0.08s;
    }

    /* ヒットゾーンインジケーター */
    #hit-zone-indicator {
      position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%);
      z-index: 10; pointer-events: none; text-align: center;
      opacity: 0; transition: opacity 0.15s;
    }
    #hit-zone-indicator .hz-label {
      font-size: 0.72rem; color: #aaa; letter-spacing: 2px; margin-bottom: 3px;
    }
    #hit-zone-indicator .hz-value {
      font-size: 1.05rem; font-weight: 900; letter-spacing: 3px;
      text-shadow: 0 0 12px currentColor;
    }
    #hit-zone-indicator .hz-hint {
      font-size: 0.68rem; color: #ffd700; margin-top: 3px; letter-spacing: 1px;
    }

    /* チャットログ用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="special-gauge-container">
    <div id="special-gauge-label">⚡ SPECIAL ⚡</div>
    <div id="special-gauge-bg"><div id="special-gauge-fill"></div></div>
  </div>
  <div id="special-ready-btn">💥 必殺技発動!クリック!</div>
  <div id="special-effect-overlay"></div>
  
  <!-- ヒットゾーンインジケーター -->
  <div id="hit-zone-indicator">
    <div class="hz-label">HIT ZONE</div>
    <div class="hz-value" id="hz-value">─</div>
    <div class="hz-hint" id="hz-hint"></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>
</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>
       【カメラ】自分の体の動きでゲーム内の体を移動・回避<br>
       ⚡<span class="key-hint">SPECIALゲージMAX時:次のクリックで必殺技発動!</span><br>
       UPPER CUT(近距離/頭)・BODY BLOW(中〜遠距離/胴体)を自動選択</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>
    
    <div style="margin-bottom: 20px; text-align: left;">
      <label style="font-size: 0.9rem; color: #00f3ff; font-weight: bold;">あなたのID (相手に教えてください):</label>
      <div style="display: flex; gap: 5px; margin-top: 5px;">
        <input type="text" id="my-id-display" readonly value="取得中..." style="margin-bottom: 0; background: #222; color: #00f3ff; font-weight: bold;">
        <button class="btn" id="copy-btn" onclick="copyMyId()" style="margin: 0; width: 90px; padding: 10px; font-size: 0.95rem;">コピー</button>
      </div>
    </div>
    
    <div style="margin-bottom: 20px; text-align: left;">
      <label style="font-size: 0.9rem; color: #aaa;">接続先:</label>
      <input type="text" id="target-id-input" placeholder="相手のIDを入力して接続" style="margin-top: 5px;">
    </div>
    
    <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); }
  if (type === 'special_ready') {
    playTone(880, 'sine', 0.15, 0.6); setTimeout(()=>playTone(1100, 'sine', 0.15, 0.7), 120);
    setTimeout(()=>playTone(1320, 'sine', 0.25, 0.9), 240);
  }
  if (type === 'special_activate') {
    playTone(200, 'sawtooth', 0.08, 0.9);
    setTimeout(()=>playTone(400, 'sawtooth', 0.08, 0.9), 60);
    setTimeout(()=>playTone(800, 'square', 0.08, 1.0), 120);
    setTimeout(()=>playTone(1600, 'square', 0.3, 1.0), 180);
    setTimeout(()=>playTone(2200, 'sine', 0.4, 0.8), 260);
  }
  if (type === 'special_enemy') {
    playTone(80, 'sawtooth', 0.1, 0.9); setTimeout(()=>playTone(160, 'sawtooth', 0.1, 0.9), 80);
    setTimeout(()=>playTone(320, 'square', 0.1, 1.0), 160); setTimeout(()=>playTone(100, 'sawtooth', 0.5, 1.0), 240);
  }
}

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

// ─── 必殺技システム ───────────────────────────────────────────
// 技リスト: 部位狙い特化型(足への攻撃は反則のため廃止)
const SPECIAL_MOVES = [
  {
    name: 'UPPER CUT',       // アッパーカット → 頭部狙い
    motion: 'special_rising',
    effectColor: '#ff4400',
    description: '下から頭部へのアッパーカット!近距離必須',
    targetPart: 'head',
    baseDamage: 38,
    minDz: 60,
    maxDz: 320,
    rangeBonus: 0,
    knockback: 80,
    extraHits: [],
  },
  {
    name: 'BODY BLOW',        // ボディブロー → 胴体狙い
    motion: 'special_twin',
    effectColor: '#ff8800',
    description: '左右交互に胴体へ連打!中距離で有効',
    targetPart: 'body',
    baseDamage: 22,
    minDz: 60,
    maxDz: 480,
    rangeBonus: 60,
    knockback: 20,
    extraHits: ['body', 'body'],
  },
];

let specialGauge     = 0;        // 0 ~ 100
let specialReady     = false;    // ゲージMAX状態か
let specialActive    = false;    // 必殺技発動中か
let specialTimer     = 0;        // 必殺技モーションフレームカウンタ
let specialMoveIndex = 0;        // 発動する技インデックス
let specialMotion    = '';       // 現在の必殺技モーション名
let enemySpecialTimer = 0;       // 敵必殺技カウンタ
let enemySpecialActive = false;  // 敵必殺技発動中か
let enemySpecialMotion = '';     // 敵必殺技モーション名

function updateSpecialGauge(delta) {
  if (!gameRunning || !roundInProgress) return;
  specialGauge = Math.max(0, Math.min(100, specialGauge + delta));
  document.getElementById('special-gauge-fill').style.width = specialGauge + '%';
  if (specialGauge >= 100 && !specialReady) {
    specialReady = true;
    playSound('special_ready');
    document.getElementById('special-ready-btn').style.display = 'block';
    document.getElementById('special-gauge-bg').style.borderColor = '#fff';
    document.getElementById('special-gauge-bg').style.boxShadow = '0 0 20px #ffd700, 0 0 40px #ff8800';
  }
}

function activateSpecialMove() {
  if (!specialReady || specialActive || !gameRunning || !roundInProgress) return;
  specialReady = false;
  specialActive = true;
  specialGauge = 0;
  document.getElementById('special-ready-btn').style.display = 'none';
  document.getElementById('special-gauge-fill').style.width = '0%';
  document.getElementById('special-gauge-bg').style.borderColor = '#ffd700';
  document.getElementById('special-gauge-bg').style.boxShadow = '0 0 10px rgba(255,215,0,0.4)';

  // 現在の位置関係から最適な技を自動選択(距離で選択)
  const dz = playerPos.z - enemyPos.z;
  const dist = Math.abs(dz);
  // 距離に応じた技選択: 近→アッパーカット(head)、遠→ボディブロー(body)
  if (dist <= 280) {
    specialMoveIndex = 0; // UPPER CUT
  } else {
    specialMoveIndex = 1; // BODY BLOW
  }

  const move = SPECIAL_MOVES[specialMoveIndex];
  specialMotion = move.motion;
  const duration = move.motion === 'special_rising' ? 50 : 60; // UPPER CUT=50f, BODY BLOW=60f
  specialTimer = duration;

  playSound('special_activate');
  playerAction = specialMotion;
  playerBlocking = false;
  clearTimeout(playerActionTimer);

  // フラッシュ&エフェクト
  const eff = document.getElementById('special-effect-overlay');
  eff.style.background = `radial-gradient(ellipse at center, rgba(255,255,255,0.1) 0%, ${move.effectColor}cc 60%, rgba(0,0,0,0.5) 100%)`;
  eff.style.opacity = '1';
  screenShake = 60; flashTimer = 10; hitChromatic = 18;

  showHitNumber(`⚡ ${move.name} ⚡`, move.effectColor, 2.0);
  spawnParticles(W()/2, H()/2, move.effectColor, 50);
  spawnParticles(W()/2, H()/2, '#ffffff', 25);

  // 位置関係チェック用の判定関数
  function evaluateSpecialHit(move) {
    const curDist = Math.abs(enemyPos.z - playerPos.z); // 絶対距離
    const curDx = Math.abs(playerPos.x - enemyPos.x);

    const maxReach = move.maxDz + move.rangeBonus;
    if (curDist > maxReach)   return { hit: false, reason: 'OUT OF RANGE!' };
    if (curDist < move.minDz) return { hit: false, reason: 'TOO CLOSE!' };
    if (curDx > 280)          return { hit: false, reason: 'MISS (DODGED)' };
    if (enemy.state === 'blocking') return { hit: false, reason: 'GUARD!' };

    const isClean = (curDist >= move.minDz && curDist < move.maxDz * 0.6 && curDx < 170);
    const cfg = PART_CONFIG[move.targetPart] || PART_CONFIG.body;
    const dmg = Math.floor(move.baseDamage * cfg.multi * (isClean ? 1.5 : 1.0));
    return { hit: true, dmg, isClean, cfg, part: move.targetPart };
  }

  // ダメージ判定: モーション中盤
  const hitDelay = Math.floor(duration * 0.45) * (1000 / 60);
  setTimeout(() => {
    if (!gameRunning) return;

    // マルチモード: 必殺技パケットを送信して相手側で判定
    if (gameMode === 'multi') {
      if (conn && conn.open) {
        conn.send({ type: 'special', targetPart: move.targetPart, dmg: move.baseDamage });
      }
      eff.style.opacity = '0';
      return;
    }

    const r = evaluateSpecialHit(move);
    if (r.hit) {
      enemy.hp = Math.max(0, enemy.hp - r.dmg);
      applyLimbDamage('enemy', r.part, r.dmg);
      hitEnemyVisual(r.dmg, 0, r.part === 'head' ? -150 : -50, r.isClean, r.part);

      // ノックバック
      if (move.knockback > 0) {
        enemyPos.z += move.knockback;
      }

      if (enemy.hp <= 0) { eff.style.opacity = '0'; onEnemyKO(); return; }

      // 追加ヒット(ボディブロー)
      if (move.extraHits && move.extraHits.length > 0) {
        move.extraHits.forEach((part, i) => {
          setTimeout(() => {
            if (!gameRunning || enemy.hp <= 0) return;
            const r2 = evaluateSpecialHit(move);
            if (r2.hit) {
              const d2 = Math.floor(move.baseDamage * 0.55 * (PART_CONFIG[part]?.multi || 1.2) * (r2.isClean ? 1.3 : 1.0));
              enemy.hp = Math.max(0, enemy.hp - d2);
              applyLimbDamage('enemy', part, d2);
              hitEnemyVisual(d2, (Math.random()-0.5)*80, (Math.random()-0.5)*60, false, part);
              if (enemy.hp <= 0) { eff.style.opacity = '0'; onEnemyKO(); }
            }
          }, (i + 1) * 180);
        });
      }
    } else {
      showHitNumber(r.reason, '#aaa');
    }
    eff.style.opacity = '0';
  }, hitDelay);

  // モーション終了
  setTimeout(() => {
    specialActive = false;
    specialMotion = '';
    if (playerAction === move.motion) playerAction = 'idle';
    eff.style.opacity = '0';
  }, duration * (1000 / 60));
}

// 敵必殺技発動
function triggerEnemySpecial() {
  if (enemySpecialActive || !roundInProgress) return;
  enemySpecialActive = true;
  const motions = ['e_special_smash', 'e_special_rush', 'e_special_upper'];
  enemySpecialMotion = motions[Math.floor(Math.random() * motions.length)];
  enemySpecialTimer = 70;
  enemy.state = enemySpecialMotion;
  enemy.stateTimer = 70;
  playSound('special_enemy');

  screenShake = 30;
  showHitNumber('ENEMY SPECIAL!!', '#ff4400', 1.8);
  spawnParticles(W()/2, H()/3, '#ff4400', 40);

  // ダメージ判定(中盤)
  setTimeout(() => {
    if (!gameRunning) return;
    const result = evaluateHit(enemyPos, playerPos, playerBlocking, Math.random() < 0.5 ? 'head' : 'body');
    if (result.hit) {
      const dmg = Math.floor((15 + round * 4) * result.multi);
      takeDamage(dmg, true, result.part || 'body');
    } else {
      if (result.reason === 'GUARD!') { playSound('guard'); screenShake = 15; }
      showHitNumber(result.reason, '#aaa');
    }
    // 追加ヒット(ラッシュ技は3連)
    if (enemySpecialMotion === 'e_special_rush') {
      for (let i = 1; i <= 2; i++) {
        setTimeout(() => {
          if (!gameRunning || playerHP <= 0) return;
          const r2 = evaluateHit(enemyPos, playerPos, playerBlocking, 'body');
          if (r2.hit) takeDamage(Math.floor((12 + round * 3) * r2.multi), false, 'body');
        }, i * 220);
      }
    }
  }, 600);

  setTimeout(() => {
    enemySpecialActive = false;
    enemySpecialMotion = '';
    if (enemy.state.startsWith('e_special')) { enemy.state = 'idle'; enemy.stateTimer = 0; }
  }, 70 * (1000/60));
}

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

// ─── ヒットゾーンインジケーター状態 ─────────────────────────────
// 現在のヒット可能ゾーン: null=範囲外, { part, color, isClean, hint }
let currentHitZone = null;
// 前フレームの距離帯(変化検出用)
let prevHitZoneBand = null;
// ハイライト中のメッシュIDリスト
let highlightedMeshIds = [];
// ハイライト元マテリアル退避
let meshOrigMaterials = {};

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

// ─── コピー機能 ───────────────────────────────────────────────
function copyMyId() {
  const idInput = document.getElementById('my-id-display');
  if (idInput.value && !idInput.value.includes('取得中') && !idInput.value.includes('エラー')) {
    navigator.clipboard.writeText(idInput.value).then(() => {
      const btn = document.getElementById('copy-btn');
      const originalText = btn.textContent;
      btn.textContent = 'コピー済!';
      btn.style.background = '#00ff88';
      btn.style.color = '#000';
      btn.style.borderColor = '#00ff88';
      setTimeout(() => {
        btn.textContent = originalText;
        btn.style.background = 'transparent';
        btn.style.color = '#00f3ff';
        btn.style.borderColor = '#00f3ff';
      }, 2000);
    }).catch(err => {
      alert('コピーに失敗しました。お手数ですが手動で選択してコピーしてください。');
      idInput.select();
    });
  }
}

// ─── コミュニケーションシステム (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-id-display').value = 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-id-display').value = 'エラー: ' + err.type; 
    });
    peer.on('disconnected', () => {
      document.getElementById('my-id-display').value = '切断 (再接続中...)';
      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-id-display');
  if (!statusEl.value.includes('接続中')) {
    statusEl.value = statusEl.value + ' (接続中...)';
  }
  
  conn.on('open', () => {
    // 接続成功したら元のID表示に戻す
    statusEl.value = peer.id;
    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') {
        const prevHp = enemy.hp;
        enemy.hp = Math.max(0, Math.min(enemy.maxHP, data.hp));
        // sync経由でhpがゼロになった場合(相手がtakeDamageで自滅)→ 勝利
        if (prevHp > 0 && enemy.hp <= 0 && gameRunning) {
          showResult(true, 'YOU WIN!');
          return;
        }
      }
      if (data.state) enemy.state = data.state;
      if (data.poseData) enemy.poseData = data.poseData;
      if (data.pos) {
        // 向き合い変換: 相手のx→反転(左右が鏡像)
        // 相手のz=0〜700 → enemyPos.z = 800-data.pos.z (相手z=0→z=800遠、相手z=700→z=100近)
        enemyPos.x = -data.pos.x;
        enemyPos.z = 800 - data.pos.z;
      }
      if (!gameRunning) return;
      updateUI();
    }

    // ── パンチ受信: 自分が被弾側として判定 ──────────────────────
    else if (data.type === 'punch') {
      if (!gameRunning) return;
      const tPart = data.targetPart || 'body';
      const result = evaluateHit(enemyPos, playerPos, playerBlocking, tPart);
      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.02) * result.multi);
        takeDamage(dmg, result.isClean, result.part);
        // 被弾時: 画面のメッセージをフラッシュで消す
        _clearMultiHitMessage();
        c.send({ type: 'hit_confirm', hit: true, dmg: dmg, hitType: result.type, part: result.part });
      }
    }

    // ── ヒット確認受信: 自分が攻撃側として視覚処理 ───────────────
    else if (data.type === 'hit_confirm') {
      if (!gameRunning) return;
      if (data.hit) {
        // ローカルでもhpを減算して視覚的HP表示を即座に更新(sync受信で正確な値に補正)
        enemy.hp = Math.max(0, enemy.hp - (data.dmg || 0));
        const oxSign = (Math.random() > 0.5) ? 50 : -50;
        const oySign = data.part === 'head' ? -150 : -50;
        hitEnemyVisual(data.dmg, oxSign, oySign, data.hitType && data.hitType.includes('CLEAN'), data.part || 'body');
        updateSpecialGauge(data.hitType && data.hitType.includes('CLEAN') ? 25 : 14);
        // 攻撃成功メッセージを画面に表示
        _showMultiHitMessage(data.dmg, data.hitType || 'HIT', data.part || 'body');
      } else {
        showHitNumber(data.reason || 'MISS', '#aaa');
        combo = 0; updateUI();
      }
    }

    // ── 必殺技受信: 相手が必殺技を発動した ─────────────────────
    else if (data.type === 'special') {
      if (!gameRunning) return;
      const tPart = data.targetPart || 'body';
      const result = evaluateHit(enemyPos, playerPos, playerBlocking, tPart);
      if (result.hit) {
        const dmg = Math.floor(data.dmg * result.multi);
        takeDamage(dmg, true, result.part);
        _clearMultiHitMessage();
        c.send({ type: 'special_confirm', hit: true, dmg: dmg, part: result.part });
      } else {
        showHitNumber(result.reason, '#aaa');
        c.send({ type: 'special_confirm', hit: false, reason: result.reason });
      }
    }

    // ── 必殺技ヒット確認受信 ────────────────────────────────────
    else if (data.type === 'special_confirm') {
      if (!gameRunning) return;
      if (data.hit) {
        enemy.hp = Math.max(0, enemy.hp - (data.dmg || 0));
        hitEnemyVisual(data.dmg, 0, data.part === 'head' ? -150 : -50, true, data.part || 'body');
        updateSpecialGauge(30);
        _showMultiHitMessage(data.dmg, '⚡ SPECIAL HIT', data.part || 'body');
      } else {
        showHitNumber(data.reason || 'SPECIAL MISS', '#aaa');
      }
    }

    // ── ゲームオーバー通知 ────────────────────────────────────
    else if (data.type === 'gameover') {
      showResult(true, 'YOU WIN!');
    }

    // ── チャット ────────────────────────────────────────────
    else if (data.type === 'chat') {
      displayChat('Enemy', data.text, false);
    }
  });

  c.on('close', () => {
    if (gameRunning) { gameRunning = false; alert('相手との接続が切れました。'); location.reload(); }
  });
}

// ─── マルチ専用ヒットメッセージ表示 ─────────────────────────────
// パンチを当てた側: 大きなヒット表示(画面中央上部)
let _multiHitMsgTimer = null;
function _showMultiHitMessage(dmg, hitType, part) {
  const cfg = PART_CONFIG[part] || PART_CONFIG.body;
  const isClean = hitType.includes('CLEAN') || hitType.includes('SPECIAL');
  const color = isClean ? '#ffd700' : cfg.color;
  const text = isClean ? `💥 ${cfg.label} CLEAN HIT! ${dmg}` : `👊 ${cfg.label} HIT ${dmg}`;
  // hitNumbersを使って目立つ位置に表示
  hitNumbers.push({
    x: W()/2 + (Math.random()-0.5)*80,
    y: H()/2 - 180,
    text, color,
    alpha: 1, vy: -2.5,
    scale: isClean ? 1.0 : 0.8,
    targetScale: isClean ? 2.2 : 1.6
  });
  // combo表示も更新
  const cd = document.getElementById('combo-display');
  if (combo >= 2) { cd.textContent = `${combo} HIT!!`; cd.style.opacity = '1'; }
  if (_multiHitMsgTimer) clearTimeout(_multiHitMsgTimer);
  _multiHitMsgTimer = setTimeout(() => { cd.style.opacity = '0'; }, 1200);
}

// 被弾した側: 画面フラッシュで視覚的にメッセージを消す
function _clearMultiHitMessage() {
  // hitNumbers をすべてフェードアウト加速(被弾で画面が揺れる演出)
  hitNumbers.forEach(h => { h.alpha = Math.min(h.alpha, 0.3); h.vy -= 3; });
  // combo表示を消す
  const cd = document.getElementById('combo-display');
  cd.style.opacity = '0';
  combo = 0;
  updateUI();
}

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: specialActive ? playerAction : playerAction,
      poseData: pd,
      pos: { x: playerPos.x, z: playerPos.z },
      specialGauge: specialGauge,
      specialActive: specialActive
    });
  } 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 };
  specialGauge = 0; specialReady = false; specialActive = false; specialMotion = '';
  enemySpecialActive = false; enemySpecialMotion = '';
  currentHitZone = null; prevHitZoneBand = null;
  _clearHitZoneHighlight();
  document.getElementById('hit-zone-indicator').style.opacity = '0';
  document.getElementById('special-gauge-fill').style.width = '0%';
  document.getElementById('special-ready-btn').style.display = 'none';
  
  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();
  }

  // マルチモード: ラウンドなし→ラウンドブレイク非表示で即スタート
  if (gameMode === 'multi') {
    roundInProgress = true;
    requestAnimationFrame(gameLoop);
    if (commMode === 'text' && speechRecognition) { try { speechRecognition.start(); } catch(e) {} }
    return;
  }

  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 || !roundInProgress) 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();
  specialGauge = 0; specialReady = false; specialActive = false; specialMotion = '';
  enemySpecialActive = false; enemySpecialMotion = '';
  document.getElementById('special-gauge-fill').style.width = '0%';
  document.getElementById('special-ready-btn').style.display = 'none';
  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) {
        // カメラモードでもゲージMAX時は必殺技発動
        if (specialReady && !specialActive && roundInProgress) { activateSpecialMove(); camPunchCooldownL = 20; }
        else {
          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) {
        // カメラモードでもゲージMAX時は必殺技発動
        if (specialReady && !specialActive && roundInProgress) { activateSpecialMove(); camPunchCooldownR = 20; }
        else {
          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)) * 1600;
  // マウス上下: 上=前進(z大)、下=後退(z小) / 0〜700の範囲
  targetPlayerPos.z = (1.0 - (e.clientY / H())) * 700;
}
function onMouseDown(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  // ゲージMAX時はどちらのクリックでも必殺技発動
  if (specialReady && !specialActive && roundInProgress) {
    activateSpecialMove();
    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 (specialActive) 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;
  if (controlMode === 'mouse') {
    let movedByKey = false;
    const speed = Math.round(30 * 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;
  // 移動限界: x広め、z=0〜700(より自由に歩き回れる)
  playerPos.x = Math.max(-1800, Math.min(1800, playerPos.x));
  playerPos.z = Math.max(0, Math.min(700, 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.10) return 'head';
  return 'body';
}

const PART_CONFIG = {
  head:  { multi: 2.0, label: 'HEAD',  color: '#ff0055' },
  body:  { multi: 1.2, label: 'BODY',  color: '#ff8800' },
  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 absDz = Math.abs(attackerPos.z - defenderPos.z);

  if (absDz > 560)             return { hit: false, reason: 'MISS (TOO FAR)' };
  if (dx > 320)                return { hit: false, reason: 'MISS (DODGED)' };
  if (isDefenderBlocking)      return { hit: false, reason: 'GUARD!' };

  // CLEAN HIT条件: 適正距離かつ横ズレ少ない
  const isClean = (absDz >= 40 && absDz <= 460 && dx < 200);
  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 };
}

// ─── ヒットゾーンインジケーター ────────────────────────────────
// 距離帯の定義: どの距離でどの部位が光るか(足への攻撃は反則のため廃止)
const HIT_ZONE_BANDS = [
  // { minDz, maxDz, maxDx, part, color, hint }
  { minDz:  60, maxDz: 220, maxDx: 160, part: 'head',  color: '#ff0055', hint: '⬆ クリックでHEAD打撃!' },
  { minDz: 220, maxDz: 420, maxDx: 200, part: 'body',  color: '#ff8800', hint: '⬆ クリックでBODY打撃!' },
  // 距離があっても横ズレ少なければ当たる(弱)
  { minDz: 420, maxDz: 560, maxDx: 280, part: 'body',  color: '#00f3ff', hint: '距離を縮めましょう' },
];

// 部位名→ハイライト対象メッシュIDのマップ(足は含まない)
const PART_MESH_IDS = {
  head:  ['e_head', 'e_neck'],
  body:  ['e_chest', 'e_abdomen', 'e_shoulder', 'e_necktorso'],
  larm:  ['e_uarm_l', 'e_larm_l', 'e_elbow_l', 'e_glove_l'],
  rarm:  ['e_uarm_r', 'e_larm_r', 'e_elbow_r', 'e_glove_r'],
};

// 光らせるマテリアルキャッシュ
const _hlMats = {};
function getHighlightMat(hexColor) {
  if (_hlMats[hexColor]) return _hlMats[hexColor];
  const c = parseInt(hexColor.replace('#',''), 16);
  _hlMats[hexColor] = new THREE.MeshStandardMaterial({
    color: c, emissive: c, emissiveIntensity: 1.6, roughness: 0.2
  });
  return _hlMats[hexColor];
}

// ハイライトパルス更新(毎フレームgameLoopから呼ぶ)
function updateHighlightPulse() {
  if (highlightedMeshIds.length === 0) return;
  const pulse = 1.2 + Math.sin(Date.now() * 0.008) * 0.8; // 0.4〜2.0でパルス
  highlightedMeshIds.forEach(id => {
    if (meshes[id] && meshes[id].material && meshes[id].material.emissiveIntensity !== undefined) {
      meshes[id].material.emissiveIntensity = pulse;
    }
  });
}

function updateHitZoneIndicator() {
  if (!gameRunning || !roundInProgress || specialActive) {
    _clearHitZoneHighlight();
    document.getElementById('hit-zone-indicator').style.opacity = '0';
    return;
  }

  const absDz = Math.abs(enemyPos.z - playerPos.z);
  const dx = Math.abs(playerPos.x - enemyPos.x);

  // 現在の距離帯を検索(絶対距離ベース、evaluateHitと一致)
  let band = null;
  for (const b of HIT_ZONE_BANDS) {
    if (absDz >= b.minDz && absDz < b.maxDz && dx <= b.maxDx) {
      band = b; break;
    }
  }

  const bandKey = band ? `${band.part}_${Math.floor(absDz / 60)}` : null;

  // 距離帯が変わったときだけハイライトを切り替え
  if (bandKey !== prevHitZoneBand) {
    prevHitZoneBand = bandKey;
    _clearHitZoneHighlight();

    if (band) {
      currentHitZone = band;
      // 3Dメッシュをハイライト
      const ids = PART_MESH_IDS[band.part] || [];
      const hlMat = getHighlightMat(band.color);
      ids.forEach(id => {
        if (meshes[id]) {
          meshOrigMaterials[id] = meshes[id].material;
          meshes[id].material = hlMat;
          highlightedMeshIds.push(id);
        }
      });
      // UI表示
      const cfg = PART_CONFIG[band.part] || PART_CONFIG.body;
      const hzVal = document.getElementById('hz-value');
      const hzHint = document.getElementById('hz-hint');
      hzVal.textContent = cfg.label;
      hzVal.style.color = band.color;
      hzHint.textContent = band.hint;
      document.getElementById('hit-zone-indicator').style.opacity = '1';
    } else {
      currentHitZone = null;
      document.getElementById('hz-value').textContent = '─';
      document.getElementById('hz-hint').textContent = '距離を調整してください';
      document.getElementById('hit-zone-indicator').style.opacity = '0.5';
    }
  }
}

function _clearHitZoneHighlight() {
  highlightedMeshIds.forEach(id => {
    if (meshes[id] && meshOrigMaterials[id]) {
      meshes[id].material = meshOrigMaterials[id];
    }
  });
  highlightedMeshIds = [];
  meshOrigMaterials = {};
}

function tryPunch(side, speed) {
  if (specialActive) return; // 必殺技発動中は通常パンチ無効
  // ヒットゾーン内なら対応部位を狙う、それ以外はaimYから判定
  const targetPart = (currentHitZone && roundInProgress) ? currentHitZone.part : 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 {
    // baseDmg: speed250×staminaDebuff→max10→×部位倍率 (削れすぎを防ぐため係数を0.02に)
    const baseDmg = Math.max(4, Math.min(12, Math.floor(effectiveSpeed * 0.02)));
    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);
    // 必殺技ゲージをヒットごとに増加 (CLEAN HITは多め・増加速度UP)
    updateSpecialGauge(result.isClean ? 25 : 14);
    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);
}

function hitEnemyVisual(dmg, ox, oy, isCleanHit, part) {
  part = part || 'body';
  playSound(isCleanHit ? 'clean' : 'hit');
  combo++;
  // スコアはコンボ倍率を乗算するが、HPダメージ自体はコンボ倍率なし(既にenemy.hpから引いた後に呼ばれる)
  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();
  // HPがゼロになっていたら確実にKO処理(シングルモード)
  if (gameMode !== 'multi' && enemy.hp <= 0 && roundInProgress) {
    onEnemyKO();
  }
}

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);
  // ダメージを受けると必殺技ゲージが少し増加(根性)速度UP
  updateSpecialGauge(isCleanHit ? 16 : 8);

  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, Math.random() < 0.3 ? 'head' : '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((6 + round * 1.5) * result.multi), result.isClean, Math.random() < 0.3 ? 'head' : 'body'); }
    }
    // 敵必殺技モーション中は通常AI終了
    if (enemy.state.startsWith('e_special')) {
      if (enemy.stateTimer <= 0) { enemy.state = 'idle'; enemySpecialActive = false; }
      enemy.hurtShake *= 0.78;
      enemyPos.x += (enemy.targetX - enemyPos.x) * 0.14;
      enemyPos.z += (enemy.targetZ - enemyPos.z) * 0.06;
      return;
    }
    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;
  // プレイヤーの利き腕側に回り込む(dodge offset縮小、過度な回り込み防止)
  let dodgeOffset = (leftRatio - 0.5) * 200;

  // ヒット有効absDz: 0〜520(evaluateHitと一致)。中心240に近づくよう追従
  const targetDist = 220 + Math.sin(Date.now() * 0.0008) * 60; // 160〜280でゆらぎ→確実にヒット圏内
  const approachSpeed = enemy.state === 'idle' ? 0.12 : 0.18;

  if (enemy.state === 'idle') {
    enemy.targetX = playerPos.x + dodgeOffset + Math.sin(Date.now() * 0.001) * 120;
    enemy.targetZ = playerPos.z + targetDist;
  } else if (enemy.state.includes('windup')) {
    // 振りかぶり: プレイヤー正面に近づく
    enemy.targetX = playerPos.x + (Math.random() - 0.5) * 80;
    enemy.targetZ = playerPos.z + targetDist - 20;
  } else if (enemy.state.includes('punch')) {
    // パンチ中: さらに踏み込む
    enemy.targetX = playerPos.x;
    enemy.targetZ = playerPos.z + 120;
  }

  enemyPos.x += (enemy.targetX - enemyPos.x) * approachSpeed;
  enemyPos.z += (enemy.targetZ - enemyPos.z) * approachSpeed;

  enemy.attackTimer++;
  if (enemy.attackTimer >= enemy.attackInterval && enemy.state === 'idle') {
    // ヒット有効距離に入っているときのみ攻撃(外からパンチ空振りを防ぐ)
    const curAbsDz = Math.abs(enemyPos.z - playerPos.z);
    const curDx = Math.abs(enemyPos.x - playerPos.x);
    if (curAbsDz <= 480 && curDx <= 280) {
      enemy.attackTimer = 0;
      const enemyHpRatio = enemy.hp / enemy.maxHP;
      const specialChance = (round >= 2 ? 0.18 : 0.08) + (enemyHpRatio < 0.35 ? 0.20 : 0);
      if (!enemySpecialActive && Math.random() < specialChance) {
        triggerEnemySpecial();
      } else {
        enemy.nextPunch = Math.random() > 0.5 ? 'R' : 'L';
        enemy.state = 'windup' + enemy.nextPunch;
        enemy.stateTimer = 44;
        playSound('swing');
      }
    }
    // 範囲外なら攻撃タイマーを少し巻き戻して待機
    else if (enemy.attackTimer >= enemy.attackInterval + 30) {
      enemy.attackTimer = enemy.attackInterval - 20;
    }
  }
}

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; }
  stopP2PSyncLoop();
  _clearHitZoneHighlight();
  currentHitZone = null; prevHitZoneBand = null;
  document.getElementById('hit-zone-indicator').style.opacity = '0';
  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';

  // 2秒後にリトライ選択UIを表示
  setTimeout(() => {
    msg.style.opacity = '0';
    // オーバーレイを使ってリトライUIを表示
    const overlay = document.getElementById('overlay');
    const panels = overlay.querySelectorAll('.menu-panel');
    panels.forEach(p => p.style.display = 'none');

    // リトライパネルを動的に作成(既存があれば再利用)
    let retryPanel = document.getElementById('menu-retry');
    if (!retryPanel) {
      retryPanel = document.createElement('div');
      retryPanel.id = 'menu-retry';
      retryPanel.className = 'menu-panel';
      overlay.appendChild(retryPanel);
    }
    const winColor = isWin ? '#00ff88' : '#ff0055';
    const winText  = label || (isWin ? '🏆 YOU WIN!' : '💀 K.O.');
    retryPanel.innerHTML = `
      <h3 style="color:${winColor}; font-size:2.2rem; margin-bottom:18px; text-shadow:0 0 20px ${winColor};">${winText}</h3>
      <p style="color:#ccc; margin-bottom:24px;">もう一度戦いますか?</p>
      <button class="btn" onclick="location.reload()">🔄 リトライ</button>
      <button class="btn" style="border-color:#555;color:#aaa;" onclick="showMenu('menu-main')">🏠 タイトルに戻る</button>
    `;
    retryPanel.style.display = 'block';
    overlay.style.display = 'flex';
  }, 2000);
}

const meshes = {};
function updateMesh(id, type, p1, p2, radius, mat) {
  const tx = p1.x, ty = -p1.y, tz = -p1.z;
  // ハイライト中のメッシュはマテリアルを上書きしない
  const isHL = highlightedMeshIds.includes(id);
  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(!isHL && 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(!isHL && 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 isEnemySpecial = enemy.state.startsWith('e_special');
  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;  // 攻撃時はプレイヤー方向(z-)に踏み込む
  const rootX = enemyPos.x + sx, rootZ = enemyPos.z + sz + stepZ;      // z+stepZは後で方向反転

  // プレイヤー方向はz-(ゲーム座標系でenemyPos.z > playerPos.z なので z-がプレイヤー向き)
  // 踏み込みはz-方向: rootZ = enemyPos.z - stepZ
  const rootZAdj = enemyPos.z + sz - stepZ;  // 攻撃時はz-方向(プレイヤー向き)に踏み込む

  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);
  // 敵の前方はz-方向(プレイヤー向き)。hurt時の反りはz-に向かう(前のめり)
  const waistZ = rootZAdj + waistRiseZ * 0.3, chestZ = rootZAdj + archAmount * 0.6, shoulderZ = rootZAdj + archAmount * 0.85, neckBaseZ = rootZAdj + archAmount * 0.95, neckTopZ = rootZAdj + archAmount + headExtra * 0.3, headZ = rootZAdj + 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: rootZAdj }, lhip: { x: rootX - 65, y: hipY, z: rootZAdj }, rhip: { x: rootX + 65, y: hipY, z: rootZAdj },
    lknee: { x: rootX - 75, y: kneeY, z: rootZAdj - footStep + kneeBend * 1.2 }, rknee: { x: rootX + 75, y: kneeY, z: rootZAdj + footStep + kneeBend * 0.8 },
    lfoot: { x: rootX - 80, y: footY, z: rootZAdj - footStep - 15 }, rfoot: { x: rootX + 80, y: footY, z: rootZAdj + footStep - 15 },
  };
  const lsZ2 = joints.ls.z, rsZ2 = joints.rs.z;

  if (isRecoil) {
    // のけぞり: 腕を後方(z+、プレイヤーと反対方向)に跳ね上げる
    joints.le={x:rootX-170,y:shoulderY-40,z:shoulderZ-50}; joints.lw={x:rootX-290,y:shoulderY-130,z:shoulderZ-130};
    joints.re={x:rootX+170,y:shoulderY-40,z:shoulderZ-50}; joints.rw={x:rootX+290,y:shoulderY-130,z:shoulderZ-130};
  } else if (isBlocking) {
    // ガード: 腕を前方(z-、プレイヤー向き)に張り出す
    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) {
    // 振りかぶり: 打つ腕を後方(z+)に引き、逆腕はガード(z-前方)
    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) {
    // パンチ: 前方(z-、プレイヤー向き)にリーチを伸ばす
    const punchReach = playerBlocking ? rootZAdj - 200 : rootZAdj - 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 (enemy.state === 'e_special_smash') {
    // 敵必殺技: 叩きつけ(腕を前方z-方向に振り下ろし)
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const sp = Math.sin(prog * Math.PI);
    joints.le = {x:rootX-170, y:shoulderY-100-sp*200, z:shoulderZ-60+prog*350};
    joints.lw = {x:rootX- 80, y:shoulderY-180-sp*260, z:shoulderZ-20+prog*600};
    joints.re = {x:rootX+170, y:shoulderY-100-sp*200, z:shoulderZ-60+prog*350};
    joints.rw = {x:rootX+ 80, y:shoulderY-180-sp*260, z:shoulderZ-20+prog*600};
    joints.lknee = {x:rootX-75, y:kneeY+sp*60, z:rootZAdj-footStep};
    joints.rknee = {x:rootX+75, y:kneeY+sp*60, z:rootZAdj+footStep};
  } else if (enemy.state === 'e_special_rush') {
    // 敵必殺技: ラッシュ(前方z-方向への高速交互突き)
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const altL = Math.sin(prog * Math.PI * 5);
    const altR = Math.sin(prog * Math.PI * 5 + Math.PI);
    const reachZ = rootZAdj - 550;
    joints.le = {x:rootX-110, y:shoulderY+20+altL*25, z:shoulderZ-40+Math.max(0,altL)*80};
    joints.lw = {x:rootX- 60, y:shoulderY+10+altL*15, z:reachZ+Math.max(0,altL)*100};
    joints.re = {x:rootX+110, y:shoulderY+20+altR*25, z:shoulderZ-40+Math.max(0,altR)*80};
    joints.rw = {x:rootX+ 60, y:shoulderY+10+altR*15, z:reachZ+Math.max(0,altR)*100};
  } else if (enemy.state === 'e_special_upper') {
    // 敵必殺技: アッパー(しゃがみ→前方z-方向への突き上げ)
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const crouch = Math.max(0, 1 - prog * 2.5);
    const rise   = Math.max(0, prog * 2 - 0.4);
    const sp = Math.sin(prog * Math.PI);
    joints.lknee = {x:rootX-75, y:kneeY+crouch*120, z:rootZAdj-footStep};
    joints.rknee = {x:rootX+75, y:kneeY+crouch*120, z:rootZAdj+footStep};
    joints.lfoot = {x:rootX-80, y:footY+crouch*80,  z:rootZAdj-footStep-15};
    joints.rfoot = {x:rootX+80, y:footY+crouch*80,  z:rootZAdj+footStep-15};
    joints.le = {x:rootX-140, y:shoulderY+70 -rise*300, z:shoulderZ+30};
    joints.lw = {x:rootX- 90, y:shoulderY+150-rise*450-sp*60, z:shoulderZ-60+rise*200};
    joints.re = {x:rootX+120, y:shoulderY+50 -rise*220, z:shoulderZ+20};
    joints.rw = {x:rootX+ 70, y:shoulderY+130-rise*400, z:shoulderZ-40+rise*160};
  } else if (isHurt) {
    // 被弾: 腕が後方(z+、プレイヤーと反対)に開く
    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 {
    // アイドル: 両腕を前方(z-、プレイヤー向き)で構える
    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 isSpecial   = playerAction.startsWith('special_');
  // 必殺技進行度 (0→1)
  const specMove    = isSpecial ? SPECIAL_MOVES.find(m => m.motion === playerAction) : null;
  const specProg    = isSpecial && specMove ? 1.0 - (specialTimer / specMove.duration) : 0;

  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;

  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 if (isSpecial) {
    // 必殺技モーション: 技ごとに異なるポーズ
    const sp  = Math.sin(specProg * Math.PI); // 0→1→0 の弧 (ピーク=中盤)
    const sp2 = Math.min(1, specProg * 2);    // 前半で1に到達
    if (playerAction === 'special_meteor') {
      // 両腕を大きく振り下ろす
      joints.le = { x: rootX - 160, y: shoulderY - 120 - sp * 200, z: shoulderZ - 80 + sp2 * 300 };
      joints.lw = { x: rootX -  60, y: shoulderY - 200 - sp * 250, z: shoulderZ - 40 + sp2 * 500 };
      joints.re = { x: rootX + 160, y: shoulderY - 120 - sp * 200, z: shoulderZ - 80 + sp2 * 300 };
      joints.rw = { x: rootX +  60, y: shoulderY - 200 - sp * 250, z: shoulderZ - 40 + sp2 * 500 };
      joints.lknee = { x: rootX - 70, y: kneeY + sp * 40, z: rootZ + footStep + sp * 60 };
      joints.rknee = { x: rootX + 70, y: kneeY + sp * 40, z: rootZ - footStep - sp * 60 };
    } else if (playerAction === 'special_twin') {
      // 両腕を素早く前に突き出す (左右交互)
      const altL = Math.sin(specProg * Math.PI * 4);
      const altR = Math.sin(specProg * Math.PI * 4 + Math.PI);
      joints.le = { x: rootX - 100, y: shoulderY + 20 + altL * 30, z: shoulderZ + 40 + Math.max(0, altL) * 100 };
      joints.lw = { x: rootX -  40, y: shoulderY + 10 + altL * 20, z: shoulderZ + 200 + Math.max(0, altL) * 300 };
      joints.re = { x: rootX + 100, y: shoulderY + 20 + altR * 30, z: shoulderZ + 40 + Math.max(0, altR) * 100 };
      joints.rw = { x: rootX +  40, y: shoulderY + 10 + altR * 20, z: shoulderZ + 200 + Math.max(0, altR) * 300 };
    } else if (playerAction === 'special_rising') {
      // 下からのアッパー: しゃがみ→立ち上がりながら打ち上げ
      const crouch = Math.max(0, 1 - specProg * 2.5);
      const rise   = Math.max(0, specProg * 2 - 0.4);
      joints.lknee = { x: rootX - 70, y: kneeY + crouch * 120, z: rootZ + footStep };
      joints.rknee = { x: rootX + 70, y: kneeY + crouch * 120, z: rootZ - footStep };
      joints.lfoot = { x: rootX - 75, y: footY + crouch * 80,  z: rootZ + footStep };
      joints.rfoot = { x: rootX + 75, y: footY + crouch * 80,  z: rootZ - footStep };
      joints.le = { x: rootX - 120, y: shoulderY + 80  - rise * 280, z: shoulderZ - 60 };
      joints.lw = { x: rootX -  80, y: shoulderY + 150 - rise * 400 - sp * 50, z: shoulderZ + 50 + rise * 200 };
      joints.re = { x: rootX + 100, y: shoulderY + 50  - rise * 200, z: shoulderZ - 30 };
      joints.rw = { x: rootX +  60, y: shoulderY + 120 - rise * 350, z: shoulderZ + 80 + rise * 150 };
    } else {
      // フォールバック (未知の必殺技)
      joints.le = { x: rootX - 160, y: shoulderY - 100, z: shoulderZ + 200 };
      joints.lw = { x: rootX -  60, y: shoulderY - 150, z: shoulderZ + 450 };
      joints.re = { x: rootX + 160, y: shoulderY - 100, z: shoulderZ + 200 };
      joints.rw = { x: rootX +  60, y: shoulderY - 150, z: shoulderZ + 450 };
    }
  } 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 }; 
  }

  if (controlMode === 'camera' && !isAttacking && !isWindup && !isRecoil && !isHurt && !isSpecial) {
    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--;

  // ヒットゾーンインジケーター更新(距離変化で部位ハイライト)
  updateHitZoneIndicator();
  // ハイライトパルスアニメーション
  updateHighlightPulse();

  // 必殺技タイマー更新
  if (specialActive && specialTimer > 0) {
    specialTimer--;
    if (specialTimer <= 0) { specialActive = false; specialMotion = ''; if (playerAction.startsWith('special_')) playerAction = 'idle'; }
  }

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

  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