Compare commits

...

12 Commits

Author SHA1 Message Date
dejvino
6ad69e59f0 Traffic that actually gets somewhere
The circling was still there, and the reason it survived three previous
attempts is that it was never the thing it looked like. Not junctions,
not crowding, not a jam: arithmetic.

A vehicle steering at a point ahead of it oscillates unless that point
lies outside its own turning circle, with roughly double the margin.
The tightest circle traffic can drive is speed / TURN_RATE - 6.4 metres
at 14 m/s - so the lookahead has to clear about thirteen. It was a flat
eleven, below the threshold at every speed traffic actually drives at,
which is why it circled wherever it happened to be rather than anywhere
in particular. It now scales with speed, with a floor and a ceiling:
too small oscillates, unbounded runs the aim point so far down the lane
that the correction vanishes and the car drifts away instead.

Four things around it also had to change. Following distance is
measured along the lane rather than along the nose, because in a knot
of stopped cars the noses swing and every car is intermittently blocked
by every other. Steering is proportional to how far the car actually
moved, since a stationary car cannot change which way it points and a
queue was turning into a slow merry-go-round. Separation only resolves
genuine overlap now, at three metres rather than 4.5, because shoving a
car sideways off its lane and then having pure pursuit curve it back is
itself a circle - and it handles two cars at exactly the same point,
which it used to skip, welding the pair together permanently.

And a last resort: a car that has not got any closer to where it is
going for twelve seconds is put back on its lane and given a new route.
Repositioning alone was not enough - it resumed the same state within
seconds.

The measurement took as long to get right as the fix. Counting how much
cars turn is useless, because a grid with a junction every hundred
metres has them turning constantly; so is start-to-finish displacement,
since a car can loop the network and come back. How far a car ever gets
from where it started is the one that separates driving around town
from driving around a lamppost. Over ninety seconds, all thirty-four
cars now get between 199 and 480 metres away. None stay put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:29:02 +02:00
dejvino
4b1f8c6444 Cover comes from the road's reputation and the car you turned up in
Occupied ground used to blow your cover on its own: park anywhere past
the line, in anything, and somebody would eventually make you. But
nobody is looking for a driver they have no reason to believe exists.
Suspicion now runs off how wary the place actually is - the heat on the
road being used, or on the district when off it - and off how much the
vehicle draws the eye.

Two things had to change for that to mean anything.

Alertness caps how sure anyone can get, not just how fast. Without the
ceiling a quiet car on a cold road is only *slower* to be made, never
safe from it, because everybody saturates eventually and the whole
distinction collapses.

And the decay runs the whole time rather than only when nobody is
watching, which turns an accumulator into a threshold. Below
DECAY-worth of attention you are not slowly being made, you are simply
not being made. An accumulator always reaches the top given long
enough, so "nobody suspects you here" could never have meant anything.

Certainty and what certainty costs are kept apart. A person cannot be
more than sure - so attention caps, and it is what the markers over
their heads show - but somebody sure about an armoured carrier on a
road they have worked all week is a different event from somebody
equally sure about a tractor on a quiet one. Folding those together
made every vehicle identical the moment anyone was certain, which took
the garage's whole decision back out.

Tuned against the stated pacing, and pinned to it. Driving the same
checkpoint repeatedly as the road heats up, the pass you are caught on:
armoured carrier 1st, sports coupe 2nd, lorry 4th, estate 5th,
runabout 7th, tractor 8th. Under continuous watch an armoured carrier
is made in half a second on a notorious road and still under six on one
nobody has touched; a runabout on a cold road is never made at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:18:54 +02:00
dejvino
5b881312b0 Six vehicles, and suspicion is about belonging rather than being good
Reworked to the requested catalogue: small runabout, tractor, large
estate, lorry, sports coupe, armoured carrier. The runabout is free and
everything else is unlocked.

The important change is not the list, it is the axis. Attention is no
longer tied to capability - it is tied to whether the vehicle *belongs
here*. A lorry is three tonnes, well protected and nobody looks twice,
because lorries exist. A sports coupe is quick and flimsy and everybody
looks, because who drives that, here, now. So the whole civilian half
stays quiet however capable it gets, and you pay for speed and armour
in money and in handling instead. Only two vehicles cost you attention,
and they are the two that do not belong: the flashy one and the one
with a gun mount.

That kills the old "each rung must be more conspicuous than the last"
test, which no longer describes the design. In its place is the
invariant that actually matters and is much stronger: across all
thirty pairs, no vehicle may be at least as good as another on speed,
protection, discretion *and* handling at once. That is what stops a
dominant pick appearing and the loop losing its only decision - a lorry
is safe and unremarkable and pays for it by steering like a barge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:05:03 +02:00
dejvino
6a0c16fcf4 Make the car you picked the car you are driving
deriveHandling now takes the spec as well as the condition, and every
figure is the catalogue's own number scaled by wear - so the table
means what it says rather than describing a single car with a skin on
it. A tractor turns tighter and grips less than an armoured car, and
stays that way as both of them fall apart.

Fragility reaches both damage paths, walls and bullets alike, since
being shot at is precisely the situation armour is bought for. The same
head-on leaves the tractor at 15% chassis and the armoured car at 77%.

Wear scales the car's figures rather than replacing them, and bottoms
out at a third rather than at nothing. A car that stops entirely at 0%
is a fail state wearing a dial, and the whole point of the garage is
that a bad car is a situation rather than an ending.

Mass moves with the spec; the collider deliberately does not. Different
colliders would mean rebuilding the vehicle controller mid-campaign,
and every barricade gap and building alley in the world is sized
against one car. The mesh changes shape and paint, so a tractor reads
as a tractor from the mirror without the world needing re-measuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:02:32 +02:00
dejvino
ea386bca2a A garage of cars, traded against being noticed
The loop's problem was that it had no decision in it. Condition was
health, capability, currency sink and fail state all at once, so every
event pushed the same way and there was nothing to weigh against
anything else.

This is the axis the loop turns on instead:

  A tractor is slow and made of tin, and nobody looks twice at it.
  An armoured car survives being shot at, and everybody notices it.

Five cars from a field tractor to an armoured car. Money buys them
permanently rather than paying a repair bill, so progress climbs while
any single drive still declines. There is deliberately no best one - a
quiet errand down cold roads wants the thing nobody reports, a run into
a district that already knows your face wants the thing that survives
what happens next.

Kept honest by a pairwise test rather than a glance at the table: every
rung has to be more conspicuous than the one below by at least the
geometric mean of what it gains in toughness and speed. The armoured
car failed it on the first pass - 3.7x tougher for 1.4x the attention,
which is a strictly better car and would have collapsed the choice back
into "drive the newest thing". Repriced to 3.6, because being
undercover in an armoured car ought to be close to a contradiction in
terms.

Catalogue and state only; nothing reads it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:59:21 +02:00
dejvino
47e9e8bfdb Stop the loop eating itself, ahead of the garage
Diagnosed from a complaint that the car swerves, goes too slow, cannot
outrun anything, and that dying just restarts the same decline. The
constants below are not the real answer - the loop needs restructuring,
and a garage of cars is coming - but each of these is a defect on its
own terms and any redesign still wants them fixed.

steeringPull was the worst of them. It is an uncommanded steering input
that never lets go, and at 0.06 linear it walked the car off a
fifteen-metre trunk, driving dead straight, in under seven seconds at
90% chassis. That is the swerve, and it was also most of the speed
loss, because off the tarmac drive force drops to 55%. It reads from
`chassis`, the subsystem a collision hurts most - so raising crash
damage fivefold, which I did on request without checking what depended
on it, quietly turned one bad corner into a car that cannot be driven
straight. Squared and much smaller, early wear is texture and only a
genuinely bent car fights you for the wheel. Same test: on the road at
90%, and holding for 13 seconds at 80% instead of six.

Dying sent you to the nearest base, and two of the four sit out in
frontier ground - so dying deep put you deeper, in a worse car, with
five ambient patrols for company. Now the nearest base on ground your
own side holds.

Repairs were 100 funds a condition point, so one crash cost 1.4 points,
¤140, four missions - in a car by then too slow to do them well. The
permanent share is what a crash is supposed to cost; being unable to
afford to get back on the road at all is a dead end, not a
consequence. Repairs are 2.5x cheaper and the scar is priced
separately so it stays expensive.

And a floor: a derelict car at a base with an empty pocket gets patched
up for nothing. It buys back no ceiling, so the decline is untouched -
it only removes the state where no sequence of good decisions gets you
out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:56:29 +02:00
dejvino
cf5d55bdf0 Look around with the mouse
Hold the right button and drag to swing the view round the car; let go
and it eases back to the road over a moment, the way you turn your head
back rather than being snapped to it. Measured: 9.6m of orbit on a
short drag, and exactly zero drift from centre once released.

Deliberately not pointer lock. Locking the cursor feels better in a
driving game right up until you want to press one of the debug panel
buttons or read the quest board, both of which are ordinary DOM sitting
over the canvas - a game that swallows the cursor to look left is one
you have to escape out of to use its own interface.

The camera orbits the car rather than turning on the spot, so the car
stays in frame and you can still see what you are about to drive into
while looking away from it.

Read at frame rate rather than from the fixed step, since this is the
one input where lag shows up directly as the view dragging behind the
mouse - through a separate accessor, because `read()` clears the
buffered one-shot keys and calling it from the render loop would
swallow whichever keypress landed that frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:51:57 +02:00
dejvino
4bec70ba38 Show who is looking at you, and give them time to decide
Two complaints with one cause: cover was too easy to lose, and losing
it came out of nowhere. Suspicion started climbing the instant anybody
had line of sight, so driving *past* a checkpoint cost exactly what
loitering at one did, and the only feedback was a bar filling up with
no indication of who was filling it.

Watchers now settle on you before they start filling anything. Passing
through quickly leaves everyone at a glance; lingering is what costs
you. Measured: attention 0.35 at one second, 0.70 at two, sure at
three, hunted at six - so there is a couple of seconds where somebody
has clocked you and there is still time to be somewhere else.

And that half is now visible. A diamond floats over anybody watching,
growing and reddening as they make their mind up, so the decision -
keep going or get out of sight - has a direction and a rate attached to
it out in the world rather than in the corner of the screen. The HUD
says how many are watching and how settled the keenest of them is.

Once it goes wrong, hunters are drawn on the minimap in solid red with
a line back to the car, and the HUD carries a compass arrow per hunter.
A count tells you to panic; a set of bearings tells you which way to
go, which is the actual decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:48:23 +02:00
dejvino
18477f0e38 Traffic that rejoins its lane instead of orbiting it
Cars were driving tight circles at certain junctions, one after
another: twenty laps in thirty seconds to net eight metres.

The lane aim point was a fixed distance *along the lane* from where the
car projects onto it, and that breaks down at both ends. Close in, the
point falls inside the tightest circle the car can drive - about six
metres at TURN_RATE - so it cannot be reached, the car swings past and
comes round again, forever. The obvious fix of growing the lookahead
with the error makes the other end worse: the point runs away down the
lane until the direction to it is almost parallel with the lane itself,
the steering correction vanishes, and the car drifts further out every
step. Instrumented one drifting from 28 to 42 metres wide of its own
road while dutifully pointing along it.

Measuring the lookahead from the *car* instead fixes both at once. On
the line it sits ahead and the car tracks straight; off it by less than
the lookahead the point slides back along the lane and the car cuts in;
off by more there is no such point at all, so it aims at the nearest
bit of lane there is and drives at it.

Worst car over thirty seconds: 21 turns before, 2.7 after - and 2.7
across 419 metres of a grid map is just corners. Worst distance off
lane: 42 metres and growing, now 13.6 and closing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:43:22 +02:00
dejvino
d8a1f784ae Civilians with some sense of self-preservation
Three things people were not doing.

They wandered into the road and ambled across four lanes at the pace
they cross a courtyard. That quietly made running one over the world's
fault rather than yours: if people walk into traffic by themselves,
hitting one costs the player nothing. They now keep off the tarmac, and
anyone who does need the other side commits and hurries - holding their
heading, because dithering in the middle of a road is the one thing
nobody does. Measured over four minutes: none of thirty standing in a
road, and crossings covered well over half again the ground per second
that strolling does. A small chance of committing is deliberate; pure
avoidance turns every block into a sealed pen.

They only fled the sight of somebody holding a rifle, not the sound of
one going off. Gunfire now raises an alarm that outlasts the noise by
six seconds, because a crowd that froze the instant the last round was
fired reads as a switch rather than as fear. Running from it beats
keeping off the road - that is the moment people stop being careful.

And traffic drove straight into a parked car and shunted it down the
street, which makes every other vehicle read as weather rather than as
a driver. Civilians now yield to the player as well as to each other.
Parked across a road for ninety seconds: closest approach 9.2m, shoved
zero metres. Unless they have just heard shooting, in which case they
do not wait behind you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:49:38 +02:00
dejvino
2ffef66ef8 Field contacts exist in the world, not only on the map
The minimap has always drawn these as a pink dot and the 3D world had
nothing there at all, so the one kind of work you are meant to find by
looking was the one kind you could only find by reading the map - and
driving to the dot put you in an empty street.

There is now a marker in the world, and underneath it the thing itself:
somebody standing where nobody stands, or a burnt-out car worth
stripping. The prop is what you recognise; the marker is so you can
pick it out from scenery at the speed you are going, and the ring on
the ground is the radius you have to stop inside.

Deliberately a fifth the height of a base beacon. These are work you
find rather than work you plan - you cannot compare one against
anything or route to it - and a pillar visible across the map would
quietly turn them into errands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:39:07 +02:00
dejvino
627eca2f3d Everyone was driving on the left
This world is right-handed with Y up, so a car facing +Z has its
right-hand side toward -X. The lane offset used (dirZ, -dirX), which is
+X, which is left. Every vehicle in the game drove on the wrong side of
the road.

It was invisible for the usual reason: traffic still held neat parallel
lines and still passed oncoming cars without touching, just on the
wrong side. And the test agreed, because the test computed its idea of
"right" by copying the sim's vector, so it inherited the same inverted
sign and cheerfully certified the whole map.

Confirmed three ways before changing anything: the geometry, the HUD's
own compass - which reads +X at heading 0 as a left turn - and driving
the car, where holding W and D from heading 0 ends up at negative x.

The test now derives nothing from sim/units.ts. It asks the HUD's
compass which way the lane offset points and requires the answer to be
"right". Two modules that must agree, written independently, is the
only version of this test that can fail; inverting the vector under it
now fails all three cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:35:47 +02:00
19 changed files with 2136 additions and 94 deletions

View File

@ -34,6 +34,7 @@ seeds may be numbers or words.
| `1``3` | accept a mission, when parked at a base | | `1``3` | accept a mission, when parked at a base |
| `X` | give up the mission in hand, when parked at a base | | `X` | give up the mission in hand, when parked at a base |
| `C` | overhaul the car, when parked at a base | | `C` | overhaul the car, when parked at a base |
| right-drag | look around; let go and the view swings back |
| `M` | sound on/off | | `M` | sound on/off |
| hold `R` | wipe the campaign and start over (5 seconds) | | hold `R` | wipe the campaign and start over (5 seconds) |

View File

