カメラで対戦する「MotionBoxing」


画像


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>モーションボクシング</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      background: #0d0d0d;
      color: white;
      font-family: 'Arial', sans-serif;
      overflow: hidden;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    #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.6);
      border: 2px solid #e94560;
      border-radius: 10px;
      padding: 8px 20px;
      text-align: center;
    }
    .stat-box .label { font-size: 0.75rem; color: #aaa; }
    .stat-box .value { font-size: 1.6rem; font-weight: bold; color: #ffd700; }
    #health-bar-container {
      position: absolute;
      top: 90px;
      width: 80%;
      left: 10%;
      z-index: 10;
      pointer-events: none;
    }
    .health-label { font-size: 0.75rem; color: #aaa; margin-bottom: 3px; }
    .health-bar-bg {
      background: #333;
      border-radius: 10px;
      height: 18px;
      overflow: hidden;
      border: 1px solid #555;
    }
    .health-bar-fill {
      height: 100%;
      border-radius: 10px;
      transition: width 0.3s;
    }
    #enemy-health-fill { background: linear-gradient(90deg, #e94560, #ff6b6b); width: 100%; }
    #player-health-fill { background: linear-gradient(90deg, #2ecc71, #27ae60); width: 100%; }
    #canvas-container {
      position: relative;
      width: 100vw;
      height: 100vh;
    }
    #video { display: none; }
    #gameCanvas {
      width: 100vw;
      height: 100vh;
      display: block;
    }
    #overlay {
      position: absolute;
      top: 0; left: 0;
      width: 100%; height: 100%;
      background: rgba(0,0,0,0.75);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      z-index: 20;
    }
    #overlay h1 { font-size: 3rem; color: #ffd700; text-shadow: 0 0 20px #ffd700; }
    #overlay p { margin: 10px 0; color: #ccc; font-size: 1rem; text-align: center; max-width: 400px; }
    #overlay button {
      margin-top: 20px;
      padding: 14px 40px;
      font-size: 1.2rem;
      background: #e94560;
      color: white;
      border: none;
      border-radius: 10px;
      cursor: pointer;
    }
    #overlay button:hover { background: #c73652; }
    #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 50%, rgba(233,69,96,0.4) 100%);
      transition: opacity 0.1s;
    }
    #combo-display {
      position: absolute;
      bottom: 30px;
      left: 50%;
      transform: translateX(-50%);
      font-size: 2.5rem;
      font-weight: bold;
      color: #ffd700;
      text-shadow: 0 0 15px #ffd700;
      z-index: 10;
      pointer-events: none;
      opacity: 0;
      transition: opacity 0.3s;
    }
  </style>
</head>
<body>

<div id="ui">
  <div class="stat-box">
    <div class="label">スコア</div>
    <div class="value" id="score">0</div>
  </div>
  <div class="stat-box">
    <div class="label">ラウンド</div>
    <div class="value" id="round">1</div>
  </div>
  <div class="stat-box">
    <div class="label">コンボ</div>
    <div class="value" id="combo">x0</div>
  </div>
</div>

<div id="health-bar-container">
  <div class="health-label">敵 HP</div>
  <div class="health-bar-bg"><div class="health-bar-fill" id="enemy-health-fill"></div></div>
  <div style="height:8px"></div>
  <div class="health-label">自分 HP</div>
  <div class="health-bar-bg"><div class="health-bar-fill" id="player-health-fill"></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>

<div id="overlay" id="overlay">
  <h1>🥊 モーションボクシング</h1>
  <p>カメラに向かって拳を素早く前に突き出すとパンチになります。<br>
  右手 → 右パンチ 左手 → 左パンチ<br>
  腕を上げてブロック、体を横に動かしてかわそう!</p>
  <button onclick="startGame()">ゲームスタート</button>
</div>

<!-- MediaPipe & TF -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose/pose.js" crossorigin="anonymous"></script>

<script>
const video = document.getElementById('video');
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// ─── ゲーム状態 ───────────────────────────────────────────────
let gameRunning = false;
let score = 0;
let round = 1;
let combo = 0;
let comboTimer = null;
let playerHP = 100;
let enemyHP = 100;
let maxEnemyHP = 100;

