カメラで対戦する「MotionBoxing」


【更新履歴】

 ・2026/3/12 バージョン1.0公開。
          (中略)
 ・2026/3/12 バージョン1.5公開。(複数人対戦を追加)
 ・2026/3/12 バージョン1.6公開。(必殺技を追加)
 ・2026/3/13 バージョン1.7公開。
 ・2026/3/13 バージョン1.8公開。
 ・2026/3/18 デスクトップ版1.0公開。(AIアシスト)
 ・2026/6/15 バージョン1.9公開。(見た目を改善)

画像

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

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


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


《セットアップ手順》


※デスクトップ版には、whisper.binが含まれていません。
 ファイル名を「ggml-base.bin」に変更して、
 実行ファイルのあるフォルダ内に配置してください。

※音声で入力する場合は、このツールの実行ファイルのあるフォルダ内に
 whisper.netモデルファイルを置いておく必要があります。↓

ggml-base.binをダウンロードして、
 ファイル名を「whisper.bin」に変更してください。

※音声を出力する場合は、voiceboxを起動しておいてください。↓


・デスクトップ版で使用することができるVRMについては、
 フリー素材で配布されているものがあります。↓


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

<!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: [],
    duration: 50,
  },
  {
    name: 'BODY BLOW',        // ボディブロー → 胴体狙い
    motion: 'special_twin',
    effectColor: '#ff8800',
    description: '左右交互に胴体へ連打!中距離で有効',
    targetPart: 'body',
    baseDamage: 22,
    minDz: 60,
    maxDz: 480,
    rangeBonus: 60,
    knockback: 20,
    extraHits: ['body', 'body'],
    duration: 60,
  },
];

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.duration || (move.motion === 'special_rising' ? 50 : 60);
  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(0x050510, 0.00035);
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 1, 15000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); // 高解像度端末でも性能を確保
renderer.setClearColor(0x050510, 1);
renderer.outputEncoding = THREE.sRGBEncoding;
document.getElementById('threejs-container').appendChild(renderer.domElement);

// ── ライティング: アリーナのリムライトで筋肉のシルエットを立たせる ──
const ambient = new THREE.AmbientLight(0x6070a0, 0.55); scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.05);   // 前上からのキーライト
keyLight.position.set(200, 1400, 900); scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x99bbff, 0.35);  // 柔らかいフィル
fillLight.position.set(-600, 600, -400); scene.add(fillLight);
const rimCyan = new THREE.DirectionalLight(0x00f3ff, 0.9);     // 青リム(左)
rimCyan.position.set(-1200, 500, -700); scene.add(rimCyan);
const rimMagenta = new THREE.DirectionalLight(0xff2266, 0.8);  // 赤リム(右)
rimMagenta.position.set(1200, 500, -700); scene.add(rimMagenta);
const pointLight = new THREE.PointLight(0x00f3ff, 0.9, 3500); scene.add(pointLight);

// ── 床: リングマット+ネオングリッド(足元 render-y ≈ -620 に合わせる)──
const FLOOR_Y = -620;
const ringMat = new THREE.Mesh(new THREE.PlaneGeometry(5200, 5200), new THREE.MeshStandardMaterial({
  color: 0x0a0e1a, roughness: 0.9, metalness: 0.1
}));
ringMat.rotation.x = -Math.PI / 2; ringMat.position.y = FLOOR_Y - 5; scene.add(ringMat);
// マット縁の発光ライン
const ringEdge = new THREE.Mesh(new THREE.RingGeometry(2050, 2150, 72), new THREE.MeshBasicMaterial({
  color: 0x00f3ff, transparent: true, opacity: 0.45, side: THREE.DoubleSide
}));
ringEdge.rotation.x = -Math.PI / 2; ringEdge.position.y = FLOOR_Y - 1; scene.add(ringEdge);

const grid = new THREE.GridHelper(20000, 80, 0x00f3ff, 0x002233);
grid.position.y = FLOOR_Y; grid.material.opacity = 0.22; grid.material.transparent = true;
scene.add(grid);

// ── ブロブシャドウ(簡易接地影): 影マップを使わず軽量に接地感を出す ──
const blobShadowTex = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 128;
  const g = c.getContext('2d');
  const grad = g.createRadialGradient(64, 64, 4, 64, 64, 64);
  grad.addColorStop(0, 'rgba(0,0,0,0.55)');
  grad.addColorStop(0.6, 'rgba(0,0,0,0.28)');
  grad.addColorStop(1, 'rgba(0,0,0,0)');
  g.fillStyle = grad; g.fillRect(0, 0, 128, 128);
  return new THREE.CanvasTexture(c);
})();
function makeBlobShadow() {
  const m = new THREE.Mesh(
    new THREE.PlaneGeometry(440, 440),
    new THREE.MeshBasicMaterial({ map: blobShadowTex, transparent: true, depthWrite: false })
  );
  m.rotation.x = -Math.PI / 2; m.position.y = FLOOR_Y + 2; scene.add(m); return m;
}
const playerShadow = makeBlobShadow();
const enemyShadow  = makeBlobShadow();

