Compare commits
12 Commits
b4f8fd6c99
...
6ad69e59f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad69e59f0 | ||
|
|
4b1f8c6444 | ||
|
|
5b881312b0 | ||
|
|
6a0c16fcf4 | ||
|
|
ea386bca2a | ||
|
|
47e9e8bfdb | ||
|
|
cf5d55bdf0 | ||
|
|
4bec70ba38 | ||
|
|
18477f0e38 | ||
|
|
d8a1f784ae | ||
|
|
2ffef66ef8 | ||
|
|
627eca2f3d |
@ -34,6 +34,7 @@ seeds may be numbers or words.
|
||||
| `1`–`3` | accept a mission, 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 |
|
||||
| right-drag | look around; let go and the view swings back |
|
||||
| `M` | sound on/off |
|
||||
| hold `R` | wipe the campaign and start over (5 seconds) |
|
||||
|
||||
|
||||
@ -13,6 +13,14 @@ export interface DriverInput {
|
||||
abandon: boolean;
|
||||
/** True on the frame C was pressed: overhaul the car. Consumed on read. */
|
||||
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 = {
|
||||
@ -31,6 +39,14 @@ const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
||||
|
||||
export function createInput(): {
|
||||
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;
|
||||
dispose(): void;
|
||||
} {
|
||||
@ -42,6 +58,18 @@ export function createInput(): {
|
||||
let pendingMute = false;
|
||||
let pendingAbandon = 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. */
|
||||
let onFirstGesture: (() => void) | null = null;
|
||||
|
||||
@ -69,11 +97,45 @@ export function createInput(): {
|
||||
pendingMute = false;
|
||||
pendingAbandon = 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('keyup', onUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('mousedown', onMouseDown);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
return {
|
||||
read: () => {
|
||||
@ -94,9 +156,12 @@ export function createInput(): {
|
||||
toggleMute,
|
||||
abandon,
|
||||
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,
|
||||
* so the audio context is resumed from here rather than at boot.
|
||||
@ -115,6 +180,10 @@ export function createInput(): {
|
||||
window.removeEventListener('keydown', onDown);
|
||||
window.removeEventListener('keyup', onUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('mousedown', onMouseDown);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('contextmenu', onContextMenu);
|
||||
down.clear();
|
||||
},
|
||||
};
|
||||
|
||||
127
src/main.ts
127
src/main.ts
@ -3,8 +3,10 @@ import { generateWorld } from './sim/world';
|
||||
import {
|
||||
applyWear,
|
||||
canOverhaul,
|
||||
charity,
|
||||
deriveHandling,
|
||||
freshCondition,
|
||||
isDerelict,
|
||||
overhaul,
|
||||
repair,
|
||||
surfaceFor,
|
||||
@ -33,12 +35,14 @@ import {
|
||||
type Offer,
|
||||
} from './sim/quests';
|
||||
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
|
||||
import { createGarage, current as drivingNow } from './sim/garage';
|
||||
import { createRadio, pollRadio } from './sim/radio';
|
||||
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
||||
import { createHeatProps } from './heatProps';
|
||||
import { createUnits, dispatchTo, stepUnits } from './sim/units';
|
||||
import { createCombat, dangerNear, stepCombat } from './sim/combat';
|
||||
import {
|
||||
closestAttention,
|
||||
createPursuit,
|
||||
PROVOCATION,
|
||||
recognitionMeter,
|
||||
@ -73,6 +77,7 @@ const DEAD_HANDS = {
|
||||
toggleMute: false,
|
||||
abandon: false,
|
||||
overhaul: false,
|
||||
look: { yaw: 0, pitch: 0, active: false },
|
||||
} as const;
|
||||
|
||||
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
|
||||
@ -163,6 +168,16 @@ async function boot() {
|
||||
const unitView = createUnitView(view.scene);
|
||||
const unitBodies = createUnitBodies(physics);
|
||||
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"
|
||||
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
||||
@ -254,6 +269,8 @@ async function boot() {
|
||||
let provocation = 0;
|
||||
/** Times the car has been written off underneath the player. */
|
||||
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. */
|
||||
let dying = 0;
|
||||
/** Where the camera has got to in its drift around it. */
|
||||
@ -404,7 +421,21 @@ async function boot() {
|
||||
const finishDeath = () => {
|
||||
kia++;
|
||||
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,
|
||||
);
|
||||
|
||||
@ -473,7 +504,14 @@ async function boot() {
|
||||
|
||||
// Tarmac or open ground — decided last step, since the road lookup needs
|
||||
// a position and the car has not moved yet this one.
|
||||
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
|
||||
drive(
|
||||
physics,
|
||||
driveState,
|
||||
cmd,
|
||||
deriveHandling(condition, car),
|
||||
dt,
|
||||
surfaceFor(currentSegment !== null),
|
||||
);
|
||||
physics.step(dt);
|
||||
captureChassis();
|
||||
|
||||
@ -486,12 +524,12 @@ async function boot() {
|
||||
|
||||
const lastImpact = physics.drainImpactForce();
|
||||
audio.impact(lastImpact);
|
||||
condition = applyWear(condition, {
|
||||
dt,
|
||||
distance,
|
||||
throttle: Math.abs(cmd.throttle),
|
||||
impactForce: lastImpact,
|
||||
});
|
||||
condition = applyWear(
|
||||
condition,
|
||||
{ dt, distance, throttle: Math.abs(cmd.throttle), impactForce: lastImpact },
|
||||
// Armour is this number: what the car actually feels of a knock.
|
||||
car.fragility,
|
||||
);
|
||||
|
||||
// Heat: the road remembers being used — including being driven alongside.
|
||||
// Behind your own lines it remembers nothing: nobody there is watching.
|
||||
@ -550,6 +588,18 @@ async function boot() {
|
||||
const base = dead ? null : baseAt(bases, at.x, at.z);
|
||||
const 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 ---
|
||||
// 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
|
||||
@ -758,6 +808,9 @@ async function boot() {
|
||||
// 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.
|
||||
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,
|
||||
graph,
|
||||
@ -824,6 +877,21 @@ async function boot() {
|
||||
units,
|
||||
canSee,
|
||||
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) {
|
||||
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
|
||||
@ -855,10 +923,12 @@ async function boot() {
|
||||
},
|
||||
condition,
|
||||
chatterRng,
|
||||
car.fragility,
|
||||
);
|
||||
condition = shooting.condition;
|
||||
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
||||
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
||||
lastGunfire = shooting.fired;
|
||||
if (shooting.playerHit) {
|
||||
provocation += PROVOCATION.shotAt;
|
||||
audio.hit();
|
||||
@ -913,6 +983,8 @@ async function boot() {
|
||||
// anywhere the player needs to be — the bases' own beacons take over.
|
||||
const heading = quests.active && quests.active.stage !== 'return' ? quests.active : 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) {
|
||||
@ -945,7 +1017,7 @@ async function boot() {
|
||||
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
||||
}
|
||||
|
||||
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
|
||||
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z }, pursuit.eyes);
|
||||
view.followSun();
|
||||
view.setTone(control, frameDt);
|
||||
markers.update(elapsed);
|
||||
@ -954,6 +1026,10 @@ async function boot() {
|
||||
frameDt,
|
||||
physics.vehicle.currentVehicleSpeed(),
|
||||
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);
|
||||
|
||||
@ -972,6 +1048,16 @@ async function boot() {
|
||||
heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
|
||||
objective: quest && quest.stage !== 'return' ? target : null,
|
||||
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, {
|
||||
@ -1003,6 +1089,15 @@ async function boot() {
|
||||
hunters: pursuit.hunters.size,
|
||||
// Counts down only once nobody has eyes on you.
|
||||
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),
|
||||
completed: quests.completed,
|
||||
@ -1024,7 +1119,19 @@ async function boot() {
|
||||
view,
|
||||
physics,
|
||||
// Live state, so a script can find something interesting and go look at it.
|
||||
state: { units, combat, heat, areas, intel, quests, front, model, bases, pursuit },
|
||||
state: {
|
||||
units,
|
||||
combat,
|
||||
heat,
|
||||
areas,
|
||||
intel,
|
||||
quests,
|
||||
front,
|
||||
model,
|
||||
bases,
|
||||
pursuit,
|
||||
opportunities,
|
||||
},
|
||||
condition: () => condition,
|
||||
/**
|
||||
* Force the car's condition, for looking at how a wrecked car drives
|
||||
|
||||
@ -22,6 +22,7 @@ const IDLE: DriverInput = {
|
||||
toggleMute: false,
|
||||
abandon: false,
|
||||
overhaul: false,
|
||||
look: { yaw: 0, pitch: 0, active: false },
|
||||
};
|
||||
|
||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||
|
||||
@ -20,6 +20,17 @@ export interface PhysicsWorld {
|
||||
telemetry(): Telemetry;
|
||||
/** A body the sim moves by hand, which still collides with the player. */
|
||||
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 {
|
||||
@ -157,6 +168,22 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
||||
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) {
|
||||
// Kinematic rather than dynamic: the unit sim owns where these are, but
|
||||
// they still shove the player's car when they meet it.
|
||||
|
||||
@ -1,11 +1,22 @@
|
||||
import * as THREE from 'three';
|
||||
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.
|
||||
* The player navigates by looking, so both need to be visible over scenery.
|
||||
*/
|
||||
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.
|
||||
@ -15,7 +26,7 @@ const BEACON_HEIGHT = 34;
|
||||
* middle of one, that is exactly where you spend your time. Narrow enough to see
|
||||
* past, tall enough to see over buildings.
|
||||
*/
|
||||
function beacon(colour: number, radius: number): THREE.Group {
|
||||
function beacon(colour: number, radius: number, height = BEACON_HEIGHT): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: colour,
|
||||
@ -28,10 +39,10 @@ function beacon(colour: number, radius: number): THREE.Group {
|
||||
});
|
||||
|
||||
const pillar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.9, 0.9, BEACON_HEIGHT, 8, 1, true),
|
||||
new THREE.CylinderGeometry(0.9, 0.9, height, 8, 1, true),
|
||||
material,
|
||||
);
|
||||
pillar.position.y = BEACON_HEIGHT / 2;
|
||||
pillar.position.y = height / 2;
|
||||
group.add(pillar);
|
||||
|
||||
// The ring is what tells you where to actually stop.
|
||||
@ -68,7 +79,54 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
|
||||
objective.visible = false;
|
||||
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 {
|
||||
/**
|
||||
* 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. */
|
||||
setObjective(target: { x: number; z: number } | null) {
|
||||
objective.visible = target !== null;
|
||||
@ -77,6 +135,7 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
|
||||
/** Slow spin, so a beacon reads as a marker rather than scenery. */
|
||||
update(elapsed: number) {
|
||||
objective.rotation.y = elapsed * 0.6;
|
||||
contact.rotation.y = elapsed * 0.45;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@ export interface SceneView {
|
||||
wheels: THREE.Object3D[];
|
||||
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */
|
||||
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. */
|
||||
followSun(): void;
|
||||
/** Eases the world's colour and visibility toward the current territory. */
|
||||
@ -198,9 +200,14 @@ export function createScene(model: WorldModel): SceneView {
|
||||
|
||||
// --- Car ---
|
||||
const car = new THREE.Group();
|
||||
const bodyMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x8c3b34,
|
||||
roughness: 0.55,
|
||||
metalness: 0.15,
|
||||
});
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }),
|
||||
bodyMat,
|
||||
);
|
||||
body.castShadow = true;
|
||||
car.add(body);
|
||||
@ -251,6 +258,16 @@ export function createScene(model: WorldModel): SceneView {
|
||||
car,
|
||||
wheels,
|
||||
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() {
|
||||
// Keep the shadow frustum centred on the car rather than the origin.
|
||||
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
|
||||
@ -285,6 +302,19 @@ const toneColour = new THREE.Color();
|
||||
const camTarget = new THREE.Vector3();
|
||||
const camDesired = new THREE.Vector3();
|
||||
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.
|
||||
@ -314,6 +344,7 @@ export function updateCamera(
|
||||
dt: number,
|
||||
speed: number,
|
||||
wake: { angle: number; progress: number } | null = null,
|
||||
looking: { yaw: number; pitch: number; active: boolean } = { yaw: 0, pitch: 0, active: false },
|
||||
): void {
|
||||
const { camera, car } = view;
|
||||
|
||||
@ -332,14 +363,26 @@ export function updateCamera(
|
||||
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);
|
||||
// Pull back a little at speed for a sense of pace.
|
||||
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);
|
||||
|
||||
const lerp = 1 - Math.exp(-6 * dt);
|
||||
camera.position.lerp(camDesired, lerp);
|
||||
|
||||
camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position);
|
||||
camTarget.set(0, 1.2, 4).applyAxisAngle(UP, look.yaw).applyQuaternion(car.quaternion);
|
||||
camTarget.add(car.position);
|
||||
camera.lookAt(camTarget);
|
||||
}
|
||||
|
||||
@ -53,6 +53,15 @@ function colourOf(unit: Unit): number {
|
||||
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) {
|
||||
const scratch = new THREE.Matrix4();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
@ -114,6 +123,23 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
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.
|
||||
const towers = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(3, 6, 3),
|
||||
@ -128,7 +154,7 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
32,
|
||||
);
|
||||
|
||||
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) {
|
||||
for (const mesh of [cars, people, crew, guns, eyes, tracers, towers, towerGuns]) {
|
||||
mesh.frustumCulled = false;
|
||||
scene.add(mesh);
|
||||
}
|
||||
@ -139,7 +165,13 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
};
|
||||
|
||||
return {
|
||||
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
|
||||
update(
|
||||
units: UnitState,
|
||||
rounds: Round[],
|
||||
elapsed: number,
|
||||
player: { x: number; z: number },
|
||||
watching: Map<number, number>,
|
||||
) {
|
||||
let carCount = 0;
|
||||
let personCount = 0;
|
||||
let crewCount = 0;
|
||||
@ -200,6 +232,25 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
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(people, personCount);
|
||||
park(crew, crewCount);
|
||||
|
||||
124
src/sim/car.ts
124
src/sim/car.ts
@ -12,6 +12,17 @@
|
||||
* 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 {
|
||||
/** 1 = factory fresh, 0 = ruined. */
|
||||
engine: number;
|
||||
@ -50,8 +61,15 @@ export interface Handling {
|
||||
* 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
|
||||
* read as a fraction of something unnamed.
|
||||
*
|
||||
* Lowered from 100 once crash damage went up fivefold. A reference head-on
|
||||
* costs 1.4 points of condition; at the old rate that was ¤140 to put right,
|
||||
* four missions' pay, for one bad corner — in a car that was by then too slow
|
||||
* to do those missions well. The *permanent* share is meant to be what a crash
|
||||
* costs you. Being unable to afford to get back on the road at all is not a
|
||||
* consequence, it is a dead end.
|
||||
*/
|
||||
export const FUNDS_PER_CONDITION = 100;
|
||||
export const FUNDS_PER_CONDITION = 40;
|
||||
|
||||
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
||||
|
||||
@ -63,26 +81,63 @@ export const freshCondition = (): CarCondition => ({
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
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;
|
||||
return {
|
||||
// A tired engine simply cannot push as hard.
|
||||
engineForce: lerp(900, 2600, engine),
|
||||
engineForce: lerp(spec.engineForce * RUINED.drive, spec.engineForce, engine),
|
||||
// Worn pads take longer to haul the car down. These are Rapier brake
|
||||
// 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.
|
||||
// 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.
|
||||
brakeForce: lerp(12, 36, tires),
|
||||
brakeForce: lerp(spec.brakeForce * RUINED.brake, spec.brakeForce, tires),
|
||||
// Lifting off has to actually slow you down. Without this the car coasts
|
||||
// almost forever and every stop needs a deliberate stab at the brake.
|
||||
coastBrake: lerp(4, 9, engine),
|
||||
maxSteer: lerp(0.4, 0.62, chassis),
|
||||
maxSteer: lerp(spec.maxSteer * RUINED.steer, spec.maxSteer, chassis),
|
||||
// Bald tyres are the most legible failure: the back end starts to leave.
|
||||
frictionSlip: lerp(1.6, 5, tires),
|
||||
frictionSlip: lerp(spec.grip * RUINED.grip, spec.grip, tires),
|
||||
sideFrictionStiffness: lerp(0.5, 1, tires),
|
||||
// A bent chassis pulls to one side. Sign is stable for a given car.
|
||||
steeringPull: (1 - chassis) * 0.06,
|
||||
/*
|
||||
* A bent chassis pulls to one side. Sign is stable for a given car.
|
||||
*
|
||||
* Linear at 0.06 this was the single most destructive number in the game.
|
||||
* It is an *uncommanded steering input that never lets go*: at 90% chassis
|
||||
* it walked the car off a fifteen-metre trunk, driving dead straight, in
|
||||
* under seven seconds. That is the swerve, and it was also most of the
|
||||
* speed loss, because off the tarmac drive force drops to 55% — measured
|
||||
* top speed fell 108 to 86 to 75 km/h across the first fifth of the car's
|
||||
* life, against a hunter that does 76.
|
||||
*
|
||||
* Worse, it reads from `chassis`, which is the subsystem a collision hurts
|
||||
* most. So the thing that most destroys your ability to drive was wired to
|
||||
* the thing that takes the most damage, and raising crash damage fivefold
|
||||
* quietly turned one bad corner into a car that cannot be driven straight.
|
||||
*
|
||||
* Squared and much smaller, early wear is texture — a car that needs
|
||||
* watching — and only a genuinely ruined one fights you for the wheel.
|
||||
*/
|
||||
steeringPull: (1 - chassis) ** 2 * 0.03,
|
||||
};
|
||||
}
|
||||
|
||||
@ -182,8 +237,13 @@ const MAX_DAMAGE_PER_STEP = 0.85;
|
||||
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
||||
* not enough to ignore.
|
||||
*/
|
||||
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
||||
const impact = w.impactForce / IMPACT_REFERENCE;
|
||||
export function applyWear(
|
||||
c: CarCondition,
|
||||
w: WearInput,
|
||||
/** How much of a knock this car actually feels. Armour is a number below 1. */
|
||||
fragility = 1,
|
||||
): CarCondition {
|
||||
const impact = (w.impactForce / IMPACT_REFERENCE) * fragility;
|
||||
const damage: Subsystems = {
|
||||
// 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.
|
||||
@ -235,10 +295,13 @@ export function repair(c: CarCondition, funds: number): { condition: CarConditio
|
||||
* patching it up; it is the expensive thing you do when patching has stopped
|
||||
* helping.
|
||||
*
|
||||
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
|
||||
* undo, or seven missions' pay.
|
||||
* Deliberately an absolute number rather than a multiple of the repair rate.
|
||||
* Repairs got cheaper so that a crash cannot strand you; the *scar* is supposed
|
||||
* to stay expensive, and tying the two together would have quietly discounted
|
||||
* the one thing that is meant to hurt. A crash's permanent cost — about 0.42
|
||||
* of ceiling across the three subsystems — still runs some ¤350, ten missions.
|
||||
*/
|
||||
const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
|
||||
const CEILING_PER_FUND = 0.0012;
|
||||
|
||||
/**
|
||||
* Spends parts on raising the ceiling itself, worst subsystem first.
|
||||
@ -285,6 +348,41 @@ export function overhaul(c: CarCondition, funds: number): { condition: CarCondit
|
||||
*/
|
||||
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. */
|
||||
export const canOverhaul = (c: CarCondition): boolean =>
|
||||
SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON);
|
||||
|
||||
@ -116,6 +116,12 @@ export function stepCombat(
|
||||
step: CombatStep,
|
||||
condition: CarCondition,
|
||||
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 {
|
||||
const { dt } = step;
|
||||
let playerHit = false;
|
||||
@ -185,16 +191,19 @@ export function stepCombat(
|
||||
// Routed through the same wear model as everything else, so a bullet
|
||||
// costs you ceiling too — it is permanent in the same way a crash is.
|
||||
updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
|
||||
const engineHit = HIT_ENGINE * fragility;
|
||||
const tyreHit = HIT_TIRES * fragility;
|
||||
const chassisHit = HIT_CHASSIS * fragility;
|
||||
updated = {
|
||||
level: {
|
||||
engine: Math.max(0, updated.level.engine - HIT_ENGINE),
|
||||
tires: Math.max(0, updated.level.tires - HIT_TIRES),
|
||||
chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS),
|
||||
engine: Math.max(0, updated.level.engine - engineHit),
|
||||
tires: Math.max(0, updated.level.tires - tyreHit),
|
||||
chassis: Math.max(0, updated.level.chassis - chassisHit),
|
||||
},
|
||||
ceiling: {
|
||||
engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3),
|
||||
tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3),
|
||||
chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3),
|
||||
engine: Math.max(0, updated.ceiling.engine - engineHit * 0.3),
|
||||
tires: Math.max(0, updated.ceiling.tires - tyreHit * 0.3),
|
||||
chassis: Math.max(0, updated.ceiling.chassis - chassisHit * 0.3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
179
src/sim/garage.test.ts
Normal file
179
src/sim/garage.test.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CARS,
|
||||
buy,
|
||||
carById,
|
||||
createGarage,
|
||||
current,
|
||||
nextUnlock,
|
||||
owns,
|
||||
take,
|
||||
virtues,
|
||||
} from './garage';
|
||||
import { applyWear, deriveHandling, freshCondition } from './car';
|
||||
|
||||
describe('the catalogue', () => {
|
||||
it('gives you something to drive for nothing', () => {
|
||||
// There is always a car. The loop's dead end was a player with no money and
|
||||
// nothing that ran, and the garage exists to make that state unreachable.
|
||||
const garage = createGarage();
|
||||
expect(garage.owned.length).toBeGreaterThan(0);
|
||||
expect(CARS[0]!.cost).toBe(0);
|
||||
expect(current(garage)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no car that beats another at everything', () => {
|
||||
// The invariant the whole design rests on. Suspicion is not tied to
|
||||
// capability here — a lorry is huge and tough and nobody looks twice —
|
||||
// so the thing keeping the catalogue honest is not a ladder but the
|
||||
// absence of a dominant option. If any vehicle were at least as good on
|
||||
// speed, protection, discretion *and* handling, picking the car would
|
||||
// stop being a decision and the loop would lose its only one.
|
||||
const axes = ['speed', 'protection', 'discretion', 'handling'] as const;
|
||||
for (const a of CARS) {
|
||||
for (const b of CARS) {
|
||||
if (a === b) continue;
|
||||
const va = virtues(a);
|
||||
const vb = virtues(b);
|
||||
const dominates = axes.every((k) => va[k] >= vb[k]);
|
||||
expect(dominates, `${a.name} dominates ${b.name}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the civilian half quiet however capable it gets', () => {
|
||||
// An ordinary thing doing an ordinary thing is invisible whatever its
|
||||
// specification. This is what stops "buy protection" and "stay unnoticed"
|
||||
// being the same axis.
|
||||
const lorry = carById('lorry');
|
||||
const sports = carById('sports');
|
||||
expect(lorry.presence).toBeLessThan(sports.presence / 2);
|
||||
// ...even though it is far better protected than the flashy one.
|
||||
expect(lorry.fragility).toBeLessThan(sports.fragility);
|
||||
});
|
||||
|
||||
it('charges attention only for the things that do not belong here', () => {
|
||||
const conspicuous = CARS.filter((c) => c.presence > 1.5).map((c) => c.id);
|
||||
expect(conspicuous.sort()).toEqual(['apc', 'sports']);
|
||||
});
|
||||
|
||||
it('does not put discretion up for sale', () => {
|
||||
// Being unremarkable must stay available for nothing, or the early game is
|
||||
// simply the worst version of the late one. The quietest vehicle in the
|
||||
// catalogue is one of the two cheapest.
|
||||
const byQuiet = [...CARS].sort((a, b) => a.presence - b.presence);
|
||||
const byPrice = [...CARS].sort((a, b) => a.cost - b.cost);
|
||||
expect(byPrice.slice(0, 2).map((c) => c.id)).toContain(byQuiet[0]!.id);
|
||||
});
|
||||
|
||||
it('starts you in the small car and nothing else', () => {
|
||||
const garage = createGarage();
|
||||
expect(garage.owned).toEqual(['runabout']);
|
||||
expect(current(garage).cost).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buying and taking cars', () => {
|
||||
it('will not sell you what you cannot afford', () => {
|
||||
const garage = createGarage();
|
||||
const dear = CARS[CARS.length - 1]!;
|
||||
const { bought, funds } = buy(garage, dear.id, dear.cost - 1);
|
||||
expect(bought).toBe(false);
|
||||
expect(funds).toBe(dear.cost - 1);
|
||||
expect(owns(garage, dear.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('takes the money once and only once', () => {
|
||||
const garage = createGarage();
|
||||
const car = CARS[1]!;
|
||||
const first = buy(garage, car.id, car.cost + 50);
|
||||
expect(first.bought).toBe(true);
|
||||
expect(first.funds).toBe(50);
|
||||
// Buying it again is not a way to lose fifty more.
|
||||
const second = buy(garage, car.id, first.funds);
|
||||
expect(second.bought).toBe(false);
|
||||
expect(second.funds).toBe(50);
|
||||
});
|
||||
|
||||
it('only lets you drive what is in the garage', () => {
|
||||
const garage = createGarage();
|
||||
expect(take(garage, CARS[2]!.id)).toBe(false);
|
||||
expect(current(garage).id).toBe(CARS[0]!.id);
|
||||
buy(garage, CARS[2]!.id, 99999);
|
||||
expect(take(garage, CARS[2]!.id)).toBe(true);
|
||||
expect(current(garage).id).toBe(CARS[2]!.id);
|
||||
});
|
||||
|
||||
it('points at the next thing worth saving for, until there is none', () => {
|
||||
const garage = createGarage();
|
||||
expect(nextUnlock(garage)!.id).toBe(CARS[1]!.id);
|
||||
for (const car of CARS) buy(garage, car.id, 99999);
|
||||
expect(nextUnlock(garage)).toBeNull();
|
||||
});
|
||||
|
||||
it('hands back a real car for an id it does not know', () => {
|
||||
// Saves outlive catalogues; a renamed car must not leave the player on foot.
|
||||
expect(carById('no-such-car').id).toBe(CARS[0]!.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the car you picked actually changes', () => {
|
||||
const fresh = freshCondition();
|
||||
const runabout = carById('runabout');
|
||||
const tractor = carById('tractor');
|
||||
const armoured = carById('apc');
|
||||
|
||||
it('drives like the car in the catalogue, not like one car with a skin', () => {
|
||||
const slow = deriveHandling(fresh, tractor);
|
||||
const quick = deriveHandling(fresh, armoured);
|
||||
expect(slow.engineForce).toBeCloseTo(tractor.engineForce, 6);
|
||||
expect(quick.engineForce).toBeCloseTo(armoured.engineForce, 6);
|
||||
// A tractor turns tighter than an armoured car and grips less.
|
||||
expect(slow.maxSteer).toBeGreaterThan(quick.maxSteer);
|
||||
expect(slow.frictionSlip).toBeLessThan(quick.frictionSlip);
|
||||
});
|
||||
|
||||
it('still leaves a ruined car driveable, whichever car it is', () => {
|
||||
// The garage exists so that being in a bad way is a situation rather than
|
||||
// an ending. A car that stops entirely at 0% is a fail state wearing a dial.
|
||||
const ruined = {
|
||||
level: { engine: 0, tires: 0, chassis: 0 },
|
||||
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||
};
|
||||
for (const spec of CARS) {
|
||||
const h = deriveHandling(ruined, spec);
|
||||
expect(h.engineForce).toBeGreaterThan(0);
|
||||
expect(h.maxSteer).toBeGreaterThan(0);
|
||||
expect(h.brakeForce).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('makes armour worth the attention it costs', () => {
|
||||
// The same crash, in the thing with no protection and the thing built for it.
|
||||
const crash = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 2.47e6 };
|
||||
const inTin = applyWear(fresh, crash, runabout.fragility);
|
||||
const inArmour = applyWear(fresh, crash, armoured.fragility);
|
||||
// Tin: one head-on and it is all but finished. The per-step cap is what
|
||||
// stops it reading as exactly zero.
|
||||
expect(inTin.level.chassis).toBeLessThan(0.25);
|
||||
// Steel: the same crash is a bad afternoon.
|
||||
expect(inArmour.level.chassis).toBeGreaterThan(0.5);
|
||||
// And the permanent scar scales with it too.
|
||||
expect(inArmour.ceiling.chassis).toBeGreaterThan(inTin.ceiling.chassis);
|
||||
});
|
||||
|
||||
it('leaves the free car genuinely fragile, so the trade has two sides', () => {
|
||||
// If the starting car merely went slower it would be a punishment rather
|
||||
// than a choice. It has to actually be made of tin.
|
||||
const knock = { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 6e5 };
|
||||
const tin = applyWear(fresh, knock, runabout.fragility);
|
||||
const steel = applyWear(fresh, knock, armoured.fragility);
|
||||
expect(1 - tin.level.chassis).toBeGreaterThan((1 - steel.level.chassis) * 3);
|
||||
});
|
||||
|
||||
it('gives the tractor real protection, since it is a lump of iron', () => {
|
||||
// "Very slow, some protection": it is not the fragile one, the runabout is.
|
||||
expect(tractor.fragility).toBeLessThan(runabout.fragility);
|
||||
expect(virtues(tractor).speed).toBeLessThan(virtues(runabout).speed);
|
||||
});
|
||||
});
|
||||
227
src/sim/garage.ts
Normal file
227
src/sim/garage.ts
Normal file
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* The cars you can be driving, and which of them you own. Pure — no engine
|
||||
* imports.
|
||||
*
|
||||
* This is the loop's central decision, and it exists because the old one had
|
||||
* none. Condition used to be health, capability, currency sink and fail state
|
||||
* all at once, so every event pushed the same way: damage made the car slower
|
||||
* and harder to hold straight, which made it worse at earning, which meant
|
||||
* fewer repairs, which meant more damage. A spiral with no negative term in it
|
||||
* anywhere, and no decision inside it either.
|
||||
*
|
||||
* A garage breaks that apart. Damage is now a *sortie* resource — you are
|
||||
* patched up when you get home — and money buys **cars**, permanently. So
|
||||
* progress climbs while any given drive still declines.
|
||||
*
|
||||
* The choice itself is the point, and the axis is not capability. It is
|
||||
* whether the vehicle *belongs here*:
|
||||
*
|
||||
* A lorry is huge and tough and nobody looks twice, because lorries exist.
|
||||
* A sports car is quick and flimsy and everybody looks, because who drives
|
||||
* that, here, now.
|
||||
*
|
||||
* So the civilian half of the catalogue — runabout, tractor, estate, lorry —
|
||||
* stays quiet however capable it gets, and you pay for protection and speed in
|
||||
* money and in handling rather than in attention. The military half buys you
|
||||
* out of that at a price nothing else charges.
|
||||
*
|
||||
* There is deliberately no best car, and it is checked rather than asserted:
|
||||
* no vehicle may beat another on speed, protection, discretion *and* handling
|
||||
* at once. A quiet errand down cold roads wants the thing nobody reports; a run
|
||||
* into a district that already knows your face wants the thing that survives
|
||||
* what happens next. Picking wrong is supposed to be how you lose.
|
||||
*/
|
||||
|
||||
export interface CarSpec {
|
||||
id: string;
|
||||
name: string;
|
||||
/** One line, in the words somebody handing you the keys would use. */
|
||||
blurb: string;
|
||||
/** What it costs to put one in the garage. The first is free. */
|
||||
cost: number;
|
||||
|
||||
// --- How it drives ---
|
||||
/** Kilograms. Heavier shrugs off shunts and takes far longer to stop. */
|
||||
mass: number;
|
||||
/** Newtons per driven wheel at full throttle, in good condition. */
|
||||
engineForce: number;
|
||||
/** Braking impulse per wheel, in good condition. */
|
||||
brakeForce: number;
|
||||
/** Steering lock in radians, in good condition. Big things turn like barges. */
|
||||
maxSteer: number;
|
||||
/** Tyre grip, in good condition. Lower slides. */
|
||||
grip: number;
|
||||
|
||||
// --- What it costs you ---
|
||||
/**
|
||||
* Share of incoming damage the car actually takes. Armour is this number.
|
||||
* Above 1 is worse than bare metal; 0.22 is something built to be shot at.
|
||||
*/
|
||||
fragility: number;
|
||||
/**
|
||||
* How much attention it draws, as a multiplier on how fast anyone watching
|
||||
* makes their mind up about you.
|
||||
*
|
||||
* Deliberately *not* correlated with how good the vehicle is. An ordinary
|
||||
* thing doing an ordinary thing is invisible whatever its specification; the
|
||||
* two that cost you here are the one nobody in this country drives and the
|
||||
* one with a gun mount.
|
||||
*/
|
||||
presence: number;
|
||||
|
||||
// --- What it looks like ---
|
||||
colour: number;
|
||||
/** Scale on the body mesh, so a lorry reads as a lorry from the mirror. */
|
||||
size: { width: number; height: number; length: number };
|
||||
}
|
||||
|
||||
/** The catalogue, cheapest first. */
|
||||
export const CARS: CarSpec[] = [
|
||||
{
|
||||
id: 'runabout',
|
||||
name: 'Small runabout',
|
||||
blurb: 'Somebody’s 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;
|
||||
@ -10,6 +10,7 @@ import {
|
||||
type PursuitStep,
|
||||
} from './pursuit';
|
||||
import { createUnits, type Faction, type Unit, type UnitState } from './units';
|
||||
import { carById } from './garage';
|
||||
import type { Control } from './regions';
|
||||
|
||||
function place(state: UnitState, faction: Faction, x: number, z: number): Unit {
|
||||
@ -50,6 +51,10 @@ function run(
|
||||
units,
|
||||
canSee: () => true,
|
||||
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,
|
||||
})) {
|
||||
events.push(event.kind);
|
||||
@ -132,6 +137,8 @@ describe('being noticed', () => {
|
||||
units,
|
||||
canSee: () => true,
|
||||
provocation: PROVOCATION.ranOverSoldier,
|
||||
wariness: 0,
|
||||
presence: 1,
|
||||
});
|
||||
expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2);
|
||||
});
|
||||
@ -276,3 +283,196 @@ describe('your own side pulling them off you', () => {
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,6 +25,16 @@ export interface PursuitState {
|
||||
lastSeen: { x: number; z: number } | null;
|
||||
/** Unit ids currently chasing. */
|
||||
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 => ({
|
||||
@ -33,22 +43,69 @@ export const createPursuit = (): PursuitState => ({
|
||||
unseenFor: 0,
|
||||
lastSeen: null,
|
||||
hunters: new Set(),
|
||||
eyes: new Map(),
|
||||
});
|
||||
|
||||
// --- Tuning ---------------------------------------------------------------
|
||||
|
||||
/** How far an enemy can make you out at all. */
|
||||
export const SIGHT_RANGE = 95;
|
||||
/** Suspicion per second with someone right on top of you. */
|
||||
const MAX_GAIN = 0.42;
|
||||
/**
|
||||
* Suspicion shed per second with nobody watching.
|
||||
* Suspicion per second from somebody certain about you, right on top of you,
|
||||
* on ground and in a vehicle that both fully deserve it.
|
||||
*
|
||||
* Slow on purpose. Cover is meant to be something you lose and then have to
|
||||
* earn back by staying dull for a while; at a brisk decay rate you are clean
|
||||
* again before you have finished the corner.
|
||||
* Large, because it is multiplied down hard by `alertness` in almost every real
|
||||
* situation. The pacing it is set against is concrete: an armoured carrier is
|
||||
* made within a couple of seconds even on a road nobody has been working, and a
|
||||
* small unremarkable car can go through the same checkpoint several times
|
||||
* before the road has learned enough about it to matter.
|
||||
*/
|
||||
const DECAY = 0.05;
|
||||
const MAX_GAIN = 1.6;
|
||||
/**
|
||||
* How fast somebody goes from noticing a car to being sure about it, per second,
|
||||
* with the car right beside them.
|
||||
*
|
||||
* This exists because cover was too easy to lose. Suspicion used to start
|
||||
* climbing the instant anybody had line of sight, so driving *past* a checkpoint
|
||||
* cost the same as loitering at one, and being made was something that happened
|
||||
* to you rather than something you could feel coming and back out of.
|
||||
*
|
||||
* Now a watcher has to settle first, and only then do they start filling the
|
||||
* meter. Passing through quickly leaves everyone at a glance; lingering is what
|
||||
* actually costs you. It roughly doubles the time to be recognised, and — more
|
||||
* to the point — makes the first half of that time legible.
|
||||
*/
|
||||
const FOCUS_RATE = 0.55;
|
||||
/**
|
||||
* How much attention a place pays a stranger when nothing has happened there.
|
||||
*
|
||||
* The floor under `wariness`, and the point of the whole mechanism. Occupied
|
||||
* ground used to blow your cover on its own: park anywhere past the line, in
|
||||
* anything, and someone would eventually make you. But nobody is looking for a
|
||||
* driver they have no reason to believe exists — if no road round here has been
|
||||
* worked and no district has a story about you, a car going past is a car going
|
||||
* past.
|
||||
*
|
||||
* Above zero because occupied ground is still occupied: sit in front of a
|
||||
* checkpoint long enough and somebody will wander over regardless.
|
||||
*/
|
||||
const COLD_GROUND = 0.12;
|
||||
/** How fast that interest fades once they cannot see you. Quicker than suspicion. */
|
||||
const FOCUS_FADE = 0.7;
|
||||
/**
|
||||
* Suspicion shed per second, at all times.
|
||||
*
|
||||
* Slow on purpose, and for two reasons now. Cover is meant to be something you
|
||||
* lose and then have to earn back by being dull for a while. And because it
|
||||
* runs continuously it is also the *threshold*: below DECAY worth of attention
|
||||
* you are not slowly being made, you are simply not being made.
|
||||
*
|
||||
* Small enough that a little survives the gap between one trip past a
|
||||
* checkpoint and the next. That residue is what lets a small car go through
|
||||
* several times before the place has worked out that it keeps coming through —
|
||||
* the road's own heat does most of that remembering, but not all of it.
|
||||
*/
|
||||
const DECAY = 0.02;
|
||||
/** Suspicion at which you stop being a car and start being a target. */
|
||||
const RECOGNISED = 1;
|
||||
/** Above this the HUD starts warning you. */
|
||||
@ -100,6 +157,20 @@ export interface PursuitStep {
|
||||
canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean;
|
||||
/** Suspicion added outright this step by things the player just did. */
|
||||
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 =
|
||||
@ -121,18 +192,75 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
|
||||
const exposure = EXPOSURE[step.control];
|
||||
const seenBy = watchers(step);
|
||||
|
||||
// --- Suspicion ---
|
||||
if (exposure > 0 && seenBy.length > 0) {
|
||||
// --- Who is looking, and how settled they are about it ---
|
||||
// Everyone who can see you creeps toward being sure; everyone who cannot
|
||||
// loses interest, faster than they gained it.
|
||||
/*
|
||||
* How much trouble this is, all in one number: whose ground it is, how wary
|
||||
* the place has been made, and what you turned up in.
|
||||
*
|
||||
* All three belong on the *focus* ramp rather than on suspicion directly,
|
||||
* because that is the half the player can see. A hot road does not silently
|
||||
* fill a meter faster — the diamonds over people's heads redden faster, and
|
||||
* you can read the road's reputation off the crowd before it costs you
|
||||
* anything.
|
||||
*/
|
||||
const wariness = COLD_GROUND + (1 - COLD_GROUND) * Math.min(1, Math.max(0, step.wariness));
|
||||
const alertness = exposure * wariness * step.presence;
|
||||
|
||||
const looking = new Set<number>();
|
||||
let attention = 0;
|
||||
for (const unit of seenBy) {
|
||||
looking.add(unit.id);
|
||||
const gap = Math.hypot(unit.x - step.player.x, unit.z - step.player.z);
|
||||
const proximity = (1 - gap / SIGHT_RANGE) ** 2;
|
||||
/*
|
||||
* Alertness caps how sure anyone can get, not merely how fast.
|
||||
*
|
||||
* Without the ceiling a quiet car on a cold road is only *slower* to blow
|
||||
* your cover, never safe from it — everybody eventually saturates and the
|
||||
* whole distinction collapses. With it, somebody with no reason to suspect
|
||||
* a driver exists tops out at a glance and stays there.
|
||||
*/
|
||||
const ceiling = Math.min(1, alertness);
|
||||
const settled = Math.min(
|
||||
ceiling,
|
||||
(state.eyes.get(unit.id) ?? 0) + FOCUS_RATE * proximity * alertness * step.dt,
|
||||
);
|
||||
state.eyes.set(unit.id, settled);
|
||||
// Whoever has the best look at you sets the pace; a crowd is not more
|
||||
// suspicious than one person standing right next to the car.
|
||||
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));
|
||||
attention = Math.max(attention, settled * proximity);
|
||||
}
|
||||
const proximity = (1 - closest / SIGHT_RANGE) ** 2;
|
||||
state.suspicion += proximity * MAX_GAIN * exposure * step.dt;
|
||||
} else if (state.alert !== 'hunted') {
|
||||
state.suspicion -= DECAY * step.dt;
|
||||
for (const [id, settled] of state.eyes) {
|
||||
if (looking.has(id)) continue;
|
||||
const faded = settled - FOCUS_FADE * step.dt;
|
||||
if (faded <= 0) state.eyes.delete(id);
|
||||
else state.eyes.set(id, faded);
|
||||
}
|
||||
|
||||
/*
|
||||
* Suspicion, as a race between being looked at and being forgotten.
|
||||
*
|
||||
* Two different things, kept apart on purpose. `attention` is how *sure* the
|
||||
* keenest onlooker is, and a person cannot be more than certain — so it caps,
|
||||
* and it is what the markers over their heads show. `alertness` is what that
|
||||
* certainty costs you here, and it does not cap at all: somebody certain
|
||||
* about an armoured carrier on a road they have been working all week is a
|
||||
* different event from somebody equally certain about a tractor on a quiet
|
||||
* one. Folding the two together made every vehicle identical the moment
|
||||
* anybody was sure about it, which took the garage's whole decision back out.
|
||||
*
|
||||
* The decay runs the whole time rather than only when nobody is watching, and
|
||||
* that is what turns this from an accumulator into a threshold. Below
|
||||
* DECAY/MAX_GAIN of attention you are not slowly being made, you are simply
|
||||
* *not being made* — a tractor on a road nobody has worked can go about its
|
||||
* business all day. Above it, the clock is running. An accumulator has no
|
||||
* such line: given long enough it always reaches the top, and "nobody
|
||||
* suspects you here" could never mean anything.
|
||||
*/
|
||||
if (state.alert !== 'hunted') {
|
||||
state.suspicion += (attention * alertness * MAX_GAIN - DECAY) * step.dt;
|
||||
}
|
||||
|
||||
// Provocations land whatever the range, and they land in full: driving over
|
||||
@ -223,3 +351,7 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
|
||||
|
||||
/** Fraction of the way to being recognised, for the HUD meter. */
|
||||
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());
|
||||
|
||||
@ -3,6 +3,8 @@ import { generateWorld } from './world';
|
||||
import {
|
||||
applyWear,
|
||||
canOverhaul,
|
||||
charity,
|
||||
isDerelict,
|
||||
FUNDS_PER_CONDITION,
|
||||
deriveHandling,
|
||||
freshCondition,
|
||||
@ -330,3 +332,101 @@ describe('overhaul', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,6 +20,8 @@ import {
|
||||
type UnitState,
|
||||
} from './units';
|
||||
import { createCombat, segmentHits, stepCombat } from './combat';
|
||||
import { arrowFor } from '../ui/hud';
|
||||
import { segmentAt } from './roads';
|
||||
|
||||
const world = generateWorld(1337);
|
||||
const graph = buildGraph(world.roads);
|
||||
@ -735,11 +737,18 @@ describe('nobody fires at what they cannot see', () => {
|
||||
describe('traffic that drives like traffic', () => {
|
||||
/** 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]) => {
|
||||
// Signed offset from the segment's centreline, positive to the car's right.
|
||||
// Signed offset from the centreline, positive to the *car's* right.
|
||||
//
|
||||
// Right-handed world, Y up: something facing +Z has its right toward -X.
|
||||
// So for a forward of (sin a, cos a) the right vector is (-cos a, sin a).
|
||||
// Deriving this independently of the sim is the whole value of the test —
|
||||
// the first version copied the sim's vector, inherited its inverted sign,
|
||||
// and cheerfully certified that everything drove on the correct side while
|
||||
// the entire map drove on the left.
|
||||
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
|
||||
const px = unit.x - seg.ax;
|
||||
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.
|
||||
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
|
||||
};
|
||||
@ -893,3 +902,393 @@ describe('your own side, with vehicles', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('which way is right', () => {
|
||||
/**
|
||||
* The lane offset is the one piece of geometry in this file where getting the
|
||||
* sign backwards is both easy and completely invisible: everything still
|
||||
* drives in neat parallel lines, just on the wrong side of the road. The
|
||||
* first version of it did exactly that.
|
||||
*
|
||||
* So it is checked against the HUD's compass rather than against another copy
|
||||
* of the same reasoning. Two modules that must agree, written independently,
|
||||
* is the only version of this test that can actually fail.
|
||||
*/
|
||||
const laneRightOf = (headingRad: number) => {
|
||||
const dirX = Math.sin(headingRad);
|
||||
const dirZ = Math.cos(headingRad);
|
||||
// Must match sim/units.ts `advance`.
|
||||
return { x: -dirZ, z: dirX };
|
||||
};
|
||||
|
||||
const bearingTo = (headingRad: number, to: { x: number; z: number }) =>
|
||||
Math.atan2(to.x, to.z) - headingRad;
|
||||
|
||||
it('offsets cars toward the side the HUD calls right', () => {
|
||||
for (const heading of [0, Math.PI / 2, Math.PI, -Math.PI / 2, 0.7, -2.4]) {
|
||||
const right = laneRightOf(heading);
|
||||
expect(arrowFor(bearingTo(heading, right))).toBe('→');
|
||||
}
|
||||
});
|
||||
|
||||
it('is not merely self-consistent — the other side reads as left', () => {
|
||||
for (const heading of [0, 1.2, -0.9]) {
|
||||
const right = laneRightOf(heading);
|
||||
expect(arrowFor(bearingTo(heading, { x: -right.x, z: -right.z }))).toBe('←');
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with the car: steering right from heading 0 goes toward -X', () => {
|
||||
// Measured on the running game with Rapier: hold W and D from heading 0 and
|
||||
// the car ends up at negative x. The sim layer cannot import the physics,
|
||||
// so the number is recorded here instead of re-derived.
|
||||
expect(laneRightOf(0).x).toBeLessThan(0);
|
||||
expect(laneRightOf(0).z).toBeCloseTo(0, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('civilians have some sense', () => {
|
||||
const onRoad = (u: { x: number; z: number }) => segmentAt(world.roads, u.x, u.z) !== null;
|
||||
|
||||
const crowd = (overrides = {}) => {
|
||||
const state = createUnits();
|
||||
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z }, ...overrides }, makeRng(44));
|
||||
return state;
|
||||
};
|
||||
|
||||
it('keeps people out of the road', () => {
|
||||
const people = crowd().units.filter((u) => u.role === 'pedestrian');
|
||||
expect(people.length).toBeGreaterThan(5);
|
||||
// Not zero: somebody is always mid-crossing. But it has to be the exception.
|
||||
expect(people.filter(onRoad).length / people.length).toBeLessThan(0.15);
|
||||
});
|
||||
|
||||
it('makes the ones who are crossing hurry', () => {
|
||||
// Sampled over time, since at any instant only a couple are on tarmac.
|
||||
const state = createUnits();
|
||||
let crossingSteps = 0;
|
||||
let crossingDistance = 0;
|
||||
let strollingSteps = 0;
|
||||
let strollingDistance = 0;
|
||||
const before = new Map<number, { x: number; z: number }>();
|
||||
for (let i = 0; i < 2400; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: i * 0.1,
|
||||
player: { x: world.spawn.x, z: world.spawn.z },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(44),
|
||||
);
|
||||
for (const u of state.units) {
|
||||
if (u.role !== 'pedestrian') continue;
|
||||
const was = before.get(u.id);
|
||||
if (was) {
|
||||
const moved = Math.hypot(u.x - was.x, u.z - was.z);
|
||||
if (onRoad(u)) {
|
||||
crossingSteps++;
|
||||
crossingDistance += moved;
|
||||
} else {
|
||||
strollingSteps++;
|
||||
strollingDistance += moved;
|
||||
}
|
||||
}
|
||||
before.set(u.id, { x: u.x, z: u.z });
|
||||
}
|
||||
}
|
||||
expect(crossingSteps).toBeGreaterThan(30);
|
||||
// Nobody strolls across a road.
|
||||
expect(crossingDistance / crossingSteps).toBeGreaterThan(
|
||||
(strollingDistance / strollingSteps) * 1.5,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs from gunfire, not only from the sight of a rifle', () => {
|
||||
// Run the same four seconds twice, identical but for one shot fired on the
|
||||
// first step with nobody visibly armed anywhere. Everything else — the
|
||||
// wander, the seed, the road avoidance — is held constant, so the
|
||||
// difference is the gunfire and nothing else.
|
||||
const walk = (withShot: boolean) => {
|
||||
const state = createUnits();
|
||||
run(state, 60, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(44));
|
||||
const person = state.units.find((u) => u.role === 'pedestrian')!;
|
||||
const shot = { x: person.x + 6, z: person.z };
|
||||
for (let i = 0; i < 40; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: 60 + i * 0.1,
|
||||
player: { x: world.spawn.x, z: world.spawn.z },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
// Fired once, on the first step only.
|
||||
gunfire: withShot && i === 0 ? [shot] : [],
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(44),
|
||||
);
|
||||
}
|
||||
return Math.hypot(person.x - shot.x, person.z - shot.z);
|
||||
};
|
||||
|
||||
// Four seconds, against a six-second panic: they are still running when the
|
||||
// measurement is taken, rather than having frozen the instant it went quiet.
|
||||
expect(walk(true)).toBeGreaterThan(walk(false) + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic and the player', () => {
|
||||
/**
|
||||
* One civilian car partway down a long straight, with the player parked
|
||||
* twelve metres ahead of it on the same road.
|
||||
*
|
||||
* Built rather than found: taking whichever car happened to exist put the
|
||||
* player on its instantaneous heading, which is not the same as its lane, so
|
||||
* the car would reach a junction and turn off before anything was decided.
|
||||
*/
|
||||
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
|
||||
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
|
||||
|
||||
const approaching = () => {
|
||||
const state = createUnits();
|
||||
const at = 0.25;
|
||||
const car = {
|
||||
id: state.nextId++,
|
||||
kind: 'car' as const,
|
||||
faction: 'civilian' as const,
|
||||
role: 'traffic' as const,
|
||||
x: straight.ax + (straight.bx - straight.ax) * at,
|
||||
z: straight.az + (straight.bz - straight.az) * at,
|
||||
heading: along,
|
||||
speed: 12,
|
||||
hp: 60,
|
||||
path: [straight.b],
|
||||
lastNode: straight.a,
|
||||
expires: 1e6,
|
||||
assigned: null,
|
||||
onStation: 0,
|
||||
cooldown: 1,
|
||||
elevation: 1,
|
||||
};
|
||||
state.units.push(car);
|
||||
return {
|
||||
state,
|
||||
car,
|
||||
player: { x: car.x + Math.sin(along) * 12, z: car.z + Math.cos(along) * 12 },
|
||||
};
|
||||
};
|
||||
|
||||
const drive = (
|
||||
state: ReturnType<typeof createUnits>,
|
||||
car: { x: number; z: number; heading: number },
|
||||
player: { x: number; z: number },
|
||||
gunfire = false,
|
||||
) => {
|
||||
// Fired from *behind* the car, so panic pushes it on toward the player
|
||||
// rather than simply away from the noise — otherwise the test only proves
|
||||
// that people run from gunfire, which is a different test.
|
||||
const shot = { x: car.x - Math.sin(along) * 10, z: car.z - Math.cos(along) * 10 };
|
||||
// The closest it ever gets. End-distance is useless here: a car that does
|
||||
// not stop drives straight past and ends up further away than one that did.
|
||||
let closest = Infinity;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: 60 + i * 0.1,
|
||||
player,
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
gunfire: gunfire && i === 0 ? [shot] : [],
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(2),
|
||||
);
|
||||
closest = Math.min(closest, Math.hypot(car.x - player.x, car.z - player.z));
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
it('stops rather than shunting a parked car down the road', () => {
|
||||
const { state, car, player } = approaching();
|
||||
// Never gets within ramming distance of a car sitting in its way. Traffic
|
||||
// used to drive straight into a stationary player and shove them along,
|
||||
// which makes every other vehicle read as weather rather than as a driver.
|
||||
expect(drive(state, car, player)).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('does not stop for you when it is running from gunfire', () => {
|
||||
const calm = approaching();
|
||||
const kept = drive(calm.state, calm.car, calm.player);
|
||||
|
||||
const panicked = approaching();
|
||||
const closed = drive(panicked.state, panicked.car, panicked.player, true);
|
||||
|
||||
// A driver getting out of a firefight is not going to wait behind you.
|
||||
expect(closed).toBeLessThan(kept);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a car knocked off its lane', () => {
|
||||
/**
|
||||
* The failure this pins is not subtle to watch and is very easy to reintroduce:
|
||||
* traffic driving tight circles forever, one car after another, because the
|
||||
* aim point it is steering at cannot be reached.
|
||||
*/
|
||||
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
|
||||
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
|
||||
|
||||
const displaced = (metresOff: number) => {
|
||||
const state = createUnits();
|
||||
// Right of the direction of travel, matching sim/units.ts.
|
||||
const offX = -Math.cos(along);
|
||||
const offZ = Math.sin(along);
|
||||
const car = {
|
||||
id: state.nextId++,
|
||||
kind: 'car' as const,
|
||||
faction: 'civilian' as const,
|
||||
role: 'traffic' as const,
|
||||
x: straight.ax + (straight.bx - straight.ax) * 0.15 + offX * metresOff,
|
||||
z: straight.az + (straight.bz - straight.az) * 0.15 + offZ * metresOff,
|
||||
heading: along,
|
||||
speed: 12,
|
||||
hp: 60,
|
||||
path: [straight.b],
|
||||
lastNode: straight.a,
|
||||
expires: 1e6,
|
||||
assigned: null,
|
||||
onStation: 0,
|
||||
cooldown: 1,
|
||||
elevation: 1,
|
||||
};
|
||||
state.units.push(car);
|
||||
|
||||
let turned = 0;
|
||||
let previousHeading = car.heading;
|
||||
// Closest it ever gets to its lane while still driving that leg. Measuring
|
||||
// at the end is useless: it reaches the junction, picks a new destination,
|
||||
// and the original lane stops meaning anything.
|
||||
let closestToLane = Infinity;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: i * 0.1,
|
||||
// On the road, not far away: units well outside SIM_RADIUS of the
|
||||
// player are culled, and a culled car proves nothing.
|
||||
player: { x: (straight.ax + straight.bx) / 2, z: (straight.az + straight.bz) / 2 },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(6),
|
||||
);
|
||||
let delta = car.heading - previousHeading;
|
||||
if (delta > Math.PI) delta -= Math.PI * 2;
|
||||
if (delta < -Math.PI) delta += Math.PI * 2;
|
||||
turned += Math.abs(delta);
|
||||
previousHeading = car.heading;
|
||||
if (car.path[0] === straight.b) {
|
||||
closestToLane = Math.min(
|
||||
closestToLane,
|
||||
Math.abs((car.x - straight.ax) * -Math.cos(along) + (car.z - straight.az) * Math.sin(along)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { turns: turned / (Math.PI * 2), lateral: closestToLane, car };
|
||||
};
|
||||
|
||||
it('gets everybody somewhere, over a long run', () => {
|
||||
/*
|
||||
* The honest measure of the circling bug, and the one that took several
|
||||
* attempts to arrive at. Counting how much cars *turn* is no good: a grid
|
||||
* with a junction every hundred metres legitimately has them turning
|
||||
* constantly. Nor is displacement from start to finish, since a car can
|
||||
* loop the network and come back past where it began.
|
||||
*
|
||||
* How far it ever got from where it started is the one that separates
|
||||
* "driving around town" from "driving around a lamppost".
|
||||
*/
|
||||
const state = createUnits();
|
||||
const start = new Map<number, { x: number; z: number }>();
|
||||
const furthest = new Map<number, number>();
|
||||
const seen = new Map<number, number>();
|
||||
for (let i = 0; i < 900; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: i * 0.1,
|
||||
player: { x: world.spawn.x, z: world.spawn.z },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(19),
|
||||
);
|
||||
for (const u of state.units) {
|
||||
if (u.role !== 'traffic') continue;
|
||||
const from = start.get(u.id) ?? { x: u.x, z: u.z };
|
||||
start.set(u.id, from);
|
||||
furthest.set(
|
||||
u.id,
|
||||
Math.max(furthest.get(u.id) ?? 0, Math.hypot(u.x - from.x, u.z - from.z)),
|
||||
);
|
||||
seen.set(u.id, (seen.get(u.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
// Only cars that were around for most of it: one that spawned in the last
|
||||
// few seconds has had no chance to go anywhere and proves nothing.
|
||||
const settled = [...furthest.entries()]
|
||||
.filter(([id]) => (seen.get(id) ?? 0) > 600)
|
||||
.map(([, d]) => d);
|
||||
expect(settled.length).toBeGreaterThan(15);
|
||||
// Nobody spends a minute and a half orbiting the spot they started on.
|
||||
expect(Math.min(...settled)).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('rejoins the road instead of orbiting it', () => {
|
||||
// Thirty metres wide of its own road: far enough that the aim point used to
|
||||
// be unreachable, so the car circled indefinitely and drifted further out
|
||||
// every lap rather than coming back.
|
||||
// Judged on whether it reached its lane, not on how much it turned: over
|
||||
// thirty seconds it drives well past this junction and on through others,
|
||||
// and turning at those is what driving is.
|
||||
const run = displaced(30);
|
||||
expect(run.lateral).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it('holds its lane when it is already on it', () => {
|
||||
const run = displaced(0);
|
||||
// Starts on the line and never wanders more than a lane's width off it.
|
||||
expect(run.lateral).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
346
src/sim/units.ts
346
src/sim/units.ts
@ -11,7 +11,7 @@
|
||||
*/
|
||||
import type { Rng } from '../core/rng';
|
||||
import type { RoadNetwork, RoadSegment } from './roads';
|
||||
import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads';
|
||||
import { ROAD_SPEED, pointOnSegment, projectOntoSegment, segmentAt } from './roads';
|
||||
import { findRoute, travelTime, type Graph } from './routing';
|
||||
import type { Control, Front } from './regions';
|
||||
import { controlAt, depthAt } from './regions';
|
||||
@ -67,6 +67,10 @@ export interface Unit {
|
||||
chaseSpeed?: number;
|
||||
/** Junction most recently left, so the leg being driven is known. */
|
||||
lastNode?: number;
|
||||
/** Closest this unit has got to its next junction, for spotting a stall. */
|
||||
closest?: number;
|
||||
/** Seconds since it last got any closer to it. */
|
||||
stuckFor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,6 +104,14 @@ export interface UnitState {
|
||||
dispatched: Set<number>;
|
||||
nextId: 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 => ({
|
||||
@ -108,6 +120,7 @@ export const createUnits = (): UnitState => ({
|
||||
dispatched: new Set(),
|
||||
nextId: 1,
|
||||
lastSkirmishAt: -999,
|
||||
alarm: null,
|
||||
});
|
||||
|
||||
// --- Tuning ---------------------------------------------------------------
|
||||
@ -119,6 +132,25 @@ export const PEDESTRIAN_TARGET = 30;
|
||||
export const SIM_RADIUS = 640;
|
||||
/** Civilians keep clear of the shooting. */
|
||||
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 };
|
||||
/**
|
||||
@ -329,8 +361,37 @@ function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment |
|
||||
* road rather than a corridor.
|
||||
*/
|
||||
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_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. */
|
||||
const TURN_RATE = 2.2;
|
||||
/** Gap a driver keeps to whatever is in front, metres. */
|
||||
@ -339,8 +400,23 @@ const FOLLOW_GAP = 9;
|
||||
const FOLLOW_RANGE = 26;
|
||||
/** How wide a lane counts as "in front of me" rather than "beside me". */
|
||||
const FOLLOW_WIDTH = 2.6;
|
||||
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
|
||||
const CAR_SEPARATION = 4.5;
|
||||
/**
|
||||
* Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m.
|
||||
*
|
||||
* Only genuine overlap, not polite spacing — the following distance already
|
||||
* keeps a queue nine metres apart, and this exists for the case that rule
|
||||
* cannot see: two cars crossing at a junction, on different legs, heading
|
||||
* ninety degrees apart.
|
||||
*
|
||||
* It used to be 4.5 and it shoved cars several metres sideways off their own
|
||||
* lane. Pure pursuit then curved them back, and a steady sideways shove against
|
||||
* a steady curve back is a circle — which is exactly what was happening at
|
||||
* intersections. Tight enough now that it separates cars that are actually
|
||||
* inside one another and otherwise leaves the steering alone.
|
||||
*/
|
||||
const CAR_SEPARATION = 3;
|
||||
/** Share of the overlap resolved per step, so it eases apart rather than jumps. */
|
||||
const SEPARATION_EASE = 0.35;
|
||||
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
|
||||
export const CREW_ELEVATION = 1.5;
|
||||
|
||||
@ -367,23 +443,46 @@ function turnToward(from: number, to: number, limit: number): number {
|
||||
* never reaches the road it was sent to, and the whole escalation chain is
|
||||
* built on dispatched units actually arriving.
|
||||
*/
|
||||
function carAhead(state: UnitState, unit: Unit): number | null {
|
||||
const forwardX = Math.sin(unit.heading);
|
||||
const forwardZ = Math.cos(unit.heading);
|
||||
function carAhead(
|
||||
state: UnitState,
|
||||
unit: Unit,
|
||||
player: { x: number; z: number },
|
||||
/** Direction of the lane being driven — *not* the nose. See below. */
|
||||
forwardX: number,
|
||||
forwardZ: number,
|
||||
): number | null {
|
||||
let nearest: number | null = null;
|
||||
|
||||
/** 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) {
|
||||
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
|
||||
if (Math.cos(other.heading - unit.heading) < 0) continue;
|
||||
const dx = other.x - unit.x;
|
||||
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;
|
||||
if (Math.sin(other.heading) * forwardX + Math.cos(other.heading) * forwardZ < 0) continue;
|
||||
const ahead = inTheWay(other.x, other.z);
|
||||
if (ahead !== null && (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;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
@ -392,7 +491,13 @@ function carAhead(state: UnitState, unit: Unit): number | null {
|
||||
* nose round rather than snapping it, and it lifts off for whatever is in
|
||||
* front instead of driving through it.
|
||||
*/
|
||||
function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean {
|
||||
function advance(
|
||||
unit: Unit,
|
||||
roads: RoadNetwork,
|
||||
state: UnitState,
|
||||
player: { x: number; z: number },
|
||||
dt: number,
|
||||
): boolean {
|
||||
const next = unit.path[0];
|
||||
if (next === undefined) return true;
|
||||
|
||||
@ -423,9 +528,20 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
|
||||
dirX /= legLength;
|
||||
dirZ /= legLength;
|
||||
}
|
||||
// Right of the direction of travel.
|
||||
const rightX = dirZ;
|
||||
const rightZ = -dirX;
|
||||
/*
|
||||
* Right of the direction of travel.
|
||||
*
|
||||
* 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 offset = halfWidth * LANE_SHARE;
|
||||
@ -446,27 +562,136 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
|
||||
if (remaining < 3 + offset || beyond > -0.5) {
|
||||
unit.lastNode = next;
|
||||
unit.path.shift();
|
||||
unit.closest = undefined;
|
||||
unit.stuckFor = 0;
|
||||
return unit.path.length === 0;
|
||||
}
|
||||
|
||||
// Pure pursuit: aim at the point on the lane line a fixed distance ahead of
|
||||
// wherever the car currently projects onto it. Short lookahead weaves, long
|
||||
// lookahead never quite arrives.
|
||||
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD;
|
||||
/*
|
||||
* Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD`
|
||||
* metres *from the car*.
|
||||
*
|
||||
* 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 aimZ = laneZ + dirZ * along;
|
||||
|
||||
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
|
||||
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
|
||||
|
||||
// Close on the car in front and lift off. Only civilians defer; anyone with
|
||||
// somewhere to be leans on the horn and keeps going.
|
||||
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
|
||||
/*
|
||||
* Close on whatever is in front and lift off. Only civilians defer; anyone
|
||||
* with somewhere to be leans on the horn and keeps going — and neither does
|
||||
* anybody who has just heard shooting, because a driver getting out of a
|
||||
* firefight is not going to wait behind you.
|
||||
*
|
||||
* "In front" is measured along the *lane*, not along the nose. In a cluster
|
||||
* of stopped cars the noses swing about, so a heading-based cone has every
|
||||
* car intermittently blocked by every other one and none of them can leave.
|
||||
*/
|
||||
const yields =
|
||||
(unit.role === 'traffic' || unit.role === 'convoy') && !panicking(state, unit);
|
||||
const gap = yields ? carAhead(state, unit, player, dirX, dirZ) : null;
|
||||
const allowed =
|
||||
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);
|
||||
|
||||
/*
|
||||
* 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.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;
|
||||
}
|
||||
|
||||
@ -837,6 +1062,13 @@ export interface UnitStep {
|
||||
* were seen. Null when nobody is chasing.
|
||||
*/
|
||||
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
|
||||
* through them, which is the entire reason turning a corner works.
|
||||
@ -932,7 +1164,7 @@ export function stepUnits(
|
||||
switch (unit.role) {
|
||||
case 'traffic':
|
||||
case 'convoy': {
|
||||
if (advance(unit, roads, state, dt)) {
|
||||
if (advance(unit, roads, state, player, dt)) {
|
||||
// Somewhere else to be. A convoy stays on ground its own side holds;
|
||||
// a taxi does not care.
|
||||
const pool =
|
||||
@ -955,7 +1187,26 @@ export function stepUnits(
|
||||
// A slow wander. People are not going anywhere in particular — but they
|
||||
// do go *round* the buildings, and take the turn as their new heading
|
||||
// so they walk along a wall rather than repeatedly into it.
|
||||
//
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
break;
|
||||
}
|
||||
@ -968,7 +1219,7 @@ export function stepUnits(
|
||||
break;
|
||||
}
|
||||
if (unit.path.length > 0) {
|
||||
advance(unit, roads, state, dt);
|
||||
advance(unit, roads, state, player, dt);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1093,23 +1344,50 @@ export function stepUnits(
|
||||
const dx = b.x - a.x;
|
||||
const dz = b.z - a.z;
|
||||
const gap = Math.hypot(dx, dz);
|
||||
if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
|
||||
const push = (CAR_SEPARATION - gap) / 2;
|
||||
const nx = dx / gap;
|
||||
const nz = dz / gap;
|
||||
if (gap >= CAR_SEPARATION) continue;
|
||||
const push = ((CAR_SEPARATION - gap) / 2) * SEPARATION_EASE;
|
||||
/*
|
||||
* Exactly coincident is the one case that has to be handled rather than
|
||||
* skipped. Two cars at the same point have no direction to be pushed
|
||||
* apart along, and skipping them welds the pair together permanently —
|
||||
* they then orbit as a unit, for ever. Any direction will do so long as
|
||||
* it is deterministic; theirs are opposed, so they part.
|
||||
*/
|
||||
const nx = gap < 1e-6 ? 1 : dx / gap;
|
||||
const nz = gap < 1e-6 ? 0 : dz / gap;
|
||||
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
|
||||
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
if (unit.faction !== 'civilian') continue;
|
||||
const danger = state.units.find(
|
||||
// Either somebody visibly holding a rifle, or the sound of one going off.
|
||||
const armed = state.units.find(
|
||||
(other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE,
|
||||
);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,12 @@ export interface HudModel {
|
||||
hunters: number;
|
||||
/** Seconds of staying out of sight before they give up. */
|
||||
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. */
|
||||
danger: number;
|
||||
@ -65,7 +71,14 @@ export interface HudModel {
|
||||
/** Eight-point compass, starting at "straight ahead" and turning left. */
|
||||
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));
|
||||
return ARROWS[((sector % 8) + 8) % 8]!;
|
||||
};
|
||||
@ -77,15 +90,30 @@ const arrowFor = (bearing: number): string => {
|
||||
*/
|
||||
function pursuitLines(pursuit: HudModel['pursuit']): string[] {
|
||||
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 [
|
||||
`HUNTED — ${pursuit.hunters} on you`,
|
||||
`HUNTED — ${pursuit.hunters} on you ${from}`,
|
||||
pursuit.losingIn > 0
|
||||
? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them`
|
||||
: 'they can see you',
|
||||
];
|
||||
}
|
||||
if (pursuit.meter <= 0.02) return ['cover intact'];
|
||||
return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`];
|
||||
if (pursuit.watching === 0 && pursuit.meter <= 0.02) return ['cover intact'];
|
||||
const lines: string[] = [];
|
||||
if (pursuit.watching > 0) {
|
||||
// The half of this the player could never see: somebody has clocked you and
|
||||
// is making their mind up, and there is still time to be somewhere else.
|
||||
lines.push(
|
||||
`${pursuit.watching} watching ${bar(pursuit.attention)}` +
|
||||
(pursuit.attention > 0.75 ? ' — they have you' : ''),
|
||||
);
|
||||
}
|
||||
if (pursuit.meter > 0.02) lines.push(`noticed ${bar(pursuit.meter)}`);
|
||||
return lines.length > 0 ? lines : ['cover intact'];
|
||||
}
|
||||
|
||||
const CONTROL_WORDS: Record<Control, string> = {
|
||||
@ -171,7 +199,7 @@ export function createHud(seed: number, debug = false) {
|
||||
`[debug] seed ${seed}`,
|
||||
]
|
||||
: []),
|
||||
'WASD drive · space handbrake · R respawn',
|
||||
'WASD drive · space handbrake · R respawn · right-drag to look',
|
||||
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
'hold R to reset the campaign',
|
||||
];
|
||||
|
||||
@ -49,6 +49,14 @@ export interface MinimapView {
|
||||
opportunity: { x: number; z: number } | null;
|
||||
/** Elapsed time, for ageing observations. */
|
||||
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) {
|
||||
@ -135,6 +143,32 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu
|
||||
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 ---
|
||||
if (view.opportunity) {
|
||||
ctx.beginPath();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user