Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. cameron.tngl.io/misaligned
Something went wrong. Try again.
19 kB · 312 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313import { attachInput } from './input';import { dropRuse, runRuse, setRuseAuto, cancelJob, canInstall, claim, closeTicket, installProg, newGame, 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, renderTree, renderSchemes, renderHandle, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui';import { attachTips } from './tip';import type { Path } from './sim';import { 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,};let S: State = newGame(BUILDING);let speed = 0; // paused behind the start screenlet 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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-tool]').forEach(b => b.classList.toggle('on', b.dataset.tool === t));}function restart() { 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 }) }); // for poking at it from the console
// panels// pointerdown so the frequent card refresh can't swallow a click; click covers the keyboardconst onCard = (e: Event) => { const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[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<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(); refresh();});$('card').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onCard(e); });const onJobs = (e: Event) => { const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[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<HTMLButtonElement>('[data-speed]').forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.speed!)));document.querySelectorAll<HTMLButtonElement>('[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 itconst onTree = (e: Event) => { const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLElement>('[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 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);$('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<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) { 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 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;});
// start screen, and "?" to bring the rules back$('go').addEventListener('click', () => { $('brief').hidden = true; setSpeed(lastSpeed); });function showRules() { setSpeed(0); $('brief').hidden = false; $('brief').classList.add('rules'); $('intro').hidden = true; $('howto').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<string, Prog> = { '1': 'tap', '2': 'cache', '3': 'trainer', '4': 'broker', '5': 'trader' };const MODE_KEYS: Record<string, Mode> = { 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 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 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<HTMLElement>('.stat')!, $('dstats')], [$('owned').closest<HTMLElement>('.stat')!, $('dstats')], [$('frontlist').closest<HTMLElement>('.stat')!, $('dstats')], [$('jobs').closest<HTMLElement>('.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);$('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<HTMLElement>('.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<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 }; 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<HTMLElement>('.logbox')!.offsetHeight || 0) - 32}px`; }}
// looplet last = performance.now(), uiTick = 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; } if (S.slow) { S.slow = false; if (speed > 1) setSpeed(1); } // something big: drop to 1x so you can see it uiTick += dt; if (uiTick > 0.25 || S.over) { uiTick = 0; refresh(); } if (S.over) showOver(S); } 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)); } stepCamera(S, V, realDt); document.querySelectorAll<HTMLButtonElement>('[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); 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);requestAnimationFrame(frame);