Files
idle_clicker/public/js/ui/view.js
T

270 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}