diff --git a/README.md b/README.md index ff9d963..549083e 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ 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: +Every metre driven **on or alongside** a road adds heat to that segment; every +road everywhere sheds it slowly. Cross a threshold and the road escalates: `Clear → Patrol → Barricade → Turret` @@ -41,7 +41,27 @@ 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: +### The route corridor + +Heat is credited to a road for anything within `ROUTE_CATCHMENT` (12 m) of its +centreline, not just the 9 m of tarmac. Otherwise the dominant strategy is to +drive *next to* every road and never accrue heat at all. + +Two consequences follow from that, and both are deliberate: + +- **The cutoff is hard, not a taper.** A taper leaves a gradient to optimise + along — sit at 80% of the catchment, take 20% of the heat. A cliff edge means + you either use the route or you actually leave it. +- **Barricades span the full corridor**, well past the kerb. A checkpoint that + stopped at the tarmac would be free to round on the verge at speed. + +12 m is chosen, not arbitrary: it puts ~7.5 m of verge beyond the kerb (about +two car widths, so hugging the shoulder gains nothing) while leaving 57% of the +map genuinely off-route. Widening it backfires — at 20 m two thirds of the map +counts as on-route, heat becomes unavoidable, and "take a different route" stops +being a choice at all. + +### Other deliberate choices - **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 @@ -97,6 +117,14 @@ one place where "how broken the car is" turns into "how it drives". rate changes. - **Condition and heat are not yet persisted** across reloads. IndexedDB comes with the campaign layer. +- **Junctions have no heat of their own.** A point near a junction is credited to + whichever segment is nearest, so a heavily used crossroads never fortifies as a + crossroads. Known gap, deferred on purpose. +- **Barricades are visually crude.** Blocking the whole 24 m corridor means two + long concrete slabs, which reads more like a wall than a checkpoint. The + gameplay shape is right; the presentation wants berms, wire, or wreckage on the + outer sections. They can also clip scenery placed on the verge — both are + static bodies, so physics is unaffected, but it looks wrong up close. - **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. diff --git a/src/main.ts b/src/main.ts index 64d2495..820b50a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { generateWorld } from './sim/world'; import { applyWear, deriveHandling, freshCondition } from './sim/car'; import { createHeat, stepHeat } from './sim/heat'; -import { segmentAt } from './sim/roads'; +import { routeAt, segmentAt } from './sim/roads'; import { createHeatProps } from './heatProps'; import { seedFromString } from './core/rng'; import { startLoop } from './core/loop'; @@ -34,7 +34,9 @@ async function boot() { 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; document.getElementById('boot')?.remove(); @@ -59,9 +61,10 @@ async function boot() { impactForce: physics.drainImpactForce(), }); - // Heat: the road under the wheels remembers being used. + // Heat: the road remembers being used — including being driven alongside. const at = physics.chassis.translation(); - currentSegment = segmentAt(model.roads, at.x, at.z)?.id ?? null; + 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); }, @@ -98,6 +101,7 @@ async function boot() { view.renderer.render(view.scene, view.camera); hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { segmentId: currentSegment, + onTarmac, value: currentSegment === null ? 0 : heat.value[currentSegment]!, level: currentSegment === null ? null : heat.level[currentSegment]!, hottest: Math.max(...heat.value), diff --git a/src/sim/heat.test.ts b/src/sim/heat.test.ts index db8394f..26e898e 100644 --- a/src/sim/heat.test.ts +++ b/src/sim/heat.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { generateRoads, segmentAt, distanceToRoad, projectOntoSegment } from './roads'; +import { + generateRoads, + segmentAt, + routeAt, + distanceToRoad, + pointOnSegment, + projectOntoSegment, + ROUTE_CATCHMENT, +} from './roads'; import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat'; import { generateWorld } from './world'; @@ -41,6 +49,51 @@ describe('road network', () => { }); }); +describe('route catchment', () => { + const s = roads.segments[3]!; + const beside = (metres: number) => pointOnSegment(s, 0.5, metres); + + it('counts the verge as using the road', () => { + const verge = beside(s.width / 2 + 2); + // Off the tarmac, but still unmistakably travelling that route. + expect(segmentAt(roads, verge.x, verge.z)).toBeNull(); + expect(routeAt(roads, verge.x, verge.z)?.id).toBe(s.id); + }); + + it('closes the drive-alongside exploit right up to the cutoff', () => { + for (const offset of [s.width / 2 + 0.5, ROUTE_CATCHMENT * 0.7, ROUTE_CATCHMENT - 0.5]) { + const p = beside(offset); + expect(routeAt(roads, p.x, p.z)?.id).toBe(s.id); + } + }); + + it('stops counting once you have genuinely left the route', () => { + const away = beside(ROUTE_CATCHMENT + 5); + expect(routeAt(roads, away.x, away.z)).toBeNull(); + }); + + it('accrues heat at the same rate on the verge as on the tarmac', () => { + const onRoad = createHeat(roads); + const onVerge = createHeat(roads); + const centre = pointOnSegment(s, 0.5, 0); + const verge = beside(s.width / 2 + 2); + for (let m = 0; m < 200; m++) { + stepHeat(onRoad, { + dt: 1 / 60, + segmentId: routeAt(roads, centre.x, centre.z)!.id, + distance: 1, + }); + stepHeat(onVerge, { + dt: 1 / 60, + segmentId: routeAt(roads, verge.x, verge.z)!.id, + distance: 1, + }); + } + // No discount for hugging the shoulder — that is the whole point. + expect(onVerge.value[s.id]).toBeCloseTo(onRoad.value[s.id]!, 10); + }); +}); + describe('world', () => { it('spawns the car on the network and keeps scenery off the tarmac', () => { const world = generateWorld(5); @@ -131,17 +184,36 @@ describe('heat props', () => { 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('leaves a gap in the barricade rather than sealing the route', () => { + for (const seed of [0, 1, 2, 3, 4, 5, 6, 7]) { + const s = roads.segments[seed]!; + const blocks = propsFor(s, 'barricade') + .filter((p) => p.kind === 'barricade') + .sort((a, b) => a.lateral - b.lateral); + expect(blocks).toHaveLength(2); + const gap = blocks[1]!.lateral - blocks[1]!.width / 2 - (blocks[0]!.lateral + blocks[0]!.width / 2); + // Wide enough for a 1.8m car, tight enough to force a lift off the throttle. + expect(gap).toBeGreaterThan(3.5); + expect(gap).toBeLessThan(5); + } }); - it('keeps patrols and barricades within the road, and towers beside it', () => { + it('blocks the whole route corridor, not just the tarmac', () => { + const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade'); + // Driving round the end of a barricade must mean leaving the route entirely. + for (const side of [-1, 1]) { + const reach = Math.max( + ...blocks.map((b) => side * b.lateral + b.width / 2), + ); + expect(reach).toBeGreaterThanOrEqual(ROUTE_CATCHMENT); + } + }); + + it('keeps patrols on the tarmac and towers off it', () => { for (const prop of propsFor(segment, 'turret')) { const { distance } = projectOntoSegment(segment, prop.x, prop.z); + if (prop.kind === 'patrol') expect(distance).toBeLessThan(segment.width / 2); 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 index 13ffa26..50f6bb7 100644 --- a/src/sim/heat.ts +++ b/src/sim/heat.ts @@ -6,7 +6,7 @@ * 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'; +import { pointOnSegment, ROUTE_CATCHMENT, type RoadNetwork, type RoadSegment } from './roads'; export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const; export type HeatLevel = (typeof HEAT_LEVELS)[number]; @@ -30,6 +30,9 @@ const METRES_PER_HEAT = 330; /** Heat shed per second everywhere. A hot road cools in roughly four minutes. */ const DECAY_PER_SECOND = 0.004; +/** Half the width of the opening left in a barricade. The car is 1.8m wide. */ +const GAP_HALF_WIDTH = 2.1; + export interface HeatState { /** Indexed by segment id. */ value: number[]; @@ -87,6 +90,8 @@ export interface HeatProp { x: number; z: number; yaw: number; + /** Offset from the road centreline, positive to the segment's left. */ + lateral: number; width: number; height: number; depth: number; @@ -113,30 +118,56 @@ export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] { // 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' }); + const patrolLateral = patrolSide * (half - 1.4); + props.push({ + ...pointOnSegment(segment, patrolT, patrolLateral), + lateral: patrolLateral, + 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. + // Barricade: blocks with a gap you have to slow down for. + // + // It spans the full route catchment, not just the tarmac. A checkpoint that + // stopped at the kerb would be pointless — you would swing onto the verge, + // round it at speed, and still be on the route. Blocking the whole corridor + // means the only ways past are the gap or genuinely leaving the road. 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 inner = gapCentre + side * GAP_HALF_WIDTH; + const outer = side * ROUTE_CATCHMENT; 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' }); + const lateral = (inner + outer) / 2; + props.push({ + ...pointOnSegment(segment, barricadeT, lateral), + lateral, + 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' }); + const towerLateral = (rng() < 0.5 ? -1 : 1) * (half + 2.5); + props.push({ + ...pointOnSegment(segment, barricadeT + 0.06, towerLateral), + lateral: towerLateral, + width: 3, + height: 6, + depth: 3, + kind: 'tower', + }); return props; } diff --git a/src/sim/roads.ts b/src/sim/roads.ts index 1848a91..45fa831 100644 --- a/src/sim/roads.ts +++ b/src/sim/roads.ts @@ -143,13 +143,32 @@ export function projectOntoSegment( 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 { +/** + * How far off the tarmac still counts as using a road. + * + * Without this, the obvious play is to drive alongside a road rather than on it + * and never accrue heat at all — which defeats the entire system. The verge is + * part of the route as far as anyone watching it is concerned. + * + * 12m leaves roughly 7.5m of verge beyond the tarmac edge — about two car widths, + * so hugging the shoulder buys nothing. Widening it is tempting but backfires: + * at 20m two thirds of the map counts as on-route, heat becomes unavoidable + * everywhere, and "take a different route" stops being a choice. At 12m about + * 57% of the map is genuinely off-route. + */ +export const ROUTE_CATCHMENT = 12; + +function nearest( + roads: RoadNetwork, + x: number, + z: number, + limit: (s: RoadSegment) => 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) { + if (distance < limit(s) && distance < bestDistance) { bestDistance = distance; best = s; } @@ -157,6 +176,21 @@ export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment return best; } +/** Nearest segment the point is actually *on the tarmac of*, or null. */ +export const segmentAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null => + nearest(roads, x, z, (s) => s.width / 2); + +/** + * Nearest segment the point counts as *travelling along*, tarmac or verge. + * + * Deliberately a hard cutoff rather than a taper. A taper would leave a gradient + * to optimise along — sit at 80% of the catchment and take 20% of the heat. + * A cliff edge means you either use the route or you genuinely leave it, and + * out there the scenery is its own punishment. + */ +export const routeAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null => + nearest(roads, x, z, () => ROUTE_CATCHMENT); + /** 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; diff --git a/src/ui/hud.ts b/src/ui/hud.ts index eec6872..bf24ef2 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -10,6 +10,8 @@ function bar(value: number): string { export interface HeatReadout { segmentId: number | null; + /** False while on the verge — still counts as using the road. */ + onTarmac: boolean; value: number; level: HeatLevel | null; hottest: number; @@ -33,7 +35,11 @@ export function createHud(seed: number) { '', // 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] road ${ + heat.segmentId === null + ? 'off-route' + : `#${heat.segmentId} ${heat.onTarmac ? '(on road)' : '(alongside)'}` + }`, `[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`, `[debug] hottest ${bar(heat.hottest)}`, '',