Phase 3: regional control, sketch minimap, and fix the base hut

Three things, all tangled together enough to land at once.

Regions. A single directional front cuts the map into liberated, contested,
occupied and frontier. Heat accrual behind your own lines is exactly zero, not
merely small — a trickle would eventually fortify your own back garden, and
would mean the safe area quietly punished you for using it. Decay is judged by
whoever holds the road, not whoever holds the ground the car is standing on.
Modelled as one front rather than blobs of territory because the brief wants the
line to move in both directions, and Phase 4 can then just slide the offsets.

Territory is signalled by tone, never by the world announcing itself: crossing a
border eases the fog, light and palette between four looks. Phase 3's gate is
telling occupied from liberated without being told, so the HUD line naming the
band is debug scaffolding like the heat numbers.

Minimap. There was no way to tell where a target was. The map is drawn from
Intel, not from the world: the road network's shape is always faintly visible,
but only roads actually seen carry remembered heat, only ground actually driven
through is filled in, and the front line is dashed in only where both sides of
it have been explored. Adds a coarse fog-of-war grid to Intel, plus an
eight-point compass arrow in the HUD.

Base huts. The building was centred on the junction, so the starting hut sat
directly over the car and hid it. Huts now stand off the junction, in whichever
direction is furthest from tarmac, and are solid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 10:23:23 +02:00
parent 73e8ac68f1
commit 477195a7d6
12 changed files with 737 additions and 40 deletions

View File

