Files

346 lines
11 KiB
JavaScript

// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI.
import { abandonContract, acceptContract, claimEvent } from './engine/contracts.js';
import {
advance,
buyGenerator,
buyMarketing,
buyMaterial,
buyUpgrade,
click,
derive,
setPrice,
simulateOffline,
} from './engine/engine.js';
import { buyEndless } from './engine/endless.js';
import { buyChip, buyReliability, gamble } from './engine/gamble.js';
import { buyShares, sellShares } from './engine/investments.js';
import { buyPerk, prestige } from './engine/legacy.js';
import { buyLab, buyResearch } from './engine/research.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,
deleteGame,
loadGameText,
loadPrefs,
saveGameText,
savePrefs,
takeLegacySaves,
} from './services/storage.js';
import { applyThemeStyles } from './services/themes.js';
import { createAchievementsView } from './ui/achievementsView.js';
import { createContractsView } from './ui/contractsView.js';
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
import { createEndlessView } from './ui/endlessView.js';
import { createGambleView } from './ui/gambleView.js';
import { createInvestView } from './ui/investView.js';
import { createLegacyView } from './ui/legacyView.js';
import { createResearchView } from './ui/researchView.js';
import { createSettings } from './ui/settings.js';
import { createTabs } from './ui/tabs.js';
import { formatDuration, moneyFormatter, rewardText } from './ui/text.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 tabs;
let income = 0;
let lastSaveError = 0;
const themeNames = new Map();
// ------------------------------------------------------------------ saving
function persist() {
if (!state) return;
state.savedAt = Date.now();
try {
saveGameText(serializeState(state));
} catch {
if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) {
lastSaveError = Date.now();
showToast(t('toast.saveFailed'), { error: true });
}
}
}
function parseSaved(text) {
try {
return sanitizeState(JSON.parse(text), ctx);
} catch {
return null;
}
}
/**
* The shared game from cookies. Falls back to the per-theme saves of the first
* version: the most advanced one becomes the shared game, the others are dropped.
*/
function loadSavedState() {
const text = loadGameText();
if (text) return parseSaved(text);
const legacy = takeLegacySaves(themes.map((th) => th.id)).map(parseSaved).filter(Boolean);
legacy.sort((a, b) => b.totalProduced - a.totalProduced);
return legacy[0] ?? 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 every screen 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);
endless.rebuild(ctx, fmt);
for (const screen of Object.values(screens)) screen.rebuild(ctx, fmt);
tabs.rebuild();
refreshStaticIcons();
await fillSelects();
}
/** Change the skin only: the game state is shared by every theme. */
async function setTheme(id) {
themeId = id;
await applyThemeStyles(id);
await refreshTexts();
persistPrefs();
if (state) render();
}
// ------------------------------------------------------------------ the loop
/** Tell the player about what just happened (see `notices` in engine.advance). */
function announce(notice) {
switch (notice.kind) {
case 'contract_done':
showToast(t('toast.contractDone', { reward: rewardText(notice.reward, fmt, notice.seconds) }));
break;
case 'contract_failed':
showToast(t('toast.contractFailed'), { error: true });
break;
case 'event_incident':
showToast(t('toast.eventIncident', { title: t(`event.${notice.type}.title`) }), { error: true });
break;
case 'achievement':
showToast(t('toast.achievement', { title: t(`ach.title.${notice.id}`) }));
break;
default:
}
}
/** 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: moneyFormatter(fmt)(report.revenue),
}),
);
}
/** Tell the player when an older save had to be rebalanced (see repairOlderSave in state.js). */
function reportRepair() {
if (!state.repaired) return;
state.repaired = false;
showToast(t('toast.repaired'), { sticky: true });
}
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 totals = advance(state, ctx, dt);
income += (totals.revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
totals.notices.forEach(announce);
}
render();
}
/** The resource bar and the unlocked tabs always; the open section only. */
function render() {
const d = derive(state, ctx);
view.update(state, d, { income });
tabs.update(state);
if (tabs.active === 'main') endless.update(state);
else screens[tabs.active]?.update(state, d);
}
// ------------------------------------------------------------------- actions
/** Wrap an engine action: run it on the current state, refresh the screen, report if it happened. */
const act = (fn) => (...args) => {
const done = fn(state, ctx, ...args);
render();
return done;
};
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),
});
const endless = createEndlessView($('#endless'), { buy: act(buyEndless) });
function restart() {
if (!confirm(t('legacy.confirm'))) return;
const gain = prestige(state, ctx);
if (!gain) return;
income = 0;
persist();
tabs.select('main');
showToast(t('toast.restarted', { gain: fmt.number(gain) }));
}
/** One screen per section; each is built and refreshed by its own module. */
const screens = {
contracts: createContractsView($('#view-contracts'), {
accept: act(acceptContract),
abandon: act(abandonContract),
claimEvent: () => {
claimEvent(state, ctx, derive(state, ctx));
render();
},
}),
invest: createInvestView($('#view-invest'), { buy: act(buyShares), sell: act(sellShares) }),
research: createResearchView($('#view-research'), { buyLab: act(buyLab), buyNode: act(buyResearch) }),
gamble: createGambleView($('#view-gamble'), {
buyChip: act(buyChip),
buyReliability: act(buyReliability),
run(stake) {
const result = gamble(state, ctx, stake, derive(state, ctx).mods);
render();
return result;
},
}),
legacy: createLegacyView($('#view-legacy'), { buyPerk: act(buyPerk), restart }),
achievements: createAchievementsView($('#view-achievements')),
};
createSettings({
getExport: () => ({
text: buildExport(state),
name: `idle-clicker-${new Date().toISOString().slice(0, 10)}`,
}),
importText(text) {
state = parseImport(text, ctx); // throws before anything changes
reportRepair();
income = 0;
persist();
render();
},
reset() {
deleteGame();
state = createState(ctx);
income = 0;
persist();
tabs.select('main');
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/');
tabs = createTabs(ctx.tabs, () => state && render());
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) => setTheme(e.target.value));
$('#locale-select').addEventListener('change', async (e) => {
locale = e.target.value;
persistPrefs();
await refreshTexts();
render();
});
await setTheme(initialTheme.id);
state = loadSavedState() ?? createState(ctx);
reportRepair();
catchUp((Date.now() - state.savedAt) / 1000);
persist();
render();
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);