clone

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

main
miau-is-true / src / render / world-card.js
world-card.js 8.8K
1import { BOARD_COLORS } from './board/model.js';23/**4 * Colours for the world-entry card. The property accent identifies the card5 * border and action; the surfaces use the neutral menu palette.6 */7export const WORLD_CARD_COLORS = Object.freeze({8 ink: '#0F0E0D',9 board: '#191816',10 edge: '#2E2B28',11 lip: '#000000',12 fg: '#F2EEE6',13 muted: '#8E877C',14 ...Object.fromEntries(Object.entries(BOARD_COLORS).map(([key, value]) => [key.toLowerCase(), value])),15});1617const FONT = "'Recursive', ui-monospace, monospace";18const EMOJI_FONT = '"Apple Color Emoji", "Segoe UI Emoji", sans-serif';19const ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat', 'tick']);2021function clamp(value, min, max) {22 return Math.max(min, Math.min(max, value));23}2425function easeOut(value) {26 return 1 - ((1 - value) ** 3);27}2829function roundRect(ctx, x, y, width, height, radius) {30 ctx.beginPath();31 if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);32 else ctx.rect(x, y, width, height);33}3435function wrapText(ctx, value, maxWidth) {36 const words = String(value || '').trim().split(/\s+/).filter(Boolean);37 if (!words.length) return [];38 const lines = [];39 let line = '';40 for (const word of words) {41 const candidate = line ? `${line} ${word}` : word;42 if (line && ctx.measureText(candidate).width > maxWidth) {43 lines.push(line);44 line = word;45 } else line = candidate;46 }47 if (line) lines.push(line);48 return lines;49}5051function fitFont(ctx, text, maxWidth, size, minSize, weight = 800) {52 let current = size;53 ctx.font = `${weight} ${current}px ${FONT}`;54 while (current > minSize && ctx.measureText(text).width > maxWidth) {55 current -= 1;56 ctx.font = `${weight} ${current}px ${FONT}`;57 }58 return `${weight} ${Math.max(minSize, current)}px ${FONT}`;59}6061function fitWrappedText(ctx, text, maxWidth, size, minSize, maxLines = 2) {62 let current = size;63 let lines;64 do {65 ctx.font = `500 ${current}px ${FONT}`;66 lines = wrapText(ctx, text, maxWidth);67 if (lines.length <= maxLines || current <= minSize) return lines;68 current -= 1;69 } while (current >= minSize);70 return lines;71}7273function worldValue(world, key, fallback = '') {74 return String(world?.[key] ?? fallback).trim();75}7677function accentColor(world, colors) {78 const token = world?.accent;79 return ACCENTS.has(token) ? colors[token] || colors.fg : colors.fg;80}8182export function worldCardDescription(world) {83 const details = [84 worldValue(world, 'name', 'World'),85 worldValue(world, 'tagline'),86 worldValue(world, 'author') ? `By ${worldValue(world, 'author')}` : '',87 ].filter(Boolean).map(value => /[.!?]$/.test(value) ? value : `${value}.`);88 return `${details.join(' ')} Press Enter to enter.`;89}9091function drawKeycap(ctx, x, y, options = {}) {92 const key = 'ENTER';93 const label = 'enter world';94 ctx.save();95 ctx.font = `800 10px ${FONT}`;96 const keyWidth = Math.max(42, ctx.measureText(key).width + 14);97 ctx.font = `600 10px ${FONT}`;98 const labelWidth = ctx.measureText(label).width;99 const totalWidth = keyWidth + 10 + labelWidth;100 const left = x - totalWidth / 2;101 const pressOffset = options.press ? 3 : 0;102 const visual = { x: left, y: y - 10, width: totalWidth, height: 20 };103 ctx.fillStyle = colorsFor(options).lip;104 if (!options.press) ctx.fillRect(left + 4, y - 10, keyWidth, 20);105 ctx.fillStyle = colorsFor(options).fg;106 ctx.fillRect(left, y - 10 + pressOffset, keyWidth, 20);107 ctx.fillStyle = colorsFor(options).ink;108 ctx.textAlign = 'left';109 ctx.textBaseline = 'middle';110 ctx.font = `800 10px ${FONT}`;111 ctx.fillText(key, left + 7, y + 1 + pressOffset);112 ctx.fillStyle = colorsFor(options).muted;113 ctx.font = `600 10px ${FONT}`;114 ctx.fillText(label, left + keyWidth + 10, y + 1 + pressOffset);115 ctx.restore();116 return {117 x: left,118 y: y - (options.coarsePointer ? 22 : 10),119 width: totalWidth,120 height: options.coarsePointer ? 44 : 20,121 visual,122 };123}124125function drawMobileAction(ctx, card, y, accent, colors, pressed = false) {126 const geometry = { x: card.x + 16, y, width: card.width - 32, height: 48 };127 ctx.save();128 ctx.fillStyle = colors.lip;129 ctx.fillRect(geometry.x + 4, geometry.y + 4, geometry.width, geometry.height);130 ctx.fillStyle = accent;131 ctx.fillRect(geometry.x, geometry.y + (pressed ? 4 : 0), geometry.width, geometry.height);132 ctx.fillStyle = colors.ink;133 ctx.font = `800 12px ${FONT}`;134 ctx.textAlign = 'center';135 ctx.textBaseline = 'middle';136 ctx.fillText('ENTER WORLD', geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + (pressed ? 2 : 0));137 ctx.restore();138 return { ...geometry, visual: geometry };139}140141function colorsFor(options) {142 return options.colors || WORLD_CARD_COLORS;143}144145/**146 * Draw a world entry card and return stable hit geometry for scene input.147 *148 * `world` is the normalized D20 world view model (`name`, `author`, `glyph`,149 * `tagline`, and `accent`). `options.irisProgress` is 0..1 for the entrance150 * reveal; reduced motion cuts directly to the settled card. The renderer is151 * pure with respect to the view model and does not handle Enter or scenes.152 */153export function drawWorldCard(ctx, world, options = {}) {154 if (!ctx) throw new TypeError('drawWorldCard(ctx, world): ctx is required');155 const width = options.width ?? ctx.canvas?.width ?? 640;156 const height = options.height ?? ctx.canvas?.height ?? 420;157 const colors = { ...WORLD_CARD_COLORS, ...(options.colors || {}) };158 const reducedMotion = options.reducedMotion === true;159 const progress = reducedMotion ? 1 : clamp(Number(options.irisProgress ?? 1), 0, 1);160 const mobile = width < 560 || options.coarsePointer === true;161 const cardWidth = Math.min(width - 32, mobile ? 420 : 620);162 // The action belongs to the content group. Keeping the card compact leaves163 // the same 16px rhythm between metadata and the choice on every viewport.164 const cardHeight = Math.min(height - 32, mobile ? 372 : 336);165 const card = {166 x: (width - cardWidth) / 2,167 y: (height - cardHeight) / 2,168 width: cardWidth,169 height: cardHeight,170 };171 const accent = accentColor(world, colors);172 const name = worldValue(world, 'name', 'Untitled world');173 const author = worldValue(world, 'author');174 const glyph = worldValue(world, 'glyph', '✦') || '✦';175 const tagline = worldValue(world, 'tagline');176 const worldNumber = String(world?.number || 1).padStart(2, '0');177 const proofTotal = Number.isFinite(world?.levelCount)178 ? Math.max(0, world.levelCount)179 : Array.isArray(world?.levels) ? world.levels.length : 0;180 const completed = Number.isFinite(world?.completedCount) ? Math.max(0, world.completedCount) : 0;181 const innerWidth = card.width - (mobile ? 32 : 64);182 const glyphSize = 96;183 const titleY = card.y + 192;184 const titleFont = fitFont(ctx, name, innerWidth, 32, 16);185 ctx.save();186 ctx.fillStyle = colors.ink;187 ctx.fillRect(0, 0, width, height);188 if (progress < 1) {189 // Keep the card readable during the iris while avoiding a separate DOM190 // transition. The clipped reveal is deterministic for reduced-motion QA.191 const radius = Math.hypot(width, height) * easeOut(progress);192 ctx.beginPath();193 ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2);194 ctx.clip();195 }196 ctx.fillStyle = colors.lip;197 ctx.fillRect(card.x + 8, card.y + 8, card.width, card.height);198 roundRect(ctx, card.x, card.y, card.width, card.height, 5);199 ctx.fillStyle = colors.board;200 ctx.fill();201 ctx.strokeStyle = accent;202 ctx.lineWidth = 2;203 ctx.stroke();204205 ctx.textAlign = 'center';206 ctx.textBaseline = 'middle';207 ctx.font = `${glyphSize}px ${EMOJI_FONT}`;208 ctx.fillStyle = colors.fg;209 ctx.fillText(glyph, width / 2, card.y + 92);210211 ctx.fillStyle = accent;212 ctx.font = `800 11px ${FONT}`;213 ctx.fillText(`WORLD ${worldNumber}`, width / 2, card.y + 144);214 ctx.fillStyle = colors.fg;215 ctx.font = titleFont;216 ctx.fillText(name, width / 2, titleY);217218 const taglineLines = fitWrappedText(ctx, tagline, innerWidth, 15, 10, 2);219 ctx.fillStyle = colors.muted;220 taglineLines.forEach((line, index) => ctx.fillText(line, width / 2, titleY + 35 + index * 16));221 const meta = `${proofTotal} ${proofTotal === 1 ? 'proof' : 'proofs'} | ★ ${completed}${proofTotal ? ` / ${proofTotal}` : ' / 0'}${author ? ` | by ${author}` : ''}`;222 const metaY = titleY + 35 + taglineLines.length * 16 + 24;223 ctx.fillStyle = colors.muted;224 ctx.font = fitFont(ctx, meta, innerWidth, 11, 8, 600);225 ctx.fillText(meta, width / 2, metaY);226 const enter = mobile227 ? drawMobileAction(ctx, card, metaY + 32, accent, colors, options.press === true)228 : drawKeycap(ctx, width / 2, metaY + 28, {229 colors,230 press: options.press === true,231 });232 ctx.restore();233234 return {235 card,236 enter,237 iris: { x: width / 2, y: height / 2, progress, radius: Math.hypot(width, height) * easeOut(progress) },238 world: { name, author, glyph, tagline, accent, number: worldNumber, proofTotal, completed, meta },239 };240}