ドライブゲーム「HyperDrive」

【更新履歴】

 ・2026/6/19 バージョン1.0公開。

画像


画像


画像


【概要】

・本作は、以前作っていた「Image Booster」の
 主に見た目を改良したものです。


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

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

game.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HYPER DRIVE // Image Booster 8.0</title>
    <style>
        :root{
            --neon-cyan:#00f0ff; --neon-mag:#ff2a6d; --neon-yel:#fee440; --neon-grn:#adff2f;
        }
        @font-face{}
        body { margin: 0; overflow: hidden; background: #05030f; font-family: 'Arial Black', Arial, sans-serif; }
        canvas { display:block; }

        #ui-layer { position: absolute; inset:0; width: 100%; height: 100%; pointer-events: none; color: white; text-shadow: 0 2px 6px #000; z-index: 10; }

        #view-mode { position: absolute; top: 18px; right: 20px; font-size: 16px; color: var(--neon-cyan); letter-spacing:1px; opacity:.85; }
        #stage-info { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); font-size: 18px; color: var(--neon-yel); letter-spacing:2px; }
        #stage-pips { position:absolute; top:44px; left:50%; transform:translateX(-50%); display:flex; gap:6px; }
        .pip{ width:16px; height:5px; border-radius:3px; background:rgba(255,255,255,.18); box-shadow:0 0 4px rgba(0,0,0,.6);}
        .pip.done{ background:var(--neon-cyan); box-shadow:0 0 10px var(--neon-cyan);}
        .pip.cur{ background:var(--neon-yel); box-shadow:0 0 12px var(--neon-yel);}
        #score-info { position: absolute; top: 16px; left: 20px; font-size: 22px; color: #fff; letter-spacing:1px;}
        #score-info small{ font-size:12px; color:#8be9ff; display:block; letter-spacing:3px; }

        /* Animated speed streaks overlay (CSS layer on top of WebGL streaks) */
        #speed-lines { position: absolute; inset:0; opacity: 0; transition: opacity 0.12s ease-out; pointer-events: none; z-index: 5; mix-blend-mode: screen;
            background:
              radial-gradient(circle at 50% 50%, transparent 22%, rgba(120,230,255,0.05) 55%, rgba(160,210,255,0.16) 100%),
              repeating-conic-gradient(from 0deg at 50% 50%, transparent 0deg, transparent 1.4deg, rgba(180,240,255,0.20) 1.5deg, transparent 1.7deg);
        }
        #vignette{ position:absolute; inset:0; pointer-events:none; z-index:6;
            background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 120%); opacity:.7; }
        #boost-flash{ position:absolute; inset:0; pointer-events:none; z-index:7; background:radial-gradient(circle at 50% 60%, rgba(255,60,170,.0) 40%, rgba(255,60,170,.0) 100%); opacity:0; transition:opacity .25s; mix-blend-mode:screen;}

        .lock-on-sight { position: absolute; width: 40px; height: 40px; border: 3px solid var(--neon-grn); border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 12px var(--neon-grn), inset 0 0 6px var(--neon-grn); z-index: 8; transition:width .08s,height .08s; }
        .lock-on-sight::after{ content:''; position:absolute; inset:-9px; border:1px solid currentColor; border-radius:50%; opacity:.4; }
        .lock-on-sight.rushing { border-width: 5px; width: 64px; height: 64px; background: rgba(255, 255, 255, 0.18); }

        #hud-bottom { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%); width: 88%; max-width:1100px; display: none; flex-direction: row; justify-content: space-between; align-items: flex-end; gap:18px;}
        .gauge-container { flex:1; text-align: center; }
        .gauge-label { font-size: 14px; margin-bottom: 6px; letter-spacing:2px; color:#cfe9ff;}
        .bar-bg { width: 100%; height: 12px; border: 2px solid rgba(255,255,255,.85); border-radius: 6px; overflow: hidden; background: rgba(0,0,0,0.45); transform: skewX(-20deg); box-shadow:0 0 10px rgba(0,0,0,.6);}
        .bar-fill { height: 100%; width: 100%; transition: width 0.06s linear; }
        #hp-fill { background: linear-gradient(90deg,#7bff5a,#adff2f); box-shadow: 0 0 12px var(--neon-grn); }
        #energy-fill { background: linear-gradient(90deg,#ff9e2a,#ffe14d); box-shadow:0 0 12px #ffcc33;}
        #energy-fill.overheat{ background:linear-gradient(90deg,#ff3b3b,#ff7b00); box-shadow:0 0 16px #ff3b3b;}
        .speed-wrap{ flex:1.3; text-align:center;}
        #speed-val { font-size: 60px; line-height: 1; color: var(--neon-cyan); text-shadow: 0 0 18px var(--neon-cyan); font-style: italic; }
        #speed-unit { font-size: 18px; color: #9fd6e8; margin-left: 5px; letter-spacing:2px;}
        #speed-fill { background: var(--neon-cyan); box-shadow:0 0 12px var(--neon-cyan);}
        #time-fill { background: linear-gradient(90deg,#ffb700,#ffcc00); box-shadow:0 0 10px #ffcc00;}

        #center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; pointer-events: auto; }
        h1 { font-size: 72px; margin: 0; color: var(--neon-cyan); text-shadow: 0 0 30px var(--neon-cyan), 0 0 60px rgba(0,240,255,.4); font-style: italic; letter-spacing: -2px; }
        h1.tag{ font-size:24px; letter-spacing:8px; color:var(--neon-mag); text-shadow:0 0 18px var(--neon-mag); margin-top:-6px;}
        p { font-size: 22px; margin: 10px 0; color: #fff; }
        .hint{ font-size:14px; color:#8be9ff; letter-spacing:1px; line-height:1.8;}
        .pop-text { position: absolute; font-weight: bold; font-size: 38px; animation: moveUpFade 0.8s forwards; transform: translate(-50%, -50%); z-index: 999; text-shadow: 0 0 12px currentColor; }
        @keyframes moveUpFade { 0% { transform: translate(-50%, 0) scale(1); opacity: 1; } 100% { transform: translate(-50%, -120px) scale(1.6); opacity: 0; } }

        .title-btn { background: linear-gradient(180deg,#0b8fe0,#0667a8); color: #fff; padding: 12px 22px; font-size: 18px; border: 2px solid #2bd3ff; cursor: pointer; border-radius: 6px; margin: 8px; font-family: 'Arial Black'; transition: 0.18s; box-shadow:0 0 0 rgba(43,211,255,0);}
        .title-btn:hover { background: linear-gradient(180deg,#1aa6f7,#0c7fc9); box-shadow: 0 0 22px var(--neon-cyan); }
        .start-btn{ background:linear-gradient(180deg,#ff2a6d,#c01250); border-color:#ff7aa6;}
        .start-btn:hover{ box-shadow:0 0 26px var(--neon-mag);}
        .course-select { padding: 10px; font-size: 15px; font-family: sans-serif; background: #141826; color: white; border: 1px solid #2bd3ff; border-radius: 6px; margin-bottom: 12px; width: 280px; }
        #controls-help{ position:absolute; bottom:14px; left:18px; font-size:11px; color:#6fb6cf; letter-spacing:1px; line-height:1.7; opacity:.8; z-index:10;}
        #controls-help b{ color:#cfeefc;}
        #fps{ position:absolute; bottom:14px; right:18px; font-size:11px; color:#4f6a78; z-index:10;}
    </style>
    <script type="importmap"> 
        { "imports": { 
            "three": "https://unpkg.com/three@0.160.0/build/three.module.js", 
            "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" 
        } } 
    </script>
</head>
<body>
    <div id="speed-lines"></div>
    <div id="vignette"></div>
    <div id="boost-flash"></div>
    <div id="ui-layer">
        <div id="score-info">SCORE: 0<small>HYPER DRIVE</small></div>
        <div id="stage-info">STAGE 1</div>
        <div id="stage-pips"></div>
        <div id="view-mode">VIEW: F3</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 class="gauge-label">BOOST</div><div class="bar-bg" id="energy-bg"><div id="energy-fill" class="bar-fill"></div></div></div>
            <div class="speed-wrap"><div style="margin-bottom:6px;"><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: 26px;">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>
    <div id="controls-help"><b>↑/↓</b> ACCEL/BRAKE &nbsp; <b>←/→</b> STEER &nbsp; <b>Z</b> BOOST/RUSH &nbsp; <b>X</b> JUMP &nbsp; <b>F1-F5</b> VIEW &nbsp; <b>ESC</b> PAUSE</div>
    <div id="fps"></div>

    <script type="module">
        import * as THREE from 'three';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
        import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

        /* ================= ASSET / VFS ================= */
        let vfs = {}; 
        let courseFiles = {}; 

        function handleFolderSelect(files) {
            vfs = {}; courseFiles = {};
            const status = document.getElementById('load-status'); if(status) status.innerText = "SCANNING ASSETS...";
            for (let f of files) {
                const parts = f.webkitRelativePath.split('/'); parts.shift();
                const relPath = parts.join('/');
                vfs[relPath] = URL.createObjectURL(f);
                vfs[f.name] = vfs[relPath]; 
                if (f.name.endsWith('.json')) {
                    const r = new FileReader(); r.onload = e => { try{ courseFiles[f.name] = JSON.parse(e.target.result); populateCourseSelect(); }catch(err){} }; r.readAsText(f);
                }
            }
            if(status) status.innerText = "ASSETS READY. PRESS START.";
        }

        function populateCourseSelect() {
            const select = document.getElementById('course-select-box'); if(!select) return;
            select.innerHTML = ''; for(let name in courseFiles) { const opt = document.createElement('option'); opt.value = name; opt.innerText = name; select.appendChild(opt); }
            select.style.display = Object.keys(courseFiles).length ? 'inline-block' : 'none';
        }

        function getCourseSelectUI() {
            return `
                <div style="margin-top:22px; pointer-events:auto;">
                    <label class="title-btn" style="background:linear-gradient(180deg,#444,#2a2a2a); border-color:#666;">LOAD ASSETS FOLDER (OPTIONAL)<input type="file" id="assets-folder" webkitdirectory directory multiple style="display:none;"></label>
                    <div id="load-status" style="margin:8px; color:#8be9ff; font-size:13px;">Default neon course will be used.</div>
                    <select id="course-select-box" class="course-select" style="display:none;"></select><br>
                    <button id="start-btn" class="title-btn start-btn" style="font-size:24px;">START GAME (SPACE)</button>
                </div>`;
        }

        let customCourseCurve = null, customCourseLength = 0, customCourseFrames = { tangents: [], normals: [], binormals: [] }, customCourseData = null;
        let loadedModels = {}, loadedTextures = {}, envTextures = {};
        const manager = new THREE.LoadingManager();
        manager.setURLModifier(url => { const fName = url.split('/').pop(); return vfs[fName] ? vfs[fName] : (vfs[url] ? vfs[url] : url); });
        const gltfLoader = new GLTFLoader(manager), objLoader = new OBJLoader(manager), texLoader = new THREE.TextureLoader(manager);

        function getLoopCatmullRom(values, t) {
            if(values.length === 0) return 0; const len = values.length; const exact = t * len;
            let i1 = Math.floor(exact) % len; if(i1 < 0) i1 += len;
            const frac = exact - Math.floor(exact);
            const i0 = (i1 - 1 + len) % len, i2 = (i1 + 1) % len, i3 = (i1 + 2) % len;
            const v1 = values[i1];
            let v0 = values[i0]; while(v0 - v1 > 180) v0 -= 360; while(v0 - v1 < -180) v0 += 360;
            let v2 = values[i2]; while(v2 - v1 > 180) v2 -= 360; while(v2 - v1 < -180) v2 += 360;
            let v3 = values[i3]; while(v3 - v2 > 180) v3 -= 360; while(v3 - v2 < -180) v3 += 360;
            const c0 = v1, c1 = 0.5 * (v2 - v0), c2 = v0 - 2.5 * v1 + 2 * v2 - 0.5 * v3, c3 = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3;
            return ((c3 * frac + c2) * frac + c1) * frac + c0;
        }

        function loadCourseAndStart(filename) {
            const data = courseFiles[filename]; customCourseData = data;
            if (data.track && data.track.length >= 3) {
                const points = data.track.map(p => new THREE.Vector3(p.x, p.y, p.z));
                customCourseCurve = new THREE.CatmullRomCurve3(points, true);
                customCourseLength = customCourseCurve.getLength();
                const SEGMENTS = data.track.length * 20; customCourseFrames = { tangents: [], normals: [], binormals: [] };
                for(let i=0; i<=SEGMENTS; i++) customCourseFrames.tangents.push(customCourseCurve.getTangentAt(customCourseCurve.getUtoTmapping(i/SEGMENTS)).normalize());
                customCourseFrames.normals[0] = new THREE.Vector3(0,1,0); 
                customCourseFrames.binormals[0] = new THREE.Vector3().crossVectors(customCourseFrames.tangents[0], customCourseFrames.normals[0]).normalize();
                customCourseFrames.normals[0].crossVectors(customCourseFrames.binormals[0], customCourseFrames.tangents[0]).normalize();
                for(let i=1; i<=SEGMENTS; i++) {
                    const axis = new THREE.Vector3().crossVectors(customCourseFrames.tangents[i-1], customCourseFrames.tangents[i]);
                    const sin = axis.length(), cos = customCourseFrames.tangents[i-1].dot(customCourseFrames.tangents[i]), angle = Math.atan2(sin, cos);
                    const q = new THREE.Quaternion(); if(sin > 0.0001) q.setFromAxisAngle(axis.normalize(), angle);
                    customCourseFrames.normals.push(customCourseFrames.normals[i-1].clone().applyQuaternion(q));
                    customCourseFrames.binormals.push(customCourseFrames.binormals[i-1].clone().applyQuaternion(q));
                }
                let twistTotal = Math.acos(Math.max(-1, Math.min(1, customCourseFrames.normals[0].dot(customCourseFrames.normals[SEGMENTS]))));
                if (new THREE.Vector3().crossVectors(customCourseFrames.normals[SEGMENTS], customCourseFrames.normals[0]).dot(customCourseFrames.tangents[0]) < 0) twistTotal = -twistTotal;
                for(let i=1; i<=SEGMENTS; i++) { const q = new THREE.Quaternion().setFromAxisAngle(customCourseFrames.tangents[i], (i/SEGMENTS)*twistTotal); customCourseFrames.normals[i].applyQuaternion(q); customCourseFrames.binormals[i].applyQuaternion(q); }
            }

            document.getElementById('center-text').innerHTML = "<h2 style='color:#fff;'>LOADING SCENE...</h2>";
            let modelsToLoad = data.assets?.bgModels || [], texToLoad = [ ...(data.assets?.blockTextures || []), data.assets?.env?.road, data.assets?.env?.wall, data.assets?.env?.sky, data.assets?.env?.ground ].filter(Boolean);
            if(data.scenery) data.scenery.forEach(s => { if(s.type === 'texture' && s.texturePath && !texToLoad.includes(s.texturePath)) texToLoad.push(s.texturePath); });

            let loadedCount = 0; const total = modelsToLoad.length + texToLoad.length;
            const checkDone = () => { loadedCount++; if(loadedCount >= total) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score = 0; currentStage = 1; changeState("STAGE_START"); } };
            if(total === 0) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score=0; currentStage=1; changeState("STAGE_START"); return; }

            modelsToLoad.forEach(p => {
                if(loadedModels[p]) { checkDone(); return; } const url = vfs[p] || vfs[p.split('/').pop()]; if(!url) { checkDone(); return; }
                if(p.endsWith('.obj')) objLoader.load(url, o => { loadedModels[p] = o; checkDone(); }, undefined, ()=>checkDone()); else gltfLoader.load(url, g => { loadedModels[p] = g.scene; checkDone(); }, undefined, ()=>checkDone());
            });
            texToLoad.forEach(p => {
                if(loadedTextures[p]) { checkDone(); return; } const url = vfs[p] || vfs[p.split('/').pop()]; if(!url) { checkDone(); return; }
                texLoader.load(url, t => { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = THREE.SRGBColorSpace; loadedTextures[p] = t; checkDone(); }, undefined, ()=>checkDone());
            });
        }

        function applyEnvironment() {
            const env = customCourseData?.assets?.env || {};
            const usingCustomGround = !!(env.ground && loadedTextures[env.ground]);
            const usingCustomSky = !!(env.sky && loadedTextures[env.sky]);

            if(usingCustomGround) { planeGround.material.map = loadedTextures[env.ground]; planeGround.material.color.setHex(0xffffff); planeGround.visible = true; planeGround.material.needsUpdate = true; gridFloor.visible = false; }
            else { planeGround.visible = false; gridFloor.visible = true; }

            if(usingCustomSky) { if(!skyTexMesh) { skyTexMesh = new THREE.Mesh(new THREE.SphereGeometry(4200,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); backdrop.add(skyTexMesh); } skyTexMesh.material.map = loadedTextures[env.sky]; skyTexMesh.visible = true; skyDome.visible=false; sunMesh.visible=false; mountains.visible=false; stars.visible = false; }
            else { if(skyTexMesh) skyTexMesh.visible = false; skyDome.visible=true; sunMesh.visible=true; mountains.visible=true; stars.visible = true; }
            rebuildTunnelMesh(viewMode);
        }

        function setupCustomScenery() {
            worldObjects.forEach(obj => { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }); worldObjects = [];
            if (customCourseData.scenery) {
                customCourseData.scenery.forEach(s => {
                    if(s.type === 'gimmick') {
                        const zOffset = s.curveU * customCourseLength;
                        let type = s.gimmickType; let stackHeight = 1;
                        if (type.startsWith('block')) { stackHeight = parseInt(type.replace('block', '')) || 1; type = 'block'; }
                        for (let k = 0; k < stackHeight; k++) {
                            const mesh = makeGimmickMesh(type, k);
                            let alt = 5.0; 
                            if (type === "hurdle") alt = 2.0; else if (type === "heal") alt = 1.0; else if (type === "score") alt = 1.5; else if (type === "dash") alt = 0.5;
                            if (type === 'block') alt += k * 3.0; 
                            scene.add(mesh);
                            worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: s.angle, altitude: alt, vel: new THREE.Vector3(), localPos: new THREE.Vector3(), locked: false, sightDom: null, isDead: false, isScenery: false });
                        }
                    } else if(s.type !== 'texture') {
                        let mesh;
                        if(s.type === 'block') { const mat = new THREE.MeshStandardMaterial({ color: s.color, roughness:.5, metalness:.3 }); if(s.texture && loadedTextures[s.texture]) { mat.map = loadedTextures[s.texture]; mat.color.setHex(0xffffff); } mesh = new THREE.Mesh(new THREE.BoxGeometry(10,10,10), mat); }
                        else if(s.type === 'bgmodel' && loadedModels[s.modelName]) { mesh = loadedModels[s.modelName].clone(); }
                        if(mesh) { mesh.position.set(s.x, s.y, s.z); mesh.scale.set(s.sx, s.sy, s.sz); mesh.rotation.y = s.ry || 0; scene.add(mesh); worldObjects.push({ mesh, type: s.type, flying: false, z: 0, isScenery: true, locked: false, isDead: false, sightDom: null }); }
                    }
                });
            }
        }

        /* ================= SOUND ================= */
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let noiseBuffer = null, windSource = null, windGain = null, windFilter = null, jetOsc = null, jetGain = null, subOsc=null, subGain=null, brakeCooldown = 0, bankSoundTimer = 0;
        function createNoiseBuffer() { const bSize = audioCtx.sampleRate * 2.0; const b = audioCtx.createBuffer(1, bSize, audioCtx.sampleRate); const d = b.getChannelData(0); for (let i = 0; i < bSize; i++) d[i] = Math.random() * 2 - 1; return b; }
        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();
                subOsc = audioCtx.createOscillator(); subOsc.type='sine'; subOsc.frequency.value=55; subGain=audioCtx.createGain(); subGain.gain.value=0; subOsc.connect(subGain); subGain.connect(audioCtx.destination); subOsc.start();
            },
            updateEngine: (sRatio, isB) => { if (!windSource) return; const now = audioCtx.currentTime; if (sRatio <= 0.01) { windGain.gain.setTargetAtTime(0, now, 0.1); jetGain.gain.setTargetAtTime(0, now, 0.1); subGain.gain.setTargetAtTime(0,now,0.1); return; } windFilter.frequency.setTargetAtTime(200 + (sRatio * 2200), now, 0.1); windGain.gain.setTargetAtTime(Math.min(0.32, sRatio * 0.22), now, 0.1); jetGain.gain.setTargetAtTime(isB ? 0.16 : 0.025, now, 0.2); jetOsc.frequency.setTargetAtTime(560 + (sRatio * 3200), now, 0.1); subGain.gain.setTargetAtTime(Math.min(0.18, sRatio*0.12) + (isB?0.08:0), now, 0.15); subOsc.frequency.setTargetAtTime(45 + sRatio*40, now, 0.2); },
            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 === 'scrape') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(400, now); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(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 === '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(2200, 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);
                    const o=audioCtx.createOscillator(); const og=audioCtx.createGain(); o.type='sawtooth'; o.frequency.setValueAtTime(120,now); o.frequency.exponentialRampToValueAtTime(420,now+0.4); og.gain.setValueAtTime(0.18,now); og.gain.exponentialRampToValueAtTime(0.01,now+0.45); o.connect(og); og.connect(audioCtx.destination); o.start(now); o.stop(now+0.45); }
                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 === 'overheat') { const osc=audioCtx.createOscillator(); const g=audioCtx.createGain(); osc.type='sawtooth'; osc.frequency.setValueAtTime(300,now); osc.frequency.exponentialRampToValueAtTime(90,now+0.4); g.gain.setValueAtTime(0.25,now); g.gain.exponentialRampToValueAtTime(0.01,now+0.4); osc.connect(g); g.connect(audioCtx.destination); osc.start(now); osc.stop(now+0.4); }
                else if (type === 'gate') { const osc=audioCtx.createOscillator(); const g=audioCtx.createGain(); osc.type='sine'; osc.frequency.setValueAtTime(900,now); osc.frequency.exponentialRampToValueAtTime(1600,now+0.08); g.gain.setValueAtTime(0.06,now); g.gain.exponentialRampToValueAtTime(0.001,now+0.12); osc.connect(g); g.connect(audioCtx.destination); osc.start(now); osc.stop(now+0.12); }
                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); }
            }
        };

        /* ================= CONSTANTS ================= */
        const MAX_HP = 10, TUBE_R = 36, MAX_TIME = 60, NORMAL_MAX_SPEED = 360.0, RUSH_SPEED = NORMAL_MAX_SPEED * 2.6, NORMAL_ACCEL = 220.0, GRAVITY = 100.0, JUMP_POWER = 60.0, TOTAL_STAGES = 8, FOG_NEAR = 220, FOG_FAR = 1400, SPEED_DISPLAY_MULTIPLIER = 1.15; 
        const MAX_ENERGY = 100, BOOST_DRAIN = 34, BOOST_REGEN = 16, OVERHEAT_RECOVER = 35;
        const BASE_FOV = 80, MAX_FOV = 138;
        const TILE_SEGMENT_LENGTH = 14, TILES_PER_RING = 16, VISIBLE_SEGMENTS = 110;
        const GATE_SPACING = 230, GATE_POOL = 9;
        let tunnelSegments = [];
        let scene, camera, renderer, composer, bloomPass, speedPass, clock, gameState = "TITLE", viewMode = "F3", isPaused = false, score = 0, stageScore = 0, hp = MAX_HP, timeLeft = MAX_TIME, currentStage = 1;
        let player = { mesh: null, angle: 0, x: 0, z: 0, prevZ: 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, thrust: null, flames:[], wingTipL: null, wingTipR: null, mode: "NORMAL", lockTargets: [], rushIndex: 0, lockTimer: 0, currentLockType: null, rushVisualOffset: 0, energy: MAX_ENERGY, overheat:false, gForce:0, camVel:new THREE.Vector3(), camPos:new THREE.Vector3() };
        let worldObjects = [], debris = [], stars, skyTexMesh=null, skyDome=null, sunMesh=null, mountains=null, gridFloor=null, backdrop=null, planeGround, trackSplineGroup = null, keys = {}, nextSpawnZ = 150, trails = []; 
        let gates = [], nextGateZ = GATE_SPACING;
        let streakField = null;
        let timeScale = 1.0; 
        let fpsAccum=0, fpsFrames=0, fpsTime=0;

        /* ================= TRAIL ================= */
        class Trail {
            constructor(color) { this.maxPoints = 70; this.points = []; this.width = 0.9; const geometry = new THREE.BufferGeometry(); const pos = new Float32Array(this.maxPoints * 2 * 3); geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const indices = []; for(let i=0; i<this.maxPoints-1; i++) { const base = i*2; indices.push(base, base+1, base+2, base+1, base+3, base+2); } geometry.setIndex(indices); const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.55, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }); this.mesh = new THREE.Mesh(geometry, material); this.mesh.frustumCulled = false; scene.add(this.mesh); }
            update(newPos, rightVec) { const p1 = newPos.clone().addScaledVector(rightVec, this.width * 0.5), p2 = newPos.clone().addScaledVector(rightVec, -this.width * 0.5); this.points.unshift({p1, p2}); if(this.points.length > this.maxPoints) this.points.pop(); const positions = this.mesh.geometry.attributes.position.array; let idx = 0; for(let i=0; i<this.points.length; i++) { const pt = this.points[i]; positions[idx++] = pt.p1.x; positions[idx++] = pt.p1.y; positions[idx++] = pt.p1.z; positions[idx++] = pt.p2.x; positions[idx++] = pt.p2.y; positions[idx++] = pt.p2.z; } const last = this.points[this.points.length-1]; while(idx < positions.length) { positions[idx++] = last.p1.x; positions[idx++] = last.p1.y; positions[idx++] = last.p1.z; positions[idx++] = last.p2.x; positions[idx++] = last.p2.y; positions[idx++] = last.p2.z; } this.mesh.geometry.attributes.position.needsUpdate = true; }
            reset() { this.points = []; const positions = this.mesh.geometry.attributes.position.array; for(let i=0; i<positions.length; i++) positions[i] = 0; this.mesh.geometry.attributes.position.needsUpdate = true; }
        }

        /* ================= SPEED STREAK FIELD =================
           Near-field warp streaks that elongate with speed -> primary sense-of-speed cue. */
        class StreakField {
            constructor(count){
                this.count = count;
                this.data = [];
                const geo = new THREE.BufferGeometry();
                this.positions = new Float32Array(count*2*3);
                geo.setAttribute('position', new THREE.BufferAttribute(this.positions,3));
                const mat = new THREE.LineBasicMaterial({ color:0xbfefff, transparent:true, opacity:0.0, blending:THREE.AdditiveBlending, depthWrite:false });
                this.mesh = new THREE.LineSegments(geo, mat); this.mesh.frustumCulled = false; scene.add(this.mesh);
                for(let i=0;i<count;i++){ this.data.push({ angle:Math.random()*Math.PI*2, radius: (0.25+Math.random()*1.15), zOff: Math.random()*520 }); }
            }
            update(){
                const ahead = 30, range = 520;
                const spdRatio = Math.min(3.0, player.vz / NORMAL_MAX_SPEED);
                const boosting = (player.mode==="RUSHING"||player.mode==="MANUAL_BOOST");
                let op = Math.min(0.85, Math.max(0,(spdRatio-0.15))*0.5) * (boosting?1.25:1.0);
                if(spdRatio <= 0.18) op = 0;
                this.mesh.material.opacity = op;
                const streakLen = THREE.MathUtils.clamp(player.vz*0.10, 4, 120) * (boosting?1.6:1.0);
                // distance the player advanced since last frame -> streaks sweep toward camera
                const dz = Math.max(0, player.z - (this._lastZ===undefined ? player.z : this._lastZ));
                this._lastZ = player.z;
                const pos = this.positions; let k=0;
                for(let i=0;i<this.count;i++){
                    const s = this.data[i];
                    s.zOff -= dz;
                    if(s.zOff < 0){ s.zOff += range; s.angle=Math.random()*Math.PI*2; s.radius=0.25+Math.random()*1.15; }
                    const z = player.z + ahead + s.zOff;
                    const basis = getBasis(z, viewMode);
                    const r = basis.width * s.radius;
                    const head = getSectionPosition(s.angle, r, basis, viewMode);
                    const tail = head.clone().addScaledVector(basis.T, -streakLen);
                    pos[k++]=head.x; pos[k++]=head.y; pos[k++]=head.z;
                    pos[k++]=tail.x; pos[k++]=tail.y; pos[k++]=tail.z;
                }
                this.mesh.geometry.attributes.position.needsUpdate = true;
            }
            reset(){ this._lastZ = undefined; for(let i=0;i<this.count;i++){ this.data[i].zOff = Math.random()*520; } }
        }

        /* ================= COURSE GEOMETRY ================= */
        function getDefaultCurve(z, mode) {
            const coarseX = Math.sin(z * 0.0015) * 240, coarseY = Math.cos(z * 0.0015) * 150, detailY = Math.sin(z * 0.006) * 95 + Math.sin(z * 0.015) * 34, detailX = Math.cos(z * 0.004) * 70; 
            if (mode === "F2") return new THREE.Vector3(0, coarseY + detailY, z);
            return new THREE.Vector3(coarseX + detailX, coarseY + detailY, z);
        }

        function getBasis(z, mode) {
            if (customCourseCurve) {
                let t = (z % customCourseLength) / customCourseLength; if (t < 0) t += 1.0;
                const u = t; const tParam = customCourseCurve.getUtoTmapping(u);
                const origin = customCourseCurve.getPointAt(u), T = customCourseCurve.getTangentAt(u).normalize();
                const segs = customCourseFrames.tangents.length; const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
                const track = customCourseData.track, rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam), wVal = getLoopCatmullRom(track.map(n=>n.width||36), tParam);
                const q = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
                const U = customCourseFrames.normals[fi].clone().applyQuaternion(q), R = new THREE.Vector3().crossVectors(T, U).normalize();
                return { origin, T, U, R, twistRad: THREE.MathUtils.degToRad(tDeg), width: wVal };
            } else {
                const origin = getDefaultCurve(z, mode), forward = getDefaultCurve(z + 5.0, mode); 
                const T = new THREE.Vector3().subVectors(forward, origin).normalize(), R = new THREE.Vector3().crossVectors(T, new THREE.Vector3(0, 1, 0)).normalize(), U = new THREE.Vector3().crossVectors(R, T).normalize();
                return { origin, T, U, R, twistRad: 0, width: TUBE_R };
            }
        }

        function getSectionPosition(angle, radius, basis, mode) {
            const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad);
            const rAxis = basis.R.clone().applyQuaternion(qTwist), uAxis = basis.U.clone().applyQuaternion(qTwist);
            if (mode === "F2" && !customCourseCurve) {
                const spread = basis.width * Math.PI; const ratio = angle / Math.PI; const localX = ratio * (spread / 2);
                return basis.origin.clone().addScaledVector(rAxis, localX).addScaledVector(uAxis, -radius);
            } else {
                const localX = Math.sin(angle) * radius, localY = -Math.cos(angle) * radius;
                return basis.origin.clone().addScaledVector(rAxis, localX).addScaledVector(uAxis, localY);
            }
        }

        function getSegmentColor(randomHue, lightnessVar = 0.0) { return new THREE.Color().setHSL(randomHue, 0.7, 0.5 + lightnessVar); }
        function isGap(segmentIndex) { if (segmentIndex < 15) return false; return (segmentIndex % 60) >= 56; }

        /* ================= DEFAULT TUNNEL (enhanced solid neon road) ================= */
        function initTunnel() {
            const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
            const boxGeo = new THREE.BoxGeometry(tileWidth * 1.04, 0.6, TILE_SEGMENT_LENGTH * 1.02);
            for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
                const segmentGroup = new THREE.Group();
                segmentGroup.userData = { zIndex: i, hue: Math.random(), lVar: (Math.random() * 0.2) - 0.1 }; 
                for (let j = 0; j < TILES_PER_RING; j++) {
                    // Bottom arc (road) is opaque & bright; upper arc (ceiling/walls) is translucent neon.
                    const isRoad = (j >= 4 && j <= 12);
                    const mat = new THREE.MeshStandardMaterial({ color: 0x14182a, emissive:0x000000, emissiveIntensity:1.0, roughness:0.35, metalness:0.6, opacity: isRoad?1.0:0.32, transparent: !isRoad, side: THREE.DoubleSide });
                    const tile = new THREE.Mesh(boxGeo, mat); tile.userData.j=j; segmentGroup.add(tile);
                }
                tunnelSegments.push(segmentGroup); scene.add(segmentGroup);
            }
        }

        function tileLook(j, segIdx){
            // returns {emissive, color, intensity} forming road lines, edge rails, dashes.
            const centerLine = (j===8);
            const edgeL = (j===4), edgeR = (j===12);
            const isRoad = (j>=4 && j<=12);
            if(centerLine){ const on = (segIdx % 4) < 2; return { color:0x101830, emissive: on?0x00f0ff:0x021622, intensity: on?2.4:0.6 }; }
            if(edgeL||edgeR){ return { color:0x160a18, emissive:0xff2a6d, intensity:2.2 }; }
            if(isRoad){ const checker = ((j+segIdx)%2===0); return { color: checker?0x14182a:0x0e1120, emissive:0x05203a, intensity:0.5 }; }
            // ceiling / wall neon panels
            const pulse = ((segIdx + j) % 9 === 0);
            return { color:0x0a0f1f, emissive: pulse?0x7b2ff7:0x06122a, intensity: pulse?1.6:0.35 };
        }

        function updateTunnel() {
            if(customCourseCurve) { tunnelSegments.forEach(seg => seg.visible = false); return; } 
            const playerSegmentIndex = Math.floor(player.z / TILE_SEGMENT_LENGTH), startSegment = playerSegmentIndex - 8; 
            tunnelSegments.forEach((seg) => {
                let segIdx = seg.userData.zIndex;
                if (segIdx < startSegment) {
                    segIdx += VISIBLE_SEGMENTS; seg.userData.zIndex = segIdx; seg.userData.hue = Math.random(); seg.userData.lVar = (Math.random() * 0.2) - 0.1;
                }
                const gap = isGap(segIdx); seg.visible = !gap;
                const zPos = segIdx * TILE_SEGMENT_LENGTH, 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;
                    tile.position.copy(getSectionPosition(angle, basis.width, basis, viewMode));
                    const look = tileLook(j, segIdx);
                    tile.material.color.setHex(look.color); tile.material.emissive.setHex(look.emissive); tile.material.emissiveIntensity = look.intensity;
                    if (viewMode === "F2") {
                        const xaxis=new THREE.Vector3(1,0,0); const up=new THREE.Vector3().crossVectors(xaxis, basis.T).normalize(); const m = new THREE.Matrix4(); m.makeBasis(xaxis, up, new THREE.Vector3().crossVectors(up, xaxis).normalize()); tile.rotation.setFromRotationMatrix(m);
                    } else {
                        const isOutside = (viewMode === "F4" || viewMode === "F5");
                        const normal = new THREE.Vector3().addScaledVector(basis.R, Math.sin(angle)).addScaledVector(basis.U, -Math.cos(angle)).normalize();
                        const up = isOutside ? normal : normal.clone().multiplyScalar(-1);
                        const tangent = new THREE.Vector3().crossVectors(up, basis.T).normalize();
                        const m = new THREE.Matrix4(); m.makeBasis(tangent, up, new THREE.Vector3().crossVectors(tangent, up).normalize()); tile.rotation.setFromRotationMatrix(m);
                    }
                });
            });
        }

        /* ================= LIGHT GATES (whip-past speed markers) ================= */
        function makeGate(){
            const g = new THREE.Group();
            const frameMat = new THREE.MeshStandardMaterial({ color:0x111122, emissive:0x00f0ff, emissiveIntensity:2.6, metalness:0.7, roughness:0.3 });
            const ring = new THREE.Mesh(new THREE.TorusGeometry(1, 0.045, 10, 40), frameMat);
            g.add(ring);
            const glowMat = new THREE.MeshBasicMaterial({ color:0x00f0ff, transparent:true, opacity:0.10, side:THREE.DoubleSide, blending:THREE.AdditiveBlending, depthWrite:false });
            const glow = new THREE.Mesh(new THREE.RingGeometry(0.86, 1.06, 40), glowMat); g.add(glow);
            // accent bars
            const barMat = new THREE.MeshStandardMaterial({ color:0x220011, emissive:0xff2a6d, emissiveIntensity:2.2 });
            for(let a=0;a<4;a++){ const bar=new THREE.Mesh(new THREE.BoxGeometry(0.14,0.34,0.05), barMat); const ang=a*Math.PI/2; bar.position.set(Math.cos(ang),Math.sin(ang),0); bar.rotation.z=ang; g.add(bar); }
            g.userData = { z:-99999, frameMat, glowMat, ring }; g.visible=false; scene.add(g); return g;
        }
        function initGates(){ gates=[]; for(let i=0;i<GATE_POOL;i++) gates.push(makeGate()); nextGateZ = Math.ceil(player.z/GATE_SPACING)*GATE_SPACING + GATE_SPACING; }
        function updateGates(){
            const isOutside = (viewMode==="F4"||viewMode==="F5");
            // assign z to gates that are behind player
            gates.forEach(g=>{ if(g.userData.z < player.z - 60 || g.userData.z < -90000){ g.userData.z = nextGateZ; nextGateZ += GATE_SPACING; g.userData.passed=false; } });
            gates.forEach(g=>{
                const z = g.userData.z; const basis = getBasis(z, viewMode);
                g.visible = true;
                g.position.copy(basis.origin);
                const m = new THREE.Matrix4(); const up = basis.U.clone(); const right = basis.R.clone(); const fwd = basis.T.clone();
                m.makeBasis(right, up, fwd); g.quaternion.setFromRotationMatrix(m);
                const s = basis.width * 1.12; g.scale.set(s,s,s);
                const pulse = 2.2 + Math.sin(performance.now()*0.004 + z)*0.6;
                g.userData.frameMat.emissiveIntensity = pulse;
                if(!g.userData.passed && z <= player.z){ g.userData.passed=true; sound.play('gate'); }
            });
        }

        /* ================= GIMMICK MESH ================= */
        function makeGimmickMesh(type, k){
            let geo, color, emissive, ei=1.0;
            if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); color=0x330505; emissive=0xff3300; ei=1.8; } 
            else if (type === 'heal') { geo = new THREE.OctahedronGeometry(1.4,0); color=0x052b05; emissive=0x00ff44; ei=2.0; } 
            else if (type === 'score') { geo = new THREE.IcosahedronGeometry(1.7,0); color=0x333000; emissive=0xffe000; ei=2.0; } 
            else if (type === 'dash') { geo = new THREE.BoxGeometry(4,0.6,4); color=0x002033; emissive=0x00aaff; ei=2.2; } 
            else { geo = new THREE.BoxGeometry(3,3,3); const g=0.3+Math.random()*0.4; color=new THREE.Color(g,g,g).getHex(); emissive=0x050505; ei=0.4; }
            const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color, emissive, emissiveIntensity:ei, metalness:0.4, roughness:0.4 }));
            return mesh;
        }

        /* ================= ENVIRONMENT BUILD (synthwave backdrop) ================= */
        function buildBackdrop(){
            backdrop = new THREE.Group(); scene.add(backdrop);

            // Gradient sky dome
            const skyGeo = new THREE.SphereGeometry(4000, 32, 24);
            const skyMat = new THREE.ShaderMaterial({
                side: THREE.BackSide, depthWrite:false,
                uniforms:{ uTop:{value:new THREE.Color(0x0a0420)}, uMid:{value:new THREE.Color(0x3a0d52)}, uHor:{value:new THREE.Color(0xff2a6d)}, uSun:{value:new THREE.Color(0xffd23f)}, uSunDir:{value:new THREE.Vector3(0,0.06,1).normalize()} },
                vertexShader:`varying vec3 vDir; void main(){ vDir=normalize(position); gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
                fragmentShader:`varying vec3 vDir; uniform vec3 uTop,uMid,uHor,uSun,uSunDir;
                    void main(){ float h=clamp(vDir.y*0.5+0.5,0.0,1.0);
                        vec3 col = mix(uHor, uMid, smoothstep(0.45,0.7,h));
                        col = mix(col, uTop, smoothstep(0.6,1.0,h));
                        float horizonGlow = pow(1.0-abs(vDir.y),6.0); col += uHor*horizonGlow*0.5;
                        float sd = max(dot(normalize(vDir), normalize(uSunDir)),0.0);
                        col += uSun * pow(sd, 40.0) * 1.2;
                        col += uSun * pow(sd, 6.0) * 0.18;
                        gl_FragColor=vec4(col,1.0);
                    }`
            });
            skyDome = new THREE.Mesh(skyGeo, skyMat); backdrop.add(skyDome);

            // Sun disc with synthwave scanlines
            const sunMat = new THREE.ShaderMaterial({ transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
                uniforms:{ uA:{value:new THREE.Color(0xffe14d)}, uB:{value:new THREE.Color(0xff3d7f)} },
                vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
                fragmentShader:`varying vec2 vUv; uniform vec3 uA,uB; void main(){ vec2 p=vUv*2.0-1.0; float d=length(p); if(d>1.0) discard; vec3 col=mix(uA,uB, clamp(vUv.y,0.0,1.0)); float band=step(0.5, fract((vUv.y)*16.0)); float mask=1.0; if(vUv.y<0.55) mask = band; float a=(1.0-smoothstep(0.7,1.0,d))*mask; gl_FragColor=vec4(col, a); }`
            });
            sunMesh = new THREE.Mesh(new THREE.PlaneGeometry(900,900), sunMat); sunMesh.position.set(0, 120, 3200); backdrop.add(sunMesh);

            // Neon horizon mountains (360 cylinder shader)
            const mGeo = new THREE.CylinderGeometry(2800, 2800, 1400, 96, 1, true);
            const mMat = new THREE.ShaderMaterial({ side:THREE.BackSide, transparent:true, depthWrite:false,
                uniforms:{ uColor:{value:new THREE.Color(0x14082a)}, uRim:{value:new THREE.Color(0xff2a6d)} },
                vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
                fragmentShader:`varying vec2 vUv; uniform vec3 uColor,uRim;
                    float h(float x){ return 0.40 + 0.10*sin(x*9.0) + 0.07*sin(x*23.0+1.3) + 0.05*sin(x*47.0+2.1) + 0.03*sin(x*97.0); }
                    void main(){ float m=h(vUv.x*6.2831853); float edge=m - vUv.y; if(edge<0.0) discard;
                        float rim = 1.0-smoothstep(0.0,0.04,edge);
                        vec3 col = mix(uColor, uRim, rim*0.9);
                        col *= mix(0.25,1.0, clamp(vUv.y/max(m,0.001),0.0,1.0));
                        float a = smoothstep(0.0,0.03,edge);
                        gl_FragColor=vec4(col, a); }`
            });
            mountains = new THREE.Mesh(mGeo, mMat); mountains.position.y = -260; backdrop.add(mountains);

            // Neon grid floor
            const gridMat = new THREE.ShaderMaterial({ transparent:true, depthWrite:false, side:THREE.DoubleSide,
                uniforms:{ uScroll:{value:0}, uA:{value:new THREE.Color(0x00e5ff)}, uB:{value:new THREE.Color(0xff2a6d)} },
                vertexShader:`varying vec2 vUv; varying vec3 vW; void main(){ vUv=uv; vec4 w=modelMatrix*vec4(position,1.0); vW=w.xyz; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
                fragmentShader:`varying vec2 vUv; varying vec3 vW; uniform float uScroll; uniform vec3 uA,uB;
                    float gline(float x){ float g=abs(fract(x-0.5)-0.5)/fwidth(x); return 1.0-min(g,1.0);} 
                    void main(){ float scale=40.0; float u=vUv.x*scale; float v=(vUv.y*scale)+uScroll;
                        float lx=gline(u); float lz=gline(v); float l=max(lx,lz);
                        vec3 col = mix(uA,uB, vUv.y);
                        float dist = length(vW.xz - cameraPosition.xz);
                        float fade = 1.0 - smoothstep(300.0, 3000.0, dist);
                        float a = l*fade*0.9; if(a<0.01) discard;
                        gl_FragColor=vec4(col*1.4, a); }`
            });
            gridFloor = new THREE.Mesh(new THREE.PlaneGeometry(7000,7000,1,1), gridMat); gridFloor.rotation.x=-Math.PI/2; gridFloor.position.y=-140; backdrop.add(gridFloor);

            // Stars (subtle, far)
            const starGeo = new THREE.BufferGeometry(); const N=2600; const posArr=new Float32Array(N*3);
            for(let i=0;i<N;i++){ const r=1500+Math.random()*2200, th=Math.random()*Math.PI*2, ph=Math.random()*Math.PI*0.5+0.05; posArr[i*3]=Math.cos(th)*Math.sin(ph)*r; posArr[i*3+1]=Math.cos(ph)*r*0.7+200; posArr[i*3+2]=Math.sin(th)*Math.sin(ph)*r; }
            starGeo.setAttribute('position', new THREE.BufferAttribute(posArr,3));
            stars = new THREE.Points(starGeo, new THREE.PointsMaterial({ color:0xbfe9ff, size:3.0, transparent:true, opacity:0.85, depthWrite:false })); backdrop.add(stars);
        }

        /* ================= INIT ================= */
        function init() {
            sound.init(); clock = new THREE.Clock(); scene = new THREE.Scene(); scene.background = new THREE.Color(0x05030f); scene.fog = new THREE.Fog(0x140a2a, FOG_NEAR, FOG_FAR);
            renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference:'high-performance' });
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.outputColorSpace = THREE.SRGBColorSpace;
            renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05;
            document.body.appendChild(renderer.domElement);
            camera = new THREE.PerspectiveCamera(BASE_FOV, window.innerWidth / window.innerHeight, 0.1, 6000);

            // Lights
            scene.add(new THREE.AmbientLight(0x4a4f8a, 1.1));
            const sun = new THREE.DirectionalLight(0xfff0e0, 1.4); sun.position.set(0, 120, -60); scene.add(sun);
            const rim = new THREE.DirectionalLight(0x00f0ff, 1.0); rim.position.set(0, -60, 60); scene.add(rim);
            const rim2 = new THREE.DirectionalLight(0xff2a6d, 0.7); rim2.position.set(60, 20, 0); scene.add(rim2);
            const headlight = new THREE.PointLight(0x9fe6ff, 2.2, 260, 1.6); scene.add(headlight); player.headlight = headlight;

            buildBackdrop();

            // legacy ground plane (only used for custom-course ground textures)
            planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000), new THREE.MeshStandardMaterial({ color: 0x222222, roughness:0.9 })); planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -200; planeGround.visible=false; scene.add(planeGround);

            buildPlayer();
            initTunnel();
            initGates();
            streakField = new StreakField(320);

            window.addEventListener('resize', onResize);
            window.addEventListener('keydown', onKeyDown);
            window.addEventListener('keyup', (e) => { keys[e.code] = false; if (e.code === 'KeyZ') { if (player.mode === "RUSHING" || player.lockTargets.length > 0) { player.mode = "NORMAL"; player.isBoosting = false; clearAllLocks(); } else if (player.mode === "MANUAL_BOOST") { player.mode = "NORMAL"; player.isBoosting = false; } } });

            setupComposer();
            changeState("TITLE"); animate();
        }

        function setupComposer(){
            composer = new EffectComposer(renderer);
            composer.addPass(new RenderPass(scene, camera));
            bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.85, 0.7, 0.82);
            composer.addPass(bloomPass);

            const SpeedShader = {
                uniforms: { tDiffuse:{value:null}, uStrength:{value:0.0}, uChroma:{value:0.0}, uVig:{value:0.62}, uCenter:{value:new THREE.Vector2(0.5,0.5)} },
                vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
                fragmentShader:`uniform sampler2D tDiffuse; uniform float uStrength,uChroma,uVig; uniform vec2 uCenter; varying vec2 vUv;
                    void main(){ vec2 dir = vUv - uCenter; vec3 col=vec3(0.0);
                        const int N=10;
                        for(int i=0;i<N;i++){ float t=float(i)/float(N); vec2 uv=vUv - dir*uStrength*t; col += texture2D(tDiffuse, uv).rgb; }
                        col/=float(N);
                        float d=length(dir); vec2 off=dir*uChroma*(0.4+d);
                        col.r = mix(col.r, texture2D(tDiffuse, vUv+off).r, 0.7);
                        col.b = mix(col.b, texture2D(tDiffuse, vUv-off).b, 0.7);
                        float vig = smoothstep(1.25, 0.35, d*1.7);
                        col *= mix(1.0, vig, uVig);
                        gl_FragColor=vec4(col,1.0);
                    }`
            };
            speedPass = new ShaderPass(SpeedShader);
            composer.addPass(speedPass);
            composer.addPass(new OutputPass());
        }

        function onResize(){
            const w=window.innerWidth, h=window.innerHeight;
            camera.aspect=w/h; camera.updateProjectionMatrix();
            renderer.setSize(w,h);
            if(composer) composer.setSize(w,h);
            if(bloomPass) bloomPass.setSize(w,h);
        }

        function onKeyDown(e){
            if(["F1","F2","F3","F4","F5"].includes(e.code)) { e.preventDefault(); viewMode = e.code; document.getElementById('view-mode').innerText = "VIEW: " + e.code; if(customCourseData) rebuildTunnelMesh(viewMode); }
            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;'>PAUSED</h1><p class='hint'>ESC to resume</p>"; clock.stop(); } else { sound.resume(); document.getElementById('center-text').innerHTML = ""; clock.start(); } }
            if(e.code === 'Space') { 
                e.preventDefault(); 
                if (gameState === "TITLE" || gameState === "GAMEOVER" || gameState === "ALL_CLEAR") {
                    sound.play('ui'); sound.startEngine(); if(gameState !== "TITLE") { score = 0; currentStage = 1; }
                    const sel = document.getElementById('course-select-box');
                    if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value);
                    else { customCourseData = null; customCourseCurve = null; 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"); }
            }
            if(e.code === 'KeyX') { if (gameState === "PLAYING" && player.altitude <= 0.1 && !isPaused) { sound.play('jump'); player.jumpV = JUMP_POWER; player.wasAirborne = true; } }
            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'); flashBoost(); } else if (player.mode === "NORMAL" && !player.overheat) { player.mode = "MANUAL_BOOST"; player.isBoosting = true; sound.play('boost_dash'); flashBoost(); } }
        }

        function flashBoost(){ const f=document.getElementById('boost-flash'); f.style.opacity='0.55'; setTimeout(()=>f.style.opacity='0',180); }

        function buildPlayer(){
            const playerGeo = new THREE.Group();
            const stealthGeo = new THREE.BufferGeometry(); const vertices = new Float32Array([0,0.5,3.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.5,3.0, 4.5,0,-2.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.8,-0.5, 0,0.5,-3.0, 0,0.8,-0.5, 4.5,0,-2.0, 0,0.5,-3.0, 0,0,3.0, -4.5,0,-2.0, 0,-0.3,-0.5, 0,0,3.0, 0,-0.3,-0.5, 4.5,0,-2.0, -4.5,0,-2.0, 0,0.5,-3.0, 0,-0.3,-0.5, 4.5,0,-2.0, 0,-0.3,-0.5, 0,0.5,-3.0]); stealthGeo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); stealthGeo.computeVertexNormals();
            playerGeo.add(new THREE.Mesh(stealthGeo, new THREE.MeshStandardMaterial({ color: 0xdfe8ff, roughness: 0.28, metalness: 0.75, emissive: 0x101a33, emissiveIntensity:1.0, flatShading: true })));
            // glowing accent strips along the hull edges
            const accentMat = new THREE.MeshStandardMaterial({ color:0x001018, emissive:0x00f0ff, emissiveIntensity:3.0, metalness:0.5, roughness:0.3 });
            const stripL = new THREE.Mesh(new THREE.BoxGeometry(0.12,0.12,5.0), accentMat); stripL.position.set(-1.4,0.3,-0.3); stripL.rotation.y=0.32; playerGeo.add(stripL);
            const stripR = stripL.clone(); stripR.position.x=1.4; stripR.rotation.y=-0.32; playerGeo.add(stripR);
            const cockpit = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.5, 4), new THREE.MeshStandardMaterial({ color: 0x00aaff, emissive: 0x0066aa, emissiveIntensity:2.0, metalness:0.6, roughness:0.2 })); cockpit.rotation.x = -Math.PI * 0.4; cockpit.position.set(0, 0.6, 0.5); cockpit.scale.z = 0.5; playerGeo.add(cockpit);

            player.wingTipL = new THREE.Object3D(); player.wingTipL.position.set(-4.5, 0, -2.0); player.wingTipR = new THREE.Object3D(); player.wingTipR.position.set(4.5, 0, -2.0); playerGeo.add(player.wingTipL); playerGeo.add(player.wingTipR);

            // twin engine flames
            for(let i=0;i<2;i++){ const fl = new THREE.Mesh(new THREE.ConeGeometry(0.36, 4, 10), new THREE.MeshBasicMaterial({ color: 0x66e0ff, transparent: true, opacity: 0.9, blending:THREE.AdditiveBlending, depthWrite:false })); fl.rotation.x = Math.PI / 2; fl.position.set(i===0?-1.1:1.1, 0, -3.0); playerGeo.add(fl); player.flames.push(fl); }
            player.thrust = player.flames[0];

            player.barrier = new THREE.Mesh(new THREE.IcosahedronGeometry(3.6, 1), new THREE.MeshStandardMaterial({ color: 0x00ffff, transparent: true, opacity: 0.22, wireframe: true, emissive: 0x00aaaa, emissiveIntensity:1.2 })); playerGeo.add(player.barrier);
            player.sonicBoom = new THREE.Mesh(new THREE.SphereGeometry(5.0, 32, 16, 0, Math.PI * 2, 0, Math.PI * 0.5), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.35, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite:false })); player.sonicBoom.rotation.x = -Math.PI / 2; player.sonicBoom.position.z = 2.0; player.sonicBoom.visible = false; playerGeo.add(player.sonicBoom);
            player.mesh = playerGeo; scene.add(player.mesh);
            player.shadow = new THREE.Mesh(new THREE.PlaneGeometry(4.5, 4.5), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.55, depthWrite:false })); scene.add(player.shadow);
            trails.push(new Trail(0x00f0ff)); trails.push(new Trail(0xff2a6d)); 
        }

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

        /* ================= CUSTOM COURSE MESH (enhanced) ================= */
        function rebuildTunnelMesh(mode) {
            if (trackSplineGroup) scene.remove(trackSplineGroup); if (!customCourseData || !customCourseCurve) return;
            const SEGMENTS = customCourseData.track.length * 20;
            const frames = customCourseFrames, track = customCourseData.track;
            const positions = [], uvs = [], indices = [];
            const geo = new THREE.BufferGeometry();
            const materials = []; const matIndexMap = {};
            const env = customCourseData.assets?.env || {};
            const roadMat = new THREE.MeshStandardMaterial({ color: 0x12182a, emissive:0x041830, emissiveIntensity:0.7, metalness:0.6, roughness:0.4, side: THREE.DoubleSide }); 
            if(env.road && loadedTextures[env.road]) { roadMat.map = loadedTextures[env.road]; roadMat.color.setHex(0xffffff); roadMat.emissiveIntensity=0.0; roadMat.needsUpdate = true; }
            const wallMat = new THREE.MeshStandardMaterial({ color: 0x0a0f1f, emissive:0x2a0f4a, emissiveIntensity:0.8, metalness:0.5, roughness:0.5, side: THREE.DoubleSide, transparent:true, opacity:0.92 }); 
            if(env.wall && loadedTextures[env.wall]) { wallMat.map = loadedTextures[env.wall]; wallMat.color.setHex(0xffffff); wallMat.emissiveIntensity=0.0; wallMat.transparent=false; wallMat.needsUpdate = true; }
            const edgeMat = new THREE.MeshStandardMaterial({ color:0x110011, emissive:0x00f0ff, emissiveIntensity:2.6, side:THREE.DoubleSide });
            materials.push(roadMat); materials.push(wallMat); materials.push(edgeMat);
            let nextMatIdx = 3;
            const envTexList = customCourseData.assets?.envTextures || [];
            envTexList.forEach(key => {
                const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness:0.4, roughness:0.6, side: THREE.DoubleSide }); 
                if(loadedTextures[key]) { mat.map = loadedTextures[key]; mat.needsUpdate=true; }
                materials.push(mat); matIndexMap[key] = nextMatIdx++;
            });

            for (let i = 0; i <= SEGMENTS; i++) {
                const u = i / SEGMENTS, tParam = customCourseCurve.getUtoTmapping(u);
                const pt = customCourseCurve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
                const rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam), currentW = getLoopCatmullRom(track.map(n=>n.width||36), tParam);
                const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
                const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
                const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
                const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
                for (let j = 0; j <= 16; j++) {
                    const angle = (j / 16) * Math.PI * 2 - Math.PI; let v;
                    if(j >= 4 && j <= 12) { v = pt.clone().addScaledVector(twistRight, Math.sin(angle)*currentW).addScaledVector(twistUp, -Math.cos(angle)*currentW); }
                    else { v = pt.clone().addScaledVector(right, Math.sin(angle)*currentW).addScaledVector(up, -Math.cos(angle)*currentW); }
                    positions.push(v.x, v.y, v.z); uvs.push(j / 16, u * track.length * 8);
                }
            }
            let faceIdx = 0; const scenery = customCourseData.scenery || [];
            for (let i = 0; i < SEGMENTS; i++) {
                const segIdx = Math.floor((i / SEGMENTS) * track.length); const row = i % 20; 
                for (let c = 0; c < 16; c++) { 
                    if(mode === "F2" && (c < 4 || c > 11)) continue; 
                    const a = i*17+c, b = (i+1)*17+c, c_idx = (i+1)*17+(c+1), d = i*17+(c+1); 
                    indices.push(a,b,d); indices.push(b,c_idx,d); 
                    let mIdx = (c >= 4 && c <= 11) ? 0 : 1; 
                    if(c === 4 || c === 12) mIdx = 2; // glowing road edge rails
                    const texItem = scenery.find(s => s.type === 'texture' && s.segIdx === segIdx && s.row === row && s.col === c);
                    if (texItem && matIndexMap[texItem.texturePath]) mIdx = matIndexMap[texItem.texturePath];
                    geo.addGroup(faceIdx * 6, 6, mIdx); faceIdx++;
                }
            }
            geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); 
            geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); 
            geo.setIndex(indices); geo.computeVertexNormals(); 
            trackSplineGroup = new THREE.Mesh(geo, materials); scene.add(trackSplineGroup);
        }

        /* ================= STATE ================= */
        function renderPips(){
            const wrap=document.getElementById('stage-pips'); if(!wrap) return; wrap.innerHTML='';
            for(let i=1;i<=TOTAL_STAGES;i++){ const d=document.createElement('div'); d.className='pip'+(i<currentStage?' done':(i===currentStage?' cur':'')); wrap.appendChild(d); }
        }
        function changeState(s) {
            gameState = s; document.getElementById('hud-bottom').style.display = (s === "PLAYING") ? "flex" : "none";
            const menu = document.getElementById('center-text'); document.getElementById('speed-lines').style.opacity = 0;
            document.getElementById('stage-info').innerText = "STAGE " + Math.min(currentStage, TOTAL_STAGES) + " / " + TOTAL_STAGES;
            renderPips();
            const bindTitleButtons = () => {
                const af = document.getElementById('assets-folder'); if(af) af.addEventListener('change', e => handleFolderSelect(e.target.files));
                const sb = document.getElementById('start-btn'); if(sb) sb.addEventListener('click', () => { sound.startEngine(); const sel = document.getElementById('course-select-box'); if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value); else { customCourseData = null; customCourseCurve = null; score=0; currentStage=1; changeState("STAGE_START"); } });
                populateCourseSelect();
            };
            if(s === "TITLE") {
                menu.innerHTML = `<h1>HYPER DRIVE</h1><h1 class="tag">ANTI-GRAVITY RACER</h1><p class="hint">Z: BOOST &amp; chain-RUSH locked targets &nbsp;|&nbsp; X: JUMP &nbsp;|&nbsp; F1-F5: camera</p>${getCourseSelectUI()}`;
                bindTitleButtons();
            }
            else if(s === "STAGE_START") { stageScore = 0; menu.innerHTML = `<h1>STAGE ${Math.min(currentStage,TOTAL_STAGES)}</h1><p>READY?</p>`; resetPlayerPos(); setTimeout(()=>{ if(gameState==="STAGE_START") changeState("PLAYING"); }, 1400); }
            else if(s === "PLAYING") menu.innerHTML = "";
            else if(s === "STAGE_CLEAR") {
                if(currentStage >= TOTAL_STAGES){ changeState("ALL_CLEAR"); return; }
                menu.innerHTML = `<h1 style='color:#0f0;'>STAGE CLEAR!</h1><p>SCORE: ${score}</p><p class="hint">PRESS SPACE FOR NEXT STAGE</p>`;
            }
            else if(s === "ALL_CLEAR") {
                menu.innerHTML = `<h1 style='color:var(--neon-yel);'>ALL CLEAR!</h1><h1 class="tag">YOU MASTERED HYPER DRIVE</h1><p>FINAL SCORE: ${score}</p>${getCourseSelectUI()}`;
                bindTitleButtons();
            }
            else if(s === "GAMEOVER") {
                menu.innerHTML = `<h1 style='color:red;'>CRITICAL FAILURE</h1><p>SCORE: ${score}</p>${getCourseSelectUI()}`;
                bindTitleButtons();
            }
        }

        function resetPlayerPos() { player.angle = 0; player.x = 0; player.z = 0; player.prevZ = 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; player.rushVisualOffset = 0; player.energy = MAX_ENERGY; player.overheat=false; player.gForce=0; debris.forEach(d => scene.remove(d.mesh)); debris = []; trails.forEach(t => t.reset()); if(streakField) streakField.reset(); nextSpawnZ = 150; tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; }); gates.forEach(g=>g.userData.z=-99999); nextGateZ = GATE_SPACING; timeScale = 1.0; player.camPos.set(0,0,0); }

        /* ================= LOOP ================= */
        function animate() {
            requestAnimationFrame(animate); 
            const rawDt = clock.getDelta();
            timeScale += (1.0 - timeScale) * (rawDt * 10.0);
            const dt = rawDt * timeScale;

            // continuous speed-reactive FOV
            const spdRatio = THREE.MathUtils.clamp(player.vz / NORMAL_MAX_SPEED, 0, 3);
            let targetFov = BASE_FOV + Math.min(1.0, spdRatio) * 34;
            if(player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") targetFov += 22;
            targetFov = Math.min(MAX_FOV, targetFov);
            camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, 6 * rawDt);
            camera.updateProjectionMatrix();

            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" || player.mode === "MANUAL_BOOST") ratio = 3.0; sound.updateEngine(ratio, player.isBoosting); } else if (!isPaused) sound.updateEngine(0, false);

            if(!isPaused) {
                if(!customCourseCurve) updateTunnel();
                updateGates();
                updatePlayerVisuals(rawDt);
                if(streakField) streakField.update();
                // backdrop follows player; grid scrolls
                if(backdrop){ backdrop.position.z = player.z; if(gridFloor){ gridFloor.position.x = getBasis(player.z,viewMode).origin.x*0.0; gridFloor.material.uniforms.uScroll.value = (player.z*0.02)%1000; } if(stars) stars.rotation.y += 0.0002; if(sunMesh){ sunMesh.position.x = THREE.MathUtils.lerp(sunMesh.position.x, getBasis(player.z+50,viewMode).origin.x*0.2, 0.02);} }
            }

            // post-processing speed uniforms
            if(speedPass){
                let str = THREE.MathUtils.clamp((spdRatio-0.15)*0.075, 0, 0.20);
                if(player.mode==="RUSHING"||player.mode==="MANUAL_BOOST") str += 0.13;
                if(player.mode==="RUSHING") str += 0.05;
                speedPass.uniforms.uStrength.value = THREE.MathUtils.lerp(speedPass.uniforms.uStrength.value, str, 0.25);
                speedPass.uniforms.uChroma.value = THREE.MathUtils.lerp(speedPass.uniforms.uChroma.value, Math.min(0.006, spdRatio*0.0022 + (player.isBoosting?0.0025:0)), 0.2);
                if(bloomPass) bloomPass.strength = 0.8 + (player.isBoosting?0.5:0) + Math.min(0.4, spdRatio*0.15);
            }

            if(player.headlight) player.headlight.position.copy(player.mesh.position);

            try { composer.render(); } catch(e){ renderer.render(scene, camera); }

            // fps
            fpsFrames++; fpsTime += rawDt; if(fpsTime>=0.5){ const fps=Math.round(fpsFrames/fpsTime); const el=document.getElementById('fps'); if(el) el.innerText=fps+' FPS'; fpsFrames=0; fpsTime=0; }
        }

        /* ================= PHYSICS ================= */
        function updatePhysics(dt) {
            // boost energy / overheat
            if (player.mode === "MANUAL_BOOST") {
                player.energy -= BOOST_DRAIN * dt;
                if(player.energy <= 0){ player.energy=0; player.overheat=true; player.mode="NORMAL"; player.isBoosting=false; showPopText("OVERHEAT!","#ff4444"); sound.play('overheat'); }
            } else if (player.mode !== "RUSHING") {
                player.energy = Math.min(MAX_ENERGY, player.energy + BOOST_REGEN * dt);
                if(player.overheat && player.energy >= OVERHEAT_RECOVER) player.overheat=false;
            }

            if (player.mode === "RUSHING") { handleRushPhysics(dt); return; }
            let currentAccel = 0; const isBraking = keys['ArrowDown'];
            player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 10 * dt);

            if (player.mode === "MANUAL_BOOST") { if (player.vz < RUSH_SPEED) player.vz += NORMAL_ACCEL * 4.0 * dt; player.surge = THREE.MathUtils.lerp(player.surge, 10.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0.85; }
            else 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); document.getElementById('speed-lines').style.opacity = Math.min(0.5, (player.vz/NORMAL_MAX_SPEED)*0.5); } 
            else { currentAccel = 0; player.surge = THREE.MathUtils.lerp(player.surge, 0, 5 * dt); document.getElementById('speed-lines').style.opacity = Math.min(0.4,(player.vz/NORMAL_MAX_SPEED)*0.4); }
            if (isBraking && player.mode !== "MANUAL_BOOST") { player.vz -= NORMAL_ACCEL * 1.5 * dt; player.surge = THREE.MathUtils.lerp(player.surge, -3.0, 5 * dt); if (brakeCooldown <= 0) { sound.play('brake'); brakeCooldown = 0.2; } brakeCooldown -= dt; if (player.vz > 50) createDebris(player.mesh.position.clone(), 0xffaa00, 1.0, 1, "spark_brake"); } else brakeCooldown = 0;
            if (player.mode !== "MANUAL_BOOST") { 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); handleGravity(dt); handleSteering(dt); player.prevZ = player.z; 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;
            if (player.lockTargets.length > 0) return;
            let candidates = worldObjects.filter(obj => { 
                if (obj.locked || obj.isDead || obj.z < player.z || obj.isScenery) return false; 
                let angleDiff = Math.abs(obj.angle - player.angle); while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
                return Math.abs(angleDiff) <= 3.2 && (!player.currentLockType || player.currentLockType === obj.type); 
            });
            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; 
                const div = document.createElement('div'); div.className = 'lock-on-sight'; let col = "#fff"; if(target.type==="block")col="#ddd"; else if(target.type==="hurdle")col="#f30"; else if(target.type==="heal")col="#0f0"; else if(target.type==="score")col="#ff0"; div.style.borderColor = col; div.style.color=col; div.style.boxShadow = `0 0 12px ${col}, inset 0 0 10px ${col}`; document.getElementById('ui-layer').appendChild(div); target.sightDom = div; sound.play('lockon'); 
            }
        }

        function handleRushPhysics(dt) {
            player.lockTargets = player.lockTargets.filter(t => !t.isDead);
            if (player.lockTargets.length === 0 || player.rushIndex >= player.lockTargets.length) { 
                if (keys['KeyZ'] && !player.overheat) player.mode = "MANUAL_BOOST"; else { player.mode = "NORMAL"; player.isBoosting = false; } 
                player.surge = 0; clearAllLocks(); return; 
            }
            const targetObj = player.lockTargets[player.rushIndex]; 
            if (!worldObjects.includes(targetObj) || targetObj.isDead) { player.rushIndex++; return; }
            const rushDist = RUSH_SPEED * dt; const distToTarget = targetObj.z - player.z;
            if (distToTarget < 250 && distToTarget > rushDist) {
                timeScale = 0.3; 
                if (distToTarget > 80) { player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 120, 10 * dt); }
                else { player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 20 * dt); timeScale = 0.1; }
            } else { timeScale = 1.0; player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 5 * dt); }
            if (distToTarget <= rushDist) { 
                player.prevZ = player.z; player.z = targetObj.z; player.angle = targetObj.angle; handleCollision(targetObj, true); player.surge = 25.0; player.rushIndex++; timeScale = 0.05; player.rushVisualOffset = 0; sound.play('crash');
            } else { 
                player.prevZ = player.z; player.z += rushDist; const t = 10.0 * dt; player.angle = THREE.MathUtils.lerp(player.angle, targetObj.angle, t); player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t); player.surge = THREE.MathUtils.lerp(player.surge, 30.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 1.0; 
            }
            updateGameTimers(dt); updateUI();
        }

        function handleGravity(dt) { 
            const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
            const inGap = (!customCourseCurve && isGap(currentSegIdx));
            const speedKmh = player.vz * SPEED_DISPLAY_MULTIPLIER;
            const isFloating = (speedKmh > 30 && !player.wasAirborne);
            player.altitude += player.jumpV * dt; 
            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 = (player.vz > 300.0) ? 0.5 : 1.0; steerFactor *= (viewMode === "F2" ? 4.0 : 2.0); let targetBank = 0; const BANK_LIMIT = 0.78; 
            const isOutside = (viewMode === "F4" || viewMode === "F5"); const steerDirection = isOutside ? -1 : 1; 
            const prevAngle = player.angle;
            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; player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 5 * dt);
            player.gForce = THREE.MathUtils.lerp(player.gForce, (player.angle-prevAngle)/Math.max(dt,0.001)*0.04, 0.2);
            if (viewMode === "F2") { const limit = Math.PI * 1.0; if (player.angle > limit) { player.angle = limit; player.vAngle = 0; } else if (player.angle < -limit) { player.angle = -limit; player.vAngle = 0; } } 
            if (Math.abs(player.bank) > (BANK_LIMIT * 0.95)) { if(bankSoundTimer <= 0) { sound.play('scrape'); bankSoundTimer = 0.2; } bankSoundTimer -= dt; createDebris(player.mesh.position.clone(), 0xffaa00, 0.5, 1, "spark"); } else bankSoundTimer = 0;
        }

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

        /* ================= PLAYER VISUALS / CAMERA ================= */
        function updatePlayerVisuals(rawDt) {
            const visualZ = player.z + player.surge + player.dashOffset, basis = getBasis(visualZ, viewMode);
            const isOutside = (viewMode === "F4" || viewMode === "F5");
            const r = isOutside ? basis.width + 5.0 + player.altitude : basis.width - 5.0 - player.altitude;
            const pos = getSectionPosition(player.angle, r, basis, viewMode);
            const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad), uAxis = basis.U.clone().applyQuaternion(qTwist);
            let playerUp;
            if (viewMode === "F2" && !customCourseCurve) { playerUp = uAxis.clone(); } 
            else { 
                const isRoad = (player.angle >= -Math.PI/2 && player.angle <= Math.PI/2);
                const rAxis = isRoad ? basis.R.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.R.clone();
                const uyAxis = isRoad ? basis.U.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.U.clone();
                const normal = new THREE.Vector3().addScaledVector(rAxis, Math.sin(player.angle)).addScaledVector(uyAxis, -Math.cos(player.angle)).normalize(); 
                playerUp = isOutside ? normal : normal.clone().negate(); 
            }
            const playerForward = basis.T.clone();
            player.mesh.position.copy(pos); const m = new THREE.Matrix4(), right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize(), 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");

            const rushOffset = player.rushVisualOffset || 0;
            if (viewMode !== "F1" && viewMode !== "F4") player.mesh.position.addScaledVector(playerForward, rushOffset);

            if (player.mesh.visible) { const tipLPos = new THREE.Vector3(), tipRPos = new THREE.Vector3(); player.wingTipL.getWorldPosition(tipLPos); player.wingTipR.getWorldPosition(tipRPos); const widthVec = orthoUp.clone().normalize(); if (trails[0]) trails[0].update(tipLPos, widthVec); if (trails[1]) trails[1].update(tipRPos, widthVec); }
            if (player.sonicBoom) { player.sonicBoom.visible = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST"); if(player.sonicBoom.visible) { 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"); }

            // engine flames
            const speedRatio = player.vz / RUSH_SPEED;
            const boosting = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST");
            player.flames.forEach(fl=>{ if (player.vz < 1.0 && !boosting) fl.visible=false; else { fl.visible=true; const flick=0.85+Math.random()*0.3; fl.scale.set(0.6,0.6,(0.6+speedRatio*1.8)*flick); fl.material.opacity = 0.65 + speedRatio*0.35; fl.material.color.setHex(boosting?0xff39c0:0x66e0ff); } });
            if (player.barrier) { player.barrier.scale.setScalar(0.8 + (Math.max(0, hp / MAX_HP) * 0.2)); player.barrier.rotation.y += 0.05; if (boosting) { player.barrier.material.color.setHex(0xff0033); player.barrier.material.emissive.setHex(0x550011); } else { player.barrier.material.color.setHex(0x00ffff); player.barrier.material.emissive.setHex(0x004444); } }

            if (player.shadow) { 
                if (viewMode !== "F1" && viewMode !== "F4" && (!customCourseCurve ? !isGap(Math.floor(player.z/TILE_SEGMENT_LENGTH)) : true)) { 
                    player.shadow.visible = true; let shadowR = isOutside ? basis.width + 0.5 : basis.width - 0.5;
                    player.shadow.position.copy(getSectionPosition(player.angle, shadowR, basis, viewMode)); player.shadow.position.addScaledVector(playerForward, rushOffset);
                    const sm = new THREE.Matrix4(); sm.makeBasis(right, playerUp, playerForward); player.shadow.rotation.setFromRotationMatrix(sm); player.shadow.rotateX(-Math.PI/2);
                    player.shadow.scale.setScalar(Math.max(0.1, 1.0 - Math.max(0, player.altitude) * 0.02)); 
                } else player.shadow.visible = false; 
            }

            const shake = (boosting ? (Math.random()-0.5)*1.4 : (Math.random()-0.5)*0.15) * (1.0 + player.vz/NORMAL_MAX_SPEED*0.5); 
            const cameraUp = orthoUp.clone(); cameraUp.applyAxisAngle(playerForward, player.bank * 0.6 + player.gForce*0.5);
            const lookTarget = player.mesh.position.clone().addScaledVector(playerForward, 30).addScaledVector(right, player.gForce*8);

            if (viewMode === "F1" || viewMode === "F4") { const eyePos = pos.clone().addScaledVector(playerForward, 2.0).addScaledVector(orthoUp, 0.5); camera.position.copy(eyePos); camera.position.y += shake; camera.up.copy(cameraUp); camera.lookAt(eyePos.clone().add(playerForward).addScaledVector(right, player.gForce*6)); } 
            else if (viewMode === "F2") { const camPos = pos.clone().addScaledVector(playerForward, -60).addScaledVector(playerUp, 50); camera.position.copy(camPos); camera.up.copy(playerUp); camera.lookAt(player.mesh.position); }
            else {
                // chase cam with speed-reactive pull-in + slight lag for "being chased" feel
                const pull = THREE.MathUtils.lerp(34, 24, Math.min(1, player.vz/NORMAL_MAX_SPEED));
                const lift = THREE.MathUtils.lerp(9, 6.5, Math.min(1, player.vz/NORMAL_MAX_SPEED));
                const camPos = pos.clone().addScaledVector(playerForward, -pull).addScaledVector(orthoUp, lift); camPos.addScaledVector(right, shake);
                // smooth follow (frame-rate independent)
                if(player.camPos.lengthSq()===0) player.camPos.copy(camPos);
                const camLerp = 1 - Math.exp(-16 * (rawDt || 0.016));
                player.camPos.lerp(camPos, Math.min(1, camLerp));
                camera.position.copy(player.camPos); camera.up.copy(cameraUp); camera.lookAt(lookTarget);
            }

            worldObjects.forEach(obj => { if (obj.sightDom) { if (obj.isDead || obj.z < player.z) { obj.locked = false; obj.sightDom.remove(); obj.sightDom = null; return; } const vector = obj.mesh.position.clone(); vector.project(camera); obj.sightDom.style.left = (vector.x*.5+.5)*window.innerWidth + 'px'; obj.sightDom.style.top = -(vector.y*.5-.5)*window.innerHeight + 'px'; if (player.mode === "RUSHING" && player.lockTargets[player.rushIndex] === obj) obj.sightDom.classList.add('rushing'); } });
        }

        /* ================= OBJECTS ================= */
        function updateObjects(dt) {
            let spawnDist = ((150 - (currentStage * 10.0)) / 3.0) + (player.vz * 0.5); 
            if (player.z + 1500 > nextSpawnZ && !customCourseCurve) { spawnRandomObject(nextSpawnZ); nextSpawnZ += Math.random() * 50 + spawnDist; }
            for (let i = worldObjects.length - 1; i >= 0; i--) {
                const obj = worldObjects[i]; if (obj.isDead && !obj.flying) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; }
                if (obj.isScenery) continue; 
                if(obj.flying) { obj.localPos.addScaledVector(obj.vel, dt * 60); obj.mesh.rotation.x += 10 * dt; obj.mesh.rotation.y += 10 * dt; } 
                else { obj.mesh.rotation.y += dt * ((obj.type==='score'||obj.type==='heal')?2.5:0.0); }
                const basis = getBasis(obj.z, viewMode); let worldPos;
                if (obj.flying) worldPos = basis.origin.clone().add(obj.localPos); 
                else { 
                    const isOutside = (viewMode === "F4" || viewMode === "F5"); let objR = isOutside ? (basis.width + obj.altitude) : (basis.width - obj.altitude);
                    worldPos = getSectionPosition(obj.angle, objR, basis, viewMode); 
                    let up;
                    if(viewMode === "F2" && !customCourseCurve) { up = basis.U.clone().applyAxisAngle(basis.T, basis.twistRad); } 
                    else { 
                        const isRoad = (obj.angle >= -Math.PI/2 && obj.angle <= Math.PI/2);
                        const rAxis = isRoad ? basis.R.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.R.clone();
                        const uAxis = isRoad ? basis.U.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.U.clone();
                        const normal = new THREE.Vector3().addScaledVector(rAxis, Math.sin(obj.angle)).addScaledVector(uAxis, -Math.cos(obj.angle)).normalize(); 
                        up = isOutside ? normal : normal.clone().negate(); 
                    }
                    if(obj.type!=='score'&&obj.type!=='heal'){ const right = new THREE.Vector3().crossVectors(up, basis.T).normalize(); const m = new THREE.Matrix4(); m.makeBasis(right, up, new THREE.Vector3().crossVectors(right, up).normalize()); obj.mesh.rotation.setFromRotationMatrix(m); }
                }
                obj.mesh.position.copy(worldPos);
                if (player.mode !== "RUSHING" && !obj.flying && !obj.isDead) { const passedThrough = (obj.z >= player.prevZ && obj.z <= player.z); if (passedThrough || Math.abs(player.z - obj.z) < 10.0) { let dx = Math.abs(obj.angle - player.angle) * 10.0; if ((passedThrough && dx < 5.0) || player.mesh.position.distanceTo(obj.mesh.position) < 8.0) { handleCollision(obj, true); 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 spawnRandomObject(z) {
            let type = (Math.random() < 0.1) ? "score" : (Math.random() < 0.05) ? "heal" : "block"; 
            if (Math.random() < Math.min(0.4, 0.1 + (currentStage * 0.04))) type = "hurdle";
            if (Math.random() < 0.06) type = "dash";
            let xPos = (Math.random() - 0.5) * 2.4; 
            let length = (type === 'block') ? 3 : 1; 
            const basis = getBasis(z, viewMode); const currentW = basis.width;
            for(let i = 0; i < length; i++) { 
                let zOffset = z + (i * 12.0), stackHeight = (type === 'block') ? Math.floor(Math.random() * 2) + 1 : 1; 
                let baseAlt = 1.5;
                if (type === "hurdle") baseAlt = 2.0; else if (type === "heal") baseAlt = 1.0; else if (type === "score") baseAlt = 1.5; else if (type === "dash") baseAlt = 0.5;
                for (let k = 0; k < stackHeight; k++) { 
                    const mesh = makeGimmickMesh(type, k);
                    let currentAlt = baseAlt + (type === 'block' ? k * 3.0 : 0);
                    scene.add(mesh); worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: xPos, altitude: currentAlt, vel: new THREE.Vector3(), localPos: new THREE.Vector3(Math.sin(xPos)*currentW, -Math.cos(xPos)*currentW, 0), locked: false, sightDom: null, isDead: false, isScenery: false }); 
                } 
            }
        }

        function handleCollision(obj, forcedKill = false) {
            if (obj.isDead) return; obj.isDead = true; 
            const col = obj.mesh.material?.color || new THREE.Color(0xffffff);
            if (obj.type === "block" || obj.type === "hurdle") {
                if (forcedKill) { sound.play('crash'); createDebris(obj.mesh.position, col, 2.0, 15, "explode"); if (obj.type === "hurdle") { hp -= 1; showPopText("BREAK! -1HP", "#ff3300"); } else { score += 50; stageScore += 50; showPopText("SMASH! +50", "#ffaa00"); } scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }
                else { if (obj.type === "hurdle") { hp -= 2; showPopText("CRASH! -2HP", "#ff0000"); sound.play('crash'); createDebris(obj.mesh.position, col, 1.0, 10, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*2, 5, 8); } else { score += 10; stageScore += 10; showPopText("HIT! +10", "#ffffff"); sound.play('crash'); createDebris(obj.mesh.position, col, 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'); score += 100; stageScore += 100; showPopText("+100", "#ffff00"); createDebris(obj.mesh.position, 0xffe000, 0.6, 8, "spark"); } 
            else if (obj.type === "heal") { sound.play('heal'); hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff66"); createDebris(obj.mesh.position, 0x00ff66, 0.6, 8, "spark"); }
            else if (obj.type === "dash") { sound.play('boost_dash'); player.surge += 15.0; player.energy = Math.min(MAX_ENERGY, player.energy+25); showPopText("DASH!", "#00aaff"); flashBoost(); createDebris(obj.mesh.position, 0x00aaff, 0.6, 8, "spark"); }
        }

        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 mesh = new THREE.Mesh(new THREE.BoxGeometry(s, s, s), new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0, blending: type.includes('spark')?THREE.AdditiveBlending:THREE.NormalBlending })); 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; d.mesh.material.opacity = Math.max(0, 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 = "42%"; document.getElementById('ui-layer').appendChild(div); setTimeout(() => div.remove(), 800); }

        function updateUI() {
            document.getElementById('score-info').innerHTML = "SCORE: " + score + "<small>HYPER DRIVE</small>";
            document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%";
            const eFill = document.getElementById('energy-fill'); eFill.style.width = Math.max(0,(player.energy/MAX_ENERGY*100)) + "%"; eFill.classList.toggle('overheat', player.overheat);
            const val = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? RUSH_SPEED : player.vz;
            document.getElementById('speed-val').innerText = (val * SPEED_DISPLAY_MULTIPLIER).toFixed(0);
            document.getElementById('speed-fill').style.width = Math.min(100, (val / RUSH_SPEED) * 100) + "%";
            document.getElementById('speed-fill').style.backgroundColor = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? "#ff39c0" : "#00f0ff";
            const rTime = Math.max(0, timeLeft); document.getElementById('time-num').innerText = Math.floor(rTime / 60) + ":" + (Math.floor(rTime % 60) < 10 ? "0" : "") + Math.floor(rTime % 60);
            document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%";
        }
        init();
    </script>
</body>
</html>


editor.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Image Booster Course Editor 1.3</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
        
        #menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e; }
        .menu-item { padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc; }
        .menu-item:hover { background: #3e3e3e; color: #fff; }
        .dropdown { display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item { padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px; }
        .dropdown-item:hover { background: #094771; color: #fff; }
        .separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }

        #container { display: flex; height: calc(100vh - 30px); position: relative; }
        #canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #111; transition: width 0.2s; }
        
        #properties-panel { width: 280px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto; }
        #segment-editor-panel { display: none; position: absolute; top: 0; right: 0; width: 450px; height: 100%; background: #1e1e1e; border-left: 1px solid #3e3e3e; z-index: 100; flex-direction: column; padding: 15px; box-sizing: border-box; box-shadow: -5px 0 15px rgba(0,0,0,0.5); }
        
        .prop-group { margin-bottom: 15px; }
        .prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
        input[type="text"], input[type="number"], input[type="color"], select { width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 4px; font-size: 12px; border-radius: 2px; text-align: right; }
        select { text-align: left; cursor: pointer; }
        input:focus, select:focus { border-color: #007fd4; outline: none; }
        
        button.action-btn { width: 100%; padding: 6px; background: #333; color: #ccc; border: 1px solid #444; cursor: pointer; font-size: 12px; border-radius: 2px; transition: 0.1s; margin-bottom: 5px; }
        button.action-btn:hover { background: #444; color: #fff; }
        button.highlight { background: #0e639c; color: white; border: none; font-weight: bold; }
        button.highlight:hover { background: #1177bb; }

        .file-select-row { display: flex; gap: 5px; margin-bottom: 8px; }
        .file-select-row input[type="text"] { flex-grow: 1; margin-bottom: 0; color: #aaa; }
        .file-select-row button { width: auto; margin-bottom: 0; padding: 4px 10px; }

        #status-bar { position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8); }
        #info-overlay { position: absolute; top: 10px; left: 10px; pointer-events: none; }
        #mode-display { background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block; }
        .axis-label { position: absolute; font-family: monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff; }

        /* Segment Map Grid */
        #map-grid-wrapper { flex-grow: 1; overflow-y: auto; background: #111; padding: 10px; border: 1px solid #333; margin-top: 10px; border-radius: 4px; position: relative; }
        #map-grid-container { display: grid; grid-template-columns: repeat(16, 1fr); gap: 1px; }
        .map-cell { aspect-ratio: 1; border: 1px solid #3e3e3e; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: bold; color: transparent; transition: 0.1s; position: relative; }
        .map-cell:hover { filter: brightness(1.5); border-color: #fff; z-index: 10; }
        .active-row { border-top: 2px solid #fff; border-bottom: 2px solid #fff; box-shadow: inset 0 0 10px rgba(255,255,255,0.8); }
        
        .cell-road { background: #55aa55; }
        .cell-wall { background: #225522; }
        
        .cell-hurdle { background: #ff3300 !important; color: #fff !important; }
        .cell-heal { background: #00ff00 !important; color: #000 !important; }
        .cell-score { background: #ffff00 !important; color: #000 !important; }
        .cell-dash { background: #0088ff !important; color: #fff !important; }
        .cell-block { background: #888888 !important; color: #fff !important; }
        .cell-tex { border: 2px dashed #ff00ff; }

        #modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 3000; justify-content: center; align-items: center; }
        .modal-box { background: #252526; padding: 20px; border: 1px solid #454545; width: 350px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
        .modal-box h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; margin-bottom: 15px; }
        
        .gimmick-hurdle { color: #ff3300; } .gimmick-heal { color: #00ff00; } .gimmick-score { color: #ffff00; } .gimmick-dash { color: #0088ff; } .gimmick-block { color: #aaaaaa; }
    </style>
    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
    </script>
</head>
<body>
    <div id="menubar">
        <div class="menu-item">File
            <div class="dropdown">
                <div class="dropdown-item" id="menu-new">New Course</div>
                <div class="dropdown-item" id="menu-open">Open Course JSON</div>
                <div class="dropdown-item" id="menu-save">Save Course JSON</div>
            </div>
        </div>
        <div class="menu-item">Edit
            <div class="dropdown">
                <div class="dropdown-item" id="tool-undo">Undo (Ctrl+Z)</div>
                <div class="dropdown-item" id="tool-redo">Redo (Ctrl+Y)</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-select">Select</div>
                <div class="dropdown-item" id="tool-add">Add Node / Scenery</div>
                <div class="dropdown-item" id="tool-erase">Erase (Del)</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-move">Move</div>
                <div class="dropdown-item" id="tool-rotate">Rotate</div>
                <div class="dropdown-item" id="tool-scale">Scale</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-test-play">Start Full Test Play</div>
            </div>
        </div>
        <div class="menu-item">View
            <div class="dropdown">
                <div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
                <div class="dropdown-item" onclick="viewCam('top')">Top</div>
                <div class="dropdown-item" onclick="viewCam('front')">Front</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-cam-move">Camera Move</div>
                <div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
                <div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
                <div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
            </div>
        </div>
        <div class="menu-item">Environment
            <div class="dropdown">
                <div class="dropdown-item" id="menu-env-settings">Textures Settings</div>
                <div class="dropdown-item" id="menu-terrain">Terrain Generator</div>
            </div>
        </div>
        <div style="margin-left:auto; font-size:12px; color:#aaa; padding-right:15px;">
            Tip: Double-Click the track to open 2D Segment Map Editor. Right-Click cell to erase.
        </div>
    </div>

    <div id="container">
        <div id="canvas-container" tabindex="0">
            <div id="info-overlay"><div id="mode-display">Select</div></div>
            <div id="status-bar">Ready</div>
            <div id="labels-container"></div>
        </div>
        
        <div id="properties-panel">
            <h3 id="prop-header" style="color:#4fc1ff; margin-top:0; border-bottom:1px solid #444; padding-bottom:5px;">Properties</h3>
            
            <div id="prop-add-settings" style="display:none;">
                <div class="prop-group">
                    <span class="prop-label">Item to Add</span>
                    <select id="add-item-type">
                        <option value="node">Track Node</option>
                        <option value="scenery">Background Block</option>
                        <option value="bgmodel">Background Model</option>
                    </select>
                </div>
                <div id="add-scenery-options" style="display:none; margin-top:5px;">
                    <button class="action-btn" id="btn-select-brush-tex">Select Brush Texture</button>
                    <div class="file-select-row"><input type="text" id="brush-tex-name" disabled><button class="action-btn" id="btn-clear-brush-tex">Clear</button></div>
                </div>
                <div id="add-bgmodel-options" style="display:none;">
                    <div class="prop-group"><span class="prop-label">Model List (Loaded)</span><select id="bgmodel-list" size="5"></select></div>
                    <button class="action-btn highlight" id="btn-load-model">Select Model File to Load</button>
                </div>
            </div>

            <div id="prop-content" style="display:none;">
                <div class="prop-group">
                    <span class="prop-label">Position (X, Y, Z)</span>
                    <div style="display:flex; gap:5px;"><input type="number" id="prop-x" step="10"><input type="number" id="prop-y" step="10"><input type="number" id="prop-z" step="10"></div>
                </div>
                
                <div id="prop-node-group">
                    <div class="prop-group"><span class="prop-label">Width (Radius)</span><input type="number" id="prop-width" step="1"></div>
                    <div class="prop-group"><span class="prop-label">Bank / Roll (Deg)</span><input type="number" id="prop-roll" step="5"></div>
                    <div class="prop-group"><span class="prop-label" style="color:#ffcc00;">Road Twist (Deg)</span><input type="number" id="prop-twist" step="5"></div>
                    <button class="action-btn highlight" id="btn-smooth-node" style="margin-top: 10px;">Auto Smooth (Align to Spline)</button>
                </div>

                <div id="prop-scenery-group">
                    <div class="prop-group"><span class="prop-label">Rotation Y (Deg)</span><input type="number" id="prop-ry" step="15"></div>
                    <div class="prop-group">
                        <span class="prop-label">Scale (X, Y, Z)</span>
                        <div style="display:flex; gap:5px;"><input type="number" id="prop-sx" step="1"><input type="number" id="prop-sy" step="1"><input type="number" id="prop-sz" step="1"></div>
                    </div>
                    <div id="prop-color-group" style="display:none;">
                        <div class="prop-group"><span class="prop-label">Color</span><input type="color" id="prop-color"></div>
                        <div class="prop-group"><span class="prop-label">Texture</span>
                            <div class="file-select-row"><input type="text" id="prop-tex-name" disabled>
                                <button class="action-btn" id="btn-prop-select-tex">Set</button>
                                <button class="action-btn" id="btn-prop-clear-tex">Clear</button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">No item selected.</div>
        </div>

        <div id="segment-editor-panel">
            <h3 id="seg-panel-title" style="color:#ffcc00; margin-top:0; border-bottom:1px solid #444; padding-bottom:5px;">2D Segment Map Editor</h3>
            <div style="display:flex; gap:5px; margin-bottom:15px;">
                <button class="action-btn" id="btn-seg-back" style="flex:1;">← Back to Main</button>
                <button class="action-btn highlight" id="btn-seg-play" style="flex:1; background:#aa00aa;">▶ Test Segment</button>
            </div>
            
            <div style="display:flex; justify-content:space-between; margin-bottom: 15px; border-bottom:1px solid #333; padding-bottom:10px;">
                <button class="action-btn" id="btn-seg-cam-back" style="width:48%">Cam ← Back</button>
                <button class="action-btn" id="btn-seg-cam-fwd" style="width:48%">Cam Fwd →</button>
            </div>

            <div style="display:flex; gap:5px; margin-bottom:10px;">
                <button id="btn-mode-gimmick" class="action-btn highlight" style="flex:1;">Gimmick Mode</button>
                <button id="btn-mode-texture" class="action-btn" style="flex:1;">Texture Mode</button>
            </div>

            <div id="seg-gimmick-options" class="prop-group">
                <span class="prop-label">Select Gimmick to Place</span>
                <select id="seg-gimmick-type" style="padding:8px; font-weight:bold;">
                    <option value="hurdle" class="gimmick-hurdle">Hurdle (Red)</option>
                    <option value="heal" class="gimmick-heal">Heal (Green)</option>
                    <option value="score" class="gimmick-score">Score (Yellow)</option>
                    <option value="dash" class="gimmick-dash">Dash Pad (Blue)</option>
                    <option value="block1" class="gimmick-block">Block x1 (Gray)</option>
                    <option value="block2" class="gimmick-block">Block x2 (Vertical)</option>
                    <option value="block3" class="gimmick-block">Block x3 (Vertical)</option>
                </select>
            </div>

            <div id="seg-texture-options" class="prop-group" style="display:none;">
                <span class="prop-label">Select Texture to Paint</span>
                <select id="seg-texture-type" style="padding:8px;">
                    <option value="default_road">Default Road Texture</option>
                    <option value="default_wall">Default Wall Texture</option>
                </select>
            </div>

            <span class="prop-label" style="text-align:center; color:#888;">↑ Forward (Next Node) ↑</span>
            <div id="map-grid-wrapper">
                <div id="map-grid-container"></div>
            </div>
            <span class="prop-label" style="text-align:center; color:#888; margin-top:5px;">↓ Backward (Current Node) ↓</span>
        </div>
    </div>

    <div id="modal-overlay">
        <div class="modal-box" id="env-dialog" style="display:none;">
            <h3>Environment Textures</h3>
            <div class="prop-group"><span class="prop-label">Road Texture</span><div class="file-select-row"><input type="text" id="env-road" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'RoadImage/', f=>updateEnvField('env-road',f))">Select</button></div></div>
            <div class="prop-group"><span class="prop-label">Wall Texture</span><div class="file-select-row"><input type="text" id="env-wall" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'WallImage/', f=>updateEnvField('env-wall',f))">Select</button></div></div>
            <div class="prop-group"><span class="prop-label">SkyDome Texture</span><div class="file-select-row"><input type="text" id="env-sky" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'SkyDomeImage/', f=>updateEnvField('env-sky',f))">Select</button></div></div>
            <div class="prop-group"><span class="prop-label">Ground Texture</span><div class="file-select-row"><input type="text" id="env-ground" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'BgGroundImage/', f=>updateEnvField('env-ground',f))">Select</button></div></div>
            <div style="text-align:right; margin-top:20px;"><button class="action-btn highlight" style="width:auto; padding:5px 15px;" onclick="closeModal()">Close & Apply</button></div>
        </div>
        <div class="modal-box" id="terr-dialog" style="display:none;">
            <h3>Terrain Generator</h3>
            <div class="prop-group"><span class="prop-label">Amplitude (Height)</span><input type="number" id="terr-amp" value="100" step="10"></div>
            <div class="prop-group"><span class="prop-label">Frequency (Scale)</span><input type="number" id="terr-freq" value="0.003" step="0.001"></div>
            <div style="text-align:right; margin-top:20px;">
                <button class="action-btn" style="width:auto; padding:5px 15px;" onclick="closeModal()">Cancel</button>
                <button class="action-btn highlight" style="width:auto; padding:5px 15px;" onclick="generateTerrain(); closeModal(); saveState();">Generate</button>
            </div>
        </div>
    </div>
    <input type="file" id="file-input-json" style="display: none;" accept=".json">

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';

        let scene, camera, renderer, controls, raycaster, mouse;
        let currentTool = 'select', gridSize = 10, baseHeight = 50;
        let trackNodes = [], sceneryItems = [], trackSplineGroup = null, selectedItem = null, addItemType = 'node', clipboard = null;
        let currentBrushTex = null;
        let planeGround, cursorTarget, cursorPoint, cursorSelect, labelsContainer;
        let isDragging = false, dragHasChanged = false, dragStartMouse = new THREE.Vector2(), dragStartVal = new THREE.Vector3(), startCursorPos = new THREE.Vector3();
        let axisLock = { x: false, y: false, z: false };
        
        let isLapping = false, lapsQ = new THREE.Quaternion(), lapsTargetQ = new THREE.Quaternion(), lapsRadius = 300, lapsCenter = new THREE.Vector3();
        let isTestPlaying = false, testPlayT = 0;
        let dragStartCamPos = new THREE.Vector3(), dragStartCamTarget = new THREE.Vector3(), dragStartPolar = 0, dragStartAzimuth = 0;
        let gridGroup = new THREE.Group(), guideLineGroup = new THREE.Group(), shadowPool = [], walls = {};

        let cachedCurve = null, cachedFrames = null;

        // Segment Map Editor
        let isSegmentMode = false, mapMode = 'gimmick', currentSegIdx = 0, segmentCamU = 0, isSegmentTestPlaying = false;
        const GRID_ROWS = 20, GRID_COLS = 16; 

        const textureLoader = new THREE.TextureLoader(), gltfLoader = new GLTFLoader(), objLoader = new OBJLoader();

        // Undo / Redo System
        let historyStack = [], historyIndex = -1;
        let ASSETS = { env: { road: null, wall: null, sky: null, ground: null }, bgModels: {}, envTextures: {} };
        let envMeshes = { sky: null, stars: null };

        // Helper: File Selection
        window.selectFile = (accept, folderPrefix, callback) => {
            const input = document.createElement('input'); input.type = 'file'; input.accept = accept;
            input.onchange = e => { 
                const f = e.target.files[0]; 
                if(f) {
                    const obj = { name: folderPrefix + f.name, url: URL.createObjectURL(f) };
                    // Add to generic texture cache for map mode
                    if(accept.includes('png')||accept.includes('jpg')) {
                        ASSETS.envTextures[obj.name] = obj;
                        refreshTextureSelect();
                    }
                    callback(obj); 
                }
            };
            input.click();
        };

        window.updateEnvField = (id, fileObj) => { 
            const el = document.getElementById(id);
            if(el) el.value = fileObj.name; 
            ASSETS.env[id.split('-')[1]] = fileObj; 
            applyEnvironment(); 
        };
        window.closeModal = () => { document.getElementById('modal-overlay').style.display = 'none'; document.getElementById('env-dialog').style.display = 'none'; document.getElementById('terr-dialog').style.display = 'none'; };

        function getTex(fileObj, callback) {
            if(!fileObj) return null;
            if(ASSETS.envTextures[fileObj.name] && ASSETS.envTextures[fileObj.name].texture) { const t = ASSETS.envTextures[fileObj.name].texture; if(callback) callback(t); return t; }
            const tex = textureLoader.load(fileObj.url, t => { t.wrapS = t.wrapT = THREE.RepeatWrapping; if(callback) callback(t); });
            if(!ASSETS.envTextures[fileObj.name]) ASSETS.envTextures[fileObj.name] = { name: fileObj.name, url: fileObj.url };
            ASSETS.envTextures[fileObj.name].texture = tex;
            return tex;
        }

        function refreshTextureSelect() {
            const sel = document.getElementById('seg-texture-type');
            if(!sel) return;
            sel.innerHTML = '<option value="default_road">Default Road Texture</option><option value="default_wall">Default Wall Texture</option>';
            for(let key in ASSETS.envTextures) {
                const opt = document.createElement('option'); opt.value = key; opt.innerText = key; sel.appendChild(opt);
            }
        }

        // --- Math & Spline Helpers ---
        function distanceToSegment(p, v, w) {
            let l2 = v.distanceToSquared(w); if (l2 === 0) return p.distanceTo(v);
            let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y) + (p.z - v.z) * (w.z - v.z)) / l2;
            t = Math.max(0, Math.min(1, t));
            let proj = new THREE.Vector3(v.x + t * (w.x - v.x), v.y + t * (w.y - v.y), v.z + t * (w.z - v.z));
            return p.distanceTo(proj);
        }
        function lerpAngleDeg(a, b, t) { let d = b - a; while(d > 180) d -= 360; while(d < -180) d += 360; return a + d * t; }

        function getLoopCatmullRom(values, t) {
            if(values.length === 0) return 0; const len = values.length; const exact = t * len;
            let i1 = Math.floor(exact) % len; if(i1 < 0) i1 += len;
            const frac = exact - Math.floor(exact);
            const i0 = (i1 - 1 + len) % len, i2 = (i1 + 1) % len, i3 = (i1 + 2) % len;
            const v1 = values[i1];
            let v0 = values[i0]; while(v0 - v1 > 180) v0 -= 360; while(v0 - v1 < -180) v0 += 360;
            let v2 = values[i2]; while(v2 - v1 > 180) v2 -= 360; while(v2 - v1 < -180) v2 += 360;
            let v3 = values[i3]; while(v3 - v2 > 180) v3 -= 360; while(v3 - v2 < -180) v3 += 360;
            const c0 = v1, c1 = 0.5 * (v2 - v0), c2 = v0 - 2.5 * v1 + 2 * v2 - 0.5 * v3, c3 = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3;
            return ((c3 * frac + c2) * frac + c1) * frac + c0;
        }

        function getSplineInterpolation(targetPos) {
            if(trackNodes.length < 3) return null;
            const points = trackNodes.map(n => n.position);
            const curve = new THREE.CatmullRomCurve3(points, true);
            let minDist = Infinity, bestT = 0; const RES = trackNodes.length * 50;
            for(let i=0; i<=RES; i++) { 
                const t = i / RES; const pt = curve.getPointAt(t); const dist = pt.distanceToSquared(targetPos); 
                if(dist < minDist) { minDist = dist; bestT = t; } 
            }
            const exact = bestT * trackNodes.length; let insertIdx = Math.ceil(exact) % trackNodes.length; if (insertIdx === 0) insertIdx = trackNodes.length;
            const u = bestT; const tParam = curve.getUtoTmapping(u);
            const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
            
            return { position: curve.getPointAt(u), roll: getLoopCatmullRom(rolls, tParam), twist: getLoopCatmullRom(twists, tParam), width: getLoopCatmullRom(widths, tParam), insertIdx: insertIdx };
        }

        // Automatic Smoothing Helper to align angles based on spline position 
        function autoAdjustNodeAngles(nodeToSmooth) {
            if(!nodeToSmooth || nodeToSmooth.userData.type !== 'node') return;
            const idx = trackNodes.indexOf(nodeToSmooth); if(idx < 0 || trackNodes.length <= 3) return;
            const tempNodes = [...trackNodes]; tempNodes.splice(idx, 1);
            const points = tempNodes.map(n => n.position); const curve = new THREE.CatmullRomCurve3(points, true);
            let minDist = Infinity, bestU = 0; const RES = tempNodes.length * 50;
            for(let i=0; i<=RES; i++) { 
                const u = i / RES; const pt = curve.getPointAt(u); const dist = pt.distanceToSquared(nodeToSmooth.position); 
                if(dist < minDist) { minDist = dist; bestU = u; } 
            }
            const tParam = curve.getUtoTmapping(bestU);
            const rolls = tempNodes.map(n => n.userData.roll), twists = tempNodes.map(n => n.userData.twist);
            nodeToSmooth.userData.roll = getLoopCatmullRom(rolls, tParam); 
            nodeToSmooth.userData.twist = getLoopCatmullRom(twists, tParam); 
        }

        function smoothNode(nodeToSmooth) {
            if(!nodeToSmooth || nodeToSmooth.userData.type !== 'node') return;
            const idx = trackNodes.indexOf(nodeToSmooth); if(idx < 0 || trackNodes.length <= 3) return;
            const tempNodes = [...trackNodes]; tempNodes.splice(idx, 1);
            const points = tempNodes.map(n => n.position); const curve = new THREE.CatmullRomCurve3(points, true);
            let tIndex = idx - 0.5; if (tIndex < 0) tIndex += tempNodes.length; const u = tIndex / tempNodes.length; const tParam = curve.getUtoTmapping(u);
            const rolls = tempNodes.map(n => n.userData.roll), twists = tempNodes.map(n => n.userData.twist), widths = tempNodes.map(n => n.userData.width || 36);
            nodeToSmooth.position.copy(curve.getPointAt(u)); nodeToSmooth.userData.roll = getLoopCatmullRom(rolls, tParam); nodeToSmooth.userData.twist = getLoopCatmullRom(twists, tParam); nodeToSmooth.userData.width = getLoopCatmullRom(widths, tParam);
        }

        function init() {
            const container = document.getElementById('canvas-container'); labelsContainer = document.getElementById('labels-container');
            scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000);
            camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 1, 5000);
            camera.position.set(200, 200, 200); camera.lookAt(0,0,0);
            renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement);

            scene.add(new THREE.AmbientLight(0xffffff, 0.6));
            const sun = new THREE.DirectionalLight(0xffffff, 0.8); sun.position.set(100, 300, 200); scene.add(sun);
            
            scene.add(gridGroup); scene.add(guideLineGroup);
            planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000, 100, 100), new THREE.MeshPhongMaterial({ color: 0x222222, flatShading: true }));
            planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -0.1; scene.add(planeGround);

            const starGeo = new THREE.BufferGeometry(); const posArray = new Float32Array(2000 * 3); for(let i=0; i<2000 * 3; i++) posArray[i] = (Math.random() - 0.5) * 4000; starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); envMeshes.stars = new THREE.Points(starGeo, new THREE.PointsMaterial({color: 0xffffff, size: 2.0})); scene.add(envMeshes.stars);

            initCursors(); initShadowPool(); updateEnvironment();

            controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true;
            raycaster = new THREE.Raycaster(); mouse = new THREE.Vector2();

            window.addEventListener('resize', onResize);
            window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
            const cvs = renderer.domElement;
            cvs.addEventListener('pointermove', onPointerMove); cvs.addEventListener('pointerdown', onPointerDown); cvs.addEventListener('pointerup', onPointerUp);
            cvs.addEventListener('dblclick', onDoubleClick);
            cvs.addEventListener('contextmenu', e => e.preventDefault());

            setupUI(); setTool('select');
            // Default width 36 for broader tunnel
            createNode(new THREE.Vector3(0, 50, 0), 0, 0, 36); createNode(new THREE.Vector3(0, 50, -300), 0, 0, 36); createNode(new THREE.Vector3(300, 50, -150), 45, -15, 36);
            rebuildTrackCurve(); 
            saveState(); 
            animate();
        }

        function onResize() {
            const container = document.getElementById('canvas-container');
            if(camera && renderer && container) { camera.aspect = container.clientWidth/container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); updateLabels(); }
        }

        window.saveState = () => {
            const data = {
                track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist, width: n.userData.width||36 })),
                scenery: sceneryItems.map(s => { 
                    const base = { type: s.userData.subType, x: s.position.x, y: s.position.y, z: s.position.z, sx: s.scale.x, sy: s.scale.y, sz: s.scale.z, ry: s.rotation.y, segIdx: s.userData.segIdx, row: s.userData.row, col: s.userData.col, curveU: s.userData.curveU, angle: s.userData.angle }; 
                    if(s.userData.subType === 'block') { base.color = s.material.color.getHex(); base.texture = s.userData.texObj?.name; } 
                    if(s.userData.subType === 'bgmodel') base.modelName = s.userData.fileObj?.name; 
                    if(s.userData.subType === 'gimmick') base.gimmickType = s.userData.gimmickType;
                    if(s.userData.subType === 'texture') base.texturePath = s.userData.texObj?.name;
                    return base; 
                })
            };
            historyStack.splice(historyIndex + 1); historyStack.push(JSON.stringify(data)); historyIndex++;
            const sb = document.getElementById('status-bar'); if(sb) sb.innerText = `State Saved [${historyIndex+1}/${historyStack.length}]`;
        };

        function restoreState(index) {
            if(index < 0 || index >= historyStack.length) return;
            const data = JSON.parse(historyStack[index]);
            trackNodes.forEach(n => scene.remove(n)); sceneryItems.forEach(s => { if(s.type!=='texture') scene.remove(s); }); trackNodes = []; sceneryItems = [];
            
            if(data.track) data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll, n.twist, n.width));
            if(data.scenery) {
                data.scenery.forEach(s => {
                    const pos = new THREE.Vector3(s.x, s.y, s.z), scl = new THREE.Vector3(s.sx, s.sy, s.sz); let mesh;
                    if(s.type === 'block') mesh = createSceneryBlock(pos, scl, s.color, s.ry, s.texture ? {name: s.texture, url: s.texture} : null);
                    else if(s.type === 'bgmodel' && ASSETS.bgModels[s.modelName]) mesh = createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
                    else if(s.type === 'gimmick') mesh = createGimmick(s.gimmickType, pos, scl, s.ry);
                    else if(s.type === 'texture') { sceneryItems.push({ userData: { type: 'scenery', subType: 'texture', texObj: {name: s.texturePath}, segIdx: s.segIdx, row: s.row, col: s.col }}); }
                    if(mesh) { mesh.userData.segIdx = s.segIdx; mesh.userData.row = s.row; mesh.userData.col = s.col; mesh.userData.curveU = s.curveU; mesh.userData.angle = s.angle; }
                });
            }
            selectItem(null); rebuildTrackCurve(); historyIndex = index; if(isSegmentMode) renderMap();
            const sb = document.getElementById('status-bar'); if(sb) sb.innerText = `Restored [${historyIndex+1}/${historyStack.length}]`;
        }

        function initCursors() {
            cursorTarget = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })); cursorTarget.rotation.x = -Math.PI / 2; scene.add(cursorTarget);
            cursorPoint = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 })); scene.add(cursorPoint);
            cursorSelect = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false })); cursorSelect.visible = false; scene.add(cursorSelect);
        }
        function initShadowPool() { for(let i=0; i<6; i++) { const s = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(1, 1)), new THREE.LineBasicMaterial({ color: 0xffffff })); shadowPool.push(s); guideLineGroup.add(s); } }

        function updateEnvironment() {
            while(gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]);
            const s = 2000; gridGroup.add(new THREE.GridHelper(s, 200, 0x555555, 0x2a2a2a));
            const makeWall = (rx, rz, px, pz) => { const w = new THREE.GridHelper(s, 200, 0x444444, 0x222222); w.rotation.x = rx; w.rotation.z = rz; w.position.set(px, s/2, pz); return w; };
            walls.zNeg = makeWall(-Math.PI/2, 0, 0, -s/2); walls.zPos = makeWall(-Math.PI/2, 0, 0, s/2); walls.xNeg = makeWall(0, -Math.PI/2, -s/2, 0); walls.xPos = makeWall(0, -Math.PI/2, s/2, 0);
            gridGroup.add(walls.zNeg); gridGroup.add(walls.zPos); gridGroup.add(walls.xNeg); gridGroup.add(walls.xPos);
        }

        window.generateTerrain = () => {
            const amp = parseFloat(document.getElementById('terr-amp').value) || 100, freq = parseFloat(document.getElementById('terr-freq').value) || 0.003;
            const geo = planeGround.geometry; const pos = geo.attributes.position;
            for(let i=0; i<pos.count; i++) { let x = pos.getX(i), y = pos.getY(i), dist = Math.min(1, Math.abs(x)/200), z = Math.sin(x * freq) * Math.cos(y * freq) * amp * dist; pos.setZ(i, z); }
            pos.needsUpdate = true; geo.computeVertexNormals();
        };

        function applyEnvironment() {
            if(ASSETS.env.ground) { getTex(ASSETS.env.ground, t => { planeGround.material.map = t; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; }); } else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
            if(ASSETS.env.sky) { if(!envMeshes.sky) { envMeshes.sky = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(envMeshes.sky); } getTex(ASSETS.env.sky, t => { envMeshes.sky.material.map = t; envMeshes.sky.visible = true; }); envMeshes.stars.visible = false; } else { if(envMeshes.sky) envMeshes.sky.visible = false; envMeshes.stars.visible = true; }
            rebuildTrackCurve();
        }

        function rebuildTrackCurve() {
            if (trackSplineGroup) scene.remove(trackSplineGroup);
            if (trackNodes.length < 3) return;

            const points = trackNodes.map(n => n.position);
            const curve = new THREE.CatmullRomCurve3(points, true);
            const SEGMENTS = trackNodes.length * 20;
            
            const frames = { tangents: [], normals: [], binormals: [] };
            for(let i=0; i<=SEGMENTS; i++) { frames.tangents.push(curve.getTangentAt(curve.getUtoTmapping(i/SEGMENTS)).normalize()); }
            frames.normals[0] = new THREE.Vector3(0,1,0); 
            frames.binormals[0] = new THREE.Vector3().crossVectors(frames.tangents[0], frames.normals[0]).normalize();
            frames.normals[0].crossVectors(frames.binormals[0], frames.tangents[0]).normalize();

            for(let i=1; i<=SEGMENTS; i++) {
                const axis = new THREE.Vector3().crossVectors(frames.tangents[i-1], frames.tangents[i]);
                const sin = axis.length(), cos = frames.tangents[i-1].dot(frames.tangents[i]), angle = Math.atan2(sin, cos);
                const q = new THREE.Quaternion(); if(sin > 0.0001) q.setFromAxisAngle(axis.normalize(), angle);
                frames.normals.push(frames.normals[i-1].clone().applyQuaternion(q));
                frames.binormals.push(frames.binormals[i-1].clone().applyQuaternion(q));
            }
            let twistTotal = Math.acos(Math.max(-1, Math.min(1, frames.normals[0].dot(frames.normals[SEGMENTS]))));
            if (new THREE.Vector3().crossVectors(frames.normals[SEGMENTS], frames.normals[0]).dot(frames.tangents[0]) < 0) twistTotal = -twistTotal;
            for(let i=1; i<=SEGMENTS; i++) {
                const q = new THREE.Quaternion().setFromAxisAngle(frames.tangents[i], (i/SEGMENTS)*twistTotal);
                frames.normals[i].applyQuaternion(q); frames.binormals[i].applyQuaternion(q);
            }

            cachedCurve = curve; cachedFrames = frames; 

            const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
            const positions = [], uvs = [], indices = [];

            for (let i = 0; i <= SEGMENTS; i++) {
                const u = i / SEGMENTS, tParam = curve.getUtoTmapping(u);
                const pt = curve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
                
                const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), currentW = getLoopCatmullRom(widths, tParam);
                const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
                const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
                const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
                const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);

                // c=0~15 : 0~3 Left Wall, 4~11 Road, 12~15 Right Wall
                for (let j = 0; j <= 16; j++) {
                    const angle = (j / 16) * Math.PI * 2 - Math.PI; 
                    let v;
                    if(j >= 4 && j <= 12) { // Road twisted
                        v = pt.clone().addScaledVector(twistRight, Math.sin(angle)*currentW).addScaledVector(twistUp, -Math.cos(angle)*currentW);
                    } else { // Wall normal
                        v = pt.clone().addScaledVector(right, Math.sin(angle)*currentW).addScaledVector(up, -Math.cos(angle)*currentW);
                    }
                    positions.push(v.x, v.y, v.z); 
                    uvs.push(j / 16, u * trackNodes.length * 8);
                }
            }

            const geo = new THREE.BufferGeometry();
            
            // Build Multi-Materials
            const materials = []; const matIndexMap = {};
            const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide }); if(ASSETS.env.road) getTex(ASSETS.env.road, t => { roadMat.map = t; roadMat.needsUpdate = true; });
            const wallMat = new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide }); if(ASSETS.env.wall) getTex(ASSETS.env.wall, t => { wallMat.map = t; wallMat.needsUpdate = true; }); else wallMat.wireframe = true;
            materials.push(roadMat); materials.push(wallMat);
            let nextMatIdx = 2;
            for(let key in ASSETS.envTextures) {
                const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, side: THREE.DoubleSide }); getTex(ASSETS.envTextures[key], t => { mat.map = t; mat.needsUpdate=true; });
                materials.push(mat); matIndexMap[key] = nextMatIdx++;
            }

            let faceIdx = 0;
            for (let i = 0; i < SEGMENTS; i++) {
                const segIdx = Math.floor((i / SEGMENTS) * trackNodes.length);
                const row = i % 20; 
                for (let c = 0; c < 16; c++) { 
                    const a = i*17+c, b = (i+1)*17+c, c_idx = (i+1)*17+(c+1), d = i*17+(c+1); 
                    indices.push(a,b,d); indices.push(b,c_idx,d); 
                    
                    let mIdx = (c >= 4 && c <= 11) ? 0 : 1; 
                    const texItem = sceneryItems.find(s => s.userData.subType === 'texture' && s.userData.segIdx === segIdx && s.userData.row === row && s.userData.col === c);
                    if (texItem && matIndexMap[texItem.userData.texObj.name]) mIdx = matIndexMap[texItem.userData.texObj.name];
                    
                    geo.addGroup(faceIdx * 6, 6, mIdx);
                    faceIdx++;
                }
            }

            geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); 
            geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); 
            geo.setIndex(indices); geo.computeVertexNormals(); 
            
            trackSplineGroup = new THREE.Mesh(geo, materials);
            scene.add(trackSplineGroup);
            updateGimmicksTransform();
        }

        // --- Nodes & Scenery Creation ---
        function createNode(pos, roll, twist, width = 36) {
            const mesh = new THREE.Mesh(new THREE.SphereGeometry(6, 16, 16), new THREE.MeshPhongMaterial({ color: 0x00ff00, emissive: 0x004400 }));
            mesh.position.copy(pos); mesh.userData = { type: 'node', roll: roll, twist: twist, width: width };
            scene.add(mesh); trackNodes.push(mesh); return mesh;
        }

        function createSceneryBlock(pos, scaleVec, colorHex, rotY, texObj = null) {
            const mat = new THREE.MeshStandardMaterial({ color: colorHex });
            const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
            if(texObj) { getTex(texObj, t => { mat.map = t; mat.color.setHex(0xffffff); mat.needsUpdate = true; }); mesh.userData.texObj = texObj; }
            mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
            mesh.userData.type = 'scenery'; mesh.userData.subType = 'block';
            scene.add(mesh); sceneryItems.push(mesh); return mesh;
        }

        function createBgModelInstance(fileObj, pos, scaleVec, rotY) {
            if(!ASSETS.bgModels[fileObj.name]) return null;
            const mesh = ASSETS.bgModels[fileObj.name].object.clone();
            mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
            mesh.userData = { type: 'scenery', subType: 'bgmodel', fileObj: fileObj };
            const box = new THREE.Box3().setFromObject(mesh); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3());
            const hitBox = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z), new THREE.MeshBasicMaterial({visible:false}));
            hitBox.position.copy(center).sub(mesh.position); mesh.add(hitBox); mesh.userData.hitBox = hitBox;
            scene.add(mesh); sceneryItems.push(mesh); return mesh;
        }

        function createGimmick(type, pos, scaleVec, rotY) {
            let col = 0xffffff; let isBlock = false; let stack = 1;
            if(type==='hurdle') col=0xff3300; else if(type==='heal') col=0x00ff00; else if(type==='score') col=0xffff00; else if(type==='dash') col=0x0088ff;
            else if(type.startsWith('block')) { col=0xaaaaaa; isBlock=true; stack = parseInt(type.replace('block',''))||1; }
            
            const group = new THREE.Group();
            const mat = new THREE.MeshBasicMaterial({color:col, wireframe:true, transparent:true, opacity:0.8});
            for(let i=0; i<stack; i++) {
                const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
                mesh.position.y = isBlock ? (i * gridSize) : 0;
                group.add(mesh);
            }
            group.position.copy(pos); group.scale.copy(scaleVec); group.rotation.y = rotY;
            group.userData = { type: 'scenery', subType: 'gimmick', gimmickType: type };
            scene.add(group); sceneryItems.push(group); return group;
        }

        function updateGimmicksTransform() {
            if(!cachedCurve || !cachedFrames) return;
            sceneryItems.forEach(s => {
                if(s.userData.subType === 'gimmick') {
                    const row = s.userData.row, colIdx = s.userData.col, segIdx = s.userData.segIdx;
                    if(row === undefined || colIdx === undefined) return;
                    
                    const t = row / GRID_ROWS, u = (segIdx + t) / trackNodes.length; 
                    const tParam = cachedCurve.getUtoTmapping(u);
                    const pt = cachedCurve.getPointAt(u), segs = cachedFrames.tangents.length;
                    const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
                    const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi], B = cachedFrames.binormals[fi];
                    
                    const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
                    const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), currentW = getLoopCatmullRom(widths, tParam);
                    const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg)), up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
                    const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg)), twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);

                    const angle = (colIdx / 16) * Math.PI * 2 - Math.PI; 
                    const localX = Math.sin(angle) * currentW, localY = -Math.cos(angle) * currentW;

                    let finalPos, finalUp, finalRight;
                    if (colIdx >= 4 && colIdx <= 11) { // Road
                        finalPos = pt.clone().addScaledVector(twistRight, localX).addScaledVector(twistUp, localY);
                        finalUp = new THREE.Vector3().addScaledVector(twistRight, -Math.sin(angle)).addScaledVector(twistUp, Math.cos(angle)).normalize();
                        finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
                    } else { // Wall
                        finalPos = pt.clone().addScaledVector(right, localX).addScaledVector(up, localY);
                        finalUp = new THREE.Vector3().addScaledVector(right, -Math.sin(angle)).addScaledVector(up, Math.cos(angle)).normalize();
                        finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
                    }
                    
                    let offset = gridSize/2; finalPos.addScaledVector(finalUp, offset);
                    const m = new THREE.Matrix4(); m.makeBasis(finalRight, finalUp, new THREE.Vector3().crossVectors(finalRight, finalUp).normalize());
                    const euler = new THREE.Euler().setFromRotationMatrix(m);
                    
                    s.position.copy(finalPos); s.rotation.copy(euler); s.userData.curveU = u; s.userData.angle = angle;
                }
            });
        }

        // --- Segment Editor & 2D Map ---
        function openSegmentEditor() {
            if(trackNodes.length < 3) { alert("Need at least 3 nodes to edit segments."); return; }
            isSegmentMode = true; document.getElementById('properties-panel').style.display = 'none'; document.getElementById('segment-editor-panel').style.display = 'flex';
            document.getElementById('canvas-container').style.width = 'calc(100% - 450px)'; onResize();
            const ph = document.getElementById('seg-panel-title'); if(ph) ph.innerText = `Segment Editor: Node ${currentSegIdx} to ${(currentSegIdx+1)%trackNodes.length}`;
            
            segmentCamU = currentSegIdx / trackNodes.length; updateSegmentCamera(); renderMap();
            const md = document.getElementById('mode-display'); if(md) md.innerText = "Segment 2D Edit";
        }

        function closeSegmentEditor() {
            isSegmentMode = false; isSegmentTestPlaying = false; 
            const btn = document.getElementById('btn-seg-play'); if(btn) btn.innerText = "▶ Test Segment"; 
            document.getElementById('properties-panel').style.display = 'block'; document.getElementById('segment-editor-panel').style.display = 'none';
            document.getElementById('canvas-container').style.width = '100%'; onResize(); setTool('select'); viewCam('default');
        }

        function updateSegmentCamera() {
            if(!cachedCurve || !cachedFrames) return;
            const u = segmentCamU % 1.0; const pt = cachedCurve.getPointAt(u);
            const lookPt = cachedCurve.getPointAt((u + 0.01) % 1.0);
            
            const tParam = cachedCurve.getUtoTmapping(u);
            const segs = cachedFrames.tangents.length;
            const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
            const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi];
            const rDeg = getLoopCatmullRom(trackNodes.map(n=>n.userData.roll), tParam);
            const tDeg = getLoopCatmullRom(trackNodes.map(n=>n.userData.twist), tParam);
            const currentW = getLoopCatmullRom(trackNodes.map(n=>n.userData.width||36), tParam);
            
            const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
            const up = N.clone().applyQuaternion(qRoll);
            const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
            const twistUp = up.clone().applyQuaternion(qTwist);

            const roadCenter = pt.clone().addScaledVector(twistUp, -currentW);
            const eyePos = roadCenter.addScaledVector(twistUp, 4.0).addScaledVector(T, -20.0); // Slightly back to see row 0
            
            camera.position.copy(eyePos); camera.up.copy(twistUp); camera.lookAt(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.target.copy(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.update();

            // Highlight Active Row in Map
            const localU = (segmentCamU * trackNodes.length) % 1.0;
            const currentRow = Math.floor(localU * GRID_ROWS);
            document.querySelectorAll('.map-cell').forEach(c => c.classList.remove('active-row'));
            document.querySelectorAll(`.map-cell[data-row='${currentRow}']`).forEach(c => c.classList.add('active-row'));
        }

        function renderMap() {
            const grid = document.getElementById('map-grid-container'); if(!grid) return; grid.innerHTML = '';
            for (let r = GRID_ROWS - 1; r >= 0; r--) {
                for (let c = 0; c < GRID_COLS; c++) {
                    const cell = document.createElement('div'); cell.className = 'map-cell'; cell.dataset.row = r; cell.dataset.col = c;
                    
                    if (c >= 4 && c <= 11) cell.classList.add('cell-road'); else cell.classList.add('cell-wall');

                    const texItem = sceneryItems.find(s => s.userData.subType === 'texture' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
                    if (texItem) { cell.classList.add('cell-tex'); cell.innerText = "T"; }

                    const g = sceneryItems.find(s => s.userData.subType === 'gimmick' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
                    if (g) { 
                        let tClass = g.userData.gimmickType; let dispText = tClass.charAt(0).toUpperCase();
                        if(tClass.startsWith('block')) { const stack = parseInt(tClass.replace('block','')) || 1; tClass = 'block'; dispText = (stack > 1) ? `B${stack}` : 'B'; }
                        cell.classList.add(`cell-${tClass}`); cell.innerText = dispText; cell.style.color = '#fff'; 
                    }
                    cell.onclick = () => handleCellClick(r, c); 
                    cell.oncontextmenu = (e) => {
                        e.preventDefault();
                        const gIdx = sceneryItems.findIndex(s => s.userData.subType === 'gimmick' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
                        if (gIdx >= 0) { scene.remove(sceneryItems[gIdx]); sceneryItems.splice(gIdx, 1); }
                        const tIdx = sceneryItems.findIndex(s => s.userData.subType === 'texture' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
                        if (tIdx >= 0) { sceneryItems.splice(tIdx, 1); rebuildTrackCurve(); }
                        saveState(); renderMap();
                    };
                    grid.appendChild(cell);
                }
            }
        }

        function handleCellClick(row, col) {
            if (mapMode === 'gimmick') {
                const type = document.getElementById('seg-gimmick-type').value;
                const existingIdx = sceneryItems.findIndex(s => s.userData.subType === 'gimmick' && s.userData.segIdx === currentSegIdx && s.userData.row === row && s.userData.col === col);
                if (existingIdx >= 0) { scene.remove(sceneryItems[existingIdx]); sceneryItems.splice(existingIdx, 1); }
                if (type !== 'erase') addGimmickAtGrid(row, col, type);
            } else {
                const texKey = document.getElementById('seg-texture-type').value;
                const existingIdx = sceneryItems.findIndex(s => s.userData.subType === 'texture' && s.userData.segIdx === currentSegIdx && s.userData.row === row && s.userData.col === col);
                if (existingIdx >= 0) sceneryItems.splice(existingIdx, 1);
                if (texKey !== 'default_road' && texKey !== 'default_wall') {
                    sceneryItems.push({ userData: { type: 'scenery', subType: 'texture', texObj: {name: texKey}, segIdx: currentSegIdx, row: row, col: col }});
                }
                rebuildTrackCurve();
            }
            saveState(); renderMap();
        }

        function addGimmickAtGrid(row, colIdx, type) {
            if(!cachedCurve || !cachedFrames) return;
            const t = row / GRID_ROWS; const u = (currentSegIdx + t) / trackNodes.length; const tParam = cachedCurve.getUtoTmapping(u);
            const pt = cachedCurve.getPointAt(u), segs = cachedFrames.tangents.length, fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
            const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi], B = cachedFrames.binormals[fi];
            const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
            const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), currentW = getLoopCatmullRom(widths, tParam);
            const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg)), up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
            const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg)), twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
            
            const angle = (colIdx / 16) * Math.PI * 2 - Math.PI; 
            const localX = Math.sin(angle) * currentW, localY = -Math.cos(angle) * currentW;

            let finalPos, finalUp, finalRight;
            if (colIdx >= 4 && colIdx <= 11) { // Road
                finalPos = pt.clone().addScaledVector(twistRight, localX).addScaledVector(twistUp, localY);
                finalUp = new THREE.Vector3().addScaledVector(twistRight, -Math.sin(angle)).addScaledVector(twistUp, Math.cos(angle)).normalize();
                finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
            } else { // Wall
                finalPos = pt.clone().addScaledVector(right, localX).addScaledVector(up, localY);
                finalUp = new THREE.Vector3().addScaledVector(right, -Math.sin(angle)).addScaledVector(up, Math.cos(angle)).normalize();
                finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
            }
            
            let offset = gridSize/2; finalPos.addScaledVector(finalUp, offset);
            const m = new THREE.Matrix4(); m.makeBasis(finalRight, finalUp, new THREE.Vector3().crossVectors(finalRight, finalUp).normalize());
            const euler = new THREE.Euler().setFromRotationMatrix(m);
            
            const mesh = createGimmick(type, finalPos, new THREE.Vector3(1,1,1), 0); 
            mesh.rotation.copy(euler); mesh.userData.segIdx = currentSegIdx; mesh.userData.row = row; mesh.userData.col = colIdx; mesh.userData.curveU = u; mesh.userData.angle = angle;
        }

        // --- UI Binding (Safe) ---
        function safeBindClick(id, fn) { const el = document.getElementById(id); if(el) el.onclick = fn; }

        function onDoubleClick(e) {
            if(isSegmentMode) return;
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObject(trackSplineGroup, true);
            if (intersects.length > 0) {
                const hit = intersects[0];
                if (hit.uv) {
                    const u = hit.uv.y / (trackNodes.length * 4);
                    currentSegIdx = Math.floor(u * trackNodes.length) % trackNodes.length;
                    openSegmentEditor();
                }
            }
        }

        function getHitObjects() { const t = [...trackNodes]; sceneryItems.forEach(s => { if(s.userData.subType === 'bgmodel' && s.userData.hitBox) t.push(s.userData.hitBox); else t.push(s); }); return t; }
        function getActualItem(hitObj) { return (hitObj.parent && hitObj.parent.userData.type === 'scenery') ? hitObj.parent : hitObj; }

        function onPointerMove(e) {
            if(isSegmentMode) return;
            const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
            const isLocked = axisLock.x || axisLock.y || axisLock.z;
            
            if(isLapping && isDragging) {
                const dx = e.clientX - dragStartMouse.x, dy = e.clientY - dragStartMouse.y; const dQ = new THREE.Quaternion();
                if(!axisLock.y && !axisLock.z) dQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), -dx*0.005));
                if(axisLock.y || axisLock.z || !axisLock.x) dQ.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion), -dy*0.005));
                lapsTargetQ.premultiply(dQ); dragStartMouse.set(e.clientX, e.clientY); return;
            }

            if (isDragging && selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) {
                dragHasChanged = true; const deltaX = e.clientX - dragStartMouse.x, deltaY = e.clientY - dragStartMouse.y;
                if (currentTool === 'move') {
                    if (isLocked) {
                        const mag = (deltaX - deltaY) * 0.5; 
                        if(axisLock.x) selectedItem.position.x = Math.round((dragStartVal.x + mag)/gridSize)*gridSize; if(axisLock.y) selectedItem.position.y = Math.round((dragStartVal.y + mag)/gridSize)*gridSize; if(axisLock.z) selectedItem.position.z = Math.round((dragStartVal.z + mag)/gridSize)*gridSize;
                    } else { raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(planeGround); if(intersects.length > 0) { selectedItem.position.x = Math.round(intersects[0].point.x/gridSize)*gridSize; selectedItem.position.z = Math.round(intersects[0].point.z/gridSize)*gridSize; } }
                    if(selectedItem.userData.type === 'node') { rebuildTrackCurve(); updateGimmicksTransform(); }
                }
                else if (currentTool === 'rotate' && selectedItem.userData.type === 'scenery') selectedItem.rotation.y = Math.round((dragStartVal.y + deltaX * 0.02) / (Math.PI/4)) * (Math.PI/4);
                else if (currentTool === 'scale' && selectedItem.userData.type === 'scenery') {
                    const mag = -deltaY * 0.05; let sVec = dragStartVal.clone();
                    if(isLocked) { if(axisLock.x) sVec.x = Math.max(0.1, dragStartVal.x + mag); if(axisLock.y) sVec.y = Math.max(0.1, dragStartVal.y + mag); if(axisLock.z) sVec.z = Math.max(0.1, dragStartVal.z + mag); }
                    else { const s = Math.max(0.1, dragStartVal.x + mag); sVec.set(s,s,s); }
                    selectedItem.scale.copy(sVec);
                }
                updateSelectionBox(); updateUIFromSelection(); return;
            }

            if (isDragging && !selectedItem && (currentTool === 'add' || currentTool === 'paint') && isLocked) {
                const mag = ((e.clientX - dragStartMouse.x) - (e.clientY - dragStartMouse.y)) * 0.5;
                if(axisLock.x) cursorTarget.position.x = Math.round((startCursorPos.x + mag)/gridSize)*gridSize; if(axisLock.y) cursorTarget.position.y = Math.round((startCursorPos.y + mag)/gridSize)*gridSize; if(axisLock.z) cursorTarget.position.z = Math.round((startCursorPos.z + mag)/gridSize)*gridSize;
                return;
            }

            if (!isDragging) {
                raycaster.setFromCamera(mouse, camera);
                if (currentTool === 'add') {
                    const intersects = raycaster.intersectObject(planeGround);
                    if (intersects.length > 0) cursorTarget.position.set(Math.round(intersects[0].point.x/gridSize)*gridSize, baseHeight, Math.round(intersects[0].point.z/gridSize)*gridSize);
                } else {
                    const intersects = raycaster.intersectObjects(getHitObjects());
                    if (intersects.length > 0) {
                        const hit = getActualItem(intersects[0].object); cursorPoint.position.copy(hit.position);
                        if(hit.userData.type === 'node') cursorPoint.scale.set(1.5,1.5,1.5);
                        else { const box = new THREE.Box3().setFromObject(hit); const size = box.getSize(new THREE.Vector3()); cursorPoint.scale.set(size.x/10, size.y/10, size.z/10); }
                        cursorPoint.visible = true;
                    } else {
                        const pIntersects = raycaster.intersectObject(planeGround);
                        if (pIntersects.length > 0) { cursorPoint.scale.set(1,1,1); cursorPoint.position.set(Math.round(pIntersects[0].point.x/gridSize)*gridSize, baseHeight, Math.round(pIntersects[0].point.z/gridSize)*gridSize); cursorPoint.visible = true; }
                    }
                }
            }
        }

        function onPointerDown(e) {
            if (e.button !== 0 || isSegmentMode) return;
            if(isLapping) { isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = false; return; }
            dragStartCamPos.copy(camera.position); dragStartCamTarget.copy(controls.target); dragStartPolar = controls.getPolarAngle(); dragStartAzimuth = controls.getAzimuthalAngle();
            if (currentTool.startsWith('cam-')) { isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = true; return; }

            const isLocked = axisLock.x || axisLock.y || axisLock.z;
            if (isLocked) {
                isDragging = true; dragHasChanged = false; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = false; 
                if(selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) { if (currentTool === 'move') dragStartVal.copy(selectedItem.position); if (currentTool === 'rotate') dragStartVal.set(0, selectedItem.rotation.y, 0); if (currentTool === 'scale') dragStartVal.copy(selectedItem.scale); } else startCursorPos.copy(cursorTarget.position);
                return; 
            }

            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(getHitObjects());
            const hitItem = intersects.length > 0 ? getActualItem(intersects[0].object) : null;

            if (currentTool === 'select') selectItem(hitItem);
            else if (currentTool === 'add') {
                if(addItemType === 'node') {
                    if(trackNodes.length >= 3) {
                        const interp = getSplineInterpolation(cursorTarget.position);
                        if(interp) { 
                            let mesh = createNode(interp.position, interp.roll, interp.twist, interp.width); 
                            trackNodes.pop(); trackNodes.splice(interp.insertIdx, 0, mesh); 
                            autoAdjustNodeAngles(mesh);
                            rebuildTrackCurve(); updateGimmicksTransform(); 
                        } 
                        else { createNode(cursorTarget.position.clone(), 0, 0, 36); rebuildTrackCurve(); updateGimmicksTransform(); }
                    } else { createNode(cursorTarget.position.clone(), 0, 0, 36); rebuildTrackCurve(); updateGimmicksTransform(); }
                    saveState();
                }
                else if(addItemType === 'scenery') {
                    createSceneryBlock(cursorTarget.position.clone(), new THREE.Vector3(1,1,1), 0xaaaaaa, 0, currentBrushTex);
                    saveState();
                }
                else if(addItemType === 'bgmodel') { const sel = document.getElementById('bgmodel-list').value; if(sel && ASSETS.bgModels[sel]) { createBgModelInstance(ASSETS.bgModels[sel].fileObj, cursorTarget.position.clone(), new THREE.Vector3(1,1,1), 0); saveState(); } else alert("Select a model from the list first."); }
            }
            else if (currentTool === 'erase') { if(hitItem) { deleteItem(hitItem); saveState(); } }
            else if (['move', 'rotate', 'scale'].includes(currentTool)) { if (hitItem) { selectItem(hitItem); isDragging = true; dragHasChanged = false; controls.enabled = false; dragStartMouse.set(e.clientX, e.clientY); if(currentTool==='move') dragStartVal.copy(hitItem.position); if(currentTool==='rotate') dragStartVal.set(0, hitItem.rotation.y, 0); if(currentTool==='scale') dragStartVal.copy(hitItem.scale); } else selectItem(null); }
        }

        function onPointerUp() { 
            isDragging = false; controls.enabled = true; 
            if(dragHasChanged) { 
                if (selectedItem && selectedItem.userData.type === 'node' && ['move'].includes(currentTool)) {
                    autoAdjustNodeAngles(selectedItem);
                    rebuildTrackCurve();
                    updateGimmicksTransform();
                    updateUIFromSelection();
                }
                saveState(); dragHasChanged = false; 
            } 
        }

        function onKeyDown(e) {
            if (e.target.tagName === 'INPUT' || isSegmentMode) return;
            if(e.key.toLowerCase() === 'x') axisLock.x = true; if(e.key.toLowerCase() === 'y') axisLock.y = true; if(e.key.toLowerCase() === 'z') axisLock.z = true;
            if(e.key.toLowerCase() === 'z' && e.ctrlKey) { e.preventDefault(); if(historyIndex > 0) restoreState(--historyIndex); }
            if(e.key.toLowerCase() === 'y' && e.ctrlKey) { e.preventDefault(); if(historyIndex < historyStack.length - 1) restoreState(++historyIndex); }
            if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
            if (e.key === 'PageUp') { e.preventDefault(); camera.zoom = Math.min(camera.zoom + 0.1, 5); camera.updateProjectionMatrix(); }
            if (e.key === 'PageDown') { e.preventDefault(); camera.zoom = Math.max(camera.zoom - 0.1, 0.1); camera.updateProjectionMatrix(); }
            if (e.key.toLowerCase() === 'c' && e.ctrlKey && selectedItem) {
                clipboard = { type: selectedItem.userData.type, subType: selectedItem.userData.subType, roll: selectedItem.userData.roll, twist: selectedItem.userData.twist, width: selectedItem.userData.width, sx: selectedItem.scale.x, sy: selectedItem.scale.y, sz: selectedItem.scale.z, ry: selectedItem.rotation.y };
                if(selectedItem.userData.subType === 'block') { clipboard.color = selectedItem.material.color.getHex(); clipboard.texObj = selectedItem.userData.texObj; }
                if(selectedItem.userData.subType === 'bgmodel') clipboard.fileObj = selectedItem.userData.fileObj;
                if(selectedItem.userData.subType === 'gimmick') clipboard.gimmickType = selectedItem.userData.gimmickType;
            }
            if (e.key.toLowerCase() === 'v' && e.ctrlKey && clipboard && cursorPoint.visible) {
                if(clipboard.type === 'node') {
                    if(trackNodes.length >= 3) {
                        const interp = getSplineInterpolation(cursorPoint.position);
                        if(interp) { 
                            let mesh = createNode(interp.position, clipboard.roll, clipboard.twist, clipboard.width); 
                            trackNodes.pop(); trackNodes.splice(interp.insertIdx, 0, mesh); 
                            autoAdjustNodeAngles(mesh);
                            rebuildTrackCurve(); updateGimmicksTransform(); 
                        } 
                        else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist, clipboard.width); rebuildTrackCurve(); updateGimmicksTransform(); }
                    } else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist, clipboard.width); rebuildTrackCurve(); updateGimmicksTransform(); }
                }
                else if(clipboard.subType === 'bgmodel') createBgModelInstance(clipboard.fileObj, cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.ry);
                saveState();
            }
            if (e.key === 'ArrowUp') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y += gridSize; if(selectedItem.userData.type === 'node') { rebuildTrackCurve(); updateGimmicksTransform(); } updateUIFromSelection(); updateSelectionBox(); saveState(); } else changeBaseHeight(10); }
            if (e.key === 'ArrowDown') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y -= gridSize; if(selectedItem.userData.type === 'node') { rebuildTrackCurve(); updateGimmicksTransform(); } updateUIFromSelection(); updateSelectionBox(); saveState(); } else changeBaseHeight(-10); }
            if (e.key === 'Delete' && selectedItem) { deleteItem(selectedItem); saveState(); }
        }
        function onKeyUp(e) { if(e.key.toLowerCase() === 'x') axisLock.x = false; if(e.key.toLowerCase() === 'y') axisLock.y = false; if(e.key.toLowerCase() === 'z') axisLock.z = false; }

        function changeBaseHeight(dir) { baseHeight += dir; const el=document.getElementById('height-display'); if(el) el.textContent = `Height: ${baseHeight}`; const rect = renderer.domElement.getBoundingClientRect(); onPointerMove({ clientX: rect.left + (mouse.x+1)/2*rect.width, clientY: rect.top - (mouse.y-1)/2*rect.height }); }
        
        function deleteItem(mesh) { 
            if (mesh.userData.type === 'node' && trackNodes.length <= 3) {
                alert("Cannot delete the first 3 nodes. Minimum 3 nodes required.");
                return;
            }
            scene.remove(mesh); 
            if(mesh.userData.type === 'node') { 
                trackNodes = trackNodes.filter(v => v !== mesh); 
                rebuildTrackCurve(); updateGimmicksTransform(); 
            } else { 
                sceneryItems = sceneryItems.filter(v => v !== mesh); 
            } 
            if (selectedItem === mesh) selectItem(null); 
        }

        function selectItem(mesh) { selectedItem = mesh; if (mesh) { if(!['add','paint'].includes(currentTool)) cursorSelect.visible = true; updateSelectionBox(); updateUIFromSelection(); } else { cursorSelect.visible = false; if (!['add','paint'].includes(currentTool)) { const pc=document.getElementById('prop-content'); if(pc) pc.style.display = 'none'; const pe=document.getElementById('prop-empty'); if(pe) pe.style.display = 'block'; } } }
        function updateSelectionBox() { if(!selectedItem) return; cursorSelect.position.copy(selectedItem.position); cursorSelect.rotation.copy(selectedItem.rotation); if(selectedItem.userData.type === 'node') cursorSelect.scale.set(1.5,1.5,1.5); else { const box = new THREE.Box3().setFromObject(selectedItem); const size = box.getSize(new THREE.Vector3()); cursorSelect.scale.set(size.x/10 * 1.05, size.y/10 * 1.05, size.z/10 * 1.05); } }

        function updateGuides() {
            shadowPool.forEach(s => s.visible = false);
            const limX = (camera.position.x > 0) ? -1000 : 1000, limZ = (camera.position.z > 0) ? -1000 : 1000;
            const placeShadow = (idxStart, pos3d, scale) => { const s1=shadowPool[idxStart], s2=shadowPool[idxStart+1]; s1.visible=true; s1.position.set(pos3d.x, pos3d.y, limZ); s1.scale.set(scale.x*10, scale.y*10, 1); s2.visible=true; s2.position.set(limX, pos3d.y, pos3d.z); s2.scale.set(1, scale.y*10, scale.z*10); s2.rotation.y = Math.PI/2; };
            if(!isSegmentMode) {
                if(['add','paint'].includes(currentTool) && cursorTarget.visible) placeShadow(0, cursorTarget.position, cursorTarget.scale);
                if(cursorPoint.visible) placeShadow(2, cursorPoint.position, cursorPoint.scale);
                if(selectedItem && cursorSelect.visible) placeShadow(4, selectedItem.position, cursorSelect.scale);
            }
            walls.xNeg.visible = (camera.position.x > 0); walls.xPos.visible = (camera.position.x < 0); walls.zNeg.visible = (camera.position.z > 0); walls.zPos.visible = (camera.position.z < 0);
        }

        function updateLabels() {
            labelsContainer.innerHTML = '';
            if(!isSegmentMode && (axisLock.x || axisLock.y || axisLock.z)) {
                const active = selectedItem ? selectedItem.position : (cursorPoint.visible ? cursorPoint.position : cursorTarget.position);
                const addL = (pos, txt, col) => { const d = document.createElement('div'); d.className = 'axis-label'; d.textContent = txt; d.style.backgroundColor = col; const vec = pos.clone().project(camera); if(vec.z<=1) { d.style.left = (vec.x*0.5+0.5)*renderer.domElement.clientWidth+'px'; d.style.top = -(vec.y*0.5-0.5)*renderer.domElement.clientHeight+'px'; labelsContainer.appendChild(d); } };
                if(axisLock.x) { addL(active.clone().add(new THREE.Vector3(20,0,0)), "+X", "#aa0000"); addL(active.clone().add(new THREE.Vector3(-20,0,0)), "-X", "#aa0000"); }
                if(axisLock.y) { addL(active.clone().add(new THREE.Vector3(0,20,0)), "+Y", "#00aa00"); addL(active.clone().add(new THREE.Vector3(0,-20,0)), "-Y", "#00aa00"); }
                if(axisLock.z) { addL(active.clone().add(new THREE.Vector3(0,0,20)), "+Z", "#0044aa"); addL(active.clone().add(new THREE.Vector3(0,0,-20)), "-Z", "#0044aa"); }
            }
        }

        function animate() {
            requestAnimationFrame(animate); 
            if (isTestPlaying && trackNodes.length >= 3 && cachedCurve) {
                testPlayT += 0.003; if (testPlayT > 1) testPlayT -= 1;
                const pt = cachedCurve.getPointAt(testPlayT);
                
                const tParam = cachedCurve.getUtoTmapping(testPlayT), segs = cachedFrames.tangents.length;
                const fi = Math.min(segs - 1, Math.max(0, Math.floor(testPlayT * (segs - 1))));
                const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi];
                const tDeg = getLoopCatmullRom(trackNodes.map(n=>n.userData.twist), tParam);
                const currentW = getLoopCatmullRom(trackNodes.map(n=>n.userData.width||36), tParam);
                
                const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
                const twistUp = N.clone().applyQuaternion(qTwist);

                const roadCenter = pt.clone().addScaledVector(twistUp, -currentW);
                const eyePos = roadCenter.addScaledVector(twistUp, 4.0).addScaledVector(T, 2.0);
                
                camera.position.copy(eyePos); camera.up.copy(twistUp); camera.lookAt(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.target.copy(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.update();
            } else if (isSegmentMode && isSegmentTestPlaying && cachedCurve) {
                segmentCamU += 0.002; const startU = currentSegIdx / trackNodes.length, endU = (currentSegIdx + 1) / trackNodes.length;
                if (segmentCamU >= endU) segmentCamU = startU; updateSegmentCamera();
            } else if(isLapping) { 
                lapsQ.slerp(lapsTargetQ, 0.1); const v = new THREE.Vector3(0, 0, lapsRadius).applyQuaternion(lapsQ); camera.position.copy(v.add(lapsCenter)); camera.lookAt(lapsCenter); controls.target.copy(lapsCenter); controls.update(); 
            } else {
                if(currentTool === 'cam-rotate') { controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI; controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity; if(isDragging) { if(axisLock.x) controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar; else if(axisLock.y||axisLock.z) controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth; } }
                else if(currentTool === 'cam-move' && isDragging) { if(axisLock.x) { camera.position.y=dragStartCamPos.y; camera.position.z=dragStartCamPos.z; controls.target.y=dragStartCamTarget.y; controls.target.z=dragStartCamTarget.z; } if(axisLock.y) { camera.position.x=dragStartCamPos.x; camera.position.z=dragStartCamPos.z; controls.target.x=dragStartCamTarget.x; controls.target.z=dragStartCamTarget.z; } if(axisLock.z) { camera.position.x=dragStartCamPos.x; camera.position.y=dragStartCamPos.y; controls.target.x=dragStartCamTarget.x; controls.target.y=dragStartCamTarget.y; } }
                controls.update();
            }
            if(cursorTarget) cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(Date.now() * 0.005 * 8);
            updateGuides(); updateLabels(); renderer.render(scene, camera);
        }

        window.setTool = (tool) => {
            currentTool = tool; const names = { 'select': 'Select', 'add': 'Add', 'erase': 'Erase', 'paint': 'Paint', 'move': 'Move', 'rotate': 'Rotate', 'scale': 'Scale', 'cam-move':'Camera Move', 'cam-rotate':'Camera Rotate', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps' };
            const modeDisplay = document.getElementById('mode-display');
            if(names[tool] && modeDisplay) modeDisplay.innerText = names[tool];
            
            if(tool === 'add' || tool === 'paint') {
                cursorTarget.visible = (tool==='add'); cursorPoint.visible = false; cursorSelect.visible = false;
                const ph = document.getElementById('prop-header'); if(ph) ph.innerText = tool==='add'?"Add Settings":"Paint Brush"; 
                const pa = document.getElementById('prop-add-settings'); if(pa) pa.style.display = 'block'; 
                const pc = document.getElementById('prop-content'); if(pc) pc.style.display = 'none'; 
                const pe = document.getElementById('prop-empty'); if(pe) pe.style.display = 'none';
            } else {
                cursorTarget.visible = false; cursorPoint.visible = true; if(selectedItem) cursorSelect.visible = true;
                const ph = document.getElementById('prop-header'); if(ph) ph.innerText = "Properties"; 
                const pa = document.getElementById('prop-add-settings'); if(pa) pa.style.display = 'none';
                if(selectedItem) updateUIFromSelection(); else { 
                    const pc = document.getElementById('prop-content'); if(pc) pc.style.display = 'none'; 
                    const pe = document.getElementById('prop-empty'); if(pe) pe.style.display = 'block'; 
                }
            }
            isLapping = (tool === 'cam-laps');
            if(isLapping) { const act = selectedItem ? selectedItem.position : (cursorPoint.visible ? cursorPoint.position : cursorTarget.position); lapsCenter.copy(act); const rel = camera.position.clone().sub(lapsCenter); lapsRadius = rel.length(); rel.normalize(); lapsQ.setFromUnitVectors(new THREE.Vector3(0,0,1), rel); lapsTargetQ.copy(lapsQ); controls.enabled = false; }
            else { controls.enabled = !isTestPlaying && !isSegmentMode; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; if (tool === 'cam-move') controls.mouseButtons.LEFT = THREE.MOUSE.PAN; else if (tool === 'cam-rotate') controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; else if (tool === 'cam-zoom') { controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY; controls.zoomSpeed = 3.0; } else controls.zoomSpeed = 1.0; }
        };

        window.viewCam = (dir) => {
            if(isSegmentMode) closeSegmentEditor();
            const dist = 300; camera.up.set(0, 1, 0); if(isLapping){ isLapping = false; setTool('select'); } if(isTestPlaying) { isTestPlaying = false; const btn = document.getElementById('tool-test-play'); if(btn) btn.innerText = "Start Full Test Play"; controls.enabled = true; }
            switch(dir) { case 'default': camera.position.set(dist, dist, dist); break; case 'front': camera.position.set(0, 0, dist); break; case 'top': camera.position.set(0, dist, 0); break; }
            camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update();
        };

        function setupUI() {
            safeBindClick('btn-seg-back', closeSegmentEditor);
            safeBindClick('btn-seg-cam-fwd', () => { segmentCamU += 1 / (trackNodes.length * 20); updateSegmentCamera(); });
            safeBindClick('btn-seg-cam-back', () => { segmentCamU -= 1 / (trackNodes.length * 20); updateSegmentCamera(); });
            safeBindClick('btn-seg-play', () => { isSegmentTestPlaying = !isSegmentTestPlaying; const b=document.getElementById('btn-seg-play'); if(b) b.innerText = isSegmentTestPlaying ? "■ Stop" : "▶ Test Segment"; });
            
            safeBindClick('btn-mode-gimmick', () => { mapMode = 'gimmick'; document.getElementById('seg-gimmick-options').style.display='block'; document.getElementById('seg-texture-options').style.display='none'; });
            safeBindClick('btn-mode-texture', () => { mapMode = 'texture'; document.getElementById('seg-gimmick-options').style.display='none'; document.getElementById('seg-texture-options').style.display='block'; refreshTextureSelect(); });

            safeBindClick('tool-undo', () => { if(historyIndex > 0) restoreState(--historyIndex); });
            safeBindClick('tool-redo', () => { if(historyIndex < historyStack.length - 1) restoreState(++historyIndex); });
            safeBindClick('tool-test-play', () => { isTestPlaying = !isTestPlaying; const el = document.getElementById('tool-test-play'); if(el) el.innerText = isTestPlaying ? "Stop Test Play" : "Start Full Test Play"; if(!isTestPlaying) viewCam('default'); else { controls.enabled = false; testPlayT = 0; } });
            
            ['select', 'add', 'paint', 'erase', 'move', 'rotate', 'scale', 'cam-move', 'cam-rotate', 'cam-zoom', 'cam-laps'].forEach(t => safeBindClick(`tool-${t}`, () => setTool(t)));
            
            const addItemTypeEl = document.getElementById('add-item-type');
            if(addItemTypeEl) addItemTypeEl.addEventListener('change', (e) => { 
                addItemType = e.target.value; 
                const o1 = document.getElementById('add-scenery-options'); if(o1) o1.style.display = addItemType === 'scenery' ? 'block' : 'none'; 
                const o2 = document.getElementById('add-bgmodel-options'); if(o2) o2.style.display = addItemType === 'bgmodel' ? 'block' : 'none'; 
            });

            const bindProp = (id, objKey, axis, isDeg) => {
                const el = document.getElementById(id);
                if(el) el.addEventListener('change', (e) => {
                    if(!selectedItem) return; let val = parseFloat(e.target.value); if(isDeg) val = THREE.MathUtils.degToRad(val);
                    if(objKey === 'roll' || objKey === 'twist' || objKey === 'width') { selectedItem.userData[objKey] = val; rebuildTrackCurve(); updateGimmicksTransform(); }
                    else if(axis) { selectedItem[objKey][axis] = val; if(objKey === 'position' && selectedItem.userData.type === 'node') { autoAdjustNodeAngles(selectedItem); rebuildTrackCurve(); updateGimmicksTransform(); } }
                    else if(objKey === 'color' && selectedItem.userData.subType === 'block') { selectedItem.material.color.set(e.target.value); if(selectedItem.material.map) selectedItem.material.color.setHex(0xffffff); }
                    updateSelectionBox(); saveState();
                });
            };
            bindProp('prop-x', 'position', 'x'); bindProp('prop-y', 'position', 'y'); bindProp('prop-z', 'position', 'z');
            bindProp('prop-sx', 'scale', 'x'); bindProp('prop-sy', 'scale', 'y'); bindProp('prop-sz', 'scale', 'z');
            bindProp('prop-ry', 'rotation', 'y', true); bindProp('prop-roll', 'roll', null, false); bindProp('prop-twist', 'twist', null, false);
            bindProp('prop-width', 'width', null, false); bindProp('prop-color', 'color');

            safeBindClick('btn-smooth-node', () => {
                if(!selectedItem || selectedItem.userData.type !== 'node') return;
                smoothNode(selectedItem);
                updateUIFromSelection(); updateSelectionBox(); rebuildTrackCurve(); updateGimmicksTransform(); saveState();
            });

            safeBindClick('btn-select-brush-tex', () => selectFile('.png,.jpg', 'BgBlockImage/', f => { currentBrushTex = f; const b=document.getElementById('brush-tex-name'); if(b) b.value = f.name; }));
            safeBindClick('btn-clear-brush-tex', () => { currentBrushTex = null; const b=document.getElementById('brush-tex-name'); if(b) b.value = ""; });
            safeBindClick('btn-prop-select-tex', () => selectFile('.png,.jpg', 'BgBlockImage/', f => { if(selectedItem && selectedItem.userData.subType === 'block') { getTex(f, t=>{ selectedItem.material.map=t; selectedItem.material.color.setHex(0xffffff); selectedItem.material.needsUpdate=true; }); selectedItem.userData.texObj = f; const p=document.getElementById('prop-tex-name'); if(p) p.value = f.name; saveState(); } }));
            safeBindClick('btn-prop-clear-tex', () => { if(selectedItem && selectedItem.userData.subType === 'block') { selectedItem.material.map = null; selectedItem.userData.texObj = null; const c=document.getElementById('prop-color'); if(c) selectedItem.material.color.set(c.value); selectedItem.material.needsUpdate = true; const p=document.getElementById('prop-tex-name'); if(p) p.value = ""; saveState(); } });

            safeBindClick('btn-load-model', () => selectFile('.glb,.obj', 'BgModel/', f => { const sb=document.getElementById('status-bar'); if(sb) sb.innerText = `Loading ${f.name}...`; const onload = obj => { ASSETS.bgModels[f.name] = { fileObj: f, object: obj }; const opt = document.createElement('option'); opt.value = f.name; opt.innerText = f.name; const bl=document.getElementById('bgmodel-list'); if(bl) bl.appendChild(opt); if(sb) sb.innerText = `Loaded ${f.name}`; }; if(f.name.endsWith('.obj')) objLoader.load(f.url, onload); else gltfLoader.load(f.url, g=>onload(g.scene)); }));
            
            safeBindClick('menu-env-settings', () => { const r=document.getElementById('env-road'); if(r) r.value = ASSETS.env.road ? ASSETS.env.road.name : ""; const w=document.getElementById('env-wall'); if(w) w.value = ASSETS.env.wall ? ASSETS.env.wall.name : ""; const s=document.getElementById('env-sky'); if(s) s.value = ASSETS.env.sky ? ASSETS.env.sky.name : ""; const g=document.getElementById('env-ground'); if(g) g.value = ASSETS.env.ground ? ASSETS.env.ground.name : ""; const m=document.getElementById('modal-overlay'); if(m) m.style.display = 'flex'; const e=document.getElementById('env-dialog'); if(e) e.style.display = 'block'; });
            safeBindClick('menu-terrain', () => { const m=document.getElementById('modal-overlay'); if(m) m.style.display = 'flex'; const t=document.getElementById('terr-dialog'); if(t) t.style.display = 'block'; });

            safeBindClick('menu-new', () => { 
                if(confirm("Clear all?")) { 
                    trackNodes.forEach(n=>scene.remove(n)); sceneryItems.forEach(s=>{ if(s.type!=='texture') scene.remove(s); }); 
                    trackNodes=[]; sceneryItems=[]; 
                    createNode(new THREE.Vector3(0, 50, 0), 0, 0, 36); 
                    createNode(new THREE.Vector3(0, 50, -300), 0, 0, 36); 
                    createNode(new THREE.Vector3(300, 50, -150), 45, -15, 36);
                    rebuildTrackCurve(); selectItem(null); historyStack = []; historyIndex = -1; saveState(); 
                    if(isSegmentMode) closeSegmentEditor();
                } 
            });
            
            safeBindClick('menu-save', () => {
                const data = {
                    assets: { env: { road: ASSETS.env.road?.name, wall: ASSETS.env.wall?.name, sky: ASSETS.env.sky?.name, ground: ASSETS.env.ground?.name }, bgModels: Object.keys(ASSETS.bgModels), envTextures: Object.keys(ASSETS.envTextures) },
                    track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist, width: n.userData.width||36 })),
                    scenery: sceneryItems.map(s => { const base = { type: s.userData.subType, x: s.position.x, y: s.position.y, z: s.position.z, sx: s.scale.x, sy: s.scale.y, sz: s.scale.z, ry: s.rotation.y, segIdx: s.userData.segIdx, row: s.userData.row, col: s.userData.col, curveU: s.userData.curveU, angle: s.userData.angle }; if(s.userData.subType === 'block') { base.color = s.material.color.getHex(); base.texture = s.userData.texObj?.name; } if(s.userData.subType === 'bgmodel') base.modelName = s.userData.fileObj?.name; if(s.userData.subType === 'gimmick') { base.gimmickType = s.userData.gimmickType; } if(s.userData.subType === 'texture') { base.texturePath = s.userData.texObj?.name; } return base; })
                };
                const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data)], {type:'application/json'})); link.download = 'course.json'; link.click();
            });
            safeBindClick('menu-open', () => { const f=document.getElementById('file-input-json'); if(f) f.click(); });
            const fileInput = document.getElementById('file-input-json');
            if(fileInput) fileInput.onchange = (e) => { const f = e.target.files[0]; if(!f) return; const r = new FileReader(); r.onload = ev => loadCourseData(JSON.parse(ev.target.result)); r.readAsText(f); };
        }

        function loadCourseData(data) {
            trackNodes.forEach(n => scene.remove(n)); sceneryItems.forEach(s => { if(s.type!=='texture') scene.remove(s); }); trackNodes = []; sceneryItems = [];
            
            if(data.assets) {
                ASSETS.env = { road: data.assets.env?.road ? { name: data.assets.env.road, url: data.assets.env.road } : null, wall: data.assets.env?.wall ? { name: data.assets.env.wall, url: data.assets.env.wall } : null, sky: data.assets.env?.sky ? { name: data.assets.env.sky, url: data.assets.env.sky } : null, ground: data.assets.env?.ground ? { name: data.assets.env.ground, url: data.assets.env.ground } : null };
                ASSETS.bgModels = {};
                if(data.assets.bgModels) { data.assets.bgModels.forEach(path => { ASSETS.bgModels[path] = { fileObj: { name: path, url: path }, object: null }; const onload = obj => { ASSETS.bgModels[path].object = obj; const opt = document.createElement('option'); opt.value = path; opt.innerText = path; const bl=document.getElementById('bgmodel-list'); if(bl) bl.appendChild(opt); }; if(path.endsWith('.obj')) objLoader.load(path, onload); else gltfLoader.load(path, g=>onload(g.scene)); }); }
                ASSETS.envTextures = {}; if(data.assets.envTextures) data.assets.envTextures.forEach(path => { getTex({name: path, url: path}); });
                applyEnvironment();
            }

            if(data.track) data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll, n.twist, n.width));
            
            const finalizeLoad = () => {
                rebuildTrackCurve();
                updateGimmicksTransform();
                if(isSegmentMode) closeSegmentEditor();
                selectItem(null); 
                const sb=document.getElementById('status-bar'); if(sb) sb.innerText = "Course JSON Loaded.";
                historyStack = []; historyIndex = -1; saveState(); 
            };

            if(data.scenery && data.scenery.length > 0) {
                setTimeout(() => {
                    data.scenery.forEach(s => {
                        const pos = new THREE.Vector3(s.x, s.y, s.z), scl = new THREE.Vector3(s.sx, s.sy, s.sz); let mesh;
                        if(s.type === 'block') mesh = createSceneryBlock(pos, scl, s.color, s.ry, s.texture ? {name: s.texture, url: s.texture} : null);
                        else if(s.type === 'bgmodel' && ASSETS.bgModels[s.modelName]) mesh = createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
                        else if(s.type === 'gimmick') mesh = createGimmick(s.gimmickType, pos, scl, s.ry);
                        else if(s.type === 'texture') { sceneryItems.push({ userData: { type: 'scenery', subType: 'texture', texObj: {name: s.texturePath}, segIdx: s.segIdx, row: s.row, col: s.col }}); }
                        if(mesh) { mesh.userData.segIdx = s.segIdx; mesh.userData.row = s.row; mesh.userData.col = s.col; mesh.userData.curveU = s.curveU; mesh.userData.angle = s.angle; }
                    });
                    finalizeLoad();
                }, 500); 
            } else {
                finalizeLoad();
            }
        }

        function updateUIFromSelection() {
            if(!selectedItem || ['add','paint'].includes(currentTool)) return;
            const pc = document.getElementById('prop-content'), pe = document.getElementById('prop-empty');
            if(pc) pc.style.display = 'block'; if(pe) pe.style.display = 'none';
            const isNode = selectedItem.userData.type === 'node';
            
            const setVal = (id, val) => { const el = document.getElementById(id); if(el) el.value = val; };
            setVal('prop-x', selectedItem.position.x); setVal('prop-y', selectedItem.position.y); setVal('prop-z', selectedItem.position.z);
            
            const pNode = document.getElementById('prop-node-group'), pScen = document.getElementById('prop-scenery-group');
            if(pNode) pNode.style.display = isNode ? 'block' : 'none'; 
            if(pScen) pScen.style.display = isNode ? 'none' : 'block';

            if(isNode) { setVal('prop-roll', selectedItem.userData.roll); setVal('prop-twist', selectedItem.userData.twist || 0); setVal('prop-width', selectedItem.userData.width || 36); }
            else {
                setVal('prop-sx', selectedItem.scale.x); setVal('prop-sy', selectedItem.scale.y); setVal('prop-sz', selectedItem.scale.z); setVal('prop-ry', Math.round(THREE.MathUtils.radToDeg(selectedItem.rotation.y)));
                const cGrp = document.getElementById('prop-color-group');
                if(selectedItem.userData.subType === 'block') { if(cGrp) cGrp.style.display = 'block'; setVal('prop-color', '#' + selectedItem.material.color.getHexString()); setVal('prop-tex-name', selectedItem.userData.texObj ? selectedItem.userData.texObj.name : ""); } 
                else { if(cGrp) cGrp.style.display = 'none'; }
            }
        }
        init();
    </script>
</body>
</html>

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

コメント

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