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
+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`;
});
}