drive-between-the-lines/src/main.ts
dejvino d8a1f784ae Civilians with some sense of self-preservation
Three things people were not doing.

They wandered into the road and ambled across four lanes at the pace
they cross a courtyard. That quietly made running one over the world's
fault rather than yours: if people walk into traffic by themselves,
hitting one costs the player nothing. They now keep off the tarmac, and
anyone who does need the other side commits and hurries - holding their
heading, because dithering in the middle of a road is the one thing
nobody does. Measured over four minutes: none of thirty standing in a
road, and crossings covered well over half again the ground per second
that strolling does. A small chance of committing is deliberate; pure
avoidance turns every block into a sealed pen.

They only fled the sight of somebody holding a rifle, not the sound of
one going off. Gunfire now raises an alarm that outlasts the noise by
six seconds, because a crowd that froze the instant the last round was
fired reads as a switch rather than as fear. Running from it beats
keeping off the road - that is the moment people stop being careful.

And traffic drove straight into a parked car and shunted it down the
street, which makes every other vehicle read as weather rather than as
a driver. Civilians now yield to the player as well as to each other.
Parked across a road for ninety seconds: closest approach 9.2m, shoved
zero metres. Unless they have just heard shooting, in which case they
do not wait behind you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:49:38 +02:00

1114 lines
42 KiB
TypeScript

