ロボットミニゲーム「Wire Arms」


【更新履歴】

・2026/6/8 バージョン1.0公開。
・2026/6/8 バージョン1.1公開。(ルート選択を導入)
・2026/6/8 バージョン1.2公開。(コマンド選択を導入)
・2026/6/8 バージョン1.3公開。(コマンド1パス方式に変更)


画像


1. タクティカルモード(未来軌道予測)


・敵機に近づくとロックオンされ、攻撃軌道が表示されます。

・Z、X、C、Vのいずれかのキーを押すと、
 待機中のアームが、ターゲットに向けて発射されます。

(ゲーム開始時には、ZとXの2つのアームしか有りません。)
 

アクティブガード&自動迎撃


・敵のアームが自機に向かって発射され、
 自機と衝突する危険がある(距離35以内かつ接近中)の場合は、
 「WARNING: [Z/X/C/V] でガード&迎撃」 と警告が表示されます。

インターセプトの瞬時発動:

・この警告中に、待機中のアームキーを押すと即座に発動します。

バリアと自動追尾:

・自機に防御バリアを展開すると同時に、
 指定したアームを「迎撃用インターセプター」として射出します。

完全相殺:

・迎撃アームは飛来する敵の弾を空中で自動追尾し、
 激突して完全に相殺(相打ち撃墜)します。

・成功時には、画面に 「INTERCEPT!」 と表示されます。

部位破壊とアーム管理


・各機体は、「本体HP」のほかに、
 最大4基のアームそれぞれに独立した「アームHP」を持っています。

・被弾時、一定確率でアームが身代わりとなってダメージを受けます。

ATTACK PREDICT (緑表示):

・アーム待機状態。
・いつでも攻撃予測モードへ移行可能です。

GUARD / INTERCEPT (水色表示):

・敵の攻撃を感知した迎撃可能状態。
・押すことでアクティブガードが発動します。

IN USE (グレー表示):

・現在アームを射出中。
・手元に戻るまで再使用はできません。

CRUSHED (赤・取り消し線表示):

・アームHPが0になり大破した状態。
・資材(MATERIAL)による自動修理が完了するまで
 キー入力は受け付けられません。

EMPTY (黒表示):

・拡張マウント枠はあるが、
 まだアームが製造されていない空きスロットです。

ナノマシン・クラフト


・フィールド上のアイテム(撃破した敵の残滓など)を回収することで、
 機体はDATA(情報)とMATERIAL(資材)を蓄積し、
 リアルタイムに自己進化・修復を行います。

【DATA(水色アイテム / 1回収につき +10)】


・敵を凌駕するための戦術情報リソースです。

最大マウント枠の拡張:

・ゲーム開始時の初期アームは2基ですが、
 累計DATAが200ポイントに達するごとに
 「最大アーム搭載枠」が1つずつ自動解放
されます。
 (最大4枠まで拡張可能)

【MATERIAL(黄緑アイテム / 1回収につき +10)】


・機体の物理的構成を書き換えるためのナノマシン資材です。

・蓄積された資材は、戦闘状況に応じて
 以下の優先順位で自動的に消費(クラフト)されます。

自己修復(本体HPの再生):

・本体HPが最大値未満のとき、毎秒 MATERIAL を 1 消費し、
 本体HPを 10 ずつ継続回復
します。

アーム修理(CRUSHEDからの再起動):

・大破(CRUSHED)状態のアームが存在する場合、
 MATERIAL を 10 消費 してアームHPを全回復させ、
 即座に再起動・戦線復帰させます。

アーム新造(空き枠への追加武装):

・DATA拡張によってアームの最大搭載枠が増え、
 スロットに空き(EMPTY)がある場合、
 MATERIAL を 20 消費 して
 新たな武装アームを瞬時に製造し、自動でマウントします。

・戦闘中は画面上部のゲージ
 (緑:本体HP、水色:DATA進行度、黄緑:MATERIAL貯蔵量)を
 常に意識し、リソースの枯渇に注意して立ち回ってください。

・ダウンロードされる方はこちら。↓

