カメラで対戦する「MotionBoxing」


【更新履歴】

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


画像

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

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


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


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

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Motion Boxing 1.6</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 class="limb-row">
      <span class="limb-label">LEGS</span>
      <div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-legs" style="width:100%;background:#ffd700;"></div></div>
    </div>
  </div>
</div>

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

<div id="overlay">
  <h1>MotionBoxing</h1>
  
  <div class="menu-panel" id="menu-main">
    <h3>モードを選択</h3>
    <p>踏み込んで打てば<span class="key-hint">大ダメージ(CLEAN HIT)</span>!<br>
       パンチを合わせて弾き返せ(PARRY)!</p>
    <button class="btn" onclick="showMenu('menu-single')">👤 1 VS 1 (対NPC)</button>
    <button class="btn btn-red" onclick="showMenu('menu-multi')">🌐 マルチプレイ通信対戦</button>
    <button class="btn" style="border-color:#aaa; color:#aaa;" onclick="showMenu('menu-settings')">⚙️ 設定 (Settings)</button>
  </div>
  
  <div class="menu-panel" id="menu-single" style="display:none;">
    <h3>操作方法 (1 VS 1)</h3>
    <p>【マウス】ポインタで移動。左右クリックでパンチ。<span class="key-hint">同時押しでガード</span><br>
       【カメラ】自分の体の動きでゲーム内の体を移動・回避<br>
       ⚡<span class="key-hint">SPECIALゲージMAX時:次のクリックで必殺技発動!</span><br>
       UPPER CUT(近距離/頭)・BODY BLOW(中距離/胴)・LEG SWEEP(遠距離/足)を自動選択</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)); }

// ─── 必殺技システム ───────────────────────────────────────────
// 技リスト: 部位狙い特化型
// targetPart: 狙う部位  minDz: 発動に必要な最小接近距離(dz)  maxDz: 最大距離
// rangeBonus: この技は通常より遠くても当たる  knockback: 距離を開ける効果
const SPECIAL_MOVES = [
  {
    name: 'UPPER CUT',       // アッパーカット → 頭部狙い
    motion: 'special_rising',
    effectColor: '#ff4400',
    description: '下から頭部へのアッパーカット!近距離必須',
    targetPart: 'head',
    baseDamage: 38,
    minDz: 80,    // 近すぎても外れる(すり抜け)
    maxDz: 320,   // 近距離限定
    rangeBonus: 0,
    knockback: 80,
    extraHits: [],
  },
  {
    name: 'BODY BLOW',        // ボディブロー → 胴体狙い
    motion: 'special_twin',
    effectColor: '#ff8800',
    description: '左右交互に胴体へ連打!中距離で有効',
    targetPart: 'body',
    baseDamage: 22,
    minDz: 50,
    maxDz: 420,
    rangeBonus: 60,
    knockback: 20,
    extraHits: ['body', 'body'],  // 3ヒット合計
  },
  {
    name: 'LEG SWEEP',        // 足払い → 足部狙い
    motion: 'special_meteor',
    effectColor: '#ffd700',
    description: '足払いで相手をよろめかせる!スタミナ奪取',
    targetPart: 'legs',
    baseDamage: 18,
    minDz: 0,
    maxDz: 380,
    rangeBonus: 0,
    knockback: 0,
    extraHits: [],
    staminaDrain: 35,   // 相手スタミナを奪う
  },
];

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; // 正=プレイヤーが後方、敵がdz分前
  const dist = Math.abs(dz);
  // 距離に応じた技選択: 近→アッパー、中→ボディブロー、遠→足払い
  if (dist <= 320) {
    specialMoveIndex = 0; // UPPER CUT
  } else if (dist <= 420) {
    specialMoveIndex = 1; // BODY BLOW
  } else {
    specialMoveIndex = 2; // LEG SWEEP (遠距離でも足払いは届く)
  }

  const move = SPECIAL_MOVES[specialMoveIndex];
  specialMotion = move.motion;
  const duration = move.motion === 'special_rising' ? 50 : move.motion === 'special_twin' ? 60 : 80;
  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 curDzSigned = enemyPos.z - playerPos.z; // 正=敵が前方
    const curDist = Math.abs(curDzSigned);
    const curDx = Math.abs(playerPos.x - enemyPos.x);

    // 位置関係による当否: 技ごとに有効距離が異なる
    const maxReach = move.maxDz + move.rangeBonus;
    if (curDzSigned < -80)   return { hit: false, reason: 'MISS (BEHIND)' };
    if (curDzSigned > 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 = (curDzSigned < move.maxDz * 0.6 && curDx < 160);
    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;
    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 : r.part === 'legs' ? 80 : -50, r.isClean, r.part);

      // 足払いはスタミナ奪取
      if (move.staminaDrain && move.staminaDrain > 0) {
        // 敵AIのstaminaはないがKOカウントを使って硬直を長くする
        enemy.stateTimer = Math.max(enemy.stateTimer, 55);
        enemy.state = 'hurt';
        enemy.hurtShake = 60;
        showHitNumber(`SWEEP! -${move.staminaDrain}STA`, '#ffd700', 1.4);
      }

      // ノックバック(足払い以外)
      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(result && result.reason ? result.reason : r.reason, '#aaa');
    }
    eff.style.opacity = '0';
  }, hitDelay);

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

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

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

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

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

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

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

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

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

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

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

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

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

