702 lines
28 KiB
JavaScript
702 lines
28 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, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
|
||
import { constructionResourceCost, formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../../../shared/resources.js";
|
||
import {
|
||
buildingBuildCost,
|
||
} from "../../../shared/rules.js";
|
||
import { buildingEffectNodes, buildingEffectText, signedMoney, quotedBuildCost } from "./format.js";
|
||
import { renderBudget } from "./budget.js";
|
||
import { bindTabs } from "./tabs.js";
|
||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||
import {
|
||
buildCard,
|
||
setCardMeta,
|
||
tooltipSections,
|
||
resourceBill,
|
||
CARD_RESOURCES,
|
||
hideCardTooltip,
|
||
} from "../ui/card.js";
|
||
import { currencySymbol, formatMoney } from "../currency.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.$peopleSub = $("#city-people-sub");
|
||
this.$peopleList = $("#city-people-list");
|
||
|
||
this.$budgetSub = $("#city-budget-sub");
|
||
this.$budgetList = $("#city-budget-list");
|
||
|
||
this.$resourcesSub = $("#city-resources-sub");
|
||
this.$resourcesList = $("#city-resources-list");
|
||
|
||
this.$economySub = $("#city-economy-sub");
|
||
this.$economyList = $("#city-economy-list");
|
||
|
||
this.cityId = 0;
|
||
this.gdp = 0;
|
||
this.coastal = false;
|
||
// Construction-technology bonus, which shortens every displayed duration.
|
||
this.constructionSpeed = 0;
|
||
// Local test game: each order also offers a free, instant button.
|
||
this.testing = false;
|
||
// Today's market prices, so build costs are quoted at current rates.
|
||
this.prices = {};
|
||
this._levels = {};
|
||
this.protoNames = new Map();
|
||
// Signature of the trainable set currently drawn, so a snapshot that did
|
||
// not change it leaves the list (and its scroll) alone.
|
||
this._trainableSignature = null;
|
||
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
|
||
this.buildingRows = [];
|
||
// The grid the building cards live in, and the built-first order last drawn
|
||
// so a snapshot that changes nothing does not reshuffle the cards.
|
||
this.$buildingGrid = null;
|
||
this._buildingOrderSignature = null;
|
||
// Building types whose upkeep breakdown is expanded on the budget tab.
|
||
this.expandedBudgetGroups = new Set();
|
||
// Resources whose per-building demand breakdown is expanded on the
|
||
// Resources tab.
|
||
this.expandedResources = new Set();
|
||
this._lastBreakdown = {};
|
||
|
||
this.onTrain = () => {};
|
||
this.onCancel = () => {};
|
||
this.onBuild = () => {};
|
||
this.onDemolish = () => {};
|
||
this.onFreeTrain = () => {};
|
||
this.onFreeBuild = () => {};
|
||
|
||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||
bindTabs(this.$modal, "city-tab-");
|
||
}
|
||
|
||
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 = {}) {
|
||
hideCardTooltip();
|
||
this.cityId = city.id;
|
||
this.gdp = view.gdp || 0;
|
||
this.coastal = !!view.coastal;
|
||
this.constructionSpeed = view.constructionSpeed || 0;
|
||
this.testing = !!view.testing;
|
||
this.prices = view.prices || {};
|
||
this.$title.text(`${city.name} — City`);
|
||
this._renderTrainList(this.gdp, view.trainable || []);
|
||
this.renderQueue(view.queue || []);
|
||
this.$trainStatus.addClass("hidden");
|
||
this.$buildingsList.empty();
|
||
this.$buildingGrid = $("<div class='building-grid'></div>");
|
||
this.$buildingsList.append(
|
||
$("<div class='building-scroller'></div>").append(this.$buildingGrid)
|
||
);
|
||
this._buildingOrderSignature = null;
|
||
this.buildingRows = (view.buildings || []).map((proto, index) =>
|
||
this._makeBuildingCard(proto, index)
|
||
);
|
||
for (const row of this.buildingRows) this.$buildingGrid.append(row.card.$card);
|
||
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
|
||
this.$budgetSub.text(`Region net +${currencySymbol()}0/h`);
|
||
this.renderBudget(null);
|
||
this.renderPeople(
|
||
view.ethnicMakeup || [],
|
||
view.approval,
|
||
view.taxMultiplier,
|
||
view.civs || [],
|
||
view.shortage || {},
|
||
!!view.famine
|
||
);
|
||
this.renderResources(view.resources);
|
||
this.renderEconomy(view.cash || {}, view.cashValue || 0, view.currencies || [], view.debt || 0);
|
||
this.$modal.removeClass("hidden");
|
||
}
|
||
|
||
// The region's people: their ethnic makeup and their government approval,
|
||
// which decides how much tax they pay and how fast they build. `shortage`
|
||
// reports the resources they went short of over the last day and by how much;
|
||
// `famine` says whether they are starving.
|
||
renderPeople(makeup, approval, taxMultiplier, civs, shortage = {}, famine = false) {
|
||
if (!this.$peopleList || !this.$peopleList.length) return;
|
||
this.$peopleList.empty();
|
||
const value = approval === undefined || approval === null ? 0.5 : approval;
|
||
if (this.$peopleSub && this.$peopleSub.length) {
|
||
this.$peopleSub.text(`Government approval ${Math.round(value * 100)}%`);
|
||
}
|
||
if (famine) {
|
||
this.$peopleList.append(
|
||
$("<div class='desc famine-warning'></div>").text(
|
||
"Famine! The region has run out of food and its people are dying."
|
||
)
|
||
);
|
||
}
|
||
if (makeup && makeup.length > 0) {
|
||
this.$peopleList.append(ethnicityChart(makeup, civs || []));
|
||
} else {
|
||
this.$peopleList.append($("<div class='desc'></div>").text("No population data."));
|
||
}
|
||
this._renderShortages(shortage || {});
|
||
if (taxMultiplier !== undefined && taxMultiplier < 0.999) {
|
||
this.$peopleList.append(
|
||
$("<div class='desc politics-penalty'></div>").text(
|
||
`Discontent: only ${Math.round(taxMultiplier * 100)}% of taxes are collected and ` +
|
||
"construction is slowed until approval recovers."
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
// What the region's people went without over the last day: each resource they
|
||
// could not buy enough of, the amount it left them short, and the share of
|
||
// the day's need that was.
|
||
_renderShortages(shortage) {
|
||
const entries = Object.entries(shortage);
|
||
this.$peopleList.append($("<div class='subhead'></div>").text("Resource shortages"));
|
||
if (entries.length === 0) {
|
||
this.$peopleList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"No shortages: the region met all of its people's and industry's needs."
|
||
)
|
||
);
|
||
return;
|
||
}
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Resource"))
|
||
.append($("<th class='numeric'></th>").text("Short by"))
|
||
.append($("<th class='numeric'></th>").text("Share of need"))
|
||
);
|
||
const $body = $("<tbody></tbody>");
|
||
for (const [id, entry] of entries) {
|
||
const proto = resourceById(id);
|
||
const name = proto ? proto.name : id;
|
||
const amount = typeof entry === "number" ? entry : entry.amount;
|
||
const fraction = typeof entry === "number" ? 0 : entry.fraction;
|
||
$body.append(
|
||
$("<tr></tr>")
|
||
.append($("<td></td>").text(name))
|
||
.append(
|
||
$("<td class='numeric'></td>").text(formatResourceAmount(id, amount || 0))
|
||
)
|
||
.append(
|
||
$("<td class='numeric'></td>").text(`${Math.round((fraction || 0) * 100)}%`)
|
||
)
|
||
);
|
||
}
|
||
$table.append($head, $body);
|
||
this.$peopleList.append($table);
|
||
this.$peopleList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"Shortages cut approval and slow the region's economy."
|
||
)
|
||
);
|
||
}
|
||
|
||
// The region's material reserves: how much of each resource it holds, what
|
||
// that reserve is worth at today's market, and how much its people and its
|
||
// buildings draw each day. Every resource expands into the buildings that
|
||
// demanded it and where in the region they stand. Energy is a flow, so it has
|
||
// no reserve of its own.
|
||
renderResources(resources) {
|
||
if (!this.$resourcesList || !this.$resourcesList.length) return;
|
||
this.$resourcesList.empty();
|
||
const info = resources || {};
|
||
const stock = info.stock || {};
|
||
const consumption = info.consumption || {};
|
||
const demand = info.demand || {};
|
||
const value = info.value || 0;
|
||
if (this.$resourcesSub && this.$resourcesSub.length) {
|
||
this.$resourcesSub.text(`Reserves worth ${formatMoney(value)}`);
|
||
}
|
||
this.$resourcesList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"The region's private sector holds these reserves and draws them down as " +
|
||
"its people and buildings consume them. Expand a resource to see which " +
|
||
"buildings demand it. Energy is a flow -- it has no reserve."
|
||
)
|
||
);
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Resource"))
|
||
.append($("<th class='numeric'></th>").text("Reserve"))
|
||
.append($("<th class='numeric'></th>").text("Consumption/day"))
|
||
.append($("<th class='numeric'></th>").text("Value"))
|
||
);
|
||
const $body = $("<tbody></tbody>");
|
||
for (const id of RESOURCE_IDS) {
|
||
const proto = resourceById(id);
|
||
const name = proto ? proto.name : id;
|
||
const storable = proto ? proto.storable : false;
|
||
const held = stock[id] || 0;
|
||
const price = this.prices[id] || 0;
|
||
const consumers = (demand[id] || []).slice().sort((a, b) => b.amount - a.amount);
|
||
const open = this.expandedResources.has(id);
|
||
const row = $("<tr></tr>");
|
||
if (consumers.length > 0) {
|
||
const $name = $("<td class='clickable'></td>");
|
||
$name.append($("<span class='resource-caret'></span>").text(open ? "▾" : "▸"));
|
||
$name.append(document.createTextNode(` ${name}`));
|
||
$name.on("click", () => {
|
||
if (this.expandedResources.has(id)) this.expandedResources.delete(id);
|
||
else this.expandedResources.add(id);
|
||
this.renderResources(resources);
|
||
});
|
||
row.append($name);
|
||
} else {
|
||
row.append($("<td></td>").text(name));
|
||
}
|
||
row.append(
|
||
$("<td class='numeric'></td>").text(storable ? formatResourceAmount(id, held) : "—")
|
||
);
|
||
row.append(
|
||
$("<td class='numeric'></td>").text(formatResourceAmount(id, consumption[id] || 0))
|
||
);
|
||
row.append(
|
||
$("<td class='numeric'></td>").text(storable ? formatMoney(held * price) : "—")
|
||
);
|
||
$body.append(row);
|
||
if (open) {
|
||
const $detail = $("<tr class='resource-demand'></tr>");
|
||
const $cell = $("<td colspan='4'></td>");
|
||
for (const consumer of consumers) {
|
||
const where = consumer.coords
|
||
? ` (${consumer.coords[0]}, ${consumer.coords[1]})`
|
||
: "";
|
||
$cell.append(
|
||
$("<div class='demand-line'></div>").text(
|
||
`${consumer.name}${where} · ${formatResourceAmount(id, consumer.amount)}/day`
|
||
)
|
||
);
|
||
}
|
||
$detail.append($cell);
|
||
$body.append($detail);
|
||
}
|
||
}
|
||
$table.append($head, $body);
|
||
this.$resourcesList.append($table);
|
||
}
|
||
|
||
// The region's private-sector cash: a basket of currencies it holds. Regions
|
||
// are the game's private economic agents, so this is their own money, not the
|
||
// government treasury.
|
||
renderEconomy(cash, cashValue, currencies = [], debt = 0) {
|
||
if (!this.$economyList || !this.$economyList.length) return;
|
||
this.$economyList.empty();
|
||
const byCode = new Map(currencies.map((entry) => [entry.code, entry]));
|
||
if (this.$economySub && this.$economySub.length) {
|
||
const owed = debt > 0
|
||
? ` · Debt: ${currencySymbol()}${groupDigits(Math.round(debt))}`
|
||
: "";
|
||
this.$economySub.text(
|
||
`Private-sector cash: ${currencySymbol()}${groupDigits(Math.round(cashValue || 0))}${owed}`
|
||
);
|
||
}
|
||
this.$economyList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"This region is a private economic agent: it holds its own cash reserve, " +
|
||
"trades with its neighbours and chases profit."
|
||
)
|
||
);
|
||
const holdings = Object.entries(cash || {}).filter(([, amount]) => amount > 0);
|
||
if (holdings.length === 0) {
|
||
this.$economyList.append($("<div class='desc'></div>").text("No cash held."));
|
||
return;
|
||
}
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Currency"))
|
||
.append($("<th class='numeric'></th>").text("Held"))
|
||
);
|
||
const $body = $("<tbody></tbody>");
|
||
for (const [code, amount] of holdings) {
|
||
const currency = byCode.get(code);
|
||
const name = currency ? `${currency.symbol} ${currency.name} (${code})` : code;
|
||
$body.append(
|
||
$("<tr></tr>")
|
||
.append($("<td></td>").text(name))
|
||
.append($("<td class='numeric'></td>").text(groupDigits(Math.round(amount))))
|
||
);
|
||
}
|
||
$table.append($head, $body);
|
||
this.$economyList.append($table);
|
||
}
|
||
|
||
// -------------------------------------------------------------- training --
|
||
|
||
// The fraction of the base build time a construction technology leaves, so a
|
||
// 1% level shows a 1% shorter build.
|
||
_constructionTimeFactor() {
|
||
return 1 / (1 + (this.constructionSpeed || 0));
|
||
}
|
||
|
||
// The full price of a construction at today's market: the build cost plus its
|
||
// material bill at current prices, whether or not the city already holds the
|
||
// materials.
|
||
_quotedCost(cost, proto, level) {
|
||
return quotedBuildCost(cost, constructionResourceCost(proto, level, cost), this.prices);
|
||
}
|
||
|
||
_renderTrainList(gdp, trainableUnits) {
|
||
this.protoNames = new Map();
|
||
this.$trainList.empty();
|
||
const $grid = $("<div class='building-grid'></div>");
|
||
this.$trainList.append($grid);
|
||
const signature = [];
|
||
for (let index = 0; index < trainableUnits.length; index++) {
|
||
const proto = trainableUnits[index];
|
||
if (!proto.cost && proto.cost !== 0) continue;
|
||
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
|
||
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
|
||
signature.push(serverIndex);
|
||
this.protoNames.set(serverIndex, proto.name);
|
||
const card = buildCard({
|
||
icon: proto.icon,
|
||
name: proto.name,
|
||
status: this._unitStatus(proto),
|
||
note: this._unitNote(proto),
|
||
action: {
|
||
label: "Train",
|
||
money: this._quotedCost(proto.cost, proto, 0),
|
||
hours: hours(duration),
|
||
},
|
||
tip: () => this._unitTip(proto),
|
||
onAction: () => this.onTrain(this.cityId, serverIndex),
|
||
});
|
||
// In a local test game, a second button trains the unit for free.
|
||
if (this.testing) {
|
||
const $free = $("<button class='aero bt-free free-button'></button>")
|
||
.text(`Free · ${proto.name}`)
|
||
.on("click", () => this.onFreeTrain(this.cityId, serverIndex));
|
||
card.$card.append($free);
|
||
}
|
||
$grid.append(card.$card);
|
||
}
|
||
this._trainableSignature = signature.join(",");
|
||
}
|
||
|
||
// The unit's one-line summary on its card: its fighting figures for a combat
|
||
// unit, what it carries or sees otherwise.
|
||
_unitStatus(proto) {
|
||
if (proto.military) return `Attack ${proto.attack} · Defense ${proto.defense}`;
|
||
if (proto.transportCapacity) return `Carries ${proto.transportCapacity} ${proto.transportKind}`;
|
||
if (proto.vision) return `Vision ${proto.vision}`;
|
||
return "Civilian";
|
||
}
|
||
|
||
// The materials and food a unit wears out standing each day. Energy is left
|
||
// out: it is fed to the grid through the city it stands in, not billed to the
|
||
// unit. Aircraft and ships eat nothing, so only their materials show.
|
||
_unitUpkeepItems(proto) {
|
||
const upkeep = protoUpkeep(proto, proto.cost);
|
||
return [
|
||
{ id: "steel", amount: upkeep.steel },
|
||
{ id: "food", amount: unitFoodPerDay(proto) },
|
||
{ id: "hightech", amount: upkeep.hightech },
|
||
].filter((item) => item.amount > 0);
|
||
}
|
||
|
||
// The unit's daily maintenance, paid in materials and food rather than money,
|
||
// drawn as the same icon bill a building's consumes uses.
|
||
_unitNote(proto) {
|
||
const items = this._unitUpkeepItems(proto);
|
||
if (items.length === 0) return "";
|
||
return resourceBill(items, { sign: "−", suffix: "/day" });
|
||
}
|
||
|
||
// The hover detail for a unit card: what it is worth in a fight, what it
|
||
// wears out each day and the materials training it spends.
|
||
_unitTip(proto) {
|
||
const stats = [];
|
||
if (proto.military) {
|
||
stats.push(`Attack ${proto.attack}`, `Defense ${proto.defense}`);
|
||
if (proto.range > 1) stats.push(`Range ${proto.range}`);
|
||
}
|
||
if (proto.vision) stats.push(`Vision ${proto.vision}`);
|
||
if (proto.transportCapacity) {
|
||
stats.push(`Carries ${proto.transportCapacity} ${proto.transportKind}`);
|
||
}
|
||
if (proto.air) stats.push(`Endurance ${proto.enduranceHours}h`);
|
||
if (stats.length === 0) stats.push("Civilian");
|
||
const sections = [
|
||
{ label: "Unit", cls: "tip-produce", sign: "+", suffix: "", items: stats.map((text) => ({ text })) },
|
||
];
|
||
const consume = this._unitUpkeepItems(proto);
|
||
if (consume.length > 0) {
|
||
sections.push({ label: "Upkeep", cls: "tip-consume", sign: "−", suffix: "/day", items: consume });
|
||
}
|
||
const cost = this._resourceItems(constructionResourceCost(proto, 0, proto.cost));
|
||
if (cost.length > 0) {
|
||
sections.push({ label: "Costs", cls: "tip-cost", sign: "−", suffix: "", items: cost });
|
||
}
|
||
return sections;
|
||
}
|
||
|
||
// Rebuilds the trainable list only when the set changes, so a building that
|
||
// finishes while the panel is open (an airport, say) unlocks its unit without
|
||
// reopening the city and without discarding the list's scroll position.
|
||
refreshTrainList(trainableUnits) {
|
||
const signature = trainableUnits.map((proto) => proto.serverIndex).join(",");
|
||
if (signature === this._trainableSignature) return;
|
||
this._renderTrainList(this.gdp, trainableUnits);
|
||
}
|
||
|
||
// 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;
|
||
// A building still gathering its materials has not started building, so
|
||
// it adds no time to the queue's readiness estimate.
|
||
const gather = entry.kind === "building" && entry.phase === "materials";
|
||
cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0);
|
||
if (index === group.indices[0]) firstReady = cumulative;
|
||
}
|
||
const count = group.indices.length;
|
||
const gathering = queue[group.indices[0]].kind === "building" &&
|
||
queue[group.indices[0]].phase === "materials";
|
||
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(
|
||
gathering
|
||
? "Gathering materials"
|
||
: 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%");
|
||
}
|
||
|
||
// The selected city's head of queue is a building still buying its steel and
|
||
// high-tech: show how far that gathering has got, with no hours figure since
|
||
// the construction clock has not started.
|
||
setGatheringProgress(name, fraction = 0) {
|
||
this.$trainLabel.text(`Gathering materials for ${name}…`);
|
||
this.$trainProgress.css("width", `${Math.min(100, Math.max(0, fraction) * 100)}%`);
|
||
this.$trainStatus.removeClass("hidden");
|
||
}
|
||
|
||
// ------------------------------------------------------------- buildings --
|
||
|
||
// One card per building, in the shared card idiom. The card keeps only the
|
||
// icon, name and level; the produce/consume/organisation figures live in the
|
||
// hover tooltip and the price on the action button. `row` is mutated by
|
||
// refreshBuildings, and the tooltip reads it when opened so it always quotes
|
||
// the level currently on show.
|
||
_makeBuildingCard(proto, index) {
|
||
const row = { proto, index, level: 0, pending: 0, built: false, requirement: null };
|
||
row.card = buildCard({
|
||
icon: proto.icon,
|
||
name: proto.name,
|
||
status: "Level 0",
|
||
action: { label: "▲" },
|
||
remove: { label: "▾", title: "Downgrade", enabled: false },
|
||
tip: () => this._buildingTip(row),
|
||
onAction: () => this.onBuild(this.cityId, index),
|
||
onRemove: () => this.onDemolish(this.cityId, index),
|
||
});
|
||
// In a local test game, a second button raises the next level for free.
|
||
if (this.testing) {
|
||
row.$free = $("<button class='aero bt-free free-button'></button>")
|
||
.on("click", () => this.onFreeBuild(this.cityId, index));
|
||
row.card.$card.append(row.$free);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
// The hover detail for a building card: what it makes at the level it is
|
||
// buying, what it wears out each day there, and the materials its next order
|
||
// spends.
|
||
_buildingTip(row) {
|
||
const shownLevel = Math.max(row.level + row.pending, 1);
|
||
const effect = buildingEffectText(row.proto, shownLevel);
|
||
const upkeep = protoUpkeep(row.proto, buildingBuildCost(row.proto, shownLevel - 1));
|
||
const cost = buildingBuildCost(row.proto, row.level + row.pending);
|
||
const materials = constructionResourceCost(row.proto, row.level + row.pending, cost);
|
||
return tooltipSections({
|
||
produce: effect && effect !== "No effect" ? [{ node: buildingEffectNodes(row.proto, shownLevel) }] : [],
|
||
consume: this._resourceItems(upkeep),
|
||
consumeSuffix: "/day",
|
||
cost: this._resourceItems(materials),
|
||
});
|
||
}
|
||
|
||
// The positive entries of a resource bill, as tooltip items.
|
||
_resourceItems(resources) {
|
||
const items = [];
|
||
for (const id of CARD_RESOURCES) {
|
||
if (resources && resources[id] > 0) items.push({ id, amount: resources[id] });
|
||
}
|
||
return items;
|
||
}
|
||
|
||
// 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: ${formatMoney(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 built = level > 0;
|
||
const requirement = this._buildRequirement(row.proto);
|
||
row.level = level;
|
||
row.pending = pending;
|
||
row.built = built;
|
||
row.requirement = requirement;
|
||
|
||
const levelText = pending > 0 ? `Level ${level} (+${pending} queued)` : `Level ${level}`;
|
||
row.card.$card.toggleClass("built", built).toggleClass("locked", !!requirement);
|
||
row.card.$status
|
||
.toggleClass("built", built && !requirement)
|
||
.text(requirement || levelText);
|
||
row.card.$action.prop("disabled", !!requirement);
|
||
const cost = buildingBuildCost(row.proto, nextLevel);
|
||
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
|
||
setCardMeta(row.card.$meta, {
|
||
money: requirement ? null : this._quotedCost(cost, row.proto, nextLevel),
|
||
hours: hours(duration),
|
||
});
|
||
if (row.card.$remove) {
|
||
// Downgrading returns nothing, so the control is only enabled once the
|
||
// building actually stands.
|
||
row.card.$remove.prop("disabled", !built);
|
||
}
|
||
if (row.$free) {
|
||
// Free does not ignore placement rules (a port still needs the sea).
|
||
row.$free.text(`Free · ${row.proto.name}`).prop("disabled", !!requirement);
|
||
}
|
||
}
|
||
this._sortBuildingCards();
|
||
}
|
||
|
||
// Buildings already standing lead the grid, so what a city owns is always in
|
||
// view. The order is only rewritten when a building's standing changes, so the
|
||
// horizontal scroll is not disturbed on every snapshot.
|
||
_sortBuildingCards() {
|
||
const signature = this.buildingRows.map((row) => (row.built ? "1" : "0")).join("");
|
||
if (signature === this._buildingOrderSignature) return;
|
||
this._buildingOrderSignature = signature;
|
||
const ordered = this.buildingRows
|
||
.slice()
|
||
.sort((a, b) => (b.built ? 1 : 0) - (a.built ? 1 : 0));
|
||
for (const row of ordered) this.$buildingGrid.append(row.card.$card);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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() {
|
||
hideCardTooltip();
|
||
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;
|
||
}
|