※このゲームで遊ぶには、いつものテスト用ブラウザが必要です。↓


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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WIRE ARMS - TACTICAL CORE</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #050510; font-family: 'Courier New', monospace; user-select: none; }
        canvas { display: block; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; z-index: -1; transition: filter 0.3s; }
        body.blur canvas { filter: blur(6px) brightness(0.6); }
        
        #ui { position: absolute; top: 10px; left: 10px; width: 100%; pointer-events: none; transition: opacity 0.3s; z-index: 10; }
        .bar-container { display: flex; justify-content: space-between; width: 90%; margin: 0 auto; }
        .status-box { background: rgba(0,20,0,0.75); border: 1px solid #0f0; padding: 10px; width: 40%; text-shadow: 0 0 5px #000; color: #fff; transition: all 0.3s; }
        .status-box.enemy { background: rgba(20,0,0,0.75); border-color: #f00; text-align: right; }

        .gauge-label { font-size: 11px; margin-top: 4px; font-weight: bold; }
        .hp-bar-bg { width: 100%; height: 14px; background-color: #400; border: 1px solid #777; position: relative; overflow: hidden; margin-bottom: 2px;}
        .hp-bar-fg { height: 100%; background-color: #0f0; width: 100%; transition: width 0.2s linear; box-shadow: 0 0 10px #0f0; }
        .enemy .hp-bar-fg { background-color: #f00; box-shadow: 0 0 10px #f00; }
        
        .data-bar-fg { height: 100%; background-color: #00ffff; box-shadow: 0 0 8px #00ffff; transition: width 0.2s; }
        .mat-bar-fg { height: 100%; background-color: #adff2f; box-shadow: 0 0 8px #adff2f; transition: width 0.2s; }
        .enemy .data-bar-fg { background-color: #ff00ff; box-shadow: 0 0 8px #ff00ff; }
        
        #log { position: absolute; bottom: 120px; right: 20px; color: #aaa; pointer-events: none; font-size: 14px; text-shadow: 0 0 2px #000; line-height: 1.5; z-index: 10; text-align: right;}
        
        #centerPrompt { position: absolute; top: 35%; width: 100%; text-align: center; font-size: 24px; font-weight: bold; color: #fff; text-shadow: 0 0 10px #000, 0 0 20px #0ff; pointer-events: none; transition: color 0.2s; z-index: 10;}
        .locked-on { color: #ffea00 !important; text-shadow: 0 0 20px #ffea00 !important; }
        .obstructed { color: #ff0000 !important; text-shadow: 0 0 20px #ff0000 !important; }

        .target-hud { position: absolute; color: #fff; text-shadow: 0 0 5px #000; font-size: 12px; pointer-events: none; text-align: center; white-space: nowrap; transform: translate(-50%, -100%); display: none; background: rgba(0,0,0,0.6); border: 1px solid #0ff; padding: 4px; z-index: 5;}
        .target-hud.enemy-hud { border-color: #f00; color: #f33; }

        /* COMMAND MENU 横並び化&色対応 */
        #commandMenu { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: row; gap: 15px; z-index: 10; opacity: 0; transition: opacity 0.3s; }
        .cmd-item { background: rgba(0,20,0,0.85); border: 2px solid #0f0; padding: 10px 15px; font-size: 16px; font-weight: bold; width: 140px; text-align: center; transition: all 0.2s; box-shadow: 0 0 10px rgba(0,0,0,0.5); white-space: pre-line; border-radius: 4px; }
        .cmd-item.in-use { background: rgba(30,30,30,0.8); border-color: #555 !important; color: #777 !important; text-shadow: none !important; box-shadow: none !important; }
        .cmd-item.crushed { background: rgba(50,0,0,0.85); border-color: #f00 !important; color: #f55 !important; text-decoration: line-through; text-shadow: 0 0 5px #f00 !important; box-shadow: none !important;}
        .cmd-item.empty { background: rgba(0,0,0,0.5); border-color: #333 !important; color: #444 !important; text-shadow: none !important; box-shadow: none !important; }
        .cmd-item.selected { transform: scale(1.15) translateY(-5px); }

        h2, p { margin: 0; padding: 0; }

        .screen { display: none; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,5,15,0.85); flex-direction: column; justify-content: center; align-items: center; z-index: 100; color: #fff; text-align: center; }
        .screen.active { display: flex; }
        .screen h1 { font-size: 72px; margin-bottom: 20px; letter-spacing: 10px; text-shadow: 0 0 20px #0ff; }
        .screen h2 { font-size: 36px; margin-bottom: 20px; color: #0ff; }
        .screen p { font-size: 24px; color: #ccc; max-width: 600px; line-height: 1.5; }
        .blink { animation: blinker 1.5s linear infinite; }
        @keyframes blinker { 50% { opacity: 0; } }

        #stageIndicator { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); color: #0ff; font-size: 18px; text-shadow: 0 0 10px #0ff; pointer-events: none; opacity: 0; transition: opacity 0.3s; z-index: 10;}
        #stageIndicator.visible { opacity: 1; }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
</head>
<body>

<div id="ui" style="opacity: 0;">
    <div class="bar-container">
        <div class="status-box" id="uiPlayer">
            <h2 style="color:#0f0; text-shadow:0 0 5px #0f0;">PLAYER</h2>
            <div class="gauge-label">HP (<span id="hpPlayerTxt">1000</span>)</div>
            <div class="hp-bar-bg"><div class="hp-bar-fg" id="hpBarPlayer"></div></div>
            <div class="gauge-label" style="color:#00ffff;">DATA (<span id="dataPlayerTxt">0</span>) - ARM MAX: 4</div>
            <div class="hp-bar-bg" style="height:6px; background:#004444;"><div class="data-bar-fg" id="dataBarPlayer"></div></div>
            <div class="gauge-label" style="color:#adff2f;">MATERIAL (<span id="matPlayerTxt">0</span>)</div>
            <div class="hp-bar-bg" style="height:6px; background:#224400;"><div class="mat-bar-fg" id="matBarPlayer"></div></div>
        </div>
        
        <div class="status-box enemy" id="uiEnemy">
            <h2 style="color:#f33; text-shadow:0 0 5px #f00;">ENEMY</h2>
            <div class="gauge-label">HP (<span id="hpEnemyTxt">300</span>)</div>
            <div class="hp-bar-bg"><div class="hp-bar-fg" id="hpBarEnemy"></div></div>
            <div class="gauge-label" style="color:#ff00ff;">DATA (<span id="dataEnemyTxt">0</span>)</div>
            <div class="hp-bar-bg" style="height:6px; background:#404;"><div class="data-bar-fg" id="dataBarEnemy"></div></div>
            <div class="gauge-label" style="color:#adff2f;">MATERIAL (<span id="matEnemyTxt">0</span>)</div>
            <div class="hp-bar-bg" style="height:6px; background:#224400;"><div class="mat-bar-fg" id="matBarEnemy"></div></div>
        </div>
    </div>
</div>

<div id="commandMenu"></div>
<div id="stageIndicator"></div>
<div id="centerPrompt"></div>
<div id="log"></div>
<div id="targetHud" class="target-hud"></div>

<div id="screenTitle" class="screen active"><h1>WIRE ARMS</h1><p class="blink">CLICK TO START</p></div>
<div id="screenOpening" class="screen"><h2>INITIALIZING SYSTEM</h2><p>神経接続パス確立...<br>自律戦闘AI、起動準備完了。</p></div>
<div id="screenStageStart" class="screen"><h1 id="stageText">STAGE 1</h1><p>敵対プログラムを殲滅せよ</p></div>
<div id="screenPause" class="screen"><h1 style="color:#ffa500;">PAUSED</h1><p class="blink">[ESC]キーで戦闘再開</p></div>
<div id="screenStageClear" class="screen"><h1 style="color:#0f0;">STAGE CLEAR</h1><p>敵データの残滓を吸収...<br>次フェーズへ移行します。</p></div>
<div id="screenGameOver" class="screen"><h1 style="color:#f00;">GAME OVER</h1><p>機体大破。接続をロストしました。</p></div>
<div id="screenGameClear" class="screen"><h1 style="color:#ff0ff0;">ALL CLEARED</h1><p>MISSION ACCOMPLISHED.<br>全脅威の排除を確認しました。</p></div>

<script>
// ==========================================
// 0. ゲーム状態管理 & コマンドカラー定義
// ==========================================
let gameState = 'TITLE';
let currentStage = 1;
const maxStages = 5;
let enemies = [];
let accumulatedEnemyData = 0;
let accumulatedEnemyMat = 0;

const CMD_COLORS = [0x00bfff, 0x32cd32, 0xffd700, 0xda70d6]; // 青, 緑, 黄, 紫
const CMD_COLORS_CSS = ['#00bfff', '#32cd32', '#ffd700', '#da70d6'];

// リアルタイムの戦術状況を管理するオブジェクト
const playerTactics = {
    routes: [],
    lines: [],
    target: null,
    interceptBit: null,
    selectedIndex: null, // 0~3 または null
    updateTimer: 0
};

function switchState(newState) {
    document.querySelectorAll('.screen').forEach(el => el.classList.remove('active'));
    gameState = newState;
    
    const ui = document.getElementById('ui');
    const cmdMenu = document.getElementById('commandMenu');
    const stageInd = document.getElementById('stageIndicator');
    const targetHud = document.getElementById('targetHud');
    const body = document.body;

    if (newState === 'PLAYING') {
        ui.style.opacity = '1'; cmdMenu.style.opacity = '1';
        stageInd.classList.add('visible');
        body.classList.remove('blur');
    } else {
        ui.style.opacity = '0'; cmdMenu.style.opacity = '0';
        stageInd.classList.remove('visible');
        targetHud.style.display = 'none';
        body.classList.add('blur');
        document.getElementById('centerPrompt').innerText = "";
        playerTactics.selectedIndex = null; // メニュー等に入ったら選択解除
    }

    if (newState === 'TITLE') document.getElementById('screenTitle').classList.add('active');
    else if (newState === 'OPENING') document.getElementById('screenOpening').classList.add('active');
    else if (newState === 'STAGE_START') {
        document.getElementById('stageText').innerText = "STAGE " + currentStage;
        document.getElementById('screenStageStart').classList.add('active');
        setTimeout(() => { initStage(); switchState('PLAYING'); }, 2000);
    }
    else if (newState === 'PAUSE') document.getElementById('screenPause').classList.add('active');
    else if (newState === 'STAGE_CLEAR') {
        document.getElementById('screenStageClear').classList.add('active');
        player.data += accumulatedEnemyData;
        player.materials += accumulatedEnemyMat;
        game.addMessage(`SYSTEM: DATA +${accumulatedEnemyData} / MAT +${accumulatedEnemyMat}`);
        player.checkCrafting();
    }
    else if (newState === 'GAME_OVER') document.getElementById('screenGameOver').classList.add('active');
    else if (newState === 'GAME_CLEAR') document.getElementById('screenGameClear').classList.add('active');
}

window.addEventListener('click', () => {
    window.focus();
    if(audioCtx.state === 'suspended') audioCtx.resume();
    if (gameState === 'TITLE') {
        switchState('OPENING');
        setTimeout(() => switchState('STAGE_START'), 2500);
    } else if (gameState === 'GAME_OVER' || gameState === 'GAME_CLEAR') {
        currentStage = 1;
        player.data = 0; player.materials = 0;
        player.maxArms = 2;
        player.arms = [{id: 'ARM1', hp: 100, maxHp: 100}, {id: 'ARM2', hp: 100, maxHp: 100}];
        switchState('TITLE');
    }
});

// ==========================================
// 1. サウンド
// ==========================================
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();
function playSound(type) {
    if (audioCtx.state === 'suspended') audioCtx.resume();
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.connect(gain); gain.connect(audioCtx.destination);
    const now = audioCtx.currentTime;
    if(type === 'attack') {
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(300, now); osc.frequency.exponentialRampToValueAtTime(80, now + 0.15);
        gain.gain.setValueAtTime(0.25, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
        osc.start(now); osc.stop(now + 0.15);
    } else if(type === 'laser') {
        osc.type = 'sine'; osc.frequency.setValueAtTime(1200, now); osc.frequency.linearRampToValueAtTime(400, now + 0.25);
        gain.gain.setValueAtTime(0.3, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25);
        osc.start(now); osc.stop(now + 0.25);
    } else if(type === 'hit') {
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(100, now); osc.frequency.exponentialRampToValueAtTime(20, now + 0.3);
        gain.gain.setValueAtTime(0.5, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
        osc.start(now); osc.stop(now + 0.3);
    } else if(type === 'craft') {
        osc.type = 'square'; osc.frequency.setValueAtTime(600, now); osc.frequency.setValueAtTime(800, now + 0.1); osc.frequency.setValueAtTime(1200, now + 0.2);
        gain.gain.setValueAtTime(0.4, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.4);
        osc.start(now); osc.stop(now + 0.4);
    } else if(type === 'crush') {
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(50, now); osc.frequency.exponentialRampToValueAtTime(10, now + 0.4);
        gain.gain.setValueAtTime(0.8, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.4);
        osc.start(now); osc.stop(now + 0.4);
    } else if(type === 'intercept') {
        osc.type = 'square'; osc.frequency.setValueAtTime(1500, now); osc.frequency.exponentialRampToValueAtTime(500, now + 0.1);
        gain.gain.setValueAtTime(0.3, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
        osc.start(now); osc.stop(now + 0.1);
    } else if(type === 'menu') {
        osc.type = 'sine'; osc.frequency.setValueAtTime(800, now); osc.frequency.linearRampToValueAtTime(1200, now + 0.05);
        gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
        osc.start(now); osc.stop(now + 0.05);
    }
}

// ==========================================
// 2. UI更新 (コマンドメニュー含む)
// ==========================================
const game = {
    messages: ["SYSTEM: 起動待機中..."],
    addMessage(msg) {
        this.messages.push(msg);
        if(this.messages.length > 5) this.messages.shift();
        document.getElementById('log').innerHTML = this.messages.join('<br>');
    },
    updateUI() {
        document.getElementById('hpBarPlayer').style.width = Math.max(0, (player.hp / player.maxHp) * 100) + '%';
        document.getElementById('hpPlayerTxt').innerText = Math.floor(player.hp);
        document.getElementById('dataPlayerTxt').innerText = player.data;
        document.getElementById('dataBarPlayer').style.width = Math.min(100, ((player.data % 200) / 200) * 100) + '%';
        document.getElementById('matPlayerTxt').innerText = player.materials;
        document.getElementById('matBarPlayer').style.width = Math.min(100, (player.materials / 50) * 100) + '%';
        
        const firstAliveEnemy = enemies.find(e => e.hp > 0) || enemies[0];
        if (firstAliveEnemy) {
            document.getElementById('hpBarEnemy').style.width = Math.max(0, (firstAliveEnemy.hp / firstAliveEnemy.maxHp) * 100) + '%';
            document.getElementById('hpEnemyTxt').innerText = Math.floor(firstAliveEnemy.hp);
            document.getElementById('dataEnemyTxt').innerText = firstAliveEnemy.data;
            document.getElementById('dataBarEnemy').style.width = Math.min(100, ((firstAliveEnemy.data % 200) / 200) * 100) + '%';
            document.getElementById('matEnemyTxt').innerText = firstAliveEnemy.materials;
            document.getElementById('matBarEnemy').style.width = Math.min(100, (firstAliveEnemy.materials / 50) * 100) + '%';
        } else {
            document.getElementById('hpBarEnemy').style.width = '0%';
        }
        document.getElementById('stageIndicator').innerText = `STAGE ${currentStage} / ${maxStages}  ENEMIES: ${enemies.filter(e=>e.hp>0).length}`;
        this.updateCommandMenu();
    },
    updateCommandMenu() {
        if(gameState !== 'PLAYING') return;
        const keyMap = ['Z', 'X', 'C', 'V'];
        const container = document.getElementById('commandMenu');
        container.innerHTML = '';
        
        for (let i = 0; i < 4; i++) {
            const arm = player.arms[i];
            const key = keyMap[i];
            const div = document.createElement('div');
            const colorCss = CMD_COLORS_CSS[i];
            
            div.className = 'cmd-item';
            
            if (!arm) {
                div.className += ' empty';
                div.innerText = `[${key}]\nEMPTY`;
            } else if (arm.hp <= 0) {
                div.className += ' crushed';
                div.innerText = `[${key}]\nCRUSHED`;
            } else if (bits.find(b => b.owner === player && b.armId === arm.id)) {
                div.className += ' in-use';
                div.innerText = `[${key}]\nIN USE`;
            } else {
                if (playerTactics.selectedIndex === i) {
                    div.className += ' selected';
                    div.style.borderColor = colorCss;
                    div.style.color = '#fff';
                    div.style.backgroundColor = colorCss;
                    div.style.textShadow = '0 0 10px #fff';
                    div.style.boxShadow = `0 0 20px ${colorCss}`;
                } else {
                    div.style.borderColor = colorCss;
                    div.style.color = colorCss;
                    div.style.textShadow = `0 0 5px ${colorCss}`;
                }
                
                let modeText = playerTactics.interceptBit ? "GUARD" : "ATTACK";
                let routeName = playerTactics.routes[i] ? playerTactics.routes[i].name : modeText;
                div.innerText = `[${key}]\n${routeName}`;
            }
            container.appendChild(div);
        }
    }
};

const floatingTexts = [];
function createFloatingText(text, pos, color, size = "24px") {
    const div = document.createElement('div');
    div.innerText = text;
    div.style.position = 'absolute';
    div.style.color = color;
    div.style.fontWeight = 'bold';
    div.style.textShadow = '0 0 5px #000';
    div.style.pointerEvents = 'none';
    div.style.fontSize = size;
    div.style.zIndex = 20;
    document.body.appendChild(div);
    floatingTexts.push({ el: div, pos: pos.clone(), life: 1.5 });
}

function updateFloatingTexts(dt) {
    for (let i = floatingTexts.length - 1; i >= 0; i--) {
        let ft = floatingTexts[i];
        ft.life -= dt;
        if (ft.life <= 0) { ft.el.remove(); floatingTexts.splice(i, 1); continue; }
        ft.pos.y += dt * 10; 
        let proj = ft.pos.clone().project(camera);
        let x = (proj.x * 0.5 + 0.5) * window.innerWidth;
        let y = (proj.y * -0.5 + 0.5) * window.innerHeight;
        ft.el.style.left = `calc(${x}px - 20px)`;
        ft.el.style.top = `${y}px`;
        ft.el.style.opacity = Math.min(1.0, ft.life);
    }
}

function updateTargetHud() {
    const targetHud = document.getElementById('targetHud');
    let target = playerTactics.target;
    if (!target) {
        const aliveEnemies = enemies.filter(e => e.hp > 0);
        target = player.getNearestOpponent(aliveEnemies);
    }
    if (gameState === 'PLAYING' && target && target.hp > 0) {
        let proj = target.pos.clone().add(new THREE.Vector3(0, 10, 0)).project(camera);
        let x = (proj.x * 0.5 + 0.5) * window.innerWidth;
        let y = (proj.y * -0.5 + 0.5) * window.innerHeight;
        targetHud.style.left = `${x}px`;
        targetHud.style.top = `${y}px`;
        targetHud.style.display = 'block';
        targetHud.className = target.isPlayer ? 'target-hud' : 'target-hud enemy-hud';
        targetHud.innerHTML = `TARGET HP: ${Math.floor(target.hp)}`;
    } else {
        targetHud.style.display = 'none';
    }
}

// ==========================================
// 3. Three.js セットアップ
// ==========================================
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050510, 0.012);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 50, 60);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x050510, 1.0); 
document.body.appendChild(renderer.domElement);

const ambientLight = new THREE.AmbientLight(0x404040, 2.0);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(20, 50, 20);
scene.add(dirLight);

const gridHelper = new THREE.GridHelper(100, 25, 0x004444, 0x001111);
scene.add(gridHelper);

const blocks = [];
const blockGeo = new THREE.BoxGeometry(8, 10, 8);
const fragments = [];

function generateBlocks() {
    blocks.forEach(b => scene.remove(b));
    blocks.length = 0;
    const currentStageWireColor = new THREE.Color().setHSL(Math.random(), 0.8, 0.5);
    const blockMat = new THREE.MeshBasicMaterial({ color: currentStageWireColor, wireframe: true });
    
    for (let x = -40; x <= 40; x += 20) {
        for (let z = -40; z <= 40; z += 20) {
            if (Math.abs(x) < 15 && Math.abs(z) < 30) continue; 
            let mesh = new THREE.Mesh(blockGeo, blockMat);
            mesh.position.set(x, 5, z);
            scene.add(mesh);
            blocks.push(mesh);
        }
    }
}

function checkCollision(pos, radius = 4.0) {
    for (let b of blocks) {
        let dx = Math.max(b.position.x - 4, Math.min(pos.x, b.position.x + 4)) - pos.x;
        let dz = Math.max(b.position.z - 4, Math.min(pos.z, b.position.z + 4)) - pos.z;
        if (dx * dx + dz * dz < radius * radius) return true;
    }
    return false;
}

// ==========================================
// 4. A* アルゴリズム & 複数ルート生成
// ==========================================
const PATH_GRID_SIZE = 4;
const PATH_GRID_W = Math.ceil(100 / PATH_GRID_SIZE);

function worldToGrid(x, z) { return { c: Math.floor((x + 50) / PATH_GRID_SIZE), r: Math.floor((z + 50) / PATH_GRID_SIZE) }; }
function gridToWorld(c, r) { return new THREE.Vector3(c * PATH_GRID_SIZE - 50 + PATH_GRID_SIZE/2, 1, r * PATH_GRID_SIZE - 50 + PATH_GRID_SIZE/2); }

function findPath(startPos, endPos, radius = 4.0) {
    let start = worldToGrid(startPos.x, startPos.z);
    let end = worldToGrid(endPos.x, endPos.z);
    start.c = Math.max(0, Math.min(PATH_GRID_W-1, start.c)); start.r = Math.max(0, Math.min(PATH_GRID_W-1, start.r));
    end.c = Math.max(0, Math.min(PATH_GRID_W-1, end.c)); end.r = Math.max(0, Math.min(PATH_GRID_W-1, end.r));

    let open = [{c: start.c, r: start.r, g: 0, h: Math.hypot(start.c-end.c, start.r-end.r), parent: null}];
    let closed = new Set();
    let getKey = (c, r) => `${c},${r}`;
    
    while(open.length > 0) {
        open.sort((a,b) => (a.g + a.h) - (b.g + b.h));
        let current = open.shift();
        
        if (current.c === end.c && current.r === end.r) {
            let path = []; let curr = current;
            while(curr) { path.push(gridToWorld(curr.c, curr.r)); curr = curr.parent; }
            path.reverse(); path[0] = startPos.clone(); path[path.length-1] = endPos.clone();
            return path;
        }
        closed.add(getKey(current.c, current.r));
        
        const dirs = [[0,1], [1,0], [0,-1], [-1,0], [1,1], [1,-1], [-1,1], [-1,-1]];
        for(let d of dirs) {
            let nc = current.c + d[0], nr = current.r + d[1];
            if (nc < 0 || nc >= PATH_GRID_W || nr < 0 || nr >= PATH_GRID_W) continue;
            if (closed.has(getKey(nc, nr))) continue;
            if (checkCollision(gridToWorld(nc, nr), radius)) { if (nc !== end.c || nr !== end.r) continue; }
            
            let moveCost = (d[0] !== 0 && d[1] !== 0) ? 1.414 : 1;
            let g = current.g + moveCost;
            let existing = open.find(n => n.c === nc && n.r === nr);
            
            if (existing) { if (g < existing.g) { existing.g = g; existing.parent = current; } }
            else { open.push({c: nc, r: nr, g: g, h: Math.hypot(nc-end.c, nr-end.r), parent: current}); }
        }
    }
    return null;
}

function generateMultiPaths(startPos, targetPos, attackerData, defenderData) {
    const routes = [];
    const start = startPos.clone().setY(5);
    const target = targetPos.clone().setY(5);

    // Route 1: DIRECT
    let direct = findPath(start, target, 4.0);
    if (!direct) direct = [start.clone(), target.clone()];
    routes.push({ path: direct, name: "DIRECT" });

    const dir = target.clone().sub(start).normalize();
    const leftSide = new THREE.Vector3(-dir.z, 0, dir.x);
    const rightSide = new THREE.Vector3(dir.z, 0, -dir.x);

    // Route 2: FLANK_L
    const leftMid = start.clone().lerp(target, 0.5).add(leftSide.clone().multiplyScalar(25));
    let pathL1 = findPath(start, leftMid, 4.0) || [start.clone(), leftMid.clone()];
    let pathL2 = findPath(leftMid, target, 4.0) || [leftMid.clone(), target.clone()];
    routes.push({ path: pathL1.concat(pathL2.slice(1)), name: "FLANK_L" });

    // Route 3: FLANK_R
    const rightMid = start.clone().lerp(target, 0.5).add(rightSide.clone().multiplyScalar(25));
    let pathR1 = findPath(start, rightMid, 4.0) || [start.clone(), rightMid.clone()];
    let pathR2 = findPath(rightMid, target, 4.0) || [rightMid.clone(), target.clone()];
    routes.push({ path: pathR1.concat(pathR2.slice(1)), name: "FLANK_R" });

    // Route 4: WIDE_FLANK
    const wideMid = start.clone().lerp(target, 0.4).add(leftSide.clone().multiplyScalar(40));
    let pathW1 = findPath(start, wideMid, 4.0) || [start.clone(), wideMid.clone()];
    let pathW2 = findPath(wideMid, target, 4.0) || [wideMid.clone(), target.clone()];
    routes.push({ path: pathW1.concat(pathW2.slice(1)), name: "WIDE_ARC" });

    return routes;
}

// ==========================================
// 4.5 戦術ルート常時更新
// ==========================================
function updatePlayerTrajectories(interceptTarget, attackTarget) {
    playerTactics.routes = [];
    const startPos = player.pos.clone().setY(5);

    if (interceptTarget) {
        for(let i=0; i<4; i++) {
            const dest = interceptTarget.pos.clone().setY(5);
            // 迎撃時は直線ルート
            playerTactics.routes.push({ path: [startPos, dest], name: "INTERCEPT" });
        }
        playerTactics.target = interceptTarget.owner;
        playerTactics.interceptBit = interceptTarget;
    } else if (attackTarget) {
        playerTactics.routes = generateMultiPaths(startPos, attackTarget.pos, player.data, attackTarget.data);
        playerTactics.target = attackTarget;
        playerTactics.interceptBit = null;
    } else {
        playerTactics.target = null;
        playerTactics.interceptBit = null;
    }
}

function drawPlayerTrajectories() {
    while(playerTactics.lines.length < 4) {
        const mat = new THREE.LineDashedMaterial({ dashSize: 1.5, gapSize: 0.5, transparent: true, opacity: 0.5, linewidth: 2 });
        const line = new THREE.Line(new THREE.BufferGeometry(), mat);
        scene.add(line);
        playerTactics.lines.push(line);
    }

    for (let i = 0; i < 4; i++) {
        const line = playerTactics.lines[i];
        const arm = player.arms[i];
        const route = playerTactics.routes[i];
        const inUse = bits.find(b => b.owner === player && b.armId === (arm ? arm.id : ''));

        if (!arm || arm.hp <= 0 || inUse || !route) {
            line.visible = false;
        } else {
            line.visible = true;
            const points = route.path.map(p => p.clone().setY(5));
            line.geometry.setFromPoints(points);
            line.computeLineDistances();
            
            line.material.color.setHex(CMD_COLORS[i]);
            
            if (playerTactics.selectedIndex === i) {
                const pulse = (Math.sin(performance.now() * 0.01) + 1.0) * 0.5;
                line.material.opacity = 0.6 + pulse * 0.4;
            } else if (playerTactics.selectedIndex !== null) {
                line.material.opacity = 0.15;
            } else {
                line.material.opacity = 0.5;
            }
        }
    }
}

function clearRouteLines() {
    for (let line of playerTactics.lines) {
        scene.remove(line);
        line.geometry.dispose();
        line.material.dispose();
    }
    playerTactics.lines = [];
}

// ==========================================
// 5. パーティクル & 残像
// ==========================================
const particles = [];
function createHitParticles(pos, color) {
    for (let i = 0; i < 18; i++) {
        const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.25, 4, 4), new THREE.MeshBasicMaterial({ color: color, wireframe: true }));
        mesh.position.copy(pos); scene.add(mesh);
        const vel = new THREE.Vector3((Math.random() - 0.5) * 20, Math.random() * 15 + 5, (Math.random() - 0.5) * 20);
        particles.push({ mesh, vel, life: 0.6 + Math.random() * 0.4, maxLife: 1.0 });
    }
}
function updateParticles(dt) {
    for (let i = particles.length - 1; i >= 0; i--) {
        const p = particles[i]; p.life -= dt;
        if (p.life <= 0) { scene.remove(p.mesh); particles.splice(i, 1); continue; }
        p.vel.y -= 20 * dt; p.mesh.position.addScaledVector(p.vel, dt);
        p.mesh.rotation.x += dt * 5; p.mesh.rotation.z += dt * 3;
        p.mesh.material.opacity = p.life / p.maxLife; p.mesh.material.transparent = true;
    }
}

const afterImages = [];
function createAfterImage(robotGroup, color) {
    const cloneGroup = new THREE.Group();
    robotGroup.traverse(child => {
        if (child.isMesh) {
            const clone = new THREE.Mesh(child.geometry, new THREE.MeshBasicMaterial({ color: color, wireframe: true, transparent: true, opacity: 0.55 }));
            clone.position.copy(child.getWorldPosition(new THREE.Vector3()));
            clone.quaternion.copy(child.getWorldQuaternion(new THREE.Quaternion()));
            clone.scale.copy(child.getWorldScale(new THREE.Vector3()));
            cloneGroup.add(clone);
        }
    });
    scene.add(cloneGroup);
    afterImages.push({ group: cloneGroup, life: 0.35, maxLife: 0.35 });
}
function updateAfterImages(dt) {
    for (let i = afterImages.length - 1; i >= 0; i--) {
        const ai = afterImages[i]; ai.life -= dt;
        if (ai.life <= 0) { scene.remove(ai.group); afterImages.splice(i, 1); continue; }
        const alpha = (ai.life / ai.maxLife) * 0.55;
        ai.group.traverse(child => { if (child.isMesh) child.material.opacity = alpha; });
    }
}

// ==========================================
// 6. ビット(飛翔体)
// ==========================================
const bits = [];
class Bit {
    constructor(startPos, path, color, owner, targetObj, armId) {
        this.owner = owner; this.targetObj = targetObj; this.path = path;
        this.pathIndex = 0; this.speed = 90; this.pos = startPos.clone();
        this.color = color; this.armId = armId; this.isInterceptor = false; 
        this.bitState = 'OUTBOUND'; this.laserTimer = 0; this.laserLine = null; this.life = 15.0; 

        const geo = new THREE.OctahedronGeometry(0.7, 0);
        const mat = new THREE.MeshBasicMaterial({ color: color, wireframe: true });
        this.mesh = new THREE.Mesh(geo, mat); this.mesh.position.copy(this.pos); scene.add(this.mesh);
        const ringGeo = new THREE.TorusGeometry(1.2, 0.1, 4, 8);
        const ringMat = new THREE.MeshBasicMaterial({ color: color, wireframe: true });
        this.ring = new THREE.Mesh(ringGeo, ringMat); this.mesh.add(this.ring);

        // 自身の予測線を生成
        const points = this.path.map(p => p.clone().setY(5));
        const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
        const lineMat = new THREE.LineDashedMaterial({ color: color, dashSize: 1, gapSize: 0.5, transparent: true, opacity: 0.8 });
        this.trajectoryLine = new THREE.Line(lineGeo, lineMat);
        this.trajectoryLine.computeLineDistances(); scene.add(this.trajectoryLine);
    }
    update(dt) {
        this.life -= dt; this.mesh.rotation.y += dt * 4; this.ring.rotation.x += dt * 6;
        if (this.bitState === 'OUTBOUND') {
            if (this.isInterceptor && this.targetObj && this.targetObj.life > 0) {
                const targetPos = this.targetObj.pos.clone();
                const dir = new THREE.Vector3().subVectors(targetPos, this.pos).normalize();
                this.pos.addScaledVector(dir, this.speed * dt); this.mesh.position.copy(this.pos);
                if (this.pos.distanceTo(targetPos) < 3.0) this.fireLaser();
            } else if (this.path && this.pathIndex < this.path.length - 1) {
                const targetPos = this.path[this.pathIndex + 1].clone().setY(5);
                const dir = new THREE.Vector3().subVectors(targetPos, this.pos).normalize();
                const moveDist = this.speed * dt; const remaining = this.pos.distanceTo(targetPos);
                if (remaining <= moveDist) { this.pos.copy(targetPos); this.pathIndex++; } 
                else { this.pos.addScaledVector(dir, moveDist); }
                this.mesh.position.copy(this.pos);
                if (this.targetObj && this.targetObj.hp > 0 && this.pos.distanceTo(this.targetObj.pos) < 5) this.fireLaser();
            } else { this.fireLaser(); }
        } else if (this.bitState === 'FIRING') {
            this.laserTimer -= dt;
            if (this.laserTimer <= 0) {
                if (this.laserLine && this.laserLine.parent) scene.remove(this.laserLine);
                this.laserLine = null; this.mesh.visible = true; this.bitState = 'RETURN';
            }
        } else if (this.bitState === 'RETURN') {
            const targetPos = this.owner.pos.clone().setY(5);
            const dir = new THREE.Vector3().subVectors(targetPos, this.pos).normalize();
            const moveDist = this.speed * 4.0 * dt; const remaining = this.pos.distanceTo(targetPos);
            if (remaining < moveDist || remaining < 2.0) { this.destroy(); return true; } 
            else { this.pos.addScaledVector(dir, moveDist); }
            this.mesh.position.copy(this.pos);
        }
        return this.life <= 0;
    }
    fireLaser() {
        if (this.bitState === 'FIRING') return;
        this.bitState = 'FIRING'; playSound('laser');
        if (this.trajectoryLine && this.trajectoryLine.parent) scene.remove(this.trajectoryLine);
        this.trajectoryLine = null; this.mesh.visible = false;

        if (this.isInterceptor) {
            if (this.targetObj && this.targetObj.life > 0) {
                const points = [this.pos.clone(), this.targetObj.pos.clone()];
                const laserGeo = new THREE.BufferGeometry().setFromPoints(points);
                const laserMat = new THREE.LineBasicMaterial({ color: this.color, linewidth: 3 });
                this.laserLine = new THREE.Line(laserGeo, laserMat); scene.add(this.laserLine);
                createHitParticles(this.targetObj.pos, 0xffaa00);
                createFloatingText("INTERCEPTED!", this.targetObj.pos.clone().add(new THREE.Vector3(0,5,0)), "#ffaa00");
                playSound('hit');
                this.targetObj.destroy(); 
            }
        } else {
            if (!this.targetObj || this.targetObj.hp <= 0) { this.laserTimer = 0.1; return; }
            const points = [this.pos.clone(), this.targetObj.pos.clone().setY(3)];
            const laserGeo = new THREE.BufferGeometry().setFromPoints(points);
            const laserMat = new THREE.LineBasicMaterial({ color: this.color, linewidth: 3 });
            this.laserLine = new THREE.Line(laserGeo, laserMat); scene.add(this.laserLine);
            this.owner.resolveAttack(this.targetObj);
            createHitParticles(this.targetObj.pos.clone().setY(3), this.color);
        }
        this.laserTimer = 0.3;
    }
    destroy() {
        if (this.mesh.parent) scene.remove(this.mesh);
        if (this.laserLine && this.laserLine.parent) scene.remove(this.laserLine);
        if (this.trajectoryLine && this.trajectoryLine.parent) scene.remove(this.trajectoryLine);
        this.life = 0;
    }
}
function updateBits(dt) {
    for (let i = bits.length - 1; i >= 0; i--) { const done = bits[i].update(dt); if (done) { bits[i].destroy(); bits.splice(i, 1); } }
}

// ==========================================
// 7. ワイヤーフレーム
// ==========================================
function createWireframeRobot(color) {
    const group = new THREE.Group(); const mat = () => new THREE.MeshBasicMaterial({ color: color, wireframe: true });
    const torso = new THREE.Mesh(new THREE.BoxGeometry(3, 4, 2), mat()); torso.position.set(0, 5, 0); group.add(torso);
    const head = new THREE.Mesh(new THREE.BoxGeometry(1.8, 1.8, 1.8), mat()); head.position.set(0, 7.8, 0); group.add(head);
    const visor = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.4, 0.3), mat()); visor.position.set(0, 7.9, 0.9); group.add(visor);
    const lUpperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 2, 6), mat()); lUpperArm.position.set(-2.5, 5.5, 0); group.add(lUpperArm);
    const lForeArm = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, 1.8, 6), mat()); lForeArm.position.set(-3.5, 4.0, 0); group.add(lForeArm);
    const lHand = new THREE.Mesh(new THREE.OctahedronGeometry(0.6, 0), mat()); lHand.position.set(-4.3, 3.0, 0); group.add(lHand);
    const rUpperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 2, 6), mat()); rUpperArm.position.set(2.5, 5.5, 0); group.add(rUpperArm);
    const rForeArm = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, 1.8, 6), mat()); rForeArm.position.set(3.5, 4.0, 0); group.add(rForeArm);
    const rHand = new THREE.Mesh(new THREE.OctahedronGeometry(0.6, 0), mat()); rHand.position.set(4.3, 3.0, 0); group.add(rHand);
    const lThigh = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.38, 2.2, 6), mat()); lThigh.position.set(-1.0, 1.8, 0); group.add(lThigh);
    const lShin = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.28, 2.0, 6), mat()); lShin.position.set(-1.1, -0.3, 0.1); group.add(lShin);
    const lFoot = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.4, 1.4), mat()); lFoot.position.set(-1.1, -1.6, 0.3); group.add(lFoot);
    const rThigh = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.38, 2.2, 6), mat()); rThigh.position.set(1.0, 1.8, 0); group.add(rThigh);
    const rShin = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.28, 2.0, 6), mat()); rShin.position.set(1.1, -0.3, 0.1); group.add(rShin);
    const rFoot = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.4, 1.4), mat()); rFoot.position.set(1.1, -1.6, 0.3); group.add(rFoot);
    scene.add(group); return group;
}
function createShieldMesh() {
    const mesh = new THREE.Mesh(new THREE.SphereGeometry(5.0, 12, 12), new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.25, wireframe: true }));
    mesh.visible = false; scene.add(mesh); return mesh;
}

