Compare commits

..

No commits in common. "6ad69e59f06dc3b904065df59d2cdd7a347c4c1a" and "b4f8fd6c9926c9cc319436fdf61adfad29e8c232" have entirely different histories.

19 changed files with 95 additions and 2137 deletions

View File

@ -34,7 +34,6 @@ 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,14 +13,6 @@ 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 = {
@ -39,14 +31,6 @@ 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;
} { } {
@ -58,18 +42,6 @@ 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;
@ -97,45 +69,11 @@ 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: () => {
@ -156,12 +94,9 @@ 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.
@ -180,10 +115,6 @@ 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,10 +3,8 @@ import { generateWorld } from './sim/world';
import { import {
applyWear, applyWear,
canOverhaul, canOverhaul,
charity,
deriveHandling, deriveHandling,
freshCondition, freshCondition,
isDerelict,
overhaul, overhaul,
repair, repair,
surfaceFor, surfaceFor,
@ -35,14 +33,12 @@ 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,
@ -77,7 +73,6 @@ 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. */
@ -168,16 +163,6 @@ 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.
@ -269,8 +254,6 @@ 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. */
@ -421,21 +404,7 @@ 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,
); );
@ -504,14 +473,7 @@ 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( drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
physics,
driveState,
cmd,
deriveHandling(condition, car),
dt,
surfaceFor(currentSegment !== null),
);
physics.step(dt); physics.step(dt);
captureChassis(); captureChassis();
@ -524,12 +486,12 @@ async function boot() {
const lastImpact = physics.drainImpactForce(); const lastImpact = physics.drainImpactForce();
audio.impact(lastImpact); audio.impact(lastImpact);
condition = applyWear( condition = applyWear(condition, {
condition, dt,
{ dt, distance, throttle: Math.abs(cmd.throttle), impactForce: lastImpact }, distance,
// Armour is this number: what the car actually feels of a knock. throttle: Math.abs(cmd.throttle),
car.fragility, impactForce: lastImpact,
); });
// 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.
@ -588,18 +550,6 @@ 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
@ -808,9 +758,6 @@ 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,
@ -877,21 +824,6 @@ 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);
@ -923,12 +855,10 @@ 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();
@ -983,8 +913,6 @@ 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) {
@ -1017,7 +945,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 }, pursuit.eyes); unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
view.followSun(); view.followSun();
view.setTone(control, frameDt); view.setTone(control, frameDt);
markers.update(elapsed); markers.update(elapsed);
@ -1026,10 +954,6 @@ 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);
@ -1048,16 +972,6 @@ 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, {
@ -1089,15 +1003,6 @@ 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,
@ -1119,19 +1024,7 @@ 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: { state: { units, combat, heat, areas, intel, quests, front, model, bases, pursuit },
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,7 +22,6 @@ 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,17 +20,6 @@ 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 {
@ -168,22 +157,6 @@ 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,22 +1,11 @@
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.
@ -26,7 +15,7 @@ const CONTACT_HEIGHT = 7;
* 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, height = BEACON_HEIGHT): THREE.Group { function beacon(colour: number, radius: number): 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,
@ -39,10 +28,10 @@ function beacon(colour: number, radius: number, height = BEACON_HEIGHT): THREE.G
}); });
const pillar = new THREE.Mesh( const pillar = new THREE.Mesh(
new THREE.CylinderGeometry(0.9, 0.9, height, 8, 1, true), new THREE.CylinderGeometry(0.9, 0.9, BEACON_HEIGHT, 8, 1, true),
material, material,
); );
pillar.position.y = height / 2; pillar.position.y = BEACON_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.
@ -79,54 +68,7 @@ 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;
@ -135,7 +77,6 @@ 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,8 +12,6 @@ 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. */
@ -200,14 +198,9 @@ 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),
bodyMat, new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }),
); );
body.castShadow = true; body.castShadow = true;
car.add(body); car.add(body);
@ -258,16 +251,6 @@ 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);
@ -302,19 +285,6 @@ 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.
@ -344,7 +314,6 @@ 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;
@ -363,26 +332,14 @@ 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).applyAxisAngle(UP, look.yaw).applyQuaternion(car.quaternion); camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position);
camTarget.add(car.position);
camera.lookAt(camTarget); camera.lookAt(camTarget);
} }

View File

