- commit
- b3a754ee49b8e85c14e957f8f74442f2056e5fce
- parent
- 92c774cb292e638b6df43287918bfef4ff6debdb
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 244 | +++--------------------------- |
| A | src/ | 222 | +++++++++++++++++++++++++++ |
@@ -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);@@ -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+}