clone

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

Route application intents through a scene adapter

Dami committed 938e925
commit
938e925d77e9fbc58733110652e4d994cbcf10ba
parent
17912082fe4a9ac69b81dc7ad9e1bea71715c619
author
Dami <dami.etoile@gmail.com>
3 files changed, +139 -1
Asrc/app/intent-router.js70++++++++++++++++++++++++++++++
Msrc/main.js15++++++-
Atests/intent-router.test.js55++++++++++++++++++++++++
A src/app/intent-router.js +70 -0
@@ -0,0 +1,70 @@
1+/**2+ * Routes normalized input intents to the active application scene.3+ *4+ * Device adapters only describe intent and the stage only owns scene UI. This5+ * boundary connects those two concerns without knowing DOM event details.6+ */7+export function createIntentRouter({8+ getStage,9+ getTitleController,10+ move = () => {},11+ wait = () => {},12+ undo = () => {},13+ restart = () => {},14+ mute = () => {},15+ onSummary = () => {},16+} = {}) {17+ const moves = Object.freeze({18+ up: () => move(0, -1),19+ down: () => move(0, 1),20+ left: () => move(-1, 0),21+ right: () => move(1, 0),22+ });23+24+ function route(intent = {}) {25+ const stage = getStage();26+ const type = intent.type;27+ if (!stage) return false;28+29+ if (type === 'summary') {30+ onSummary();31+ return true;32+ }33+34+ if (stage.scene === 'title') {35+ if (type === 'menu') {36+ stage.show('pause');37+ return true;38+ }39+ const titleInput = type === 'confirm'40+ ? 'enter'41+ : type === 'undo'42+ ? 'z'43+ : type === 'restart'44+ ? 'r'45+ : intent.dir;46+ getTitleController()?.input(titleInput);47+ stage.render();48+ return true;49+ }50+51+ if (stage.scene === 'proof' || stage.scene === 'win') {52+ if (type === 'move' && stage.scene === 'proof') moves[intent.dir]?.();53+ if (type === 'wait' && stage.scene === 'proof') wait();54+ if (type === 'undo') undo();55+ if (type === 'restart') restart();56+ if (type === 'menu' && stage.scene === 'proof') stage.show('pause');57+ if (type === 'mute') mute();58+ if (type === 'confirm' && stage.scene === 'win') stage.show('index');59+ return true;60+ }61+62+ if (intent.source !== 'keyboard' && !(intent.source === 'touch' && intent.point))63+ return Boolean(stage.handleIntent?.(intent));64+ return false;65+ }66+67+ return Object.freeze({ route });68+}69+70+export default createIntentRouter;
M src/main.js +14 -1
@@ -16,6 +16,7 @@ import { createWorkshopScene } from './scenes/workshop.js';
1616 import { createTitleScene } from './scenes/title.js';1717 import { createTurnActions } from './app/turns.js';1818 import { createDevView } from './app/dev-view.js';19+import { createIntentRouter } from './app/intent-router.js';1920 import './style.css';2021 import './overrides.css';2122 import './font.css';
@@ -238,7 +239,19 @@ function move(dx, dy) { turns.move(dx, dy); }
238239 function waitTurn() { turns.wait(); }239240 function restart() { turns.restart(); }240241 function undo() { turns.undo(); }241-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); }242+const intentRouter = createIntentRouter({243+ getStage: () => stage,244+ getTitleController: () => titleController,245+ move,246+ wait: waitTurn,247+ undo,248+ restart,249+ mute: () => soundButton.click(),250+ onSummary: announceBoardSummary,251+});252+function handleIntent(intent) {253+ intentRouter.route(intent);254+}242255 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])()));243256 document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));244257 function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}
A tests/intent-router.test.js +55 -0
@@ -0,0 +1,55 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createIntentRouter } from '../src/app/intent-router.js';4+5+function harness(scene = 'proof') {6+ const events = [];7+ const stage = {8+ scene,9+ show: target => { events.push(`show:${target}`); stage.scene = target; },10+ render: () => events.push('render'),11+ handleIntent: intent => { events.push(`delegate:${intent.type}`); return true; },12+ };13+ const title = { input: value => events.push(`title:${value}`) };14+ const router = createIntentRouter({15+ getStage: () => stage,16+ getTitleController: () => title,17+ move: (x, y) => events.push(`move:${x},${y}`),18+ wait: () => events.push('wait'),19+ undo: () => events.push('undo'),20+ restart: () => events.push('restart'),21+ mute: () => events.push('mute'),22+ onSummary: () => events.push('summary'),23+ });24+ return { router, stage, events };25+}26+27+test('routes proof commands to gameplay actions and scene changes', () => {28+ const { router, events } = harness();29+ router.route({ type: 'move', dir: 'right' });30+ router.route({ type: 'wait' });31+ router.route({ type: 'undo' });32+ router.route({ type: 'restart' });33+ router.route({ type: 'summary' });34+ router.route({ type: 'menu' });35+ assert.deepEqual(events, ['move:1,0', 'wait', 'undo', 'restart', 'summary', 'show:pause']);36+});37+38+test('maps title intents to the title controller', () => {39+ const { router, events } = harness('title');40+ router.route({ type: 'confirm' });41+ router.route({ type: 'undo' });42+ router.route({ type: 'restart' });43+ router.route({ type: 'move', dir: 'left' });44+ assert.deepEqual(events, ['title:enter', 'render', 'title:z', 'render', 'title:r', 'render', 'title:left', 'render']);45+});46+47+test('routes win confirmation and non-keyboard scene intents', () => {48+ const win = harness('win');49+ win.router.route({ type: 'confirm' });50+ assert.deepEqual(win.events, ['show:index']);51+52+ const workshop = harness('workshop');53+ workshop.router.route({ type: 'confirm', source: 'gamepad' });54+ assert.deepEqual(workshop.events, ['delegate:confirm']);55+});