feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user