feat: New features (research, invest, gambling, new game+, trophies)

This commit is contained in:
gauvainboiche
2026-10-08 14:03:00 +02:00
parent 1caa4d92b9
commit fc0d5e3ba4
56 changed files with 4332 additions and 249 deletions
+57
View File
@@ -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 };
}
+164
View File
@@ -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
View File
@@ -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));
}
+167
View File
@@ -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 };
}
+181
View File
@@ -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 };
}
+106
View File
@@ -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 };
}
+114
View File
@@ -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 };
}
+89
View File
@@ -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; } };
}
+51
View File
@@ -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)}%`;
}
+4 -1
View File
@@ -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);
}