import * as THREE from 'three';
import { generateWorld } from './sim/world';
import {
applyWear,
canOverhaul,
deriveHandling,
freshCondition,
overhaul,
repair,
surfaceFor,
} from './sim/car';
import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
import { baseAt, placeBases } from './sim/bases';
import { buildGraph } from './sim/routing';
import { createIntel, observe, reveal, revealHomeGround, survey } from './sim/intel';
import {
applyMissionImpact,
controlAt,
createFront,
stepFront,
DECAY_MULTIPLIER,
HEAT_MULTIPLIER,
} from './sim/regions';
import { createMinimap } from './ui/minimap';
import {
accept,
createQuests,
failQuest,
MISSION_SHAPE,
offersAt,
stepQuest,
type Offer,
} from './sim/quests';
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
import { createRadio, pollRadio } from './sim/radio';
import { createOpportunities, stepOpportunities } from './sim/opportunities';
import { createHeatProps } from './heatProps';
import { createUnits, dispatchTo, stepUnits } from './sim/units';
import { createCombat, dangerNear, stepCombat } from './sim/combat';
import {
createPursuit,
PROVOCATION,
recognitionMeter,
stepPursuit,
LOSE_SECONDS,
} from './sim/pursuit';
import { createUnitView } from './render/units';
import { createUnitBodies } from './unitBodies';
import { createPersistence } from './persistence';
import { apply, type Snapshot } from './sim/save';
import { makeRng, seedFromString } from './core/rng';
import { startLoop, type LoopHandlers } from './core/loop';
import { exposeDebug } from './debug';
import { createInput } from './core/input';
import { createPhysics } from './physics/physics';
import { createDriveState, drive } from './physics/drive';
import { createScene, updateCamera } from './render/scene';
import { createMarkers } from './render/markers';
import { createHud } from './ui/hud';
import { createBoard } from './ui/board';
import { createAudio } from './audio/audio';
import { CAR, WHEELS } from './carSpec';
import { CURRENCY } from './ui/currency';
/** What the wheel reads while nobody is holding it. */
const DEAD_HANDS = {
throttle: 0,
steer: 0,
handbrake: true,
respawn: false,
select: null,
toggleMute: false,
abandon: false,
overhaul: false,
} as const;
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
export const DEBUG = new URLSearchParams(location.search).has('debug');
/** How far the driver can see well enough to fill in the map, metres. */
const SIGHT_RADIUS = 85;
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
const RESET_HOLD_SECONDS = 5;
/**
* Metres of front line given up for walking away from a job.
*
* Half what dying costs (`KIA_LAND_LOSS`). Deciding a route is not worth it is
* a judgement the game should let you make, but it cannot be free, or the board
* becomes a reroll button: park, dislike the route, drop it, take another.
*/
const ABANDON_LAND_LOSS = 12;
/**
* Seconds spent watching the wreck before you come round somewhere else.
*
* Long enough to register what happened and where it happened, short enough
* that it does not become the thing you dread about dying.
*/
const KIA_HOLD = 5;
/**
* Radians per second the view turns while the camera climbs away from a wreck.
* Slow: this is a drift across the scene, not an orbit around the car.
*/
const WAKE_ORBIT_RATE = 0.22;
/**
* Share of the death cam anyone keeps shooting for.
*
* The burst that killed you should finish — cutting the gunfire on the exact
* frame the car dies looks like the sound broke. After that they stop, because
* standing over a wreck emptying magazines into it reads as the enemy beating a
* dead horse rather than as a firefight ending.
*/
const KIA_CEASE_FIRE = 0.25;
function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed');
if (!raw) return 1337;
const n = Number(raw);
return Number.isFinite(n) ? n >>> 0 : seedFromString(raw);
}
async function boot() {
const seed = resolveSeed();
const model = generateWorld(seed);
const bases = placeBases(model.roads, model.spawn);
const graph = buildGraph(model.roads);
const front = createFront(seed, model.extent, model.spawn);
const physics = await createPhysics(model);
const view = createScene(model);
const markers = createMarkers(view.scene, bases);
const minimap = createMinimap(model.roads, bases, model.extent);
// Base buildings are solid. They are placed off the junction, so this never
// walls off the road the player has to park on.
for (const base of bases) {
physics.addStaticBox({
x: base.hutX,
z: base.hutZ,
yaw: 0,
width: 7,
height: 3.4,
depth: 7,
});
}
const input = createInput();
// Audio is built now but stays silent until the browser lets it speak, which
// is not until the player has actually touched something.
const audio = createAudio();
input.onGesture(() => audio.resume());
const hud = createHud(seed, DEBUG);
const board = createBoard();
const driveState = createDriveState();
const areas = createAreas(model.roads, model.extent);
const heat = createHeat(model.roads, areas);
const heatProps = createHeatProps(model.roads, physics, view.scene);
const intel = createIntel(model.roads, model.extent);
const quests = createQuests();
const radio = createRadio();
const units = createUnits();
const combat = createCombat();
const unitView = createUnitView(view.scene);
const unitBodies = createUnitBodies(physics);
const pursuit = createPursuit();
// Bullets stop at buildings, so combat needs a fast "is this inside a wall"
// lookup. A grid built once at boot beats scanning a thousand obstacles.
const BUILDING_CELL = 24;
const buildingGrid = new Map<string, typeof model.obstacles>();
const cellOf = (v: number) => Math.floor(v / BUILDING_CELL);
const cellKey = (x: number, z: number) => `${cellOf(x)},${cellOf(z)}`;
for (const o of model.obstacles) {
if (o.kind !== 'block') continue;
const reach = Math.hypot(o.width, o.depth) / 2;
// Walk cell indices, not metres. Stepping the world coordinate by
// BUILDING_CELL only ever took one step, because a building's reach (~7m)
// is smaller than a cell (24m) — so every building registered exactly one
// cell and silently vanished from the other three it straddled. Points in
// those cells read as open ground: bullets flew through the wall, line of
// sight saw through it, and people stood inside it.
for (let cx = cellOf(o.x - reach); cx <= cellOf(o.x + reach); cx++) {
for (let cz = cellOf(o.z - reach); cz <= cellOf(o.z + reach); cz++) {
const key = `${cx},${cz}`;
const list = buildingGrid.get(key) ?? [];
list.push(o);
buildingGrid.set(key, list);
}
}
}
const insideBuilding = (x: number, z: number): boolean => {
for (const o of buildingGrid.get(cellKey(x, z)) ?? []) {
const dx = x - o.x;
const dz = z - o.z;
const cos = Math.cos(-o.yaw);
const sin = Math.sin(-o.yaw);
if (Math.abs(dx * cos - dz * sin) < o.width / 2 && Math.abs(dx * sin + dz * cos) < o.depth / 2) {
return true;
}
}
return false;
};
/**
* Can one point see another? Marched rather than swept, at a step short
* enough that the smallest building cannot be stepped over.
*/
const canSee = (from: { x: number; z: number }, to: { x: number; z: number }): boolean => {
const dx = to.x - from.x;
const dz = to.z - from.z;
const distance = Math.hypot(dx, dz);
const steps = Math.ceil(distance / 3);
for (let i = 1; i < steps; i++) {
const t = i / steps;
if (insideBuilding(from.x + dx * t, from.z + dz * t)) return false;
}
return true;
};
/** Physics bodies for finished checkpoint towers, so they are solid cover. */
const towerBodies = new Map<number, ReturnType<typeof physics.addStaticBox>>();
const opportunities = createOpportunities();
const chatterRng = makeRng(seed ^ 0x2c1b3c6d);
let condition = freshCondition();
let elapsed = 0;
let respawnLatch = false;
/** The road being travelled along — includes the verge, not just the tarmac. */
let currentSegment: number | null = null;
let onTarmac = false;
let control = controlAt(front, model.spawn.x, model.spawn.z);
/** Who holds each road right now. Recomputed each step: the line moves. */
const segmentControl = model.roads.segments.map(() => control);
const refreshSegmentControl = () => {
for (const s of model.roads.segments) {
segmentControl[s.id] = controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2);
}
};
refreshSegmentControl();
/** Offers are generated once per arrival at a base, not every frame. */
let openBase: number | null = null;
let offers: Offer[] = [];
let notice = '';
let noticeUntil = 0;
let lastMinimapDraw = -1;
let lastFrontPosition = front.boundaries.liberated;
/** Smoothed, because a single step's movement is far too noisy to react to. */
let driftAverage = 0;
/** Seconds R has been held, toward a full reset. */
let resetHeld = 0;
/** People you have killed who were not part of anyone's war. */
let civilianDeaths = 0;
/** Suspicion earned outright this step by something the player just did. */
let provocation = 0;
/** Times the car has been written off underneath the player. */
let kia = 0;
/** Muzzle flashes from last step, so civilians can react to being shot near. */
let lastGunfire: Array<{ x: number; z: number }> = [];
/** Seconds left of watching the wreck, or 0 when alive. */
let dying = 0;
/** Where the camera has got to in its drift around it. */
let deathAngle = 0;
const say = (text: string, seconds = 4) => {
notice = text;
noticeUntil = elapsed + seconds;
audio.radio();
};
const nodeOf = (id: number) => model.roads.nodes[id]!;
const controlOf = (x: number, z: number) => controlAt(front, x, z);
/** Car yaw about +Y, measured from world +Z, which is the car's forward. */
const headingOf = (): number => {
const r = physics.chassis.rotation();
return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x));
};
// --- Persistence ---
const persistence = createPersistence(
seed,
model.roads.segments.length,
intel.explored.length,
heat.area.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,
kia,
};
};
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;
kia = live.kia;
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,
);
stepFront(front, 0);
refreshSegmentControl();
// A save does not record who was standing where, so barricades on roads
// that were already hot are treated as already built rather than making the
// player wait for a fresh engineer to be sent out.
for (const segment of model.roads.segments) {
if (heat.level[segment.id] === 'barricade' || heat.level[segment.id] === 'turret') {
heatProps.finished(segment.id);
}
}
} else {
// A fresh campaign starts knowing its own ground. You are a local with a
// car, not an amnesiac: the pocket your side holds is the part of the map
// you would obviously know, and the war is the part you would not.
revealHomeGround(intel, heat, model.roads, elapsed, controlOf);
}
// 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();
/**
* The moment the car came apart around you.
*
* The run does not end here — it ends `KIA_HOLD` seconds later. What happens
* in between is nothing: you have no control, the camera pulls off the bumper
* and drifts round the wreck, and the world carries on around it. Cutting
* straight from the crash to a base three hundred metres away read as a bug
* rather than as a death, and the one moment in the campaign worth actually
* looking at was the one moment you were not allowed to see.
*/
const beginDeath = () => {
dying = KIA_HOLD;
deathAngle = 0;
// Nobody is chasing a car that is no longer going anywhere, and being shot
// at while you watch your own wreck is not a thing anyone needs to see.
for (const unit of units.units) unit.hunting = false;
pursuit.hunters.clear();
pursuit.suspicion = 0;
pursuit.alert = 'clear';
pursuit.lastSeen = null;
pursuit.unseenFor = 0;
audio.impact(26000);
say('That is the car finished.', KIA_HOLD);
};
/**
* Coming round somewhere else.
*
* You wake up at whichever base was nearest, in a worse car than the one you
* wrote off, with the job you were carrying written off along with it and the
* ground it would have won handed back. Nothing about this is announced as a
* number — the front moving against you is something you find out about by
* driving back out there.
*/
const finishDeath = () => {
kia++;
const at = physics.chassis.translation();
const home = bases.reduce((best, b) =>
Math.hypot(b.x - at.x, b.z - at.z) < Math.hypot(best.x - at.x, best.z - at.z) ? b : best,
);
const lost = failQuest(quests);
// Ground you had taken is given back. Negative impact, through the same
// path a finished mission uses, so the war moves for one reason only.
applyMissionImpact(front, -KIA_LAND_LOSS);
condition = replacementCar(condition);
physics.chassis.setTranslation({ x: home.x, y: CAR.spawn.y, z: home.z }, true);
physics.chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
captureChassis();
captureChassis();
say(
lost
? `You woke up at ${home.name}. The car did not, and neither did the ${MISSION_SHAPE[
lost.type
].label.toLowerCase()}.`
: `You woke up at ${home.name}. Somebody else is driving what is left of the car.`,
9,
);
persistence.checkpoint(elapsed, snapshot());
};
document.getElementById('boot')?.remove();
const handlers: LoopHandlers = {
fixedUpdate(dt) {
elapsed += dt;
const raw = input.read();
// While the wreck is being looked at, the world carries on and the player
// does not. Everything below still runs — traffic, the war, the front —
// because a death cam over a frozen world is a screenshot, not a moment.
// What stops is the driver.
const dead = dying > 0;
const deathProgress = dead ? 1 - dying / KIA_HOLD : 0;
if (dead) {
dying -= dt;
deathAngle += WAKE_ORBIT_RATE * dt;
if (dying <= 0) {
dying = 0;
finishDeath();
}
}
const cmd = dead ? DEAD_HANDS : raw;
// Tap R to get unstuck; hold it to wipe the campaign and start over.
// A reset has to be hard to do by accident — it throws away every mile
// of wear, every road the enemy has learned, and the whole map you built.
if (cmd.respawn) {
resetHeld += dt;
if (resetHeld >= RESET_HOLD_SECONDS) {
persistence.clear();
location.reload();
return;
}
} else {
resetHeld = 0;
}
if (cmd.respawn && !respawnLatch) physics.respawn();
respawnLatch = cmd.respawn;
// Tarmac or open ground — decided last step, since the road lookup needs
// a position and the car has not moved yet this one.
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
physics.step(dt);
captureChassis();
// Wear is applied from what actually happened this step, not from intent.
const speed = physics.vehicle.currentVehicleSpeed();
const distance = Math.abs(speed) * dt;
// Read once: both the car's wear and any cargo aboard are damaged by the
// same knock, and draining the queue twice would lose one of them.
if (cmd.toggleMute) say(audio.toggleMute() ? 'Sound off.' : 'Sound on.', 2);
const lastImpact = physics.drainImpactForce();
audio.impact(lastImpact);
condition = applyWear(condition, {
dt,
distance,
throttle: Math.abs(cmd.throttle),
impactForce: lastImpact,
});
// Heat: the road remembers being used — including being driven alongside.
// Behind your own lines it remembers nothing: nobody there is watching.
// The war moves whether or not the player is looking at it.
stepFront(front, dt);
refreshSegmentControl();
const at = physics.chassis.translation();
control = controlAt(front, at.x, at.z);
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
const heatChanged = stepHeat(heat, {
dt,
segmentId: currentSegment,
areaId: areaAt(areas, at.x, at.z),
distance,
// 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] *
speedAttention(speed) *
(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]!],
}, areas);
heatProps.sync({ x: at.x, z: at.z });
// Escalation is entirely something the enemy has to *do*. Every level
// sends somebody: a patrol to work the road, an engineer to pour the
// concrete, another to put the tower up. Nothing appears on its own.
for (const id of heatChanged) {
const segment = model.roads.segments[id]!;
const level = heat.level[id]!;
if (level === 'patrol') {
dispatchTo(units, model.roads, graph, segment, 'patrol', chatterRng);
}
if (level === 'barricade') {
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'barricade');
}
if (level === 'turret') {
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'tower');
}
// Cooled back down: whatever is standing there stops being maintained.
if (level === 'clear' || level === 'patrol') heatProps.abandon(id);
}
// Driving a road is how you learn what is on it, and seeing ground is how
// it stops being a blank on the map.
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf);
// A wreck is not parked at a base, whatever the coordinates say.
const base = dead ? null : baseAt(bases, at.x, at.z);
const stopped = Math.abs(speed) < 2.5;
// --- Overhaul: buying back what the car is capable of ---
// Offered whether or not there is a job in hand, since a workshop does
// not care what you are carrying. Deliberately a bad rate next to an
// ordinary repair — this rebuilds what the car *could* be, which is the
// expensive thing you do once patching it up has stopped helping.
if (base && stopped && cmd.overhaul) {
if (!canOverhaul(condition)) {
say('Nothing to rebuild. She is as good as she ever was.', 4);
} else if (quests.funds < 1) {
say(`Nothing to pay with. Bring something back first.`, 4);
} else {
const before = condition.ceiling;
const done = overhaul(condition, quests.funds);
const spent = quests.funds - done.unused;
condition = done.condition;
quests.funds = done.unused;
const gained =
condition.ceiling.engine +
condition.ceiling.tires +
condition.ceiling.chassis -
(before.engine + before.tires + before.chassis);
// A car that has turned a wheel is never *exactly* at factory, so the
// sim will happily sell you a hundredth of a percent. Judge by what
// the player can actually see rather than by what is left to buy.
if (gained < 0.005) {
say('Nothing worth stripping her down for. She is near enough new.', 4);
} else {
say(
`Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
`${(gained * 100).toFixed(0)}% more than she would have.`,
7,
);
audio.ui('accept');
persistence.checkpoint(elapsed, snapshot());
}
}
}
// --- Quest board: only when parked at a base with nothing in hand ---
if (base && stopped && !quests.active) {
if (openBase !== base.nodeId) {
openBase = base.nodeId;
// Seeded by base and runs completed, not by time: leaving and coming
// straight back must not reroll a board you did not like.
offers = offersAt(
quests,
model.roads,
graph,
intel,
base,
elapsed,
seed ^ (base.nodeId * 31) ^ (quests.completed * 7919),
);
board.show(base, offers);
audio.ui('open');
}
const chosen = cmd.select === null ? undefined : offers[cmd.select - 1];
if (chosen) {
accept(quests, chosen, base);
board.hide();
openBase = null;
audio.ui('accept');
say(`${MISSION_SHAPE[chosen.type].label} accepted.`);
// Checkpoint: taking a job is a decision worth not having to make twice.
persistence.checkpoint(elapsed, snapshot());
}
} else if (base && stopped && quests.active && quests.active.stage !== 'return') {
// Parked somewhere friendly with a job you have not started bringing
// home. There has to be a way out of one: a target can end up behind
// concrete there is no way round, and the only exit used to be holding
// R for five seconds, which does not drop the job — it destroys the
// whole campaign. Not offered on the return leg, because that arrival
// is a hand-in and stepQuest is about to pay you for it.
if (openBase !== base.nodeId) {
openBase = base.nodeId;
board.showActive(base, MISSION_SHAPE[quests.active.type].label.toLowerCase());
audio.ui('open');
}
if (cmd.abandon) {
const dropped = failQuest(quests);
// Walking away is cheaper than dying, and it still costs the war
// something. Otherwise the board is a free reroll: park, dislike the
// route, drop it, take another.
applyMissionImpact(front, -ABANDON_LAND_LOSS);
board.hide();
openBase = null;
audio.ui('open');
say(
`${MISSION_SHAPE[dropped!.type].label} dropped. That is ground somebody else has to take back.`,
6,
);
persistence.checkpoint(elapsed, snapshot());
}
} else if (openBase !== null) {
openBase = null;
board.hide();
}
// --- Active mission ---
// Not while you are unconscious: a wreck sitting on the target must not
// quietly finish the job, and stepQuest only asks whether you are stopped.
const quest = dead ? null : quests.active;
if (quest) {
const target = nodeOf(quest.targetNode);
const event = stepQuest(quests, {
dt,
distanceToTarget: Math.hypot(target.x - at.x, target.z - at.z),
atBase: base,
speed: Math.abs(speed),
impactForce: lastImpact,
});
if (event?.kind === 'arrived') {
intel.visitedNodes.add(quest.targetNode);
say(`At the target. Stop the car — ${MISSION_SHAPE[quest.type].working}.`);
} else if (event?.kind === 'cargoDamaged') {
if (event.cargo < 0.5) say('That was the cargo taking the hit, not you.', 3);
} else if (event?.kind === 'worked') {
if (event.type === 'recon') {
const count = survey(
intel,
heat,
model.roads,
target.x,
target.z,
elapsed,
undefined,
controlOf,
);
say(`Survey complete — ${count} roads mapped. Report back to any base.`);
} else if (event.type === 'retrieval') {
say('Loaded. Get it back in one piece — damaged goods pay less.');
} else if (event.type === 'intel') {
say('Transmission sent. Move before anyone triangulates it.');
} else {
say('Cargo delivered. Report back to any base.');
}
} else if (event?.kind === 'completed') {
// The line moves because of what the player did. They are not told by
// how much — they find out by going back and looking.
applyMissionImpact(front, event.impact);
const outcome = repair(condition, quests.funds);
const spent = quests.funds - outcome.unused;
condition = outcome.condition;
quests.funds = outcome.unused;
say(
spent > 0.5
? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
: 'Handed in. Nothing left worth repairing.',
);
// Checkpoint: a finished run is the most expensive thing to lose.
persistence.checkpoint(elapsed, snapshot());
}
}
// --- Work found rather than given ---
const found = stepOpportunities(
opportunities,
{
dt,
now: elapsed,
control,
carX: at.x,
carZ: at.z,
// Nothing turns up for a man who is not going anywhere, and a wreck
// must not collect one by virtue of having stopped moving.
onMission: dead || quests.active !== null,
},
model.roads,
chatterRng,
stopped && !dead,
);
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
else if (found?.kind === 'helped') {
quests.funds += found.opportunity.reward;
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
say(found.opportunity.done, 7);
persistence.checkpoint(elapsed, snapshot());
}
// --- The inhabited world ---
const unitEvents = stepUnits(
units,
{
dt,
now: elapsed,
player: { x: at.x, z: at.z },
front,
heatLevel: (id) => heat.level[id]!,
// A patrol working its road is what actually brings the heat down.
decayHeat: (id, amount) => {
heat.value[id] = Math.max(0, heat.value[id]! - amount);
},
decayArea: (x, z, amount) => {
const cell = areaAt(areas, x, z);
if (cell !== null) heat.area[cell] = Math.max(0, heat.area[cell]! - amount);
},
// Head for the player if they are in view, otherwise for wherever
// they were last seen — which is what makes breaking line of sight
// and then changing direction actually work.
hunt:
pursuit.alert === 'hunted'
? (pursuit.lastSeen ?? { x: at.x, z: at.z })
: null,
// Hunters come round the buildings, not through them. Line of sight
// is already blocked by them; movement has to be too, or "break line
// of sight and change direction" is advice the world does not honour.
blocked: insideBuilding,
// Shooting is resolved further down, so this is last step's. People
// hear it and then move, which is the right way round anyway.
gunfire: lastGunfire,
},
model.roads,
graph,
chatterRng,
);
// Whatever an engineer just finished now exists in the world.
for (const { segment, stage } of unitEvents.built) {
if (stage === 'barricade') {
heatProps.finished(segment);
say('They have put concrete across that road.', 6);
continue;
}
const site = units.checkpoints.get(segment);
if (!site || towerBodies.has(segment)) continue;
towerBodies.set(
segment,
physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }),
);
say('They have finished the tower on that road.', 6);
}
for (const id of unitEvents.removed) {
heatProps.abandon(id);
const body = towerBodies.get(id);
if (!body) continue;
physics.removeBody(body);
towerBodies.delete(id);
}
// --- Hitting things ---
// Vehicles are solid; people are not, so you drive through them rather
// than getting hung up on them.
const contact = unitBodies.sync(units, { x: at.x, z: at.z }, speed, dt);
for (const victim of contact.ranOver) {
audio.impact(26000);
civilianDeaths += victim.faction === 'civilian' ? 1 : 0;
provocation +=
victim.faction === 'civilian'
? PROVOCATION.ranOverCivilian
: PROVOCATION.ranOverSoldier;
say(
victim.faction === 'civilian'
? 'You just put someone under the wheels.'
: 'One of theirs, under the wheels.',
4,
);
}
if (contact.rammed.length > 0) {
provocation += PROVOCATION.ram * contact.rammed.length;
if (elapsed > noticeUntil) say('Metal on metal. Somebody noticed that.', 3);
}
// --- Being noticed ---
// Not while you are dead. Enemies standing over the wreck would otherwise
// build suspicion on it all over again and re-recognise a car that is not
// going anywhere, which is how they ended up shooting at it for five
// solid seconds.
const noticed = dead
? []
: stepPursuit(pursuit, {
dt,
player: { x: at.x, z: at.z },
control,
units,
canSee,
provocation,
});
for (const event of noticed) {
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
if (event.kind === 'lost') say('You lost them.', 5);
if (event.kind === 'distracted' && event.remaining > 0) {
say('Ours have pulled some of them off you.', 4);
}
}
provocation = 0;
// --- Shooting ---
// They shoot at you because they have recognised you, not because of the
// road you happen to be on. Being hunted *is* the state of being a target.
// While dying, exposure runs off the clock rather than off the meter: the
// shooting tails off shortly after the car does.
const exposed = dead ? deathProgress < KIA_CEASE_FIRE : pursuit.alert === 'hunted';
const shooting = stepCombat(
combat,
units,
{
dt,
player: { x: at.x, z: at.z },
playerExposed: exposed,
blocked: insideBuilding,
// Nobody fires at what they cannot see. Rounds already stopped at
// walls; without this the muzzle flashes still came from inside them.
canSee,
},
condition,
chatterRng,
);
condition = shooting.condition;
const listener = { x: at.x, z: at.z, heading: headingOf() };
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
lastGunfire = shooting.fired;
if (shooting.playerHit) {
provocation += PROVOCATION.shotAt;
audio.hit();
if (elapsed > noticeUntil) say('Taking fire.', 2.5);
}
// --- The end of a car ---
// Checked here rather than beside `applyWear`, because being shot at
// damages the chassis too and either route has to be able to finish you.
if (!dead && isWrittenOff(condition)) beginDeath();
// --- Radio ---
// Front drift is measured, not narrated: the chatter about the enemy
// being busy elsewhere fires because the line really is moving.
const drift = (front.boundaries.liberated - lastFrontPosition) / dt;
lastFrontPosition = front.boundaries.liberated;
driftAverage += (drift - driftAverage) * Math.min(1, dt / 3);
if (!notice || elapsed > noticeUntil) {
const line = pollRadio(
radio,
{
now: elapsed,
control,
roadClass: currentSegment === null ? null : model.roads.segments[currentSegment]!.cls,
heatLevel: currentSegment === null ? null : heat.level[currentSegment]!,
condition,
frontDrift: driftAverage,
cargo: quests.active?.cargo ?? null,
carrying: quests.active?.stage === 'return',
},
chatterRng,
);
if (line) say(line, 8);
}
// Continuous audio follows the sim, not the frame rate, so it is driven
// from the fixed step like everything else that has to stay consistent.
const wheels = physics.telemetry();
audio.update({
speed,
throttle: cmd.throttle,
condition,
sideSlip: wheels.sideSlip,
wheelsOnGround: wheels.wheelsOnGround,
control,
listener: { x: at.x, z: at.z, heading: headingOf() },
});
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);
// The minimap has always drawn field contacts; now the world does too.
markers.setOpportunity(opportunities.current);
},
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]!;
const suspension =
physics.vehicle.wheelSuspensionLength(i) ?? CAR.wheel.suspensionRestLength;
pivot.position.y = CAR.wheel.offsetY - suspension;
pivot.rotation.y = physics.vehicle.wheelSteering(i) ?? 0;
pivot.children[0]!.rotation.x = physics.vehicle.wheelRotation(i) ?? 0;
}
// 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();
mesh.position.set(t.x, t.y, t.z);
mesh.quaternion.set(q.x, q.y, q.z, q.w);
}
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
view.followSun();
view.setTone(control, frameDt);
markers.update(elapsed);
updateCamera(
view,
frameDt,
physics.vehicle.currentVehicleSpeed(),
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
);
view.renderer.render(view.scene, view.camera);
const quest = quests.active;
const target = quest ? nodeOf(quest.targetNode) : 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,
opportunity: opportunities.current,
});
}
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
heat: {
segmentId: currentSegment,
onTarmac,
value: currentSegment === null ? 0 : effectiveHeat(heat, areas, currentSegment),
area: heat.area[areaAt(areas, p.x, p.z) ?? 0] ?? 0,
level: currentSegment === null ? null : heat.level[currentSegment]!,
},
quest:
quest && target
? {
type: quest.type,
stage: quest.stage,
novel: quest.novel,
worked: quest.worked,
cargo: quest.cargo,
distance: Math.hypot(target.x - p.x, target.z - p.z),
bearing:
Math.atan2(target.x - p.x, target.z - p.z) -
Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
}
: null,
control,
pursuit: {
meter: recognitionMeter(pursuit),
alert: pursuit.alert,
hunters: pursuit.hunters.size,
// Counts down only once nobody has eyes on you.
losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0,
},
danger: dangerNear(combat, p.x, p.z, 90),
completed: quests.completed,
failed: quests.failed,
kia,
funds: quests.funds,
notice: elapsed < noticeUntil ? notice : '',
resetProgress: resetHeld / RESET_HOLD_SECONDS,
muted: audio.muted,
});
},
};
startLoop(handlers);
if (DEBUG) {
exposeDebug({
handlers,
view,
physics,
// Live state, so a script can find something interesting and go look at it.
state: {
units,
combat,
heat,
areas,
intel,
quests,
front,
model,
bases,
pursuit,
opportunities,
},
condition: () => condition,
/**
* Force the car's condition, for looking at how a wrecked car drives
* without having to spend twenty minutes wrecking one.
*/
setCondition(level: number) {
const clamped = Math.max(0, Math.min(1, level));
condition = {
level: { engine: clamped, tires: clamped, chassis: clamped },
// Raising the ceiling too, so this can undo damage as well as cause
// it. It is a debug lever, not a repair: nothing in the game does this.
ceiling: {
engine: Math.max(clamped, condition.ceiling.engine),
tires: Math.max(clamped, condition.ceiling.tires),
chassis: Math.max(clamped, condition.ceiling.chassis),
},
};
return condition;
},
funds: () => quests.funds,
/** Set the money in hand. Nothing in the game hands out money like this. */
setFunds(amount: number) {
quests.funds = Math.max(0, amount);
return quests.funds;
},
teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
},
info: () => ({
elapsed,
control,
segment: currentSegment,
onTarmac,
funds: quests.funds,
completed: quests.completed,
failed: quests.failed,
kia,
condition,
front: front.boundaries,
opportunity: opportunities.current,
dying: +dying.toFixed(2),
alert: pursuit.alert,
suspicion: +pursuit.suspicion.toFixed(2),
hunters: pursuit.hunters.size,
civilianDeaths,
unitBodies: unitBodies.count,
units: units.units.length,
roles: units.units.reduce<Record<string, number>>((counts, u) => {
counts[u.role] = (counts[u.role] ?? 0) + 1;
return counts;
}, {}),
rounds: combat.rounds.length,
checkpoints: units.checkpoints.size,
notice,
}),
});
}
}
boot().catch((err) => {
console.error(err);
const boot = document.getElementById('boot');
if (boot) boot.textContent = `failed to start: ${err}`;
});