Braking, stutter, a real road hierarchy, denser towns, and autosave
Braking. Rapier brake impulses were far too small next to a 1100kg chassis, so the car would not come to a standstill. Raised, plus a coast brake so lifting off actually slows you and a hold below walking pace so "stop the car" for a mission is not a fight. First attempt overshot to 1.9g, which reads as hitting a wall; tuned to about 0.8g, ~25m from 70km/h, and pinned with tests that assert both an upper and a lower bound on stopping distance. Stutter. The render drew the raw latest physics transform, so frames that consumed two steps or none landed as visible jitter at speed. Now interpolated between the last two steps by the leftover accumulator. Buildings moved into one instanced draw call and the minimap dropped to 10Hz, both of which were wasted work in a frame budget that also has to fit a shadow pass. Roads. Three classes: single-track lanes, two-lane roads, four-lane trunks that cross the whole map and are never thinned away. Class drives width, the route corridor, barricade span, map line weight, surface colour, and two things that matter more: routing is now by travel time so journeys prefer trunks, and attention scales with class, so a farm track is slow and unwatched while a trunk is fast and the first place anyone looks. Towns. Buildings were small and sparse enough to drive around freely. They are now laid out on a jittered lattice — rejection sampling cannot pack boxes and saturated at a third of the target — which takes bypass lanes around a barricade from 0% obstructed to about two thirds. World scaled to 400m half-extent, 81 junctions, 107 roads. Autosave. Every 20 seconds, plus an immediate checkpoint on taking and completing a job. Saves carry the subsystem ceilings, what the player knew including the parts that are now wrong, and where the war had got to. A save is validated against seed, format version and array lengths before anything is applied: half-restoring a campaign is worse than starting a fresh one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+73
-16
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three';
|
||||
import type { WorldModel } from '../sim/world';
|
||||
import type { Control } from '../sim/regions';
|
||||
import type { RoadClass } from '../sim/roads';
|
||||
import { CAR, WHEELS } from '../carSpec';
|
||||
|
||||
export interface SceneView {
|
||||
@@ -9,7 +10,8 @@ export interface SceneView {
|
||||
camera: THREE.PerspectiveCamera;
|
||||
car: THREE.Group;
|
||||
wheels: THREE.Object3D[];
|
||||
obstacles: THREE.Mesh[];
|
||||
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */
|
||||
crates: Array<{ index: number; mesh: THREE.Mesh }>;
|
||||
/** Keeps the shadow frustum centred on the car. */
|
||||
followSun(): void;
|
||||
/** Eases the world's colour and visibility toward the current territory. */
|
||||
@@ -80,41 +82,96 @@ export function createScene(model: WorldModel): SceneView {
|
||||
scene.add(grid);
|
||||
|
||||
// --- Roads ---
|
||||
const roadMat = new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 1 });
|
||||
// Trunks read a shade lighter than lanes, so road class is legible from the
|
||||
// driver's seat and not just from the map.
|
||||
const surfaces: Record<RoadClass, THREE.MeshStandardMaterial> = {
|
||||
trunk: new THREE.MeshStandardMaterial({ color: 0x2b2f34, roughness: 1 }),
|
||||
road: new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 1 }),
|
||||
track: new THREE.MeshStandardMaterial({ color: 0x38332a, roughness: 1 }),
|
||||
};
|
||||
const quad = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
|
||||
for (const s of model.roads.segments) {
|
||||
const strip = new THREE.Mesh(quad, roadMat);
|
||||
const strip = new THREE.Mesh(quad, surfaces[s.cls]);
|
||||
strip.scale.set(s.width, 1, s.length);
|
||||
strip.position.set((s.ax + s.bx) / 2, 0.03, (s.az + s.bz) / 2);
|
||||
strip.rotation.y = Math.atan2(s.bx - s.ax, s.bz - s.az);
|
||||
strip.receiveShadow = true;
|
||||
scene.add(strip);
|
||||
}
|
||||
// Discs fill the wedge-shaped gaps where segments meet at an angle.
|
||||
|
||||
// Lane markings down the middle of anything wider than a single track.
|
||||
const markingMat = new THREE.MeshBasicMaterial({ color: 0x6a6a5e, transparent: true, opacity: 0.5 });
|
||||
for (const s of model.roads.segments) {
|
||||
if (s.cls === 'track') continue;
|
||||
const dashes = Math.max(1, Math.floor(s.length / 12));
|
||||
for (let i = 0; i < dashes; i++) {
|
||||
const t = (i + 0.5) / dashes;
|
||||
const dash = new THREE.Mesh(quad, markingMat);
|
||||
dash.scale.set(0.3, 1, 5);
|
||||
dash.position.set(
|
||||
s.ax + (s.bx - s.ax) * t,
|
||||
0.035,
|
||||
s.az + (s.bz - s.az) * t,
|
||||
);
|
||||
dash.rotation.y = Math.atan2(s.bx - s.ax, s.bz - s.az);
|
||||
scene.add(dash);
|
||||
}
|
||||
}
|
||||
|
||||
// Discs fill the wedge-shaped gaps where segments meet at an angle. Sized to
|
||||
// the widest road arriving, or a trunk junction gets a lane-sized patch.
|
||||
const junction = new THREE.CircleGeometry(1, 20).rotateX(-Math.PI / 2);
|
||||
for (const n of model.roads.nodes) {
|
||||
const disc = new THREE.Mesh(junction, roadMat);
|
||||
disc.scale.setScalar(4.5);
|
||||
const widest = model.roads.segments
|
||||
.filter((s) => s.a === n.id || s.b === n.id)
|
||||
.reduce((max, s) => Math.max(max, s.width), 0);
|
||||
if (widest === 0) continue;
|
||||
const disc = new THREE.Mesh(junction, surfaces.road);
|
||||
disc.scale.setScalar(widest / 2);
|
||||
disc.position.set(n.x, 0.031, n.z);
|
||||
disc.receiveShadow = true;
|
||||
scene.add(disc);
|
||||
}
|
||||
|
||||
// --- Obstacles ---
|
||||
// Buildings never move, so they go into one instanced draw call. With a few
|
||||
// hundred of them, a mesh each was a real cost in a frame budget that also has
|
||||
// to fit a shadow pass.
|
||||
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;
|
||||
const blocks = model.obstacles.filter((o) => o.kind === 'block');
|
||||
const blockMesh = new THREE.InstancedMesh(boxGeo, blockMat, Math.max(1, blocks.length));
|
||||
blockMesh.castShadow = true;
|
||||
blockMesh.receiveShadow = true;
|
||||
const transform = new THREE.Matrix4();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
blocks.forEach((o, i) => {
|
||||
transform.compose(
|
||||
new THREE.Vector3(o.x, o.height / 2, o.z),
|
||||
quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), o.yaw),
|
||||
new THREE.Vector3(o.width, o.height, o.depth),
|
||||
);
|
||||
blockMesh.setMatrixAt(i, transform);
|
||||
});
|
||||
blockMesh.instanceMatrix.needsUpdate = true;
|
||||
scene.add(blockMesh);
|
||||
|
||||
// Crates can be shoved around, so they stay individual meshes that get synced.
|
||||
const crates = model.obstacles
|
||||
.map((o, index) => ({ o, index }))
|
||||
.filter(({ o }) => o.kind === 'crate')
|
||||
.map(({ o, index }) => {
|
||||
const mesh = new THREE.Mesh(boxGeo, crateMat);
|
||||
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 { index, mesh };
|
||||
});
|
||||
|
||||
// --- Car ---
|
||||
const car = new THREE.Group();
|
||||
@@ -170,7 +227,7 @@ export function createScene(model: WorldModel): SceneView {
|
||||
camera,
|
||||
car,
|
||||
wheels,
|
||||
obstacles,
|
||||
crates,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user