- commit
- 250c61edbc10c90e9f75c4732f2fbdd4b33b473b
- parent
- a67f26c7fbca57fc8c7388afae64efb26c345241
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 46 | +++++++++++++++++++++++++----- |
@@ -64,12 +64,21 @@ let hintPrepared;
6464 let feedback = '';6565 let feedbackTimer;6666 67-const glyphs = { MIAU:'🐱', CAT:'🐱', BABA:'🐱', FLAG:'⚑', ROCK:'●', WALL:'▦', WATER:'≈', GRASS:'✿', STAR:'✦', KEY:'⚿', DOOR:'▣', KEKE:'🐾' };67+const glyphs = { MIAU:'🐈', CAT:'🐈', BABA:'🧒', FLAG:'🚩', ROCK:'🪨', WALL:'🧱', WATER:'💧', GRASS:'🌿', STAR:'⭐', KEY:'🔑', DOOR:'🚪', KEKE:'🐾', 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));7070 const errorText = errors => (errors || []).map(error => error?.message || error?.detail || String(error)).join('\n');7171 7272 function currentLevels() { return catalog.getLevels(); }73+function displayState() {74+ const list = currentLevels();75+ return {76+ ...game.getState(),77+ proofNumber: levelNo + 1,78+ proofTotal: list.length,79+ completedCount: list.filter(level => progress.isSolved(level.packId, level.localId)).length,80+ };81+}7382 let indexLayout;7483 let indexNavigator;7584 function refreshIndex(focusId) {@@ -95,7 +104,7 @@ function loadLevel(index) {
95104 hintPrepared = prepare(hintCopy.textContent, '12px Space Grotesk');96105 hintCopy.style.minHeight = `${Math.max(18, layout(hintPrepared, 270, 18).height)}px`;97106 winToast.hidden = !lastState.won;98- juice.syncState(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(lastState, game, activePalette(), glyphs, juice.frame());107+ juice.syncState(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(displayState(), game, activePalette(), glyphs, juice.frame());99108 return true;100109 }101110 function packLevelIndexes(packId = activePackId()) {@@ -155,15 +164,34 @@ function playTurnSounds(result) {
155164 }156165 function showFeedback(message) { feedback = message; clearTimeout(feedbackTimer); feedbackTimer = setTimeout(() => { feedback = ''; updateUI(); }, 1000); updateUI(); }157166 function accepted(result) { return typeof result === 'object' && result !== null ? result.accepted : result; }167+function announceLive(message) { const copy = String(message || '').trim(); if (!copy) return; if (stage.announce) stage.announce(copy); else { const live = document.querySelector('.stage-sr'); if (live) live.textContent = copy; } }168+function announceTurn(result) {169+ if (!accepted(result) || !game) return;170+ const state = game.getState();171+ const formed = (result.rulesAdded || []).map(rule => `formed ${rule.text}`);172+ const broken = (result.rulesRemoved || []).map(rule => `broken ${rule.text}`);173+ const active = state.rules.length ? `Active rules: ${state.rules.map(rule => rule.text).join(', ')}.` : 'No active rules.';174+ const status = result.won ? 'Proof complete.' : result.controlLost ? 'Nothing is YOU.' : '';175+ announceLive([...formed, ...broken, active, status].filter(Boolean).join(' '));176+}177+function announceBoardSummary() {178+ let board;179+ try { board = JSON.parse(window.render_game_to_text?.() || '{}'); } catch { board = {}; }180+ const rules = board.rules?.map(rule => rule.text).filter(Boolean) || [];181+ const objects = board.objects?.map(object => `${object.kind === 'word' ? object.text : object.noun} at column ${Number(object.x) + 1}, row ${Number(object.y) + 1}`) || [];182+ 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();183+ announceLive(summary);184+ if (globalThis.speechSynthesis && globalThis.SpeechSynthesisUtterance) { globalThis.speechSynthesis.cancel(); globalThis.speechSynthesis.speak(new globalThis.SpeechSynthesisUtterance(summary)); }185+}158186 let animationRequest = 0;159187 let animationDeadline = 0;160188 let winTimer = 0;161-function paintBoardFrame(now = performance.now()) { stage.setBoard(game.getState(),game,activePalette(),glyphs,juice.frame(now)); }189+function paintBoardFrame(now = performance.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }162190 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); }163-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); 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});},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);} }164-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');updateUI();render();stage.show('proof');paintBoardFrame(now);animateBoard(300);}165-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');updateUI();render();stage.show('proof');paintBoardFrame(now);animateBoard(300);}else playSound('undoEmpty');}166-function handleIntent(intent) { if (stage.scene === 'title') { 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); }191+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);} }192+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);animateBoard(300);}193+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');}194+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); }167195 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])()));168196 document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));169197 function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}@@ -187,4 +215,8 @@ window.render_game_to_text=()=>JSON.stringify({coordinateSystem:'origin top-left
187215 window.advanceTime=ms=>{render();return ms};188216 const input = createInput({ onIntent: handleIntent });189217 input.attach({ stage: stage.canvas });218+// Canvas text is rasterized at draw time, so the first scene must wait for the219+// self-hosted face instead of relying on CSS font-display to repaint it.220+await document.fonts.load('800 20px Recursive');221+await document.fonts.load('600 20px Recursive');190222 refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict}); stage.show('gift'); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');