// 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 // 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 import { 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, siteNameTip, takeAllTip } from './ui'; import { foldedSites, machineAt, pos, roomTabAt, stopAt, regionHit, regionSites, schemeAt, schemePos, shown, siteHit, siteKnown, siteNameAt, 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(); 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 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); 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 nm = sw || scheme ? null : siteNameAt(V, wx, wy); // a site's name: it opens the site's card const rg = m || sw || nm || 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 && !nm)) tipAt(null); else if (nm) tipAt(siteNameTip(S, nm.id, V.sites.size === 1 && V.sites.has(nm.id)), { left: nm.box[0], top: nm.box[1], right: nm.box[2], bottom: nm.box[3], width: nm.box[2] - nm.box[0] }); 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 || !!nm || (!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 }); }