@ -53,15 +53,6 @@ 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();
@ -123,23 +114,6 @@ 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),
@ -154,7 +128,7 @@ export function createUnitView(scene: THREE.Scene) {
32, 32,
); );
for (const mesh of [cars, people, crew, guns, eyes, tracers, towers, towerGuns]) { for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) {
mesh.frustumCulled = false; mesh.frustumCulled = false;
scene.add(mesh); scene.add(mesh);
} }
@ -165,13 +139,7 @@ export function createUnitView(scene: THREE.Scene) {
}; };
return { return {
update( update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
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;
@ -232,25 +200,6 @@ 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,17 +12,6 @@
* 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;
@ -61,15 +50,8 @@ 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 = 40; export const FUNDS_PER_CONDITION = 100;
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const; export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
@ -81,63 +63,26 @@ 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(spec.engineForce * RUINED.drive, spec.engineForce, engine), engineForce: lerp(900, 2600, 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(spec.brakeForce * RUINED.brake, spec.brakeForce, tires), brakeForce: lerp(12, 36, 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(spec.maxSteer * RUINED.steer, spec.maxSteer, chassis), maxSteer: lerp(0.4, 0.62, 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(spec.grip * RUINED.grip, spec.grip, tires), frictionSlip: lerp(1.6, 5, 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.
* A bent chassis pulls to one side. Sign is stable for a given car. steeringPull: (1 - chassis) * 0.06,
*
* 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,
}; };
} }
@ -237,13 +182,8 @@ 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( export function applyWear(c: CarCondition, w: WearInput): CarCondition {
c: CarCondition, const impact = w.impactForce / IMPACT_REFERENCE;
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.
@ -295,13 +235,10 @@ 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.
* *
* Deliberately an absolute number rather than a multiple of the repair rate. * A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
* Repairs got cheaper so that a crash cannot strand you; the *scar* is supposed * undo, or seven missions' pay.
* 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.0012; const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
/** /**
* Spends parts on raising the ceiling itself, worst subsystem first. * Spends parts on raising the ceiling itself, worst subsystem first.
@ -348,41 +285,6 @@ 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,12 +116,6 @@ 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;
@ -191,19 +185,16 @@ 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 - engineHit), engine: Math.max(0, updated.level.engine - HIT_ENGINE),
tires: Math.max(0, updated.level.tires - tyreHit), tires: Math.max(0, updated.level.tires - HIT_TIRES),
chassis: Math.max(0, updated.level.chassis - chassisHit), chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS),
}, },
ceiling: { ceiling: {
engine: Math.max(0, updated.ceiling.engine - engineHit * 0.3), engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3),
tires: Math.max(0, updated.ceiling.tires - tyreHit * 0.3), tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3),
chassis: Math.max(0, updated.ceiling.chassis - chassisHit * 0.3), chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3),
}, },
}; };
} }

View File

@ -1,179 +0,0 @@
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);
});
});

View File

@ -1,227 +0,0 @@
/**
* 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,7 +10,6 @@ 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 {
@ -51,10 +50,6 @@ 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);
@ -137,8 +132,6 @@ 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);
}); });
@ -283,196 +276,3 @@ 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,16 +25,6 @@ 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 => ({
@ -43,69 +33,22 @@ 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 per second from somebody certain about you, right on top of you, * Suspicion shed per second with nobody watching.
* on ground and in a vehicle that both fully deserve it.
* *
* Large, because it is multiplied down hard by `alertness` in almost every real * Slow on purpose. Cover is meant to be something you lose and then have to
* situation. The pacing it is set against is concrete: an armoured carrier is * earn back by staying dull for a while; at a brisk decay rate you are clean
* made within a couple of seconds even on a road nobody has been working, and a * again before you have finished the corner.
* small unremarkable car can go through the same checkpoint several times
* before the road has learned enough about it to matter.
*/ */
const MAX_GAIN = 1.6; const DECAY = 0.05;
/**
* 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. */
@ -157,20 +100,6 @@ 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 =
@ -192,75 +121,18 @@ 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);
// --- Who is looking, and how settled they are about it --- // --- Suspicion ---
// Everyone who can see you creeps toward being sure; everyone who cannot if (exposure > 0 && seenBy.length > 0) {
// 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.
attention = Math.max(attention, settled * proximity); let closest = SIGHT_RANGE;
for (const unit of seenBy) {
closest = Math.min(closest, Math.hypot(unit.x - step.player.x, unit.z - step.player.z));
} }
for (const [id, settled] of state.eyes) { const proximity = (1 - closest / SIGHT_RANGE) ** 2;
if (looking.has(id)) continue; state.suspicion += proximity * MAX_GAIN * exposure * step.dt;
const faded = settled - FOCUS_FADE * step.dt; } else if (state.alert !== 'hunted') {
if (faded <= 0) state.eyes.delete(id); state.suspicion -= DECAY * step.dt;
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
@ -351,7 +223,3 @@ 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,8 +3,6 @@ import { generateWorld } from './world';
import { import {
applyWear, applyWear,
canOverhaul, canOverhaul,
charity,
isDerelict,
FUNDS_PER_CONDITION, FUNDS_PER_CONDITION,
deriveHandling, deriveHandling,
freshCondition, freshCondition,
@ -332,101 +330,3 @@ 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,8 +20,6 @@ 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);
@ -737,18 +735,11 @@ 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 centreline, positive to the *car's* right. // Signed offset from the segment's 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;
}; };
@ -902,393 +893,3 @@ 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, segmentAt } from './roads'; import { ROAD_SPEED, pointOnSegment, projectOntoSegment } 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,10 +67,6 @@ 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;
} }
/** /**
@ -104,14 +100,6 @@ 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 => ({
@ -120,7 +108,6 @@ export const createUnits = (): UnitState => ({
dispatched: new Set(), dispatched: new Set(),
nextId: 1, nextId: 1,
lastSkirmishAt: -999, lastSkirmishAt: -999,
alarm: null,
}); });
// --- Tuning --------------------------------------------------------------- // --- Tuning ---------------------------------------------------------------
@ -132,25 +119,6 @@ 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 };
/** /**
@ -361,37 +329,8 @@ 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. */
@ -400,23 +339,8 @@ 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. */
* Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. const CAR_SEPARATION = 4.5;
*
* 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;
@ -443,46 +367,23 @@ 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( function carAhead(state: UnitState, unit: Unit): number | null {
state: UnitState, const forwardX = Math.sin(unit.heading);
unit: Unit, const forwardZ = Math.cos(unit.heading);
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.sin(other.heading) * forwardX + Math.cos(other.heading) * forwardZ < 0) continue; if (Math.cos(other.heading - unit.heading) < 0) continue;
const ahead = inTheWay(other.x, other.z); const dx = other.x - unit.x;
if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead; const dz = other.z - unit.z;
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.
* *
@ -491,13 +392,7 @@ const panicking = (state: UnitState, unit: Unit): boolean =>
* 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( function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean {
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;
@ -528,20 +423,9 @@ function advance(
dirX /= legLength; dirX /= legLength;
dirZ /= legLength; dirZ /= legLength;
} }
/* // Right of the direction of travel.
* Right of the direction of travel. const rightX = dirZ;
* 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;
@ -562,136 +446,27 @@ function advance(
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
* Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD` // wherever the car currently projects onto it. Short lookahead weaves, long
* metres *from the car*. // lookahead never quite arrives.
* 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);
* Close on whatever is in front and lift off. Only civilians defer; anyone unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
* 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 // Close on the car in front and lift off. Only civilians defer; anyone with
* firefight is not going to wait behind you. // somewhere to be leans on the horn and keeps going.
* const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
* "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;
} }
@ -1062,13 +837,6 @@ 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.
@ -1164,7 +932,7 @@ export function stepUnits(
switch (unit.role) { switch (unit.role) {
case 'traffic': case 'traffic':
case 'convoy': { case 'convoy': {
if (advance(unit, roads, state, player, dt)) { if (advance(unit, roads, state, 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 =
@ -1187,26 +955,7 @@ 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;
} }
@ -1219,7 +968,7 @@ export function stepUnits(
break; break;
} }
if (unit.path.length > 0) { if (unit.path.length > 0) {
advance(unit, roads, state, player, dt); advance(unit, roads, state, dt);
break; break;
} }
@ -1344,50 +1093,23 @@ 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) continue; if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
const push = ((CAR_SEPARATION - gap) / 2) * SEPARATION_EASE; const push = (CAR_SEPARATION - gap) / 2;
/* const nx = dx / gap;
* Exactly coincident is the one case that has to be handled rather than const nz = dz / gap;
* 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;
// Either somebody visibly holding a rifle, or the sound of one going off. const danger = state.units.find(
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,12 +47,6 @@ 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;
@ -71,14 +65,7 @@ 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]!;
}; };
@ -90,30 +77,15 @@ export 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 ${from}`, `HUNTED — ${pursuit.hunters} on you`,
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.watching === 0 && pursuit.meter <= 0.02) return ['cover intact']; if (pursuit.meter <= 0.02) return ['cover intact'];
const lines: string[] = []; return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`];
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> = {
@ -199,7 +171,7 @@ export function createHud(seed: number, debug = false) {
`[debug] seed ${seed}`, `[debug] seed ${seed}`,
] ]
: []), : []),
'WASD drive · space handbrake · R respawn · right-drag to look', 'WASD drive · space handbrake · R respawn',
`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,14 +49,6 @@ 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) {
@ -143,32 +135,6 @@ 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();