Phase 6: an inhabited world, and a war to drive through

Nothing on a road simply appears any more. A road that turns notorious gets a
patrol dispatched to it: it starts at a junction a few hundred metres away,
drives there, sweeps the road for about a minute, and while it works the road it
brings the heat down — then leaves. Checkpoints are built rather than placed. At
turret level an engineer is sent, the tower rises over forty seconds of someone
standing on site, and a gunner mans it once it stands, up on top so he clears his
own barricade. Let the road cool and the whole thing is abandoned. heat.ts now
only pours concrete; everything living has to arrive.

Civilian traffic routes across the network with somewhere to be, people wander on
foot, and both scatter when shooting starts.

Skirmishes break out between the two armies on contested and occupied ground
regardless of where the player is. Rounds are real objects in flight rather than
instant hits between combatants — that is the point of the feature. If shots
resolved instantly, driving through a battle would cost nothing and "the only
road to the target runs through a firefight" would not be a decision. Strays hit
bystanders, civilians and the player alike; buildings stop them, so cover means
something; hits go through the same wear model as a crash, so being shot costs
ceiling too.

The enemy does not shoot at an undercover driver by default. They open up only
past the line and only on a road they have already checkpointed.

Reload was being counted in stepUnits while firing lived in stepCombat, so combat
stepped on its own never reloaded. A test caught it; ownership now sits with the
module that fires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 15:49:11 +02:00
parent 405f1b0dfe
commit 4feae2c731
11 changed files with 1500 additions and 51 deletions

View File

