clone

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

feat(index): render proof progress and solved-node feedback

Dami committed ebec06b
commit
ebec06b5e9ec1598cd500a75cf24b8ec641bac62
parent
1aa6f71d074c8116d80b12d52a953d220f126585
author
Dami <dami.etoile@gmail.com>
2 files changed, +97 -24
Msrc/render/index.js84+++++++++++++++++++++---------
Mtests/index-render.test.js37+++++++++++++
M src/render/index.js +60 -24
@@ -9,16 +9,19 @@
99 export const INDEX_COLORS = Object.freeze({1010 stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',1111 ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C', dot: '#4A4540',12- tile: '#F2EEE6', coral: '#FF4B3E', solved: '#2EE6A8', locked: '#8E877C',12+ tile: '#F2EEE6', coral: '#FF4B3E', solved: '#2EE6A8', locked: '#8E877C', lockedTile: '#3B3733',1313 });1414 15+const FONT = "'Recursive', ui-monospace, monospace";16+1517 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }18+function easeOut(value) { return 1 - ((1 - value) ** 3); }1619 function roundRect(ctx, x, y, width, height, radius) {1720 ctx.beginPath();1821 if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);1922 else ctx.rect(x, y, width, height);2023 }21-function fitFont(ctx, text, maxWidth, size, weight = 800, family = 'ui-monospace, monospace') {24+function fitFont(ctx, text, maxWidth, size, weight = 800, family = FONT) {2225 let current = size;2326 if (typeof ctx.measureText !== 'function') return `${weight} ${current}px ${family}`;2427 while (current > size * 0.55) {
@@ -84,10 +87,12 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
8487 const solved = node.solved === true;8588 const active = current?.id === node.id;8689 const signal = solved ? colors.solved : colors.coral;87- const alpha = locked ? 0.34 : 1;88- const rect = { id: node.id, x: left, y: top, width: tileWidth, height: tileHeight, centerX: center.x, centerY: center.y, locked, solved, active };90+ const popping = options.popNodeId === node.id;91+ const popProgress = popping ? clamp(Number.isFinite(options.popProgress) ? options.popProgress : 1, 0, 1) : 1;92+ const popScale = popping && !options.reducedMotion ? 0.76 + 0.24 * easeOut(popProgress) : 1;93+ const rect = { id: node.id, x: left, y: top, width: tileWidth, height: tileHeight, centerX: center.x, centerY: center.y, locked, solved, active, popScale, sparks: popping && !options.reducedMotion && popProgress < 1 };8994 ctx.save();90- ctx.globalAlpha *= alpha;95+ if (popScale !== 1) { ctx.translate(center.x, center.y); ctx.scale(popScale, popScale); ctx.translate(-center.x, -center.y); }9196 // The cat replaces the tile; only its outline remains under the glyph.9297 if (active) {9398 roundRect(ctx, left - geometry.cell * 0.055, top - geometry.cell * 0.055, tileWidth + geometry.cell * 0.11, tileHeight + geometry.cell * 0.11, geometry.cell * 0.035);
@@ -99,27 +104,39 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
99104 roundRect(ctx, left + geometry.cell * 0.04, top + geometry.cell * 0.04, tileWidth, tileHeight, geometry.cell * 0.025);100105 ctx.fillStyle = colors.lip; ctx.fill();101106 roundRect(ctx, left, top, tileWidth, tileHeight, geometry.cell * 0.025);102- ctx.fillStyle = solved ? colors.solved : colors.tile; ctx.fill();103- ctx.strokeStyle = solved ? colors.solved : colors.edge; ctx.lineWidth = Math.max(1, geometry.cell * 0.025); ctx.stroke();107+ ctx.fillStyle = solved ? colors.solved : locked ? colors.lockedTile : colors.tile; ctx.fill();108+ ctx.strokeStyle = solved ? colors.solved : locked ? colors.locked : colors.edge; ctx.lineWidth = Math.max(1, geometry.cell * 0.025); ctx.stroke();104109 ctx.font = fitFont(ctx, String(node.number).padStart(2, '0'), tileWidth * 0.75, geometry.cell * 0.22);105- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.ink; ctx.fillText(String(node.number).padStart(2, '0'), center.x, center.y);110+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = locked ? colors.locked : colors.ink; ctx.fillText(String(node.number).padStart(2, '0'), center.x, center.y);106111 }107112 ctx.restore();108-109- ctx.save(); ctx.globalAlpha *= alpha;110- ctx.textAlign = 'center'; ctx.textBaseline = 'top';113+ if (popping && !options.reducedMotion && popProgress < 1) {114+ const sparkAlpha = 1 - popProgress;115+ const radius = geometry.cell * (0.42 + popProgress * 0.3);116+ ctx.save(); ctx.globalAlpha = sparkAlpha; ctx.fillStyle = colors.solved;117+ ctx.font = `${Math.max(10, geometry.cell * 0.13)}px ${FONT}`;118+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';119+ for (let index = 0; index < 4; index++) {120+ const angle = index * Math.PI / 2 + popProgress * Math.PI * 0.5;121+ ctx.fillText('★', center.x + Math.cos(angle) * radius, center.y + Math.sin(angle) * radius);122+ }123+ ctx.restore();124+ }111125 if (locked) {112- ctx.fillStyle = colors.locked; ctx.font = fitFont(ctx, 'LOCKED', geometry.cell * 0.8, geometry.cell * 0.105, 800);126+ ctx.save();127+ ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.locked;128+ ctx.font = fitFont(ctx, 'LOCKED', geometry.cell * 0.8, geometry.cell * 0.105, 800);113129 ctx.fillText('LOCKED', center.x, top + tileHeight + geometry.cell * 0.09);114- } else if (active || options.showTitles) {115- const title = String(node.title || '').toUpperCase();116- ctx.fillStyle = active ? signal : colors.muted; ctx.font = fitFont(ctx, title, geometry.cell * 0.94, geometry.cell * 0.105, active ? 800 : 600);117- ctx.fillText(title, center.x, top + tileHeight + geometry.cell * 0.09);130+ ctx.restore();118131 }119- ctx.restore();120132 return rect;121133 }122134 135+function sentenceCase(value) {136+ const text = String(value || '').trim().toLowerCase();137+ return text ? `${text[0].toUpperCase()}${text.slice(1)}` : '';138+}139+123140 /**124141 * Draw one INDEX sheet and return geometry for hit testing and camera motion.125142 * `navigator.current.node` determines the sheet and the cat's replacement
@@ -153,14 +170,33 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
153170 154171 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.fg;155172 ctx.font = fitFont(ctx, 'INDEX', canvasWidth * 0.24, Math.max(18, cell * 0.3)); ctx.fillText('INDEX', margin, margin * 0.55);156- ctx.font = fitFont(ctx, String(sheet.packName || sheet.packId).toUpperCase(), canvasWidth * 0.45, Math.max(11, cell * 0.14), 700); ctx.fillStyle = colors.muted;157- ctx.fillText(String(sheet.packName || sheet.packId).toUpperCase(), margin, margin * 0.55 + Math.max(22, cell * 0.32));158- ctx.textAlign = 'right'; ctx.fillStyle = colors.muted; ctx.font = fitFont(ctx, `SHEET ${sheet.globalSheetIndex + 1} / ${layout.sheets.length}`, canvasWidth * 0.34, Math.max(11, cell * 0.14), 700);159- ctx.fillText(`SHEET ${sheet.globalSheetIndex + 1} / ${layout.sheets.length}`, canvasWidth - margin, margin * 0.7);173+ ctx.font = fitFont(ctx, 'walk the path, pick a proof', canvasWidth * 0.5, Math.max(11, cell * 0.14), 600); ctx.fillStyle = colors.muted;174+ ctx.fillText('walk the path, pick a proof', margin, margin * 0.55 + Math.max(22, cell * 0.32));175+ ctx.textAlign = 'right'; ctx.fillStyle = colors.muted;176+ // The built-in INDEX keeps the header quiet: its right side is only the177+ // campaign completion count. Imported sheets may identify their pack here.178+ if (sheet.packId !== 'core') {179+ ctx.font = fitFont(ctx, `${sheet.packName || sheet.packId} / SHEET ${sheet.globalSheetIndex + 1}`, canvasWidth * 0.45, Math.max(11, cell * 0.14), 700);180+ ctx.fillText(`${String(sheet.packName || sheet.packId).toUpperCase()} / SHEET ${sheet.globalSheetIndex + 1}`, canvasWidth - margin, margin * 0.7);181+ }182+ const solvedCount = sheet.nodes.filter(node => node.solved === true).length;183+ ctx.font = fitFont(ctx, `★ ${solvedCount} / ${sheet.nodes.length}`, canvasWidth * 0.25, Math.max(11, cell * 0.14), 700);184+ ctx.fillText(`★ ${solvedCount} / ${sheet.nodes.length}`, canvasWidth - margin, margin * 0.7 + (sheet.packId === 'core' ? 0 : Math.max(18, cell * 0.24)));160185 if (current && current.sheetId === sheet.id) {161- ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillStyle = current.locked ? colors.locked : colors.coral;162- ctx.font = fitFont(ctx, String(current.title || '').toUpperCase(), width * 0.8, Math.max(12, cell * 0.15), 800);163- ctx.fillText(String(current.title || '').toUpperCase(), x + width / 2, canvasHeight - margin * 0.55);186+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = current.locked ? colors.locked : colors.fg;187+ const banner = `${String(current.number).padStart(2, '0')} ${sentenceCase(current.title)}`;188+ ctx.font = fitFont(ctx, banner, width * 0.62, Math.max(12, cell * 0.15), 800);189+ ctx.fillText(banner, margin, canvasHeight - margin * 0.55);190+ const keycap = '[ENTER]';191+ const keycapWidth = Math.max(56, cell * 0.9);192+ const keycapHeight = Math.max(20, cell * 0.3);193+ const keycapX = canvasWidth - margin - keycapWidth;194+ const keycapY = canvasHeight - margin * 0.55 - keycapHeight / 2;195+ roundRect(ctx, keycapX, keycapY, keycapWidth, keycapHeight, keycapHeight * 0.18);196+ ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();197+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;198+ ctx.font = fitFont(ctx, keycap, keycapWidth * 0.88, Math.max(10, cell * 0.11), 800); ctx.fillText(keycap, keycapX + keycapWidth / 2, keycapY + keycapHeight / 2);199+ ctx.textAlign = 'right'; ctx.font = fitFont(ctx, 'PLAY', canvasWidth * 0.12, Math.max(10, cell * 0.11), 700); ctx.fillText('PLAY', keycapX - margin * 0.45, canvasHeight - margin * 0.55);164200 }165201 ctx.restore();166202 return geometry;
M tests/index-render.test.js +37 -0
@@ -8,6 +8,7 @@ function context(width = 640, height = 420) {
88 return {99 canvas: { width, height }, calls,1010 save() { calls.push(['save']); }, restore() { calls.push(['restore']); },11+ translate(...args) { calls.push(['translate', ...args]); }, scale(...args) { calls.push(['scale', ...args]); },1112 beginPath() { calls.push(['beginPath']); }, closePath() { calls.push(['closePath']); },1213 moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },1314 rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },
@@ -31,6 +32,10 @@ test('drawIndex returns adaptive non-overlapping node geometry and renders the p
3132 assert.equal(geometry.nodes.length, 6);3233 assert.ok(ctx.calls.some(call => call[0] === 'lineTo'));3334 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐱'));35+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'walk the path, pick a proof'));36+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 0 / 6'));37+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '[ENTER]'));38+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'MIAU CORE / SHEET 1'), false);3439 for (let i = 0; i < geometry.nodes.length; i++) for (let j = i + 1; j < geometry.nodes.length; j++) {3540 const a = geometry.nodes[i]; const b = geometry.nodes[j];3641 const overlap = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
@@ -38,6 +43,15 @@ test('drawIndex returns adaptive non-overlapping node geometry and renders the p
3843 }3944 });4045 46+test('built-in INDEX header keeps only the solved count on the right', () => {47+ const layout = decorateIndex(createIndexLayout(Array.from({ length: 9 }, (_, i) => level('core', `p${i + 1}`))), null);48+ const navigator = createIndexNavigator(layout);49+ const ctx = context(640, 420);50+ drawIndex(ctx, layout, navigator);51+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 0 / 9'));52+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && String(call[1]).includes('SHEET')), false);53+});54+4155 test('drawIndex signals solved and locked proofs while the cat replaces the current tile', () => {4256 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read'), level('core', 'two', 'Break'), level('core', 'three', 'Push')]), {4357 isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },
@@ -49,5 +63,28 @@ test('drawIndex signals solved and locked proofs while the cat replaces the curr
4963 assert.equal(geometry.nodes.find(node => node.id === 'core:one').solved, true);5064 assert.equal(geometry.nodes.find(node => node.id === 'core:three').locked, true);5165 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'LOCKED'));66+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Read'), false);67+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Push'), false);68+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '02 Break'));69+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 1 / 3'));5270 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐱'));5371 });72+73+test('drawIndex pops a solved node and emits stars, with a reduced motion cut', () => {74+ const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read')]), {75+ isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },76+ });77+ const navigator = createIndexNavigator(layout);78+ const animatedContext = context(600, 400);79+ const animated = drawIndex(animatedContext, layout, navigator, { popNodeId: 'core:one', popProgress: 0.5 });80+ const animatedNode = animated.nodes[0];81+ assert.ok(animatedNode.popScale < 1);82+ assert.equal(animatedNode.sparks, true);83+ assert.ok(animatedContext.calls.filter(call => call[0] === 'fillText' && call[1] === '★').length >= 4);84+85+ const reducedContext = context(600, 400);86+ const reduced = drawIndex(reducedContext, layout, navigator, { popNodeId: 'core:one', popProgress: 0, reducedMotion: true });87+ assert.equal(reduced.nodes[0].popScale, 1);88+ assert.equal(reduced.nodes[0].sparks, false);89+ assert.equal(reducedContext.calls.some(call => call[0] === 'fillText' && call[1] === '★'), false);90+});