import * as THREE from 'three'; import { generateWorld } from './sim/world'; import { applyWear, deriveHandling, freshCondition, repair } from './sim/car'; import { createHeat, 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, survey } from './sim/intel'; import { applyMissionImpact, controlAt, createFront, stepFront, DECAY_MULTIPLIER, HEAT_MULTIPLIER, } from './sim/regions'; import { createMinimap } from './ui/minimap'; import { accept, createQuests, MISSION_SHAPE, offersAt, stepQuest, type Offer, } from './sim/quests'; 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 { 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'; /** 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; 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); const board = createBoard(); const driveState = createDriveState(); const heat = createHeat(model.roads); 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); // 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 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; }; /** 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; 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); 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, }; }; 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; 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); } } } // 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(); document.getElementById('boot')?.remove(); const handlers: LoopHandlers = { fixedUpdate(dt) { elapsed += dt; const cmd = input.read(); // 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; drive(physics, driveState, cmd, deriveHandling(condition), dt); 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, 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] * (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]!], }); 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); const base = baseAt(bases, at.x, at.z); const stopped = Math.abs(speed) < 2.5; // --- 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 (openBase !== null) { openBase = null; board.hide(); } // --- Active mission --- const quest = 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, onMission: quests.active !== null, }, model.roads, chatterRng, stopped, ); 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); }, }, 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; say( victim.faction === 'civilian' ? 'You just put someone under the wheels.' : 'One of theirs, under the wheels.', 4, ); } if (contact.rammed.length > 0 && elapsed > noticeUntil) { say('Metal on metal. Somebody noticed that.', 3); } // --- Shooting --- // The enemy shoots at the player only once a road has been noticed enough // to be checkpointed, and only past the line. Undercover means undercover. const exposed = (control === 'occupied' || control === 'frontier') && currentSegment !== null && (heat.level[currentSegment] === 'barricade' || heat.level[currentSegment] === 'turret'); 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) { audio.hit(); if (elapsed > noticeUntil) say('Taking fire.', 2.5); } // --- 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()); 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 : heat.value[currentSegment]!, 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, danger: dangerNear(combat, p.x, p.z, 90), completed: quests.completed, 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, intel, quests, front, model, bases }, 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, condition, front: front.boundaries, opportunity: opportunities.current, 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}`; });