カメラで対戦する「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>


《似た感じのゲーム》


「The Thrill of the Fight」


いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
カメラで対戦する「MotionBoxing」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1