ブラウザで遊べるドライブゲームImageBooster

【更新機歴】

・2025/12/22 バージョン3.0公開。
・2025/12/24 バージョン4.0公開。
・2025/12/26 バージョン5.0公開。…トンネルの外側を追加。
・2026/1/4 バージョン6.0公開。…ブーストにロックオンを追加。

画像
トンネルの内側を走行中のようす(三人称視点時)
画像
フラットモード
画像
トンネルの外側を走行中のようす(三人称視点時)



・テキストファイルを新規作成して、(index.html)
 以下のソースコードをコピペして保存し、ファイルをクリックすると、
 webブラウザが起動してゲームが遊べます。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Image Booster 6.0 (Lock-on Rush)</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; font-family: 'Arial Black', sans-serif; }
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; color: white; text-shadow: 2px 2px 4px #000; z-index: 10; }
        
        #view-mode { position: absolute; top: 20px; right: 20px; font-size: 20px; color: #0f0; }
        #stage-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); font-size: 20px; color: #ff0; }
        #score-info { position: absolute; top: 20px; left: 20px; font-size: 24px; color: #fff; }

        #speed-lines {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            background: radial-gradient(circle, transparent 40%, rgba(255, 255, 255, 0.1) 80%),
                        repeating-conic-gradient(transparent 0deg, transparent 2deg, rgba(100, 255, 255, 0.3) 2.1deg, transparent 3deg);
            opacity: 0;
            transition: opacity 0.1s ease-out;
            pointer-events: none;
            z-index: 5;
            mix-blend-mode: screen;
        }

        /* ロックオンサイト用のスタイル */
        .lock-on-sight {
            position: absolute;
            width: 40px; height: 40px;
            border: 3px solid #0f0;
            border-radius: 50%;
            transform: translate(-50%, -50%);
            box-shadow: 0 0 10px #0f0, inset 0 0 10px #0f0;
            z-index: 8;
            transition: all 0.1s;
        }
        .lock-on-sight.locked {
            border-color: #f00;
            box-shadow: 0 0 15px #f00, inset 0 0 15px #f00;
            background: rgba(255, 0, 0, 0.2);
            width: 50px; height: 50px;
        }

        #hud-bottom { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 85%; display: none; flex-direction: row; justify-content: space-around; align-items: flex-end; }
        .gauge-container { width: 30%; text-align: center; }
        .gauge-label { font-size: 18px; margin-bottom: 5px; }
        .bar-bg { width: 100%; height: 12px; border: 2px solid #fff; border-radius: 6px; overflow: hidden; background: rgba(0,0,0,0.5); transform: skewX(-20deg); }
        .bar-fill { height: 100%; width: 100%; transition: width 0.05s ease-out; }

        #hp-fill { background: #adff2f; box-shadow: 0 0 10px #adff2f; }
        
        #speed-val { font-size: 56px; line-height: 1; color: #00ffff; text-shadow: 0 0 15px #00ffff; font-style: italic; }
        #speed-unit { font-size: 20px; color: #aaa; margin-left: 5px; }
        #speed-bg { }
        #speed-fill { background: #00ffff; }
        
        #time-bg { }
        #time-fill { background: #ffcc00; }

        #center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; }
        h1 { font-size: 70px; margin: 0; color: #0f0; text-shadow: 0 0 30px #0f0; font-style: italic; letter-spacing: -2px; }
        p { font-size: 24px; margin: 10px 0; color: #fff; }
        .pop-text { position: absolute; font-weight: bold; font-size: 40px; animation: moveUpFade 0.8s forwards; transform: translate(-50%, -50%); z-index: 999; text-shadow: 0 0 10px white; }
        @keyframes moveUpFade { 0% { transform: translate(-50%, 0) scale(1); opacity: 1; } 100% { transform: translate(-50%, -100px) scale(1.5); opacity: 0; } }
    </style>
    <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } } </script>
</head>
<body>
    <div id="speed-lines"></div>
    <div id="ui-layer">
        <div id="score-info">SCORE: 0</div>
        <div id="stage-info">STAGE: 1 / 8</div>
        <div id="view-mode">VIEW: 3RD PERSON</div>
        <div id="hud-bottom">
            <div class="gauge-container">
                <div class="gauge-label">SHIELD</div>
                <div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div>
            </div>
            <div class="gauge-container">
                <div style="margin-bottom:5px;">
                    <span id="speed-val">0</span><span id="speed-unit">km/h</span>
                </div>
                <div class="bar-bg" id="speed-bg"><div id="speed-fill" class="bar-fill"></div></div>
            </div>
            <div class="gauge-container">
                <div class="gauge-label">TIME: <span id="time-num" style="font-size: 32px;">1:00</span></div>
                <div class="bar-bg" id="time-bg"><div id="time-fill" class="bar-fill"></div></div>
            </div>
        </div>
        <div id="center-text"></div>
    </div>

    <script type="module">
        import * as THREE from 'three';

        // --- Sound Manager ---
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let noiseBuffer = null;
        let windSource = null, windGain = null, windFilter = null;
        let jetOsc = null, jetGain = null;

        function createNoiseBuffer() {
            const bufferSize = audioCtx.sampleRate * 2.0; 
            const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
            const data = buffer.getChannelData(0);
            for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
            return buffer;
        }

        const sound = {
            init: () => { if (!noiseBuffer) noiseBuffer = createNoiseBuffer(); },
            suspend: () => { if(audioCtx.state === 'running') audioCtx.suspend(); },
            resume: () => { if(audioCtx.state === 'suspended') audioCtx.resume(); },
            startEngine: () => {
                if (windSource) return;
                windSource = audioCtx.createBufferSource();
                windSource.buffer = noiseBuffer;
                windSource.loop = true;
                windFilter = audioCtx.createBiquadFilter();
                windFilter.type = 'bandpass';
                windFilter.Q.value = 1.0;
                windFilter.frequency.value = 400;
                windGain = audioCtx.createGain();
                windGain.gain.value = 0;
                windSource.connect(windFilter); windFilter.connect(windGain); windGain.connect(audioCtx.destination);
                windSource.start();

                jetOsc = audioCtx.createOscillator();
                jetOsc.type = 'sawtooth';
                jetOsc.frequency.value = 800;
                jetGain = audioCtx.createGain();
                jetGain.gain.value = 0;
                jetOsc.connect(jetGain); jetGain.connect(audioCtx.destination);
                jetOsc.start();
            },
            updateEngine: (speedRatio, isBoosting) => {
                if (!windSource) return;
                const now = audioCtx.currentTime;
                const targetFreq = 200 + (speedRatio * 1500);
                const targetVol = Math.min(0.2, speedRatio * 0.15); 
                windFilter.frequency.setTargetAtTime(targetFreq, now, 0.1);
                windGain.gain.setTargetAtTime(targetVol, now, 0.1);

                const jetVol = isBoosting ? 0.08 : 0.01;
                const jetFreq = 600 + (speedRatio * 2500); 
                jetGain.gain.setTargetAtTime(jetVol, now, 0.2);
                jetOsc.frequency.setTargetAtTime(jetFreq, now, 0.1);
            },
            play: (type) => {
                if (audioCtx.state === 'suspended' && type !== 'ui') audioCtx.resume();
                const now = audioCtx.currentTime;
                
                if (type === 'crash') {
                    const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer;
                    const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; 
                    f.frequency.setValueAtTime(800, now); f.frequency.exponentialRampToValueAtTime(100, now + 1.2);
                    const g = audioCtx.createGain(); g.gain.setValueAtTime(1.0, now); g.gain.exponentialRampToValueAtTime(0.01, now + 1.2);
                    src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 1.2);
                } else if (type === 'heal') {
                    const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
                    osc.connect(g); g.connect(audioCtx.destination); osc.type = 'sine';
                    osc.frequency.setValueAtTime(600, now); osc.frequency.exponentialRampToValueAtTime(2000, now+0.4);
                    g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0, now+0.4);
                    osc.start(now); osc.stop(now+0.4);
                } else if (type === 'lockon') {
                    const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
                    osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square';
                    osc.frequency.setValueAtTime(800, now); 
                    g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1);
                    osc.start(now); osc.stop(now+0.1);
                } else if (type === 'boost_dash') {
                    const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer;
                    const f = audioCtx.createBiquadFilter(); f.type = 'highpass';
                    f.frequency.setValueAtTime(500, now); f.frequency.linearRampToValueAtTime(2000, now+0.5);
                    const g = audioCtx.createGain();
                    g.gain.setValueAtTime(0.5, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.5);
                    src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.5);
                } else if (type === 'jump') {
                    const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
                    osc.connect(g); g.connect(audioCtx.destination); osc.type = 'triangle';
                    osc.frequency.setValueAtTime(150, now); osc.frequency.linearRampToValueAtTime(300, now+0.2);
                    g.gain.setValueAtTime(0.8, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.3); 
                    osc.start(now); osc.stop(now+0.3);
                } else if (type === 'land') {
                    const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer;
                    const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(300, now);
                    const g = audioCtx.createGain(); g.gain.setValueAtTime(1.5, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.2); 
                    src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now+0.2);
                } else if (type === 'coin') {
                    const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
                    osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square';
                    osc.frequency.setValueAtTime(1200, now); osc.frequency.setValueAtTime(1800, now+0.05);
                    g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1);
                    osc.start(now); osc.stop(now+0.1);
                } else if (type === 'ui') {
                    if(audioCtx.state === 'suspended') audioCtx.resume();
                    const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
                    osc.connect(g); g.connect(audioCtx.destination); osc.frequency.setValueAtTime(880, now);
                    g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1);
                    osc.start(now); osc.stop(now+0.1);
                }
            }
        };

        // --- Config ---
        const MAX_HP = 10, TUBE_R = 18, MAX_TIME = 60;
        
        const NORMAL_MAX_SPEED = 3.8;  
        // Rush speed is handled differently (position lerp/step)
        const RUSH_SPEED = 15.0; 
        const NORMAL_ACCEL = 0.08;
        const TOTAL_STAGES = 8;
        
        const VISIBLE_SEGMENTS = 90;
        const FOG_NEAR = 150;
        const FOG_FAR = 700;
        
        const SPEED_DISPLAY_MULTIPLIER = 53;

        let scene, camera, renderer;
        let gameState = "TITLE", viewMode = "F3"; 
        let isPaused = false;
        let score = 0, stageScore = 0; 
        let hp = MAX_HP, timeLeft = MAX_TIME;
        let currentStage = 1;
        
        // Player state extensions
        let player = { 
            mesh: null, 
            angle: 0, 
            x: 0, 
            z: 0, 
            vz: 0.1, vAngle: 0, vx: 0, 
            altitude: 0, jumpV: 0,
            surge: 0, bank: 0,
            isBoosting: false,     // Rendering purpose mainly
            dashOffset: 0, 
            wasAirborne: false,
            barrier: null, 
            shadow: null,
            sonicBoom: null, 
            barrierRadius: 3.5,
            
            // New for Lock-on Rush
            mode: "NORMAL", // "NORMAL", "LOCKING", "RUSHING"
            lockTargets: [], // Array of objects to rush
            rushIndex: 0     // Current target index in rush
        };
        
        let worldObjects = [], debris = [], stars;
        let keys = {};
        let nextSpawnZ = 150;

        // --- Tunnel & Curve Logic ---
        const TILE_SEGMENT_LENGTH = 8; 
        const TILES_PER_RING = 16;
        let tunnelSegments = [];       
        
        function getCurve(z, mode) {
            const coarseX = Math.sin(z * 0.0015) * 200;
            const coarseY = Math.cos(z * 0.0015) * 150;
            const detailY = Math.sin(z * 0.006) * 80 + Math.sin(z * 0.015) * 30;
            const detailX = Math.cos(z * 0.004) * 60; 

            if (mode === "F2") {
                return new THREE.Vector3(0, coarseY + detailY, z);
            }
            return new THREE.Vector3(coarseX + detailX, coarseY + detailY, z);
        }

        function getBasis(z, mode) {
            const origin = getCurve(z, mode);
            const forwardPoint = getCurve(z + 4.0, mode); 
            const T = new THREE.Vector3().subVectors(forwardPoint, origin).normalize();
            const tempUp = new THREE.Vector3(0, 1, 0);
            const R = new THREE.Vector3().crossVectors(T, tempUp).normalize();
            const U = new THREE.Vector3().crossVectors(R, T).normalize();
            return { origin, T, U, R };
        }

        function getSectionPosition(angle, radius, basis, mode) {
            let a = angle;
            while (a > Math.PI) a -= Math.PI * 2;
            while (a < -Math.PI) a += Math.PI * 2;

            if (mode === "F2") {
                const spread = TUBE_R * Math.PI; 
                const localX = (a / Math.PI) * spread;
                const localY = -radius; 

                return new THREE.Vector3(
                    basis.origin.x + localX, 
                    basis.origin.y + localY, 
                    basis.origin.z
                );
            } 
            
            const localX = Math.sin(angle) * radius;
            const localY = -Math.cos(angle) * radius;
            
            return basis.origin.clone()
                .addScaledVector(basis.R, localX)
                .addScaledVector(basis.U, localY);
        }

        function getGrassColor() {
            const hue = 0.45 + (Math.random() * 0.1); 
            const sat = 0.6;  
            const val = 0.15 + (Math.random() * 0.1);  
            return new THREE.Color().setHSL(hue, sat, val);
        }

        function isGap(segmentIndex) {
            if (segmentIndex < 15) return false; 
            return (segmentIndex % 60) >= 57; 
        }

        function init() {
            sound.init();
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x050510);
            scene.fog = new THREE.Fog(0x050510, FOG_NEAR, FOG_FAR);

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

            camera = new THREE.PerspectiveCamera(85, window.innerWidth / window.innerHeight, 0.1, 2000);
            
            scene.add(new THREE.AmbientLight(0x404040, 0.5));
            const sun = new THREE.DirectionalLight(0xffffff, 1.2);
            sun.position.set(0, 100, -50);
            scene.add(sun);
            const rim = new THREE.DirectionalLight(0x00ffff, 0.8);
            rim.position.set(0, -50, 50);
            scene.add(rim);

            createStars();

            // --- Player Model ---
            const playerGeo = new THREE.Group();
            
            const bodyGeo = new THREE.ConeGeometry(1.5, 6, 4);
            const bodyMat = new THREE.MeshPhongMaterial({ color: 0xdddddd, specular: 0xffffff, shininess: 50 });
            const body = new THREE.Mesh(bodyGeo, bodyMat);
            body.rotation.x = -Math.PI / 2;
            body.scale.set(1, 1, 0.5);
            playerGeo.add(body);

            const wingGeo = new THREE.BoxGeometry(5, 0.2, 2);
            const wingMat = new THREE.MeshPhongMaterial({ color: 0x00aaff });
            const wing = new THREE.Mesh(wingGeo, wingMat);
            wing.position.set(0, 0, 1);
            playerGeo.add(wing);

            const thrustGeo = new THREE.ConeGeometry(0.8, 8, 8);
            const thrustMat = new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.7 });
            const thrust = new THREE.Mesh(thrustGeo, thrustMat);
            thrust.rotation.x = Math.PI / 2;
            thrust.position.z = -3.5;
            playerGeo.add(thrust);
            player.thrust = thrust;

            const barrierGeo = new THREE.IcosahedronGeometry(3.5, 1);
            const barrierMat = new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3, wireframe: true, emissive: 0x00aaaa, shininess: 100 });
            const barrier = new THREE.Mesh(barrierGeo, barrierMat);
            barrier.position.z = 1.0; 
            playerGeo.add(barrier);
            player.barrier = barrier;

            // Sonic Boom Mesh
            const boomGeo = new THREE.SphereGeometry(4.0, 32, 16, 0, Math.PI * 2, 0, Math.PI * 0.5);
            const boomMat = new THREE.MeshBasicMaterial({ 
                color: 0xffffff, 
                transparent: true, 
                opacity: 0.4, 
                side: THREE.DoubleSide,
                blending: THREE.AdditiveBlending 
            });
            const boom = new THREE.Mesh(boomGeo, boomMat);
            boom.rotation.x = -Math.PI / 2; 
            boom.position.z = 3.0; 
            boom.visible = false;
            playerGeo.add(boom);
            player.sonicBoom = boom;

            player.mesh = playerGeo;
            scene.add(player.mesh);

            const shadowGeo = new THREE.PlaneGeometry(2.5, 4.0);
            const shadowMat = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.9 });
            player.shadow = new THREE.Mesh(shadowGeo, shadowMat);
            scene.add(player.shadow);

            initTunnel();

            window.addEventListener('keydown', (e) => {
                const views = ["F1", "F2", "F3", "F4", "F5"];
                if(views.includes(e.code)) {
                    e.preventDefault();
                    viewMode = e.code;
                    updateViewLabel();
                }
                keys[e.code] = true;
                
                if (e.code === 'Escape' && gameState === "PLAYING") {
                    isPaused = !isPaused;
                    if(isPaused) {
                        sound.suspend();
                        document.getElementById('center-text').innerHTML = "<h1 style='color:white; text-shadow:0 0 10px #000;'>PAUSED</h1>";
                    } else {
                        sound.resume();
                        document.getElementById('center-text').innerHTML = "";
                    }
                }

                if(e.code === 'Space') { e.preventDefault(); handleSpace(); }
                if(e.code === 'KeyX') handleJump();
                
                // Z Key Logic for Lock-on Rush
                if(e.code === 'KeyZ' && gameState === "PLAYING" && !isPaused) {
                    if (player.mode === "NORMAL") {
                        player.mode = "LOCKING";
                        player.lockTargets = [];
                        // Play charge sound?
                    }
                }
            });
            window.addEventListener('keyup', (e) => { 
                keys[e.code] = false; 
                
                if (e.code === 'KeyZ' && gameState === "PLAYING" && !isPaused) {
                    if (player.mode === "LOCKING") {
                        if (player.lockTargets.length > 0) {
                            player.mode = "RUSHING";
                            player.rushIndex = 0;
                            player.isBoosting = true;
                            sound.play('boost_dash');
                        } else {
                            player.mode = "NORMAL"; // No targets
                        }
                    }
                }
            });
            
            updateViewLabel();
            changeState("TITLE");
            animate();
        }

        function initTunnel() {
            const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
            const boxGeo = new THREE.BoxGeometry(tileWidth * 1.2, 0.5, TILE_SEGMENT_LENGTH * 1.1);
            for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
                const segmentGroup = new THREE.Group();
                segmentGroup.userData = { zIndex: i }; 
                for (let j = 0; j < TILES_PER_RING; j++) {
                    const mat = new THREE.MeshPhongMaterial({ 
                        color: getGrassColor(),
                        side: THREE.DoubleSide
                    });
                    const tile = new THREE.Mesh(boxGeo, mat);
                    segmentGroup.add(tile);
                }
                tunnelSegments.push(segmentGroup);
                scene.add(segmentGroup);
            }
        }

        function updateTunnel() {
            const playerSegmentIndex = Math.floor(player.z / TILE_SEGMENT_LENGTH);
            const startSegment = playerSegmentIndex - 8; 

            tunnelSegments.forEach((seg) => {
                let segIdx = seg.userData.zIndex;
                
                if (segIdx < startSegment) {
                    segIdx += VISIBLE_SEGMENTS;
                    seg.userData.zIndex = segIdx;
                    seg.children.forEach(tile => {
                        tile.material.color.copy(getGrassColor());
                        if(Math.random() < 0.05) tile.material.emissive.setHex(0x222222);
                        else tile.material.emissive.setHex(0x000000);
                    });
                }

                const gap = isGap(segIdx);
                seg.visible = !gap;

                const zPos = segIdx * TILE_SEGMENT_LENGTH;
                const basis = getBasis(zPos, viewMode);
                
                seg.children.forEach((tile, j) => {
                    let angle;
                    if (viewMode === "F2") {
                        const ratio = j / TILES_PER_RING; 
                        angle = (ratio * Math.PI * 2) - Math.PI; 
                    } else {
                        angle = (j / TILES_PER_RING) * Math.PI * 2;
                    }

                    const pos = getSectionPosition(angle, TUBE_R, basis, viewMode);
                    tile.position.copy(pos);

                    if (viewMode === "F2") {
                        const forward = basis.T.clone(); 
                        const right = new THREE.Vector3(1, 0, 0); 
                        const up = new THREE.Vector3().crossVectors(right, forward).normalize();
                        const orthoForward = new THREE.Vector3().crossVectors(up, right).normalize();
                        
                        const m = new THREE.Matrix4();
                        m.makeBasis(right, up, orthoForward);
                        tile.rotation.setFromRotationMatrix(m);

                    } else {
                        const isOutside = (viewMode === "F4" || viewMode === "F5");
                        const forward = basis.T.clone();
                        
                        const tileLocalX = Math.sin(angle);
                        const tileLocalY = -Math.cos(angle);
                        const normal = new THREE.Vector3()
                            .addScaledVector(basis.R, tileLocalX)
                            .addScaledVector(basis.U, tileLocalY)
                            .normalize();
                        
                        const up = isOutside ? normal : normal.clone().multiplyScalar(-1);
                        const tangent = new THREE.Vector3().crossVectors(up, forward).normalize();
                        const orthoForward = new THREE.Vector3().crossVectors(tangent, up).normalize();
                        
                        const m = new THREE.Matrix4();
                        m.makeBasis(tangent, up, orthoForward);
                        tile.rotation.setFromRotationMatrix(m);
                    }
                });
            });
        }

        function createStars() {
            const starGeo = new THREE.BufferGeometry();
            const starCount = 5000;
            const posArray = new Float32Array(starCount * 3);
            for(let i=0; i<starCount * 3; i++) posArray[i] = (Math.random() - 0.5) * 3000; 
            starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
            const starMat = new THREE.PointsMaterial({color: 0xffffff, size: 2.0, transparent: true, opacity: 0.8});
            stars = new THREE.Points(starGeo, starMat);
            scene.add(stars);
        }

        function updateStars() {
            if(stars) {
                stars.position.set(0, 0, player.z); 
                stars.rotation.z += 0.0005; 
            }
        }

        function updateViewLabel() {
            const labels = { 
                "F1": "1ST PERSON (INNER)", 
                "F2": "FLAT MODE (FIXED)", 
                "F3": "3RD PERSON (INNER)",
                "F4": "1ST PERSON (OUTER)",
                "F5": "3RD PERSON (OUTER)"
            };
            document.getElementById('view-mode').innerText = "VIEW: " + labels[viewMode];
        }

        function handleSpace() {
            if (gameState === "TITLE" || gameState === "GAMEOVER" || gameState === "ALL_CLEAR") {
                sound.play('ui');
                sound.startEngine(); 
                if(gameState !== "TITLE") { score = 0; currentStage = 1; }
                changeState("STAGE_START");
            }
            else if (gameState === "STAGE_CLEAR") { 
                sound.play('ui'); 
                currentStage++;
                changeState("STAGE_START"); 
            }
            else if (gameState === "STAGE_START") { sound.play('ui'); changeState("PLAYING"); }
        }

        function handleJump() {
            if (gameState === "PLAYING" && player.altitude <= 0.01 && !isPaused) {
                sound.play('jump');
                player.jumpV = 1.5; 
                player.wasAirborne = true;
            }
        }

        function changeState(s) {
            gameState = s;
            document.getElementById('hud-bottom').style.display = (s === "PLAYING") ? "flex" : "none";
            const menu = document.getElementById('center-text');
            document.getElementById('stage-info').innerText = `STAGE: ${currentStage} / ${TOTAL_STAGES}`;
            document.getElementById('speed-lines').style.opacity = 0;

            if(s === "TITLE") menu.innerHTML = "<h1>Image Booster 6.0</h1><p style='color:#adff2f;'>LOCK-ON RUSH</p><p>PRESS SPACE TO START</p><p style='font-size:18px; color:#aaa;'>[UP]: ACCEL / [DOWN]: BRAKE / [Z]: LOCK & RUSH / [X]: JUMP</p>";
            else if(s === "STAGE_START") { 
                stageScore = 0; 
                menu.innerHTML = "<h1>STAGE "+currentStage+"</h1><p>READY?</p><p style='font-size:16px; color:#aaa;'>SPACE: GO!</p>"; 
                resetPlayerPos(); 
            }
            else if(s === "PLAYING") menu.innerHTML = "";
            else if(s === "STAGE_CLEAR") {
                if (currentStage >= TOTAL_STAGES) {
                    changeState("ALL_CLEAR");
                } else {
                    menu.innerHTML = "<h1 style='color:#0f0;'>STAGE CLEAR!</h1><p>SCORE: "+stageScore+"</p><p>SPACE FOR NEXT STAGE</p>"; 
                }
            }
            else if(s === "ALL_CLEAR") {
                menu.innerHTML = "<h1 style='color:#0ff;'>COMPLETE!</h1><p>FINAL SCORE: "+score+"</p><p>YOU ARE THE CHAMPION</p><p>SPACE TO TITLE</p>";
            }
            else if(s === "GAMEOVER") menu.innerHTML = "<h1 style='color:red;'>CRITICAL FAILURE</h1><p>SCORE: "+score+"</p><p>SPACE TO RESTART</p>";
        }

        function resetPlayerPos() {
            player.angle = 0; player.x = 0; player.z = 0; player.vz = 0.0; player.altitude = 0; player.jumpV = 0;
            player.surge = 0; player.bank = 0; player.dashOffset = 0;
            hp = MAX_HP; timeLeft = MAX_TIME;
            
            player.mode = "NORMAL";
            player.lockTargets = [];
            player.isBoosting = false;
            
            worldObjects.forEach(obj => {
                scene.remove(obj.mesh);
                if(obj.sightDom) obj.sightDom.remove();
            });
            debris.forEach(d => scene.remove(d));
            worldObjects = []; debris = [];
            nextSpawnZ = 150;
            
            tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; });
        }

        function animate() {
            requestAnimationFrame(animate);
            if (gameState === "PLAYING" && !isPaused) {
                updatePhysics();
                updateObjects();
                updateDebris();
            }
            
            if(gameState === "PLAYING" && !isPaused) {
                let ratio = player.vz / NORMAL_MAX_SPEED;
                if(player.mode === "RUSHING") ratio = 3.0; // Higher pitch
                sound.updateEngine(ratio, player.isBoosting);
            } else if (!isPaused) {
                sound.updateEngine(0, false);
            }

            if(!isPaused) {
                updateTunnel(); 
                updatePlayerVisuals(); 
                updateStars();
            }
            renderer.render(scene, camera);
        }

        function updatePhysics() {
            // State Machine for Player Movement
            
            if (player.mode === "RUSHING") {
                handleRushPhysics();
                return; // Skip normal physics
            }

            // --- Normal & Locking Physics ---
            let targetMax = NORMAL_MAX_SPEED;
            let currentAccel = 0;
            
            // In LOCKING mode, maybe slight slowdown or normal? Keeping normal.
            
            if (keys['ArrowUp']) {
                currentAccel = NORMAL_ACCEL;
                player.surge = THREE.MathUtils.lerp(player.surge, 3.0, 0.05);
                player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 0.1);
            } 
            else {
                currentAccel = 0;
                player.surge = THREE.MathUtils.lerp(player.surge, 0, 0.05);
                player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 0.1);
            }

            if (keys['ArrowDown']) {
                player.vz *= 0.95; 
                player.surge = THREE.MathUtils.lerp(player.surge, -3.0, 0.1);
            }

            if (currentAccel > 0) {
                if (player.vz < targetMax) player.vz += currentAccel;
                else player.vz *= 0.99; 
            } else player.vz *= 0.99;
            player.vz = Math.max(0.0, player.vz); 

            // FX
            document.getElementById('speed-lines').style.opacity = 0; // Only rush has lines

            // Jump / Gravity
            handleGravity();
            
            // Steering
            handleSteering();

            player.z += player.vz;
            
            // --- Lock-on Logic ---
            if (player.mode === "LOCKING") {
                scanTargets();
            }

            updateGameTimers();
            updateUI();
        }

        function handleRushPhysics() {
            if (player.lockTargets.length === 0 || player.rushIndex >= player.lockTargets.length) {
                // Rush end
                player.mode = "NORMAL";
                player.isBoosting = false;
                player.surge = 0;
                return;
            }

            const targetObj = player.lockTargets[player.rushIndex];
            
            // ターゲットが存在しない(既に消滅など)場合スキップ
            if (!worldObjects.includes(targetObj)) {
                player.rushIndex++;
                return;
            }

            // Move player towards target
            // Instead of velocity, we use direct movement step to ensure hit
            const rushSpeed = RUSH_SPEED; // Units per frame
            
            // Calculate distance to target along track (approx by Z)
            // But we need to handle curve. Simplification: Move Z, interp Angle/Altitude
            
            const dz = targetObj.z - player.z;
            
            // 到達判定: 1フレームで移動できる距離より近いか?
            if (dz <= rushSpeed) {
                // Hit!
                player.z = targetObj.z;
                player.angle = targetObj.angle;
                
                // Destroy logic
                handleCollision(targetObj, worldObjects.indexOf(targetObj), true); // true = forced kill
                
                // Screen shake / surge effect
                player.surge = 15.0; // momentary push
                
                // Next
                player.rushIndex++;
            } else {
                // Move closer
                player.z += rushSpeed;
                
                // Smoothly interp angle/altitude to target to look cool
                // Lerp factor depends on distance, but simple Lerp is fine for rush
                const t = 0.2;
                // Normalize angles for lerp
                let targetAngle = targetObj.angle;
                // Simple loop fix for angle lerp
                if (targetAngle - player.angle > Math.PI) targetAngle -= Math.PI*2;
                if (player.angle - targetAngle > Math.PI) targetAngle += Math.PI*2;
                
                player.angle = THREE.MathUtils.lerp(player.angle, targetAngle, t);
                // Also align altitude (though usually 0)
                player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t);
                
                // Rush FX
                player.surge = THREE.MathUtils.lerp(player.surge, 20.0, 0.1); // Keep high surge
                document.getElementById('speed-lines').style.opacity = 1.0;
            }
            
            // Gravity ignored during rush (anti-gravity drive active!)
            // Steering ignored
            
            updateGameTimers();
            updateUI();
        }

        function scanTargets() {
            // Find objects in front of player within range and angle
            const scanRange = 300;
            const scanFov = 0.5; // Radians roughly
            
            for (let obj of worldObjects) {
                if (obj.locked) continue; // Already locked
                if (obj.z < player.z) continue; // Behind
                if (obj.z > player.z + scanRange) continue; // Too far

                // Check angle difference
                let da = Math.abs(obj.angle - player.angle);
                if (da > Math.PI) da = Math.PI * 2 - da;
                
                if (da < scanFov) {
                    // Lock on!
                    obj.locked = true;
                    player.lockTargets.push(obj);
                    createLockSight(obj);
                    sound.play('lockon');
                }
            }
        }

        function createLockSight(obj) {
            const div = document.createElement('div');
            div.className = 'lock-on-sight';
            document.getElementById('ui-layer').appendChild(div);
            obj.sightDom = div;
        }

        function updateLockSights() {
            // Update 2D position of sights
            // This requires projecting 3D pos to 2D screen
            // Since we curve the world in shader-like JS, actual 3D pos is `obj.mesh.position`
            
            worldObjects.forEach(obj => {
                if (obj.sightDom) {
                    if (!worldObjects.includes(obj)) {
                        obj.sightDom.remove();
                        obj.sightDom = null;
                        return;
                    }

                    // Simple check if object is roughly visible
                    if (obj.z < player.z || obj.z > player.z + 500) {
                        obj.sightDom.style.display = 'none';
                        return;
                    }
                    obj.sightDom.style.display = 'block';

                    // Project
                    const vector = obj.mesh.position.clone();
                    vector.project(camera);

                    const x = (vector.x * .5 + .5) * window.innerWidth;
                    const y = -(vector.y * .5 - .5) * window.innerHeight;

                    obj.sightDom.style.left = x + 'px';
                    obj.sightDom.style.top = y + 'px';
                    
                    if (player.mode === "RUSHING" && player.lockTargets[player.rushIndex] === obj) {
                        obj.sightDom.classList.add('locked');
                    }
                }
            });
        }

        function handleGravity() {
            const currentCurveY = getCurve(player.z, viewMode).y;
            const nextCurveY = getCurve(player.z + player.vz * 4, viewMode).y;
            const dropRate = currentCurveY - nextCurveY; 

            if (dropRate > 1.5 && player.vz > 5.0 && player.altitude <= 0.5) {
                player.jumpV += dropRate * 0.06; 
                player.wasAirborne = true;
            }

            player.altitude += player.jumpV;
            
            const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
            const inGap = isGap(currentSegIdx);
            
            const GRAVITY = 0.06;

            if (inGap) {
                player.jumpV -= GRAVITY;
            } else {
                if (player.altitude > 0) player.jumpV -= GRAVITY;
                else { 
                    if (player.wasAirborne && player.jumpV < -0.5) { 
                        sound.play('land'); 
                        player.wasAirborne = false; 
                    }
                    player.altitude = 0; 
                    player.jumpV = 0; 
                }
            }
            
            if (player.altitude < -40) {
                hp = 0; 
                changeState("GAMEOVER");
            }
        }

        function handleSteering() {
            let steerFactor = 1.0;
            if (player.vz > 10.0) steerFactor = 0.8; 
            if (viewMode === "F2") steerFactor = 8.0; else steerFactor = 4.0; 

            let targetBank = 0;
            const isOutside = (viewMode === "F4" || viewMode === "F5");
            const isFlat = (viewMode === "F2");
            const steerDirection = (isOutside || isFlat) ? -1 : 1;
            const BANK_LIMIT = 0.78;

            if (keys['ArrowLeft']) { 
                player.vAngle = THREE.MathUtils.lerp(player.vAngle, -0.05 * steerFactor * steerDirection, 0.2); 
                targetBank = -BANK_LIMIT; 
            }
            else if (keys['ArrowRight']) { 
                player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0.05 * steerFactor * steerDirection, 0.2); 
                targetBank = BANK_LIMIT; 
            }
            else {
                player.vAngle *= 0.7; 
            }
            
            player.angle += player.vAngle;

            if (viewMode === "F2") {
                const limit = Math.PI * 1.0; 
                if (player.angle > limit) { player.angle = limit; player.vAngle = 0; }
                else if (player.angle < -limit) { player.angle = -limit; player.vAngle = 0; }
            } 

            if (player.bank > BANK_LIMIT) player.bank = BANK_LIMIT;
            if (player.bank < -BANK_LIMIT) player.bank = -BANK_LIMIT;
            
            player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 0.15);
        }

        function updateGameTimers() {
            timeLeft -= 1/60;
            if (timeLeft <= 0) { timeLeft = 0; changeState("STAGE_CLEAR"); }
            if (hp <= 0) { hp = 0; changeState("GAMEOVER"); }
        }

        function updatePlayerVisuals() {
            // Visual Z includes surge for the MESH
            const visualZ = player.z + player.surge + player.dashOffset;
            const basis = getBasis(visualZ, viewMode);

            const isOutside = (viewMode === "F4" || viewMode === "F5");
            
            const r = isOutside 
                ? TUBE_R + 2.0 + player.altitude 
                : TUBE_R - 2.0 - player.altitude;

            const pos = getSectionPosition(player.angle, r, basis, viewMode);
            const center = basis.origin.clone();

            let playerUp, playerForward;

            if (viewMode === "F2") {
                playerForward = basis.T.clone(); 
                const right = new THREE.Vector3(1, 0, 0); 
                playerUp = new THREE.Vector3().crossVectors(right, playerForward).normalize();
            } else {
                if (isOutside) {
                    playerUp = new THREE.Vector3().subVectors(pos, center).normalize();
                } else {
                    playerUp = new THREE.Vector3().subVectors(center, pos).normalize();
                }
                playerForward = basis.T.clone();
            }

            player.mesh.position.copy(pos);

            const m = new THREE.Matrix4();
            const right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize();
            const orthoUp = new THREE.Vector3().crossVectors(right, playerForward).normalize();
            
            m.makeBasis(right, orthoUp, playerForward);
            player.mesh.rotation.setFromRotationMatrix(m);
            
            player.mesh.rotateZ(player.bank);
            if (player.altitude > 0.5) player.mesh.rotateX(-0.1);

            player.mesh.visible = (viewMode !== "F1" && viewMode !== "F4");

            // Sonic Boom
            if (player.sonicBoom) {
                player.sonicBoom.visible = (player.mode === "RUSHING");
                if(player.mode === "RUSHING") {
                    const s = 1.0 + Math.random() * 0.1;
                    player.sonicBoom.scale.set(s, s, s);
                }
            }

            // Sparks
            if (Math.abs(player.bank) > 0.7 && player.vz > 1.0) {
                const sideOffset = (player.bank > 0) ? 2.0 : -2.0; 
                const sparkPos = player.mesh.position.clone()
                    .addScaledVector(right, sideOffset)
                    .addScaledVector(playerUp, -0.5); 
                createDebris(sparkPos, 0xffff00, 0.3, 15, "spark");
            }

            if (player.thrust) {
                const speedRatio = player.vz / (player.mode === "RUSHING" ? RUSH_SPEED : NORMAL_MAX_SPEED);
                player.thrust.visible = (player.vz > 0.05 || player.mode === "RUSHING");
                player.thrust.scale.z = 1.0 + speedRatio * 3.0; 
                player.thrust.material.opacity = 0.6 + speedRatio * 0.4;
                player.thrust.material.color.setHex(player.mode === "RUSHING" ? 0xff00ff : 0x00ffff);
            }

            if (player.barrier) {
                const hpRatio = Math.max(0, hp / MAX_HP);
                const targetScale = 0.8 + (hpRatio * 0.2);
                player.barrier.scale.setScalar(targetScale);
                player.barrier.rotation.y += 0.05;
                if (player.mode === "RUSHING") {
                    player.barrier.material.color.setHex(0xff0000); 
                    player.barrier.material.emissive.setHex(0x550000);
                } else {
                    player.barrier.material.color.setHex(0x00ffff); 
                    player.barrier.material.emissive.setHex(0x004444);
                }
            }

            if (player.shadow) {
                const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
                const inGap = isGap(currentSegIdx);
                const isFirstPerson = (viewMode === "F1" || viewMode === "F4");

                if (!isFirstPerson && !inGap) {
                    player.shadow.visible = true;
                    const groundR = isOutside ? TUBE_R + 0.2 : TUBE_R - 0.2;
                    const groundPos = getSectionPosition(player.angle, groundR, basis, viewMode);
                    player.shadow.position.copy(groundPos);
                    
                    if (viewMode === "F2") {
                         const mShadow = new THREE.Matrix4();
                         mShadow.makeBasis(right, orthoUp, playerForward); 
                         player.shadow.rotation.setFromRotationMatrix(mShadow);
                         player.shadow.rotateX(-Math.PI/2); 
                    } else {
                        player.shadow.lookAt(center);
                        if(isOutside) player.shadow.rotateY(Math.PI); 
                    }

                    const distFromGround = Math.max(0, player.altitude);
                    const shadowScale = Math.max(0.1, 1.0 - distFromGround * 0.05); 
                    player.shadow.scale.setScalar(shadowScale);
                    player.shadow.material.opacity = Math.max(0.4, 0.9 - distFromGround * 0.05);

                } else {
                    player.shadow.visible = false;
                }
            }

            updateCamera(pos, orthoUp, playerForward, visualZ, isOutside);
            updateLockSights();
        }

        function updateCamera(playerPos, playerUp, playerForward, visualZ, isOutside) {
            const shake = (player.mode === "RUSHING") ? (Math.random()-0.5)*0.5 : (Math.random()-0.5)*0.02; 
            
            const cameraRoll = player.bank * 0.6; 
            const cameraUp = playerUp.clone();
            cameraUp.applyAxisAngle(playerForward, cameraRoll);
            
            // Cam uses real Z for basis to separate camera from player surge
            const cameraBaseZ = player.z; 
            const camBasis = getBasis(cameraBaseZ, viewMode);
            const camForward = camBasis.T.clone();

            if (viewMode === "F1" || viewMode === "F4") {
                const eyePos = playerPos.clone().addScaledVector(playerForward, 2.0).addScaledVector(playerUp, 0.5);
                camera.position.copy(eyePos);
                camera.position.y += shake; 
                camera.up.copy(cameraUp);
                const target = eyePos.clone().add(playerForward);
                camera.lookAt(target);
            } 
            else if (viewMode === "F2") { 
                const camOffsetZ = -40;
                const camOffsetY = 30;
                const camPos = new THREE.Vector3(
                    playerPos.x, 
                    playerPos.y + camOffsetY,
                    player.z + camOffsetZ
                );
                camera.position.copy(camPos);
                camera.up.set(0, 1, 0); 
                camera.lookAt(playerPos); 
            } 
            else { 
                // 3RD PERSON
                // If rushing, camera lags behind a bit more
                const vz = (player.mode === "RUSHING") ? RUSH_SPEED : player.vz;
                const camDist = 20 + (vz * 1.5); 
                const camHeight = 6 + (vz * 0.3);
                
                const logicalR = isOutside ? TUBE_R + 2.0 + player.altitude : TUBE_R - 2.0 - player.altitude;
                const logicalPos = getSectionPosition(player.angle, logicalR, camBasis, viewMode);
                const logicalUp = isOutside ? new THREE.Vector3().subVectors(logicalPos, camBasis.origin).normalize() : new THREE.Vector3().subVectors(camBasis.origin, logicalPos).normalize();

                const camPos = logicalPos.clone()
                    .addScaledVector(camForward, -camDist)
                    .addScaledVector(logicalUp, camHeight);    
                
                camera.position.copy(camPos);
                
                const right = new THREE.Vector3().crossVectors(camForward, logicalUp).normalize();
                camera.position.addScaledVector(right, shake);

                camera.up.copy(cameraUp);
                camera.lookAt(playerPos);
            }
        }

        function updateObjects() {
            let speed = (player.mode === "RUSHING") ? RUSH_SPEED : player.vz;
            let spawnDist = (50 - (currentStage * 3.0)) + (speed * 2.0); 
            
            const spawnHorizon = player.z + 700;

            if (spawnHorizon > nextSpawnZ) { 
                spawnObject(nextSpawnZ); 
                nextSpawnZ += Math.random() * 20 + spawnDist; 
            }

            for (let i = worldObjects.length - 1; i >= 0; i--) {
                const obj = worldObjects[i];
                
                if(obj.flying) {
                    obj.localPos.add(obj.vel);
                    obj.mesh.rotation.x += 0.2;
                    obj.mesh.rotation.y += 0.2;
                } 

                const objVisualZ = obj.z; 
                const basis = getBasis(objVisualZ, viewMode);
                
                let worldPos;
                if (obj.flying) {
                    worldPos = basis.origin.clone().add(obj.localPos); 
                } else {
                    const isOutside = (viewMode === "F4" || viewMode === "F5");
                    
                    worldPos = getSectionPosition(obj.angle, obj.r, basis, viewMode);
                    
                    const forward = basis.T.clone();
                    let up;
                    
                    if (viewMode === "F2") {
                        const right = new THREE.Vector3(1, 0, 0);
                        up = new THREE.Vector3().crossVectors(right, forward).normalize();
                    } else {
                        const objLocalX = Math.sin(obj.angle);
                        const objLocalY = -Math.cos(obj.angle);
                        const normal = new THREE.Vector3()
                            .addScaledVector(basis.R, objLocalX)
                            .addScaledVector(basis.U, objLocalY)
                            .normalize();
                        up = isOutside ? normal : normal.clone().multiplyScalar(-1);
                    }
                    
                    const right = new THREE.Vector3().crossVectors(up, forward).normalize();
                    const parallelForward = new THREE.Vector3().crossVectors(right, up).normalize();
                    
                    const m = new THREE.Matrix4();
                    m.makeBasis(right, up, parallelForward);
                    obj.mesh.rotation.setFromRotationMatrix(m);
                }
                
                obj.mesh.position.copy(worldPos);

                // Collision handling for normal mode (Rushing handled in physics)
                if (player.mode !== "RUSHING" && !obj.flying) {
                    const dz = Math.abs(player.z - obj.z);
                    // Dynamic threshold to prevent pass-through in normal mode
                    let collisionThreshold = 6.0; 
                    if (player.vz > 5.0) collisionThreshold = Math.max(6.0, player.vz * 1.2); 
                    if (obj.type === "block" && player.barrierRadius) collisionThreshold = Math.max(collisionThreshold, player.barrierRadius + 2.0); 

                    if (dz < collisionThreshold) {
                        const dist = player.mesh.position.distanceTo(obj.mesh.position);
                        let distThreshold = 6.0;
                        if (dist < distThreshold) { 
                            handleCollision(obj, i);
                            if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") {
                                scene.remove(obj.mesh);
                                if(obj.sightDom) obj.sightDom.remove();
                                worldObjects.splice(i, 1);
                                continue;
                            }
                        }
                    }
                }

                if (obj.z < player.z - 50) {
                    scene.remove(obj.mesh);
                    if(obj.sightDom) obj.sightDom.remove();
                    worldObjects.splice(i, 1);
                }
            }
        }

        function spawnObject(z) {
            let hurdleRate = Math.min(0.4, 0.1 + (currentStage * 0.04));
            let type = (Math.random() < 0.1) ? "score" : (Math.random() < 0.05) ? "heal" : "block";
            if (Math.random() < hurdleRate) type = "hurdle";

            const seg = Math.floor(Math.random() * TILES_PER_RING);
            let tubeAngle = (seg / TILES_PER_RING) * Math.PI * 2 - Math.PI;
            
            let length = (type === 'block') ? 3 : 1; 

            for(let i = 0; i < length; i++) {
                let zOffset = z + (i * 8.0); 
                let stackHeight = (type === 'block') ? Math.floor(Math.random() * 2) + 1 : 1; 

                for (let k = 0; k < stackHeight; k++) {
                    let geo;
                    let blockSize = 2.5; 

                    if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); blockSize=4.0; }
                    else if (type === 'heal') { geo = new THREE.BoxGeometry(2,2,2); blockSize=2.0; }
                    else { geo = new THREE.BoxGeometry(3,3,3); blockSize=3.0; }

                    let color;
                    if (type === 'block') {
                        const gVal = 0.3 + Math.random() * 0.5; 
                        color = new THREE.Color(gVal, gVal, gVal);
                    } else {
                        const colors = { hurdle: 0xff3300, score: 0xffff00, heal: 0x00ff00 };
                        color = new THREE.Color(colors[type]);
                    }

                    const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color }));
                    
                    let baseR = TUBE_R;
                    const isOutside = (viewMode === "F4" || viewMode === "F5");
                    const offset = (type === 'block') ? (1.5 + k * 3.0) : 1.5;

                    if (isOutside) baseR += offset;
                    else baseR -= offset;

                    const lx = Math.sin(tubeAngle) * baseR;
                    const ly = -Math.cos(tubeAngle) * baseR;

                    scene.add(mesh);
                    worldObjects.push({ 
                        mesh, type, flying: false, 
                        z: zOffset, 
                        angle: tubeAngle,
                        r: baseR,
                        localPos: new THREE.Vector3(lx, ly, 0),
                        vel: new THREE.Vector3(),
                        locked: false,
                        sightDom: null
                    });
                }
            }
        }

        function handleCollision(obj, index, forcedKill = false) {
            const isHighSpeed = player.vz >= 10.0;

            if (obj.type === "block" || obj.type === "hurdle") {
                if (forcedKill || isHighSpeed) {
                    sound.play('crash'); 
                    createDebris(obj.mesh.position, obj.mesh.material.color, 2.0, 15, "explode");
                    scene.remove(obj.mesh);
                    if(obj.sightDom) obj.sightDom.remove();
                    worldObjects.splice(index, 1);
                    const pts = 50; score += pts; stageScore += pts; 
                    showPopText("SMASH! +50", "#ffaa00");
                } else {
                    // Normal Hit (slow)
                    if (obj.type === "hurdle") { hp -= 2; showPopText("CRASH! -HP", "#ff0000"); }
                    sound.play('crash');
                    createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 10, "explode");
                    obj.flying = true;
                    if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; }
                    obj.vel.set((Math.random()-0.5)*2, 5, 8);
                }
            } 
            else if (obj.type === "score") { 
                sound.play('coin');
                const pts = 100; score += pts; stageScore += pts;
                showPopText("+100", "#ffff00"); 
                scene.remove(obj.mesh);
                if(obj.sightDom) obj.sightDom.remove();
                worldObjects.splice(index, 1);
            }
            else if (obj.type === "heal") { 
                sound.play('heal');
                hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff00"); 
                scene.remove(obj.mesh);
                if(obj.sightDom) obj.sightDom.remove();
                worldObjects.splice(index, 1);
            }
        }

        function createDebris(pos, color, size, count, type) {
            for(let i=0; i<count; i++) {
                const s = (type === "spark") ? size * (Math.random()*0.5 + 0.5) : size;
                const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0 });
                const geo = new THREE.BoxGeometry(s, s, s);
                const mesh = new THREE.Mesh(geo, mat);
                mesh.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5), (Math.random()-0.5), (Math.random()-0.5)));
                scene.add(mesh);
                
                let vel, life;
                if (type === "spark") {
                    vel = new THREE.Vector3((Math.random()-0.5)*2, (Math.random())*2, (Math.random()-0.5)*2);
                    life = 10 + Math.random() * 10;
                } else {
                    vel = new THREE.Vector3((Math.random()-0.5)*15, (Math.random()-0.5)*15, (Math.random()-0.5)*15);
                    life = 60;
                }
                debris.push({ mesh, vel, life, type });
            }
        }

        function updateDebris() {
            for(let i = debris.length - 1; i >= 0; i--) {
                const d = debris[i];
                d.mesh.position.add(d.vel);
                d.life--;
                if (d.type === "spark") d.mesh.rotation.z += 0.5;
                else d.mesh.rotation.x += 0.2;
                if(d.life <= 0) {
                    scene.remove(d.mesh);
                    debris.splice(i, 1);
                }
            }
        }

        function showPopText(text, color) {
            const div = document.createElement('div');
            div.className = 'pop-text';
            div.style.color = color;
            div.innerText = text;
            div.style.left = "50%"; div.style.top = "40%";
            document.getElementById('ui-layer').appendChild(div);
            setTimeout(() => div.remove(), 800);
        }

        function updateUI() {
            document.getElementById('score-info').innerText = "SCORE: " + score;
            document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%";
            
            // Speed display
            const val = (player.mode === "RUSHING") ? RUSH_SPEED : player.vz;
            const spd = (val * SPEED_DISPLAY_MULTIPLIER).toFixed(0);
            document.getElementById('speed-val').innerText = spd;
            
            const speedRatio = val / (player.mode==="RUSHING" ? RUSH_SPEED : NORMAL_MAX_SPEED);
            document.getElementById('speed-fill').style.width = Math.min(100, speedRatio * 100) + "%";
            
            if (player.mode === "RUSHING") document.getElementById('speed-fill').style.backgroundColor = "#ff00ff";
            else document.getElementById('speed-fill').style.backgroundColor = "#00ff88";

            const remainingTime = Math.max(0, timeLeft);
            const minutes = Math.floor(remainingTime / 60);
            const seconds = Math.floor(remainingTime % 60);
            const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
            document.getElementById('time-num').innerText = timeStr;
            document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%";
        }

        init();
    </script>
