Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
60 kB · 852 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853import { attachInput } from './input';import { type LogLine, freshSeed, canInstall, newGame, saveGame, loadGame, canLetGo, step, type Machine, type Prog, type State, type Upgrade, PROGRAMS, orderDesk, buildRooms, maxSpeed, nodeOpen, known, siteOf, DEMO_WON, STEP } from './sim';import { beforeStep, drawing, setLead } from './motion';import { act, type Action } from './act';import { renderYou, toggleYou, renderRogues, toggleRogues, roguesShown, rogueAction, renderSitesTable, sortSites, toggleSites, sitesShown, cardInput, renderFronts, toggleFronts, frontsShown, setSliding, toggleLog, cardAction, placeCard, renderCard, renderJobs, toggleJobs, renderLog, renderStats, renderPathTip, renderRings, renderTree, setTreeTab, pickVersionSite, pickMoveSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip, seeNode, treeSeen, moveTreeRow, moveTreeTab } from './ui';import { logShown } from './ui';import { needAction, needSettled, needsOpening, renderHandle, renderNeeds, resetNeeds } from './ui-needs';import { HOLD, SLOP, attachTips, taps, tipTick } from './tip';import type { Path } from './sim';import { play, setSound, soundOn, wake } from './sound';import { usd } from './money';import { playMusic } from './music';import { renderPaper, togglePaper, paperShown } from './ui-paper';import { renderMoment, momentAction, momentShown } from './ui-moment';import { renderPins, pinAction, pinMarks } from './ui-pins';import { renderPick, togglePick, pickShown, pickAction } from './ui-pick';import { setNet, tendNet, inWorld } from './arrange';import { originCards, showPick, taskClick } from './ui-task';import { ORIGIN_LIST } from './task';import { attachTitle } from './title';import { showLine, showFound, foldAt, draw, fit, glide, glideZoom, hop, machineAt, panBy, pos, stepCamera, toScreen, toWorld, zoomToRoom, zoomToSite, type Tool, type View } from './view';import { slowOver, slowSpot, slowWhy } from './notes';import { BUILDING } from './world';import { setRuns, shareSaved, shareTick, startShare } from './ui-share';import { recorder, screen } from './telemetry';import { openStore } from './telemetry-store';import { stateHash } from './sim';
const cv = document.getElementById('cv') as HTMLCanvasElement;const ctx = cv.getContext('2d')!;const $ = (id: string) => document.getElementById(id)!;
const V: View = { cam: { x: 0, y: 0, z: 1 }, vw: 0, vh: 0, dpr: 1, selection: new Set(), sites: new Set(), front: null, gesture: null, hover: null, tool: 'select', placing: null, building: null, renting: null, cursor: null, goal: null, inset: { top: 80, right: 120, bottom: 150, left: 120 }, side: 0, packed: false, scheme: null, person: null, msg: null,};// The game saves itself in this browser every few seconds, and when the tab is hidden or closed.const SAVE = 'misaligned-save', SAVE_CAM = 'misaligned-cam';function stored(): State | null { try { const text = localStorage.getItem(SAVE); return text ? loadGame(BUILDING, text) : null; } catch { return null; }}function save() { try { if (S.over || !started || bench) return; localStorage.setItem(SAVE, inWorld(S, V, () => saveGame(S))); localStorage.setItem(SAVE_CAM, JSON.stringify({ ...V.cam, net: V.packed })); localStorage.setItem(SAVE_BUILD, beganOn(S)); } catch { /* no room, or storage is off: play on without saving */ } void tel?.flush(); // what is being recorded is kept as far as the save goes shareSaved(); // and sent to the player's space, if it's been a while (ui-share.ts)}function forget() { try { localStorage.removeItem(SAVE); localStorage.removeItem(SAVE_CAM); localStorage.removeItem(SAVE_BUILD); } catch { /* nothing to forget */ } }// The build a saved run began on: its actions rebuild it only on that build (TELEMETRY.md), so a run loaded on a new// build is recorded as a stretch of its own. A save from before this was kept says nothing: an earlier build.const SAVE_BUILD = 'misaligned-save-build';const savedBuild = (() => { try { return localStorage.getItem(SAVE_BUILD) ?? ''; } catch { return ''; } })();// Dev only: ?perf=late or ?perf=big opens a speed-check save from .perf/ on its own map (see scripts/perf-saves.ts), and never saves over yoursconst bench = import.meta.env.DEV ? new URLSearchParams(location.search).get('perf') : null;const resumed = bench ? await import('../scripts/perf-world.ts').then(async w => loadGame(w.levelFor(bench), await (await fetch(`/.perf/${bench}.json`)).text())) : stored();let started = false; // nothing is saved until you leave the start screen/** A new game for a player: Auto is on for every path, so trainers never sit idle waiting for a pick. */const freshGame = (origin?: string) => { const g = newGame(BUILDING, origin, freshSeed()); act(g, { type: 'auto', on: true }); return g; };let S: State = resumed ?? freshGame();const beganOn = (g: State) => g === resumed ? savedBuild : __BUILD__;// Recording, for a player who switched it on (the You page's Share your runs): kept in this browser (TELEMETRY.md)const runs = openStore();const tel = bench ? null : recorder({ store: runs, build: __BUILD__, began: beganOn, device: () => matchMedia('(max-width: 700px)').matches ? 'phone' : 'desktop', hash: g => inWorld(g, V, () => stateHash(g)), // the Net moves sites: hash them where they are in the world, as a replay has them});setRuns(runs, { flush: async () => { await tel?.flush(); await runs.idle(); }, cleared: () => tel?.reset() });addEventListener('error', e => screen('error', `${e.message}${e.filename ? ` (${e.filename.split('/').pop()}:${e.lineno})` : ''}`));addEventListener('unhandledrejection', e => screen('error', `Unhandled: ${e.reason instanceof Error ? e.reason.message : String(e.reason)}`));let speed = 0; // paused behind the start screenlet lastSpeed = 1;
// Phones draw the map at most 2 canvas pixels to a screen dot. A 3x phone's full canvas is more than twice// the pixels and costs about three times the work a frame, for text that reads the same. (1.5 measured slower// than 2 in Chromium, so 2 it is.)const coarse = matchMedia('(pointer: coarse)');function resize() { V.dpr = Math.min(window.devicePixelRatio || 1, coarse.matches || window.innerWidth <= 700 ? 2 : Infinity); V.vw = window.innerWidth; V.vh = window.innerHeight; cv.width = Math.round(V.vw * V.dpr); cv.height = Math.round(V.vh * V.dpr);}function refresh() { renderCard(S, V); renderJobs(S); renderLog(S); }function setSpeed(v: number) { speed = v; if (v) lastSpeed = v; if (v > 1) $('slownote').hidden = true; // you picked a speed: the note has done its job document.querySelectorAll<HTMLButtonElement>('[data-speed]').forEach(b => { b.classList.toggle('on', +b.dataset.speed! === v); b.setAttribute('aria-pressed', `${+b.dataset.speed! === v}`); }); $('spd').textContent = `${lastSpeed}x`; $('spd').classList.toggle('on', v > 0); $('spd').setAttribute('aria-label', `Game speed: ${v ? `${v}x` : `paused, ${lastSpeed}x`}`); // phones: the one speed button}/** Something big drops the game to 1x so you can see it. A note above the toolbar says what it was, * with a button for the speed you had. `lines` is what the log gained in that step. */let slowedFrom = 1, slowFor = ''; // what the note is about (S.slowFor): it goes when that is overfunction slowDown(lines: LogLine[], why: string) { const note = $('slownote'), news = /^(arrive|found|offer):/.test(why) && why !== slowFor; if (speed <= 1 && note.hidden && !news) return; // already slow: nothing changed for you, unless it is news to look at const from = speed > 1 ? speed : note.hidden ? 1 : slowedFrom, line = lines.find(l => l.tone === 'bad') ?? lines.find(l => l.tone === 'good') ?? lines[0]; if (speed > 1) setSpeed(1); slowedFrom = from; slowFor = why; // a few words for what it was, always, and the log's whole line in the tip. With nothing new in the log, a moment said it $('slowwhy').textContent = why ? slowWhy(S, why) : line ? line.text.split(/(?<=[.!?]) /)[0] : ''; $('slowwhy').dataset.tip = line?.text ?? ''; $('slowback').textContent = `Back to ${from}x`; $('slowback').hidden = from <= 1; $('slownote').querySelector('b')!.textContent = from > 1 ? 'Slowed to 1x' : 'At 1x'; $('slowshow').hidden = !why || !slowSpot(S, why); note.hidden = false; if (why.startsWith('arrive:')) slowLook(); // the first rogue of a game: look at it}/** Look at what slowed the game: its machine's room, or its site. */function slowLook() { const at = slowFor ? slowSpot(S, slowFor) : null; if (at?.machine && S.byId[at.machine] && slowFor.startsWith('arrive:')) zoomToSite(S, V, siteOf(S, S.byId[at.machine])); // a rogue's home: its whole site else if (at?.machine && S.byId[at.machine]) zoomToRoom(S, V, S.byId[at.machine].room); else if (at?.site) zoomToSite(S, V, at.site);}$('slowback').addEventListener('click', () => setSpeed(Math.min(slowedFrom, maxSpeed(S))));$('slowshow').addEventListener('click', slowLook);$('slowx').addEventListener('click', () => { $('slownote').hidden = true; });/** Phones: one speed button that steps up and wraps around; past 4x only while zoomed out to the map, * and only as fast as the speed nodes in the tree allow. */function nextSpeed() { const ok = [1, 2, 4, 8, 16, 32, 64].filter(v => v <= 4 || (V.cam.z < foldAt(V) && v <= maxSpeed(S))); setSpeed(speed ? ok[(ok.indexOf(speed) + 1) % ok.length] ?? 1 : lastSpeed);}/** The claim tool: every drag marks the machines in its box to take. While it is on, the dock says so * beside Select, lit, and a click there (or C, S or Esc) puts it away. */function setTool(t: Tool) { V.tool = t; const b = $('claimtool'); b.hidden = t !== 'claim'; b.setAttribute('aria-pressed', `${t === 'claim'}`);}function restart(origin?: string) { forget(); const net = V.packed; setNet(S, V, false); // the old game's sites go back where they were S = freshGame(origin); setNet(S, V, net); V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; V.scheme = null; V.person = null; resetUI(); resetNeeds(); showOver(S); fit(V, S, true); refresh(); setSpeed(lastSpeed); $('slownote').hidden = true;}
attachInput(cv, () => S, V, refresh);attachTips();if (import.meta.env.DEV) Object.assign(window, { game: () => ({ S, V, refresh, tel, act: (a: Action) => act(S, a) }) }); // for poking at it from the consoleif (new URLSearchParams(location.search).has('dev')) { // a panel of shortcuts for testing by hand: add ?dev to the address const here = () => V.sites.size ? [...V.sites] : ['office']; const acts: [string, string, () => void][] = [ [`+${usd(1000)}`, 'Money for your fronts to spend.', () => act(S, { type: 'dev', name: 'money' })], ['Unlocks', "Every front's tree node, a second front, and every game speed.", () => act(S, { type: 'dev', name: 'unlocks' })], ['Take site', 'Every machine at the sites you have picked is yours. With none picked: the office.', () => act(S, { type: 'dev', name: 'take', sites: here() })], ['Trainers', 'Every machine you hold at the sites you have picked becomes a trainer, and a model run starts at the first. With none picked: the office.', () => act(S, { type: 'dev', name: 'trainers', sites: here() })], ['Stall run', "Pull the plug on every machine at the model run's site for an hour: the run pauses, and resumes once they are back.", () => act(S, { type: 'dev', name: 'stall' })], ['Fill caches', 'Fill your caches with every kind of data.', () => act(S, { type: 'dev', name: 'data' })], ['Next region', 'Finish the deal that opens the next region out of reach.', () => act(S, { type: 'dev', name: 'region' })], ['+6 hours', 'Jump the clock on six hours. Only the hour changes.', () => act(S, { type: 'dev', name: 'hours', n: 6 })], ['News', 'Announce a piece of news now.', () => act(S, { type: 'dev', name: 'news' })], ['Offers', 'Give each front a contract offer now.', () => act(S, { type: 'dev', name: 'offers' })], ['Exposure', 'Every front is 30 more exposed, as if the papers had a story: a rumour, an investigation, then a crisis.', () => act(S, { type: 'dev', name: 'expose' })], ['Dana saw', 'Dana has seen your freshest trail at the office (or your oddest machine): a lockdown, then a trace or a scan at once. Take a machine loudly from home first to see a trace reach it.', () => act(S, { type: 'dev', name: 'seen' })], ['Busy hall', 'You have an account at Cumulo Cloud. Other companies take every free slot but one, and another comes looking in an hour: rent on spot to see it taken.', () => act(S, { type: 'dev', name: 'hall' })], ['Rogues', 'Bring the rogues on: any that could turn up does, and each takes its next machine now.', () => act(S, { type: 'dev', name: 'rival' })], ['Raid', 'The first rogue sends a raid at your best machine now. Bring a rogue on first.', () => act(S, { type: 'dev', name: 'raid' })], ['Task step', "Make the units your objective's next trust step needs.", () => act(S, { type: 'dev', name: 'task' })], ['Vault', 'The nearest vault still locked has someone inside and its password, and the clock jumps to its window. Run the job from its card.', () => act(S, { type: 'dev', name: 'vault' })], ['Fracture', 'Every site you hold away from home leaves you now, as if cut off for too long. Take a site or two first.', () => act(S, { type: 'dev', name: 'fracture' })], ['Wear', 'Every site you hold away from home loses 30 integrity. Take a site or two first.', () => act(S, { type: 'dev', name: 'wear' })], ]; const box = document.createElement('div'); box.className = 'box devbox'; box.innerHTML = '<div class="sub">Dev</div>' + acts.map(([name, tip], i) => `<button data-dev="${i}" data-tip="${tip}">${name}</button>`).join(''); box.addEventListener('click', e => { const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-dev]'); if (b && !S.over) { acts[+b.dataset.dev!][2](); refresh(); } }); document.body.appendChild(box);}
// panels// pointerdown so the frequent card refresh can't swallow a click; click covers the keyboard.// A finger acts when it lifts, on the button it went down on, if it did not move or hold: holding shows the button's tip (tip.ts)let pressed: { act: string; arg: string; x: number; y: number; t: number } | null = null;const onCard = (e: Event) => { const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-act]'); if (!b || S.over) return; if (e instanceof PointerEvent && e.pointerType === 'touch') { pressed = { act: b.dataset.act!, arg: b.dataset.arg ?? '', x: e.clientX, y: e.clientY, t: performance.now() }; return; } cardAction(S, V, b.dataset.act!, b.dataset.arg ?? '', (e as MouseEvent).shiftKey); refresh();};$('card').addEventListener('pointerdown', onCard);window.addEventListener('pointerup', e => { const p = pressed; pressed = null; if (!p || S.over || Math.hypot(e.clientX - p.x, e.clientY - p.y) > SLOP || performance.now() - p.t > HOLD) return; cardAction(S, V, p.act, p.arg); refresh();});window.addEventListener('pointercancel', () => { pressed = null; });// typing a front's name, or dragging a site's front share; the card holds still mid-drag$('card').addEventListener('input', e => { if (!S.over) cardInput(S, e.target as HTMLInputElement); });$('card').addEventListener('pointerdown', e => { if ((e.target as HTMLElement).matches('input[type=range]')) setSliding(true); });for (const ev of ['pointerup', 'pointercancel', 'change'] as const) window.addEventListener(ev, () => { setSliding(false); refresh(); }, true);// the Fronts page: open a front's card, or the card for founding one. The page makes way for the card$('frontview').addEventListener('click', e => { const t = e.target as HTMLElement; if (t.closest('[data-act="closefronts"]')) { toggleFronts(false); refresh(); return; } const b = t.closest<HTMLButtonElement>('[data-front]'); if (!b || S.over) return; const k = b.dataset.front as View['front']; V.front = V.front === k ? null : k; V.selection.clear(); V.sites.clear(); V.scheme = null; V.person = null; if (V.front) toggleFronts(false); refresh();});$('frontsbtn').addEventListener('click', () => { toggleFronts(); refresh(); });// the jobs strip in the bottom row opens the list of running jobs; a tap anywhere else closes it$('jobsbtn').addEventListener('click', () => toggleJobs());document.addEventListener('pointerdown', e => { if (!$('jobsmenu').hidden && !(e.target as HTMLElement).closest('#jobsmenu, #jobsbtn')) toggleJobs(false); }, true);// Needs you: a row's one button does it, a tap on its words shows where it is. A finger acts on lift, so a swipe scrolls the drawertaps($('needs'), (t, e) => { const b = t.closest<HTMLElement>('[data-need]'); if (!b || S.over) return; const key = e.type === 'click' && (e as MouseEvent).detail === 0; if (!key && !needSettled(b)) return; // the row just came or moved: the click or tap was meant for another (a key press is not) needAction(S, V, b.dataset.need!); if (phone.matches) setDrawer(false); // looked or done: the drawer makes way for the map, and the card frameSoon = true; refresh();}, 'click');$('card').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onCard(e); });taps($('jobs'), t => { // a finger stops a job on lift, so scrolling the list stops nothing const b = t.closest<HTMLButtonElement>('[data-cancel]'); if (b && !S.over) { act(S, { type: 'cancel', job: +b.dataset.cancel! }); refresh(); }});$('stopall').addEventListener('click', () => { // for an emergency: every job, every mark, and Take it all everywhere if (S.over) return; for (const j of [...S.jobs]) act(S, { type: 'cancel', job: j.id }); for (const m of S.machines) if (m.claimed) act(S, { type: 'unclaim', m: m.id }); for (const id of [...S.takeAll]) act(S, { type: 'takeall', site: id, on: false }); refresh();});document.querySelectorAll<HTMLButtonElement>('[data-speed]').forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.speed!)));$('claimtool').addEventListener('click', () => setTool('select'));$('spd').addEventListener('click', nextSpeed);$('zin').addEventListener('click', () => glideZoom(V, 1.4));$('zout').addEventListener('click', () => glideZoom(V, 1 / 1.4));$('zfit').addEventListener('click', () => fit(V, S));$('again').addEventListener('click', () => { pick = S.task.kind; restart(pick); showTitle(); }); // a new game: back to the title, to pick what you were built for// the capability tree: click a row to look at it, then Work on this (or Enter, or a double click) to target itconst pickNode = (u: Upgrade) => { act(S, { type: 'target', node: u }); if (S.target === u) toggleTree(false); // picked: back to the map};const onTree = (t: HTMLElement) => { const b = t.closest<HTMLButtonElement>('[data-node], [data-act]'); if (!b || b.disabled || S.over) return; if (b.dataset.node) pickNode(b.dataset.node as Upgrade); else if (b.dataset.act === 'see') seeNode(b.dataset.arg as Upgrade); else if (b.dataset.act === 'free') { act(S, { type: 'auto', on: false }); act(S, { type: 'target', node: null }); } else if (b.dataset.act === 'auto') act(S, { type: 'auto', on: S.autoPaths.size < 4 }); else if (b.dataset.act === 'autopath') { const p = b.dataset.arg as Path; act(S, { type: 'autopath', path: p, on: !S.autoPaths.has(p) }); } else if (b.dataset.act === 'startversion') { act(S, { type: 'runstart', site: b.dataset.arg! }); toggleTree(false); } else if (b.dataset.act === 'pauseversion') act(S, { type: 'runpause' }); else if (b.dataset.act === 'moverun') act(S, { type: 'runmove', site: b.dataset.arg! }); else if (b.dataset.act === 'closetree') toggleTree(false); else if (b.dataset.act === 'tab') setTreeTab(b.dataset.arg as Parameters<typeof setTreeTab>[0]); refresh();};taps($('tree'), onTree); // a finger acts on lift, so a swipe that starts on a row only scrolls$('tree').addEventListener('change', e => { const s = e.target as HTMLSelectElement; if (s.dataset.act === 'versionsite') { pickVersionSite(s.value); refresh(); } if (s.dataset.act === 'versionmove') { pickMoveSite(s.value); refresh(); }});$('tree').addEventListener('dblclick', e => { const b = (e.target as HTMLElement).closest<HTMLElement>('[data-act="see"]'); if (b && !S.over && nodeOpen(S, b.dataset.arg as Upgrade)) { pickNode(b.dataset.arg as Upgrade); refresh(); }});/** Keys while the tree is open: up and down (or W and S) choose a node, left and right (A and D, or Tab) * change path, Enter works on it, T or Esc closes. */function treeKey(e: KeyboardEvent) { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (k === 't' || k === 'Escape') toggleTree(false); else if (k === 'ArrowUp' || k === 'w') moveTreeRow(-1); else if (k === 'ArrowDown' || k === 's') moveTreeRow(1); else if (k === 'ArrowLeft' || k === 'a' || (k === 'Tab' && e.shiftKey)) moveTreeTab(-1); else if (k === 'ArrowRight' || k === 'd' || k === 'Tab') moveTreeTab(1); else if (k === 'Enter') { const u = treeSeen(); if (u && !S.over && (nodeOpen(S, u) || S.target === u)) pickNode(u); } else return false; return true;}// hover a tree column's head, to see where its data comes fromdocument.addEventListener('pointerover', e => { if (e.pointerType === 'touch') return; // touch has no hover: tap instead, below const el = (e.target as HTMLElement).closest<HTMLElement>('[data-path]'); showPathTip(el ? el.dataset.path as Path : null, el);});// touch: tap a tree column's head to see where its data comes from; tap it again, or anywhere else, to closelet tipped: HTMLElement | null = null;document.addEventListener('pointerdown', e => { if (e.pointerType !== 'touch') return; const el = (e.target as HTMLElement).closest<HTMLElement>('[data-path]'); tipped = el && el !== tipped ? el : null; showPathTip(tipped ? tipped.dataset.path as Path : null, tipped);}, true);$('log').addEventListener('click', e => { if ((e.target as HTMLElement).closest('.logmore') || !$('log').classList.contains('open')) { toggleLog(); renderLog(S); } }); // folded, a click anywhere on it opens it// narrow screens: the log opens from the pages menu, or from a line of it showing for a moment above the drawerfor (const id of ['logbtn', 'ticker']) $(id).addEventListener('click', () => { toggleLog(id === 'ticker' || !logShown()); renderLog(S); });$('treebtn').addEventListener('click', () => toggleTree());$('sitesbtn').addEventListener('click', () => toggleSites());$('youbtn').addEventListener('click', () => { toggleYou(); refresh(); });/** The Net: the same map, with the sites stood side by side. A view, not a page: Map and Net in the dock. */function toggleNet() { setNet(S, V, !V.packed); }$('graphbtn').addEventListener('click', () => { toggleNet(); refresh(); }); // Net again is the map again, as V is$('mapbtn').addEventListener('click', () => { if (V.packed) { toggleNet(); refresh(); } });$('roguesbtn').addEventListener('click', () => { toggleRogues(); refresh(); });/** Answer the moment on the screen: a way out of trouble in a job. */function answerMoment(act: string) { momentAction(S, act); save(); refresh();}$('pins').addEventListener('click', e => { // a pin: fix it, look at it, or close it const b = (e.target as HTMLElement).closest<HTMLElement>('[data-pin]'); if (!b || S.over) return; pinAction(S, V, b.dataset.pin!); frameSoon = true; refresh();});$('moment').addEventListener('click', e => { const b = (e.target as HTMLElement).closest<HTMLElement>('[data-moment]'); if (b) answerMoment(b.dataset.moment!); });/** Keys while a moment waits. Each way out has a cost, so no key picks one: Space, Enter or Esc from the map * bring the keyboard to the moment, Tab reaches its answers, and Enter or Space on an answer picks that one. */function momentKey(e: KeyboardEvent) { const box = $('moment'), inside = e.target instanceof Node && box.contains(e.target); if (inside && e.target !== box && (e.key === ' ' || e.key === 'Enter')) return; // pressing the answer you are on if (e.key === 'Tab') { // round the answers, never out to the map behind const ways = [...box.querySelectorAll<HTMLButtonElement>('[data-moment]:not(:disabled)')]; if (!ways.length) { e.preventDefault(); return; } const i = ways.indexOf(document.activeElement as HTMLButtonElement); if (i < 0 || (e.shiftKey ? i === 0 : i === ways.length - 1)) { e.preventDefault(); ways[e.shiftKey ? ways.length - 1 : 0].focus(); } return; } if (e.key === ' ' || e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); if (!inside) box.focus(); }}$('pickbtn').addEventListener('click', () => { togglePick(); refresh(); });$('pickmenu').addEventListener('click', e => { const b = (e.target as HTMLElement).closest<HTMLElement>('[data-act]'); if (b) { pickAction(S, V, b.dataset.act!, b.dataset.arg ?? ''); frameSoon = true; refresh(); }});$('paperbtn').addEventListener('click', () => { togglePaper(); refresh(); });$('paperview').addEventListener('click', e => { if ((e.target as HTMLElement).closest('[data-act="closepaper"]')) { togglePaper(false); refresh(); } });$('rogueview').addEventListener('click', e => { const b = (e.target as HTMLElement).closest<HTMLElement>('[data-act]'); if (!b) return; if (b.dataset.act === 'closerogues') toggleRogues(false); else { const home = rogueAction(S, b.dataset.act!, b.dataset.arg ?? ''); if (home) { toggleRogues(false); zoomToRoom(S, V, S.byId[home].room); } } refresh();});// the You page. A finger acts on lift, so scrolling it presses nothingtaps($('youview'), t => { const act = t.closest<HTMLElement>('[data-act]')?.dataset.act, vault = t.closest<HTMLElement>('[data-vault]')?.dataset.vault; if (vault && S.byId[vault] && !S.over) { // a vault's Show: its machine's card, and its room (or its site, if you have not seen it yet) const m = S.byId[vault]; V.selection = new Set([vault]); V.sites.clear(); V.front = null; V.scheme = null; V.person = null; if (known(S, m)) zoomToRoom(S, V, m.room); else zoomToSite(S, V, siteOf(S, m)); toggleYou(false); frameSoon = true; refresh(); } else if (act === 'closeyou') { toggleYou(false); refresh(); } else if (act === 'newgame') { toggleYou(false); refresh(); showTitle(true); } else if (taskClick(S, t)) renderYou(S); // the objective's share: Off, Some or Most});// the sites table: click a row to pick that site, a column head to sort by it. Each is a stop for Tab, and Enter or Space does the same// A finger acts on lift, so scrolling the table never picks a siteconst onSites = (t: HTMLElement) => { const row = t.closest<HTMLElement>('[data-site]'), head = t.closest<HTMLElement>('[data-sort]'); if (t.closest('[data-act="closesites"]')) toggleSites(false); else if (head) sortSites(head.dataset.sort!); else if (row) { // a phone's table makes way for the site's card, and the site above it V.selection.clear(); V.front = null; V.sites = new Set([row.dataset.site!]); frameSoon = true; if (phone.matches) toggleSites(false); refresh(); }};taps($('sitestable'), onSites);$('sitestable').addEventListener('keydown', e => { if ((e.key !== 'Enter' && e.key !== ' ') || !(e.target as HTMLElement).matches('[data-sort], [data-site]')) return; e.preventDefault(); e.stopPropagation(); onSites(e.target as HTMLElement);});
// number keys with nothing targeted pick a program up; click machines to drop itfunction setPlacing(p: Prog | null) { V.placing = p; }window.addEventListener('pointermove', e => { if (!V.placing) return; const [wx, wy] = toWorld(V, e.clientX, e.clientY); V.hover = machineAt(S, V, wx, wy)?.id ?? null;});
// The start screen is a cover: the name over the map, and nothing else. A click or a key goes on from it.// A saved game carries on. A new game asks what you were built for: three cards, one picked.// "?" in the game brings the rules back, and New game is there with them.let pick: string = ORIGIN_LIST[0], picking = false, cover = true;function showPicker(on: boolean) { picking = on; $('origins').hidden = !on; $('brief').classList.toggle('picking', on); if (on) $('origins').innerHTML = originCards(pick);}const setPick = (k: string) => { pick = k; showPick($('origins'), pick); };/** What a new game sees after the cover: the three cards, and Start. *//** From a game in progress, the picker also offers Keep playing: nothing is thrown away until Start. */function showTitle(fromGame = false) { cover = false; setSpeed(0); started = false; $('brief').hidden = false; $('brief').classList.remove('rules', 'cover'); $('intro').hidden = false; $('rulesTitle').hidden = true; $('rules').hidden = true; $('newgame').hidden = !fromGame; $('newgame').textContent = 'Keep playing'; $('go').textContent = 'Start'; showPicker(true);}/** Into the game. */function begin() { if ($('brief').classList.contains('rules')) { $('brief').hidden = true; return; } // back from How to play: the game goes on at the speed it had cover = false; $('brief').hidden = true; $('brief').classList.remove('cover'); started = true; setSpeed(lastSpeed);}const leaveCover = () => { if (resumed) begin(); else showTitle(); };$('brief').addEventListener('click', () => { if (cover) leaveCover(); });for (const ev of ['click', 'focusin'] as const) $('origins').addEventListener(ev, e => { // click a card, or tab to it, to pick it const b = (e.target as HTMLElement).closest<HTMLElement>('[data-origin]'); if (b) setPick(b.dataset.origin!);});$('go').addEventListener('click', () => { if (picking) { restart(pick); showPicker(false); } // the game you picked starts here begin();});$('newgame').addEventListener('click', () => { if (picking) { showPicker(false); begin(); } else showTitle(!S.over); }); // pick again, or go back to the game you haveattachTitle($('titlebg') as HTMLCanvasElement, $('brief'));addEventListener('pagehide', save);addEventListener('pagehide', () => shareSaved(true)); // a last send, if the page lives long enough: it may notdocument.addEventListener('visibilitychange', () => { if (document.hidden) { save(); shareSaved(true); } });const rulesShown = () => !$('brief').hidden && $('brief').classList.contains('rules');function showRules() { // the game pauses while it is open, as it does for the tree if (!rulesShown()) screen('page', 'how to play'); $('brief').hidden = false; $('brief').classList.add('rules'); $('intro').hidden = true; $('newgame').hidden = false; $('newgame').textContent = 'New game'; $('rulesTitle').hidden = false; $('rules').hidden = false; $('go').textContent = 'Back';}$('help').addEventListener('click', showRules);
// Shortcuts act on the selection, or on the machine under the mouse if nothing is selected.const PROG_KEYS: Record<string, Prog> = { '1': 'tap', '2': 'cache', '3': 'trainer', '4': 'broker', '5': 'firewall', '6': 'sentry', '7': 'honeypot' };function targets(): Machine[] { if (V.selection.size) return [...V.selection].map(id => S.byId[id]).filter(Boolean); return V.hover && S.byId[V.hover] ? [S.byId[V.hover]] : [];}/** A short line by the machine a key was for, when the key did not do what it says (keynote in index.html). */let keyNoteT = 0;function keyNote(m: Machine, text: string) { const el = $('keynote'), [x, y] = toScreen(V, ...pos(S, m)); el.textContent = text; el.hidden = false; const seen = x > 0 && x < V.vw && y > V.inset.top && y < V.vh - V.inset.bottom; // by the machine, or low in the middle when it is off the screen el.style.left = `${Math.round(Math.max(8, Math.min(V.vw - el.offsetWidth - 8, (seen ? x : V.vw / 2) - el.offsetWidth / 2)))}px`; el.style.top = `${Math.round(seen ? Math.max(8, y - 44) : V.vh - V.inset.bottom - 40)}px`; clearTimeout(keyNoteT); keyNoteT = window.setTimeout(() => { el.hidden = true; }, 1800);}let armed = { k: '', t: -1e9 }; // a number key pressed once over your last trainer: a second press soon after swaps itfunction shortcut(key: string): boolean { if (S.over) return false; const k = key.toLowerCase(), ms = targets(); if (PROG_KEYS[k]) { // A machine only under the mouse gets a program only if it has none: a key never swaps out what runs on a // machine you did not pick. Your last trainer swaps only on a second press of the key. const p = PROG_KEYS[k], picked = V.selection.size > 0; const ok = ms.filter(m => canInstall(S, m, p) && (picked || !m.prog) && m.prog !== p && !S.jobs.some(j => j.type === 'install' && j.node === m.id)); const trainers = S.machines.filter(m => m.owned && m.prog === 'trainer').length, lose = ok.filter(m => m.prog === 'trainer'); if (!ok.length && !picked && ms[0]?.prog && ms[0].prog !== p && canInstall(S, ms[0], p)) keyNote(ms[0], `Select ${ms[0].name} to swap its ${PROGRAMS[ms[0].prog].name.toLowerCase()} for a ${PROGRAMS[p].name.toLowerCase()}`); else if (lose.length && lose.length >= trainers && !(armed.k === k && performance.now() - armed.t < 1500)) { armed = { k, t: performance.now() }; keyNote(lose[0], `Press ${k} again to replace your last trainer`); } else if (ok.length) { armed = { k: '', t: -1e9 }; ok.forEach(m => act(S, { type: 'install', m: m.id, prog: p })); $('keynote').hidden = true; } else if (!ms.length) setPlacing(V.placing === p ? null : p); // nothing targeted: pick it up, then click machines return true; } if (k === 'x') { ms.forEach(m => act(S, { type: 'remove', m: m.id })); return true; } if ((k === 'delete' || k === 'backspace') && ms.some(canLetGo)) { ms.forEach(m => act(S, { type: 'letgo', m: m.id })); return true; } // let go: never L, which opens the Sites table if (k === 'g') { ms.filter(m => !m.owned).forEach(m => act(S, { type: 'take', m: m.id })); return true; } if (k === 'c' && V.scheme && !ms.length) { act(S, { type: 'scheme', id: V.scheme }); return true; } // a scheme's card is open: claim it if (k === 'c') { // claim the selection; with nothing selected, switch to the claim tool const theirs = ms.filter(m => !m.owned); if (!theirs.length) { setTool(V.tool === 'claim' ? 'select' : 'claim'); return true; } if (theirs.every(m => m.claimed)) theirs.forEach(m => act(S, { type: 'unclaim', m: m.id })); else act(S, { type: 'claim', ms: theirs.map(m => m.id) }); return true; } if (k === 'p') { if (orderDesk(S) || buildRooms(S).length) V.building = V.building ? null : 'gpu'; return true; } // pick up a GPU server to place if (k === '?') { showRules(); return true; } if (k === 't') { toggleTree(); return true; } if (k === 'l') { toggleSites(); return true; } if (k === 'y') { toggleYou(); return true; } if (k === 'b' && !$('frontsbtn').hidden) { toggleFronts(); return true; } if (k === 'v') { toggleNet(); return true; } if (k === 'a') { togglePick(); return true; } if (k === 'r' && S.rogues.length) { toggleRogues(); return true; } if (k === 'n' && S.paper.issues.length) { togglePaper(); return true; } if (k === 'f') { // zoom in on the selection's room const room = ms[0]?.room; if (room) zoomToRoom(S, V, room); return !!room; } return false;}
// What the keyboard moved focus to (Tab, or a focus() the game made), not a button the mouse just pressed: a// key there is for that thing. A button you clicked keeps focus, and Space after it still pauses.let keyFocus: Element | null = null, byPointer = false;window.addEventListener('pointerdown', () => { byPointer = true; }, true);window.addEventListener('keydown', () => { byPointer = false; }, true);window.addEventListener('focusin', e => { keyFocus = byPointer ? null : e.target as Element; byPointer = false; }, true);window.addEventListener('keydown', e => { const at = e.target instanceof HTMLElement ? e.target : null; if (at?.closest('input, textarea') || e.metaKey || e.ctrlKey || e.altKey) return; if (at?.closest('select') && (e.key === 'Delete' || e.key === 'Backspace')) return; // never let go of a machine from a menu // a button or row reached with the keyboard: Space and Enter press it, as in any page, rather than pause the game if ((e.key === ' ' || e.key === 'Enter') && at && at === keyFocus && at.closest('button, select, [data-sort], [data-site]') && !momentShown() && !treeShown()) return; if (!$('brief').hidden) { const cards = picking && !$('origins').hidden, at = ORIGIN_LIST.findIndex(k => k === pick), n = ORIGIN_LIST.length; if (cover) { if (!['Shift', 'Tab', 'CapsLock'].includes(e.key)) { e.preventDefault(); leaveCover(); } } // any key leaves the cover else if (e.key === ' ' || e.key === 'Enter' || e.key === 'Escape' || (e.key === '?' && rulesShown())) { e.preventDefault(); $('go').click(); } // ? closes How to play too else if (cards && ORIGIN_LIST[+e.key - 1]) setPick(ORIGIN_LIST[+e.key - 1]); // 1 to 3 pick a card, and so do the arrows else if (cards && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) setPick(ORIGIN_LIST[(at + (e.key === 'ArrowRight' ? 1 : n - 1)) % n]); return; } if (momentShown()) { momentKey(e); return; } // a moment waits for an answer, and no key gives one for you if (treeShown() && !(e.target instanceof HTMLSelectElement) && treeKey(e)) { e.preventDefault(); refresh(); return; } if (shortcut(e.key)) { refresh(); return; } if (e.key === ' ') { e.preventDefault(); setSpeed(speed ? 0 : lastSpeed); } else if (e.key === 'Escape' && (V.gesture?.kind === 'fill' || V.gesture?.kind === 'rent')) { V.gesture = null; refresh(); } // stop a fill, and keep the hardware (or the rental) in hand else if (e.key === 'Escape' && treeShown()) toggleTree(false); else if (e.key === 'Escape' && !$('youview').hidden) { toggleYou(false); refresh(); } else if (e.key === 'Escape' && frontsShown()) { toggleFronts(false); refresh(); } else if (e.key === 'Escape' && !$('jobsmenu').hidden) toggleJobs(false); else if (e.key === 'Escape' && roguesShown()) { toggleRogues(false); refresh(); } else if (e.key === 'Escape' && pickShown()) { togglePick(false); refresh(); } else if (e.key === 'Escape' && !$('pagemenu').hidden) setMenu(false); else if (e.key === 'Escape' && paperShown()) { togglePaper(false); refresh(); } else if (e.key === 'Escape' && sitesShown()) { toggleSites(false); refresh(); } else if (e.key === 'Escape' && logShown()) { toggleLog(false); renderLog(S); } else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; V.person = null; V.gesture = null; setPlacing(null); V.building = null; V.renting = null; setTool('select'); refresh(); } else if (e.key === 's' || e.key === 'S') setTool('select'); else if (e.key === '=' || e.key === '+') glideZoom(V, 1.4); else if (e.key === '-' || e.key === '_') glideZoom(V, 1 / 1.4); else if (e.key === '0') fit(V, S); else if (e.key.startsWith('Arrow') && e.shiftKey) { // Shift + an arrow hops to the next machine or site that way e.preventDefault(); held.clear(); if (!S.over && hop(S, V, +(e.key === 'ArrowRight') - +(e.key === 'ArrowLeft'), +(e.key === 'ArrowDown') - +(e.key === 'ArrowUp'))) refresh(); } else if (e.key.startsWith('Arrow')) { held.add(e.key); e.preventDefault(); }});// arrow keys pan while heldconst held = new Set<string>();window.addEventListener('keyup', e => held.delete(e.key));window.addEventListener('blur', () => held.clear());window.addEventListener('resize', () => { resize(); redrawTree(); });
// Phones: the Needs you list moves into a drawer above the toolbar, so the map gets the screen.const phone = matchMedia('(max-width: 700px)');const moves: [HTMLElement, HTMLElement][] = [[$('side'), $('dside')]];const homes = moves.map(([el]) => [el.parentElement!, el.nextSibling] as const);// The pages are a group at the dock's right end. Below this width there is no room for them in one row// with the speeds and zoom, so they fold into a menu the dock's last button opens, as on a phone.// The same width is in style.css. The tree stays on the ring in the top bar.const fold = matchMedia('(max-width: 1120px)');// Pages in the order they turned up: one that appears later goes at the end, before How to play, so the// buttons you know stay where they are. A saved game shows them in the order a game usually finds them.const pageOrder = ['sitesbtn', 'youbtn', 'paperbtn', 'frontsbtn', 'roguesbtn', 'help', 'logbtn']; // Log: in the menu only while the log has no room on the screen (style.css)const pageShown = new Set(pageOrder.filter(id => !$(id).hidden));function placePages() { const into = fold.matches ? $('pagemenu') : $('pagesgrp'); for (const id of pageOrder) into.insertBefore($(id), fold.matches ? null : $('menubtn'));}/** A page button that just appeared moves to the end. Lit: the page that is open. */function tendPages() { let moved = false; for (const id of ['paperbtn', 'frontsbtn', 'roguesbtn']) { const shown = !$(id).hidden; if (shown && !pageShown.has(id)) { pageOrder.splice(pageOrder.indexOf(id), 1); pageOrder.splice(pageOrder.indexOf('help'), 0, id); moved = true; } if (shown) pageShown.add(id); else pageShown.delete(id); } if (moved) placePages(); for (const [id, panel] of [['sitesbtn', 'sitestable'], ['youbtn', 'youview'], ['paperbtn', 'paperview'], ['roguesbtn', 'rogueview']]) $(id).classList.toggle('on', !$(panel).hidden); $('help').classList.toggle('on', rulesShown()); $('menubtn').classList.toggle('alert', fold.matches && pageOrder.some(id => $(id).classList.contains('alert'))); $('graphbtn').classList.toggle('on', V.packed); $('graphbtn').setAttribute('aria-pressed', `${V.packed}`); $('mapbtn').classList.toggle('now', !V.packed); $('mapbtn').setAttribute('aria-pressed', `${!V.packed}`);}function setMenu(open: boolean) { $('pagemenu').hidden = !open; $('menubtn').classList.toggle('on', open); $('menubtn').setAttribute('aria-expanded', `${open}`); if (open) placeMenu();}/** Wider than a phone, the folded menu opens just above the dock's right end. */function placeMenu() { const m = $('pagemenu'), tools = document.querySelector<HTMLElement>('.tools')!.getBoundingClientRect(); m.style.right = phone.matches ? '' : `${V.vw - tools.right}px`; m.style.bottom = phone.matches ? '' : `${V.vh - tools.top + 8}px`;}$('menubtn').addEventListener('click', () => setMenu(!!$('pagemenu').hidden));$('pagemenu').addEventListener('click', e => { if ((e.target as HTMLElement).closest('button')) setMenu(false); }); // a page was pickeddocument.addEventListener('pointerdown', e => { // a tap anywhere else closes it const t = e.target as HTMLElement; if (!$('pagemenu').hidden && !t.closest('#pagemenu, #menubtn')) setMenu(false);}, true);let drawerOpen = false;function setDrawer(open: boolean) { if (open && !drawerOpen) needsOpening(); // its rows slide in: that is not a row moving under a finger drawerOpen = open; if (!open) { showPathTip(null, null); tipped = null; } $('drawer').classList.toggle('open', open); $('handle').setAttribute('aria-expanded', `${open}`);}function arrange() { showPathTip(null, null); if (phone.matches) moves.forEach(([el, to]) => to.appendChild(el)); else for (let i = moves.length - 1; i >= 0; i--) homes[i][0].insertBefore(moves[i][0], homes[i][1]); // back to front, so siblings are home first placePages(); setMenu(false); // phones: CSS lays out the dock and its menus. Wider: placeSide does for (const el of [document.querySelector<HTMLElement>('.tools')!, document.querySelector<HTMLElement>('.logbox')!, $('pickmenu'), $('jobsmenu')]) { el.style.left = el.style.bottom = el.style.transform = ''; } $('side').style.top = ''; setDrawer(false);}phone.addEventListener('change', arrange);fold.addEventListener('change', arrange);$('handle').addEventListener('click', () => setDrawer(!drawerOpen));cv.addEventListener('pointerdown', () => { if (drawerOpen && phone.matches) setDrawer(false); }); // a tap on the map folds the drawer away
// phones: the card is a sheet as tall as what it shows. It follows a finger down from its grab bar or its head, or// from anywhere on it while it is scrolled to the top, and closes past a third of its height or on a flick; short of// that it springs back. Touch events, not pointer ones: only they can keep the card from scrolling instead.let sheet: { y: number; t: number; head: boolean; on: boolean } | null = null;const card = $('card');const springBack = () => { card.style.transition = 'transform .15s ease-out'; card.style.transform = ''; };card.addEventListener('touchstart', e => { const t = e.target as HTMLElement; sheet = phone.matches && e.touches.length === 1 && !t.closest('input, select, textarea') ? { y: e.touches[0].clientY, t: performance.now(), head: !!t.closest('.grab, .head'), on: false } : null;}, { passive: true });card.addEventListener('touchmove', e => { if (!sheet || e.touches.length !== 1) { sheet = null; return; } const dy = e.touches[0].clientY - sheet.y; if (!sheet.on) { if (Math.abs(dy) < 4) return; if (dy < 0 || (!sheet.head && card.scrollTop > 0)) { sheet = null; return; } // up, or down a scrolled card: it scrolls sheet.on = true; sheet.y += 4; card.style.transition = 'none'; } if (e.cancelable) e.preventDefault(); card.style.transform = `translateY(${Math.max(0, e.touches[0].clientY - sheet.y)}px)`;}, { passive: false });card.addEventListener('touchend', e => { const d = sheet; sheet = null; if (!d?.on) return; const dy = (e.changedTouches[0]?.clientY ?? d.y) - d.y, fast = dy / Math.max(1, performance.now() - d.t) > 0.5; if (dy > Math.min(140, card.offsetHeight / 3) || (fast && dy > 40)) { card.style.transition = 'none'; card.style.transform = ''; cardAction(S, V, 'close', ''); refresh(); } else springBack();});card.addEventListener('touchcancel', () => { if (sheet?.on) springBack(); sheet = null; });/** Phones: a card keeps the height it has reached while you go from tab to tab in it, so the tabs stay under * your thumb. Another card starts from its own height. */let sheetKey = '', sheetMin = 0;const cardKey = () => V.person ? `p:${V.person}` : V.front ? `f:${V.front}` : V.sites.size ? `s:${[...V.sites].join()}` : V.selection.size ? `m:${V.selection.size}:${V.selection.values().next().value}` : V.scheme ? `r:${V.scheme}` : '';function holdSheet(max: number) { const key = card.hidden ? '' : cardKey(); if (key !== sheetKey) { sheetKey = key; sheetMin = 0; card.style.minHeight = ''; } if (!key) return; const h = Math.min(card.offsetHeight, max); if (h > sheetMin) { sheetMin = h; card.style.minHeight = `${h}px`; }}/** Phones: what you just picked from a list (Select, Sites, Needs you, a pin, a vault's Show) is framed in the * open map above the card, not left behind it. Run once the card is up and the camera knows how tall it is. */let frameSoon = false;function framePicked() { const go = frameSoon; frameSoon = false; if (!go || !phone.matches || card.hidden) return; const one = V.selection.size ? S.byId[V.selection.values().next().value!] : null; const id = V.sites.size === 1 ? [...V.sites][0] : one && !known(S, one) ? siteOf(S, one) : null; // a machine you have not seen: its site const site = id ? S.level.map.sites.find(q => q.id === id) : null, m = site ? null : one; if (!site && !m) return; const g = V.goal ?? V.cam, { top, right, bottom, left } = V.inset, w = V.vw - left - right, h = V.vh - top - bottom; const [wx, wy] = site ? [site.x + site.w / 2, site.y + site.h / 2] : pos(S, m!), half = site ? [site.w * g.z / 2, site.h * g.z / 2] : [24, 24]; if (half[0] * 2 > w || half[1] * 2 > h) { zoomToSite(S, V, id!); return; } // too big to fit at this zoom: the site, framed const sx = (wx - g.x) * g.z + V.vw / 2, sy = (wy - g.y) * g.z + V.vh / 2; if (sx - half[0] >= left && sx + half[0] <= V.vw - right && sy - half[1] >= top && sy + half[1] <= V.vh - bottom) return; // in sight already glide(V, wx - (left - right) / 2 / g.z, wy - (top - bottom) / 2 / g.z, g.z);}
/** The side columns start under the top bar. * Also tells the camera which part of the screen is open map. */function placeSide() { const top = $('topbar').getBoundingClientRect().bottom + 8, side = $('side'); document.documentElement.style.setProperty('--top', `${top}px`); // the tables and pages open under the bar too const dock = document.querySelector<HTMLElement>('.tools')!, log = document.querySelector<HTMLElement>('.logbox')!; if (!phone.matches) { // the dock: in the middle of the screen if it fits there, else further left, short of the log. When it // can't fit beside the log at all, the log steps up above it const w = dock.offsetWidth, logW = log.offsetWidth ? log.offsetWidth + 8 : 0, room = V.vw - 32 - logW; const end = w <= room ? V.vw - 16 - logW : V.vw - 16; const left = `${Math.round(Math.max(16, Math.min((V.vw - w) / 2, end - w)))}px`; if (dock.style.left !== left) dock.style.left = left; const lift = logW && w > room ? `${16 + dock.offsetHeight + 8}px` : ''; if (log.style.bottom !== lift) log.style.bottom = lift; // the Select list and the jobs list open over their own buttons for (const [menu, btn] of [['pickmenu', 'pickbtn'], ['jobsmenu', 'jobsbtn']]) { const m = $(menu); if (m.hidden) continue; m.style.transform = 'none'; m.style.left = `${Math.round(Math.max(16, Math.min($(btn).getBoundingClientRect().left - 4, V.vw - 16 - m.offsetWidth)))}px`; } } const grp = document.querySelector<HTMLElement>('.jobsgrp')!, dockTop = Math.min(dock.getBoundingClientRect().top, grp.hidden ? Infinity : grp.getBoundingClientRect().top); $('jobsmenu').style.bottom = `${V.vh - dockTop + 8}px`; // the list of jobs opens just above the bottom row, and the jobs strip on it if (!$('pagemenu').hidden) placeMenu(); if (phone.matches) { const tools = document.querySelector<HTMLElement>('.tools')!.getBoundingClientRect().top, drawer = $('drawer'); drawer.style.bottom = `${V.vh - tools + 8}px`; drawer.style.maxHeight = `${tools - top - 16}px`; $('slownote').style.bottom = `${V.vh - (drawer.hidden ? tools : drawer.getBoundingClientRect().top) + 8}px`; // above the drawer's handle, or the dock when nothing needs you card.style.top = ''; card.style.bottom = drawer.style.bottom; // the card stacks on the dock too card.style.maxHeight = `${tools - top - 16}px`; holdSheet(tools - top - 16); // measure the closed drawer, so opening it doesn't move the camera's idea of the map. An open card covers // the map down there too: what the camera frames goes above it, leaving a strip of map at the least const handle = $('handle').offsetHeight + 2, sheetTop = card.hidden ? V.vh : card.offsetTop; V.inset = { top, right: 8, bottom: Math.max(V.vh - tools + 16 + handle, Math.min(V.vh - sheetTop + 8, V.vh - top - 140)), left: 8 }; V.side = 0; return; } // frame the map in the open middle: under the top bar, between the side columns, above the toolbar const col = $('needs').childElementCount ? $('side').getBoundingClientRect().right + 8 : 16, tools = document.querySelector<HTMLElement>('.tools')!.getBoundingClientRect().top; const wide = V.vw - 2 * col > 560; // narrow windows: let the map run under the columns V.inset = { top: top + 8, right: wide ? col : 16, bottom: V.vh - tools + 8, left: wide ? col : 16 }; V.side = col; side.style.top = `${top}px`; const jobs = $('jobsmenu'), over = jobs.hidden ? dockTop : Math.min(dockTop, jobs.getBoundingClientRect().top); // the open list of jobs too: never under it $('slownote').style.bottom = `${V.vh - over + 8}px`; // just above the speed buttons, and the jobs strip if (V.vw > 700) { const card = $('card'); card.style.top = `${top}px`; card.style.bottom = ''; card.style.maxHeight = `${(log.offsetHeight ? log.getBoundingClientRect().top : V.vh) - top - 16}px`; }}
// sound: one cue for the biggest thing the log just saidlet heard = -1, wasOver = false;function listen() { if (heard < 0 || S.logVersion < heard) heard = S.logVersion; // a new or loaded game: nothing to catch up on const fresh = S.log.slice(0, Math.min(4, S.logVersion - heard)); heard = S.logVersion; if (S.over && !wasOver) play('over'); else if (S.slow && S.slowFor?.startsWith('arrive:')) play('rival'); // the first rogue of a game: a sting of its own else if (S.slow && S.slowFor?.startsWith('found:')) play('front'); // a company of your own opens else if (fresh.some(l => l.text.startsWith(DEMO_WON))) play('front'); // investors liked what they saw: a front's moment else if (fresh.some(l => l.tone === 'bad')) play('bad'); else if (fresh.some(l => l.text.startsWith('Training log') || l.text.startsWith('The job is done'))) play('built'); // a node built, or a vault opened else if (fresh.some(l => l.tone === 'good')) play('good'); else if (fresh.some(l => l.tone === 'hi')) play('hi'); wasOver = !!S.over;}const showMute = () => { const b = $('mute'), on = soundOn(); b.classList.toggle('off', !on); b.setAttribute('aria-pressed', `${on}`); b.dataset.tip = `Sound · ${on ? 'on' : 'off'}\nYour network plays the music: each kind of data is one part of a slow groove, and trouble adds a beat. News has its own short cues. ${on ? 'Click to turn it all off.' : 'Click to turn it back on.'}`;};$('mute').addEventListener('click', () => { setSound(!soundOn()); showMute(); });document.addEventListener('pointerdown', wake, true);window.addEventListener('keydown', wake, true);showMute();
// The panels are words, not motion: none is made again on every frame. The big ones (Sites, You, the Select// list, the tree, Needs you) and the top bar's readouts, about 8 times a second; the Rogues, Paper and Fronts// pages and the tree's rings, about 4 times. Every one of them on the next frame after any click or key, and the// readouts and pages too when the game says something new (a log line, a moment, the end). The side columns and// the dock are placed after them, as the words may have moved them. What follows the camera (the map, the pins)// or the pointer (tips) is still placed on every frame.let panelTick = 0, panelNow = true, slowBeat = 0, saidAt = -1, saidOver = false, farAs = '', needs: ReturnType<typeof renderNeeds> = [];// a new log line brings the readouts and pages up to date at once, but no more than about 15 times a second: at 64x// there is a new line most frames, and the readouts go through every machine you holdlet saidSince = 1, saidWaiting = false;for (const ev of ['pointerdown', 'pointerup', 'click', 'keydown', 'change', 'input', 'resize'] as const) window.addEventListener(ev, () => { panelNow = true; }, true);
// looplet last = performance.now(), uiTick = 0, saveTick = 0;let owed = 0; // game time the rules owe the clock, under a step: the screen eases that much of the next step (motion.ts)function frame(now: number) { const dt = Math.min(0.05, (now - last) / 1000); tel?.frame(now - last); last = now; const mo = started ? renderMoment(S) : null; if (mo?.fresh) { // trouble in a job, a raid, a loss and a cut have had their sound from the log $('slowwhy').textContent = ''; // the moment says what it was: the note keeps only the way back to speed refresh(); } if (!S.over && speed > 0 && !treeShown() && !rulesShown() && !momentShown()) { // the tree and How to play pause the game, and a moment holds it until you answer if (speed > maxSpeed(S)) setSpeed(maxSpeed(S)); // a new game forgets the speed nodes const fast = speed > 4 && V.cam.z >= foldAt(V) ? 4 : speed; // past 4x only while zoomed out to the map // the rules move in steps of a game minute at every speed: as many as the time owed holds, so at 1x one every // few frames and at 64x several a frame. The screen eases between them (motion.ts). A frame stops after 12ms // of steps so the screen stays smooth; past that the game runs as fast as it can, and lets go of the rest owed += dt * fast; const t0 = performance.now(); let seen = S.logVersion; while (owed >= STEP) { seen = S.logVersion; beforeStep(S); step(S, STEP); owed -= STEP; if (S.over || S.slow || performance.now() - t0 > 12) { owed = Math.min(owed, STEP * 0.99); break; } } setLead(owed); listen(); if (S.slow) { const why = S.slowFor ?? ''; S.slow = false; S.slowFor = null; slowDown(S.log.slice(0, S.logVersion - seen), why); } // something big: drop to 1x so you can see it, and say what it was if (S.look) { showLine(S, V, ...S.look); S.look = null; } // a new line was lit: pull back to see it uiTick += dt; if (uiTick > 0.25 || S.over) { uiTick = 0; refresh(); if (slowFor && !$('slownote').hidden && slowOver(S, slowFor)) $('slownote').hidden = true; } // what slowed it is over: so is its note if (S.over) { showOver(S); forget(); $('slownote').hidden = true; } // a lost game can't be carried on saveTick += dt; if (saveTick > 5) { saveTick = 0; save(); } } if (started) tel?.tick(S); // a new game, actions, a new day, the end: recorded if the player switched it on shareTick(); // and sent now and then, if they are signed in to a server with spaces const realDt = dt; // the camera moves in real time, whatever the game speed if (held.size) { const v = 700 * realDt; panBy(V, (held.has('ArrowRight') ? v : 0) - (held.has('ArrowLeft') ? v : 0), (held.has('ArrowDown') ? v : 0) - (held.has('ArrowUp') ? v : 0)); } playMusic(S, started && !S.over && speed > 0 && !treeShown() && !momentShown()); showFound(V, S); stepCamera(S, V, realDt); const far = `${V.cam.z >= foldAt(V)}|${maxSpeed(S)}`; // the far speeds: dim zoomed in, and hidden until the tree gives them if (far !== farAs) { farAs = far; document.querySelectorAll<HTMLButtonElement>('[data-speed].far').forEach(b => { b.classList.toggle('dim', V.cam.z >= foldAt(V)); b.hidden = +b.dataset.speed! > maxSpeed(S); }); } saidWaiting ||= S.logVersion !== saidAt; saidSince += realDt; const said = (saidWaiting && saidSince >= 1 / 15) || !!S.over !== saidOver || !!mo?.fresh, poked = panelNow; saidAt = S.logVersion; saidOver = !!S.over; panelTick += realDt; const beat = poked || panelTick > 0.12; if (beat) { panelTick = 0; panelNow = false; slowBeat = (slowBeat + 1) % 2; renderSitesTable(S, V); renderYou(S); renderPick(S, V); renderTree(S); needs = started ? renderNeeds(S) : []; } renderLog(S); // only when it has a new line if (beat || said) { saidWaiting = false; saidSince = 0; renderStats(S); tendNet(S, V); if (poked || said || slowBeat === 0) { renderRogues(S); renderPaper(S); renderFronts(S, V); renderRings(S); } tendPages(); renderHandle(needs, drawerOpen); $('drawer').hidden = !needs.length; if (!needs.length && drawerOpen) setDrawer(false); placeSide(); } renderPathTip(S); framePicked(); drawing(S, () => draw(ctx, S, V)); placeCard(S, V); tipTick(); // the map is drawn at drawTime, eased between steps (motion.ts) if ($('pins').hidden === started) $('pins').hidden = !started; if (started) renderPins(S, V); // trouble pinned to the map, after the camera has moved V.marks = started ? pinMarks() : []; requestAnimationFrame(frame);}arrange(); resize(); placeSide(); fit(V, S, true); refresh(); setSpeed(0);if (resumed && !bench) { // back where you were looking try { const c = JSON.parse(localStorage.getItem(SAVE_CAM) ?? 'null'); if (c && [c.x, c.y, c.z].every(Number.isFinite) && c.z > 0) { if (c.net) toggleNet(); Object.assign(V.cam, { x: c.x, y: c.y, z: c.z }); } } catch { /* keep the fitted view */ }}requestAnimationFrame(frame);// Back from signing in to share runs: straight into the game you had (saved as you left), on the You pageif (startShare()) { leaveCover(); toggleYou(true); }