import { generateWorld } from './sim/world'; import { applyWear, deriveHandling, freshCondition, repair } from './sim/car'; import { createHeat, stepHeat } from './sim/heat'; import { routeAt, segmentAt } from './sim/roads'; import { baseAt, placeBases } from './sim/bases'; import { buildGraph } from './sim/routing'; import { createIntel, observe, survey } from './sim/intel'; import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests'; import { createHeatProps } from './heatProps'; import { 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'; 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 physics = await createPhysics(model); const view = createScene(model); const markers = createMarkers(view.scene, bases); 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); const quests = createQuests(); 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; /** 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; const say = (text: string, seconds = 4) => { notice = text; noticeUntil = elapsed + seconds; }; const nodeOf = (id: number) => model.roads.nodes[id]!; 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); // Wear is applied from what actually happened this step, not from intent. const speed = physics.vehicle.currentVehicleSpeed(); const distance = Math.abs(speed) * dt; condition = applyWear(condition, { dt, distance, throttle: Math.abs(cmd.throttle), impactForce: physics.drainImpactForce(), }); // Heat: the road remembers being used — including being driven alongside. const at = physics.chassis.translation(); 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 }), heat, currentSegment, ); // Driving a road is how you learn what is on it. if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed); 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(`${chosen.type === 'recon' ? 'Survey' : 'Supply run'} accepted.`); } } 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), }); if (event?.kind === 'arrived') { intel.visitedNodes.add(quest.targetNode); say('At the target. Stop the car and hold position.'); } else if (event?.kind === 'worked') { if (event.type === 'recon') { const count = survey(intel, heat, model.roads, target.x, target.z, elapsed); say(`Survey complete — ${count} roads mapped. Report back to any base.`); } else { say('Cargo delivered. Report back to any base.'); } } else if (event?.kind === 'completed') { 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.', ); } } markers.setObjective(quests.active ? nodeOf(quests.active.targetNode) : null); }, render(_alpha, frameDt) { // No transform interpolation yet: at a 60 Hz fixed step it is not yet // worth the bookkeeping. Revisit if the step rate ever drops. const p = physics.chassis.translation(); const r = physics.chassis.rotation(); view.car.position.set(p.x, p.y, p.z); view.car.quaternion.set(r.x, r.y, r.z, r.w); 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 never move; only the crates need syncing. for (let i = 0; i < view.obstacles.length; i++) { if (model.obstacles[i]!.kind !== 'crate') continue; const body = physics.obstacleBodies[i]!; const t = body.translation(); const q = body.rotation(); const mesh = view.obstacles[i]!; mesh.position.set(t.x, t.y, t.z); mesh.quaternion.set(q.x, q.y, q.z, q.w); } view.followSun(); 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; 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 ? { type: quest.type, stage: quest.stage, novel: quest.novel, worked: quest.worked, distance: target ? Math.hypot(target.x - p.x, target.z - p.z) : 0, } : null, 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}`; });