diff --git a/README.md b/README.md index b5d1c0c..8e700b7 100644 --- a/README.md +++ b/README.md @@ -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 0–5 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. diff --git a/src/debug.ts b/src/debug.ts index d100ad4..173b512 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -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; info: () => Record; 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, }; diff --git a/src/heatProps.ts b/src/heatProps.ts index b215721..60c0f28 100644 --- a/src/heatProps.ts +++ b/src/heatProps.ts @@ -12,9 +12,7 @@ import type { RoadNetwork } from './sim/roads'; import type { PhysicsWorld } from './physics/physics'; const MATERIALS: Record = { - 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); diff --git a/src/main.ts b/src/main.ts index 001cc8e..236ce46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); + 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>(); 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>((counts, u) => { + counts[u.role] = (counts[u.role] ?? 0) + 1; + return counts; + }, {}), + rounds: combat.rounds.length, + checkpoints: units.checkpoints.size, notice, }), }); diff --git a/src/render/units.ts b/src/render/units.ts new file mode 100644 index 0000000..96139be --- /dev/null +++ b/src/render/units.ts @@ -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; + }, + }; +} diff --git a/src/sim/combat.ts b/src/sim/combat.ts new file mode 100644 index 0000000..61e8551 --- /dev/null +++ b/src/sim/combat.ts @@ -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; diff --git a/src/sim/heat.test.ts b/src/sim/heat.test.ts index b22290c..d79f8f5 100644 --- a/src/sim/heat.test.ts +++ b/src/sim/heat.test.ts @@ -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)); } }); }); diff --git a/src/sim/heat.ts b/src/sim/heat.ts index e16bbbd..ad606c7 100644 --- a/src/sim/heat.ts +++ b/src/sim/heat.ts @@ -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; } diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts new file mode 100644 index 0000000..91506b5 --- /dev/null +++ b/src/sim/units.test.ts @@ -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[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(); + 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); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts new file mode 100644 index 0000000..1c6f908 --- /dev/null +++ b/src/sim/units.ts @@ -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; + /** Segments a patrol is already on the way to, so we do not send five. */ + dispatched: Set; + 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 = { 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 = { 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 { + 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]; diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 21099c8..b9a25de 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -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.