let screenShake = 0, hitStop = 0, flashTimer = 0, hitChromatic = 0;
let particles = [], hitNumbers = [];
let walkBob = 0, playerHurtShake = 0, camHurtPitch = 0;

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

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

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

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

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

let wasRunning = false;
let mouseEventsRegistered = false;
function showMenu(id) {
  document.querySelectorAll('.menu-panel').forEach(el => el.style.display = 'none');
  document.getElementById(id).style.display = 'block';
  document.getElementById('overlay').style.display = 'flex';
}
function openSettings() { wasRunning = gameRunning; gameRunning = false; showMenu('menu-settings'); }
function closeSettings() {
  updateAudioVol();
  if (wasRunning) {
    document.getElementById('overlay').style.display = 'none';
    gameRunning = true;
    requestAnimationFrame(gameLoop);
    if (commMode === 'text' && speechRecognition) {
      try { speechRecognition.start(); } catch(e) {}
    }
  } else {
    showMenu('menu-main');
  }
}

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

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

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

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

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

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

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

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

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

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

function initPeer() {
  try {
    if (peer && !peer.destroyed) peer.destroy();
    peer = new Peer(undefined, { debug: 0 });
    peer.on('open', id => { 
      document.getElementById('my-id-display').value = id; 
    });
    peer.on('connection', c => {
      conn = c;
      c.on('open', () => {
        setupP2PConnection(c);
        initP2PGame(controlMode || 'mouse');
        if (commMode === 'voice' && localAudioStream) makeVoiceCall(c.peer);
      });
    });
    peer.on('call', call => {
      peerCall = call;
      call.answer(localAudioStream || undefined);
      setupCallEvents(call);
    });
    peer.on('error', err => { 
      document.getElementById('my-id-display').value = 'エラー: ' + err.type; 
    });
    peer.on('disconnected', () => {
      document.getElementById('my-id-display').value = '切断 (再接続中...)';
      peer.reconnect();
    });
  } catch(e) { console.error(e); }
}
initPeer();

