clone

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

fix(index): clarify locked proofs and align sheet controls

Dami committed 2255d0b
commit
2255d0b1bfe2a6287da9b40d7266e02b7d3d225c
parent
6580a7f22de207161b61732f5acd72f7f82d28de
author
Dami <dami.etoile@gmail.com>
2 files changed, +91 -35
Msrc/render/index.js72++++++++++++++++++------------
Mtests/index-render.test.js54++++++++++++++++++++---
M src/render/index.js +44 -28
@@ -60,7 +60,7 @@ function drawSlab(ctx, geometry, colors) {
6060 ctx.fillStyle = colors.board; ctx.fill();6161 ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();6262 ctx.fillStyle = colors.dot;63- for (let row = 0; row <= 3; row++) for (let column = 0; column <= 5; column++) {63+ for (let row = 0; row <= geometry.rows; row++) for (let column = 0; column <= geometry.columns; column++) {6464 ctx.beginPath(); ctx.arc(geometry.x + column * geometry.cell, geometry.y + row * geometry.cell, Math.max(1, geometry.cell * 0.018), 0, Math.PI * 2); ctx.fill();6565 }6666 }
@@ -99,7 +99,7 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
9999 ctx.fillStyle = colors.board; ctx.fill();100100 ctx.strokeStyle = signal; ctx.lineWidth = Math.max(2, geometry.cell * 0.045); ctx.stroke();101101 ctx.font = `${Math.max(18, geometry.cell * 0.42)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;102- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(options.cat || '🐱', center.x, center.y);102+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(options.cat || '🐈', center.x, center.y);103103 } else {104104 roundRect(ctx, left + geometry.cell * 0.04, top + geometry.cell * 0.04, tileWidth, tileHeight, geometry.cell * 0.025);105105 ctx.fillStyle = colors.lip; ctx.fill();
@@ -122,13 +122,6 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
122122 }123123 ctx.restore();124124 }125- if (locked) {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);129- ctx.fillText('LOCKED', center.x, top + tileHeight + geometry.cell * 0.09);130- ctx.restore();131- }132125 return rect;133126 }134127
@@ -151,15 +144,17 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
151144 const canvasHeight = options.availableHeight ?? ctx.canvas?.height ?? 420;152145 const margin = options.margin ?? clamp(Math.min(canvasWidth, canvasHeight) * 0.08, 18, 52);153146 const hudHeight = options.hudHeight ?? clamp(canvasHeight * 0.18, 54, 96);154- const footerHeight = options.footerHeight ?? clamp(canvasHeight * 0.14, 34, 64);147+ const hideFooter = options.hideFooter === true || options.hideBanner === true;148+ const footerHeight = hideFooter ? 0 : (options.footerHeight ?? clamp(canvasHeight * 0.14, 34, 64));149+ const usedRows = Math.max(1, Math.min(3, Math.ceil(sheet.nodes.length / 5)));155150 const availableWidth = Math.max(1, canvasWidth - margin * 2);156151 const availableHeight = Math.max(1, canvasHeight - hudHeight - footerHeight - margin * 2);157- const cell = options.cell ?? clamp(Math.min(availableWidth / 5, availableHeight / 3), 28, 150);152+ const cell = options.cell ?? clamp(Math.min(availableWidth / 5, availableHeight / usedRows), 28, 150);158153 const width = cell * 5;159- const height = cell * 3;154+ const height = cell * usedRows;160155 const x = options.x ?? (canvasWidth - width) / 2;161156 const y = options.y ?? hudHeight + (availableHeight - height) / 2 + margin;162- const geometry = { x, y, cell, width, height, sheetId: sheet.id, packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [] };157+ const geometry = { x, y, cell, width, height, columns: 5, rows: usedRows, sheetId: sheet.id, packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [] };163158 const current = currentNode(navigator);164159 165160 ctx.save();
@@ -182,21 +177,42 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
182177 const solvedCount = sheet.nodes.filter(node => node.solved === true).length;183178 ctx.font = fitFont(ctx, `★ ${solvedCount} / ${sheet.nodes.length}`, canvasWidth * 0.25, Math.max(11, cell * 0.14), 700);184179 ctx.fillText(`★ ${solvedCount} / ${sheet.nodes.length}`, canvasWidth - margin, margin * 0.7 + (sheet.packId === 'core' ? 0 : Math.max(18, cell * 0.24)));185- if (current && current.sheetId === sheet.id) {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);180+ const lockedNode = options.lockedNode?.locked ? options.lockedNode : null;181+ const bannerNode = lockedNode || current;182+ if (bannerNode && bannerNode.sheetId === sheet.id && !hideFooter) {183+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = bannerNode.locked ? colors.locked : colors.fg;184+ const predecessor = bannerNode.locked185+ ? layout.nodes.find(node => node.id === bannerNode.previousId)186+ : null;187+ const banner = bannerNode.locked188+ ? `Locked. Prove ${String(predecessor?.number || Math.max(1, bannerNode.number - 1)).padStart(2, '0')} first.`189+ : `${String(bannerNode.number).padStart(2, '0')} ${sentenceCase(bannerNode.title)}`;190+ const footerY = canvasHeight - margin * 0.55;191+ const narrowFooter = canvasWidth < 560;192+ const keyHeight = Math.max(20, cell * 0.3);193+ const groups = [194+ { key: 'ENTER', label: 'play', width: Math.max(56, cell * 0.9) },195+ ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', width: Math.max(42, cell * 0.65) }] : []),196+ ];197+ const groupGap = Math.max(8, cell * 0.12);198+ const labelGap = Math.max(5, cell * 0.08);199+ const labelWidth = Math.max(26, cell * 0.42);200+ const keyRowWidth = groups.reduce((total, group) => total + group.width + labelGap + labelWidth, 0) + groupGap * Math.max(0, groups.length - 1);201+ const keyStart = canvasWidth - margin - keyRowWidth;202+ const bannerWidth = Math.max(84, keyStart - margin * 1.2);203+ ctx.font = fitFont(ctx, banner, bannerWidth, Math.max(12, cell * 0.15), 800);204+ ctx.fillText(banner, margin, narrowFooter ? footerY - keyHeight * 1.15 : footerY);205+ if (bannerNode.locked) { ctx.restore(); return geometry; }206+ let keyX = keyStart;207+ for (const group of groups) {208+ const keycapY = footerY - keyHeight / 2;209+ roundRect(ctx, keyX, keycapY, group.width, keyHeight, keyHeight * 0.18);210+ ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();211+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;212+ ctx.font = fitFont(ctx, group.key, group.width * 0.88, Math.max(10, cell * 0.11), 800); ctx.fillText(group.key, keyX + group.width / 2, keycapY + keyHeight / 2);213+ ctx.textAlign = 'left'; ctx.font = fitFont(ctx, group.label, labelWidth, Math.max(10, cell * 0.11), 700); ctx.fillText(group.label, keyX + group.width + labelGap, keycapY + keyHeight / 2);214+ keyX += group.width + labelGap + labelWidth + groupGap;215+ }200216 }201217 ctx.restore();202218 return geometry;
M tests/index-render.test.js +47 -7
@@ -31,11 +31,20 @@ test('drawIndex returns adaptive non-overlapping node geometry and renders the p
3131 assert.equal(geometry.sheetId, 'core:sheet-1');3232 assert.equal(geometry.nodes.length, 6);3333 assert.ok(ctx.calls.some(call => call[0] === 'lineTo'));34- assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐱'));34+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));3535 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'walk the path, pick a proof'));3636 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]'));37+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'ENTER'));38+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'play'));39+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'ESC'));40+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'menu'));41+ const footerLabels = ctx.calls.filter(call => call[0] === 'fillText' && ['ENTER', 'play', 'ESC', 'menu'].includes(call[1]));42+ assert.deepEqual(footerLabels.map(call => call[1]), ['ENTER', 'play', 'ESC', 'menu']);43+ assert.ok(footerLabels[0][2] < footerLabels[1][2]);44+ assert.ok(footerLabels[1][2] < footerLabels[2][2]);45+ assert.ok(footerLabels[2][2] < footerLabels[3][2]);3846 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'MIAU CORE / SHEET 1'), false);47+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'LOCKED'), false);3948 for (let i = 0; i < geometry.nodes.length; i++) for (let j = i + 1; j < geometry.nodes.length; j++) {4049 const a = geometry.nodes[i]; const b = geometry.nodes[j];4150 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;
@@ -56,18 +65,49 @@ test('drawIndex signals solved and locked proofs while the cat replaces the curr
5665 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read'), level('core', 'two', 'Break'), level('core', 'three', 'Push')]), {5766 isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },5867 });59- const navigator = createIndexNavigator(layout, { startNodeId: 'core:two' });68+ const navigator = createIndexNavigator(layout, { startNodeId: 'core:three' });6069 const ctx = context(600, 400);6170 const geometry = drawIndex(ctx, layout, navigator, { showTitles: true });62- assert.equal(geometry.nodes.find(node => node.id === 'core:two').active, true);71+ assert.equal(geometry.nodes.find(node => node.id === 'core:three').active, true);6372 assert.equal(geometry.nodes.find(node => node.id === 'core:one').solved, true);6473 assert.equal(geometry.nodes.find(node => node.id === 'core:three').locked, true);65- assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'LOCKED'));74+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Locked. Prove 02 first.'));6675 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Read'), false);6776 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'));77+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '02 Break'), false);6978 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '★ 1 / 3'));70- assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐱'));79+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));80+});81+82+test('drawIndex centers a nine-proof sheet in the two rows it uses', () => {83+ const layout = decorateIndex(createIndexLayout(Array.from({ length: 9 }, (_, i) => level('core', `p${i + 1}`))), null);84+ const navigator = createIndexNavigator(layout);85+ const ctx = context(640, 420);86+ const geometry = drawIndex(ctx, layout, navigator);87+ assert.equal(geometry.rows, 2);88+ assert.equal(geometry.height, geometry.cell * 2);89+ assert.ok(geometry.y > 96);90+});91+92+test('drawIndex accepts a transient locked attempt from navigator.move', () => {93+ const layout = decorateIndex(createIndexLayout([level('core', 'one'), level('core', 'two'), level('core', 'three')]), {94+ isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },95+ });96+ const navigator = createIndexNavigator(layout, { startNodeId: 'core:two' });97+ const attempt = navigator.move('next');98+ assert.equal(attempt.locked, true);99+ const ctx = context(600, 400);100+ drawIndex(ctx, layout, navigator, { lockedNode: attempt.node });101+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Locked. Prove 02 first.'));102+});103+104+test('drawIndex can hide the banner and footer for pause overlays', () => {105+ const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);106+ const navigator = createIndexNavigator(layout);107+ const ctx = context(600, 400);108+ drawIndex(ctx, layout, navigator, { hideFooter: true });109+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'ENTER'), false);110+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '01 One'), false);71111 });72112 73113 test('drawIndex pops a solved node and emits stars, with a reduced motion cut', () => {