clone

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

fix(play): reset level visuals and surface immediate wins

Dami committed 739d277
commit
739d277d1107113fcddf08cf8565d6772a746dec
parent
52483d92ecd1c812bcd17de47a76aaf5aceba6d1
author
Dami <dami.etoile@gmail.com>
3 files changed, +80 -4
Msrc/juice.js21++++++++++++++++
Msrc/main.js23++++++++++++++---
Mtests/juice.test.js40++++++++++++++++++++++++++++++
M src/juice.js +21 -0
@@ -29,6 +29,7 @@ const asNumber = value => Number.isFinite(value) ? value : 0;
2929 const keyForRule = rule => `${rule?.text || ''}|${(rule?.sourceIds || []).join(',')}|${rule?.axis || ''}`;3030 const keyForSentence = sentence => (sentence?.ids || []).join('>');3131 const cellOf = object => ({ id: object.id, x: asNumber(object.x), y: asNumber(object.y) });32+const facingFor = (dx, dy) => dx > 0 ? 'right' : dx < 0 ? 'left' : dy > 0 ? 'down' : dy < 0 ? 'up' : null;3233 3334 /**3435 * @param {{ reducedMotion?: boolean, now?: () => number }} options
@@ -64,6 +65,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
6465 start: 0,6566 duration: 0,6667 pushed: false,68+ facing: null,6769 });6870 return entities.get(id);6971 }
@@ -92,6 +94,22 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
9294 blocked = null;9395 }9496 97+ /** Start a fresh visual timeline for a different level. */98+ function resetLevel(state, at = readNow()) {99+ entities.clear();100+ rules.clear();101+ sentences.clear();102+ priorSentenceCells = new Map();103+ priorSentences = new Map();104+ globalShake = null;105+ blocked = null;106+ tint = null;107+ wipe = null;108+ win = null;109+ syncState(state, at);110+ return frame(at);111+ }112+95113 /**96114 * Called immediately before an input is sent to the engine. The renderer97115 * can call this even when the input later turns out to be blocked.
@@ -150,6 +168,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
150168 entity.pushed = entry.pushedBy != null;151169 entity.dx = Math.sign(entity.x - entity.fromX);152170 entity.dy = Math.sign(entity.y - entity.fromY);171+ entity.facing = facingFor(entity.dx, entity.dy) || entity.facing;153172 }154173 155174 function consumeRuleAdded(rule, at, holdUntil) {
@@ -354,6 +373,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
354373 scaleY: (entity.dy ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,355374 moving: progress < 1,356375 pushed: entity.pushed,376+ facing: entity.facing,357377 };358378 }359379
@@ -435,6 +455,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
435455 consume,436456 frame,437457 snap,458+ resetLevel,438459 syncState,439460 setReducedMotion,440461 get reducedMotion() { return currentReducedMotion; },
M src/main.js +19 -4
@@ -52,7 +52,7 @@ const audio = createAudioSystem(); audio.installGestureListeners(document);
5252 const juice = createJuice({ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches });5353 const progress = createProgressStore();5454 const titleController = createTitleScene({ onSolved: () => stage.show('index'), onSkip: () => stage.show('index') });55-const stage = createStage({ levels: () => currentLevels(), title: titleController, onSelectLevel: id => loadLevel(currentLevels().findIndex(level => level.id === id)), onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });55+const stage = createStage({ levels: () => currentLevels(), title: titleController, onSelectLevel: id => { const ok = loadLevel(currentLevels().findIndex(level => level.id === id)); if (ok) scheduleInitialWin(); return ok; }, onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });5656 let giftOrigin = 'url';5757 const giftController = createGiftScene({ onAction: result => { if (result.action === 'keep' || result.action === 'replace' || result.action === 'copy') { const installed=packService.installPack(result.pack,{origin:giftOrigin,...(result.install||{})}); if(installed.ok){ showModStatus(`Pack installed / ${installed.pack.name}`); const first = currentLevels().findIndex(level => level.packId === installed.pack.id); if (first >= 0) loadLevel(first); refreshIndex(currentLevels()[first]?.id); workshopController.refresh(); stage.show('index'); } else {workshopController.showErrors(installed.errors);stage.show('workshop');} } } });5858 const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.show('pause'), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index'); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => showModStatus('LINK IS COPIED')).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });
@@ -97,6 +97,7 @@ function loadLevel(index) {
9797 const list = currentLevels();9898 if (typeof index === 'string') index = Math.max(0, list.findIndex(level => level.id === index));9999 if (!list[index]) { showModStatus('Level unavailable'); return false; }100+ clearTimeout(winTimer);100101 feedback = '';101102 levelNo = index; catalog.setActiveLevel(list[levelNo].id); game = createGame(list[levelNo]); lastState = game.getState();102103 levelTitle.textContent = lastState.title; levelIndex.textContent = `${String(levelNo + 1).padStart(2,'0')} / ${String(list.length).padStart(2,'0')}`;
@@ -104,7 +105,9 @@ function loadLevel(index) {
104105 hintPrepared = prepare(hintCopy.textContent, '12px Space Grotesk');105106 hintCopy.style.minHeight = `${Math.max(18, layout(hintPrepared, 270, 18).height)}px`;106107 winToast.hidden = !lastState.won;107- juice.syncState(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(displayState(), game, activePalette(), glyphs, juice.frame());108+ // A new proof needs fresh positions, sentence animations, and win effects;109+ // authored levels may reuse entity IDs from the previous proof.110+ juice.resetLevel(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(displayState(), game, activePalette(), glyphs, juice.frame());108111 return true;109112 }110113 function packLevelIndexes(packId = activePackId()) {
@@ -186,17 +189,29 @@ function announceBoardSummary() {
186189 let animationRequest = 0;187190 let animationDeadline = 0;188191 let winTimer = 0;192+function scheduleInitialWin() {193+ clearTimeout(winTimer);194+ if (!game?.getState().won) return;195+ const level = currentLevels()[levelNo];196+ if (level?.packId && level?.localId) {197+ progress.markSolved(level.packId, level.localId);198+ refreshIndex(level.id);199+ }200+ winTimer = setTimeout(() => {201+ if (game?.getState().won && stage.scene === 'proof') stage.show('win', { steps: game.getState().moves, nodeId: level?.id });202+ }, 0);203+}189204 function paintBoardFrame(now = performance.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }190205 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); }191206 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);}207+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);}193208 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');}194209 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); }195210 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])()));196211 document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));197212 function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}198213 document.querySelector('#fullscreen-btn').addEventListener('click',toggleFullscreen);199-undoButton.addEventListener('click', undo); restartButton.addEventListener('click', restart); continueButton.addEventListener('click', () => { const next = nextLevelInPack(); if (next !== undefined) loadLevel(next); });214+undoButton.addEventListener('click', undo); restartButton.addEventListener('click', restart); continueButton.addEventListener('click', () => { const next = nextLevelInPack(); if (next !== undefined && loadLevel(next)) { stage.show('proof'); scheduleInitialWin(); } });200215 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()}); });201216 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()});} } });202217 stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});
M tests/juice.test.js +40 -0
@@ -27,6 +27,46 @@ test('move and push tweens expose grid coordinates and push scale', () => {
2727 juice.beginInput(50);2828 assert.equal(juice.frame(50).positions.get('miau').x, 1);2929 assert.equal(juice.frame(50).positions.get('rock').x, 2);30+ assert.equal(juice.frame(50).positions.get('miau').facing, 'right');31+ assert.equal(juice.frame(50).positions.get('rock').facing, 'right');32+});33+34+test('snapping before a level transition prevents reused ids from drawing at old cells', () => {35+ const juice = createJuice({ now: () => 0 });36+ juice.syncState(state([object('player', 0, 0)]), 0);37+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);38+ juice.snap(10);39+ juice.syncState(state([object('player', 0, 1)]), 10);40+ assert.deepEqual(juice.frame(10).positions.get('player'), { id: 'player', x: 0, y: 1, scaleX: 1, scaleY: 1, moving: false, pushed: false, facing: 'right' });41+});42+43+test('resetLevel clears old sentences, effects, and win beats before seeding the next proof', () => {44+ const juice = createJuice({ now: () => 0 });45+ const oldState = { objects: [object('a', 0, 0), object('b', 1, 0)], sentences: [{ ids: ['a', 'b'], axis: 'h' }] };46+ juice.syncState(oldState, 0);47+ juice.consume({ kind: 'step', accepted: true, moved: [], rulesAdded: [{ text: 'MIAU IS WIN', subject: 'MIAU', predicate: 'WIN', sourceIds: ['a', 'b'], axis: 'h' }], rulesRemoved: [], destroyed: [{ id: 'a', noun: 'MIAU', at: { x: 0, y: 0 }, cause: 'sink' }], won: true }, 0, oldState);48+ const nextState = { objects: [object('a', 3, 1), object('b', 4, 1)], sentences: [{ ids: ['a', 'b'], axis: 'v' }] };49+ const frame = juice.resetLevel(nextState, 20);50+ assert.equal(frame.win, null);51+ assert.deepEqual(frame.effects, []);52+ assert.deepEqual(frame.rules, []);53+ assert.deepEqual(frame.sentences.map(sentence => ({ ids: sentence.ids, axis: sentence.axis })), [{ ids: ['a', 'b'], axis: 'v' }]);54+ assert.deepEqual(frame.positions.get('a'), { id: 'a', x: 3, y: 1, scaleX: 1, scaleY: 1, moving: false, pushed: false, facing: null });55+});56+57+test('facing follows travel direction, survives snap, and resets between levels', () => {58+ const juice = createJuice({ now: () => 0 });59+ juice.syncState(state([object('player', 1, 1)]), 0);60+ juice.consume({61+ kind: 'step', accepted: true,62+ moved: [{ id: 'player', from: { x: 1, y: 1 }, to: { x: 1, y: 0 }, pushedBy: null }],63+ rulesAdded: [], rulesRemoved: [], destroyed: [],64+ }, 0);65+ assert.equal(juice.frame(45).positions.get('player').facing, 'up');66+ juice.snap(100);67+ assert.equal(juice.frame(100).positions.get('player').facing, 'up');68+ juice.resetLevel(state([object('player', 3, 3)]), 120);69+ assert.equal(juice.frame(120).positions.get('player').facing, null);3070 });3171 3272 test('rule formation waits 45ms after movement lands', () => {