Three things people were not doing. They wandered into the road and ambled across four lanes at the pace they cross a courtyard. That quietly made running one over the world's fault rather than yours: if people walk into traffic by themselves, hitting one costs the player nothing. They now keep off the tarmac, and anyone who does need the other side commits and hurries - holding their heading, because dithering in the middle of a road is the one thing nobody does. Measured over four minutes: none of thirty standing in a road, and crossings covered well over half again the ground per second that strolling does. A small chance of committing is deliberate; pure avoidance turns every block into a sealed pen. They only fled the sight of somebody holding a rifle, not the sound of one going off. Gunfire now raises an alarm that outlasts the noise by six seconds, because a crowd that froze the instant the last round was fired reads as a switch rather than as fear. Running from it beats keeping off the road - that is the moment people stop being careful. And traffic drove straight into a parked car and shunted it down the street, which makes every other vehicle read as weather rather than as a driver. Civilians now yield to the player as well as to each other. Parked across a road for ninety seconds: closest approach 9.2m, shoved zero metres. Unless they have just heard shooting, in which case they do not wait behind you. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1114 lines
42 KiB
TypeScript
1114 lines
42 KiB
TypeScript
import * as THREE from 'three';
|
|
import { generateWorld } from './sim/world';
|
|
import {
|
|
applyWear,
|
|
canOverhaul,
|
|
deriveHandling,
|
|
freshCondition,
|
|
overhaul,
|
|
repair,
|
|
surfaceFor,
|
|
} from './sim/car';
|
|
import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
|
|
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
|
|
import { baseAt, placeBases } from './sim/bases';
|
|
import { buildGraph } from './sim/routing';
|
|
import { createIntel, observe, reveal, revealHomeGround, survey } from './sim/intel';
|
|
import {
|
|
applyMissionImpact,
|
|
controlAt,
|
|
createFront,
|
|
stepFront,
|
|
DECAY_MULTIPLIER,
|
|
HEAT_MULTIPLIER,
|
|
} from './sim/regions';
|
|
import { createMinimap } from './ui/minimap';
|
|
import {
|
|
accept,
|
|
createQuests,
|
|
failQuest,
|
|
MISSION_SHAPE,
|
|
offersAt,
|
|
stepQuest,
|
|
type Offer,
|
|
} from './sim/quests';
|
|
import { isWrittenOff, KIA_LAND_LOSS, replacementCar } from './sim/kia';
|
|
import { createRadio, pollRadio } from './sim/radio';
|
|
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
|
import { createHeatProps } from './heatProps';
|
|
import { createUnits, dispatchTo, stepUnits } from './sim/units';
|
|
import { createCombat, dangerNear, stepCombat } from './sim/combat';
|
|
import {
|
|
createPursuit,
|
|
PROVOCATION,
|
|
recognitionMeter,
|
|
stepPursuit,
|
|
LOSE_SECONDS,
|
|
} from './sim/pursuit';
|
|
import { createUnitView } from './render/units';
|
|
import { createUnitBodies } from './unitBodies';
|
|
import { createPersistence } from './persistence';
|
|
import { apply, type Snapshot } from './sim/save';
|
|
import { makeRng, seedFromString } from './core/rng';
|
|
import { startLoop, type LoopHandlers } from './core/loop';
|
|
import { exposeDebug } from './debug';
|
|
import { createInput } from './core/input';
|
|
import { createPhysics } from './physics/physics';
|
|
import { createDriveState, drive } from './physics/drive';
|
|
import { createScene, updateCamera } from './render/scene';
|
|
import { createMarkers } from './render/markers';
|
|
import { createHud } from './ui/hud';
|
|
import { createBoard } from './ui/board';
|
|
import { createAudio } from './audio/audio';
|
|
import { CAR, WHEELS } from './carSpec';
|
|
import { CURRENCY } from './ui/currency';
|
|
|
|
/** What the wheel reads while nobody is holding it. */
|
|
const DEAD_HANDS = {
|
|
throttle: 0,
|
|
steer: 0,
|
|
handbrake: true,
|
|
respawn: false,
|
|
select: null,
|
|
toggleMute: false,
|
|
abandon: false,
|
|
overhaul: false,
|
|
} as const;
|
|
|
|
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
|
|
export const DEBUG = new URLSearchParams(location.search).has('debug');
|
|
|
|
/** How far the driver can see well enough to fill in the map, metres. */
|
|
const SIGHT_RADIUS = 85;
|
|
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
|
|
const RESET_HOLD_SECONDS = 5;
|
|
/**
|
|
* Metres of front line given up for walking away from a job.
|
|
*
|
|
* Half what dying costs (`KIA_LAND_LOSS`). Deciding a route is not worth it is
|
|
* a judgement the game should let you make, but it cannot be free, or the board
|
|
* becomes a reroll button: park, dislike the route, drop it, take another.
|
|
*/
|
|
const ABANDON_LAND_LOSS = 12;
|
|
/**
|
|
* Seconds spent watching the wreck before you come round somewhere else.
|
|
*
|
|
* Long enough to register what happened and where it happened, short enough
|
|
* that it does not become the thing you dread about dying.
|
|
*/
|
|
const KIA_HOLD = 5;
|
|
/**
|
|
* Radians per second the view turns while the camera climbs away from a wreck.
|
|
* Slow: this is a drift across the scene, not an orbit around the car.
|
|
*/
|
|
const WAKE_ORBIT_RATE = 0.22;
|
|
/**
|
|
* Share of the death cam anyone keeps shooting for.
|
|
*
|
|
* The burst that killed you should finish — cutting the gunfire on the exact
|
|
* frame the car dies looks like the sound broke. After that they stop, because
|
|
* standing over a wreck emptying magazines into it reads as the enemy beating a
|
|
* dead horse rather than as a firefight ending.
|
|
*/
|
|
const KIA_CEASE_FIRE = 0.25;
|
|
|
|
function resolveSeed(): number {
|
|
const raw = new URLSearchParams(location.search).get('seed');
|
|
if (!raw) return 1337;
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) ? n >>> 0 : seedFromString(raw);
|
|
}
|
|
|
|
async function boot() {
|
|
const seed = resolveSeed();
|
|
const model = generateWorld(seed);
|
|
const bases = placeBases(model.roads, model.spawn);
|
|
const graph = buildGraph(model.roads);
|
|
const front = createFront(seed, model.extent, model.spawn);
|
|
|
|
const physics = await createPhysics(model);
|
|
const view = createScene(model);
|
|
const markers = createMarkers(view.scene, bases);
|
|
const minimap = createMinimap(model.roads, bases, model.extent);
|
|
|
|
// Base buildings are solid. They are placed off the junction, so this never
|
|
// walls off the road the player has to park on.
|
|
for (const base of bases) {
|
|
physics.addStaticBox({
|
|
x: base.hutX,
|
|
z: base.hutZ,
|
|
yaw: 0,
|
|
width: 7,
|
|
height: 3.4,
|
|
depth: 7,
|
|
});
|
|
}
|
|
|
|
const input = createInput();
|
|
// Audio is built now but stays silent until the browser lets it speak, which
|
|
// is not until the player has actually touched something.
|
|
const audio = createAudio();
|
|
input.onGesture(() => audio.resume());
|
|
const hud = createHud(seed, DEBUG);
|
|
const board = createBoard();
|
|
const driveState = createDriveState();
|
|
const areas = createAreas(model.roads, model.extent);
|
|
const heat = createHeat(model.roads, areas);
|
|
const heatProps = createHeatProps(model.roads, physics, view.scene);
|
|
const intel = createIntel(model.roads, model.extent);
|
|
const quests = createQuests();
|
|
const radio = createRadio();
|
|
const units = createUnits();
|
|
const combat = createCombat();
|
|
const unitView = createUnitView(view.scene);
|
|
const unitBodies = createUnitBodies(physics);
|
|
const pursuit = createPursuit();
|
|
|
|
// 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.
|
|
const BUILDING_CELL = 24;
|
|
const buildingGrid = new Map<string, typeof model.obstacles>();
|
|
const cellOf = (v: number) => Math.floor(v / BUILDING_CELL);
|
|
const cellKey = (x: number, z: number) => `${cellOf(x)},${cellOf(z)}`;
|
|
for (const o of model.obstacles) {
|
|
if (o.kind !== 'block') continue;
|
|
const reach = Math.hypot(o.width, o.depth) / 2;
|
|
// Walk cell indices, not metres. Stepping the world coordinate by
|
|
// BUILDING_CELL only ever took one step, because a building's reach (~7m)
|
|
// is smaller than a cell (24m) — so every building registered exactly one
|
|
// cell and silently vanished from the other three it straddled. Points in
|
|
// those cells read as open ground: bullets flew through the wall, line of
|
|
// sight saw through it, and people stood inside it.
|
|
for (let cx = cellOf(o.x - reach); cx <= cellOf(o.x + reach); cx++) {
|
|
for (let cz = cellOf(o.z - reach); cz <= cellOf(o.z + reach); cz++) {
|
|
const key = `${cx},${cz}`;
|
|
const list = buildingGrid.get(key) ?? [];
|
|
list.push(o);
|
|
buildingGrid.set(key, list);
|
|
}
|
|
}
|
|
}
|
|
const insideBuilding = (x: number, z: number): boolean => {
|
|
for (const o of buildingGrid.get(cellKey(x, z)) ?? []) {
|
|
const dx = x - o.x;
|
|
const dz = z - o.z;
|
|
const cos = Math.cos(-o.yaw);
|
|
const sin = Math.sin(-o.yaw);
|
|
if (Math.abs(dx * cos - dz * sin) < o.width / 2 && Math.abs(dx * sin + dz * cos) < o.depth / 2) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Can one point see another? Marched rather than swept, at a step short
|
|
* enough that the smallest building cannot be stepped over.
|
|
*/
|
|
const canSee = (from: { x: number; z: number }, to: { x: number; z: number }): boolean => {
|
|
const dx = to.x - from.x;
|
|
const dz = to.z - from.z;
|
|
const distance = Math.hypot(dx, dz);
|
|
const steps = Math.ceil(distance / 3);
|
|
for (let i = 1; i < steps; i++) {
|
|
const t = i / steps;
|
|
if (insideBuilding(from.x + dx * t, from.z + dz * t)) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/** Physics bodies for finished checkpoint towers, so they are solid cover. */
|
|
const towerBodies = new Map<number, ReturnType<typeof physics.addStaticBox>>();
|
|
const opportunities = createOpportunities();
|
|
const chatterRng = makeRng(seed ^ 0x2c1b3c6d);
|
|
|
|
let condition = freshCondition();
|
|
let elapsed = 0;
|
|
let respawnLatch = false;
|
|
/** The road being travelled along — includes the verge, not just the tarmac. */
|
|
let currentSegment: number | null = null;
|
|
let onTarmac = false;
|
|
let control = controlAt(front, model.spawn.x, model.spawn.z);
|
|
/** Who holds each road right now. Recomputed each step: the line moves. */
|
|
const segmentControl = model.roads.segments.map(() => control);
|
|
const refreshSegmentControl = () => {
|
|
for (const s of model.roads.segments) {
|
|
segmentControl[s.id] = controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2);
|
|
}
|
|
};
|
|
refreshSegmentControl();
|
|
/** Offers are generated once per arrival at a base, not every frame. */
|
|
let openBase: number | null = null;
|
|
let offers: Offer[] = [];
|
|
let notice = '';
|
|
let noticeUntil = 0;
|
|
let lastMinimapDraw = -1;
|
|
let lastFrontPosition = front.boundaries.liberated;
|
|
/** Smoothed, because a single step's movement is far too noisy to react to. */
|
|
let driftAverage = 0;
|
|
/** Seconds R has been held, toward a full reset. */
|
|
let resetHeld = 0;
|
|
/** People you have killed who were not part of anyone's war. */
|
|
let civilianDeaths = 0;
|
|
/** Suspicion earned outright this step by something the player just did. */
|
|
let provocation = 0;
|
|
/** Times the car has been written off underneath the player. */
|
|
let kia = 0;
|
|
/** Muzzle flashes from last step, so civilians can react to being shot near. */
|
|
let lastGunfire: Array<{ x: number; z: number }> = [];
|
|
/** Seconds left of watching the wreck, or 0 when alive. */
|
|
let dying = 0;
|
|
/** Where the camera has got to in its drift around it. */
|
|
let deathAngle = 0;
|
|
|
|
const say = (text: string, seconds = 4) => {
|
|
notice = text;
|
|
noticeUntil = elapsed + seconds;
|
|
audio.radio();
|
|
};
|
|
|
|
const nodeOf = (id: number) => model.roads.nodes[id]!;
|
|
const controlOf = (x: number, z: number) => controlAt(front, x, z);
|
|
|
|
/** Car yaw about +Y, measured from world +Z, which is the car's forward. */
|
|
const headingOf = (): number => {
|
|
const r = physics.chassis.rotation();
|
|
return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x));
|
|
};
|
|
|
|
// --- Persistence ---
|
|
const persistence = createPersistence(
|
|
seed,
|
|
model.roads.segments.length,
|
|
intel.explored.length,
|
|
heat.area.length,
|
|
);
|
|
|
|
const snapshot = (): Snapshot => {
|
|
const t = physics.chassis.translation();
|
|
const r = physics.chassis.rotation();
|
|
const v = physics.chassis.linvel();
|
|
const w = physics.chassis.angvel();
|
|
return {
|
|
seed,
|
|
elapsed,
|
|
condition,
|
|
car: {
|
|
position: [t.x, t.y, t.z],
|
|
rotation: [r.x, r.y, r.z, r.w],
|
|
linvel: [v.x, v.y, v.z],
|
|
angvel: [w.x, w.y, w.z],
|
|
},
|
|
heat,
|
|
intel,
|
|
front,
|
|
quests,
|
|
kia,
|
|
};
|
|
};
|
|
|
|
const saved = persistence.load();
|
|
if (saved) {
|
|
const live = snapshot();
|
|
apply(saved, live);
|
|
// `apply` writes into the live objects for everything held by reference;
|
|
// the values that are not have to be copied back out by hand.
|
|
elapsed = live.elapsed;
|
|
condition = live.condition;
|
|
kia = live.kia;
|
|
physics.chassis.setTranslation(
|
|
{ x: saved.car.position[0], y: saved.car.position[1], z: saved.car.position[2] },
|
|
true,
|
|
);
|
|
physics.chassis.setRotation(
|
|
{
|
|
x: saved.car.rotation[0],
|
|
y: saved.car.rotation[1],
|
|
z: saved.car.rotation[2],
|
|
w: saved.car.rotation[3],
|
|
},
|
|
true,
|
|
);
|
|
physics.chassis.setLinvel(
|
|
{ x: saved.car.linvel[0], y: saved.car.linvel[1], z: saved.car.linvel[2] },
|
|
true,
|
|
);
|
|
physics.chassis.setAngvel(
|
|
{ x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] },
|
|
true,
|
|
);
|
|
stepFront(front, 0);
|
|
refreshSegmentControl();
|
|
// A save does not record who was standing where, so barricades on roads
|
|
// that were already hot are treated as already built rather than making the
|
|
// player wait for a fresh engineer to be sent out.
|
|
for (const segment of model.roads.segments) {
|
|
if (heat.level[segment.id] === 'barricade' || heat.level[segment.id] === 'turret') {
|
|
heatProps.finished(segment.id);
|
|
}
|
|
}
|
|
} else {
|
|
// A fresh campaign starts knowing its own ground. You are a local with a
|
|
// car, not an amnesiac: the pocket your side holds is the part of the map
|
|
// you would obviously know, and the war is the part you would not.
|
|
revealHomeGround(intel, heat, model.roads, elapsed, controlOf);
|
|
}
|
|
|
|
// Chassis transform at the end of the last two fixed steps, for interpolation.
|
|
const previous = { position: new THREE.Vector3(), rotation: new THREE.Quaternion() };
|
|
const current = { position: new THREE.Vector3(), rotation: new THREE.Quaternion() };
|
|
const captureChassis = () => {
|
|
previous.position.copy(current.position);
|
|
previous.rotation.copy(current.rotation);
|
|
const t = physics.chassis.translation();
|
|
const q = physics.chassis.rotation();
|
|
current.position.set(t.x, t.y, t.z);
|
|
current.rotation.set(q.x, q.y, q.z, q.w);
|
|
};
|
|
// Prime both, so the first frame does not interpolate out of the origin.
|
|
captureChassis();
|
|
captureChassis();
|
|
|
|
/**
|
|
* The moment the car came apart around you.
|
|
*
|
|
* The run does not end here — it ends `KIA_HOLD` seconds later. What happens
|
|
* in between is nothing: you have no control, the camera pulls off the bumper
|
|
* and drifts round the wreck, and the world carries on around it. Cutting
|
|
* straight from the crash to a base three hundred metres away read as a bug
|
|
* rather than as a death, and the one moment in the campaign worth actually
|
|
* looking at was the one moment you were not allowed to see.
|
|
*/
|
|
const beginDeath = () => {
|
|
dying = KIA_HOLD;
|
|
deathAngle = 0;
|
|
// Nobody is chasing a car that is no longer going anywhere, and being shot
|
|
// at while you watch your own wreck is not a thing anyone needs to see.
|
|
for (const unit of units.units) unit.hunting = false;
|
|
pursuit.hunters.clear();
|
|
pursuit.suspicion = 0;
|
|
pursuit.alert = 'clear';
|
|
pursuit.lastSeen = null;
|
|
pursuit.unseenFor = 0;
|
|
audio.impact(26000);
|
|
say('That is the car finished.', KIA_HOLD);
|
|
};
|
|
|
|
/**
|
|
* Coming round somewhere else.
|
|
*
|
|
* You wake up at whichever base was nearest, in a worse car than the one you
|
|
* wrote off, with the job you were carrying written off along with it and the
|
|
* ground it would have won handed back. Nothing about this is announced as a
|
|
* number — the front moving against you is something you find out about by
|
|
* driving back out there.
|
|
*/
|
|
const finishDeath = () => {
|
|
kia++;
|
|
const at = physics.chassis.translation();
|
|
const home = 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,
|
|
);
|
|
|
|
const lost = failQuest(quests);
|
|
// Ground you had taken is given back. Negative impact, through the same
|
|
// path a finished mission uses, so the war moves for one reason only.
|
|
applyMissionImpact(front, -KIA_LAND_LOSS);
|
|
condition = replacementCar(condition);
|
|
|
|
physics.chassis.setTranslation({ x: home.x, y: CAR.spawn.y, z: home.z }, true);
|
|
physics.chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
|
|
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
|
physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
|
captureChassis();
|
|
captureChassis();
|
|
|
|
say(
|
|
lost
|
|
? `You woke up at ${home.name}. The car did not, and neither did the ${MISSION_SHAPE[
|
|
lost.type
|
|
].label.toLowerCase()}.`
|
|
: `You woke up at ${home.name}. Somebody else is driving what is left of the car.`,
|
|
9,
|
|
);
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
};
|
|
|
|
document.getElementById('boot')?.remove();
|
|
|
|
const handlers: LoopHandlers = {
|
|
fixedUpdate(dt) {
|
|
elapsed += dt;
|
|
const raw = input.read();
|
|
|
|
// While the wreck is being looked at, the world carries on and the player
|
|
// does not. Everything below still runs — traffic, the war, the front —
|
|
// because a death cam over a frozen world is a screenshot, not a moment.
|
|
// What stops is the driver.
|
|
const dead = dying > 0;
|
|
const deathProgress = dead ? 1 - dying / KIA_HOLD : 0;
|
|
if (dead) {
|
|
dying -= dt;
|
|
deathAngle += WAKE_ORBIT_RATE * dt;
|
|
if (dying <= 0) {
|
|
dying = 0;
|
|
finishDeath();
|
|
}
|
|
}
|
|
const cmd = dead ? DEAD_HANDS : raw;
|
|
|
|
// Tap R to get unstuck; hold it to wipe the campaign and start over.
|
|
// A reset has to be hard to do by accident — it throws away every mile
|
|
// of wear, every road the enemy has learned, and the whole map you built.
|
|
if (cmd.respawn) {
|
|
resetHeld += dt;
|
|
if (resetHeld >= RESET_HOLD_SECONDS) {
|
|
persistence.clear();
|
|
location.reload();
|
|
return;
|
|
}
|
|
} else {
|
|
resetHeld = 0;
|
|
}
|
|
if (cmd.respawn && !respawnLatch) physics.respawn();
|
|
respawnLatch = cmd.respawn;
|
|
|
|
// Tarmac or open ground — decided last step, since the road lookup needs
|
|
// a position and the car has not moved yet this one.
|
|
drive(physics, driveState, cmd, deriveHandling(condition), dt, surfaceFor(currentSegment !== null));
|
|
physics.step(dt);
|
|
captureChassis();
|
|
|
|
// Wear is applied from what actually happened this step, not from intent.
|
|
const speed = physics.vehicle.currentVehicleSpeed();
|
|
const distance = Math.abs(speed) * dt;
|
|
// Read once: both the car's wear and any cargo aboard are damaged by the
|
|
// same knock, and draining the queue twice would lose one of them.
|
|
if (cmd.toggleMute) say(audio.toggleMute() ? 'Sound off.' : 'Sound on.', 2);
|
|
|
|
const lastImpact = physics.drainImpactForce();
|
|
audio.impact(lastImpact);
|
|
condition = applyWear(condition, {
|
|
dt,
|
|
distance,
|
|
throttle: Math.abs(cmd.throttle),
|
|
impactForce: lastImpact,
|
|
});
|
|
|
|
// Heat: the road remembers being used — including being driven alongside.
|
|
// Behind your own lines it remembers nothing: nobody there is watching.
|
|
// The war moves whether or not the player is looking at it.
|
|
stepFront(front, dt);
|
|
refreshSegmentControl();
|
|
|
|
const at = physics.chassis.translation();
|
|
control = controlAt(front, at.x, at.z);
|
|
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
|
|
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
|
|
const heatChanged = stepHeat(heat, {
|
|
dt,
|
|
segmentId: currentSegment,
|
|
areaId: areaAt(areas, at.x, at.z),
|
|
distance,
|
|
// Who holds the ground, times how conspicuous this class of road is.
|
|
// A farm track behind the lines costs nothing; a trunk past the front
|
|
// is the most watched thing you can drive on.
|
|
accrual:
|
|
HEAT_MULTIPLIER[control] *
|
|
speedAttention(speed) *
|
|
(currentSegment === null
|
|
? 1
|
|
: ROAD_ATTENTION[model.roads.segments[currentSegment]!.cls]),
|
|
// Each road decays according to whoever holds *it*, not whoever holds
|
|
// the ground the car happens to be standing on.
|
|
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
|
|
}, areas);
|
|
heatProps.sync({ x: at.x, z: at.z });
|
|
|
|
// Escalation is entirely something the enemy has to *do*. Every level
|
|
// sends somebody: a patrol to work the road, an engineer to pour the
|
|
// concrete, another to put the tower up. Nothing appears on its own.
|
|
for (const id of heatChanged) {
|
|
const segment = model.roads.segments[id]!;
|
|
const level = heat.level[id]!;
|
|
if (level === 'patrol') {
|
|
dispatchTo(units, model.roads, graph, segment, 'patrol', chatterRng);
|
|
}
|
|
if (level === 'barricade') {
|
|
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'barricade');
|
|
}
|
|
if (level === 'turret') {
|
|
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'tower');
|
|
}
|
|
// Cooled back down: whatever is standing there stops being maintained.
|
|
if (level === 'clear' || level === 'patrol') heatProps.abandon(id);
|
|
}
|
|
// Driving a road is how you learn what is on it, and seeing ground is how
|
|
// it stops being a blank on the map.
|
|
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
|
|
reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf);
|
|
|
|
// A wreck is not parked at a base, whatever the coordinates say.
|
|
const base = dead ? null : baseAt(bases, at.x, at.z);
|
|
const stopped = Math.abs(speed) < 2.5;
|
|
|
|
// --- Overhaul: buying back what the car is capable of ---
|
|
// Offered whether or not there is a job in hand, since a workshop does
|
|
// not care what you are carrying. Deliberately a bad rate next to an
|
|
// ordinary repair — this rebuilds what the car *could* be, which is the
|
|
// expensive thing you do once patching it up has stopped helping.
|
|
if (base && stopped && cmd.overhaul) {
|
|
if (!canOverhaul(condition)) {
|
|
say('Nothing to rebuild. She is as good as she ever was.', 4);
|
|
} else if (quests.funds < 1) {
|
|
say(`Nothing to pay with. Bring something back first.`, 4);
|
|
} else {
|
|
const before = condition.ceiling;
|
|
const done = overhaul(condition, quests.funds);
|
|
const spent = quests.funds - done.unused;
|
|
condition = done.condition;
|
|
quests.funds = done.unused;
|
|
const gained =
|
|
condition.ceiling.engine +
|
|
condition.ceiling.tires +
|
|
condition.ceiling.chassis -
|
|
(before.engine + before.tires + before.chassis);
|
|
// A car that has turned a wheel is never *exactly* at factory, so the
|
|
// sim will happily sell you a hundredth of a percent. Judge by what
|
|
// the player can actually see rather than by what is left to buy.
|
|
if (gained < 0.005) {
|
|
say('Nothing worth stripping her down for. She is near enough new.', 4);
|
|
} else {
|
|
say(
|
|
`Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
|
|
`${(gained * 100).toFixed(0)}% more than she would have.`,
|
|
7,
|
|
);
|
|
audio.ui('accept');
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Quest board: only when parked at a base with nothing in hand ---
|
|
if (base && stopped && !quests.active) {
|
|
if (openBase !== base.nodeId) {
|
|
openBase = base.nodeId;
|
|
// Seeded by base and runs completed, not by time: leaving and coming
|
|
// straight back must not reroll a board you did not like.
|
|
offers = offersAt(
|
|
quests,
|
|
model.roads,
|
|
graph,
|
|
intel,
|
|
base,
|
|
elapsed,
|
|
seed ^ (base.nodeId * 31) ^ (quests.completed * 7919),
|
|
);
|
|
board.show(base, offers);
|
|
audio.ui('open');
|
|
}
|
|
const chosen = cmd.select === null ? undefined : offers[cmd.select - 1];
|
|
if (chosen) {
|
|
accept(quests, chosen, base);
|
|
board.hide();
|
|
openBase = null;
|
|
audio.ui('accept');
|
|
say(`${MISSION_SHAPE[chosen.type].label} accepted.`);
|
|
// Checkpoint: taking a job is a decision worth not having to make twice.
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
} else if (base && stopped && quests.active && quests.active.stage !== 'return') {
|
|
// Parked somewhere friendly with a job you have not started bringing
|
|
// home. There has to be a way out of one: a target can end up behind
|
|
// concrete there is no way round, and the only exit used to be holding
|
|
// R for five seconds, which does not drop the job — it destroys the
|
|
// whole campaign. Not offered on the return leg, because that arrival
|
|
// is a hand-in and stepQuest is about to pay you for it.
|
|
if (openBase !== base.nodeId) {
|
|
openBase = base.nodeId;
|
|
board.showActive(base, MISSION_SHAPE[quests.active.type].label.toLowerCase());
|
|
audio.ui('open');
|
|
}
|
|
if (cmd.abandon) {
|
|
const dropped = failQuest(quests);
|
|
// Walking away is cheaper than dying, and it still costs the war
|
|
// something. Otherwise the board is a free reroll: park, dislike the
|
|
// route, drop it, take another.
|
|
applyMissionImpact(front, -ABANDON_LAND_LOSS);
|
|
board.hide();
|
|
openBase = null;
|
|
audio.ui('open');
|
|
say(
|
|
`${MISSION_SHAPE[dropped!.type].label} dropped. That is ground somebody else has to take back.`,
|
|
6,
|
|
);
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
} else if (openBase !== null) {
|
|
openBase = null;
|
|
board.hide();
|
|
}
|
|
|
|
// --- Active mission ---
|
|
// Not while you are unconscious: a wreck sitting on the target must not
|
|
// quietly finish the job, and stepQuest only asks whether you are stopped.
|
|
const quest = dead ? null : quests.active;
|
|
if (quest) {
|
|
const target = nodeOf(quest.targetNode);
|
|
const event = stepQuest(quests, {
|
|
dt,
|
|
distanceToTarget: Math.hypot(target.x - at.x, target.z - at.z),
|
|
atBase: base,
|
|
speed: Math.abs(speed),
|
|
impactForce: lastImpact,
|
|
});
|
|
|
|
if (event?.kind === 'arrived') {
|
|
intel.visitedNodes.add(quest.targetNode);
|
|
say(`At the target. Stop the car — ${MISSION_SHAPE[quest.type].working}.`);
|
|
} else if (event?.kind === 'cargoDamaged') {
|
|
if (event.cargo < 0.5) say('That was the cargo taking the hit, not you.', 3);
|
|
} else if (event?.kind === 'worked') {
|
|
if (event.type === 'recon') {
|
|
const count = survey(
|
|
intel,
|
|
heat,
|
|
model.roads,
|
|
target.x,
|
|
target.z,
|
|
elapsed,
|
|
undefined,
|
|
controlOf,
|
|
);
|
|
say(`Survey complete — ${count} roads mapped. Report back to any base.`);
|
|
} else if (event.type === 'retrieval') {
|
|
say('Loaded. Get it back in one piece — damaged goods pay less.');
|
|
} else if (event.type === 'intel') {
|
|
say('Transmission sent. Move before anyone triangulates it.');
|
|
} else {
|
|
say('Cargo delivered. Report back to any base.');
|
|
}
|
|
} else if (event?.kind === 'completed') {
|
|
// The line moves because of what the player did. They are not told by
|
|
// how much — they find out by going back and looking.
|
|
applyMissionImpact(front, event.impact);
|
|
const outcome = repair(condition, quests.funds);
|
|
const spent = quests.funds - outcome.unused;
|
|
condition = outcome.condition;
|
|
quests.funds = outcome.unused;
|
|
say(
|
|
spent > 0.5
|
|
? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
|
|
: 'Handed in. Nothing left worth repairing.',
|
|
);
|
|
// Checkpoint: a finished run is the most expensive thing to lose.
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
}
|
|
|
|
// --- Work found rather than given ---
|
|
const found = stepOpportunities(
|
|
opportunities,
|
|
{
|
|
dt,
|
|
now: elapsed,
|
|
control,
|
|
carX: at.x,
|
|
carZ: at.z,
|
|
// Nothing turns up for a man who is not going anywhere, and a wreck
|
|
// must not collect one by virtue of having stopped moving.
|
|
onMission: dead || quests.active !== null,
|
|
},
|
|
model.roads,
|
|
chatterRng,
|
|
stopped && !dead,
|
|
);
|
|
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
|
|
else if (found?.kind === 'helped') {
|
|
quests.funds += found.opportunity.reward;
|
|
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
|
|
say(found.opportunity.done, 7);
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
|
|
// --- The inhabited world ---
|
|
const unitEvents = stepUnits(
|
|
units,
|
|
{
|
|
dt,
|
|
now: elapsed,
|
|
player: { x: at.x, z: at.z },
|
|
front,
|
|
heatLevel: (id) => heat.level[id]!,
|
|
// A patrol working its road is what actually brings the heat down.
|
|
decayHeat: (id, amount) => {
|
|
heat.value[id] = Math.max(0, heat.value[id]! - amount);
|
|
},
|
|
decayArea: (x, z, amount) => {
|
|
const cell = areaAt(areas, x, z);
|
|
if (cell !== null) heat.area[cell] = Math.max(0, heat.area[cell]! - amount);
|
|
},
|
|
// Head for the player if they are in view, otherwise for wherever
|
|
// they were last seen — which is what makes breaking line of sight
|
|
// and then changing direction actually work.
|
|
hunt:
|
|
pursuit.alert === 'hunted'
|
|
? (pursuit.lastSeen ?? { x: at.x, z: at.z })
|
|
: null,
|
|
// Hunters come round the buildings, not through them. Line of sight
|
|
// is already blocked by them; movement has to be too, or "break line
|
|
// of sight and change direction" is advice the world does not honour.
|
|
blocked: insideBuilding,
|
|
// Shooting is resolved further down, so this is last step's. People
|
|
// hear it and then move, which is the right way round anyway.
|
|
gunfire: lastGunfire,
|
|
},
|
|
model.roads,
|
|
graph,
|
|
chatterRng,
|
|
);
|
|
|
|
// Whatever an engineer just finished now exists in the world.
|
|
for (const { segment, stage } of unitEvents.built) {
|
|
if (stage === 'barricade') {
|
|
heatProps.finished(segment);
|
|
say('They have put concrete across that road.', 6);
|
|
continue;
|
|
}
|
|
const site = units.checkpoints.get(segment);
|
|
if (!site || towerBodies.has(segment)) continue;
|
|
towerBodies.set(
|
|
segment,
|
|
physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }),
|
|
);
|
|
say('They have finished the tower on that road.', 6);
|
|
}
|
|
for (const id of unitEvents.removed) {
|
|
heatProps.abandon(id);
|
|
const body = towerBodies.get(id);
|
|
if (!body) continue;
|
|
physics.removeBody(body);
|
|
towerBodies.delete(id);
|
|
}
|
|
|
|
// --- Hitting things ---
|
|
// Vehicles are solid; people are not, so you drive through them rather
|
|
// than getting hung up on them.
|
|
const contact = unitBodies.sync(units, { x: at.x, z: at.z }, speed, dt);
|
|
for (const victim of contact.ranOver) {
|
|
audio.impact(26000);
|
|
civilianDeaths += victim.faction === 'civilian' ? 1 : 0;
|
|
provocation +=
|
|
victim.faction === 'civilian'
|
|
? PROVOCATION.ranOverCivilian
|
|
: PROVOCATION.ranOverSoldier;
|
|
say(
|
|
victim.faction === 'civilian'
|
|
? 'You just put someone under the wheels.'
|
|
: 'One of theirs, under the wheels.',
|
|
4,
|
|
);
|
|
}
|
|
if (contact.rammed.length > 0) {
|
|
provocation += PROVOCATION.ram * contact.rammed.length;
|
|
if (elapsed > noticeUntil) say('Metal on metal. Somebody noticed that.', 3);
|
|
}
|
|
|
|
// --- Being noticed ---
|
|
// Not while you are dead. Enemies standing over the wreck would otherwise
|
|
// build suspicion on it all over again and re-recognise a car that is not
|
|
// going anywhere, which is how they ended up shooting at it for five
|
|
// solid seconds.
|
|
const noticed = dead
|
|
? []
|
|
: stepPursuit(pursuit, {
|
|
dt,
|
|
player: { x: at.x, z: at.z },
|
|
control,
|
|
units,
|
|
canSee,
|
|
provocation,
|
|
});
|
|
for (const event of noticed) {
|
|
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
|
|
if (event.kind === 'lost') say('You lost them.', 5);
|
|
if (event.kind === 'distracted' && event.remaining > 0) {
|
|
say('Ours have pulled some of them off you.', 4);
|
|
}
|
|
}
|
|
provocation = 0;
|
|
|
|
// --- Shooting ---
|
|
// They shoot at you because they have recognised you, not because of the
|
|
// road you happen to be on. Being hunted *is* the state of being a target.
|
|
// While dying, exposure runs off the clock rather than off the meter: the
|
|
// shooting tails off shortly after the car does.
|
|
const exposed = dead ? deathProgress < KIA_CEASE_FIRE : pursuit.alert === 'hunted';
|
|
|
|
const shooting = stepCombat(
|
|
combat,
|
|
units,
|
|
{
|
|
dt,
|
|
player: { x: at.x, z: at.z },
|
|
playerExposed: exposed,
|
|
blocked: insideBuilding,
|
|
// Nobody fires at what they cannot see. Rounds already stopped at
|
|
// walls; without this the muzzle flashes still came from inside them.
|
|
canSee,
|
|
},
|
|
condition,
|
|
chatterRng,
|
|
);
|
|
condition = shooting.condition;
|
|
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
|
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
|
lastGunfire = shooting.fired;
|
|
if (shooting.playerHit) {
|
|
provocation += PROVOCATION.shotAt;
|
|
audio.hit();
|
|
if (elapsed > noticeUntil) say('Taking fire.', 2.5);
|
|
}
|
|
|
|
// --- The end of a car ---
|
|
// Checked here rather than beside `applyWear`, because being shot at
|
|
// damages the chassis too and either route has to be able to finish you.
|
|
if (!dead && isWrittenOff(condition)) beginDeath();
|
|
|
|
// --- Radio ---
|
|
// Front drift is measured, not narrated: the chatter about the enemy
|
|
// being busy elsewhere fires because the line really is moving.
|
|
const drift = (front.boundaries.liberated - lastFrontPosition) / dt;
|
|
lastFrontPosition = front.boundaries.liberated;
|
|
driftAverage += (drift - driftAverage) * Math.min(1, dt / 3);
|
|
if (!notice || elapsed > noticeUntil) {
|
|
const line = pollRadio(
|
|
radio,
|
|
{
|
|
now: elapsed,
|
|
control,
|
|
roadClass: currentSegment === null ? null : model.roads.segments[currentSegment]!.cls,
|
|
heatLevel: currentSegment === null ? null : heat.level[currentSegment]!,
|
|
condition,
|
|
frontDrift: driftAverage,
|
|
cargo: quests.active?.cargo ?? null,
|
|
carrying: quests.active?.stage === 'return',
|
|
},
|
|
chatterRng,
|
|
);
|
|
if (line) say(line, 8);
|
|
}
|
|
|
|
// Continuous audio follows the sim, not the frame rate, so it is driven
|
|
// from the fixed step like everything else that has to stay consistent.
|
|
const wheels = physics.telemetry();
|
|
audio.update({
|
|
speed,
|
|
throttle: cmd.throttle,
|
|
condition,
|
|
sideSlip: wheels.sideSlip,
|
|
wheelsOnGround: wheels.wheelsOnGround,
|
|
control,
|
|
listener: { x: at.x, z: at.z, heading: headingOf() },
|
|
});
|
|
|
|
persistence.tick(elapsed, snapshot);
|
|
|
|
// Once the cargo is dropped or the survey is done, the target is no longer
|
|
// anywhere the player needs to be — the bases' own beacons take over.
|
|
const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null;
|
|
markers.setObjective(heading ? nodeOf(heading.targetNode) : null);
|
|
// The minimap has always drawn field contacts; now the world does too.
|
|
markers.setOpportunity(opportunities.current);
|
|
},
|
|
|
|
render(alpha, frameDt) {
|
|
// Interpolate between the last two physics steps.
|
|
//
|
|
// The display almost never lines up with a 60Hz fixed step, so some frames
|
|
// consume two steps and some none. Drawing the raw latest transform makes
|
|
// that land as visible stutter at speed even when the framerate is fine.
|
|
// Blending by the leftover accumulator hides it.
|
|
view.car.position.lerpVectors(previous.position, current.position, alpha);
|
|
view.car.quaternion.slerpQuaternions(previous.rotation, current.rotation, alpha);
|
|
const p = view.car.position;
|
|
const r = view.car.quaternion;
|
|
|
|
for (let i = 0; i < WHEELS.length; i++) {
|
|
const pivot = view.wheels[i]!;
|
|
const suspension =
|
|
physics.vehicle.wheelSuspensionLength(i) ?? CAR.wheel.suspensionRestLength;
|
|
pivot.position.y = CAR.wheel.offsetY - suspension;
|
|
pivot.rotation.y = physics.vehicle.wheelSteering(i) ?? 0;
|
|
pivot.children[0]!.rotation.x = physics.vehicle.wheelRotation(i) ?? 0;
|
|
}
|
|
|
|
// Static blocks are instanced and never move; only crates need syncing.
|
|
for (const { index, mesh } of view.crates) {
|
|
const body = physics.obstacleBodies[index]!;
|
|
const t = body.translation();
|
|
const q = body.rotation();
|
|
mesh.position.set(t.x, t.y, t.z);
|
|
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
|
}
|
|
|
|
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
|
|
view.followSun();
|
|
view.setTone(control, frameDt);
|
|
markers.update(elapsed);
|
|
updateCamera(
|
|
view,
|
|
frameDt,
|
|
physics.vehicle.currentVehicleSpeed(),
|
|
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
|
|
);
|
|
view.renderer.render(view.scene, view.camera);
|
|
|
|
const quest = quests.active;
|
|
const target = quest ? nodeOf(quest.targetNode) : null;
|
|
|
|
// The map is a sketch that changes slowly; redrawing every cell of it at
|
|
// display rate is wasted work in the same frame budget as the shadow pass.
|
|
if (elapsed - lastMinimapDraw > 0.1) {
|
|
lastMinimapDraw = elapsed;
|
|
minimap.draw(intel, {
|
|
now: elapsed,
|
|
x: p.x,
|
|
z: p.z,
|
|
// Yaw about +Y, from world +Z, which is the car's local forward.
|
|
heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
|
|
objective: quest && quest.stage !== 'return' ? target : null,
|
|
opportunity: opportunities.current,
|
|
});
|
|
}
|
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
|
heat: {
|
|
segmentId: currentSegment,
|
|
onTarmac,
|
|
value: currentSegment === null ? 0 : effectiveHeat(heat, areas, currentSegment),
|
|
area: heat.area[areaAt(areas, p.x, p.z) ?? 0] ?? 0,
|
|
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
|
},
|
|
quest:
|
|
quest && target
|
|
? {
|
|
type: quest.type,
|
|
stage: quest.stage,
|
|
novel: quest.novel,
|
|
worked: quest.worked,
|
|
cargo: quest.cargo,
|
|
distance: Math.hypot(target.x - p.x, target.z - p.z),
|
|
bearing:
|
|
Math.atan2(target.x - p.x, target.z - p.z) -
|
|
Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
|
|
}
|
|
: null,
|
|
control,
|
|
pursuit: {
|
|
meter: recognitionMeter(pursuit),
|
|
alert: pursuit.alert,
|
|
hunters: pursuit.hunters.size,
|
|
// Counts down only once nobody has eyes on you.
|
|
losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0,
|
|
},
|
|
danger: dangerNear(combat, p.x, p.z, 90),
|
|
completed: quests.completed,
|
|
failed: quests.failed,
|
|
kia,
|
|
funds: quests.funds,
|
|
notice: elapsed < noticeUntil ? notice : '',
|
|
resetProgress: resetHeld / RESET_HOLD_SECONDS,
|
|
muted: audio.muted,
|
|
});
|
|
},
|
|
};
|
|
|
|
startLoop(handlers);
|
|
|
|
if (DEBUG) {
|
|
exposeDebug({
|
|
handlers,
|
|
view,
|
|
physics,
|
|
// Live state, so a script can find something interesting and go look at it.
|
|
state: {
|
|
units,
|
|
combat,
|
|
heat,
|
|
areas,
|
|
intel,
|
|
quests,
|
|
front,
|
|
model,
|
|
bases,
|
|
pursuit,
|
|
opportunities,
|
|
},
|
|
condition: () => condition,
|
|
/**
|
|
* Force the car's condition, for looking at how a wrecked car drives
|
|
* without having to spend twenty minutes wrecking one.
|
|
*/
|
|
setCondition(level: number) {
|
|
const clamped = Math.max(0, Math.min(1, level));
|
|
condition = {
|
|
level: { engine: clamped, tires: clamped, chassis: clamped },
|
|
// Raising the ceiling too, so this can undo damage as well as cause
|
|
// it. It is a debug lever, not a repair: nothing in the game does this.
|
|
ceiling: {
|
|
engine: Math.max(clamped, condition.ceiling.engine),
|
|
tires: Math.max(clamped, condition.ceiling.tires),
|
|
chassis: Math.max(clamped, condition.ceiling.chassis),
|
|
},
|
|
};
|
|
return condition;
|
|
},
|
|
funds: () => quests.funds,
|
|
/** Set the money in hand. Nothing in the game hands out money like this. */
|
|
setFunds(amount: number) {
|
|
quests.funds = Math.max(0, amount);
|
|
return quests.funds;
|
|
},
|
|
teleport(x, z) {
|
|
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
|
|
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
|
physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
|
},
|
|
info: () => ({
|
|
elapsed,
|
|
control,
|
|
segment: currentSegment,
|
|
onTarmac,
|
|
funds: quests.funds,
|
|
completed: quests.completed,
|
|
failed: quests.failed,
|
|
kia,
|
|
condition,
|
|
front: front.boundaries,
|
|
opportunity: opportunities.current,
|
|
dying: +dying.toFixed(2),
|
|
alert: pursuit.alert,
|
|
suspicion: +pursuit.suspicion.toFixed(2),
|
|
hunters: pursuit.hunters.size,
|
|
civilianDeaths,
|
|
unitBodies: unitBodies.count,
|
|
units: units.units.length,
|
|
roles: units.units.reduce<Record<string, number>>((counts, u) => {
|
|
counts[u.role] = (counts[u.role] ?? 0) + 1;
|
|
return counts;
|
|
}, {}),
|
|
rounds: combat.rounds.length,
|
|
checkpoints: units.checkpoints.size,
|
|
notice,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
boot().catch((err) => {
|
|
console.error(err);
|
|
const boot = document.getElementById('boot');
|
|
if (boot) boot.textContent = `failed to start: ${err}`;
|
|
});
|