- commit
- 68af59a011c7e7043098c09ade54da703f853488
- parent
- bf34e5eeb51f533c5dc59aee127ce89aafca0a08
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 104 | +++++++++++++++++++++++++----- |
| M | tests/ | 32 | +++++++++ |
@@ -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) {@@ -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'; },