147 lines
5.8 KiB
JavaScript
147 lines
5.8 KiB
JavaScript
// 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. Each icon's `label(dict)` says what to draw, from that
|
|
* theme's texts. Every group has to be drawn in every theme's style.
|
|
*/
|
|
export function iconGroups(ctx) {
|
|
const text = (key) => (dict) => render(dict, key);
|
|
const resource = (name) => ({ key: `res.${name}`, label: text(`theme.res.${name}`) });
|
|
|
|
return [
|
|
{
|
|
title: 'Resources',
|
|
shownIn: 'top bar (units, funds, material), Gamble, Research and Legacy screens',
|
|
icons: ['units', 'funds', 'material', 'chips', 'research', 'legacy'].map(resource),
|
|
},
|
|
{
|
|
title: 'Automation tiers',
|
|
shownIn: 'Production screen, Automation list',
|
|
icons: ctx.generators.map((g) => ({ key: `gen.${g.id}`, label: text(`theme.gen.${g.id}.name`) })),
|
|
},
|
|
{
|
|
title: 'Upgrades',
|
|
shownIn: 'Production screen, Upgrades list',
|
|
icons: ctx.upgrades.map((u) => ({ key: `upg.${u.id}`, label: text(`theme.upg.${u.id}`) })),
|
|
},
|
|
{
|
|
title: 'Endless upgrades',
|
|
shownIn: 'Production screen, Endless upgrades panel',
|
|
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}`),
|
|
})),
|
|
},
|
|
{
|
|
title: 'Investment societies',
|
|
shownIn: 'Investments screen',
|
|
icons: ctx.investments.societies.map((s) => ({ key: `inv.${s.id}`, label: text(`theme.inv.${s.id}.name`) })),
|
|
},
|
|
{
|
|
title: 'Research',
|
|
shownIn: 'Research screen',
|
|
icons: ctx.research.map((r) => ({ key: `research.${r.id}`, label: text(`research.node.${r.id}`) })),
|
|
},
|
|
{
|
|
title: 'Legacy perks',
|
|
shownIn: 'Legacy screen',
|
|
icons: ctx.legacy.map((p) => ({ key: `legacy.${p.id}`, label: text(`legacy.perk.${p.id}`) })),
|
|
},
|
|
{
|
|
title: 'Trophy',
|
|
shownIn: 'Achievements screen (one shared icon for all achievements)',
|
|
icons: [{ key: 'trophy', label: () => 'A trophy' }],
|
|
},
|
|
];
|
|
}
|
|
|
|
export function allIconKeys(ctx) {
|
|
return iconGroups(ctx).flatMap((group) => group.icons.map((icon) => icon.key));
|
|
}
|
|
|
|
/** Default file name of an icon: gen.gen1 -> img/gen-gen1.svg */
|
|
export function fileFor(key, extension = 'svg') {
|
|
return `img/${key.replaceAll('.', '-')}.${extension}`;
|
|
}
|
|
|
|
/** The plain-text work list written to ICONS.txt: one checklist per theme. */
|
|
export function renderText(ctx) {
|
|
const groups = iconGroups(ctx);
|
|
const total = groups.reduce((sum, g) => sum + g.icons.length, 0);
|
|
const all = groups.flatMap((g) => g.icons);
|
|
const keyWidth = Math.max(...all.map((icon) => icon.key.length));
|
|
const fileWidth = Math.max(...all.map((icon) => fileFor(icon.key).length));
|
|
|
|
const lines = [
|
|
'ICONS TO PRODUCE',
|
|
'================',
|
|
'',
|
|
'Generated by "npm run icons -- --write" from the game data: do not edit by hand.',
|
|
`${total} icons per theme, ${total * THEME_IDS.length} in total (${THEME_IDS.length} themes: ${THEME_IDS.join(', ')}).`,
|
|
'',
|
|
'Format One square image per icon: SVG preferred (or PNG, 128x128 px or more),',
|
|
' transparent background, readable at 28 px (top bar) and 48 px (lists).',
|
|
"Files public/themes/<theme>/img/<file>, then listed in that theme's theme.json:",
|
|
' { "assets": { "<key>": "img/<file>" } } (an icon that is not listed keeps its grey square)',
|
|
'',
|
|
'SUMMARY (icons per theme)',
|
|
...groups.map((g) => ` ${String(g.icons.length).padStart(3)} ${g.title} [${g.shownIn}]`),
|
|
` ${String(total).padStart(3)} TOTAL`,
|
|
];
|
|
|
|
for (const themeId of THEME_IDS) {
|
|
const dict = dictionaryFor(themeId);
|
|
lines.push('', '', `THEME: ${themeId}`, '-'.repeat(7 + themeId.length));
|
|
for (const group of groups) {
|
|
lines.push('', `${group.title} (${group.icons.length})`);
|
|
for (const icon of group.icons) {
|
|
const file = fileFor(icon.key).padEnd(fileWidth);
|
|
lines.push(` [ ] ${file} ${icon.key.padEnd(keyWidth)} ${icon.label(dict)}`);
|
|
}
|
|
}
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
/** 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, fileFor(key, extension)]));
|
|
}
|