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>
235 lines
8.4 KiB
TypeScript
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}`;
|
|
});
|