clone

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

feat(stage): show automata beats, cues, and wait controls

Dami committed 09aa413
commit
09aa413d9baa7558eea347e8cf3ce81a292d479e
parent
e6736ab18f149303984c3bd23215f9dcc5c4198b
author
Dami <dami.etoile@gmail.com>
10 files changed, +1024 -140
Msrc/audio.js2+
Msrc/input.js8+-
Msrc/juice.js53++-
Msrc/main.js54++-
Msrc/render/board.js600+++++++++++++++++++++++++-----
Msrc/stage.js240+++++++++++-
Mtests/board.test.js10+-
Atests/browser/automata.mjs150++++++++
Mtests/input.test.js9+
Mtests/juice.test.js38++
M src/audio.js +2 -0
M src/input.js +4 -4
@@ -113,7 +113,7 @@ export function createInput(options = {}) {
113113 if (typeof dirOrIntent === 'string') {114114 if (DIRECTIONS[dirOrIntent]) return pressDirection(dirOrIntent, source, `${source}:${dirOrIntent}`, time);115115 if (dirOrIntent === 'undo') return pressUndo(source, source, time);116- if (['confirm', 'restart', 'menu', 'mute', 'summary'].includes(dirOrIntent)) { emit({ type: dirOrIntent, source }); return true; }116+ if (['confirm', 'restart', 'menu', 'mute', 'summary', 'wait'].includes(dirOrIntent)) { emit({ type: dirOrIntent, source }); return true; }117117 return false;118118 }119119 if (!dirOrIntent || typeof dirOrIntent !== 'object') return false;
@@ -127,7 +127,7 @@ export function createInput(options = {}) {
127127 }128128 function pressAction(type, source = 'gamepad', time = now()) {129129 if (type === 'undo') return pressUndo(source, source, time);130- if (['confirm', 'restart', 'menu', 'mute', 'summary'].includes(type)) { emit({ type, source }); return true; }130+ if (['confirm', 'restart', 'menu', 'mute', 'summary', 'wait'].includes(type)) { emit({ type, source }); return true; }131131 return false;132132 }133133 function releaseAction(type, source = 'gamepad') {
@@ -148,7 +148,7 @@ export function createInput(options = {}) {
148148 if (!event.repeat) pressUndo('keyboard', `keyboard:${key}`);149149 return;150150 }151- const action = ({ Enter: 'confirm', Escape: 'menu', p: 'menu', P: 'menu', r: 'restart', R: 'restart', m: 'mute', M: 'mute', t: 'summary', T: 'summary' })[key];151+ const action = ({ Enter: 'confirm', ' ': 'wait', Escape: 'menu', p: 'menu', P: 'menu', r: 'restart', R: 'restart', m: 'mute', M: 'mute', t: 'summary', T: 'summary' })[key];152152 if (action) { event.preventDefault?.(); if (!event.repeat) emit({ type: action, source: 'keyboard' }); }153153 }154154 function handleKeyUp(event) {
@@ -199,7 +199,7 @@ export function createInput(options = {}) {
199199 if (dir) { pressDirection(dir, 'gamepad', 'gamepad:direction', time); gamepadDirection = dir; }200200 else gamepadDirection = null;201201 }202- const actionButtons = [[0, 'confirm'], [1, 'undo'], [3, 'restart'], [9, 'menu']];202+ const actionButtons = [[0, 'confirm'], [1, 'undo'], [2, 'wait'], [3, 'restart'], [9, 'menu']];203203 for (const [index, action] of actionButtons) {204204 const current = buttonPressed(pad.buttons?.[index]); const previous = Boolean(previousGamepadButtons[index]);205205 if (current && !previous) pressAction(action, 'gamepad', time);
M src/juice.js +46 -7
@@ -21,6 +21,8 @@ const TINT_MS = 140;
2121 const WIPE_MS = 260;2222 const WIN_BEAT_MS = 260;2323 const FACE_FLIP_MS = 70;24+const AUTOMATON_BEAT_MS = 90;25+const AUTOMATON_DELAY_MS = MOVE_MS + 45;2426 2527 const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));2628 const easeOut = value => 1 - ((1 - value) ** 3);
@@ -39,6 +41,8 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
3941 const entities = new Map();4042 const rules = new Map();4143 const sentences = new Map();44+ const automataPhases = new Map();45+ const automataTransitions = new Map();4246 let priorSentenceCells = new Map();4347 let priorSentences = new Map();4448 let globalShake = null;
@@ -102,6 +106,8 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
102106 entities.clear();103107 rules.clear();104108 sentences.clear();109+ automataPhases.clear();110+ automataTransitions.clear();105111 facingHistory.length = 0;106112 priorSentenceCells = new Map();107113 priorSentences = new Map();
@@ -126,6 +132,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
126132 /** Seed or reconcile visual positions from the settled engine state. */127133 function syncState(state, at = readNow()) {128134 if (!state || !Array.isArray(state.objects)) return;135+ for (const system of state.automata || []) if (!automataPhases.has(system.id)) automataPhases.set(system.id, system.phase);129136 priorSentenceCells = new Map();130137 priorSentences = new Map();131138 for (const sentence of state.sentences || []) {
@@ -172,10 +179,12 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
172179 entity.pushed = entry.pushedBy != null;173180 entity.dx = Math.sign(entity.x - entity.fromX);174181 entity.dy = Math.sign(entity.y - entity.fromY);175- // The board uses facing as a character cue. Only a horizontal move made176- // by the entity itself changes it; pushes and vertical moves preserve it.177- if (entry.pushedBy == null && entity.dx !== 0) {178- const nextFacing = facingFor(entity.dx, 0);182+ // A pushed actor can also move itself later in the same settled turn.183+ // Prefer that self movement for facing, even when the merged path began184+ // with a push; pure pushes and vertical self moves preserve facing.185+ const selfDx = entry.selfDirection?.dx ?? (entry.pushedBy == null ? entity.dx : 0);186+ if (selfDx !== 0) {187+ const nextFacing = facingFor(selfDx, 0);179188 if (nextFacing !== entity.facing) entity.faceFlipStart = at;180189 entity.facing = nextFacing;181190 }
@@ -285,6 +294,9 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
285294 snap(at);286295 blocked = null;287296 sentences.clear();297+ automataPhases.clear();298+ automataTransitions.clear();299+ for (const system of result.automata || state?.automata || []) automataPhases.set(system.id, system.phase);288300 facingHistory.length = 0;289301 for (const entity of entities.values()) {290302 entity.facing = null;
@@ -305,13 +317,35 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
305317 if (result.kind === 'undo') {306318 const restoredFacing = facingHistory.pop();307319 if (restoredFacing) for (const [id, facing] of restoredFacing) priorFacing.set(id, facing);308- } else if (result.kind === 'step' && result.moved?.length) {320+ for (const entity of entities.values()) entity.bounceDuration = 0;321+ } else if (result.kind === 'step' || result.kind === 'wait') {309322 for (const [id, entity] of entities) {310323 if (!String(id).startsWith('__spark__')) priorFacing.set(id, entity.facing);311324 }312325 facingHistory.push(priorFacing);313326 }314- for (const entry of result.moved || []) consumeMovement(entry, at, moveDuration);327+ for (const entry of result.moved || []) {328+ const start = entry.cause === 'automaton' && !currentReducedMotion ? at + AUTOMATON_DELAY_MS : at;329+ consumeMovement(entry, start, moveDuration);330+ }331+ for (const system of result.automata || state?.automata || []) {332+ const prior = automataPhases.get(system.id);333+ if (prior !== undefined && prior !== system.phase) automataTransitions.set(system.id, {334+ id: system.id,335+ fromPhase: prior,336+ toPhase: system.phase,337+ start: at + (result.kind === 'undo' ? 0 : AUTOMATON_DELAY_MS),338+ duration: duration(AUTOMATON_BEAT_MS),339+ wrapped: system.phase === 0 && prior === system.pattern?.length - 1,340+ });341+ automataPhases.set(system.id, system.phase);342+ }343+ if (result.kind === 'step' || result.kind === 'wait') for (const entry of result.automated || []) {344+ if (!entry.active || !entry.id) continue;345+ const entity = entityAt(entry.id);346+ entity.bounceStart = at + (currentReducedMotion ? 0 : AUTOMATON_DELAY_MS);347+ entity.bounceDuration = duration(BOUNCE_MS);348+ }315349 if (result.kind === 'undo') for (const [id, facing] of priorFacing) {316350 const entity = entityAt(id);317351 entity.facing = facing;
@@ -397,7 +431,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
397431 if (restoredDeathEffect) globalShake = null;398432 syncState(state, at);399433 }400- if (result.won && result.kind === 'step') win = { beatStart: at + moveDuration, cardAt: at + moveDuration + WIN_BEAT_MS };434+ if (result.won && (result.kind === 'step' || result.kind === 'wait')) win = { beatStart: at + moveDuration, cardAt: at + moveDuration + WIN_BEAT_MS };401435 }402436 403437 function entityVisual(id, at = readNow()) {
@@ -494,11 +528,16 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
494528 if (visual && (visual.phase !== 'removing' || visual.progress > 0)) visibleSentences.push(visual);495529 }496530 const beatProgress = win ? clamp((at - win.beatStart) / WIN_BEAT_MS) : 0;531+ const automata = [...automataTransitions.values()].map(transition => ({532+ ...transition,533+ progress: transition.duration ? clamp((at - transition.start) / transition.duration) : 1,534+ }));497535 return {498536 positions,499537 rules: visibleRules,500538 sentences: visibleSentences,501539 effects: effects(at),540+ automata,502541 shake: globalShake && globalShake.duration503542 ? { amplitude: globalShake.amplitude * (1 - clamp((at - globalShake.start) / globalShake.duration)) }504543 : { amplitude: 0 },
M src/main.js +46 -8
@@ -64,7 +64,7 @@ let hintPrepared;
6464 let feedback = '';6565 let feedbackTimer;6666 67-const glyphs = { MIAU:'🐈', CAT:'🐈', BABA:'🧒', FLAG:'🚩', ROCK:'🪨', WALL:'🧱', WATER:'💧', GRASS:'🌿', STAR:'⭐', KEY:'🔑', DOOR:'🚪', KEKE:'🦊', THORN:'🌵', GEM:'💎' };67+const glyphs = { MIAU:'🐈', CAT:'🐈', BABA:'🧒', FLAG:'🚩', ROCK:'🪨', WALL:'🧱', WATER:'💧', GRASS:'🌿', STAR:'⭐', KEY:'🔑', DOOR:'🚪', FENN:'🦊', THORN:'🌵', GEM:'💎' };6868 const colors = { YOU:'#f06d5f', PUSH:'#efb34a', STOP:'#625e88', WIN:'#2d9c88', SINK:'#6f9ca8', DEFEAT:'#b45273' };6969 const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));7070 const errorText = errors => (errors || []).map(error => error?.message || error?.detail || String(error)).join('\n');
@@ -161,7 +161,11 @@ function render() {
161161 }162162 function playSound(name) { if (!soundOn) return; audio.play(name === 'rule' ? 'ruleFormed' : name); }163163 function playTurnSounds(result) {164- playSound(result.moved?.some(entry => entry.pushedBy) ? 'push' : 'move');164+ const cues = new Set();165+ if (result.moved?.length) cues.add(result.moved.some(entry => entry.pushedBy) ? 'push' : 'move');166+ const acting = (result.automated || []).filter(entry => entry.active && entry.id);167+ if (acting.length) cues.add(acting.find(entry => entry.sound)?.sound || 'tick');168+ for (const cue of cues) playSound(cue);165169 if (result.rulesRemoved?.length) playSound('ruleBroken');166170 if (result.rulesAdded?.length) playSound('ruleFormed');167171 if (result.transformed?.length) playSound('transform');
@@ -177,15 +181,17 @@ function announceTurn(result) {
177181 const formed = (result.rulesAdded || []).map(rule => `formed ${rule.text}`);178182 const broken = (result.rulesRemoved || []).map(rule => `broken ${rule.text}`);179183 const active = state.rules.length ? `Active rules: ${state.rules.map(rule => rule.text).join(', ')}.` : 'No active rules.';184+ const beats = (state.automata || []).filter(system => system.active).map(system => `${system.subject} ${system.property} beat ${system.phase + 1} of ${system.pattern.length}.`);180185 const status = result.won ? 'Proof complete.' : result.controlLost ? 'Nothing is YOU.' : '';181- announceLive([...formed, ...broken, active, status].filter(Boolean).join(' '));186+ announceLive([...formed, ...broken, active, ...beats, status].filter(Boolean).join(' '));182187 }183188 function announceBoardSummary() {184189 let board;185190 try { board = JSON.parse(window.render_game_to_text?.() || '{}'); } catch { board = {}; }186191 const rules = board.rules?.map(rule => rule.text).filter(Boolean) || [];187192 const objects = board.objects?.map(object => `${object.kind === 'word' ? object.text : object.noun} at column ${Number(object.x) + 1}, row ${Number(object.y) + 1}`) || [];188- const summary = `${board.level || 'Board'}: ${board.mode || 'play'}, ${board.moves || 0} steps. ${rules.length ? `Rules: ${rules.join(', ')}.` : 'No active rules.'} ${objects.length ? `Objects: ${objects.slice(0, 24).join('; ')}.` : ''}`.trim();193+ const beats = board.automata?.map(system => `${system.subject} ${system.property}, ${system.active ? 'active' : 'inactive'}, beat ${system.phase + 1} of ${system.pattern.length}`) || [];194+ const summary = `${board.level || 'Board'}: ${board.mode || 'play'}, ${board.moves || 0} steps. ${rules.length ? `Rules: ${rules.join(', ')}.` : 'No active rules.'} ${beats.length ? `Automata: ${beats.join('; ')}.` : ''} ${objects.length ? `Objects: ${objects.slice(0, 24).join('; ')}.` : ''}`.trim();189195 announceLive(summary);190196 if (globalThis.speechSynthesis && globalThis.SpeechSynthesisUtterance) { globalThis.speechSynthesis.cancel(); globalThis.speechSynthesis.speak(new globalThis.SpeechSynthesisUtterance(summary)); }191197 }
@@ -206,17 +212,49 @@ function scheduleInitialWin() {
206212 }207213 function paintBoardFrame(now = performance.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }208214 function animateBoard(duration = 650) { animationDeadline = Math.max(animationDeadline,performance.now()+duration); if (animationRequest) return; const tick = now => { animationRequest=0; if (stage.scene === 'proof' || stage.scene === 'win') paintBoardFrame(now); if (now < animationDeadline) animationRequest=requestAnimationFrame(tick); }; animationRequest=requestAnimationFrame(tick); }209-function move(dx,dy) { if (!game || game.getState().won) return; const now=performance.now(); juice.syncState(game.getState(),now); juice.beginInput(now); const result=game.step(dx,dy); juice.consume(result,now,game.getState()); if(accepted(result)){feedback=''; playTurnSounds(result); announceTurn(result); if(game.getState().won){ const level=currentLevels()[levelNo]; if(level?.packId && level?.localId) { progress.markSolved(level.packId,level.localId); refreshIndex(level.id); } playSound('win'); clearTimeout(winTimer); const cardAt=juice.frame(now).win?.cardAt ?? now; winTimer=setTimeout(()=>{if(game?.getState().won)stage.show('win',{steps:game.getState().moves,nodeId:level?.id});},Math.max(0,cardAt-now)); } updateUI();render();paintBoardFrame(now);animateBoard();} else {playSound(result.blocked?.reason==='bounds'?'blockedBounds':'blockedStop');showFeedback('BLOCKED / try another sentence');paintBoardFrame(now);animateBoard(180);} }215+function performTurn(action) {216+ if (!game || game.getState().won) return;217+ const now = performance.now();218+ juice.syncState(game.getState(), now);219+ juice.beginInput(now);220+ const result = action();221+ juice.consume(result, now, game.getState());222+ if (accepted(result)) {223+ feedback = '';224+ playTurnSounds(result);225+ announceTurn(result);226+ if (game.getState().won) {227+ const level = currentLevels()[levelNo];228+ if (level?.packId && level?.localId) {229+ progress.markSolved(level.packId, level.localId);230+ refreshIndex(level.id);231+ }232+ playSound('win');233+ clearTimeout(winTimer);234+ const cardAt = juice.frame(now).win?.cardAt ?? now;235+ winTimer = setTimeout(() => {236+ if (game?.getState().won) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });237+ }, Math.max(0, cardAt - now));238+ }239+ updateUI(); render(); paintBoardFrame(now); animateBoard();240+ } else if (result.blocked) {241+ playSound(result.blocked.reason === 'bounds' ? 'blockedBounds' : 'blockedStop');242+ showFeedback('BLOCKED / try another sentence');243+ paintBoardFrame(now); animateBoard(180);244+ }245+}246+function move(dx, dy) { performTurn(() => game.step(dx, dy)); }247+function waitTurn() { if (typeof game?.wait === 'function') performTurn(() => game.wait()); }210248 function restart(){if(!game)return;clearTimeout(winTimer);const now=performance.now();juice.syncState(game.getState(),now);const result=game.reset();juice.consume(result,now,game.getState());juice.syncState(game.getState(),now);feedback='';playSound('restart');announceTurn(result);updateUI();render();stage.show('proof');paintBoardFrame(now);scheduleInitialWin();animateBoard(300);}211249 function undo(){if(!game)return;const now=performance.now();juice.syncState(game.getState(),now);const result=game.undo();if(accepted(result)){clearTimeout(winTimer);juice.consume(result,now,game.getState());feedback='';playSound('undo');announceTurn(result);updateUI();render();stage.show('proof');paintBoardFrame(now);animateBoard(300);}else playSound('undoEmpty');}212-function handleIntent(intent) { if (intent.type === 'summary') { announceBoardSummary(); return; } if (stage.scene === 'title') { if(intent.type==='menu'){stage.show('pause');return;} titleController.input(intent.type==='confirm'?'enter':intent.type==='undo'?'z':intent.type==='restart'?'r':intent.dir); stage.render(); return; } if (stage.scene === 'proof' || stage.scene === 'win') { if (intent.type === 'move' && stage.scene === 'proof') ({up: () => move(0,-1), down: () => move(0,1), left: () => move(-1,0), right: () => move(1,0)}[intent.dir])?.(); if (intent.type === 'undo') undo(); if (intent.type === 'restart') restart(); if (intent.type === 'menu' && stage.scene === 'proof') stage.show('pause'); if (intent.type === 'mute') soundButton.click(); if (intent.type === 'confirm' && stage.scene === 'win') stage.show('index'); return; } if(intent.source !== 'keyboard' && !(intent.source === 'touch' && intent.point)) stage.handleIntent?.(intent); }250+function handleIntent(intent) { if (intent.type === 'summary') { announceBoardSummary(); return; } if (stage.scene === 'title') { if(intent.type==='menu'){stage.show('pause');return;} titleController.input(intent.type==='confirm'?'enter':intent.type==='undo'?'z':intent.type==='restart'?'r':intent.dir); stage.render(); return; } if (stage.scene === 'proof' || stage.scene === 'win') { if (intent.type === 'move' && stage.scene === 'proof') ({up: () => move(0,-1), down: () => move(0,1), left: () => move(-1,0), right: () => move(1,0)}[intent.dir])?.(); if (intent.type === 'wait' && stage.scene === 'proof') waitTurn(); if (intent.type === 'undo') undo(); if (intent.type === 'restart') restart(); if (intent.type === 'menu' && stage.scene === 'proof') stage.show('pause'); if (intent.type === 'mute') soundButton.click(); if (intent.type === 'confirm' && stage.scene === 'win') stage.show('index'); return; } if(intent.source !== 'keyboard' && !(intent.source === 'touch' && intent.point)) stage.handleIntent?.(intent); }213251 document.querySelectorAll('[data-dir]').forEach(button=>button.addEventListener('click',()=>({up:move.bind(null,0,-1),down:move.bind(null,0,1),left:move.bind(null,-1,0),right:move.bind(null,1,0)}[button.dataset.dir])()));214252 document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));215253 function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}216254 document.querySelector('#fullscreen-btn').addEventListener('click',toggleFullscreen);217255 undoButton.addEventListener('click', undo); restartButton.addEventListener('click', restart); continueButton.addEventListener('click', () => { const next = nextLevelInPack(); if (next !== undefined && loadLevel(next)) { stage.show('proof'); scheduleInitialWin(); } });218256 soundButton.addEventListener('click', () => { soundOn = !soundOn; audio.mute(!soundOn); soundButton.textContent = soundOn ? '◖' : '○'; soundButton.setAttribute('aria-label', soundOn ? 'Mute sound' : 'Enable sound'); stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()}); });219-stage.setActions({ onPauseAction: action => { if(action==='restart') restart(); if(action==='sound') soundButton.click(); if(action==='music'){audio.setMusicEnabled(!audio.isMusicEnabled());stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});} } });257+stage.setActions({ onWaitAction: waitTurn, onPauseAction: action => { if(action==='restart') restart(); if(action==='sound') soundButton.click(); if(action==='music'){audio.setMusicEnabled(!audio.isMusicEnabled());stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});} } });220258 stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});221259 function reportPackError(error) { const errors=Array.isArray(error?.errors)?error.errors:[{message:error?.message||String(error),detail:error?.message||String(error)}];workshopController.showErrors(errors);stage.show('workshop');showModStatus(`Invalid pack: ${errors[0]?.message||'Unknown error'}`,errorText(errors)); }222260 function previewLocalText(source, filename) { const parsed=packService.importPackText(source, filename); if(!parsed.ok){const error=new Error(errorText(parsed.errors));error.errors=parsed.errors;throw error;} giftOrigin='local'; giftController.show(parsed.pack,{collision:parsed.conflict}); stage.show('gift'); return parsed.pack.id; }
@@ -229,7 +267,7 @@ function makeShareUrl() { const shared = packService.packLink(activePackId()); i
229267 exportButton?.addEventListener('click', downloadPack);230268 copyShareButton?.addEventListener('click', async () => { let value; try { value = makeShareUrl(); shareUrlInput.value = value; } catch (error) { showModStatus(error instanceof RangeError ? 'Share URL too large / export .cjs instead' : 'Could not create share URL', error.message); return; } try { await navigator.clipboard.writeText(value); showModStatus('Share URL copied'); } catch (error) { showModStatus('Share URL ready / copy it manually', error.message); } });231269 function sharedPayload() { return location.hash.startsWith('#pack=') ? location.hash.slice(6) : ''; }232-window.render_game_to_text=()=>JSON.stringify({coordinateSystem:'origin top-left; x right, y down',level:lastState?.title||'',mode:lastState?.won?'won':'play',moves:lastState?.moves||0,grid:{width:lastState?.width||0,height:lastState?.height||0},rules:lastState?.rules||[],objects:lastState?.objects?.map(({id,kind,noun,text,x,y})=>({id,kind,noun,text,x,y}))||[]});270+window.render_game_to_text=()=>JSON.stringify({coordinateSystem:'origin top-left; x right, y down',level:lastState?.title||'',mode:lastState?.won?'won':'play',moves:lastState?.moves||0,grid:{width:lastState?.width||0,height:lastState?.height||0},rules:lastState?.rules||[],automata:lastState?.automata||[],objects:lastState?.objects?.map(({id,kind,noun,text,x,y})=>({id,kind,noun,text,x,y}))||[]});233271 window.advanceTime=ms=>{render();return ms};234272 const input = createInput({ onIntent: handleIntent });235273 input.attach({ stage: stage.canvas });
M src/render/board.js +495 -105
@@ -6,29 +6,73 @@
66 */77 88 export const BOARD_COLORS = Object.freeze({9- stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',10- tile: '#F2EEE6', ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C',11- wall: '#252321', dot: '#4A4540',12- YOU: '#FF4B3E', PUSH: '#FFB020', STOP: '#8B91FF', WIN: '#2EE6A8', SINK: '#3AB8FF', DEFEAT: '#FF4FA3',9+ stage: "#0F0E0D",10+ board: "#191816",11+ edge: "#2E2B28",12+ lip: "#000000",13+ tile: "#F2EEE6",14+ ink: "#0F0E0D",15+ fg: "#F2EEE6",16+ muted: "#8E877C",17+ wall: "#252321",18+ dot: "#4A4540",19+ YOU: "#FF4B3E",20+ PUSH: "#FFB020",21+ STOP: "#8B91FF",22+ WIN: "#2EE6A8",23+ SINK: "#3AB8FF",24+ DEFEAT: "#FF4FA3",25+ TICK: "#C6F432",1326 });1427 15-const PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);16-const OPERATORS = new Set(['IS', 'AND']);28+const PROPERTIES = new Set([29+ "YOU",30+ "PUSH",31+ "STOP",32+ "WIN",33+ "SINK",34+ "DEFEAT",35+ "TICK",36+]);37+const OPERATORS = new Set(["IS", "AND"]);1738 // These glyphs depict an actor with a readable horizontal facing. Terrain,1839 // flags, doors, and abstract symbols stay untouched when a frame has facing data.19-const DIRECTIONAL_NOUNS = new Set(['MIAU', 'KEKE', 'CAT', 'DOG', 'FOX', 'RABBIT', 'MOUSE', 'ROBOT', 'GHOST', 'BIRD', 'FISH', 'MONSTER']);40+const DIRECTIONAL_NOUNS = new Set([41+ "MIAU",42+ "FENN",43+ "CAT",44+ "DOG",45+ "FOX",46+ "RABBIT",47+ "MOUSE",48+ "ROBOT",49+ "GHOST",50+ "BIRD",51+ "FISH",52+ "MONSTER",53+]);2054 const FONT = "'Recursive', ui-monospace, monospace";2155 22-function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }23-function role(text) { return OPERATORS.has(text) ? 'operator' : PROPERTIES.has(text) ? 'property' : 'noun'; }56+function clamp(value, min, max) {57+ return Math.max(min, Math.min(max, value));58+}59+function role(text) {60+ return OPERATORS.has(text)61+ ? "operator"62+ : PROPERTIES.has(text)63+ ? "property"64+ : "noun";65+}2466 function roundRect(ctx, x, y, width, height, radius) {2567 ctx.beginPath();26- if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);68+ if (typeof ctx.roundRect === "function")69+ ctx.roundRect(x, y, width, height, radius);2770 else ctx.rect(x, y, width, height);2871 }2972 function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = FONT) {3073 let size = baseSize;31- if (typeof ctx.measureText !== 'function') return `${weight} ${size}px ${family}`;74+ if (typeof ctx.measureText !== "function")75+ return `${weight} ${size}px ${family}`;3276 while (size > baseSize * 0.55) {3377 ctx.font = `${weight} ${size}px ${family}`;3478 if (ctx.measureText(text).width <= maxWidth) break;
@@ -38,19 +82,35 @@ function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = FONT) {
3882 }3983 function propertiesFor(object, state, game) {4084 if (game?.properties) return new Set(game.properties(object));41- if (object.kind === 'word') return new Set(['PUSH']);42- return new Set((state.rules || []).filter(rule => rule.subject === object.noun && PROPERTIES.has(rule.predicate)).map(rule => rule.predicate));85+ if (object.kind === "word") return new Set(["PUSH"]);86+ return new Set(87+ (state.rules || [])88+ .filter(89+ (rule) =>90+ rule.subject === object.noun && PROPERTIES.has(rule.predicate),91+ )92+ .map((rule) => rule.predicate),93+ );4394 }4495 function horizontalFacing(visual) {4596 const facing = visual?.facing;46- if (typeof facing === 'string') return facing.toLowerCase() === 'left' ? 'left' : facing.toLowerCase() === 'right' ? 'right' : null;47- const dx = Number.isFinite(facing?.x) ? facing.x : Number.isFinite(visual?.dx) ? visual.dx : null;48- return dx < 0 ? 'left' : dx > 0 ? 'right' : null;97+ if (typeof facing === "string")98+ return facing.toLowerCase() === "left"99+ ? "left"100+ : facing.toLowerCase() === "right"101+ ? "right"102+ : null;103+ const dx = Number.isFinite(facing?.x)104+ ? facing.x105+ : Number.isFinite(visual?.dx)106+ ? visual.dx107+ : null;108+ return dx < 0 ? "left" : dx > 0 ? "right" : null;49109 }50110 function isEmojiGlyph(glyph) {51- return Array.from(String(glyph || '')).some(character => {111+ return Array.from(String(glyph || "")).some((character) => {52112 const code = character.codePointAt(0);53- return code > 0x1f000 || character === '\uFE0F';113+ return code > 0x1f000 || character === "\uFE0F";54114 });55115 }56116 function boardGeometry(ctx, state, options) {
@@ -58,51 +118,119 @@ function boardGeometry(ctx, state, options) {
58118 const height = state.height || 0;59119 const x = options.x ?? options.ox ?? 0;60120 const y = options.y ?? options.oy ?? 0;61- const availableWidth = options.availableWidth ?? ctx.canvas?.width ?? width * 64;62- const availableHeight = options.availableHeight ?? ctx.canvas?.height ?? height * 64;63- const cell = options.cell ?? clamp(Math.floor(Math.min(availableWidth / Math.max(width, 1), availableHeight / Math.max(height, 1))), 22, 96);64- return { x, y, cell, width, height, boardWidth: width * cell, boardHeight: height * cell };121+ const availableWidth =122+ options.availableWidth ?? ctx.canvas?.width ?? width * 64;123+ const availableHeight =124+ options.availableHeight ?? ctx.canvas?.height ?? height * 64;125+ const cell =126+ options.cell ??127+ clamp(128+ Math.floor(129+ Math.min(130+ availableWidth / Math.max(width, 1),131+ availableHeight / Math.max(height, 1),132+ ),133+ ),134+ 22,135+ 96,136+ );137+ return {138+ x,139+ y,140+ cell,141+ width,142+ height,143+ boardWidth: width * cell,144+ boardHeight: height * cell,145+ };65146 }66147 67148 function drawSlab(ctx, geometry, colors) {68149 const { x, y, cell, boardWidth, boardHeight } = geometry;69150 const pad = Math.round(cell * 0.22);70151 ctx.fillStyle = colors.lip;71- ctx.fillRect(x - pad + 8, y - pad + 8, boardWidth + pad * 2, boardHeight + pad * 2);72- roundRect(ctx, x - pad, y - pad, boardWidth + pad * 2, boardHeight + pad * 2, 4);73- ctx.fillStyle = colors.board; ctx.fill();74- ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();152+ ctx.fillRect(153+ x - pad + 8,154+ y - pad + 8,155+ boardWidth + pad * 2,156+ boardHeight + pad * 2,157+ );158+ roundRect(159+ ctx,160+ x - pad,161+ y - pad,162+ boardWidth + pad * 2,163+ boardHeight + pad * 2,164+ 4,165+ );166+ ctx.fillStyle = colors.board;167+ ctx.fill();168+ ctx.strokeStyle = colors.edge;169+ ctx.lineWidth = 1;170+ ctx.stroke();75171 ctx.fillStyle = colors.dot;76- for (let iy = 0; iy <= geometry.height; iy++) for (let ix = 0; ix <= geometry.width; ix++) {77- ctx.beginPath(); ctx.arc(x + ix * cell, y + iy * cell, Math.max(1.2, cell * 0.022), 0, Math.PI * 2); ctx.fill();78- }172+ for (let iy = 0; iy <= geometry.height; iy++)173+ for (let ix = 0; ix <= geometry.width; ix++) {174+ ctx.beginPath();175+ ctx.arc(176+ x + ix * cell,177+ y + iy * cell,178+ Math.max(1.2, cell * 0.022),179+ 0,180+ Math.PI * 2,181+ );182+ ctx.fill();183+ }79184 }80185 81186 function drawTile(ctx, text, cx, cy, cell, active, colors) {82187 const kind = role(text);83188 const width = cell * 0.74;84- const height = kind === 'operator' ? cell * 0.48 : cell * 0.74;189+ const height = kind === "operator" ? cell * 0.48 : cell * 0.74;85190 const left = cx - width / 2;86191 const top = cy - height / 2;87192 const lip = cell * 0.04;88193 const shape = (dx, dy) => {89- if (kind === 'operator') { roundRect(ctx, left + dx, top + dy, width, height, height / 2); return; }90- if (kind === 'property') {194+ if (kind === "operator") {195+ roundRect(ctx, left + dx, top + dy, width, height, height / 2);196+ return;197+ }198+ if (kind === "property") {91199 const chamfer = cell * 0.14;92- ctx.beginPath(); ctx.moveTo(left + dx, top + dy); ctx.lineTo(left + dx + width - chamfer, top + dy);93- ctx.lineTo(left + dx + width, top + dy + chamfer); ctx.lineTo(left + dx + width, top + dy + height);94- ctx.lineTo(left + dx, top + dy + height); ctx.closePath(); return;200+ ctx.beginPath();201+ ctx.moveTo(left + dx, top + dy);202+ ctx.lineTo(left + dx + width - chamfer, top + dy);203+ ctx.lineTo(left + dx + width, top + dy + chamfer);204+ ctx.lineTo(left + dx + width, top + dy + height);205+ ctx.lineTo(left + dx, top + dy + height);206+ ctx.closePath();207+ return;95208 }96209 roundRect(ctx, left + dx, top + dy, width, height, cell * 0.04);97210 };98211 ctx.save();99212 ctx.globalAlpha *= active ? 1 : 0.4;100- if (kind !== 'operator') { shape(lip, lip); ctx.fillStyle = colors.lip; ctx.fill(); }213+ if (kind !== "operator") {214+ shape(lip, lip);215+ ctx.fillStyle = colors.lip;216+ ctx.fill();217+ }101218 shape(0, 0);102- if (kind === 'operator') { ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.fg; ctx.lineWidth = Math.max(2, cell * 0.035); ctx.stroke(); }103- else { ctx.fillStyle = kind === 'property' ? (colors[text] || colors.ink) : colors.tile; ctx.fill(); }219+ if (kind === "operator") {220+ ctx.fillStyle = colors.board;221+ ctx.fill();222+ ctx.strokeStyle = colors.fg;223+ ctx.lineWidth = Math.max(2, cell * 0.035);224+ ctx.stroke();225+ } else {226+ ctx.fillStyle =227+ kind === "property" ? colors[text] || colors.ink : colors.tile;228+ ctx.fill();229+ }104230 ctx.font = fitFont(ctx, text, width * 0.84, cell * 0.25, active ? 800 : 600);105- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = kind === 'operator' ? colors.fg : colors.ink;231+ ctx.textAlign = "center";232+ ctx.textBaseline = "middle";233+ ctx.fillStyle = kind === "operator" ? colors.fg : colors.ink;106234 ctx.fillText(text, cx, cy + cell * 0.012);107235 ctx.restore();108236 }
@@ -111,41 +239,79 @@ function drawWall(ctx, object, geometry, walls, colors, visual = object) {
111239 const { x, y, cell } = geometry;112240 const left = x + visual.x * cell;113241 const top = y + visual.y * cell;114- ctx.fillStyle = colors.wall; ctx.fillRect(left, top, cell, cell);115- ctx.save(); ctx.beginPath(); ctx.rect(left, top, cell, cell); ctx.clip();116- ctx.strokeStyle = 'rgba(242,238,230,.08)'; ctx.lineWidth = 2;242+ ctx.fillStyle = colors.wall;243+ ctx.fillRect(left, top, cell, cell);244+ ctx.save();245+ ctx.beginPath();246+ ctx.rect(left, top, cell, cell);247+ ctx.clip();248+ ctx.strokeStyle = "rgba(242,238,230,.08)";249+ ctx.lineWidth = 2;117250 for (let offset = -cell; offset < cell * 2; offset += cell / 4) {118- ctx.beginPath(); ctx.moveTo(left + offset, top); ctx.lineTo(left + offset - cell, top + cell); ctx.stroke();251+ ctx.beginPath();252+ ctx.moveTo(left + offset, top);253+ ctx.lineTo(left + offset - cell, top + cell);254+ ctx.stroke();119255 }120256 ctx.restore();121257 }122258 123-function drawSentenceBars(ctx, state, geometry, colors, objectById, frame = null, visualOf = object => object) {259+function drawSentenceBars(260+ ctx,261+ state,262+ geometry,263+ colors,264+ objectById,265+ frame = null,266+ visualOf = (object) => object,267+) {124268 const { x, y, cell } = geometry;125- const sentences = frame && Array.isArray(frame.sentences) && frame.sentences.length126- ? frame.sentences127- : (Array.isArray(state.sentences) ? state.sentences : []);269+ const sentences =270+ frame && Array.isArray(frame.sentences) && frame.sentences.length271+ ? frame.sentences272+ : Array.isArray(state.sentences)273+ ? state.sentences274+ : [];128275 for (const sentence of sentences) {129- const objects = sentence.ids.map(id => objectById.get(id)).filter(Boolean);130- const words = objects.filter(object => object.kind === 'word');276+ const objects = sentence.ids277+ .map((id) => objectById.get(id))278+ .filter(Boolean);279+ const words = objects.filter((object) => object.kind === "word");131280 const cells = sentence.frozenCells?.length132- ? sentence.frozenCells.map(cell => ({ x: cell.x, y: cell.y }))133- : words.map(word => { const visual = visualOf(word); return { x: visual.x, y: visual.y }; });281+ ? sentence.frozenCells.map((cell) => ({ x: cell.x, y: cell.y }))282+ : words.map((word) => {283+ const visual = visualOf(word);284+ return { x: visual.x, y: visual.y };285+ });134286 if (!cells.length) continue;135- const progress = Number.isFinite(sentence.progress) ? Math.max(0, Math.min(1, sentence.progress)) : 1;287+ const progress = Number.isFinite(sentence.progress)288+ ? Math.max(0, Math.min(1, sentence.progress))289+ : 1;136290 // Sentence bars are persistent structure signals, so they stay neutral;137291 // property colors belong to the tiles and transient rule effects.138292 ctx.fillStyle = colors.fg;139- if (sentence.axis === 'v') {140- const px = Math.min(...cells.map(cellPosition => cellPosition.x));141- const y0 = Math.min(...cells.map(cellPosition => cellPosition.y));142- const y1 = Math.max(...cells.map(cellPosition => cellPosition.y));143- ctx.fillRect(x + px * cell + cell * 0.045, y + y0 * cell + cell * 0.16, Math.max(3, cell * 0.04), (y1 - y0 + 0.68) * cell * progress);293+ if (sentence.axis === "v") {294+ const px = Math.min(...cells.map((cellPosition) => cellPosition.x));295+ const y0 = Math.min(...cells.map((cellPosition) => cellPosition.y));296+ const y1 = Math.max(...cells.map((cellPosition) => cellPosition.y));297+ ctx.fillRect(298+ x + px * cell + cell * 0.045,299+ y + y0 * cell + cell * 0.16,300+ Math.max(3, cell * 0.04),301+ (y1 - y0 + 0.68) * cell * progress,302+ );144303 } else {145- const py = Math.min(...cells.map(cellPosition => cellPosition.y));146- const x0 = Math.min(...cells.map(cellPosition => cellPosition.x));147- const x1 = Math.max(...cells.map(cellPosition => cellPosition.x));148- roundRect(ctx, x + x0 * cell + cell * 0.13, y + (py + 0.935) * cell, (x1 - x0 + 0.78) * cell * progress, Math.max(3, cell * 0.04), cell * 0.02);304+ const py = Math.min(...cells.map((cellPosition) => cellPosition.y));305+ const x0 = Math.min(...cells.map((cellPosition) => cellPosition.x));306+ const x1 = Math.max(...cells.map((cellPosition) => cellPosition.x));307+ roundRect(308+ ctx,309+ x + x0 * cell + cell * 0.13,310+ y + (py + 0.935) * cell,311+ (x1 - x0 + 0.78) * cell * progress,312+ Math.max(3, cell * 0.04),313+ cell * 0.02,314+ );149315 ctx.fill();150316 }151317 }
@@ -163,105 +329,307 @@ function drawFrameEffects(ctx, frame, geometry, colors) {
163329 const cx = x + (cellX + 0.5) * cell;164330 const cy = y + (cellY + 0.5) * cell;165331 const color = colors[effect.color] || colors.fg;166- ctx.save(); ctx.globalAlpha = 1 - progress; ctx.fillStyle = color; ctx.strokeStyle = color;167- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';168- if (effect.type === 'transform') {332+ ctx.save();333+ ctx.globalAlpha = 1 - progress;334+ ctx.fillStyle = color;335+ ctx.strokeStyle = color;336+ ctx.textAlign = "center";337+ ctx.textBaseline = "middle";338+ if (effect.type === "transform") {169339 ctx.font = `800 ${Math.max(12, cell * 0.22)}px ${FONT}`;170- ctx.fillText(effect.text || '', cx, cy - cell * progress * 0.3);340+ ctx.fillText(effect.text || "", cx, cy - cell * progress * 0.3);171341 } else {172342 ctx.font = `800 ${Math.max(15, cell * 0.34)}px ${FONT}`;173- ctx.fillText(effect.text || (effect.type === 'sink' ? 'SINK' : 'DEFEAT'), cx, cy - cell * progress * 0.22);343+ ctx.fillText(344+ effect.text || (effect.type === "sink" ? "SINK" : "DEFEAT"),345+ cx,346+ cy - cell * progress * 0.22,347+ );174348 }175349 ctx.restore();176350 }177351 if (frame.tint?.progress > 0) {178- ctx.save(); ctx.globalAlpha = Math.max(0, Math.min(1, frame.tint.progress)) * 0.18; ctx.fillStyle = '#6CA9D8';179- ctx.fillRect(x, y, boardWidth, boardHeight); ctx.restore();352+ ctx.save();353+ ctx.globalAlpha = Math.max(0, Math.min(1, frame.tint.progress)) * 0.18;354+ ctx.fillStyle = "#6CA9D8";355+ ctx.fillRect(x, y, boardWidth, boardHeight);356+ ctx.restore();180357 }181358 if (frame.wipe?.progress != null && frame.wipe.progress < 1) {182- ctx.save(); ctx.globalAlpha = 1 - Math.max(0, Math.min(1, frame.wipe.progress)); ctx.fillStyle = colors.stage;183- ctx.fillRect(x, y, boardWidth, boardHeight); ctx.restore();359+ ctx.save();360+ ctx.globalAlpha = 1 - Math.max(0, Math.min(1, frame.wipe.progress));361+ ctx.fillStyle = colors.stage;362+ ctx.fillRect(x, y, boardWidth, boardHeight);363+ ctx.restore();364+ }365+}366+367+function automataPhase(system, frame) {368+ const transition = frame?.automata?.find?.((item) => item.id === system.id);369+ if (!transition || !Number.isInteger(transition.toPhase))370+ return system.phase || 0;371+ return Number(transition.progress) >= 0.5372+ ? transition.toPhase373+ : (transition.fromPhase ?? system.phase ?? 0);374+}375+376+function drawAutomataCue(ctx, action, cx, cy, cell, colors) {377+ if (!action) return;378+ ctx.save();379+ ctx.strokeStyle = colors.TICK;380+ ctx.fillStyle = colors.TICK;381+ ctx.lineWidth = Math.max(2, cell * 0.035);382+ ctx.lineCap = "round";383+ if (action.action === "wait") {384+ const width = cell * 0.16;385+ for (const offset of [-width, 0, width]) {386+ ctx.beginPath();387+ ctx.moveTo(cx + offset, cy - cell * 0.11);388+ ctx.lineTo(cx + offset, cy + cell * 0.11);389+ ctx.stroke();390+ }391+ } else {392+ const dx = action.dx || 0;393+ const dy = action.dy || 0;394+ const tipX = cx + dx * cell * 0.2;395+ const tipY = cy + dy * cell * 0.2;396+ const tailX = cx - dx * cell * 0.14;397+ const tailY = cy - dy * cell * 0.14;398+ const wingX = dy * cell * 0.1;399+ const wingY = dx * cell * 0.1;400+ ctx.beginPath();401+ ctx.moveTo(tailX, tailY);402+ ctx.lineTo(tipX, tipY);403+ ctx.moveTo(tipX, tipY);404+ ctx.lineTo(tipX - dx * cell * 0.1 + wingX, tipY - dy * cell * 0.1 + wingY);405+ ctx.moveTo(tipX, tipY);406+ ctx.lineTo(tipX - dx * cell * 0.1 - wingX, tipY - dy * cell * 0.1 - wingY);407+ ctx.stroke();408+ }409+ ctx.restore();410+}411+412+function drawAutomataIndicators(ctx, state, geometry, colors, game, frame) {413+ const systems = Array.isArray(state.automata)414+ ? state.automata.filter(415+ (system) => system?.active && system?.pattern?.length,416+ )417+ : [];418+ if (!systems.length) return;419+ const objects = Array.isArray(state.objects) ? state.objects : [];420+ const positions = frame?.positions instanceof Map ? frame.positions : null;421+ for (const system of systems) {422+ const phase = automataPhase(system, frame) % system.pattern.length;423+ const action = system.pattern[phase];424+ if (!action) continue;425+ const actors = objects.filter(426+ (object) =>427+ object.kind === "object" &&428+ (!system.subject ||429+ String(object.noun).toUpperCase() ===430+ String(system.subject).toUpperCase()) &&431+ propertiesFor(object, state, game).has(system.property),432+ );433+ actors.forEach((object, index) => {434+ const visual = positions?.get(object.id) || object;435+ const stackOffset = Math.min(index, 2) * 0.09;436+ const cellX = geometry.x + (visual.x + 0.5 + stackOffset) * geometry.cell;437+ const cellY = geometry.y + (visual.y + 0.5 - stackOffset) * geometry.cell;438+ drawAutomataCue(439+ ctx,440+ action,441+ cellX + geometry.cell * 0.31,442+ cellY - geometry.cell * 0.27,443+ geometry.cell,444+ colors,445+ );446+ });184447 }185448 }186449 187450 function drawLost(ctx, geometry, colors) {188451 const { x, y, cell, boardWidth } = geometry;189- const words = ['NOTHING', 'IS', 'YOU'];452+ const words = ["NOTHING", "IS", "YOU"];190453 const height = Math.max(18, cell * 0.34);191- const widths = words.map(text => { ctx.font = `800 ${height * 0.44}px ${FONT}`; return Math.max(height, ctx.measureText(text).width + height * 0.6); });454+ const widths = words.map((text) => {455+ ctx.font = `800 ${height * 0.44}px ${FONT}`;456+ return Math.max(height, ctx.measureText(text).width + height * 0.6);457+ });192458 const gap = 6;193459 const total = widths.reduce((sum, width) => sum + width, 0) + gap * 2;194460 let left = x + (boardWidth - total) / 2;195461 const cy = y + geometry.boardHeight / 2;196462 words.forEach((word, index) => {197463 const width = widths[index];198- roundRect(ctx, left, cy - height / 2, width, height, word === 'IS' ? height / 2 : height * 0.1);199- ctx.fillStyle = word === 'YOU' ? colors.YOU : word === 'IS' ? colors.board : colors.tile; ctx.fill();200- ctx.fillStyle = word === 'IS' ? colors.fg : colors.ink; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';201- ctx.fillText(word, left + width / 2, cy); left += width + gap;464+ roundRect(465+ ctx,466+ left,467+ cy - height / 2,468+ width,469+ height,470+ word === "IS" ? height / 2 : height * 0.1,471+ );472+ ctx.fillStyle =473+ word === "YOU" ? colors.YOU : word === "IS" ? colors.board : colors.tile;474+ ctx.fill();475+ ctx.fillStyle = word === "IS" ? colors.fg : colors.ink;476+ ctx.textAlign = "center";477+ ctx.textBaseline = "middle";478+ ctx.fillText(word, left + width / 2, cy);479+ left += width + gap;202480 });203- ctx.fillStyle = colors.YOU; ctx.font = `700 ${Math.max(11, cell * 0.15)}px ${FONT}`; ctx.textAlign = 'center';204- ctx.fillText('Z TO UNDO | R TO RESTART', x + boardWidth / 2, cy + height * 1.55);481+ ctx.fillStyle = colors.YOU;482+ ctx.font = `700 ${Math.max(11, cell * 0.15)}px ${FONT}`;483+ ctx.textAlign = "center";484+ ctx.fillText(485+ "Z TO UNDO | R TO RESTART",486+ x + boardWidth / 2,487+ cy + height * 1.55,488+ );205489 }206490 207491 /** Draw a board and return its computed geometry. */208492 export function drawBoard(ctx, state, options = {}) {209- if (!ctx || !state || !Number.isInteger(state.width) || !Number.isInteger(state.height)) throw new TypeError('drawBoard(ctx, state): state needs integer width and height');493+ if (494+ !ctx ||495+ !state ||496+ !Number.isInteger(state.width) ||497+ !Number.isInteger(state.height)498+ )499+ throw new TypeError(500+ "drawBoard(ctx, state): state needs integer width and height",501+ );210502 const colors = { ...BOARD_COLORS, ...(options.colors || {}) };211503 const geometry = boardGeometry(ctx, state, options);212504 const frame = options.frame || null;213- const framePositions = frame?.positions instanceof Map ? frame.positions : null;214- const visualOf = object => {505+ const framePositions =506+ frame?.positions instanceof Map ? frame.positions : null;507+ const visualOf = (object) => {215508 const visual = framePositions?.get(object.id);216509 return visual ? { ...object, ...visual } : object;217510 };218511 const shake = Number(frame?.shake?.amplitude) || 0;219- if (frame && shake) { ctx.save(); ctx.translate(shake * 0.35, -shake * 0.2); }512+ if (frame && shake) {513+ ctx.save();514+ ctx.translate(shake * 0.35, -shake * 0.2);515+ }220516 drawSlab(ctx, geometry, colors);221517 const objects = Array.isArray(state.objects) ? state.objects : [];222- const objectById = new Map(objects.map(object => [object.id, object]));223- const walls = new Set(objects.filter(object => object.kind === 'object' && (options.wallNouns || ['WALL']).includes(object.noun)).map(object => `${object.x},${object.y}`));518+ const objectById = new Map(objects.map((object) => [object.id, object]));519+ const walls = new Set(520+ objects521+ .filter(522+ (object) =>523+ object.kind === "object" &&524+ (options.wallNouns || ["WALL"]).includes(object.noun),525+ )526+ .map((object) => `${object.x},${object.y}`),527+ );224528 const stacks = new Map();225- objects.forEach(object => { const key = `${object.x},${object.y}`; stacks.set(key, (stacks.get(key) || 0) + 1); });529+ objects.forEach((object) => {530+ const key = `${object.x},${object.y}`;531+ stacks.set(key, (stacks.get(key) || 0) + 1);532+ });226533 const game = options.game;227534 const sorted = [...objects].sort((a, b) => {228- const rank = object => object.kind === 'word' ? 3 : walls.has(`${object.x},${object.y}`) ? 0 : propertiesFor(object, state, game).has('YOU') ? 2 : 1;535+ const rank = (object) =>536+ object.kind === "word"537+ ? 3538+ : walls.has(`${object.x},${object.y}`)539+ ? 0540+ : propertiesFor(object, state, game).has("YOU")541+ ? 2542+ : 1;229543 return rank(a) - rank(b);230544 });231- const activeIds = new Set((state.sentences || []).flatMap(sentence => sentence.ids || []));545+ const activeIds = new Set(546+ (state.sentences || []).flatMap((sentence) => sentence.ids || []),547+ );232548 const stackIndices = new Map();233549 for (const object of sorted) {234550 const key = `${object.x},${object.y}`;235- const index = stackIndices.get(key) || 0; stackIndices.set(key, index + 1);551+ const index = stackIndices.get(key) || 0;552+ stackIndices.set(key, index + 1);236553 const count = stacks.get(key) || 1;237554 const visual = visualOf(object);238- if (object.kind === 'object' && (options.wallNouns || ['WALL']).includes(object.noun)) { drawWall(ctx, object, geometry, walls, colors, visual); continue; }555+ if (556+ object.kind === "object" &&557+ (options.wallNouns || ["WALL"]).includes(object.noun)558+ ) {559+ drawWall(ctx, object, geometry, walls, colors, visual);560+ continue;561+ }239562 const offset = count > 1 ? index * 0.09 : 0;240563 const cx = geometry.x + (visual.x + 0.5 + offset) * geometry.cell;241564 const cy = geometry.y + (visual.y + 0.5 - offset) * geometry.cell;242565 const scaleX = Number.isFinite(visual.scaleX) ? visual.scaleX : 1;243566 const scaleY = Number.isFinite(visual.scaleY) ? visual.scaleY : 1;244- if (scaleX !== 1 || scaleY !== 1) { ctx.save(); ctx.translate(cx, cy); ctx.scale(scaleX, scaleY); ctx.translate(-cx, -cy); }245- if (object.kind === 'word') {246- ctx.save(); if (count > 1) ctx.rotate((index % 2 ? 1 : -1) * 0.07); drawTile(ctx, object.text, cx, cy, geometry.cell, activeIds.has(object.id), colors); ctx.restore();567+ if (scaleX !== 1 || scaleY !== 1) {568+ ctx.save();569+ ctx.translate(cx, cy);570+ ctx.scale(scaleX, scaleY);571+ ctx.translate(-cx, -cy);572+ }573+ if (object.kind === "word") {574+ ctx.save();575+ if (count > 1) ctx.rotate((index % 2 ? 1 : -1) * 0.07);576+ drawTile(577+ ctx,578+ object.text,579+ cx,580+ cy,581+ geometry.cell,582+ activeIds.has(object.id),583+ colors,584+ );585+ ctx.restore();247586 if (scaleX !== 1 || scaleY !== 1) ctx.restore();248587 continue;249588 }250589 const props = propertiesFor(object, state, game);251590 const paletteEntry = options.palette?.[object.noun];252- const paletteGlyph = paletteEntry && typeof paletteEntry === 'object' ? paletteEntry.glyph : paletteEntry;253- const glyph = paletteGlyph || options.glyphs?.[object.noun] || object.noun.slice(0, 3);254- if (props.has('YOU')) {255- ctx.fillStyle = `${colors.YOU}66`; ctx.beginPath(); ctx.ellipse(cx, geometry.y + (object.y + 0.82) * geometry.cell, geometry.cell * 0.26, geometry.cell * 0.06, 0, 0, Math.PI * 2); ctx.fill();591+ const paletteGlyph =592+ paletteEntry && typeof paletteEntry === "object"593+ ? paletteEntry.glyph594+ : paletteEntry;595+ const glyph =596+ paletteGlyph || options.glyphs?.[object.noun] || object.noun.slice(0, 3);597+ if (props.has("YOU")) {598+ ctx.fillStyle = `${colors.YOU}66`;599+ ctx.beginPath();600+ ctx.ellipse(601+ cx,602+ geometry.y + (object.y + 0.82) * geometry.cell,603+ geometry.cell * 0.26,604+ geometry.cell * 0.06,605+ 0,606+ 0,607+ Math.PI * 2,608+ );609+ ctx.fill();256610 }257611 ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;258- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;259- const nativeFacing = paletteEntry && typeof paletteEntry === 'object'260- ? (paletteEntry.faces === 'left' || paletteEntry.faces === 'right' ? paletteEntry.faces : null)261- : !paletteEntry && DIRECTIONAL_NOUNS.has(String(object.noun || '').toUpperCase()) ? 'left' : null;612+ ctx.textAlign = "center";613+ ctx.textBaseline = "middle";614+ ctx.fillStyle = colors.fg;615+ const nativeFacing =616+ paletteEntry && typeof paletteEntry === "object"617+ ? paletteEntry.faces === "left" || paletteEntry.faces === "right"618+ ? paletteEntry.faces619+ : null620+ : !paletteEntry &&621+ DIRECTIONAL_NOUNS.has(String(object.noun || "").toUpperCase())622+ ? "left"623+ : null;262624 const actualFacing = horizontalFacing(visual) || nativeFacing;263- const mirror = nativeFacing && actualFacing && nativeFacing !== actualFacing && isEmojiGlyph(glyph);264- const glyphScaleX = Number.isFinite(visual.glyphScaleX) ? visual.glyphScaleX : 1;625+ const mirror =626+ nativeFacing &&627+ actualFacing &&628+ nativeFacing !== actualFacing &&629+ isEmojiGlyph(glyph);630+ const glyphScaleX = Number.isFinite(visual.glyphScaleX)631+ ? visual.glyphScaleX632+ : 1;265633 // The Apple Color Emoji cat faces left by default. Mirror right-facing266634 // actors; left-facing and non-directional glyphs keep their native form.267635 if (mirror || glyphScaleX !== 1) {
@@ -274,13 +642,35 @@ export function drawBoard(ctx, state, options = {}) {
274642 } else {275643 ctx.fillText(glyph, cx, cy);276644 }277- if (count > 2 && index === 0) { ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ${FONT}`; ctx.fillText(`×${count}`, geometry.x + (object.x + 0.86) * geometry.cell, geometry.y + (object.y + 0.86) * geometry.cell); }645+ if (count > 2 && index === 0) {646+ ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ${FONT}`;647+ ctx.fillText(648+ `×${count}`,649+ geometry.x + (object.x + 0.86) * geometry.cell,650+ geometry.y + (object.y + 0.86) * geometry.cell,651+ );652+ }278653 if (scaleX !== 1 || scaleY !== 1) ctx.restore();279654 }655+ drawAutomataIndicators(ctx, state, geometry, colors, game, frame);280656 drawSentenceBars(ctx, state, geometry, colors, objectById, frame, visualOf);281657 drawFrameEffects(ctx, frame, geometry, colors);282- const hasYou = objects.some(object => propertiesFor(object, state, game).has('YOU'));283- if (!hasYou) { ctx.save(); ctx.globalAlpha = 0.68; ctx.fillStyle = colors.ink; ctx.fillRect(geometry.x, geometry.y, geometry.boardWidth, geometry.boardHeight); ctx.restore(); drawLost(ctx, geometry, colors); }658+ const hasYou = objects.some((object) =>659+ propertiesFor(object, state, game).has("YOU"),660+ );661+ if (!hasYou) {662+ ctx.save();663+ ctx.globalAlpha = 0.68;664+ ctx.fillStyle = colors.ink;665+ ctx.fillRect(666+ geometry.x,667+ geometry.y,668+ geometry.boardWidth,669+ geometry.boardHeight,670+ );671+ ctx.restore();672+ drawLost(ctx, geometry, colors);673+ }284674 if (frame && shake) ctx.restore();285675 return geometry;286676 }
M src/stage.js +229 -11
@@ -210,7 +210,13 @@ export function createStage(options = {}) {
210210 ctx.fillText(label, centerX, y + 27);211211 ctx.restore();212212 }213- function drawTouchControls(ctx, width, height, force = false) {213+ function drawTouchControls(214+ ctx,215+ width,216+ height,217+ force = false,218+ hasAutomata = false,219+ ) {214220 if (width > 760 && !force) return;215221 const y = height - 30;216222 const controls = [
@@ -241,8 +247,159 @@ export function createStage(options = {}) {
241247 ctx.fillStyle = "#8E877C";242248 ctx.font = "500 10px Recursive, monospace";243249 ctx.textAlign = "center";244- ctx.fillText("SWIPE TO WALK", width / 2, y - 22);250+ if (!hasAutomata) ctx.fillText("SWIPE TO WALK", width / 2, y - 22);251+ if (hasAutomata) {252+ const waitText = "SPACE WAIT";253+ ctx.font = "700 10px Recursive, monospace";254+ const waitWidth = ctx.measureText(waitText).width + 18;255+ const waitLeft = width / 2 - waitWidth / 2;256+ ctx.fillStyle = "#232120";257+ ctx.strokeStyle = "#4A4640";258+ ctx.fillRect(waitLeft, height - 78, waitWidth, 24);259+ ctx.strokeRect(waitLeft + 0.5, height - 77.5, waitWidth - 1, 23);260+ ctx.fillStyle = "#F2EEE6";261+ ctx.fillText(waitText, width / 2, height - 66);262+ }263+ ctx.restore();264+ }265+ function automataGlyph(subject) {266+ const entry = boardPalette?.[String(subject || "").toUpperCase()];267+ if (entry && typeof entry === "object" && entry.glyph) return entry.glyph;268+ if (typeof entry === "string" && entry.length) return entry;269+ return boardGlyphs?.[String(subject || "").toUpperCase()] || "⚙️";270+ }271+ function automataCue(action) {272+ if (typeof action?.cue === "string" && action.cue.trim())273+ return action.cue.trim().slice(0, 16);274+ if (action?.action === "wait") return "WAIT";275+ if (action?.dx === 1) return "→";276+ if (action?.dx === -1) return "←";277+ if (action?.dy === 1) return "↓";278+ if (action?.dy === -1) return "↑";279+ return "?";280+ }281+ function isEmojiCue(value) {282+ return Array.from(String(value || "")).some((character) => {283+ const code = character.codePointAt(0);284+ return code > 0x1f000 || character === "\uFE0F";285+ });286+ }287+ function drawAutomataStrip(ctx, automata, x, y, maxWidth, rules = []) {288+ const rows = Array.isArray(automata)289+ ? automata.filter((item) => item?.pattern?.length)290+ : [];291+ if (!rows.length) return { bottom: y, height: 0 };292+ const visibleRows = rows.slice(0, innerWidth <= 760 ? 2 : 3);293+ const rowHeight = innerWidth <= 760 ? 24 : 30;294+ const labelWidth = 76;295+ const beatGap = 5;296+ const maxBeats = Math.max(4, Math.floor((maxWidth - labelWidth) / 22));297+ ctx.save();298+ ctx.textBaseline = "middle";299+ visibleRows.forEach((system, rowIndex) => {300+ const rowY = y + rowIndex * rowHeight;301+ const phase = Number.isInteger(system.phase)302+ ? system.phase % system.pattern.length303+ : 0;304+ const derivedSubject =305+ system.subject ||306+ rules.find(307+ (rule) =>308+ String(rule.predicate || "").toUpperCase() ===309+ String(system.property || "").toUpperCase(),310+ )?.subject;311+ const beatIndexes =312+ system.pattern.length <= maxBeats313+ ? system.pattern.map((_, index) => index)314+ : [315+ ...new Set([316+ phase,317+ (phase + 1) % system.pattern.length,318+ (phase + system.pattern.length - 1) % system.pattern.length,319+ (phase + 2) % system.pattern.length,320+ ]),321+ ];322+ const omitted = system.pattern.length - beatIndexes.length;323+ const beatWidth = Math.max(324+ 14,325+ Math.min(326+ 34,327+ (maxWidth - labelWidth - beatGap * (beatIndexes.length - 1)) /328+ beatIndexes.length,329+ ),330+ );331+ const glyph = automataGlyph(derivedSubject);332+ ctx.fillStyle = "#F2EEE6";333+ ctx.font = `${Math.min(22, beatWidth)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;334+ ctx.textAlign = "left";335+ ctx.fillText(glyph, x, rowY + rowHeight / 2);336+ ctx.fillStyle = "#8E877C";337+ ctx.font = "700 9px Recursive, monospace";338+ ctx.fillText(339+ String(system.property || "").toUpperCase() || "AUTOMATA",340+ x + 28,341+ rowY + 10,342+ );343+ ctx.font = "600 9px Recursive, monospace";344+ ctx.fillText(345+ String(system.active ? "ACTIVE" : "DORMANT"),346+ x + 28,347+ rowY + 21,348+ );349+ beatIndexes.forEach((patternIndex, beatIndex) => {350+ const action = system.pattern[patternIndex];351+ const beatX = x + labelWidth + beatIndex * (beatWidth + beatGap);352+ const current = patternIndex === phase;353+ const next = patternIndex === (phase + 1) % system.pattern.length;354+ ctx.globalAlpha = current || next ? 1 : 0.4;355+ ctx.fillStyle = current ? "#C6F432" : "#191816";356+ ctx.strokeStyle = current || next ? "#C6F432" : "#4A4640";357+ ctx.lineWidth = current ? 2 : 1;358+ ctx.fillRect(beatX, rowY + 4, beatWidth, 22);359+ if (!current)360+ ctx.strokeRect(beatX + 0.5, rowY + 4.5, beatWidth - 1, 21);361+ const cue =362+ action.action === "wait" && beatWidth < 24363+ ? "○"364+ : automataCue(action);365+ const emojiCue = isEmojiCue(cue);366+ const cueSize = emojiCue367+ ? Math.min(16, beatWidth * 0.58)368+ : Math.min(369+ cue.length > 4 || action.action === "wait" ? 7 : 13,370+ beatWidth * 0.8,371+ );372+ ctx.fillStyle = current ? "#0F0E0D" : next ? "#0F0E0D" : "#8E877C";373+ ctx.font = `${current ? "800" : "600"} ${cueSize}px ${emojiCue ? '"Apple Color Emoji", "Segoe UI Emoji", sans-serif' : "Recursive, monospace"}`;374+ ctx.textAlign = "center";375+ ctx.fillText(cue, beatX + beatWidth / 2, rowY + 16);376+ ctx.globalAlpha = 1;377+ if (omitted && beatIndex === beatIndexes.length - 1) {378+ ctx.fillStyle = "#8E877C";379+ ctx.font = "600 8px Recursive, monospace";380+ ctx.fillText(381+ `+${omitted}`,382+ beatX + beatWidth / 2,383+ rowY + rowHeight - 4,384+ );385+ }386+ });387+ });388+ if (rows.length > visibleRows.length) {389+ ctx.fillStyle = "#8E877C";390+ ctx.font = "600 9px Recursive, monospace";391+ ctx.textAlign = "right";392+ ctx.fillText(393+ `+${rows.length - visibleRows.length} automata`,394+ x + maxWidth,395+ y + visibleRows.length * rowHeight - 4,396+ );397+ }245398 ctx.restore();399+ return {400+ bottom: y + visibleRows.length * rowHeight,401+ height: visibleRows.length * rowHeight,402+ };246403 }247404 function drawMiniTile(248405 ctx,
@@ -259,6 +416,7 @@ export function createStage(options = {}) {
259416 WIN: "#2EE6A8",260417 PUSH: "#F2C14E",261418 STOP: "#C6B7FF",419+ TICK: "#C6F432",262420 IS: "#F2EEE6",263421 AND: "#F2EEE6",264422 };
@@ -300,7 +458,7 @@ export function createStage(options = {}) {
300458 .filter(Boolean);301459 let cursor = x;302460 for (const word of words) {303- const role = ["YOU", "WIN", "PUSH", "STOP", "IS", "AND"].includes(461+ const role = ["YOU", "WIN", "PUSH", "STOP", "TICK", "IS", "AND"].includes(304462 word.toUpperCase(),305463 )306464 ? word.toUpperCase()
@@ -356,9 +514,15 @@ export function createStage(options = {}) {
356514 lineY += lineHeight;357515 cursor = x;358516 }359- const role = ["YOU", "WIN", "PUSH", "STOP", "IS", "AND"].includes(360- word.toUpperCase(),361- )517+ const role = [518+ "YOU",519+ "WIN",520+ "PUSH",521+ "STOP",522+ "TICK",523+ "IS",524+ "AND",525+ ].includes(word.toUpperCase())362526 ? word.toUpperCase()363527 : "noun";364528 drawMiniTile(ctx, word.toUpperCase(), cursor, lineY, role, 10);
@@ -456,6 +620,14 @@ export function createStage(options = {}) {
456620 return;457621 }458622 if (scene === "proof" && boardState) {623+ const automata = Array.isArray(boardState.automata)624+ ? boardState.automata.filter((system) => system?.pattern?.length)625+ : [];626+ const automataRows = Math.min(w <= 760 ? 2 : 3, automata.length);627+ const automataRowHeight = w <= 760 ? 24 : 30;628+ const automataReserve = automataRows629+ ? Math.min(120, automataRows * automataRowHeight + 18)630+ : 0;459631 const cell = Math.min(460632 96,461633 Math.max(
@@ -463,7 +635,7 @@ export function createStage(options = {}) {
463635 Math.floor(464636 Math.min(465637 (w - 80) / boardState.width,466- (h - 320) / boardState.height,638+ (h - 320 - automataReserve) / boardState.height,467639 ),468640 ),469641 ),
@@ -506,9 +678,24 @@ export function createStage(options = {}) {
506678 ctx.textBaseline = "middle";507679 ctx.fillText(proofNumber, 49, 44);508680 ctx.textAlign = "left";681+ const fullProofTitle = String(boardState.title || "Proof");682+ canvas.setAttribute(683+ "aria-label",684+ `Miau Is True proof: ${fullProofTitle}`,685+ );509686 ctx.fillStyle = "#F2EEE6";510687 ctx.font = "800 22px Recursive, monospace";511- ctx.fillText(boardState.title || "Proof", 78, 31);688+ const titleMaxWidth = Math.max(96, w - 190);689+ let visibleProofTitle = fullProofTitle;690+ if (ctx.measureText(visibleProofTitle).width > titleMaxWidth) {691+ while (692+ visibleProofTitle.length > 4 &&693+ ctx.measureText(`${visibleProofTitle}…`).width > titleMaxWidth694+ )695+ visibleProofTitle = visibleProofTitle.slice(0, -1);696+ visibleProofTitle = `${visibleProofTitle.trimEnd()}…`;697+ }698+ ctx.fillText(visibleProofTitle, 78, 31);512699 ctx.font = "600 10px Recursive, monospace";513700 ctx.fillStyle = "#8E877C";514701 ctx.fillText(
@@ -540,6 +727,9 @@ export function createStage(options = {}) {
540727 w - x - 32,541728 );542729 const hint = String(boardState.hint || "").trim();730+ const automataY = automata.length731+ ? h - (automataRows * automataRowHeight + 90)732+ : h - 80;543733 if (hint) {544734 drawHint(545735 ctx,
@@ -547,18 +737,35 @@ export function createStage(options = {}) {
547737 x,548738 Math.min(ledger.bottom + 12, h - 78),549739 w - x - 32,550- h - 60,740+ automata.length ? automataY - 8 : h - 60,551741 );552742 }743+ if (automata.length)744+ drawAutomataStrip(745+ ctx,746+ automata,747+ x,748+ automataY,749+ w - x - 32,750+ boardState.rules,751+ );553752 const coarsePointer =554753 globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;555754 if (w > 760 && !coarsePointer) {556755 const proofKeyY = h - 26;756+ if (automata.some((system) => system.active))757+ drawKeycap(ctx, "SPACE", "wait", w - 400, proofKeyY);557758 drawKeycap(ctx, "Z", "undo", w - 290, proofKeyY);558759 drawKeycap(ctx, "R", "restart", w - 185, proofKeyY);559760 drawKeycap(ctx, "ESC", "menu", w - 32, proofKeyY, "right");560761 }561- drawTouchControls(ctx, w, h, coarsePointer);762+ drawTouchControls(763+ ctx,764+ w,765+ h,766+ coarsePointer,767+ automata.some((system) => system.active),768+ );562769 } else if (scene === "index" && indexLayout && indexNavigator) {563770 indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {564771 availableWidth: w,
@@ -1174,7 +1381,18 @@ export function createStage(options = {}) {
11741381 }11751382 }11761383 if (scene === "proof" && innerWidth <= 760) {1177- if (y >= innerHeight - 50 && x < innerWidth * 0.32)1384+ const hasActiveAutomata = boardState?.automata?.some(1385+ (system) => system.active,1386+ );1387+ if (1388+ hasActiveAutomata &&1389+ y >= innerHeight - 92 &&1390+ y < innerHeight - 50 &&1391+ x >= innerWidth * 0.32 &&1392+ x <= innerWidth * 0.681393+ )1394+ options.onWaitAction?.();1395+ else if (y >= innerHeight - 50 && x < innerWidth * 0.32)11781396 document.querySelector("#undo-btn")?.click();11791397 else if (11801398 y >= innerHeight - 50 &&
M tests/board.test.js +5 -5
A tests/browser/automata.mjs +150 -0
@@ -0,0 +1,150 @@
1+#!/usr/bin/env node2+3+/**4+ * Focused browser acceptance for the unbundled automata example pack.5+ *6+ * Usage:7+ * PLAYWRIGHT_MODULE=/Users/dami/.codex/skills/develop-web-game/node_modules/playwright \8+ * node tests/browser/automata.mjs --url=http://127.0.0.1:5173/9+ */10+11+import assert from 'node:assert/strict';12+import { mkdir } from 'node:fs/promises';13+import { dirname, resolve } from 'node:path';14+import { fileURLToPath } from 'node:url';15+16+const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');17+const modPath = resolve(projectRoot, 'mods/automata-example.cjs');18+const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || 'playwright';19+const playwrightModule = configuredPlaywright.startsWith('/')20+ ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`21+ : configuredPlaywright;22+const playwright = await import(playwrightModule);23+const { chromium } = playwright.default || playwright;24+const baseUrl = process.env.MIAU_URL25+ || process.argv.find(argument => argument.startsWith('--url='))?.slice(6)26+ || 'http://127.0.0.1:5173/';27+const devUrl = (() => { const value = new URL(baseUrl); value.searchParams.set('dev', '1'); value.hash = ''; return value.toString(); })();28+const screenshots = '/private/tmp/miau-stage-qa';29+const timeout = Number(process.env.MIAU_BROWSER_TIMEOUT || 10_000);30+31+await mkdir(screenshots, { recursive: true });32+const browser = await chromium.launch({ headless: !process.argv.includes('--headed') });33+const consoleErrors = [];34+const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });35+const page = await context.newPage();36+page.on('console', message => { if (message.type() === 'error') consoleErrors.push(`console: ${message.text()}`); });37+page.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`));38+39+async function scene(expected) {40+ await page.waitForFunction(value => document.querySelector('.stage-shell')?.dataset.scene === value, expected, { timeout });41+}42+async function state() {43+ return page.evaluate(() => JSON.parse(window.render_game_to_text()));44+}45+async function screenshot(name) {46+ await page.waitForTimeout(750);47+ const path = `${screenshots}/${name}`;48+ await page.screenshot({ path });49+ return path;50+}51+function check(label) { process.stdout.write(`✓ ${label}\n`); }52+53+try {54+ await page.goto(devUrl, { waitUntil: 'networkidle' });55+ await page.waitForSelector('#game-canvas', { state: 'visible', timeout });56+57+ // Import through the same trusted local-module path a player uses.58+ await page.locator('#mod-input').setInputFiles(modPath);59+ await scene('gift');60+ assert.match(await page.locator('body').innerText(), /A GIFT FOR YOU|KEEP AND PLAY/i);61+ check('local automata module opens Gift');62+ await page.keyboard.press('Enter');63+ await scene('index');64+ check('Gift Keep installs and returns to INDEX');65+66+ // The dev catalog exposes the generated data-only URL after install.67+ const pack = page.locator('#pack-list [data-pack-id="automata-example"]');68+ await pack.waitFor({ state: 'visible', timeout });69+ await pack.click();70+ await page.locator('#copy-share').click();71+ const shareUrl = await page.locator('#share-url-input').inputValue();72+ assert.match(shareUrl, /#pack=/, 'share URL should carry an encoded data-only fragment');73+ check('encoded data-only share URL generated');74+75+ await page.keyboard.press('Enter');76+ await scene('proof');77+ let current = await state();78+ assert.equal(current.level, 'The fox keeps time');79+ assert.equal(current.moves, 0);80+ assert.equal(current.automata[0].id, 'fenn-clock');81+ assert.equal(current.automata[0].property, 'TICK');82+ assert.equal(current.automata[0].phase, 0);83+ assert.equal(current.automata[0].active, true);84+ const fenn = current.objects.find(object => object.kind === 'object' && object.noun === 'FENN');85+ assert.deepEqual({ x: fenn.x, y: fenn.y }, { x: 4, y: 3 });86+ await screenshot('automata-desktop-initial.png');87+ check('desktop proof starts with FENN at (4,3), phase 0');88+89+ await page.keyboard.press('Space');90+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });91+ current = await state();92+ const movedFenn = current.objects.find(object => object.kind === 'object' && object.noun === 'FENN');93+ assert.equal(current.automata[0].phase, 1);94+ assert.deepEqual({ x: movedFenn.x, y: movedFenn.y }, { x: 3, y: 3 });95+ await screenshot('automata-desktop-wait.png');96+ check('desktop Space WAIT advances phase and moves FENN left');97+98+ await page.keyboard.press('z');99+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 0, { timeout });100+ current = await state();101+ const undoneFenn = current.objects.find(object => object.kind === 'object' && object.noun === 'FENN');102+ assert.equal(current.automata[0].phase, 0);103+ assert.deepEqual({ x: undoneFenn.x, y: undoneFenn.y }, { x: 4, y: 3 });104+ check('undo restores phase 0 and FENN position');105+106+ await page.keyboard.press('r');107+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 0, { timeout });108+ current = await state();109+ assert.equal(current.automata[0].phase, 0);110+ check('reset keeps automata at phase 0');111+112+ // A fresh page proves the URL is self-contained and avoids catalog state leakage.113+ const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });114+ const mobileErrors = [];115+ const mobilePage = await mobile.newPage();116+ mobilePage.on('console', message => { if (message.type() === 'error') mobileErrors.push(`console: ${message.text()}`); });117+ mobilePage.on('pageerror', error => mobileErrors.push(`pageerror: ${error.message}`));118+ await mobilePage.goto(shareUrl, { waitUntil: 'networkidle' });119+ await mobilePage.waitForSelector('#game-canvas', { state: 'attached', timeout });120+ await mobilePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift', { timeout });121+ await mobilePage.keyboard.press('Enter');122+ await mobilePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index', { timeout });123+ await mobilePage.keyboard.press('Enter');124+ await mobilePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof', { timeout });125+ let mobileState = await mobilePage.evaluate(() => JSON.parse(window.render_game_to_text()));126+ assert.equal(mobileState.automata[0].phase, 0);127+ const initialMobileFenn = mobileState.objects.find(object => object.kind === 'object' && object.noun === 'FENN');128+ assert.deepEqual({ x: initialMobileFenn.x, y: initialMobileFenn.y }, { x: 4, y: 3 });129+ await mobilePage.waitForTimeout(750);130+ await mobilePage.screenshot({ path: `${screenshots}/automata-mobile-initial.png` });131+ await mobilePage.mouse.click(195, 770);132+ await mobilePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });133+ mobileState = await mobilePage.evaluate(() => JSON.parse(window.render_game_to_text()));134+ assert.equal(mobileState.automata[0].phase, 1);135+ const mobileFenn = mobileState.objects.find(object => object.kind === 'object' && object.noun === 'FENN');136+ assert.deepEqual({ x: mobileFenn.x, y: mobileFenn.y }, { x: 3, y: 3 });137+ await mobilePage.waitForTimeout(750);138+ await mobilePage.screenshot({ path: `${screenshots}/automata-mobile-wait.png` });139+ check('mobile WAIT control advances phase and moves FENN');140+ assert.deepEqual(mobileErrors, [], 'mobile browser should have no console/page errors');141+ await mobile.close();142+143+ assert.deepEqual(consoleErrors, [], 'desktop browser should have no console/page errors');144+ check('desktop and mobile complete without console errors');145+ process.stdout.write(`shareUrl=${shareUrl}\n`);146+ process.stdout.write(`screenshots=${screenshots}/automata-desktop-initial.png,${screenshots}/automata-desktop-wait.png,${screenshots}/automata-mobile-initial.png,${screenshots}/automata-mobile-wait.png\n`);147+} finally {148+ await context.close();149+ await browser.close();150+}
M tests/input.test.js +9 -0
M tests/juice.test.js +38 -0
@@ -250,3 +250,41 @@ test('undo, reset, destruction, and win expose presentation timelines', () => {
250250 juice.consume({ kind: 'reset', accepted: true }, 30);251251 assert.equal(juice.frame(30).wipe.progress, 0);252252 });253+254+test('automaton wait pulses its actor and undo clears the pulse', () => {255+ const juice = createJuice({ now: () => 0 });256+ const board = state([object('fox', 2, 1)]);257+ juice.syncState(board, 0);258+ juice.consume({259+ kind: 'wait', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],260+ automated: [{ systemId: 'fox-clock', phase: 0, action: 'wait', active: true, id: 'fox' }],261+ }, 0, board);262+ assert.ok(juice.frame(190).positions.get('fox').scaleX > 1);263+ juice.consume({ kind: 'undo', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 200, board);264+ assert.equal(juice.frame(200).positions.get('fox').scaleX, 1);265+});266+267+test('an actor pushed and then self moved faces its autonomous direction', () => {268+ const juice = createJuice({ now: () => 0 });269+ juice.syncState(state([object('fox', 2, 1)]), 0);270+ juice.consume({271+ kind: 'step', accepted: true, rulesAdded: [], rulesRemoved: [], destroyed: [],272+ moved: [{ id: 'fox', from: { x: 2, y: 1 }, to: { x: 2, y: 1 }, pushedBy: 'player', selfDirection: { dx: -1, dy: 0 } }],273+ }, 0);274+ assert.equal(juice.frame(0).positions.get('fox').facing, 'left');275+});276+277+test('automaton movement follows the player beat and phase slides, then rewinds on undo', () => {278+ const juice = createJuice({ now: () => 0 });279+ const before = { ...state([object('fox', 2, 1)]), automata: [{ id: 'clock', phase: 0, pattern: [0, 1, 2] }] };280+ const after = { ...state([object('fox', 3, 1)]), automata: [{ id: 'clock', phase: 1, pattern: [0, 1, 2] }] };281+ juice.resetLevel(before, 0);282+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'fox', from: { x: 2, y: 1 }, to: { x: 3, y: 1 }, pushedBy: null, cause: 'automaton' }], automata: after.automata, rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0, after);283+ assert.equal(juice.frame(90).positions.get('fox').x, 2);284+ assert.equal(juice.frame(90).automata[0].progress, 0);285+ assert.ok(juice.frame(180).positions.get('fox').x > 2);286+ assert.ok(juice.frame(180).automata[0].progress > 0);287+ juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'fox', from: { x: 3, y: 1 }, to: { x: 2, y: 1 }, pushedBy: null }], automata: before.automata, rulesAdded: [], rulesRemoved: [], destroyed: [] }, 300, before);288+ assert.equal(juice.frame(300).automata[0].fromPhase, 1);289+ assert.equal(juice.frame(300).automata[0].toPhase, 0);290+});