/** * 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; /** Muzzle positions this step, drained by the caller. */ firedThisStep: Array<{ x: number; z: number }>; } export const createCombat = (): CombatState => ({ rounds: [], playerHits: 0, firedThisStep: [], }); // --- 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; /** * 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 { state.firedThisStep.push({ x: from.x, z: from.z }); 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 { playerHit: boolean; condition: CarCondition; /** Where shots were fired from this step, so they can be heard. */ fired: Array<{ x: number; z: number }>; } export function stepCombat( state: CombatState, units: UnitState, step: CombatStep, condition: CarCondition, rng: Rng, ): CombatResult { const { dt } = step; let playerHit = false; let updated = condition; state.firedThisStep = []; // --- 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 && step.canSee(unit, 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] && step.canSee(unit, step.player) ) { 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, fired: state.firedThisStep }; } /** * 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;