diff --git a/src/main.ts b/src/main.ts index 26fff6b..45e7e6a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -101,6 +101,15 @@ const KIA_HOLD = 5; * Slow: this is a drift across the scene, not an orbit around the car. */ const WAKE_ORBIT_RATE = 0.22; +/** + * Share of the death cam anyone keeps shooting for. + * + * The burst that killed you should finish — cutting the gunfire on the exact + * frame the car dies looks like the sound broke. After that they stop, because + * standing over a wreck emptying magazines into it reads as the enemy beating a + * dead horse rather than as a firefight ending. + */ +const KIA_CEASE_FIRE = 0.25; function resolveSeed(): number { const raw = new URLSearchParams(location.search).get('seed'); @@ -158,14 +167,20 @@ async function boot() { // 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)}`; + const cellOf = (v: number) => Math.floor(v / BUILDING_CELL); + const cellKey = (x: number, z: number) => `${cellOf(x)},${cellOf(z)}`; for (const o of model.obstacles) { if (o.kind !== 'block') continue; const reach = Math.hypot(o.width, o.depth) / 2; - 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); + // Walk cell indices, not metres. Stepping the world coordinate by + // BUILDING_CELL only ever took one step, because a building's reach (~7m) + // is smaller than a cell (24m) — so every building registered exactly one + // cell and silently vanished from the other three it straddled. Points in + // those cells read as open ground: bullets flew through the wall, line of + // sight saw through it, and people stood inside it. + for (let cx = cellOf(o.x - reach); cx <= cellOf(o.x + reach); cx++) { + for (let cz = cellOf(o.z - reach); cz <= cellOf(o.z + reach); cz++) { + const key = `${cx},${cz}`; const list = buildingGrid.get(key) ?? []; list.push(o); buildingGrid.set(key, list); @@ -428,6 +443,7 @@ async function boot() { // because a death cam over a frozen world is a screenshot, not a moment. // What stops is the driver. const dead = dying > 0; + const deathProgress = dead ? 1 - dying / KIA_HOLD : 0; if (dead) { dying -= dt; deathAngle += WAKE_ORBIT_RATE * dt; @@ -794,14 +810,21 @@ async function boot() { } // --- Being noticed --- - for (const event of stepPursuit(pursuit, { - dt, - player: { x: at.x, z: at.z }, - control, - units, - canSee, - provocation, - })) { + // Not while you are dead. Enemies standing over the wreck would otherwise + // build suspicion on it all over again and re-recognise a car that is not + // going anywhere, which is how they ended up shooting at it for five + // solid seconds. + const noticed = dead + ? [] + : stepPursuit(pursuit, { + dt, + player: { x: at.x, z: at.z }, + control, + units, + canSee, + provocation, + }); + for (const event of noticed) { if (event.kind === 'recognised') say('They have made you. Drive.', 6); if (event.kind === 'lost') say('You lost them.', 5); if (event.kind === 'distracted' && event.remaining > 0) { @@ -813,7 +836,9 @@ async function boot() { // --- Shooting --- // They shoot at you because they have recognised you, not because of the // road you happen to be on. Being hunted *is* the state of being a target. - const exposed = pursuit.alert === 'hunted'; + // While dying, exposure runs off the clock rather than off the meter: the + // shooting tails off shortly after the car does. + const exposed = dead ? deathProgress < KIA_CEASE_FIRE : pursuit.alert === 'hunted'; const shooting = stepCombat( combat, @@ -823,6 +848,9 @@ async function boot() { player: { x: at.x, z: at.z }, playerExposed: exposed, blocked: insideBuilding, + // Nobody fires at what they cannot see. Rounds already stopped at + // walls; without this the muzzle flashes still came from inside them. + canSee, }, condition, chatterRng, diff --git a/src/sim/combat.ts b/src/sim/combat.ts index 52475dd..dca155a 100644 --- a/src/sim/combat.ts +++ b/src/sim/combat.ts @@ -73,6 +73,16 @@ export interface CombatStep { playerExposed: boolean; /** Blocks a round: buildings stop bullets. */ blocked: (x: number, z: number) => boolean; + /** + * Can one point see another? Buildings block it. + * + * Rounds already died against walls, but nothing checked before pulling the + * trigger, so units happily emptied magazines into the building between them + * and a target they could not possibly see. What you saw from the car was + * tracers appearing out of solid concrete. Firing is a decision, and it needs + * the same information the round does. + */ + canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean; } function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng: Rng): void { @@ -121,7 +131,7 @@ export function stepCombat( if (unit.role === 'pedestrian' || unit.role === 'traffic') continue; const target = nearestHostile(units, unit, RANGE[unit.kind]); - if (target) { + if (target && step.canSee(unit, target)) { fire(state, unit, target, rng); continue; } @@ -132,7 +142,8 @@ export function stepCombat( 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] + Math.hypot(step.player.x - unit.x, step.player.z - unit.z) < RANGE[unit.kind] && + step.canSee(unit, step.player) ) { fire(state, unit, step.player, rng); } diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 87e7e73..5bd9674 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -335,7 +335,7 @@ describe('rounds in flight', () => { stepCombat( combat, units, - { dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false }, + { dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false, canSee: () => true }, freshCondition(), makeRng(1), ); @@ -353,7 +353,7 @@ describe('rounds in flight', () => { const result = stepCombat( combat, units, - { dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false }, + { dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false, canSee: () => true }, condition, rng, ); @@ -380,6 +380,7 @@ describe('rounds in flight', () => { playerExposed: false, // Walls between the player and each squad, so nothing has a line. blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22), + canSee: () => true, }, condition, makeRng(5), @@ -414,7 +415,7 @@ describe('rounds in flight', () => { stepCombat( combat, units, - { dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false }, + { dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false, canSee: () => true }, freshCondition(), makeRng(9), ); @@ -447,7 +448,7 @@ describe('rounds in flight', () => { stepCombat( combat, units, - { dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false }, + { dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false, canSee: () => true }, freshCondition(), makeRng(9), ); @@ -627,3 +628,104 @@ describe('your own side, standing in your own streets', () => { expect(populate('liberated').length).toBeGreaterThan(0); }); }); + +describe('walls are real', () => { + /** A slab across the middle of the world, with open ground either side. */ + const wall = (x: number, z: number) => Math.abs(x) < 40 && z > 30 && z < 60; + + it('keeps people on foot out of the buildings', () => { + const state = createUnits(); + run(state, 200, { player: { x: 0, z: 0 }, blocked: wall }, makeRng(12)); + const onFoot = state.units.filter((u) => u.kind === 'soldier'); + expect(onFoot.length).toBeGreaterThan(5); + for (const u of onFoot) expect(wall(u.x, u.z)).toBe(false); + }); + + it('does not let a chase cut through the middle of one', () => { + const state = createUnits(); + const unit = dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7))!; + unit.x = 0; + unit.z = 0; + unit.path = []; + unit.hunting = true; + for (let i = 0; i < 60 * 10; i++) { + stepUnits( + state, + { + dt: 1 / 60, + now: i / 60, + player: { x: 0, z: 300 }, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: { x: 0, z: 300 }, + blocked: wall, + }, + world.roads, + graph, + makeRng(5), + ); + expect(wall(unit.x, unit.z)).toBe(false); + } + }); +}); + +describe('nobody fires at what they cannot see', () => { + /** Two hostiles in range of each other with something solid between them. */ + const facingOff = () => { + const units = createUnits(); + const place = (faction: 'enemy' | 'insurgent', x: number) => { + units.units.push({ + id: units.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, + }); + }; + place('enemy', -20); + place('insurgent', 20); + return units; + }; + + const shotsOver = (canSee: () => boolean) => { + const combat = createCombat(); + const units = facingOff(); + let fired = 0; + for (let i = 0; i < 300; i++) { + const r = stepCombat( + combat, + units, + { + dt: 1 / 60, + player: { x: 999, z: 999 }, + playerExposed: false, + blocked: () => false, + canSee, + }, + freshCondition(), + makeRng(9), + ); + fired += r.fired.length; + } + return fired; + }; + + it('holds fire through a wall, and opens up without one', () => { + // The rounds always died against the building. What nothing checked was + // whether to pull the trigger, so tracers appeared out of solid concrete. + expect(shotsOver(() => false)).toBe(0); + expect(shotsOver(() => true)).toBeGreaterThan(0); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index 4fc5ca3..ae47ad9 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -135,12 +135,12 @@ 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. + * Headings anything on foot or on wheels 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]; +const 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. */ @@ -246,6 +246,36 @@ function nearestNode(roads: RoadNetwork, x: number, z: number): number { return best.id; } +/** + * Moves a unit, going round whatever is in the way rather than through it. + * + * Every kind of free movement in this file used to write straight into x and z, + * which meant pedestrians, militia and fighters all walked through walls — and + * a world where people stand inside buildings is one where cover means nothing, + * because nothing is really there. Returns the heading actually taken. + */ +function moveThrough( + unit: Unit, + heading: number, + distance: number, + blocked?: (x: number, z: number) => boolean, +): number { + for (const offset of DETOURS) { + const tried = heading + offset; + const x = unit.x + Math.sin(tried) * distance; + const z = unit.z + Math.cos(tried) * distance; + if (blocked?.(x, z)) continue; + unit.x = x; + unit.z = z; + return tried; + } + // Boxed in on every heading. Stay put rather than clipping out of it. + return heading; +} + +/** Range a fighter closes to before standing its ground. */ +const gapTooFar = (gap: number) => gap > 45; + /** 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]; @@ -326,9 +356,21 @@ function spawnTraffic( }); } -function spawnPedestrian(state: UnitState, near: { x: number; z: number }, rng: Rng): void { - const angle = rng() * Math.PI * 2; - const radius = 60 + rng() * 200; +function spawnPedestrian( + state: UnitState, + near: { x: number; z: number }, + rng: Rng, + blocked?: (x: number, z: number) => boolean, +): void { + // Somewhere that is actually a street. Placing people by polar coordinates + // alone stood a tenth of them inside a building on the first frame. + let angle = rng() * Math.PI * 2; + let radius = 60 + rng() * 200; + for (let attempt = 0; attempt < 8; attempt++) { + if (!blocked?.(near.x + Math.cos(angle) * radius, near.z + Math.sin(angle) * radius)) break; + angle = rng() * Math.PI * 2; + radius = 60 + rng() * 200; + } makeUnit(state, { kind: 'soldier', faction: 'civilian', @@ -455,6 +497,7 @@ function spawnAlly( near: { x: number; z: number }, front: Front, rng: Rng, + blocked?: (x: number, z: number) => boolean, ): boolean { for (let attempt = 0; attempt < 8; attempt++) { const angle = rng() * Math.PI * 2; @@ -463,6 +506,7 @@ function spawnAlly( const z = near.z + Math.sin(angle) * radius; const control = controlAt(front, x, z); if (control !== 'liberated' && control !== 'contested') continue; + if (blocked?.(x, z)) continue; makeUnit(state, { kind: 'soldier', @@ -492,6 +536,7 @@ function spawnSkirmish( near: { x: number; z: number }, front: Front, rng: Rng, + blocked?: (x: number, z: number) => boolean, ): boolean { const candidates = roads.nodes.filter((n) => { const d = distance(n, near); @@ -512,12 +557,20 @@ function spawnSkirmish( ] as const) { const count = 2 + Math.floor(rng() * 3); for (let i = 0; i < count; i++) { + // Scattered around the meeting point, but not inside the scenery. + let fx = 0; + let fz = 0; + for (let attempt = 0; attempt < 6; attempt++) { + fx = at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16; + fz = at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16; + if (!blocked?.(fx, fz)) break; + } 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, + x: fx, + z: fz, heading: angle + (side > 0 ? Math.PI : 0), speed: SPEED.soldier, hp: UNIT_HP.soldier, @@ -586,7 +639,7 @@ export function stepUnits( spawnTraffic(state, roads, graph, player, rng); } if (pedestrians < PEDESTRIAN_TARGET && rng() < dt * 2) { - spawnPedestrian(state, player, rng); + spawnPedestrian(state, player, rng, step.blocked); } // --- Enemy ground is patrolled because it is enemy ground --- @@ -599,12 +652,12 @@ export function stepUnits( // --- 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); + spawnAlly(state, player, step.front, rng, step.blocked); } // --- 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)) { + if (spawnSkirmish(state, roads, player, step.front, rng, step.blocked)) { state.lastSkirmishAt = step.now; events.skirmish = true; } @@ -631,21 +684,9 @@ export function stepUnits( // 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; - } + // Round the side of whatever is in the way, never through it: a chase + // you cannot lose by turning a corner is not a chase, it is a timer. + unit.heading = moveThrough(unit, wanted, Math.min(gap, unit.chaseSpeed * dt), step.blocked); } // Being chased keeps a unit alive past its ordinary shift. unit.expires = Math.max(unit.expires, 30); @@ -665,10 +706,11 @@ export function stepUnits( } case 'pedestrian': { - // A slow wander. People are not going anywhere in particular. + // A slow wander. People are not going anywhere in particular — but they + // do go *round* the buildings, and take the turn as their new heading + // so they walk along a wall rather than repeatedly into it. 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; + unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked); break; } @@ -762,11 +804,10 @@ export function stepUnits( // 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; + const wanted = Math.atan2(enemy.x - unit.x, enemy.z - unit.z); + unit.heading = wanted; + if (gapTooFar(distance(unit, enemy))) { + unit.heading = moveThrough(unit, wanted, unit.speed * dt, step.blocked); } break; } @@ -776,8 +817,7 @@ export function stepUnits( // 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; + unit.heading = moveThrough(unit, unit.heading, unit.speed * 0.4 * dt, step.blocked); break; } @@ -794,8 +834,7 @@ export function stepUnits( ); 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; + unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked); } // --- Checkpoints whose road has cooled off are abandoned ---