Back to Impact Yard remix
SOURCE / PINNED RELEASE

Made of little things.

Impact Yard remix

Release
5b31a37bbd07
Author-recorded commit
7762f7efb018…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-c1d8c061b02

Archive hash verified: c640627baa321925. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
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 { ARENA, 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;
let selectedSite = -1;
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, 800);
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 propBatches: { props: Prop[]; meshes: THREE.InstancedMesh[] }[] = [];
const siteHeights: number[] = [];
const instanceTransform = new THREE.Object3D();
const wheels: THREE.Group[] = [];
const car = new THREE.Group();
const exhausts: THREE.Group[] = [];
const rocketGlow = new THREE.PointLight(0x6be5ff, 0, 12, 2);
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let effectTime = 0;
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>();
const dustCount = 384;
const dustParticles = Array.from({ length: dustCount }, () => ({ life: 0, duration: 1, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, size: 0 }));
const dustPositions = new Float32Array(dustCount * 3);
const dustSizes = new Float32Array(dustCount);
const dustAlphas = new Float32Array(dustCount);
const dustGeometry = new THREE.BufferGeometry();
dustGeometry.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3).setUsage(THREE.DynamicDrawUsage));
dustGeometry.setAttribute('size', new THREE.BufferAttribute(dustSizes, 1).setUsage(THREE.DynamicDrawUsage));
dustGeometry.setAttribute('alpha', new THREE.BufferAttribute(dustAlphas, 1).setUsage(THREE.DynamicDrawUsage));
const dustMaterial = new THREE.ShaderMaterial({
  uniforms: { pointScale: { value: 600 }, dustColor: { value: new THREE.Color(0xc6b798) } },
  vertexShader: `attribute float size; attribute float alpha; varying float opacity; uniform float pointScale;
    void main() { vec4 p = modelViewMatrix * vec4(position, 1.0); opacity = alpha;
    gl_PointSize = clamp(size * pointScale / max(1.0, -p.z), 1.0, 180.0); gl_Position = projectionMatrix * p; }`,
  fragmentShader: `varying float opacity; uniform vec3 dustColor;
    void main() { vec2 p = gl_PointCoord * 2.0 - 1.0;
    float a = (1.0 - smoothstep(0.1, 1.0, length(p))) * opacity;
    if (a < 0.005) discard; gl_FragColor = vec4(dustColor, a); }`,
  transparent: true, depthWrite: false,
});
const dustCloud = new THREE.Points(dustGeometry, dustMaterial);
dustCloud.frustumCulled = false;
let dustCursor = 0;

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(1000, 1000), asphalt);
  floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor);
  // Expansion joints, lightly worn lane paint and individual parking bays.
  const edge = ARENA.halfSize;
  const grid = new THREE.GridHelper(edge * 2, edge * 2 / 3, 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 [-edge + 5, edge - 5]) groundLine(x, 0, 0.12, edge * 2 - 10);
  for (const z of [-edge + 5, edge - 5]) groundLine(0, z, edge * 2 - 10, 0.12);
  for (let z = -80; z < 83; z += 5) { groundLine(36, z, 0.14, 2.2); groundLine(-39, z, 0.14, 2.2); }
  for (let x = -80; x < 83; x += 5) groundLine(x, 39, 2.2, 0.14);
  for (let n = -edge + 12; n < edge - 10; n += 6) {
    for (const lane of [-82, 82]) { groundLine(lane, n, 0.18, 3); groundLine(n, lane, 3, 0.18); }
  }
  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(`${String(i + 1).padStart(2, '0')} / ${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 = -edge + 3; n < edge; n += 9) {
    box(0.22, 2.3, 0.22, darkMetal, scene, n, ARENA.wallHeight + 0.85, -edge);
    box(0.22, 2.3, 0.22, darkMetal, scene, -edge, ARENA.wallHeight + 0.85, n);
    box(2.8, 0.3, 0.06, lineMaterial, scene, n, 2.6, -edge + 0.82);
  }
  // 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 [[-40, -edge - 15, 62, 17, 22], [34, -edge - 18, 58, 13, 26], [-edge - 18, 5, 22, 15, 100]]) {
    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 = edge + 8 + (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 [[-edge + 6, -edge + 8], [edge - 6, -edge + 8], [-edge + 6, edge - 14], [edge - 6, edge - 14]]) {
    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) * (edge + 170), 5, Math.sin(a) * (edge + 170)); 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 === 'column' || prop.kind === 'deck') {
    const [w, h, d] = prop.size;
    const structural = new THREE.MeshStandardMaterial({ color: prop.kind === 'column' ? 0xb96c37 : [0x577e85, 0x688f91, 0x497079, 0x809c98][prop.variant], roughness: 0.72, metalness: 0.3 });
    box(w, h, d, structural, group);
    if (prop.kind === 'column') {
      for (const y of [-h / 2 + 0.08, h / 2 - 0.08]) box(w + 0.06, 0.16, d + 0.06, darkMetal, group, 0, y);
      for (const side of [-1, 1]) {
        box(w * 0.6, 0.22, 0.035, lineMaterial, group, 0, -h * 0.2, side * (d / 2 + 0.025));
        box(w * 0.6, 0.22, 0.035, lineMaterial, group, 0, -h * 0.2 + 0.42, side * (d / 2 + 0.025));
      }
    } else {
      for (const side of [-1, 1]) {
        box(w, 0.12, 0.12, trim, group, 0, h / 2, side * (d / 2 - 0.06));
        box(0.12, 0.12, d, trim, group, side * (w / 2 - 0.06), h / 2);
        box(w, Math.min(h * 0.45, 0.28), 0.07, darkMetal, group, 0, 0, side * (d / 2 + 0.015));
      }
      for (let x = -w / 2 + 1; x < w / 2; x += 2) box(0.045, 0.014, d * 0.95, darkMetal, group, x, h / 2 + 0.014);
    }
  } else 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.2, 0.34], [0.2, 0.16], [-0.2, -0.16], [0.2, -0.34]];
      if (prop.variant % 2) pips.push([0, 0.25], [0, -0.25]);
      for (const [x, y] of pips) { const pip = cylinder(w * 0.047, 0.012, dots, group, x * w, y * h, 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 side of [-1, 1]) box(prop.size[0] * 0.31, 0.012, prop.size[2] * 0.58, darkMetal, group, side * prop.size[0] * 0.24, 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); }
  // Twin roof/rear rocket motors: mounts, tanks, collars and open flared bells.
  const nozzleInterior = new THREE.MeshStandardMaterial({ color: 0x10252c, roughness: 0.6, metalness: 0.8 });
  for (const x of [-0.53, 0.53]) {
    for (const z of [0.55, 1.7]) box(0.24, 0.55, 0.17, darkMetal, car, x, 0.86, z);
    const motor = cylinder(0.31, 1.75, trim, car, x, 1.16, 1.15, 20); motor.rotation.x = Math.PI / 2;
    for (const z of [0.5, 1.55, 1.98]) { const band = cylinder(0.34, 0.13, darkMetal, car, x, 1.16, z, 20); band.rotation.x = Math.PI / 2; }
    const nose = new THREE.Mesh(new THREE.ConeGeometry(0.31, 0.38, 20), paint); nose.rotation.x = -Math.PI / 2;
    nose.position.set(x, 1.16, 0.1); car.add(nose);
    const bell = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.24, 0.42, 20, 1, true), trim);
    bell.rotation.x = Math.PI / 2; bell.position.set(x, 1.16, 2.22); car.add(bell);
    const throat = cylinder(0.32, 0.02, nozzleInterior, car, x, 1.16, 2.32, 20); throat.rotation.x = Math.PI / 2;
  }
  batchProp(car);
  const flameMaterials = [
    new THREE.MeshBasicMaterial({ color: 0xff862c, transparent: true, opacity: 0.65, blending: THREE.AdditiveBlending, depthWrite: false }),
    new THREE.MeshBasicMaterial({ color: 0x90edff, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }),
  ];
  for (const x of [-0.53, 0.53]) {
    const exhaust = new THREE.Group(); exhaust.position.set(x, 1.16, 2.43); exhaust.visible = false;
    flameMaterials.forEach((material, i) => {
      const flame = new THREE.Mesh(new THREE.ConeGeometry(i ? 0.23 : 0.39, 1, 12), material);
      flame.rotation.x = Math.PI / 2; exhaust.add(flame);
    });
    exhausts.push(exhaust); car.add(exhaust);
  }
  rocketGlow.position.set(0, 1.3, 3); car.add(rocketGlow);
  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;
      }
    }
    batchProp(group);
  }
}
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, 230, 600);
  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 createPropInstances() {
  for (let i = 0; i < GROUPS.length; i++) siteHeights[i] = Math.max(...simulation.props.filter(prop => prop.group === i).map(prop => prop.start.y + prop.size[1] / 2));
  const prototypes = new Map<string, Prop[]>();
  for (const prop of simulation.props) {
    const key = `${prop.kind}/${prop.size.join('/')}/${prop.variant}`;
    const list = prototypes.get(key) ?? []; list.push(prop); prototypes.set(key, list);
  }
  for (const props of prototypes.values()) {
    const prototype = batchProp(propVisual(props[0]));
    const meshes = prototype.children.map(child => {
      const source = child as THREE.Mesh;
      const mesh = new THREE.InstancedMesh(source.geometry, source.material, props.length);
      mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      mesh.castShadow = source.castShadow; mesh.receiveShadow = source.receiveShadow;
      scene.add(mesh); return mesh;
    });
    propBatches.push({ props, meshes });
  }
}
function syncMeshes() {
  car.position.copy(simulation.chassis.position); car.quaternion.copy(simulation.chassis.quaternion);
  for (const batch of propBatches) {
    batch.props.forEach((prop, i) => {
      instanceTransform.position.copy(prop.body.position); instanceTransform.quaternion.copy(prop.body.quaternion); instanceTransform.updateMatrix();
      batch.meshes.forEach(mesh => mesh.setMatrixAt(i, instanceTransform.matrix));
    });
    batch.meshes.forEach(mesh => {
      mesh.instanceMatrix.needsUpdate = true;
      // Cull distant complexes, while keeping bounds correct for flying debris.
      mesh.computeBoundingSphere();
    });
  }
  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 clearDust() {
  dustParticles.forEach(p => { p.life = 0; }); dustAlphas.fill(0);
  dustGeometry.getAttribute('alpha').needsUpdate = true;
}
function updateDust(dt: number) {
  for (const impact of simulation.drainImpacts()) {
    const count = reducedMotion.matches ? 1 : Math.ceil(impact.strength * 5);
    for (let i = 0; i < count; i++) {
      const p = dustParticles[dustCursor++ % dustCount], angle = Math.random() * Math.PI * 2;
      p.life = p.duration = 1.6 + Math.random() * 1.5;
      p.x = impact.x; p.y = Math.max(0.15, impact.y); p.z = impact.z;
      p.vx = Math.cos(angle) * (1 + impact.strength * 2); p.vz = Math.sin(angle) * (1 + impact.strength * 2);
      p.vy = 1 + Math.random() * 2; p.size = 1.8 + impact.strength * 1.6;
    }
  }
  dustParticles.forEach((p, i) => {
    p.life = Math.max(0, p.life - dt);
    if (p.life > 0) {
      p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
      p.vx *= Math.exp(-dt * 1.2); p.vz *= Math.exp(-dt * 1.2);
      const age = 1 - p.life / p.duration;
      dustPositions.set([p.x, p.y, p.z], i * 3);
      dustSizes[i] = p.size * (0.4 + age * 1.8);
      dustAlphas[i] = Math.min(1, age * 10) * (1 - age) * (reducedMotion.matches ? 0.12 : 0.28);
    } else dustAlphas[i] = 0;
  });
  for (const name of ['position', 'size', 'alpha']) dustGeometry.getAttribute(name).needsUpdate = true;
}
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;
  const boosting = simulation.rocketState().active && !reducedMotion.matches;
  const targetFov = boosting ? 62 : 48;
  camera.fov += (targetFov - camera.fov) * (immediate ? 1 : 1 - Math.exp(-dt * 7));
  camera.updateProjectionMatrix();
  if (cameraMode === 0) {
    cameraTarget.copy(carPosition).addScaledVector(forward, (compact ? -12 : -10) - (boosting ? 3 : 0));
    cameraTarget.y += compact ? 8.5 : 6.3;
    lookTarget.copy(carPosition).addScaledVector(forward, compact ? 5 : 6); lookTarget.y += 0.5;
  } else if (cameraMode === 1) {
    if (selectedSite >= 12) {
      const site = GROUPS[selectedSite], height = siteHeights[selectedSite];
      const aspect = Math.max(0.2, (canvas.clientWidth - (canvas.clientWidth >= 900 ? 244 : 0)) / Math.max(1, canvas.clientHeight));
      const verticalFov = THREE.MathUtils.degToRad(48);
      const field = Math.min(verticalFov, 2 * Math.atan(Math.tan(verticalFov / 2) * aspect));
      const distance = Math.hypot(site.width, height, site.depth) / 2 / Math.sin(field / 2) * 1.15;
      const radius = distance * Math.cos(0.4);
      cameraTarget.set(site.x + Math.sin(orbitAngle) * radius, height / 2 + distance * Math.sin(0.4), site.z + Math.cos(orbitAngle) * radius);
      lookTarget.set(site.x, height / 2, site.z);
    } else {
      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 ? 76 : 100), carPosition.z + 24);
    lookTarget.copy(carPosition); lookTarget.z -= 2;
  }
  camera.position.lerp(cameraTarget, immediate ? 1 : 1 - Math.exp(-dt * (boosting ? 10 : 5)));
  camera.up.copy(worldUp); camera.lookAt(lookTarget);
  sun.position.copy(carPosition).add(new THREE.Vector3(-35, 75, 28)); sun.target.position.copy(carPosition); sun.target.updateMatrixWorld();
}
function releaseInput() {
  down.clear(); touches.clear();
  simulation?.cancelBoost();
  document.querySelectorAll('.pressed').forEach(el => el.classList.remove('pressed'));
}
function fireBoost() {
  if (paused || document.hidden || input().brake) return;
  canvas.focus({ preventScroll: true });
  if (simulation.boost()) showStatus('ROCKET BOOST', 1000);
  else showStatus('Rocket recharging', 1000);
}
function visitSite(index: number) {
  releaseInput(); selectedSite = index; simulation.visit(index); clearDust();
  document.querySelectorAll<HTMLButtonElement>('[data-site]').forEach(button => button.setAttribute('aria-pressed', String(Number(button.dataset.site) === index)));
  updateCamera(0, true); canvas.focus({ preventScroll: true });
  showStatus(`${GROUPS[index].name} · F to hit the support · C for orbit view`, 5500);
}
function nextSite() { visitSite(selectedSite < 0 ? 12 : (selectedSite + 1) % GROUPS.length); }
function updateRocket(dt: number) {
  const rocket = simulation.rocketState();
  if (!paused && !document.hidden) effectTime += dt * (slow ? 0.5 : 1);
  exhausts.forEach((exhaust, i) => {
    exhaust.visible = rocket.active;
    const length = reducedMotion.matches ? 5 : 5 + Math.sin(effectTime * 61 + i) * 0.9 + Math.sin(effectTime * 97) * 0.5;
    exhaust.children.forEach((flame, j) => { const size = length * (j ? 0.55 : 1); flame.scale.y = size; flame.position.z = size / 2; });
  });
  rocketGlow.intensity = rocket.active ? 7 : 0;
  const label = rocket.active ? 'FIRING' : rocket.cooldown > 0 ? `${rocket.cooldown.toFixed(1)}s` : 'READY';
  $('boost-label').textContent = label;
  $('boost-fill').style.width = `${rocket.charge * 100}%`;
  $('boost').dataset.state = rocket.active ? 'firing' : rocket.cooldown > 0 ? 'charging' : 'ready';
  $('boost').setAttribute('aria-label', rocket.active ? 'Rocket firing' : rocket.cooldown > 0 ? 'Rocket recharging' : 'Fire rocket boost');
  $('boost').setAttribute('aria-disabled', String(paused || rocket.cooldown > 0));
}
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();
  dustMaterial.uniforms.pointScale.value = h * renderer.getPixelRatio();
  updateCamera(0, true);
}
const resizeObserver = new ResizeObserver(resize);
function bindControls() {
  $('site').addEventListener('click', nextSite, { signal });
  document.querySelectorAll<HTMLButtonElement>('[data-site]').forEach(button => button.addEventListener('click', () => visitSite(Number(button.dataset.site)), { signal }));
  $('boost').addEventListener('pointerdown', event => event.preventDefault(), { signal });
  $('boost').addEventListener('click', fireBoost, { signal });
  $('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(); selectedSite = -1; clearDust();
    document.querySelectorAll('[data-site]').forEach(button => button.setAttribute('aria-pressed', 'false'));
    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 (event.code === 'KeyF') { event.preventDefault(); if (!event.repeat) fireBoost(); }
    if (driveCodes.has(event.code)) { event.preventDefault(); if (!paused) down.add(event.code); }
    if (event.repeat) return;
    if (event.code === 'KeyN') { event.preventDefault(); nextSite(); }
    if (event.code === 'KeyR') { releaseInput(); 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: -60, right: 60, top: 60, bottom: -60, near: 1, far: 180 });
  sun.shadow.bias = -0.00015; sun.shadow.normalBias = 0.035; sun.shadow.radius = 3;
  scene.add(sun.target);
  setSceneTheme('#182420');
  simulation = createSimulation(); createYard(); createCar();
  createPropInstances(); scene.add(dustCloud);
  $('structure-list').innerHTML = GROUPS.map((group, i) => `<button class="structure" data-site="${i}" aria-label="Visit ${group.name}" aria-pressed="false"><span class="structure-number">${String(i + 1).padStart(2, '0')}</span><span class="structure-name">${group.name}</span><span class="structure-indicator"></span></button>`).join('');
  $('structure-count').textContent = String(GROUPS.length);
  $('prop-total').textContent = `/ ${simulation.props.length}`;
  bindControls(); resizeObserver.observe(canvas); syncMeshes(); resize();
  showStatus('WASD to drive · F to boost · N to visit the giant structures', 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();
    const simulationDt = !paused && !document.hidden ? dt * (slow ? 0.5 : 1) : 0;
    if (simulationDt) simulation.step(simulationDt, drive);
    updateDust(simulationDt);
    syncMeshes(); updateRocket(dt); 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 / 280, 1) * 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 || object instanceof THREE.Points) { 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; }); }