- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red. - Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region. - A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything. - The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it. - Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice. - The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
1941 lines
76 KiB
JavaScript
1941 lines
76 KiB
JavaScript
// The game screen's HUD: the top and bottom panels, selection and stack menus,
|
||
// hover tooltips, the pillage forecast and the modal wiring. These methods run
|
||
// on the GameScreen instance (composed in ../game_screen.js) and only read the
|
||
// snapshot and forward orders.
|
||
|
||
import { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "../modals.js";
|
||
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
|
||
import {
|
||
CIVILISATIONS,
|
||
TRAINABLE_UNIT_IDS,
|
||
BUILDINGS,
|
||
GOVERNMENTS,
|
||
TECHNOLOGIES,
|
||
NEWS_WAR,
|
||
NEWS_TECHNOLOGY,
|
||
NEWS_JOIN,
|
||
ECONOMY,
|
||
EFFECT_PRODUCTION,
|
||
APPROVAL,
|
||
effectValue,
|
||
pillageSteps,
|
||
ROADS,
|
||
TRANSPORT_IMPROVEMENTS,
|
||
transportImprovement,
|
||
TILE_IMPROVEMENTS,
|
||
RESOURCE_BUILDINGS,
|
||
resourceById,
|
||
RESOURCES,
|
||
RESOURCE_RULES,
|
||
tileImprovementById,
|
||
AIR_STRIKE,
|
||
} from "../../../shared/data.js";
|
||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||
import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||
import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||
import { isEconomicMode } from "../map_view/economic.js";
|
||
import { isWaterTile } from "../map_view/textures.js";
|
||
import { audio } from "../audio.js";
|
||
import { currencySymbol } from "../currency.js";
|
||
|
||
export const panelMethods = {
|
||
_bindHud() {
|
||
$("#city-manage").on("click", () => this._openCityModal());
|
||
$("#civ-flag").on("click", () => this._openNationModal());
|
||
$("#news-button").on("click", () => this._openNewsModal());
|
||
$("#map-modes-button").on("click", (event) => {
|
||
event.stopPropagation();
|
||
this.mapModesModal.toggle(this.map.mapMode);
|
||
});
|
||
this.mapModesModal.onMode = (mode) => this._setMapMode(mode);
|
||
// A click anywhere outside the picker dismisses it.
|
||
$(document).on("mousedown.mapmodes", (event) => {
|
||
if (this.mapModesModal.isShowing() &&
|
||
!$(event.target).closest("#map-modes-popover").length &&
|
||
!$(event.target).closest("#map-modes-button").length) {
|
||
this.mapModesModal.close();
|
||
}
|
||
});
|
||
|
||
this.cityModal.onTrain = (cityId, protoIndex) =>
|
||
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
|
||
this.cityModal.onCancel = (cityId, index) =>
|
||
this.sendOrder({ type: "cancel_train", city: cityId, index });
|
||
this.cityModal.onBuild = (cityId, index) =>
|
||
this.sendOrder({ type: "build", city: cityId, building: index });
|
||
this.cityModal.onDemolish = (cityId, index) =>
|
||
this.sendOrder({ type: "demolish", city: cityId, building: index });
|
||
// Testing mode adds a free, instant version of each order. The server
|
||
// ignores `free` unless the game is a local test game.
|
||
this.cityModal.onFreeTrain = (cityId, protoIndex) =>
|
||
this.sendOrder({ type: "train", city: cityId, proto: protoIndex, free: true });
|
||
this.cityModal.onFreeBuild = (cityId, index) =>
|
||
this.sendOrder({ type: "build", city: cityId, building: index, free: true });
|
||
this.nationModal.onGovernment = (index) =>
|
||
this.sendOrder({ type: "government", government: index });
|
||
this.nationModal.onFocusTechnology = (index) =>
|
||
this.sendOrder({ type: "focus_technology", technology: index });
|
||
this.nationModal.onDeclareWar = (target) =>
|
||
this.sendOrder({ type: "declare_war", target });
|
||
this.nationModal.onCampaign = (observer, targetKind, target, direction) =>
|
||
this.sendOrder({ type: "campaign", observer, targetKind, target, direction });
|
||
this.nationModal.onCancelCampaign = (campaign) =>
|
||
this.sendOrder({ type: "cancel_campaign", campaign });
|
||
this.nationModal.onSetCampaignSpending = (campaign, amount) =>
|
||
this.sendOrder({ type: "campaign_spending", campaign, amount });
|
||
this.nationModal.onConfirm = (title, message, onConfirm) =>
|
||
this.confirmModal.show(title, message, onConfirm);
|
||
this.nationModal.onPolicy = (policy, ethnicity) =>
|
||
this.sendOrder({ type: "policy", policy, ethnicity });
|
||
this.nationModal.onRevokePolicy = (policy) =>
|
||
this.sendOrder({ type: "revoke_policy", policy });
|
||
this.nationModal.onSetInterestRate = (rate) =>
|
||
this.sendOrder({ type: "set_interest_rate", rate });
|
||
|
||
$("#unit-attack")
|
||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||
.on("mouseleave", () => this._hideStatTooltip());
|
||
$("#unit-defense")
|
||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||
.on("mouseleave", () => this._hideStatTooltip());
|
||
$("#unit-pillage")
|
||
.on("click", () => this._pillageSelected())
|
||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||
.on("mouseleave", () => this._hideStatTooltip());
|
||
$("#unit-trench").on("click", () => this._digTrenchSelected());
|
||
|
||
// Every summary stat explains itself on hover through the shared bubble.
|
||
$("#top-panel .stat")
|
||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||
.on("mouseleave", () => this._hideStatTooltip());
|
||
|
||
// A click anywhere outside the stack menu dismisses it.
|
||
$(document).on("mousedown.stackmenu", (event) => {
|
||
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
|
||
this._hideStackMenu();
|
||
}
|
||
});
|
||
|
||
$(window).on("keydown.game", (event) => this._onKey(event));
|
||
},
|
||
|
||
// A small hover tooltip anchored above a stat, fed by its data-tooltip text.
|
||
_showStatTooltip($stat) {
|
||
const text = $stat.attr("data-tooltip");
|
||
if (!text) return;
|
||
this._hoveredStat = $stat;
|
||
this.$statTooltip.text(text).removeClass("hidden");
|
||
const offset = $stat.offset() || { left: 0, top: 0 };
|
||
const width = this.$statTooltip.outerWidth() || 0;
|
||
const height = this.$statTooltip.outerHeight() || 0;
|
||
this.$statTooltip.css({
|
||
left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`,
|
||
top: `${Math.max(8, offset.top - height - 8)}px`,
|
||
});
|
||
},
|
||
|
||
_hideStatTooltip() {
|
||
this._hoveredStat = null;
|
||
this.$statTooltip.addClass("hidden");
|
||
},
|
||
|
||
// ------------------------------------------------------- selection ------
|
||
|
||
// Selects the given unit ids (already filtered to our own), refreshing the
|
||
// map highlight and the bottom panel. A single id is a normal selection; an
|
||
// empty list clears; several ids form a stack order.
|
||
_selectUnits(ids) {
|
||
const units = [];
|
||
for (const id of ids) {
|
||
const unit = this._unit(id);
|
||
if (unit && unit.civ === this.localCiv) units.push(unit);
|
||
}
|
||
this.selectedUnitIds = units.map((unit) => unit.id);
|
||
this.selectedCityId = 0;
|
||
if (units.length === 0) {
|
||
// Clearing the selection also drops the inspected tile, so the tile panel
|
||
// closes with it. Selecting units leaves the inspected tile alone, which
|
||
// is what lets the unit panel and the tile panel show together.
|
||
this._inspectedTile = null;
|
||
$("#tile-info, #city-info").addClass("hidden");
|
||
}
|
||
this.map.setSelectedUnits(this.selectedUnitIds);
|
||
this._hideStackMenu();
|
||
this._showUnits(units);
|
||
this._updateTileHighlight();
|
||
},
|
||
|
||
_selectedUnits() {
|
||
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
|
||
},
|
||
|
||
// Outlines the tile the current selection or inspection sits on: the first
|
||
// selected unit's tile, else the selected city's, else the inspected tile.
|
||
_updateTileHighlight() {
|
||
if (this.selectedUnitIds.length > 0) {
|
||
const unit = this._unit(this.selectedUnitIds[0]);
|
||
if (unit) {
|
||
this.map.setSelectedTile({ x: unit.coords[0], y: unit.coords[1] });
|
||
return;
|
||
}
|
||
}
|
||
if (this.selectedCityId) {
|
||
const city = this._city(this.selectedCityId);
|
||
if (city) {
|
||
this.map.setSelectedTile({ x: city.coords[0], y: city.coords[1] });
|
||
return;
|
||
}
|
||
}
|
||
if (this._inspectedTile) {
|
||
this.map.setSelectedTile(this._inspectedTile);
|
||
return;
|
||
}
|
||
this.map.clearSelectedTile();
|
||
},
|
||
|
||
_openStackMenu(coords, ids, clientX, clientY) {
|
||
const units = [];
|
||
for (const id of ids) {
|
||
const unit = this._unit(id);
|
||
if (unit && unit.civ === this.localCiv) units.push(unit);
|
||
}
|
||
if (units.length === 0) return;
|
||
const $menu = this.$stackMenu.empty();
|
||
const selection = this._selectedUnits();
|
||
if (selection.length > 0) {
|
||
const $move = $("<button class='stack-item stack-move'></button>").text("Move here");
|
||
$move.on("click", () => {
|
||
this._hideStackMenu();
|
||
this._orderMove(selection, coords);
|
||
});
|
||
$menu.append($move);
|
||
}
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
const $item = $("<button class='stack-item'></button>")
|
||
.text(`${proto ? proto.name : "Unit"} — ${Math.round(unit.hp)}/${unit.maxHp} HP`);
|
||
$item.on("click", () => this._selectUnits([unit.id]));
|
||
$menu.append($item);
|
||
}
|
||
const $all = $("<button class='stack-item stack-all'></button>").text(
|
||
`Select all (${units.length})`
|
||
);
|
||
$all.on("click", () => this._selectUnits(units.map((unit) => unit.id)));
|
||
$menu.append($all);
|
||
$menu.removeClass("hidden").css({ left: `${clientX}px`, top: `${clientY}px` });
|
||
},
|
||
|
||
_hideStackMenu() {
|
||
if (this.$stackMenu && this.$stackMenu.length) this.$stackMenu.addClass("hidden");
|
||
},
|
||
|
||
// The slowest member of a group sets the pace. The fallback matches the
|
||
// server's DEFAULT_UNIT_SPEED so prediction cannot disagree with the
|
||
// authoritative movement cost.
|
||
_groupSpeed(units) {
|
||
let speed = Infinity;
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
const value = proto && proto.speed ? proto.speed : DEFAULT_UNIT_SPEED;
|
||
speed = Math.min(speed, value);
|
||
}
|
||
return Number.isFinite(speed) ? speed : null;
|
||
},
|
||
|
||
async _groupMovePlan(units, coords) {
|
||
const speed = units.length > 1 ? this._groupSpeed(units) : null;
|
||
const targets = [];
|
||
const routes = new Map();
|
||
// Same-type units in one stack share a route, so search once per type.
|
||
const cache = new Map();
|
||
for (const unit of units) {
|
||
const route = await this.map.planMove(unit, coords, speed, cache);
|
||
if (!route || route.length < 2) continue;
|
||
routes.set(unit.id, route);
|
||
if (!this._isMilitaryUnit(unit)) continue;
|
||
for (const point of route) {
|
||
const owner = this.map.territory.get(`${point.x},${point.y}`);
|
||
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
|
||
if (this._isAtWar(unit.civ, owner)) continue;
|
||
if (!targets.includes(owner)) targets.push(owner);
|
||
}
|
||
}
|
||
return { targets, speed, routes };
|
||
},
|
||
|
||
// ------------------------------------------------------- pillage -------
|
||
|
||
// What the selected units would remove, gain and heal by pillaging the tile
|
||
// they stand on, or null when they cannot pillage.
|
||
_pillageForecast(units) {
|
||
if (units.length === 0) return null;
|
||
const coords = units[0].coords;
|
||
const sameTile = units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]);
|
||
if (!sameTile) return null;
|
||
const owner = this._territoryOwner.has(`${coords[0]},${coords[1]}`)
|
||
? this._territoryOwner.get(`${coords[0]},${coords[1]}`)
|
||
: -1;
|
||
if (owner < 0 || owner === this.localCiv) return null;
|
||
// A unit in combat cannot pillage.
|
||
if (this._battles.has(`${coords[0]},${coords[1]}`)) return null;
|
||
const population = this._population.get(`${coords[0]},${coords[1]}`) || 0;
|
||
const { steps, productionMultiplier } = pillageSteps(population, units.length);
|
||
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
|
||
const healed = ordered.map((unit, index) =>
|
||
Math.min(unit.maxHp, unit.hp + steps[index].heal) - unit.hp
|
||
);
|
||
return { population, steps, productionMultiplier, healed };
|
||
},
|
||
|
||
_updatePillageButton(units) {
|
||
const $button = $("#unit-pillage");
|
||
const forecast = this._pillageForecast(units);
|
||
if (!forecast) {
|
||
$button.addClass("hidden").removeAttr("data-tooltip");
|
||
return;
|
||
}
|
||
const removed = forecast.population - forecast.steps[forecast.steps.length - 1].remaining;
|
||
const share = forecast.population > 0 ? (removed / forecast.population) * 100 : 0;
|
||
const heals = forecast.healed.map((hp) => `+${Math.round(hp)}`).join(", ");
|
||
$button
|
||
.removeClass("hidden")
|
||
.attr(
|
||
"data-tooltip",
|
||
`Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
|
||
`production ×${forecast.productionMultiplier.toFixed(2)} · heals ${heals} HP`
|
||
);
|
||
},
|
||
|
||
// Whether the selected units may dig in where they stand: every one is
|
||
// infantry, sharing one controlled land tile with no trench yet.
|
||
_canDigTrench(units) {
|
||
if (units.length === 0) return false;
|
||
const coords = units[0].coords;
|
||
if (!units.every((u) => u.coords[0] === coords[0] && u.coords[1] === coords[1])) return false;
|
||
if (!units.every((u) => {
|
||
const proto = this.protoUnits[u.proto];
|
||
return proto && proto.infantry;
|
||
})) return false;
|
||
const k = `${coords[0]},${coords[1]}`;
|
||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
|
||
if (owner !== this.localCiv) return false;
|
||
const tile = this.map.getTile({ x: coords[0], y: coords[1] });
|
||
if (!tile || tile.terrainClass !== "Land") return false;
|
||
return !this.map.trenches.has(k);
|
||
},
|
||
|
||
_updateTrenchButton(units) {
|
||
const $button = $("#unit-trench");
|
||
if (!$button.length) return;
|
||
$button.toggleClass("hidden", !this._canDigTrench(units));
|
||
},
|
||
|
||
_digTrenchSelected() {
|
||
const units = this._selectedUnits();
|
||
if (!this._canDigTrench(units)) return;
|
||
this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
|
||
},
|
||
|
||
_isMilitaryUnit(unit) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
return !!(proto && proto.military);
|
||
},
|
||
|
||
// A unit that attacks from range instead of charging into melee: every
|
||
// aircraft and anything with a reach beyond a single tile. Mirrors
|
||
// GameState._isStrikeUnit so the browser predicts the same outcome.
|
||
_isRangedUnit(unit) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
return !!(proto && (proto.strike || proto.air || (proto.range || 1) > 1));
|
||
},
|
||
|
||
_isAirUnit(unit) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
return !!(proto && proto.air);
|
||
},
|
||
|
||
// Whether `coords` holds a hostile military unit, the only thing a strike may
|
||
// target.
|
||
_isEnemyMilitaryAt(coords) {
|
||
return this.map.unitIdsAt(coords).some((id) => {
|
||
const data = this._unit(id);
|
||
return data && data.civ !== this.localCiv && this._isMilitaryUnit(data);
|
||
});
|
||
},
|
||
|
||
// A ground/naval battery attack: resolved where the battery stands, with a
|
||
// standing hourly target rather than a march.
|
||
_rangedAttackOrder(units, coords) {
|
||
if (units.length === 0) return false;
|
||
if (units.some((unit) => this._isAirUnit(unit))) return false;
|
||
if (!units.every((unit) => this._isRangedUnit(unit))) return false;
|
||
return this._isEnemyMilitaryAt(coords);
|
||
},
|
||
|
||
// An aircraft attack: the plane flies to the hostile unit and bombs it on
|
||
// arrival, then returns home to re-arm.
|
||
_airStrikeOrder(units, coords) {
|
||
if (units.length === 0) return false;
|
||
if (!units.every((unit) => this._isAirUnit(unit))) return false;
|
||
return this._isEnemyMilitaryAt(coords);
|
||
},
|
||
|
||
// Why an air strike cannot be issued right now, or null when it can. Blocks a
|
||
// plane that is already flying a mission, is still re-arming, or whose
|
||
// nation cannot afford the (deliberately steep) price.
|
||
_airStrikeBlocked(units) {
|
||
const snapshot = this.snapshot || {};
|
||
const totalHours = snapshot.totalHours || 0;
|
||
const budget = (snapshot.viewerStats || {}).budget || 0;
|
||
for (const unit of units) {
|
||
if (unit.strikeTarget) {
|
||
return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." };
|
||
}
|
||
if ((unit.strikeReadyHour || 0) > totalHours) {
|
||
const hours = Math.ceil(unit.strikeReadyHour - totalHours);
|
||
return {
|
||
title: "Aircraft re-arming",
|
||
message: `This aircraft cannot strike again for ${hours} more hour(s).`,
|
||
};
|
||
}
|
||
}
|
||
const cost = (AIR_STRIKE.cost || 0) * units.length;
|
||
if (cost > budget) {
|
||
const prices = marketPricesFrom(this.snapshot);
|
||
return {
|
||
title: "Insufficient budget",
|
||
message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`,
|
||
};
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// The friendly airport an aircraft would fly home to from `coords`, mirroring
|
||
// GameState._returnAirport: the nearest one to where it is heading, so a
|
||
// rebased plane is not sent back across the map to its original base.
|
||
_airReturnAirport(unit, coords) {
|
||
const cities = (this.snapshot && this.snapshot.cities) || [];
|
||
const index = BUILDINGS.findIndex((b) => b.id === "airport");
|
||
const hasAirport = (city) =>
|
||
index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
|
||
let best = null;
|
||
let bestDistance = Infinity;
|
||
for (const city of cities) {
|
||
if (city.civ !== this.localCiv || !hasAirport(city)) continue;
|
||
const distance = this.map.topology.tileDistance(coords, {
|
||
x: city.coords[0],
|
||
y: city.coords[1],
|
||
});
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
best = city;
|
||
}
|
||
}
|
||
return best;
|
||
},
|
||
|
||
// Why an air strike on `coords` is out of reach, or null when the whole round
|
||
// trip fits. Mirrors GameState._strikeReady so the browser refuses exactly
|
||
// what the server would, instead of sending an order that is dropped.
|
||
_airStrikeRangeError(units, coords) {
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
if (!proto) return "Unknown aircraft.";
|
||
const from = { x: unit.coords[0], y: unit.coords[1] };
|
||
const toTarget = this.map.topology.tileDistance(from, coords);
|
||
const airport = this._airReturnAirport(unit, coords);
|
||
if (!airport) return "This aircraft has no friendly airport to return to.";
|
||
const toHome = this.map.topology.tileDistance(coords, {
|
||
x: airport.coords[0],
|
||
y: airport.coords[1],
|
||
});
|
||
const speed = proto.speed || 1;
|
||
const fuel = unit.airHours === undefined || unit.airHours === null
|
||
? proto.enduranceHours
|
||
: unit.airHours;
|
||
// The furthest it can strike: half the fuel buys the flight out, the other
|
||
// half the flight home, capped by the mission radius.
|
||
const effectiveRange = Math.floor(
|
||
Math.min(proto.missionRange || Infinity, (fuel * speed) / 2 + 1e-9)
|
||
);
|
||
const beyondMission =
|
||
toTarget > (proto.missionRange || Infinity);
|
||
const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9;
|
||
if (beyondMission || beyondFuel) {
|
||
return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Whether `coords` is one of our own cities with an airport, the only tiles
|
||
// an aircraft may be ordered to when it is not attacking.
|
||
_isFriendlyAirport(coords) {
|
||
const city = ((this.snapshot && this.snapshot.cities) || []).find(
|
||
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
|
||
);
|
||
if (!city || city.civ !== this.localCiv) return false;
|
||
const index = BUILDINGS.findIndex((b) => b.id === "airport");
|
||
return index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
|
||
},
|
||
|
||
_isAtWar(a, b) {
|
||
if (a < 0 || b < 0 || a === b) return false;
|
||
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
|
||
},
|
||
|
||
_civName(index) {
|
||
const civ = this.civilisations[index];
|
||
return civ ? civ.name : `Nation ${index + 1}`;
|
||
},
|
||
|
||
_civDemonym(index) {
|
||
const civ = this.civilisations[index];
|
||
return civ && civ.demonym ? civ.demonym : this._civName(index);
|
||
},
|
||
|
||
// ------------------------------------------------------- top panel -----
|
||
|
||
_updateTopPanel(state) {
|
||
const civ = this.civilisations[this.localCiv];
|
||
if (civ) {
|
||
$("#civ-name").text(civ.name);
|
||
const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag;
|
||
if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`);
|
||
}
|
||
$("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || "");
|
||
$("#admin-label").toggle(this.network.isAdmin);
|
||
$("#stat-date").text(dateString(state.totalHours));
|
||
const stats = state.viewerStats || {};
|
||
// The nation's figures one calendar month ago, when there is one, so each
|
||
// stat can show whether it rose or fell since.
|
||
const month = ((state.monthly || [])[this.localCiv]) || null;
|
||
$("#stat-pop").text(compact(stats.population || 0));
|
||
$("#stat-gdp").text(`${currencySymbol()}${compact(stats.gdp || 0)}`);
|
||
$("#stat-gdppc").text(`${currencySymbol()}${compact(stats.gdpPerCapita || 0)}`);
|
||
$("#stat-budget").text(`${currencySymbol()}${compact(stats.budget || 0)}`);
|
||
// GDP, population and GDP/capita double the arrow past a 10% month-on-month
|
||
// move; the others only ever show one.
|
||
this._updateStatArrow("stat-pop", stats.population, month && month.population, 0.1);
|
||
this._updateStatArrow("stat-gdp", stats.gdp, month && month.gdp, 0.1);
|
||
this._updateStatArrow("stat-gdppc", stats.gdpPerCapita, month && month.gdpPerCapita, 0.1);
|
||
this._updateStatArrow("stat-budget", stats.budget, month && month.budget, null);
|
||
// The stat tooltips carry the longer-run growth: against January 2000 for
|
||
// the first year, then against the same month a year earlier.
|
||
this._updateGrowthTooltips(stats.growth || null);
|
||
this._updateBudgetTooltip(stats, state.totalHours);
|
||
const popularityPoints = stats.popularity || 0;
|
||
const popularity = popularityDisplay(popularityPoints);
|
||
this._updateApprovalStat(popularity);
|
||
// The people drift one point an hour back to neutral, so the estimate is
|
||
// simply how many points away they are; zero is already there.
|
||
const pointsAway = Math.abs(popularityPoints);
|
||
this._setStatTooltip(
|
||
"stat-approval",
|
||
pointsAway <= 0
|
||
? "Popularity is exactly neutral"
|
||
: `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
|
||
"at the current drift of one point an hour"
|
||
);
|
||
// Popularity doubles its arrow past ten percentage points.
|
||
this._updateStatArrow(
|
||
"stat-approval",
|
||
popularity,
|
||
month ? popularityDisplay(month.popularity || 0) : null,
|
||
0.1,
|
||
0.005
|
||
);
|
||
// Keep an open stat tooltip in step with the figures it describes.
|
||
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
|
||
const text = this._hoveredStat.attr("data-tooltip");
|
||
if (text) this.$statTooltip.text(text);
|
||
}
|
||
},
|
||
|
||
// Appends the month-on-month change marker inside a stat's value: a bold
|
||
// green up arrow, a red down arrow, doubled once a big move occurred, or an
|
||
// equals sign when nothing changed. No baseline (January 2000) means none.
|
||
_updateStatArrow(statId, current, previous, doubleAt = null, equalEpsilon = 0.5) {
|
||
const $value = $(`#${statId}`);
|
||
if (!$value.length) return;
|
||
$value.find(".stat-arrow").remove();
|
||
if (previous === undefined || previous === null || current === undefined || current === null) {
|
||
return;
|
||
}
|
||
const diff = current - previous;
|
||
const $arrow = $("<span class='stat-arrow'></span>");
|
||
if (Math.abs(diff) <= equalEpsilon) {
|
||
$arrow.text("=").addClass("equal");
|
||
} else {
|
||
const rising = diff > 0;
|
||
const double = doubleAt !== null && previous !== 0 &&
|
||
Math.abs(diff) / Math.abs(previous) > doubleAt;
|
||
$arrow
|
||
.text(rising ? (double ? "▲▲" : "▲") : (double ? "▼▼" : "▼"))
|
||
.addClass(rising ? "up" : "down");
|
||
}
|
||
$value.append($arrow);
|
||
},
|
||
|
||
// Shows or clears a summary stat's hover tooltip. Passing no text removes it,
|
||
// so a stale bubble never lingers. Any older native title is dropped too.
|
||
_setStatTooltip(statId, text) {
|
||
const $stat = $(`#${statId}`).closest(".stat");
|
||
if (!$stat.length) return;
|
||
$stat.removeAttr("title");
|
||
if (text) $stat.attr("data-tooltip", text);
|
||
else $stat.removeAttr("data-tooltip");
|
||
},
|
||
|
||
// Puts the long-run growth figure in each headline stat's hover tooltip: the
|
||
// rise since January 2000 through the first year, and the year-on-year rise
|
||
// afterwards. The hovered datum names itself, so the text stays short.
|
||
_updateGrowthTooltips(growth) {
|
||
const scope = growth && growth.sinceStart ? "since 1 January 2000" : "year on year";
|
||
const apply = (statId, rate) => {
|
||
if (!growth || !Number.isFinite(rate)) {
|
||
this._setStatTooltip(statId, null);
|
||
return;
|
||
}
|
||
const percent = `${rate < 0 ? "−" : "+"}${(Math.abs(rate) * 100).toFixed(2)}%`;
|
||
this._setStatTooltip(statId, `${scope}: ${percent}`);
|
||
};
|
||
apply("stat-pop", growth && growth.population);
|
||
apply("stat-gdp", growth && growth.gdp);
|
||
apply("stat-gdppc", growth && growth.gdpPerCapita);
|
||
},
|
||
|
||
// Forecasts the budget a month ahead at the current income and upkeep: the
|
||
// hourly net spread over the hours of the next calendar month.
|
||
_updateBudgetTooltip(stats, totalHours) {
|
||
const net = stats.breakdown ? stats.breakdown.net : null;
|
||
if (net === null || !Number.isFinite(net)) {
|
||
this._setStatTooltip("stat-budget", null);
|
||
return;
|
||
}
|
||
const expected = net * hoursInNextMonth(totalHours || 0);
|
||
const sign = expected < 0 ? "−" : "+";
|
||
this._setStatTooltip("stat-budget", `${sign}${currencySymbol()}${compact(Math.abs(expected))}/mo`);
|
||
},
|
||
|
||
// The summary's popularity figure: white while it sits in the neutral band
|
||
// (40–60%), red when the people disapprove and green when they approve.
|
||
_updateApprovalStat(approval) {
|
||
const $approval = $("#stat-approval");
|
||
if (!$approval.length) return;
|
||
const pct = Math.round(approval * 100);
|
||
$approval.text(`${pct}%`);
|
||
$approval
|
||
.removeClass("approval-neutral approval-positive approval-negative")
|
||
.addClass(
|
||
pct < 40
|
||
? "approval-negative"
|
||
: pct > 60
|
||
? "approval-positive"
|
||
: "approval-neutral"
|
||
);
|
||
},
|
||
|
||
_updateBottomPanel() {
|
||
if (this.selectedUnitIds.length > 0) {
|
||
this._showUnits(this._selectedUnits());
|
||
} else if (this.selectedCityId) {
|
||
const city = this._city(this.selectedCityId);
|
||
if (city) this._showCity(city);
|
||
} else if (this._inspectedTile) {
|
||
// Keep the inspected tile's improvements and build button current after
|
||
// the world changes (a road may have just been built on it).
|
||
this._renderTileInfo(this._inspectedTile);
|
||
}
|
||
},
|
||
|
||
// Shows the bottom panel for one or many selected units. A stack shows its
|
||
// size and combined health, plus the pillage action when it can use it.
|
||
_showUnits(units) {
|
||
if (units.length === 0) {
|
||
$("#unit-info").addClass("hidden");
|
||
this._clearStackList();
|
||
this._updateCargo([]);
|
||
this._hideStatTooltip();
|
||
this._refreshPanelVisibility();
|
||
return;
|
||
}
|
||
if (units.length === 1) {
|
||
this._showUnit(units[0]);
|
||
return;
|
||
}
|
||
const hp = units.reduce((sum, unit) => sum + unit.hp, 0);
|
||
const maxHp = units.reduce((sum, unit) => sum + unit.maxHp, 0);
|
||
$("#unit-name").text(`${units.length} units selected`);
|
||
$("#unit-attack, #unit-defense").addClass("hidden");
|
||
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
|
||
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
|
||
this._renderStackList(units);
|
||
this._updatePillageButton(units);
|
||
this._updateTrenchButton(units);
|
||
this._updateCargo(units);
|
||
$("#unit-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
},
|
||
|
||
// The stack panel's checklist: every member of the selected stack, ticked,
|
||
// so the player can narrow the selection to the units they want to order.
|
||
_renderStackList(units) {
|
||
const $list = $("#unit-stack-list");
|
||
if (!$list.length) return;
|
||
$list.removeClass("hidden");
|
||
const signature = units.map((unit) => unit.id).join(",");
|
||
if (signature !== this._stackListSignature) {
|
||
this._stackListSignature = signature;
|
||
$list.empty();
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
const $row = $("<label class='stack-unit'></label>");
|
||
const $check = $("<input type='checkbox'>")
|
||
.attr("data-id", unit.id)
|
||
.prop("checked", true);
|
||
$check.on("change", () => {
|
||
const ids = [];
|
||
$list.find("input:checked").each((_index, element) => {
|
||
ids.push(Number($(element).attr("data-id")));
|
||
});
|
||
this._selectUnits(ids);
|
||
});
|
||
$row.append(
|
||
$check,
|
||
$("<span class='stack-unit-name'></span>").text(proto ? proto.name : "Unit"),
|
||
$("<span class='stack-unit-hp'></span>").text(
|
||
`${Math.round(unit.hp)}/${unit.maxHp}`
|
||
)
|
||
);
|
||
$list.append($row);
|
||
}
|
||
}
|
||
// Keep the hit-point readouts current even when the set did not change.
|
||
units.forEach((unit, index) => {
|
||
$list
|
||
.find(".stack-unit-hp")
|
||
.eq(index)
|
||
.text(`${Math.round(unit.hp)}/${unit.maxHp}`);
|
||
});
|
||
},
|
||
|
||
_clearStackList() {
|
||
const $list = $("#unit-stack-list");
|
||
if ($list.length) $list.addClass("hidden").empty();
|
||
this._stackListSignature = null;
|
||
},
|
||
|
||
_showUnit(unit) {
|
||
if (!unit) {
|
||
$("#unit-info").addClass("hidden");
|
||
this._clearStackList();
|
||
this._hideStatTooltip();
|
||
this._refreshPanelVisibility();
|
||
return;
|
||
}
|
||
this._clearStackList();
|
||
const proto = this.protoUnits[unit.proto];
|
||
$("#unit-name").text(this._unitLabel(unit, proto));
|
||
$("#unit-attack, #unit-defense").removeClass("hidden");
|
||
this._showUnitStats(unit, proto);
|
||
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
|
||
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
|
||
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
|
||
this._updatePillageButton([unit]);
|
||
this._updateTrenchButton([unit]);
|
||
this._updateCargo([unit]);
|
||
$("#unit-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
},
|
||
|
||
// The unit's name with its current air activity, so the panel explains why a
|
||
// plane cannot strike again yet.
|
||
_unitLabel(unit, proto) {
|
||
const name = proto ? proto.name : "Unit";
|
||
if (!proto || !proto.air) return name;
|
||
const totalHours = (this.snapshot && this.snapshot.totalHours) || 0;
|
||
if (unit.strikeTarget) return `${name} — on a strike mission`;
|
||
if ((unit.strikeReadyHour || 0) > totalHours) {
|
||
const hours = Math.ceil(unit.strikeReadyHour - totalHours);
|
||
return `${name} — re-arming (${hours} h)`;
|
||
}
|
||
if (unit.airborne) return `${name} — airborne`;
|
||
return name;
|
||
},
|
||
|
||
// Attack and defense of the unit where it stands, with the hovering tooltips
|
||
// listing each factor that shaped the number.
|
||
_showUnitStats(unit, proto) {
|
||
const coords = { x: unit.coords[0], y: unit.coords[1] };
|
||
const tile = this.map.getTile(coords);
|
||
const hasCity = ((this.snapshot && this.snapshot.cities) || []).some(
|
||
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
|
||
);
|
||
const baseAttack = proto && typeof proto.attack === "number" ? proto.attack : 0;
|
||
const baseDefense = proto && typeof proto.defense === "number" ? proto.defense : 0;
|
||
$("#unit-attack")
|
||
.text(`Attack ${Math.round(baseAttack)}`)
|
||
.attr("data-tooltip", `Base ${Math.round(baseAttack)}`);
|
||
|
||
const factors = defenseFactors(tile, hasCity);
|
||
const defense = unitDefense(baseDefense, tile, hasCity);
|
||
const parts = [`Base ${Math.round(baseDefense)}`];
|
||
for (const factor of factors) {
|
||
parts.push(`${factor.label} +${Math.round(baseDefense * factor.bonus)}`);
|
||
}
|
||
$("#unit-defense")
|
||
.text(`Defense ${Math.round(defense)}`)
|
||
.attr("data-tooltip", parts.join(", "));
|
||
// Keep an open tooltip in step with the unit it describes across snapshots.
|
||
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
|
||
const text = this._hoveredStat.attr("data-tooltip");
|
||
if (text) this.$statTooltip.text(text);
|
||
} else {
|
||
this._hideStatTooltip();
|
||
}
|
||
},
|
||
|
||
// A transport shows its berths and who is aboard, and any boarding still
|
||
// under way; non-transports show nothing.
|
||
_updateCargo(units) {
|
||
const $cargo = $("#unit-cargo");
|
||
if (!$cargo.length) return;
|
||
const lines = [];
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
if (!proto || !proto.transportCapacity) continue;
|
||
const cargo = unit.cargo || [];
|
||
const names = cargo
|
||
.map((passenger) => {
|
||
const passengerProto = this.protoUnits[passenger.proto];
|
||
return passengerProto ? passengerProto.name : "troops";
|
||
})
|
||
.join(", ");
|
||
let text = `Cargo ${cargo.length}/${proto.transportCapacity}`;
|
||
if (names) text += ` — ${names}`;
|
||
if (unit.embarking && unit.embarking.count) {
|
||
text += ` · boarding ${unit.embarking.count} (${Math.ceil(unit.embarking.hoursLeft)} h)`;
|
||
}
|
||
lines.push(text);
|
||
}
|
||
if (lines.length === 0) {
|
||
$cargo.addClass("hidden").text("");
|
||
return;
|
||
}
|
||
$cargo.removeClass("hidden").text(lines.join(" · "));
|
||
},
|
||
|
||
// A city panel shows the tile it stands on (owner, terrain, population, GDP
|
||
// per capita, improvements) plus the city's name and its actions; the tile
|
||
// fields are the same ones the plain tile panel renders, so nothing is
|
||
// duplicated between the two.
|
||
// `keepUnits` leaves the unit panel open, so clicking a unit that stands on a
|
||
// city shows the city's tile detail without dropping the unit selection.
|
||
_showCity(city, { keepUnits = false } = {}) {
|
||
if (!city) {
|
||
$("#city-info").addClass("hidden");
|
||
$("#tile-info").addClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
return;
|
||
}
|
||
this._inspectedTile = null;
|
||
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
|
||
$("#city-name").text(city.name);
|
||
$("#city-manage").toggleClass("hidden", city.civ !== this.localCiv);
|
||
if (!keepUnits) $("#unit-info").addClass("hidden");
|
||
$("#tile-info, #city-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
this._updateTileHighlight();
|
||
},
|
||
|
||
// Selects one of the player's cities and shows its quick panel. Returns false
|
||
// when the city is unknown or someone else's. Double-clicking the city icon
|
||
// uses the same selection before opening the full city screen.
|
||
_selectCity(id) {
|
||
const city = (this.snapshot.cities || []).find((c) => c.id === id);
|
||
if (!city || city.civ !== this.localCiv) return false;
|
||
this.selectedCityId = id;
|
||
this.selectedUnitIds = [];
|
||
this.map.setSelectedUnits([]);
|
||
this._hideStackMenu();
|
||
this._showCity(city);
|
||
return true;
|
||
},
|
||
|
||
// Clicking a unit opens the panel for the tile it stands on (or the city it
|
||
// occupies), alongside the unit panel rather than replacing it.
|
||
_inspectUnitTile(unit) {
|
||
const coords = { x: unit.coords[0], y: unit.coords[1] };
|
||
const city = (this.snapshot.cities || []).find(
|
||
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
|
||
);
|
||
if (city) {
|
||
// Keep the city id selected as well as the units, so the city panel's
|
||
// button still knows which city to open.
|
||
this.selectedCityId = city.id;
|
||
this._showCity(city, { keepUnits: true });
|
||
return;
|
||
}
|
||
this._inspectedTile = coords;
|
||
this._renderTileInfo(coords);
|
||
$("#city-info").addClass("hidden");
|
||
$("#tile-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
this._updateTileHighlight();
|
||
},
|
||
|
||
_showTile(coords) {
|
||
// Inspecting a tile drops any city or unit selection, otherwise the next
|
||
// snapshot would immediately switch the panel back to the old selection.
|
||
this.selectedCityId = 0;
|
||
this.selectedUnitIds = [];
|
||
this._inspectedTile = { x: coords.x, y: coords.y };
|
||
this.map.setSelectedUnits([]);
|
||
this._hideStackMenu();
|
||
this._renderTileInfo(coords);
|
||
$("#city-info, #unit-info").addClass("hidden");
|
||
$("#tile-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
this._updateTileHighlight();
|
||
},
|
||
|
||
// Fills the shared tile fields without touching which section is visible.
|
||
_renderTileInfo(coords) {
|
||
const k = `${coords.x},${coords.y}`;
|
||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
|
||
const tile = this.map.getTile(coords);
|
||
const city = (this.snapshot.cities || []).find(
|
||
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
|
||
);
|
||
const regionCity = (this.snapshot.cities || []).find(
|
||
(c) => c.id === this._tileRegion.get(k)
|
||
);
|
||
$("#tile-desc").text(this._tileDescription(coords, owner, tile, regionCity));
|
||
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
|
||
this._renderTilePeople(k);
|
||
this._renderTileProduction(
|
||
this._tileResourceProduction(coords, owner, city, tile)
|
||
);
|
||
this._renderRegionGdp(owner >= 0 ? regionCity : null);
|
||
const transport = this.map.railways.has(k)
|
||
? ["Railway"]
|
||
: this.map.roads.has(k)
|
||
? [ROADS.improvementName]
|
||
: [];
|
||
const cityImprovements = (city ? city.improvements || [] : []).filter(
|
||
(name) => name !== ROADS.improvementName && name !== "Railway"
|
||
);
|
||
// Military works and trenches are shown alongside the transport network.
|
||
const works = [];
|
||
if (this.map.trenches.has(k)) works.push("Trench");
|
||
const militaryWorks = this.map.tileImprovements.get(k);
|
||
if (militaryWorks) {
|
||
const proto = tileImprovementById(militaryWorks.id);
|
||
works.push(proto ? proto.name : militaryWorks.id);
|
||
}
|
||
$("#tile-improvements").text(
|
||
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
|
||
);
|
||
this._renderTileWorks(coords);
|
||
this._renderCityProduction(city);
|
||
// Only a land tile the player controls can carry a new improvement. A city
|
||
// improves itself, so it never offers the build buttons.
|
||
const buildable = !city &&
|
||
owner === this.localCiv &&
|
||
owner >= 0 &&
|
||
!!tile &&
|
||
tile.terrainClass === "Land";
|
||
const current = this.map.railways.has(k)
|
||
? "railway"
|
||
: this.map.roads.has(k)
|
||
? "road"
|
||
: null;
|
||
this._renderImprovementActions(coords, buildable, current);
|
||
},
|
||
|
||
// The military improvement under construction on this tile, drawn with the
|
||
// same progress bar as a city's production.
|
||
_renderTileWorks(coords) {
|
||
const $el = $("#tile-works");
|
||
if (!$el.length) return;
|
||
$el.empty();
|
||
const queue = (this.snapshot.tileWorks || [])[this.localCiv] || [];
|
||
const work = queue.find(
|
||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||
);
|
||
if (!work) return;
|
||
const proto = tileImprovementById(work.id);
|
||
const name = proto ? proto.name : work.id;
|
||
const total = Math.max(work.totalHours || 0, 0.0001);
|
||
const elapsed = work.elapsedHours || 0;
|
||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||
$el.append(
|
||
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
|
||
$bar
|
||
);
|
||
},
|
||
|
||
// The city panel's quick view of what is currently being produced there, so
|
||
// the player can read progress without opening the city modal. A foreign
|
||
// city's production is never shown, even when its tile is inspected.
|
||
_renderCityProduction(city) {
|
||
const $el = $("#city-production");
|
||
if (!$el.length) return;
|
||
$el.empty();
|
||
if (!city || city.civ !== this.localCiv) return;
|
||
const queue = city ? this._trainingQueue(city.id) : null;
|
||
const entry = queue && queue[0];
|
||
if (!entry) return;
|
||
const building = entry.kind === "building";
|
||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||
const name = proto ? proto.name : (building ? "Building" : "Unit");
|
||
const verb = building ? "Building" : "Training";
|
||
const total = Math.max(entry.totalHours || 0, 0.0001);
|
||
const elapsed = entry.elapsedHours || 0;
|
||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||
$el.append(
|
||
$("<div class='city-production-label'></div>").text(`${verb} ${name} — ${remaining} h left`),
|
||
$bar
|
||
);
|
||
},
|
||
|
||
// A square icon button for one buildable improvement: the type's icon with
|
||
// its cost beneath, and the name (plus upkeep and build time) in the tooltip.
|
||
// A `costLabel` override replaces the resource cost (used by the free button).
|
||
_improvementButton(improvement, { className, costLabel, title, disabled, current, prices } = {}) {
|
||
const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
|
||
const $button = $(`<button type='button' class='${classes}'></button>`)
|
||
.attr("title", title)
|
||
.prop("disabled", disabled)
|
||
.toggleClass("current", current);
|
||
$button.append(
|
||
$("<img class='improvement-icon'>")
|
||
.attr("src", `assets/${improvement.icon}`)
|
||
.attr("alt", improvement.name)
|
||
);
|
||
if (costLabel !== undefined) {
|
||
$button.append($("<span class='improvement-cost'></span>").text(costLabel));
|
||
} else {
|
||
$button.append(this._improvementCost(improvement, prices));
|
||
}
|
||
return $button;
|
||
},
|
||
|
||
// The cost line under an improvement icon: the steel, electricity and
|
||
// high-tech it spends, each with its resource icon, then the full price at
|
||
// today's market (build cost plus the material bill) in parentheses.
|
||
_improvementCost(improvement, prices) {
|
||
const costs = tileImprovementResourceCost(improvement);
|
||
const $cost = $("<span class='improvement-cost'></span>");
|
||
const items = [];
|
||
for (const id of ["steel", "energy", "hightech"]) {
|
||
if (!(costs[id] > 0)) continue;
|
||
const proto = resourceById(id);
|
||
items.push(
|
||
$("<span class='cost-res'></span>").append(
|
||
$("<img class='cost-icon'>")
|
||
.attr("src", `assets/${proto.icon}`)
|
||
.attr("alt", proto.name)
|
||
.attr("title", proto.name),
|
||
$("<span></span>").text(formatResourceAmount(id, costs[id]))
|
||
)
|
||
);
|
||
}
|
||
items.push(
|
||
$("<span class='cost-money'></span>").text(
|
||
`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`
|
||
)
|
||
);
|
||
items.forEach((item, index) => {
|
||
if (index > 0) $cost.append(" ");
|
||
$cost.append(item);
|
||
});
|
||
return $cost;
|
||
},
|
||
|
||
// The same cost as plain text, for a tooltip: the resource amounts then the
|
||
// market price.
|
||
_improvementCostText(improvement, prices) {
|
||
const costs = tileImprovementResourceCost(improvement);
|
||
const parts = [];
|
||
for (const id of ["steel", "energy", "hightech"]) {
|
||
if (costs[id] > 0) {
|
||
parts.push(`${formatResourceAmount(id, costs[id])} ${resourceById(id).name.toLowerCase()}`);
|
||
}
|
||
}
|
||
parts.push(`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`);
|
||
return parts.join(", ");
|
||
},
|
||
|
||
// The daily resource upkeep of a tile work, for its tooltip. Transport wears
|
||
// per tile rather than as a share of its cost.
|
||
_improvementUpkeepText(improvement) {
|
||
if (transportImprovement(improvement.id)) {
|
||
const steel = RESOURCE_RULES.transportUpkeepSteel[improvement.id] || 0;
|
||
return `${formatResourceAmount("steel", steel)} steel`;
|
||
}
|
||
const upkeep = protoUpkeep(improvement, improvement.buildCost || 0);
|
||
const parts = [];
|
||
for (const id of ["steel", "energy", "hightech"]) {
|
||
if (upkeep[id] > 0) {
|
||
parts.push(`${formatResourceAmount(id, upkeep[id])} ${resourceById(id).name.toLowerCase()}`);
|
||
}
|
||
}
|
||
return parts.length > 0 ? parts.join(", ") : "no upkeep";
|
||
},
|
||
|
||
// One square icon button per improvement, grouped under a category heading:
|
||
// military first, then transportation, then economic (resource producers),
|
||
// plus a control to remove the one already standing on the tile. Shown only
|
||
// on a controlled land tile; an unaffordable improvement is greyed out.
|
||
_renderImprovementActions(coords, buildable, current) {
|
||
const $row = $("#tile-improvement-actions");
|
||
if (!$row.length) return;
|
||
$row.empty();
|
||
if (!buildable) return;
|
||
const budget = (this.snapshot.viewerStats || {}).budget || 0;
|
||
const testing = !!this.testing;
|
||
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
|
||
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
|
||
const prices = marketPricesFrom(this.snapshot);
|
||
const standing = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
|
||
const standingProto = standing ? tileImprovementById(standing.id) : null;
|
||
const isResource = !!(standingProto && standingProto.resource);
|
||
|
||
// Military tile works. They cost resources and take production time, so the
|
||
// tooltip names the expected hours alongside the upkeep.
|
||
const military = [];
|
||
for (const improvement of TILE_IMPROVEMENTS) {
|
||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||
const built = !!(standing && standing.id === improvement.id);
|
||
const affordable = budget >= improvement.buildCost;
|
||
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
|
||
const title = standing && !built
|
||
? `${improvement.name} — another improvement already stands here`
|
||
: affordable
|
||
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`
|
||
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
|
||
const $button = this._improvementButton(improvement, {
|
||
className: "tile-btn",
|
||
title,
|
||
disabled: !!standing || !affordable,
|
||
current: built,
|
||
prices,
|
||
});
|
||
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
|
||
military.push($button);
|
||
}
|
||
|
||
// Transportation: roads and railways, bought outright and laid at once.
|
||
const transport = [];
|
||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||
const isCurrent = current === improvement.id;
|
||
const affordable = budget >= improvement.buildCost;
|
||
const title = isCurrent
|
||
? `${improvement.name} — already built on this tile`
|
||
: affordable
|
||
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||
`${this._improvementUpkeepText(improvement)}/day upkeep`
|
||
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
|
||
const $button = this._improvementButton(improvement, {
|
||
className: "transport-btn",
|
||
title,
|
||
disabled: isCurrent || !affordable,
|
||
current: isCurrent,
|
||
prices,
|
||
});
|
||
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
|
||
transport.push($button);
|
||
// A local test game may raise it for free and at once.
|
||
if (testing && !isCurrent) {
|
||
const $free = this._improvementButton(improvement, {
|
||
className: "free-button",
|
||
costLabel: "free",
|
||
title: `${improvement.name} — build free and instantly (testing)`,
|
||
disabled: false,
|
||
current: false,
|
||
});
|
||
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
|
||
transport.push($free);
|
||
}
|
||
}
|
||
if (current) {
|
||
const proto = transportImprovement(current);
|
||
const name = proto ? proto.name : current;
|
||
transport.push(
|
||
$("<button type='button' class='aero improvement-btn remove'></button>")
|
||
.text(`Remove ${name}`)
|
||
.on("click", () => this._removeImprovement(coords))
|
||
);
|
||
}
|
||
|
||
// Economic: resource producers. They live on their own land, take
|
||
// production time and feed the delivery graph; only one may stand on a
|
||
// tile.
|
||
const economic = [];
|
||
if (!isResource) {
|
||
for (const producer of RESOURCE_BUILDINGS) {
|
||
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
|
||
const built = !!(standing && standing.id === producer.id);
|
||
const affordable = budget >= producer.buildCost;
|
||
const buildHours = ECONOMY.trainingHours(gdp, producer.buildCost) / (1 + speed);
|
||
// Some producers are locked behind a technology (the fusion plant).
|
||
const techIndex = producer.requiresTechnology
|
||
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
|
||
: -1;
|
||
const locked = techIndex >= 0 && !this._researched.has(techIndex);
|
||
const out = producer.resource === "energy"
|
||
? formatEnergy(producer.outputPerDay)
|
||
: `${compact(producer.outputPerDay)} ${producer.resource}/day`;
|
||
const title = locked
|
||
? `${producer.name} — requires ${TECHNOLOGIES[techIndex].name}`
|
||
: standing && !built
|
||
? `${producer.name} — another building already stands here`
|
||
: affordable
|
||
? `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
|
||
`${hours(buildHours)} to build`
|
||
: `${producer.name} — needs ${this._improvementCostText(producer, prices)}`;
|
||
const $button = this._improvementButton(producer, {
|
||
className: "tile-btn resource-btn",
|
||
title,
|
||
disabled: !!standing || !affordable || locked,
|
||
current: built,
|
||
prices,
|
||
});
|
||
$button.on("click", () => this._buildTileImprovement(coords, producer.id));
|
||
economic.push($button);
|
||
}
|
||
}
|
||
if (standing) {
|
||
const name = standingProto ? standingProto.name : standing.id;
|
||
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
|
||
.text(`Remove ${name}`)
|
||
.on("click", () => this._removeTileImprovement(coords));
|
||
// A standing work is removed from its own category.
|
||
if (isResource) economic.push($remove);
|
||
else military.push($remove);
|
||
}
|
||
|
||
for (const group of [
|
||
{ label: "Military", buttons: military },
|
||
{ label: "Transportation", buttons: transport },
|
||
{ label: "Economic", buttons: economic },
|
||
]) {
|
||
if (group.buttons.length === 0) continue;
|
||
const $group = $("<div class='improvement-group'></div>");
|
||
$group.append($("<div class='improvement-category'></div>").text(group.label));
|
||
const $buttons = $("<div class='improvement-row'></div>");
|
||
for (const $button of group.buttons) $buttons.append($button);
|
||
$group.append($buttons);
|
||
$row.append($group);
|
||
}
|
||
},
|
||
|
||
// Whether a land tile touches the sea, so a coastal cannon may be raised.
|
||
_tileIsCoastal(coords) {
|
||
if (!this.map.topology) return false;
|
||
for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
|
||
const tile = this.map.getTile(neighbour);
|
||
if (tile && tile.terrainClass === "Sea") return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
_buildTileImprovement(coords, id) {
|
||
this.sendOrder({
|
||
type: "build_tile_improvement",
|
||
coords: [coords.x, coords.y],
|
||
improvement: id,
|
||
});
|
||
},
|
||
|
||
_removeTileImprovement(coords) {
|
||
this.sendOrder({
|
||
type: "remove_tile_improvement",
|
||
coords: [coords.x, coords.y],
|
||
});
|
||
},
|
||
|
||
_buildImprovement(coords, id, free = false) {
|
||
const order = {
|
||
type: "build_improvement",
|
||
coords: [coords.x, coords.y],
|
||
improvement: id,
|
||
};
|
||
if (free) order.free = true;
|
||
this.sendOrder(order);
|
||
},
|
||
|
||
_removeImprovement(coords) {
|
||
this.sendOrder({
|
||
type: "remove_improvement",
|
||
coords: [coords.x, coords.y],
|
||
});
|
||
},
|
||
|
||
// A short, readable label for the tile: its owner's demonym, the terrain and
|
||
// where it sits relative to the city whose region claims it.
|
||
_tileDescription(coords, owner, tile, regionCity) {
|
||
const civ = owner >= 0 ? this.civilisations[owner] : null;
|
||
const terrain = tile ? tile.terrainType.toLowerCase() : "unknown terrain";
|
||
const parts = [];
|
||
if (civ) parts.push(civ.demonym || civ.name);
|
||
parts.push(terrain);
|
||
if (regionCity) {
|
||
const sameTile =
|
||
regionCity.coords[0] === coords.x && regionCity.coords[1] === coords.y;
|
||
if (sameTile) {
|
||
parts.push(`around ${regionCity.name}`);
|
||
} else {
|
||
const direction = this._compassDirection(
|
||
{ x: regionCity.coords[0], y: regionCity.coords[1] },
|
||
coords
|
||
);
|
||
if (direction) parts.push(`${direction} of ${regionCity.name}`);
|
||
}
|
||
}
|
||
const text = parts.join(" ");
|
||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||
},
|
||
|
||
// The eight-point compass direction from `origin` to `coords`, measured in
|
||
// screen space so the label matches what the player sees on the map.
|
||
_compassDirection(origin, coords) {
|
||
const topology = this.map.topology;
|
||
if (!topology) return "";
|
||
const delta = topology.pixelDelta(origin, coords);
|
||
if (delta.x === 0 && delta.y === 0) return "";
|
||
const names = [
|
||
"east", "southeast", "south", "southwest",
|
||
"west", "northwest", "north", "northeast",
|
||
];
|
||
const index = Math.round(Math.atan2(delta.y, delta.x) / (Math.PI / 4));
|
||
return names[((index % 8) + 8) % 8];
|
||
},
|
||
|
||
// The tile's people: the head count up top, then a small donut and legend of
|
||
// its ethnicities with last-day migration arrows.
|
||
_renderTilePeople(k) {
|
||
const $container = $("#tile-ethnicity");
|
||
if (!$container || !$container.length) return;
|
||
$container.empty();
|
||
const population = this._population.get(k) || 0;
|
||
$container.append($("<div class='info-title'></div>").text("People"));
|
||
$container.append(
|
||
$("<div class='tile-population'></div>").text(
|
||
`Population: ${groupDigits(population)}`
|
||
)
|
||
);
|
||
const entries = this._tileEthnicity.get(k) || [];
|
||
if (entries.length === 0) return;
|
||
const makeup = entries
|
||
.map(([ethnicity, share, trend]) => ({
|
||
ethnicity,
|
||
share,
|
||
count: share * population,
|
||
trend,
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
$container.append(ethnicityChart(makeup, this.civilisations, 104));
|
||
},
|
||
|
||
// The resources a tile produces in a day, each amount already carrying the
|
||
// tile's production modifiers, plus the factor list behind it. Food comes from
|
||
// the people on the tile; every other resource from the work standing there.
|
||
_tileResourceProduction(coords, owner, city, tile) {
|
||
const k = `${coords.x},${coords.y}`;
|
||
const regionCity =
|
||
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
|
||
const factors = this._productionFactors(coords, owner, city, tile, regionCity);
|
||
const multiplier = productionMultiplier(factors);
|
||
const amounts = { energy: 0, steel: 0, food: 0, luxury: 0, hightech: 0 };
|
||
const population = this._population.get(k) || 0;
|
||
let foodMultiplier = 1;
|
||
if (population > 0 && tile) {
|
||
foodMultiplier = regionCity ? this._regionFoodMultiplier(regionCity) : 1;
|
||
const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
|
||
amounts.food = foodNeedPerDay(population) * factor * foodMultiplier * multiplier;
|
||
}
|
||
const improvement = this.map.tileImprovements.get(k);
|
||
const proto = improvement ? tileImprovementById(improvement.id) : null;
|
||
if (proto && proto.resource && proto.outputPerDay) {
|
||
amounts[proto.resource] += proto.outputPerDay * multiplier;
|
||
}
|
||
return { amounts, factors, foodMultiplier };
|
||
},
|
||
|
||
// The production modifiers of one tile: terrain, settlement, war and the
|
||
// region's and nation's own bonuses. Mirrors the server's `productionFactors`
|
||
// inputs so the tile panel and the simulation explain the same figure.
|
||
_productionFactors(coords, owner, city, tile, regionCity) {
|
||
const k = `${coords.x},${coords.y}`;
|
||
return productionFactors(tile, {
|
||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||
nextToCity: !city && this._isNextToCity(coords, owner),
|
||
pillagePenalty: this._tileProductionPenalties.has(k) ? this._tileProductionPenalties.get(k) : 1,
|
||
battleDeficit: this._tileBattleProductionDeficits.get(k) || 0,
|
||
trench: this.map.trenches.has(k),
|
||
regionalMultiplier: 1 + (regionCity ? this._cityProductionModifier(regionCity) : 0),
|
||
regionalLabel: regionCity ? regionCity.name : null,
|
||
nationalMultiplier: 1 + this._nationalProductionModifier(owner),
|
||
});
|
||
},
|
||
|
||
// The extra food a region's ports bring in, mirroring the server: 5% per
|
||
// adjacent sea tile per port level.
|
||
_regionFoodMultiplier(city) {
|
||
const protos = this.snapshot.buildings || [];
|
||
let bonus = 0;
|
||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||
const proto = protos[index];
|
||
if (proto && proto.mechanic === "port_production") {
|
||
bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
|
||
}
|
||
}
|
||
if (bonus === 0) return 1;
|
||
let sea = 0;
|
||
for (const neighbour of this.map.topology.neighbours(city.coords[0], city.coords[1])) {
|
||
const tile = this.map.getTile(neighbour);
|
||
if (tile && tile.terrainClass === "Sea") sea += 1;
|
||
}
|
||
return 1 + bonus * sea;
|
||
},
|
||
|
||
// -------------------------------------------------------- map modes -----
|
||
|
||
// Applied when the Map modes picker reports a choice. An economic mode needs
|
||
// its per-tile figures before the map switches to them.
|
||
_setMapMode(mode) {
|
||
if (mode === this.map.mapMode) return;
|
||
if (isEconomicMode(mode)) {
|
||
const { values, min, max } = this._computeEconomicValues(mode);
|
||
this.map.setEconomicData(mode, values, min, max);
|
||
}
|
||
this.map.setMapMode(mode);
|
||
},
|
||
|
||
// Recomputes the active economic mode's figures after a snapshot, so the map
|
||
// and its legend always show current numbers.
|
||
_refreshEconomicMode() {
|
||
const mode = this.map.mapMode;
|
||
if (!isEconomicMode(mode)) return;
|
||
const { values, min, max } = this._computeEconomicValues(mode);
|
||
this.map.setEconomicData(mode, values, min, max);
|
||
},
|
||
|
||
// One figure per land tile for an economic mode, with the range the legend
|
||
// and the colour scale need. GDP per capita is the region's, so every tile of
|
||
// a region shares one colour; population is the raw per-tile count.
|
||
_computeEconomicValues(mode) {
|
||
const values = new Map();
|
||
let min = Infinity;
|
||
let max = -Infinity;
|
||
if (!this.map || !this.map.tiles) return { values, min: 0, max: 0 };
|
||
const gdpPerCapita = new Map();
|
||
if (mode === "gdp") {
|
||
for (const city of this.snapshot.cities || []) {
|
||
const stats = this._cityStats(city.id);
|
||
const population = stats.population || 0;
|
||
gdpPerCapita.set(city.id, population > 0 ? (stats.gdp || 0) / population : 0);
|
||
}
|
||
}
|
||
for (const k in this.map.tiles) {
|
||
const tile = this.map.tiles[k];
|
||
if (isWaterTile(tile)) continue;
|
||
let value;
|
||
if (mode === "population") {
|
||
value = this._population.get(k) || 0;
|
||
} else {
|
||
value = gdpPerCapita.get(this._tileRegion.get(k)) || 0;
|
||
}
|
||
values.set(k, value);
|
||
if (value < min) min = value;
|
||
if (value > max) max = value;
|
||
}
|
||
if (!Number.isFinite(min)) return { values, min: 0, max: 0 };
|
||
return { values, min, max };
|
||
},
|
||
|
||
// The production bonus a single city's own buildings give.
|
||
_cityProductionModifier(city) {
|
||
let total = 0;
|
||
for (const indexStr of Object.keys(city.buildings || {})) {
|
||
const proto = BUILDINGS[Number(indexStr)];
|
||
if (!proto) continue;
|
||
const level = city.buildings[indexStr];
|
||
for (const e of proto.effects) {
|
||
if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, level);
|
||
}
|
||
}
|
||
return total;
|
||
},
|
||
|
||
// The production bonus a nation gets from its government and researched
|
||
// technology.
|
||
_nationalProductionModifier(civ) {
|
||
let total = 0;
|
||
const government = GOVERNMENTS[(this.snapshot.government || [])[civ]];
|
||
if (government) {
|
||
for (const e of government.effects) {
|
||
if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, 1);
|
||
}
|
||
}
|
||
for (const index of (this.snapshot.researched || [])[civ] || []) {
|
||
const tech = TECHNOLOGIES[index];
|
||
if (!tech) continue;
|
||
for (const e of tech.effects) {
|
||
if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, 1);
|
||
}
|
||
}
|
||
return total;
|
||
},
|
||
|
||
_isNextToCity(coords, owner) {
|
||
for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
|
||
const found = (this.snapshot.cities || []).some(
|
||
(city) =>
|
||
city.civ === owner &&
|
||
city.coords[0] === neighbour.x &&
|
||
city.coords[1] === neighbour.y
|
||
);
|
||
if (found) return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// The tile panel's headline: what the tile actually produces, one row per
|
||
// resource (nothing is shown for a resource the tile does not make), with the
|
||
// modifiers that shape it in a hover tooltip.
|
||
_renderTileProduction(production) {
|
||
const $container = $("#tile-production").empty();
|
||
const rows = [];
|
||
for (const resource of RESOURCES) {
|
||
const amount = production.amounts[resource.id] || 0;
|
||
if (!(amount > 0)) continue;
|
||
const notes = [];
|
||
for (const factor of production.factors) {
|
||
if (factor.multiplier !== undefined) {
|
||
if (factor.multiplier === 1) continue;
|
||
notes.push(`${factor.label}: ×${factor.multiplier.toFixed(2)}`);
|
||
} else if (factor.amount) {
|
||
const sign = factor.amount < 0 ? "−" : "+";
|
||
notes.push(`${factor.label}: ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(factor.amount)))}`);
|
||
}
|
||
}
|
||
if (resource.id === "food" && production.foodMultiplier !== 1) {
|
||
notes.push(`Port food: ×${production.foodMultiplier.toFixed(2)}`);
|
||
}
|
||
const $row = $("<div class='tile-prod-row'></div>");
|
||
$row.append(
|
||
$("<img class='resource-icon' width='16' height='16'>")
|
||
.attr("src", `assets/${resource.icon}`)
|
||
.attr("alt", resource.name),
|
||
$("<span class='tile-prod-name'></span>").text(resource.name),
|
||
$("<span class='tile-prod-value'></span>").text(
|
||
`${formatResourceAmount(resource.id, amount)}/day`
|
||
)
|
||
);
|
||
if (notes.length > 0) $row.attr("title", notes.join("\n"));
|
||
rows.push($row);
|
||
}
|
||
if (rows.length === 0) {
|
||
$container.append($("<div class='tile-prod-empty'></div>").text("No production"));
|
||
return;
|
||
}
|
||
for (const $row of rows) $container.append($row);
|
||
},
|
||
|
||
// The only place GDP per capita is shown: the region the tile belongs to. A
|
||
// tile never gets a per-tile figure of its own.
|
||
_renderRegionGdp(regionCity) {
|
||
const $container = $("#tile-gdp").empty();
|
||
if (!regionCity) {
|
||
$container.text("Region GDP/capita: —");
|
||
return;
|
||
}
|
||
const stats = this._cityStats(regionCity.id);
|
||
const population = stats.population || 0;
|
||
const perCapita = population > 0 ? (stats.gdp || 0) / population : 0;
|
||
$container.append(
|
||
$("<div class='tile-gdp-head'></div>").text(
|
||
`Region GDP/capita: ${currencySymbol()}${groupDigits(Math.round(perCapita))}`
|
||
)
|
||
);
|
||
},
|
||
|
||
// The tile/city panel and the unit panel are independent docks, so either
|
||
// can be open without the other.
|
||
_refreshPanelVisibility() {
|
||
const tileVisible =
|
||
!$("#tile-info").hasClass("hidden") ||
|
||
!$("#city-info").hasClass("hidden");
|
||
const unitVisible = !$("#unit-info").hasClass("hidden");
|
||
$("#tile-panel").toggleClass("hidden", !tileVisible);
|
||
$("#unit-panel").toggleClass("hidden", !unitVisible);
|
||
},
|
||
|
||
_unit(id) {
|
||
return (this.snapshot.units || []).find((u) => u.id === id) || null;
|
||
},
|
||
|
||
_city(id) {
|
||
return (this.snapshot.cities || []).find((c) => c.id === id) || null;
|
||
},
|
||
|
||
// -------------------------------------------------------------- modals ---
|
||
|
||
_openCityModal() {
|
||
const city = this._city(this.selectedCityId);
|
||
if (!city) return;
|
||
this.cityModal.show({ id: city.id, name: city.name }, this._cityView(city));
|
||
this._updateCityModal();
|
||
},
|
||
|
||
// The units a city may train right now. Aircraft and warships only appear
|
||
// once the city has the installation that trains them, so the order can never
|
||
// be sent and rejected. Recomputed each snapshot so a building finished while
|
||
// the modal is open unlocks its unit without reopening the panel.
|
||
_trainableUnits(city) {
|
||
const levels = this._cityBuildings.get(city.id) || {};
|
||
const hasBuilding = (id) => {
|
||
const index = BUILDINGS.findIndex((b) => b.id === id);
|
||
return index >= 0 && (levels[index] || 0) > 0;
|
||
};
|
||
return TRAINABLE_UNIT_IDS.map((id) => {
|
||
const index = this.protoUnits.findIndex((p) => p.id === id);
|
||
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
|
||
}).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
|
||
},
|
||
|
||
_cityView(city) {
|
||
const stats = this.snapshot.viewerStats || {};
|
||
const cityStats = this._cityStats(city.id);
|
||
return {
|
||
budget: stats.budget || 0,
|
||
gdp: stats.gdp || 0,
|
||
coastal: !!city.coastal,
|
||
testing: !!this.testing,
|
||
trainable: this._trainableUnits(city),
|
||
buildings: BUILDINGS,
|
||
levels: this._cityBuildings.get(city.id) || {},
|
||
queue: this._trainingQueue(city.id),
|
||
civs: this.civilisations,
|
||
ethnicMakeup: cityStats.ethnicMakeup || [],
|
||
// The region's private-sector cash basket and the currencies it names.
|
||
cash: cityStats.cash || {},
|
||
cashValue: cityStats.cashValue || 0,
|
||
debt: cityStats.debt || 0,
|
||
currencies: stats.currencies || [],
|
||
approval: cityStats.approval,
|
||
// What the region's people went short of over the last day.
|
||
shortage: cityStats.shortage || {},
|
||
// The region's reserves, their value and its daily consumption.
|
||
resources: cityStats.resources || null,
|
||
// The region's tax base and approval price an upgrade's expected income.
|
||
base: cityStats.base || 0,
|
||
// Construction technology shortens the displayed build times.
|
||
constructionSpeed: stats.constructionSpeed || 0,
|
||
taxMultiplier: this._taxMultiplier(cityStats.approval),
|
||
// Today's market prices, so build costs are quoted at current rates.
|
||
prices: marketPricesFrom(this.snapshot),
|
||
};
|
||
},
|
||
|
||
_trainingQueue(cityId) {
|
||
return (this.snapshot.training || {})[cityId] || [];
|
||
},
|
||
|
||
_updateCityModal() {
|
||
if (!this.snapshot || !this.selectedCityId) return;
|
||
if (!this.cityModal.isShowing(this.selectedCityId)) {
|
||
this._trainingCityId = 0;
|
||
return;
|
||
}
|
||
const stats = this.snapshot.viewerStats || {};
|
||
const queue = this._trainingQueue(this.selectedCityId);
|
||
this.cityModal.renderQueue(queue);
|
||
this._updateCityProgress(queue);
|
||
const cityStats = this._cityStats(this.selectedCityId);
|
||
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
|
||
this.cityModal.prices = marketPricesFrom(this.snapshot);
|
||
const city = this._city(this.selectedCityId);
|
||
if (city) {
|
||
this.cityModal.refreshTrainList(this._trainableUnits(city));
|
||
this.cityModal.renderPeople(
|
||
cityStats.ethnicMakeup || [],
|
||
cityStats.approval,
|
||
this._taxMultiplier(cityStats.approval),
|
||
this.civilisations,
|
||
cityStats.shortage || {}
|
||
);
|
||
this.cityModal.renderResources(cityStats.resources || null);
|
||
}
|
||
this.cityModal.refreshBuildings(
|
||
stats.budget || 0,
|
||
this._cityBuildings.get(this.selectedCityId) || {},
|
||
stats.gdp || 0,
|
||
this._queuedBuildingLevels(this.selectedCityId),
|
||
{ base: cityStats.base || 0, approval: cityStats.approval }
|
||
);
|
||
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
|
||
this.cityModal.renderEconomy(
|
||
cityStats.cash || {},
|
||
cityStats.cashValue || 0,
|
||
stats.currencies || [],
|
||
cityStats.debt || 0
|
||
);
|
||
},
|
||
|
||
_updateCityProgress(queue) {
|
||
const entry = queue[0];
|
||
if (!entry) {
|
||
if (this._trainingCityId === this.selectedCityId) {
|
||
this.cityModal.endTraining();
|
||
this._trainingCityId = 0;
|
||
}
|
||
return;
|
||
}
|
||
const building = entry.kind === "building";
|
||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||
const name = proto ? proto.name : (building ? "building" : "unit");
|
||
const verb = building ? "Building" : "Training";
|
||
if (this._trainingCityId !== this.selectedCityId) {
|
||
this._trainingCityId = this.selectedCityId;
|
||
this.cityModal.beginTraining(name, entry.totalHours, entry.elapsedHours, verb);
|
||
} else {
|
||
this.cityModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours, verb);
|
||
}
|
||
},
|
||
|
||
// How many levels of each building are already waiting in the city's shared
|
||
// production queue, so the buildings tab prices the next order correctly.
|
||
_queuedBuildingLevels(cityId) {
|
||
const counts = {};
|
||
for (const entry of this._trainingQueue(cityId)) {
|
||
if (entry.kind !== "building") continue;
|
||
counts[entry.protoIndex] = (counts[entry.protoIndex] || 0) + 1;
|
||
}
|
||
return counts;
|
||
},
|
||
|
||
// The selected city's regional budget, serialised alongside its stats.
|
||
_cityBudget(cityId) {
|
||
const stats = (this.snapshot.cityStats || []).find((city) => city.id === cityId);
|
||
return stats ? stats.budget : null;
|
||
},
|
||
|
||
// The selected city's serialised region stats: GDP, tax and its people.
|
||
_cityStats(cityId) {
|
||
return (this.snapshot.cityStats || []).find((city) => city.id === cityId) || {};
|
||
},
|
||
|
||
// Mirrors GameState.taxApprovalMultiplier so the People tab explains the
|
||
// penalty the server is already applying.
|
||
_taxMultiplier(approval) {
|
||
const value = approval === undefined || approval === null ? 1 : approval;
|
||
if (value >= APPROVAL.rebellionThreshold) return 1;
|
||
const floor = APPROVAL.taxFloorFraction;
|
||
return floor + (1 - floor) * (value / APPROVAL.rebellionThreshold);
|
||
},
|
||
|
||
_openNationModal() {
|
||
const stats = this.snapshot.viewerStats || {};
|
||
this.nationModal.show(
|
||
GOVERNMENTS,
|
||
stats.government || 0,
|
||
TECHNOLOGIES,
|
||
this._researched,
|
||
stats.gdp || 0,
|
||
stats.budget || 0,
|
||
stats.researchRate || 0,
|
||
this._finances(stats),
|
||
this._diplomacyInfo(),
|
||
this._conflicts,
|
||
this._politicsInfo(),
|
||
this._techInfo(),
|
||
this._resourcesInfo(),
|
||
this._moneyInfo()
|
||
);
|
||
},
|
||
|
||
// The money the Economy tab needs: the viewer's currency, everyone else's,
|
||
// the exchange rates, the central bank and the aggregate private-sector cash.
|
||
_moneyInfo() {
|
||
const stats = (this.snapshot && this.snapshot.viewerStats) || {};
|
||
return {
|
||
viewerCiv: this.localCiv,
|
||
currency: stats.currency || null,
|
||
currencies: stats.currencies || [],
|
||
exchangeRates: stats.exchangeRates || [],
|
||
centralBank: stats.centralBank || { rate: 0, reserves: [] },
|
||
privateCash: stats.privateCash || 0,
|
||
// Where the viewer's own currency sits: treasury, regions, foreign banks.
|
||
currencyHoldings: stats.currencyHoldings || null,
|
||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||
};
|
||
},
|
||
|
||
// Everything the Resources tab needs: the nation's own stores, needs and
|
||
// trade from the viewer stats, plus the world market's prices, the world's
|
||
// supply and demand, and every nation's import/export ledger.
|
||
_resourcesInfo() {
|
||
const stats = (this.snapshot && this.snapshot.viewerStats) || {};
|
||
return {
|
||
summary: stats.resources || null,
|
||
market: (this.snapshot && this.snapshot.resourceMarket) || [],
|
||
trade: (this.snapshot && this.snapshot.resourceTrade) || [],
|
||
civs: this.civilisations,
|
||
};
|
||
},
|
||
|
||
// The focused-research figures the technology tab reads from the snapshot.
|
||
_techInfo() {
|
||
const snapshot = this.snapshot || {};
|
||
const focus = (snapshot.focusTechnology || [])[this.localCiv];
|
||
const focusIndex = focus === undefined || focus === null ? -1 : focus;
|
||
// Progress is kept per technology, so a focus that was switched away from
|
||
// still shows what it had committed when it is selected again.
|
||
const byTech = (snapshot.techProgress || [])[this.localCiv] || {};
|
||
return {
|
||
focus: focusIndex,
|
||
progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
|
||
progressByTech: byTech,
|
||
repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
|
||
};
|
||
},
|
||
|
||
_updateNationModal() {
|
||
if (!this.snapshot || !this.nationModal.isShowing()) return;
|
||
const stats = this.snapshot.viewerStats || {};
|
||
this.nationModal.refresh(
|
||
stats.government || 0,
|
||
this._researched,
|
||
stats.gdp || 0,
|
||
stats.budget || 0,
|
||
stats.researchRate || 0,
|
||
this._finances(stats),
|
||
this._diplomacyInfo(),
|
||
this._conflicts,
|
||
this._politicsInfo(),
|
||
this._techInfo(),
|
||
this._resourcesInfo(),
|
||
this._moneyInfo()
|
||
);
|
||
},
|
||
|
||
_diplomacyInfo() {
|
||
return {
|
||
civs: this.civilisations,
|
||
localCiv: this.localCiv,
|
||
relations: (this.snapshot && this.snapshot.diplomacy) || [],
|
||
stats: (this.snapshot && this.snapshot.civStats) || [],
|
||
};
|
||
},
|
||
|
||
// Everything the Politics tab needs from the snapshot.
|
||
_politicsInfo() {
|
||
const snapshot = this.snapshot || {};
|
||
return {
|
||
civs: this.civilisations,
|
||
localCiv: this.localCiv,
|
||
totalHours: snapshot.totalHours || 0,
|
||
approval: snapshot.approval || [],
|
||
opinions: snapshot.opinions || { ethnic: [], government: [] },
|
||
policies: snapshot.policies || [],
|
||
campaigns: snapshot.campaigns || [],
|
||
migrations: snapshot.migrations || [],
|
||
cultureImpact: snapshot.cultureImpact || [],
|
||
viewerStats: snapshot.viewerStats || {},
|
||
};
|
||
},
|
||
|
||
_openNewsModal() {
|
||
this.newsModal.show(this._newsForModal(), this.civilisations);
|
||
},
|
||
|
||
_updateNewsModal() {
|
||
if (!this.snapshot || !this.newsModal.isShowing()) return;
|
||
this.newsModal.refresh(this._newsForModal(), this.civilisations);
|
||
},
|
||
|
||
// The news list, without the repeatable-research notices: those still reach
|
||
// the player through the corner feed, but are not news events.
|
||
_newsForModal() {
|
||
return (this._news || []).filter((entry) => !entry.repeatable);
|
||
},
|
||
|
||
// Pops newly arrived news into the corner feed. The first snapshot only
|
||
// records what is already known, so history never floods the screen.
|
||
_updateFeed(news) {
|
||
if (this._seenNews === null) {
|
||
this._seenNews = new Set(news.map((entry) => entry.id));
|
||
return;
|
||
}
|
||
for (const entry of news) {
|
||
if (this._seenNews.has(entry.id)) continue;
|
||
this._seenNews.add(entry.id);
|
||
this._pushNews(entry);
|
||
}
|
||
},
|
||
|
||
_pushNews(entry) {
|
||
if (entry.type === NEWS_WAR && entry.target === this.localCiv) {
|
||
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
|
||
return;
|
||
}
|
||
// A technology the viewer finishes chimes like a building or unit; someone
|
||
// else taking control of a nation is the neutral notice (Mouth_08).
|
||
if (entry.type === NEWS_TECHNOLOGY && entry.civ === this.localCiv) {
|
||
audio.play("complete");
|
||
} else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) {
|
||
audio.play("notification");
|
||
}
|
||
this.feed.push(describeNews(entry, this.civilisations));
|
||
},
|
||
|
||
// Pops the viewer's own daily resource-shortage notices into the corner feed.
|
||
// Each building, region and resource is announced once per game day.
|
||
_updateResourceAlerts(stats) {
|
||
const alerts = (stats && stats.resourceAlerts) || [];
|
||
if (this._seenAlerts === null) {
|
||
this._seenAlerts = new Set(alerts.map((alert) => this._alertSignature(alert)));
|
||
return;
|
||
}
|
||
for (const alert of alerts) {
|
||
const signature = this._alertSignature(alert);
|
||
if (this._seenAlerts.has(signature)) continue;
|
||
this._seenAlerts.add(signature);
|
||
const proto = resourceById(alert.resource);
|
||
const name = proto ? proto.name : alert.resource;
|
||
this.feed.push(
|
||
`${alert.building} in ${alert.region} is missing ${name}`, { alert: true }
|
||
);
|
||
}
|
||
},
|
||
|
||
_alertSignature(alert) {
|
||
return `${alert.day || 0}|${alert.building}|${alert.region}|${alert.resource}`;
|
||
},
|
||
|
||
_finances(stats) {
|
||
if (!stats) return null;
|
||
// The month reports page back through the stored budget digests; the region
|
||
// figures are global, so the Cities tab can show every nation's, and the
|
||
// panel filters them to the viewer.
|
||
return {
|
||
months: stats.budgetMonths || [],
|
||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||
};
|
||
},
|
||
};
|