278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
// 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;
|
||
}
|