clone

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

main
miau-is-true / src / scenes / title.js
title.js 12.8K
1import { createGame } from '../engine.js';2import { drawBoard } from '../render/board.js';3import { nativeHorizontalFacing } from '../render/facing.js';4import { actionMatches, keycapPressFrame } from '../render/motion.js';56/**7 * Playable title scene.8 * createTitleScene({ onSolved, onSkip }) returns an engine-backed controller9 * with move/undo/reset/input methods and draw(ctx, options). TRUE is decorative10 * title copy and is never part of TITLE_LEVEL's engine vocabulary.11 */12export const TITLE_LEVEL = Object.freeze({13 id: 'title', title: 'Miau Is True', width: 9, height: 4,14 objects: Object.freeze([15 { id: 'title-miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },16 { id: 'title-is-you', kind: 'word', text: 'IS', x: 1, y: 0 },17 { id: 'title-you-word', kind: 'word', text: 'YOU', x: 2, y: 0 },18 { id: 'title-door-word', kind: 'word', text: 'DOOR', x: 5, y: 0 },19 { id: 'title-door-is', kind: 'word', text: 'IS', x: 6, y: 0 },20 { id: 'title-win-word', kind: 'word', text: 'WIN', x: 7, y: 0 },21 { id: 'title-miau', kind: 'object', noun: 'MIAU', x: 1, y: 2 },22 { id: 'title-door', kind: 'object', noun: 'DOOR', x: 7, y: 2 },23 ]),24});2526export const titleScene = Object.freeze({27 id: 'title', eyebrow: 'MIAU IS TRUE', title: 'A world made of sentences',28 prompt: 'walk the cat to the door', help: '',29});3031const directions = Object.freeze({ up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] });3233function rr(ctx, x, y, width, height, radius = 4) {34 ctx.beginPath();35 if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius); else ctx.rect(x, y, width, height);36}37function keycap(ctx, label, x, y, options = {}) {38 const size = options.size || 10;39 ctx.save?.(); ctx.font = `800 ${size}px Recursive, monospace`;40 const width = Math.max(options.minWidth || 24, (ctx.measureText?.(label).width || label.length * size * 0.6) + 14);41 const height = options.height || 22;42 const press = options.press && actionMatches(options.action || label, options.press.action)43 ? keycapPressFrame({ now: options.now, startedAt: options.press.startedAt, reducedMotion: options.reducedMotion })44 : { offsetY: 0, press: 0 };45 const offsetY = press.offsetY || 0;46 ctx.fillStyle = '#000'; ctx.fillRect?.(x + 3, y + 3, width, height);47 rr(ctx, x, y, width, height, 4); ctx.fillStyle = '#232120'; ctx.fill(); ctx.strokeStyle = '#4A4540'; ctx.lineWidth = 1; ctx.stroke();48 ctx.fillStyle = '#F2EEE6'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, x + width / 2, y + height / 2 + 1 + offsetY); ctx.restore?.();49 return width;50}51function headerTile(ctx, label, x, y, width, height, role, progress, exitProgress = 0) {52 const drop = 1 - progress;53 const offsetY = -height * 0.9 * drop - exitProgress * height * 0.7;54 const scale = (0.94 + progress * 0.06) * (1 - exitProgress * 0.08);55 const lip = Math.max(3, height * 0.07);56 ctx.save?.(); ctx.translate?.(x + width / 2, y + height / 2 + offsetY); ctx.scale?.(scale, scale);57 ctx.fillStyle = '#000'; rr(ctx, -width / 2 + lip, -height / 2 + lip, width, height, role === 'operator' ? height / 2 : 4); ctx.fill();58 rr(ctx, -width / 2, -height / 2, width, height, role === 'operator' ? height / 2 : 4);59 if (role === 'operator') { ctx.fillStyle = '#191816'; ctx.fill(); ctx.strokeStyle = '#F2EEE6'; ctx.lineWidth = 2; ctx.stroke(); }60 else { ctx.fillStyle = role === 'property' ? '#FF4B3E' : '#F2EEE6'; ctx.fill(); }61 ctx.fillStyle = role === 'operator' ? '#F2EEE6' : '#0F0E0D'; ctx.font = `850 ${Math.max(13, height * 0.36)}px Recursive, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, 0, 1);62 ctx.restore?.();63}64function drawHeader(ctx, width, height, { now = 0, reducedMotion = false, exitProgress = 0 } = {}) {65 if (!ctx || typeof ctx.fillText !== 'function') return;66 const labels = [['MIAU', 'noun'], ['IS', 'operator'], ['TRUE', 'property']];67 const gap = Math.max(7, Math.min(14, width * 0.018));68 const tileHeight = Math.max(44, Math.min(82, width * 0.13));69 const widths = labels.map(([label]) => Math.max(tileHeight * 1.25, label.length * tileHeight * 0.31 + tileHeight * 0.42));70 const available = width - 32;71 const ratio = Math.min(1, available / (widths.reduce((sum, value) => sum + value, 0) + gap * 2));72 const scaledWidths = widths.map(value => value * ratio); const scaledHeight = tileHeight * ratio;73 const total = scaledWidths.reduce((sum, value) => sum + value, 0) + gap * 2;74 let x = (width - total) / 2; const top = Math.max(26, height * 0.06);75 const progresses = [];76 labels.forEach(([label, role], index) => {77 const duration = reducedMotion ? 0 : 320; const start = index * 80; const progress = duration ? Math.max(0, Math.min(1, (now - start) / duration)) : 1;78 progresses.push(progress);79 headerTile(ctx, label, x, top + index * 3, scaledWidths[index], scaledHeight, role, progress, exitProgress); x += scaledWidths[index] + gap;80 });81 return { top, height: scaledHeight, bottom: top + scaledHeight, progresses };82}83function drawFooter(ctx, width, height, options = {}) {84 if (!ctx) return [];85 const items = [['← ↑ → ↓', 'walk', 'walk'], ['ENTER', 'skip to index', 'skip'], ['ESC', 'menu', 'menu'], ['SPACE', 'workshop', 'workshop']];86 const size = width < 520 ? 8 : 11; const gap = width < 520 ? 6 : 18;87 ctx.save?.(); ctx.font = `700 ${size}px Recursive, monospace`;88 const widths = items.map(([label, verb]) => Math.max(24, (ctx.measureText?.(label).width || label.length * size * 0.6) + 14) + 7 + (ctx.measureText?.(verb).width || verb.length * size * 0.55));89 const total = widths.reduce((sum, value) => sum + value, 0) + gap * (items.length - 1);90 const rows = total > width - 32 ? [[0, 1], [2, 3]] : [items.map((_, index) => index)];91 const baseY = height - (width < 520 ? 48 : 34);92 const rowGap = rows.length > 1 ? 38 : 0;93 ctx.fillStyle = '#F2EEE6'; ctx.font = `700 ${width < 520 ? 11 : 13}px Recursive, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('walk the cat to the door', width / 2, baseY - 18 - rowGap);94 const targets = [];95 rows.forEach((row, rowIndex) => {96 const rowTotal = row.reduce((sum, itemIndex) => sum + widths[itemIndex], 0) + gap * (row.length - 1);97 let x = Math.max(16, (width - rowTotal) / 2);98 const y = baseY - (rows.length - 1 - rowIndex) * rowGap;99 row.forEach((itemIndex, rowPosition) => {100 const [label, verb, action] = items[itemIndex];101 const keyX = x;102 const w = keycap(ctx, label, keyX, y, { size, minWidth: label.length > 4 ? 42 : 24, action: action === 'walk' ? 'navigate' : action, ...options });103 const verbX = keyX + w + 7;104 const verbWidth = ctx.measureText?.(verb).width || verb.length * size * 0.55;105 ctx.fillStyle = '#8E877C'; ctx.font = `700 ${size}px Recursive, monospace`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(verb, verbX, y + 11);106 targets.push({ action, x: keyX, y, width: w + 7 + verbWidth, height: 34 });107 x = verbX + verbWidth + (rowPosition < row.length - 1 ? gap : 0);108 });109 });110 ctx.restore?.();111 return targets;112}113114export function createTitleScene(options = {}) {115 const game = createGame(TITLE_LEVEL);116 const titleEpoch = options.startTime ?? (typeof performance === 'undefined' ? 0 : performance.now());117 let solvedNotified = false;118 let footerLayout = [];119 let renderNow = titleEpoch;120 let miauFlipStartedAt = null;121 const facingHistory = [];122 // The built-in cat's declared native pose faces left. Keep the last accepted123 // horizontal direction separately from the engine so blocked and idle124 // inputs do not make the title character turn.125 const nativeMiauFacing = nativeHorizontalFacing('MIAU', '🐈');126 let miauFacing = nativeMiauFacing;127 function updateFacing(event) {128 if (!event?.accepted) return;129 if (event.kind === 'reset') { facingHistory.length = 0; miauFacing = nativeMiauFacing; return; }130 if (event.kind === 'undo') {131 if (facingHistory.length) {132 const priorFacing = facingHistory.pop();133 if (priorFacing !== miauFacing) miauFlipStartedAt = renderNow;134 miauFacing = priorFacing;135 }136 return;137 }138 if (event.kind !== 'step') return;139 facingHistory.push(miauFacing);140 const movement = event.moved?.find(entry => entry.id === 'title-miau');141 if (!movement) return;142 const dx = movement.to.x - movement.from.x;143 const nextFacing = dx > 0 ? 'right' : dx < 0 ? 'left' : miauFacing;144 if (nextFacing !== miauFacing) miauFlipStartedAt = renderNow;145 miauFacing = nextFacing;146 }147 function settle(event) {148 updateFacing(event);149 if (event.accepted && event.won && !solvedNotified) { solvedNotified = true; options.onSolved?.(event); }150 return event;151 }152 function move(dx, dy) { return settle(game.step(dx, dy)); }153 function undo() { return settle(game.undo()); }154 function reset() { solvedNotified = false; return settle(game.reset()); }155 function input(action) {156 const value = typeof action === 'string' ? action : action?.key;157 const key = String(value || '').toLowerCase();158 if (directions[key]) return move(...directions[key]);159 if (key === 'arrowup' || key === 'w') return move(0, -1);160 if (key === 'arrowdown' || key === 's') return move(0, 1);161 if (key === 'arrowleft' || key === 'a') return move(-1, 0);162 if (key === 'arrowright' || key === 'd') return move(1, 0);163 if (key === 'z' || key === 'undo') return undo();164 if (key === 'r' || key === 'reset' || key === 'restart') return reset();165 if (key === 'escape' || key === 'menu') { options.onMenu?.(); return { accepted: true, kind: 'menu', won: game.getState().won }; }166 if (key === 'enter' || key === 'confirm') { options.onSkip?.(); return { accepted: true, kind: 'skip', won: game.getState().won }; }167 if (key === ' ' || key === 'space' || key === 'spacebar' || key === 'workshop') { options.onWorkshop?.(); return { accepted: true, kind: 'workshop', won: game.getState().won }; }168 return { accepted: false, kind: 'input', won: game.getState().won };169 }170 function hit(pointerX, pointerY) {171 const target = footerLayout.find(item => pointerX >= item.x && pointerX <= item.x + item.width && pointerY >= item.y && pointerY <= item.y + item.height);172 if (!target) return { accepted: false, kind: 'hit' };173 if (target.action === 'workshop') options.onWorkshop?.();174 else if (target.action === 'skip') options.onSkip?.();175 else if (target.action === 'menu') options.onMenu?.();176 return { accepted: true, kind: target.action, won: game.getState().won };177 }178 function draw(ctx, drawOptions = {}) {179 const width = drawOptions.width ?? ctx.canvas?.clientWidth ?? ctx.canvas?.width ?? 800;180 const height = drawOptions.height ?? ctx.canvas?.clientHeight ?? ctx.canvas?.height ?? 600;181 const state = game.getState();182 const reducedMotion = Boolean(drawOptions.reducedMotion ?? options.reducedMotion);183 const now = drawOptions.now ?? (typeof performance === 'undefined' ? titleEpoch : performance.now());184 renderNow = now;185 const elapsed = Math.max(0, now - titleEpoch);186 const sceneMotion = drawOptions.sceneMotion || {};187 const header = drawHeader(ctx, width, height, {188 now: elapsed,189 reducedMotion,190 exitProgress: sceneMotion.exitProgress || 0,191 });192 const cell = drawOptions.cell ?? Math.min(84, Math.max(30, Math.floor(Math.min((width - 64) / state.width, (height - 330) / state.height))));193 const x = drawOptions.x ?? Math.floor((width - state.width * cell) / 2);194 const boardHeight = state.height * cell;195 const boardTop = Math.max((header?.bottom || 130) + 42, 160);196 const boardBottom = height - (width < 520 ? 100 : 78);197 const y = drawOptions.y ?? Math.floor(boardTop + Math.max(0, (boardBottom - boardTop - boardHeight) / 2));198 const framePositions = drawOptions.frame?.positions instanceof Map ? new Map(drawOptions.frame.positions) : new Map();199 const existingMiau = framePositions.get('title-miau') || {};200 const flipProgress = miauFlipStartedAt == null ? 1 : Math.max(0, Math.min(1, (now - miauFlipStartedAt) / 70));201 const flipScale = reducedMotion || flipProgress >= 1 ? 1 : 1 - 0.8 * Math.sin(Math.PI * flipProgress);202 framePositions.set('title-miau', { ...existingMiau, facing: miauFacing, glyphScaleX: flipScale });203 const frame = { ...(drawOptions.frame || {}), positions: framePositions };204 drawBoard(ctx, state, { glyphs: { MIAU: '🐈', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, frame, x, y, cell, game });205 footerLayout = drawFooter(ctx, width, height, drawOptions);206 return { state, geometry: { x, y, cell, width: state.width * cell, height: boardHeight }, header: { ...header, footerY: height - (width < 520 ? 48 : 34) }, footer: footerLayout };207 }208 return Object.freeze({ id: 'title', game, get state() { return game.getState(); }, get facing() { return miauFacing; }, move, undo, reset, input, hit, draw, get canUndo() { return game.canUndo; } });209}210211export default titleScene;