- commit
- 0616901233bf0bf0885d92d6e2f7a741817e2040
- parent
- 7b85faf21316f2c90c06bde8d19eb1d0d2ae82dd
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 318 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 72 | +++++++ |
@@ -0,0 +1,318 @@
1+/**2+ * Presentation state for settled engine events.3+ *4+ * This module deliberately knows nothing about canvas, DOM, audio, or game5+ * state. A board renderer asks for a visual snapshot each frame and maps the6+ * returned grid coordinates to pixels. Calling beginInput() before every7+ * engine action makes input snap any old tween to its endpoint.8+ */9+10+const MOVE_MS = 90;11+const UNDO_MS = 70;12+const RULE_HOLD_MS = 45;13+const RULE_FORM_MS = 170;14+const RULE_REMOVE_MS = 140;15+const BLOCKED_MS = 130;16+const FRAME_MS = 16;17+18+const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));19+const easeOut = value => 1 - ((1 - value) ** 3);20+const smooth = value => value * value * (3 - 2 * value);21+const asNumber = value => Number.isFinite(value) ? value : 0;22+const keyForRule = rule => `${rule?.text || ''}|${(rule?.sourceIds || []).join(',')}|${rule?.axis || ''}`;23+24+/**25+ * @param {{ reducedMotion?: boolean, now?: () => number }} options26+ */27+export function createJuice({ reducedMotion = false, now = () => performance.now() } = {}) {28+ const entities = new Map();29+ const rules = new Map();30+ let globalShake = null;31+ let blocked = null;32+ let currentReducedMotion = Boolean(reducedMotion);33+34+ const duration = value => currentReducedMotion ? 0 : value;35+ const readNow = value => Number.isFinite(value) ? value : now();36+37+ function setReducedMotion(value) {38+ currentReducedMotion = Boolean(value);39+ if (currentReducedMotion) snap();40+ }41+42+ function entityAt(id) {43+ if (!entities.has(id)) entities.set(id, {44+ id,45+ x: 0,46+ y: 0,47+ fromX: 0,48+ fromY: 0,49+ start: 0,50+ duration: 0,51+ pushed: false,52+ });53+ return entities.get(id);54+ }55+56+ function settleEntity(entity, at) {57+ if (entity.duration <= 0) return;58+ const progress = clamp((at - entity.start) / entity.duration);59+ if (progress < 1) return;60+ entity.fromX = entity.x;61+ entity.fromY = entity.y;62+ entity.start = at;63+ entity.duration = 0;64+ entity.pushed = false;65+ }66+67+ /** Finish old tweens without changing simulation state. */68+ function snap(at = readNow()) {69+ for (const entity of entities.values()) {70+ entity.fromX = entity.x;71+ entity.fromY = entity.y;72+ entity.start = at;73+ entity.duration = 0;74+ entity.pushed = false;75+ }76+ globalShake = null;77+ 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+ }85+ }86+87+ /**88+ * Called immediately before an input is sent to the engine. The renderer89+ * can call this even when the input later turns out to be blocked.90+ */91+ function beginInput(at = readNow()) {92+ snap(at);93+ return at;94+ }95+96+ /** Seed or reconcile visual positions from the settled engine state. */97+ function syncState(state, at = readNow()) {98+ if (!state || !Array.isArray(state.objects)) return;99+ const live = new Set();100+ for (const object of state.objects) {101+ live.add(object.id);102+ const entity = entityAt(object.id);103+ if (entity.duration > 0) {104+ // Keep a current tween when the renderer reconciles immediately after105+ // consume(). The event is the source of animation timing.106+ continue;107+ }108+ entity.x = asNumber(object.x);109+ entity.y = asNumber(object.y);110+ entity.fromX = entity.x;111+ entity.fromY = entity.y;112+ entity.start = at;113+ entity.duration = 0;114+ }115+ for (const id of entities.keys()) if (!live.has(id)) {116+ const entity = entities.get(id);117+ if (!entity.effect) entities.delete(id);118+ }119+ }120+121+ function consumeMovement(entry, at, moveDuration) {122+ const entity = entityAt(entry.id);123+ settleEntity(entity, at);124+ entity.fromX = asNumber(entry.from?.x);125+ entity.fromY = asNumber(entry.from?.y);126+ entity.x = asNumber(entry.to?.x);127+ entity.y = asNumber(entry.to?.y);128+ entity.start = at;129+ entity.duration = moveDuration;130+ entity.pushed = entry.pushedBy != null;131+ entity.dx = Math.sign(entity.x - entity.fromX);132+ entity.dy = Math.sign(entity.y - entity.fromY);133+ }134+135+ function consumeRuleAdded(rule, at, holdUntil) {136+ const key = keyForRule(rule);137+ rules.set(key, {138+ key,139+ rule,140+ phase: holdUntil > at ? 'holding' : 'forming',141+ start: holdUntil,142+ holdUntil,143+ duration: duration(RULE_FORM_MS),144+ progress: 0,145+ });146+ }147+148+ function consumeRuleRemoved(rule, at) {149+ const key = keyForRule(rule);150+ const prior = rules.get(key);151+ if (prior) {152+ prior.phase = 'removing';153+ prior.start = at;154+ prior.holdUntil = at;155+ prior.duration = duration(RULE_REMOVE_MS);156+ prior.progress = 1;157+ return;158+ }159+ rules.set(key, {160+ key,161+ rule,162+ phase: 'removing',163+ start: at,164+ holdUntil: at,165+ duration: duration(RULE_REMOVE_MS),166+ progress: 1,167+ });168+ }169+170+ /** Consume one settled result returned by step(), undo(), or reset(). */171+ function consume(result, at = readNow()) {172+ if (!result || typeof result !== 'object') return;173+ const accepted = Boolean(result.accepted);174+ if (!accepted) {175+ if (result.blocked) {176+ const durationMs = duration(BLOCKED_MS);177+ blocked = {178+ ...result.blocked,179+ start: at,180+ duration: durationMs,181+ outlineUntil: at + (currentReducedMotion ? FRAME_MS : durationMs),182+ };183+ globalShake = currentReducedMotion ? null : {184+ start: at,185+ duration: durationMs,186+ amplitude: result.blocked.reason === 'bounds' ? 1.5 : 2.5,187+ };188+ }189+ return;190+ }191+192+ if (result.kind === 'reset') {193+ snap(at);194+ blocked = null;195+ return;196+ }197+198+ const moveDuration = duration(result.kind === 'undo' ? UNDO_MS : MOVE_MS);199+ for (const entry of result.moved || []) consumeMovement(entry, at, moveDuration);200+201+ const ruleChanged = (result.rulesAdded?.length || result.rulesRemoved?.length);202+ const holdUntil = at + (ruleChanged ? duration(RULE_HOLD_MS) : 0) + moveDuration;203+ for (const rule of result.rulesAdded || []) consumeRuleAdded(rule, at, holdUntil);204+ for (const rule of result.rulesRemoved || []) consumeRuleRemoved(rule, at);205+206+ // Effects stay available for the renderer at their settled coordinates.207+ // Their text and cause are copied so a destroyed entity need not exist in208+ // the next engine state.209+ for (const destruction of result.destroyed || []) {210+ const entity = entityAt(destruction.id);211+ entity.effect = {212+ type: destruction.cause === 'sink' ? 'sink' : 'defeat',213+ text: destruction.noun || destruction.text || '',214+ x: asNumber(destruction.at?.x ?? destruction.at?.[0]),215+ y: asNumber(destruction.at?.y ?? destruction.at?.[1]),216+ start: at,217+ duration: duration(destruction.cause === 'sink' ? 200 : 200),218+ };219+ }220+ }221+222+ function entityVisual(id, at = readNow()) {223+ const entity = entities.get(id);224+ if (!entity) return null;225+ const progress = entity.duration ? clamp((at - entity.start) / entity.duration) : 1;226+ const eased = easeOut(progress);227+ let x = entity.fromX + (entity.x - entity.fromX) * eased;228+ let y = entity.fromY + (entity.y - entity.fromY) * eased;229+ let shakeX = 0;230+ let shakeY = 0;231+ if (blocked && blocked.id === id && blocked.duration) {232+ const n = clamp((at - blocked.start) / blocked.duration);233+ const amount = Math.sin(n * Math.PI) * 0.2;234+ shakeX = blocked.dir?.[0] * amount || 0;235+ shakeY = blocked.dir?.[1] * amount || 0;236+ }237+ const squash = currentReducedMotion || progress >= 1 ? 0 : Math.sin(progress * Math.PI) * (entity.pushed ? 0.05 : 0.10);238+ return {239+ id,240+ x: x + shakeX,241+ y: y + shakeY,242+ scaleX: entity.dx ? 1 + squash : 1 - squash * 0.6,243+ scaleY: entity.dy ? 1 + squash : 1 - squash * 0.6,244+ moving: progress < 1,245+ pushed: entity.pushed,246+ };247+ }248+249+ function ruleVisual(ruleOrKey, at = readNow()) {250+ const key = typeof ruleOrKey === 'string' ? ruleOrKey : keyForRule(ruleOrKey);251+ const animation = rules.get(key);252+ if (!animation) return null;253+ if (animation.phase === 'holding') {254+ if (at < animation.holdUntil) return { ...animation, progress: 0 };255+ animation.phase = 'forming';256+ animation.start = animation.holdUntil;257+ }258+ const progress = animation.duration ? clamp((at - animation.start) / animation.duration) : 1;259+ animation.progress = animation.phase === 'removing' ? 1 - smooth(progress) : smooth(progress);260+ if (animation.phase === 'forming' && progress >= 1) animation.phase = 'active';261+ return { ...animation, progress: animation.progress };262+ }263+264+ function effects(at = readNow()) {265+ const result = [];266+ for (const entity of entities.values()) {267+ if (!entity.effect) continue;268+ const progress = entity.effect.duration ? clamp((at - entity.effect.start) / entity.effect.duration) : 1;269+ if (progress < 1) result.push({ ...entity.effect, progress });270+ else delete entity.effect;271+ }272+ return result;273+ }274+275+ function frame(at = readNow()) {276+ const positions = new Map();277+ for (const id of entities.keys()) {278+ const visual = entityVisual(id, at);279+ if (visual) positions.set(id, visual);280+ }281+ const visibleRules = [];282+ for (const animation of rules.values()) {283+ const visual = ruleVisual(animation.key, at);284+ if (visual && (visual.phase !== 'removing' || visual.progress > 0)) visibleRules.push(visual);285+ }286+ return {287+ positions,288+ rules: visibleRules,289+ effects: effects(at),290+ shake: globalShake && globalShake.duration291+ ? { amplitude: globalShake.amplitude * (1 - clamp((at - globalShake.start) / globalShake.duration)) }292+ : { amplitude: 0 },293+ blocked: blocked && at < blocked.outlineUntil ? { ...blocked } : null,294+ reducedMotion: currentReducedMotion,295+ };296+ }297+298+ return {299+ beginInput,300+ consume,301+ frame,302+ snap,303+ syncState,304+ setReducedMotion,305+ get reducedMotion() { return currentReducedMotion; },306+ };307+}308+309+export const JUICE_TIMINGS = Object.freeze({310+ move: MOVE_MS,311+ undo: UNDO_MS,312+ ruleHold: RULE_HOLD_MS,313+ ruleForm: RULE_FORM_MS,314+ ruleRemove: RULE_REMOVE_MS,315+ blocked: BLOCKED_MS,316+});317+318+export default createJuice;@@ -0,0 +1,72 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createJuice, JUICE_TIMINGS } from '../src/juice.js';4+5+const state = objects => ({ objects });6+const object = (id, x, y) => ({ id, kind: 'object', noun: 'MIAU', x, y });7+8+test('move and push tweens expose grid coordinates and push scale', () => {9+ const juice = createJuice({ now: () => 0 });10+ juice.syncState(state([object('miau', 0, 0), object('rock', 1, 0)]), 0);11+ juice.consume({12+ kind: 'step', accepted: true,13+ moved: [14+ { id: 'miau', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null },15+ { id: 'rock', from: { x: 1, y: 0 }, to: { x: 2, y: 0 }, pushedBy: 'miau' },16+ ],17+ rulesAdded: [], rulesRemoved: [], destroyed: [],18+ }, 0);19+20+ const half = juice.frame(45);21+ assert.equal(half.positions.get('miau').moving, true);22+ assert.ok(half.positions.get('miau').x > 0.8);23+ assert.equal(half.positions.get('rock').pushed, true);24+ assert.ok(half.positions.get('rock').scaleX < half.positions.get('miau').scaleX);25+26+ juice.beginInput(50);27+ assert.equal(juice.frame(50).positions.get('miau').x, 1);28+ assert.equal(juice.frame(50).positions.get('rock').x, 2);29+});30+31+test('rule formation waits 45ms after movement lands', () => {32+ const juice = createJuice({ now: () => 0 });33+ const rule = { text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: ['a', 'b', 'c'], axis: 'h' };34+ juice.consume({35+ kind: 'step', accepted: true,36+ moved: [{ id: 'a', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }],37+ rulesAdded: [rule], rulesRemoved: [], destroyed: [],38+ }, 0);39+ assert.equal(juice.frame(JUICE_TIMINGS.move + JUICE_TIMINGS.ruleHold - 1).rules[0].phase, 'holding');40+ assert.equal(juice.frame(JUICE_TIMINGS.move + JUICE_TIMINGS.ruleHold).rules[0].progress, 0);41+ assert.equal(juice.frame(JUICE_TIMINGS.move + JUICE_TIMINGS.ruleHold + JUICE_TIMINGS.ruleForm).rules[0].phase, 'active');42+});43+44+test('blocked movement produces a nudge and reduced motion outline', () => {45+ const normal = createJuice({ now: () => 0 });46+ normal.consume({ kind: 'step', accepted: false, blocked: { id: 'miau', dir: [1, 0], blockerId: 'wall', reason: 'stop' } }, 0);47+ normal.syncState(state([object('miau', 0, 0)]), 0);48+ assert.ok(normal.frame(65).positions.get('miau').x > 0);49+ assert.equal(normal.frame(200).blocked, null);50+51+ const reduced = createJuice({ reducedMotion: true, now: () => 0 });52+ reduced.syncState(state([object('miau', 0, 0)]), 0);53+ reduced.consume({ kind: 'step', accepted: false, blocked: { id: 'miau', dir: [1, 0], blockerId: null, reason: 'bounds' } }, 0);54+ assert.equal(reduced.frame(0).positions.get('miau').x, 0);55+ assert.equal(reduced.frame(0).blocked.reason, 'bounds');56+ assert.equal(reduced.frame(20).blocked, null);57+});58+59+test('reduced motion makes accepted movement and rule changes immediate', () => {60+ const juice = createJuice({ reducedMotion: true, now: () => 0 });61+ juice.syncState(state([object('miau', 0, 0)]), 0);62+ juice.consume({63+ kind: 'step', accepted: true,64+ moved: [{ id: 'miau', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }],65+ rulesAdded: [{ text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: [], axis: 'h' }],66+ rulesRemoved: [], destroyed: [],67+ }, 0);68+ const frame = juice.frame(0);69+ assert.equal(frame.positions.get('miau').x, 1);70+ assert.equal(frame.rules[0].phase, 'active');71+ assert.equal(frame.rules[0].progress, 1);72+});