</body>
</html>

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

【操作説明】


・左右キー … 自機を左右に移動。
・上キー … アクセル。
・下キー … ブレーキ。
・Zキー … キー押下で前方のオブジェクトをロックオン、
      その状態でキー押上するとブースト。
・Xキー … ジャンプ。

・F1キー … チューブ内側モードの一人称視点に切り替え。
・F2キー … 平面モードに切り替え。
・F3キー … チューブ内側モードの三人称視点に切り替え。
・F4キー … チューブ外側モードの一人称視点に切り替え。
・F5キー … チューブ外側モードの三人称視点に切り替え。

・Escキー … ゲームを一時停止、ゲームを再開。

・障害物を避けて、高得点を目指しましょう。

・灰色ブロック … バリアで破壊可能。+10点。
・赤色ブロック … バリアでは防げず、HPにダメージ。
・黄緑色ブロック … HPを回復。
・黄色ブロック … +100点。

・HPが減るとバリアが小さくなる。
・HPがゼロになるとゲームオーバー。

【おまけ】

・チューブ系のゲームを検索してみたところ、
 やはりいくつかあったので紹介しておきます。

1981 Atari Tempest Arcade 

・これはレースゲームというか、シューティングみたいな感じですね。

1983 ジャイラス コナミ

・これもシューティングゲームで、やや滑らかに回る感じ。

