カメラで対戦する「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.3</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: 20px; right: 20px; width: auto; padding: 10px 20px; z-index: 30; pointer-events: auto; }
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; }
</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</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>
<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;
let comboTimer = null;
let playerHP = 100, playerStamina = 100;
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 = 0;
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;
} 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';
score = 0; round = 1; combo = 0; playerHP = 100; playerStamina = 100; camHurtPitch = 0;
enemy.maxHP = 100; enemy.hp = 100;
playerPos = { x: 0, z: 0 }; targetPlayerPos = { x: 0, z: 0 }; enemyPos = { x: 0, z: 600 };
updateUI(); gameRunning = 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();
}
requestAnimationFrame(gameLoop);
}
// ─── カメラ・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 < 15) return; playerStamina -= 15;
playerBlocking = false; playerAction = 'windupL'; playSound('swing');
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupL') { playerAction = 'punchL'; tryPunch('left', 250); 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); setTimeout(() => { if(playerAction==='punchR') playerAction = 'idle'; }, 300); }
}, 120);
}
else if (!mouseL && !mouseR) {
playerBlocking = false;
if (playerAction === 'guard' || playerAction === 'windupL' || playerAction === 'windupR') {
clearTimeout(playerActionTimer); playerAction = 'idle';
}
}
}
function detectMovement() {
let isMoving = false;
if (controlMode === 'mouse') {
let movedByKey = false; const speed = 25; // ★移動速度アップ
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;
}
// ─── 当たり判定と戦闘ロジック ─────────────────────────
function evaluateHit(attackerPos, defenderPos, isDefenderBlocking, isHeadshotAim) {
const dx = Math.abs(attackerPos.x - defenderPos.x);
const dz = Math.abs(attackerPos.z - defenderPos.z);
if (dx > 200) return { hit: false, reason: 'MISS (DODGED)' };
if (dz > 450) return { hit: false, reason: 'MISS (TOO FAR)' };
if (isDefenderBlocking) return { hit: false, reason: 'GUARD' };
let isClean = (dz < 250 && dx < 100);
if (isHeadshotAim) return { hit: true, type: isClean ? 'HEADSHOT! CLEAN HIT' : 'HEADSHOT', multi: isClean ? 2.0 : 1.5 };
return { hit: true, type: isClean ? 'CLEAN HIT' : 'HIT', multi: isClean ? 1.5 : 1.0 };
}
function tryPunch(side, speed) {
let isHead = aimY < -0.2;
if(side === 'left') aiMemory.leftStrikes++; else aiMemory.rightStrikes++;
saveMemory();
if (gameMode === 'multi') {
if (conn && conn.open) conn.send({ type: 'punch', side: side, speed: speed, isHeadshot: isHead });
} else {
// ★パンチ同士の相殺(パリィ)判定
const isEnemyAttacking = enemy.state.includes('punch');
if (isEnemyAttacking) {
// 距離が届く範囲でお互いパンチを出していたら相殺
const dz = Math.abs(playerPos.z - enemyPos.z);
if (dz < 450) {
playSound('parry');
showHitNumber('PARRY!', '#00f3ff', 1.5);
screenShake = 20; hitStop = 10;
enemy.state = 'recoil'; enemy.stateTimer = 25; // 敵も弾かれる
playerAction = 'recoil'; clearTimeout(playerActionTimer); // 自分も弾かれる
setTimeout(() => { if(playerAction==='recoil') playerAction = 'idle'; }, 400);
return;
}
}
const isEnemyBlocking = enemy.state === 'blocking';
const result = evaluateHit(playerPos, enemyPos, isEnemyBlocking, isHead);
if (!result.hit) {
if (result.reason === 'GUARD') playSound('guard');
showHitNumber(result.reason, '#aaa');
combo = 0; updateUI();
} else {
const dmg = Math.floor((10 + speed * 0.05) * result.multi);
enemy.hp = Math.max(0, enemy.hp - dmg);
hitEnemyVisual(dmg, side === 'right' ? 50 : -50, isHead ? -150 : -50, result.type.includes('CLEAN HIT'));
if (enemy.hp <= 0) setTimeout(nextRound, 1000);
}
}
}
function hitEnemyVisual(dmg, ox, oy, isCleanHit) {
playSound(isCleanHit ? 'clean' : 'hit');
combo++; score += dmg * combo * (isCleanHit ? 2 : 1);
enemy.state = 'hurt'; enemy.stateTimer = 25; enemy.hurtShake = isCleanHit ? 70 : 35;
hitStop = isCleanHit ? 12 : 6; screenShake = isCleanHit ? 35 : 18; flashTimer = isCleanHit ? 6 : 3;
const color = isCleanHit ? '#ffd700' : '#ff0055';
// ── パーティクルを種類分け ──────────────────────────
// 通常ヒット: 汗の飛沫(白/青白)+ 衝撃火花
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);
// HPが低い場合は赤い血しぶきも追加
if (enemy.hp / enemy.maxHP < 0.4) {
spawnParticlesDirected(W()/2 + ox + 20, H()/2 + oy, '#ff0000', 12, -0.8, 0.6);
}
}
showHitNumber(isCleanHit ? `CLEAN HIT! ${dmg}` : `${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();
}
// 方向付きパーティクル: 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) {
playSound('damage');
playerHP = Math.max(0, playerHP - dmg);
combo = 0; updateUI();
// ── プレイヤー被弾: 激しいリアクション ──────────────────────
screenShake = isCleanHit ? 90 : 45;
playerHurtShake = isCleanHit ? 110 : 55;
camHurtPitch = isCleanHit ? 1100 : 650;
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);
// ── クロマティックアバレーション風の残像 (canvas) ─────────
hitChromatic = isCleanHit ? 12 : 6;
if (playerHP <= 0) {
if (gameMode === 'multi' && conn && conn.open) conn.send({ type: 'gameover' });
showResult(false);
}
}
// ─── NPC AI ──────────────────────────────────────────
function updateEnemyAI() {
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, false);
if (!result.hit) {
if (result.reason === 'GUARD') playSound('guard');
showHitNumber(result.reason, '#aaa');
} else {
takeDamage(Math.floor((10 + round * 2) * result.multi), result.type.includes('CLEAN HIT'));
}
}
if (enemy.stateTimer <= 0) enemy.state = 'idle';
}
if (enemy.hurtShake > 0) enemy.hurtShake *= 0.8;
let totalStrikes = aiMemory.leftStrikes + aiMemory.rightStrikes || 1;
let leftRatio = aiMemory.leftStrikes / totalStrikes;
let dodgeOffset = (leftRatio - 0.5) * 300;
const targetDist = 450;
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 - 50;
} else if (enemy.state.includes('punch')) {
enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + 150;
}
// ★敵の追従速度(プレイヤーの左右移動への対応)を機敏に
enemyPos.x += (enemy.targetX - enemyPos.x) * 0.15;
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 = 45;
playSound('swing');
}
}
function nextRound() {
if(!gameRunning) return; round++;
enemy.maxHP = 100 + round * 30; enemy.hp = enemy.maxHP;
enemy.attackInterval = Math.max(60, 120 - round * 10);
playerHP = Math.min(100, playerHP + 30); updateUI();
}
function showResult(isWin) {
gameRunning = false;
const msg = document.getElementById('msg-overlay');
msg.textContent = isWin ? 'YOU WIN!' : 'K.O.'; msg.style.color = isWin ? '#00ff88' : '#ff0055'; msg.style.opacity = '1';
setTimeout(() => location.reload(), 4000);
}
// ─── 描画とエフェクト更新 ───────────────────────────────────────
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;
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 + '%';
}
// ─── 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';
// ── のけぞり・揺れ計算 ──────────────────────────────────────
const hurtProgress = isHurt ? (1.0 - enemy.stateTimer / 25) : 0;
// 被弾時: 上体が後ろへ倒れ、膝が少し曲がる
let leanZ = isHurt ? 280 * Math.sin(hurtProgress * Math.PI) : 0;
let torsoTiltZ = isHurt ? 180 * Math.sin(hurtProgress * Math.PI) : 0;
let headLeanZ = isHurt ? 220 * Math.sin(hurtProgress * Math.PI) : 0;
let kneeBend = isHurt ? 60 * Math.sin(hurtProgress * Math.PI) : 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;
// ── ベース座標: ボクシングスタンス ──────────────────────────
// ベースY: ボブ(上下の揺れ)
const bobY = Math.sin(npcPhase) * 12;
// アタック時に一歩踏み込む
const stepZ = isAttacking ? -80 : 0;
const ex = enemyPos.x + sx;
const ey = -300 + bobY + sy + (isHurt ? 30 * Math.sin(hurtProgress * Math.PI) : 0);
const ez = enemyPos.z + leanZ + sz + stepZ;
// ── 各部位のY座標 ─────────────────────────────────────────
const neckY = ey - 20; // 首
const chestY = ey + 80; // 胸
const waistY = ey + 175; // 腰
const hipY = ey + 220; // 骨盤
const kneeY = hipY + 200 + kneeBend;
const footY = kneeY + 190;
const footStep = Math.sin(npcPhase * 0.5) * 40;
// ── ジョイント定義 ──────────────────────────────────────────
// 頭: 被弾時は後方に傾く
const headZ = ez + headLeanZ;
const headY = ey - 10; // 頭は肩より上
// 肩: ボクサーは左肩を前に出す (オーソドックス)
const lsZ = ez + 20; // 左肩はやや前
const rsZ = ez - 20; // 右肩はやや後ろ
let joints = {
head: { x: ex, y: headY, z: headZ },
neck: { x: ex, y: neckY, z: ez + torsoTiltZ * 0.3 },
chest: { x: ex, y: chestY, z: ez + torsoTiltZ * 0.15 },
waist: { x: ex, y: waistY, z: ez },
hip: { x: ex, y: hipY, z: ez },
ls: { x: ex - 110, y: chestY - 20, z: lsZ },
rs: { x: ex + 110, y: chestY - 20, z: rsZ },
lhip: { x: ex - 65, y: hipY, z: ez },
rhip: { x: ex + 65, y: hipY, z: ez },
lknee: { x: ex - 75, y: kneeY, z: ez - footStep },
rknee: { x: ex + 75, y: kneeY, z: ez + footStep },
lfoot: { x: ex - 80, y: footY, z: ez - footStep - 15 },
rfoot: { x: ex + 80, y: footY, z: ez + footStep - 15 },
};
// ── 腕のポーズ: 状態ごと ───────────────────────────────────
if (isRecoil) {
// 相殺弾かれ: 腕がバンザイ状態
joints.le = { x: ex - 160, y: chestY - 60, z: ez };
joints.lw = { x: ex - 270, y: chestY - 130, z: ez - 60 };
joints.re = { x: ex + 160, y: chestY - 60, z: ez };
joints.rw = { x: ex + 270, y: chestY - 130, z: ez - 60 };
} else if (isBlocking) {
// ブロック: 両腕を顔前でクロス
joints.le = { x: ex - 80, y: chestY - 80, z: ez - 120 };
joints.lw = { x: ex - 40, y: headY + 30, z: ez - 220 };
joints.re = { x: ex + 80, y: chestY - 80, z: ez - 120 };
joints.rw = { x: ex + 40, y: headY + 30, z: ez - 220 };
} else if (isWindup) {
// 振りかぶり: 打つ側の肘を後ろへ引く
if (isLeftPunch) {
joints.le = { x: ex - 130, y: chestY - 30, z: ez + 190 }; // 左肘を後方へ
joints.lw = { x: ex - 90, y: chestY - 70, z: ez + 90 }; // 左拳は引いた位置
joints.re = { x: ex + 130, y: chestY - 50, z: ez - 30 };
joints.rw = { x: ex + 85, y: headY + 40, z: ez - 180 }; // 右は警護
} else {
joints.le = { x: ex - 130, y: chestY - 50, z: ez - 30 };
joints.lw = { x: ex - 85, y: headY + 40, z: ez - 180 }; // 左は警護
joints.re = { x: ex + 130, y: chestY - 30, z: ez + 190 }; // 右肘を後方へ
joints.rw = { x: ex + 90, y: chestY - 70, z: ez + 90 };
}
} else if (isAttacking) {
// パンチ繰り出し
const punchReach = playerBlocking ? ez - 230 : ez - 560;
const punchOffX = isLeftPunch ? -55 : 55;
if (isLeftPunch) {
joints.le = { x: ex - 100, y: chestY - 20, z: ez - 80 };
joints.lw = { x: ex + punchOffX, y: headY + 20, z: punchReach };
joints.re = { x: ex + 130, y: chestY - 50, z: ez - 30 };
joints.rw = { x: ex + 85, y: headY + 40, z: ez - 180 };
} else {
joints.le = { x: ex - 130, y: chestY - 50, z: ez - 30 };
joints.lw = { x: ex - 85, y: headY + 40, z: ez - 180 };
joints.re = { x: ex + 100, y: chestY - 20, z: ez - 80 };
joints.rw = { x: ex + punchOffX, y: headY + 20, z: punchReach };
}
} else if (isHurt) {
// 被弾のけぞり: 腕が下がる・開く
const openAmt = 80 * Math.sin(hurtProgress * Math.PI);
joints.le = { x: ex - 130, y: chestY + 20, z: ez };
joints.lw = { x: ex - 200 - openAmt, y: chestY + 60 + openAmt * 0.5, z: ez - 80 };
joints.re = { x: ex + 130, y: chestY + 20, z: ez };
joints.rw = { x: ex + 200 + openAmt, y: chestY + 60 + openAmt * 0.5, z: ez - 80 };
} else {
// アイドル: 左ガード高め、右ガード低め (オーソドックス)
const lwavL = Math.sin(npcPhase) * 20;
const lwavR = Math.cos(npcPhase) * 20;
joints.le = { x: ex - 120, y: chestY - 60 + lwavL, z: ez - 40 };
joints.lw = { x: ex - 80, y: headY + 50 + lwavL, z: ez - 180 }; // 左拳は顔ガード
joints.re = { x: ex + 130, y: chestY - 40 + lwavR, z: ez - 30 };
joints.rw = { x: ex + 80, y: chestY - 10 + lwavR, z: ez - 160 }; // 右拳はやや低め
}
// ── 描画 ─────────────────────────────────────────────────────
const mSkin = currentSkinMat;
const mGlove = materials.glove;
const mPants = materials.pants;
const mShoe = materials.shoe;
// 頭部: 球 + 頸部
updateMesh('e_head', 'joint', joints.head, null, 68, mSkin);
updateMesh('e_neck', 'bone', joints.neck, { x: joints.neck.x, y: joints.neck.y - 30, z: joints.neck.z }, 32, mSkin);
// 胴体: 肩→胸(やや太め)→腰→骨盤 と分けてテーパーを表現
updateMesh('e_shoulder', 'bone', joints.ls, joints.rs, 55, 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, 68, mSkin); // 胸→腰 (広め)
updateMesh('e_abdomen', 'bone', joints.waist, joints.hip, 55, 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;
playerStamina = Math.min(100, playerStamina + 0.3);
document.getElementById('player-stamina-fill').style.width = playerStamina + '%';
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.8;
// 被弾時はカメラ自体もノイズで揺らす
let shakeX = (Math.random()-0.5) * playerHurtShake * 0.5;
let shakeY = (Math.random()-0.5) * playerHurtShake * 0.5;
camera.position.set(playerPos.x + viewOffsetX + shakeX, 250 - viewOffsetY + camBobY + shakeY, -playerPos.z + 100 + camPunchZ);
// ★見つめるターゲットの高さを一瞬引き上げる(天井を向くのけぞり表現)
camera.lookAt(playerPos.x + viewOffsetX * 1.5, 250 - 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);
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--;
}
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.5)'; ctx.font = 'bold 40px Arial'; ctx.textAlign = 'center'; ctx.fillText('GUARD', W()/2, H() - 200);
}
ctx.restore();
requestAnimationFrame(gameLoop);
}
</script>
</body>
</html>

コメント