// ==========================================
// 8. Entity
// ==========================================
class Entity {
    constructor(color, x, z, isPlayer) {
        this.isPlayer = isPlayer; this.color = color;
        this.pos = new THREE.Vector3(x, 1, z); this.lastPos = this.pos.clone(); this.radius = 2.5; 
        this.maxHp = isPlayer ? 1000 : 300; this.hp = this.maxHp;
        this.data = 0; this.materials = 0; this.maxArms = 2;
        this.arms = [ { id: 'ARM1', hp: 100, maxHp: 100 }, { id: 'ARM2', hp: 100, maxHp: 100 } ];

        this.robotGroup = createWireframeRobot(color); this.robotGroup.position.copy(this.pos);
        this.shieldMesh = createShieldMesh();
        this.facing = new THREE.Vector3(0, 0, isPlayer ? -1 : 1); this.targetFacing = this.facing.clone();
        this.state = 'idle'; this.timer = 0; this.regenTickTimer = 0;
        this.activeArm = null; this.currentAttackPath = null; this.attackTarget = null;
        this.activeArmColor = null; // カスタムカラー保持用
        this.afterImageTimer = 0; this.walkCycle = 0;
        this.aiActionTimer = isPlayer ? 0 : 2.0; this.moveTarget = new THREE.Vector3(x, 1, z);
    }
    get dataLevel() { return Math.floor(this.data / 100); }
    isDataSuperior(opponent) { return this.data > opponent.data; }
    getAvailableArm() { return this.arms.find(a => a.hp > 0 && !bits.find(b => b.owner === this && b.armId === a.id)); }

