カメラで対戦する「MotionBoxing」
【更新履歴】
・2026/3/12 バージョン1.0公開。
・2026/3/12 バージョン1.1公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Motion Boxing 1.1</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: 15px;
}
.health-bar-wrapper { width: 100%; }
.health-label { font-size: 0.9rem; color: #ddd; margin-bottom: 5px; font-weight: bold; text-shadow: 1px 1px 2px black; }
.health-bar-bg {
background: rgba(30,30,30,0.8);
border-radius: 4px;
height: 24px;
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; }
#canvas-container {
position: relative;
width: 100vw;
height: 100vh;
}
#video { display: none; }
#gameCanvas {
width: 100vw;
height: 100vh;
display: block;
cursor: crosshair;
}
#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; }
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;
}
#hit-effect {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 15;
opacity: 0;
background: radial-gradient(circle, transparent 30%, rgba(255,0,85,0.6) 100%);
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>
<div id="canvas-container">
<video id="video" autoplay playsinline></video>
<canvas id="gameCanvas"></canvas>
<div id="hit-effect"></div>
<div id="combo-display"></div>
<div id="msg-overlay"></div>
</div>
<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>
バックステップで空振りさせよう!</p>
<button class="btn" onclick="showMenu('menu-single')">👤 シングルプレイ (対NPC)</button>
<button class="btn btn-red" onclick="showMenu('menu-multi')">🌐 P2P通信対戦</button>
</div>
<div class="menu-panel" id="menu-single" style="display:none;">
<h3>操作方法</h3>
<p>【マウス】左右クリックでパンチ。<span class="key-hint">同時押しでガード</span><br>
【キーボード】<span class="key-hint">W/A/S/D または 方向キー</span>でリング内を歩く</p>
<button class="btn" onclick="initGame('single', 'camera')">📷 カメラでプレイ (全身)</button>
<button class="btn" onclick="initGame('single', 'mouse')">🖱️ マウス&キーボードでプレイ</button>
<button class="btn" style="border-color:#555;color:#aaa;" onclick="showMenu('menu-main')">戻る</button>
</div>
<div class="menu-panel" id="menu-multi" style="display:none;">
<h3>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>
<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;
function initAudio() {
if (!audioCtx) audioCtx = new AudioContext();
if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playTone(freq, type, duration, vol) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(vol, 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 === 'swing') { playTone(150, 'sine', 0.2, 0.4); }
if (type === 'damage') { playTone(100, 'sawtooth', 0.4, 0.8); }
}
const video = document.getElementById('video');
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function resize() { canvas.width = window.innerWidth; canvas.height = 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 playerHP = 100;
// 座標系 (Zプラスが画面奥)
let playerPos = { x: 0, z: 0 };
let enemyPos = { x: 0, z: 500 };
let keys = { w:false, a:false, s:false, d:false, ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false };
// エフェクト・アニメーション
let screenShake = 0;
let hitStop = 0;
let flashTimer = 0;
let particles = [];
let hitNumbers = [];
let walkBob = 0; // 歩行時の揺れ
// プレイヤー腕・視点の制御
let viewOffsetX = 0, viewOffsetY = 0;
let targetViewOffsetX = 0, targetViewOffsetY = 0;
let playerHurtShake = 0;
let mouseL = false, mouseR = false;
let playerAction = 'idle'; // idle, windupL, windupR, punchL, punchR, guard
let playerActionTimer = null;
let playerBlocking = false;
// 滑らかな腕の動きのための変数
let armL = { x: -160, y: 150, z: 0 };
let armR = { x: 160, y: 150, z: 0 };
let targetArmL = { x: -160, y: 150, z: 0 };
let targetArmR = { x: 160, y: 150, z: 0 };
// 敵状態
let enemy = {
hp: 100, maxHP: 100,
state: 'idle', stateTimer: 0,
attackTimer: 0, attackInterval: 80,
targetX: 0, targetZ: 500,
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; });
// ─── P2P通信 (PeerJS) ───────────────────────────────────────
let peer = null; let conn = null;
function initPeer() {
try {
peer = new Peer();
peer.on('open', id => { document.getElementById('my-peer-id').textContent = id; });
peer.on('connection', connection => {
conn = connection; setupP2PConnection(conn); initP2PGame(controlMode || 'mouse');
});
} catch(e) { console.error('Peer error', e); }
}
initPeer();
function showMenu(id) {
document.querySelectorAll('.menu-panel').forEach(el => el.style.display = 'none');
document.getElementById(id).style.display = 'block';
initAudio(); // ユーザー操作をフックにしてオーディオ解除
}
function connectToPeer(ctrl) {
const targetId = document.getElementById('target-id-input').value;
if(!targetId) return alert('IDを入力してください');
initAudio();
controlMode = ctrl;
conn = peer.connect(targetId);
setupP2PConnection(conn);
initP2PGame(ctrl);
}
function setupP2PConnection(c) {
c.on('data', data => {
if (!gameRunning) return;
if (data.type === 'sync') {
enemy.hp = data.hp; enemy.state = data.state; enemy.poseData = data.pose;
if (data.pos) { enemyPos.x = -data.pos.x; enemyPos.z = 500 - data.pos.z; }
}
else if (data.type === 'punch') {
const result = evaluateHit(enemyPos, playerPos, playerBlocking);
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 * 0.05) * result.multi);
takeDamage(dmg, result.type === 'CLEAN HIT');
c.send({ type: 'hit_confirm', hit: true, dmg: dmg, hitType: result.type });
}
}
else if (data.type === 'hit_confirm') {
if (data.hit) hitEnemyVisual(data.dmg, 0, 0, data.hitType === 'CLEAN HIT');
else { showHitNumber(data.reason, '#aaa'); combo = 0; updateUI(); }
}
else if (data.type === 'gameover') showResult(true);
});
}
function sendP2PData() {
if (conn && conn.open) {
conn.send({
type: 'sync', hp: playerHP,
state: playerAction,
pose: null, pos: { x: playerPos.x, z: playerPos.z }
});
}
}
// ─── ゲーム初期化 ─────────────────────────────────────────────
function initGame(mode, ctrl) { initAudio(); gameMode = mode; controlMode = ctrl; startGameFlow(); }
function initP2PGame(ctrl) { gameMode = 'multi'; controlMode = ctrl; startGameFlow(); }
function startGameFlow() {
document.getElementById('overlay').style.display = 'none';
document.getElementById('round-box').style.display = gameMode === 'multi' ? 'none' : 'block';
document.getElementById('enemy-label').innerText = gameMode === 'multi' ? 'OPPONENT HP' : 'ENEMY HP';
score = 0; round = 1; combo = 0; playerHP = 100; enemy.maxHP = 100; enemy.hp = 100;
playerPos = { x: 0, z: 0 }; enemyPos = { x: 0, z: 500 };
updateUI(); gameRunning = true;
if (controlMode === 'mouse') {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('contextmenu', e => e.preventDefault());
}
requestAnimationFrame(gameLoop);
}
// ─── 入力とアクション制御 ─────────────────────────────────────
function onMouseMove(e) {
if (!gameRunning || controlMode !== 'mouse') return;
targetViewOffsetX = -(e.clientX - W()/2) * 0.6;
targetViewOffsetY = -(e.clientY - H()/2) * 0.6;
}
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 (mouseL && mouseR) {
// 両方押しでガード(振りかぶりキャンセル)
clearTimeout(playerActionTimer);
playerAction = 'guard';
playerBlocking = true;
playSound('swing');
}
else if (mouseL && !mouseR && playerAction === 'idle') {
// 左クリックで振りかぶり開始
playerBlocking = false;
playerAction = 'windupL';
playSound('swing');
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupL') {
playerAction = 'punchL';
tryPunch('left', 250);
setTimeout(() => { if (playerAction === 'punchL') playerAction = 'idle'; }, 400);
}
}, 250); // 250msの予備動作
}
else if (mouseR && !mouseL && playerAction === 'idle') {
// 右クリックで振りかぶり開始
playerBlocking = false;
playerAction = 'windupR';
playSound('swing');
playerActionTimer = setTimeout(() => {
if (playerAction === 'windupR') {
playerAction = 'punchR';
tryPunch('right', 250);
setTimeout(() => { if (playerAction === 'punchR') playerAction = 'idle'; }, 400);
}
}, 250);
}
else if (!mouseL && !mouseR) {
// 離したらアイドルに戻す
playerBlocking = false;
if (playerAction === 'guard' || playerAction === 'windupL' || playerAction === 'windupR') {
clearTimeout(playerActionTimer);
playerAction = 'idle';
}
}
}
function detectMovement() {
const speed = 18;
let isMoving = false;
// WASDで実際に体(座標)を動かす
if (keys.w || keys.ArrowUp) { playerPos.z += speed; isMoving = true; }
if (keys.s || keys.ArrowDown) { playerPos.z -= speed; isMoving = true; }
if (keys.a || keys.ArrowLeft) { playerPos.x -= speed; isMoving = true; }
if (keys.d || keys.ArrowRight) { playerPos.x += speed; isMoving = true; }
if (isMoving) walkBob += 0.2;
else walkBob = 0;
// リング制限
playerPos.x = Math.max(-800, Math.min(800, playerPos.x));
playerPos.z = Math.max(-200, Math.min(1000, playerPos.z));
// 腕の目標位置の設定
if (playerAction === 'idle') {
targetArmL = { x: -180, y: 150 + Math.sin(walkBob)*20, z: 0 };
targetArmR = { x: 180, y: 150 + Math.sin(walkBob + Math.PI)*20, z: 0 };
} else if (playerAction === 'guard') {
targetArmL = { x: -70, y: 80, z: 150 };
targetArmR = { x: 70, y: 80, z: 150 };
} else if (playerAction === 'windupL') {
targetArmL = { x: -250, y: 250, z: -200 }; // 後ろに引く
targetArmR = { x: 180, y: 150, z: 0 };
} else if (playerAction === 'windupR') {
targetArmL = { x: -180, y: 150, z: 0 };
targetArmR = { x: 250, y: 250, z: -200 };
} else if (playerAction === 'punchL') {
targetArmL = { x: -50, y: 80, z: 1200 }; // 前に突き出す
targetArmR = { x: 180, y: 150, z: 0 };
} else if (playerAction === 'punchR') {
targetArmL = { x: -180, y: 150, z: 0 };
targetArmR = { x: 50, y: 80, z: 1200 };
}
// なめらかに追従
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) {
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' };
if (dz < 250 && dx < 100) return { hit: true, type: 'CLEAN HIT', multi: 1.5 };
return { hit: true, type: 'HIT', multi: 1.0 };
}
function tryPunch(side, speed) {
if (gameMode === 'multi') {
if (conn && conn.open) conn.send({ type: 'punch', side: side, speed: speed });
} else {
const isEnemyBlocking = enemy.state === 'blocking';
const result = evaluateHit(playerPos, enemyPos, isEnemyBlocking);
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, -50, result.type === '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 ? 60 : 30; // 敵の揺れ幅
hitStop = isCleanHit ? 10 : 5;
screenShake = isCleanHit ? 30 : 15;
flashTimer = isCleanHit ? 5 : 2;
const color = isCleanHit ? '#ffd700' : '#ff0055';
spawnParticles(W()/2 + ox, H()/2 + oy, color, isCleanHit ? 40 : 20);
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);
}
updateUI();
}
function takeDamage(dmg, isCleanHit) {
playSound('damage');
playerHP = Math.max(0, playerHP - dmg);
combo = 0; updateUI();
// プレイヤー側のダメージリアクション
screenShake = isCleanHit ? 60 : 30;
playerHurtShake = isCleanHit ? 80 : 40;
flashTimer = 5;
document.getElementById('hit-effect').style.opacity = '1';
document.getElementById('hit-effect').style.background = isCleanHit ? 'radial-gradient(circle, transparent 30%, rgba(255,200,0,0.8) 100%)' : 'radial-gradient(circle, transparent 30%, rgba(255,0,85,0.6) 100%)';
setTimeout(() => document.getElementById('hit-effect').style.opacity = '0', 150);
if (isCleanHit) showHitNumber("CRITICAL DANGER!", "#ff0000", 1.5);
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.stateTimer <= 0) enemy.state = 'idle';
}
if (enemy.hurtShake > 0) enemy.hurtShake *= 0.8;
const targetDist = 400; // 前より離れる
if (enemy.state === 'idle') {
enemy.targetX = playerPos.x + Math.sin(Date.now() * 0.001) * 200;
enemy.targetZ = playerPos.z + targetDist;
} else if (enemy.state === 'windup') {
enemy.targetX = playerPos.x;
enemy.targetZ = playerPos.z + targetDist - 50; // ちょっと踏み込む
} else if (enemy.state === 'attacking') {
enemy.targetX = playerPos.x;
enemy.targetZ = playerPos.z + 150; // 大きく踏み込む
}
enemyPos.x += (enemy.targetX - enemyPos.x) * 0.06;
enemyPos.z += (enemy.targetZ - enemyPos.z) * 0.06;
enemy.attackTimer++;
if (enemy.attackTimer >= enemy.attackInterval && enemy.state === 'idle') {
enemy.attackTimer = 0;
enemy.state = 'windup';
enemy.stateTimer = 25; // 敵の予備動作時間
playSound('swing');
setTimeout(() => {
if (!gameRunning || enemy.state !== 'windup') return;
enemy.state = 'attacking';
enemy.stateTimer = 20; // パンチ伸ばす時間
const result = evaluateHit(enemyPos, playerPos, playerBlocking);
if (!result.hit) {
if (result.reason === 'GUARD') playSound('guard');
showHitNumber(result.reason, '#aaa');
} else {
takeDamage(Math.floor((10 + round * 2) * result.multi), result.type === 'CLEAN HIT');
}
}, 400); // 約400ms後に攻撃発生
}
}
function nextRound() {
if(!gameRunning) return;
round++;
enemy.maxHP = 100 + round * 30; enemy.hp = enemy.maxHP;
enemy.attackInterval = Math.max(40, 90 - 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);
}
// ─── エフェクト・UI更新 ───────────────────────────────────────
function spawnParticles(x, y, color, count) {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const 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 + '%';
}
// ─── 3D 描画ロジック ──────────────────────────────────────────
const FOCAL = 700;
let renderQueue = [];
function proj(worldX, worldY, worldZ) {
const relX = worldX - playerPos.x;
const relZ = worldZ - playerPos.z;
const pz = relZ + FOCAL;
if (pz < 10) return { x: 0, y: 0, s: 0.001, z: relZ, visible: false };
const scale = FOCAL / pz;
return {
x: W()/2 + relX * scale + viewOffsetX,
y: H()/2 + worldY * scale + viewOffsetY,
s: scale, z: relZ, visible: true
};
}
function queueBone(p1, p2, w1, w2, color) {
const z = (p1.z + p2.z) / 2;
const pp1 = proj(p1.x, p1.y, p1.z);
const pp2 = proj(p2.x, p2.y, p2.z);
if (!pp1.visible || !pp2.visible) return;
const dx = pp2.x - pp1.x, dy = pp2.y - pp1.y;
const len = Math.hypot(dx, dy);
if (len < 0.1) return;
const nx = -dy/len, ny = dx/len;
const rw1 = w1 * pp1.s, rw2 = w2 * pp2.s;
renderQueue.push({
type: 'poly', z: z, color: color,
pts: [
{x: pp1.x + nx*rw1, y: pp1.y + ny*rw1},
{x: pp1.x - nx*rw1, y: pp1.y - ny*rw1},
{x: pp2.x - nx*rw2, y: pp2.y - ny*rw2},
{x: pp2.x + nx*rw2, y: pp2.y + ny*rw2}
]
});
renderQueue.push({ type: 'circle', z: p1.z, x: pp1.x, y: pp1.y, r: rw1, color: color });
renderQueue.push({ type: 'circle', z: p2.z, x: pp2.x, y: pp2.y, r: rw2, color: color });
}
function queueHead(p, radius, color) {
const pp = proj(p.x, p.y, p.z);
if(!pp.visible) return;
renderQueue.push({ type: 'circle', z: p.z, x: pp.x, y: pp.y, r: radius * pp.s, color: color });
}
function flushRenderQueue() {
renderQueue.sort((a, b) => b.z - a.z); // 奥から手前へ描画
for (let item of renderQueue) {
if (item.type === 'poly') {
ctx.fillStyle = item.color;
ctx.beginPath();
ctx.moveTo(item.pts[0].x, item.pts[0].y);
for(let i=1; i<4; i++) ctx.lineTo(item.pts[i].x, item.pts[i].y);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1; ctx.stroke();
} else if (item.type === 'circle') {
const grad = ctx.createRadialGradient(
item.x - item.r*0.3, item.y - item.r*0.3, item.r*0.1,
item.x, item.y, item.r
);
grad.addColorStop(0, '#fff');
grad.addColorStop(0.3, item.color);
grad.addColorStop(1, '#000');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(item.x, item.y, item.r, 0, Math.PI*2); ctx.fill();
}
}
renderQueue = [];
}
// ─── 大きくしたポリゴン構築 ────────────────────────────────────────
function buildEnemyPoly() {
npcPhase += 0.08;
const isWindup = enemy.state === 'windup';
const isAttacking = enemy.state === 'attacking';
const isHurt = enemy.state === 'hurt';
// ダメージ時ののけぞり計算
let leanZ = isHurt ? 250 : 0;
let leanY = isHurt ? -200 : 0;
const hz = enemyPos.z + leanZ;
const hy = isHurt ? -350 + leanY : -300 + Math.sin(npcPhase)*15;
// 敵の全体的な揺れ(ヒット時)
let ex = enemyPos.x + (Math.random()-0.5) * enemy.hurtShake;
let ey = hy + (Math.random()-0.5) * enemy.hurtShake;
let ez = hz + (Math.random()-0.5) * enemy.hurtShake;
// サイズを約1.5倍〜2倍に拡大
let joints = {
head: {x: ex, y: ey, z: ez},
body: {x: ex, y: 100, z: hz},
ls: {x: ex - 140, y: ey + 120, z: ez},
rs: {x: ex + 140, y: ey + 120, z: ez},
};
if (isWindup) {
// 振りかぶり(右腕を引く)
joints.lw = {x: ex - 100, y: 0, z: hz - 100};
joints.le = {x: ex - 150, y: 50, z: hz - 50};
joints.rw = {x: ex + 150, y: 50, z: hz + 300}; // 後ろに引く
joints.re = {x: ex + 200, y: 100, z: hz + 150};
} else if (isAttacking) {
// 攻撃(右腕を前に出す)
joints.lw = {x: ex - 100, y: 0, z: hz - 100};
joints.le = {x: ex - 150, y: 50, z: hz - 50};
joints.rw = {x: ex + 50, y: -50, z: hz - 600}; // 手前に伸びる
joints.re = {x: ex + 100, y: 0, z: hz - 300};
} else {
// 構え
joints.lw = {x: ex - 100, y: -40 + Math.sin(npcPhase)*30, z: hz - 150};
joints.le = {x: ex - 150, y: 60, z: hz - 50};
joints.rw = {x: ex + 100, y: -40 + Math.cos(npcPhase)*30, z: hz - 150};
joints.re = {x: ex + 150, y: 60, z: hz - 50};
}
const cBody = '#444', cSkin = '#e0ac69', cGlove = '#ff0055';
queueBone(joints.ls, joints.rs, 70, 70, cBody);
queueBone({x: (joints.ls.x+joints.rs.x)/2, y: joints.ls.y, z: joints.ls.z}, joints.body, 70, 50, cBody);
queueBone(joints.ls, joints.le, 45, 35, cSkin);
queueBone(joints.le, joints.lw, 35, 25, cSkin);
queueBone(joints.rs, joints.re, 45, 35, cSkin);
queueBone(joints.re, joints.rw, 35, 25, cSkin);
queueHead(joints.head, 85, cSkin);
queueHead(joints.lw, 60, cGlove);
queueHead(joints.rw, 60, cGlove);
}
function drawMyArms() {
if (playerHurtShake > 0) playerHurtShake *= 0.8; // 揺れの減衰
// ダメージ時のランダム揺れ
let sx = (Math.random()-0.5) * playerHurtShake;
let sy = (Math.random()-0.5) * playerHurtShake;
// プレイヤーの腕も大きくする
const lShoulder = {x: playerPos.x - 200 + sx, y: 250 + sy, z: playerPos.z};
const rShoulder = {x: playerPos.x + 200 + sx, y: 250 + 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};
queueBone(lShoulder, lGlove, 50, 40, '#00b359');
queueHead(lGlove, 70, '#00f3ff');
queueBone(rShoulder, rGlove, 50, 40, '#00b359');
queueHead(rGlove, 70, '#00f3ff');
}
// ─── メインループ ─────────────────────────────────────────────
function gameLoop() {
if (!gameRunning) return;
viewOffsetX += (targetViewOffsetX - viewOffsetX) * 0.15;
viewOffsetY += (targetViewOffsetY - viewOffsetY) * 0.15;
detectMovement();
if (gameMode !== 'multi') updateEnemyAI();
if (hitStop > 0) hitStop--;
ctx.fillStyle = '#050505';
ctx.fillRect(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;
}
// 床グリッド
ctx.strokeStyle = 'rgba(0, 243, 255, 0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
const step = 300; // 床マスも大きく
for (let i = -10; i <= 20; i++) {
const worldZ = Math.floor(playerPos.z/step)*step + i * step;
const p1 = proj(playerPos.x - 3000, 400, worldZ);
const p2 = proj(playerPos.x + 3000, 400, worldZ);
if(p1.visible && p2.visible) { ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); }
}
for (let i = -10; i <= 10; i++) {
const worldX = Math.floor(playerPos.x/step)*step + i * step;
const p1 = proj(worldX, 400, playerPos.z - 500);
const p2 = proj(worldX, 400, playerPos.z + 5000);
if(p1.visible || p2.visible) { ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); }
}
ctx.stroke();
if (flashTimer > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${flashTimer*0.2})`;
ctx.fillRect(-100, -100, W()+200, H()+200);
flashTimer--;
}
buildEnemyPoly();
drawMyArms();
flushRenderQueue();
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(); 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>

コメント