function connectToPeer(ctrl) {
  const targetId = document.getElementById('target-id-input').value.trim();
  if (!targetId) return alert('接続先のIDを入力してください');
  if (!peer || peer.destroyed) return alert('PeerJSが初期化されていません。ページをリロードしてください。');
  initAudio();
  controlMode = ctrl;
  if (conn && conn.open) conn.close();
  conn = peer.connect(targetId, { reliable: true, serialization: 'json' });
  
  // 接続中表示
  const statusEl = document.getElementById('my-id-display');
  if (!statusEl.value.includes('接続中')) {
    statusEl.value = statusEl.value + ' (接続中...)';
  }
  
  conn.on('open', () => {
    // 接続成功したら元のID表示に戻す
    statusEl.value = peer.id;
    setupP2PConnection(conn);
    initP2PGame(ctrl);
    if (commMode === 'voice' && localAudioStream) makeVoiceCall(targetId);
  });
  conn.on('error', err => { alert('接続エラー: ' + err.message); });
}

function setupP2PConnection(c) {
  c.on('data', data => {
    if (!data || !data.type) return;
    if (data.type === 'sync') {
      if (typeof data.hp === 'number') enemy.hp = Math.max(0, Math.min(enemy.maxHP, data.hp));
      if (data.state) enemy.state = data.state;
      if (data.poseData) enemy.poseData = data.poseData;
      if (data.pos) { enemyPos.x = -data.pos.x; enemyPos.z = 600 - data.pos.z; }
      if (!gameRunning) return;
      updateUI();
    }
    else if (data.type === 'punch') {
      if (!gameRunning) return;
      const result = evaluateHit(enemyPos, playerPos, playerBlocking, data.targetPart || 'body');
      if (!result.hit) {
        showHitNumber(result.reason, '#aaa');
        c.send({ type: 'hit_confirm', hit: false, reason: result.reason });
      } else {
        const dmg = Math.floor((10 + (data.speed || 250) * 0.05) * result.multi);
        takeDamage(dmg, result.type.includes('CLEAN HIT'));
        c.send({ type: 'hit_confirm', hit: true, dmg: dmg, hitType: result.type });
      }
    }
    else if (data.type === 'hit_confirm') {
      if (!gameRunning) return;
      if (data.hit) hitEnemyVisual(data.dmg, 0, 0, data.hitType && data.hitType.includes('CLEAN HIT'));
      else { showHitNumber(data.reason || 'MISS', '#aaa'); combo = 0; updateUI(); }
    }
    else if (data.type === 'gameover') { showResult(true); }
    else if (data.type === 'chat') { displayChat('Enemy', data.text, false); }
  });
  c.on('close', () => {
    if (gameRunning) { gameRunning = false; alert('相手との接続が切れました。'); location.reload(); }
  });
}

function sendP2PData() {
  if (!conn || !conn.open) return;
  const pd = (controlMode === 'camera' && latestPoseData) ? {
    lw: latestPoseData.lw, rw: latestPoseData.rw, ls: latestPoseData.ls, rs: latestPoseData.rs
  } : null;
  try {
    conn.send({ type: 'sync', hp: playerHP, state: playerAction, poseData: pd, pos: { x: playerPos.x, z: playerPos.z } });
  } catch(e) { }
}

function startP2PSyncLoop() {
  if (p2pSyncInterval) clearInterval(p2pSyncInterval);
  p2pSyncInterval = setInterval(() => {
    if (!gameRunning) { clearInterval(p2pSyncInterval); return; }
    sendP2PData();
  }, 33);
}

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

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

