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

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
カメラで対戦する「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