diff --git a/README.md b/README.md index 177c8de..ff9d963 100644 --- a/README.md +++ b/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 - physics/ Rapier world, raycast vehicle, input → wheel forces - render/ three.js scene, chase camera - core/ fixed-timestep loop, seeded RNG, keyboard - ui/ debug HUD - carSpec.ts shared car dimensions, so body and mesh cannot drift apart + sim/ pure model — roads, heat, world generation, condition → handling + physics/ Rapier world, raycast vehicle, input → wheel forces + 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. diff --git a/src/carSpec.ts b/src/carSpec.ts index fef9d21..c40f673 100644 --- a/src/carSpec.ts +++ b/src/carSpec.ts @@ -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. */ diff --git a/src/heatProps.test.ts b/src/heatProps.test.ts new file mode 100644 index 0000000..fb859a5 --- /dev/null +++ b/src/heatProps.test.ts @@ -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); + } + }); +}); diff --git a/src/heatProps.ts b/src/heatProps.ts new file mode 100644 index 0000000..b215721 --- /dev/null +++ b/src/heatProps.ts @@ -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 = { + 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(); + /** Level changes waiting for the player to get off the road in question. */ + const pending = new Set(); + + 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); + } + }, + }; +} diff --git a/src/main.ts b/src/main.ts index ae4e7af..64d2495 100644 --- a/src/main.ts +++ b/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), + }); }, }); } diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index d3a94f7..e0cc0f1 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -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, 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, 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)), diff --git a/src/physics/physics.ts b/src/physics/physics.ts index 57ea18d..f66ad31 100644 --- a/src/physics/physics.ts +++ b/src/physics/physics.ts @@ -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 { // --- 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 { 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); diff --git a/src/render/scene.ts b/src/render/scene.ts index 1b29505..e6960a0 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -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 }); diff --git a/src/sim/heat.test.ts b/src/sim/heat.test.ts new file mode 100644 index 0000000..db8394f --- /dev/null +++ b/src/sim/heat.test.ts @@ -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(); + 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); + } + }); +}); diff --git a/src/sim/heat.ts b/src/sim/heat.ts new file mode 100644 index 0000000..13ffa26 --- /dev/null +++ b/src/sim/heat.ts @@ -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, 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; +} diff --git a/src/sim/roads.ts b/src/sim/roads.ts new file mode 100644 index 0000000..1848a91 --- /dev/null +++ b/src/sim/roads.ts @@ -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(); + 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(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, +): 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), + }; +} diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index aa62172..25f8457 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -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); } }); }); diff --git a/src/sim/world.ts b/src/sim/world.ts index 1ca9503..7c3dd0a 100644 --- a/src/sim/world.ts +++ b/src/sim/world.ts @@ -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,44 +24,62 @@ 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 - ? { - x, - z, - width: randRange(rng, 0.8, 1.4), - height: randRange(rng, 0.8, 1.4), - depth: randRange(rng, 0.8, 1.4), - yaw: rng() * Math.PI, - kind: 'crate', - } - : { - x, - z, - width: randRange(rng, 1.5, 5), - height: randRange(rng, 1.2, 3.5), - depth: randRange(rng, 1.5, 5), - yaw: rng() * Math.PI, - kind: 'block', - }, - ); + const obstacle: Obstacle = crate + ? { + x, + z, + width: randRange(rng, 0.8, 1.4), + height: randRange(rng, 0.8, 1.4), + depth: randRange(rng, 0.8, 1.4), + yaw: rng() * Math.PI, + kind: 'crate', + } + : { + x, + z, + width: randRange(rng, 1.5, 5), + height: randRange(rng, 1.2, 3.5), + 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 }; } diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 97bfb78..eec6872 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -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');