@ -13,6 +13,14 @@ export interface DriverInput {
abandon: boolean; abandon: boolean;
/** True on the frame C was pressed: overhaul the car. Consumed on read. */ /** True on the frame C was pressed: overhaul the car. Consumed on read. */
overhaul: boolean; overhaul: boolean;
/**
* Where the player is looking, relative to straight ahead, in radians.
*
* `active` is whether they are holding the look button right now. The offsets
* survive the release so the camera can ease back rather than snapping, which
* is the difference between glancing over your shoulder and being teleported.
*/
look: { yaw: number; pitch: number; active: boolean };
} }
const KEYS = { const KEYS = {
@ -31,6 +39,14 @@ const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
export function createInput(): { export function createInput(): {
read(): DriverInput; read(): DriverInput;
/**
* Where the player is looking, without consuming anything.
*
* `read` clears the buffered one-shot keys as a side effect, so the render
* loop cannot call it just to find out where the camera should point doing
* that swallows whichever keypress happened to land that frame.
*/
look(): DriverInput['look'];
onGesture(callback: () => void): void; onGesture(callback: () => void): void;
dispose(): void; dispose(): void;
} { } {
@ -42,6 +58,18 @@ export function createInput(): {
let pendingMute = false; let pendingMute = false;
let pendingAbandon = false; let pendingAbandon = false;
let pendingOverhaul = false; let pendingOverhaul = false;
/**
* Look-around, on a held mouse button and a drag.
*
* Deliberately not pointer lock. Locking the cursor is the better feel for a
* driving game right up until you want to press one of the buttons on the
* debug panel or read the quest board, both of which are ordinary DOM sitting
* over the canvas and a game that swallows the cursor to look left is a
* game you have to escape out of to use its own interface.
*/
let lookYaw = 0;
let lookPitch = 0;
let looking = false;
/** Called on the first real interaction, to satisfy autoplay policy. */ /** Called on the first real interaction, to satisfy autoplay policy. */
let onFirstGesture: (() => void) | null = null; let onFirstGesture: (() => void) | null = null;
@ -69,11 +97,45 @@ export function createInput(): {
pendingMute = false; pendingMute = false;
pendingAbandon = false; pendingAbandon = false;
pendingOverhaul = false; pendingOverhaul = false;
// Losing the window with the button held would otherwise leave the view
// stuck over one shoulder with no way to let go of it.
looking = false;
}; };
/** Radians of view per pixel dragged. */
const LOOK_SENSITIVITY = 0.005;
/** How far round you can crane your neck. Just past square, not all the way. */
const LOOK_YAW_LIMIT = Math.PI * 0.75;
const LOOK_PITCH_LIMIT = 0.55;
const onMouseDown = (e: MouseEvent) => {
// Right or middle button: left stays free for the panels drawn over the top.
if (e.button !== 2 && e.button !== 1) return;
looking = true;
e.preventDefault();
};
const onMouseUp = (e: MouseEvent) => {
if (e.button !== 2 && e.button !== 1) return;
looking = false;
};
const onMouseMove = (e: MouseEvent) => {
if (!looking) return;
lookYaw = Math.max(-LOOK_YAW_LIMIT, Math.min(LOOK_YAW_LIMIT, lookYaw - e.movementX * LOOK_SENSITIVITY));
lookPitch = Math.max(
-LOOK_PITCH_LIMIT,
Math.min(LOOK_PITCH_LIMIT, lookPitch - e.movementY * LOOK_SENSITIVITY),
);
};
/** Or the drag would open the browser's own menu over the game. */
const onContextMenu = (e: MouseEvent) => e.preventDefault();
window.addEventListener('keydown', onDown); window.addEventListener('keydown', onDown);
window.addEventListener('keyup', onUp); window.addEventListener('keyup', onUp);
window.addEventListener('blur', onBlur); window.addEventListener('blur', onBlur);
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('contextmenu', onContextMenu);
return { return {
read: () => { read: () => {
@ -94,9 +156,12 @@ export function createInput(): {
toggleMute, toggleMute,
abandon, abandon,
overhaul, overhaul,
look: { yaw: lookYaw, pitch: lookPitch, active: looking },
}; };
}, },
look: () => ({ yaw: lookYaw, pitch: lookPitch, active: looking }),
/** /**
* Browsers will not let anything make noise until the user has interacted, * Browsers will not let anything make noise until the user has interacted,
* so the audio context is resumed from here rather than at boot. * so the audio context is resumed from here rather than at boot.
@ -115,6 +180,10 @@ export function createInput(): {
window.removeEventListener('keydown', onDown); window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp); window.removeEventListener('keyup', onUp);
window.removeEventListener('blur', onBlur); window.removeEventListener('blur', onBlur);
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('contextmenu', onContextMenu);
down.clear(); down.clear();
}, },
}; };

View File

@ -3,8 +3,10 @@ import { generateWorld } from './sim/world';
import { import {
applyWear, applyWear,
canOverhaul, canOverhaul,
charity,
deriveHandling, deriveHandling,
freshCondition, freshCondition,
isDerelict,
overhaul, overhaul,
repair, repair,
surfaceFor, surfaceFor,
@ -33,12 +35,14 @@ import {
type Offer, type Offer,
} from './sim/quests'; } from './sim/quests';
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia'; import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
import { createGarage, current as drivingNow } from './sim/garage';
import { createRadio, pollRadio } from './sim/radio'; import { createRadio, pollRadio } from './sim/radio';
import { createOpportunities, stepOpportunities } from './sim/opportunities'; import { createOpportunities, stepOpportunities } from './sim/opportunities';
import { createHeatProps } from './heatProps'; import { createHeatProps } from './heatProps';
import { createUnits, dispatchTo, stepUnits } from './sim/units'; import { createUnits, dispatchTo, stepUnits } from './sim/units';
import { createCombat, dangerNear, stepCombat } from './sim/combat'; import { createCombat, dangerNear, stepCombat } from './sim/combat';
import { import {
closestAttention,
createPursuit, createPursuit,
PROVOCATION, PROVOCATION,
recognitionMeter, recognitionMeter,
@ -73,6 +77,7 @@ const DEAD_HANDS = {
toggleMute: false, toggleMute: false,
abandon: false, abandon: false,
overhaul: false, overhaul: false,
look: { yaw: 0, pitch: 0, active: false },
} as const; } as const;
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */ /** Debug handle and frame capture, for inspecting a build that cannot be seen. */
@ -163,6 +168,16 @@ async function boot() {
const unitView = createUnitView(view.scene); const unitView = createUnitView(view.scene);
const unitBodies = createUnitBodies(physics); const unitBodies = createUnitBodies(physics);
const pursuit = createPursuit(); const pursuit = createPursuit();
const garage = createGarage();
/** Put whatever is in the garage under the player: weight, and paint. */
const equip = () => {
const spec = drivingNow(garage);
physics.setCarMass(spec.mass);
view.setCarLook(spec);
return spec;
};
let car = equip();
// Bullets stop at buildings, so combat needs a fast "is this inside a wall" // Bullets stop at buildings, so combat needs a fast "is this inside a wall"
// lookup. A grid built once at boot beats scanning a thousand obstacles. // lookup. A grid built once at boot beats scanning a thousand obstacles.
@ -254,6 +269,8 @@ async function boot() {
let provocation = 0; let provocation = 0;
/** Times the car has been written off underneath the player. */ /** Times the car has been written off underneath the player. */
let kia = 0; let kia = 0;
/** Muzzle flashes from last step, so civilians can react to being shot near. */
let lastGunfire: Array<{ x: number; z: number }> = [];
/** Seconds left of watching the wreck, or 0 when alive. */ /** Seconds left of watching the wreck, or 0 when alive. */
let dying = 0; let dying = 0;
/** Where the camera has got to in its drift around it. */ /** Where the camera has got to in its drift around it. */
@ -404,7 +421,21 @@ async function boot() {
const finishDeath = () => { const finishDeath = () => {
kia++; kia++;
const at = physics.chassis.translation(); const at = physics.chassis.translation();
const home = bases.reduce((best, b) => /*
* The nearest base *your own side holds*, not simply the nearest.
*
* Bases are placed by spreading them across the map, which was fine when
* they were only quest boards two of the four sit out in frontier ground.
* Once one of them became where you wake up after dying, "nearest" meant
* that dying deep in enemy country put you deeper still: a fresh dent in
* the car, five ambient patrols for company, and a drive home you were in
* no state to make. Dying should set you back, not bury you.
*/
const reachable = bases.filter((b) => {
const ground = controlAt(front, b.x, b.z);
return ground === 'liberated' || ground === 'contested';
});
const home = (reachable.length > 0 ? reachable : bases).reduce((best, b) =>
Math.hypot(b.x - at.x, b.z - at.z) < Math.hypot(best.x - at.x, best.z - at.z) ? b : best, Math.hypot(b.x - at.x, b.z - at.z) < Math.hypot(best.x - at.x, best.z - at.z) ? b : best,
); );
@ -473,7 +504,14 @@ async function boot() {
// Tarmac or open ground — decided last step, since the road lookup needs // Tarmac or open ground — decided last step, since the road lookup needs
// a position and the car has not moved yet this one. // a position and the car has not moved yet this one.
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null)); drive(
physics,
driveState,
cmd,
deriveHandling(condition, car),
dt,
surfaceFor(currentSegment !== null),
);
physics.step(dt); physics.step(dt);
captureChassis(); captureChassis();
@ -486,12 +524,12 @@ async function boot() {
const lastImpact = physics.drainImpactForce(); const lastImpact = physics.drainImpactForce();
audio.impact(lastImpact); audio.impact(lastImpact);
condition = applyWear(condition, { condition = applyWear(
dt, condition,
distance, { dt, distance, throttle: Math.abs(cmd.throttle), impactForce: lastImpact },
throttle: Math.abs(cmd.throttle), // Armour is this number: what the car actually feels of a knock.
impactForce: lastImpact, car.fragility,
}); );
// Heat: the road remembers being used — including being driven alongside. // Heat: the road remembers being used — including being driven alongside.
// Behind your own lines it remembers nothing: nobody there is watching. // Behind your own lines it remembers nothing: nobody there is watching.
@ -550,6 +588,18 @@ async function boot() {
const base = dead ? null : baseAt(bases, at.x, at.z); const base = dead ? null : baseAt(bases, at.x, at.z);
const stopped = Math.abs(speed) < 2.5; const stopped = Math.abs(speed) < 2.5;
// --- The bottom of the barrel ---
// Nobody is left sitting in a wreck with an empty pocket. This is the one
// thing in the loop that pushes back: everything else makes a bad car
// likelier to get worse. It restores nothing permanent — the ceiling is
// untouched — so the decline still stands and only the dead end goes.
if (base && stopped && isDerelict(condition) && quests.funds < 20) {
condition = charity(condition);
say('They will not watch you push it. Patched up, and you owe them.', 7);
audio.ui('accept');
persistence.checkpoint(elapsed, snapshot());
}
// --- Overhaul: buying back what the car is capable of --- // --- Overhaul: buying back what the car is capable of ---
// Offered whether or not there is a job in hand, since a workshop does // Offered whether or not there is a job in hand, since a workshop does
// not care what you are carrying. Deliberately a bad rate next to an // not care what you are carrying. Deliberately a bad rate next to an
@ -758,6 +808,9 @@ async function boot() {
// is already blocked by them; movement has to be too, or "break line // is already blocked by them; movement has to be too, or "break line
// of sight and change direction" is advice the world does not honour. // of sight and change direction" is advice the world does not honour.
blocked: insideBuilding, blocked: insideBuilding,
// Shooting is resolved further down, so this is last step's. People
// hear it and then move, which is the right way round anyway.
gunfire: lastGunfire,
}, },
model.roads, model.roads,
graph, graph,
@ -824,6 +877,21 @@ async function boot() {
units, units,
canSee, canSee,
provocation, provocation,
/*
* How wary this place is: the heat on the road being used, or on
* the district when off it.
*
* This is what finally makes heat a *continuous* pressure rather
* than something that only exists at the thresholds where concrete
* appears. Use one route all week and the people on it start
* expecting you; take a cold road and a car going past is a car
* going past.
*/
wariness:
currentSegment === null
? (heat.area[areaAt(areas, at.x, at.z) ?? 0] ?? 0)
: effectiveHeat(heat, areas, currentSegment),
presence: car.presence,
}); });
for (const event of noticed) { 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);
@ -855,10 +923,12 @@ async function boot() {
}, },
condition, condition,
chatterRng, chatterRng,
car.fragility,
); );
condition = shooting.condition; condition = shooting.condition;
const listener = { x: at.x, z: at.z, heading: headingOf() }; const listener = { x: at.x, z: at.z, heading: headingOf() };
for (const muzzle of shooting.fired) audio.shot(muzzle, listener); for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
lastGunfire = shooting.fired;
if (shooting.playerHit) { if (shooting.playerHit) {
provocation += PROVOCATION.shotAt; provocation += PROVOCATION.shotAt;
audio.hit(); audio.hit();
@ -913,6 +983,8 @@ async function boot() {
// anywhere the player needs to be — the bases' own beacons take over. // anywhere the player needs to be — the bases' own beacons take over.
const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null; const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null;
markers.setObjective(heading ? nodeOf(heading.targetNode) : null); markers.setObjective(heading ? nodeOf(heading.targetNode) : null);
// The minimap has always drawn field contacts; now the world does too.
markers.setOpportunity(opportunities.current);
}, },
render(alpha, frameDt) { render(alpha, frameDt) {
@ -945,7 +1017,7 @@ async function boot() {
mesh.quaternion.set(q.x, q.y, q.z, q.w); mesh.quaternion.set(q.x, q.y, q.z, q.w);
} }
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z }); unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z }, pursuit.eyes);
view.followSun(); view.followSun();
view.setTone(control, frameDt); view.setTone(control, frameDt);
markers.update(elapsed); markers.update(elapsed);
@ -954,6 +1026,10 @@ async function boot() {
frameDt, frameDt,
physics.vehicle.currentVehicleSpeed(), physics.vehicle.currentVehicleSpeed(),
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null, dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
// Read at frame rate rather than from the fixed step: this is the one
// input where lag is felt directly, as the view dragging behind the
// mouse. Deliberately not `read()`, which consumes the buffered keys.
input.look(),
); );
view.renderer.render(view.scene, view.camera); view.renderer.render(view.scene, view.camera);
@ -972,6 +1048,16 @@ async function boot() {
heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)), heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
objective: quest && quest.stage !== 'return' ? target : null, objective: quest && quest.stage !== 'return' ? target : null,
opportunity: opportunities.current, opportunity: opportunities.current,
// Live positions, not remembered ones: these are people currently
// looking out of a window at you.
watchers: units.units
.filter((u) => pursuit.eyes.has(u.id) || u.hunting)
.map((u) => ({
x: u.x,
z: u.z,
settled: pursuit.eyes.get(u.id) ?? 1,
hunting: u.hunting === true,
})),
}); });
} }
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
@ -1003,6 +1089,15 @@ async function boot() {
hunters: pursuit.hunters.size, hunters: pursuit.hunters.size,
// Counts down only once nobody has eyes on you. // Counts down only once nobody has eyes on you.
losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0, losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0,
attention: closestAttention(pursuit),
watching: pursuit.eyes.size,
bearings: units.units
.filter((u) => pursuit.hunters.has(u.id))
.map(
(u) =>
Math.atan2(u.x - p.x, u.z - p.z) -
Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
),
}, },
danger: dangerNear(combat, p.x, p.z, 90), danger: dangerNear(combat, p.x, p.z, 90),
completed: quests.completed, completed: quests.completed,
@ -1024,7 +1119,19 @@ async function boot() {
view, view,
physics, physics,
// Live state, so a script can find something interesting and go look at it. // Live state, so a script can find something interesting and go look at it.
state: { units, combat, heat, areas, intel, quests, front, model, bases, pursuit }, state: {
units,
combat,
heat,
areas,
intel,
quests,
front,
model,
bases,
pursuit,
opportunities,
},
condition: () => condition, condition: () => condition,
/** /**
* Force the car's condition, for looking at how a wrecked car drives * Force the car's condition, for looking at how a wrecked car drives

View File

@ -22,6 +22,7 @@ const IDLE: DriverInput = {
toggleMute: false, toggleMute: false,
abandon: false, abandon: false,
overhaul: false, overhaul: false,
look: { yaw: 0, pitch: 0, active: false },
}; };
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */ /** Rapier runs headless, so vehicle tuning is checkable without a browser. */

View File

@ -20,6 +20,17 @@ export interface PhysicsWorld {
telemetry(): Telemetry; telemetry(): Telemetry;
/** A body the sim moves by hand, which still collides with the player. */ /** A body the sim moves by hand, which still collides with the player. */
addKinematicBox(box: KinematicBox): RAPIER.RigidBody; addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
/**
* Swap what the player is driving.
*
* Only the mass properties change, not the collider: a tractor and an
* armoured car occupy visibly different boxes on screen, but giving them
* different colliders means rebuilding the vehicle controller mid-campaign,
* and every barricade gap and building alley in the world is sized against
* one car. Mass is what actually changes how the thing drives what it
* shrugs off, how long it takes to stop so that is what moves.
*/
setCarMass(mass: number): void;
} }
export interface KinematicBox { export interface KinematicBox {
@ -157,6 +168,22 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
return v; return v;
}, },
setCarMass(mass: number) {
chassis.setAdditionalMassProperties(
mass,
{ x: 0, y: -0.35, z: 0 },
// Scaled from the reference car's tensor, so a heavier vehicle also
// resists being spun round rather than merely being harder to shift.
{
x: 1369 * (mass / CAR.mass),
y: 1621 * (mass / CAR.mass),
z: 342 * (mass / CAR.mass),
},
{ x: 0, y: 0, z: 0, w: 1 },
true,
);
},
addKinematicBox(box) { addKinematicBox(box) {
// Kinematic rather than dynamic: the unit sim owns where these are, but // Kinematic rather than dynamic: the unit sim owns where these are, but
// they still shove the player's car when they meet it. // they still shove the player's car when they meet it.

View File

@ -1,11 +1,22 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { Base } from '../sim/bases'; import type { Base } from '../sim/bases';
import { OPPORTUNITY_RADIUS, type Opportunity } from '../sim/opportunities';
/** /**
* Bases and the active objective, as things you can see from a distance. * Bases and the active objective, as things you can see from a distance.
* The player navigates by looking, so both need to be visible over scenery. * The player navigates by looking, so both need to be visible over scenery.
*/ */
const BEACON_HEIGHT = 34; const BEACON_HEIGHT = 34;
/**
* How tall a field contact's marker stands.
*
* Deliberately a fraction of a base beacon. These are meant to be work you
* *find*, not work you plan you cannot compare one against anything or route
* to it, you take the detour in front of you or you drive on. A pillar visible
* across the map would turn them into errands. At this height it clears a car
* and reads a street away, and no further.
*/
const CONTACT_HEIGHT = 7;
/** /**
* A slim pillar plus a ring on the ground. * A slim pillar plus a ring on the ground.
@ -15,7 +26,7 @@ const BEACON_HEIGHT = 34;
* middle of one, that is exactly where you spend your time. Narrow enough to see * middle of one, that is exactly where you spend your time. Narrow enough to see
* past, tall enough to see over buildings. * past, tall enough to see over buildings.
*/ */
function beacon(colour: number, radius: number): THREE.Group { function beacon(colour: number, radius: number, height = BEACON_HEIGHT): THREE.Group {
const group = new THREE.Group(); const group = new THREE.Group();
const material = new THREE.MeshBasicMaterial({ const material = new THREE.MeshBasicMaterial({
color: colour, color: colour,
@ -28,10 +39,10 @@ function beacon(colour: number, radius: number): THREE.Group {
}); });
const pillar = new THREE.Mesh( const pillar = new THREE.Mesh(
new THREE.CylinderGeometry(0.9, 0.9, BEACON_HEIGHT, 8, 1, true), new THREE.CylinderGeometry(0.9, 0.9, height, 8, 1, true),
material, material,
); );
pillar.position.y = BEACON_HEIGHT / 2; pillar.position.y = height / 2;
group.add(pillar); group.add(pillar);
// The ring is what tells you where to actually stop. // The ring is what tells you where to actually stop.
@ -68,7 +79,54 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
objective.visible = false; objective.visible = false;
scene.add(objective); scene.add(objective);
/*
* A field contact, as something actually standing in the road.
*
* The minimap has always drawn these as a pink dot and the world had nothing
* there at all, so the one kind of work you are supposed to find by looking
* was the one kind you could only find by reading the map and driving to
* the dot put you in an empty street.
*
* So there is a marker, and underneath it the thing itself: somebody standing
* at the roadside, or a burnt-out car worth stripping. The prop is what you
* actually recognise; the marker is so you can tell it from the scenery at
* the speed you are going.
*/
const contact = beacon(0xcf6bd6, OPPORTUNITY_RADIUS, CONTACT_HEIGHT);
const person = new THREE.Mesh(
new THREE.CapsuleGeometry(0.45, 1.4, 4, 8),
new THREE.MeshStandardMaterial({ color: 0xd8c9a4, roughness: 0.8 }),
);
person.position.y = 1.05;
person.castShadow = true;
contact.add(person);
const wreck = new THREE.Mesh(
new THREE.BoxGeometry(1.9, 1.3, 4.2),
new THREE.MeshStandardMaterial({ color: 0x2b2521, roughness: 1 }),
);
wreck.position.y = 0.6;
// Slewed and nose-down, so it reads as abandoned rather than parked.
wreck.rotation.set(0.06, 0.7, 0.11);
wreck.castShadow = true;
contact.add(wreck);
contact.visible = false;
scene.add(contact);
return { return {
/**
* Show whoever is waiting, or nobody. A wreck is a wreck; everything else
* is a person standing where a person would not normally stand.
*/
setOpportunity(current: Opportunity | null) {
contact.visible = current !== null;
if (!current) return;
contact.position.set(current.x, 0, current.z);
wreck.visible = current.kind === 'wreck';
person.visible = current.kind !== 'wreck';
},
/** Point the objective beacon at a target, or hide it when idle. */ /** Point the objective beacon at a target, or hide it when idle. */
setObjective(target: { x: number; z: number } | null) { setObjective(target: { x: number; z: number } | null) {
objective.visible = target !== null; objective.visible = target !== null;
@ -77,6 +135,7 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
/** Slow spin, so a beacon reads as a marker rather than scenery. */ /** Slow spin, so a beacon reads as a marker rather than scenery. */
update(elapsed: number) { update(elapsed: number) {
objective.rotation.y = elapsed * 0.6; objective.rotation.y = elapsed * 0.6;
contact.rotation.y = elapsed * 0.45;
}, },
}; };
} }

View File

@ -12,6 +12,8 @@ export interface SceneView {
wheels: THREE.Object3D[]; wheels: THREE.Object3D[];
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */ /** Only the dynamic crates need per-frame syncing; blocks are instanced. */
crates: Array<{ index: number; mesh: THREE.Mesh }>; crates: Array<{ index: number; mesh: THREE.Mesh }>;
/** Dress the car as whatever is being driven: shape and paint. */
setCarLook(spec: { colour: number; size: { width: number; height: number; length: number } }): void;
/** Keeps the shadow frustum centred on the car. */ /** Keeps the shadow frustum centred on the car. */
followSun(): void; followSun(): void;
/** Eases the world's colour and visibility toward the current territory. */ /** Eases the world's colour and visibility toward the current territory. */
@ -198,9 +200,14 @@ export function createScene(model: WorldModel): SceneView {
// --- Car --- // --- Car ---
const car = new THREE.Group(); const car = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: 0x8c3b34,
roughness: 0.55,
metalness: 0.15,
});
const body = new THREE.Mesh( const body = new THREE.Mesh(
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2), new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }), bodyMat,
); );
body.castShadow = true; body.castShadow = true;
car.add(body); car.add(body);
@ -251,6 +258,16 @@ export function createScene(model: WorldModel): SceneView {
car, car,
wheels, wheels,
crates, crates,
setCarLook(spec) {
// The mesh changes shape; the collider deliberately does not — see
// `setCarMass`. A tractor should *look* like a tractor from the mirror
// without every barricade gap in the world having to be re-sized.
bodyMat.color.setHex(spec.colour);
body.scale.set(spec.size.width, spec.size.height, spec.size.length);
cabin.position.y = CAR.halfHeight * spec.size.height + 0.25;
cabin.scale.set(spec.size.width, spec.size.height, spec.size.length);
},
followSun() { followSun() {
// Keep the shadow frustum centred on the car rather than the origin. // Keep the shadow frustum centred on the car rather than the origin.
sun.position.set(car.position.x + 45, 70, car.position.z + 25); sun.position.set(car.position.x + 45, 70, car.position.z + 25);
@ -285,6 +302,19 @@ const toneColour = new THREE.Color();
const camTarget = new THREE.Vector3(); const camTarget = new THREE.Vector3();
const camDesired = new THREE.Vector3(); const camDesired = new THREE.Vector3();
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5); const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);
const UP = new THREE.Vector3(0, 1, 0);
/**
* Where the camera is currently looking relative to straight ahead, eased.
*
* Held here rather than in the input layer because it is a property of the
* *camera*, not of what the player is doing with the mouse: they let go of the
* button and the view swings back over a moment, the way you turn your head
* back to the road rather than being snapped to it.
*/
const look = { yaw: 0, pitch: 0 };
/** How high the camera rises across the full pitch range, metres. */
const LOOK_LIFT = 4.5;
/** /**
* Where the camera starts and ends up while pulling off a wreck. * Where the camera starts and ends up while pulling off a wreck.
@ -314,6 +344,7 @@ export function updateCamera(
dt: number, dt: number,
speed: number, speed: number,
wake: { angle: number; progress: number } | null = null, wake: { angle: number; progress: number } | null = null,
looking: { yaw: number; pitch: number; active: boolean } = { yaw: 0, pitch: 0, active: false },
): void { ): void {
const { camera, car } = view; const { camera, car } = view;
@ -332,14 +363,26 @@ export function updateCamera(
return; return;
} }
// Ease toward where they are looking, or back to the road once they let go.
const wantYaw = looking.active ? looking.yaw : 0;
const wantPitch = looking.active ? looking.pitch : 0;
const settle = 1 - Math.exp(-(looking.active ? 14 : 5) * dt);
look.yaw += (wantYaw - look.yaw) * settle;
look.pitch += (wantPitch - look.pitch) * settle;
camDesired.copy(CHASE_OFFSET); camDesired.copy(CHASE_OFFSET);
// Pull back a little at speed for a sense of pace. // Pull back a little at speed for a sense of pace.
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3); camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);
// Swing round the car rather than turning on the spot, so the car stays in
// frame and you can see what you are about to drive into while looking away.
camDesired.applyAxisAngle(UP, look.yaw);
camDesired.y += look.pitch * LOOK_LIFT;
camDesired.applyQuaternion(car.quaternion).add(car.position); camDesired.applyQuaternion(car.quaternion).add(car.position);
const lerp = 1 - Math.exp(-6 * dt); const lerp = 1 - Math.exp(-6 * dt);
camera.position.lerp(camDesired, lerp); camera.position.lerp(camDesired, lerp);
camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position); camTarget.set(0, 1.2, 4).applyAxisAngle(UP, look.yaw).applyQuaternion(car.quaternion);
camTarget.add(car.position);
camera.lookAt(camTarget); camera.lookAt(camTarget);
} }

View File

@ -53,6 +53,15 @@ function colourOf(unit: Unit): number {
return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier; return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier;
} }
/**
* Colours for the marker over somebody who is looking at you: a glance is
* amber and barely there, certainty is red and unmistakable.
*/
const GLANCE = new THREE.Color(0xe0b040);
const CERTAIN = new THREE.Color(0xff3a2a);
/** How high above a unit its attention marker floats. */
const EYE_HEIGHT = 3.1;
export function createUnitView(scene: THREE.Scene) { export function createUnitView(scene: THREE.Scene) {
const scratch = new THREE.Matrix4(); const scratch = new THREE.Matrix4();
const quaternion = new THREE.Quaternion(); const quaternion = new THREE.Quaternion();
@ -114,6 +123,23 @@ export function createUnitView(scene: THREE.Scene) {
MAX_UNITS, MAX_UNITS,
); );
/**
* A diamond over anybody who has their eye on you, growing and reddening as
* they settle on it.
*
* Cover used to be a single bar in the corner that filled up with no
* indication of who was filling it, so being recognised arrived from nowhere.
* The decision the player is being asked to make keep going or get out of
* sight needs a *direction* and a rate, and both of those live out in the
* world rather than in the HUD.
*/
const eyes = new THREE.InstancedMesh(
new THREE.OctahedronGeometry(0.55),
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.9 }),
MAX_UNITS,
);
eyes.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
// Towers, which only exist once someone has built them. // Towers, which only exist once someone has built them.
const towers = new THREE.InstancedMesh( const towers = new THREE.InstancedMesh(
new THREE.BoxGeometry(3, 6, 3), new THREE.BoxGeometry(3, 6, 3),
@ -128,7 +154,7 @@ export function createUnitView(scene: THREE.Scene) {
32, 32,
); );
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) { for (const mesh of [cars, people, crew, guns, eyes, tracers, towers, towerGuns]) {
mesh.frustumCulled = false; mesh.frustumCulled = false;
scene.add(mesh); scene.add(mesh);
} }
@ -139,7 +165,13 @@ export function createUnitView(scene: THREE.Scene) {
}; };
return { return {
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) { update(
units: UnitState,
rounds: Round[],
elapsed: number,
player: { x: number; z: number },
watching: Map<number, number>,
) {
let carCount = 0; let carCount = 0;
let personCount = 0; let personCount = 0;
let crewCount = 0; let crewCount = 0;
@ -200,6 +232,25 @@ export function createUnitView(scene: THREE.Scene) {
crewCount++; crewCount++;
} }
// --- Whoever currently has eyes on you ---
let eyeCount = 0;
for (const unit of units.units) {
const settled = watching.get(unit.id);
if (settled === undefined || eyeCount >= MAX_UNITS) continue;
// Grows and reddens as they make their mind up, and bobs so it reads as
// a marker rather than as something built on the roof.
const size = 0.5 + settled * 0.85;
position.set(unit.x, EYE_HEIGHT + Math.sin(elapsed * 3 + unit.id) * 0.12, unit.z);
quaternion.setFromAxisAngle(up, elapsed * 1.6);
scratch.compose(position, quaternion, scale.set(size, size, size));
eyes.setMatrixAt(eyeCount, scratch);
eyes.setColorAt(eyeCount, colour.copy(GLANCE).lerp(CERTAIN, settled));
eyeCount++;
}
park(eyes, eyeCount);
eyes.instanceMatrix.needsUpdate = true;
if (eyes.instanceColor) eyes.instanceColor.needsUpdate = true;
park(cars, carCount); park(cars, carCount);
park(people, personCount); park(people, personCount);
park(crew, crewCount); park(crew, crewCount);

View File

@ -12,6 +12,17 @@
* what keeps it meaningful is the exchange rate rather than the impossibility. * what keeps it meaningful is the exchange rate rather than the impossibility.
*/ */
import { carById, type CarSpec } from './garage';
/**
* The car every balance figure in this file is quoted against.
*
* The estate is the ordinary one an unremarkable family car in the middle of
* the catalogue so "a crash costs 1.4 points of condition" means something
* concrete rather than depending on what happens to be in the garage.
*/
const REFERENCE_CAR: CarSpec = carById('estate');
export interface Subsystems { export interface Subsystems {
/** 1 = factory fresh, 0 = ruined. */ /** 1 = factory fresh, 0 = ruined. */
engine: number; engine: number;
@ -50,8 +61,15 @@ export interface Handling {
* the units it is actually about, and keeps the scale of the currency a purely * the units it is actually about, and keeps the scale of the currency a purely
* presentational decision ¤34 for a supply run reads as a payment, where 0.34 * presentational decision ¤34 for a supply run reads as a payment, where 0.34
* read as a fraction of something unnamed. * read as a fraction of something unnamed.
*
* Lowered from 100 once crash damage went up fivefold. A reference head-on
* costs 1.4 points of condition; at the old rate that was ¤140 to put right,
* four missions' pay, for one bad corner in a car that was by then too slow
* to do those missions well. The *permanent* share is meant to be what a crash
* costs you. Being unable to afford to get back on the road at all is not a
* consequence, it is a dead end.
*/ */
export const FUNDS_PER_CONDITION = 100; export const FUNDS_PER_CONDITION = 40;
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const; export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
@ -63,26 +81,63 @@ export const freshCondition = (): CarCondition => ({
const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
export function deriveHandling(c: CarCondition): Handling { /**
* Share of a car's figures still available when that subsystem is ruined.
*
* Condition scales what the car can do rather than replacing it, so a wrecked
* tractor is still a tractor and a wrecked armoured car is still heavy. Not
* zero: a car that stops entirely at 0% is a fail state wearing a dial, and the
* point of the garage is that being in a bad car is a situation rather than an
* ending.
*/
const RUINED = { drive: 0.35, brake: 0.33, steer: 0.65, grip: 0.35 };
/**
* What the car can do, from what it is and what state it is in.
*
* Both halves matter and they are different kinds of thing: the spec is the
* car you chose at the garage, the condition is what this sortie has done to
* it. Everything here is the spec's own figure scaled by wear, so the
* catalogue's numbers mean what they say.
*/
export function deriveHandling(c: CarCondition, spec: CarSpec = REFERENCE_CAR): Handling {
const { engine, tires, chassis } = c.level; const { engine, tires, chassis } = c.level;
return { return {
// A tired engine simply cannot push as hard. // A tired engine simply cannot push as hard.
engineForce: lerp(900, 2600, engine), engineForce: lerp(spec.engineForce * RUINED.drive, spec.engineForce, engine),
// Worn pads take longer to haul the car down. These are Rapier brake // Worn pads take longer to haul the car down. These are Rapier brake
// impulses, which have to be large next to a 1100kg chassis — the first // impulses, which have to be large next to a 1100kg chassis — the first
// values here were so weak the car would not come to a standstill at all. // values here were so weak the car would not come to a standstill at all.
// Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and // Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and
// the brake pedal stops feeling like a brake and starts feeling like a wall. // the brake pedal stops feeling like a brake and starts feeling like a wall.
brakeForce: lerp(12, 36, tires), brakeForce: lerp(spec.brakeForce * RUINED.brake, spec.brakeForce, tires),
// Lifting off has to actually slow you down. Without this the car coasts // Lifting off has to actually slow you down. Without this the car coasts
// almost forever and every stop needs a deliberate stab at the brake. // almost forever and every stop needs a deliberate stab at the brake.
coastBrake: lerp(4, 9, engine), coastBrake: lerp(4, 9, engine),
maxSteer: lerp(0.4, 0.62, chassis), maxSteer: lerp(spec.maxSteer * RUINED.steer, spec.maxSteer, chassis),
// Bald tyres are the most legible failure: the back end starts to leave. // Bald tyres are the most legible failure: the back end starts to leave.
frictionSlip: lerp(1.6, 5, tires), frictionSlip: lerp(spec.grip * RUINED.grip, spec.grip, tires),
sideFrictionStiffness: lerp(0.5, 1, tires), sideFrictionStiffness: lerp(0.5, 1, tires),
// A bent chassis pulls to one side. Sign is stable for a given car. /*
steeringPull: (1 - chassis) * 0.06, * A bent chassis pulls to one side. Sign is stable for a given car.
*
* Linear at 0.06 this was the single most destructive number in the game.
* It is an *uncommanded steering input that never lets go*: at 90% chassis
* it walked the car off a fifteen-metre trunk, driving dead straight, in
* under seven seconds. That is the swerve, and it was also most of the
* speed loss, because off the tarmac drive force drops to 55% measured
* top speed fell 108 to 86 to 75 km/h across the first fifth of the car's
* life, against a hunter that does 76.
*
* Worse, it reads from `chassis`, which is the subsystem a collision hurts
* most. So the thing that most destroys your ability to drive was wired to
* the thing that takes the most damage, and raising crash damage fivefold
* quietly turned one bad corner into a car that cannot be driven straight.
*
* Squared and much smaller, early wear is texture a car that needs
* watching and only a genuinely ruined one fights you for the wheel.
*/
steeringPull: (1 - chassis) ** 2 * 0.03,
}; };
} }
@ -182,8 +237,13 @@ const MAX_DAMAGE_PER_STEP = 0.85;
* the ratio the whole risk/reward loop rests on: enough slack to gamble with, * the ratio the whole risk/reward loop rests on: enough slack to gamble with,
* not enough to ignore. * not enough to ignore.
*/ */
export function applyWear(c: CarCondition, w: WearInput): CarCondition { export function applyWear(
const impact = w.impactForce / IMPACT_REFERENCE; c: CarCondition,
w: WearInput,
/** How much of a knock this car actually feels. Armour is a number below 1. */
fragility = 1,
): CarCondition {
const impact = (w.impactForce / IMPACT_REFERENCE) * fragility;
const damage: Subsystems = { const damage: Subsystems = {
// Ordered by what a collision actually ruins: the shell takes the worst of // Ordered by what a collision actually ruins: the shell takes the worst of
// it, the tyres and suspension a good share, the engine least of all. // it, the tyres and suspension a good share, the engine least of all.
@ -235,10 +295,13 @@ export function repair(c: CarCondition, funds: number): { condition: CarConditio
* patching it up; it is the expensive thing you do when patching has stopped * patching it up; it is the expensive thing you do when patching has stopped
* helping. * helping.
* *
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to * Deliberately an absolute number rather than a multiple of the repair rate.
* undo, or seven missions' pay. * Repairs got cheaper so that a crash cannot strand you; the *scar* is supposed
* to stay expensive, and tying the two together would have quietly discounted
* the one thing that is meant to hurt. A crash's permanent cost about 0.42
* of ceiling across the three subsystems still runs some ¤350, ten missions.
*/ */
const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION; const CEILING_PER_FUND = 0.0012;
/** /**
* Spends parts on raising the ceiling itself, worst subsystem first. * Spends parts on raising the ceiling itself, worst subsystem first.
@ -285,6 +348,41 @@ export function overhaul(c: CarCondition, funds: number): { condition: CarCondit
*/ */
const OVERHAUL_EPSILON = 1e-6; const OVERHAUL_EPSILON = 1e-6;
/**
* Condition below which a car is not really a working vehicle, and the level a
* friendly workshop will drag it back to for nothing.
*
* This is the one piece of negative feedback in an otherwise entirely
* self-reinforcing loop. Everything else in the game pushes the same way: a
* crash makes the car slower and harder to hold straight, which makes the next
* crash likelier, which costs money you earn by driving, which you now do
* badly. Reach the bottom with nothing in your pocket and there is no sequence
* of good decisions that gets you out you are not playing a hard game, you
* are watching one end.
*
* So the insurgency will not let its only driver sit in a wreck. It patches you
* up to something that runs and no further, and it cannot touch the ceiling, so
* the decline is untouched and only the dead end is removed.
*/
export const DERELICT = 0.3;
export const CHARITY_LEVEL = 0.55;
/** Would a friendly workshop take pity on this car? */
export const isDerelict = (c: CarCondition): boolean =>
overallCondition(c) < DERELICT;
/**
* Patch a wreck back to something driveable, for free. Ceilings are untouched:
* this buys back none of the decline, only the ability to keep playing.
*/
export function charity(c: CarCondition): CarCondition {
const level = { ...c.level };
for (const part of SUBSYSTEMS) {
level[part] = Math.min(c.ceiling[part], Math.max(level[part], CHARITY_LEVEL));
}
return { level, ceiling: { ...c.ceiling } };
}
/** True when there is nothing an overhaul could buy: the car is as good as new. */ /** True when there is nothing an overhaul could buy: the car is as good as new. */
export const canOverhaul = (c: CarCondition): boolean => export const canOverhaul = (c: CarCondition): boolean =>
SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON); SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON);

View File

@ -116,6 +116,12 @@ export function stepCombat(
step: CombatStep, step: CombatStep,
condition: CarCondition, condition: CarCondition,
rng: Rng, rng: Rng,
/**
* How much of a round the car actually feels. An armoured car is what this
* number is for: being shot at is precisely the situation it is bought for,
* so armour has to count here as much as it does against a wall.
*/
fragility = 1,
): CombatResult { ): CombatResult {
const { dt } = step; const { dt } = step;
let playerHit = false; let playerHit = false;
@ -185,16 +191,19 @@ export function stepCombat(
// Routed through the same wear model as everything else, so a bullet // 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. // 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 = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
const engineHit = HIT_ENGINE * fragility;
const tyreHit = HIT_TIRES * fragility;
const chassisHit = HIT_CHASSIS * fragility;
updated = { updated = {
level: { level: {
engine: Math.max(0, updated.level.engine - HIT_ENGINE), engine: Math.max(0, updated.level.engine - engineHit),
tires: Math.max(0, updated.level.tires - HIT_TIRES), tires: Math.max(0, updated.level.tires - tyreHit),
chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS), chassis: Math.max(0, updated.level.chassis - chassisHit),
}, },
ceiling: { ceiling: {
engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3), engine: Math.max(0, updated.ceiling.engine - engineHit * 0.3),
tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3), tires: Math.max(0, updated.ceiling.tires - tyreHit * 0.3),
chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3), chassis: Math.max(0, updated.ceiling.chassis - chassisHit * 0.3),
}, },
}; };
} }

179
src/sim/garage.test.ts Normal file
View File

@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
CARS,
buy,
carById,
createGarage,
current,
nextUnlock,
owns,
take,
virtues,
} from './garage';
import { applyWear, deriveHandling, freshCondition } from './car';
describe('the catalogue', () => {
it('gives you something to drive for nothing', () => {
// There is always a car. The loop's dead end was a player with no money and
// nothing that ran, and the garage exists to make that state unreachable.
const garage = createGarage();
expect(garage.owned.length).toBeGreaterThan(0);
expect(CARS[0]!.cost).toBe(0);
expect(current(garage)).toBeTruthy();
});
it('has no car that beats another at everything', () => {
// The invariant the whole design rests on. Suspicion is not tied to
// capability here — a lorry is huge and tough and nobody looks twice —
// so the thing keeping the catalogue honest is not a ladder but the
// absence of a dominant option. If any vehicle were at least as good on
// speed, protection, discretion *and* handling, picking the car would
// stop being a decision and the loop would lose its only one.
const axes = ['speed', 'protection', 'discretion', 'handling'] as const;
for (const a of CARS) {
for (const b of CARS) {
if (a === b) continue;
const va = virtues(a);
const vb = virtues(b);
const dominates = axes.every((k) => va[k] >= vb[k]);
expect(dominates, `${a.name} dominates ${b.name}`).toBe(false);
}
}
});
it('keeps the civilian half quiet however capable it gets', () => {
// An ordinary thing doing an ordinary thing is invisible whatever its
// specification. This is what stops "buy protection" and "stay unnoticed"
// being the same axis.
const lorry = carById('lorry');
const sports = carById('sports');
expect(lorry.presence).toBeLessThan(sports.presence / 2);
// ...even though it is far better protected than the flashy one.
expect(lorry.fragility).toBeLessThan(sports.fragility);
});
it('charges attention only for the things that do not belong here', () => {
const conspicuous = CARS.filter((c) => c.presence > 1.5).map((c) => c.id);
expect(conspicuous.sort()).toEqual(['apc', 'sports']);
});
it('does not put discretion up for sale', () => {
// Being unremarkable must stay available for nothing, or the early game is
// simply the worst version of the late one. The quietest vehicle in the
// catalogue is one of the two cheapest.
const byQuiet = [...CARS].sort((a, b) => a.presence - b.presence);
const byPrice = [...CARS].sort((a, b) => a.cost - b.cost);
expect(byPrice.slice(0, 2).map((c) => c.id)).toContain(byQuiet[0]!.id);
});
it('starts you in the small car and nothing else', () => {
const garage = createGarage();
expect(garage.owned).toEqual(['runabout']);
expect(current(garage).cost).toBe(0);
});
});
describe('buying and taking cars', () => {
it('will not sell you what you cannot afford', () => {
const garage = createGarage();
const dear = CARS[CARS.length - 1]!;
const { bought, funds } = buy(garage, dear.id, dear.cost - 1);
expect(bought).toBe(false);
expect(funds).toBe(dear.cost - 1);
expect(owns(garage, dear.id)).toBe(false);
});
it('takes the money once and only once', () => {
const garage = createGarage();
const car = CARS[1]!;
const first = buy(garage, car.id, car.cost + 50);
expect(first.bought).toBe(true);
expect(first.funds).toBe(50);
// Buying it again is not a way to lose fifty more.
const second = buy(garage, car.id, first.funds);
expect(second.bought).toBe(false);
expect(second.funds).toBe(50);
});
it('only lets you drive what is in the garage', () => {
const garage = createGarage();
expect(take(garage, CARS[2]!.id)).toBe(false);
expect(current(garage).id).toBe(CARS[0]!.id);
buy(garage, CARS[2]!.id, 99999);
expect(take(garage, CARS[2]!.id)).toBe(true);
expect(current(garage).id).toBe(CARS[2]!.id);
});
it('points at the next thing worth saving for, until there is none', () => {
const garage = createGarage();
expect(nextUnlock(garage)!.id).toBe(CARS[1]!.id);
for (const car of CARS) buy(garage, car.id, 99999);
expect(nextUnlock(garage)).toBeNull();
});
it('hands back a real car for an id it does not know', () => {
// Saves outlive catalogues; a renamed car must not leave the player on foot.
expect(carById('no-such-car').id).toBe(CARS[0]!.id);
});
});
describe('what the car you picked actually changes', () => {
const fresh = freshCondition();
const runabout = carById('runabout');
const tractor = carById('tractor');
const armoured = carById('apc');
it('drives like the car in the catalogue, not like one car with a skin', () => {
const slow = deriveHandling(fresh, tractor);
const quick = deriveHandling(fresh, armoured);
expect(slow.engineForce).toBeCloseTo(tractor.engineForce, 6);
expect(quick.engineForce).toBeCloseTo(armoured.engineForce, 6);
// A tractor turns tighter than an armoured car and grips less.
expect(slow.maxSteer).toBeGreaterThan(quick.maxSteer);
expect(slow.frictionSlip).toBeLessThan(quick.frictionSlip);
});
it('still leaves a ruined car driveable, whichever car it is', () => {
// The garage exists so that being in a bad way is a situation rather than
// an ending. A car that stops entirely at 0% is a fail state wearing a dial.
const ruined = {
level: { engine: 0, tires: 0, chassis: 0 },
ceiling: { engine: 1, tires: 1, chassis: 1 },
};
for (const spec of CARS) {
const h = deriveHandling(ruined, spec);
expect(h.engineForce).toBeGreaterThan(0);
expect(h.maxSteer).toBeGreaterThan(0);
expect(h.brakeForce).toBeGreaterThan(0);
}
});
it('makes armour worth the attention it costs', () => {
// The same crash, in the thing with no protection and the thing built for it.
const crash = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 2.47e6 };
const inTin = applyWear(fresh, crash, runabout.fragility);
const inArmour = applyWear(fresh, crash, armoured.fragility);
// Tin: one head-on and it is all but finished. The per-step cap is what
// stops it reading as exactly zero.
expect(inTin.level.chassis).toBeLessThan(0.25);
// Steel: the same crash is a bad afternoon.
expect(inArmour.level.chassis).toBeGreaterThan(0.5);
// And the permanent scar scales with it too.
expect(inArmour.ceiling.chassis).toBeGreaterThan(inTin.ceiling.chassis);
});
it('leaves the free car genuinely fragile, so the trade has two sides', () => {
// If the starting car merely went slower it would be a punishment rather
// than a choice. It has to actually be made of tin.
const knock = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 6e5 };
const tin = applyWear(fresh, knock, runabout.fragility);
const steel = applyWear(fresh, knock, armoured.fragility);
expect(1 - tin.level.chassis).toBeGreaterThan((1 - steel.level.chassis) * 3);
});
it('gives the tractor real protection, since it is a lump of iron', () => {
// "Very slow, some protection": it is not the fragile one, the runabout is.
expect(tractor.fragility).toBeLessThan(runabout.fragility);
expect(virtues(tractor).speed).toBeLessThan(virtues(runabout).speed);
});
});

227
src/sim/garage.ts Normal file
View File

@ -0,0 +1,227 @@
/**
* The cars you can be driving, and which of them you own. Pure no engine
* imports.
*
* This is the loop's central decision, and it exists because the old one had
* none. Condition used to be health, capability, currency sink and fail state
* all at once, so every event pushed the same way: damage made the car slower
* and harder to hold straight, which made it worse at earning, which meant
* fewer repairs, which meant more damage. A spiral with no negative term in it
* anywhere, and no decision inside it either.
*
* A garage breaks that apart. Damage is now a *sortie* resource you are
* patched up when you get home and money buys **cars**, permanently. So
* progress climbs while any given drive still declines.
*
* The choice itself is the point, and the axis is not capability. It is
* whether the vehicle *belongs here*:
*
* A lorry is huge and tough and nobody looks twice, because lorries exist.
* A sports car is quick and flimsy and everybody looks, because who drives
* that, here, now.
*
* So the civilian half of the catalogue runabout, tractor, estate, lorry
* stays quiet however capable it gets, and you pay for protection and speed in
* money and in handling rather than in attention. The military half buys you
* out of that at a price nothing else charges.
*
* There is deliberately no best car, and it is checked rather than asserted:
* no vehicle may beat another on speed, protection, discretion *and* handling
* at once. A quiet errand down cold roads wants the thing nobody reports; a run
* into a district that already knows your face wants the thing that survives
* what happens next. Picking wrong is supposed to be how you lose.
*/
export interface CarSpec {
id: string;
name: string;
/** One line, in the words somebody handing you the keys would use. */
blurb: string;
/** What it costs to put one in the garage. The first is free. */
cost: number;
// --- How it drives ---
/** Kilograms. Heavier shrugs off shunts and takes far longer to stop. */
mass: number;
/** Newtons per driven wheel at full throttle, in good condition. */
engineForce: number;
/** Braking impulse per wheel, in good condition. */
brakeForce: number;
/** Steering lock in radians, in good condition. Big things turn like barges. */
maxSteer: number;
/** Tyre grip, in good condition. Lower slides. */
grip: number;
// --- What it costs you ---
/**
* Share of incoming damage the car actually takes. Armour is this number.
* Above 1 is worse than bare metal; 0.22 is something built to be shot at.
*/
fragility: number;
/**
* How much attention it draws, as a multiplier on how fast anyone watching
* makes their mind up about you.
*
* Deliberately *not* correlated with how good the vehicle is. An ordinary
* thing doing an ordinary thing is invisible whatever its specification; the
* two that cost you here are the one nobody in this country drives and the
* one with a gun mount.
*/
presence: number;
// --- What it looks like ---
colour: number;
/** Scale on the body mesh, so a lorry reads as a lorry from the mirror. */
size: { width: number; height: number; length: number };
}
/** The catalogue, cheapest first. */
export const CARS: CarSpec[] = [
{
id: 'runabout',
name: 'Small runabout',
blurb: 'Somebodys second car. Slow, tinny, and completely unremarkable.',
cost: 0,
mass: 950,
engineForce: 1900,
brakeForce: 30,
maxSteer: 0.64,
grip: 4.6,
fragility: 1.3,
presence: 0.5,
colour: 0x9aa2a8,
size: { width: 0.9, height: 0.88, length: 0.86 },
},
{
id: 'tractor',
name: 'Field tractor',
blurb: 'Barely faster than walking, built out of girders, part of the scenery.',
cost: 220,
mass: 1600,
engineForce: 1300,
brakeForce: 24,
maxSteer: 0.7,
grip: 3.4,
fragility: 0.8,
presence: 0.45,
colour: 0x6a7042,
size: { width: 1.02, height: 1.24, length: 0.9 },
},
{
id: 'estate',
name: 'Large estate',
blurb: 'A family car with some weight to it. Quick enough, dull enough.',
cost: 520,
mass: 1350,
engineForce: 3000,
brakeForce: 33,
maxSteer: 0.56,
grip: 4.5,
fragility: 0.85,
presence: 0.7,
colour: 0x7c8378,
size: { width: 1.02, height: 1, length: 1.12 },
},
{
id: 'lorry',
name: 'Lorry',
blurb: 'Three tonnes of cab and flatbed. Steers like a barge, stops like one.',
cost: 950,
mass: 3200,
engineForce: 4200,
brakeForce: 26,
maxSteer: 0.38,
grip: 3.8,
fragility: 0.5,
presence: 0.8,
colour: 0x4f6068,
size: { width: 1.16, height: 1.34, length: 1.5 },
},
{
id: 'sports',
name: 'Sports coupe',
blurb: 'Nothing here goes this fast. That is the problem with it.',
cost: 1500,
mass: 1000,
engineForce: 5200,
brakeForce: 42,
maxSteer: 0.6,
grip: 5.8,
fragility: 1.35,
presence: 2.4,
colour: 0xb03a2e,
size: { width: 1, height: 0.8, length: 1.06 },
},
{
id: 'apc',
name: 'Armoured carrier',
blurb: 'Nothing short of a tower will stop it. Everybody phones it in.',
cost: 2600,
mass: 4000,
engineForce: 3000,
brakeForce: 28,
maxSteer: 0.34,
grip: 3.6,
fragility: 0.22,
presence: 3.8,
colour: 0x4d5348,
size: { width: 1.2, height: 1.32, length: 1.22 },
},
];
/** The four ways a vehicle can be good, for the no-dominance check. */
export const virtues = (c: CarSpec) => ({
/** Acceleration, which is what actually gets you away. */
speed: c.engineForce / c.mass,
protection: 1 / c.fragility,
discretion: 1 / c.presence,
/** Lock times grip: how well it will actually take a junction. */
handling: c.maxSteer * c.grip,
});
export const carById = (id: string): CarSpec => CARS.find((c) => c.id === id) ?? CARS[0]!;
export interface GarageState {
/** Ids of every car in the garage. The first one is always there. */
owned: string[];
/** What is being driven right now. */
driving: string;
}
export const createGarage = (): GarageState => ({
owned: [CARS[0]!.id],
driving: CARS[0]!.id,
});
export const owns = (garage: GarageState, id: string): boolean => garage.owned.includes(id);
export const current = (garage: GarageState): CarSpec => carById(garage.driving);
/**
* Buys a car, if it is not already owned and the money is there.
* Returns what is left. Money is only ever spent here.
*/
export function buy(
garage: GarageState,
id: string,
funds: number,
): { bought: boolean; funds: number } {
const spec = CARS.find((c) => c.id === id);
if (!spec || owns(garage, id) || funds < spec.cost) return { bought: false, funds };
garage.owned.push(id);
return { bought: true, funds: funds - spec.cost };
}
/** Takes a different car out. Only ones you own, and only at a base. */
export function take(garage: GarageState, id: string): boolean {
if (!owns(garage, id)) return false;
garage.driving = id;
return true;
}
/**
* The next thing worth saving for: cheapest car not yet owned.
* Null once the garage is complete.
*/
export const nextUnlock = (garage: GarageState): CarSpec | null =>
CARS.find((c) => !owns(garage, c.id)) ?? null;

View File

@ -10,6 +10,7 @@ import {
type PursuitStep, type PursuitStep,
} from './pursuit'; } from './pursuit';
import { createUnits, type Faction, type Unit, type UnitState } from './units'; import { createUnits, type Faction, type Unit, type UnitState } from './units';
import { carById } from './garage';
import type { Control } from './regions'; import type { Control } from './regions';
function place(state: UnitState, faction: Faction, x: number, z: number): Unit { function place(state: UnitState, faction: Faction, x: number, z: number): Unit {
@ -50,6 +51,10 @@ function run(
units, units,
canSee: () => true, canSee: () => true,
provocation: 0, provocation: 0,
// A road that has been worked, in an ordinary car: the conditions every
// existing test in this file was written against.
wariness: 1,
presence: 1,
...overrides, ...overrides,
})) { })) {
events.push(event.kind); events.push(event.kind);
@ -132,6 +137,8 @@ describe('being noticed', () => {
units, units,
canSee: () => true, canSee: () => true,
provocation: PROVOCATION.ranOverSoldier, provocation: PROVOCATION.ranOverSoldier,
wariness: 0,
presence: 1,
}); });
expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2); expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2);
}); });
@ -276,3 +283,196 @@ describe('your own side pulling them off you', () => {
expect(state.alert).toBe('hunted'); expect(state.alert).toBe('hunted');
}); });
}); });
describe('who is looking at you', () => {
it('names them, rather than only counting the meter', () => {
const units = createUnits();
const watcher = place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 2);
expect([...state.eyes.keys()]).toEqual([watcher.id]);
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(0);
});
it('has them settle on you rather than deciding instantly', () => {
const units = createUnits();
const watcher = place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 0.4);
const glance = state.eyes.get(watcher.id)!;
run(state, units, 3);
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(glance);
expect(state.eyes.get(watcher.id)!).toBeCloseTo(1, 1);
});
it('loses interest once it cannot see you, quicker than it gained it', () => {
const units = createUnits();
const watcher = place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 4);
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(0.5);
run(state, units, 2, { canSee: () => false });
expect(state.eyes.has(watcher.id)).toBe(false);
});
it('makes lingering cost more than passing', () => {
// The complaint this answers: cover was too easy to lose, because
// suspicion started climbing the instant anybody had line of sight. Driving
// past a checkpoint cost exactly what loitering at one did.
const passing = () => {
const units = createUnits();
place(units, 'enemy', 20, 0);
const state = createPursuit();
// Seen for a moment, then gone.
run(state, units, 1.2);
run(state, units, 4, { canSee: () => false });
return state.suspicion;
};
const lingering = () => {
const units = createUnits();
place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 5.2);
return state.suspicion;
};
expect(lingering()).toBeGreaterThan(passing() * 3);
});
it('still gets you made if you sit there long enough', () => {
const units = createUnits();
place(units, 'enemy', 12, 0);
const state = createPursuit();
run(state, units, 20);
expect(state.alert).toBe('hunted');
});
});
describe('cover depends on the place and the car', () => {
/** Somebody standing twenty metres away, watching, for a given few seconds. */
const watched = (seconds: number, overrides: Partial<PursuitStep>) => {
const units = createUnits();
place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, seconds, overrides);
return state;
};
it('barely notices you on a road nobody has been working', () => {
// The complaint this answers: occupied ground blew your cover on its own.
// Nobody is looking for a driver they have no reason to think exists.
// Ten seconds: on a maximally notorious road, in an ordinary car, parked
// twenty metres from somebody, that is about how long being made takes.
const cold = watched(10, { wariness: 0, presence: 1 });
const hot = watched(10, { wariness: 1, presence: 1 });
expect(cold.suspicion).toBeLessThan(hot.suspicion / 5);
expect(cold.alert).toBe('clear');
expect(hot.alert).toBe('hunted');
});
it('is why using one road all week costs you', () => {
// Heat used to matter only at the thresholds where concrete appears. It is
// now a continuous pressure: the people on your favourite route are
// expecting you.
const quiet = watched(5, { wariness: 0.15, presence: 1 });
const notorious = watched(5, { wariness: 0.9, presence: 1 });
expect(notorious.suspicion).toBeGreaterThan(quiet.suspicion * 3);
});
it('makes the vehicle you chose part of the same sum', () => {
const tractor = watched(6, { wariness: 0.5, presence: 0.45 });
const carrier = watched(6, { wariness: 0.5, presence: 3.8 });
expect(tractor.suspicion).toBeLessThan(carrier.suspicion / 4);
});
it('lets a quiet car on a cold road go about its business indefinitely', () => {
// The whole point of the cheap end of the garage.
const state = watched(45, { wariness: 0, presence: 0.45 });
expect(state.alert).toBe('clear');
expect(state.suspicion).toBeLessThan(0.3);
});
it('gets an armoured carrier made almost at once on a notorious road', () => {
// And the whole point of the expensive end: it buys survival, not cover.
const state = watched(4, { wariness: 1, presence: 3.8 });
expect(state.alert).toBe('hunted');
});
it('still lets nobody watch you behind your own lines, whatever you drive', () => {
const state = watched(30, { control: 'liberated', wariness: 1, presence: 3.8 });
expect(state.suspicion).toBe(0);
expect(state.alert).toBe('clear');
});
it('shows the difference on the faces before it costs you anything', () => {
// The place and the car ride on the *focus* ramp, not on suspicion
// directly, so the markers over people's heads redden faster on a hot road
// — you can read the road's reputation off the crowd.
const cold = watched(2, { wariness: 0, presence: 1 });
const hot = watched(2, { wariness: 1, presence: 1 });
expect(Math.max(...hot.eyes.values())).toBeGreaterThan(
Math.max(...cold.eyes.values()) * 3,
);
});
});
describe('the pacing the garage is meant to produce', () => {
/**
* A checkpoint you drive past: in sight for a few seconds, then gone.
*
* `wariness` is the road's heat, and heat is what accumulates between passes
* the suspicion meter itself decays back to nothing, deliberately. So the
* memory of "this car keeps coming through here" lives on the road rather
* than in the guard's head, which is what makes "use a different route" a
* decision about cover and not just about concrete.
*/
const drivePast = (state: PursuitState, presence: number, wariness: number) => {
const units = createUnits();
const guard = place(units, 'enemy', 14, 0);
run(state, units, 5, { presence, wariness });
// Checked here, in front of the checkpoint — not after driving away.
// Twenty seconds down the road you have always shaken them, so asking at
// the end of the cycle is asking whether you got away, which is a
// different question and always has the same answer.
const made = state.alert === 'hunted';
guard.x = 9000;
run(state, units, 20, { presence, wariness });
return made;
};
it('has an armoured carrier made almost at once', () => {
const state = createPursuit();
const units = createUnits();
place(units, 'enemy', 14, 0);
// A road nobody has even been working. It does not help.
run(state, units, 3, { presence: carById('apc').presence, wariness: 0.1 });
expect(state.alert).toBe('hunted');
});
it('lets a small car through the same checkpoint several times first', () => {
const state = createPursuit();
const small = carById('runabout').presence;
// Each pass leaves the road a little warier than the last.
// Heat climbs the way repeated use of one road actually climbs it.
const passes = [0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1, 1, 1, 1];
const caughtOn = passes.findIndex((w) => drivePast(state, small, w));
// Several trips through before the place works out that this car keeps
// coming through — and not never, or the cheap car would be a free pass.
expect(caughtOn).toBeGreaterThan(2);
expect(caughtOn).toBeLessThan(passes.length);
});
it('catches the flashy car far sooner than the dull one', () => {
const runsBefore = (presence: number) => {
const state = createPursuit();
const passes = [0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1, 1, 1, 1];
const at = passes.findIndex((w) => drivePast(state, presence, w));
return at === -1 ? passes.length : at;
};
expect(runsBefore(carById('sports').presence)).toBeLessThan(
runsBefore(carById('runabout').presence),
);
expect(runsBefore(carById('tractor').presence)).toBeGreaterThanOrEqual(
runsBefore(carById('estate').presence),
);
});
});

