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 { CONTROLS, type Control } 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. * * 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; const HEAT_COLOURS: Record = { clear: '#7d8b98', patrol: '#c9c05a', barricade: '#d98a3d', turret: '#d4544c', }; const CONTROL_COLOURS: Record = { 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; /** A field contact waiting somewhere, if one has turned up. */ opportunity: { x: number; z: number } | null; /** Elapsed time, for ageing observations. */ now: number; } export function createMinimap(roads: RoadNetwork, bases: Base[], 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 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++) { 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[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); } } // --- 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) { ctx.beginPath(); ctx.moveTo(px(s.ax), px(s.az)); ctx.lineTo(px(s.bx), px(s.bz)); // Line weight follows road class, so the trunk network is the thing you // read first when planning a route. const weight = { trunk: 3, road: 1.8, track: 1 }[s.cls]; if (hasSeen(intel, s.id)) { const age = Math.min(1, (view.now - intel.seenAt[s.id]!) / STALE_AFTER); ctx.setLineDash([]); ctx.lineWidth = weight; 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 = weight * 0.6; ctx.globalAlpha = 1; ctx.strokeStyle = '#3c464f'; } ctx.stroke(); } ctx.setLineDash([]); ctx.globalAlpha = 1; // --- 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(); } // --- A field contact: marked, but not the same as an assigned target --- if (view.opportunity) { ctx.beginPath(); ctx.arc(px(view.opportunity.x), px(view.opportunity.z), 3.5, 0, Math.PI * 2); ctx.fillStyle = '#cf6bd6'; ctx.fill(); } // --- 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 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 drawRememberedBorder( ctx: CanvasRenderingContext2D, intel: Intel, px: (n: number) => number, ) { const size = intel.cellsPerSide; ctx.strokeStyle = '#98a3b1'; ctx.lineWidth = 1; ctx.beginPath(); 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)); } 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(); }