clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / src / stage / events.js
events.js 12.1K
1import { overlayInputForKey } from "./dispatch.js";2import { winPointerTarget } from "../render/stage-win.js";3import { pausePointerTarget } from "../render/menu-motion.js";45/**6 * Installs the stage's DOM event boundary. Gameplay input is still routed by7 * the owning scene; this module only translates browser events into stage8 * intents and performs scene-local hit testing.9 */10export function installStageEvents({11 canvas,12 documentTarget = document,13 windowTarget = globalThis,14 viewport = () => ({ width: innerWidth, height: innerHeight }),15 getScene,16 getTitle,17 getGift,18 getWorkshop,19 getIndexGeometry,20 moveIndexWorld,21 getIndexNavigator,22 getWorldCard,23 clearIndexLockedNode,24 getPauseMotionPhase,25 getPauseItems,26 getWinFlow,27 winEntranceEnd,28 getBoardState,29 getVisualNow,30 getPauseMotionLayout,31 dispatchOverlayIntent,32 noteInput,33 handleIntent,34 show,35 closeGift,36 announce,37 render,38 setSelected,39 activatePauseSelection,40 onWaitAction,41 onSelectLevel,42}) {43 function handleKey(event) {44 const scene = getScene();45 if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;46 // Gameplay intents for title, proof, and win use the shared input router.47 if (scene === "title" || scene === "proof" || scene === "win") return;48 if (scene === "index" && getWorldCard?.()) {49 if (event.key === "Enter" || event.key === "Escape") {50 noteInput?.({ type: event.key === "Enter" ? "confirm" : "menu", source: "keyboard" });51 dispatchOverlayIntent({ type: event.key === "Enter" ? "confirm" : "menu" });52 event.preventDefault();53 event.stopPropagation();54 }55 return;56 }57 if (scene === "index" && (event.key === "q" || event.key === "Q" || event.key === "e" || event.key === "E")) {58 const delta = event.key.toLowerCase() === "e" ? 1 : -1;59 noteInput?.({ type: "world", delta, source: "keyboard" });60 dispatchOverlayIntent({ type: "world", delta });61 event.preventDefault();62 return;63 }64 if (["Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key))65 event.stopPropagation();66 if (event.key === "Escape" || (scene === "pause" && event.key.toLowerCase() === "z")) {67 noteInput?.({ type: "menu", source: "keyboard" });68 dispatchOverlayIntent({ type: "menu" });69 event.preventDefault();70 return;71 }72 if (scene !== "pause" && ["w", "a", "s", "d"].includes(event.key.toLowerCase())) {73 handleIntent({74 type: "move",75 dir: { w: "up", a: "left", s: "down", d: "right" }[event.key.toLowerCase()],76 });77 event.preventDefault();78 return;79 }80 if (scene === "gift" && getGift()) {81 const type = overlayInputForKey(event.key);82 noteInput?.({ type, source: "keyboard" });83 dispatchOverlayIntent({ type });84 event.preventDefault();85 return;86 }87 if (scene === "workshop" && getWorkshop()) {88 const type = overlayInputForKey(event.key);89 noteInput?.({ type, source: "keyboard" });90 dispatchOverlayIntent({ type });91 event.preventDefault();92 return;93 }94 if (scene === "pause") {95 if (event.key === "ArrowUp") {96 noteInput?.({ type: "move", dir: "up", source: "keyboard" });97 dispatchOverlayIntent({ type: "move", dir: "up" });98 event.preventDefault();99 return;100 }101 if (event.key === "ArrowDown") {102 noteInput?.({ type: "move", dir: "down", source: "keyboard" });103 dispatchOverlayIntent({ type: "move", dir: "down" });104 event.preventDefault();105 return;106 }107 if (event.key === "Enter") {108 noteInput?.({ type: "confirm", source: "keyboard" });109 dispatchOverlayIntent({ type: "confirm" });110 event.preventDefault();111 return;112 }113 }114 if (scene === "index" && event.key === "Enter") {115 noteInput?.({ type: "confirm", source: "keyboard" });116 dispatchOverlayIntent({ type: "confirm" });117 event.preventDefault();118 return;119 }120 if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {121 noteInput?.({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down", source: "keyboard" });122 dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down" });123 event.preventDefault();124 return;125 }126 if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {127 noteInput?.({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up", source: "keyboard" });128 dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up" });129 event.preventDefault();130 return;131 }132 if (scene === "pause" && event.key.toLowerCase() === "w") {133 noteInput?.({ type: "workshop", source: "keyboard" });134 show("workshop");135 event.preventDefault();136 }137 }138139 function handleIndexArrowCapture(event) {140 const scene = getScene();141 if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;142 if (event.key === " " && (scene === "index" || scene === "title")) {143 noteInput?.({ type: "workshop", source: "keyboard" });144 event.preventDefault();145 event.stopImmediatePropagation();146 show("workshop", { origin: scene });147 return;148 }149 if (scene !== "index") return;150 const direction = {151 ArrowRight: "right", ArrowDown: "down", ArrowLeft: "left", ArrowUp: "up",152 }[event.key];153 if (!direction) return;154 event.preventDefault();155 event.stopImmediatePropagation();156 noteInput?.({ type: "move", dir: direction, source: "keyboard" });157 dispatchOverlayIntent({ type: "move", dir: direction });158 }159160 function handlePointerUp(event) {161 const scene = getScene();162 const { width, height } = viewport();163 const rect = canvas.getBoundingClientRect();164 const x = event.clientX - rect.left;165 const y = event.clientY - rect.top;166 if (scene === "title") {167 const titleHit = getTitle()?.hit?.(x, y);168 if (titleHit?.kind === "workshop") { noteInput?.({ type: "workshop", source: "touch" }); return; }169 if (titleHit?.kind === "skip") { noteInput?.({ type: "confirm", source: "touch" }); return; }170 if (titleHit?.kind === "menu") {171 noteInput?.({ type: "menu", source: "touch" });172 show("pause");173 return;174 }175 }176 if (scene === "index" && getIndexGeometry()?.nodes?.length) {177 const geometry = getIndexGeometry();178 const card = getWorldCard?.();179 if (card?.geometry?.enter) {180 event.preventDefault?.();181 event.stopImmediatePropagation?.();182 const target = card.geometry.enter;183 if (x >= target.x && x <= target.x + target.width && y >= target.y && y <= target.y + target.height) {184 noteInput?.({ type: "confirm", source: "touch" });185 dispatchOverlayIntent({ type: "confirm" });186 }187 return;188 }189 const worldStrip = geometry.worldStrip;190 const previousWorld = worldStrip?.arrows?.previous;191 const nextWorld = worldStrip?.arrows?.next;192 const inTarget = (target) => target && x >= target.x && x <= target.x + target.width && y >= target.y && y <= target.y + target.height;193 if (inTarget(previousWorld) || inTarget(nextWorld)) {194 const delta = inTarget(nextWorld) ? 1 : -1;195 noteInput?.({ type: "world", delta, source: "touch" });196 moveIndexWorld?.(delta);197 render();198 return;199 }200 const menuHit = geometry.footerActions?.menu;201 if (inTarget(menuHit)) {202 noteInput?.({ type: "menu", source: "touch" });203 dispatchOverlayIntent({ type: "menu" });204 return;205 }206 const workshopHit = geometry.footerActions?.workshop;207 if (workshopHit && x >= workshopHit.x && x <= workshopHit.x + workshopHit.width &&208 y >= workshopHit.y && y <= workshopHit.y + workshopHit.height) {209 noteInput?.({ type: "workshop", source: "touch" });210 show("workshop", { origin: "index" });211 render();212 return;213 }214 const hit = geometry.nodes.find(node => x >= node.x && x <= node.x + node.width &&215 y >= node.y && y <= node.y + node.height);216 const navigator = getIndexNavigator();217 if (hit && navigator) {218 clearIndexLockedNode();219 if (navigator.current?.node?.id === hit.id) {220 noteInput?.({ type: "confirm", source: "touch" });221 const choice = navigator.enter();222 if (choice?.ok && onSelectLevel?.(choice.node.id) !== false) show("proof");223 } else {224 navigator.setNode?.(hit.id);225 render();226 }227 }228 }229 if (scene === "proof" && width <= 760) {230 const hasActiveAutomata = getBoardState()?.automata?.some(system => system.active);231 if (hasActiveAutomata && y >= height - 92 && y < height - 50 && x >= width * 0.32 && x <= width * 0.68) {232 noteInput?.({ type: "wait", source: "touch" });233 onWaitAction?.();234 } else if (y >= height - 50 && x < width * 0.32) {235 noteInput?.({ type: "undo", source: "touch" });236 documentTarget.querySelector("#undo-btn")?.click();237 } else if (y >= height - 50 && x >= width * 0.32 && x <= width * 0.68) {238 noteInput?.({ type: "restart", source: "touch" });239 documentTarget.querySelector("#restart-btn")?.click();240 } else if (y >= height - 50 && x > width * 0.68) {241 noteInput?.({ type: "menu", source: "touch" });242 show("pause");243 }244 }245 if (scene === "win") {246 const winFlow = getWinFlow();247 if (winFlow.exiting) return;248 const coarsePointer = windowTarget.matchMedia?.("(pointer: coarse)").matches ?? false;249 const target = winPointerTarget({ width, height, x, y, coarsePointer });250 if (!target) return;251 if (!winFlow.view.winSkip && getVisualNow() - winFlow.view.winStart < winEntranceEnd) {252 winFlow.confirm();253 } else if (target === "continue" || (target === "card" && !coarsePointer && width > 760)) {254 winFlow.confirm();255 } else if (target === "undo") {256 documentTarget.querySelector("#undo-btn")?.click();257 } else if (target === "replay") {258 documentTarget.querySelector("#restart-btn")?.click();259 }260 }261 if (scene === "gift") {262 const result = getGift()?.hit?.(x, y);263 if (result?.action === "details") noteInput?.({ type: "details", source: "touch" });264 if (result?.action === "copy") noteInput?.({ type: "copy", source: "touch" });265 if (result?.action === "keep") noteInput?.({ type: "confirm", source: "touch" });266 if (result?.action === "later") noteInput?.({ type: "menu", source: "touch" });267 if (result?.action === "later") closeGift();268 if (result?.action === "details") announce(getGift()?.accessibilityText?.());269 }270 if (scene === "workshop") {271 const outcome = getWorkshop()?.hit?.(x, y);272 Promise.resolve(outcome).then(result => {273 const type = result?.event?.type;274 if (type === "back") noteInput?.({ type: "menu", source: "touch" });275 else if (["import", "play", "share", "export"].includes(type)) noteInput?.({ type: "confirm", source: "touch" });276 });277 Promise.resolve(outcome).then(result => {278 if (result?.event?.type === "notes") announce(getWorkshop()?.accessibilityText?.());279 });280 }281 if (scene === "pause" && getPauseMotionPhase() !== "exiting") {282 const target = pausePointerTarget({283 x, y, width, height, itemCount: getPauseItems().length,284 layout: getPauseMotionLayout(),285 });286 if (typeof target === "number") {287 setSelected(target);288 noteInput?.({ type: "confirm", source: "touch" });289 activatePauseSelection();290 } else if (target === "outside") {291 noteInput?.({ type: "menu", source: "touch" });292 dispatchOverlayIntent({ type: "menu" });293 }294 }295 render();296 }297298 windowTarget.addEventListener("keydown", handleIndexArrowCapture, true);299 documentTarget.addEventListener("keydown", handleKey);300 canvas.addEventListener("pointerup", handlePointerUp);301 return () => {302 windowTarget.removeEventListener("keydown", handleIndexArrowCapture, true);303 documentTarget.removeEventListener("keydown", handleKey);304 canvas.removeEventListener("pointerup", handlePointerUp);305 };306}