// ─── ポーズデータ ─────────────────────────────────────────────
let landmarks = null;
let prevLandmarks = null;
let poseHistory = [];

// ─── パンチ検出 ───────────────────────────────────────────────
let leftPunchCooldown = false;
let rightPunchCooldown = false;
let leftPunchAnim = 0;
let rightPunchAnim = 0;

// ─── 敵キャラ ─────────────────────────────────────────────────
let enemy = {
  x: 0, y: 0,
  size: 120,
  hp: 100,
  maxHP: 100,
  state: 'idle',    // idle, attacking, hurt, blocking
  stateTimer: 0,
  attackTimer: 0,
  attackInterval: 90,
  dx: 0,
  flashTimer: 0,
  eyeAnim: 0,
};

// ─── エフェクト ───────────────────────────────────────────────
let particles = [];
let hitNumbers = [];
let screenShake = 0;

// ─── MediaPipe Pose ───────────────────────────────────────────
const pose = new Pose({
  locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`
});

pose.setOptions({
  modelComplexity: 1,
  smoothLandmarks: true,
  minDetectionConfidence: 0.6,
  minTrackingConfidence: 0.6
});

pose.onResults(onPoseResults);

let camera = null;

function startGame() {
  document.getElementById('overlay').style.display = 'none';
  gameRunning = true;

  enemy.x = canvas.width * 0.5;
  enemy.y = canvas.height * 0.42;

  camera = new Camera(video, {
    onFrame: async () => {
      if (gameRunning) await pose.send({ image: video });
    },
    width: 640,
    height: 480
  });
  camera.start();

  requestAnimationFrame(gameLoop);
}

// ─── ポーズ結果処理 ───────────────────────────────────────────
function onPoseResults(results) {
  if (!results.poseLandmarks) return;
  prevLandmarks = landmarks;
  landmarks = results.poseLandmarks;

  if (prevLandmarks) {
    detectPunch();
    detectBlock();
    detectDodge();
  }
}

const W = () => canvas.width;
const H = () => canvas.height;

// ランドマーク → キャンバス座標
function lm(idx) {
  if (!landmarks || !landmarks[idx]) return null;
  const l = landmarks[idx];
  return { x: (1 - l.x) * W(), y: l.y * H(), v: l.visibility };
}
function prevLm(idx) {
  if (!prevLandmarks || !prevLandmarks[idx]) return null;
  const l = prevLandmarks[idx];
  return { x: (1 - l.x) * W(), y: l.y * H(), v: l.visibility };
}

// ─── パンチ検出 ───────────────────────────────────────────────
let playerBlocking = false;
let playerDodge = 0; // -1 left, 0 center, 1 right

function detectPunch() {
  // 右手首(16), 右肩(12), 左手首(15), 左肩(11)
  const rWrist = lm(16), rShoulder = lm(12);
  const lWrist = lm(15), lShoulder = lm(11);
  const prWrist = prevLm(16), plWrist = prevLm(15);

  // 右パンチ:手首が肩よりかなり前に出て、速く動いた
  if (rWrist && rShoulder && prWrist && rWrist.v > 0.5) {
    const velX = Math.abs(rWrist.x - prWrist.x);
    const velY = Math.abs(rWrist.y - prWrist.y);
    const speed = Math.sqrt(velX*velX + velY*velY);
    // 手首がほぼ肩の高さより上、十分な速さ
    if (speed > W() * 0.04 && rWrist.y < rShoulder.y + H()*0.1 && !rightPunchCooldown) {
      triggerPunch('right', speed);
    }
  }

  // 左パンチ
  if (lWrist && lShoulder && plWrist && lWrist.v > 0.5) {
    const velX = Math.abs(lWrist.x - plWrist.x);
    const velY = Math.abs(lWrist.y - plWrist.y);
    const speed = Math.sqrt(velX*velX + velY*velY);
    if (speed > W() * 0.04 && lWrist.y < lShoulder.y + H()*0.1 && !leftPunchCooldown) {
      triggerPunch('left', speed);
    }
  }
}

function triggerPunch(side, speed) {
  if (!gameRunning) return;
  const isRight = side === 'right';

  // クールダウン
  if (isRight) { rightPunchCooldown = true; setTimeout(() => rightPunchCooldown = false, 600); }
  else          { leftPunchCooldown  = true; setTimeout(() => leftPunchCooldown  = false, 600); }

  // 敵がブロック中かチェック
  if (enemy.state === 'blocking') {
    spawnParticles(enemy.x, enemy.y, '#4a90d9', 8);
    showHitNumber(enemy.x, enemy.y - 60, 'BLOCK!', '#4a90d9');
    combo = 0;
    updateComboUI();
    return;
  }

  // ダメージ計算
  const base = isRight ? 12 : 10;
  const dmg = Math.min(30, Math.floor(base + (speed / W()) * 40));
  combo++;
  score += dmg * combo;
  enemy.hp = Math.max(0, enemy.hp - dmg);
  enemy.state = 'hurt';
  enemy.stateTimer = 20;
  enemy.flashTimer = 8;

  // アニメ
  if (isRight) { rightPunchAnim = 15; } else { leftPunchAnim = 15; }

  // パーティクル
  spawnParticles(enemy.x + (Math.random()-0.5)*enemy.size, enemy.y + (Math.random()-0.5)*enemy.size*0.5, '#ffd700', 12);
  showHitNumber(enemy.x, enemy.y - 80, `${dmg}`, combo > 2 ? '#ff4444' : '#ffd700');

  if (combo >= 3) {
    const cd = document.getElementById('combo-display');
    cd.textContent = `${combo} HIT COMBO!!`;
    cd.style.opacity = '1';
    if (comboTimer) clearTimeout(comboTimer);
    comboTimer = setTimeout(() => { cd.style.opacity = '0'; }, 1200);
  }

  screenShake = 8;
  updateComboUI();
  updateScoreUI();
  updateHealthUI();

  if (enemy.hp <= 0) nextRound();
}

// ─── ブロック検出 (両手を顔の前に上げる) ─────────────────────
function detectBlock() {
  const rWrist = lm(16), lWrist = lm(15);
  const nose = lm(0);
  if (!rWrist || !lWrist || !nose) return;
  const rUp = rWrist.y < nose.y + H()*0.05;
  const lUp = lWrist.y < nose.y + H()*0.05;
  playerBlocking = rUp && lUp;
}

// ─── かわし検出 (肩の傾き) ────────────────────────────────────
function detectDodge() {
  const rShoulder = lm(12), lShoulder = lm(11);
  if (!rShoulder || !lShoulder) return;
  const diff = rShoulder.y - lShoulder.y;
  if (diff > H() * 0.05) playerDodge = 1;
  else if (diff < -H() * 0.05) playerDodge = -1;
  else playerDodge = 0;
}

// ─── パーティクル ─────────────────────────────────────────────
function spawnParticles(x, y, color, count) {
  for (let i = 0; i < count; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = 3 + Math.random() * 6;
    particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - 3,
      alpha: 1,
      size: 4 + Math.random() * 6,
      color
    });
  }
}

function showHitNumber(x, y, text, color) {
  hitNumbers.push({ x, y, text, color, alpha: 1, vy: -3 });
}

// ─── 敵AI ─────────────────────────────────────────────────────
function updateEnemy() {
  if (!gameRunning) return;

  // 状態タイマー
  if (enemy.stateTimer > 0) { enemy.stateTimer--; if (enemy.stateTimer === 0) enemy.state = 'idle'; }
  if (enemy.flashTimer > 0) enemy.flashTimer--;

  // 攻撃タイマー
  enemy.attackTimer++;
  const interval = Math.max(40, enemy.attackInterval - round * 5);
  if (enemy.attackTimer >= interval && enemy.state === 'idle') {
    enemy.attackTimer = 0;
    enemyAttack();
  }

  // 敵の揺れ
  enemy.dx = Math.sin(Date.now() * 0.002) * 12;
}

function enemyAttack() {
  enemy.state = 'attacking';
  enemy.stateTimer = 40;

  // かわし・ブロック判定
  const dodged = Math.abs(playerDodge) > 0 && Math.random() < 0.55;
  const blocked = playerBlocking && Math.random() < 0.7;

  setTimeout(() => {
    if (!gameRunning) return;
    if (dodged) {
      showHitNumber(canvas.width/2, canvas.height*0.5, 'DODGE!', '#2ecc71');
      spawnParticles(canvas.width/2, canvas.height*0.5, '#2ecc71', 10);
      return;
    }
    if (blocked) {
      showHitNumber(canvas.width/2, canvas.height*0.5, 'BLOCK!', '#4a90d9');
      spawnParticles(canvas.width/2, canvas.height*0.5, '#4a90d9', 10);
      return;
    }
    const dmg = 8 + round * 2 + Math.floor(Math.random() * 8);
    playerHP = Math.max(0, playerHP - dmg);
    combo = 0;
    updateComboUI();
    screenShake = 12;
    document.getElementById('hit-effect').style.opacity = '1';
    setTimeout(() => document.getElementById('hit-effect').style.opacity = '0', 150);
    updateHealthUI();
    if (playerHP <= 0) gameOver();
  }, 500);
}

// ─── 次のラウンド ─────────────────────────────────────────────
function nextRound() {
  round++;
  maxEnemyHP = 100 + round * 20;
  enemy.hp = maxEnemyHP;
  enemy.maxHP = maxEnemyHP;
  enemy.attackInterval = Math.max(40, 90 - round * 8);
  enemy.size = Math.min(180, 120 + round * 8);
  playerHP = Math.min(100, playerHP + 25);
  document.getElementById('round').textContent = round;
  updateHealthUI();
}

// ─── ゲームオーバー ───────────────────────────────────────────
function gameOver() {
  gameRunning = false;
  const ov = document.getElementById('overlay');
  ov.innerHTML = `
    <h1>💀 KO !</h1>
    <p style="font-size:1.4rem;color:#ffd700">スコア: ${score}</p>
    <p>ラウンド ${round} まで戦いました</p>
    <button onclick="location.reload()">もう一度</button>
  `;
  ov.style.display = 'flex';
}

// ─── UI更新 ───────────────────────────────────────────────────
function updateScoreUI() {
  document.getElementById('score').textContent = score;
}
function updateComboUI() {
  document.getElementById('combo').textContent = `x${combo}`;
}
function updateHealthUI() {
  document.getElementById('enemy-health-fill').style.width = (enemy.hp / enemy.maxHP * 100) + '%';
  document.getElementById('player-health-fill').style.width = playerHP + '%';
}

// ─── 描画 ─────────────────────────────────────────────────────
function drawBackground() {
  // リング風背景
  ctx.fillStyle = '#1a0a00';
  ctx.fillRect(0, 0, W(), H());

  // リング床
  ctx.save();
  const grad = ctx.createRadialGradient(W()/2, H()*0.7, 50, W()/2, H()*0.7, W()*0.6);
  grad.addColorStop(0, '#3d1a00');
  grad.addColorStop(1, '#0d0500');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W(), H());
  ctx.restore();

  // スポットライト
  ctx.save();
  const light = ctx.createRadialGradient(W()/2, 0, 0, W()/2, 0, H()*0.8);
  light.addColorStop(0, 'rgba(255,220,100,0.08)');
  light.addColorStop(1, 'rgba(0,0,0,0)');
  ctx.fillStyle = light;
  ctx.fillRect(0, 0, W(), H());
  ctx.restore();
}

function drawEnemy() {
  const ex = enemy.x + enemy.dx;
  const ey = enemy.y;
  const s = enemy.size;

  ctx.save();

  // ダメージフラッシュ
  if (enemy.flashTimer > 0 && enemy.flashTimer % 2 === 0) {
    ctx.globalAlpha = 0.4;
  }

  // 攻撃状態で前傾
  let lean = 0;
  if (enemy.state === 'attacking') lean = 15;
  if (enemy.state === 'hurt') lean = -10;
  ctx.translate(ex, ey);
  ctx.rotate((lean * Math.PI) / 180);
  ctx.translate(-ex, -ey);

  // 胴体
  const bodyGrad = ctx.createLinearGradient(ex - s*0.35, ey - s*0.1, ex + s*0.35, ey + s*0.6);
  bodyGrad.addColorStop(0, '#c0392b');
  bodyGrad.addColorStop(1, '#7b241c');
  ctx.fillStyle = bodyGrad;
  ctx.beginPath();
  ctx.ellipse(ex, ey + s*0.3, s*0.32, s*0.45, 0, 0, Math.PI*2);
  ctx.fill();

  // 頭
  const headGrad = ctx.createRadialGradient(ex - s*0.08, ey - s*0.52, s*0.05, ex, ey - s*0.45, s*0.38);
  headGrad.addColorStop(0, '#f4c090');
  headGrad.addColorStop(1, '#c8814a');
  ctx.fillStyle = headGrad;
  ctx.beginPath();
  ctx.ellipse(ex, ey - s*0.42, s*0.3, s*0.36, 0, 0, Math.PI*2);
  ctx.fill();

  // 目
  enemy.eyeAnim = Math.sin(Date.now() * 0.005) * 2;
  const eyeY = ey - s*0.48 + enemy.eyeAnim;
  [ex - s*0.1, ex + s*0.1].forEach(ex2 => {
    ctx.fillStyle = 'white';
    ctx.beginPath(); ctx.ellipse(ex2, eyeY, s*0.07, s*0.09, 0, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = enemy.state === 'attacking' ? '#ff0000' : '#1a1a1a';
    ctx.beginPath(); ctx.ellipse(ex2, eyeY+2, s*0.04, s*0.05, 0, 0, Math.PI*2); ctx.fill();
  });

  // 口
  ctx.strokeStyle = '#333';
  ctx.lineWidth = 2;
  ctx.beginPath();
  if (enemy.state === 'hurt') {
    ctx.arc(ex, ey - s*0.28, s*0.08, 0, Math.PI); // 驚いた口
  } else if (enemy.state === 'attacking') {
    ctx.arc(ex, ey - s*0.32, s*0.1, Math.PI, 0); // 怒り口
  } else {
    ctx.moveTo(ex - s*0.1, ey - s*0.3);
    ctx.lineTo(ex + s*0.1, ey - s*0.3);
  }
  ctx.stroke();

  // グローブ(左右)
  const gloveColors = ['#c0392b', '#922b21'];
  const gloveGrad = ctx.createRadialGradient(ex - s*0.45, ey + s*0.05, 5, ex - s*0.45, ey + s*0.05, s*0.2);
  gloveGrad.addColorStop(0, '#e74c3c');
  gloveGrad.addColorStop(1, '#922b21');

  // 左グローブ
  let lx = ex - s*0.52, ly = ey + s*0.05;
  if (enemy.state === 'attacking') { lx += s*0.2; ly -= s*0.1; }
  ctx.fillStyle = gloveGrad;
  ctx.beginPath(); ctx.ellipse(lx, ly, s*0.2, s*0.17, -0.3, 0, Math.PI*2); ctx.fill();

  // 右グローブ
  const rGloveGrad = ctx.createRadialGradient(ex + s*0.45, ey + s*0.05, 5, ex + s*0.45, ey + s*0.05, s*0.2);
  rGloveGrad.addColorStop(0, '#e74c3c');
  rGloveGrad.addColorStop(1, '#922b21');
  let rx = ex + s*0.52, ry = ey + s*0.05;
  if (enemy.state === 'attacking') { rx -= s*0.2; ry -= s*0.1; }
  ctx.fillStyle = rGloveGrad;
  ctx.beginPath(); ctx.ellipse(rx, ry, s*0.2, s*0.17, 0.3, 0, Math.PI*2); ctx.fill();

  // ブロック状態
  if (enemy.state === 'blocking') {
    ctx.strokeStyle = 'rgba(100,180,255,0.7)';
    ctx.lineWidth = 4;
    ctx.beginPath();
    ctx.ellipse(ex, ey - s*0.1, s*0.55, s*0.65, 0, 0, Math.PI*2);
    ctx.stroke();
  }

  ctx.restore();
}

function drawPoseSkeleton() {
  if (!landmarks) return;

  // スケルトン接続
  const connections = [
    [11,12],[11,13],[13,15],[12,14],[14,16],
    [11,23],[12,24],[23,24],[23,25],[25,27],[24,26],[26,28]
  ];

  ctx.save();
  ctx.strokeStyle = 'rgba(100, 220, 255, 0.6)';
  ctx.lineWidth = 3;
  for (const [a, b] of connections) {
    const pa = lm(a), pb = lm(b);
    if (!pa || !pb || pa.v < 0.4 || pb.v < 0.4) continue;
    ctx.beginPath();
    ctx.moveTo(pa.x, pa.y);
    ctx.lineTo(pb.x, pb.y);
    ctx.stroke();
  }

  // 関節点
  const keyPoints = [11,12,13,14,15,16,23,24,25,26,27,28];
  for (const idx of keyPoints) {
    const p = lm(idx);
    if (!p || p.v < 0.4) continue;
    ctx.fillStyle = idx === 15 || idx === 16 ? '#ffd700' : 'rgba(100, 220, 255, 0.9)';
    ctx.beginPath();
    ctx.arc(p.x, p.y, idx === 15 || idx === 16 ? 10 : 6, 0, Math.PI*2);
    ctx.fill();
  }

  // パンチエフェクト
  if (rightPunchAnim > 0) {
    const p = lm(16);
    if (p) {
      ctx.fillStyle = `rgba(255, 200, 0, ${rightPunchAnim/15})`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 20 + (15 - rightPunchAnim)*2, 0, Math.PI*2);
      ctx.fill();
    }
    rightPunchAnim--;
  }
  if (leftPunchAnim > 0) {
    const p = lm(15);
    if (p) {
      ctx.fillStyle = `rgba(255, 200, 0, ${leftPunchAnim/15})`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 20 + (15 - leftPunchAnim)*2, 0, Math.PI*2);
      ctx.fill();
    }
    leftPunchAnim--;
  }

  // ブロック表示
  if (playerBlocking) {
    ctx.strokeStyle = 'rgba(100, 180, 255, 0.8)';
    ctx.lineWidth = 3;
    ctx.setLineDash([6, 3]);
    const nose = lm(0);
    if (nose) {
      ctx.beginPath();
      ctx.arc(nose.x, nose.y, 50, 0, Math.PI*2);
      ctx.stroke();
    }
    ctx.setLineDash([]);
  }

  ctx.restore();
}

function drawParticles() {
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx; p.y += p.vy; p.vy += 0.3;
    p.alpha -= 0.03;
    if (p.alpha <= 0) { particles.splice(i, 1); continue; }
    ctx.save();
    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.restore();
  }
}

function drawHitNumbers() {
  for (let i = hitNumbers.length - 1; i >= 0; i--) {
    const h = hitNumbers[i];
    h.y += h.vy;
    h.alpha -= 0.025;
    if (h.alpha <= 0) { hitNumbers.splice(i, 1); continue; }
    ctx.save();
    ctx.globalAlpha = h.alpha;
    ctx.fillStyle = h.color;
    ctx.font = `bold ${h.text.length > 3 ? 22 : 36}px Arial`;
    ctx.textAlign = 'center';
    ctx.shadowColor = 'black';
    ctx.shadowBlur = 6;
    ctx.fillText(h.text, h.x, h.y);
    ctx.restore();
  }
}

function drawHUD() {
  // かわし中
  if (Math.abs(playerDodge) > 0) {
    ctx.save();
    ctx.fillStyle = 'rgba(46,204,113,0.18)';
    ctx.fillRect(0, 0, W(), H());
    ctx.fillStyle = '#2ecc71';
    ctx.font = 'bold 28px Arial';
    ctx.textAlign = 'center';
    ctx.fillText('DODGE ▶', W()/2, H() - 50);
    ctx.restore();
  }
}

// ─── メインループ ─────────────────────────────────────────────
function gameLoop() {
  if (!gameRunning) return;

  // スクリーンシェイク
  let sx = 0, sy = 0;
  if (screenShake > 0) {
    sx = (Math.random() - 0.5) * screenShake;
    sy = (Math.random() - 0.5) * screenShake;
    screenShake *= 0.75;
    if (screenShake < 0.5) screenShake = 0;
  }

  ctx.save();
  ctx.translate(sx, sy);

  drawBackground();
  updateEnemy();
  drawEnemy();
  drawPoseSkeleton();
  drawParticles();
  drawHitNumbers();
  drawHUD();

  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