clone

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

Extract stage input and pointer event wiring

Dami committed b3a754e
commit
b3a754ee49b8e85c14e957f8f74442f2056e5fce
parent
92c774cb292e638b6df43287918bfef4ff6debdb
author
Dami <dami.etoile@gmail.com>
2 files changed, +250 -216
Msrc/stage.js244+++---------------------------
Asrc/stage/events.js222+++++++++++++++++++++++++++
M src/stage.js +28 -216
@@ -9,8 +9,9 @@ import { nativeHorizontalFacing } from './render/facing.js';
99 import { renderWinScene, WIN_TIMING } from './render/stage-win.js';1010 import { renderPauseScene } from './render/stage-pause.js';1111 import { pauseMotionTimings, pauseMotionLayout } from './render/menu-motion.js';12-import { overlayInputForKey, pauseMenuItems } from "./stage/dispatch.js";12+import { pauseMenuItems } from "./stage/dispatch.js";1313 import { createWinFlow, WIN_ENTRANCE_END } from './stage/win-flow.js';14+import { installStageEvents } from './stage/events.js';1415 1516 const SCENES = {1617 title: titleScene,
@@ -619,223 +620,34 @@ export function createStage(options = {}) {
619620 hide();620621 });621622 laterButton.addEventListener("click", () => hide());622- function handleKey(event) {623- if (event.target?.matches?.("input,textarea,select,[contenteditable]"))624- return;625- // Gameplay intents for title, proof, and win use the shared input router.626- if (scene === "title" || scene === "proof" || scene === "win") return;627- if (628- [629- "Escape",630- "Enter",631- "ArrowUp",632- "ArrowDown",633- "ArrowLeft",634- "ArrowRight",635- ].includes(event.key)636- )637- event.stopPropagation();638- if (event.key === "Escape" || (scene === "pause" && event.key.toLowerCase() === "z")) {639- dispatchOverlayIntent({ type: "menu" });640- event.preventDefault();641- return;642- }643- if (644- scene !== "pause" &&645- ["w", "a", "s", "d"].includes(event.key.toLowerCase())646- ) {647- handleIntent({648- type: "move",649- dir: { w: "up", a: "left", s: "down", d: "right" }[650- event.key.toLowerCase()651- ],652- });653- event.preventDefault();654- return;655- }656- if (scene === "gift" && gift) {657- dispatchOverlayIntent({ type: overlayInputForKey(event.key) });658- event.preventDefault();659- return;660- }661- if (scene === "workshop" && workshop) {662- dispatchOverlayIntent({ type: overlayInputForKey(event.key) });663- event.preventDefault();664- return;665- }666- if (scene === "pause") {667- if (event.key === "ArrowUp") {668- dispatchOverlayIntent({ type: "move", dir: "up" });669- event.preventDefault();670- return;671- }672- if (event.key === "ArrowDown") {673- dispatchOverlayIntent({ type: "move", dir: "down" });674- event.preventDefault();675- return;676- }677- if (event.key === "Enter") {678- dispatchOverlayIntent({ type: "confirm" });679- event.preventDefault();680- return;681- }682- }683- if (scene === "index" && event.key === "Enter") {684- dispatchOverlayIntent({ type: "confirm" });685- event.preventDefault();686- return;687- }688- if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {689- dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down" });690- event.preventDefault();691- return;692- }693- if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {694- dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up" });695- event.preventDefault();696- return;697- }698- if (scene === "pause" && event.key.toLowerCase() === "w") {699- show("workshop");700- event.preventDefault();701- }702- }703- function handleIndexArrowCapture(event) {704- if (event.target?.matches?.("input,textarea,select,[contenteditable]"))705- return;706- if (event.key === " " && (scene === "index" || scene === "title")) {707- event.preventDefault();708- event.stopImmediatePropagation();709- show("workshop", { origin: scene });710- return;711- }712- if (scene !== "index") return;713- const directions = {714- ArrowRight: "right",715- ArrowDown: "down",716- ArrowLeft: "left",717- ArrowUp: "up",718- };719- const direction = directions[event.key];720- if (!direction) return;721- event.preventDefault();722- event.stopImmediatePropagation();723- dispatchOverlayIntent({ type: "move", dir: direction });724- }725623 addEventListener("resize", resize);726624 resize();727- addEventListener("keydown", handleIndexArrowCapture, true);728- document.addEventListener("keydown", handleKey);729- canvas.addEventListener("pointerup", (event) => {730- const rect = canvas.getBoundingClientRect();731- const x = event.clientX - rect.left,732- y = event.clientY - rect.top;733- if (scene === "title") {734- const titleHit = title?.hit?.(x, y);735- if (titleHit?.kind === "workshop" || titleHit?.kind === "skip") return;736- if (titleHit?.kind === "menu") {737- show("pause");738- return;739- }740- }741- if (scene === "index" && indexGeometry?.nodes?.length) {742- const workshopHit = indexGeometry.footerActions?.workshop;743- if (744- workshopHit &&745- x >= workshopHit.x &&746- x <= workshopHit.x + workshopHit.width &&747- y >= workshopHit.y &&748- y <= workshopHit.y + workshopHit.height749- ) {750- show("workshop", { origin: "index" });751- render();752- return;753- }754- const hit = indexGeometry.nodes.find(755- (node) =>756- x >= node.x &&757- x <= node.x + node.width &&758- y >= node.y &&759- y <= node.y + node.height,760- );761- if (hit && indexNavigator) {762- indexLockedNode = null;763- const currentId = indexNavigator.current?.node?.id;764- if (currentId === hit.id) {765- const choice = indexNavigator.enter();766- if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)767- show("proof");768- } else {769- indexNavigator.setNode?.(hit.id);770- render();771- }772- }773- }774- if (scene === "proof" && innerWidth <= 760) {775- const hasActiveAutomata = boardState?.automata?.some(776- (system) => system.active,777- );778- if (779- hasActiveAutomata &&780- y >= innerHeight - 92 &&781- y < innerHeight - 50 &&782- x >= innerWidth * 0.32 &&783- x <= innerWidth * 0.68784- )785- options.onWaitAction?.();786- else if (y >= innerHeight - 50 && x < innerWidth * 0.32)787- document.querySelector("#undo-btn")?.click();788- else if (789- y >= innerHeight - 50 &&790- x >= innerWidth * 0.32 &&791- x <= innerWidth * 0.68792- )793- document.querySelector("#restart-btn")?.click();794- else if (y >= innerHeight - 50 && x > innerWidth * 0.68) show("pause");795- }796- if (scene === 'win') {797- if (winFlow.exiting) return;798- const cardWidth = Math.min(620, innerWidth - 48);799- const cardX = (innerWidth - cardWidth) / 2;800- const cardY = Math.max(120, innerHeight * 0.25);801- if (x >= cardX && x <= cardX + cardWidth && y >= cardY && y <= cardY + 290) {802- const actionRow = y >= cardY + 220;803- if (!winFlow.view.winSkip && visualNow() - winFlow.view.winStart < WIN_ENTRANCE_END)804- winFlow.confirm();805- else if (actionRow && (innerWidth <= 760 || globalThis.matchMedia?.('(pointer: coarse)').matches)) {806- const slot = Math.min(2, Math.floor((x - cardX) / (cardWidth / 3)));807- if (slot === 1) document.querySelector('#undo-btn')?.click();808- else if (slot === 2) document.querySelector('#restart-btn')?.click();809- else winFlow.confirm();810- } else winFlow.confirm();811- }812- }813- if (scene === "gift") {814- const result = gift?.hit(x, y);815- if (result?.action === "later") closeGift();816- if (result?.action === "details") announce(gift.accessibilityText?.());817- }818- if (scene === "workshop") {819- const outcome = workshop?.hit?.(x, y);820- Promise.resolve(outcome).then(result => {821- if (result?.event?.type === "notes") announce(workshop.accessibilityText?.());822- });823- }824- if (825- scene === "pause" &&826- pauseMotionPhase !== "exiting" &&827- x >= innerWidth / 2 - 160 &&828- x <= innerWidth / 2 + 160829- ) {830- const relativeY = y - innerHeight * 0.24;831- const index = Math.floor(relativeY / pauseMotionLayout.pitch);832- const withinTile = relativeY - index * pauseMotionLayout.pitch;833- if (index >= 0 && index < pauseItems.length && withinTile >= 0 && withinTile <= pauseMotionLayout.height) {834- setSelected(index);835- activatePauseSelection();836- }837- }838- render();625+ installStageEvents({626+ canvas,627+ getScene: () => scene,628+ getTitle: () => title,629+ getGift: () => gift,630+ getWorkshop: () => workshop,631+ getIndexGeometry: () => indexGeometry,632+ getIndexNavigator: () => indexNavigator,633+ clearIndexLockedNode: () => { indexLockedNode = null; },634+ getPauseMotionPhase: () => pauseMotionPhase,635+ getPauseItems: () => pauseItems,636+ getWinFlow: () => winFlow,637+ winEntranceEnd: WIN_ENTRANCE_END,638+ getBoardState: () => boardState,639+ getVisualNow: visualNow,640+ getPauseMotionLayout: () => pauseMotionLayout,641+ dispatchOverlayIntent,642+ handleIntent: intent => dispatchOverlayIntent(intent),643+ show,644+ closeGift,645+ announce,646+ render,647+ setSelected,648+ activatePauseSelection,649+ onWaitAction: options.onWaitAction,650+ onSelectLevel: options.onSelectLevel,839651 });840652 function handleIntent(intent = {}) {841653 return dispatchOverlayIntent(intent);
A src/stage/events.js +222 -0
@@ -0,0 +1,222 @@
1+import { overlayInputForKey } from "./dispatch.js";2+3+/**4+ * Installs the stage's DOM event boundary. Gameplay input is still routed by5+ * the owning scene; this module only translates browser events into stage6+ * intents and performs scene-local hit testing.7+ */8+export function installStageEvents({9+ canvas,10+ documentTarget = document,11+ windowTarget = globalThis,12+ viewport = () => ({ width: innerWidth, height: innerHeight }),13+ getScene,14+ getTitle,15+ getGift,16+ getWorkshop,17+ getIndexGeometry,18+ getIndexNavigator,19+ clearIndexLockedNode,20+ getPauseMotionPhase,21+ getPauseItems,22+ getWinFlow,23+ winEntranceEnd,24+ getBoardState,25+ getVisualNow,26+ getPauseMotionLayout,27+ dispatchOverlayIntent,28+ handleIntent,29+ show,30+ closeGift,31+ announce,32+ render,33+ setSelected,34+ activatePauseSelection,35+ onWaitAction,36+ onSelectLevel,37+}) {38+ function handleKey(event) {39+ const scene = getScene();40+ if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;41+ // Gameplay intents for title, proof, and win use the shared input router.42+ if (scene === "title" || scene === "proof" || scene === "win") return;43+ if (["Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key))44+ event.stopPropagation();45+ if (event.key === "Escape" || (scene === "pause" && event.key.toLowerCase() === "z")) {46+ dispatchOverlayIntent({ type: "menu" });47+ event.preventDefault();48+ return;49+ }50+ if (scene !== "pause" && ["w", "a", "s", "d"].includes(event.key.toLowerCase())) {51+ handleIntent({52+ type: "move",53+ dir: { w: "up", a: "left", s: "down", d: "right" }[event.key.toLowerCase()],54+ });55+ event.preventDefault();56+ return;57+ }58+ if (scene === "gift" && getGift()) {59+ dispatchOverlayIntent({ type: overlayInputForKey(event.key) });60+ event.preventDefault();61+ return;62+ }63+ if (scene === "workshop" && getWorkshop()) {64+ dispatchOverlayIntent({ type: overlayInputForKey(event.key) });65+ event.preventDefault();66+ return;67+ }68+ if (scene === "pause") {69+ if (event.key === "ArrowUp") {70+ dispatchOverlayIntent({ type: "move", dir: "up" });71+ event.preventDefault();72+ return;73+ }74+ if (event.key === "ArrowDown") {75+ dispatchOverlayIntent({ type: "move", dir: "down" });76+ event.preventDefault();77+ return;78+ }79+ if (event.key === "Enter") {80+ dispatchOverlayIntent({ type: "confirm" });81+ event.preventDefault();82+ return;83+ }84+ }85+ if (scene === "index" && event.key === "Enter") {86+ dispatchOverlayIntent({ type: "confirm" });87+ event.preventDefault();88+ return;89+ }90+ if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {91+ dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down" });92+ event.preventDefault();93+ return;94+ }95+ if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {96+ dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up" });97+ event.preventDefault();98+ return;99+ }100+ if (scene === "pause" && event.key.toLowerCase() === "w") {101+ show("workshop");102+ event.preventDefault();103+ }104+ }105+106+ function handleIndexArrowCapture(event) {107+ const scene = getScene();108+ if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;109+ if (event.key === " " && (scene === "index" || scene === "title")) {110+ event.preventDefault();111+ event.stopImmediatePropagation();112+ show("workshop", { origin: scene });113+ return;114+ }115+ if (scene !== "index") return;116+ const direction = {117+ ArrowRight: "right", ArrowDown: "down", ArrowLeft: "left", ArrowUp: "up",118+ }[event.key];119+ if (!direction) return;120+ event.preventDefault();121+ event.stopImmediatePropagation();122+ dispatchOverlayIntent({ type: "move", dir: direction });123+ }124+125+ function handlePointerUp(event) {126+ const scene = getScene();127+ const { width, height } = viewport();128+ const rect = canvas.getBoundingClientRect();129+ const x = event.clientX - rect.left;130+ const y = event.clientY - rect.top;131+ if (scene === "title") {132+ const titleHit = getTitle()?.hit?.(x, y);133+ if (titleHit?.kind === "workshop" || titleHit?.kind === "skip") return;134+ if (titleHit?.kind === "menu") {135+ show("pause");136+ return;137+ }138+ }139+ if (scene === "index" && getIndexGeometry()?.nodes?.length) {140+ const geometry = getIndexGeometry();141+ const workshopHit = geometry.footerActions?.workshop;142+ if (workshopHit && x >= workshopHit.x && x <= workshopHit.x + workshopHit.width &&143+ y >= workshopHit.y && y <= workshopHit.y + workshopHit.height) {144+ show("workshop", { origin: "index" });145+ render();146+ return;147+ }148+ const hit = geometry.nodes.find(node => x >= node.x && x <= node.x + node.width &&149+ y >= node.y && y <= node.y + node.height);150+ const navigator = getIndexNavigator();151+ if (hit && navigator) {152+ clearIndexLockedNode();153+ if (navigator.current?.node?.id === hit.id) {154+ const choice = navigator.enter();155+ if (choice?.ok && onSelectLevel?.(choice.node.id) !== false) show("proof");156+ } else {157+ navigator.setNode?.(hit.id);158+ render();159+ }160+ }161+ }162+ if (scene === "proof" && width <= 760) {163+ const hasActiveAutomata = getBoardState()?.automata?.some(system => system.active);164+ if (hasActiveAutomata && y >= height - 92 && y < height - 50 && x >= width * 0.32 && x <= width * 0.68)165+ onWaitAction?.();166+ else if (y >= height - 50 && x < width * 0.32) documentTarget.querySelector("#undo-btn")?.click();167+ else if (y >= height - 50 && x >= width * 0.32 && x <= width * 0.68)168+ documentTarget.querySelector("#restart-btn")?.click();169+ else if (y >= height - 50 && x > width * 0.68) show("pause");170+ }171+ if (scene === "win") {172+ const winFlow = getWinFlow();173+ if (winFlow.exiting) return;174+ const cardWidth = Math.min(620, width - 48);175+ const cardX = (width - cardWidth) / 2;176+ const cardY = Math.max(120, height * 0.25);177+ if (x >= cardX && x <= cardX + cardWidth && y >= cardY && y <= cardY + 290) {178+ const actionRow = y >= cardY + 220;179+ if (!winFlow.view.winSkip && getVisualNow() - winFlow.view.winStart < winEntranceEnd) winFlow.confirm();180+ else if (actionRow && (width <= 760 || windowTarget.matchMedia?.("(pointer: coarse)").matches)) {181+ const slot = Math.min(2, Math.floor((x - cardX) / (cardWidth / 3)));182+ if (slot === 1) documentTarget.querySelector("#undo-btn")?.click();183+ else if (slot === 2) documentTarget.querySelector("#restart-btn")?.click();184+ else winFlow.confirm();185+ } else winFlow.confirm();186+ }187+ }188+ if (scene === "gift") {189+ const result = getGift()?.hit?.(x, y);190+ if (result?.action === "later") closeGift();191+ if (result?.action === "details") announce(getGift()?.accessibilityText?.());192+ }193+ if (scene === "workshop") {194+ const outcome = getWorkshop()?.hit?.(x, y);195+ Promise.resolve(outcome).then(result => {196+ if (result?.event?.type === "notes") announce(getWorkshop()?.accessibilityText?.());197+ });198+ }199+ if (scene === "pause" && getPauseMotionPhase() !== "exiting" &&200+ x >= width / 2 - 160 && x <= width / 2 + 160) {201+ const relativeY = y - height * 0.24;202+ const layout = getPauseMotionLayout();203+ const items = getPauseItems();204+ const index = Math.floor(relativeY / layout.pitch);205+ const withinTile = relativeY - index * layout.pitch;206+ if (index >= 0 && index < items.length && withinTile >= 0 && withinTile <= layout.height) {207+ setSelected(index);208+ activatePauseSelection();209+ }210+ }211+ render();212+ }213+214+ windowTarget.addEventListener("keydown", handleIndexArrowCapture, true);215+ documentTarget.addEventListener("keydown", handleKey);216+ canvas.addEventListener("pointerup", handlePointerUp);217+ return () => {218+ windowTarget.removeEventListener("keydown", handleIndexArrowCapture, true);219+ documentTarget.removeEventListener("keydown", handleKey);220+ canvas.removeEventListener("pointerup", handlePointerUp);221+ };222+}