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

【更新機歴】

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

画像


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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Image Booster 4.0</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(100, 255, 150, 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; 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); }
        .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: 48px; line-height: 1; color: #00ffff; text-shadow: 0 0 10px #00ffff; }
        #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: 60px; margin: 0; color: #0f0; text-shadow: 0 0 20px #0f0; }
        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">HP: <span id="hp-num" style="font-size: 32px;">10</span></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(); },
            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.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);

                    // ガラガラ感を出すための変調(AM)
                    const modOsc = audioCtx.createOscillator();
                    modOsc.type = 'square';
                    modOsc.frequency.value = 20; // 20Hzで揺らす
                    const modGain = audioCtx.createGain();
                    modGain.gain.value = 0.5;
                    
                    modOsc.connect(modGain);
                    modGain.connect(g.gain); // 音量を揺らす
                    modOsc.start(now); modOsc.stop(now + 1.2);

                    src.connect(f); f.connect(g); g.connect(audioCtx.destination); 
                    src.start(now); src.stop(now + 1.2);

                } else if (type === 'heal') {
                    const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
                    osc.connect(g); g.connect(audioCtx.destination); osc.type = 'sine';
                    osc.frequency.setValueAtTime(600, now); osc.frequency.exponentialRampToValueAtTime(2000, now+0.4);
                    g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0, now+0.4);
                    osc.start(now); osc.stop(now+0.4);
                } else if (type === 'boost_dash') {
                    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.exponentialRampToValueAtTime(50, now + 0.3);
                    g.gain.setValueAtTime(0.5, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
                    osc.start(now); osc.stop(now + 0.3);
                } 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);
                }
            }
        };

        // --- Config ---
        const MAX_HP = 10, TUBE_R = 18, MAX_TIME = 60; // 15 -> 18(タイルを外側に)
        const NORMAL_MAX_SPEED = 5.0; 
        const BOOST_MAX_SPEED = 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,
            dashOffset: 0, 
            wasAirborne: false
        };
        
        let worldObjects = [], debris = [], stars;
        let keys = {};
        let nextSpawnZ = 50;

        // --- Tunnel & Curve Logic ---
        const TILE_SEGMENT_LENGTH = 8; 
        const VISIBLE_SEGMENTS = 40;   
        const TILES_PER_RING = 16;
        let tunnelSegments = [];       
        
        // カーブ関数:基本は緩やかな左右カーブ + 急な上下カーブ
        function getCurve(z) {
            const scale = 0.002;
            // 基本的な緩やかな左右カーブ
            let x = Math.sin(z * scale) * 120 + Math.sin(z * scale * 2.3) * 60;
            // 基本的な緩やかな上下カーブ
            let y = Math.cos(z * scale * 0.7) * 90 + Math.sin(z * scale * 1.5) * 50;
            
            // 急カーブ区間を追加(上り坂・下り坂)
            const sharpCurveInterval = 4000; // 急カーブの間隔
            const sharpCurveWidth = 400; // 急カーブの幅
            const segmentInCycle = z % sharpCurveInterval;
            
            if (segmentInCycle < sharpCurveWidth) {
                // 急な上下カーブ区間
                const t = segmentInCycle / sharpCurveWidth; // 0 -> 1
                const sharpness = Math.sin(t * Math.PI) * 150; // 上下の急カーブ
                // Y軸(上下)に大きな変化を加える
                y += sharpness;
                // X軸(左右)は緩やかに
                x += Math.sin(z * 0.003) * 30;
            }
            
            return new THREE.Vector3(x, y, z);
        }

        // コースの基準ベクトル(接線、法線、従法線)を取得
        function getBasis(z) {
            const origin = getCurve(z);
            const forwardPoint = getCurve(z + 1.0);
            
            // Tangent (Z軸進行方向)
            const T = new THREE.Vector3().subVectors(forwardPoint, origin).normalize();
            
            // 仮の上方向
            const tempUp = new THREE.Vector3(0, 1, 0);
            
            // Right (Binormal)
            const R = new THREE.Vector3().crossVectors(T, tempUp).normalize();
            
            // Corrected Up (Normal)
            const U = new THREE.Vector3().crossVectors(R, T).normalize();

            return { origin, T, U, R };
        }

        // 断面上の座標を計算する関数
        function getSectionPosition(angle, radius, basis, mode) {
            let localX, localY;

            if (mode === "F2") {
                // F2モード:下半分のU字型(ハーフパイプ)
                // 角度を単純にマッピングするのではなく、全周のタイルを下半分のU字ラインに再配置する
                
                // angleは 0~2PI なので、これを -PI/2 ~ PI/2 (下半分の弧) に圧縮してマッピング
                // ただし、angle 0 が下(6時)。
                // プレイヤーの操作感と合わせるため、以下のように変換
                
                // 入力 angle: 0(下), PI(上)
                // U字展開: 0を中心として左右に広げる
                
                let a = angle;
                // 正規化 (-PI ~ PI)
                while (a > Math.PI) a -= Math.PI * 2;
                while (a < -Math.PI) a += Math.PI * 2;

                // F2ではトンネルを開いて、緩やかなU字谷にする
                // a (回転角) を横方向の位置(spread)に変換
                const spread = radius * 0.9; // 道幅を半分に(1.8 -> 0.9)
                localX = a / Math.PI * spread * 2.5; // 横に広く
                
                // Y座標は放物線的(U字)にする
                // 中心(0)が低く、端が高い
                localY = (a * a * 0.8) - radius; 

            } else {
                // 通常トンネルモード (円形)
                localX = Math.sin(angle) * radius;
                localY = -Math.cos(angle) * radius;
            }

            // ワールド座標に変換
            const pos = basis.origin.clone()
                .addScaledVector(basis.R, localX)
                .addScaledVector(basis.U, localY);
            
            return pos;
        }

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

        function init() {
            sound.init();
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x000000);
            scene.fog = new THREE.Fog(0x000000, 50, 600); // 20,300 -> 50,600に変更

            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, 1000);
            
            scene.add(new THREE.AmbientLight(0xffffff, 0.4));
            const sun = new THREE.DirectionalLight(0xffffee, 1.0);
            sun.position.set(0, 100, -50);
            scene.add(sun);
            const rim = new THREE.DirectionalLight(0x00aaff, 0.8);
            rim.position.set(0, -50, 50);
            scene.add(rim);

            createStars();

            // プレイヤーモデル(正方形の箱型 + ロケット噴射)
            const playerGeo = new THREE.Group();
            const body = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshPhongMaterial({ color: 0x00ffff, emissive: 0x004444 }));
            playerGeo.add(body);

            // ロケット噴射エフェクト(長い円錐形)
            const thrustGeo = new THREE.ConeGeometry(0.8, 8, 8); // 長さ3 -> 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 = -5; // 自機の後ろ(-2.5 -> -5)
            playerGeo.add(thrust);
            player.thrust = thrust; // 後で制御できるように保存

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

            initTunnel();

            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();
                if(e.code === 'KeyZ' && !player.isBoosting && gameState === 'PLAYING') {
                    player.dashOffset = 0; 
                    sound.play('boost_dash');
                }
            });
            window.addEventListener('keyup', (e) => { keys[e.code] = false; });
            
            updateViewLabel();
            changeState("TITLE");
            animate();
        }

        function initTunnel() {
            // タイルの幅を計算(円周をタイル数で割る)
            const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
            const boxGeo = new THREE.BoxGeometry(tileWidth * 0.95, 0.15, TILE_SEGMENT_LENGTH * 0.95); // 厚さ 0.3 -> 0.15
            for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
                const segmentGroup = new THREE.Group();
                segmentGroup.userData = { zIndex: i }; 
                for (let j = 0; j < TILES_PER_RING; j++) {
                    const mat = new THREE.MeshPhongMaterial({ 
                        color: getGrassColor(),
                        transparent: true,
                        opacity: 0.7
                    });
                    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 - 5; 

            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());
                        tile.visible = true; 
                    });

                    // 穴あき処理
                    const gapNoise = Math.sin(segIdx * 0.3) + Math.sin(segIdx * 0.1);
                    if (gapNoise < -0.8) seg.children.forEach(t => t.visible = false);
                    else if (gapNoise < 0) seg.children.forEach(t => { if(Math.random() < 0.3) t.visible = false; });
                }

                // セグメント配置
                const zPos = segIdx * TILE_SEGMENT_LENGTH;
                const basis = getBasis(zPos);
                
                seg.children.forEach((tile, j) => {
                    const angle = (j / TILES_PER_RING) * Math.PI * 2;
                    
                    // 位置決定(完璧な円形配置)
                    const pos = getSectionPosition(angle, TUBE_R, basis, viewMode);
                    tile.position.copy(pos);

                    if (viewMode === "F2") {
                        // F2モードの回転
                        const center = basis.origin.clone();
                        const virtualCenter = center.clone().add(basis.U.clone().multiplyScalar(40));
                        let tileUp = new THREE.Vector3().subVectors(pos, virtualCenter).normalize();
                        const tileForward = basis.T.clone();
                        const tileRight = new THREE.Vector3().crossVectors(tileForward, tileUp).normalize();
                        const correctedTileUp = new THREE.Vector3().crossVectors(tileRight, tileForward).normalize();
                        const m = new THREE.Matrix4();
                        m.makeBasis(tileRight, correctedTileUp, tileForward);
                        tile.rotation.setFromRotationMatrix(m);
                    } else {
                        // 通常モード:円筒の内壁として完璧な配置
                        // 
                        // ローカル座標での円形配置から法線を計算
                        const localX = Math.sin(angle) * TUBE_R;
                        const localY = -Math.cos(angle) * TUBE_R;
                        
                        // ローカル座標系での法線(円の中心から外向き)
                        // 中心は(0,0)なので、法線は単純に(localX, localY)の方向
                        const localNormal = new THREE.Vector2(localX, localY).normalize();
                        
                        // ワールド座標系での法線ベクトル
                        // basis.R が横方向、basis.U が縦方向
                        const worldNormal = new THREE.Vector3()
                            .addScaledVector(basis.R, localNormal.x)
                            .addScaledVector(basis.U, localNormal.y)
                            .normalize();
                        
                        // タイルの座標系を構築
                        const forward = basis.T.clone(); // 進行方向
                        const up = worldNormal; // 法線方向(外向き)
                        const right = new THREE.Vector3().crossVectors(forward, up).normalize();
                        const correctedUp = new THREE.Vector3().crossVectors(right, forward).normalize();
                        
                        // 回転行列を作成
                        const m = new THREE.Matrix4();
                        m.makeBasis(right, correctedUp, forward);
                        tile.rotation.setFromRotationMatrix(m);
                    }
                });
            });
        }

        function createStars() {
            const starGeo = new THREE.BufferGeometry();
            const starCount = 3000;
            const posArray = new Float32Array(starCount * 3);
            for(let i=0; i<starCount * 3; i++) posArray[i] = (Math.random() - 0.5) * 1000; 
            starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
            const starMat = new THREE.PointsMaterial({color: 0xffffff, size: 1.5, 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.0002; 
            }
        }

        function updateViewLabel() {
            const labels = { "F1": "1ST PERSON", "F2": "U-PIPE MODE", "F3": "3RD PERSON" };
            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'); 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 4.0</h1><p style='color:#adff2f;'>ROCK & ROLL</p><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; player.dashOffset = 0;
            hp = MAX_HP; timeLeft = MAX_TIME;
            
            worldObjects.forEach(obj => scene.remove(obj.mesh));
            debris.forEach(d => scene.remove(d));
            worldObjects = []; debris = [];
            nextSpawnZ = 50;
            
            tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; });
        }

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

            updateTunnel(); 
            updatePlayerVisuals(); 
            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);
                if(player.dashOffset < 15.0) player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 20.0, 0.2); 
            } 
            else if (keys['ArrowUp']) {
                targetMax = NORMAL_MAX_SPEED;
                currentAccel = NORMAL_ACCEL;
                player.surge = THREE.MathUtils.lerp(player.surge, 2.0, 0.05);
                player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 0.1);
            } 
            else {
                currentAccel = 0;
                player.surge = THREE.MathUtils.lerp(player.surge, 0, 0.05);
                player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 0.1);
            }

            if (keys['ArrowDown']) {
                player.vz *= 0.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; 

            // F2モードの速度調整を削除(操作が効くように)
            // if (viewMode === "F2") steerFactor *= 0.5; // この行を削除

            let targetBank = 0;

            // F1/F3モードでの操作修正
            // 左キー: 負の回転(vAngle-) -> 画面上では左へ
            // 右キー: 正の回転(vAngle+) -> 画面上では右へ
            
            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);
            player.z += player.vz;
            
            timeLeft -= 1/60;
            if (timeLeft <= 0) { timeLeft = 0; changeState("STAGE_CLEAR"); }
            if (hp <= 0) { hp = 0; changeState("GAMEOVER"); }

            updateUI();
        }

        function updatePlayerVisuals() {
            const visualZ = player.z + player.surge + player.dashOffset;
            const basis = getBasis(visualZ);

            const r = TUBE_R - 1.5 - player.altitude;
            const pos = getSectionPosition(player.angle, r, basis, viewMode);

            // 姿勢計算
            const center = basis.origin.clone();
            // 基本Upベクトル
            let playerUp = new THREE.Vector3().subVectors(center, pos).normalize();
            
            // F2のU字谷では、プレイヤーのUpベクトルも仮想中心(上空)に向ける
            if (viewMode === "F2") {
                const virtualCenter = center.clone().add(basis.U.clone().multiplyScalar(40));
                playerUp = new THREE.Vector3().subVectors(virtualCenter, pos).normalize();
            }

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

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

            // ロケット噴射エフェクトの更新
            if (player.thrust) {
                const speedRatio = player.vz / BOOST_MAX_SPEED;
                player.thrust.scale.z = 0.5 + speedRatio * 1.5; // 速度に応じて伸びる
                player.thrust.material.opacity = 0.4 + speedRatio * 0.4;
                
                // ブースト時は色を変える
                if (player.isBoosting) {
                    player.thrust.material.color.setHex(0xff00ff); // マゼンタ
                } else {
                    player.thrust.material.color.setHex(0xff6600); // オレンジ
                }
                
                // 揺らめき効果
                player.thrust.scale.x = 0.8 + Math.random() * 0.4;
                player.thrust.scale.y = 0.8 + Math.random() * 0.4;
            }

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

        function updateCamera(playerPos, playerUp, playerForward, visualZ) {
            const shake = player.isBoosting ? (Math.random()-0.5)*0.3 : 0; 
            
            if (viewMode === "F1") {
                const eyePos = playerPos.clone().addScaledVector(playerForward, 2.0).addScaledVector(playerUp, 0.5);
                camera.position.copy(eyePos);
                camera.position.y += shake; 
                camera.up.copy(playerUp);
                const target = eyePos.clone().add(playerForward);
                camera.lookAt(target);
            } 
            else if (viewMode === "F2") { 
                // U-PIPE Mode: 自機のやや後ろ上から見下ろす
                const offset = new THREE.Vector3(0, 30, -40); // 後ろ上の位置
                camera.position.copy(playerPos).add(offset);
                camera.up.set(0, 1, 0); 
                camera.lookAt(playerPos); // 自機を注視
            } 
            else { 
                const camDist = 20 + (player.vz * 1.0); 
                const camHeight = 6 + (player.vz * 0.2);

                const camPos = playerPos.clone()
                    .addScaledVector(playerForward, -camDist) 
                    .addScaledVector(playerUp, camHeight);    
                
                camera.position.copy(camPos);
                camera.position.addScaledVector(camera.position.clone().cross(playerForward).normalize(), shake);
                camera.up.copy(playerUp);
                
                const target = playerPos.clone().addScaledVector(playerForward, 20);
                camera.lookAt(target);
            }
        }

        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.localPos.add(obj.vel);
                    obj.mesh.rotation.x += 0.1;
                    obj.mesh.rotation.y += 0.1;
                } 

                const objVisualZ = obj.z; 
                const basis = getBasis(objVisualZ);
                
                let worldPos;
                if (obj.flying) {
                    worldPos = basis.origin.clone().add(obj.localPos); 
                } else {
                    worldPos = getSectionPosition(obj.angle, obj.r, basis, viewMode);
                    
                    // オブジェクトの姿勢制御(タイルと同様に面に合わせる)
                    const center = basis.origin.clone();
                    let up = new THREE.Vector3().subVectors(center, worldPos).normalize();
                    if(viewMode === "F2") {
                         const virtualCenter = center.clone().add(basis.U.clone().multiplyScalar(40));
                         up = new THREE.Vector3().subVectors(virtualCenter, worldPos).normalize();
                    }

                    const forward = basis.T.clone();
                    const m = new THREE.Matrix4();
                    const right = new THREE.Vector3().crossVectors(forward, up).normalize();
                    const orthoUp = new THREE.Vector3().crossVectors(right, forward).normalize();
                    m.makeBasis(right, orthoUp, forward);
                    obj.mesh.rotation.setFromRotationMatrix(m);
                }
                
                obj.mesh.position.copy(worldPos);

                const dz = Math.abs(player.z - obj.z);
                if (dz < 2.5 && !obj.flying) {
                    // 衝突判定
                    // F2モードでの座標変換によるズレを考慮し、角度差判定を甘くするか、距離判定にする
                    // ここでは簡易的に「位置距離」で判定する
                    const dist = player.mesh.position.distanceTo(obj.mesh.position);
                    
                    if (dist < 4.0) { // 判定距離
                        handleCollision(obj, i);
                        if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") {
                            scene.remove(obj.mesh);
                            worldObjects.splice(i, 1);
                            continue;
                        }
                    }
                }
                if (obj.z < player.z - 50) {
                    scene.remove(obj.mesh);
                    worldObjects.splice(i, 1);
                }
            }
        }

        function spawnObject(z) {
            let hurdleRate = Math.min(0.5, 0.15 + (currentStage * 0.05));
            // ブロック出現率をさらに上げる(デフォルトをblockに)
            let type = (Math.random() < 0.15) ? "score" : (Math.random() < 0.08) ? "heal" : "block";
            if (Math.random() < hurdleRate) type = "hurdle";

            const colors = { block: 0xaa5533, hurdle: 0xff3300, score: 0xffff00, heal: 0x00ff00 };
            let stackCount = (type === 'block') ? Math.floor(Math.random() * 7) + 4 : 1; // 4~10個(1~5 -> 4~10) 

            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);
                let baseR = TUBE_R;
                if (type === 'hurdle') baseR -= 2;
                else if (type === 'block') baseR -= 1.25; 
                else baseR -= 0.7;

                // localPos初期化用(F2で位置が変わるのであくまで相対保存用)
                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() 
                });
            }
        }

        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');
                    // 破片を飛び散らせる
                    createExplosion(obj.mesh.position, obj.mesh.material.color, 0.8);
                    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"); 
                scene.remove(obj.mesh);
                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);
                worldObjects.splice(index, 1);
            }
        }

        function createExplosion(pos, color, scaleFactor) {
            const count = 8 * 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)*10, (Math.random()-0.5)*10, (Math.random()-0.5)*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;
            
            // HP表示(数字を大きく)
            document.getElementById('hp-num').innerText = hp;
            document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%";
            
            const spd = (player.vz * 40).toFixed(0);
            document.getElementById('speed-val').innerText = 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 = "#00ff88";

            // タイム表示(残り時間を分:秒で表示)
            const remainingTime = Math.max(0, timeLeft);
            const minutes = Math.floor(remainingTime / 60);
            const seconds = Math.floor(remainingTime % 60);
            const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
            document.getElementById('time-num').innerText = timeStr;
            document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%";
        }

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

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

【操作説明】

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

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

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

【おまけ】

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

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

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

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

便利なツール

  • 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