drive-between-the-lines/src/sim/units.ts
dejvino baf534c4c4 Your own people, standing in your own streets
Liberated territory read as empty. No patrols by design, and nobody
else either - so the safest part of the map was also the deadest, and
coming home felt like arriving nowhere rather than arriving somewhere
held. It also left the palette doing all the work of telling the bands
apart, when the population is the more legible signal: who is standing
in the street tells you whose street it is.

Militia mirror AMBIENT_PATROLS the other way up - thick at home,
thinning fast, none past the contested band, because a friendly face on
the frontier would be a safety net the setting is not supposed to have.
Driving across the map now reads 14, 14, 11, 5, 0 against enemies 0, 0,
3, 4, 4.

They are placed by checking who holds the ground they would stand on
rather than the band the player is in - near a border those are
different answers - and they leave when the line moves over them, or
when the player has driven far enough that they are only ambience
following him about.

There is a quieter consequence. Pursuit already called a chase off when
a hunter had insurgents on its doorstep, so running for your own lines
now genuinely works. The rule existed; there was nobody around to
trigger it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:27:54 +02:00

871 lines
30 KiB
TypeScript

/**
* 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'
/** Your own side, holding the ground it holds. Thick at home, thin at the line. */
| 'militia';
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;
/** What an engineer was sent to put up. */
building?: BuildStage;
/** Set while this unit is chasing the player. Owned by sim/pursuit.ts. */
hunting?: boolean;
/** Current speed while chasing, wound up from a standing start. */
chaseSpeed?: number;
}
/**
* What can be put up on a road, in the order it goes up.
*
* Both stages are *work*. Concrete does not pour itself the instant a road
* becomes notorious any more than a tower does — someone has to be sent, arrive,
* and stand there while it goes up.
*/
export type BuildStage = 'barricade' | 'tower';
export interface Checkpoint {
segment: number;
/** What is going up right now. */
stage: BuildStage;
/** 0..1 toward the current stage. */
progress: number;
/** Stages finished and standing. */
done: BuildStage[];
x: number;
z: number;
}
export const hasBuilt = (site: Checkpoint | undefined, stage: BuildStage): boolean =>
site?.done.includes(stage) ?? false;
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. */
export const TRAFFIC_TARGET = 34;
export const PEDESTRIAN_TARGET = 30;
/** Units further than this from the player are despawned; the world is big. */
export const SIM_RADIUS = 640;
/** Civilians keep clear of the shooting. */
const CIVILIAN_FLEE_RANGE = 70;
const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
/**
* How fast a car drives when it is chasing you rather than working.
*
* A patrol on its rounds does 14 m/s. A healthy player tops out around 29, so
* at ordinary speed a chase was won by holding W until the 95m sight radius
* did the rest — there was no decision in it anywhere.
*
* 21 m/s sits deliberately between the two: faster than anyone actually
* averages through a grid of buildings and junctions, slower than the car's
* top end on a clear run. So a long trunk straight is an escape and the lanes
* are not, which puts the road hierarchy under real pressure for the first
* time — the fast conspicuous road becomes the one you *want* when they are
* behind you, and it is the one they will look on next time.
*/
const HUNT_SPEED = 21;
/** How quickly a hunter winds up to it, m/s². Nobody is at chase speed at once. */
const HUNT_ACCELERATION = 7;
/**
* Headings a hunter will try, in order, when the straight line is blocked:
* dead ahead, then further and further round either side of the obstruction.
* Alternating sides means they take whichever way round is actually open
* rather than committing to a direction and grinding along a wall.
*/
const HUNT_DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9];
/** 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.005;
/**
* District heat a patrol settles per second, just by being present.
*
* An order of magnitude below the road figure, on purpose. A patrol re-secures
* the road it was sent to; it barely touches how the district feels about you.
* If this were anywhere near the road rate, sitting still while patrols came and
* went would quietly launder your reputation, and the whole point of district
* heat is that it is the thing you cannot patrol away.
*/
export const PATROL_AREA_DECAY = 0.0005;
/**
* Seconds of an engineer standing on site per stage. Concrete is quicker than a
* tower, but neither is instant — arriving mid-build is meant to be a thing that
* happens to you.
*/
const BUILD_SECONDS: Record<BuildStage, number> = { barricade: 22, tower: 40 };
/** Minimum gap between skirmishes breaking out. */
const SKIRMISH_SPACING = 55;
/**
* Standing patrols the enemy keeps on the roads near the player, by whose
* ground it is.
*
* These are not a reaction to anything the player did. Occupied territory is
* patrolled because it is occupied, and the deeper in you go the more of it
* there is. Heat-triggered patrols arrive on top of this.
*/
export const AMBIENT_PATROLS: Record<Control, number> = {
liberated: 0,
contested: 1,
occupied: 3,
frontier: 5,
};
/**
* Your own people on the ground, by whose ground it is.
*
* The mirror of AMBIENT_PATROLS, and deliberately the other way up. Liberated
* territory read as empty — no patrols by design, and nobody else either — so
* the safest part of the map was also the deadest, and coming home felt like
* arriving nowhere rather than arriving somewhere held. It also made the
* palette do all the work of telling the bands apart, when the population is
* the more legible signal: who is standing in the street tells you whose street
* it is.
*
* Thick at home and thinning fast, so the gradient itself is information. Past
* the contested band there are none: that is what "past the line" means, and a
* friendly face on the frontier would be a safety net the place is not supposed
* to have.
*
* There is a second, quieter consequence. Pursuit calls a chase off when a
* hunter has insurgents on its doorstep, so running for your own lines now
* genuinely works — the closer you get to home the more likely they peel off.
* That was already the rule; there was simply nobody around to trigger it.
*/
export const AMBIENT_ALLIES: Record<Control, number> = {
liberated: 14,
contested: 5,
occupied: 0,
frontier: 0,
};
/**
* How far from the player a militiaman is still worth simulating.
*
* Far tighter than SIM_RADIUS, and past the fog in every band, so they leave
* out of sight rather than popping. They are ambience with no job to travel
* to: keeping them to the general cull radius meant a crowd of friendly faces
* followed the player half a kilometre into occupied ground.
*/
const MILITIA_RADIUS = 380;
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;
}
/**
* Node list for a journey, *including* the node it starts from.
*
* Dropping the start node looked harmless and was not. A unit re-routing from
* mid-segment is not standing on the node its route begins at, so its first leg
* became a straight line to a node two hops away — across buildings, across
* open country, wherever that line happened to go. Keeping the start node makes
* a unit rejoin the road before setting off. Anything already standing on it
* shifts past within a step.
*/
function routeTo(graph: Graph, roads: RoadNetwork, from: number, to: number): number[] {
return findRoute(graph, from, to, travelTime(roads))?.nodes ?? [];
}
// --- 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,
stage: BuildStage = 'barricade',
): 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);
// Far enough to be somewhere else, close enough to arrive while it matters.
return d > 150 && d < 430;
});
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,
building: role === 'engineer' ? stage : undefined,
});
}
/**
* A patrol that is simply *there*, already working a road in enemy territory,
* rather than one summoned by the player's own tracks.
*
* Spawned onto its road rather than driven in from a depot: hostile ground
* should have patrols on it the moment you arrive, not thirty seconds later.
*/
function spawnAmbientPatrol(
state: UnitState,
roads: RoadNetwork,
near: { x: number; z: number },
front: Front,
rng: Rng,
): boolean {
const candidates = roads.segments.filter((segment) => {
if (state.dispatched.has(segment.id)) return false;
const mid = { x: (segment.ax + segment.bx) / 2, z: (segment.az + segment.bz) / 2 };
const gap = distance(mid, near);
// Not on top of the player, not beyond what is simulated.
if (gap < 90 || gap > SIM_RADIUS) return false;
return controlAt(front, mid.x, mid.z) !== 'liberated';
});
if (candidates.length === 0) return false;
const segment = candidates[Math.floor(rng() * candidates.length)]!;
const start = pointOnSegment(segment, rng(), 0);
state.dispatched.add(segment.id);
makeUnit(state, {
kind: 'car',
faction: 'enemy',
role: 'patrol',
x: start.x,
z: start.z,
heading: start.yaw,
speed: SPEED.car,
hp: UNIT_HP.car,
path: [],
expires: 260 + rng() * 200,
assigned: segment.id,
// Start part-way through a sweep, so they are not all in step.
onStation: rng() * 20,
cooldown: 1,
elevation: 1,
});
return true;
}
/**
* One of yours, on foot, on ground your side holds.
*
* Placed by walking out from the player and checking who holds where they would
* stand, rather than by trusting the band the player is in: near a border those
* are different answers, and a militiaman standing fifty metres inside occupied
* ground is exactly the thing that would make the front unreadable.
*/
function spawnAlly(
state: UnitState,
near: { x: number; z: number },
front: Front,
rng: Rng,
): boolean {
for (let attempt = 0; attempt < 8; attempt++) {
const angle = rng() * Math.PI * 2;
const radius = 70 + rng() * 230;
const x = near.x + Math.cos(angle) * radius;
const z = near.z + Math.sin(angle) * radius;
const control = controlAt(front, x, z);
if (control !== 'liberated' && control !== 'contested') continue;
makeUnit(state, {
kind: 'soldier',
faction: 'insurgent',
role: 'militia',
x,
z,
heading: rng() * Math.PI * 2,
speed: SPEED.soldier * (0.5 + rng() * 0.5),
hp: UNIT_HP.soldier,
path: [],
expires: 400 + rng() * 300,
assigned: null,
onStation: 0,
cooldown: rng(),
elevation: 1.2,
});
return true;
}
return false;
}
/** 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;
/**
* Called for the district a patrol is standing in. Takes a position rather
* than a district id so this file never has to know how areas are gridded.
*/
decayArea: (x: number, z: number, amount: number) => void;
/**
* Where hunting units should be heading: the player, or the last place they
* were seen. Null when nobody is chasing.
*/
hunt: { x: number; z: number } | null;
/**
* Is this point inside a building? Hunters drive round them rather than
* through them, which is the entire reason turning a corner works.
*/
blocked?: (x: number, z: number) => boolean;
}
export interface UnitEvents {
/** Stages finished this step, so the world can put them up. */
built: Array<{ segment: number; stage: BuildStage }>;
/** Checkpoints abandoned, so the world 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);
}
// --- Enemy ground is patrolled because it is enemy ground ---
const here = controlAt(step.front, player.x, player.z);
const patrols = state.units.filter((u) => u.role === 'patrol').length;
if (patrols < AMBIENT_PATROLS[here] && rng() < dt * 1.5) {
spawnAmbientPatrol(state, roads, player, step.front, rng);
}
// --- And your own ground has your own people standing in it ---
const allies = state.units.filter((u) => u.role === 'militia').length;
if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) {
spawnAlly(state, player, step.front, 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;
// Chasing overrides the job. A patrol that has recognised you is no longer
// working a road, and a hunt that politely waited its turn behind whatever
// the unit was already doing would not be a hunt.
if (unit.hunting && step.hunt) {
const dx = step.hunt.x - unit.x;
const dz = step.hunt.z - unit.z;
const gap = Math.hypot(dx, dz);
const wanted = Math.atan2(dx, dz);
unit.heading = wanted;
// Cars wind up to chase speed; people on foot chase at the pace they walk.
const top = unit.kind === 'car' ? HUNT_SPEED : unit.speed;
unit.chaseSpeed = Math.min(top, (unit.chaseSpeed ?? unit.speed) + HUNT_ACCELERATION * dt);
// Hold off a little rather than piling into the car; they want to shoot
// at it, and a ram is the player's move, not theirs.
if (gap > 12) {
const move = Math.min(gap, unit.chaseSpeed * dt);
// Straight at the target if there is a way through, otherwise round the
// side of whatever is in the way. Without this they drive through the
// buildings, and a chase you cannot lose by turning a corner is not a
// chase — it is a timer.
for (const offset of HUNT_DETOURS) {
const heading = wanted + offset;
const x = unit.x + Math.sin(heading) * move;
const z = unit.z + Math.cos(heading) * move;
if (step.blocked?.(x, z)) continue;
unit.x = x;
unit.z = z;
unit.heading = heading;
break;
}
}
// Being chased keeps a unit alive past its ordinary shift.
unit.expires = Math.max(unit.expires, 30);
continue;
}
// Off the chase, so back to whatever pace the job runs at.
unit.chaseSpeed = undefined;
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);
// Presence settles the district a little too, but only a little.
step.decayArea(unit.x, unit.z, PATROL_AREA_DECAY * dt);
if (unit.onStation > PATROL_DURATION) unit.expires = 0;
} else {
const stage: BuildStage = unit.building ?? 'barricade';
const site = state.checkpoints.get(segment.id) ?? {
segment: segment.id,
stage,
progress: 0,
done: [],
x: point.x,
z: point.z,
};
state.checkpoints.set(segment.id, site);
if (site.done.includes(stage)) {
// Already standing; nothing left for this one to do.
unit.expires = 0;
break;
}
site.stage = stage;
site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS[stage]);
if (site.progress >= 1) {
site.done.push(stage);
site.progress = 0;
events.built.push({ segment: segment.id, stage });
// A tower is no use unless someone is standing on it.
if (stage === 'tower') {
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':
case 'militia': {
// 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;
}
// Nothing to shoot at. Militia are holding ground rather than taking
// it, so they drift about it instead of standing to attention — a
// street of statues reads as scenery, which is the opposite of the
// point of putting them there.
if (unit.role !== 'militia') break;
if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6;
unit.x += Math.sin(unit.heading) * unit.speed * 0.4 * dt;
unit.z += Math.cos(unit.heading) * unit.speed * 0.4 * 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;
}
// --- Ground your side no longer holds has none of your people standing on it ---
// The line moves on its own, and when it moves over somebody they are not
// there any more. It also keeps the population honest as the player advances:
// militia are local, so they are culled at a far tighter radius than anything
// with a job to do, and driving deep into occupied ground leaves them behind
// rather than towing a friendly crowd along the front.
for (const unit of state.units) {
if (unit.role !== 'militia') continue;
const control = controlAt(step.front, unit.x, unit.z);
if (control !== 'liberated' && control !== 'contested') unit.expires = 0;
if (distance(unit, player) > MILITIA_RADIUS) unit.expires = 0;
}
// --- Retire the dead, the finished and the far away ---
const survivors: Unit[] = [];
for (const unit of state.units) {
// Anything with a job keeps it regardless of distance. Culling by range
// caught units that were dispatched from further out than the cull radius
// and deleted them on their first step — so the patrol or the engineer the
// player had earned simply never turned up. Their `expires` bounds them.
const tooFar = unit.assigned === null && 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];