Phase 4: a front line that moves, and stops the map from telling you

The objective beacon now goes out once the cargo is dropped or the survey is
done. The target is no longer somewhere the player needs to be, and the bases'
own beacons take over for the trip home.

The front moves from two independent sources. Mission completions push it
forward, weighted by what the job was worth, and that contribution decays over
about seven minutes so ground won is not held for free. On top of that the line
drifts on its own in both directions, which the brief is explicit about: ambient
drift is what makes this a war rather than a level, and it is the only mechanism
for the enemy's attention being pulled elsewhere.

Drift is a sum of slow waves, not a random walk. A first attempt at a damped
walk moved 3m in four minutes, because white noise scaled by dt averages itself
out. Waves at incommensurate periods are unpredictable in practice, bounded by
construction, and reproducible from the seed, so a test can assert that a region
changed hands while the player was away. Most of the movement is shared across
boundaries, since a front advances as a whole; independent per-band waves were
squeezing bands into the ordering clamp.

Crucially the map no longer reads live state. Intel now records who held each
cell when the player last stood in it, and the minimap draws that, with the
border inferred from where remembered control changes rather than from the
front's true position. Otherwise the map would quietly hand the player a live
readout of a line that is supposed to move behind their back. Stale roads are
drawn faded, so untrustworthy notes look untrustworthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 11:42:34 +02:00
co-authored by Claude Opus 5
parent 477195a7d6
commit f5742de595
7 changed files with 457 additions and 86 deletions
+42 -10
View File
@@ -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: {
+25 -3
View File
@@ -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<number>;
/** 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;
}
+20 -2
View File
@@ -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;
}
+123 -1
View File
@@ -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);
+137 -12
View File
@@ -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<BoundaryName, 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 };
/** 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<BoundaryName, number[]>;
elapsed: number;
}
/**
@@ -47,6 +59,37 @@ export const DECAY_MULTIPLIER: Record<Control, number> = {
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. */
+52 -45
View File
@@ -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();
}