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
co-authored by Claude Opus 5
parent 73e8ac68f1
commit 477195a7d6
12 changed files with 737 additions and 40 deletions
+25 -1
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
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([]);
}