clone

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

Polish world cards for authored chapter identity

Dami committed 7b6e2c9
commit
7b6e2c92a8bffb82f73f455cf4cb036db746e375
parent
e02efa12a5ab63ea22221df64660738818d67bdc
author
Dami <dami.etoile@gmail.com>
2 files changed, +74 -27
Msrc/render/world-card.js70++++++++++++++++++++----------
Mtests/world-card.test.js31++++++++++++--
M src/render/world-card.js +47 -23
@@ -1,9 +1,8 @@
11 import { BOARD_COLORS } from './board/model.js';22 33 /**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.4+ * Colours for the world-entry card. The property accent identifies the card5+ * border and action; the surfaces use the neutral menu palette.76 */87 export const WORLD_CARD_COLORS = Object.freeze({98 ink: '#0F0E0D',
@@ -82,7 +81,7 @@ function accentColor(world, colors) {
8281 8382 function drawKeycap(ctx, x, y, options = {}) {8483 const key = 'ENTER';85- const label = 'enter';84+ const label = 'enter world';8685 ctx.save();8786 ctx.font = `800 10px ${FONT}`;8887 const keyWidth = Math.max(42, ctx.measureText(key).width + 14);
@@ -114,6 +113,22 @@ function drawKeycap(ctx, x, y, options = {}) {
114113 };115114 }116115 116+function drawMobileAction(ctx, card, y, accent, colors, pressed = false) {117+ const geometry = { x: card.x + 16, y, width: card.width - 32, height: 48 };118+ ctx.save();119+ ctx.fillStyle = colors.lip;120+ ctx.fillRect(geometry.x + 4, geometry.y + 4, geometry.width, geometry.height);121+ ctx.fillStyle = accent;122+ ctx.fillRect(geometry.x, geometry.y + (pressed ? 4 : 0), geometry.width, geometry.height);123+ ctx.fillStyle = colors.ink;124+ ctx.font = `800 12px ${FONT}`;125+ ctx.textAlign = 'center';126+ ctx.textBaseline = 'middle';127+ ctx.fillText('ENTER WORLD', geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + (pressed ? 2 : 0));128+ ctx.restore();129+ return { ...geometry, visual: geometry };130+}131+117132 function colorsFor(options) {118133 return options.colors || WORLD_CARD_COLORS;119134 }
@@ -135,7 +150,9 @@ export function drawWorldCard(ctx, world, options = {}) {
135150 const progress = reducedMotion ? 1 : clamp(Number(options.irisProgress ?? 1), 0, 1);136151 const mobile = width < 560 || options.coarsePointer === true;137152 const cardWidth = Math.min(width - 32, mobile ? 420 : 620);138- const cardHeight = Math.min(height - 32, mobile ? 452 : 430);153+ // The action belongs to the content group. Keeping the card compact leaves154+ // the same 16px rhythm between metadata and the choice on every viewport.155+ const cardHeight = Math.min(height - 32, mobile ? 372 : 336);139156 const card = {140157 x: (width - cardWidth) / 2,141158 y: (height - cardHeight) / 2,
@@ -144,13 +161,18 @@ export function drawWorldCard(ctx, world, options = {}) {
144161 };145162 const accent = accentColor(world, colors);146163 const name = worldValue(world, 'name', 'Untitled world');147- const author = worldValue(world, 'author', 'Unknown author');164+ const author = worldValue(world, 'author');148165 const glyph = worldValue(world, 'glyph', '✦') || '✦';149166 const tagline = worldValue(world, 'tagline');167+ const worldNumber = String(world?.number || 1).padStart(2, '0');168+ const proofTotal = Number.isFinite(world?.levelCount)169+ ? Math.max(0, world.levelCount)170+ : Array.isArray(world?.levels) ? world.levels.length : 0;171+ const completed = Number.isFinite(world?.completedCount) ? Math.max(0, world.completedCount) : 0;150172 const innerWidth = card.width - (mobile ? 32 : 64);151173 const glyphSize = 96;152- const titleY = card.y + (mobile ? 196 : 190);153- const titleFont = fitFont(ctx, name, innerWidth, mobile ? 28 : 32, 16);174+ const titleY = card.y + 192;175+ const titleFont = fitFont(ctx, name, innerWidth, 32, 16);154176 ctx.save();155177 ctx.fillStyle = colors.ink;156178 ctx.fillRect(0, 0, width, height);
@@ -167,8 +189,8 @@ export function drawWorldCard(ctx, world, options = {}) {
167189 roundRect(ctx, card.x, card.y, card.width, card.height, 5);168190 ctx.fillStyle = colors.board;169191 ctx.fill();170- ctx.strokeStyle = colors.edge;171- ctx.lineWidth = 1;192+ ctx.strokeStyle = accent;193+ ctx.lineWidth = 2;172194 ctx.stroke();173195 174196 ctx.textAlign = 'center';
@@ -178,30 +200,32 @@ export function drawWorldCard(ctx, world, options = {}) {
178200 ctx.fillText(glyph, width / 2, card.y + 92);179201 180202 ctx.fillStyle = accent;181- ctx.font = `700 11px ${FONT}`;182- ctx.fillText('A WORLD TO PROVE', width / 2, card.y + 142);203+ ctx.font = `800 11px ${FONT}`;204+ ctx.fillText(`WORLD ${worldNumber}`, width / 2, card.y + 144);183205 ctx.fillStyle = colors.fg;184206 ctx.font = titleFont;185207 ctx.fillText(name, width / 2, titleY);186208 187- const taglineLines = fitWrappedText(ctx, tagline, innerWidth, mobile ? 12 : 13, 9);209+ const taglineLines = fitWrappedText(ctx, tagline, innerWidth, 15, 10, 2);188210 ctx.fillStyle = colors.muted;189211 taglineLines.forEach((line, index) => ctx.fillText(line, width / 2, titleY + 35 + index * 16));190- const bylineY = titleY + 35 + taglineLines.length * 16 + 19;191- ctx.font = `600 11px ${FONT}`;192- ctx.fillText(`by ${author}`, width / 2, bylineY);193- const enterY = card.y + card.height - (mobile ? 48 : 52);194- const enter = drawKeycap(ctx, width / 2, enterY, {195- colors,196- press: options.press === true,197- coarsePointer: mobile,198- });212+ const meta = `${proofTotal} ${proofTotal === 1 ? 'proof' : 'proofs'} | ★ ${completed}${proofTotal ? ` / ${proofTotal}` : ' / 0'}${author ? ` | by ${author}` : ''}`;213+ const metaY = titleY + 35 + taglineLines.length * 16 + 24;214+ ctx.fillStyle = colors.muted;215+ ctx.font = fitFont(ctx, meta, innerWidth, 11, 8, 600);216+ ctx.fillText(meta, width / 2, metaY);217+ const enter = mobile218+ ? drawMobileAction(ctx, card, metaY + 32, accent, colors, options.press === true)219+ : drawKeycap(ctx, width / 2, metaY + 28, {220+ colors,221+ press: options.press === true,222+ });199223 ctx.restore();200224 201225 return {202226 card,203227 enter,204228 iris: { x: width / 2, y: height / 2, progress, radius: Math.hypot(width, height) * easeOut(progress) },205- world: { name, author, glyph, tagline, accent },229+ world: { name, author, glyph, tagline, accent, number: worldNumber, proofTotal, completed, meta },206230 };207231 }
M tests/world-card.test.js +27 -4
@@ -29,16 +29,21 @@ test('drawWorldCard returns enter hit geometry and normalized display values', (
2929 const result = drawWorldCard(ctx, {3030 name: 'Tidewater', author: 'Dami', glyph: '🌊',3131 tagline: "Everything floats until it doesn't.", accent: 'sink',32+ number: 3, levelCount: 9, completedCount: 4,3233 }, { width: 640, height: 420 });3334 assert.deepEqual(result.world, {3435 name: 'Tidewater', author: 'Dami', glyph: '🌊',3536 tagline: "Everything floats until it doesn't.", accent: WORLD_CARD_COLORS.sink,37+ number: '03', proofTotal: 9, completed: 4,38+ meta: "9 proofs | ★ 4 / 9 | by Dami",3639 });3740 assert.ok(result.enter.width > 60);3841 assert.equal(result.enter.height, 20);3942 assert.ok(result.enter.x >= 0 && result.enter.x + result.enter.width <= 640);4043 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'));44+ assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === 'WORLD 03'));45+ assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === result.world.meta));46+ assert.ok(ctx.calls.some((call) => call[0] === 'stroke' && call[1] === WORLD_CARD_COLORS.sink));4247 });4348 4449 test('world card fits a narrow mobile canvas and wraps the tagline', () => {
@@ -46,17 +51,35 @@ test('world card fits a narrow mobile canvas and wraps the tagline', () => {
4651 const result = drawWorldCard(ctx, {4752 name: 'A Very Long World Name', author: 'Dami', glyph: '🧭',4853 tagline: 'Every proof turns the compass toward another room.', accent: 'tick',54+ number: 2, levelCount: 3, completedCount: 1,4955 }, { width: 390, height: 844, coarsePointer: true });5056 assert.equal(result.card.x, 16);5157 assert.equal(result.card.width, 358);5258 assert.ok(result.card.y >= 16);5359 assert.ok(result.enter.y + result.enter.height <= result.card.y + result.card.height);5460 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');61+ assert.ok(lines.length >= 7, 'mobile card should include wrapped tagline plus card copy');5662 assert.ok(lines.some((line) => line.startsWith('Every proof turns')));5763 assert.ok(lines.some((line) => line.endsWith('room.')));58- assert.equal(result.enter.height, 44, 'coarse-pointer ENTER hit target should be touch sized');59- assert.equal(result.enter.visual.height, 20, 'touch hit target should remain larger than visible keycap');64+ assert.equal(result.enter.height, 48, 'mobile action should be a 48px touch target');65+ assert.equal(result.enter.width, result.card.width - 32, 'mobile action should span the card content width');66+ assert.ok(lines.includes('ENTER WORLD'), 'mobile action should use the full button label');67+ const metaCall = ctx.calls.find((call) => call[0] === 'fillText' && call[1] === result.world.meta);68+ assert.ok(metaCall, 'mobile card should draw proof metadata');69+ assert.ok(result.enter.y - metaCall[3] <= 48, 'mobile action should stay close to metadata');70+});71+72+test('world card omits an absent byline while preserving proof metadata', () => {73+ const ctx = context();74+ const result = drawWorldCard(ctx, {75+ name: 'No Author', glyph: '✦', tagline: 'A small proof.',76+ number: 7, levelCount: 1, completedCount: 0,77+ }, { width: 390, height: 844 });78+ assert.equal(result.world.author, '');79+ assert.equal(result.world.meta, '1 proof | ★ 0 / 1');80+ const labels = ctx.calls.filter((call) => call[0] === 'fillText').map((call) => call[1]);81+ assert.ok(labels.includes(result.world.meta));82+ assert.ok(!labels.some((label) => String(label).includes('by ')));6083 });6184 6285 test('reduced motion settles the iris without clipping or changing card geometry', () => {