Files
Battle-for-Tismo/client/js/modals.js
T
2026-09-17 00:56:59 +02:00

519 lines
19 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.
// The three modal panels of the game HUD: city training, city buildings, and
// the nation panel (government + technology + budget). They only display state
// supplied by the game screen and forward the player's choices as orders.
import { groupDigits, hours } from "../../shared/text_format.js";
import { ECONOMY } from "../../shared/data.js";
import {
buildingBuildCost,
buildingRefund,
buildingUpkeep,
governmentChangeCost,
prerequisitesMet,
} from "../../shared/rules.js";
function formatEffect(effect, level) {
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
switch (effect.stat) {
case "gdp":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP`;
case "budget_income":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% budget income`;
case "research":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
}
function effectsText(effects, level) {
const parts = effects.map((e) => formatEffect(e, level));
return parts.length > 0 ? parts.join(" · ") : "No effect";
}
const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
function signedMoney(value) {
return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`;
}
function percent(value) {
return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
}
export class TrainModal {
constructor() {
this.$modal = $("#modal-train");
this.$title = $("#train-title");
this.$list = $("#train-list");
this.$status = $("#train-status");
this.$label = $("#train-label");
this.$progress = $("#train-progress");
this.cityId = 0;
this.gdp = 0;
this.onTrain = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, trainableUnits) {
this.cityId = city.id;
this.gdp = gdp;
this.$title.text(`${city.name} — Train`);
this.$list.empty();
this.$status.addClass("hidden");
for (let index = 0; index < trainableUnits.length; index++) {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
const button = $("<button class='aero'></button>");
const duration = ECONOMY.trainingHours(gdp, proto.cost);
button.text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`);
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
button.on("click", () => this.onTrain(city.id, serverIndex));
this.$list.append(button);
}
this.$modal.removeClass("hidden");
}
beginTraining(name, totalHours, elapsedHours = 0) {
this.$label.text(`Training ${name}…`);
this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
this.$status.removeClass("hidden");
this.$list.find("button").prop("disabled", true);
}
setTrainingProgress(name, elapsedHours, totalHours) {
const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours));
this.$label.text(`Training ${name}… (${remaining} h left)`);
this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
}
endTraining() {
this.$label.text("Training complete");
this.$progress.css("width", "100%");
this.$list.find("button").prop("disabled", false);
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
export class BuildingsModal {
constructor() {
this.$modal = $("#modal-buildings");
this.$title = $("#buildings-title");
this.$budget = $("#buildings-budget");
this.$list = $("#buildings-list");
this.cityId = 0;
this.cityName = "";
this.buildings = [];
this.rows = [];
this.onBuild = () => {};
this.onDemolish = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, budget, buildings, levels) {
this.cityId = city.id;
this.cityName = city.name;
this.buildings = buildings;
this.$title.text(`${city.name} — Buildings`);
this.$list.empty();
this.rows = buildings.map((proto, index) => this._makeRow(proto, index));
this.refresh(gdp, budget, levels);
this.$modal.removeClass("hidden");
}
_makeRow(proto, index) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description);
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $upgrade = $("<button class='aero'></button>");
const $downgrade = $("<button class='aero'></button>");
$upgrade.on("click", () => this.onBuild(this.cityId, index));
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
$actions.append($upgrade, $downgrade);
$card.append($info, $actions);
this.$list.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
}
refresh(gdp, budget, levels) {
this.$budget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.rows) {
const level = levels[row.index] || 0;
row.$name.text(`${row.proto.name} — Level ${level} / ${row.proto.maxLevel}`);
row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : 1));
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, gdp, level)))}/h`);
if (level < row.proto.maxLevel) {
parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, gdp, level)))}`);
}
row.$cost.text(parts.join(" · "));
if (level < row.proto.maxLevel) {
const cost = buildingBuildCost(row.proto, gdp, level);
row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
} else {
row.$upgrade.text("Max level").prop("disabled", true);
}
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, gdp, level)))}`)
.prop("disabled", false);
} else {
row.$downgrade.text("Not built").prop("disabled", true);
}
}
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetList = $("#budget-list");
this.$diplomacySub = $("#diplomacy-sub");
this.$diplomacyList = $("#diplomacy-list");
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
this.diplomacy = null;
this.budgetRows = {
income: this._makeBudgetRow("Tax income", "State revenue drawn from the nation's GDP."),
building: this._makeBudgetRow("Building upkeep", "Upkeep of every building across your cities."),
units: this._makeBudgetRow("Military upkeep", "Upkeep of all your units."),
net: this._makeBudgetRow("Net balance", "Projected change to the treasury each hour."),
};
this.onGovernment = () => {};
this.onTechnology = () => {};
this.onDeclareWar = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find(".tab").on("click", (event) => {
const tab = $(event.currentTarget).data("tab");
this.$modal.find(".tab").removeClass("active");
$(event.currentTarget).addClass("active");
this.$modal.find(".tab-page").removeClass("active");
$(`#tab-${tab}`).addClass("active");
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy);
this.$modal.removeClass("hidden");
}
_makeBudgetRow(name, description) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(name);
const $desc = $("<div class='desc'></div>").text(description);
const $details = $("<div class='budget-detail'></div>");
const $value = $("<div class='value'></div>");
$info.append($name, $desc, $details);
$card.append($info, $value);
this.$budgetList.append($card);
return { $value, $details };
}
_renderBudgetRow(row, value, lines) {
row.$value
.text(signedMoney(value))
.toggleClass("positive", value >= 0)
.toggleClass("negative", value < 0);
row.$details.empty();
for (const line of lines) {
row.$details.append($("<div class='budget-line'></div>").text(line));
}
}
_sourceLines(sources, format, empty) {
if (!sources || sources.length === 0) return [empty];
return sources.map((source) => format(source));
}
_makeRow($list, proto, index, kind) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description || "");
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
else this.onTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
return { proto, index, kind, $name, $effects, $cost, $action };
}
refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null) {
this.currentGovernment = currentGovernment;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`);
const breakdown = finances || {};
const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [] };
const building = breakdown.buildingUpkeep || { total: 0, sources: [] };
const units = breakdown.unitUpkeep || { total: 0, sources: [] };
const net = breakdown.net || 0;
this.$budgetSub.text(
`Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}`
);
this._renderBudgetRow(this.budgetRows.income, income.total, [
`Tax rate ${(income.rate * 100).toFixed(2)}% of GDP — base €${groupDigits(Math.round(income.base))}/h`,
...this._sourceLines(
income.sources,
(s) => `${s.label} (${SOURCE_LABELS[s.kind] || s.kind}) ${percent(s.effect)} → ${signedMoney(s.amount)}`,
"No bonuses"
),
]);
this._renderBudgetRow(
this.budgetRows.building,
-building.total,
this._sourceLines(
building.sources,
(s) => `${s.label} — €${groupDigits(Math.round(s.amount))}/h`,
"No buildings"
)
);
this._renderBudgetRow(
this.budgetRows.units,
-units.total,
this._sourceLines(
units.sources,
(s) => `${s.label} ×${s.count} — €${groupDigits(Math.round(s.amount))}/h`,
"No units"
)
);
this._renderBudgetRow(this.budgetRows.net, net, [
`Income ${signedMoney(income.total)}`,
`Expenses −€${groupDigits(Math.round(building.total + units.total))}/h`,
]);
for (const row of this.governmentRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (row.index === currentGovernment) {
row.$cost.text("Currently in power");
row.$action.text("Current government").prop("disabled", true);
} else {
const cost = governmentChangeCost(proto, gdp);
row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`);
row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
}
}
for (const row of this.technologyRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (this.researched.has(row.index)) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
const names = proto.prerequisites
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
} else {
row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`);
row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
}
}
this._renderDiplomacy();
}
// Lists every other nation with its relation to the player and a button to
// declare war. `diplomacy` is { civs, localCiv, relations }, where relations
// is the snapshot's [a, b, status] matrix.
_renderDiplomacy() {
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
this.$diplomacyList.empty();
const info = this.diplomacy;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
if (entry[0] === localCiv) wars.add(entry[1]);
else if (entry[1] === localCiv) wars.add(entry[0]);
}
if (this.$diplomacySub && this.$diplomacySub.length) {
const atWar = wars.size;
this.$diplomacySub.text(
atWar === 0
? "Your nation is at peace with every other nation."
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
);
}
for (let index = 0; index < civs.length; index++) {
if (index === localCiv) continue;
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(civs[index].name);
const atWar = wars.has(index);
const $status = $("<div class='desc'></div>").text(atWar ? "At war" : "At peace");
$info.append($name, $status);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
if (atWar) {
$action.text("At war").prop("disabled", true);
} else {
$action.text("Declare war");
$action.on("click", () => this.onDeclareWar(index));
}
$actions.append($action);
$card.append($info, $actions);
this.$diplomacyList.append($card);
}
}
close() {
this.$modal.addClass("hidden");
}
}
// A generic yes/no prompt. `show` takes the callbacks it should run when the
// player confirms; cancelling (or closing) runs nothing.
export class ConfirmModal {
constructor() {
this.$modal = $("#modal-confirm");
this.$title = $("#confirm-title");
this.$message = $("#confirm-message");
this._onConfirm = null;
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find("#confirm-cancel").on("click", () => this.close());
this.$modal.find("#confirm-ok").on("click", () => {
const callback = this._onConfirm;
this.close();
if (callback) callback();
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(title, message, onConfirm) {
this.$title.text(title);
this.$message.text(message);
this._onConfirm = onConfirm || null;
this.$modal.removeClass("hidden");
}
close() {
this.$modal.addClass("hidden");
this._onConfirm = null;
}
}
// The news feed: a reverse-chronological list of newsworthy events. Civ indices
// are resolved to names through the catalogue the game screen passes in.
export class NewsModal {
constructor() {
this.$modal = $("#modal-news");
this.$list = $("#news-list");
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(news, civs) {
this.refresh(news, civs);
this.$modal.removeClass("hidden");
}
refresh(news, civs = []) {
if (!this.$list || !this.$list.length) return;
this.$list.empty();
const entries = (news || []).slice().reverse();
if (entries.length === 0) {
this.$list.append($("<div class='news-empty'></div>").text("No news yet."));
return;
}
for (const entry of entries) {
const $item = $("<div class='news-item'></div>");
$item.append($("<div class='news-text'></div>").text(describeNews(entry, civs)));
$item.append($("<div class='news-date'></div>").text(entry.date || ""));
this.$list.append($item);
}
}
close() {
this.$modal.addClass("hidden");
}
}
export function describeNews(entry, civs) {
const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
const people = (index) => {
const civ = civs[index];
return civ && civ.demonym ? `The ${civ.demonym}` : name(index);
};
switch (entry.type) {
case "war":
return `${people(entry.actor)} declared war on ${name(entry.target)}`;
case "capture":
return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
default:
return entry.type;
}
}