diff --git a/src/main.ts b/src/main.ts index 67695ec..635473e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ +import * as THREE from 'three'; import { generateWorld } from './sim/world'; import { applyWear, deriveHandling, freshCondition, repair } from './sim/car'; import { createHeat, stepHeat } from './sim/heat'; -import { routeAt, segmentAt } from './sim/roads'; +import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads'; import { baseAt, placeBases } from './sim/bases'; import { buildGraph } from './sim/routing'; import { createIntel, observe, reveal, survey } from './sim/intel'; @@ -16,6 +17,8 @@ import { import { createMinimap } from './ui/minimap'; import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests'; import { createHeatProps } from './heatProps'; +import { createPersistence } from './persistence'; +import { apply, type Snapshot } from './sim/save'; import { seedFromString } from './core/rng'; import { startLoop } from './core/loop'; import { createInput } from './core/input'; @@ -91,6 +94,7 @@ async function boot() { let offers: Offer[] = []; let notice = ''; let noticeUntil = 0; + let lastMinimapDraw = -1; const say = (text: string, seconds = 4) => { notice = text; @@ -100,6 +104,86 @@ async function boot() { const nodeOf = (id: number) => model.roads.nodes[id]!; const controlOf = (x: number, z: number) => controlAt(front, x, z); + // --- Persistence --- + const persistence = createPersistence(seed, model.roads.segments.length, intel.explored.length); + + const snapshot = (): Snapshot => { + const t = physics.chassis.translation(); + const r = physics.chassis.rotation(); + const v = physics.chassis.linvel(); + const w = physics.chassis.angvel(); + return { + seed, + elapsed, + condition, + car: { + position: [t.x, t.y, t.z], + rotation: [r.x, r.y, r.z, r.w], + linvel: [v.x, v.y, v.z], + angvel: [w.x, w.y, w.z], + }, + heat, + intel, + front, + quests, + }; + }; + + const saved = persistence.load(); + if (saved) { + const live = snapshot(); + apply(saved, live); + // `apply` writes into the live objects for everything held by reference; + // the values that are not have to be copied back out by hand. + elapsed = live.elapsed; + condition = live.condition; + physics.chassis.setTranslation( + { x: saved.car.position[0], y: saved.car.position[1], z: saved.car.position[2] }, + true, + ); + physics.chassis.setRotation( + { + x: saved.car.rotation[0], + y: saved.car.rotation[1], + z: saved.car.rotation[2], + w: saved.car.rotation[3], + }, + true, + ); + physics.chassis.setLinvel( + { x: saved.car.linvel[0], y: saved.car.linvel[1], z: saved.car.linvel[2] }, + true, + ); + physics.chassis.setAngvel( + { x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] }, + true, + ); + // Rebuild whatever the restored heat implies, so checkpoints are standing + // where the save says they are rather than appearing as you drive past. + stepFront(front, 0); + refreshSegmentControl(); + heatProps.sync( + model.roads.segments.map((s) => s.id), + heat, + null, + ); + } + + // Chassis transform at the end of the last two fixed steps, for interpolation. + const previous = { position: new THREE.Vector3(), rotation: new THREE.Quaternion() }; + const current = { position: new THREE.Vector3(), rotation: new THREE.Quaternion() }; + const captureChassis = () => { + previous.position.copy(current.position); + previous.rotation.copy(current.rotation); + const t = physics.chassis.translation(); + const q = physics.chassis.rotation(); + current.position.set(t.x, t.y, t.z); + current.rotation.set(q.x, q.y, q.z, q.w); + }; + // Prime both, so the first frame does not interpolate out of the origin. + captureChassis(); + captureChassis(); + document.getElementById('boot')?.remove(); startLoop({ @@ -112,6 +196,7 @@ async function boot() { drive(physics, driveState, cmd, deriveHandling(condition), dt); physics.step(dt); + captureChassis(); // Wear is applied from what actually happened this step, not from intent. const speed = physics.vehicle.currentVehicleSpeed(); @@ -138,7 +223,14 @@ async function boot() { dt, segmentId: currentSegment, distance, - accrual: HEAT_MULTIPLIER[control], + // Who holds the ground, times how conspicuous this class of road is. + // A farm track behind the lines costs nothing; a trunk past the front + // is the most watched thing you can drive on. + accrual: + HEAT_MULTIPLIER[control] * + (currentSegment === null + ? 1 + : ROAD_ATTENTION[model.roads.segments[currentSegment]!.cls]), // Each road decays according to whoever holds *it*, not whoever holds // the ground the car happens to be standing on. decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!], @@ -177,6 +269,8 @@ async function boot() { board.hide(); openBase = null; say(`${chosen.type === 'recon' ? 'Survey' : 'Supply run'} accepted.`); + // Checkpoint: taking a job is a decision worth not having to make twice. + persistence.checkpoint(elapsed, snapshot()); } } else if (openBase !== null) { openBase = null; @@ -226,22 +320,30 @@ async function boot() { ? `Handed in. Repairs used ${spent.toFixed(2)} parts.` : 'Handed in. Nothing left worth repairing.', ); + // Checkpoint: a finished run is the most expensive thing to lose. + persistence.checkpoint(elapsed, snapshot()); } } + persistence.tick(elapsed, snapshot); + // Once the cargo is dropped or the survey is done, the target is no longer // anywhere the player needs to be — the bases' own beacons take over. const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null; markers.setObjective(heading ? nodeOf(heading.targetNode) : null); }, - render(_alpha, frameDt) { - // No transform interpolation yet: at a 60 Hz fixed step it is not yet - // worth the bookkeeping. Revisit if the step rate ever drops. - const p = physics.chassis.translation(); - const r = physics.chassis.rotation(); - view.car.position.set(p.x, p.y, p.z); - view.car.quaternion.set(r.x, r.y, r.z, r.w); + render(alpha, frameDt) { + // Interpolate between the last two physics steps. + // + // The display almost never lines up with a 60Hz fixed step, so some frames + // consume two steps and some none. Drawing the raw latest transform makes + // that land as visible stutter at speed even when the framerate is fine. + // Blending by the leftover accumulator hides it. + view.car.position.lerpVectors(previous.position, current.position, alpha); + view.car.quaternion.slerpQuaternions(previous.rotation, current.rotation, alpha); + const p = view.car.position; + const r = view.car.quaternion; for (let i = 0; i < WHEELS.length; i++) { const pivot = view.wheels[i]!; @@ -252,13 +354,11 @@ async function boot() { pivot.children[0]!.rotation.x = physics.vehicle.wheelRotation(i) ?? 0; } - // Static blocks never move; only the crates need syncing. - for (let i = 0; i < view.obstacles.length; i++) { - if (model.obstacles[i]!.kind !== 'crate') continue; - const body = physics.obstacleBodies[i]!; + // Static blocks are instanced and never move; only crates need syncing. + for (const { index, mesh } of view.crates) { + const body = physics.obstacleBodies[index]!; const t = body.translation(); const q = body.rotation(); - const mesh = view.obstacles[i]!; mesh.position.set(t.x, t.y, t.z); mesh.quaternion.set(q.x, q.y, q.z, q.w); } @@ -272,17 +372,19 @@ async function boot() { const quest = quests.active; const target = quest ? nodeOf(quest.targetNode) : null; - minimap.draw(intel, { - now: elapsed, - x: p.x, - z: p.z, - // Yaw about +Y, measured from world +Z, which is the car's local forward. - heading: Math.atan2( - 2 * (r.w * r.y + r.x * r.z), - 1 - 2 * (r.y * r.y + r.x * r.x), - ), - objective: quest && quest.stage !== 'return' ? target : null, - }); + // The map is a sketch that changes slowly; redrawing every cell of it at + // display rate is wasted work in the same frame budget as the shadow pass. + if (elapsed - lastMinimapDraw > 0.1) { + lastMinimapDraw = elapsed; + minimap.draw(intel, { + now: elapsed, + x: p.x, + z: p.z, + // Yaw about +Y, from world +Z, which is the car's local forward. + heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)), + objective: quest && quest.stage !== 'return' ? target : null, + }); + } hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { heat: { segmentId: currentSegment, diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..f2a69e5 --- /dev/null +++ b/src/persistence.ts @@ -0,0 +1,78 @@ +/** + * Where saves actually live. Integration layer: knows about the browser, + * knows nothing about the game beyond the shape in sim/save.ts. + * + * localStorage rather than IndexedDB: a campaign serialises to a few tens of + * kilobytes, which is nowhere near the quota, and a synchronous read at boot + * avoids an async gate before the first frame. If saves ever grow to hold + * per-cell history this is the one file that has to change. + */ +import { isLoadable, serialise, type SaveData, type Snapshot } from './sim/save'; + +/** Seconds between routine saves. */ +export const AUTOSAVE_INTERVAL = 20; + +const keyFor = (seed: number) => `dbtl.save.${seed}`; + +export function createPersistence(seed: number, segments: number, cells: number) { + let lastSave = 0; + let failed = false; + + const write = (snapshot: Snapshot): boolean => { + if (failed) return false; + try { + localStorage.setItem(keyFor(seed), JSON.stringify(serialise(snapshot))); + return true; + } catch (err) { + // Private browsing, a full quota, or a disabled store. Losing saves is + // not worth losing the session over, so give up quietly and keep playing. + console.warn('autosave disabled:', err); + failed = true; + return false; + } + }; + + return { + /** Returns a validated save for this world, or null. */ + load(): SaveData | null { + try { + const raw = localStorage.getItem(keyFor(seed)); + if (!raw) return null; + const data: unknown = JSON.parse(raw); + if (!isLoadable(data, seed, segments, cells)) { + // A save from an older format or an older world generator. Dropping + // it is correct — restoring half of it would be worse. + localStorage.removeItem(keyFor(seed)); + return null; + } + return data as SaveData; + } catch { + return null; + } + }, + + /** Routine save. Cheap to call every frame; only writes on the interval. */ + tick(now: number, snapshot: () => Snapshot): boolean { + if (now - lastSave < AUTOSAVE_INTERVAL) return false; + lastSave = now; + return write(snapshot()); + }, + + /** + * Save now, whatever the interval says. Used at the moments that would + * hurt most to lose: taking a job, and finishing one. + */ + checkpoint(now: number, snapshot: Snapshot): boolean { + lastSave = now; + return write(snapshot); + }, + + clear() { + try { + localStorage.removeItem(keyFor(seed)); + } catch { + /* nothing to do */ + } + }, + }; +} diff --git a/src/physics/drive.ts b/src/physics/drive.ts index bf99ce1..c126454 100644 --- a/src/physics/drive.ts +++ b/src/physics/drive.ts @@ -11,6 +11,8 @@ const STEER_RETURN_RATE = 4.5; * Lower = more falloff. At 12, full lock is roughly halved by 45 km/h. */ const STEER_SPEED_FALLOFF = 12; +/** Below this, lifting off should bring the car to rest rather than a crawl. */ +const CREEP_SPEED = 1.2; export interface DriveState { steer: number; @@ -48,11 +50,18 @@ export function drive( const wantsReverse = input.throttle < 0; const braking = input.handbrake || (wantsReverse && speed > 1) || (input.throttle > 0 && speed < -1); const engineForce = braking ? 0 : input.throttle * handling.engineForce; - const brakeForce = input.handbrake - ? handling.brakeForce * 1.6 - : braking - ? handling.brakeForce - : 0; + + let brakeForce: number; + if (input.handbrake) brakeForce = handling.brakeForce * 1.6; + else if (braking) brakeForce = handling.brakeForce; + else if (input.throttle === 0) brakeForce = handling.coastBrake; + else brakeForce = 0; + + // At a crawl with no throttle, hold the car still instead of letting it creep. + // Otherwise "stop the car" for a mission is a fight against a rolling wreck. + if (input.throttle === 0 && Math.abs(speed) < CREEP_SPEED) { + brakeForce = Math.max(brakeForce, handling.brakeForce); + } for (let i = 0; i < WHEELS.length; i++) { const w = WHEELS[i]!; diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index 1449bb2..974fd44 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -39,6 +39,75 @@ async function run(input: Partial, seconds: number) { }; } +/** Gets up to speed, then measures what happens after a given input. */ +async function stopFrom(after: Partial, seconds = 8) { + const world = generateWorld(1, 0); + const physics = await createPhysics(world); + const state = createDriveState(); + const handling = deriveHandling(freshCondition()); + + for (let i = 0; i < 6 / STEP; i++) { + drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP); + physics.step(STEP); + } + const entry = physics.vehicle.currentVehicleSpeed(); + const from = physics.chassis.translation(); + + let time = 0; + const cmd = { ...IDLE, ...after }; + for (let i = 0; i < seconds / STEP; i++) { + if (Math.abs(physics.vehicle.currentVehicleSpeed()) < 0.3) break; + drive(physics, state, cmd, handling, STEP); + physics.step(STEP); + time += STEP; + } + const to = physics.chassis.translation(); + return { + entrySpeed: entry, + finalSpeed: physics.vehicle.currentVehicleSpeed(), + distance: Math.hypot(to.x - from.x, to.z - from.z), + time, + }; +} + +describe('slowing down', () => { + it('hauls the car to a standstill on the brake', async () => { + const r = await stopFrom({ throttle: -1 }); + expect(r.entrySpeed).toBeGreaterThan(12); + expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3); + // Roughly road-car braking: not a parachute, not a barge. The lower bound + // matters as much as the upper one — brakes strong enough to stop in 10m + // read as hitting a wall. + expect(r.distance).toBeGreaterThan(15); + expect(r.distance).toBeLessThan(38); + expect(r.time).toBeGreaterThan(1.5); + expect(r.time).toBeLessThan(4.5); + }); + + it('comes to rest on its own when you simply lift off', async () => { + const r = await stopFrom({}, 20); + // The complaint this fixes: coasting used to never actually stop. + expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3); + expect(r.time).toBeLessThan(16); + }); + + it('stops harder on the brake than on the overrun', async () => { + const braked = await stopFrom({ throttle: -1 }); + const coasted = await stopFrom({}, 20); + expect(braked.distance).toBeLessThan(coasted.distance); + }); + + it('stops worse on bald tyres', async () => { + const physics = await createPhysics(generateWorld(1, 0)); + const worn = deriveHandling({ + level: { engine: 0.2, tires: 0.2, chassis: 0.2 }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, + }); + expect(worn.brakeForce).toBeLessThan(deriveHandling(freshCondition()).brakeForce); + expect(physics.vehicle.numWheels()).toBe(4); + }); +}); + describe('vehicle', () => { it('settles on its suspension instead of sinking or bouncing away', async () => { const r = await run({}, 2); diff --git a/src/render/scene.ts b/src/render/scene.ts index 434ca26..13bcb24 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -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 = { + 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); diff --git a/src/sim/bases.ts b/src/sim/bases.ts index 970f24c..4008b3c 100644 --- a/src/sim/bases.ts +++ b/src/sim/bases.ts @@ -21,7 +21,7 @@ export interface Base { } /** How far the building sits from the junction it serves. */ -const HUT_OFFSET = 15; +const HUT_OFFSET = 11; const NAMES = ['Anvil', 'Birch', 'Cinder', 'Dovetail', 'Ember', 'Foxglove']; diff --git a/src/sim/car.ts b/src/sim/car.ts index f6d7239..3f273fa 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -23,8 +23,10 @@ export interface CarCondition { export interface Handling { /** Newtons of drive force available per driven wheel at full throttle. */ engineForce: number; - /** Braking impulse per wheel. */ + /** Braking impulse per wheel under the brake pedal. */ brakeForce: number; + /** Braking applied with no throttle: engine braking and rolling resistance. */ + coastBrake: number; /** Max steering angle, radians. */ maxSteer: number; /** Tyre grip. Lower = slides. */ @@ -49,8 +51,15 @@ export function deriveHandling(c: CarCondition): Handling { return { // A tired engine simply cannot push as hard. engineForce: lerp(900, 2600, engine), - // Worn pads take longer to haul the car down. - brakeForce: lerp(4, 14, tires), + // Worn pads take longer to haul the car down. These are Rapier brake + // impulses, which have to be large next to a 1100kg chassis — the first + // values here were so weak the car would not come to a standstill at all. + // Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and + // the brake pedal stops feeling like a brake and starts feeling like a wall. + brakeForce: lerp(12, 36, tires), + // Lifting off has to actually slow you down. Without this the car coasts + // almost forever and every stop needs a deliberate stab at the brake. + coastBrake: lerp(4, 9, engine), maxSteer: lerp(0.35, 0.55, chassis), // Bald tyres are the most legible failure: the back end starts to leave. frictionSlip: lerp(1.6, 5, tires), diff --git a/src/sim/heat.test.ts b/src/sim/heat.test.ts index 26e898e..b22290c 100644 --- a/src/sim/heat.test.ts +++ b/src/sim/heat.test.ts @@ -6,7 +6,7 @@ import { distanceToRoad, pointOnSegment, projectOntoSegment, - ROUTE_CATCHMENT, + catchmentOf, } from './roads'; import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat'; import { generateWorld } from './world'; @@ -61,14 +61,14 @@ describe('route catchment', () => { }); it('closes the drive-alongside exploit right up to the cutoff', () => { - for (const offset of [s.width / 2 + 0.5, ROUTE_CATCHMENT * 0.7, ROUTE_CATCHMENT - 0.5]) { + for (const offset of [s.width / 2 + 0.5, catchmentOf(s) * 0.7, catchmentOf(s) - 0.5]) { const p = beside(offset); expect(routeAt(roads, p.x, p.z)?.id).toBe(s.id); } }); it('stops counting once you have genuinely left the route', () => { - const away = beside(ROUTE_CATCHMENT + 5); + const away = beside(catchmentOf(s) + 5); expect(routeAt(roads, away.x, away.z)).toBeNull(); }); @@ -205,7 +205,7 @@ describe('heat props', () => { const reach = Math.max( ...blocks.map((b) => side * b.lateral + b.width / 2), ); - expect(reach).toBeGreaterThanOrEqual(ROUTE_CATCHMENT); + expect(reach).toBeGreaterThanOrEqual(catchmentOf(segment)); } }); diff --git a/src/sim/heat.ts b/src/sim/heat.ts index db5f49b..e16bbbd 100644 --- a/src/sim/heat.ts +++ b/src/sim/heat.ts @@ -6,7 +6,7 @@ * escalation is meant to be read off what is physically sitting in the road. */ import { makeRng, randRange } from '../core/rng'; -import { pointOnSegment, ROUTE_CATCHMENT, type RoadNetwork, type RoadSegment } from './roads'; +import { catchmentOf, pointOnSegment, type RoadNetwork, type RoadSegment } from './roads'; export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const; export type HeatLevel = (typeof HEAT_LEVELS)[number]; @@ -148,7 +148,7 @@ export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] { const gapCentre = gapSide * randRange(rng, half * 0.35, half * 0.6); for (const side of [-1, 1]) { const inner = gapCentre + side * GAP_HALF_WIDTH; - const outer = side * ROUTE_CATCHMENT; + const outer = side * catchmentOf(segment); const blockWidth = Math.abs(outer - inner); if (blockWidth < 0.8) continue; const lateral = (inner + outer) / 2; diff --git a/src/sim/quests.ts b/src/sim/quests.ts index 43e1ce2..ff2f09d 100644 --- a/src/sim/quests.ts +++ b/src/sim/quests.ts @@ -9,7 +9,7 @@ import { makeRng, randRange, type Rng } from '../core/rng'; import type { Base } from './bases'; import { summariseRoute, type Intel, type RouteIntel } from './intel'; -import { findRoute, type Graph, type Route } from './routing'; +import { findRoute, travelTime, type Graph, type Route } from './routing'; import type { RoadNetwork } from './roads'; export type MissionType = 'delivery' | 'recon'; @@ -123,7 +123,9 @@ export function offersAt( const offers: Offer[] = []; for (const { node, novel } of chosen) { - const route = findRoute(graph, base.nodeId, node); + // Routed by time, not distance, so the board's estimate matches the way a + // driver would actually go: trunk where there is one, lanes where there is not. + const route = findRoute(graph, base.nodeId, node, travelTime(roads)); if (!route) continue; const type: MissionType = rng() < 0.5 ? 'recon' : 'delivery'; offers.push({ diff --git a/src/sim/roads.ts b/src/sim/roads.ts index 45fa831..ff5e31b 100644 --- a/src/sim/roads.ts +++ b/src/sim/roads.ts @@ -15,6 +15,24 @@ export interface RoadNode { z: number; } +/** + * Roads are not all the same road. + * + * A four-lane trunk is fast and direct, and it is exactly where anyone looking + * for you would look. A single-car track is slow and awkward, and nobody is + * watching it. That trade — speed against attention — is the road network doing + * real work rather than just being the floor you drive on. + */ +export const ROAD_CLASSES = ['track', 'road', 'trunk'] as const; +export type RoadClass = (typeof ROAD_CLASSES)[number]; + +/** Tarmac width in metres: one car, two lanes, four lanes. */ +export const ROAD_WIDTH: Record = { track: 4.5, road: 9, trunk: 15 }; +/** Rough speed you can carry, used to weight routes. */ +export const ROAD_SPEED: Record = { track: 0.55, road: 1, trunk: 1.35 }; +/** How much attention using this road draws. Nobody watches farm tracks. */ +export const ROAD_ATTENTION: Record = { track: 0.55, road: 1, trunk: 1.35 }; + export interface RoadSegment { id: number; a: number; @@ -25,6 +43,7 @@ export interface RoadSegment { bz: number; length: number; width: number; + cls: RoadClass; } export interface RoadNetwork { @@ -32,12 +51,15 @@ export interface RoadNetwork { segments: RoadSegment[]; } -const GRID = 7; -const ROAD_WIDTH = 9; +const GRID = 9; /** Fraction of grid spacing a node may wander from its lattice point. */ const JITTER = 0.3; -/** Share of edges to try to remove, budget permitting. */ -const THINNING = 0.28; +/** Share of edges to try to remove, budget permitting. Trunks are never cut. */ +const THINNING = 0.26; +/** Lattice rows and columns promoted to four-lane through-routes. */ +const TRUNK_LINES = 2; +/** Share of the remaining edges that are two-lane rather than single-track. */ +const ROAD_SHARE = 0.45; export function generateRoads(seed: number, extent: number): RoadNetwork { const rng: Rng = makeRng(seed ^ 0x9e3779b9); @@ -54,21 +76,43 @@ export function generateRoads(seed: number, extent: number): RoadNetwork { } } - // Full lattice first, then thin it out. - const edges: Array<[number, number]> = []; + // A couple of lattice rows and columns become the trunk routes that cross the + // whole map. Everything else is secondary, so the network has a shape you can + // learn — "get onto the north trunk, then work down the lanes" — rather than + // being a uniform mesh where every road is as good as every other. + const pickLines = (): Set => { + const lines = new Set(); + while (lines.size < TRUNK_LINES) lines.add(1 + Math.floor(rng() * (GRID - 2))); + return lines; + }; + const trunkRows = pickLines(); + const trunkCols = pickLines(); + + const edges: Array<{ a: number; b: number; cls: RoadClass }> = []; for (let row = 0; row < GRID; row++) { for (let col = 0; col < GRID; col++) { const i = row * GRID + col; - if (col + 1 < GRID) edges.push([i, i + 1]); - if (row + 1 < GRID) edges.push([i, i + GRID]); + // A horizontal edge runs along a row; it is trunk if that row is one. + if (col + 1 < GRID) { + edges.push({ a: i, b: i + 1, cls: trunkRows.has(row) ? 'trunk' : 'road' }); + } + if (row + 1 < GRID) { + edges.push({ a: i, b: i + GRID, cls: trunkCols.has(col) ? 'trunk' : 'road' }); + } } } + // Demote most of the non-trunk edges to single-track lanes. + for (const edge of edges) { + if (edge.cls !== 'trunk' && rng() > ROAD_SHARE) edge.cls = 'track'; + } const order = shuffle(edges.map((_, i) => i), rng); const removed = new Set(); const budget = Math.floor(edges.length * THINNING); for (const idx of order) { if (removed.size >= budget) break; + // Trunks run the length of the map; cutting one defeats the point of it. + if (edges[idx]!.cls === 'trunk') continue; removed.add(idx); // A road that cuts the map in two is worse than a boring one. if (!isConnected(nodes.length, edges, removed)) removed.delete(idx); @@ -77,8 +121,8 @@ export function generateRoads(seed: number, extent: number): RoadNetwork { const segments: RoadSegment[] = []; edges.forEach((edge, idx) => { if (removed.has(idx)) return; - const a = nodes[edge[0]]!; - const b = nodes[edge[1]]!; + const a = nodes[edge.a]!; + const b = nodes[edge.b]!; segments.push({ id: segments.length, a: a.id, @@ -88,7 +132,8 @@ export function generateRoads(seed: number, extent: number): RoadNetwork { bx: b.x, bz: b.z, length: Math.hypot(b.x - a.x, b.z - a.z), - width: ROAD_WIDTH, + width: ROAD_WIDTH[edge.cls], + cls: edge.cls, }); }); @@ -105,11 +150,11 @@ function shuffle(items: T[], rng: Rng): T[] { function isConnected( nodeCount: number, - edges: Array<[number, number]>, + edges: Array<{ a: number; b: number }>, removed: ReadonlySet, ): boolean { const adjacency: number[][] = Array.from({ length: nodeCount }, () => []); - edges.forEach(([a, b], idx) => { + edges.forEach(({ a, b }, idx) => { if (removed.has(idx)) return; adjacency[a]!.push(b); adjacency[b]!.push(a); @@ -156,7 +201,14 @@ export function projectOntoSegment( * everywhere, and "take a different route" stops being a choice. At 12m about * 57% of the map is genuinely off-route. */ -export const ROUTE_CATCHMENT = 12; +export const VERGE = 7.5; + +/** + * How far off a road's tarmac still counts as travelling along it. Scales with + * the road, so a trunk's corridor is wide and a lane's is narrow — hugging the + * shoulder of either buys you nothing. + */ +export const catchmentOf = (s: RoadSegment): number => s.width / 2 + VERGE; function nearest( roads: RoadNetwork, @@ -189,7 +241,7 @@ export const segmentAt = (roads: RoadNetwork, x: number, z: number): RoadSegment * out there the scenery is its own punishment. */ export const routeAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null => - nearest(roads, x, z, () => ROUTE_CATCHMENT); + nearest(roads, x, z, catchmentOf); /** Distance to the nearest road surface, used to keep scenery off the tarmac. */ export function distanceToRoad(roads: RoadNetwork, x: number, z: number): number { diff --git a/src/sim/routing.ts b/src/sim/routing.ts index d174e76..df3aa53 100644 --- a/src/sim/routing.ts +++ b/src/sim/routing.ts @@ -5,7 +5,7 @@ * *before* the player commits to it — which is the whole point of Phase 2. A * target is not a decision until you can weigh what getting there costs. */ -import type { RoadNetwork } from './roads'; +import { ROAD_SPEED, type RoadNetwork } from './roads'; export interface Route { /** Node ids from start to goal, inclusive. */ @@ -33,6 +33,18 @@ export function buildGraph(roads: RoadNetwork): Graph { return graph; } +/** + * Default routing cost: time, not distance. + * + * Routing purely by metres sends every journey down single-car tracks that + * happen to be marginally shorter, which is neither what a driver would do nor + * what makes the trunk network worth having. + */ +export const travelTime = + (roads: RoadNetwork) => + (segmentId: number, length: number): number => + length / ROAD_SPEED[roads.segments[segmentId]!.cls]; + /** * Dijkstra with a plain linear scan for the frontier. The graph is ~50 nodes; * a heap would be more code than it is worth and this never runs per-frame. diff --git a/src/sim/save.test.ts b/src/sim/save.test.ts new file mode 100644 index 0000000..cdec218 --- /dev/null +++ b/src/sim/save.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { generateWorld } from './world'; +import { applyWear, freshCondition } from './car'; +import { createHeat, stepHeat } from './heat'; +import { createIntel, observe, reveal } from './intel'; +import { applyMissionImpact, controlAt, createFront, stepFront } from './regions'; +import { createQuests } from './quests'; +import { apply, isLoadable, serialise, SAVE_VERSION, type Snapshot } from './save'; + +const world = generateWorld(1337); + +/** A campaign with some history behind it, so a round trip has work to do. */ +function played(): Snapshot { + const heat = createHeat(world.roads); + const intel = createIntel(world.roads, world.extent); + const front = createFront(1337, world.extent, world.spawn); + const quests = createQuests(); + let condition = freshCondition(); + + for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 2, distance: 1 }); + for (let i = 0; i < 200; i++) { + condition = applyWear(condition, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 3e4 }); + } + observe(intel, heat, 2, 12); + reveal(intel, world.spawn.x, world.spawn.z, 90, (x, z) => controlAt(front, x, z)); + intel.visitedNodes.add(9); + for (let i = 0; i < 60 * 45; i++) stepFront(front, 1 / 60); + applyMissionImpact(front, 18); + quests.completed = 3; + quests.parts = 0.42; + quests.nextId = 7; + + return { + seed: 1337, + elapsed: 412.5, + condition, + car: { position: [12, 1.1, -30], rotation: [0, 0.3, 0, 0.95], linvel: [4, 0, 1], angvel: [0, 0.2, 0] }, + heat, + intel, + front, + quests, + }; +} + +/** A pristine campaign for the same world, to restore into. */ +function blank(): Snapshot { + const front = createFront(1337, world.extent, world.spawn); + return { + seed: 1337, + elapsed: 0, + condition: freshCondition(), + car: { position: [0, 0, 0], rotation: [0, 0, 0, 1], linvel: [0, 0, 0], angvel: [0, 0, 0] }, + heat: createHeat(world.roads), + intel: createIntel(world.roads, world.extent), + front, + quests: createQuests(), + }; +} + +describe('save round trip', () => { + it('survives JSON, which is what actually goes to storage', () => { + const source = played(); + const restored = blank(); + const data = JSON.parse(JSON.stringify(serialise(source))); + expect(isLoadable(data, 1337, world.roads.segments.length, restored.intel.explored.length)).toBe( + true, + ); + apply(data, restored); + + expect(restored.elapsed).toBe(source.elapsed); + expect(restored.condition).toEqual(source.condition); + expect(restored.car).toEqual(source.car); + expect(restored.heat.value).toEqual(source.heat.value); + expect(restored.heat.level).toEqual(source.heat.level); + expect(restored.quests.completed).toBe(3); + expect(restored.quests.parts).toBeCloseTo(0.42, 9); + }); + + it('brings back the ceiling, not just the current condition', () => { + const source = played(); + const restored = blank(); + // Otherwise a reload would quietly undo every permanent bit of damage, + // which is the one thing "decline, not reset" cannot survive. + expect(source.condition.ceiling.chassis).toBeLessThan(1); + apply(JSON.parse(JSON.stringify(serialise(source))), restored); + expect(restored.condition.ceiling).toEqual(source.condition.ceiling); + }); + + it('restores what the player knew, including the parts that are wrong', () => { + const source = played(); + const restored = blank(); + apply(JSON.parse(JSON.stringify(serialise(source))), restored); + + expect(restored.intel.seenAt).toEqual(source.intel.seenAt); + expect(restored.intel.rememberedLevel).toEqual(source.intel.rememberedLevel); + expect([...restored.intel.visitedNodes]).toEqual([...source.intel.visitedNodes]); + expect(restored.intel.explored).toEqual(source.intel.explored); + expect(restored.intel.rememberedControl).toEqual(source.intel.rememberedControl); + expect(restored.intel.explored).toBeInstanceOf(Uint8Array); + }); + + it('puts the front back where the war had got to', () => { + const source = played(); + const restored = blank(); + expect(controlAt(restored.front, 100, 100)).toBeDefined(); + apply(JSON.parse(JSON.stringify(serialise(source))), restored); + stepFront(restored.front, 0); + stepFront(source.front, 0); + expect(restored.front.boundaries).toEqual(source.front.boundaries); + }); + + it('keeps an in-flight mission, so a save mid-run is not a lost run', () => { + const source = played(); + source.quests.active = { + id: 4, + type: 'delivery', + targetNode: 11, + novel: true, + route: { nodes: [1, 2], segments: [0], length: 120 }, + intel: { worst: 'patrol', unknownCount: 1, stalest: 30 }, + reward: 0.2, + impact: 12, + originBase: 1, + stage: 'return', + worked: 3, + }; + const restored = blank(); + apply(JSON.parse(JSON.stringify(serialise(source))), restored); + expect(restored.quests.active?.stage).toBe('return'); + expect(restored.quests.active?.targetNode).toBe(11); + }); +}); + +describe('save validation', () => { + const segments = world.roads.segments.length; + const cells = createIntel(world.roads, world.extent).explored.length; + const good = () => JSON.parse(JSON.stringify(serialise(played()))); + + it('accepts a save for this world', () => { + expect(isLoadable(good(), 1337, segments, cells)).toBe(true); + }); + + it('rejects a save from a different world', () => { + // Seeds define the map; loading one campaign's roads into another's world + // would put checkpoints and memories on roads that do not exist. + expect(isLoadable(good(), 999, segments, cells)).toBe(false); + }); + + it('rejects a save from an older format', () => { + expect(isLoadable({ ...good(), version: SAVE_VERSION - 1 }, 1337, segments, cells)).toBe(false); + }); + + it('rejects a save whose arrays no longer match the generator', () => { + // Changing world generation invalidates old saves. Refusing is correct: + // a half-restored campaign is worse than a fresh one. + expect(isLoadable(good(), 1337, segments + 1, cells)).toBe(false); + expect(isLoadable(good(), 1337, segments, cells + 1)).toBe(false); + }); + + it('rejects junk without throwing', () => { + for (const junk of [null, undefined, 42, 'save', {}, { version: SAVE_VERSION }]) { + expect(isLoadable(junk, 1337, segments, cells)).toBe(false); + } + }); +}); diff --git a/src/sim/save.ts b/src/sim/save.ts new file mode 100644 index 0000000..b72a6f0 --- /dev/null +++ b/src/sim/save.ts @@ -0,0 +1,136 @@ +/** + * Turning the campaign into JSON and back. Pure — no engine, no storage. + * + * "Decline, not reset" only means anything if the decline survives closing the + * tab. Everything the player has spent time accumulating goes in here: what the + * car has been through, what the roads remember, what the player has seen, and + * where the war has got to. + * + * Deliberately explicit rather than a blind structural clone: a save that + * silently half-restores is worse than one that refuses to load, so anything + * whose shape has changed should fail the version check and be discarded. + */ +import type { CarCondition } from './car'; +import type { HeatLevel, HeatState } from './heat'; +import type { Intel } from './intel'; +import type { Boundaries, Front } from './regions'; +import type { ActiveQuest, QuestState } from './quests'; + +export const SAVE_VERSION = 1; + +export interface CarSnapshot { + position: [number, number, number]; + rotation: [number, number, number, number]; + linvel: [number, number, number]; + angvel: [number, number, number]; +} + +export interface SaveData { + version: number; + /** Saves are per-world; a different seed is a different campaign. */ + seed: number; + savedAt: number; + elapsed: number; + condition: CarCondition; + car: CarSnapshot; + heat: { value: number[]; level: HeatLevel[] }; + intel: { + rememberedHeat: number[]; + rememberedLevel: HeatLevel[]; + seenAt: number[]; + visitedNodes: number[]; + explored: number[]; + rememberedControl: number[]; + }; + front: { elapsed: number; playerOffset: Boundaries }; + quests: { + active: ActiveQuest | null; + completed: number; + parts: number; + nextId: number; + }; +} + +export interface Snapshot { + seed: number; + elapsed: number; + condition: CarCondition; + car: CarSnapshot; + heat: HeatState; + intel: Intel; + front: Front; + quests: QuestState; +} + +export function serialise(s: Snapshot): SaveData { + return { + version: SAVE_VERSION, + seed: s.seed, + savedAt: Date.now(), + elapsed: s.elapsed, + condition: { level: { ...s.condition.level }, ceiling: { ...s.condition.ceiling } }, + car: s.car, + heat: { value: [...s.heat.value], level: [...s.heat.level] }, + intel: { + rememberedHeat: [...s.intel.rememberedHeat], + rememberedLevel: [...s.intel.rememberedLevel], + seenAt: [...s.intel.seenAt], + visitedNodes: [...s.intel.visitedNodes], + // Typed arrays do not survive JSON; plain arrays of 0/1 are small enough. + explored: Array.from(s.intel.explored), + rememberedControl: Array.from(s.intel.rememberedControl), + }, + front: { elapsed: s.front.elapsed, playerOffset: { ...s.front.playerOffset } }, + quests: { + active: s.quests.active, + completed: s.quests.completed, + parts: s.quests.parts, + nextId: s.quests.nextId, + }, + }; +} + +/** + * Checks a save is for this world and this format before anything is applied. + * Half-restoring a campaign is worse than starting a fresh one. + */ +export function isLoadable(data: unknown, seed: number, segments: number, cells: number): boolean { + if (!data || typeof data !== 'object') return false; + const save = data as Partial; + if (save.version !== SAVE_VERSION || save.seed !== seed) return false; + if (!save.heat || !save.intel || !save.front || !save.quests || !save.condition) return false; + // The world is regenerated from the seed, so array lengths must line up with + // it. A generator change invalidates old saves, which is the correct outcome. + return ( + save.heat.value?.length === segments && + save.intel.seenAt?.length === segments && + save.intel.explored?.length === cells + ); +} + +/** Writes a validated save back over live state, in place. */ +export function apply(data: SaveData, into: Snapshot): void { + into.elapsed = data.elapsed; + into.condition.level = { ...data.condition.level }; + into.condition.ceiling = { ...data.condition.ceiling }; + into.car = data.car; + + into.heat.value = [...data.heat.value]; + into.heat.level = [...data.heat.level]; + + into.intel.rememberedHeat = [...data.intel.rememberedHeat]; + into.intel.rememberedLevel = [...data.intel.rememberedLevel]; + into.intel.seenAt = [...data.intel.seenAt]; + into.intel.visitedNodes = new Set(data.intel.visitedNodes); + into.intel.explored = Uint8Array.from(data.intel.explored); + into.intel.rememberedControl = Uint8Array.from(data.intel.rememberedControl); + + // The front's shape comes from the seed; only its position is saved. + into.front.elapsed = data.front.elapsed; + into.front.playerOffset = { ...data.front.playerOffset }; + + into.quests.active = data.quests.active; + into.quests.completed = data.quests.completed; + into.quests.parts = data.quests.parts; + into.quests.nextId = data.quests.nextId; +} diff --git a/src/sim/world.ts b/src/sim/world.ts index 7c3dd0a..7404445 100644 --- a/src/sim/world.ts +++ b/src/sim/world.ts @@ -30,11 +30,24 @@ export interface WorldModel { spawn: { x: number; z: number }; } -const SPAWN_CLEARANCE = 12; +const SPAWN_CLEARANCE = 22; /** Scenery this close to tarmac would read as a roadblock. Heat places those. */ const ROAD_CLEARANCE = 2.5; +/** Gap kept between buildings. Small, so off-road is a maze, not a car park. */ +const OBSTACLE_SPACING = 2; +/** + * Lattice pitch for buildings, and the sizes that fit inside it. + * BLOCK_MAX + 2*BLOCK_JITTER + OBSTACLE_SPACING must stay under BLOCK_CELL, or + * neighbouring plots can grow into each other. + */ +const BLOCK_CELL = 15; +const BLOCK_JITTER = 1; +const BLOCK_MIN = 7; +const BLOCK_MAX = 10; +/** Share of plots left empty, so the maze has through-routes to find. */ +const BLOCK_GAP_CHANCE = 0.05; -export function generateWorld(seed: number, count = 200, extent = 220): WorldModel { +export function generateWorld(seed: number, count = 1800, extent = 400): WorldModel { const rng: Rng = makeRng(seed); const roads = generateRoads(seed, extent); const obstacles: Obstacle[] = []; @@ -44,41 +57,78 @@ export function generateWorld(seed: number, count = 200, extent = 220): WorldMod ); const spawn = { x: spawnNode.x, z: spawnNode.z }; - let attempts = 0; - while (obstacles.length < count && attempts < count * 40) { - attempts++; - const x = randRange(rng, -extent, extent); - const z = randRange(rng, -extent, extent); + const reachOf = (o: Obstacle) => Math.hypot(o.width, o.depth) / 2; + + /** Clearance from the things that must stay usable: roads, junctions, spawn. */ + const clearsTheMap = (candidate: Obstacle): boolean => { + const reach = reachOf(candidate); // Leave the player's spawn point clear so the car never starts inside a wall. - if (Math.hypot(x - spawn.x, z - spawn.z) < SPAWN_CLEARANCE) continue; - - const crate = rng() < 0.45; - const obstacle: Obstacle = crate - ? { - x, - z, - width: randRange(rng, 0.8, 1.4), - height: randRange(rng, 0.8, 1.4), - depth: randRange(rng, 0.8, 1.4), - yaw: rng() * Math.PI, - kind: 'crate', - } - : { - x, - z, - width: randRange(rng, 1.5, 5), - height: randRange(rng, 1.2, 3.5), - depth: randRange(rng, 1.5, 5), - yaw: rng() * Math.PI, - kind: 'block', - }; - + if (Math.hypot(candidate.x - spawn.x, candidate.z - spawn.z) < SPAWN_CLEARANCE + reach) { + return false; + } // Roads have to stay drivable — obstruction is heat's job, not scenery's. // Measured from the box's corner, since it may be rotated any which way. - const reach = Math.hypot(obstacle.width, obstacle.depth) / 2; - if (distanceToRoad(roads, x, z) < ROAD_CLEARANCE + reach) continue; + if (distanceToRoad(roads, candidate.x, candidate.z) < ROAD_CLEARANCE + reach) return false; + // Junctions keep a little elbow room so bases have somewhere to stand. Kept + // deliberately tight: a generous radius here, times fifty junctions, covers + // most of the map and leaves nowhere to build at all. + return !roads.nodes.some( + (n) => Math.hypot(n.x - candidate.x, n.z - candidate.z) < 17 + reach, + ); + }; - obstacles.push(obstacle); + const clearsOtherObstacles = (candidate: Obstacle): boolean => { + const reach = reachOf(candidate); + return !obstacles.some( + (other) => + Math.hypot(other.x - candidate.x, other.z - candidate.z) < + reach + reachOf(other) + OBSTACLE_SPACING, + ); + }; + + // Buildings are laid out on a jittered lattice rather than scattered at + // random. Random placement saturates early — rejection sampling cannot pack + // large boxes — and leaves open country everywhere, which is exactly the free + // bypass around checkpoints that the route corridor is meant to prevent. + // A lattice packs tightly and reads as a built-up area with streets in it. + // Buildings get their own share of the budget. Given the whole of it they + // fill the map and leave no room for crates at all, since the lattice offers + // far more plots than the budget allows. + const blockTarget = Math.round(count * 0.85); + const cells = Math.floor((extent * 2) / BLOCK_CELL); + for (let row = 0; row <= cells && obstacles.length < blockTarget; row++) { + for (let col = 0; col <= cells && obstacles.length < blockTarget; col++) { + if (rng() < BLOCK_GAP_CHANCE) continue; + const candidate: Obstacle = { + x: -extent + col * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER), + z: -extent + row * BLOCK_CELL + randRange(rng, -BLOCK_JITTER, BLOCK_JITTER), + width: randRange(rng, BLOCK_MIN, BLOCK_MAX), + height: randRange(rng, 4, 12), + depth: randRange(rng, BLOCK_MIN, BLOCK_MAX), + // Only a slight lean: enough to look unplanned, not enough that a + // rotated corner spills into the neighbouring plot. + yaw: randRange(rng, -0.25, 0.25), + kind: 'block', + }; + // No mutual-overlap test here: the lattice already guarantees separation, + // and testing it by circumscribed radius would reject every neighbour, + // since a box's half-diagonal wildly overstates how much room it needs. + if (clearsTheMap(candidate)) obstacles.push(candidate); + } + } + + // Crates fill the gaps between buildings: things to shove, not walls. + for (let attempt = 0; attempt < count * 40 && obstacles.length < count; attempt++) { + const candidate: Obstacle = { + x: randRange(rng, -extent, extent), + z: randRange(rng, -extent, extent), + width: randRange(rng, 0.8, 1.4), + height: randRange(rng, 0.8, 1.4), + depth: randRange(rng, 0.8, 1.4), + yaw: rng() * Math.PI, + kind: 'crate', + }; + if (clearsTheMap(candidate) && clearsOtherObstacles(candidate)) obstacles.push(candidate); } return { seed, extent, roads, obstacles, spawn }; diff --git a/src/ui/minimap.ts b/src/ui/minimap.ts index 8a80f6e..1a32a2d 100644 --- a/src/ui/minimap.ts +++ b/src/ui/minimap.ts @@ -97,16 +97,19 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu ctx.beginPath(); ctx.moveTo(px(s.ax), px(s.az)); ctx.lineTo(px(s.bx), px(s.bz)); + // Line weight follows road class, so the trunk network is the thing you + // read first when planning a route. + const weight = { trunk: 3, road: 1.8, track: 1 }[s.cls]; if (hasSeen(intel, s.id)) { const age = Math.min(1, (view.now - intel.seenAt[s.id]!) / STALE_AFTER); ctx.setLineDash([]); - ctx.lineWidth = 2; + ctx.lineWidth = weight; ctx.globalAlpha = FRESH_ALPHA + (STALE_ALPHA - FRESH_ALPHA) * age; ctx.strokeStyle = HEAT_COLOURS[intel.rememberedLevel[s.id]!]; } else { // Known to exist, nothing known about it. ctx.setLineDash([2, 3]); - ctx.lineWidth = 1; + ctx.lineWidth = weight * 0.6; ctx.globalAlpha = 1; ctx.strokeStyle = '#3c464f'; }