feat: New features (research, invest, gambling, new game+, trophies)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
// Achievements tab: milestones with a small permanent bonus each.
|
||||
|
||||
import { statValue } from '../engine/achievements.js';
|
||||
import { t } from '../services/i18n.js';
|
||||
import { h, icon } from './dom.js';
|
||||
import { effectText, setText } from './text.js';
|
||||
|
||||
export function createAchievementsView(root) {
|
||||
let ctx;
|
||||
let fmt;
|
||||
let summary;
|
||||
const rows = new Map();
|
||||
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
rows.clear();
|
||||
summary = h('p', { class: 'hint' });
|
||||
|
||||
const list = h('ul', { class: 'cards cards--grid' });
|
||||
for (const a of ctx.achievements) {
|
||||
const progress = h('p', { class: 'card__meta' });
|
||||
const li = h(
|
||||
'li',
|
||||
{ class: 'card' },
|
||||
icon(`ach.${a.id}`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__body' },
|
||||
h('h3', { class: 'card__title', text: t(`ach.title.${a.id}`) }),
|
||||
h('p', { class: 'card__desc', text: t(`ach.cond.${a.condition.stat}`, { value: fmt.number(a.condition.min) }) }),
|
||||
h('p', { class: 'card__meta', text: effectText(a.effect, fmt) }),
|
||||
progress,
|
||||
),
|
||||
);
|
||||
list.append(li);
|
||||
rows.set(a.id, { a, li, progress });
|
||||
}
|
||||
root.replaceChildren(
|
||||
h('section', { class: 'panel panel--wide' }, h('h2', { class: 'panel__title', text: t('tab.achievements') }), summary, list),
|
||||
);
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
const bonus = state.achievements.reduce((product, id) => product * ctx.achievementsById.get(id).effect.value, 1);
|
||||
setText(summary, t('ach.summary', { done: state.achievements.length, total: ctx.achievements.length, bonus: fmt.number(bonus) }));
|
||||
for (const { a, li, progress } of rows.values()) {
|
||||
const done = state.achievements.includes(a.id);
|
||||
li.classList.toggle('card--done', done);
|
||||
li.classList.toggle('card--locked', !done);
|
||||
const value = Math.min(statValue(state, a.condition.stat), a.condition.min);
|
||||
setText(progress, done ? t('ach.done') : `${fmt.number(Math.floor(value))} / ${fmt.number(a.condition.min)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Contracts tab: opportunities to seize, active effects, the active contract
|
||||
// and the board of offers.
|
||||
|
||||
import { t } from '../services/i18n.js';
|
||||
import { h } from './dom.js';
|
||||
import { formatDuration, moneyFormatter, rewardText, setText } from './text.js';
|
||||
|
||||
const BUFF_LABEL = {
|
||||
demand_mult: 'contracts.buff.demand',
|
||||
global_mult: 'contracts.buff.production',
|
||||
material_price_mult: 'contracts.buff.material',
|
||||
};
|
||||
|
||||
export function createContractsView(root, actions) {
|
||||
let ctx;
|
||||
let fmt;
|
||||
let els;
|
||||
let offersSignature = '';
|
||||
let buffsSignature = '';
|
||||
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
offersSignature = '';
|
||||
buffsSignature = '';
|
||||
|
||||
els = {
|
||||
eventTitle: h('h3', { class: 'card__title' }),
|
||||
eventText: h('p', { class: 'card__desc' }),
|
||||
eventTime: h('p', { class: 'card__meta' }),
|
||||
claim: h('button', { class: 'btn btn--primary', type: 'button', text: t('contracts.claim'), onclick: () => actions.claimEvent() }),
|
||||
eventBox: h('div', { class: 'callout' }),
|
||||
eventNone: h('p', { class: 'hint', text: t('contracts.eventNone') }),
|
||||
buffs: h('ul', { class: 'cards' }),
|
||||
activeBox: h('div', { class: 'callout' }),
|
||||
activeTitle: h('h3', { class: 'card__title' }),
|
||||
activeReward: h('p', { class: 'card__desc' }),
|
||||
activeProgress: h('progress', { class: 'progress', max: 1, value: 0 }),
|
||||
activeText: h('p', { class: 'card__meta' }),
|
||||
abandon: h('button', { class: 'btn', type: 'button', text: t('contracts.abandon'), onclick: () => actions.abandon() }),
|
||||
activeNone: h('p', { class: 'hint', text: t('contracts.activeNone') }),
|
||||
offers: h('ul', { class: 'cards' }),
|
||||
offersNone: h('p', { class: 'hint', text: t('contracts.offersNone') }),
|
||||
};
|
||||
els.eventBox.append(els.eventTitle, els.eventText, els.eventTime, els.claim);
|
||||
els.activeBox.append(els.activeTitle, els.activeReward, els.activeProgress, els.activeText, els.abandon);
|
||||
|
||||
root.replaceChildren(
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
h('h2', { class: 'panel__title', text: t('contracts.events') }),
|
||||
els.eventBox,
|
||||
els.eventNone,
|
||||
h('h3', { class: 'dialog__subtitle', text: t('contracts.effects') }),
|
||||
els.buffs,
|
||||
),
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
h('h2', { class: 'panel__title', text: t('contracts.active') }),
|
||||
els.activeBox,
|
||||
els.activeNone,
|
||||
h('h3', { class: 'dialog__subtitle', text: t('contracts.board') }),
|
||||
h('p', { class: 'hint', text: t('contracts.boardInfo', { seconds: ctx.rules.contracts.offerEverySeconds }) }),
|
||||
els.offers,
|
||||
els.offersNone,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function eventText(type) {
|
||||
const cfg = ctx.rules.events.types.find((e) => e.id === type);
|
||||
return t(`event.${type}.text`, {
|
||||
value: cfg?.buff ? fmt.number(cfg.buff.value) : '',
|
||||
seconds: cfg?.buff ? cfg.buff.seconds : '',
|
||||
});
|
||||
}
|
||||
|
||||
function updateEvent(state) {
|
||||
const current = state.events.current;
|
||||
els.eventBox.hidden = !current;
|
||||
els.eventNone.hidden = Boolean(current);
|
||||
if (!current) return;
|
||||
setText(els.eventTitle, t(`event.${current.type}.title`));
|
||||
setText(els.eventText, eventText(current.type));
|
||||
setText(els.eventTime, t('contracts.timeLeft', { time: formatDuration(current.remaining) }));
|
||||
}
|
||||
|
||||
function updateBuffs(state) {
|
||||
const signature = state.events.buffs.map((b) => `${b.type}:${b.value}`).join();
|
||||
if (signature !== buffsSignature) {
|
||||
buffsSignature = signature;
|
||||
els.buffs.replaceChildren(
|
||||
...state.events.buffs.map((b) =>
|
||||
h(
|
||||
'li',
|
||||
{ class: 'card' },
|
||||
h('div', { class: 'card__body' }, h('h3', { class: 'card__title', text: t(BUFF_LABEL[b.type], { value: fmt.number(b.value) }) }), h('p', { class: 'card__meta' })),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
[...els.buffs.children].forEach((li, i) => {
|
||||
const buff = state.events.buffs[i];
|
||||
if (buff) setText(li.querySelector('.card__meta'), t('contracts.timeLeft', { time: formatDuration(buff.remaining) }));
|
||||
});
|
||||
}
|
||||
|
||||
function updateActive(state) {
|
||||
const active = state.contracts.active;
|
||||
els.activeBox.hidden = !active;
|
||||
els.activeNone.hidden = Boolean(active);
|
||||
if (!active) return;
|
||||
const { offer } = active;
|
||||
setText(els.activeTitle, t(`contracts.template.${offer.template}`));
|
||||
setText(els.activeReward, rewardText(offer.reward, fmt, offer.seconds));
|
||||
els.activeProgress.max = offer.target;
|
||||
els.activeProgress.value = active.progress;
|
||||
setText(
|
||||
els.activeText,
|
||||
t('contracts.progress', {
|
||||
done: fmt.number(Math.floor(active.progress)),
|
||||
target: fmt.number(offer.target),
|
||||
time: formatDuration(active.remaining),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function updateOffers(state) {
|
||||
const offers = state.contracts.offers;
|
||||
const signature = offers.map((o) => `${o.template}:${o.target}:${o.reward.type}:${o.reward.amount}`).join();
|
||||
if (signature !== offersSignature) {
|
||||
offersSignature = signature;
|
||||
els.offers.replaceChildren(
|
||||
...offers.map((offer, index) =>
|
||||
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__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) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
els.offersNone.hidden = offers.length > 0;
|
||||
for (const button of els.offers.querySelectorAll('button')) button.disabled = Boolean(state.contracts.active);
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
updateEvent(state);
|
||||
updateBuffs(state);
|
||||
updateActive(state);
|
||||
updateOffers(state);
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
+2
-5
@@ -35,10 +35,7 @@ export function icon(key) {
|
||||
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } });
|
||||
}
|
||||
|
||||
/** Re-resolve the icons that live in index.html (resource tiles). */
|
||||
/** Re-resolve the icons that live in index.html (the resource bar). */
|
||||
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));
|
||||
}
|
||||
for (const el of root.querySelectorAll('.resourcebar [data-icon]')) el.replaceWith(icon(el.dataset.icon));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Gamble tab: chips are hard to buy and hard to keep. Stake them on a cycle.
|
||||
|
||||
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 { moneyFormatter, setText } from './text.js';
|
||||
|
||||
const STAKES = [
|
||||
{ id: 'one', count: () => 1 },
|
||||
{ id: 'tenth', fraction: 0.1 },
|
||||
{ id: 'half', fraction: 0.5 },
|
||||
{ id: 'all', fraction: 1 },
|
||||
];
|
||||
const LOG_SIZE = 6;
|
||||
|
||||
export function createGambleView(root, actions) {
|
||||
let ctx;
|
||||
let fmt;
|
||||
let money;
|
||||
let stake = STAKES[0];
|
||||
let els;
|
||||
const log = [];
|
||||
|
||||
const stakeCount = (chips) => (stake.count ? stake.count() : Math.max(1, Math.ceil(chips * stake.fraction)));
|
||||
|
||||
function stakeLabel(option) {
|
||||
if (option.id === 'one') return t('gamble.stakeOne');
|
||||
if (option.id === 'all') return t('gamble.stakeAll');
|
||||
return t('gamble.stakePercent', { percent: option.fraction * 100 });
|
||||
}
|
||||
|
||||
function renderLog() {
|
||||
els.log.replaceChildren(
|
||||
...log.map((entry) =>
|
||||
h('li', { class: `log__entry log__entry--${entry.outcome}`, text: t(`gamble.result.${entry.outcome}`, { amount: fmt.number(Math.abs(entry.delta)) }) }),
|
||||
),
|
||||
);
|
||||
els.logEmpty.hidden = log.length > 0;
|
||||
}
|
||||
|
||||
function run(state) {
|
||||
const result = actions.run(stakeCount(state.chips));
|
||||
if (!result) return;
|
||||
log.unshift(result);
|
||||
log.length = Math.min(log.length, LOG_SIZE);
|
||||
renderLog();
|
||||
}
|
||||
|
||||
let lastState;
|
||||
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
money = moneyFormatter(fmt);
|
||||
|
||||
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() }),
|
||||
odds: h('p', { class: 'hint' }),
|
||||
run: h('button', { class: 'btn btn--primary btn--big', type: 'button', onclick: () => run(lastState) }),
|
||||
log: h('ul', { class: 'log' }),
|
||||
logEmpty: h('p', { class: 'hint', text: t('gamble.logEmpty') }),
|
||||
};
|
||||
|
||||
root.replaceChildren(
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
h('h2', { class: 'panel__title', text: t('theme.tab.gamble') }),
|
||||
h('p', { class: 'hint', text: t('gamble.intro') }),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'tile' },
|
||||
icon('res.chips'),
|
||||
h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.chips') }), els.chips, els.bonus),
|
||||
),
|
||||
els.buyChip,
|
||||
els.reliability,
|
||||
),
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
h('h2', { class: 'panel__title', text: t('gamble.play') }),
|
||||
h('p', { class: 'field__label', text: t('gamble.stake') }),
|
||||
h(
|
||||
'fieldset',
|
||||
{ class: 'segmented' },
|
||||
h('legend', { class: 'sr-only', text: t('gamble.stake') }),
|
||||
...STAKES.map((option) =>
|
||||
h(
|
||||
'label',
|
||||
{},
|
||||
h('input', {
|
||||
type: 'radio',
|
||||
name: 'gamble-stake',
|
||||
value: option.id,
|
||||
checked: option === stake,
|
||||
onchange: () => {
|
||||
stake = option;
|
||||
},
|
||||
}),
|
||||
h('span', { text: stakeLabel(option) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
els.odds,
|
||||
els.run,
|
||||
h('h3', { class: 'dialog__subtitle', text: t('gamble.log') }),
|
||||
els.logEmpty,
|
||||
els.log,
|
||||
),
|
||||
);
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function update(state, d) {
|
||||
lastState = state;
|
||||
const g = ctx.rules.gamble;
|
||||
|
||||
setText(els.chips, fmt.number(state.chips));
|
||||
setText(els.bonus, t('gamble.bonus', { bonus: fmt.number(chipDemandBonus(state.chips, g)) }));
|
||||
|
||||
const cost = chipCost(state, ctx);
|
||||
setText(els.buyChip, t('gamble.buyChip', { cost: money(cost) }));
|
||||
els.buyChip.disabled = state.funds < cost || state.chips >= g.maxChips;
|
||||
|
||||
const maxed = state.reliabilityLevel >= g.reliability.maxLevel;
|
||||
setText(
|
||||
els.reliability,
|
||||
maxed
|
||||
? t('gamble.reliabilityMax', { level: state.reliabilityLevel })
|
||||
: t('gamble.reliability', {
|
||||
level: state.reliabilityLevel,
|
||||
max: g.reliability.maxLevel,
|
||||
cost: money(reliabilityCost(state, ctx)),
|
||||
}),
|
||||
);
|
||||
els.reliability.disabled = maxed || state.funds < reliabilityCost(state, ctx);
|
||||
|
||||
const p = odds(state, ctx, d.mods);
|
||||
const percent = (x) => fmt.number(Math.round(x * 1000) / 10);
|
||||
setText(
|
||||
els.odds,
|
||||
t('gamble.odds', {
|
||||
win: percent(p.win),
|
||||
jackpot: percent(p.jackpot),
|
||||
multiplier: g.jackpotMultiplier,
|
||||
crash: percent(p.crash),
|
||||
lose: percent(p.lose),
|
||||
}),
|
||||
);
|
||||
|
||||
const cooling = state.gambleCooldown > 0;
|
||||
setText(
|
||||
els.run,
|
||||
cooling
|
||||
? t('gamble.cooldown', { seconds: fmt.number(Math.ceil(state.gambleCooldown * 10) / 10) })
|
||||
: `${t('theme.action.gamble')} (${fmt.number(Math.min(stakeCount(state.chips), state.chips))})`,
|
||||
);
|
||||
els.run.disabled = cooling || state.chips < 1;
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Investments tab: nine societies in three risk tiers, all visible at once.
|
||||
|
||||
import { portfolio } from '../engine/investments.js';
|
||||
import { t } from '../services/i18n.js';
|
||||
import { h, icon } from './dom.js';
|
||||
import { moneyFormatter, setText, signedPercent } from './text.js';
|
||||
|
||||
const AMOUNTS = [0.01, 0.1, 0.5, 1];
|
||||
const TIERS = ['low', 'mid', 'high'];
|
||||
|
||||
export function createInvestView(root, actions) {
|
||||
let ctx;
|
||||
let fmt;
|
||||
let money;
|
||||
let amount = 0.1;
|
||||
let summary;
|
||||
const rows = new Map();
|
||||
|
||||
function societyRow(society) {
|
||||
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),
|
||||
});
|
||||
const root = h(
|
||||
'li',
|
||||
{ class: 'card card--stack' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__row' },
|
||||
icon(`inv.${society.id}`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__body' },
|
||||
h('h3', { class: 'card__title' }, h('span', { text: t(`theme.inv.${society.id}.name`) }), change),
|
||||
h('p', { class: 'card__meta' }, price),
|
||||
h('p', { class: 'card__meta' }, held),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'card__actions' }, buy, sell),
|
||||
);
|
||||
return { root, change, price, held, buy, sell };
|
||||
}
|
||||
|
||||
function tierPanel(tier) {
|
||||
const cfg = ctx.investments.tiers[tier];
|
||||
const list = h('ul', { class: 'cards' });
|
||||
for (const society of ctx.investments.societies.filter((s) => s.tier === tier)) {
|
||||
const row = societyRow(society);
|
||||
rows.set(society.id, row);
|
||||
list.append(row.root);
|
||||
}
|
||||
return h(
|
||||
'section',
|
||||
{ class: `panel panel--${tier}` },
|
||||
h('h2', { class: 'panel__title', text: t(`invest.tier.${tier}`) }),
|
||||
h('p', {
|
||||
class: 'hint',
|
||||
text: t('invest.tierInfo', {
|
||||
swing: fmt.number(cfg.swing * 100),
|
||||
dividend: fmt.number(cfg.dividend * 100),
|
||||
}),
|
||||
}),
|
||||
list,
|
||||
);
|
||||
}
|
||||
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
money = moneyFormatter(fmt);
|
||||
rows.clear();
|
||||
|
||||
summary = {
|
||||
value: h('strong'),
|
||||
basis: h('strong'),
|
||||
profit: h('strong'),
|
||||
next: h('p', { class: 'hint' }),
|
||||
};
|
||||
const amountGroup = h(
|
||||
'fieldset',
|
||||
{ class: 'segmented' },
|
||||
h('legend', { class: 'sr-only', text: t('invest.amount') }),
|
||||
...AMOUNTS.map((a) =>
|
||||
h(
|
||||
'label',
|
||||
{},
|
||||
h('input', {
|
||||
type: 'radio',
|
||||
name: 'invest-amount',
|
||||
value: String(a),
|
||||
checked: a === amount,
|
||||
onchange: () => {
|
||||
amount = a;
|
||||
},
|
||||
}),
|
||||
h('span', { text: `${a * 100}%` }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
root.replaceChildren(
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel panel--wide' },
|
||||
h('h2', { class: 'panel__title', text: t('tab.invest') }),
|
||||
h('p', {
|
||||
class: 'hint',
|
||||
text: t('invest.intro', {
|
||||
seconds: ctx.investments.intervalSeconds,
|
||||
fee: fmt.number(ctx.investments.fee * 100),
|
||||
}),
|
||||
}),
|
||||
h(
|
||||
'dl',
|
||||
{ class: 'stats' },
|
||||
h('div', {}, h('dt', { text: t('invest.portfolio') }), h('dd', {}, summary.value)),
|
||||
h('div', {}, h('dt', { text: t('invest.invested') }), h('dd', {}, summary.basis)),
|
||||
h('div', {}, h('dt', { text: t('invest.profit') }), h('dd', {}, summary.profit)),
|
||||
),
|
||||
h('p', { class: 'field__label', text: t('invest.amountLabel') }),
|
||||
amountGroup,
|
||||
summary.next,
|
||||
),
|
||||
...TIERS.map(tierPanel),
|
||||
);
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
const p = portfolio(state, ctx);
|
||||
setText(summary.value, money(p.value));
|
||||
setText(summary.basis, money(p.basis));
|
||||
setText(summary.profit, money(p.profit));
|
||||
summary.profit.classList.toggle('pos', p.profit > 0);
|
||||
summary.profit.classList.toggle('neg', p.profit < 0);
|
||||
const remaining = Math.ceil(ctx.investments.intervalSeconds - state.investTimer);
|
||||
setText(summary.next, t('invest.nextUpdate', { seconds: remaining }));
|
||||
|
||||
for (const society of ctx.investments.societies) {
|
||||
const row = rows.get(society.id);
|
||||
const price = state.invest.prices[society.id];
|
||||
const shares = state.invest.holdings[society.id];
|
||||
const change = state.invest.change[society.id];
|
||||
const basis = state.invest.basis[society.id];
|
||||
|
||||
const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '■';
|
||||
setText(row.change, `${arrow} ${signedPercent(change, fmt)}`);
|
||||
row.change.classList.toggle('pos', change > 0);
|
||||
row.change.classList.toggle('neg', change < 0);
|
||||
|
||||
setText(
|
||||
row.price,
|
||||
t('invest.priceLine', {
|
||||
price: fmt.price(price),
|
||||
gap: signedPercent(price / society.basePrice - 1, fmt),
|
||||
}),
|
||||
);
|
||||
const value = shares * price;
|
||||
setText(
|
||||
row.held,
|
||||
shares > 0
|
||||
? 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;
|
||||
row.sell.disabled = shares <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Legacy tab: restart the run for permanent points, spend them on perks.
|
||||
|
||||
import { legacyGain, perkCost, perkLevel } from '../engine/legacy.js';
|
||||
import { t } from '../services/i18n.js';
|
||||
import { h, icon } from './dom.js';
|
||||
import { effectText, setText } from './text.js';
|
||||
|
||||
export function createLegacyView(root, actions) {
|
||||
let ctx;
|
||||
let fmt;
|
||||
let els;
|
||||
const perkRows = new Map();
|
||||
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
perkRows.clear();
|
||||
|
||||
els = {
|
||||
points: h('strong'),
|
||||
earned: h('dd'),
|
||||
prestiges: h('dd'),
|
||||
passive: h('dd'),
|
||||
gain: h('p', { class: 'card__desc' }),
|
||||
need: h('p', { class: 'hint' }),
|
||||
progress: h('progress', { class: 'progress', max: 1, value: 0 }),
|
||||
restart: h('button', { class: 'btn btn--danger btn--wide', type: 'button', onclick: () => actions.restart() }),
|
||||
};
|
||||
|
||||
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 li = h(
|
||||
'li',
|
||||
{ class: 'card' },
|
||||
icon(`legacy.${perk.id}`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__body' },
|
||||
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,
|
||||
);
|
||||
perks.append(li);
|
||||
perkRows.set(perk.id, { perk, level, cost, buy });
|
||||
}
|
||||
|
||||
root.replaceChildren(
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
h('h2', { class: 'panel__title', text: t('tab.legacy') }),
|
||||
h('p', { class: 'hint', text: t('legacy.intro', { passive: fmt.number(ctx.rules.legacy.passivePerPoint * 100) }) }),
|
||||
h('div', { class: 'tile tile--primary' }, icon('res.legacy'), h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.legacy') }), els.points)),
|
||||
h(
|
||||
'dl',
|
||||
{ class: 'stats' },
|
||||
h('div', {}, h('dt', { text: t('legacy.earned') }), els.earned),
|
||||
h('div', {}, h('dt', { text: t('legacy.restarts') }), els.prestiges),
|
||||
h('div', {}, h('dt', { text: t('legacy.passive') }), els.passive),
|
||||
),
|
||||
h('h3', { class: 'dialog__subtitle', text: t('legacy.restartTitle') }),
|
||||
els.gain,
|
||||
els.progress,
|
||||
els.need,
|
||||
els.restart,
|
||||
),
|
||||
h('section', { class: 'panel' }, h('h2', { class: 'panel__title', text: t('legacy.perks') }), perks),
|
||||
);
|
||||
}
|
||||
|
||||
function update(state, d) {
|
||||
const { threshold, passivePerPoint } = ctx.rules.legacy;
|
||||
setText(els.points, fmt.number(state.legacy.points));
|
||||
setText(els.earned, fmt.number(state.legacy.earned));
|
||||
setText(els.prestiges, fmt.number(state.legacy.prestiges));
|
||||
setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number((1 + passivePerPoint) ** state.legacy.earned) }));
|
||||
|
||||
const gain = legacyGain(state, ctx, d.mods);
|
||||
setText(els.gain, t('legacy.gain', { gain: fmt.number(gain) }));
|
||||
els.progress.max = threshold;
|
||||
els.progress.value = Math.min(state.totalProduced, threshold);
|
||||
setText(
|
||||
els.need,
|
||||
gain >= 1
|
||||
? t('legacy.ready')
|
||||
: t('legacy.need', { have: fmt.number(Math.floor(state.totalProduced)), need: fmt.number(threshold) }),
|
||||
);
|
||||
setText(els.restart, t('legacy.restart'));
|
||||
els.restart.disabled = gain < 1;
|
||||
|
||||
for (const { perk, level, cost, buy } 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;
|
||||
}
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Research tab: labs produce points over time; points buy nodes of the tree.
|
||||
|
||||
import { labCost, researchStatus } from '../engine/research.js';
|
||||
import { t } from '../services/i18n.js';
|
||||
import { h, icon } from './dom.js';
|
||||
import { effectText, moneyFormatter, setText } from './text.js';
|
||||
|
||||
const BRANCHES = ['prod', 'mkt', 'log', 'fin', 'ins'];
|
||||
|
||||
export function createResearchView(root, actions) {
|
||||
let ctx;
|
||||
let fmt;
|
||||
let money;
|
||||
let els;
|
||||
const nodeRows = new Map();
|
||||
|
||||
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 li = h(
|
||||
'li',
|
||||
{ class: 'card' },
|
||||
icon(`research.${node.id}`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card__body' },
|
||||
h('h3', { class: 'card__title', text: t(`research.node.${node.id}`) }),
|
||||
h('p', { class: 'card__desc', text: effectText(node.effect, fmt) }),
|
||||
status,
|
||||
),
|
||||
buy,
|
||||
);
|
||||
return { li, status, buy, node };
|
||||
}
|
||||
|
||||
function rebuild(newCtx, formatter) {
|
||||
ctx = newCtx;
|
||||
fmt = formatter;
|
||||
money = moneyFormatter(fmt);
|
||||
nodeRows.clear();
|
||||
|
||||
els = {
|
||||
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() }),
|
||||
};
|
||||
|
||||
const branchPanels = BRANCHES.map((branch) => {
|
||||
const list = h('ul', { class: 'cards' });
|
||||
for (const node of ctx.research.filter((n) => n.branch === branch)) {
|
||||
const row = nodeRow(node);
|
||||
nodeRows.set(node.id, row);
|
||||
list.append(row.li);
|
||||
}
|
||||
return h(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
h('h2', { class: 'panel__title', text: t(`research.branch.${branch}`) }),
|
||||
list,
|
||||
);
|
||||
});
|
||||
|
||||
root.replaceChildren(
|
||||
h(
|
||||
'section',
|
||||
{ class: 'panel panel--wide' },
|
||||
h('h2', { class: 'panel__title', text: t('tab.research') }),
|
||||
h('p', { class: 'hint', text: t('research.intro') }),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'tile' },
|
||||
icon('res.research'),
|
||||
h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.research') }), els.rp, els.rate),
|
||||
),
|
||||
els.labs,
|
||||
els.buyLab,
|
||||
),
|
||||
...branchPanels,
|
||||
);
|
||||
}
|
||||
|
||||
function update(state, d) {
|
||||
const r = ctx.rules.research;
|
||||
setText(els.rp, fmt.number(Math.floor(state.rp)));
|
||||
setText(els.rate, t('research.rate', { rate: fmt.number(Math.round(d.rpRate * 1000) / 1000) }));
|
||||
setText(els.labs, t('research.labs', { count: state.labs, perLab: fmt.number(r.rpPerLabPerSecond * d.mods.rp) }));
|
||||
|
||||
const maxed = state.labs >= r.labMax;
|
||||
const cost = labCost(state, ctx);
|
||||
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()) {
|
||||
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) }));
|
||||
if (s === 'owned') setText(status, t('research.owned'));
|
||||
else if (s === 'locked') {
|
||||
const names = node.requires.map((id) => t(`research.node.${id}`)).join(', ');
|
||||
setText(status, t('research.requires', { names }));
|
||||
} else setText(status, '');
|
||||
}
|
||||
}
|
||||
|
||||
return { rebuild, update };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// The section menu: horizontal tabs on desktop/tablet, a burger menu on phones
|
||||
// (the markup is the same, the CSS decides). Tabs unlock with progress.
|
||||
|
||||
import { isUnlocked } from '../engine/engine.js';
|
||||
import { has, t } from '../services/i18n.js';
|
||||
import { $, h } from './dom.js';
|
||||
import { showToast } from './toast.js';
|
||||
|
||||
const labelKey = (id) => (has(`theme.tab.${id}`) ? `theme.tab.${id}` : `tab.${id}`);
|
||||
|
||||
/**
|
||||
* @param tabs data/tabs.json
|
||||
* @param onSelect (tabId) called when the visible section changes
|
||||
*/
|
||||
export function createTabs(tabs, onSelect) {
|
||||
const list = $('#tabs');
|
||||
const menu = $('#menu');
|
||||
const toggle = $('#menu-toggle');
|
||||
const buttons = new Map();
|
||||
let active = 'main';
|
||||
let known = null; // unlocked tabs seen so far (null until the first update)
|
||||
const unseen = new Set();
|
||||
|
||||
const view = (id) => $(`[data-view="${id}"]`);
|
||||
|
||||
function select(id) {
|
||||
active = id;
|
||||
unseen.delete(id);
|
||||
for (const tab of tabs) {
|
||||
const section = view(tab.id);
|
||||
if (section) section.hidden = tab.id !== id;
|
||||
buttons.get(tab.id)?.button.setAttribute('aria-current', tab.id === id ? 'page' : 'false');
|
||||
}
|
||||
buttons.get(id)?.item.classList.remove('tab--new');
|
||||
setMenuOpen(false);
|
||||
onSelect(id);
|
||||
}
|
||||
|
||||
function setMenuOpen(open) {
|
||||
menu.toggleAttribute('data-open', open);
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', () => setMenuOpen(!menu.hasAttribute('data-open')));
|
||||
|
||||
/** Build the buttons (call again after a language or theme change to relabel). */
|
||||
function rebuild() {
|
||||
list.replaceChildren();
|
||||
buttons.clear();
|
||||
for (const tab of tabs) {
|
||||
const button = h('button', {
|
||||
class: 'tab',
|
||||
type: 'button',
|
||||
'aria-current': tab.id === active ? 'page' : 'false',
|
||||
text: t(labelKey(tab.id)),
|
||||
onclick: () => select(tab.id),
|
||||
});
|
||||
const item = h('li', { class: 'tabs__item' }, button);
|
||||
item.hidden = known ? !known.has(tab.id) : tab.id !== 'main';
|
||||
if (unseen.has(tab.id)) item.classList.add('tab--new');
|
||||
list.append(item);
|
||||
buttons.set(tab.id, { item, button });
|
||||
}
|
||||
}
|
||||
|
||||
/** Call every frame: reveals newly unlocked tabs, flags the ones needing attention. */
|
||||
function update(state) {
|
||||
const unlocked = new Set(tabs.filter((tab) => isUnlocked(state, tab.unlock)).map((tab) => tab.id));
|
||||
|
||||
for (const tab of tabs) {
|
||||
const entry = buttons.get(tab.id);
|
||||
const isNew = known && unlocked.has(tab.id) && !known.has(tab.id);
|
||||
entry.item.hidden = !unlocked.has(tab.id);
|
||||
if (isNew) {
|
||||
unseen.add(tab.id);
|
||||
entry.item.classList.add('tab--new');
|
||||
showToast(t('toast.tabUnlocked', { name: t(labelKey(tab.id)) }));
|
||||
}
|
||||
}
|
||||
known = unlocked;
|
||||
|
||||
// An opportunity is waiting on the contracts tab.
|
||||
buttons.get('contracts')?.item.classList.toggle('tab--alert', Boolean(state.events.current));
|
||||
|
||||
if (!unlocked.has(active)) select('main');
|
||||
}
|
||||
|
||||
return { rebuild, update, select, get active() { return active; } };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Text helpers shared by the views: money, durations, effects and rewards.
|
||||
|
||||
import { t } from '../services/i18n.js';
|
||||
|
||||
/** Money in the theme's currency, e.g. "12.50 GAL". */
|
||||
export const moneyFormatter = (fmt) => (n) => t('theme.fmt.funds', { value: fmt.money(n) });
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const minutes = Math.floor(total / 60);
|
||||
if (minutes < 1) return t('time.seconds', { value: total });
|
||||
if (minutes < 60) return t('time.minutes', { value: minutes });
|
||||
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 });
|
||||
}
|
||||
|
||||
/** "×1.5 production", "-20% generator cost"... from an effect object. */
|
||||
export function effectText(effect, fmt) {
|
||||
const params = { value: fmt.number(effect.value) };
|
||||
if (effect.target) params.target = t(`theme.gen.${effect.target}.name`);
|
||||
if (effect.type === 'batch_discount' || effect.type === 'generator_cost_mult') {
|
||||
params.percent = fmt.number(Math.round((1 - effect.value) * 100));
|
||||
}
|
||||
if (effect.type === 'reliability_add' || effect.type === 'offline_add') {
|
||||
params.percent = fmt.number(Math.round(effect.value * 100));
|
||||
}
|
||||
if (effect.type === 'start_funds') params.funds = moneyFormatter(fmt)(effect.value);
|
||||
return t(`effect.${effect.type}`, params);
|
||||
}
|
||||
|
||||
/** What a contract pays, e.g. "Reward: 1.2M GAL". */
|
||||
export function rewardText(reward, fmt, seconds = 0) {
|
||||
const money = moneyFormatter(fmt);
|
||||
switch (reward.type) {
|
||||
case 'funds':
|
||||
return t('contracts.reward.funds', { amount: money(reward.amount) });
|
||||
case 'boost':
|
||||
return t('contracts.reward.boost', { value: fmt.number(reward.amount), duration: formatDuration(seconds) });
|
||||
default: // research, chips
|
||||
return t(`contracts.reward.${reward.type}`, { amount: fmt.number(reward.amount) });
|
||||
}
|
||||
}
|
||||
|
||||
export function setText(node, text) {
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
|
||||
/** "+3.2%" / "-1.0%" */
|
||||
export function signedPercent(fraction, fmt) {
|
||||
const sign = fraction > 0 ? '+' : '';
|
||||
return `${sign}${fmt.number(Math.round(fraction * 1000) / 10)}%`;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { $, h } from './dom.js';
|
||||
|
||||
const DURATION_MS = 6000;
|
||||
const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages
|
||||
|
||||
export function showToast(message, { error = false } = {}) {
|
||||
const container = $('#toasts');
|
||||
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
|
||||
$('#toasts').append(toast);
|
||||
container.append(toast);
|
||||
while (container.children.length > MAX_VISIBLE) container.firstElementChild.remove();
|
||||
setTimeout(() => toast.remove(), DURATION_MS);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user