drive-between-the-lines/src/main.ts
dejvino 06c7555188 Phase 5: four real jobs, radio, and work found in the field
Mission types. Four of them, differing in pacing and risk rather than in the
noun on the board: a quick supply drop, a retrieval whose danger is all on the
return leg, a long stationary intel transmission in the open, and a cheap survey
whose payoff is the map. Retrieval carries cargo that impacts damage, and pays
for what arrives rather than what you set off with, which makes the drive home a
different problem from the drive out.

Radio. Chatter framing the player as an undercover driver being talked at by
people busy elsewhere. Every line is triggered by something actually happening
in the sim — crossing a border, a road you personally made notorious, a car past
saving, or the front genuinely drifting. The enemy-has-other-priorities line
fires off measured drift, so the brief's ambient-drift-is-the-mechanism idea is
said out loud without ever showing a number. Topics carry cooldowns and never
repeat a sentence back to back; a test caught that they could.

Field work. Past the line, with nothing in hand, a wounded stranger or a contact
or a strippable wreck occasionally turns up. Deliberately not on a board: no
route preview, no comparison, no shopping between them. Take the detour in front
of you or drive on. They never appear behind your own lines, where a safe errand
would defeat the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:35:37 +02:00

496 lines
18 KiB
TypeScript

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