View File

@ -25,6 +25,16 @@ export interface PursuitState {
lastSeen: { x: number; z: number } | null; lastSeen: { x: number; z: number } | null;
/** Unit ids currently chasing. */ /** Unit ids currently chasing. */
hunters: Set<number>; hunters: Set<number>;
/**
* Who has their eye on you, and how settled they are about it: 0 is a glance,
* 1 is somebody who has decided you are worth watching.
*
* This is the thing the player needed to be able to see. Cover used to be a
* single number that filled up with no indication of *who* was filling it, so
* being recognised arrived out of nowhere and there was nothing to react to
* except the meter itself.
*/
eyes: Map<number, number>;
} }
export const createPursuit = (): PursuitState => ({ export const createPursuit = (): PursuitState => ({
@ -33,22 +43,69 @@ export const createPursuit = (): PursuitState => ({
unseenFor: 0, unseenFor: 0,
lastSeen: null, lastSeen: null,
hunters: new Set(), hunters: new Set(),
eyes: new Map(),
}); });
// --- Tuning --------------------------------------------------------------- // --- Tuning ---------------------------------------------------------------
/** How far an enemy can make you out at all. */ /** How far an enemy can make you out at all. */
export const SIGHT_RANGE = 95; export const SIGHT_RANGE = 95;
/** Suspicion per second with someone right on top of you. */
const MAX_GAIN = 0.42;
/** /**
* Suspicion shed per second with nobody watching. * Suspicion per second from somebody certain about you, right on top of you,
* on ground and in a vehicle that both fully deserve it.
* *
* Slow on purpose. Cover is meant to be something you lose and then have to * Large, because it is multiplied down hard by `alertness` in almost every real
* earn back by staying dull for a while; at a brisk decay rate you are clean * situation. The pacing it is set against is concrete: an armoured carrier is
* again before you have finished the corner. * made within a couple of seconds even on a road nobody has been working, and a
* small unremarkable car can go through the same checkpoint several times
* before the road has learned enough about it to matter.
*/ */
const DECAY = 0.05; const MAX_GAIN = 1.6;
/**
* How fast somebody goes from noticing a car to being sure about it, per second,
* with the car right beside them.
*
* This exists because cover was too easy to lose. Suspicion used to start
* climbing the instant anybody had line of sight, so driving *past* a checkpoint
* cost the same as loitering at one, and being made was something that happened
* to you rather than something you could feel coming and back out of.
*
* Now a watcher has to settle first, and only then do they start filling the
* meter. Passing through quickly leaves everyone at a glance; lingering is what
* actually costs you. It roughly doubles the time to be recognised, and more
* to the point makes the first half of that time legible.
*/
const FOCUS_RATE = 0.55;
/**
* How much attention a place pays a stranger when nothing has happened there.
*
* The floor under `wariness`, and the point of the whole mechanism. Occupied
* ground used to blow your cover on its own: park anywhere past the line, in
* anything, and someone would eventually make you. But nobody is looking for a
* driver they have no reason to believe exists if no road round here has been
* worked and no district has a story about you, a car going past is a car going
* past.
*
* Above zero because occupied ground is still occupied: sit in front of a
* checkpoint long enough and somebody will wander over regardless.
*/
const COLD_GROUND = 0.12;
/** How fast that interest fades once they cannot see you. Quicker than suspicion. */
const FOCUS_FADE = 0.7;
/**
* Suspicion shed per second, at all times.
*
* Slow on purpose, and for two reasons now. Cover is meant to be something you
* lose and then have to earn back by being dull for a while. And because it
* runs continuously it is also the *threshold*: below DECAY worth of attention
* you are not slowly being made, you are simply not being made.
*
* Small enough that a little survives the gap between one trip past a
* checkpoint and the next. That residue is what lets a small car go through
* several times before the place has worked out that it keeps coming through
* the road's own heat does most of that remembering, but not all of it.
*/
const DECAY = 0.02;
/** Suspicion at which you stop being a car and start being a target. */ /** Suspicion at which you stop being a car and start being a target. */
const RECOGNISED = 1; const RECOGNISED = 1;
/** Above this the HUD starts warning you. */ /** Above this the HUD starts warning you. */
@ -100,6 +157,20 @@ export interface PursuitStep {
canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean; canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean;
/** Suspicion added outright this step by things the player just did. */ /** Suspicion added outright this step by things the player just did. */
provocation: number; provocation: number;
/**
* How wary this particular place is, 0..1 the heat on the road being used,
* or on the district when off it.
*
* This is what makes "take a different road" a decision about *cover* rather
* than only about concrete. Use one route all week and the people on it are
* expecting you.
*/
wariness: number;
/**
* How much the vehicle draws the eye. A tractor is part of the scenery; an
* armoured carrier is a thing people phone in.
*/
presence: number;
} }
export type PursuitEvent = export type PursuitEvent =
@ -121,18 +192,75 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
const exposure = EXPOSURE[step.control]; const exposure = EXPOSURE[step.control];
const seenBy = watchers(step); const seenBy = watchers(step);
// --- Suspicion --- // --- Who is looking, and how settled they are about it ---
if (exposure > 0 && seenBy.length > 0) { // Everyone who can see you creeps toward being sure; everyone who cannot
// loses interest, faster than they gained it.
/*
* How much trouble this is, all in one number: whose ground it is, how wary
* the place has been made, and what you turned up in.
*
* All three belong on the *focus* ramp rather than on suspicion directly,
* because that is the half the player can see. A hot road does not silently
* fill a meter faster the diamonds over people's heads redden faster, and
* you can read the road's reputation off the crowd before it costs you
* anything.
*/
const wariness = COLD_GROUND + (1 - COLD_GROUND) * Math.min(1, Math.max(0, step.wariness));
const alertness = exposure * wariness * step.presence;
const looking = new Set<number>();
let attention = 0;
for (const unit of seenBy) {
looking.add(unit.id);
const gap = Math.hypot(unit.x - step.player.x, unit.z - step.player.z);
const proximity = (1 - gap / SIGHT_RANGE) ** 2;
/*
* Alertness caps how sure anyone can get, not merely how fast.
*
* Without the ceiling a quiet car on a cold road is only *slower* to blow
* your cover, never safe from it everybody eventually saturates and the
* whole distinction collapses. With it, somebody with no reason to suspect
* a driver exists tops out at a glance and stays there.
*/
const ceiling = Math.min(1, alertness);
const settled = Math.min(
ceiling,
(state.eyes.get(unit.id) ?? 0) + FOCUS_RATE * proximity * alertness * step.dt,
);
state.eyes.set(unit.id, settled);
// Whoever has the best look at you sets the pace; a crowd is not more // Whoever has the best look at you sets the pace; a crowd is not more
// suspicious than one person standing right next to the car. // suspicious than one person standing right next to the car.
let closest = SIGHT_RANGE; attention = Math.max(attention, settled * proximity);
for (const unit of seenBy) {
closest = Math.min(closest, Math.hypot(unit.x - step.player.x, unit.z - step.player.z));
} }
const proximity = (1 - closest / SIGHT_RANGE) ** 2; for (const [id, settled] of state.eyes) {
state.suspicion += proximity * MAX_GAIN * exposure * step.dt; if (looking.has(id)) continue;
} else if (state.alert !== 'hunted') { const faded = settled - FOCUS_FADE * step.dt;
state.suspicion -= DECAY * step.dt; if (faded <= 0) state.eyes.delete(id);
else state.eyes.set(id, faded);
}
/*
* Suspicion, as a race between being looked at and being forgotten.
*
* Two different things, kept apart on purpose. `attention` is how *sure* the
* keenest onlooker is, and a person cannot be more than certain so it caps,
* and it is what the markers over their heads show. `alertness` is what that
* certainty costs you here, and it does not cap at all: somebody certain
* about an armoured carrier on a road they have been working all week is a
* different event from somebody equally certain about a tractor on a quiet
* one. Folding the two together made every vehicle identical the moment
* anybody was sure about it, which took the garage's whole decision back out.
*
* The decay runs the whole time rather than only when nobody is watching, and
* that is what turns this from an accumulator into a threshold. Below
* DECAY/MAX_GAIN of attention you are not slowly being made, you are simply
* *not being made* a tractor on a road nobody has worked can go about its
* business all day. Above it, the clock is running. An accumulator has no
* such line: given long enough it always reaches the top, and "nobody
* suspects you here" could never mean anything.
*/
if (state.alert !== 'hunted') {
state.suspicion += (attention * alertness * MAX_GAIN - DECAY) * step.dt;
} }
// Provocations land whatever the range, and they land in full: driving over // Provocations land whatever the range, and they land in full: driving over
@ -223,3 +351,7 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
/** Fraction of the way to being recognised, for the HUD meter. */ /** Fraction of the way to being recognised, for the HUD meter. */
export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED; export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED;
/** How settled the most interested onlooker is, 0..1. */
export const closestAttention = (state: PursuitState): number =>
state.eyes.size === 0 ? 0 : Math.max(...state.eyes.values());

View File

@ -3,6 +3,8 @@ import { generateWorld } from './world';
import { import {
applyWear, applyWear,
canOverhaul, canOverhaul,
charity,
isDerelict,
FUNDS_PER_CONDITION, FUNDS_PER_CONDITION,
deriveHandling, deriveHandling,
freshCondition, freshCondition,
@ -330,3 +332,101 @@ describe('overhaul', () => {
expect(missions).toBeLessThan(20); expect(missions).toBeLessThan(20);
}); });
}); });
describe('the car does not fight you for the wheel', () => {
/**
* `steeringPull` is an uncommanded steering input that never lets go, and at
* its original 0.06 linear it was the most destructive number in the game: at
* 90% chassis it walked the car off a fifteen-metre trunk, driving dead
* straight, in under seven seconds. That also cost most of the top speed,
* because off the tarmac drive force drops to 55%.
*
* The numbers below are metres of drift over ten seconds at 25 m/s, from
* `steeringPull` alone. A trunk is 15m wide, so half of it is the budget.
*/
const driftOverTenSeconds = (chassis: number) => {
const pull = deriveHandling({
level: { engine: 1, tires: 1, chassis },
ceiling: { engine: 1, tires: 1, chassis: 1 },
}).steeringPull;
// Small-angle: lateral ≈ ½·(v²/R)·t², with R = wheelbase/tan(pull).
const speed = 25;
const radius = 2.6 / Math.max(pull, 1e-9);
return 0.5 * ((speed * speed) / radius) * 10 * 10;
};
it('holds a lane on a car that is merely worn', () => {
// A car at 90% should need watching, not constant correction.
expect(driftOverTenSeconds(0.9)).toBeLessThan(7.5);
});
it('still wanders once it is genuinely bent', () => {
// But a wreck has to be a handful, or damage means nothing.
expect(driftOverTenSeconds(0.2)).toBeGreaterThan(driftOverTenSeconds(0.9) * 20);
});
it('gets worse faster than it gets bad, rather than the other way round', () => {
// Squared, not linear: the first fifth of the car's life should barely be
// felt, and the last fifth should be most of it.
const early = driftOverTenSeconds(0.8) - driftOverTenSeconds(1);
const late = driftOverTenSeconds(0.2) - driftOverTenSeconds(0.4);
expect(late).toBeGreaterThan(early * 5);
});
});
describe('there is a way back up', () => {
const wrecked = (): CarCondition => ({
level: { engine: 0.1, tires: 0.15, chassis: 0.05 },
ceiling: { engine: 0.7, tires: 0.75, chassis: 0.65 },
});
it('recognises a car that cannot do the job', () => {
expect(isDerelict(wrecked())).toBe(true);
expect(isDerelict(freshCondition())).toBe(false);
});
it('patches it back to something that runs', () => {
const after = charity(wrecked());
expect(isDerelict(after)).toBe(false);
for (const part of SUBSYSTEMS) expect(after.level[part]).toBeGreaterThan(0.5);
});
it('buys back none of the decline', () => {
// The whole point: it removes the dead end without touching the ratchet.
const before = wrecked();
const after = charity(before);
expect(after.ceiling).toEqual(before.ceiling);
});
it('never lifts anything above what the car is still capable of', () => {
const nearlyFinished: CarCondition = {
level: { engine: 0.05, tires: 0.05, chassis: 0.05 },
ceiling: { engine: 0.4, tires: 0.4, chassis: 0.4 },
};
const after = charity(nearlyFinished);
for (const part of SUBSYSTEMS) expect(after.level[part]).toBeCloseTo(0.4, 9);
});
it('leaves a crash affordable to repair, and its scar expensive', () => {
const crashed = applyWear(freshCondition(), {
dt: 1 / 60,
distance: 0.4,
throttle: 0,
impactForce: 2.47e6,
});
const MISSION = 34;
const lost =
3 - (crashed.level.engine + crashed.level.tires + crashed.level.chassis);
const toRepair = (lost * FUNDS_PER_CONDITION) / MISSION;
// A couple of jobs to be driving properly again...
expect(toRepair).toBeLessThan(3);
// ...but the permanent share still costs an evening to undo.
let missions = 0;
let c = crashed;
while (canOverhaul(c) && missions < 200) {
c = overhaul(c, MISSION).condition;
missions++;
}
expect(missions).toBeGreaterThan(6);
});
});

