ドライブゲーム「HyperDrive」

【更新履歴】

 ・2026/6/19 バージョン1.0公開。
 ・2026/6/19 バージョン1.1公開。
 ・2026/6/26 バージョン1.2公開。(F12キーで設定画面表示)


画像
トンネルの内側を走行


画像
フラットコースを走行中


画像
トンネルコースの外を走行中
画像
マルチプレイモード(2人で対戦)


画像
ゲーム設定画面


【概要】

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

【操作方法】(追加機能)

F12キーを押すと、設定を変更できます。(v1.2以降)


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

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

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

        /* ===== multiplayer split-screen overlay ===== */
        #mp-layer{ position:absolute; inset:0; pointer-events:none; z-index:11; display:none; }
        #mp-divider{ position:absolute; top:0; bottom:0; left:50%; width:2px; transform:translateX(-1px); background:linear-gradient(180deg,rgba(43,211,255,.0),rgba(43,211,255,.9),rgba(255,42,109,.9),rgba(255,42,109,.0)); box-shadow:0 0 12px #2bd3ff; }
        .mp-hud{ position:absolute; bottom:18px; width:46%; font-family:'Arial Black',sans-serif; color:#fff; }
        #mp-hud-1{ left:2%; text-align:left; }
        #mp-hud-2{ right:2%; text-align:right; }
        .mp-tag{ font-size:14px; letter-spacing:2px; margin-bottom:4px; }
        #mp-hud-1 .mp-tag{ color:var(--neon-cyan); }
        #mp-hud-2 .mp-tag{ color:var(--neon-mag); }
        .mp-speed{ font-size:30px; line-height:1; }
        .mp-score{ font-size:26px; font-weight:900; letter-spacing:1px; margin-bottom:2px; }
        .mp-score span{ font-size:34px; }
        #mp-hud-1 .mp-score{ color:#bfefff; } #mp-hud-1 .mp-score span{ color:var(--neon-cyan); text-shadow:0 0 14px var(--neon-cyan); }
        #mp-hud-2 .mp-score{ color:#ffc6da; } #mp-hud-2 .mp-score span{ color:var(--neon-mag); text-shadow:0 0 14px var(--neon-mag); }
        .mp-speed small{ font-size:13px; opacity:.7; margin-left:4px; }
        .mp-bars{ margin-top:6px; }
        .mp-bar-bg{ height:9px; background:rgba(255,255,255,.12); border-radius:5px; overflow:hidden; margin:3px 0; }
        .mp-bar-fill{ height:100%; width:0%; border-radius:5px; transition:width .08s linear; }
        .mp-boost{ background:linear-gradient(90deg,#ff9e2a,#ffe14d); }
        .mp-boost.overheat{ background:linear-gradient(90deg,#ff3b3b,#ff7b00); }
        .mp-prog-1{ background:linear-gradient(90deg,#0b8fe0,#2bd3ff); }
        .mp-prog-2{ background:linear-gradient(90deg,#c01250,#ff2a6d); }
        #mp-result{ position:absolute; top:0; left:0; right:0; bottom:0; display:none; align-items:center; justify-content:center; flex-direction:column; text-align:center; pointer-events:auto; z-index:12; background:rgba(4,6,16,.55); }
        #mp-count{ position:absolute; top:42%; left:50%; transform:translate(-50%,-50%); font-family:'Arial Black',sans-serif; font-size:90px; color:#fff; text-shadow:0 0 30px var(--neon-cyan); pointer-events:none; }
        /* ===== Settings dialog (F12) ===== */
        #settings-overlay{ position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(2,4,12,.72); z-index:60; pointer-events:auto; }
        #settings-panel{ width:min(560px,92vw); max-height:88vh; overflow-y:auto; background:linear-gradient(180deg,#0b1024,#070a16); border:1px solid #2bd3ff; border-radius:14px; box-shadow:0 0 40px rgba(43,211,255,.35),inset 0 0 24px rgba(43,211,255,.08); padding:22px 24px; font-family:'Segoe UI',Arial,sans-serif; color:#dff3ff; }
        #settings-panel h2{ margin:0 0 4px; font-size:20px; letter-spacing:1px; color:#fff; text-shadow:0 0 12px var(--neon-cyan); }
        #settings-panel .sub{ margin:0 0 16px; font-size:12px; color:#7fa9c9; }
        .set-row{ display:flex; align-items:center; gap:12px; margin:9px 0; }
        .set-row label{ flex:1 1 auto; font-size:13px; color:#bfe2f5; }
        .set-row input[type=range]{ flex:0 0 150px; accent-color:#2bd3ff; }
        .set-row input[type=number]{ flex:0 0 84px; background:#0a1330; border:1px solid #2a4a6a; color:#eaf6ff; border-radius:6px; padding:5px 7px; font-size:13px; text-align:right; }
        #settings-actions{ display:flex; gap:10px; margin-top:18px; flex-wrap:wrap; }
        #settings-actions button{ flex:1 1 auto; min-width:120px; padding:10px 14px; border-radius:8px; border:1px solid #2bd3ff; background:#0e2748; color:#eaf6ff; font-size:14px; font-weight:bold; cursor:pointer; letter-spacing:.5px; }
        #settings-actions button.primary{ background:linear-gradient(90deg,#0b8fe0,#2bd3ff); color:#02121f; border:none; }
        #settings-actions button.ghost{ background:transparent; }
        #settings-actions button:hover{ filter:brightness(1.15); }
        #settings-note{ margin-top:12px; font-size:11px; color:#7fa9c9; line-height:1.5; }
    </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>

    <div id="mp-layer">
        <div id="mp-divider"></div>
        <div id="mp-hud-1" class="mp-hud">
            <div class="mp-tag">PLAYER 1</div>
            <div class="mp-score">SCORE <span id="mp1-score">0</span></div>
            <div class="mp-speed"><span id="mp1-spd">0</span><small>km/h</small></div>
            <div class="mp-bars">
                <div class="mp-bar-bg"><div id="mp1-boost" class="mp-bar-fill mp-boost"></div></div>
                <div class="mp-bar-bg"><div id="mp1-prog" class="mp-bar-fill mp-prog-1"></div></div>
            </div>
        </div>
        <div id="mp-hud-2" class="mp-hud">
            <div class="mp-tag">PLAYER 2</div>
            <div class="mp-score">SCORE <span id="mp2-score">0</span></div>
            <div class="mp-speed"><span id="mp2-spd">0</span><small>km/h</small></div>
            <div class="mp-bars">
                <div class="mp-bar-bg"><div id="mp2-boost" class="mp-bar-fill mp-boost"></div></div>
                <div class="mp-bar-bg"><div id="mp2-prog" class="mp-bar-fill mp-prog-2"></div></div>
            </div>
        </div>
        <div id="mp-result"></div>
        <div id="mp-count"></div>
    </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>
                    <button id="mp-start-btn" class="title-btn" style="font-size:20px; background:linear-gradient(180deg,#7b2ff7,#4a1aa0); border-color:#b98cff;">2P VERSUS (SPLIT)</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 = {
            _last: {},
            _minGap: { lockon:0.14, crash:0.06, coin:0.06, heal:0.10, scrape:0.14, land:0.18, gate:0.10 },
            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;
                // rate-limit retriggerable SFX so they can never machine-gun into a rattle
                const gap = sound._minGap[type]; if(gap){ if((sound._last[type]||-1) > now - gap) return; sound._last[type] = now; }
                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 ================= */
        /* ===================== 自動生成コース用 設定定数 (COURSE / GAMEPLAY CONFIG) =====================
           ここの値が初期値です。ゲーム中に F12 キーで設定ダイアログを開いて変更でき、変更内容は
           ブラウザに保存され、次回起動時に自動で読み込まれます(自動生成コースにのみ適用)。 */
        const CFG = {
            NORMAL_SPEED:       330.0,  // 通常走行時の最高速度(物理速度)
            BOOST_SPEED_MULT:   1.9,    // ブースト時の最高速度倍率(× NORMAL_SPEED。約2倍)
            ACCEL:              220.0,  // 通常加速度
            BOOST_ACCEL_MULT:   1.3,    // ブースト加速の滑らかさ/強さ(小さいほどゆっくり滑らか)
            SPAWN_DENSITY:      1.0,    // ブロック/アイテムの出現数(大きいほど多い。例:2.0で約2倍)
            STEEP_SLOPE_CHANCE: 0.30,   // 普通の急勾配が出る頻度(0=出ない … 1=頻繁)
            ULTRA_STEEP_CHANCE: 0.15,   // 90度近い超急勾配が出る頻度(0=出ない … 1=頻繁)
            RATE_BLOCK:         1.00,   // 通常ブロックグループの出現比率(重み)
            RATE_HURDLE:        0.45,   // 赤ブロック(ハードル)の出現比率(重み)
            RATE_SCORE:         0.22,   // スコアアイテムの出現比率(重み)
            RATE_HEAL:          0.10,   // 回復アイテムの出現比率(重み)
            DISPLAY_NORMAL_KMH: 400,    // 通常最高速のときに表示する km/h
            DISPLAY_MAX_KMH:    1000,   // ブースト最高速のときに表示する km/h(スピードゲージ満タン)
        };
        const CFG_STORAGE_KEY = 'hyperdrive_config_v2';
        const CFG_DEFAULTS = Object.freeze({ ...CFG });
        const CFG_META = [
            { key:'NORMAL_SPEED',       label:'通常最高速度 (物理)',        min:80,  max:1200, step:10 },
            { key:'BOOST_SPEED_MULT',   label:'ブースト最高速倍率 (×)',     min:1.0, max:4.0,  step:0.1 },
            { key:'ACCEL',              label:'通常加速度',                min:40,  max:800,  step:10 },
            { key:'BOOST_ACCEL_MULT',   label:'ブースト加速の強さ',         min:0.3, max:4.0,  step:0.1 },
            { key:'SPAWN_DENSITY',      label:'ブロック/アイテム出現数 (×)', min:0.2, max:5.0,  step:0.1 },
            { key:'STEEP_SLOPE_CHANCE', label:'普通の急勾配の頻度 (0-1)',   min:0.0, max:1.0,  step:0.05 },
            { key:'ULTRA_STEEP_CHANCE', label:'超急勾配(90°近)の頻度 (0-1)', min:0.0, max:1.0,  step:0.05 },
            { key:'RATE_BLOCK',         label:'通常ブロック 出現比率',      min:0.0, max:5.0,  step:0.05 },
            { key:'RATE_HURDLE',        label:'赤ブロック 出現比率',        min:0.0, max:5.0,  step:0.05 },
            { key:'RATE_SCORE',         label:'スコアアイテム 出現比率',    min:0.0, max:5.0,  step:0.05 },
            { key:'RATE_HEAL',          label:'回復アイテム 出現比率',      min:0.0, max:5.0,  step:0.05 },
            { key:'DISPLAY_NORMAL_KMH', label:'通常速度の表示km/h',         min:50,  max:2000, step:10 },
            { key:'DISPLAY_MAX_KMH',    label:'最高速の表示km/h',           min:100, max:5000, step:10 },
        ];
        function loadConfig(){
            try {
                const raw = (typeof localStorage!=='undefined') ? localStorage.getItem(CFG_STORAGE_KEY) : null;
                if(raw){ const o = JSON.parse(raw); for(const k in CFG){ if(typeof o[k]==='number' && isFinite(o[k])) CFG[k] = o[k]; } }
            } catch(e){ /* storage unavailable (e.g. sandbox) → use defaults */ }
        }
        function saveConfig(){
            try { if(typeof localStorage!=='undefined') localStorage.setItem(CFG_STORAGE_KEY, JSON.stringify(CFG)); } catch(e){}
        }
        /* ============================================================================================= */

        const MAX_HP = 10, TUBE_R = 86, MAX_TIME = 60, GRAVITY = 100.0, JUMP_POWER = 60.0, TOTAL_STAGES = 8, FOG_NEAR = 220, FOG_FAR = 1400, SPEED_DISPLAY_MULTIPLIER = 1.15; 
        // Derived from CFG; these are `let` so the F12 settings dialog can update them live.
        let NORMAL_MAX_SPEED, RUSH_SPEED, NORMAL_ACCEL, BOOST_ACCEL_MULT;
        function applyConfig(){
            NORMAL_MAX_SPEED = CFG.NORMAL_SPEED;
            RUSH_SPEED       = CFG.NORMAL_SPEED * CFG.BOOST_SPEED_MULT;
            NORMAL_ACCEL     = CFG.ACCEL;
            BOOST_ACCEL_MULT = CFG.BOOST_ACCEL_MULT;
        }
        loadConfig();
        applyConfig();
        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 = 220;
        const GATE_SPACING = 230, GATE_POOL = 16;
        let tunnelSegments = [];
        let tunnelDirty = true;                         // re-lay all tunnel tiles on next update (view change / reset / mp start)
        const _tunM = new THREE.Matrix4(), _tunNormal = new THREE.Vector3(), _tunUp = new THREE.Vector3(), _tunTangent = new THREE.Vector3(), _tunBi = new THREE.Vector3(), _tunXaxis = new THREE.Vector3(1,0,0);
        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;

        /* ================= MULTIPLAYER (split-screen versus) =================
           Fully self-contained: reuses pure helpers (getBasis/getSectionPosition/isGap,
           updateTunnel/updateGates) but never touches the single-player physics, HUD or state. */
        const MP_FINISH_Z = 42000;            // race distance
        const MP_CTRL_1 = { left:'ArrowLeft', right:'ArrowRight', up:'ArrowUp', down:'ArrowDown', boost:'KeyZ', jump:'KeyX' };
        const MP_CTRL_2 = { left:['Numpad4','Digit4'], right:['Numpad6','Digit6'], up:['Numpad8','Digit8'], down:['Numpad2','Digit2'], boost:'KeyN', jump:'KeyM' };
        let mpActive = false;
        let mpPlayers = [];                   // [p1, p2]
        let mpClock = 0;
        let mpReady = 0;                      // countdown before GO
        let mpInited = false;
        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; 
            // (1) normal steep climbs/dives — frequency = CFG.STEEP_SLOPE_CHANCE (0 disables)
            const nChance = THREE.MathUtils.clamp(CFG.STEEP_SLOPE_CHANCE, 0, 1);
            const nPow = THREE.MathUtils.lerp(40, 4, nChance);
            const hillEnv = nChance > 0 ? Math.pow(Math.max(0, Math.sin(z * 0.00045 + 1.3)), nPow) : 0;
            const coaster = hillEnv * Math.sin(z * 0.004) * 230;
            // (2) ultra-steep (~90°) sections — frequency = CFG.ULTRA_STEEP_CHANCE (0 disables)
            const uChance = THREE.MathUtils.clamp(CFG.ULTRA_STEEP_CHANCE, 0, 1);
            const uPow = THREE.MathUtils.lerp(45, 3, uChance);
            const steepEnv = uChance > 0 ? Math.pow(Math.max(0, Math.sin(z * 0.00022 + 2.7)), uPow) : 0;
            const steepCoaster = steepEnv * Math.sin(z * 0.007) * 520;
            const y = coarseY + detailY + coaster + steepCoaster;
            if (mode === "F2") {
                // F2: STRAIGHT in X (no left/right winding). With zero X-curvature the road frame's right-axis is a
                // constant horizontal vector, so the surface mathematically cannot tilt left/right. The vertical
                // roller-coaster (normal + ultra-steep slopes) is fully retained.
                return new THREE.Vector3(0, y, z);
            }
            return new THREE.Vector3(coarseX + detailX, y, 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();
                if (mode === "F2") {
                    // F2: NO roll/bank (twistRad = 0) so road tiles never tilt individually. The course still winds and
                    // climbs/dives like a roller-coaster (handled in getDefaultCurve); pitch is fine, only roll is removed.
                    return { origin, T, U, R, twistRad: 0, width: TUBE_R * 1.4 };
                }
                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; }
        // Displayed km/h is decoupled from physical velocity: 0..NORMAL_MAX_SPEED -> 0..DISPLAY_NORMAL_KMH,
        // boost zone NORMAL_MAX..RUSH_SPEED -> DISPLAY_NORMAL_KMH..DISPLAY_MAX_KMH.
        function displaySpeedKmh(vz){
            if(vz <= NORMAL_MAX_SPEED) return (vz / NORMAL_MAX_SPEED) * CFG.DISPLAY_NORMAL_KMH;
            const t = Math.min(1.0, (vz - NORMAL_MAX_SPEED) / Math.max(1e-6, (RUSH_SPEED - NORMAL_MAX_SPEED)));
            return CFG.DISPLAY_NORMAL_KMH + t * (CFG.DISPLAY_MAX_KMH - CFG.DISPLAY_NORMAL_KMH);
        }

        /* ================= 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?1.65:0.5 }; }
            if(edgeL||edgeR){ return { color:0x160a18, emissive:0xff2a6d, intensity:1.55 }; }
            if(isRoad){ const checker = ((j+segIdx)%2===0); return { color: checker?0x14182a:0x0e1120, emissive:0x05203a, intensity:0.42 }; }
            // ceiling / wall neon panels
            const pulse = ((segIdx + j) % 9 === 0);
            return { color:0x0a0f1f, emissive: pulse?0x5a23b8:0x06122a, intensity: pulse?0.5:0.18 };
        }

        function updateTunnel() {
            if(customCourseCurve) { for(let i=0;i<tunnelSegments.length;i++) tunnelSegments[i].visible = false; return; }
            // anchor on the rear player in versus so both players stay covered; on the single player otherwise.
            const anchorZ = mpActive ? Math.min(mpPlayers[0].z, mpPlayers[1].z) : player.z;
            const frontRefZ = mpActive ? Math.max(mpPlayers[0].z, mpPlayers[1].z) : player.z;
            const startSegment = Math.floor(anchorZ / TILE_SEGMENT_LENGTH) - 8;
            const endSegment = startSegment + VISIBLE_SEGMENTS;
            const cullBack = anchorZ - 130;                  // a little behind the rear player
            const cullFront = frontRefZ + FOG_FAR + 140;     // beyond this everything is fully fogged → don't draw
            const dirty = tunnelDirty;
            const isOutside = (viewMode === "F4" || viewMode === "F5");
            for (let s = 0; s < tunnelSegments.length; s++) {
                const seg = tunnelSegments[s];
                let segIdx = seg.userData.zIndex;
                let recycled = false;
                if (segIdx < startSegment) { const k = Math.ceil((startSegment - segIdx) / VISIBLE_SEGMENTS); segIdx += k * VISIBLE_SEGMENTS; recycled = true; }
                else if (segIdx >= endSegment) { const k = Math.ceil((segIdx - endSegment + 1) / VISIBLE_SEGMENTS); segIdx -= k * VISIBLE_SEGMENTS; recycled = true; }
                if (recycled) { seg.userData.zIndex = segIdx; seg.userData.hue = Math.random(); seg.userData.lVar = (Math.random() * 0.2) - 0.1; }
                const gap = isGap(segIdx);
                const zPos = segIdx * TILE_SEGMENT_LENGTH;
                // cheap visibility every frame (distance + gap); transforms persist on the mesh while hidden.
                seg.visible = !gap && zPos <= cullFront && zPos >= cullBack;
                // expensive tile layout only when this segment was recycled or a full re-lay was requested.
                if (!(recycled || dirty) || gap) continue;
                const basis = getBasis(zPos, viewMode);
                const children = seg.children;
                for (let j = 0; j < children.length; j++) {
                    const tile = children[j];
                    const angle = (viewMode === "F2") ? ((j / TILES_PER_RING) * Math.PI * 2) - Math.PI : (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") {
                        // bank the tile with the same twist used for its position → the whole road tilts as one plane
                        _tunTangent.copy(basis.R).applyAxisAngle(basis.T, basis.twistRad);
                        _tunUp.copy(basis.U).applyAxisAngle(basis.T, basis.twistRad);
                        _tunM.makeBasis(_tunTangent, _tunUp, basis.T); tile.rotation.setFromRotationMatrix(_tunM);
                    } else {
                        _tunNormal.set(0,0,0).addScaledVector(basis.R, Math.sin(angle)).addScaledVector(basis.U, -Math.cos(angle)).normalize();
                        if (isOutside) _tunUp.copy(_tunNormal); else _tunUp.copy(_tunNormal).multiplyScalar(-1);
                        _tunTangent.crossVectors(_tunUp, basis.T).normalize();
                        _tunBi.crossVectors(_tunTangent, _tunUp).normalize();
                        _tunM.makeBasis(_tunTangent, _tunUp, _tunBi); tile.rotation.setFromRotationMatrix(_tunM);
                    }
                }
            }
            tunnelDirty = false;
        }

        /* ================= 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.6,3.6,3.6); color=0x3a2400; emissive=0xff9a1e; ei=0.95; }
            const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color, emissive, emissiveIntensity:ei, metalness:0.4, roughness:0.4 }));
            if (type !== 'hurdle' && type !== 'heal' && type !== 'score' && type !== 'dash') {
                // breakable block: bright neon edge outline so it reads clearly against the neon scene
                mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: 0xffd884 })));
            }
            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);
        }

        /* ================= CITYSCAPE BACKGROUND ================= */
        const CITY_POOL = 80;                  // max building count
        const CITY_RANGE = 3200;               // how far ahead buildings spawn
        const CITY_CLEAR_MARGIN = 360;         // extra gap kept between a tower and the playfield (covers track winding)
        const CITY_SPREAD = 460;               // random lateral spread beyond the safe minimum
        const CITY_GROUND_Y = -900;            // fixed ground level: skyscrapers rise from far below
        // widest horizontal extent of the playfield: the F2 flat road spreads to width*π/2, wider than the tube radius
        const TUBE_CLEAR_R = TUBE_R * 1.4 * Math.PI / 2;
        let cityBuildings = [];
        let cityNextZ = 0;
        let cityLOD = 1.0;                     // performance-adaptive detail (0..1)
        let fpsHistory = [];                    // rolling FPS for LOD adaptation
        let cityMeshCache = [];                 // reusable mesh pool for MP buffering
        let cityInited = false;

        function makeBuildingMesh(){
            // check cache first (multiplayer buffering)
            if(cityMeshCache.length > 0) {
                const cached = cityMeshCache.pop();
                cached.visible = true;
                return cached;
            }
            // big, tall skyscrapers (≈2× thicker than before) so even a modest count reads as a dense city
            const w = 90 + Math.random() * 190;
            const h = 700 + Math.random() * 900;
            const d = 90 + Math.random() * 190;
            const g = new THREE.Group();
            // main body (base sits at group origin, extends upward)
            const bodyMat = new THREE.MeshStandardMaterial({
                color: 0x070b16, emissive: 0x030820, emissiveIntensity: 0.4,
                metalness: 0.85, roughness: 0.25
            });
            const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), bodyMat);
            body.position.y = h / 2;
            g.add(body);
            // neon window strips (horizontal bands)
            const windowColors = [0x00f0ff, 0xff2a6d, 0xffe14d, 0xadff2f, 0x9955ff];
            const wCol = windowColors[Math.floor(Math.random() * windowColors.length)];
            const winMat = new THREE.MeshBasicMaterial({
                color: wCol, transparent: true, opacity: 0.22 + Math.random() * 0.3,
                blending: THREE.AdditiveBlending, depthWrite: false
            });
            const floorGap = 22 + Math.random() * 16;
            const stripCount = Math.floor(h / floorGap);
            const stripH = 2.5 + Math.random() * 3.0;
            for (let i = 0; i < Math.min(stripCount, 22); i++) {
                const yy = 14 + i * (h - 28) / Math.max(stripCount, 1);
                if (Math.random() < 0.3) continue;  // skip some floors for variety
                // front face
                const strip = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.86, stripH), winMat);
                strip.position.set(0, yy, d / 2 + 0.2);
                g.add(strip);
                // back face
                if (Math.random() < 0.5) {
                    const back = strip.clone(); back.position.z = -d / 2 - 0.2; back.rotation.y = Math.PI; g.add(back);
                }
            }
            // rooftop antenna / beacon
            if (Math.random() < 0.55) {
                const antMat = new THREE.MeshBasicMaterial({ color: 0xff0040, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending });
                const ant = new THREE.Mesh(new THREE.BoxGeometry(1.6, 25 + Math.random() * 45, 1.6), antMat);
                ant.position.y = h + 16; g.add(ant);
            }
            // edge outline for visual clarity
            const edgeMat = new THREE.LineBasicMaterial({ color: wCol, transparent: true, opacity: 0.16 });
            const edges = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(w, h, d)), edgeMat);
            edges.position.y = h / 2; g.add(edges);
            g.userData._bldgH = h;
            g.userData._halfW = Math.max(w, d) * 0.5;   // worst-case horizontal half-extent (for clearance)
            return g;
        }

        function buildCityscape() {
            cityBuildings = [];
            cityNextZ = -200;
            const count = Math.floor(CITY_POOL * cityLOD);
            for (let i = 0; i < count; i++) {
                const mesh = makeBuildingMesh();
                mesh.visible = false;
                scene.add(mesh);
                cityBuildings.push({ mesh, z: -99999, side: 0, dist: 0, active: false });
            }
            cityInited = true;
        }

        function recycleCityBuilding(b, z) {
            const mode = viewMode;
            const basis = getBasis(z, mode);
            const side = (Math.random() < 0.5) ? -1 : 1;
            // keep the whole tower clear of the tunnel: distance must exceed tube radius + tower half-width + margin
            const halfW = b.mesh.userData._halfW || 140;
            const safeMin = TUBE_CLEAR_R + halfW + CITY_CLEAR_MARGIN;
            const dist = safeMin + Math.random() * CITY_SPREAD;
            const origin = basis.origin;
            const right = basis.R;
            // lateral offset follows the track; base anchored to a fixed low ground so towers stand upright
            b.mesh.position.copy(origin).addScaledVector(right, side * dist);
            b.mesh.position.y = CITY_GROUND_Y;
            b.mesh.rotation.set(0, Math.random() * Math.PI * 2, 0);  // upright, only yaw varies
            b.mesh.visible = true;
            b.z = z;
            b.side = side;
            b.dist = dist;
            b.active = true;
        }

        function updateCityscape() {
            if (!cityInited || customCourseCurve) return;
            const refZ = mpActive ? Math.min(mpPlayers[0].z, mpPlayers[1].z) : player.z;
            const frontZ = mpActive ? Math.max(mpPlayers[0].z, mpPlayers[1].z) : player.z;
            // adaptive LOD based on FPS history
            const activeCount = Math.floor(CITY_POOL * cityLOD);
            // spawn new buildings ahead
            while (cityNextZ < frontZ + CITY_RANGE) {
                let best = null;
                for (let i = 0; i < cityBuildings.length && i < activeCount; i++) {
                    const b = cityBuildings[i];
                    if (!b.active || b.z < refZ - 200) { best = b; break; }
                }
                if (!best) break;
                recycleCityBuilding(best, cityNextZ);
                cityNextZ += 120 + Math.random() * 170;
            }
            // hide buildings far behind
            for (let i = 0; i < cityBuildings.length; i++) {
                const b = cityBuildings[i];
                if (b.active && b.z < refZ - 300) {
                    b.mesh.visible = false;
                    b.active = false;
                }
            }
        }

        function updateCityLOD(currentFps) {
            fpsHistory.push(currentFps);
            if (fpsHistory.length > 30) fpsHistory.shift();
            if (fpsHistory.length < 10) return;
            const avgFps = fpsHistory.reduce((a, b) => a + b, 0) / fpsHistory.length;
            if (avgFps < 30) cityLOD = Math.max(0.2, cityLOD - 0.05);
            else if (avgFps < 45) cityLOD = Math.max(0.4, cityLOD - 0.02);
            else if (avgFps > 55) cityLOD = Math.min(1.0, cityLOD + 0.02);
        }

        function cacheCityMeshes() {
            // cache building meshes for multiplayer reuse
            cityBuildings.forEach(b => {
                if (b.mesh && b.active) {
                    b.mesh.visible = false;
                    b.active = false;
                }
            });
        }

        function resetCityscape() {
            cityNextZ = (mpActive ? 0 : player.z) - 200;
            cityBuildings.forEach(b => { b.active = false; if(b.mesh) b.mesh.visible = false; });
        }

        /* ================= LOCK-ON PATH ARROWS ================= */
        const MAX_LOCK_CHAIN = 6;              // how many targets can be chained into one rush
        let lockPathGroup = null, lockPathLine = null, lockPathArrows = [];
        const _lpPts = [];

        function initLockPath(){
            lockPathGroup = new THREE.Group(); scene.add(lockPathGroup);
            const g = new THREE.BufferGeometry();
            g.setAttribute('position', new THREE.BufferAttribute(new Float32Array((MAX_LOCK_CHAIN + 1) * 3), 3));
            const lineMat = new THREE.LineBasicMaterial({ color: 0x00ff99, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false });
            lockPathLine = new THREE.Line(g, lineMat); lockPathLine.frustumCulled = false; lockPathLine.renderOrder = 998; lockPathGroup.add(lockPathLine);
            for (let i = 0; i < MAX_LOCK_CHAIN; i++) {
                const cone = new THREE.Mesh(new THREE.ConeGeometry(2.0, 5.5, 4), new THREE.MeshBasicMaterial({ color: 0x00ff99, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false }));
                cone.frustumCulled = false; cone.renderOrder = 999; cone.visible = false; lockPathGroup.add(cone); lockPathArrows.push(cone);
            }
            lockPathGroup.visible = false;
        }

        function updateLockPath(){
            if (!lockPathGroup) return;
            const targets = player.lockTargets.filter(t => t && !t.isDead && t.mesh);
            if (targets.length === 0) { lockPathGroup.visible = false; lockPathArrows.forEach(a => a.visible = false); return; }
            lockPathGroup.visible = true;
            // animated pulse colour for impact
            const pulse = 0.55 + 0.45 * Math.sin(performance.now() * 0.012);
            const baseCol = player.mode === "RUSHING" ? new THREE.Color(0xff39c0) : new THREE.Color(0x00ff99);
            const col = baseCol.clone().multiplyScalar(0.6 + pulse * 0.7);
            lockPathLine.material.color.copy(col); lockPathLine.material.opacity = 0.5 + pulse * 0.45;

            // build polyline: ship -> t0 -> t1 -> ...
            _lpPts.length = 0;
            _lpPts.push(player.mesh.position.clone());
            for (let i = 0; i < targets.length; i++) _lpPts.push(targets[i].mesh.position.clone());

            const posAttr = lockPathLine.geometry.attributes.position; const arr = posAttr.array;
            for (let i = 0; i < _lpPts.length; i++) { arr[i*3] = _lpPts[i].x; arr[i*3+1] = _lpPts[i].y; arr[i*3+2] = _lpPts[i].z; }
            // collapse any unused tail onto the last point
            for (let i = _lpPts.length; i <= MAX_LOCK_CHAIN; i++) { const last = _lpPts[_lpPts.length-1]; arr[i*3] = last.x; arr[i*3+1] = last.y; arr[i*3+2] = last.z; }
            posAttr.needsUpdate = true;
            lockPathLine.geometry.setDrawRange(0, _lpPts.length);

            // place an arrowhead just before each target, pointing along the path
            for (let i = 0; i < lockPathArrows.length; i++) {
                const cone = lockPathArrows[i];
                if (i >= targets.length) { cone.visible = false; continue; }
                const from = _lpPts[i], to = _lpPts[i + 1];
                const dir = to.clone().sub(from); const len = dir.length();
                if (len < 0.001) { cone.visible = false; continue; }
                dir.normalize();
                cone.visible = true;
                // sit the arrowhead a little ahead of the segment midpoint, biased toward the target
                cone.position.copy(to).addScaledVector(dir, -7.0);
                cone.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir);
                cone.material.color.copy(col); cone.material.opacity = 0.55 + pulse * 0.45;
                const s = 1.0 + pulse * 0.35; cone.scale.set(s, s, s);
            }
        }

        function hideLockPath(){ if(lockPathGroup){ lockPathGroup.visible = false; lockPathArrows.forEach(a => a.visible = false); } }

        /* ================= 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();
            buildCityscape();
            initLockPath();
            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.42, 0.6, 0.9);
            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){
            // F12 (or backup "O") opens/closes the settings dialog from anywhere.
            if(e.code === 'F12' || e.code === 'KeyO'){ e.preventDefault(); if(settingsOpen) closeSettings(); else openSettings(); return; }
            if(settingsOpen){ if(e.code === 'Escape'){ e.preventDefault(); closeSettings(); } return; }
            if(!mpActive && ["F1","F2","F3","F4","F5"].includes(e.code)) { e.preventDefault(); viewMode = e.code; document.getElementById('view-mode').innerText = "VIEW: " + e.code; tunnelDirty = true; resetCityscape(); if(customCourseData) rebuildTunnelMesh(viewMode); }
            keys[e.code] = true;
            // ---- multiplayer input (self-contained; returns early so SP handlers never fire) ----
            if(mpActive){
                if(["ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Space"].includes(e.code) || e.code.startsWith("Numpad")) e.preventDefault();
                if(gameState === "MP_PLAYING" && mpReady<=0){
                    const p1=mpPlayers[0], p2=mpPlayers[1];
                    // ジャンプのキーリピートを防止
                    if(e.code===MP_CTRL_1.jump && !p1.finished && p1.altitude<=0.1 && !e.repeat){ p1.jumpV=JUMP_POWER; p1.wasAirborne=true; sound.play('jump'); }
                    if(e.code===MP_CTRL_2.jump && !p2.finished && p2.altitude<=0.1 && !e.repeat){ p2.jumpV=JUMP_POWER; p2.wasAirborne=true; sound.play('jump'); }
                }
                if(gameState === "MP_RESULT"){ if(e.code==="Space"){ e.preventDefault(); startMultiplayer(); } else if(e.code==="Escape"){ exitMultiplayer(); } }
                else if(e.code==="Escape"){ exitMultiplayer(); }
                return;
            }
            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"); }
            }
            
            // Xキー(ジャンプ)のキーリピート暴発を防止
            if(e.code === 'KeyX' && !e.repeat) { 
                if (gameState === "PLAYING" && player.altitude <= 0.1 && !isPaused) { 
                    sound.play('jump'); player.jumpV = JUMP_POWER; player.wasAirborne = true; 
                } 
            }
            
            // Zキー(ブースト/ラッシュ)のキーリピート暴発を防止し、状態遷移を安定化
            if(e.code === 'KeyZ' && !e.repeat && gameState === "PLAYING" && !isPaused) { 
                if (player.lockTargets.length > 0 && player.mode !== "RUSHING") { 
                    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; hideLockPath(); }

        /* ================= 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"); } });
                const mb = document.getElementById('mp-start-btn'); if(mb) mb.addEventListener('click', () => { sound.startEngine(); startMultiplayer(); });
                populateCourseSelect();
            };
            if(s === "TITLE") {
                menu.innerHTML = `<h1>HYPER DRIVE</h1><h1 class="tag">ANTI-GRAVITY RACER</h1><p class="hint">1P &nbsp; Z: BOOST &amp; chain-RUSH &nbsp;|&nbsp; X: JUMP &nbsp;|&nbsp; F1-F5: camera</p><p class="hint">2P VERSUS &nbsp; P1: ←↑↓→ / Z boost / X jump &nbsp;&nbsp; P2: numpad 4 8 6 2 / N boost / M jump</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; }); tunnelDirty = true; gates.forEach(g=>g.userData.z=-99999); nextGateZ = GATE_SPACING; timeScale = 1.0; player.camPos.set(0,0,0); resetCityscape(); }

        /* ================= MULTIPLAYER (split-screen versus) =================
           Self-contained: reuses pure helpers (getBasis/getSectionPosition/isGap,
           updateTunnel/updateGates) but never touches single-player physics, HUD or state. */
        function mpHeld(ctl){ if(Array.isArray(ctl)) return ctl.some(c=>keys[c]); return !!keys[ctl]; }

        function buildShipMesh(accentHex){
            const g = 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();
            g.add(new THREE.Mesh(stealthGeo, new THREE.MeshStandardMaterial({ color: 0xdfe8ff, roughness: 0.28, metalness: 0.75, emissive: 0x101a33, emissiveIntensity:1.0, flatShading: true })));
            const accentMat = new THREE.MeshStandardMaterial({ color:0x001018, emissive:accentHex, emissiveIntensity:2.4, 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; g.add(stripL);
            const stripR = stripL.clone(); stripR.position.x=1.4; stripR.rotation.y=-0.32; g.add(stripR);
            const cockpit = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.5, 4), new THREE.MeshStandardMaterial({ color: 0x00aaff, emissive: accentHex, emissiveIntensity:1.6, 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; g.add(cockpit);
            const 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); g.add(fl); flames.push(fl); }
            scene.add(g);
            return { group:g, flames };
        }

        const _mpA = new THREE.Vector3(), _mpB = new THREE.Vector3(), _mpC = new THREE.Vector3(), _mpD = new THREE.Vector3(), _mpE = new THREE.Vector3(), _mpM = new THREE.Matrix4();
        let mpSavedPixelRatio = 1.75;

        function makeMpPlayer(idx, accentHex, ctl){
            const ship = buildShipMesh(accentHex);
            const shadow = new THREE.Mesh(new THREE.PlaneGeometry(5.0, 5.0), new THREE.MeshBasicMaterial({ color:0x000000, transparent:true, opacity:0.55, depthWrite:false }));
            shadow.visible = false; scene.add(shadow);
            return { idx, ctl, mesh:ship.group, flames:ship.flames, shadow,
                angle:0, z:0, prevZ:0, vz:0, vAngle:0, altitude:0, jumpV:0, bank:0, surge:0, gForce:0,
                energy:MAX_ENERGY, overheat:false, mode:"NORMAL", isBoosting:false, wasAirborne:false,
                score:0, nextGateScoreZ:GATE_SPACING,
                finished:false, finishTime:0, camPos:new THREE.Vector3(),
                camera:new THREE.PerspectiveCamera(BASE_FOV, 1, 0.1, 6000),
                _pos:new THREE.Vector3(), _fwd:new THREE.Vector3(0,0,1), _up:new THREE.Vector3(0,1,0), _right:new THREE.Vector3(1,0,0) };
        }

        function startMultiplayer(){
            // versus always runs on the default neon course
            customCourseData = null; customCourseCurve = null;
            if(player.mesh) player.mesh.visible = false; if(player.shadow) player.shadow.visible = false;
            trails.forEach(t => { if(t.mesh) t.mesh.visible = false; });
            if(streakField && streakField.mesh) streakField.mesh.visible = false;
            document.getElementById('hud-bottom').style.display = 'none';
            document.getElementById('center-text').innerHTML = '';
            document.getElementById('ui-layer').style.display = 'none';
            document.getElementById('controls-help').style.display = 'none';
            document.getElementById('mp-layer').style.display = 'block';
            document.getElementById('mp-result').style.display = 'none';
            document.getElementById('mp-count').innerText = '';
            viewMode = 'F3';
            if(!mpInited){ mpPlayers = [ makeMpPlayer(0, 0x00f0ff, MP_CTRL_1), makeMpPlayer(1, 0xff2a6d, MP_CTRL_2) ]; mpInited = true; }
            mpPlayers.forEach(p => { p.angle=0; p.z=0; p.prevZ=0; p.vz=0; p.vAngle=0; p.altitude=0; p.jumpV=0; p.bank=0; p.surge=0; p.gForce=0; p.energy=MAX_ENERGY; p.overheat=false; p.mode="NORMAL"; p.isBoosting=false; p.wasAirborne=false; p.score=0; p.nextGateScoreZ=GATE_SPACING; p.finished=false; p.finishTime=0; p.camPos.set(0,0,0); p.camera.fov=BASE_FOV; p.mesh.visible=true; if(p.shadow) p.shadow.visible=false; });
            mpClock = 0; mpReady = 3.6; mpActive = true;
            // split-screen renders the scene twice — lower the pixel ratio to keep the frame rate up (fill-rate bound).
            mpSavedPixelRatio = renderer.getPixelRatio();
            renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.0));
            // reset shared world streaming to the start
            player.z = 0; gates.forEach(g=>g.userData.z=-99999); nextGateZ = GATE_SPACING; tunnelSegments.forEach((seg,i)=>{ seg.userData.zIndex = i; }); tunnelDirty = true;
            resetCityscape();
            gameState = "MP_PLAYING";
        }

        function exitMultiplayer(){
            mpActive = false;
            mpPlayers.forEach(p => { p.mesh.visible = false; if(p.shadow) p.shadow.visible = false; });
            document.getElementById('mp-layer').style.display = 'none';
            document.getElementById('mp-result').style.display = 'none';
            document.getElementById('ui-layer').style.display = '';
            document.getElementById('controls-help').style.display = '';
            if(streakField && streakField.mesh) streakField.mesh.visible = true;
            trails.forEach(t => { if(t.mesh) t.mesh.visible = true; });
            renderer.setScissorTest(false);
            renderer.setPixelRatio(mpSavedPixelRatio);   // restore full resolution for single-player
            onResize();                                  // re-sync renderer + composer sizes
            score = 0; currentStage = 1;
            changeState("TITLE");
        }

        function mpUpdatePlayer(p, dt){
            if(p.finished){ p.vz = THREE.MathUtils.lerp(p.vz, 0, 1.5*dt); p.surge = THREE.MathUtils.lerp(p.surge,0,5*dt); p.bank = THREE.MathUtils.lerp(p.bank,0,5*dt); p.prevZ=p.z; p.z += p.vz*dt; return; }
            const c = p.ctl;
            // boost energy / overheat
            if(p.mode==="BOOST"){ p.energy -= BOOST_DRAIN*dt; if(p.energy<=0){ p.energy=0; p.overheat=true; p.mode="NORMAL"; p.isBoosting=false; sound.play('overheat'); } }
            else { p.energy = Math.min(MAX_ENERGY, p.energy + BOOST_REGEN*dt); if(p.overheat && p.energy>=OVERHEAT_RECOVER) p.overheat=false; }
            const boostHeld = mpHeld(c.boost);
            if(boostHeld && !p.overheat && p.mode!=="BOOST" && p.energy>5){ p.mode="BOOST"; p.isBoosting=true; sound.play('boost_dash'); }
            if(p.mode==="BOOST" && (!boostHeld || p.overheat)){ p.mode="NORMAL"; p.isBoosting=false; }
            // longitudinal
            const accelHeld = mpHeld(c.up), brakeHeld = mpHeld(c.down);
            if(p.mode==="BOOST"){ p.vz = THREE.MathUtils.lerp(p.vz, RUSH_SPEED, Math.min(1, BOOST_ACCEL_MULT*dt)); p.surge = THREE.MathUtils.lerp(p.surge,10.0,5*dt); }
            else if(accelHeld){ if(p.vz<NORMAL_MAX_SPEED) p.vz += NORMAL_ACCEL*dt; else p.vz *= (1.0-0.5*dt); p.surge = THREE.MathUtils.lerp(p.surge,3.0,5*dt); }
            else if(brakeHeld){ p.vz -= NORMAL_ACCEL*1.5*dt; p.surge = THREE.MathUtils.lerp(p.surge,-3.0,5*dt); }
            else { p.vz *= (1.0-0.2*dt); p.surge = THREE.MathUtils.lerp(p.surge,0,5*dt); }
            p.vz = Math.max(0, p.vz);
            // steering
            let steerFactor = (p.vz>300.0)?0.5:1.0; steerFactor *= 2.0; const BANK_LIMIT=0.78; let targetBank=0; const prevAngle=p.angle;
            if(mpHeld(c.left)){ p.vAngle = THREE.MathUtils.lerp(p.vAngle, -1.5*steerFactor, 5*dt); targetBank=-BANK_LIMIT; }
            else if(mpHeld(c.right)){ p.vAngle = THREE.MathUtils.lerp(p.vAngle, 1.5*steerFactor, 5*dt); targetBank=BANK_LIMIT; }
            else p.vAngle = THREE.MathUtils.lerp(p.vAngle, 0, 10*dt);
            p.angle += p.vAngle*dt; p.bank = THREE.MathUtils.lerp(p.bank, targetBank, 5*dt);
            p.gForce = THREE.MathUtils.lerp(p.gForce, (p.angle-prevAngle)/Math.max(dt,0.001)*0.04, 0.2);
            // gravity / gap / landing
            const segIdx = Math.floor(p.z/TILE_SEGMENT_LENGTH); const inGap = isGap(segIdx);
            const isFloating = (p.vz > 26 && !p.wasAirborne);
            p.altitude += p.jumpV*dt;
            if(inGap && !isFloating){ p.jumpV -= GRAVITY*dt; }
            else { if(p.altitude>0) p.jumpV -= GRAVITY*dt; else { if(p.wasAirborne && p.jumpV<-10) sound.play('land'); p.altitude=0; p.jumpV=0; p.wasAirborne=false; } }
            if(p.altitude < -40){ p.altitude=0; p.jumpV=0; p.vz *= 0.4; createDebris(p.mesh.position.clone(), 0xff5555, 0.6, 8, "spark"); }
            // scoring: speed/distance + boost + airtime, with gate milestones
            p.score += p.vz * dt * 0.06;
            if(p.mode==="BOOST") p.score += 28 * dt;
            if(p.altitude > 2) p.score += 36 * dt;
            p.prevZ = p.z; p.z += p.vz*dt;
            while(p.z >= p.nextGateScoreZ){ p.score += 150; p.nextGateScoreZ += GATE_SPACING; }
            if(!p.finished && p.z >= MP_FINISH_Z){ p.finished=true; p.finishTime=mpClock; p.score += 1000; }
        }

        function mpUpdateShipVisual(p){
            const visualZ = p.z + p.surge; const basis = getBasis(visualZ, "F3");
            const r = basis.width - 5.0 - p.altitude;
            const pos = getSectionPosition(p.angle, r, basis, "F3");
            const isRoad = (p.angle >= -Math.PI/2 && p.angle <= Math.PI/2);
            const rAxis = isRoad ? _mpA.copy(basis.R).applyAxisAngle(basis.T, basis.twistRad) : _mpA.copy(basis.R);
            const uyAxis = isRoad ? _mpB.copy(basis.U).applyAxisAngle(basis.T, basis.twistRad) : _mpB.copy(basis.U);
            const normal = _mpC.set(0,0,0).addScaledVector(rAxis, Math.sin(p.angle)).addScaledVector(uyAxis, -Math.cos(p.angle)).normalize();
            const playerUp = _mpD.copy(normal).negate();
            p._fwd.copy(basis.T);
            p._pos.copy(pos);
            p._right.crossVectors(p._fwd, playerUp).normalize();
            p._up.crossVectors(p._right, p._fwd).normalize();
            p.mesh.position.copy(pos);
            _mpM.makeBasis(p._right, p._up, p._fwd); p.mesh.rotation.setFromRotationMatrix(_mpM); p.mesh.rotateZ(p.bank); if(p.altitude>0.5) p.mesh.rotateX(-0.1);
            // road shadow (projected straight down to the track surface)
            if(p.shadow){
                const inGap = isGap(Math.floor(p.z/TILE_SEGMENT_LENGTH));
                if(inGap){ p.shadow.visible = false; }
                else {
                    p.shadow.visible = true;
                    p.shadow.position.copy(getSectionPosition(p.angle, basis.width - 0.5, basis, "F3"));
                    _mpM.makeBasis(p._right, playerUp, p._fwd); p.shadow.rotation.setFromRotationMatrix(_mpM); p.shadow.rotateX(-Math.PI/2);
                    const a = Math.max(0, p.altitude); p.shadow.scale.setScalar(1.0 + a*0.035); p.shadow.material.opacity = Math.max(0.14, 0.6 - a*0.012);
                }
            }
            const speedRatio = p.vz/RUSH_SPEED; const boosting = (p.mode==="BOOST");
            p.flames.forEach(fl=>{ if(p.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); } });
        }

        function mpUpdateCamera(p, rawDt){
            const pos=p._pos, playerForward=p._fwd, orthoUp=p._up, right=p._right;
            const shake = (p.mode==="BOOST" ? (Math.random()-0.5)*1.2 : (Math.random()-0.5)*0.12) * (1.0 + p.vz/NORMAL_MAX_SPEED*0.5);
            const cameraUp = _mpA.copy(orthoUp).applyAxisAngle(playerForward, p.bank*0.6 + p.gForce*0.5);
            const lookTarget = _mpB.copy(pos).addScaledVector(playerForward, 52).addScaledVector(right, p.gForce*8).addScaledVector(orthoUp, 17);
            const pull = 7.0;      // very short constant pull → ship locked large in the foreground
            const lift = 3.5;
            const camPos = _mpC.copy(pos).addScaledVector(playerForward, -pull).addScaledVector(orthoUp, lift).addScaledVector(right, shake);
            if(p.camPos.lengthSq()===0) p.camPos.copy(camPos);
            const camLerp = 1 - Math.exp(-16 * (rawDt || 0.016));
            p.camPos.lerp(camPos, Math.min(1, camLerp));
            p.camera.position.copy(p.camPos); p.camera.up.copy(cameraUp); p.camera.lookAt(lookTarget);
            const spd = THREE.MathUtils.clamp(p.vz/NORMAL_MAX_SPEED, 0, 3);
            let tf = BASE_FOV + Math.min(1, spd)*24; if(p.mode==="BOOST") tf += 18; tf = Math.min(MAX_FOV, tf);
            p.camera.fov = THREE.MathUtils.lerp(p.camera.fov, tf, 6*(rawDt||0.016));
            const w=window.innerWidth, h=window.innerHeight; p.camera.aspect = (w*0.5)/h; p.camera.updateProjectionMatrix();
        }

        function mpUpdateHUD(){
            const p1=mpPlayers[0], p2=mpPlayers[1];
            document.getElementById('mp1-spd').innerText = Math.round(displaySpeedKmh(p1.vz));
            document.getElementById('mp2-spd').innerText = Math.round(displaySpeedKmh(p2.vz));
            document.getElementById('mp1-score').innerText = Math.floor(p1.score);
            document.getElementById('mp2-score').innerText = Math.floor(p2.score);
            const b1=document.getElementById('mp1-boost'); b1.style.width = Math.max(0, p1.energy/MAX_ENERGY*100)+'%'; b1.classList.toggle('overheat', p1.overheat);
            const b2=document.getElementById('mp2-boost'); b2.style.width = Math.max(0, p2.energy/MAX_ENERGY*100)+'%'; b2.classList.toggle('overheat', p2.overheat);
            document.getElementById('mp1-prog').style.width = Math.min(100, p1.z/MP_FINISH_Z*100)+'%';
            document.getElementById('mp2-prog').style.width = Math.min(100, p2.z/MP_FINISH_Z*100)+'%';
        }

        function mpShowResult(){
            gameState = "MP_RESULT";
            const p1=mpPlayers[0], p2=mpPlayers[1];
            let winner;
            if(p1.finished && p2.finished) winner = (p1.finishTime<=p2.finishTime)?1:2;
            else if(p1.finished) winner=1; else if(p2.finished) winner=2; else winner=(p1.z>=p2.z)?1:2;
            const title = winner===1 ? "PLAYER 1 WINS!" : "PLAYER 2 WINS!";
            const col = winner===1 ? "#2bd3ff" : "#ff2a6d";
            const r = document.getElementById('mp-result');
            r.innerHTML = `<h1 style="color:${col}; font-size:46px;">${title}</h1>
                <div style="display:flex; gap:40px; margin-top:14px; font-family:'Arial Black',sans-serif;">
                    <div style="color:#2bd3ff;">P1<br><span style="font-size:40px;">${Math.floor(p1.score)}</span><br><span style="font-size:13px;opacity:.8;">${p1.finished?p1.finishTime.toFixed(2)+'s':'DNF'}</span></div>
                    <div style="color:#ff2a6d;">P2<br><span style="font-size:40px;">${Math.floor(p2.score)}</span><br><span style="font-size:13px;opacity:.8;">${p2.finished?p2.finishTime.toFixed(2)+'s':'DNF'}</span></div>
                </div>
                <div style="margin-top:20px;">
                    <button id="mp-again" class="title-btn" style="background:linear-gradient(180deg,#7b2ff7,#4a1aa0); border-color:#b98cff;">REMATCH (SPACE)</button>
                    <button id="mp-quit" class="title-btn">BACK TO TITLE (ESC)</button>
                </div>`;
            r.style.display = 'flex';
            sound.play('ui');
            const again=document.getElementById('mp-again'); if(again) again.addEventListener('click', ()=>startMultiplayer());
            const quit=document.getElementById('mp-quit'); if(quit) quit.addEventListener('click', ()=>exitMultiplayer());
        }

        function mpFrame(rawDt){
            const dt = Math.min(rawDt, 0.05);
            if(gameState === "MP_RESULT"){ mpRender(); return; }
            if(mpReady > 0){ mpReady -= rawDt; const n = Math.ceil(mpReady-0.6); document.getElementById('mp-count').innerText = mpReady<=0.6 ? "GO!" : (n>0?String(n):""); }
            else { document.getElementById('mp-count').innerText=''; mpClock += rawDt; mpPlayers.forEach(p=>mpUpdatePlayer(p, dt)); }
            mpPlayers.forEach(p=>mpUpdateShipVisual(p));
            mpPlayers.forEach(p=>mpUpdateCamera(p, rawDt));
            // shared world streaming centred on the rear player so both stay covered
            const rearZ = Math.min(mpPlayers[0].z, mpPlayers[1].z);
            player.z = rearZ;
            updateTunnel(); updateGates();
            updateCityscape();
            if(backdrop){ backdrop.position.z = rearZ; if(gridFloor){ gridFloor.material.uniforms.uScroll.value = (rearZ*0.02)%1000; } if(stars) stars.rotation.y += 0.0002; }
            sound.updateEngine(Math.max(mpPlayers[0].vz, mpPlayers[1].vz)/NORMAL_MAX_SPEED, mpPlayers.some(p=>p.isBoosting));
            mpUpdateHUD();
            if(mpReady<=0 && mpPlayers.some(p=>p.finished)) mpShowResult();
            mpRender();
        }

        function mpRender(){
            const w=window.innerWidth, h=window.innerHeight; const halfW=Math.floor(w/2);
            renderer.setScissorTest(true);
            renderer.setViewport(0,0,halfW,h); renderer.setScissor(0,0,halfW,h);
            renderer.render(scene, mpPlayers[0].camera);
            renderer.setViewport(halfW,0,w-halfW,h); renderer.setScissor(halfW,0,w-halfW,h);
            renderer.render(scene, mpPlayers[1].camera);
            renderer.setScissorTest(false); renderer.setViewport(0,0,w,h);
        }

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

            // continuous speed-reactive FOV (moderated so the foreground ship stays large)
            const spdRatio = THREE.MathUtils.clamp(player.vz / NORMAL_MAX_SPEED, 0, 3);
            let targetFov = BASE_FOV + Math.min(1.0, spdRatio) * 24;
            if(player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") targetFov += 18;
            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();
                updateCityscape();
                updatePlayerVisuals(rawDt);
                if(gameState === "PLAYING") updateLockPath(); else hideLockPath();
                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.06, 0, 0.16);
                // a gentle pulsing radial blur during boost — strong enough to feel fast, light enough to keep obstacles visible
                if(player.mode==="RUSHING"||player.mode==="MANUAL_BOOST") str += 0.08 + 0.025*Math.sin(performance.now()*0.02);
                if(player.mode==="RUSHING") str += 0.03;
                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.0020 + (player.isBoosting?0.0025:0)), 0.2);
                if(bloomPass) bloomPass.strength = 0.4 + (player.isBoosting?0.25:0) + Math.min(0.2, spdRatio*0.08);
            }

            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'; updateCityLOD(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") { player.vz = THREE.MathUtils.lerp(player.vz, RUSH_SPEED, Math.min(1, BOOST_ACCEL_MULT * 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) {
            // No locking while rushing or while manually boosting — repeated locks otherwise machine-gun the
            // lock-on blip into a "ガガガ" rattle during boost. Locking resumes in normal flight.
            if (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") return; player.lockTimer += dt; if (player.lockTimer < 0.12) return; player.lockTimer = 0;
            if (player.lockTargets.length >= MAX_LOCK_CHAIN) return;
            // chain forward: each new lock must sit ahead of the previously locked one (or the player)
            const lastLocked = player.lockTargets[player.lockTargets.length - 1];
            const fromZ = lastLocked ? lastLocked.z : player.z;
            const refAngle = lastLocked ? lastLocked.angle : player.angle;
            let candidates = worldObjects.filter(obj => {
                if (obj.locked || obj.isDead || obj.isScenery) return false;
                if (obj.z <= fromZ + 0.1 || obj.z > player.z + 1600) return false;
                let angleDiff = Math.abs(obj.angle - refAngle); while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
                if (Math.abs(angleDiff) > (player.lockTargets.length === 0 ? 3.2 : 2.2)) return false;
                return (!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.55; 
                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.4; }
            } 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.35; 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 isFloating = (player.vz > 26 && !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);
                    // shadow on the road stays dark; as the ship climbs it spreads a little and softens → reads as "floating"
                    const _sa = Math.max(0, player.altitude); player.shadow.scale.setScalar(1.15 + _sa * 0.05); player.shadow.material.opacity = THREE.MathUtils.clamp(0.72 - _sa * 0.01, 0.32, 0.72); 
                } 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, 52).addScaledVector(right, player.gForce*8).addScaledVector(orthoUp, 17);

            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, -17).addScaledVector(playerUp, 10); camera.position.copy(camPos); camera.up.copy(playerUp); camera.lookAt(player.mesh.position.clone().addScaledVector(playerForward, 44)); }
            else {
                // chase cam: very short CONSTANT pull → ship sits big and low in the foreground at all speeds.
                const pull = 7.0;
                const lift = 3.5;
                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) {
            const density = Math.max(0.05, CFG.SPAWN_DENSITY);
            let spawnDist = (((150 - (currentStage * 10.0)) / 3.0) + (player.vz * 0.5)) / density; 
            if (player.z + 2000 > nextSpawnZ && !customCourseCurve) { spawnRandomObject(nextSpawnZ); nextSpawnZ += (Math.random() * 50) / density + 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 pickSpawnType(){
            // weighted random over the configurable per-type rates (+ a small fixed weight for the dash gimmick)
            const w = { block: Math.max(0, CFG.RATE_BLOCK), hurdle: Math.max(0, CFG.RATE_HURDLE), score: Math.max(0, CFG.RATE_SCORE), heal: Math.max(0, CFG.RATE_HEAL), dash: 0.06 };
            let total = 0; for(const k in w) total += w[k];
            if(total <= 0) return "block";
            let r = Math.random() * total;
            for(const k in w){ r -= w[k]; if(r <= 0) return k; }
            return "block";
        }
        function spawnRandomObject(z) {
            let type = pickSpawnType();
            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") {
                // Hitting a solid object scrubs boost speed back to normal so the next obstacle is visible and the
                // player can re-boost into it → satisfying continuous-destruction rhythm. (Not during lock-on rush.)
                if (!mpActive && player.mode !== "RUSHING") player.vz = Math.min(player.vz, NORMAL_MAX_SPEED * 0.8);
                if (forcedKill) { sound.play('crash'); const fxCol = (obj.type === "hurdle") ? 0xff5522 : 0xffc44d; createDebris(obj.mesh.position, fxCol, 2.6, 24, "explode"); spawnShockwave(obj.mesh.position, fxCol); 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, 0xff5522, 1.5, 18, "explode"); spawnShockwave(obj.mesh.position, 0xff5522); 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, 0xffc44d, 1.5, 14, "explode"); spawnShockwave(obj.mesh.position, 0xffc44d); 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 spawnShockwave(pos, color){
            const m = new THREE.Mesh(new THREE.RingGeometry(0.8, 1.5, 28), new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.85, side:THREE.DoubleSide, blending:THREE.AdditiveBlending, depthWrite:false }));
            m.position.copy(pos); m.lookAt(camera.position); scene.add(m);
            debris.push({ mesh:m, vel:new THREE.Vector3(), life:0.42, maxLife:0.42, type:"ring" });
        }

        function updateDebris(dt) { for(let i = debris.length - 1; i >= 0; i--) { const d = debris[i]; d.life -= dt;
            if (d.type === "ring") { const k = 1 - Math.max(0, d.life)/d.maxLife; d.mesh.scale.setScalar(1 + k*16); d.mesh.material.opacity = Math.max(0, d.life)/d.maxLife * 0.85; if(d.life <= 0){ scene.remove(d.mesh); debris.splice(i,1); } continue; }
            d.mesh.position.addScaledVector(d.vel, dt * 20); 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.vz;
            document.getElementById('speed-val').innerText = displaySpeedKmh(val).toFixed(0);
            document.getElementById('speed-fill').style.width = Math.min(100, (displaySpeedKmh(val) / CFG.DISPLAY_MAX_KMH) * 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) + "%";
        }
        /* ================= SETTINGS DIALOG (F12) ================= */
        let settingsOpen = false, settingsPausedByUs = false;
        function buildSettingsRows(){
            const wrap = document.getElementById('settings-rows'); if(!wrap) return; wrap.innerHTML = '';
            CFG_META.forEach(m => {
                const row = document.createElement('div'); row.className = 'set-row'; row._key = m.key; row._meta = m;
                const lab = document.createElement('label'); lab.textContent = m.label;
                const range = document.createElement('input'); range.type = 'range'; range.min = m.min; range.max = m.max; range.step = m.step; range.value = CFG[m.key];
                const num = document.createElement('input'); num.type = 'number'; num.min = m.min; num.max = m.max; num.step = m.step; num.value = CFG[m.key];
                range.addEventListener('input', () => { num.value = range.value; });
                num.addEventListener('input', () => { range.value = num.value; });
                row._get = () => parseFloat(num.value);
                row.appendChild(lab); row.appendChild(range); row.appendChild(num);
                wrap.appendChild(row);
            });
        }
        function openSettings(){
            buildSettingsRows();
            document.getElementById('settings-overlay').style.display = 'flex';
            settingsOpen = true;
            if(gameState === "PLAYING" && !isPaused){ isPaused = true; settingsPausedByUs = true; try{ sound.suspend(); }catch(e){} if(clock) clock.stop(); }
            else settingsPausedByUs = false;
        }
        function closeSettings(){
            document.getElementById('settings-overlay').style.display = 'none';
            settingsOpen = false;
            if(settingsPausedByUs && gameState === "PLAYING"){ isPaused = false; try{ sound.resume(); }catch(e){} if(clock) clock.start(); }
            settingsPausedByUs = false;
        }
        function commitSettings(){
            document.querySelectorAll('#settings-rows .set-row').forEach(row => {
                let v = row._get(); if(!isFinite(v)) return; const m = row._meta; v = Math.max(m.min, Math.min(m.max, v)); CFG[row._key] = v;
            });
            applyConfig(); saveConfig(); tunnelDirty = true; if(typeof resetCityscape === 'function') resetCityscape();
        }
        function setupSettingsUI(){
            const save = document.getElementById('set-save'), def = document.getElementById('set-default'), cancel = document.getElementById('set-cancel');
            if(save) save.addEventListener('click', () => { commitSettings(); closeSettings(); });
            if(def) def.addEventListener('click', () => { for(const k in CFG_DEFAULTS) CFG[k] = CFG_DEFAULTS[k]; buildSettingsRows(); });
            if(cancel) cancel.addEventListener('click', () => closeSettings());
            const ov = document.getElementById('settings-overlay');
            if(ov) ov.addEventListener('click', (e) => { if(e.target === ov) closeSettings(); });
        }
        setupSettingsUI();

        init();
    </script>
    <div id="settings-overlay">
        <div id="settings-panel">
            <h2>⚙ ゲーム設定 (SETTINGS)</h2>
            <p class="sub">自動生成コースに適用されます。保存するとブラウザに記録され、次回起動時に自動で読み込まれます。</p>
            <div id="settings-rows"></div>
            <div id="settings-actions">
                <button class="primary" id="set-save">保存して適用</button>
                <button id="set-default" class="ghost">初期値に戻す</button>
                <button id="set-cancel" class="ghost">キャンセル</button>
            </div>
            <p id="settings-note">F12キーでいつでも開閉できます。「保存して適用」で即座に反映され、設定はこのブラウザに保存されます。</p>
        </div>
    </div>
</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>

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

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

気楽に遊べるゲーム

  • 29本

コメント

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