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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user