View File

@ -20,6 +20,8 @@ import {
type UnitState, type UnitState,
} from './units'; } from './units';
import { createCombat, segmentHits, stepCombat } from './combat'; import { createCombat, segmentHits, stepCombat } from './combat';
import { arrowFor } from '../ui/hud';
import { segmentAt } from './roads';
const world = generateWorld(1337); const world = generateWorld(1337);
const graph = buildGraph(world.roads); const graph = buildGraph(world.roads);
@ -735,11 +737,18 @@ describe('nobody fires at what they cannot see', () => {
describe('traffic that drives like traffic', () => { describe('traffic that drives like traffic', () => {
/** Where each car sits relative to the centre of the road it is on. */ /** Where each car sits relative to the centre of the road it is on. */
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => { const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
// Signed offset from the segment's centreline, positive to the car's right. // Signed offset from the centreline, positive to the *car's* right.
//
// Right-handed world, Y up: something facing +Z has its right toward -X.
// So for a forward of (sin a, cos a) the right vector is (-cos a, sin a).
// Deriving this independently of the sim is the whole value of the test —
// the first version copied the sim's vector, inherited its inverted sign,
// and cheerfully certified that everything drove on the correct side while
// the entire map drove on the left.
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az); const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
const px = unit.x - seg.ax; const px = unit.x - seg.ax;
const pz = unit.z - seg.az; const pz = unit.z - seg.az;
const lateral = px * Math.cos(along) - pz * Math.sin(along); const lateral = -px * Math.cos(along) + pz * Math.sin(along);
// Flip for cars travelling the other way down the same segment. // Flip for cars travelling the other way down the same segment.
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral; return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
}; };
@ -893,3 +902,393 @@ describe('your own side, with vehicles', () => {
} }
}); });
}); });
describe('which way is right', () => {
/**
* The lane offset is the one piece of geometry in this file where getting the
* sign backwards is both easy and completely invisible: everything still
* drives in neat parallel lines, just on the wrong side of the road. The
* first version of it did exactly that.
*
* So it is checked against the HUD's compass rather than against another copy
* of the same reasoning. Two modules that must agree, written independently,
* is the only version of this test that can actually fail.
*/
const laneRightOf = (headingRad: number) => {
const dirX = Math.sin(headingRad);
const dirZ = Math.cos(headingRad);
// Must match sim/units.ts `advance`.
return { x: -dirZ, z: dirX };
};
const bearingTo = (headingRad: number, to: { x: number; z: number }) =>
Math.atan2(to.x, to.z) - headingRad;
it('offsets cars toward the side the HUD calls right', () => {
for (const heading of [0, Math.PI / 2, Math.PI, -Math.PI / 2, 0.7, -2.4]) {
const right = laneRightOf(heading);
expect(arrowFor(bearingTo(heading, right))).toBe('→');
}
});
it('is not merely self-consistent — the other side reads as left', () => {
for (const heading of [0, 1.2, -0.9]) {
const right = laneRightOf(heading);
expect(arrowFor(bearingTo(heading, { x: -right.x, z: -right.z }))).toBe('←');
}
});
it('agrees with the car: steering right from heading 0 goes toward -X', () => {
// Measured on the running game with Rapier: hold W and D from heading 0 and
// the car ends up at negative x. The sim layer cannot import the physics,
// so the number is recorded here instead of re-derived.
expect(laneRightOf(0).x).toBeLessThan(0);
expect(laneRightOf(0).z).toBeCloseTo(0, 9);
});
});
describe('civilians have some sense', () => {
const onRoad = (u: { x: number; z: number }) => segmentAt(world.roads, u.x, u.z) !== null;
const crowd = (overrides = {}) => {
const state = createUnits();
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z }, ...overrides }, makeRng(44));
return state;
};
it('keeps people out of the road', () => {
const people = crowd().units.filter((u) => u.role === 'pedestrian');
expect(people.length).toBeGreaterThan(5);
// Not zero: somebody is always mid-crossing. But it has to be the exception.
expect(people.filter(onRoad).length / people.length).toBeLessThan(0.15);
});
it('makes the ones who are crossing hurry', () => {
// Sampled over time, since at any instant only a couple are on tarmac.
const state = createUnits();
let crossingSteps = 0;
let crossingDistance = 0;
let strollingSteps = 0;
let strollingDistance = 0;
const before = new Map<number, { x: number; z: number }>();
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(44),
);
for (const u of state.units) {
if (u.role !== 'pedestrian') continue;
const was = before.get(u.id);
if (was) {
const moved = Math.hypot(u.x - was.x, u.z - was.z);
if (onRoad(u)) {
crossingSteps++;
crossingDistance += moved;
} else {
strollingSteps++;
strollingDistance += moved;
}
}
before.set(u.id, { x: u.x, z: u.z });
}
}
expect(crossingSteps).toBeGreaterThan(30);
// Nobody strolls across a road.
expect(crossingDistance / crossingSteps).toBeGreaterThan(
(strollingDistance / strollingSteps) * 1.5,
);
});
it('runs from gunfire, not only from the sight of a rifle', () => {
// Run the same four seconds twice, identical but for one shot fired on the
// first step with nobody visibly armed anywhere. Everything else — the
// wander, the seed, the road avoidance — is held constant, so the
// difference is the gunfire and nothing else.
const walk = (withShot: boolean) => {
const state = createUnits();
run(state, 60, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(44));
const person = state.units.find((u) => u.role === 'pedestrian')!;
const shot = { x: person.x + 6, z: person.z };
for (let i = 0; i < 40; i++) {
stepUnits(
state,
{
dt: 0.1,
now: 60 + i * 0.1,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
// Fired once, on the first step only.
gunfire: withShot && i === 0 ? [shot] : [],
},
world.roads,
graph,
makeRng(44),
);
}
return Math.hypot(person.x - shot.x, person.z - shot.z);
};
// Four seconds, against a six-second panic: they are still running when the
// measurement is taken, rather than having frozen the instant it went quiet.
expect(walk(true)).toBeGreaterThan(walk(false) + 3);
});
});
describe('traffic and the player', () => {
/**
* One civilian car partway down a long straight, with the player parked
* twelve metres ahead of it on the same road.
*
* Built rather than found: taking whichever car happened to exist put the
* player on its instantaneous heading, which is not the same as its lane, so
* the car would reach a junction and turn off before anything was decided.
*/
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
const approaching = () => {
const state = createUnits();
const at = 0.25;
const car = {
id: state.nextId++,
kind: 'car' as const,
faction: 'civilian' as const,
role: 'traffic' as const,
x: straight.ax + (straight.bx - straight.ax) * at,
z: straight.az + (straight.bz - straight.az) * at,
heading: along,
speed: 12,
hp: 60,
path: [straight.b],
lastNode: straight.a,
expires: 1e6,
assigned: null,
onStation: 0,
cooldown: 1,
elevation: 1,
};
state.units.push(car);
return {
state,
car,
player: { x: car.x + Math.sin(along) * 12, z: car.z + Math.cos(along) * 12 },
};
};
const drive = (
state: ReturnType<typeof createUnits>,
car: { x: number; z: number; heading: number },
player: { x: number; z: number },
gunfire = false,
) => {
// Fired from *behind* the car, so panic pushes it on toward the player
// rather than simply away from the noise — otherwise the test only proves
// that people run from gunfire, which is a different test.
const shot = { x: car.x - Math.sin(along) * 10, z: car.z - Math.cos(along) * 10 };
// The closest it ever gets. End-distance is useless here: a car that does
// not stop drives straight past and ends up further away than one that did.
let closest = Infinity;
for (let i = 0; i < 40; i++) {
stepUnits(
state,
{
dt: 0.1,
now: 60 + i * 0.1,
player,
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
gunfire: gunfire && i === 0 ? [shot] : [],
},
world.roads,
graph,
makeRng(2),
);
closest = Math.min(closest, Math.hypot(car.x - player.x, car.z - player.z));
}
return closest;
};
it('stops rather than shunting a parked car down the road', () => {
const { state, car, player } = approaching();
// Never gets within ramming distance of a car sitting in its way. Traffic
// used to drive straight into a stationary player and shove them along,
// which makes every other vehicle read as weather rather than as a driver.
expect(drive(state, car, player)).toBeGreaterThan(3);
});
it('does not stop for you when it is running from gunfire', () => {
const calm = approaching();
const kept = drive(calm.state, calm.car, calm.player);
const panicked = approaching();
const closed = drive(panicked.state, panicked.car, panicked.player, true);
// A driver getting out of a firefight is not going to wait behind you.
expect(closed).toBeLessThan(kept);
});
});
describe('a car knocked off its lane', () => {
/**
* The failure this pins is not subtle to watch and is very easy to reintroduce:
* traffic driving tight circles forever, one car after another, because the
* aim point it is steering at cannot be reached.
*/
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
const displaced = (metresOff: number) => {
const state = createUnits();
// Right of the direction of travel, matching sim/units.ts.
const offX = -Math.cos(along);
const offZ = Math.sin(along);
const car = {
id: state.nextId++,
kind: 'car' as const,
faction: 'civilian' as const,
role: 'traffic' as const,
x: straight.ax + (straight.bx - straight.ax) * 0.15 + offX * metresOff,
z: straight.az + (straight.bz - straight.az) * 0.15 + offZ * metresOff,
heading: along,
speed: 12,
hp: 60,
path: [straight.b],
lastNode: straight.a,
expires: 1e6,
assigned: null,
onStation: 0,
cooldown: 1,
elevation: 1,
};
state.units.push(car);
let turned = 0;
let previousHeading = car.heading;
// Closest it ever gets to its lane while still driving that leg. Measuring
// at the end is useless: it reaches the junction, picks a new destination,
// and the original lane stops meaning anything.
let closestToLane = Infinity;
for (let i = 0; i < 300; i++) {
stepUnits(
state,
{
dt: 0.1,
now: i * 0.1,
// On the road, not far away: units well outside SIM_RADIUS of the
// player are culled, and a culled car proves nothing.
player: { x: (straight.ax + straight.bx) / 2, z: (straight.az + straight.bz) / 2 },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(6),
);
let delta = car.heading - previousHeading;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
turned += Math.abs(delta);
previousHeading = car.heading;
if (car.path[0] === straight.b) {
closestToLane = Math.min(
closestToLane,
Math.abs((car.x - straight.ax) * -Math.cos(along) + (car.z - straight.az) * Math.sin(along)),
);
}
}
return { turns: turned / (Math.PI * 2), lateral: closestToLane, car };
};
it('gets everybody somewhere, over a long run', () => {
/*
* The honest measure of the circling bug, and the one that took several
* attempts to arrive at. Counting how much cars *turn* is no good: a grid
* with a junction every hundred metres legitimately has them turning
* constantly. Nor is displacement from start to finish, since a car can
* loop the network and come back past where it began.
*
* How far it ever got from where it started is the one that separates
* "driving around town" from "driving around a lamppost".
*/
const state = createUnits();
const start = new Map<number, { x: number; z: number }>();
const furthest = new Map<number, number>();
const seen = new Map<number, number>();
for (let i = 0; i < 900; 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(19),
);
for (const u of state.units) {
if (u.role !== 'traffic') continue;
const from = start.get(u.id) ?? { x: u.x, z: u.z };
start.set(u.id, from);
furthest.set(
u.id,
Math.max(furthest.get(u.id) ?? 0, Math.hypot(u.x - from.x, u.z - from.z)),
);
seen.set(u.id, (seen.get(u.id) ?? 0) + 1);
}
}
// Only cars that were around for most of it: one that spawned in the last
// few seconds has had no chance to go anywhere and proves nothing.
const settled = [...furthest.entries()]
.filter(([id]) => (seen.get(id) ?? 0) > 600)
.map(([, d]) => d);
expect(settled.length).toBeGreaterThan(15);
// Nobody spends a minute and a half orbiting the spot they started on.
expect(Math.min(...settled)).toBeGreaterThan(40);
});
it('rejoins the road instead of orbiting it', () => {
// Thirty metres wide of its own road: far enough that the aim point used to
// be unreachable, so the car circled indefinitely and drifted further out
// every lap rather than coming back.
// Judged on whether it reached its lane, not on how much it turned: over
// thirty seconds it drives well past this junction and on through others,
// and turning at those is what driving is.
const run = displaced(30);
expect(run.lateral).toBeLessThan(6);
});
it('holds its lane when it is already on it', () => {
const run = displaced(0);
// Starts on the line and never wanders more than a lane's width off it.
expect(run.lateral).toBeLessThan(2);
});
});

