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>
This commit is contained in:
dejvino 2026-08-09 08:02:08 +02:00
parent 563ee66dcd
commit 1019c3aec3
4 changed files with 240 additions and 60 deletions

View File

@ -101,6 +101,15 @@ const KIA_HOLD = 5;
* Slow: this is a drift across the scene, not an orbit around the car. * Slow: this is a drift across the scene, not an orbit around the car.
*/ */
const WAKE_ORBIT_RATE = 0.22; 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 { function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed'); 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. // lookup. A grid built once at boot beats scanning a thousand obstacles.
const BUILDING_CELL = 24; const BUILDING_CELL = 24;
const buildingGrid = new Map<string, typeof model.obstacles>(); const buildingGrid = new Map<string, typeof model.obstacles>();
const cellKey = (x: number, z: number) => const cellOf = (v: number) => Math.floor(v / BUILDING_CELL);
`${Math.floor(x / BUILDING_CELL)},${Math.floor(z / BUILDING_CELL)}`; const cellKey = (x: number, z: number) => `${cellOf(x)},${cellOf(z)}`;
for (const o of model.obstacles) { for (const o of model.obstacles) {
if (o.kind !== 'block') continue; if (o.kind !== 'block') continue;
const reach = Math.hypot(o.width, o.depth) / 2; const reach = Math.hypot(o.width, o.depth) / 2;
for (let x = o.x - reach; x <= o.x + reach; x += BUILDING_CELL) { // Walk cell indices, not metres. Stepping the world coordinate by
for (let z = o.z - reach; z <= o.z + reach; z += BUILDING_CELL) { // BUILDING_CELL only ever took one step, because a building's reach (~7m)
const key = cellKey(x, z); // 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) ?? []; const list = buildingGrid.get(key) ?? [];
list.push(o); list.push(o);
buildingGrid.set(key, list); 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. // because a death cam over a frozen world is a screenshot, not a moment.
// What stops is the driver. // What stops is the driver.
const dead = dying > 0; const dead = dying > 0;
const deathProgress = dead ? 1 - dying / KIA_HOLD : 0;
if (dead) { if (dead) {
dying -= dt; dying -= dt;
deathAngle += WAKE_ORBIT_RATE * dt; deathAngle += WAKE_ORBIT_RATE * dt;
@ -794,14 +810,21 @@ async function boot() {
} }
// --- Being noticed --- // --- Being noticed ---
for (const event of stepPursuit(pursuit, { // Not while you are dead. Enemies standing over the wreck would otherwise
dt, // build suspicion on it all over again and re-recognise a car that is not
player: { x: at.x, z: at.z }, // going anywhere, which is how they ended up shooting at it for five
control, // solid seconds.
units, const noticed = dead
canSee, ? []
provocation, : 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 === 'recognised') say('They have made you. Drive.', 6);
if (event.kind === 'lost') say('You lost them.', 5); if (event.kind === 'lost') say('You lost them.', 5);
if (event.kind === 'distracted' && event.remaining > 0) { if (event.kind === 'distracted' && event.remaining > 0) {
@ -813,7 +836,9 @@ async function boot() {
// --- Shooting --- // --- Shooting ---
// They shoot at you because they have recognised you, not because of the // 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. // 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( const shooting = stepCombat(
combat, combat,
@ -823,6 +848,9 @@ async function boot() {
player: { x: at.x, z: at.z }, player: { x: at.x, z: at.z },
playerExposed: exposed, playerExposed: exposed,
blocked: insideBuilding, 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, condition,
chatterRng, chatterRng,

View File

@ -73,6 +73,16 @@ export interface CombatStep {
playerExposed: boolean; playerExposed: boolean;
/** Blocks a round: buildings stop bullets. */ /** Blocks a round: buildings stop bullets. */
blocked: (x: number, z: number) => boolean; 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 { 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; if (unit.role === 'pedestrian' || unit.role === 'traffic') continue;
const target = nearestHostile(units, unit, RANGE[unit.kind]); const target = nearestHostile(units, unit, RANGE[unit.kind]);
if (target) { if (target && step.canSee(unit, target)) {
fire(state, unit, target, rng); fire(state, unit, target, rng);
continue; continue;
} }
@ -132,7 +142,8 @@ export function stepCombat(
unit.faction === 'enemy' && unit.faction === 'enemy' &&
step.playerExposed && step.playerExposed &&
(unit.role === 'garrison' || unit.role === 'patrol') && (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); fire(state, unit, step.player, rng);
} }

View File

@ -335,7 +335,7 @@ describe('rounds in flight', () => {
stepCombat( stepCombat(
combat, combat,
units, 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(), freshCondition(),
makeRng(1), makeRng(1),
); );
@ -353,7 +353,7 @@ describe('rounds in flight', () => {
const result = stepCombat( const result = stepCombat(
combat, combat,
units, 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, condition,
rng, rng,
); );
@ -380,6 +380,7 @@ describe('rounds in flight', () => {
playerExposed: false, playerExposed: false,
// Walls between the player and each squad, so nothing has a line. // Walls between the player and each squad, so nothing has a line.
blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22), blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22),
canSee: () => true,
}, },
condition, condition,
makeRng(5), makeRng(5),
@ -414,7 +415,7 @@ describe('rounds in flight', () => {
stepCombat( stepCombat(
combat, combat,
units, 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(), freshCondition(),
makeRng(9), makeRng(9),
); );
@ -447,7 +448,7 @@ describe('rounds in flight', () => {
stepCombat( stepCombat(
combat, combat,
units, 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(), freshCondition(),
makeRng(9), makeRng(9),
); );
@ -627,3 +628,104 @@ describe('your own side, standing in your own streets', () => {
expect(populate('liberated').length).toBeGreaterThan(0); 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);
});
});

View File

@ -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. */ /** How quickly a hunter winds up to it, m/s². Nobody is at chase speed at once. */
const HUNT_ACCELERATION = 7; const HUNT_ACCELERATION = 7;
/** /**
* Headings a hunter will try, in order, when the straight line is blocked: * Headings anything on foot or on wheels will try, in order, when the straight
* dead ahead, then further and further round either side of the obstruction. * line is blocked: dead ahead, then further and further round either side of
* Alternating sides means they take whichever way round is actually open * the obstruction. Alternating sides means they take whichever way round is
* rather than committing to a direction and grinding along a wall. * 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. */ /** Seconds a dispatched patrol works its road before leaving. */
const PATROL_DURATION = 90; const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */ /** 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; 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. */ /** Steps a unit along its path. Returns true when the path is exhausted. */
function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean { function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
const next = unit.path[0]; const next = unit.path[0];
@ -326,9 +356,21 @@ function spawnTraffic(
}); });
} }
function spawnPedestrian(state: UnitState, near: { x: number; z: number }, rng: Rng): void { function spawnPedestrian(
const angle = rng() * Math.PI * 2; state: UnitState,
const radius = 60 + rng() * 200; 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, { makeUnit(state, {
kind: 'soldier', kind: 'soldier',
faction: 'civilian', faction: 'civilian',
@ -455,6 +497,7 @@ function spawnAlly(
near: { x: number; z: number }, near: { x: number; z: number },
front: Front, front: Front,
rng: Rng, rng: Rng,
blocked?: (x: number, z: number) => boolean,
): boolean { ): boolean {
for (let attempt = 0; attempt < 8; attempt++) { for (let attempt = 0; attempt < 8; attempt++) {
const angle = rng() * Math.PI * 2; const angle = rng() * Math.PI * 2;
@ -463,6 +506,7 @@ function spawnAlly(
const z = near.z + Math.sin(angle) * radius; const z = near.z + Math.sin(angle) * radius;
const control = controlAt(front, x, z); const control = controlAt(front, x, z);
if (control !== 'liberated' && control !== 'contested') continue; if (control !== 'liberated' && control !== 'contested') continue;
if (blocked?.(x, z)) continue;
makeUnit(state, { makeUnit(state, {
kind: 'soldier', kind: 'soldier',
@ -492,6 +536,7 @@ function spawnSkirmish(
near: { x: number; z: number }, near: { x: number; z: number },
front: Front, front: Front,
rng: Rng, rng: Rng,
blocked?: (x: number, z: number) => boolean,
): boolean { ): boolean {
const candidates = roads.nodes.filter((n) => { const candidates = roads.nodes.filter((n) => {
const d = distance(n, near); const d = distance(n, near);
@ -512,12 +557,20 @@ function spawnSkirmish(
] as const) { ] as const) {
const count = 2 + Math.floor(rng() * 3); const count = 2 + Math.floor(rng() * 3);
for (let i = 0; i < count; i++) { 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, { makeUnit(state, {
kind: 'soldier', kind: 'soldier',
faction, faction,
role: 'fighter', role: 'fighter',
x: at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16, x: fx,
z: at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16, z: fz,
heading: angle + (side > 0 ? Math.PI : 0), heading: angle + (side > 0 ? Math.PI : 0),
speed: SPEED.soldier, speed: SPEED.soldier,
hp: UNIT_HP.soldier, hp: UNIT_HP.soldier,
@ -586,7 +639,7 @@ export function stepUnits(
spawnTraffic(state, roads, graph, player, rng); spawnTraffic(state, roads, graph, player, rng);
} }
if (pedestrians < PEDESTRIAN_TARGET && rng() < dt * 2) { 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 --- // --- 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 --- // --- And your own ground has your own people standing in it ---
const allies = state.units.filter((u) => u.role === 'militia').length; const allies = state.units.filter((u) => u.role === 'militia').length;
if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) { 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 --- // --- A war going on regardless of the player ---
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) { 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; state.lastSkirmishAt = step.now;
events.skirmish = true; events.skirmish = true;
} }
@ -631,21 +684,9 @@ export function stepUnits(
// Hold off a little rather than piling into the car; they want to shoot // 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. // at it, and a ram is the player's move, not theirs.
if (gap > 12) { if (gap > 12) {
const move = Math.min(gap, unit.chaseSpeed * dt); // Round the side of whatever is in the way, never through it: a chase
// Straight at the target if there is a way through, otherwise round the // you cannot lose by turning a corner is not a chase, it is a timer.
// side of whatever is in the way. Without this they drive through the unit.heading = moveThrough(unit, wanted, Math.min(gap, unit.chaseSpeed * dt), step.blocked);
// buildings, and a chase you cannot lose by turning a corner is not a
// chase — it is a timer.
for (const offset of HUNT_DETOURS) {
const heading = wanted + offset;
const x = unit.x + Math.sin(heading) * move;
const z = unit.z + Math.cos(heading) * move;
if (step.blocked?.(x, z)) continue;
unit.x = x;
unit.z = z;
unit.heading = heading;
break;
}
} }
// Being chased keeps a unit alive past its ordinary shift. // Being chased keeps a unit alive past its ordinary shift.
unit.expires = Math.max(unit.expires, 30); unit.expires = Math.max(unit.expires, 30);
@ -665,10 +706,11 @@ export function stepUnits(
} }
case 'pedestrian': { 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; if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
unit.x += Math.sin(unit.heading) * unit.speed * dt; unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
unit.z += Math.cos(unit.heading) * unit.speed * dt;
break; break;
} }
@ -762,11 +804,10 @@ export function stepUnits(
// Close on the nearest enemy of the other army and stand your ground. // Close on the nearest enemy of the other army and stand your ground.
const enemy = nearestHostile(state, unit, 140); const enemy = nearestHostile(state, unit, 140);
if (enemy) { if (enemy) {
const gap = distance(unit, enemy); const wanted = Math.atan2(enemy.x - unit.x, enemy.z - unit.z);
unit.heading = Math.atan2(enemy.x - unit.x, enemy.z - unit.z); unit.heading = wanted;
if (gap > 45) { if (gapTooFar(distance(unit, enemy))) {
unit.x += Math.sin(unit.heading) * unit.speed * dt; unit.heading = moveThrough(unit, wanted, unit.speed * dt, step.blocked);
unit.z += Math.cos(unit.heading) * unit.speed * dt;
} }
break; break;
} }
@ -776,8 +817,7 @@ export function stepUnits(
// point of putting them there. // point of putting them there.
if (unit.role !== 'militia') break; if (unit.role !== 'militia') break;
if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6; if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6;
unit.x += Math.sin(unit.heading) * unit.speed * 0.4 * dt; unit.heading = moveThrough(unit, unit.heading, unit.speed * 0.4 * dt, step.blocked);
unit.z += Math.cos(unit.heading) * unit.speed * 0.4 * dt;
break; break;
} }
@ -794,8 +834,7 @@ export function stepUnits(
); );
if (!danger) continue; if (!danger) continue;
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z); const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
unit.x += Math.sin(away) * unit.speed * 1.4 * dt; unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
unit.z += Math.cos(away) * unit.speed * 1.4 * dt;
} }
// --- Checkpoints whose road has cooled off are abandoned --- // --- Checkpoints whose road has cooled off are abandoned ---