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