ImageBoosterのコースエディタ

【更新履歴】

・2026/3/6 バージョン1.0公開。
 (中略)
・2026/3/6 バージョン1.2公開。
・2026/3/7 バージョン1.3公開。

画像

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


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



《取扱説明書》


・本ツールは、「Image Booster」用のカスタムコース
 および背景オブジェクトを配置・作成するための
 ブラウザベースのエディタです。

1. 画面構成

  • メニューバー (上部): ファイルの保存/読み込み、ツールの切り替え、視点変更、環境設定(テクスチャの指定)を行います。

  • 3Dビューポート (中央): コースやオブジェクトを視覚的に確認・編集するメイン画面です。

  • プロパティパネル (右側): 選択中のツール設定や、選択したオブジェクトの座標・角度・スケール・色などの詳細情報を表示・編集します。

  • ステータス・情報表示 (左下/左上): 現在のツール、カーソルの高さ(Height)、操作のヒントなどが表示されます。


2. カメラ操作とショートカットキー

3Dビューポート内での基本的な操作です。

  • カメラ操作 (通常時)

    • 左クリック + ドラッグ: カメラの回転 (Orbit)

    • 右クリック + ドラッグ: カメラの平行移動 (Pan)

    • マウスホイール: ズームイン / ズームアウト

  • ショートカットキー

    • [ X ] / [ Y ] / [ Z ] キー (長押し): 移動・回転・スケール操作時、特定の軸方向にのみ変化を固定します(軸ロック)。

    • [ ↑ ] / [ ↓ ] (上下矢印キー):

      • オブジェクト選択時は、オブジェクトの高さをグリッド単位で上下させます。

      • 未選択時は、オブジェクトを追加する際の「ベースの高さ(Height)」を上下させます。

    • [ PageUp ] / [ PageDown ]: カメラのズームイン / ズームアウト。

    • [ Delete ]: 選択中のオブジェクト(ノード、ブロック、モデル)を削除します。

    • [ Ctrl ] + [ C ]: 選択中のオブジェクトをコピーします。

    • [ Ctrl ] + [ V ]: コピーしたオブジェクトをカーソル位置にペースト(貼り付け)します。

    • [ F1 ]: カメラを初期位置(Default)に戻します。


3. ツールの種類 (Edit メニュー)

メニューバーの「Edit」から、以下のツールを切り替えて操作します。

  • Select (選択): オブジェクトをクリックして選択し、右パネルで数値を直接編集します。

  • Add (追加): 右パネルの「Item to Add」で選んだアイテムを、クリックした位置に配置します。

  • Paint (ペイント): Scenery Block(背景ブロック)の色やテクスチャを、クリックで塗り替えます。

  • Erase (消去): クリックしたオブジェクトを削除します。

  • Move / Rotate / Scale (移動 / 回転 / スケール):

    • オブジェクトをドラッグして直感的に変形させます。

    • ドラッグ中に [X] [Y] [Z] キーを押すことで、その軸のみに変更を限定できます。

  • Camera Move / Rotate / Zoom: 左クリックのドラッグ操作を、それぞれのカメラ操作に固定します。

  • Camera Laps (周回カメラ): 選択中のオブジェクトを中心に、カメラをぐるぐると周回させながら確認できる特殊モードです。


4. アイテムの追加とプロパティ

「Add」ツールを選択すると、右パネルから以下の3種類のアイテムを選んで配置できます。

① Track Node (コースの通過点 / 緑の球体)

コースの骨組みとなるポイントです。3つ以上配置することで、自動的にそれらを結ぶ滑らかなループコース(下半分のトンネルと道路)が生成されます。

  • スマート追加機能: すでにコースがある状態で、コースの経路上(2つのノードの間)をクリックすると、自動的に両隣のノードの中間座標と中間角度が計算され、滑らかにノードが挿入されます。

  • Bank / Roll (Deg): トンネル全体(壁+道路)の傾き・ひねりを設定します。

  • Road Twist (Deg): トンネルの壁はそのままに、**「路面だけ」**の傾きを設定します。

② Scenery Block (背景ブロック)

障害物や建物のベースとなる箱型のオブジェクトです。

  • Block Color: ブロックの基本色を設定します。

  • Block Texture File: 手持ちの画像ファイル(.png / .jpg)を選択して、ブロックにテクスチャとして貼り付けることができます。(※JSONにはファイル名のみが記録されます)

