feat: Simplification of UI and icons creation

This commit is contained in:
gauvainboiche
2026-10-08 16:52:21 +02:00
parent a88b8f5f14
commit 2f7c307f0e
22 changed files with 1099 additions and 408 deletions
-1
View File
@@ -124,7 +124,6 @@ export function stepEvents(state, ctx, dt, d, rng, offline, totals) {
totals.notices.push({ kind: 'event_incident', type: type.id });
} else {
ev.current = { type: type.id, remaining: cfg.claimSeconds };
totals.notices.push({ kind: 'event_opportunity', type: type.id });
}
}
+3 -4
View File
@@ -166,9 +166,6 @@ function announce(notice) {
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;
@@ -221,9 +218,11 @@ function render() {
// ------------------------------------------------------------------- actions
/** Wrap an engine action: run it on the current state, refresh the screen, report if it happened. */
const act = (fn) => (...args) => {
fn(state, ctx, ...args);
const done = fn(state, ctx, ...args);
render();
return done;
};
const view = createView({
+1 -1
View File
@@ -23,7 +23,7 @@ export function createAchievementsView(root) {
const li = h(
'li',
{ class: 'card' },
icon(`ach.${a.id}`),
icon('trophy'),
h(
'div',
{ class: 'card__body' },
+16 -8
View File
@@ -2,7 +2,8 @@
// and the board of offers.
import { t } from '../services/i18n.js';
import { h } from './dom.js';
import { h, makeBuyable, setBuyable } from './dom.js';
import { purchased } from './fx.js';
import { formatDuration, moneyFormatter, rewardText, setText } from './text.js';
const BUFF_LABEL = {
@@ -133,24 +134,31 @@ export function createContractsView(root, actions) {
if (signature !== offersSignature) {
offersSignature = signature;
els.offers.replaceChildren(
...offers.map((offer, index) =>
h(
...offers.map((offer, index) => {
const li = h(
'li',
{ class: 'card' },
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title', text: t(`contracts.template.${offer.template}`) }),
h('p', { class: 'card__desc', text: t('contracts.goal', { target: fmt.number(offer.target), duration: formatDuration(offer.seconds) }) }),
h('p', {
class: 'card__desc',
text: t('contracts.goal', { target: fmt.number(offer.target), duration: formatDuration(offer.seconds) }),
}),
h('p', { class: 'card__meta', text: rewardText(offer.reward, fmt, offer.seconds) }),
),
h('button', { class: 'btn btn--buy', type: 'button', text: t('contracts.accept'), 'data-index': index, onclick: () => actions.accept(index) }),
),
),
h('div', { class: 'card__price' }, h('strong', { text: t('contracts.accept') })),
);
makeBuyable(li, () => {
if (actions.accept(index)) purchased(li, '✓', null);
});
return li;
}),
);
}
els.offersNone.hidden = offers.length > 0;
for (const button of els.offers.querySelectorAll('button')) button.disabled = Boolean(state.contracts.active);
for (const li of els.offers.children) setBuyable(li, !state.contracts.active);
}
function update(state) {
+28
View File
@@ -39,3 +39,31 @@ export function icon(key) {
export function refreshStaticIcons(root = document) {
for (const el of root.querySelectorAll('.resourcebar [data-icon]')) el.replaceWith(icon(el.dataset.icon));
}
// ------------------------------------------------------------ buyable blocks
// A block you click to buy (instead of a button inside it): it is announced as
// a button, reachable with the keyboard (Enter / Space), outlined when hovered
// and greyed out when it cannot be bought.
/** Make `el` act as one big button. `activate(event)` runs on click, Enter or Space. */
export function makeBuyable(el, activate) {
el.classList.add('card--buy');
el.setAttribute('role', 'button');
el.tabIndex = 0;
const allowed = () => el.getAttribute('aria-disabled') !== 'true';
el.addEventListener('click', (event) => {
if (allowed()) activate(event);
});
el.addEventListener('keydown', (event) => {
if ((event.key === 'Enter' || event.key === ' ') && event.target === el) {
event.preventDefault();
if (allowed()) activate(event);
}
});
}
/** Show whether a buyable block can currently be bought. */
export function setBuyable(el, canBuy) {
el.classList.toggle('is-disabled', !canBuy);
el.setAttribute('aria-disabled', String(!canBuy));
}
+17 -14
View File
@@ -2,7 +2,8 @@
import { endlessQuote, lineAvailable, lineLevel } from '../engine/endless.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { h, icon, makeBuyable, setBuyable } from './dom.js';
import { bump, purchased } from './fx.js';
import { effectText, moneyFormatter, setText } from './text.js';
const AMOUNTS = [1, 10, 100, 'max'];
@@ -22,14 +23,8 @@ export function createEndlessView(root, actions) {
function rowFor(line) {
const level = h('span', { class: 'badge' });
const total = h('p', { class: 'card__meta' });
const label = h('span', { class: 'btn__label' });
const label = h('strong');
const cost = h('small');
const buy = h(
'button',
{ class: 'btn btn--buy', type: 'button', onclick: () => actions.buy(line.id, amount) },
label,
cost,
);
const li = h(
'li',
{ class: 'card', hidden: true },
@@ -41,9 +36,15 @@ export function createEndlessView(root, actions) {
h('p', { class: 'card__desc', text: t('endless.perLevel', { effect: effectText(line.effect, fmt) }) }),
total,
),
buy,
h('div', { class: 'card__price' }, label, cost),
);
return { li, level, total, label, cost, buy, line };
const row = { li, level, total, label, cost, line, shown: 1 };
makeBuyable(li, () => {
if (!actions.buy(line.id, amount)) return;
purchased(li, `+${fmt.number(row.shown)}`);
bump(level);
});
return row;
}
function rebuild(newCtx, formatter) {
@@ -87,14 +88,15 @@ export function createEndlessView(root, actions) {
h('h2', { class: 'panel__title', text: t('endless.title') }),
amountGroup,
),
h('p', { class: 'hint', text: t('endless.intro') }),
h('p', { class: 'hint', text: `${t('endless.intro')} ${t('ui.clickToBuy')}` }),
list,
);
}
function update(state) {
let any = false;
for (const { li, level, total, label, cost, buy, line } of rows.values()) {
for (const row of rows.values()) {
const { li, level, total, label, cost, line } = row;
const open = lineAvailable(state, line);
li.hidden = !open;
if (!open) continue;
@@ -112,9 +114,10 @@ export function createEndlessView(root, actions) {
? t('endless.total', { effect: effectText({ ...line.effect, value: line.effect.value ** lv }, fmt) })
: t('endless.none'),
);
setText(label, maxed ? t('endless.maxed') : t('endless.buy', { count: fmt.number(quote.count) }));
row.shown = quote.count;
setText(label, maxed ? t('endless.maxed') : `×${fmt.number(quote.count)}`);
setText(cost, maxed ? '' : money(quote.cost));
buy.disabled = maxed || quote.count <= 0 || !(state.funds >= quote.cost);
setBuyable(li, !maxed && quote.count > 0 && state.funds >= quote.cost);
}
root.hidden = !any;
}
+54
View File
@@ -0,0 +1,54 @@
// Quick feedback animations (0.15 to 0.25 s), so buying feels immediate.
// All of them are plain CSS animations restarted from here; users who prefer
// reduced motion get none (see components.css).
const FALLBACK_MS = 400; // clean-up if the browser never fires animationend
/** Restart a one-shot CSS animation class on an element. */
function play(el, className) {
if (!el) return;
el.classList.remove(className);
void el.offsetWidth; // let the browser notice the class was removed
el.classList.add(className);
const done = () => el.classList.remove(className);
el.addEventListener('animationend', done, { once: true });
setTimeout(done, FALLBACK_MS);
}
/** A block that was just bought: a quick pop and an accent glow. */
export const pop = (el) => play(el, 'fx-pop');
/** A number that just changed: a quick bump. */
export const bump = (el) => play(el, 'fx-bump');
/** A resource that was just spent: a quick flash. */
export const spend = (el) => play(el, 'fx-spend');
/**
* A short "+3" that rises from the middle of `anchor` (or from a point) and fades.
* `at` = { x, y } in viewport coordinates, used when given instead of an element.
* `tone` ('good' | 'bad') colours it green or red; by default it takes the accent colour.
*/
export function floater(anchor, text, at, tone) {
const rect = at ? { left: at.x, top: at.y, width: 0 } : anchor?.getBoundingClientRect();
if (!rect) return;
const el = document.createElement('span');
el.className = tone ? `floater floater--${tone}` : 'floater';
el.textContent = text;
el.style.left = `${rect.left + rect.width / 2}px`;
el.style.top = `${rect.top}px`;
document.body.append(el);
el.addEventListener('animationend', () => el.remove(), { once: true });
setTimeout(() => el.remove(), FALLBACK_MS);
}
/**
* Pop a bought block, float a label above it and flash what was spent (the funds
* in the top bar unless `spentId` says otherwise, or is null for no flash).
*/
export function purchased(el, label, spentId = 'v-funds') {
pop(el);
floater(el, label);
if (spentId) spend(document.getElementById(spentId));
return true;
}
+22 -2
View File
@@ -4,6 +4,7 @@ import { chipCost, odds, reliabilityCost } from '../engine/gamble.js';
import { chipDemandBonus } from '../engine/formulas.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { bump, floater, pop, purchased } from './fx.js';
import { moneyFormatter, setText } from './text.js';
const STAKES = [
@@ -43,6 +44,10 @@ export function createGambleView(root, actions) {
const result = actions.run(stakeCount(state.chips));
if (!result) return;
log.unshift(result);
pop(els.run);
const sign = result.delta > 0 ? '+' : '−';
floater(els.run, `${sign}${fmt.number(Math.abs(result.delta))}`, undefined, result.delta > 0 ? 'good' : 'bad');
bump(els.chips);
log.length = Math.min(log.length, LOG_SIZE);
renderLog();
}
@@ -57,8 +62,23 @@ export function createGambleView(root, actions) {
els = {
chips: h('strong'),
bonus: h('span', { class: 'hint' }),
buyChip: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyChip() }),
reliability: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyReliability() }),
buyChip: h('button', {
class: 'btn btn--wide',
type: 'button',
onclick: () => {
if (actions.buyChip()) {
purchased(els.buyChip, '+1');
bump(els.chips);
}
},
}),
reliability: h('button', {
class: 'btn btn--wide',
type: 'button',
onclick: () => {
if (actions.buyReliability()) purchased(els.reliability, '+1');
},
}),
odds: h('p', { class: 'hint' }),
run: h('button', { class: 'btn btn--primary btn--big', type: 'button', onclick: () => run(lastState) }),
log: h('ul', { class: 'log' }),
+18 -11
View File
@@ -2,7 +2,8 @@
import { portfolio } from '../engine/investments.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { h, icon, makeBuyable, setBuyable } from './dom.js';
import { bump, floater, pop, purchased } from './fx.js';
import { moneyFormatter, setText, signedPercent } from './text.js';
const AMOUNTS = [0.01, 0.1, 0.5, 1];
@@ -20,17 +21,18 @@ export function createInvestView(root, actions) {
const change = h('span', { class: 'badge' });
const price = h('span');
const held = h('span');
const buy = h('button', {
class: 'btn',
type: 'button',
text: t('invest.buy'),
onclick: () => actions.buy(society.id, amount),
});
const sell = h('button', {
class: 'btn',
type: 'button',
text: t('invest.sell'),
onclick: () => actions.sell(society.id, amount),
onclick: (event) => {
event.stopPropagation(); // the block itself buys
if (actions.sell(society.id, amount)) {
pop(root);
floater(sell, '↘');
bump(held);
}
},
});
const root = h(
'li',
@@ -47,9 +49,14 @@ export function createInvestView(root, actions) {
h('p', { class: 'card__meta' }, held),
),
),
h('div', { class: 'card__actions' }, buy, sell),
h('div', { class: 'card__actions' }, h('span', { class: 'card__hint', text: t('invest.clickToBuy') }), sell),
);
return { root, change, price, held, buy, sell };
makeBuyable(root, () => {
if (!actions.buy(society.id, amount)) return;
purchased(root, '↗');
bump(held);
});
return { root, change, price, held, sell };
}
function tierPanel(tier) {
@@ -172,7 +179,7 @@ export function createInvestView(root, actions) {
? t('invest.heldLine', { value: money(value), profit: signedPercent(basis > 0 ? value / basis - 1 : 0, fmt) })
: t('invest.heldNone'),
);
row.buy.disabled = state.funds < 0.01;
setBuyable(row.root, state.funds >= 0.01);
row.sell.disabled = shares <= 0;
}
}
+19 -8
View File
@@ -2,7 +2,8 @@
import { legacyGain, perkCost, perkLevel } from '../engine/legacy.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { h, icon, makeBuyable, setBuyable } from './dom.js';
import { bump, purchased } from './fx.js';
import { effectText, setText } from './text.js';
export function createLegacyView(root, actions) {
@@ -30,8 +31,7 @@ export function createLegacyView(root, actions) {
const perks = h('ul', { class: 'cards' });
for (const perk of ctx.legacy) {
const level = h('span', { class: 'badge' });
const cost = h('span', { class: 'btn__label' });
const buy = h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyPerk(perk.id) }, cost);
const cost = h('strong');
const li = h(
'li',
{ class: 'card' },
@@ -42,10 +42,15 @@ export function createLegacyView(root, actions) {
h('h3', { class: 'card__title' }, h('span', { text: t(`legacy.perk.${perk.id}`) }), level),
h('p', { class: 'card__desc', text: effectText(perk.effect, fmt) }),
),
buy,
h('div', { class: 'card__price' }, cost),
);
makeBuyable(li, () => {
if (!actions.buyPerk(perk.id)) return;
purchased(li, '+1', null);
bump(level);
});
perks.append(li);
perkRows.set(perk.id, { perk, level, cost, buy });
perkRows.set(perk.id, { perk, li, level, cost });
}
root.replaceChildren(
@@ -68,7 +73,13 @@ export function createLegacyView(root, actions) {
els.need,
els.restart,
),
h('section', { class: 'panel' }, h('h2', { class: 'panel__title', text: t('legacy.perks') }), perks),
h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('legacy.perks') }),
h('p', { class: 'hint', text: t('ui.clickToBuy') }),
perks,
),
);
}
@@ -92,13 +103,13 @@ export function createLegacyView(root, actions) {
setText(els.restart, t('legacy.restart'));
els.restart.disabled = gain < 1;
for (const { perk, level, cost, buy } of perkRows.values()) {
for (const { perk, li, level, cost } of perkRows.values()) {
const lv = perkLevel(state, perk);
const maxed = lv >= perk.maxLevel;
const price = perkCost(perk, lv);
setText(level, t('legacy.level', { level: lv, max: perk.maxLevel }));
setText(cost, maxed ? t('legacy.maxed') : t('legacy.perkCost', { cost: fmt.number(price) }));
buy.disabled = maxed || state.legacy.points < price;
setBuyable(li, !maxed && state.legacy.points >= price);
}
}
+21 -14
View File
@@ -2,7 +2,8 @@
import { labCost, researchStatus } from '../engine/research.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { h, icon, makeBuyable, setBuyable } from './dom.js';
import { purchased } from './fx.js';
import { effectText, moneyFormatter, setText } from './text.js';
const BRANCHES = ['prod', 'mkt', 'log', 'fin', 'ins'];
@@ -16,11 +17,8 @@ export function createResearchView(root, actions) {
function nodeRow(node) {
const status = h('p', { class: 'card__meta' });
const buy = h('button', {
class: 'btn btn--buy',
type: 'button',
onclick: () => actions.buyNode(node.id),
});
const cost = h('strong');
const price = h('div', { class: 'card__price' }, cost);
const li = h(
'li',
{ class: 'card' },
@@ -32,9 +30,12 @@ export function createResearchView(root, actions) {
h('p', { class: 'card__desc', text: effectText(node.effect, fmt) }),
status,
),
buy,
price,
);
return { li, status, buy, node };
makeBuyable(li, () => {
if (actions.buyNode(node.id)) purchased(li, '✓', null);
});
return { li, status, price, cost, node };
}
function rebuild(newCtx, formatter) {
@@ -47,7 +48,13 @@ export function createResearchView(root, actions) {
rp: h('strong'),
rate: h('span', { class: 'hint' }),
labs: h('span', { class: 'hint' }),
buyLab: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyLab() }),
buyLab: h('button', {
class: 'btn btn--wide',
type: 'button',
onclick: () => {
if (actions.buyLab()) purchased(els.buyLab, '+1');
},
}),
};
const branchPanels = BRANCHES.map((branch) => {
@@ -70,7 +77,7 @@ export function createResearchView(root, actions) {
'section',
{ class: 'panel panel--wide' },
h('h2', { class: 'panel__title', text: t('tab.research') }),
h('p', { class: 'hint', text: t('research.intro') }),
h('p', { class: 'hint', text: `${t('research.intro')} ${t('ui.clickToBuy')}` }),
h(
'div',
{ class: 'tile' },
@@ -95,13 +102,13 @@ export function createResearchView(root, actions) {
setText(els.buyLab, maxed ? t('research.labMax') : t('research.buyLab', { cost: money(cost) }));
els.buyLab.disabled = maxed || state.funds < cost;
for (const { li, status, buy, node } of nodeRows.values()) {
for (const { li, status, price, cost, node } of nodeRows.values()) {
const s = researchStatus(state, node);
li.classList.toggle('card--done', s === 'owned');
li.classList.toggle('card--locked', s === 'locked');
buy.hidden = s !== 'available';
buy.disabled = state.rp < node.cost;
setText(buy, t('research.cost', { cost: fmt.number(node.cost) }));
price.hidden = s !== 'available';
setText(cost, t('research.cost', { cost: fmt.number(node.cost) }));
setBuyable(li, s === 'available' && state.rp >= node.cost);
if (s === 'owned') setText(status, t('research.owned'));
else if (s === 'locked') {
const names = node.requires.map((id) => t(`research.node.${id}`)).join(', ');
+47 -30
View File
@@ -3,15 +3,13 @@
import { generatorQuote, isUnlocked, materialPurchase, upgradeVisible } from '../engine/engine.js';
import { t } from '../services/i18n.js';
import { $, h, icon, refreshStaticIcons } from './dom.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
function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
}
const TREND = { '-1': '▼', 1: '▲' }; // last move of the material price
/**
* @param actions callbacks into the game: make, buyMaterial, buyMarketing,
@@ -44,6 +42,7 @@ export function createView(actions) {
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;
@@ -53,9 +52,22 @@ export function createView(actions) {
// ---------------------------------------------------------------- controls
el.make.addEventListener('click', () => actions.make());
el.buyMaterial.addEventListener('click', () => actions.buyMaterial());
el.buyMarketing.addEventListener('click', () => actions.buyMarketing());
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;
@@ -78,24 +90,17 @@ export function createView(actions) {
// ------------------------------------------------------------------- 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);
/** 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('span', { class: 'btn__label' });
const amount = h('strong');
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 },
@@ -107,14 +112,20 @@ export function createView(actions) {
h('p', { class: 'card__desc', text: t(`theme.gen.${g.id}.desc`) }),
h('p', { class: 'card__meta' }, rate),
),
buy,
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, buy };
return { root, owned, rate, amount, cost };
}
function upgradeRow(u) {
const cost = h('small');
const cost = h('strong');
const root = h(
'li',
{ class: 'card' },
@@ -123,11 +134,16 @@ export function createView(actions) {
'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('p', { class: 'card__desc', text: effectText(u.effect, fmt) }),
),
h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyUpgrade(u.id) }, cost),
h('div', { class: 'card__price' }, cost),
);
return { root, cost, buy: root.lastElementChild, upgrade: u };
makeBuyable(root, () => {
if (!actions.buyUpgrade(u.id)) return;
purchased(root, '✓');
bump(el.upgradeCount);
});
return { root, cost, upgrade: u };
}
// ------------------------------------------------------------------ public
@@ -160,9 +176,9 @@ export function createView(actions) {
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.amount, `×${fmt.number(quote.count)}`);
setText(row.cost, money(quote.cost));
row.buy.disabled = state.funds < quote.cost;
setBuyable(row.root, state.funds >= quote.cost);
}
}
@@ -177,7 +193,7 @@ export function createView(actions) {
}
for (const row of upgradeRows.values()) {
setText(row.cost, money(row.upgrade.cost));
row.buy.disabled = state.funds < row.upgrade.cost;
setBuyable(row.root, state.funds >= row.upgrade.cost);
}
setText(
el.upgradeCount,
@@ -222,6 +238,7 @@ export function createView(actions) {
/** @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)));