Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
21 kB · 306 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307// Mouse, touch, and keyboard on the map.// drag from your machine to another take it over (or claim it for later)// drag on empty space box-select// Shift + drag (or the claim tool: C, Esc to stop) drag a box to claim machines// touch: hold still, then drag box-select; from your own machine, take over or link (a plain drag pans)// touch: hold still and lift read what is under the finger (what the mouse's hover says), and act on nothing// touch: tap a region's name pick its sites, and read which deal opens it// hardware picked from the order desk click an open spot in a room to order it there, or drag across the// room to fill an area (touch: hold still, then drag); Esc or right-click stops a fill// a cloud hall's rental in hand (Pick slots on its card) click a free slot to rent one, or drag across the hall// to rent every free slot covered (touch: hold still, then drag); Esc or right-click puts it down// right or middle drag, one finger move the map// two-finger scroll move the map; a mouse wheel, ctrl-wheel or pinch zooms// a site's name open the site's card; again to zoom to the site// the switch beside a site's name turn Take it all on or off for that site// the double line chip beside a site's name pay to double its lines out// the plan chip beside a site's name switch to its next plan: none, Gather, Store, Train// a scheme (green diamond) click to see its card; the Claim tool claims it// double-click or double-tap a machine claim it, as C does (again to take the claim off)import { buildRoomAt, canInstall, roomMachines, known, PLAN_LIST, type Plan, type State } from './sim';import { tipAt } from './tip';import { act, local } from './act';import { slotsIn } from './hall';import { doubleTip, planTip, regionTip, siteNameTip, takeAllTip } from './ui';import { foldedSites, machineAt, msgAt, pos, roomTabAt, stopAt, regionHit, regionSites, schemeAt, schemePos, shown, machineTip, mapTipAt, siteHit, siteKnown, siteNameAt, siteSays, switchAt, toMap, toScreen, toWorld, zoomAt, zoomToSite, type View } from './view';
/** The Plan chip goes from no plan through each plan in turn, then back to none. */const nextPlan = (p: Plan | undefined) => p ? PLAN_LIST[PLAN_LIST.indexOf(p) + 1] ?? null : PLAN_LIST[0];
type Said = { text: string; box: [number, number, number, number]; kind: 'machine' | 'switch' | 'name' | 'mark' | 'region' };/** What the map says about the spot at (wx, wy), as a tip: a machine's marks, a site's switch or chip or name, a mark * on the map, or a region's name. The mouse reads it by pointing; a finger by holding still. */function sayAt(S: State, V: View, wx: number, wy: number): Said | null { const scheme = !!schemeAt(S, V, wx, wy), m = machineAt(S, V, wx, wy); if (m) { const said = machineTip(S, m), [mx, my] = toScreen(V, ...pos(S, m)); return said ? { text: said, box: [mx - 20, my - 20, mx + 20, my + 20], kind: 'machine' } : null; } const sw = switchAt(V, wx, wy); // a site's Take all switch or Plan chip: say what it does if (sw) return { text: sw.kind === 'take' ? takeAllTip(S, [sw.id]) : sw.kind === 'double' ? doubleTip(S, sw.id) : planTip(S, sw.id), box: [sw.x0, sw.y0, sw.x1, sw.y1], kind: 'switch' }; const nm = scheme ? null : siteNameAt(V, wx, wy); // a site's name: its name and what it is for, then the numbers the map leaves off, then what a click does if (nm) { const [head, click] = siteNameTip(S, nm.id, V.sites.size === 1 && V.sites.has(nm.id)).split('\n'); return { text: [head, ...siteSays(S, nm.id), click].join('\n'), box: nm.box, kind: 'name' }; } const st = mapTipAt(V, wx, wy); // a mark on the map that says more: a storm, a trouble, a plate, a scheme if (st) return { text: st.text, box: st.box, kind: 'mark' }; const rg = scheme ? null : S.level.map.regions.find(r => r.name === regionHit(S, V, wx, wy)); // a region's name: is it in reach, and how to get there if (rg) { const [rx, ry] = toScreen(V, ...toMap(S, rg.at[0], rg.at[1])); return { text: regionTip(S, rg.name), box: [rx - 60, ry - 14, rx + 60, ry + 20], kind: 'region' }; } return null;}const tipFor = (t: Said | null, now = false) => tipAt(t?.text ?? null, t ? { left: t.box[0], top: t.box[1], right: t.box[2], bottom: t.box[3], width: t.box[2] - t.box[0] } : undefined, now);
export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, changed: () => void) { const pointers = new Map<number, [number, number]>(); let pan: null | { x: number; y: number; cx: number; cy: number } = null; let pinch: null | { d: number; z: number } = null; let start: null | { sx: number; sy: number; moved: boolean; touch: boolean; held?: boolean } = null; // held: a finger held still, then the gesture began let hold = 0; // a finger held still on empty map turns a pan into a box const HOLD_MS = 350; const stopHold = () => { clearTimeout(hold); hold = 0; }; let flip: null | { kind: string; id: string } = null; // a finger on a site's switch or chip: it flips on lift, unless it held let press: null | { room: string; x: number; y: number; filled?: boolean } = null; // hardware (or a rental) in hand, pressed on a room (or a hall): a click orders one, a drag fills /** The hall the pointer is in, with a rental in hand: its site, or null. */ const hallAt = (S: State, x: number, y: number) => { const id = V.renting, def = id ? S.level.map.sites.find(s => s.id === id) : null, b = def ? S.level.net.subnets[def.rooms[0]] : null; return b && x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h ? id : null; };
const world = (e: PointerEvent) => toWorld(V, e.clientX, e.clientY); let lastTap: { id: string; t: number } | null = null; // a second click on the same machine soon after is a claim const DOUBLE_MS = 350;
/** Click a machine to pick it. Click a site to see its card; click it again to zoom in. Shift adds sites. * Click a region's name to pick every site you know there. */ function clickAt(wx: number, wy: number, add = false) { const S = getS(); const msg = add ? null : msgAt(S, V, wx, wy); if (msg) { V.msg = msg.id; V.scheme = null; V.person = null; V.selection.clear(); V.sites.clear(); V.front = null; changed(); return; } const scheme = add ? null : schemeAt(S, V, wx, wy); V.scheme = scheme?.id ?? null; V.person = null; V.msg = null; if (scheme) { V.selection.clear(); V.sites.clear(); V.front = null; changed(); return; } const stop = stopAt(wx, wy); // a cross on the map: stop that takeover or job, or take the mark off that machine if (stop) { if (stop[0] === 'job') act(S, { type: 'cancel', job: +stop[1] }); else if (S.byId[stop[1]]) act(S, { type: 'unclaim', m: stop[1] }); changed(); return; } const tab = add ? null : roomTabAt(wx, wy); // a room's title tab: every machine you know in the room if (tab) { V.selection.clear(); V.sites.clear(); V.front = null; for (const x of roomMachines(S, tab)) if (known(S, x)) V.selection.add(x.id); changed(); return; } const named = siteNameAt(V, wx, wy)?.id ?? null; // a site's name button, even where it lies over a machine const m = named ? null : machineAt(S, V, wx, wy); const site = named ?? (m ? null : siteHit(S, V, wx, wy)), region = m || site ? null : regionHit(S, V, wx, wy); const again = !!site && !add && V.sites.size === 1 && V.sites.has(site); V.selection.clear(); V.front = null; if (!add || !site) V.sites.clear(); if (m) { V.selection.add(m.id); const now = performance.now(), twice = !add && lastTap?.id === m.id && now - lastTap.t < DOUBLE_MS; lastTap = twice ? null : { id: m.id, t: now }; if (twice && !m.owned) { if (m.claimed) act(S, { type: 'unclaim', m: m.id }); else act(S, { type: 'claim', ms: [m.id] }); } // the same as C on the selection } else if (again) zoomToSite(S, V, site!); else if (site) { if (add && V.sites.has(site)) V.sites.delete(site); else V.sites.add(site); } else if (region) V.sites = new Set(regionSites(S, region)); changed(); }
cv.addEventListener('contextmenu', e => e.preventDefault()); cv.addEventListener('pointerdown', e => { const S = getS(); try { cv.setPointerCapture(e.pointerId); } catch { /* synthetic or already-released pointer */ } pointers.set(e.pointerId, [e.clientX, e.clientY]); const touch = e.pointerType === 'touch'; if (pointers.size === 2) { const [[x1, y1], [x2, y2]] = [...pointers.values()]; pinch = { d: Math.hypot(x2 - x1, y2 - y1), z: V.cam.z }; pan = null; V.gesture = null; start = null; press = null; stopHold(); return; } press = null; start = { sx: e.clientX, sy: e.clientY, moved: false, touch }; const [wx, wy] = world(e); const beginPan = () => { pan = { x: e.clientX, y: e.clientY, cx: V.cam.x, cy: V.cam.y }; cv.classList.add('panning'); }; if (e.button === 2 && (V.placing || V.building || V.renting)) { V.placing = null; V.building = null; V.renting = null; changed(); return; } if (e.button === 1 || e.button === 2) { beginPan(); return; } const sw = e.button === 0 && !V.building && !V.placing && !V.renting ? switchAt(V, wx, wy) : null; if (sw && touch) { // a finger: it acts on lift, as a button does, and a hold reads its tip instead beginPan(); flip = sw; hold = window.setTimeout(() => { hold = 0; if (!pan || !start || start.moved || pointers.size !== 1) return; V.cam.x = pan.cx; V.cam.y = pan.cy; pan = null; cv.classList.remove('panning'); start.held = true; flip = null; tipFor(sayAt(getS(), V, wx, wy), true); navigator.vibrate?.(12); }, HOLD_MS); return; } if (sw) { toggleSwitch(sw); start = null; changed(); return; } // a site's Take all switch or Plan chip on the map if (V.building || V.renting) { // hardware in hand: click an open spot to order it there, or drag to fill an area; Shift keeps it in hand. A rental: the same, over a hall's slots const room = V.renting ? hallAt(S, wx, wy) : buildRoomAt(S, wx, wy); if (!room) { beginPan(); return; } press = { room, x: wx, y: wy }; if (touch) { // a finger: a plain drag still pans, and holding still first starts a fill beginPan(); hold = window.setTimeout(() => { hold = 0; if (!pan || !start || start.moved || pointers.size !== 1 || !press || !(V.building || V.renting)) return; V.cam.x = pan.cx; V.cam.y = pan.cy; pan = null; cv.classList.remove('panning'); V.gesture = V.renting ? { kind: 'rent', site: room, x0: wx, y0: wy, x1: wx, y1: wy } : { kind: 'fill', room, x0: wx, y0: wy, x1: wx, y1: wy }; press.filled = true; navigator.vibrate?.(12); changed(); }, HOLD_MS); } return; } if (V.placing) { const m = machineAt(S, V, wx, wy); if (m && canInstall(S, m, V.placing)) { act(S, { type: 'install', m: m.id, prog: V.placing }); if (!e.shiftKey) V.placing = null; changed(); } else beginPan(); return; } if (e.shiftKey && siteHit(S, V, wx, wy)) { clickAt(wx, wy, true); return; } if (V.tool === 'claim' || e.shiftKey) { V.gesture = { kind: 'box', x0: wx, y0: wy, x1: wx, y1: wy, claim: true }; return; } const m = schemeAt(S, V, wx, wy) ? null : machineAt(S, V, wx, wy); // a scheme sits over its machine's reach: it wins the click if (m?.owned && !touch) { V.gesture = { kind: 'link', from: m.id, x: wx, y: wy }; return; } if (touch) { // a plain drag pans, wherever it starts. Held still first: a line from your machine, or a box from anywhere else, and the tip of what is under the finger beginPan(); hold = window.setTimeout(() => { hold = 0; if (!pan || !start || start.moved || pointers.size !== 1) return; V.cam.x = pan.cx; V.cam.y = pan.cy; pan = null; cv.classList.remove('panning'); start.held = true; V.gesture = m?.owned ? { kind: 'link', from: m.id, x: wx, y: wy } : { kind: 'box', x0: wx, y0: wy, x1: wx, y1: wy }; tipFor(sayAt(getS(), V, wx, wy), true); navigator.vibrate?.(12); changed(); }, HOLD_MS); return; } V.gesture = { kind: 'box', x0: wx, y0: wy, x1: wx, y1: wy }; });
cv.addEventListener('pointermove', e => { const S = getS(); if (!pointers.has(e.pointerId)) { if (e.pointerType === 'touch') return; // a finger has no hover: it holds still to read (pointerdown) const [wx, wy] = world(e), m = machineAt(S, V, wx, wy); V.hover = m?.id ?? null; V.cursor = [wx, wy]; const scheme = !!schemeAt(S, V, wx, wy); cv.classList.toggle('over-mine', !!m?.owned && !scheme && V.tool === 'select'); const said = sayAt(S, V, wx, wy); tipFor(said); cv.classList.toggle('over-site', (scheme || said?.kind === 'name' || (!m && V.tool === 'select' && (!!siteHit(S, V, wx, wy) || said?.kind === 'switch' || said?.kind === 'region')))); return; } pointers.set(e.pointerId, [e.clientX, e.clientY]); if (pinch && pointers.size === 2) { const [[x1, y1], [x2, y2]] = [...pointers.values()]; zoomAt(V, (x1 + x2) / 2, (y1 + y2) / 2, (pinch.z * Math.hypot(x2 - x1, y2 - y1)) / pinch.d / V.cam.z); return; } if (start && Math.hypot(e.clientX - start.sx, e.clientY - start.sy) > 5) { if (start.held && !start.moved) tipAt(null); start.moved = true; if (pan) stopHold(); } // a held finger moving on: the tip goes, the gesture goes on if (pan) { V.goal = null; V.cam.x = pan.cx - (e.clientX - pan.x) / V.cam.z; V.cam.y = pan.cy - (e.clientY - pan.y) / V.cam.z; return; } const [wx, wy] = world(e); if (press && !press.filled && start?.moved && !V.gesture && e.pointerType !== 'touch' && (V.building || V.renting)) { // once: Esc or a right-click stops it for good press.filled = true; V.gesture = V.renting ? { kind: 'rent', site: press.room, x0: press.x, y0: press.y, x1: wx, y1: wy } : { kind: 'fill', room: press.room, x0: press.x, y0: press.y, x1: wx, y1: wy }; } const G = V.gesture; if ((G?.kind === 'fill' || G?.kind === 'rent') && (e.buttons & 2)) { V.gesture = null; changed(); return; } // a right-click while dragging stops the fill if (G?.kind === 'fill' || G?.kind === 'rent') { G.x1 = wx; G.y1 = wy; V.cursor = [wx, wy]; } else if (G?.kind === 'link') { G.x = wx; G.y = wy; } else if (G?.kind === 'box') { G.x1 = wx; G.y1 = wy; } });
const end = (e: PointerEvent) => { const S = getS(); stopHold(); pointers.delete(e.pointerId); if (pointers.size < 2) pinch = null; if (pointers.size) return; cv.classList.remove('panning'); const moved = !!start?.moved, G = V.gesture; V.gesture = null; const [wx, wy] = world(e), P = press; press = null; const F = flip; flip = null; if (pan) { pan = null; if (!moved && start?.touch) { if (P && (V.building || V.renting)) orderOne(P, e.shiftKey); else if (F) { toggleSwitch(F); changed(); } else { clickAt(wx, wy); const said = sayAt(S, V, wx, wy); if (said?.kind === 'region') tipFor(said, true); } // a region's name: its sites, and which deal opens it } start = null; return; } const held = !!start?.held && !moved; start = null; if (held && (G?.kind === 'box' || G?.kind === 'link')) return; // a finger held still and lifted: it read the tip, and acts on nothing if (P && (V.building || V.renting) && !G) { if (!moved) orderOne(P, e.shiftKey); return; } // moved with no fill: Esc or a right-click stopped it if (!G) return; if (G.kind === 'rent') { if (!V.renting) return; if (act(S, { type: 'rent', ms: slotsIn(S, G.site, [G.x0, G.y0], [G.x1, G.y1]).map(m => m.id), say: true }) && !e.shiftKey) V.renting = null; changed(); } else if (G.kind === 'fill') { const b = V.building; if (!b) return; if (act(S, { type: 'fill', build: b, room: G.room, from: local(S, G.room, [G.x0, G.y0]), to: local(S, G.room, [G.x1, G.y1]) }) && !e.shiftKey) V.building = null; changed(); } else if (G.kind === 'link') { const target = machineAt(S, V, wx, wy); if (!moved || !target || target.id === G.from) clickAt(wx, wy); else if (!target.owned) { act(S, { type: 'take', m: target.id, from: G.from }); V.selection.clear(); changed(); } else { act(S, { type: 'link', from: G.from, to: target.id }); changed(); } // onto another machine of yours: run a link of your own between them } else if (G.kind === 'box') { const hit = !moved && G.claim ? schemeAt(S, V, wx, wy) : null; if (hit) { act(S, { type: 'scheme', id: hit.id }); V.scheme = hit.id; changed(); return; } // the Claim tool on a scheme claims it if (!moved && G.claim) { // a tap with the Claim tool marks a machine to take, and a second tap takes the mark off (and stops its takeover) const m = stopAt(wx, wy) ? null : machineAt(S, V, wx, wy); if (m && !m.owned && !m.rented && m.special !== 'account') { const j = S.jobs.find(x => x.type === 'take' && x.node === m.id); if (j) act(S, { type: 'cancel', job: j.id }); else if (m.claimed) act(S, { type: 'unclaim', m: m.id }); else act(S, { type: 'claim', ms: [m.id] }); changed(); return; } } if (!moved) { clickAt(wx, wy); return; } const x0 = Math.min(G.x0, G.x1), x1 = Math.max(G.x0, G.x1), y0 = Math.min(G.y0, G.y1), y1 = Math.max(G.y0, G.y1); const inside = S.machines.filter(m => { const [x, y] = pos(S, m); return shown(S, m) && x >= x0 && x <= x1 && y >= y0 && y <= y1; }); // zoomed out, a box around folded sites picks the sites const sites = S.level.map.sites.filter(s => foldedSites.has(s.id) && siteKnown(S, s) && s.x + s.w / 2 >= x0 && s.x + s.w / 2 <= x1 && s.y + s.h / 2 >= y0 && s.y + s.h / 2 <= y1); if (G.claim) { // claim box: take them as soon as they're in reach, and claim any schemes in it act(S, { type: 'claim', ms: inside.filter(m => !m.owned).map(m => m.id) }); for (const r of S.ruses) { const p = schemePos(S, V, r); if (p && p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1) act(S, { type: 'scheme', id: r.id }); } } else if (!inside.length && sites.length) { V.sites = new Set(sites.map(s => s.id)); V.selection.clear(); V.front = null; } else { V.selection = new Set(inside.map(m => m.id)); V.sites.clear(); V.front = null; } changed(); } }; /** A click with hardware in hand: order one where it was pressed. With a rental in hand: rent the slot under it. Shift keeps it in hand. */ function orderOne(P: { room: string; x: number; y: number }, keep: boolean) { const S = getS(); if (V.renting) { if (act(S, { type: 'rent', ms: slotsIn(S, P.room, [P.x, P.y], [P.x, P.y]).slice(0, 1).map(m => m.id) }) && !keep) V.renting = null; changed(); return; } if (V.building && act(S, { type: 'order', build: V.building, room: P.room, at: local(S, P.room, [P.x, P.y]) })) { if (!keep) V.building = null; changed(); } } /** A site's switch or chip on the map, pressed: Take it all on or off, double its lines, or its next plan. */ function toggleSwitch(sw: { kind: string; id: string }) { const S = getS(); if (sw.kind === 'take') act(S, { type: 'takeall', site: sw.id, on: !S.takeAll.has(sw.id) }); else if (sw.kind === 'double') act(S, { type: 'double', site: sw.id }); else act(S, { type: 'plan', sites: [sw.id], plan: nextPlan(S.plans[sw.id]) }); } cv.addEventListener('pointerup', end); cv.addEventListener('pointercancel', e => { stopHold(); flip = null; pointers.delete(e.pointerId); pan = null; pinch = null; V.gesture = null; start = null; press = null; cv.classList.remove('panning'); });
cv.addEventListener('wheel', e => { e.preventDefault(); if (e.ctrlKey || e.metaKey) zoomAt(V, e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); else if (e.deltaMode === 1 || (Math.abs(e.deltaY) >= 50 && e.deltaX === 0 && Number.isInteger(e.deltaY))) { zoomAt(V, e.clientX, e.clientY, Math.exp(-e.deltaY * 0.002)); // a mouse wheel zooms } else { V.goal = null; V.cam.x += e.deltaX / V.cam.z; V.cam.y += e.deltaY / V.cam.z; } // a trackpad moves }, { passive: false });
}