ImageBoosterのコースエディタ

【更新履歴】

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

画像

・「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.1</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
        
        #menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e; }
        .menu-item { padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc; }
        .menu-item:hover { background: #3e3e3e; color: #fff; }
        .dropdown { display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item { padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px; }
        .dropdown-item:hover { background: #094771; color: #fff; }
        .separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }

        #container { display: flex; height: calc(100vh - 30px); }
        #canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #111; }
        
        #properties-panel { width: 280px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto; }
        .prop-group { margin-bottom: 15px; }
        .prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
        input[type="text"], input[type="number"], input[type="color"], select { width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 4px; font-size: 12px; border-radius: 2px; text-align: right; }
        select { text-align: left; cursor: pointer; }
        input:focus, select:focus { border-color: #007fd4; outline: none; }
        
        button.action-btn { width: 100%; padding: 6px; background: #333; color: #ccc; border: 1px solid #444; cursor: pointer; font-size: 12px; border-radius: 2px; transition: 0.1s; margin-bottom: 5px; }
        button.action-btn:hover { background: #444; color: #fff; }
        button.highlight { background: #0e639c; color: white; border: none; 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; }
        #height-display { background: rgba(0,0,0,0.5); color: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table; }

        .axis-label { position: absolute; font-family: monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff; }

        #modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 3000; justify-content: center; align-items: center; }
        .modal-box { background: #252526; padding: 20px; border: 1px solid #454545; width: 350px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
        .modal-box h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; margin-bottom: 15px; }
    </style>
    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
    </script>
</head>
<body>
    <div id="menubar">
        <div class="menu-item">File
            <div class="dropdown">
                <div class="dropdown-item" id="menu-new">New Course</div>
                <div class="dropdown-item" id="menu-open">Open Course JSON</div>
                <div class="dropdown-item" id="menu-save">Save Course JSON</div>
            </div>
        </div>
        <div class="menu-item">Edit
            <div class="dropdown">
                <div class="dropdown-item" id="tool-select">Select</div>
                <div class="dropdown-item" id="tool-add">Add</div>
                <div class="dropdown-item" id="tool-paint">Paint (Block Texture)</div>
                <div class="dropdown-item" id="tool-erase">Erase (Del)</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-move">Move</div>
                <div class="dropdown-item" id="tool-rotate">Rotate</div>
                <div class="dropdown-item" id="tool-scale">Scale</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-cam-move">Camera Move</div>
                <div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
                <div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
                <div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
            </div>
        </div>
        <div class="menu-item">View
            <div class="dropdown">
                <div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
                <div class="dropdown-item" onclick="viewCam('top')">Top</div>
                <div class="dropdown-item" onclick="viewCam('bottom')">Bottom</div>
                <div class="dropdown-item" onclick="viewCam('front')">Front</div>
                <div class="dropdown-item" onclick="viewCam('back')">Back</div>
                <div class="dropdown-item" onclick="viewCam('left')">Left</div>
                <div class="dropdown-item" onclick="viewCam('right')">Right</div>
            </div>
        </div>
        <div class="menu-item" id="menu-environment">Settings</div>
    </div>

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

                <div id="add-bgmodel-options" style="display:none;">
                    <div class="prop-group">
                        <span class="prop-label">Model List (Loaded)</span>
                        <select id="bgmodel-list" size="5"></select>
                    </div>
                    <button class="action-btn highlight" id="btn-load-model">Select Model File to Load</button>
                </div>
            </div>

            <div id="prop-content" style="display:none;">
                <div class="prop-group">
                    <span class="prop-label">Position (X, Y, Z)</span>
                    <div style="display:flex; gap:5px;">
                        <input type="number" id="prop-x" step="10"><input type="number" id="prop-y" step="10"><input type="number" id="prop-z" step="10">
                    </div>
                </div>
                
                <div id="prop-node-group">
                    <div class="prop-group"><span class="prop-label">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 class="prop-group" id="prop-color-group">
                        <span class="prop-label">Color</span><input type="color" id="prop-color">
                        <span class="prop-label" style="margin-top:10px;">Texture File</span>
                        <div class="file-select-row">
                            <input type="text" id="prop-tex-name" disabled placeholder="None">
                            <button class="action-btn" id="btn-prop-select-tex">Select</button>
                        </div>
                        <button class="action-btn" id="btn-prop-clear-tex">Clear</button>
                    </div>
                </div>
            </div>
            <div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">No item selected.</div>
        </div>
    </div>

    <div id="modal-overlay">
        <div class="modal-box" id="env-dialog">
            <h3>Environment Textures</h3>
            <div class="prop-group">
                <span class="prop-label">Road Texture</span>
                <div class="file-select-row"><input type="text" id="env-road" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'RoadImage/', f=>updateEnvField('env-road',f))">Select</button></div>
            </div>
            <div class="prop-group">
                <span class="prop-label">Wall Texture</span>
                <div class="file-select-row"><input type="text" id="env-wall" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'WallImage/', f=>updateEnvField('env-wall',f))">Select</button></div>
            </div>
            <div class="prop-group">
                <span class="prop-label">SkyDome Texture</span>
                <div class="file-select-row"><input type="text" id="env-sky" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'SkyDomeImage/', f=>updateEnvField('env-sky',f))">Select</button></div>
            </div>
            <div class="prop-group">
                <span class="prop-label">Ground Texture</span>
                <div class="file-select-row"><input type="text" id="env-ground" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'BgGroundImage/', f=>updateEnvField('env-ground',f))">Select</button></div>
            </div>
            <div style="text-align:right; margin-top:20px;">
                <button class="action-btn highlight" style="width:auto; padding:5px 15px; display:inline-block;" onclick="document.getElementById('modal-overlay').style.display='none'">Close & Apply</button>
            </div>
        </div>
    </div>
    <input type="file" id="file-input-json" style="display: none;" accept=".json">

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

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

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

        // Asset Management
        let ASSETS = { env: { road: null, wall: null, sky: null, ground: null }, bgModels: {}, blockTextures: {} };
        let currentBrushTex = null; 
        let envMeshes = { sky: null, stars: null };

        window.selectFile = (accept, folderPrefix, callback) => {
            const input = document.createElement('input'); input.type = 'file'; input.accept = accept;
            input.onchange = e => { const f = e.target.files[0]; if(f) callback({ name: folderPrefix + f.name, url: URL.createObjectURL(f) }); };
            input.click();
        };

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

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

        // Utils: Math
        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;
            let bestT = 0;
            const RESOLUTION = trackNodes.length * 50;
            for(let i=0; i<=RESOLUTION; i++) {
                const t = i / RESOLUTION;
                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 rolls = trackNodes.map(n => n.userData.roll);
            const twists = trackNodes.map(n => n.userData.twist);
            const widths = trackNodes.map(n => n.userData.width || 18);
            
            const u = bestT;
            const tParam = curve.getUtoTmapping(u);
            
            return {
                position: curve.getPointAt(u),
                roll: getLoopCatmullRom(rolls, tParam),
                twist: getLoopCatmullRom(twists, tParam),
                width: getLoopCatmullRom(widths, tParam),
                insertIdx: insertIdx
            };
        }

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

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

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

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

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

            window.addEventListener('resize', () => { camera.aspect = container.clientWidth/container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); updateLabels(); });
            window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
            const cvs = renderer.domElement;
            cvs.addEventListener('pointermove', onPointerMove); cvs.addEventListener('pointerdown', onPointerDown); cvs.addEventListener('pointerup', () => { isDragging = false; controls.enabled = true; });
            cvs.addEventListener('contextmenu', e => e.preventDefault());

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

        function initCursors() {
            cursorTarget = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })); cursorTarget.rotation.x = -Math.PI / 2; scene.add(cursorTarget);
            cursorPoint = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 })); scene.add(cursorPoint);
            cursorSelect = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false })); cursorSelect.visible = false; scene.add(cursorSelect);
        }

        function initShadowPool() { for(let i=0; i<6; i++) { const s = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(1, 1)), new THREE.LineBasicMaterial({ color: 0xffffff })); shadowPool.push(s); guideLineGroup.add(s); } }

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

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

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

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

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

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

            for (let i = 0; i <= SEGMENTS; i++) {
                const u = i / SEGMENTS, tParam = curve.getUtoTmapping(u);
                const pt = curve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
                
                const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), 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);

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

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

            const makeGeo = (pos, uv, idx) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals(); return g; };
            const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide });
            if(ASSETS.env.road) getTex(ASSETS.env.road, t => { roadMat.map = t; roadMat.needsUpdate = true; });
            
            trackSplineGroup = new THREE.Group();
            trackSplineGroup.add(new THREE.Mesh(makeGeo(roadPositions, roadUVs, roadIndices), roadMat));
            scene.add(trackSplineGroup);
        }

        function createNode(pos, roll, twist, width = 18) {
            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) {
            const mat = new THREE.MeshStandardMaterial({ color: colorHex });
            if(texObj) getTex(texObj, t => { mat.map = t; mat.color.setHex(0xffffff); mat.needsUpdate = true; });
            const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
            mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
            mesh.userData = { type: 'scenery', subType: 'block', texObj: texObj };
            scene.add(mesh); sceneryItems.push(mesh); return mesh;
        }

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

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

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

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

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

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

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

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

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

            if (currentTool === 'select') selectItem(hitItem);
            else if (currentTool === 'add') {
                if(addItemType === 'node') {
                    // 自動スナップ追加機能
                    if(trackNodes.length >= 3) {
                        const interp = getSplineInterpolation(cursorTarget.position);
                        if(interp) {
                            let mesh = createNode(interp.position, interp.roll, interp.twist, interp.width);
                            trackNodes.pop(); // createNodeで末尾に入るので取り除いて、正しい位置に挿入
                            trackNodes.splice(interp.insertIdx, 0, mesh);
                            rebuildTrackCurve();
                        } else {
                            createNode(cursorTarget.position.clone(), 0, 0, 18); rebuildTrackCurve();
                        }
                    } else { 
                        createNode(cursorTarget.position.clone(), 0, 0, 18); rebuildTrackCurve(); 
                    }
                }
                else if(addItemType === 'scenery') createSceneryBlock(cursorTarget.position.clone(), new THREE.Vector3(1,1,1), document.getElementById('brush-color').value, 0, currentBrushTex);
                else if(addItemType === 'bgmodel') {
                    const sel = document.getElementById('bgmodel-list').value;
                    if(sel && ASSETS.bgModels[sel]) createBgModelInstance(ASSETS.bgModels[sel].fileObj, cursorTarget.position.clone(), new THREE.Vector3(1,1,1), 0);
                    else alert("Select a model from the list first.");
                }
            }
            else if (currentTool === 'paint') {
                if(hitItem && hitItem.userData.subType === 'block') {
                    hitItem.material.color.set(document.getElementById('brush-color').value);
                    if(currentBrushTex) { getTex(currentBrushTex, t => { hitItem.material.map = t; hitItem.material.color.setHex(0xffffff); hitItem.material.needsUpdate = true; }); }
                    else { hitItem.material.map = null; hitItem.material.needsUpdate = true; }
                    hitItem.userData.texObj = currentBrushTex; updateUIFromSelection();
                }
            }
            else if (currentTool === 'erase') { if(hitItem) deleteItem(hitItem); }
            else if (['move', 'rotate', 'scale'].includes(currentTool)) {
                if (hitItem) { selectItem(hitItem); isDragging = true; controls.enabled = false; dragStartMouse.set(e.clientX, e.clientY); if(currentTool==='move') dragStartVal.copy(hitItem.position); if(currentTool==='rotate') dragStartVal.set(0, hitItem.rotation.y, 0); if(currentTool==='scale') dragStartVal.copy(hitItem.scale); }
                else selectItem(null);
            }
        }

        function onKeyDown(e) {
            if (e.target.tagName === 'INPUT') return;
            if(e.key.toLowerCase() === 'x') axisLock.x = true; if(e.key.toLowerCase() === 'y') axisLock.y = true; if(e.key.toLowerCase() === 'z') axisLock.z = true;
            if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
            if (e.key === 'PageUp') { e.preventDefault(); camera.zoom = Math.min(camera.zoom + 0.1, 5); camera.updateProjectionMatrix(); }
            if (e.key === 'PageDown') { e.preventDefault(); camera.zoom = Math.max(camera.zoom - 0.1, 0.1); camera.updateProjectionMatrix(); }
            if (e.key.toLowerCase() === 'c' && e.ctrlKey && selectedItem) {
                clipboard = { type: selectedItem.userData.type, subType: selectedItem.userData.subType, roll: selectedItem.userData.roll, twist: selectedItem.userData.twist, 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 (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); rebuildTrackCurve();
                        } else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist, clipboard.width); rebuildTrackCurve(); }
                    } else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist, clipboard.width); rebuildTrackCurve(); }
                }
                else if(clipboard.subType === 'block') createSceneryBlock(cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.color, clipboard.ry, clipboard.texObj);
                else if(clipboard.subType === 'bgmodel') createBgModelInstance(clipboard.fileObj, cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.ry);
            }
            if (e.key === 'ArrowUp') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y += gridSize; if(selectedItem.userData.type === 'node') rebuildTrackCurve(); updateUIFromSelection(); updateSelectionBox(); } else changeBaseHeight(10); }
            if (e.key === 'ArrowDown') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y -= gridSize; if(selectedItem.userData.type === 'node') rebuildTrackCurve(); updateUIFromSelection(); updateSelectionBox(); } else changeBaseHeight(-10); }
            if (e.key === 'Delete' && selectedItem) deleteItem(selectedItem);
        }
        function onKeyUp(e) { if(e.key.toLowerCase() === 'x') axisLock.x = false; if(e.key.toLowerCase() === 'y') axisLock.y = false; if(e.key.toLowerCase() === 'z') axisLock.z = false; }

        function changeBaseHeight(dir) { baseHeight += dir; document.getElementById('height-display').textContent = `Height: ${baseHeight}`; const rect = renderer.domElement.getBoundingClientRect(); onPointerMove({ clientX: rect.left + (mouse.x+1)/2*rect.width, clientY: rect.top - (mouse.y-1)/2*rect.height }); }
        function deleteItem(mesh) { scene.remove(mesh); if(mesh.userData.type === 'node') { trackNodes = trackNodes.filter(v => v !== mesh); rebuildTrackCurve(); } else sceneryItems = sceneryItems.filter(v => v !== mesh); if (selectedItem === mesh) selectItem(null); }
        function selectItem(mesh) { selectedItem = mesh; if (mesh) { if(!['add','paint'].includes(currentTool)) cursorSelect.visible = true; updateSelectionBox(); updateUIFromSelection(); } else { cursorSelect.visible = false; if (!['add','paint'].includes(currentTool)) { document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').style.display = 'block'; } } }
        function updateSelectionBox() { if(!selectedItem) return; cursorSelect.position.copy(selectedItem.position); cursorSelect.rotation.copy(selectedItem.rotation); if(selectedItem.userData.type === 'node') cursorSelect.scale.set(1.5,1.5,1.5); else { const box = new THREE.Box3().setFromObject(selectedItem); const size = box.getSize(new THREE.Vector3()); cursorSelect.scale.set(size.x/10 * 1.05, size.y/10 * 1.05, size.z/10 * 1.05); } }

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

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

        function animate() {
            requestAnimationFrame(animate); 
            if(isLapping) { lapsQ.slerp(lapsTargetQ, 0.1); const v = new THREE.Vector3(0, 0, lapsRadius).applyQuaternion(lapsQ); camera.position.copy(v.add(lapsCenter)); camera.lookAt(lapsCenter); controls.target.copy(lapsCenter); controls.update(); }
            else {
                if(currentTool === 'cam-rotate') { controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI; controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity; if(isDragging) { if(axisLock.x) controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar; else if(axisLock.y||axisLock.z) controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth; } }
                else if(currentTool === 'cam-move' && isDragging) { if(axisLock.x) { camera.position.y=dragStartCamPos.y; camera.position.z=dragStartCamPos.z; controls.target.y=dragStartCamTarget.y; controls.target.z=dragStartCamTarget.z; } if(axisLock.y) { camera.position.x=dragStartCamPos.x; camera.position.z=dragStartCamPos.z; controls.target.x=dragStartCamTarget.x; controls.target.z=dragStartCamTarget.z; } if(axisLock.z) { camera.position.x=dragStartCamPos.x; camera.position.y=dragStartCamPos.y; controls.target.x=dragStartCamTarget.x; controls.target.y=dragStartCamTarget.y; } }
                controls.update();
            }
            if(cursorTarget) cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(Date.now() * 0.005 * 8);
            updateGuides(); updateLabels(); renderer.render(scene, camera);
        }

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

        window.viewCam = (dir) => {
            const dist = 300; camera.up.set(0, 1, 0); if(isLapping){ isLapping = false; setTool('select'); }
            switch(dir) { case 'default': camera.position.set(dist, dist, dist); break; case 'front': camera.position.set(0, 0, dist); break; case 'back': camera.position.set(0, 0, -dist); break; case 'left': camera.position.set(-dist, 0, 0); break; case 'right': camera.position.set(dist, 0, 0); break; case 'top': camera.position.set(0, dist, 0); break; case 'bottom': camera.position.set(0, -dist, 0); break; }
            camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update();
        };

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

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

            document.getElementById('btn-smooth-node').onclick = () => {
                if(!selectedItem || selectedItem.userData.type !== 'node') return;
                const idx = trackNodes.indexOf(selectedItem); 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); const twists = tempNodes.map(n => n.userData.twist); const widths = tempNodes.map(n => n.userData.width || 18);
                
                selectedItem.position.copy(curve.getPointAt(u));
                selectedItem.userData.roll = getLoopCatmullRom(rolls, tParam);
                selectedItem.userData.twist = getLoopCatmullRom(twists, tParam);
                selectedItem.userData.width = getLoopCatmullRom(widths, tParam);
                
                updateUIFromSelection(); updateSelectionBox(); rebuildTrackCurve();
            };

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

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

            document.getElementById('menu-new').onclick = () => { if(confirm("Clear all?")) { trackNodes.forEach(n=>scene.remove(n)); sceneryItems.forEach(s=>scene.remove(s)); trackNodes=[]; sceneryItems=[]; rebuildTrackCurve(); selectItem(null); } };
            document.getElementById('menu-save').onclick = () => {
                const data = {
                    assets: { env: { road: ASSETS.env.road?.name, wall: ASSETS.env.wall?.name, sky: ASSETS.env.sky?.name, ground: ASSETS.env.ground?.name }, bgModels: Object.keys(ASSETS.bgModels), blockTextures: Object.keys(ASSETS.blockTextures) },
                    track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist, width: n.userData.width||18 })),
                    scenery: sceneryItems.map(s => { const base = { type: s.userData.subType, x: s.position.x, y: s.position.y, z: s.position.z, sx: s.scale.x, sy: s.scale.y, sz: s.scale.z, ry: s.rotation.y }; if(s.userData.subType === 'block') { base.color = s.material.color.getHex(); base.texture = s.userData.texObj?.name; } if(s.userData.subType === 'bgmodel') base.modelName = s.userData.fileObj?.name; return base; })
                };
                const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data)], {type:'application/json'})); link.download = 'course.json'; link.click();
            };
            document.getElementById('menu-open').onclick = () => document.getElementById('file-input-json').click();
            document.getElementById('file-input-json').onchange = (e) => { const f = e.target.files[0]; if(!f) return; const r = new FileReader(); r.onload = ev => loadCourseData(JSON.parse(ev.target.result)); r.readAsText(f); };
        }

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

            if(data.track) data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll, n.twist, n.width));
            
            if(data.scenery) {
                setTimeout(() => {
                    data.scenery.forEach(s => {
                        const pos = new THREE.Vector3(s.x, s.y, s.z), scl = new THREE.Vector3(s.sx, s.sy, s.sz);
                        if(s.type === 'block') createSceneryBlock(pos, scl, s.color, s.ry, s.texture ? {name: s.texture, url: s.texture} : null);
                        else if(s.type === 'bgmodel' && ASSETS.bgModels[s.modelName]) createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
                    });
                    selectItem(null); 
                    document.getElementById('status-bar').innerText = "Course JSON Loaded.";
                }, 500); 
            }
        }

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

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







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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            document.getElementById('center-text').innerHTML = "<h2 style='color:#fff;'>LOADING SCENE...</h2>";
            let modelsToLoad = data.assets?.bgModels || [], texToLoad = [ ...(data.assets?.blockTextures || []), data.assets?.env?.road, data.assets?.env?.wall, data.assets?.env?.sky, data.assets?.env?.ground ].filter(Boolean);
            let loadedCount = 0; const total = modelsToLoad.length + texToLoad.length;
            const checkDone = () => { loadedCount++; if(loadedCount >= total) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score = 0; changeState("STAGE_START"); } };
            if(total === 0) checkDone();

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

        function applyEnvironment() {
            const env = customCourseData?.assets?.env || {};
            if(env.ground && loadedTextures[env.ground]) { planeGround.material.map = loadedTextures[env.ground]; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; } else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
            if(env.sky && loadedTextures[env.sky]) { if(!skyMesh) { skyMesh = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(skyMesh); } skyMesh.material.map = loadedTextures[env.sky]; skyMesh.visible = true; stars.visible = false; } else { if(skyMesh) skyMesh.visible = false; stars.visible = true; }
            rebuildTunnelMesh(viewMode);
        }

        function setupCustomScenery() {
            worldObjects.forEach(obj => { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }); worldObjects = [];
            if (customCourseData.scenery) {
                customCourseData.scenery.forEach(s => {
                    let mesh;
                    if(s.type === 'block') { const mat = new THREE.MeshStandardMaterial({ color: s.color }); if(s.texture && loadedTextures[s.texture]) { mat.map = loadedTextures[s.texture]; mat.color.setHex(0xffffff); } mesh = new THREE.Mesh(new THREE.BoxGeometry(10,10,10), mat); }
                    else if(s.type === 'bgmodel' && loadedModels[s.modelName]) { mesh = loadedModels[s.modelName].clone(); }
                    if(mesh) { mesh.position.set(s.x, s.y, s.z); mesh.scale.set(s.sx, s.sy, s.sz); mesh.rotation.y = s.ry || 0; scene.add(mesh); worldObjects.push({ mesh, type: s.type, flying: false, z: 0, isScenery: true, locked: false, isDead: false, sightDom: null }); }
                });
            }
        }

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

        const MAX_HP = 10, TUBE_R = 18, MAX_TIME = 60, NORMAL_MAX_SPEED = 350.0, RUSH_SPEED = NORMAL_MAX_SPEED * 2.0, NORMAL_ACCEL = 200.0, GRAVITY = 100.0, JUMP_POWER = 40.0, TOTAL_STAGES = 8, FOG_NEAR = 150, FOG_FAR = 900, SPEED_DISPLAY_MULTIPLIER = 0.6; 
        const TILE_SEGMENT_LENGTH = 12, TILES_PER_RING = 16, VISIBLE_SEGMENTS = 90;
        let tunnelSegments = [];
        let scene, camera, renderer, clock, gameState = "TITLE", viewMode = "F3", isPaused = false, score = 0, stageScore = 0, hp = MAX_HP, timeLeft = MAX_TIME, currentStage = 1;
        let player = { mesh: null, angle: 0, x: 0, z: 0, prevZ: 0, vz: 10, vAngle: 0, vx: 0, altitude: 0, jumpV: 0, surge: 0, bank: 0, isBoosting: false, dashOffset: 0, wasAirborne: false, barrier: null, shadow: null, sonicBoom: null, thrust: null, wingTipL: null, wingTipR: null, mode: "NORMAL", lockTargets: [], rushIndex: 0, lockTimer: 0, currentLockType: null };
        let worldObjects = [], debris = [], stars, skyMesh, planeGround, trackSplineGroup = null, keys = {}, nextSpawnZ = 150, trails = []; 
        
        class Trail {
            constructor(color) { this.maxPoints = 60; this.points = []; this.width = 0.8; const geometry = new THREE.BufferGeometry(); const pos = new Float32Array(this.maxPoints * 2 * 3); geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const indices = []; for(let i=0; i<this.maxPoints-1; i++) { const base = i*2; indices.push(base, base+1, base+2, base+1, base+3, base+2); } geometry.setIndex(indices); const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }); this.mesh = new THREE.Mesh(geometry, material); this.mesh.frustumCulled = false; scene.add(this.mesh); }
            update(newPos, rightVec) { const p1 = newPos.clone().addScaledVector(rightVec, this.width * 0.5), p2 = newPos.clone().addScaledVector(rightVec, -this.width * 0.5); this.points.unshift({p1, p2}); if(this.points.length > this.maxPoints) this.points.pop(); const positions = this.mesh.geometry.attributes.position.array; let idx = 0; for(let i=0; i<this.points.length; i++) { const pt = this.points[i]; positions[idx++] = pt.p1.x; positions[idx++] = pt.p1.y; positions[idx++] = pt.p1.z; positions[idx++] = pt.p2.x; positions[idx++] = pt.p2.y; positions[idx++] = pt.p2.z; } const last = this.points[this.points.length-1]; while(idx < positions.length) { positions[idx++] = last.p1.x; positions[idx++] = last.p1.y; positions[idx++] = last.p1.z; positions[idx++] = last.p2.x; positions[idx++] = last.p2.y; positions[idx++] = last.p2.z; } this.mesh.geometry.attributes.position.needsUpdate = true; }
            reset() { this.points = []; const positions = this.mesh.geometry.attributes.position.array; for(let i=0; i<positions.length; i++) positions[i] = 0; this.mesh.geometry.attributes.position.needsUpdate = true; }
        }

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

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

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

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

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

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

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

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

            const playerGeo = new THREE.Group();
            const stealthGeo = new THREE.BufferGeometry(); const vertices = new Float32Array([0,0.5,3.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.5,3.0, 4.5,0,-2.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.8,-0.5, 0,0.5,-3.0, 0,0.8,-0.5, 4.5,0,-2.0, 0,0.5,-3.0, 0,0,3.0, -4.5,0,-2.0, 0,-0.3,-0.5, 0,0,3.0, 0,-0.3,-0.5, 4.5,0,-2.0, -4.5,0,-2.0, 0,0.5,-3.0, 0,-0.3,-0.5, 4.5,0,-2.0, 0,-0.3,-0.5, 0,0.5,-3.0]); stealthGeo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); stealthGeo.computeVertexNormals();
            playerGeo.add(new THREE.Mesh(stealthGeo, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.4, metalness: 0.1, emissive: 0x333333, flatShading: true })));
            const cockpit = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.5, 4), new THREE.MeshPhongMaterial({ color: 0x00aaff, emissive: 0x002244, shininess: 100 })); cockpit.rotation.x = -Math.PI * 0.4; cockpit.position.set(0, 0.6, 0.5); cockpit.scale.z = 0.5; playerGeo.add(cockpit);
            player.wingTipL = new THREE.Object3D(); player.wingTipL.position.set(-4.5, 0, -2.0); player.wingTipR = new THREE.Object3D(); player.wingTipR.position.set(4.5, 0, -2.0); playerGeo.add(player.wingTipL); playerGeo.add(player.wingTipR);
            player.thrust = new THREE.Mesh(new THREE.ConeGeometry(0.4, 4, 8), new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.8 })); player.thrust.rotation.x = Math.PI / 2; player.thrust.position.z = -3.5; playerGeo.add(player.thrust);
            player.barrier = new THREE.Mesh(new THREE.IcosahedronGeometry(3.5, 1), new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3, wireframe: true, emissive: 0x00aaaa, shininess: 100 })); player.barrier.position.z = 0; playerGeo.add(player.barrier);
            player.sonicBoom = new THREE.Mesh(new THREE.SphereGeometry(5.0, 32, 16, 0, Math.PI * 2, 0, Math.PI * 0.5), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4, side: THREE.DoubleSide, blending: THREE.AdditiveBlending })); player.sonicBoom.rotation.x = -Math.PI / 2; player.sonicBoom.position.z = 2.0; player.sonicBoom.visible = false; playerGeo.add(player.sonicBoom);
            player.mesh = playerGeo; scene.add(player.mesh);
            player.shadow = new THREE.Mesh(new THREE.PlaneGeometry(4.0, 4.0), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.8 })); scene.add(player.shadow);
            trails.push(new Trail(0x00ffff)); trails.push(new Trail(0x00ffff)); 

            initTunnel();

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

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

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

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

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

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

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

        function changeState(s) {
            gameState = s; document.getElementById('hud-bottom').style.display = (s === "PLAYING") ? "flex" : "none";
            const menu = document.getElementById('center-text'); document.getElementById('speed-lines').style.opacity = 0;
            if(s === "TITLE") {
                menu.innerHTML = `<h1>Image Booster 7.1.1</h1><p style='color:#adff2f;'>CUSTOM SPLINE</p>${getCourseSelectUI()}`;
                document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
                document.getElementById('start-btn').addEventListener('click', () => { 
                    const sel = document.getElementById('course-select-box'); 
                    if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value); 
                    else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); } 
                }); 
                populateCourseSelect();
            }
            else if(s === "STAGE_START") { stageScore = 0; menu.innerHTML = "<h1>READY?</h1>"; resetPlayerPos(); setTimeout(()=>changeState("PLAYING"), 1500); }
            else if(s === "PLAYING") menu.innerHTML = "";
            else if(s === "STAGE_CLEAR" || s === "GAMEOVER") { 
                const isClear = s === "STAGE_CLEAR"; 
                menu.innerHTML = `<h1 style='color:${isClear?"#0f0":"red"};'>${isClear?"STAGE CLEAR!":"CRITICAL FAILURE"}</h1><p>SCORE: ${score}</p>${getCourseSelectUI()}`;
                document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
                document.getElementById('start-btn').addEventListener('click', () => { 
                    const sel = document.getElementById('course-select-box'); 
                    if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value); 
                    else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); } 
                }); 
                populateCourseSelect();
            }
        }

        function resetPlayerPos() { player.angle = 0; player.x = 0; player.z = 0; player.prevZ = 0; player.vz = 0.0; player.altitude = 0; player.jumpV = 0; player.surge = 0; player.bank = 0; player.dashOffset = 0; hp = MAX_HP; timeLeft = MAX_TIME; player.mode = "NORMAL"; clearAllLocks(); player.isBoosting = false; debris.forEach(d => scene.remove(d)); debris = []; trails.forEach(t => t.reset()); nextSpawnZ = 150; tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; }); }

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

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

        function updateAutoLock(dt) {
            if (player.mode === "RUSHING") return; player.lockTimer += dt; if (player.lockTimer < 0.1) return; player.lockTimer = 0;
            let candidates = worldObjects.filter(obj => { if (obj.locked || obj.isDead || obj.z < player.z || obj.z > player.z + 250 || obj.isScenery) return false; let dx = Math.abs(obj.angle - player.angle) * 10.0; return dx <= 8.0 && (!player.currentLockType || player.currentLockType === obj.type); });
            if (candidates.length > 0) { candidates.sort((a,b) => a.z - b.z); const target = candidates[0]; target.locked = true; player.lockTargets.push(target); player.currentLockType = target.type; const div = document.createElement('div'); div.className = 'lock-on-sight'; let col = "#fff"; if(target.type==="block")col="#ddd"; else if(target.type==="hurdle")col="#f30"; else if(target.type==="heal")col="#0f0"; else if(target.type==="score")col="#ff0"; div.style.borderColor = col; div.style.boxShadow = `0 0 10px ${col}, inset 0 0 10px ${col}`; document.getElementById('ui-layer').appendChild(div); target.sightDom = div; sound.play('lockon'); }
        }

        function handleRushPhysics(dt) {
            player.lockTargets = player.lockTargets.filter(t => !t.isDead);
            if (player.lockTargets.length === 0 || player.rushIndex >= player.lockTargets.length) { if (keys['KeyZ']) player.mode = "MANUAL_BOOST"; else { player.mode = "NORMAL"; player.isBoosting = false; } player.surge = 0; clearAllLocks(); return; }
            const targetObj = player.lockTargets[player.rushIndex]; if (!worldObjects.includes(targetObj) || targetObj.isDead) { player.rushIndex++; return; }
            const rushDist = RUSH_SPEED * dt; if (targetObj.z - player.z <= rushDist) { player.prevZ = player.z; player.z = targetObj.z; player.angle = targetObj.angle; handleCollision(targetObj, true); player.surge = 15.0; player.rushIndex++; } else { player.prevZ = player.z; player.z += rushDist; const t = 10.0 * dt; player.angle = THREE.MathUtils.lerp(player.angle, targetObj.angle, t); player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t); player.surge = THREE.MathUtils.lerp(player.surge, 20.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 1.0; }
            updateGameTimers(dt); updateUI();
        }

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

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

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

            if (keys['ArrowLeft']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, -1.5 * steerFactor * steerDirection, 5 * dt); targetBank = -BANK_LIMIT; }
            else if (keys['ArrowRight']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, 1.5 * steerFactor * steerDirection, 5 * dt); targetBank = BANK_LIMIT; }
            else player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0, 10 * dt); 
            
            player.angle += player.vAngle * dt; player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 5 * dt);
            if (viewMode === "F2") { const limit = Math.PI * 1.0; if (player.angle > limit) { player.angle = limit; player.vAngle = 0; } else if (player.angle < -limit) { player.angle = -limit; player.vAngle = 0; } } 
            if (Math.abs(player.bank) > (BANK_LIMIT * 0.95)) { if(bankSoundTimer <= 0) { sound.play('scrape'); bankSoundTimer = 0.2; } bankSoundTimer -= dt; createDebris(player.mesh.position.clone(), 0xffaa00, 0.5, 1, "spark"); } else bankSoundTimer = 0;
        }

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

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

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

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

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

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

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

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

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

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

        function createDebris(pos, color, size, count, type) {
            for(let i=0; i<count; i++) { const s = (type.includes("spark")) ? size * (Math.random()*0.5 + 0.5) : size; const mesh = new THREE.Mesh(new THREE.BoxGeometry(s, s, s), new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0 })); mesh.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5)*2, (Math.random()-0.5)*2)); scene.add(mesh); let vel, life; if (type === "spark") { vel = new THREE.Vector3((Math.random()-0.5)*5, (Math.random())*5, (Math.random()-0.5)*5); life = 0.5; } else if (type === "spark_brake") { vel = new THREE.Vector3((Math.random()-0.5)*10, 5+Math.random()*10, 5+Math.random()*10); life = 0.3; } else { vel = new THREE.Vector3((Math.random()-0.5)*30, (Math.random()-0.5)*30, (Math.random()-0.5)*30); life = 1.0; } debris.push({ mesh, vel, life, type }); }
        }

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

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

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

コメント

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

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