Files
Battle-for-Tismo/client/js/game_screen/panels.js
T

600 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
TRAINABLE_UNIT_IDS,
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
ECONOMY,
pillageSteps,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
export const panelMethods = {
_bindHud() {
$("#city-train").on("click", () => this._openTrainModal());
$("#city-build").on("click", () => this._openBuildingsModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
this.trainModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
this.trainModal.onCancel = (cityId, index) =>
this.sendOrder({ type: "cancel_train", city: cityId, index });
this.buildingsModal.onBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index });
this.buildingsModal.onDemolish = (cityId, index) =>
this.sendOrder({ type: "demolish", city: cityId, building: index });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
$("#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());
// 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;
this.map.setSelectedUnits(this.selectedUnitIds);
this._hideStackMenu();
this._showUnits(units);
},
_selectedUnits() {
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
},
_openStackMenu(coords, ids, clientX, clientY) {
const units = [];
for (const id of ids) {
const unit = this._unit(id);
if (unit && unit.civ === this.localCiv) units.push(unit);
}
if (units.length === 0) return;
const $menu = this.$stackMenu.empty();
const selection = this._selectedUnits();
if (selection.length > 0) {
const $move = $("<button class='stack-item stack-move'></button>").text("Move here");
$move.on("click", () => {
this._hideStackMenu();
this._orderMove(selection, coords);
});
$menu.append($move);
}
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const $item = $("<button class='stack-item'></button>")
.text(`${proto ? proto.name : "Unit"} — ${Math.round(unit.hp)}/${unit.maxHp} HP`);
$item.on("click", () => this._selectUnits([unit.id]));
$menu.append($item);
}
const $all = $("<button class='stack-item stack-all'></button>").text(
`Select all (${units.length})`
);
$all.on("click", () => this._selectUnits(units.map((unit) => unit.id)));
$menu.append($all);
$menu.removeClass("hidden").css({ left: `${clientX}px`, top: `${clientY}px` });
},
_hideStackMenu() {
if (this.$stackMenu && this.$stackMenu.length) this.$stackMenu.addClass("hidden");
},
// The slowest member of a group sets the pace. The fallback matches the
// server's DEFAULT_UNIT_SPEED so prediction cannot disagree with the
// authoritative movement cost.
_groupSpeed(units) {
let speed = Infinity;
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const value = proto && proto.speed ? proto.speed : DEFAULT_UNIT_SPEED;
speed = Math.min(speed, value);
}
return Number.isFinite(speed) ? speed : null;
},
async _groupMovePlan(units, coords) {
const speed = units.length > 1 ? this._groupSpeed(units) : null;
const targets = [];
const routes = new Map();
// Same-type units in one stack share a route, so search once per type.
const cache = new Map();
for (const unit of units) {
const route = await this.map.planMove(unit, coords, speed, cache);
if (!route || route.length < 2) continue;
routes.set(unit.id, route);
if (!this._isMilitaryUnit(unit)) continue;
for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
if (this._isAtWar(unit.civ, owner)) continue;
if (!targets.includes(owner)) targets.push(owner);
}
}
return { targets, speed, routes };
},
// ------------------------------------------------------- pillage -------
// What the selected units would remove, gain and heal by pillaging the tile
// they stand on, or null when they cannot pillage.
_pillageForecast(units) {
if (units.length === 0) return null;
const coords = units[0].coords;
const sameTile = units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]);
if (!sameTile) return null;
const owner = this._territoryOwner.has(`${coords[0]},${coords[1]}`)
? this._territoryOwner.get(`${coords[0]},${coords[1]}`)
: -1;
if (owner < 0 || owner === this.localCiv) return null;
// A unit in combat cannot pillage.
if (this._battles.has(`${coords[0]},${coords[1]}`)) return null;
const population = this._population.get(`${coords[0]},${coords[1]}`) || 0;
const { steps, 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`
);
},
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
},
_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 || {};
$("#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)}`);
},
_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);
}
},
// 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._hideStatTooltip();
this._refreshBottomVisibility();
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._updatePillageButton(units);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
return;
}
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit");
$("#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]);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
},
// 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();
}
},
_showCity(city) {
if (!city) {
$("#city-info").addClass("hidden");
this._refreshBottomVisibility();
return;
}
$("#city-name").text(city.name);
$("#city-population").text(`Population: ${groupDigits(city.population)}`);
$("#city-improvements").text(`Improvements: ${fmtImprovements(city.improvements)}`);
$("#tile-info, #unit-info").addClass("hidden");
$("#city-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_showTile(coords) {
const k = `${coords.x},${coords.y}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const civ = this.civilisations[owner];
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
$("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`);
$("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`);
$("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`);
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
$("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
},
// The base GDP per capita of a claimable tile plus every factor that shapes
// it, mirroring the server's getTileGdpPerCapita through the shared helper.
_tileGdpBreakdown(coords, owner, city, tile) {
const k = `${coords.x},${coords.y}`;
const factors = gdpPerCapitaFactors(tile, {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && this._isNextToCity(coords, owner),
pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
});
return {
base: ECONOMY.baseGdpPerCapita,
factors,
total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors),
};
},
_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;
}
$container.append(
$("<div></div>").text(`GDP/capita: €${groupDigits(Math.round(breakdown.total))}`)
);
$container.append(
$("<div class='tile-gdp-mod'></div>").text(`Base €${groupDigits(breakdown.base)}`)
);
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
? `−€${groupDigits(Math.round(Math.abs(factor.amount)))}`
: `×${factor.multiplier.toFixed(2)}`;
$container.append(
$("<div class='tile-gdp-mod'></div>").text(`${factor.label} ${value}`)
);
}
},
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden") ||
!$("#unit-info").hasClass("hidden");
$("#bottom-panel").toggleClass("hidden", !visible);
},
_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 ---
_openTrainModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter(Boolean);
this.buildingsModal.close();
this.trainModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
trainable,
this._trainingQueue(city.id)
);
this._updateTrainModal();
},
_trainingQueue(cityId) {
return (this.snapshot.training || {})[cityId] || [];
},
_updateTrainModal() {
if (!this.snapshot || !this.selectedCityId) return;
if (!this.trainModal.isShowing(this.selectedCityId)) {
this._trainingCityId = 0;
return;
}
const queue = this._trainingQueue(this.selectedCityId);
this.trainModal.renderQueue(queue);
const entry = queue[0];
if (!entry) {
if (this._trainingCityId === this.selectedCityId) {
this.trainModal.endTraining();
this._trainingCityId = 0;
}
return;
}
const proto = this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : "unit";
if (this._trainingCityId !== this.selectedCityId) {
this._trainingCityId = this.selectedCityId;
this.trainModal.beginTraining(name, entry.totalHours, entry.elapsedHours);
} else {
this.trainModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours);
}
},
_openBuildingsModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
this.trainModal.close();
this.buildingsModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).budget || 0,
BUILDINGS,
this._cityBuildings.get(city.id) || {}
);
},
_updateBuildingsModal() {
if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return;
const stats = this.snapshot.viewerStats || {};
this.buildingsModal.refresh(
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {}
);
},
_openNationModal() {
const stats = this.snapshot.viewerStats || {};
this.nationModal.show(
GOVERNMENTS,
stats.government || 0,
TECHNOLOGIES,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts
);
},
_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.research || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts
);
},
_diplomacyInfo() {
return {
civs: this.civilisations,
localCiv: this.localCiv,
relations: (this.snapshot && this.snapshot.diplomacy) || [],
stats: (this.snapshot && this.snapshot.civStats) || [],
};
},
_openNewsModal() {
this.newsModal.show(this._news, this.civilisations);
},
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._news, this.civilisations);
},
// 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;
}
this.feed.push(describeNews(entry, this.civilisations));
},
_finances(stats) {
return stats.breakdown || null;
},
};