clone

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

Extract pure visual and effect projections from juice

Dami committed b772422
commit
b772422c8c1aabda6e5054b1178a0577eb26a7ad
parent
938e925d77e9fbc58733110652e4d994cbcf10ba
author
Dami <dami.etoile@gmail.com>
4 files changed, +166 -67
Msrc/juice.js88+++++++-----------------------
Asrc/juice/effects.js60++++++++++++++++++++
Asrc/juice/visuals.js61+++++++++++++++++++++
Mtests/juice.test.js24++++++++
M src/juice.js +21 -67
@@ -7,6 +7,14 @@
77 * engine action makes input snap any old tween to its endpoint.88 */99 10+import { facingFor, projectEntityVisual } from './juice/visuals.js';11+import {12+ automatonTransition,13+ blockedPresentation,14+ destructionEffect,15+ transformEffect,16+} from './juice/effects.js';17+1018 const MOVE_MS = 90;1119 const UNDO_MS = 70;1220 const RULE_HOLD_MS = 45;
@@ -25,14 +33,11 @@ const AUTOMATON_BEAT_MS = 90;
2533 const AUTOMATON_DELAY_MS = MOVE_MS + 45;2634 2735 const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));28-const easeOut = value => 1 - ((1 - value) ** 3);2936 const smooth = value => value * value * (3 - 2 * value);30-const easeBack = value => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * ((value - 1) ** 3) + c1 * ((value - 1) ** 2); };3137 const asNumber = value => Number.isFinite(value) ? value : 0;3238 const keyForRule = rule => `${rule?.text || ''}|${(rule?.sourceIds || []).join(',')}|${rule?.axis || ''}`;3339 const keyForSentence = sentence => (sentence?.ids || []).join('>');3440 const cellOf = object => ({ id: object.id, x: asNumber(object.x), y: asNumber(object.y) });35-const facingFor = (dx, dy) => dx > 0 ? 'right' : dx < 0 ? 'left' : dy > 0 ? 'down' : dy < 0 ? 'up' : null;3641 3742 /**3843 * @param {{ reducedMotion?: boolean, now?: () => number }} options
@@ -274,17 +279,9 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
274279 if (!accepted) {275280 if (result.blocked) {276281 const durationMs = duration(BLOCKED_MS);277- blocked = {278- ...result.blocked,279- start: at,280- duration: durationMs,281- outlineUntil: at + (currentReducedMotion ? FRAME_MS : durationMs),282- };283- globalShake = currentReducedMotion ? null : {284- start: at,285- duration: durationMs,286- amplitude: result.blocked.reason === 'bounds' ? 1.5 : 2.5,287- };282+ const presentation = blockedPresentation(result.blocked, at, durationMs, currentReducedMotion, FRAME_MS);283+ blocked = presentation.blocked;284+ globalShake = presentation.shake;288285 }289286 return;290287 }
@@ -329,14 +326,8 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
329326 }330327 for (const system of result.automata || state?.automata || []) {331328 const prior = automataPhases.get(system.id);332- if (prior !== undefined && prior !== system.phase) automataTransitions.set(system.id, {333- id: system.id,334- fromPhase: prior,335- toPhase: system.phase,336- start: at + (result.kind === 'undo' ? 0 : AUTOMATON_DELAY_MS),337- duration: duration(AUTOMATON_BEAT_MS),338- wrapped: system.phase === 0 && prior === system.pattern?.length - 1,339- });329+ if (prior !== undefined && prior !== system.phase) automataTransitions.set(system.id,330+ automatonTransition(system, prior, at, AUTOMATON_DELAY_MS, duration(AUTOMATON_BEAT_MS), result.kind === 'undo'));340331 automataPhases.set(system.id, system.phase);341332 }342333 if (result.kind === 'step' || result.kind === 'wait') for (const entry of result.automated || []) {
@@ -388,25 +379,14 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
388379 // the next engine state.389380 for (const destruction of result.destroyed || []) {390381 const entity = entityAt(destruction.id);391- entity.effect = {392- type: destruction.cause === 'sink' ? 'sink' : 'defeat',393- text: destruction.noun || destruction.text || '',394- x: asNumber(destruction.at?.x ?? destruction.at?.[0]),395- y: asNumber(destruction.at?.y ?? destruction.at?.[1]),396- cell: { x: asNumber(destruction.at?.x ?? destruction.at?.[0]), y: asNumber(destruction.at?.y ?? destruction.at?.[1]) },397- color: destruction.cause === 'sink' ? 'SINK' : 'DEFEAT',398- role: destruction.cause === 'sink' ? 'SINK' : 'DEFEAT',399- start: at,400- t0: at,401- duration: duration(EFFECT_MS),402- };382+ entity.effect = destructionEffect(destruction, at, duration(EFFECT_MS));403383 if (!currentReducedMotion) globalShake = { start: at, duration: EFFECT_MS, amplitude: 4 };404384 }405385 for (const transform of result.transformed || []) {406386 const entity = entityAt(transform.id);407387 entity.popStart = at;408388 entity.popDuration = duration(POP_MS);409- 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) };389+ entity.effect = transformEffect(transform, entity, at, duration(EFFECT_MS));410390 }411391 // Undo can restore an object that had been removed by SINK or DEFEAT.412392 // Such an object has no movement entry because it did not move in the
@@ -436,38 +416,12 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
436416 function entityVisual(id, at = readNow()) {437417 const entity = entities.get(id);438418 if (!entity) return null;439- const progress = entity.duration ? clamp((at - entity.start) / entity.duration) : 1;440- const eased = easeOut(progress);441- let x = entity.fromX + (entity.x - entity.fromX) * eased;442- let y = entity.fromY + (entity.y - entity.fromY) * eased;443- let shakeX = 0;444- let shakeY = 0;445- if (blocked && blocked.id === id && blocked.duration) {446- const n = clamp((at - blocked.start) / blocked.duration);447- const amount = Math.sin(n * Math.PI) * 0.2;448- shakeX = blocked.dir?.[0] * amount || 0;449- shakeY = blocked.dir?.[1] * amount || 0;450- }451- const squash = currentReducedMotion || progress >= 1 ? 0 : Math.sin(progress * Math.PI) * (entity.pushed ? 0.05 : 0.10);452- const popProgress = entity.popDuration ? clamp((at - entity.popStart) / entity.popDuration) : 1;453- const bounceProgress = entity.bounceDuration ? clamp((at - entity.bounceStart) / entity.bounceDuration) : 1;454- const popScale = entity.popDuration ? 0.55 + 0.45 * easeBack(popProgress) : 1;455- const bounceScale = entity.bounceDuration ? 1 + Math.sin(bounceProgress * Math.PI) * 0.14 : 1;456- const faceFlipProgress = !currentReducedMotion && Number.isFinite(entity.faceFlipStart)457- ? clamp((at - entity.faceFlipStart) / FACE_FLIP_MS)458- : 1;459- const glyphScaleX = currentReducedMotion ? 1 : 1 - 0.8 * Math.sin(Math.PI * faceFlipProgress);460- return {461- id,462- x: x + shakeX,463- y: y + shakeY,464- scaleX: (entity.dx ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,465- scaleY: (entity.dy ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,466- moving: progress < 1,467- pushed: entity.pushed,468- facing: entity.facing,469- glyphScaleX,470- };419+ return projectEntityVisual(entity, {420+ at,421+ blocked,422+ reducedMotion: currentReducedMotion,423+ faceFlipDuration: FACE_FLIP_MS,424+ });471425 }472426 473427 function ruleVisual(ruleOrKey, at = readNow()) {
A src/juice/effects.js +60 -0
@@ -0,0 +1,60 @@
1+const asNumber = value => Number.isFinite(value) ? value : 0;2+3+export function blockedPresentation(blocked, at, durationMs, reducedMotion, frameMs) {4+ return {5+ blocked: {6+ ...blocked,7+ start: at,8+ duration: durationMs,9+ outlineUntil: at + (reducedMotion ? frameMs : durationMs),10+ },11+ shake: reducedMotion ? null : {12+ start: at,13+ duration: durationMs,14+ amplitude: blocked.reason === 'bounds' ? 1.5 : 2.5,15+ },16+ };17+}18+19+export function automatonTransition(system, priorPhase, at, startDelay, durationMs, undo) {20+ return {21+ id: system.id,22+ fromPhase: priorPhase,23+ toPhase: system.phase,24+ start: at + (undo ? 0 : startDelay),25+ duration: durationMs,26+ wrapped: system.phase === 0 && priorPhase === system.pattern?.length - 1,27+ };28+}29+30+export function destructionEffect(destruction, at, durationMs) {31+ const x = asNumber(destruction.at?.x ?? destruction.at?.[0]);32+ const y = asNumber(destruction.at?.y ?? destruction.at?.[1]);33+ const type = destruction.cause === 'sink' ? 'sink' : 'defeat';34+ const role = destruction.cause === 'sink' ? 'SINK' : 'DEFEAT';35+ return {36+ type,37+ text: destruction.noun || destruction.text || '',38+ x,39+ y,40+ cell: { x, y },41+ color: role,42+ role,43+ start: at,44+ t0: at,45+ duration: durationMs,46+ };47+}48+49+export function transformEffect(transform, entity, at, durationMs) {50+ return {51+ type: 'transform',52+ text: transform.from || '',53+ cell: { x: entity.x, y: entity.y },54+ color: 'fg',55+ role: 'fg',56+ start: at,57+ t0: at,58+ duration: durationMs,59+ };60+}
A src/juice/visuals.js +61 -0
@@ -0,0 +1,61 @@
1+/**2+ * Pure projection from an in-flight entity tween to the renderer's visual3+ * snapshot. Keeping this outside createJuice makes the frame math reusable4+ * without giving render code access to the mutable animation maps.5+ */6+7+const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));8+const easeOut = value => 1 - ((1 - value) ** 3);9+const easeBack = value => {10+ const c1 = 1.70158;11+ const c3 = c1 + 1;12+ return 1 + c3 * ((value - 1) ** 3) + c1 * ((value - 1) ** 2);13+};14+15+export const facingFor = (dx, dy) => (16+ dx > 0 ? 'right' : dx < 0 ? 'left' : dy > 0 ? 'down' : dy < 0 ? 'up' : null17+);18+19+/**20+ * @param {object} entity21+ * @param {{ at: number, blocked?: object|null, reducedMotion?: boolean, faceFlipDuration?: number }} options22+ */23+export function projectEntityVisual(entity, {24+ at,25+ blocked = null,26+ reducedMotion = false,27+ faceFlipDuration = 70,28+} = {}) {29+ const progress = entity.duration ? clamp((at - entity.start) / entity.duration) : 1;30+ const eased = easeOut(progress);31+ let x = entity.fromX + (entity.x - entity.fromX) * eased;32+ let y = entity.fromY + (entity.y - entity.fromY) * eased;33+ let shakeX = 0;34+ let shakeY = 0;35+ if (blocked && blocked.id === entity.id && blocked.duration) {36+ const n = clamp((at - blocked.start) / blocked.duration);37+ const amount = Math.sin(n * Math.PI) * 0.2;38+ shakeX = blocked.dir?.[0] * amount || 0;39+ shakeY = blocked.dir?.[1] * amount || 0;40+ }41+ const squash = reducedMotion || progress >= 1 ? 0 : Math.sin(progress * Math.PI) * (entity.pushed ? 0.05 : 0.10);42+ const popProgress = entity.popDuration ? clamp((at - entity.popStart) / entity.popDuration) : 1;43+ const bounceProgress = entity.bounceDuration ? clamp((at - entity.bounceStart) / entity.bounceDuration) : 1;44+ const popScale = entity.popDuration ? 0.55 + 0.45 * easeBack(popProgress) : 1;45+ const bounceScale = entity.bounceDuration ? 1 + Math.sin(bounceProgress * Math.PI) * 0.14 : 1;46+ const faceFlipProgress = !reducedMotion && Number.isFinite(entity.faceFlipStart)47+ ? clamp((at - entity.faceFlipStart) / faceFlipDuration)48+ : 1;49+ const glyphScaleX = reducedMotion ? 1 : 1 - 0.8 * Math.sin(Math.PI * faceFlipProgress);50+ return {51+ id: entity.id,52+ x: x + shakeX,53+ y: y + shakeY,54+ scaleX: (entity.dx ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,55+ scaleY: (entity.dy ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,56+ moving: progress < 1,57+ pushed: entity.pushed,58+ facing: entity.facing,59+ glyphScaleX,60+ };61+}
M tests/juice.test.js +24 -0
@@ -2,10 +2,34 @@ import test from 'node:test';
22 import assert from 'node:assert/strict';33 import { createGame } from '../src/engine.js';44 import { createJuice, JUICE_TIMINGS } from '../src/juice.js';5+import { projectEntityVisual } from '../src/juice/visuals.js';6+import { destructionEffect, blockedPresentation } from '../src/juice/effects.js';57 68 const state = objects => ({ objects });79 const object = (id, x, y) => ({ id, kind: 'object', noun: 'MIAU', x, y });810 11+test('projectEntityVisual keeps presentation math pure and isolated from juice state', () => {12+ const visual = projectEntityVisual({13+ id: 'miau', fromX: 0, fromY: 1, x: 1, y: 1, start: 0, duration: 100,14+ pushed: false, dx: 1, dy: 0, facing: 'right', faceFlipStart: 0,15+ popDuration: 0, bounceDuration: 0,16+ }, { at: 50, faceFlipDuration: 70 });17+ assert.equal(visual.id, 'miau');18+ assert.equal(visual.moving, true);19+ assert.equal(visual.facing, 'right');20+ assert.ok(visual.x > 0.8);21+ assert.ok(visual.glyphScaleX < 0.6);22+});23+24+test('juice effect builders preserve settled coordinates and reduced motion timing', () => {25+ const death = destructionEffect({ cause: 'sink', noun: 'MIAU', at: { x: 2, y: 3 } }, 10, 200);26+ assert.deepEqual(death.cell, { x: 2, y: 3 });27+ assert.equal(death.role, 'SINK');28+ const blocked = blockedPresentation({ id: 'miau', reason: 'bounds' }, 10, 0, true, 16);29+ assert.equal(blocked.shake, null);30+ assert.equal(blocked.blocked.outlineUntil, 26);31+});32+933 test('move and push tweens expose grid coordinates and push scale', () => {1034 const juice = createJuice({ now: () => 0 });1135 juice.syncState(state([object('miau', 0, 0), object('rock', 1, 0)]), 0);