ImageBoosterのコースエディタ
【更新履歴】
・2026/3/6 バージョン1.0公開。
(中略)
・2026/3/6 バージョン1.2公開。
・2026/3/7 バージョン1.3公開。
・「Image Booster」についてはこちら。↓
・ダウンロードされる方はこちら。↓
《取扱説明書》
・本ツールは、「Image Booster」用のカスタムコース
および背景オブジェクトを配置・作成するための
ブラウザベースのエディタです。
1. 画面構成
メニューバー (上部): ファイルの保存/読み込み、ツールの切り替え、視点変更、環境設定(テクスチャの指定)を行います。
3Dビューポート (中央): コースやオブジェクトを視覚的に確認・編集するメイン画面です。
プロパティパネル (右側): 選択中のツール設定や、選択したオブジェクトの座標・角度・スケール・色などの詳細情報を表示・編集します。
ステータス・情報表示 (左下/左上): 現在のツール、カーソルの高さ(Height)、操作のヒントなどが表示されます。
2. カメラ操作とショートカットキー
3Dビューポート内での基本的な操作です。
カメラ操作 (通常時)
左クリック + ドラッグ: カメラの回転 (Orbit)
右クリック + ドラッグ: カメラの平行移動 (Pan)
マウスホイール: ズームイン / ズームアウト
ショートカットキー
[ X ] / [ Y ] / [ Z ] キー (長押し): 移動・回転・スケール操作時、特定の軸方向にのみ変化を固定します(軸ロック)。
[ ↑ ] / [ ↓ ] (上下矢印キー):
オブジェクト選択時は、オブジェクトの高さをグリッド単位で上下させます。
未選択時は、オブジェクトを追加する際の「ベースの高さ(Height)」を上下させます。
[ PageUp ] / [ PageDown ]: カメラのズームイン / ズームアウト。
[ Delete ]: 選択中のオブジェクト(ノード、ブロック、モデル)を削除します。
[ Ctrl ] + [ C ]: 選択中のオブジェクトをコピーします。
[ Ctrl ] + [ V ]: コピーしたオブジェクトをカーソル位置にペースト(貼り付け)します。
[ F1 ]: カメラを初期位置(Default)に戻します。
3. ツールの種類 (Edit メニュー)
メニューバーの「Edit」から、以下のツールを切り替えて操作します。
Select (選択): オブジェクトをクリックして選択し、右パネルで数値を直接編集します。
Add (追加): 右パネルの「Item to Add」で選んだアイテムを、クリックした位置に配置します。
Paint (ペイント): Scenery Block(背景ブロック)の色やテクスチャを、クリックで塗り替えます。
Erase (消去): クリックしたオブジェクトを削除します。
Move / Rotate / Scale (移動 / 回転 / スケール):
オブジェクトをドラッグして直感的に変形させます。
ドラッグ中に [X] [Y] [Z] キーを押すことで、その軸のみに変更を限定できます。
Camera Move / Rotate / Zoom: 左クリックのドラッグ操作を、それぞれのカメラ操作に固定します。
Camera Laps (周回カメラ): 選択中のオブジェクトを中心に、カメラをぐるぐると周回させながら確認できる特殊モードです。
4. アイテムの追加とプロパティ
「Add」ツールを選択すると、右パネルから以下の3種類のアイテムを選んで配置できます。
① Track Node (コースの通過点 / 緑の球体)
コースの骨組みとなるポイントです。3つ以上配置することで、自動的にそれらを結ぶ滑らかなループコース(下半分のトンネルと道路)が生成されます。
スマート追加機能: すでにコースがある状態で、コースの経路上(2つのノードの間)をクリックすると、自動的に両隣のノードの中間座標と中間角度が計算され、滑らかにノードが挿入されます。
Bank / Roll (Deg): トンネル全体(壁+道路)の傾き・ひねりを設定します。
Road Twist (Deg): トンネルの壁はそのままに、**「路面だけ」**の傾きを設定します。
② Scenery Block (背景ブロック)
障害物や建物のベースとなる箱型のオブジェクトです。
Block Color: ブロックの基本色を設定します。
Block Texture File: 手持ちの画像ファイル(.png / .jpg)を選択して、ブロックにテクスチャとして貼り付けることができます。(※JSONにはファイル名のみが記録されます)
③ Background Model (背景3Dモデル)
外部の3Dモデル(.glb / .obj)を背景として配置します。
Select Model File to Load: 初回のみこのボタンを押し、使用したい3Dモデルファイルを選択して読み込みます。
読み込んだモデルは「Model List」に追加されるので、リストから選択した状態で画面をクリックすると配置できます。
5. 環境設定 (Environment Settings)
メニューバーの「Environment Settings」を開くと、コース全体に適用されるテクスチャ画像を設定できます。 それぞれの「Select」ボタンを押し、ローカルの画像ファイルを選択してください。
Road Texture: 道路の表面に貼られる画像です。
Wall Texture: トンネルの壁(U字部分)に貼られる画像です。
SkyDome Texture: 背景の空を覆うドーム状の画像です。指定しない場合は星空になります。
Ground Texture: コースの下に広がる地面の画像です。
6. ファイルの保存と読み込み (File メニュー)
Save Course JSON: 作成したコースデータ、配置したオブジェクト、設定した画像やモデルの「ファイル名」を、ひとつの course.json としてダウンロード(保存)します。
Open Course JSON: 保存した course.json を読み込んでエディタ上に復元します。
【重要】セキュリティ上の仕様について: ブラウザのセキュリティ制限により、JSONを読み込んだだけでは、以前使用していた画像や3Dモデルのファイル実体を自動で読み込むことはできません。JSON読み込み後、テクスチャやモデルがグレーになっている場合は、再度各プロパティから該当するファイルを手動で選択(再リンク)してください。
New Course: 現在の編集内容をすべて破棄し、初期状態に戻します。
・コースエディタのソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Booster Course Editor 1.3</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
#menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e; }
.menu-item { padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc; }
.menu-item:hover { background: #3e3e3e; color: #fff; }
.dropdown { display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.menu-item:hover .dropdown { display: block; }
.dropdown-item { padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px; }
.dropdown-item:hover { background: #094771; color: #fff; }
.separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }
#container { display: flex; height: calc(100vh - 30px); position: relative; }
#canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #111; transition: width 0.2s; }
#properties-panel { width: 280px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto; }
#segment-editor-panel { display: none; position: absolute; top: 0; right: 0; width: 450px; height: 100%; background: #1e1e1e; border-left: 1px solid #3e3e3e; z-index: 100; flex-direction: column; padding: 15px; box-sizing: border-box; box-shadow: -5px 0 15px rgba(0,0,0,0.5); }
.prop-group { margin-bottom: 15px; }
.prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
input[type="text"], input[type="number"], input[type="color"], select { width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 4px; font-size: 12px; border-radius: 2px; text-align: right; }
select { text-align: left; cursor: pointer; }
input:focus, select:focus { border-color: #007fd4; outline: none; }
button.action-btn { width: 100%; padding: 6px; background: #333; color: #ccc; border: 1px solid #444; cursor: pointer; font-size: 12px; border-radius: 2px; transition: 0.1s; margin-bottom: 5px; }
button.action-btn:hover { background: #444; color: #fff; }
button.highlight { background: #0e639c; color: white; border: none; font-weight: bold; }
button.highlight:hover { background: #1177bb; }
.file-select-row { display: flex; gap: 5px; margin-bottom: 8px; }
.file-select-row input[type="text"] { flex-grow: 1; margin-bottom: 0; color: #aaa; }
.file-select-row button { width: auto; margin-bottom: 0; padding: 4px 10px; }
#status-bar { position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8); }
#info-overlay { position: absolute; top: 10px; left: 10px; pointer-events: none; }
#mode-display { background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block; }
.axis-label { position: absolute; font-family: monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff; }
/* Segment Map Grid */
#map-grid-wrapper { flex-grow: 1; overflow-y: auto; background: #111; padding: 10px; border: 1px solid #333; margin-top: 10px; border-radius: 4px; position: relative; }
#map-grid-container { display: grid; grid-template-columns: repeat(16, 1fr); gap: 1px; }
.map-cell { aspect-ratio: 1; border: 1px solid #3e3e3e; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: bold; color: transparent; transition: 0.1s; position: relative; }
.map-cell:hover { filter: brightness(1.5); border-color: #fff; z-index: 10; }
.active-row { border-top: 2px solid #fff; border-bottom: 2px solid #fff; box-shadow: inset 0 0 10px rgba(255,255,255,0.8); }
.cell-road { background: #55aa55; }
.cell-wall { background: #225522; }
.cell-hurdle { background: #ff3300 !important; color: #fff !important; }
.cell-heal { background: #00ff00 !important; color: #000 !important; }
.cell-score { background: #ffff00 !important; color: #000 !important; }
.cell-dash { background: #0088ff !important; color: #fff !important; }
.cell-block { background: #888888 !important; color: #fff !important; }
.cell-tex { border: 2px dashed #ff00ff; }
#modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 3000; justify-content: center; align-items: center; }
.modal-box { background: #252526; padding: 20px; border: 1px solid #454545; width: 350px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
.modal-box h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; margin-bottom: 15px; }
.gimmick-hurdle { color: #ff3300; } .gimmick-heal { color: #00ff00; } .gimmick-score { color: #ffff00; } .gimmick-dash { color: #0088ff; } .gimmick-block { color: #aaaaaa; }
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script>
</head>
<body>
<div id="menubar">
<div class="menu-item">File
<div class="dropdown">
<div class="dropdown-item" id="menu-new">New Course</div>
<div class="dropdown-item" id="menu-open">Open Course JSON</div>
<div class="dropdown-item" id="menu-save">Save Course JSON</div>
</div>
</div>
<div class="menu-item">Edit
<div class="dropdown">
<div class="dropdown-item" id="tool-undo">Undo (Ctrl+Z)</div>
<div class="dropdown-item" id="tool-redo">Redo (Ctrl+Y)</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-select">Select</div>
<div class="dropdown-item" id="tool-add">Add Node / Scenery</div>
<div class="dropdown-item" id="tool-erase">Erase (Del)</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-move">Move</div>
<div class="dropdown-item" id="tool-rotate">Rotate</div>
<div class="dropdown-item" id="tool-scale">Scale</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-test-play">Start Full Test Play</div>
</div>
</div>
<div class="menu-item">View
<div class="dropdown">
<div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
<div class="dropdown-item" onclick="viewCam('top')">Top</div>
<div class="dropdown-item" onclick="viewCam('front')">Front</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-cam-move">Camera Move</div>
<div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
<div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
<div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
</div>
</div>
<div class="menu-item">Environment
<div class="dropdown">
<div class="dropdown-item" id="menu-env-settings">Textures Settings</div>
<div class="dropdown-item" id="menu-terrain">Terrain Generator</div>
</div>
</div>
<div style="margin-left:auto; font-size:12px; color:#aaa; padding-right:15px;">
Tip: Double-Click the track to open 2D Segment Map Editor. Right-Click cell to erase.
</div>
</div>
<div id="container">
<div id="canvas-container" tabindex="0">
<div id="info-overlay"><div id="mode-display">Select</div></div>
<div id="status-bar">Ready</div>
<div id="labels-container"></div>
</div>
<div id="properties-panel">
<h3 id="prop-header" style="color:#4fc1ff; margin-top:0; border-bottom:1px solid #444; padding-bottom:5px;">Properties</h3>
<div id="prop-add-settings" style="display:none;">
<div class="prop-group">
<span class="prop-label">Item to Add</span>
<select id="add-item-type">
<option value="node">Track Node</option>
<option value="scenery">Background Block</option>
<option value="bgmodel">Background Model</option>
</select>
</div>
<div id="add-scenery-options" style="display:none; margin-top:5px;">
<button class="action-btn" id="btn-select-brush-tex">Select Brush Texture</button>
<div class="file-select-row"><input type="text" id="brush-tex-name" disabled><button class="action-btn" id="btn-clear-brush-tex">Clear</button></div>
</div>
<div id="add-bgmodel-options" style="display:none;">
<div class="prop-group"><span class="prop-label">Model List (Loaded)</span><select id="bgmodel-list" size="5"></select></div>
<button class="action-btn highlight" id="btn-load-model">Select Model File to Load</button>
</div>
</div>
<div id="prop-content" style="display:none;">
<div class="prop-group">
<span class="prop-label">Position (X, Y, Z)</span>
<div style="display:flex; gap:5px;"><input type="number" id="prop-x" step="10"><input type="number" id="prop-y" step="10"><input type="number" id="prop-z" step="10"></div>
</div>
<div id="prop-node-group">
<div class="prop-group"><span class="prop-label">Width (Radius)</span><input type="number" id="prop-width" step="1"></div>
<div class="prop-group"><span class="prop-label">Bank / Roll (Deg)</span><input type="number" id="prop-roll" step="5"></div>
<div class="prop-group"><span class="prop-label" style="color:#ffcc00;">Road Twist (Deg)</span><input type="number" id="prop-twist" step="5"></div>
<button class="action-btn highlight" id="btn-smooth-node" style="margin-top: 10px;">Auto Smooth (Align to Spline)</button>
</div>
<div id="prop-scenery-group">
<div class="prop-group"><span class="prop-label">Rotation Y (Deg)</span><input type="number" id="prop-ry" step="15"></div>
<div class="prop-group">
<span class="prop-label">Scale (X, Y, Z)</span>
<div style="display:flex; gap:5px;"><input type="number" id="prop-sx" step="1"><input type="number" id="prop-sy" step="1"><input type="number" id="prop-sz" step="1"></div>
</div>
<div id="prop-color-group" style="display:none;">
<div class="prop-group"><span class="prop-label">Color</span><input type="color" id="prop-color"></div>
<div class="prop-group"><span class="prop-label">Texture</span>
<div class="file-select-row"><input type="text" id="prop-tex-name" disabled>
<button class="action-btn" id="btn-prop-select-tex">Set</button>
<button class="action-btn" id="btn-prop-clear-tex">Clear</button>
</div>
</div>
</div>
</div>
</div>
<div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">No item selected.</div>
</div>
<div id="segment-editor-panel">
<h3 id="seg-panel-title" style="color:#ffcc00; margin-top:0; border-bottom:1px solid #444; padding-bottom:5px;">2D Segment Map Editor</h3>
<div style="display:flex; gap:5px; margin-bottom:15px;">
<button class="action-btn" id="btn-seg-back" style="flex:1;">← Back to Main</button>
<button class="action-btn highlight" id="btn-seg-play" style="flex:1; background:#aa00aa;">▶ Test Segment</button>
</div>
<div style="display:flex; justify-content:space-between; margin-bottom: 15px; border-bottom:1px solid #333; padding-bottom:10px;">
<button class="action-btn" id="btn-seg-cam-back" style="width:48%">Cam ← Back</button>
<button class="action-btn" id="btn-seg-cam-fwd" style="width:48%">Cam Fwd →</button>
</div>
<div style="display:flex; gap:5px; margin-bottom:10px;">
<button id="btn-mode-gimmick" class="action-btn highlight" style="flex:1;">Gimmick Mode</button>
<button id="btn-mode-texture" class="action-btn" style="flex:1;">Texture Mode</button>
</div>
<div id="seg-gimmick-options" class="prop-group">
<span class="prop-label">Select Gimmick to Place</span>
<select id="seg-gimmick-type" style="padding:8px; font-weight:bold;">
<option value="hurdle" class="gimmick-hurdle">Hurdle (Red)</option>
<option value="heal" class="gimmick-heal">Heal (Green)</option>
<option value="score" class="gimmick-score">Score (Yellow)</option>
<option value="dash" class="gimmick-dash">Dash Pad (Blue)</option>
<option value="block1" class="gimmick-block">Block x1 (Gray)</option>
<option value="block2" class="gimmick-block">Block x2 (Vertical)</option>
<option value="block3" class="gimmick-block">Block x3 (Vertical)</option>
</select>
</div>
<div id="seg-texture-options" class="prop-group" style="display:none;">
<span class="prop-label">Select Texture to Paint</span>
<select id="seg-texture-type" style="padding:8px;">
<option value="default_road">Default Road Texture</option>
<option value="default_wall">Default Wall Texture</option>
</select>
</div>
<span class="prop-label" style="text-align:center; color:#888;">↑ Forward (Next Node) ↑</span>
<div id="map-grid-wrapper">
<div id="map-grid-container"></div>
</div>
<span class="prop-label" style="text-align:center; color:#888; margin-top:5px;">↓ Backward (Current Node) ↓</span>
</div>
</div>
<div id="modal-overlay">
<div class="modal-box" id="env-dialog" style="display:none;">
<h3>Environment Textures</h3>
<div class="prop-group"><span class="prop-label">Road Texture</span><div class="file-select-row"><input type="text" id="env-road" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'RoadImage/', f=>updateEnvField('env-road',f))">Select</button></div></div>
<div class="prop-group"><span class="prop-label">Wall Texture</span><div class="file-select-row"><input type="text" id="env-wall" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'WallImage/', f=>updateEnvField('env-wall',f))">Select</button></div></div>
<div class="prop-group"><span class="prop-label">SkyDome Texture</span><div class="file-select-row"><input type="text" id="env-sky" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'SkyDomeImage/', f=>updateEnvField('env-sky',f))">Select</button></div></div>
<div class="prop-group"><span class="prop-label">Ground Texture</span><div class="file-select-row"><input type="text" id="env-ground" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'BgGroundImage/', f=>updateEnvField('env-ground',f))">Select</button></div></div>
<div style="text-align:right; margin-top:20px;"><button class="action-btn highlight" style="width:auto; padding:5px 15px;" onclick="closeModal()">Close & Apply</button></div>
</div>
<div class="modal-box" id="terr-dialog" style="display:none;">
<h3>Terrain Generator</h3>
<div class="prop-group"><span class="prop-label">Amplitude (Height)</span><input type="number" id="terr-amp" value="100" step="10"></div>
<div class="prop-group"><span class="prop-label">Frequency (Scale)</span><input type="number" id="terr-freq" value="0.003" step="0.001"></div>
<div style="text-align:right; margin-top:20px;">
<button class="action-btn" style="width:auto; padding:5px 15px;" onclick="closeModal()">Cancel</button>
<button class="action-btn highlight" style="width:auto; padding:5px 15px;" onclick="generateTerrain(); closeModal(); saveState();">Generate</button>
</div>
</div>
</div>
<input type="file" id="file-input-json" style="display: none;" accept=".json">
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
let scene, camera, renderer, controls, raycaster, mouse;
let currentTool = 'select', gridSize = 10, baseHeight = 50;
let trackNodes = [], sceneryItems = [], trackSplineGroup = null, selectedItem = null, addItemType = 'node', clipboard = null;
let currentBrushTex = null;
let planeGround, cursorTarget, cursorPoint, cursorSelect, labelsContainer;
let isDragging = false, dragHasChanged = false, dragStartMouse = new THREE.Vector2(), dragStartVal = new THREE.Vector3(), startCursorPos = new THREE.Vector3();
let axisLock = { x: false, y: false, z: false };
let isLapping = false, lapsQ = new THREE.Quaternion(), lapsTargetQ = new THREE.Quaternion(), lapsRadius = 300, lapsCenter = new THREE.Vector3();
let isTestPlaying = false, testPlayT = 0;
let dragStartCamPos = new THREE.Vector3(), dragStartCamTarget = new THREE.Vector3(), dragStartPolar = 0, dragStartAzimuth = 0;
let gridGroup = new THREE.Group(), guideLineGroup = new THREE.Group(), shadowPool = [], walls = {};
let cachedCurve = null, cachedFrames = null;
// Segment Map Editor
let isSegmentMode = false, mapMode = 'gimmick', currentSegIdx = 0, segmentCamU = 0, isSegmentTestPlaying = false;
const GRID_ROWS = 20, GRID_COLS = 16;
const textureLoader = new THREE.TextureLoader(), gltfLoader = new GLTFLoader(), objLoader = new OBJLoader();
// Undo / Redo System
let historyStack = [], historyIndex = -1;
let ASSETS = { env: { road: null, wall: null, sky: null, ground: null }, bgModels: {}, envTextures: {} };
let envMeshes = { sky: null, stars: null };
// Helper: File Selection
window.selectFile = (accept, folderPrefix, callback) => {
const input = document.createElement('input'); input.type = 'file'; input.accept = accept;
input.onchange = e => {
const f = e.target.files[0];
if(f) {
const obj = { name: folderPrefix + f.name, url: URL.createObjectURL(f) };
// Add to generic texture cache for map mode
if(accept.includes('png')||accept.includes('jpg')) {
ASSETS.envTextures[obj.name] = obj;
refreshTextureSelect();
}
callback(obj);
}
};
input.click();
};
window.updateEnvField = (id, fileObj) => {
const el = document.getElementById(id);
if(el) el.value = fileObj.name;
ASSETS.env[id.split('-')[1]] = fileObj;
applyEnvironment();
};
window.closeModal = () => { document.getElementById('modal-overlay').style.display = 'none'; document.getElementById('env-dialog').style.display = 'none'; document.getElementById('terr-dialog').style.display = 'none'; };
function getTex(fileObj, callback) {
if(!fileObj) return null;
if(ASSETS.envTextures[fileObj.name] && ASSETS.envTextures[fileObj.name].texture) { const t = ASSETS.envTextures[fileObj.name].texture; if(callback) callback(t); return t; }
const tex = textureLoader.load(fileObj.url, t => { t.wrapS = t.wrapT = THREE.RepeatWrapping; if(callback) callback(t); });
if(!ASSETS.envTextures[fileObj.name]) ASSETS.envTextures[fileObj.name] = { name: fileObj.name, url: fileObj.url };
ASSETS.envTextures[fileObj.name].texture = tex;
return tex;
}
function refreshTextureSelect() {
const sel = document.getElementById('seg-texture-type');
if(!sel) return;
sel.innerHTML = '<option value="default_road">Default Road Texture</option><option value="default_wall">Default Wall Texture</option>';
for(let key in ASSETS.envTextures) {
const opt = document.createElement('option'); opt.value = key; opt.innerText = key; sel.appendChild(opt);
}
}
// --- Math & Spline Helpers ---
function distanceToSegment(p, v, w) {
let l2 = v.distanceToSquared(w); if (l2 === 0) return p.distanceTo(v);
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y) + (p.z - v.z) * (w.z - v.z)) / l2;
t = Math.max(0, Math.min(1, t));
let proj = new THREE.Vector3(v.x + t * (w.x - v.x), v.y + t * (w.y - v.y), v.z + t * (w.z - v.z));
return p.distanceTo(proj);
}
function lerpAngleDeg(a, b, t) { let d = b - a; while(d > 180) d -= 360; while(d < -180) d += 360; return a + d * t; }
function getLoopCatmullRom(values, t) {
if(values.length === 0) return 0; const len = values.length; const exact = t * len;
let i1 = Math.floor(exact) % len; if(i1 < 0) i1 += len;
const frac = exact - Math.floor(exact);
const i0 = (i1 - 1 + len) % len, i2 = (i1 + 1) % len, i3 = (i1 + 2) % len;
const v1 = values[i1];
let v0 = values[i0]; while(v0 - v1 > 180) v0 -= 360; while(v0 - v1 < -180) v0 += 360;
let v2 = values[i2]; while(v2 - v1 > 180) v2 -= 360; while(v2 - v1 < -180) v2 += 360;
let v3 = values[i3]; while(v3 - v2 > 180) v3 -= 360; while(v3 - v2 < -180) v3 += 360;
const c0 = v1, c1 = 0.5 * (v2 - v0), c2 = v0 - 2.5 * v1 + 2 * v2 - 0.5 * v3, c3 = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3;
return ((c3 * frac + c2) * frac + c1) * frac + c0;
}
function getSplineInterpolation(targetPos) {
if(trackNodes.length < 3) return null;
const points = trackNodes.map(n => n.position);
const curve = new THREE.CatmullRomCurve3(points, true);
let minDist = Infinity, bestT = 0; const RES = trackNodes.length * 50;
for(let i=0; i<=RES; i++) {
const t = i / RES; const pt = curve.getPointAt(t); const dist = pt.distanceToSquared(targetPos);
if(dist < minDist) { minDist = dist; bestT = t; }
}
const exact = bestT * trackNodes.length; let insertIdx = Math.ceil(exact) % trackNodes.length; if (insertIdx === 0) insertIdx = trackNodes.length;
const u = bestT; const tParam = curve.getUtoTmapping(u);
const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
return { position: curve.getPointAt(u), roll: getLoopCatmullRom(rolls, tParam), twist: getLoopCatmullRom(twists, tParam), width: getLoopCatmullRom(widths, tParam), insertIdx: insertIdx };
}
// Automatic Smoothing Helper to align angles based on spline position
function autoAdjustNodeAngles(nodeToSmooth) {
if(!nodeToSmooth || nodeToSmooth.userData.type !== 'node') return;
const idx = trackNodes.indexOf(nodeToSmooth); if(idx < 0 || trackNodes.length <= 3) return;
const tempNodes = [...trackNodes]; tempNodes.splice(idx, 1);
const points = tempNodes.map(n => n.position); const curve = new THREE.CatmullRomCurve3(points, true);
let minDist = Infinity, bestU = 0; const RES = tempNodes.length * 50;
for(let i=0; i<=RES; i++) {
const u = i / RES; const pt = curve.getPointAt(u); const dist = pt.distanceToSquared(nodeToSmooth.position);
if(dist < minDist) { minDist = dist; bestU = u; }
}
const tParam = curve.getUtoTmapping(bestU);
const rolls = tempNodes.map(n => n.userData.roll), twists = tempNodes.map(n => n.userData.twist);
nodeToSmooth.userData.roll = getLoopCatmullRom(rolls, tParam);
nodeToSmooth.userData.twist = getLoopCatmullRom(twists, tParam);
}
function smoothNode(nodeToSmooth) {
if(!nodeToSmooth || nodeToSmooth.userData.type !== 'node') return;
const idx = trackNodes.indexOf(nodeToSmooth); if(idx < 0 || trackNodes.length <= 3) return;
const tempNodes = [...trackNodes]; tempNodes.splice(idx, 1);
const points = tempNodes.map(n => n.position); const curve = new THREE.CatmullRomCurve3(points, true);
let tIndex = idx - 0.5; if (tIndex < 0) tIndex += tempNodes.length; const u = tIndex / tempNodes.length; const tParam = curve.getUtoTmapping(u);
const rolls = tempNodes.map(n => n.userData.roll), twists = tempNodes.map(n => n.userData.twist), widths = tempNodes.map(n => n.userData.width || 36);
nodeToSmooth.position.copy(curve.getPointAt(u)); nodeToSmooth.userData.roll = getLoopCatmullRom(rolls, tParam); nodeToSmooth.userData.twist = getLoopCatmullRom(twists, tParam); nodeToSmooth.userData.width = getLoopCatmullRom(widths, tParam);
}
function init() {
const container = document.getElementById('canvas-container'); labelsContainer = document.getElementById('labels-container');
scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000);
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 1, 5000);
camera.position.set(200, 200, 200); camera.lookAt(0,0,0);
renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.8); sun.position.set(100, 300, 200); scene.add(sun);
scene.add(gridGroup); scene.add(guideLineGroup);
planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000, 100, 100), new THREE.MeshPhongMaterial({ color: 0x222222, flatShading: true }));
planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -0.1; scene.add(planeGround);
const starGeo = new THREE.BufferGeometry(); const posArray = new Float32Array(2000 * 3); for(let i=0; i<2000 * 3; i++) posArray[i] = (Math.random() - 0.5) * 4000; starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); envMeshes.stars = new THREE.Points(starGeo, new THREE.PointsMaterial({color: 0xffffff, size: 2.0})); scene.add(envMeshes.stars);
initCursors(); initShadowPool(); updateEnvironment();
controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true;
raycaster = new THREE.Raycaster(); mouse = new THREE.Vector2();
window.addEventListener('resize', onResize);
window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
const cvs = renderer.domElement;
cvs.addEventListener('pointermove', onPointerMove); cvs.addEventListener('pointerdown', onPointerDown); cvs.addEventListener('pointerup', onPointerUp);
cvs.addEventListener('dblclick', onDoubleClick);
cvs.addEventListener('contextmenu', e => e.preventDefault());
setupUI(); setTool('select');
// Default width 36 for broader tunnel
createNode(new THREE.Vector3(0, 50, 0), 0, 0, 36); createNode(new THREE.Vector3(0, 50, -300), 0, 0, 36); createNode(new THREE.Vector3(300, 50, -150), 45, -15, 36);
rebuildTrackCurve();
saveState();
animate();
}
function onResize() {
const container = document.getElementById('canvas-container');
if(camera && renderer && container) { camera.aspect = container.clientWidth/container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); updateLabels(); }
}
window.saveState = () => {
const data = {
track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist, width: n.userData.width||36 })),
scenery: sceneryItems.map(s => {
const base = { type: s.userData.subType, x: s.position.x, y: s.position.y, z: s.position.z, sx: s.scale.x, sy: s.scale.y, sz: s.scale.z, ry: s.rotation.y, segIdx: s.userData.segIdx, row: s.userData.row, col: s.userData.col, curveU: s.userData.curveU, angle: s.userData.angle };
if(s.userData.subType === 'block') { base.color = s.material.color.getHex(); base.texture = s.userData.texObj?.name; }
if(s.userData.subType === 'bgmodel') base.modelName = s.userData.fileObj?.name;
if(s.userData.subType === 'gimmick') base.gimmickType = s.userData.gimmickType;
if(s.userData.subType === 'texture') base.texturePath = s.userData.texObj?.name;
return base;
})
};
historyStack.splice(historyIndex + 1); historyStack.push(JSON.stringify(data)); historyIndex++;
const sb = document.getElementById('status-bar'); if(sb) sb.innerText = `State Saved [${historyIndex+1}/${historyStack.length}]`;
};
function restoreState(index) {
if(index < 0 || index >= historyStack.length) return;
const data = JSON.parse(historyStack[index]);
trackNodes.forEach(n => scene.remove(n)); sceneryItems.forEach(s => { if(s.type!=='texture') scene.remove(s); }); trackNodes = []; sceneryItems = [];
if(data.track) data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll, n.twist, n.width));
if(data.scenery) {
data.scenery.forEach(s => {
const pos = new THREE.Vector3(s.x, s.y, s.z), scl = new THREE.Vector3(s.sx, s.sy, s.sz); let mesh;
if(s.type === 'block') mesh = createSceneryBlock(pos, scl, s.color, s.ry, s.texture ? {name: s.texture, url: s.texture} : null);
else if(s.type === 'bgmodel' && ASSETS.bgModels[s.modelName]) mesh = createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
else if(s.type === 'gimmick') mesh = createGimmick(s.gimmickType, pos, scl, s.ry);
else if(s.type === 'texture') { sceneryItems.push({ userData: { type: 'scenery', subType: 'texture', texObj: {name: s.texturePath}, segIdx: s.segIdx, row: s.row, col: s.col }}); }
if(mesh) { mesh.userData.segIdx = s.segIdx; mesh.userData.row = s.row; mesh.userData.col = s.col; mesh.userData.curveU = s.curveU; mesh.userData.angle = s.angle; }
});
}
selectItem(null); rebuildTrackCurve(); historyIndex = index; if(isSegmentMode) renderMap();
const sb = document.getElementById('status-bar'); if(sb) sb.innerText = `Restored [${historyIndex+1}/${historyStack.length}]`;
}
function initCursors() {
cursorTarget = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })); cursorTarget.rotation.x = -Math.PI / 2; scene.add(cursorTarget);
cursorPoint = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 })); scene.add(cursorPoint);
cursorSelect = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false })); cursorSelect.visible = false; scene.add(cursorSelect);
}
function initShadowPool() { for(let i=0; i<6; i++) { const s = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(1, 1)), new THREE.LineBasicMaterial({ color: 0xffffff })); shadowPool.push(s); guideLineGroup.add(s); } }
function updateEnvironment() {
while(gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]);
const s = 2000; gridGroup.add(new THREE.GridHelper(s, 200, 0x555555, 0x2a2a2a));
const makeWall = (rx, rz, px, pz) => { const w = new THREE.GridHelper(s, 200, 0x444444, 0x222222); w.rotation.x = rx; w.rotation.z = rz; w.position.set(px, s/2, pz); return w; };
walls.zNeg = makeWall(-Math.PI/2, 0, 0, -s/2); walls.zPos = makeWall(-Math.PI/2, 0, 0, s/2); walls.xNeg = makeWall(0, -Math.PI/2, -s/2, 0); walls.xPos = makeWall(0, -Math.PI/2, s/2, 0);
gridGroup.add(walls.zNeg); gridGroup.add(walls.zPos); gridGroup.add(walls.xNeg); gridGroup.add(walls.xPos);
}
window.generateTerrain = () => {
const amp = parseFloat(document.getElementById('terr-amp').value) || 100, freq = parseFloat(document.getElementById('terr-freq').value) || 0.003;
const geo = planeGround.geometry; const pos = geo.attributes.position;
for(let i=0; i<pos.count; i++) { let x = pos.getX(i), y = pos.getY(i), dist = Math.min(1, Math.abs(x)/200), z = Math.sin(x * freq) * Math.cos(y * freq) * amp * dist; pos.setZ(i, z); }
pos.needsUpdate = true; geo.computeVertexNormals();
};
function applyEnvironment() {
if(ASSETS.env.ground) { getTex(ASSETS.env.ground, t => { planeGround.material.map = t; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; }); } else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
if(ASSETS.env.sky) { if(!envMeshes.sky) { envMeshes.sky = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(envMeshes.sky); } getTex(ASSETS.env.sky, t => { envMeshes.sky.material.map = t; envMeshes.sky.visible = true; }); envMeshes.stars.visible = false; } else { if(envMeshes.sky) envMeshes.sky.visible = false; envMeshes.stars.visible = true; }
rebuildTrackCurve();
}
function rebuildTrackCurve() {
if (trackSplineGroup) scene.remove(trackSplineGroup);
if (trackNodes.length < 3) return;
const points = trackNodes.map(n => n.position);
const curve = new THREE.CatmullRomCurve3(points, true);
const SEGMENTS = trackNodes.length * 20;
const frames = { tangents: [], normals: [], binormals: [] };
for(let i=0; i<=SEGMENTS; i++) { frames.tangents.push(curve.getTangentAt(curve.getUtoTmapping(i/SEGMENTS)).normalize()); }
frames.normals[0] = new THREE.Vector3(0,1,0);
frames.binormals[0] = new THREE.Vector3().crossVectors(frames.tangents[0], frames.normals[0]).normalize();
frames.normals[0].crossVectors(frames.binormals[0], frames.tangents[0]).normalize();
for(let i=1; i<=SEGMENTS; i++) {
const axis = new THREE.Vector3().crossVectors(frames.tangents[i-1], frames.tangents[i]);
const sin = axis.length(), cos = frames.tangents[i-1].dot(frames.tangents[i]), angle = Math.atan2(sin, cos);
const q = new THREE.Quaternion(); if(sin > 0.0001) q.setFromAxisAngle(axis.normalize(), angle);
frames.normals.push(frames.normals[i-1].clone().applyQuaternion(q));
frames.binormals.push(frames.binormals[i-1].clone().applyQuaternion(q));
}
let twistTotal = Math.acos(Math.max(-1, Math.min(1, frames.normals[0].dot(frames.normals[SEGMENTS]))));
if (new THREE.Vector3().crossVectors(frames.normals[SEGMENTS], frames.normals[0]).dot(frames.tangents[0]) < 0) twistTotal = -twistTotal;
for(let i=1; i<=SEGMENTS; i++) {
const q = new THREE.Quaternion().setFromAxisAngle(frames.tangents[i], (i/SEGMENTS)*twistTotal);
frames.normals[i].applyQuaternion(q); frames.binormals[i].applyQuaternion(q);
}
cachedCurve = curve; cachedFrames = frames;
const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
const positions = [], uvs = [], indices = [];
for (let i = 0; i <= SEGMENTS; i++) {
const u = i / SEGMENTS, tParam = curve.getUtoTmapping(u);
const pt = curve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), currentW = getLoopCatmullRom(widths, tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
// c=0~15 : 0~3 Left Wall, 4~11 Road, 12~15 Right Wall
for (let j = 0; j <= 16; j++) {
const angle = (j / 16) * Math.PI * 2 - Math.PI;
let v;
if(j >= 4 && j <= 12) { // Road twisted
v = pt.clone().addScaledVector(twistRight, Math.sin(angle)*currentW).addScaledVector(twistUp, -Math.cos(angle)*currentW);
} else { // Wall normal
v = pt.clone().addScaledVector(right, Math.sin(angle)*currentW).addScaledVector(up, -Math.cos(angle)*currentW);
}
positions.push(v.x, v.y, v.z);
uvs.push(j / 16, u * trackNodes.length * 8);
}
}
const geo = new THREE.BufferGeometry();
// Build Multi-Materials
const materials = []; const matIndexMap = {};
const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide }); if(ASSETS.env.road) getTex(ASSETS.env.road, t => { roadMat.map = t; roadMat.needsUpdate = true; });
const wallMat = new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide }); if(ASSETS.env.wall) getTex(ASSETS.env.wall, t => { wallMat.map = t; wallMat.needsUpdate = true; }); else wallMat.wireframe = true;
materials.push(roadMat); materials.push(wallMat);
let nextMatIdx = 2;
for(let key in ASSETS.envTextures) {
const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, side: THREE.DoubleSide }); getTex(ASSETS.envTextures[key], t => { mat.map = t; mat.needsUpdate=true; });
materials.push(mat); matIndexMap[key] = nextMatIdx++;
}
let faceIdx = 0;
for (let i = 0; i < SEGMENTS; i++) {
const segIdx = Math.floor((i / SEGMENTS) * trackNodes.length);
const row = i % 20;
for (let c = 0; c < 16; c++) {
const a = i*17+c, b = (i+1)*17+c, c_idx = (i+1)*17+(c+1), d = i*17+(c+1);
indices.push(a,b,d); indices.push(b,c_idx,d);
let mIdx = (c >= 4 && c <= 11) ? 0 : 1;
const texItem = sceneryItems.find(s => s.userData.subType === 'texture' && s.userData.segIdx === segIdx && s.userData.row === row && s.userData.col === c);
if (texItem && matIndexMap[texItem.userData.texObj.name]) mIdx = matIndexMap[texItem.userData.texObj.name];
geo.addGroup(faceIdx * 6, 6, mIdx);
faceIdx++;
}
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices); geo.computeVertexNormals();
trackSplineGroup = new THREE.Mesh(geo, materials);
scene.add(trackSplineGroup);
updateGimmicksTransform();
}
// --- Nodes & Scenery Creation ---
function createNode(pos, roll, twist, width = 36) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(6, 16, 16), new THREE.MeshPhongMaterial({ color: 0x00ff00, emissive: 0x004400 }));
mesh.position.copy(pos); mesh.userData = { type: 'node', roll: roll, twist: twist, width: width };
scene.add(mesh); trackNodes.push(mesh); return mesh;
}
function createSceneryBlock(pos, scaleVec, colorHex, rotY, texObj = null) {
const mat = new THREE.MeshStandardMaterial({ color: colorHex });
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
if(texObj) { getTex(texObj, t => { mat.map = t; mat.color.setHex(0xffffff); mat.needsUpdate = true; }); mesh.userData.texObj = texObj; }
mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
mesh.userData.type = 'scenery'; mesh.userData.subType = 'block';
scene.add(mesh); sceneryItems.push(mesh); return mesh;
}
function createBgModelInstance(fileObj, pos, scaleVec, rotY) {
if(!ASSETS.bgModels[fileObj.name]) return null;
const mesh = ASSETS.bgModels[fileObj.name].object.clone();
mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
mesh.userData = { type: 'scenery', subType: 'bgmodel', fileObj: fileObj };
const box = new THREE.Box3().setFromObject(mesh); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3());
const hitBox = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z), new THREE.MeshBasicMaterial({visible:false}));
hitBox.position.copy(center).sub(mesh.position); mesh.add(hitBox); mesh.userData.hitBox = hitBox;
scene.add(mesh); sceneryItems.push(mesh); return mesh;
}
function createGimmick(type, pos, scaleVec, rotY) {
let col = 0xffffff; let isBlock = false; let stack = 1;
if(type==='hurdle') col=0xff3300; else if(type==='heal') col=0x00ff00; else if(type==='score') col=0xffff00; else if(type==='dash') col=0x0088ff;
else if(type.startsWith('block')) { col=0xaaaaaa; isBlock=true; stack = parseInt(type.replace('block',''))||1; }
const group = new THREE.Group();
const mat = new THREE.MeshBasicMaterial({color:col, wireframe:true, transparent:true, opacity:0.8});
for(let i=0; i<stack; i++) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
mesh.position.y = isBlock ? (i * gridSize) : 0;
group.add(mesh);
}
group.position.copy(pos); group.scale.copy(scaleVec); group.rotation.y = rotY;
group.userData = { type: 'scenery', subType: 'gimmick', gimmickType: type };
scene.add(group); sceneryItems.push(group); return group;
}
function updateGimmicksTransform() {
if(!cachedCurve || !cachedFrames) return;
sceneryItems.forEach(s => {
if(s.userData.subType === 'gimmick') {
const row = s.userData.row, colIdx = s.userData.col, segIdx = s.userData.segIdx;
if(row === undefined || colIdx === undefined) return;
const t = row / GRID_ROWS, u = (segIdx + t) / trackNodes.length;
const tParam = cachedCurve.getUtoTmapping(u);
const pt = cachedCurve.getPointAt(u), segs = cachedFrames.tangents.length;
const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi], B = cachedFrames.binormals[fi];
const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), currentW = getLoopCatmullRom(widths, tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg)), up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg)), twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
const angle = (colIdx / 16) * Math.PI * 2 - Math.PI;
const localX = Math.sin(angle) * currentW, localY = -Math.cos(angle) * currentW;
let finalPos, finalUp, finalRight;
if (colIdx >= 4 && colIdx <= 11) { // Road
finalPos = pt.clone().addScaledVector(twistRight, localX).addScaledVector(twistUp, localY);
finalUp = new THREE.Vector3().addScaledVector(twistRight, -Math.sin(angle)).addScaledVector(twistUp, Math.cos(angle)).normalize();
finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
} else { // Wall
finalPos = pt.clone().addScaledVector(right, localX).addScaledVector(up, localY);
finalUp = new THREE.Vector3().addScaledVector(right, -Math.sin(angle)).addScaledVector(up, Math.cos(angle)).normalize();
finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
}
let offset = gridSize/2; finalPos.addScaledVector(finalUp, offset);
const m = new THREE.Matrix4(); m.makeBasis(finalRight, finalUp, new THREE.Vector3().crossVectors(finalRight, finalUp).normalize());
const euler = new THREE.Euler().setFromRotationMatrix(m);
s.position.copy(finalPos); s.rotation.copy(euler); s.userData.curveU = u; s.userData.angle = angle;
}
});
}
// --- Segment Editor & 2D Map ---
function openSegmentEditor() {
if(trackNodes.length < 3) { alert("Need at least 3 nodes to edit segments."); return; }
isSegmentMode = true; document.getElementById('properties-panel').style.display = 'none'; document.getElementById('segment-editor-panel').style.display = 'flex';
document.getElementById('canvas-container').style.width = 'calc(100% - 450px)'; onResize();
const ph = document.getElementById('seg-panel-title'); if(ph) ph.innerText = `Segment Editor: Node ${currentSegIdx} to ${(currentSegIdx+1)%trackNodes.length}`;
segmentCamU = currentSegIdx / trackNodes.length; updateSegmentCamera(); renderMap();
const md = document.getElementById('mode-display'); if(md) md.innerText = "Segment 2D Edit";
}
function closeSegmentEditor() {
isSegmentMode = false; isSegmentTestPlaying = false;
const btn = document.getElementById('btn-seg-play'); if(btn) btn.innerText = "▶ Test Segment";
document.getElementById('properties-panel').style.display = 'block'; document.getElementById('segment-editor-panel').style.display = 'none';
document.getElementById('canvas-container').style.width = '100%'; onResize(); setTool('select'); viewCam('default');
}
function updateSegmentCamera() {
if(!cachedCurve || !cachedFrames) return;
const u = segmentCamU % 1.0; const pt = cachedCurve.getPointAt(u);
const lookPt = cachedCurve.getPointAt((u + 0.01) % 1.0);
const tParam = cachedCurve.getUtoTmapping(u);
const segs = cachedFrames.tangents.length;
const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi];
const rDeg = getLoopCatmullRom(trackNodes.map(n=>n.userData.roll), tParam);
const tDeg = getLoopCatmullRom(trackNodes.map(n=>n.userData.twist), tParam);
const currentW = getLoopCatmullRom(trackNodes.map(n=>n.userData.width||36), tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const up = N.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
const twistUp = up.clone().applyQuaternion(qTwist);
const roadCenter = pt.clone().addScaledVector(twistUp, -currentW);
const eyePos = roadCenter.addScaledVector(twistUp, 4.0).addScaledVector(T, -20.0); // Slightly back to see row 0
camera.position.copy(eyePos); camera.up.copy(twistUp); camera.lookAt(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.target.copy(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.update();
// Highlight Active Row in Map
const localU = (segmentCamU * trackNodes.length) % 1.0;
const currentRow = Math.floor(localU * GRID_ROWS);
document.querySelectorAll('.map-cell').forEach(c => c.classList.remove('active-row'));
document.querySelectorAll(`.map-cell[data-row='${currentRow}']`).forEach(c => c.classList.add('active-row'));
}
function renderMap() {
const grid = document.getElementById('map-grid-container'); if(!grid) return; grid.innerHTML = '';
for (let r = GRID_ROWS - 1; r >= 0; r--) {
for (let c = 0; c < GRID_COLS; c++) {
const cell = document.createElement('div'); cell.className = 'map-cell'; cell.dataset.row = r; cell.dataset.col = c;
if (c >= 4 && c <= 11) cell.classList.add('cell-road'); else cell.classList.add('cell-wall');
const texItem = sceneryItems.find(s => s.userData.subType === 'texture' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
if (texItem) { cell.classList.add('cell-tex'); cell.innerText = "T"; }
const g = sceneryItems.find(s => s.userData.subType === 'gimmick' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
if (g) {
let tClass = g.userData.gimmickType; let dispText = tClass.charAt(0).toUpperCase();
if(tClass.startsWith('block')) { const stack = parseInt(tClass.replace('block','')) || 1; tClass = 'block'; dispText = (stack > 1) ? `B${stack}` : 'B'; }
cell.classList.add(`cell-${tClass}`); cell.innerText = dispText; cell.style.color = '#fff';
}
cell.onclick = () => handleCellClick(r, c);
cell.oncontextmenu = (e) => {
e.preventDefault();
const gIdx = sceneryItems.findIndex(s => s.userData.subType === 'gimmick' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
if (gIdx >= 0) { scene.remove(sceneryItems[gIdx]); sceneryItems.splice(gIdx, 1); }
const tIdx = sceneryItems.findIndex(s => s.userData.subType === 'texture' && s.userData.segIdx === currentSegIdx && s.userData.row === r && s.userData.col === c);
if (tIdx >= 0) { sceneryItems.splice(tIdx, 1); rebuildTrackCurve(); }
saveState(); renderMap();
};
grid.appendChild(cell);
}
}
}
function handleCellClick(row, col) {
if (mapMode === 'gimmick') {
const type = document.getElementById('seg-gimmick-type').value;
const existingIdx = sceneryItems.findIndex(s => s.userData.subType === 'gimmick' && s.userData.segIdx === currentSegIdx && s.userData.row === row && s.userData.col === col);
if (existingIdx >= 0) { scene.remove(sceneryItems[existingIdx]); sceneryItems.splice(existingIdx, 1); }
if (type !== 'erase') addGimmickAtGrid(row, col, type);
} else {
const texKey = document.getElementById('seg-texture-type').value;
const existingIdx = sceneryItems.findIndex(s => s.userData.subType === 'texture' && s.userData.segIdx === currentSegIdx && s.userData.row === row && s.userData.col === col);
if (existingIdx >= 0) sceneryItems.splice(existingIdx, 1);
if (texKey !== 'default_road' && texKey !== 'default_wall') {
sceneryItems.push({ userData: { type: 'scenery', subType: 'texture', texObj: {name: texKey}, segIdx: currentSegIdx, row: row, col: col }});
}
rebuildTrackCurve();
}
saveState(); renderMap();
}
function addGimmickAtGrid(row, colIdx, type) {
if(!cachedCurve || !cachedFrames) return;
const t = row / GRID_ROWS; const u = (currentSegIdx + t) / trackNodes.length; const tParam = cachedCurve.getUtoTmapping(u);
const pt = cachedCurve.getPointAt(u), segs = cachedFrames.tangents.length, fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi], B = cachedFrames.binormals[fi];
const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist), widths = trackNodes.map(n => n.userData.width || 36);
const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam), currentW = getLoopCatmullRom(widths, tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg)), up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg)), twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
const angle = (colIdx / 16) * Math.PI * 2 - Math.PI;
const localX = Math.sin(angle) * currentW, localY = -Math.cos(angle) * currentW;
let finalPos, finalUp, finalRight;
if (colIdx >= 4 && colIdx <= 11) { // Road
finalPos = pt.clone().addScaledVector(twistRight, localX).addScaledVector(twistUp, localY);
finalUp = new THREE.Vector3().addScaledVector(twistRight, -Math.sin(angle)).addScaledVector(twistUp, Math.cos(angle)).normalize();
finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
} else { // Wall
finalPos = pt.clone().addScaledVector(right, localX).addScaledVector(up, localY);
finalUp = new THREE.Vector3().addScaledVector(right, -Math.sin(angle)).addScaledVector(up, Math.cos(angle)).normalize();
finalRight = new THREE.Vector3().crossVectors(finalUp, T).normalize();
}
let offset = gridSize/2; finalPos.addScaledVector(finalUp, offset);
const m = new THREE.Matrix4(); m.makeBasis(finalRight, finalUp, new THREE.Vector3().crossVectors(finalRight, finalUp).normalize());
const euler = new THREE.Euler().setFromRotationMatrix(m);
const mesh = createGimmick(type, finalPos, new THREE.Vector3(1,1,1), 0);
mesh.rotation.copy(euler); mesh.userData.segIdx = currentSegIdx; mesh.userData.row = row; mesh.userData.col = colIdx; mesh.userData.curveU = u; mesh.userData.angle = angle;
}
// --- UI Binding (Safe) ---
function safeBindClick(id, fn) { const el = document.getElementById(id); if(el) el.onclick = fn; }
function onDoubleClick(e) {
if(isSegmentMode) return;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(trackSplineGroup, true);
if (intersects.length > 0) {
const hit = intersects[0];
if (hit.uv) {
const u = hit.uv.y / (trackNodes.length * 4);
currentSegIdx = Math.floor(u * trackNodes.length) % trackNodes.length;
openSegmentEditor();
}
}
}
function getHitObjects() { const t = [...trackNodes]; sceneryItems.forEach(s => { if(s.userData.subType === 'bgmodel' && s.userData.hitBox) t.push(s.userData.hitBox); else t.push(s); }); return t; }
function getActualItem(hitObj) { return (hitObj.parent && hitObj.parent.userData.type === 'scenery') ? hitObj.parent : hitObj; }
function onPointerMove(e) {
if(isSegmentMode) return;
const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const isLocked = axisLock.x || axisLock.y || axisLock.z;
if(isLapping && isDragging) {
const dx = e.clientX - dragStartMouse.x, dy = e.clientY - dragStartMouse.y; const dQ = new THREE.Quaternion();
if(!axisLock.y && !axisLock.z) dQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), -dx*0.005));
if(axisLock.y || axisLock.z || !axisLock.x) dQ.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion), -dy*0.005));
lapsTargetQ.premultiply(dQ); dragStartMouse.set(e.clientX, e.clientY); return;
}
if (isDragging && selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) {
dragHasChanged = true; const deltaX = e.clientX - dragStartMouse.x, deltaY = e.clientY - dragStartMouse.y;
if (currentTool === 'move') {
if (isLocked) {
const mag = (deltaX - deltaY) * 0.5;
if(axisLock.x) selectedItem.position.x = Math.round((dragStartVal.x + mag)/gridSize)*gridSize; if(axisLock.y) selectedItem.position.y = Math.round((dragStartVal.y + mag)/gridSize)*gridSize; if(axisLock.z) selectedItem.position.z = Math.round((dragStartVal.z + mag)/gridSize)*gridSize;
} else { raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(planeGround); if(intersects.length > 0) { selectedItem.position.x = Math.round(intersects[0].point.x/gridSize)*gridSize; selectedItem.position.z = Math.round(intersects[0].point.z/gridSize)*gridSize; } }
if(selectedItem.userData.type === 'node') { rebuildTrackCurve(); updateGimmicksTransform(); }
}
else if (currentTool === 'rotate' && selectedItem.userData.type === 'scenery') selectedItem.rotation.y = Math.round((dragStartVal.y + deltaX * 0.02) / (Math.PI/4)) * (Math.PI/4);
else if (currentTool === 'scale' && selectedItem.userData.type === 'scenery') {
const mag = -deltaY * 0.05; let sVec = dragStartVal.clone();
if(isLocked) { if(axisLock.x) sVec.x = Math.max(0.1, dragStartVal.x + mag); if(axisLock.y) sVec.y = Math.max(0.1, dragStartVal.y + mag); if(axisLock.z) sVec.z = Math.max(0.1, dragStartVal.z + mag); }
else { const s = Math.max(0.1, dragStartVal.x + mag); sVec.set(s,s,s); }
selectedItem.scale.copy(sVec);
}
updateSelectionBox(); updateUIFromSelection(); return;
}
if (isDragging && !selectedItem && (currentTool === 'add' || currentTool === 'paint') && isLocked) {
const mag = ((e.clientX - dragStartMouse.x) - (e.clientY - dragStartMouse.y)) * 0.5;
if(axisLock.x) cursorTarget.position.x = Math.round((startCursorPos.x + mag)/gridSize)*gridSize; if(axisLock.y) cursorTarget.position.y = Math.round((startCursorPos.y + mag)/gridSize)*gridSize; if(axisLock.z) cursorTarget.position.z = Math.round((startCursorPos.z + mag)/gridSize)*gridSize;
return;
}
if (!isDragging) {
raycaster.setFromCamera(mouse, camera);
if (currentTool === 'add') {
const intersects = raycaster.intersectObject(planeGround);
if (intersects.length > 0) cursorTarget.position.set(Math.round(intersects[0].point.x/gridSize)*gridSize, baseHeight, Math.round(intersects[0].point.z/gridSize)*gridSize);
} else {
const intersects = raycaster.intersectObjects(getHitObjects());
if (intersects.length > 0) {
const hit = getActualItem(intersects[0].object); cursorPoint.position.copy(hit.position);
if(hit.userData.type === 'node') cursorPoint.scale.set(1.5,1.5,1.5);
else { const box = new THREE.Box3().setFromObject(hit); const size = box.getSize(new THREE.Vector3()); cursorPoint.scale.set(size.x/10, size.y/10, size.z/10); }
cursorPoint.visible = true;
} else {
const pIntersects = raycaster.intersectObject(planeGround);
if (pIntersects.length > 0) { cursorPoint.scale.set(1,1,1); cursorPoint.position.set(Math.round(pIntersects[0].point.x/gridSize)*gridSize, baseHeight, Math.round(pIntersects[0].point.z/gridSize)*gridSize); cursorPoint.visible = true; }
}
}
}
}
function onPointerDown(e) {
if (e.button !== 0 || isSegmentMode) return;
if(isLapping) { isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = false; return; }
dragStartCamPos.copy(camera.position); dragStartCamTarget.copy(controls.target); dragStartPolar = controls.getPolarAngle(); dragStartAzimuth = controls.getAzimuthalAngle();
if (currentTool.startsWith('cam-')) { isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = true; return; }
const isLocked = axisLock.x || axisLock.y || axisLock.z;
if (isLocked) {
isDragging = true; dragHasChanged = false; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = false;
if(selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) { if (currentTool === 'move') dragStartVal.copy(selectedItem.position); if (currentTool === 'rotate') dragStartVal.set(0, selectedItem.rotation.y, 0); if (currentTool === 'scale') dragStartVal.copy(selectedItem.scale); } else startCursorPos.copy(cursorTarget.position);
return;
}
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(getHitObjects());
const hitItem = intersects.length > 0 ? getActualItem(intersects[0].object) : null;
if (currentTool === 'select') selectItem(hitItem);
else if (currentTool === 'add') {
if(addItemType === 'node') {
if(trackNodes.length >= 3) {
const interp = getSplineInterpolation(cursorTarget.position);
if(interp) {
let mesh = createNode(interp.position, interp.roll, interp.twist, interp.width);
trackNodes.pop(); trackNodes.splice(interp.insertIdx, 0, mesh);
autoAdjustNodeAngles(mesh);
rebuildTrackCurve(); updateGimmicksTransform();
}
else { createNode(cursorTarget.position.clone(), 0, 0, 36); rebuildTrackCurve(); updateGimmicksTransform(); }
} else { createNode(cursorTarget.position.clone(), 0, 0, 36); rebuildTrackCurve(); updateGimmicksTransform(); }
saveState();
}
else if(addItemType === 'scenery') {
createSceneryBlock(cursorTarget.position.clone(), new THREE.Vector3(1,1,1), 0xaaaaaa, 0, currentBrushTex);
saveState();
}
else if(addItemType === 'bgmodel') { const sel = document.getElementById('bgmodel-list').value; if(sel && ASSETS.bgModels[sel]) { createBgModelInstance(ASSETS.bgModels[sel].fileObj, cursorTarget.position.clone(), new THREE.Vector3(1,1,1), 0); saveState(); } else alert("Select a model from the list first."); }
}
else if (currentTool === 'erase') { if(hitItem) { deleteItem(hitItem); saveState(); } }
else if (['move', 'rotate', 'scale'].includes(currentTool)) { if (hitItem) { selectItem(hitItem); isDragging = true; dragHasChanged = false; controls.enabled = false; dragStartMouse.set(e.clientX, e.clientY); if(currentTool==='move') dragStartVal.copy(hitItem.position); if(currentTool==='rotate') dragStartVal.set(0, hitItem.rotation.y, 0); if(currentTool==='scale') dragStartVal.copy(hitItem.scale); } else selectItem(null); }
}
function onPointerUp() {
isDragging = false; controls.enabled = true;
if(dragHasChanged) {
if (selectedItem && selectedItem.userData.type === 'node' && ['move'].includes(currentTool)) {
autoAdjustNodeAngles(selectedItem);
rebuildTrackCurve();
updateGimmicksTransform();
updateUIFromSelection();
}
saveState(); dragHasChanged = false;
}
}
function onKeyDown(e) {
if (e.target.tagName === 'INPUT' || isSegmentMode) return;
if(e.key.toLowerCase() === 'x') axisLock.x = true; if(e.key.toLowerCase() === 'y') axisLock.y = true; if(e.key.toLowerCase() === 'z') axisLock.z = true;
if(e.key.toLowerCase() === 'z' && e.ctrlKey) { e.preventDefault(); if(historyIndex > 0) restoreState(--historyIndex); }
if(e.key.toLowerCase() === 'y' && e.ctrlKey) { e.preventDefault(); if(historyIndex < historyStack.length - 1) restoreState(++historyIndex); }
if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
if (e.key === 'PageUp') { e.preventDefault(); camera.zoom = Math.min(camera.zoom + 0.1, 5); camera.updateProjectionMatrix(); }
if (e.key === 'PageDown') { e.preventDefault(); camera.zoom = Math.max(camera.zoom - 0.1, 0.1); camera.updateProjectionMatrix(); }
if (e.key.toLowerCase() === 'c' && e.ctrlKey && selectedItem) {
clipboard = { type: selectedItem.userData.type, subType: selectedItem.userData.subType, roll: selectedItem.userData.roll, twist: selectedItem.userData.twist, width: selectedItem.userData.width, sx: selectedItem.scale.x, sy: selectedItem.scale.y, sz: selectedItem.scale.z, ry: selectedItem.rotation.y };
if(selectedItem.userData.subType === 'block') { clipboard.color = selectedItem.material.color.getHex(); clipboard.texObj = selectedItem.userData.texObj; }
if(selectedItem.userData.subType === 'bgmodel') clipboard.fileObj = selectedItem.userData.fileObj;
if(selectedItem.userData.subType === 'gimmick') clipboard.gimmickType = selectedItem.userData.gimmickType;
}
if (e.key.toLowerCase() === 'v' && e.ctrlKey && clipboard && cursorPoint.visible) {
if(clipboard.type === 'node') {
if(trackNodes.length >= 3) {
const interp = getSplineInterpolation(cursorPoint.position);
if(interp) {
let mesh = createNode(interp.position, clipboard.roll, clipboard.twist, clipboard.width);
trackNodes.pop(); trackNodes.splice(interp.insertIdx, 0, mesh);
autoAdjustNodeAngles(mesh);
rebuildTrackCurve(); updateGimmicksTransform();
}
else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist, clipboard.width); rebuildTrackCurve(); updateGimmicksTransform(); }
} else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist, clipboard.width); rebuildTrackCurve(); updateGimmicksTransform(); }
}
else if(clipboard.subType === 'bgmodel') createBgModelInstance(clipboard.fileObj, cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.ry);
saveState();
}
if (e.key === 'ArrowUp') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y += gridSize; if(selectedItem.userData.type === 'node') { rebuildTrackCurve(); updateGimmicksTransform(); } updateUIFromSelection(); updateSelectionBox(); saveState(); } else changeBaseHeight(10); }
if (e.key === 'ArrowDown') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y -= gridSize; if(selectedItem.userData.type === 'node') { rebuildTrackCurve(); updateGimmicksTransform(); } updateUIFromSelection(); updateSelectionBox(); saveState(); } else changeBaseHeight(-10); }
if (e.key === 'Delete' && selectedItem) { deleteItem(selectedItem); saveState(); }
}
function onKeyUp(e) { if(e.key.toLowerCase() === 'x') axisLock.x = false; if(e.key.toLowerCase() === 'y') axisLock.y = false; if(e.key.toLowerCase() === 'z') axisLock.z = false; }
function changeBaseHeight(dir) { baseHeight += dir; const el=document.getElementById('height-display'); if(el) el.textContent = `Height: ${baseHeight}`; const rect = renderer.domElement.getBoundingClientRect(); onPointerMove({ clientX: rect.left + (mouse.x+1)/2*rect.width, clientY: rect.top - (mouse.y-1)/2*rect.height }); }
function deleteItem(mesh) {
if (mesh.userData.type === 'node' && trackNodes.length <= 3) {
alert("Cannot delete the first 3 nodes. Minimum 3 nodes required.");
return;
}
scene.remove(mesh);
if(mesh.userData.type === 'node') {
trackNodes = trackNodes.filter(v => v !== mesh);
rebuildTrackCurve(); updateGimmicksTransform();
} else {
sceneryItems = sceneryItems.filter(v => v !== mesh);
}
if (selectedItem === mesh) selectItem(null);
}
function selectItem(mesh) { selectedItem = mesh; if (mesh) { if(!['add','paint'].includes(currentTool)) cursorSelect.visible = true; updateSelectionBox(); updateUIFromSelection(); } else { cursorSelect.visible = false; if (!['add','paint'].includes(currentTool)) { const pc=document.getElementById('prop-content'); if(pc) pc.style.display = 'none'; const pe=document.getElementById('prop-empty'); if(pe) pe.style.display = 'block'; } } }
function updateSelectionBox() { if(!selectedItem) return; cursorSelect.position.copy(selectedItem.position); cursorSelect.rotation.copy(selectedItem.rotation); if(selectedItem.userData.type === 'node') cursorSelect.scale.set(1.5,1.5,1.5); else { const box = new THREE.Box3().setFromObject(selectedItem); const size = box.getSize(new THREE.Vector3()); cursorSelect.scale.set(size.x/10 * 1.05, size.y/10 * 1.05, size.z/10 * 1.05); } }
function updateGuides() {
shadowPool.forEach(s => s.visible = false);
const limX = (camera.position.x > 0) ? -1000 : 1000, limZ = (camera.position.z > 0) ? -1000 : 1000;
const placeShadow = (idxStart, pos3d, scale) => { const s1=shadowPool[idxStart], s2=shadowPool[idxStart+1]; s1.visible=true; s1.position.set(pos3d.x, pos3d.y, limZ); s1.scale.set(scale.x*10, scale.y*10, 1); s2.visible=true; s2.position.set(limX, pos3d.y, pos3d.z); s2.scale.set(1, scale.y*10, scale.z*10); s2.rotation.y = Math.PI/2; };
if(!isSegmentMode) {
if(['add','paint'].includes(currentTool) && cursorTarget.visible) placeShadow(0, cursorTarget.position, cursorTarget.scale);
if(cursorPoint.visible) placeShadow(2, cursorPoint.position, cursorPoint.scale);
if(selectedItem && cursorSelect.visible) placeShadow(4, selectedItem.position, cursorSelect.scale);
}
walls.xNeg.visible = (camera.position.x > 0); walls.xPos.visible = (camera.position.x < 0); walls.zNeg.visible = (camera.position.z > 0); walls.zPos.visible = (camera.position.z < 0);
}
function updateLabels() {
labelsContainer.innerHTML = '';
if(!isSegmentMode && (axisLock.x || axisLock.y || axisLock.z)) {
const active = selectedItem ? selectedItem.position : (cursorPoint.visible ? cursorPoint.position : cursorTarget.position);
const addL = (pos, txt, col) => { const d = document.createElement('div'); d.className = 'axis-label'; d.textContent = txt; d.style.backgroundColor = col; const vec = pos.clone().project(camera); if(vec.z<=1) { d.style.left = (vec.x*0.5+0.5)*renderer.domElement.clientWidth+'px'; d.style.top = -(vec.y*0.5-0.5)*renderer.domElement.clientHeight+'px'; labelsContainer.appendChild(d); } };
if(axisLock.x) { addL(active.clone().add(new THREE.Vector3(20,0,0)), "+X", "#aa0000"); addL(active.clone().add(new THREE.Vector3(-20,0,0)), "-X", "#aa0000"); }
if(axisLock.y) { addL(active.clone().add(new THREE.Vector3(0,20,0)), "+Y", "#00aa00"); addL(active.clone().add(new THREE.Vector3(0,-20,0)), "-Y", "#00aa00"); }
if(axisLock.z) { addL(active.clone().add(new THREE.Vector3(0,0,20)), "+Z", "#0044aa"); addL(active.clone().add(new THREE.Vector3(0,0,-20)), "-Z", "#0044aa"); }
}
}
function animate() {
requestAnimationFrame(animate);
if (isTestPlaying && trackNodes.length >= 3 && cachedCurve) {
testPlayT += 0.003; if (testPlayT > 1) testPlayT -= 1;
const pt = cachedCurve.getPointAt(testPlayT);
const tParam = cachedCurve.getUtoTmapping(testPlayT), segs = cachedFrames.tangents.length;
const fi = Math.min(segs - 1, Math.max(0, Math.floor(testPlayT * (segs - 1))));
const T = cachedFrames.tangents[fi], N = cachedFrames.normals[fi];
const tDeg = getLoopCatmullRom(trackNodes.map(n=>n.userData.twist), tParam);
const currentW = getLoopCatmullRom(trackNodes.map(n=>n.userData.width||36), tParam);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
const twistUp = N.clone().applyQuaternion(qTwist);
const roadCenter = pt.clone().addScaledVector(twistUp, -currentW);
const eyePos = roadCenter.addScaledVector(twistUp, 4.0).addScaledVector(T, 2.0);
camera.position.copy(eyePos); camera.up.copy(twistUp); camera.lookAt(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.target.copy(eyePos.clone().add(T.clone().multiplyScalar(40))); controls.update();
} else if (isSegmentMode && isSegmentTestPlaying && cachedCurve) {
segmentCamU += 0.002; const startU = currentSegIdx / trackNodes.length, endU = (currentSegIdx + 1) / trackNodes.length;
if (segmentCamU >= endU) segmentCamU = startU; updateSegmentCamera();
} else if(isLapping) {
lapsQ.slerp(lapsTargetQ, 0.1); const v = new THREE.Vector3(0, 0, lapsRadius).applyQuaternion(lapsQ); camera.position.copy(v.add(lapsCenter)); camera.lookAt(lapsCenter); controls.target.copy(lapsCenter); controls.update();
} else {
if(currentTool === 'cam-rotate') { controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI; controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity; if(isDragging) { if(axisLock.x) controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar; else if(axisLock.y||axisLock.z) controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth; } }
else if(currentTool === 'cam-move' && isDragging) { if(axisLock.x) { camera.position.y=dragStartCamPos.y; camera.position.z=dragStartCamPos.z; controls.target.y=dragStartCamTarget.y; controls.target.z=dragStartCamTarget.z; } if(axisLock.y) { camera.position.x=dragStartCamPos.x; camera.position.z=dragStartCamPos.z; controls.target.x=dragStartCamTarget.x; controls.target.z=dragStartCamTarget.z; } if(axisLock.z) { camera.position.x=dragStartCamPos.x; camera.position.y=dragStartCamPos.y; controls.target.x=dragStartCamTarget.x; controls.target.y=dragStartCamTarget.y; } }
controls.update();
}
if(cursorTarget) cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(Date.now() * 0.005 * 8);
updateGuides(); updateLabels(); renderer.render(scene, camera);
}
window.setTool = (tool) => {
currentTool = tool; const names = { 'select': 'Select', 'add': 'Add', 'erase': 'Erase', 'paint': 'Paint', 'move': 'Move', 'rotate': 'Rotate', 'scale': 'Scale', 'cam-move':'Camera Move', 'cam-rotate':'Camera Rotate', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps' };
const modeDisplay = document.getElementById('mode-display');
if(names[tool] && modeDisplay) modeDisplay.innerText = names[tool];
if(tool === 'add' || tool === 'paint') {
cursorTarget.visible = (tool==='add'); cursorPoint.visible = false; cursorSelect.visible = false;
const ph = document.getElementById('prop-header'); if(ph) ph.innerText = tool==='add'?"Add Settings":"Paint Brush";
const pa = document.getElementById('prop-add-settings'); if(pa) pa.style.display = 'block';
const pc = document.getElementById('prop-content'); if(pc) pc.style.display = 'none';
const pe = document.getElementById('prop-empty'); if(pe) pe.style.display = 'none';
} else {
cursorTarget.visible = false; cursorPoint.visible = true; if(selectedItem) cursorSelect.visible = true;
const ph = document.getElementById('prop-header'); if(ph) ph.innerText = "Properties";
const pa = document.getElementById('prop-add-settings'); if(pa) pa.style.display = 'none';
if(selectedItem) updateUIFromSelection(); else {
const pc = document.getElementById('prop-content'); if(pc) pc.style.display = 'none';
const pe = document.getElementById('prop-empty'); if(pe) pe.style.display = 'block';
}
}
isLapping = (tool === 'cam-laps');
if(isLapping) { const act = selectedItem ? selectedItem.position : (cursorPoint.visible ? cursorPoint.position : cursorTarget.position); lapsCenter.copy(act); const rel = camera.position.clone().sub(lapsCenter); lapsRadius = rel.length(); rel.normalize(); lapsQ.setFromUnitVectors(new THREE.Vector3(0,0,1), rel); lapsTargetQ.copy(lapsQ); controls.enabled = false; }
else { controls.enabled = !isTestPlaying && !isSegmentMode; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; if (tool === 'cam-move') controls.mouseButtons.LEFT = THREE.MOUSE.PAN; else if (tool === 'cam-rotate') controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; else if (tool === 'cam-zoom') { controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY; controls.zoomSpeed = 3.0; } else controls.zoomSpeed = 1.0; }
};
window.viewCam = (dir) => {
if(isSegmentMode) closeSegmentEditor();
const dist = 300; camera.up.set(0, 1, 0); if(isLapping){ isLapping = false; setTool('select'); } if(isTestPlaying) { isTestPlaying = false; const btn = document.getElementById('tool-test-play'); if(btn) btn.innerText = "Start Full Test Play"; controls.enabled = true; }
switch(dir) { case 'default': camera.position.set(dist, dist, dist); break; case 'front': camera.position.set(0, 0, dist); break; case 'top': camera.position.set(0, dist, 0); break; }
camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update();
};
function setupUI() {
safeBindClick('btn-seg-back', closeSegmentEditor);
safeBindClick('btn-seg-cam-fwd', () => { segmentCamU += 1 / (trackNodes.length * 20); updateSegmentCamera(); });
safeBindClick('btn-seg-cam-back', () => { segmentCamU -= 1 / (trackNodes.length * 20); updateSegmentCamera(); });
safeBindClick('btn-seg-play', () => { isSegmentTestPlaying = !isSegmentTestPlaying; const b=document.getElementById('btn-seg-play'); if(b) b.innerText = isSegmentTestPlaying ? "■ Stop" : "▶ Test Segment"; });
safeBindClick('btn-mode-gimmick', () => { mapMode = 'gimmick'; document.getElementById('seg-gimmick-options').style.display='block'; document.getElementById('seg-texture-options').style.display='none'; });
safeBindClick('btn-mode-texture', () => { mapMode = 'texture'; document.getElementById('seg-gimmick-options').style.display='none'; document.getElementById('seg-texture-options').style.display='block'; refreshTextureSelect(); });
safeBindClick('tool-undo', () => { if(historyIndex > 0) restoreState(--historyIndex); });
safeBindClick('tool-redo', () => { if(historyIndex < historyStack.length - 1) restoreState(++historyIndex); });
safeBindClick('tool-test-play', () => { isTestPlaying = !isTestPlaying; const el = document.getElementById('tool-test-play'); if(el) el.innerText = isTestPlaying ? "Stop Test Play" : "Start Full Test Play"; if(!isTestPlaying) viewCam('default'); else { controls.enabled = false; testPlayT = 0; } });
['select', 'add', 'paint', 'erase', 'move', 'rotate', 'scale', 'cam-move', 'cam-rotate', 'cam-zoom', 'cam-laps'].forEach(t => safeBindClick(`tool-${t}`, () => setTool(t)));
const addItemTypeEl = document.getElementById('add-item-type');
if(addItemTypeEl) addItemTypeEl.addEventListener('change', (e) => {
addItemType = e.target.value;
const o1 = document.getElementById('add-scenery-options'); if(o1) o1.style.display = addItemType === 'scenery' ? 'block' : 'none';
const o2 = document.getElementById('add-bgmodel-options'); if(o2) o2.style.display = addItemType === 'bgmodel' ? 'block' : 'none';
});
const bindProp = (id, objKey, axis, isDeg) => {
const el = document.getElementById(id);
if(el) el.addEventListener('change', (e) => {
if(!selectedItem) return; let val = parseFloat(e.target.value); if(isDeg) val = THREE.MathUtils.degToRad(val);
if(objKey === 'roll' || objKey === 'twist' || objKey === 'width') { selectedItem.userData[objKey] = val; rebuildTrackCurve(); updateGimmicksTransform(); }
else if(axis) { selectedItem[objKey][axis] = val; if(objKey === 'position' && selectedItem.userData.type === 'node') { autoAdjustNodeAngles(selectedItem); rebuildTrackCurve(); updateGimmicksTransform(); } }
else if(objKey === 'color' && selectedItem.userData.subType === 'block') { selectedItem.material.color.set(e.target.value); if(selectedItem.material.map) selectedItem.material.color.setHex(0xffffff); }
updateSelectionBox(); saveState();
});
};
bindProp('prop-x', 'position', 'x'); bindProp('prop-y', 'position', 'y'); bindProp('prop-z', 'position', 'z');
bindProp('prop-sx', 'scale', 'x'); bindProp('prop-sy', 'scale', 'y'); bindProp('prop-sz', 'scale', 'z');
bindProp('prop-ry', 'rotation', 'y', true); bindProp('prop-roll', 'roll', null, false); bindProp('prop-twist', 'twist', null, false);
bindProp('prop-width', 'width', null, false); bindProp('prop-color', 'color');
safeBindClick('btn-smooth-node', () => {
if(!selectedItem || selectedItem.userData.type !== 'node') return;
smoothNode(selectedItem);
updateUIFromSelection(); updateSelectionBox(); rebuildTrackCurve(); updateGimmicksTransform(); saveState();
});
safeBindClick('btn-select-brush-tex', () => selectFile('.png,.jpg', 'BgBlockImage/', f => { currentBrushTex = f; const b=document.getElementById('brush-tex-name'); if(b) b.value = f.name; }));
safeBindClick('btn-clear-brush-tex', () => { currentBrushTex = null; const b=document.getElementById('brush-tex-name'); if(b) b.value = ""; });
safeBindClick('btn-prop-select-tex', () => selectFile('.png,.jpg', 'BgBlockImage/', f => { if(selectedItem && selectedItem.userData.subType === 'block') { getTex(f, t=>{ selectedItem.material.map=t; selectedItem.material.color.setHex(0xffffff); selectedItem.material.needsUpdate=true; }); selectedItem.userData.texObj = f; const p=document.getElementById('prop-tex-name'); if(p) p.value = f.name; saveState(); } }));
safeBindClick('btn-prop-clear-tex', () => { if(selectedItem && selectedItem.userData.subType === 'block') { selectedItem.material.map = null; selectedItem.userData.texObj = null; const c=document.getElementById('prop-color'); if(c) selectedItem.material.color.set(c.value); selectedItem.material.needsUpdate = true; const p=document.getElementById('prop-tex-name'); if(p) p.value = ""; saveState(); } });
safeBindClick('btn-load-model', () => selectFile('.glb,.obj', 'BgModel/', f => { const sb=document.getElementById('status-bar'); if(sb) sb.innerText = `Loading ${f.name}...`; const onload = obj => { ASSETS.bgModels[f.name] = { fileObj: f, object: obj }; const opt = document.createElement('option'); opt.value = f.name; opt.innerText = f.name; const bl=document.getElementById('bgmodel-list'); if(bl) bl.appendChild(opt); if(sb) sb.innerText = `Loaded ${f.name}`; }; if(f.name.endsWith('.obj')) objLoader.load(f.url, onload); else gltfLoader.load(f.url, g=>onload(g.scene)); }));
safeBindClick('menu-env-settings', () => { const r=document.getElementById('env-road'); if(r) r.value = ASSETS.env.road ? ASSETS.env.road.name : ""; const w=document.getElementById('env-wall'); if(w) w.value = ASSETS.env.wall ? ASSETS.env.wall.name : ""; const s=document.getElementById('env-sky'); if(s) s.value = ASSETS.env.sky ? ASSETS.env.sky.name : ""; const g=document.getElementById('env-ground'); if(g) g.value = ASSETS.env.ground ? ASSETS.env.ground.name : ""; const m=document.getElementById('modal-overlay'); if(m) m.style.display = 'flex'; const e=document.getElementById('env-dialog'); if(e) e.style.display = 'block'; });
safeBindClick('menu-terrain', () => { const m=document.getElementById('modal-overlay'); if(m) m.style.display = 'flex'; const t=document.getElementById('terr-dialog'); if(t) t.style.display = 'block'; });
safeBindClick('menu-new', () => {
if(confirm("Clear all?")) {
trackNodes.forEach(n=>scene.remove(n)); sceneryItems.forEach(s=>{ if(s.type!=='texture') scene.remove(s); });
trackNodes=[]; sceneryItems=[];
createNode(new THREE.Vector3(0, 50, 0), 0, 0, 36);
createNode(new THREE.Vector3(0, 50, -300), 0, 0, 36);
createNode(new THREE.Vector3(300, 50, -150), 45, -15, 36);
rebuildTrackCurve(); selectItem(null); historyStack = []; historyIndex = -1; saveState();
if(isSegmentMode) closeSegmentEditor();
}
});
safeBindClick('menu-save', () => {
const data = {
assets: { env: { road: ASSETS.env.road?.name, wall: ASSETS.env.wall?.name, sky: ASSETS.env.sky?.name, ground: ASSETS.env.ground?.name }, bgModels: Object.keys(ASSETS.bgModels), envTextures: Object.keys(ASSETS.envTextures) },
track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist, width: n.userData.width||36 })),
scenery: sceneryItems.map(s => { const base = { type: s.userData.subType, x: s.position.x, y: s.position.y, z: s.position.z, sx: s.scale.x, sy: s.scale.y, sz: s.scale.z, ry: s.rotation.y, segIdx: s.userData.segIdx, row: s.userData.row, col: s.userData.col, curveU: s.userData.curveU, angle: s.userData.angle }; if(s.userData.subType === 'block') { base.color = s.material.color.getHex(); base.texture = s.userData.texObj?.name; } if(s.userData.subType === 'bgmodel') base.modelName = s.userData.fileObj?.name; if(s.userData.subType === 'gimmick') { base.gimmickType = s.userData.gimmickType; } if(s.userData.subType === 'texture') { base.texturePath = s.userData.texObj?.name; } return base; })
};
const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data)], {type:'application/json'})); link.download = 'course.json'; link.click();
});
safeBindClick('menu-open', () => { const f=document.getElementById('file-input-json'); if(f) f.click(); });
const fileInput = document.getElementById('file-input-json');
if(fileInput) fileInput.onchange = (e) => { const f = e.target.files[0]; if(!f) return; const r = new FileReader(); r.onload = ev => loadCourseData(JSON.parse(ev.target.result)); r.readAsText(f); };
}
function loadCourseData(data) {
trackNodes.forEach(n => scene.remove(n)); sceneryItems.forEach(s => { if(s.type!=='texture') scene.remove(s); }); trackNodes = []; sceneryItems = [];
if(data.assets) {
ASSETS.env = { road: data.assets.env?.road ? { name: data.assets.env.road, url: data.assets.env.road } : null, wall: data.assets.env?.wall ? { name: data.assets.env.wall, url: data.assets.env.wall } : null, sky: data.assets.env?.sky ? { name: data.assets.env.sky, url: data.assets.env.sky } : null, ground: data.assets.env?.ground ? { name: data.assets.env.ground, url: data.assets.env.ground } : null };
ASSETS.bgModels = {};
if(data.assets.bgModels) { data.assets.bgModels.forEach(path => { ASSETS.bgModels[path] = { fileObj: { name: path, url: path }, object: null }; const onload = obj => { ASSETS.bgModels[path].object = obj; const opt = document.createElement('option'); opt.value = path; opt.innerText = path; const bl=document.getElementById('bgmodel-list'); if(bl) bl.appendChild(opt); }; if(path.endsWith('.obj')) objLoader.load(path, onload); else gltfLoader.load(path, g=>onload(g.scene)); }); }
ASSETS.envTextures = {}; if(data.assets.envTextures) data.assets.envTextures.forEach(path => { getTex({name: path, url: path}); });
applyEnvironment();
}
if(data.track) data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll, n.twist, n.width));
const finalizeLoad = () => {
rebuildTrackCurve();
updateGimmicksTransform();
if(isSegmentMode) closeSegmentEditor();
selectItem(null);
const sb=document.getElementById('status-bar'); if(sb) sb.innerText = "Course JSON Loaded.";
historyStack = []; historyIndex = -1; saveState();
};
if(data.scenery && data.scenery.length > 0) {
setTimeout(() => {
data.scenery.forEach(s => {
const pos = new THREE.Vector3(s.x, s.y, s.z), scl = new THREE.Vector3(s.sx, s.sy, s.sz); let mesh;
if(s.type === 'block') mesh = createSceneryBlock(pos, scl, s.color, s.ry, s.texture ? {name: s.texture, url: s.texture} : null);
else if(s.type === 'bgmodel' && ASSETS.bgModels[s.modelName]) mesh = createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
else if(s.type === 'gimmick') mesh = createGimmick(s.gimmickType, pos, scl, s.ry);
else if(s.type === 'texture') { sceneryItems.push({ userData: { type: 'scenery', subType: 'texture', texObj: {name: s.texturePath}, segIdx: s.segIdx, row: s.row, col: s.col }}); }
if(mesh) { mesh.userData.segIdx = s.segIdx; mesh.userData.row = s.row; mesh.userData.col = s.col; mesh.userData.curveU = s.curveU; mesh.userData.angle = s.angle; }
});
finalizeLoad();
}, 500);
} else {
finalizeLoad();
}
}
function updateUIFromSelection() {
if(!selectedItem || ['add','paint'].includes(currentTool)) return;
const pc = document.getElementById('prop-content'), pe = document.getElementById('prop-empty');
if(pc) pc.style.display = 'block'; if(pe) pe.style.display = 'none';
const isNode = selectedItem.userData.type === 'node';
const setVal = (id, val) => { const el = document.getElementById(id); if(el) el.value = val; };
setVal('prop-x', selectedItem.position.x); setVal('prop-y', selectedItem.position.y); setVal('prop-z', selectedItem.position.z);
const pNode = document.getElementById('prop-node-group'), pScen = document.getElementById('prop-scenery-group');
if(pNode) pNode.style.display = isNode ? 'block' : 'none';
if(pScen) pScen.style.display = isNode ? 'none' : 'block';
if(isNode) { setVal('prop-roll', selectedItem.userData.roll); setVal('prop-twist', selectedItem.userData.twist || 0); setVal('prop-width', selectedItem.userData.width || 36); }
else {
setVal('prop-sx', selectedItem.scale.x); setVal('prop-sy', selectedItem.scale.y); setVal('prop-sz', selectedItem.scale.z); setVal('prop-ry', Math.round(THREE.MathUtils.radToDeg(selectedItem.rotation.y)));
const cGrp = document.getElementById('prop-color-group');
if(selectedItem.userData.subType === 'block') { if(cGrp) cGrp.style.display = 'block'; setVal('prop-color', '#' + selectedItem.material.color.getHexString()); setVal('prop-tex-name', selectedItem.userData.texObj ? selectedItem.userData.texObj.name : ""); }
else { if(cGrp) cGrp.style.display = 'none'; }
}
}
init();
</script>
</body>
</html>・ゲーム側のソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Image Booster 7.3</title>
<style>
body { margin: 0; overflow: hidden; background: #000; font-family: 'Arial Black', sans-serif; }
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; color: white; text-shadow: 2px 2px 4px #000; z-index: 10; }
#view-mode { position: absolute; top: 20px; right: 20px; font-size: 20px; color: #0f0; }
#stage-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); font-size: 20px; color: #ff0; }
#score-info { position: absolute; top: 20px; left: 20px; font-size: 24px; color: #fff; }
#speed-lines { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle, transparent 40%, rgba(255, 255, 255, 0.1) 80%), repeating-conic-gradient(transparent 0deg, transparent 2deg, rgba(100, 255, 255, 0.3) 2.1deg, transparent 3deg); opacity: 0; transition: opacity 0.1s ease-out; pointer-events: none; z-index: 5; mix-blend-mode: screen; }
.lock-on-sight { position: absolute; width: 40px; height: 40px; border: 3px solid #0f0; border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 10px rgba(255,255,255,0.5), inset 0 0 5px rgba(255,255,255,0.5); z-index: 8; }
.lock-on-sight.rushing { border-width: 5px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.2); }
#hud-bottom { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 85%; display: none; flex-direction: row; justify-content: space-around; align-items: flex-end; }
.gauge-container { width: 30%; text-align: center; }
.gauge-label { font-size: 18px; margin-bottom: 5px; }
.bar-bg { width: 100%; height: 12px; border: 2px solid #fff; border-radius: 6px; overflow: hidden; background: rgba(0,0,0,0.5); transform: skewX(-20deg); }
.bar-fill { height: 100%; width: 100%; transition: width 0.05s ease-out; }
#hp-fill { background: #adff2f; box-shadow: 0 0 10px #adff2f; }
#speed-val { font-size: 56px; line-height: 1; color: #00ffff; text-shadow: 0 0 15px #00ffff; font-style: italic; }
#speed-unit { font-size: 20px; color: #aaa; margin-left: 5px; }
#speed-fill { background: #00ffff; }
#time-fill { background: #ffcc00; }
#center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; pointer-events: auto; }
h1 { font-size: 70px; margin: 0; color: #0f0; text-shadow: 0 0 30px #0f0; font-style: italic; letter-spacing: -2px; }
p { font-size: 24px; margin: 10px 0; color: #fff; }
.pop-text { position: absolute; font-weight: bold; font-size: 40px; animation: moveUpFade 0.8s forwards; transform: translate(-50%, -50%); z-index: 999; text-shadow: 0 0 10px white; }
@keyframes moveUpFade { 0% { transform: translate(-50%, 0) scale(1); opacity: 1; } 100% { transform: translate(-50%, -100px) scale(1.5); opacity: 0; } }
.title-btn { background: #007fd4; color: #fff; padding: 10px 20px; font-size: 18px; border: 2px solid #005f9e; cursor: pointer; border-radius: 5px; margin: 10px; font-family: 'Arial Black'; transition: 0.2s; }
.title-btn:hover { background: #1177bb; box-shadow: 0 0 15px #00ffff; }
.course-select { padding: 10px; font-size: 16px; font-family: sans-serif; background: #333; color: white; border: 1px solid #555; border-radius: 5px; margin-bottom: 15px; width: 250px; }
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script>
</head>
<body>
<div id="speed-lines"></div>
<div id="ui-layer">
<div id="score-info">SCORE: 0</div><div id="stage-info">STAGE: 1</div><div id="view-mode">VIEW: F3</div>
<div id="hud-bottom">
<div class="gauge-container"><div class="gauge-label">SHIELD</div><div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div></div>
<div class="gauge-container"><div style="margin-bottom:5px;"><span id="speed-val">0</span><span id="speed-unit">km/h</span></div><div class="bar-bg" id="speed-bg"><div id="speed-fill" class="bar-fill"></div></div></div>
<div class="gauge-container"><div class="gauge-label">TIME: <span id="time-num" style="font-size: 32px;">1:00</span></div><div class="bar-bg" id="time-bg"><div id="time-fill" class="bar-fill"></div></div></div>
</div>
<div id="center-text"></div>
</div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
let vfs = {};
let courseFiles = {};
function handleFolderSelect(files) {
vfs = {}; courseFiles = {};
const status = document.getElementById('load-status'); status.innerText = "SCANNING ASSETS...";
for (let f of files) {
const parts = f.webkitRelativePath.split('/'); parts.shift();
const relPath = parts.join('/');
vfs[relPath] = URL.createObjectURL(f);
vfs[f.name] = vfs[relPath];
if (f.name.endsWith('.json')) {
const r = new FileReader(); r.onload = e => { courseFiles[f.name] = JSON.parse(e.target.result); populateCourseSelect(); }; r.readAsText(f);
}
}
status.innerText = "ASSETS READY. PRESS START TO PLAY.";
}
function populateCourseSelect() {
const select = document.getElementById('course-select-box'); if(!select) return;
select.innerHTML = ''; for(let name in courseFiles) { const opt = document.createElement('option'); opt.value = name; opt.innerText = name; select.appendChild(opt); }
select.style.display = 'inline-block';
}
function getCourseSelectUI() {
return `
<div style="margin-top:20px; pointer-events:auto;">
<label class="title-btn" style="background:#555; border-color:#333;">1. LOAD ASSETS FOLDER (OPTIONAL)<input type="file" id="assets-folder" webkitdirectory directory multiple style="display:none;"></label>
<div id="load-status" style="margin:10px; color:#aaa; font-size:14px;">(No custom folder loaded. Default course will be used.)</div>
<select id="course-select-box" class="course-select" style="display:none;"></select><br>
<button id="start-btn" class="title-btn" style="background:#ffaa00; border-color:#cc8800; font-size:24px;">2. START GAME (SPACE)</button>
</div>`;
}
let customCourseCurve = null, customCourseLength = 0, customCourseFrames = { tangents: [], normals: [], binormals: [] }, customCourseData = null;
let loadedModels = {}, loadedTextures = {}, envTextures = {};
const manager = new THREE.LoadingManager();
manager.setURLModifier(url => { const fName = url.split('/').pop(); return vfs[fName] ? vfs[fName] : (vfs[url] ? vfs[url] : url); });
const gltfLoader = new GLTFLoader(manager), objLoader = new OBJLoader(manager), texLoader = new THREE.TextureLoader(manager);
function getLoopCatmullRom(values, t) {
if(values.length === 0) return 0; const len = values.length; const exact = t * len;
let i1 = Math.floor(exact) % len; if(i1 < 0) i1 += len;
const frac = exact - Math.floor(exact);
const i0 = (i1 - 1 + len) % len, i2 = (i1 + 1) % len, i3 = (i1 + 2) % len;
const v1 = values[i1];
let v0 = values[i0]; while(v0 - v1 > 180) v0 -= 360; while(v0 - v1 < -180) v0 += 360;
let v2 = values[i2]; while(v2 - v1 > 180) v2 -= 360; while(v2 - v1 < -180) v2 += 360;
let v3 = values[i3]; while(v3 - v2 > 180) v3 -= 360; while(v3 - v2 < -180) v3 += 360;
const c0 = v1, c1 = 0.5 * (v2 - v0), c2 = v0 - 2.5 * v1 + 2 * v2 - 0.5 * v3, c3 = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3;
return ((c3 * frac + c2) * frac + c1) * frac + c0;
}
function loadCourseAndStart(filename) {
const data = courseFiles[filename]; customCourseData = data;
if (data.track && data.track.length >= 3) {
const points = data.track.map(p => new THREE.Vector3(p.x, p.y, p.z));
customCourseCurve = new THREE.CatmullRomCurve3(points, true);
customCourseLength = customCourseCurve.getLength();
const SEGMENTS = data.track.length * 20; customCourseFrames = { tangents: [], normals: [], binormals: [] };
for(let i=0; i<=SEGMENTS; i++) customCourseFrames.tangents.push(customCourseCurve.getTangentAt(customCourseCurve.getUtoTmapping(i/SEGMENTS)).normalize());
customCourseFrames.normals[0] = new THREE.Vector3(0,1,0);
customCourseFrames.binormals[0] = new THREE.Vector3().crossVectors(customCourseFrames.tangents[0], customCourseFrames.normals[0]).normalize();
customCourseFrames.normals[0].crossVectors(customCourseFrames.binormals[0], customCourseFrames.tangents[0]).normalize();
for(let i=1; i<=SEGMENTS; i++) {
const axis = new THREE.Vector3().crossVectors(customCourseFrames.tangents[i-1], customCourseFrames.tangents[i]);
const sin = axis.length(), cos = customCourseFrames.tangents[i-1].dot(customCourseFrames.tangents[i]), angle = Math.atan2(sin, cos);
const q = new THREE.Quaternion(); if(sin > 0.0001) q.setFromAxisAngle(axis.normalize(), angle);
customCourseFrames.normals.push(customCourseFrames.normals[i-1].clone().applyQuaternion(q));
customCourseFrames.binormals.push(customCourseFrames.binormals[i-1].clone().applyQuaternion(q));
}
let twistTotal = Math.acos(Math.max(-1, Math.min(1, customCourseFrames.normals[0].dot(customCourseFrames.normals[SEGMENTS]))));
if (new THREE.Vector3().crossVectors(customCourseFrames.normals[SEGMENTS], customCourseFrames.normals[0]).dot(customCourseFrames.tangents[0]) < 0) twistTotal = -twistTotal;
for(let i=1; i<=SEGMENTS; i++) { const q = new THREE.Quaternion().setFromAxisAngle(customCourseFrames.tangents[i], (i/SEGMENTS)*twistTotal); customCourseFrames.normals[i].applyQuaternion(q); customCourseFrames.binormals[i].applyQuaternion(q); }
}
document.getElementById('center-text').innerHTML = "<h2 style='color:#fff;'>LOADING SCENE...</h2>";
let modelsToLoad = data.assets?.bgModels || [], texToLoad = [ ...(data.assets?.blockTextures || []), data.assets?.env?.road, data.assets?.env?.wall, data.assets?.env?.sky, data.assets?.env?.ground ].filter(Boolean);
if(data.scenery) {
data.scenery.forEach(s => {
if(s.type === 'texture' && s.texturePath && !texToLoad.includes(s.texturePath)) texToLoad.push(s.texturePath);
});
}
let loadedCount = 0; const total = modelsToLoad.length + texToLoad.length;
const checkDone = () => { loadedCount++; if(loadedCount >= total) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score = 0; changeState("STAGE_START"); } };
if(total === 0) checkDone();
modelsToLoad.forEach(p => {
if(loadedModels[p]) { checkDone(); return; } const url = vfs[p] || vfs[p.split('/').pop()]; if(!url) { checkDone(); return; }
if(p.endsWith('.obj')) objLoader.load(url, o => { loadedModels[p] = o; checkDone(); }); else gltfLoader.load(url, g => { loadedModels[p] = g.scene; checkDone(); });
});
texToLoad.forEach(p => {
if(loadedTextures[p]) { checkDone(); return; } const url = vfs[p] || vfs[p.split('/').pop()]; if(!url) { checkDone(); return; }
texLoader.load(url, t => { t.wrapS = t.wrapT = THREE.RepeatWrapping; loadedTextures[p] = t; checkDone(); });
});
}
function applyEnvironment() {
const env = customCourseData?.assets?.env || {};
if(env.ground && loadedTextures[env.ground]) { planeGround.material.map = loadedTextures[env.ground]; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; } else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
if(env.sky && loadedTextures[env.sky]) { if(!skyMesh) { skyMesh = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(skyMesh); } skyMesh.material.map = loadedTextures[env.sky]; skyMesh.visible = true; stars.visible = false; } else { if(skyMesh) skyMesh.visible = false; stars.visible = true; }
rebuildTunnelMesh(viewMode);
}
function setupCustomScenery() {
worldObjects.forEach(obj => { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }); worldObjects = [];
if (customCourseData.scenery) {
customCourseData.scenery.forEach(s => {
if(s.type === 'gimmick') {
const zOffset = s.curveU * customCourseLength;
let type = s.gimmickType;
let stackHeight = 1;
if (type.startsWith('block')) { stackHeight = parseInt(type.replace('block', '')) || 1; type = 'block'; }
for (let k = 0; k < stackHeight; k++) {
let geo, color;
if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); color = new THREE.Color(0xff3300); }
else if (type === 'heal') { geo = new THREE.BoxGeometry(2,2,2); color = new THREE.Color(0x00ff00); }
else if (type === 'score') { geo = new THREE.BoxGeometry(3,3,3); color = new THREE.Color(0xffff00); }
else if (type === 'dash') { geo = new THREE.BoxGeometry(4,1,4); color = new THREE.Color(0x0088ff); }
else { geo = new THREE.BoxGeometry(3,3,3); color = new THREE.Color(0xaaaaaa); }
const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color }));
let alt = 5.0;
if (type === "hurdle") alt = 2.0; else if (type === "heal") alt = 1.0; else if (type === "score") alt = 1.5; else if (type === "dash") alt = 0.5;
if (type === 'block') alt += k * 3.0;
scene.add(mesh);
worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: s.angle, altitude: alt, vel: new THREE.Vector3(), localPos: new THREE.Vector3(), locked: false, sightDom: null, isDead: false, isScenery: false });
}
} else if(s.type !== 'texture') {
let mesh;
if(s.type === 'block') { const mat = new THREE.MeshStandardMaterial({ color: s.color }); if(s.texture && loadedTextures[s.texture]) { mat.map = loadedTextures[s.texture]; mat.color.setHex(0xffffff); } mesh = new THREE.Mesh(new THREE.BoxGeometry(10,10,10), mat); }
else if(s.type === 'bgmodel' && loadedModels[s.modelName]) { mesh = loadedModels[s.modelName].clone(); }
if(mesh) { mesh.position.set(s.x, s.y, s.z); mesh.scale.set(s.sx, s.sy, s.sz); mesh.rotation.y = s.ry || 0; scene.add(mesh); worldObjects.push({ mesh, type: s.type, flying: false, z: 0, isScenery: true, locked: false, isDead: false, sightDom: null }); }
}
});
}
}
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let noiseBuffer = null, windSource = null, windGain = null, windFilter = null, jetOsc = null, jetGain = null, brakeCooldown = 0, bankSoundTimer = 0;
function createNoiseBuffer() { const bSize = audioCtx.sampleRate * 2.0; const b = audioCtx.createBuffer(1, bSize, audioCtx.sampleRate); const d = b.getChannelData(0); for (let i = 0; i < bSize; i++) d[i] = Math.random() * 2 - 1; return b; }
const sound = {
init: () => { if (!noiseBuffer) noiseBuffer = createNoiseBuffer(); },
suspend: () => { if(audioCtx.state === 'running') audioCtx.suspend(); },
resume: () => { if(audioCtx.state === 'suspended') audioCtx.resume(); },
startEngine: () => { if (windSource) return; windSource = audioCtx.createBufferSource(); windSource.buffer = noiseBuffer; windSource.loop = true; windFilter = audioCtx.createBiquadFilter(); windFilter.type = 'bandpass'; windFilter.Q.value = 1.0; windFilter.frequency.value = 400; windGain = audioCtx.createGain(); windGain.gain.value = 0; windSource.connect(windFilter); windFilter.connect(windGain); windGain.connect(audioCtx.destination); windSource.start(); jetOsc = audioCtx.createOscillator(); jetOsc.type = 'sawtooth'; jetOsc.frequency.value = 800; jetGain = audioCtx.createGain(); jetGain.gain.value = 0; jetOsc.connect(jetGain); jetGain.connect(audioCtx.destination); jetOsc.start(); },
updateEngine: (sRatio, isB) => { if (!windSource) return; const now = audioCtx.currentTime; if (sRatio <= 0.01) { windGain.gain.setTargetAtTime(0, now, 0.1); jetGain.gain.setTargetAtTime(0, now, 0.1); return; } windFilter.frequency.setTargetAtTime(200 + (sRatio * 2000), now, 0.1); windGain.gain.setTargetAtTime(Math.min(0.3, sRatio * 0.2), now, 0.1); jetGain.gain.setTargetAtTime(isB ? 0.15 : 0.02, now, 0.2); jetOsc.frequency.setTargetAtTime(600 + (sRatio * 3000), now, 0.1); },
play: (type) => {
if (audioCtx.state === 'suspended' && type !== 'ui') audioCtx.resume(); const now = audioCtx.currentTime;
if (type === 'crash') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(800, now); f.frequency.exponentialRampToValueAtTime(100, now + 1.2); const g = audioCtx.createGain(); g.gain.setValueAtTime(1.0, now); g.gain.exponentialRampToValueAtTime(0.01, now + 1.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 1.2); }
else if (type === 'brake') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(1200, now); f.frequency.linearRampToValueAtTime(400, now + 0.3); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.3, now); g.gain.linearRampToValueAtTime(0.01, now + 0.3); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.3); }
else if (type === 'scrape') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(400, now); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0.01, now + 0.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.2); }
else if (type === 'heal') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(600, now); osc.frequency.exponentialRampToValueAtTime(2000, now+0.4); g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0, now+0.4); osc.start(now); osc.stop(now+0.4); }
else if (type === 'lockon') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square'; osc.frequency.setValueAtTime(1200, now); g.gain.setValueAtTime(0.05, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.08); osc.start(now); osc.stop(now+0.08); }
else if (type === 'boost_dash') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'highpass'; f.frequency.setValueAtTime(500, now); f.frequency.linearRampToValueAtTime(2000, now+0.5); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.5, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.5); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.5); }
else if (type === 'jump') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'triangle'; osc.frequency.setValueAtTime(150, now); osc.frequency.linearRampToValueAtTime(300, now+0.2); g.gain.setValueAtTime(0.8, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.3); osc.start(now); osc.stop(now+0.3); }
else if (type === 'land') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(300, now); const g = audioCtx.createGain(); g.gain.setValueAtTime(1.5, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now+0.2); }
else if (type === 'coin') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square'; osc.frequency.setValueAtTime(1200, now); osc.frequency.setValueAtTime(1800, now+0.05); g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1); osc.start(now); osc.stop(now+0.1); }
else if (type === 'ui') { if(audioCtx.state === 'suspended') audioCtx.resume(); const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.frequency.setValueAtTime(880, now); g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1); osc.start(now); osc.stop(now+0.1); }
}
};
const MAX_HP = 10, TUBE_R = 36, MAX_TIME = 60, NORMAL_MAX_SPEED = 350.0, RUSH_SPEED = NORMAL_MAX_SPEED * 2.5, NORMAL_ACCEL = 200.0, GRAVITY = 100.0, JUMP_POWER = 60.0, TOTAL_STAGES = 8, FOG_NEAR = 150, FOG_FAR = 900, SPEED_DISPLAY_MULTIPLIER = 0.6;
const TILE_SEGMENT_LENGTH = 14, TILES_PER_RING = 16, VISIBLE_SEGMENTS = 90;
let tunnelSegments = [];
let scene, camera, renderer, clock, gameState = "TITLE", viewMode = "F3", isPaused = false, score = 0, stageScore = 0, hp = MAX_HP, timeLeft = MAX_TIME, currentStage = 1;
let player = { mesh: null, angle: 0, x: 0, z: 0, prevZ: 0, vz: 10, vAngle: 0, vx: 0, altitude: 0, jumpV: 0, surge: 0, bank: 0, isBoosting: false, dashOffset: 0, wasAirborne: false, barrier: null, shadow: null, sonicBoom: null, thrust: null, wingTipL: null, wingTipR: null, mode: "NORMAL", lockTargets: [], rushIndex: 0, lockTimer: 0, currentLockType: null, rushVisualOffset: 0 };
let worldObjects = [], debris = [], stars, skyMesh, planeGround, trackSplineGroup = null, keys = {}, nextSpawnZ = 150, trails = [];
let timeScale = 1.0;
class Trail {
constructor(color) { this.maxPoints = 60; this.points = []; this.width = 0.8; const geometry = new THREE.BufferGeometry(); const pos = new Float32Array(this.maxPoints * 2 * 3); geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const indices = []; for(let i=0; i<this.maxPoints-1; i++) { const base = i*2; indices.push(base, base+1, base+2, base+1, base+3, base+2); } geometry.setIndex(indices); const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }); this.mesh = new THREE.Mesh(geometry, material); this.mesh.frustumCulled = false; scene.add(this.mesh); }
update(newPos, rightVec) { const p1 = newPos.clone().addScaledVector(rightVec, this.width * 0.5), p2 = newPos.clone().addScaledVector(rightVec, -this.width * 0.5); this.points.unshift({p1, p2}); if(this.points.length > this.maxPoints) this.points.pop(); const positions = this.mesh.geometry.attributes.position.array; let idx = 0; for(let i=0; i<this.points.length; i++) { const pt = this.points[i]; positions[idx++] = pt.p1.x; positions[idx++] = pt.p1.y; positions[idx++] = pt.p1.z; positions[idx++] = pt.p2.x; positions[idx++] = pt.p2.y; positions[idx++] = pt.p2.z; } const last = this.points[this.points.length-1]; while(idx < positions.length) { positions[idx++] = last.p1.x; positions[idx++] = last.p1.y; positions[idx++] = last.p1.z; positions[idx++] = last.p2.x; positions[idx++] = last.p2.y; positions[idx++] = last.p2.z; } this.mesh.geometry.attributes.position.needsUpdate = true; }
reset() { this.points = []; const positions = this.mesh.geometry.attributes.position.array; for(let i=0; i<positions.length; i++) positions[i] = 0; this.mesh.geometry.attributes.position.needsUpdate = true; }
}
function getDefaultCurve(z, mode) {
const coarseX = Math.sin(z * 0.0015) * 200, coarseY = Math.cos(z * 0.0015) * 150, detailY = Math.sin(z * 0.006) * 80 + Math.sin(z * 0.015) * 30, detailX = Math.cos(z * 0.004) * 60;
if (mode === "F2") return new THREE.Vector3(0, coarseY + detailY, z);
return new THREE.Vector3(coarseX + detailX, coarseY + detailY, z);
}
function getBasis(z, mode) {
if (customCourseCurve) {
let t = (z % customCourseLength) / customCourseLength; if (t < 0) t += 1.0;
const u = t; const tParam = customCourseCurve.getUtoTmapping(u);
const origin = customCourseCurve.getPointAt(u), T = customCourseCurve.getTangentAt(u).normalize();
const segs = customCourseFrames.tangents.length; const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
const track = customCourseData.track, rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam), wVal = getLoopCatmullRom(track.map(n=>n.width||36), tParam);
const q = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const U = customCourseFrames.normals[fi].clone().applyQuaternion(q), R = new THREE.Vector3().crossVectors(T, U).normalize();
return { origin, T, U, R, twistRad: THREE.MathUtils.degToRad(tDeg), width: wVal };
} else {
const origin = getDefaultCurve(z, mode), forward = getDefaultCurve(z + 5.0, mode);
const T = new THREE.Vector3().subVectors(forward, origin).normalize(), R = new THREE.Vector3().crossVectors(T, new THREE.Vector3(0, 1, 0)).normalize(), U = new THREE.Vector3().crossVectors(R, T).normalize();
return { origin, T, U, R, twistRad: 0, width: TUBE_R };
}
}
function getSectionPosition(angle, radius, basis, mode) {
const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad);
const rAxis = basis.R.clone().applyQuaternion(qTwist), uAxis = basis.U.clone().applyQuaternion(qTwist);
if (mode === "F2" && !customCourseCurve) {
const spread = basis.width * Math.PI;
const ratio = angle / Math.PI;
const localX = ratio * (spread / 2);
return basis.origin.clone().addScaledVector(rAxis, localX).addScaledVector(uAxis, -radius);
} else {
const localX = Math.sin(angle) * radius, localY = -Math.cos(angle) * radius;
return basis.origin.clone().addScaledVector(rAxis, localX).addScaledVector(uAxis, localY);
}
}
function getSegmentColor(randomHue, lightnessVar = 0.0) { return new THREE.Color().setHSL(randomHue, 0.6, 0.6 + lightnessVar); }
function isGap(segmentIndex) { if (segmentIndex < 15) return false; return (segmentIndex % 60) >= 56; }
function initTunnel() {
const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
const boxGeo = new THREE.BoxGeometry(tileWidth * 1.1, 0.5, TILE_SEGMENT_LENGTH * 1.05);
for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
const segmentGroup = new THREE.Group();
segmentGroup.userData = { zIndex: i, hue: Math.random(), lVar: (Math.random() * 0.2) - 0.1 };
for (let j = 0; j < TILES_PER_RING; j++) {
const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, opacity: 0.1, transparent: true, side: THREE.DoubleSide, shininess: 100 });
const tile = new THREE.Mesh(boxGeo, mat); segmentGroup.add(tile);
}
tunnelSegments.push(segmentGroup); scene.add(segmentGroup);
}
}
function updateTunnel() {
if(customCourseCurve) { tunnelSegments.forEach(seg => seg.visible = false); return; }
const playerSegmentIndex = Math.floor(player.z / TILE_SEGMENT_LENGTH), startSegment = playerSegmentIndex - 8;
tunnelSegments.forEach((seg) => {
let segIdx = seg.userData.zIndex;
if (segIdx < startSegment) {
segIdx += VISIBLE_SEGMENTS; seg.userData.zIndex = segIdx; seg.userData.hue = Math.random(); seg.userData.lVar = (Math.random() * 0.2) - 0.1;
const newColor = getSegmentColor(seg.userData.hue, seg.userData.lVar);
seg.children.forEach(tile => { tile.material.color.copy(newColor); if(segIdx % 10 === 0) tile.material.emissive.setHex(0x222222); else tile.material.emissive.setHex(0x000000); });
}
const gap = isGap(segIdx); seg.visible = !gap;
const zPos = segIdx * TILE_SEGMENT_LENGTH, basis = getBasis(zPos, viewMode);
seg.children.forEach((tile, j) => {
let angle; if (viewMode === "F2") { const ratio = j / TILES_PER_RING; angle = (ratio * Math.PI * 2) - Math.PI; } else angle = (j / TILES_PER_RING) * Math.PI * 2;
tile.position.copy(getSectionPosition(angle, basis.width, basis, viewMode));
if (viewMode === "F2") {
const m = new THREE.Matrix4(); m.makeBasis(new THREE.Vector3(1, 0, 0), new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), basis.T).normalize(), new THREE.Vector3().crossVectors(new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), basis.T).normalize(), new THREE.Vector3(1, 0, 0)).normalize()); tile.rotation.setFromRotationMatrix(m);
} else {
const isOutside = (viewMode === "F4" || viewMode === "F5");
const normal = new THREE.Vector3().addScaledVector(basis.R, Math.sin(angle)).addScaledVector(basis.U, -Math.cos(angle)).normalize();
const up = isOutside ? normal : normal.clone().multiplyScalar(-1);
const tangent = new THREE.Vector3().crossVectors(up, basis.T).normalize();
const m = new THREE.Matrix4(); m.makeBasis(tangent, up, new THREE.Vector3().crossVectors(tangent, up).normalize()); tile.rotation.setFromRotationMatrix(m);
}
});
});
}
function init() {
sound.init(); clock = new THREE.Clock(); scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000); scene.fog = new THREE.Fog(0x000000, FOG_NEAR, FOG_FAR);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(90, window.innerWidth / window.innerHeight, 0.1, 5000);
scene.add(new THREE.AmbientLight(0x606060, 0.8)); const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(0, 100, -50); scene.add(sun); const rim = new THREE.DirectionalLight(0x00ffff, 0.8); rim.position.set(0, -50, 50); scene.add(rim);
planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000), new THREE.MeshPhongMaterial({ color: 0x222222 })); planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -200; scene.add(planeGround);
const starGeo = new THREE.BufferGeometry(); const posArray = new Float32Array(5000 * 3); for(let i=0; i<5000 * 3; i++) posArray[i] = (Math.random() - 0.5) * 6000; starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); stars = new THREE.Points(starGeo, new THREE.PointsMaterial({color: 0xffffff, size: 2.0, transparent: true, opacity: 0.8})); scene.add(stars);
const playerGeo = new THREE.Group();
const stealthGeo = new THREE.BufferGeometry(); const vertices = new Float32Array([0,0.5,3.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.5,3.0, 4.5,0,-2.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.8,-0.5, 0,0.5,-3.0, 0,0.8,-0.5, 4.5,0,-2.0, 0,0.5,-3.0, 0,0,3.0, -4.5,0,-2.0, 0,-0.3,-0.5, 0,0,3.0, 0,-0.3,-0.5, 4.5,0,-2.0, -4.5,0,-2.0, 0,0.5,-3.0, 0,-0.3,-0.5, 4.5,0,-2.0, 0,-0.3,-0.5, 0,0.5,-3.0]); stealthGeo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); stealthGeo.computeVertexNormals();
playerGeo.add(new THREE.Mesh(stealthGeo, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.4, metalness: 0.1, emissive: 0x333333, flatShading: true })));
const cockpit = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.5, 4), new THREE.MeshPhongMaterial({ color: 0x00aaff, emissive: 0x002244, shininess: 100 })); cockpit.rotation.x = -Math.PI * 0.4; cockpit.position.set(0, 0.6, 0.5); cockpit.scale.z = 0.5; playerGeo.add(cockpit);
player.wingTipL = new THREE.Object3D(); player.wingTipL.position.set(-4.5, 0, -2.0); player.wingTipR = new THREE.Object3D(); player.wingTipR.position.set(4.5, 0, -2.0); playerGeo.add(player.wingTipL); playerGeo.add(player.wingTipR);
player.thrust = new THREE.Mesh(new THREE.ConeGeometry(0.4, 4, 8), new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.8 })); player.thrust.rotation.x = Math.PI / 2; player.thrust.position.z = -3.5; playerGeo.add(player.thrust);
player.barrier = new THREE.Mesh(new THREE.IcosahedronGeometry(3.5, 1), new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3, wireframe: true, emissive: 0x00aaaa, shininess: 100 })); player.barrier.position.z = 0; playerGeo.add(player.barrier);
player.sonicBoom = new THREE.Mesh(new THREE.SphereGeometry(5.0, 32, 16, 0, Math.PI * 2, 0, Math.PI * 0.5), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4, side: THREE.DoubleSide, blending: THREE.AdditiveBlending })); player.sonicBoom.rotation.x = -Math.PI / 2; player.sonicBoom.position.z = 2.0; player.sonicBoom.visible = false; playerGeo.add(player.sonicBoom);
player.mesh = playerGeo; scene.add(player.mesh);
player.shadow = new THREE.Mesh(new THREE.PlaneGeometry(4.0, 4.0), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.8 })); scene.add(player.shadow);
trails.push(new Trail(0x00ffff)); trails.push(new Trail(0x00ffff));
initTunnel();
window.addEventListener('keydown', (e) => {
if(["F1", "F2", "F3", "F4", "F5"].includes(e.code)) {
e.preventDefault(); viewMode = e.code; document.getElementById('view-mode').innerText = "VIEW: " + e.code;
if(customCourseData) rebuildTunnelMesh(viewMode);
}
keys[e.code] = true;
if (e.code === 'Escape' && gameState === "PLAYING") { isPaused = !isPaused; if(isPaused) { sound.suspend(); document.getElementById('center-text').innerHTML = "<h1 style='color:white;'>PAUSED</h1>"; clock.stop(); } else { sound.resume(); document.getElementById('center-text').innerHTML = ""; clock.start(); } }
if(e.code === 'Space') {
e.preventDefault();
if (gameState === "TITLE" || gameState === "GAMEOVER" || gameState === "ALL_CLEAR") {
sound.play('ui'); sound.startEngine(); if(gameState !== "TITLE") { score = 0; currentStage = 1; }
const sel = document.getElementById('course-select-box');
if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value);
else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); }
}
else if (gameState === "STAGE_CLEAR") { sound.play('ui'); currentStage++; changeState("STAGE_START"); }
else if (gameState === "STAGE_START") { sound.play('ui'); changeState("PLAYING"); }
}
if(e.code === 'KeyX') { if (gameState === "PLAYING" && player.altitude <= 0.1 && !isPaused) { sound.play('jump'); player.jumpV = JUMP_POWER; player.wasAirborne = true; } }
if(e.code === 'KeyZ' && gameState === "PLAYING" && !isPaused) { if (player.lockTargets.length > 0) { player.mode = "RUSHING"; player.rushIndex = 0; player.isBoosting = true; sound.play('boost_dash'); } else if (player.mode === "NORMAL") { player.mode = "MANUAL_BOOST"; player.isBoosting = true; sound.play('boost_dash'); } }
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; if (e.code === 'KeyZ') { if (player.mode === "RUSHING" || player.lockTargets.length > 0) { player.mode = "NORMAL"; player.isBoosting = false; clearAllLocks(); } else if (player.mode === "MANUAL_BOOST") { player.mode = "NORMAL"; player.isBoosting = false; } } });
changeState("TITLE"); animate();
}
function clearAllLocks() { player.lockTargets.forEach(obj => { obj.locked = false; if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; } }); player.lockTargets = []; player.currentLockType = null; player.rushIndex = 0; }
function rebuildTunnelMesh(mode) {
if (trackSplineGroup) scene.remove(trackSplineGroup); if (!customCourseData || !customCourseCurve) return;
const SEGMENTS = customCourseData.track.length * 20, WALL_RADIAL_SEGS = 16;
const frames = customCourseFrames, track = customCourseData.track;
const positions = [], uvs = [], indices = [];
const geo = new THREE.BufferGeometry();
const materials = []; const matIndexMap = {};
const env = customCourseData.assets?.env || {};
const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide });
if(env.road && loadedTextures[env.road]) { roadMat.map = loadedTextures[env.road]; roadMat.needsUpdate = true; }
const wallMat = new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide });
if(env.wall && loadedTextures[env.wall]) { wallMat.map = loadedTextures[env.wall]; wallMat.needsUpdate = true; } else wallMat.wireframe = true;
materials.push(roadMat); materials.push(wallMat);
let nextMatIdx = 2;
const envTexList = customCourseData.assets?.envTextures || [];
envTexList.forEach(key => {
const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, side: THREE.DoubleSide });
if(loadedTextures[key]) { mat.map = loadedTextures[key]; mat.needsUpdate=true; }
materials.push(mat); matIndexMap[key] = nextMatIdx++;
});
for (let i = 0; i <= SEGMENTS; i++) {
const u = i / SEGMENTS, tParam = customCourseCurve.getUtoTmapping(u);
const pt = customCourseCurve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
const rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam), currentW = getLoopCatmullRom(track.map(n=>n.width||36), tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
for (let j = 0; j <= 16; j++) {
const angle = (j / 16) * Math.PI * 2 - Math.PI;
let v;
if(j >= 4 && j <= 12) { // Road
v = pt.clone().addScaledVector(twistRight, Math.sin(angle)*currentW).addScaledVector(twistUp, -Math.cos(angle)*currentW);
} else { // Wall
v = pt.clone().addScaledVector(right, Math.sin(angle)*currentW).addScaledVector(up, -Math.cos(angle)*currentW);
}
positions.push(v.x, v.y, v.z);
uvs.push(j / 16, u * track.length * 8);
}
}
let faceIdx = 0;
const scenery = customCourseData.scenery || [];
for (let i = 0; i < SEGMENTS; i++) {
const segIdx = Math.floor((i / SEGMENTS) * track.length);
const row = i % 20;
for (let c = 0; c < 16; c++) {
if(mode === "F2" && (c < 4 || c > 11)) continue;
const a = i*17+c, b = (i+1)*17+c, c_idx = (i+1)*17+(c+1), d = i*17+(c+1);
indices.push(a,b,d); indices.push(b,c_idx,d);
let mIdx = (c >= 4 && c <= 11) ? 0 : 1;
const texItem = scenery.find(s => s.type === 'texture' && s.segIdx === segIdx && s.row === row && s.col === c);
if (texItem && matIndexMap[texItem.texturePath]) mIdx = matIndexMap[texItem.texturePath];
geo.addGroup(faceIdx * 6, 6, mIdx);
faceIdx++;
}
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices); geo.computeVertexNormals();
trackSplineGroup = new THREE.Mesh(geo, materials);
scene.add(trackSplineGroup);
}
function changeState(s) {
gameState = s; document.getElementById('hud-bottom').style.display = (s === "PLAYING") ? "flex" : "none";
const menu = document.getElementById('center-text'); document.getElementById('speed-lines').style.opacity = 0;
if(s === "TITLE") {
menu.innerHTML = `<h1>Image Booster 7.3</h1><p style='color:#adff2f;'>CUSTOM SPLINE</p>${getCourseSelectUI()}`;
document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
document.getElementById('start-btn').addEventListener('click', () => { const sel = document.getElementById('course-select-box'); if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value); else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); } }); populateCourseSelect();
}
else if(s === "STAGE_START") { stageScore = 0; menu.innerHTML = "<h1>READY?</h1>"; resetPlayerPos(); setTimeout(()=>changeState("PLAYING"), 1500); }
else if(s === "PLAYING") menu.innerHTML = "";
else if(s === "STAGE_CLEAR" || s === "GAMEOVER") {
const isClear = s === "STAGE_CLEAR";
menu.innerHTML = `<h1 style='color:${isClear?"#0f0":"red"};'>${isClear?"STAGE CLEAR!":"CRITICAL FAILURE"}</h1><p>SCORE: ${score}</p>${getCourseSelectUI()}`;
document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
document.getElementById('start-btn').addEventListener('click', () => { const sel = document.getElementById('course-select-box'); if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value); else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); } }); populateCourseSelect();
}
}
function resetPlayerPos() { player.angle = 0; player.x = 0; player.z = 0; player.prevZ = 0; player.vz = 0.0; player.altitude = 0; player.jumpV = 0; player.surge = 0; player.bank = 0; player.dashOffset = 0; hp = MAX_HP; timeLeft = MAX_TIME; player.mode = "NORMAL"; clearAllLocks(); player.isBoosting = false; player.rushVisualOffset = 0; debris.forEach(d => scene.remove(d)); debris = []; trails.forEach(t => t.reset()); nextSpawnZ = 150; tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; }); timeScale = 1.0; }
function animate() {
requestAnimationFrame(animate);
const rawDt = clock.getDelta();
timeScale += (1.0 - timeScale) * (rawDt * 10.0);
const dt = rawDt * timeScale;
camera.fov = THREE.MathUtils.lerp(camera.fov, (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? 130 : 90, 5 * rawDt);
camera.updateProjectionMatrix();
if (gameState === "PLAYING" && !isPaused) { const safeDt = Math.min(dt, 0.1); updatePhysics(safeDt); updateObjects(safeDt); updateDebris(safeDt); updateAutoLock(safeDt); }
if(gameState === "PLAYING" && !isPaused) { let ratio = player.vz / NORMAL_MAX_SPEED; if(player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ratio = 3.0; sound.updateEngine(ratio, player.isBoosting); } else if (!isPaused) sound.updateEngine(0, false);
if(!isPaused) { if(!customCourseCurve) updateTunnel(); updatePlayerVisuals(); if(stars) { stars.position.set(0, 0, player.z); stars.rotation.z += 0.0005; } }
renderer.render(scene, camera);
}
function updatePhysics(dt) {
if (player.mode === "RUSHING") { handleRushPhysics(dt); return; }
let currentAccel = 0; const isBraking = keys['ArrowDown'];
player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 10 * dt);
if (player.mode === "MANUAL_BOOST") { if (player.vz < RUSH_SPEED) player.vz += NORMAL_ACCEL * 4.0 * dt; player.surge = THREE.MathUtils.lerp(player.surge, 10.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0.8; }
else if (keys['ArrowUp']) { currentAccel = NORMAL_ACCEL; player.surge = THREE.MathUtils.lerp(player.surge, 3.0, 5 * dt); player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0; }
else { currentAccel = 0; player.surge = THREE.MathUtils.lerp(player.surge, 0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0; }
if (isBraking && player.mode !== "MANUAL_BOOST") { player.vz -= NORMAL_ACCEL * 1.5 * dt; player.surge = THREE.MathUtils.lerp(player.surge, -3.0, 5 * dt); if (brakeCooldown <= 0) { sound.play('brake'); brakeCooldown = 0.2; } brakeCooldown -= dt; if (player.vz > 50) createDebris(player.mesh.position.clone(), 0xffaa00, 1.0, 1, "spark_brake"); } else brakeCooldown = 0;
if (player.mode !== "MANUAL_BOOST") { if (currentAccel > 0) { if (player.vz < NORMAL_MAX_SPEED) player.vz += currentAccel * dt; else player.vz *= (1.0 - (0.5 * dt)); } else if (!isBraking) player.vz *= (1.0 - (0.2 * dt)); }
player.vz = Math.max(0.0, player.vz); handleGravity(dt); handleSteering(dt); player.prevZ = player.z; player.z += player.vz * dt; updateGameTimers(dt); updateUI();
}
function updateAutoLock(dt) {
if (player.mode === "RUSHING") return; player.lockTimer += dt; if (player.lockTimer < 0.1) return; player.lockTimer = 0;
if (player.lockTargets.length > 0) return;
let candidates = worldObjects.filter(obj => {
if (obj.locked || obj.isDead || obj.z < player.z || obj.isScenery) return false;
let angleDiff = Math.abs(obj.angle - player.angle);
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
return Math.abs(angleDiff) <= 3.2 && (!player.currentLockType || player.currentLockType === obj.type);
});
if (candidates.length > 0) {
candidates.sort((a,b) => a.z - b.z);
const target = candidates[0];
target.locked = true;
player.lockTargets.push(target);
player.currentLockType = target.type;
const div = document.createElement('div'); div.className = 'lock-on-sight'; let col = "#fff"; if(target.type==="block")col="#ddd"; else if(target.type==="hurdle")col="#f30"; else if(target.type==="heal")col="#0f0"; else if(target.type==="score")col="#ff0"; div.style.borderColor = col; div.style.boxShadow = `0 0 10px ${col}, inset 0 0 10px ${col}`; document.getElementById('ui-layer').appendChild(div); target.sightDom = div; sound.play('lockon');
}
}
function handleRushPhysics(dt) {
player.lockTargets = player.lockTargets.filter(t => !t.isDead);
if (player.lockTargets.length === 0 || player.rushIndex >= player.lockTargets.length) {
if (keys['KeyZ']) player.mode = "MANUAL_BOOST"; else { player.mode = "NORMAL"; player.isBoosting = false; }
player.surge = 0;
clearAllLocks();
return;
}
const targetObj = player.lockTargets[player.rushIndex];
if (!worldObjects.includes(targetObj) || targetObj.isDead) { player.rushIndex++; return; }
const rushDist = RUSH_SPEED * dt;
const distToTarget = targetObj.z - player.z;
if (distToTarget < 250 && distToTarget > rushDist) {
timeScale = 0.3;
if (distToTarget > 80) {
player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 120, 10 * dt);
} else {
player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 20 * dt);
timeScale = 0.1;
}
} else {
timeScale = 1.0;
player.rushVisualOffset = THREE.MathUtils.lerp(player.rushVisualOffset || 0, 0, 5 * dt);
}
if (distToTarget <= rushDist) {
player.prevZ = player.z; player.z = targetObj.z; player.angle = targetObj.angle;
handleCollision(targetObj, true);
player.surge = 25.0;
player.rushIndex++;
timeScale = 0.05;
player.rushVisualOffset = 0;
sound.play('crash');
} else {
player.prevZ = player.z; player.z += rushDist;
const t = 10.0 * dt;
player.angle = THREE.MathUtils.lerp(player.angle, targetObj.angle, t);
player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t);
player.surge = THREE.MathUtils.lerp(player.surge, 30.0, 5 * dt);
document.getElementById('speed-lines').style.opacity = 1.0;
}
updateGameTimers(dt); updateUI();
}
function handleGravity(dt) {
const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
const inGap = (!customCourseCurve && isGap(currentSegIdx));
const speedKmh = player.vz * SPEED_DISPLAY_MULTIPLIER;
const isFloating = (speedKmh > 30 && !player.wasAirborne);
player.altitude += player.jumpV * dt;
if (inGap && !isFloating) {
player.jumpV -= GRAVITY * dt;
} else {
if (player.altitude > 0) player.jumpV -= GRAVITY * dt;
else { if (player.wasAirborne && player.jumpV < -10) { sound.play('land'); player.wasAirborne = false; } player.altitude = 0; player.jumpV = 0; }
}
if (player.altitude < -40) { hp = 0; changeState("GAMEOVER"); }
}
function handleSteering(dt) {
let steerFactor = (player.vz > 300.0) ? 0.5 : 1.0; steerFactor *= (viewMode === "F2" ? 4.0 : 2.0); let targetBank = 0; const BANK_LIMIT = 0.78;
const isOutside = (viewMode === "F4" || viewMode === "F5");
const steerDirection = isOutside ? -1 : 1;
if (keys['ArrowLeft']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, -1.5 * steerFactor * steerDirection, 5 * dt); targetBank = -BANK_LIMIT; }
else if (keys['ArrowRight']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, 1.5 * steerFactor * steerDirection, 5 * dt); targetBank = BANK_LIMIT; }
else player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0, 10 * dt);
player.angle += player.vAngle * dt; player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 5 * dt);
if (viewMode === "F2") { const limit = Math.PI * 1.0; if (player.angle > limit) { player.angle = limit; player.vAngle = 0; } else if (player.angle < -limit) { player.angle = -limit; player.vAngle = 0; } }
if (Math.abs(player.bank) > (BANK_LIMIT * 0.95)) { if(bankSoundTimer <= 0) { sound.play('scrape'); bankSoundTimer = 0.2; } bankSoundTimer -= dt; createDebris(player.mesh.position.clone(), 0xffaa00, 0.5, 1, "spark"); } else bankSoundTimer = 0;
}
function updateGameTimers(dt) { timeLeft -= dt; if (timeLeft <= 0) { timeLeft = 0; changeState("STAGE_CLEAR"); } if (hp <= 0) { hp = 0; changeState("GAMEOVER"); } }
function updatePlayerVisuals() {
const visualZ = player.z + player.surge + player.dashOffset, basis = getBasis(visualZ, viewMode);
const isOutside = (viewMode === "F4" || viewMode === "F5");
const r = isOutside ? basis.width + 5.0 + player.altitude : basis.width - 5.0 - player.altitude;
const pos = getSectionPosition(player.angle, r, basis, viewMode);
const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad), uAxis = basis.U.clone().applyQuaternion(qTwist);
let playerUp;
if (viewMode === "F2" && !customCourseCurve) { playerUp = uAxis.clone(); }
else {
const isRoad = (player.angle >= -Math.PI/2 && player.angle <= Math.PI/2);
const rAxis = isRoad ? basis.R.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.R.clone();
const uyAxis = isRoad ? basis.U.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.U.clone();
const normal = new THREE.Vector3().addScaledVector(rAxis, Math.sin(player.angle)).addScaledVector(uyAxis, -Math.cos(player.angle)).normalize();
playerUp = isOutside ? normal : normal.clone().negate();
}
const playerForward = basis.T.clone();
player.mesh.position.copy(pos); const m = new THREE.Matrix4(), right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize(), orthoUp = new THREE.Vector3().crossVectors(right, playerForward).normalize();
m.makeBasis(right, orthoUp, playerForward); player.mesh.rotation.setFromRotationMatrix(m); player.mesh.rotateZ(player.bank); if (player.altitude > 0.5) player.mesh.rotateX(-0.1);
player.mesh.visible = (viewMode !== "F1" && viewMode !== "F4");
const rushOffset = player.rushVisualOffset || 0;
if (viewMode !== "F1" && viewMode !== "F4") {
player.mesh.position.addScaledVector(playerForward, rushOffset);
}
if (player.mesh.visible) { const tipLPos = new THREE.Vector3(), tipRPos = new THREE.Vector3(); player.wingTipL.getWorldPosition(tipLPos); player.wingTipR.getWorldPosition(tipRPos); const widthVec = orthoUp.clone().normalize(); if (trails[0]) trails[0].update(tipLPos, widthVec); if (trails[1]) trails[1].update(tipRPos, widthVec); }
if (player.sonicBoom) { player.sonicBoom.visible = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST"); if(player.sonicBoom.visible) { const s = 1.0 + Math.random() * 0.1; player.sonicBoom.scale.set(s, s, s); } }
if (Math.abs(player.bank) > 0.7 && player.vz > 100.0) { const sideOffset = (player.bank > 0) ? 2.0 : -2.0; const sparkPos = player.mesh.position.clone().addScaledVector(right, sideOffset).addScaledVector(playerUp, -0.5); createDebris(sparkPos, 0xffff00, 0.3, 2, "spark"); }
if (player.thrust) { if (player.vz < 1.0 && player.mode !== "RUSHING" && player.mode !== "MANUAL_BOOST") player.thrust.visible = false; else { player.thrust.visible = true; const speedRatio = player.vz / RUSH_SPEED; player.thrust.scale.set(0.5, 0.5, 0.5 + speedRatio * 1.5); player.thrust.material.opacity = 0.6 + speedRatio * 0.4; player.thrust.material.color.setHex((player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? 0xff00ff : 0x00ffff); } }
if (player.barrier) { player.barrier.scale.setScalar(0.8 + (Math.max(0, hp / MAX_HP) * 0.2)); player.barrier.rotation.y += 0.05; if (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") { player.barrier.material.color.setHex(0xff0000); player.barrier.material.emissive.setHex(0x550000); } else { player.barrier.material.color.setHex(0x00ffff); player.barrier.material.emissive.setHex(0x004444); } }
if (player.shadow) {
if (viewMode !== "F1" && viewMode !== "F4" && (!customCourseCurve ? !isGap(Math.floor(player.z/TILE_SEGMENT_LENGTH)) : true)) {
player.shadow.visible = true;
let shadowR = isOutside ? basis.width + 0.5 : basis.width - 0.5;
player.shadow.position.copy(getSectionPosition(player.angle, shadowR, basis, viewMode));
player.shadow.position.addScaledVector(playerForward, rushOffset);
const sm = new THREE.Matrix4();
sm.makeBasis(right, playerUp, playerForward);
player.shadow.rotation.setFromRotationMatrix(sm);
player.shadow.rotateX(-Math.PI/2);
player.shadow.scale.setScalar(Math.max(0.1, 1.0 - Math.max(0, player.altitude) * 0.02));
} else {
player.shadow.visible = false;
}
}
const shake = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? (Math.random()-0.5)*1.0 : (Math.random()-0.5)*0.1; const cameraUp = orthoUp.clone(); cameraUp.applyAxisAngle(playerForward, player.bank * 0.6);
const lookTarget = player.mesh.position.clone().addScaledVector(playerForward, 30);
if (viewMode === "F1" || viewMode === "F4") { const eyePos = pos.clone().addScaledVector(playerForward, 2.0).addScaledVector(orthoUp, 0.5); camera.position.copy(eyePos); camera.position.y += shake; camera.up.copy(cameraUp); camera.lookAt(eyePos.clone().add(playerForward)); }
else if (viewMode === "F2") { const camPos = pos.clone().addScaledVector(playerForward, -60).addScaledVector(playerUp, 50); camera.position.copy(camPos); camera.up.copy(playerUp); camera.lookAt(player.mesh.position); }
else { const camPos = pos.clone().addScaledVector(playerForward, -30).addScaledVector(orthoUp, 8); camPos.addScaledVector(right, shake); camera.position.copy(camPos); camera.up.copy(cameraUp); camera.lookAt(lookTarget); }
worldObjects.forEach(obj => { if (obj.sightDom) { if (obj.isDead || obj.z < player.z) { obj.locked = false; obj.sightDom.remove(); obj.sightDom = null; return; } const vector = obj.mesh.position.clone(); vector.project(camera); obj.sightDom.style.left = (vector.x*.5+.5)*window.innerWidth + 'px'; obj.sightDom.style.top = -(vector.y*.5-.5)*window.innerHeight + 'px'; if (player.mode === "RUSHING" && player.lockTargets[player.rushIndex] === obj) obj.sightDom.classList.add('rushing'); } });
}
function updateObjects(dt) {
let spawnDist = ((150 - (currentStage * 10.0)) / 3.0) + (player.vz * 0.5);
if (player.z + 1500 > nextSpawnZ && !customCourseCurve) { spawnRandomObject(nextSpawnZ); nextSpawnZ += Math.random() * 50 + spawnDist; }
for (let i = worldObjects.length - 1; i >= 0; i--) {
const obj = worldObjects[i]; if (obj.isDead && !obj.flying) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; }
if (obj.isScenery) continue;
if(obj.flying) { obj.localPos.addScaledVector(obj.vel, dt * 60); obj.mesh.rotation.x += 10 * dt; obj.mesh.rotation.y += 10 * dt; }
const basis = getBasis(obj.z, viewMode); let worldPos;
if (obj.flying) worldPos = basis.origin.clone().add(obj.localPos);
else {
const isOutside = (viewMode === "F4" || viewMode === "F5");
let objR = isOutside ? (basis.width + obj.altitude) : (basis.width - obj.altitude);
worldPos = getSectionPosition(obj.angle, objR, basis, viewMode);
let up;
if(viewMode === "F2" && !customCourseCurve) {
up = basis.U.clone().applyAxisAngle(basis.T, basis.twistRad);
} else {
const isRoad = (obj.angle >= -Math.PI/2 && obj.angle <= Math.PI/2);
const rAxis = isRoad ? basis.R.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.R.clone();
const uAxis = isRoad ? basis.U.clone().applyAxisAngle(basis.T, basis.twistRad) : basis.U.clone();
const normal = new THREE.Vector3().addScaledVector(rAxis, Math.sin(obj.angle)).addScaledVector(uAxis, -Math.cos(obj.angle)).normalize();
up = isOutside ? normal : normal.clone().negate();
}
const right = new THREE.Vector3().crossVectors(up, basis.T).normalize(); const m = new THREE.Matrix4(); m.makeBasis(right, up, new THREE.Vector3().crossVectors(right, up).normalize()); obj.mesh.rotation.setFromRotationMatrix(m);
}
obj.mesh.position.copy(worldPos);
if (player.mode !== "RUSHING" && !obj.flying && !obj.isDead) { const passedThrough = (obj.z >= player.prevZ && obj.z <= player.z); if (passedThrough || Math.abs(player.z - obj.z) < 10.0) { let dx = Math.abs(obj.angle - player.angle) * 10.0; if ((passedThrough && dx < 5.0) || player.mesh.position.distanceTo(obj.mesh.position) < 8.0) { handleCollision(obj, true); if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; } } } }
if (obj.z < player.z - 100) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); }
}
}
function spawnRandomObject(z) {
let type = (Math.random() < 0.1) ? "score" : (Math.random() < 0.05) ? "heal" : "block"; if (Math.random() < Math.min(0.4, 0.1 + (currentStage * 0.04))) type = "hurdle";
let xPos = (Math.random() - 0.5) * 2.4;
let length = (type === 'block') ? 3 : 1;
const basis = getBasis(z, viewMode);
const currentW = basis.width;
for(let i = 0; i < length; i++) {
let zOffset = z + (i * 12.0), stackHeight = (type === 'block') ? Math.floor(Math.random() * 2) + 1 : 1;
let baseAlt = 1.5;
if (type === "hurdle") baseAlt = 2.0; else if (type === "heal") baseAlt = 1.0; else if (type === "score") baseAlt = 1.5;
for (let k = 0; k < stackHeight; k++) {
let geo, color; if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); color = new THREE.Color(0xff3300); } else if (type === 'heal') { geo = new THREE.BoxGeometry(2,2,2); color = new THREE.Color(0x00ff00); } else if (type === 'score') { geo = new THREE.BoxGeometry(3,3,3); color = new THREE.Color(0xffff00); } else { geo = new THREE.BoxGeometry(3,3,3); const g = 0.3+Math.random()*0.5; color = new THREE.Color(g,g,g); } const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color }));
let currentAlt = baseAlt + (type === 'block' ? k * 3.0 : 0);
scene.add(mesh); worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: xPos, altitude: currentAlt, vel: new THREE.Vector3(), localPos: new THREE.Vector3(Math.sin(xPos)*currentW, -Math.cos(xPos)*currentW, 0), locked: false, sightDom: null, isDead: false, isScenery: false });
}
}
}
function handleCollision(obj, forcedKill = false) {
if (obj.isDead) return; obj.isDead = true;
if (obj.type === "block" || obj.type === "hurdle") {
if (forcedKill) { sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 2.0, 15, "explode"); if (obj.type === "hurdle") { hp -= 1; showPopText("BREAK! -1HP", "#ff3300"); } else { score += 50; stageScore += 50; showPopText("SMASH! +50", "#ffaa00"); } scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }
else { if (obj.type === "hurdle") { hp -= 2; showPopText("CRASH! -2HP", "#ff0000"); sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 10, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*2, 5, 8); } else { score += 10; stageScore += 10; showPopText("HIT! +10", "#ffffff"); sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 5, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*4, 10, 15); } if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; } }
} else if (obj.type === "score") { sound.play('coin'); score += 100; stageScore += 100; showPopText("+100", "#ffff00"); } else if (obj.type === "heal") { sound.play('heal'); hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff00"); }
else if (obj.type === "dash") { sound.play('boost_dash'); player.surge += 15.0; showPopText("DASH!", "#0088ff"); }
}
function createDebris(pos, color, size, count, type) {
for(let i=0; i<count; i++) { const s = (type.includes("spark")) ? size * (Math.random()*0.5 + 0.5) : size; const mesh = new THREE.Mesh(new THREE.BoxGeometry(s, s, s), new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0 })); mesh.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5)*2, (Math.random()-0.5)*2)); scene.add(mesh); let vel, life; if (type === "spark") { vel = new THREE.Vector3((Math.random()-0.5)*5, (Math.random())*5, (Math.random()-0.5)*5); life = 0.5; } else if (type === "spark_brake") { vel = new THREE.Vector3((Math.random()-0.5)*10, 5+Math.random()*10, 5+Math.random()*10); life = 0.3; } else { vel = new THREE.Vector3((Math.random()-0.5)*30, (Math.random()-0.5)*30, (Math.random()-0.5)*30); life = 1.0; } debris.push({ mesh, vel, life, type }); }
}
function updateDebris(dt) { for(let i = debris.length - 1; i >= 0; i--) { const d = debris[i]; d.mesh.position.addScaledVector(d.vel, dt * 20); d.life -= dt; if (d.type.includes("spark")) d.mesh.rotation.z += 10 * dt; else d.mesh.rotation.x += 5 * dt; if(d.life <= 0) { scene.remove(d.mesh); debris.splice(i, 1); } } }
function showPopText(text, color) { const div = document.createElement('div'); div.className = 'pop-text'; div.style.color = color; div.innerText = text; div.style.left = "50%"; div.style.top = "40%"; document.getElementById('ui-layer').appendChild(div); setTimeout(() => div.remove(), 800); }
function updateUI() { document.getElementById('score-info').innerText = "SCORE: " + score; document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%"; const val = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? RUSH_SPEED : player.vz; document.getElementById('speed-val').innerText = (val * SPEED_DISPLAY_MULTIPLIER).toFixed(0); document.getElementById('speed-fill').style.width = Math.min(100, (val / RUSH_SPEED) * 100) + "%"; document.getElementById('speed-fill').style.backgroundColor = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? "#ff00ff" : "#00ff88"; const rTime = Math.max(0, timeLeft); document.getElementById('time-num').innerText = Math.floor(rTime / 60) + ":" + (Math.floor(rTime % 60) < 10 ? "0" : "") + Math.floor(rTime % 60); document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%"; }
init();
</script>
</body>
</html>

コメント