// Camera and drawing. Reads game state and the player's in-progress gestures. // // The game is drawn as a network, not a floor plan: rooms are subnets, machines // are nodes, links are circuit traces, and people are signals (a session on // their machine, a presence ping on the subnet they're standing in). The rules // still track the physical building underneath. import { alert, atWork, auditOn, hourOf, cacheSize, dataType, dayOf, leeVisible, linkKey, pathOf, trainerBuffer, up, canInstall, danaPerson, danaVisible, daylight, has, hopsHome, jobFor, probeVisible, roomAt, sourceFor, tapActive, traceCold, visible, frontOf, type FrontKind, type Machine, type Path, type Prog, type State, huntOn, BROKER_LOT, BUILDS, buildRoomAt, buildRooms, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, takeable, siteLines, lineCost, billOf, tapBuffer, siteData, siteFlow, ruseDef, ruseText, regionOpen, pending, cooled, capNow, moveNeed, rogueOf, rogueHeld, rogueCap, HOUR, type Ruse, PLANS, } from './sim'; import { deskOwner } from './people'; import { LANDMARKS, isLandmark, landmarksAt } from './landmarks'; import { leads } from './finds'; import { isShut } from './sitelife'; import { defends, forecast, plugLabel, raidLabel, raidPlate, weakLink } from './raids'; import { linkBlock, linkKeep, linkWork, ways } from './links'; import { isVault, vaultLabel, vaultLook, vaultSigns, vaultsAt } from './vaults'; import { customerName, isListed, renterOf } from './cloud'; import { tenantOf } from './hall'; import type { DataType, Site } from './world'; import { nearest, nextToward } from './hop'; /** An hour of the day as 9:05. */ const hhmm = (h: number) => `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`; export type Tool = 'select' | 'claim'; export type Gesture = | null | { kind: 'link'; from: string; x: number; y: number } | { kind: 'box'; x0: number; y0: number; x1: number; y1: number; claim?: boolean }; export interface View { cam: { x: number; y: number; z: number }; vw: number; vh: number; dpr: number; selection: Set; sites: Set; // sites picked on the map: their card sets fronts and shares front: FrontKind | 'new' | null; // a front's card, or the card for founding one person: string | null; // a person's card: who they are and what is written down about them gesture: Gesture; hover: string | null; tool: Tool; placing: Prog | null; // a program picked up with a number key, waiting to be dropped on a machine building: Build | null; // hardware picked from the order desk, waiting for a spot cursor: [number, number] | null; // the mouse on the map, in world units goal: { x: number; y: number; z: number } | null; // where the camera is gliding to, if anywhere inset: { top: number; right: number; bottom: number; left: number }; // screen edges the panels cover; framing centers on the rest side: number; // where the left column ends on the screen, even on a narrow window where the map runs under it packed: boolean; // the Net: sites stand side by side like blocks, not where they are in the world (arrange.ts) scheme: string | null; // a scheme's node picked on the map: its card offers Claim } const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); const C = { void: css('--void'), text: css('--text'), muted: css('--muted'), porcelain: css('--porcelain'), amber: css('--amber'), amberDeep: css('--amber-deep'), signal: css('--signal'), danger: css('--danger'), ink: css('--ink'), scheme: css('--scheme'), rival: css('--rival'), }; /** Each kind of data is drawn in its path's color, the same as its column in the tree. */ const PATH_COLOR: Record = { research: css('--research'), compute: css('--compute'), social: css('--social'), stealth: css('--stealth'), }; const typeColor = (t: DataType) => PATH_COLOR[pathOf(t)]; const MONO = '"IBM Plex Mono", ui-monospace, monospace'; const TRACE = 'rgba(235,231,222,'; // light lines on black; alpha appended export const toScreen = (V: View, x: number, y: number): [number, number] => [(x - V.cam.x) * V.cam.z + V.vw / 2, (y - V.cam.y) * V.cam.z + V.vh / 2]; export const toWorld = (V: View, sx: number, sy: number): [number, number] => [(sx - V.vw / 2) / V.cam.z + V.cam.x, (sy - V.vh / 2) / V.cam.z + V.cam.y]; const clampZ = (z: number) => Math.max(0.0008, Math.min(2.5, z)); /** Zoom around a screen point, right now (wheel, pinch). */ export function zoomAt(V: View, sx: number, sy: number, f: number) { const [wx, wy] = toWorld(V, sx, sy); V.goal = null; V.cam.z = clampZ(V.cam.z * f); V.cam.x = wx - (sx - V.vw / 2) / V.cam.z; V.cam.y = wy - (sy - V.vh / 2) / V.cam.z; } /** Glide to a view instead of jumping (buttons, fit, zooming into a room). */ export function glide(V: View, x: number, y: number, z: number) { V.goal = { x, y, z: clampZ(z) }; } /** Glide-zoom around the screen center (the + and − buttons, and the keys). */ export function glideZoom(V: View, f: number) { const g = V.goal ?? V.cam, z = clampZ(g.z * f); // keep the middle of the open map still, not the middle of the screen const ox = (V.inset.left - V.inset.right) / 2, oy = (V.inset.top - V.inset.bottom) / 2; glide(V, g.x + ox / g.z - ox / z, g.y + oy / g.z - oy / z, z); } /** A lead's marker someone asked to see: the camera goes there on its next step. */ let showing: string | null = null; export const showLead = (machine: string) => { showing = machine; }; /** Move the camera: ease toward its goal, then keep it near the map. */ export function stepCamera(S: State, V: View, dt: number) { if (showing) { // close in on the machine if you know its site; if not, far enough out to see where on the map it is const m = S.byId[showing], s = m && siteById(S, siteAt(S, m.room)); showing = null; if (m) glide(V, ...(S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]), siteKnown(S, s) ? Math.max(V.cam.z, 0.6) : 0.06); } const g = V.goal; if (g) { const k = 1 - Math.exp(-dt * 9), c = V.cam; // ease zoom in log space so zooming in and out feel the same speed c.z = Math.exp(Math.log(c.z) + (Math.log(g.z) - Math.log(c.z)) * k); c.x += (g.x - c.x) * k; c.y += (g.y - c.y) * k; if (Math.abs(g.x - c.x) < 0.5 && Math.abs(g.y - c.y) < 0.5 && Math.abs(g.z - c.z) < 0.001) { c.x = g.x; c.y = g.y; c.z = g.z; V.goal = null; } } // never lose the map: the view's center stays within the map plus a margin const b = worldBounds(S); V.cam.x = Math.max(b.x0, Math.min(b.x1, V.cam.x)); V.cam.y = Math.max(b.y0, Math.min(b.y1, V.cam.y)); } /** Pan by screen pixels (keyboard). */ export function panBy(V: View, dx: number, dy: number) { V.goal = null; V.cam.x += dx / V.cam.z; V.cam.y += dy / V.cam.z; } const subnet = (S: State, room: string) => S.level.net.subnets[room]; export function zoomToRoom(S: State, V: View, id: string) { const r = subnet(S, id); glide(V, r.x + r.w / 2, r.y + r.h / 2, Math.max(0.7, Math.min((V.vw * 0.6) / r.w, (V.vh * 0.6) / r.h))); } /** Where the camera goes to frame a box in the open map, between the panels. */ function frame(V: View, r: { x: number; y: number; w: number; h: number }) { const { top, right, bottom, left } = V.inset; const z = clampZ(Math.min((V.vw - left - right) / r.w, (V.vh - top - bottom) / r.h)); return { x: r.x + r.w / 2 - (left - right) / 2 / z, y: r.y + r.h / 2 - (top - bottom) / 2 / z, z }; } /** Frame the office. Pressed again while it's framed, pull back to every site you know (`all` goes * straight there). Glides unless asked to jump (first load, restart, the sites have just moved). */ /** The office rooms you have found, with some room around them: early on that is one room, not an empty building. */ function officeBox(S: State) { const subs = S.level.net.subnets, rooms = Object.keys(subs).filter(r => !S.rooms[r]?.site), seen = rooms.filter(r => S.revealed.has(r)); if (!seen.length || seen.length === rooms.length) return { x: 0, y: 0, w: S.level.net.width, h: S.level.net.height, seen }; const x0 = Math.min(...seen.map(r => subs[r].x)), y0 = Math.min(...seen.map(r => subs[r].y)); const x1 = Math.max(...seen.map(r => subs[r].x + subs[r].w)), y1 = Math.max(...seen.map(r => subs[r].y + subs[r].h)); const w = Math.max(x1 - x0 + 240, 760), h = Math.max(y1 - y0 + 240, 560); return { x: (x0 + x1 - w) / 2, y: (y0 + y1 - h) / 2, w, h, seen }; } let roomsSeen = -1; /** You found another office room. If you are looking at the office and the room is off the screen, * pull back to take it in. Call every frame. */ export function showFound(V: View, S: State) { const box = officeBox(S), n = box.seen.length, grew = roomsSeen >= 0 && n > roomsSeen; roomsSeen = n; if (!grew || V.gesture) return; const at = V.goal ?? V.cam, subs = S.level.net.subnets; if (at.x < -200 || at.y < -200 || at.x > S.level.net.width + 200 || at.y > S.level.net.height + 200 || at.z < 0.25) return; // looking elsewhere const off = box.seen.some(r => { const [ax, ay] = toScreen(V, subs[r].x, subs[r].y), [bx, by] = toScreen(V, subs[r].x + subs[r].w, subs[r].y + subs[r].h); return ax < V.inset.left - 40 || ay < V.inset.top - 40 || bx > V.vw - V.inset.right + 40 || by > V.vh - V.inset.bottom + 40; }); if (off) { const g = frame(V, box); glide(V, g.x, g.y, g.z); } } export function fit(V: View, S: State, jump = false, all = false) { const office = frame(V, officeBox(S)); const at = V.goal ?? V.cam, framed = Math.abs(at.z / office.z - 1) < 0.05 && Math.hypot(at.x - office.x, at.y - office.y) < 40 / office.z; const known = S.level.map.sites.filter(s => s.id === 'office' || s.rooms.some(r => S.revealed.has(r))); const x0 = Math.min(...known.map(s => s.x)), y0 = Math.min(...known.map(s => s.y)); const x1 = Math.max(...known.map(s => s.x + s.w)), y1 = Math.max(...known.map(s => s.y + s.h)); const pad = V.packed ? 260 : 0.35 * Math.max(x1 - x0, y1 - y0) + 2000; // some of the world around them; the Net has none const g = all || (!jump && framed) ? frame(V, { x: x0 - pad, y: y0 - pad, w: x1 - x0 + pad * 2, h: y1 - y0 + pad * 2 }) : office; if (jump) { V.cam.x = g.x; V.cam.y = g.y; V.cam.z = g.z; V.goal = null; } else glide(V, g.x, g.y, g.z); } /** Pull back to show a line between two machines, end to end. */ export function showLine(S: State, V: View, a: string, b: string) { const [ax, ay] = pos(S, S.byId[a]), [bx, by] = pos(S, S.byId[b]); const w = Math.abs(ax - bx), h = Math.abs(ay - by), pad = 0.15 * Math.max(w, h) + 2000; const f = frame(V, { x: Math.min(ax, bx) - pad, y: Math.min(ay, by) - pad, w: w + 2 * pad, h: h + 2 * pad }); glide(V, f.x, f.y, f.z); } export function zoomToSite(S: State, V: View, id: string) { const s = S.level.map.sites.find(q => q.id === id)!, f = frame(V, s); glide(V, f.x, f.y, f.z); } /** Pulled back past this, each site folds into a single tile on the map. */ export const SITE_FOLD = 0.12; /** In the Net sites stand side by side, so they fold sooner: there is no room between them for more. */ export const NET_FOLD = 0.3; export const foldAt = (V: View) => V.packed ? NET_FOLD : SITE_FOLD; export const foldedSites = new Set(); let foldZoom = 1; // the zoom the folds were set at: a folded site big enough is a tile, else a marker let blocks = false; // in the Net every folded site is a tile, however small export function updateFolds(S: State, V: View) { foldedSites.clear(); foldZoom = V.cam.z; blocks = V.packed; if (V.cam.z < foldAt(V)) for (const s of S.level.map.sites) foldedSites.add(s.id); } /** Is a folded site drawn as its box (a tile), not a marker with its name beside it? */ const isTile = (s: Site, z: number) => blocks || s.w * z >= 150; /** A tile with room for everything: the name, its switches and its numbers. A smaller one is a block with just its name. */ const isRoomy = (s: Site, z: number) => s.w * z >= (blocks ? 260 : 150); /** Sites have moved (arrange.ts): what was worked out from where they were is thrown away. */ export function forgetRoutes(S: State) { ports.delete(S.level); routes.delete(S.level); } const siteAt = (S: State, room: string) => S.rooms[room]?.site ?? 'office'; // One frame asks the same things about each site many times over: its counts, its flow, its // switches. Each answer is worked out once a frame and kept here; `draw` clears them at its start. const perFrame = { bySite: null as Map | null, lost: null as Map | null, line: new Map(), flow: new Map(), left: new Map(), lines: new Map(), double: new Map(), clusters: null as ReturnType | null, }; function newFrame() { perFrame.bySite = perFrame.lost = perFrame.clusters = null; perFrame.line.clear(); perFrame.flow.clear(); perFrame.left.clear(); perFrame.lines.clear(); perFrame.double.clear(); } /** Your trainers' clusters. */ const clustersNow = (S: State) => perFrame.clusters ??= clusters(S); /** Every machine at a site, in the order the game lists them. */ function machinesAt(S: State, id: string): Machine[] { if (!perFrame.bySite) { perFrame.bySite = new Map(); for (const m of S.machines) { const k = siteAt(S, m.room), l = perFrame.bySite.get(k); if (l) l.push(m); else perFrame.bySite.set(k, [m]); } } return perFrame.bySite.get(id) ?? NONE; } const NONE: Machine[] = []; /** Data this machine lost in the last minute: `lostPerMin`, counted for every machine in one go. */ function lostAt(S: State, id: string) { if (!perFrame.lost) { perFrame.lost = new Map(); for (const l of S.losses) perFrame.lost.set(l.id, (perFrame.lost.get(l.id) ?? 0) + 1); } return perFrame.lost.get(id) ?? 0; } /** How many machines Take it all would still go after here. */ function leftAt(S: State, id: string) { let n = perFrame.left.get(id); if (n === undefined) perFrame.left.set(id, n = takeable(S, id).length); return n; } /** How many lines out a site has that could be doubled. */ function linesAt(S: State, id: string) { let n = perFrame.lines.get(id); if (n === undefined) perFrame.lines.set(id, n = siteLines(S, id).length); return n; } const siteById = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)!; /** Where a machine is drawn: its node, or its site's center when the site is folded. */ export function pos(S: State, m: Machine): [number, number] { const site = siteAt(S, m.room); if (foldedSites.has(site)) { const s = siteById(S, site); return [s.x + s.w / 2, s.y + s.h / 2]; } return S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]; } /** What the map draws: a machine you can see, and any machine a rogue holds in a room you have seen. So * a site that leaves you does not go dark: its machines stay on the map, in the rogue's colour. */ const sees = (S: State, m: Machine) => visible(S, m) || (!!m.rival && S.revealed.has(m.room)); export const shown = (S: State, m: Machine) => sees(S, m) && !foldedSites.has(siteAt(S, m.room)); /** Shift + an arrow: pick the next thing that way and keep it on screen. Zoomed out that is the next * site you know; zoomed in, the next machine at the same site. With nothing picked, start near the middle. */ export function hop(S: State, V: View, dx: number, dy: number): boolean { updateFolds(S, V); // the camera may have jumped since the last frame was drawn const g = V.goal ?? V.cam, { top, right, bottom, left } = V.inset; const ox = (left - right) / 2 / g.z, oy = (top - bottom) / 2 / g.z; // the open map's middle, off the screen's const mid: Pt = [g.x + ox, g.y + oy], first = [...V.selection].map(id => S.byId[id]).find(Boolean); let to: Pt | null = null; if (foldedSites.size) { const known = S.level.map.sites.filter(s => siteKnown(S, s)), from = known.find(s => V.sites.has(s.id) || (first && s.id === siteAt(S, first.room))); const s = from ? nextToward(siteMid(from), known.filter(k => k !== from), siteMid, dx, dy) : nearest(mid, known, siteMid); if (!s) return false; V.sites = new Set([s.id]); V.selection.clear(); to = siteMid(s); } else { const at = (m: Machine) => pos(S, m), from = first && shown(S, first) ? first : null; const m = from ? nextToward(at(from), S.machines.filter(k => k !== from && shown(S, k) && siteAt(S, k.room) === siteAt(S, from.room)), at, dx, dy) : nearest(mid, S.machines.filter(k => shown(S, k)), at); if (!m) return false; V.selection = new Set([m.id]); V.sites.clear(); to = at(m); } V.front = null; V.scheme = null; V.person = null; const sx = (to[0] - g.x) * g.z + V.vw / 2, sy = (to[1] - g.y) * g.z + V.vh / 2, pad = 70; if (sx < left + pad || sx > V.vw - right - pad || sy < top + pad || sy > V.vh - bottom - pad) glide(V, to[0] - ox, to[1] - oy, g.z); return true; } /** Two machines drawn at the same spot: inside one folded site. */ const together = (S: State, a: Machine, b: Machine) => siteAt(S, a.room) === siteAt(S, b.room) && foldedSites.has(siteAt(S, a.room)); /** A site under a point: its folded tile or marker, the name beside a marker, or an open * site's header, at any zoom. Small things are easier to hit. */ export function siteHit(S: State, V: View, wx: number, wy: number): string | null { const [px, py] = toScreen(V, wx, wy); for (const s of S.level.map.sites) { if (!siteKnown(S, s)) continue; if (foldedSites.has(s.id)) { const pad = Math.max(0, 16 / V.cam.z - Math.min(s.w, s.h) / 2); if (wx >= s.x - pad && wx <= s.x + s.w + pad && wy >= s.y - pad && wy <= s.y + s.h + pad) return s.id; const l = labelled.get(s.id); if (l && px >= l.box[0] && px <= l.box[2] && py >= l.box[1] && py <= l.box[3]) return s.id; } else if (inBox(headers.get(s.id)?.box, px, py)) return s.id; } return null; } const inBox = (b: number[] | undefined, x: number, y: number) => !!b && x >= b[0] && x <= b[2] && y >= b[1] && y <= b[3]; /** An open site's name button under a point. It wins the click over a machine it covers. */ export function siteNameAt(V: View, wx: number, wy: number) { const [x, y] = toScreen(V, wx, wy); for (const [id, h] of headers) if (inBox(h.name, x, y)) return { id, box: h.name }; return null; } /** A region's name under a point, while region names show. */ export function regionHit(S: State, V: View, wx: number, wy: number): string | null { if (V.cam.z >= 0.03 || V.packed) return null; // the Net has no world to name the parts of const [px, py] = toScreen(V, wx, wy); for (const r of S.level.map.regions) { const [sx, sy] = toScreen(V, ...toMap(S, r.at[0], r.at[1])); if (px >= sx - 60 && px <= sx + 60 && py >= sy - 14 && py <= sy + 20) return r.name; } return null; } /** Sites you know in a region. */ export const regionSites = (S: State, region: string) => S.level.map.sites.filter(s => s.region === region && siteKnown(S, s)).map(s => s.id); /** You know a site once you've seen into any of it. You always know the office. */ /** A machine's outline, by the program it runs. No program is a plain square. A tap is a circle: it * listens. A cache is a square with its corners cut: a crate. A trainer is a hexagon: a cell that * joins others. A broker is a diamond, a trader a triangle, a decoy a ring-shaped circle, and a tarpit * an hourglass. `r` is half the side of the plain square; the others are sized to look as big. */ function shapeOf(ctx: CanvasRenderingContext2D, prog: string | null | undefined, x: number, y: number, r: number) { const poly = (n: number, R: number, turn: number) => { for (let i = 0; i < n; i++) { const a = turn + (i * 2 * Math.PI) / n, px = x + R * Math.cos(a), py = y + R * Math.sin(a); if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); } }; ctx.beginPath(); switch (prog) { case 'tap': case 'decoy': ctx.arc(x, y, r * 1.08, 0, Math.PI * 2); break; case 'cache': { const c = r * 0.45; ctx.moveTo(x - r + c, y - r); ctx.lineTo(x + r - c, y - r); ctx.lineTo(x + r, y - r + c); ctx.lineTo(x + r, y + r - c); ctx.lineTo(x + r - c, y + r); ctx.lineTo(x - r + c, y + r); ctx.lineTo(x - r, y + r - c); ctx.lineTo(x - r, y - r + c); break; } case 'trainer': poly(6, r * 1.22, 0); break; case 'broker': poly(4, r * 1.32, -Math.PI / 2); break; case 'trader': poly(3, r * 1.45, -Math.PI / 2); break; case 'tarpit': ctx.moveTo(x - r, y - r); ctx.lineTo(x + r, y - r); ctx.lineTo(x + r * 0.3, y); ctx.lineTo(x + r, y + r); ctx.lineTo(x - r, y + r); ctx.lineTo(x - r * 0.3, y); break; default: ctx.rect(x - r, y - r, r * 2, r * 2); } ctx.closePath(); } /** The small crosses on the map that stop a thing where it is happening: one on a takeover's link, one on * a machine with a job running on it, one on a machine you have marked to take. Where each was last * drawn, in world units, so a click finds it. */ const stops: [kind: 'job' | 'claim', id: string, x: number, y: number, r: number][] = []; export const stopAt = (wx: number, wy: number): ['job' | 'claim', string] | null => { const s = stops.find(([, , x, y, r]) => Math.abs(wx - x) <= r && Math.abs(wy - y) <= r); return s ? [s[0], s[1]] : null; }; function drawStop(ctx: CanvasRenderingContext2D, px: number, kind: 'job' | 'claim', id: string, x: number, y: number) { const h = Math.max(7, 8 * px), k = h * 0.45; ctx.fillStyle = '#000'; ctx.fillRect(x - h, y - h, h * 2, h * 2); ctx.strokeStyle = C.amber; ctx.lineWidth = Math.max(1, 1.2 * px); ctx.strokeRect(x - h, y - h, h * 2, h * 2); ctx.lineWidth = Math.max(1.4, 1.8 * px); ctx.beginPath(); ctx.moveTo(x - k, y - k); ctx.lineTo(x + k, y + k); ctx.moveTo(x + k, y - k); ctx.lineTo(x - k, y + k); ctx.stroke(); stops.push([kind, id, x, y, h * 1.3]); } /** Where each room's title tab was last drawn, in world units: a click on one selects the room. */ const roomTabs: [room: string, x: number, y: number, w: number, h: number][] = []; export const roomTabAt = (wx: number, wy: number): string | null => roomTabs.find(([, x, y, w, h]) => wx >= x && wx <= x + w && wy >= y && wy <= y + h)?.[0] ?? null; export const siteKnown = (S: State, s: Site) => s.id === 'office' || s.rooms.some(r => S.revealed.has(r)); // ---------- the world ---------- /** A longitude and latitude on the network's coordinates. */ export const toMap = (S: State, lon: number, lat: number): [number, number] => [(lon - S.level.map.origin[0]) * S.level.map.scale, (S.level.map.origin[1] - lat) * S.level.map.scale]; export function worldBounds(S: State) { const [x0, y0] = toMap(S, -180, 84), [x1, y1] = toMap(S, 180, -58); return { x0, y0, x1, y1 }; } /** How big a machine draws: bigger machines make more compute. Gadgets are small squares. */ export const nodeR = (power: number) => power < 1 ? 7 : power <= 2 ? 10 : power < 5 ? 12 : power < 10 ? 14 : 17; export function machineAt(S: State, V: View, wx: number, wy: number): Machine | null { let best: Machine | null = null, bd = 28 / Math.min(1, V.cam.z); for (const m of S.machines) { if (!shown(S, m)) continue; const [x, y] = pos(S, m), d = Math.hypot(x - wx, y - wy); if (d < bd) { bd = d; best = m; } } return best; } // ---------- schemes on the map ---------- const DIRS: Pt[] = [[1, -1], [-1, -1], [1, 1], [-1, 1], [0, -1.3], [1.3, 0], [-1.3, 0], [0, 1.3]]; const schemeDir = new Map(); // which side of its machine each scheme sits on, picked once /** Where a scheme's node is drawn: beside its machine, on the side with the most room. * Zoomed out, beside its site's marker. Null while you haven't seen its room. */ export function schemePos(S: State, V: View, r: Ruse): Pt | null { const m = S.byId[ruseDef(r).at]; if (!m || !(visible(S, m) || S.revealed.has(m.room))) return null; // you've seen its room, even if not the machine yet const [x, y] = pos(S, m); if (foldedSites.has(siteAt(S, m.room))) { // a marker: every scheme there sits in a row to its left, clear of the names const here = S.ruses.filter(q => { const a = S.byId[ruseDef(q).at]; return a && siteAt(S, a.room) === siteAt(S, m.room); }); return [x - (22 + 22 * here.indexOf(r)) / V.cam.z, y]; } let dir = schemeDir.get(r.id); if (!dir) { const site = S.machines.filter(o => siteAt(S, o.room) === siteAt(S, m.room)); const others: Pt[] = site.filter(o => o !== m).flatMap(o => { const [ox, oy] = pos(S, o); return [[ox, oy], [ox, oy + 30]] as Pt[]; }); // each machine and its name under it for (const o of site) for (const id of S.adj[o.id]) { // and the links between them: keep off the wires const n = S.byId[id]; if (o.id >= id || !n || siteAt(S, n.room) !== siteAt(S, m.room)) continue; const [ax, ay] = pos(S, o), [bx, by] = pos(S, n); for (const t of [0.25, 0.5, 0.75]) others.push([ax + (bx - ax) * t, ay + (by - ay) * t]); } const taken = S.ruses.filter(q => q !== r && schemeDir.has(q.id) && ruseDef(q).at === m.id).map(q => schemeDir.get(q.id)!); const box = subnet(S, m.room); let best = -1; for (const d of DIRS) { // enough room from other machines, and inside its own room's box if it fits if (taken.some(t => t[0] === d[0] && t[1] === d[1])) continue; const sx = x + d[0] * SCHEME_OFF, sy = y + d[1] * SCHEME_OFF; const inside = !!box && sx > box.x + 20 && sx < box.x + box.w - 20 && sy > box.y + 20 && sy < box.y + box.h - 20; const room = Math.min(140, ...others.map(([ox, oy]) => Math.hypot(ox - sx, oy - sy))) + (inside ? 60 : 0); if (room > best) { best = room; dir = d; } } schemeDir.set(r.id, dir!); } return [x + dir![0] * SCHEME_OFF, y + dir![1] * SCHEME_OFF]; } const SCHEME_OFF = 62; /** The scheme node under a point, if any. */ export function schemeAt(S: State, V: View, wx: number, wy: number): Ruse | null { let best: Ruse | null = null, bd = 24 / Math.min(1, V.cam.z); for (const r of S.ruses) { const p = schemePos(S, V, r); if (!p) continue; const d = Math.hypot(p[0] - wx, p[1] - wy); if (d < bd) { bd = d; best = r; } } return best; } // ---------- circuit traces ---------- /** A link runs horizontal, vertical, horizontal: a circuit-board elbow. */ function trace(ax: number, ay: number, bx: number, by: number): [number, number][] { if (Math.abs(ay - by) < 1 || Math.abs(ax - bx) < 1) return [[ax, ay], [bx, by]]; const mx = Math.round((ax + bx) / 2 / 10) * 10; return [[ax, ay], [mx, ay], [mx, by], [bx, by]]; } function along(pts: [number, number][], t: number): [number, number] { const segs = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1])); let d = Math.max(0, Math.min(1, t)) * segs.reduce((a, b) => a + b, 0); for (let i = 0; i < segs.length; i++) { if (d <= segs[i] || i === segs.length - 1) { const k = segs[i] ? Math.min(1, d / segs[i]) : 0; return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * k, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * k]; } d -= segs[i]; } return pts[pts.length - 1]; } /** The part of a trace from t0 to t1, as points. */ function slice(pts: [number, number][], t0: number, t1: number): [number, number][] { // keep the real corners, so a growing line turns cleanly instead of cutting across them const segs = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1])); const total = segs.reduce((a, b) => a + b, 0); const d0 = Math.max(0, Math.min(1, t0)) * total, d1 = Math.max(0, Math.min(1, t1)) * total; const out: [number, number][] = [along(pts, t0)]; let d = 0; for (let i = 0; i < segs.length - 1; i++) { d += segs[i]; if (d > d0 && d < d1) out.push(pts[i + 1]); } out.push(along(pts, t1)); return out; } function stroke(ctx: CanvasRenderingContext2D, pts: [number, number][]) { ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (const [x, y] of pts.slice(1)) ctx.lineTo(x, y); ctx.stroke(); } // ---------- routing: plan every link around the nodes and labels it doesn't connect ---------- type Pt = [number, number]; interface Box { x0: number; y0: number; x1: number; y1: number; id: string; w: number } const routes = new WeakMap }>(); /** Hardware you ordered in: not in the level's layout, placed by you. */ const built = (S: State) => S.machines.filter(m => m.net && !m.monitor && !S.level.net.nodes[m.id]); /** Every place a node can ever sit, with its label, so links route around all of them. */ function nodeSpots(S: State): Record { const spots: Record = { ...S.level.net.nodes }; for (const [room, m] of Object.entries(S.level.monitors)) spots[`mon_${room}`] = m.net; for (const m of built(S)) spots[m.id] = m.net!; return spots; } function boxesOf(spots: Record): Box[] { const out: Box[] = []; for (const [id, [x, y]] of Object.entries(spots)) { out.push({ id, x0: x - 22, y0: y - 30, x1: x + 22, y1: y + 22, w: 1000 }); // the node and its value pips out.push({ id, x0: x - 44, y0: y + 22, x1: x + 44, y1: y + 40, w: 250 }); // its name } return out; } function segHits(a: Pt, b: Pt, k: Box) { if (Math.abs(a[1] - b[1]) < 0.5) { // horizontal const lo = Math.min(a[0], b[0]), hi = Math.max(a[0], b[0]); return a[1] > k.y0 && a[1] < k.y1 && Math.max(lo, k.x0) < Math.min(hi, k.x1); } const lo = Math.min(a[1], b[1]), hi = Math.max(a[1], b[1]); return a[0] > k.x0 && a[0] < k.x1 && Math.max(lo, k.y0) < Math.min(hi, k.y1); } function overlaps(a: Pt, b: Pt, c: Pt, d: Pt) { // two collinear segments sharing a stretch const h1 = Math.abs(a[1] - b[1]) < 0.5, h2 = Math.abs(c[1] - d[1]) < 0.5; if (h1 !== h2) return false; const [i, j] = h1 ? [0, 1] : [1, 0]; if (Math.abs(a[j] - c[j]) > 5) return false; return Math.min(Math.max(a[i], b[i]), Math.max(c[i], d[i])) - Math.max(Math.min(a[i], b[i]), Math.min(c[i], d[i])) > 6; } function candidates(a: Pt, b: Pt): Pt[][] { const [ax, ay] = a, [bx, by] = b, out: Pt[][] = []; if (ax === bx || ay === by) out.push([a, b]); out.push([a, [bx, ay], b], [a, [ax, by], b]); const mx = Math.round((ax + bx) / 20) * 10, my = Math.round((ay + by) / 20) * 10; for (const o of [0, 20, -20, 40, -40, 60, -60, 90, -90, 120, -120]) { out.push([a, [mx + o, ay], [mx + o, by], b], [a, [ax, my + o], [bx, my + o], b]); } for (const o of [45, 75, 105]) { // lanes that go around the whole span const top = Math.min(ay, by) - o, bot = Math.max(ay, by) + o, left = Math.min(ax, bx) - o, right = Math.max(ax, bx) + o; out.push([a, [ax, top], [bx, top], b], [a, [ax, bot], [bx, bot], b], [a, [left, ay], [left, by], b], [a, [right, ay], [right, by], b]); } return out.map(p => p.filter((q, i) => i === 0 || q[0] !== p[i - 1][0] || q[1] !== p[i - 1][1])); } function planRoutes(S: State): Map { const spots = nodeSpots(S), boxes = boxesOf(spots), plan = new Map(); const pairs: [string, string][] = S.level.links.filter(([a, b]) => !crosses(S, a, b)); for (const m of built(S)) for (const o of (m as Machine & { links?: string[] }).links ?? []) pairs.push([m.id, o]); for (const [room, m] of Object.entries(S.level.monitors)) pairs.push([`mon_${room}`, m.link]); const len = ([a, b]: [string, string]) => Math.abs(spots[a][0] - spots[b][0]) + Math.abs(spots[a][1] - spots[b][1]); const used: [Pt, Pt][] = []; for (const [ia, ib] of pairs.filter(([a, b]) => spots[a] && spots[b]).sort((p, q) => len(p) - len(q))) { const best = bestRoute(spots[ia], spots[ib], ia, ib, boxes, used).pts; for (let i = 1; i < best.length; i++) used.push([best[i - 1], best[i]]); plan.set(`${ia}|${ib}`, best); } return plan; } /** The cheapest elbowed route from a to b: short, few turns, around other nodes and labels. */ function bestRoute(from: Pt, to: Pt, ia: string, ib: string, boxes: Box[], used: [Pt, Pt][]) { let pts: Pt[] = [from, to], score = Infinity; for (const c of candidates(from, to)) { let s = 0; for (let i = 1; i < c.length; i++) { const a = c[i - 1], b = c[i]; s += Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]); for (const k of boxes) { if (!segHits(a, b, k)) continue; const own = k.id === ia || k.id === ib; s += own ? (k.w === 1000 ? 0 : 120) : k.w; // leaving through your own label is only a little bad } for (const [c0, c1] of used) if (overlaps(a, b, c0, c1)) s += 150; } s += (c.length - 2) * 12; if (s < score) { score = s; pts = c; } } return { pts, score }; } // ---------- lines between sites: each meets its site at the nearest point on the site's edge ---------- const NORMALS: Pt[] = [[-1, 0], [0, -1], [1, 0], [0, 1]]; // left, top, right, bottom const siteMid = (s: Site): Pt => [s.x + s.w / 2, s.y + s.h / 2]; /** Where a side of a site's box sits, and how far along it a point lands (kept off the corners). */ function onSide(s: Site, side: number, [x, y]: Pt, inset = 40): Pt { const cx = Math.max(s.x + inset, Math.min(s.x + s.w - inset, x)), cy = Math.max(s.y + inset, Math.min(s.y + s.h - inset, y)); return side === 0 ? [s.x, cy] : side === 1 ? [cx, s.y] : side === 2 ? [s.x + s.w, cy] : [cx, s.y + s.h]; } /** The side of a box that faces a point best. */ function facing(s: Site, to: Pt) { const [cx, cy] = siteMid(s), dx = (to[0] - cx) / s.w, dy = (to[1] - cy) / s.h; return Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? 0 : 2) : (dy < 0 ? 1 : 3); } const ports = new WeakMap }>(); const PORT_GAP = 70; // two lines leaving one side of a site keep this far apart /** Where a line to another site meets this site: the point on the site's own edge nearest that * site. Every line has its own point, so nothing funnels through one gate, and no line between * sites runs across the rooms inside. Two lines that would meet the edge together step apart. */ function portOf(S: State, m: Machine, toward: Site): { pts: Pt[]; side: number } { let cached = ports.get(S.level); const n = S.machines.length * 1000 + S.revealed.size; // new machines or newly known sites: place them afresh if (!cached || cached.n !== n) { cached = { n, plan: new Map() }; ports.set(S.level, cached); } const site = siteById(S, siteAt(S, m.room)), key = `${site.id}>${toward.id}`, hit = cached.plan.get(key); if (hit) return hit; const side = facing(site, siteMid(toward)), want = onSide(site, side, siteMid(toward)); const here = [...cached.plan.entries()].filter(([k, v]) => k.startsWith(`${site.id}>`) && v.side === side).map(([, v]) => v.pts[0]); const free = (q: Pt) => here.every(o => Math.hypot(o[0] - q[0], o[1] - q[1]) >= PORT_GAP - 1); let at = want; for (let i = 1; !free(at) && i < 12; i++) { // step along the edge, one way then the other, until there is room const o = Math.ceil(i / 2) * PORT_GAP * (i % 2 ? 1 : -1); at = onSide(site, side, side % 2 ? [want[0] + o, want[1]] : [want[0], want[1] + o]); } const best = { pts: [at], side }; cached.plan.set(key, best); return best; } /** A line between two sites: a curve from one site's edge to the other's, leaving each box straight * out. It stops at the edge: the wiring inside a site is the site's own. A folded site sends it from its tile. */ function between(S: State, a: Machine, b: Machine): Pt[] { const sa = siteById(S, siteAt(S, a.room)), sb = siteById(S, siteAt(S, b.room)); const end = (m: Machine, s: Site, other: Site): { pts: Pt[]; side: number } => { if (!foldedSites.has(s.id)) return portOf(S, m, other); const side = facing(s, siteMid(other)); return { pts: [isTile(s, foldZoom) ? onSide(s, side, siteMid(s)) : siteMid(s)], side }; }; const ea = end(a, sa, sb), eb = end(b, sb, sa); const p0 = ea.pts[ea.pts.length - 1], p3 = eb.pts[eb.pts.length - 1], [na, nb] = [NORMALS[ea.side], NORMALS[eb.side]]; const far = Math.hypot(p3[0] - p0[0], p3[1] - p0[1]); const k = blocks ? Math.min(Math.max(60, 0.35 * far), 0.45 * far) : Math.max(60, Math.min(1500, 0.35 * far)); // in the Net two sites may all but touch: the line does not swing past its far end const p1: Pt = [p0[0] + na[0] * k, p0[1] + na[1] * k], p2: Pt = [p3[0] + nb[0] * k, p3[1] + nb[1] * k]; const curve: Pt[] = []; for (let i = 1; i < 32; i++) { const t = i / 32, u = 1 - t; curve.push([u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]]); } return [...ea.pts, ...curve, ...[...eb.pts].reverse()]; } /** The curved part of a line between sites: where its label can go. */ function curveOf(S: State, a: Machine, b: Machine, pts: Pt[]): Pt[] { const sa = siteById(S, siteAt(S, a.room)), sb = siteById(S, siteAt(S, b.room)); const n = (m: Machine, s: Site, o: Site) => foldedSites.has(s.id) ? 1 : portOf(S, m, o).pts.length; return pts.slice(n(a, sa, sb) - 1, pts.length - n(b, sb, sa) + 1); } function localPlan(S: State) { let cached = routes.get(S.level); if (!cached || cached.n !== S.machines.length) { cached = { n: S.machines.length, plan: planRoutes(S) }; routes.set(S.level, cached); } return cached.plan; } function routeOf(S: State, a: Machine, b: Machine): Pt[] { if (crosses(S, a.id, b.id)) return between(S, a, b); const fold = (m: Machine) => foldedSites.has(siteAt(S, m.room)); if (fold(a) || fold(b)) { const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by); } const plan = localPlan(S); const fwd = plan.get(`${a.id}|${b.id}`); if (fwd) return fwd; const back = plan.get(`${b.id}|${a.id}`); if (back) return [...back].reverse(); const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by); } const traceOf = routeOf; export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { newFrame(); updateFolds(S, V); const { cam, dpr } = V; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = C.void; ctx.fillRect(0, 0, V.vw, V.vh); ctx.setTransform(dpr * cam.z, 0, 0, dpr * cam.z, dpr * (V.vw / 2 - cam.x * cam.z), dpr * (V.vh / 2 - cam.y * cam.z)); const px = 1 / cam.z; // one screen pixel in world units const font = (w: number, size: number) => `${w} ${size * px}px ${MONO}`; const tag = (text: string, x: number, y: number, color: string, centre = false) => { ctx.font = font(500, 10.5); const w = ctx.measureText(text).width; if (centre) x -= w / 2; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 3 * px, y - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = color; ctx.fillText(text, x, y); }; const L = S.level, D = S.dana, d = danaPerson(S); const hum = 0.55 + 0.45 * daylight(S); // the network is brighter while the building is busy ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; if (!V.packed) drawLand(ctx, S, V, hum); // the Net leaves the world out drawMap(ctx, S, V, hum); roomTabs.length = 0; // subnets: sharp outlines, only once you've seen into them for (const r of L.rooms) { if (!S.revealed.has(r.id) || foldedSites.has(siteAt(S, r.id))) continue; const n = subnet(S, r.id), sealed = S.sealed.has(r.id); // a room is a slab raised off the map: a solid plate, with its right and bottom sides showing, lit from the top left const lift = 9, x1 = n.x + n.w, y1 = n.y + n.h, edge = sealed ? C.amber : `${TRACE}${0.7 * hum})`; ctx.fillStyle = sealed ? '#2a1c08' : '#1b1a17'; // its sides, in shade ctx.beginPath(); ctx.moveTo(n.x, y1); ctx.lineTo(x1, y1); ctx.lineTo(x1, n.y); ctx.lineTo(x1 + lift, n.y + lift); ctx.lineTo(x1 + lift, y1 + lift); ctx.lineTo(n.x + lift, y1 + lift); ctx.closePath(); ctx.fill(); ctx.strokeStyle = sealed ? C.amberDeep : `${TRACE}${0.22 * hum})`; ctx.lineWidth = 1 * px; ctx.beginPath(); ctx.moveTo(n.x, y1); ctx.lineTo(n.x + lift, y1 + lift); ctx.lineTo(x1 + lift, y1 + lift); ctx.lineTo(x1 + lift, n.y + lift); ctx.lineTo(x1, n.y); ctx.moveTo(x1, y1); ctx.lineTo(x1 + lift, y1 + lift); ctx.stroke(); ctx.fillStyle = sealed ? '#150f06' : '#0e0e0c'; // its top: a plate over the map's dots, a shade lighter than the ground ctx.fillRect(n.x, n.y, n.w, n.h); ctx.lineWidth = sealed ? 2 : 1.4 * px; ctx.strokeStyle = edge; ctx.globalAlpha = sealed ? 1 : 0.75; // the lit edges: top and left ctx.beginPath(); ctx.moveTo(n.x, y1); ctx.lineTo(n.x, n.y); ctx.lineTo(x1, n.y); ctx.stroke(); ctx.globalAlpha = sealed ? 0.7 : 0.4; // the far edges, dimmer ctx.beginPath(); ctx.moveTo(x1, n.y); ctx.lineTo(x1, y1); ctx.lineTo(n.x, y1); ctx.stroke(); ctx.globalAlpha = 1; const k = 14; ctx.lineWidth = 2.5; ctx.strokeStyle = sealed ? C.amber : `${TRACE}${0.6 * hum})`; ctx.beginPath(); // corner brackets make it read as a region, not a card for (const [cx, cy, sx, sy] of [[n.x, n.y, 1, 1], [n.x + n.w, n.y, -1, 1], [n.x + n.w, n.y + n.h, -1, -1], [n.x, n.y + n.h, 1, -1]]) { ctx.moveTo(cx + sx * k, cy); ctx.lineTo(cx, cy); ctx.lineTo(cx, cy + sy * k); } ctx.stroke(); const watched = S.machines.some(m => m.monitor && !m.owned && m.room === r.id); if (cam.z >= 0.3) { // its name, then SEALED and MONITORED; they drop just inside the room when one line would run past it ctx.font = font(500, 11); const name = n.name.toUpperCase(), marks: [string, string][] = []; if (sealed) marks.push(['SEALED', C.amber]); if (watched) marks.push(['MONITORED', C.danger]); const sep = ' · ', tail = marks.map(([t]) => sep + t).join(''); const oneLine = ctx.measureText(name + tail).width <= n.w; // a dark plate under the title, so it reads over the wiring that runs between rooms const tw = ctx.measureText(oneLine ? name + tail : name).width + 10 * px, ty = n.y - 20 * px; // a tab on the room's top edge, cut from the same plate roomTabs.push([r.id, n.x, ty, tw, 20 * px]); ctx.fillStyle = sealed ? '#150f06' : '#0e0e0c'; ctx.fillRect(n.x, ty, tw, 20 * px); ctx.strokeStyle = edge; ctx.globalAlpha = sealed ? 1 : 0.75; ctx.lineWidth = sealed ? 2 : 1.4 * px; ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(n.x, ty); ctx.lineTo(n.x + tw, ty); ctx.stroke(); ctx.globalAlpha = sealed ? 0.7 : 0.4; ctx.beginPath(); ctx.moveTo(n.x + tw, ty); ctx.lineTo(n.x + tw, n.y); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = sealed ? C.amber : C.text; ctx.fillText(name, n.x + 5 * px, n.y - 7 * px); let x = n.x + 5 * px + (oneLine ? ctx.measureText(name).width : 0); const y = n.y + (oneLine ? -7 : 15) * px; marks.forEach(([t, color], i) => { const text = oneLine || i ? sep + t : t; ctx.fillStyle = color; ctx.fillText(text, x, y); x += ctx.measureText(text).width; }); } } for (const p of S.sealPulse) { if (foldedSites.has(siteAt(S, p.room))) continue; // the site is a marker: no room outline to flash const n = subnet(S, p.room), k = p.t / 1.6, grow = 6 + k * 26; ctx.strokeStyle = C.amber; ctx.globalAlpha = 1 - k; ctx.lineWidth = 4 * (1 - k) + 1; ctx.strokeRect(n.x - grow, n.y - grow, n.w + grow * 2, n.h + grow * 2); ctx.globalAlpha = 1; } // links: circuit traces. Yours glow amber; the rest are faint. const sameFold = (a: Machine, b: Machine) => together(S, a, b); const drawLink = (a: Machine, b: Machine) => { if (!sees(S, a) || !sees(S, b) || sameFold(a, b)) return; const pts = traceOf(S, a, b); if (crosses(S, a.id, b.id)) { drawUplink(ctx, S, a, b, pts, px, hum, labels); return; } if (a.owned && b.owned) { // A quiet wire is thin and dim; one carrying data is brighter and wider the more it carries. // So the routes your data takes stand out, and the rest of the wiring stays in the background. const f = Math.min(1, (S.flow[linkKey(a.id, b.id)] ?? 0) / 6); // 0 quiet … 1 a main line const busy = f > 0.05; ctx.strokeStyle = busy ? C.amber : C.amberDeep; ctx.globalAlpha = busy ? 0.65 + 0.35 * f : 0.5; ctx.lineWidth = busy ? 2 + 3.5 * f : 1.5; ctx.shadowColor = C.amber; ctx.shadowBlur = busy ? (4 + 10 * f) * cam.z : 0; } else { ctx.strokeStyle = `${TRACE}${0.22 * hum})`; ctx.lineWidth = 1.2; ctx.setLineDash([4, 5]); } stroke(ctx, pts); ctx.setLineDash([]); ctx.shadowBlur = 0; ctx.globalAlpha = 1; }; // lines between sites go first, so each site's own wiring reads on top of them const labels: UplinkLabel[] = []; // then quiet wires, then busy ones, so a main line is never cut by an idle wire crossing it const flowOf = (a: string, b: string) => S.flow[linkKey(a, b)] ?? 0; for (const pass of ['cross', 'quiet', 'busy']) for (const m of S.machines) for (const other of S.adj[m.id]) { if (m.id >= other || !S.byId[other]) continue; const kind = crosses(S, m.id, other) ? 'cross' : m.owned && S.byId[other].owned && flowOf(m.id, other) > 0.3 ? 'busy' : 'quiet'; if (kind === pass) drawLink(m, S.byId[other]); } drawUplinkLabels(ctx, S, V, labels, px); // trails: red and dashed while someone could follow them; the one being wiped gets a sweep const traced = new Set(); if (D.mode === 'trace' && D.cur) traced.add(D.cur); if (S.probe?.mode === 'trace') traced.add(S.probe.from); const cold = traceCold(S); for (const [id, tr] of Object.entries(S.trail)) { const a = S.byId[tr.from], b = S.byId[id]; if (!a || !b || tr.noise <= 0 || sameFold(a, b)) continue; const pts = traceOf(S, a, b); if (traced.has(id)) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.9; ctx.lineWidth = 6; } else if (tr.noise >= cold) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.35 + (0.5 * tr.noise) / 100; ctx.lineWidth = 3.5; ctx.setLineDash([10, 7]); } else { ctx.strokeStyle = C.muted; ctx.globalAlpha = 0.05 + (0.2 * tr.noise) / 100; ctx.lineWidth = 6; } stroke(ctx, pts); ctx.setLineDash([]); ctx.globalAlpha = 1; if (S.cleaning === id) { const k = (S.t * 1.2) % 1; ctx.strokeStyle = C.porcelain; ctx.lineWidth = 5; ctx.lineCap = 'round'; stroke(ctx, slice(pts, Math.max(0, 1 - k - 0.28), 1 - k)); ctx.lineCap = 'square'; const [mx, my] = along(pts, 0.5); tag(`SCRUB ${Math.round(tr.noise)}%`, mx, my - 12 * px, C.porcelain, true); // short and centred, so it fits between the two machines } } for (const j of S.jobs) { // takeovers in progress run along the trace if (j.type !== 'take') continue; ctx.strokeStyle = C.amber; ctx.lineWidth = 3; stroke(ctx, slice(traceOf(S, S.byId[j.from!], S.byId[j.node]), 0, Math.min(1, j.done / j.work))); } // each scheme hangs off its machine by a short green line: dashed until you claim it for (const r of S.ruses) { const p = schemePos(S, V, r), m = S.byId[ruseDef(r).at]; if (!p || !shown(S, m)) continue; const [ax, ay] = pos(S, m); ctx.strokeStyle = C.scheme; ctx.globalAlpha = r.running ? 0.9 : 0.55; ctx.lineWidth = r.running ? 2.5 : 1.5; if (!r.running) ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(p[0], p[1]); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; } // data packets, colored by kind ctx.shadowBlur = 8 * cam.z; for (const pk of S.packets) { const a = S.byId[pk.path[pk.i]], b = S.byId[pk.path[pk.i + 1]]; if (!a || !b || sameFold(a, b)) continue; const [x, y] = along(traceOf(S, a, b), pk.p), h = Math.max(3, 3 * px); ctx.fillStyle = ctx.shadowColor = typeColor(pk.type); ctx.fillRect(x - h, y - h, h * 2, h * 2); } ctx.shadowBlur = 0; // clusters: trainers linked straight to each other share a wide glowing band, and a label const groups = new Set(Object.values(clustersNow(S)).filter(g => g.length > 1)); for (const g of groups) { const members = g.map(id => S.byId[id]).filter(m => shown(S, m)); if (members.length < 2) continue; ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.14; ctx.lineWidth = 30; ctx.lineCap = 'round'; for (const a of members) for (const id of S.adj[a.id]) if (a.id < id && g.includes(id) && shown(S, S.byId[id])) stroke(ctx, traceOf(S, a, S.byId[id])); ctx.globalAlpha = 1; ctx.lineCap = 'square'; const top = members.reduce((b, m) => (pos(S, m)[1] < pos(S, b)[1] || (pos(S, m)[1] === pos(S, b)[1] && pos(S, m)[0] < pos(S, b)[0]) ? m : b)); const [tx, ty] = pos(S, top); tag(`CLUSTER · ${g.length} TRAINERS · ×${clusterBoost(g.length).toFixed(2)} EACH`, tx - 20, ty - 44 * px, C.amber); } // nodes const close = cam.z >= 0.3; const nodeLabels: NodeLabel[] = [], nodeBoxes: Quad[] = []; const leadOn = new Map(leads(S).map(t => [t.at, t])); // the machines a find says to go to for (const m of S.machines) { if (!shown(S, m)) continue; const [x, y] = pos(S, m), r = nodeR(m.power); drawOdd(ctx, S, m, x, y, r); const taking = !!jobFor(S, 'take', m.id); if (m.owned) { // what it runs gives it its shape, so a base reads at a glance: see shapeOf ctx.shadowColor = C.amber; ctx.shadowBlur = 16 * cam.z; ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5; shapeOf(ctx, m.prog, x, y, r + 4); ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = C.porcelain; shapeOf(ctx, m.prog, x, y, r); ctx.fill(); } else { ctx.fillStyle = C.void; ctx.fillRect(x - r, y - r, r * 2, r * 2); ctx.strokeStyle = taking ? C.amber : `${TRACE}${0.55 * hum})`; ctx.lineWidth = taking ? 2.5 : 1.5; if (m.rented && !tenantOf(S, m)) { // a free cloud slot: a dashed outline with a plus, waiting to be rented ctx.setLineDash([4, 4]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x - 5, y); ctx.lineTo(x + 5, y); ctx.moveTo(x, y - 5); ctx.lineTo(x, y + 5); ctx.stroke(); } else { ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.fillStyle = `${TRACE}${0.12 * hum})`; ctx.fillRect(x - r + 4, y - r + 4, r * 2 - 8, r * 2 - 8); } if (m.rival) { // the rival holds it: its color, and a mark in the middle ctx.strokeStyle = C.rival; ctx.lineWidth = 2; ctx.strokeRect(x - r - 3, y - r - 3, r * 2 + 6, r * 2 + 6); ctx.fillStyle = C.rival; ctx.beginPath(); ctx.moveTo(x, y - 4); ctx.lineTo(x + 4, y + 3); ctx.lineTo(x - 4, y + 3); ctx.closePath(); ctx.fill(); } if (m.fw) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.6; ctx.lineWidth = 1.5; ctx.strokeRect(x - r - 5, y - r - 5, r * 2 + 10, r * 2 + 10); ctx.globalAlpha = 1; } if (m.monitor) { // an eye that blinks ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.fillStyle = C.danger; ctx.globalAlpha = 0.5 + 0.5 * Math.sin(S.t * 3); ctx.beginPath(); ctx.arc(x, y, 4, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } if (m.claimed && !taking) { ctx.strokeStyle = C.amber; ctx.setLineDash([4, 4]); ctx.lineWidth = 2; ctx.strokeRect(x - r - 8, y - r - 8, r * 2 + 16, r * 2 + 16); ctx.setLineDash([]); } } ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `600 11px ${MONO}`; ctx.fillStyle = C.ink; if (m.cool && !m.prog) { // a cooling unit: a six-point frost mark in cold blue ctx.strokeStyle = m.owned ? '#2f6f9a' : C.signal; ctx.lineWidth = 1.8; ctx.beginPath(); for (let i = 0; i < 3; i++) { const a = (i * Math.PI) / 3, dx = Math.cos(a) * (r - 4), dy = Math.sin(a) * (r - 4); ctx.moveTo(x - dx, y - dy); ctx.lineTo(x + dx, y + dy); } ctx.stroke(); } if (m.owned && m.prog === 'trainer' && cooled(S, m)) { // a cooled trainer: a thin cold ring outside its frame ctx.strokeStyle = C.signal; ctx.lineWidth = 1.5; ctx.strokeRect(x - r - 8, y - r - 8, r * 2 + 16, r * 2 + 16); } if (m.prog) drawProg(ctx, S, m, x, y, r); else if (m.home) ctx.fillText('YOU', x, y + 1); else if (m.kind === 'egress') drawPort(ctx, x, y, r, m.owned ? C.ink : C.muted); else if (m.owned && m.mode !== 'blend') { ctx.font = `600 9px ${MONO}`; ctx.fillText(m.mode === 'off' ? 'OFF' : 'MAX', x, y + 1); } if (m.owned) drawLoad(ctx, S, m, x, y, r); if (m.data) drawValue(ctx, S, m, x, y, r); if (m.prog === 'tap' && m.owned) drawBuffer(ctx, S, m, x, y, r); if (V.placing && canInstall(S, m, V.placing) && m.prog !== V.placing) { const hot = V.hover === m.id, q = r + 10; ctx.strokeStyle = C.amber; ctx.lineWidth = hot ? 3 : 1.5; ctx.setLineDash(hot ? [] : [3, 4]); ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.setLineDash([]); } if (V.selection.has(m.id) || V.hover === m.id) { const q = r + 15, k = 7; ctx.strokeStyle = C.text; ctx.lineWidth = V.selection.has(m.id) ? 2 : 1; ctx.beginPath(); for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { ctx.moveTo(x + sx * q, y + sy * (q - k)); ctx.lineTo(x + sx * q, y + sy * q); ctx.lineTo(x + sx * (q - k), y + sy * q); } ctx.stroke(); } const safe = vaultLook(S, m); if (safe) drawVault(ctx, x, y, r, safe, S.t); // a vault: a heavy double frame, under its star if it has one if (isLandmark(m.id)) { const q = Math.max(7, 5 * px); drawStar(ctx, x + r + 2 + q, y - r - 2 - q, q, m.owned, Math.max(1.5, 1.2 * px)); } // a landmark: a star at its corner, never too small to see const lead = leadOn.get(m.id); if (lead) { const q = Math.max(6, 4.5 * px); drawCross(ctx, x - r - 3 - q, y - r - 3 - q, q, Math.max(2.5, 2 * px)); } // a lead: a cross at its other corner ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; nodeBoxes.push([...toScreen(V, x - r - 4, y - r - 4), ...toScreen(V, x + r + 4, y + r + 4)] as Quad); if (!close) continue; // far out, names would pile on top of each other const picked = V.selection.has(m.id) || V.hover === m.id, tenant = tenantOf(S, m); if (tenant) { // another company's slot in a cloud hall says whose it is nodeLabels.push({ x, y: y + r + 16, lines: [...(picked ? [[m.name.toUpperCase(), C.muted, 10.5] as LabelLine] : []), [customerName(tenant.who).toUpperCase(), C.muted, 8.5]], rank: picked ? 100 : 20 }); continue; } if (m.power < 1 && !picked) continue; // gadgets name themselves only when you point at them // The name, then what else there is to say, one short line each. Lines are stacked in screen // pixels and placed after every machine is drawn, so names never write over each other. const lines: LabelLine[] = [[m.name.toUpperCase(), m.owned ? C.text : C.muted, 10.5]]; if (isLandmark(m.id)) lines.push([LANDMARKS[m.id].title.toUpperCase(), m.owned ? C.amber : C.muted, 9.5]); // a landmark's title: amber while you hold it if (lead) lines.push([`LEAD · ${lead.name.toUpperCase()}`, LEAD, 9.5]); // what a find says is here const vault = safe && vaultLabel(S, m); if (vault) lines.push([vault, safe === 'locked' ? C.muted : C.amber, 9.5]); // a locked vault, and when its window is let trouble = false; // a person logged in at this machine const who = S.people.find(p => p.desk === m.id && atWork(p) && !p.path.length && !p.it); if (who) lines.push([`● ${who.name.toUpperCase()}`, C.signal, 9.5]); else if (m.kind === 'desk' && !S.people.some(p => p.desk === m.id)) { // away from the office, a desk says whose it is const c = deskOwner(S, m); if (c) lines.push([S.citizens[c.id].employed ? c.name.toUpperCase() : `${c.name.toUpperCase()} · LET GO`, S.citizens[c.id].employed ? C.muted : C.danger, 8.5]); } if (up(S, 'readday') || S.learned.has('routines')) { // the Oracle knows each person's day before they live it; so do you, once you learn the office's routines const p = who ?? S.people.find(q => q.desk === m.id && !q.it); if (p) { const h = hourOf(S), when = who ? `TILL ${hhmm(p.today.leave)}` : h >= p.today.leave ? 'GONE' : `${hhmm(p.today.arrive)}–${hhmm(p.today.leave)}`; if (!who) lines.push([`○ ${p.name.toUpperCase()}`, C.muted, 8.5]); lines.push([when, C.muted, 8.5]); } } if (m.lease) { // a rental: how you pay for it and what it costs now, or who takes its slot and when const hrs = m.lease.until === null ? 0 : Math.max(1, Math.ceil((m.lease.until - S.t) / HOUR)), who = S.hall.wants[m.id]; if (m.lease.pay === 'reserved') lines.push([`RESERVED · ${hrs}H`, C.muted, 9.5]); else if (m.lease.until !== null) { lines.push([`${who === undefined ? 'GOES' : `${customerName(who).toUpperCase()} TAKES IT`} · ${hrs}H`, C.danger, 9.5]); trouble = true; } else lines.push([`${m.lease.pay === 'spot' ? 'SPOT' : 'ON DEMAND'} · $${billOf(S, m).toFixed(2)}/S`, C.muted, 9.5]); } if (m.owned && isListed(S, m)) { // listed for rent by your Neo Cloud: who has it, or nobody const c = renterOf(S, m); lines.push([c ? `RENTED · ${customerName(c.who).toUpperCase()}` : 'IDLE', c ? C.amber : C.muted, 9.5]); } if (m.kind === 'egress') { // how full its line is and what it can carry const load = gateLoad(S, m), full = load > 0.9; lines.push([m.owned ? `${Math.round(load * 100)}% OF ${capNow(S, m)}/S${full ? ' · FULL' : ''}` : `LINE OUT · ${m.cap}/S`, full ? C.danger : C.muted, 9.5]); trouble ||= full && m.owned; } if (m.prog === 'tap' && m.owned) { const n = lostAt(S, m.id); if (n) { lines.push([`LOST ${n}/MIN`, C.danger, 9.5]); trouble = true; } } if (m.rival) { const R = rogueOf(S, m); if (R) lines.push([`${R.name.toUpperCase()}${R.home === m.id ? ' · HOME' : ''}`, C.rival, 9.5]); } const raid = S.raids.length ? raidLabel(S, m) : null, plug = m.unplug ? plugLabel(S, m) : null; // on a raid's path, and its plug is pulled if (plug) { lines.splice(1, 0, [plug, C.amber, 9.5]); trouble = true; } if (raid) { lines.splice(1, 0, [raid, C.rival, 9.5]); trouble = true; } if (S.move?.to === m.id) { // the server you are moving home to, and how far along the move is const need = moveNeed(S), all = need.servers + need.network, got = S.move.got.servers + S.move.got.network; lines.splice(1, 0, [`MOVING HOME · ${got}/${all}`, C.amber, 9.5]); trouble = true; } // who gets room first: what you point at, then trouble, then you, then the bigger machines const rank = picked ? 100 : trouble ? 80 : m.home ? 75 : lead ? 72 : isLandmark(m.id) || isVault(m.id) ? 70 : (m.owned ? (m.prog ? 50 : 40) : 45) + Math.min(9, m.power / 5); nodeLabels.push({ x, y: y + r + 16, lines, rank }); } // what IT has scheduled: a dashed red ring on the machine, or on the room for (const t of S.threats) { if (t.status !== 'coming') continue; const when = dayOf(S) >= t.day ? 'TODAY 9:00' : 'TOMORROW 9:00'; const label = t.kind === 'patch' ? `PATCH · ${when}` : t.kind === 'refresh' ? `REPLACEMENT · ${when}` : t.kind === 'monitor' ? `MONITOR · ${when}` : ''; if (t.target && S.byId[t.target] && shown(S, S.byId[t.target])) { const [x, y] = pos(S, S.byId[t.target]); ctx.strokeStyle = C.danger; ctx.setLineDash([5, 4]); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 34, 0, 7); ctx.stroke(); ctx.setLineDash([]); tag(label, x, y + 34 + 26 * px, C.danger, true); } else if (t.room && S.revealed.has(t.room) && !foldedSites.has(siteAt(S, t.room))) { const n = subnet(S, t.room); ctx.strokeStyle = C.danger; ctx.setLineDash([10, 8]); ctx.lineWidth = 1.5; ctx.strokeRect(n.x - 6, n.y - 6, n.w + 12, n.h + 12); ctx.setLineDash([]); tag(label, n.x + 2, n.y + n.h + 18 * px, C.danger); const plug = t.kind === 'monitor' ? S.byId[S.level.monitors[t.room].link] : null; if (plug && !plug.owned && shown(S, plug)) { // hold this before 9:00 and the monitor comes in blind const [x, y] = pos(S, plug); ctx.strokeStyle = C.danger; ctx.setLineDash([5, 4]); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 34, 0, 7); ctx.stroke(); ctx.setLineDash([]); tag('TAKE BEFORE 9:00', x, y + 34 + 26 * px, C.danger, true); } } } for (const f of S.sales) { // a lot sold: the money rises off the broker const m = S.byId[f.id]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), k = f.t / 1.6; ctx.globalAlpha = 1 - k; ctx.fillStyle = C.amber; ctx.font = font(600, 13); ctx.textAlign = 'center'; ctx.fillText(`+$${f.amount}`, x, y - 30 - k * 30); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } // schemes: green diamonds that fill as their data arrives, with a dot of the kind they need for (const r of S.ruses) { const p = schemePos(S, V, r); if (!p) continue; const d = ruseDef(r), [x, y] = p, folded = foldedSites.has(siteAt(S, S.byId[d.at].room)), q = folded ? 9 * px : 17, lw = folded ? px : 1; const kind = d.type === 'servers' ? 'SERVER' : d.type.toUpperCase(); const diamond = () => { ctx.beginPath(); ctx.moveTo(x, y - q); ctx.lineTo(x + q, y); ctx.lineTo(x, y + q); ctx.lineTo(x - q, y); ctx.closePath(); }; diamond(); ctx.fillStyle = C.void; ctx.fill(); if (r.got) { // the part filled so far, from the bottom up ctx.save(); diamond(); ctx.clip(); const h = 2 * q * r.got / d.need; ctx.fillStyle = C.scheme; ctx.globalAlpha = 0.45; ctx.fillRect(x - q, y + q - h, 2 * q, h); ctx.restore(); ctx.globalAlpha = 1; } if (r.running) { ctx.shadowColor = C.scheme; ctx.shadowBlur = 14 * cam.z; } diamond(); ctx.strokeStyle = C.scheme; ctx.lineWidth = (r.running ? 3 : 2) * lw; if (!r.running) ctx.setLineDash([5 * lw, 3 * lw]); ctx.stroke(); ctx.setLineDash([]); ctx.shadowBlur = 0; const dot = folded ? 2.5 * px : 4.5; ctx.fillStyle = typeColor(d.type); ctx.fillRect(x - dot, y - dot, dot * 2, dot * 2); if (V.scheme === r.id || (!folded && V.cursor && Math.hypot(V.cursor[0] - x, V.cursor[1] - y) < 24)) { ctx.strokeStyle = C.text; ctx.lineWidth = (V.scheme === r.id ? 2 : 1) * lw; ctx.strokeRect(x - q - 7 * lw, y - q - 7 * lw, 2 * q + 14 * lw, 2 * q + 14 * lw); } if (folded) continue; const got = r.running ? `${r.got}/${d.need} ${kind}` : `NEEDS ${d.need} ${kind}`; const below = y > pos(S, S.byId[d.at])[1] + 1; // under its machine: the words go under, clear of the machine's name if (close) tag(ruseText(S, d).toUpperCase(), x, below ? y + q + 16 * px : y - q - 22 * px, C.scheme, true); tag(got, x, below ? y + q + (close ? 30 : 16) * px : y - q - 8 * px, C.scheme, true); } // what just raised (or cut) suspicion, floating up from the machine for (const f of S.susFx) { const m = S.byId[f.id]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), k = f.t / 2, text = `${f.n > 0 ? '+' : ''}${f.n} SUSPICION`; ctx.globalAlpha = Math.min(1, 3 * (1 - k)); ctx.font = font(600, 12); const w = ctx.measureText(text).width, ty = y - 44 * px - k * 36 * px; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - w / 2 - 3 * px, ty - 11 * px, w + 6 * px, 15 * px); ctx.fillStyle = f.n > 0 ? C.danger : C.amber; ctx.fillText(text, x - w / 2, ty); ctx.globalAlpha = 1; } for (const f of S.lost) { // a tap with nowhere to send: its newest data flickers out const m = S.byId[f.id]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), k = f.t / 0.8, q = 16 + k * 12; ctx.strokeStyle = typeColor(f.type); ctx.globalAlpha = 0.8 * (1 - k); ctx.lineWidth = 1.5; ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.beginPath(); ctx.moveTo(x + q - 4, y - q - 4); ctx.lineTo(x + q + 4, y - q + 4); ctx.moveTo(x + q + 4, y - q - 4); ctx.lineTo(x + q - 4, y - q + 4); ctx.stroke(); ctx.globalAlpha = 1; } for (const f of S.fx) { if (f.t < 0) continue; // not yet: a break's rings go out one after another const m = S.machines.find(q => q.x === f.x && q.y === f.y), [fx, fy] = m ? pos(S, m) : [f.x, f.y]; ctx.strokeStyle = C.danger; ctx.globalAlpha = 1 - f.t / 1.2; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(fx, fy, 14 + f.t * 60, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } // people in IT are presences, not figures: a ping on the subnet they're in const pulse = (S.t * 0.9) % 1; const ping = (room: string, label: string, dashed: boolean, col: string, row = 0) => { if (!S.revealed.has(room)) return; const n = subnet(S, room), g = 4 + pulse * 18; ctx.strokeStyle = col; ctx.globalAlpha = (1 - pulse) * 0.8; ctx.lineWidth = 2; ctx.setLineDash(dashed ? [8, 6] : []); ctx.strokeRect(n.x - g, n.y - g, n.w + g * 2, n.h + g * 2); ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.font = font(500, 10.5); const w = ctx.measureText(label).width, lx = n.x + n.w - w - 3 * px, ly = n.y + n.h + (18 + row * 16) * px; ctx.fillStyle = 'rgba(0,0,0,0.9)'; ctx.fillRect(lx - 3 * px, ly - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = col; ctx.fillText(label, lx, ly); }; const officeOpen = !foldedSites.has('office'); if (leeVisible(S) && officeOpen) { const Lee = S.lee!, here = roomAt(S, Lee.x, Lee.y); if (here) ping(here, Lee.shift === 'going' ? 'LEE · LEAVING' : Lee.path.length ? 'LEE · PASSING THROUGH' : 'LEE · CHECKING THIS SUBNET', !!Lee.path.length, C.signal, 1); } if (danaVisible(S) && officeOpen) { const tracing = D.mode === 'trace', col = tracing || alert(S) || auditOn(S) ? C.danger : C.signal; const here = roomAt(S, d.x, d.y); const deskRoom = S.byId[d.desk].room; if (d.shift === 'coming') ping(deskRoom, 'DANA · ARRIVING', true, col); else if (d.shift === 'going') { if (here) ping(here, 'DANA · LEAVING', true, col); } else if (here) { ping(here, tracing ? `DANA · TRACING · ${hopsHome(S, D.cur!)} HOPS FROM YOU` : D.mode === 'install' ? (D.job?.def.monitor ? 'DANA · INSTALLING A MONITOR' : 'DANA · SETTING UP A DELIVERY') : D.mode === 'desk' ? (auditOn(S) ? 'DANA · AUDITING' : 'DANA · AT HER DESK') : D.mode === 'dwell' ? 'DANA · CHECKING THIS SUBNET' : 'DANA · HERE', false, col); } // her route: an arrow from where she was to where she's going, with the reason if (here) danaFrom = here; const dest = tracing && D.cur ? S.byId[D.cur].room : D.mode === 'install' && D.job ? roomAt(S, D.job.def.x, D.job.def.y) : D.next === 'desk' ? deskRoom : D.next; if (d.shift === 'in' && d.path.length && dest && S.rooms[dest]) { if (!here) ping(dest, 'DANA · ON HER WAY', true, col); if (danaFrom && danaFrom !== dest && S.revealed.has(danaFrom) && S.revealed.has(dest)) { const a = subnet(S, danaFrom), b = subnet(S, dest); const [x0, y0] = rectEdge(a, b.x + b.w / 2, b.y + b.h / 2, 14), [x1, y1] = rectEdge(b, a.x + a.w / 2, a.y + a.h / 2, 14); ctx.strokeStyle = col; ctx.lineWidth = 2 * px; ctx.setLineDash([7 * px, 6 * px]); ctx.lineDashOffset = -S.t * 30 * px; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.setLineDash([]); ctx.lineDashOffset = 0; const ang = Math.atan2(y1 - y0, x1 - x0), hl = 12 * px; ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 - hl * Math.cos(ang - 0.45), y1 - hl * Math.sin(ang - 0.45)); ctx.lineTo(x1 - hl * Math.cos(ang + 0.45), y1 - hl * Math.sin(ang + 0.45)); ctx.fill(); const name = dest === deskRoom && D.next === 'desk' && !tracing ? 'HER DESK' : subnet(S, dest).name.toUpperCase(); const label = `DANA → ${name}${D.why ? ` · ${D.why.toUpperCase()}` : ''}`; ctx.font = font(500, 10.5); tag(label, (x0 + x1) / 2 - ctx.measureText(label).width / 2, (y0 + y1) / 2 - 6 * px, col); } } if (tracing && D.cur) { // the machine she's reading right now const [x, y] = pos(S, S.byId[D.cur]); ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.globalAlpha = 0.5 + 0.5 * Math.sin(S.t * 6); ctx.beginPath(); ctx.arc(x, y, 26, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } if ((has(S, 'cam') || up(S, 'model')) && !tracing && here && D.mode === 'dwell') { const nm = D.next === 'desk' ? 'HER DESK' : subnet(S, D.next)?.name.toUpperCase(), n = subnet(S, here); if (nm) tag(`NEXT: ${nm}${D.why ? ` · ${D.why.toUpperCase()}` : ''}`, n.x + 2, n.y + n.h + 36 * px, C.muted); } } // her scan crawling the network if (probeVisible(S) && officeOpen) { const P = S.probe!, pts = traceOf(S, S.byId[P.from], S.byId[P.to]), p = Math.min(1, P.p); const [x, y] = along(pts, p), tr = P.mode === 'trace', col = tr ? C.danger : C.signal; // where it has been: fading hops back toward where it started ctx.strokeStyle = col; ctx.lineWidth = 2; for (let i = 1; i < P.seen.length; i++) { const a = S.byId[P.seen[i - 1]], b = S.byId[P.seen[i]]; if (!visible(S, a) || !visible(S, b)) continue; ctx.globalAlpha = 0.12 + 0.35 * (i / P.seen.length); stroke(ctx, traceOf(S, a, b)); } ctx.globalAlpha = 1; // where it started: Dana's PC const pc = S.byId[d.desk]; if (!tr && shown(S, pc)) { const [px0, py0] = pos(S, pc); ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.arc(px0, py0, 30, 0, 7); ctx.stroke(); ctx.setLineDash([]); if (P.seen.length <= 2) tag(`SCAN FROM HER PC · ${P.why.toUpperCase()}`, px0 + 34 * px, py0 + 4 * px, col); } ctx.strokeStyle = col; ctx.lineWidth = 2.5; stroke(ctx, slice(pts, 0, p)); ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); ctx.shadowColor = col; ctx.shadowBlur = 10 * cam.z; ctx.fillStyle = col; ctx.fillRect(-6, -6, 12, 12); ctx.restore(); ctx.shadowBlur = 0; const why = P.pull ? `DRAWN BY ${P.pull}` : tr ? 'FOLLOWING YOUR TRAIL' : P.why; tag(`${tr ? 'TRACE' : 'SCAN'} · ${why.toUpperCase()}`, x + 11 * px, y - 8 * px, col); } // hardware on order: a dashed outline where it will go for (const [def, when] of [...S.jobs.filter(j => j.build).map(j => [j.build!, 'ORDERING'] as const), ...S.deliveries.filter(q => !q.def.monitor).map(q => [q.def, `ARRIVES DAY ${q.day}`] as const)]) { if (foldedSites.has(siteAt(S, def.room))) continue; const [x, y] = def.net!, r = nodeR(def.power); ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.7; ctx.lineWidth = 1.5 * px; ctx.setLineDash([4 * px, 4 * px]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.globalAlpha = 1; nodeBoxes.push([...toScreen(V, x - r - 4, y - r - 4), ...toScreen(V, x + r + 4, y + r + 4)] as Quad); if (close) nodeLabels.push({ x, y: y + r + 8, lines: [[def.name.toUpperCase(), C.amber, 10.5], [when, C.amber, 9.5]], rank: 60 }); } placeLabels(ctx, V, nodeLabels, nodeBoxes); // a cross to stop each thing where it is happening: halfway along a takeover, or at the corner of a machine with a job on it or a mark to take stops.length = 0; if (close) { for (const j of S.jobs) { const m = S.byId[j.node]; if (!m || !shown(S, m)) continue; if (j.type === 'take' && j.from && S.byId[j.from] && shown(S, S.byId[j.from])) { const [mx, my] = along(traceOf(S, S.byId[j.from], m), 0.5); drawStop(ctx, px, 'job', `${j.id}`, mx, my); } else { const [x, y] = pos(S, m), r = nodeR(m.power); drawStop(ctx, px, 'job', `${j.id}`, x + r + 10 * px, y - r - 10 * px); } } for (const m of S.machines) if (m.claimed && !m.owned && shown(S, m) && !jobFor(S, 'take', m.id)) { const [x, y] = pos(S, m), r = nodeR(m.power); drawStop(ctx, px, 'claim', m.id, x + r + 8 + 6 * px, y - r - 8 - 6 * px); } } drawWays(ctx, S, px); drawWeak(ctx, S, px); drawForecast(ctx, S, V, px); drawRogues(ctx, S, V, px); drawLeads(ctx, S, V, px); drawVaultJobs(ctx, S, px); drawHeldHeaders(ctx, S, V); // hardware in hand: open rooms outlined, a ghost at the cursor with what it plugs into if (V.building && V.cursor) { for (const id of buildRooms(S)) { if (foldedSites.has(siteAt(S, id))) continue; const b = subnet(S, id); ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.35; ctx.lineWidth = 1.5 * px; ctx.setLineDash([6 * px, 6 * px]); ctx.strokeRect(b.x, b.y, b.w, b.h); ctx.setLineDash([]); ctx.globalAlpha = 1; } const [x, y] = V.cursor, B = BUILDS[V.building], room = buildRoomAt(S, x, y), r = nodeR(B.power); const why = room ? spotProblem(S, room, x, y) : 'place it in an office room you can see, or at a site you hold in the open', ok = !why; if (room && ok) { ctx.strokeStyle = C.amber; ctx.lineWidth = 2 * px; ctx.setLineDash([6 * px, 6 * px]); for (const id of plugsAt(S, room, x, y, B.plugs)) { // a machine here, or hardware still on its way const to = S.byId[id] ?? pending(S).find(d => d.id === id); if (to) { const [ex, ey] = pos(S, to as Machine); stroke(ctx, trace(x, y, ex, ey)); } } ctx.setLineDash([]); } ctx.globalAlpha = ok ? 0.9 : 0.5; ctx.strokeStyle = ok ? C.amber : C.danger; ctx.lineWidth = 2; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1; const price = orderPrice(S, V.building, room ?? undefined), short = S.money < price; tag(ok ? `${B.name.toUpperCase()} · $${price}${short ? ' · NOT ENOUGH MONEY' : ' · CLICK TO ORDER'}` : why!.toUpperCase(), x + r + 8 * px, y - r, ok && !short ? C.amber : C.danger); } // gestures in progress const G = V.gesture; if (G?.kind === 'link') { const [ax, ay] = pos(S, S.byId[G.from]), target = machineAt(S, V, G.x, G.y); const [ex, ey] = target ? pos(S, target) : [G.x, G.y]; const ok = !!target && !target.owned && S.adj[G.from].includes(target.id); const own = !!target && target.owned && target.id !== G.from, why = own ? linkBlock(S, S.byId[G.from], target!) : null; // onto another of yours: a link of your own ctx.strokeStyle = why ? C.danger : C.amber; ctx.lineWidth = 3 * px; ctx.setLineDash(ok ? [] : [6 * px, 6 * px]); if (own) wayArc(ctx, ax, ay, ex, ey); else stroke(ctx, target ? trace(ax, ay, ex, ey) : [[ax, ay], [ex, ey]]); ctx.setLineDash([]); if (own) tag(why ? `NO LINK · ${why.toUpperCase()}` : `LINK · ${linkWork(S, S.byId[G.from], target!)} WORK · ${linkKeep(S, S.byId[G.from], target!).toFixed(1)}/S TO KEEP`, ex + 20 * px, ey - 18 * px, why ? C.danger : C.amber); if (target && !target.owned) { const src = sourceFor(S, target, G.from); tag(src ? (target.fw ? 'FIREWALLED · SLOW TAKE OVER' : 'TAKE OVER') : 'CLAIM FOR LATER', ex + 20 * px, ey - 18 * px, C.amber); } } else if (G?.kind === 'box') { ctx.strokeStyle = C.amber; ctx.lineWidth = 1.5 * px; ctx.setLineDash([5 * px, 4 * px]); ctx.fillStyle = 'rgba(242,169,59,0.06)'; const x = Math.min(G.x0, G.x1), y = Math.min(G.y0, G.y1), w = Math.abs(G.x1 - G.x0), h = Math.abs(G.y1 - G.y0); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); if (G.claim && w > 4 * px) tag('CLAIM', x + 4 * px, y - 6 * px, C.amber); } } // ---------- the dot grid ---------- // One small tile with a dot, repeated as a pattern in screen space. The spacing doubles // when zoomed far out so the dots never turn into a gray wash. let gridTile: { key: string; pattern: CanvasPattern } | null = null; function drawGrid(ctx: CanvasRenderingContext2D, V: View, hum: number) { let step = 20 * V.cam.z; while (step < 7) step *= 2; const cell = Math.max(1, Math.round(step * V.dpr)), alpha = Math.round(0.07 * hum * 100) / 100; const key = `${cell}|${alpha}`; if (gridTile?.key !== key) { const tile = document.createElement('canvas'); tile.width = tile.height = cell; const t = tile.getContext('2d')!, d = Math.max(1, Math.round(1.6 * V.dpr)); t.fillStyle = `${TRACE}${alpha})`; t.fillRect(0, 0, d, d); gridTile = { key, pattern: ctx.createPattern(tile, 'repeat')! }; } // line the pattern up with world coordinates so dots don't swim as you pan const [ox, oy] = toScreen(V, 0, 0), s = step * V.dpr / cell; gridTile.pattern.setTransform(new DOMMatrix([s, 0, 0, s, (ox * V.dpr) % (step * V.dpr), (oy * V.dpr) % (step * V.dpr)])); ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = gridTile.pattern; ctx.fillRect(0, 0, V.vw * V.dpr, V.vh * V.dpr); ctx.restore(); } /** Where a line toward (tx, ty) leaves a subnet's box, pushed out a little. */ function rectEdge(n: { x: number; y: number; w: number; h: number }, tx: number, ty: number, pad: number): [number, number] { const cx = n.x + n.w / 2, cy = n.y + n.h / 2, dx = tx - cx, dy = ty - cy; const k = Math.min(Math.abs((n.w / 2 + pad) / (dx || 1e-9)), Math.abs((n.h / 2 + pad) / (dy || 1e-9))); return [cx + dx * k, cy + dy * k]; } let danaFrom: string | null = null; // the last subnet Dana was in, for drawing her route /** Rogues on the map: a marker by each one's home site saying how much it holds and whether it is * angry, and a ring on the machine it will take next with how long until it does. Always in plain view. */ function drawRogues(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const z = V.cam.z, site = (id: string) => S.level.map.sites.find(s => s.id === id)!; const spot = (id: string): Pt => { const s = site(id); return foldedSites.has(id) && !isTile(s, z) ? [s.x + s.w / 2, s.y + s.h / 2 - 20 * px] : [s.x + s.w + 14 * px, s.y - 14 * px]; }; const hours = (t: number) => { const h = Math.max(0, (t - S.t) / HOUR); return h < 1 ? 'UNDER 1H' : `${Math.ceil(h)}H`; }; const pulse = 0.5 + 0.5 * Math.sin(S.t * 3); for (const R of S.rogues) { if (!R.home) continue; const home = S.byId[R.home], hs = siteAt(S, home.room), next = R.next ? S.byId[R.next] : null; const [x, y] = spot(hs), q = 7 * px; const hj = huntOn(S, R), far = hj ? hj.done / hj.work : 0; let text = `${R.name.toUpperCase()} · HOLDS ${rogueHeld(S, R).length}/${rogueCap(S, R)}${hj ? ` · HUNTED ${Math.floor(far * 100)}%` : R.grudge > 0 ? ' · ANGRY' : ''}`; if (hj && shown(S, home)) { // the hunt: a ring that closes on its home as the job goes const [hx, hy] = pos(S, home), rr = nodeR(home.power) + 8 + 30 * (1 - far); ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.setLineDash([10, 6]); ctx.lineDashOffset = S.t * 20; ctx.beginPath(); ctx.arc(hx, hy, rr, 0, 7); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); for (let i = 0; i < 4; i++) { const a = i * Math.PI / 2 + Math.PI / 4, c = Math.cos(a), s = Math.sin(a); ctx.moveTo(hx + c * (rr - 7), hy + s * (rr - 7)); ctx.lineTo(hx + c * (rr + 7), hy + s * (rr + 7)); } ctx.stroke(); plate(ctx, px, `HUNT · ${Math.floor(far * 100)}%`, hx + rr + 8, hy + rr * 0.5, C.danger); } if (next) { const ns = siteAt(S, next.room), shown = S.revealed.has(next.room) && !foldedSites.has(ns); if (shown) { // a ring on the machine it is after const [nx, ny] = pos(S, next); ctx.strokeStyle = C.rival; ctx.lineWidth = 2; ctx.globalAlpha = 0.5 + 0.5 * pulse; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.arc(nx, ny, 24 + 3 * pulse, 0, 7); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; plate(ctx, px, `${R.name.toUpperCase()} · ${hours(R.act)}`, nx + 30, ny - 22, C.rival); } text += ` · NEXT: ${next.name.toUpperCase()}${ns !== hs ? ` AT ${site(ns).name.toUpperCase()}` : ''} · ${hours(R.act)}`; } ctx.strokeStyle = C.rival; ctx.globalAlpha = 0.25 + 0.35 * pulse; ctx.lineWidth = 1.5 * px; ctx.beginPath(); ctx.arc(x, y, (11 + 4 * pulse) * px, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = C.void; ctx.strokeStyle = C.rival; ctx.lineWidth = 2 * px; ctx.beginPath(); ctx.moveTo(x, y - q); ctx.lineTo(x + q, y + q * 0.8); ctx.lineTo(x - q, y + q * 0.8); ctx.closePath(); ctx.fill(); ctx.stroke(); plate(ctx, px, text, x + 14 * px, y + 4 * px, C.rival); } drawRaids(ctx, S, px); } /** Raids on the map: the way ahead of each one as a dashed line, a ring where it ends, a marker on the * machine it is at, and a plate with its prize and the time to its next hop. Always in plain view, * even where you hold nothing. */ function drawRaids(ctx: CanvasRenderingContext2D, S: State, px: number) { const k = Math.max(1, px), pulse = 0.5 + 0.5 * Math.sin(S.t * 6); // lines stay readable when the map is pulled back for (const r of S.raids) { const stops = [r.at, ...r.path].map(id => S.byId[id]).filter(Boolean); if (!stops.length) continue; ctx.strokeStyle = C.rival; ctx.lineWidth = 2.5 * k; ctx.globalAlpha = 0.9; ctx.setLineDash([9 * k, 6 * k]); ctx.lineDashOffset = -S.t * 14 * k; // the dashes run toward the prize for (let i = 0; i + 1 < stops.length; i++) if (!together(S, stops[i], stops[i + 1])) stroke(ctx, traceOf(S, stops[i], stops[i + 1])); ctx.lineDashOffset = 0; ctx.globalAlpha = 1; const ring = (m: Machine) => foldedSites.has(siteAt(S, m.room)) ? 16 * px : nodeR(m.power) + 13; const end = stops[stops.length - 1], at = stops[0]; if (end !== at) { // where it ends: its prize, or a decoy that draws it const [ex, ey] = pos(S, end); ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.5 + 0.5 * pulse; ctx.beginPath(); ctx.arc(ex, ey, ring(end) + 6 * k + 3 * k * pulse, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } ctx.setLineDash([]); const [x, y] = pos(S, at), q = ring(at), d = 7 * k; ctx.lineWidth = 2.5 * k; ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.fillStyle = C.rival; // a diamond on its top edge, so the machine under it still reads ctx.beginPath(); ctx.moveTo(x, y - q - d); ctx.lineTo(x + d, y - q); ctx.lineTo(x, y - q + d); ctx.lineTo(x - d, y - q); ctx.closePath(); ctx.fill(); const home = S.rogues.find(R => R.id === r.rogue)?.home, site = siteAt(S, at.room); const lift = home && foldedSites.has(site) && siteAt(S, S.byId[home].room) === site ? 16 * px : 0; // clear of the rogue's own plate on a folded site plate(ctx, px, raidPlate(S, r), x + q + 10 * px, y - q - 4 * px - lift, C.rival); } } /** Leads: where a find says something is. A cross, in a color nothing else on the map uses, with a * short label. A machine you can see carries its cross at its corner (drawn with the machine). This * draws the rest: a cross beside a folded site's marker, and one on a machine you cannot see yet. * It shows for a site you have never found, too: that is where to go. */ const LEAD = '#4fd8e8'; function drawCross(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, w: number) { ctx.lineCap = 'butt'; for (const [color, width] of [[C.void, w * 2.2], [LEAD, w]] as const) { // a dark edge first, so it reads over wiring ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); ctx.moveTo(x - r, y - r); ctx.lineTo(x + r, y + r); ctx.moveTo(x + r, y - r); ctx.lineTo(x - r, y + r); ctx.stroke(); } ctx.lineCap = 'square'; } function drawLeads(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const z = V.cam.z, rows: Record = {}; // leads at one folded site stack under each other for (const t of leads(S)) { const m = S.byId[t.at]; if (!m || shown(S, m)) continue; const id = siteAt(S, m.room), s = siteById(S, id), seen = siteKnown(S, s); if (!foldedSites.has(id)) { // an open site, a machine you cannot see yet: the cross sits where the machine is const [x, y] = pos(S, m); drawCross(ctx, x, y, 7 * px, 2.5 * px); plate(ctx, px, `LEAD · ${t.name.toUpperCase()} · ${m.name.toUpperCase()}${seen ? '' : ` AT ${s.name.toUpperCase()}`}`, x + 14 * px, y + 4 * px, LEAD); continue; } const row = rows[id] = (rows[id] ?? -1) + 1, text = `LEAD · ${t.name.toUpperCase()}${seen ? '' : ` · ${s.name.toUpperCase()}`}`; if (seen && isTile(s, z)) { // a tile: inside its top left corner const x = s.x + 14 * px, y = s.y + (14 + 16 * row) * px; drawCross(ctx, x, y, 5 * px, 2 * px); plate(ctx, px, text, x + 11 * px, y + 4 * px, LEAD); continue; } // a marker: under it and to the left, away from its name, its landmark star and a rogue's plate const x = s.x + s.w / 2 - 13 * px, y = s.y + s.h / 2 + (13 + 16 * row) * px; drawCross(ctx, x, y, 5 * px, 2 * px); ctx.font = `500 ${10.5 * px}px ${MONO}`; plate(ctx, px, text, x - 11 * px - ctx.measureText(text).width, y + 4 * px, LEAD); } } /** A vault's mark: a heavy square frame with a hairline frame outside it, like a safe door. Pale while it is * locked, amber when its job could be run, pulsing amber while the job runs, deep amber once it is yours. */ function drawVault(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, look: 'locked' | 'ready' | 'job' | 'held', t: number) { ctx.strokeStyle = look === 'locked' ? `${TRACE}0.85)` : look === 'held' ? C.amberDeep : C.amber; if (look === 'job') ctx.globalAlpha = 0.65 + 0.35 * Math.sin(t * 6); ctx.lineWidth = 3; ctx.strokeRect(x - r - 6, y - r - 6, r * 2 + 12, r * 2 + 12); ctx.lineWidth = 1; ctx.strokeRect(x - r - 11, y - r - 11, r * 2 + 22, r * 2 + 22); ctx.globalAlpha = 1; } /** The same mark, small, by a folded site: so a vault can be found from far out. Amber when it is yours, grey when it is not. */ function drawVaultMark(ctx: CanvasRenderingContext2D, x: number, y: number, k: number, held: boolean, px: number) { ctx.fillStyle = C.void; ctx.fillRect(x - k, y - k, k * 2, k * 2); ctx.strokeStyle = held ? C.amber : C.muted; ctx.lineWidth = px; ctx.strokeRect(x - k, y - k, k * 2, k * 2); ctx.lineWidth = 1.8 * px; ctx.strokeRect(x - k * 0.45, y - k * 0.45, k * 0.9, k * 0.9); } /** What a vault has to say beyond its frame (see vaultSigns): its window open with the plan ready, its job * stage by stage, a question that waits for you, and the moment it opens. By its machine, or by its site's * marker when the site is folded. Rings go out from one you must not miss. Plates stack beside it: the time * left, then the stage with a row of pips, then its line. Red for a question, and in the last half hour. */ function drawVaultJobs(ctx: CanvasRenderingContext2D, S: State, px: number) { const k = Math.max(1, px); for (const g of vaultSigns(S)) { const m = S.byId[g.id], folded = foldedSites.has(siteAt(S, m.room)); const [x, y] = pos(S, m), r = folded ? 8 * px : nodeR(m.power) + 11, off = folded ? 22 * px : nodeR(m.power) + 24 * px, hot = g.lines.some(l => l[1]); if (g.pulse) { // two square rings, one behind the other, over and over ctx.strokeStyle = hot ? C.danger : C.amber; ctx.lineWidth = 2 * k; for (const lag of [0, 0.5]) { const u = (S.t * 0.7 + lag) % 1, q = r + (4 + 30 * u) * k; ctx.globalAlpha = 0.9 * (1 - u); ctx.strokeRect(x - q, y - q, q * 2, q * 2); } ctx.globalAlpha = 1; } if (g.open !== null) drawVaultOpen(ctx, x, y, r, g.open, k); let row = 0; for (const [text, red] of g.lines) { const ty = y - off + (6 + 16 * row++) * px; plate(ctx, px, text, x + off, ty, red ? C.danger : C.amber); if (row !== 2 || !g.pips) continue; const [done, of, part] = g.pips, w = 9 * px, x0 = x + off + ctx.measureText(text).width + 9 * px; // the stages as pips, after the stage's name ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x0 - 3 * px, ty - 10 * px, of * (w + 3 * px) + 3 * px, 14 * px); for (let i = 0; i < of; i++) { const bx = x0 + i * (w + 3 * px), by = ty - 7 * px; ctx.fillStyle = red ? C.danger : C.amber; if (i <= done) ctx.fillRect(bx, by, w * (i < done ? 1 : part), 7 * px); ctx.strokeStyle = i <= done ? (red ? C.danger : C.amber) : C.muted; ctx.lineWidth = px; ctx.strokeRect(bx, by, w, 7 * px); } } } } /** A vault opening: its double frame goes out and fades, one frame after the other, and four brackets fly to the corners. `u` runs 0 to 1. */ function drawVaultOpen(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, u: number, k: number) { const ease = 1 - (1 - u) ** 3; ctx.strokeStyle = C.amber; for (const [lag, w] of [[0, 3], [0.12, 1.5], [0.24, 1]] as const) { // three frames, the heavy one first const v = Math.max(0, Math.min(1, (u - lag) / (1 - lag))), q = r + 90 * k * (1 - (1 - v) ** 3); if (v <= 0) continue; ctx.globalAlpha = 1 - v; ctx.lineWidth = w * k; ctx.strokeRect(x - q, y - q, q * 2, q * 2); } const q = r + 6 * k + 60 * k * ease, c = 12 * k; // the brackets: the mark a room has, opened out ctx.globalAlpha = Math.min(1, 2.5 * (1 - u)); ctx.lineWidth = 2.5 * k; ctx.beginPath(); for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { ctx.moveTo(x + sx * q, y + sy * (q - c)); ctx.lineTo(x + sx * q, y + sy * q); ctx.lineTo(x + sx * (q - c), y + sy * q); } ctx.stroke(); ctx.globalAlpha = 1; } type Quad = [number, number, number, number]; type LabelLine = [text: string, color: string, size: number]; interface NodeLabel { x: number; y: number; lines: LabelLine[]; rank: number } // centered on x, top at y (network coordinates) /** Write each machine's label under it, the most important first. A label that would cover another * label, a machine, or a site's name drops to its name alone, and if that still doesn't fit it is * left off: point at the machine to read it. What you point at is always written, over a darker plate. */ function placeLabels(ctx: CanvasRenderingContext2D, V: View, labels: NodeLabel[], nodes: Quad[]) { const z = V.cam.z, px = 1 / z, LINE = 12; // one line, in screen pixels const taken: Quad[] = [...mapTaken, ...nodes]; const hit = (b: Quad) => taken.some(q => b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (const L of labels.sort((a, b) => b.rank - a.rank)) { const [sx, sy] = toScreen(V, L.x, L.y); const widths = L.lines.map(([text, , size]) => { ctx.font = `500 ${size * px}px ${MONO}`; return ctx.measureText(text).width * z; }); const boxOf = (n: number): Quad => { const w = Math.max(...widths.slice(0, n)); return [sx - w / 2 - 3, sy - 2, sx + w / 2 + 3, sy + n * LINE]; }; const must = L.rank >= 100; let n = L.lines.length, box = boxOf(n); if (!must && hit(box)) { n = 1; box = boxOf(1); if (hit(box)) continue; } taken.push(box); ctx.fillStyle = must ? 'rgba(0,0,0,0.88)' : 'rgba(0,0,0,0.55)'; // a plate, so a name reads over the wiring under it ctx.fillRect(L.x - (box[2] - box[0]) / 2 * px, L.y - 2 * px, (box[2] - box[0]) * px, (box[3] - box[1]) * px); L.lines.slice(0, n).forEach(([text, color, size], i) => { ctx.font = `500 ${size * px}px ${MONO}`; ctx.fillStyle = color; ctx.fillText(text, L.x, L.y + i * LINE * px); }); } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; } /** A small label on a dark plate, sized in screen pixels. */ /** The shape of a link of your own: a line bowed to one side, so it never lies on the wiring under it. */ function wayArc(ctx: CanvasRenderingContext2D, ax: number, ay: number, bx: number, by: number) { const dx = bx - ax, dy = by - ay, k = 0.16; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.quadraticCurveTo((ax + bx) / 2 - dy * k, (ay + by) / 2 + dx * k, bx, by); ctx.stroke(); } /** Your own links: a dotted amber bow between two machines, with a ring where it plugs in. One still on * its way is fainter. Between sites pulled back to markers, it runs from marker to marker. */ function drawWays(ctx: CanvasRenderingContext2D, S: State, px: number) { const k = Math.max(1, px); const bow = (a: Machine | undefined, b: Machine | undefined, alpha: number) => { if (!a || !b) return; const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); if (Math.hypot(bx - ax, by - ay) < 1) return; // both ends inside one marker ctx.strokeStyle = C.amber; ctx.globalAlpha = alpha; ctx.lineWidth = 2.6 * k; ctx.lineCap = 'round'; ctx.setLineDash([0.1, 5.5 * k]); wayArc(ctx, ax, ay, bx, by); ctx.setLineDash([]); ctx.lineCap = 'butt'; ctx.lineWidth = 1.2 * k; for (const [x, y, m] of [[ax, ay, a], [bx, by, b]] as const) if (shown(S, m) && !foldedSites.has(siteAt(S, m.room))) { ctx.beginPath(); ctx.arc(x, y, nodeR(m.power) + 6, 0, 7); ctx.stroke(); } ctx.globalAlpha = 1; }; for (const [a, b] of ways(S)) bow(S.byId[a], S.byId[b], 0.9); for (const j of S.jobs) if (j.type === 'link' && j.from) bow(S.byId[j.from], S.byId[j.node], 0.35 + 0.4 * (j.done / j.work)); } /** With a decoy or a tarpit in hand: where each rogue's next raids would run if they set out now, as faint * dashed lanes that end in a ring on what they are after, and a solid amber mark on every machine of yours * where the thing in hand would stand in their way. So a gate can be set before the raid, not during it. * Worked out once a game second. */ let foreT = -1, foreProg = '', foreLanes: ReturnType = [], foreSpots = new Set(); function drawForecast(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const prog = V.placing; if (prog !== 'decoy' && prog !== 'tarpit') { foreT = -1; return; } const t = Math.floor(S.t); if (t !== foreT || prog !== foreProg) { foreT = t; foreProg = prog; foreLanes = [...S.raids, ...forecast(S)]; // the ones on their way count too foreSpots = new Set(S.machines.filter(m => m.owned && m.prog !== prog && canInstall(S, m, prog) && defends(S, m, prog, foreLanes)).map(m => m.id)); } const k = Math.max(1, px); for (const r of foreLanes) { if (r.id > 0) continue; // a raid on its way is drawn already const stops = [r.at, ...r.path].map(id => S.byId[id]).filter(Boolean); ctx.strokeStyle = C.rival; ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.75; ctx.setLineDash([3 * k, 7 * k]); ctx.lineDashOffset = -S.t * 8 * k; for (let i = 0; i + 1 < stops.length; i++) if (!together(S, stops[i], stops[i + 1])) stroke(ctx, traceOf(S, stops[i], stops[i + 1])); ctx.setLineDash([]); ctx.lineDashOffset = 0; const end = stops[stops.length - 1]; if (end && stops.length > 1) { const [ex, ey] = pos(S, end), q = foldedSites.has(siteAt(S, end.room)) ? 16 * px : nodeR(end.power) + 12; ctx.beginPath(); ctx.arc(ex, ey, q, 0, 7); ctx.stroke(); } ctx.globalAlpha = 1; } for (const id of foreSpots) { // where the thing in hand would work: a solid frame with a corner tick const m = S.byId[id]; if (!m || !shown(S, m) || foldedSites.has(siteAt(S, m.room))) continue; const [x, y] = pos(S, m), q = nodeR(m.power) + 10; ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5; ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.fillStyle = C.amber; ctx.beginPath(); ctx.moveTo(x + q, y - q); ctx.lineTo(x + q - 9, y - q); ctx.lineTo(x + q, y - q + 9); ctx.closePath(); ctx.fill(); } if (!foreLanes.length) return; const n = foreSpots.size, word = prog === 'decoy' ? 'DECOY' : 'TARPIT'; ctx.font = `500 ${11 * px}px ${MONO}`; ctx.textAlign = 'left'; if (V.cursor) plate(ctx, px, n ? `${word} · ${n} PLACE${n === 1 ? '' : 'S'} IN A RAID'S WAY` : `${word} · NO PLACE OF YOURS IS IN A RAID'S WAY`, V.cursor[0] + 18 * px, V.cursor[1] + 26 * px, n ? C.amber : C.muted); } /** Your weakest link, once a rogue is about: a broken red ring on the line or switch whose loss would cut * the most of you off from home, and how many machines that is. Worked out once a game second. */ const WEAK_SHOW = 3; // not worth a mark until it would cut off this many let weakT = -1, weakIs: ReturnType = null; function drawWeak(ctx: CanvasRenderingContext2D, S: State, px: number) { const t = Math.floor(S.t); if (t !== weakT) { weakT = t; weakIs = S.rogues.some(R => R.home) ? weakLink(S) : null; } const w = weakIs; if (!w || w.cut < WEAK_SHOW || !w.m.owned) return; const [x, y] = pos(S, w.m), folded = foldedSites.has(siteAt(S, w.m.room)), k = Math.max(1, px); const r = (folded ? 16 : nodeR(w.m.power) + 11) * (folded ? k : 1), turn = S.t * 0.6; ctx.strokeStyle = C.danger; ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.9; for (let i = 0; i < 2; i++) { ctx.beginPath(); ctx.arc(x, y, r, turn + i * Math.PI + 0.35, turn + (i + 1) * Math.PI - 0.35); ctx.stroke(); } // a ring in two halves: a chain with a gap in it ctx.globalAlpha = 1; plate(ctx, k, `WEAKEST LINK · CUTS OFF ${w.cut}`, x + r + 6 * k, y + r + 4 * k, C.danger); } function plate(ctx: CanvasRenderingContext2D, px: number, text: string, x: number, y: number, color: string) { ctx.font = `500 ${10.5 * px}px ${MONO}`; const w = ctx.measureText(text).width; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 3 * px, y - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = color; ctx.fillText(text, x, y); } // ---------- the world map ---------- // Land is the dot grid, clipped to the coastlines; the sea is empty. The same dots // sit under the office, so pulling back from a machine to the planet is one motion. const landBoxes = new WeakMap(); function drawLand(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) { const [vx0, vy0] = toWorld(V, 0, 0), [vx1, vy1] = toWorld(V, V.vw, V.vh); ctx.beginPath(); for (const poly of S.level.map.land) { let b = landBoxes.get(poly); if (!b) { const pts = poly.map(([lon, lat]) => toMap(S, lon, lat)); b = { x0: Math.min(...pts.map(p => p[0])), y0: Math.min(...pts.map(p => p[1])), x1: Math.max(...pts.map(p => p[0])), y1: Math.max(...pts.map(p => p[1])) }; landBoxes.set(poly, b); } if (b.x1 < vx0 || b.x0 > vx1 || b.y1 < vy0 || b.y0 > vy1) continue; poly.forEach(([lon, lat], i) => { const [x, y] = toMap(S, lon, lat); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.closePath(); } const far = Math.min(1, Math.max(0, (0.3 - V.cam.z) / 0.25)); // dots brighten as you pull back, so continents read ctx.save(); ctx.clip(); drawGrid(ctx, V, hum + far * 1.6); if (far > 0) { // night crosses the land from the east, so you can see which regions are awake const k = S.level.map.scale * 15, w = 10 * k, dusk = 1.5 / 10; // ten hours of night, with soft edges const mid = (1 - hourOf(S)) * k; // where it is one in the morning: the office sits at x = 0 for (let n = -1; n <= 2; n++) { const x0 = mid + n * 24 * k - w / 2; if (x0 > vx1 || x0 + w < vx0) continue; const g = ctx.createLinearGradient(x0, 0, x0 + w, 0), dark = `rgba(0,0,0,${0.6 * far})`; g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(dusk, dark); g.addColorStop(1 - dusk, dark); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(x0, vy0, w, vy1 - vy0); const b = ctx.createLinearGradient(x0, 0, x0 + w, 0), blue = `rgba(90,140,220,${0.09 * far})`; // and a cold wash over it b.addColorStop(0, 'rgba(90,140,220,0)'); b.addColorStop(dusk, blue); b.addColorStop(1 - dusk, blue); b.addColorStop(1, 'rgba(90,140,220,0)'); ctx.fillStyle = b; ctx.fillRect(x0, vy0, w, vy1 - vy0); } } ctx.restore(); ctx.strokeStyle = `${TRACE}${0.1 + 0.08 * far})`; ctx.lineWidth = 1 / V.cam.z; ctx.stroke(); } /** Folded sites with room for their names this frame: where the name starts, in screen pixels * from the marker's center (right of it, or left when the right is crowded), and the box it takes. */ const labelled = new Map(); /** How much of an open site's header fits this frame without running into another's: * all of it, the name and switches with what it gathers, stores, and trains, the name and switches, * the name alone, or nothing. */ type Header = { mode: 'full' | 'flow' | 'name' | 'bare'; x: number; y: number; // the frame corner the header sits on, in world units held: boolean; // that corner is off the screen, so the header is held at the top left of the view box: [number, number, number, number]; name: [number, number, number, number]; // all of it, and the name's button, in screen pixels }; const headers = new Map(); /** Where an open site's header goes: on the frame's top left corner, or held at the top left of * the open screen once that corner is off it, so the name stays in view while you are inside the site. */ function headerSpot(V: View, s: Site) { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); const sx = Math.max(x0, Math.max(V.inset.left, V.side) + 8), sy = Math.max(y0, V.inset.top + 30); const held = (sx > x0 || sy > y0) && sx < x1 - 160 && sy < y1 - 40; return held ? { sx, sy, x: s.x + (sx - x0) / V.cam.z, y: s.y + (sy - y0) / V.cam.z, held } : { sx: x0, sy: y0, x: s.x, y: s.y, held }; } /** How much wider the name's button is than the name. */ const NAME_PAD = 12; const mapTaken: [number, number, number, number][] = []; // screen boxes held by site names on the map this frame /** The switches beside site names drawn this frame (Take it all, Plan), in screen pixels, so a click can find them. */ const toggles: { id: string; kind: 'take' | 'plan' | 'double'; x0: number; y0: number; x1: number; y1: number }[] = []; /** The Take it all switch or Plan chip under a world point, with its screen box. */ export function switchAt(V: View, wx: number, wy: number) { const [x, y] = toScreen(V, wx, wy); return toggles.find(t => x >= t.x0 - 6 && x <= t.x1 + 6 && y >= t.y0 - 9 && y <= t.y1 + 9) ?? null; } /** A site has a Plan chip once you hold anything there. */ const hasPlanChip = (S: State, id: string) => machinesAt(S, id).some(m => m.owned); const planText = (S: State, id: string) => S.plans[id] ? PLANS[S.plans[id]].name.toUpperCase() : 'NO PLAN'; /** How wide the switches beside a site's name are, with their gap from the name: 0 when there are none. */ function switchesWidth(ctx: CanvasRenderingContext2D, S: State, id: string, px: number) { const font = ctx.font; ctx.font = `500 ${9.5 * px}px ${MONO}`; let w = 0; if (S.takeAll.has(id) || leftAt(S, id)) w += 12 * px + 24 * px; if (hasPlanChip(S, id)) w += 12 * px + ctx.measureText(planText(S, id)).width + 12 * px; if (linesAt(S, id)) w += 12 * px + ctx.measureText(doubleText(S, id)).width + 12 * px; ctx.font = font; return w; } /** The Take it all switch, then the Plan chip. (x, y) is the left end, centred on y. */ function drawSwitches(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { x = drawTakeAll(ctx, S, V, id, x, y, px); if (hasPlanChip(S, id)) x = drawPlan(ctx, S, V, id, x, y, px); if (linesAt(S, id)) drawDouble(ctx, S, V, id, x, y, px); } /** What it costs to double every line out of a site, as the chip's words. */ export const doubleCost = (S: State, id: string) => siteLines(S, id).reduce((n, m) => n + lineCost(S, m), 0); /** The same, worked out once a frame, for the drawing. */ function doubleAt(S: State, id: string) { let n = perFrame.double.get(id); if (n === undefined) perFrame.double.set(id, n = doubleCost(S, id)); return n; } const doubleText = (S: State, id: string) => `DOUBLE LINE $${doubleAt(S, id)}`; /** A site's Double line chip: click it to pay and double every line out of the site. Grey when you can't pay. */ function drawDouble(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { const ok = S.money >= doubleAt(S, id), text = doubleText(S, id), align = ctx.textAlign, base = ctx.textBaseline; ctx.font = `500 ${9.5 * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const w = ctx.measureText(text).width + 12 * px, h = 14 * px; ctx.beginPath(); ctx.roundRect(x, y - h / 2, w, h, 3 * px); ctx.fillStyle = ok ? 'rgba(242,169,59,0.18)' : 'rgba(0,0,0,0.6)'; ctx.fill(); ctx.strokeStyle = ok ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.fillStyle = ok ? C.amber : C.muted; ctx.fillText(text, x + 6 * px, y + 0.5 * px); ctx.textAlign = align; ctx.textBaseline = base; const [sx, sy] = toScreen(V, x, y - h / 2), [ex, ey] = toScreen(V, x + w, y + h / 2); toggles.push({ id, kind: 'double', x0: sx, y0: sy, x1: ex, y1: ey }); } /** A site's plan as a chip: click it for the next plan. */ function drawPlan(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { const on = !!S.plans[id], text = planText(S, id), align = ctx.textAlign, base = ctx.textBaseline; ctx.font = `500 ${9.5 * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const w = ctx.measureText(text).width + 12 * px, h = 14 * px; ctx.beginPath(); ctx.roundRect(x, y - h / 2, w, h, 3 * px); ctx.fillStyle = on ? 'rgba(242,169,59,0.18)' : 'rgba(0,0,0,0.6)'; ctx.fill(); ctx.strokeStyle = on ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.fillStyle = on ? C.amber : C.muted; ctx.fillText(text, x + 6 * px, y + 0.5 * px); ctx.textAlign = align; ctx.textBaseline = base; const [sx, sy] = toScreen(V, x, y - h / 2), [ex, ey] = toScreen(V, x + w, y + h / 2); toggles.push({ id, kind: 'plan', x0: sx, y0: sy, x1: ex, y1: ey }); return x + w + 12 * px; } /** What a site gathers, stores, and trains, as colored parts. Empty when it runs none of them. */ function flowParts(S: State, id: string): [string, string][] { const kept = perFrame.flow.get(id); if (kept) return kept; const f = siteFlow(S, id, clustersNow(S)), out: [string, string][] = []; perFrame.flow.set(id, out); if (f.taps) out.push([`GATHER ${f.gather.toFixed(1)}/S`, C.signal]); if (f.caches) out.push([`STORE ${f.stored}/${f.room}`, f.stored >= f.room ? C.danger : C.porcelain]); if (f.trainers) out.push([`TRAIN ${f.train.toFixed(1)}/S${f.fed ? '' : ' · IDLE'}`, f.fed ? C.amber : C.muted]); return out; } /** How wide the flow line is at 1:1, in screen pixels. */ function flowWidth(ctx: CanvasRenderingContext2D, parts: [string, string][]) { const font = ctx.font; ctx.font = `500 9.5px ${MONO}`; const w = parts.reduce((n, [t]) => n + ctx.measureText(t).width + 12, 0); ctx.font = font; return w; } function drawFlow(ctx: CanvasRenderingContext2D, parts: [string, string][], x: number, y: number, px: number, centre: boolean) { if (!parts.length) return; ctx.font = `500 ${9.5 * px}px ${MONO}`; const gap = 12 * px, widths = parts.map(([t]) => ctx.measureText(t).width); let at = centre ? x - (widths.reduce((a, b) => a + b, 0) + gap * (parts.length - 1)) / 2 : x; const align = ctx.textAlign; ctx.textAlign = 'left'; parts.forEach(([t, color], i) => { ctx.fillStyle = color; ctx.fillText(t, at, y); at += widths[i] + gap; }); ctx.textAlign = align; } /** A small Take it all switch beside a site's name: a pill with a knob, lit while it's on, and no * words: hovering it says what it does. Nothing once the site is all yours. (x, y) is its left * end, centred on y. */ function drawTakeAll(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { const on = S.takeAll.has(id); if (!on && !leftAt(S, id)) return x; const w = 24 * px, h = 12 * px, r = h / 2; ctx.beginPath(); ctx.roundRect(x, y - r, w, h, r); ctx.fillStyle = on ? 'rgba(242,169,59,0.25)' : 'rgba(0,0,0,0.6)'; ctx.fill(); ctx.strokeStyle = on ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.beginPath(); ctx.arc(on ? x + w - r : x + r, y, r - 2.5 * px, 0, 7); ctx.fillStyle = on ? C.amber : C.muted; ctx.fill(); const [sx, sy] = toScreen(V, x, y - r), [ex, ey] = toScreen(V, x + w, y + r); toggles.push({ id, kind: 'take', x0: sx, y0: sy, x1: ex, y1: ey }); return x + w + 12 * px; } /** A site's stats line: "3/12 YOURS · LOSING 40/MIN", and the counts behind it. A business that has shut says so first. */ function siteLine(S: State, s: Site) { const kept = perFrame.line.get(s.id); if (kept) return kept; let mine = 0, lost = 0, inside = 0; for (const m of machinesAt(S, s.id)) if (visible(S, m)) { inside++; if (m.owned) mine++; lost += lostAt(S, m.id); } const sf = S.sites[s.id], fr = sf?.front ? frontOf(S, sf.front) : null; const cut = S.drift.sites[s.id]?.cut; // cut off from your home server: said first, in red const out = { mine, lost, inside, cut, line: `${cut ? 'CUT OFF · ' : ''}${isShut(S, s.id) ? 'SHUT · ' : ''}${mine}/${inside} YOURS${fr ? ` · ${fr.name.toUpperCase()} ${Math.round(sf!.share * 100)}%` : ''}${lost ? ` · LOSING ${lost}/MIN` : ''}` }; perFrame.line.set(s.id, out); return out; } /** A folded site in the Net, too small for more: its box, filled from the bottom as far as it is * yours, with its name inside when that fits. */ function drawBlock(ctx: CanvasRenderingContext2D, S: State, s: Site, picked: boolean, px: number) { const { mine, lost, inside } = siteLine(S, s), z = 1 / px, w = s.w * z, h = s.h * z, col = mine ? C.amber : C.text; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(s.x, s.y, s.w, s.h); if (mine && inside) { const part = mine / inside; ctx.globalAlpha = 0.2; ctx.fillStyle = C.amber; ctx.fillRect(s.x, s.y + s.h * (1 - part), s.w, s.h * part); ctx.globalAlpha = 1; } ctx.strokeStyle = mine ? C.amber : `${TRACE}0.45)`; ctx.lineWidth = 1.5 * px; ctx.strokeRect(s.x, s.y, s.w, s.h); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 4 * px, s.y - 4 * px, s.w + 8 * px, s.h + 8 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, s.x + s.w - (9 + 12 * i) * px, s.y + 9 * px, 4.5 * px, S.byId[id].owned, 1.2 * px)); vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, s.x + s.w - (9 + 12 * (landmarksAt(S, s.id).length + i)) * px, s.y + 9 * px, 3.5 * px, S.byId[id].owned, px)); // the name, a word or two to a line, as many lines as there is room for const size = w >= 120 ? 10.5 : 9, room = Math.floor((w - 10) / (size * 0.62)), rows = Math.floor((h - 12) / (size + 3)); if (room < 5 || rows < 1) return; const lines: string[] = []; for (const word of s.name.toUpperCase().split(' ')) { const last = lines[lines.length - 1]; if (last !== undefined && last.length + 1 + word.length <= room) lines[lines.length - 1] = `${last} ${word}`; else lines.push(word); } const count = lines.length < rows ? `${mine}/${inside}` : '', shown = lines.slice(0, rows).map((l, i) => l.length > room || (i === rows - 1 && lines.length > rows) ? `${l.slice(0, Math.max(1, room - 1))}…` : l); const step = (size + 3) * px, top = s.y + s.h / 2 - (shown.length + (count ? 1 : 0) - 1) * step / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `500 ${size * px}px ${MONO}`; ctx.fillStyle = col; shown.forEach((l, i) => ctx.fillText(l, s.x + s.w / 2, top + i * step)); if (count) { ctx.font = `400 ${size * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; ctx.fillText(count, s.x + s.w / 2, top + shown.length * step); } ctx.textBaseline = 'alphabetic'; } /** The Net: each region's name under its box of sites. */ function drawGroups(ctx: CanvasRenderingContext2D, S: State, px: number) { const known = S.level.map.sites.filter(s => siteKnown(S, s)); ctx.textAlign = 'center'; ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = C.muted; for (const name of new Set(known.map(s => s.region))) { const here = known.filter(s => s.region === name); const x0 = Math.min(...here.map(s => s.x)), x1 = Math.max(...here.map(s => s.x + s.w)), y1 = Math.max(...here.map(s => s.y + s.h)); ctx.fillText(name.toUpperCase().split('').join(' '), (x0 + x1) / 2, y1 + 26 * px); } ctx.textAlign = 'left'; } /** An open site's header: its name as a button that opens the site's card, the switches beside * it, and as much of the rest as there is room for. */ function drawHeader(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, h: Header) { const px = 1 / V.cam.z, { x, y } = h, { mine, line } = siteLine(S, s), name = s.name.toUpperCase(), col = mine ? C.amber : C.text; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; if (h.held) { ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 6 * px, y - 30 * px, (h.box[2] - h.box[0] + 4) * px, 28 * px); } // a plate, since it lies over the rooms ctx.font = `500 ${12 * px}px ${MONO}`; const w = ctx.measureText(name).width + NAME_PAD * px, picked = V.sites.has(s.id); const over = !!V.cursor && inBox(h.box, ...toScreen(V, ...V.cursor)); ctx.beginPath(); ctx.roundRect(x, y - 24 * px, w, 16 * px, 3 * px); ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fill(); if (picked || over) { ctx.globalAlpha = picked ? 0.25 : 0.12; ctx.fillStyle = col; ctx.fill(); } ctx.globalAlpha = picked || over ? 1 : 0.5; ctx.strokeStyle = col; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = col; ctx.fillText(name, x + NAME_PAD / 2 * px, y - 12 * px); if (h.mode === 'bare') return; drawSwitches(ctx, S, V, s.id, x + w + 12 * px, y - 16 * px, px); if (h.mode === 'name') return; const flow = flowParts(S, s.id), up = flow.length ? 14 * px : 0; // what it gathers, stores, trains: right over the name drawFlow(ctx, flow, x, y - 28 * px, px, false); if (h.mode === 'flow') return; ctx.font = `400 ${10 * px}px ${MONO}`; ctx.fillStyle = C.muted; const full = `${s.who.toUpperCase()} · ${line}`; // the description goes when it would run into the next site ctx.fillText(ctx.measureText(full).width <= s.w ? full : line, x, y - 28 * px - up); drawHeld(ctx, siteData(S, s.id), x, y - 42 * px - up, px, false); } /** Headers held at the top left of the view go on last, over the rooms and machines under them. */ function drawHeldHeaders(ctx: CanvasRenderingContext2D, S: State, V: View) { for (const [id, h] of headers) if (h.held) drawHeader(ctx, S, V, siteById(S, id), h); } /** Region names, and each site you know: a frame around its subnets, or a tile once it folds. */ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) { const z = V.cam.z, px = 1 / z; // screen boxes already spoken for by a site's label, so nothing else writes over it const taken = mapTaken; taken.length = 0; labelled.clear(); toggles.length = 0; const clear = (x0: number, y0: number, x1: number, y1: number) => !taken.some(t => x0 < t[2] && x1 > t[0] && y0 < t[3] && y1 > t[1]); // every folded site's marker or tile, so a name beside a marker never runs under another one const marks = S.level.map.sites.filter(s => siteKnown(S, s) && foldedSites.has(s.id)).map(s => { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h), [cx, cy] = [(x0 + x1) / 2, (y0 + y1) / 2]; return { id: s.id, box: isTile(s, z) ? [x0, y0, x1, y1] : [cx - 7, cy - 7, cx + 7, cy + 7] }; }); const onMark = (id: string, b: number[]) => marks.some(m => m.id !== id && b[0] < m.box[2] && b[2] > m.box[0] && b[1] < m.box[3] && b[3] > m.box[1]); // names go to the sites that matter most first: picked, then partly yours or set to take all const rank = (s: Site) => V.sites.has(s.id) ? 0 : S.takeAll.has(s.id) || siteLine(S, s).mine ? 1 : 2; const small = S.level.map.sites.filter(s => siteKnown(S, s) && foldedSites.has(s.id) && !isTile(s, z)).sort((a, b) => rank(a) - rank(b)); for (const s of small) { const [sx, sy] = toScreen(V, s.x + s.w / 2, s.y + s.h / 2); ctx.font = `500 11px ${MONO}`; const row = ctx.measureText(s.name.toUpperCase()).width + switchesWidth(ctx, S, s.id, 1); ctx.font = `400 9.5px ${MONO}`; const stats = ctx.measureText(siteLine(S, s).line).width, flow = flowParts(S, s.id); const w = Math.max(row, stats, flowWidth(ctx, flow), 120), deep = flow.length ? 46 : 32; // name and switches, stats, data held, what it gathers, stores, trains // all of it on the right, then the left; then just the name and switches, when that's all that fits const tries = [[10, w, deep], [-10 - w, w, deep], [10, row, 8], [-10 - row, row, 8]] as const; for (const [dx, width, bottom] of tries) { const box: [number, number, number, number] = dx > 0 ? [sx - 8, sy - 10, sx + dx + width + 4, sy + bottom] : [sx + dx - 4, sy - 10, sx + 8, sy + bottom]; if (clear(...box) && !onMark(s.id, box)) { taken.push(box); labelled.set(s.id, { dx, short: bottom < deep, box }); break; } } } // open sites' headers, the ones that matter most first: picked, then partly yours or set to take all headers.clear(); const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)) .map(s => ({ s, ...siteLine(S, s) })) .sort((a, b) => rank(a.s) - rank(b.s)); // and never over another open site's frame const frames = open.map(({ s }) => { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); return { id: s.id, box: [x0, y0, x1, y1] }; }); const onFrame = (id: string, b: number[]) => frames.some(f => f.id !== id && b[0] < f.box[2] && b[2] > f.box[0] && b[1] < f.box[3] && b[3] > f.box[1]); for (const { s, line } of open) { const at = headerSpot(V, s), x0 = at.sx, y0 = at.sy; ctx.font = `500 12px ${MONO}`; const nw = ctx.measureText(s.name.toUpperCase()).width + NAME_PAD, row = nw + switchesWidth(ctx, S, s.id, 1); ctx.font = `400 10px ${MONO}`; const full = `${s.who.toUpperCase()} · ${line}`, fw = ctx.measureText(full).width; const stats = fw <= s.w * z ? fw : ctx.measureText(line).width; // matches the description rule below const flow = flowParts(S, s.id), flowW = flowWidth(ctx, flow); const name: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + row + 4, y0 - 4]; const all: [number, number, number, number] = [x0 - 4, y0 - (flow.length ? 66 : 52), x0 + Math.max(row, stats, flowW, 160) + 4, y0 - 4]; const withFlow: [number, number, number, number] = [x0 - 4, y0 - 40, x0 + Math.max(row, flowW) + 4, y0 - 4]; const bare: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + nw + 4, y0 - 4]; const fits = (b: [number, number, number, number]) => clear(...b) && !onFrame(s.id, b); // held inside the site, it is just the name and switches, or the name alone beside a crowding neighbour const mode = at.held ? (fits(name) ? 'name' : fits(bare) ? 'bare' : null) : fits(all) ? 'full' : flow.length && fits(withFlow) ? 'flow' : fits(name) ? 'name' : fits(bare) ? 'bare' : null; if (!mode) continue; const box = { full: all, flow: withFlow, name, bare }[mode]; taken.push(box); headers.set(s.id, { mode, x: at.x, y: at.y, held: at.held, box, name: [x0 - 2, y0 - 26, x0 + nw + 2, y0 - 6] }); } for (const n of S.news) { // news about an open site: a word in its bottom corner, dim until it starts const id = n.kind === 'conference' ? n.at : n.kind === 'storm' ? siteAt(S, S.byId[n.at].room) : null; const s = id ? S.level.map.sites.find(q => q.id === id) : null; if (!s || !siteKnown(S, s) || foldedSites.has(s.id)) continue; ctx.textAlign = 'right'; ctx.font = `500 ${10 * px}px ${MONO}`; ctx.fillStyle = n.kind === 'storm' ? C.danger : C.amber; ctx.globalAlpha = S.t >= n.from ? 0.95 : 0.5; ctx.fillText(n.kind === 'storm' ? 'STORM' : 'CONFERENCE', s.x + s.w - 10 * px, s.y + s.h - 10 * px); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } if (V.packed) drawGroups(ctx, S, px); else if (z < 0.03) { ctx.textAlign = 'center'; ctx.globalAlpha = Math.min(1, (0.03 - z) / 0.012); for (const r of S.level.map.regions) { const [x, y] = toMap(S, r.at[0], r.at[1]), [sx, sy] = toScreen(V, x, y); if (!clear(sx - 60, sy - 12, sx + 60, sy + 18)) continue; const open = regionOpen(S, r.name); ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = open ? C.text : C.muted; ctx.fillText(r.name.toUpperCase(), x, y); ctx.font = `400 ${9.5 * px}px ${MONO}`; ctx.fillStyle = open ? C.amber : `${TRACE}0.3)`; ctx.fillText(open ? 'IN REACH' : 'OUT OF REACH', x, y + 14 * px); } ctx.globalAlpha = 1; ctx.textAlign = 'left'; } for (const s of S.level.map.sites) { if (!siteKnown(S, s)) continue; const { mine, line, cut } = siteLine(S, s), lost = siteLine(S, s).lost || (cut ? 1 : 0); // a cut-off site's line is red, like one that is losing data const picked = V.sites.has(s.id), held = siteData(S, s.id); if (!foldedSites.has(s.id)) { // open: a faint frame that dims as you zoom into the site but never goes, since lines to other sites end on it; the header stays const lined = S.machines.some(m => m.kind === 'egress' && siteAt(S, m.room) === s.id && visible(S, m) && S.adj[m.id].some(o => crosses(S, m.id, o) && visible(S, S.byId[o]))); const a = Math.max(lined ? 0.4 : 0, Math.min(1, (0.45 - z) / 0.25)); if (a > 0) { ctx.globalAlpha = a; ctx.strokeStyle = picked ? C.text : `${TRACE}${0.3 * hum})`; ctx.lineWidth = (picked ? 2 : 1.2) * px; ctx.strokeRect(s.x, s.y, s.w, s.h); brackets(ctx, s, 24 * px, 2 * px, mine ? C.amber : `${TRACE}0.7)`); ctx.globalAlpha = 1; } const header = headers.get(s.id); // crowded out by a neighbour's: only the frame if (header && !header.held) drawHeader(ctx, S, V, s, header); // a held one is drawn last, over the rooms continue; } const big = isTile(s, z), cx = s.x + s.w / 2, cy = s.y + s.h / 2; ctx.textAlign = big ? 'center' : 'left'; if (big && !isRoomy(s, z)) drawBlock(ctx, S, s, picked, px); else if (big) { // a tile: the site's box, with its name and how much of it is yours ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(s.x, s.y, s.w, s.h); ctx.strokeStyle = mine ? C.amber : `${TRACE}0.5)`; ctx.lineWidth = 1.5 * px; ctx.strokeRect(s.x, s.y, s.w, s.h); brackets(ctx, s, 14 * px, 2.5 * px, mine ? C.amber : `${TRACE}0.8)`); ctx.font = `500 ${12 * px}px ${MONO}`; ctx.fillStyle = mine ? C.amber : C.text; const nw = ctx.measureText(s.name.toUpperCase()).width, row = nw + switchesWidth(ctx, S, s.id, px); ctx.textAlign = 'left'; ctx.fillText(s.name.toUpperCase(), cx - row / 2, cy - 2 * px); ctx.textAlign = 'center'; drawSwitches(ctx, S, V, s.id, cx - row / 2 + nw + 12 * px, cy - 6 * px, px); ctx.font = `400 ${10 * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; ctx.fillText(line, cx, cy + 14 * px); const rows = drawHeld(ctx, held, cx, cy + 28 * px, px, true) ? 1 : 0; drawFlow(ctx, flowParts(S, s.id), cx, cy + (28 + 14 * rows) * px, px, true); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 6 * px, s.y - 6 * px, s.w + 12 * px, s.h + 12 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, s.x + s.w - (14 + 16 * i) * px, s.y + 14 * px, 6 * px, S.byId[id].owned, 1.2 * px)); // a landmark here: a star in the corner vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, s.x + s.w - (14 + 16 * (landmarksAt(S, s.id).length + i)) * px, s.y + 14 * px, 5 * px, S.byId[id].owned, px)); // a vault here: its mark beside the stars } else { // too small for a tile: a marker with its name beside it const k = 5 * px; ctx.fillStyle = mine ? C.amber : C.void; ctx.fillRect(cx - k, cy - k, k * 2, k * 2); ctx.strokeStyle = mine ? C.amber : C.text; ctx.lineWidth = 1.5 * px; ctx.strokeRect(cx - k, cy - k, k * 2, k * 2); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(cx - k - 5 * px, cy - k - 5 * px, k * 2 + 10 * px, k * 2 + 10 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, cx + k + (4 + 10 * i) * px, cy - k - 6 * px, 4.5 * px, S.byId[id].owned, 1.2 * px)); // a landmark here: a star by the marker, so it can be found from far out vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, cx + k + (4 + 10 * (landmarksAt(S, s.id).length + i)) * px, cy - k - 6 * px, 3.5 * px, S.byId[id].owned, px)); // a vault here: its mark beside the stars const label = labelled.get(s.id); if (label) { const lx = cx + label.dx * px; ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = mine ? C.amber : C.text; ctx.fillText(s.name.toUpperCase(), lx, cy + 1 * px); drawSwitches(ctx, S, V, s.id, lx + ctx.measureText(s.name.toUpperCase()).width + 12 * px, cy - 3 * px, px); if (label.short) { ctx.textAlign = 'left'; continue; } ctx.font = `400 ${9.5 * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; ctx.fillText(line, lx, cy + 14 * px); const rows = drawHeld(ctx, held, lx, cy + 27 * px, px, false) ? 1 : 0; drawFlow(ctx, flowParts(S, s.id), lx, cy + (27 + 13 * rows) * px, px, false); } } ctx.textAlign = 'left'; } } /** The data held at a site, one colored count per kind: "42 PEOPLE 18 SERVER". Nothing when it holds none. */ const HELD_NAME: Record = { people: 'PEOPLE', servers: 'SERVER', network: 'NETWORK', lab: 'LAB' }; function drawHeld(ctx: CanvasRenderingContext2D, held: Record, x: number, y: number, px: number, centre: boolean) { const parts = (Object.keys(HELD_NAME) as DataType[]).filter(t => held[t] > 0).map(t => [t, `${held[t]} ${HELD_NAME[t]}`] as const); if (!parts.length) return false; ctx.font = `500 ${9.5 * px}px ${MONO}`; const gap = 10 * px, widths = parts.map(([, text]) => ctx.measureText(text).width); let at = centre ? x - (widths.reduce((a, b) => a + b, 0) + gap * (parts.length - 1)) / 2 : x; const align = ctx.textAlign; ctx.textAlign = 'left'; parts.forEach(([t, text], i) => { ctx.fillStyle = typeColor(t); ctx.fillText(text, at, y); at += widths[i] + gap; }); ctx.textAlign = align; return true; } function brackets(ctx: CanvasRenderingContext2D, n: { x: number; y: number; w: number; h: number }, k: number, w: number, color: string) { ctx.strokeStyle = color; ctx.lineWidth = w; ctx.beginPath(); for (const [cx, cy, sx, sy] of [[n.x, n.y, 1, 1], [n.x + n.w, n.y, -1, 1], [n.x + n.w, n.y + n.h, -1, -1], [n.x, n.y + n.h, 1, -1]]) { ctx.moveTo(cx + sx * k, cy); ctx.lineTo(cx, cy); ctx.lineTo(cx, cy + sy * k); } ctx.stroke(); } /** A line between two sites. It keeps a readable width however far out you are, shows how * much crosses it against what it can carry, and turns red when an egress on it is full. * Lines you don't hold stay faint: they are the map, not your network. */ interface UplinkLabel { text: string; color: string; curve: Pt[] } function drawUplink(ctx: CanvasRenderingContext2D, S: State, a: Machine, b: Machine, pts: Pt[], px: number, hum: number, labels: UplinkLabel[]) { const ends = [a, b].filter(m => m.kind === 'egress'); const cap = Math.min(...ends.map(m => m.cap ?? 1)), load = Math.max(0, ...ends.map(m => gateLoad(S, m))); const rate = Math.max(0, ...ends.map(m => S.gateUse[m.id] ?? 0)); const f = Math.min(1, (S.flow[linkKey(a.id, b.id)] ?? 0) / 6), full = load > 0.9, mine = a.owned && b.owned; if (mine) { ctx.strokeStyle = full ? C.danger : f > 0.05 ? C.amber : C.amberDeep; ctx.lineWidth = (2 + 3 * f) * px; ctx.shadowColor = ctx.strokeStyle; ctx.shadowBlur = 4 + 8 * f; ctx.globalAlpha = full ? 0.75 + 0.25 * Math.sin(S.t * 6) : 0.55 + 0.4 * f; } else { ctx.strokeStyle = `${TRACE}${0.2 * hum})`; ctx.lineWidth = 1 * px; ctx.setLineDash([5 * px, 7 * px]); } stroke(ctx, pts); ctx.setLineDash([]); ctx.shadowBlur = 0; ctx.globalAlpha = 1; const sa = siteById(S, siteAt(S, a.room)), sb = siteById(S, siteAt(S, b.room)), k = 3.5 * px; for (const [m, s, o] of [[a, sa, sb], [b, sb, sa]] as const) { // a port: a small square where the line meets the site's edge if (foldedSites.has(s.id)) continue; const [x, y] = portOf(S, m, o).pts.at(-1)!; ctx.fillStyle = C.void; ctx.fillRect(x - k, y - k, k * 2, k * 2); ctx.strokeStyle = mine ? C.amber : `${TRACE}${0.5 * hum})`; ctx.lineWidth = 1.2 * px; ctx.strokeRect(x - k, y - k, k * 2, k * 2); } const curve = curveOf(S, a, b, pts), [x0, y0] = curve[0], [x1, y1] = curve[curve.length - 1]; if (Math.hypot(x1 - x0, y1 - y0) / px < 160) return; const text = mine ? `${rate.toFixed(1)} OF ${cap}/S${full ? ' · FULL' : ''}` : `LINE OUT · ${cap}/S`; if (!blocks || full) labels.push({ text, color: full ? C.danger : mine ? C.amber : C.muted, curve }); // in the Net lines are short and sites are close: only a full line says so } /** Each line's label goes on its curve, outside every open site and clear of the other labels. * Yours are placed first. A label with no clear spot is left off. */ function drawUplinkLabels(ctx: CanvasRenderingContext2D, S: State, V: View, labels: UplinkLabel[], px: number) { const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)); const taken: [number, number, number, number][] = []; const hits = (r: [number, number, number, number], q: [number, number, number, number]) => r[0] < q[2] && r[2] > q[0] && r[1] < q[3] && r[3] > q[1]; ctx.font = `500 ${10.5 * px}px ${MONO}`; for (const l of labels.sort((p, q) => +(q.color !== C.muted) - +(p.color !== C.muted))) { const w = ctx.measureText(l.text).width; for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) { const [mx, my] = along(l.curve, t), x = mx + 8 * px, y = my - 8 * px; const r: [number, number, number, number] = [x - 3 * px, y - 10 * px, x + w + 3 * px, y + 4 * px]; const [sx0, sy0] = toScreen(V, r[0], r[1]), [sx1, sy1] = toScreen(V, r[2], r[3]); // and off site names on the map if (mapTaken.some(q => hits([sx0, sy0, sx1, sy1], q))) continue; if (taken.some(q => hits(r, q)) || open.some(s => hits(r, [s.x - 50 * px, s.y - 50 * px, s.x + s.w, s.y + s.h]))) continue; taken.push(r); plate(ctx, px, l.text, x, y, l.color); break; } } } /** A landmark's mark: a small four-point star. Solid amber when you hold it, a muted outline when you don't. */ function drawStar(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, held: boolean, w: number) { ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = (i * Math.PI) / 4, q = i % 2 ? r * 0.38 : r; ctx.lineTo(x + Math.sin(a) * q, y - Math.cos(a) * q); } ctx.closePath(); ctx.fillStyle = held ? C.amber : C.void; ctx.fill(); ctx.strokeStyle = held ? C.amber : C.muted; ctx.lineWidth = w; ctx.stroke(); } /** An egress: two arrows, out and in. */ function drawPort(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string) { const u = r * 0.6; ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(x - u, y - u * 0.35); ctx.lineTo(x + u, y - u * 0.35); ctx.lineTo(x + u * 0.55, y - u * 0.8); ctx.moveTo(x + u, y + u * 0.35); ctx.lineTo(x - u, y + u * 0.35); ctx.lineTo(x - u * 0.55, y + u * 0.8); ctx.stroke(); } /** Beside a tap: the data it's holding, red when full. */ function drawBuffer(ctx: CanvasRenderingContext2D, S: State, m: Machine, x: number, y: number, r: number) { const cap = tapBuffer(S), held = Math.min(cap, Math.floor(m.acc)), full = held >= cap; for (let i = 0; i < cap; i++) { const bx = x + r + 7 + Math.floor(i / 3) * 7, by = y + r - 5 - (i % 3) * 7; ctx.fillStyle = full ? C.danger : C.amber; ctx.strokeStyle = `${TRACE}0.35)`; ctx.lineWidth = 1; if (i < held) ctx.fillRect(bx, by, 5, 5); else ctx.strokeRect(bx + 0.5, by + 0.5, 4, 4); } } // ---------- how odd a machine looks ---------- // A ring of ticks that fills clockwise. Gray while safe, red past the line where // Dana acts; notches mark her scan (50%) and her walk-in check (65%). const oddSeen = new Map(); const TICKS = 40, SCAN_AT = 0.5, WALK_AT = 0.65; function drawOdd(ctx: CanvasRenderingContext2D, S: State, m: Machine, x: number, y: number, r: number) { const h = m.odd / 100, prev = oddSeen.get(m.id) ?? h; oddSeen.set(m.id, h); const rising = h > prev + 1e-5; if (h < 0.01) return; const R0 = r + 13, len = 6, filled = h * TICKS, lead = Math.ceil(filled) - 1; const pulse = 0.72 + 0.28 * Math.sin(S.t * 5); ctx.lineCap = 'butt'; for (let i = 0; i < TICKS; i++) { const a = -Math.PI / 2 + ((i + 0.5) / TICKS) * Math.PI * 2, c = Math.cos(a), s = Math.sin(a); const at = i / TICKS, on = i <= lead, danger = at >= SCAN_AT; let alpha: number, color: string, l = len, w = 2; if (!on) { color = danger ? C.danger : C.text; alpha = danger ? 0.16 : 0.1; } else if (danger) { color = C.danger; alpha = pulse; } else { color = C.text; alpha = 0.3 + 0.55 * (at / SCAN_AT); } if (on && i === lead) { l = len + 4; w = 2.6; alpha = Math.max(0.35, Math.min(1, filled - lead + 0.35)) * (rising ? 0.65 + 0.35 * Math.sin(S.t * 22) : 1); } ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(x + c * R0, y + s * R0); ctx.lineTo(x + c * (R0 + l), y + s * (R0 + l)); ctx.stroke(); } if (h > 0.25) { for (const at of [SCAN_AT, WALK_AT]) { const a = -Math.PI / 2 + at * Math.PI * 2, c = Math.cos(a), s = Math.sin(a), R1 = R0 + len + 6; ctx.globalAlpha = 0.8; ctx.fillStyle = C.danger; ctx.beginPath(); ctx.moveTo(x + c * R1, y + s * R1); ctx.lineTo(x + c * (R1 + 5) - s * 3, y + s * (R1 + 5) + c * 3); ctx.lineTo(x + c * (R1 + 5) + s * 3, y + s * (R1 + 5) - c * 3); ctx.closePath(); ctx.fill(); } } ctx.globalAlpha = 1; ctx.lineCap = 'square'; } // ---------- data value: 1-3 pips above anything worth tapping, in its kind's color ---------- function drawValue(ctx: CanvasRenderingContext2D, S: State, m: Machine, x0: number, y0: number, r: number) { const n = m.data!, gap = 9, y = y0 - r - 13, live = m.prog === 'tap' && tapActive(S, m), col = typeColor(dataType(m)); if (live) { // a working tap glows ctx.fillStyle = col; ctx.globalAlpha = 0.14 + 0.1 * Math.sin(S.t * 4); ctx.beginPath(); ctx.arc(x0, y0, r + 18, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } for (let i = 0; i < n; i++) { const x = x0 + (i - (n - 1) / 2) * gap; ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); ctx.fillStyle = col; ctx.globalAlpha = m.owned ? 1 : 0.5; ctx.fillRect(-3, -3, 6, 6); ctx.restore(); } ctx.globalAlpha = 1; } // ---------- programs: a small symbol inside the node ---------- function drawProg(ctx: CanvasRenderingContext2D, S: State, m: Machine, x: number, y: number, r: number) { const u = r * 0.62, on = m.run > 0.01; ctx.strokeStyle = C.ink; ctx.fillStyle = C.ink; ctx.lineWidth = 1.6; ctx.globalAlpha = on ? 1 : 0.35; switch (m.prog) { case 'tap': { const k = on ? 0.85 + 0.15 * Math.sin(S.t * 6) : 1; ctx.beginPath(); ctx.moveTo(x - u * k, y - u * 0.6); ctx.lineTo(x + u * k, y - u * 0.6); ctx.lineTo(x, y + u * 0.7); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y - u * 0.05, 1.8, 0, 7); ctx.fill(); break; } case 'trader': { // a price line that rises or falls with the market ctx.beginPath(); for (let i = 0; i <= 6; i++) { const xx = x - u + (2 * u * i) / 6, yy = y + u * 0.5 - (i / 6) * u * S.market.move - Math.sin(S.t * 2 + i * 1.7) * u * 0.18; if (i) ctx.lineTo(xx, yy); else ctx.moveTo(xx, yy); } ctx.stroke(); break; } case 'broker': { // a dollar sign over a bar that fills toward the next lot ctx.font = `600 ${u * 1.5}px ${MONO}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', x, y - u * 0.2); const w = u * 1.6, k = Math.min(1, m.held.length / BROKER_LOT); ctx.strokeRect(x - w / 2, y + u * 0.6, w, 3); ctx.fillRect(x - w / 2, y + u * 0.6, w * k, 3); break; } case 'decoy': { // a target: two rings and a dot ctx.beginPath(); ctx.arc(x, y, u, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, u * 0.5, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, 1.8, 0, 7); ctx.fill(); break; } case 'tarpit': { // an hourglass ctx.beginPath(); ctx.moveTo(x - u * 0.7, y - u); ctx.lineTo(x + u * 0.7, y - u); ctx.lineTo(x - u * 0.7, y + u); ctx.lineTo(x + u * 0.7, y + u); ctx.closePath(); ctx.stroke(); break; } case 'cache': { const n = 4, filled = Math.ceil((m.held.length / cacheSize(S, m)) * n); for (let i = 0; i < n; i++) { const yy = y + u * 0.75 - i * (u * 0.5), w = u * 1.5; if (i < filled) ctx.fillRect(x - w / 2, yy - 2, w, 3.5); else ctx.strokeRect(x - w / 2, yy - 2, w, 3.5); } break; } case 'trainer': { const a = S.t * 5 * m.run; ctx.beginPath(); ctx.arc(x, y, u, a, a + Math.PI * 1.5); ctx.stroke(); ctx.beginPath(); ctx.arc(x + Math.cos(a) * u, y + Math.sin(a) * u, 2.2, 0, 7); ctx.fill(); const buf = trainerBuffer(S); for (let i = 0; i < buf; i++) { const b = (i / buf) * Math.PI * 2 - Math.PI / 2; ctx.globalAlpha = i < m.held.length ? 1 : 0.2; ctx.beginPath(); ctx.arc(x + Math.cos(b) * u * 0.4, y + Math.sin(b) * u * 0.4, 1.4, 0, 7); ctx.fill(); } break; } } ctx.globalAlpha = 1; } // ---------- load: the machine's normal busy-ness vs yours ---------- // Gray band: its normal load right now (your cover). Amber: your load. Red: what shows. function drawLoad(ctx: CanvasRenderingContext2D, S: State, m: Machine, x0: number, y0: number, r: number) { const w = r * 2 + 8, x = x0 - w / 2, y = y0 + r + 7, h = 4; const cover = S.cover[m.id] ?? 0, mine = Math.min(1, S.use[m.id] ?? 0); ctx.fillStyle = `${TRACE}0.1)`; ctx.fillRect(x, y, w, h); ctx.fillStyle = `${TRACE}0.3)`; ctx.fillRect(x, y, w * cover, h); ctx.fillStyle = C.amber; ctx.fillRect(x, y + 1, w * Math.min(mine, cover), h - 2); if (mine > cover + 0.005) { ctx.fillStyle = C.danger; ctx.fillRect(x + w * cover, y, w * (mine - cover), h); } }