カメラで対戦する「MotionBoxing」
【更新履歴】
・2026/3/12 バージョン1.0公開。
(中略)
・2026/3/12 バージョン1.4公開。
【操作説明】(シングルモードでマウス操作の場合)
・マウスを移動させると、プレイヤーキャラの位置が移動します。
・左ボタンで左パンチ。
・右ボタンで右パンチ。
・左右を同時に押すとガード。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Motion Boxing 1.4</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; }
.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; }
</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>
<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="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="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>VIRTUAL BOUT</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')">👤 シングルプレイ (対NPC)</button>
<button class="btn btn-red" onclick="showMenu('menu-multi')">🌐 P2P通信対戦</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>操作方法</h3>
<p>【マウス】ポインタで移動。左右クリックでパンチ。<span class="key-hint">同時押しでガード</span><br>
【カメラ】自分の体の動きでゲーム内の体を移動・回避</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>P2P 通信対戦</h3>
<p style="color:#00f3ff; font-weight:bold;">あなたのID: <span id="my-peer-id">接続中...</span></p>
<input type="text" id="target-id-input" placeholder="相手のIDを入力して接続">
<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>🎮 ゲームシステム (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); }
}
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.6;
lfo.connect(lfoGain.gain);
const oscGain = audioCtx.createGain(); oscGain.gain.value = 0.5;
lfoGain.connect(oscGain.gain);
osc.connect(oscGain); oscGain.connect(delay); oscGain.connect(bgmGain);
osc.start(); lfo.start();
});
}
// ─── AI 長期記憶システム ─────────────────────────
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)); }
// ─── 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, 0x00f3ff);
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 }),
playerGlove: new THREE.MeshStandardMaterial({ color: 0x00f3ff, roughness: 0.2, emissive: 0x00f3ff, emissiveIntensity: 0.2 }),
playerArm: new THREE.MeshStandardMaterial({ color: 0x00b359, roughness: 0.5 }),
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; // 1ラウンド秒数(3分)
let roundTimeLeft = ROUND_DURATION; // 残り時間(秒)
let roundTimerInterval = null; // setIntervalハンドル
let roundInProgress = false; // ラウンド進行中フラグ(ブレイク中はfalse)
let comboTimer = null;
let playerHP = 100, playerStamina = 100;
// ── 部位ダメージ (0=無傷 100=完全損傷) ───────────────────────
// enemyの部位ダメージ
let enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
// プレイヤーの部位ダメージ → パフォーマンス低下に使用
let playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
// ── スタミナ詳細 ─────────────────────────────────────────────
// staminaDebuff: スタミナ低下による速度・パンチ力低下係数(0.0~1.0, 1=通常)
let staminaDebuff = 1.0;
let playerPos = { x: 0, z: 0 };
let targetPlayerPos = { x: 0, z: 0 };
let enemyPos = { x: 0, z: 600 };
let keys = { w:false, a:false, s:false, d:false, ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false };
let screenShake = 0, hitStop = 0, flashTimer = 0, hitChromatic = 0;
let particles = [], hitNumbers = [];
let walkBob = 0, playerHurtShake = 0, camHurtPitch = 0;
let viewOffsetX = 0, viewOffsetY = 0;
// ★初期視点オフセット: マウス未操作でも正面を向くよう調整
let targetViewOffsetX = 0, targetViewOffsetY = -60;
let aimY = 0;
let mouseL = false, mouseR = false;
let playerAction = 'idle';
let playerActionTimer = null;
let playerBlocking = false;
let armL = { x: -140, y: -150, z: 200 }, armR = { x: 140, y: -150, z: 200 };
let targetArmL = { x: -140, y: -150, z: 200 }, targetArmR = { x: 140, y: -150, z: 200 };
let enemy = {
hp: 100, maxHP: 100, state: 'idle', stateTimer: 0, nextPunch: 'R',
attackTimer: 0, attackInterval: 120, targetX: 0, targetZ: 600,
hurtShake: 0, poseData: null
};
let npcPhase = 0;
document.addEventListener('keydown', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = true; });
document.addEventListener('keyup', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = false; });
let wasRunning = false;
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;
// gameLoopはrequestAnimationFrameベースなので再起動が必要
requestAnimationFrame(gameLoop);
} else {
showMenu('menu-main');
}
}
// ─── P2P通信 (PeerJS) ───────────────────────────────────────
let peer = null; let conn = null;
let p2pSyncInterval = null;
function initPeer() {
try {
// 既存のpeerがあれば破棄
if (peer && !peer.destroyed) peer.destroy();
peer = new Peer(undefined, { debug: 0 });
peer.on('open', id => {
document.getElementById('my-peer-id').textContent = id;
});
// 相手から接続を受けた側: open確認後にゲーム開始
peer.on('connection', c => {
conn = c;
c.on('open', () => {
setupP2PConnection(c);
initP2PGame(controlMode || 'mouse');
});
});
peer.on('error', err => {
console.error('PeerJS error:', err);
document.getElementById('my-peer-id').textContent = 'エラー: ' + err.type;
});
peer.on('disconnected', () => {
document.getElementById('my-peer-id').textContent = '切断 (再接続試行中...)';
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-peer-id');
statusEl.textContent = statusEl.textContent + ' (接続中...)';
// 接続が確立してからゲーム開始
conn.on('open', () => {
setupP2PConnection(conn);
initP2PGame(ctrl);
});
conn.on('error', err => {
console.error('Connection error:', err);
alert('接続エラー: ' + err.message);
});
}
function setupP2PConnection(c) {
c.on('data', data => {
if (!data || !data.type) return;
if (data.type === 'sync') {
// 相手のHPと状態を自分の敵(enemy)に反映
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.isHeadshot);
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);
}
});
c.on('close', () => {
if (gameRunning) {
gameRunning = false;
alert('相手との接続が切れました。');
location.reload();
}
});
c.on('error', err => console.error('conn error:', err));
}
function sendP2PData() {
if (!conn || !conn.open) return;
// poseDataは現在のポーズ検知結果を含める
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);
// 約30fps でsyncデータを送信
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 };
updateUI(); gameRunning = true; roundInProgress = true;
if (controlMode === 'mouse') {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('contextmenu', e => e.preventDefault());
} else if (controlMode === 'camera') {
initCamera();
}
// ラウンドタイマー開始
startRoundTimer();
// 最初のラウンド開幕演出
showRoundBreak(1, false, () => { requestAnimationFrame(gameLoop); });
}
// ─── ラウンド制システム ───────────────────────────────────────────
// ラウンドブレイク演出: 画面を暗転して「ROUND X FIGHT!」を表示し、終わったらcallbackを呼ぶ
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) {
// ラウンド終了 → 次ラウンド開幕の2段階
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...';
// 2秒後に FIGHT! 表示
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');
// 1.5秒後にゲーム再開
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')}`;
// 残り30秒以下は赤く点滅
const timerEl = document.getElementById('timer');
timerEl.style.color = roundTimeLeft <= 30 ? '#ff0055' : '#fff';
}
// 時間切れ処理
function onRoundTimeUp() {
if (!gameRunning) return;
roundInProgress = false;
// 判定: HPで勝敗を判断
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();
}
}
// KOによるラウンド終了(プレイヤーが敵をKO)
function onEnemyKO() {
if (!gameRunning) return;
roundInProgress = false;
if (roundTimerInterval) { clearInterval(roundTimerInterval); roundTimerInterval = null; }
// KO演出
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) {
// 最終ラウンドでKO → YOU WIN
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++;
// 双方HP・スタミナ全回復、部位ダメージも回復
playerHP = 100; playerStamina = 100; staminaDebuff = 1.0;
playerLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
// 敵HP全回復・強化
enemy.maxHP = 100 + (round - 1) * 30; // Round2=130, Round3=160
enemy.hp = enemy.maxHP;
enemy.attackInterval = Math.max(70, 110 - (round - 1) * 15); // R2=95f, R3=80f
enemyLimbDmg = { larm: 0, rarm: 0, body: 0, legs: 0, head: 0 };
combo = 0;
updateUI();
updateLimbUI();
document.getElementById('round').textContent = `${round} / ${TOTAL_ROUNDS}`;
// ラウンドブレイク演出後にゲーム再開
showRoundBreak(round, true, null);
}
// ─── カメラ・MediaPose検知 ───────────────────────────────────────
let latestPoseData = null;
let poseInstance = null;
let cameraInstance = null;
// ポーズ検知の前フレームデータ(速度計算用)
let prevWristL = null, prevWristR = null;
let prevShoulderL = null, prevShoulderR = null;
// カメラ動作→パンチ検知の状態
let camPunchCooldownL = 0, camPunchCooldownR = 0;
let camGuardFrames = 0;
function initCamera() {
const videoEl = document.getElementById('video');
videoEl.style.display = 'none';
if (!window.Pose) {
console.error('MediaPipe 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 => {
console.error('カメラ起動失敗:', err);
alert('カメラへのアクセスが拒否されました。\nマウスモードに切り替えてください。');
});
} else {
// camera_utils がなければ直接getUserMediaを使用
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 => {
console.error('カメラアクセス失敗:', err);
alert('カメラへのアクセスが拒否されました。マウスモードをお使いください。');
});
}
}
function onPoseResults(results) {
if (!gameRunning || !results.poseLandmarks) return;
const lm = results.poseLandmarks;
// MediaPipe Pose ランドマークインデックス
// 11=左肩, 12=右肩, 13=左肘, 14=右肘, 15=左手首, 16=右手首
// 23=左腰, 24=右腰, 0=鼻
const ls = lm[11], rs = lm[12];
const lw = lm[15], rw = lm[16];
const lhip = lm[23], rhip = lm[24];
const nose = lm[0];
if (!ls || !rs || !lw || !rw) return;
// 信頼度フィルタ
if (ls.visibility < 0.4 || rs.visibility < 0.4) return;
// カメラ座標 (0~1) → ゲーム座標変換
// xは左右反転(カメラは鏡映し)
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;
}
// 手首の3D位置を保存(ゲーム座標)
const lwGameX = (1.0 - lw.x - 0.5) * 1600;
const lwGameY = (lw.y - 0.5) * 800;
const rwGameX = (1.0 - rw.x - 0.5) * 1600;
const rwGameY = (rw.y - 0.5) * 800;
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;
const velLY = (lw.y - prevWristL.y) / refWidth;
const velRX = (rw.x - prevWristR.x) / refWidth;
const velRY = (rw.y - prevWristR.y) / refWidth;
// 前方への速度: カメラ画像ではY軸上昇(y減少)=前方突き出し として検知
// 実際は手首がカメラに近づく→画像上で手首が大きく見えるが単純ランドマークでは取れないため
// 左右方向への素早い動き + 向き(相手方向)で判定
const speedL = Math.sqrt(velLX*velLX + velLY*velLY);
const speedR = Math.sqrt(velRX*velRX + velRY*velRY);
const PUNCH_THRESHOLD = 0.18;
// 左パンチ: 左手首が素早く右方向(相手方向)に動く、かつ肩より前(高め)
if (camPunchCooldownL <= 0 && speedL > PUNCH_THRESHOLD && lw.visibility > 0.5 && playerAction === 'idle') {
const isForward = velLX > 0.1; // 鏡映しなので右向き=前に出す
if (isForward && playerStamina >= 15) {
playerStamina -= 15;
aimY = lw.y < 0.35 ? -0.5 : 0.1; // 手首高さでヘッドショット判定
playerBlocking = false;
playerAction = 'windupL';
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupL') {
playerAction = 'punchL';
tryPunch('left', Math.min(600, speedL * 3000));
setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300);
}
}, 80);
camPunchCooldownL = 20; // 約0.33秒クールダウン
}
}
// 右パンチ: 右手首が素早く左方向(相手方向)に動く
if (camPunchCooldownR <= 0 && speedR > PUNCH_THRESHOLD && rw.visibility > 0.5 && playerAction === 'idle') {
const isForward = velRX < -0.1; // 右手は左方向=前に出す
if (isForward && playerStamina >= 15) {
playerStamina -= 15;
aimY = rw.y < 0.35 ? -0.5 : 0.1;
playerBlocking = false;
playerAction = 'windupR';
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupR') {
playerAction = 'punchR';
tryPunch('right', Math.min(600, speedR * 3000));
setTimeout(() => { if (playerAction === 'punchR') playerAction = 'idle'; }, 300);
}
}, 80);
camPunchCooldownR = 20;
}
}
}
// ── ガード検知: 両手首を顔の前(高い位置)に上げる ──
const lHandHigh = lw.y < ls.y - 0.05 && lw.visibility > 0.4;
const rHandHigh = rw.y < rs.y - 0.05 && rw.visibility > 0.4;
if (lHandHigh && rHandHigh && playerAction === 'idle') {
camGuardFrames++;
if (camGuardFrames > 3) {
mouseL = true; mouseR = true;
updatePlayerAction();
}
} else {
if (camGuardFrames > 3 && playerAction === 'guard') {
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)) * 800; // ★左右移動範囲拡大
targetPlayerPos.z = ((e.clientY - H()/2) / (H()/2)) * 400 + 150;
}
function onMouseDown(e) {
if (!gameRunning || controlMode !== 'mouse') return;
if (e.button === 0) mouseL = true;
if (e.button === 2) mouseR = true;
updatePlayerAction();
}
function onMouseUp(e) {
if (!gameRunning || controlMode !== 'mouse') return;
if (e.button === 0) mouseL = false;
if (e.button === 2) mouseR = false;
updatePlayerAction();
}
function updatePlayerAction() {
if (playerAction === 'recoil') return;
if (mouseL && mouseR) {
clearTimeout(playerActionTimer); playerAction = 'guard'; playerBlocking = true; playSound('swing');
}
else if (mouseL && !mouseR && playerAction === 'idle') {
if (playerStamina < 12) return; // スタミナ切れはパンチ不可
playerStamina -= 15;
playerBlocking = false; playerAction = 'windupL'; playSound('swing');
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupL') {
playerAction = 'punchL';
tryPunch('left', 250 * staminaDebuff); // スタミナデバフをspeedに反映
setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 300);
}
}, 120);
}
else if (mouseR && !mouseL && playerAction === 'idle') {
if (playerStamina < 12) 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;
// 脚ダメージによる移動速度低下: 最大40%低下
const legPenalty = 1.0 - (playerLimbDmg.legs / 250);
if (controlMode === 'mouse') {
let movedByKey = false;
const speed = Math.round(25 * 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(-200, Math.min(1000, playerPos.z));
if (playerAction === 'idle') {
targetArmL = { x: -140, y: -150 + Math.sin(walkBob)*10, z: 200 };
targetArmR = { x: 140, y: -150 + Math.sin(walkBob + Math.PI)*10, z: 200 };
} else if (playerAction === 'guard') {
targetArmL = { x: -60, y: -250, z: 300 };
targetArmR = { x: 60, y: -250, z: 300 };
} else if (playerAction === 'windupL') {
targetArmL = { x: -200, y: -100, z: -100 };
targetArmR = { x: 140, y: -150, z: 200 };
} else if (playerAction === 'windupR') {
targetArmL = { x: -140, y: -150, z: 200 };
targetArmR = { x: 200, y: -100, z: -100 };
} else if (playerAction === 'punchL') {
targetArmL = { x: -40, y: -280, z: 1200 };
targetArmR = { x: 140, y: -150, z: 200 };
} else if (playerAction === 'punchR') {
targetArmL = { x: -140, y: -150, z: 200 };
targetArmR = { x: 40, y: -280, z: 1200 };
} else if (playerAction === 'recoil') {
targetArmL = { x: -300, y: -100, z: 0 };
targetArmR = { x: 300, y: -100, z: 0 };
}
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;
}
// ─── 当たり判定と戦闘ロジック ─────────────────────────
// aimYから狙い部位を判定する
// aimY: -1(画面上端)〜+1(画面下端)
// 頭: aimY < -0.15
// 上体: -0.15 <= aimY < 0.25
// 腹/腰: 0.25 <= aimY < 0.55
// 脚: aimY >= 0.55
function getTargetPart(aimYVal) {
if (aimYVal < -0.15) return 'head';
if (aimYVal < 0.25) return 'body';
if (aimYVal < 0.55) return 'body'; // 腹も body 扱い
return 'legs';
}
// 部位ごとのダメージ倍率と効果
const PART_CONFIG = {
head: { multi: 2.0, label: 'HEAD', color: '#ff0055' },
body: { multi: 1.2, label: 'BODY', color: '#ff8800' },
legs: { multi: 0.8, label: 'LEGS', color: '#ffcc00' },
larm: { multi: 0.7, label: 'L.ARM', color: '#00f3ff' },
rarm: { multi: 0.7, label: 'R.ARM', color: '#00f3ff' },
};
function evaluateHit(attackerPos, defenderPos, isDefenderBlocking, targetPart) {
const dx = Math.abs(attackerPos.x - defenderPos.x);
const dz = attackerPos.z - defenderPos.z;
if (Math.abs(dz) > 480) return { hit: false, reason: 'MISS (TOO FAR)' };
if (dx > 220) return { hit: false, reason: 'MISS (DODGED)' };
if (isDefenderBlocking) return { hit: false, reason: 'GUARD!' };
const isClean = (Math.abs(dz) < 280 && dx < 110);
const cfg = PART_CONFIG[targetPart] || PART_CONFIG.body;
const multi = cfg.multi * (isClean ? 1.4 : 1.0);
return {
hit: true,
type: isClean ? `${cfg.label} CLEAN HIT` : `${cfg.label} HIT`,
multi,
part: targetPart,
isClean
};
}
function tryPunch(side, speed) {
// aimYから部位を決定
const targetPart = getTargetPart(aimY);
if (side === 'left') aiMemory.leftStrikes++; else aiMemory.rightStrikes++;
saveMemory();
// スタミナデバフをパンチ速度に反映
const effectiveSpeed = speed * staminaDebuff;
if (gameMode === 'multi') {
if (conn && conn.open) conn.send({ type: 'punch', side: side, speed: effectiveSpeed, targetPart: targetPart });
return;
}
// ── パリィ判定 ──────────────────────────────────────────────
const isEnemyAttacking = enemy.state.includes('punch');
if (isEnemyAttacking) {
const dz = Math.abs(playerPos.z - enemyPos.z);
if (dz < 480) {
playSound('parry');
spawnParticles(W() / 2, H() / 2, '#00f3ff', 30);
showHitNumber('PARRY!!', '#00f3ff', 1.8);
screenShake = 25; hitStop = 12;
enemy.state = 'recoil'; enemy.stateTimer = 28;
playerAction = 'recoil'; clearTimeout(playerActionTimer);
setTimeout(() => { if (playerAction === 'recoil') playerAction = 'idle'; }, 420);
return;
}
}
const isEnemyBlocking = enemy.state === 'blocking';
const result = evaluateHit(playerPos, enemyPos, isEnemyBlocking, targetPart);
if (!result.hit) {
if (result.reason === 'GUARD!') playSound('guard');
showHitNumber(result.reason, '#aaa');
combo = 0; updateUI();
} else {
// 基礎ダメージ: speed×0.04 × スタミナデバフ
const baseDmg = Math.max(6, Math.floor(effectiveSpeed * 0.04));
const dmg = Math.floor(baseDmg * result.multi);
enemy.hp = Math.max(0, enemy.hp - dmg);
// 部位ダメージ蓄積
applyLimbDamage('enemy', result.part, dmg);
hitEnemyVisual(dmg, side === 'right' ? 50 : -50, result.part === 'head' ? -150 : -50, result.isClean, result.part);
if (enemy.hp <= 0) {
onEnemyKO();
}
}
}
// 部位ダメージを蓄積し、UIと効果を更新
function applyLimbDamage(target, part, dmg) {
if (target === 'enemy') {
enemyLimbDmg[part] = Math.min(100, (enemyLimbDmg[part] || 0) + dmg * 0.8);
// 敵の部位ダメージ効果(AI動作への影響は将来拡張用の記録のみ)
} else {
playerLimbDmg[part] = Math.min(100, (playerLimbDmg[part] || 0) + dmg * 0.8);
// プレイヤー部位ダメージ効果
// 腕へのダメージ → staminaDebuff経由でパンチ力低下
const armDmg = (playerLimbDmg.larm + playerLimbDmg.rarm) / 200;
// 脚へのダメージ → 移動速度低下(detectMovement内で参照)
// 胴体→スタミナ回復速度低下(gameLoop内で参照)
updateLimbUI();
}
}
// 部位ダメージUIを更新
function updateLimbUI() {
const fillColor = (pct) => pct > 60 ? '#ff0055' : pct > 30 ? '#ffaa00' : '#00f3ff';
const set = (id, val) => {
const el = document.getElementById(id);
if (!el) return;
const pct = val;
const 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) {
playSound(isCleanHit ? 'clean' : 'hit');
combo++; score += dmg * combo * (isCleanHit ? 2 : 1);
enemy.state = 'hurt'; enemy.stateTimer = 30; enemy.hurtShake = isCleanHit ? 70 : 35;
hitStop = isCleanHit ? 12 : 6; screenShake = isCleanHit ? 35 : 18; flashTimer = isCleanHit ? 6 : 3;
const cfg = PART_CONFIG[part] || PART_CONFIG.body;
const color = isCleanHit ? '#ffd700' : cfg.color;
spawnParticles(W()/2 + ox, H()/2 + oy, '#ffffff', isCleanHit ? 18 : 8);
spawnParticles(W()/2 + ox, H()/2 + oy, color, isCleanHit ? 30 : 14);
if (isCleanHit) {
spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ffd700', 20, -1.0, 0.4);
spawnParticlesDirected(W()/2 + ox, H()/2 + oy + 30, '#ff6600', 15, -1.2, 0.3);
if (enemy.hp / enemy.maxHP < 0.4) {
spawnParticlesDirected(W()/2 + ox + 20, H()/2 + oy, '#ff0000', 12, -0.8, 0.6);
}
}
// 部位名とダメージ数値を表示
const label = isCleanHit ? `${cfg.label} CLEAN! ${dmg}` : `${cfg.label} ${dmg}`;
showHitNumber(label, 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();
}
// 方向付きパーティクル: vyBias で上方向に飛ばすなど
function spawnParticlesDirected(x, y, color, count, vyBias, spread) {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const 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;
// 部位ダメージ蓄積とUI更新
applyLimbDamage('player', hitPart, dmg);
// 頭への大ダメージ → ノックダウンしやすい(HPが残っていてもふらつき)
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.');
}
}
// ─── NPC AI ──────────────────────────────────────────
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 {
// 敵ダメージ: 基礎 8 + round*2、部位は body または head でランダム
const hitPart = Math.random() < 0.3 ? 'head' : 'body';
const baseDmg = 8 + round * 2;
takeDamage(Math.floor(baseDmg * result.multi), result.isClean, hitPart);
}
}
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;
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; }
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);
}
// ─── Three.js メッシュ管理 ──────────────────────────────────────────
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');
const isAttacking = enemy.state.includes('punch') || enemy.state === 'attacking';
const isHurt = enemy.state === 'hurt';
const isRecoil = enemy.state === 'recoil';
const isLeftPunch = enemy.state.includes('L');
const isBlocking = enemy.state === 'blocking';
// ── のけぞり進行度 (0→1→0) ─────────────────────────────────
// stateTimerは25から0へカウントダウン → 被弾直後ほどhurtProgress大
const hurtMax = 30; // stateTimer初期値と合わせる
const hurtProgress = isHurt
? Math.sin((1.0 - enemy.stateTimer / hurtMax) * Math.PI)
: 0;
// ── 海老反り: 上体を後ろへ弓なりに反らせる ──────────────────
// leanZ: 足元基準でどれだけ後ろへ倒れるか(骨盤は動かさず上体だけ)
const archAmount = isHurt ? 320 * hurtProgress : 0; // 上体が後ろへ
const headExtra = isHurt ? 160 * hurtProgress : 0; // 頭はさらに後ろへ
const kneeBend = isHurt ? 80 * hurtProgress : 0; // 膝が前へ出る(踏ん張り)
const waistRiseZ = isHurt ? 120 * hurtProgress : 0; // 腰も少し後退
// ── 揺れシェイク ────────────────────────────────────────────
const shake = enemy.hurtShake;
const sx = (Math.random() - 0.5) * shake;
const sy = (Math.random() - 0.5) * shake;
const sz = (Math.random() - 0.5) * shake;
// ── ベース座標 ──────────────────────────────────────────────
const bobY = Math.sin(npcPhase) * 12;
const stepZ = isAttacking ? -80 : 0; // パンチ時に一歩踏み込む
// 骨盤ルート(ここは被弾時も動かさない=海老反りの支点)
const rootX = enemyPos.x + sx;
const rootZ = enemyPos.z + sz + stepZ;
// ── Y軸オフセット(下から上へ積み上げる) ───────────────────
// キャラ全高を整理:
// 足元(footY) → 膝(kneeY) → 骨盤(hipY) → 腰(waistY) → 胸(chestY)
// → 肩(shoulderY) → 首(neckY) → 頭中心(headY)
// Three.js座標 y軸: 上がプラス → ゲーム座標はy増加=下方向なので注意
// updateMesh内で ty = -p1.y に変換されるため、ゲーム座標でy値が小さい=画面上
const footY = 600 + bobY; // 足底(最下)
const kneeY = 420 + bobY + kneeBend;
const hipY = 220 + bobY; // 骨盤(支点)
const waistY = 130 + bobY + (isHurt ? 10 * hurtProgress : 0);
// 胸・肩は被弾時に後ろへ反る(Y座標も少し持ち上がる=y減少)
const chestY = 30 + bobY + sy + (isHurt ? -20 * hurtProgress : 0);
const shoulderY = -10 + bobY + sy;
// 首は肩の真上から頭への橋渡し
const neckBaseY = -50 + bobY + sy; // 首の根元
const neckTopY = -100 + bobY + sy; // 首の先(頭との接合点)
// 頭中心は首先端のさらに上(頭の球半径分)
const headCenterY = -170 + bobY + sy + (isHurt ? -10 * hurtProgress : 0);
// ── Z軸: 海老反りで上体だけ後退 ────────────────────────────
// 骨盤(rootZ)を支点に、上へ行くほど後ろへ反る
const waistZ = rootZ + waistRiseZ * 0.3;
const chestZ = rootZ + archAmount * 0.6;
const shoulderZ = rootZ + archAmount * 0.85;
const neckBaseZ = rootZ + archAmount * 0.95;
const neckTopZ = rootZ + archAmount + headExtra * 0.3;
const headZ = rootZ + archAmount + headExtra; // 頭が最も後方
// 足のX位置(ボクシングスタンス: 左足前)
const footStep = Math.sin(npcPhase * 0.5) * 40;
let joints = {
// 頭・首(Z方向に分離して首が見えるようにする)
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 lsX = joints.ls.x, lsY = joints.ls.y, lsZ2 = joints.ls.z;
const rsX = joints.rs.x, rsY = joints.rs.y, 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) {
// パンチ繰り出し: ガード中はプレイヤーの腕で止まる(すり抜けない)
// playerBlocking=true の時はリーチを短く (腕の前あたりで停止)
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 (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;
const 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;
const mGlove = materials.glove;
const mPants = materials.pants;
const mShoe = materials.shoe;
// 頭部: 球体
updateMesh('e_head', 'joint', joints.head, null, 68, mSkin);
// 首: 頭の付け根(neckTop)→首の根元(neckBase) を1本のボーンで繋ぐ
// これにより頭と首が離れなくなる(headZとneckTopZを同一にしているため)
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 drawMyArms() {
if (playerHurtShake > 0) playerHurtShake *= 0.8;
let sx = (Math.random()-0.5) * playerHurtShake, sy = (Math.random()-0.5) * playerHurtShake;
const lShoulder = {x: playerPos.x - 160 + sx, y: -80 + sy, z: playerPos.z};
const rShoulder = {x: playerPos.x + 160 + sx, y: -80 + sy, z: playerPos.z};
const lGlove = {x: playerPos.x + armL.x + sx, y: armL.y + sy, z: playerPos.z + armL.z};
const rGlove = {x: playerPos.x + armR.x + sx, y: armR.y + sy, z: playerPos.z + armR.z};
const elbowDrop = 80;
const lElbow = { x: (lShoulder.x + lGlove.x) / 2 - 30, y: (lShoulder.y + lGlove.y) / 2 + elbowDrop, z: (lShoulder.z + lGlove.z) / 2 };
const rElbow = { x: (rShoulder.x + rGlove.x) / 2 + 30, y: (rShoulder.y + rGlove.y) / 2 + elbowDrop, z: (rShoulder.z + rGlove.z) / 2 };
const matArm = materials.playerArm, matGlove = materials.playerGlove;
updateMesh('p_uarm_l', 'bone', lShoulder, lElbow, 45, matArm); updateMesh('p_elbow_l', 'joint', lElbow, null, 40, matArm); updateMesh('p_larm_l', 'bone', lElbow, lGlove, 40, matArm); updateMesh('p_glove_l', 'joint', lGlove, null, 70, matGlove);
updateMesh('p_uarm_r', 'bone', rShoulder, rElbow, 45, matArm); updateMesh('p_elbow_r', 'joint', rElbow, null, 40, matArm); updateMesh('p_larm_r', 'bone', rElbow, rGlove, 40, matArm); updateMesh('p_glove_r', 'joint', rGlove, null, 70, matGlove);
}
// ─── メインループ ─────────────────────────────────────────────
function gameLoop() {
if (!gameRunning) return;
viewOffsetX += (targetViewOffsetX - viewOffsetX) * 0.15;
viewOffsetY += (targetViewOffsetY - viewOffsetY) * 0.15;
// ── スタミナ回復ロジック ───────────────────────────────────────
// 胴体ダメージが多いほど回復が遅い
// ガード中・待機中は回復速が速い
// 移動中・アクション中は少し消耗
const bodyDmgPenalty = 1.0 - (playerLimbDmg.body / 200); // 最大50%回復低下
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);
// スタミナデバフ計算: 50%以下から徐々に低下、20%以下で大幅低下
if (playerStamina >= 50) {
staminaDebuff = 1.0;
} else if (playerStamina >= 20) {
staminaDebuff = 0.7 + (playerStamina - 20) / 30 * 0.3; // 0.70〜1.00
} else {
staminaDebuff = 0.4 + playerStamina / 20 * 0.3; // 0.40〜0.70
}
// 腕ダメージによるパンチ力低下を追加
const armPenalty = 1.0 - (playerLimbDmg.larm + playerLimbDmg.rarm) / 400;
staminaDebuff *= armPenalty;
document.getElementById('player-stamina-fill').style.width = playerStamina + '%';
// スタミナバーの色: 50%以上=青、20〜50%=黄、20%以下=赤
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内で参照
detectMovement();
if (gameMode !== 'multi') updateEnemyAI();
if (hitStop > 0) hitStop--;
// ── カメラ計算 ────────────────────────────────────────────────
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;
// ★初期カメラ高さ: y=250 から y=180 に下げ、初期から正しい視点にする
// viewOffsetY 初期値 -60 により lookatYが下がり敵の顔方向を向く
camera.position.set(
playerPos.x + viewOffsetX + shakeX,
180 - viewOffsetY + camBobY + shakeY,
-playerPos.z + 100 + camPunchZ
);
camera.lookAt(
playerPos.x + viewOffsetX * 1.5,
180 - viewOffsetY * 1.5 + camHurtPitch,
-playerPos.z - 1000
);
camera.rotation.z = roll;
pointLight.position.set(playerPos.x, 300, -playerPos.z);
buildEnemyPoly();
drawMyArms();
renderer.render(scene, camera);
// ── 2D Canvas エフェクト ─────────────────────────────────────
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();
}
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;
}
// スタミナ低下 HUD 警告
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>

コメント