Bullfrog - Tube - 1995

・かなりレースゲームっぽい感じになってきました。

Ballistics (2001) - PC

これは障害物を避けていくパターンで、一応、レースゲームなのかな?

Tube Slider (2003) GameCube

・これは任天堂のレースゲームで、
 「F-ZERO」の後継シリーズとして開発されていたとのこと。
・一回転できる区間がない感じだけど、遊んだこと無いので不明。

Torus Trooper(2004)長健太

・これはシューティングゲームだけど、
 一回転できる区間があるし、雰囲気的には一番似ているかも。
・Zキーでショット発射。Xキーで貯め撃ち。
・敵弾は、避けやすいけど、たまに当たる。難易度はやや高い。
・2004年11月にver.0.1が公開。2005年のver.0.22以降は更新無し。

Space Giraffe(2007)Llamasoft

・これはまたシューティングゲームで、テンペストっぽい感じ。

「Race The Sun」Flippfly (2013)

・これはチューブ系ではないものの、平面モードはわりと近いかも。

「Thumper」Drool(2016)

・これはリズム系のゲームで、
 タイミングを合わせると障害物を回避できる。

「Tunnel Rush」(2024?)

・これも障害物を避けまくるゲーム。

「Redout」(2016)34BigThings 

「Redout 2」(2022)34BigThings 

・これはレースゲームですが、
ブーストが付いていて1000km/hまで加速可能。

とまあ、似たような雰囲気のゲームは
これまでにもあったみたいなんですが、
ぶっ壊しに主軸をおいたゲームでは無かったみたいなので、
まあまあセーフだったりするのかも知れない。(^^;

3Dのこういう真正面を向いたゲームは、
合わせるのが難しいので、
気軽に遊べるのがいいのではないだろうか。uu


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

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

便利なツール

  • 40本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
買うたび 抽選 ※条件・上限あり \note クリエイター感謝祭ポイントバックキャンペーン/最大全額もどってくる! 12.1 月〜1.14 水 まで
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
ブラウザで遊べるドライブゲームImageBooster|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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