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 */910import { facingFor, projectEntityVisual } from './juice/visuals.js';11import {12 automatonTransition,13 blockedPresentation,14 destructionEffect,15 transformEffect,16} from './juice/effects.js';17import { projectLedgerEntry } from './juice/ledger.js';1819const MOVE_MS = 90;20const UNDO_MS = 70;21const RULE_HOLD_MS = 45;22const RULE_FORM_MS = 170;23const RULE_REMOVE_MS = 140;24const BLOCKED_MS = 130;25const FRAME_MS = 16;26const BOUNCE_MS = 200;27const POP_MS = 240;28const EFFECT_MS = 200;29const TINT_MS = 140;30const WIPE_MS = 260;31const WIN_BEAT_MS = 260;32const FACE_FLIP_MS = 70;33const AUTOMATON_BEAT_MS = 90;34const AUTOMATON_DELAY_MS = MOVE_MS + 45;3536const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));37const smooth = value => value * value * (3 - 2 * value);38const asNumber = value => Number.isFinite(value) ? value : 0;39const keyForRule = rule => `${rule?.text || ''}|${(rule?.sourceIds || []).join(',')}|${rule?.axis || ''}`;40const keyForSentence = sentence => (sentence?.ids || []).join('>');41const cellOf = object => ({ id: object.id, x: asNumber(object.x), y: asNumber(object.y) });4243/**44 * @param {{ reducedMotion?: boolean, now?: () => number }} options45 */46export function createJuice({ reducedMotion = false, now = () => performance.now() } = {}) {47 const entities = new Map();48 const rules = new Map();49 const sentences = new Map();50 const ledger = new Map();51 const automataPhases = new Map();52 const automataTransitions = new Map();53 let priorSentenceCells = new Map();54 let priorSentences = new Map();55 let globalShake = null;56 let blocked = null;57 let tint = null;58 let wipe = null;59 let win = null;60 const facingHistory = [];61 let currentReducedMotion = Boolean(reducedMotion);6263 const duration = value => currentReducedMotion ? 0 : value;64 const readNow = value => Number.isFinite(value) ? value : now();6566 function setReducedMotion(value) {67 currentReducedMotion = Boolean(value);68 if (currentReducedMotion) snap();69 }7071 function entityAt(id) {72 if (!entities.has(id)) entities.set(id, {73 id,74 x: 0,75 y: 0,76 fromX: 0,77 fromY: 0,78 start: 0,79 duration: 0,80 pushed: false,81 facing: null,82 faceFlipStart: null,83 });84 return entities.get(id);85 }8687 function settleEntity(entity, at) {88 if (entity.duration <= 0) return;89 const progress = clamp((at - entity.start) / entity.duration);90 if (progress < 1) return;91 entity.fromX = entity.x;92 entity.fromY = entity.y;93 entity.start = at;94 entity.duration = 0;95 entity.pushed = false;96 }9798 /** Finish old tweens without changing simulation state. */99 function snap(at = readNow()) {100 for (const entity of entities.values()) {101 entity.fromX = entity.x;102 entity.fromY = entity.y;103 entity.start = at;104 entity.duration = 0;105 entity.pushed = false;106 }107 globalShake = null;108 blocked = null;109 }110111 /** Start a fresh visual timeline for a different level. */112 function resetLevel(state, at = readNow()) {113 entities.clear();114 rules.clear();115 sentences.clear();116 ledger.clear();117 automataPhases.clear();118 automataTransitions.clear();119 facingHistory.length = 0;120 priorSentenceCells = new Map();121 priorSentences = new Map();122 globalShake = null;123 blocked = null;124 tint = null;125 wipe = null;126 win = null;127 syncState(state, at);128 return frame(at);129 }130131 /**132 * Called immediately before an input is sent to the engine. The renderer133 * can call this even when the input later turns out to be blocked.134 */135 function beginInput(at = readNow()) {136 snap(at);137 return at;138 }139140 /** Seed or reconcile visual positions from the settled engine state. */141 function syncState(state, at = readNow()) {142 if (!state || !Array.isArray(state.objects)) return;143 for (const system of state.automata || []) if (!automataPhases.has(system.id)) automataPhases.set(system.id, system.phase);144 priorSentenceCells = new Map();145 priorSentences = new Map();146 for (const sentence of state.sentences || []) {147 const normalized = {148 ids: [...(sentence.ids || [])],149 axis: sentence.axis,150 tokens: (sentence.ids || []).map(id => {151 const object = state.objects.find(candidate => candidate.id === id);152 return object?.text || object?.noun || '';153 }),154 };155 const key = keyForSentence(normalized);156 priorSentences.set(key, normalized);157 priorSentenceCells.set(key, normalized.ids.map(id => {158 const object = state.objects.find(candidate => candidate.id === id);159 return object ? cellOf(object) : null;160 }).filter(Boolean));161 if (!sentences.has(key)) sentences.set(key, { key, ids: [...normalized.ids], axis: normalized.axis, phase: 'active', start: at, holdUntil: at, duration: 0, frozenCells: null });162 if (!ledger.has(key)) ledger.set(key, { key, ids: [...normalized.ids], tokens: [...normalized.tokens], phase: 'active', start: at });163 }164 const live = new Set();165 for (const object of state.objects) {166 live.add(object.id);167 const entity = entityAt(object.id);168 if (entity.duration > 0) {169 // Keep a current tween when the renderer reconciles immediately after170 // consume(). The event is the source of animation timing.171 continue;172 }173 entity.x = asNumber(object.x);174 entity.y = asNumber(object.y);175 entity.fromX = entity.x;176 entity.fromY = entity.y;177 entity.start = at;178 entity.duration = 0;179 }180 for (const id of entities.keys()) if (!live.has(id)) {181 const entity = entities.get(id);182 if (!entity.effect) entities.delete(id);183 }184 }185186 function consumeMovement(entry, at, moveDuration) {187 const entity = entityAt(entry.id);188 settleEntity(entity, at);189 entity.fromX = asNumber(entry.from?.x);190 entity.fromY = asNumber(entry.from?.y);191 entity.x = asNumber(entry.to?.x);192 entity.y = asNumber(entry.to?.y);193 entity.start = at;194 entity.duration = moveDuration;195 entity.pushed = entry.pushedBy != null;196 entity.dx = Math.sign(entity.x - entity.fromX);197 entity.dy = Math.sign(entity.y - entity.fromY);198 // Face actual horizontal travel, including a push. If a pushed actor199 // then moves itself, the self-movement direction is the final intent.200 const travelDx = entry.selfDirection?.dx || entity.dx;201 if (travelDx !== 0) {202 const nextFacing = facingFor(travelDx, 0);203 if (nextFacing !== entity.facing) entity.faceFlipStart = at;204 entity.facing = nextFacing;205 }206 }207208 function consumeRuleAdded(rule, at, holdUntil) {209 const key = keyForRule(rule);210 rules.set(key, {211 key,212 rule,213 phase: holdUntil > at ? 'holding' : 'forming',214 start: holdUntil,215 holdUntil,216 duration: duration(RULE_FORM_MS),217 progress: 0,218 });219 }220221 function consumeRuleRemoved(rule, at) {222 const key = keyForRule(rule);223 const prior = rules.get(key);224 if (prior) {225 prior.phase = 'removing';226 prior.start = at;227 prior.holdUntil = at;228 prior.duration = duration(RULE_REMOVE_MS);229 prior.progress = 1;230 return;231 }232 rules.set(key, {233 key,234 rule,235 phase: 'removing',236 start: at,237 holdUntil: at,238 duration: duration(RULE_REMOVE_MS),239 progress: 1,240 });241 }242243 function consumeSentenceAdded(sentence, at, holdUntil, predicate = 'ink', tokens = []) {244 const key = keyForSentence(sentence);245 const start = holdUntil;246 sentences.set(key, {247 key,248 ids: [...(sentence.ids || [])],249 axis: sentence.axis,250 predicate,251 phase: start > at ? 'holding' : 'forming',252 start,253 holdUntil,254 duration: duration(RULE_FORM_MS),255 frozenCells: null,256 });257 ledger.set(key, { key, ids: [...(sentence.ids || [])], tokens: [...tokens], phase: 'stamping', start });258 for (const id of sentence.ids || []) {259 const entity = entityAt(id);260 entity.bounceStart = start;261 entity.bounceDuration = duration(BOUNCE_MS);262 }263 const lastId = sentence.ids?.at(-1);264 if (lastId) {265 const cell = priorSentenceCells.get(key)?.at(-1) || entityAt(lastId);266 const color = predicate;267 const effect = { type: 'spark', text: '✦', cell: { x: asNumber(cell.x), y: asNumber(cell.y) }, color, role: color, t0: start, start, duration: duration(EFFECT_MS) };268 entityAt(`__spark__${key}`).effect = effect;269 }270 }271272 function consumeSentenceRemoved(sentence, at) {273 const key = keyForSentence(sentence);274 const prior = sentences.get(key);275 sentences.set(key, {276 key,277 ids: [...(sentence.ids || [])],278 axis: sentence.axis,279 phase: 'removing',280 start: at,281 holdUntil: at,282 duration: duration(RULE_REMOVE_MS),283 frozenCells: priorSentenceCells.get(key)?.map(cell => ({ ...cell })) || prior?.frozenCells || [],284 });285 const ledgerEntry = ledger.get(key);286 ledger.set(key, {287 key,288 ids: [...(sentence.ids || ledgerEntry?.ids || [])],289 tokens: [...(sentence.tokens || ledgerEntry?.tokens || [])],290 phase: 'removing',291 start: at,292 });293 }294295 /** Consume one settled result returned by step(), undo(), or reset(). */296 function consume(result, at = readNow(), state = null) {297 if (!result || typeof result !== 'object') return;298 const accepted = Boolean(result.accepted);299 if (!accepted) {300 if (result.blocked) {301 const durationMs = duration(BLOCKED_MS);302 const presentation = blockedPresentation(result.blocked, at, durationMs, currentReducedMotion, FRAME_MS);303 blocked = presentation.blocked;304 globalShake = presentation.shake;305 }306 return;307 }308309 if (result.kind === 'reset') {310 snap(at);311 blocked = null;312 sentences.clear();313 automataPhases.clear();314 automataTransitions.clear();315 for (const system of result.automata || state?.automata || []) automataPhases.set(system.id, system.phase);316 facingHistory.length = 0;317 for (const entity of entities.values()) {318 entity.facing = null;319 entity.faceFlipStart = null;320 entity.effect = null;321 entity.popDuration = 0;322 entity.bounceDuration = 0;323 }324 rules.clear();325 wipe = { start: at, duration: duration(WIPE_MS) };326 tint = null;327 win = null;328 return;329 }330331 const moveDuration = duration(result.kind === 'undo' ? UNDO_MS : MOVE_MS);332 const priorFacing = new Map();333 if (result.kind === 'undo') {334 const restoredFacing = facingHistory.pop();335 if (restoredFacing) for (const [id, facing] of restoredFacing) priorFacing.set(id, facing);336 for (const entity of entities.values()) entity.bounceDuration = 0;337 } else if (result.kind === 'step' || result.kind === 'wait') {338 for (const [id, entity] of entities) {339 if (!String(id).startsWith('__spark__')) priorFacing.set(id, entity.facing);340 }341 facingHistory.push(priorFacing);342 }343 for (const entry of result.moved || []) {344 const start = entry.cause === 'automaton' && !currentReducedMotion ? at + AUTOMATON_DELAY_MS : at;345 consumeMovement(entry, start, moveDuration);346 }347 for (const system of result.automata || state?.automata || []) {348 const prior = automataPhases.get(system.id);349 if (prior !== undefined && prior !== system.phase) automataTransitions.set(system.id,350 automatonTransition(system, prior, at, AUTOMATON_DELAY_MS, duration(AUTOMATON_BEAT_MS), result.kind === 'undo'));351 automataPhases.set(system.id, system.phase);352 }353 if (result.kind === 'step' || result.kind === 'wait') for (const entry of result.automated || []) {354 if (!entry.active || !entry.id) continue;355 const entity = entityAt(entry.id);356 entity.bounceStart = at + (currentReducedMotion ? 0 : AUTOMATON_DELAY_MS);357 entity.bounceDuration = duration(BOUNCE_MS);358 }359 if (result.kind === 'undo') for (const [id, facing] of priorFacing) {360 const entity = entityAt(id);361 entity.facing = facing;362 entity.faceFlipStart = null;363 }364365 if (result.kind === 'undo') tint = { start: at, duration: duration(TINT_MS) };366 if (result.restored || result.kind === 'undo') {367 for (const entry of result.moved || []) {368 const entity = entityAt(entry.id);369 entity.popStart = at;370 entity.popDuration = duration(POP_MS);371 }372 }373374 const ruleChanged = (result.rulesAdded?.length || result.rulesRemoved?.length);375 const holdUntil = at + (ruleChanged ? duration(RULE_HOLD_MS) : 0) + moveDuration;376 for (const rule of result.rulesAdded || []) consumeRuleAdded(rule, at, holdUntil);377 for (const rule of result.rulesRemoved || []) consumeRuleRemoved(rule, at);378379 const nextSentences = state && Array.isArray(state.sentences)380 ? new Map(state.sentences.map(sentence => [keyForSentence(sentence), sentence]))381 : null;382 if (nextSentences) {383 for (const sentence of nextSentences.values()) {384 const key = keyForSentence(sentence);385 if (!priorSentences.has(key)) {386 const rule = (result.rulesAdded || []).find(candidate => (candidate.sourceIds || []).join('>') === key);387 const tokens = sentence.ids.map(id => {388 const object = state.objects.find(candidate => candidate.id === id);389 return object?.text || object?.noun || '';390 });391 consumeSentenceAdded(sentence, at, holdUntil, rule?.predicate || 'ink', tokens);392 }393 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 });394 }395 for (const sentence of priorSentences.values()) {396 const key = keyForSentence(sentence);397 if (!nextSentences.has(key)) consumeSentenceRemoved(sentence, at);398 }399 }400401 // Effects stay available for the renderer at their settled coordinates.402 // Their text and cause are copied so a destroyed entity need not exist in403 // the next engine state.404 for (const destruction of result.destroyed || []) {405 const entity = entityAt(destruction.id);406 entity.effect = destructionEffect(destruction, at, duration(EFFECT_MS));407 if (!currentReducedMotion) globalShake = { start: at, duration: EFFECT_MS, amplitude: 4 };408 }409 for (const transform of result.transformed || []) {410 const entity = entityAt(transform.id);411 entity.popStart = at;412 entity.popDuration = duration(POP_MS);413 entity.effect = transformEffect(transform, entity, at, duration(EFFECT_MS));414 }415 // Undo can restore an object that had been removed by SINK or DEFEAT.416 // Such an object has no movement entry because it did not move in the417 // undo diff, so reconcile the settled state to seed its visual position.418 if (result.kind === 'undo' && state && Array.isArray(state.objects)) {419 const restoredIds = new Set(state.objects.map(object => object.id));420 let restoredDeathEffect = false;421 for (const [id, entity] of entities) {422 if (!restoredIds.has(id) || !['sink', 'defeat'].includes(entity.effect?.type)) continue;423 // A destroyed actor can still be tweening toward its hazard when the424 // player presses Undo. Remove that stale death presentation before425 // reconciling the restored settled cell.426 entity.effect = null;427 entity.fromX = entity.x;428 entity.fromY = entity.y;429 entity.start = at;430 entity.duration = 0;431 entity.pushed = false;432 restoredDeathEffect = true;433 }434 if (restoredDeathEffect) globalShake = null;435 syncState(state, at);436 }437 if (result.won && (result.kind === 'step' || result.kind === 'wait')) win = { beatStart: at + moveDuration, cardAt: at + moveDuration + WIN_BEAT_MS };438 }439440 function entityVisual(id, at = readNow()) {441 const entity = entities.get(id);442 if (!entity) return null;443 return projectEntityVisual(entity, {444 at,445 blocked,446 reducedMotion: currentReducedMotion,447 faceFlipDuration: FACE_FLIP_MS,448 });449 }450451 function ruleVisual(ruleOrKey, at = readNow()) {452 const key = typeof ruleOrKey === 'string' ? ruleOrKey : keyForRule(ruleOrKey);453 const animation = rules.get(key);454 if (!animation) return null;455 if (animation.phase === 'holding') {456 if (at < animation.holdUntil) return { ...animation, progress: 0 };457 animation.phase = 'forming';458 animation.start = animation.holdUntil;459 }460 const progress = animation.duration ? clamp((at - animation.start) / animation.duration) : 1;461 animation.progress = animation.phase === 'removing' ? 1 - smooth(progress) : smooth(progress);462 if (animation.phase === 'forming' && progress >= 1) animation.phase = 'active';463 return { ...animation, progress: animation.progress };464 }465466 function effects(at = readNow()) {467 const result = [];468 for (const entity of entities.values()) {469 if (!entity.effect) continue;470 const progress = entity.effect.duration ? clamp((at - entity.effect.start) / entity.effect.duration) : 1;471 if (progress < 1) result.push({ ...entity.effect, progress });472 else delete entity.effect;473 }474 return result;475 }476477 function sentenceVisual(animation, at) {478 if (animation.phase === 'holding') {479 if (at < animation.holdUntil) return { ...animation, progress: 0 };480 animation.phase = 'forming';481 animation.start = animation.holdUntil;482 }483 const progress = animation.duration ? clamp((at - animation.start) / animation.duration) : 1;484 const value = animation.phase === 'removing' ? 1 - smooth(progress) : smooth(progress);485 if (animation.phase === 'forming' && progress >= 1) animation.phase = 'active';486 return { ...animation, progress: value, frozenCells: animation.frozenCells ? animation.frozenCells.map(cell => ({ ...cell })) : null };487 }488489 function frame(at = readNow()) {490 const positions = new Map();491 for (const id of entities.keys()) {492 if (!String(id).startsWith('__spark__')) {493 const visual = entityVisual(id, at);494 if (visual) positions.set(id, visual);495 }496 }497 const visibleRules = [];498 for (const animation of rules.values()) {499 const visual = ruleVisual(animation.key, at);500 if (visual && (visual.phase !== 'removing' || visual.progress > 0)) visibleRules.push(visual);501 }502 const visibleSentences = [];503 for (const animation of sentences.values()) {504 const visual = sentenceVisual(animation, at);505 if (visual && (visual.phase !== 'removing' || visual.progress > 0)) visibleSentences.push(visual);506 }507 const beatProgress = win ? clamp((at - win.beatStart) / WIN_BEAT_MS) : 0;508 const automata = [...automataTransitions.values()].map(transition => ({509 ...transition,510 progress: transition.duration ? clamp((at - transition.start) / transition.duration) : 1,511 }));512 return {513 positions,514 rules: visibleRules,515 sentences: visibleSentences,516 ledger: [...ledger.values()]517 .map(entry => projectLedgerEntry(entry, at, currentReducedMotion518 ? { stamp: 0, tileStagger: 0, strike: 0, exit: 0 }519 : undefined))520 .filter(entry => entry.visible),521 effects: effects(at),522 automata,523 shake: globalShake && globalShake.duration524 ? { amplitude: globalShake.amplitude * (1 - clamp((at - globalShake.start) / globalShake.duration)) }525 : { amplitude: 0 },526 blocked: blocked && at < blocked.outlineUntil ? { ...blocked } : null,527 tint: tint && tint.duration ? { progress: 1 - clamp((at - tint.start) / tint.duration), color: 'cool' } : null,528 wipe: wipe && wipe.duration ? { progress: clamp((at - wipe.start) / wipe.duration) } : null,529 win: win ? { beatStart: win.beatStart, beatProgress, cardAt: win.cardAt, cardVisible: at >= win.cardAt } : null,530 reducedMotion: currentReducedMotion,531 };532 }533534 return {535 beginInput,536 consume,537 frame,538 snap,539 resetLevel,540 syncState,541 setReducedMotion,542 get reducedMotion() { return currentReducedMotion; },543 };544}545546export const JUICE_TIMINGS = Object.freeze({547 move: MOVE_MS,548 undo: UNDO_MS,549 ruleHold: RULE_HOLD_MS,550 ruleForm: RULE_FORM_MS,551 ruleRemove: RULE_REMOVE_MS,552 blocked: BLOCKED_MS,553});554555export default createJuice;