clone

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

Animate Title and INDEX scene exits

Dami committed 4fa04eb
commit
4fa04ebb7834814ff727c8b19b1e1760f59522e2
parent
0412695c754df78aedc65e38c5cf93050cab3701
author
Dami <dami.etoile@gmail.com>
6 files changed, +149 -13
Msrc/render/index.js8+++--
Msrc/scenes/title.js17++++++---
Msrc/stage.js60++++++++++++++++++++++++++++--
Asrc/stage/scene-motion.js42+++++++++++++++++++++
Mtests/index-render.test.js11++++++
Atests/scene-motion.test.js24++++++++++++
M src/render/index.js +5 -3
@@ -96,9 +96,11 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
9696 const popping = options.popNodeId === node.id;9797 const popProgress = popping ? clamp(Number.isFinite(options.popProgress) ? options.popProgress : 1, 0, 1) : 1;9898 const popScale = popping && !options.reducedMotion ? 0.76 + 0.24 * easeOut(popProgress) : 1;99- const rect = { id: node.id, x: left, y: top, width: tileWidth, height: tileHeight, centerX: center.x, centerY: center.y, locked, solved, active, popScale, sparks: popping && !options.reducedMotion && popProgress < 1 };99+ const exitProgress = clamp(Number.isFinite(options.exitProgress) ? options.exitProgress : 0, 0, 1);100+ const exitScale = 1 - exitProgress * 0.08;101+ const rect = { id: node.id, x: left, y: top, width: tileWidth, height: tileHeight, centerX: center.x, centerY: center.y, locked, solved, active, popScale: popScale * exitScale, sparks: popping && !options.reducedMotion && popProgress < 1 };100102 ctx.save();101- if (popScale !== 1) { ctx.translate(center.x, center.y); ctx.scale(popScale, popScale); ctx.translate(-center.x, -center.y); }103+ if (popScale !== 1 || exitProgress > 0) { ctx.translate(center.x, center.y); ctx.scale(popScale * exitScale, popScale * exitScale); ctx.translate(-center.x, -center.y); }102104 // The cat replaces the tile; only its outline remains under the glyph.103105 if (active) {104106 roundRect(ctx, left - geometry.cell * 0.055, top - geometry.cell * 0.055, tileWidth + geometry.cell * 0.11, tileHeight + geometry.cell * 0.11, geometry.cell * 0.035);
@@ -128,7 +130,7 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
128130 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = locked ? colors.locked : colors.ink; ctx.fillText(String(node.number).padStart(2, '0'), center.x, center.y);129131 }130132 ctx.restore();131- if (popping && !options.reducedMotion && popProgress < 1) {133+ if (popping && !options.reducedMotion && popProgress < 1 && exitProgress < 1) {132134 const sparkAlpha = 1 - popProgress;133135 const radius = geometry.cell * (0.42 + popProgress * 0.3);134136 ctx.save(); ctx.globalAlpha = sparkAlpha; ctx.fillStyle = colors.solved;
M src/scenes/title.js +11 -6
@@ -48,10 +48,10 @@ function keycap(ctx, label, x, y, options = {}) {
4848 ctx.fillStyle = '#F2EEE6'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, x + width / 2, y + height / 2 + 1 + offsetY); ctx.restore?.();4949 return width;5050 }51-function headerTile(ctx, label, x, y, width, height, role, progress) {51+function headerTile(ctx, label, x, y, width, height, role, progress, exitProgress = 0) {5252 const drop = 1 - progress;53- const offsetY = -height * 0.9 * drop;54- const scale = 0.94 + progress * 0.06;53+ const offsetY = -height * 0.9 * drop - exitProgress * height * 0.7;54+ const scale = (0.94 + progress * 0.06) * (1 - exitProgress * 0.08);5555 const lip = Math.max(3, height * 0.07);5656 ctx.save?.(); ctx.translate?.(x + width / 2, y + height / 2 + offsetY); ctx.scale?.(scale, scale);5757 ctx.fillStyle = '#000'; rr(ctx, -width / 2 + lip, -height / 2 + lip, width, height, role === 'operator' ? height / 2 : 4); ctx.fill();
@@ -61,7 +61,7 @@ function headerTile(ctx, label, x, y, width, height, role, progress) {
6161 ctx.fillStyle = role === 'operator' ? '#F2EEE6' : '#0F0E0D'; ctx.font = `850 ${Math.max(13, height * 0.36)}px Recursive, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, 0, 1);6262 ctx.restore?.();6363 }64-function drawHeader(ctx, width, height, { now = 0, reducedMotion = false } = {}) {64+function drawHeader(ctx, width, height, { now = 0, reducedMotion = false, exitProgress = 0 } = {}) {6565 if (!ctx || typeof ctx.fillText !== 'function') return;6666 const labels = [['MIAU', 'noun'], ['IS', 'operator'], ['TRUE', 'property']];6767 const gap = Math.max(7, Math.min(14, width * 0.018));
@@ -76,7 +76,7 @@ function drawHeader(ctx, width, height, { now = 0, reducedMotion = false } = {})
7676 labels.forEach(([label, role], index) => {7777 const duration = reducedMotion ? 0 : 320; const start = index * 80; const progress = duration ? Math.max(0, Math.min(1, (now - start) / duration)) : 1;7878 progresses.push(progress);79- headerTile(ctx, label, x, top + index * 3, scaledWidths[index], scaledHeight, role, progress); x += scaledWidths[index] + gap;79+ headerTile(ctx, label, x, top + index * 3, scaledWidths[index], scaledHeight, role, progress, exitProgress); x += scaledWidths[index] + gap;8080 });8181 return { top, height: scaledHeight, bottom: top + scaledHeight, progresses };8282 }
@@ -183,7 +183,12 @@ export function createTitleScene(options = {}) {
183183 const now = drawOptions.now ?? (typeof performance === 'undefined' ? titleEpoch : performance.now());184184 renderNow = now;185185 const elapsed = Math.max(0, now - titleEpoch);186- const header = drawHeader(ctx, width, height, { now: elapsed, reducedMotion });186+ const sceneMotion = drawOptions.sceneMotion || {};187+ const header = drawHeader(ctx, width, height, {188+ now: elapsed,189+ reducedMotion,190+ exitProgress: sceneMotion.exitProgress || 0,191+ });187192 const cell = drawOptions.cell ?? Math.min(84, Math.max(30, Math.floor(Math.min((width - 64) / state.width, (height - 330) / state.height))));188193 const x = drawOptions.x ?? Math.floor((width - state.width * cell) / 2);189194 const boardHeight = state.height * cell;
M src/stage.js +56 -4
@@ -11,6 +11,7 @@ import { renderPauseScene } from './render/stage-pause.js';
1111 import { pauseMotionTimings, pauseMotionLayout } from './render/menu-motion.js';1212 import { pauseMenuItems } from "./stage/dispatch.js";1313 import { createWinFlow, WIN_ENTRANCE_END } from './stage/win-flow.js';14+import { SCENE_MOTION_TIMING, sceneMotionPhase } from './stage/scene-motion.js';1415 import { installStageEvents } from './stage/events.js';1516 1617 const SCENES = {
@@ -132,6 +133,7 @@ export function createStage(options = {}) {
132133 let winningNodeId = null;133134 let indexPopStart = 0;134135 let indexPopNodeId = null;136+ let sceneMotion = { phase: 'settled', scene: null, target: null, data: null, openedAt: 0, exitStartedAt: null, generation: 0, timer: null };135137 let copyFloatStart = 0;136138 const reducedMotion =137139 globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??
@@ -230,6 +232,18 @@ export function createStage(options = {}) {
230232 visualFrame((time) => drawTransition(time, generation));231233 else transitionStart = 0;232234 }235+ function drawSceneExitShutter(now) {236+ if (sceneMotion.phase !== 'exiting' || reducedMotion) return;237+ const motion = sceneMotionPhase({ now, exitStartedAt: sceneMotion.exitStartedAt, reducedMotion });238+ const amount = motion.shutterProgress;239+ if (amount <= 0) return;240+ const band = innerHeight * 0.5 * amount;241+ ctx.save();242+ ctx.fillStyle = '#0F0E0D';243+ ctx.fillRect(0, 0, innerWidth, band);244+ ctx.fillRect(0, innerHeight - band, innerWidth, band);245+ ctx.restore();246+ }233247 function moveIndex(direction) {234248 const result = indexNavigator?.move(direction);235249 indexLockedNode = result?.locked ? result.node : null;
@@ -238,6 +252,9 @@ export function createStage(options = {}) {
238252 return result;239253 }240254 function animateVisuals(now) {255+ if (sceneMotion.phase === 'opening' && now - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {256+ sceneMotion = { ...sceneMotion, phase: 'settled' };257+ }241258 render();242259 const winView = winFlow.view;243260 const winActive = scene === "win" && winFlow.active &&
@@ -250,6 +267,7 @@ export function createStage(options = {}) {
250267 const keycapActive = keycapPress && now - keycapPress.startedAt < 90;251268 const indexActive =252269 scene === "index" && indexPopStart && now - indexPopStart < 520;270+ const sceneMotionActive = sceneMotion.phase !== 'settled';253271 if (scene === "pause" && pauseMotionPhase === "opening" && now - pauseOpenedAt >= pauseMotionTimings.open)254272 pauseMotionPhase = "idle";255273 const pauseActive =
@@ -258,13 +276,16 @@ export function createStage(options = {}) {
258276 now - selectedAt < pauseMotionTimings.slide);259277 if (260278 !reducedMotion &&261- (winActive || titleActive || stepActive || indexActive || pauseActive || keycapActive)279+ (winActive || titleActive || stepActive || indexActive || pauseActive || keycapActive || sceneMotionActive)262280 )263281 visualFrame(animateVisuals);264282 }265283 function render() {266284 const w = innerWidth,267285 h = innerHeight;286+ if (sceneMotion.phase === 'opening' && visualNow() - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {287+ sceneMotion = { ...sceneMotion, phase: 'settled' };288+ }268289 ctx.fillStyle = "#0F0E0D";269290 ctx.fillRect(0, 0, w, h);270291 if (scene !== "proof" && scene !== "gift" && scene !== "workshop")
@@ -276,6 +297,7 @@ export function createStage(options = {}) {
276297 now: visualNow(),277298 reducedMotion,278299 keycapPress,300+ sceneMotion: sceneMotionPhase({ now: visualNow(), openedAt: sceneMotion.openedAt, exitStartedAt: sceneMotion.scene === 'title' ? sceneMotion.exitStartedAt : null, reducedMotion }),279301 });280302 const cat = drawn?.state?.objects?.find(281303 (object) => object.noun === "MIAU",
@@ -285,6 +307,7 @@ export function createStage(options = {}) {
285307 x: drawn.geometry.x + (cat.x + 0.5) * drawn.geometry.cell,286308 y: drawn.geometry.y + (cat.y + 0.5) * drawn.geometry.cell,287309 };310+ drawSceneExitShutter(visualNow());288311 return;289312 }290313 if (scene === "gift" && gift) {
@@ -326,12 +349,16 @@ export function createStage(options = {}) {
326349 popProgress: indexPopStart327350 ? Math.min(1, (visualNow() - indexPopStart) / 520)328351 : 1,352+ exitProgress: sceneMotion.scene === 'index'353+ ? sceneMotionPhase({ now: visualNow(), openedAt: sceneMotion.openedAt, exitStartedAt: sceneMotion.exitStartedAt, reducedMotion }).exitProgress354+ : 0,329355 reducedMotion,330356 lockedNode: indexLockedNode,331357 now: visualNow(),332358 });333359 const active = indexGeometry.nodes?.find((node) => node.active);334360 if (active) catCenter = { x: active.centerX, y: active.centerY };361+ drawSceneExitShutter(visualNow());335362 } else if (scene === "pause") {336363 renderPauseScene(ctx, {337364 width: w, height: h, now: visualNow(), boardState, boardGame,
@@ -377,7 +404,7 @@ export function createStage(options = {}) {
377404 ctx.textAlign = "left";378405 }379406 }380- function show(id, data = {}) {407+ function enterScene(id, data = {}) {381408 if (id !== 'win') winFlow.cancel();382409 irisCenter = catCenter;383410 if (id === "workshop") workshopOrigin = data.origin || scene;
@@ -440,6 +467,30 @@ export function createStage(options = {}) {
440467 visualFrame((time) => drawTransition(time, generation));441468 }442469 }470+ function show(id, data = {}) {471+ if (sceneMotion.phase === 'exiting') return api;472+ const menuScene = scene === 'title' || scene === 'index';473+ if (!data.instant && !reducedMotion && menuScene && id !== scene) {474+ const now = visualNow();475+ const generation = sceneMotion.generation + 1;476+ sceneMotion = {477+ phase: 'exiting', scene, target: id, data, openedAt: sceneMotion.openedAt,478+ exitStartedAt: now, generation, timer: null,479+ };480+ sceneMotion.timer = clock.setTimeout(() => {481+ if (sceneMotion.generation !== generation || sceneMotion.phase !== 'exiting') return;482+ const next = sceneMotion;483+ sceneMotion = { phase: 'opening', scene: next.target, target: null, data: null, openedAt: visualNow(), exitStartedAt: null, generation, timer: null };484+ enterScene(next.target, next.data);485+ }, SCENE_MOTION_TIMING.exit);486+ render();487+ visualFrame(animateVisuals);488+ return api;489+ }490+ const openedAt = visualNow();491+ sceneMotion = { phase: 'opening', scene: id, target: null, data: null, openedAt, exitStartedAt: null, generation: sceneMotion.generation + 1, timer: null };492+ enterScene(id, data);493+ }443494 function hide() {444495 if (scene === "pause") {445496 beginPauseExit("resume");
@@ -563,6 +614,7 @@ export function createStage(options = {}) {
563614 }564615 function dispatchOverlayIntent(intent = {}) {565616 const type = intent.type || intent;617+ if (sceneMotion.phase === 'exiting') return true;566618 if (scene === "pause") {567619 if (pauseMotionPhase === "exiting") return true;568620 if (type === "move") {
@@ -627,7 +679,7 @@ export function createStage(options = {}) {
627679 resize();628680 installStageEvents({629681 canvas,630- getScene: () => scene,682+ getScene: () => sceneMotion.phase === 'exiting' ? `${scene}-exiting` : scene,631683 getTitle: () => title,632684 getGift: () => gift,633685 getWorkshop: () => workshop,
@@ -674,7 +726,7 @@ export function createStage(options = {}) {
674726 render,675727 canvas,676728 get scene() {677- return scene;729+ return sceneMotion.phase === 'exiting' ? `${scene}-exiting` : scene;678730 },679731 get selected() {680732 return selected;
A src/stage/scene-motion.js +42 -0
M tests/index-render.test.js +11 -0
A tests/scene-motion.test.js +24 -0