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.
12 kB · 199 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200// 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 C to toggle) drag a box to claim machines// touch: hold still, then drag box-select// hardware picked from the order desk click an open spot in a room to order it there// right-drag, one finger move the map// wheel / two-finger scroll move the map; ctrl-wheel or pinch zooms// 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 itimport { buildRoomAt, canInstall, cancelJob, claim, installProg, order, roomMachines, unclaim, visible, runRuse, setPlan, setTakeAll, upgradeSiteLine, takeOver, PLAN_LIST, type Plan, type State } from './sim';import { tipAt } from './tip';import { doubleTip, planTip, regionTip, takeAllTip } from './ui';import { foldedSites, machineAt, pos, roomTabAt, stopAt, regionHit, regionSites, schemeAt, schemePos, shown, siteHit, siteKnown, 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];
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 } = null; 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; };
const world = (e: PointerEvent) => toWorld(V, e.clientX, e.clientY);
/** 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 scheme = add ? null : schemeAt(S, V, wx, wy); V.scheme = scheme?.id ?? null; V.person = 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') cancelJob(S, +stop[1]); else { const x = S.byId[stop[1]]; if (x) unclaim(x); } changed(); return; } const tab = add ? null : roomTabAt(wx, wy); // a room's title tab: every machine you can see in the room if (tab) { V.selection.clear(); V.sites.clear(); V.front = null; for (const x of roomMachines(S, tab)) if (visible(S, x)) V.selection.add(x.id); changed(); return; } const m = machineAt(S, V, wx, wy); const site = 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); 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; stopHold(); return; } 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.placing = null; V.building = null; changed(); return; } if (e.button === 1 || e.button === 2) { beginPan(); return; } const sw = e.button === 0 && !V.building && !V.placing ? switchAt(V, wx, wy) : null; if (sw) { // a site's Take all switch or Plan chip on the map if (sw.kind === 'take') setTakeAll(S, sw.id, !S.takeAll.has(sw.id)); else if (sw.kind === 'double') upgradeSiteLine(S, sw.id); else setPlan(S, [sw.id], nextPlan(S.plans[sw.id])); start = null; changed(); return; } if (V.building) { // hardware in hand: click an open spot to order it there; Shift keeps it in hand const room = buildRoomAt(S, wx, wy); if (room && order(S, V.building, room, [wx, wy])) { if (!e.shiftKey) V.building = null; changed(); } else beginPan(); return; } if (V.placing) { const m = machineAt(S, V, wx, wy); if (m && canInstall(S, m, V.placing)) { installProg(S, m, 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) { V.gesture = { kind: 'link', from: m.id, x: wx, y: wy }; return; } if (touch) { 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'); V.gesture = { kind: 'box', x0: wx, y0: wy, x1: wx, y1: wy }; 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)) { 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 sw = m ? null : switchAt(V, wx, wy); // a site's Take all switch or Plan chip: say what it does const rg = m || sw || 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 (e.pointerType === 'touch' || (!sw && !rg)) tipAt(null); else if (sw) tipAt(sw.kind === 'take' ? takeAllTip(S, [sw.id]) : sw.kind === 'double' ? doubleTip(S, sw.id) : planTip(S, sw.id), { left: sw.x0, top: sw.y0, right: sw.x1, bottom: sw.y1, width: sw.x1 - sw.x0 }); else if (rg) { const [rx, ry] = toScreen(V, ...toMap(S, rg.at[0], rg.at[1])); tipAt(regionTip(S, rg.name), { left: rx - 60, top: ry - 14, right: rx + 60, bottom: ry + 20, width: 120 }); } cv.classList.toggle('over-site', (scheme || (!m && V.tool === 'select' && (!!siteHit(S, V, wx, wy) || !!sw || !!rg)))); 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) { start.moved = true; if (pan) stopHold(); } 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), G = V.gesture; 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); if (pan) { pan = null; if (!moved && start?.touch) clickAt(wx, wy); start = null; return; } start = null; if (!G) return; 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) { takeOver(S, target, G.from); V.selection.clear(); changed(); } } else if (G.kind === 'box') { const hit = !moved && G.claim ? schemeAt(S, V, wx, wy) : null; if (hit) { runRuse(S, 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) cancelJob(S, j.id); else if (m.claimed) unclaim(m); else claim(S, [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 claim(S, 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) runRuse(S, 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(); } }; cv.addEventListener('pointerup', end); cv.addEventListener('pointercancel', e => { stopHold(); pointers.delete(e.pointerId); pan = null; pinch = null; V.gesture = null; start = 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 });
}