Phase 0: drivable car with persistent wear

Vite + TypeScript + three.js + Rapier raycast vehicle, fixed 60 Hz step.

Flat plate, seeded obstacle scatter, chase camera, debug HUD. Car condition
(engine/tires/chassis) degrades permanently and is derived into handling
numbers, so decline is felt through the wheel rather than read off a meter.

src/sim/ is kept free of three.js and Rapier imports — the later heat, region
and front-line systems all live there, and staying engine-free is what makes
them unit-testable without a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 07:39:05 +02:00
co-authored by Claude Opus 5
commit a66032674d
20 changed files with 2695 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
import * as THREE from 'three';
import type { WorldModel } from '../sim/world';
import { CAR, WHEELS } from '../carSpec';
export interface SceneView {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
car: THREE.Group;
wheels: THREE.Object3D[];
obstacles: THREE.Mesh[];
/** Keeps the shadow frustum centred on the car. */
followSun(): void;
dispose(): void;
}
const SKY = 0x11161c;
export function createScene(model: WorldModel): SceneView {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(SKY);
scene.fog = new THREE.Fog(SKY, 90, 320);
const camera = new THREE.PerspectiveCamera(62, innerWidth / innerHeight, 0.2, 900);
camera.position.set(0, 6, -12);
scene.add(new THREE.HemisphereLight(0x9fb4c7, 0x2a2823, 1.1));
const sun = new THREE.DirectionalLight(0xfff0dc, 2.1);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
const cam = sun.shadow.camera;
cam.left = -40;
cam.right = 40;
cam.top = 40;
cam.bottom = -40;
cam.near = 1;
cam.far = 140;
scene.add(sun);
scene.add(sun.target);
// --- Ground ---
const groundSize = (model.extent + 60) * 2;
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(groundSize, groundSize),
new THREE.MeshStandardMaterial({ color: 0x2f3630, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// A grid gives the flat plate enough texture to read speed off.
const grid = new THREE.GridHelper(groundSize, groundSize / 10, 0x4a5750, 0x3a423c);
grid.position.y = 0.02;
scene.add(grid);
// --- Obstacles ---
const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 });
const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 });
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const obstacles = model.obstacles.map((o) => {
const mesh = new THREE.Mesh(boxGeo, o.kind === 'crate' ? crateMat : blockMat);
mesh.scale.set(o.width, o.height, o.depth);
mesh.position.set(o.x, o.height / 2, o.z);
mesh.rotation.y = o.yaw;
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
});
// --- Car ---
const car = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }),
);
body.castShadow = true;
car.add(body);
// A cabin block, purely so the car's facing is readable at a glance.
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 0.55, 1.8),
new THREE.MeshStandardMaterial({ color: 0x25303a, roughness: 0.4 }),
);
cabin.position.set(0, CAR.halfHeight + 0.25, -0.15);
cabin.castShadow = true;
car.add(cabin);
const wheelGeo = new THREE.CylinderGeometry(
CAR.wheel.radius,
CAR.wheel.radius,
CAR.wheel.width,
16,
);
// Cylinders are Y-up; rotate so the axle runs along X.
wheelGeo.rotateZ(Math.PI / 2);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x1c1f22, roughness: 0.95 });
const wheels = WHEELS.map((w) => {
// Pivot carries steering yaw; the mesh inside carries roll.
const pivot = new THREE.Group();
pivot.position.set(w.x, CAR.wheel.offsetY, w.z);
const mesh = new THREE.Mesh(wheelGeo, wheelMat);
mesh.castShadow = true;
pivot.add(mesh);
car.add(pivot);
return pivot;
});
scene.add(car);
const onResize = () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
};
addEventListener('resize', onResize);
return {
renderer,
scene,
camera,
car,
wheels,
obstacles,
followSun() {
// Keep the shadow frustum centred on the car rather than the origin.
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
sun.target.position.copy(car.position);
},
dispose() {
removeEventListener('resize', onResize);
renderer.dispose();
renderer.domElement.remove();
},
};
}
const camTarget = new THREE.Vector3();
const camDesired = new THREE.Vector3();
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);
/** Smoothed chase camera. Frame-rate independent damping. */
export function updateCamera(view: SceneView, dt: number, speed: number): void {
const { camera, car } = view;
camDesired.copy(CHASE_OFFSET);
// Pull back a little at speed for a sense of pace.
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);
camDesired.applyQuaternion(car.quaternion).add(car.position);
const lerp = 1 - Math.exp(-6 * dt);
camera.position.lerp(camDesired, lerp);
camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position);
camera.lookAt(camTarget);
}