diff --git a/README.md b/README.md index eda6417..563f3b8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Endless driving survival game. - **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. +- **Phase 4** — the front line moves, from your work and on its own. ## Running @@ -25,7 +26,9 @@ seeds may be numbers or words. | `1`–`3` | accept a mission, when parked at a base | Drive to a green beacon (a base) and stop. Take a job, drive to the amber -beacon, stop and hold position for three seconds, then return to **any** base. +beacon, stop and hold position for three seconds, then return to **any** base — +the amber beacon goes out once the job at the target is done, since the target +is no longer anywhere you need to be. ```bash npm test # sim + headless physics @@ -146,10 +149,37 @@ 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. +### The line moves + +Two forces, and they are independent by design. + +**Your work.** Completing a mission pushes the front forward, weighted by what +the job was worth — a long haul into unknown country moves the war more than a +local errand, and supply runs move it more than surveys. Ground won is not held +forever: the player's contribution decays over about seven minutes, so the enemy +presses back on its own. + +**The war elsewhere.** The line also drifts with nobody's help, in both +directions. This is not background flavour: the brief is explicit that ambient +drift is what makes the setting a war rather than a level, and that it is the +*only* mechanism for the enemy's attention being pulled elsewhere — there is no +separate "baseline threat" system alongside it. + +Drift is a sum of slow waves rather than a random walk. A walk either wanders +off the map or, once reined in, moves so little the war reads as static. Waves +at incommensurate periods are unpredictable in practice, always bounded, and +reproducible from the seed — so "the region changed while you were away" is +something a test can assert. Most of the movement is shared by every boundary, +because a front that advances advances as a whole; each band then wobbles a +little on its own so the map is not one rigid object sliding about. + +In practice a boundary swings about ±45 m and a spot near a border changes hands +several times over a quarter of an hour. + +**Nothing announces any of this.** There is no notification, no readout, no +number. The minimap shows who held each patch of ground *when you last stood in +it* — so after a while away, your map is simply wrong, and the only way to find +out is to go back and look. **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 @@ -166,10 +196,17 @@ A sketch map, top right, drawn from `Intel` rather than from the world: 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. +- **Ground you have driven through** is filled in, tinted by who held it *when + you were last there*. A survey mission fills in a whole area at once. +- **The border** is drawn between adjacent remembered cells that you recall as + belonging to different sides — never from the front's true position, which + would hand you a live readout of a line that is supposed to move behind your + back. +- **Old notes fade.** An observation drawn faint is one you should not trust. + +Nothing on the map reads live world state. Heat is what you last saw on a road; +territory is who held it when you last stood there. Both keep changing after you +leave, and the map has no way of knowing. 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. @@ -263,10 +300,18 @@ 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. +**Phase 4:** returning to a region after time away, you are genuinely unsure +what you will find until you are back on the ground — and when it has changed, +it reads as the world having moved rather than as a notification you were given. +If the world instead feels arbitrary, the drift is too fast: lengthen the wave +periods in `sim/regions.ts` before shrinking their amplitude, since it is the +*rate* of change that reads as noise, not the size of it. + +Next is **Phase 5** — narrative and mission texture, all four mission types made +to feel distinct, and signals that the enemy has other priorities. Before that, +two things are worth doing regardless: persistence (nothing survives a reload) +and taking the debug lines out of the HUD, since several gates now depend on +reading the world rather than a readout. 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 diff --git a/src/main.ts b/src/main.ts index 03120ae..67695ec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,14 @@ import { routeAt, segmentAt } from './sim/roads'; import { baseAt, placeBases } from './sim/bases'; import { buildGraph } from './sim/routing'; import { createIntel, observe, reveal, survey } from './sim/intel'; -import { controlAt, createFront, DECAY_MULTIPLIER, HEAT_MULTIPLIER } from './sim/regions'; +import { + applyMissionImpact, + controlAt, + createFront, + stepFront, + 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'; @@ -40,7 +47,7 @@ async function boot() { const physics = await createPhysics(model); const view = createScene(model); const markers = createMarkers(view.scene, bases); - const minimap = createMinimap(model.roads, bases, front, model.extent); + const minimap = createMinimap(model.roads, bases, 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. @@ -71,10 +78,14 @@ async function boot() { 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), - ); + /** Who holds each road right now. Recomputed each step: the line moves. */ + const segmentControl = model.roads.segments.map(() => control); + const refreshSegmentControl = () => { + for (const s of model.roads.segments) { + segmentControl[s.id] = controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2); + } + }; + refreshSegmentControl(); /** Offers are generated once per arrival at a base, not every frame. */ let openBase: number | null = null; let offers: Offer[] = []; @@ -87,6 +98,7 @@ async function boot() { }; const nodeOf = (id: number) => model.roads.nodes[id]!; + const controlOf = (x: number, z: number) => controlAt(front, x, z); document.getElementById('boot')?.remove(); @@ -113,6 +125,10 @@ async function boot() { // Heat: the road remembers being used — including being driven alongside. // Behind your own lines it remembers nothing: nobody there is watching. + // The war moves whether or not the player is looking at it. + stepFront(front, dt); + refreshSegmentControl(); + const at = physics.chassis.translation(); control = controlAt(front, at.x, at.z); currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null; @@ -133,7 +149,7 @@ async function boot() { // 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); + reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf); const base = baseAt(bases, at.x, at.z); const stopped = Math.abs(speed) < 2.5; @@ -183,12 +199,24 @@ async function boot() { say('At the target. Stop the car and hold position.'); } else if (event?.kind === 'worked') { if (event.type === 'recon') { - const count = survey(intel, heat, model.roads, target.x, target.z, elapsed); + const count = survey( + intel, + heat, + model.roads, + target.x, + target.z, + elapsed, + undefined, + controlOf, + ); say(`Survey complete — ${count} roads mapped. Report back to any base.`); } else { say('Cargo delivered. Report back to any base.'); } } else if (event?.kind === 'completed') { + // The line moves because of what the player did. They are not told by + // how much — they find out by going back and looking. + applyMissionImpact(front, event.impact); const outcome = repair(condition, quests.parts); const spent = quests.parts - outcome.unused; condition = outcome.condition; @@ -201,7 +229,10 @@ async function boot() { } } - markers.setObjective(quests.active ? nodeOf(quests.active.targetNode) : null); + // Once the cargo is dropped or the survey is done, the target is no longer + // anywhere the player needs to be — the bases' own beacons take over. + const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null; + markers.setObjective(heading ? nodeOf(heading.targetNode) : null); }, render(_alpha, frameDt) { @@ -242,6 +273,7 @@ async function boot() { const target = quest ? nodeOf(quest.targetNode) : null; minimap.draw(intel, { + now: elapsed, x: p.x, z: p.z, // Yaw about +Y, measured from world +Z, which is the car's local forward. @@ -249,7 +281,7 @@ async function boot() { 2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x), ), - objective: target, + objective: quest && quest.stage !== 'return' ? target : null, }); hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { heat: { diff --git a/src/sim/intel.ts b/src/sim/intel.ts index fb0a8d2..640710c 100644 --- a/src/sim/intel.ts +++ b/src/sim/intel.ts @@ -7,6 +7,7 @@ * but ageing; roads you have not are blank. */ import { levelFor, type HeatLevel, type HeatState } from './heat'; +import { CONTROLS, type Control } from './regions'; import type { RoadNetwork } from './roads'; /** Side of a fog-of-war cell, metres. Coarse on purpose — this is a sketch map. */ @@ -23,6 +24,12 @@ export interface Intel { visitedNodes: Set; /** Ground actually laid eyes on, as a coarse grid. 1 = explored. */ explored: Uint8Array; + /** + * Who held each cell *when the player last stood in it*, as an index into + * CONTROLS. The front moves on its own, so this is the only honest thing the + * map can show: the player is never told a region changed hands behind them. + */ + rememberedControl: Uint8Array; cellsPerSide: number; /** World coordinate of the grid's lower corner. */ gridOrigin: number; @@ -39,6 +46,7 @@ export function createIntel(roads: RoadNetwork, extent: number): Intel { seenAt: new Array(roads.segments.length).fill(-1), visitedNodes: new Set(), explored: new Uint8Array(cellsPerSide * cellsPerSide), + rememberedControl: new Uint8Array(cellsPerSide * cellsPerSide), cellsPerSide, gridOrigin, }; @@ -56,8 +64,20 @@ export const isExplored = (intel: Intel, x: number, z: number): boolean => { 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 { +/** + * Marks everything within `radius` of a point as seen, recording who holds it + * right now. Re-revealing ground you are standing in refreshes that record — + * which is exactly how the player finds out the line has moved: by going back. + * + * Returns the count of cells that were blank before. + */ +export function reveal( + intel: Intel, + x: number, + z: number, + radius: number, + controlOf?: (x: number, z: number) => Control, +): number { let found = 0; const min = Math.floor((x - radius - intel.gridOrigin) / CELL_SIZE); const max = Math.ceil((x + radius - intel.gridOrigin) / CELL_SIZE); @@ -71,6 +91,7 @@ export function reveal(intel: Intel, x: number, z: number, radius: number): numb 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 (controlOf) intel.rememberedControl[index] = CONTROLS.indexOf(controlOf(cx, cz)); if (intel.explored[index] === 1) continue; intel.explored[index] = 1; found++; @@ -101,6 +122,7 @@ export function survey( z: number, now: number, radius = 130, + controlOf?: (x: number, z: number) => Control, ): number { let count = 0; for (const s of roads.segments) { @@ -111,7 +133,7 @@ export function survey( count++; } // A survey fills in the ground too, not just the roads through it. - reveal(intel, x, z, radius); + reveal(intel, x, z, radius, controlOf); return count; } diff --git a/src/sim/quests.ts b/src/sim/quests.ts index 69a9a0e..43e1ce2 100644 --- a/src/sim/quests.ts +++ b/src/sim/quests.ts @@ -25,6 +25,12 @@ export interface Offer { intel: RouteIntel; /** Parts awarded on hand-in. */ reward: number; + /** + * How far this job pushes the front line, in metres. The brief asks for + * impact to be weighted per mission rather than every job counting the same, + * so a long haul into unknown country moves the war more than a local errand. + */ + impact: number; } export interface ActiveQuest extends Offer { @@ -68,6 +74,17 @@ function rewardFor(type: MissionType, route: Route, novel: boolean): number { return base * (novel ? NOVELTY_BONUS : 1) * (type === 'recon' ? RECON_BONUS : 1); } +/** Metres of front-line movement a job is worth. */ +const IMPACT_BASE = 5; +const IMPACT_PER_KM = 11; + +function impactFor(type: MissionType, route: Route, novel: boolean): number { + // Supply runs move the line more than surveys: intelligence is worth having, + // but it is materiel that holds ground. + const base = IMPACT_BASE + (route.length / 1000) * IMPACT_PER_KM; + return base * (novel ? 1.4 : 1) * (type === 'delivery' ? 1.2 : 0.8); +} + /** * Builds the board for a base: always at least one previously visited target * and one the player has never been to, so the comparison the phase is testing @@ -117,6 +134,7 @@ export function offersAt( route, intel: summariseRoute(intel, route.segments, now), reward: rewardFor(type, route, novel) * randRange(rng, 0.9, 1.1), + impact: impactFor(type, route, novel), }); } return offers; @@ -139,7 +157,7 @@ export interface QuestStep { export type QuestEvent = | { kind: 'arrived' } | { kind: 'worked'; type: MissionType } - | { kind: 'completed'; parts: number }; + | { kind: 'completed'; parts: number; impact: number }; /** Advances the active mission. Returns whatever just happened, if anything. */ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null { @@ -178,7 +196,7 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null state.active = null; state.completed++; state.parts += quest.reward; - return { kind: 'completed', parts: quest.reward }; + return { kind: 'completed', parts: quest.reward, impact: quest.impact }; } return null; } diff --git a/src/sim/regions.test.ts b/src/sim/regions.test.ts index ae320ba..ceb8268 100644 --- a/src/sim/regions.test.ts +++ b/src/sim/regions.test.ts @@ -2,7 +2,17 @@ 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 { + applyMissionImpact, + BOUNDARIES, + controlAt, + createFront, + CONTROLS, + DECAY_MULTIPLIER, + HEAT_MULTIPLIER, + depthAt, + stepFront, +} from './regions'; import { createHeat, stepHeat } from './heat'; import { cellIndex, createIntel, isExplored, reveal } from './intel'; @@ -99,6 +109,118 @@ describe('heat under regional control', () => { }); }); +describe('the front moving on its own', () => { + const drift = (seconds: number, seed = 1337) => { + const f = createFront(seed, world.extent, world.spawn); + const samples: number[] = []; + for (let i = 0; i < seconds * 60; i++) { + stepFront(f, 1 / 60); + if (i % 60 === 0) samples.push(f.boundaries.liberated); + } + return { front: f, samples }; + }; + + it('drifts without the player doing anything', () => { + const { samples } = drift(240); + const spread = Math.max(...samples) - Math.min(...samples); + // The war continuing elsewhere has to be visible, not a rounding error. + expect(spread).toBeGreaterThan(15); + }); + + it('moves in both directions, not just outward', () => { + const { front, samples } = drift(400); + const start = front.baseline.liberated; + expect(Math.min(...samples)).toBeLessThan(start); + expect(Math.max(...samples)).toBeGreaterThan(start); + }); + + it('stays bounded rather than wandering off the map', () => { + for (const seed of [1, 2, 3, 4, 5]) { + const { front } = drift(900, seed); + for (const name of BOUNDARIES) { + expect(Math.abs(front.offset[name])).toBeLessThanOrEqual(70); + } + } + }); + + it('never lets the bands cross over each other', () => { + const f = createFront(1337, world.extent, world.spawn); + for (let i = 0; i < 60 * 600; i++) { + stepFront(f, 1 / 60); + // Also shove it hard, to make sure impact cannot invert the order either. + if (i % 600 === 0) applyMissionImpact(f, 40); + expect(f.boundaries.contested).toBeGreaterThan(f.boundaries.liberated); + expect(f.boundaries.occupied).toBeGreaterThan(f.boundaries.contested); + } + }); +}); + +describe('the front moving because of the player', () => { + it('pushes the line forward when a mission is completed', () => { + const f = createFront(1337, world.extent, world.spawn); + const before = { ...f.boundaries }; + applyMissionImpact(f, 20); + for (const name of BOUNDARIES) { + expect(f.boundaries[name]).toBeGreaterThan(before[name]); + } + }); + + it('moves the near boundary more than the far one', () => { + const f = createFront(1337, world.extent, world.spawn); + const before = { ...f.boundaries }; + applyMissionImpact(f, 20); + const moved = BOUNDARIES.map((n) => f.boundaries[n] - before[n]); + expect(moved[0]!).toBeGreaterThan(moved[1]!); + expect(moved[1]!).toBeGreaterThan(moved[2]!); + }); + + it('can liberate ground that used to be contested', () => { + const f = createFront(1337, world.extent, world.spawn); + // A point just past the liberated edge. + const depth = f.boundaries.liberated + 20; + const x = f.axis.x * depth; + const z = f.axis.z * depth; + expect(controlAt(f, x, z)).toBe('contested'); + + for (let i = 0; i < 4; i++) applyMissionImpact(f, 15); + expect(controlAt(f, x, z)).toBe('liberated'); + }); +}); + +describe('remembered territory', () => { + it('records who held the ground at the moment it was seen', () => { + const f = createFront(1337, world.extent, world.spawn); + const map = createIntel(world.roads, world.extent); + reveal(map, world.spawn.x, world.spawn.z, 40, (x, z) => controlAt(f, x, z)); + + const index = cellIndex(map, world.spawn.x, world.spawn.z)!; + expect(CONTROLS[map.rememberedControl[index]!]).toBe('liberated'); + }); + + it('does not update itself when the line moves behind the player', () => { + const f = createFront(1337, world.extent, world.spawn); + const map = createIntel(world.roads, world.extent); + + // Somewhere past the line, seen once. + const depth = f.boundaries.liberated + 20; + const x = f.axis.x * depth; + const z = f.axis.z * depth; + reveal(map, x, z, 20, (px, pz) => controlAt(f, px, pz)); + const index = cellIndex(map, x, z)!; + expect(CONTROLS[map.rememberedControl[index]!]).toBe('contested'); + + // The war moves on; the player is elsewhere. + for (let i = 0; i < 6; i++) applyMissionImpact(f, 15); + expect(controlAt(f, x, z)).toBe('liberated'); + // The map still says what the player last saw. It is wrong, and it should be. + expect(CONTROLS[map.rememberedControl[index]!]).toBe('contested'); + + // Going back is the only way to find out. + reveal(map, x, z, 20, (px, pz) => controlAt(f, px, pz)); + expect(CONTROLS[map.rememberedControl[index]!]).toBe('liberated'); + }); +}); + describe('fog of war', () => { const intel = () => createIntel(world.roads, world.extent); diff --git a/src/sim/regions.ts b/src/sim/regions.ts index 0efc372..9473073 100644 --- a/src/sim/regions.ts +++ b/src/sim/regions.ts @@ -15,14 +15,26 @@ import { makeRng } from '../core/rng'; export const CONTROLS = ['liberated', 'contested', 'occupied', 'frontier'] as const; export type Control = (typeof CONTROLS)[number]; +export const BOUNDARIES = ['liberated', 'contested', 'occupied'] as const; +export type BoundaryName = (typeof BOUNDARIES)[number]; +export type Boundaries = Record; + 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 }; + /** Distance along the axis at which each band ends. Monotonic. */ + boundaries: Boundaries; + /** Where the line would sit with no war going on. Drift is pulled back here. */ + baseline: Boundaries; + /** Current displacement from baseline, metres. */ + offset: Boundaries; + /** Ground won by the player, which the enemy slowly presses back. */ + playerOffset: Boundaries; + /** Phases of the waves the whole front shares. */ + sharedPhases: number[]; + /** Phases of each boundary's own small wobble. */ + phases: Record; + elapsed: number; } /** @@ -47,6 +59,37 @@ export const DECAY_MULTIPLIER: Record = { frontier: 0.8, }; +/** + * Ambient drift is a sum of slow waves rather than a random walk. + * + * A walk either wanders off the map or, once you rein it in, moves so little + * that the war reads as static. Incommensurate periods give movement that is + * unpredictable in practice, always bounded, and reproducible from the seed — + * so "the region changed while you were away" is testable rather than hoped for. + */ +/** + * Most of the movement is shared by every boundary: a front that advances + * advances as a whole. Each band then wobbles a little on its own, so the map + * does not read as one rigid object sliding back and forth. + */ +const SHARED_WAVES = [ + { period: 97, amplitude: 20 }, + { period: 173, amplitude: 15 }, + { period: 271, amplitude: 10 }, +]; +const LOCAL_WAVES = [ + { period: 61, amplitude: 6 }, + { period: 113, amplitude: 5 }, +]; +/** Cap on total displacement from baseline, metres. */ +const DRIFT_RANGE = 70; +/** Seconds over which ground the player won is pressed back. */ +const PLAYER_TAU = 420; +/** Cap on how far the player alone can shift a boundary, metres. */ +const PLAYER_RANGE = 60; +/** Minimum metres between adjacent boundaries, so bands cannot cross over. */ +const MIN_BAND = 40; + export function createFront(seed: number, extent: number, home: { x: number; z: number }): Front { const rng = makeRng(seed ^ 0x51ed270b); const angle = rng() * Math.PI * 2; @@ -55,14 +98,96 @@ export function createFront(seed: number, extent: number, home: { x: number; z: // 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, - }, + const baseline: Boundaries = { + liberated: at + extent * 0.3, + contested: at + extent * 0.75, + occupied: at + extent * 1.3, }; + const zero = (): Boundaries => ({ liberated: 0, contested: 0, occupied: 0 }); + const front: Front = { + axis, + boundaries: { ...baseline }, + baseline, + offset: zero(), + playerOffset: zero(), + sharedPhases: SHARED_WAVES.map(() => rng() * Math.PI * 2), + phases: { + liberated: LOCAL_WAVES.map(() => rng() * Math.PI * 2), + contested: LOCAL_WAVES.map(() => rng() * Math.PI * 2), + occupied: LOCAL_WAVES.map(() => rng() * Math.PI * 2), + }, + elapsed: 0, + }; + // Bring `boundaries` in line with the waves at t=0, or the very first step + // would look like a jump. + stepFront(front, 0); + return front; +} + +const waveSum = ( + waves: ReadonlyArray<{ period: number; amplitude: number }>, + phases: number[], + t: number, +): number => + waves.reduce( + (total, wave, i) => total + wave.amplitude * Math.sin((t / wave.period) * Math.PI * 2 + phases[i]!), + 0, + ); + +const ambientAt = (front: Front, name: BoundaryName): number => + waveSum(SHARED_WAVES, front.sharedPhases, front.elapsed) + + waveSum(LOCAL_WAVES, front.phases[name], front.elapsed); + +function settle(front: Front): void { + for (const name of BOUNDARIES) { + front.offset[name] = Math.max(-DRIFT_RANGE, Math.min(DRIFT_RANGE, front.offset[name])); + front.boundaries[name] = front.baseline[name] + front.offset[name]; + } + // Keep the bands in order even when drift and player impact disagree. + front.boundaries.contested = Math.max( + front.boundaries.contested, + front.boundaries.liberated + MIN_BAND, + ); + front.boundaries.occupied = Math.max( + front.boundaries.occupied, + front.boundaries.contested + MIN_BAND, + ); +} + +/** + * The war continuing without you. + * + * This is not background flavour — the brief is explicit that ambient drift is + * what makes the setting a war rather than a level, and that it is the *only* + * mechanism for the enemy's attention being pulled elsewhere. So the line moves + * whether or not the player is doing anything, and in both directions. + */ +export function stepFront(front: Front, dt: number): void { + front.elapsed += dt; + for (const name of BOUNDARIES) { + // Ground taken is not held forever; the enemy presses back on its own. + front.playerOffset[name] *= Math.exp(-dt / PLAYER_TAU); + front.offset[name] = ambientAt(front, name) + front.playerOffset[name]; + } + settle(front); +} + +/** + * A completed mission pushes the line forward, weighted by how much the job + * was worth. The near boundaries move most: liberating ground consolidates what + * is already yours faster than it pushes the far edge of the war. + */ +const IMPACT_WEIGHT: Boundaries = { liberated: 1, contested: 0.7, occupied: 0.4 }; + +export function applyMissionImpact(front: Front, impact: number): void { + for (const name of BOUNDARIES) { + front.playerOffset[name] = Math.max( + -PLAYER_RANGE, + Math.min(PLAYER_RANGE, front.playerOffset[name] + impact * IMPACT_WEIGHT[name]), + ); + front.offset[name] = ambientAt(front, name) + front.playerOffset[name]; + } + settle(front); } /** Signed distance along the front axis. Higher is deeper into enemy ground. */ diff --git a/src/ui/minimap.ts b/src/ui/minimap.ts index 2eb98da..8a80f6e 100644 --- a/src/ui/minimap.ts +++ b/src/ui/minimap.ts @@ -2,7 +2,7 @@ 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'; +import { CONTROLS, type Control } from '../sim/regions'; /** * A sketch map, drawn from `Intel` rather than from the world. @@ -12,7 +12,16 @@ import { controlAt, type Control, type Front } from '../sim/regions'; * 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. + * + * Nothing here reads live world state. Heat is what you last saw on a road; + * territory is who held it when you last stood there. Both go on drifting once + * you leave, and the map has no way of knowing — which is the point. Old notes + * are drawn faded, so you can see at a glance which of them you should not trust. */ +/** Seconds after which an observation is drawn as faded as it will ever get. */ +const STALE_AFTER = 240; +const FRESH_ALPHA = 1; +const STALE_ALPHA = 0.35; const SIZE = 190; const PADDING = 12; @@ -36,14 +45,11 @@ export interface MinimapView { /** Car heading in radians, matching the world's yaw convention. */ heading: number; objective: { x: number; z: number } | null; + /** Elapsed time, for ageing observations. */ + now: number; } -export function createMinimap( - roads: RoadNetwork, - bases: Base[], - front: Front, - worldExtent: number, -) { +export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) { const canvas = document.createElement('canvas'); canvas.id = 'minimap'; const scale = devicePixelRatio > 1 ? 2 : 1; @@ -69,41 +75,45 @@ export function createMinimap( draw(intel: Intel, view: MinimapView) { ctx.clearRect(0, 0, SIZE, SIZE); - // --- Explored ground, tinted by who holds it --- + // --- Explored ground, tinted by who held it when you were last there --- 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 index = row * intel.cellsPerSide + col; + if (intel.explored[index] !== 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)]; + ctx.fillStyle = CONTROL_COLOURS[CONTROLS[intel.rememberedControl[index]!]!]; // +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 --- + // --- The border, drawn where remembered territory changes hands --- + drawRememberedBorder(ctx, intel, px); + + // --- Roads: rough everywhere, detailed where seen, faded where stale --- 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) { + if (hasSeen(intel, s.id)) { + const age = Math.min(1, (view.now - intel.seenAt[s.id]!) / STALE_AFTER); ctx.setLineDash([]); ctx.lineWidth = 2; + ctx.globalAlpha = FRESH_ALPHA + (STALE_ALPHA - FRESH_ALPHA) * age; ctx.strokeStyle = HEAT_COLOURS[intel.rememberedLevel[s.id]!]; } else { // Known to exist, nothing known about it. ctx.setLineDash([2, 3]); ctx.lineWidth = 1; + ctx.globalAlpha = 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); + ctx.globalAlpha = 1; // --- Bases --- for (const base of bases) { @@ -146,44 +156,41 @@ export function createMinimap( } /** - * 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. + * The border is not drawn from the front's true position — that would hand the + * player a live readout of a line that is supposed to move behind their back. + * Instead it is drawn between adjacent explored cells that the player remembers + * as belonging to different sides. You know where the border was because you + * crossed it; if it has since moved, your map is simply wrong. */ -function drawFront( +function drawRememberedBorder( 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]); + const size = intel.cellsPerSide; + ctx.strokeStyle = '#98a3b1'; ctx.lineWidth = 1; + ctx.beginPath(); - 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; + for (let row = 0; row < size; row++) { + for (let col = 0; col < size; col++) { + const index = row * size + col; + if (intel.explored[index] !== 1) continue; + const control = intel.rememberedControl[index]; + const wx = intel.gridOrigin + col * CELL_SIZE; + const wz = intel.gridOrigin + row * CELL_SIZE; + + const right = index + 1; + if (col + 1 < size && intel.explored[right] === 1 && intel.rememberedControl[right] !== control) { + ctx.moveTo(px(wx + CELL_SIZE), px(wz)); + ctx.lineTo(px(wx + CELL_SIZE), px(wz + CELL_SIZE)); } - if (!drawing) { - ctx.moveTo(px(x), px(z)); - drawing = true; - } else { - ctx.lineTo(px(x), px(z)); + const below = index + size; + if (row + 1 < size && intel.explored[below] === 1 && intel.rememberedControl[below] !== control) { + ctx.moveTo(px(wx), px(wz + CELL_SIZE)); + ctx.lineTo(px(wx + CELL_SIZE), px(wz + CELL_SIZE)); } } - ctx.stroke(); } - ctx.setLineDash([]); + ctx.stroke(); }