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
+27
View File
@@ -0,0 +1,27 @@
import { EFFECT_TYPES } from './effects.js';
/**
* Bundle the static game data (data/*.json) with lookup tables and validate it,
* so a typo in a JSON file fails loudly at startup instead of mid-game.
*/
export function buildContext({ rules, generators, upgrades }) {
const generatorsById = new Map(generators.map((g) => [g.id, g]));
const upgradesById = new Map(upgrades.map((u) => [u.id, u]));
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
for (const u of upgrades) {
if (!EFFECT_TYPES.includes(u.effect?.type)) {
throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`);
}
if (u.effect.target && !generatorsById.has(u.effect.target)) {
throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`);
}
if (u.requires && !upgradesById.has(u.requires)) {
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
}
}
return { rules, generators, upgrades, generatorsById, upgradesById };
}
+38
View File
@@ -0,0 +1,38 @@
// Upgrade effects. To add a new effect type: register it here, add its
// `effect.<type>` string to the core locale files, and use it in upgrades.json.
export function newModifiers() {
return {
click: 1,
generators: {},
global: 1,
demand: 1,
value: 1,
materialEfficiency: 1,
batchDiscount: 1,
autoBuyMaterial: false,
};
}
const APPLY = {
click_mult: (m, e) => void (m.click *= e.value),
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value),
global_mult: (m, e) => void (m.global *= e.value),
demand_mult: (m, e) => void (m.demand *= e.value),
value_mult: (m, e) => void (m.value *= e.value),
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
batch_discount: (m, e) => void (m.batchDiscount *= e.value),
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
};
export const EFFECT_TYPES = Object.keys(APPLY);
/** Fold every owned upgrade into a single set of multipliers. */
export function computeModifiers(ownedIds, upgradesById) {
const m = newModifiers();
for (const id of ownedIds) {
const effect = upgradesById.get(id)?.effect;
if (effect) APPLY[effect.type]?.(m, effect);
}
return m;
}
+179
View File
@@ -0,0 +1,179 @@
// Game rules. Every function takes the mutable `state` and the static `ctx`
// (see context.js) and never touches the DOM, so it runs the same in the
// browser and in Node (tests, balance simulations).
import { computeModifiers } from './effects.js';
import { bulkCost, clamp, demandPerSecond, maxAffordable } from './formulas.js';
const MAX_STEP_SECONDS = 1;
// ---------------------------------------------------------------- conditions
export function isUnlocked(state, condition = {}) {
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false;
if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) {
return false;
}
return true;
}
export function upgradeVisible(state, upgrade) {
if (state.upgrades.includes(upgrade.id)) return false;
if (upgrade.requires && !state.upgrades.includes(upgrade.requires)) return false;
return isUnlocked(state, upgrade.unlock);
}
// ------------------------------------------------------------------- derived
/** Everything computed from the state (rates, multipliers, current prices). */
export function derive(state, ctx) {
const { rules } = ctx;
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
const perGenerator = {};
let rate = 0;
for (const g of ctx.generators) {
perGenerator[g.id] = g.baseRate * (mods.generators[g.id] ?? 1) * mods.global;
rate += perGenerator[g.id] * (state.generators[g.id] ?? 0);
}
const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing;
return {
clickYield: rules.click.yield * mods.click,
perGenerator,
rate,
demandMultiplier,
demand: demandPerSecond(rules.market, state.price, demandMultiplier),
valueMultiplier: mods.value,
materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency,
batchSize: rules.materialMarket.batchSize,
batchCost: Math.ceil(rules.materialMarket.basePrice * state.materialFactor * mods.batchDiscount),
autoBuyMaterial: mods.autoBuyMaterial,
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing),
};
}
// ------------------------------------------------------------------- actions
// Each returns true when the action was performed.
export function click(state, ctx) {
const d = derive(state, ctx);
if (state.material < d.materialPerUnit) return false;
const made = Math.min(d.clickYield, state.material / d.materialPerUnit);
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
return true;
}
export function buyMaterial(state, ctx) {
const d = derive(state, ctx);
if (state.funds < d.batchCost) return false;
state.funds -= d.batchCost;
state.material += d.batchSize;
return true;
}
export function buyMarketing(state, ctx) {
const d = derive(state, ctx);
if (state.funds < d.marketingCost) return false;
state.funds -= d.marketingCost;
state.marketing += 1;
return true;
}
/** How many generators the player gets for `amount` ("max" or a number). */
export function generatorQuote(state, ctx, id, amount) {
const g = ctx.generatorsById.get(id);
const owned = state.generators[id] ?? 0;
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) };
}
export function buyGenerator(state, ctx, id, amount = 1) {
if (!ctx.generatorsById.has(id)) return false;
const { count, cost } = generatorQuote(state, ctx, id, amount);
if (count <= 0 || state.funds < cost) return false;
state.funds -= cost;
state.generators[id] += count;
return true;
}
export function buyUpgrade(state, ctx, id) {
const upgrade = ctx.upgradesById.get(id);
if (!upgrade || !upgradeVisible(state, upgrade) || state.funds < upgrade.cost) return false;
state.funds -= upgrade.cost;
state.upgrades.push(id);
return true;
}
export function setPrice(state, ctx, price) {
const { minPrice, maxPrice } = ctx.rules.market;
if (!Number.isFinite(price)) return false;
state.price = Math.round(clamp(price, minPrice, maxPrice) * 100) / 100;
return true;
}
// ---------------------------------------------------------------------- time
function stepMaterialMarket(state, ctx, dt, rng) {
const m = ctx.rules.materialMarket;
state.materialTimer += dt;
while (state.materialTimer >= m.updateEverySeconds) {
state.materialTimer -= m.updateEverySeconds;
const shock = 1 + (rng() - 0.5) * 2 * m.volatility;
const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion;
state.materialFactor = clamp(pulled * shock, m.minFactor, m.maxFactor);
}
}
function step(state, ctx, dt, rng, totals) {
stepMaterialMarket(state, ctx, dt, rng);
const d = derive(state, ctx);
// Production, limited by the material in stock.
const made = Math.min(d.rate * dt, state.material / d.materialPerUnit);
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
// Automatic restock when the stock runs low.
if (d.autoBuyMaterial && state.material < d.batchSize / 2 && state.funds >= d.batchCost) {
buyMaterial(state, ctx);
}
// Customers buy what they want, up to what is in stock.
const sold = Math.min(state.units, d.demand * dt);
const revenue = sold * state.price * d.valueMultiplier;
state.units -= sold;
state.funds += revenue;
state.totalSold += sold;
totals.produced += made;
totals.sold += sold;
totals.revenue += revenue;
}
/** Advance the simulation by `seconds`. Returns what happened during that time. */
export function advance(state, ctx, seconds, rng = Math.random) {
const totals = { produced: 0, sold: 0, revenue: 0 };
let left = Math.max(0, seconds);
while (left > 1e-9) {
const dt = Math.min(left, MAX_STEP_SECONDS);
step(state, ctx, dt, rng, totals);
left -= dt;
}
return totals;
}
/**
* Catch up on the time the game was closed. Deterministic (no price noise)
* and discounted by `offline.efficiency`. Returns null if too short to matter.
*/
export function simulateOffline(state, ctx, elapsedSeconds) {
const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline;
if (elapsedSeconds < minSeconds) return null;
const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
const totals = advance(state, ctx, seconds, () => 0.5);
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
}
+37
View File
@@ -0,0 +1,37 @@
// Pure math helpers. No state, no DOM.
export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/** Price of the next item when `owned` items are already owned. */
export function unitCost(base, growth, owned) {
return base * growth ** owned;
}
/** Total price of buying `count` more items (geometric series, rounded up). */
export function bulkCost(base, growth, owned, count) {
if (count <= 0) return 0;
return Math.ceil((unitCost(base, growth, owned) * (growth ** count - 1)) / (growth - 1));
}
/** How many items `budget` can pay for, robust against float error. */
export function maxAffordable(base, growth, owned, budget) {
const first = unitCost(base, growth, owned);
if (Number.isNaN(budget) || budget < first) return 0;
let n = Math.floor(Math.log((budget * (growth - 1)) / first + 1) / Math.log(growth));
while (n > 0 && bulkCost(base, growth, owned, n) > budget) n--;
while (bulkCost(base, growth, owned, n + 1) <= budget) n++;
return Math.max(n, 0);
}
/**
* Units customers want to buy per second at `price`.
* Demand is maxed at or below the fair price and drops steeply above it, so
* extra production is only worth selling at a higher price while supply is
* short; to sell more at the fair price, the player needs more demand.
*/
export function demandPerSecond(market, price, multiplier) {
const overpriced = Math.max(1, price / market.fairPrice);
return (market.baseDemand * multiplier) / overpriced ** market.elasticity;
}
+70
View File
@@ -0,0 +1,70 @@
import { clamp } from './formulas.js';
export const STATE_VERSION = 1;
const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
const MAX_MARKETING = 300;
export function createState(ctx, now = Date.now()) {
const { start, market } = ctx.rules;
return {
v: STATE_VERSION,
units: start.units,
funds: start.funds,
material: start.material,
totalProduced: 0,
totalSold: 0,
price: start.price ?? market.basePrice,
marketing: 0,
materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [],
savedAt: now,
materialTimer: 0, // transient, never saved
};
}
function num(value, fallback, min = 0, max = MAX_NUMBER) {
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
}
function int(value, max) {
return Math.floor(num(value, 0, 0, max));
}
/**
* Turn untrusted data (cookie, imported file) into a valid state.
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
*/
export function sanitizeState(raw, ctx, now = Date.now()) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const { market, materialMarket } = ctx.rules;
const base = createState(ctx, now);
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
return {
...base,
units: num(raw.units, base.units),
funds: num(raw.funds, base.funds),
material: num(raw.material, base.material),
totalProduced: num(raw.totalProduced, 0),
totalSold: num(raw.totalSold, 0),
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
marketing: int(raw.marketing, MAX_MARKETING),
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
generators: Object.fromEntries(
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
),
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)),
savedAt: num(raw.savedAt, now, 0, now),
};
}
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
export function serializeState(state) {
const { materialTimer, ...persisted } = state;
return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
);
}
+237
View File
@@ -0,0 +1,237 @@
// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI.
import {
advance,
buyGenerator,
buyMarketing,
buyMaterial,
buyUpgrade,
click,
derive,
setPrice,
simulateOffline,
} from './engine/engine.js';
import { createState, sanitizeState, serializeState } from './engine/state.js';
import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js';
import { createFormatter } from './services/format.js';
import { applyTranslations, fetchThemeName, FALLBACK_LOCALE, loadDictionary, t } from './services/i18n.js';
import { buildExport, parseImport } from './services/saveio.js';
import { cookiesAvailable, loadGameText, loadPrefs, saveGameText, savePrefs, deleteGame } from './services/storage.js';
import { applyThemeStyles } from './services/themes.js';
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
import { createSettings } from './ui/settings.js';
import { showToast } from './ui/toast.js';
import { createView } from './ui/view.js';
const TICK_MS = 100;
const INCOME_SMOOTHING_SECONDS = 3;
const SAVE_ERROR_COOLDOWN_MS = 60_000;
let ctx;
let themes = [];
let locales = [];
let themeId;
let locale;
let state;
let fmt;
let income = 0;
let lastSaveError = 0;
const themeNames = new Map();
// ------------------------------------------------------------------ saving
function persist() {
if (!state) return;
state.savedAt = Date.now();
try {
saveGameText(themeId, serializeState(state));
} catch {
if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) {
lastSaveError = Date.now();
showToast(t('toast.saveFailed'), { error: true });
}
}
}
function loadSavedState() {
const text = loadGameText(themeId);
if (!text) return null;
try {
return sanitizeState(JSON.parse(text), ctx);
} catch {
return null;
}
}
function persistPrefs() {
savePrefs({ theme: themeId, lang: locale });
}
// ------------------------------------------------------------ theme / language
function pickLocale(preferred) {
const codes = locales.map((l) => l.code);
if (codes.includes(preferred)) return preferred;
for (const tag of navigator.languages ?? [navigator.language]) {
const match = codes.find((code) => code === tag || code === tag?.split('-')[0]);
if (match) return match;
}
return FALLBACK_LOCALE;
}
async function themeLabel(id) {
const key = `${id}:${locale}`;
if (!themeNames.has(key)) themeNames.set(key, await fetchThemeName(id, locale));
return themeNames.get(key);
}
async function fillSelects() {
const themeSelect = $('#theme-select');
const labels = await Promise.all(themes.map((th) => themeLabel(th.id)));
themeSelect.replaceChildren(
...themes.map((th, i) => new Option(labels[i], th.id, false, th.id === themeId)),
);
$('#locale-select').replaceChildren(
...locales.map((l) => new Option(l.name, l.code, false, l.code === locale)),
);
}
/** Reload texts, assets and the lists for the current theme + language. */
async function refreshTexts() {
await loadDictionary(locale, themeId);
fmt = createFormatter(locale);
setThemeAssets(themeId, themes.find((th) => th.id === themeId)?.assets);
applyTranslations();
document.title = t('theme.title');
view.rebuild(ctx, fmt);
await fillSelects();
}
async function enterTheme(id) {
persist(); // keep the theme we are leaving
themeId = id;
await applyThemeStyles(id);
await refreshTexts();
state = loadSavedState() ?? createState(ctx);
income = 0;
catchUp((Date.now() - state.savedAt) / 1000);
persistPrefs();
render();
}
// ------------------------------------------------------------------ the loop
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
if (minutes < 1) return t('time.seconds', { value: Math.round(seconds) });
if (minutes < 60) return t('time.minutes', { value: minutes });
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 });
}
/** Apply progress made while the game was closed or the device asleep. */
function catchUp(elapsedSeconds) {
const report = simulateOffline(state, ctx, elapsedSeconds);
if (!report || (report.produced <= 0 && report.revenue <= 0)) return;
showToast(
t('toast.welcomeBack', {
duration: formatDuration(report.seconds),
produced: fmt.number(Math.floor(report.produced)),
funds: t('theme.fmt.funds', { value: fmt.money(report.revenue) }),
}),
);
}
let lastFrame = Date.now();
function frame() {
const now = Date.now();
const dt = (now - lastFrame) / 1000;
lastFrame = now;
if (!state || dt <= 0) return;
if (dt >= ctx.rules.offline.minSeconds) {
catchUp(dt); // tab was frozen or the device slept
} else {
const { revenue } = advance(state, ctx, dt);
income += (revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
}
render();
}
function render() {
view.update(state, derive(state, ctx), { income });
}
// ------------------------------------------------------------------- actions
const act = (fn) => (...args) => {
fn(state, ctx, ...args);
render();
};
const view = createView({
make: act(click),
buyMaterial: act(buyMaterial),
buyMarketing: act(buyMarketing),
buyGenerator: act(buyGenerator),
buyUpgrade: act(buyUpgrade),
setPrice: act(setPrice),
nudgePrice: (delta) => act(setPrice)(state.price + delta),
});
createSettings({
getExport: () => ({
text: buildExport(themeId, state),
name: `idle-clicker-${themeId}-${new Date().toISOString().slice(0, 10)}`,
}),
async importText(text) {
const imported = parseImport(text, ctx, themes.map((th) => th.id)); // throws before any change
if (imported.themeId !== themeId) await enterTheme(imported.themeId);
state = imported.state;
persist();
render();
},
reset() {
deleteGame(themeId);
state = createState(ctx);
income = 0;
persist();
render();
},
});
// ---------------------------------------------------------------------- boot
function showFatal(error) {
console.error(error);
document.body.replaceChildren(Object.assign(document.createElement('pre'), { textContent: String(error) }));
}
async function boot() {
[ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]);
if (themes.length === 0) throw new Error('No theme installed in public/themes/');
const prefs = loadPrefs();
locale = pickLocale(prefs.lang);
const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0];
$('#cookie-warning').hidden = cookiesAvailable();
$('#theme-select').addEventListener('change', (e) => enterTheme(e.target.value));
$('#locale-select').addEventListener('change', async (e) => {
locale = e.target.value;
persistPrefs();
await refreshTexts();
render();
});
await enterTheme(initialTheme.id);
refreshStaticIcons();
lastFrame = Date.now();
setInterval(frame, TICK_MS);
setInterval(persist, ctx.rules.autosaveSeconds * 1000);
document.addEventListener('visibilitychange', () => document.hidden && persist());
addEventListener('pagehide', persist);
}
boot().catch(showFatal);
+21
View File
@@ -0,0 +1,21 @@
// Thin wrapper over document.cookie. Values must already be cookie-safe
// (see storage.js, which base64url-encodes everything).
const ONE_YEAR = 60 * 60 * 24 * 365;
export function readCookie(name) {
const prefix = `${name}=`;
for (const part of document.cookie.split('; ')) {
if (part.startsWith(prefix)) return part.slice(prefix.length);
}
return null;
}
export function writeCookie(name, value) {
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${value}; Max-Age=${ONE_YEAR}; Path=/; SameSite=Lax${secure}`;
}
export function removeCookie(name) {
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
}
+24
View File
@@ -0,0 +1,24 @@
// Loads the static game data and the lists of installed themes / languages.
import { buildContext } from '../engine/context.js';
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
return response.json();
}
export async function loadGameContext() {
const [rules, generators, upgrades] = await Promise.all([
getJson('data/rules.json'),
getJson('data/generators.json'),
getJson('data/upgrades.json'),
]);
return buildContext({ rules, generators, upgrades });
}
/** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */
export const fetchThemes = () => getJson('api/themes');
/** [{ code, name }] discovered from public/locales/ by the server. */
export const fetchLocales = () => getJson('api/locales');
+25
View File
@@ -0,0 +1,25 @@
// Locale-aware number formatting with compact notation for big values.
export function createFormatter(locale) {
const make = (options) => new Intl.NumberFormat(locale, options);
const decimals = make({ maximumFractionDigits: 2 });
const integer = make({ maximumFractionDigits: 0 });
const fixed2 = make({ minimumFractionDigits: 2, maximumFractionDigits: 2 });
const compact = make({ notation: 'compact', maximumFractionDigits: 2 });
const scientific = make({ notation: 'scientific', maximumFractionDigits: 2 });
function number(n) {
if (!Number.isFinite(n)) return '∞';
const abs = Math.abs(n);
if (abs < 1000) return Number.isInteger(n) ? integer.format(n) : decimals.format(n);
if (abs < 1e6) return integer.format(n);
return abs < 1e15 ? compact.format(n) : scientific.format(n);
}
/** Amounts of money: always two decimals while small. */
function money(n) {
return Math.abs(n) < 1000 ? fixed2.format(n) : number(n);
}
return { number, money, price: (n) => fixed2.format(n) };
}
+81
View File
@@ -0,0 +1,81 @@
// Internationalisation.
//
// Two kinds of locale files are merged into one flat dictionary:
// locales/<lang>.json core UI texts (shared by all themes)
// themes/<id>/locales/<lang>.json theme texts, all under the "theme." root
// Missing keys fall back to English, then to the key itself.
//
// Strings support {param} interpolation and {@some.key} to embed another entry
// (e.g. "{@theme.res.material}"), so core texts can use theme vocabulary.
export const FALLBACK_LOCALE = 'en';
const MAX_EMBED_DEPTH = 5;
let dictionary = {};
let currentLocale = FALLBACK_LOCALE;
export const getLocale = () => currentLocale;
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;
}
async function fetchJson(url) {
try {
const response = await fetch(url);
return response.ok ? await response.json() : {};
} catch {
return {};
}
}
export async function loadDictionary(locale, themeId) {
const order = locale === FALLBACK_LOCALE ? [FALLBACK_LOCALE] : [FALLBACK_LOCALE, locale];
const files = order.flatMap((l) => [`locales/${l}.json`, `themes/${themeId}/locales/${l}.json`]);
const parts = await Promise.all(files.map(fetchJson));
dictionary = Object.assign({}, ...parts.map((p) => flatten(p)));
currentLocale = locale;
document.documentElement.lang = locale;
}
export function has(key) {
return key in dictionary;
}
export function t(key, params = {}, depth = 0) {
const text = dictionary[key];
if (text === undefined) return key;
return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => {
if (embed) return depth < MAX_EMBED_DEPTH ? t(name, params, depth + 1) : '';
return name in params ? String(params[name]) : match;
});
}
/**
* Translate static markup:
* data-i18n="key" -> textContent
* data-i18n-attr="title:key;aria-label:key" -> attributes
*/
export function applyTranslations(root = document) {
for (const el of root.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of root.querySelectorAll('[data-i18n-attr]')) {
for (const pair of el.dataset.i18nAttr.split(';')) {
const [attr, key] = pair.split(':').map((s) => s.trim());
if (attr && key) el.setAttribute(attr, t(key));
}
}
}
/** Display name of a theme in `locale` (falls back to English, then the id). */
export async function fetchThemeName(themeId, locale) {
for (const l of [locale, FALLBACK_LOCALE]) {
const file = await fetchJson(`themes/${themeId}/locales/${l}.json`);
if (file?.theme?.name) return file.theme.name;
}
return themeId;
}
+64
View File
@@ -0,0 +1,64 @@
// Export / import of a save as a JSON or TXT file (or pasted text).
// Works on every device: download via a Blob link, import via file input or paste.
import { sanitizeState, serializeState } from '../engine/state.js';
const GAME_ID = 'idle-clicker';
const FORMAT = 1;
export class ImportError extends Error {
/** @param {'empty'|'invalid'|'unknown_theme'|'wrong_game'} code */
constructor(code) {
super(code);
this.code = code;
}
}
/** The text a player downloads, copies or pastes back. */
export function buildExport(themeId, state, now = Date.now()) {
return JSON.stringify(
{
game: GAME_ID,
format: FORMAT,
theme: themeId,
exportedAt: new Date(now).toISOString(),
state: JSON.parse(serializeState(state)),
},
null,
2,
);
}
/** Validate imported text. Throws ImportError; never trusts the content. */
export function parseImport(text, ctx, knownThemeIds) {
const trimmed = String(text ?? '').trim().replace(/^/, '');
if (!trimmed) throw new ImportError('empty');
let data;
try {
data = JSON.parse(trimmed);
} catch {
throw new ImportError('invalid');
}
if (data?.game !== GAME_ID || data.format !== FORMAT) throw new ImportError('wrong_game');
if (!knownThemeIds.includes(data.theme)) throw new ImportError('unknown_theme');
const state = sanitizeState(data.state, ctx);
if (!state) throw new ImportError('invalid');
return { themeId: data.theme, state };
}
export function downloadText(filename, text, mime = 'application/json') {
const url = URL.createObjectURL(new Blob([text], { type: `${mime};charset=utf-8` }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export function readFileText(file) {
return file.text();
}
+106
View File
@@ -0,0 +1,106 @@
// Persistence in browser cookies.
//
// A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks:
// ic_save_<theme>_n -> number of chunks
// ic_save_<theme>_0.. -> the chunks
// Each theme has its own save slot. Preferences (theme, language) are stored
// in a single small cookie.
import { readCookie, removeCookie, writeCookie } from './cookies.js';
const CHUNK_SIZE = 3000;
const MAX_CHUNKS = 6;
const PREFS_KEY = 'ic_prefs';
const saveKey = (themeId) => `ic_save_${themeId}`;
// ------------------------------------------------------------------ encoding
export function encode(text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
export function decode(encoded) {
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
}
// ------------------------------------------------------------------- chunked
function writeChunked(key, text) {
const encoded = encode(text);
const chunks = [];
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) chunks.push(encoded.slice(i, i + CHUNK_SIZE));
if (chunks.length > MAX_CHUNKS) throw new Error('save_too_large');
const previous = Number(readCookie(`${key}_n`)) || 0;
chunks.forEach((chunk, i) => writeCookie(`${key}_${i}`, chunk));
writeCookie(`${key}_n`, String(chunks.length));
for (let i = chunks.length; i < previous; i++) removeCookie(`${key}_${i}`);
}
function readChunked(key) {
const count = Number(readCookie(`${key}_n`));
if (!Number.isInteger(count) || count < 1 || count > MAX_CHUNKS) return null;
let encoded = '';
for (let i = 0; i < count; i++) {
const chunk = readCookie(`${key}_${i}`);
if (chunk == null) return null;
encoded += chunk;
}
try {
return decode(encoded);
} catch {
return null;
}
}
function removeChunked(key) {
const count = Number(readCookie(`${key}_n`)) || 0;
for (let i = 0; i < count; i++) removeCookie(`${key}_${i}`);
removeCookie(`${key}_n`);
}
// -------------------------------------------------------------------- public
/** False when the browser refuses cookies (private mode, blocked, ...). */
export function cookiesAvailable() {
try {
writeCookie('ic_probe', '1');
const ok = readCookie('ic_probe') === '1';
removeCookie('ic_probe');
return ok;
} catch {
return false;
}
}
/** Throws if the save does not fit in cookies. */
export function saveGameText(themeId, text) {
writeChunked(saveKey(themeId), text);
}
export function loadGameText(themeId) {
return readChunked(saveKey(themeId));
}
export function deleteGame(themeId) {
removeChunked(saveKey(themeId));
}
export function savePrefs(prefs) {
writeCookie(PREFS_KEY, encode(JSON.stringify(prefs)));
}
export function loadPrefs() {
try {
const raw = readCookie(PREFS_KEY);
const prefs = raw && JSON.parse(decode(raw));
return prefs && typeof prefs === 'object' ? prefs : {};
} catch {
return {};
}
}
+18
View File
@@ -0,0 +1,18 @@
// Applies a theme's stylesheet. Texts are handled by i18n.js, mechanics are
// shared (public/data); a theme is only a skin: colours, vocabulary, images.
export function applyThemeStyles(themeId) {
document.documentElement.dataset.theme = themeId;
let link = document.getElementById('theme-css');
if (!link) {
link = document.createElement('link');
link.id = 'theme-css';
link.rel = 'stylesheet';
document.head.append(link);
}
return new Promise((resolve) => {
link.onload = link.onerror = () => resolve();
link.href = `themes/${themeId}/theme.css`;
});
}
+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 };
}