feat: First iteration, multi themes, I18N

This commit is contained in:
gauvainboiche
2026-10-08 11:58:18 +02:00
parent 9240fa3c3c
commit aa9721ac2a
53 changed files with 3363 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
export const $ = (selector, root = document) => root.querySelector(selector);
/** Create an element: h('button', { class: 'btn', text: 'Go', onclick }, ...children). */
export function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value == null || value === false) continue;
if (key === 'class') el.className = value;
else if (key === 'text') el.textContent = value;
else if (key === 'dataset') Object.assign(el.dataset, value);
else if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), value);
else el.setAttribute(key, value === true ? '' : value);
}
el.append(...children.flat().filter((c) => c != null && c !== false));
return el;
}
// ---------------------------------------------------------------------- icons
// Every icon is a grey square for now. When a theme provides images, list them
// in its theme.json ("assets": { "gen.gen1": "img/autoclipper.svg", ... }) and
// they replace the placeholders, with no change to the UI code.
let assetBase = '';
let assets = {};
export function setThemeAssets(themeId, themeAssets) {
assetBase = `themes/${themeId}/`;
assets = themeAssets ?? {};
}
/** key examples: "res.units", "gen.gen1", "upg.click_1" */
export function icon(key) {
const path = assets[key];
if (path) return h('img', { class: 'icon', src: assetBase + path, alt: '', loading: 'lazy' });
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } });
}
/** Re-resolve the icons that live in index.html (resource tiles). */
export function refreshStaticIcons(root = document) {
for (const el of root.querySelectorAll('[data-icon]')) {
if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js
el.replaceWith(icon(el.dataset.icon));
}
}
+71
View File
@@ -0,0 +1,71 @@
// "Save & settings" dialog: export (file or text), import (file or pasted text), reset.
import { downloadText, ImportError, readFileText } from '../services/saveio.js';
import { t } from '../services/i18n.js';
import { $ } from './dom.js';
import { showToast } from './toast.js';
/**
* @param hooks.getExport () => { text, name } current save as text + file base name
* @param hooks.importText (text) => void throws ImportError on bad input
* @param hooks.reset () => void
*/
export function createSettings(hooks) {
const dialog = $('#settings');
const exportBox = $('#export-text');
const importBox = $('#import-text');
const fileInput = $('#import-file');
function open() {
exportBox.value = hooks.getExport().text;
importBox.value = '';
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
function download(extension, mime) {
const { text, name } = hooks.getExport();
downloadText(`${name}.${extension}`, text, mime);
}
async function copy() {
exportBox.value = hooks.getExport().text;
try {
await navigator.clipboard.writeText(exportBox.value);
} catch {
exportBox.select();
if (!document.execCommand?.('copy')) return showToast(t('toast.copyFailed'), { error: true });
}
showToast(t('toast.copied'));
}
async function runImport(text) {
try {
await hooks.importText(text);
dialog.close?.();
showToast(t('toast.imported'));
} catch (error) {
const code = error instanceof ImportError ? error.code : 'invalid';
showToast(t(`error.import.${code}`), { error: true });
}
}
$('#open-settings').addEventListener('click', open);
$('#export-json').addEventListener('click', () => download('json', 'application/json'));
$('#export-txt').addEventListener('click', () => download('txt', 'text/plain'));
$('#export-copy').addEventListener('click', copy);
$('#import-paste').addEventListener('click', () => runImport(importBox.value));
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
fileInput.value = ''; // allow picking the same file again
if (file) await runImport(await readFileText(file));
});
$('#reset-game').addEventListener('click', () => {
if (!confirm(t('settings.resetConfirm'))) return;
hooks.reset();
dialog.close?.();
showToast(t('toast.reset'));
});
return { open };
}
+9
View File
@@ -0,0 +1,9 @@
import { $, h } from './dom.js';
const DURATION_MS = 6000;
export function showToast(message, { error = false } = {}) {
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
$('#toasts').append(toast);
setTimeout(() => toast.remove(), DURATION_MS);
}
+231
View File
@@ -0,0 +1,231 @@
// Main game view. Static markup lives in index.html; this module binds the
// controls once, builds the generator/upgrade lists, and refreshes the numbers.
import { generatorQuote, isUnlocked, upgradeVisible } from '../engine/engine.js';
import { t } from '../services/i18n.js';
import { $, h, icon, refreshStaticIcons } from './dom.js';
const SLIDER_STEPS = 1000;
function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
}
/**
* @param actions callbacks into the game: make, buyMaterial, buyMarketing,
* buyGenerator(id, amount), buyUpgrade(id), setPrice(value)
*/
export function createView(actions) {
const el = {
units: $('#v-units'),
funds: $('#v-funds'),
material: $('#v-material'),
total: $('#v-total'),
rate: $('#v-rate'),
demand: $('#v-demand'),
income: $('#v-income'),
make: $('#make'),
makeYield: $('#make-yield'),
buyMaterial: $('#buy-material'),
buyMaterialInfo: $('#buy-material-info'),
buyMarketing: $('#buy-marketing'),
buyMarketingInfo: $('#buy-marketing-info'),
priceInput: $('#price-input'),
priceSlider: $('#price-slider'),
priceHint: $('#price-hint'),
generators: $('#generators'),
upgrades: $('#upgrades'),
upgradeCount: $('#upgrade-count'),
upgradesEmpty: $('#upgrades-empty'),
};
let ctx;
let fmt;
let buyAmount = 1;
let generatorRows = new Map();
let upgradeRows = new Map();
let upgradeSignature = null;
const money = (n) => t('theme.fmt.funds', { value: fmt.money(n) });
const perSecond = (n) => t('ui.perSecond', { value: fmt.number(n) });
// ---------------------------------------------------------------- controls
el.make.addEventListener('click', () => actions.make());
el.buyMaterial.addEventListener('click', () => actions.buyMaterial());
el.buyMarketing.addEventListener('click', () => actions.buyMarketing());
const priceFromSlider = () => {
const { minPrice, maxPrice } = ctx.rules.market;
return minPrice * (maxPrice / minPrice) ** (Number(el.priceSlider.value) / SLIDER_STEPS);
};
const sliderFromPrice = (price) => {
const { minPrice, maxPrice } = ctx.rules.market;
return (SLIDER_STEPS * Math.log(price / minPrice)) / Math.log(maxPrice / minPrice);
};
el.priceSlider.addEventListener('input', () => actions.setPrice(priceFromSlider()));
el.priceInput.addEventListener('change', () => actions.setPrice(Number(el.priceInput.value)));
$('#price-down').addEventListener('click', () => actions.nudgePrice(-ctx.rules.market.priceStep));
$('#price-up').addEventListener('click', () => actions.nudgePrice(ctx.rules.market.priceStep));
$('#buy-amount').addEventListener('change', (event) => {
const value = event.target.value;
buyAmount = value === 'max' ? 'max' : Number(value);
});
// ------------------------------------------------------------------- lists
function effectText(effect) {
const params = { value: fmt.number(effect.value) };
if (effect.target) params.target = t(`theme.gen.${effect.target}.name`);
if (effect.type === 'batch_discount') params.percent = fmt.number(Math.round((1 - effect.value) * 100));
return t(`effect.${effect.type}`, params);
}
function generatorRow(g) {
const owned = h('span', { class: 'badge' });
const rate = h('span');
const amount = h('span', { class: 'btn__label' });
const cost = h('small');
const buy = h(
'button',
{ class: 'btn btn--buy', type: 'button', onclick: () => actions.buyGenerator(g.id, buyAmount) },
amount,
cost,
);
const root = h(
'li',
{ class: 'card', hidden: true },
icon(`gen.${g.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title' }, h('span', { text: t(`theme.gen.${g.id}.name`) }), owned),
h('p', { class: 'card__desc', text: t(`theme.gen.${g.id}.desc`) }),
h('p', { class: 'card__meta' }, rate),
),
buy,
);
el.generators.append(root);
return { root, owned, rate, amount, cost, buy };
}
function upgradeRow(u) {
const cost = h('small');
const root = h(
'li',
{ class: 'card' },
icon(`upg.${u.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title', text: t(`theme.upg.${u.id}`) }),
h('p', { class: 'card__desc', text: effectText(u.effect) }),
),
h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyUpgrade(u.id) }, cost),
);
return { root, cost, buy: root.lastElementChild, upgrade: u };
}
// ------------------------------------------------------------------ public
/** Call after the theme, language or game data changed. */
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
el.generators.replaceChildren();
el.upgrades.replaceChildren();
generatorRows = new Map(ctx.generators.map((g) => [g.id, generatorRow(g)]));
upgradeRows = new Map();
upgradeSignature = null;
el.priceInput.min = ctx.rules.market.minPrice;
el.priceInput.max = ctx.rules.market.maxPrice;
el.priceInput.step = ctx.rules.market.priceStep;
refreshStaticIcons();
}
function updateGenerators(state, d) {
for (const g of ctx.generators) {
const row = generatorRows.get(g.id);
const owned = state.generators[g.id];
const visible = owned > 0 || isUnlocked(state, g.unlock);
row.root.hidden = !visible;
if (!visible) continue;
let quote = generatorQuote(state, ctx, g.id, buyAmount);
if (quote.count <= 0) quote = generatorQuote(state, ctx, g.id, 1);
setText(row.owned, t('ui.owned', { count: fmt.number(owned) }));
setText(row.rate, t('ui.rateEach', { rate: fmt.number(d.perGenerator[g.id]) }));
setText(row.amount, t('ui.buyN', { count: fmt.number(quote.count) }));
setText(row.cost, money(quote.cost));
row.buy.disabled = state.funds < quote.cost;
}
}
function updateUpgrades(state) {
const visible = ctx.upgrades.filter((u) => upgradeVisible(state, u)).sort((a, b) => a.cost - b.cost);
const signature = visible.map((u) => u.id).join();
if (signature !== upgradeSignature) {
upgradeSignature = signature;
upgradeRows = new Map(visible.map((u) => [u.id, upgradeRow(u)]));
el.upgrades.replaceChildren(...[...upgradeRows.values()].map((row) => row.root));
el.upgradesEmpty.hidden = visible.length > 0;
}
for (const row of upgradeRows.values()) {
setText(row.cost, money(row.upgrade.cost));
row.buy.disabled = state.funds < row.upgrade.cost;
}
setText(
el.upgradeCount,
t('ui.upgradeCount', { owned: state.upgrades.length, total: ctx.upgrades.length }),
);
}
function updateMarket(state, d) {
const { fairPrice, minPrice } = ctx.rules.market;
if (document.activeElement !== el.priceInput) el.priceInput.value = state.price.toFixed(2);
if (document.activeElement !== el.priceSlider) {
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
}
const hintKey = state.price > fairPrice ? 'ui.hintHigh' : 'ui.hintFair';
setText(el.priceHint, t(hintKey, { fair: money(fairPrice), demand: fmt.number(d.demand) }));
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
setText(
el.buyMarketingInfo,
t('ui.marketingInfo', {
level: state.marketing + 1,
multiplier: fmt.number(nextLevelMultiplier),
cost: money(d.marketingCost),
}),
);
el.buyMarketing.disabled = state.funds < d.marketingCost;
}
/** @param flow { income } smoothed funds per second */
function update(state, d, flow) {
setText(el.units, fmt.number(Math.floor(state.units)));
setText(el.funds, money(state.funds));
setText(el.material, fmt.number(Math.floor(state.material)));
setText(el.total, fmt.number(Math.floor(state.totalProduced)));
setText(el.rate, perSecond(d.rate));
setText(el.demand, perSecond(d.demand));
setText(el.income, t('ui.perSecond', { value: money(flow.income) }));
setText(el.makeYield, t('ui.makeYield', { amount: fmt.number(d.clickYield) }));
el.make.disabled = state.material < d.materialPerUnit;
setText(
el.buyMaterialInfo,
t('ui.batchInfo', { amount: fmt.number(d.batchSize), cost: money(d.batchCost) }),
);
el.buyMaterial.disabled = state.funds < d.batchCost;
updateMarket(state, d);
updateGenerators(state, d);
updateUpgrades(state);
}
return { rebuild, update };
}