SOURCE / PINNED RELEASE
Made of little things.
Impact Yard
- Release
- 09fe6aebec3c…
- Author-recorded commit
- 0696a4211733…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-0b3d7b60562
Archive hash verified: 823f5de0d2677ef2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import type { Box } from 'cannon-es';
import { STORAGE_DOMAIN, THEME_DOMAIN, storage, themeGet, themeOnChanged, type Subscription, type Theme } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { createSimulation, GROUPS, RAMP, type Prop, type Simulation } from './simulation.js';
import './styles.css';
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const canvas = $<HTMLCanvasElement>('scene');
const status = $('status');
const aborter = new AbortController();
const signal = aborter.signal;
let statusTimer = 0;
let themeSubscription: Subscription | null = null;
let renderer: THREE.WebGLRenderer;
let simulation: Simulation;
let frameId = 0;
let paused = false;
let slow = false;
let cameraMode = 0;
let cameraChanged = false;
let orbitAngle = 0.55;
let stopped = false;
const cameraNames = ['Chase camera', 'Orbit camera', 'Overhead'];
const down = new Set<string>();
const touches = new Map<number, string>();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 230);
const lookTarget = new THREE.Vector3();
const cameraTarget = new THREE.Vector3();
const carPosition = new THREE.Vector3();
const carQuaternion = new THREE.Quaternion();
const forward = new THREE.Vector3();
const worldUp = new THREE.Vector3(0, 1, 0);
const propMeshes: THREE.Group[] = [];
const wheels: THREE.Group[] = [];
const car = new THREE.Group();
const paint = new THREE.MeshStandardMaterial({ color: 0xe96b34, roughness: 0.38, metalness: 0.3 });
const darkMetal = new THREE.MeshStandardMaterial({ color: 0x23322f, roughness: 0.72, metalness: 0.2 });
const tireMaterial = new THREE.MeshStandardMaterial({ color: 0x171e1c, roughness: 0.96 });
const glass = new THREE.MeshStandardMaterial({ color: 0x426363, roughness: 0.2, metalness: 0.65 });
const trim = new THREE.MeshStandardMaterial({ color: 0xc1c9b6, roughness: 0.48, metalness: 0.6 });
const asphalt = new THREE.MeshStandardMaterial({ color: 0x8f9b86, roughness: 0.97 });
const concrete = new THREE.MeshStandardMaterial({ color: 0xbac1ac, roughness: 0.93 });
const lineMaterial = new THREE.MeshBasicMaterial({ color: 0xd7d9a8, transparent: true, opacity: 0.62, depthWrite: false });
const lamp = new THREE.MeshStandardMaterial({ color: 0xffefc7, emissive: 0xffd78a, emissiveIntensity: 0.65 });
const tailLight = new THREE.MeshStandardMaterial({ color: 0x942c1f, emissive: 0xff351c, emissiveIntensity: 0.2 });
const sun = new THREE.DirectionalLight(0xffedcc, 3.0);
const hemi = new THREE.HemisphereLight(0xe5eee1, 0x6b7259, 2.6);
scene.add(hemi, sun);
const geometryCache = new Map<string, THREE.BoxGeometry>();
function box(w: number, h: number, d: number, material: THREE.Material, parent: THREE.Object3D, x = 0, y = 0, z = 0) {
const key = `${w}/${h}/${d}`;
let geometry = geometryCache.get(key);
if (!geometry) { geometry = new THREE.BoxGeometry(w, h, d); geometryCache.set(key, geometry); }
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z); mesh.castShadow = true; mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
}
function cylinder(radius: number, height: number, material: THREE.Material, parent: THREE.Object3D, x = 0, y = 0, z = 0, segments = 16) {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, segments), material);
mesh.position.set(x, y, z); mesh.castShadow = true; mesh.receiveShadow = true; parent.add(mesh);
return mesh;
}
function batchProp(group: THREE.Group) {
// Decorative boards share a draw call while each prop keeps its own physics body.
const batches = new Map<string, { material: THREE.Material; geometries: THREE.BufferGeometry[]; cast: boolean; receive: boolean }>();
for (const child of group.children) {
if (!(child instanceof THREE.Mesh) || Array.isArray(child.material)) continue;
child.updateMatrix();
const geometry = child.geometry.clone().applyMatrix4(child.matrix);
const key = `${child.material.uuid}/${child.castShadow}/${child.receiveShadow}`;
const batch = batches.get(key) ?? { material: child.material, geometries: [] as THREE.BufferGeometry[], cast: child.castShadow, receive: child.receiveShadow };
batch.geometries.push(geometry); batches.set(key, batch);
}
group.clear();
for (const { material, geometries, cast, receive } of batches.values()) {
const geometry = mergeGeometries(geometries);
geometries.forEach(part => part.dispose());
if (!geometry) continue;
const mesh = new THREE.Mesh(geometry, material); mesh.castShadow = cast; mesh.receiveShadow = receive; group.add(mesh);
}
return group;
}
function surfaceLabel(text: string, x: number, z: number, width: number, depth: number, size = 54) {
const labelCanvas = document.createElement('canvas'); labelCanvas.width = 512; labelCanvas.height = 128;
const ctx = labelCanvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#dce0ba'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `600 ${size}px monospace`;
ctx.fillText(text, 256, 64);
const texture = new THREE.CanvasTexture(labelCanvas); texture.colorSpace = THREE.SRGBColorSpace;
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(width, depth), new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.64, depthWrite: false }));
mesh.rotation.x = -Math.PI / 2; mesh.position.set(x, 0.018, z); scene.add(mesh);
}
function groundLine(x: number, z: number, w: number, d: number) {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, d), lineMaterial);
mesh.rotation.x = -Math.PI / 2; mesh.position.set(x, 0.012, z); scene.add(mesh);
}
function createYard() {
const floor = new THREE.Mesh(new THREE.PlaneGeometry(500, 500), asphalt);
floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor);
// Expansion joints, lightly worn lane paint and individual parking bays.
const grid = new THREE.GridHelper(78, 26, 0x76846f, 0x76846f); grid.position.y = 0.006;
(grid.material as THREE.Material).transparent = true; (grid.material as THREE.Material).opacity = 0.23; scene.add(grid);
for (const x of [-34, 34]) groundLine(x, 0, 0.12, 69);
for (const z of [-34, 34]) groundLine(0, z, 68, 0.12);
for (let z = -30; z < 33; z += 5) groundLine(7.5, z, 0.14, 2.2);
for (let x = -29; x <= 29; x += 7.2) { groundLine(x, 30, 0.1, 7.8); groundLine(x + 3.6, 26, 7.2, 0.1); }
for (const [i, group] of GROUPS.entries()) {
const w = group.width, d = group.depth;
groundLine(group.x, group.z - d / 2, w, 0.11); groundLine(group.x, group.z + d / 2, w, 0.11);
groundLine(group.x - w / 2, group.z, 0.11, d); groundLine(group.x + w / 2, group.z, 0.11, d);
surfaceLabel(`0${i + 1} / ${group.short}`, group.x, group.z + d / 2 + 1.4, w, 2.1, 42);
}
surfaceLabel('START', 0, 21, 4.8, 1.4);
groundLine(0, 20, 6, 0.16);
surfaceLabel('JUMP', 18, 5, 4.4, 1.5);
const rampVisual = new THREE.Group(); scene.add(rampVisual);
rampVisual.position.copy(simulation.ramp.position); rampVisual.quaternion.copy(simulation.ramp.quaternion);
const rampPositions: number[] = [];
for (const face of RAMP.faces) for (let i = 1; i < face.length - 1; i++) {
for (const vertex of [face[0], face[i], face[i + 1]]) rampPositions.push(...RAMP.vertices[vertex]);
}
const rampGeometry = new THREE.BufferGeometry();
rampGeometry.setAttribute('position', new THREE.Float32BufferAttribute(rampPositions, 3)); rampGeometry.computeVertexNormals();
const rampMesh = new THREE.Mesh(rampGeometry, darkMetal); rampMesh.castShadow = true; rampMesh.receiveShadow = true; rampVisual.add(rampMesh);
const slope = Math.atan(RAMP.height / RAMP.length), deckLength = Math.hypot(RAMP.length, RAMP.height);
for (const x of [-2.8, 2.8]) {
const stripe = box(0.13, 0.012, deckLength - 0.1, lineMaterial, rampVisual, x, RAMP.height / 2 + 0.01);
stripe.rotation.x = slope;
}
for (let z = -6.6; z < 6.8; z += 0.65) {
const rung = box(5.5, 0.012, 0.035, trim, rampVisual, 0, (RAMP.length / 2 - z) * RAMP.height / RAMP.length + 0.01, z);
rung.rotation.x = slope;
}
for (const barrier of simulation.barriers) {
const shape = barrier.shapes[0] as Box;
const s = shape.halfExtents;
box(s.x * 2, s.y * 2, s.z * 2, concrete, scene, barrier.position.x, barrier.position.y, barrier.position.z);
}
// Perimeter bollards and hazard markings.
for (let n = -36; n < 39; n += 6) {
box(0.22, 2.3, 0.22, darkMetal, scene, n, 2.15, -39);
box(0.22, 2.3, 0.22, darkMetal, scene, -39, 2.15, n);
box(1.8, 0.14, 0.06, lineMaterial, scene, n, 1.25, -38.48);
}
// Buildings stay beyond the drivable perimeter.
const warehouseMat = new THREE.MeshStandardMaterial({ color: 0x7b8b7d, roughness: 0.88 });
const roofMat = new THREE.MeshStandardMaterial({ color: 0x536959, roughness: 0.93 });
const shutterMat = new THREE.MeshStandardMaterial({ color: 0x48594d, roughness: 0.85 });
for (const [x, z, w, h, d] of [[-25, -53, 38, 11, 19], [22, -56, 37, 8, 20], [-54, 5, 18, 10, 55]]) {
box(w, h, d, warehouseMat, scene, x, h / 2, z);
box(w + 0.7, 0.35, d + 0.7, roofMat, scene, x, h + 0.17, z);
if (z < -40) for (let bx = x - w / 2 + 5; bx < x + w / 2; bx += 10) {
box(6.8, 5.5, 0.1, shutterMat, scene, bx, 2.75, z + d / 2 + 0.1);
for (let y = 0.4; y < 5.6; y += 0.42) box(6.8, 0.035, 0.12, roofMat, scene, bx, y, z + d / 2 + 0.16);
box(6.5, 1.2, 0.13, glass, scene, bx, 7, z + d / 2 + 0.15);
}
}
const containerColors = [0x8e6748, 0x6c8175, 0xa29260];
for (let i = 0; i < 4; i++) {
const material = new THREE.MeshStandardMaterial({ color: containerColors[i % 3], roughness: 0.9 });
const x = 47 + (i % 2) * 5, z = -15 + Math.floor(i / 2) * 18;
box(4, 4, 13, material, scene, x, 2, z);
for (let k = -6; k < 7; k += 0.55) box(0.08, 3.7, 0.1, material, scene, x - 2.04, 2, z + k);
}
for (const [x, z] of [[-34, -32], [34, -32], [-34, 24], [34, 24]]) {
cylinder(0.11, 11, darkMetal, scene, x, 5.5, z, 8);
box(2, 0.15, 0.15, darkMetal, scene, x, 11, z);
box(1.6, 0.13, 0.7, lamp, scene, x, 10.9, z);
}
// Low silhouettes make a complete horizon without loading any assets.
const hillMat = new THREE.MeshStandardMaterial({ color: 0x82917a, roughness: 1, flatShading: true });
for (let i = 0; i < 20; i++) {
const a = i / 20 * Math.PI * 2;
const hill = new THREE.Mesh(new THREE.ConeGeometry(24 + i % 4 * 7, 15 + i % 5 * 5, 5), hillMat);
hill.position.set(Math.cos(a) * 130, 5, Math.sin(a) * 130); hill.rotation.y = a; scene.add(hill);
}
const buildings = new THREE.Group();
for (const child of [...scene.children]) if (child instanceof THREE.Mesh) buildings.add(child);
scene.add(batchProp(buildings));
}
function crateVisual(prop: Prop) {
const group = new THREE.Group();
const [w, h, d] = prop.size;
const wood = new THREE.MeshStandardMaterial({ color: [0xbd8950, 0xc5975a, 0xa77a46, 0xb78a51][prop.variant], roughness: 0.96 });
const edges = new THREE.MeshStandardMaterial({ color: 0x795a36, roughness: 0.92 });
box(w, h, d, wood, group);
for (const side of [-1, 1]) {
for (const x of [-w * 0.39, w * 0.39]) box(0.12, h, 0.045, edges, group, x, 0, side * (d / 2 + 0.025));
for (const y of [-h * 0.39, h * 0.39]) box(w, 0.12, 0.045, edges, group, 0, y, side * (d / 2 + 0.025));
for (const z of [-d * 0.39, d * 0.39]) box(0.045, h, 0.12, edges, group, side * (w / 2 + 0.025), 0, z);
const brace = box(w * 1.18, 0.08, 0.025, edges, group, 0, 0, side * (d / 2 + 0.05)); brace.rotation.z = side * 0.72;
}
for (const z of [-d * 0.3, d * 0.3]) box(w, 0.04, 0.09, edges, group, 0, h / 2 + 0.015, z);
return group;
}
function propVisual(prop: Prop) {
if (prop.kind === 'crate') return crateVisual(prop);
const group = new THREE.Group();
if (prop.kind === 'timber') {
const [w, h, d] = prop.size;
const wood = new THREE.MeshStandardMaterial({ color: [0xc99555, 0xb48149, 0xd3a367, 0xbd905b][prop.variant], roughness: 0.92 });
const grain = new THREE.MeshStandardMaterial({ color: 0x8e663c, roughness: 1 });
box(w, h, d, wood, group);
const alongX = w > d;
for (const offset of [-0.25, 0, 0.25]) {
box(alongX ? w * 0.96 : 0.014, 0.006, alongX ? 0.014 : d * 0.96, grain, group,
alongX ? 0 : offset * w, h / 2 + 0.003, alongX ? offset * d : 0);
}
for (const side of [-1, 1]) {
box(alongX ? 0.014 : w * 0.83, h * 0.65, alongX ? d * 0.83 : 0.014, grain, group,
alongX ? side * (w / 2 + 0.002) : 0, 0, alongX ? 0 : side * (d / 2 + 0.002));
}
} else if (prop.kind === 'domino') {
const [w, h, d] = prop.size;
const material = new THREE.MeshStandardMaterial({ color: [0xdbd8b9, 0x416d61, 0xcb7947, 0x354b47][prop.variant], roughness: 0.7 });
const dots = new THREE.MeshStandardMaterial({ color: prop.variant === 0 ? 0x30423b : 0xf2e4b8, roughness: 0.75 });
box(w, h, d, material, group);
box(w, 0.06, d, tireMaterial, group, 0, -h / 2 + 0.03);
for (const side of [-1, 1]) {
box(w * 0.85, 0.035, 0.01, dots, group, 0, 0, side * (d / 2 + 0.008));
const pips = [[-0.57, 1.15], [0.57, 0.55], [-0.57, -0.55], [0.57, -1.15]];
if (prop.variant % 2) pips.push([0, 0.85], [0, -0.85]);
for (const [x, y] of pips) { const pip = cylinder(0.13, 0.012, dots, group, x, y, side * (d / 2 + 0.018), 10); pip.rotation.x = Math.PI / 2; }
}
} else if (prop.kind === 'brick') {
const mat = new THREE.MeshStandardMaterial({ color: [0xb3b7a5, 0xc1c3af, 0xa4ad9b, 0xb8bba8][prop.variant], roughness: 0.98 });
box(...prop.size, mat, group);
// Dark inset tops hint at hollow concrete masonry units.
for (const x of [-0.34, 0.34]) box(0.43, 0.012, 0.52, darkMetal, group, x, prop.size[1] / 2 + 0.006);
} else {
const bodyMat = new THREE.MeshStandardMaterial({ color: [0xb07043, 0xb88d51, 0x697e72, 0xae7148][prop.variant], roughness: 0.66, metalness: 0.25 });
cylinder(0.56, 1.48, bodyMat, group);
for (const y of [-0.69, -0.34, 0.34, 0.69]) cylinder(0.575, 0.055, darkMetal, group, 0, y);
cylinder(0.12, 0.024, darkMetal, group, 0.23, 0.754, 0);
}
return group;
}
function createCar() {
scene.add(car);
box(1.79, 0.46, 3.75, paint, car, 0, 0.04);
box(1.75, 0.19, 1.16, paint, car, 0, 0.33, -1.2);
box(1.7, 0.15, 0.65, paint, car, 0, 0.31, 1.5);
box(1.47, 0.54, 1.77, glass, car, 0, 0.58, 0.22);
box(1.52, 0.09, 1.78, paint, car, 0, 0.88, 0.22);
for (const x of [-0.75, 0.75]) {
for (const z of [-0.63, 0.2, 1.06]) box(0.055, 0.57, 0.065, paint, car, x, 0.6, z);
box(0.1, 0.12, 1.78, paint, car, x, 0.36, 0.22);
box(0.25, 0.08, 0.045, darkMetal, car, x * 1.2, 0.16, 0.25);
box(0.21, 0.17, 0.29, paint, car, x * 1.28, 0.43, -0.5);
box(0.07, 0.08, 3.25, darkMetal, car, x * 1.2, -0.05);
}
for (const z of [-1.89, 1.89]) box(1.84, 0.2, 0.13, darkMetal, car, 0, -0.11, z);
box(0.78, 0.15, 0.03, darkMetal, car, 0, 0.14, -1.9);
for (const x of [-0.62, 0.62]) {
box(0.38, 0.18, 0.04, lamp, car, x, 0.15, -1.91);
box(0.34, 0.16, 0.05, tailLight, car, x, 0.13, 1.92);
}
box(0.4, 0.13, 0.03, trim, car, 0, -0.02, 1.965);
// Contrasting stripes make the heading readable even in a small frame.
for (const x of [-0.22, 0.22]) { box(0.18, 0.013, 1.1, darkMetal, car, x, 0.432, -1.2); box(0.18, 0.014, 1.76, darkMetal, car, x, 0.932, 0.22); }
for (let i = 0; i < 4; i++) {
const group = new THREE.Group(); scene.add(group); wheels.push(group);
const tire = cylinder(0.4, 0.3, tireMaterial, group, 0, 0, 0, 20); tire.rotation.z = Math.PI / 2;
for (const side of [-1, 1]) {
const hub = cylinder(0.245, 0.025, trim, group, side * 0.155, 0); hub.rotation.z = Math.PI / 2;
const cap = cylinder(0.12, 0.03, darkMetal, group, side * 0.175, 0); cap.rotation.z = Math.PI / 2;
for (let a = 0; a < 5; a++) {
const spoke = box(0.012, 0.065, 0.39, darkMetal, group, side * 0.175); spoke.rotation.x = a * Math.PI / 5;
}
}
}
}
function showStatus(message: string, duration = 2800) {
clearTimeout(statusTimer); status.textContent = message;
if (duration) statusTimer = window.setTimeout(() => { status.textContent = ''; }, duration);
}
function setSceneTheme(bg: string) {
const color = new THREE.Color(bg);
const light = color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722 > 0.4;
document.documentElement.style.colorScheme = light ? 'light' : 'dark';
// Keep the yard legible in both themes; its atmosphere inherits the host hue.
const sky = new THREE.Color(light ? 0xc9d2bd : 0x9da98f).lerp(color, light ? 0.12 : 0.08);
scene.background = sky;
scene.fog = new THREE.Fog(sky, 55, 175);
asphalt.color.set(light ? 0x9aa58d : 0x86957c);
}
function applyTheme(theme: Theme) {
const root = document.documentElement.style;
root.setProperty('--bg', theme.colors.background); root.setProperty('--fg', theme.colors.text); root.setProperty('--primary', theme.colors.primary);
setSceneTheme(theme.colors.background);
}
function syncMeshes() {
car.position.copy(simulation.chassis.position); car.quaternion.copy(simulation.chassis.quaternion);
for (let i = 0; i < propMeshes.length; i++) { propMeshes[i].position.copy(simulation.props[i].body.position); propMeshes[i].quaternion.copy(simulation.props[i].body.quaternion); }
for (let i = 0; i < 4; i++) {
simulation.vehicle.updateWheelTransform(i);
const transform = simulation.vehicle.wheelInfos[i].worldTransform;
wheels[i].position.copy(transform.position); wheels[i].quaternion.copy(transform.quaternion);
}
}
function updateCamera(dt: number, immediate = false) {
carPosition.copy(simulation.chassis.position); carQuaternion.copy(simulation.chassis.quaternion);
forward.set(0, 0, -1).applyQuaternion(carQuaternion); forward.y = 0;
if (forward.lengthSq() < 0.01) forward.set(0, 0, -1); else forward.normalize();
const compact = canvas.clientWidth < 480;
if (cameraMode === 0) {
cameraTarget.copy(carPosition).addScaledVector(forward, compact ? -12 : -10);
cameraTarget.y += compact ? 8.5 : 6.3;
lookTarget.copy(carPosition).addScaledVector(forward, compact ? 5 : 6); lookTarget.y += 0.5;
} else if (cameraMode === 1) {
cameraTarget.set(carPosition.x + Math.sin(orbitAngle) * 15, carPosition.y + 10, carPosition.z + Math.cos(orbitAngle) * 15);
lookTarget.copy(carPosition); lookTarget.y += 0.7;
} else {
cameraTarget.set(carPosition.x, carPosition.y + (compact ? 42 : 33), carPosition.z + 13);
lookTarget.copy(carPosition); lookTarget.z -= 2;
}
camera.position.lerp(cameraTarget, immediate ? 1 : 1 - Math.exp(-dt * 5));
camera.up.copy(worldUp); camera.lookAt(lookTarget);
sun.position.copy(carPosition).add(new THREE.Vector3(-22, 38, 16)); sun.target.position.copy(carPosition); sun.target.updateMatrixWorld();
}
function releaseInput() {
down.clear(); touches.clear();
document.querySelectorAll('.pressed').forEach(el => el.classList.remove('pressed'));
}
function input() {
const pressed = (names: string[], touch: string) => names.some(name => down.has(name)) || [...touches.values()].includes(touch);
return { throttle: Number(pressed(['KeyW', 'ArrowUp'], 'forward')) - Number(pressed(['KeyS', 'ArrowDown'], 'reverse')), steer: Number(pressed(['KeyA', 'ArrowLeft'], 'left')) - Number(pressed(['KeyD', 'ArrowRight'], 'right')), brake: pressed(['Space'], 'brake') };
}
function togglePause() {
paused = !paused; releaseInput();
$('pause').setAttribute('aria-pressed', String(paused)); $('pause').setAttribute('aria-label', paused ? 'Resume simulation' : 'Pause simulation');
$('pause').title = paused ? 'Resume simulation' : 'Pause simulation';
$('pause').querySelector('span')!.textContent = paused ? '▶' : 'Ⅱ';
$('paused-notice').hidden = !paused;
if (!paused) canvas.focus({ preventScroll: true });
}
async function changeCamera() {
cameraChanged = true; cameraMode = (cameraMode + 1) % 3;
$('camera-label').textContent = cameraNames[cameraMode]; updateCamera(0, true);
if (runtimeHasDomain(STORAGE_DOMAIN)) {
try { await withTimeout(storage.setItem('driving-camera', String(cameraMode))); }
catch { showStatus('Camera changed. Preference could not be saved.'); }
}
}
async function withTimeout<T>(operation: Promise<T>): Promise<T> {
let timer = 0;
try { return await Promise.race([operation, new Promise<T>((_, reject) => { timer = window.setTimeout(() => reject(new Error('Runtime timeout')), 4000); })]); }
finally { clearTimeout(timer); }
}
function resize() {
if (!renderer) return;
const w = Math.max(1, canvas.clientWidth), h = Math.max(1, canvas.clientHeight);
renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix();
updateCamera(0, true);
}
const resizeObserver = new ResizeObserver(resize);
function bindControls() {
$('pause').addEventListener('click', togglePause, { signal }); $('resume').addEventListener('click', togglePause, { signal });
$('slow').addEventListener('click', () => {
slow = !slow; $('slow').setAttribute('aria-pressed', String(slow)); $('slow').querySelector('.button-label')!.textContent = slow ? '½× speed' : '1× speed';
showStatus(slow ? 'Slow motion · ½ speed' : 'Normal speed');
}, { signal });
$('camera').addEventListener('click', () => { void changeCamera(); }, { signal });
$('recover').addEventListener('click', () => { releaseInput(); simulation.recover(); updateCamera(0, true); showStatus('Car recovered'); }, { signal });
$('rebuild').addEventListener('click', () => { releaseInput(); simulation.rebuild(); updateCamera(0, true); showStatus('Yard rebuilt. Ready for another run.'); }, { signal });
const driveCodes = new Set(['KeyW', 'KeyS', 'KeyA', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']);
// Focused game input belongs to this canvas, never to the host's global shortcuts.
canvas.addEventListener('keydown', event => {
if (driveCodes.has(event.code)) { event.preventDefault(); if (!paused) down.add(event.code); }
if (event.repeat) return;
if (event.code === 'KeyR') { simulation.recover(); updateCamera(0, true); showStatus('Car recovered'); }
if (event.code === 'KeyC') void changeCamera();
if (event.code === 'KeyP' || event.code === 'Escape') togglePause();
}, { signal });
canvas.addEventListener('keyup', event => { if (driveCodes.has(event.code)) { event.preventDefault(); down.delete(event.code); } }, { signal });
canvas.addEventListener('blur', releaseInput, { signal });
window.addEventListener('blur', releaseInput, { signal });
document.addEventListener('visibilitychange', () => { if (document.hidden) releaseInput(); }, { signal });
let dragging = false, lastX = 0;
canvas.addEventListener('pointerdown', event => { canvas.focus({ preventScroll: true }); dragging = true; lastX = event.clientX; canvas.setPointerCapture(event.pointerId); }, { signal });
canvas.addEventListener('pointermove', event => { if (dragging && cameraMode === 1) { orbitAngle -= (event.clientX - lastX) * 0.007; lastX = event.clientX; } }, { signal });
canvas.addEventListener('pointerup', () => { dragging = false; }, { signal });
canvas.addEventListener('lostpointercapture', () => { dragging = false; }, { signal });
document.querySelectorAll<HTMLButtonElement>('[data-drive]').forEach(button => {
button.addEventListener('pointerdown', event => {
event.preventDefault(); if (paused) return;
button.setPointerCapture(event.pointerId); touches.set(event.pointerId, button.dataset.drive!); button.classList.add('pressed');
}, { signal });
const release = (event: PointerEvent) => { touches.delete(event.pointerId); if (![...touches.values()].includes(button.dataset.drive!)) button.classList.remove('pressed'); };
button.addEventListener('pointerup', release, { signal }); button.addEventListener('pointercancel', release, { signal }); button.addEventListener('lostpointercapture', release, { signal });
button.addEventListener('keydown', event => { if (['Space', 'Enter'].includes(event.code)) { event.preventDefault(); if (!paused) { touches.set(-[...document.querySelectorAll('[data-drive]')].indexOf(button) - 1, button.dataset.drive!); button.classList.add('pressed'); } } }, { signal });
button.addEventListener('keyup', event => { if (['Space', 'Enter'].includes(event.code)) { touches.delete(-[...document.querySelectorAll('[data-drive]')].indexOf(button) - 1); button.classList.remove('pressed'); } }, { signal });
button.addEventListener('blur', () => { touches.delete(-[...document.querySelectorAll('[data-drive]')].indexOf(button) - 1); button.classList.remove('pressed'); }, { signal });
});
}
function boot() {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.16;
sun.castShadow = true; sun.shadow.mapSize.set(1024, 1024);
Object.assign(sun.shadow.camera, { left: -30, right: 30, top: 30, bottom: -30, near: 1, far: 100 });
sun.shadow.bias = -0.00015; sun.shadow.normalBias = 0.035; sun.shadow.radius = 3;
scene.add(sun.target);
setSceneTheme('#182420');
simulation = createSimulation(); createYard(); createCar();
for (const prop of simulation.props) { const mesh = batchProp(propVisual(prop)); propMeshes.push(mesh); scene.add(mesh); }
$('structure-list').innerHTML = GROUPS.map((group, i) => `<div class="structure"><span class="structure-number">0${i + 1}</span><span class="structure-name">${group.name}</span><span class="structure-indicator"></span></div>`).join('');
$('structure-count').textContent = String(GROUPS.length);
$('prop-total').textContent = `/ ${simulation.props.length}`;
bindControls(); resizeObserver.observe(canvas); syncMeshes(); resize();
showStatus('Click the yard, then use WASD to drive', 6500);
if (runtimeHasDomain(THEME_DOMAIN)) {
try { void withTimeout(themeGet()).then(applyTheme).catch(() => undefined); themeSubscription = themeOnChanged(applyTheme); } catch { /* The fallback theme remains usable. */ }
}
if (runtimeHasDomain(STORAGE_DOMAIN)) {
void withTimeout(storage.getItem('driving-camera')).then(value => {
if (!cameraChanged && value !== null && ['0', '1', '2'].includes(value)) { cameraMode = Number(value); $('camera-label').textContent = cameraNames[cameraMode]; updateCamera(0, true); }
}).catch(() => undefined);
}
let previous = performance.now(), lastTelemetry = 0;
function frame(now: number) {
if (stopped) return;
const dt = Math.min((now - previous) / 1000, 0.05); previous = now;
const drive = input();
if (!paused && !document.hidden) simulation.step(dt * (slow ? 0.5 : 1), drive);
syncMeshes(); updateCamera(dt);
tailLight.emissiveIntensity = drive.brake ? 2 : 0.2;
if (now - lastTelemetry > 100) {
const stats = simulation.stats();
$('speed').textContent = String(Math.round(stats.speed)); $('speed-fill').style.width = `${Math.min(stats.speed, 100)}%`;
$('gear').textContent = drive.brake ? 'BRAKE' : stats.speed < 0.8 ? 'N' : drive.throttle < 0 ? 'R' : 'D';
$('displaced').textContent = String(stats.displaced); $('impact').textContent = String(Math.round(stats.peakImpact));
document.querySelectorAll('.structure').forEach((el, i) => { el.classList.toggle('hit', stats.groups[i] > 0); });
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(car.quaternion);
if (up.y < 0.25 && stats.speed < 3 && !status.textContent) showStatus('Tipped over? Press R to recover.', 3500);
lastTelemetry = now;
}
renderer.render(scene, camera); frameId = requestAnimationFrame(frame);
}
frameId = requestAnimationFrame(frame);
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); paused = true; releaseInput(); showStatus('Graphics interrupted. Reload to restart.', 0); $('retry').hidden = false; }, { signal });
}
function dispose() {
stopped = true; cancelAnimationFrame(frameId); clearTimeout(statusTimer); resizeObserver.disconnect(); themeSubscription?.close(); aborter.abort();
const geometries = new Set<THREE.BufferGeometry>(), materials = new Set<THREE.Material>(), textures = new Set<THREE.Texture>();
scene.traverse(object => { if (object instanceof THREE.Mesh) { geometries.add(object.geometry); (Array.isArray(object.material) ? object.material : [object.material]).forEach(mat => materials.add(mat)); } });
materials.forEach(material => { for (const value of Object.values(material)) if (value instanceof THREE.Texture) textures.add(value); material.dispose(); });
geometries.forEach(geometry => geometry.dispose()); textures.forEach(texture => texture.dispose()); renderer?.dispose();
}
$('retry').addEventListener('click', () => window.location.reload(), { signal });
window.addEventListener('pagehide', dispose, { once: true });
try { boot(); }
catch (error) { showStatus(`Unable to start 3D graphics: ${error instanceof Error ? error.message : 'unknown error'}`, 0); $('retry').hidden = false; document.querySelectorAll<HTMLButtonElement>('button:not(#retry)').forEach(button => { button.disabled = true; }); }