    checkCrafting() {
        this.maxArms = Math.min(4, 2 + Math.floor(this.data / 200));
        for (let arm of this.arms) {
            if (arm.hp <= 0 && this.materials >= 10) {
                this.materials -= 10; arm.hp = arm.maxHp;
                if (this.isPlayer) game.addMessage(`SYSTEM: [${arm.id}] REPAIRED.`);
                playSound('craft');
            }
        }
        while (this.arms.length < this.maxArms && this.materials >= 20) {
            this.materials -= 20; const newId = 'ARM' + (this.arms.length + 1);
            this.arms.push({ id: newId, hp: 100, maxHp: 100 });
            if (this.isPlayer) game.addMessage(`SYSTEM: [${newId}] BUILT.`);
            playSound('craft');
        }
    }

    fireBit(targetEntity, preCalcPath, armObj, customColor) {
        if (!targetEntity || targetEntity.hp <= 0 || !armObj) return;
        const startPos = this.pos.clone().add(new THREE.Vector3(0, 5, 0));
        let path = preCalcPath || generateMultiPaths(startPos, targetEntity.pos, this.data, targetEntity.data)[0].path;
        const bitColor = customColor || this.color;
        const bit = new Bit(startPos, path, bitColor, this, targetEntity, armObj.id);
        bits.push(bit); playSound('attack');
    }

