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:
+25
-1
@@ -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 ${
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
Reference in New Issue
Block a user