clone

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

Add pure world entry card renderer

Dami committed 5c504dc
commit
5c504dc923d8651e610897999f14f5de2ea7356f
parent
17de0b30ef075c51ce8553d1347d28e939641d4a
author
Dami <dami.etoile@gmail.com>
2 files changed, +257 -0
Asrc/render/world-card.js185++++++++++++++++++++++++++++++
Atests/world-card.test.js72++++++++++++
A src/render/world-card.js +185 -0
@@ -0,0 +1,185 @@
1+import { BOARD_COLORS } from './board/model.js';2+3+/**4+ * Colours for the world-entry card. The property accent is only a signal5+ * colour; the card itself keeps the ink-and-signal neutral surfaces used by6+ * the other menu renderers.7+ */8+export const WORLD_CARD_COLORS = Object.freeze({9+ ink: '#0F0E0D',10+ board: '#191816',11+ edge: '#2E2B28',12+ lip: '#000000',13+ fg: '#F2EEE6',14+ muted: '#8E877C',15+ ...Object.fromEntries(Object.entries(BOARD_COLORS).map(([key, value]) => [key.toLowerCase(), value])),16+});17+18+const FONT = "'Recursive', ui-monospace, monospace";19+const EMOJI_FONT = '"Apple Color Emoji", "Segoe UI Emoji", sans-serif';20+const ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat', 'tick']);21+22+function clamp(value, min, max) {23+ return Math.max(min, Math.min(max, value));24+}25+26+function easeOut(value) {27+ return 1 - ((1 - value) ** 3);28+}29+30+function roundRect(ctx, x, y, width, height, radius) {31+ ctx.beginPath();32+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);33+ else ctx.rect(x, y, width, height);34+}35+36+function wrapText(ctx, value, maxWidth) {37+ const words = String(value || '').trim().split(/\s+/).filter(Boolean);38+ if (!words.length) return [];39+ const lines = [];40+ let line = '';41+ for (const word of words) {42+ const candidate = line ? `${line} ${word}` : word;43+ if (line && ctx.measureText(candidate).width > maxWidth) {44+ lines.push(line);45+ line = word;46+ } else line = candidate;47+ }48+ if (line) lines.push(line);49+ return lines;50+}51+52+function fitFont(ctx, text, maxWidth, size, minSize, weight = 800) {53+ let current = size;54+ ctx.font = `${weight} ${current}px ${FONT}`;55+ while (current > minSize && ctx.measureText(text).width > maxWidth) {56+ current -= 1;57+ ctx.font = `${weight} ${current}px ${FONT}`;58+ }59+ return `${weight} ${Math.max(minSize, current)}px ${FONT}`;60+}61+62+function worldValue(world, key, fallback = '') {63+ return String(world?.[key] ?? fallback).trim();64+}65+66+function accentColor(world, colors) {67+ const token = world?.accent;68+ return ACCENTS.has(token) ? colors[token] || colors.fg : colors.fg;69+}70+71+function drawKeycap(ctx, x, y, options = {}) {72+ const key = 'ENTER';73+ const label = 'enter';74+ ctx.save();75+ ctx.font = `800 10px ${FONT}`;76+ const keyWidth = Math.max(42, ctx.measureText(key).width + 14);77+ ctx.font = `600 10px ${FONT}`;78+ const labelWidth = ctx.measureText(label).width;79+ const totalWidth = keyWidth + 10 + labelWidth;80+ const left = x - totalWidth / 2;81+ const pressOffset = options.press ? 3 : 0;82+ ctx.fillStyle = colorsFor(options).lip;83+ if (!options.press) ctx.fillRect(left + 4, y - 10, keyWidth, 20);84+ ctx.fillStyle = colorsFor(options).fg;85+ ctx.fillRect(left, y - 10 + pressOffset, keyWidth, 20);86+ ctx.fillStyle = colorsFor(options).ink;87+ ctx.textAlign = 'left';88+ ctx.textBaseline = 'middle';89+ ctx.font = `800 10px ${FONT}`;90+ ctx.fillText(key, left + 7, y + 1 + pressOffset);91+ ctx.fillStyle = colorsFor(options).muted;92+ ctx.font = `600 10px ${FONT}`;93+ ctx.fillText(label, left + keyWidth + 10, y + 1 + pressOffset);94+ ctx.restore();95+ return { x: left, y: y - 10, width: totalWidth, height: 20 };96+}97+98+function colorsFor(options) {99+ return options.colors || WORLD_CARD_COLORS;100+}101+102+/**103+ * Draw a world entry card and return stable hit geometry for scene input.104+ *105+ * `world` is the normalized D20 world view model (`name`, `author`, `glyph`,106+ * `tagline`, and `accent`). `options.irisProgress` is 0..1 for the entrance107+ * reveal; reduced motion cuts directly to the settled card. The renderer is108+ * pure with respect to the view model and does not handle Enter or scenes.109+ */110+export function drawWorldCard(ctx, world, options = {}) {111+ if (!ctx) throw new TypeError('drawWorldCard(ctx, world): ctx is required');112+ const width = options.width ?? ctx.canvas?.width ?? 640;113+ const height = options.height ?? ctx.canvas?.height ?? 420;114+ const colors = { ...WORLD_CARD_COLORS, ...(options.colors || {}) };115+ const reducedMotion = options.reducedMotion === true;116+ const progress = reducedMotion ? 1 : clamp(Number(options.irisProgress ?? 1), 0, 1);117+ const mobile = width < 560 || options.coarsePointer === true;118+ const cardWidth = Math.min(width - 32, mobile ? 420 : 620);119+ const cardHeight = Math.min(height - 32, mobile ? 452 : 430);120+ const card = {121+ x: (width - cardWidth) / 2,122+ y: (height - cardHeight) / 2,123+ width: cardWidth,124+ height: cardHeight,125+ };126+ const accent = accentColor(world, colors);127+ const name = worldValue(world, 'name', 'Untitled world');128+ const author = worldValue(world, 'author', 'Unknown author');129+ const glyph = worldValue(world, 'glyph', '✦') || '✦';130+ const tagline = worldValue(world, 'tagline');131+ const innerWidth = card.width - (mobile ? 32 : 64);132+ const glyphSize = 96;133+ const titleY = card.y + (mobile ? 196 : 190);134+ const titleFont = fitFont(ctx, name, innerWidth, mobile ? 28 : 32, 16);135+ ctx.save();136+ ctx.fillStyle = colors.ink;137+ ctx.fillRect(0, 0, width, height);138+ if (progress < 1) {139+ // Keep the card readable during the iris while avoiding a separate DOM140+ // transition. The clipped reveal is deterministic for reduced-motion QA.141+ const radius = Math.hypot(width, height) * easeOut(progress);142+ ctx.beginPath();143+ ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2);144+ ctx.clip();145+ }146+ ctx.fillStyle = colors.lip;147+ ctx.fillRect(card.x + 8, card.y + 8, card.width, card.height);148+ roundRect(ctx, card.x, card.y, card.width, card.height, 5);149+ ctx.fillStyle = colors.board;150+ ctx.fill();151+ ctx.strokeStyle = colors.edge;152+ ctx.lineWidth = 1;153+ ctx.stroke();154+155+ ctx.textAlign = 'center';156+ ctx.textBaseline = 'middle';157+ ctx.font = `${glyphSize}px ${EMOJI_FONT}`;158+ ctx.fillStyle = colors.fg;159+ ctx.fillText(glyph, width / 2, card.y + 92);160+161+ ctx.fillStyle = accent;162+ ctx.font = `700 11px ${FONT}`;163+ ctx.fillText('A WORLD TO PROVE', width / 2, card.y + 142);164+ ctx.fillStyle = colors.fg;165+ ctx.font = titleFont;166+ ctx.fillText(name, width / 2, titleY);167+168+ const taglineLines = wrapText(ctx, tagline, innerWidth).slice(0, 2);169+ ctx.fillStyle = colors.muted;170+ ctx.font = `500 ${mobile ? 12 : 13}px ${FONT}`;171+ taglineLines.forEach((line, index) => ctx.fillText(line, width / 2, titleY + 35 + index * 16));172+ const bylineY = titleY + 35 + taglineLines.length * 16 + 19;173+ ctx.font = `600 11px ${FONT}`;174+ ctx.fillText(`by ${author}`, width / 2, bylineY);175+ const enterY = card.y + card.height - (mobile ? 48 : 52);176+ const enter = drawKeycap(ctx, width / 2, enterY, { colors, press: options.press === true });177+ ctx.restore();178+179+ return {180+ card,181+ enter,182+ iris: { x: width / 2, y: height / 2, progress, radius: Math.hypot(width, height) * easeOut(progress) },183+ world: { name, author, glyph, tagline, accent },184+ };185+}
A tests/world-card.test.js +72 -0
@@ -0,0 +1,72 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { drawWorldCard, WORLD_CARD_COLORS } from '../src/render/world-card.js';4+5+function context() {6+ const calls = [];7+ const ctx = {8+ calls,9+ canvas: { width: 640, height: 420 },10+ font: '',11+ save() { calls.push(['save']); },12+ restore() { calls.push(['restore']); },13+ beginPath() { calls.push(['beginPath']); },14+ roundRect(...args) { calls.push(['roundRect', ...args]); },15+ rect(...args) { calls.push(['rect', ...args]); },16+ arc(...args) { calls.push(['arc', ...args]); },17+ clip() { calls.push(['clip']); },18+ fill() { calls.push(['fill', this.fillStyle]); },19+ stroke() { calls.push(['stroke', this.strokeStyle]); },20+ fillRect(...args) { calls.push(['fillRect', ...args, this.fillStyle]); },21+ fillText(...args) { calls.push(['fillText', ...args]); },22+ measureText(text) { return { width: String(text).length * 7 }; },23+ };24+ return ctx;25+}26+27+test('drawWorldCard returns enter hit geometry and normalized display values', () => {28+ const ctx = context();29+ const result = drawWorldCard(ctx, {30+ name: 'Tidewater', author: 'Dami', glyph: '🌊',31+ tagline: "Everything floats until it doesn't.", accent: 'sink',32+ }, { width: 640, height: 420 });33+ assert.deepEqual(result.world, {34+ name: 'Tidewater', author: 'Dami', glyph: '🌊',35+ tagline: "Everything floats until it doesn't.", accent: WORLD_CARD_COLORS.sink,36+ });37+ assert.ok(result.enter.width > 60);38+ assert.equal(result.enter.height, 20);39+ assert.ok(result.enter.x >= 0 && result.enter.x + result.enter.width <= 640);40+ assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === '🌊'));41+ assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === 'by Dami'));42+});43+44+test('world card fits a narrow mobile canvas and wraps the tagline', () => {45+ const ctx = context();46+ const result = drawWorldCard(ctx, {47+ name: 'A Very Long World Name', author: 'Dami', glyph: '🧭',48+ tagline: 'Every proof turns the compass toward another room.', accent: 'tick',49+ }, { width: 390, height: 844, coarsePointer: true });50+ assert.equal(result.card.x, 16);51+ assert.equal(result.card.width, 358);52+ assert.ok(result.card.y >= 16);53+ assert.ok(result.enter.y + result.enter.height <= result.card.y + result.card.height);54+ const lines = ctx.calls.filter((call) => call[0] === 'fillText').map((call) => call[1]);55+ assert.ok(lines.length >= 7, 'mobile card should include two tagline lines plus card copy');56+ assert.ok(lines.some((line) => line.startsWith('Every proof turns')));57+ assert.ok(lines.some((line) => line.endsWith('room.')));58+});59+60+test('reduced motion settles the iris without clipping or changing card geometry', () => {61+ const animated = drawWorldCard(context(), { name: 'Core', glyph: '🐈' }, {62+ width: 640, height: 420, irisProgress: 0.25,63+ });64+ const reducedCtx = context();65+ const reduced = drawWorldCard(reducedCtx, { name: 'Core', glyph: '🐈' }, {66+ width: 640, height: 420, irisProgress: 0.25, reducedMotion: true,67+ });68+ assert.equal(animated.iris.progress, 0.25);69+ assert.equal(reduced.iris.progress, 1);70+ assert.deepEqual(reduced.card, animated.card);71+ assert.equal(reducedCtx.calls.filter((call) => call[0] === 'clip').length, 0);72+});