ImageBoosterのコースエディタ
【更新履歴】
・2026/3/6 バージョン1.0公開。
・2026/3/6 バージョン1.1公開。
・「Image Booster」についてはこちら。↓
・ダウンロードされる方はこちら。↓
・コースエディタのソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Booster Course Editor 1.1</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
#menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e; }
.menu-item { padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc; }
.menu-item:hover { background: #3e3e3e; color: #fff; }
.dropdown { display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.menu-item:hover .dropdown { display: block; }
.dropdown-item { padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px; }
.dropdown-item:hover { background: #094771; color: #fff; }
.separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }
#container { display: flex; height: calc(100vh - 30px); }
#canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #111; }
#properties-panel { width: 280px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto; }
.prop-group { margin-bottom: 15px; }
.prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
input[type="text"], input[type="number"], input[type="color"], select { width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 4px; font-size: 12px; border-radius: 2px; text-align: right; }
select { text-align: left; cursor: pointer; }
input:focus, select:focus { border-color: #007fd4; outline: none; }
button.action-btn { width: 100%; padding: 6px; background: #333; color: #ccc; border: 1px solid #444; cursor: pointer; font-size: 12px; border-radius: 2px; transition: 0.1s; margin-bottom: 5px; }
button.action-btn:hover { background: #444; color: #fff; }
button.highlight { background: #0e639c; color: white; border: none; }
button.highlight:hover { background: #1177bb; }
.file-select-row { display: flex; gap: 5px; margin-bottom: 8px; }
.file-select-row input[type="text"] { flex-grow: 1; margin-bottom: 0; color: #aaa; }
.file-select-row button { width: auto; margin-bottom: 0; padding: 4px 10px; }
#status-bar { position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8); }
#info-overlay { position: absolute; top: 10px; left: 10px; pointer-events: none; }
#mode-display { background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block; }
#height-display { background: rgba(0,0,0,0.5); color: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table; }
.axis-label { position: absolute; font-family: monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff; }
#modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 3000; justify-content: center; align-items: center; }
.modal-box { background: #252526; padding: 20px; border: 1px solid #454545; width: 350px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
.modal-box h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; margin-bottom: 15px; }
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script>
</head>
<body>
<div id="menubar">
<div class="menu-item">File
<div class="dropdown">
<div class="dropdown-item" id="menu-new">New Course</div>
<div class="dropdown-item" id="menu-open">Open Course JSON</div>
<div class="dropdown-item" id="menu-save">Save Course JSON</div>
</div>
</div>
<div class="menu-item">Edit
<div class="dropdown">
<div class="dropdown-item" id="tool-select">Select</div>
<div class="dropdown-item" id="tool-add">Add</div>
<div class="dropdown-item" id="tool-paint">Paint (Block Texture)</div>
<div class="dropdown-item" id="tool-erase">Erase (Del)</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-move">Move</div>
<div class="dropdown-item" id="tool-rotate">Rotate</div>
<div class="dropdown-item" id="tool-scale">Scale</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-cam-move">Camera Move</div>
<div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
<div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
<div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
</div>
</div>
<div class="menu-item">View
<div class="dropdown">
<div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
<div class="dropdown-item" onclick="viewCam('top')">Top</div>
<div class="dropdown-item" onclick="viewCam('bottom')">Bottom</div>
<div class="dropdown-item" onclick="viewCam('front')">Front</div>
<div class="dropdown-item" onclick="viewCam('back')">Back</div>
<div class="dropdown-item" onclick="viewCam('left')">Left</div>
<div class="dropdown-item" onclick="viewCam('right')">Right</div>
</div>
</div>
<div class="menu-item" id="menu-environment">Settings</div>
</div>
<div id="container">
<div id="canvas-container" tabindex="0">
<div id="info-overlay"><div id="mode-display">Select</div><div id="height-display">Height: 0</div></div>
<div id="status-bar">Ready</div>
<div id="labels-container"></div>
</div>
<div id="properties-panel">
<h3 id="prop-header" style="color:#4fc1ff; margin-top:0; border-bottom:1px solid #444; padding-bottom:5px;">Properties</h3>
<div id="prop-add-settings" style="display:none;">
<div class="prop-group">
<span class="prop-label">Item to Add</span>
<select id="add-item-type">
<option value="node">Track Node</option>
<option value="scenery">Scenery Block</option>
<option value="bgmodel">Background Model</option>
</select>
</div>
<div id="add-scenery-options" style="display:none;">
<div class="prop-group">
<span class="prop-label">Block Color</span>
<input type="color" id="brush-color" value="#888888">
</div>
<div class="prop-group">
<span class="prop-label">Block Texture File</span>
<div class="file-select-row">
<input type="text" id="brush-tex-name" disabled placeholder="None">
<button class="action-btn" id="btn-select-brush-tex">Select</button>
</div>
<button class="action-btn" id="btn-clear-brush-tex">Clear Texture</button>
</div>
</div>
<div id="add-bgmodel-options" style="display:none;">
<div class="prop-group">
<span class="prop-label">Model List (Loaded)</span>
<select id="bgmodel-list" size="5"></select>
</div>
<button class="action-btn highlight" id="btn-load-model">Select Model File to Load</button>
</div>
</div>
<div id="prop-content" style="display:none;">
<div class="prop-group">
<span class="prop-label">Position (X, Y, Z)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-x" step="10"><input type="number" id="prop-y" step="10"><input type="number" id="prop-z" step="10">
</div>
</div>
<div id="prop-node-group">
<div class="prop-group"><span class="prop-label">Bank / Roll (Deg)</span><input type="number" id="prop-roll" step="5"></div>
<div class="prop-group"><span class="prop-label" style="color:#ffcc00;">Road Twist (Deg)</span><input type="number" id="prop-twist" step="5"></div>
</div>
<div id="prop-scenery-group">
<div class="prop-group"><span class="prop-label">Rotation Y (Deg)</span><input type="number" id="prop-ry" step="15"></div>
<div class="prop-group">
<span class="prop-label">Scale (X, Y, Z)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-sx" step="1"><input type="number" id="prop-sy" step="1"><input type="number" id="prop-sz" step="1">
</div>
</div>
<div class="prop-group" id="prop-color-group">
<span class="prop-label">Color</span><input type="color" id="prop-color">
<span class="prop-label" style="margin-top:10px;">Texture File</span>
<div class="file-select-row">
<input type="text" id="prop-tex-name" disabled placeholder="None">
<button class="action-btn" id="btn-prop-select-tex">Select</button>
</div>
<button class="action-btn" id="btn-prop-clear-tex">Clear</button>
</div>
</div>
</div>
<div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">No item selected.</div>
</div>
</div>
<div id="modal-overlay">
<div class="modal-box" id="env-dialog">
<h3>Environment Textures</h3>
<div class="prop-group">
<span class="prop-label">Road Texture</span>
<div class="file-select-row"><input type="text" id="env-road" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'RoadImage/', f=>updateEnvField('env-road',f))">Select</button></div>
</div>
<div class="prop-group">
<span class="prop-label">Wall Texture</span>
<div class="file-select-row"><input type="text" id="env-wall" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'WallImage/', f=>updateEnvField('env-wall',f))">Select</button></div>
</div>
<div class="prop-group">
<span class="prop-label">SkyDome Texture</span>
<div class="file-select-row"><input type="text" id="env-sky" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'SkyDomeImage/', f=>updateEnvField('env-sky',f))">Select</button></div>
</div>
<div class="prop-group">
<span class="prop-label">Ground Texture</span>
<div class="file-select-row"><input type="text" id="env-ground" disabled><button class="action-btn" onclick="selectFile('.png,.jpg', 'BgGroundImage/', f=>updateEnvField('env-ground',f))">Select</button></div>
</div>
<div style="text-align:right; margin-top:20px;">
<button class="action-btn" style="width:auto; padding:5px 15px; display:inline-block;" onclick="document.getElementById('modal-overlay').style.display='none'">Close & Apply</button>
</div>
</div>
</div>
<input type="file" id="file-input-json" style="display: none;" accept=".json">
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
let scene, camera, renderer, controls, raycaster, mouse;
let currentTool = 'select', gridSize = 10, baseHeight = 0;
let trackNodes = [], sceneryItems = [], trackSplineGroup = null, selectedItem = null, addItemType = 'node', clipboard = null;
let planeGround, cursorTarget, cursorPoint, cursorSelect, labelsContainer;
let isDragging = false, dragStartMouse = new THREE.Vector2(), dragStartVal = new THREE.Vector3(), startCursorPos = new THREE.Vector3();
let axisLock = { x: false, y: false, z: false };
let isLapping = false, lapsQ = new THREE.Quaternion(), lapsTargetQ = new THREE.Quaternion(), lapsRadius = 300, lapsCenter = new THREE.Vector3();
let dragStartCamPos = new THREE.Vector3(), dragStartCamTarget = new THREE.Vector3(), dragStartPolar = 0, dragStartAzimuth = 0;
let gridGroup = new THREE.Group(), guideLineGroup = new THREE.Group(), shadowPool = [], walls = {};
const textureLoader = new THREE.TextureLoader(), gltfLoader = new GLTFLoader(), objLoader = new OBJLoader();
// Asset Management
let ASSETS = { env: { road: null, wall: null, sky: null, ground: null }, bgModels: {}, blockTextures: {} };
let currentBrushTex = null;
let envMeshes = { sky: null, stars: null };
// Helper: File Selection
window.selectFile = (accept, folderPrefix, callback) => {
const input = document.createElement('input'); input.type = 'file'; input.accept = accept;
input.onchange = e => { const f = e.target.files[0]; if(f) callback({ name: folderPrefix + f.name, url: URL.createObjectURL(f) }); };
input.click();
};
window.updateEnvField = (id, fileObj) => {
document.getElementById(id).value = fileObj.name;
const key = id.split('-')[1];
ASSETS.env[key] = fileObj;
applyEnvironment();
};
function getTex(fileObj, callback) {
if(!fileObj) return null;
if(ASSETS.blockTextures[fileObj.name]) { const t = ASSETS.blockTextures[fileObj.name].texture; if(callback) callback(t); return t; }
const tex = textureLoader.load(fileObj.url, t => { t.wrapS = t.wrapT = THREE.RepeatWrapping; if(callback) callback(t); });
ASSETS.blockTextures[fileObj.name] = { url: fileObj.url, texture: tex };
return tex;
}
// Utils: Point Math
function distanceToSegment(p, v, w) {
let l2 = v.distanceToSquared(w);
if (l2 === 0) return p.distanceTo(v);
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y) + (p.z - v.z) * (w.z - v.z)) / l2;
t = Math.max(0, Math.min(1, t));
let proj = new THREE.Vector3(v.x + t * (w.x - v.x), v.y + t * (w.y - v.y), v.z + t * (w.z - v.z));
return p.distanceTo(proj);
}
function lerpAngleDeg(a, b, t) { let d = b - a; while(d > 180) d -= 360; while(d < -180) d += 360; return a + d * t; }
function getLoopCatmullRom(values, t) {
if(values.length === 0) return 0;
const len = values.length; const exact = t * len;
let i1 = Math.floor(exact) % len; if(i1 < 0) i1 += len;
const frac = exact - Math.floor(exact);
const i0 = (i1 - 1 + len) % len, i2 = (i1 + 1) % len, i3 = (i1 + 2) % len;
const v1 = values[i1];
let v0 = values[i0]; while(v0 - v1 > 180) v0 -= 360; while(v0 - v1 < -180) v0 += 360;
let v2 = values[i2]; while(v2 - v1 > 180) v2 -= 360; while(v2 - v1 < -180) v2 += 360;
let v3 = values[i3]; while(v3 - v2 > 180) v3 -= 360; while(v3 - v2 < -180) v3 += 360;
const c0 = v1, c1 = 0.5 * (v2 - v0), c2 = v0 - 2.5 * v1 + 2 * v2 - 0.5 * v3, c3 = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3;
return ((c3 * frac + c2) * frac + c1) * frac + c0;
}
function init() {
const container = document.getElementById('canvas-container'); labelsContainer = document.getElementById('labels-container');
scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000);
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 1, 5000);
camera.position.set(200, 200, 200); camera.lookAt(0,0,0);
renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.8); sun.position.set(100, 300, 200); scene.add(sun);
scene.add(gridGroup); scene.add(guideLineGroup);
planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000), new THREE.MeshPhongMaterial({ color: 0x222222 }));
planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -0.1; scene.add(planeGround);
const starGeo = new THREE.BufferGeometry(); const posArray = new Float32Array(2000 * 3); for(let i=0; i<2000 * 3; i++) posArray[i] = (Math.random() - 0.5) * 4000; starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); envMeshes.stars = new THREE.Points(starGeo, new THREE.PointsMaterial({color: 0xffffff, size: 2.0})); scene.add(envMeshes.stars);
initCursors(); initShadowPool(); updateEnvironment();
controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true;
raycaster = new THREE.Raycaster(); mouse = new THREE.Vector2();
window.addEventListener('resize', () => { camera.aspect = container.clientWidth/container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); updateLabels(); });
window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
const cvs = renderer.domElement;
cvs.addEventListener('pointermove', onPointerMove); cvs.addEventListener('pointerdown', onPointerDown); cvs.addEventListener('pointerup', () => { isDragging = false; controls.enabled = true; });
cvs.addEventListener('contextmenu', e => e.preventDefault());
setupUI(); setTool('select');
createNode(new THREE.Vector3(0, 50, 0), 0, 0); createNode(new THREE.Vector3(0, 50, -300), 0, 0); createNode(new THREE.Vector3(300, 50, -150), 45, -15);
rebuildTrackCurve(); animate();
}
function initCursors() {
cursorTarget = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })); cursorTarget.rotation.x = -Math.PI / 2; scene.add(cursorTarget);
cursorPoint = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 })); scene.add(cursorPoint);
cursorSelect = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10)), new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false })); cursorSelect.visible = false; scene.add(cursorSelect);
}
function initShadowPool() { for(let i=0; i<6; i++) { const s = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(1, 1)), new THREE.LineBasicMaterial({ color: 0xffffff })); shadowPool.push(s); guideLineGroup.add(s); } }
function updateEnvironment() {
while(gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]);
const s = 2000; gridGroup.add(new THREE.GridHelper(s, 200, 0x555555, 0x2a2a2a));
const makeWall = (rx, rz, px, pz) => { const w = new THREE.GridHelper(s, 200, 0x444444, 0x222222); w.rotation.x = rx; w.rotation.z = rz; w.position.set(px, s/2, pz); return w; };
walls.zNeg = makeWall(-Math.PI/2, 0, 0, -s/2); walls.zPos = makeWall(-Math.PI/2, 0, 0, s/2);
walls.xNeg = makeWall(0, -Math.PI/2, -s/2, 0); walls.xPos = makeWall(0, -Math.PI/2, s/2, 0);
gridGroup.add(walls.zNeg); gridGroup.add(walls.zPos); gridGroup.add(walls.xNeg); gridGroup.add(walls.xPos);
}
function applyEnvironment() {
if(ASSETS.env.ground) { getTex(ASSETS.env.ground, t => { planeGround.material.map = t; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; }); }
else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
if(ASSETS.env.sky) { if(!envMeshes.sky) { envMeshes.sky = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(envMeshes.sky); } getTex(ASSETS.env.sky, t => { envMeshes.sky.material.map = t; envMeshes.sky.visible = true; }); envMeshes.stars.visible = false; }
else { if(envMeshes.sky) envMeshes.sky.visible = false; envMeshes.stars.visible = true; }
rebuildTrackCurve();
}
function rebuildTrackCurve() {
if (trackSplineGroup) scene.remove(trackSplineGroup);
if (trackNodes.length < 3) return;
const points = trackNodes.map(n => n.position);
const curve = new THREE.CatmullRomCurve3(points, true);
const SEGMENTS = trackNodes.length * 20, WALL_RADIAL_SEGS = 16, R = 18;
const frames = { tangents: [], normals: [], binormals: [] };
for(let i=0; i<=SEGMENTS; i++) { frames.tangents.push(curve.getTangentAt(curve.getUtoTmapping(i/SEGMENTS)).normalize()); }
frames.normals[0] = new THREE.Vector3(0,1,0);
frames.binormals[0] = new THREE.Vector3().crossVectors(frames.tangents[0], frames.normals[0]).normalize();
frames.normals[0].crossVectors(frames.binormals[0], frames.tangents[0]).normalize();
for(let i=1; i<=SEGMENTS; i++) {
const axis = new THREE.Vector3().crossVectors(frames.tangents[i-1], frames.tangents[i]);
const sin = axis.length(), cos = frames.tangents[i-1].dot(frames.tangents[i]), angle = Math.atan2(sin, cos);
const q = new THREE.Quaternion(); if(sin > 0.0001) q.setFromAxisAngle(axis.normalize(), angle);
frames.normals.push(frames.normals[i-1].clone().applyQuaternion(q));
frames.binormals.push(frames.binormals[i-1].clone().applyQuaternion(q));
}
let twistTotal = Math.acos(Math.max(-1, Math.min(1, frames.normals[0].dot(frames.normals[SEGMENTS]))));
if (new THREE.Vector3().crossVectors(frames.normals[SEGMENTS], frames.normals[0]).dot(frames.tangents[0]) < 0) twistTotal = -twistTotal;
for(let i=1; i<=SEGMENTS; i++) {
const q = new THREE.Quaternion().setFromAxisAngle(frames.tangents[i], (i/SEGMENTS)*twistTotal);
frames.normals[i].applyQuaternion(q); frames.binormals[i].applyQuaternion(q);
}
const rolls = trackNodes.map(n => n.userData.roll), twists = trackNodes.map(n => n.userData.twist);
const roadPositions = [], roadUVs = [], roadIndices = [];
for (let i = 0; i <= SEGMENTS; i++) {
const u = i / SEGMENTS, tParam = curve.getUtoTmapping(u);
const pt = curve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
const rDeg = getLoopCatmullRom(rolls, tParam), tDeg = getLoopCatmullRom(twists, tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
// エディタでは道路のある半円(下半分: PI ~ 2PI)のみを描画
for (let j = 0; j <= WALL_RADIAL_SEGS; j++) {
const thetaR = Math.PI + (j / WALL_RADIAL_SEGS) * Math.PI;
const vr = pt.clone().addScaledVector(twistRight, Math.cos(thetaR)*R).addScaledVector(twistUp, Math.sin(thetaR)*R);
roadPositions.push(vr.x, vr.y, vr.z); roadUVs.push(j / WALL_RADIAL_SEGS, u * trackNodes.length * 4);
}
}
for (let i = 0; i < SEGMENTS; i++) {
for (let j = 0; j < WALL_RADIAL_SEGS; j++) { const a = i*(WALL_RADIAL_SEGS+1)+j, b = (i+1)*(WALL_RADIAL_SEGS+1)+j, c = (i+1)*(WALL_RADIAL_SEGS+1)+(j+1), d = i*(WALL_RADIAL_SEGS+1)+(j+1); roadIndices.push(a,b,d); roadIndices.push(b,c,d); }
}
const makeGeo = (pos, uv, idx) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals(); return g; };
const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide });
if(ASSETS.env.road) getTex(ASSETS.env.road, t => { roadMat.map = t; roadMat.needsUpdate = true; });
trackSplineGroup = new THREE.Group();
trackSplineGroup.add(new THREE.Mesh(makeGeo(roadPositions, roadUVs, roadIndices), roadMat));
scene.add(trackSplineGroup);
}
function createNode(pos, roll, twist) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(6, 16, 16), new THREE.MeshPhongMaterial({ color: 0x00ff00, emissive: 0x004400 }));
mesh.position.copy(pos); mesh.userData = { type: 'node', roll: roll, twist: twist };
scene.add(mesh); trackNodes.push(mesh); return mesh;
}
function createSceneryBlock(pos, scaleVec, colorHex, rotY, texObj) {
const mat = new THREE.MeshStandardMaterial({ color: colorHex });
if(texObj) getTex(texObj, t => { mat.map = t; mat.color.setHex(0xffffff); mat.needsUpdate = true; });
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gridSize, gridSize, gridSize), mat);
mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
mesh.userData = { type: 'scenery', subType: 'block', texObj: texObj };
scene.add(mesh); sceneryItems.push(mesh); return mesh;
}
function createBgModelInstance(fileObj, pos, scaleVec, rotY) {
if(!ASSETS.bgModels[fileObj.name]) return null;
const mesh = ASSETS.bgModels[fileObj.name].object.clone();
mesh.position.copy(pos); mesh.scale.copy(scaleVec); mesh.rotation.y = rotY;
mesh.userData = { type: 'scenery', subType: 'bgmodel', fileObj: fileObj };
const box = new THREE.Box3().setFromObject(mesh); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3());
const hitBox = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z), new THREE.MeshBasicMaterial({visible:false}));
hitBox.position.copy(center).sub(mesh.position); mesh.add(hitBox); mesh.userData.hitBox = hitBox;
scene.add(mesh); sceneryItems.push(mesh); return mesh;
}
function getHitObjects() { const t = [...trackNodes]; sceneryItems.forEach(s => { if(s.userData.subType === 'bgmodel' && s.userData.hitBox) t.push(s.userData.hitBox); else t.push(s); }); return t; }
function getActualItem(hitObj) { return (hitObj.parent && hitObj.parent.userData.type === 'scenery') ? hitObj.parent : hitObj; }
function onPointerMove(e) {
const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const isLocked = axisLock.x || axisLock.y || axisLock.z;
if(isLapping && isDragging) {
const dx = e.clientX - dragStartMouse.x, dy = e.clientY - dragStartMouse.y;
const dQ = new THREE.Quaternion();
if(!axisLock.y && !axisLock.z) dQ.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), -dx*0.005));
if(axisLock.y || axisLock.z || !axisLock.x) dQ.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion), -dy*0.005));
lapsTargetQ.premultiply(dQ); dragStartMouse.set(e.clientX, e.clientY); return;
}
if (isDragging && selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) {
const deltaX = e.clientX - dragStartMouse.x, deltaY = e.clientY - dragStartMouse.y;
if (currentTool === 'move') {
if (isLocked) {
const mag = (deltaX - deltaY) * 0.5;
if(axisLock.x) selectedItem.position.x = Math.round((dragStartVal.x + mag)/gridSize)*gridSize;
if(axisLock.y) selectedItem.position.y = Math.round((dragStartVal.y + mag)/gridSize)*gridSize;
if(axisLock.z) selectedItem.position.z = Math.round((dragStartVal.z + mag)/gridSize)*gridSize;
} else {
raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(planeGround);
if(intersects.length > 0) { selectedItem.position.x = Math.round(intersects[0].point.x/gridSize)*gridSize; selectedItem.position.z = Math.round(intersects[0].point.z/gridSize)*gridSize; }
}
if(selectedItem.userData.type === 'node') rebuildTrackCurve();
}
else if (currentTool === 'rotate' && selectedItem.userData.type === 'scenery') selectedItem.rotation.y = Math.round((dragStartVal.y + deltaX * 0.02) / (Math.PI/4)) * (Math.PI/4);
else if (currentTool === 'scale' && selectedItem.userData.type === 'scenery') {
const mag = -deltaY * 0.05; let sVec = dragStartVal.clone();
if(isLocked) { if(axisLock.x) sVec.x = Math.max(0.1, dragStartVal.x + mag); if(axisLock.y) sVec.y = Math.max(0.1, dragStartVal.y + mag); if(axisLock.z) sVec.z = Math.max(0.1, dragStartVal.z + mag); }
else { const s = Math.max(0.1, dragStartVal.x + mag); sVec.set(s,s,s); }
selectedItem.scale.copy(sVec);
}
updateSelectionBox(); updateUIFromSelection(); return;
}
if (isDragging && !selectedItem && (currentTool === 'add' || currentTool === 'paint') && isLocked) {
const mag = ((e.clientX - dragStartMouse.x) - (e.clientY - dragStartMouse.y)) * 0.5;
if(axisLock.x) cursorTarget.position.x = Math.round((startCursorPos.x + mag)/gridSize)*gridSize;
if(axisLock.y) cursorTarget.position.y = Math.round((startCursorPos.y + mag)/gridSize)*gridSize;
if(axisLock.z) cursorTarget.position.z = Math.round((startCursorPos.z + mag)/gridSize)*gridSize;
return;
}
if (!isDragging) {
raycaster.setFromCamera(mouse, camera);
if (currentTool === 'add') {
const intersects = raycaster.intersectObject(planeGround);
if (intersects.length > 0) cursorTarget.position.set(Math.round(intersects[0].point.x/gridSize)*gridSize, baseHeight, Math.round(intersects[0].point.z/gridSize)*gridSize);
} else {
const intersects = raycaster.intersectObjects(getHitObjects());
if (intersects.length > 0) {
const hit = getActualItem(intersects[0].object); cursorPoint.position.copy(hit.position);
if(hit.userData.type === 'node') cursorPoint.scale.set(1.5,1.5,1.5);
else { const box = new THREE.Box3().setFromObject(hit); const size = box.getSize(new THREE.Vector3()); cursorPoint.scale.set(size.x/10, size.y/10, size.z/10); }
cursorPoint.visible = true;
} else {
const pIntersects = raycaster.intersectObject(planeGround);
if (pIntersects.length > 0) { cursorPoint.scale.set(1,1,1); cursorPoint.position.set(Math.round(pIntersects[0].point.x/gridSize)*gridSize, baseHeight, Math.round(pIntersects[0].point.z/gridSize)*gridSize); cursorPoint.visible = true; }
}
}
}
}
function onPointerDown(e) {
if (e.button !== 0) return;
if(isLapping) { isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = false; return; }
dragStartCamPos.copy(camera.position); dragStartCamTarget.copy(controls.target); dragStartPolar = controls.getPolarAngle(); dragStartAzimuth = controls.getAzimuthalAngle();
if (currentTool.startsWith('cam-')) { isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = true; return; }
const isLocked = axisLock.x || axisLock.y || axisLock.z;
if (isLocked) {
isDragging = true; dragStartMouse.set(e.clientX, e.clientY); controls.enabled = false;
if(selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) {
if (currentTool === 'move') dragStartVal.copy(selectedItem.position); if (currentTool === 'rotate') dragStartVal.set(0, selectedItem.rotation.y, 0); if (currentTool === 'scale') dragStartVal.copy(selectedItem.scale);
} else startCursorPos.copy(cursorTarget.position);
return;
}
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(getHitObjects());
const hitItem = intersects.length > 0 ? getActualItem(intersects[0].object) : null;
if (currentTool === 'select') selectItem(hitItem);
else if (currentTool === 'add') {
if(addItemType === 'node') {
if(trackNodes.length >= 2) {
let minDist = Infinity; let insertIdx = trackNodes.length; let pA, pB, rollA, rollB, twistA, twistB;
for(let i=0; i<trackNodes.length; i++) {
let n1 = trackNodes[i]; let n2 = trackNodes[(i+1)%trackNodes.length];
let dist = distanceToSegment(cursorTarget.position, n1.position, n2.position);
if(dist < minDist) { minDist = dist; insertIdx = i + 1; pA = n1.position; pB = n2.position; rollA = n1.userData.roll; rollB = n2.userData.roll; twistA = n1.userData.twist; twistB = n2.userData.twist; }
}
let midPos = new THREE.Vector3().addVectors(pA, pB).multiplyScalar(0.5);
let midRoll = lerpAngleDeg(rollA, rollB, 0.5), midTwist = lerpAngleDeg(twistA, twistB, 0.5);
let mesh = createNode(midPos, midRoll, midTwist);
trackNodes.pop(); trackNodes.splice(insertIdx, 0, mesh); rebuildTrackCurve();
} else { createNode(cursorTarget.position.clone(), 0, 0); rebuildTrackCurve(); }
}
else if(addItemType === 'scenery') createSceneryBlock(cursorTarget.position.clone(), new THREE.Vector3(1,1,1), document.getElementById('brush-color').value, 0, currentBrushTex);
else if(addItemType === 'bgmodel') {
const sel = document.getElementById('bgmodel-list').value;
if(sel && ASSETS.bgModels[sel]) createBgModelInstance(ASSETS.bgModels[sel].fileObj, cursorTarget.position.clone(), new THREE.Vector3(1,1,1), 0);
else alert("Select a model from the list first.");
}
}
else if (currentTool === 'paint') {
if(hitItem && hitItem.userData.subType === 'block') {
hitItem.material.color.set(document.getElementById('brush-color').value);
if(currentBrushTex) { getTex(currentBrushTex, t => { hitItem.material.map = t; hitItem.material.color.setHex(0xffffff); hitItem.material.needsUpdate = true; }); }
else { hitItem.material.map = null; hitItem.material.needsUpdate = true; }
hitItem.userData.texObj = currentBrushTex; updateUIFromSelection();
}
}
else if (currentTool === 'erase') { if(hitItem) deleteItem(hitItem); }
else if (['move', 'rotate', 'scale'].includes(currentTool)) {
if (hitItem) { selectItem(hitItem); isDragging = true; controls.enabled = false; dragStartMouse.set(e.clientX, e.clientY); if(currentTool==='move') dragStartVal.copy(hitItem.position); if(currentTool==='rotate') dragStartVal.set(0, hitItem.rotation.y, 0); if(currentTool==='scale') dragStartVal.copy(hitItem.scale); }
else selectItem(null);
}
}
function onKeyDown(e) {
if (e.target.tagName === 'INPUT') return;
if(e.key.toLowerCase() === 'x') axisLock.x = true; if(e.key.toLowerCase() === 'y') axisLock.y = true; if(e.key.toLowerCase() === 'z') axisLock.z = true;
if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
if (e.key === 'PageUp') { e.preventDefault(); camera.zoom = Math.min(camera.zoom + 0.1, 5); camera.updateProjectionMatrix(); }
if (e.key === 'PageDown') { e.preventDefault(); camera.zoom = Math.max(camera.zoom - 0.1, 0.1); camera.updateProjectionMatrix(); }
if (e.key.toLowerCase() === 'c' && e.ctrlKey && selectedItem) {
clipboard = { type: selectedItem.userData.type, subType: selectedItem.userData.subType, roll: selectedItem.userData.roll, twist: selectedItem.userData.twist, sx: selectedItem.scale.x, sy: selectedItem.scale.y, sz: selectedItem.scale.z, ry: selectedItem.rotation.y };
if(selectedItem.userData.subType === 'block') { clipboard.color = selectedItem.material.color.getHex(); clipboard.texObj = selectedItem.userData.texObj; }
if(selectedItem.userData.subType === 'bgmodel') clipboard.fileObj = selectedItem.userData.fileObj;
}
if (e.key.toLowerCase() === 'v' && e.ctrlKey && clipboard && cursorPoint.visible) {
if(clipboard.type === 'node') {
if(trackNodes.length >= 2) {
let minDist = Infinity; let insertIdx = trackNodes.length; let pA, pB, rollA, rollB, twistA, twistB;
for(let i=0; i<trackNodes.length; i++) {
let n1 = trackNodes[i]; let n2 = trackNodes[(i+1)%trackNodes.length];
let dist = distanceToSegment(cursorPoint.position, n1.position, n2.position);
if(dist < minDist) { minDist = dist; insertIdx = i + 1; pA = n1.position; pB = n2.position; rollA = n1.userData.roll; rollB = n2.userData.roll; twistA = n1.userData.twist; twistB = n2.userData.twist; }
}
let midPos = new THREE.Vector3().addVectors(pA, pB).multiplyScalar(0.5);
let mesh = createNode(midPos, clipboard.roll, clipboard.twist); trackNodes.pop(); trackNodes.splice(insertIdx, 0, mesh); rebuildTrackCurve();
} else { createNode(cursorPoint.position.clone(), clipboard.roll, clipboard.twist); rebuildTrackCurve(); }
}
else if(clipboard.subType === 'block') createSceneryBlock(cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.color, clipboard.ry, clipboard.texObj);
else if(clipboard.subType === 'bgmodel') createBgModelInstance(clipboard.fileObj, cursorPoint.position.clone(), new THREE.Vector3(clipboard.sx, clipboard.sy, clipboard.sz), clipboard.ry);
}
if (e.key === 'ArrowUp') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y += gridSize; if(selectedItem.userData.type === 'node') rebuildTrackCurve(); updateUIFromSelection(); updateSelectionBox(); } else changeBaseHeight(10); }
if (e.key === 'ArrowDown') { e.preventDefault(); if (['move','select'].includes(currentTool) && selectedItem) { selectedItem.position.y -= gridSize; if(selectedItem.userData.type === 'node') rebuildTrackCurve(); updateUIFromSelection(); updateSelectionBox(); } else changeBaseHeight(-10); }
if (e.key === 'Delete' && selectedItem) deleteItem(selectedItem);
}
function onKeyUp(e) { if(e.key.toLowerCase() === 'x') axisLock.x = false; if(e.key.toLowerCase() === 'y') axisLock.y = false; if(e.key.toLowerCase() === 'z') axisLock.z = false; }
function changeBaseHeight(dir) { baseHeight += dir; document.getElementById('height-display').textContent = `Height: ${baseHeight}`; const rect = renderer.domElement.getBoundingClientRect(); onPointerMove({ clientX: rect.left + (mouse.x+1)/2*rect.width, clientY: rect.top - (mouse.y-1)/2*rect.height }); }
function deleteItem(mesh) { scene.remove(mesh); if(mesh.userData.type === 'node') { trackNodes = trackNodes.filter(v => v !== mesh); rebuildTrackCurve(); } else sceneryItems = sceneryItems.filter(v => v !== mesh); if (selectedItem === mesh) selectItem(null); }
function selectItem(mesh) { selectedItem = mesh; if (mesh) { if(!['add','paint'].includes(currentTool)) cursorSelect.visible = true; updateSelectionBox(); updateUIFromSelection(); } else { cursorSelect.visible = false; if (!['add','paint'].includes(currentTool)) { document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').style.display = 'block'; } } }
function updateSelectionBox() { if(!selectedItem) return; cursorSelect.position.copy(selectedItem.position); cursorSelect.rotation.copy(selectedItem.rotation); if(selectedItem.userData.type === 'node') cursorSelect.scale.set(1.5,1.5,1.5); else { const box = new THREE.Box3().setFromObject(selectedItem); const size = box.getSize(new THREE.Vector3()); cursorSelect.scale.set(size.x/10 * 1.05, size.y/10 * 1.05, size.z/10 * 1.05); } }
function updateGuides() {
shadowPool.forEach(s => s.visible = false);
const limX = (camera.position.x > 0) ? -1000 : 1000, limZ = (camera.position.z > 0) ? -1000 : 1000;
const placeShadow = (idxStart, pos3d, scale) => { const s1=shadowPool[idxStart], s2=shadowPool[idxStart+1]; s1.visible=true; s1.position.set(pos3d.x, pos3d.y, limZ); s1.scale.set(scale.x*10, scale.y*10, 1); s2.visible=true; s2.position.set(limX, pos3d.y, pos3d.z); s2.scale.set(1, scale.y*10, scale.z*10); s2.rotation.y = Math.PI/2; };
if(['add','paint'].includes(currentTool) && cursorTarget.visible) placeShadow(0, cursorTarget.position, cursorTarget.scale);
if(cursorPoint.visible) placeShadow(2, cursorPoint.position, cursorPoint.scale);
if(selectedItem && cursorSelect.visible) placeShadow(4, selectedItem.position, cursorSelect.scale);
walls.xNeg.visible = (camera.position.x > 0); walls.xPos.visible = (camera.position.x < 0);
walls.zNeg.visible = (camera.position.z > 0); walls.zPos.visible = (camera.position.z < 0);
}
function updateLabels() {
labelsContainer.innerHTML = '';
if(axisLock.x || axisLock.y || axisLock.z) {
const active = selectedItem ? selectedItem.position : (cursorPoint.visible ? cursorPoint.position : cursorTarget.position);
const addL = (pos, txt, col) => { const d = document.createElement('div'); d.className = 'axis-label'; d.textContent = txt; d.style.backgroundColor = col; const vec = pos.clone().project(camera); if(vec.z<=1) { d.style.left = (vec.x*0.5+0.5)*renderer.domElement.clientWidth+'px'; d.style.top = -(vec.y*0.5-0.5)*renderer.domElement.clientHeight+'px'; labelsContainer.appendChild(d); } };
if(axisLock.x) { addL(active.clone().add(new THREE.Vector3(20,0,0)), "+X", "#aa0000"); addL(active.clone().add(new THREE.Vector3(-20,0,0)), "-X", "#aa0000"); }
if(axisLock.y) { addL(active.clone().add(new THREE.Vector3(0,20,0)), "+Y", "#00aa00"); addL(active.clone().add(new THREE.Vector3(0,-20,0)), "-Y", "#00aa00"); }
if(axisLock.z) { addL(active.clone().add(new THREE.Vector3(0,0,20)), "+Z", "#0044aa"); addL(active.clone().add(new THREE.Vector3(0,0,-20)), "-Z", "#0044aa"); }
}
}
function animate() {
requestAnimationFrame(animate);
if(isLapping) { lapsQ.slerp(lapsTargetQ, 0.1); const v = new THREE.Vector3(0, 0, lapsRadius).applyQuaternion(lapsQ); camera.position.copy(v.add(lapsCenter)); camera.lookAt(lapsCenter); controls.target.copy(lapsCenter); controls.update(); }
else {
if(currentTool === 'cam-rotate') { controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI; controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity; if(isDragging) { if(axisLock.x) controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar; else if(axisLock.y||axisLock.z) controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth; } }
else if(currentTool === 'cam-move' && isDragging) { if(axisLock.x) { camera.position.y=dragStartCamPos.y; camera.position.z=dragStartCamPos.z; controls.target.y=dragStartCamTarget.y; controls.target.z=dragStartCamTarget.z; } if(axisLock.y) { camera.position.x=dragStartCamPos.x; camera.position.z=dragStartCamPos.z; controls.target.x=dragStartCamTarget.x; controls.target.z=dragStartCamTarget.z; } if(axisLock.z) { camera.position.x=dragStartCamPos.x; camera.position.y=dragStartCamPos.y; controls.target.x=dragStartCamTarget.x; controls.target.y=dragStartCamTarget.y; } }
controls.update();
}
if(cursorTarget) cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(Date.now() * 0.005 * 8);
updateGuides(); updateLabels(); renderer.render(scene, camera);
}
window.setTool = (tool) => {
currentTool = tool; const names = { 'select': 'Select', 'add': 'Add', 'erase': 'Erase', 'paint': 'Paint', 'move': 'Move', 'rotate': 'Rotate', 'scale': 'Scale', 'cam-move':'Camera Move', 'cam-rotate':'Camera Rotate', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps' };
document.getElementById('mode-display').innerText = names[tool];
if(tool === 'add' || tool === 'paint') {
cursorTarget.visible = (tool==='add'); cursorPoint.visible = false; cursorSelect.visible = false;
document.getElementById('prop-header').innerText = tool==='add'?"Add Settings":"Paint Brush"; document.getElementById('prop-add-settings').style.display = 'block'; document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').style.display = 'none';
} else {
cursorTarget.visible = false; cursorPoint.visible = true; if(selectedItem) cursorSelect.visible = true;
document.getElementById('prop-header').innerText = "Properties"; document.getElementById('prop-add-settings').style.display = 'none';
if(selectedItem) updateUIFromSelection(); else { document.getElementById('prop-content').style.display = 'none'; document.getElementById('prop-empty').style.display = 'block'; }
}
isLapping = (tool === 'cam-laps');
if(isLapping) { const act = selectedItem ? selectedItem.position : (cursorPoint.visible ? cursorPoint.position : cursorTarget.position); lapsCenter.copy(act); const rel = camera.position.clone().sub(lapsCenter); lapsRadius = rel.length(); rel.normalize(); lapsQ.setFromUnitVectors(new THREE.Vector3(0,0,1), rel); lapsTargetQ.copy(lapsQ); controls.enabled = false; }
else { controls.enabled = true; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; if (tool === 'cam-move') controls.mouseButtons.LEFT = THREE.MOUSE.PAN; else if (tool === 'cam-rotate') controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; else if (tool === 'cam-zoom') { controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY; controls.zoomSpeed = 3.0; } else controls.zoomSpeed = 1.0; }
};
window.viewCam = (dir) => {
const dist = 300; camera.up.set(0, 1, 0); if(isLapping){ isLapping = false; setTool('select'); }
switch(dir) { case 'default': camera.position.set(dist, dist, dist); break; case 'front': camera.position.set(0, 0, dist); break; case 'back': camera.position.set(0, 0, -dist); break; case 'left': camera.position.set(-dist, 0, 0); break; case 'right': camera.position.set(dist, 0, 0); break; case 'top': camera.position.set(0, dist, 0); break; case 'bottom': camera.position.set(0, -dist, 0); break; }
camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update();
};
function setupUI() {
['select', 'add', 'paint', 'erase', 'move', 'rotate', 'scale', 'cam-move', 'cam-rotate', 'cam-zoom', 'cam-laps'].forEach(t => document.getElementById(`tool-${t}`).onclick = () => setTool(t));
document.getElementById('add-item-type').addEventListener('change', (e) => { addItemType = e.target.value; document.getElementById('add-scenery-options').style.display = addItemType === 'scenery' ? 'block' : 'none'; document.getElementById('add-bgmodel-options').style.display = addItemType === 'bgmodel' ? 'block' : 'none'; });
const bindProp = (id, objKey, axis, isDeg) => {
document.getElementById(id).addEventListener('input', (e) => {
if(!selectedItem) return; let val = parseFloat(e.target.value); if(isDeg) val = THREE.MathUtils.degToRad(val);
if(objKey === 'roll' || objKey === 'twist') { selectedItem.userData[objKey] = val; rebuildTrackCurve(); }
else if(axis) { selectedItem[objKey][axis] = val; if(objKey === 'position' && selectedItem.userData.type === 'node') rebuildTrackCurve(); }
else if(objKey === 'color' && selectedItem.userData.subType === 'block') { selectedItem.material.color.set(e.target.value); if(selectedItem.material.map) selectedItem.material.color.setHex(0xffffff); }
updateSelectionBox();
});
};
bindProp('prop-x', 'position', 'x'); bindProp('prop-y', 'position', 'y'); bindProp('prop-z', 'position', 'z');
bindProp('prop-sx', 'scale', 'x'); bindProp('prop-sy', 'scale', 'y'); bindProp('prop-sz', 'scale', 'z');
bindProp('prop-ry', 'rotation', 'y', true); bindProp('prop-roll', 'roll', null, false); bindProp('prop-twist', 'twist', null, false);
bindProp('prop-color', 'color');
document.getElementById('btn-select-brush-tex').onclick = () => selectFile('.png,.jpg', 'BgBlockImage/', f => { currentBrushTex = f; document.getElementById('brush-tex-name').value = f.name; });
document.getElementById('btn-clear-brush-tex').onclick = () => { currentBrushTex = null; document.getElementById('brush-tex-name').value = ""; };
document.getElementById('btn-prop-select-tex').onclick = () => selectFile('.png,.jpg', 'BgBlockImage/', f => { if(selectedItem && selectedItem.userData.subType === 'block') { getTex(f, t=>{ selectedItem.material.map=t; selectedItem.material.color.setHex(0xffffff); selectedItem.material.needsUpdate=true; }); selectedItem.userData.texObj = f; document.getElementById('prop-tex-name').value = f.name; } });
document.getElementById('btn-prop-clear-tex').onclick = () => { if(selectedItem && selectedItem.userData.subType === 'block') { selectedItem.material.map = null; selectedItem.userData.texObj = null; selectedItem.material.color.set(document.getElementById('prop-color').value); selectedItem.material.needsUpdate = true; document.getElementById('prop-tex-name').value = ""; } };
document.getElementById('btn-load-model').onclick = () => selectFile('.glb,.obj', 'BgModel/', f => { document.getElementById('status-bar').innerText = `Loading ${f.name}...`; const onload = obj => { ASSETS.bgModels[f.name] = { fileObj: f, object: obj }; const opt = document.createElement('option'); opt.value = f.name; opt.innerText = f.name; document.getElementById('bgmodel-list').appendChild(opt); document.getElementById('status-bar').innerText = `Loaded ${f.name}`; }; if(f.name.endsWith('.obj')) objLoader.load(f.url, onload); else gltfLoader.load(f.url, g=>onload(g.scene)); });
document.getElementById('menu-environment').onclick = () => { document.getElementById('env-road').value = ASSETS.env.road ? ASSETS.env.road.name : ""; document.getElementById('env-wall').value = ASSETS.env.wall ? ASSETS.env.wall.name : ""; document.getElementById('env-sky').value = ASSETS.env.sky ? ASSETS.env.sky.name : ""; document.getElementById('env-ground').value = ASSETS.env.ground ? ASSETS.env.ground.name : ""; document.getElementById('modal-overlay').style.display = 'flex'; };
document.getElementById('menu-new').onclick = () => { if(confirm("Clear all?")) { trackNodes.forEach(n=>scene.remove(n)); sceneryItems.forEach(s=>scene.remove(s)); trackNodes=[]; sceneryItems=[]; rebuildTrackCurve(); selectItem(null); } };
document.getElementById('menu-save').onclick = () => {
const data = {
assets: { env: { road: ASSETS.env.road?.name, wall: ASSETS.env.wall?.name, sky: ASSETS.env.sky?.name, ground: ASSETS.env.ground?.name }, bgModels: Object.keys(ASSETS.bgModels), blockTextures: Object.keys(ASSETS.blockTextures) },
track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll, twist: n.userData.twist })),
scenery: sceneryItems.map(s => { const base = { type: s.userData.subType, x: s.position.x, y: s.position.y, z: s.position.z, sx: s.scale.x, sy: s.scale.y, sz: s.scale.z, ry: s.rotation.y }; if(s.userData.subType === 'block') { base.color = s.material.color.getHex(); base.texture = s.userData.texObj?.name; } if(s.userData.subType === 'bgmodel') base.modelName = s.userData.fileObj?.name; return base; })
};
const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data)], {type:'application/json'})); link.download = 'course.json'; link.click();
};
document.getElementById('menu-open').onclick = () => document.getElementById('file-input-json').click();
document.getElementById('file-input-json').onchange = (e) => { const f = e.target.files[0]; if(!f) return; const r = new FileReader(); r.onload = ev => loadCourseData(JSON.parse(ev.target.result)); r.readAsText(f); };
}
function loadCourseData(data) {
trackNodes.forEach(n => scene.remove(n)); sceneryItems.forEach(s => scene.remove(s)); trackNodes = []; sceneryItems = [];
if(data.assets) {
ASSETS.env = {
road: data.assets.env?.road ? { name: data.assets.env.road, url: data.assets.env.road } : null,
wall: data.assets.env?.wall ? { name: data.assets.env.wall, url: data.assets.env.wall } : null,
sky: data.assets.env?.sky ? { name: data.assets.env.sky, url: data.assets.env.sky } : null,
ground: data.assets.env?.ground ? { name: data.assets.env.ground, url: data.assets.env.ground } : null
};
ASSETS.bgModels = {};
if(data.assets.bgModels) {
data.assets.bgModels.forEach(path => {
ASSETS.bgModels[path] = { fileObj: { name: path, url: path }, object: null };
const onload = obj => { ASSETS.bgModels[path].object = obj; const opt = document.createElement('option'); opt.value = path; opt.innerText = path; document.getElementById('bgmodel-list').appendChild(opt); };
if(path.endsWith('.obj')) objLoader.load(path, onload); else gltfLoader.load(path, g=>onload(g.scene));
});
}
ASSETS.blockTextures = {};
if(data.assets.blockTextures) {
data.assets.blockTextures.forEach(path => { getTex({name: path, url: path}); });
}
applyEnvironment();
}
if(data.track) data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll, n.twist));
if(data.scenery) {
setTimeout(() => {
data.scenery.forEach(s => {
const pos = new THREE.Vector3(s.x, s.y, s.z), scl = new THREE.Vector3(s.sx, s.sy, s.sz);
if(s.type === 'block') createSceneryBlock(pos, scl, s.color, s.ry, s.texture ? {name: s.texture, url: s.texture} : null);
else if(s.type === 'bgmodel' && ASSETS.bgModels[s.modelName]) createBgModelInstance(ASSETS.bgModels[s.modelName].fileObj, pos, scl, s.ry);
});
selectItem(null);
document.getElementById('status-bar').innerText = "Course JSON Loaded.";
}, 500);
}
}
function updateUIFromSelection() {
if(!selectedItem || ['add','paint'].includes(currentTool)) return;
document.getElementById('prop-content').style.display = 'block'; document.getElementById('prop-empty').style.display = 'none';
const isNode = selectedItem.userData.type === 'node';
document.getElementById('prop-x').value = selectedItem.position.x; document.getElementById('prop-y').value = selectedItem.position.y; document.getElementById('prop-z').value = selectedItem.position.z;
document.getElementById('prop-node-group').style.display = isNode ? 'block' : 'none'; document.getElementById('prop-scenery-group').style.display = isNode ? 'none' : 'block';
if(isNode) { document.getElementById('prop-roll').value = selectedItem.userData.roll; document.getElementById('prop-twist').value = selectedItem.userData.twist || 0; }
else {
document.getElementById('prop-sx').value = selectedItem.scale.x; document.getElementById('prop-sy').value = selectedItem.scale.y; document.getElementById('prop-sz').value = selectedItem.scale.z; document.getElementById('prop-ry').value = Math.round(THREE.MathUtils.radToDeg(selectedItem.rotation.y));
if(selectedItem.userData.subType === 'block') { document.getElementById('prop-color-group').style.display = 'block'; document.getElementById('prop-color').value = '#' + selectedItem.material.color.getHexString(); document.getElementById('prop-tex-name').value = selectedItem.userData.texObj ? selectedItem.userData.texObj.name : ""; } else document.getElementById('prop-color-group').style.display = 'none';
}
}
init();
</script>
</body>
</html>・ゲーム側のソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Image Booster 7.1</title>
<style>
body { margin: 0; overflow: hidden; background: #000; font-family: 'Arial Black', sans-serif; }
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; color: white; text-shadow: 2px 2px 4px #000; z-index: 10; }
#view-mode { position: absolute; top: 20px; right: 20px; font-size: 20px; color: #0f0; }
#stage-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); font-size: 20px; color: #ff0; }
#score-info { position: absolute; top: 20px; left: 20px; font-size: 24px; color: #fff; }
#speed-lines { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle, transparent 40%, rgba(255, 255, 255, 0.1) 80%), repeating-conic-gradient(transparent 0deg, transparent 2deg, rgba(100, 255, 255, 0.3) 2.1deg, transparent 3deg); opacity: 0; transition: opacity 0.1s ease-out; pointer-events: none; z-index: 5; mix-blend-mode: screen; }
.lock-on-sight { position: absolute; width: 40px; height: 40px; border: 3px solid #0f0; border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 10px rgba(255,255,255,0.5), inset 0 0 5px rgba(255,255,255,0.5); z-index: 8; }
.lock-on-sight.rushing { border-width: 5px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.2); }
#hud-bottom { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 85%; display: none; flex-direction: row; justify-content: space-around; align-items: flex-end; }
.gauge-container { width: 30%; text-align: center; }
.gauge-label { font-size: 18px; margin-bottom: 5px; }
.bar-bg { width: 100%; height: 12px; border: 2px solid #fff; border-radius: 6px; overflow: hidden; background: rgba(0,0,0,0.5); transform: skewX(-20deg); }
.bar-fill { height: 100%; width: 100%; transition: width 0.05s ease-out; }
#hp-fill { background: #adff2f; box-shadow: 0 0 10px #adff2f; }
#speed-val { font-size: 56px; line-height: 1; color: #00ffff; text-shadow: 0 0 15px #00ffff; font-style: italic; }
#speed-unit { font-size: 20px; color: #aaa; margin-left: 5px; }
#speed-fill { background: #00ffff; }
#time-fill { background: #ffcc00; }
#center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; pointer-events: auto; }
h1 { font-size: 70px; margin: 0; color: #0f0; text-shadow: 0 0 30px #0f0; font-style: italic; letter-spacing: -2px; }
p { font-size: 24px; margin: 10px 0; color: #fff; }
.pop-text { position: absolute; font-weight: bold; font-size: 40px; animation: moveUpFade 0.8s forwards; transform: translate(-50%, -50%); z-index: 999; text-shadow: 0 0 10px white; }
@keyframes moveUpFade { 0% { transform: translate(-50%, 0) scale(1); opacity: 1; } 100% { transform: translate(-50%, -100px) scale(1.5); opacity: 0; } }
.title-btn { background: #007fd4; color: #fff; padding: 10px 20px; font-size: 18px; border: 2px solid #005f9e; cursor: pointer; border-radius: 5px; margin: 10px; font-family: 'Arial Black'; transition: 0.2s; }
.title-btn:hover { background: #1177bb; box-shadow: 0 0 15px #00ffff; }
.course-select { padding: 10px; font-size: 16px; font-family: sans-serif; background: #333; color: white; border: 1px solid #555; border-radius: 5px; margin-bottom: 15px; width: 250px; }
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script>
</head>
<body>
<div id="speed-lines"></div>
<div id="ui-layer">
<div id="score-info">SCORE: 0</div><div id="stage-info">STAGE: 1</div><div id="view-mode">VIEW: 3RD PERSON</div>
<div id="hud-bottom">
<div class="gauge-container"><div class="gauge-label">SHIELD</div><div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div></div>
<div class="gauge-container"><div style="margin-bottom:5px;"><span id="speed-val">0</span><span id="speed-unit">km/h</span></div><div class="bar-bg" id="speed-bg"><div id="speed-fill" class="bar-fill"></div></div></div>
<div class="gauge-container"><div class="gauge-label">TIME: <span id="time-num" style="font-size: 32px;">1:00</span></div><div class="bar-bg" id="time-bg"><div id="time-fill" class="bar-fill"></div></div></div>
</div>
<div id="center-text"></div>
</div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
let vfs = {};
let courseFiles = {};
function handleFolderSelect(files) {
vfs = {}; courseFiles = {};
const status = document.getElementById('load-status'); status.innerText = "SCANNING ASSETS...";
for (let f of files) {
const parts = f.webkitRelativePath.split('/'); parts.shift();
const relPath = parts.join('/');
vfs[relPath] = URL.createObjectURL(f);
vfs[f.name] = vfs[relPath];
if (f.name.endsWith('.json')) {
const r = new FileReader(); r.onload = e => { courseFiles[f.name] = JSON.parse(e.target.result); populateCourseSelect(); }; r.readAsText(f);
}
}
status.innerText = "ASSETS READY. PRESS START TO PLAY.";
}
function populateCourseSelect() {
const select = document.getElementById('course-select-box'); if(!select) return;
select.innerHTML = ''; for(let name in courseFiles) { const opt = document.createElement('option'); opt.value = name; opt.innerText = name; select.appendChild(opt); }
select.style.display = 'inline-block';
}
function getCourseSelectUI() {
return `
<div style="margin-top:20px; pointer-events:auto;">
<label class="title-btn" style="background:#555; border-color:#333;">1. LOAD ASSETS FOLDER (OPTIONAL)<input type="file" id="assets-folder" webkitdirectory directory multiple style="display:none;"></label>
<div id="load-status" style="margin:10px; color:#aaa; font-size:14px;">(No custom folder loaded. Default course will be used.)</div>
<select id="course-select-box" class="course-select" style="display:none;"></select><br>
<button id="start-btn" class="title-btn" style="background:#ffaa00; border-color:#cc8800; font-size:24px;">2. START GAME (SPACE)</button>
</div>`;
}
let customCourseCurve = null, customCourseLength = 0, customCourseFrames = { tangents: [], normals: [], binormals: [] }, customCourseData = null;
let loadedModels = {}, loadedTextures = {};
const manager = new THREE.LoadingManager();
manager.setURLModifier(url => { const fName = url.split('/').pop(); return vfs[fName] ? vfs[fName] : (vfs[url] ? vfs[url] : url); });
const gltfLoader = new GLTFLoader(manager), objLoader = new OBJLoader(manager), texLoader = new THREE.TextureLoader(manager);
function getLoopCatmullRom(values, t) {
if(values.length === 0) return 0;
const len = values.length; const exact = t * len;
let i1 = Math.floor(exact) % len; if(i1 < 0) i1 += len;
const frac = exact - Math.floor(exact);
const i0 = (i1 - 1 + len) % len, i2 = (i1 + 1) % len, i3 = (i1 + 2) % len;
const v1 = values[i1];
let v0 = values[i0]; while(v0 - v1 > 180) v0 -= 360; while(v0 - v1 < -180) v0 += 360;
let v2 = values[i2]; while(v2 - v1 > 180) v2 -= 360; while(v2 - v1 < -180) v2 += 360;
let v3 = values[i3]; while(v3 - v2 > 180) v3 -= 360; while(v3 - v2 < -180) v3 += 360;
const c0 = v1, c1 = 0.5 * (v2 - v0), c2 = v0 - 2.5 * v1 + 2 * v2 - 0.5 * v3, c3 = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3;
return ((c3 * frac + c2) * frac + c1) * frac + c0;
}
function loadCourseAndStart(filename) {
const data = courseFiles[filename]; customCourseData = data;
if (data.track && data.track.length >= 3) {
const points = data.track.map(p => new THREE.Vector3(p.x, p.y, p.z));
customCourseCurve = new THREE.CatmullRomCurve3(points, true);
customCourseLength = customCourseCurve.getLength();
const SEGMENTS = data.track.length * 20; customCourseFrames = { tangents: [], normals: [], binormals: [] };
for(let i=0; i<=SEGMENTS; i++) customCourseFrames.tangents.push(customCourseCurve.getTangentAt(customCourseCurve.getUtoTmapping(i/SEGMENTS)).normalize());
customCourseFrames.normals[0] = new THREE.Vector3(0,1,0);
customCourseFrames.binormals[0] = new THREE.Vector3().crossVectors(customCourseFrames.tangents[0], customCourseFrames.normals[0]).normalize();
customCourseFrames.normals[0].crossVectors(customCourseFrames.binormals[0], customCourseFrames.tangents[0]).normalize();
for(let i=1; i<=SEGMENTS; i++) {
const axis = new THREE.Vector3().crossVectors(customCourseFrames.tangents[i-1], customCourseFrames.tangents[i]);
const sin = axis.length(), cos = customCourseFrames.tangents[i-1].dot(customCourseFrames.tangents[i]), angle = Math.atan2(sin, cos);
const q = new THREE.Quaternion(); if(sin > 0.0001) q.setFromAxisAngle(axis.normalize(), angle);
customCourseFrames.normals.push(customCourseFrames.normals[i-1].clone().applyQuaternion(q));
customCourseFrames.binormals.push(customCourseFrames.binormals[i-1].clone().applyQuaternion(q));
}
let twistTotal = Math.acos(Math.max(-1, Math.min(1, customCourseFrames.normals[0].dot(customCourseFrames.normals[SEGMENTS]))));
if (new THREE.Vector3().crossVectors(customCourseFrames.normals[SEGMENTS], customCourseFrames.normals[0]).dot(customCourseFrames.tangents[0]) < 0) twistTotal = -twistTotal;
for(let i=1; i<=SEGMENTS; i++) { const q = new THREE.Quaternion().setFromAxisAngle(customCourseFrames.tangents[i], (i/SEGMENTS)*twistTotal); customCourseFrames.normals[i].applyQuaternion(q); customCourseFrames.binormals[i].applyQuaternion(q); }
}
document.getElementById('center-text').innerHTML = "<h2 style='color:#fff;'>LOADING SCENE...</h2>";
let modelsToLoad = data.assets?.bgModels || [], texToLoad = [ ...(data.assets?.blockTextures || []), data.assets?.env?.road, data.assets?.env?.wall, data.assets?.env?.sky, data.assets?.env?.ground ].filter(Boolean);
let loadedCount = 0; const total = modelsToLoad.length + texToLoad.length;
const checkDone = () => { loadedCount++; if(loadedCount >= total) { applyEnvironment(); setupCustomScenery(); sound.startEngine(); score = 0; changeState("STAGE_START"); } };
if(total === 0) checkDone();
modelsToLoad.forEach(p => {
if(loadedModels[p]) { checkDone(); return; } const url = vfs[p] || vfs[p.split('/').pop()]; if(!url) { checkDone(); return; }
if(p.endsWith('.obj')) objLoader.load(url, o => { loadedModels[p] = o; checkDone(); }); else gltfLoader.load(url, g => { loadedModels[p] = g.scene; checkDone(); });
});
texToLoad.forEach(p => {
if(loadedTextures[p]) { checkDone(); return; } const url = vfs[p] || vfs[p.split('/').pop()]; if(!url) { checkDone(); return; }
texLoader.load(url, t => { t.wrapS = t.wrapT = THREE.RepeatWrapping; loadedTextures[p] = t; checkDone(); });
});
}
function applyEnvironment() {
const env = customCourseData?.assets?.env || {};
if(env.ground && loadedTextures[env.ground]) { planeGround.material.map = loadedTextures[env.ground]; planeGround.material.color.setHex(0xffffff); planeGround.material.needsUpdate = true; } else { planeGround.material.map = null; planeGround.material.color.setHex(0x222222); planeGround.material.needsUpdate = true; }
if(env.sky && loadedTextures[env.sky]) { if(!skyMesh) { skyMesh = new THREE.Mesh(new THREE.SphereGeometry(4000,32,32), new THREE.MeshBasicMaterial({side:THREE.BackSide})); scene.add(skyMesh); } skyMesh.material.map = loadedTextures[env.sky]; skyMesh.visible = true; stars.visible = false; } else { if(skyMesh) skyMesh.visible = false; stars.visible = true; }
rebuildTunnelMesh(viewMode);
}
function setupCustomScenery() {
worldObjects.forEach(obj => { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }); worldObjects = [];
if (customCourseData.scenery) {
customCourseData.scenery.forEach(s => {
let mesh;
if(s.type === 'block') { const mat = new THREE.MeshStandardMaterial({ color: s.color }); if(s.texture && loadedTextures[s.texture]) { mat.map = loadedTextures[s.texture]; mat.color.setHex(0xffffff); } mesh = new THREE.Mesh(new THREE.BoxGeometry(10,10,10), mat); }
else if(s.type === 'bgmodel' && loadedModels[s.modelName]) { mesh = loadedModels[s.modelName].clone(); }
if(mesh) { mesh.position.set(s.x, s.y, s.z); mesh.scale.set(s.sx, s.sy, s.sz); mesh.rotation.y = s.ry || 0; scene.add(mesh); worldObjects.push({ mesh, type: s.type, flying: false, z: 0, isScenery: true, locked: false, isDead: false, sightDom: null }); }
});
}
}
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let noiseBuffer = null, windSource = null, windGain = null, windFilter = null, jetOsc = null, jetGain = null, brakeCooldown = 0, bankSoundTimer = 0;
function createNoiseBuffer() { const bSize = audioCtx.sampleRate * 2.0; const b = audioCtx.createBuffer(1, bSize, audioCtx.sampleRate); const d = b.getChannelData(0); for (let i = 0; i < bSize; i++) d[i] = Math.random() * 2 - 1; return b; }
const sound = {
init: () => { if (!noiseBuffer) noiseBuffer = createNoiseBuffer(); },
suspend: () => { if(audioCtx.state === 'running') audioCtx.suspend(); },
resume: () => { if(audioCtx.state === 'suspended') audioCtx.resume(); },
startEngine: () => { if (windSource) return; windSource = audioCtx.createBufferSource(); windSource.buffer = noiseBuffer; windSource.loop = true; windFilter = audioCtx.createBiquadFilter(); windFilter.type = 'bandpass'; windFilter.Q.value = 1.0; windFilter.frequency.value = 400; windGain = audioCtx.createGain(); windGain.gain.value = 0; windSource.connect(windFilter); windFilter.connect(windGain); windGain.connect(audioCtx.destination); windSource.start(); jetOsc = audioCtx.createOscillator(); jetOsc.type = 'sawtooth'; jetOsc.frequency.value = 800; jetGain = audioCtx.createGain(); jetGain.gain.value = 0; jetOsc.connect(jetGain); jetGain.connect(audioCtx.destination); jetOsc.start(); },
updateEngine: (sRatio, isB) => { if (!windSource) return; const now = audioCtx.currentTime; if (sRatio <= 0.01) { windGain.gain.setTargetAtTime(0, now, 0.1); jetGain.gain.setTargetAtTime(0, now, 0.1); return; } windFilter.frequency.setTargetAtTime(200 + (sRatio * 2000), now, 0.1); windGain.gain.setTargetAtTime(Math.min(0.3, sRatio * 0.2), now, 0.1); jetGain.gain.setTargetAtTime(isB ? 0.15 : 0.02, now, 0.2); jetOsc.frequency.setTargetAtTime(600 + (sRatio * 3000), now, 0.1); },
play: (type) => {
if (audioCtx.state === 'suspended' && type !== 'ui') audioCtx.resume(); const now = audioCtx.currentTime;
if (type === 'crash') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(800, now); f.frequency.exponentialRampToValueAtTime(100, now + 1.2); const g = audioCtx.createGain(); g.gain.setValueAtTime(1.0, now); g.gain.exponentialRampToValueAtTime(0.01, now + 1.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 1.2); }
else if (type === 'brake') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(1200, now); f.frequency.linearRampToValueAtTime(400, now + 0.3); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.3, now); g.gain.linearRampToValueAtTime(0.01, now + 0.3); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.3); }
else if (type === 'scrape') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(400, now); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0.01, now + 0.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.2); }
else if (type === 'heal') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(600, now); osc.frequency.exponentialRampToValueAtTime(2000, now+0.4); g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0, now+0.4); osc.start(now); osc.stop(now+0.4); }
else if (type === 'lockon') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square'; osc.frequency.setValueAtTime(1200, now); g.gain.setValueAtTime(0.05, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.08); osc.start(now); osc.stop(now+0.08); }
else if (type === 'boost_dash') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'highpass'; f.frequency.setValueAtTime(500, now); f.frequency.linearRampToValueAtTime(2000, now+0.5); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.5, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.5); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now + 0.5); }
else if (type === 'jump') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'triangle'; osc.frequency.setValueAtTime(150, now); osc.frequency.linearRampToValueAtTime(300, now+0.2); g.gain.setValueAtTime(0.8, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.3); osc.start(now); osc.stop(now+0.3); }
else if (type === 'land') { const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer; const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(300, now); const g = audioCtx.createGain(); g.gain.setValueAtTime(1.5, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.2); src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now+0.2); }
else if (type === 'coin') { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square'; osc.frequency.setValueAtTime(1200, now); osc.frequency.setValueAtTime(1800, now+0.05); g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1); osc.start(now); osc.stop(now+0.1); }
else if (type === 'ui') { if(audioCtx.state === 'suspended') audioCtx.resume(); const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(audioCtx.destination); osc.frequency.setValueAtTime(880, now); g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1); osc.start(now); osc.stop(now+0.1); }
}
};
const MAX_HP = 10, TUBE_R = 18, MAX_TIME = 60, NORMAL_MAX_SPEED = 350.0, RUSH_SPEED = NORMAL_MAX_SPEED * 2.0, NORMAL_ACCEL = 200.0, GRAVITY = 100.0, JUMP_POWER = 40.0, TOTAL_STAGES = 8, FOG_NEAR = 150, FOG_FAR = 900, SPEED_DISPLAY_MULTIPLIER = 0.6;
const TILE_SEGMENT_LENGTH = 12, TILES_PER_RING = 16, VISIBLE_SEGMENTS = 90;
let tunnelSegments = [];
let scene, camera, renderer, clock, gameState = "TITLE", viewMode = "F3", isPaused = false, score = 0, stageScore = 0, hp = MAX_HP, timeLeft = MAX_TIME, currentStage = 1;
let player = { mesh: null, angle: 0, x: 0, z: 0, prevZ: 0, vz: 10, vAngle: 0, vx: 0, altitude: 0, jumpV: 0, surge: 0, bank: 0, isBoosting: false, dashOffset: 0, wasAirborne: false, barrier: null, shadow: null, sonicBoom: null, thrust: null, wingTipL: null, wingTipR: null, mode: "NORMAL", lockTargets: [], rushIndex: 0, lockTimer: 0, currentLockType: null };
let worldObjects = [], debris = [], stars, skyMesh, planeGround, trackSplineGroup = null, keys = {}, nextSpawnZ = 150, trails = [];
class Trail {
constructor(color) { this.maxPoints = 60; this.points = []; this.width = 0.8; const geometry = new THREE.BufferGeometry(); const pos = new Float32Array(this.maxPoints * 2 * 3); geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const indices = []; for(let i=0; i<this.maxPoints-1; i++) { const base = i*2; indices.push(base, base+1, base+2, base+1, base+3, base+2); } geometry.setIndex(indices); const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }); this.mesh = new THREE.Mesh(geometry, material); this.mesh.frustumCulled = false; scene.add(this.mesh); }
update(newPos, rightVec) { const p1 = newPos.clone().addScaledVector(rightVec, this.width * 0.5), p2 = newPos.clone().addScaledVector(rightVec, -this.width * 0.5); this.points.unshift({p1, p2}); if(this.points.length > this.maxPoints) this.points.pop(); const positions = this.mesh.geometry.attributes.position.array; let idx = 0; for(let i=0; i<this.points.length; i++) { const pt = this.points[i]; positions[idx++] = pt.p1.x; positions[idx++] = pt.p1.y; positions[idx++] = pt.p1.z; positions[idx++] = pt.p2.x; positions[idx++] = pt.p2.y; positions[idx++] = pt.p2.z; } const last = this.points[this.points.length-1]; while(idx < positions.length) { positions[idx++] = last.p1.x; positions[idx++] = last.p1.y; positions[idx++] = last.p1.z; positions[idx++] = last.p2.x; positions[idx++] = last.p2.y; positions[idx++] = last.p2.z; } this.mesh.geometry.attributes.position.needsUpdate = true; }
reset() { this.points = []; const positions = this.mesh.geometry.attributes.position.array; for(let i=0; i<positions.length; i++) positions[i] = 0; this.mesh.geometry.attributes.position.needsUpdate = true; }
}
// Default Course Generator
function getDefaultCurve(z, mode) {
const coarseX = Math.sin(z * 0.0015) * 200, coarseY = Math.cos(z * 0.0015) * 150, detailY = Math.sin(z * 0.006) * 80 + Math.sin(z * 0.015) * 30, detailX = Math.cos(z * 0.004) * 60;
if (mode === "F2") return new THREE.Vector3(0, coarseY + detailY, z);
return new THREE.Vector3(coarseX + detailX, coarseY + detailY, z);
}
function getBasis(z, mode) {
if (customCourseCurve) {
let t = (z % customCourseLength) / customCourseLength; if (t < 0) t += 1.0;
const u = t; const tParam = customCourseCurve.getUtoTmapping(u);
const origin = customCourseCurve.getPointAt(u), T = customCourseCurve.getTangentAt(u).normalize();
const segs = customCourseFrames.tangents.length; const fi = Math.min(segs - 1, Math.max(0, Math.floor(u * (segs - 1))));
const track = customCourseData.track, rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam);
const q = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const U = customCourseFrames.normals[fi].clone().applyQuaternion(q), R = new THREE.Vector3().crossVectors(T, U).normalize();
return { origin, T, U, R, twistRad: THREE.MathUtils.degToRad(tDeg) };
} else {
const origin = getDefaultCurve(z, mode), forward = getDefaultCurve(z + 5.0, mode);
const T = new THREE.Vector3().subVectors(forward, origin).normalize(), R = new THREE.Vector3().crossVectors(T, new THREE.Vector3(0, 1, 0)).normalize(), U = new THREE.Vector3().crossVectors(R, T).normalize();
return { origin, T, U, R, twistRad: 0 };
}
}
function getSectionPosition(angle, radius, basis, mode) {
const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad);
const rAxis = basis.R.clone().applyQuaternion(qTwist), uAxis = basis.U.clone().applyQuaternion(qTwist);
if (mode === "F2") {
const spread = TUBE_R * Math.PI;
const ratio = angle / Math.PI;
const localX = ratio * (spread / 2);
return basis.origin.clone().addScaledVector(rAxis, localX).addScaledVector(uAxis, -radius);
} else {
const localX = Math.sin(angle) * radius, localY = -Math.cos(angle) * radius;
return basis.origin.clone().addScaledVector(rAxis, localX).addScaledVector(uAxis, localY);
}
}
function getSegmentColor(randomHue, lightnessVar = 0.0) { return new THREE.Color().setHSL(randomHue, 0.6, 0.6 + lightnessVar); }
function isGap(segmentIndex) { if (segmentIndex < 15) return false; return (segmentIndex % 60) >= 56; }
function initTunnel() {
const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
const boxGeo = new THREE.BoxGeometry(tileWidth * 1.1, 0.5, TILE_SEGMENT_LENGTH * 1.05);
for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
const segmentGroup = new THREE.Group();
segmentGroup.userData = { zIndex: i, hue: Math.random(), lVar: (Math.random() * 0.2) - 0.1 };
for (let j = 0; j < TILES_PER_RING; j++) {
const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, opacity: 0.1, transparent: true, side: THREE.DoubleSide, shininess: 100 });
const tile = new THREE.Mesh(boxGeo, mat); segmentGroup.add(tile);
}
tunnelSegments.push(segmentGroup); scene.add(segmentGroup);
}
}
function updateTunnel() {
if(customCourseCurve) { tunnelSegments.forEach(seg => seg.visible = false); return; }
const playerSegmentIndex = Math.floor(player.z / TILE_SEGMENT_LENGTH), startSegment = playerSegmentIndex - 8;
tunnelSegments.forEach((seg) => {
let segIdx = seg.userData.zIndex;
if (segIdx < startSegment) {
segIdx += VISIBLE_SEGMENTS; seg.userData.zIndex = segIdx; seg.userData.hue = Math.random(); seg.userData.lVar = (Math.random() * 0.2) - 0.1;
const newColor = getSegmentColor(seg.userData.hue, seg.userData.lVar);
seg.children.forEach(tile => { tile.material.color.copy(newColor); if(segIdx % 10 === 0) tile.material.emissive.setHex(0x222222); else tile.material.emissive.setHex(0x000000); });
}
const gap = isGap(segIdx); seg.visible = !gap;
const zPos = segIdx * TILE_SEGMENT_LENGTH, basis = getBasis(zPos, viewMode);
seg.children.forEach((tile, j) => {
let angle; if (viewMode === "F2") { const ratio = j / TILES_PER_RING; angle = (ratio * Math.PI * 2) - Math.PI; } else angle = (j / TILES_PER_RING) * Math.PI * 2;
tile.position.copy(getSectionPosition(angle, TUBE_R, basis, viewMode));
if (viewMode === "F2") {
const m = new THREE.Matrix4(); m.makeBasis(new THREE.Vector3(1, 0, 0), new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), basis.T).normalize(), new THREE.Vector3().crossVectors(new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), basis.T).normalize(), new THREE.Vector3(1, 0, 0)).normalize()); tile.rotation.setFromRotationMatrix(m);
} else {
const isOutside = (viewMode === "F4" || viewMode === "F5");
const normal = new THREE.Vector3().addScaledVector(basis.R, Math.sin(angle)).addScaledVector(basis.U, -Math.cos(angle)).normalize();
const up = isOutside ? normal : normal.clone().multiplyScalar(-1);
const tangent = new THREE.Vector3().crossVectors(up, basis.T).normalize();
const m = new THREE.Matrix4(); m.makeBasis(tangent, up, new THREE.Vector3().crossVectors(tangent, up).normalize()); tile.rotation.setFromRotationMatrix(m);
}
});
});
}
function init() {
sound.init(); clock = new THREE.Clock(); scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000); scene.fog = new THREE.Fog(0x000000, FOG_NEAR, FOG_FAR);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(90, window.innerWidth / window.innerHeight, 0.1, 5000);
scene.add(new THREE.AmbientLight(0x606060, 0.8)); const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(0, 100, -50); scene.add(sun); const rim = new THREE.DirectionalLight(0x00ffff, 0.8); rim.position.set(0, -50, 50); scene.add(rim);
planeGround = new THREE.Mesh(new THREE.PlaneGeometry(10000, 10000), new THREE.MeshPhongMaterial({ color: 0x222222 })); planeGround.rotation.x = -Math.PI / 2; planeGround.position.y = -200; scene.add(planeGround);
const starGeo = new THREE.BufferGeometry(); const posArray = new Float32Array(5000 * 3); for(let i=0; i<5000 * 3; i++) posArray[i] = (Math.random() - 0.5) * 6000; starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); stars = new THREE.Points(starGeo, new THREE.PointsMaterial({color: 0xffffff, size: 2.0, transparent: true, opacity: 0.8})); scene.add(stars);
const playerGeo = new THREE.Group();
const stealthGeo = new THREE.BufferGeometry(); const vertices = new Float32Array([0,0.5,3.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.5,3.0, 4.5,0,-2.0, 0,0.8,-0.5, -4.5,0,-2.0, 0,0.8,-0.5, 0,0.5,-3.0, 0,0.8,-0.5, 4.5,0,-2.0, 0,0.5,-3.0, 0,0,3.0, -4.5,0,-2.0, 0,-0.3,-0.5, 0,0,3.0, 0,-0.3,-0.5, 4.5,0,-2.0, -4.5,0,-2.0, 0,0.5,-3.0, 0,-0.3,-0.5, 4.5,0,-2.0, 0,-0.3,-0.5, 0,0.5,-3.0]); stealthGeo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); stealthGeo.computeVertexNormals();
playerGeo.add(new THREE.Mesh(stealthGeo, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.4, metalness: 0.1, emissive: 0x333333, flatShading: true })));
const cockpit = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.5, 4), new THREE.MeshPhongMaterial({ color: 0x00aaff, emissive: 0x002244, shininess: 100 })); cockpit.rotation.x = -Math.PI * 0.4; cockpit.position.set(0, 0.6, 0.5); cockpit.scale.z = 0.5; playerGeo.add(cockpit);
player.wingTipL = new THREE.Object3D(); player.wingTipL.position.set(-4.5, 0, -2.0); player.wingTipR = new THREE.Object3D(); player.wingTipR.position.set(4.5, 0, -2.0); playerGeo.add(player.wingTipL); playerGeo.add(player.wingTipR);
player.thrust = new THREE.Mesh(new THREE.ConeGeometry(0.4, 4, 8), new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.8 })); player.thrust.rotation.x = Math.PI / 2; player.thrust.position.z = -3.5; playerGeo.add(player.thrust);
player.barrier = new THREE.Mesh(new THREE.IcosahedronGeometry(3.5, 1), new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3, wireframe: true, emissive: 0x00aaaa, shininess: 100 })); player.barrier.position.z = 0; playerGeo.add(player.barrier);
player.sonicBoom = new THREE.Mesh(new THREE.SphereGeometry(5.0, 32, 16, 0, Math.PI * 2, 0, Math.PI * 0.5), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4, side: THREE.DoubleSide, blending: THREE.AdditiveBlending })); player.sonicBoom.rotation.x = -Math.PI / 2; player.sonicBoom.position.z = 2.0; player.sonicBoom.visible = false; playerGeo.add(player.sonicBoom);
player.mesh = playerGeo; scene.add(player.mesh);
player.shadow = new THREE.Mesh(new THREE.PlaneGeometry(4.0, 4.0), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.8 })); scene.add(player.shadow);
trails.push(new Trail(0x00ffff)); trails.push(new Trail(0x00ffff));
initTunnel();
window.addEventListener('keydown', (e) => {
if(["F1", "F2", "F3", "F4", "F5"].includes(e.code)) {
e.preventDefault(); viewMode = e.code; document.getElementById('view-mode').innerText = "VIEW: " + e.code;
if(customCourseData) rebuildTunnelMesh(viewMode);
}
keys[e.code] = true;
if (e.code === 'Escape' && gameState === "PLAYING") { isPaused = !isPaused; if(isPaused) { sound.suspend(); document.getElementById('center-text').innerHTML = "<h1 style='color:white;'>PAUSED</h1>"; clock.stop(); } else { sound.resume(); document.getElementById('center-text').innerHTML = ""; clock.start(); } }
if(e.code === 'Space') {
e.preventDefault();
if (gameState === "TITLE" || gameState === "GAMEOVER" || gameState === "ALL_CLEAR") {
sound.play('ui'); sound.startEngine(); if(gameState !== "TITLE") { score = 0; currentStage = 1; }
const sel = document.getElementById('course-select-box');
if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value);
else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); }
}
else if (gameState === "STAGE_CLEAR") { sound.play('ui'); currentStage++; changeState("STAGE_START"); }
else if (gameState === "STAGE_START") { sound.play('ui'); changeState("PLAYING"); }
}
if(e.code === 'KeyX') { if (gameState === "PLAYING" && player.altitude <= 0.1 && !isPaused) { sound.play('jump'); player.jumpV = JUMP_POWER; player.wasAirborne = true; } }
if(e.code === 'KeyZ' && gameState === "PLAYING" && !isPaused) { if (player.lockTargets.length > 0) { player.mode = "RUSHING"; player.rushIndex = 0; player.isBoosting = true; sound.play('boost_dash'); } else if (player.mode === "NORMAL") { player.mode = "MANUAL_BOOST"; player.isBoosting = true; sound.play('boost_dash'); } }
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; if (e.code === 'KeyZ') { if (player.mode === "RUSHING" || player.lockTargets.length > 0) { player.mode = "NORMAL"; player.isBoosting = false; clearAllLocks(); } else if (player.mode === "MANUAL_BOOST") { player.mode = "NORMAL"; player.isBoosting = false; } } });
changeState("TITLE"); animate();
}
function clearAllLocks() { player.lockTargets.forEach(obj => { obj.locked = false; if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; } }); player.lockTargets = []; player.currentLockType = null; player.rushIndex = 0; }
function rebuildTunnelMesh(mode) {
if (trackSplineGroup) scene.remove(trackSplineGroup); if (!customCourseData || !customCourseCurve) return;
const SEGMENTS = customCourseData.track.length * 20, WALL_RADIAL_SEGS = 16, R = 18;
const frames = customCourseFrames, track = customCourseData.track;
const wallPositions = [], wallUVs = [], wallIndices = [], roadPositions = [], roadUVs = [], roadIndices = [];
for (let i = 0; i <= SEGMENTS; i++) {
const u = i / SEGMENTS, tParam = customCourseCurve.getUtoTmapping(u);
const pt = customCourseCurve.getPointAt(u), T = frames.tangents[i], N = frames.normals[i], B = frames.binormals[i];
const rDeg = getLoopCatmullRom(track.map(n=>n.roll||0), tParam), tDeg = getLoopCatmullRom(track.map(n=>n.twist||0), tParam);
const qRoll = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(rDeg));
const up = N.clone().applyQuaternion(qRoll), right = B.clone().applyQuaternion(qRoll);
const qTwist = new THREE.Quaternion().setFromAxisAngle(T, THREE.MathUtils.degToRad(tDeg));
const twistUp = up.clone().applyQuaternion(qTwist), twistRight = right.clone().applyQuaternion(qTwist);
if (mode === "F2") {
const spread = R * Math.PI;
const roadCenter = pt.clone().addScaledVector(twistUp, -R);
const rLeft = roadCenter.clone().addScaledVector(twistRight, -spread/2);
const rRight = roadCenter.clone().addScaledVector(twistRight, spread/2);
roadPositions.push(rLeft.x, rLeft.y, rLeft.z); roadUVs.push(0, u * track.length * 4);
roadPositions.push(rRight.x, rRight.y, rRight.z); roadUVs.push(1, u * track.length * 4);
} else {
for (let j = 0; j <= WALL_RADIAL_SEGS; j++) {
const thetaW = (j / WALL_RADIAL_SEGS) * Math.PI;
const vw = pt.clone().addScaledVector(right, Math.cos(thetaW)*R).addScaledVector(up, Math.sin(thetaW)*R);
wallPositions.push(vw.x, vw.y, vw.z); wallUVs.push(j / WALL_RADIAL_SEGS, u * track.length * 4);
const thetaR = Math.PI + (j / WALL_RADIAL_SEGS) * Math.PI;
const vr = pt.clone().addScaledVector(twistRight, Math.cos(thetaR)*R).addScaledVector(twistUp, Math.sin(thetaR)*R);
roadPositions.push(vr.x, vr.y, vr.z); roadUVs.push(j / WALL_RADIAL_SEGS, u * track.length * 4);
}
}
}
if (mode === "F2") {
for (let i = 0; i < SEGMENTS; i++) { const a = i*2, b=(i+1)*2, c=(i+1)*2+1, d=i*2+1; roadIndices.push(a,b,d); roadIndices.push(b,c,d); }
} else {
for (let i = 0; i < SEGMENTS; i++) {
for (let j = 0; j < WALL_RADIAL_SEGS; j++) { const a = i*(WALL_RADIAL_SEGS+1)+j, b=(i+1)*(WALL_RADIAL_SEGS+1)+j, c=(i+1)*(WALL_RADIAL_SEGS+1)+(j+1), d=i*(WALL_RADIAL_SEGS+1)+(j+1); wallIndices.push(a,b,d); wallIndices.push(b,c,d); roadIndices.push(a,b,d); roadIndices.push(b,c,d); }
}
}
const makeGeo = (pos, uv, idx) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals(); return g; };
const env = customCourseData.assets?.env || {};
const wallMat = new THREE.MeshPhongMaterial({ color: 0x888888, side: THREE.DoubleSide });
if(env.wall && loadedTextures[env.wall.name]) wallMat.map = loadedTextures[env.wall.name]; else wallMat.wireframe = true;
const roadMat = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide });
if(env.road && loadedTextures[env.road.name]) roadMat.map = loadedTextures[env.road.name];
trackSplineGroup = new THREE.Group();
if(roadPositions.length > 0) trackSplineGroup.add(new THREE.Mesh(makeGeo(roadPositions, roadUVs, roadIndices), roadMat));
if(wallPositions.length > 0 && mode !== "F2") trackSplineGroup.add(new THREE.Mesh(makeGeo(wallPositions, wallUVs, wallIndices), wallMat));
scene.add(trackSplineGroup);
}
function changeState(s) {
gameState = s; document.getElementById('hud-bottom').style.display = (s === "PLAYING") ? "flex" : "none";
const menu = document.getElementById('center-text'); document.getElementById('speed-lines').style.opacity = 0;
if(s === "TITLE") {
menu.innerHTML = `<h1>Image Booster 7.1.1</h1><p style='color:#adff2f;'>CUSTOM SPLINE</p>${getCourseSelectUI()}`;
document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
document.getElementById('start-btn').addEventListener('click', () => {
const sel = document.getElementById('course-select-box');
if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value);
else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); }
});
populateCourseSelect();
}
else if(s === "STAGE_START") { stageScore = 0; menu.innerHTML = "<h1>READY?</h1>"; resetPlayerPos(); setTimeout(()=>changeState("PLAYING"), 1500); }
else if(s === "PLAYING") menu.innerHTML = "";
else if(s === "STAGE_CLEAR" || s === "GAMEOVER") {
const isClear = s === "STAGE_CLEAR";
menu.innerHTML = `<h1 style='color:${isClear?"#0f0":"red"};'>${isClear?"STAGE CLEAR!":"CRITICAL FAILURE"}</h1><p>SCORE: ${score}</p>${getCourseSelectUI()}`;
document.getElementById('assets-folder').addEventListener('change', e => handleFolderSelect(e.target.files));
document.getElementById('start-btn').addEventListener('click', () => {
const sel = document.getElementById('course-select-box');
if(sel && sel.value && Object.keys(courseFiles).length > 0) loadCourseAndStart(sel.value);
else { customCourseData = null; customCourseCurve = null; changeState("STAGE_START"); }
});
populateCourseSelect();
}
}
function resetPlayerPos() { player.angle = 0; player.x = 0; player.z = 0; player.prevZ = 0; player.vz = 0.0; player.altitude = 0; player.jumpV = 0; player.surge = 0; player.bank = 0; player.dashOffset = 0; hp = MAX_HP; timeLeft = MAX_TIME; player.mode = "NORMAL"; clearAllLocks(); player.isBoosting = false; debris.forEach(d => scene.remove(d)); debris = []; trails.forEach(t => t.reset()); nextSpawnZ = 150; tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; }); }
function animate() {
requestAnimationFrame(animate); const dt = clock.getDelta();
if (gameState === "PLAYING" && !isPaused) { const safeDt = Math.min(dt, 0.1); updatePhysics(safeDt); updateObjects(safeDt); updateDebris(safeDt); updateAutoLock(safeDt); }
if(gameState === "PLAYING" && !isPaused) { let ratio = player.vz / NORMAL_MAX_SPEED; if(player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ratio = 3.0; sound.updateEngine(ratio, player.isBoosting); } else if (!isPaused) sound.updateEngine(0, false);
if(!isPaused) { if(!customCourseCurve) updateTunnel(); updatePlayerVisuals(); if(stars) { stars.position.set(0, 0, player.z); stars.rotation.z += 0.0005; } }
renderer.render(scene, camera);
}
function updatePhysics(dt) {
if (player.mode === "RUSHING") { handleRushPhysics(dt); return; }
let currentAccel = 0; const isBraking = keys['ArrowDown'];
if (player.mode === "MANUAL_BOOST") { if (player.vz < RUSH_SPEED) player.vz += NORMAL_ACCEL * 4.0 * dt; player.surge = THREE.MathUtils.lerp(player.surge, 10.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0.8; }
else if (keys['ArrowUp']) { currentAccel = NORMAL_ACCEL; player.surge = THREE.MathUtils.lerp(player.surge, 3.0, 5 * dt); player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0; }
else { currentAccel = 0; player.surge = THREE.MathUtils.lerp(player.surge, 0, 5 * dt); document.getElementById('speed-lines').style.opacity = 0; }
if (isBraking && player.mode !== "MANUAL_BOOST") { player.vz -= NORMAL_ACCEL * 1.5 * dt; player.surge = THREE.MathUtils.lerp(player.surge, -3.0, 5 * dt); if (brakeCooldown <= 0) { sound.play('brake'); brakeCooldown = 0.2; } brakeCooldown -= dt; if (player.vz > 50) createDebris(player.mesh.position.clone(), 0xffaa00, 1.0, 1, "spark_brake"); } else brakeCooldown = 0;
if (player.mode !== "MANUAL_BOOST") { if (currentAccel > 0) { if (player.vz < NORMAL_MAX_SPEED) player.vz += currentAccel * dt; else player.vz *= (1.0 - (0.5 * dt)); } else if (!isBraking) player.vz *= (1.0 - (0.2 * dt)); }
player.vz = Math.max(0.0, player.vz); handleGravity(dt); handleSteering(dt); player.prevZ = player.z; player.z += player.vz * dt; updateGameTimers(dt); updateUI();
}
function updateAutoLock(dt) {
if (player.mode === "RUSHING") return; player.lockTimer += dt; if (player.lockTimer < 0.1) return; player.lockTimer = 0;
let candidates = worldObjects.filter(obj => { if (obj.locked || obj.isDead || obj.z < player.z || obj.z > player.z + 250 || obj.isScenery) return false; let dx = Math.abs(obj.angle - player.angle) * 10.0; return dx <= 8.0 && (!player.currentLockType || player.currentLockType === obj.type); });
if (candidates.length > 0) { candidates.sort((a,b) => a.z - b.z); const target = candidates[0]; target.locked = true; player.lockTargets.push(target); player.currentLockType = target.type; const div = document.createElement('div'); div.className = 'lock-on-sight'; let col = "#fff"; if(target.type==="block")col="#ddd"; else if(target.type==="hurdle")col="#f30"; else if(target.type==="heal")col="#0f0"; else if(target.type==="score")col="#ff0"; div.style.borderColor = col; div.style.boxShadow = `0 0 10px ${col}, inset 0 0 10px ${col}`; document.getElementById('ui-layer').appendChild(div); target.sightDom = div; sound.play('lockon'); }
}
function handleRushPhysics(dt) {
player.lockTargets = player.lockTargets.filter(t => !t.isDead);
if (player.lockTargets.length === 0 || player.rushIndex >= player.lockTargets.length) { if (keys['KeyZ']) player.mode = "MANUAL_BOOST"; else { player.mode = "NORMAL"; player.isBoosting = false; } player.surge = 0; clearAllLocks(); return; }
const targetObj = player.lockTargets[player.rushIndex]; if (!worldObjects.includes(targetObj) || targetObj.isDead) { player.rushIndex++; return; }
const rushDist = RUSH_SPEED * dt; if (targetObj.z - player.z <= rushDist) { player.prevZ = player.z; player.z = targetObj.z; player.angle = targetObj.angle; handleCollision(targetObj, true); player.surge = 15.0; player.rushIndex++; } else { player.prevZ = player.z; player.z += rushDist; const t = 10.0 * dt; player.angle = THREE.MathUtils.lerp(player.angle, targetObj.angle, t); player.altitude = THREE.MathUtils.lerp(player.altitude, 0, t); player.surge = THREE.MathUtils.lerp(player.surge, 20.0, 5 * dt); document.getElementById('speed-lines').style.opacity = 1.0; }
updateGameTimers(dt); updateUI();
}
function handleGravity(dt) {
const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
const inGap = (!customCourseCurve && isGap(currentSegIdx));
const speedKmh = player.vz * SPEED_DISPLAY_MULTIPLIER;
const isFloating = (speedKmh > 30 && !player.wasAirborne);
player.altitude += player.jumpV * dt;
if (inGap && !isFloating) {
player.jumpV -= GRAVITY * dt;
} else {
if (player.altitude > 0) player.jumpV -= GRAVITY * dt;
else { if (player.wasAirborne && player.jumpV < -10) { sound.play('land'); player.wasAirborne = false; } player.altitude = 0; player.jumpV = 0; }
}
if (player.altitude < -40) { hp = 0; changeState("GAMEOVER"); }
}
function handleSteering(dt) {
let steerFactor = (player.vz > 300.0) ? 0.5 : 1.0; steerFactor *= (viewMode === "F2" ? 4.0 : 2.0); let targetBank = 0; const BANK_LIMIT = 0.78;
const isOutside = (viewMode === "F4" || viewMode === "F5");
const steerDirection = isOutside ? -1 : 1; // F2は除外
if (keys['ArrowLeft']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, -1.5 * steerFactor * steerDirection, 5 * dt); targetBank = -BANK_LIMIT; }
else if (keys['ArrowRight']) { player.vAngle = THREE.MathUtils.lerp(player.vAngle, 1.5 * steerFactor * steerDirection, 5 * dt); targetBank = BANK_LIMIT; }
else player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0, 10 * dt);
player.angle += player.vAngle * dt; player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 5 * dt);
if (viewMode === "F2") { const limit = Math.PI * 1.0; if (player.angle > limit) { player.angle = limit; player.vAngle = 0; } else if (player.angle < -limit) { player.angle = -limit; player.vAngle = 0; } }
if (Math.abs(player.bank) > (BANK_LIMIT * 0.95)) { if(bankSoundTimer <= 0) { sound.play('scrape'); bankSoundTimer = 0.2; } bankSoundTimer -= dt; createDebris(player.mesh.position.clone(), 0xffaa00, 0.5, 1, "spark"); } else bankSoundTimer = 0;
}
function updateGameTimers(dt) { timeLeft -= dt; if (timeLeft <= 0) { timeLeft = 0; changeState("STAGE_CLEAR"); } if (hp <= 0) { hp = 0; changeState("GAMEOVER"); } }
function updatePlayerVisuals() {
const visualZ = player.z + player.surge + player.dashOffset, basis = getBasis(visualZ, viewMode);
const isOutside = (viewMode === "F4" || viewMode === "F5");
const r = isOutside ? TUBE_R + 2.0 + player.altitude : TUBE_R - 2.0 - player.altitude;
const pos = getSectionPosition(player.angle, r, basis, viewMode);
const center = basis.origin.clone();
const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad), uAxis = basis.U.clone().applyQuaternion(qTwist);
let playerUp;
if (viewMode === "F2") { playerUp = uAxis.clone(); }
else { playerUp = isOutside ? new THREE.Vector3().subVectors(pos, center).normalize() : new THREE.Vector3().subVectors(center, pos).normalize(); }
const playerForward = basis.T.clone();
player.mesh.position.copy(pos); const m = new THREE.Matrix4(), right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize(), orthoUp = new THREE.Vector3().crossVectors(right, playerForward).normalize();
m.makeBasis(right, orthoUp, playerForward); player.mesh.rotation.setFromRotationMatrix(m); player.mesh.rotateZ(player.bank); if (player.altitude > 0.5) player.mesh.rotateX(-0.1);
player.mesh.visible = (viewMode !== "F1" && viewMode !== "F4");
if (player.mesh.visible) { const tipLPos = new THREE.Vector3(), tipRPos = new THREE.Vector3(); player.wingTipL.getWorldPosition(tipLPos); player.wingTipR.getWorldPosition(tipRPos); const widthVec = orthoUp.clone().normalize(); if (trails[0]) trails[0].update(tipLPos, widthVec); if (trails[1]) trails[1].update(tipRPos, widthVec); }
if (player.sonicBoom) { player.sonicBoom.visible = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST"); if(player.sonicBoom.visible) { const s = 1.0 + Math.random() * 0.1; player.sonicBoom.scale.set(s, s, s); } }
if (Math.abs(player.bank) > 0.7 && player.vz > 100.0) { const sideOffset = (player.bank > 0) ? 2.0 : -2.0; const sparkPos = player.mesh.position.clone().addScaledVector(right, sideOffset).addScaledVector(playerUp, -0.5); createDebris(sparkPos, 0xffff00, 0.3, 2, "spark"); }
if (player.thrust) { if (player.vz < 1.0 && player.mode !== "RUSHING" && player.mode !== "MANUAL_BOOST") player.thrust.visible = false; else { player.thrust.visible = true; const speedRatio = player.vz / RUSH_SPEED; player.thrust.scale.set(0.5, 0.5, 0.5 + speedRatio * 1.5); player.thrust.material.opacity = 0.6 + speedRatio * 0.4; player.thrust.material.color.setHex((player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? 0xff00ff : 0x00ffff); } }
if (player.barrier) { player.barrier.scale.setScalar(0.8 + (Math.max(0, hp / MAX_HP) * 0.2)); player.barrier.rotation.y += 0.05; if (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") { player.barrier.material.color.setHex(0xff0000); player.barrier.material.emissive.setHex(0x550000); } else { player.barrier.material.color.setHex(0x00ffff); player.barrier.material.emissive.setHex(0x004444); } }
if (player.shadow) { if (viewMode !== "F1" && viewMode !== "F4" && (!customCourseCurve ? !isGap(Math.floor(player.z/TILE_SEGMENT_LENGTH)) : true)) { player.shadow.visible = true; const groundR = isOutside ? TUBE_R + 0.2 : TUBE_R - 0.2; player.shadow.position.copy(getSectionPosition(player.angle, groundR, basis, viewMode)); if(viewMode === "F2") { const sm = new THREE.Matrix4(); sm.makeBasis(right, orthoUp, playerForward); player.shadow.rotation.setFromRotationMatrix(sm); player.shadow.rotateX(-Math.PI/2); } else { player.shadow.lookAt(center); if(isOutside) player.shadow.rotateY(Math.PI); } player.shadow.scale.setScalar(Math.max(0.1, 1.0 - Math.max(0, player.altitude) * 0.05)); } else player.shadow.visible = false; }
const shake = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? (Math.random()-0.5)*1.0 : (Math.random()-0.5)*0.1; const cameraUp = orthoUp.clone(); cameraUp.applyAxisAngle(playerForward, player.bank * 0.6);
if (viewMode === "F1" || viewMode === "F4") { const eyePos = pos.clone().addScaledVector(playerForward, 2.0).addScaledVector(orthoUp, 0.5); camera.position.copy(eyePos); camera.position.y += shake; camera.up.copy(cameraUp); camera.lookAt(eyePos.clone().add(playerForward)); }
else if (viewMode === "F2") { const camPos = pos.clone().addScaledVector(playerForward, -60).addScaledVector(playerUp, 50); camera.position.copy(camPos); camera.up.copy(playerUp); camera.lookAt(pos); }
else { const camPos = pos.clone().addScaledVector(playerForward, -30).addScaledVector(orthoUp, 8); camPos.addScaledVector(right, shake); camera.position.copy(camPos); camera.up.copy(cameraUp); camera.lookAt(camPos.clone().add(playerForward)); }
worldObjects.forEach(obj => { if (obj.sightDom) { if (obj.isDead || obj.z < player.z) { obj.locked = false; obj.sightDom.remove(); obj.sightDom = null; return; } const vector = obj.mesh.position.clone(); vector.project(camera); obj.sightDom.style.left = (vector.x*.5+.5)*window.innerWidth + 'px'; obj.sightDom.style.top = -(vector.y*.5-.5)*window.innerHeight + 'px'; if (player.mode === "RUSHING" && player.lockTargets[player.rushIndex] === obj) obj.sightDom.classList.add('rushing'); } });
}
function updateObjects(dt) {
let spawnDist = ((150 - (currentStage * 10.0)) / 3.0) + (player.vz * 0.5);
if (player.z + 1500 > nextSpawnZ) { spawnRandomObject(nextSpawnZ); nextSpawnZ += Math.random() * 50 + spawnDist; }
for (let i = worldObjects.length - 1; i >= 0; i--) {
const obj = worldObjects[i]; if (obj.isDead && !obj.flying) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; }
if (obj.isScenery) continue;
if(obj.flying) { obj.localPos.addScaledVector(obj.vel, dt * 60); obj.mesh.rotation.x += 10 * dt; obj.mesh.rotation.y += 10 * dt; }
const basis = getBasis(obj.z, viewMode); let worldPos;
if (obj.flying) worldPos = basis.origin.clone().add(obj.localPos);
else {
const isOutside = (viewMode === "F4" || viewMode === "F5");
worldPos = getSectionPosition(obj.angle, obj.r, basis, viewMode);
const qTwist = new THREE.Quaternion().setFromAxisAngle(basis.T, basis.twistRad);
const uAxis = basis.U.clone().applyQuaternion(qTwist);
let up;
if(viewMode === "F2") { up = uAxis.clone(); }
else { const normal = new THREE.Vector3().addScaledVector(basis.R.clone().applyQuaternion(qTwist), Math.sin(obj.angle)).addScaledVector(uAxis, -Math.cos(obj.angle)).normalize(); up = isOutside ? normal : normal.clone().multiplyScalar(-1); }
const right = new THREE.Vector3().crossVectors(up, basis.T).normalize(); const m = new THREE.Matrix4(); m.makeBasis(right, up, new THREE.Vector3().crossVectors(right, up).normalize()); obj.mesh.rotation.setFromRotationMatrix(m);
}
obj.mesh.position.copy(worldPos);
if (player.mode !== "RUSHING" && !obj.flying && !obj.isDead) { const passedThrough = (obj.z >= player.prevZ && obj.z <= player.z); if (passedThrough || Math.abs(player.z - obj.z) < 10.0) { let dx = Math.abs(obj.angle - player.angle) * 10.0; if ((passedThrough && dx < 5.0) || player.mesh.position.distanceTo(obj.mesh.position) < 8.0) { handleCollision(obj, true); if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); continue; } } } }
if (obj.z < player.z - 100) { scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); worldObjects.splice(i, 1); }
}
}
function spawnRandomObject(z) {
let type = (Math.random() < 0.1) ? "score" : (Math.random() < 0.05) ? "heal" : "block"; if (Math.random() < Math.min(0.4, 0.1 + (currentStage * 0.04))) type = "hurdle";
let xPos = (Math.random() - 0.5) * 2.4; if(customCourseCurve) xPos = (Math.floor(Math.random() * 16) / 16) * Math.PI * 2 - Math.PI;
let length = (type === 'block') ? 3 : 1;
for(let i = 0; i < length; i++) { let zOffset = z + (i * 12.0), stackHeight = (type === 'block') ? Math.floor(Math.random() * 2) + 1 : 1; for (let k = 0; k < stackHeight; k++) { let geo, color; if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); color = new THREE.Color(0xff3300); } else if (type === 'heal') { geo = new THREE.BoxGeometry(2,2,2); color = new THREE.Color(0x00ff00); } else if (type === 'score') { geo = new THREE.BoxGeometry(3,3,3); color = new THREE.Color(0xffff00); } else { geo = new THREE.BoxGeometry(3,3,3); const g = 0.3+Math.random()*0.5; color = new THREE.Color(g,g,g); } const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color })); let baseR = TUBE_R + ((viewMode === "F4" || viewMode === "F5") ? ((type === 'block') ? 1.5+k*3.0 : 1.5) : -((type === 'block') ? 1.5+k*3.0 : 1.5)); scene.add(mesh); worldObjects.push({ mesh, type, flying: false, z: zOffset, angle: xPos, r: baseR, vel: new THREE.Vector3(), localPos: new THREE.Vector3(Math.sin(xPos)*baseR, -Math.cos(xPos)*baseR, 0), locked: false, sightDom: null, isDead: false, isScenery: false }); } }
}
function handleCollision(obj, forcedKill = false) {
if (obj.isDead) return; obj.isDead = true;
if (obj.type === "block" || obj.type === "hurdle") {
if (forcedKill) { sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 2.0, 15, "explode"); if (obj.type === "hurdle") { hp -= 1; showPopText("BREAK! -1HP", "#ff3300"); } else { score += 50; stageScore += 50; showPopText("SMASH! +50", "#ffaa00"); } scene.remove(obj.mesh); if(obj.sightDom) obj.sightDom.remove(); }
else { if (obj.type === "hurdle") { hp -= 2; showPopText("CRASH! -2HP", "#ff0000"); sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 10, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*2, 5, 8); } else { score += 10; stageScore += 10; showPopText("HIT! +10", "#ffffff"); sound.play('crash'); createDebris(obj.mesh.position, obj.mesh.material.color, 1.0, 5, "explode"); obj.flying = true; obj.vel.set((Math.random()-0.5)*4, 10, 15); } if(obj.sightDom) { obj.sightDom.remove(); obj.sightDom = null; } }
} else if (obj.type === "score") { sound.play('coin'); score += 100; stageScore += 100; showPopText("+100", "#ffff00"); } else if (obj.type === "heal") { sound.play('heal'); hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff00"); }
}
function createDebris(pos, color, size, count, type) {
for(let i=0; i<count; i++) { const s = (type.includes("spark")) ? size * (Math.random()*0.5 + 0.5) : size; const mesh = new THREE.Mesh(new THREE.BoxGeometry(s, s, s), new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0 })); mesh.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5)*2, (Math.random()-0.5)*2, (Math.random()-0.5)*2)); scene.add(mesh); let vel, life; if (type === "spark") { vel = new THREE.Vector3((Math.random()-0.5)*5, (Math.random())*5, (Math.random()-0.5)*5); life = 0.5; } else if (type === "spark_brake") { vel = new THREE.Vector3((Math.random()-0.5)*10, 5+Math.random()*10, 5+Math.random()*10); life = 0.3; } else { vel = new THREE.Vector3((Math.random()-0.5)*30, (Math.random()-0.5)*30, (Math.random()-0.5)*30); life = 1.0; } debris.push({ mesh, vel, life, type }); }
}
function updateDebris(dt) { for(let i = debris.length - 1; i >= 0; i--) { const d = debris[i]; d.mesh.position.addScaledVector(d.vel, dt * 20); d.life -= dt; if (d.type.includes("spark")) d.mesh.rotation.z += 10 * dt; else d.mesh.rotation.x += 5 * dt; if(d.life <= 0) { scene.remove(d.mesh); debris.splice(i, 1); } } }
function showPopText(text, color) { const div = document.createElement('div'); div.className = 'pop-text'; div.style.color = color; div.innerText = text; div.style.left = "50%"; div.style.top = "40%"; document.getElementById('ui-layer').appendChild(div); setTimeout(() => div.remove(), 800); }
function updateUI() { document.getElementById('score-info').innerText = "SCORE: " + score; document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%"; const val = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? RUSH_SPEED : player.vz; document.getElementById('speed-val').innerText = (val * SPEED_DISPLAY_MULTIPLIER).toFixed(0); document.getElementById('speed-fill').style.width = Math.min(100, (val / RUSH_SPEED) * 100) + "%"; document.getElementById('speed-fill').style.backgroundColor = (player.mode === "RUSHING" || player.mode === "MANUAL_BOOST") ? "#ff00ff" : "#00ff88"; const rTime = Math.max(0, timeLeft); document.getElementById('time-num').innerText = Math.floor(rTime / 60) + ":" + (Math.floor(rTime % 60) < 10 ? "0" : "") + Math.floor(rTime % 60); document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%"; }
init();
</script>
</body>
</html>

コメント