- commit
- 5c504dc923d8651e610897999f14f5de2ea7356f
- parent
- 17de0b30ef075c51ce8553d1347d28e939641d4a
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 185 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 72 | ++++++++++++ |
@@ -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+}@@ -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+});