ImageBoosterのコースエディタ

【更新履歴】

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

画像

・「Image Booster」についてはこちら。↓


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

・コースエディタのソースコードはこちら。↓

<!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.1</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); }
        #canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #111; }
        
        #properties-panel { width: 280px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto; }
        .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; }
        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; }
        #height-display { background: rgba(0,0,0,0.5); color: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table; }

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

        #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; }
    </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-select">Select</div>
                <div class="dropdown-item" id="tool-add">Add</div>
                <div class="dropdown-item" id="tool-paint">Paint (Block Texture)</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-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">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('bottom')">Bottom</div>
                <div class="dropdown-item" onclick="viewCam('front')">Front</div>
                <div class="dropdown-item" onclick="viewCam('back')">Back</div>
                <div class="dropdown-item" onclick="viewCam('left')">Left</div>
                <div class="dropdown-item" onclick="viewCam('right')">Right</div>
            </div>
        </div>
        <div class="menu-item" id="menu-environment">Settings</div>
    </div>

    <div id="container">
        <div id="canvas-container" tabindex="0">
            <div id="info-overlay"><div id="mode-display">Select</div><div id="height-display">Height: 0</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">Scenery Block</option>
                        <option value="bgmodel">Background Model</option>
                    </select>
                </div>
                
                <div id="add-scenery-options" style="display:none;">
                    <div class="prop-group">
                        <span class="prop-label">Block Color</span>
                        <input type="color" id="brush-color" value="#888888">
                    </div>
                    <div class="prop-group">
                        <span class="prop-label">Block Texture File</span>
                        <div class="file-select-row">
                            <input type="text" id="brush-tex-name" disabled placeholder="None">
                            <button class="action-btn" id="btn-select-brush-tex">Select</button>
                        </div>
                        <button class="action-btn" id="btn-clear-brush-tex">Clear Texture</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">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>
                </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 class="prop-group" id="prop-color-group">
                        <span class="prop-label">Color</span><input type="color" id="prop-color">
                        <span class="prop-label" style="margin-top:10px;">Texture File</span>
                        <div class="file-select-row">
                            <input type="text" id="prop-tex-name" disabled placeholder="None">
                            <button class="action-btn" id="btn-prop-select-tex">Select</button>
                        </div>
                        <button class="action-btn" id="btn-prop-clear-tex">Clear</button>
                    </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>

    <div id="modal-overlay">
        <div class="modal-box" id="env-dialog">
            <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" style="width:auto; padding:5px 15px; display:inline-block;" onclick="document.getElementById('modal-overlay').style.display='none'">Close & Apply</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 = 0;
        let trackNodes = [], sceneryItems = [], trackSplineGroup = null, selectedItem = null, addItemType = 'node', clipboard = null;
        let planeGround, cursorTarget, cursorPoint, cursorSelect, labelsContainer;
        let isDragging = 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 dragStartCamPos = new THREE.Vector3(), dragStartCamTarget = new THREE.Vector3(), dragStartPolar = 0, dragStartAzimuth = 0;
        let gridGroup = new THREE.Group(), guideLineGroup = new THREE.Group(), shadowPool = [], walls = {};

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

        // Asset Management
        let ASSETS = { env: { road: null, wall: null, sky: null, ground: null }, bgModels: {}, blockTextures: {} };
        let currentBrushTex = null; 
        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) callback({ name: folderPrefix + f.name, url: URL.createObjectURL(f) }); };
            input.click();
        };

        window.updateEnvField = (id, fileObj) => {
            document.getElementById(id).value = fileObj.name;
            const key = id.split('-')[1];
            ASSETS.env[key] = fileObj;
            applyEnvironment();
        };

        function getTex(fileObj, callback) {
            if(!fileObj) return null;
            if(ASSETS.blockTextures[fileObj.name]) { const t = ASSETS.blockTextures[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); });
            ASSETS.blockTextures[fileObj.name] = { url: fileObj.url, texture: tex };
            return tex;
        }

        // Utils: Point Math
        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 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), new THREE.MeshPhongMaterial({ color: 0x222222 }));
            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', () => { camera.aspect = container.clientWidth/container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); updateLabels(); });
            window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
            const cvs = renderer.domElement;
            cvs.addEventListener('pointermove', onPointerMove); cvs.addEventListener('pointerdown', onPointerDown); cvs.addEventListener('pointerup', () => { isDragging = false; controls.enabled = true; });
            cvs.addEventListener('contextmenu', e => e.preventDefault());

            setupUI(); setTool('select');
            createNode(new THREE.Vector3(0, 50, 0), 0, 0); createNode(new THREE.Vector3(0, 50, -300), 0, 0); createNode(new THREE.Vector3(300, 50, -150), 45, -15);
            rebuildTrackCurve(); animate();
        }

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

        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, WALL_RADIAL_SEGS = 16, R = 18;
            
            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);
            }

            const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist);
            const roadPositions = [], roadUVs = [], roadIndices = [];

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

                // エディタでは道路のある半円(下半分: PI ~ 2PI)のみを描画
                for (let j = 0; j <= WALL_RADIAL_SEGS; j++) {
                    const thetaR = Math.PI + (j / WALL_RADIAL_SEGS) * Math.PI; 
                    const vr = pt.clone().addScaledVector(twistRight, Math.cos(thetaR)*R).addScaledVector(twistUp, Math.sin(thetaR)*R);
                    roadPositions.push(vr.x, vr.y, vr.z); roadUVs.push(j / WALL_RADIAL_SEGS, u * trackNodes.length * 4);
                }
            }

            for (let i = 0; i < SEGMENTS; i++) {
                for (let j = 0; j < WALL_RADIAL_SEGS; j++) { const a = i*(WALL_RADIAL_SEGS+1)+j, b = (i+1)*(WALL_RADIAL_SEGS+1)+j, c = (i+1)*(WALL_RADIAL_SEGS+1)+(j+1), d = i*(WALL_RADIAL_SEGS+1)+(j+1); roadIndices.push(a,b,d); roadIndices.push(b,c,d); }
            }

            const makeGeo = (pos, uv, idx) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals(); return g; };
            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; });
            
            trackSplineGroup = new THREE.Group();
            trackSplineGroup.add(new THREE.Mesh(makeGeo(roadPositions, roadUVs, roadIndices), roadMat));
            scene.add(trackSplineGroup);
        }

        function createNode(pos, roll, twist) {
            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 };
            scene.add(mesh); trackNodes.push(mesh); return mesh;
        }

        function createSceneryBlock(pos, scaleVec, colorHex, rotY, texObj) {
            const mat = new THREE.MeshStandardMaterial({ color: colorHex });
            if(texObj) getTex(texObj, t => { mat.map = t; mat.color.setHex(0xffffff); mat.needsUpdate = true; });
            const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
            mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
            mesh.userData = { type: 'scenery', subType: 'block', texObj: texObj };
            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 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) {
            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)) {
                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();
                }
                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) 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; 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 >= 2) {
                        let minDist = Infinity; let insertIdx = trackNodes.length; let pA, pB, rollA, rollB, twistA, twistB;
                        for(let i=0; i<trackNodes.length; i++) {
                            let n1 = trackNodes[i]; let n2 = trackNodes[(i+1)%trackNodes.length];
                            let dist = distanceToSegment(cursorTarget.position, n1.position, n2.position);
                            if(dist < minDist) { minDist = dist; insertIdx = i + 1; pA = n1.position; pB = n2.position; rollA = n1.userData.roll; rollB = n2.userData.roll; twistA = n1.userData.twist; twistB = n2.userData.twist; }
                        }
                        let midPos = new THREE.Vector3().addVectors(pA, pB).multiplyScalar(0.5);
                        let midRoll = lerpAngleDeg(rollA, rollB, 0.5), midTwist = lerpAngleDeg(twistA, twistB, 0.5);
                        let mesh = createNode(midPos, midRoll, midTwist);
                        trackNodes.pop(); trackNodes.splice(insertIdx, 0, mesh); rebuildTrackCurve();
                    } else { createNode(cursorTarget.position.clone(), 0, 0); rebuildTrackCurve(); }
                }
                else if(addItemType === 'scenery') createSceneryBlock(cursorTarget.position.clone(), new THREE.Vector3(1,1,1), document.getElementById('brush-color').value, 0, currentBrushTex);
                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);
                    else alert("Select a model from the list first.");
                }
            }
            else if (currentTool === 'paint') {
                if(hitItem && hitItem.userData.subType === 'block') {
                    hitItem.material.color.set(document.getElementById('brush-color').value);
                    if(currentBrushTex) { getTex(currentBrushTex, t => { hitItem.material.map = t; hitItem.material.color.setHex(0xffffff); hitItem.material.needsUpdate = true; }); }
                    else { hitItem.material.map = null; hitItem.material.needsUpdate = true; }
                    hitItem.userData.texObj = currentBrushTex; updateUIFromSelection();
                }
            }
            else if (currentTool === 'erase') { if(hitItem) deleteItem(hitItem); }
            else if (['move', 'rotate', 'scale'].includes(currentTool)) {
                if (hitItem) { selectItem(hitItem); isDragging = true; 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 onKeyDown(e) {
            if (e.target.tagName === 'INPUT') 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 === '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, 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 (e.key.toLowerCase() === 'v' && e.ctrlKey && clipboard && cursorPoint.visible) {
                if(clipboard.type === 'node') {
                    if(trackNodes.length >= 2) {
                        let minDist = Infinity; let insertIdx = trackNodes.length; let pA, pB, rollA, rollB, twistA, twistB;
                        for(let i=0; i<trackNodes.length; i++) {
                            let n1 = trackNodes[i]; let n2 = trackNodes[(i+1)%trackNodes.length];
                            let dist = distanceToSegment(cursorPoint.position, n1.position, n2.position);
                            if(dist < minDist) { minDist = dist; insertIdx = i + 1; pA = n1.position; pB = n2.position; rollA = n1.userData.roll; rollB = n2.userData.roll; twistA = n1.userData.twist; twistB = n2.userData.twist; }
                        }
                        let midPos = new THREE.Vector3().addVectors(pA, pB).multiplyScalar(0.5);
                        let mesh = createNode(midPos, clipboard.roll, clipboard.twist); trackNodes.pop(); trackNodes.splice(insertIdx, 0, mesh); rebuildTrackCurve();
                    } else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist); rebuildTrackCurve(); }
                }
                else if(clipboard.subType === 'block') createSceneryBlock(cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.color, clipboard.ry, clipboard.texObj);
                else if(clipboard.subType === 'bgmodel') createBgModelInstance(clipboard.fileObj, cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.ry);
            }
            if (e.key === 'ArrowUp') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y += gridSize; if(selectedItem.userData.type === 'node') rebuildTrackCurve(); updateUIFromSelection(); updateSelectionBox(); } 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(); updateUIFromSelection(); updateSelectionBox(); } else changeBaseHeight(-10); }
            if (e.key === 'Delete' && selectedItem) deleteItem(selectedItem);
        }
        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; document.getElementById('height-display').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) { scene.remove(mesh); if(mesh.userData.type === 'node') { trackNodes = trackNodes.filter(v => v !== mesh); rebuildTrackCurve(); } 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)) { document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').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(['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(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(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' };
            document.getElementById('mode-display').innerText = names[tool];
            if(tool === 'add' || tool === 'paint') {
                cursorTarget.visible = (tool==='add'); cursorPoint.visible = false; cursorSelect.visible = false;
                document.getElementById('prop-header').innerText = tool==='add'?"Add Settings":"Paint Brush"; document.getElementById('prop-add-settings').style.display = 'block'; document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').style.display = 'none';
            } else {
                cursorTarget.visible = false; cursorPoint.visible = true; if(selectedItem) cursorSelect.visible = true;
                document.getElementById('prop-header').innerText = "Properties"; document.getElementById('prop-add-settings').style.display = 'none';
                if(selectedItem) updateUIFromSelection(); else { document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').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 = true; 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) => {
            const dist = 300; camera.up.set(0, 1, 0); if(isLapping){ isLapping = false; setTool('select'); }
            switch(dir) { case 'default': camera.position.set(dist, dist, dist); break; case 'front': camera.position.set(0, 0, dist); break; case 'back': camera.position.set(0, 0, -dist); break; case 'left': camera.position.set(-dist, 0, 0); break; case 'right': camera.position.set(dist, 0, 0); break; case 'top': camera.position.set(0, dist, 0); break; case 'bottom': camera.position.set(0, -dist, 0); break; }
            camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update();
        };

        function setupUI() {
            ['select', 'add', 'paint', 'erase', 'move', 'rotate', 'scale', 'cam-move', 'cam-rotate', 'cam-zoom', 'cam-laps'].forEach(t => document.getElementById(`tool-${t}`).onclick = () => setTool(t));
            document.getElementById('add-item-type').addEventListener('change', (e) => { addItemType = e.target.value; document.getElementById('add-scenery-options').style.display = addItemType === 'scenery' ? 'block' : 'none'; document.getElementById('add-bgmodel-options').style.display = addItemType === 'bgmodel' ? 'block' : 'none'; });

            const bindProp = (id, objKey, axis, isDeg) => {
                document.getElementById(id).addEventListener('input', (e) => {
                    if(!selectedItem) return; let val = parseFloat(e.target.value); if(isDeg) val = THREE.MathUtils.degToRad(val);
                    if(objKey === 'roll' || objKey === 'twist') { selectedItem.userData[objKey] = val; rebuildTrackCurve(); }
                    else if(axis) { selectedItem[objKey][axis] = val; if(objKey === 'position' && selectedItem.userData.type === 'node') rebuildTrackCurve(); }
                    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();
                });
            };
            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-color', 'color');

            document.getElementById('btn-select-brush-tex').onclick = () => selectFile('.png,.jpg', 'BgBlockImage/', f => { currentBrushTex = f; document.getElementById('brush-tex-name').value = f.name; });
            document.getElementById('btn-clear-brush-tex').onclick = () => { currentBrushTex = null; document.getElementById('brush-tex-name').value = ""; };
            document.getElementById('btn-prop-select-tex').onclick = () => 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; document.getElementById('prop-tex-name').value = f.name; } });
            document.getElementById('btn-prop-clear-tex').onclick = () => { if(selectedItem && selectedItem.userData.subType === 'block') { selectedItem.material.map = null; selectedItem.userData.texObj = null; selectedItem.material.color.set(document.getElementById('prop-color').value); selectedItem.material.needsUpdate = true; document.getElementById('prop-tex-name').value = ""; } };

            document.getElementById('btn-load-model').onclick = () => selectFile('.glb,.obj', 'BgModel/', f => { document.getElementById('status-bar').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; document.getElementById('bgmodel-list').appendChild(opt); document.getElementById('status-bar').innerText = `Loaded ${f.name}`; }; if(f.name.endsWith('.obj')) objLoader.load(f.url, onload); else gltfLoader.load(f.url, g=>onload(g.scene)); });
            
            document.getElementById('menu-environment').onclick = () => { document.getElementById('env-road').value = ASSETS.env.road ? ASSETS.env.road.name : ""; document.getElementById('env-wall').value = ASSETS.env.wall ? ASSETS.env.wall.name : ""; document.getElementById('env-sky').value = ASSETS.env.sky ? ASSETS.env.sky.name : ""; document.getElementById('env-ground').value = ASSETS.env.ground ? ASSETS.env.ground.name : ""; document.getElementById('modal-overlay').style.display = 'flex'; };

            document.getElementById('menu-new').onclick = () => { if(confirm("Clear all?")) { trackNodes.forEach(n=>scene.remove(n)); sceneryItems.forEach(s=>scene.remove(s)); trackNodes=[]; sceneryItems=[]; rebuildTrackCurve(); selectItem(null); } };
            document.getElementById('menu-save').onclick = () => {
                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), blockTextures: Object.keys(ASSETS.blockTextures) },
                    track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist })),
                    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 }; 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; 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();
            };
            document.getElementById('menu-open').onclick = () => document.getElementById('file-input-json').click();
            document.getElementById('file-input-json').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 => 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; document.getElementById('bgmodel-list').appendChild(opt); };
                        if(path.endsWith('.obj')) objLoader.load(path, onload); else gltfLoader.load(path, g=>onload(g.scene));
                    });
                }
                
                ASSETS.blockTextures = {};
                if(data.assets.blockTextures) {
                    data.assets.blockTextures.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));
            
            if(data.scenery) {
                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);
                        if(s.type === 'block') 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]) createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
                    });
                    selectItem(null); 
                    document.getElementById('status-bar').innerText = "Course JSON Loaded.";
                }, 500); 
            }
        }

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

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







