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:
parent
f5742de595
commit
90dc18fd17
152
src/main.ts
152
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,
|
||||
|
||||
78
src/persistence.ts
Normal file
78
src/persistence.ts
Normal file
@ -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 */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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]!;
|
||||
|
||||
@ -39,6 +39,75 @@ async function run(input: Partial<DriverInput>, seconds: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Gets up to speed, then measures what happens after a given input. */
|
||||
async function stopFrom(after: Partial<DriverInput>, 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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'];
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<RoadClass, number> = { track: 4.5, road: 9, trunk: 15 };
|
||||
/** Rough speed you can carry, used to weight routes. */
|
||||
export const ROAD_SPEED: Record<RoadClass, number> = { track: 0.55, road: 1, trunk: 1.35 };
|
||||
/** How much attention using this road draws. Nobody watches farm tracks. */
|
||||
export const ROAD_ATTENTION: Record<RoadClass, number> = { 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<number> => {
|
||||
const lines = new Set<number>();
|
||||
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<number>();
|
||||
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<T>(items: T[], rng: Rng): T[] {
|
||||
|
||||
function isConnected(
|
||||
nodeCount: number,
|
||||
edges: Array<[number, number]>,
|
||||
edges: Array<{ a: number; b: number }>,
|
||||
removed: ReadonlySet<number>,
|
||||
): 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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
165
src/sim/save.test.ts
Normal file
165
src/sim/save.test.ts
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
136
src/sim/save.ts
Normal file
136
src/sim/save.ts
Normal file
@ -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<SaveData>;
|
||||
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;
|
||||
}
|
||||
116
src/sim/world.ts
116
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 };
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user