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(); 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>(); 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>((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}`; });