clone

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

Render world identity and progress on INDEX

Dami committed 68af59a
commit
68af59a011c7e7043098c09ade54da703f853488
parent
bf34e5eeb51f533c5dc59aee127ce89aafca0a08
author
Dami <dami.etoile@gmail.com>
2 files changed, +118 -18
Msrc/render/index.js104+++++++++++++++++++++++++-----
Mtests/index-render.test.js32+++++++++
M src/render/index.js +86 -18
@@ -20,6 +20,10 @@ const FONT = "'Recursive', ui-monospace, monospace";
2020 const INDEX_KEYCAPS = Object.freeze({ ENTER: 42, ESC: 30, SPACE: 44 });2121 const INDEX_KEYCAP_HEIGHT = 22;2222 const INDEX_POINTER_TARGET = 44;23+const WORLD_ACCENT_COLORS = Object.freeze({24+ you: '#FF4B3E', push: '#FFB020', stop: '#8B91FF', win: '#2EE6A8',25+ sink: '#3AB8FF', defeat: '#FF4FA3', tick: '#C6F432',26+});2327 2428 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }2529 function easeOut(value) { return 1 - ((1 - value) ** 3); }
@@ -52,6 +56,67 @@ function currentSheet(layout, navigator, options) {
5256 return layout.sheets.find(sheet => sheet.id === requested) || layout.sheets[options.sheetIndex || 0] || layout.sheets[0] || null;5357 }5458 59+function currentWorld(layout, navigator, sheet) {60+ const current = navigator?.current;61+ const worldId = current?.worldId || current?.node?.worldId || sheet?.worldId;62+ return layout.worlds?.find(world => world.id === worldId) || layout.worlds?.[0] || null;63+}64+65+function worldAccent(world, colors) {66+ const token = String(world?.accent || '').toLowerCase();67+ return WORLD_ACCENT_COLORS[token] || colors.fg;68+}69+70+function drawWorldHeader(ctx, world, worlds, geometry, colors, options) {71+ if (!world) return null;72+ const margin = geometry.margin;73+ const top = geometry.headerTop;74+ const worldNumber = String(world.number || 1).padStart(2, '0');75+ const worldCount = worlds.length;76+ const completed = Number.isFinite(world.completedCount) ? world.completedCount : 0;77+ const total = Number.isFinite(world.levelCount) ? world.levelCount : 0;78+ const name = `${world.glyph ? `${world.glyph} ` : ''}${world.name || world.id || 'World'}`;79+ const leftWidth = Math.max(120, geometry.canvasWidth * 0.52);80+ ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;81+ ctx.font = fitFont(ctx, `WORLD ${worldNumber}`, leftWidth, Math.max(10, geometry.cell * 0.13), 800);82+ ctx.fillText(`WORLD ${worldNumber}`, margin, top + Math.max(28, geometry.cell * 0.25));83+ ctx.fillStyle = colors.fg;84+ const nameFit = fitSceneTitle(ctx, name, { maxWidth: leftWidth, maxSize: Math.max(16, geometry.cell * 0.2), minSize: 12, maxLines: 1 });85+ ctx.font = `800 ${nameFit.size}px ${FONT}`;86+ ctx.fillText(nameFit.lines[0], margin, top + Math.max(43, geometry.cell * 0.4));87+ ctx.fillStyle = colors.muted;88+ ctx.font = fitFont(ctx, 'walk the path, pick a proof', leftWidth, Math.max(10, geometry.cell * 0.14), 600);89+ ctx.fillText('walk the path, pick a proof', margin, top + Math.max(64, geometry.cell * 0.61));90+91+ const stripGap = Math.max(5, geometry.cell * 0.06);92+ const dotSize = Math.max(3, Math.min(5, geometry.cell * 0.045));93+ const arrowWidth = Math.max(12, geometry.cell * 0.15);94+ const stripWidth = Math.min(geometry.canvasWidth * 0.43, arrowWidth * 2 + stripGap * (worldCount + 1) + dotSize * worldCount);95+ const stripX = geometry.canvasWidth - margin - stripWidth;96+ const stripY = top + Math.max(35, geometry.cell * 0.32);97+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.muted;98+ ctx.font = `800 ${Math.max(12, geometry.cell * 0.13)}px ${FONT}`;99+ ctx.fillText('◀', stripX + arrowWidth / 2, stripY);100+ let dotX = stripX + arrowWidth + stripGap;101+ const dots = [];102+ for (let index = 0; index < worldCount; index += 1) {103+ const active = index === world.number - 1;104+ ctx.beginPath(); ctx.arc(dotX + dotSize / 2, stripY, dotSize * (active ? 1.25 : 0.75), 0, Math.PI * 2);105+ ctx.fillStyle = active ? worldAccent(world, colors) : colors.dot; ctx.fill();106+ dots.push({ index, x: dotX, y: stripY - dotSize, width: dotSize, height: dotSize * 2, active });107+ dotX += dotSize + stripGap;108+ }109+ ctx.fillStyle = colors.muted; ctx.fillText('▶', geometry.canvasWidth - margin - arrowWidth / 2, stripY);110+ ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;111+ ctx.font = fitFont(ctx, `★ ${completed} / ${total}`, geometry.canvasWidth * 0.25, Math.max(11, geometry.cell * 0.14), 700);112+ ctx.fillText(`★ ${completed} / ${total}`, geometry.canvasWidth - margin, top);113+ return {114+ x: stripX, y: stripY - Math.max(12, geometry.cell * 0.16), width: stripWidth,115+ height: Math.max(24, geometry.cell * 0.3), dots, worldAccent: worldAccent(world, colors),116+ worldNumber: world.number, worldCount, worldName: world.name || world.id || 'World',117+ };118+}119+55120 function nodeCenter(sheet, node, geometry) {56121 return {57122 x: geometry.x + (node.column + 0.5) * geometry.cell,
@@ -75,7 +140,7 @@ function drawSlab(ctx, geometry, colors) {
75140 function drawPath(ctx, sheet, geometry, colors) {76141 const byId = new Map(sheet.nodes.map(node => [node.id, node]));77142 ctx.save();78- ctx.strokeStyle = colors.dot; ctx.lineWidth = Math.max(1, geometry.cell * 0.045); ctx.lineCap = 'round';143+ ctx.strokeStyle = colors.worldAccent || colors.dot; ctx.lineWidth = Math.max(1, geometry.cell * 0.045); ctx.lineCap = 'round';79144 for (const node of sheet.nodes) {80145 const next = byId.get(node.nextId);81146 if (!next || next.sheetId !== sheet.id) continue;
@@ -93,7 +158,7 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
93158 const locked = node.locked === true;94159 const solved = node.solved === true;95160 const active = current?.id === node.id;96- const signal = solved ? colors.solved : colors.coral;161+ const signal = colors.worldAccent || (solved ? colors.solved : colors.coral);97162 const popping = options.popNodeId === node.id;98163 const popProgress = popping ? clamp(Number.isFinite(options.popProgress) ? options.popProgress : 1, 0, 1) : 1;99164 const popScale = popping && !options.reducedMotion ? 0.76 + 0.24 * easeOut(popProgress) : 1;
@@ -175,29 +240,32 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
175240 const height = cell * usedRows;176241 const x = options.x ?? (canvasWidth - width) / 2;177242 const y = options.y ?? hudHeight + (availableHeight - height) / 2 + margin;178- const geometry = { x, y, cell, width, height, columns: 5, rows: usedRows, sheetId: sheet.id, packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [], footerActions: {} };243+ const world = currentWorld(layout, navigator, sheet);244+ const renderColors = {245+ ...colors,246+ worldAccent: world?.accent ? worldAccent(world, colors) : null,247+ };248+ const geometry = {249+ x, y, cell, width, height, columns: 5, rows: usedRows, sheetId: sheet.id,250+ packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [], footerActions: {},251+ margin, canvasWidth, canvasHeight, headerTop: margin * 0.55,252+ };179253 const current = currentNode(navigator);180254 181255 ctx.save();182256 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, canvasWidth, canvasHeight);183- drawSlab(ctx, geometry, colors);184- drawPath(ctx, sheet, geometry, colors);185- for (const node of sheet.nodes) geometry.nodes.push(drawNode(ctx, node, nodeCenter(sheet, node, geometry), geometry, current, colors, options));257+ drawSlab(ctx, geometry, renderColors);258+ drawPath(ctx, sheet, geometry, renderColors);259+ for (const node of sheet.nodes) geometry.nodes.push(drawNode(ctx, node, nodeCenter(sheet, node, geometry), geometry, current, renderColors, options));186260 187261 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.fg;188262 ctx.font = fitFont(ctx, 'INDEX', canvasWidth * 0.24, Math.max(18, cell * 0.3)); ctx.fillText('INDEX', margin, margin * 0.55);189- ctx.font = fitFont(ctx, 'walk the path, pick a proof', canvasWidth * 0.5, Math.max(11, cell * 0.14), 600); ctx.fillStyle = colors.muted;190- ctx.fillText('walk the path, pick a proof', margin, margin * 0.55 + Math.max(22, cell * 0.32));191- ctx.textAlign = 'right'; ctx.fillStyle = colors.muted;192- // The built-in INDEX keeps the header quiet: its right side is only the193- // campaign completion count. Imported sheets may identify their pack here.194- if (sheet.packId !== 'core') {195- ctx.font = fitFont(ctx, `${sheet.packName || sheet.packId} / SHEET ${sheet.globalSheetIndex + 1}`, canvasWidth * 0.45, Math.max(11, cell * 0.14), 700);196- ctx.fillText(`${String(sheet.packName || sheet.packId).toUpperCase()} / SHEET ${sheet.globalSheetIndex + 1}`, canvasWidth - margin, margin * 0.7);197- }198- const solvedCount = sheet.nodes.filter(node => node.solved === true).length;199- ctx.font = fitFont(ctx, `★ ${solvedCount} / ${sheet.nodes.length}`, canvasWidth * 0.25, Math.max(11, cell * 0.14), 700);200- ctx.fillText(`★ ${solvedCount} / ${sheet.nodes.length}`, canvasWidth - margin, margin * 0.7 + (sheet.packId === 'core' ? 0 : Math.max(18, cell * 0.24)));263+ geometry.world = world ? {264+ number: world.number, count: layout.worlds?.length || 1, name: world.name || world.id,265+ glyph: world.glyph || null, tagline: world.tagline || null, accent: world.accent || null,266+ completedCount: world.completedCount || 0, levelCount: world.levelCount || 0,267+ } : null;268+ geometry.worldStrip = drawWorldHeader(ctx, world, layout.worlds || [], geometry, colors, options);201269 const lockedNode = options.lockedNode?.locked ? options.lockedNode : null;202270 const bannerNode = lockedNode || current;203271 if (bannerNode && bannerNode.sheetId === sheet.id && !hideFooter) {
M tests/index-render.test.js +32 -0
@@ -61,6 +61,38 @@ test('built-in INDEX header keeps only the solved count on the right', () => {
6161 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && String(call[1]).includes('SHEET')), false);6262 });6363 64+test('drawIndex renders the active world header and strip on mobile', () => {65+ const base = decorateIndex(createIndexLayout(Array.from({ length: 9 }, (_, i) => level('core', `p${i + 1}`))), null);66+ const worlds = [67+ { ...base.worlds[0], number: 1, name: 'Miau Core', levelCount: 9, completedCount: 2 },68+ { ...base.worlds[0], id: 'tidewater', number: 2, name: 'Tidewater', glyph: '🌊', accent: 'sink', levelCount: 9, completedCount: 3 },69+ { ...base.worlds[0], id: 'clockwork', number: 3, name: 'Clockwork', levelCount: 9, completedCount: 0 },70+ ];71+ const layout = { ...base, worlds };72+ const navigator = createIndexNavigator(layout);73+ const ctx = context(390, 844);74+ const geometry = drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 844 });75+ const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);76+ assert.ok(labels.includes('WORLD 01'));77+ assert.ok(labels.includes('🌊 Tidewater') === false, 'inactive world names stay out of the active header');78+ assert.ok(labels.includes('★ 2 / 9'));79+ assert.ok(labels.includes('◀'));80+ assert.ok(labels.includes('▶'));81+ assert.equal(geometry.world.number, 1);82+ assert.equal(geometry.worldStrip.dots.length, 3);83+ assert.ok(geometry.worldStrip.worldAccent === '#F2EEE6');84+});85+86+test('drawIndex uses world accent for the active cat frame and path', () => {87+ const base = decorateIndex(createIndexLayout([level('core', 'one')]), null);88+ const worlds = [{ ...base.worlds[0], name: 'Tidewater', number: 1, accent: 'sink', completedCount: 0, levelCount: 1 }];89+ const layout = { ...base, worlds };90+ const navigator = createIndexNavigator(layout);91+ const geometry = drawIndex(context(640, 420), layout, navigator);92+ assert.equal(geometry.world.accent, 'sink');93+ assert.equal(geometry.worldStrip.worldAccent, '#3AB8FF');94+});95+6496 test('drawIndex signals solved and locked proofs while the cat replaces the current tile', () => {6597 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read'), level('core', 'two', 'Break'), level('core', 'three', 'Push')]), {6698 isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },