270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
// 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 };
|
||
}
|