519 lines
19 KiB
JavaScript
519 lines
19 KiB
JavaScript
// 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;
|
||
}
|
||
}
|