// The complete list of icons a theme must provide, derived from the game data // (so it can never drift from the game). Every key is what the UI passes to // icon(key) in public/js/ui/dom.js; a theme maps it to a file in theme.json. import { readdirSync } from 'node:fs'; import { PUBLIC_DIR, readJson } from './context.js'; export const THEME_IDS = readdirSync(`${PUBLIC_DIR}themes`, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort((a, b) => readJson(`themes/${a}/theme.json`).order - readJson(`themes/${b}/theme.json`).order); function flatten(object, prefix = '', out = {}) { for (const [key, value] of Object.entries(object)) { const path = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object') flatten(value, path, out); else out[path] = String(value); } return out; } /** English texts of a theme: the core locale plus the theme's own, merged like the game does. */ function dictionaryFor(themeId) { return { ...flatten(readJson('locales/en.json')), ...flatten(readJson(`themes/${themeId}/locales/en.json`)) }; } /** Same interpolation as services/i18n.js: {param} and {@other.key}. */ function render(dict, key, params = {}, depth = 0) { const text = dict[key]; if (text === undefined) return key; return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => { if (embed) return depth < 5 ? render(dict, name, params, depth + 1) : ''; return name in params ? params[name] : match; }); } /** * Groups of icons. `themed` groups have a different subject in every theme * (so the table has one column per theme); the others have the same subject * everywhere (but still need drawing in each theme's style). * Each icon's `label(dict)` gives what to draw, from that theme's texts. */ export function iconGroups(ctx) { const text = (key) => (dict) => render(dict, key); const resource = (name) => ({ key: `res.${name}`, label: text(`theme.res.${name}`) }); return [ { id: 'res', title: 'Resources', shownIn: 'top bar (units, funds, material), Gamble, Research and Legacy screens', themed: true, icons: ['units', 'funds', 'material', 'chips', 'research', 'legacy'].map(resource), }, { id: 'gen', title: 'Automation tiers', shownIn: 'Production screen, Automation list', themed: true, icons: ctx.generators.map((g) => ({ key: `gen.${g.id}`, label: text(`theme.gen.${g.id}.name`) })), }, { id: 'upg', title: 'Upgrades', shownIn: 'Production screen, Upgrades list', themed: true, icons: ctx.upgrades.map((u) => ({ key: `upg.${u.id}`, label: text(`theme.upg.${u.id}`) })), }, { id: 'inv', title: 'Investment societies', shownIn: 'Investments screen', themed: true, icons: ctx.investments.societies.map((s) => ({ key: `inv.${s.id}`, label: text(`theme.inv.${s.id}.name`) })), }, { id: 'endless', title: 'Endless upgrades', shownIn: 'Production screen, Endless upgrades panel', themed: true, icons: ctx.endless.map((line) => ({ key: `endless.${line.id}`, label: (dict) => line.effect.target ? render(dict, 'endless.name.generator', { target: dict[`theme.gen.${line.effect.target}.name`] }) : render(dict, `endless.name.${line.id}`), })), }, { id: 'research', title: 'Research', shownIn: 'Research screen', themed: false, icons: ctx.research.map((r) => ({ key: `research.${r.id}`, label: text(`research.node.${r.id}`) })), }, { id: 'legacy', title: 'Legacy perks', shownIn: 'Legacy screen', themed: false, icons: ctx.legacy.map((p) => ({ key: `legacy.${p.id}`, label: text(`legacy.perk.${p.id}`) })), }, { id: 'ach', title: 'Achievements', shownIn: 'Achievements screen', themed: false, icons: ctx.achievements.map((a) => ({ key: `ach.${a.id}`, label: text(`ach.title.${a.id}`) })), }, ]; } export function allIconKeys(ctx) { return iconGroups(ctx).flatMap((group) => group.icons.map((icon) => icon.key)); } /** Markdown for the README: totals, then one table per group. */ export function renderMarkdown(ctx) { const groups = iconGroups(ctx); const dicts = Object.fromEntries(THEME_IDS.map((id) => [id, dictionaryFor(id)])); const perTheme = groups.reduce((sum, g) => sum + g.icons.length, 0); const themeColumns = THEME_IDS.map((id) => `\`${id}\``); const lines = [ `**${perTheme} icons per theme, ${perTheme * THEME_IDS.length} in total** (${THEME_IDS.length} themes: ${themeColumns.join(', ')}).`, '', '| Group | Icons per theme | Shown in |', '|---|---:|---|', ...groups.map((g) => `| ${g.title} | ${g.icons.length} | ${g.shownIn} |`), `| **Total** | **${perTheme}** | |`, '', ]; for (const group of groups) { lines.push(`#### ${group.title} (${group.icons.length})`, ''); if (group.themed) { lines.push( `| Key | ${themeColumns.join(' | ')} |`, `|---|${THEME_IDS.map(() => '---').join('|')}|`, ...group.icons.map( (icon) => `| \`${icon.key}\` | ${THEME_IDS.map((id) => icon.label(dicts[id])).join(' | ')} |`, ), ); } else { lines.push( "| Key | Subject (the same in every theme, drawn in each theme's style) |", '|---|---|', ...group.icons.map((icon) => `| \`${icon.key}\` | ${icon.label(dicts[THEME_IDS[0]])} |`), ); } lines.push(''); } return lines.join('\n').trimEnd(); } /** theme.json "assets" skeleton: every key mapped to its default file name. */ export function assetsSkeleton(ctx, extension = 'svg') { return Object.fromEntries(allIconKeys(ctx).map((key) => [key, `img/${key.replaceAll('.', '-')}.${extension}`])); }