    update(dt, opponents) {
        this.regenTickTimer += dt;
        if (this.regenTickTimer > 1.0) {
            this.regenTickTimer = 0;
            if (this.hp < this.maxHp && this.materials >= 1) {
                this.hp = Math.min(this.maxHp, this.hp + 10); this.materials -= 1;
                createFloatingText("+10 HP", this.pos.clone().add(new THREE.Vector3(0,4,0)), "#0f0");
            }
        }

        if (this.targetFacing.lengthSq() > 0.001) { this.facing.lerp(this.targetFacing, 0.12); this.facing.y = 0; this.facing.normalize(); }
        this.robotGroup.rotation.y = Math.atan2(this.facing.x, this.facing.z); this.robotGroup.position.copy(this.pos);

        this.shieldMesh.position.copy(this.pos).add(new THREE.Vector3(0, 4, 0));
        this.shieldMesh.visible = (this.state === 'guarding');
        if (this.state === 'guarding') this.shieldMesh.rotation.y += dt * 3;

        if (this.state === 'windup' || this.state === 'guarding') {
            this.afterImageTimer -= dt;
            if (this.afterImageTimer <= 0) { createAfterImage(this.robotGroup, this.color); this.afterImageTimer = 0.04; }
        } else { this.afterImageTimer = 0; }

        const movedDist = this.pos.distanceTo(this.lastPos);
        const actualSpeed = dt > 0 ? movedDist / dt : 0; this.lastPos.copy(this.pos);
        this.animateRobot(dt, actualSpeed);

        if (this.state === 'windup') {
            this.timer -= dt;
            if (this.timer <= 0) {
                this.state = 'attacking'; this.timer = 0.3;
                this.fireBit(this.attackTarget, this.currentAttackPath, this.activeArm, this.activeArmColor);
            }
        } else if (this.state === 'attacking' || this.state === 'guarding') {
            this.timer -= dt;
            if (this.timer <= 0) this.state = 'idle';
        }
    }