@ -5,6 +5,7 @@ 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.
- **Phase 2** — bases, a quest board, and known-vs-new target selection.
- **Phase 3** — regional control: behind your own lines, nobody is watching.
## Running
@ -122,6 +123,57 @@ This is what gives the board's reward a reason to matter. Without an economy the
choice between a safe route and a risky one has no stakes; with one, the risky
job is what keeps the car alive a while longer.
## Regional control
The map is cut by a single **front line** into four bands, running from your own
territory out past the edge of the known world:
`Liberated → Contested → Occupied → Frontier`
| Band | Heat accrual | Heat decay |
|---|---|---|
| Liberated | **×0** | ×4 |
| Contested | ×0.55 | ×1.4 |
| Occupied | ×1 | ×1 |
| Frontier | ×1.3 | ×0.8 |
Liberated accrual is exactly zero, not merely small. A trickle would eventually
fortify your own back garden, which is nonsense, and it would mean the safe area
quietly punished you for using it. Behind your own lines, nobody is writing down
which roads you take.
Decay is judged by whoever holds *the road*, not whoever holds the ground the
car is standing on — your own side dismantles checkpoints on its own roads
whether or not you are there to watch.
It is modelled as one directional front rather than blobs of territory,
deliberately: the brief wants the line to *move*, in both directions, and a
scalar offset per boundary is something Phase 4 can slide either way without
touching anything else.
**You are never told which band you are in by the world itself.** Crossing a
border eases the fog, the light and the palette: your own ground is open and
green, contested goes flat and hazy, occupied turns dusty and closes in, the
frontier is cold and short-sighted. The HUD names the band for now, but that
line is debug scaffolding for the same reason the heat numbers are — Phase 3's
gate is telling them apart *without* being told.
## The map
A sketch map, top right, drawn from `Intel` rather than from the world:
- **The road network's shape is always visible**, as faint dashed lines. You are
an operative with a map, not an amnesiac.
- **Roads you have actually seen** are drawn solid and coloured by the heat you
remember on them — which is what you last saw, not what is there now.
- **Ground you have driven through** is filled in, tinted by who holds it. A
survey mission fills in a whole area at once.
- **The front line** is dashed in only where you have explored both sides of it.
You know where the border is because you crossed it.
The HUD also carries an eight-point compass arrow to the active target, since
the map alone does not tell you which way to point the car.
## Layout
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
@ -134,12 +186,13 @@ ever opening a browser.
src/
sim/ pure model, no engine imports:
world, roads, heat — the map and its memory
regions — the front line and who holds what
intel, routing, quests — what the player knows and is asked to do
bases, car — where missions come from, and decline
physics/ Rapier world, raycast vehicle, input → wheel forces
render/ three.js scene, roads, markers, chase camera
core/ fixed-timestep loop, seeded RNG, keyboard
ui/ debug HUD, quest board
ui/ debug HUD, quest board, sketch minimap
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
heatProps.ts integration layer: heat levels → colliders + meshes
```
@ -205,6 +258,16 @@ reasons other than curiosity — because the known one has genuinely got
expensive. If the known option is always strictly better, raise `NOVELTY_BONUS`.
If you never take a known target, lower it.
**Phase 3:** driving into an occupied region, you can tell it from a liberated
one within a couple of minutes, from tone alone, without reading the HUD line
that names it. If you cannot, the palettes in `render/scene.ts` are too close
together — push them apart before trusting the band boundaries.
The static front is the whole of Phase 3. **Phase 4** is what makes it move:
mission completions nudging the boundaries, plus ambient drift that happens
whether or not you are watching. `Front.boundaries` is already the only thing
that would have to change.
Watch for one failure mode in particular: if you find yourself taking whichever
job is *nearest* and ignoring the intel line entirely, the board is decorative
and the phase has not landed, whatever the reward numbers say.

View File

@ -4,7 +4,9 @@ import { createHeat, stepHeat } from './sim/heat';
import { routeAt, segmentAt } from './sim/roads';
import { baseAt, placeBases } from './sim/bases';
import { buildGraph } from './sim/routing';
import { createIntel, observe, survey } from './sim/intel';
import { createIntel, observe, reveal, survey } from './sim/intel';
import { controlAt, createFront, DECAY_MULTIPLIER, HEAT_MULTIPLIER } from './sim/regions';
import { createMinimap } from './ui/minimap';
import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests';
import { createHeatProps } from './heatProps';
import { seedFromString } from './core/rng';
@ -18,6 +20,9 @@ import { createHud } from './ui/hud';
import { createBoard } from './ui/board';
import { CAR, WHEELS } from './carSpec';
/** How far the driver can see well enough to fill in the map, metres. */
const SIGHT_RADIUS = 85;
function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed');
if (!raw) return 1337;
@ -30,17 +35,33 @@ async function boot() {
const model = generateWorld(seed);
const bases = placeBases(model.roads, model.spawn);
const graph = buildGraph(model.roads);
const front = createFront(seed, model.extent, model.spawn);
const physics = await createPhysics(model);
const view = createScene(model);
const markers = createMarkers(view.scene, bases);
const minimap = createMinimap(model.roads, bases, front, model.extent);
// Base buildings are solid. They are placed off the junction, so this never
// walls off the road the player has to park on.
for (const base of bases) {
physics.addStaticBox({
x: base.hutX,
z: base.hutZ,
yaw: 0,
width: 7,
height: 3.4,
depth: 7,
});
}
const input = createInput();
const hud = createHud(seed);
const board = createBoard();
const driveState = createDriveState();
const heat = createHeat(model.roads);
const heatProps = createHeatProps(model.roads, physics, view.scene);
const intel = createIntel(model.roads);
const intel = createIntel(model.roads, model.extent);
const quests = createQuests();
let condition = freshCondition();
@ -49,6 +70,11 @@ async function boot() {
/** The road being travelled along — includes the verge, not just the tarmac. */
let currentSegment: number | null = null;
let onTarmac = false;
let control = controlAt(front, model.spawn.x, model.spawn.z);
/** Which side holds each road, cached — the front is static in this phase. */
const segmentControl = model.roads.segments.map((s) =>
controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2),
);
/** Offers are generated once per arrival at a base, not every frame. */
let openBase: number | null = null;
let offers: Offer[] = [];
@ -86,16 +112,28 @@ async function boot() {
});
// Heat: the road remembers being used — including being driven alongside.
// Behind your own lines it remembers nothing: nobody there is watching.
const at = physics.chassis.translation();
control = controlAt(front, at.x, at.z);
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
heatProps.sync(
stepHeat(heat, { dt, segmentId: currentSegment, distance }),
stepHeat(heat, {
dt,
segmentId: currentSegment,
distance,
accrual: HEAT_MULTIPLIER[control],
// Each road decays according to whoever holds *it*, not whoever holds
// the ground the car happens to be standing on.
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
}),
heat,
currentSegment,
);
// Driving a road is how you learn what is on it.
// Driving a road is how you learn what is on it, and seeing ground is how
// it stops being a blank on the map.
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
reveal(intel, at.x, at.z, SIGHT_RADIUS);
const base = baseAt(bases, at.x, at.z);
const stopped = Math.abs(speed) < 2.5;
@ -195,12 +233,24 @@ async function boot() {
}
view.followSun();
view.setTone(control, frameDt);
markers.update(elapsed);
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
view.renderer.render(view.scene, view.camera);
const quest = quests.active;
const target = quest ? nodeOf(quest.targetNode) : null;
minimap.draw(intel, {
x: p.x,
z: p.z,
// Yaw about +Y, measured from world +Z, which is the car's local forward.
heading: Math.atan2(
2 * (r.w * r.y + r.x * r.z),
1 - 2 * (r.y * r.y + r.x * r.x),
),
objective: target,
});
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
heat: {
segmentId: currentSegment,
@ -208,17 +258,20 @@ async function boot() {
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
level: currentSegment === null ? null : heat.level[currentSegment]!,
},
quest: quest
quest:
quest && target
? {
type: quest.type,
stage: quest.stage,
novel: quest.novel,
worked: quest.worked,
distance: target
? Math.hypot(target.x - p.x, target.z - p.z)
: 0,
distance: Math.hypot(target.x - p.x, target.z - p.z),
bearing:
Math.atan2(target.x - p.x, target.z - p.z) -
Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
}
: null,
control,
completed: quests.completed,
parts: quests.parts,
notice: elapsed < noticeUntil ? notice : '',

View File

@ -26,11 +26,14 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
new THREE.BoxGeometry(7, 3.4, 7),
new THREE.MeshStandardMaterial({ color: 0x3f5a46, roughness: 0.85 }),
);
hut.position.set(base.x, 1.7, base.z);
// The building sits beside the junction, not on it — on it, it blocks the
// road, and at the starting base it sits directly over the car.
hut.position.set(base.hutX, 1.7, base.hutZ);
hut.castShadow = true;
hut.receiveShadow = true;
scene.add(hut);
// The beacon stays over the junction, since that is where you park.
const light = beacon(0x5fd08a, 5);
light.position.set(base.x, BEACON_HEIGHT / 2, base.z);
scene.add(light);

View File

@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { WorldModel } from '../sim/world';
import type { Control } from '../sim/regions';
import { CAR, WHEELS } from '../carSpec';
export interface SceneView {
@ -11,9 +12,26 @@ export interface SceneView {
obstacles: THREE.Mesh[];
/** Keeps the shadow frustum centred on the car. */
followSun(): void;
/** Eases the world's colour and visibility toward the current territory. */
setTone(control: Control, dt: number): void;
dispose(): void;
}
/**
* How each band of control looks.
*
* Phase 3's gate is that you can tell occupied ground from liberated within a
* couple of minutes *without being told*. Nothing here is a label: your own
* territory is open and green, contested ground goes flat and hazy, occupied
* turns dusty and closes in, and the frontier is cold and short-sighted.
*/
const TONES: Record<Control, { sky: number; ground: number; sun: number; fog: [number, number] }> = {
liberated: { sky: 0x9fc0b4, ground: 0x2f3830, sun: 0xfff0dc, fog: [110, 380] },
contested: { sky: 0x9aa3a8, ground: 0x30302a, sun: 0xf6ecd8, fog: [80, 300] },
occupied: { sky: 0xb09274, ground: 0x352c24, sun: 0xffdcb0, fog: [55, 210] },
frontier: { sky: 0x7f8ba8, ground: 0x272a33, sun: 0xd8ddf0, fog: [35, 150] },
};
const SKY = 0x11161c;
export function createScene(model: WorldModel): SceneView {
@ -31,7 +49,8 @@ export function createScene(model: WorldModel): SceneView {
const camera = new THREE.PerspectiveCamera(62, innerWidth / innerHeight, 0.2, 900);
camera.position.set(0, 6, -12);
scene.add(new THREE.HemisphereLight(0x9fb4c7, 0x2a2823, 1.1));
const hemi = new THREE.HemisphereLight(0x9fb4c7, 0x2a2823, 1.1);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff0dc, 2.1);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
@ -157,6 +176,21 @@ export function createScene(model: WorldModel): SceneView {
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
sun.target.position.copy(car.position);
},
setTone(control, dt) {
const tone = TONES[control];
// Ease rather than snap, so a border is something you notice having
// crossed rather than a switch being thrown in your face.
const t = 1 - Math.exp(-0.7 * dt);
hemi.color.lerp(toneColour.set(tone.sky), t);
hemi.groundColor.lerp(toneColour.set(tone.ground), t);
sun.color.lerp(toneColour.set(tone.sun), t);
(scene.background as THREE.Color).lerp(toneColour.set(tone.sky).multiplyScalar(0.22), t);
const fog = scene.fog as THREE.Fog;
fog.color.copy(scene.background as THREE.Color);
fog.near += (tone.fog[0] - fog.near) * t;
fog.far += (tone.fog[1] - fog.far) * t;
},
dispose() {
removeEventListener('resize', onResize);
renderer.dispose();
@ -165,6 +199,9 @@ export function createScene(model: WorldModel): SceneView {
};
}
/** Scratch colour, so tone easing allocates nothing per frame. */
const toneColour = new THREE.Color();
const camTarget = new THREE.Vector3();
const camDesired = new THREE.Vector3();
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);

View File

@ -7,15 +7,22 @@
* That only works if they are spread out, hence farthest-point selection rather
* than random placement.
*/
import type { RoadNetwork } from './roads';
import { distanceToRoad, type RoadNetwork } from './roads';
export interface Base {
nodeId: number;
/** The junction itself — where the car parks and the board opens. */
x: number;
z: number;
/** The building, set off the junction so it never blocks the road or the view. */
hutX: number;
hutZ: number;
name: string;
}
/** How far the building sits from the junction it serves. */
const HUT_OFFSET = 15;
const NAMES = ['Anvil', 'Birch', 'Cinder', 'Dovetail', 'Ember', 'Foxglove'];
export function placeBases(roads: RoadNetwork, spawn: { x: number; z: number }, count = 4): Base[] {
@ -46,12 +53,34 @@ export function placeBases(roads: RoadNetwork, spawn: { x: number; z: number },
chosen.push(best);
}
return chosen.map((n, i) => ({
return chosen.map((n, i) => {
// Put the building beside the junction, never on it. On it, the hut sits
// on the road, and at the starting base it swallows the car entirely.
// Pick whichever direction gets furthest from tarmac.
let bestX = n.x + HUT_OFFSET;
let bestZ = n.z;
let bestClearance = -Infinity;
for (let step = 0; step < 12; step++) {
const angle = (step / 12) * Math.PI * 2;
const x = n.x + Math.cos(angle) * HUT_OFFSET;
const z = n.z + Math.sin(angle) * HUT_OFFSET;
const clearance = distanceToRoad(roads, x, z);
if (clearance > bestClearance) {
bestClearance = clearance;
bestX = x;
bestZ = z;
}
}
return {
nodeId: n.id,
x: n.x,
z: n.z,
hutX: bestX,
hutZ: bestZ,
name: NAMES[i % NAMES.length]!,
}));
};
});
}
/** Nearest base within `radius` metres, or null. */

View File

@ -64,16 +64,23 @@ export interface HeatStep {
segmentId: number | null;
/** Metres travelled this step. */
distance: number;
/**
* Accrual multiplier for the territory the car is in. Zero behind your own
* lines: nobody there is writing down which roads you use.
*/
accrual?: number;
/** Per-segment decay multiplier — your own side clears roads faster. */
decayFor?: (segmentId: number) => 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;
const accrual = step.accrual ?? 1;
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;
let heat = state.value[id]! - DECAY_PER_SECOND * step.dt * (step.decayFor?.(id) ?? 1);
if (id === step.segmentId) heat += (step.distance / METRES_PER_HEAT) * accrual;
heat = Math.min(1, Math.max(0, heat));
state.value[id] = heat;

View File

@ -9,6 +9,9 @@
import { levelFor, type HeatLevel, type HeatState } from './heat';
import type { RoadNetwork } from './roads';
/** Side of a fog-of-war cell, metres. Coarse on purpose — this is a sketch map. */
export const CELL_SIZE = 24;
export interface Intel {
/** Heat value at the moment each segment was last observed. */
rememberedHeat: number[];
@ -18,17 +21,64 @@ export interface Intel {
seenAt: number[];
/** Target nodes the player has actually reached. */
visitedNodes: Set<number>;
/** Ground actually laid eyes on, as a coarse grid. 1 = explored. */
explored: Uint8Array;
cellsPerSide: number;
/** World coordinate of the grid's lower corner. */
gridOrigin: number;
}
export function createIntel(roads: RoadNetwork): Intel {
export function createIntel(roads: RoadNetwork, extent: number): Intel {
// Cover more than the road network reaches, since node jitter pushes the
// outermost junctions past `extent`.
const gridOrigin = -(extent + 60);
const cellsPerSide = Math.ceil((-gridOrigin * 2) / CELL_SIZE);
return {
rememberedHeat: new Array(roads.segments.length).fill(0),
rememberedLevel: new Array(roads.segments.length).fill('clear'),
seenAt: new Array(roads.segments.length).fill(-1),
visitedNodes: new Set(),
explored: new Uint8Array(cellsPerSide * cellsPerSide),
cellsPerSide,
gridOrigin,
};
}
export const cellIndex = (intel: Intel, x: number, z: number): number | null => {
const col = Math.floor((x - intel.gridOrigin) / CELL_SIZE);
const row = Math.floor((z - intel.gridOrigin) / CELL_SIZE);
if (col < 0 || row < 0 || col >= intel.cellsPerSide || row >= intel.cellsPerSide) return null;
return row * intel.cellsPerSide + col;
};
export const isExplored = (intel: Intel, x: number, z: number): boolean => {
const index = cellIndex(intel, x, z);
return index !== null && intel.explored[index] === 1;
};
/** Marks everything within `radius` of a point as seen. Returns new cells found. */
export function reveal(intel: Intel, x: number, z: number, radius: number): number {
let found = 0;
const min = Math.floor((x - radius - intel.gridOrigin) / CELL_SIZE);
const max = Math.ceil((x + radius - intel.gridOrigin) / CELL_SIZE);
const minRow = Math.floor((z - radius - intel.gridOrigin) / CELL_SIZE);
const maxRow = Math.ceil((z + radius - intel.gridOrigin) / CELL_SIZE);
for (let row = minRow; row <= maxRow; row++) {
for (let col = min; col <= max; col++) {
if (col < 0 || row < 0 || col >= intel.cellsPerSide || row >= intel.cellsPerSide) continue;
const cx = intel.gridOrigin + (col + 0.5) * CELL_SIZE;
const cz = intel.gridOrigin + (row + 0.5) * CELL_SIZE;
if (Math.hypot(cx - x, cz - z) > radius) continue;
const index = row * intel.cellsPerSide + col;
if (intel.explored[index] === 1) continue;
intel.explored[index] = 1;
found++;
}
}
return found;
}
export const hasSeen = (intel: Intel, segmentId: number): boolean => intel.seenAt[segmentId]! >= 0;
/** Record what a segment looks like right now. */
@ -60,6 +110,8 @@ export function survey(
observe(intel, heat, s.id, now);
count++;
}
// A survey fills in the ground too, not just the roads through it.
reveal(intel, x, z, radius);
return count;
}

View File

@ -71,7 +71,7 @@ describe('routing', () => {
describe('intel', () => {
it('starts blank and only records what was observed', () => {
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
expect(hasSeen(intel, 4)).toBe(false);
observe(intel, heat, 4, 10);
@ -80,7 +80,7 @@ describe('intel', () => {
});
it('remembers what a road was, not what it has since become', () => {
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
observe(intel, heat, 0, 0);
expect(intel.rememberedLevel[0]).toBe('clear');
@ -92,7 +92,7 @@ describe('intel', () => {
});
it('surveys a whole area at once, which is what recon is for', () => {
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
const node = world.roads.nodes[24]!;
const mapped = survey(intel, heat, world.roads, node.x, node.z, 5);
@ -101,7 +101,7 @@ describe('intel', () => {
});
it('summarises a route by its worst road and how much is unscouted', () => {
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads);
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
@ -122,7 +122,7 @@ describe('intel', () => {
describe('quest board', () => {
it('always offers something new to go and look at', () => {
const state = createQuests();
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 1);
expect(offers.length).toBeGreaterThan(0);
expect(offers.some((o) => o.novel)).toBe(true);
@ -130,7 +130,7 @@ describe('quest board', () => {
it('offers both a known and an unknown target once somewhere has been visited', () => {
const state = createQuests();
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
intel.visitedNodes.add(world.roads.nodes[10]!.id);
intel.visitedNodes.add(world.roads.nodes[30]!.id);
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 7);
@ -141,8 +141,8 @@ describe('quest board', () => {
it('pays more for an unknown target, or nobody would ever take one', () => {
// Same target, same route — the only difference is whether it is known.
const state = createQuests();
const blank = createIntel(world.roads);
const known = createIntel(world.roads);
const blank = createIntel(world.roads, world.extent);
const known = createIntel(world.roads, world.extent);
for (const n of world.roads.nodes) known.visitedNodes.add(n.id);
const rewardPerKm = (intel: typeof blank) => {
@ -156,7 +156,7 @@ describe('quest board', () => {
it('never offers the base you are standing in as a target', () => {
const state = createQuests();
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
for (const base of bases) {
for (const offer of offersAt(state, world.roads, graph, intel, base, 0, 11)) {
expect(offer.targetNode).not.toBe(base.nodeId);
@ -168,7 +168,7 @@ describe('quest board', () => {
describe('mission lifecycle', () => {
const start = () => {
const state = createQuests();
const intel = createIntel(world.roads);
const intel = createIntel(world.roads, world.extent);
const offer = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 5)[0]!;
accept(state, offer, bases[0]!);
return state;

150
src/sim/regions.test.ts Normal file
View File

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { generateWorld } from './world';
import { placeBases } from './bases';
import { distanceToRoad } from './roads';
import { controlAt, createFront, CONTROLS, DECAY_MULTIPLIER, HEAT_MULTIPLIER, depthAt } from './regions';
import { createHeat, stepHeat } from './heat';
import { cellIndex, createIntel, isExplored, reveal } from './intel';
const world = generateWorld(1337);
const front = createFront(1337, world.extent, world.spawn);
describe('front line', () => {
it('starts the player behind their own line', () => {
expect(controlAt(front, world.spawn.x, world.spawn.z)).toBe('liberated');
});
it('runs through every band as you push along the axis', () => {
const seen = new Set<string>();
for (let d = -world.extent; d < world.extent * 2; d += 5) {
seen.add(controlAt(front, front.axis.x * d, front.axis.z * d));
}
expect([...seen].sort()).toEqual([...CONTROLS].sort());
});
it('is a line, not a blob: control depends only on depth along the axis', () => {
const along = { x: -front.axis.z, z: front.axis.x };
const base = { x: front.axis.x * 40, z: front.axis.z * 40 };
const control = controlAt(front, base.x, base.z);
// Sliding sideways along the front must not change whose ground it is.
for (const t of [-300, -80, 80, 300]) {
expect(controlAt(front, base.x + along.x * t, base.z + along.z * t)).toBe(control);
}
expect(depthAt(front, base.x, base.z)).toBeCloseTo(40, 6);
});
it('gets more hostile the deeper you go, never less', () => {
const accrual = CONTROLS.map((c) => HEAT_MULTIPLIER[c]);
const decay = CONTROLS.map((c) => DECAY_MULTIPLIER[c]);
for (let i = 1; i < CONTROLS.length; i++) {
expect(accrual[i]!).toBeGreaterThan(accrual[i - 1]!);
expect(decay[i]!).toBeLessThan(decay[i - 1]!);
}
});
});
describe('heat under regional control', () => {
it('accrues nothing at all behind your own lines', () => {
const heat = createHeat(world.roads);
for (let m = 0; m < 2000; m++) {
stepHeat(heat, {
dt: 1 / 60,
segmentId: 0,
distance: 1,
accrual: HEAT_MULTIPLIER.liberated,
});
}
// Not "a little" — exactly none. Your own back garden never fortifies.
expect(heat.value[0]).toBe(0);
expect(heat.level[0]).toBe('clear');
});
it('accrues fastest past the front', () => {
const drive = (accrual: number) => {
const heat = createHeat(world.roads);
for (let m = 0; m < 200; m++) {
stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1, accrual });
}
return heat.value[0]!;
};
expect(drive(HEAT_MULTIPLIER.frontier)).toBeGreaterThan(drive(HEAT_MULTIPLIER.occupied));
expect(drive(HEAT_MULTIPLIER.occupied)).toBeGreaterThan(drive(HEAT_MULTIPLIER.contested));
});
it('lets your own side dismantle a checkpoint faster than the enemy rebuilds', () => {
const cool = (decay: number) => {
const heat = createHeat(world.roads);
heat.value[0] = 1;
for (let i = 0; i < 60 * 60; i++) {
stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0, decayFor: () => decay });
}
return heat.value[0]!;
};
expect(cool(DECAY_MULTIPLIER.liberated)).toBeLessThan(cool(DECAY_MULTIPLIER.frontier));
});
it('decays a road by who holds that road, not who holds the car', () => {
const heat = createHeat(world.roads);
heat.value[0] = 1;
heat.value[1] = 1;
for (let i = 0; i < 60 * 30; i++) {
stepHeat(heat, {
dt: 1 / 60,
segmentId: null,
distance: 0,
decayFor: (id) => (id === 0 ? DECAY_MULTIPLIER.liberated : DECAY_MULTIPLIER.frontier),
});
}
expect(heat.value[0]).toBeLessThan(heat.value[1]!);
});
});
describe('fog of war', () => {
const intel = () => createIntel(world.roads, world.extent);
it('starts entirely blank', () => {
expect(intel().explored.some((v) => v === 1)).toBe(false);
});
it('uncovers only what you drove past', () => {
const map = intel();
reveal(map, 0, 0, 60);
expect(isExplored(map, 0, 0)).toBe(true);
expect(isExplored(map, 30, 0)).toBe(true);
expect(isExplored(map, 200, 200)).toBe(false);
});
it('does not re-count ground already uncovered', () => {
const map = intel();
const first = reveal(map, 0, 0, 60);
expect(first).toBeGreaterThan(0);
expect(reveal(map, 0, 0, 60)).toBe(0);
});
it('handles the map edges without falling off the grid', () => {
const map = intel();
const corner = world.extent + 55;
expect(() => reveal(map, corner, corner, 90)).not.toThrow();
expect(cellIndex(map, corner * 4, 0)).toBeNull();
expect(isExplored(map, corner * 4, 0)).toBe(false);
});
});
describe('base buildings', () => {
const bases = placeBases(world.roads, world.spawn);
it('sits the hut off the road, not on the junction the player parks at', () => {
for (const base of bases) {
// Clear of the tarmac...
expect(distanceToRoad(world.roads, base.hutX, base.hutZ)).toBeGreaterThan(3.5);
// ...and clear of the spawn point, which is what was swallowing the car.
expect(Math.hypot(base.hutX - base.x, base.hutZ - base.z)).toBeGreaterThan(10);
}
});
it('keeps the hut close enough to still read as that base', () => {
for (const base of bases) {
expect(Math.hypot(base.hutX - base.x, base.hutZ - base.z)).toBeLessThan(20);
}
});
});

90
src/sim/regions.ts Normal file
View File

@ -0,0 +1,90 @@
/**
* Regional control and the front line. Pure no engine imports.
*
* The map is not a uniform hostile plain. Behind your own lines nobody is
* watching you, so driving costs you nothing but fuel and tyres; past the front
* every road you use is a road someone notices. That boundary is what makes
* "which way do I go" a question about territory and not just distance.
*
* Modelled as a single directional front rather than blobs of territory,
* because the brief wants the line to *move*, in both directions and a scalar
* offset per boundary is something Phase 4 can slide either way.
*/
import { makeRng } from '../core/rng';
export const CONTROLS = ['liberated', 'contested', 'occupied', 'frontier'] as const;
export type Control = (typeof CONTROLS)[number];
export interface Front {
/** Unit vector pointing from friendly territory toward enemy territory. */
axis: { x: number; z: number };
/**
* Distance along the axis at which each band ends. Monotonic. Phase 4 moves
* these; nothing else about the model has to change.
*/
boundaries: { liberated: number; contested: number; occupied: number };
}
/**
* Heat accrual multiplier per band.
*
* Liberated is exactly zero, not merely small. A trickle would still eventually
* fortify your own back garden, which is nonsense, and it would mean the safe
* area quietly punishes you for using it.
*/
export const HEAT_MULTIPLIER: Record<Control, number> = {
liberated: 0,
contested: 0.55,
occupied: 1,
frontier: 1.3,
};
/** Heat decay multiplier: your own side dismantles checkpoints quickly. */
export const DECAY_MULTIPLIER: Record<Control, number> = {
liberated: 4,
contested: 1.4,
occupied: 1,
frontier: 0.8,
};
export function createFront(seed: number, extent: number, home: { x: number; z: number }): Front {
const rng = makeRng(seed ^ 0x51ed270b);
const angle = rng() * Math.PI * 2;
const axis = { x: Math.cos(angle), z: Math.sin(angle) };
// Anchor the bands relative to home, so the player always starts safely
// behind their own line rather than wherever the seed happened to put it.
const at = home.x * axis.x + home.z * axis.z;
return {
axis,
boundaries: {
liberated: at + extent * 0.3,
contested: at + extent * 0.75,
occupied: at + extent * 1.3,
},
};
}
/** Signed distance along the front axis. Higher is deeper into enemy ground. */
export const depthAt = (front: Front, x: number, z: number): number =>
x * front.axis.x + z * front.axis.z;
export function controlAt(front: Front, x: number, z: number): Control {
const depth = depthAt(front, x, z);
if (depth < front.boundaries.liberated) return 'liberated';
if (depth < front.boundaries.contested) return 'contested';
if (depth < front.boundaries.occupied) return 'occupied';
return 'frontier';
}
/**
* How far the point is past the nearest band edge, in metres. Used to fade the
* world's tone across a border rather than snapping it, so crossing reads as a
* gradient the player feels before they can name it.
*/
export function distanceToBorder(front: Front, x: number, z: number): number {
const depth = depthAt(front, x, z);
return Math.min(
...Object.values(front.boundaries).map((edge) => Math.abs(depth - edge)),
);
}

View File

@ -1,6 +1,7 @@
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
import type { HeatLevel } from '../sim/heat';
import type { MissionType } from '../sim/quests';
import type { Control } from '../sim/regions';
import { WORK_SECONDS } from '../sim/quests';
const BAR_WIDTH = 12;
@ -31,12 +32,30 @@ export interface HudModel {
novel: boolean;
worked: number;
distance: number;
/** Radians from the car's nose to the target, positive to the left. */
bearing: number;
} | null;
control: Control;
completed: number;
parts: number;
notice: string;
}
/** Eight-point compass, starting at "straight ahead" and turning left. */
const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗'];
const arrowFor = (bearing: number): string => {
const sector = Math.round(bearing / (Math.PI / 4));
return ARROWS[((sector % 8) + 8) % 8]!;
};
const CONTROL_WORDS: Record<Control, string> = {
liberated: 'liberated — nobody is watching the roads here',
contested: 'contested — the roads remember, slowly',
occupied: 'occupied — every road you use is noticed',
frontier: 'frontier — beyond the line, and it shows',
};
function questLines(quest: NonNullable<HudModel['quest']>): string[] {
const label = quest.type === 'recon' ? 'Survey' : 'Supply run';
const kind = quest.novel ? 'new target' : 'known target';
@ -46,7 +65,10 @@ function questLines(quest: NonNullable<HudModel['quest']>): string[] {
if (quest.stage === 'working') {
return [`${label} — working ${quest.worked.toFixed(1)}/${WORK_SECONDS}s (hold still)`];
}
return [`${label} (${kind}) — ${(quest.distance / 1000).toFixed(2)} km to target`];
return [
`${label} (${kind})`,
`${arrowFor(quest.bearing)} ${(quest.distance / 1000).toFixed(2)} km to target`,
];
}
export function createHud(seed: number) {
@ -71,6 +93,8 @@ export function createHud(seed: number) {
'',
...(model.quest ? questLines(model.quest) : ['no mission — find a base']),
'',
CONTROL_WORDS[model.control],
'',
// Debug only. The real game signals heat through the road itself —
// see the design brief: no numeric meter ships.
`[debug] road ${

189
src/ui/minimap.ts Normal file
View File

@ -0,0 +1,189 @@
import type { Base } from '../sim/bases';
import type { HeatLevel } from '../sim/heat';
import { CELL_SIZE, hasSeen, type Intel } from '../sim/intel';
import type { RoadNetwork } from '../sim/roads';
import { controlAt, type Control, type Front } from '../sim/regions';
/**
* A sketch map, drawn from `Intel` rather than from the world.
*
* It shows the shape of the road network everywhere you are an operative with
* a map, not an amnesiac but only ground you have actually been through gets
* filled in, and only roads you have actually seen carry any information about
* what is on them. Unexplored country is a faint outline: enough to plan a route
* toward, not enough to know what it costs.
*/
const SIZE = 190;
const PADDING = 12;
const HEAT_COLOURS: Record<HeatLevel, string> = {
clear: '#7d8b98',
patrol: '#c9c05a',
barricade: '#d98a3d',
turret: '#d4544c',
};
const CONTROL_COLOURS: Record<Control, string> = {
liberated: '#2c4436',
contested: '#3d3f2e',
occupied: '#43302c',
frontier: '#3a2b3d',
};
export interface MinimapView {
x: number;
z: number;
/** Car heading in radians, matching the world's yaw convention. */
heading: number;
objective: { x: number; z: number } | null;
}
export function createMinimap(
roads: RoadNetwork,
bases: Base[],
front: Front,
worldExtent: number,
) {
const canvas = document.createElement('canvas');
canvas.id = 'minimap';
const scale = devicePixelRatio > 1 ? 2 : 1;
canvas.width = SIZE * scale;
canvas.height = SIZE * scale;
canvas.style.cssText = `
position: fixed; top: 12px; right: 12px;
width: ${SIZE}px; height: ${SIZE}px;
background: rgba(10,13,17,.86);
border: 1px solid #2f3944; border-radius: 4px;
`;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d')!;
ctx.scale(scale, scale);
// World spans [-half, half]; map that onto the canvas once, here.
const half = worldExtent + 60;
const span = half * 2;
const px = (worldX: number) => PADDING + ((worldX + half) / span) * (SIZE - PADDING * 2);
return {
draw(intel: Intel, view: MinimapView) {
ctx.clearRect(0, 0, SIZE, SIZE);
// --- Explored ground, tinted by who holds it ---
const cell = (CELL_SIZE / span) * (SIZE - PADDING * 2);
for (let row = 0; row < intel.cellsPerSide; row++) {
for (let col = 0; col < intel.cellsPerSide; col++) {
if (intel.explored[row * intel.cellsPerSide + col] !== 1) continue;
const wx = intel.gridOrigin + (col + 0.5) * CELL_SIZE;
const wz = intel.gridOrigin + (row + 0.5) * CELL_SIZE;
ctx.fillStyle = CONTROL_COLOURS[controlAt(front, wx, wz)];
// +1 closes the hairline seams between neighbouring cells.
ctx.fillRect(px(wx) - cell / 2, px(wz) - cell / 2, cell + 1, cell + 1);
}
}
// --- Roads: rough everywhere, detailed where seen ---
for (const s of roads.segments) {
const seen = hasSeen(intel, s.id);
ctx.beginPath();
ctx.moveTo(px(s.ax), px(s.az));
ctx.lineTo(px(s.bx), px(s.bz));
if (seen) {
ctx.setLineDash([]);
ctx.lineWidth = 2;
ctx.strokeStyle = HEAT_COLOURS[intel.rememberedLevel[s.id]!];
} else {
// Known to exist, nothing known about it.
ctx.setLineDash([2, 3]);
ctx.lineWidth = 1;
ctx.strokeStyle = '#3c464f';
}
ctx.stroke();
}
ctx.setLineDash([]);
// --- The front line, once you have seen ground on both sides of it ---
drawFront(ctx, front, intel, px, half);
// --- Bases ---
for (const base of bases) {
ctx.fillStyle = '#5fd08a';
ctx.fillRect(px(base.x) - 3, px(base.z) - 3, 6, 6);
}
// --- Objective ---
if (view.objective) {
ctx.beginPath();
ctx.arc(px(view.objective.x), px(view.objective.z), 4.5, 0, Math.PI * 2);
ctx.strokeStyle = '#ffc247';
ctx.lineWidth = 2;
ctx.stroke();
}
// --- The car, as an arrow, so heading is readable at a glance ---
const cx = px(view.x);
const cz = px(view.z);
ctx.save();
ctx.translate(cx, cz);
// World forward is +Z at heading 0, and canvas +y is world +z.
ctx.rotate(-view.heading);
ctx.beginPath();
ctx.moveTo(0, 6);
ctx.lineTo(-4, -4);
ctx.lineTo(0, -1.5);
ctx.lineTo(4, -4);
ctx.closePath();
ctx.fillStyle = '#f2f6fa';
ctx.fill();
ctx.restore();
// --- North, since the map never rotates ---
ctx.fillStyle = '#6d7883';
ctx.font = '9px ui-monospace, monospace';
ctx.fillText('N', SIZE / 2 - 3, 10);
},
};
}
/**
* The front is drawn as a dashed line, but only across country the player has
* explored. You know where the border is because you crossed it, not because
* you were handed a map of the war.
*/
function drawFront(
ctx: CanvasRenderingContext2D,
front: Front,
intel: Intel,
px: (n: number) => number,
half: number,
) {
const along = { x: -front.axis.z, z: front.axis.x };
ctx.setLineDash([3, 4]);
ctx.lineWidth = 1;
for (const edge of Object.values(front.boundaries)) {
ctx.strokeStyle = '#8892a0';
ctx.beginPath();
let drawing = false;
for (let t = -half * 1.6; t <= half * 1.6; t += CELL_SIZE / 2) {
const x = front.axis.x * edge + along.x * t;
const z = front.axis.z * edge + along.z * t;
const index =
Math.floor((z - intel.gridOrigin) / CELL_SIZE) * intel.cellsPerSide +
Math.floor((x - intel.gridOrigin) / CELL_SIZE);
const known = intel.explored[index] === 1;
if (!known) {
drawing = false;
continue;
}
if (!drawing) {
ctx.moveTo(px(x), px(z));
drawing = true;
} else {
ctx.lineTo(px(x), px(z));
}
}
ctx.stroke();
}
ctx.setLineDash([]);
}