- commit
- 3ac3f9d138536aabe3625be7a6c2050b78b504aa
- parent
- 02f34bc5524a63c09f08a6b44196c853fd52941f
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 11 | ++++ |
| M | src/ | 94 | +++++++++++++++++++++--------- |
| M | tests/ | 10 | +++- |
@@ -124,3 +124,14 @@ export const pauseMotionTimings = Object.freeze({
124124 });125125 126126 export const pauseMotionLayout = Object.freeze({ pitch: ITEM_PITCH, height: ITEM_HEIGHT });127+128+/** Match pointer targets to the settled tile rectangles drawn by stage-pause. */129+export function pausePointerTarget({ x, y, width, height, itemCount, layout = pauseMotionLayout }) {130+ if (x < width / 2 - 160 || x > width / 2 + 160) return "outside";131+ const firstTop = height * 0.24 - layout.height / 2;132+ const relativeY = y - firstTop;133+ const index = Math.floor(relativeY / layout.pitch);134+ if (index < 0 || index >= itemCount) return "outside";135+ if (relativeY - index * layout.pitch >= layout.height) return null;136+ return index;137+}@@ -1,4 +1,6 @@
11 import { overlayInputForKey } from "./dispatch.js";2+import { winPointerTarget } from "../render/stage-win.js";3+import { pausePointerTarget } from "../render/menu-motion.js";24 35 /**46 * Installs the stage's DOM event boundary. Gameplay input is still routed by@@ -25,6 +27,7 @@ export function installStageEvents({
2527 getVisualNow,2628 getPauseMotionLayout,2729 dispatchOverlayIntent,30+ noteInput,2831 handleIntent,2932 show,3033 closeGift,@@ -43,6 +46,7 @@ export function installStageEvents({
4346 if (["Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key))4447 event.stopPropagation();4548 if (event.key === "Escape" || (scene === "pause" && event.key.toLowerCase() === "z")) {49+ noteInput?.({ type: "menu", source: "keyboard" });4650 dispatchOverlayIntent({ type: "menu" });4751 event.preventDefault();4852 return;@@ -56,48 +60,59 @@ export function installStageEvents({
5660 return;5761 }5862 if (scene === "gift" && getGift()) {59- dispatchOverlayIntent({ type: overlayInputForKey(event.key) });63+ const type = overlayInputForKey(event.key);64+ noteInput?.({ type, source: "keyboard" });65+ dispatchOverlayIntent({ type });6066 event.preventDefault();6167 return;6268 }6369 if (scene === "workshop" && getWorkshop()) {64- dispatchOverlayIntent({ type: overlayInputForKey(event.key) });70+ const type = overlayInputForKey(event.key);71+ noteInput?.({ type, source: "keyboard" });72+ dispatchOverlayIntent({ type });6573 event.preventDefault();6674 return;6775 }6876 if (scene === "pause") {6977 if (event.key === "ArrowUp") {78+ noteInput?.({ type: "move", dir: "up", source: "keyboard" });7079 dispatchOverlayIntent({ type: "move", dir: "up" });7180 event.preventDefault();7281 return;7382 }7483 if (event.key === "ArrowDown") {84+ noteInput?.({ type: "move", dir: "down", source: "keyboard" });7585 dispatchOverlayIntent({ type: "move", dir: "down" });7686 event.preventDefault();7787 return;7888 }7989 if (event.key === "Enter") {90+ noteInput?.({ type: "confirm", source: "keyboard" });8091 dispatchOverlayIntent({ type: "confirm" });8192 event.preventDefault();8293 return;8394 }8495 }8596 if (scene === "index" && event.key === "Enter") {97+ noteInput?.({ type: "confirm", source: "keyboard" });8698 dispatchOverlayIntent({ type: "confirm" });8799 event.preventDefault();88100 return;89101 }90102 if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {103+ noteInput?.({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down", source: "keyboard" });91104 dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down" });92105 event.preventDefault();93106 return;94107 }95108 if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {109+ noteInput?.({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up", source: "keyboard" });96110 dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up" });97111 event.preventDefault();98112 return;99113 }100114 if (scene === "pause" && event.key.toLowerCase() === "w") {115+ noteInput?.({ type: "workshop", source: "keyboard" });101116 show("workshop");102117 event.preventDefault();103118 }@@ -107,6 +122,7 @@ export function installStageEvents({
107122 const scene = getScene();108123 if (event.target?.matches?.("input,textarea,select,[contenteditable]")) return;109124 if (event.key === " " && (scene === "index" || scene === "title")) {125+ noteInput?.({ type: "workshop", source: "keyboard" });110126 event.preventDefault();111127 event.stopImmediatePropagation();112128 show("workshop", { origin: scene });@@ -119,6 +135,7 @@ export function installStageEvents({
119135 if (!direction) return;120136 event.preventDefault();121137 event.stopImmediatePropagation();138+ noteInput?.({ type: "move", dir: direction, source: "keyboard" });122139 dispatchOverlayIntent({ type: "move", dir: direction });123140 }124141 @@ -130,8 +147,10 @@ export function installStageEvents({
130147 const y = event.clientY - rect.top;131148 if (scene === "title") {132149 const titleHit = getTitle()?.hit?.(x, y);133- if (titleHit?.kind === "workshop" || titleHit?.kind === "skip") return;150+ if (titleHit?.kind === "workshop") { noteInput?.({ type: "workshop", source: "touch" }); return; }151+ if (titleHit?.kind === "skip") { noteInput?.({ type: "confirm", source: "touch" }); return; }134152 if (titleHit?.kind === "menu") {153+ noteInput?.({ type: "menu", source: "touch" });135154 show("pause");136155 return;137156 }@@ -141,6 +160,7 @@ export function installStageEvents({
141160 const workshopHit = geometry.footerActions?.workshop;142161 if (workshopHit && x >= workshopHit.x && x <= workshopHit.x + workshopHit.width &&143162 y >= workshopHit.y && y <= workshopHit.y + workshopHit.height) {163+ noteInput?.({ type: "workshop", source: "touch" });144164 show("workshop", { origin: "index" });145165 render();146166 return;@@ -151,6 +171,7 @@ export function installStageEvents({
151171 if (hit && navigator) {152172 clearIndexLockedNode();153173 if (navigator.current?.node?.id === hit.id) {174+ noteInput?.({ type: "confirm", source: "touch" });154175 const choice = navigator.enter();155176 if (choice?.ok && onSelectLevel?.(choice.node.id) !== false) show("proof");156177 } else {@@ -161,51 +182,68 @@ export function installStageEvents({
161182 }162183 if (scene === "proof" && width <= 760) {163184 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)185+ if (hasActiveAutomata && y >= height - 92 && y < height - 50 && x >= width * 0.32 && x <= width * 0.68) {186+ noteInput?.({ type: "wait", source: "touch" });165187 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)188+ } else if (y >= height - 50 && x < width * 0.32) {189+ noteInput?.({ type: "undo", source: "touch" });190+ documentTarget.querySelector("#undo-btn")?.click();191+ } else if (y >= height - 50 && x >= width * 0.32 && x <= width * 0.68) {192+ noteInput?.({ type: "restart", source: "touch" });168193 documentTarget.querySelector("#restart-btn")?.click();169- else if (y >= height - 50 && x > width * 0.68) show("pause");194+ } else if (y >= height - 50 && x > width * 0.68) {195+ noteInput?.({ type: "menu", source: "touch" });196+ show("pause");197+ }170198 }171199 if (scene === "win") {172200 const winFlow = getWinFlow();173201 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();202+ const coarsePointer = windowTarget.matchMedia?.("(pointer: coarse)").matches ?? false;203+ const target = winPointerTarget({ width, height, x, y, coarsePointer });204+ if (!target) return;205+ if (!winFlow.view.winSkip && getVisualNow() - winFlow.view.winStart < winEntranceEnd) {206+ winFlow.confirm();207+ } else if (target === "continue" || (target === "card" && !coarsePointer && width > 760)) {208+ winFlow.confirm();209+ } else if (target === "undo") {210+ documentTarget.querySelector("#undo-btn")?.click();211+ } else if (target === "replay") {212+ documentTarget.querySelector("#restart-btn")?.click();186213 }187214 }188215 if (scene === "gift") {189216 const result = getGift()?.hit?.(x, y);217+ if (result?.action === "details") noteInput?.({ type: "details", source: "touch" });218+ if (result?.action === "copy") noteInput?.({ type: "copy", source: "touch" });219+ if (result?.action === "keep") noteInput?.({ type: "confirm", source: "touch" });220+ if (result?.action === "later") noteInput?.({ type: "menu", source: "touch" });190221 if (result?.action === "later") closeGift();191222 if (result?.action === "details") announce(getGift()?.accessibilityText?.());192223 }193224 if (scene === "workshop") {194225 const outcome = getWorkshop()?.hit?.(x, y);226+ Promise.resolve(outcome).then(result => {227+ const type = result?.event?.type;228+ if (type === "back") noteInput?.({ type: "menu", source: "touch" });229+ else if (["import", "play", "share", "export"].includes(type)) noteInput?.({ type: "confirm", source: "touch" });230+ });195231 Promise.resolve(outcome).then(result => {196232 if (result?.event?.type === "notes") announce(getWorkshop()?.accessibilityText?.());197233 });198234 }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);235+ if (scene === "pause" && getPauseMotionPhase() !== "exiting") {236+ const target = pausePointerTarget({237+ x, y, width, height, itemCount: getPauseItems().length,238+ layout: getPauseMotionLayout(),239+ });240+ if (typeof target === "number") {241+ setSelected(target);242+ noteInput?.({ type: "confirm", source: "touch" });208243 activatePauseSelection();244+ } else if (target === "outside") {245+ noteInput?.({ type: "menu", source: "touch" });246+ dispatchOverlayIntent({ type: "menu" });209247 }210248 }211249 render();@@ -1,6 +1,6 @@
11 import test from "node:test";22 import assert from "node:assert/strict";3-import { pauseMotionFrame, pauseMotionTimings } from "../src/render/menu-motion.js";3+import { pauseMotionFrame, pauseMotionTimings, pausePointerTarget } from "../src/render/menu-motion.js";44 import { pausePawX, wrapCanvasText } from "../src/render/stage-pause.js";55 66 test("pause menu drops items on a 35ms stagger and settles at the end", () => {@@ -50,6 +50,14 @@ test("pause paw stays inside narrow menu bounds", () => {
5050 assert.equal(pausePawX(1280), 464);5151 });5252 53+test("pause pointer targets follow the drawn tiles and leave the visible gap inert", () => {54+ const point = (x, y) => pausePointerTarget({ x, y, width: 390, height: 844, itemCount: 3 });55+ assert.equal(point(195, 202), 0);56+ assert.equal(point(195, 237), null);57+ assert.equal(point(195, 263), 1);58+ assert.equal(point(6, 202), "outside");59+});60+5361 test("pause hints wait until the menu lands and fade out on exit", () => {5462 const before = pauseMotionFrame({ now: 1200, openedAt: 1000, phase: "opening", itemCount: 2 });5563 const after = pauseMotionFrame({ now: 1400, openedAt: 1000, phase: "idle", itemCount: 2 });