clone

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

Polish Win card states and touch actions

Dami committed bad5e49
commit
bad5e49422c7a67ec95afc0811a2eb7575acd182
parent
c0076558d0e0bf0481d8ccb2d5663154b0340440
author
Dami <dami.etoile@gmail.com>
3 files changed, +118 -18
Msrc/render/stage-hud.js7++-
Msrc/render/stage-win.js94+++++++++++++++++++++++++-----
Mtests/stage-win.test.js35+++++++++++-
M src/render/stage-hud.js +5 -2
M src/render/stage-win.js +79 -15
@@ -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;
M tests/stage-win.test.js +34 -1
@@ -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+});