- commit
- ebb2c834c3b46942dfb3a2053317e0438e73cb8a
- parent
- a8473dd3207838c6d4b01f13ad8840954882a5d4
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 15 | ++++++++ |
| A | src/ | 57 | ++++++++++++++++++++++++++++++ |
| M | tests/ | 43 | +++++++++++++++++++++++ |
@@ -14,6 +14,7 @@ import {
1414 destructionEffect,1515 transformEffect,1616 } from './juice/effects.js';17+import { projectLedgerEntry } from './juice/ledger.js';1718 1819 const MOVE_MS = 90;1920 const UNDO_MS = 70;@@ -46,6 +47,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
4647 const entities = new Map();4748 const rules = new Map();4849 const sentences = new Map();50+ const ledger = new Map();4951 const automataPhases = new Map();5052 const automataTransitions = new Map();5153 let priorSentenceCells = new Map();@@ -111,6 +113,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
111113 entities.clear();112114 rules.clear();113115 sentences.clear();116+ ledger.clear();114117 automataPhases.clear();115118 automataTransitions.clear();116119 facingHistory.length = 0;@@ -149,6 +152,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
149152 return object ? cellOf(object) : null;150153 }).filter(Boolean));151154 if (!sentences.has(key)) sentences.set(key, { key, ids: [...normalized.ids], axis: normalized.axis, phase: 'active', start: at, holdUntil: at, duration: 0, frozenCells: null });155+ if (!ledger.has(key)) ledger.set(key, { key, ids: [...normalized.ids], phase: 'active', start: at });152156 }153157 const live = new Set();154158 for (const object of state.objects) {@@ -243,6 +247,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
243247 duration: duration(RULE_FORM_MS),244248 frozenCells: null,245249 });250+ ledger.set(key, { key, ids: [...(sentence.ids || [])], phase: 'stamping', start });246251 for (const id of sentence.ids || []) {247252 const entity = entityAt(id);248253 entity.bounceStart = start;@@ -270,6 +275,13 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
270275 duration: duration(RULE_REMOVE_MS),271276 frozenCells: priorSentenceCells.get(key)?.map(cell => ({ ...cell })) || prior?.frozenCells || [],272277 });278+ const ledgerEntry = ledger.get(key);279+ ledger.set(key, {280+ key,281+ ids: [...(sentence.ids || ledgerEntry?.ids || [])],282+ phase: 'removing',283+ start: at,284+ });273285 }274286 275287 /** Consume one settled result returned by step(), undo(), or reset(). */@@ -489,6 +501,9 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
489501 positions,490502 rules: visibleRules,491503 sentences: visibleSentences,504+ ledger: [...ledger.values()]505+ .map(entry => projectLedgerEntry(entry, at))506+ .filter(entry => entry.visible),492507 effects: effects(at),493508 automata,494509 shake: globalShake && globalShake.duration@@ -0,0 +1,57 @@
1+/**2+ * Pure presentation math for the physical rule ledger.3+ *4+ * The ledger uses the same physical sentence ids as the board. Keeping this5+ * projection separate lets a renderer draw the tiles without owning a second6+ * copy of rule timing or guessing which duplicate sentence changed.7+ */8+9+const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));10+11+export const LEDGER_TIMINGS = Object.freeze({12+ stamp: 90,13+ tileStagger: 70,14+ strike: 140,15+ exit: 160,16+});17+18+const stampProgress = (at, start, index, timings) => {19+ const elapsed = at - start - index * timings.tileStagger;20+ return elapsed <= 0 ? 0 : clamp(elapsed / timings.stamp);21+};22+23+/**24+ * Project one ledger entry at a presentation timestamp.25+ *26+ * `entry.phase` is either `stamping`, `active`, or `removing`. Removing27+ * entries remain available through the strike and exit so a renderer can28+ * show the physical sentence leaving the ledger instead of replacing it.29+ */30+export function projectLedgerEntry(entry, at, timings = LEDGER_TIMINGS) {31+ const ids = [...(entry.ids || [])];32+ if (entry.phase === 'removing') {33+ const strikeProgress = clamp((at - entry.start) / timings.strike);34+ const exitProgress = clamp((at - (entry.start + timings.strike)) / timings.exit);35+ return {36+ key: entry.key,37+ ids,38+ phase: exitProgress >= 1 ? 'exited' : 'removing',39+ stampProgress: ids.map(() => 1),40+ strikeProgress,41+ exitProgress,42+ visible: exitProgress < 1,43+ };44+ }45+ const progresses = ids.map((_, index) => stampProgress(at, entry.start, index, timings));46+ const complete = progresses.every(progress => progress >= 1);47+ return {48+ key: entry.key,49+ ids,50+ phase: complete ? 'active' : 'stamping',51+ stampProgress: progresses,52+ strikeProgress: 0,53+ exitProgress: 0,54+ visible: true,55+ };56+}57+@@ -21,6 +21,26 @@ test('projectEntityVisual keeps presentation math pure and isolated from juice s
2121 assert.ok(visual.glyphScaleX < 0.6);2222 });2323 24+test('pushed word visuals skid only during horizontal travel', () => {25+ const pushed = projectEntityVisual({26+ id: 'word-you', fromX: 0, fromY: 0, x: 1, y: 0, start: 0, duration: 90,27+ pushed: true, dx: 1, dy: 0, facing: 'right', popDuration: 0, bounceDuration: 0,28+ }, { at: 45 });29+ assert.ok(Math.abs(pushed.skid - 0.09) < 1e-9);30+ assert.equal(projectEntityVisual({31+ id: 'word-you', fromX: 0, fromY: 0, x: 1, y: 0, start: 0, duration: 90,32+ pushed: false, dx: 1, dy: 0, facing: 'right', popDuration: 0, bounceDuration: 0,33+ }, { at: 45 }).skid, 0);34+ assert.equal(projectEntityVisual({35+ id: 'word-you', fromX: 0, fromY: 0, x: 0, y: 1, start: 0, duration: 90,36+ pushed: true, dx: 0, dy: 1, facing: 'down', popDuration: 0, bounceDuration: 0,37+ }, { at: 45 }).skid, 0);38+ assert.equal(projectEntityVisual({39+ id: 'word-you', fromX: 0, fromY: 0, x: 1, y: 0, start: 0, duration: 90,40+ pushed: true, dx: 1, dy: 0, facing: 'right', popDuration: 0, bounceDuration: 0,41+ }, { at: 90 }).skid, 0);42+});43+2444 test('juice effect builders preserve settled coordinates and reduced motion timing', () => {2545 const death = destructionEffect({ cause: 'sink', noun: 'MIAU', at: { x: 2, y: 3 } }, 10, 200);2646 assert.deepEqual(death.cell, { x: 2, y: 3 });@@ -237,6 +257,29 @@ test('physical sentence bars use ids and freeze removed pre-move cells', () => {
237257 assert.deepEqual(bar.frozenCells.map(cell => [cell.x, cell.y]), [[0, 0], [1, 0], [2, 0]]);238258 });239259 260+test('physical ledger sentences stamp by tile and strike before exiting', () => {261+ const juice = createJuice({ now: () => 0 });262+ const empty = { objects: [object('a', 0, 0), object('b', 1, 0)], sentences: [] };263+ const formed = { ...empty, sentences: [{ ids: ['a', 'b'], axis: 'h' }] };264+ juice.syncState(empty, 0);265+ juice.consume({266+ kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],267+ }, 0, formed);268+ assert.deepEqual(juice.frame(90).ledger[0].stampProgress, [0, 0]);269+ assert.ok(juice.frame(180).ledger[0].stampProgress[0] > 0);270+ assert.equal(juice.frame(250).ledger[0].stampProgress[0], 1);271+ assert.ok(juice.frame(250).ledger[0].stampProgress[1] > 0);272+273+ juice.syncState(formed, 300);274+ juice.consume({275+ kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],276+ }, 300, empty);277+ assert.equal(juice.frame(300).ledger[0].phase, 'removing');278+ assert.equal(juice.frame(440).ledger[0].strikeProgress, 1);279+ assert.ok(juice.frame(440).ledger[0].exitProgress < 1);280+ assert.equal(juice.frame(600).ledger.length, 0);281+});282+240283 test('engine push breaking a sentence freezes its pre-push cells', () => {241284 const game = createGame({242285 width: 5,