const materials = {
  normal:      new THREE.MeshStandardMaterial({ color: 0xe0ac69, roughness: 0.55, metalness: 0.05 }),
  glove:       new THREE.MeshStandardMaterial({ color: 0xd1003c, roughness: 0.35, metalness: 0.1 }),
  gloveTrim:   new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.4 }),
  hair:        new THREE.MeshStandardMaterial({ color: 0x1a1612, roughness: 0.7 }),
  eye:         new THREE.MeshStandardMaterial({ color: 0x111118, roughness: 0.3 }),
  playerBody:  new THREE.MeshStandardMaterial({ color: 0x2a72c8, roughness: 0.45, metalness: 0.08 }),
  playerArm:   new THREE.MeshStandardMaterial({ color: 0x2a72c8, roughness: 0.45, metalness: 0.08 }),
  playerGlove: new THREE.MeshStandardMaterial({ color: 0x00d8e8, roughness: 0.25, metalness: 0.1, emissive: 0x007788, emissiveIntensity: 0.35 }),
  playerHair:  new THREE.MeshStandardMaterial({ color: 0x14202e, roughness: 0.7 }),
  playerPants: new THREE.MeshStandardMaterial({ color: 0x0d1626, roughness: 0.85 }),
  playerTrim:  new THREE.MeshStandardMaterial({ color: 0x00f3ff, roughness: 0.3, emissive: 0x00aacc, emissiveIntensity: 0.4 }),
  pants:       new THREE.MeshStandardMaterial({ color: 0x141414, roughness: 0.85 }),
  enemyTrim:   new THREE.MeshStandardMaterial({ color: 0xff0044, roughness: 0.3, emissive: 0xaa0022, emissiveIntensity: 0.4 }),
  shoe:        new THREE.MeshStandardMaterial({ color: 0xe8e8ee, roughness: 0.5 }),
  shoeP:       new THREE.MeshStandardMaterial({ color: 0xdfe8ff, roughness: 0.5 }),
  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 };
  resetFighterJoints();
  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 = {};

// ─── 共有ジオメトリ(割り当て削減のためキャッシュ)─────────────────
const UNIT_SPHERE   = new THREE.SphereGeometry(1, 20, 16);
const UNIT_ICO      = new THREE.IcosahedronGeometry(1, 1);
// テーパー付き円柱はボーンごとに比率が一定なのでキャッシュ
const _boneGeoCache = {};
function getBoneGeo(rTop, rBot) {
  const key = rTop.toFixed(3) + '_' + rBot.toFixed(3);
  if (!_boneGeoCache[key]) {
    const g = new THREE.CylinderGeometry(rTop, rBot, 1, 16, 1, false);
    g.computeVertexNormals();
    _boneGeoCache[key] = g;
  }
  return _boneGeoCache[key];
}

// ─── スクラッチベクトル(毎フレームのGCを防止)─────────────────────
const _sv1 = new THREE.Vector3();
const _sv2 = new THREE.Vector3();
const _svDir = new THREE.Vector3();
const _svUp = new THREE.Vector3(0, 1, 0);
const _svFwd = new THREE.Vector3(0, 0, 1);
const _svQ = new THREE.Quaternion();

function pickSphereGeo(mat) {
  return mat === materials.crystal ? UNIT_ICO : UNIT_SPHERE;
}

// 球メッシュ(関節・筋肉・頭部など)。任意の非一様スケールと整列に対応
function setSphere(id, p, r, mat, scl) {
  let m = meshes[id];
  const geo = pickSphereGeo(mat);
  if (!m) { m = new THREE.Mesh(geo, mat); scene.add(m); meshes[id] = m; }
  const isHL = highlightedMeshIds.includes(id);
  if (!isHL && m.material !== mat) { m.material = mat; if (m.geometry !== geo) m.geometry = geo; }
  m.position.set(p.x, -p.y, -p.z);
  m.quaternion.identity();
  if (scl) m.scale.set(r * scl.x, r * scl.y, r * scl.z);
  else     m.scale.set(r, r, r);
  return m;
}

// テーパー付きボーン(p1→p2をつなぐ)。rA=p1側半径, rB=p2側半径
function setBone(id, p1, p2, rA, rB, mat) {
  let m = meshes[id];
  if (!m) {
    m = new THREE.Mesh(getBoneGeo(rB, rA), mat); // top(+y)=p2側=rB, bottom(-y)=p1側=rA
    scene.add(m); meshes[id] = m;
    m.userData.rA = rA; m.userData.rB = rB;
  }
  const isHL = highlightedMeshIds.includes(id);
  if (!isHL && m.material !== mat) m.material = mat;
  _sv1.set(p1.x, -p1.y, -p1.z);
  _sv2.set(p2.x, -p2.y, -p2.z);
  _svDir.subVectors(_sv2, _sv1);
  const dist = _svDir.length();
  if (dist < 0.001) return;
  _svDir.multiplyScalar(1 / dist);
  m.position.set((_sv1.x + _sv2.x) * 0.5, (_sv1.y + _sv2.y) * 0.5, (_sv1.z + _sv2.z) * 0.5);
  m.quaternion.setFromUnitVectors(_svUp, _svDir);
  m.scale.set(1, dist, 1);
}

// 線分上の補間点(筋肉のバルジ配置に使用)
function lerpPt(a, b, t) {
  return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t };
}

