import { attachInput } from './input'; import { type LogLine, logSince, 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, setLogKind, 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, youShown } from './ui'; import { onePage } from './layers'; import { signInReturn } from './atproto/scope'; 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 { hideAway, showAway, watchAway } from './ui-away'; 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'; import { operationChoice, operationClick } from './ui-operations'; 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 yours const 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 screen let 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('[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); labelSpeed(); // 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 over function 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. */ const speedAfter = () => { const ok = [1, 2, 4, 8, 16, 32, 64].filter(v => v <= 4 || (V.cam.z < foldAt(V) && v <= maxSpeed(S))); return speed ? ok[(ok.indexOf(speed) + 1) % ok.length] ?? 1 : lastSpeed; }; function nextSpeed() { setSpeed(speedAfter()); } /** Its face shows the speed it runs at; its name says that and what a tap does, as the next speed can depend on the zoom. */ function labelSpeed() { const label = speed ? `Speed ${speed}x, tap for ${speedAfter()}x` : `Paused at ${lastSpeed}x, tap to play`; if ($('spd').getAttribute('aria-label') !== label) $('spd').setAttribute('aria-label', label); } /** 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; hideAway(); } 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 console if (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' })], ['Operation', 'Prepare the Ironvault operation and open its window. Choose an approach on the You page.', () => act(S, { type: 'dev', name: 'operation' })], ['Studio', 'An Influencer studio opens now at the office and Bayview, with every creator it has room for signed.', () => act(S, { type: 'dev', name: 'studio' })], ['Consultancy', 'A Security consultancy opens now at the office and Bayview, with every client it has room for.', () => act(S, { type: 'dev', name: 'consultancy' })], ['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 = '
Dev
' + acts.map(([name, tip], i) => ``).join(''); box.addEventListener('click', e => { const b = (e.target as HTMLElement).closest('[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('[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('[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 drawer taps($('needs'), (t, e) => { const b = t.closest('[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('[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('[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 it const 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('[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[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('[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 from document.addEventListener('pointerover', e => { if (e.pointerType === 'touch') return; // touch has no hover: tap instead, below const el = (e.target as HTMLElement).closest('[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 close let tipped: HTMLElement | null = null; document.addEventListener('pointerdown', e => { if (e.pointerType !== 'touch') return; const el = (e.target as HTMLElement).closest('[data-path]'); tipped = el && el !== tipped ? el : null; showPathTip(tipped ? tipped.dataset.path as Path : null, tipped); }, true); $('log').addEventListener('click', e => { const k = (e.target as HTMLElement).closest('[data-logkind]'); if (k) { setLogKind(k.dataset.logkind!); renderLog(S); return; } 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 drawer for (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('[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('[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('[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('[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('[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 nothing taps($('youview'), t => { const operation = operationClick(S, t); if (operation) { if (operation.machine && S.byId[operation.machine]) { const m = S.byId[operation.machine]; V.selection = new Set([m.id]); V.sites.clear(); V.front = null; V.scheme = null; V.person = null; V.msg = null; if (known(S, m)) zoomToRoom(S, V, m.room); else zoomToSite(S, V, siteOf(S, m)); toggleYou(false); frameSoon = true; } else if (operation.person) { V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; V.msg = null; V.person = operation.person; toggleYou(false); } refresh(); return; } const act = t.closest('[data-act]')?.dataset.act, vault = t.closest('[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 }); $('youview').addEventListener('change', e => { if (e.target instanceof HTMLSelectElement) { operationChoice(S, e.target); refresh(); } }); // 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 site const onSites = (t: HTMLElement) => { const row = t.closest('[data-site]'), head = t.closest('[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 = 'site'; 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 it function 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(); showAway(S); } else showTitle(); }; // a saved game: where you left off $('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('[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 have attachTitle($('titlebg') as HTMLCanvasElement, $('brief')); addEventListener('pagehide', save); addEventListener('pagehide', () => shareSaved(true)); // a last send, if the page lives long enough: it may not document.addEventListener('visibilitychange', () => { if (document.hidden) { save(); shareSaved(true); } }); watchAway(() => started && !S.over ? S : null); // back after a long while away: where you left off 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 = { '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 it function 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') closeTop(); 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(); } }); /** Close what is on top, in this order: the tree, a page, a menu, the log, then the card and whatever is in hand. * Esc does it, and so does Back (which also closes How to play and the Needs you drawer, and stops at nothing: * with nothing open, Back leaves the game as on any page). */ function closeTop(back = false) { if (back && rulesShown()) { $('brief').hidden = true; return; } // as Back on How to play does: the game goes on if (treeShown()) toggleTree(false); else if (youShown()) toggleYou(false); else if (frontsShown()) toggleFronts(false); else if (!$('jobsmenu').hidden) toggleJobs(false); else if (roguesShown()) toggleRogues(false); else if (pickShown()) togglePick(false); else if (!$('pagemenu').hidden) setMenu(false); else if (paperShown()) togglePaper(false); else if (sitesShown()) toggleSites(false); else if (logShown()) toggleLog(false); else if (back && drawerOpen) setDrawer(false); else { 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(); } /** Is anything open that Back should close first? The card counts, and hardware or a program in hand. */ const layered = () => rulesShown() || treeShown() || youShown() || frontsShown() || !$('jobsmenu').hidden || roguesShown() || pickShown() || !$('pagemenu').hidden || paperShown() || sitesShown() || logShown() || (phone.matches && drawerOpen) || !$('card').hidden || !!V.placing || !!V.building || !!V.renting; // The phone's (or the browser's) Back closes the top layer rather than leave the game. While anything is open there is // one entry of ours on top of the page's own, at the same address (so ?dev, ?perf and the rest stay), never more: // Back takes it off and closes one layer, and if more is open the next frame puts one back. Once everything is closed // some other way, ours is taken off again, so the next Back leaves as it would have. // Chrome's Back skips an entry a page pushed before anyone touched it (its guard against pages that trap Back), so // ours waits for the first tap, click or key: back from a sign-in the You page is open with nothing touched yet. let backPushed = false, backGoing = false; const touched = () => (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }).userActivation?.hasBeenActive ?? true; function tendBack() { if (!started || S.over) { if (backPushed && !backGoing) { backGoing = true; history.back(); } return; } const open = layered(); if (open && !backPushed && !backGoing && touched() && !signInReturn(location.hash)) { history.pushState({ misaligned: 'layer' }, ''); backPushed = true; } // a sign-in coming back: its library tidies the address first else if (!open && backPushed && !backGoing) { backGoing = true; history.back(); } } addEventListener('popstate', e => { if (backGoing) { backGoing = false; backPushed = false; return; } // our own entry, taken off if ((e.state as { misaligned?: string } | null)?.misaligned === 'layer') { backPushed = true; return; } // Forward onto it again if (!backPushed) return; backPushed = false; closeTop(true); renderLog(S); panelNow = true; }); // arrow keys pan while held const held = new Set(); window.addEventListener('keyup', e => held.delete(e.key)); window.addEventListener('blur', () => held.clear()); window.addEventListener('resize', () => { resize(); redrawTree(); }); // iOS Safari moves its bars without always telling the window: the visual viewport says so, and the canvas and the sheet follow window.visualViewport?.addEventListener('resize', () => { resize(); panelNow = true; }); // 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('.tools')!.getBoundingClientRect(); m.style.right = phone.matches ? '' : `${V.vw - tools.right}px`; m.style.bottom = phone.matches ? '' : `${V.vh - tools.top + 8}px`; m.style.maxHeight = phone.matches ? '' : `${tools.top - 16}px`; // a short screen: it scrolls rather than run off the top } onePage('menu', () => { if (!$('pagemenu').hidden) setMenu(false); }); // a page that opens closes the menu too, but the menu opening closes no page $('menubtn').addEventListener('click', () => setMenu(!!$('pagemenu').hidden)); $('pagemenu').addEventListener('click', e => { if ((e.target as HTMLElement).closest('button')) setMenu(false); }); // a page was picked document.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('.tools')!, document.querySelector('.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 with two heights. It opens at about half the screen, or as tall as what it shows if // that is less, and scrolls inside. A finger on its grab bar or its head raises it to its full height, under the // top bar, as does a tap on the bar; another tap, or a drag down, lowers it to half. From half, a drag down (from // the bar or the head, or from anywhere while it is scrolled to the top) closes it past a third of its height or on // a flick, and short of that it springs back. Touch events, not pointer ones: only they can keep the card from // scrolling instead. /** The phone's sheet opens this share of the screen tall, at most. */ const SHEET_HALF = 0.5; let sheet: { y: number; t: number; head: boolean; grab: boolean; on: 'down' | 'up' | 'lower' | null; h: number } | null = null; const card = $('card'); /** The sheet's two heights, as placeSide last worked them out, and whether it is up at full. */ let sheetFull = false, sheetH = { half: 0, full: 0 }; const springBack = () => { card.style.transition = 'transform .15s ease-out'; card.style.transform = ''; }; /** Is there more to the card than the sheet shows? Only then does it go up. */ const sheetMore = () => card.scrollHeight > card.clientHeight + 1; /** Raise the sheet to full, or lower it to half. */ function setSheet(full: boolean) { sheetFull = full; card.style.transition = card.style.transform = ''; placeSide(); } 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'), grab: !!t.closest('.grab'), on: null, h: card.offsetHeight } : 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) { // up: the bar or the head raises a sheet with more to show; anywhere else, it scrolls if (!sheet.head || sheetFull || !sheetMore()) { sheet = null; return; } sheet.on = 'up'; } else { if (!sheet.head && card.scrollTop > 0) { sheet = null; return; } // down a scrolled card: it scrolls sheet.on = sheetFull ? 'lower' : 'down'; } sheet.y += Math.sign(dy) * 4; card.style.transition = 'none'; } if (e.cancelable) e.preventDefault(); const d = e.touches[0].clientY - sheet.y; if (sheet.on === 'down') card.style.transform = `translateY(${Math.max(0, d)}px)`; else { // its top edge follows the finger, between the two heights const h = Math.max(sheetH.half, Math.min(sheetH.full, sheet.h - d)); card.style.maxHeight = card.style.minHeight = `${h}px`; } }, { passive: false }); card.addEventListener('touchend', e => { const d = sheet; sheet = null; if (!d) return; if (!d.on) { // a tap on the bar: up, or down again if (d.grab && performance.now() - d.t < 400) setSheet(!sheetFull && sheetMore()); return; } const dy = (e.changedTouches[0]?.clientY ?? d.y) - d.y, fast = Math.abs(dy) / Math.max(1, performance.now() - d.t) > 0.5; if (d.on === 'up') { setSheet(-dy > 60 || fast); return; } if (d.on === 'lower') { setSheet(!(dy > 60 || fast)); return; } 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 === 'down') springBack(); else if (sheet?.on) setSheet(sheetFull); sheet = null; }); // a mouse in a narrow window: a click on the bar raises and lowers it too card.addEventListener('click', e => { if (phone.matches && (e as PointerEvent).pointerType !== 'touch' && (e.target as HTMLElement).closest('.grab')) setSheet(!sheetFull && sheetMore()); }); /** 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, and stays up if you raised it. Another card starts again at half. */ 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}` : ''; /** The sheet's height for this frame: `full` is all the room there is, under the top bar. */ function holdSheet(full: number) { const key = card.hidden ? '' : cardKey(); if (key !== sheetKey) { sheetKey = key; sheetMin = 0; sheetFull = false; card.style.minHeight = ''; } sheetH = { full, half: Math.min(full, Math.round(V.vh * SHEET_HALF)) }; if (sheet?.on === 'up' || sheet?.on === 'lower') return; // a finger has it const max = sheetFull ? sheetH.full : sheetH.half; card.style.maxHeight = `${max}px`; if (!key) return; sheetMin = Math.min(sheetMin, max); // lowered: no taller than half const was = sheetMin ? `${sheetMin}px` : ''; if (card.style.minHeight !== was) card.style.minHeight = was; 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: boolean | 'site' = false; // 'site': picked from the Sites table, so the site fills the open map, not a dot in it 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 || (go === 'site' && site)) { zoomToSite(S, V, id!); return; } // too big to fit at this zoom, or asked for by name: 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('.tools')!, log = document.querySelector('.logbox')!; const bar = $('topbar').getBoundingClientRect(), safeL = bar.left, safeR = V.vw - bar.right; // the top bar keeps clear of a notch: so does the map's frame 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; let end = w <= room ? V.vw - 16 - logW : V.vw - 16; // a short screen (a phone on its side): the card runs down to the dock's row, so the dock keeps left of it if it fits const cardLeft = card.hidden || V.vh > 520 ? V.vw : card.getBoundingClientRect().left; if (cardLeft - 8 - 16 >= w) end = Math.min(end, cardLeft - 8); 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('.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('.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 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: safeR, bottom: Math.max(V.vh - tools + 16 + handle, Math.min(V.vh - sheetTop + 8, V.vh - top - 140)), left: safeL }; 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('.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 : safeR, bottom: V.vh - tools + 8, left: wide ? col : safeL }; 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) { // down to the log, or the screen's foot; and short of the dock's row when the two would meet const d = dock.getBoundingClientRect(), c = card.getBoundingClientRect(), meets = !card.hidden && d.right > c.left && d.left < c.right; const foot = Math.min(log.offsetHeight ? log.getBoundingClientRect().top : V.vh, meets ? dockTop : V.vh); card.style.top = `${top}px`; card.style.bottom = ''; card.style.maxHeight = `${foot - top - (meets ? 8 : 16)}px`; } } // sound: one cue for the biggest thing the log just said let 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 = logSince(S, heard).slice(0, 4); 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 = []; // 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 hold let saidSince = 1, saidWaiting = false; for (const ev of ['pointerdown', 'pointerup', 'click', 'keydown', 'change', 'input', 'resize'] as const) window.addEventListener(ev, () => { panelNow = true; }, true); // loop let 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(logSince(S, 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('[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, V.front) : []; } 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); labelSpeed(); framePicked(); tendBack(); 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 page if (startShare()) { leaveCover(); toggleYou(true); }