③ Background Model (背景3Dモデル)

外部の3Dモデル(.glb / .obj)を背景として配置します。

  • Select Model File to Load: 初回のみこのボタンを押し、使用したい3Dモデルファイルを選択して読み込みます。

  • 読み込んだモデルは「Model List」に追加されるので、リストから選択した状態で画面をクリックすると配置できます。


5. 環境設定 (Environment Settings)

メニューバーの「Environment Settings」を開くと、コース全体に適用されるテクスチャ画像を設定できます。 それぞれの「Select」ボタンを押し、ローカルの画像ファイルを選択してください。

  • Road Texture: 道路の表面に貼られる画像です。

  • Wall Texture: トンネルの壁(U字部分)に貼られる画像です。

  • SkyDome Texture: 背景の空を覆うドーム状の画像です。指定しない場合は星空になります。

  • Ground Texture: コースの下に広がる地面の画像です。


6. ファイルの保存と読み込み (File メニュー)

  • Save Course JSON: 作成したコースデータ、配置したオブジェクト、設定した画像やモデルの「ファイル名」を、ひとつの course.json としてダウンロード(保存)します。

  • Open Course JSON: 保存した course.json を読み込んでエディタ上に復元します。

    • 【重要】セキュリティ上の仕様について: ブラウザのセキュリティ制限により、JSONを読み込んだだけでは、以前使用していた画像や3Dモデルのファイル実体を自動で読み込むことはできません。JSON読み込み後、テクスチャやモデルがグレーになっている場合は、再度各プロパティから該当するファイルを手動で選択(再リンク)してください。

  • New Course: 現在の編集内容をすべて破棄し、初期状態に戻します。


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        let cachedCurve = null, cachedFrames = null;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            cachedCurve = curve; cachedFrames = frames; 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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







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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Image Booster 7.3</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: F3</div>
        <div id="hud-bottom">
            <div class="gauge-container"><div class="gauge-label">SHIELD</div><div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div></div>
            <div class="gauge-container"><div 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 = {}, envTextures = {};
        const manager = new THREE.LoadingManager();
        manager.setURLModifier(url => { const fName = url.split('/').pop(); return vfs[fName] ? vfs[fName] : (vfs[url] ? vfs[url] : url); });
        const gltfLoader = new GLTFLoader(manager), objLoader = new OBJLoader(manager), texLoader = new THREE.TextureLoader(manager);

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

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

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

            let loadedCount = 0; const total = modelsToLoad.length + texToLoad.length;
            const checkDone = () => { loadedCount++; if(loadedCount >= total) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score = 0; 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 => {
                    if(s.type === 'gimmick') {
                        const zOffset = s.curveU * customCourseLength;
                        let type = s.gimmickType;
                        let stackHeight = 1;
                        if (type.startsWith('block')) { stackHeight = parseInt(type.replace('block', '')) || 1; type = 'block'; }
                        
                        for (let k = 0; k < stackHeight; k++) {
                            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 if (type === 'dash') { geo = new THREE.BoxGeometry(4,1,4); color = new THREE.Color(0x0088ff); } 
                            else { geo = new THREE.BoxGeometry(3,3,3); color = new THREE.Color(0xaaaaaa); } 
                            
                            const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color })); 
                            
                            let alt = 5.0; 
                            if (type === "hurdle") alt = 2.0; else if (type === "heal") alt = 1.0; else if (type === "score") alt = 1.5; else if (type === "dash") alt = 0.5;
                            if (type === 'block') alt += k * 3.0; 

                            scene.add(mesh);
                            worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: s.angle, altitude: alt, vel: new THREE.Vector3(), localPos: new THREE.Vector3(), locked: false, sightDom: null, isDead: false, isScenery: false });
                        }
                    } else if(s.type !== 'texture') {
                        let mesh;
                        if(s.type === 'block') { const mat = new THREE.MeshStandardMaterial({ color: s.color }); 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 = 36, MAX_TIME = 60, NORMAL_MAX_SPEED = 350.0, RUSH_SPEED = NORMAL_MAX_SPEED * 2.5, NORMAL_ACCEL = 200.0, GRAVITY = 100.0, JUMP_POWER = 60.0, TOTAL_STAGES = 8, FOG_NEAR = 150, FOG_FAR = 900, SPEED_DISPLAY_MULTIPLIER = 0.6; 
        const TILE_SEGMENT_LENGTH = 14, 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, rushVisualOffset: 0 };
        let worldObjects = [], debris = [], stars, skyMesh, planeGround, trackSplineGroup = null, keys = {}, nextSpawnZ = 150, trails = []; 
        let timeScale = 1.0; 

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

        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), wVal = getLoopCatmullRom(track.map(n=>n.width||36), tParam);
                const q = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
                const U = customCourseFrames.normals[fi].clone().applyQuaternion(q), R = new THREE.Vector3().crossVectors(T, U).normalize();
                return { origin, T, U, R, twistRad: THREE.MathUtils.degToRad(tDeg), width: wVal };
            } else {
                const origin = getDefaultCurve(z, mode), forward = getDefaultCurve(z + 5.0, mode); 
                const T = new THREE.Vector3().subVectors(forward, origin).normalize(), R = new THREE.Vector3().crossVectors(T, new THREE.Vector3(0, 1, 0)).normalize(), U = new THREE.Vector3().crossVectors(R, T).normalize();
                return { origin, T, U, R, twistRad: 0, width: TUBE_R };
            }
        }

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

        function getSegmentColor(randomHue, lightnessVar = 0.0) { return new THREE.Color().setHSL(randomHue, 0.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, basis.width, 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;
            const frames = customCourseFrames, track = customCourseData.track;
            const positions = [], uvs = [], indices = [];

            const geo = new THREE.BufferGeometry();
            const materials = []; const matIndexMap = {};
            const env = customCourseData.assets?.env || {};
            const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide }); 
            if(env.road && loadedTextures[env.road]) { roadMat.map = loadedTextures[env.road]; roadMat.needsUpdate = true; }
            const wallMat = new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide }); 
            if(env.wall && loadedTextures[env.wall]) { wallMat.map = loadedTextures[env.wall]; wallMat.needsUpdate = true; } else wallMat.wireframe = true;
            
            materials.push(roadMat); materials.push(wallMat);
            let nextMatIdx = 2;
            const envTexList = customCourseData.assets?.envTextures || [];
            envTexList.forEach(key => {
                const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, side: THREE.DoubleSide }); 
                if(loadedTextures[key]) { mat.map = loadedTextures[key]; mat.needsUpdate=true; }
                materials.push(mat); matIndexMap[key] = nextMatIdx++;
            });

            for (let i = 0; i <= SEGMENTS; i++) {
                const u = i / SEGMENTS, tParam = customCourseCurve.getUtoTmapping(u);
                const pt = customCourseCurve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
                const rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam), currentW = getLoopCatmullRom(track.map(n=>n.width||36), tParam);
                
                const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
                const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
                const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
                const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);

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

            let faceIdx = 0;
            const scenery = customCourseData.scenery || [];
            for (let i = 0; i < SEGMENTS; i++) {
                const segIdx = Math.floor((i / SEGMENTS) * track.length);
                const row = i % 20; 
                for (let c = 0; c < 16; c++) { 
                    if(mode === "F2" && (c < 4 || c > 11)) continue; 

                    const a = i*17+c, b = (i+1)*17+c, c_idx = (i+1)*17+(c+1), d = i*17+(c+1); 
                    indices.push(a,b,d); indices.push(b,c_idx,d); 
                    
                    let mIdx = (c >= 4 && c <= 11) ? 0 : 1; 
                    const texItem = scenery.find(s => s.type === 'texture' && s.segIdx === segIdx && s.row === row && s.col === c);
                    if (texItem && matIndexMap[texItem.texturePath]) mIdx = matIndexMap[texItem.texturePath];
                    
                    geo.addGroup(faceIdx * 6, 6, mIdx);
                    faceIdx++;
                }
            }

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

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

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

            camera.fov = THREE.MathUtils.lerp(camera.fov, (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? 130 : 90, 5 * rawDt);
            camera.updateProjectionMatrix();

            if (gameState === "PLAYING" && !isPaused) { const safeDt = Math.min(dt, 0.1); updatePhysics(safeDt); updateObjects(safeDt); updateDebris(safeDt); updateAutoLock(safeDt); }
            if(gameState === "PLAYING" && !isPaused) { let ratio = player.vz / NORMAL_MAX_SPEED; if(player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ratio = 3.0; sound.updateEngine(ratio, player.isBoosting); } else if (!isPaused) sound.updateEngine(0, false);
            if(!isPaused) { if(!customCourseCurve) updateTunnel(); 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'];
            
            player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 10 * dt);

            if (player.mode === "MANUAL_BOOST") { if (player.vz < RUSH_SPEED) player.vz += NORMAL_ACCEL * 4.0 * dt; player.surge = THREE.MathUtils.lerp(player.surge, 10.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0.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;
            if (player.lockTargets.length > 0) return;

            let candidates = worldObjects.filter(obj => { 
                if (obj.locked || obj.isDead || obj.z < player.z || obj.isScenery) return false; 
                let angleDiff = Math.abs(obj.angle - player.angle);
                while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
                return Math.abs(angleDiff) <= 3.2 && (!player.currentLockType || player.currentLockType === obj.type); 
            });
            if (candidates.length > 0) { 
                candidates.sort((a,b) => a.z - b.z); 
                const target = candidates[0]; 
                target.locked = true; 
                player.lockTargets.push(target); 
                player.currentLockType = target.type; 
                const div = document.createElement('div'); div.className = 'lock-on-sight'; let col = "#fff"; if(target.type==="block")col="#ddd"; else if(target.type==="hurdle")col="#f30"; else if(target.type==="heal")col="#0f0"; else if(target.type==="score")col="#ff0"; div.style.borderColor = col; div.style.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; 
            const distToTarget = targetObj.z - player.z;

            if (distToTarget < 250 && distToTarget > rushDist) {
                timeScale = 0.3; 
                if (distToTarget > 80) {
                    player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 120, 10 * dt);
                } else {
                    player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 20 * dt); 
                    timeScale = 0.1;
                }
            } else {
                timeScale = 1.0;
                player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 5 * dt);
            }

            if (distToTarget <= rushDist) { 
                player.prevZ = player.z; player.z = targetObj.z; player.angle = targetObj.angle; 
                handleCollision(targetObj, true); 
                player.surge = 25.0; 
                player.rushIndex++; 
                timeScale = 0.05; 
                player.rushVisualOffset = 0;
                sound.play('crash');
            } else { 
                player.prevZ = player.z; player.z += rushDist; 
                const t = 10.0 * dt; 
                player.angle = THREE.MathUtils.lerp(player.angle, targetObj.angle, t); 
                player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t); 
                player.surge = THREE.MathUtils.lerp(player.surge, 30.0, 5 * dt); 
                document.getElementById('speed-lines').style.opacity = 1.0; 
            }
            updateGameTimers(dt); updateUI();
        }

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

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

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

            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 ? basis.width + 5.0 + player.altitude : basis.width - 5.0 - player.altitude;
            const pos = getSectionPosition(player.angle, r, basis, viewMode);
            const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad), uAxis = basis.U.clone().applyQuaternion(qTwist);
            
            let playerUp;
            if (viewMode === "F2" && !customCourseCurve) { playerUp = uAxis.clone(); } 
            else { 
                const isRoad = (player.angle >= -Math.PI/2 && player.angle <= Math.PI/2);
                const rAxis = isRoad ? basis.R.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.R.clone();
                const uyAxis = isRoad ? basis.U.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.U.clone();
                const normal = new THREE.Vector3().addScaledVector(rAxis, Math.sin(player.angle)).addScaledVector(uyAxis, -Math.cos(player.angle)).normalize(); 
                playerUp = isOutside ? normal : normal.clone().negate(); 
            }
            const playerForward = basis.T.clone();

            player.mesh.position.copy(pos); const m = new THREE.Matrix4(), right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize(), orthoUp = new THREE.Vector3().crossVectors(right, playerForward).normalize();
            m.makeBasis(right, orthoUp, playerForward); player.mesh.rotation.setFromRotationMatrix(m); player.mesh.rotateZ(player.bank); if (player.altitude > 0.5) player.mesh.rotateX(-0.1);
            player.mesh.visible = (viewMode !== "F1" && viewMode !== "F4");

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

            if (player.mesh.visible) { const tipLPos = new THREE.Vector3(), tipRPos = new THREE.Vector3(); player.wingTipL.getWorldPosition(tipLPos); player.wingTipR.getWorldPosition(tipRPos); const widthVec = orthoUp.clone().normalize(); if (trails[0]) trails[0].update(tipLPos, widthVec); if (trails[1]) trails[1].update(tipRPos, widthVec); }
            if (player.sonicBoom) { player.sonicBoom.visible = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST"); if(player.sonicBoom.visible) { const s = 1.0 + Math.random() * 0.1; player.sonicBoom.scale.set(s, s, s); } }
            if (Math.abs(player.bank) > 0.7 && player.vz > 100.0) { const sideOffset = (player.bank > 0) ? 2.0 : -2.0; const sparkPos = player.mesh.position.clone().addScaledVector(right, sideOffset).addScaledVector(playerUp, -0.5); createDebris(sparkPos, 0xffff00, 0.3, 2, "spark"); }

            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; 
                    let shadowR = isOutside ? basis.width + 0.5 : basis.width - 0.5;
                    player.shadow.position.copy(getSectionPosition(player.angle, shadowR, basis, viewMode)); 
                    player.shadow.position.addScaledVector(playerForward, rushOffset);
                    
                    const sm = new THREE.Matrix4(); 
                    sm.makeBasis(right, playerUp, playerForward); 
                    player.shadow.rotation.setFromRotationMatrix(sm); 
                    player.shadow.rotateX(-Math.PI/2);
                    
                    player.shadow.scale.setScalar(Math.max(0.1, 1.0 - Math.max(0, player.altitude) * 0.02)); 
                } else {
                    player.shadow.visible = false; 
                }
            }

            const shake = (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);
            const lookTarget = player.mesh.position.clone().addScaledVector(playerForward, 30);

            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(player.mesh.position); }
            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(lookTarget); }

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

        function updateObjects(dt) {
            let spawnDist = ((150 - (currentStage * 10.0)) / 3.0) + (player.vz * 0.5); 
            if (player.z + 1500 > nextSpawnZ && !customCourseCurve) { spawnRandomObject(nextSpawnZ); nextSpawnZ += Math.random() * 50 + spawnDist; }
            for (let i = worldObjects.length - 1; i >= 0; i--) {
                const obj = worldObjects[i]; if (obj.isDead && !obj.flying) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; }
                if (obj.isScenery) continue; 
                if(obj.flying) { obj.localPos.addScaledVector(obj.vel, dt * 60); obj.mesh.rotation.x += 10 * dt; obj.mesh.rotation.y += 10 * dt; } 
                const basis = getBasis(obj.z, viewMode); let worldPos;
                if (obj.flying) worldPos = basis.origin.clone().add(obj.localPos); 
                else { 
                    const isOutside = (viewMode === "F4" || viewMode === "F5"); 
                    let objR = isOutside ? (basis.width + obj.altitude) : (basis.width - obj.altitude);
                    worldPos = getSectionPosition(obj.angle, objR, basis, viewMode); 
                    
                    let up;
                    if(viewMode === "F2" && !customCourseCurve) { 
                        up = basis.U.clone().applyAxisAngle(basis.T, basis.twistRad); 
                    } else { 
                        const isRoad = (obj.angle >= -Math.PI/2 && obj.angle <= Math.PI/2);
                        const rAxis = isRoad ? basis.R.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.R.clone();
                        const uAxis = isRoad ? basis.U.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.U.clone();
                        const normal = new THREE.Vector3().addScaledVector(rAxis, Math.sin(obj.angle)).addScaledVector(uAxis, -Math.cos(obj.angle)).normalize(); 
                        up = isOutside ? normal : normal.clone().negate(); 
                    }
                    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; 
            let length = (type === 'block') ? 3 : 1; 
            const basis = getBasis(z, viewMode);
            const currentW = basis.width;
            for(let i = 0; i < length; i++) { 
                let zOffset = z + (i * 12.0), stackHeight = (type === 'block') ? Math.floor(Math.random() * 2) + 1 : 1; 
                let baseAlt = 1.5;
                if (type === "hurdle") baseAlt = 2.0; else if (type === "heal") baseAlt = 1.0; else if (type === "score") baseAlt = 1.5;

                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 currentAlt = baseAlt + (type === 'block' ? k * 3.0 : 0);
                    scene.add(mesh); worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: xPos, altitude: currentAlt, vel: new THREE.Vector3(), localPos: new THREE.Vector3(Math.sin(xPos)*currentW, -Math.cos(xPos)*currentW, 0), locked: false, sightDom: null, isDead: false, isScenery: false }); 
                } 
            }
        }

        function handleCollision(obj, forcedKill = false) {
            if (obj.isDead) return; obj.isDead = true; 
            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"); }
            else if (obj.type === "dash") { sound.play('boost_dash'); player.surge += 15.0; showPopText("DASH!", "#0088ff"); }
        }

        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