@ -8,6 +8,7 @@ Endless driving survival game.
- **Phase 3** — regional control: behind your own lines, nobody is watching.
- **Phase 4** — the front line moves, from your work and on its own.
- **Phase 5** — four distinct jobs, radio texture, and work found in the field.
- **Phase 6** — an inhabited world: traffic, people, patrols, and a live war.
## Running
@ -184,6 +185,46 @@ This is what gives the board's reward a reason to matter. Without an economy the
choice between a safe route and a risky one has no stakes; with one, the risky
job is what keeps the car alive a while longer.
## A world with people in it
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
the whole of the enemy. `sim/units.ts` is the layer that makes it inhabited.
**Nothing appears any more — things are sent.** When a road turns notorious a
patrol is *dispatched*: it starts at a junction a few hundred metres away, drives
to the road, sweeps up and down it for about a minute, and while it is working
the road it brings the heat back down. Then it leaves. The enemy is not punishing
you; they are re-securing a route and going home.
**Checkpoints are built.** At `turret` an engineer is dispatched, and the tower
rises over about forty seconds of someone standing on site. Arrive early and you
find a half-built stump and no one on it. Once it stands, a gunner mans it, up on
top so he shoots over his own barricade. Let the road cool and the whole thing is
abandoned.
**Traffic and people.** Civilian cars route across the network with somewhere to
be; people wander on foot near the buildings. Both scatter when shooting starts.
### The war you are driving through
Skirmishes break out between the two armies on contested and occupied ground,
whether or not you are anywhere near. They are not a set piece for the player —
you are not invited.
Rounds are **real objects in flight**, not instant hits resolved between two
combatants. That is the whole point: if shots resolved instantly, driving through
a battle would cost nothing, and "the only road to the target runs through a
firefight" would not be a decision. A stray round does not check whose war it is,
so it will hit a bystander, a civilian, or you.
Buildings stop bullets, which is what makes cover worth anything. Hits are routed
through the same wear model as a crash, so being shot costs you ceiling too — it
is permanent in the same way everything else is.
**The enemy does not shoot at you by default.** You are an undercover driver, not
a target. They open up only past the line *and* on a road they have already
checkpointed — that is, a road you personally made notorious.
## Regional control
The map is cut by a single **front line** into four bands, running from your own
@ -282,6 +323,7 @@ src/
sim/ pure model, no engine imports:
world, roads, heat — the map and its memory
radio, opportunities — texture: chatter and field work
units, combat — traffic, patrols, soldiers, rounds
save — the campaign as JSON
regions — the front line and who holds what
intel, routing, quests — what the player knows and is asked to do
@ -291,6 +333,7 @@ src/
core/ fixed-timestep loop, seeded RNG, keyboard
ui/ debug HUD, quest board, sketch minimap
persistence.ts integration layer: saves in localStorage
debug.ts ?debug=1 handle: step, render and capture frames to shots/
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
heatProps.ts integration layer: heat levels → colliders + meshes
```
@ -374,7 +417,12 @@ If the world instead feels arbitrary, the drift is too fast: lengthen the wave
periods in `sim/regions.ts` before shrinking their amplitude, since it is the
*rate* of change that reads as noise, not the size of it.
Phases 05 are all in. **Phase 6** is the deferred pile: a deeper repair economy
**Phase 6:** the world should feel lived in before it feels dangerous — traffic
and people you have no reason to interact with, and a war that is obviously not
about you. If firefights read as encounters staged for the player, the spacing
and spawn rules in `sim/units.ts` are the dial.
Still deferred: a deeper repair economy
with scavenged parts and on-foot work, car identity and disguise feeding into
heat, and expanded combat. Nothing there should be pulled forward until the loop
below it is proven.

View File

@ -32,6 +32,8 @@ export interface DebugApi {
handlers: LoopHandlers;
view: SceneView;
physics: PhysicsWorld;
/** Live simulation state, deliberately untyped: this is a debug window. */
state: Record<string, unknown>;
info: () => Record<string, unknown>;
teleport: (x: number, z: number) => void;
}
@ -106,6 +108,7 @@ export function exposeDebug(api: DebugApi): void {
teleport: api.teleport,
info: api.info,
state: api.state,
three: THREE,
api,
};

View File

@ -12,9 +12,7 @@ import type { RoadNetwork } from './sim/roads';
import type { PhysicsWorld } from './physics/physics';
const MATERIALS: Record<HeatProp['kind'], THREE.MeshStandardMaterial> = {
patrol: new THREE.MeshStandardMaterial({ color: 0x4b5540, roughness: 0.7 }),
barricade: new THREE.MeshStandardMaterial({ color: 0x9aa0a4, roughness: 0.95 }),
tower: new THREE.MeshStandardMaterial({ color: 0x54493d, roughness: 0.9 }),
};
const BOX = new THREE.BoxGeometry(1, 1, 1);

View File

@ -26,6 +26,9 @@ import {
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 { createUnitView } from './render/units';
import { createPersistence } from './persistence';
import { apply, type Snapshot } from './sim/save';
import { makeRng, seedFromString } from './core/rng';
@ -87,6 +90,43 @@ async function boot() {
const intel = createIntel(model.roads, model.extent);
const quests = createQuests();
const radio = createRadio();
const units = createUnits();
const combat = createCombat();
const unitView = createUnitView(view.scene);
// 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 cellKey = (x: number, z: number) =>
`${Math.floor(x / BUILDING_CELL)},${Math.floor(z / BUILDING_CELL)}`;
for (const o of model.obstacles) {
if (o.kind !== 'block') continue;
const reach = Math.hypot(o.width, o.depth) / 2;
for (let x = o.x - reach; x <= o.x + reach; x += BUILDING_CELL) {
for (let z = o.z - reach; z <= o.z + reach; z += BUILDING_CELL) {
const key = cellKey(x, z);
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;
};
/** 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);
@ -240,8 +280,7 @@ async function boot() {
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;
heatProps.sync(
stepHeat(heat, {
const heatChanged = stepHeat(heat, {
dt,
segmentId: currentSegment,
distance,
@ -256,10 +295,17 @@ async function boot() {
// 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]!],
}),
heat,
currentSegment,
);
});
heatProps.sync(heatChanged, heat, currentSegment);
// Escalation is now something the enemy has to *do*. A road that turns
// notorious gets a patrol sent to it; one that turns worse gets an
// engineer sent to build a checkpoint. Both have to drive here first.
for (const id of heatChanged) {
const level = heat.level[id]!;
if (level === 'patrol') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'patrol', chatterRng);
if (level === 'turret') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'engineer', chatterRng);
}
// 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);
@ -377,6 +423,65 @@ async function boot() {
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);
},
},
model.roads,
graph,
chatterRng,
);
// Finished towers become solid; abandoned ones stop being solid.
for (const id of unitEvents.built) {
const site = units.checkpoints.get(id);
if (!site || towerBodies.has(id)) continue;
towerBodies.set(
id,
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) {
const body = towerBodies.get(id);
if (!body) continue;
physics.removeBody(body);
towerBodies.delete(id);
}
// --- Shooting ---
// The enemy shoots at the player only once a road has been noticed enough
// to be checkpointed, and only past the line. Undercover means undercover.
const exposed =
(control === 'occupied' || control === 'frontier') &&
currentSegment !== null &&
(heat.level[currentSegment] === 'barricade' || heat.level[currentSegment] === 'turret');
const shooting = stepCombat(
combat,
units,
{
dt,
player: { x: at.x, z: at.z },
playerExposed: exposed,
blocked: insideBuilding,
},
condition,
chatterRng,
);
condition = shooting.condition;
if (shooting.playerHit && elapsed > noticeUntil) say('Taking fire.', 2.5);
// --- Radio ---
// Front drift is measured, not narrated: the chatter about the enemy
// being busy elsewhere fires because the line really is moving.
@ -439,6 +544,7 @@ async function boot() {
mesh.quaternion.set(q.x, q.y, q.z, q.w);
}
unitView.update(units, combat.rounds, elapsed);
view.followSun();
view.setTone(control, frameDt);
markers.update(elapsed);
@ -484,6 +590,7 @@ async function boot() {
}
: null,
control,
danger: dangerNear(combat, p.x, p.z, 90),
completed: quests.completed,
parts: quests.parts,
notice: elapsed < noticeUntil ? notice : '',
@ -498,6 +605,8 @@ async function boot() {
handlers,
view,
physics,
// Live state, so a script can find something interesting and go look at it.
state: { units, combat, heat, intel, quests, front, model, bases },
teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
@ -513,6 +622,13 @@ async function boot() {
condition,
front: front.boundaries,
opportunity: opportunities.current,
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,
}),
});

162
src/render/units.ts Normal file
View File

@ -0,0 +1,162 @@
import * as THREE from 'three';
import type { Round } from '../sim/combat';
import type { Unit, UnitState } from '../sim/units';
/**
* Draws the inhabited world: traffic, people, patrols, tower gunners, tracers.
*
* Everything here is instanced. Unit counts swing about constantly as things
* spawn, fight and are cleaned up, and a mesh per unit would mean churning the
* scene graph every frame so each category gets one draw call with a cap, and
* unused instances are parked out of sight rather than removed.
*/
const MAX_UNITS = 160;
const MAX_ROUNDS = 400;
/** Somewhere off the map to park instances that are not in use this frame. */
const HIDDEN = new THREE.Vector3(0, -5000, 0);
/**
* Deliberately high-contrast. At a driver's eye height a person is a few pixels
* of a dark capsule against dark ground the first pass was effectively
* invisible, which defeats the point of putting people in the world at all.
*/
const COLOURS = {
civilianCar: 0x9aa7b4,
civilianPerson: 0xd8c9a4,
enemy: 0xb0704e,
enemySoldier: 0xd08a4a,
insurgent: 0x76c98a,
} as const;
function colourOf(unit: Unit): number {
if (unit.faction === 'civilian') {
return unit.kind === 'car' ? COLOURS.civilianCar : COLOURS.civilianPerson;
}
if (unit.faction === 'insurgent') return COLOURS.insurgent;
return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier;
}
export function createUnitView(scene: THREE.Scene) {
const scratch = new THREE.Matrix4();
const quaternion = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const colour = new THREE.Color();
const cars = new THREE.InstancedMesh(
new THREE.BoxGeometry(1.8, 1.4, 4),
new THREE.MeshStandardMaterial({ roughness: 0.6 }),
MAX_UNITS,
);
cars.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
cars.castShadow = true;
const people = new THREE.InstancedMesh(
// Slightly larger than life, for the same reason the colours are loud.
new THREE.CapsuleGeometry(0.45, 1.4, 4, 8),
new THREE.MeshStandardMaterial({ roughness: 0.8 }),
MAX_UNITS,
);
people.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
people.castShadow = true;
// Tracers: thin stretched boxes along the round's direction of travel.
const tracers = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.12, 0.12, 1),
new THREE.MeshBasicMaterial({ color: 0xffd28a }),
MAX_ROUNDS,
);
// Towers, which only exist once someone has built them.
const towers = new THREE.InstancedMesh(
new THREE.BoxGeometry(3, 6, 3),
new THREE.MeshStandardMaterial({ color: 0x54493d, roughness: 0.9 }),
32,
);
towers.castShadow = true;
// The gun on top, so a finished checkpoint reads as armed at a glance.
const guns = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.35, 0.35, 2.6),
new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 0.5 }),
32,
);
for (const mesh of [cars, people, tracers, towers, guns]) {
mesh.frustumCulled = false;
scene.add(mesh);
}
const park = (mesh: THREE.InstancedMesh, from: number) => {
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
for (let i = from; i < mesh.count; i++) mesh.setMatrixAt(i, scratch);
};
return {
update(units: UnitState, rounds: Round[], elapsed: number) {
let carCount = 0;
let personCount = 0;
for (const unit of units.units) {
const mesh = unit.kind === 'car' ? cars : people;
const index = unit.kind === 'car' ? carCount++ : personCount++;
if (index >= MAX_UNITS) continue;
position.set(unit.x, unit.kind === 'car' ? 0.7 : 0.9, unit.z);
// Garrison gunners stand on their tower rather than beside it.
if (unit.role === 'garrison') position.y = unit.elevation;
quaternion.setFromAxisAngle(up, unit.heading);
scratch.compose(position, quaternion, scale.set(1, 1, 1));
mesh.setMatrixAt(index, scratch);
mesh.setColorAt(index, colour.setHex(colourOf(unit)));
}
park(cars, carCount);
park(people, personCount);
cars.instanceMatrix.needsUpdate = true;
people.instanceMatrix.needsUpdate = true;
if (cars.instanceColor) cars.instanceColor.needsUpdate = true;
if (people.instanceColor) people.instanceColor.needsUpdate = true;
// --- Checkpoints under construction, and finished ones ---
let towerCount = 0;
for (const site of units.checkpoints.values()) {
if (towerCount >= towers.count) break;
const built = site.built;
const height = built ? 1 : Math.max(0.15, site.progress);
position.set(site.x, (6 * height) / 2, site.z);
scratch.compose(position, quaternion.identity(), scale.set(1, height, 1));
towers.setMatrixAt(towerCount, scratch);
// The gun only exists on a finished tower.
if (built) {
position.set(site.x, 6.2, site.z);
quaternion.setFromAxisAngle(up, elapsed * 0.25);
scratch.compose(position, quaternion, scale.set(1, 1, 1));
} else {
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
}
guns.setMatrixAt(towerCount, scratch);
towerCount++;
}
park(towers, towerCount);
park(guns, towerCount);
towers.instanceMatrix.needsUpdate = true;
guns.instanceMatrix.needsUpdate = true;
// --- Tracers ---
let roundCount = 0;
for (const round of rounds) {
if (roundCount >= MAX_ROUNDS) break;
const speed = Math.hypot(round.vx, round.vz);
position.set(round.x, round.y, round.z);
quaternion.setFromAxisAngle(up, Math.atan2(round.vx, round.vz));
// Length scales with speed so a tracer reads as motion, not a stick.
scratch.compose(position, quaternion, scale.set(1, 1, Math.max(2, speed * 0.03)));
tracers.setMatrixAt(roundCount++, scratch);
}
park(tracers, roundCount);
tracers.instanceMatrix.needsUpdate = true;
},
};
}

216
src/sim/combat.ts Normal file
View File

@ -0,0 +1,216 @@
/**
* Shooting. Pure no engine imports.
*
* Rounds are real objects travelling across the world, not instant hits between
* two units. That is deliberate and it is the whole point of the feature: a
* firefight has to be dangerous *to pass through*, not merely dangerous to join.
* If shots resolved instantly between combatants, driving through a battle would
* cost you nothing, and "the only road to the target runs through a firefight"
* would not be a decision.
*/
import type { Rng } from '../core/rng';
import type { CarCondition } from './car';
import { applyWear } from './car';
import { nearestHostile, type Faction, type Unit, type UnitState } from './units';
export interface Round {
x: number;
z: number;
/** Metres per second. */
vx: number;
vz: number;
faction: Faction;
damage: number;
/** Seconds left before it is considered spent. */
ttl: number;
/** Muzzle height, so tower gunners shoot over their own barricade. */
y: number;
}
export interface CombatState {
rounds: Round[];
/** Rounds that hit the player since the last read, for feedback. */
playerHits: number;
}
export const createCombat = (): CombatState => ({ rounds: [], playerHits: 0 });
// --- Tuning ---------------------------------------------------------------
const MUZZLE_SPEED = 260;
const RANGE: Record<'car' | 'soldier', number> = { car: 90, soldier: 120 };
const RELOAD: Record<'car' | 'soldier', number> = { car: 1.5, soldier: 1.1 };
const DAMAGE = 9;
/**
* Aim error in radians. This is the dial that decides how dangerous a battle is
* to a bystander: every missed shot keeps flying.
*/
const SPREAD = 0.11;
/** Radius the car is hit within. Roughly the car, slightly generous. */
const PLAYER_RADIUS = 2.4;
const UNIT_RADIUS = 1.6;
/** Rounds pass over the player unless they were fired at car height. */
const PLAYER_HEIGHT = 1.8;
/**
* How much a hit hurts the car. Rounds are not going to destroy a chassis, but
* they wreck the things that keep you moving which is worse.
*/
const HIT_ENGINE = 0.02;
const HIT_TIRES = 0.03;
const HIT_CHASSIS = 0.012;
export interface CombatStep {
dt: number;
player: { x: number; z: number };
/** True when the enemy has reason to shoot at the player specifically. */
playerExposed: boolean;
/** Blocks a round: buildings stop bullets. */
blocked: (x: number, z: number) => boolean;
}
function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng: Rng): void {
const dx = at.x - from.x;
const dz = at.z - from.z;
const bearing = Math.atan2(dx, dz) + (rng() - 0.5) * 2 * SPREAD;
state.rounds.push({
x: from.x,
z: from.z,
y: from.elevation,
vx: Math.sin(bearing) * MUZZLE_SPEED,
vz: Math.cos(bearing) * MUZZLE_SPEED,
faction: from.faction,
damage: DAMAGE,
ttl: RANGE[from.kind] / MUZZLE_SPEED,
});
from.cooldown = RELOAD[from.kind] * (0.75 + rng() * 0.5);
}
export interface CombatResult {
/** Damage dealt to the player's car this step, if any. */
playerHit: boolean;
condition: CarCondition;
}
export function stepCombat(
state: CombatState,
units: UnitState,
step: CombatStep,
condition: CarCondition,
rng: Rng,
): CombatResult {
const { dt } = step;
let playerHit = false;
let updated = condition;
// --- Who pulls a trigger ---
// Reload is counted here rather than in stepUnits: firing is this module's
// job, and splitting the two meant combat stepped on its own never reloaded.
for (const unit of units.units) {
unit.cooldown = Math.max(0, unit.cooldown - dt);
if (unit.faction === 'civilian' || unit.cooldown > 0) continue;
if (unit.role === 'pedestrian' || unit.role === 'traffic') continue;
const target = nearestHostile(units, unit, RANGE[unit.kind]);
if (target) {
fire(state, unit, target, rng);
continue;
}
// Only the enemy shoots at the player, and only when the player has given
// them a reason. An undercover driver is not a target by default.
if (
unit.faction === 'enemy' &&
step.playerExposed &&
(unit.role === 'garrison' || unit.role === 'patrol') &&
Math.hypot(step.player.x - unit.x, step.player.z - unit.z) < RANGE[unit.kind]
) {
fire(state, unit, step.player, rng);
}
}
// --- Rounds in flight ---
const living: Round[] = [];
for (const round of state.rounds) {
round.ttl -= dt;
if (round.ttl <= 0) continue;
const nx = round.x + round.vx * dt;
const nz = round.z + round.vz * dt;
// Buildings stop bullets, which is what makes cover mean anything.
if (step.blocked(nx, nz)) continue;
let consumed = false;
// Anything of another side standing in the way, including bystanders —
// a stray round does not check whose war it is.
for (const unit of units.units) {
if (unit.faction === round.faction) continue;
if (segmentHits(round.x, round.z, nx, nz, unit.x, unit.z, UNIT_RADIUS)) {
unit.hp -= round.damage;
consumed = true;
break;
}
}
if (
!consumed &&
round.y < PLAYER_HEIGHT + 1.2 &&
segmentHits(round.x, round.z, nx, nz, step.player.x, step.player.z, PLAYER_RADIUS)
) {
consumed = true;
playerHit = true;
state.playerHits++;
// Routed through the same wear model as everything else, so a bullet
// costs you ceiling too — it is permanent in the same way a crash is.
updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
updated = {
level: {
engine: Math.max(0, updated.level.engine - HIT_ENGINE),
tires: Math.max(0, updated.level.tires - HIT_TIRES),
chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS),
},
ceiling: {
engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3),
tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3),
chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3),
},
};
}
if (consumed) continue;
round.x = nx;
round.z = nz;
living.push(round);
}
state.rounds = living;
return { playerHit, condition: updated };
}
/**
* Does the step a round took this frame pass within `radius` of a point?
*
* Checking only the endpoints would let fast rounds skip straight through
* people at 260 m/s a round covers four metres between steps.
*/
export function segmentHits(
x1: number,
z1: number,
x2: number,
z2: number,
px: number,
pz: number,
radius: number,
): boolean {
const dx = x2 - x1;
const dz = z2 - z1;
const lengthSq = dx * dx + dz * dz;
const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lengthSq));
return Math.hypot(px - (x1 + dx * t), pz - (z1 + dz * t)) < radius;
}
/** Rounds currently in the air near a point — used to warn the player. */
export const dangerNear = (state: CombatState, x: number, z: number, radius: number): number =>
state.rounds.filter((r) => Math.hypot(r.x - x, r.z - z) < radius).length;

View File

@ -171,13 +171,13 @@ describe('heat', () => {
describe('heat props', () => {
const segment = roads.segments[0]!;
it('puts nothing on a clear road and escalates from there', () => {
it('builds concrete only, and only once a road is properly notorious', () => {
// Patrols and towers are not built here — they are units that have to
// arrive. Only the barricade is something that can simply be poured.
expect(propsFor(segment, 'clear')).toEqual([]);
const counts = (['patrol', 'barricade', 'turret'] as const).map(
(l) => propsFor(segment, l).length,
);
expect(counts[0]).toBeLessThan(counts[1]!);
expect(counts[1]).toBeLessThan(counts[2]!);
expect(propsFor(segment, 'patrol')).toEqual([]);
expect(propsFor(segment, 'barricade').length).toBeGreaterThan(0);
expect(propsFor(segment, 'turret')).toEqual(propsFor(segment, 'barricade'));
});
it('is stable for a segment, so a checkpoint stays where you left it', () => {
@ -209,11 +209,10 @@ describe('heat props', () => {
}
});
it('keeps patrols on the tarmac and towers off it', () => {
it('keeps every block inside the corridor it is meant to close', () => {
for (const prop of propsFor(segment, 'turret')) {
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
if (prop.kind === 'patrol') expect(distance).toBeLessThan(segment.width / 2);
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
expect(distance).toBeLessThanOrEqual(catchmentOf(segment));
}
});
});

View File

@ -93,6 +93,8 @@ export function stepHeat(state: HeatState, step: HeatStep): number[] {
return changed;
}
export type HeatPropKind = 'barricade';
export interface HeatProp {
x: number;
z: number;
@ -102,16 +104,16 @@ export interface HeatProp {
width: number;
height: number;
depth: number;
kind: 'patrol' | 'barricade' | 'tower';
kind: HeatPropKind;
}
/**
* What a given heat level physically puts on a road.
* What a given heat level physically *builds* on a road.
*
* There is no enemy AI yet, so these are stationary hazards: escalation shows up
* as the road getting harder to drive fast and more expensive to get wrong. That
* is enough to test the phase's real question do you start avoiding a road
* because of its history?
* Only barricades: concrete, which does not have to travel or be manned. The
* living parts of escalation the patrol that drives here, the engineer who
* puts the tower up, the gunner who stands on it are units, in sim/units.ts,
* because they should have to arrive.
*/
export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
if (level === 'clear') return [];
@ -122,19 +124,9 @@ export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
const props: HeatProp[] = [];
const half = segment.width / 2;
// Patrol: parked on the verge. Narrows the road, does not block it.
const patrolT = randRange(rng, 0.3, 0.7);
const patrolSide = rng() < 0.5 ? -1 : 1;
const patrolLateral = patrolSide * (half - 1.4);
props.push({
...pointOnSegment(segment, patrolT, patrolLateral),
lateral: patrolLateral,
width: 2,
height: 1.6,
depth: 4.4,
kind: 'patrol',
});
// Nothing is placed for 'patrol' any more. A patrol is not scenery that
// appears on a road — it is a vehicle dispatched from somewhere that has to
// drive here, work the road for a while, and leave. See sim/units.ts.
if (level === 'patrol') return props;
// Barricade: blocks with a gap you have to slow down for.
@ -162,19 +154,8 @@ export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
});
}
if (level === 'barricade') return props;
// Turret: a tower overlooking the checkpoint. Inert for now — it is a landmark
// that says "this road has been noticed", and something to collide with.
const towerLateral = (rng() < 0.5 ? -1 : 1) * (half + 2.5);
props.push({
...pointOnSegment(segment, barricadeT + 0.06, towerLateral),
lateral: towerLateral,
width: 3,
height: 6,
depth: 3,
kind: 'tower',
});
// The tower is not placed here either. At 'turret' an engineer is dispatched,
// and the tower rises over about forty seconds of someone standing on site
// building it — so you can arrive to find it half-finished and unmanned.
return props;
}

379
src/sim/units.test.ts Normal file
View File

@ -0,0 +1,379 @@
import { describe, expect, it } from 'vitest';
import { makeRng } from '../core/rng';
import { generateWorld } from './world';
import { buildGraph } from './routing';
import { createFront, controlAt } from './regions';
import { createHeat } from './heat';
import { freshCondition } from './car';
import {
createUnits,
dispatchTo,
stepUnits,
PATROL_HEAT_DECAY,
patrolProgress,
type UnitState,
} from './units';
import { createCombat, segmentHits, stepCombat } from './combat';
const world = generateWorld(1337);
const graph = buildGraph(world.roads);
const front = createFront(1337, world.extent, world.spawn);
function run(
state: UnitState,
seconds: number,
overrides: Partial<Parameters<typeof stepUnits>[1]> = {},
rng = makeRng(3),
) {
const events = { built: [] as number[], removed: [] as number[], skirmish: false };
for (let i = 0; i < seconds * 10; i++) {
const step = stepUnits(
state,
{
dt: 0.1,
now: i * 0.1,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'turret',
decayHeat: () => {},
...overrides,
},
world.roads,
graph,
rng,
);
events.built.push(...step.built);
events.removed.push(...step.removed);
events.skirmish ||= step.skirmish;
}
return events;
}
describe('a world with people in it', () => {
it('puts traffic on the roads and people on foot', () => {
const state = createUnits();
run(state, 40);
expect(state.units.filter((u) => u.role === 'traffic').length).toBeGreaterThan(3);
expect(state.units.filter((u) => u.role === 'pedestrian').length).toBeGreaterThan(2);
});
it('does not let the population grow without bound', () => {
const state = createUnits();
run(state, 400);
expect(state.units.filter((u) => u.role === 'traffic').length).toBeLessThanOrEqual(14);
expect(state.units.filter((u) => u.role === 'pedestrian').length).toBeLessThanOrEqual(10);
});
it('cleans up anything that wanders too far from the player', () => {
const state = createUnits();
run(state, 60);
expect(state.units.length).toBeGreaterThan(0);
// Move the player to the far corner. Everything left behind is retired —
// though fresh traffic will of course appear around wherever they now are.
run(state, 30, { player: { x: 100000, z: 100000 } });
const nearOldPosition = state.units.filter(
(u) => Math.hypot(u.x - world.spawn.x, u.z - world.spawn.z) < 5000,
);
expect(nearOldPosition).toHaveLength(0);
});
});
describe('patrols are dispatched, not conjured', () => {
const segment = world.roads.segments[4]!;
it('starts somewhere else and has to drive to the road', () => {
const state = createUnits();
const unit = dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7))!;
expect(unit).not.toBeNull();
// It begins well away from its assignment, with a route to follow.
expect(Math.hypot(unit.x - segment.ax, unit.z - segment.az)).toBeGreaterThan(140);
expect(unit.path.length).toBeGreaterThan(0);
expect(unit.assigned).toBe(segment.id);
});
it('does not send a second patrol to a road that already has one coming', () => {
const state = createUnits();
expect(dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7))).not.toBeNull();
expect(dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7))).toBeNull();
});
it('arrives, works the road, and cools it down', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
let cooled = 0;
run(state, 120, {
player: { x: segment.ax, z: segment.az },
decayHeat: (id, amount) => {
expect(id).toBe(segment.id);
cooled += amount;
},
});
// A patrol on station is the thing that brings heat back down.
expect(cooled).toBeGreaterThan(PATROL_HEAT_DECAY * 20);
});
it('leaves once it has done its rounds', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
run(state, 260, { player: { x: segment.ax, z: segment.az } });
expect(state.units.some((u) => u.role === 'patrol')).toBe(false);
// And the road is free to be patrolled again later.
expect(state.dispatched.has(segment.id)).toBe(false);
});
it('sweeps back and forth rather than parking', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'patrol', makeRng(7));
run(state, 60, { player: { x: segment.ax, z: segment.az } });
const patrol = state.units.find((u) => u.role === 'patrol');
if (!patrol) return;
const seen = new Set<string>();
for (let i = 0; i < 200; i++) {
run(state, 1, { player: { x: segment.ax, z: segment.az } });
const live = state.units.find((u) => u.role === 'patrol');
if (live) seen.add(patrolProgress(live, segment).toFixed(1));
}
expect(seen.size).toBeGreaterThan(2);
});
});
describe('checkpoints have to be built', () => {
const segment = world.roads.segments[6]!;
it('does not exist until an engineer has stood there long enough', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
// Nothing is standing yet.
expect(state.checkpoints.size).toBe(0);
const events = run(state, 200, { player: { x: segment.ax, z: segment.az } });
expect(events.built).toContain(segment.id);
const site = state.checkpoints.get(segment.id)!;
expect(site.built).toBe(true);
});
it('is manned once it stands', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
run(state, 200, { player: { x: segment.ax, z: segment.az } });
const gunner = state.units.find((u) => u.role === 'garrison');
expect(gunner).toBeDefined();
// Up on the tower, so it shoots over its own barricade.
expect(gunner!.elevation).toBeGreaterThan(3);
});
it('is abandoned when the road stops being worth guarding', () => {
const state = createUnits();
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
run(state, 200, { player: { x: segment.ax, z: segment.az } });
expect(state.checkpoints.size).toBe(1);
const events = run(state, 5, {
player: { x: segment.ax, z: segment.az },
heatLevel: () => 'clear',
});
expect(events.removed).toContain(segment.id);
expect(state.checkpoints.size).toBe(0);
expect(state.units.some((u) => u.role === 'garrison')).toBe(false);
});
});
describe('a war going on regardless', () => {
it('breaks out fights between the two armies', () => {
const state = createUnits();
const events = run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21));
expect(events.skirmish).toBe(true);
const fighters = state.units.filter((u) => u.role === 'fighter');
expect(fighters.length).toBeGreaterThan(1);
expect(new Set(fighters.map((f) => f.faction)).size).toBe(2);
});
it('only fights over ground that is actually contested', () => {
const state = createUnits();
run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21));
for (const fighter of state.units.filter((u) => u.role === 'fighter')) {
const control = controlAt(front, fighter.x, fighter.z);
// Spawned where the war is — they may drift, but not from behind a line.
expect(['contested', 'occupied']).toContain(control);
}
});
});
describe('rounds in flight', () => {
it('catches a target the round passed straight through between steps', () => {
// At 260 m/s a round covers four metres a step; endpoint checks would miss.
expect(segmentHits(0, 0, 10, 0, 5, 0.5, 1.6)).toBe(true);
expect(segmentHits(0, 0, 10, 0, 5, 4, 1.6)).toBe(false);
});
const shooters = () => {
const state = createUnits();
for (const [faction, x] of [
['enemy', 0],
['insurgent', 30],
] as const) {
state.units.push({
id: state.nextId++,
kind: 'soldier',
faction,
role: 'fighter',
x,
z: 0,
heading: 0,
speed: 0,
hp: 30,
path: [],
expires: 999,
assigned: null,
onStation: 0,
cooldown: 0,
elevation: 1.2,
});
}
return state;
};
it('puts rounds in the air when two sides are in range', () => {
const units = shooters();
const combat = createCombat();
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false },
freshCondition(),
makeRng(1),
);
expect(combat.rounds.length).toBeGreaterThan(0);
});
it('lets a stray round hit a bystander who never joined in', () => {
const units = shooters();
const combat = createCombat();
const rng = makeRng(5);
let condition = freshCondition();
let hit = false;
// The player parked directly between two squads shooting at each other.
for (let i = 0; i < 600 && !hit; i++) {
const result = stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false },
condition,
rng,
);
condition = result.condition;
hit ||= result.playerHit;
}
expect(hit).toBe(true);
// And it costs something permanent, like every other kind of damage.
expect(condition.ceiling.tires).toBeLessThan(1);
});
it('stops rounds at walls, so cover is worth something', () => {
const units = shooters();
const combat = createCombat();
let condition = freshCondition();
let hit = false;
for (let i = 0; i < 600 && !hit; i++) {
const result = stepCombat(
combat,
units,
{
dt: 1 / 60,
player: { x: 15, z: 0 },
playerExposed: false,
// Walls between the player and each squad, so nothing has a line.
blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22),
},
condition,
makeRng(5),
);
condition = result.condition;
hit ||= result.playerHit;
}
expect(hit).toBe(false);
});
it('does not shoot at an undercover driver who has done nothing', () => {
const units = createUnits();
units.units.push({
id: 1,
kind: 'soldier',
faction: 'enemy',
role: 'garrison',
x: 0,
z: 0,
heading: 0,
speed: 0,
hp: 60,
path: [],
expires: 999,
assigned: 0,
onStation: 0,
cooldown: 0,
elevation: 5,
});
const combat = createCombat();
for (let i = 0; i < 300; i++) {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false },
freshCondition(),
makeRng(9),
);
}
expect(combat.rounds.length).toBe(0);
});
it('does shoot once the player is a known quantity on that road', () => {
const units = createUnits();
units.units.push({
id: 1,
kind: 'soldier',
faction: 'enemy',
role: 'garrison',
x: 0,
z: 0,
heading: 0,
speed: 0,
hp: 60,
path: [],
expires: 999,
assigned: 0,
onStation: 0,
cooldown: 0,
elevation: 5,
});
const combat = createCombat();
let fired = 0;
for (let i = 0; i < 300; i++) {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false },
freshCondition(),
makeRng(9),
);
// Counted as they are fired: rounds expire in under half a second, so
// checking the tally at the end would find an empty sky.
fired = Math.max(fired, combat.rounds.length);
}
expect(fired).toBeGreaterThan(0);
});
});
describe('heat and units together', () => {
it('leaves heat alone when no patrol has arrived', () => {
const heat = createHeat(world.roads);
heat.value[4] = 0.5;
const state = createUnits();
dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7));
// Still driving here; nothing has been re-secured yet.
run(state, 3, {
player: { x: world.spawn.x, z: world.spawn.z },
decayHeat: () => expect.unreachable('patrol has not arrived yet'),
});
expect(heat.value[4]).toBe(0.5);
});
});

544
src/sim/units.ts Normal file
View File

@ -0,0 +1,544 @@
/**
* Everything in the world that moves and has a side. Pure no engine imports.
*
* Up to now the world was scenery: heat put concrete on a road and that was the
* whole of the enemy. This is the layer that makes it inhabited traffic that
* has somewhere to be, people on foot, patrols that are *dispatched* to a road
* because of what you did to it, checkpoints that have to be built before they
* exist, and two armies fighting each other whether or not you are watching.
*
* The player is not one of these. They are a car driving through it.
*/
import type { Rng } from '../core/rng';
import type { RoadNetwork, RoadSegment } from './roads';
import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads';
import { findRoute, travelTime, type Graph } from './routing';
import type { Control, Front } from './regions';
import { controlAt } from './regions';
export type Faction = 'enemy' | 'insurgent' | 'civilian';
export type UnitKind = 'car' | 'soldier';
export type Role =
/** Civilian traffic with somewhere to be. */
| 'traffic'
/** Civilians on foot, near buildings. */
| 'pedestrian'
/** Dispatched to a road because that road got noticed. */
| 'patrol'
/** Sent to build a checkpoint, then leaves. */
| 'engineer'
/** Stands on a finished checkpoint and shoots. */
| 'garrison'
/** Fighting the other army. */
| 'fighter';
export interface Unit {
id: number;
kind: UnitKind;
faction: Faction;
role: Role;
x: number;
z: number;
heading: number;
speed: number;
hp: number;
/** Remaining node ids to drive through. */
path: number[];
/** Seconds before this unit gives up and leaves. */
expires: number;
/** Segment a patrol or engineer was sent to. */
assigned: number | null;
/** Time spent doing the job at the assignment. */
onStation: number;
/** Seconds until this unit can fire again. */
cooldown: number;
/** Height of the muzzle, so a gunner on a tower shoots over the barricade. */
elevation: number;
}
export interface Checkpoint {
segment: number;
/** 0..1. A tower does not exist until someone builds it. */
progress: number;
built: boolean;
x: number;
z: number;
}
export interface UnitState {
units: Unit[];
checkpoints: Map<number, Checkpoint>;
/** Segments a patrol is already on the way to, so we do not send five. */
dispatched: Set<number>;
nextId: number;
lastSkirmishAt: number;
}
export const createUnits = (): UnitState => ({
units: [],
checkpoints: new Map(),
dispatched: new Set(),
nextId: 1,
lastSkirmishAt: -999,
});
// --- Tuning ---------------------------------------------------------------
/** How many civilian vehicles try to exist near the player. */
const TRAFFIC_TARGET = 14;
const PEDESTRIAN_TARGET = 10;
/** Units further than this from the player are despawned; the world is big. */
export const SIM_RADIUS = 420;
/** Civilians keep clear of the shooting. */
const CIVILIAN_FLEE_RANGE = 70;
const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
/** Seconds a dispatched patrol works its road before leaving. */
const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */
export const PATROL_HEAT_DECAY = 0.012;
/** Seconds of an engineer standing on site to finish a checkpoint. */
const BUILD_SECONDS = 40;
/** Minimum gap between skirmishes breaking out. */
const SKIRMISH_SPACING = 55;
export const UNIT_HP: Record<UnitKind, number> = { car: 60, soldier: 30 };
// --- Helpers --------------------------------------------------------------
const distance = (a: { x: number; z: number }, b: { x: number; z: number }) =>
Math.hypot(a.x - b.x, a.z - b.z);
function makeUnit(state: UnitState, unit: Omit<Unit, 'id'>): Unit {
const made = { ...unit, id: state.nextId++ };
state.units.push(made);
return made;
}
const nodeById = (roads: RoadNetwork, id: number) => roads.nodes[id]!;
/** Nearest node to a point, for putting a unit onto the network. */
function nearestNode(roads: RoadNetwork, x: number, z: number): number {
let best = roads.nodes[0]!;
let bestDistance = Infinity;
for (const n of roads.nodes) {
const d = Math.hypot(n.x - x, n.z - z);
if (d < bestDistance) {
bestDistance = d;
best = n;
}
}
return best.id;
}
/** Steps a unit along its path. Returns true when the path is exhausted. */
function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
const next = unit.path[0];
if (next === undefined) return true;
const node = nodeById(roads, next);
const dx = node.x - unit.x;
const dz = node.z - unit.z;
const remaining = Math.hypot(dx, dz);
if (remaining < 3) {
unit.path.shift();
return unit.path.length === 0;
}
unit.heading = Math.atan2(dx, dz);
const move = Math.min(remaining, unit.speed * dt);
unit.x += (dx / remaining) * move;
unit.z += (dz / remaining) * move;
return false;
}
function routeTo(graph: Graph, roads: RoadNetwork, from: number, to: number): number[] {
return findRoute(graph, from, to, travelTime(roads))?.nodes.slice(1) ?? [];
}
// --- Spawning -------------------------------------------------------------
/**
* Civilian traffic. Picks somewhere to be and drives there, then picks
* somewhere else. It exists to make the roads feel used by anyone other than
* you and, incidentally, to make a road with *no* traffic on it feel wrong.
*/
function spawnTraffic(
state: UnitState,
roads: RoadNetwork,
graph: Graph,
near: { x: number; z: number },
rng: Rng,
): void {
const candidates = roads.nodes.filter((n) => {
const d = distance(n, near);
// Far enough to arrive rather than pop into view.
return d > 120 && d < SIM_RADIUS;
});
if (candidates.length < 2) return;
const from = candidates[Math.floor(rng() * candidates.length)]!;
const to = candidates[Math.floor(rng() * candidates.length)]!;
const path = routeTo(graph, roads, from.id, to.id);
if (path.length === 0) return;
makeUnit(state, {
kind: 'car',
faction: 'civilian',
role: 'traffic',
x: from.x,
z: from.z,
heading: 0,
speed: SPEED.car * (0.7 + rng() * 0.4),
hp: UNIT_HP.car,
path,
expires: 600,
assigned: null,
onStation: 0,
cooldown: 0,
elevation: 1,
});
}
function spawnPedestrian(state: UnitState, near: { x: number; z: number }, rng: Rng): void {
const angle = rng() * Math.PI * 2;
const radius = 60 + rng() * 200;
makeUnit(state, {
kind: 'soldier',
faction: 'civilian',
role: 'pedestrian',
x: near.x + Math.cos(angle) * radius,
z: near.z + Math.sin(angle) * radius,
heading: rng() * Math.PI * 2,
speed: SPEED.soldier * (0.5 + rng() * 0.5),
hp: UNIT_HP.soldier,
path: [],
expires: 400,
assigned: null,
onStation: 0,
cooldown: 0,
elevation: 1.2,
});
}
/**
* The point of this whole file: heat on a road is no longer a thing that simply
* appears. Something is *sent*, from somewhere, and it has to arrive.
*/
export function dispatchTo(
state: UnitState,
roads: RoadNetwork,
graph: Graph,
segment: RoadSegment,
role: 'patrol' | 'engineer',
rng: Rng,
): Unit | null {
if (state.dispatched.has(segment.id)) return null;
// Come from a junction well behind the segment, deeper into enemy ground, so
// the patrol arrives from somewhere plausible rather than materialising.
const origins = roads.nodes.filter((n) => {
const d = Math.hypot(n.x - segment.ax, n.z - segment.az);
return d > 150 && d < 600;
});
if (origins.length === 0) return null;
const origin = origins[Math.floor(rng() * origins.length)]!;
const path = routeTo(graph, roads, origin.id, segment.a);
if (path.length === 0) return null;
state.dispatched.add(segment.id);
return makeUnit(state, {
kind: 'car',
faction: 'enemy',
role,
x: origin.x,
z: origin.z,
heading: 0,
speed: SPEED.car * (role === 'engineer' ? 0.8 : 1),
hp: UNIT_HP.car,
path,
expires: 400,
assigned: segment.id,
onStation: 0,
cooldown: 1,
elevation: 1,
});
}
/** Two squads run into each other. The player is not invited. */
function spawnSkirmish(
state: UnitState,
roads: RoadNetwork,
near: { x: number; z: number },
front: Front,
rng: Rng,
): boolean {
const candidates = roads.nodes.filter((n) => {
const d = distance(n, near);
if (d < 150 || d > SIM_RADIUS) return false;
const control = controlAt(front, n.x, n.z);
// Fights happen where the war is, not behind either side's lines.
return control === 'contested' || control === 'occupied';
});
if (candidates.length === 0) return false;
const at = candidates[Math.floor(rng() * candidates.length)]!;
const separation = 34;
const angle = rng() * Math.PI * 2;
for (const [faction, side] of [
['enemy', 1],
['insurgent', -1],
] as const) {
const count = 2 + Math.floor(rng() * 3);
for (let i = 0; i < count; i++) {
makeUnit(state, {
kind: 'soldier',
faction,
role: 'fighter',
x: at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16,
z: at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16,
heading: angle + (side > 0 ? Math.PI : 0),
speed: SPEED.soldier,
hp: UNIT_HP.soldier,
path: [],
expires: 150 + rng() * 90,
assigned: null,
onStation: 0,
cooldown: rng(),
elevation: 1.2,
});
}
}
return true;
}
// --- The step -------------------------------------------------------------
export interface UnitStep {
dt: number;
now: number;
player: { x: number; z: number };
front: Front;
/** Current heat level per segment, to decide what should be dispatched. */
heatLevel: (segmentId: number) => string;
/** Called when a patrol is working a road, to cool it down. */
decayHeat: (segmentId: number, amount: number) => void;
}
export interface UnitEvents {
/** Checkpoints finished this step, so the renderer can build them. */
built: number[];
/** Checkpoints abandoned, so the renderer can take them away. */
removed: number[];
skirmish: boolean;
}
export function stepUnits(
state: UnitState,
step: UnitStep,
roads: RoadNetwork,
graph: Graph,
rng: Rng,
): UnitEvents {
const events: UnitEvents = { built: [], removed: [], skirmish: false };
const { dt, player } = step;
// --- Population: only simulate what is near enough to matter ---
const civilianCars = state.units.filter((u) => u.role === 'traffic').length;
const pedestrians = state.units.filter((u) => u.role === 'pedestrian').length;
if (civilianCars < TRAFFIC_TARGET && rng() < dt * 2) {
spawnTraffic(state, roads, graph, player, rng);
}
if (pedestrians < PEDESTRIAN_TARGET && rng() < dt * 2) {
spawnPedestrian(state, player, rng);
}
// --- A war going on regardless of the player ---
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
if (spawnSkirmish(state, roads, player, step.front, rng)) {
state.lastSkirmishAt = step.now;
events.skirmish = true;
}
}
// --- Per-unit behaviour ---
for (const unit of state.units) {
unit.expires -= dt;
switch (unit.role) {
case 'traffic': {
if (advance(unit, roads, dt)) {
const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!;
unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id);
if (unit.path.length === 0) unit.expires = 0;
}
break;
}
case 'pedestrian': {
// A slow wander. People are not going anywhere in particular.
if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
unit.x += Math.sin(unit.heading) * unit.speed * dt;
unit.z += Math.cos(unit.heading) * unit.speed * dt;
break;
}
case 'patrol':
case 'engineer': {
const segment = unit.assigned === null ? null : roads.segments[unit.assigned];
if (!segment) {
unit.expires = 0;
break;
}
if (unit.path.length > 0) {
advance(unit, roads, dt);
break;
}
// On station. Drive the road back and forth.
unit.onStation += dt;
const sweep = (Math.sin(unit.onStation * 0.28) + 1) / 2;
const point = pointOnSegment(segment, sweep, 0);
const dx = point.x - unit.x;
const dz = point.z - unit.z;
const gap = Math.hypot(dx, dz);
if (gap > 1) {
unit.heading = Math.atan2(dx, dz);
const move = Math.min(gap, unit.speed * 0.45 * dt);
unit.x += (dx / gap) * move;
unit.z += (dz / gap) * move;
}
if (unit.role === 'patrol') {
// Working the road is what brings it back down. The enemy is not
// punishing you; they are re-securing a route and then leaving.
step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt);
if (unit.onStation > PATROL_DURATION) unit.expires = 0;
} else {
const site = state.checkpoints.get(segment.id) ?? {
segment: segment.id,
progress: 0,
built: false,
x: point.x,
z: point.z,
};
state.checkpoints.set(segment.id, site);
if (!site.built) {
site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS);
if (site.progress >= 1) {
site.built = true;
events.built.push(segment.id);
// Someone has to man it once it stands.
const post = pointOnSegment(segment, 0.5, segment.width / 2 + 2.5);
makeUnit(state, {
kind: 'soldier',
faction: 'enemy',
role: 'garrison',
x: post.x,
z: post.z,
heading: post.yaw,
speed: 0,
hp: UNIT_HP.soldier * 2,
path: [],
expires: 1e9,
assigned: segment.id,
onStation: 0,
cooldown: 0,
// Up on the tower, shooting over its own barricade.
elevation: 5,
});
unit.expires = 0;
}
}
}
break;
}
case 'fighter': {
// Close on the nearest enemy of the other army and stand your ground.
const enemy = nearestHostile(state, unit, 140);
if (enemy) {
const gap = distance(unit, enemy);
unit.heading = Math.atan2(enemy.x - unit.x, enemy.z - unit.z);
if (gap > 45) {
unit.x += Math.sin(unit.heading) * unit.speed * dt;
unit.z += Math.cos(unit.heading) * unit.speed * dt;
}
}
break;
}
case 'garrison':
break;
}
}
// --- Civilians get out of the way of a firefight ---
for (const unit of state.units) {
if (unit.faction !== 'civilian') continue;
const danger = state.units.find(
(other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE,
);
if (!danger) continue;
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
unit.x += Math.sin(away) * unit.speed * 1.4 * dt;
unit.z += Math.cos(away) * unit.speed * 1.4 * dt;
}
// --- Checkpoints whose road has cooled off are abandoned ---
for (const [segmentId, site] of state.checkpoints) {
if (step.heatLevel(segmentId) === 'turret') continue;
state.checkpoints.delete(segmentId);
events.removed.push(segmentId);
for (const unit of state.units) {
if (unit.role === 'garrison' && unit.assigned === segmentId) unit.expires = 0;
}
void site;
}
// --- Retire the dead, the finished and the far away ---
const survivors: Unit[] = [];
for (const unit of state.units) {
const tooFar = distance(unit, player) > SIM_RADIUS * 1.3;
if (unit.hp <= 0 || unit.expires <= 0 || tooFar) {
if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) {
state.dispatched.delete(unit.assigned);
}
continue;
}
survivors.push(unit);
}
state.units = survivors;
return events;
}
/** Nearest unit of a faction this one would shoot at. */
export function nearestHostile(state: UnitState, unit: Unit, range: number): Unit | null {
let best: Unit | null = null;
let bestDistance = range;
for (const other of state.units) {
if (other.faction === unit.faction || other.faction === 'civilian') continue;
if (unit.faction === 'civilian') continue;
const d = distance(unit, other);
if (d < bestDistance) {
bestDistance = d;
best = other;
}
}
return best;
}
/** Which side holds the ground a unit is standing on. Used for spawn rules. */
export const controlOfUnit = (front: Front, unit: Unit): Control =>
controlAt(front, unit.x, unit.z);
/** How far along its assigned road a patrol currently is, for tests. */
export const patrolProgress = (unit: Unit, segment: RoadSegment): number =>
projectOntoSegment(segment, unit.x, unit.z).t;
export const roadSpeedOf = (segment: RoadSegment): number => ROAD_SPEED[segment.cls];

View File

@ -37,6 +37,8 @@ export interface HudModel {
bearing: number;
} | null;
control: Control;
/** Rounds in the air nearby. Not a health bar — a reason to keep moving. */
danger: number;
completed: number;
parts: number;
notice: string;
@ -98,6 +100,7 @@ export function createHud(seed: number) {
...(model.quest ? questLines(model.quest) : ['no mission — find a base']),
'',
CONTROL_WORDS[model.control],
...(model.danger > 0 ? [`⚠ rounds in the air nearby (${model.danger})`] : []),
'',
// Debug only. The real game signals heat through the road itself —
// see the design brief: no numeric meter ships.