カメラで対戦する「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 }),
  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, nextPunch: 'R',
  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--;
    
    // ★修正: タイマーベースで攻撃発動を完全同期 (setTimeout廃止)
    if (enemy.state.includes('windup') && enemy.stateTimer === 15) {
      enemy.state = 'punch' + enemy.nextPunch;
      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'));
      }
    }
    
    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.includes('windup')) {
    enemy.targetX = playerPos.x; enemy.targetZ = playerPos.z + targetDist - 50; 
  } else if (enemy.state.includes('punch')) {
    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.nextPunch = Math.random() > 0.5 ? 'R' : 'L'; // ★修正: ランダムに左右の腕で攻撃
    enemy.state = 'windup' + enemy.nextPunch; 
    enemy.stateTimer = 45; // 全体で45フレーム(約750ms)、残り15フレームでパンチ
    playSound('swing');
  }
}

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.includes('windup');
  const isAttacking = enemy.state.includes('punch') || enemy.state === 'attacking';
  const isHurt = enemy.state === 'hurt';
  const isLeftPunch = enemy.state.includes('L'); // Lが含まれていれば左手攻撃
  
  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) {
    if (isLeftPunch) {
      joints.rw = {x: ex + 80, y: ey + 50, z: hz - 50}; joints.re = {x: ex + 120, y: ey + 100, z: hz - 20};
      joints.lw = {x: ex - 150, y: ey + 20, z: hz + 300}; joints.le = {x: ex - 200, y: ey + 80, z: hz + 150};
    } else {
      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) {
    if (isLeftPunch) {
      joints.rw = {x: ex + 80, y: ey + 50, z: hz - 50}; joints.re = {x: ex + 120, y: ey + 100, z: hz - 20};
      joints.lw = {x: ex - 50, y: ey + 20, z: hz - 600}; joints.le = {x: ex - 100, y: ey + 50, z: hz - 300};
    } else {
      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