- commit
- b772422c8c1aabda6e5054b1178a0577eb26a7ad
- parent
- 938e925d77e9fbc58733110652e4d994cbcf10ba
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 88 | +++++++----------------------- |
| A | src/ | 60 | ++++++++++++++++++++ |
| A | src/ | 61 | +++++++++++++++++++++ |
| M | tests/ | 24 | ++++++++ |
@@ -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()) {@@ -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+}@@ -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+}@@ -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);