- commit
- 4fa04ebb7834814ff727c8b19b1e1760f59522e2
- parent
- 0412695c754df78aedc65e38c5cf93050cab3701
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 8 | +++-- |
| M | src/ | 17 | ++++++--- |
| M | src/ | 60 | ++++++++++++++++++++++++++++-- |
| A | src/ | 42 | +++++++++++++++++++++ |
| M | tests/ | 11 | ++++++ |
| A | tests/ | 24 | ++++++++++++ |
@@ -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;@@ -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;@@ -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;@@ -0,0 +1,42 @@
1+/**2+ * Shared timing contract for menu-like scenes. The renderers keep their own3+ * settled geometry; this helper only describes the presentation phase around4+ * it so a scene can capture one exit action and ignore later input.5+ */6+export const SCENE_MOTION_TIMING = Object.freeze({7+ entry: 520,8+ exit: 280,9+ shutter: 140,10+ stagger: 35,11+});12+13+export function sceneMotionPhase({14+ now = 0,15+ openedAt = 0,16+ exitStartedAt = null,17+ reducedMotion = false,18+ timing = SCENE_MOTION_TIMING,19+} = {}) {20+ if (reducedMotion) return { phase: 'settled', entryProgress: 1, exitProgress: 0, shutterProgress: 0 };21+ if (Number.isFinite(exitStartedAt)) {22+ const elapsed = Math.max(0, now - exitStartedAt);23+ const exitProgress = Math.min(1, elapsed / timing.exit);24+ return {25+ phase: exitProgress >= 1 ? 'settled' : 'exiting',26+ entryProgress: 1,27+ exitProgress,28+ shutterProgress: Math.min(1, elapsed / timing.shutter),29+ };30+ }31+ const entryProgress = Math.min(1, Math.max(0, (now - openedAt) / timing.entry));32+ return { phase: entryProgress >= 1 ? 'settled' : 'opening', entryProgress, exitProgress: 0, shutterProgress: 0 };33+}34+35+export function sceneMotionExitFrame(now, exitStartedAt, timing = SCENE_MOTION_TIMING) {36+ const elapsed = Math.max(0, now - exitStartedAt);37+ return {38+ progress: Math.min(1, elapsed / timing.exit),39+ shutterProgress: Math.min(1, elapsed / timing.shutter),40+ };41+}42+@@ -129,6 +129,17 @@ test('drawIndex pops a solved node and emits stars, with a reduced motion cut',
129129 assert.equal(reducedContext.calls.some(call => call[0] === 'fillText' && call[1] === '★'), false);130130 });131131 132+test('drawIndex keeps settled hit geometry while nodes reverse-scale on exit', () => {133+ const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read')]), null);134+ const navigator = createIndexNavigator(layout);135+ const ctx = context();136+ const settled = drawIndex(ctx, layout, navigator, { exitProgress: 0 });137+ const exiting = drawIndex(ctx, layout, navigator, { exitProgress: 0.5 });138+ assert.equal(exiting.nodes[0].x, settled.nodes[0].x);139+ assert.equal(exiting.nodes[0].y, settled.nodes[0].y);140+ assert.ok(exiting.nodes[0].popScale < settled.nodes[0].popScale);141+});142+132143 test('drawIndex mirrors the native-left cat only for right-facing cursor travel', () => {133144 const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);134145 const navigator = createIndexNavigator(layout);@@ -0,0 +1,24 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { SCENE_MOTION_TIMING, sceneMotionExitFrame, sceneMotionPhase } from '../src/stage/scene-motion.js';4+5+test('scene motion opens, settles, and exits on the shared timing contract', () => {6+ assert.equal(sceneMotionPhase({ now: 0, openedAt: 0 }).phase, 'opening');7+ assert.equal(sceneMotionPhase({ now: SCENE_MOTION_TIMING.entry, openedAt: 0 }).phase, 'settled');8+ const exiting = sceneMotionPhase({ now: 70, exitStartedAt: 0 });9+ assert.equal(exiting.phase, 'exiting');10+ assert.equal(exiting.shutterProgress, 0.5);11+ assert.equal(sceneMotionPhase({ now: SCENE_MOTION_TIMING.exit, exitStartedAt: 0 }).phase, 'settled');12+});13+14+test('reduced motion resolves to a settled scene without a shutter', () => {15+ assert.deepEqual(sceneMotionPhase({ now: 0, openedAt: 500, exitStartedAt: 0, reducedMotion: true }), {16+ phase: 'settled', entryProgress: 1, exitProgress: 0, shutterProgress: 0,17+ });18+});19+20+test('exit frame keeps its shutter at the shorter D22 beat', () => {21+ assert.deepEqual(sceneMotionExitFrame(140, 0), { progress: 0.5, shutterProgress: 1 });22+ assert.deepEqual(sceneMotionExitFrame(280, 0), { progress: 1, shutterProgress: 1 });23+});24+