drive-between-the-lines/src/main.ts
dejvino 73e8ac68f1 Phase 2: bases, quest board, and known-vs-new targets
Four allied bases, placed by greedy farthest-point so no choke point can strand
the player, with the first at the spawn junction so the loop is available at
once. Missions hand in at any base, not the issuing one.

The board offers known and new targets side by side and describes each route in
words. Crucially it never reads heat directly — it reads sim/intel.ts, a record
of what the player has actually observed and when. Notes age while roads keep
escalating unwatched, so a known route is knowable, stale, and never a promise.
Recon missions survey everything within 130m, which is how a new target becomes
a known one without driving every road there.

New targets pay 1.8x. That multiplier is the phase's tuning dial: too low and
nobody takes the unknown, too high and nobody takes the known.

Payment is parts, which repair the car. To keep "decline, not reset" intact,
each subsystem gains a ceiling that falls permanently with damage — repairs
restore up to what the car is still capable of, never to what it was. Without an
economy the board's rewards had no stakes; with one, the risky job is what keeps
the car running a while longer.

Adds Dijkstra over the road graph, taking a cost function so a "safest route"
preview is a small change later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:06:32 +02:00

235 lines
8.4 KiB
TypeScript

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