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 |
|
| `1`–`3` | accept a mission, when parked at a base |
|
||||||
| `X` | give up the mission in hand, when parked at a base |
|
| `X` | give up the mission in hand, when parked at a base |
|
||||||
| `C` | overhaul the car, when parked at a base |
|
| `C` | overhaul the car, when parked at a base |
|
||||||
|
| right-drag | look around; let go and the view swings back |
|
||||||
| `M` | sound on/off |
|
| `M` | sound on/off |
|
||||||
| hold `R` | wipe the campaign and start over (5 seconds) |
|
| hold `R` | wipe the campaign and start over (5 seconds) |
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,14 @@ export interface DriverInput {
|
|||||||
abandon: boolean;
|
abandon: boolean;
|
||||||
/** True on the frame C was pressed: overhaul the car. Consumed on read. */
|
/** True on the frame C was pressed: overhaul the car. Consumed on read. */
|
||||||
overhaul: boolean;
|
overhaul: boolean;
|
||||||
|
/**
|
||||||
|
* Where the player is looking, relative to straight ahead, in radians.
|
||||||
|
*
|
||||||
|
* `active` is whether they are holding the look button right now. The offsets
|
||||||
|
* survive the release so the camera can ease back rather than snapping, which
|
||||||
|
* is the difference between glancing over your shoulder and being teleported.
|
||||||
|
*/
|
||||||
|
look: { yaw: number; pitch: number; active: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
@ -31,6 +39,14 @@ const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
|||||||
|
|
||||||
export function createInput(): {
|
export function createInput(): {
|
||||||
read(): DriverInput;
|
read(): DriverInput;
|
||||||
|
/**
|
||||||
|
* Where the player is looking, without consuming anything.
|
||||||
|
*
|
||||||
|
* `read` clears the buffered one-shot keys as a side effect, so the render
|
||||||
|
* loop cannot call it just to find out where the camera should point — doing
|
||||||
|
* that swallows whichever keypress happened to land that frame.
|
||||||
|
*/
|
||||||
|
look(): DriverInput['look'];
|
||||||
onGesture(callback: () => void): void;
|
onGesture(callback: () => void): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
} {
|
} {
|
||||||
@ -42,6 +58,18 @@ export function createInput(): {
|
|||||||
let pendingMute = false;
|
let pendingMute = false;
|
||||||
let pendingAbandon = false;
|
let pendingAbandon = false;
|
||||||
let pendingOverhaul = false;
|
let pendingOverhaul = false;
|
||||||
|
/**
|
||||||
|
* Look-around, on a held mouse button and a drag.
|
||||||
|
*
|
||||||
|
* Deliberately not pointer lock. Locking the cursor is the better feel for a
|
||||||
|
* driving game right up until you want to press one of the buttons on the
|
||||||
|
* debug panel or read the quest board, both of which are ordinary DOM sitting
|
||||||
|
* over the canvas — and a game that swallows the cursor to look left is a
|
||||||
|
* game you have to escape out of to use its own interface.
|
||||||
|
*/
|
||||||
|
let lookYaw = 0;
|
||||||
|
let lookPitch = 0;
|
||||||
|
let looking = false;
|
||||||
/** Called on the first real interaction, to satisfy autoplay policy. */
|
/** Called on the first real interaction, to satisfy autoplay policy. */
|
||||||
let onFirstGesture: (() => void) | null = null;
|
let onFirstGesture: (() => void) | null = null;
|
||||||
|
|
||||||
@ -69,11 +97,45 @@ export function createInput(): {
|
|||||||
pendingMute = false;
|
pendingMute = false;
|
||||||
pendingAbandon = false;
|
pendingAbandon = false;
|
||||||
pendingOverhaul = false;
|
pendingOverhaul = false;
|
||||||
|
// Losing the window with the button held would otherwise leave the view
|
||||||
|
// stuck over one shoulder with no way to let go of it.
|
||||||
|
looking = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Radians of view per pixel dragged. */
|
||||||
|
const LOOK_SENSITIVITY = 0.005;
|
||||||
|
/** How far round you can crane your neck. Just past square, not all the way. */
|
||||||
|
const LOOK_YAW_LIMIT = Math.PI * 0.75;
|
||||||
|
const LOOK_PITCH_LIMIT = 0.55;
|
||||||
|
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
// Right or middle button: left stays free for the panels drawn over the top.
|
||||||
|
if (e.button !== 2 && e.button !== 1) return;
|
||||||
|
looking = true;
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
const onMouseUp = (e: MouseEvent) => {
|
||||||
|
if (e.button !== 2 && e.button !== 1) return;
|
||||||
|
looking = false;
|
||||||
|
};
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!looking) return;
|
||||||
|
lookYaw = Math.max(-LOOK_YAW_LIMIT, Math.min(LOOK_YAW_LIMIT, lookYaw - e.movementX * LOOK_SENSITIVITY));
|
||||||
|
lookPitch = Math.max(
|
||||||
|
-LOOK_PITCH_LIMIT,
|
||||||
|
Math.min(LOOK_PITCH_LIMIT, lookPitch - e.movementY * LOOK_SENSITIVITY),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/** Or the drag would open the browser's own menu over the game. */
|
||||||
|
const onContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
|
|
||||||
window.addEventListener('keydown', onDown);
|
window.addEventListener('keydown', onDown);
|
||||||
window.addEventListener('keyup', onUp);
|
window.addEventListener('keyup', onUp);
|
||||||
window.addEventListener('blur', onBlur);
|
window.addEventListener('blur', onBlur);
|
||||||
|
window.addEventListener('mousedown', onMouseDown);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('contextmenu', onContextMenu);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read: () => {
|
read: () => {
|
||||||
@ -94,9 +156,12 @@ export function createInput(): {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
abandon,
|
abandon,
|
||||||
overhaul,
|
overhaul,
|
||||||
|
look: { yaw: lookYaw, pitch: lookPitch, active: looking },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
look: () => ({ yaw: lookYaw, pitch: lookPitch, active: looking }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browsers will not let anything make noise until the user has interacted,
|
* Browsers will not let anything make noise until the user has interacted,
|
||||||
* so the audio context is resumed from here rather than at boot.
|
* so the audio context is resumed from here rather than at boot.
|
||||||
@ -115,6 +180,10 @@ export function createInput(): {
|
|||||||
window.removeEventListener('keydown', onDown);
|
window.removeEventListener('keydown', onDown);
|
||||||
window.removeEventListener('keyup', onUp);
|
window.removeEventListener('keyup', onUp);
|
||||||
window.removeEventListener('blur', onBlur);
|
window.removeEventListener('blur', onBlur);
|
||||||
|
window.removeEventListener('mousedown', onMouseDown);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('contextmenu', onContextMenu);
|
||||||
down.clear();
|
down.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
127
src/main.ts
127
src/main.ts
@ -3,8 +3,10 @@ import { generateWorld } from './sim/world';
|
|||||||
import {
|
import {
|
||||||
applyWear,
|
applyWear,
|
||||||
canOverhaul,
|
canOverhaul,
|
||||||
|
charity,
|
||||||
deriveHandling,
|
deriveHandling,
|
||||||
freshCondition,
|
freshCondition,
|
||||||
|
isDerelict,
|
||||||
overhaul,
|
overhaul,
|
||||||
repair,
|
repair,
|
||||||
surfaceFor,
|
surfaceFor,
|
||||||
@ -33,12 +35,14 @@ import {
|
|||||||
type Offer,
|
type Offer,
|
||||||
} from './sim/quests';
|
} from './sim/quests';
|
||||||
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
|
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
|
||||||
|
import { createGarage, current as drivingNow } from './sim/garage';
|
||||||
import { createRadio, pollRadio } from './sim/radio';
|
import { createRadio, pollRadio } from './sim/radio';
|
||||||
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
||||||
import { createHeatProps } from './heatProps';
|
import { createHeatProps } from './heatProps';
|
||||||
import { createUnits, dispatchTo, stepUnits } from './sim/units';
|
import { createUnits, dispatchTo, stepUnits } from './sim/units';
|
||||||
import { createCombat, dangerNear, stepCombat } from './sim/combat';
|
import { createCombat, dangerNear, stepCombat } from './sim/combat';
|
||||||
import {
|
import {
|
||||||
|
closestAttention,
|
||||||
createPursuit,
|
createPursuit,
|
||||||
PROVOCATION,
|
PROVOCATION,
|
||||||
recognitionMeter,
|
recognitionMeter,
|
||||||
@ -73,6 +77,7 @@ const DEAD_HANDS = {
|
|||||||
toggleMute: false,
|
toggleMute: false,
|
||||||
abandon: false,
|
abandon: false,
|
||||||
overhaul: false,
|
overhaul: false,
|
||||||
|
look: { yaw: 0, pitch: 0, active: false },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
|
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
|
||||||
@ -163,6 +168,16 @@ async function boot() {
|
|||||||
const unitView = createUnitView(view.scene);
|
const unitView = createUnitView(view.scene);
|
||||||
const unitBodies = createUnitBodies(physics);
|
const unitBodies = createUnitBodies(physics);
|
||||||
const pursuit = createPursuit();
|
const pursuit = createPursuit();
|
||||||
|
const garage = createGarage();
|
||||||
|
|
||||||
|
/** Put whatever is in the garage under the player: weight, and paint. */
|
||||||
|
const equip = () => {
|
||||||
|
const spec = drivingNow(garage);
|
||||||
|
physics.setCarMass(spec.mass);
|
||||||
|
view.setCarLook(spec);
|
||||||
|
return spec;
|
||||||
|
};
|
||||||
|
let car = equip();
|
||||||
|
|
||||||
// Bullets stop at buildings, so combat needs a fast "is this inside a wall"
|
// Bullets stop at buildings, so combat needs a fast "is this inside a wall"
|
||||||
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
||||||
@ -254,6 +269,8 @@ async function boot() {
|
|||||||
let provocation = 0;
|
let provocation = 0;
|
||||||
/** Times the car has been written off underneath the player. */
|
/** Times the car has been written off underneath the player. */
|
||||||
let kia = 0;
|
let kia = 0;
|
||||||
|
/** Muzzle flashes from last step, so civilians can react to being shot near. */
|
||||||
|
let lastGunfire: Array<{ x: number; z: number }> = [];
|
||||||
/** Seconds left of watching the wreck, or 0 when alive. */
|
/** Seconds left of watching the wreck, or 0 when alive. */
|
||||||
let dying = 0;
|
let dying = 0;
|
||||||
/** Where the camera has got to in its drift around it. */
|
/** Where the camera has got to in its drift around it. */
|
||||||
@ -404,7 +421,21 @@ async function boot() {
|
|||||||
const finishDeath = () => {
|
const finishDeath = () => {
|
||||||
kia++;
|
kia++;
|
||||||
const at = physics.chassis.translation();
|
const at = physics.chassis.translation();
|
||||||
const home = bases.reduce((best, b) =>
|
/*
|
||||||
|
* The nearest base *your own side holds*, not simply the nearest.
|
||||||
|
*
|
||||||
|
* Bases are placed by spreading them across the map, which was fine when
|
||||||
|
* they were only quest boards — two of the four sit out in frontier ground.
|
||||||
|
* Once one of them became where you wake up after dying, "nearest" meant
|
||||||
|
* that dying deep in enemy country put you deeper still: a fresh dent in
|
||||||
|
* the car, five ambient patrols for company, and a drive home you were in
|
||||||
|
* no state to make. Dying should set you back, not bury you.
|
||||||
|
*/
|
||||||
|
const reachable = bases.filter((b) => {
|
||||||
|
const ground = controlAt(front, b.x, b.z);
|
||||||
|
return ground === 'liberated' || ground === 'contested';
|
||||||
|
});
|
||||||
|
const home = (reachable.length > 0 ? reachable : bases).reduce((best, b) =>
|
||||||
Math.hypot(b.x - at.x, b.z - at.z) < Math.hypot(best.x - at.x, best.z - at.z) ? b : best,
|
Math.hypot(b.x - at.x, b.z - at.z) < Math.hypot(best.x - at.x, best.z - at.z) ? b : best,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -473,7 +504,14 @@ async function boot() {
|
|||||||
|
|
||||||
// Tarmac or open ground — decided last step, since the road lookup needs
|
// Tarmac or open ground — decided last step, since the road lookup needs
|
||||||
// a position and the car has not moved yet this one.
|
// a position and the car has not moved yet this one.
|
||||||
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
|
drive(
|
||||||
|
physics,
|
||||||
|
driveState,
|
||||||
|
cmd,
|
||||||
|
deriveHandling(condition, car),
|
||||||
|
dt,
|
||||||
|
surfaceFor(currentSegment !== null),
|
||||||
|
);
|
||||||
physics.step(dt);
|
physics.step(dt);
|
||||||
captureChassis();
|
captureChassis();
|
||||||
|
|
||||||
@ -486,12 +524,12 @@ async function boot() {
|
|||||||
|
|
||||||
const lastImpact = physics.drainImpactForce();
|
const lastImpact = physics.drainImpactForce();
|
||||||
audio.impact(lastImpact);
|
audio.impact(lastImpact);
|
||||||
condition = applyWear(condition, {
|
condition = applyWear(
|
||||||
dt,
|
condition,
|
||||||
distance,
|
{ dt, distance, throttle: Math.abs(cmd.throttle), impactForce: lastImpact },
|
||||||
throttle: Math.abs(cmd.throttle),
|
// Armour is this number: what the car actually feels of a knock.
|
||||||
impactForce: lastImpact,
|
car.fragility,
|
||||||
});
|
);
|
||||||
|
|
||||||
// Heat: the road remembers being used — including being driven alongside.
|
// Heat: the road remembers being used — including being driven alongside.
|
||||||
// Behind your own lines it remembers nothing: nobody there is watching.
|
// Behind your own lines it remembers nothing: nobody there is watching.
|
||||||
@ -550,6 +588,18 @@ async function boot() {
|
|||||||
const base = dead ? null : baseAt(bases, at.x, at.z);
|
const base = dead ? null : baseAt(bases, at.x, at.z);
|
||||||
const stopped = Math.abs(speed) < 2.5;
|
const stopped = Math.abs(speed) < 2.5;
|
||||||
|
|
||||||
|
// --- The bottom of the barrel ---
|
||||||
|
// Nobody is left sitting in a wreck with an empty pocket. This is the one
|
||||||
|
// thing in the loop that pushes back: everything else makes a bad car
|
||||||
|
// likelier to get worse. It restores nothing permanent — the ceiling is
|
||||||
|
// untouched — so the decline still stands and only the dead end goes.
|
||||||
|
if (base && stopped && isDerelict(condition) && quests.funds < 20) {
|
||||||
|
condition = charity(condition);
|
||||||
|
say('They will not watch you push it. Patched up, and you owe them.', 7);
|
||||||
|
audio.ui('accept');
|
||||||
|
persistence.checkpoint(elapsed, snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
// --- Overhaul: buying back what the car is capable of ---
|
// --- Overhaul: buying back what the car is capable of ---
|
||||||
// Offered whether or not there is a job in hand, since a workshop does
|
// Offered whether or not there is a job in hand, since a workshop does
|
||||||
// not care what you are carrying. Deliberately a bad rate next to an
|
// not care what you are carrying. Deliberately a bad rate next to an
|
||||||
@ -758,6 +808,9 @@ async function boot() {
|
|||||||
// is already blocked by them; movement has to be too, or "break line
|
// is already blocked by them; movement has to be too, or "break line
|
||||||
// of sight and change direction" is advice the world does not honour.
|
// of sight and change direction" is advice the world does not honour.
|
||||||
blocked: insideBuilding,
|
blocked: insideBuilding,
|
||||||
|
// Shooting is resolved further down, so this is last step's. People
|
||||||
|
// hear it and then move, which is the right way round anyway.
|
||||||
|
gunfire: lastGunfire,
|
||||||
},
|
},
|
||||||
model.roads,
|
model.roads,
|
||||||
graph,
|
graph,
|
||||||
@ -824,6 +877,21 @@ async function boot() {
|
|||||||
units,
|
units,
|
||||||
canSee,
|
canSee,
|
||||||
provocation,
|
provocation,
|
||||||
|
/*
|
||||||
|
* How wary this place is: the heat on the road being used, or on
|
||||||
|
* the district when off it.
|
||||||
|
*
|
||||||
|
* This is what finally makes heat a *continuous* pressure rather
|
||||||
|
* than something that only exists at the thresholds where concrete
|
||||||
|
* appears. Use one route all week and the people on it start
|
||||||
|
* expecting you; take a cold road and a car going past is a car
|
||||||
|
* going past.
|
||||||
|
*/
|
||||||
|
wariness:
|
||||||
|
currentSegment === null
|
||||||
|
? (heat.area[areaAt(areas, at.x, at.z) ?? 0] ?? 0)
|
||||||
|
: effectiveHeat(heat, areas, currentSegment),
|
||||||
|
presence: car.presence,
|
||||||
});
|
});
|
||||||
for (const event of noticed) {
|
for (const event of noticed) {
|
||||||
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
|
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
|
||||||
@ -855,10 +923,12 @@ async function boot() {
|
|||||||
},
|
},
|
||||||
condition,
|
condition,
|
||||||
chatterRng,
|
chatterRng,
|
||||||
|
car.fragility,
|
||||||
);
|
);
|
||||||
condition = shooting.condition;
|
condition = shooting.condition;
|
||||||
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
||||||
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
||||||
|
lastGunfire = shooting.fired;
|
||||||
if (shooting.playerHit) {
|
if (shooting.playerHit) {
|
||||||
provocation += PROVOCATION.shotAt;
|
provocation += PROVOCATION.shotAt;
|
||||||
audio.hit();
|
audio.hit();
|
||||||
@ -913,6 +983,8 @@ async function boot() {
|
|||||||
// anywhere the player needs to be — the bases' own beacons take over.
|
// anywhere the player needs to be — the bases' own beacons take over.
|
||||||
const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null;
|
const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null;
|
||||||
markers.setObjective(heading ? nodeOf(heading.targetNode) : null);
|
markers.setObjective(heading ? nodeOf(heading.targetNode) : null);
|
||||||
|
// The minimap has always drawn field contacts; now the world does too.
|
||||||
|
markers.setOpportunity(opportunities.current);
|
||||||
},
|
},
|
||||||
|
|
||||||
render(alpha, frameDt) {
|
render(alpha, frameDt) {
|
||||||
@ -945,7 +1017,7 @@ async function boot() {
|
|||||||
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
||||||
}
|
}
|
||||||
|
|
||||||
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
|
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z }, pursuit.eyes);
|
||||||
view.followSun();
|
view.followSun();
|
||||||
view.setTone(control, frameDt);
|
view.setTone(control, frameDt);
|
||||||
markers.update(elapsed);
|
markers.update(elapsed);
|
||||||
@ -954,6 +1026,10 @@ async function boot() {
|
|||||||
frameDt,
|
frameDt,
|
||||||
physics.vehicle.currentVehicleSpeed(),
|
physics.vehicle.currentVehicleSpeed(),
|
||||||
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
|
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
|
||||||
|
// Read at frame rate rather than from the fixed step: this is the one
|
||||||
|
// input where lag is felt directly, as the view dragging behind the
|
||||||
|
// mouse. Deliberately not `read()`, which consumes the buffered keys.
|
||||||
|
input.look(),
|
||||||
);
|
);
|
||||||
view.renderer.render(view.scene, view.camera);
|
view.renderer.render(view.scene, view.camera);
|
||||||
|
|
||||||
@ -972,6 +1048,16 @@ async function boot() {
|
|||||||
heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
|
heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
|
||||||
objective: quest && quest.stage !== 'return' ? target : null,
|
objective: quest && quest.stage !== 'return' ? target : null,
|
||||||
opportunity: opportunities.current,
|
opportunity: opportunities.current,
|
||||||
|
// Live positions, not remembered ones: these are people currently
|
||||||
|
// looking out of a window at you.
|
||||||
|
watchers: units.units
|
||||||
|
.filter((u) => pursuit.eyes.has(u.id) || u.hunting)
|
||||||
|
.map((u) => ({
|
||||||
|
x: u.x,
|
||||||
|
z: u.z,
|
||||||
|
settled: pursuit.eyes.get(u.id) ?? 1,
|
||||||
|
hunting: u.hunting === true,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
||||||
@ -1003,6 +1089,15 @@ async function boot() {
|
|||||||
hunters: pursuit.hunters.size,
|
hunters: pursuit.hunters.size,
|
||||||
// Counts down only once nobody has eyes on you.
|
// Counts down only once nobody has eyes on you.
|
||||||
losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0,
|
losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0,
|
||||||
|
attention: closestAttention(pursuit),
|
||||||
|
watching: pursuit.eyes.size,
|
||||||
|
bearings: units.units
|
||||||
|
.filter((u) => pursuit.hunters.has(u.id))
|
||||||
|
.map(
|
||||||
|
(u) =>
|
||||||
|
Math.atan2(u.x - p.x, u.z - p.z) -
|
||||||
|
Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
danger: dangerNear(combat, p.x, p.z, 90),
|
danger: dangerNear(combat, p.x, p.z, 90),
|
||||||
completed: quests.completed,
|
completed: quests.completed,
|
||||||
@ -1024,7 +1119,19 @@ async function boot() {
|
|||||||
view,
|
view,
|
||||||
physics,
|
physics,
|
||||||
// Live state, so a script can find something interesting and go look at it.
|
// Live state, so a script can find something interesting and go look at it.
|
||||||
state: { units, combat, heat, areas, intel, quests, front, model, bases, pursuit },
|
state: {
|
||||||
|
units,
|
||||||
|
combat,
|
||||||
|
heat,
|
||||||
|
areas,
|
||||||
|
intel,
|
||||||
|
quests,
|
||||||
|
front,
|
||||||
|
model,
|
||||||
|
bases,
|
||||||
|
pursuit,
|
||||||
|
opportunities,
|
||||||
|
},
|
||||||
condition: () => condition,
|
condition: () => condition,
|
||||||
/**
|
/**
|
||||||
* Force the car's condition, for looking at how a wrecked car drives
|
* Force the car's condition, for looking at how a wrecked car drives
|
||||||
|
|||||||
@ -22,6 +22,7 @@ const IDLE: DriverInput = {
|
|||||||
toggleMute: false,
|
toggleMute: false,
|
||||||
abandon: false,
|
abandon: false,
|
||||||
overhaul: false,
|
overhaul: false,
|
||||||
|
look: { yaw: 0, pitch: 0, active: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||||
|
|||||||
@ -20,6 +20,17 @@ export interface PhysicsWorld {
|
|||||||
telemetry(): Telemetry;
|
telemetry(): Telemetry;
|
||||||
/** A body the sim moves by hand, which still collides with the player. */
|
/** A body the sim moves by hand, which still collides with the player. */
|
||||||
addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
|
addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
|
||||||
|
/**
|
||||||
|
* Swap what the player is driving.
|
||||||
|
*
|
||||||
|
* Only the mass properties change, not the collider: a tractor and an
|
||||||
|
* armoured car occupy visibly different boxes on screen, but giving them
|
||||||
|
* different colliders means rebuilding the vehicle controller mid-campaign,
|
||||||
|
* and every barricade gap and building alley in the world is sized against
|
||||||
|
* one car. Mass is what actually changes how the thing drives — what it
|
||||||
|
* shrugs off, how long it takes to stop — so that is what moves.
|
||||||
|
*/
|
||||||
|
setCarMass(mass: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KinematicBox {
|
export interface KinematicBox {
|
||||||
@ -157,6 +168,22 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
|||||||
return v;
|
return v;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCarMass(mass: number) {
|
||||||
|
chassis.setAdditionalMassProperties(
|
||||||
|
mass,
|
||||||
|
{ x: 0, y: -0.35, z: 0 },
|
||||||
|
// Scaled from the reference car's tensor, so a heavier vehicle also
|
||||||
|
// resists being spun round rather than merely being harder to shift.
|
||||||
|
{
|
||||||
|
x: 1369 * (mass / CAR.mass),
|
||||||
|
y: 1621 * (mass / CAR.mass),
|
||||||
|
z: 342 * (mass / CAR.mass),
|
||||||
|
},
|
||||||
|
{ x: 0, y: 0, z: 0, w: 1 },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
addKinematicBox(box) {
|
addKinematicBox(box) {
|
||||||
// Kinematic rather than dynamic: the unit sim owns where these are, but
|
// Kinematic rather than dynamic: the unit sim owns where these are, but
|
||||||
// they still shove the player's car when they meet it.
|
// they still shove the player's car when they meet it.
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { Base } from '../sim/bases';
|
import type { Base } from '../sim/bases';
|
||||||
|
import { OPPORTUNITY_RADIUS, type Opportunity } from '../sim/opportunities';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bases and the active objective, as things you can see from a distance.
|
* Bases and the active objective, as things you can see from a distance.
|
||||||
* The player navigates by looking, so both need to be visible over scenery.
|
* The player navigates by looking, so both need to be visible over scenery.
|
||||||
*/
|
*/
|
||||||
const BEACON_HEIGHT = 34;
|
const BEACON_HEIGHT = 34;
|
||||||
|
/**
|
||||||
|
* How tall a field contact's marker stands.
|
||||||
|
*
|
||||||
|
* Deliberately a fraction of a base beacon. These are meant to be work you
|
||||||
|
* *find*, not work you plan — you cannot compare one against anything or route
|
||||||
|
* to it, you take the detour in front of you or you drive on. A pillar visible
|
||||||
|
* across the map would turn them into errands. At this height it clears a car
|
||||||
|
* and reads a street away, and no further.
|
||||||
|
*/
|
||||||
|
const CONTACT_HEIGHT = 7;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A slim pillar plus a ring on the ground.
|
* A slim pillar plus a ring on the ground.
|
||||||
@ -15,7 +26,7 @@ const BEACON_HEIGHT = 34;
|
|||||||
* middle of one, that is exactly where you spend your time. Narrow enough to see
|
* middle of one, that is exactly where you spend your time. Narrow enough to see
|
||||||
* past, tall enough to see over buildings.
|
* past, tall enough to see over buildings.
|
||||||
*/
|
*/
|
||||||
function beacon(colour: number, radius: number): THREE.Group {
|
function beacon(colour: number, radius: number, height = BEACON_HEIGHT): THREE.Group {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
const material = new THREE.MeshBasicMaterial({
|
const material = new THREE.MeshBasicMaterial({
|
||||||
color: colour,
|
color: colour,
|
||||||
@ -28,10 +39,10 @@ function beacon(colour: number, radius: number): THREE.Group {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pillar = new THREE.Mesh(
|
const pillar = new THREE.Mesh(
|
||||||
new THREE.CylinderGeometry(0.9, 0.9, BEACON_HEIGHT, 8, 1, true),
|
new THREE.CylinderGeometry(0.9, 0.9, height, 8, 1, true),
|
||||||
material,
|
material,
|
||||||
);
|
);
|
||||||
pillar.position.y = BEACON_HEIGHT / 2;
|
pillar.position.y = height / 2;
|
||||||
group.add(pillar);
|
group.add(pillar);
|
||||||
|
|
||||||
// The ring is what tells you where to actually stop.
|
// The ring is what tells you where to actually stop.
|
||||||
@ -68,7 +79,54 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
|
|||||||
objective.visible = false;
|
objective.visible = false;
|
||||||
scene.add(objective);
|
scene.add(objective);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A field contact, as something actually standing in the road.
|
||||||
|
*
|
||||||
|
* The minimap has always drawn these as a pink dot and the world had nothing
|
||||||
|
* there at all, so the one kind of work you are supposed to find by looking
|
||||||
|
* was the one kind you could only find by reading the map — and driving to
|
||||||
|
* the dot put you in an empty street.
|
||||||
|
*
|
||||||
|
* So there is a marker, and underneath it the thing itself: somebody standing
|
||||||
|
* at the roadside, or a burnt-out car worth stripping. The prop is what you
|
||||||
|
* actually recognise; the marker is so you can tell it from the scenery at
|
||||||
|
* the speed you are going.
|
||||||
|
*/
|
||||||
|
const contact = beacon(0xcf6bd6, OPPORTUNITY_RADIUS, CONTACT_HEIGHT);
|
||||||
|
const person = new THREE.Mesh(
|
||||||
|
new THREE.CapsuleGeometry(0.45, 1.4, 4, 8),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xd8c9a4, roughness: 0.8 }),
|
||||||
|
);
|
||||||
|
person.position.y = 1.05;
|
||||||
|
person.castShadow = true;
|
||||||
|
contact.add(person);
|
||||||
|
|
||||||
|
const wreck = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(1.9, 1.3, 4.2),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x2b2521, roughness: 1 }),
|
||||||
|
);
|
||||||
|
wreck.position.y = 0.6;
|
||||||
|
// Slewed and nose-down, so it reads as abandoned rather than parked.
|
||||||
|
wreck.rotation.set(0.06, 0.7, 0.11);
|
||||||
|
wreck.castShadow = true;
|
||||||
|
contact.add(wreck);
|
||||||
|
|
||||||
|
contact.visible = false;
|
||||||
|
scene.add(contact);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Show whoever is waiting, or nobody. A wreck is a wreck; everything else
|
||||||
|
* is a person standing where a person would not normally stand.
|
||||||
|
*/
|
||||||
|
setOpportunity(current: Opportunity | null) {
|
||||||
|
contact.visible = current !== null;
|
||||||
|
if (!current) return;
|
||||||
|
contact.position.set(current.x, 0, current.z);
|
||||||
|
wreck.visible = current.kind === 'wreck';
|
||||||
|
person.visible = current.kind !== 'wreck';
|
||||||
|
},
|
||||||
|
|
||||||
/** Point the objective beacon at a target, or hide it when idle. */
|
/** Point the objective beacon at a target, or hide it when idle. */
|
||||||
setObjective(target: { x: number; z: number } | null) {
|
setObjective(target: { x: number; z: number } | null) {
|
||||||
objective.visible = target !== null;
|
objective.visible = target !== null;
|
||||||
@ -77,6 +135,7 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
|
|||||||
/** Slow spin, so a beacon reads as a marker rather than scenery. */
|
/** Slow spin, so a beacon reads as a marker rather than scenery. */
|
||||||
update(elapsed: number) {
|
update(elapsed: number) {
|
||||||
objective.rotation.y = elapsed * 0.6;
|
objective.rotation.y = elapsed * 0.6;
|
||||||
|
contact.rotation.y = elapsed * 0.45;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export interface SceneView {
|
|||||||
wheels: THREE.Object3D[];
|
wheels: THREE.Object3D[];
|
||||||
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */
|
/** Only the dynamic crates need per-frame syncing; blocks are instanced. */
|
||||||
crates: Array<{ index: number; mesh: THREE.Mesh }>;
|
crates: Array<{ index: number; mesh: THREE.Mesh }>;
|
||||||
|
/** Dress the car as whatever is being driven: shape and paint. */
|
||||||
|
setCarLook(spec: { colour: number; size: { width: number; height: number; length: number } }): void;
|
||||||
/** Keeps the shadow frustum centred on the car. */
|
/** Keeps the shadow frustum centred on the car. */
|
||||||
followSun(): void;
|
followSun(): void;
|
||||||
/** Eases the world's colour and visibility toward the current territory. */
|
/** Eases the world's colour and visibility toward the current territory. */
|
||||||
@ -198,9 +200,14 @@ export function createScene(model: WorldModel): SceneView {
|
|||||||
|
|
||||||
// --- Car ---
|
// --- Car ---
|
||||||
const car = new THREE.Group();
|
const car = new THREE.Group();
|
||||||
|
const bodyMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x8c3b34,
|
||||||
|
roughness: 0.55,
|
||||||
|
metalness: 0.15,
|
||||||
|
});
|
||||||
const body = new THREE.Mesh(
|
const body = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
|
new THREE.BoxGeometry(CAR.halfWidth * 2, CAR.halfHeight * 2, CAR.halfLength * 2),
|
||||||
new THREE.MeshStandardMaterial({ color: 0x8c3b34, roughness: 0.55, metalness: 0.15 }),
|
bodyMat,
|
||||||
);
|
);
|
||||||
body.castShadow = true;
|
body.castShadow = true;
|
||||||
car.add(body);
|
car.add(body);
|
||||||
@ -251,6 +258,16 @@ export function createScene(model: WorldModel): SceneView {
|
|||||||
car,
|
car,
|
||||||
wheels,
|
wheels,
|
||||||
crates,
|
crates,
|
||||||
|
setCarLook(spec) {
|
||||||
|
// The mesh changes shape; the collider deliberately does not — see
|
||||||
|
// `setCarMass`. A tractor should *look* like a tractor from the mirror
|
||||||
|
// without every barricade gap in the world having to be re-sized.
|
||||||
|
bodyMat.color.setHex(spec.colour);
|
||||||
|
body.scale.set(spec.size.width, spec.size.height, spec.size.length);
|
||||||
|
cabin.position.y = CAR.halfHeight * spec.size.height + 0.25;
|
||||||
|
cabin.scale.set(spec.size.width, spec.size.height, spec.size.length);
|
||||||
|
},
|
||||||
|
|
||||||
followSun() {
|
followSun() {
|
||||||
// Keep the shadow frustum centred on the car rather than the origin.
|
// Keep the shadow frustum centred on the car rather than the origin.
|
||||||
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
|
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
|
||||||
@ -285,6 +302,19 @@ const toneColour = new THREE.Color();
|
|||||||
const camTarget = new THREE.Vector3();
|
const camTarget = new THREE.Vector3();
|
||||||
const camDesired = new THREE.Vector3();
|
const camDesired = new THREE.Vector3();
|
||||||
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);
|
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);
|
||||||
|
const UP = new THREE.Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the camera is currently looking relative to straight ahead, eased.
|
||||||
|
*
|
||||||
|
* Held here rather than in the input layer because it is a property of the
|
||||||
|
* *camera*, not of what the player is doing with the mouse: they let go of the
|
||||||
|
* button and the view swings back over a moment, the way you turn your head
|
||||||
|
* back to the road rather than being snapped to it.
|
||||||
|
*/
|
||||||
|
const look = { yaw: 0, pitch: 0 };
|
||||||
|
/** How high the camera rises across the full pitch range, metres. */
|
||||||
|
const LOOK_LIFT = 4.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the camera starts and ends up while pulling off a wreck.
|
* Where the camera starts and ends up while pulling off a wreck.
|
||||||
@ -314,6 +344,7 @@ export function updateCamera(
|
|||||||
dt: number,
|
dt: number,
|
||||||
speed: number,
|
speed: number,
|
||||||
wake: { angle: number; progress: number } | null = null,
|
wake: { angle: number; progress: number } | null = null,
|
||||||
|
looking: { yaw: number; pitch: number; active: boolean } = { yaw: 0, pitch: 0, active: false },
|
||||||
): void {
|
): void {
|
||||||
const { camera, car } = view;
|
const { camera, car } = view;
|
||||||
|
|
||||||
@ -332,14 +363,26 @@ export function updateCamera(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ease toward where they are looking, or back to the road once they let go.
|
||||||
|
const wantYaw = looking.active ? looking.yaw : 0;
|
||||||
|
const wantPitch = looking.active ? looking.pitch : 0;
|
||||||
|
const settle = 1 - Math.exp(-(looking.active ? 14 : 5) * dt);
|
||||||
|
look.yaw += (wantYaw - look.yaw) * settle;
|
||||||
|
look.pitch += (wantPitch - look.pitch) * settle;
|
||||||
|
|
||||||
camDesired.copy(CHASE_OFFSET);
|
camDesired.copy(CHASE_OFFSET);
|
||||||
// Pull back a little at speed for a sense of pace.
|
// Pull back a little at speed for a sense of pace.
|
||||||
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);
|
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);
|
||||||
|
// Swing round the car rather than turning on the spot, so the car stays in
|
||||||
|
// frame and you can see what you are about to drive into while looking away.
|
||||||
|
camDesired.applyAxisAngle(UP, look.yaw);
|
||||||
|
camDesired.y += look.pitch * LOOK_LIFT;
|
||||||
camDesired.applyQuaternion(car.quaternion).add(car.position);
|
camDesired.applyQuaternion(car.quaternion).add(car.position);
|
||||||
|
|
||||||
const lerp = 1 - Math.exp(-6 * dt);
|
const lerp = 1 - Math.exp(-6 * dt);
|
||||||
camera.position.lerp(camDesired, lerp);
|
camera.position.lerp(camDesired, lerp);
|
||||||
|
|
||||||
camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position);
|
camTarget.set(0, 1.2, 4).applyAxisAngle(UP, look.yaw).applyQuaternion(car.quaternion);
|
||||||
|
camTarget.add(car.position);
|
||||||
camera.lookAt(camTarget);
|
camera.lookAt(camTarget);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,15 @@ function colourOf(unit: Unit): number {
|
|||||||
return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier;
|
return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colours for the marker over somebody who is looking at you: a glance is
|
||||||
|
* amber and barely there, certainty is red and unmistakable.
|
||||||
|
*/
|
||||||
|
const GLANCE = new THREE.Color(0xe0b040);
|
||||||
|
const CERTAIN = new THREE.Color(0xff3a2a);
|
||||||
|
/** How high above a unit its attention marker floats. */
|
||||||
|
const EYE_HEIGHT = 3.1;
|
||||||
|
|
||||||
export function createUnitView(scene: THREE.Scene) {
|
export function createUnitView(scene: THREE.Scene) {
|
||||||
const scratch = new THREE.Matrix4();
|
const scratch = new THREE.Matrix4();
|
||||||
const quaternion = new THREE.Quaternion();
|
const quaternion = new THREE.Quaternion();
|
||||||
@ -114,6 +123,23 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
MAX_UNITS,
|
MAX_UNITS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A diamond over anybody who has their eye on you, growing and reddening as
|
||||||
|
* they settle on it.
|
||||||
|
*
|
||||||
|
* Cover used to be a single bar in the corner that filled up with no
|
||||||
|
* indication of who was filling it, so being recognised arrived from nowhere.
|
||||||
|
* The decision the player is being asked to make — keep going or get out of
|
||||||
|
* sight — needs a *direction* and a rate, and both of those live out in the
|
||||||
|
* world rather than in the HUD.
|
||||||
|
*/
|
||||||
|
const eyes = new THREE.InstancedMesh(
|
||||||
|
new THREE.OctahedronGeometry(0.55),
|
||||||
|
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.9 }),
|
||||||
|
MAX_UNITS,
|
||||||
|
);
|
||||||
|
eyes.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
|
||||||
|
|
||||||
// Towers, which only exist once someone has built them.
|
// Towers, which only exist once someone has built them.
|
||||||
const towers = new THREE.InstancedMesh(
|
const towers = new THREE.InstancedMesh(
|
||||||
new THREE.BoxGeometry(3, 6, 3),
|
new THREE.BoxGeometry(3, 6, 3),
|
||||||
@ -128,7 +154,7 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
32,
|
32,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) {
|
for (const mesh of [cars, people, crew, guns, eyes, tracers, towers, towerGuns]) {
|
||||||
mesh.frustumCulled = false;
|
mesh.frustumCulled = false;
|
||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
}
|
}
|
||||||
@ -139,7 +165,13 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
|
update(
|
||||||
|
units: UnitState,
|
||||||
|
rounds: Round[],
|
||||||
|
elapsed: number,
|
||||||
|
player: { x: number; z: number },
|
||||||
|
watching: Map<number, number>,
|
||||||
|
) {
|
||||||
let carCount = 0;
|
let carCount = 0;
|
||||||
let personCount = 0;
|
let personCount = 0;
|
||||||
let crewCount = 0;
|
let crewCount = 0;
|
||||||
@ -200,6 +232,25 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
crewCount++;
|
crewCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Whoever currently has eyes on you ---
|
||||||
|
let eyeCount = 0;
|
||||||
|
for (const unit of units.units) {
|
||||||
|
const settled = watching.get(unit.id);
|
||||||
|
if (settled === undefined || eyeCount >= MAX_UNITS) continue;
|
||||||
|
// Grows and reddens as they make their mind up, and bobs so it reads as
|
||||||
|
// a marker rather than as something built on the roof.
|
||||||
|
const size = 0.5 + settled * 0.85;
|
||||||
|
position.set(unit.x, EYE_HEIGHT + Math.sin(elapsed * 3 + unit.id) * 0.12, unit.z);
|
||||||
|
quaternion.setFromAxisAngle(up, elapsed * 1.6);
|
||||||
|
scratch.compose(position, quaternion, scale.set(size, size, size));
|
||||||
|
eyes.setMatrixAt(eyeCount, scratch);
|
||||||
|
eyes.setColorAt(eyeCount, colour.copy(GLANCE).lerp(CERTAIN, settled));
|
||||||
|
eyeCount++;
|
||||||
|
}
|
||||||
|
park(eyes, eyeCount);
|
||||||
|
eyes.instanceMatrix.needsUpdate = true;
|
||||||
|
if (eyes.instanceColor) eyes.instanceColor.needsUpdate = true;
|
||||||
|
|
||||||
park(cars, carCount);
|
park(cars, carCount);
|
||||||
park(people, personCount);
|
park(people, personCount);
|
||||||
park(crew, crewCount);
|
park(crew, crewCount);
|
||||||
|
|||||||
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.
|
* what keeps it meaningful is the exchange rate rather than the impossibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { carById, type CarSpec } from './garage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car every balance figure in this file is quoted against.
|
||||||
|
*
|
||||||
|
* The estate is the ordinary one — an unremarkable family car in the middle of
|
||||||
|
* the catalogue — so "a crash costs 1.4 points of condition" means something
|
||||||
|
* concrete rather than depending on what happens to be in the garage.
|
||||||
|
*/
|
||||||
|
const REFERENCE_CAR: CarSpec = carById('estate');
|
||||||
|
|
||||||
export interface Subsystems {
|
export interface Subsystems {
|
||||||
/** 1 = factory fresh, 0 = ruined. */
|
/** 1 = factory fresh, 0 = ruined. */
|
||||||
engine: number;
|
engine: number;
|
||||||
@ -50,8 +61,15 @@ export interface Handling {
|
|||||||
* the units it is actually about, and keeps the scale of the currency a purely
|
* the units it is actually about, and keeps the scale of the currency a purely
|
||||||
* presentational decision — ¤34 for a supply run reads as a payment, where 0.34
|
* presentational decision — ¤34 for a supply run reads as a payment, where 0.34
|
||||||
* read as a fraction of something unnamed.
|
* read as a fraction of something unnamed.
|
||||||
|
*
|
||||||
|
* Lowered from 100 once crash damage went up fivefold. A reference head-on
|
||||||
|
* costs 1.4 points of condition; at the old rate that was ¤140 to put right,
|
||||||
|
* four missions' pay, for one bad corner — in a car that was by then too slow
|
||||||
|
* to do those missions well. The *permanent* share is meant to be what a crash
|
||||||
|
* costs you. Being unable to afford to get back on the road at all is not a
|
||||||
|
* consequence, it is a dead end.
|
||||||
*/
|
*/
|
||||||
export const FUNDS_PER_CONDITION = 100;
|
export const FUNDS_PER_CONDITION = 40;
|
||||||
|
|
||||||
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
||||||
|
|
||||||
@ -63,26 +81,63 @@ export const freshCondition = (): CarCondition => ({
|
|||||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
export function deriveHandling(c: CarCondition): Handling {
|
/**
|
||||||
|
* Share of a car's figures still available when that subsystem is ruined.
|
||||||
|
*
|
||||||
|
* Condition scales what the car can do rather than replacing it, so a wrecked
|
||||||
|
* tractor is still a tractor and a wrecked armoured car is still heavy. Not
|
||||||
|
* zero: a car that stops entirely at 0% is a fail state wearing a dial, and the
|
||||||
|
* point of the garage is that being in a bad car is a situation rather than an
|
||||||
|
* ending.
|
||||||
|
*/
|
||||||
|
const RUINED = { drive: 0.35, brake: 0.33, steer: 0.65, grip: 0.35 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the car can do, from what it is and what state it is in.
|
||||||
|
*
|
||||||
|
* Both halves matter and they are different kinds of thing: the spec is the
|
||||||
|
* car you chose at the garage, the condition is what this sortie has done to
|
||||||
|
* it. Everything here is the spec's own figure scaled by wear, so the
|
||||||
|
* catalogue's numbers mean what they say.
|
||||||
|
*/
|
||||||
|
export function deriveHandling(c: CarCondition, spec: CarSpec = REFERENCE_CAR): Handling {
|
||||||
const { engine, tires, chassis } = c.level;
|
const { engine, tires, chassis } = c.level;
|
||||||
return {
|
return {
|
||||||
// A tired engine simply cannot push as hard.
|
// A tired engine simply cannot push as hard.
|
||||||
engineForce: lerp(900, 2600, engine),
|
engineForce: lerp(spec.engineForce * RUINED.drive, spec.engineForce, engine),
|
||||||
// Worn pads take longer to haul the car down. These are Rapier brake
|
// Worn pads take longer to haul the car down. These are Rapier brake
|
||||||
// impulses, which have to be large next to a 1100kg chassis — the first
|
// impulses, which have to be large next to a 1100kg chassis — the first
|
||||||
// values here were so weak the car would not come to a standstill at all.
|
// values here were so weak the car would not come to a standstill at all.
|
||||||
// Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and
|
// Tuned to about 0.8g fresh: roughly 25m from 70km/h. Much past this and
|
||||||
// the brake pedal stops feeling like a brake and starts feeling like a wall.
|
// the brake pedal stops feeling like a brake and starts feeling like a wall.
|
||||||
brakeForce: lerp(12, 36, tires),
|
brakeForce: lerp(spec.brakeForce * RUINED.brake, spec.brakeForce, tires),
|
||||||
// Lifting off has to actually slow you down. Without this the car coasts
|
// Lifting off has to actually slow you down. Without this the car coasts
|
||||||
// almost forever and every stop needs a deliberate stab at the brake.
|
// almost forever and every stop needs a deliberate stab at the brake.
|
||||||
coastBrake: lerp(4, 9, engine),
|
coastBrake: lerp(4, 9, engine),
|
||||||
maxSteer: lerp(0.4, 0.62, chassis),
|
maxSteer: lerp(spec.maxSteer * RUINED.steer, spec.maxSteer, chassis),
|
||||||
// Bald tyres are the most legible failure: the back end starts to leave.
|
// Bald tyres are the most legible failure: the back end starts to leave.
|
||||||
frictionSlip: lerp(1.6, 5, tires),
|
frictionSlip: lerp(spec.grip * RUINED.grip, spec.grip, tires),
|
||||||
sideFrictionStiffness: lerp(0.5, 1, tires),
|
sideFrictionStiffness: lerp(0.5, 1, tires),
|
||||||
// A bent chassis pulls to one side. Sign is stable for a given car.
|
/*
|
||||||
steeringPull: (1 - chassis) * 0.06,
|
* A bent chassis pulls to one side. Sign is stable for a given car.
|
||||||
|
*
|
||||||
|
* Linear at 0.06 this was the single most destructive number in the game.
|
||||||
|
* It is an *uncommanded steering input that never lets go*: at 90% chassis
|
||||||
|
* it walked the car off a fifteen-metre trunk, driving dead straight, in
|
||||||
|
* under seven seconds. That is the swerve, and it was also most of the
|
||||||
|
* speed loss, because off the tarmac drive force drops to 55% — measured
|
||||||
|
* top speed fell 108 to 86 to 75 km/h across the first fifth of the car's
|
||||||
|
* life, against a hunter that does 76.
|
||||||
|
*
|
||||||
|
* Worse, it reads from `chassis`, which is the subsystem a collision hurts
|
||||||
|
* most. So the thing that most destroys your ability to drive was wired to
|
||||||
|
* the thing that takes the most damage, and raising crash damage fivefold
|
||||||
|
* quietly turned one bad corner into a car that cannot be driven straight.
|
||||||
|
*
|
||||||
|
* Squared and much smaller, early wear is texture — a car that needs
|
||||||
|
* watching — and only a genuinely ruined one fights you for the wheel.
|
||||||
|
*/
|
||||||
|
steeringPull: (1 - chassis) ** 2 * 0.03,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,8 +237,13 @@ const MAX_DAMAGE_PER_STEP = 0.85;
|
|||||||
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
||||||
* not enough to ignore.
|
* not enough to ignore.
|
||||||
*/
|
*/
|
||||||
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
export function applyWear(
|
||||||
const impact = w.impactForce / IMPACT_REFERENCE;
|
c: CarCondition,
|
||||||
|
w: WearInput,
|
||||||
|
/** How much of a knock this car actually feels. Armour is a number below 1. */
|
||||||
|
fragility = 1,
|
||||||
|
): CarCondition {
|
||||||
|
const impact = (w.impactForce / IMPACT_REFERENCE) * fragility;
|
||||||
const damage: Subsystems = {
|
const damage: Subsystems = {
|
||||||
// Ordered by what a collision actually ruins: the shell takes the worst of
|
// Ordered by what a collision actually ruins: the shell takes the worst of
|
||||||
// it, the tyres and suspension a good share, the engine least of all.
|
// it, the tyres and suspension a good share, the engine least of all.
|
||||||
@ -235,10 +295,13 @@ export function repair(c: CarCondition, funds: number): { condition: CarConditio
|
|||||||
* patching it up; it is the expensive thing you do when patching has stopped
|
* patching it up; it is the expensive thing you do when patching has stopped
|
||||||
* helping.
|
* helping.
|
||||||
*
|
*
|
||||||
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
|
* Deliberately an absolute number rather than a multiple of the repair rate.
|
||||||
* undo, or seven missions' pay.
|
* Repairs got cheaper so that a crash cannot strand you; the *scar* is supposed
|
||||||
|
* to stay expensive, and tying the two together would have quietly discounted
|
||||||
|
* the one thing that is meant to hurt. A crash's permanent cost — about 0.42
|
||||||
|
* of ceiling across the three subsystems — still runs some ¤350, ten missions.
|
||||||
*/
|
*/
|
||||||
const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
|
const CEILING_PER_FUND = 0.0012;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spends parts on raising the ceiling itself, worst subsystem first.
|
* Spends parts on raising the ceiling itself, worst subsystem first.
|
||||||
@ -285,6 +348,41 @@ export function overhaul(c: CarCondition, funds: number): { condition: CarCondit
|
|||||||
*/
|
*/
|
||||||
const OVERHAUL_EPSILON = 1e-6;
|
const OVERHAUL_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition below which a car is not really a working vehicle, and the level a
|
||||||
|
* friendly workshop will drag it back to for nothing.
|
||||||
|
*
|
||||||
|
* This is the one piece of negative feedback in an otherwise entirely
|
||||||
|
* self-reinforcing loop. Everything else in the game pushes the same way: a
|
||||||
|
* crash makes the car slower and harder to hold straight, which makes the next
|
||||||
|
* crash likelier, which costs money you earn by driving, which you now do
|
||||||
|
* badly. Reach the bottom with nothing in your pocket and there is no sequence
|
||||||
|
* of good decisions that gets you out — you are not playing a hard game, you
|
||||||
|
* are watching one end.
|
||||||
|
*
|
||||||
|
* So the insurgency will not let its only driver sit in a wreck. It patches you
|
||||||
|
* up to something that runs and no further, and it cannot touch the ceiling, so
|
||||||
|
* the decline is untouched and only the dead end is removed.
|
||||||
|
*/
|
||||||
|
export const DERELICT = 0.3;
|
||||||
|
export const CHARITY_LEVEL = 0.55;
|
||||||
|
|
||||||
|
/** Would a friendly workshop take pity on this car? */
|
||||||
|
export const isDerelict = (c: CarCondition): boolean =>
|
||||||
|
overallCondition(c) < DERELICT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch a wreck back to something driveable, for free. Ceilings are untouched:
|
||||||
|
* this buys back none of the decline, only the ability to keep playing.
|
||||||
|
*/
|
||||||
|
export function charity(c: CarCondition): CarCondition {
|
||||||
|
const level = { ...c.level };
|
||||||
|
for (const part of SUBSYSTEMS) {
|
||||||
|
level[part] = Math.min(c.ceiling[part], Math.max(level[part], CHARITY_LEVEL));
|
||||||
|
}
|
||||||
|
return { level, ceiling: { ...c.ceiling } };
|
||||||
|
}
|
||||||
|
|
||||||
/** True when there is nothing an overhaul could buy: the car is as good as new. */
|
/** True when there is nothing an overhaul could buy: the car is as good as new. */
|
||||||
export const canOverhaul = (c: CarCondition): boolean =>
|
export const canOverhaul = (c: CarCondition): boolean =>
|
||||||
SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON);
|
SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON);
|
||||||
|
|||||||
@ -116,6 +116,12 @@ export function stepCombat(
|
|||||||
step: CombatStep,
|
step: CombatStep,
|
||||||
condition: CarCondition,
|
condition: CarCondition,
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
|
/**
|
||||||
|
* How much of a round the car actually feels. An armoured car is what this
|
||||||
|
* number is for: being shot at is precisely the situation it is bought for,
|
||||||
|
* so armour has to count here as much as it does against a wall.
|
||||||
|
*/
|
||||||
|
fragility = 1,
|
||||||
): CombatResult {
|
): CombatResult {
|
||||||
const { dt } = step;
|
const { dt } = step;
|
||||||
let playerHit = false;
|
let playerHit = false;
|
||||||
@ -185,16 +191,19 @@ export function stepCombat(
|
|||||||
// Routed through the same wear model as everything else, so a bullet
|
// Routed through the same wear model as everything else, so a bullet
|
||||||
// costs you ceiling too — it is permanent in the same way a crash is.
|
// costs you ceiling too — it is permanent in the same way a crash is.
|
||||||
updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
|
updated = applyWear(updated, { dt, distance: 0, throttle: 0, impactForce: 0 });
|
||||||
|
const engineHit = HIT_ENGINE * fragility;
|
||||||
|
const tyreHit = HIT_TIRES * fragility;
|
||||||
|
const chassisHit = HIT_CHASSIS * fragility;
|
||||||
updated = {
|
updated = {
|
||||||
level: {
|
level: {
|
||||||
engine: Math.max(0, updated.level.engine - HIT_ENGINE),
|
engine: Math.max(0, updated.level.engine - engineHit),
|
||||||
tires: Math.max(0, updated.level.tires - HIT_TIRES),
|
tires: Math.max(0, updated.level.tires - tyreHit),
|
||||||
chassis: Math.max(0, updated.level.chassis - HIT_CHASSIS),
|
chassis: Math.max(0, updated.level.chassis - chassisHit),
|
||||||
},
|
},
|
||||||
ceiling: {
|
ceiling: {
|
||||||
engine: Math.max(0, updated.ceiling.engine - HIT_ENGINE * 0.3),
|
engine: Math.max(0, updated.ceiling.engine - engineHit * 0.3),
|
||||||
tires: Math.max(0, updated.ceiling.tires - HIT_TIRES * 0.3),
|
tires: Math.max(0, updated.ceiling.tires - tyreHit * 0.3),
|
||||||
chassis: Math.max(0, updated.ceiling.chassis - HIT_CHASSIS * 0.3),
|
chassis: Math.max(0, updated.ceiling.chassis - chassisHit * 0.3),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/sim/garage.test.ts
Normal file
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,
|
type PursuitStep,
|
||||||
} from './pursuit';
|
} from './pursuit';
|
||||||
import { createUnits, type Faction, type Unit, type UnitState } from './units';
|
import { createUnits, type Faction, type Unit, type UnitState } from './units';
|
||||||
|
import { carById } from './garage';
|
||||||
import type { Control } from './regions';
|
import type { Control } from './regions';
|
||||||
|
|
||||||
function place(state: UnitState, faction: Faction, x: number, z: number): Unit {
|
function place(state: UnitState, faction: Faction, x: number, z: number): Unit {
|
||||||
@ -50,6 +51,10 @@ function run(
|
|||||||
units,
|
units,
|
||||||
canSee: () => true,
|
canSee: () => true,
|
||||||
provocation: 0,
|
provocation: 0,
|
||||||
|
// A road that has been worked, in an ordinary car: the conditions every
|
||||||
|
// existing test in this file was written against.
|
||||||
|
wariness: 1,
|
||||||
|
presence: 1,
|
||||||
...overrides,
|
...overrides,
|
||||||
})) {
|
})) {
|
||||||
events.push(event.kind);
|
events.push(event.kind);
|
||||||
@ -132,6 +137,8 @@ describe('being noticed', () => {
|
|||||||
units,
|
units,
|
||||||
canSee: () => true,
|
canSee: () => true,
|
||||||
provocation: PROVOCATION.ranOverSoldier,
|
provocation: PROVOCATION.ranOverSoldier,
|
||||||
|
wariness: 0,
|
||||||
|
presence: 1,
|
||||||
});
|
});
|
||||||
expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2);
|
expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2);
|
||||||
});
|
});
|
||||||
@ -276,3 +283,196 @@ describe('your own side pulling them off you', () => {
|
|||||||
expect(state.alert).toBe('hunted');
|
expect(state.alert).toBe('hunted');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('who is looking at you', () => {
|
||||||
|
it('names them, rather than only counting the meter', () => {
|
||||||
|
const units = createUnits();
|
||||||
|
const watcher = place(units, 'enemy', 20, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
run(state, units, 2);
|
||||||
|
expect([...state.eyes.keys()]).toEqual([watcher.id]);
|
||||||
|
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has them settle on you rather than deciding instantly', () => {
|
||||||
|
const units = createUnits();
|
||||||
|
const watcher = place(units, 'enemy', 20, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
run(state, units, 0.4);
|
||||||
|
const glance = state.eyes.get(watcher.id)!;
|
||||||
|
run(state, units, 3);
|
||||||
|
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(glance);
|
||||||
|
expect(state.eyes.get(watcher.id)!).toBeCloseTo(1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loses interest once it cannot see you, quicker than it gained it', () => {
|
||||||
|
const units = createUnits();
|
||||||
|
const watcher = place(units, 'enemy', 20, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
run(state, units, 4);
|
||||||
|
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(0.5);
|
||||||
|
run(state, units, 2, { canSee: () => false });
|
||||||
|
expect(state.eyes.has(watcher.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes lingering cost more than passing', () => {
|
||||||
|
// The complaint this answers: cover was too easy to lose, because
|
||||||
|
// suspicion started climbing the instant anybody had line of sight. Driving
|
||||||
|
// past a checkpoint cost exactly what loitering at one did.
|
||||||
|
const passing = () => {
|
||||||
|
const units = createUnits();
|
||||||
|
place(units, 'enemy', 20, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
// Seen for a moment, then gone.
|
||||||
|
run(state, units, 1.2);
|
||||||
|
run(state, units, 4, { canSee: () => false });
|
||||||
|
return state.suspicion;
|
||||||
|
};
|
||||||
|
const lingering = () => {
|
||||||
|
const units = createUnits();
|
||||||
|
place(units, 'enemy', 20, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
run(state, units, 5.2);
|
||||||
|
return state.suspicion;
|
||||||
|
};
|
||||||
|
expect(lingering()).toBeGreaterThan(passing() * 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still gets you made if you sit there long enough', () => {
|
||||||
|
const units = createUnits();
|
||||||
|
place(units, 'enemy', 12, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
run(state, units, 20);
|
||||||
|
expect(state.alert).toBe('hunted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cover depends on the place and the car', () => {
|
||||||
|
/** Somebody standing twenty metres away, watching, for a given few seconds. */
|
||||||
|
const watched = (seconds: number, overrides: Partial<PursuitStep>) => {
|
||||||
|
const units = createUnits();
|
||||||
|
place(units, 'enemy', 20, 0);
|
||||||
|
const state = createPursuit();
|
||||||
|
run(state, units, seconds, overrides);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('barely notices you on a road nobody has been working', () => {
|
||||||
|
// The complaint this answers: occupied ground blew your cover on its own.
|
||||||
|
// Nobody is looking for a driver they have no reason to think exists.
|
||||||
|
// Ten seconds: on a maximally notorious road, in an ordinary car, parked
|
||||||
|
// twenty metres from somebody, that is about how long being made takes.
|
||||||
|
const cold = watched(10, { wariness: 0, presence: 1 });
|
||||||
|
const hot = watched(10, { wariness: 1, presence: 1 });
|
||||||
|
expect(cold.suspicion).toBeLessThan(hot.suspicion / 5);
|
||||||
|
expect(cold.alert).toBe('clear');
|
||||||
|
expect(hot.alert).toBe('hunted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is why using one road all week costs you', () => {
|
||||||
|
// Heat used to matter only at the thresholds where concrete appears. It is
|
||||||
|
// now a continuous pressure: the people on your favourite route are
|
||||||
|
// expecting you.
|
||||||
|
const quiet = watched(5, { wariness: 0.15, presence: 1 });
|
||||||
|
const notorious = watched(5, { wariness: 0.9, presence: 1 });
|
||||||
|
expect(notorious.suspicion).toBeGreaterThan(quiet.suspicion * 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the vehicle you chose part of the same sum', () => {
|
||||||
|
const tractor = watched(6, { wariness: 0.5, presence: 0.45 });
|
||||||
|
const carrier = watched(6, { wariness: 0.5, presence: 3.8 });
|
||||||
|
expect(tractor.suspicion).toBeLessThan(carrier.suspicion / 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a quiet car on a cold road go about its business indefinitely', () => {
|
||||||
|
// The whole point of the cheap end of the garage.
|
||||||
|
const state = watched(45, { wariness: 0, presence: 0.45 });
|
||||||
|
expect(state.alert).toBe('clear');
|
||||||
|
expect(state.suspicion).toBeLessThan(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets an armoured carrier made almost at once on a notorious road', () => {
|
||||||
|
// And the whole point of the expensive end: it buys survival, not cover.
|
||||||
|
const state = watched(4, { wariness: 1, presence: 3.8 });
|
||||||
|
expect(state.alert).toBe('hunted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets nobody watch you behind your own lines, whatever you drive', () => {
|
||||||
|
const state = watched(30, { control: 'liberated', wariness: 1, presence: 3.8 });
|
||||||
|
expect(state.suspicion).toBe(0);
|
||||||
|
expect(state.alert).toBe('clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the difference on the faces before it costs you anything', () => {
|
||||||
|
// The place and the car ride on the *focus* ramp, not on suspicion
|
||||||
|
// directly, so the markers over people's heads redden faster on a hot road
|
||||||
|
// — you can read the road's reputation off the crowd.
|
||||||
|
const cold = watched(2, { wariness: 0, presence: 1 });
|
||||||
|
const hot = watched(2, { wariness: 1, presence: 1 });
|
||||||
|
expect(Math.max(...hot.eyes.values())).toBeGreaterThan(
|
||||||
|
Math.max(...cold.eyes.values()) * 3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the pacing the garage is meant to produce', () => {
|
||||||
|
/**
|
||||||
|
* A checkpoint you drive past: in sight for a few seconds, then gone.
|
||||||
|
*
|
||||||
|
* `wariness` is the road's heat, and heat is what accumulates between passes
|
||||||
|
* — the suspicion meter itself decays back to nothing, deliberately. So the
|
||||||
|
* memory of "this car keeps coming through here" lives on the road rather
|
||||||
|
* than in the guard's head, which is what makes "use a different route" a
|
||||||
|
* decision about cover and not just about concrete.
|
||||||
|
*/
|
||||||
|
const drivePast = (state: PursuitState, presence: number, wariness: number) => {
|
||||||
|
const units = createUnits();
|
||||||
|
const guard = place(units, 'enemy', 14, 0);
|
||||||
|
run(state, units, 5, { presence, wariness });
|
||||||
|
// Checked here, in front of the checkpoint — not after driving away.
|
||||||
|
// Twenty seconds down the road you have always shaken them, so asking at
|
||||||
|
// the end of the cycle is asking whether you got away, which is a
|
||||||
|
// different question and always has the same answer.
|
||||||
|
const made = state.alert === 'hunted';
|
||||||
|
guard.x = 9000;
|
||||||
|
run(state, units, 20, { presence, wariness });
|
||||||
|
return made;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('has an armoured carrier made almost at once', () => {
|
||||||
|
const state = createPursuit();
|
||||||
|
const units = createUnits();
|
||||||
|
place(units, 'enemy', 14, 0);
|
||||||
|
// A road nobody has even been working. It does not help.
|
||||||
|
run(state, units, 3, { presence: carById('apc').presence, wariness: 0.1 });
|
||||||
|
expect(state.alert).toBe('hunted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a small car through the same checkpoint several times first', () => {
|
||||||
|
const state = createPursuit();
|
||||||
|
const small = carById('runabout').presence;
|
||||||
|
// Each pass leaves the road a little warier than the last.
|
||||||
|
// Heat climbs the way repeated use of one road actually climbs it.
|
||||||
|
const passes = [0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1, 1, 1, 1];
|
||||||
|
const caughtOn = passes.findIndex((w) => drivePast(state, small, w));
|
||||||
|
// Several trips through before the place works out that this car keeps
|
||||||
|
// coming through — and not never, or the cheap car would be a free pass.
|
||||||
|
expect(caughtOn).toBeGreaterThan(2);
|
||||||
|
expect(caughtOn).toBeLessThan(passes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches the flashy car far sooner than the dull one', () => {
|
||||||
|
const runsBefore = (presence: number) => {
|
||||||
|
const state = createPursuit();
|
||||||
|
const passes = [0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1, 1, 1, 1];
|
||||||
|
const at = passes.findIndex((w) => drivePast(state, presence, w));
|
||||||
|
return at === -1 ? passes.length : at;
|
||||||
|
};
|
||||||
|
expect(runsBefore(carById('sports').presence)).toBeLessThan(
|
||||||
|
runsBefore(carById('runabout').presence),
|
||||||
|
);
|
||||||
|
expect(runsBefore(carById('tractor').presence)).toBeGreaterThanOrEqual(
|
||||||
|
runsBefore(carById('estate').presence),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -25,6 +25,16 @@ export interface PursuitState {
|
|||||||
lastSeen: { x: number; z: number } | null;
|
lastSeen: { x: number; z: number } | null;
|
||||||
/** Unit ids currently chasing. */
|
/** Unit ids currently chasing. */
|
||||||
hunters: Set<number>;
|
hunters: Set<number>;
|
||||||
|
/**
|
||||||
|
* Who has their eye on you, and how settled they are about it: 0 is a glance,
|
||||||
|
* 1 is somebody who has decided you are worth watching.
|
||||||
|
*
|
||||||
|
* This is the thing the player needed to be able to see. Cover used to be a
|
||||||
|
* single number that filled up with no indication of *who* was filling it, so
|
||||||
|
* being recognised arrived out of nowhere and there was nothing to react to
|
||||||
|
* except the meter itself.
|
||||||
|
*/
|
||||||
|
eyes: Map<number, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPursuit = (): PursuitState => ({
|
export const createPursuit = (): PursuitState => ({
|
||||||
@ -33,22 +43,69 @@ export const createPursuit = (): PursuitState => ({
|
|||||||
unseenFor: 0,
|
unseenFor: 0,
|
||||||
lastSeen: null,
|
lastSeen: null,
|
||||||
hunters: new Set(),
|
hunters: new Set(),
|
||||||
|
eyes: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Tuning ---------------------------------------------------------------
|
// --- Tuning ---------------------------------------------------------------
|
||||||
|
|
||||||
/** How far an enemy can make you out at all. */
|
/** How far an enemy can make you out at all. */
|
||||||
export const SIGHT_RANGE = 95;
|
export const SIGHT_RANGE = 95;
|
||||||
/** Suspicion per second with someone right on top of you. */
|
|
||||||
const MAX_GAIN = 0.42;
|
|
||||||
/**
|
/**
|
||||||
* Suspicion shed per second with nobody watching.
|
* Suspicion per second from somebody certain about you, right on top of you,
|
||||||
|
* on ground and in a vehicle that both fully deserve it.
|
||||||
*
|
*
|
||||||
* Slow on purpose. Cover is meant to be something you lose and then have to
|
* Large, because it is multiplied down hard by `alertness` in almost every real
|
||||||
* earn back by staying dull for a while; at a brisk decay rate you are clean
|
* situation. The pacing it is set against is concrete: an armoured carrier is
|
||||||
* again before you have finished the corner.
|
* made within a couple of seconds even on a road nobody has been working, and a
|
||||||
|
* small unremarkable car can go through the same checkpoint several times
|
||||||
|
* before the road has learned enough about it to matter.
|
||||||
*/
|
*/
|
||||||
const DECAY = 0.05;
|
const MAX_GAIN = 1.6;
|
||||||
|
/**
|
||||||
|
* How fast somebody goes from noticing a car to being sure about it, per second,
|
||||||
|
* with the car right beside them.
|
||||||
|
*
|
||||||
|
* This exists because cover was too easy to lose. Suspicion used to start
|
||||||
|
* climbing the instant anybody had line of sight, so driving *past* a checkpoint
|
||||||
|
* cost the same as loitering at one, and being made was something that happened
|
||||||
|
* to you rather than something you could feel coming and back out of.
|
||||||
|
*
|
||||||
|
* Now a watcher has to settle first, and only then do they start filling the
|
||||||
|
* meter. Passing through quickly leaves everyone at a glance; lingering is what
|
||||||
|
* actually costs you. It roughly doubles the time to be recognised, and — more
|
||||||
|
* to the point — makes the first half of that time legible.
|
||||||
|
*/
|
||||||
|
const FOCUS_RATE = 0.55;
|
||||||
|
/**
|
||||||
|
* How much attention a place pays a stranger when nothing has happened there.
|
||||||
|
*
|
||||||
|
* The floor under `wariness`, and the point of the whole mechanism. Occupied
|
||||||
|
* ground used to blow your cover on its own: park anywhere past the line, in
|
||||||
|
* anything, and someone would eventually make you. But nobody is looking for a
|
||||||
|
* driver they have no reason to believe exists — if no road round here has been
|
||||||
|
* worked and no district has a story about you, a car going past is a car going
|
||||||
|
* past.
|
||||||
|
*
|
||||||
|
* Above zero because occupied ground is still occupied: sit in front of a
|
||||||
|
* checkpoint long enough and somebody will wander over regardless.
|
||||||
|
*/
|
||||||
|
const COLD_GROUND = 0.12;
|
||||||
|
/** How fast that interest fades once they cannot see you. Quicker than suspicion. */
|
||||||
|
const FOCUS_FADE = 0.7;
|
||||||
|
/**
|
||||||
|
* Suspicion shed per second, at all times.
|
||||||
|
*
|
||||||
|
* Slow on purpose, and for two reasons now. Cover is meant to be something you
|
||||||
|
* lose and then have to earn back by being dull for a while. And because it
|
||||||
|
* runs continuously it is also the *threshold*: below DECAY worth of attention
|
||||||
|
* you are not slowly being made, you are simply not being made.
|
||||||
|
*
|
||||||
|
* Small enough that a little survives the gap between one trip past a
|
||||||
|
* checkpoint and the next. That residue is what lets a small car go through
|
||||||
|
* several times before the place has worked out that it keeps coming through —
|
||||||
|
* the road's own heat does most of that remembering, but not all of it.
|
||||||
|
*/
|
||||||
|
const DECAY = 0.02;
|
||||||
/** Suspicion at which you stop being a car and start being a target. */
|
/** Suspicion at which you stop being a car and start being a target. */
|
||||||
const RECOGNISED = 1;
|
const RECOGNISED = 1;
|
||||||
/** Above this the HUD starts warning you. */
|
/** Above this the HUD starts warning you. */
|
||||||
@ -100,6 +157,20 @@ export interface PursuitStep {
|
|||||||
canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean;
|
canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean;
|
||||||
/** Suspicion added outright this step by things the player just did. */
|
/** Suspicion added outright this step by things the player just did. */
|
||||||
provocation: number;
|
provocation: number;
|
||||||
|
/**
|
||||||
|
* How wary this particular place is, 0..1 — the heat on the road being used,
|
||||||
|
* or on the district when off it.
|
||||||
|
*
|
||||||
|
* This is what makes "take a different road" a decision about *cover* rather
|
||||||
|
* than only about concrete. Use one route all week and the people on it are
|
||||||
|
* expecting you.
|
||||||
|
*/
|
||||||
|
wariness: number;
|
||||||
|
/**
|
||||||
|
* How much the vehicle draws the eye. A tractor is part of the scenery; an
|
||||||
|
* armoured carrier is a thing people phone in.
|
||||||
|
*/
|
||||||
|
presence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PursuitEvent =
|
export type PursuitEvent =
|
||||||
@ -121,18 +192,75 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
|
|||||||
const exposure = EXPOSURE[step.control];
|
const exposure = EXPOSURE[step.control];
|
||||||
const seenBy = watchers(step);
|
const seenBy = watchers(step);
|
||||||
|
|
||||||
// --- Suspicion ---
|
// --- Who is looking, and how settled they are about it ---
|
||||||
if (exposure > 0 && seenBy.length > 0) {
|
// Everyone who can see you creeps toward being sure; everyone who cannot
|
||||||
|
// loses interest, faster than they gained it.
|
||||||
|
/*
|
||||||
|
* How much trouble this is, all in one number: whose ground it is, how wary
|
||||||
|
* the place has been made, and what you turned up in.
|
||||||
|
*
|
||||||
|
* All three belong on the *focus* ramp rather than on suspicion directly,
|
||||||
|
* because that is the half the player can see. A hot road does not silently
|
||||||
|
* fill a meter faster — the diamonds over people's heads redden faster, and
|
||||||
|
* you can read the road's reputation off the crowd before it costs you
|
||||||
|
* anything.
|
||||||
|
*/
|
||||||
|
const wariness = COLD_GROUND + (1 - COLD_GROUND) * Math.min(1, Math.max(0, step.wariness));
|
||||||
|
const alertness = exposure * wariness * step.presence;
|
||||||
|
|
||||||
|
const looking = new Set<number>();
|
||||||
|
let attention = 0;
|
||||||
|
for (const unit of seenBy) {
|
||||||
|
looking.add(unit.id);
|
||||||
|
const gap = Math.hypot(unit.x - step.player.x, unit.z - step.player.z);
|
||||||
|
const proximity = (1 - gap / SIGHT_RANGE) ** 2;
|
||||||
|
/*
|
||||||
|
* Alertness caps how sure anyone can get, not merely how fast.
|
||||||
|
*
|
||||||
|
* Without the ceiling a quiet car on a cold road is only *slower* to blow
|
||||||
|
* your cover, never safe from it — everybody eventually saturates and the
|
||||||
|
* whole distinction collapses. With it, somebody with no reason to suspect
|
||||||
|
* a driver exists tops out at a glance and stays there.
|
||||||
|
*/
|
||||||
|
const ceiling = Math.min(1, alertness);
|
||||||
|
const settled = Math.min(
|
||||||
|
ceiling,
|
||||||
|
(state.eyes.get(unit.id) ?? 0) + FOCUS_RATE * proximity * alertness * step.dt,
|
||||||
|
);
|
||||||
|
state.eyes.set(unit.id, settled);
|
||||||
// Whoever has the best look at you sets the pace; a crowd is not more
|
// Whoever has the best look at you sets the pace; a crowd is not more
|
||||||
// suspicious than one person standing right next to the car.
|
// suspicious than one person standing right next to the car.
|
||||||
let closest = SIGHT_RANGE;
|
attention = Math.max(attention, settled * proximity);
|
||||||
for (const unit of seenBy) {
|
|
||||||
closest = Math.min(closest, Math.hypot(unit.x - step.player.x, unit.z - step.player.z));
|
|
||||||
}
|
}
|
||||||
const proximity = (1 - closest / SIGHT_RANGE) ** 2;
|
for (const [id, settled] of state.eyes) {
|
||||||
state.suspicion += proximity * MAX_GAIN * exposure * step.dt;
|
if (looking.has(id)) continue;
|
||||||
} else if (state.alert !== 'hunted') {
|
const faded = settled - FOCUS_FADE * step.dt;
|
||||||
state.suspicion -= DECAY * step.dt;
|
if (faded <= 0) state.eyes.delete(id);
|
||||||
|
else state.eyes.set(id, faded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Suspicion, as a race between being looked at and being forgotten.
|
||||||
|
*
|
||||||
|
* Two different things, kept apart on purpose. `attention` is how *sure* the
|
||||||
|
* keenest onlooker is, and a person cannot be more than certain — so it caps,
|
||||||
|
* and it is what the markers over their heads show. `alertness` is what that
|
||||||
|
* certainty costs you here, and it does not cap at all: somebody certain
|
||||||
|
* about an armoured carrier on a road they have been working all week is a
|
||||||
|
* different event from somebody equally certain about a tractor on a quiet
|
||||||
|
* one. Folding the two together made every vehicle identical the moment
|
||||||
|
* anybody was sure about it, which took the garage's whole decision back out.
|
||||||
|
*
|
||||||
|
* The decay runs the whole time rather than only when nobody is watching, and
|
||||||
|
* that is what turns this from an accumulator into a threshold. Below
|
||||||
|
* DECAY/MAX_GAIN of attention you are not slowly being made, you are simply
|
||||||
|
* *not being made* — a tractor on a road nobody has worked can go about its
|
||||||
|
* business all day. Above it, the clock is running. An accumulator has no
|
||||||
|
* such line: given long enough it always reaches the top, and "nobody
|
||||||
|
* suspects you here" could never mean anything.
|
||||||
|
*/
|
||||||
|
if (state.alert !== 'hunted') {
|
||||||
|
state.suspicion += (attention * alertness * MAX_GAIN - DECAY) * step.dt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provocations land whatever the range, and they land in full: driving over
|
// Provocations land whatever the range, and they land in full: driving over
|
||||||
@ -223,3 +351,7 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
|
|||||||
|
|
||||||
/** Fraction of the way to being recognised, for the HUD meter. */
|
/** Fraction of the way to being recognised, for the HUD meter. */
|
||||||
export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED;
|
export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED;
|
||||||
|
|
||||||
|
/** How settled the most interested onlooker is, 0..1. */
|
||||||
|
export const closestAttention = (state: PursuitState): number =>
|
||||||
|
state.eyes.size === 0 ? 0 : Math.max(...state.eyes.values());
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { generateWorld } from './world';
|
|||||||
import {
|
import {
|
||||||
applyWear,
|
applyWear,
|
||||||
canOverhaul,
|
canOverhaul,
|
||||||
|
charity,
|
||||||
|
isDerelict,
|
||||||
FUNDS_PER_CONDITION,
|
FUNDS_PER_CONDITION,
|
||||||
deriveHandling,
|
deriveHandling,
|
||||||
freshCondition,
|
freshCondition,
|
||||||
@ -330,3 +332,101 @@ describe('overhaul', () => {
|
|||||||
expect(missions).toBeLessThan(20);
|
expect(missions).toBeLessThan(20);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the car does not fight you for the wheel', () => {
|
||||||
|
/**
|
||||||
|
* `steeringPull` is an uncommanded steering input that never lets go, and at
|
||||||
|
* its original 0.06 linear it was the most destructive number in the game: at
|
||||||
|
* 90% chassis it walked the car off a fifteen-metre trunk, driving dead
|
||||||
|
* straight, in under seven seconds. That also cost most of the top speed,
|
||||||
|
* because off the tarmac drive force drops to 55%.
|
||||||
|
*
|
||||||
|
* The numbers below are metres of drift over ten seconds at 25 m/s, from
|
||||||
|
* `steeringPull` alone. A trunk is 15m wide, so half of it is the budget.
|
||||||
|
*/
|
||||||
|
const driftOverTenSeconds = (chassis: number) => {
|
||||||
|
const pull = deriveHandling({
|
||||||
|
level: { engine: 1, tires: 1, chassis },
|
||||||
|
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||||
|
}).steeringPull;
|
||||||
|
// Small-angle: lateral ≈ ½·(v²/R)·t², with R = wheelbase/tan(pull).
|
||||||
|
const speed = 25;
|
||||||
|
const radius = 2.6 / Math.max(pull, 1e-9);
|
||||||
|
return 0.5 * ((speed * speed) / radius) * 10 * 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('holds a lane on a car that is merely worn', () => {
|
||||||
|
// A car at 90% should need watching, not constant correction.
|
||||||
|
expect(driftOverTenSeconds(0.9)).toBeLessThan(7.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still wanders once it is genuinely bent', () => {
|
||||||
|
// But a wreck has to be a handful, or damage means nothing.
|
||||||
|
expect(driftOverTenSeconds(0.2)).toBeGreaterThan(driftOverTenSeconds(0.9) * 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets worse faster than it gets bad, rather than the other way round', () => {
|
||||||
|
// Squared, not linear: the first fifth of the car's life should barely be
|
||||||
|
// felt, and the last fifth should be most of it.
|
||||||
|
const early = driftOverTenSeconds(0.8) - driftOverTenSeconds(1);
|
||||||
|
const late = driftOverTenSeconds(0.2) - driftOverTenSeconds(0.4);
|
||||||
|
expect(late).toBeGreaterThan(early * 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('there is a way back up', () => {
|
||||||
|
const wrecked = (): CarCondition => ({
|
||||||
|
level: { engine: 0.1, tires: 0.15, chassis: 0.05 },
|
||||||
|
ceiling: { engine: 0.7, tires: 0.75, chassis: 0.65 },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises a car that cannot do the job', () => {
|
||||||
|
expect(isDerelict(wrecked())).toBe(true);
|
||||||
|
expect(isDerelict(freshCondition())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches it back to something that runs', () => {
|
||||||
|
const after = charity(wrecked());
|
||||||
|
expect(isDerelict(after)).toBe(false);
|
||||||
|
for (const part of SUBSYSTEMS) expect(after.level[part]).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buys back none of the decline', () => {
|
||||||
|
// The whole point: it removes the dead end without touching the ratchet.
|
||||||
|
const before = wrecked();
|
||||||
|
const after = charity(before);
|
||||||
|
expect(after.ceiling).toEqual(before.ceiling);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lifts anything above what the car is still capable of', () => {
|
||||||
|
const nearlyFinished: CarCondition = {
|
||||||
|
level: { engine: 0.05, tires: 0.05, chassis: 0.05 },
|
||||||
|
ceiling: { engine: 0.4, tires: 0.4, chassis: 0.4 },
|
||||||
|
};
|
||||||
|
const after = charity(nearlyFinished);
|
||||||
|
for (const part of SUBSYSTEMS) expect(after.level[part]).toBeCloseTo(0.4, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a crash affordable to repair, and its scar expensive', () => {
|
||||||
|
const crashed = applyWear(freshCondition(), {
|
||||||
|
dt: 1 / 60,
|
||||||
|
distance: 0.4,
|
||||||
|
throttle: 0,
|
||||||
|
impactForce: 2.47e6,
|
||||||
|
});
|
||||||
|
const MISSION = 34;
|
||||||
|
const lost =
|
||||||
|
3 - (crashed.level.engine + crashed.level.tires + crashed.level.chassis);
|
||||||
|
const toRepair = (lost * FUNDS_PER_CONDITION) / MISSION;
|
||||||
|
// A couple of jobs to be driving properly again...
|
||||||
|
expect(toRepair).toBeLessThan(3);
|
||||||
|
// ...but the permanent share still costs an evening to undo.
|
||||||
|
let missions = 0;
|
||||||
|
let c = crashed;
|
||||||
|
while (canOverhaul(c) && missions < 200) {
|
||||||
|
c = overhaul(c, MISSION).condition;
|
||||||
|
missions++;
|
||||||
|
}
|
||||||
|
expect(missions).toBeGreaterThan(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import {
|
|||||||
type UnitState,
|
type UnitState,
|
||||||
} from './units';
|
} from './units';
|
||||||
import { createCombat, segmentHits, stepCombat } from './combat';
|
import { createCombat, segmentHits, stepCombat } from './combat';
|
||||||
|
import { arrowFor } from '../ui/hud';
|
||||||
|
import { segmentAt } from './roads';
|
||||||
|
|
||||||
const world = generateWorld(1337);
|
const world = generateWorld(1337);
|
||||||
const graph = buildGraph(world.roads);
|
const graph = buildGraph(world.roads);
|
||||||
@ -735,11 +737,18 @@ describe('nobody fires at what they cannot see', () => {
|
|||||||
describe('traffic that drives like traffic', () => {
|
describe('traffic that drives like traffic', () => {
|
||||||
/** Where each car sits relative to the centre of the road it is on. */
|
/** Where each car sits relative to the centre of the road it is on. */
|
||||||
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
|
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
|
||||||
// Signed offset from the segment's centreline, positive to the car's right.
|
// Signed offset from the centreline, positive to the *car's* right.
|
||||||
|
//
|
||||||
|
// Right-handed world, Y up: something facing +Z has its right toward -X.
|
||||||
|
// So for a forward of (sin a, cos a) the right vector is (-cos a, sin a).
|
||||||
|
// Deriving this independently of the sim is the whole value of the test —
|
||||||
|
// the first version copied the sim's vector, inherited its inverted sign,
|
||||||
|
// and cheerfully certified that everything drove on the correct side while
|
||||||
|
// the entire map drove on the left.
|
||||||
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
|
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
|
||||||
const px = unit.x - seg.ax;
|
const px = unit.x - seg.ax;
|
||||||
const pz = unit.z - seg.az;
|
const pz = unit.z - seg.az;
|
||||||
const lateral = px * Math.cos(along) - pz * Math.sin(along);
|
const lateral = -px * Math.cos(along) + pz * Math.sin(along);
|
||||||
// Flip for cars travelling the other way down the same segment.
|
// Flip for cars travelling the other way down the same segment.
|
||||||
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
|
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
|
||||||
};
|
};
|
||||||
@ -893,3 +902,393 @@ describe('your own side, with vehicles', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('which way is right', () => {
|
||||||
|
/**
|
||||||
|
* The lane offset is the one piece of geometry in this file where getting the
|
||||||
|
* sign backwards is both easy and completely invisible: everything still
|
||||||
|
* drives in neat parallel lines, just on the wrong side of the road. The
|
||||||
|
* first version of it did exactly that.
|
||||||
|
*
|
||||||
|
* So it is checked against the HUD's compass rather than against another copy
|
||||||
|
* of the same reasoning. Two modules that must agree, written independently,
|
||||||
|
* is the only version of this test that can actually fail.
|
||||||
|
*/
|
||||||
|
const laneRightOf = (headingRad: number) => {
|
||||||
|
const dirX = Math.sin(headingRad);
|
||||||
|
const dirZ = Math.cos(headingRad);
|
||||||
|
// Must match sim/units.ts `advance`.
|
||||||
|
return { x: -dirZ, z: dirX };
|
||||||
|
};
|
||||||
|
|
||||||
|
const bearingTo = (headingRad: number, to: { x: number; z: number }) =>
|
||||||
|
Math.atan2(to.x, to.z) - headingRad;
|
||||||
|
|
||||||
|
it('offsets cars toward the side the HUD calls right', () => {
|
||||||
|
for (const heading of [0, Math.PI / 2, Math.PI, -Math.PI / 2, 0.7, -2.4]) {
|
||||||
|
const right = laneRightOf(heading);
|
||||||
|
expect(arrowFor(bearingTo(heading, right))).toBe('→');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not merely self-consistent — the other side reads as left', () => {
|
||||||
|
for (const heading of [0, 1.2, -0.9]) {
|
||||||
|
const right = laneRightOf(heading);
|
||||||
|
expect(arrowFor(bearingTo(heading, { x: -right.x, z: -right.z }))).toBe('←');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with the car: steering right from heading 0 goes toward -X', () => {
|
||||||
|
// Measured on the running game with Rapier: hold W and D from heading 0 and
|
||||||
|
// the car ends up at negative x. The sim layer cannot import the physics,
|
||||||
|
// so the number is recorded here instead of re-derived.
|
||||||
|
expect(laneRightOf(0).x).toBeLessThan(0);
|
||||||
|
expect(laneRightOf(0).z).toBeCloseTo(0, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('civilians have some sense', () => {
|
||||||
|
const onRoad = (u: { x: number; z: number }) => segmentAt(world.roads, u.x, u.z) !== null;
|
||||||
|
|
||||||
|
const crowd = (overrides = {}) => {
|
||||||
|
const state = createUnits();
|
||||||
|
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z }, ...overrides }, makeRng(44));
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps people out of the road', () => {
|
||||||
|
const people = crowd().units.filter((u) => u.role === 'pedestrian');
|
||||||
|
expect(people.length).toBeGreaterThan(5);
|
||||||
|
// Not zero: somebody is always mid-crossing. But it has to be the exception.
|
||||||
|
expect(people.filter(onRoad).length / people.length).toBeLessThan(0.15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the ones who are crossing hurry', () => {
|
||||||
|
// Sampled over time, since at any instant only a couple are on tarmac.
|
||||||
|
const state = createUnits();
|
||||||
|
let crossingSteps = 0;
|
||||||
|
let crossingDistance = 0;
|
||||||
|
let strollingSteps = 0;
|
||||||
|
let strollingDistance = 0;
|
||||||
|
const before = new Map<number, { x: number; z: number }>();
|
||||||
|
for (let i = 0; i < 2400; i++) {
|
||||||
|
stepUnits(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
dt: 0.1,
|
||||||
|
now: i * 0.1,
|
||||||
|
player: { x: world.spawn.x, z: world.spawn.z },
|
||||||
|
front,
|
||||||
|
heatLevel: () => 'clear',
|
||||||
|
decayHeat: () => {},
|
||||||
|
decayArea: () => {},
|
||||||
|
hunt: null,
|
||||||
|
},
|
||||||
|
world.roads,
|
||||||
|
graph,
|
||||||
|
makeRng(44),
|
||||||
|
);
|
||||||
|
for (const u of state.units) {
|
||||||
|
if (u.role !== 'pedestrian') continue;
|
||||||
|
const was = before.get(u.id);
|
||||||
|
if (was) {
|
||||||
|
const moved = Math.hypot(u.x - was.x, u.z - was.z);
|
||||||
|
if (onRoad(u)) {
|
||||||
|
crossingSteps++;
|
||||||
|
crossingDistance += moved;
|
||||||
|
} else {
|
||||||
|
strollingSteps++;
|
||||||
|
strollingDistance += moved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before.set(u.id, { x: u.x, z: u.z });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(crossingSteps).toBeGreaterThan(30);
|
||||||
|
// Nobody strolls across a road.
|
||||||
|
expect(crossingDistance / crossingSteps).toBeGreaterThan(
|
||||||
|
(strollingDistance / strollingSteps) * 1.5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs from gunfire, not only from the sight of a rifle', () => {
|
||||||
|
// Run the same four seconds twice, identical but for one shot fired on the
|
||||||
|
// first step with nobody visibly armed anywhere. Everything else — the
|
||||||
|
// wander, the seed, the road avoidance — is held constant, so the
|
||||||
|
// difference is the gunfire and nothing else.
|
||||||
|
const walk = (withShot: boolean) => {
|
||||||
|
const state = createUnits();
|
||||||
|
run(state, 60, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(44));
|
||||||
|
const person = state.units.find((u) => u.role === 'pedestrian')!;
|
||||||
|
const shot = { x: person.x + 6, z: person.z };
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
stepUnits(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
dt: 0.1,
|
||||||
|
now: 60 + i * 0.1,
|
||||||
|
player: { x: world.spawn.x, z: world.spawn.z },
|
||||||
|
front,
|
||||||
|
heatLevel: () => 'clear',
|
||||||
|
decayHeat: () => {},
|
||||||
|
decayArea: () => {},
|
||||||
|
hunt: null,
|
||||||
|
// Fired once, on the first step only.
|
||||||
|
gunfire: withShot && i === 0 ? [shot] : [],
|
||||||
|
},
|
||||||
|
world.roads,
|
||||||
|
graph,
|
||||||
|
makeRng(44),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Math.hypot(person.x - shot.x, person.z - shot.z);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Four seconds, against a six-second panic: they are still running when the
|
||||||
|
// measurement is taken, rather than having frozen the instant it went quiet.
|
||||||
|
expect(walk(true)).toBeGreaterThan(walk(false) + 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('traffic and the player', () => {
|
||||||
|
/**
|
||||||
|
* One civilian car partway down a long straight, with the player parked
|
||||||
|
* twelve metres ahead of it on the same road.
|
||||||
|
*
|
||||||
|
* Built rather than found: taking whichever car happened to exist put the
|
||||||
|
* player on its instantaneous heading, which is not the same as its lane, so
|
||||||
|
* the car would reach a junction and turn off before anything was decided.
|
||||||
|
*/
|
||||||
|
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
|
||||||
|
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
|
||||||
|
|
||||||
|
const approaching = () => {
|
||||||
|
const state = createUnits();
|
||||||
|
const at = 0.25;
|
||||||
|
const car = {
|
||||||
|
id: state.nextId++,
|
||||||
|
kind: 'car' as const,
|
||||||
|
faction: 'civilian' as const,
|
||||||
|
role: 'traffic' as const,
|
||||||
|
x: straight.ax + (straight.bx - straight.ax) * at,
|
||||||
|
z: straight.az + (straight.bz - straight.az) * at,
|
||||||
|
heading: along,
|
||||||
|
speed: 12,
|
||||||
|
hp: 60,
|
||||||
|
path: [straight.b],
|
||||||
|
lastNode: straight.a,
|
||||||
|
expires: 1e6,
|
||||||
|
assigned: null,
|
||||||
|
onStation: 0,
|
||||||
|
cooldown: 1,
|
||||||
|
elevation: 1,
|
||||||
|
};
|
||||||
|
state.units.push(car);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
car,
|
||||||
|
player: { x: car.x + Math.sin(along) * 12, z: car.z + Math.cos(along) * 12 },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const drive = (
|
||||||
|
state: ReturnType<typeof createUnits>,
|
||||||
|
car: { x: number; z: number; heading: number },
|
||||||
|
player: { x: number; z: number },
|
||||||
|
gunfire = false,
|
||||||
|
) => {
|
||||||
|
// Fired from *behind* the car, so panic pushes it on toward the player
|
||||||
|
// rather than simply away from the noise — otherwise the test only proves
|
||||||
|
// that people run from gunfire, which is a different test.
|
||||||
|
const shot = { x: car.x - Math.sin(along) * 10, z: car.z - Math.cos(along) * 10 };
|
||||||
|
// The closest it ever gets. End-distance is useless here: a car that does
|
||||||
|
// not stop drives straight past and ends up further away than one that did.
|
||||||
|
let closest = Infinity;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
stepUnits(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
dt: 0.1,
|
||||||
|
now: 60 + i * 0.1,
|
||||||
|
player,
|
||||||
|
front,
|
||||||
|
heatLevel: () => 'clear',
|
||||||
|
decayHeat: () => {},
|
||||||
|
decayArea: () => {},
|
||||||
|
hunt: null,
|
||||||
|
gunfire: gunfire && i === 0 ? [shot] : [],
|
||||||
|
},
|
||||||
|
world.roads,
|
||||||
|
graph,
|
||||||
|
makeRng(2),
|
||||||
|
);
|
||||||
|
closest = Math.min(closest, Math.hypot(car.x - player.x, car.z - player.z));
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('stops rather than shunting a parked car down the road', () => {
|
||||||
|
const { state, car, player } = approaching();
|
||||||
|
// Never gets within ramming distance of a car sitting in its way. Traffic
|
||||||
|
// used to drive straight into a stationary player and shove them along,
|
||||||
|
// which makes every other vehicle read as weather rather than as a driver.
|
||||||
|
expect(drive(state, car, player)).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stop for you when it is running from gunfire', () => {
|
||||||
|
const calm = approaching();
|
||||||
|
const kept = drive(calm.state, calm.car, calm.player);
|
||||||
|
|
||||||
|
const panicked = approaching();
|
||||||
|
const closed = drive(panicked.state, panicked.car, panicked.player, true);
|
||||||
|
|
||||||
|
// A driver getting out of a firefight is not going to wait behind you.
|
||||||
|
expect(closed).toBeLessThan(kept);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a car knocked off its lane', () => {
|
||||||
|
/**
|
||||||
|
* The failure this pins is not subtle to watch and is very easy to reintroduce:
|
||||||
|
* traffic driving tight circles forever, one car after another, because the
|
||||||
|
* aim point it is steering at cannot be reached.
|
||||||
|
*/
|
||||||
|
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
|
||||||
|
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
|
||||||
|
|
||||||
|
const displaced = (metresOff: number) => {
|
||||||
|
const state = createUnits();
|
||||||
|
// Right of the direction of travel, matching sim/units.ts.
|
||||||
|
const offX = -Math.cos(along);
|
||||||
|
const offZ = Math.sin(along);
|
||||||
|
const car = {
|
||||||
|
id: state.nextId++,
|
||||||
|
kind: 'car' as const,
|
||||||
|
faction: 'civilian' as const,
|
||||||
|
role: 'traffic' as const,
|
||||||
|
x: straight.ax + (straight.bx - straight.ax) * 0.15 + offX * metresOff,
|
||||||
|
z: straight.az + (straight.bz - straight.az) * 0.15 + offZ * metresOff,
|
||||||
|
heading: along,
|
||||||
|
speed: 12,
|
||||||
|
hp: 60,
|
||||||
|
path: [straight.b],
|
||||||
|
lastNode: straight.a,
|
||||||
|
expires: 1e6,
|
||||||
|
assigned: null,
|
||||||
|
onStation: 0,
|
||||||
|
cooldown: 1,
|
||||||
|
elevation: 1,
|
||||||
|
};
|
||||||
|
state.units.push(car);
|
||||||
|
|
||||||
|
let turned = 0;
|
||||||
|
let previousHeading = car.heading;
|
||||||
|
// Closest it ever gets to its lane while still driving that leg. Measuring
|
||||||
|
// at the end is useless: it reaches the junction, picks a new destination,
|
||||||
|
// and the original lane stops meaning anything.
|
||||||
|
let closestToLane = Infinity;
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
stepUnits(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
dt: 0.1,
|
||||||
|
now: i * 0.1,
|
||||||
|
// On the road, not far away: units well outside SIM_RADIUS of the
|
||||||
|
// player are culled, and a culled car proves nothing.
|
||||||
|
player: { x: (straight.ax + straight.bx) / 2, z: (straight.az + straight.bz) / 2 },
|
||||||
|
front,
|
||||||
|
heatLevel: () => 'clear',
|
||||||
|
decayHeat: () => {},
|
||||||
|
decayArea: () => {},
|
||||||
|
hunt: null,
|
||||||
|
},
|
||||||
|
world.roads,
|
||||||
|
graph,
|
||||||
|
makeRng(6),
|
||||||
|
);
|
||||||
|
let delta = car.heading - previousHeading;
|
||||||
|
if (delta > Math.PI) delta -= Math.PI * 2;
|
||||||
|
if (delta < -Math.PI) delta += Math.PI * 2;
|
||||||
|
turned += Math.abs(delta);
|
||||||
|
previousHeading = car.heading;
|
||||||
|
if (car.path[0] === straight.b) {
|
||||||
|
closestToLane = Math.min(
|
||||||
|
closestToLane,
|
||||||
|
Math.abs((car.x - straight.ax) * -Math.cos(along) + (car.z - straight.az) * Math.sin(along)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { turns: turned / (Math.PI * 2), lateral: closestToLane, car };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('gets everybody somewhere, over a long run', () => {
|
||||||
|
/*
|
||||||
|
* The honest measure of the circling bug, and the one that took several
|
||||||
|
* attempts to arrive at. Counting how much cars *turn* is no good: a grid
|
||||||
|
* with a junction every hundred metres legitimately has them turning
|
||||||
|
* constantly. Nor is displacement from start to finish, since a car can
|
||||||
|
* loop the network and come back past where it began.
|
||||||
|
*
|
||||||
|
* How far it ever got from where it started is the one that separates
|
||||||
|
* "driving around town" from "driving around a lamppost".
|
||||||
|
*/
|
||||||
|
const state = createUnits();
|
||||||
|
const start = new Map<number, { x: number; z: number }>();
|
||||||
|
const furthest = new Map<number, number>();
|
||||||
|
const seen = new Map<number, number>();
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
stepUnits(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
dt: 0.1,
|
||||||
|
now: i * 0.1,
|
||||||
|
player: { x: world.spawn.x, z: world.spawn.z },
|
||||||
|
front,
|
||||||
|
heatLevel: () => 'clear',
|
||||||
|
decayHeat: () => {},
|
||||||
|
decayArea: () => {},
|
||||||
|
hunt: null,
|
||||||
|
},
|
||||||
|
world.roads,
|
||||||
|
graph,
|
||||||
|
makeRng(19),
|
||||||
|
);
|
||||||
|
for (const u of state.units) {
|
||||||
|
if (u.role !== 'traffic') continue;
|
||||||
|
const from = start.get(u.id) ?? { x: u.x, z: u.z };
|
||||||
|
start.set(u.id, from);
|
||||||
|
furthest.set(
|
||||||
|
u.id,
|
||||||
|
Math.max(furthest.get(u.id) ?? 0, Math.hypot(u.x - from.x, u.z - from.z)),
|
||||||
|
);
|
||||||
|
seen.set(u.id, (seen.get(u.id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only cars that were around for most of it: one that spawned in the last
|
||||||
|
// few seconds has had no chance to go anywhere and proves nothing.
|
||||||
|
const settled = [...furthest.entries()]
|
||||||
|
.filter(([id]) => (seen.get(id) ?? 0) > 600)
|
||||||
|
.map(([, d]) => d);
|
||||||
|
expect(settled.length).toBeGreaterThan(15);
|
||||||
|
// Nobody spends a minute and a half orbiting the spot they started on.
|
||||||
|
expect(Math.min(...settled)).toBeGreaterThan(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejoins the road instead of orbiting it', () => {
|
||||||
|
// Thirty metres wide of its own road: far enough that the aim point used to
|
||||||
|
// be unreachable, so the car circled indefinitely and drifted further out
|
||||||
|
// every lap rather than coming back.
|
||||||
|
// Judged on whether it reached its lane, not on how much it turned: over
|
||||||
|
// thirty seconds it drives well past this junction and on through others,
|
||||||
|
// and turning at those is what driving is.
|
||||||
|
const run = displaced(30);
|
||||||
|
expect(run.lateral).toBeLessThan(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds its lane when it is already on it', () => {
|
||||||
|
const run = displaced(0);
|
||||||
|
// Starts on the line and never wanders more than a lane's width off it.
|
||||||
|
expect(run.lateral).toBeLessThan(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
346
src/sim/units.ts
346
src/sim/units.ts
@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { Rng } from '../core/rng';
|
import type { Rng } from '../core/rng';
|
||||||
import type { RoadNetwork, RoadSegment } from './roads';
|
import type { RoadNetwork, RoadSegment } from './roads';
|
||||||
import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads';
|
import { ROAD_SPEED, pointOnSegment, projectOntoSegment, segmentAt } from './roads';
|
||||||
import { findRoute, travelTime, type Graph } from './routing';
|
import { findRoute, travelTime, type Graph } from './routing';
|
||||||
import type { Control, Front } from './regions';
|
import type { Control, Front } from './regions';
|
||||||
import { controlAt, depthAt } from './regions';
|
import { controlAt, depthAt } from './regions';
|
||||||
@ -67,6 +67,10 @@ export interface Unit {
|
|||||||
chaseSpeed?: number;
|
chaseSpeed?: number;
|
||||||
/** Junction most recently left, so the leg being driven is known. */
|
/** Junction most recently left, so the leg being driven is known. */
|
||||||
lastNode?: number;
|
lastNode?: number;
|
||||||
|
/** Closest this unit has got to its next junction, for spotting a stall. */
|
||||||
|
closest?: number;
|
||||||
|
/** Seconds since it last got any closer to it. */
|
||||||
|
stuckFor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -100,6 +104,14 @@ export interface UnitState {
|
|||||||
dispatched: Set<number>;
|
dispatched: Set<number>;
|
||||||
nextId: number;
|
nextId: number;
|
||||||
lastSkirmishAt: number;
|
lastSkirmishAt: number;
|
||||||
|
/**
|
||||||
|
* Where shooting was last heard, and how long people keep running from it.
|
||||||
|
*
|
||||||
|
* Held here rather than read per frame because panic outlasts the noise: a
|
||||||
|
* crowd that stopped dead the instant the last round was fired would read as
|
||||||
|
* a switch being flipped rather than as people being frightened.
|
||||||
|
*/
|
||||||
|
alarm: { x: number; z: number; until: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createUnits = (): UnitState => ({
|
export const createUnits = (): UnitState => ({
|
||||||
@ -108,6 +120,7 @@ export const createUnits = (): UnitState => ({
|
|||||||
dispatched: new Set(),
|
dispatched: new Set(),
|
||||||
nextId: 1,
|
nextId: 1,
|
||||||
lastSkirmishAt: -999,
|
lastSkirmishAt: -999,
|
||||||
|
alarm: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Tuning ---------------------------------------------------------------
|
// --- Tuning ---------------------------------------------------------------
|
||||||
@ -119,6 +132,25 @@ export const PEDESTRIAN_TARGET = 30;
|
|||||||
export const SIM_RADIUS = 640;
|
export const SIM_RADIUS = 640;
|
||||||
/** Civilians keep clear of the shooting. */
|
/** Civilians keep clear of the shooting. */
|
||||||
const CIVILIAN_FLEE_RANGE = 70;
|
const CIVILIAN_FLEE_RANGE = 70;
|
||||||
|
/**
|
||||||
|
* How much quicker somebody moves while they are in the road.
|
||||||
|
*
|
||||||
|
* Pedestrians used to amble across four lanes of traffic at the same pace they
|
||||||
|
* wander a courtyard, which reads as a world where nobody minds being run over.
|
||||||
|
* Nobody strolls across a road: you either stay off it or you get over it.
|
||||||
|
*/
|
||||||
|
const CROSSING_HASTE = 2.3;
|
||||||
|
/** How far ahead somebody looks before stepping into the road. */
|
||||||
|
const KERB_LOOKAHEAD = 4;
|
||||||
|
/**
|
||||||
|
* Chance per second that somebody actually needs the other side.
|
||||||
|
*
|
||||||
|
* Without this they never cross at all and each block becomes a sealed pen,
|
||||||
|
* which looks even more wrong than wandering into traffic did.
|
||||||
|
*/
|
||||||
|
const CROSSING_CHANCE = 0.12;
|
||||||
|
/** Seconds people keep running after the shooting stops. */
|
||||||
|
const PANIC_SECONDS = 6;
|
||||||
|
|
||||||
const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
|
const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
|
||||||
/**
|
/**
|
||||||
@ -329,8 +361,37 @@ function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment |
|
|||||||
* road rather than a corridor.
|
* road rather than a corridor.
|
||||||
*/
|
*/
|
||||||
const LANE_SHARE = 0.45;
|
const LANE_SHARE = 0.45;
|
||||||
/** How far ahead a driver looks along their lane when deciding where to point. */
|
/**
|
||||||
|
* How far ahead a driver looks along their lane, at minimum and per m/s.
|
||||||
|
*
|
||||||
|
* The speed term is the one that matters and it is not a nicety — it is the
|
||||||
|
* stability condition for this kind of steering, and getting it wrong is what
|
||||||
|
* had traffic driving in circles through four separate attempts at a fix.
|
||||||
|
*
|
||||||
|
* A vehicle chasing a point ahead of it oscillates unless that point sits
|
||||||
|
* outside its own turning circle, and the margin has to be about double. The
|
||||||
|
* tightest circle anything here can drive is speed / TURN_RATE: at 14 m/s and
|
||||||
|
* 2.2 rad/s, 6.4 metres. So the lookahead has to clear roughly 13 metres, and
|
||||||
|
* it was a flat 11 — below the threshold at every speed traffic actually
|
||||||
|
* drives at, which is why it was never a junction bug or a crowding bug. It
|
||||||
|
* was arithmetic, and it circled wherever it happened to be.
|
||||||
|
*
|
||||||
|
* 1.4 m per m/s gives about 20 metres at cruise: comfortably outside the
|
||||||
|
* circle, with room for the car to be knocked about and still recover.
|
||||||
|
*/
|
||||||
const LANE_LOOKAHEAD = 11;
|
const LANE_LOOKAHEAD = 11;
|
||||||
|
const LANE_LOOKAHEAD_PER_SPEED = 1.4;
|
||||||
|
/**
|
||||||
|
* How far ahead they look per metre off the lane, and the ceiling on it.
|
||||||
|
*
|
||||||
|
* Above 1 so the aim point never sits square to the lane, which is what makes
|
||||||
|
* the steering oscillate and then circle. Capped so it can never run so far
|
||||||
|
* ahead that the correction goes to nothing and the car drifts away instead.
|
||||||
|
*/
|
||||||
|
const LANE_RECOVERY = 1.7;
|
||||||
|
const LANE_LOOKAHEAD_CAP = 2.5;
|
||||||
|
/** How long a car may fail to get any closer to its destination before it is reset. */
|
||||||
|
const STUCK_SECONDS = 12;
|
||||||
/** How quickly a driver can swing the nose round, radians per second. */
|
/** How quickly a driver can swing the nose round, radians per second. */
|
||||||
const TURN_RATE = 2.2;
|
const TURN_RATE = 2.2;
|
||||||
/** Gap a driver keeps to whatever is in front, metres. */
|
/** Gap a driver keeps to whatever is in front, metres. */
|
||||||
@ -339,8 +400,23 @@ const FOLLOW_GAP = 9;
|
|||||||
const FOLLOW_RANGE = 26;
|
const FOLLOW_RANGE = 26;
|
||||||
/** How wide a lane counts as "in front of me" rather than "beside me". */
|
/** How wide a lane counts as "in front of me" rather than "beside me". */
|
||||||
const FOLLOW_WIDTH = 2.6;
|
const FOLLOW_WIDTH = 2.6;
|
||||||
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
|
/**
|
||||||
const CAR_SEPARATION = 4.5;
|
* Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m.
|
||||||
|
*
|
||||||
|
* Only genuine overlap, not polite spacing — the following distance already
|
||||||
|
* keeps a queue nine metres apart, and this exists for the case that rule
|
||||||
|
* cannot see: two cars crossing at a junction, on different legs, heading
|
||||||
|
* ninety degrees apart.
|
||||||
|
*
|
||||||
|
* It used to be 4.5 and it shoved cars several metres sideways off their own
|
||||||
|
* lane. Pure pursuit then curved them back, and a steady sideways shove against
|
||||||
|
* a steady curve back is a circle — which is exactly what was happening at
|
||||||
|
* intersections. Tight enough now that it separates cars that are actually
|
||||||
|
* inside one another and otherwise leaves the steering alone.
|
||||||
|
*/
|
||||||
|
const CAR_SEPARATION = 3;
|
||||||
|
/** Share of the overlap resolved per step, so it eases apart rather than jumps. */
|
||||||
|
const SEPARATION_EASE = 0.35;
|
||||||
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
|
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
|
||||||
export const CREW_ELEVATION = 1.5;
|
export const CREW_ELEVATION = 1.5;
|
||||||
|
|
||||||
@ -367,23 +443,46 @@ function turnToward(from: number, to: number, limit: number): number {
|
|||||||
* never reaches the road it was sent to, and the whole escalation chain is
|
* never reaches the road it was sent to, and the whole escalation chain is
|
||||||
* built on dispatched units actually arriving.
|
* built on dispatched units actually arriving.
|
||||||
*/
|
*/
|
||||||
function carAhead(state: UnitState, unit: Unit): number | null {
|
function carAhead(
|
||||||
const forwardX = Math.sin(unit.heading);
|
state: UnitState,
|
||||||
const forwardZ = Math.cos(unit.heading);
|
unit: Unit,
|
||||||
|
player: { x: number; z: number },
|
||||||
|
/** Direction of the lane being driven — *not* the nose. See below. */
|
||||||
|
forwardX: number,
|
||||||
|
forwardZ: number,
|
||||||
|
): number | null {
|
||||||
let nearest: number | null = null;
|
let nearest: number | null = null;
|
||||||
|
|
||||||
|
/** Is this thing in my way, and how far off is it? */
|
||||||
|
const inTheWay = (x: number, z: number): number | null => {
|
||||||
|
const dx = x - unit.x;
|
||||||
|
const dz = z - unit.z;
|
||||||
|
const ahead = dx * forwardX + dz * forwardZ;
|
||||||
|
if (ahead <= 0 || ahead > FOLLOW_RANGE) return null;
|
||||||
|
if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) return null;
|
||||||
|
return ahead;
|
||||||
|
};
|
||||||
|
|
||||||
for (const other of state.units) {
|
for (const other of state.units) {
|
||||||
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
|
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
|
||||||
if (Math.cos(other.heading - unit.heading) < 0) continue;
|
if (Math.sin(other.heading) * forwardX + Math.cos(other.heading) * forwardZ < 0) continue;
|
||||||
const dx = other.x - unit.x;
|
const ahead = inTheWay(other.x, other.z);
|
||||||
const dz = other.z - unit.z;
|
if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead;
|
||||||
const ahead = dx * forwardX + dz * forwardZ;
|
|
||||||
if (ahead <= 0 || ahead > FOLLOW_RANGE) continue;
|
|
||||||
if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) continue;
|
|
||||||
if (nearest === null || ahead < nearest) nearest = ahead;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And the player, whichever way they happen to be pointing. Traffic used to
|
||||||
|
// drive straight into a stationary car and shunt it down the road, which
|
||||||
|
// makes every other vehicle feel like weather rather than like a driver.
|
||||||
|
const atPlayer = inTheWay(player.x, player.z);
|
||||||
|
if (atPlayer !== null && (nearest === null || atPlayer < nearest)) nearest = atPlayer;
|
||||||
|
|
||||||
return nearest;
|
return nearest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is this civilian close enough to the shooting to have stopped being careful? */
|
||||||
|
const panicking = (state: UnitState, unit: Unit): boolean =>
|
||||||
|
state.alarm !== null && distance(state.alarm, unit) < CIVILIAN_FLEE_RANGE;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steps a unit along its path. Returns true when the path is exhausted.
|
* Steps a unit along its path. Returns true when the path is exhausted.
|
||||||
*
|
*
|
||||||
@ -392,7 +491,13 @@ function carAhead(state: UnitState, unit: Unit): number | null {
|
|||||||
* nose round rather than snapping it, and it lifts off for whatever is in
|
* nose round rather than snapping it, and it lifts off for whatever is in
|
||||||
* front instead of driving through it.
|
* front instead of driving through it.
|
||||||
*/
|
*/
|
||||||
function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean {
|
function advance(
|
||||||
|
unit: Unit,
|
||||||
|
roads: RoadNetwork,
|
||||||
|
state: UnitState,
|
||||||
|
player: { x: number; z: number },
|
||||||
|
dt: number,
|
||||||
|
): boolean {
|
||||||
const next = unit.path[0];
|
const next = unit.path[0];
|
||||||
if (next === undefined) return true;
|
if (next === undefined) return true;
|
||||||
|
|
||||||
@ -423,9 +528,20 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
|
|||||||
dirX /= legLength;
|
dirX /= legLength;
|
||||||
dirZ /= legLength;
|
dirZ /= legLength;
|
||||||
}
|
}
|
||||||
// Right of the direction of travel.
|
/*
|
||||||
const rightX = dirZ;
|
* Right of the direction of travel.
|
||||||
const rightZ = -dirX;
|
*
|
||||||
|
* Worth deriving rather than guessing, because the obvious form is the wrong
|
||||||
|
* one and it is invisible in code: this world is right-handed with Y up, so a
|
||||||
|
* car facing +Z has its right-hand side toward **-X**, not +X. Confirmed by
|
||||||
|
* the HUD's own compass — which reads +X at heading 0 as a left turn — and by
|
||||||
|
* driving the thing: hold W and D from heading 0 and the car goes to -X.
|
||||||
|
*
|
||||||
|
* The first version of this had the sign the other way round and every
|
||||||
|
* vehicle in the game quietly drove on the left.
|
||||||
|
*/
|
||||||
|
const rightX = -dirZ;
|
||||||
|
const rightZ = dirX;
|
||||||
|
|
||||||
const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2;
|
const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2;
|
||||||
const offset = halfWidth * LANE_SHARE;
|
const offset = halfWidth * LANE_SHARE;
|
||||||
@ -446,27 +562,136 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number):
|
|||||||
if (remaining < 3 + offset || beyond > -0.5) {
|
if (remaining < 3 + offset || beyond > -0.5) {
|
||||||
unit.lastNode = next;
|
unit.lastNode = next;
|
||||||
unit.path.shift();
|
unit.path.shift();
|
||||||
|
unit.closest = undefined;
|
||||||
|
unit.stuckFor = 0;
|
||||||
return unit.path.length === 0;
|
return unit.path.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure pursuit: aim at the point on the lane line a fixed distance ahead of
|
/*
|
||||||
// wherever the car currently projects onto it. Short lookahead weaves, long
|
* Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD`
|
||||||
// lookahead never quite arrives.
|
* metres *from the car*.
|
||||||
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD;
|
*
|
||||||
|
* The distance being measured from the car rather than along the lane is the
|
||||||
|
* entire trick, and getting it wrong is what had traffic doing twenty laps of
|
||||||
|
* a junction to net eight metres, one car after another.
|
||||||
|
*
|
||||||
|
* Aim a fixed distance *along the lane* from where the car projects onto it
|
||||||
|
* and the geometry breaks down twice over. Close in, the aim point falls
|
||||||
|
* inside the tightest circle the car can drive — about six metres at
|
||||||
|
* `TURN_RATE` — so it cannot reach it, swings past and comes round again.
|
||||||
|
* Far out, the fix of growing the lookahead makes it worse: the aim point
|
||||||
|
* runs away down the lane until the direction to it is almost parallel with
|
||||||
|
* the lane itself, the steering correction vanishes, and the car drifts
|
||||||
|
* further off every step. Measured drifting from 28 to 42 metres wide of its
|
||||||
|
* own road while dutifully pointing along it.
|
||||||
|
*
|
||||||
|
* Holding the aim at a fixed radius from the car fixes both ends at once. On
|
||||||
|
* the line, it sits `LANE_LOOKAHEAD` ahead and the car tracks straight. Off
|
||||||
|
* the line by less than that, the point slides back along the lane and the
|
||||||
|
* car cuts in at a sane angle. Off by more than that, there is no such point
|
||||||
|
* at all, so it aims at the nearest bit of lane there is and drives at it.
|
||||||
|
*/
|
||||||
|
const lateral = (unit.x - laneX) * rightX + (unit.z - laneZ) * rightZ;
|
||||||
|
const projected = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ;
|
||||||
|
/*
|
||||||
|
* The lookahead has to stay comfortably clear of how far off the lane the car
|
||||||
|
* actually is, and it has to stop growing.
|
||||||
|
*
|
||||||
|
* Let it equal the error and the aim point collapses onto the perpendicular:
|
||||||
|
* the car turns square at its own lane, overshoots, arrives the same distance
|
||||||
|
* out on the far side, and repeats. At full lock that oscillation is a
|
||||||
|
* circle, and it was the one still left — 40 laps in a minute, 817 metres
|
||||||
|
* travelled, eight metres gained, at full speed the whole way.
|
||||||
|
*
|
||||||
|
* Letting it grow without limit is the other failure and it was the first fix
|
||||||
|
* tried here: the point runs away down the lane until the direction to it is
|
||||||
|
* almost parallel with the lane, the correction vanishes, and the car drifts
|
||||||
|
* out for ever. So: proportional to the error, floored, and capped.
|
||||||
|
*/
|
||||||
|
const lookahead = Math.min(
|
||||||
|
LANE_LOOKAHEAD * LANE_LOOKAHEAD_CAP,
|
||||||
|
Math.max(
|
||||||
|
LANE_LOOKAHEAD,
|
||||||
|
unit.speed * LANE_LOOKAHEAD_PER_SPEED,
|
||||||
|
Math.abs(lateral) * LANE_RECOVERY,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const reach = Math.sqrt(Math.max(0, lookahead * lookahead - lateral * lateral));
|
||||||
|
const along = projected + reach;
|
||||||
const aimX = laneX + dirX * along;
|
const aimX = laneX + dirX * along;
|
||||||
const aimZ = laneZ + dirZ * along;
|
const aimZ = laneZ + dirZ * along;
|
||||||
|
|
||||||
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
|
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
|
||||||
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
|
|
||||||
|
|
||||||
// Close on the car in front and lift off. Only civilians defer; anyone with
|
/*
|
||||||
// somewhere to be leans on the horn and keeps going.
|
* Close on whatever is in front and lift off. Only civilians defer; anyone
|
||||||
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
|
* with somewhere to be leans on the horn and keeps going — and neither does
|
||||||
|
* anybody who has just heard shooting, because a driver getting out of a
|
||||||
|
* firefight is not going to wait behind you.
|
||||||
|
*
|
||||||
|
* "In front" is measured along the *lane*, not along the nose. In a cluster
|
||||||
|
* of stopped cars the noses swing about, so a heading-based cone has every
|
||||||
|
* car intermittently blocked by every other one and none of them can leave.
|
||||||
|
*/
|
||||||
|
const yields =
|
||||||
|
(unit.role === 'traffic' || unit.role === 'convoy') && !panicking(state, unit);
|
||||||
|
const gap = yields ? carAhead(state, unit, player, dirX, dirZ) : null;
|
||||||
const allowed =
|
const allowed =
|
||||||
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
|
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
|
||||||
const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
|
const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Steer in proportion to how far the car actually travelled.
|
||||||
|
*
|
||||||
|
* This is the fix for traffic driving in circles. A jam brakes every car in
|
||||||
|
* it to a standstill — each has a neighbour inside the following distance —
|
||||||
|
* and they were still turning at the full rate while stopped, so a queue
|
||||||
|
* became a slowly rotating heap that stirred itself and never dispersed. A
|
||||||
|
* stationary car cannot change which way it is pointing; you steer by moving.
|
||||||
|
*/
|
||||||
|
const rolled = unit.speed * dt < 1e-9 ? 0 : move / (unit.speed * dt);
|
||||||
|
unit.heading = turnToward(unit.heading, desired, TURN_RATE * rolled * dt);
|
||||||
|
|
||||||
unit.x += Math.sin(unit.heading) * move;
|
unit.x += Math.sin(unit.heading) * move;
|
||||||
unit.z += Math.cos(unit.heading) * move;
|
unit.z += Math.cos(unit.heading) * move;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Last resort: notice when a car is getting nowhere, and put it back.
|
||||||
|
*
|
||||||
|
* Everything above is a controller with several interacting parts — a lane to
|
||||||
|
* follow, a car in front to defer to, other vehicles shoving it out of the
|
||||||
|
* way — and controllers of that shape have failure modes that are far easier
|
||||||
|
* to detect than to enumerate. The symptom is always the same and it is
|
||||||
|
* plainly visible from the road: driving in circles, at speed, for ever.
|
||||||
|
*
|
||||||
|
* So rather than trusting that the last one of those is now fixed, this
|
||||||
|
* measures the only thing that actually matters — is it getting closer to
|
||||||
|
* where it is going — and if the answer has been no for long enough, sets the
|
||||||
|
* car back down on its lane pointing the right way. A car that is genuinely
|
||||||
|
* queueing is not getting closer either, which is why the threshold is long
|
||||||
|
* enough to sit out any plausible hold-up.
|
||||||
|
*/
|
||||||
|
const gapToNode = Math.hypot(node.x - unit.x, node.z - unit.z);
|
||||||
|
if (unit.closest === undefined || gapToNode < unit.closest - 0.5) {
|
||||||
|
unit.closest = gapToNode;
|
||||||
|
unit.stuckFor = 0;
|
||||||
|
} else {
|
||||||
|
unit.stuckFor = (unit.stuckFor ?? 0) + dt;
|
||||||
|
if (unit.stuckFor > STUCK_SECONDS) {
|
||||||
|
// Put it back on its lane, pointing along it — and throw the route away.
|
||||||
|
// Repositioning alone was not enough: the car went straight back to
|
||||||
|
// whatever it had been doing and was stuck again within seconds. Losing
|
||||||
|
// the path forces a fresh one from wherever it now is, which is the only
|
||||||
|
// recovery that cannot resume the state it was stuck in.
|
||||||
|
unit.x = laneX + dirX * Math.max(0, projected);
|
||||||
|
unit.z = laneZ + dirZ * Math.max(0, projected);
|
||||||
|
unit.heading = Math.atan2(dirX, dirZ);
|
||||||
|
unit.path = [];
|
||||||
|
unit.lastNode = undefined;
|
||||||
|
unit.closest = undefined;
|
||||||
|
unit.stuckFor = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -837,6 +1062,13 @@ export interface UnitStep {
|
|||||||
* were seen. Null when nobody is chasing.
|
* were seen. Null when nobody is chasing.
|
||||||
*/
|
*/
|
||||||
hunt: { x: number; z: number } | null;
|
hunt: { x: number; z: number } | null;
|
||||||
|
/**
|
||||||
|
* Muzzle positions from the last step, so civilians run from gunfire itself
|
||||||
|
* rather than only from the sight of somebody holding a rifle. Shooting is
|
||||||
|
* resolved after this runs, so these are one step old — which is fine, and
|
||||||
|
* arguably right: you hear it, then you move.
|
||||||
|
*/
|
||||||
|
gunfire?: Array<{ x: number; z: number }>;
|
||||||
/**
|
/**
|
||||||
* Is this point inside a building? Hunters drive round them rather than
|
* Is this point inside a building? Hunters drive round them rather than
|
||||||
* through them, which is the entire reason turning a corner works.
|
* through them, which is the entire reason turning a corner works.
|
||||||
@ -932,7 +1164,7 @@ export function stepUnits(
|
|||||||
switch (unit.role) {
|
switch (unit.role) {
|
||||||
case 'traffic':
|
case 'traffic':
|
||||||
case 'convoy': {
|
case 'convoy': {
|
||||||
if (advance(unit, roads, state, dt)) {
|
if (advance(unit, roads, state, player, dt)) {
|
||||||
// Somewhere else to be. A convoy stays on ground its own side holds;
|
// Somewhere else to be. A convoy stays on ground its own side holds;
|
||||||
// a taxi does not care.
|
// a taxi does not care.
|
||||||
const pool =
|
const pool =
|
||||||
@ -955,7 +1187,26 @@ export function stepUnits(
|
|||||||
// A slow wander. People are not going anywhere in particular — but they
|
// A slow wander. People are not going anywhere in particular — but they
|
||||||
// do go *round* the buildings, and take the turn as their new heading
|
// do go *round* the buildings, and take the turn as their new heading
|
||||||
// so they walk along a wall rather than repeatedly into it.
|
// so they walk along a wall rather than repeatedly into it.
|
||||||
|
//
|
||||||
|
// And they keep off the road. Standing in traffic used to cost a
|
||||||
|
// pedestrian nothing, which quietly made running one over cost the
|
||||||
|
// player nothing either: if people wander into the road by themselves,
|
||||||
|
// hitting one is the world's fault rather than yours.
|
||||||
|
if (segmentAt(roads, unit.x, unit.z)) {
|
||||||
|
// Already out there. Hold the heading — dithering in the middle of a
|
||||||
|
// road is the one thing nobody does — and get across at a run.
|
||||||
|
unit.heading = moveThrough(unit, unit.heading, unit.speed * CROSSING_HASTE * dt, step.blocked);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
|
if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
|
||||||
|
|
||||||
|
// At the kerb: step off it, unless this is somebody who actually wants
|
||||||
|
// the other side, in which case they commit and hurry.
|
||||||
|
const kerbX = unit.x + Math.sin(unit.heading) * KERB_LOOKAHEAD;
|
||||||
|
const kerbZ = unit.z + Math.cos(unit.heading) * KERB_LOOKAHEAD;
|
||||||
|
const crossing = segmentAt(roads, kerbX, kerbZ) !== null && rng() > dt * CROSSING_CHANCE;
|
||||||
|
if (crossing) unit.heading += Math.PI * (0.5 + rng() * 0.5) * (rng() < 0.5 ? 1 : -1);
|
||||||
unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
|
unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -968,7 +1219,7 @@ export function stepUnits(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (unit.path.length > 0) {
|
if (unit.path.length > 0) {
|
||||||
advance(unit, roads, state, dt);
|
advance(unit, roads, state, player, dt);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1093,23 +1344,50 @@ export function stepUnits(
|
|||||||
const dx = b.x - a.x;
|
const dx = b.x - a.x;
|
||||||
const dz = b.z - a.z;
|
const dz = b.z - a.z;
|
||||||
const gap = Math.hypot(dx, dz);
|
const gap = Math.hypot(dx, dz);
|
||||||
if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
|
if (gap >= CAR_SEPARATION) continue;
|
||||||
const push = (CAR_SEPARATION - gap) / 2;
|
const push = ((CAR_SEPARATION - gap) / 2) * SEPARATION_EASE;
|
||||||
const nx = dx / gap;
|
/*
|
||||||
const nz = dz / gap;
|
* Exactly coincident is the one case that has to be handled rather than
|
||||||
|
* skipped. Two cars at the same point have no direction to be pushed
|
||||||
|
* apart along, and skipping them welds the pair together permanently —
|
||||||
|
* they then orbit as a unit, for ever. Any direction will do so long as
|
||||||
|
* it is deterministic; theirs are opposed, so they part.
|
||||||
|
*/
|
||||||
|
const nx = gap < 1e-6 ? 1 : dx / gap;
|
||||||
|
const nz = gap < 1e-6 ? 0 : dz / gap;
|
||||||
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
|
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
|
||||||
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
|
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Civilians get out of the way of a firefight ---
|
// --- Civilians get out of the way of a firefight ---
|
||||||
|
// Anything fired near enough to be heard resets the clock, and people keep
|
||||||
|
// running for a few seconds after it stops. A crowd that froze the instant
|
||||||
|
// the last round went off would read as a switch, not as fear.
|
||||||
|
for (const muzzle of step.gunfire ?? []) {
|
||||||
|
if (distance(muzzle, player) > SIM_RADIUS) continue;
|
||||||
|
state.alarm = { x: muzzle.x, z: muzzle.z, until: step.now + PANIC_SECONDS };
|
||||||
|
}
|
||||||
|
if (state.alarm && step.now > state.alarm.until) state.alarm = null;
|
||||||
|
|
||||||
for (const unit of state.units) {
|
for (const unit of state.units) {
|
||||||
if (unit.faction !== 'civilian') continue;
|
if (unit.faction !== 'civilian') continue;
|
||||||
const danger = state.units.find(
|
// Either somebody visibly holding a rifle, or the sound of one going off.
|
||||||
|
const armed = state.units.find(
|
||||||
(other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE,
|
(other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE,
|
||||||
);
|
);
|
||||||
|
const heard =
|
||||||
|
state.alarm && distance(state.alarm, unit) < CIVILIAN_FLEE_RANGE ? state.alarm : null;
|
||||||
|
const danger =
|
||||||
|
armed && heard
|
||||||
|
? distance(armed, unit) < distance(heard, unit)
|
||||||
|
? armed
|
||||||
|
: heard
|
||||||
|
: (armed ?? heard);
|
||||||
if (!danger) continue;
|
if (!danger) continue;
|
||||||
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
|
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
|
||||||
|
// Running from gunfire beats keeping off the road: the point of the road
|
||||||
|
// rule is that people are careful, and this is the moment they are not.
|
||||||
unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
|
unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,12 @@ export interface HudModel {
|
|||||||
hunters: number;
|
hunters: number;
|
||||||
/** Seconds of staying out of sight before they give up. */
|
/** Seconds of staying out of sight before they give up. */
|
||||||
losingIn: number;
|
losingIn: number;
|
||||||
|
/** How settled the most interested onlooker is, 0..1. */
|
||||||
|
attention: number;
|
||||||
|
/** How many people currently have eyes on you. */
|
||||||
|
watching: number;
|
||||||
|
/** Bearings to each hunter, radians from the car's nose, positive to the left. */
|
||||||
|
bearings: number[];
|
||||||
};
|
};
|
||||||
/** Rounds in the air nearby. Not a health bar — a reason to keep moving. */
|
/** Rounds in the air nearby. Not a health bar — a reason to keep moving. */
|
||||||
danger: number;
|
danger: number;
|
||||||
@ -65,7 +71,14 @@ export interface HudModel {
|
|||||||
/** Eight-point compass, starting at "straight ahead" and turning left. */
|
/** Eight-point compass, starting at "straight ahead" and turning left. */
|
||||||
const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗'];
|
const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗'];
|
||||||
|
|
||||||
const arrowFor = (bearing: number): string => {
|
/**
|
||||||
|
* Exported so the world and the HUD can be held to the same idea of "right".
|
||||||
|
* This world is right-handed with Y up, so a car facing +Z has its right-hand
|
||||||
|
* side toward -X — which is easy to get backwards in code and invisible when
|
||||||
|
* you do, so sim/units.ts is tested against this function rather than against
|
||||||
|
* its own copy of the convention.
|
||||||
|
*/
|
||||||
|
export const arrowFor = (bearing: number): string => {
|
||||||
const sector = Math.round(bearing / (Math.PI / 4));
|
const sector = Math.round(bearing / (Math.PI / 4));
|
||||||
return ARROWS[((sector % 8) + 8) % 8]!;
|
return ARROWS[((sector % 8) + 8) % 8]!;
|
||||||
};
|
};
|
||||||
@ -77,15 +90,30 @@ const arrowFor = (bearing: number): string => {
|
|||||||
*/
|
*/
|
||||||
function pursuitLines(pursuit: HudModel['pursuit']): string[] {
|
function pursuitLines(pursuit: HudModel['pursuit']): string[] {
|
||||||
if (pursuit.alert === 'hunted') {
|
if (pursuit.alert === 'hunted') {
|
||||||
|
// Where they are, not just how many. A count tells you to panic; a set of
|
||||||
|
// bearings tells you which way to go, which is the actual decision.
|
||||||
|
const from = pursuit.bearings.length
|
||||||
|
? pursuit.bearings.map(arrowFor).join(' ')
|
||||||
|
: '—';
|
||||||
return [
|
return [
|
||||||
`HUNTED — ${pursuit.hunters} on you`,
|
`HUNTED — ${pursuit.hunters} on you ${from}`,
|
||||||
pursuit.losingIn > 0
|
pursuit.losingIn > 0
|
||||||
? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them`
|
? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them`
|
||||||
: 'they can see you',
|
: 'they can see you',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (pursuit.meter <= 0.02) return ['cover intact'];
|
if (pursuit.watching === 0 && pursuit.meter <= 0.02) return ['cover intact'];
|
||||||
return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`];
|
const lines: string[] = [];
|
||||||
|
if (pursuit.watching > 0) {
|
||||||
|
// The half of this the player could never see: somebody has clocked you and
|
||||||
|
// is making their mind up, and there is still time to be somewhere else.
|
||||||
|
lines.push(
|
||||||
|
`${pursuit.watching} watching ${bar(pursuit.attention)}` +
|
||||||
|
(pursuit.attention > 0.75 ? ' — they have you' : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pursuit.meter > 0.02) lines.push(`noticed ${bar(pursuit.meter)}`);
|
||||||
|
return lines.length > 0 ? lines : ['cover intact'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTROL_WORDS: Record<Control, string> = {
|
const CONTROL_WORDS: Record<Control, string> = {
|
||||||
@ -171,7 +199,7 @@ export function createHud(seed: number, debug = false) {
|
|||||||
`[debug] seed ${seed}`,
|
`[debug] seed ${seed}`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
'WASD drive · space handbrake · R respawn',
|
'WASD drive · space handbrake · R respawn · right-drag to look',
|
||||||
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
|
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||||
'hold R to reset the campaign',
|
'hold R to reset the campaign',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -49,6 +49,14 @@ export interface MinimapView {
|
|||||||
opportunity: { x: number; z: number } | null;
|
opportunity: { x: number; z: number } | null;
|
||||||
/** Elapsed time, for ageing observations. */
|
/** Elapsed time, for ageing observations. */
|
||||||
now: number;
|
now: number;
|
||||||
|
/**
|
||||||
|
* Anyone with their eye on you, and how settled they are about it.
|
||||||
|
*
|
||||||
|
* Drawn from live positions rather than from Intel, which is the one place
|
||||||
|
* this map is allowed to tell the truth: these are people you can see out of
|
||||||
|
* the window right now, not something you remember about a road.
|
||||||
|
*/
|
||||||
|
watchers: Array<{ x: number; z: number; settled: number; hunting: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) {
|
export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) {
|
||||||
@ -135,6 +143,32 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Who is looking at you, and who has stopped looking and started ---
|
||||||
|
for (const w of view.watchers) {
|
||||||
|
const wx = px(w.x);
|
||||||
|
const wz = px(w.z);
|
||||||
|
if (w.hunting) {
|
||||||
|
// A hunter is not a shade of anything. Solid, and bigger.
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(wx, wz, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#ff3a2a';
|
||||||
|
ctx.fill();
|
||||||
|
// A line back to the car, so a glance reads as a direction rather
|
||||||
|
// than as a dot you have to find yourself on the map.
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px(view.x), px(view.z));
|
||||||
|
ctx.lineTo(wx, wz);
|
||||||
|
ctx.strokeStyle = 'rgba(255,58,42,.35)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(wx, wz, 2 + w.settled * 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(224,176,64,${(0.35 + w.settled * 0.65).toFixed(2)})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- A field contact: marked, but not the same as an assigned target ---
|
// --- A field contact: marked, but not the same as an assigned target ---
|
||||||
if (view.opportunity) {
|
if (view.opportunity) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user