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
+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();
}