カメラで対戦する「MotionBoxing」
【更新履歴】
・2026/3/12 バージョン1.0公開。
(中略)
・2026/3/12 バージョン1.2公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Motion Boxing 1.2</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>
バックステップで空振りさせよう!</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 === '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 });
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 }),
// thicknessプロパティを削除し、r128で動作するように修正
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;
let particles = [], hitNumbers = [];
let walkBob = 0, playerHurtShake = 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,
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();
document.getElementById('overlay').style.display = wasRunning ? 'none' : 'flex';
if(wasRunning) { gameRunning = true; showMenu('menu-main'); } else { showMenu('menu-main'); }
}
// ─── 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', c => { conn = c; setupP2PConnection(conn); initP2PGame(controlMode || 'mouse'); });
} catch(e) { console.error(e); }
}
initPeer();
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 = 600 - data.pos.z; }
}
else if (data.type === 'punch') {
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 * 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 (data.hit) hitEnemyVisual(data.dmg, 0, 0, data.hitType.includes('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() {
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;
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());
}
requestAnimationFrame(gameLoop);
}
// ─── 入力とアクション ─────────────────────────────────────────
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)) * 500;
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 (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 = 18;
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(-800, Math.min(800, 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 };
}
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 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 ? 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;
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 === '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 = 35;
playSound('swing');
setTimeout(() => {
if (!gameRunning || enemy.state !== 'windup') return;
enemy.state = 'attacking'; enemy.stateTimer = 20;
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'));
}
}, 600);
}
}
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 === 'windup', isAttacking = enemy.state === 'attacking', isHurt = enemy.state === 'hurt';
let leanZ = isHurt ? 250 : 0, leanY = isHurt ? -200 : 0;
const hz = enemyPos.z + leanZ, hy = isHurt ? -350 + leanY : -300 + Math.sin(npcPhase)*15;
let ex = enemyPos.x + (Math.random()-0.5) * enemy.hurtShake, ey = hy + (Math.random()-0.5) * enemy.hurtShake, ez = hz + (Math.random()-0.5) * enemy.hurtShake;
const hipY = ey + 200, kneeY = hipY + 180, footY = kneeY + 180, footStep = Math.sin(npcPhase * 0.5) * 50;
let joints = {
head: {x: ex, y: ey, z: ez}, body: {x: ex, y: ey + 100, z: hz}, hip: {x: ex, y: hipY, z: hz},
ls: {x: ex - 100, y: ey + 50, z: ez}, rs: {x: ex + 100, y: ey + 50, z: ez},
lhip: {x: ex - 60, y: hipY, z: ez}, rhip: {x: ex + 60, y: hipY, z: ez},
lknee:{x: ex - 80, y: kneeY, z: ez - footStep}, rknee:{x: ex + 80, y: kneeY, z: ez + footStep},
lfoot:{x: ex - 90, y: footY, z: ez - footStep - 20}, rfoot:{x: ex + 90, y: footY, z: ez + footStep - 20},
};
if (isWindup) {
joints.lw = {x: ex - 80, y: ey + 50, z: hz - 50}; joints.le = {x: ex - 120, y: ey + 100, z: hz - 20};
joints.rw = {x: ex + 150, y: ey + 20, z: hz + 300}; joints.re = {x: ex + 200, y: ey + 80, z: hz + 150};
} else if (isAttacking) {
joints.lw = {x: ex - 80, y: ey + 50, z: hz - 50}; joints.le = {x: ex - 120, y: ey + 100, z: hz - 20};
joints.rw = {x: ex + 50, y: ey + 20, z: hz - 600}; joints.re = {x: ex + 100, y: ey + 50, z: hz - 300};
} else {
joints.lw = {x: ex - 70, y: ey + 20 + Math.sin(npcPhase)*30, z: hz - 200}; joints.le = {x: ex - 120, y: ey + 100, z: hz - 50};
joints.rw = {x: ex + 70, y: ey + 20 + Math.cos(npcPhase)*30, z: hz - 200}; joints.re = {x: ex + 120, y: ey + 100, z: hz - 50};
}
const matSkin = currentSkinMat, matGlove = materials.glove, matPants = materials.pants, matShoe = materials.shoe;
updateMesh('e_shoulder', 'bone', joints.ls, joints.rs, 60, matSkin);
updateMesh('e_torso', 'bone', {x: (joints.ls.x+joints.rs.x)/2, y: joints.ls.y, z: joints.ls.z}, joints.hip, 60, matSkin);
updateMesh('e_uarm_l', 'bone', joints.ls, joints.le, 35, matSkin); updateMesh('e_elbow_l', 'joint', joints.le, null, 30, matSkin); updateMesh('e_larm_l', 'bone', joints.le, joints.lw, 30, matSkin);
updateMesh('e_uarm_r', 'bone', joints.rs, joints.re, 35, matSkin); updateMesh('e_elbow_r', 'joint', joints.re, null, 30, matSkin); updateMesh('e_larm_r', 'bone', joints.re, joints.rw, 30, matSkin);
updateMesh('e_head', 'joint', joints.head, null, 65, matSkin);
updateMesh('e_glove_l', 'joint', joints.lw, null, 55, matGlove); updateMesh('e_glove_r', 'joint', joints.rw, null, 55, matGlove);
updateMesh('e_pelvis', 'bone', joints.hip, {x: joints.hip.x, y: joints.hip.y+30, z: joints.hip.z}, 55, matPants);
updateMesh('e_uleg_l', 'bone', joints.lhip, joints.lknee, 40, matSkin); updateMesh('e_knee_l', 'joint', joints.lknee, null, 30, matSkin); updateMesh('e_lleg_l', 'bone', joints.lknee, joints.lfoot, 30, matSkin); updateMesh('e_foot_l', 'joint', joints.lfoot, null, 28, matShoe);
updateMesh('e_uleg_r', 'bone', joints.rhip, joints.rknee, 40, matSkin); updateMesh('e_knee_r', 'joint', joints.rknee, null, 30, matSkin); updateMesh('e_lleg_r', 'bone', joints.rknee, joints.rfoot, 30, matSkin); updateMesh('e_foot_r', 'joint', joints.rfoot, null, 28, matShoe);
}
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;
camera.position.set(playerPos.x + viewOffsetX, 250 - viewOffsetY + camBobY, -playerPos.z + 100 + camPunchZ);
camera.lookAt(playerPos.x + viewOffsetX * 1.5, 250 - viewOffsetY * 1.5, -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 (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(); 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>

コメント