    getNearestOpponent(opponents) {
        if (!opponents || opponents.length === 0) return null;
        let nearest = null, minDist = Infinity;
        for (const op of opponents) {
            if (op.hp <= 0) continue;
            const d = this.pos.distanceTo(op.pos);
            if (d < minDist) { minDist = d; nearest = op; }
        }
        return nearest;
    }

    resolveAttack(defender) {
        if (!defender || defender.hp <= 0) return;
        let isHit = true;
        
        if (defender.state === 'guarding') {
            let guardChance = 0.5 + ((defender.data / 100) * 0.1) - ((this.data / 100) * 0.05);
            guardChance = Math.max(0.1, Math.min(1.0, guardChance));
            if (Math.random() < guardChance) {
                isHit = false; defender.data += 100;
                createFloatingText("GUARD!", defender.pos.clone().add(new THREE.Vector3(0,4,0)), "#00ffff");
            } else {
                createFloatingText("GUARD BREAK!", defender.pos.clone().add(new THREE.Vector3(0,4,0)), "#ff8800");
            }
        }

        if (isHit) {
            const dmg = 25; this.data += 100; 
            const aliveArms = defender.arms.filter(a => a.hp > 0);
            if (aliveArms.length > 0 && Math.random() < 0.4) {
                const hitArm = aliveArms[Math.floor(Math.random() * aliveArms.length)];
                hitArm.hp -= dmg;
                createFloatingText(`ARM HIT! -${dmg}`, defender.pos.clone().add(new THREE.Vector3(0,5,0)), "#ffaa00", "28px");
                playSound('crush');
                if (hitArm.hp <= 0) createFloatingText(`[${hitArm.id}] CRUSHED!`, defender.pos.clone().add(new THREE.Vector3(0,6,0)), "#ff0000", "32px");
            } else {
                defender.hp -= dmg; playSound('hit');
                createFloatingText(`-${dmg}`, defender.pos.clone().add(new THREE.Vector3(0,4,0)), "#ff0000", "36px");
            }
            createHitParticles(defender.pos.clone().add(new THREE.Vector3(0,3,0)), 0xff4400);
        }
        this.checkCrafting(); defender.checkCrafting(); game.updateUI();
    }

