clone

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

feat(title): teach movement with a playable door proof

Dami committed 3f76a68
commit
3f76a68a113c8450e34d5a5bbd671102f9fdf4b6
parent
a965b59e0ab6c61ae4b90d6ee791bbfa1afe95fd
author
Dami <dami.etoile@gmail.com>
2 files changed, +116 -0
Asrc/scenes/title.js75++++++++++++++++++++++++++++++
Atests/title.test.js41++++++++++++++++
A src/scenes/title.js +75 -0
@@ -0,0 +1,75 @@
1+import { createGame } from '../engine.js';2+import { drawBoard } from '../render/board.js';3+4+/**5+ * Playable title scene.6+ * createTitleScene({ onSolved, onSkip }) returns an engine-backed controller7+ * with move/undo/reset/input methods and draw(ctx, options). TRUE is decorative8+ * title copy and is never part of TITLE_LEVEL's engine vocabulary.9+ */10+export const TITLE_LEVEL = Object.freeze({11+ id: 'title', title: 'Miau Is True', width: 9, height: 4,12+ objects: Object.freeze([13+ { id: 'title-miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },14+ { id: 'title-is-you', kind: 'word', text: 'IS', x: 1, y: 0 },15+ { id: 'title-you-word', kind: 'word', text: 'YOU', x: 2, y: 0 },16+ { id: 'title-door-word', kind: 'word', text: 'DOOR', x: 5, y: 0 },17+ { id: 'title-door-is', kind: 'word', text: 'IS', x: 6, y: 0 },18+ { id: 'title-win-word', kind: 'word', text: 'WIN', x: 7, y: 0 },19+ { id: 'title-miau', kind: 'object', noun: 'MIAU', x: 1, y: 2 },20+ { id: 'title-door', kind: 'object', noun: 'DOOR', x: 7, y: 2 },21+ ]),22+});23+24+export const titleScene = Object.freeze({25+ id: 'title', eyebrow: 'MIAU IS TRUE', title: 'A world made of sentences',26+ prompt: 'WALK THE CAT TO THE DOOR', help: 'ARROWS / WASD MOVE · Z UNDO · R RESTART · ENTER INDEX',27+});28+29+const directions = Object.freeze({ up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] });30+31+function drawHeader(ctx, width, height) {32+ if (!ctx || typeof ctx.fillText !== 'function') return;33+ ctx.save?.(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle';34+ ctx.fillStyle = '#F2EEE6'; ctx.font = `800 ${Math.max(28, Math.min(76, width * 0.065))}px Recursive, monospace`; ctx.fillText('MIAU', width * 0.38, height * 0.1);35+ ctx.fillStyle = '#8E877C'; ctx.font = `800 ${Math.max(20, Math.min(52, width * 0.045))}px Recursive, monospace`; ctx.fillText('IS', width * 0.5, height * 0.1);36+ ctx.fillStyle = '#FF4B3E'; ctx.fillText('TRUE', width * 0.63, height * 0.1); ctx.restore?.();37+}38+39+export function createTitleScene(options = {}) {40+ const game = createGame(TITLE_LEVEL);41+ let solvedNotified = false;42+ function settle(event) {43+ if (event.accepted && event.won && !solvedNotified) { solvedNotified = true; options.onSolved?.(event); }44+ return event;45+ }46+ function move(dx, dy) { return settle(game.step(dx, dy)); }47+ function undo() { return settle(game.undo()); }48+ function reset() { solvedNotified = false; return settle(game.reset()); }49+ function input(action) {50+ const value = typeof action === 'string' ? action : action?.key;51+ const key = String(value || '').toLowerCase();52+ if (directions[key]) return move(...directions[key]);53+ if (key === 'arrowup' || key === 'w') return move(0, -1);54+ if (key === 'arrowdown' || key === 's') return move(0, 1);55+ if (key === 'arrowleft' || key === 'a') return move(-1, 0);56+ if (key === 'arrowright' || key === 'd') return move(1, 0);57+ if (key === 'z' || key === 'undo') return undo();58+ if (key === 'r' || key === 'reset' || key === 'restart') return reset();59+ if (key === 'enter' || key === 'confirm') { options.onSkip?.(); return { accepted: true, kind: 'skip', won: game.getState().won }; }60+ return { accepted: false, kind: 'input', won: game.getState().won };61+ }62+ function draw(ctx, drawOptions = {}) {63+ const width = drawOptions.width ?? ctx.canvas?.clientWidth ?? ctx.canvas?.width ?? 800;64+ const height = drawOptions.height ?? ctx.canvas?.clientHeight ?? ctx.canvas?.height ?? 600;65+ const state = game.getState();66+ const cell = drawOptions.cell ?? Math.min(84, Math.max(30, Math.floor(Math.min((width - 80) / state.width, (height - 190) / state.height))));67+ const x = drawOptions.x ?? Math.floor((width - state.width * cell) / 2);68+ const y = drawOptions.y ?? Math.floor(height * 0.22);69+ drawHeader(ctx, width, height); drawBoard(ctx, state, { ...drawOptions, x, y, cell, game });70+ return { state, geometry: { x, y, cell, width: state.width * cell, height: state.height * cell } };71+ }72+ return Object.freeze({ id: 'title', game, get state() { return game.getState(); }, move, undo, reset, input, draw, get canUndo() { return game.canUndo; } });73+}74+75+export default titleScene;
A tests/title.test.js +41 -0
@@ -0,0 +1,41 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createTitleScene, TITLE_LEVEL } from '../src/scenes/title.js';4+5+test('title level is a 9x4 engine board with decorative TRUE only', () => {6+ const scene = createTitleScene();7+ assert.equal(TITLE_LEVEL.width, 9);8+ assert.equal(TITLE_LEVEL.height, 4);9+ assert.equal(scene.state.rules.some(rule => rule.text.includes('TRUE')), false);10+ assert.equal(scene.state.rules.some(rule => rule.text === 'MIAU IS YOU'), true);11+ assert.equal(scene.state.rules.some(rule => rule.text === 'DOOR IS WIN'), true);12+});13+14+test('title controller exposes movement, undo, reset, skip, and solved callback', () => {15+ let solved = 0;16+ let skipped = 0;17+ const scene = createTitleScene({ onSolved: () => { solved++; }, onSkip: () => { skipped++; } });18+ for (let index = 0; index < 6; index++) assert.equal(scene.input('right').accepted, true);19+ assert.equal(scene.state.won, true);20+ assert.equal(solved, 1);21+ scene.input('z');22+ assert.equal(scene.state.won, false);23+ scene.input('r');24+ assert.equal(scene.state.objects.find(object => object.id === 'title-miau').x, 1);25+ scene.input('Enter');26+ assert.equal(skipped, 1);27+});28+29+test('title draw renders decorative header and delegates board geometry', () => {30+ const labels = [];31+ const ctx = {32+ canvas: { width: 800, height: 600 },33+ save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {},34+ moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},35+ fillText(text) { labels.push(text); }, measureText(text) { return { width: text.length * 8 }; },36+ };37+ const scene = createTitleScene();38+ const result = scene.draw(ctx);39+ assert.equal(result.geometry.cell > 0, true);40+ assert.equal(labels.includes('TRUE'), true);41+});