feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { EFFECT_TYPES } from './effects.js';
|
||||
|
||||
/**
|
||||
* Bundle the static game data (data/*.json) with lookup tables and validate it,
|
||||
* so a typo in a JSON file fails loudly at startup instead of mid-game.
|
||||
*/
|
||||
export function buildContext({ rules, generators, upgrades }) {
|
||||
const generatorsById = new Map(generators.map((g) => [g.id, g]));
|
||||
const upgradesById = new Map(upgrades.map((u) => [u.id, u]));
|
||||
|
||||
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
|
||||
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
|
||||
|
||||
for (const u of upgrades) {
|
||||
if (!EFFECT_TYPES.includes(u.effect?.type)) {
|
||||
throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`);
|
||||
}
|
||||
if (u.effect.target && !generatorsById.has(u.effect.target)) {
|
||||
throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`);
|
||||
}
|
||||
if (u.requires && !upgradesById.has(u.requires)) {
|
||||
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return { rules, generators, upgrades, generatorsById, upgradesById };
|
||||
}
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
// Upgrade effects. To add a new effect type: register it here, add its
|
||||
// `effect.<type>` string to the core locale files, and use it in upgrades.json.
|
||||
|
||||
export function newModifiers() {
|
||||
return {
|
||||
click: 1,
|
||||
generators: {},
|
||||
global: 1,
|
||||
demand: 1,
|
||||
value: 1,
|
||||
materialEfficiency: 1,
|
||||
batchDiscount: 1,
|
||||
autoBuyMaterial: false,
|
||||
};
|
||||
}
|
||||
|
||||
const APPLY = {
|
||||
click_mult: (m, e) => void (m.click *= e.value),
|
||||
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value),
|
||||
global_mult: (m, e) => void (m.global *= e.value),
|
||||
demand_mult: (m, e) => void (m.demand *= e.value),
|
||||
value_mult: (m, e) => void (m.value *= e.value),
|
||||
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
|
||||
batch_discount: (m, e) => void (m.batchDiscount *= e.value),
|
||||
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
|
||||
};
|
||||
|
||||
export const EFFECT_TYPES = Object.keys(APPLY);
|
||||
|
||||
/** Fold every owned upgrade into a single set of multipliers. */
|
||||
export function computeModifiers(ownedIds, upgradesById) {
|
||||
const m = newModifiers();
|
||||
for (const id of ownedIds) {
|
||||
const effect = upgradesById.get(id)?.effect;
|
||||
if (effect) APPLY[effect.type]?.(m, effect);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Game rules. Every function takes the mutable `state` and the static `ctx`
|
||||
// (see context.js) and never touches the DOM, so it runs the same in the
|
||||
// browser and in Node (tests, balance simulations).
|
||||
|
||||
import { computeModifiers } from './effects.js';
|
||||
import { bulkCost, clamp, demandPerSecond, maxAffordable } from './formulas.js';
|
||||
|
||||
const MAX_STEP_SECONDS = 1;
|
||||
|
||||
// ---------------------------------------------------------------- conditions
|
||||
|
||||
export function isUnlocked(state, condition = {}) {
|
||||
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false;
|
||||
if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function upgradeVisible(state, upgrade) {
|
||||
if (state.upgrades.includes(upgrade.id)) return false;
|
||||
if (upgrade.requires && !state.upgrades.includes(upgrade.requires)) return false;
|
||||
return isUnlocked(state, upgrade.unlock);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- derived
|
||||
|
||||
/** Everything computed from the state (rates, multipliers, current prices). */
|
||||
export function derive(state, ctx) {
|
||||
const { rules } = ctx;
|
||||
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
|
||||
|
||||
const perGenerator = {};
|
||||
let rate = 0;
|
||||
for (const g of ctx.generators) {
|
||||
perGenerator[g.id] = g.baseRate * (mods.generators[g.id] ?? 1) * mods.global;
|
||||
rate += perGenerator[g.id] * (state.generators[g.id] ?? 0);
|
||||
}
|
||||
|
||||
const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing;
|
||||
return {
|
||||
clickYield: rules.click.yield * mods.click,
|
||||
perGenerator,
|
||||
rate,
|
||||
demandMultiplier,
|
||||
demand: demandPerSecond(rules.market, state.price, demandMultiplier),
|
||||
valueMultiplier: mods.value,
|
||||
materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency,
|
||||
batchSize: rules.materialMarket.batchSize,
|
||||
batchCost: Math.ceil(rules.materialMarket.basePrice * state.materialFactor * mods.batchDiscount),
|
||||
autoBuyMaterial: mods.autoBuyMaterial,
|
||||
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- actions
|
||||
// Each returns true when the action was performed.
|
||||
|
||||
export function click(state, ctx) {
|
||||
const d = derive(state, ctx);
|
||||
if (state.material < d.materialPerUnit) return false;
|
||||
const made = Math.min(d.clickYield, state.material / d.materialPerUnit);
|
||||
state.material -= made * d.materialPerUnit;
|
||||
state.units += made;
|
||||
state.totalProduced += made;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buyMaterial(state, ctx) {
|
||||
const d = derive(state, ctx);
|
||||
if (state.funds < d.batchCost) return false;
|
||||
state.funds -= d.batchCost;
|
||||
state.material += d.batchSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buyMarketing(state, ctx) {
|
||||
const d = derive(state, ctx);
|
||||
if (state.funds < d.marketingCost) return false;
|
||||
state.funds -= d.marketingCost;
|
||||
state.marketing += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** How many generators the player gets for `amount` ("max" or a number). */
|
||||
export function generatorQuote(state, ctx, id, amount) {
|
||||
const g = ctx.generatorsById.get(id);
|
||||
const owned = state.generators[id] ?? 0;
|
||||
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
|
||||
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) };
|
||||
}
|
||||
|
||||
export function buyGenerator(state, ctx, id, amount = 1) {
|
||||
if (!ctx.generatorsById.has(id)) return false;
|
||||
const { count, cost } = generatorQuote(state, ctx, id, amount);
|
||||
if (count <= 0 || state.funds < cost) return false;
|
||||
state.funds -= cost;
|
||||
state.generators[id] += count;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buyUpgrade(state, ctx, id) {
|
||||
const upgrade = ctx.upgradesById.get(id);
|
||||
if (!upgrade || !upgradeVisible(state, upgrade) || state.funds < upgrade.cost) return false;
|
||||
state.funds -= upgrade.cost;
|
||||
state.upgrades.push(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setPrice(state, ctx, price) {
|
||||
const { minPrice, maxPrice } = ctx.rules.market;
|
||||
if (!Number.isFinite(price)) return false;
|
||||
state.price = Math.round(clamp(price, minPrice, maxPrice) * 100) / 100;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- time
|
||||
|
||||
function stepMaterialMarket(state, ctx, dt, rng) {
|
||||
const m = ctx.rules.materialMarket;
|
||||
state.materialTimer += dt;
|
||||
while (state.materialTimer >= m.updateEverySeconds) {
|
||||
state.materialTimer -= m.updateEverySeconds;
|
||||
const shock = 1 + (rng() - 0.5) * 2 * m.volatility;
|
||||
const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion;
|
||||
state.materialFactor = clamp(pulled * shock, m.minFactor, m.maxFactor);
|
||||
}
|
||||
}
|
||||
|
||||
function step(state, ctx, dt, rng, totals) {
|
||||
stepMaterialMarket(state, ctx, dt, rng);
|
||||
const d = derive(state, ctx);
|
||||
|
||||
// Production, limited by the material in stock.
|
||||
const made = Math.min(d.rate * dt, state.material / d.materialPerUnit);
|
||||
state.material -= made * d.materialPerUnit;
|
||||
state.units += made;
|
||||
state.totalProduced += made;
|
||||
|
||||
// Automatic restock when the stock runs low.
|
||||
if (d.autoBuyMaterial && state.material < d.batchSize / 2 && state.funds >= d.batchCost) {
|
||||
buyMaterial(state, ctx);
|
||||
}
|
||||
|
||||
// Customers buy what they want, up to what is in stock.
|
||||
const sold = Math.min(state.units, d.demand * dt);
|
||||
const revenue = sold * state.price * d.valueMultiplier;
|
||||
state.units -= sold;
|
||||
state.funds += revenue;
|
||||
state.totalSold += sold;
|
||||
|
||||
totals.produced += made;
|
||||
totals.sold += sold;
|
||||
totals.revenue += revenue;
|
||||
}
|
||||
|
||||
/** Advance the simulation by `seconds`. Returns what happened during that time. */
|
||||
export function advance(state, ctx, seconds, rng = Math.random) {
|
||||
const totals = { produced: 0, sold: 0, revenue: 0 };
|
||||
let left = Math.max(0, seconds);
|
||||
while (left > 1e-9) {
|
||||
const dt = Math.min(left, MAX_STEP_SECONDS);
|
||||
step(state, ctx, dt, rng, totals);
|
||||
left -= dt;
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch up on the time the game was closed. Deterministic (no price noise)
|
||||
* and discounted by `offline.efficiency`. Returns null if too short to matter.
|
||||
*/
|
||||
export function simulateOffline(state, ctx, elapsedSeconds) {
|
||||
const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline;
|
||||
if (elapsedSeconds < minSeconds) return null;
|
||||
const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
|
||||
const totals = advance(state, ctx, seconds, () => 0.5);
|
||||
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Pure math helpers. No state, no DOM.
|
||||
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** Price of the next item when `owned` items are already owned. */
|
||||
export function unitCost(base, growth, owned) {
|
||||
return base * growth ** owned;
|
||||
}
|
||||
|
||||
/** Total price of buying `count` more items (geometric series, rounded up). */
|
||||
export function bulkCost(base, growth, owned, count) {
|
||||
if (count <= 0) return 0;
|
||||
return Math.ceil((unitCost(base, growth, owned) * (growth ** count - 1)) / (growth - 1));
|
||||
}
|
||||
|
||||
/** How many items `budget` can pay for, robust against float error. */
|
||||
export function maxAffordable(base, growth, owned, budget) {
|
||||
const first = unitCost(base, growth, owned);
|
||||
if (Number.isNaN(budget) || budget < first) return 0;
|
||||
let n = Math.floor(Math.log((budget * (growth - 1)) / first + 1) / Math.log(growth));
|
||||
while (n > 0 && bulkCost(base, growth, owned, n) > budget) n--;
|
||||
while (bulkCost(base, growth, owned, n + 1) <= budget) n++;
|
||||
return Math.max(n, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Units customers want to buy per second at `price`.
|
||||
* Demand is maxed at or below the fair price and drops steeply above it, so
|
||||
* extra production is only worth selling at a higher price while supply is
|
||||
* short; to sell more at the fair price, the player needs more demand.
|
||||
*/
|
||||
export function demandPerSecond(market, price, multiplier) {
|
||||
const overpriced = Math.max(1, price / market.fairPrice);
|
||||
return (market.baseDemand * multiplier) / overpriced ** market.elasticity;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { clamp } from './formulas.js';
|
||||
|
||||
export const STATE_VERSION = 1;
|
||||
|
||||
const MAX_NUMBER = 1e300;
|
||||
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
|
||||
const MAX_MARKETING = 300;
|
||||
|
||||
export function createState(ctx, now = Date.now()) {
|
||||
const { start, market } = ctx.rules;
|
||||
return {
|
||||
v: STATE_VERSION,
|
||||
units: start.units,
|
||||
funds: start.funds,
|
||||
material: start.material,
|
||||
totalProduced: 0,
|
||||
totalSold: 0,
|
||||
price: start.price ?? market.basePrice,
|
||||
marketing: 0,
|
||||
materialFactor: 1,
|
||||
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
|
||||
upgrades: [],
|
||||
savedAt: now,
|
||||
materialTimer: 0, // transient, never saved
|
||||
};
|
||||
}
|
||||
|
||||
function num(value, fallback, min = 0, max = MAX_NUMBER) {
|
||||
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
|
||||
}
|
||||
|
||||
function int(value, max) {
|
||||
return Math.floor(num(value, 0, 0, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn untrusted data (cookie, imported file) into a valid state.
|
||||
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
|
||||
*/
|
||||
export function sanitizeState(raw, ctx, now = Date.now()) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const { market, materialMarket } = ctx.rules;
|
||||
const base = createState(ctx, now);
|
||||
|
||||
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
|
||||
return {
|
||||
...base,
|
||||
units: num(raw.units, base.units),
|
||||
funds: num(raw.funds, base.funds),
|
||||
material: num(raw.material, base.material),
|
||||
totalProduced: num(raw.totalProduced, 0),
|
||||
totalSold: num(raw.totalSold, 0),
|
||||
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
|
||||
marketing: int(raw.marketing, MAX_MARKETING),
|
||||
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
|
||||
generators: Object.fromEntries(
|
||||
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
|
||||
),
|
||||
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)),
|
||||
savedAt: num(raw.savedAt, now, 0, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
|
||||
export function serializeState(state) {
|
||||
const { materialTimer, ...persisted } = state;
|
||||
return JSON.stringify(persisted, (_key, value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const $ = (selector, root = document) => root.querySelector(selector);
|
||||
|
||||
/** Create an element: h('button', { class: 'btn', text: 'Go', onclick }, ...children). */
|
||||
export function h(tag, props = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value == null || value === false) continue;
|
||||
if (key === 'class') el.className = value;
|
||||
else if (key === 'text') el.textContent = value;
|
||||
else if (key === 'dataset') Object.assign(el.dataset, value);
|
||||
else if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
else el.setAttribute(key, value === true ? '' : value);
|
||||
}
|
||||
el.append(...children.flat().filter((c) => c != null && c !== false));
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- icons
|
||||
// Every icon is a grey square for now. When a theme provides images, list them
|
||||
// in its theme.json ("assets": { "gen.gen1": "img/autoclipper.svg", ... }) and
|
||||
// they replace the placeholders, with no change to the UI code.
|
||||
|
||||
let assetBase = '';
|
||||
let assets = {};
|
||||
|
||||
export function setThemeAssets(themeId, themeAssets) {
|
||||
assetBase = `themes/${themeId}/`;
|
||||
assets = themeAssets ?? {};
|
||||
}
|
||||
|
||||
/** key examples: "res.units", "gen.gen1", "upg.click_1" */
|
||||
export function icon(key) {
|
||||
const path = assets[key];
|
||||
if (path) return h('img', { class: 'icon', src: assetBase + path, alt: '', loading: 'lazy' });
|
||||
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } });
|
||||
}
|
||||
|
||||
/** Re-resolve the icons that live in index.html (resource tiles). */
|
||||
export function refreshStaticIcons(root = document) {
|
||||
for (const el of root.querySelectorAll('[data-icon]')) {
|
||||
if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js
|
||||
el.replaceWith(icon(el.dataset.icon));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// "Save & settings" dialog: export (file or text), import (file or pasted text), reset.
|
||||
|
||||
import { downloadText, ImportError, readFileText } from '../services/saveio.js';
|
||||
import { t } from '../services/i18n.js';
|
||||
import { $ } from './dom.js';
|
||||
import { showToast } from './toast.js';
|
||||
|
||||
/**
|
||||
* @param hooks.getExport () => { text, name } current save as text + file base name
|
||||
* @param hooks.importText (text) => void throws ImportError on bad input
|
||||
* @param hooks.reset () => void
|
||||
*/
|
||||
export function createSettings(hooks) {
|
||||
const dialog = $('#settings');
|
||||
const exportBox = $('#export-text');
|
||||
const importBox = $('#import-text');
|
||||
const fileInput = $('#import-file');
|
||||
|
||||
function open() {
|
||||
exportBox.value = hooks.getExport().text;
|
||||
importBox.value = '';
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
}
|
||||
|
||||
function download(extension, mime) {
|
||||
const { text, name } = hooks.getExport();
|
||||
downloadText(`${name}.${extension}`, text, mime);
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
exportBox.value = hooks.getExport().text;
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportBox.value);
|
||||
} catch {
|
||||
exportBox.select();
|
||||
if (!document.execCommand?.('copy')) return showToast(t('toast.copyFailed'), { error: true });
|
||||
}
|
||||
showToast(t('toast.copied'));
|
||||
}
|
||||
|
||||
async function runImport(text) {
|
||||
try {
|
||||
await hooks.importText(text);
|
||||
dialog.close?.();
|
||||
showToast(t('toast.imported'));
|
||||
} catch (error) {
|
||||
const code = error instanceof ImportError ? error.code : 'invalid';
|
||||
showToast(t(`error.import.${code}`), { error: true });
|
||||
}
|
||||
}
|
||||
|
||||
$('#open-settings').addEventListener('click', open);
|
||||
$('#export-json').addEventListener('click', () => download('json', 'application/json'));
|
||||
$('#export-txt').addEventListener('click', () => download('txt', 'text/plain'));
|
||||
$('#export-copy').addEventListener('click', copy);
|
||||
$('#import-paste').addEventListener('click', () => runImport(importBox.value));
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
fileInput.value = ''; // allow picking the same file again
|
||||
if (file) await runImport(await readFileText(file));
|
||||
});
|
||||
$('#reset-game').addEventListener('click', () => {
|
||||
if (!confirm(t('settings.resetConfirm'))) return;
|
||||
hooks.reset();
|
||||
dialog.close?.();
|
||||
showToast(t('toast.reset'));
|
||||
});
|
||||
|
||||
return { open };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { $, h } from './dom.js';
|
||||
|
||||
const DURATION_MS = 6000;
|
||||
|
||||
export function showToast(message, { error = false } = {}) {
|
||||
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
|
||||
$('#toasts').append(toast);
|
||||
setTimeout(() => toast.remove(), DURATION_MS);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Main game view. Static markup lives in index.html; this module binds the
|
||||
// controls once, builds the generator/upgrade lists, and refreshes the numbers.
|
||||
|
||||
import { generatorQuote, isUnlocked, upgradeVisible } from '../engine/engine.js';
|
||||
import { t } from '../services/i18n.js';
|
||||
import { $, h, icon, refreshStaticIcons } from './dom.js';
|
||||
|
||||
const SLIDER_STEPS = 1000;
|
||||
|
||||
function setText(node, text) {
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param actions callbacks into the game: make, buyMaterial, buyMarketing,
|
||||
* buyGenerator(id, amount), buyUpgrade(id), setPrice(value)
|
||||
*/
|
||||
export function createView(actions) {
|
||||
const el = {
|
||||
units: $('#v-units'),
|
||||
funds: $('#v-funds'),
|
||||
material: $('#v-material'),
|
||||
total: $('#v-total'),
|
||||
rate: $('#v-rate'),
|
||||
demand: $('#v-demand'),
|
||||
income: $('#v-income'),
|
||||
make: $('#make'),
|
||||
makeYield: $('#make-yield'),
|
||||
buyMaterial: $('#buy-material'),
|
||||
buyMaterialInfo: $('#buy-material-info'),
|
||||
buyMarketing: $('#buy-marketing'),
|
||||
buyMarketingInfo: $('#buy-marketing-info'),
|
||||
priceInput: $('#price-input'),
|
||||
priceSlider: $('#price-slider'),
|
||||
priceHint: $('#price-hint'),
|
||||
generators: $('#generators'),
|
||||
upgrades: $('#upgrades'),
|
||||
upgradeCount: $('#upgrade-count'),
|
||||
upgradesEmpty: $('#upgrades-empty'),
|
||||
};
|
||||
|
||||
let ctx;
|
||||
let fmt;
|
||||
let buyAmount = 1;
|
||||
let generatorRows = new Map();
|
||||
let upgradeRows = new Map();
|
||||
let upgradeSignature = null;
|
||||
|
||||
const money = (n) => t('theme.fmt.funds', { value: fmt.money(n) });
|
||||
const perSecond = (n) => t('ui.perSecond', { value: fmt.number(n) });
|
||||
|
||||
// ---------------------------------------------------------------- controls
|
||||
|
||||
el.make.addEventListener('click', () => actions.make());
|
||||
el.buyMaterial.addEventListener('click', () => actions.buyMaterial());
|
||||
el.buyMarketing.addEventListener('click', () => actions.buyMarketing());
|
||||
|
||||
const priceFromSlider = () => {
|
||||
const { minPrice, maxPrice } = ctx.rules.market;
|
||||
return minPrice * (maxPrice / minPrice) ** (Number(el.priceSlider.value) / SLIDER_STEPS);
|
||||
};
|
||||
const sliderFromPrice = (price) => {
|
||||
const { minPrice, maxPrice } = ctx.rules.market;
|
||||
return (SLIDER_STEPS * Math.log(price / minPrice)) / Math.log(maxPrice / minPrice);
|
||||
};
|
||||
|
||||
el.priceSlider.addEventListener('input', () => actions.setPrice(priceFromSlider()));
|
||||
el.priceInput.addEventListener('change', () => actions.setPrice(Number(el.priceInput.value)));
|
||||
$('#price-down').addEventListener('click', () => actions.nudgePrice(-ctx.rules.market.priceStep));
|
||||
$('#price-up').addEventListener('click', () => actions.nudgePrice(ctx.rules.market.priceStep));
|
||||
|
||||
$('#buy-amount').addEventListener('change', (event) => {
|
||||
const value = event.target.value;
|
||||
buyAmount = value === 'max' ? 'max' : Number(value);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- lists
|
||||
|
||||
function effectText(effect) {
|
||||
const params = { value: fmt.number(effect.value) };
|
||||
if (effect.target) params.target = t(`theme.gen.${effect.target}.name`);
|
||||
if (effect.type === 'batch_discount') params.percent = fmt.number(Math.round((1 - effect.value) * 100));
|
||||
return t(`effect.${effect.type}`, params);
|
||||
}
|
||||
|
||||
function generatorRow(g) {
|
||||
const owned = h('span', { class: 'badge' });
|
||||
const rate = h('span');
|
||||
const amount = h('span', { class: 'btn__label' });
|
||||
const cost = h('small');
|
||||
const buy = h(
|
||||
'button',
|
||||
{ class: 'btn btn--buy', type: 'button', onclick: () => actions.buyGenerator(g.id, buyAmount) },
|
||||
amount,
|
||||
cost,
|
||||
);
|
||||
const root = h(
|
||||
'li',
|
||||
{ class: 'card', hidden: true },
|
||||
icon(`gen.${g.id}`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__body' },
|
||||
h('h3', { class: 'card__title' }, h('span', { text: t(`theme.gen.${g.id}.name`) }), owned),
|
||||
h('p', { class: 'card__desc', text: t(`theme.gen.${g.id}.desc`) }),
|
||||
h('p', { class: 'card__meta' }, rate),
|
||||
),
|
||||
buy,
|
||||
);
|
||||
el.generators.append(root);
|
||||
return { root, owned, rate, amount, cost, buy };
|
||||
}
|
||||
|
||||
function upgradeRow(u) {
|
||||
const cost = h('small');
|
||||
const root = h(
|
||||
'li',
|
||||
{ class: 'card' },
|
||||
icon(`upg.${u.id}`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__body' },
|
||||
h('h3', { class: 'card__title', text: t(`theme.upg.${u.id}`) }),
|
||||
h('p', { class: 'card__desc', text: effectText(u.effect) }),
|
||||
),
|
||||
h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyUpgrade(u.id) }, cost),
|
||||
);
|
||||
return { root, cost, buy: root.lastElementChild, upgrade: u };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ public
|
||||
|
||||
/** Call after the theme, language or game data changed. */
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
el.generators.replaceChildren();
|
||||
el.upgrades.replaceChildren();
|
||||
generatorRows = new Map(ctx.generators.map((g) => [g.id, generatorRow(g)]));
|
||||
upgradeRows = new Map();
|
||||
upgradeSignature = null;
|
||||
el.priceInput.min = ctx.rules.market.minPrice;
|
||||
el.priceInput.max = ctx.rules.market.maxPrice;
|
||||
el.priceInput.step = ctx.rules.market.priceStep;
|
||||
refreshStaticIcons();
|
||||
}
|
||||
|
||||
function updateGenerators(state, d) {
|
||||
for (const g of ctx.generators) {
|
||||
const row = generatorRows.get(g.id);
|
||||
const owned = state.generators[g.id];
|
||||
const visible = owned > 0 || isUnlocked(state, g.unlock);
|
||||
row.root.hidden = !visible;
|
||||
if (!visible) continue;
|
||||
|
||||
let quote = generatorQuote(state, ctx, g.id, buyAmount);
|
||||
if (quote.count <= 0) quote = generatorQuote(state, ctx, g.id, 1);
|
||||
|
||||
setText(row.owned, t('ui.owned', { count: fmt.number(owned) }));
|
||||
setText(row.rate, t('ui.rateEach', { rate: fmt.number(d.perGenerator[g.id]) }));
|
||||
setText(row.amount, t('ui.buyN', { count: fmt.number(quote.count) }));
|
||||
setText(row.cost, money(quote.cost));
|
||||
row.buy.disabled = state.funds < quote.cost;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUpgrades(state) {
|
||||
const visible = ctx.upgrades.filter((u) => upgradeVisible(state, u)).sort((a, b) => a.cost - b.cost);
|
||||
const signature = visible.map((u) => u.id).join();
|
||||
if (signature !== upgradeSignature) {
|
||||
upgradeSignature = signature;
|
||||
upgradeRows = new Map(visible.map((u) => [u.id, upgradeRow(u)]));
|
||||
el.upgrades.replaceChildren(...[...upgradeRows.values()].map((row) => row.root));
|
||||
el.upgradesEmpty.hidden = visible.length > 0;
|
||||
}
|
||||
for (const row of upgradeRows.values()) {
|
||||
setText(row.cost, money(row.upgrade.cost));
|
||||
row.buy.disabled = state.funds < row.upgrade.cost;
|
||||
}
|
||||
setText(
|
||||
el.upgradeCount,
|
||||
t('ui.upgradeCount', { owned: state.upgrades.length, total: ctx.upgrades.length }),
|
||||
);
|
||||
}
|
||||
|
||||
function updateMarket(state, d) {
|
||||
const { fairPrice, minPrice } = ctx.rules.market;
|
||||
if (document.activeElement !== el.priceInput) el.priceInput.value = state.price.toFixed(2);
|
||||
if (document.activeElement !== el.priceSlider) {
|
||||
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
|
||||
}
|
||||
const hintKey = state.price > fairPrice ? 'ui.hintHigh' : 'ui.hintFair';
|
||||
setText(el.priceHint, t(hintKey, { fair: money(fairPrice), demand: fmt.number(d.demand) }));
|
||||
|
||||
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
|
||||
setText(
|
||||
el.buyMarketingInfo,
|
||||
t('ui.marketingInfo', {
|
||||
level: state.marketing + 1,
|
||||
multiplier: fmt.number(nextLevelMultiplier),
|
||||
cost: money(d.marketingCost),
|
||||
}),
|
||||
);
|
||||
el.buyMarketing.disabled = state.funds < d.marketingCost;
|
||||
}
|
||||
|
||||
/** @param flow { income } smoothed funds per second */
|
||||
function update(state, d, flow) {
|
||||
setText(el.units, fmt.number(Math.floor(state.units)));
|
||||
setText(el.funds, money(state.funds));
|
||||
setText(el.material, fmt.number(Math.floor(state.material)));
|
||||
setText(el.total, fmt.number(Math.floor(state.totalProduced)));
|
||||
setText(el.rate, perSecond(d.rate));
|
||||
setText(el.demand, perSecond(d.demand));
|
||||
setText(el.income, t('ui.perSecond', { value: money(flow.income) }));
|
||||
|
||||
setText(el.makeYield, t('ui.makeYield', { amount: fmt.number(d.clickYield) }));
|
||||
el.make.disabled = state.material < d.materialPerUnit;
|
||||
setText(
|
||||
el.buyMaterialInfo,
|
||||
t('ui.batchInfo', { amount: fmt.number(d.batchSize), cost: money(d.batchCost) }),
|
||||
);
|
||||
el.buyMaterial.disabled = state.funds < d.batchCost;
|
||||
|
||||
updateMarket(state, d);
|
||||
updateGenerators(state, d);
|
||||
updateUpgrades(state);
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
Reference in New Issue
Block a user