// 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 } 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_GDP, APPROVAL, effectValue, pillageSteps, ROADS, TRANSPORT_IMPROVEMENTS, transportImprovement, TILE_IMPROVEMENTS, AIR_STRIKE, } from "../../../shared/data.js"; import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js"; import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; import { parseKey } from "../../../shared/hex.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"; // A tiny memo cache keyed by bucket and key, used while a per-tile economic // figure is computed for every tile at once. function memo(caches, bucket, key, compute) { if (!caches) return compute(); let cache = caches[bucket]; if (!cache) { cache = new Map(); caches[bucket] = cache; } if (!cache.has(key)) cache.set(key, compute()); return cache.get(key); } 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 }); $("#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 = $("").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 = $("") .text(`${proto ? proto.name : "Unit"} — ${Math.round(unit.hp)}/${unit.maxHp} HP`); $item.on("click", () => this._selectUnits([unit.id])); $menu.append($item); } const $all = $("").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, gdpMultiplier } = 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, gdpMultiplier, 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)}%) · ` + `GDP/capita ×${forecast.gdpMultiplier.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) { return { title: "Insufficient budget", message: `An air strike costs €${groupDigits(cost)}, which the treasury cannot cover.`, }; } return null; }, // The friendly airport an aircraft would fly home to from `coords`, mirroring // GameState._returnAirport: its own base while that is still a friendly // airport, otherwise the nearest friendly airport anywhere. _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; const home = cities.find((city) => city.id === unit.homeCityId); if (home && home.civ === this.localCiv && hasAirport(home)) return home; 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(`€${compact(stats.gdp || 0)}`); $("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`); $("#stat-budget").text(`€${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 = $(""); 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}€${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 = $(""); const $check = $("") .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, $("").text(proto ? proto.name : "Unit"), $("").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._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : 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 = TILE_IMPROVEMENTS.find((entry) => entry.id === 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 = TILE_IMPROVEMENTS.find((improvement) => improvement.id === 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 = $("
"); $bar.append($("
").css("width", `${pct}%`)); $el.append( $("
").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 = $("
"); $bar.append($("
").css("width", `${pct}%`)); $el.append( $("
").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. _improvementButton(improvement, { className, costLabel, title, disabled, current }) { const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`; const $button = $(``) .attr("title", title) .prop("disabled", disabled) .toggleClass("current", current); $button.append( $("") .attr("src", `assets/${improvement.icon}`) .attr("alt", improvement.name), $("").text(costLabel) ); return $button; }, // One square icon button per transport and military improvement, 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; 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} — €${groupDigits(improvement.maintenance)}/tile·h upkeep` : `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`; const $button = this._improvementButton(improvement, { className: "transport-btn", costLabel: `€${compact(improvement.buildCost)}`, title, disabled: isCurrent || !affordable, current: isCurrent, }); $button.on("click", () => this._buildImprovement(coords, improvement.id, false)); $row.append($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)); $row.append($free); } } if (current) { const name = transportImprovement(current) ? transportImprovement(current).name : current; const $remove = $("") .text(`Remove ${name}`) .on("click", () => this._removeImprovement(coords)); $row.append($remove); } // Military tile improvements. They cost money and take production time, so // the tooltip names the expected hours alongside the upkeep. const military = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null; const gdp = (this.snapshot.viewerStats || {}).gdp || 0; const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0; for (const improvement of TILE_IMPROVEMENTS) { if (improvement.coastal && !this._tileIsCoastal(coords)) continue; const built = !!(military && military.id === improvement.id); const affordable = budget >= improvement.buildCost; const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed); const title = military && !built ? `${improvement.name} — another improvement already stands here` : affordable ? `${improvement.name} — €${groupDigits(improvement.maintenance)}/h upkeep · ${hours(buildHours)} to build` : `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`; const $button = this._improvementButton(improvement, { className: "tile-btn", costLabel: `€${compact(improvement.buildCost)}`, title, disabled: !!military || !affordable, current: built, }); $button.on("click", () => this._buildTileImprovement(coords, improvement.id)); $row.append($button); } if (military) { const name = (TILE_IMPROVEMENTS.find((i) => i.id === military.id) || {}).name || military.id; const $remove = $("") .text(`Remove ${name}`) .on("click", () => this._removeTileImprovement(coords)); $row.append($remove); } }, // 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($("
").text("People")); $container.append( $("
").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 base GDP per capita of a claimable tile plus every factor that shapes // it, mirroring the server's getTileGdpPerCapita through the shared helper. // The region that develops the tile supplies the building bonus; government // and research supply the nation-wide one. _tileGdpBreakdown(coords, owner, city, tile, caches = null) { const k = `${coords.x},${coords.y}`; const regionCity = city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k)); const regionalModifier = regionCity ? memo(caches, "regional", regionCity.id, () => this._cityGdpModifier(regionCity)) : 0; const nationalModifier = memo(caches, "national", owner, () => this._nationalGdpModifier(owner)); const factors = gdpPerCapitaFactors(tile, { cityKind: city ? (city.isCapital ? "capital" : "city") : null, nextToCity: !city && memo(caches, "nextToCity", k, () => this._isNextToCity(coords, owner)), pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1, battleDeficit: this._tileBattleGdpDeficits.get(k) || 0, regionalMultiplier: 1 + regionalModifier, regionalLabel: regionCity ? regionCity.name : null, nationalMultiplier: 1 + nationalModifier, portGdp: regionCity ? memo(caches, "portGdp", regionCity.id, () => this._regionPortGdp(regionCity)) : 0, naturalGrowth: memo(caches, "growth", k, () => this._naturalTileGdpGrowth(k)), }); return { base: ECONOMY.baseGdpPerCapita, factors, total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors), }; }, // The flat GDP per capita a region's ports add to every tile it covers, // mirroring the server: 1000 per port level for each sea tile beside the city. _regionPortGdp(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_gdp") { bonus += (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level; } } if (bonus === 0) return 0; 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 bonus * sea; }, // The flat GDP per capita a tile has gained since January 2000, mirroring the // server: its opening figure times the daily rate, once per day elapsed. _naturalTileGdpGrowth(k) { const baseline = this._gdpBaseline.get(k) || 0; if (!baseline) return 0; const totalHours = (this.snapshot && this.snapshot.totalHours) || 0; const days = Math.floor(Math.max(0, totalHours) / HOURS_PER_DAY); return baseline * ECONOMY.naturalGdpGrowthPerDay * days; }, // -------------------------------------------------------- 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 mirrors the tile panel's formula; // population is the snapshot's 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 cities = this.snapshot.cities || []; const cityByKey = new Map(cities.map((c) => [`${c.coords[0]},${c.coords[1]}`, c])); const caches = mode === "gdp" ? {} : null; 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 { const coords = parseKey(k); const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1; const city = cityByKey.get(k) || null; value = this._tileGdpBreakdown(coords, owner, city, tile, caches).total; } 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 GDP bonus a single city's own buildings give. _cityGdpModifier(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_GDP) total += effectValue(e, level); } } return total; }, // The GDP bonus a nation gets from its government and researched technology. _nationalGdpModifier(civ) { let total = 0; const government = GOVERNMENTS[(this.snapshot.government || [])[civ]]; if (government) { for (const e of government.effects) { if (e.stat === EFFECT_GDP) 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_GDP) 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; }, // Lists the tile's GDP per capita and each factor behind it, so a click // explains exactly why the figure is what it is. _renderTileGdp(breakdown) { const $container = $("#tile-gdp").empty(); if (!breakdown) { $container.text("GDP/capita: —"); return; } const $mods = $("
"); if (!this._gdpExpanded) $mods.addClass("hidden"); $mods.append( $("
").text(`Base €${groupDigits(breakdown.base)}`) ); let modifierCount = 0; for (const factor of breakdown.factors) { // A modifier that changes nothing (×1 or ±€0) is left out. if (factor.amount !== undefined) { if (factor.amount === 0) continue; } else if (factor.multiplier === 1) { continue; } const value = factor.amount !== undefined ? `${factor.amount < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(factor.amount)))}` : `×${factor.multiplier.toFixed(2)}`; $mods.append( $("
").text(`${factor.label} ${value}`) ); modifierCount += 1; } const $head = $("
").text( `GDP/capita: €${groupDigits(Math.round(breakdown.total))}` ); if (modifierCount > 0) { $head.addClass("tile-gdp-toggle"); $head.append( $("").text(this._gdpExpanded ? "▾" : "▸") ); $head.on("click", () => { this._gdpExpanded = !this._gdpExpanded; $mods.toggleClass("hidden", !this._gdpExpanded); $head.find(".tile-gdp-caret").text(this._gdpExpanded ? "▾" : "▸"); }); $container.append($head, $mods); } else { $container.append($head); } }, // 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 || [], approval: cityStats.approval, // 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), }; }, _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; 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 ); } 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)); }, _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() ); }, // 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() ); }, _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)); }, _finances(stats) { if (!stats || !stats.breakdown) return null; // The region figures are global, so the Cities tab can show every nation's; // the panel filters them to the viewer. return { ...stats.breakdown, cities: (this.snapshot && this.snapshot.cityStats) || [], }; }, };