View File

@ -11,7 +11,7 @@
*/ */
import type { Rng } from '../core/rng'; import type { Rng } from '../core/rng';
import type { RoadNetwork, RoadSegment } from './roads'; import type { RoadNetwork, RoadSegment } from './roads';
import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads'; import { ROAD_SPEED, pointOnSegment, projectOntoSegment, segmentAt } from './roads';
import { findRoute, travelTime, type Graph } from './routing'; import { findRoute, travelTime, type Graph } from './routing';
import type { Control, Front } from './regions'; import type { Control, Front } from './regions';
import { controlAt, depthAt } from './regions'; import { controlAt, depthAt } from './regions';
@ -67,6 +67,10 @@ export interface Unit {
chaseSpeed?: number; chaseSpeed?: number;
/** Junction most recently left, so the leg being driven is known. */ /** Junction most recently left, so the leg being driven is known. */
lastNode?: number; lastNode?: number;
/** Closest this unit has got to its next junction, for spotting a stall. */
closest?: number;
/** Seconds since it last got any closer to it. */
stuckFor?: number;
} }
/** /**
@ -100,6 +104,14 @@ export interface UnitState {
dispatched: Set<number>; dispatched: Set<number>;
nextId: number; nextId: number;
lastSkirmishAt: number; lastSkirmishAt: number;
/**
* Where shooting was last heard, and how long people keep running from it.
*
* Held here rather than read per frame because panic outlasts the noise: a
* crowd that stopped dead the instant the last round was fired would read as
* a switch being flipped rather than as people being frightened.
*/
alarm: { x: number; z: number; until: number } | null;
} }
export const createUnits = (): UnitState => ({ export const createUnits = (): UnitState => ({
@ -108,6 +120,7 @@ export const createUnits = (): UnitState => ({
dispatched: new Set(), dispatched: new Set(),
nextId: 1, nextId: 1,
lastSkirmishAt: -999, lastSkirmishAt: -999,
alarm: null,
}); });
// --- Tuning --------------------------------------------------------------- // --- Tuning ---------------------------------------------------------------
@ -119,6 +132,25 @@ export const PEDESTRIAN_TARGET = 30;
export const SIM_RADIUS = 640; export const SIM_RADIUS = 640;
/** Civilians keep clear of the shooting. */ /** Civilians keep clear of the shooting. */
const CIVILIAN_FLEE_RANGE = 70; const CIVILIAN_FLEE_RANGE = 70;
/**
* How much quicker somebody moves while they are in the road.
*
* Pedestrians used to amble across four lanes of traffic at the same pace they
* wander a courtyard, which reads as a world where nobody minds being run over.
* Nobody strolls across a road: you either stay off it or you get over it.
*/
const CROSSING_HASTE = 2.3;
/** How far ahead somebody looks before stepping into the road. */
const KERB_LOOKAHEAD = 4;
/**
* Chance per second that somebody actually needs the other side.
*
* Without this they never cross at all and each block becomes a sealed pen,
* which looks even more wrong than wandering into traffic did.
*/
const CROSSING_CHANCE = 0.12;
/** Seconds people keep running after the shooting stops. */
const PANIC_SECONDS = 6;
const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 }; const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
/** /**
@ -329,8 +361,37 @@ function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment |
* road rather than a corridor. * road rather than a corridor.
*/ */
const LANE_SHARE = 0.45; const LANE_SHARE = 0.45;
/** How far ahead a driver looks along their lane when deciding where to point. */ /**
* How far ahead a driver looks along their lane, at minimum and per m/s.
*
* The speed term is the one that matters and it is not a nicety it is the
* stability condition for this kind of steering, and getting it wrong is what
* had traffic driving in circles through four separate attempts at a fix.
*
* A vehicle chasing a point ahead of it oscillates unless that point sits
* outside its own turning circle, and the margin has to be about double. The
* tightest circle anything here can drive is speed / TURN_RATE: at 14 m/s and
* 2.2 rad/s, 6.4 metres. So the lookahead has to clear roughly 13 metres, and
* it was a flat 11 below the threshold at every speed traffic actually
* drives at, which is why it was never a junction bug or a crowding bug. It
* was arithmetic, and it circled wherever it happened to be.
*
* 1.4 m per m/s gives about 20 metres at cruise: comfortably outside the
* circle, with room for the car to be knocked about and still recover.
*/
const LANE_LOOKAHEAD = 11; const LANE_LOOKAHEAD = 11;
const LANE_LOOKAHEAD_PER_SPEED = 1.4;
/**
* How far ahead they look per metre off the lane, and the ceiling on it.
*
* Above 1 so the aim point never sits square to the lane, which is what makes
* the steering oscillate and then circle. Capped so it can never run so far
* ahead that the correction goes to nothing and the car drifts away instead.
*/
const LANE_RECOVERY = 1.7;
const LANE_LOOKAHEAD_CAP = 2.5;
/** How long a car may fail to get any closer to its destination before it is reset. */
const STUCK_SECONDS = 12;
/** How quickly a driver can swing the nose round, radians per second. */ /** How quickly a driver can swing the nose round, radians per second. */
const TURN_RATE = 2.2; const TURN_RATE = 2.2;
/** Gap a driver keeps to whatever is in front, metres. */ /** Gap a driver keeps to whatever is in front, metres. */
@ -339,8 +400,23 @@ const FOLLOW_GAP = 9;
const FOLLOW_RANGE = 26; const FOLLOW_RANGE = 26;
/** How wide a lane counts as "in front of me" rather than "beside me". */ /** How wide a lane counts as "in front of me" rather than "beside me". */
const FOLLOW_WIDTH = 2.6; const FOLLOW_WIDTH = 2.6;
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */ /**
const CAR_SEPARATION = 4.5; * Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m.
*
* Only genuine overlap, not polite spacing the following distance already
* keeps a queue nine metres apart, and this exists for the case that rule
* cannot see: two cars crossing at a junction, on different legs, heading
* ninety degrees apart.
*
* It used to be 4.5 and it shoved cars several metres sideways off their own
* lane. Pure pursuit then curved them back, and a steady sideways shove against
* a steady curve back is a circle which is exactly what was happening at
* intersections. Tight enough now that it separates cars that are actually
* inside one another and otherwise leaves the steering alone.
*/
const CAR_SEPARATION = 3;
/** Share of the overlap resolved per step, so it eases apart rather than jumps. */
const SEPARATION_EASE = 0.35;
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */ /** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
export const CREW_ELEVATION = 1.5; export const CREW_ELEVATION = 1.5;
@ -367,23 +443,46 @@ function turnToward(from: number, to: number, limit: number): number {
* never reaches the road it was sent to, and the whole escalation chain is * never reaches the road it was sent to, and the whole escalation chain is
* built on dispatched units actually arriving. * built on dispatched units actually arriving.
*/ */
function carAhead(state: UnitState, unit: Unit): number | null { function carAhead(
const forwardX = Math.sin(unit.heading); state: UnitState,
const forwardZ = Math.cos(unit.heading); unit: Unit,
player: { x: number; z: number },
/** Direction of the lane being driven — *not* the nose. See below. */
forwardX: number,
forwardZ: number,
): number | null {
let nearest: number | null = null; let nearest: number | null = null;
/** Is this thing in my way, and how far off is it? */
const inTheWay = (x: number, z: number): number | null => {
const dx = x - unit.x;
const dz = z - unit.z;
const ahead = dx * forwardX + dz * forwardZ;
if (ahead <= 0 || ahead > FOLLOW_RANGE) return null;
if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) return null;
return ahead;
};
for (const other of state.units) { for (const other of state.units) {
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue; if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
if (Math.cos(other.heading - unit.heading) < 0) continue; if (Math.sin(other.heading) * forwardX + Math.cos(other.heading) * forwardZ < 0) continue;
const dx = other.x - unit.x; const ahead = inTheWay(other.x, other.z);
const dz = other.z - unit.z; if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead;
const ahead = dx * forwardX + dz * forwardZ;
if (ahead <= 0 || ahead > FOLLOW_RANGE) continue;
if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) continue;
if (nearest === null || ahead < nearest) nearest = ahead;
} }
// And the player, whichever way they happen to be pointing. Traffic used to
// drive straight into a stationary car and shunt it down the road, which
// makes every other vehicle feel like weather rather than like a driver.
const atPlayer = inTheWay(player.x, player.z);
if (atPlayer !== null && (nearest === null || atPlayer < nearest)) nearest = atPlayer;
return nearest; return nearest;
} }
/** Is this civilian close enough to the shooting to have stopped being careful? */
const panicking = (state: UnitState, unit: Unit): boolean =>
state.alarm !== null && distance(state.alarm, unit) < CIVILIAN_FLEE_RANGE;
/** /**
* 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.
* *
@ -392,7 +491,13 @@ function carAhead(state: UnitState, unit: Unit): number | null {
* nose round rather than snapping it, and it lifts off for whatever is in * nose round rather than snapping it, and it lifts off for whatever is in
* front instead of driving through it. * front instead of driving through it.
*/ */
function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean { function advance(
unit: Unit,
roads: RoadNetwork,
state: UnitState,
player: { x: number; z: number },
dt: number,
): boolean {
const next = unit.path[0]; const next = unit.path[0];
if (next === undefined) return true; if (next === undefined) return true;
@ -423,9 +528,20 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
dirX /= legLength; dirX /= legLength;
dirZ /= legLength; dirZ /= legLength;
} }
// Right of the direction of travel. /*
const rightX = dirZ; * Right of the direction of travel.
const rightZ = -dirX; *
* Worth deriving rather than guessing, because the obvious form is the wrong
* one and it is invisible in code: this world is right-handed with Y up, so a
* car facing +Z has its right-hand side toward **-X**, not +X. Confirmed by
* the HUD's own compass which reads +X at heading 0 as a left turn and by
* driving the thing: hold W and D from heading 0 and the car goes to -X.
*
* The first version of this had the sign the other way round and every
* vehicle in the game quietly drove on the left.
*/
const rightX = -dirZ;
const rightZ = dirX;
const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2; const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2;
const offset = halfWidth * LANE_SHARE; const offset = halfWidth * LANE_SHARE;
@ -446,27 +562,136 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
if (remaining < 3 + offset || beyond > -0.5) { if (remaining < 3 + offset || beyond > -0.5) {
unit.lastNode = next; unit.lastNode = next;
unit.path.shift(); unit.path.shift();
unit.closest = undefined;
unit.stuckFor = 0;
return unit.path.length === 0; return unit.path.length === 0;
} }
// 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 * Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD`
// lookahead never quite arrives. * metres *from the car*.
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD; *
* The distance being measured from the car rather than along the lane is the
* entire trick, and getting it wrong is what had traffic doing twenty laps of
* a junction to net eight metres, one car after another.
*
* Aim a fixed distance *along the lane* from where the car projects onto it
* and the geometry breaks down twice over. Close in, the aim point falls
* inside the tightest circle the car can drive about six metres at
* `TURN_RATE` so it cannot reach it, swings past and comes round again.
* Far out, the fix of growing the lookahead makes it worse: the aim point
* runs away down the lane until the direction to it is almost parallel with
* the lane itself, the steering correction vanishes, and the car drifts
* further off every step. Measured drifting from 28 to 42 metres wide of its
* own road while dutifully pointing along it.
*
* Holding the aim at a fixed radius from the car fixes both ends at once. On
* the line, it sits `LANE_LOOKAHEAD` ahead and the car tracks straight. Off
* the line by less than that, the point slides back along the lane and the
* car cuts in at a sane angle. Off by more than that, there is no such point
* at all, so it aims at the nearest bit of lane there is and drives at it.
*/
const lateral = (unit.x - laneX) * rightX + (unit.z - laneZ) * rightZ;
const projected = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ;
/*
* The lookahead has to stay comfortably clear of how far off the lane the car
* actually is, and it has to stop growing.
*
* Let it equal the error and the aim point collapses onto the perpendicular:
* the car turns square at its own lane, overshoots, arrives the same distance
* out on the far side, and repeats. At full lock that oscillation is a
* circle, and it was the one still left 40 laps in a minute, 817 metres
* travelled, eight metres gained, at full speed the whole way.
*
* Letting it grow without limit is the other failure and it was the first fix
* tried here: the point runs away down the lane until the direction to it is
* almost parallel with the lane, the correction vanishes, and the car drifts
* out for ever. So: proportional to the error, floored, and capped.
*/
const lookahead = Math.min(
LANE_LOOKAHEAD * LANE_LOOKAHEAD_CAP,
Math.max(
LANE_LOOKAHEAD,
unit.speed * LANE_LOOKAHEAD_PER_SPEED,
Math.abs(lateral) * LANE_RECOVERY,
),
);
const reach = Math.sqrt(Math.max(0, lookahead * lookahead - lateral * lateral));
const along = projected + reach;
const aimX = laneX + dirX * along; const aimX = laneX + dirX * along;
const aimZ = laneZ + dirZ * along; const aimZ = laneZ + dirZ * along;
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z); 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. * Close on whatever is in front and lift off. Only civilians defer; anyone
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null; * with somewhere to be leans on the horn and keeps going and neither does
* anybody who has just heard shooting, because a driver getting out of a
* firefight is not going to wait behind you.
*
* "In front" is measured along the *lane*, not along the nose. In a cluster
* of stopped cars the noses swing about, so a heading-based cone has every
* car intermittently blocked by every other one and none of them can leave.
*/
const yields =
(unit.role === 'traffic' || unit.role === 'convoy') && !panicking(state, unit);
const gap = yields ? carAhead(state, unit, player, dirX, dirZ) : null;
const allowed = const allowed =
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed); 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); const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
/*
* Steer in proportion to how far the car actually travelled.
*
* This is the fix for traffic driving in circles. A jam brakes every car in
* it to a standstill each has a neighbour inside the following distance
* and they were still turning at the full rate while stopped, so a queue
* became a slowly rotating heap that stirred itself and never dispersed. A
* stationary car cannot change which way it is pointing; you steer by moving.
*/
const rolled = unit.speed * dt < 1e-9 ? 0 : move / (unit.speed * dt);
unit.heading = turnToward(unit.heading, desired, TURN_RATE * rolled * dt);
unit.x += Math.sin(unit.heading) * move; unit.x += Math.sin(unit.heading) * move;
unit.z += Math.cos(unit.heading) * move; unit.z += Math.cos(unit.heading) * move;
/*
* Last resort: notice when a car is getting nowhere, and put it back.
*
* Everything above is a controller with several interacting parts a lane to
* follow, a car in front to defer to, other vehicles shoving it out of the
* way and controllers of that shape have failure modes that are far easier
* to detect than to enumerate. The symptom is always the same and it is
* plainly visible from the road: driving in circles, at speed, for ever.
*
* So rather than trusting that the last one of those is now fixed, this
* measures the only thing that actually matters is it getting closer to
* where it is going and if the answer has been no for long enough, sets the
* car back down on its lane pointing the right way. A car that is genuinely
* queueing is not getting closer either, which is why the threshold is long
* enough to sit out any plausible hold-up.
*/
const gapToNode = Math.hypot(node.x - unit.x, node.z - unit.z);
if (unit.closest === undefined || gapToNode < unit.closest - 0.5) {
unit.closest = gapToNode;
unit.stuckFor = 0;
} else {
unit.stuckFor = (unit.stuckFor ?? 0) + dt;
if (unit.stuckFor > STUCK_SECONDS) {
// Put it back on its lane, pointing along it — and throw the route away.
// Repositioning alone was not enough: the car went straight back to
// whatever it had been doing and was stuck again within seconds. Losing
// the path forces a fresh one from wherever it now is, which is the only
// recovery that cannot resume the state it was stuck in.
unit.x = laneX + dirX * Math.max(0, projected);
unit.z = laneZ + dirZ * Math.max(0, projected);
unit.heading = Math.atan2(dirX, dirZ);
unit.path = [];
unit.lastNode = undefined;
unit.closest = undefined;
unit.stuckFor = 0;
return true;
}
}
return false; return false;
} }
@ -837,6 +1062,13 @@ export interface UnitStep {
* were seen. Null when nobody is chasing. * were seen. Null when nobody is chasing.
*/ */
hunt: { x: number; z: number } | null; hunt: { x: number; z: number } | null;
/**
* Muzzle positions from the last step, so civilians run from gunfire itself
* rather than only from the sight of somebody holding a rifle. Shooting is
* resolved after this runs, so these are one step old which is fine, and
* arguably right: you hear it, then you move.
*/
gunfire?: Array<{ x: number; z: number }>;
/** /**
* Is this point inside a building? Hunters drive round them rather than * Is this point inside a building? Hunters drive round them rather than
* through them, which is the entire reason turning a corner works. * through them, which is the entire reason turning a corner works.
@ -932,7 +1164,7 @@ export function stepUnits(
switch (unit.role) { switch (unit.role) {
case 'traffic': case 'traffic':
case 'convoy': { case 'convoy': {
if (advance(unit, roads, state, dt)) { if (advance(unit, roads, state, player, dt)) {
// Somewhere else to be. A convoy stays on ground its own side holds; // Somewhere else to be. A convoy stays on ground its own side holds;
// a taxi does not care. // a taxi does not care.
const pool = const pool =
@ -955,7 +1187,26 @@ export function stepUnits(
// A slow wander. People are not going anywhere in particular — but they // 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 // do go *round* the buildings, and take the turn as their new heading
// so they walk along a wall rather than repeatedly into it. // so they walk along a wall rather than repeatedly into it.
//
// And they keep off the road. Standing in traffic used to cost a
// pedestrian nothing, which quietly made running one over cost the
// player nothing either: if people wander into the road by themselves,
// hitting one is the world's fault rather than yours.
if (segmentAt(roads, unit.x, unit.z)) {
// Already out there. Hold the heading — dithering in the middle of a
// road is the one thing nobody does — and get across at a run.
unit.heading = moveThrough(unit, unit.heading, unit.speed * CROSSING_HASTE * dt, step.blocked);
break;
}
if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5; if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
// At the kerb: step off it, unless this is somebody who actually wants
// the other side, in which case they commit and hurry.
const kerbX = unit.x + Math.sin(unit.heading) * KERB_LOOKAHEAD;
const kerbZ = unit.z + Math.cos(unit.heading) * KERB_LOOKAHEAD;
const crossing = segmentAt(roads, kerbX, kerbZ) !== null && rng() > dt * CROSSING_CHANCE;
if (crossing) unit.heading += Math.PI * (0.5 + rng() * 0.5) * (rng() < 0.5 ? 1 : -1);
unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked); unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
break; break;
} }
@ -968,7 +1219,7 @@ export function stepUnits(
break; break;
} }
if (unit.path.length > 0) { if (unit.path.length > 0) {
advance(unit, roads, state, dt); advance(unit, roads, state, player, dt);
break; break;
} }
@ -1093,23 +1344,50 @@ export function stepUnits(
const dx = b.x - a.x; const dx = b.x - a.x;
const dz = b.z - a.z; const dz = b.z - a.z;
const gap = Math.hypot(dx, dz); const gap = Math.hypot(dx, dz);
if (gap >= CAR_SEPARATION || gap < 1e-6) continue; if (gap >= CAR_SEPARATION) continue;
const push = (CAR_SEPARATION - gap) / 2; const push = ((CAR_SEPARATION - gap) / 2) * SEPARATION_EASE;
const nx = dx / gap; /*
const nz = dz / gap; * Exactly coincident is the one case that has to be handled rather than
* skipped. Two cars at the same point have no direction to be pushed
* apart along, and skipping them welds the pair together permanently
* they then orbit as a unit, for ever. Any direction will do so long as
* it is deterministic; theirs are opposed, so they part.
*/
const nx = gap < 1e-6 ? 1 : dx / gap;
const nz = gap < 1e-6 ? 0 : dz / gap;
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked); moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
moveThrough(b, Math.atan2(nx, nz), push, step.blocked); moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
} }
} }
// --- Civilians get out of the way of a firefight --- // --- Civilians get out of the way of a firefight ---
// Anything fired near enough to be heard resets the clock, and people keep
// running for a few seconds after it stops. A crowd that froze the instant
// the last round went off would read as a switch, not as fear.
for (const muzzle of step.gunfire ?? []) {
if (distance(muzzle, player) > SIM_RADIUS) continue;
state.alarm = { x: muzzle.x, z: muzzle.z, until: step.now + PANIC_SECONDS };
}
if (state.alarm && step.now > state.alarm.until) state.alarm = null;
for (const unit of state.units) { for (const unit of state.units) {
if (unit.faction !== 'civilian') continue; if (unit.faction !== 'civilian') continue;
const danger = state.units.find( // Either somebody visibly holding a rifle, or the sound of one going off.
const armed = state.units.find(
(other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE, (other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE,
); );
const heard =
state.alarm && distance(state.alarm, unit) < CIVILIAN_FLEE_RANGE ? state.alarm : null;
const danger =
armed && heard
? distance(armed, unit) < distance(heard, unit)
? armed
: heard
: (armed ?? heard);
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);
// Running from gunfire beats keeping off the road: the point of the road
// rule is that people are careful, and this is the moment they are not.
unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked); unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
} }