// ─── ジョイント補間(スナップ防止=動きの重み付け)─────────────────
// 各ファイターの「現在描画されているジョイント」を保持し、目標へ滑らかに追従させる
const fighterJointState = { p: null, e: null };
function smoothJoints(stateKey, target, armK, bodyK) {
  let cur = fighterJointState[stateKey];
  if (!cur) { // 初回はスナップ
    cur = {}; for (const k in target) cur[k] = { x: target[k].x, y: target[k].y, z: target[k].z };
    fighterJointState[stateKey] = cur; return cur;
  }
  const ARM = { le:1, re:1, lw:1, rw:1, ls:1, rs:1 };
  for (const k in target) {
    if (!cur[k]) { cur[k] = { x: target[k].x, y: target[k].y, z: target[k].z }; continue; }
    const k2 = ARM[k] ? armK : bodyK;
    cur[k].x += (target[k].x - cur[k].x) * k2;
    cur[k].y += (target[k].y - cur[k].y) * k2;
    cur[k].z += (target[k].z - cur[k].z) * k2;
  }
  return cur;
}
function resetFighterJoints() { fighterJointState.p = null; fighterJointState.e = null; }

// ─── 共有ファイター描画 ───────────────────────────────────────────
// prefix: 'p' or 'e' / J: 補間済みジョイント / pal: マテリアルパレット
// faceDir: 相手方向のgameZ符号(+1 player, -1 enemy)
function renderFighter(prefix, J, pal, faceDir, shadowMesh, rootX, rootZ) {
  const id = (s) => prefix + '_' + s;

  // 肩中心・胸中心
  const shoulderC = { x: (J.ls.x + J.rs.x) / 2, y: J.ls.y, z: (J.ls.z + J.rs.z) / 2 };

  // ── 胴体(テーパーで自然な体型に)──
  setBone(id('necktorso'), J.neckBase, shoulderC, 26, 40, pal.skin);
  setBone(id('shoulder'),  J.ls, J.rs, 30, 30, pal.skin);          // 鎖骨ライン(ハイライト互換のため保持)
  setBone(id('chest'),     shoulderC, J.waist, 76, 58, pal.skin);  // 胸→腹(広い胸)
  setBone(id('abdomen'),   J.waist, J.hip, 56, 50, pal.skin);      // 腹筋部
  // 大胸筋(胸の前面、faceDir側に膨らみ)
  const pecY = J.ls.y + 38, pecZ = shoulderC.z + faceDir * 34;
  setSphere(id('pec_l'), { x: shoulderC.x - 34, y: pecY, z: pecZ }, 34, pal.skin, { x:1, y:0.82, z:0.78 });
  setSphere(id('pec_r'), { x: shoulderC.x + 34, y: pecY, z: pecZ }, 34, pal.skin, { x:1, y:0.82, z:0.78 });

  // ── トランクス(腰回り+短パン)──
  setBone(id('pelvis'), J.lhip, J.rhip, 54, 54, pal.trunks);
  // 腰のウエストバンド(発光トリム)
  const beltC = lerpPt(J.waist, J.hip, 0.78);
  setSphere(id('belt'), beltC, 56, pal.trim, { x:1.0, y:0.18, z:0.92 });
  // トランクスの脚部(太もも上40%を覆う)
  const trunkLkneeMix = lerpPt(J.lhip, J.lknee, 0.42);
  const trunkRkneeMix = lerpPt(J.rhip, J.rknee, 0.42);
  setBone(id('trunk_l'), J.lhip, trunkLkneeMix, 50, 44, pal.trunks);
  setBone(id('trunk_r'), J.rhip, trunkRkneeMix, 50, 44, pal.trunks);

  // ── 首・頭部 ──
  setBone(id('neck'), J.neckTop, J.neckBase, 24, 30, pal.skin);
  // 頭(卵型)
  setSphere(id('head'), J.head, 52, pal.skin, { x:0.94, y:1.06, z:1.0 });
  // 髪(後頭部〜頭頂を覆うキャップ)
  const hairP = { x: J.head.x, y: J.head.y - 14, z: J.head.z - faceDir * 12 };
  setSphere(id('hair'), hairP, 50, pal.hair, { x:0.96, y:0.86, z:1.0 });
  // 顎
  setSphere(id('jaw'), { x: J.head.x, y: J.head.y + 34, z: J.head.z + faceDir * 16 }, 30, pal.skin, { x:0.8, y:0.62, z:0.78 });
  // 眉(相手側の顔面)
  const browY = J.head.y - 6, faceZ = J.head.z + faceDir * 46;
  setSphere(id('brow'), { x: J.head.x, y: browY, z: faceZ - faceDir * 4 }, 30, pal.hair, { x:0.92, y:0.22, z:0.4 });
  // 目
  setSphere(id('eye_l'), { x: J.head.x - 17, y: browY + 12, z: faceZ }, 8, pal.eye, { x:1, y:1.2, z:0.7 });
  setSphere(id('eye_r'), { x: J.head.x + 17, y: browY + 12, z: faceZ }, 8, pal.eye, { x:1, y:1.2, z:0.7 });

  // ── 腕(三角筋→上腕→肘→前腕→グローブ)──
  function arm(side) {
    const s = side === 'l' ? J.ls : J.rs;
    const e = side === 'l' ? J.le : J.re;
    const w = side === 'l' ? J.lw : J.rw;
    setSphere(id('delt_' + side), s, 40, pal.skin);                 // 三角筋
    setBone(id('uarm_' + side), s, e, 36, 30, pal.skin);            // 上腕
    setSphere(id('bicep_' + side), lerpPt(s, e, 0.5), 32, pal.skin, { x:1.05, y:1.0, z:1.05 }); // 力こぶ
    setSphere(id('elbow_' + side), e, 27, pal.skin);               // 肘
    setBone(id('larm_' + side), e, w, 27, 22, pal.skin);           // 前腕(手首に向けテーパー)
    // 前腕方向を算出(グローブ・カフの整列に使用)
    _sv1.set(e.x, -e.y, -e.z); _sv2.set(w.x, -w.y, -w.z);
    _svDir.subVectors(_sv2, _sv1);
    const hasDir = _svDir.lengthSq() > 0.001;
    if (hasDir) { _svDir.normalize(); _svQ.setFromUnitVectors(_svFwd, _svDir); }
    // グローブ(前腕方向に伸びた丸み)
    const gm = setSphere(id('glove_' + side), w, 50, pal.glove, { x:0.95, y:0.95, z:1.18 });
    if (hasDir) gm.quaternion.copy(_svQ);
    // グローブの白トリム(手首側のカフ)
    const cm = setSphere(id('gtrim_' + side), lerpPt(w, e, 0.34), 36, pal.gloveTrim, { x:1.0, y:1.0, z:0.5 });
    if (hasDir) cm.quaternion.copy(_svQ);
  }
  arm('l'); arm('r');

  // ── 脚(臀部→太もも→膝→ふくらはぎ→シューズ)──
  function leg(side) {
    const h = side === 'l' ? J.lhip : J.rhip;
    const k = side === 'l' ? J.lknee : J.rknee;
    const f = side === 'l' ? J.lfoot : J.rfoot;
    setBone(id('uleg_' + side), h, k, 44, 32, pal.skin);                       // 太もも
    setSphere(id('knee_' + side), k, 30, pal.skin);                           // 膝
    setBone(id('lleg_' + side), k, f, 32, 20, pal.skin);                      // すね
    setSphere(id('calf_' + side), lerpPt(k, f, 0.32), 27, pal.skin, { x:1.0, y:1.1, z:1.05 }); // ふくらはぎ
    // シューズ(前方=faceDirへ伸びる)
    const sm = setSphere(id('foot_' + side), { x: f.x, y: f.y + 6, z: f.z + faceDir * 18 }, 26, pal.shoe, { x:0.92, y:0.7, z:1.5 });
    // シューズのトリム
    setSphere(id('ftrim_' + side), { x: f.x, y: f.y - 4, z: f.z }, 26, pal.trim, { x:0.9, y:0.22, z:1.1 });
  }
  leg('l'); leg('r');

  // ── 接地ブロブシャドウ ──
  if (shadowMesh) {
    shadowMesh.position.set(rootX, FLOOR_Y + 2, -rootZ);
    // しゃがみ等で足が開くと影を少し広げる
    const footSpread = Math.abs(J.lfoot.x - J.rfoot.x) / 140;
    shadowMesh.scale.set(footSpread * 1.05, 1.0, 1.3);
  }
}

