- commit
- bad5e49422c7a67ec95afc0811a2eb7575acd182
- parent
- c0076558d0e0bf0481d8ccb2d5663154b0340440
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 7 | ++- |
| M | src/ | 94 | +++++++++++++++++++++++++----- |
| M | tests/ | 35 | +++++++++++- |
@@ -84,7 +84,7 @@ export function drawStepCounter(ctx, value, x, y, options = {}) {
8484 return frame;8585 }8686 87-export function drawInlineActions(ctx, items, centerX, y) {87+export function drawInlineActions(ctx, items, centerX, y, options = {}) {8888 ctx.save();8989 ctx.font = "800 10px Recursive, monospace";9090 const metrics = items.map(({ key, label }) => {@@ -106,7 +106,10 @@ export function drawInlineActions(ctx, items, centerX, y) {
106106 gap * (metrics.length - 1);107107 let x = centerX - total / 2;108108 for (const item of metrics) {109- drawKeycap(ctx, item.key, item.label, x, y);109+ drawKeycap(ctx, item.key, item.label, x, y, 'left', {110+ ...options,111+ action: item.action || item.label,112+ });110113 x += item.width + gap;111114 }112115 ctx.restore();@@ -24,6 +24,18 @@ export const WIN_TIMING = Object.freeze({
2424 exit: 170,2525 });2626 27+export const WIN_CARD = Object.freeze({28+ widthMax: 620,29+ desktopHeight: 290,30+ mobileHeight: 330,31+ mobileBreakpoint: 760,32+ mobileInset: 24,33+ mobileContinueTop: 218,34+ mobileContinueHeight: 48,35+ mobileSecondaryTop: 278,36+ mobileSecondaryHeight: 44,37+});38+2739 function clamp(value, min = 0, max = 1) {2840 return Math.max(min, Math.min(max, value));2941 }@@ -54,6 +66,7 @@ function settledFrame(tileCount, exitProgress = 0) {
5466 ringProgress: 1,5567 cardProgress: 1,5668 cardScale: 1,69+ cardAlpha: 1,5770 cardOffset: 48 * easeIn(exitProgress),5871 tileProgress: Array.from({ length: tileCount }, () => 1),5972 barProgress: 1,@@ -109,6 +122,7 @@ export function winMotionFrame(elapsedOrOptions = {}, options = {}) {
109122 ringProgress: phase(frameNow, started, WIN_TIMING.ring),110123 cardProgress: phase(frameNow, cardStart, WIN_TIMING.slam),111124 cardScale: 1.05 - 0.05 * easeOut(phase(frameNow, cardStart, WIN_TIMING.slam)),125+ cardAlpha: phase(frameNow, cardStart, 80),112126 cardOffset: 0,113127 tileProgress: Array.from({ length: count }, (_, index) =>114128 phase(frameNow, cardStart + (WIN_TIMING.stamps[index] ?? WIN_TIMING.stamps.at(-1) + (index - 2) * 70), WIN_TIMING.stamp),@@ -154,6 +168,51 @@ function sentenceFor(boardState, winningRule) {
154168 return boardState?.rules?.find(rule => String(rule.predicate).toUpperCase() === 'WIN')?.text || 'MIAU IS WIN';155169 }156170 171+/** Shared card and touch-target geometry for the renderer and stage events. */172+export function winCardLayout({ width, height, cardY = Math.max(120, height * 0.25), coarsePointer = false } = {}) {173+ const mobile = width <= WIN_CARD.mobileBreakpoint || coarsePointer;174+ const cardWidth = Math.min(WIN_CARD.widthMax, Math.max(1, width - 48));175+ const cardX = (width - cardWidth) / 2;176+ const cardHeight = mobile ? WIN_CARD.mobileHeight : WIN_CARD.desktopHeight;177+ const card = { x: cardX, y: cardY, width: cardWidth, height: cardHeight };178+ if (!mobile) return { mobile, card, continue: null, undo: null, replay: null };179+ const innerWidth = Math.max(1, cardWidth - WIN_CARD.mobileInset * 2);180+ const continueButton = {181+ x: cardX + WIN_CARD.mobileInset,182+ y: cardY + WIN_CARD.mobileContinueTop,183+ width: innerWidth,184+ height: WIN_CARD.mobileContinueHeight,185+ };186+ const secondaryWidth = Math.max(1, (innerWidth - 16) / 2);187+ return {188+ mobile,189+ card,190+ continue: continueButton,191+ undo: { x: continueButton.x, y: cardY + WIN_CARD.mobileSecondaryTop, width: secondaryWidth, height: WIN_CARD.mobileSecondaryHeight },192+ replay: { x: continueButton.x + secondaryWidth + 16, y: cardY + WIN_CARD.mobileSecondaryTop, width: secondaryWidth, height: WIN_CARD.mobileSecondaryHeight },193+ };194+}195+196+function contains(rect, x, y) {197+ return Boolean(rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height);198+}199+200+/** Return the semantic Win pointer target, or null outside the card/actions. */201+export function winPointerTarget({ width, height, x, y, coarsePointer = false } = {}) {202+ const layout = winCardLayout({ width, height, coarsePointer });203+ if (!contains(layout.card, x, y)) return null;204+ if (!layout.mobile) return 'card';205+ if (contains(layout.continue, x, y)) return 'continue';206+ if (contains(layout.undo, x, y)) return 'undo';207+ if (contains(layout.replay, x, y)) return 'replay';208+ return 'card';209+}210+211+export function winStepLabel(steps, countProgress) {212+ const value = Math.round(Number(steps) * easeOut(countProgress));213+ return `PROVEN IN ${value} ${value === 1 && countProgress >= 1 ? 'STEP' : 'STEPS'}`;214+}215+157216 function drawRing(ctx, geometry, cell, progress) {158217 if (!cell || progress >= 1) return;159218 const q = easeOut(progress);@@ -171,15 +230,16 @@ function drawRing(ctx, geometry, cell, progress) {
171230 172231 function drawCard(ctx, view, frame, winningRule) {173232 const { width: w, height: h } = view;174- const cardWidth = Math.min(620, w - 48);175- const cardX = (w - cardWidth) / 2;176- const cardHeight = 290;177233 const baseY = Math.max(120, h * 0.25);178234 const slam = easeBack(frame.cardProgress);179235 const cardY = baseY + (1 - slam) * -56 + frame.cardOffset;236+ const layout = winCardLayout({ width: w, height: h, cardY, coarsePointer: view.coarsePointer ?? false });237+ const { cardWidth, cardX, cardHeight } = { cardWidth: layout.card.width, cardX: layout.card.x, cardHeight: layout.card.height };180238 const opacity = 1 - frame.exitProgress;181239 ctx.save();182- ctx.globalAlpha = opacity * Math.min(1, frame.cardProgress * 3);240+ // The face, lip, and signal stroke are one physical card and share this241+ // alpha. It reaches full ink in 80ms, rather than fading each layer apart.242+ ctx.globalAlpha = opacity * frame.cardAlpha;183243 ctx.translate(w / 2, cardY + cardHeight / 2);184244 ctx.scale(frame.cardScale, frame.cardScale);185245 ctx.translate(-w / 2, -(cardY + cardHeight / 2));@@ -219,8 +279,7 @@ function drawCard(ctx, view, frame, winningRule) {
219279 ctx.textBaseline = 'middle';220280 ctx.fillStyle = '#F2EEE6';221281 const steps = Number(view.boardState?.moves || view.steps || 0);222- const shown = Math.round(steps * easeOut(frame.countProgress));223- const headline = `PROVEN IN ${shown} STEPS`;282+ const headline = winStepLabel(steps, frame.countProgress);224283 let headlineSize = Math.min(44, Math.max(26, w * 0.055));225284 do {226285 ctx.font = `800 ${headlineSize}px Recursive, monospace`;@@ -232,16 +291,21 @@ function drawCard(ctx, view, frame, winningRule) {
232291 ctx.globalAlpha *= frame.actionsProgress;233292 ctx.fillStyle = '#8E877C';234293 ctx.font = '600 16px Recursive, monospace';235- ctx.fillText(`${view.boardState?.completedCount ?? 0} of ${view.boardState?.proofTotal ?? 9} proofs complete`, w / 2, cardY + 190);294+ ctx.fillText(`${view.boardState?.completedCount ?? 0} of ${view.boardState?.proofTotal ?? 9} proofs complete`, w / 2, cardY + 202);236295 ctx.globalAlpha *= frame.actionsProgress;237296 const coarsePointer = view.coarsePointer ?? false;238- if (w <= 760 || coarsePointer) {239- const actionY = cardY + 250;240- drawStackedAction(ctx, 'ENTER', 'continue', cardX + cardWidth / 6, actionY);241- drawStackedAction(ctx, 'Z', 'undo', cardX + cardWidth / 2, actionY);242- drawStackedAction(ctx, 'R', 'replay', cardX + (cardWidth * 5) / 6, actionY);297+ if (layout.mobile) {298+ ctx.fillStyle = '#2EE6A8';299+ ctx.fillRect(layout.continue.x, layout.continue.y, layout.continue.width, layout.continue.height);300+ ctx.fillStyle = '#0F0E0D';301+ ctx.font = '800 13px Recursive, monospace';302+ ctx.fillText('CONTINUE', layout.continue.x + layout.continue.width / 2, layout.continue.y + layout.continue.height / 2);303+ ctx.fillStyle = '#F2EEE6';304+ ctx.font = '800 12px Recursive, monospace';305+ ctx.fillText('UNDO', layout.undo.x + layout.undo.width / 2, layout.undo.y + layout.undo.height / 2);306+ ctx.fillText('REPLAY', layout.replay.x + layout.replay.width / 2, layout.replay.y + layout.replay.height / 2);243307 } else if (!coarsePointer) {244- drawInlineActions(ctx, [{ key: 'ENTER', label: 'continue' }, { key: 'Z', label: 'undo' }, { key: 'R', label: 'replay' }], w / 2, cardY + 250);308+ drawInlineActions(ctx, [{ key: 'ENTER', label: 'continue', action: 'confirm' }, { key: 'Z', label: 'undo', action: 'undo' }, { key: 'R', label: 'replay', action: 'restart' }], w / 2, cardY + 250, { now: view.now, keycapPress: view.keycapPress, reducedMotion: view.reducedMotion });245309 }246310 ctx.restore();247311 }@@ -262,12 +326,12 @@ export function renderWinScene(ctx, view = {}) {
262326 }263327 const frame = winMotionFrame({ now: view.now ?? 0, winStart: view.winStart ?? 0, winSkip: view.winSkip ?? false, winExitStart: view.winExitStart ?? 0, reducedMotion, tileCount: 3 });264328 const opacity = 1 - frame.exitProgress;265- ctx.fillStyle = `rgba(0,0,0,${0.66 * opacity * Math.min(1, frame.cardProgress * 2)})`;329+ ctx.fillStyle = `rgba(0,0,0,${0.66 * opacity * frame.cardAlpha})`;266330 ctx.fillRect(0, 0, w, h);267331 if (geometry) drawRing(ctx, geometry, winningCell(boardState, boardGame, view.winningCell), frame.ringProgress);268332 drawCard(ctx, view, frame, view.winningRule);269333 ctx.textAlign = 'left';270- return { frame, geometry, winningCell: winningCell(boardState, boardGame, view.winningCell) };334+ return { frame, geometry, winningCell: winningCell(boardState, boardGame, view.winningCell), layout: winCardLayout({ width: w, height: h, coarsePointer: view.coarsePointer ?? false }) };271335 }272336 273337 export default renderWinScene;@@ -1,6 +1,12 @@
11 import test from 'node:test';22 import assert from 'node:assert/strict';3-import { WIN_TIMING, winMotionFrame } from '../src/render/stage-win.js';3+import {4+ WIN_TIMING,5+ winCardLayout,6+ winMotionFrame,7+ winPointerTarget,8+ winStepLabel,9+} from '../src/render/stage-win.js';410 511 test('win timeline rings first and delays card choreography', () => {612 const start = 1000;@@ -58,3 +64,30 @@ test('win timeline also accepts stage-friendly elapsed and exit elapsed values',
5864 const exiting = winMotionFrame(0, { exitElapsed: WIN_TIMING.exit });5965 assert.equal(exiting.exitProgress, 1);6066 });67+68+test('win card ink reaches full alpha within the card slam beat', () => {69+ const start = 1000;70+ const cardStart = start + WIN_TIMING.cardDelay;71+ assert.equal(winMotionFrame({ now: cardStart, winStart: start }).cardAlpha, 0);72+ assert.equal(winMotionFrame({ now: cardStart + 80, winStart: start }).cardAlpha, 1);73+});74+75+test('win headline keeps plural while counting and uses singular once settled', () => {76+ assert.equal(winStepLabel(1, 0.5), 'PROVEN IN 1 STEPS');77+ assert.equal(winStepLabel(1, 1), 'PROVEN IN 1 STEP');78+ assert.equal(winStepLabel(0, 1), 'PROVEN IN 0 STEPS');79+ assert.equal(winStepLabel(2, 1), 'PROVEN IN 2 STEPS');80+});81+82+test('mobile Win card exposes full-width continue and 44px secondary targets', () => {83+ const layout = winCardLayout({ width: 390, height: 844 });84+ assert.equal(layout.mobile, true);85+ assert.equal(layout.continue.height, 48);86+ assert.equal(layout.continue.width, layout.card.width - 48);87+ assert.equal(layout.undo.height, 44);88+ assert.equal(layout.replay.height, 44);89+ assert.equal(winPointerTarget({ width: 390, height: 844, x: 4, y: 4 }), null);90+ assert.equal(winPointerTarget({ width: 390, height: 844, x: layout.continue.x + 10, y: layout.continue.y + 10 }), 'continue');91+ assert.equal(winPointerTarget({ width: 390, height: 844, x: layout.undo.x + 10, y: layout.undo.y + 10 }), 'undo');92+ assert.equal(winPointerTarget({ width: 390, height: 844, x: layout.replay.x + 10, y: layout.replay.y + 10 }), 'replay');93+});