Files
Battle-for-Tismo/client/js/modals/city.js
T

278 lines
11 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 city panel: train units, raise and demolish buildings and read the
// region's budget, all in one tabbed modal. Units and buildings share the
// city's production queue, so the train tab shows both kinds of order.
import { groupDigits, hours } from "../../../shared/text_format.js";
import { ECONOMY, BUILDINGS } from "../../../shared/data.js";
import {
buildingBuildCost,
buildingRefund,
buildingUpkeep,
} from "../../../shared/rules.js";
import { effectsText, signedMoney } from "./format.js";
import { renderBudget } from "./budget.js";
export class CityModal {
constructor() {
this.$modal = $("#modal-city");
this.$title = $("#city-title");
this.$trainList = $("#city-train-list");
this.$trainStatus = $("#city-train-status");
this.$trainLabel = $("#city-train-label");
this.$trainProgress = $("#city-train-progress");
this.$trainQueue = $("#city-train-queue");
this.$buildingsBudget = $("#city-buildings-budget");
this.$buildingsList = $("#city-buildings-list");
this.$budgetSub = $("#city-budget-sub");
this.$budgetList = $("#city-budget-list");
this.cityId = 0;
this.gdp = 0;
this.coastal = false;
this._levels = {};
this.protoNames = new Map();
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
this.buildingRows = [];
// Building types whose upkeep breakdown is expanded on the budget tab.
this.expandedBudgetGroups = new Set();
this._lastBreakdown = {};
this.onTrain = () => {};
this.onCancel = () => {};
this.onBuild = () => {};
this.onDemolish = () => {};
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");
$(`#city-tab-${tab}`).addClass("active");
});
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
// Opens the panel for `city` and lays out every tab in one pass. `view`
// carries { budget, gdp, trainable, buildings, levels, queue }.
show(city, view = {}) {
this.cityId = city.id;
this.gdp = view.gdp || 0;
this.coastal = !!view.coastal;
this.$title.text(`${city.name} — City`);
this._renderTrainList(this.gdp, view.trainable || []);
this.renderQueue(view.queue || []);
this.$trainStatus.addClass("hidden");
this.$buildingsList.empty();
this.buildingRows = (view.buildings || []).map((proto, index) =>
this._makeBuildingRow(proto, index)
);
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
this.$budgetSub.text("Region net +€0/h");
this.renderBudget(null);
this.$modal.removeClass("hidden");
}
// -------------------------------------------------------------- training --
_renderTrainList(gdp, trainableUnits) {
this.protoNames = new Map();
this.$trainList.empty();
for (let index = 0; index < trainableUnits.length; index++) {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
const button = $("<button class='aero train-button'></button>");
if (proto.icon) {
button.append(
$("<img class='unit-icon' />").attr({ src: `assets/${proto.icon}`, alt: proto.name })
);
}
const duration = ECONOMY.trainingHours(gdp, proto.cost);
button.append(
$("<span></span>").text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`)
);
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
this.protoNames.set(serverIndex, proto.name);
button.on("click", () => this.onTrain(this.cityId, serverIndex));
this.$trainList.append(button);
}
}
// Draws the shared production queue: one row per run of contiguous identical
// entries, with the cumulated hours until each run is ready and a button to
// cancel its last entry. Cancelling refunds nothing.
renderQueue(queue) {
if (!this.$trainQueue || !this.$trainQueue.length) return;
this.$trainQueue.empty();
if (!queue || queue.length === 0) {
this.$trainQueue.append(
$("<div class='desc queue-empty'></div>").text("Nothing queued.")
);
return;
}
let cumulative = 0;
groupQueue(queue).forEach((group, groupIndex) => {
let firstReady = 0;
for (const index of group.indices) {
const entry = queue[index];
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
cumulative += Math.max(entry.totalHours - elapsed, 0);
if (index === group.indices[0]) firstReady = cumulative;
}
const count = group.indices.length;
const name = group.kind === "building"
? this.buildingNames.get(group.protoIndex) || "Building"
: this.protoNames.get(group.protoIndex) || "Unit";
const label = count > 1 ? `${name} ×${count}` : name;
const $row = $("<div class='row-card queue-row'></div>");
const $info = $("<div class='info'></div>");
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
$info.append(
$("<div class='desc'></div>").text(
count > 1
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
: `Ready in ${hours(cumulative)}`
)
);
const $actions = $("<div class='actions'></div>");
const $cancel = $("<button class='aero'></button>").text(count > 1 ? "Cancel last" : "Cancel");
$cancel.on("click", () => this.onCancel(this.cityId, group.indices[group.indices.length - 1]));
$actions.append($cancel);
$row.append($info, $actions);
this.$trainQueue.append($row);
});
}
beginTraining(name, totalHours, elapsedHours = 0, verb = "Training") {
this.$trainLabel.text(`${verb} ${name}…`);
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
this.$trainStatus.removeClass("hidden");
}
setTrainingProgress(name, elapsedHours, totalHours, verb = "Training") {
const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours));
this.$trainLabel.text(`${verb} ${name}… (${remaining} h left)`);
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
}
endTraining() {
this.$trainLabel.text("Training complete");
this.$trainProgress.css("width", "100%");
}
// ------------------------------------------------------------- buildings --
_makeBuildingRow(proto, index) {
const $card = $("<div class='row-card'></div>");
const $icon = proto.icon
? $("<img class='building-icon' />").attr({ src: `assets/${proto.icon}`, alt: proto.name })
: null;
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);
if ($icon) $card.append($icon);
$card.append($info, $actions);
this.$buildingsList.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
}
// Why a building cannot be raised here, or null when it can. Ports need the
// sea and shipyards need a port, mirrored from GameState.requestBuild.
_buildRequirement(proto) {
if (proto.coastal && !this.coastal) return "Needs a coastal city";
if (proto.requiresBuilding) {
const index = BUILDINGS.findIndex((b) => b.id === proto.requiresBuilding);
if (index < 0 || (this._levels[index] || 0) <= 0) {
const name = index >= 0 ? BUILDINGS[index].name : proto.requiresBuilding;
return `Needs a ${name}`;
}
}
return null;
}
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
this._levels = levels || {};
this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.buildingRows) {
const level = levels[row.index] || 0;
const pending = queued[row.index] || 0;
// The next order is priced off the level the queued ones will reach, so
// an upgrade can cost more than the completed level alone suggests.
const nextLevel = level + pending;
const levelText = pending > 0 ? `Level ${level} (+${pending} queued)` : `Level ${level}`;
row.$name.text(`${row.proto.name} — ${levelText}`);
row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : Math.max(nextLevel, 1)));
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
const cost = buildingBuildCost(row.proto, nextLevel);
const duration = ECONOMY.trainingHours(gdp, cost);
parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`);
const requirement = this._buildRequirement(row.proto);
if (requirement) parts.push(requirement);
row.$cost.text(parts.join(" · "));
row.$upgrade
.text(`Upgrade €${groupDigits(Math.round(cost))}`)
.prop("disabled", !!requirement || budget < cost);
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
.prop("disabled", false);
} else {
row.$downgrade.text("Not built").prop("disabled", true);
}
}
}
// ---------------------------------------------------------------- budget --
renderBudget(breakdown) {
if (!this.$budgetList || !this.$budgetList.length) return;
this._lastBreakdown = breakdown || {};
const net = this._lastBreakdown.net || 0;
this.$budgetSub.text(`Region net ${signedMoney(net)}`);
renderBudget(this.$budgetList, this._lastBreakdown, {
expanded: this.expandedBudgetGroups,
onToggle: () => this.renderBudget(this._lastBreakdown),
showUnits: false,
});
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
// Runs of contiguous queue entries of the same kind and type, as lists of queue
// indices. Units and buildings never merge into one row, and non-adjacent
// entries of the same type stay separate so a row only ever represents the
// things sitting together.
function groupQueue(queue) {
const groups = [];
queue.forEach((entry, index) => {
const kind = entry.kind === "building" ? "building" : "unit";
const last = groups[groups.length - 1];
if (last && last.kind === kind && last.protoIndex === entry.protoIndex) {
last.indices.push(index);
} else {
groups.push({ kind, protoIndex: entry.protoIndex, indices: [index] });
}
});
return groups;
}