clone

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

main
miau-is-true / src / stage.js
stage.js 31.2K
1import { titleScene } from "./scenes/title.js";2import { indexScene } from "./scenes/index.js";3import { proofScene } from "./scenes/proof.js";4import { pauseScene } from "./scenes/pause.js";5import { winScene } from "./scenes/win.js";6import { drawIndex } from "./render/index.js";7import { renderProofScene } from "./render/stage-proof.js";8import { nativeHorizontalFacing } from './render/facing.js';9import { renderWinScene, WIN_TIMING } from './render/stage-win.js';10import { renderPauseScene } from './render/stage-pause.js';11import { pauseMotionTimings, pauseMotionLayout } from './render/menu-motion.js';12import { pauseMenuItems } from "./stage/dispatch.js";13import { createWinFlow, WIN_ENTRANCE_END } from './stage/win-flow.js';14import { SCENE_MOTION_TIMING, sceneMotionPhase } from './stage/scene-motion.js';15import { installStageEvents } from './stage/events.js';16import { drawWorldCard, worldCardDescription } from './render/world-card.js';1718const SCENES = {19 title: titleScene,20 index: indexScene,21 proof: proofScene,22 pause: pauseScene,23 win: winScene,24 workshop: {25 id: "workshop",26 eyebrow: "WORKSHOP",27 title: "Packs and proofs",28 prompt: "IMPORT / PLAY / SHARE / EXPORT",29 help: "ESC BACK",30 },31 gift: {32 id: "gift",33 eyebrow: "A GIFT FOR YOU",34 title: "A proof pack has arrived",35 prompt: "KEEP AND PLAY",36 help: "ENTER KEEP / ESC NOT NOW",37 },38};3940export function copyConfirmationProgress(now, startedAt, duration = 900) {41 if (!Number.isFinite(now) || !Number.isFinite(startedAt) || now < startedAt)42 return 0;43 return Math.max(0, Math.min(1, (now - startedAt) / duration));44}4546/** Small scene shell. Gameplay remains owned by main.js; this owns scene intent and overlays. */47export function createStage(options = {}) {48 const clock = options.clock || {49 now: () => performance.now(),50 requestAnimationFrame: callback => requestAnimationFrame(callback),51 setTimeout: (callback, delay) => setTimeout(callback, delay),52 clearTimeout: handle => clearTimeout(handle),53 };54 const visualNow = () => clock.now();55 const visualFrame = callback => clock.requestAnimationFrame(callback);56 const canvas = document.createElement("canvas");57 canvas.className = "stage-canvas";58 canvas.tabIndex = 0;59 canvas.setAttribute("aria-label", "Miau Is True game stage");60 document.body.append(canvas);61 const ctx = canvas.getContext("2d");62 let boardState = null;63 let boardGame = null;64 let boardPalette = {};65 let boardGlyphs = {};66 let boardFrame = null;67 let indexLayout = null;68 let indexNavigator = null;69 let indexGeometry = null;70 let indexLockedNode = null;71 let indexFacing = nativeHorizontalFacing('MIAU', '🐈');72 let title = options.title || null;73 let gift = options.gift || null;74 let workshop = options.workshop || null;75 let dpr = 1;76 const root = document.createElement("div");77 root.className = "stage-shell";78 root.setAttribute("aria-live", "polite");79 root.hidden = true;80 const live = document.createElement("div");81 live.className = "stage-sr";82 live.setAttribute("role", "status");83 live.setAttribute("aria-live", "polite");84 live.setAttribute("aria-atomic", "true");85 document.body.append(live);86 const card = document.createElement("div");87 card.className = "stage-card";88 const eyebrow = document.createElement("div");89 eyebrow.className = "stage-eyebrow";90 const titleEl = document.createElement("h2");91 titleEl.className = "stage-title";92 const prompt = document.createElement("div");93 prompt.className = "stage-prompt";94 const help = document.createElement("div");95 help.className = "stage-help";96 const choices = document.createElement("div");97 choices.className = "stage-choices";98 const keepButton = document.createElement("button");99 keepButton.className = "stage-choice stage-choice-primary";100 keepButton.textContent = "KEEP AND PLAY";101 const laterButton = document.createElement("button");102 laterButton.className = "stage-choice";103 laterButton.textContent = "NOT NOW";104 choices.append(keepButton, laterButton);105 choices.hidden = true;106 card.append(eyebrow, titleEl, prompt, choices, help);107 root.append(card);108 document.body.append(root);109 let scene = "proof";110 let pausedFrom = "proof";111 let workshopOrigin = "pause";112 let giftOrigin = "title";113 let selected = 0;114 let previousSelected = 0;115 let selectedAt = 0;116 let pressedIndex = -1;117 let pressedAt = 0;118 let pauseMotionPhase = 'idle';119 let pauseOpenedAt = 0;120 let pauseExitStartedAt = 0;121 let pauseExitTimer = null;122 let pauseMotionGeneration = 0;123 let pauseStatus = { soundOn: true, musicOn: false };124 const pauseItems = pauseMenuItems("proof", pauseStatus);125 let transitionStart = 0;126 let transitionGeneration = 0;127 let catCenter = null;128 let irisCenter = null;129 let titleStart = 0;130 let stepPopStart = 0;131 let stepRoll = null;132 let keycapPress = null;133 let previousMoves = null;134 let winningNodeId = null;135 let indexPopStart = 0;136 let indexPopNodeId = null;137 let indexWorldTransition = null;138 const worldCardsSeen = new Set();139 let worldCard = null;140 let sceneMotion = { phase: 'settled', scene: null, target: null, data: null, openedAt: 0, exitStartedAt: null, generation: 0, timer: null };141 let copyFloatStart = 0;142 const reducedMotion =143 globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??144 false;145 const winFlow = createWinFlow({146 clock,147 reducedMotion,148 onChange: () => { render(); if (!reducedMotion) visualFrame(animateVisuals); },149 onContinue: () => show('index'),150 });151 function resize() {152 dpr = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1));153 canvas.width = innerWidth * dpr;154 canvas.height = innerHeight * dpr;155 canvas.style.width = `${innerWidth}px`;156 canvas.style.height = `${innerHeight}px`;157 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);158 render();159 }160 function announce(message) {161 live.textContent = message;162 }163 function drawCopyConfirmation(ctx, width, height, now = visualNow()) {164 if (!copyFloatStart) return;165 const progress = copyConfirmationProgress(now, copyFloatStart);166 const labels = ["LINK", "IS", "COPIED"];167 const size = Math.min(16, Math.max(12, width * 0.035));168 ctx.save();169 ctx.globalAlpha = 1 - progress;170 ctx.font = `800 ${size}px Recursive, monospace`;171 const widths = labels.map((label) =>172 Math.max(size * 1.6, ctx.measureText(label).width + size),173 );174 const gap = Math.max(5, size * 0.4);175 const total =176 widths.reduce((sum, value) => sum + value, 0) + gap * (labels.length - 1);177 let x = Math.max(16, (width - total) / 2);178 const y = Math.min(height - 82, height * 0.78) - progress * 52;179 labels.forEach((label, index) => {180 const tileWidth = widths[index];181 ctx.save();182 ctx.globalAlpha = 1 - progress;183 ctx.fillStyle = "#000000";184 ctx.fillRect(x + 3, y + 3, tileWidth, size * 1.5);185 ctx.fillStyle = "#F2EEE6";186 ctx.fillRect(x, y, tileWidth, size * 1.5);187 ctx.fillStyle = "#0F0E0D";188 ctx.textAlign = "center";189 ctx.textBaseline = "middle";190 ctx.font = `800 ${size}px Recursive, monospace`;191 ctx.fillText(label, x + tileWidth / 2, y + size * 0.78);192 ctx.restore();193 x += widths[index] + gap;194 });195 ctx.restore();196 if (progress >= 1) copyFloatStart = 0;197 }198 function showCopyConfirmation() {199 copyFloatStart = visualNow();200 if (reducedMotion) {201 copyFloatStart = 0;202 return api;203 }204 render();205 visualFrame(showCopyConfirmationFrame);206 return api;207 }208 function showCopyConfirmationFrame() {209 if (!copyFloatStart) return;210 if (visualNow() - copyFloatStart >= 900 || (scene !== "gift" && scene !== "workshop")) {211 copyFloatStart = 0;212 return;213 }214 render();215 if (copyFloatStart) visualFrame(showCopyConfirmationFrame);216 }217 function drawTransition(now, generation) {218 if (219 !transitionStart ||220 reducedMotion ||221 generation !== transitionGeneration222 )223 return;224 const progress = Math.max(0, Math.min(1, (now - transitionStart) / 280));225 const radius = Math.hypot(innerWidth, innerHeight) * progress;226 const center = irisCenter || { x: innerWidth / 2, y: innerHeight / 2 };227 render();228 ctx.save();229 ctx.fillStyle = "#0F0E0D";230 ctx.beginPath();231 ctx.rect(0, 0, innerWidth, innerHeight);232 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2, true);233 ctx.fill("evenodd");234 ctx.restore();235 if (progress < 1)236 visualFrame((time) => drawTransition(time, generation));237 else {238 transitionStart = 0;239 // The iris frame paints over the destination once it reaches full240 // coverage. Draw the destination again so the handoff never leaves a241 // black canvas until the next unrelated input.242 render();243 }244 }245 function drawSceneExitShutter(now) {246 if (sceneMotion.phase !== 'exiting' || reducedMotion) return;247 const motion = sceneMotionPhase({ now, exitStartedAt: sceneMotion.exitStartedAt, reducedMotion });248 const amount = motion.shutterProgress;249 if (amount <= 0) return;250 const band = innerHeight * 0.5 * amount;251 ctx.save();252 ctx.fillStyle = '#0F0E0D';253 ctx.fillRect(0, 0, innerWidth, band);254 ctx.fillRect(0, innerHeight - band, innerWidth, band);255 ctx.restore();256 }257 function moveIndex(direction) {258 const result = indexNavigator?.move(direction);259 indexLockedNode = result?.locked ? result.node : null;260 if (result?.moved && (direction === "left" || direction === "right"))261 indexFacing = direction;262 if (result?.moved && result.node?.worldId !== result.from?.worldId)263 recordWorldTransition(result, direction === 'left' ? -1 : 1);264 return result;265 }266 function recordWorldTransition(result, delta) {267 const world = result.world || indexLayout?.getWorld?.(result.node?.worldId);268 indexWorldTransition = {269 from: result.from?.worldId || null,270 fromNodeId: result.from?.id || null,271 to: world?.id || result.node?.worldId || null,272 nodeId: result.node?.id || null,273 delta: Math.sign(delta),274 startedAt: visualNow(),275 duration: 280,276 };277 options.onWorldTransition?.(indexWorldTransition);278 announce(`World ${world?.number || ''}: ${world?.name || world?.id || 'World'}`.trim());279 requestWorldCard(world, 'index');280 }281 function moveIndexWorld(delta) {282 if (scene !== 'index' || !indexNavigator) return false;283 const result = indexNavigator.moveWorld(delta);284 if (result.moved) {285 indexLockedNode = null;286 recordWorldTransition(result, delta);287 } else {288 indexLockedNode = null;289 announce(delta < 0 ? 'Already at the first world' : 'Already at the last world');290 }291 render();292 return result.moved;293 }294 function requestWorldCard(world, origin = 'index') {295 if (!world?.id || worldCardsSeen.has(world.id)) return false;296 worldCardsSeen.add(world.id);297 worldCard = { world, origin, openedAt: visualNow(), geometry: null };298 announce(worldCardDescription(world));299 render();300 if (!reducedMotion) visualFrame(animateVisuals);301 return true;302 }303 function dismissWorldCard() {304 if (!worldCard) return false;305 worldCard = null;306 announce('INDEX');307 render();308 return true;309 }310 function animateVisuals(now) {311 if (indexWorldTransition && now - indexWorldTransition.startedAt >= (indexWorldTransition.duration || 280))312 indexWorldTransition = null;313 if (sceneMotion.phase === 'opening' && now - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {314 sceneMotion = { ...sceneMotion, phase: 'settled' };315 }316 render();317 const winView = winFlow.view;318 const winActive = scene === "win" && winFlow.active &&319 (winFlow.exiting320 ? now - winView.winExitStart < WIN_TIMING.exit321 : !winView.winSkip && now - winView.winStart < WIN_ENTRANCE_END);322 const titleActive =323 scene === "title" && titleStart && now - titleStart < 520;324 const stepActive = stepPopStart && now - stepPopStart < 240;325 const keycapActive = keycapPress && now - keycapPress.startedAt < 90;326 const indexActive =327 scene === "index" && indexPopStart && now - indexPopStart < 520;328 const worldTransitionActive =329 scene === "index" && indexWorldTransition && now - indexWorldTransition.startedAt < (indexWorldTransition.duration || 280);330 const sceneMotionActive = sceneMotion.phase !== 'settled';331 if (scene === "pause" && pauseMotionPhase === "opening" && now - pauseOpenedAt >= pauseMotionTimings.open)332 pauseMotionPhase = "idle";333 const pauseActive =334 scene === "pause" && (pauseMotionPhase !== "idle" ||335 (pressedIndex >= 0 && now - pressedAt < pauseMotionTimings.press) ||336 now - selectedAt < pauseMotionTimings.slide);337 if (338 !reducedMotion &&339 (winActive || titleActive || stepActive || indexActive || worldTransitionActive || pauseActive || keycapActive || sceneMotionActive)340 )341 visualFrame(animateVisuals);342 }343 function render() {344 const w = innerWidth,345 h = innerHeight;346 if (sceneMotion.phase === 'opening' && visualNow() - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {347 sceneMotion = { ...sceneMotion, phase: 'settled' };348 }349 ctx.fillStyle = "#0F0E0D";350 ctx.fillRect(0, 0, w, h);351 if (scene !== "proof" && scene !== "gift" && scene !== "workshop")352 canvas.setAttribute("aria-label", `Miau Is True ${scene}`);353 if (scene === "title" && title) {354 const drawn = title.draw(ctx, {355 width: w,356 height: h,357 now: visualNow(),358 reducedMotion,359 keycapPress,360 sceneMotion: sceneMotionPhase({ now: visualNow(), openedAt: sceneMotion.openedAt, exitStartedAt: sceneMotion.scene === 'title' ? sceneMotion.exitStartedAt : null, reducedMotion }),361 });362 const cat = drawn?.state?.objects?.find(363 (object) => object.noun === "MIAU",364 );365 if (cat && drawn.geometry)366 catCenter = {367 x: drawn.geometry.x + (cat.x + 0.5) * drawn.geometry.cell,368 y: drawn.geometry.y + (cat.y + 0.5) * drawn.geometry.cell,369 };370 drawSceneExitShutter(visualNow());371 return;372 }373 if (scene === "gift" && gift) {374 gift.draw(ctx, visualNow(), {375 width: w,376 height: h,377 keycapPress,378 reducedMotion,379 sceneMotion: sceneMotionPhase({380 now: visualNow(),381 openedAt: sceneMotion.openedAt,382 exitStartedAt: sceneMotion.scene === 'gift' ? sceneMotion.exitStartedAt : null,383 reducedMotion,384 }),385 });386 canvas.setAttribute("aria-label", gift.accessibilityText?.() || "Miau Is True Gift");387 drawCopyConfirmation(ctx, w, h);388 return;389 }390 if (scene === "workshop" && workshop) {391 workshop.draw(ctx, {392 width: w,393 height: h,394 keycapPress,395 now: visualNow(),396 reducedMotion,397 sceneMotion: sceneMotionPhase({398 now: visualNow(),399 openedAt: sceneMotion.openedAt,400 exitStartedAt: sceneMotion.scene === 'workshop' ? sceneMotion.exitStartedAt : null,401 reducedMotion,402 }),403 });404 canvas.setAttribute("aria-label", workshop.accessibilityText?.() || "Miau Is True Workshop");405 drawCopyConfirmation(ctx, w, h);406 return;407 }408 if (scene === "proof" && boardState) {409 const proofView = renderProofScene(ctx, boardState, {410 width: w,411 height: h,412 now: visualNow(),413 game: boardGame,414 palette: boardPalette,415 glyphs: boardGlyphs,416 frame: boardFrame,417 stepPopStart,418 stepRoll,419 keycapPress,420 reducedMotion,421 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,422 });423 if (proofView?.ariaLabel) canvas.setAttribute("aria-label", proofView.ariaLabel);424 if (proofView?.controlledCenter) catCenter = proofView.controlledCenter;425 } else if (scene === "index" && indexLayout && indexNavigator) {426 indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {427 availableWidth: w,428 availableHeight: h,429 cat: boardGlyphs.MIAU || "🐈",430 catFacing: indexFacing,431 popNodeId: indexPopNodeId,432 popProgress: indexPopStart433 ? Math.min(1, (visualNow() - indexPopStart) / 520)434 : 1,435 exitProgress: sceneMotion.scene === 'index'436 ? sceneMotionPhase({ now: visualNow(), openedAt: sceneMotion.openedAt, exitStartedAt: sceneMotion.exitStartedAt, reducedMotion }).exitProgress437 : 0,438 reducedMotion,439 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,440 worldTransition: indexWorldTransition,441 lockedNode: indexLockedNode,442 now: visualNow(),443 });444 const active = indexGeometry.nodes?.find((node) => node.active);445 if (active) catCenter = { x: active.centerX, y: active.centerY };446 const activeWorld = indexNavigator.current?.world;447 if (activeWorld) {448 const completed = Number.isFinite(activeWorld.completedCount) ? activeWorld.completedCount : 0;449 const total = Number.isFinite(activeWorld.levelCount) ? activeWorld.levelCount : 0;450 canvas.setAttribute(451 "aria-label",452 `World ${activeWorld.number || 1}: ${activeWorld.name || activeWorld.id || 'World'}, ${completed} of ${total} proofs complete. Q and E switch worlds.`,453 );454 }455 drawSceneExitShutter(visualNow());456 if (worldCard) {457 worldCard.geometry = drawWorldCard(ctx, worldCard.world, {458 width: w,459 height: h,460 irisProgress: (visualNow() - worldCard.openedAt) / SCENE_MOTION_TIMING.entry,461 reducedMotion,462 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,463 });464 canvas.setAttribute("aria-label", worldCardDescription(worldCard.world));465 }466 } else if (scene === "pause") {467 renderPauseScene(ctx, {468 width: w, height: h, now: visualNow(), boardState, boardGame,469 boardPalette, boardGlyphs, boardFrame, pausedFrom, indexLayout,470 indexNavigator, indexFacing, indexPopNodeId, indexPopStart,471 indexLockedNode, title, pauseItems, selected, reducedMotion,472 pauseMotion: {473 phase: pauseMotionPhase,474 openedAt: pauseOpenedAt,475 exitStartedAt: pauseExitStartedAt,476 previousSelected,477 selectedAt,478 pressedIndex,479 pressedAt,480 },481 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,482 keycapPress,483 onIndexGeometry: (geometry) => { indexGeometry = geometry; },484 });485 } else if (scene === "win") {486 renderWinScene(ctx, {487 width: w, height: h, now: visualNow(), boardState, boardGame,488 boardPalette, boardGlyphs, boardFrame, reducedMotion, ...winFlow.view,489 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,490 keycapPress,491 });492 } else {493 const spec = SCENES[scene] || proofScene;494 ctx.textAlign = "center";495 ctx.fillStyle = "#FF4B3E";496 ctx.font = "600 12px Recursive, monospace";497 ctx.fillText(spec.eyebrow, w / 2, h * 0.38);498 ctx.fillStyle = "#F2EEE6";499 ctx.font = `800 ${Math.min(64, Math.max(28, w * 0.06))}px Recursive, monospace`;500 ctx.fillText(501 (spec.title || scene).replace("{steps}", boardState?.moves || 0),502 w / 2,503 h * 0.48,504 );505 ctx.fillStyle = "#8E877C";506 ctx.font = "500 12px Recursive, monospace";507 ctx.fillText(spec.prompt || "", w / 2, h * 0.58);508 ctx.textAlign = "left";509 }510 }511 function enterScene(id, data = {}) {512 if (id !== 'win') winFlow.cancel();513 if (id !== 'index') worldCard = null;514 irisCenter = catCenter;515 if (id === "workshop") workshopOrigin = data.origin || scene;516 if (id === "gift") giftOrigin = data.origin || scene;517 if (id !== "index") indexLockedNode = null;518 if (id === "win") {519 winningNodeId =520 data.nodeId ||521 indexNavigator?.current?.node?.id ||522 boardState?.levelId ||523 boardState?.id ||524 null;525 }526 if (id === "title") titleStart = visualNow();527 if (id === "index" && winningNodeId) {528 indexPopNodeId = winningNodeId;529 indexPopStart = visualNow();530 }531 if (id === "pause" && scene !== "pause") {532 pausedFrom = scene;533 selected = 0;534 previousSelected = 0;535 selectedAt = visualNow();536 pressedIndex = -1;537 pressedAt = 0;538 pauseMotionPhase = "opening";539 pauseOpenedAt = visualNow();540 pauseExitStartedAt = 0;541 pauseMotionGeneration += 1;542 if (pauseExitTimer) clock.clearTimeout(pauseExitTimer);543 pauseExitTimer = null;544 pauseItems.splice(0, pauseItems.length, ...pauseMenuItems(pausedFrom, pauseStatus));545 }546 scene = id;547 if (id === 'win') winFlow.enter({ instant: Boolean(data.instant) });548 if (id !== "pause") pauseMotionPhase = "idle";549 const spec = SCENES[id] || proofScene;550 eyebrow.textContent = spec.eyebrow;551 titleEl.textContent = (spec.title || "").replace(552 "{steps}",553 data.steps ?? "0",554 );555 prompt.textContent = data.prompt || spec.prompt;556 help.textContent = spec.help;557 choices.hidden = true;558 root.hidden = true;559 root.dataset.scene = id;560 announce(id === "gift" && gift?.accessibilityText ? gift.accessibilityText()561 : id === "workshop" && workshop?.accessibilityText ? workshop.accessibilityText()562 : `${spec.eyebrow || ""} ${titleEl.textContent}`.trim());563 render();564 if (id === 'index' && indexNavigator && data.worldCard !== false) {565 const world = data.worldId ? indexLayout?.getWorld?.(data.worldId) : indexNavigator.current?.world;566 requestWorldCard(world, data.origin === 'workshop' ? 'workshop' : 'index');567 }568 if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause" || id === "gift" || id === "workshop"))569 visualFrame(animateVisuals);570 if (id === 'win' || id === 'pause') {571 transitionStart = 0;572 transitionGeneration += 1;573 } else {574 transitionStart = visualNow();575 const generation = ++transitionGeneration;576 visualFrame((time) => drawTransition(time, generation));577 }578 }579 function show(id, data = {}) {580 if (sceneMotion.phase === 'exiting') {581 // External events such as a completed file import can arrive while a582 // menu is leaving. Keep the current exit animation and enter the latest583 // requested scene when it finishes. Menu input is blocked separately.584 sceneMotion = { ...sceneMotion, target: id, data };585 return api;586 }587 const menuScene = scene === 'title' || scene === 'index' || scene === 'gift' || scene === 'workshop';588 if (!data.instant && !reducedMotion && menuScene && id !== scene) {589 const now = visualNow();590 const generation = sceneMotion.generation + 1;591 sceneMotion = {592 phase: 'exiting', scene, target: id, data, openedAt: sceneMotion.openedAt,593 exitStartedAt: now, generation, timer: null,594 };595 sceneMotion.timer = clock.setTimeout(() => {596 if (sceneMotion.generation !== generation || sceneMotion.phase !== 'exiting') return;597 const next = sceneMotion;598 sceneMotion = { phase: 'opening', scene: next.target, target: null, data: null, openedAt: visualNow(), exitStartedAt: null, generation, timer: null };599 enterScene(next.target, next.data);600 }, SCENE_MOTION_TIMING.exit);601 render();602 visualFrame(animateVisuals);603 return api;604 }605 const openedAt = visualNow();606 sceneMotion = { phase: 'opening', scene: id, target: null, data: null, openedAt, exitStartedAt: null, generation: sceneMotion.generation + 1, timer: null };607 enterScene(id, data);608 }609 function hide() {610 if (scene === "pause") {611 beginPauseExit("resume");612 return api;613 }614 transitionStart = 0;615 transitionGeneration += 1;616 root.hidden = true;617 scene = scene === "pause" ? pausedFrom : "proof";618 root.dataset.scene = scene;619 render();620 }621 function closeWorkshop() {622 const origin = workshopOrigin;623 show(origin === "workshop" ? "pause" : origin);624 return api;625 }626 function closeGift() {627 const origin = giftOrigin;628 show(origin === "gift" ? "title" : origin);629 return api;630 }631 function setBoard(state, game, palette = {}, glyphs = {}, frame = null) {632 if (game === boardGame && previousMoves !== null && state?.moves !== previousMoves) {633 stepPopStart = visualNow();634 stepRoll = { from: previousMoves, to: state.moves, startedAt: stepPopStart, duration: 120 };635 } else if (game !== boardGame) {636 stepRoll = null;637 keycapPress = null;638 }639 previousMoves = state?.moves ?? null;640 boardState = state;641 boardGame = game;642 boardPalette = palette;643 boardGlyphs = glyphs;644 boardFrame = frame;645 render();646 }647 function noteInput(intent = {}) {648 const action = intent.type === 'move' ? `move-${intent.dir}` : intent.type;649 if (!['undo', 'restart', 'menu', 'wait', 'confirm', 'world', 'workshop', 'details', 'copy', 'move-left', 'move-right', 'move-up', 'move-down'].includes(action)) return;650 keycapPress = { action, startedAt: visualNow() };651 render();652 if (!reducedMotion) visualFrame(animateVisuals);653 }654 function setIndex(layout, navigator) {655 indexLayout = layout;656 indexNavigator = navigator;657 render();658 }659 function setControllers(value = {}) {660 title = value.title || title;661 gift = value.gift || gift;662 workshop = value.workshop || workshop;663 render();664 }665 function setActions(value = {}) {666 Object.assign(options, value);667 return api;668 }669 function setPauseStatus(value = {}) {670 pauseStatus = { ...pauseStatus, ...value };671 pauseItems.splice(0, pauseItems.length, ...pauseMenuItems(pausedFrom, pauseStatus));672 render();673 return api;674 }675 function setSelected(index) {676 const next = Math.max(0, Math.min(pauseItems.length - 1, index));677 if (next !== selected) {678 previousSelected = selected;679 selected = next;680 selectedAt = visualNow();681 }682 root.dataset.selected = String(selected);683 render();684 }685 function runPauseAction(action) {686 pauseMotionPhase = "idle";687 pressedIndex = -1;688 if (action === "resume") hideImmediately();689 else if (action === "restart") options.onPauseAction?.("restart");690 else if (action === "index") show("index");691 else if (action === "workshop") show("workshop");692 else if (action === "title") show("title");693 }694 function hideImmediately() {695 transitionStart = 0;696 transitionGeneration += 1;697 root.hidden = true;698 scene = pausedFrom;699 root.dataset.scene = scene;700 render();701 }702 function beginPauseExit(action) {703 if (pauseMotionPhase === "exiting") return true;704 pauseMotionPhase = "exiting";705 pauseExitStartedAt = visualNow();706 const generation = ++pauseMotionGeneration;707 if (reducedMotion) {708 runPauseAction(action);709 return true;710 }711 pauseExitTimer = clock.setTimeout(() => {712 if (generation !== pauseMotionGeneration || scene !== "pause") return;713 pauseExitTimer = null;714 runPauseAction(action);715 }, pauseMotionTimings.exit);716 visualFrame(animateVisuals);717 return true;718 }719 function activatePauseSelection() {720 const action = pauseItems[selected]?.id;721 if (action === "sound" || action === "music") {722 pressedIndex = selected;723 pressedAt = visualNow();724 options.onPauseAction?.(action);725 render();726 visualFrame(animateVisuals);727 } else if (action) beginPauseExit(action);728 return Boolean(action);729 }730 function dispatchOverlayIntent(intent = {}) {731 const type = intent.type || intent;732 if (sceneMotion.phase === 'exiting') return true;733 if (scene === 'index' && worldCard) {734 if (type === 'confirm' || type === 'menu') return dismissWorldCard();735 return true;736 }737 if (scene === "pause") {738 if (pauseMotionPhase === "exiting") return true;739 if (type === "move") {740 if (intent.dir === "up") setSelected((selected + pauseItems.length - 1) % pauseItems.length);741 if (intent.dir === "down") setSelected((selected + 1) % pauseItems.length);742 return true;743 }744 if (type === "confirm") return activatePauseSelection();745 if (type === "menu") {746 beginPauseExit("resume");747 return true;748 }749 return false;750 }751 if (scene === "index") {752 if (type === "world") {753 moveIndexWorld(intent.delta);754 return true;755 }756 if (type === "move" && indexNavigator) {757 moveIndex(intent.dir);758 render();759 return true;760 }761 if (type === "confirm" && indexNavigator) {762 const choice = indexNavigator.enter();763 if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)764 show("proof");765 else render();766 return true;767 }768 if (type === "menu") {769 show("pause");770 return true;771 }772 return false;773 }774 if (scene === "workshop" && workshop) {775 const outcome = workshop.handleInput(776 type === "confirm" ? "enter" : type === "menu" ? "escape" : intent.dir || type,777 );778 Promise.resolve(outcome).then(result => {779 if (result?.event?.type === "notes") announce(workshop.accessibilityText?.());780 });781 render();782 return true;783 }784 if (scene === "gift" && gift) {785 const result = gift.input(786 type === "confirm" ? "confirm" : type === "menu" ? "escape" : intent.dir || type,787 );788 if (result.action === "later") closeGift();789 if (result.action === "details") announce(gift.accessibilityText?.());790 render();791 return true;792 }793 if (scene === "win" && type === "confirm") return winFlow.confirm();794 return false;795 }796 keepButton.addEventListener("click", () => {797 options.onConfirm?.("gift");798 hide();799 });800 laterButton.addEventListener("click", () => hide());801 addEventListener("resize", resize);802 resize();803 installStageEvents({804 canvas,805 getScene: () => sceneMotion.phase === 'exiting' ? `${scene}-exiting` : scene,806 getTitle: () => title,807 getGift: () => gift,808 getWorkshop: () => workshop,809 getIndexGeometry: () => indexGeometry,810 getIndexNavigator: () => indexNavigator,811 moveIndexWorld,812 getWorldCard: () => worldCard,813 clearIndexLockedNode: () => { indexLockedNode = null; },814 getPauseMotionPhase: () => pauseMotionPhase,815 getPauseItems: () => pauseItems,816 getWinFlow: () => winFlow,817 winEntranceEnd: WIN_ENTRANCE_END,818 getBoardState: () => boardState,819 getVisualNow: visualNow,820 getPauseMotionLayout: () => pauseMotionLayout,821 dispatchOverlayIntent,822 handleIntent: intent => dispatchOverlayIntent(intent),823 show,824 closeGift,825 announce,826 render,827 setSelected,828 activatePauseSelection,829 noteInput,830 onWaitAction: options.onWaitAction,831 onSelectLevel: options.onSelectLevel,832 });833 function handleIntent(intent = {}) {834 return dispatchOverlayIntent(intent);835 }836 const api = {837 show,838 hide,839 setSelected,840 setBoard,841 noteInput,842 setIndex,843 setControllers,844 setActions,845 setPauseStatus,846 showCopyConfirmation,847 closeWorkshop,848 closeGift,849 announce,850 handleIntent,851 render,852 canvas,853 get scene() {854 return sceneMotion.phase === 'exiting' ? `${scene}-exiting` : scene;855 },856 get selected() {857 return selected;858 },859 get isWinExiting() {860 return winFlow.exiting;861 },862 get indexWorldTransition() {863 return indexWorldTransition;864 },865 get worldCard() {866 return worldCard;867 },868 root,869 };870 return Object.freeze(api);871}