カメラで対戦する「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: auto; 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: pointer; 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;
    }

    /* チャットログ用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>
  <button id="special-ready-btn" onclick="activateSpecialMove()">💥 必殺技!</button>
  <div id="special-effect-overlay"></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ゲージ</span>が貯まる!MAXで<span class="key-hint">必殺技ボタン</span>が出現!</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)); }

// ─── 必殺技システム ───────────────────────────────────────────
// 技リスト: { name, cost, damage, duration, motion, effectColor, description }
const SPECIAL_MOVES = [
  { name: 'METEOR SMASH',  cost: 100, damage: 55, duration: 80,  motion: 'special_meteor',  effectColor: '#ff4400', description: '全力の叩きつけ!' },
  { name: 'TWIN FANG',     cost: 80,  damage: 40, duration: 60,  motion: 'special_twin',    effectColor: '#00f3ff', description: '高速両手同時打ち!' },
  { name: 'RISING STORM',  cost: 60,  damage: 28, duration: 50,  motion: 'special_rising',  effectColor: '#ffd700', description: '下からのアッパー連打!' },
];

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

  // ランダムに技を選択
  specialMoveIndex = Math.floor(Math.random() * SPECIAL_MOVES.length);
  const move = SPECIAL_MOVES[specialMoveIndex];
  specialMotion = move.motion;
  specialTimer = move.duration;

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

  // フラッシュ&エフェクト
  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 = 80; flashTimer = 12; hitChromatic = 20;

  // 技名テキスト表示
  showHitNumber(`⚡ ${move.name} ⚡`, move.effectColor, 2.2);
  spawnParticles(W()/2, H()/2, move.effectColor, 60);
  spawnParticles(W()/2, H()/2, '#ffffff', 30);

  // ダメージ適用: モーション中盤で判定
  const hitFrame = Math.floor(move.duration * 0.45);
  const hitDelay = hitFrame * (1000 / 60);
  setTimeout(() => {
    if (!gameRunning) return;
    const result = evaluateHit(playerPos, enemyPos, enemy.state === 'blocking', 'head');
    if (result.hit) {
      const dmg = Math.floor(move.damage * (result.isClean ? 1.5 : 1.0));
      enemy.hp = Math.max(0, enemy.hp - dmg);
      applyLimbDamage('enemy', result.part, dmg);
      hitEnemyVisual(dmg, 0, -100, true, result.part);
      // 追加ヒット
      if (move.motion === 'special_twin' || move.motion === 'special_rising') {
        const hits = move.motion === 'special_twin' ? 2 : 3;
        for (let i = 1; i <= hits; i++) {
          setTimeout(() => {
            if (!gameRunning || enemy.hp <= 0) return;
            const r2 = evaluateHit(playerPos, enemyPos, enemy.state === 'blocking', i % 2 === 0 ? 'body' : 'head');
            if (r2.hit) {
              const d2 = Math.floor(move.damage * 0.4 * (r2.isClean ? 1.3 : 1.0));
              enemy.hp = Math.max(0, enemy.hp - d2);
              applyLimbDamage('enemy', r2.part, d2);
              hitEnemyVisual(d2, (Math.random()-0.5)*100, (Math.random()-0.5)*80, r2.isClean, r2.part);
              if (enemy.hp <= 0) onEnemyKO();
            }
          }, i * 200);
        }
      }
      if (enemy.hp <= 0) onEnemyKO();
    } else {
      showHitNumber(result.reason, '#aaa');
    }
    eff.style.opacity = '0';
  }, hitDelay);

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

// 敵必殺技発動
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((20 + round * 5) * 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;

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 = '';
  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) 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) {
        playerStamina -= 15; aimY = lw.y < 0.35 ? -0.5 : 0.1; playerBlocking = false; playerAction = 'windupL';
        playerActionTimer = setTimeout(() => { if (playerAction === 'windupL') { playerAction = 'punchL'; tryPunch('left', Math.min(600, speedL * 3000)); setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300); } }, 80);
        camPunchCooldownL = 20; 
      }
    }
    if (camPunchCooldownR <= 0 && speedR > 0.18 && rw.visibility > 0.5 && playerAction === 'idle') {
      if (velRX < -0.1 && playerStamina >= 15) {
        playerStamina -= 15; aimY = rw.y < 0.35 ? -0.5 : 0.1; playerBlocking = false; playerAction = 'windupR';
        playerActionTimer = setTimeout(() => { if (playerAction === 'windupR') { playerAction = 'punchR'; tryPunch('right', Math.min(600, speedR * 3000)); setTimeout(() => { if (playerAction === 'punchR') playerAction = 'idle'; }, 300); } }, 80);
        camPunchCooldownR = 20;
      }
    }
  }

  if (lw.y < ls.y - 0.05 && lw.visibility > 0.4 && rw.y < rs.y - 0.05 && rw.visibility > 0.4 && playerAction === 'idle') {
    camGuardFrames++; if (camGuardFrames > 3) { mouseL = true; mouseR = true; updatePlayerAction(); }
  } else {
    if (camGuardFrames > 3) { mouseL = false; mouseR = false; updatePlayerAction(); }
    camGuardFrames = 0;
  }

  if (camPunchCooldownL > 0) camPunchCooldownL--;
  if (camPunchCooldownR > 0) camPunchCooldownR--;
  prevWristL = { x: lw.x, y: lw.y }; prevWristR = { x: rw.x, y: rw.y };

  if (controlMode === 'camera') {
    const lwArmX = (1.0 - lw.x - 0.5) * 800 - targetPlayerPos.x; const lwArmY = (lw.y - 0.5) * -600;
    const rwArmX = (1.0 - rw.x - 0.5) * 800 - targetPlayerPos.x; const rwArmY = (rw.y - 0.5) * -600;
    if (playerAction === 'idle' || playerAction === 'guard') {
      targetArmL.x = Math.max(-350, Math.min(350, lwArmX)); targetArmL.y = Math.max(-400, Math.min(0, lwArmY));
      targetArmR.x = Math.max(-350, Math.min(350, rwArmX)); targetArmR.y = Math.max(-400, Math.min(0, rwArmY));
    }
  }
}

// ─── 入力とアクション ─────────────────────────────────────────
function onMouseMove(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  targetViewOffsetX = -(e.clientX - W()/2) * 0.4;
  targetViewOffsetY = -(e.clientY - H()/2) * 0.4;
  aimY = (e.clientY / H()) * 2 - 1; 
  // マウスの移動量を大幅に拡大
  targetPlayerPos.x = ((e.clientX - W()/2) / (W()/2)) * 1400; 
  targetPlayerPos.z = ((e.clientY - H()/2) / (H()/2)) * 800 + 200; 
}
function onMouseDown(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  if (e.button === 0) mouseL = true;
  if (e.button === 2) mouseR = true;
  updatePlayerAction();
}
function onMouseUp(e) {
  if (!gameRunning || controlMode !== 'mouse') return;
  if (e.button === 0) mouseL = false;
  if (e.button === 2) mouseR = false;
  updatePlayerAction();
}

function updatePlayerAction() {
  if (playerAction === 'recoil') return;
  if (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);
  const dz = attackerPos.z - defenderPos.z;

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

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

function tryPunch(side, speed) {
  if (specialActive) return; // 必殺技発動中は通常パンチ無効
  const targetPart = getTargetPart(aimY);
  if (side === 'left') aiMemory.leftStrikes++; else aiMemory.rightStrikes++;
  saveMemory();

  const effectiveSpeed = speed * staminaDebuff;

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

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

  const result = evaluateHit(playerPos, enemyPos, enemy.state === 'blocking', targetPart);
  if (!result.hit) {
    if (result.reason === 'GUARD!') playSound('guard');
    showHitNumber(result.reason, '#aaa'); combo = 0; updateUI();
  } else {
    const baseDmg = Math.max(6, Math.floor(effectiveSpeed * 0.04));
    const dmg = Math.floor(baseDmg * result.multi);
    enemy.hp = Math.max(0, enemy.hp - dmg);
    applyLimbDamage('enemy', result.part, dmg);
    hitEnemyVisual(dmg, side === 'right' ? 50 : -50, result.part === 'head' ? -150 : -50, result.isClean, result.part);
    // 必殺技ゲージをヒットごとに増加 (CLEAN HITは多め)
    updateSpecialGauge(result.isClean ? 18 : 9);
    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++; score += dmg * combo * (isCleanHit ? 2 : 1);
  enemy.state = 'hurt'; enemy.stateTimer = 30; enemy.hurtShake = isCleanHit ? 70 : 35;
  hitStop = isCleanHit ? 12 : 6; screenShake = isCleanHit ? 35 : 18; flashTimer = isCleanHit ? 6 : 3;

  const cfg = PART_CONFIG[part] || PART_CONFIG.body;
  const color = isCleanHit ? '#ffd700' : cfg.color;
  spawnParticles(W()/2 + ox, H()/2 + oy, '#ffffff', isCleanHit ? 18 : 8);
  spawnParticles(W()/2 + ox, H()/2 + oy, color,     isCleanHit ? 30 : 14);
  if (isCleanHit) {
    spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ffd700', 20, -1.0, 0.4);
    spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ff6600', 15, -1.2, 0.3);
    if (enemy.hp / enemy.maxHP < 0.4) spawnParticlesDirected(W()/2 + ox + 20, H()/2 + oy, '#ff0000', 12, -0.8, 0.6);
  }
  showHitNumber(isCleanHit ? `${cfg.label} CLEAN! ${dmg}` : `${cfg.label} ${dmg}`, color, isCleanHit ? 1.5 : 1);

  if (combo >= 3) {
    const cd = document.getElementById('combo-display'); cd.textContent = `${combo} HIT!!`; cd.style.opacity = '1';
    clearTimeout(comboTimer); comboTimer = setTimeout(() => cd.style.opacity = '0', 1000);
  }
  if (isCleanHit && currentSkinMat.emissive !== undefined) {
    const origEmissive = currentSkinMat.emissive.getHex(); currentSkinMat.emissive.setHex(0xffaa00); currentSkinMat.emissiveIntensity = 1.0;
    setTimeout(() => { currentSkinMat.emissive.setHex(origEmissive); currentSkinMat.emissiveIntensity = 0; }, 120);
  }
  updateUI();
}

function spawnParticlesDirected(x, y, color, count, vyBias, spread) {
  for (let i = 0; i < count; i++) {
    const a = Math.random() * Math.PI * 2, s = 8 + Math.random() * 18;
    particles.push({ x, y, vx: Math.cos(a) * s * spread, vy: Math.sin(a) * s * spread + vyBias * (8 + Math.random() * 10), alpha: 1, size: 4 + Math.random() * 9, color });
  }
}

function takeDamage(dmg, isCleanHit, hitPart) {
  hitPart = hitPart || 'body'; playSound('damage');
  playerHP = Math.max(0, playerHP - dmg); combo = 0;
  applyLimbDamage('player', hitPart, dmg);
  // ダメージを受けると必殺技ゲージが少し増加(根性)
  updateSpecialGauge(isCleanHit ? 12 : 6);

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

  updateUI(); screenShake = isCleanHit ? 90 : 45; flashTimer  = isCleanHit ? 7  : 4;
  const hitEl = document.getElementById('hit-effect');
  if (isCleanHit) {
    hitEl.style.background = 'radial-gradient(circle, transparent 20%, rgba(255,200,0,0.85) 90%)'; showHitNumber('CRITICAL DANGER!', '#ff0000', 1.5);
    if (playerHP < 30) hitEl.style.background = 'radial-gradient(circle, transparent 10%, rgba(220,0,0,0.9) 80%)';
  } else { hitEl.style.background = 'radial-gradient(circle, transparent 25%, rgba(255,0,85,0.7) 100%)'; }
  hitEl.style.opacity = '1'; setTimeout(() => { hitEl.style.opacity = '0'; }, isCleanHit ? 220 : 150); hitChromatic = isCleanHit ? 12 : 6;

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

function updateEnemyAI() {
  if (!roundInProgress) return; 
  if (enemy.stateTimer > 0) {
    enemy.stateTimer--;
    if (enemy.state.includes('windup') && enemy.stateTimer === 15) {
      enemy.state = 'punch' + enemy.nextPunch;
      const result = evaluateHit(enemyPos, playerPos, playerBlocking, 'body');
      if (!result.hit) {
        if (result.reason === 'GUARD!') { playSound('guard'); spawnParticles(W() / 2 - 80, H() / 2 + 50, '#00f3ff', 14); spawnParticles(W() / 2 + 80, H() / 2 + 50, '#00f3ff', 14); screenShake = 10; }
        showHitNumber(result.reason, '#aaa');
      } else { takeDamage(Math.floor((8 + round * 2) * result.multi), result.isClean, Math.random() < 0.3 ? 'head' : 'body'); }
    }
    // 敵必殺技モーション中は通常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; }
  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--;

  // 必殺技タイマー更新
  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