function startGameFlow() {
  startEpicBGM();
  document.getElementById('overlay').style.display = 'none';
  document.getElementById('settings-btn').style.display = 'block';
  document.getElementById('round-box').style.display = gameMode === 'multi' ? 'none' : 'block';
  document.getElementById('time-box').style.display = gameMode === 'multi' ? 'none' : 'block';

  score = 0; round = 1; combo = 0;
  playerHP = 100; playerStamina = 100; staminaDebuff = 1.0; camHurtPitch = 0;
  enemy.maxHP = 100; enemy.hp = 100; enemy.attackInterval = 110;
  enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
  playerPos = { x: 0, z: 0 }; targetPlayerPos = { x: 0, z: 0 }; enemyPos = { x: 0, z: 600 };
  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();
  }

  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)) * 1400;
  // マウス上下反転: マウス上→画面奥(z大)、マウス下→画面手前(z小)
  targetPlayerPos.z = -((e.clientY - H()/2) / (H()/2)) * 800 + 200;
}
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;
  const legPenalty = 1.0 - (playerLimbDmg.legs / 250);
  if (controlMode === 'mouse') {
    let movedByKey = false;
    const speed = Math.round(35 * legPenalty * staminaDebuff); // 移動エリア拡大に伴い速度も向上
    if (keys.w || keys.ArrowUp)    { playerPos.z += speed; movedByKey = true; }
    if (keys.s || keys.ArrowDown)  { playerPos.z -= speed; movedByKey = true; }
    if (keys.a || keys.ArrowLeft)  { playerPos.x -= speed; movedByKey = true; }
    if (keys.d || keys.ArrowRight) { playerPos.x += speed; movedByKey = true; }

    if (movedByKey) { targetPlayerPos.x = playerPos.x; targetPlayerPos.z = playerPos.z; isMoving = true; }
    else {
      const dx = targetPlayerPos.x - playerPos.x, dz = targetPlayerPos.z - playerPos.z;
      if (Math.abs(dx) > 5 || Math.abs(dz) > 5) isMoving = true;
      playerPos.x += dx * 0.15; playerPos.z += dz * 0.15;
    }
  }

  if (isMoving) walkBob += 0.2; else walkBob = 0;
  // 移動限界を拡大
  playerPos.x = Math.max(-1500, Math.min(1500, playerPos.x));
  playerPos.z = Math.max(-400, Math.min(1200, playerPos.z));

  // カメラモードでの腕の追従のみ残す
  if (controlMode === 'camera') {
    armL.x += (targetArmL.x - armL.x) * 0.4; armL.y += (targetArmL.y - armL.y) * 0.4; armL.z += (targetArmL.z - armL.z) * 0.4;
    armR.x += (targetArmR.x - armR.x) * 0.4; armR.y += (targetArmR.y - armR.y) * 0.4; armR.z += (targetArmR.z - armR.z) * 0.4;
  }
}

function getTargetPart(aimYVal) {
  if (aimYVal < -0.15) return 'head';
  if (aimYVal < 0.25)  return 'body';
  if (aimYVal < 0.55)  return 'body'; 
  return 'legs';
}

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

