Allied vehicles, with men in them
Your own side had people on foot and nothing on wheels, so home ground had civilian traffic on it and nothing else moving that was yours. A road with only civilians on it reads the same behind the lines as past them. Convoys route across the network like traffic - which means they inherit lane discipline and following distance for nothing - but only between junctions your side holds, and they leave when the line moves over them. Fewer than the militia on foot, because a truck is scarcer than a man with a rifle and because friendly traffic that outnumbered the civilian kind would turn every road into a convoy. Across the map: convoys 4, 2, 0, 0 and militia 14, 10, 3, 0 against patrols 0, 0, 3, 5. They are insurgent faction, so they shoot and get shot at, and the render already crews any armed vehicle - they carry the same visible gunner the enemy cars got. Two things this shook out. Lane keeping was building its axis from the car-to-node direction, which is nearly right mid-leg and degenerates completely near either end, where the direction swings ninety degrees over a few metres; on a grid map cars spend most of their time near a junction. Units now remember the junction they last left and drive the leg they are actually on. And arrival was still judged on the node itself while the car deliberately passes three metres to one side of it, so on trunks dispatched units drove straight past their destination - no patrol worked a road, no engineer poured any concrete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7004f2bd66
commit
5c2778785c
@ -7,6 +7,7 @@ import { createHeat, createAreas } from './heat';
|
||||
import { freshCondition } from './car';
|
||||
import {
|
||||
AMBIENT_ALLIES,
|
||||
AMBIENT_ALLY_CARS,
|
||||
AMBIENT_PATROLS,
|
||||
createUnits,
|
||||
dispatchTo,
|
||||
@ -289,8 +290,9 @@ describe('a war going on regardless', () => {
|
||||
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);
|
||||
// Spawned where the war is, and far enough past your line that closing
|
||||
// on each other cannot walk them back over it.
|
||||
expect(control).not.toBe('liberated');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -749,24 +751,51 @@ describe('traffic that drives like traffic', () => {
|
||||
};
|
||||
|
||||
it('keeps right rather than straddling the centreline', () => {
|
||||
// Sampled over four minutes rather than from one frozen frame: at any one
|
||||
// instant only a handful of cars are squarely mid-segment, and four cars is
|
||||
// not a measurement.
|
||||
const state = createUnits();
|
||||
let onTheRight = 0;
|
||||
let judged = 0;
|
||||
for (const car of traffic()) {
|
||||
// Only judge cars actually on a road, not ones crossing a junction.
|
||||
const seg = world.roads.segments.find((s) => {
|
||||
const t =
|
||||
((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length);
|
||||
if (t < 0.15 || t > 0.85) return false;
|
||||
const cx = s.ax + (s.bx - s.ax) * t;
|
||||
const cz = s.az + (s.bz - s.az) * t;
|
||||
return Math.hypot(car.x - cx, car.z - cz) < s.width / 2;
|
||||
});
|
||||
if (!seg) continue;
|
||||
judged++;
|
||||
if (sideOfRoad(car, seg) > 0) onTheRight++;
|
||||
for (let i = 0; i < 2400; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: i * 0.1,
|
||||
player: { x: world.spawn.x, z: world.spawn.z },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(31),
|
||||
);
|
||||
if (i % 10 !== 0) continue;
|
||||
for (const car of state.units) {
|
||||
if (car.role !== 'traffic' && car.role !== 'convoy') continue;
|
||||
const seg = world.roads.segments.find((s) => {
|
||||
const t =
|
||||
((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length);
|
||||
// Well inside the segment: at a junction there is no side to be on.
|
||||
if (t < 0.25 || t > 0.75) return false;
|
||||
const cx = s.ax + (s.bx - s.ax) * t;
|
||||
const cz = s.az + (s.bz - s.az) * t;
|
||||
return Math.hypot(car.x - cx, car.z - cz) < s.width / 2;
|
||||
});
|
||||
if (!seg) continue;
|
||||
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
|
||||
// Only cars travelling along this road, not crossing it.
|
||||
if (Math.abs(Math.cos(car.heading - along)) < 0.9) continue;
|
||||
judged++;
|
||||
if (sideOfRoad(car, seg) > 0) onTheRight++;
|
||||
}
|
||||
}
|
||||
expect(judged).toBeGreaterThan(3);
|
||||
expect(onTheRight / judged).toBeGreaterThan(0.75);
|
||||
expect(judged).toBeGreaterThan(200);
|
||||
expect(onTheRight / judged).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it('does not drive through the car in front', () => {
|
||||
@ -819,3 +848,48 @@ describe('traffic that drives like traffic', () => {
|
||||
expect(worst).toBeLessThanOrEqual((2.2 * 1) / 60 + 1e-6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('your own side, with vehicles', () => {
|
||||
const deepIn = (control: 'liberated' | 'contested' | 'occupied') => {
|
||||
const wanted = { liberated: -140, contested: 40, occupied: 240 }[control];
|
||||
const depth = front.boundaries.liberated + wanted;
|
||||
return { x: front.axis.x * depth, z: front.axis.z * depth };
|
||||
};
|
||||
|
||||
const convoys = (control: 'liberated' | 'contested' | 'occupied') => {
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deepIn(control) }, makeRng(8));
|
||||
return state.units.filter((u) => u.role === 'convoy');
|
||||
};
|
||||
|
||||
it('puts allied vehicles on ground your side holds', () => {
|
||||
expect(convoys('liberated').length).toBeGreaterThan(0);
|
||||
expect(convoys('liberated').length).toBeLessThanOrEqual(AMBIENT_ALLY_CARS.liberated);
|
||||
});
|
||||
|
||||
it('stops at the line, like everything else of yours', () => {
|
||||
expect(convoys('occupied').length).toBe(0);
|
||||
});
|
||||
|
||||
it('never routes one onto ground your side does not hold', () => {
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deepIn('contested') }, makeRng(8));
|
||||
for (const u of state.units.filter((c) => c.role === 'convoy')) {
|
||||
const control = controlAt(front, u.x, u.z);
|
||||
expect(control === 'liberated' || control === 'contested').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries a crew that the enemy will shoot at', () => {
|
||||
// Insurgent faction, so nearestHostile treats them as a target and they
|
||||
// treat the enemy as one — a convoy is not scenery.
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deepIn('liberated') }, makeRng(8));
|
||||
for (const u of state.units.filter((c) => c.role === 'convoy')) {
|
||||
expect(u.faction).toBe('insurgent');
|
||||
expect(u.kind).toBe('car');
|
||||
// Rounds leave the man riding in it, not the bonnet.
|
||||
expect(u.elevation).toBeGreaterThan(1.2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
199
src/sim/units.ts
199
src/sim/units.ts
@ -14,7 +14,7 @@ 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';
|
||||
import { controlAt, depthAt } from './regions';
|
||||
|
||||
export type Faction = 'enemy' | 'insurgent' | 'civilian';
|
||||
export type UnitKind = 'car' | 'soldier';
|
||||
@ -33,7 +33,9 @@ export type Role =
|
||||
/** Fighting the other army. */
|
||||
| 'fighter'
|
||||
/** Your own side, holding the ground it holds. Thick at home, thin at the line. */
|
||||
| 'militia';
|
||||
| 'militia'
|
||||
/** Your own side, with a vehicle and somewhere to be. */
|
||||
| 'convoy';
|
||||
|
||||
export interface Unit {
|
||||
id: number;
|
||||
@ -63,6 +65,8 @@ export interface Unit {
|
||||
hunting?: boolean;
|
||||
/** Current speed while chasing, wound up from a standing start. */
|
||||
chaseSpeed?: number;
|
||||
/** Junction most recently left, so the leg being driven is known. */
|
||||
lastNode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -163,6 +167,8 @@ export const PATROL_AREA_DECAY = 0.0005;
|
||||
const BUILD_SECONDS: Record<BuildStage, number> = { barricade: 22, tower: 40 };
|
||||
/** Minimum gap between skirmishes breaking out. */
|
||||
const SKIRMISH_SPACING = 55;
|
||||
/** How far past your own line a fight has to start, so it stays past it. */
|
||||
const SKIRMISH_CLEARANCE = 70;
|
||||
|
||||
/**
|
||||
* Standing patrols the enemy keeps on the roads near the player, by whose
|
||||
@ -207,6 +213,22 @@ export const AMBIENT_ALLIES: Record<Control, number> = {
|
||||
frontier: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Your own side's vehicles, on the same gradient as the men on foot.
|
||||
*
|
||||
* Fewer than the militia, because a truck is a scarcer thing than a man with a
|
||||
* rifle, and because the roads have to stay legible: friendly traffic that
|
||||
* outnumbered the civilian kind would turn every road into a convoy. They exist
|
||||
* mostly so home ground has something moving on it that is *yours* — a road
|
||||
* with only civilians on it reads the same behind the lines as past them.
|
||||
*/
|
||||
export const AMBIENT_ALLY_CARS: Record<Control, number> = {
|
||||
liberated: 4,
|
||||
contested: 2,
|
||||
occupied: 0,
|
||||
frontier: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* How far from the player a militiaman is still worth simulating.
|
||||
*
|
||||
@ -375,46 +397,71 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
|
||||
if (next === undefined) return true;
|
||||
|
||||
const node = nodeById(roads, next);
|
||||
const straightX = node.x - unit.x;
|
||||
const straightZ = node.z - unit.z;
|
||||
const remaining = Math.hypot(straightX, straightZ);
|
||||
const remaining = Math.hypot(node.x - unit.x, node.z - unit.z);
|
||||
|
||||
// Judged on the junction itself, not on the offset aiming point, or a car
|
||||
// would circle a node it had already reached.
|
||||
if (remaining < 3) {
|
||||
/*
|
||||
* Drive a line parallel to the centreline, a share of the road's own
|
||||
* half-width to the right of it — so a track gets a nudge and a trunk gets a
|
||||
* lane, and two cars meeting head-on are on opposite sides of the road.
|
||||
*
|
||||
* Built from the leg actually being driven, which is why units remember the
|
||||
* junction they last left. Deriving the axis from the car-to-node direction
|
||||
* instead is nearly right in the middle of a leg and degenerates completely
|
||||
* near either end of it, where the direction swings through ninety degrees
|
||||
* over a few metres — and a grid map with hundred-metre blocks means cars
|
||||
* spend most of their time near a junction.
|
||||
*/
|
||||
const previous = nodeById(roads, unit.lastNode ?? next);
|
||||
let dirX = node.x - previous.x;
|
||||
let dirZ = node.z - previous.z;
|
||||
const legLength = Math.hypot(dirX, dirZ);
|
||||
if (legLength < 1) {
|
||||
// No leg to speak of — first step out of a spawn, or a doubled-back path.
|
||||
dirX = (node.x - unit.x) / remaining;
|
||||
dirZ = (node.z - unit.z) / remaining;
|
||||
} else {
|
||||
dirX /= legLength;
|
||||
dirZ /= legLength;
|
||||
}
|
||||
// Right of the direction of travel.
|
||||
const rightX = dirZ;
|
||||
const rightZ = -dirX;
|
||||
|
||||
const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2;
|
||||
const offset = halfWidth * LANE_SHARE;
|
||||
const laneX = previous.x + rightX * offset;
|
||||
const laneZ = previous.z + rightZ * offset;
|
||||
|
||||
/*
|
||||
* Arrived, if either the junction is close enough or the car has simply
|
||||
* driven past it.
|
||||
*
|
||||
* "Close enough" has to know about the lane, because the car is deliberately
|
||||
* not aiming at the junction: on a fifteen-metre trunk it passes three and a
|
||||
* half metres to one side of it. A fixed three-metre radius meant dispatched
|
||||
* units drove straight past their destination and never registered arriving,
|
||||
* so no patrol ever worked a road and no engineer ever poured any concrete.
|
||||
*/
|
||||
const beyond = (unit.x - node.x) * dirX + (unit.z - node.z) * dirZ;
|
||||
if (remaining < 3 + offset || beyond > -0.5) {
|
||||
unit.lastNode = next;
|
||||
unit.path.shift();
|
||||
return unit.path.length === 0;
|
||||
}
|
||||
|
||||
// Drive a line parallel to the centreline, a share of the road's own
|
||||
// half-width to the right of it — so a track gets a nudge and a trunk gets a
|
||||
// lane. Offsetting the *destination* instead was the obvious thing and the
|
||||
// wrong one: it only bends the path at the very end of the leg, leaving cars
|
||||
// straddling the middle of every straight.
|
||||
const ahead = unit.path.length > 1 ? unit.path[1]! : next;
|
||||
const halfWidth = (segmentBetween(roads, next, ahead)?.width ?? 9) / 2;
|
||||
const toward = Math.atan2(straightX, straightZ);
|
||||
const forwardX = Math.sin(toward);
|
||||
const forwardZ = Math.cos(toward);
|
||||
const rightX = Math.cos(toward);
|
||||
const rightZ = -Math.sin(toward);
|
||||
|
||||
// Where the lane line sits, and how far off it this car currently is.
|
||||
const toLaneX = node.x + rightX * halfWidth * LANE_SHARE - unit.x;
|
||||
const toLaneZ = node.z + rightZ * halfWidth * LANE_SHARE - unit.z;
|
||||
const lateral = toLaneX * rightX + toLaneZ * rightZ;
|
||||
// Aim at a point on that line a little way ahead. Short lookahead converges
|
||||
// hard and weaves; long lookahead never quite arrives.
|
||||
const look = Math.min(LANE_LOOKAHEAD, Math.max(4, toLaneX * forwardX + toLaneZ * forwardZ));
|
||||
const aimX = unit.x + forwardX * look + rightX * lateral;
|
||||
const aimZ = unit.z + forwardZ * look + rightZ * lateral;
|
||||
// Pure pursuit: aim at the point on the lane line a fixed distance ahead of
|
||||
// wherever the car currently projects onto it. Short lookahead weaves, long
|
||||
// lookahead never quite arrives.
|
||||
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD;
|
||||
const aimX = laneX + dirX * along;
|
||||
const aimZ = laneZ + dirZ * along;
|
||||
|
||||
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
|
||||
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
|
||||
|
||||
// Close on the car in front and lift off. Only civilians defer; anyone with
|
||||
// somewhere to be leans on the horn and keeps going.
|
||||
const gap = unit.role === 'traffic' ? carAhead(state, unit) : null;
|
||||
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
|
||||
const allowed =
|
||||
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
|
||||
const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
|
||||
@ -658,6 +705,56 @@ function spawnAlly(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One of your own side's vehicles, routing across ground your side holds.
|
||||
*
|
||||
* Built on the traffic spawner rather than the militia one: it has somewhere to
|
||||
* be and drives the network to get there, which means it inherits lane
|
||||
* discipline and following distance for nothing.
|
||||
*/
|
||||
function spawnAllyCar(
|
||||
state: UnitState,
|
||||
roads: RoadNetwork,
|
||||
graph: Graph,
|
||||
near: { x: number; z: number },
|
||||
front: Front,
|
||||
rng: Rng,
|
||||
): boolean {
|
||||
const friendly = (n: { x: number; z: number }) => {
|
||||
const control = controlAt(front, n.x, n.z);
|
||||
return control === 'liberated' || control === 'contested';
|
||||
};
|
||||
const candidates = roads.nodes.filter((n) => {
|
||||
const gap = distance(n, near);
|
||||
return gap > 90 && gap < SIM_RADIUS && friendly(n);
|
||||
});
|
||||
if (candidates.length < 2) return false;
|
||||
|
||||
const from = candidates[Math.floor(rng() * candidates.length)]!;
|
||||
const to = candidates[Math.floor(rng() * candidates.length)]!;
|
||||
if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return false;
|
||||
const path = routeTo(graph, roads, from.id, to.id);
|
||||
if (path.length === 0) return false;
|
||||
|
||||
makeUnit(state, {
|
||||
kind: 'car',
|
||||
faction: 'insurgent',
|
||||
role: 'convoy',
|
||||
x: from.x,
|
||||
z: from.z,
|
||||
heading: 0,
|
||||
speed: SPEED.car * (0.75 + rng() * 0.3),
|
||||
hp: UNIT_HP.car,
|
||||
path,
|
||||
expires: 500,
|
||||
assigned: null,
|
||||
onStation: 0,
|
||||
cooldown: 1,
|
||||
elevation: CREW_ELEVATION,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Two squads run into each other. The player is not invited. */
|
||||
function spawnSkirmish(
|
||||
state: UnitState,
|
||||
@ -672,7 +769,11 @@ function spawnSkirmish(
|
||||
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 (control !== 'contested' && control !== 'occupied') return false;
|
||||
// And far enough past the line that closing on each other cannot walk them
|
||||
// over it. Fighters advance up to their standoff range, so a skirmish
|
||||
// spawned right on the boundary ends up being fought behind your own.
|
||||
return depthAt(front, n.x, n.z) > front.boundaries.liberated + SKIRMISH_CLEARANCE;
|
||||
});
|
||||
if (candidates.length === 0) return false;
|
||||
|
||||
@ -783,6 +884,10 @@ export function stepUnits(
|
||||
if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) {
|
||||
spawnAlly(state, player, step.front, rng, step.blocked);
|
||||
}
|
||||
const allyCars = state.units.filter((u) => u.role === 'convoy').length;
|
||||
if (allyCars < AMBIENT_ALLY_CARS[here] && rng() < dt * 2) {
|
||||
spawnAllyCar(state, roads, graph, player, step.front, rng);
|
||||
}
|
||||
|
||||
// --- A war going on regardless of the player ---
|
||||
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
|
||||
@ -825,10 +930,22 @@ export function stepUnits(
|
||||
unit.chaseSpeed = undefined;
|
||||
|
||||
switch (unit.role) {
|
||||
case 'traffic': {
|
||||
case 'traffic':
|
||||
case 'convoy': {
|
||||
if (advance(unit, roads, state, dt)) {
|
||||
const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!;
|
||||
unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id);
|
||||
// Somewhere else to be. A convoy stays on ground its own side holds;
|
||||
// a taxi does not care.
|
||||
const pool =
|
||||
unit.role === 'convoy'
|
||||
? roads.nodes.filter((n) => {
|
||||
const control = controlAt(step.front, n.x, n.z);
|
||||
return control === 'liberated' || control === 'contested';
|
||||
})
|
||||
: roads.nodes;
|
||||
const to = pool[Math.floor(rng() * pool.length)];
|
||||
unit.path = to
|
||||
? routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id)
|
||||
: [];
|
||||
if (unit.path.length === 0) unit.expires = 0;
|
||||
}
|
||||
break;
|
||||
@ -968,6 +1085,11 @@ export function stepUnits(
|
||||
for (let j = i + 1; j < state.units.length; j++) {
|
||||
const b = state.units[j]!;
|
||||
if (b.kind !== 'car') continue;
|
||||
// Only cars going roughly the same way. Two passing in opposite
|
||||
// directions are already a lane apart, which on a narrow road is less
|
||||
// than this radius — separating them too shoved both off their own side
|
||||
// and undid the lane discipline entirely.
|
||||
if (Math.cos(b.heading - a.heading) < 0) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dz = b.z - a.z;
|
||||
const gap = Math.hypot(dx, dz);
|
||||
@ -1009,10 +1131,13 @@ export function stepUnits(
|
||||
// with a job to do, and driving deep into occupied ground leaves them behind
|
||||
// rather than towing a friendly crowd along the front.
|
||||
for (const unit of state.units) {
|
||||
if (unit.role !== 'militia') continue;
|
||||
if (unit.role !== 'militia' && unit.role !== 'convoy') continue;
|
||||
const control = controlAt(step.front, unit.x, unit.z);
|
||||
if (control !== 'liberated' && control !== 'contested') unit.expires = 0;
|
||||
if (distance(unit, player) > MILITIA_RADIUS) unit.expires = 0;
|
||||
// Vehicles get more rope than men on foot: they cover ground, and culling
|
||||
// one at walking-pace range would delete it halfway down its own route.
|
||||
const reach = unit.role === 'convoy' ? SIM_RADIUS : MILITIA_RADIUS;
|
||||
if (distance(unit, player) > reach) unit.expires = 0;
|
||||
}
|
||||
|
||||
// --- Retire the dead, the finished and the far away ---
|
||||
|
||||
Loading…
Reference in New Issue
Block a user