View File

@ -47,6 +47,12 @@ export interface HudModel {
hunters: number; hunters: number;
/** Seconds of staying out of sight before they give up. */ /** Seconds of staying out of sight before they give up. */
losingIn: number; losingIn: number;
/** How settled the most interested onlooker is, 0..1. */
attention: number;
/** How many people currently have eyes on you. */
watching: number;
/** Bearings to each hunter, radians from the car's nose, positive to the left. */
bearings: number[];
}; };
/** Rounds in the air nearby. Not a health bar — a reason to keep moving. */ /** Rounds in the air nearby. Not a health bar — a reason to keep moving. */
danger: number; danger: number;
@ -65,7 +71,14 @@ export interface HudModel {
/** Eight-point compass, starting at "straight ahead" and turning left. */ /** Eight-point compass, starting at "straight ahead" and turning left. */
const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗']; const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗'];
const arrowFor = (bearing: number): string => { /**
* Exported so the world and the HUD can be held to the same idea of "right".
* This world is right-handed with Y up, so a car facing +Z has its right-hand
* side toward -X which is easy to get backwards in code and invisible when
* you do, so sim/units.ts is tested against this function rather than against
* its own copy of the convention.
*/
export const arrowFor = (bearing: number): string => {
const sector = Math.round(bearing / (Math.PI / 4)); const sector = Math.round(bearing / (Math.PI / 4));
return ARROWS[((sector % 8) + 8) % 8]!; return ARROWS[((sector % 8) + 8) % 8]!;
}; };
@ -77,15 +90,30 @@ const arrowFor = (bearing: number): string => {
*/ */
function pursuitLines(pursuit: HudModel['pursuit']): string[] { function pursuitLines(pursuit: HudModel['pursuit']): string[] {
if (pursuit.alert === 'hunted') { if (pursuit.alert === 'hunted') {
// Where they are, not just how many. A count tells you to panic; a set of
// bearings tells you which way to go, which is the actual decision.
const from = pursuit.bearings.length
? pursuit.bearings.map(arrowFor).join(' ')
: '—';
return [ return [
`HUNTED — ${pursuit.hunters} on you`, `HUNTED — ${pursuit.hunters} on you ${from}`,
pursuit.losingIn > 0 pursuit.losingIn > 0
? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them` ? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them`
: 'they can see you', : 'they can see you',
]; ];
} }
if (pursuit.meter <= 0.02) return ['cover intact']; if (pursuit.watching === 0 && pursuit.meter <= 0.02) return ['cover intact'];
return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`]; const lines: string[] = [];
if (pursuit.watching > 0) {
// The half of this the player could never see: somebody has clocked you and
// is making their mind up, and there is still time to be somewhere else.
lines.push(
`${pursuit.watching} watching ${bar(pursuit.attention)}` +
(pursuit.attention > 0.75 ? ' — they have you' : ''),
);
}
if (pursuit.meter > 0.02) lines.push(`noticed ${bar(pursuit.meter)}`);
return lines.length > 0 ? lines : ['cover intact'];
} }
const CONTROL_WORDS: Record<Control, string> = { const CONTROL_WORDS: Record<Control, string> = {
@ -171,7 +199,7 @@ export function createHud(seed: number, debug = false) {
`[debug] seed ${seed}`, `[debug] seed ${seed}`,
] ]
: []), : []),
'WASD drive · space handbrake · R respawn', 'WASD drive · space handbrake · R respawn · right-drag to look',
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`, `at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
'hold R to reset the campaign', 'hold R to reset the campaign',
]; ];

View File

@ -49,6 +49,14 @@ export interface MinimapView {
opportunity: { x: number; z: number } | null; opportunity: { x: number; z: number } | null;
/** Elapsed time, for ageing observations. */ /** Elapsed time, for ageing observations. */
now: number; now: number;
/**
* Anyone with their eye on you, and how settled they are about it.
*
* Drawn from live positions rather than from Intel, which is the one place
* this map is allowed to tell the truth: these are people you can see out of
* the window right now, not something you remember about a road.
*/
watchers: Array<{ x: number; z: number; settled: number; hunting: boolean }>;
} }
export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) { export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) {
@ -135,6 +143,32 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu
ctx.stroke(); ctx.stroke();
} }
// --- Who is looking at you, and who has stopped looking and started ---
for (const w of view.watchers) {
const wx = px(w.x);
const wz = px(w.z);
if (w.hunting) {
// A hunter is not a shade of anything. Solid, and bigger.
ctx.beginPath();
ctx.arc(wx, wz, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ff3a2a';
ctx.fill();
// A line back to the car, so a glance reads as a direction rather
// than as a dot you have to find yourself on the map.
ctx.beginPath();
ctx.moveTo(px(view.x), px(view.z));
ctx.lineTo(wx, wz);
ctx.strokeStyle = 'rgba(255,58,42,.35)';
ctx.lineWidth = 1;
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(wx, wz, 2 + w.settled * 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(224,176,64,${(0.35 + w.settled * 0.65).toFixed(2)})`;
ctx.fill();
}
}
// --- A field contact: marked, but not the same as an assigned target --- // --- A field contact: marked, but not the same as an assigned target ---
if (view.opportunity) { if (view.opportunity) {
ctx.beginPath(); ctx.beginPath();