    animateRobot(dt, speed) {
        const isMoving = speed > 5; this.walkCycle += dt * (isMoving ? 15 : 4);
        const walkMag = isMoving ? 1.0 : 0.15;
        const zL = Math.sin(this.walkCycle) * 2.0 * walkMag; const zR = Math.sin(this.walkCycle + Math.PI) * 2.0 * walkMag;
        const ch = this.robotGroup.children;
        if (ch.length >= 15) {
            ch[0].position.y = 5.0 + Math.max(0, Math.sin(this.walkCycle * 2)) * 0.4 * walkMag; ch[1].rotation.y = zL * 0.05; 
            if (this.state !== 'windup' && this.state !== 'attacking' && this.state !== 'guarding') {
                ch[3].rotation.set(0, 0, Math.PI * 0.15 - zL * 0.25); ch[4].rotation.set(0, 0, Math.PI * 0.2 + (isMoving ? 0.3 : 0)); 
                ch[6].rotation.set(0, 0, -Math.PI * 0.15 - zR * 0.25); ch[7].rotation.set(0, 0, -Math.PI * 0.2 - (isMoving ? 0.3 : 0));
                ch[3].position.set(-2.5, 5.5, 0); ch[6].position.set(2.5, 5.5, 0);
            }
            ch[9].position.z = zL * 0.5;   ch[10].position.z = zL * 1.0;  ch[11].position.z = zL * 1.2 + 0.2; 
            ch[12].position.z = zR * 0.5;  ch[13].position.z = zR * 1.0;  ch[14].position.z = zR * 1.2 + 0.2; 
            ch[9].position.y = 1.8 + Math.max(0, zL * 0.2); ch[10].position.y = -0.3 + Math.max(0, zL * 0.4); ch[11].position.y = -1.6 + Math.max(0, zL * 0.5);
            ch[12].position.y = 1.8 + Math.max(0, zR * 0.2); ch[13].position.y = -0.3 + Math.max(0, zR * 0.4); ch[14].position.y = -1.6 + Math.max(0, zR * 0.5);
            ch[9].rotation.x = zL * 0.2; ch[10].rotation.x = zL * 0.3 + (isMoving && zL < 0 ? -0.5 : 0); ch[11].rotation.x = zL * 0.1;
            ch[12].rotation.x = zR * 0.2; ch[13].rotation.x = zR * 0.3 + (isMoving && zR < 0 ? -0.5 : 0); ch[14].rotation.x = zR * 0.1;
        }
        if (this.state === 'windup' || this.state === 'attacking') {
            if (ch.length >= 9) {
                const isLeft = (this.activeArm && parseInt(this.activeArm.id.replace('ARM','')) % 2 !== 0);
                if (isLeft || !this.activeArm) { ch[3].rotation.set(-Math.PI / 2, 0, Math.PI * 0.1); ch[4].rotation.set(0, 0, 0); ch[3].position.z = -1.5; }
                if (!isLeft || !this.activeArm) { ch[6].rotation.set(-Math.PI / 2, 0, -Math.PI * 0.1); ch[7].rotation.set(0, 0, 0); ch[6].position.z = -1.5; }
            }
        } else if (this.state === 'guarding') {
            if (ch.length >= 9) { ch[3].rotation.set(0, 0, Math.PI * 0.6); ch[6].rotation.set(0, 0, -Math.PI * 0.6); }
        }
    }

    startAttack(opponentList) { 
        if (this.state !== 'idle') return false;
        const arm = this.getAvailableArm(); if (!arm) return false;
        const target = this.getNearestOpponent(opponentList); if (!target) return false;

        const startPos = this.pos.clone().setY(5);
        const routes = generateMultiPaths(startPos, target.pos, this.data, target.data);
        this.executeAttack(arm, target, routes[0].path);
        return true;
    }

    executeAttack(armObj, target, preCalcPath, customColor) {
        this.targetFacing.subVectors(target.pos, this.pos).normalize(); this.targetFacing.y = 0;
        this.state = 'windup'; this.activeArm = armObj; this.timer = 0.2;
        this.currentAttackPath = preCalcPath; this.attackTarget = target;
        this.activeArmColor = customColor;
    }

    startGuard(attacker) {
        if (this.state !== 'idle' && this.state !== 'guarding') return;
        this.state = 'guarding'; this.timer = 0.5;
        if (attacker) { this.targetFacing.subVectors(attacker.pos, this.pos).normalize(); this.targetFacing.y = 0; }
        playSound('hit'); 
    }
}

// ==========================================
// 11. AI
// ==========================================
function runEnemyAI(enemy, player, dt) {
    if (enemy.hp <= 0) return;
    enemy.aiActionTimer -= dt; const dataLevel = enemy.dataLevel; const isSuperior = enemy.isDataSuperior(player);
    if (enemy.state !== 'idle') return;

    let attackInterval = isSuperior ? Math.max(2.0, 4.0 - dataLevel * 0.1) : Math.max(3.0, 5.0 - dataLevel * 0.1);
    if (enemy.aiActionTimer <= 0 && Math.random() < 0.03 + dataLevel * 0.005) {
        if (enemy.getAvailableArm()) { 
            if (isSuperior) { const predictedPos = player.pos.clone().addScaledVector(new THREE.Vector3(player.targetFacing.x, 0, player.targetFacing.z), 5); enemy.moveTarget.copy(predictedPos); }
            if (enemy.startAttack([player])) { enemy.aiActionTimer = attackInterval; return; }
        }
    }

    // 敵はdataを取得しないようにフィルタ
    const validFragments = fragments.filter(f => f.userData.type !== 'data');
    if (validFragments.length > 0) {
        let closest = validFragments[0], minDist = enemy.pos.distanceTo(closest.position);
        for (let i = 1; i < validFragments.length; i++) {
            let d = enemy.pos.distanceTo(validFragments[i].position);
            if (d < minDist) { minDist = d; closest = validFragments[i]; }
        }
        enemy.moveTarget.copy(closest.position);
    } else if (enemy.pos.distanceTo(enemy.moveTarget) < 5) {
        enemy.moveTarget.set((Math.random() - 0.5) * 80, 1, (Math.random() - 0.5) * 80);
    }

    const dir = new THREE.Vector3().subVectors(enemy.moveTarget, enemy.pos);
    if (dir.lengthSq() > 0.1) {
        dir.y = 0; dir.normalize(); enemy.targetFacing.copy(dir);
        const speed = 20 + dataLevel * 1.5;
        const oldX = enemy.pos.x; enemy.pos.x += dir.x * speed * dt;
        if (checkCollision(enemy.pos, enemy.radius)) enemy.pos.x = oldX;
        const oldZ = enemy.pos.z; enemy.pos.z += dir.z * speed * dt;
        if (checkCollision(enemy.pos, enemy.radius)) enemy.pos.z = oldZ;
        enemy.pos.x = Math.max(-45, Math.min(45, enemy.pos.x)); enemy.pos.z = Math.max(-45, Math.min(45, enemy.pos.z));
    }
}

// ==========================================
// 12. 初期化
// ==========================================
const player = new Entity(0x00ffaa, 0, 20, true);

function createEnemyAt(x, z) {
    const e = new Entity(0xff2222, x, z, false);
    e.data = Math.floor(Math.random() * 200) + (currentStage * 150); 
    e.materials = Math.floor(Math.random() * 30); e.checkCrafting(); 
    e.moveTarget = new THREE.Vector3(x, 1, z); e.aiActionTimer = 2.0 + Math.random() * 2.0; 
    return e;
}

function initStage() {
    player.hp = player.maxHp; player.pos.set(0, 1, 20); player.lastPos.copy(player.pos);
    player.state = 'idle'; 
    player.facing.set(0, 0, -1); player.targetFacing.set(0, 0, -1); player.robotGroup.position.copy(player.pos);

    clearRouteLines();
    playerTactics.selectedIndex = null;

    for (const e of enemies) scene.remove(e.robotGroup); enemies.length = 0;
    for (const b of bits) b.destroy(); bits.length = 0;

    const enemyCount = currentStage;
    const spawnPositions = [[0, -20], [-15, -15], [15, -15], [-20, -25], [20, -25]];
    for (let i = 0; i < enemyCount && i < 5; i++) enemies.push(createEnemyAt(spawnPositions[i][0], spawnPositions[i][1]));

    fragments.forEach(f => scene.remove(f)); fragments.length = 0;
    generateBlocks();

    game.messages = ["SYSTEM: STAGE " + currentStage + " START"]; game.updateUI();
}

