カメラで対戦する「MotionBoxing」
【更新履歴】
・2026/3/12 バージョン1.0公開。
(中略)
・2026/3/12 バージョン1.5公開。(複数人対戦を追加)
・2026/3/12 バージョン1.6公開。(必殺技を追加)
【操作説明】(シングルモードでマウス操作の場合)
・マウスを移動させると、プレイヤーキャラの位置が移動します。
・左ボタンで左パンチ。
・右ボタンで右パンチ。
・左右を同時に押すとガード。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Motion Boxing 1.6</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #050505;
color: white;
font-family: 'Arial', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
user-select: none;
}
#ui {
position: absolute;
top: 10px;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 30px;
z-index: 10;
pointer-events: none;
}
.stat-box {
background: rgba(0,0,0,0.8);
border: 2px solid #00f3ff;
border-radius: 8px;
padding: 10px 25px;
text-align: center;
box-shadow: 0 0 10px #00f3ff;
}
.stat-box .label { font-size: 0.8rem; color: #aaa; text-transform: uppercase; letter-spacing: 2px; }
.stat-box .value { font-size: 1.8rem; font-weight: bold; color: #fff; text-shadow: 0 0 10px #00f3ff; }
#health-bar-container {
position: absolute;
top: 100px;
width: 80%;
left: 10%;
z-index: 10;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.health-bar-wrapper { width: 100%; }
.health-label { font-size: 0.8rem; color: #ddd; margin-bottom: 3px; font-weight: bold; text-shadow: 1px 1px 2px black; }
.health-bar-bg {
background: rgba(30,30,30,0.8);
border-radius: 4px;
height: 20px;
overflow: hidden;
border: 2px solid #444;
box-shadow: inset 0 0 10px black;
}
.health-bar-fill { height: 100%; border-radius: 2px; transition: width 0.15s ease-out; }
#enemy-health-fill { background: linear-gradient(90deg, #ff0055, #ff4444); width: 100%; box-shadow: 0 0 15px #ff0055; }
#player-health-fill { background: linear-gradient(90deg, #00ff88, #00b359); width: 100%; box-shadow: 0 0 15px #00ff88; }
#player-stamina-fill { background: linear-gradient(90deg, #00f3ff, #0088ff); width: 100%; box-shadow: 0 0 15px #00f3ff; transition: width 0.05s linear; }
#canvas-container {
position: relative;
width: 100vw;
height: 100vh;
}
#threejs-container {
position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;
}
#gameCanvas {
position: absolute; top: 0; left: 0; width: 100vw; height: 100vh;
display: block; cursor: crosshair; z-index: 2; pointer-events: none;
}
#video { display: none; }
#overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); backdrop-filter: blur(5px);
display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 20;
}
#overlay h1 { font-size: 4rem; color: #fff; text-shadow: 0 0 20px #00f3ff, 0 0 40px #00f3ff; font-style: italic; margin-bottom: 30px; letter-spacing: 2px; }
.menu-panel {
background: rgba(20,20,20,0.9); border: 1px solid #00f3ff; padding: 30px;
border-radius: 15px; width: 450px; text-align: center; box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);
}
.menu-panel h3 { margin-bottom: 20px; color: #ffd700; }
.menu-panel p { margin-bottom: 15px; font-size: 0.95rem; color: #ccc; line-height: 1.5; }
.key-hint { color: #00ff88; font-weight: bold; }
.btn {
display: block; width: 100%; padding: 15px; margin-bottom: 15px; font-size: 1.2rem;
background: transparent; color: #00f3ff; border: 2px solid #00f3ff; border-radius: 8px;
cursor: pointer; font-weight: bold; transition: all 0.2s;
}
.btn:hover { background: #00f3ff; color: #000; box-shadow: 0 0 20px #00f3ff; }
.btn-red { border-color: #ff0055; color: #ff0055; }
.btn-red:hover { background: #ff0055; color: #fff; box-shadow: 0 0 20px #ff0055; }
.btn-icon { position: absolute; top: 80px; right: 20px; width: auto; padding: 8px 16px; z-index: 30; pointer-events: auto; font-size: 0.95rem; }
input[type="text"] { width: 100%; padding: 12px; background: #111; border: 1px solid #444; color: white; font-size: 1.1rem; border-radius: 5px; margin-bottom: 15px; text-align: center; }
details > summary { cursor: pointer; font-weight: bold; color: #00f3ff; outline: none; padding: 5px 0; }
details > ul { list-style: none; padding-left: 20px; border-left: 1px solid #444; margin-bottom: 15px; }
details > ul > li { margin-bottom: 10px; font-size: 0.95rem; }
#hit-effect { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 15; opacity: 0; transition: opacity 0.1s; }
#combo-display { position: absolute; bottom: 50px; left: 50%; transform: translateX(-50%); font-size: 3.5rem; font-weight: bold; color: #fff; text-shadow: 0 0 20px #ff0055, 0 0 40px #ff0055; z-index: 10; pointer-events: none; opacity: 0; transition: opacity 0.2s; font-style: italic; }
#msg-overlay { position: absolute; top: 40%; width: 100%; text-align: center; font-size: 6rem; font-weight: bold; color: white; text-shadow: 0 0 30px #ff0055; z-index: 100; pointer-events: none; opacity: 0; }
/* ラウンドブレイクオーバーレイ */
#round-break-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.75); display: none; flex-direction: column;
align-items: center; justify-content: center; z-index: 50; pointer-events: none;
}
#round-break-overlay .rb-round {
font-size: 2rem; color: #aaa; letter-spacing: 6px; text-transform: uppercase; margin-bottom: 10px;
}
#round-break-overlay .rb-title {
font-size: 7rem; font-weight: 900; color: #fff; font-style: italic;
text-shadow: 0 0 40px #ff0055, 0 0 80px #ff0055; line-height: 1;
}
#round-break-overlay .rb-sub {
font-size: 1.4rem; color: #ffd700; margin-top: 18px; letter-spacing: 4px;
}
/* 部位ダメージUIバー */
#limb-status {
position: absolute; bottom: 20px; right: 20px; z-index: 10;
pointer-events: none; text-align: right;
}
.limb-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; justify-content: flex-end; }
.limb-label { font-size: 0.7rem; color: #aaa; width: 60px; text-align: right; }
.limb-bar-bg { width: 80px; height: 8px; background: rgba(30,30,30,0.8); border-radius: 3px; border: 1px solid #333; overflow: hidden; }
.limb-bar-fill { height: 100%; border-radius: 2px; transition: width 0.2s; }
/* 必殺技ゲージ */
#special-gauge-container {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
z-index: 10; pointer-events: none; text-align: center; width: 260px;
}
#special-gauge-label {
font-size: 0.75rem; color: #ffd700; letter-spacing: 3px; text-transform: uppercase;
margin-bottom: 4px; text-shadow: 0 0 8px #ffd700;
}
#special-gauge-bg {
background: rgba(20,20,20,0.85); border: 2px solid #ffd700;
border-radius: 6px; height: 18px; overflow: hidden;
box-shadow: 0 0 10px rgba(255,215,0,0.4);
}
#special-gauge-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, #ff8800, #ffd700, #ffffff);
box-shadow: 0 0 12px #ffd700;
transition: width 0.1s linear;
border-radius: 4px;
}
/* 必殺技発動ボタン(ゲージMAX時) */
#special-ready-btn {
position: absolute; bottom: 52px; left: 50%; transform: translateX(-50%);
z-index: 30; pointer-events: none; display: none;
padding: 12px 36px; font-size: 1.4rem; font-weight: 900;
background: linear-gradient(135deg, #ff8800, #ffd700);
color: #000; border: 3px solid #fff; border-radius: 12px;
cursor: default; letter-spacing: 3px;
box-shadow: 0 0 30px #ffd700, 0 0 60px #ff8800;
animation: specialPulse 0.6s ease-in-out infinite alternate;
}
@keyframes specialPulse {
from { box-shadow: 0 0 20px #ffd700, 0 0 40px #ff8800; transform: translateX(-50%) scale(1.0); }
to { box-shadow: 0 0 40px #fff, 0 0 80px #ffd700; transform: translateX(-50%) scale(1.06); }
}
/* 必殺技エフェクトオーバーレイ */
#special-effect-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 25; opacity: 0;
background: radial-gradient(ellipse at center, rgba(255,215,0,0.0) 20%, rgba(255,140,0,0.7) 80%, rgba(255,0,85,0.9) 100%);
transition: opacity 0.08s;
}
/* ヒットゾーンインジケーター */
#hit-zone-indicator {
position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%);
z-index: 10; pointer-events: none; text-align: center;
opacity: 0; transition: opacity 0.15s;
}
#hit-zone-indicator .hz-label {
font-size: 0.72rem; color: #aaa; letter-spacing: 2px; margin-bottom: 3px;
}
#hit-zone-indicator .hz-value {
font-size: 1.05rem; font-weight: 900; letter-spacing: 3px;
text-shadow: 0 0 12px currentColor;
}
#hit-zone-indicator .hz-hint {
font-size: 0.68rem; color: #ffd700; margin-top: 3px; letter-spacing: 1px;
}
/* チャットログ用CSS */
#chat-log {
position: absolute; bottom: 20px; left: 20px; width: 320px; max-height: 250px;
overflow-y: auto; z-index: 10; pointer-events: none; display: flex;
flex-direction: column; gap: 6px;
}
.chat-msg {
background: rgba(10, 10, 10, 0.7); border-left: 3px solid #00f3ff;
padding: 6px 12px; border-radius: 4px; font-size: 0.95rem; color: #fff;
text-shadow: 1px 1px 2px #000; animation: fadeOut 8s forwards; word-break: break-all;
}
.chat-msg.mine { border-left-color: #00ff88; text-align: right; }
@keyframes fadeOut { 0% { opacity: 1; } 80% { opacity: 1; } 100% { opacity: 0; } }
</style>
</head>
<body>
<div id="ui">
<div class="stat-box">
<div class="label">SCORE</div>
<div class="value" id="score">0</div>
</div>
<div class="stat-box" id="round-box">
<div class="label">ROUND</div>
<div class="value" id="round">1 / 3</div>
</div>
<div class="stat-box" id="time-box">
<div class="label">TIME</div>
<div class="value" id="timer">3:00</div>
</div>
<div class="stat-box">
<div class="label">COMBO</div>
<div class="value" id="combo">x0</div>
</div>
</div>
<div id="health-bar-container">
<div class="health-bar-wrapper">
<div class="health-label" id="enemy-label">ENEMY HP (頭部ダメージ大)</div>
<div class="health-bar-bg"><div class="health-bar-fill" id="enemy-health-fill"></div></div>
</div>
<div class="health-bar-wrapper">
<div class="health-label">PLAYER HP</div>
<div class="health-bar-bg"><div class="health-bar-fill" id="player-health-fill"></div></div>
</div>
<div class="health-bar-wrapper">
<div class="health-label">STAMINA</div>
<div class="health-bar-bg" style="height: 12px;"><div class="health-bar-fill" id="player-stamina-fill"></div></div>
</div>
</div>
<div id="canvas-container">
<video id="video" autoplay playsinline></video>
<audio id="remote-audio" autoplay style="display:none;"></audio>
<div id="threejs-container"></div>
<canvas id="gameCanvas"></canvas>
<div id="hit-effect"></div>
<div id="combo-display"></div>
<div id="msg-overlay"></div>
<div id="chat-log"></div>
<div id="round-break-overlay">
<div class="rb-round" id="rb-round-label">ROUND 1</div>
<div class="rb-title" id="rb-title">FIGHT!</div>
<div class="rb-sub" id="rb-sub"></div>
</div>
<!-- 必殺技ゲージ -->
<div id="special-gauge-container">
<div id="special-gauge-label">⚡ SPECIAL ⚡</div>
<div id="special-gauge-bg"><div id="special-gauge-fill"></div></div>
</div>
<div id="special-ready-btn">💥 必殺技発動!クリック!</div>
<div id="special-effect-overlay"></div>
<!-- ヒットゾーンインジケーター -->
<div id="hit-zone-indicator">
<div class="hz-label">HIT ZONE</div>
<div class="hz-value" id="hz-value">─</div>
<div class="hz-hint" id="hz-hint"></div>
</div>
<div id="limb-status">
<div class="limb-row">
<span class="limb-label">L.ARM</span>
<div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-larm" style="width:100%;background:#00f3ff;"></div></div>
</div>
<div class="limb-row">
<span class="limb-label">R.ARM</span>
<div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-rarm" style="width:100%;background:#00f3ff;"></div></div>
</div>
<div class="limb-row">
<span class="limb-label">BODY</span>
<div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-body" style="width:100%;background:#ffd700;"></div></div>
</div>
<div class="limb-row">
<span class="limb-label">LEGS</span>
<div class="limb-bar-bg"><div class="limb-bar-fill" id="limb-legs" style="width:100%;background:#ffd700;"></div></div>
</div>
</div>
</div>
<button class="btn btn-icon" onclick="openSettings()" style="display:none;" id="settings-btn">⚙️ 設定</button>
<div id="overlay">
<h1>MotionBoxing</h1>
<div class="menu-panel" id="menu-main">
<h3>モードを選択</h3>
<p>踏み込んで打てば<span class="key-hint">大ダメージ(CLEAN HIT)</span>!<br>
パンチを合わせて弾き返せ(PARRY)!</p>
<button class="btn" onclick="showMenu('menu-single')">👤 1 VS 1 (対NPC)</button>
<button class="btn btn-red" onclick="showMenu('menu-multi')">🌐 マルチプレイ通信対戦</button>
<button class="btn" style="border-color:#aaa; color:#aaa;" onclick="showMenu('menu-settings')">⚙️ 設定 (Settings)</button>
</div>
<div class="menu-panel" id="menu-single" style="display:none;">
<h3>操作方法 (1 VS 1)</h3>
<p>【マウス】ポインタで移動。左右クリックでパンチ。<span class="key-hint">同時押しでガード</span><br>
【カメラ】自分の体の動きでゲーム内の体を移動・回避<br>
⚡<span class="key-hint">SPECIALゲージMAX時:次のクリックで必殺技発動!</span><br>
UPPER CUT(近距離/頭)・BODY BLOW(中距離/胴)・LEG SWEEP(遠距離/足)を自動選択</p>
<button class="btn" onclick="initGame('single', 'camera')">📷 カメラでプレイ (全身)</button>
<button class="btn" onclick="initGame('single', 'mouse')">🖱️ マウスでプレイ</button>
<button class="btn" style="border-color:#555;color:#aaa;" onclick="showMenu('menu-main')">戻る</button>
</div>
<div class="menu-panel" id="menu-multi" style="display:none;">
<h3>🌐 マルチプレイ 通信対戦</h3>
<div style="margin-bottom: 20px; text-align: left;">
<label style="font-size: 0.9rem; color: #00f3ff; font-weight: bold;">あなたのID (相手に教えてください):</label>
<div style="display: flex; gap: 5px; margin-top: 5px;">
<input type="text" id="my-id-display" readonly value="取得中..." style="margin-bottom: 0; background: #222; color: #00f3ff; font-weight: bold;">
<button class="btn" id="copy-btn" onclick="copyMyId()" style="margin: 0; width: 90px; padding: 10px; font-size: 0.95rem;">コピー</button>
</div>
</div>
<div style="margin-bottom: 20px; text-align: left;">
<label style="font-size: 0.9rem; color: #aaa;">接続先:</label>
<input type="text" id="target-id-input" placeholder="相手のIDを入力して接続" style="margin-top: 5px;">
</div>
<div style="display:flex; gap:10px; margin-bottom:15px;">
<button class="btn btn-red" onclick="connectToPeer('camera')" style="margin:0;font-size:1rem;">📷 カメラ接続</button>
<button class="btn btn-red" onclick="connectToPeer('mouse')" style="margin:0;font-size:1rem;">🖱️ マウス接続</button>
</div>
<button class="btn" style="border-color:#555;color:#aaa;" onclick="showMenu('menu-main')">戻る</button>
</div>
<div class="menu-panel" id="menu-settings" style="display:none; text-align:left; max-height:80vh; overflow-y:auto;">
<h3 style="text-align:center;">⚙️ SETTINGS</h3>
<ul style="list-style:none; padding:0; color:#ccc;">
<li>
<details open>
<summary>💬 コミュニケーション (Chat)</summary>
<ul>
<li>
<label style="font-size:0.9rem;">送信モード (マルチプレイ用):</label><br>
<select id="comm-mode" onchange="changeCommMode(this.value)" style="width:100%; padding:8px; background:#111; color:#fff; border:1px solid #00f3ff; border-radius:4px; margin-top:5px; margin-bottom:10px;">
<option value="none">送信しない (Mute)</option>
<option value="text">🗣️ 音声認識テキスト (Speech-to-Text)</option>
<option value="voice">🎙️ ボイスチャット (Voice)</option>
</select>
</li>
</ul>
</details>
</li>
<li>
<details open>
<summary>🎮 ゲームシステム (System)</summary>
<ul>
<li style="color:#00ff88;">AI長期記憶学習: 有効 (localStorage)</li>
<li>スタミナシステム: 有効 (攻撃時に消費)</li>
<li>部位ダメージ・相殺判定: 有効</li>
</ul>
</details>
</li>
<li>
<details open>
<summary>✨ グラフィックス (Graphics/WebGL)</summary>
<ul>
<li>
<label>敵の質感シェーダー (Material):</label><br>
<select id="tex-select" onchange="changeTexture(this.value)" style="width:100%; padding:5px; background:#111; color:#fff; border:1px solid #00f3ff; border-radius:4px; margin-top:5px;">
<option value="normal">Normal (肌)</option>
<option value="slime">Slime (スライム・水滴風)</option>
<option value="metal">Fluid Metal (流体金属)</option>
<option value="crystal">Crystal (角ばったクリスタル)</option>
</select>
</li>
</ul>
</details>
</li>
<li>
<details open>
<summary>🎵 サウンド (Audio)</summary>
<ul>
<li><label>BGM音量:</label> <input type="range" id="bgm-vol" min="0" max="100" value="60" onchange="updateAudioVol()"></li>
<li><label>SE音量:</label> <input type="range" id="se-vol" min="0" max="100" value="80"></li>
</ul>
</details>
</li>
</ul>
<button class="btn" onclick="closeSettings()" style="margin-top:20px;">閉じる</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose/pose.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<script>
// ─── オーディオシステム ───────────────────────────
const AudioContext = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
let bgmGain = null;
let seVol = 0.8;
function initAudio() {
if (!audioCtx) audioCtx = new AudioContext();
if (audioCtx.state === 'suspended') audioCtx.resume();
updateAudioVol();
}
function updateAudioVol() {
seVol = document.getElementById('se-vol').value / 100;
if(bgmGain) bgmGain.gain.setTargetAtTime((document.getElementById('bgm-vol').value / 100) * 0.25, audioCtx.currentTime, 0.1);
}
function playTone(freq, type, duration, volMod) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(seVol * volMod, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + duration);
}
function playSound(type) {
if (type === 'hit') { playTone(800, 'square', 0.1, 0.4); setTimeout(()=>playTone(1200, 'square', 0.15, 0.4), 50); }
if (type === 'clean') { playTone(1000, 'square', 0.1, 0.7); setTimeout(()=>playTone(1500, 'square', 0.25, 0.7), 50); }
if (type === 'guard') { playTone(400, 'sawtooth', 0.15, 0.3); }
if (type === 'parry') { playTone(600, 'triangle', 0.1, 0.6); playTone(800, 'sine', 0.2, 0.6); }
if (type === 'swing') { playTone(150, 'sine', 0.2, 0.4); }
if (type === 'damage') { playTone(100, 'sawtooth', 0.4, 0.8); }
if (type === 'special_ready') {
playTone(880, 'sine', 0.15, 0.6); setTimeout(()=>playTone(1100, 'sine', 0.15, 0.7), 120);
setTimeout(()=>playTone(1320, 'sine', 0.25, 0.9), 240);
}
if (type === 'special_activate') {
playTone(200, 'sawtooth', 0.08, 0.9);
setTimeout(()=>playTone(400, 'sawtooth', 0.08, 0.9), 60);
setTimeout(()=>playTone(800, 'square', 0.08, 1.0), 120);
setTimeout(()=>playTone(1600, 'square', 0.3, 1.0), 180);
setTimeout(()=>playTone(2200, 'sine', 0.4, 0.8), 260);
}
if (type === 'special_enemy') {
playTone(80, 'sawtooth', 0.1, 0.9); setTimeout(()=>playTone(160, 'sawtooth', 0.1, 0.9), 80);
setTimeout(()=>playTone(320, 'square', 0.1, 1.0), 160); setTimeout(()=>playTone(100, 'sawtooth', 0.5, 1.0), 240);
}
}
function startEpicBGM() {
if(!audioCtx) initAudio();
if(bgmGain) return;
bgmGain = audioCtx.createGain();
updateAudioVol();
bgmGain.connect(audioCtx.destination);
const delay = audioCtx.createDelay();
delay.delayTime.value = 0.8;
const feedback = audioCtx.createGain();
feedback.gain.value = 0.5;
delay.connect(feedback); feedback.connect(delay); delay.connect(bgmGain);
const freqs = [55.00, 110.00, 164.81, 220.00];
freqs.forEach(f => {
const osc = audioCtx.createOscillator();
osc.type = 'sine'; osc.frequency.value = f;
const lfo = audioCtx.createOscillator();
lfo.type = 'sine'; lfo.frequency.value = 0.05 + Math.random() * 0.05;
const lfoGain = audioCtx.createGain(); lfoGain.gain.value = 0.3;
const oscGain = audioCtx.createGain(); oscGain.gain.value = 0.5;
lfo.connect(lfoGain);
lfoGain.connect(oscGain.gain);
osc.connect(oscGain); oscGain.connect(delay); oscGain.connect(bgmGain);
osc.start(); lfo.start();
});
}
let aiMemory = JSON.parse(localStorage.getItem('vrBout_memory')) || { leftStrikes: 0, rightStrikes: 0, dodges: 0, blocks: 0 };
function saveMemory() { localStorage.setItem('vrBout_memory', JSON.stringify(aiMemory)); }
// ─── 必殺技システム ───────────────────────────────────────────
// 技リスト: 部位狙い特化型
// targetPart: 狙う部位 minDz: 発動に必要な最小接近距離(dz) maxDz: 最大距離
// rangeBonus: この技は通常より遠くても当たる knockback: 距離を開ける効果
const SPECIAL_MOVES = [
{
name: 'UPPER CUT', // アッパーカット → 頭部狙い
motion: 'special_rising',
effectColor: '#ff4400',
description: '下から頭部へのアッパーカット!近距離必須',
targetPart: 'head',
baseDamage: 38,
minDz: 80, // 近すぎても外れる(すり抜け)
maxDz: 320, // 近距離限定
rangeBonus: 0,
knockback: 80,
extraHits: [],
},
{
name: 'BODY BLOW', // ボディブロー → 胴体狙い
motion: 'special_twin',
effectColor: '#ff8800',
description: '左右交互に胴体へ連打!中距離で有効',
targetPart: 'body',
baseDamage: 22,
minDz: 50,
maxDz: 420,
rangeBonus: 60,
knockback: 20,
extraHits: ['body', 'body'], // 3ヒット合計
},
{
name: 'LEG SWEEP', // 足払い → 足部狙い
motion: 'special_meteor',
effectColor: '#ffd700',
description: '足払いで相手をよろめかせる!スタミナ奪取',
targetPart: 'legs',
baseDamage: 18,
minDz: 0,
maxDz: 380,
rangeBonus: 0,
knockback: 0,
extraHits: [],
staminaDrain: 35, // 相手スタミナを奪う
},
];
let specialGauge = 0; // 0 ~ 100
let specialReady = false; // ゲージMAX状態か
let specialActive = false; // 必殺技発動中か
let specialTimer = 0; // 必殺技モーションフレームカウンタ
let specialMoveIndex = 0; // 発動する技インデックス
let specialMotion = ''; // 現在の必殺技モーション名
let enemySpecialTimer = 0; // 敵必殺技カウンタ
let enemySpecialActive = false; // 敵必殺技発動中か
let enemySpecialMotion = ''; // 敵必殺技モーション名
function updateSpecialGauge(delta) {
if (!gameRunning || !roundInProgress) return;
specialGauge = Math.max(0, Math.min(100, specialGauge + delta));
document.getElementById('special-gauge-fill').style.width = specialGauge + '%';
if (specialGauge >= 100 && !specialReady) {
specialReady = true;
playSound('special_ready');
document.getElementById('special-ready-btn').style.display = 'block';
document.getElementById('special-gauge-bg').style.borderColor = '#fff';
document.getElementById('special-gauge-bg').style.boxShadow = '0 0 20px #ffd700, 0 0 40px #ff8800';
}
}
function activateSpecialMove() {
if (!specialReady || specialActive || !gameRunning || !roundInProgress) return;
specialReady = false;
specialActive = true;
specialGauge = 0;
document.getElementById('special-ready-btn').style.display = 'none';
document.getElementById('special-gauge-fill').style.width = '0%';
document.getElementById('special-gauge-bg').style.borderColor = '#ffd700';
document.getElementById('special-gauge-bg').style.boxShadow = '0 0 10px rgba(255,215,0,0.4)';
// 現在の位置関係から最適な技を自動選択
const dz = playerPos.z - enemyPos.z; // 正=プレイヤーが後方、敵がdz分前
const dist = Math.abs(dz);
// 距離に応じた技選択: 近→アッパー、中→ボディブロー、遠→足払い
if (dist <= 320) {
specialMoveIndex = 0; // UPPER CUT
} else if (dist <= 420) {
specialMoveIndex = 1; // BODY BLOW
} else {
specialMoveIndex = 2; // LEG SWEEP (遠距離でも足払いは届く)
}
const move = SPECIAL_MOVES[specialMoveIndex];
specialMotion = move.motion;
const duration = move.motion === 'special_rising' ? 50 : move.motion === 'special_twin' ? 60 : 80;
specialTimer = duration;
playSound('special_activate');
playerAction = specialMotion;
playerBlocking = false;
clearTimeout(playerActionTimer);
// フラッシュ&エフェクト
const eff = document.getElementById('special-effect-overlay');
eff.style.background = `radial-gradient(ellipse at center, rgba(255,255,255,0.1) 0%, ${move.effectColor}cc 60%, rgba(0,0,0,0.5) 100%)`;
eff.style.opacity = '1';
screenShake = 60; flashTimer = 10; hitChromatic = 18;
showHitNumber(`⚡ ${move.name} ⚡`, move.effectColor, 2.0);
spawnParticles(W()/2, H()/2, move.effectColor, 50);
spawnParticles(W()/2, H()/2, '#ffffff', 25);
// 位置関係チェック用の判定関数
function evaluateSpecialHit(move) {
const curDzSigned = enemyPos.z - playerPos.z; // 正=敵が前方
const curDist = Math.abs(curDzSigned);
const curDx = Math.abs(playerPos.x - enemyPos.x);
// 位置関係による当否: 技ごとに有効距離が異なる
const maxReach = move.maxDz + move.rangeBonus;
if (curDzSigned < -80) return { hit: false, reason: 'MISS (BEHIND)' };
if (curDzSigned > maxReach) return { hit: false, reason: 'OUT OF RANGE!' };
if (curDist < move.minDz) return { hit: false, reason: 'TOO CLOSE!' };
if (curDx > 280) return { hit: false, reason: 'MISS (DODGED)' };
if (enemy.state === 'blocking') return { hit: false, reason: 'GUARD!' };
const isClean = (curDzSigned < move.maxDz * 0.6 && curDx < 160);
const cfg = PART_CONFIG[move.targetPart] || PART_CONFIG.body;
const dmg = Math.floor(move.baseDamage * cfg.multi * (isClean ? 1.5 : 1.0));
return { hit: true, dmg, isClean, cfg, part: move.targetPart };
}
// ダメージ判定: モーション中盤
const hitDelay = Math.floor(duration * 0.45) * (1000 / 60);
setTimeout(() => {
if (!gameRunning) return;
const r = evaluateSpecialHit(move);
if (r.hit) {
enemy.hp = Math.max(0, enemy.hp - r.dmg);
applyLimbDamage('enemy', r.part, r.dmg);
hitEnemyVisual(r.dmg, 0, r.part === 'head' ? -150 : r.part === 'legs' ? 80 : -50, r.isClean, r.part);
// 足払いはスタミナ奪取
if (move.staminaDrain && move.staminaDrain > 0) {
// 敵AIのstaminaはないがKOカウントを使って硬直を長くする
enemy.stateTimer = Math.max(enemy.stateTimer, 55);
enemy.state = 'hurt';
enemy.hurtShake = 60;
showHitNumber(`SWEEP! -${move.staminaDrain}STA`, '#ffd700', 1.4);
}
// ノックバック(足払い以外)
if (move.knockback > 0) {
enemyPos.z += move.knockback;
}
if (enemy.hp <= 0) { eff.style.opacity = '0'; onEnemyKO(); return; }
// 追加ヒット(ボディブロー)
if (move.extraHits && move.extraHits.length > 0) {
move.extraHits.forEach((part, i) => {
setTimeout(() => {
if (!gameRunning || enemy.hp <= 0) return;
const r2 = evaluateSpecialHit(move);
if (r2.hit) {
const d2 = Math.floor(move.baseDamage * 0.55 * (PART_CONFIG[part]?.multi || 1.2) * (r2.isClean ? 1.3 : 1.0));
enemy.hp = Math.max(0, enemy.hp - d2);
applyLimbDamage('enemy', part, d2);
hitEnemyVisual(d2, (Math.random()-0.5)*80, (Math.random()-0.5)*60, false, part);
if (enemy.hp <= 0) { eff.style.opacity = '0'; onEnemyKO(); }
}
}, (i + 1) * 180);
});
}
} else {
showHitNumber(result && result.reason ? result.reason : r.reason, '#aaa');
}
eff.style.opacity = '0';
}, hitDelay);
// モーション終了
setTimeout(() => {
specialActive = false;
specialMotion = '';
if (playerAction === move.motion) playerAction = 'idle';
eff.style.opacity = '0';
}, duration * (1000 / 60));
}
// 敵必殺技発動
function triggerEnemySpecial() {
if (enemySpecialActive || !roundInProgress) return;
enemySpecialActive = true;
const motions = ['e_special_smash', 'e_special_rush', 'e_special_upper'];
enemySpecialMotion = motions[Math.floor(Math.random() * motions.length)];
enemySpecialTimer = 70;
enemy.state = enemySpecialMotion;
enemy.stateTimer = 70;
playSound('special_enemy');
screenShake = 30;
showHitNumber('ENEMY SPECIAL!!', '#ff4400', 1.8);
spawnParticles(W()/2, H()/3, '#ff4400', 40);
// ダメージ判定(中盤)
setTimeout(() => {
if (!gameRunning) return;
const result = evaluateHit(enemyPos, playerPos, playerBlocking, Math.random() < 0.5 ? 'head' : 'body');
if (result.hit) {
const dmg = Math.floor((15 + round * 4) * result.multi);
takeDamage(dmg, true, result.part || 'body');
} else {
if (result.reason === 'GUARD!') { playSound('guard'); screenShake = 15; }
showHitNumber(result.reason, '#aaa');
}
// 追加ヒット(ラッシュ技は3連)
if (enemySpecialMotion === 'e_special_rush') {
for (let i = 1; i <= 2; i++) {
setTimeout(() => {
if (!gameRunning || playerHP <= 0) return;
const r2 = evaluateHit(enemyPos, playerPos, playerBlocking, 'body');
if (r2.hit) takeDamage(Math.floor((12 + round * 3) * r2.multi), false, 'body');
}, i * 220);
}
}
}, 600);
setTimeout(() => {
enemySpecialActive = false;
enemySpecialMotion = '';
if (enemy.state.startsWith('e_special')) { enemy.state = 'idle'; enemy.stateTimer = 0; }
}, 70 * (1000/60));
}
// ─── Three.js WebGL セットアップ ───────────────────────────────
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.0004);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 15000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x050505, 1);
document.getElementById('threejs-container').appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(0, 1000, 500); scene.add(dirLight);
const pointLight = new THREE.PointLight(0x00f3ff, 1.2, 3000); scene.add(pointLight);
const grid = new THREE.GridHelper(20000, 80, 0x00f3ff, 0x003344);
grid.position.y = -400; grid.material.opacity = 0.25; grid.material.transparent = true;
scene.add(grid);
const materials = {
normal: new THREE.MeshStandardMaterial({ color: 0xe0ac69, roughness: 0.6 }),
glove: new THREE.MeshStandardMaterial({ color: 0xff0055, roughness: 0.4 }),
playerBody: new THREE.MeshStandardMaterial({ color: 0x0088ff, roughness: 0.5 }),
playerArm: new THREE.MeshStandardMaterial({ color: 0x00b359, roughness: 0.5 }),
playerGlove: new THREE.MeshStandardMaterial({ color: 0x00f3ff, roughness: 0.2, emissive: 0x00f3ff, emissiveIntensity: 0.2 }),
playerPants: new THREE.MeshStandardMaterial({ color: 0x111133, roughness: 0.9 }),
pants: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 }),
shoe: new THREE.MeshStandardMaterial({ color: 0xdddddd }),
slime: new THREE.MeshPhysicalMaterial({ color: 0x00ffcc, transmission: 0.9, opacity: 1, transparent: true, roughness: 0.05, ior: 1.5 }),
metal: new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 1.0, roughness: 0.15 }),
crystal: new THREE.MeshPhysicalMaterial({ color: 0xddddff, transmission: 0.6, roughness: 0.0, clearcoat: 1.0, flatShading: true })
};
let currentSkinMat = materials.normal;
function changeTexture(type) { if (materials[type]) currentSkinMat = materials[type]; }
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = window.innerWidth; canvas.height = window.innerHeight;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', resize); resize();
const W = () => canvas.width; const H = () => canvas.height;
// ─── ゲーム状態・座標系 ─────────────────────────────────────────
let gameRunning = false;
let gameMode = 'single';
let controlMode = 'mouse';
let score = 0, round = 1, combo = 0;
const TOTAL_ROUNDS = 3;
const ROUND_DURATION = 180;
let roundTimeLeft = ROUND_DURATION;
let roundTimerInterval = null;
let roundInProgress = false;
let comboTimer = null;
let playerHP = 100, playerStamina = 100;
let enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
let playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
let staminaDebuff = 1.0;
let playerPos = { x: 0, z: 0 };
let targetPlayerPos = { x: 0, z: 0 };
let enemyPos = { x: 0, z: 600 };
let keys = { w:false, a:false, s:false, d:false, ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false };
let screenShake = 0, hitStop = 0, flashTimer = 0, hitChromatic = 0;
let particles = [], hitNumbers = [];
let walkBob = 0, playerHurtShake = 0, camHurtPitch = 0;
let viewOffsetX = 0, viewOffsetY = 0;
let targetViewOffsetX = 0, targetViewOffsetY = -60;
let aimY = 0;
let mouseL = false, mouseR = false;
let playerAction = 'idle';
let playerActionTimer = null;
let playerBlocking = false;
let armL = { x: -140, y: -150, z: 200 }, armR = { x: 140, y: -150, z: 200 };
let targetArmL = { x: -140, y: -150, z: 200 }, targetArmR = { x: 140, y: -150, z: 200 };
let enemy = {
hp: 100, maxHP: 100, state: 'idle', stateTimer: 0, nextPunch: 'R',
attackTimer: 0, attackInterval: 120, targetX: 0, targetZ: 600,
hurtShake: 0, poseData: null
};
let npcPhase = 0;
// ─── ヒットゾーンインジケーター状態 ─────────────────────────────
// 現在のヒット可能ゾーン: null=範囲外, { part, color, isClean, hint }
let currentHitZone = null;
// 前フレームの距離帯(変化検出用)
let prevHitZoneBand = null;
// ハイライト中のメッシュIDリスト
let highlightedMeshIds = [];
// ハイライト元マテリアル退避
let meshOrigMaterials = {};
document.addEventListener('keydown', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = true; });
document.addEventListener('keyup', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = false; });
let wasRunning = false;
let mouseEventsRegistered = false;
function showMenu(id) {
document.querySelectorAll('.menu-panel').forEach(el => el.style.display = 'none');
document.getElementById(id).style.display = 'block';
document.getElementById('overlay').style.display = 'flex';
}
function openSettings() { wasRunning = gameRunning; gameRunning = false; showMenu('menu-settings'); }
function closeSettings() {
updateAudioVol();
if (wasRunning) {
document.getElementById('overlay').style.display = 'none';
gameRunning = true;
requestAnimationFrame(gameLoop);
if (commMode === 'text' && speechRecognition) {
try { speechRecognition.start(); } catch(e) {}
}
} else {
showMenu('menu-main');
}
}
// ─── コピー機能 ───────────────────────────────────────────────
function copyMyId() {
const idInput = document.getElementById('my-id-display');
if (idInput.value && !idInput.value.includes('取得中') && !idInput.value.includes('エラー')) {
navigator.clipboard.writeText(idInput.value).then(() => {
const btn = document.getElementById('copy-btn');
const originalText = btn.textContent;
btn.textContent = 'コピー済!';
btn.style.background = '#00ff88';
btn.style.color = '#000';
btn.style.borderColor = '#00ff88';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = 'transparent';
btn.style.color = '#00f3ff';
btn.style.borderColor = '#00f3ff';
}, 2000);
}).catch(err => {
alert('コピーに失敗しました。お手数ですが手動で選択してコピーしてください。');
idInput.select();
});
}
}
// ─── コミュニケーションシステム (STT / Voice) ────────────────────────
let commMode = 'none';
let localAudioStream = null;
let peerCall = null;
let speechRecognition = null;
let chatBubbles = [];
function initCommSystem() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
speechRecognition = new SpeechRecognition();
speechRecognition.continuous = true;
speechRecognition.interimResults = false;
speechRecognition.lang = 'ja-JP';
speechRecognition.onresult = (e) => {
if (commMode !== 'text') return;
const text = e.results[e.results.length - 1][0].transcript.trim();
if (text) {
displayChat('You', text, true);
if (conn && conn.open) {
conn.send({ type: 'chat', text: text });
}
}
};
speechRecognition.onerror = (e) => console.log('Speech Recognition Error:', e.error);
speechRecognition.onend = () => {
if (commMode === 'text' && gameRunning) {
try { speechRecognition.start(); } catch(err) {}
}
};
}
}
async function changeCommMode(mode) {
commMode = mode;
if (speechRecognition) { try { speechRecognition.stop(); } catch(e){} }
if (localAudioStream) { localAudioStream.getTracks().forEach(t => t.stop()); localAudioStream = null; }
if (peerCall) { peerCall.close(); peerCall = null; }
if (mode === 'text' && speechRecognition) {
try { speechRecognition.start(); } catch(e){}
} else if (mode === 'voice') {
try {
localAudioStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
if (conn && conn.open) makeVoiceCall(conn.peer);
} catch(err) {
console.error('マイクの取得に失敗しました', err);
alert('マイクへのアクセスが拒否されました。設定や権限を確認してください。');
document.getElementById('comm-mode').value = 'none';
commMode = 'none';
}
}
}
function makeVoiceCall(targetId) {
if (!localAudioStream || !peer) return;
if (peerCall) peerCall.close();
peerCall = peer.call(targetId, localAudioStream);
setupCallEvents(peerCall);
}
function setupCallEvents(call) {
call.on('stream', remoteStream => {
const audioEl = document.getElementById('remote-audio');
audioEl.srcObject = remoteStream;
audioEl.play().catch(e => console.error('Audio play error', e));
});
call.on('close', () => { document.getElementById('remote-audio').srcObject = null; });
}
function displayChat(sender, text, isMine) {
const log = document.getElementById('chat-log');
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-msg' + (isMine ? ' mine' : '');
msgDiv.textContent = `${sender}: ${text}`;
log.appendChild(msgDiv);
log.scrollTop = log.scrollHeight;
setTimeout(() => { if(msgDiv.parentNode) msgDiv.parentNode.removeChild(msgDiv); }, 8000);
chatBubbles.push({
text: text,
x: W()/2,
y: isMine ? H() - 120 : H()/2 - 150,
alpha: 1, vy: -0.8, isMine: isMine, life: 300
});
}
initCommSystem();
// ─── P2P通信 (PeerJS) ───────────────────────────────────────
let peer = null; let conn = null; let p2pSyncInterval = null;
function initPeer() {
try {
if (peer && !peer.destroyed) peer.destroy();
peer = new Peer(undefined, { debug: 0 });
peer.on('open', id => {
document.getElementById('my-id-display').value = id;
});
peer.on('connection', c => {
conn = c;
c.on('open', () => {
setupP2PConnection(c);
initP2PGame(controlMode || 'mouse');
if (commMode === 'voice' && localAudioStream) makeVoiceCall(c.peer);
});
});
peer.on('call', call => {
peerCall = call;
call.answer(localAudioStream || undefined);
setupCallEvents(call);
});
peer.on('error', err => {
document.getElementById('my-id-display').value = 'エラー: ' + err.type;
});
peer.on('disconnected', () => {
document.getElementById('my-id-display').value = '切断 (再接続中...)';
peer.reconnect();
});
} catch(e) { console.error(e); }
}
initPeer();
function connectToPeer(ctrl) {
const targetId = document.getElementById('target-id-input').value.trim();
if (!targetId) return alert('接続先のIDを入力してください');
if (!peer || peer.destroyed) return alert('PeerJSが初期化されていません。ページをリロードしてください。');
initAudio();
controlMode = ctrl;
if (conn && conn.open) conn.close();
conn = peer.connect(targetId, { reliable: true, serialization: 'json' });
// 接続中表示
const statusEl = document.getElementById('my-id-display');
if (!statusEl.value.includes('接続中')) {
statusEl.value = statusEl.value + ' (接続中...)';
}
conn.on('open', () => {
// 接続成功したら元のID表示に戻す
statusEl.value = peer.id;
setupP2PConnection(conn);
initP2PGame(ctrl);
if (commMode === 'voice' && localAudioStream) makeVoiceCall(targetId);
});
conn.on('error', err => { alert('接続エラー: ' + err.message); });
}
function setupP2PConnection(c) {
c.on('data', data => {
if (!data || !data.type) return;
if (data.type === 'sync') {
if (typeof data.hp === 'number') enemy.hp = Math.max(0, Math.min(enemy.maxHP, data.hp));
if (data.state) enemy.state = data.state;
if (data.poseData) enemy.poseData = data.poseData;
if (data.pos) { enemyPos.x = -data.pos.x; enemyPos.z = 600 - data.pos.z; }
if (!gameRunning) return;
updateUI();
}
else if (data.type === 'punch') {
if (!gameRunning) return;
const result = evaluateHit(enemyPos, playerPos, playerBlocking, data.targetPart || 'body');
if (!result.hit) {
showHitNumber(result.reason, '#aaa');
c.send({ type: 'hit_confirm', hit: false, reason: result.reason });
} else {
const dmg = Math.floor((10 + (data.speed || 250) * 0.05) * result.multi);
takeDamage(dmg, result.type.includes('CLEAN HIT'));
c.send({ type: 'hit_confirm', hit: true, dmg: dmg, hitType: result.type });
}
}
else if (data.type === 'hit_confirm') {
if (!gameRunning) return;
if (data.hit) hitEnemyVisual(data.dmg, 0, 0, data.hitType && data.hitType.includes('CLEAN HIT'));
else { showHitNumber(data.reason || 'MISS', '#aaa'); combo = 0; updateUI(); }
}
else if (data.type === 'gameover') { showResult(true); }
else if (data.type === 'chat') { displayChat('Enemy', data.text, false); }
});
c.on('close', () => {
if (gameRunning) { gameRunning = false; alert('相手との接続が切れました。'); location.reload(); }
});
}
function sendP2PData() {
if (!conn || !conn.open) return;
const pd = (controlMode === 'camera' && latestPoseData) ? {
lw: latestPoseData.lw, rw: latestPoseData.rw, ls: latestPoseData.ls, rs: latestPoseData.rs
} : null;
try {
conn.send({ type: 'sync', hp: playerHP, state: playerAction, poseData: pd, pos: { x: playerPos.x, z: playerPos.z } });
} catch(e) { }
}
function startP2PSyncLoop() {
if (p2pSyncInterval) clearInterval(p2pSyncInterval);
p2pSyncInterval = setInterval(() => {
if (!gameRunning) { clearInterval(p2pSyncInterval); return; }
sendP2PData();
}, 33);
}
function stopP2PSyncLoop() {
if (p2pSyncInterval) { clearInterval(p2pSyncInterval); p2pSyncInterval = null; }
}
function initGame(mode, ctrl) { initAudio(); gameMode = mode; controlMode = ctrl; startGameFlow(); }
function initP2PGame(ctrl) { gameMode = 'multi'; controlMode = ctrl; startGameFlow(); startP2PSyncLoop(); }
function startGameFlow() {
startEpicBGM();
document.getElementById('overlay').style.display = 'none';
document.getElementById('settings-btn').style.display = 'block';
document.getElementById('round-box').style.display = gameMode === 'multi' ? 'none' : 'block';
document.getElementById('time-box').style.display = gameMode === 'multi' ? 'none' : 'block';
score = 0; round = 1; combo = 0;
playerHP = 100; playerStamina = 100; staminaDebuff = 1.0; camHurtPitch = 0;
enemy.maxHP = 100; enemy.hp = 100; enemy.attackInterval = 110;
enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
playerPos = { x: 0, z: 0 }; targetPlayerPos = { x: 0, z: 0 }; enemyPos = { x: 0, z: 600 };
specialGauge = 0; specialReady = false; specialActive = false; specialMotion = '';
enemySpecialActive = false; enemySpecialMotion = '';
currentHitZone = null; prevHitZoneBand = null;
_clearHitZoneHighlight();
document.getElementById('hit-zone-indicator').style.opacity = '0';
document.getElementById('special-gauge-fill').style.width = '0%';
document.getElementById('special-ready-btn').style.display = 'none';
updateUI();
gameRunning = true;
if (controlMode === 'mouse') {
if (!mouseEventsRegistered) {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('contextmenu', e => e.preventDefault());
mouseEventsRegistered = true;
}
} else if (controlMode === 'camera') {
initCamera();
}
showRoundBreak(1, false, () => {
startRoundTimer();
requestAnimationFrame(gameLoop);
if (commMode === 'text' && speechRecognition) { try { speechRecognition.start(); } catch(e) {} }
});
}
function showRoundBreak(roundNum, isBreak, callback) {
roundInProgress = false;
const overlay = document.getElementById('round-break-overlay');
const labelEl = document.getElementById('rb-round-label');
const titleEl = document.getElementById('rb-title');
const subEl = document.getElementById('rb-sub');
overlay.style.display = 'flex';
if (isBreak) {
labelEl.textContent = `END OF ROUND ${roundNum - 1}`;
titleEl.textContent = 'BREAK';
titleEl.style.color = '#00f3ff';
titleEl.style.textShadow = '0 0 40px #00f3ff, 0 0 80px #00f3ff';
subEl.textContent = 'Both fighters recover...';
setTimeout(() => {
labelEl.textContent = `ROUND ${roundNum}`;
titleEl.textContent = 'FIGHT!';
titleEl.style.color = '#fff';
titleEl.style.textShadow = '0 0 40px #ff0055, 0 0 80px #ff0055';
subEl.textContent = '';
playSound('parry');
setTimeout(() => {
overlay.style.display = 'none';
roundInProgress = true;
startRoundTimer();
if (callback) callback();
}, 1500);
}, 2000);
} else {
labelEl.textContent = `ROUND ${roundNum}`;
titleEl.textContent = 'FIGHT!';
titleEl.style.color = '#fff';
titleEl.style.textShadow = '0 0 40px #ff0055, 0 0 80px #ff0055';
subEl.textContent = '';
playSound('parry');
setTimeout(() => {
overlay.style.display = 'none';
roundInProgress = true;
if (callback) callback();
}, 1800);
}
}
function startRoundTimer() {
if (roundTimerInterval) clearInterval(roundTimerInterval);
roundTimeLeft = ROUND_DURATION;
updateTimerUI();
roundTimerInterval = setInterval(() => {
if (!gameRunning || !roundInProgress) return;
roundTimeLeft--;
updateTimerUI();
if (roundTimeLeft <= 0) {
clearInterval(roundTimerInterval);
roundTimerInterval = null;
onRoundTimeUp();
}
}, 1000);
}
function updateTimerUI() {
const m = Math.floor(roundTimeLeft / 60);
const s = roundTimeLeft % 60;
document.getElementById('timer').textContent = `${m}:${s.toString().padStart(2, '0')}`;
document.getElementById('timer').style.color = roundTimeLeft <= 30 ? '#ff0055' : '#fff';
}
function onRoundTimeUp() {
if (!gameRunning) return;
roundInProgress = false;
const playerPercent = playerHP / 100;
const enemyPercent = enemy.hp / enemy.maxHP;
if (round >= TOTAL_ROUNDS) {
if (playerPercent > enemyPercent) showResult(true, '判定勝ち! WIN BY DECISION');
else if (playerPercent < enemyPercent) showResult(false, '判定負け... LOSS BY DECISION');
else showResult(true, 'DRAW!');
} else advanceRound();
}
function onEnemyKO() {
if (!gameRunning || !roundInProgress) return; // 二重呼び出し防止
roundInProgress = false;
if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
const msg = document.getElementById('msg-overlay');
msg.textContent = 'K.O.!!';
msg.style.color = '#ffd700'; msg.style.textShadow = '0 0 30px #ffd700'; msg.style.opacity = '1';
playSound('clean'); screenShake = 60;
if (round >= TOTAL_ROUNDS) setTimeout(() => { msg.style.opacity = '0'; showResult(true, 'K.O. WIN!!'); }, 2000);
else setTimeout(() => { msg.style.opacity = '0'; advanceRound(); }, 2200);
}
function advanceRound() {
if (!gameRunning) return;
round++;
playerHP = 100; playerStamina = 100; staminaDebuff = 1.0;
playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
enemy.maxHP = 100 + (round - 1) * 30; enemy.hp = enemy.maxHP;
enemy.attackInterval = Math.max(70, 110 - (round - 1) * 15);
enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
combo = 0; updateUI(); updateLimbUI();
specialGauge = 0; specialReady = false; specialActive = false; specialMotion = '';
enemySpecialActive = false; enemySpecialMotion = '';
document.getElementById('special-gauge-fill').style.width = '0%';
document.getElementById('special-ready-btn').style.display = 'none';
document.getElementById('round').textContent = `${round} / ${TOTAL_ROUNDS}`;
showRoundBreak(round, true, () => {
requestAnimationFrame(gameLoop);
});
}
let latestPoseData = null; let poseInstance = null; let cameraInstance = null;
let prevWristL = null, prevWristR = null;
let camPunchCooldownL = 0, camPunchCooldownR = 0; let camGuardFrames = 0;
function initCamera() {
const videoEl = document.getElementById('video');
videoEl.style.display = 'none';
if (!window.Pose) return;
poseInstance = new Pose({ locateFile: file => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}` });
poseInstance.setOptions({ modelComplexity: 1, smoothLandmarks: true, enableSegmentation: false, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 });
poseInstance.onResults(onPoseResults);
if (window.Camera) {
cameraInstance = new Camera(videoEl, { onFrame: async () => { if (poseInstance) await poseInstance.send({ image: videoEl }); }, width: 640, height: 480 });
cameraInstance.start().catch(err => alert('カメラへのアクセスが拒否されました。\nマウスモードに切り替えてください。'));
} else {
navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: false })
.then(stream => {
videoEl.srcObject = stream;
videoEl.onloadedmetadata = () => {
videoEl.play();
const sendFrame = async () => { if (!gameRunning) return; if (poseInstance) await poseInstance.send({ image: videoEl }); requestAnimationFrame(sendFrame); };
requestAnimationFrame(sendFrame);
};
}).catch(err => alert('カメラへのアクセスが拒否されました。マウスモードをお使いください。'));
}
}
function onPoseResults(results) {
if (!gameRunning || !results.poseLandmarks) return;
const lm = results.poseLandmarks;
const ls = lm[11], rs = lm[12], lw = lm[15], rw = lm[16], nose = lm[0];
if (!ls || !rs || !lw || !rw || ls.visibility < 0.4 || rs.visibility < 0.4) return;
const midShoulderX = 1.0 - (ls.x + rs.x) / 2;
const midShoulderY = (ls.y + rs.y) / 2;
const shoulderWidth = Math.abs(rs.x - ls.x);
targetPlayerPos.x = (midShoulderX - 0.5) * 1600;
targetPlayerPos.z = (midShoulderY - 0.5) * 600 + 150;
if (nose) {
targetViewOffsetX = -(1.0 - nose.x - 0.5) * 200;
targetViewOffsetY = -(nose.y - 0.5) * 200;
}
latestPoseData = { ls: { x: ls.x, y: ls.y }, rs: { x: rs.x, y: rs.y }, lw: { x: lw.x, y: lw.y, vis: lw.visibility }, rw: { x: rw.x, y: rw.y, vis: rw.visibility } };
if (prevWristL && prevWristR) {
const refWidth = Math.max(shoulderWidth, 0.05);
const velLX = (lw.x - prevWristL.x) / refWidth, velLY = (lw.y - prevWristL.y) / refWidth;
const velRX = (rw.x - prevWristR.x) / refWidth, velRY = (rw.y - prevWristR.y) / refWidth;
const speedL = Math.sqrt(velLX*velLX + velLY*velLY), speedR = Math.sqrt(velRX*velRX + velRY*velRY);
if (camPunchCooldownL <= 0 && speedL > 0.18 && lw.visibility > 0.5 && playerAction === 'idle') {
if (velLX > 0.1 && playerStamina >= 15) {
// カメラモードでもゲージMAX時は必殺技発動
if (specialReady && !specialActive && roundInProgress) { activateSpecialMove(); camPunchCooldownL = 20; }
else {
playerStamina -= 15; aimY = lw.y < 0.35 ? -0.5 : 0.1; playerBlocking = false; playerAction = 'windupL';
playerActionTimer = setTimeout(() => { if (playerAction === 'windupL') { playerAction = 'punchL'; tryPunch('left', Math.min(600, speedL * 3000)); setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300); } }, 80);
camPunchCooldownL = 20;
}
}
}
if (camPunchCooldownR <= 0 && speedR > 0.18 && rw.visibility > 0.5 && playerAction === 'idle') {
if (velRX < -0.1 && playerStamina >= 15) {
// カメラモードでもゲージMAX時は必殺技発動
if (specialReady && !specialActive && roundInProgress) { activateSpecialMove(); camPunchCooldownR = 20; }
else {
playerStamina -= 15; aimY = rw.y < 0.35 ? -0.5 : 0.1; playerBlocking = false; playerAction = 'windupR';
playerActionTimer = setTimeout(() => { if (playerAction === 'windupR') { playerAction = 'punchR'; tryPunch('right', Math.min(600, speedR * 3000)); setTimeout(() => { if (playerAction === 'punchR') playerAction = 'idle'; }, 300); } }, 80);
camPunchCooldownR = 20;
}
}
}
}
if (lw.y < ls.y - 0.05 && lw.visibility > 0.4 && rw.y < rs.y - 0.05 && rw.visibility > 0.4 && playerAction === 'idle') {
camGuardFrames++; if (camGuardFrames > 3) { mouseL = true; mouseR = true; updatePlayerAction(); }
} else {
if (camGuardFrames > 3) { mouseL = false; mouseR = false; updatePlayerAction(); }
camGuardFrames = 0;
}
if (camPunchCooldownL > 0) camPunchCooldownL--;
if (camPunchCooldownR > 0) camPunchCooldownR--;
prevWristL = { x: lw.x, y: lw.y }; prevWristR = { x: rw.x, y: rw.y };
if (controlMode === 'camera') {
const lwArmX = (1.0 - lw.x - 0.5) * 800 - targetPlayerPos.x; const lwArmY = (lw.y - 0.5) * -600;
const rwArmX = (1.0 - rw.x - 0.5) * 800 - targetPlayerPos.x; const rwArmY = (rw.y - 0.5) * -600;
if (playerAction === 'idle' || playerAction === 'guard') {
targetArmL.x = Math.max(-350, Math.min(350, lwArmX)); targetArmL.y = Math.max(-400, Math.min(0, lwArmY));
targetArmR.x = Math.max(-350, Math.min(350, rwArmX)); targetArmR.y = Math.max(-400, Math.min(0, rwArmY));
}
}
}
// ─── 入力とアクション ─────────────────────────────────────────
function onMouseMove(e) {
if (!gameRunning || controlMode !== 'mouse') return;
targetViewOffsetX = -(e.clientX - W()/2) * 0.4;
targetViewOffsetY = -(e.clientY - H()/2) * 0.4;
aimY = (e.clientY / H()) * 2 - 1;
// マウス左右: 画面内の横位置
targetPlayerPos.x = ((e.clientX - W()/2) / (W()/2)) * 1400;
// マウス上下反転: マウス上→画面奥(z大)、マウス下→画面手前(z小)
targetPlayerPos.z = -((e.clientY - H()/2) / (H()/2)) * 800 + 200;
}
function onMouseDown(e) {
if (!gameRunning || controlMode !== 'mouse') return;
// ゲージMAX時はどちらのクリックでも必殺技発動
if (specialReady && !specialActive && roundInProgress) {
activateSpecialMove();
return;
}
if (e.button === 0) mouseL = true;
if (e.button === 2) mouseR = true;
updatePlayerAction();
}
function onMouseUp(e) {
if (!gameRunning || controlMode !== 'mouse') return;
if (e.button === 0) mouseL = false;
if (e.button === 2) mouseR = false;
updatePlayerAction();
}
function updatePlayerAction() {
if (playerAction === 'recoil') return;
if (specialActive) return; // 必殺技発動中は通常入力ブロック
if (mouseL && mouseR) {
clearTimeout(playerActionTimer); playerAction = 'guard'; playerBlocking = true; playSound('swing');
}
else if (mouseL && !mouseR && playerAction === 'idle') {
if (playerStamina < 15) return;
playerStamina -= 15; playerBlocking = false; playerAction = 'windupL'; playSound('swing');
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupL') {
playerAction = 'punchL'; tryPunch('left', 250 * staminaDebuff);
setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300);
}
}, 120);
}
else if (mouseR && !mouseL && playerAction === 'idle') {
if (playerStamina < 15) return;
playerStamina -= 15; playerBlocking = false; playerAction = 'windupR'; playSound('swing');
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupR') {
playerAction = 'punchR'; tryPunch('right', 250 * staminaDebuff);
setTimeout(() => { if (playerAction === 'punchR') playerAction = 'idle'; }, 300);
}
}, 120);
}
else if (!mouseL && !mouseR) {
playerBlocking = false;
if (playerAction === 'guard' || playerAction === 'windupL' || playerAction === 'windupR') {
clearTimeout(playerActionTimer); playerAction = 'idle';
}
}
}
function detectMovement() {
let isMoving = false;
const legPenalty = 1.0 - (playerLimbDmg.legs / 250);
if (controlMode === 'mouse') {
let movedByKey = false;
const speed = Math.round(35 * legPenalty * staminaDebuff); // 移動エリア拡大に伴い速度も向上
if (keys.w || keys.ArrowUp) { playerPos.z += speed; movedByKey = true; }
if (keys.s || keys.ArrowDown) { playerPos.z -= speed; movedByKey = true; }
if (keys.a || keys.ArrowLeft) { playerPos.x -= speed; movedByKey = true; }
if (keys.d || keys.ArrowRight) { playerPos.x += speed; movedByKey = true; }
if (movedByKey) { targetPlayerPos.x = playerPos.x; targetPlayerPos.z = playerPos.z; isMoving = true; }
else {
const dx = targetPlayerPos.x - playerPos.x, dz = targetPlayerPos.z - playerPos.z;
if (Math.abs(dx) > 5 || Math.abs(dz) > 5) isMoving = true;
playerPos.x += dx * 0.15; playerPos.z += dz * 0.15;
}
}
if (isMoving) walkBob += 0.2; else walkBob = 0;
// 移動限界を拡大
playerPos.x = Math.max(-1500, Math.min(1500, playerPos.x));
playerPos.z = Math.max(-400, Math.min(1200, playerPos.z));
// カメラモードでの腕の追従のみ残す
if (controlMode === 'camera') {
armL.x += (targetArmL.x - armL.x) * 0.4; armL.y += (targetArmL.y - armL.y) * 0.4; armL.z += (targetArmL.z - armL.z) * 0.4;
armR.x += (targetArmR.x - armR.x) * 0.4; armR.y += (targetArmR.y - armR.y) * 0.4; armR.z += (targetArmR.z - armR.z) * 0.4;
}
}
function getTargetPart(aimYVal) {
if (aimYVal < -0.15) return 'head';
if (aimYVal < 0.25) return 'body';
if (aimYVal < 0.55) return 'body';
return 'legs';
}
const PART_CONFIG = {
head: { multi: 2.0, label: 'HEAD', color: '#ff0055' },
body: { multi: 1.2, label: 'BODY', color: '#ff8800' },
legs: { multi: 0.8, label: 'LEGS', color: '#ffcc00' },
larm: { multi: 0.7, label: 'L.ARM', color: '#00f3ff' },
rarm: { multi: 0.7, label: 'R.ARM', color: '#00f3ff' },
};
function evaluateHit(attackerPos, defenderPos, isDefenderBlocking, targetPart) {
const dx = Math.abs(attackerPos.x - defenderPos.x);
// dz: 防御者から攻撃者を見た前後距離
// プレイヤー(z≈0~200)が敵(z≈600)を攻撃: defenderPos.z - attackerPos.z ≈ +400 (正=前方にいる)
const dzSigned = defenderPos.z - attackerPos.z;
// 前後距離チェック: 防御者が攻撃者の前方にいないと当たらない
if (dzSigned < -80) return { hit: false, reason: 'MISS (BEHIND)' };
if (dzSigned > 560) return { hit: false, reason: 'MISS (TOO FAR)' };
// 横方向: 280まで許容(当たりやすく)
if (dx > 280) return { hit: false, reason: 'MISS (DODGED)' };
if (isDefenderBlocking) return { hit: false, reason: 'GUARD!' };
// CLEAN HIT条件: 適正距離(60~400)かつ横ズレ少ない
const isClean = (dzSigned >= 60 && dzSigned <= 400 && dx < 160);
const cfg = PART_CONFIG[targetPart] || PART_CONFIG.body;
const multi = cfg.multi * (isClean ? 1.4 : 1.0);
return { hit: true, type: isClean ? `${cfg.label} CLEAN HIT` : `${cfg.label} HIT`, multi, part: targetPart, isClean };
}
// ─── ヒットゾーンインジケーター ────────────────────────────────
// 距離帯の定義: どの距離でどの部位が光るか
const HIT_ZONE_BANDS = [
// { minDz, maxDz, maxDx, part, color, hint }
{ minDz: 60, maxDz: 180, maxDx: 160, part: 'head', color: '#ff0055', hint: '⬆ クリックでHEAD打撃!' },
{ minDz: 180, maxDz: 320, maxDx: 180, part: 'body', color: '#ff8800', hint: '⬆ クリックでBODY打撃!' },
{ minDz: 320, maxDz: 460, maxDx: 200, part: 'legs', color: '#ffd700', hint: '⬆ クリックでLEGS打撃!' },
// 遠距離でも横にはみ出していなければ当たる(ただし弱)
{ minDz: 460, maxDz: 560, maxDx: 280, part: 'body', color: '#00f3ff', hint: '距離があります' },
];
// 部位名→ハイライト対象メッシュIDのマップ
const PART_MESH_IDS = {
head: ['e_head', 'e_neck'],
body: ['e_chest', 'e_abdomen', 'e_shoulder', 'e_necktorso'],
legs: ['e_uleg_l', 'e_uleg_r', 'e_lleg_l', 'e_lleg_r', 'e_knee_l', 'e_knee_r'],
larm: ['e_uarm_l', 'e_larm_l', 'e_elbow_l', 'e_glove_l'],
rarm: ['e_uarm_r', 'e_larm_r', 'e_elbow_r', 'e_glove_r'],
};
// 光らせるマテリアルキャッシュ
const _hlMats = {};
function getHighlightMat(hexColor) {
if (_hlMats[hexColor]) return _hlMats[hexColor];
const c = parseInt(hexColor.replace('#',''), 16);
_hlMats[hexColor] = new THREE.MeshStandardMaterial({
color: c, emissive: c, emissiveIntensity: 1.4, roughness: 0.3
});
return _hlMats[hexColor];
}
function updateHitZoneIndicator() {
if (!gameRunning || !roundInProgress || specialActive) {
_clearHitZoneHighlight();
document.getElementById('hit-zone-indicator').style.opacity = '0';
return;
}
const dzSigned = enemyPos.z - playerPos.z;
const dx = Math.abs(playerPos.x - enemyPos.x);
// 現在の距離帯を検索
let band = null;
for (const b of HIT_ZONE_BANDS) {
if (dzSigned >= b.minDz && dzSigned < b.maxDz && dx <= b.maxDx) {
band = b; break;
}
}
const bandKey = band ? `${band.part}_${Math.floor(dzSigned / 60)}` : null;
// 距離帯が変わったときだけハイライトを切り替え
if (bandKey !== prevHitZoneBand) {
prevHitZoneBand = bandKey;
_clearHitZoneHighlight();
if (band) {
currentHitZone = band;
// 3Dメッシュをハイライト
const ids = PART_MESH_IDS[band.part] || [];
const hlMat = getHighlightMat(band.color);
ids.forEach(id => {
if (meshes[id]) {
meshOrigMaterials[id] = meshes[id].material;
meshes[id].material = hlMat;
highlightedMeshIds.push(id);
}
});
// UI表示
const cfg = PART_CONFIG[band.part] || PART_CONFIG.body;
const hzVal = document.getElementById('hz-value');
const hzHint = document.getElementById('hz-hint');
hzVal.textContent = cfg.label;
hzVal.style.color = band.color;
hzHint.textContent = band.hint;
document.getElementById('hit-zone-indicator').style.opacity = '1';
} else {
currentHitZone = null;
document.getElementById('hz-value').textContent = '─';
document.getElementById('hz-hint').textContent = '距離を調整してください';
document.getElementById('hit-zone-indicator').style.opacity = '0.5';
}
}
}
function _clearHitZoneHighlight() {
highlightedMeshIds.forEach(id => {
if (meshes[id] && meshOrigMaterials[id]) {
meshes[id].material = meshOrigMaterials[id];
}
});
highlightedMeshIds = [];
meshOrigMaterials = {};
}
function tryPunch(side, speed) {
if (specialActive) return; // 必殺技発動中は通常パンチ無効
// ヒットゾーン内なら対応部位を狙う、それ以外はaimYから判定
const targetPart = (currentHitZone && roundInProgress) ? currentHitZone.part : getTargetPart(aimY);
if (side === 'left') aiMemory.leftStrikes++; else aiMemory.rightStrikes++;
saveMemory();
const effectiveSpeed = speed * staminaDebuff;
if (gameMode === 'multi') {
if (conn && conn.open) conn.send({ type: 'punch', side: side, speed: effectiveSpeed, targetPart: targetPart });
return;
}
const isEnemyAttacking = enemy.state.includes('punch');
if (isEnemyAttacking) {
const dz = Math.abs(playerPos.z - enemyPos.z);
if (dz < 480) {
playSound('parry'); spawnParticles(W() / 2, H() / 2, '#00f3ff', 30); showHitNumber('PARRY!!', '#00f3ff', 1.8);
screenShake = 25; hitStop = 12; enemy.state = 'recoil'; enemy.stateTimer = 28;
playerAction = 'recoil'; clearTimeout(playerActionTimer);
setTimeout(() => { if (playerAction === 'recoil') playerAction = 'idle'; }, 420);
return;
}
}
const result = evaluateHit(playerPos, enemyPos, enemy.state === 'blocking', targetPart);
if (!result.hit) {
if (result.reason === 'GUARD!') playSound('guard');
showHitNumber(result.reason, '#aaa'); combo = 0; updateUI();
} else {
// baseDmg: speed250×staminaDebuff→max10→×部位倍率 (削れすぎを防ぐため係数を0.02に)
const baseDmg = Math.max(4, Math.min(12, Math.floor(effectiveSpeed * 0.02)));
const dmg = Math.floor(baseDmg * result.multi);
enemy.hp = Math.max(0, enemy.hp - dmg);
applyLimbDamage('enemy', result.part, dmg);
hitEnemyVisual(dmg, side === 'right' ? 50 : -50, result.part === 'head' ? -150 : -50, result.isClean, result.part);
// 必殺技ゲージをヒットごとに増加 (CLEAN HITは多め・増加速度UP)
updateSpecialGauge(result.isClean ? 25 : 14);
if (enemy.hp <= 0) onEnemyKO();
}
}
function applyLimbDamage(target, part, dmg) {
if (target === 'enemy') {
enemyLimbDmg[part] = Math.min(100, (enemyLimbDmg[part] || 0) + dmg * 0.8);
} else {
playerLimbDmg[part] = Math.min(100, (playerLimbDmg[part] || 0) + dmg * 0.8);
updateLimbUI();
}
}
function updateLimbUI() {
const set = (id, val) => {
const el = document.getElementById(id);
if (!el) return;
const pct = val, remaining = 100 - pct;
el.style.width = remaining + '%';
el.style.background = remaining > 60 ? '#00f3ff' : remaining > 30 ? '#ffaa00' : '#ff0055';
};
set('limb-larm', playerLimbDmg.larm); set('limb-rarm', playerLimbDmg.rarm); set('limb-body', playerLimbDmg.body); set('limb-legs', playerLimbDmg.legs);
}
function hitEnemyVisual(dmg, ox, oy, isCleanHit, part) {
part = part || 'body';
playSound(isCleanHit ? 'clean' : 'hit');
combo++;
// スコアはコンボ倍率を乗算するが、HPダメージ自体はコンボ倍率なし(既にenemy.hpから引いた後に呼ばれる)
score += dmg * combo * (isCleanHit ? 2 : 1);
enemy.state = 'hurt'; enemy.stateTimer = 30; enemy.hurtShake = isCleanHit ? 70 : 35;
hitStop = isCleanHit ? 12 : 6; screenShake = isCleanHit ? 35 : 18; flashTimer = isCleanHit ? 6 : 3;
const cfg = PART_CONFIG[part] || PART_CONFIG.body;
const color = isCleanHit ? '#ffd700' : cfg.color;
spawnParticles(W()/2 + ox, H()/2 + oy, '#ffffff', isCleanHit ? 18 : 8);
spawnParticles(W()/2 + ox, H()/2 + oy, color, isCleanHit ? 30 : 14);
if (isCleanHit) {
spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ffd700', 20, -1.0, 0.4);
spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ff6600', 15, -1.2, 0.3);
if (enemy.hp / enemy.maxHP < 0.4) spawnParticlesDirected(W()/2 + ox + 20, H()/2 + oy, '#ff0000', 12, -0.8, 0.6);
}
showHitNumber(isCleanHit ? `${cfg.label} CLEAN! ${dmg}` : `${cfg.label} ${dmg}`, color, isCleanHit ? 1.5 : 1);
if (combo >= 3) {
const cd = document.getElementById('combo-display'); cd.textContent = `${combo} HIT!!`; cd.style.opacity = '1';
clearTimeout(comboTimer); comboTimer = setTimeout(() => cd.style.opacity = '0', 1000);
}
if (isCleanHit && currentSkinMat.emissive !== undefined) {
const origEmissive = currentSkinMat.emissive.getHex(); currentSkinMat.emissive.setHex(0xffaa00); currentSkinMat.emissiveIntensity = 1.0;
setTimeout(() => { currentSkinMat.emissive.setHex(origEmissive); currentSkinMat.emissiveIntensity = 0; }, 120);
}
updateUI();
// HPがゼロになっていたら確実にKO処理(シングルモード)
if (gameMode !== 'multi' && enemy.hp <= 0 && roundInProgress) {
onEnemyKO();
}
}
function spawnParticlesDirected(x, y, color, count, vyBias, spread) {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2, s = 8 + Math.random() * 18;
particles.push({ x, y, vx: Math.cos(a) * s * spread, vy: Math.sin(a) * s * spread + vyBias * (8 + Math.random() * 10), alpha: 1, size: 4 + Math.random() * 9, color });
}
}
function takeDamage(dmg, isCleanHit, hitPart) {
hitPart = hitPart || 'body'; playSound('damage');
playerHP = Math.max(0, playerHP - dmg); combo = 0;
applyLimbDamage('player', hitPart, dmg);
// ダメージを受けると必殺技ゲージが少し増加(根性)速度UP
updateSpecialGauge(isCleanHit ? 16 : 8);
if (hitPart === 'head' && dmg >= 12) { camHurtPitch = isCleanHit ? 1400 : 900; playerHurtShake = isCleanHit ? 130 : 70; }
else { camHurtPitch = isCleanHit ? 1100 : 650; playerHurtShake = isCleanHit ? 110 : 55; }
updateUI(); screenShake = isCleanHit ? 90 : 45; flashTimer = isCleanHit ? 7 : 4;
const hitEl = document.getElementById('hit-effect');
if (isCleanHit) {
hitEl.style.background = 'radial-gradient(circle, transparent 20%, rgba(255,200,0,0.85) 90%)'; showHitNumber('CRITICAL DANGER!', '#ff0000', 1.5);
if (playerHP < 30) hitEl.style.background = 'radial-gradient(circle, transparent 10%, rgba(220,0,0,0.9) 80%)';
} else { hitEl.style.background = 'radial-gradient(circle, transparent 25%, rgba(255,0,85,0.7) 100%)'; }
hitEl.style.opacity = '1'; setTimeout(() => { hitEl.style.opacity = '0'; }, isCleanHit ? 220 : 150); hitChromatic = isCleanHit ? 12 : 6;
if (playerHP <= 0) {
if (gameMode === 'multi' && conn && conn.open) conn.send({ type: 'gameover' });
roundInProgress = false; if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
showResult(false, 'K.O.');
}
}
function updateEnemyAI() {
if (!roundInProgress) return;
if (enemy.stateTimer > 0) {
enemy.stateTimer--;
if (enemy.state.includes('windup') && enemy.stateTimer === 15) {
enemy.state = 'punch' + enemy.nextPunch;
const result = evaluateHit(enemyPos, playerPos, playerBlocking, 'body');
if (!result.hit) {
if (result.reason === 'GUARD!') { playSound('guard'); spawnParticles(W() / 2 - 80, H() / 2 + 50, '#00f3ff', 14); spawnParticles(W() / 2 + 80, H() / 2 + 50, '#00f3ff', 14); screenShake = 10; }
showHitNumber(result.reason, '#aaa');
} else { takeDamage(Math.floor((6 + round * 1.5) * result.multi), result.isClean, Math.random() < 0.3 ? 'head' : 'body'); }
}
// 敵必殺技モーション中は通常AI終了
if (enemy.state.startsWith('e_special')) {
if (enemy.stateTimer <= 0) { enemy.state = 'idle'; enemySpecialActive = false; }
enemy.hurtShake *= 0.78;
enemyPos.x += (enemy.targetX - enemyPos.x) * 0.14;
enemyPos.z += (enemy.targetZ - enemyPos.z) * 0.06;
return;
}
if (enemy.stateTimer <= 0) enemy.state = 'idle';
}
if (enemy.hurtShake > 0) enemy.hurtShake *= 0.78;
let totalStrikes = aiMemory.leftStrikes + aiMemory.rightStrikes || 1;
let leftRatio = aiMemory.leftStrikes / totalStrikes; let dodgeOffset = (leftRatio - 0.5) * 320;
const targetDist = 440;
if (enemy.state === 'idle') { enemy.targetX = playerPos.x + Math.sin(Date.now() * 0.001) * 200 + dodgeOffset; enemy.targetZ = playerPos.z + targetDist; }
else if (enemy.state.includes('windup')) { enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + targetDist - 40; }
else if (enemy.state.includes('punch')) { enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + 160; }
enemyPos.x += (enemy.targetX - enemyPos.x) * 0.14; enemyPos.z += (enemy.targetZ - enemyPos.z) * 0.06;
enemy.attackTimer++;
if (enemy.attackTimer >= enemy.attackInterval && enemy.state === 'idle') {
enemy.attackTimer = 0;
// ラウンド2以降でHPが低いか、低確率で必殺技発動
const enemyHpRatio = enemy.hp / enemy.maxHP;
const specialChance = (round >= 2 ? 0.18 : 0.08) + (enemyHpRatio < 0.35 ? 0.20 : 0);
if (!enemySpecialActive && Math.random() < specialChance) {
triggerEnemySpecial();
} else {
enemy.nextPunch = Math.random() > 0.5 ? 'R' : 'L'; enemy.state = 'windup' + enemy.nextPunch; enemy.stateTimer = 48; playSound('swing');
}
}
}
function spawnParticles(x, y, color, count) {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2, s = 10 + Math.random() * 20;
particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s - 5, alpha: 1, size: 5 + Math.random() * 10, color });
}
}
function showHitNumber(text, color, scaleMult = 1) {
hitNumbers.push({ x: W()/2 + (Math.random()-0.5)*150, y: H()/2 - 100, text, color, alpha: 1, vy: -4, scale: 0.5 * scaleMult, targetScale: 1.5 * scaleMult });
}
function updateUI() {
document.getElementById('score').textContent = score; document.getElementById('round').textContent = `${round} / ${TOTAL_ROUNDS}`;
document.getElementById('combo').textContent = `x${combo}`; document.getElementById('enemy-health-fill').style.width = (enemy.hp / enemy.maxHP * 100) + '%';
document.getElementById('player-health-fill').style.width = playerHP + '%';
}
function showResult(isWin, label) {
gameRunning = false; roundInProgress = false; if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
_clearHitZoneHighlight();
currentHitZone = null; prevHitZoneBand = null;
document.getElementById('hit-zone-indicator').style.opacity = '0';
if (mouseEventsRegistered) {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mouseup', onMouseUp);
mouseEventsRegistered = false;
}
const msg = document.getElementById('msg-overlay');
msg.textContent = label || (isWin ? 'YOU WIN!' : 'K.O.');
msg.style.color = isWin ? '#00ff88' : '#ff0055'; msg.style.textShadow = isWin ? '0 0 30px #00ff88' : '0 0 30px #ff0055'; msg.style.opacity = '1';
setTimeout(() => location.reload(), 5000);
}
const meshes = {};
function updateMesh(id, type, p1, p2, radius, mat) {
const tx = p1.x, ty = -p1.y, tz = -p1.z;
if (type === 'joint') {
if(!meshes[id]) { let geo = mat === materials.crystal ? new THREE.IcosahedronGeometry(1, 1) : new THREE.SphereGeometry(1, 16, 16); meshes[id] = new THREE.Mesh(geo, mat); scene.add(meshes[id]); }
if(meshes[id].material !== mat) { meshes[id].material = mat; meshes[id].geometry = mat === materials.crystal ? new THREE.IcosahedronGeometry(1, 1) : new THREE.SphereGeometry(1, 16, 16); }
meshes[id].position.set(tx, ty, tz); meshes[id].scale.set(radius, radius, radius);
} else if (type === 'bone') {
if(!meshes[id]) { meshes[id] = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 1, 8), mat); scene.add(meshes[id]); }
if(meshes[id].material !== mat) meshes[id].material = mat;
const tx2 = p2.x, ty2 = -p2.y, tz2 = -p2.z; const v1 = new THREE.Vector3(tx, ty, tz), v2 = new THREE.Vector3(tx2, ty2, tz2); const dist = v1.distanceTo(v2);
if (dist < 0.1) return;
meshes[id].position.copy(v1).lerp(v2, 0.5); meshes[id].quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), v2.clone().sub(v1).normalize()); meshes[id].scale.set(radius, dist, radius);
}
}
function buildEnemyPoly() {
npcPhase += 0.08;
const isWindup = enemy.state.includes('windup'), isAttacking = enemy.state.includes('punch') || enemy.state === 'attacking';
const isHurt = enemy.state === 'hurt', isRecoil = enemy.state === 'recoil', isLeftPunch = enemy.state.includes('L'), isBlocking = enemy.state === 'blocking';
const isEnemySpecial = enemy.state.startsWith('e_special');
const hurtProgress = isHurt ? Math.sin((1.0 - enemy.stateTimer / 30) * Math.PI) : 0;
const archAmount = isHurt ? 320 * hurtProgress : 0, headExtra = isHurt ? 160 * hurtProgress : 0, kneeBend = isHurt ? 80 * hurtProgress : 0, waistRiseZ = isHurt ? 120 * hurtProgress : 0;
const sx = (Math.random() - 0.5) * enemy.hurtShake, sy = (Math.random() - 0.5) * enemy.hurtShake, sz = (Math.random() - 0.5) * enemy.hurtShake;
const bobY = Math.sin(npcPhase) * 12, stepZ = isAttacking ? -80 : 0;
const rootX = enemyPos.x + sx, rootZ = enemyPos.z + sz + stepZ;
const footY = 600 + bobY, kneeY = 420 + bobY + kneeBend, hipY = 220 + bobY, waistY = 130 + bobY + (isHurt ? 10 * hurtProgress : 0);
const chestY = 30 + bobY + sy + (isHurt ? -20 * hurtProgress : 0), shoulderY = -10 + bobY + sy, neckBaseY = -50 + bobY + sy, neckTopY = -100 + bobY + sy, headCenterY = -170 + bobY + sy + (isHurt ? -10 * hurtProgress : 0);
const waistZ = rootZ + waistRiseZ * 0.3, chestZ = rootZ + archAmount * 0.6, shoulderZ = rootZ + archAmount * 0.85, neckBaseZ = rootZ + archAmount * 0.95, neckTopZ = rootZ + archAmount + headExtra * 0.3, headZ = rootZ + archAmount + headExtra;
const footStep = Math.sin(npcPhase * 0.5) * 40;
let joints = {
head: { x: rootX, y: headCenterY, z: headZ }, neckTop: { x: rootX, y: neckTopY, z: neckTopZ }, neckBase: { x: rootX, y: neckBaseY, z: neckBaseZ },
ls: { x: rootX - 115, y: shoulderY, z: shoulderZ + 20 }, rs: { x: rootX + 115, y: shoulderY, z: shoulderZ - 20 },
chest: { x: rootX, y: chestY, z: chestZ }, waist: { x: rootX, y: waistY, z: waistZ },
hip: { x: rootX, y: hipY, z: rootZ }, lhip: { x: rootX - 65, y: hipY, z: rootZ }, rhip: { x: rootX + 65, y: hipY, z: rootZ },
lknee: { x: rootX - 75, y: kneeY, z: rootZ - footStep + kneeBend * 1.2 }, rknee: { x: rootX + 75, y: kneeY, z: rootZ + footStep + kneeBend * 0.8 },
lfoot: { x: rootX - 80, y: footY, z: rootZ - footStep - 15 }, rfoot: { x: rootX + 80, y: footY, z: rootZ + footStep - 15 },
};
const lsZ2 = joints.ls.z, rsZ2 = joints.rs.z;
if (isRecoil) { joints.le={x:rootX-170,y:shoulderY-40,z:shoulderZ+20}; joints.lw={x:rootX-290,y:shoulderY-130,z:shoulderZ-50}; joints.re={x:rootX+170,y:shoulderY-40,z:shoulderZ+20}; joints.rw={x:rootX+290,y:shoulderY-130,z:shoulderZ-50}; }
else if (isBlocking) { joints.le={x:rootX-85,y:shoulderY+30,z:shoulderZ-100}; joints.lw={x:rootX-45,y:headCenterY+40,z:headZ-180}; joints.re={x:rootX+85,y:shoulderY+30,z:shoulderZ-100}; joints.rw={x:rootX+45,y:headCenterY+40,z:headZ-180}; }
else if (isWindup) {
if (isLeftPunch) { joints.le={x:rootX-160,y:shoulderY+60,z:shoulderZ+260}; joints.lw={x:rootX-110,y:shoulderY+20,z:shoulderZ+140}; joints.re={x:rootX+130,y:shoulderY+10,z:rsZ2-30}; joints.rw={x:rootX+85,y:headCenterY+50,z:headZ-160}; }
else { joints.le={x:rootX-130,y:shoulderY+10,z:lsZ2-30}; joints.lw={x:rootX-85,y:headCenterY+50,z:headZ-160}; joints.re={x:rootX+160,y:shoulderY+60,z:shoulderZ+260}; joints.rw={x:rootX+110,y:shoulderY+20,z:shoulderZ+140}; }
} else if (isAttacking) {
const punchReach = playerBlocking ? rootZ - 200 : rootZ - 580; const punchOffX = isLeftPunch ? -60 : 60;
if (isLeftPunch) { joints.le={x:rootX-105,y:shoulderY+20,z:shoulderZ-60}; joints.lw={x:rootX+punchOffX,y:headCenterY+25,z:punchReach}; joints.re={x:rootX+130,y:shoulderY+10,z:rsZ2-30}; joints.rw={x:rootX+85,y:headCenterY+50,z:headZ-160}; }
else { joints.le={x:rootX-130,y:shoulderY+10,z:lsZ2-30}; joints.lw={x:rootX-85,y:headCenterY+50,z:headZ-160}; joints.re={x:rootX+105,y:shoulderY+20,z:shoulderZ-60}; joints.rw={x:rootX+punchOffX,y:headCenterY+25,z:punchReach}; }
} else if (enemy.state === 'e_special_smash') {
// 敵必殺技: 叩きつけ (両腕高く上げて振り下ろし)
const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
const sp = Math.sin(prog * Math.PI);
joints.le = {x:rootX-170, y:shoulderY-100-sp*200, z:shoulderZ-60+prog*350};
joints.lw = {x:rootX- 80, y:shoulderY-180-sp*260, z:shoulderZ-20+prog*600};
joints.re = {x:rootX+170, y:shoulderY-100-sp*200, z:shoulderZ-60+prog*350};
joints.rw = {x:rootX+ 80, y:shoulderY-180-sp*260, z:shoulderZ-20+prog*600};
joints.lknee = {x:rootX-75, y:kneeY+sp*60, z:rootZ-footStep};
joints.rknee = {x:rootX+75, y:kneeY+sp*60, z:rootZ+footStep};
} else if (enemy.state === 'e_special_rush') {
// 敵必殺技: ラッシュ (高速交互突き)
const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
const altL = Math.sin(prog * Math.PI * 5);
const altR = Math.sin(prog * Math.PI * 5 + Math.PI);
const reachZ = rootZ - 550;
joints.le = {x:rootX-110, y:shoulderY+20+altL*25, z:shoulderZ-40+Math.max(0,altL)*80};
joints.lw = {x:rootX- 60, y:shoulderY+10+altL*15, z:reachZ+Math.max(0,altL)*100};
joints.re = {x:rootX+110, y:shoulderY+20+altR*25, z:shoulderZ-40+Math.max(0,altR)*80};
joints.rw = {x:rootX+ 60, y:shoulderY+10+altR*15, z:reachZ+Math.max(0,altR)*100};
} else if (enemy.state === 'e_special_upper') {
// 敵必殺技: アッパー (しゃがみ→突き上げ)
const prog = Math.max(0, 1.0 - enemy.stateTimer / 70);
const crouch = Math.max(0, 1 - prog * 2.5);
const rise = Math.max(0, prog * 2 - 0.4);
const sp = Math.sin(prog * Math.PI);
joints.lknee = {x:rootX-75, y:kneeY+crouch*120, z:rootZ-footStep};
joints.rknee = {x:rootX+75, y:kneeY+crouch*120, z:rootZ+footStep};
joints.lfoot = {x:rootX-80, y:footY+crouch*80, z:rootZ-footStep-15};
joints.rfoot = {x:rootX+80, y:footY+crouch*80, z:rootZ+footStep-15};
joints.le = {x:rootX-140, y:shoulderY+70 -rise*300, z:shoulderZ+30};
joints.lw = {x:rootX- 90, y:shoulderY+150-rise*450-sp*60, z:shoulderZ-60+rise*200};
joints.re = {x:rootX+120, y:shoulderY+50 -rise*220, z:shoulderZ+20};
joints.rw = {x:rootX+ 70, y:shoulderY+130-rise*400, z:shoulderZ-40+rise*160};
} else if (isHurt) {
const openAmt = 100 * hurtProgress;
joints.le={x:rootX-140,y:chestY+40,z:chestZ+50}; joints.lw={x:rootX-220-openAmt,y:chestY+100+openAmt*0.4,z:chestZ+80}; joints.re={x:rootX+140,y:chestY+40,z:chestZ+50}; joints.rw={x:rootX+220+openAmt,y:chestY+100+openAmt*0.4,z:chestZ+80};
} else {
const wavL = Math.sin(npcPhase) * 18, wavR = Math.cos(npcPhase) * 18;
joints.le={x:rootX-125,y:shoulderY+30+wavL,z:lsZ2-30}; joints.lw={x:rootX-80,y:headCenterY+55+wavL,z:headZ-170}; joints.re={x:rootX+135,y:shoulderY+40+wavR,z:rsZ2-20}; joints.rw={x:rootX+80,y:shoulderY+10+wavR,z:rsZ2-150};
}
const mSkin = currentSkinMat, mGlove = materials.glove, mPants = materials.pants, mShoe = materials.shoe;
updateMesh('e_head', 'joint', joints.head, null, 68, mSkin);
updateMesh('e_neck', 'bone', joints.neckTop, joints.neckBase, 30, mSkin);
updateMesh('e_necktorso', 'bone', joints.neckBase, { x:(joints.ls.x+joints.rs.x)/2, y:joints.ls.y, z:(joints.ls.z+joints.rs.z)/2 }, 38, mSkin);
updateMesh('e_shoulder', 'bone', joints.ls, joints.rs, 52, mSkin);
updateMesh('e_chest', 'bone', { x:(joints.ls.x+joints.rs.x)/2, y:joints.ls.y, z:(joints.ls.z+joints.rs.z)/2 }, joints.waist, 70, mSkin);
updateMesh('e_abdomen', 'bone', joints.waist, joints.hip, 56, mSkin);
updateMesh('e_pelvis', 'bone', joints.lhip, joints.rhip, 50, mPants);
updateMesh('e_uarm_l', 'bone', joints.ls, joints.le, 36, mSkin); updateMesh('e_elbow_l', 'joint', joints.le, null, 31, mSkin); updateMesh('e_larm_l', 'bone', joints.le, joints.lw, 28, mSkin);
updateMesh('e_uarm_r', 'bone', joints.rs, joints.re, 36, mSkin); updateMesh('e_elbow_r', 'joint', joints.re, null, 31, mSkin); updateMesh('e_larm_r', 'bone', joints.re, joints.rw, 28, mSkin);
updateMesh('e_glove_l', 'joint', joints.lw, null, 57, mGlove); updateMesh('e_glove_r', 'joint', joints.rw, null, 57, mGlove);
updateMesh('e_uleg_l', 'bone', joints.lhip, joints.lknee, 42, mSkin); updateMesh('e_knee_l', 'joint', joints.lknee, null, 32, mSkin); updateMesh('e_lleg_l', 'bone', joints.lknee, joints.lfoot, 28, mSkin); updateMesh('e_foot_l', 'joint', joints.lfoot, null, 30, mShoe);
updateMesh('e_uleg_r', 'bone', joints.rhip, joints.rknee, 42, mSkin); updateMesh('e_knee_r', 'joint', joints.rknee, null, 32, mSkin); updateMesh('e_lleg_r', 'bone', joints.rknee, joints.rfoot, 28, mSkin); updateMesh('e_foot_r', 'joint', joints.rfoot, null, 30, mShoe);
}
function buildPlayerPoly() {
let pPhase = (Date.now() * 0.005) % (Math.PI * 2);
if (playerAction !== 'idle') pPhase = 0;
const isWindup = playerAction.includes('windup');
const isAttacking = playerAction.includes('punch');
const isLeftPunch = playerAction.includes('L');
const isBlocking = playerBlocking;
const isRecoil = playerAction === 'recoil';
const isHurt = playerHurtShake > 40;
const isSpecial = playerAction.startsWith('special_');
// 必殺技進行度 (0→1)
const specMove = isSpecial ? SPECIAL_MOVES.find(m => m.motion === playerAction) : null;
const specProg = isSpecial && specMove ? 1.0 - (specialTimer / specMove.duration) : 0;
const sx = (Math.random() - 0.5) * playerHurtShake;
const sy = (Math.random() - 0.5) * playerHurtShake;
const sz = (Math.random() - 0.5) * playerHurtShake;
const bobY = Math.sin(pPhase) * 8;
const stepZ = isAttacking ? 80 : 0;
const rootX = playerPos.x + sx;
const rootZ = playerPos.z + sz + stepZ;
const footY = 600 + bobY;
const kneeY = 420 + bobY;
const hipY = 220 + bobY;
const waistY = 130 + bobY;
const chestY = 30 + bobY + sy;
const shoulderY = -10 + bobY + sy;
const neckBaseY = -50 + bobY + sy;
const neckTopY = -100 + bobY + sy;
const headCenterY = -170 + bobY + sy;
const arch = isHurt ? -80 : 0;
const waistZ = rootZ + arch * 0.2;
const chestZ = rootZ + arch * 0.6;
const shoulderZ = rootZ + arch * 0.8;
const neckBaseZ = rootZ + arch * 0.9;
const neckTopZ = rootZ + arch;
const headZ = rootZ + arch;
const footStep = Math.sin(pPhase * 0.5) * 20;
let joints = {
head: { x: rootX, y: headCenterY, z: headZ },
neckTop: { x: rootX, y: neckTopY, z: neckTopZ },
neckBase: { x: rootX, y: neckBaseY, z: neckBaseZ },
ls: { x: rootX - 110, y: shoulderY, z: shoulderZ },
rs: { x: rootX + 110, y: shoulderY, z: shoulderZ },
chest: { x: rootX, y: chestY, z: chestZ },
waist: { x: rootX, y: waistY, z: waistZ },
hip: { x: rootX, y: hipY, z: rootZ },
lhip: { x: rootX - 60, y: hipY, z: rootZ },
rhip: { x: rootX + 60, y: hipY, z: rootZ },
lknee: { x: rootX - 70, y: kneeY, z: rootZ + footStep },
rknee: { x: rootX + 70, y: kneeY, z: rootZ - footStep },
lfoot: { x: rootX - 75, y: footY, z: rootZ + footStep },
rfoot: { x: rootX + 75, y: footY, z: rootZ - footStep },
};
if (isHurt) {
joints.le = { x: rootX - 140, y: shoulderY + 60, z: shoulderZ - 50 };
joints.lw = { x: rootX - 200, y: headCenterY + 80, z: shoulderZ - 80 };
joints.re = { x: rootX + 140, y: shoulderY + 60, z: shoulderZ - 50 };
joints.rw = { x: rootX + 200, y: headCenterY + 80, z: shoulderZ - 80 };
} else if (isRecoil) {
joints.le = { x: rootX - 160, y: shoulderY - 30, z: shoulderZ - 20 };
joints.lw = { x: rootX - 280, y: shoulderY - 120, z: shoulderZ - 50 };
joints.re = { x: rootX + 160, y: shoulderY - 30, z: shoulderZ - 20 };
joints.rw = { x: rootX + 280, y: shoulderY - 120, z: shoulderZ - 50 };
} else if (isBlocking) {
joints.le = { x: rootX - 80, y: shoulderY + 30, z: shoulderZ + 80 };
joints.lw = { x: rootX - 40, y: headCenterY + 40, z: headZ + 150 };
joints.re = { x: rootX + 80, y: shoulderY + 30, z: shoulderZ + 80 };
joints.rw = { x: rootX + 40, y: headCenterY + 40, z: headZ + 150 };
} else if (isWindup) {
if (isLeftPunch) {
joints.le = { x: rootX - 150, y: shoulderY + 50, z: shoulderZ - 100 };
joints.lw = { x: rootX - 100, y: shoulderY + 20, z: shoulderZ - 50 };
joints.re = { x: rootX + 120, y: shoulderY + 10, z: shoulderZ + 30 };
joints.rw = { x: rootX + 80, y: headCenterY + 50, z: headZ + 140 };
} else {
joints.le = { x: rootX - 120, y: shoulderY + 10, z: shoulderZ + 30 };
joints.lw = { x: rootX - 80, y: headCenterY + 50, z: headZ + 140 };
joints.re = { x: rootX + 150, y: shoulderY + 50, z: shoulderZ - 100 };
joints.rw = { x: rootX + 100, y: shoulderY + 20, z: shoulderZ - 50 };
}
} else if (isAttacking) {
const punchReach = rootZ + 550;
const punchOffX = isLeftPunch ? -50 : 50;
if (isLeftPunch) {
joints.le = { x: rootX - 100, y: shoulderY + 20, z: shoulderZ + 60 };
joints.lw = { x: rootX + punchOffX, y: shoulderY + 30, z: punchReach };
joints.re = { x: rootX + 120, y: shoulderY + 10, z: shoulderZ + 30 };
joints.rw = { x: rootX + 80, y: headCenterY + 50, z: headZ + 140 };
} else {
joints.le = { x: rootX - 120, y: shoulderY + 10, z: shoulderZ + 30 };
joints.lw = { x: rootX - 80, y: headCenterY + 50, z: headZ + 140 };
joints.re = { x: rootX + 100, y: shoulderY + 20, z: shoulderZ + 60 };
joints.rw = { x: rootX + punchOffX, y: shoulderY + 30, z: punchReach };
}
} else if (isSpecial) {
// 必殺技モーション: 技ごとに異なるポーズ
const sp = Math.sin(specProg * Math.PI); // 0→1→0 の弧 (ピーク=中盤)
const sp2 = Math.min(1, specProg * 2); // 前半で1に到達
if (playerAction === 'special_meteor') {
// 両腕を大きく振り下ろす
joints.le = { x: rootX - 160, y: shoulderY - 120 - sp * 200, z: shoulderZ - 80 + sp2 * 300 };
joints.lw = { x: rootX - 60, y: shoulderY - 200 - sp * 250, z: shoulderZ - 40 + sp2 * 500 };
joints.re = { x: rootX + 160, y: shoulderY - 120 - sp * 200, z: shoulderZ - 80 + sp2 * 300 };
joints.rw = { x: rootX + 60, y: shoulderY - 200 - sp * 250, z: shoulderZ - 40 + sp2 * 500 };
joints.lknee = { x: rootX - 70, y: kneeY + sp * 40, z: rootZ + footStep + sp * 60 };
joints.rknee = { x: rootX + 70, y: kneeY + sp * 40, z: rootZ - footStep - sp * 60 };
} else if (playerAction === 'special_twin') {
// 両腕を素早く前に突き出す (左右交互)
const altL = Math.sin(specProg * Math.PI * 4);
const altR = Math.sin(specProg * Math.PI * 4 + Math.PI);
joints.le = { x: rootX - 100, y: shoulderY + 20 + altL * 30, z: shoulderZ + 40 + Math.max(0, altL) * 100 };
joints.lw = { x: rootX - 40, y: shoulderY + 10 + altL * 20, z: shoulderZ + 200 + Math.max(0, altL) * 300 };
joints.re = { x: rootX + 100, y: shoulderY + 20 + altR * 30, z: shoulderZ + 40 + Math.max(0, altR) * 100 };
joints.rw = { x: rootX + 40, y: shoulderY + 10 + altR * 20, z: shoulderZ + 200 + Math.max(0, altR) * 300 };
} else if (playerAction === 'special_rising') {
// 下からのアッパー: しゃがみ→立ち上がりながら打ち上げ
const crouch = Math.max(0, 1 - specProg * 2.5);
const rise = Math.max(0, specProg * 2 - 0.4);
joints.lknee = { x: rootX - 70, y: kneeY + crouch * 120, z: rootZ + footStep };
joints.rknee = { x: rootX + 70, y: kneeY + crouch * 120, z: rootZ - footStep };
joints.lfoot = { x: rootX - 75, y: footY + crouch * 80, z: rootZ + footStep };
joints.rfoot = { x: rootX + 75, y: footY + crouch * 80, z: rootZ - footStep };
joints.le = { x: rootX - 120, y: shoulderY + 80 - rise * 280, z: shoulderZ - 60 };
joints.lw = { x: rootX - 80, y: shoulderY + 150 - rise * 400 - sp * 50, z: shoulderZ + 50 + rise * 200 };
joints.re = { x: rootX + 100, y: shoulderY + 50 - rise * 200, z: shoulderZ - 30 };
joints.rw = { x: rootX + 60, y: shoulderY + 120 - rise * 350, z: shoulderZ + 80 + rise * 150 };
} else {
// フォールバック (未知の必殺技)
joints.le = { x: rootX - 160, y: shoulderY - 100, z: shoulderZ + 200 };
joints.lw = { x: rootX - 60, y: shoulderY - 150, z: shoulderZ + 450 };
joints.re = { x: rootX + 160, y: shoulderY - 100, z: shoulderZ + 200 };
joints.rw = { x: rootX + 60, y: shoulderY - 150, z: shoulderZ + 450 };
}
} else {
const wavL = Math.sin(pPhase) * 15;
const wavR = Math.cos(pPhase) * 15;
joints.le = { x: rootX - 120, y: shoulderY + 30 + wavL, z: shoulderZ + 30 };
joints.lw = { x: rootX - 80, y: headCenterY + 55 + wavL, z: headZ + 150 };
joints.re = { x: rootX + 130, y: shoulderY + 40 + wavR, z: shoulderZ + 20 };
joints.rw = { x: rootX + 80, y: shoulderY + 10 + wavR, z: shoulderZ + 140 };
}
if (controlMode === 'camera' && !isAttacking && !isWindup && !isRecoil && !isHurt && !isSpecial) {
joints.lw.x = rootX + armL.x;
joints.lw.y = chestY + armL.y;
joints.lw.z = chestZ + 150;
joints.rw.x = rootX + armR.x;
joints.rw.y = chestY + armR.y;
joints.rw.z = chestZ + 150;
joints.le = { x: (joints.ls.x + joints.lw.x)/2 - 50, y: (joints.ls.y + joints.lw.y)/2 + 40, z: (joints.ls.z + joints.lw.z)/2 };
joints.re = { x: (joints.rs.x + joints.rw.x)/2 + 50, y: (joints.rs.y + joints.rw.y)/2 + 40, z: (joints.rs.z + joints.rw.z)/2 };
}
const mArm = materials.playerArm;
const mGlove = materials.playerGlove;
const mPants = materials.playerPants;
const mShoe = materials.shoe;
const mBody = materials.playerBody;
updateMesh('p_head', 'joint', joints.head, null, 65, mBody);
updateMesh('p_neck', 'bone', joints.neckTop, joints.neckBase, 28, mBody);
updateMesh('p_necktorso', 'bone', joints.neckBase, { x: (joints.ls.x + joints.rs.x)/2, y: joints.ls.y, z: (joints.ls.z + joints.rs.z)/2 }, 35, mBody);
updateMesh('p_shoulder', 'bone', joints.ls, joints.rs, 50, mBody);
updateMesh('p_chest', 'bone', { x: (joints.ls.x + joints.rs.x)/2, y: joints.ls.y, z: (joints.ls.z + joints.rs.z)/2 }, joints.waist, 65, mBody);
updateMesh('p_abdomen', 'bone', joints.waist, joints.hip, 52, mBody);
updateMesh('p_pelvis', 'bone', joints.lhip, joints.rhip, 48, mPants);
updateMesh('p_uarm_l', 'bone', joints.ls, joints.le, 34, mArm);
updateMesh('p_elbow_l', 'joint', joints.le, null, 29, mArm);
updateMesh('p_larm_l', 'bone', joints.le, joints.lw, 26, mArm);
updateMesh('p_uarm_r', 'bone', joints.rs, joints.re, 34, mArm);
updateMesh('p_elbow_r', 'joint', joints.re, null, 29, mArm);
updateMesh('p_larm_r', 'bone', joints.re, joints.rw, 26, mArm);
updateMesh('p_glove_l', 'joint', joints.lw, null, 55, mGlove);
updateMesh('p_glove_r', 'joint', joints.rw, null, 55, mGlove);
updateMesh('p_uleg_l', 'bone', joints.lhip, joints.lknee, 40, mPants);
updateMesh('p_knee_l', 'joint', joints.lknee, null, 30, mPants);
updateMesh('p_lleg_l', 'bone', joints.lknee, joints.lfoot, 26, mPants);
updateMesh('p_foot_l', 'joint', joints.lfoot, null, 28, mShoe);
updateMesh('p_uleg_r', 'bone', joints.rhip, joints.rknee, 40, mPants);
updateMesh('p_knee_r', 'joint', joints.rknee, null, 30, mPants);
updateMesh('p_lleg_r', 'bone', joints.rknee, joints.rfoot, 26, mPants);
updateMesh('p_foot_r', 'joint', joints.rfoot, null, 28, mShoe);
}
// ─── メインループ ─────────────────────────────────────────────
function gameLoop() {
if (!gameRunning) return;
viewOffsetX += (targetViewOffsetX - viewOffsetX) * 0.15;
viewOffsetY += (targetViewOffsetY - viewOffsetY) * 0.15;
const bodyDmgPenalty = 1.0 - (playerLimbDmg.body / 200);
let staminaRegen = 0;
if (playerAction === 'guard') staminaRegen = 0.55 * bodyDmgPenalty;
else if (playerAction === 'idle') staminaRegen = 0.35 * bodyDmgPenalty;
else staminaRegen = 0.10 * bodyDmgPenalty;
playerStamina = Math.min(100, playerStamina + staminaRegen);
if (playerStamina >= 50) staminaDebuff = 1.0;
else if (playerStamina >= 20) staminaDebuff = 0.7 + (playerStamina - 20) / 30 * 0.3;
else staminaDebuff = 0.4 + playerStamina / 20 * 0.3;
const armPenalty = 1.0 - (playerLimbDmg.larm + playerLimbDmg.rarm) / 400;
staminaDebuff *= armPenalty;
document.getElementById('player-stamina-fill').style.width = playerStamina + '%';
const staminaFill = document.getElementById('player-stamina-fill');
if (playerStamina >= 50) { staminaFill.style.background = 'linear-gradient(90deg, #00f3ff, #0088ff)'; staminaFill.style.boxShadow = '0 0 15px #00f3ff'; }
else if (playerStamina >= 20) { staminaFill.style.background = 'linear-gradient(90deg, #ffcc00, #ff8800)'; staminaFill.style.boxShadow = '0 0 15px #ffcc00'; }
else { staminaFill.style.background = 'linear-gradient(90deg, #ff0055, #ff4444)'; staminaFill.style.boxShadow = '0 0 15px #ff0055'; }
detectMovement();
if (gameMode !== 'multi') updateEnemyAI();
if (hitStop > 0) hitStop--;
// ヒットゾーンインジケーター更新(距離変化で部位ハイライト)
updateHitZoneIndicator();
// 必殺技タイマー更新
if (specialActive && specialTimer > 0) {
specialTimer--;
if (specialTimer <= 0) { specialActive = false; specialMotion = ''; if (playerAction.startsWith('special_')) playerAction = 'idle'; }
}
let camBobY = Math.sin(walkBob * 2) * 15;
let camPunchZ = 0;
if (playerAction === 'punchL' || playerAction === 'punchR') camPunchZ = -80;
let roll = -viewOffsetX * 0.0005;
if (keys.a || keys.ArrowLeft) roll += 0.05;
if (keys.d || keys.ArrowRight) roll -= 0.05;
camHurtPitch *= 0.75;
playerHurtShake *= 0.82;
let shakeX = (Math.random() - 0.5) * playerHurtShake * 0.5;
let shakeY = (Math.random() - 0.5) * playerHurtShake * 0.5;
let camDist = 550;
let camHeight = 350;
camera.position.set(
playerPos.x + viewOffsetX * 0.8 + shakeX,
camHeight - viewOffsetY * 0.5 + camBobY + shakeY,
-playerPos.z + camDist + camPunchZ
);
let lookX = (playerPos.x * 0.4 + enemyPos.x * 0.6) + viewOffsetX;
let lookY = 150 + camHurtPitch;
let lookZ = (-playerPos.z * 0.2 - enemyPos.z * 0.8);
camera.lookAt(lookX, lookY, lookZ);
camera.rotation.z = roll;
pointLight.position.set(playerPos.x, 300, -playerPos.z);
buildEnemyPoly();
buildPlayerPoly();
renderer.render(scene, camera);
ctx.clearRect(0, 0, W(), H());
ctx.save();
if (screenShake > 0 || playerHurtShake > 0) {
let shake = Math.max(screenShake, playerHurtShake);
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
if (screenShake > 0) screenShake *= 0.8;
}
if (hitChromatic > 0) {
const ca = hitChromatic;
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.globalAlpha = 0.15; ctx.drawImage(renderer.domElement, -ca, 0, W(), H());
ctx.globalAlpha = 0.10; ctx.drawImage(renderer.domElement, ca, 0, W(), H());
ctx.restore();
hitChromatic = Math.max(0, hitChromatic - 1.5);
}
if (flashTimer > 0) { ctx.fillStyle = `rgba(255,255,255,${flashTimer * 0.2})`; ctx.fillRect(-100, -100, W() + 200, H() + 200); flashTimer--; }
if (playerStamina < 30) {
const alpha = (30 - playerStamina) / 30 * 0.25;
ctx.fillStyle = `rgba(50,0,0,${alpha})`; ctx.fillRect(0, 0, W(), H());
}
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i]; p.x += p.vx; p.y += p.vy; p.vy += 0.8; p.alpha -= 0.03;
if (p.alpha <= 0) { particles.splice(i, 1); continue; }
ctx.globalAlpha = p.alpha; ctx.fillStyle = p.color; ctx.beginPath();
if (p.size > 10) ctx.ellipse(p.x, p.y, p.size * 0.6, p.size, Math.atan2(p.vy, p.vx), 0, Math.PI * 2);
else ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1.0;
for (let i = hitNumbers.length - 1; i >= 0; i--) {
let h = hitNumbers[i]; h.y += h.vy; h.vy += 0.15; h.alpha -= 0.02; h.scale += (h.targetScale - h.scale) * 0.2;
if (h.alpha <= 0) { hitNumbers.splice(i, 1); continue; }
ctx.save(); ctx.globalAlpha = h.alpha; ctx.translate(h.x, h.y); ctx.scale(h.scale, h.scale);
ctx.fillStyle = h.color; ctx.font = 'bold 40px Arial'; ctx.textAlign = 'center';
ctx.shadowColor = 'black'; ctx.shadowBlur = 10; ctx.fillText(h.text, 0, 0); ctx.restore();
}
for (let i = chatBubbles.length - 1; i >= 0; i--) {
let b = chatBubbles[i]; b.y += b.vy; b.life--;
if (b.life < 60) b.alpha = b.life / 60;
if (b.life <= 0) { chatBubbles.splice(i, 1); continue; }
ctx.save(); ctx.globalAlpha = b.alpha; ctx.font = 'bold 22px Arial'; ctx.textAlign = 'center';
const textWidth = ctx.measureText(b.text).width;
ctx.fillStyle = b.isMine ? 'rgba(0, 255, 136, 0.4)' : 'rgba(0, 243, 255, 0.4)';
ctx.beginPath(); ctx.rect(b.x - textWidth/2 - 15, b.y - 30, textWidth + 30, 40); ctx.fill();
ctx.strokeStyle = b.isMine ? '#00ff88' : '#00f3ff'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#ffffff'; ctx.shadowColor = 'black'; ctx.shadowBlur = 4; ctx.fillText(b.text, b.x, b.y - 2); ctx.restore();
}
if (playerBlocking) {
ctx.fillStyle = 'rgba(0,243,255,0.55)'; ctx.font = 'bold 42px Arial'; ctx.textAlign = 'center';
ctx.shadowColor = '#00f3ff'; ctx.shadowBlur = 18; ctx.fillText('GUARD', W() / 2, H() - 200); ctx.shadowBlur = 0;
}
if (playerStamina < 20) {
ctx.fillStyle = `rgba(255,80,0,${0.5 + Math.sin(Date.now() * 0.01) * 0.3})`;
ctx.font = 'bold 22px Arial'; ctx.textAlign = 'center'; ctx.fillText('STAMINA LOW!', W() / 2, H() - 260);
}
ctx.restore();
requestAnimationFrame(gameLoop);
}
</script>
</body>
</html>

コメント