Phase 1: road network and heat
Roads are a jittered 7x7 grid with ~a quarter of the edges thinned out, kept connected. The loops are the point: "take a different road" is not a decision without alternative routes. Every metre driven adds heat to that segment; all roads shed it slowly. Crossing a threshold escalates Clear -> Patrol -> Barricade -> Turret, each of which puts real obstructions on the tarmac. Hysteresis stops a road on a boundary from rebuilding its barricade every few seconds. Level changes are deferred while the car is on the segment. Building a barricade around the player would spawn a static collider inside the chassis, and the player is meant to discover escalation by returning to a road, not by watching it assemble behind them. Checkpoint positions are seeded from the segment id, so a stretch of road always fortifies in the same place — recognising it is part of learning the map. Scenery now avoids the tarmac and the car spawns at a junction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a66032674d
commit
506d07be5d
70
README.md
70
README.md
@ -1,8 +1,9 @@
|
||||
# Drive Between the Lines — Phase 0 prototype
|
||||
# Drive Between the Lines — prototype
|
||||
|
||||
Endless driving survival game. This is the **Phase 0** skeleton from the roadmap:
|
||||
a drivable car on a flat plate with scattered obstacles, plus the persistent wear
|
||||
system that makes condition felt through the wheel.
|
||||
Endless driving survival game.
|
||||
|
||||
- **Phase 0** — a drivable car with persistent wear that is felt through the wheel.
|
||||
- **Phase 1** — a road network whose roads remember being driven, and escalate.
|
||||
|
||||
## Running
|
||||
|
||||
@ -25,6 +26,38 @@ npm test # sim + headless physics
|
||||
npm run build # typecheck + production bundle
|
||||
```
|
||||
|
||||
## Road heat
|
||||
|
||||
Every metre driven on a road adds heat to that segment; every road everywhere
|
||||
sheds it slowly. Cross a threshold and the road escalates:
|
||||
|
||||
`Clear → Patrol → Barricade → Turret`
|
||||
|
||||
- **Patrol** parks a vehicle on the verge. The road narrows.
|
||||
- **Barricade** puts concrete across it, leaving a gap you have to slow for.
|
||||
- **Turret** adds a tower overlooking the checkpoint.
|
||||
|
||||
Roughly four traversals take a road from clear to turret; an untouched road
|
||||
cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
|
||||
guesses meant to be tuned.
|
||||
|
||||
Three things are deliberate:
|
||||
|
||||
- **No AI yet.** The props are stationary hazards. Escalation currently means the
|
||||
road gets slower and more expensive to get wrong, which is enough to test the
|
||||
phase's real question.
|
||||
- **Changes never happen on the road you are on.** They are deferred until you
|
||||
leave. Otherwise a barricade would spawn a static collider inside your car —
|
||||
and you are supposed to discover escalation by *coming back*, not by watching
|
||||
it assemble behind you.
|
||||
- **A checkpoint's position is seeded from its segment**, so a given stretch of
|
||||
road always fortifies in the same place. Recognising it is part of learning
|
||||
the map.
|
||||
|
||||
The road network is a jittered grid with roughly a quarter of its edges removed,
|
||||
keeping the whole thing connected. The loops are the point: "take a different
|
||||
road this time" is not a decision unless alternative routes exist.
|
||||
|
||||
## Layout
|
||||
|
||||
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
|
||||
@ -35,14 +68,20 @@ ever opening a browser.
|
||||
|
||||
```
|
||||
src/
|
||||
sim/ pure model — world generation, car condition → handling
|
||||
sim/ pure model — roads, heat, world generation, condition → handling
|
||||
physics/ Rapier world, raycast vehicle, input → wheel forces
|
||||
render/ three.js scene, chase camera
|
||||
render/ three.js scene, roads, chase camera
|
||||
core/ fixed-timestep loop, seeded RNG, keyboard
|
||||
ui/ debug HUD
|
||||
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
|
||||
heatProps.ts integration layer: heat levels → colliders + meshes
|
||||
```
|
||||
|
||||
`heatProps.ts` sits at the top level on purpose — it is the one module allowed to
|
||||
touch both Rapier and three.js, because it owns objects that must exist in both
|
||||
or neither. Its tests run headless: three.js scene graphs work fine in Node, so
|
||||
"the barricade's collider was removed along with its mesh" is a unit test.
|
||||
|
||||
Data flows one way: `sim` → `physics` → `render`. Condition reaches the physics
|
||||
layer already digested into a `Handling` by `deriveHandling`, so there is exactly
|
||||
one place where "how broken the car is" turns into "how it drives".
|
||||
@ -56,8 +95,13 @@ one place where "how broken the car is" turns into "how it drives".
|
||||
rather than hours. Turn them down in `applyWear` once the curve reads right.
|
||||
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
||||
rate changes.
|
||||
- **Condition is not yet persisted** across reloads. IndexedDB comes with the
|
||||
campaign layer.
|
||||
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
|
||||
with the campaign layer.
|
||||
- **Heat has no diegetic signal at a distance.** You learn a road is hot by
|
||||
arriving at the checkpoint. The brief wants it readable from patrol density and
|
||||
wreckage before you commit — that needs the enemy presence Phase 5 brings.
|
||||
- **The heat HUD lines are debug scaffolding.** The brief is explicit that no
|
||||
numeric heat meter ships; they exist to tune the curve and should come out.
|
||||
- **Bundle:** ~2.7 MB raw / ~945 KB gzipped, dominated by Rapier's WASM, which
|
||||
`rapier3d-compat` inlines as base64. Switching to the non-compat `@dimforge/rapier3d`
|
||||
package serves the WASM as a separate file (~570 KB gzipped, compiled in
|
||||
@ -66,5 +110,11 @@ one place where "how broken the car is" turns into "how it drives".
|
||||
|
||||
## Next
|
||||
|
||||
Phase 0's gate: drive for 15–20 minutes, feel the condition changing how the car
|
||||
handles, and want to keep driving anyway. Everything else waits on that.
|
||||
Phase 1's gate: **you catch yourself avoiding a road because of its history, not
|
||||
its distance.** That cannot be checked by a test — it needs you driving the same
|
||||
routes for a while and noticing what you start doing.
|
||||
|
||||
If it fails, the likely culprits, in order: escalation is too slow to matter
|
||||
within a session (`METRES_PER_HEAT`), decay is so fast that nothing accumulates
|
||||
(`DECAY_PER_SECOND`), or the props are not actually inconvenient enough to route
|
||||
around. Tune before building Phase 2 on top.
|
||||
|
||||
@ -20,7 +20,8 @@ export const CAR = {
|
||||
offsetY: -0.15,
|
||||
suspensionRestLength: 0.32,
|
||||
},
|
||||
spawn: { x: 0, y: 1.2, z: 0 },
|
||||
/** Only the drop height; the horizontal spawn comes from the world model. */
|
||||
spawn: { y: 1.2 },
|
||||
} as const;
|
||||
|
||||
/** Wheel order used everywhere: front-left, front-right, rear-left, rear-right. */
|
||||
|
||||
71
src/heatProps.test.ts
Normal file
71
src/heatProps.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import { createHeatProps } from './heatProps';
|
||||
import { createPhysics } from './physics/physics';
|
||||
import { createHeat, stepHeat } from './sim/heat';
|
||||
import { generateWorld } from './sim/world';
|
||||
|
||||
/**
|
||||
* three.js scene graphs work without a browser (only WebGLRenderer needs one),
|
||||
* so the prop lifecycle can be checked end to end — including that removing a
|
||||
* barricade removes its collider, not just its mesh.
|
||||
*/
|
||||
describe('heat props lifecycle', () => {
|
||||
it('builds and tears down colliders and meshes together', async () => {
|
||||
const world = generateWorld(3, 0);
|
||||
const physics = await createPhysics(world);
|
||||
const scene = new THREE.Scene();
|
||||
const props = createHeatProps(world.roads, physics, scene);
|
||||
const heat = createHeat(world.roads);
|
||||
|
||||
const baseBodies = physics.rapier.bodies.len();
|
||||
const baseMeshes = scene.children.length;
|
||||
|
||||
const drive = (metres: number) => {
|
||||
for (let m = 0; m < metres; m++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
||||
}
|
||||
};
|
||||
const idle = (seconds: number) => {
|
||||
for (let i = 0; i < seconds * 60; i++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
||||
}
|
||||
};
|
||||
|
||||
drive(400);
|
||||
expect(heat.level[0]).toBe('turret');
|
||||
// Still on the road, so nothing has been built around the car yet.
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
|
||||
// Leaving it lets the deferred work land.
|
||||
idle(1);
|
||||
const hotBodies = physics.rapier.bodies.len();
|
||||
expect(hotBodies).toBeGreaterThan(baseBodies);
|
||||
expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies);
|
||||
|
||||
// Left alone, the road should give everything back.
|
||||
idle(300);
|
||||
expect(heat.level[0]).toBe('clear');
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
expect(scene.children.length).toBe(baseMeshes);
|
||||
});
|
||||
|
||||
it('does not leak bodies when a road escalates through every level', async () => {
|
||||
const world = generateWorld(3, 0);
|
||||
const physics = await createPhysics(world);
|
||||
const scene = new THREE.Scene();
|
||||
const props = createHeatProps(world.roads, physics, scene);
|
||||
const heat = createHeat(world.roads);
|
||||
const baseBodies = physics.rapier.bodies.len();
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
for (let m = 0; m < 400; m++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
||||
}
|
||||
for (let i = 0; i < 300 * 60; i++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
||||
}
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
}
|
||||
});
|
||||
});
|
||||
87
src/heatProps.ts
Normal file
87
src/heatProps.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Turns heat levels into things that physically exist on the road.
|
||||
*
|
||||
* This is an integration layer: it is allowed to touch both Rapier and three.js,
|
||||
* which is why it sits outside src/sim/. The sim decides *what* should be there
|
||||
* ({@link propsFor}); this only builds and tears it down.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type RAPIER from '@dimforge/rapier3d-compat';
|
||||
import { propsFor, type HeatProp, type HeatState } from './sim/heat';
|
||||
import type { RoadNetwork } from './sim/roads';
|
||||
import type { PhysicsWorld } from './physics/physics';
|
||||
|
||||
const MATERIALS: Record<HeatProp['kind'], THREE.MeshStandardMaterial> = {
|
||||
patrol: new THREE.MeshStandardMaterial({ color: 0x4b5540, roughness: 0.7 }),
|
||||
barricade: new THREE.MeshStandardMaterial({ color: 0x9aa0a4, roughness: 0.95 }),
|
||||
tower: new THREE.MeshStandardMaterial({ color: 0x54493d, roughness: 0.9 }),
|
||||
};
|
||||
|
||||
const BOX = new THREE.BoxGeometry(1, 1, 1);
|
||||
|
||||
interface Placed {
|
||||
bodies: RAPIER.RigidBody[];
|
||||
meshes: THREE.Mesh[];
|
||||
}
|
||||
|
||||
export function createHeatProps(
|
||||
roads: RoadNetwork,
|
||||
physics: PhysicsWorld,
|
||||
scene: THREE.Scene,
|
||||
) {
|
||||
const placed = new Map<number, Placed>();
|
||||
/** Level changes waiting for the player to get off the road in question. */
|
||||
const pending = new Set<number>();
|
||||
|
||||
const clear = (segmentId: number) => {
|
||||
const existing = placed.get(segmentId);
|
||||
if (!existing) return;
|
||||
for (const body of existing.bodies) physics.removeBody(body);
|
||||
for (const mesh of existing.meshes) scene.remove(mesh);
|
||||
placed.delete(segmentId);
|
||||
};
|
||||
|
||||
const build = (segmentId: number, heat: HeatState) => {
|
||||
const segment = roads.segments[segmentId]!;
|
||||
const props = propsFor(segment, heat.level[segmentId]!);
|
||||
if (props.length === 0) return;
|
||||
|
||||
const entry: Placed = { bodies: [], meshes: [] };
|
||||
for (const prop of props) {
|
||||
entry.bodies.push(physics.addStaticBox(prop));
|
||||
|
||||
const mesh = new THREE.Mesh(BOX, MATERIALS[prop.kind]);
|
||||
mesh.scale.set(prop.width, prop.height, prop.depth);
|
||||
mesh.position.set(prop.x, prop.height / 2, prop.z);
|
||||
mesh.rotation.y = prop.yaw;
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
scene.add(mesh);
|
||||
entry.meshes.push(mesh);
|
||||
}
|
||||
placed.set(segmentId, entry);
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Rebuild the segments whose level changed — but never the one the car is
|
||||
* currently on. A barricade appearing around the player would spawn a static
|
||||
* collider inside the chassis, and watching a checkpoint assemble itself in
|
||||
* the mirror would break the fiction anyway. Changes wait until they are
|
||||
* out of sight, which is how the player is meant to find them: by returning.
|
||||
*
|
||||
* Call every step, not only when something changed, so deferred work drains.
|
||||
*/
|
||||
sync(changedSegmentIds: readonly number[], heat: HeatState, occupiedSegmentId: number | null) {
|
||||
for (const id of changedSegmentIds) pending.add(id);
|
||||
if (pending.size === 0) return;
|
||||
|
||||
for (const id of [...pending]) {
|
||||
if (id === occupiedSegmentId) continue;
|
||||
pending.delete(id);
|
||||
clear(id);
|
||||
build(id, heat);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
21
src/main.ts
21
src/main.ts
@ -1,5 +1,8 @@
|
||||
import { generateWorld } from './sim/world';
|
||||
import { applyWear, deriveHandling, freshCondition } from './sim/car';
|
||||
import { createHeat, stepHeat } from './sim/heat';
|
||||
import { segmentAt } from './sim/roads';
|
||||
import { createHeatProps } from './heatProps';
|
||||
import { seedFromString } from './core/rng';
|
||||
import { startLoop } from './core/loop';
|
||||
import { createInput } from './core/input';
|
||||
@ -25,10 +28,13 @@ async function boot() {
|
||||
const input = createInput();
|
||||
const hud = createHud(seed);
|
||||
const driveState = createDriveState();
|
||||
const heat = createHeat(model.roads);
|
||||
const heatProps = createHeatProps(model.roads, physics, view.scene);
|
||||
|
||||
let condition = freshCondition();
|
||||
let elapsed = 0;
|
||||
let respawnLatch = false;
|
||||
let currentSegment: number | null = null;
|
||||
|
||||
document.getElementById('boot')?.remove();
|
||||
|
||||
@ -45,12 +51,18 @@ async function boot() {
|
||||
|
||||
// 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: Math.abs(speed) * dt,
|
||||
distance,
|
||||
throttle: Math.abs(cmd.throttle),
|
||||
impactForce: physics.drainImpactForce(),
|
||||
});
|
||||
|
||||
// Heat: the road under the wheels remembers being used.
|
||||
const at = physics.chassis.translation();
|
||||
currentSegment = segmentAt(model.roads, at.x, at.z)?.id ?? null;
|
||||
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
|
||||
},
|
||||
|
||||
render(_alpha, frameDt) {
|
||||
@ -84,7 +96,12 @@ async function boot() {
|
||||
view.followSun();
|
||||
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
|
||||
view.renderer.render(view.scene, view.camera);
|
||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed);
|
||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
||||
segmentId: currentSegment,
|
||||
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
||||
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
||||
hottest: Math.max(...heat.value),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,7 +10,9 @@ const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: fa
|
||||
|
||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||
async function run(input: Partial<DriverInput>, seconds: number) {
|
||||
const physics = await createPhysics(generateWorld(1, 0));
|
||||
const world = generateWorld(1, 0);
|
||||
const physics = await createPhysics(world);
|
||||
const start = physics.chassis.translation();
|
||||
const state = createDriveState();
|
||||
const handling = deriveHandling(freshCondition());
|
||||
const cmd = { ...IDLE, ...input };
|
||||
@ -21,8 +23,10 @@ async function run(input: Partial<DriverInput>, seconds: number) {
|
||||
physics.step(STEP);
|
||||
maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y));
|
||||
}
|
||||
const now = physics.chassis.translation();
|
||||
return {
|
||||
pos: physics.chassis.translation(),
|
||||
// Displacement from the spawn point, which is a road junction, not the origin.
|
||||
pos: { x: now.x - start.x, y: now.y, z: now.z - start.z },
|
||||
speed: physics.vehicle.currentVehicleSpeed(),
|
||||
maxYawRate,
|
||||
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),
|
||||
|
||||
@ -13,6 +13,18 @@ export interface PhysicsWorld {
|
||||
drainImpactForce(): number;
|
||||
step(dt: number): void;
|
||||
respawn(): void;
|
||||
/** Static box, added and removed at runtime as road heat rises and falls. */
|
||||
addStaticBox(box: StaticBox): RAPIER.RigidBody;
|
||||
removeBody(body: RAPIER.RigidBody): void;
|
||||
}
|
||||
|
||||
export interface StaticBox {
|
||||
x: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Contacts weaker than this are just kerb-scrubbing, not damage. */
|
||||
@ -55,7 +67,7 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
||||
// --- Car chassis ---
|
||||
const chassis = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.dynamic()
|
||||
.setTranslation(CAR.spawn.x, CAR.spawn.y, CAR.spawn.z)
|
||||
.setTranslation(model.spawn.x, CAR.spawn.y, model.spawn.z)
|
||||
.setLinearDamping(0.1)
|
||||
.setAngularDamping(0.4)
|
||||
// The mass comes from here, not from collider density, so the centre of mass
|
||||
@ -127,8 +139,25 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
||||
return v;
|
||||
},
|
||||
|
||||
addStaticBox(box) {
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.fixed()
|
||||
.setTranslation(box.x, box.height / 2, box.z)
|
||||
.setRotation({ x: 0, y: Math.sin(box.yaw / 2), z: 0, w: Math.cos(box.yaw / 2) }),
|
||||
);
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(box.width / 2, box.height / 2, box.depth / 2).setFriction(0.8),
|
||||
body,
|
||||
);
|
||||
return body;
|
||||
},
|
||||
|
||||
removeBody(body) {
|
||||
world.removeRigidBody(body);
|
||||
},
|
||||
|
||||
respawn() {
|
||||
chassis.setTranslation({ x: CAR.spawn.x, y: CAR.spawn.y, z: CAR.spawn.z }, true);
|
||||
chassis.setTranslation({ x: model.spawn.x, y: CAR.spawn.y, z: model.spawn.z }, true);
|
||||
chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
|
||||
chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||
|
||||
@ -60,6 +60,27 @@ export function createScene(model: WorldModel): SceneView {
|
||||
grid.position.y = 0.02;
|
||||
scene.add(grid);
|
||||
|
||||
// --- Roads ---
|
||||
const roadMat = new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 1 });
|
||||
const quad = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
|
||||
for (const s of model.roads.segments) {
|
||||
const strip = new THREE.Mesh(quad, roadMat);
|
||||
strip.scale.set(s.width, 1, s.length);
|
||||
strip.position.set((s.ax + s.bx) / 2, 0.03, (s.az + s.bz) / 2);
|
||||
strip.rotation.y = Math.atan2(s.bx - s.ax, s.bz - s.az);
|
||||
strip.receiveShadow = true;
|
||||
scene.add(strip);
|
||||
}
|
||||
// Discs fill the wedge-shaped gaps where segments meet at an angle.
|
||||
const junction = new THREE.CircleGeometry(1, 20).rotateX(-Math.PI / 2);
|
||||
for (const n of model.roads.nodes) {
|
||||
const disc = new THREE.Mesh(junction, roadMat);
|
||||
disc.scale.setScalar(4.5);
|
||||
disc.position.set(n.x, 0.031, n.z);
|
||||
disc.receiveShadow = true;
|
||||
scene.add(disc);
|
||||
}
|
||||
|
||||
// --- Obstacles ---
|
||||
const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 });
|
||||
const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 });
|
||||
|
||||
147
src/sim/heat.test.ts
Normal file
147
src/sim/heat.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateRoads, segmentAt, distanceToRoad, projectOntoSegment } from './roads';
|
||||
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
|
||||
import { generateWorld } from './world';
|
||||
|
||||
const roads = generateRoads(99, 220);
|
||||
|
||||
describe('road network', () => {
|
||||
it('is reproducible and connected', () => {
|
||||
expect(generateRoads(99, 220)).toEqual(roads);
|
||||
// Every node must be reachable, or part of the map is unusable.
|
||||
const adjacency = new Map<number, number[]>();
|
||||
for (const s of roads.segments) {
|
||||
adjacency.set(s.a, [...(adjacency.get(s.a) ?? []), s.b]);
|
||||
adjacency.set(s.b, [...(adjacency.get(s.b) ?? []), s.a]);
|
||||
}
|
||||
const seen = new Set([roads.nodes[0]!.id]);
|
||||
const stack = [roads.nodes[0]!.id];
|
||||
while (stack.length) {
|
||||
for (const next of adjacency.get(stack.pop()!) ?? []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
expect(seen.size).toBe(roads.nodes.length);
|
||||
});
|
||||
|
||||
it('offers alternative routes, not just a tree', () => {
|
||||
// More edges than nodes-1 means the graph contains loops — which is the
|
||||
// whole premise of "take a different road this time".
|
||||
expect(roads.segments.length).toBeGreaterThan(roads.nodes.length);
|
||||
});
|
||||
|
||||
it('locates the segment under a point on it, and none off it', () => {
|
||||
const s = roads.segments[3]!;
|
||||
const mid = { x: (s.ax + s.bx) / 2, z: (s.az + s.bz) / 2 };
|
||||
expect(segmentAt(roads, mid.x, mid.z)?.id).toBe(s.id);
|
||||
expect(projectOntoSegment(s, mid.x, mid.z).t).toBeCloseTo(0.5, 5);
|
||||
expect(distanceToRoad(roads, mid.x, mid.z)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('world', () => {
|
||||
it('spawns the car on the network and keeps scenery off the tarmac', () => {
|
||||
const world = generateWorld(5);
|
||||
expect(segmentAt(world.roads, world.spawn.x, world.spawn.z)).not.toBeNull();
|
||||
for (const o of world.obstacles) {
|
||||
const reach = Math.hypot(o.width, o.depth) / 2;
|
||||
expect(distanceToRoad(world.roads, o.x, o.z)).toBeGreaterThan(reach);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Drive `metres` along one segment, then idle for `idleSeconds`. */
|
||||
function simulate(state: HeatState, segmentId: number, metres: number, idleSeconds = 0) {
|
||||
const changed: number[] = [];
|
||||
const stepMetres = 1;
|
||||
for (let m = 0; m < metres; m += stepMetres) {
|
||||
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId, distance: stepMetres }));
|
||||
}
|
||||
for (let i = 0; i < idleSeconds * 60; i++) {
|
||||
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId: null, distance: 0 }));
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
describe('heat', () => {
|
||||
it('escalates through every level as a road is reused', () => {
|
||||
const state = createHeat(roads);
|
||||
const seen: string[] = [];
|
||||
for (let trip = 0; trip < 6; trip++) {
|
||||
simulate(state, 0, 80);
|
||||
seen.push(state.level[0]!);
|
||||
}
|
||||
expect(seen).toContain('patrol');
|
||||
expect(seen).toContain('barricade');
|
||||
expect(seen).toContain('turret');
|
||||
});
|
||||
|
||||
it('leaves unused roads alone', () => {
|
||||
const state = createHeat(roads);
|
||||
simulate(state, 0, 300);
|
||||
expect(state.value[0]).toBeGreaterThan(0.5);
|
||||
expect(state.value[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('cools a road that goes unused', () => {
|
||||
const state = createHeat(roads);
|
||||
simulate(state, 0, 300);
|
||||
const hot = state.value[0]!;
|
||||
simulate(state, 1, 0, 120);
|
||||
expect(state.value[0]).toBeLessThan(hot - 0.4);
|
||||
});
|
||||
|
||||
it('does not flap between levels while hovering on a threshold', () => {
|
||||
const state = createHeat(roads);
|
||||
// Park heat just above the patrol threshold, then hold it there.
|
||||
simulate(state, 0, 90);
|
||||
expect(state.level[0]).toBe('patrol');
|
||||
let flips = 0;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
// Roughly cancel the decay, so heat sits on the boundary.
|
||||
const changed = stepHeat(state, { dt: 1 / 60, segmentId: 0, distance: 0.022 });
|
||||
flips += changed.length;
|
||||
}
|
||||
expect(flips).toBe(0);
|
||||
});
|
||||
|
||||
it('hysteresis means a level survives a small dip below its threshold', () => {
|
||||
expect(levelFor(0.24, 'clear')).toBe('clear');
|
||||
expect(levelFor(0.25, 'clear')).toBe('patrol');
|
||||
expect(levelFor(0.2, 'patrol')).toBe('patrol');
|
||||
expect(levelFor(0.16, 'patrol')).toBe('clear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('heat props', () => {
|
||||
const segment = roads.segments[0]!;
|
||||
|
||||
it('puts nothing on a clear road and escalates from there', () => {
|
||||
expect(propsFor(segment, 'clear')).toEqual([]);
|
||||
const counts = (['patrol', 'barricade', 'turret'] as const).map(
|
||||
(l) => propsFor(segment, l).length,
|
||||
);
|
||||
expect(counts[0]).toBeLessThan(counts[1]!);
|
||||
expect(counts[1]).toBeLessThan(counts[2]!);
|
||||
});
|
||||
|
||||
it('is stable for a segment, so a checkpoint stays where you left it', () => {
|
||||
expect(propsFor(segment, 'turret')).toEqual(propsFor(segment, 'turret'));
|
||||
});
|
||||
|
||||
it('leaves a gap in the barricade rather than sealing the road', () => {
|
||||
const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade');
|
||||
const spanned = blocks.reduce((sum, b) => sum + b.width, 0);
|
||||
expect(spanned).toBeLessThan(segment.width - 3);
|
||||
});
|
||||
|
||||
it('keeps patrols and barricades within the road, and towers beside it', () => {
|
||||
for (const prop of propsFor(segment, 'turret')) {
|
||||
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
|
||||
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
|
||||
else expect(distance).toBeLessThan(segment.width / 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
142
src/sim/heat.ts
Normal file
142
src/sim/heat.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Road heat. Pure — no three.js, no Rapier.
|
||||
*
|
||||
* "Reuse has consequences": every metre driven on a road makes that road worse,
|
||||
* and roads left alone quietly recover. The player is never shown a number — the
|
||||
* escalation is meant to be read off what is physically sitting in the road.
|
||||
*/
|
||||
import { makeRng, randRange } from '../core/rng';
|
||||
import { pointOnSegment, type RoadNetwork, type RoadSegment } from './roads';
|
||||
|
||||
export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const;
|
||||
export type HeatLevel = (typeof HEAT_LEVELS)[number];
|
||||
|
||||
/** Heat at which each level takes hold. */
|
||||
const THRESHOLDS: Record<Exclude<HeatLevel, 'clear'>, number> = {
|
||||
patrol: 0.25,
|
||||
barricade: 0.55,
|
||||
turret: 0.85,
|
||||
};
|
||||
|
||||
/**
|
||||
* A level, once established, holds until heat drops this far below its threshold.
|
||||
* Without it a road sitting on a boundary rebuilds and dismantles its barricade
|
||||
* every few seconds.
|
||||
*/
|
||||
const HYSTERESIS = 0.08;
|
||||
|
||||
/** Metres of driving that add a full point of heat. ~4 traversals to turret. */
|
||||
const METRES_PER_HEAT = 330;
|
||||
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
|
||||
const DECAY_PER_SECOND = 0.004;
|
||||
|
||||
export interface HeatState {
|
||||
/** Indexed by segment id. */
|
||||
value: number[];
|
||||
level: HeatLevel[];
|
||||
}
|
||||
|
||||
export function createHeat(roads: RoadNetwork): HeatState {
|
||||
return {
|
||||
value: new Array(roads.segments.length).fill(0),
|
||||
level: new Array(roads.segments.length).fill('clear'),
|
||||
};
|
||||
}
|
||||
|
||||
export function levelFor(heat: number, current: HeatLevel): HeatLevel {
|
||||
const rank = HEAT_LEVELS.indexOf(current);
|
||||
let next: HeatLevel = 'clear';
|
||||
for (const name of ['patrol', 'barricade', 'turret'] as const) {
|
||||
// Rising uses the plain threshold; falling has to clear the hysteresis band.
|
||||
const isCurrentOrLower = HEAT_LEVELS.indexOf(name) <= rank;
|
||||
const bar = THRESHOLDS[name] - (isCurrentOrLower ? HYSTERESIS : 0);
|
||||
if (heat >= bar) next = name;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface HeatStep {
|
||||
dt: number;
|
||||
/** Segment the car is on right now, or null if off-road. */
|
||||
segmentId: number | null;
|
||||
/** Metres travelled this step. */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** Advances heat and returns the segments whose *level* changed. */
|
||||
export function stepHeat(state: HeatState, step: HeatStep): number[] {
|
||||
const changed: number[] = [];
|
||||
const decay = DECAY_PER_SECOND * step.dt;
|
||||
|
||||
for (let id = 0; id < state.value.length; id++) {
|
||||
let heat = state.value[id]! - decay;
|
||||
if (id === step.segmentId) heat += step.distance / METRES_PER_HEAT;
|
||||
heat = Math.min(1, Math.max(0, heat));
|
||||
state.value[id] = heat;
|
||||
|
||||
const next = levelFor(heat, state.level[id]!);
|
||||
if (next !== state.level[id]) {
|
||||
state.level[id] = next;
|
||||
changed.push(id);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export interface HeatProp {
|
||||
x: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
kind: 'patrol' | 'barricade' | 'tower';
|
||||
}
|
||||
|
||||
/**
|
||||
* What a given heat level physically puts on a road.
|
||||
*
|
||||
* There is no enemy AI yet, so these are stationary hazards: escalation shows up
|
||||
* as the road getting harder to drive fast and more expensive to get wrong. That
|
||||
* is enough to test the phase's real question — do you start avoiding a road
|
||||
* because of its history?
|
||||
*/
|
||||
export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
|
||||
if (level === 'clear') return [];
|
||||
|
||||
// Seeded off the segment so a road's checkpoint is always in the same place —
|
||||
// recognising a specific stretch is part of learning the map.
|
||||
const rng = makeRng((segment.id + 1) * 0x2545f491);
|
||||
const props: HeatProp[] = [];
|
||||
const half = segment.width / 2;
|
||||
|
||||
// Patrol: parked on the verge. Narrows the road, does not block it.
|
||||
const patrolT = randRange(rng, 0.3, 0.7);
|
||||
const patrolSide = rng() < 0.5 ? -1 : 1;
|
||||
const patrol = pointOnSegment(segment, patrolT, patrolSide * (half - 1.4));
|
||||
props.push({ ...patrol, width: 2, height: 1.6, depth: 4.4, kind: 'patrol' });
|
||||
|
||||
if (level === 'patrol') return props;
|
||||
|
||||
// Barricade: blocks across the road with a gap you have to slow down for.
|
||||
const barricadeT = randRange(rng, 0.35, 0.65);
|
||||
const gapSide = rng() < 0.5 ? -1 : 1;
|
||||
const gapCentre = gapSide * randRange(rng, half * 0.35, half * 0.6);
|
||||
for (const side of [-1, 1]) {
|
||||
const inner = gapCentre + side * 2.1;
|
||||
const outer = side * half;
|
||||
const blockWidth = Math.abs(outer - inner);
|
||||
if (blockWidth < 0.8) continue;
|
||||
const at = pointOnSegment(segment, barricadeT, (inner + outer) / 2);
|
||||
props.push({ ...at, width: blockWidth, height: 1.5, depth: 1.6, kind: 'barricade' });
|
||||
}
|
||||
|
||||
if (level === 'barricade') return props;
|
||||
|
||||
// Turret: a tower overlooking the checkpoint. Inert for now — it is a landmark
|
||||
// that says "this road has been noticed", and something to collide with.
|
||||
const tower = pointOnSegment(segment, barricadeT + 0.06, (rng() < 0.5 ? -1 : 1) * (half + 2.5));
|
||||
props.push({ ...tower, width: 3, height: 6, depth: 3, kind: 'tower' });
|
||||
|
||||
return props;
|
||||
}
|
||||
178
src/sim/roads.ts
Normal file
178
src/sim/roads.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* The road network. Pure data — no three.js, no Rapier.
|
||||
*
|
||||
* A jittered grid with some edges removed. The grid matters: the central choice
|
||||
* of the game is "reuse this road or take another one", which only exists if
|
||||
* there are genuinely alternative routes between the same two places. Removed
|
||||
* edges give the network character; the connectivity check stops removal from
|
||||
* stranding a corner of the map.
|
||||
*/
|
||||
import { makeRng, randRange, type Rng } from '../core/rng';
|
||||
|
||||
export interface RoadNode {
|
||||
id: number;
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface RoadSegment {
|
||||
id: number;
|
||||
a: number;
|
||||
b: number;
|
||||
ax: number;
|
||||
az: number;
|
||||
bx: number;
|
||||
bz: number;
|
||||
length: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface RoadNetwork {
|
||||
nodes: RoadNode[];
|
||||
segments: RoadSegment[];
|
||||
}
|
||||
|
||||
const GRID = 7;
|
||||
const ROAD_WIDTH = 9;
|
||||
/** Fraction of grid spacing a node may wander from its lattice point. */
|
||||
const JITTER = 0.3;
|
||||
/** Share of edges to try to remove, budget permitting. */
|
||||
const THINNING = 0.28;
|
||||
|
||||
export function generateRoads(seed: number, extent: number): RoadNetwork {
|
||||
const rng: Rng = makeRng(seed ^ 0x9e3779b9);
|
||||
const spacing = (extent * 2) / (GRID - 1);
|
||||
|
||||
const nodes: RoadNode[] = [];
|
||||
for (let row = 0; row < GRID; row++) {
|
||||
for (let col = 0; col < GRID; col++) {
|
||||
nodes.push({
|
||||
id: row * GRID + col,
|
||||
x: -extent + col * spacing + randRange(rng, -1, 1) * spacing * JITTER,
|
||||
z: -extent + row * spacing + randRange(rng, -1, 1) * spacing * JITTER,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Full lattice first, then thin it out.
|
||||
const edges: Array<[number, number]> = [];
|
||||
for (let row = 0; row < GRID; row++) {
|
||||
for (let col = 0; col < GRID; col++) {
|
||||
const i = row * GRID + col;
|
||||
if (col + 1 < GRID) edges.push([i, i + 1]);
|
||||
if (row + 1 < GRID) edges.push([i, i + GRID]);
|
||||
}
|
||||
}
|
||||
|
||||
const order = shuffle(edges.map((_, i) => i), rng);
|
||||
const removed = new Set<number>();
|
||||
const budget = Math.floor(edges.length * THINNING);
|
||||
for (const idx of order) {
|
||||
if (removed.size >= budget) break;
|
||||
removed.add(idx);
|
||||
// A road that cuts the map in two is worse than a boring one.
|
||||
if (!isConnected(nodes.length, edges, removed)) removed.delete(idx);
|
||||
}
|
||||
|
||||
const segments: RoadSegment[] = [];
|
||||
edges.forEach((edge, idx) => {
|
||||
if (removed.has(idx)) return;
|
||||
const a = nodes[edge[0]]!;
|
||||
const b = nodes[edge[1]]!;
|
||||
segments.push({
|
||||
id: segments.length,
|
||||
a: a.id,
|
||||
b: b.id,
|
||||
ax: a.x,
|
||||
az: a.z,
|
||||
bx: b.x,
|
||||
bz: b.z,
|
||||
length: Math.hypot(b.x - a.x, b.z - a.z),
|
||||
width: ROAD_WIDTH,
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, segments };
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], rng: Rng): T[] {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[items[i], items[j]] = [items[j]!, items[i]!];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function isConnected(
|
||||
nodeCount: number,
|
||||
edges: Array<[number, number]>,
|
||||
removed: ReadonlySet<number>,
|
||||
): boolean {
|
||||
const adjacency: number[][] = Array.from({ length: nodeCount }, () => []);
|
||||
edges.forEach(([a, b], idx) => {
|
||||
if (removed.has(idx)) return;
|
||||
adjacency[a]!.push(b);
|
||||
adjacency[b]!.push(a);
|
||||
});
|
||||
|
||||
const seen = new Uint8Array(nodeCount);
|
||||
const stack = [0];
|
||||
seen[0] = 1;
|
||||
let visited = 1;
|
||||
while (stack.length) {
|
||||
for (const next of adjacency[stack.pop()!]!) {
|
||||
if (seen[next]) continue;
|
||||
seen[next] = 1;
|
||||
visited++;
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
return visited === nodeCount;
|
||||
}
|
||||
|
||||
/** Squared distance from a point to a segment, plus how far along it fell (0..1). */
|
||||
export function projectOntoSegment(
|
||||
s: RoadSegment,
|
||||
x: number,
|
||||
z: number,
|
||||
): { distance: number; t: number } {
|
||||
const dx = s.bx - s.ax;
|
||||
const dz = s.bz - s.az;
|
||||
const lenSq = dx * dx + dz * dz;
|
||||
const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - s.ax) * dx + (z - s.az) * dz) / lenSq));
|
||||
return { distance: Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)), t };
|
||||
}
|
||||
|
||||
/** Nearest segment the point is actually *on*, or null if off-road. */
|
||||
export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment | null {
|
||||
let best: RoadSegment | null = null;
|
||||
let bestDistance = Infinity;
|
||||
for (const s of roads.segments) {
|
||||
const { distance } = projectOntoSegment(s, x, z);
|
||||
if (distance < s.width / 2 && distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Distance to the nearest road surface, used to keep scenery off the tarmac. */
|
||||
export function distanceToRoad(roads: RoadNetwork, x: number, z: number): number {
|
||||
let best = Infinity;
|
||||
for (const s of roads.segments) {
|
||||
best = Math.min(best, projectOntoSegment(s, x, z).distance - s.width / 2);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A point a fraction `t` along the segment, offset `side` metres to its left. */
|
||||
export function pointOnSegment(s: RoadSegment, t: number, side: number) {
|
||||
const dx = (s.bx - s.ax) / s.length;
|
||||
const dz = (s.bz - s.az) / s.length;
|
||||
return {
|
||||
x: s.ax + (s.bx - s.ax) * t + dz * side,
|
||||
z: s.az + (s.bz - s.az) * t - dx * side,
|
||||
yaw: Math.atan2(s.bx - s.ax, s.bz - s.az),
|
||||
};
|
||||
}
|
||||
@ -12,8 +12,9 @@ describe('world generation', () => {
|
||||
});
|
||||
|
||||
it('keeps the spawn point clear', () => {
|
||||
for (const o of generateWorld(7).obstacles) {
|
||||
expect(Math.hypot(o.x, o.z)).toBeGreaterThanOrEqual(12);
|
||||
const world = generateWorld(7);
|
||||
for (const o of world.obstacles) {
|
||||
expect(Math.hypot(o.x - world.spawn.x, o.z - world.spawn.z)).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
* of engine imports is what makes those systems unit-testable and fast-forwardable.
|
||||
*/
|
||||
import { makeRng, randRange, type Rng } from '../core/rng';
|
||||
import { distanceToRoad, generateRoads, type RoadNetwork } from './roads';
|
||||
|
||||
export interface Obstacle {
|
||||
x: number;
|
||||
@ -23,24 +24,36 @@ export interface WorldModel {
|
||||
seed: number;
|
||||
/** Half-width of the drivable plate, in metres. */
|
||||
extent: number;
|
||||
roads: RoadNetwork;
|
||||
obstacles: Obstacle[];
|
||||
/** Where the car starts: a junction, so the network is reachable immediately. */
|
||||
spawn: { x: number; z: number };
|
||||
}
|
||||
|
||||
const SPAWN_CLEARANCE = 12;
|
||||
/** Scenery this close to tarmac would read as a roadblock. Heat places those. */
|
||||
const ROAD_CLEARANCE = 2.5;
|
||||
|
||||
export function generateWorld(seed: number, count = 140, extent = 220): WorldModel {
|
||||
export function generateWorld(seed: number, count = 200, extent = 220): WorldModel {
|
||||
const rng: Rng = makeRng(seed);
|
||||
const roads = generateRoads(seed, extent);
|
||||
const obstacles: Obstacle[] = [];
|
||||
|
||||
while (obstacles.length < count) {
|
||||
const spawnNode = roads.nodes.reduce((best, n) =>
|
||||
Math.hypot(n.x, n.z) < Math.hypot(best.x, best.z) ? n : best,
|
||||
);
|
||||
const spawn = { x: spawnNode.x, z: spawnNode.z };
|
||||
|
||||
let attempts = 0;
|
||||
while (obstacles.length < count && attempts < count * 40) {
|
||||
attempts++;
|
||||
const x = randRange(rng, -extent, extent);
|
||||
const z = randRange(rng, -extent, extent);
|
||||
// Leave the player's spawn point clear so the car never starts inside a wall.
|
||||
if (Math.hypot(x, z) < SPAWN_CLEARANCE) continue;
|
||||
if (Math.hypot(x - spawn.x, z - spawn.z) < SPAWN_CLEARANCE) continue;
|
||||
|
||||
const crate = rng() < 0.45;
|
||||
obstacles.push(
|
||||
crate
|
||||
const obstacle: Obstacle = crate
|
||||
? {
|
||||
x,
|
||||
z,
|
||||
@ -58,9 +71,15 @@ export function generateWorld(seed: number, count = 140, extent = 220): WorldMod
|
||||
depth: randRange(rng, 1.5, 5),
|
||||
yaw: rng() * Math.PI,
|
||||
kind: 'block',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Roads have to stay drivable — obstruction is heat's job, not scenery's.
|
||||
// Measured from the box's corner, since it may be rotated any which way.
|
||||
const reach = Math.hypot(obstacle.width, obstacle.depth) / 2;
|
||||
if (distanceToRoad(roads, x, z) < ROAD_CLEARANCE + reach) continue;
|
||||
|
||||
obstacles.push(obstacle);
|
||||
}
|
||||
|
||||
return { seed, extent, obstacles };
|
||||
return { seed, extent, roads, obstacles, spawn };
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { CarCondition } from '../sim/car';
|
||||
import type { HeatLevel } from '../sim/heat';
|
||||
|
||||
const BAR_WIDTH = 12;
|
||||
|
||||
@ -7,12 +8,19 @@ function bar(value: number): string {
|
||||
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled);
|
||||
}
|
||||
|
||||
export interface HeatReadout {
|
||||
segmentId: number | null;
|
||||
value: number;
|
||||
level: HeatLevel | null;
|
||||
hottest: number;
|
||||
}
|
||||
|
||||
export function createHud(seed: number) {
|
||||
const el = document.getElementById('hud')!;
|
||||
let last = 0;
|
||||
|
||||
return {
|
||||
update(speedMs: number, condition: CarCondition, now: number) {
|
||||
update(speedMs: number, condition: CarCondition, now: number, heat: HeatReadout) {
|
||||
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
|
||||
if (now - last < 0.1) return;
|
||||
last = now;
|
||||
@ -23,6 +31,12 @@ export function createHud(seed: number) {
|
||||
`tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`,
|
||||
`chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`,
|
||||
'',
|
||||
// Debug only. The real game signals heat through the road itself —
|
||||
// see the design brief: no numeric meter ships.
|
||||
`[debug] road ${heat.segmentId === null ? 'off-road' : `#${heat.segmentId}`}`,
|
||||
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
|
||||
`[debug] hottest ${bar(heat.hottest)}`,
|
||||
'',
|
||||
`seed ${seed}`,
|
||||
'WASD drive · space handbrake · R respawn',
|
||||
].join('\n');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user