・ゲーム側のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Image Booster 7.1</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; font-family: 'Arial Black', sans-serif; }
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; color: white; text-shadow: 2px 2px 4px #000; z-index: 10; }
        #view-mode { position: absolute; top: 20px; right: 20px; font-size: 20px; color: #0f0; }
        #stage-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); font-size: 20px; color: #ff0; }
        #score-info { position: absolute; top: 20px; left: 20px; font-size: 24px; color: #fff; }

        #speed-lines { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle, transparent 40%, rgba(255, 255, 255, 0.1) 80%), repeating-conic-gradient(transparent 0deg, transparent 2deg, rgba(100, 255, 255, 0.3) 2.1deg, transparent 3deg); opacity: 0; transition: opacity 0.1s ease-out; pointer-events: none; z-index: 5; mix-blend-mode: screen; }

        .lock-on-sight { position: absolute; width: 40px; height: 40px; border: 3px solid #0f0; border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 10px rgba(255,255,255,0.5), inset 0 0 5px rgba(255,255,255,0.5); z-index: 8; }
        .lock-on-sight.rushing { border-width: 5px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.2); }

        #hud-bottom { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 85%; display: none; flex-direction: row; justify-content: space-around; align-items: flex-end; }
        .gauge-container { width: 30%; text-align: center; }
        .gauge-label { font-size: 18px; margin-bottom: 5px; }
        .bar-bg { width: 100%; height: 12px; border: 2px solid #fff; border-radius: 6px; overflow: hidden; background: rgba(0,0,0,0.5); transform: skewX(-20deg); }
        .bar-fill { height: 100%; width: 100%; transition: width 0.05s ease-out; }
        #hp-fill { background: #adff2f; box-shadow: 0 0 10px #adff2f; }
        #speed-val { font-size: 56px; line-height: 1; color: #00ffff; text-shadow: 0 0 15px #00ffff; font-style: italic; }
        #speed-unit { font-size: 20px; color: #aaa; margin-left: 5px; }
        #speed-fill { background: #00ffff; }
        #time-fill { background: #ffcc00; }

        #center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; pointer-events: auto; }
        h1 { font-size: 70px; margin: 0; color: #0f0; text-shadow: 0 0 30px #0f0; font-style: italic; letter-spacing: -2px; }
        p { font-size: 24px; margin: 10px 0; color: #fff; }
        .pop-text { position: absolute; font-weight: bold; font-size: 40px; animation: moveUpFade 0.8s forwards; transform: translate(-50%, -50%); z-index: 999; text-shadow: 0 0 10px white; }
        @keyframes moveUpFade { 0% { transform: translate(-50%, 0) scale(1); opacity: 1; } 100% { transform: translate(-50%, -100px) scale(1.5); opacity: 0; } }

        .title-btn { background: #007fd4; color: #fff; padding: 10px 20px; font-size: 18px; border: 2px solid #005f9e; cursor: pointer; border-radius: 5px; margin: 10px; font-family: 'Arial Black'; transition: 0.2s; }
        .title-btn:hover { background: #1177bb; box-shadow: 0 0 15px #00ffff; }
        .course-select { padding: 10px; font-size: 16px; font-family: sans-serif; background: #333; color: white; border: 1px solid #555; border-radius: 5px; margin-bottom: 15px; width: 250px; }
    </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="ui-layer">
        <div id="score-info">SCORE: 0</div><div id="stage-info">STAGE: 1</div><div id="view-mode">VIEW: 3RD PERSON</div>
        <div id="hud-bottom">
            <div class="gauge-container"><div class="gauge-label">SHIELD</div><div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div></div>
            <div class="gauge-container"><div style="margin-bottom:5px;"><span id="speed-val">0</span><span id="speed-unit">km/h</span></div><div class="bar-bg" id="speed-bg"><div id="speed-fill" class="bar-fill"></div></div></div>
            <div class="gauge-container"><div class="gauge-label">TIME: <span id="time-num" style="font-size: 32px;">1:00</span></div><div class="bar-bg" id="time-bg"><div id="time-fill" class="bar-fill"></div></div></div>
        </div>
        <div id="center-text"></div>
    </div>

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

        let vfs = {}; 
        let courseFiles = {}; 

        function handleFolderSelect(files) {
            vfs = {}; courseFiles = {};
            const status = document.getElementById('load-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 => { courseFiles[f.name] = JSON.parse(e.target.result); populateCourseSelect(); }; r.readAsText(f);
                }
            }
            status.innerText = "ASSETS READY. PRESS START TO PLAY.";
        }

        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 = 'inline-block';
        }

        function getCourseSelectUI() {
            return `
                <div style="margin-top:20px; pointer-events:auto;">
                    <label class="title-btn" style="background:#555; border-color:#333;">1. LOAD ASSETS FOLDER (OPTIONAL)<input type="file" id="assets-folder" webkitdirectory directory multiple style="display:none;"></label>
                    <div id="load-status" style="margin:10px; color:#aaa; font-size:14px;">(No custom folder loaded. Default 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" style="background:#ffaa00; border-color:#cc8800; font-size:24px;">2. START GAME (SPACE)</button>
                </div>`;
        }

        let customCourseCurve = null, customCourseLength = 0, customCourseFrames = { tangents: [], normals: [], binormals: [] }, customCourseData = null;
        let loadedModels = {}, loadedTextures = {};
        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);
            let loadedCount = 0; const total = modelsToLoad.length + texToLoad.length;
            const checkDone = () => { loadedCount++; if(loadedCount >= total) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score = 0; changeState("STAGE_START"); } };
            if(total === 0) checkDone();

            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(); }); else gltfLoader.load(url, g => { loadedModels[p] = g.scene; 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; loadedTextures[p] = t; checkDone(); });
            });
        }

        function applyEnvironment() {
            const env = customCourseData?.assets?.env || {};
            if(env.ground && loadedTextures[env.ground]) { planeGround.material.map = loadedTextures[env.ground]; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; } else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
            if(env.sky && loadedTextures[env.sky]) { if(!skyMesh) { skyMesh = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(skyMesh); } skyMesh.material.map = loadedTextures[env.sky]; skyMesh.visible = true; stars.visible = false; } else { if(skyMesh) skyMesh.visible = false; 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 => {
                    let mesh;
                    if(s.type === 'block') { const mat = new THREE.MeshStandardMaterial({ color: s.color }); 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 }); }
                });
            }
        }

        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let noiseBuffer = null, windSource = null, windGain = null, windFilter = null, jetOsc = null, jetGain = null, brakeCooldown = 0, bankSoundTimer = 0;
        function createNoiseBuffer() { const bSize = audioCtx.sampleRate * 2.0; const b = audioCtx.createBuffer(1, bSize, audioCtx.sampleRate); const d = b.getChannelData(0); for (let i = 0; i < bSize; i++) d[i] = Math.random() * 2 - 1; return b; }
        const sound = {
            init: () => { if (!noiseBuffer) noiseBuffer = createNoiseBuffer(); },
            suspend: () => { if(audioCtx.state === 'running') audioCtx.suspend(); },
            resume: () => { if(audioCtx.state === 'suspended') audioCtx.resume(); },
            startEngine: () => { if (windSource) return; windSource = audioCtx.createBufferSource(); windSource.buffer = noiseBuffer; windSource.loop = true; windFilter = audioCtx.createBiquadFilter(); windFilter.type = 'bandpass'; windFilter.Q.value = 1.0; windFilter.frequency.value = 400; windGain = audioCtx.createGain(); windGain.gain.value = 0; windSource.connect(windFilter); windFilter.connect(windGain); windGain.connect(audioCtx.destination); windSource.start(); jetOsc = audioCtx.createOscillator(); jetOsc.type = 'sawtooth'; jetOsc.frequency.value = 800; jetGain = audioCtx.createGain(); jetGain.gain.value = 0; jetOsc.connect(jetGain); jetGain.connect(audioCtx.destination); jetOsc.start(); },
            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); return; } windFilter.frequency.setTargetAtTime(200 + (sRatio * 2000), now, 0.1); windGain.gain.setTargetAtTime(Math.min(0.3, sRatio * 0.2), now, 0.1); jetGain.gain.setTargetAtTime(isB ? 0.15 : 0.02, now, 0.2); jetOsc.frequency.setTargetAtTime(600 + (sRatio * 3000), now, 0.1); },
            play: (type) => {
                if (audioCtx.state === 'suspended' && type !== 'ui') audioCtx.resume(); const now = audioCtx.currentTime;
                if (type === 'crash') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(800, now); f.frequency.exponentialRampToValueAtTime(100, now + 1.2); const g = audioCtx.createGain(); g.gain.setValueAtTime(1.0, now); g.gain.exponentialRampToValueAtTime(0.01, now + 1.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 1.2); }
                else if (type === 'brake') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(1200, now); f.frequency.linearRampToValueAtTime(400, now + 0.3); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.3, now); g.gain.linearRampToValueAtTime(0.01, now + 0.3); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.3); }
                else if (type === '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(2000, now+0.5); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.5, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.5); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.5); }
                else if (type === 'jump') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'triangle'; osc.frequency.setValueAtTime(150, now); osc.frequency.linearRampToValueAtTime(300, now+0.2); g.gain.setValueAtTime(0.8, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.3); osc.start(now); osc.stop(now+0.3); }
                else if (type === 'land') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(300, now); const g = audioCtx.createGain(); g.gain.setValueAtTime(1.5, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now+0.2); }
                else if (type === 'coin') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square'; osc.frequency.setValueAtTime(1200, now); osc.frequency.setValueAtTime(1800, now+0.05); g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1); osc.start(now); osc.stop(now+0.1); }
                else if (type === 'ui') { if(audioCtx.state === 'suspended') audioCtx.resume(); const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.frequency.setValueAtTime(880, now); g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1); osc.start(now); osc.stop(now+0.1); }
            }
        };

        const MAX_HP = 10, TUBE_R = 18, MAX_TIME = 60, NORMAL_MAX_SPEED = 350.0, RUSH_SPEED = NORMAL_MAX_SPEED * 2.0, NORMAL_ACCEL = 200.0, GRAVITY = 100.0, JUMP_POWER = 40.0, TOTAL_STAGES = 8, FOG_NEAR = 150, FOG_FAR = 900, SPEED_DISPLAY_MULTIPLIER = 0.6; 
        const TILE_SEGMENT_LENGTH = 12, TILES_PER_RING = 16, VISIBLE_SEGMENTS = 90;
        let tunnelSegments = [];
        let scene, camera, renderer, 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, wingTipL: null, wingTipR: null, mode: "NORMAL", lockTargets: [], rushIndex: 0, lockTimer: 0, currentLockType: null };
        let worldObjects = [], debris = [], stars, skyMesh, planeGround, trackSplineGroup = null, keys = {}, nextSpawnZ = 150, trails = []; 
        
        class Trail {
            constructor(color) { this.maxPoints = 60; this.points = []; this.width = 0.8; 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.5, 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; }
        }

        // Default Course Generator
        function getDefaultCurve(z, mode) {
            const coarseX = Math.sin(z * 0.0015) * 200, coarseY = Math.cos(z * 0.0015) * 150, detailY = Math.sin(z * 0.006) * 80 + Math.sin(z * 0.015) * 30, detailX = Math.cos(z * 0.004) * 60; 
            if (mode === "F2") return new THREE.Vector3(0, coarseY + detailY, z);
            return new THREE.Vector3(coarseX + detailX, coarseY + detailY, z);
        }

        function getBasis(z, mode) {
            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);
                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) };
            } else {
                const origin = getDefaultCurve(z, mode), forward = getDefaultCurve(z + 5.0, mode); 
                const T = new THREE.Vector3().subVectors(forward, origin).normalize(), R = new THREE.Vector3().crossVectors(T, new THREE.Vector3(0, 1, 0)).normalize(), U = new THREE.Vector3().crossVectors(R, T).normalize();
                return { origin, T, U, R, twistRad: 0 };
            }
        }

        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") {
                const spread = TUBE_R * 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.6, 0.6 + lightnessVar); }
        function isGap(segmentIndex) { if (segmentIndex < 15) return false; return (segmentIndex % 60) >= 56; }

        function initTunnel() {
            const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
            const boxGeo = new THREE.BoxGeometry(tileWidth * 1.1, 0.5, TILE_SEGMENT_LENGTH * 1.05);
            for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
                const segmentGroup = new THREE.Group();
                segmentGroup.userData = { zIndex: i, hue: Math.random(), lVar: (Math.random() * 0.2) - 0.1 }; 
                for (let j = 0; j < TILES_PER_RING; j++) {
                    const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, opacity: 0.1, transparent: true, side: THREE.DoubleSide, shininess: 100 });
                    const tile = new THREE.Mesh(boxGeo, mat); segmentGroup.add(tile);
                }
                tunnelSegments.push(segmentGroup); scene.add(segmentGroup);
            }
        }

        function updateTunnel() {
            if(customCourseCurve) { tunnelSegments.forEach(seg => seg.visible = false); return; } 
            
            const playerSegmentIndex = Math.floor(player.z / TILE_SEGMENT_LENGTH), startSegment = playerSegmentIndex - 8; 
            tunnelSegments.forEach((seg) => {
                let segIdx = seg.userData.zIndex;
                if (segIdx < startSegment) {
                    segIdx += VISIBLE_SEGMENTS; seg.userData.zIndex = segIdx; seg.userData.hue = Math.random(); seg.userData.lVar = (Math.random() * 0.2) - 0.1;
                    const newColor = getSegmentColor(seg.userData.hue, seg.userData.lVar);
                    seg.children.forEach(tile => { tile.material.color.copy(newColor); if(segIdx % 10 === 0) tile.material.emissive.setHex(0x222222); else tile.material.emissive.setHex(0x000000); });
                }
                const gap = isGap(segIdx); seg.visible = !gap;
                const zPos = segIdx * TILE_SEGMENT_LENGTH, basis = getBasis(zPos, viewMode);
                seg.children.forEach((tile, j) => {
                    let angle; if (viewMode === "F2") { const ratio = j / TILES_PER_RING; angle = (ratio * Math.PI * 2) - Math.PI; } else angle = (j / TILES_PER_RING) * Math.PI * 2;
                    tile.position.copy(getSectionPosition(angle, TUBE_R, basis, viewMode));
                    if (viewMode === "F2") {
                        const m = new THREE.Matrix4(); m.makeBasis(new THREE.Vector3(1, 0, 0), new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), basis.T).normalize(), new THREE.Vector3().crossVectors(new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), basis.T).normalize(), new THREE.Vector3(1, 0, 0)).normalize()); tile.rotation.setFromRotationMatrix(m);
                    } else {
                        const isOutside = (viewMode === "F4" || viewMode === "F5");
                        const normal = new THREE.Vector3().addScaledVector(basis.R, Math.sin(angle)).addScaledVector(basis.U, -Math.cos(angle)).normalize();
                        const up = isOutside ? normal : normal.clone().multiplyScalar(-1);
                        const tangent = new THREE.Vector3().crossVectors(up, basis.T).normalize();
                        const m = new THREE.Matrix4(); m.makeBasis(tangent, up, new THREE.Vector3().crossVectors(tangent, up).normalize()); tile.rotation.setFromRotationMatrix(m);
                    }
                });
            });
        }

        function init() {
            sound.init(); clock = new THREE.Clock(); scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000); scene.fog = new THREE.Fog(0x000000, FOG_NEAR, FOG_FAR);
            renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);
            camera = new THREE.PerspectiveCamera(90, window.innerWidth / window.innerHeight, 0.1, 5000);
            scene.add(new THREE.AmbientLight(0x606060, 0.8)); const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(0, 100, -50); scene.add(sun); const rim = new THREE.DirectionalLight(0x00ffff, 0.8); rim.position.set(0, -50, 50); scene.add(rim);

            planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000), new THREE.MeshPhongMaterial({ color: 0x222222 })); planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -200; scene.add(planeGround);
            const starGeo = new THREE.BufferGeometry(); const posArray = new Float32Array(5000 * 3); for(let i=0; i<5000 * 3; i++) posArray[i] = (Math.random() - 0.5) * 6000; starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); stars = new THREE.Points(starGeo, new THREE.PointsMaterial({color: 0xffffff, size: 2.0, transparent: true, opacity: 0.8})); scene.add(stars);

            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: 0xffffff, roughness: 0.4, metalness: 0.1, emissive: 0x333333, flatShading: true })));
            const cockpit = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.5, 4), new THREE.MeshPhongMaterial({ color: 0x00aaff, emissive: 0x002244, shininess: 100 })); 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);
            player.thrust = new THREE.Mesh(new THREE.ConeGeometry(0.4, 4, 8), new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.8 })); player.thrust.rotation.x = Math.PI / 2; player.thrust.position.z = -3.5; playerGeo.add(player.thrust);
            player.barrier = new THREE.Mesh(new THREE.IcosahedronGeometry(3.5, 1), new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3, wireframe: true, emissive: 0x00aaaa, shininess: 100 })); player.barrier.position.z = 0; 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.4, side: THREE.DoubleSide, blending: THREE.AdditiveBlending })); 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.0, 4.0), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.8 })); scene.add(player.shadow);
            trails.push(new Trail(0x00ffff)); trails.push(new Trail(0x00ffff)); 

            initTunnel();

            window.addEventListener('keydown', (e) => {
                if(["F1", "F2", "F3", "F4", "F5"].includes(e.code)) { 
                    e.preventDefault(); viewMode = e.code; document.getElementById('view-mode').innerText = "VIEW: " + e.code; 
                    if(customCourseData) rebuildTunnelMesh(viewMode);
                }
                keys[e.code] = true;
                if (e.code === 'Escape' && gameState === "PLAYING") { isPaused = !isPaused; if(isPaused) { sound.suspend(); document.getElementById('center-text').innerHTML = "<h1 style='color:white;'>PAUSED</h1>"; clock.stop(); } else { sound.resume(); document.getElementById('center-text').innerHTML = ""; clock.start(); } }
                if(e.code === 'Space') { 
                    e.preventDefault(); 
                    if (gameState === "TITLE" || gameState === "GAMEOVER" || gameState === "ALL_CLEAR") {
                        sound.play('ui'); sound.startEngine(); if(gameState !== "TITLE") { score = 0; currentStage = 1; }
                        const sel = document.getElementById('course-select-box');
                        if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value);
                        else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); }
                    }
                    else if (gameState === "STAGE_CLEAR") { sound.play('ui'); currentStage++; changeState("STAGE_START"); }
                    else if (gameState === "STAGE_START") { sound.play('ui'); changeState("PLAYING"); }
                }
                if(e.code === 'KeyX') { if (gameState === "PLAYING" && player.altitude <= 0.1 && !isPaused) { sound.play('jump'); player.jumpV = JUMP_POWER; player.wasAirborne = true; } }
                if(e.code === 'KeyZ' && gameState === "PLAYING" && !isPaused) { if (player.lockTargets.length > 0) { player.mode = "RUSHING"; player.rushIndex = 0; player.isBoosting = true; sound.play('boost_dash'); } else if (player.mode === "NORMAL") { player.mode = "MANUAL_BOOST"; player.isBoosting = true; sound.play('boost_dash'); } }
            });
            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; } } });
            
            changeState("TITLE"); animate();
        }

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

        function rebuildTunnelMesh(mode) {
            if (trackSplineGroup) scene.remove(trackSplineGroup); if (!customCourseData || !customCourseCurve) return;
            const SEGMENTS = customCourseData.track.length * 20, WALL_RADIAL_SEGS = 16, R = 18;
            const frames = customCourseFrames, track = customCourseData.track;
            const wallPositions = [], wallUVs = [], wallIndices = [], roadPositions = [], roadUVs = [], roadIndices = [];

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

                if (mode === "F2") {
                    const spread = R * Math.PI;
                    const roadCenter = pt.clone().addScaledVector(twistUp, -R);
                    const rLeft = roadCenter.clone().addScaledVector(twistRight, -spread/2);
                    const rRight = roadCenter.clone().addScaledVector(twistRight, spread/2);
                    roadPositions.push(rLeft.x, rLeft.y, rLeft.z); roadUVs.push(0, u * track.length * 4);
                    roadPositions.push(rRight.x, rRight.y, rRight.z); roadUVs.push(1, u * track.length * 4);
                } else {
                    for (let j = 0; j <= WALL_RADIAL_SEGS; j++) {
                        const thetaW = (j / WALL_RADIAL_SEGS) * Math.PI; 
                        const vw = pt.clone().addScaledVector(right, Math.cos(thetaW)*R).addScaledVector(up, Math.sin(thetaW)*R);
                        wallPositions.push(vw.x, vw.y, vw.z); wallUVs.push(j / WALL_RADIAL_SEGS, u * track.length * 4);
                        
                        const thetaR = Math.PI + (j / WALL_RADIAL_SEGS) * Math.PI; 
                        const vr = pt.clone().addScaledVector(twistRight, Math.cos(thetaR)*R).addScaledVector(twistUp, Math.sin(thetaR)*R);
                        roadPositions.push(vr.x, vr.y, vr.z); roadUVs.push(j / WALL_RADIAL_SEGS, u * track.length * 4);
                    }
                }
            }

            if (mode === "F2") {
                for (let i = 0; i < SEGMENTS; i++) { const a = i*2, b=(i+1)*2, c=(i+1)*2+1, d=i*2+1; roadIndices.push(a,b,d); roadIndices.push(b,c,d); }
            } else {
                for (let i = 0; i < SEGMENTS; i++) {
                    for (let j = 0; j < WALL_RADIAL_SEGS; j++) { const a = i*(WALL_RADIAL_SEGS+1)+j, b=(i+1)*(WALL_RADIAL_SEGS+1)+j, c=(i+1)*(WALL_RADIAL_SEGS+1)+(j+1), d=i*(WALL_RADIAL_SEGS+1)+(j+1); wallIndices.push(a,b,d); wallIndices.push(b,c,d); roadIndices.push(a,b,d); roadIndices.push(b,c,d); }
                }
            }
            const makeGeo = (pos, uv, idx) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals(); return g; };
            const env = customCourseData.assets?.env || {};
            const wallMat = new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide });
            if(env.wall && loadedTextures[env.wall.name]) wallMat.map = loadedTextures[env.wall.name]; else wallMat.wireframe = true;
            const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide });
            if(env.road && loadedTextures[env.road.name]) roadMat.map = loadedTextures[env.road.name];

            trackSplineGroup = new THREE.Group(); 
            if(roadPositions.length > 0) trackSplineGroup.add(new THREE.Mesh(makeGeo(roadPositions, roadUVs, roadIndices), roadMat));
            if(wallPositions.length > 0 && mode !== "F2") trackSplineGroup.add(new THREE.Mesh(makeGeo(wallPositions, wallUVs, wallIndices), wallMat)); 
            scene.add(trackSplineGroup);
        }

        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;
            if(s === "TITLE") {
                menu.innerHTML = `<h1>Image Booster 7.1.1</h1><p style='color:#adff2f;'>CUSTOM SPLINE</p>${getCourseSelectUI()}`;
                document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
                document.getElementById('start-btn').addEventListener('click', () => { 
                    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"); } 
                }); 
                populateCourseSelect();
            }
            else if(s === "STAGE_START") { stageScore = 0; menu.innerHTML = "<h1>READY?</h1>"; resetPlayerPos(); setTimeout(()=>changeState("PLAYING"), 1500); }
            else if(s === "PLAYING") menu.innerHTML = "";
            else if(s === "STAGE_CLEAR" || s === "GAMEOVER") { 
                const isClear = s === "STAGE_CLEAR"; 
                menu.innerHTML = `<h1 style='color:${isClear?"#0f0":"red"};'>${isClear?"STAGE CLEAR!":"CRITICAL FAILURE"}</h1><p>SCORE: ${score}</p>${getCourseSelectUI()}`;
                document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
                document.getElementById('start-btn').addEventListener('click', () => { 
                    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"); } 
                }); 
                populateCourseSelect();
            }
        }

        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; debris.forEach(d => scene.remove(d)); debris = []; trails.forEach(t => t.reset()); nextSpawnZ = 150; tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; }); }

        function animate() {
            requestAnimationFrame(animate); const dt = clock.getDelta();
            if (gameState === "PLAYING" && !isPaused) { const safeDt = Math.min(dt, 0.1); updatePhysics(safeDt); updateObjects(safeDt); updateDebris(safeDt); updateAutoLock(safeDt); }
            if(gameState === "PLAYING" && !isPaused) { let ratio = player.vz / NORMAL_MAX_SPEED; if(player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ratio = 3.0; sound.updateEngine(ratio, player.isBoosting); } else if (!isPaused) sound.updateEngine(0, false);
            if(!isPaused) { if(!customCourseCurve) updateTunnel(); updatePlayerVisuals(); if(stars) { stars.position.set(0, 0, player.z); stars.rotation.z += 0.0005; } }
            renderer.render(scene, camera);
        }

        function updatePhysics(dt) {
            if (player.mode === "RUSHING") { handleRushPhysics(dt); return; }
            let currentAccel = 0; const isBraking = keys['ArrowDown'];
            if (player.mode === "MANUAL_BOOST") { if (player.vz < RUSH_SPEED) player.vz += NORMAL_ACCEL * 4.0 * dt; player.surge = THREE.MathUtils.lerp(player.surge, 10.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0.8; }
            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 = 0; } 
            else { currentAccel = 0; player.surge = THREE.MathUtils.lerp(player.surge, 0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0; }
            if (isBraking && player.mode !== "MANUAL_BOOST") { player.vz -= NORMAL_ACCEL * 1.5 * dt; player.surge = THREE.MathUtils.lerp(player.surge, -3.0, 5 * dt); if (brakeCooldown <= 0) { sound.play('brake'); brakeCooldown = 0.2; } brakeCooldown -= dt; if (player.vz > 50) createDebris(player.mesh.position.clone(), 0xffaa00, 1.0, 1, "spark_brake"); } else brakeCooldown = 0;
            if (player.mode !== "MANUAL_BOOST") { if (currentAccel > 0) { if (player.vz < NORMAL_MAX_SPEED) player.vz += currentAccel * dt; else player.vz *= (1.0 - (0.5 * dt)); } else if (!isBraking) player.vz *= (1.0 - (0.2 * dt)); }
            player.vz = Math.max(0.0, player.vz); handleGravity(dt); handleSteering(dt); player.prevZ = player.z; player.z += player.vz * dt; updateGameTimers(dt); updateUI();
        }

        function updateAutoLock(dt) {
            if (player.mode === "RUSHING") return; player.lockTimer += dt; if (player.lockTimer < 0.1) return; player.lockTimer = 0;
            let candidates = worldObjects.filter(obj => { if (obj.locked || obj.isDead || obj.z < player.z || obj.z > player.z + 250 || obj.isScenery) return false; let dx = Math.abs(obj.angle - player.angle) * 10.0; return dx <= 8.0 && (!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.boxShadow = `0 0 10px ${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.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; if (targetObj.z - player.z <= rushDist) { player.prevZ = player.z; player.z = targetObj.z; player.angle = targetObj.angle; handleCollision(targetObj, true); player.surge = 15.0; player.rushIndex++; } 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, 20.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 1.0; }
            updateGameTimers(dt); updateUI();
        }

        function handleGravity(dt) { 
            const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
            const inGap = (!customCourseCurve && isGap(currentSegIdx));
            const speedKmh = player.vz * SPEED_DISPLAY_MULTIPLIER;
            const isFloating = (speedKmh > 30 && !player.wasAirborne);

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

        function handleSteering(dt) {
            let steerFactor = (player.vz > 300.0) ? 0.5 : 1.0; steerFactor *= (viewMode === "F2" ? 4.0 : 2.0); let targetBank = 0; const BANK_LIMIT = 0.78; 
            const isOutside = (viewMode === "F4" || viewMode === "F5");
            const steerDirection = isOutside ? -1 : 1; // F2は除外

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

        function updatePlayerVisuals() {
            const visualZ = player.z + player.surge + player.dashOffset, basis = getBasis(visualZ, viewMode);
            const isOutside = (viewMode === "F4" || viewMode === "F5");
            
            const r = isOutside ? TUBE_R + 2.0 + player.altitude : TUBE_R - 2.0 - player.altitude;
            const pos = getSectionPosition(player.angle, r, basis, viewMode);
            const center = basis.origin.clone();
            const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad), uAxis = basis.U.clone().applyQuaternion(qTwist);
            
            let playerUp;
            if (viewMode === "F2") { playerUp = uAxis.clone(); } 
            else { playerUp = isOutside ? new THREE.Vector3().subVectors(pos, center).normalize() : new THREE.Vector3().subVectors(center, pos).normalize(); }
            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");

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

            if (player.thrust) { if (player.vz < 1.0 && player.mode !== "RUSHING" && player.mode !== "MANUAL_BOOST") player.thrust.visible = false; else { player.thrust.visible = true; const speedRatio = player.vz / RUSH_SPEED; player.thrust.scale.set(0.5, 0.5, 0.5 + speedRatio * 1.5); player.thrust.material.opacity = 0.6 + speedRatio * 0.4; player.thrust.material.color.setHex((player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? 0xff00ff : 0x00ffff); } }
            if (player.barrier) { player.barrier.scale.setScalar(0.8 + (Math.max(0, hp / MAX_HP) * 0.2)); player.barrier.rotation.y += 0.05; if (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") { player.barrier.material.color.setHex(0xff0000); player.barrier.material.emissive.setHex(0x550000); } else { player.barrier.material.color.setHex(0x00ffff); player.barrier.material.emissive.setHex(0x004444); } }
            if (player.shadow) { if (viewMode !== "F1" && viewMode !== "F4" && (!customCourseCurve ? !isGap(Math.floor(player.z/TILE_SEGMENT_LENGTH)) : true)) { player.shadow.visible = true; const groundR = isOutside ? TUBE_R + 0.2 : TUBE_R - 0.2; player.shadow.position.copy(getSectionPosition(player.angle, groundR, basis, viewMode)); if(viewMode === "F2") { const sm = new THREE.Matrix4(); sm.makeBasis(right, orthoUp, playerForward); player.shadow.rotation.setFromRotationMatrix(sm); player.shadow.rotateX(-Math.PI/2); } else { player.shadow.lookAt(center); if(isOutside) player.shadow.rotateY(Math.PI); } player.shadow.scale.setScalar(Math.max(0.1, 1.0 - Math.max(0, player.altitude) * 0.05)); } else player.shadow.visible = false; }

            const shake = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? (Math.random()-0.5)*1.0 : (Math.random()-0.5)*0.1; const cameraUp = orthoUp.clone(); cameraUp.applyAxisAngle(playerForward, player.bank * 0.6);
            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)); } 
            else if (viewMode === "F2") { const camPos = pos.clone().addScaledVector(playerForward, -60).addScaledVector(playerUp, 50); camera.position.copy(camPos); camera.up.copy(playerUp); camera.lookAt(pos); }
            else { const camPos = pos.clone().addScaledVector(playerForward, -30).addScaledVector(orthoUp, 8); camPos.addScaledVector(right, shake); camera.position.copy(camPos); camera.up.copy(cameraUp); camera.lookAt(camPos.clone().add(playerForward)); }

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

        function updateObjects(dt) {
            let spawnDist = ((150 - (currentStage * 10.0)) / 3.0) + (player.vz * 0.5); 
            if (player.z + 1500 > nextSpawnZ) { spawnRandomObject(nextSpawnZ); nextSpawnZ += Math.random() * 50 + spawnDist; }
            for (let i = worldObjects.length - 1; i >= 0; i--) {
                const obj = worldObjects[i]; if (obj.isDead && !obj.flying) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; }
                if (obj.isScenery) continue; 
                if(obj.flying) { obj.localPos.addScaledVector(obj.vel, dt * 60); obj.mesh.rotation.x += 10 * dt; obj.mesh.rotation.y += 10 * dt; } 
                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"); 
                    worldPos = getSectionPosition(obj.angle, obj.r, basis, viewMode); 
                    const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad);
                    const uAxis = basis.U.clone().applyQuaternion(qTwist);
                    let up;
                    if(viewMode === "F2") { up = uAxis.clone(); }
                    else { const normal = new THREE.Vector3().addScaledVector(basis.R.clone().applyQuaternion(qTwist), Math.sin(obj.angle)).addScaledVector(uAxis, -Math.cos(obj.angle)).normalize(); up = isOutside ? normal : normal.clone().multiplyScalar(-1); }
                    const right = new THREE.Vector3().crossVectors(up, basis.T).normalize(); const m = new THREE.Matrix4(); m.makeBasis(right, up, new THREE.Vector3().crossVectors(right, up).normalize()); obj.mesh.rotation.setFromRotationMatrix(m); 
                }
                obj.mesh.position.copy(worldPos);
                if (player.mode !== "RUSHING" && !obj.flying && !obj.isDead) { const passedThrough = (obj.z >= player.prevZ && obj.z <= player.z); if (passedThrough || Math.abs(player.z - obj.z) < 10.0) { let dx = Math.abs(obj.angle - player.angle) * 10.0; if ((passedThrough && dx < 5.0) || player.mesh.position.distanceTo(obj.mesh.position) < 8.0) { handleCollision(obj, true); if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; } } } }
                if (obj.z < player.z - 100) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); }
            }
        }

        function spawnRandomObject(z) {
            let type = (Math.random() < 0.1) ? "score" : (Math.random() < 0.05) ? "heal" : "block"; if (Math.random() < Math.min(0.4, 0.1 + (currentStage * 0.04))) type = "hurdle";
            let xPos = (Math.random() - 0.5) * 2.4; if(customCourseCurve) xPos = (Math.floor(Math.random() * 16) / 16) * Math.PI * 2 - Math.PI;
            let length = (type === 'block') ? 3 : 1; 
            for(let i = 0; i < length; i++) { let zOffset = z + (i * 12.0), stackHeight = (type === 'block') ? Math.floor(Math.random() * 2) + 1 : 1; for (let k = 0; k < stackHeight; k++) { let geo, color; if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); color = new THREE.Color(0xff3300); } else if (type === 'heal') { geo = new THREE.BoxGeometry(2,2,2); color = new THREE.Color(0x00ff00); } else if (type === 'score') { geo = new THREE.BoxGeometry(3,3,3); color = new THREE.Color(0xffff00); } else { geo = new THREE.BoxGeometry(3,3,3); const g = 0.3+Math.random()*0.5; color = new THREE.Color(g,g,g); } const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color })); let baseR = TUBE_R + ((viewMode === "F4" || viewMode === "F5") ? ((type === 'block') ? 1.5+k*3.0 : 1.5) : -((type === 'block') ? 1.5+k*3.0 : 1.5)); scene.add(mesh); worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: xPos, r: baseR, vel: new THREE.Vector3(), localPos: new THREE.Vector3(Math.sin(xPos)*baseR, -Math.cos(xPos)*baseR, 0), locked: false, sightDom: null, isDead: false, isScenery: false }); } }
        }

        function handleCollision(obj, forcedKill = false) {
            if (obj.isDead) return; obj.isDead = true; 
            if (obj.type === "block" || obj.type === "hurdle") {
                if (forcedKill) { sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 2.0, 15, "explode"); if (obj.type === "hurdle") { hp -= 1; showPopText("BREAK! -1HP", "#ff3300"); } else { 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, obj.mesh.material.color, 1.0, 10, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*2, 5, 8); } else { score += 10; stageScore += 10; showPopText("HIT! +10", "#ffffff"); sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 5, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*4, 10, 15); } if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; } }
            } else if (obj.type === "score") { sound.play('coin'); score += 100; stageScore += 100; showPopText("+100", "#ffff00"); } else if (obj.type === "heal") { sound.play('heal'); hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff00"); }
        }

        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 })); mesh.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5)*2, (Math.random()-0.5)*2)); scene.add(mesh); let vel, life; if (type === "spark") { vel = new THREE.Vector3((Math.random()-0.5)*5, (Math.random())*5, (Math.random()-0.5)*5); life = 0.5; } else if (type === "spark_brake") { vel = new THREE.Vector3((Math.random()-0.5)*10, 5+Math.random()*10, 5+Math.random()*10); life = 0.3; } else { vel = new THREE.Vector3((Math.random()-0.5)*30, (Math.random()-0.5)*30, (Math.random()-0.5)*30); life = 1.0; } debris.push({ mesh, vel, life, type }); }
        }

        function updateDebris(dt) { for(let i = debris.length - 1; i >= 0; i--) { const d = debris[i]; d.mesh.position.addScaledVector(d.vel, dt * 20); d.life -= dt; if (d.type.includes("spark")) d.mesh.rotation.z += 10 * dt; else d.mesh.rotation.x += 5 * dt; if(d.life <= 0) { scene.remove(d.mesh); debris.splice(i, 1); } } }
        function showPopText(text, color) { const div = document.createElement('div'); div.className = 'pop-text'; div.style.color = color; div.innerText = text; div.style.left = "50%"; div.style.top = "40%"; document.getElementById('ui-layer').appendChild(div); setTimeout(() => div.remove(), 800); }

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

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
ImageBoosterのコースエディタ|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1