// 敵の前方(プレイヤー向き)= gameZ の -方向。faceDir = -1
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 ? 300 * hurtProgress : 0, headExtra = isHurt ? 150 * hurtProgress : 0, kneeBend = isHurt ? 70 * hurtProgress : 0, waistRiseZ = isHurt ? 110 * hurtProgress : 0;

  // 揺れは体全体に一貫して適用(ゼリー状の崩れを防止)
  const shakeMag = enemy.hurtShake;
  const sx = (Math.random() - 0.5) * shakeMag, sy = (Math.random() - 0.5) * shakeMag * 0.6, sz = (Math.random() - 0.5) * shakeMag;

  // アイドルのバウンス(つま先で弾むボクサーらしい上下動)+ウィービング
  const bob = Math.sin(npcPhase * 1.6);
  const bobY = (enemy.state === 'idle' ? bob * 9 : bob * 4) + sy;
  const weaveX = enemy.state === 'idle' ? Math.sin(npcPhase * 0.7) * 14 : 0;
  const stepZ = isAttacking ? 90 : 0;

  const rootX = enemyPos.x + sx + weaveX;
  const rootZAdj = enemyPos.z + sz - stepZ;  // 攻撃時は前方(-z)へ踏み込む

  // 体格バランス(頭部小さめ・脚長め)
  const footY = 605 + bobY, kneeY = 432 + bobY + kneeBend, hipY = 235 + bobY, waistY = 150 + bobY + (isHurt ? 8 * hurtProgress : 0);
  const chestY = 35 + bobY + (isHurt ? -18 * hurtProgress : 0), shoulderY = -25 + bobY, neckBaseY = -62 + bobY, neckTopY = -118 + bobY, headCenterY = -182 + bobY + (isHurt ? -8 * hurtProgress : 0);
  // 反りは前方(-z)へ(前のめり)。被弾時のみ。
  const waistZ = rootZAdj - waistRiseZ * 0.3, chestZ = rootZAdj - archAmount * 0.55, shoulderZ = rootZAdj - archAmount * 0.8, neckBaseZ = rootZAdj - archAmount * 0.9, neckTopZ = rootZAdj - archAmount - headExtra * 0.3, headZ = rootZAdj - archAmount - headExtra;

  // バサミ足(サウスポー気味): 左足を前(-z)、右足を後ろ(+z)
  const stagger = 46;
  const footStepL = Math.sin(npcPhase) * 8;

  let J = {
    head: { x: rootX, y: headCenterY, z: headZ }, neckTop: { x: rootX, y: neckTopY, z: neckTopZ }, neckBase: { x: rootX, y: neckBaseY, z: neckBaseZ },
    ls: { x: rootX - 118, y: shoulderY, z: shoulderZ - 26 }, rs: { x: rootX + 118, y: shoulderY, z: shoulderZ + 26 },
    chest: { x: rootX, y: chestY, z: chestZ }, waist: { x: rootX, y: waistY, z: waistZ },
    hip: { x: rootX, y: hipY, z: rootZAdj }, lhip: { x: rootX - 56, y: hipY, z: rootZAdj - 14 }, rhip: { x: rootX + 56, y: hipY, z: rootZAdj + 14 },
    lknee: { x: rootX - 64, y: kneeY, z: rootZAdj - stagger + kneeBend }, rknee: { x: rootX + 70, y: kneeY, z: rootZAdj + stagger + kneeBend * 0.6 },
    lfoot: { x: rootX - 66, y: footY, z: rootZAdj - stagger - 12 + footStepL }, rfoot: { x: rootX + 74, y: footY, z: rootZAdj + stagger - 12 },
  };
  const lsZ2 = J.ls.z, rsZ2 = J.rs.z;

  if (isRecoil) {
    // のけぞり: 腕を後方(+z)に跳ね上げる
    J.le={x:rootX-160,y:shoulderY-40,z:shoulderZ+60}; J.lw={x:rootX-270,y:shoulderY-130,z:shoulderZ+150};
    J.re={x:rootX+160,y:shoulderY-40,z:shoulderZ+60}; J.rw={x:rootX+270,y:shoulderY-130,z:shoulderZ+150};
  } else if (isBlocking) {
    // ガード: 両グローブを顔の前(-z)へ。肘を締める。
    J.le={x:rootX-78,y:shoulderY+44,z:shoulderZ-70}; J.lw={x:rootX-40,y:headCenterY+30,z:headZ-58};
    J.re={x:rootX+78,y:shoulderY+44,z:shoulderZ-70}; J.rw={x:rootX+40,y:headCenterY+30,z:headZ-58};
  } else if (isWindup) {
    // 振りかぶり: 打つ腕を後方(+z)へ引き、逆腕は顔をガード
    if (isLeftPunch) {
      J.le={x:rootX-150,y:shoulderY+50,z:shoulderZ+170}; J.lw={x:rootX-128,y:shoulderY+24,z:shoulderZ+110};
      J.re={x:rootX+82,y:shoulderY+40,z:rsZ2-70}; J.rw={x:rootX+44,y:headCenterY+30,z:headZ-56};
    } else {
      J.le={x:rootX-82,y:shoulderY+40,z:lsZ2-70}; J.lw={x:rootX-44,y:headCenterY+30,z:headZ-56};
      J.re={x:rootX+150,y:shoulderY+50,z:shoulderZ+170}; J.rw={x:rootX+128,y:shoulderY+24,z:shoulderZ+110};
    }
  } else if (isAttacking) {
    // パンチ: 前方(-z)へリーチ。逆腕は顔をガード。
    const punchReach = playerBlocking ? rootZAdj - 220 : rootZAdj - 560; const punchOffX = isLeftPunch ? -50 : 50;
    if (isLeftPunch) {
      J.le={x:rootX-96,y:shoulderY+18,z:shoulderZ-150}; J.lw={x:rootX+punchOffX,y:headCenterY+30,z:punchReach};
      J.re={x:rootX+82,y:shoulderY+40,z:rsZ2-70}; J.rw={x:rootX+44,y:headCenterY+30,z:headZ-56};
    } else {
      J.le={x:rootX-82,y:shoulderY+40,z:lsZ2-70}; J.lw={x:rootX-44,y:headCenterY+30,z:headZ-56};
      J.re={x:rootX+96,y:shoulderY+18,z:shoulderZ-150}; J.rw={x:rootX+punchOffX,y:headCenterY+30,z:punchReach};
    }
  } else if (enemy.state === 'e_special_smash') {
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const sp = Math.sin(prog * Math.PI);
    J.le = {x:rootX-160, y:shoulderY-100-sp*200, z:shoulderZ+40-prog*350};
    J.lw = {x:rootX- 70, y:shoulderY-180-sp*260, z:shoulderZ+20-prog*600};
    J.re = {x:rootX+160, y:shoulderY-100-sp*200, z:shoulderZ+40-prog*350};
    J.rw = {x:rootX+ 70, y:shoulderY-180-sp*260, z:shoulderZ+20-prog*600};
    J.lknee = {x:rootX-64, y:kneeY+sp*60, z:rootZAdj-stagger}; J.rknee = {x:rootX+70, y:kneeY+sp*60, z:rootZAdj+stagger};
  } else if (enemy.state === 'e_special_rush') {
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const altL = Math.sin(prog * Math.PI * 5), altR = Math.sin(prog * Math.PI * 5 + Math.PI);
    const reachZ = rootZAdj - 540;
    J.le = {x:rootX-104, y:shoulderY+20+altL*25, z:shoulderZ+40-Math.max(0,altL)*80};
    J.lw = {x:rootX- 56, y:shoulderY+10+altL*15, z:reachZ-Math.max(0,altL)*100};
    J.re = {x:rootX+104, y:shoulderY+20+altR*25, z:shoulderZ+40-Math.max(0,altR)*80};
    J.rw = {x:rootX+ 56, y:shoulderY+10+altR*15, z:reachZ-Math.max(0,altR)*100};
  } else if (enemy.state === 'e_special_upper') {
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const crouch = Math.max(0, 1 - prog * 2.5), rise = Math.max(0, prog * 2 - 0.4), sp = Math.sin(prog * Math.PI);
    J.lknee = {x:rootX-64, y:kneeY+crouch*120, z:rootZAdj-stagger}; J.rknee = {x:rootX+70, y:kneeY+crouch*120, z:rootZAdj+stagger};
    J.lfoot = {x:rootX-66, y:footY+crouch*80, z:rootZAdj-stagger-12}; J.rfoot = {x:rootX+74, y:footY+crouch*80, z:rootZAdj+stagger-12};
    J.le = {x:rootX-130, y:shoulderY+70 -rise*300, z:shoulderZ-30};
    J.lw = {x:rootX- 80, y:shoulderY+150-rise*450-sp*60, z:shoulderZ+60-rise*200};
    J.re = {x:rootX+110, y:shoulderY+50 -rise*220, z:shoulderZ-20};
    J.rw = {x:rootX+ 64, y:shoulderY+130-rise*400, z:shoulderZ+40-rise*160};
  } else if (isHurt) {
    // 被弾: 腕が後方(+z)に開く
    const openAmt = 90 * hurtProgress;
    J.le={x:rootX-130,y:chestY+40,z:chestZ-50}; J.lw={x:rootX-200-openAmt,y:chestY+100+openAmt*0.4,z:chestZ-80};
    J.re={x:rootX+130,y:chestY+40,z:chestZ-50}; J.rw={x:rootX+200+openAmt,y:chestY+100+openAmt*0.4,z:chestZ-80};
  } else {
    // アイドル: タイトなハイガード(グローブを頬の高さに、肘は肋骨を守る)
    const breathe = Math.sin(npcPhase * 1.6) * 6;
    J.le={x:rootX-70,y:shoulderY+58,z:lsZ2-50}; J.lw={x:rootX-42,y:headCenterY+34+breathe,z:headZ-54};
    J.re={x:rootX+70,y:shoulderY+58,z:rsZ2-50}; J.rw={x:rootX+42,y:headCenterY+34-breathe,z:headZ-54};
  }

  // ── スナップ防止のための補間 ──
  let armK = 0.30, bodyK = 0.26;
  if (isAttacking || isWindup || isRecoil || isEnemySpecial) armK = 0.55;
  if (isHurt) { armK = 0.5; bodyK = 0.42; }
  if (isEnemySpecial) bodyK = 0.4;
  const SJ = smoothJoints('e', J, armK, bodyK);

  const pal = {
    skin: currentSkinMat, glove: materials.glove, gloveTrim: materials.gloveTrim,
    hair: materials.hair, eye: materials.eye, trunks: materials.pants,
    trim: materials.enemyTrim, shoe: materials.shoe
  };
  renderFighter('e', SJ, pal, -1, enemyShadow, enemyPos.x, enemyPos.z);
}

