clone

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

feat(juice): animate physical sentences and turn effects

Dami committed bae5dd7
commit
bae5dd768fa1e3ad7d6dcc104634747ccdf7a40a
parent
df2fd0476045f9e19f3241a5b2c6fa27b1604505
author
Dami <dami.etoile@gmail.com>
2 files changed, +184 -13
Msrc/juice.js161++++++++++++++++++++++++++++--
Mtests/juice.test.js36+++++++
M src/juice.js +148 -13
@@ -14,12 +14,21 @@ const RULE_FORM_MS = 170;
1414 const RULE_REMOVE_MS = 140;1515 const BLOCKED_MS = 130;1616 const FRAME_MS = 16;17+const BOUNCE_MS = 200;18+const POP_MS = 240;19+const EFFECT_MS = 200;20+const TINT_MS = 140;21+const WIPE_MS = 260;22+const WIN_BEAT_MS = 260;1723 1824 const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));1925 const easeOut = value => 1 - ((1 - value) ** 3);2026 const smooth = value => value * value * (3 - 2 * value);27+const easeBack = value => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * ((value - 1) ** 3) + c1 * ((value - 1) ** 2); };2128 const asNumber = value => Number.isFinite(value) ? value : 0;2229 const keyForRule = rule => `${rule?.text || ''}|${(rule?.sourceIds || []).join(',')}|${rule?.axis || ''}`;30+const keyForSentence = sentence => (sentence?.ids || []).join('>');31+const cellOf = object => ({ id: object.id, x: asNumber(object.x), y: asNumber(object.y) });2332 2433 /**2534 * @param {{ reducedMotion?: boolean, now?: () => number }} options
@@ -27,8 +36,14 @@ const keyForRule = rule => `${rule?.text || ''}|${(rule?.sourceIds || []).join('
2736 export function createJuice({ reducedMotion = false, now = () => performance.now() } = {}) {2837 const entities = new Map();2938 const rules = new Map();39+ const sentences = new Map();40+ let priorSentenceCells = new Map();41+ let priorSentences = new Map();3042 let globalShake = null;3143 let blocked = null;44+ let tint = null;45+ let wipe = null;46+ let win = null;3247 let currentReducedMotion = Boolean(reducedMotion);3348 3449 const duration = value => currentReducedMotion ? 0 : value;
@@ -75,13 +90,6 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
7590 }7691 globalShake = null;7792 blocked = null;78- for (const animation of rules.values()) {79- if (animation.phase === 'removing') animation.progress = 0;80- animation.phase = 'active';81- animation.start = at;82- animation.holdUntil = at;83- animation.progress = 1;84- }8593 }8694 8795 /**
@@ -96,6 +104,18 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
96104 /** Seed or reconcile visual positions from the settled engine state. */97105 function syncState(state, at = readNow()) {98106 if (!state || !Array.isArray(state.objects)) return;107+ priorSentenceCells = new Map();108+ priorSentences = new Map();109+ for (const sentence of state.sentences || []) {110+ const normalized = { ids: [...(sentence.ids || [])], axis: sentence.axis };111+ const key = keyForSentence(normalized);112+ priorSentences.set(key, normalized);113+ priorSentenceCells.set(key, normalized.ids.map(id => {114+ const object = state.objects.find(candidate => candidate.id === id);115+ return object ? cellOf(object) : null;116+ }).filter(Boolean));117+ if (!sentences.has(key)) sentences.set(key, { key, ids: [...normalized.ids], axis: normalized.axis, phase: 'active', start: at, holdUntil: at, duration: 0, frozenCells: null });118+ }99119 const live = new Set();100120 for (const object of state.objects) {101121 live.add(object.id);
@@ -167,8 +187,51 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
167187 });168188 }169189 190+ function consumeSentenceAdded(sentence, at, holdUntil, predicate = 'ink') {191+ const key = keyForSentence(sentence);192+ const start = holdUntil;193+ sentences.set(key, {194+ key,195+ ids: [...(sentence.ids || [])],196+ axis: sentence.axis,197+ predicate,198+ phase: start > at ? 'holding' : 'forming',199+ start,200+ holdUntil,201+ duration: duration(RULE_FORM_MS),202+ frozenCells: null,203+ });204+ for (const id of sentence.ids || []) {205+ const entity = entityAt(id);206+ entity.bounceStart = start;207+ entity.bounceDuration = duration(BOUNCE_MS);208+ }209+ const lastId = sentence.ids?.at(-1);210+ if (lastId) {211+ const cell = priorSentenceCells.get(key)?.at(-1) || entityAt(lastId);212+ const color = predicate;213+ const effect = { type: 'spark', text: '✦', cell: { x: asNumber(cell.x), y: asNumber(cell.y) }, color, role: color, t0: start, start, duration: duration(EFFECT_MS) };214+ entityAt(`__spark__${key}`).effect = effect;215+ }216+ }217+218+ function consumeSentenceRemoved(sentence, at) {219+ const key = keyForSentence(sentence);220+ const prior = sentences.get(key);221+ sentences.set(key, {222+ key,223+ ids: [...(sentence.ids || [])],224+ axis: sentence.axis,225+ phase: 'removing',226+ start: at,227+ holdUntil: at,228+ duration: duration(RULE_REMOVE_MS),229+ frozenCells: priorSentenceCells.get(key)?.map(cell => ({ ...cell })) || prior?.frozenCells || [],230+ });231+ }232+170233 /** Consume one settled result returned by step(), undo(), or reset(). */171- function consume(result, at = readNow()) {234+ function consume(result, at = readNow(), state = null) {172235 if (!result || typeof result !== 'object') return;173236 const accepted = Boolean(result.accepted);174237 if (!accepted) {
@@ -192,17 +255,49 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
192255 if (result.kind === 'reset') {193256 snap(at);194257 blocked = null;258+ sentences.clear();259+ rules.clear();260+ wipe = { start: at, duration: duration(WIPE_MS) };261+ tint = null;262+ win = null;195263 return;196264 }197265 198266 const moveDuration = duration(result.kind === 'undo' ? UNDO_MS : MOVE_MS);199267 for (const entry of result.moved || []) consumeMovement(entry, at, moveDuration);200268 269+ if (result.kind === 'undo') tint = { start: at, duration: duration(TINT_MS) };270+ if (result.restored || result.kind === 'undo') {271+ for (const entry of result.moved || []) {272+ const entity = entityAt(entry.id);273+ entity.popStart = at;274+ entity.popDuration = duration(POP_MS);275+ }276+ }277+201278 const ruleChanged = (result.rulesAdded?.length || result.rulesRemoved?.length);202279 const holdUntil = at + (ruleChanged ? duration(RULE_HOLD_MS) : 0) + moveDuration;203280 for (const rule of result.rulesAdded || []) consumeRuleAdded(rule, at, holdUntil);204281 for (const rule of result.rulesRemoved || []) consumeRuleRemoved(rule, at);205282 283+ const nextSentences = state && Array.isArray(state.sentences)284+ ? new Map(state.sentences.map(sentence => [keyForSentence(sentence), sentence]))285+ : null;286+ if (nextSentences) {287+ for (const sentence of nextSentences.values()) {288+ const key = keyForSentence(sentence);289+ if (!priorSentences.has(key)) {290+ const rule = (result.rulesAdded || []).find(candidate => (candidate.sourceIds || []).join('>') === key);291+ consumeSentenceAdded(sentence, at, holdUntil, rule?.predicate || 'ink');292+ }293+ else if (!sentences.has(key)) sentences.set(key, { key, ids: [...sentence.ids], axis: sentence.axis, phase: 'active', start: at, holdUntil: at, duration: 0, frozenCells: null });294+ }295+ for (const sentence of priorSentences.values()) {296+ const key = keyForSentence(sentence);297+ if (!nextSentences.has(key)) consumeSentenceRemoved(sentence, at);298+ }299+ }300+206301 // Effects stay available for the renderer at their settled coordinates.207302 // Their text and cause are copied so a destroyed entity need not exist in208303 // the next engine state.
@@ -213,10 +308,22 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
213308 text: destruction.noun || destruction.text || '',214309 x: asNumber(destruction.at?.x ?? destruction.at?.[0]),215310 y: asNumber(destruction.at?.y ?? destruction.at?.[1]),311+ cell: { x: asNumber(destruction.at?.x ?? destruction.at?.[0]), y: asNumber(destruction.at?.y ?? destruction.at?.[1]) },312+ color: destruction.cause === 'sink' ? 'SINK' : 'DEFEAT',313+ role: destruction.cause === 'sink' ? 'SINK' : 'DEFEAT',216314 start: at,217- duration: duration(destruction.cause === 'sink' ? 200 : 200),315+ t0: at,316+ duration: duration(EFFECT_MS),218317 };318+ if (!currentReducedMotion) globalShake = { start: at, duration: EFFECT_MS, amplitude: 4 };319+ }320+ for (const transform of result.transformed || []) {321+ const entity = entityAt(transform.id);322+ entity.popStart = at;323+ entity.popDuration = duration(POP_MS);324+ entity.effect = { type: 'transform', text: transform.from || '', cell: { x: entity.x, y: entity.y }, color: 'fg', role: 'fg', start: at, t0: at, duration: duration(EFFECT_MS) };219325 }326+ if (result.won && result.kind === 'step') win = { beatStart: at + moveDuration, cardAt: at + moveDuration + WIN_BEAT_MS };220327 }221328 222329 function entityVisual(id, at = readNow()) {
@@ -235,12 +342,16 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
235342 shakeY = blocked.dir?.[1] * amount || 0;236343 }237344 const squash = currentReducedMotion || progress >= 1 ? 0 : Math.sin(progress * Math.PI) * (entity.pushed ? 0.05 : 0.10);345+ const popProgress = entity.popDuration ? clamp((at - entity.popStart) / entity.popDuration) : 1;346+ const bounceProgress = entity.bounceDuration ? clamp((at - entity.bounceStart) / entity.bounceDuration) : 1;347+ const popScale = entity.popDuration ? 0.55 + 0.45 * easeBack(popProgress) : 1;348+ const bounceScale = entity.bounceDuration ? 1 + Math.sin(bounceProgress * Math.PI) * 0.14 : 1;238349 return {239350 id,240351 x: x + shakeX,241352 y: y + shakeY,242- scaleX: entity.dx ? 1 + squash : 1 - squash * 0.6,243- scaleY: entity.dy ? 1 + squash : 1 - squash * 0.6,353+ scaleX: (entity.dx ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,354+ scaleY: (entity.dy ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,244355 moving: progress < 1,245356 pushed: entity.pushed,246357 };
@@ -272,25 +383,49 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
272383 return result;273384 }274385 386+ function sentenceVisual(animation, at) {387+ if (animation.phase === 'holding') {388+ if (at < animation.holdUntil) return { ...animation, progress: 0 };389+ animation.phase = 'forming';390+ animation.start = animation.holdUntil;391+ }392+ const progress = animation.duration ? clamp((at - animation.start) / animation.duration) : 1;393+ const value = animation.phase === 'removing' ? 1 - smooth(progress) : smooth(progress);394+ if (animation.phase === 'forming' && progress >= 1) animation.phase = 'active';395+ return { ...animation, progress: value, frozenCells: animation.frozenCells ? animation.frozenCells.map(cell => ({ ...cell })) : null };396+ }397+275398 function frame(at = readNow()) {276399 const positions = new Map();277400 for (const id of entities.keys()) {278- const visual = entityVisual(id, at);279- if (visual) positions.set(id, visual);401+ if (!String(id).startsWith('__spark__')) {402+ const visual = entityVisual(id, at);403+ if (visual) positions.set(id, visual);404+ }280405 }281406 const visibleRules = [];282407 for (const animation of rules.values()) {283408 const visual = ruleVisual(animation.key, at);284409 if (visual && (visual.phase !== 'removing' || visual.progress > 0)) visibleRules.push(visual);285410 }411+ const visibleSentences = [];412+ for (const animation of sentences.values()) {413+ const visual = sentenceVisual(animation, at);414+ if (visual && (visual.phase !== 'removing' || visual.progress > 0)) visibleSentences.push(visual);415+ }416+ const beatProgress = win ? clamp((at - win.beatStart) / WIN_BEAT_MS) : 0;286417 return {287418 positions,288419 rules: visibleRules,420+ sentences: visibleSentences,289421 effects: effects(at),290422 shake: globalShake && globalShake.duration291423 ? { amplitude: globalShake.amplitude * (1 - clamp((at - globalShake.start) / globalShake.duration)) }292424 : { amplitude: 0 },293425 blocked: blocked && at < blocked.outlineUntil ? { ...blocked } : null,426+ tint: tint && tint.duration ? { progress: 1 - clamp((at - tint.start) / tint.duration), color: 'cool' } : null,427+ wipe: wipe && wipe.duration ? { progress: clamp((at - wipe.start) / wipe.duration) } : null,428+ win: win ? { beatProgress, cardAt: win.cardAt, cardVisible: at >= win.cardAt } : null,294429 reducedMotion: currentReducedMotion,295430 };296431 }
M tests/juice.test.js +36 -0
@@ -70,3 +70,39 @@ test('reduced motion makes accepted movement and rule changes immediate', () =>
7070 assert.equal(frame.rules[0].phase, 'active');7171 assert.equal(frame.rules[0].progress, 1);7272 });73+74+test('physical sentence bars use ids and freeze removed pre-move cells', () => {75+ const juice = createJuice({ now: () => 0 });76+ const oldState = { objects: [object('a', 0, 0), object('b', 1, 0), object('c', 2, 0)], sentences: [{ ids: ['a', 'b', 'c'], axis: 'h' }] };77+ juice.syncState(oldState, 0);78+ juice.beginInput(10);79+ const nextState = { objects: [object('a', 0, 0), object('b', 1, 1), object('c', 2, 0)], sentences: [] };80+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'b', from: { x: 1, y: 0 }, to: { x: 1, y: 1 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 10, nextState);81+ const bar = juice.frame(10).sentences[0];82+ assert.equal(bar.key, 'a>b>c');83+ assert.equal(bar.phase, 'removing');84+ assert.deepEqual(bar.frozenCells.map(cell => [cell.x, cell.y]), [[0, 0], [1, 0], [2, 0]]);85+});86+87+test('snap preserves the rule hold while it finishes movement', () => {88+ const juice = createJuice({ now: () => 0 });89+ const rule = { text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: ['a', 'b', 'c'], axis: 'h' };90+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'a', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [rule], rulesRemoved: [], destroyed: [] }, 0);91+ juice.beginInput(10);92+ assert.equal(juice.frame(10).rules[0].phase, 'holding');93+});94+95+test('undo, reset, destruction, and win expose presentation timelines', () => {96+ const juice = createJuice({ now: () => 0 });97+ juice.syncState(state([object('miau', 0, 0)]), 0);98+ juice.consume({ kind: 'undo', accepted: true, restored: true, moved: [{ id: 'miau', from: { x: 1, y: 0 }, to: { x: 0, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);99+ assert.equal(juice.frame(0).tint.color, 'cool');100+ assert.ok(juice.frame(100).positions.get('miau').scaleX > 0.55);101+ juice.consume({ kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], transformed: [], destroyed: [{ id: 'miau', noun: 'MIAU', at: { x: 0, y: 0 }, cause: 'sink' }], won: true }, 20);102+ assert.equal(juice.frame(20).effects[0].role, 'SINK');103+ assert.equal(juice.frame(20).shake.amplitude, 4);104+ assert.equal(juice.frame(20).win.cardVisible, false);105+ assert.equal(juice.frame(370).win.cardVisible, true);106+ juice.consume({ kind: 'reset', accepted: true }, 30);107+ assert.equal(juice.frame(30).wipe.progress, 0);108+});