// 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, materialPurchase, upgradeVisible } from '../engine/engine.js'; import { t } from '../services/i18n.js'; import { $, h, icon, makeBuyable, refreshStaticIcons, setBuyable } from './dom.js'; import { bump, floater, pop, purchased } from './fx.js'; import { effectText, setText } from './text.js'; const SLIDER_STEPS = 1000; const MARKET_TOLERANCE = 1.15; // demand within ±15 % of output counts as balanced const TREND = { '-1': '▼', 1: '▲' }; // last move of the material price /** * @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 current = null; // the state and derived values of the last refresh 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', (event) => { if (!actions.make()) return; pop(el.make); // The "+N" rises from the pointer (or from the button when used with the keyboard). const at = event.clientX || event.clientY ? { x: event.clientX, y: event.clientY } : undefined; floater(el.make, `+${fmt.number(current.d.clickYield)}`, at); }); el.buyMaterial.addEventListener('click', () => { if (actions.buyMaterial()) purchased(el.buyMaterial, '+'); }); el.buyMarketing.addEventListener('click', () => { if (actions.buyMarketing()) { purchased(el.buyMarketing, '+1'); bump(el.buyMarketingInfo); } }); 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 /** What a click on a generator block buys right now (at least one). */ function shownQuote(id) { const quote = generatorQuote(current.state, ctx, id, buyAmount); return quote.count > 0 ? quote : generatorQuote(current.state, ctx, id, 1); } function generatorRow(g) { const owned = h('span', { class: 'badge' }); const rate = h('span'); const amount = h('strong'); const cost = h('small'); 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), ), h('div', { class: 'card__price' }, amount, cost), ); makeBuyable(root, () => { const { count } = shownQuote(g.id); if (!actions.buyGenerator(g.id, buyAmount)) return; purchased(root, `+${fmt.number(count)}`); bump(owned); }); el.generators.append(root); return { root, owned, rate, amount, cost }; } function upgradeRow(u) { const cost = h('strong'); 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, fmt) }), ), h('div', { class: 'card__price' }, cost), ); makeBuyable(root, () => { if (!actions.buyUpgrade(u.id)) return; purchased(root, '✓'); bump(el.upgradeCount); }); return { root, cost, 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, `×${fmt.number(quote.count)}`); setText(row.cost, money(quote.cost)); setBuyable(row.root, 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)); setBuyable(row.root, state.funds >= row.upgrade.cost); } setText( el.upgradeCount, t('ui.upgradeCount', { owned: state.upgrades.length, total: ctx.upgrades.length }), ); } /** * Feedback on the price: what customers want versus what is produced, and * which side of the balance the player is on. It never gives the balanced * price itself: it depends on output and every bonus, so it must be found. */ function marketHint(d) { const demand = fmt.number(d.demand); if (d.rate <= 0) return t('ui.hintDemandOnly', { demand }); const ratio = d.demand / d.rate; let verdict = 'ui.marketBalanced'; if (ratio > MARKET_TOLERANCE) verdict = 'ui.marketShort'; else if (ratio < 1 / MARKET_TOLERANCE) verdict = 'ui.marketLong'; return `${t('ui.hintDemand', { demand, production: fmt.number(d.rate) })} ${t(verdict)}`; } function updateMarket(state, d) { const { 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))); } setText(el.priceHint, marketHint(d)); 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) { current = { state, d }; 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; const purchase = materialPurchase(state, d); // a partial lot when funds are short setText( el.buyMaterialInfo, t('ui.batchInfo', { amount: fmt.number(Math.floor(purchase.amount || d.batchSize)), cost: money(purchase.amount ? purchase.cost : d.batchCost), trend: TREND[state.materialTrend] ?? '', }), ); el.buyMaterial.disabled = purchase.amount <= 0; updateMarket(state, d); updateGenerators(state, d); updateUpgrades(state); } return { rebuild, update }; }