import { attachInput } from './input'; import { DEV, dropRuse, runRuse, setRuseAuto, cancelJob, canInstall, claim, closeTicket, installProg, newGame, saveGame, loadGame, setAuto, removeProg, letGo, setMode, setTarget, startVersionRun, pauseVersionRun, ascend, step, takeOver, unclaim, type Machine, type Mode, type Prog, type State, type Upgrade, type Ascension, orderDesk, buildRooms, maxSpeed } from './sim'; import { renderYou, toggleYou, renderSitesTable, sortSites, toggleSites, cardInput, renderFronts, setSliding, toggleGoals, toggleLog, renderGoals, cardAction, placeCard, renderCard, renderJobs, renderLog, renderStats, renderTarget, renderThreats, renderNews, renderTree, renderSchemes, renderHandle, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui'; import { attachTips } from './tip'; import type { Path } from './sim'; import { play, setSound, soundOn, wake } from './sound'; import { showLine, showFound, SITE_FOLD, draw, fit, glide, glideZoom, schemePos, machineAt, panBy, stepCamera, toWorld, zoomToRoom, type Tool, type View } from './view'; import { BUILDING } from './world'; 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, cursor: null, goal: null, inset: { top: 80, right: 120, bottom: 150, left: 120 }, scheme: 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) return; localStorage.setItem(SAVE, saveGame(S)); localStorage.setItem(SAVE_CAM, JSON.stringify(V.cam)); } catch { /* no room, or storage is off: play on without saving */ } } function forget() { try { localStorage.removeItem(SAVE); localStorage.removeItem(SAVE_CAM); } catch { /* nothing to forget */ } } const resumed = stored(); let started = false; // nothing is saved until you leave the start screen let S: State = resumed ?? newGame(BUILDING); let speed = 0; // paused behind the start screen let lastSpeed = 1; function resize() { V.dpr = window.devicePixelRatio || 1; 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; document.querySelectorAll('[data-speed]').forEach(b => b.classList.toggle('on', +b.dataset.speed! === v)); $('spd').textContent = `${lastSpeed}x`; $('spd').classList.toggle('on', v > 0); } /** 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 < SITE_FOLD && v <= maxSpeed(S))); setSpeed(speed ? ok[(ok.indexOf(speed) + 1) % ok.length] ?? 1 : lastSpeed); } function setTool(t: Tool) { V.tool = t; document.querySelectorAll('[data-tool]').forEach(b => b.classList.toggle('on', b.dataset.tool === t)); } function restart() { forget(); S = newGame(BUILDING); V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; V.scheme = null; resetUI(); showOver(S); fit(V, S, true); refresh(); setSpeed(lastSpeed); } attachInput(cv, () => S, V, refresh); attachTips(); if (import.meta.env.DEV) Object.assign(window, { game: () => ({ S, V, refresh }) }); // 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 acts: [string, string, () => void][] = [ ['+$1,000', 'Money for your fronts to spend.', () => DEV.money(S)], ['Unlocks', "Every front's tree node, a second front, and every game speed.", () => DEV.unlocks(S)], ['Take site', 'Every machine at the sites you have picked is yours. With none picked: the office.', () => DEV.take(S, V.sites.size ? [...V.sites] : ['office'])], ['Fill caches', 'Fill your caches with every kind of data.', () => DEV.data(S)], ['Next region', 'Finish the deal that opens the next region out of reach.', () => DEV.region(S)], ['+6 hours', 'Jump the clock on six hours. Only the hour changes.', () => DEV.hours(S, 6)], ['News', 'Announce a piece of news now.', () => DEV.news(S)], ['Offers', 'Give each front a contract offer now.', () => DEV.offers(S)], ['Rogues', 'Bring the rogues on: any that could turn up does, and each takes its next machine now.', () => DEV.rival(S)], ]; 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) { 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 const onCard = (e: Event) => { const b = (e.target as HTMLElement).closest('[data-act]'); if (!b || S.over) return; cardAction(S, V, b.dataset.act!, b.dataset.arg ?? ''); refresh(); }; $('card').addEventListener('pointerdown', onCard); // 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 top bar's fronts: open a front's card, or the card for founding one $('frontlist').addEventListener('click', e => { const b = (e.target as HTMLElement).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(); refresh(); }); $('card').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onCard(e); }); const onJobs = (e: Event) => { const b = (e.target as HTMLElement).closest('[data-cancel]'); if (b && !S.over) { cancelJob(S, +b.dataset.cancel!); refresh(); } }; $('jobs').addEventListener('pointerdown', onJobs); $('jobs').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onJobs(e); }); document.querySelectorAll('[data-speed]').forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.speed!))); document.querySelectorAll('[data-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool as Tool))); $('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', restart); // the capability tree: click a node to target it const onTree = (e: Event) => { const b = (e.target as HTMLElement).closest('[data-node], [data-act]'); if (!b || b.disabled || S.over) return; if (b.dataset.node) { setTarget(S, b.dataset.node as Upgrade); if (S.target === b.dataset.node) toggleTree(false); // picked: back to the map } else if (b.dataset.act === 'free') { setAuto(S, false); setTarget(S, null); } else if (b.dataset.act === 'auto') setAuto(S, !S.auto); else if (b.dataset.act === 'startversion') { startVersionRun(S, b.dataset.arg!); toggleTree(false); } else if (b.dataset.act === 'pauseversion') pauseVersionRun(S); else if (b.dataset.act === 'ascend') ascend(S, b.dataset.arg as Ascension); else if (b.dataset.act === 'closetree') toggleTree(false); else if (b.dataset.act === 'tab') setTreeTab(b.dataset.arg as Path); refresh(); }; $('tree').addEventListener('pointerdown', onTree); $('tree').addEventListener('change', e => { const s = e.target as HTMLSelectElement; if (s.dataset.act === 'versionsite') { pickVersionSite(s.value); refresh(); } }); $('tree').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onTree(e); }); const onThreats = (e: Event) => { const b = (e.target as HTMLElement).closest('[data-ticket]'); if (b && !S.over) { closeTicket(S, +b.dataset.ticket!); refresh(); } }; $('threats').addEventListener('pointerdown', onThreats); const onSchemes = (e: Event) => { const b = (e.target as HTMLElement).closest('[data-ruse], [data-drop], [data-show], [data-rauto]'); if (!b || S.over) return; if (b.dataset.ruse) runRuse(S, b.dataset.ruse); else if (b.dataset.drop) dropRuse(S, b.dataset.drop); else if (b.dataset.rauto !== undefined) setRuseAuto(S, !S.ruseAuto); else if (b.dataset.show) showScheme(b.dataset.show); refresh(); }; /** Pick a scheme from the list: its card opens and the map glides to its node. */ function showScheme(id: string) { const r = S.ruses.find(q => q.id === id), p = r && schemePos(S, V, r); V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = id; if (p) glide(V, p[0], p[1], Math.max(V.cam.z, 0.6)); } $('schemes').addEventListener('pointerdown', onSchemes); $('schemes').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onSchemes(e); }); $('threats').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onThreats(e); }); // 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); $('goals').addEventListener('click', e => { if ((e.target as HTMLElement).closest('.gohead')) { toggleGoals(); renderGoals(S); } }); $('log').addEventListener('click', e => { if ((e.target as HTMLElement).closest('.logmore')) { toggleLog(); renderLog(S); } }); $('target').addEventListener('click', () => toggleTree()); $('treebtn').addEventListener('click', () => toggleTree()); $('sitesbtn').addEventListener('click', () => toggleSites()); $('youbtn').addEventListener('click', () => { toggleYou(); refresh(); }); $('youview').addEventListener('pointerdown', e => { if ((e.target as HTMLElement).closest('[data-act="closeyou"]')) { toggleYou(false); refresh(); } }); // the sites table: click a row to pick that site, a column head to sort by it $('sitestable').addEventListener('pointerdown', e => { const t = e.target as HTMLElement, 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) { V.selection.clear(); V.front = null; V.sites = new Set([row.dataset.site!]); refresh(); } }); // 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; }); // start screen, and "?" to bring the rules back $('go').addEventListener('click', () => { $('brief').hidden = true; started = true; setSpeed(lastSpeed); }); if (resumed) { // a saved game: carry on, or throw it away and start again $('go').innerHTML = 'Continue Enter'; $('newgame').hidden = false; $('newgame').addEventListener('click', () => { restart(); $('newgame').hidden = true; $('go').click(); }); } addEventListener('pagehide', save); document.addEventListener('visibilitychange', () => { if (document.hidden) save(); }); function showRules() { setSpeed(0); $('brief').hidden = false; $('brief').classList.add('rules'); $('intro').hidden = true; $('howto').hidden = true; $('newgame').hidden = true; $('rulesTitle').hidden = false; $('rules').hidden = false; $('go').textContent = 'Back'; } $('help').addEventListener('click', showRules); $('howto').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': 'trader' }; const MODE_KEYS: Record = { b: 'blend', m: 'full', o: 'off' }; 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]] : []; } function shortcut(key: string): boolean { if (S.over) return false; const k = key.toLowerCase(), ms = targets(); if (PROG_KEYS[k]) { const p = PROG_KEYS[k], ok = ms.filter(m => canInstall(S, m, p)); if (ok.length) ok.forEach(m => installProg(S, m, p)); else if (!ms.length) setPlacing(V.placing === p ? null : p); // nothing targeted: pick it up, then click machines return true; } if (MODE_KEYS[k]) { ms.forEach(m => setMode(S, m, MODE_KEYS[k])); return true; } if (k === 'x') { ms.forEach(m => removeProg(S, m)); return true; } if (k === 'l') { ms.forEach(m => letGo(S, m)); return true; } if (k === 'g') { ms.filter(m => !m.owned).forEach(m => takeOver(S, m)); return true; } if (k === 'c' && V.scheme && !ms.length) { runRuse(S, 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(unclaim); else claim(S, 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 === 't') { toggleTree(); return true; } if (k === 'l') { toggleSites(); return true; } if (k === 'y') { toggleYou(); 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; } window.addEventListener('keydown', e => { if ((e.target instanceof HTMLElement && e.target.closest('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return; if (!$('brief').hidden) { if (e.key === ' ' || e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); $('go').click(); } return; } if (shortcut(e.key)) { refresh(); return; } if (e.key === ' ') { e.preventDefault(); setSpeed(speed ? 0 : lastSpeed); } else if (e.key === 'Escape' && treeShown()) toggleTree(false); else if (e.key === 'Escape' && !$('youview').hidden) { toggleYou(false); refresh(); } else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; V.gesture = null; setPlacing(null); V.building = null; 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')) { held.add(e.key); e.preventDefault(); } }); // 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(); }); // Phones: the target, threats and jobs move into one drawer above the toolbar, so the map gets the screen. const phone = matchMedia('(max-width: 700px)'); const moves: [HTMLElement, HTMLElement][] = [ [$('side'), $('dside')], [$('cap').closest('.stat')!, $('dstats')], [$('owned').closest('.stat')!, $('dstats')], [$('frontlist').closest('.stat')!, $('dstats')], [$('jobs').closest('.jobsbox')!, $('djobs')], ]; const homes = moves.map(([el]) => [el.parentElement!, el.nextSibling] as const); let drawerOpen = false; function setDrawer(open: boolean) { 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 $('side').style.top = ''; setDrawer(false); } phone.addEventListener('change', arrange); // Schemes, IT and News fold to their header line: click the header. On a narrow window they start folded, so the map stays in view. $('side').addEventListener('click', e => { const head = (e.target as HTMLElement).closest('.foldhead'); if (head) head.closest('.box')!.classList.toggle('folded'); }); if (innerWidth < 1200 && !phone.matches) for (const id of ['schemes', 'threats', 'news']) $(id).classList.add('folded'); $('handle').addEventListener('click', () => setDrawer(!drawerOpen)); /** The side columns start under the top bar, which wraps on narrow screens. * Also tells the camera which part of the screen is open map. */ function placeSide() { const top = $('topbar').getBoundingClientRect().bottom + 8, side = $('side'); 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`; const card = $('card'); card.style.top = ''; card.style.bottom = drawer.style.bottom; card.style.maxHeight = `${Math.min(0.55 * V.vh, tools - top - 16)}px`; // the card stacks on the dock too // measure the closed drawer, so opening it doesn't move the camera's idea of the map const handle = $('handle').offsetHeight + 2; V.inset = { top, right: 8, bottom: V.vh - tools + 16 + handle, left: 8 }; return; } // frame the map in the open middle: under the top bar, between the side columns, above the toolbar const col = $('side').getBoundingClientRect().right + 8, 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 : 16, bottom: V.vh - tools + 8, left: wide ? col : 16 }; side.style.top = `${top}px`; if (V.vw > 700) { const card = $('card'); card.style.top = `${top}px`; card.style.bottom = ''; card.style.maxHeight = `${V.vh - top - ($('log').closest('.logbox')!.offsetHeight || 0) - 32}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 = S.log.slice(0, Math.min(4, S.logVersion - heard)); heard = S.logVersion; if (S.over && !wasOver) play('over'); else if (fresh.some(l => l.tone === 'bad')) play('bad'); else if (fresh.some(l => l.text.startsWith('Training log'))) play('built'); 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'); b.classList.toggle('off', !soundOn()); b.setAttribute('aria-pressed', `${soundOn()}`); }; $('mute').addEventListener('click', () => { setSound(!soundOn()); showMute(); }); document.addEventListener('pointerdown', wake, true); window.addEventListener('keydown', wake, true); showMute(); // loop let last = performance.now(), uiTick = 0, saveTick = 0; function frame(now: number) { const dt = Math.min(0.05, (now - last) / 1000); last = now; if (!S.over && speed > 0 && !treeShown()) { // the tree pauses the game while you choose if (speed > maxSpeed(S)) setSpeed(maxSpeed(S)); // a new game forgets the speed nodes const fast = speed > 4 && V.cam.z >= SITE_FOLD ? 4 : speed; // past 4x only while zoomed out to the map // past 16x each step covers more time (up to 0.2s, which plays the same), and a frame stops // after 12ms of work so the screen stays smooth on a big network const steps = Math.min(fast, 16), each = dt * fast / steps, t0 = performance.now(); for (let i = 0; i < steps; i++) { step(S, each); if (S.over || S.slow || performance.now() - t0 > 12) break; } listen(); if (S.slow) { S.slow = false; if (speed > 1) setSpeed(1); } // something big: drop to 1x so you can see it 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 (S.over) { showOver(S); forget(); } // a lost game can't be carried on saveTick += dt; if (saveTick > 5) { saveTick = 0; save(); } } 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)); } showFound(V, S); stepCamera(S, V, realDt); document.querySelectorAll('[data-speed].far').forEach(b => { b.classList.toggle('dim', V.cam.z >= SITE_FOLD); b.hidden = +b.dataset.speed! > maxSpeed(S); }); renderStats(S); renderSitesTable(S, V); renderYou(S); renderFronts(S, V); renderGoals(S); renderLog(S); renderTarget(S); renderHandle(S, drawerOpen); renderTree(S); renderThreats(S); renderNews(S); renderSchemes(S); placeSide(); draw(ctx, S, V); placeCard(S, V); requestAnimationFrame(frame); } arrange(); resize(); renderHandle(S, false); placeSide(); fit(V, S, true); refresh(); setSpeed(0); if (resumed) { // 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) Object.assign(V.cam, { x: c.x, y: c.y, z: c.z }); } catch { /* keep the fitted view */ } } requestAnimationFrame(frame);