function evaluateHit(attackerPos, defenderPos, isDefenderBlocking, targetPart) {
  const dx = Math.abs(attackerPos.x - defenderPos.x);
  // dz: 防御者から攻撃者を見た前後距離
  // プレイヤー(z≈0~200)が敵(z≈600)を攻撃: defenderPos.z - attackerPos.z ≈ +400 (正=前方にいる)
  const dzSigned = defenderPos.z - attackerPos.z;

  // 前後距離チェック: 防御者が攻撃者の前方にいないと当たらない
  if (dzSigned < -80)         return { hit: false, reason: 'MISS (BEHIND)' };
  if (dzSigned > 560)         return { hit: false, reason: 'MISS (TOO FAR)' };
  // 横方向: 280まで許容(当たりやすく)
  if (dx > 280)               return { hit: false, reason: 'MISS (DODGED)' };
  if (isDefenderBlocking)     return { hit: false, reason: 'GUARD!' };

  // CLEAN HIT条件: 適正距離(60~400)かつ横ズレ少ない
  const isClean = (dzSigned >= 60 && dzSigned <= 400 && dx < 160);
  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: 180, maxDx: 160, part: 'head',  color: '#ff0055', hint: '⬆ クリックでHEAD打撃!' },
  { minDz: 180, maxDz: 320, maxDx: 180, part: 'body',  color: '#ff8800', hint: '⬆ クリックでBODY打撃!' },
  { minDz: 320, maxDz: 460, maxDx: 200, part: 'legs',  color: '#ffd700', hint: '⬆ クリックでLEGS打撃!' },
  // 遠距離でも横にはみ出していなければ当たる(ただし弱)
  { minDz: 460, 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'],
  legs:  ['e_uleg_l', 'e_uleg_r', 'e_lleg_l', 'e_lleg_r', 'e_knee_l', 'e_knee_r'],
  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.4, roughness: 0.3
  });
  return _hlMats[hexColor];
}

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

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

  // 現在の距離帯を検索
  let band = null;
  for (const b of HIT_ZONE_BANDS) {
    if (dzSigned >= b.minDz && dzSigned < b.maxDz && dx <= b.maxDx) {
      band = b; break;
    }
  }

  const bandKey = band ? `${band.part}_${Math.floor(dzSigned / 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); set('limb-legs', playerLimbDmg.legs);
}

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, '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; let dodgeOffset = (leftRatio - 0.5) * 320;

  const targetDist = 440;
  if (enemy.state === 'idle') { enemy.targetX = playerPos.x + Math.sin(Date.now() * 0.001) * 200 + dodgeOffset; enemy.targetZ = playerPos.z + targetDist; } 
  else if (enemy.state.includes('windup')) { enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + targetDist - 40; } 
  else if (enemy.state.includes('punch')) { enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + 160; }

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

  enemy.attackTimer++;
  if (enemy.attackTimer >= enemy.attackInterval && enemy.state === 'idle') {
    enemy.attackTimer = 0;
    // ラウンド2以降でHPが低いか、低確率で必殺技発動
    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 = 48; playSound('swing');
    }
  }
}

function spawnParticles(x, y, color, count) {
  for (let i = 0; i < count; i++) {
    const a = Math.random() * Math.PI * 2, s = 10 + Math.random() * 20;
    particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s - 5, alpha: 1, size: 5 + Math.random() * 10, color });
  }
}
function showHitNumber(text, color, scaleMult = 1) {
  hitNumbers.push({ x: W()/2 + (Math.random()-0.5)*150, y: H()/2 - 100, text, color, alpha: 1, vy: -4, scale: 0.5 * scaleMult, targetScale: 1.5 * scaleMult });
}
function updateUI() {
  document.getElementById('score').textContent = score; document.getElementById('round').textContent = `${round} / ${TOTAL_ROUNDS}`;
  document.getElementById('combo').textContent = `x${combo}`; document.getElementById('enemy-health-fill').style.width = (enemy.hp / enemy.maxHP * 100) + '%';
  document.getElementById('player-health-fill').style.width = playerHP + '%';
}
function showResult(isWin, label) {
  gameRunning = false; roundInProgress = false; if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
  _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';
  setTimeout(() => location.reload(), 5000);
}

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

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

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

  if (isRecoil) { joints.le={x:rootX-170,y:shoulderY-40,z:shoulderZ+20}; joints.lw={x:rootX-290,y:shoulderY-130,z:shoulderZ-50}; joints.re={x:rootX+170,y:shoulderY-40,z:shoulderZ+20}; joints.rw={x:rootX+290,y:shoulderY-130,z:shoulderZ-50}; } 
  else if (isBlocking) { joints.le={x:rootX-85,y:shoulderY+30,z:shoulderZ-100}; joints.lw={x:rootX-45,y:headCenterY+40,z:headZ-180}; joints.re={x:rootX+85,y:shoulderY+30,z:shoulderZ-100}; joints.rw={x:rootX+45,y:headCenterY+40,z:headZ-180}; } 
  else if (isWindup) {
    if (isLeftPunch) { joints.le={x:rootX-160,y:shoulderY+60,z:shoulderZ+260}; joints.lw={x:rootX-110,y:shoulderY+20,z:shoulderZ+140}; joints.re={x:rootX+130,y:shoulderY+10,z:rsZ2-30}; joints.rw={x:rootX+85,y:headCenterY+50,z:headZ-160}; } 
    else { joints.le={x:rootX-130,y:shoulderY+10,z:lsZ2-30}; joints.lw={x:rootX-85,y:headCenterY+50,z:headZ-160}; joints.re={x:rootX+160,y:shoulderY+60,z:shoulderZ+260}; joints.rw={x:rootX+110,y:shoulderY+20,z:shoulderZ+140}; }
  } else if (isAttacking) {
    const punchReach = playerBlocking ? rootZ - 200 : rootZ - 580; const punchOffX = isLeftPunch ? -60 : 60;
    if (isLeftPunch) { joints.le={x:rootX-105,y:shoulderY+20,z:shoulderZ-60}; joints.lw={x:rootX+punchOffX,y:headCenterY+25,z:punchReach}; joints.re={x:rootX+130,y:shoulderY+10,z:rsZ2-30}; joints.rw={x:rootX+85,y:headCenterY+50,z:headZ-160}; } 
    else { joints.le={x:rootX-130,y:shoulderY+10,z:lsZ2-30}; joints.lw={x:rootX-85,y:headCenterY+50,z:headZ-160}; joints.re={x:rootX+105,y:shoulderY+20,z:shoulderZ-60}; joints.rw={x:rootX+punchOffX,y:headCenterY+25,z:punchReach}; }
  } else if (enemy.state === 'e_special_smash') {
    // 敵必殺技: 叩きつけ (両腕高く上げて振り下ろし)
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const sp = Math.sin(prog * Math.PI);
    joints.le = {x:rootX-170, y:shoulderY-100-sp*200, z:shoulderZ-60+prog*350};
    joints.lw = {x:rootX- 80, y:shoulderY-180-sp*260, z:shoulderZ-20+prog*600};
    joints.re = {x:rootX+170, y:shoulderY-100-sp*200, z:shoulderZ-60+prog*350};
    joints.rw = {x:rootX+ 80, y:shoulderY-180-sp*260, z:shoulderZ-20+prog*600};
    joints.lknee = {x:rootX-75, y:kneeY+sp*60, z:rootZ-footStep};
    joints.rknee = {x:rootX+75, y:kneeY+sp*60, z:rootZ+footStep};
  } 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);
    const altR = Math.sin(prog * Math.PI * 5 + Math.PI);
    const reachZ = rootZ - 550;
    joints.le = {x:rootX-110, y:shoulderY+20+altL*25, z:shoulderZ-40+Math.max(0,altL)*80};
    joints.lw = {x:rootX- 60, y:shoulderY+10+altL*15, z:reachZ+Math.max(0,altL)*100};
    joints.re = {x:rootX+110, y:shoulderY+20+altR*25, z:shoulderZ-40+Math.max(0,altR)*80};
    joints.rw = {x:rootX+ 60, y:shoulderY+10+altR*15, z:reachZ+Math.max(0,altR)*100};
  } else if (enemy.state === 'e_special_upper') {
    // 敵必殺技: アッパー (しゃがみ→突き上げ)
    const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
    const crouch = Math.max(0, 1 - prog * 2.5);
    const rise   = Math.max(0, prog * 2 - 0.4);
    const sp = Math.sin(prog * Math.PI);
    joints.lknee = {x:rootX-75, y:kneeY+crouch*120, z:rootZ-footStep};
    joints.rknee = {x:rootX+75, y:kneeY+crouch*120, z:rootZ+footStep};
    joints.lfoot = {x:rootX-80, y:footY+crouch*80,  z:rootZ-footStep-15};
    joints.rfoot = {x:rootX+80, y:footY+crouch*80,  z:rootZ+footStep-15};
    joints.le = {x:rootX-140, y:shoulderY+70 -rise*300, z:shoulderZ+30};
    joints.lw = {x:rootX- 90, y:shoulderY+150-rise*450-sp*60, z:shoulderZ-60+rise*200};
    joints.re = {x:rootX+120, y:shoulderY+50 -rise*220, z:shoulderZ+20};
    joints.rw = {x:rootX+ 70, y:shoulderY+130-rise*400, z:shoulderZ-40+rise*160};
  } else if (isHurt) {
    const openAmt = 100 * hurtProgress;
    joints.le={x:rootX-140,y:chestY+40,z:chestZ+50}; joints.lw={x:rootX-220-openAmt,y:chestY+100+openAmt*0.4,z:chestZ+80}; joints.re={x:rootX+140,y:chestY+40,z:chestZ+50}; joints.rw={x:rootX+220+openAmt,y:chestY+100+openAmt*0.4,z:chestZ+80};
  } else {
    const wavL = Math.sin(npcPhase) * 18, wavR = Math.cos(npcPhase) * 18;
    joints.le={x:rootX-125,y:shoulderY+30+wavL,z:lsZ2-30}; joints.lw={x:rootX-80,y:headCenterY+55+wavL,z:headZ-170}; joints.re={x:rootX+135,y:shoulderY+40+wavR,z:rsZ2-20}; joints.rw={x:rootX+80,y:shoulderY+10+wavR,z:rsZ2-150}; 
  }

  const mSkin = currentSkinMat, mGlove = materials.glove, mPants = materials.pants, mShoe = materials.shoe;
  updateMesh('e_head', 'joint', joints.head, null, 68, mSkin);
  updateMesh('e_neck', 'bone', joints.neckTop, joints.neckBase, 30, mSkin);
  updateMesh('e_necktorso', 'bone', joints.neckBase, { x:(joints.ls.x+joints.rs.x)/2, y:joints.ls.y, z:(joints.ls.z+joints.rs.z)/2 }, 38, mSkin);
  updateMesh('e_shoulder', 'bone', joints.ls, joints.rs, 52, mSkin);
  updateMesh('e_chest', 'bone', { x:(joints.ls.x+joints.rs.x)/2, y:joints.ls.y, z:(joints.ls.z+joints.rs.z)/2 }, joints.waist, 70, mSkin);
  updateMesh('e_abdomen', 'bone', joints.waist, joints.hip, 56, mSkin);
  updateMesh('e_pelvis', 'bone', joints.lhip, joints.rhip, 50, mPants);

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

  updateMesh('e_uleg_l', 'bone', joints.lhip, joints.lknee, 42, mSkin); updateMesh('e_knee_l', 'joint', joints.lknee, null, 32, mSkin); updateMesh('e_lleg_l', 'bone', joints.lknee, joints.lfoot, 28, mSkin); updateMesh('e_foot_l', 'joint', joints.lfoot, null, 30, mShoe);
  updateMesh('e_uleg_r', 'bone', joints.rhip, joints.rknee, 42, mSkin); updateMesh('e_knee_r', 'joint', joints.rknee, null, 32, mSkin); updateMesh('e_lleg_r', 'bone', joints.rknee, joints.rfoot, 28, mSkin); updateMesh('e_foot_r', 'joint', joints.rfoot, null, 30, mShoe);
}

function buildPlayerPoly() {
  let pPhase = (Date.now() * 0.005) % (Math.PI * 2); 
  if (playerAction !== 'idle') pPhase = 0;

  const isWindup    = playerAction.includes('windup');
  const isAttacking = playerAction.includes('punch');
  const isLeftPunch = playerAction.includes('L');
  const isBlocking  = playerBlocking;
  const isRecoil    = playerAction === 'recoil';
  const isHurt      = playerHurtShake > 40;
  const isSpecial   = playerAction.startsWith('special_');
  // 必殺技進行度 (0→1)
  const specMove    = isSpecial ? SPECIAL_MOVES.find(m => m.motion === playerAction) : null;
  const specProg    = isSpecial && specMove ? 1.0 - (specialTimer / specMove.duration) : 0;

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

  const bobY = Math.sin(pPhase) * 8;
  const stepZ = isAttacking ? 80 : 0; 

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

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

  const arch = isHurt ? -80 : 0; 

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

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

  let joints = {
    head:     { x: rootX,        y: headCenterY, z: headZ    },
    neckTop:  { x: rootX,        y: neckTopY,    z: neckTopZ  },
    neckBase: { x: rootX,        y: neckBaseY,   z: neckBaseZ },
    ls:       { x: rootX - 110,  y: shoulderY,   z: shoulderZ }, 
    rs:       { x: rootX + 110,  y: shoulderY,   z: shoulderZ }, 
    chest:    { x: rootX,        y: chestY,      z: chestZ    },
    waist:    { x: rootX,        y: waistY,      z: waistZ    },
    hip:      { x: rootX,        y: hipY,        z: rootZ     },
    lhip:     { x: rootX - 60,   y: hipY,        z: rootZ     },
    rhip:     { x: rootX + 60,   y: hipY,        z: rootZ     },
    lknee:    { x: rootX - 70,   y: kneeY,       z: rootZ + footStep },
    rknee:    { x: rootX + 70,   y: kneeY,       z: rootZ - footStep },
    lfoot:    { x: rootX - 75,   y: footY,       z: rootZ + footStep },
    rfoot:    { x: rootX + 75,   y: footY,       z: rootZ - footStep },
  };

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

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

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

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

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

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

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

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

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

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

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

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

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

  // ヒットゾーンインジケーター更新(距離変化で部位ハイライト)
  updateHitZoneIndicator();

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

  let camBobY = Math.sin(walkBob * 2) * 15;
  let camPunchZ = 0;
  if (playerAction === 'punchL' || playerAction === 'punchR') camPunchZ = -80;

  let roll = -viewOffsetX * 0.0005;
  if (keys.a || keys.ArrowLeft)  roll += 0.05;
  if (keys.d || keys.ArrowRight) roll -= 0.05;

  camHurtPitch    *= 0.75;
  playerHurtShake *= 0.82;

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

  let camDist = 550; 
  let camHeight = 350; 
  camera.position.set(
    playerPos.x + viewOffsetX * 0.8 + shakeX,
    camHeight - viewOffsetY * 0.5 + camBobY + shakeY,
    -playerPos.z + camDist + camPunchZ
  );
  
  let lookX = (playerPos.x * 0.4 + enemyPos.x * 0.6) + viewOffsetX;
  let lookY = 150 + camHurtPitch;
  let lookZ = (-playerPos.z * 0.2 - enemyPos.z * 0.8);
  camera.lookAt(lookX, lookY, lookZ);
  camera.rotation.z = roll;

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

  buildEnemyPoly();
  buildPlayerPoly();

  renderer.render(scene, camera);

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

  if (hitChromatic > 0) {
    const ca = hitChromatic;
    ctx.save();
    ctx.globalCompositeOperation = 'screen';
    ctx.globalAlpha = 0.15; ctx.drawImage(renderer.domElement, -ca, 0, W(), H());
    ctx.globalAlpha = 0.10; ctx.drawImage(renderer.domElement,  ca, 0, W(), H());
    ctx.restore();
    hitChromatic = Math.max(0, hitChromatic - 1.5);
  }

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

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

  for (let i = particles.length - 1; i >= 0; i--) {
    let p = particles[i]; p.x += p.vx; p.y += p.vy; p.vy += 0.8; p.alpha -= 0.03;
    if (p.alpha <= 0) { particles.splice(i, 1); continue; }
    ctx.globalAlpha = p.alpha; ctx.fillStyle = p.color; ctx.beginPath();
    if (p.size > 10) ctx.ellipse(p.x, p.y, p.size * 0.6, p.size, Math.atan2(p.vy, p.vx), 0, Math.PI * 2);
    else ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1.0;

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

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

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

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

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

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


《似た感じのゲーム》


「The Thrill of the Fight」


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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
カメラで対戦する「MotionBoxing」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

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