// ==========================================
// 13. 入力管理 (リアルタイムアクション)
// ==========================================
const keys = {};
window.addEventListener('keydown', e => { 
    if(["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].indexOf(e.code) > -1) e.preventDefault();
    if(audioCtx.state === 'suspended') audioCtx.resume();
    
    if (e.code === 'Escape') {
        if (gameState === 'PLAYING') switchState('PAUSE');
        else if (gameState === 'PAUSE') switchState('PLAYING');
        return;
    }
    
    if (!keys[e.code]) { keys[e.code] = true; handleInput(e.code); }
});
window.addEventListener('keyup', e => { keys[e.code] = false; });

function handleInput(code) {
    if (gameState !== 'PLAYING') return;
    
    let armIndex = null;
    if (code === 'KeyZ') armIndex = 0;
    else if (code === 'KeyX') armIndex = 1;
    else if (code === 'KeyC') armIndex = 2;
    else if (code === 'KeyV') armIndex = 3;
    
    if (armIndex === null) return;
    
    const armObj = player.arms[armIndex];
    if (!armObj || armObj.hp <= 0) { game.addMessage("SYSTEM: 兵装が使用不可です。"); return; }
    if (bits.find(b => b.owner === player && b.armId === armObj.id)) return; // 使用中
    
    if (!playerTactics.routes[armIndex]) return; // パスがない
    
    if (playerTactics.selectedIndex === null) {
        // 未選択状態 -> 選択
        playerTactics.selectedIndex = armIndex;
        playSound('menu');
    } else {
        // 既に選択状態
        let executeIndex = armIndex; 
        const execArm = player.arms[executeIndex];
        
        // 選択したアームが使えない場合は選択状態を切り替えるだけ
        if (!execArm || execArm.hp <= 0 || bits.find(b => b.owner === player && b.armId === execArm.id)) {
            playerTactics.selectedIndex = armIndex;
            playSound('menu');
            return;
        }
        
        // 発射実行
        executePlayerAction(executeIndex);
        playerTactics.selectedIndex = null; // 選択解除
    }
    game.updateUI();
}

function executePlayerAction(armIndex) {
    if (player.state !== 'idle' && player.state !== 'guarding') return;
    
    const armObj = player.arms[armIndex];
    const routeInfo = playerTactics.routes[armIndex];
    
    if (playerTactics.interceptBit) {
        // 迎撃
        player.startGuard(playerTactics.target);
        const path = routeInfo.path;
        const interceptBit = new Bit(player.pos.clone().add(new THREE.Vector3(0,5,0)), path, CMD_COLORS[armIndex], player, playerTactics.interceptBit, armObj.id);
        interceptBit.isInterceptor = true; bits.push(interceptBit);
        playSound('intercept');
        createFloatingText("INTERCEPT!", player.pos.clone().add(new THREE.Vector3(0,5,0)), CMD_COLORS_CSS[armIndex]);
    } else if (playerTactics.target) {
        // 攻撃
        player.executeAttack(armObj, playerTactics.target, routeInfo.path, CMD_COLORS[armIndex]);
    }
}

// ==========================================
// 14. アイテム生成
// ==========================================
setInterval(() => {
    if (gameState !== 'PLAYING') return;
    for (let k = 0; k < 5; k++) {
        const mesh = new THREE.Mesh(new THREE.OctahedronGeometry(1.2), new THREE.MeshBasicMaterial({ color: 0x00ffff, wireframe: true }));
        let valid = false, pos = new THREE.Vector3(); let tries = 0;
        while (!valid && tries < 20) {
            pos.set((Math.random() - 0.5) * 80, 1.5, (Math.random() - 0.5) * 80);
            if (!checkCollision(pos, 2.0)) valid = true; tries++;
        }
        if (!valid) continue;
        mesh.position.copy(pos); mesh.userData.type = 'data'; scene.add(mesh); fragments.push(mesh);
    }
}, 4000);

setInterval(() => {
    if (gameState !== 'PLAYING') return;
    for (let k = 0; k < 3; k++) {
        const mesh = new THREE.Mesh(new THREE.BoxGeometry(2,2,2), new THREE.MeshBasicMaterial({ color: 0xadff2f, wireframe: true }));
        let valid = false, pos = new THREE.Vector3(); let tries = 0;
        while (!valid && tries < 20) {
            pos.set((Math.random() - 0.5) * 80, 1.5, (Math.random() - 0.5) * 80);
            if (!checkCollision(pos, 2.0)) valid = true; tries++;
        }
        if (!valid) continue;
        mesh.position.copy(pos); mesh.userData.type = 'material'; scene.add(mesh); fragments.push(mesh);
    }
}, 4500);

// ==========================================
// 15. 更新ロジック
// ==========================================
function updateLogic(dt) {
    const aliveEnemies = enemies.filter(e => e.hp > 0);
    const promptEl = document.getElementById('centerPrompt');

    // 戦術ルート更新 (約0.1秒ごと)
    playerTactics.updateTimer -= dt;
    if (playerTactics.updateTimer <= 0) {
        playerTactics.updateTimer = 0.1;
        const incomingBits = bits.filter(b => b.targetObj === player && b.bitState === 'OUTBOUND');
        incomingBits.sort((a,b) => a.pos.distanceToSq(player.pos) - b.pos.distanceToSq(player.pos));
        const currentIntercept = (incomingBits.length > 0 && incomingBits[0].pos.distanceTo(player.pos) < 35) ? incomingBits[0] : null;
        const currentTarget = player.getNearestOpponent(aliveEnemies);
        
        updatePlayerTrajectories(currentIntercept, currentTarget);
    }
    // 描画とアニメーションは毎フレーム
    drawPlayerTrajectories();

    if (playerTactics.selectedIndex !== null) {
        promptEl.innerText = "COMMAND SELECTED. PRESS AGAIN TO EXECUTE.";
        promptEl.className = 'locked-on';
    } else if (playerTactics.interceptBit) {
        promptEl.innerText = "WARNING: ENEMY ATTACK DETECTED!";
        promptEl.className = 'obstructed';
    } else if (player.state === 'idle' && aliveEnemies.length > 0 && player.getAvailableArm()) {
        promptEl.innerText = "STANDBY - CHOOSE COMMAND [Z/X/C/V]";
        promptEl.className = 'locked-on';
    } else {
        promptEl.innerText = "";
        promptEl.className = '';
    }

    // 移動制御
    let dx = (keys['ArrowRight'] ? 1 : 0) - (keys['ArrowLeft'] ? 1 : 0);
    let dz = (keys['ArrowDown'] ? 1 : 0) - (keys['ArrowUp'] ? 1 : 0);
    
    if (dx !== 0 || dz !== 0) {
        const moveDir = new THREE.Vector3(dx, 0, dz).normalize();
        player.targetFacing.copy(moveDir);
        const move = moveDir.clone().multiplyScalar(35 * dt);
        
        const oldX = player.pos.x;
        player.pos.x = Math.max(-45, Math.min(45, player.pos.x + move.x));
        if(checkCollision(player.pos, player.radius)) player.pos.x = oldX;

        const oldZ = player.pos.z;
        player.pos.z = Math.max(-45, Math.min(45, player.pos.z + move.z));
        if(checkCollision(player.pos, player.radius)) player.pos.z = oldZ;

        if (player.state === 'guarding') player.state = 'idle';
    }

    for (const e of enemies) if (e.hp > 0) runEnemyAI(e, player, dt);
    player.update(dt, aliveEnemies);
    for (const e of enemies) e.update(dt, [player]);

    updateBits(dt); updateParticles(dt); updateAfterImages(dt);

    for (let i = fragments.length - 1; i >= 0; i--) {
        fragments[i].rotation.y += dt * 2; fragments[i].rotation.x += dt;
        if (player.pos.distanceTo(fragments[i].position) < 5) {
            collectItem(player, fragments[i]); scene.remove(fragments[i]); fragments.splice(i, 1); continue;
        }
        let collected = false;
        for (const e of aliveEnemies) {
            if (e.pos.distanceTo(fragments[i].position) < 5) {
                // 敵は 'data' を取得しない
                if (fragments[i].userData.type === 'data') continue;
                
                collectItem(e, fragments[i]); scene.remove(fragments[i]); fragments.splice(i, 1);
                collected = true; break;
            }
        }
    }

    updateFloatingTexts(dt); updateTargetHud(); game.updateUI();
    
    if (player.hp <= 0 && gameState === 'PLAYING') {
        game.addMessage("SYSTEM: 機体大破。GAME OVER"); switchState('GAME_OVER');
    } else if (aliveEnemies.length === 0 && enemies.length > 0 && gameState === 'PLAYING') {
        accumulatedEnemyData = enemies.reduce((sum, e) => sum + e.data, 0);
        accumulatedEnemyMat = enemies.reduce((sum, e) => sum + e.materials, 0);
        game.addMessage("SYSTEM: 全敵AI沈黙。"); switchState('STAGE_CLEAR');
        setTimeout(() => { currentStage++; if (currentStage > maxStages) switchState('GAME_CLEAR'); else switchState('STAGE_START'); }, 3000);
    }
}

function collectItem(collector, itemMesh) {
    if (itemMesh.userData.type === 'data') {
        collector.data += 10; createFloatingText("+DATA 10", collector.pos.clone().add(new THREE.Vector3(0,4,0)), "#00ffff");
    } else if (itemMesh.userData.type === 'material') {
        collector.materials += 10; createFloatingText("+MATERIAL 10", collector.pos.clone().add(new THREE.Vector3(0,4,0)), "#adff2f");
    }
    if (collector.isPlayer) collector.checkCrafting();
}

// ==========================================
// 16. メインループ
// ==========================================
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);
    const dt = Math.min(clock.getDelta(), 0.05); 
    
    if (gameState === 'PLAYING') updateLogic(dt);
    else if (gameState !== 'PAUSE') { updateFloatingTexts(dt); updateAfterImages(dt); updateParticles(dt); }
    
    renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

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

ピックアップされています

気楽に遊べるゲーム

  • 25本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
ロボットミニゲーム「Wire Arms」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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