338 lines
10 KiB
JavaScript
338 lines
10 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_opportunity':
|
|
showToast(t('toast.eventOpportunity', { title: t(`event.${notice.type}.title`) }));
|
|
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),
|
|
}),
|
|
);
|
|
}
|
|
|
|
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
|
|
|
|
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),
|
|
});
|
|
|
|
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
|
|
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);
|
|
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);
|