drive-between-the-lines/src/sim/combat.ts
dejvino 1019c3aec3 Walls that are actually there
Three separate holes, reported as one bug.

The building lookup grid under-registered. It stepped the world
coordinate by the cell size from x-reach to x+reach, but a building's
reach is about 7m and a cell is 24m, so the loop always took exactly
one step: every building registered 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. Walk cell indices instead.

Nothing checked line of sight before pulling a trigger. Rounds always
died against the building; the decision to fire did not know the
building was there, so units emptied magazines into walls and what you
saw from the car was tracers coming out of solid concrete.

And every kind of free movement wrote straight into x and z, so
pedestrians, militia, fighters and fleeing civilians all walked through
walls. They now share the detour the hunters already used, and spawns
retry rather than dropping someone inside a building on the first
frame.

Six units in ninety were standing inside buildings. Now none are.

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

237 lines
7.8 KiB
TypeScript

/**
* 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;