Mission types. Four of them, differing in pacing and risk rather than in the noun on the board: a quick supply drop, a retrieval whose danger is all on the return leg, a long stationary intel transmission in the open, and a cheap survey whose payoff is the map. Retrieval carries cargo that impacts damage, and pays for what arrives rather than what you set off with, which makes the drive home a different problem from the drive out. Radio. Chatter framing the player as an undercover driver being talked at by people busy elsewhere. Every line is triggered by something actually happening in the sim — crossing a border, a road you personally made notorious, a car past saving, or the front genuinely drifting. The enemy-has-other-priorities line fires off measured drift, so the brief's ambient-drift-is-the-mechanism idea is said out loud without ever showing a number. Topics carry cooldowns and never repeat a sentence back to back; a test caught that they could. Field work. Past the line, with nothing in hand, a wounded stranger or a contact or a strippable wreck occasionally turns up. Deliberately not on a board: no route preview, no comparison, no shopping between them. Take the detour in front of you or drive on. They never appear behind your own lines, where a safe errand would defeat the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
7.4 KiB
TypeScript
210 lines
7.4 KiB
TypeScript
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<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;
|
|
/** 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();
|
|
}
|