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

【更新機歴】

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

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Image Booster 6.1 (Parallel Cam)</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 rgba(255,255,255,0.5), inset 0 0 5px rgba(255,255,255,0.5);
            z-index: 8;
            transition: width 0.1s, height 0.1s;
        }
        .lock-on-sight.rushing {
            border-width: 5px;
            width: 60px; height: 60px;
            background: rgba(255, 255, 255, 0.2);
        }

        #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;
                // High pitch for speed
                const targetFreq = 200 + (speedRatio * 2000);
                const targetVol = Math.min(0.3, speedRatio * 0.2); 
                windFilter.frequency.setTargetAtTime(targetFreq, now, 0.1);
                windGain.gain.setTargetAtTime(targetVol, now, 0.1);

                const jetVol = isBoosting ? 0.15 : 0.02;
                const jetFreq = 600 + (speedRatio * 3000); 
                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 === 'brake') {
                    const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer;
                    const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; 
                    f.frequency.setValueAtTime(1200, now); f.frequency.linearRampToValueAtTime(400, now + 0.3);
                    const g = audioCtx.createGain(); g.gain.setValueAtTime(0.3, now); g.gain.linearRampToValueAtTime(0.01, now + 0.3);
                    src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.3);
                } 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(1200, now); 
                    g.gain.setValueAtTime(0.05, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.08);
                    osc.start(now); osc.stop(now+0.08);
                } 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;
        
        // Speed scaling (Halved from previous version)
        // Previous: 700 / 3000
        // New: 350 / 1500
        const NORMAL_MAX_SPEED = 350.0;  
        const RUSH_SPEED = 1500.0; 
        const NORMAL_ACCEL = 200.0; // Acceleration per sec
        const GRAVITY = 100.0;
        const JUMP_POWER = 40.0;
        
        const TOTAL_STAGES = 8;
        
        const VISIBLE_SEGMENTS = 90;
        const FOG_NEAR = 150;
        const FOG_FAR = 900;
        
        // Display Multipliers (Adjusted so the number looks cool)
        const SPEED_DISPLAY_MULTIPLIER = 0.6; 

        let scene, camera, renderer, clock;
        let gameState = "TITLE", viewMode = "F3"; 
        let isPaused = false;
        let score = 0, stageScore = 0; 
        let hp = MAX_HP, timeLeft = MAX_TIME;
        let currentStage = 1;
        
        let player = { 
            mesh: null, 
            angle: 0, 
            x: 0, 
            z: 0, 
            vz: 10, vAngle: 0, vx: 0, 
            altitude: 0, jumpV: 0,
            surge: 0, bank: 0,
            isBoosting: false,     
            dashOffset: 0, 
            wasAirborne: false,
            barrier: null, 
            shadow: null,
            sonicBoom: null, 
            barrierRadius: 3.5,
            
            // Lock-on Rush System
            mode: "NORMAL", // "NORMAL", "RUSHING"
            lockTargets: [], 
            rushIndex: 0,
            lockTimer: 0,
            currentLockType: null // "block", "score", "heal", "hurdle"
        };
        
        let worldObjects = [], debris = [], stars;
        let keys = {};
        let nextSpawnZ = 150;
        let stageHue = 0.0;

        // --- Tunnel & Curve Logic ---
        const TILE_SEGMENT_LENGTH = 12; 
        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 + 5.0, mode); // Lookahead slightly increased
            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(hueShift) {
            // Pale transparent colors
            const hue = (0.45 + hueShift) % 1.0; 
            const sat = 0.4;  
            const val = 0.8;  
            return new THREE.Color().setHSL(hue, sat, val);
        }

        function isGap(segmentIndex) {
            if (segmentIndex < 15) return false; 
            return (segmentIndex % 60) >= 56; // Bigger gaps
        }

        function init() {
            sound.init();
            clock = new THREE.Clock();
            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(90, window.innerWidth / window.innerHeight, 0.1, 3000);
            
            scene.add(new THREE.AmbientLight(0x606060, 0.8));
            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: 0xeeeeee, specular: 0xffffff, shininess: 80 });
            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: 0x00ccff });
            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;

            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(3.5, 5.0);
            const shadowMat = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.8 });
            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>";
                        clock.stop();
                    } else {
                        sound.resume();
                        document.getElementById('center-text').innerHTML = "";
                        clock.start();
                    }
                }

                if(e.code === 'Space') { e.preventDefault(); handleSpace(); }
                if(e.code === 'KeyX') handleJump();
                
                // Z Key: Start Rush if targets exist
                if(e.code === 'KeyZ' && gameState === "PLAYING" && !isPaused) {
                    if (player.lockTargets.length > 0) {
                        player.mode = "RUSHING";
                        player.rushIndex = 0;
                        player.isBoosting = true;
                        sound.play('boost_dash');
                    }
                }
            });
            window.addEventListener('keyup', (e) => { 
                keys[e.code] = false; 
                
                // Release Z: Stop Rush immediately and clear locks
                if (e.code === 'KeyZ') {
                    if (player.mode === "RUSHING" || player.lockTargets.length > 0) {
                        player.mode = "NORMAL";
                        player.isBoosting = false;
                        // Clear locks
                        clearAllLocks();
                    }
                }
            });
            
            updateViewLabel();
            changeState("TITLE");
            animate();
        }

        function clearAllLocks() {
            player.lockTargets.forEach(obj => {
                obj.locked = false;
                if(obj.sightDom) {
                    obj.sightDom.remove();
                    obj.sightDom = null;
                }
            });
            player.lockTargets = [];
            player.currentLockType = null;
            player.rushIndex = 0;
        }

        function initTunnel() {
            const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
            const boxGeo = new THREE.BoxGeometry(tileWidth * 1.1, 0.5, TILE_SEGMENT_LENGTH * 1.05);
            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: 0xffffff,
                        opacity: 0.1,
                        transparent: true,
                        side: THREE.DoubleSide,
                        shininess: 100
                    });
                    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(stageHue));
                        if(segIdx % 10 === 0) 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) * 6000; 
            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.1 && !isPaused) {
                sound.play('jump');
                player.jumpV = JUMP_POWER; 
                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.1</h1><p style='color:#adff2f;'>PARALLEL CAM</p><p>PRESS SPACE TO START</p><p style='font-size:18px; color:#aaa;'>[UP]: ACCEL / [DOWN]: BRAKE / [Z]: RUSH ATTACK / [X]: JUMP</p>";
            else if(s === "STAGE_START") { 
                stageScore = 0; 
                stageHue = Math.random();
                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";
            clearAllLocks();
            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);
            const dt = clock.getDelta();

            if (gameState === "PLAYING" && !isPaused) {
                const safeDt = Math.min(dt, 0.1);
                updatePhysics(safeDt);
                updateObjects(safeDt);
                updateDebris(safeDt);
                updateAutoLock(safeDt);
            }
            
            if(gameState === "PLAYING" && !isPaused) {
                let ratio = player.vz / NORMAL_MAX_SPEED;
                if(player.mode === "RUSHING") ratio = 3.0; 
                sound.updateEngine(ratio, player.isBoosting);
            } else if (!isPaused) {
                sound.updateEngine(0, false);
            }

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

        function updatePhysics(dt) {
            if (player.mode === "RUSHING") {
                handleRushPhysics(dt);
                return; 
            }

            let currentAccel = 0;
            const isBraking = keys['ArrowDown'];
            
            if (keys['ArrowUp']) {
                currentAccel = NORMAL_ACCEL;
                player.surge = THREE.MathUtils.lerp(player.surge, 3.0, 5 * dt);
                player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 5 * dt);
            } 
            else {
                currentAccel = 0;
                player.surge = THREE.MathUtils.lerp(player.surge, 0, 5 * dt);
            }

            if (isBraking) {
                player.vz -= NORMAL_ACCEL * 1.5 * dt; 
                player.surge = THREE.MathUtils.lerp(player.surge, -3.0, 5 * dt);
                
                if (player.vz > 50) {
                    if (Math.random() < 0.5) sound.play('brake');
                    const basis = getBasis(player.z, viewMode);
                    const pos = player.mesh.position.clone();
                    createDebris(pos, 0xffaa00, 1.0, 1, "spark_brake");
                }
            }

            if (currentAccel > 0) {
                if (player.vz < NORMAL_MAX_SPEED) player.vz += currentAccel * dt;
                else player.vz *= (1.0 - (0.5 * dt)); 
            } else if (!isBraking) {
                player.vz *= (1.0 - (0.2 * dt)); 
            }
            
            player.vz = Math.max(0.0, player.vz); 

            document.getElementById('speed-lines').style.opacity = 0; 

            handleGravity(dt);
            handleSteering(dt);

            player.z += player.vz * dt;
            
            updateGameTimers(dt);
            updateUI();
        }

        function updateAutoLock(dt) {
            if (player.mode === "RUSHING") return;

            player.lockTimer += dt;
            if (player.lockTimer < 0.1) return;

            player.lockTimer = 0;

            const scanRange = 250; 
            const lineThreshold = 0.2; 

            let candidates = worldObjects.filter(obj => {
                if (obj.locked) return false;
                if (obj.z < player.z) return false;
                if (obj.z > player.z + scanRange) return false;
                
                let da = Math.abs(obj.angle - player.angle);
                if (da > Math.PI) da = Math.PI * 2 - da;
                if (da > lineThreshold) return false;

                if (player.currentLockType && player.currentLockType !== obj.type) return false;
                
                return true;
            });

            if (candidates.length > 0) {
                candidates.sort((a,b) => a.z - b.z);
                const target = candidates[0];
                
                target.locked = true;
                player.lockTargets.push(target);
                player.currentLockType = target.type;
                createLockSight(target);
                sound.play('lockon');
            }
        }

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

            const targetObj = player.lockTargets[player.rushIndex];
            
            if (!worldObjects.includes(targetObj)) {
                player.rushIndex++;
                return;
            }

            const rushDist = RUSH_SPEED * dt;
            const dz = targetObj.z - player.z;
            
            if (dz <= rushDist) {
                player.z = targetObj.z;
                player.angle = targetObj.angle;
                handleCollision(targetObj, worldObjects.indexOf(targetObj), true); 
                player.surge = 15.0; 
                player.rushIndex++;
            } else {
                player.z += rushDist;
                const t = 10.0 * dt;
                let targetAngle = targetObj.angle;
                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);
                player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t);
                
                player.surge = THREE.MathUtils.lerp(player.surge, 20.0, 5 * dt);
                document.getElementById('speed-lines').style.opacity = 1.0;
            }
            
            updateGameTimers(dt);
            updateUI();
        }

        function createLockSight(obj) {
            const div = document.createElement('div');
            div.className = 'lock-on-sight';
            
            let col = "#fff";
            if (obj.type === "block") col = "#ddd";
            else if (obj.type === "hurdle") col = "#f30";
            else if (obj.type === "heal") col = "#0f0";
            else if (obj.type === "score") col = "#ff0";
            
            div.style.borderColor = col;
            div.style.boxShadow = `0 0 10px ${col}, inset 0 0 10px ${col}`;
            
            document.getElementById('ui-layer').appendChild(div);
            obj.sightDom = div;
        }

        function updateLockSights() {
            worldObjects.forEach(obj => {
                if (obj.sightDom) {
                    if (!worldObjects.includes(obj)) {
                        obj.sightDom.remove();
                        obj.sightDom = null;
                        return;
                    }

                    if (obj.z < player.z) {
                         obj.locked = false;
                         obj.sightDom.remove();
                         obj.sightDom = null;
                         return;
                    }

                    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('rushing');
                    }
                }
            });
        }

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

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

            player.altitude += player.jumpV * dt;
            
            const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
            const inGap = isGap(currentSegIdx);
            
            const speedKmh = player.vz * SPEED_DISPLAY_MULTIPLIER;
            const isFloating = (speedKmh > 30 && !player.wasAirborne);

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

        function handleSteering(dt) {
            let steerFactor = 1.0;
            if (player.vz > 300.0) steerFactor = 0.5; 
            if (viewMode === "F2") steerFactor = 4.0; else steerFactor = 2.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, -1.5 * steerFactor * steerDirection, 5 * dt); 
                targetBank = -BANK_LIMIT; 
            }
            else if (keys['ArrowRight']) { 
                player.vAngle = THREE.MathUtils.lerp(player.vAngle, 1.5 * steerFactor * steerDirection, 5 * dt); 
                targetBank = BANK_LIMIT; 
            }
            else {
                player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0, 10 * dt);
            }
            
            player.angle += player.vAngle * dt;

            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; }
            } 

            player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 5 * dt);
        }

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

        function updatePlayerVisuals() {
            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");

            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);
                }
            }

            if (Math.abs(player.bank) > 0.7 && player.vz > 100.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, 2, "spark");
            }

            if (player.thrust) {
                const speedRatio = player.vz / (player.mode === "RUSHING" ? RUSH_SPEED : NORMAL_MAX_SPEED);
                player.thrust.visible = (player.vz > 10.0 || 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); 
                         player.shadow.material.opacity = 0.9;
                    } else {
                        player.shadow.lookAt(center);
                        if(isOutside) player.shadow.rotateY(Math.PI); 
                        player.shadow.material.opacity = Math.max(0.4, 0.9 - player.altitude * 0.02);
                    }

                    const distFromGround = Math.max(0, player.altitude);
                    const shadowScale = Math.max(0.1, 1.0 - distFromGround * 0.05); 
                    player.shadow.scale.setScalar(shadowScale);
                    
                } else {
                    player.shadow.visible = false;
                }
            }

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

        function updateCamera(playerPos, playerUp, playerForward, visualZ, isOutside, playerBasis) {
            const shake = (player.mode === "RUSHING") ? (Math.random()-0.5)*1.0 : (Math.random()-0.5)*0.1; 
            
            const cameraRoll = player.bank * 0.6; 
            const cameraUp = playerUp.clone();
            cameraUp.applyAxisAngle(playerForward, cameraRoll);
            
            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 = -60;
                const camOffsetY = 50;
                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 (F3, F5) - Fixed relative parallel camera
                
                // Defined relative offset (Back, Up)
                const dist = 30;
                const height = 8;

                // Calculate camera position strictly relative to player orientation
                // This ensures "Fixed at front of screen" behavior
                const camPos = playerPos.clone()
                    .addScaledVector(playerForward, -dist)
                    .addScaledVector(playerUp, height);
                
                // Add Shake
                const right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize();
                camPos.addScaledVector(right, shake);

                camera.position.copy(camPos);
                
                // Set Up vector (Synchronized with player's tile surface normal + bank)
                camera.up.copy(cameraUp);

                // Look At: Project forward parallel to player
                // Looking at "camPos + playerForward" ensures the view vector is parallel to playerForward
                camera.lookAt(camPos.clone().add(playerForward));
            }
        }

        function updateObjects(dt) {
            let speed = (player.mode === "RUSHING") ? RUSH_SPEED : player.vz;
            let spawnDist = (150 - (currentStage * 10.0)) + (speed * 0.5); 
            
            const spawnHorizon = player.z + 1500; 

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

            for (let i = worldObjects.length - 1; i >= 0; i--) {
                const obj = worldObjects[i];
                
                if(obj.flying) {
                    obj.localPos.addScaledVector(obj.vel, dt * 60);
                    obj.mesh.rotation.x += 10 * dt;
                    obj.mesh.rotation.y += 10 * dt;
                } 

                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);

                if (player.mode !== "RUSHING" && !obj.flying) {
                    const dz = Math.abs(player.z - obj.z);
                    let collisionThreshold = 10.0 + (player.vz * 0.02); 

                    if (dz < collisionThreshold) {
                        const dist = player.mesh.position.distanceTo(obj.mesh.position);
                        let distThreshold = 8.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 - 100) {
                    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 * 12.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 >= 350.0;

            if (obj.type === "block" || obj.type === "hurdle") {
                if (forcedKill) {
                    sound.play('crash'); 
                    createDebris(obj.mesh.position, obj.mesh.material.color, 2.0, 15, "explode");
                    
                    if (obj.type === "hurdle") {
                        hp -= 1;
                        showPopText("BREAK! -1HP", "#ff3300");
                    } else {
                        const pts = 50; score += pts; stageScore += pts; 
                        showPopText("SMASH! +50", "#ffaa00");
                    }
                    
                    scene.remove(obj.mesh);
                    if(obj.sightDom) obj.sightDom.remove();
                    worldObjects.splice(index, 1);
                } else {
                    if (obj.type === "hurdle") { 
                        hp -= 2; 
                        showPopText("CRASH! -2HP", "#ff0000"); 
                        sound.play('crash');
                        createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 10, "explode");
                        obj.flying = true;
                        obj.vel.set((Math.random()-0.5)*2, 5, 8);
                    } else {
                        const pts = 10; score += pts; stageScore += pts;
                        showPopText("HIT! +10", "#ffffff");
                        sound.play('crash');
                        createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 5, "explode");
                        obj.flying = true;
                        obj.vel.set((Math.random()-0.5)*4, 10, 15);
                    }
                    
                    if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; }
                }
            } 
            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.includes("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)*2, (Math.random()-0.5)*2, (Math.random()-0.5)*2));
                scene.add(mesh);
                
                let vel, life;
                if (type === "spark") {
                    vel = new THREE.Vector3((Math.random()-0.5)*5, (Math.random())*5, (Math.random()-0.5)*5);
                    life = 0.5; 
                } else if (type === "spark_brake") {
                    vel = new THREE.Vector3((Math.random()-0.5)*10, 5 + Math.random()*10, 5 + Math.random()*10);
                    life = 0.3;
                } else {
                    vel = new THREE.Vector3((Math.random()-0.5)*30, (Math.random()-0.5)*30, (Math.random()-0.5)*30);
                    life = 1.0;
                }
                debris.push({ mesh, vel, life, type });
            }
        }

        function updateDebris(dt) {
            for(let i = debris.length - 1; i >= 0; i--) {
                const d = debris[i];
                d.mesh.position.addScaledVector(d.vel, dt * 20); 
                d.life -= dt;
                
                if (d.type.includes("spark")) d.mesh.rotation.z += 10 * dt;
                else d.mesh.rotation.x += 5 * dt;
                
                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)) + "%";
            
            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キー … キー押下で前方のオブジェクトをロックオン、
       その状態でキー押上するとブースト。(※ver.6.0)

        ver6.1では、ロックオンは自動化され、
       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