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 a campaign worth actually looking at was the one moment the player was not allowed to see it. The run now ends five seconds after the car does. Control goes; the camera leaves the bumper and climbs away to sixty metres, turning slowly, until the street, whatever was hit and whoever is standing around watching are all in frame. The point of the shot is not the car - by then the car is a fact - it is *where* this happened, which is the thing you have to drive back out to. The world keeps running underneath it. A death cam over a frozen world is a screenshot, not a moment. What stops is the driver: no throttle, no board, no overhaul, and no quietly finishing the job by virtue of having stopped moving on top of the target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1059 lines
40 KiB
TypeScript
1059 lines
40 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';
|
|
|
|
/** 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;
|
|
|
|
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 cellKey = (x: number, z: number) =>
|
|
`${Math.floor(x / BUILDING_CELL)},${Math.floor(z / BUILDING_CELL)}`;
|
|
for (const o of model.obstacles) {
|
|
if (o.kind !== 'block') continue;
|
|
const reach = Math.hypot(o.width, o.depth) / 2;
|
|
for (let x = o.x - reach; x <= o.x + reach; x += BUILDING_CELL) {
|
|
for (let z = o.z - reach; z <= o.z + reach; z += BUILDING_CELL) {
|
|
const key = cellKey(x, z);
|
|
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;
|
|
/** 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;
|
|
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.parts < 0.05) {
|
|
say('No parts to work with. Bring something back first.', 4);
|
|
} else {
|
|
const before = condition.ceiling;
|
|
const done = overhaul(condition, quests.parts);
|
|
const spent = quests.parts - done.unused;
|
|
condition = done.condition;
|
|
quests.parts = 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. ${spent.toFixed(2)} parts, 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.parts);
|
|
const spent = quests.parts - outcome.unused;
|
|
condition = outcome.condition;
|
|
quests.parts = outcome.unused;
|
|
say(
|
|
spent > 0.001
|
|
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
|
|
: '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.parts += 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,
|
|
},
|
|
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 ---
|
|
for (const event of stepPursuit(pursuit, {
|
|
dt,
|
|
player: { x: at.x, z: at.z },
|
|
control,
|
|
units,
|
|
canSee,
|
|
provocation,
|
|
})) {
|
|
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.
|
|
const exposed = pursuit.alert === 'hunted';
|
|
|
|
const shooting = stepCombat(
|
|
combat,
|
|
units,
|
|
{
|
|
dt,
|
|
player: { x: at.x, z: at.z },
|
|
playerExposed: exposed,
|
|
blocked: insideBuilding,
|
|
},
|
|
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);
|
|
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);
|
|
},
|
|
|
|
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);
|
|
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,
|
|
parts: quests.parts,
|
|
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 },
|
|
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;
|
|
},
|
|
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,
|
|
parts: quests.parts,
|
|
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}`;
|
|
});
|