// 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) { 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( $("