// プレイヤーの前方(敵向き)= gameZ の +方向。faceDir = +1
function buildPlayerPoly() {
  let pPhase = (Date.now() * 0.005) % (Math.PI * 2);

  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 ? Math.max(0, Math.min(1, 1.0 - (specialTimer / specMove.duration))) : 0;

  // 揺れは体全体に一貫して適用
  const shakeMag = playerHurtShake;
  const sx = (Math.random() - 0.5) * shakeMag;
  const sy = (Math.random() - 0.5) * shakeMag * 0.6;
  const sz = (Math.random() - 0.5) * shakeMag;

  // アイドルのバウンス+ウィービング
  const bob = Math.sin(pPhase * 1.6);
  const bobY = (playerAction === 'idle' ? bob * 8 : bob * 3) + sy;
  const weaveX = playerAction === 'idle' ? Math.sin(pPhase * 0.7) * 12 : 0;
  const stepZ = isAttacking ? 90 : 0;

  const rootX = playerPos.x + sx + weaveX;
  const rootZ = playerPos.z + sz + stepZ;  // 攻撃時は前方(+z)へ踏み込む

  const footY       = 605 + bobY;
  const kneeY       = 432 + bobY;
  const hipY        = 235 + bobY;
  const waistY      = 150 + bobY;
  const chestY      =  35 + bobY;
  const shoulderY   = -25 + bobY;
  const neckBaseY   = -62 + bobY;
  const neckTopY    = -118 + bobY;
  const headCenterY = -182 + bobY;

  const arch = isHurt ? -70 : 0; // 被弾時は後方(-z)へのけぞる

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

  // バサミ足: 左足を前(+z)、右足を後ろ(-z)
  const stagger = 46;
  const footStepL = Math.sin(pPhase) * 7;

  let J = {
    head:     { x: rootX,        y: headCenterY, z: headZ     },
    neckTop:  { x: rootX,        y: neckTopY,    z: neckTopZ  },
    neckBase: { x: rootX,        y: neckBaseY,   z: neckBaseZ },
    ls:       { x: rootX - 118,  y: shoulderY,   z: shoulderZ + 26 },
    rs:       { x: rootX + 118,  y: shoulderY,   z: shoulderZ - 26 },
    chest:    { x: rootX,        y: chestY,      z: chestZ    },
    waist:    { x: rootX,        y: waistY,      z: waistZ    },
    hip:      { x: rootX,        y: hipY,        z: rootZ     },
    lhip:     { x: rootX - 56,   y: hipY,        z: rootZ + 14 },
    rhip:     { x: rootX + 56,   y: hipY,        z: rootZ - 14 },
    lknee:    { x: rootX - 64,   y: kneeY,       z: rootZ + stagger },
    rknee:    { x: rootX + 70,   y: kneeY,       z: rootZ - stagger },
    lfoot:    { x: rootX - 66,   y: footY,       z: rootZ + stagger + 12 + footStepL },
    rfoot:    { x: rootX + 74,   y: footY,       z: rootZ - stagger + 12 },
  };

  if (isHurt) {
    J.le = { x: rootX - 130, y: shoulderY + 60, z: shoulderZ - 50 };
    J.lw = { x: rootX - 190, y: headCenterY + 80, z: shoulderZ - 80 };
    J.re = { x: rootX + 130, y: shoulderY + 60, z: shoulderZ - 50 };
    J.rw = { x: rootX + 190, y: headCenterY + 80, z: shoulderZ - 80 };
  } else if (isRecoil) {
    J.le = { x: rootX - 150, y: shoulderY - 30,  z: shoulderZ - 20 };
    J.lw = { x: rootX - 260, y: shoulderY - 120, z: shoulderZ - 50 };
    J.re = { x: rootX + 150, y: shoulderY - 30,  z: shoulderZ - 20 };
    J.rw = { x: rootX + 260, y: shoulderY - 120, z: shoulderZ - 50 };
  } else if (isBlocking) {
    // タイトなハイガード(顔の前 +z)
    J.le = { x: rootX - 78, y: shoulderY + 44,  z: shoulderZ + 70 };
    J.lw = { x: rootX - 40, y: headCenterY + 30, z: headZ + 58 };
    J.re = { x: rootX + 78, y: shoulderY + 44,  z: shoulderZ + 70 };
    J.rw = { x: rootX + 40, y: headCenterY + 30, z: headZ + 58 };
  } else if (isWindup) {
    if (isLeftPunch) {
      J.le = { x: rootX - 150, y: shoulderY + 50,  z: shoulderZ - 120 };
      J.lw = { x: rootX - 128, y: shoulderY + 24,  z: shoulderZ - 70  };
      J.re = { x: rootX + 82,  y: shoulderY + 40,  z: shoulderZ + 56  };
      J.rw = { x: rootX + 44,  y: headCenterY + 30, z: headZ + 56 };
    } else {
      J.le = { x: rootX - 82,  y: shoulderY + 40,  z: shoulderZ + 56  };
      J.lw = { x: rootX - 44,  y: headCenterY + 30, z: headZ + 56 };
      J.re = { x: rootX + 150, y: shoulderY + 50,  z: shoulderZ - 120 };
      J.rw = { x: rootX + 128, y: shoulderY + 24,  z: shoulderZ - 70  };
    }
  } else if (isAttacking) {
    const punchReach = rootZ + 540;
    const punchOffX  = isLeftPunch ? -50 : 50;
    if (isLeftPunch) {
      J.le = { x: rootX - 96,  y: shoulderY + 18,  z: shoulderZ + 150 };
      J.lw = { x: rootX + punchOffX, y: headCenterY + 30, z: punchReach };
      J.re = { x: rootX + 82,  y: shoulderY + 40,  z: shoulderZ + 56 };
      J.rw = { x: rootX + 44,  y: headCenterY + 30, z: headZ + 56 };
    } else {
      J.le = { x: rootX - 82,  y: shoulderY + 40,  z: shoulderZ + 56 };
      J.lw = { x: rootX - 44,  y: headCenterY + 30, z: headZ + 56 };
      J.re = { x: rootX + 96,  y: shoulderY + 18,  z: shoulderZ + 150 };
      J.rw = { x: rootX + punchOffX, y: headCenterY + 30, z: punchReach };
    }
  } else if (isSpecial) {
    const sp  = Math.sin(specProg * Math.PI);
    const sp2 = Math.min(1, specProg * 2);
    if (playerAction === 'special_meteor') {
      J.le = { x: rootX - 160, y: shoulderY - 120 - sp * 200, z: shoulderZ - 80 + sp2 * 300 };
      J.lw = { x: rootX -  60, y: shoulderY - 200 - sp * 250, z: shoulderZ - 40 + sp2 * 500 };
      J.re = { x: rootX + 160, y: shoulderY - 120 - sp * 200, z: shoulderZ - 80 + sp2 * 300 };
      J.rw = { x: rootX +  60, y: shoulderY - 200 - sp * 250, z: shoulderZ - 40 + sp2 * 500 };
      J.lknee = { x: rootX - 64, y: kneeY + sp * 40, z: rootZ + stagger + sp * 60 };
      J.rknee = { x: rootX + 70, y: kneeY + sp * 40, z: rootZ - stagger - 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);
      J.le = { x: rootX - 96, y: shoulderY + 20 + altL * 30, z: shoulderZ + 40 + Math.max(0, altL) * 100 };
      J.lw = { x: rootX - 40, y: shoulderY + 10 + altL * 20, z: shoulderZ + 200 + Math.max(0, altL) * 300 };
      J.re = { x: rootX + 96, y: shoulderY + 20 + altR * 30, z: shoulderZ + 40 + Math.max(0, altR) * 100 };
      J.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);
      J.lknee = { x: rootX - 64, y: kneeY + crouch * 120, z: rootZ + stagger };
      J.rknee = { x: rootX + 70, y: kneeY + crouch * 120, z: rootZ - stagger };
      J.lfoot = { x: rootX - 66, y: footY + crouch * 80,  z: rootZ + stagger };
      J.rfoot = { x: rootX + 74, y: footY + crouch * 80,  z: rootZ - stagger };
      J.le = { x: rootX - 110, y: shoulderY + 80  - rise * 280, z: shoulderZ + 60 };
      J.lw = { x: rootX -  70, y: shoulderY + 150 - rise * 400 - sp * 50, z: shoulderZ + 50 + rise * 200 };
      J.re = { x: rootX + 96,  y: shoulderY + 50  - rise * 200, z: shoulderZ + 30 };
      J.rw = { x: rootX + 56,  y: shoulderY + 120 - rise * 350, z: shoulderZ + 80 + rise * 150 };
    } else {
      J.le = { x: rootX - 150, y: shoulderY - 100, z: shoulderZ + 200 };
      J.lw = { x: rootX -  60, y: shoulderY - 150, z: shoulderZ + 450 };
      J.re = { x: rootX + 150, y: shoulderY - 100, z: shoulderZ + 200 };
      J.rw = { x: rootX +  60, y: shoulderY - 150, z: shoulderZ + 450 };
    }
  } else {
    // アイドル: タイトなハイガード
    const breathe = Math.sin(pPhase * 1.6) * 6;
    J.le = { x: rootX - 70, y: shoulderY + 58, z: shoulderZ + 50 };
    J.lw = { x: rootX - 42, y: headCenterY + 34 + breathe, z: headZ + 54 };
    J.re = { x: rootX + 70, y: shoulderY + 58, z: shoulderZ + 50 };
    J.rw = { x: rootX + 42, y: headCenterY + 34 - breathe, z: headZ + 54 };
  }

  let camOverride = false;
  if (controlMode === 'camera' && !isAttacking && !isWindup && !isRecoil && !isHurt && !isSpecial) {
    camOverride = true;
    J.lw.x = rootX + armL.x;
    J.lw.y = chestY + armL.y;
    J.lw.z = chestZ + 150;
    J.rw.x = rootX + armR.x;
    J.rw.y = chestY + armR.y;
    J.rw.z = chestZ + 150;
    J.le = { x: (J.ls.x + J.lw.x)/2 - 50, y: (J.ls.y + J.lw.y)/2 + 40, z: (J.ls.z + J.lw.z)/2 };
    J.re = { x: (J.rs.x + J.rw.x)/2 + 50, y: (J.rs.y + J.rw.y)/2 + 40, z: (J.rs.z + J.rw.z)/2 };
  }

  // ── スナップ防止のための補間 ──
  let armK = 0.30, bodyK = 0.26;
  if (isAttacking || isWindup || isRecoil || isSpecial) armK = 0.55;
  if (isHurt) { armK = 0.5; bodyK = 0.42; }
  if (isSpecial) bodyK = 0.4;
  if (camOverride) armK = 0.5; // カメラ操作の追従性を確保
  const SJ = smoothJoints('p', J, armK, bodyK);

  const pal = {
    skin: materials.playerBody, glove: materials.playerGlove, gloveTrim: materials.gloveTrim,
    hair: materials.playerHair, eye: materials.eye, trunks: materials.playerPants,
    trim: materials.playerTrim, shoe: materials.shoeP
  };
  renderFighter('p', SJ, pal, 1, playerShadow, playerPos.x, playerPos.z);
}

// ─── メインループ ─────────────────────────────────────────────
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 + enemyPos.x) * 0.5, 260, -(playerPos.z + enemyPos.z) * 0.5);

  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」


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

ピックアップされています

気楽に遊べるゲーム

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
カメラで対戦する「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