clone

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

Model Win skip and exit as explicit scene phases

Dami committed a8473dd
commit
a8473dd3207838c6d4b01f13ad8840954882a5d4
parent
490926fb4b7841f792f68f2d622d2b94e6e9cd7a
author
Dami <dami.etoile@gmail.com>
2 files changed, +134 -0
Asrc/stage/win-flow.js66+++++++++++++++++++++++++++++
Atests/win-flow.test.js68++++++++++++++++++++++++++++++
A src/stage/win-flow.js +66 -0
@@ -0,0 +1,66 @@
1+import { WIN_TIMING } from '../render/stage-win.js';2+3+// The card starts after the winning-cell ring, and its action row is the last4+// entrance beat. A confirmation before that beat settles the card once.5+export const WIN_ENTRANCE_END = WIN_TIMING.cardDelay + WIN_TIMING.actions + 200;6+7+/** Scene intent state; the engine remains the sole owner of the won result. */8+export function createWinFlow({ clock, reducedMotion = false, onChange = () => {}, onContinue = () => {} } = {}) {9+ if (!clock || typeof clock.now !== 'function' || typeof clock.setTimeout !== 'function')10+ throw new TypeError('Win flow requires a presentation clock');11+ let start = 0;12+ let skip = false;13+ let exitStart = 0;14+ let exitTimer = null;15+ let active = false;16+ let generation = 0;17+18+ function cancel() {19+ generation += 1;20+ if (exitTimer !== null) clock.clearTimeout(exitTimer);21+ exitTimer = null;22+ active = false;23+ exitStart = 0;24+ }25+26+ function enter({ instant = false } = {}) {27+ cancel();28+ active = true;29+ start = clock.now();30+ skip = Boolean(instant || reducedMotion);31+ onChange();32+ }33+34+ function confirm() {35+ if (!active) return false;36+ if (exitStart) return true;37+ if (!skip && !reducedMotion && clock.now() - start < WIN_ENTRANCE_END) {38+ skip = true;39+ onChange();40+ return true;41+ }42+ exitStart = clock.now();43+ if (reducedMotion) {44+ cancel();45+ onContinue();46+ return true;47+ }48+ const thisGeneration = generation;49+ onChange();50+ exitTimer = clock.setTimeout(() => {51+ if (generation !== thisGeneration || !active) return;52+ cancel();53+ onContinue();54+ }, WIN_TIMING.exit);55+ return true;56+ }57+58+ return Object.freeze({59+ enter,60+ confirm,61+ cancel,62+ get view() { return { winStart: start, winSkip: skip, winExitStart: exitStart }; },63+ get active() { return active; },64+ get exiting() { return Boolean(exitStart); },65+ });66+}
A tests/win-flow.test.js +68 -0
@@ -0,0 +1,68 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createWinFlow, WIN_ENTRANCE_END } from '../src/stage/win-flow.js';4+import { WIN_TIMING } from '../src/render/stage-win.js';5+6+function harness(reducedMotion = false) {7+ let now = 100;8+ let pending = null;9+ const changes = [];10+ let continues = 0;11+ const clock = {12+ now: () => now,13+ setTimeout(callback, delay) { pending = { callback, due: now + delay }; return 1; },14+ clearTimeout() { pending = null; },15+ };16+ const flow = createWinFlow({ clock, reducedMotion, onChange: () => changes.push(now), onContinue: () => { continues += 1; } });17+ return {18+ flow, changes,19+ setTime(value) { now = value; if (pending && now >= pending.due) { const task = pending; pending = null; task.callback(); } },20+ get continues() { return continues; },21+ };22+}23+24+test('first confirmation during entrance settles Win, second exits once', () => {25+ const h = harness();26+ h.flow.enter();27+ h.setTime(100 + WIN_ENTRANCE_END - 1);28+ assert.equal(h.flow.confirm(), true);29+ assert.equal(h.flow.view.winSkip, true);30+ assert.equal(h.continues, 0);31+ assert.equal(h.flow.confirm(), true);32+ assert.equal(h.flow.exiting, true);33+ assert.equal(h.flow.confirm(), true);34+ h.setTime(100 + WIN_ENTRANCE_END - 1 + WIN_TIMING.exit);35+ assert.equal(h.continues, 1);36+});37+38+test('settled or initially won cards continue with one confirmation', () => {39+ const h = harness();40+ h.flow.enter();41+ h.setTime(100 + WIN_ENTRANCE_END);42+ h.flow.confirm();43+ h.setTime(100 + WIN_ENTRANCE_END + WIN_TIMING.exit);44+ assert.equal(h.continues, 1);45+ h.flow.enter({ instant: true });46+ assert.equal(h.flow.view.winSkip, true);47+ h.flow.confirm();48+ h.setTime(100 + WIN_ENTRANCE_END + WIN_TIMING.exit * 2);49+ assert.equal(h.continues, 2);50+});51+52+test('cancelling an exit prevents stale INDEX transition after Undo', () => {53+ const h = harness();54+ h.flow.enter({ instant: true });55+ h.flow.confirm();56+ h.flow.cancel();57+ h.setTime(1000);58+ assert.equal(h.continues, 0);59+ assert.equal(h.flow.active, false);60+});61+62+test('reduced motion continues immediately from its settled card', () => {63+ const h = harness(true);64+ h.flow.enter();65+ assert.equal(h.flow.view.winSkip, true);66+ h.flow.confirm();67+ assert.equal(h.continues, 1);68+});