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 { createPersistence } from './persistence'; import { apply, type Snapshot } from './sim/save'; import { makeRng, seedFromString } from './core/rng'; import { startLoop } from './core/loop'; 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 { CAR, WHEELS } from './carSpec'; /** How far the driver can see well enough to fill in the map, metres. */ const SIGHT_RADIUS = 85; 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(); 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 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; const say = (text: string, seconds = 4) => { notice = text; noticeUntil = elapsed + seconds; }; const nodeOf = (id: number) => model.roads.nodes[id]!; const controlOf = (x: number, z: number) => controlAt(front, x, z); // --- 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, ); // Rebuild whatever the restored heat implies, so checkpoints are standing // where the save says they are rather than appearing as you drive past. stepFront(front, 0); refreshSegmentControl(); heatProps.sync( model.roads.segments.map((s) => s.id), heat, null, ); } // 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(); startLoop({ fixedUpdate(dt) { elapsed += dt; const cmd = input.read(); 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. const lastImpact = physics.drainImpactForce(); 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; heatProps.sync( 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]!], }), heat, currentSegment, ); // 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); } const chosen = cmd.select === null ? undefined : offers[cmd.select - 1]; if (chosen) { accept(quests, chosen, base); board.hide(); openBase = null; 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()); } // --- 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); } 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); } 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, completed: quests.completed, parts: quests.parts, notice: elapsed < noticeUntil ? notice : '', }); }, }); } boot().catch((err) => { console.error(err); const boot = document.getElementById('boot'); if (boot) boot.textContent = `failed to start: ${err}`; });