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

【更新機歴】

・2025/12/22 バージョン3.0公開。

画像

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Image Booster 3.0 - High Velocity</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 30%, rgba(255, 255, 255, 0.1) 80%),
                        repeating-conic-gradient(transparent 0deg, transparent 2deg, rgba(0, 255, 255, 0.3) 2.1deg, transparent 2.5deg);
            opacity: 0;
            transition: opacity 0.1s ease-out;
            pointer-events: none;
            z-index: 5;
            mix-blend-mode: screen;
        }

        #hud-bottom { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 85%; display: none; flex-direction: row; justify-content: space-around; }
        .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; }
        .bar-fill { height: 100%; width: 100%; transition: width 0.05s ease-out; }

        #hp-bg { background: #500; }
        #hp-fill { background: #ff3333; }
        #speed-bg { background: #000050; }
        #speed-fill { background: #00ffff; }
        #time-bg { background: #554400; }
        #time-fill { background: #ffcc00; }

        #center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; }
        h1 { font-size: 60px; margin: 0; color: #0ff; }
        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; }
        @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" id="hp-txt">HP: 10</div>
                <div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div>
            </div>
            <div class="gauge-container">
                <div class="gauge-label" id="speed-txt">SPEED: 0</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" id="time-txt">TIME: 60</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 (Web Audio API) ---
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let noiseBuffer = null;
        
        // 走行音用ノード
        let windSource = null;
        let windGain = null;
        let windFilter = null;
        let jetOsc = null;
        let 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();
            },
            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 = 400 + (speedRatio * 800);
                const targetVol = Math.min(0.15, speedRatio * 0.1); 
                
                windFilter.frequency.setTargetAtTime(targetFreq, now, 0.1);
                windGain.gain.setTargetAtTime(targetVol, now, 0.1);

                // ジェット音: ブースト時のみ聞こえるように
                const jetVol = isBoosting ? 0.05 : 0.0;
                const jetFreq = 1000 + (speedRatio * 2000);
                jetGain.gain.setTargetAtTime(jetVol, now, 0.2);
                jetOsc.frequency.setTargetAtTime(jetFreq, now, 0.1);
            },
            play: (type) => {
                if (audioCtx.state === 'suspended') 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.value = 1000;
                    f.frequency.exponentialRampToValueAtTime(100, now + 0.5);
                    const g = audioCtx.createGain();
                    g.gain.setValueAtTime(0.8, 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 === 'heal') {
                    // ピュイン♪ (Sineスイープ)
                    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 === '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.3, 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(200, now); // 低音のみ
                    const g = audioCtx.createGain();
                    g.gain.setValueAtTime(0.6, now);
                    g.gain.exponentialRampToValueAtTime(0.01, now + 0.15); // 短く
                    src.connect(f); f.connect(g); g.connect(audioCtx.destination);
                    src.start(now); src.stop(now + 0.15);

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

        const MAX_HP = 10, TUBE_R = 15, PLANE_W = 12, MAX_TIME = 60;
        const NORMAL_MAX_SPEED = 5.0; 
        const BOOST_MAX_SPEED = 20.0; // 倍速化(前回9.0 -> 20.0)
        const NORMAL_ACCEL = 0.08;
        const BOOST_ACCEL = 0.5;

        const TOTAL_STAGES = 8;
        
        let scene, camera, renderer;
        let gameState = "TITLE", viewMode = "F3"; 
        let score = 0, hp = MAX_HP, timeLeft = MAX_TIME;
        let currentStage = 1;
        
        let player = { 
            mesh: null, angle: 0, x: 0, z: 0, 
            vz: 0.2, vAngle: 0, vx: 0, 
            altitude: 0, jumpV: 0,
            surge: 0, bank: 0,
            isBoosting: false,
            wasAirborne: false // 着地判定用
        };
        
        let worldObjects = [], debris = [], stars, groundTube, groundPlane;
        let keys = {};
        let nextSpawnZ = 50;
        let tubeMaterial;

        function init() {
            sound.init();
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x000005);
            scene.fog = new THREE.Fog(0x000005, 50, 800); // 視界拡張

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

            camera = new THREE.PerspectiveCamera(80, window.innerWidth / window.innerHeight, 0.1, 4000);
            scene.add(new THREE.AmbientLight(0xffffff, 0.7));
            const sun = new THREE.DirectionalLight(0xffffff, 1.2);
            sun.position.set(0, 50, 10);
            scene.add(sun);

            createStars();

            // プレイヤー
            player.mesh = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.6, 2.5), new THREE.MeshPhongMaterial({ color: 0x00aaff }));
            scene.add(player.mesh);

            // --- 市松模様(チェッカーフラグ)テクスチャ ---
            // Gray & LightGray
            const canvas = document.createElement('canvas');
            canvas.width = 512; canvas.height = 512;
            const ctx = canvas.getContext('2d');
            
            const color1 = '#444444'; // Gray
            const color2 = '#888888'; // LightGray
            
            ctx.fillStyle = color1; ctx.fillRect(0, 0, 512, 512); // Base
            ctx.fillStyle = color2; 
            // 2x2チェッカーを描く
            ctx.fillRect(256, 0, 256, 256);
            ctx.fillRect(0, 256, 256, 256);
            
            // 視認性を上げるための枠線
            ctx.strokeStyle = '#222'; ctx.lineWidth = 2;
            ctx.strokeRect(0,0,512,512);

            const gridTex = new THREE.CanvasTexture(canvas);
            gridTex.wrapS = THREE.RepeatWrapping;
            gridTex.wrapT = THREE.RepeatWrapping;
            gridTex.magFilter = THREE.LinearFilter;
            gridTex.minFilter = THREE.LinearMipMapLinearFilter;
            
            // 重要:タイリング比率の調整
            // 円周: 2 * PI * 15 ≈ 94.2
            // 奥行き: 無限だが、UVリピート数で正方形に見せる
            // 円周方向に16タイル並べるとする -> 1タイルの幅 ≈ 5.89
            // 400000 / 5.89 ≈ 67911 回のリピートが必要
            gridTex.repeat.set(16, 60000); 

            tubeMaterial = new THREE.MeshPhongMaterial({ 
                color: 0xffffff, 
                map: gridTex,
                side: THREE.BackSide, 
                shininess: 30
            });

            // トンネル
            groundTube = new THREE.Mesh(
                new THREE.CylinderGeometry(TUBE_R, TUBE_R, 400000, 32, 1, true),
                tubeMaterial
            );
            groundTube.rotation.x = Math.PI / 2;
            scene.add(groundTube);

            // 平面床
            // 平面幅200に対して、タイルのサイズを合わせる
            const planeMat = tubeMaterial.clone();
            planeMat.side = THREE.FrontSide;
            planeMat.map = gridTex.clone();
            planeMat.map.needsUpdate = true;
            // 幅200 / 5.89 ≈ 34リピート
            planeMat.map.repeat.set(34, 60000);

            groundPlane = new THREE.Mesh(new THREE.PlaneGeometry(200, 400000), planeMat);
            groundPlane.rotation.x = -Math.PI / 2;
            groundPlane.visible = false;
            scene.add(groundPlane);

            window.addEventListener('keydown', (e) => {
                if(["F1", "F2", "F3"].includes(e.code)) {
                    e.preventDefault();
                    viewMode = e.code;
                    updateViewLabel();
                }
                keys[e.code] = true;
                
                if(e.code === 'Space') {
                    e.preventDefault();
                    handleSpace();
                }
                if(e.code === 'KeyX') {
                    handleJump();
                }
            });
            window.addEventListener('keyup', (e) => {
                keys[e.code] = false;
            });
            
            updateViewLabel();
            changeState("TITLE");
            animate();
        }

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

        function updateStars() {
            if(stars) {
                stars.position.z = player.z;
                stars.rotation.z += 0.0005; 
            }
            // テクスチャスクロール
            const offsetSpeed = -player.z * 0.00017; // リピート数が多いのでスクロール速度係数は小さくする
            if(tubeMaterial) {
                tubeMaterial.map.offset.y = offsetSpeed;
            }
            if(groundPlane && groundPlane.visible) {
                groundPlane.material.map.offset.y = offsetSpeed;
            }
        }

        function updateViewLabel() {
            const labels = { "F1": "1ST PERSON", "F2": "PLANE MODE", "F3": "3RD PERSON" };
            document.getElementById('view-mode').innerText = "VIEW: " + labels[viewMode];
            groundTube.visible = (viewMode !== "F2");
            groundPlane.visible = (viewMode === "F2");
        }

        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');
                changeState("STAGE_START");
            }
            else if (gameState === "STAGE_START") {
                sound.play('ui');
                changeState("PLAYING");
            }
        }

        function handleJump() {
            if (gameState === "PLAYING" && player.altitude <= 0.01) {
                sound.play('jump');
                player.jumpV = 0.8; 
                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 3.0</h1><p>PRESS SPACE TO START</p><p style='font-size:18px; color:#aaa;'>[UP]: ACCEL / [DOWN]: BRAKE / [Z]: BOOST / [X]: JUMP</p>";
            else if(s === "STAGE_START") { 
                menu.innerHTML = "<h1>STAGE "+currentStage+"</h1><p>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: "+score+"</p><p>SPACE FOR NEXT STAGE</p>";
                    currentStage++;
                }
            }
            else if(s === "ALL_CLEAR") {
                menu.innerHTML = "<h1 style='color:#0ff;'>ALL CLEARED!</h1><p>FINAL SCORE: "+score+"</p><p>THANK YOU FOR PLAYING</p><p>SPACE TO TITLE</p>";
            }
            else if(s === "GAMEOVER") menu.innerHTML = "<h1 style='color:red;'>GAME OVER</h1><p>SCORE: "+score+"</p><p>SPACE TO RESTART</p>";
        }

        function resetPlayerPos() {
            player.angle = 0; player.x = 0; player.z = 0; player.vz = 0.5; player.altitude = 0; player.jumpV = 0;
            player.surge = 0; player.bank = 0;
            hp = MAX_HP;
            timeLeft = MAX_TIME;
            worldObjects.forEach(obj => scene.remove(obj.mesh));
            debris.forEach(d => scene.remove(d));
            worldObjects = [];
            debris = [];
            nextSpawnZ = 50;
        }

        function animate() {
            requestAnimationFrame(animate);
            if (gameState === "PLAYING") {
                updatePhysics();
                updateObjects();
                updateDebris();
            }
            
            // エンジン音更新
            if(gameState === "PLAYING") {
                const ratio = player.vz / BOOST_MAX_SPEED;
                sound.updateEngine(ratio, player.isBoosting);
            } else {
                sound.updateEngine(0, false);
            }

            updateCamera();
            updateStars();
            renderer.render(scene, camera);
        }

        function updatePhysics() {
            let targetMax = NORMAL_MAX_SPEED;
            let currentAccel = 0;

            player.isBoosting = false;

            if (keys['KeyZ']) {
                // ブースト:速度倍増
                player.isBoosting = true;
                targetMax = BOOST_MAX_SPEED;
                currentAccel = BOOST_ACCEL;
                // 加速演出
                player.surge = THREE.MathUtils.lerp(player.surge, 6.0, 0.05);
            } 
            else if (keys['ArrowUp']) {
                targetMax = NORMAL_MAX_SPEED;
                currentAccel = NORMAL_ACCEL;
                player.surge = THREE.MathUtils.lerp(player.surge, 2.0, 0.05);
            } 
            else {
                currentAccel = 0;
                player.surge = THREE.MathUtils.lerp(player.surge, 0, 0.05);
            }

            // ブレーキ
            if (keys['ArrowDown']) {
                player.vz *= 0.92;
                player.surge = THREE.MathUtils.lerp(player.surge, -2.0, 0.1);
            }

            if (currentAccel > 0) {
                if (player.vz < targetMax) {
                    player.vz += currentAccel;
                } else {
                    player.vz *= 0.98; // 超過分減衰
                }
            } else {
                player.vz *= 0.98;
            }
            player.vz = Math.max(0.1, player.vz);

            // スピード線演出
            const lineOpacity = Math.max(0, (player.vz - 8) / 10.0);
            document.getElementById('speed-lines').style.opacity = lineOpacity;

            // ジャンプ&着地
            player.altitude += player.jumpV;
            if (player.altitude > 0) {
                player.jumpV -= 0.03;
            } else { 
                if (player.wasAirborne) {
                    sound.play('land'); // 着地音
                    player.wasAirborne = false;
                }
                player.altitude = 0; 
                player.jumpV = 0; 
            }

            // 旋回(速度が速いほど旋回は鈍くする)
            let steerFactor = 1.0;
            if (player.vz > 10.0) steerFactor = 0.6; 

            let targetBank = 0;
            if (viewMode === "F2") {
                if (keys['ArrowLeft']) { player.vx = THREE.MathUtils.lerp(player.vx, 1.2 * steerFactor, 0.1); targetBank = 0.5; }
                else if (keys['ArrowRight']) { player.vx = THREE.MathUtils.lerp(player.vx, -1.2 * steerFactor, 0.1); targetBank = -0.5; }
                else player.vx *= 0.9;
                
                player.x = Math.max(-PLANE_W, Math.min(PLANE_W, player.x + player.vx));
                player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 0.1);
                player.mesh.position.set(player.x, 0.5 + player.altitude, player.z + player.surge);
                player.mesh.rotation.set(0, 0, (player.vx * 0.3) + player.bank);
            } else {
                if (keys['ArrowLeft']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0.12 * steerFactor, 0.1); targetBank = 0.8; }
                else if (keys['ArrowRight']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, -0.12 * steerFactor, 0.1); targetBank = -0.8; }
                else player.vAngle *= 0.9;
                
                player.angle += player.vAngle;
                player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 0.1);
                const r = TUBE_R - 0.5 - player.altitude;
                player.mesh.position.set(Math.sin(player.angle)*r, -Math.cos(player.angle)*r, player.z + player.surge);
                player.mesh.rotation.set(0, 0, player.angle + player.bank);
            }
            
            player.z += player.vz;
            
            timeLeft -= 1/60;
            if (timeLeft <= 0) {
                timeLeft = 0;
                changeState("STAGE_CLEAR");
            }
            if (hp <= 0) {
                hp = 0;
                changeState("GAMEOVER");
            }

            updateUI();
        }

        function updateCamera() {
            player.mesh.visible = (viewMode !== "F1");
            const shake = player.isBoosting ? (Math.random()-0.5)*0.3 : 0; 

            if (viewMode === "F1") { 
                camera.position.copy(player.mesh.position);
                camera.position.y += shake;
                camera.up.set(-player.mesh.position.x, -player.mesh.position.y, 0).normalize();
                camera.lookAt(player.mesh.position.x, player.mesh.position.y, player.z + 100);
            } else if (viewMode === "F2") { 
                camera.up.set(0, 1, 0);
                camera.position.set(player.x, 8 + shake, player.z - 25);
                camera.lookAt(player.x, 0, player.z + 50);
            } else { 
                // スピードでカメラが引く量も増やす
                const camDist = 20 + (player.vz * 1.5); 
                const camHeight = 6 + (player.vz * 0.2);
                const coreX = Math.sin(player.angle) * (TUBE_R - 0.5);
                const coreY = -Math.cos(player.angle) * (TUBE_R - 0.5);
                camera.position.x = coreX - Math.sin(player.angle) * camHeight;
                camera.position.y = coreY + Math.cos(player.angle) * camHeight + shake;
                camera.position.z = player.z - camDist;
                camera.up.set(-coreX, -coreY, 0).normalize();
                camera.lookAt(coreX, coreY, player.z + 40 + (player.vz * 4));
            }
        }

        function updateObjects() {
            // スピードが速いときは出現間隔を広げる
            let spawnDist = Math.max(10, 30 - (currentStage * 2.0)) + (player.vz * 2.0); 
            if (player.z + 600 > nextSpawnZ) { 
                spawnObject(nextSpawnZ); 
                nextSpawnZ += Math.random() * 10 + spawnDist;
            }
            for (let i = worldObjects.length - 1; i >= 0; i--) {
                const obj = worldObjects[i];
                if(obj.flying) {
                    obj.mesh.position.add(obj.vel);
                    obj.mesh.rotation.x += 0.1;
                    obj.mesh.rotation.y += 0.1;
                } else if (player.mesh.position.distanceTo(obj.mesh.position) < (obj.type === 'hurdle' ? 4.5 : 3.5)) {
                    handleCollision(obj, i);
                    if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") {
                        scene.remove(obj.mesh);
                        worldObjects.splice(i, 1);
                        continue;
                    }
                }
                if (obj.mesh.position.z < player.z - 100) {
                    scene.remove(obj.mesh);
                    worldObjects.splice(i, 1);
                }
            }
        }

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

            const colors = { block: 0xaa5533, hurdle: 0xff3300, score: 0xffff00, heal: 0x00ff00 };
            
            let stackCount = 1;
            if (type === 'block') stackCount = Math.floor(Math.random() * 5) + 1; 

            let planeX = (Math.random()-0.5)*PLANE_W*2;
            let tubeAngle = Math.random()*Math.PI*2;

            for (let k = 0; k < stackCount; k++) {
                let geo = (type === 'hurdle') ? new THREE.BoxGeometry(4,4,4) : ( (type==='score'||type==='heal') ? new THREE.BoxGeometry(1.5,1.5,1.5) : new THREE.BoxGeometry(2.5,2.5,2.5) );
                const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: colors[type] }));
                
                // ブースト速度だと一瞬で通り過ぎるので、ブロック間の隙間を広げる
                let zOffset = z + (k * 4.0);

                if (viewMode === "F2") {
                    let yPos = (type==='hurdle'?2:1.25);
                    mesh.position.set(planeX, yPos, zOffset);
                } else {
                    let baseR = TUBE_R;
                    if (type === 'hurdle') baseR -= 2;
                    else if (type === 'block') baseR -= 1.25; 
                    else baseR -= 0.7;

                    mesh.position.set(Math.sin(tubeAngle)*baseR, -Math.cos(tubeAngle)*baseR, zOffset);
                    mesh.rotation.z = tubeAngle;
                }
                scene.add(mesh);
                worldObjects.push({ mesh, type, flying: false, vel: new THREE.Vector3() });
            }
        }

        function handleCollision(obj, index) {
            const isHighSpeed = player.vz >= (NORMAL_MAX_SPEED + 2.0);

            if (obj.type === "block") {
                if (isHighSpeed) {
                    sound.play('crash'); 
                    createExplosion(obj.mesh.position, obj.mesh.material.color, 1.5);
                    scene.remove(obj.mesh);
                    worldObjects.splice(index, 1);
                    score += 50; showPopText("SMASH! +50", "#ffaa00");
                } else {
                    sound.play('crash');
                    obj.flying = true;
                    obj.vel.set((Math.random()-0.5)*2, 2, 5);
                    score += 10; showPopText("+10", "#ffaa00");
                    player.vz *= 0.8;
                }
            } else if (obj.type === "hurdle") {
                sound.play('crash');
                if (player.vz > 15.0) { // 超高速時はペナルティ軽減
                    hp -= 1; showPopText("IMPACT! HP -1", "#ff5500");
                    obj.flying = true;
                    obj.vel.set((Math.random()-0.5)*5, 5, 10);
                    player.vz *= 0.7;
                } else {
                    hp -= 2; showPopText("CRASH! HP -2", "#ff0000");
                    obj.flying = true;
                    obj.vel.set(0, 2, 2);
                    player.vz *= 0.5;
                }
            } 
            else if (obj.type === "score") { 
                sound.play('coin');
                score += 100; showPopText("+100", "#ffff00"); 
            }
            else if (obj.type === "heal") { 
                sound.play('heal');
                hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff00"); 
            }
        }

        function createExplosion(pos, color, scaleFactor) {
            const count = 12 * scaleFactor;
            for(let i=0; i<count; i++) {
                const size = 0.6 * scaleFactor;
                const mesh = new THREE.Mesh(new THREE.BoxGeometry(size, size, size), new THREE.MeshPhongMaterial({ color }));
                mesh.position.copy(pos);
                scene.add(mesh);
                // 爆散速度アップ
                const vel = new THREE.Vector3((Math.random()-0.5)*5, Math.random()*5, 5.0+Math.random()*10);
                debris.push({ mesh, vel, life: 60 });
            }
        }

        function updateDebris() {
            for(let i = debris.length - 1; i >= 0; i--) {
                const d = debris[i];
                d.mesh.position.add(d.vel);
                d.mesh.rotation.x += 0.2;
                d.life--;
                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-txt').innerText = "HP: " + hp;
            document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%";
            
            const spd = (player.vz * 40).toFixed(0);
            document.getElementById('speed-txt').innerText = "SPEED: " + spd;
            
            // スピードゲージ
            const speedRatio = player.vz / BOOST_MAX_SPEED;
            document.getElementById('speed-fill').style.width = Math.min(100, speedRatio * 100) + "%";
            
            if (player.isBoosting) document.getElementById('speed-fill').style.backgroundColor = "#ff00ff";
            else document.getElementById('speed-fill').style.backgroundColor = "#00ffff";

            document.getElementById('time-txt').innerText = "TIME: " + Math.max(0, timeLeft).toFixed(0);
            document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%";
        }

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

【操作説明】

・左右キー … 自機を左右に移動。
・上キー … アクセル。
・下キー … ブレーキ。
・スペースキー … ジャンプ。

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

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

【おまけ】

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

1981 Atari Tempest Arcade 

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

1983 ジャイラス コナミ

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

Bullfrog - Tube - 1995

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

Ballistics (2001) - PC

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

Tube Slider (2003) GameCube

・これは任天堂のレースゲームで、
 「F-ZERO」の後継シリーズとして開発されていたとのこと。

Space Giraffe(2007)Llamasoft

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

「Race The Sun」Flippfly (2013)

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

「Thumper」Drool(2016)

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

「Tunnel Rush」(2024?)

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

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

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

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
買うたび 抽選 ※条件・上限あり \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