clone

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

Animate Gift and Workshop scene transitions

Dami committed 41facbc
commit
41facbc244f99fe4940c9bd596018e5c7a2f1baf
parent
3ac3f9d138536aabe3625be7a6c2050b78b504aa
author
Dami <dami.etoile@gmail.com>
4 files changed, +109 -5
Msrc/scenes/gift.js32+++++++++++++++++++++++++++++-
Msrc/scenes/workshop.js22+++++++++++++++++++++
Msrc/stage.js31+++++++++++++++++++++++++----
Atests/menu-scene-motion.test.js29+++++++++++++++++++++++++++
M src/scenes/gift.js +31 -1
@@ -50,6 +50,26 @@ function wrapText(ctx, value, maxWidth, size = 9, weight = 650) {
5050 return lines.length ? lines : [''];5151 }5252 53+function clampMotion(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }54+function easeOutCubic(value) {55+ const t = clampMotion(value);56+ return 1 - ((1 - t) ** 3);57+}58+function easeOutBack(value) {59+ const t = clampMotion(value);60+ const overshoot = 1.70158;61+ return 1 + (overshoot + 1) * ((t - 1) ** 3) + overshoot * ((t - 1) ** 2);62+}63+export function giftMotionFrame(sceneMotion = {}, reducedMotion = false) {64+ if (reducedMotion || sceneMotion.phase === 'settled') return { offsetY: 0, alpha: 1 };65+ if (sceneMotion.phase === 'exiting') {66+ const exit = clampMotion(sceneMotion.exitProgress);67+ return { offsetY: 16 * (exit ** 3), alpha: 1 - exit * 0.22 };68+ }69+ const entry = clampMotion(sceneMotion.entryProgress);70+ return { offsetY: -48 * (1 - easeOutBack(entry)), alpha: 0.78 + easeOutCubic(entry) * 0.22 };71+}72+5373 /** Canvas-only Gift controller. It never installs a pack itself. */5474 export function createGiftScene({ pack = null, collision = false, warnings: initialWarnings = [], reducedMotion = false, onAction, copyToClipboard } = {}) {5575 let preview = pack ? cloneData(pack) : null;
@@ -122,6 +142,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
122142 if (!open || !preview || !ctx) return null;123143 const width = options.width || ctx.canvas?.width || 800;124144 const height = options.height || ctx.canvas?.height || 600;145+ const motion = giftMotionFrame(options.sceneMotion, options.reducedMotion);125146 const narrow = width < 760;126147 const coarsePointer = options.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)').matches ?? false;127148 const touchLayout = narrow || coarsePointer;
@@ -133,6 +154,12 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
133154 const displayWarningLines = detailsOpen ? noteLines.slice(0, 5).flatMap(line => wrapText(ctx, line, cardWidth - 68, 9, 650)) : [];134155 noteButtons = [];135156 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);157+ const motionContext = typeof ctx.translate === 'function';158+ if (motionContext) {159+ ctx.save();160+ ctx.globalAlpha *= motion.alpha;161+ ctx.translate(0, motion.offsetY);162+ }136163 ctx.fillStyle = COLORS.lip; rounded(ctx, left + 8, top + 8, cardWidth, cardHeight, 5); ctx.fill(); ctx.fillStyle = COLORS.board; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.stroke();137164 text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });138165 const titleLabel = fitLabel(ctx, title, cardWidth - 64, 34, 850);
@@ -245,7 +272,10 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
245272 ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2 + offsetY, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2 + offsetY, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;246273 });247274 });248- return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };275+ const layout = { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };276+ if (motionContext) ctx.restore();277+ ctx.restore();278+ return layout;249279 }250280 const api = { show, hide, setPack, choose, input, hit, draw, accessibilityText, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; }, get warnings() { return cloneData(warnings); }, get detailsOpen() { return detailsOpen; } };251281 return Object.freeze(api);
M src/scenes/workshop.js +22 -0
@@ -43,6 +43,20 @@ function wrapText(ctx, value, width, size, weight = 700) {
4343 if (line) lines.push(line);4444 return lines.length ? lines : [''];4545 }46+function clampMotion(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }47+function easeOutCubic(value) {48+ const t = clampMotion(value);49+ return 1 - ((1 - t) ** 3);50+}51+export function workshopMotionFrame(sceneMotion = {}, reducedMotion = false) {52+ if (reducedMotion || sceneMotion.phase === 'settled') return { offsetY: 0, alpha: 1 };53+ if (sceneMotion.phase === 'exiting') {54+ const exit = clampMotion(sceneMotion.exitProgress);55+ return { offsetY: 16 * (exit ** 3), alpha: 1 - exit * 0.28 };56+ }57+ const entry = clampMotion(sceneMotion.entryProgress);58+ return { offsetY: -18 * (1 - easeOutCubic(entry)), alpha: 0.76 + easeOutCubic(entry) * 0.24 };59+}4660 function drawKeyHint(ctx, key, verb, right, y, colors, options = {}) {4761 ctx.font = `800 10px ${FONT}`;4862 const verbWidth = ctx.measureText(verb).width;
@@ -246,7 +260,14 @@ export function createWorkshopScene(options = {}) {
246260 const compact = width < 600;247261 const coarsePointer = dimensions.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false;248262 const noKeycaps = width < 760 || coarsePointer;263+ const motion = workshopMotionFrame(dimensions.sceneMotion, dimensions.reducedMotion);249264 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, width, height);265+ const motionContext = typeof ctx.save === 'function' && typeof ctx.translate === 'function';266+ if (motionContext) {267+ ctx.save();268+ ctx.globalAlpha *= motion.alpha;269+ ctx.translate(0, motion.offsetY);270+ }250271 const margin = compact ? 24 : 48;251272 drawText(ctx, 'WORKSHOP', margin, compact ? 30 : 48, 13, colors.muted, 'left', 800);252273 if (compact) {
@@ -341,6 +362,7 @@ export function createWorkshopScene(options = {}) {
341362 hitLayout.confirm = { choices: confirm.choices.map((choice, index) => ({ x: buttonsLeft + index * (buttonWidth + gap), y: cardY + 118, width: buttonWidth, height: 38 })) };342363 confirm.choices.forEach((choice, index) => { const cell = hitLayout.confirm.choices[index]; roundRect(ctx, cell.x, cell.y, cell.width, cell.height, 3); ctx.fillStyle = confirm.choice === index ? colors.YOU : colors.board; ctx.fill(); ctx.strokeStyle = confirm.choice === index ? colors.YOU : colors.edge; ctx.stroke(); drawText(ctx, choice, cell.x + cell.width / 2, cell.y + cell.height / 2, 10, confirm.choice === index ? colors.ink : colors.fg, 'center', 850); });343364 }365+ if (motionContext) ctx.restore();344366 return view();345367 }346368
M src/stage.js +27 -4
@@ -317,13 +317,36 @@ export function createStage(options = {}) {
317317 return;318318 }319319 if (scene === "gift" && gift) {320- gift.draw(ctx, visualNow(), { width: w, height: h, keycapPress, reducedMotion });320+ gift.draw(ctx, visualNow(), {321+ width: w,322+ height: h,323+ keycapPress,324+ reducedMotion,325+ sceneMotion: sceneMotionPhase({326+ now: visualNow(),327+ openedAt: sceneMotion.openedAt,328+ exitStartedAt: sceneMotion.scene === 'gift' ? sceneMotion.exitStartedAt : null,329+ reducedMotion,330+ }),331+ });321332 canvas.setAttribute("aria-label", gift.accessibilityText?.() || "Miau Is True Gift");322333 drawCopyConfirmation(ctx, w, h);323334 return;324335 }325336 if (scene === "workshop" && workshop) {326- workshop.draw(ctx, { width: w, height: h, keycapPress, now: visualNow(), reducedMotion });337+ workshop.draw(ctx, {338+ width: w,339+ height: h,340+ keycapPress,341+ now: visualNow(),342+ reducedMotion,343+ sceneMotion: sceneMotionPhase({344+ now: visualNow(),345+ openedAt: sceneMotion.openedAt,346+ exitStartedAt: sceneMotion.scene === 'workshop' ? sceneMotion.exitStartedAt : null,347+ reducedMotion,348+ }),349+ });327350 canvas.setAttribute("aria-label", workshop.accessibilityText?.() || "Miau Is True Workshop");328351 drawCopyConfirmation(ctx, w, h);329352 return;
@@ -462,7 +485,7 @@ export function createStage(options = {}) {
462485 : id === "workshop" && workshop?.accessibilityText ? workshop.accessibilityText()463486 : `${spec.eyebrow || ""} ${titleEl.textContent}`.trim());464487 render();465- if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause"))488+ if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause" || id === "gift" || id === "workshop"))466489 visualFrame(animateVisuals);467490 if (id === 'win' || id === 'pause') {468491 transitionStart = 0;
@@ -475,7 +498,7 @@ export function createStage(options = {}) {
475498 }476499 function show(id, data = {}) {477500 if (sceneMotion.phase === 'exiting') return api;478- const menuScene = scene === 'title' || scene === 'index';501+ const menuScene = scene === 'title' || scene === 'index' || scene === 'gift' || scene === 'workshop';479502 if (!data.instant && !reducedMotion && menuScene && id !== scene) {480503 const now = visualNow();481504 const generation = sceneMotion.generation + 1;
A tests/menu-scene-motion.test.js +29 -0