Files
Battle-for-Tismo/client/js/game_screen.js
T
adrien 2d36685ca0 Moved tile works into a management panel and put every building on one card
The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js.

The tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size.

AGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed.
2026-09-22 23:51:48 +02:00

637 lines
25 KiB
JavaScript

// World-map screen controller. It wires the map view, the HUD and the modals
// to the snapshots the server broadcasts, and turns player input into orders.
// It owns no simulation: movement between snapshots is dead reckoning with the
// same cost model as the server.
//
// The HUD, selection and modal behaviour lives in ./game_screen/panels.js and is
// composed onto the prototype below; this file keeps the screen lifecycle, the
// snapshot plumbing and the order flow.
import { MapView } from "./map_view.js";
import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { bootLog } from "./loading.js";
import { formatCapabilityReport } from "./capabilities.js";
import { panelMethods } from "./game_screen/panels.js";
import { CompletionWatcher } from "./game_screen/completions.js";
import { BombardmentSfx, AirStrikeSfx, zoomVolume } from "./game_screen/bombing.js";
import { audio } from "./audio.js";
import { setCurrency, setCurrencyBook } from "./currency.js";
export class GameScreen {
constructor(network, config) {
this.network = network;
this.config = config;
this.$screen = $("#screen-game");
this.snapshot = null;
this.localCiv = 0;
// Whether the free, instant testing buttons are offered: true for a test
// game and for a page served from the local machine.
this.testing = !!(config && config.testing);
this.civilisations = [];
this.protoUnits = PROTO_UNITS;
this.selectedUnitIds = [];
this.selectedCityId = 0;
// The tile whose info panel is open, if any (right-click inspection).
this._inspectedTile = null;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
this._productionBaseline = new Map();
this._regionProductionBase = new Map();
this._tileEthnicity = new Map();
this._populationVersion = undefined;
this._productionBaselineVersion = undefined;
this._ethnicityVersion = undefined;
this._territoryVersion = undefined;
this._regionVersion = undefined;
this._tileProductionPenalties = new Map();
this._tileBattleProductionDeficits = new Map();
this._territoryOwner = new Map();
this._tileRegion = new Map();
this._gdpExpanded = false;
this._battles = new Set();
this._loading = false;
// Whether the first snapshot has been mirrored while the loading overlay is
// up; the frame loop waits for this before checking map readiness.
this._worldMirrored = false;
this._researched = new Set();
this._wars = new Set();
this._conflicts = [];
this._news = [];
this._seenNews = null;
this._seenAlerts = null;
this._entered = false;
// Detects buildings and units the local player finishes between snapshots;
// technologies are picked up from the news feed instead.
this.completions = new CompletionWatcher();
// Ambient impacts while a bombardment falls inside the viewport.
this.bombing = new BombardmentSfx((volume, duration) =>
audio.play("bomb", { volume, duration })
);
// The one big burst a plane makes when its bomb run actually resolves.
this.airBombing = new AirStrikeSfx((volume, duration) =>
audio.play("bomb", { volume, duration })
);
// Previous snapshot's strike state per unit, to spot a plane's bomb run
// ending (see `_detectAirStrikes`).
this._airStrikeState = null;
// The capability report is shown at most once per join, when the GPU map
// renderer was not used (see `_reportCapabilities`).
this._capabilitiesReported = false;
this._lastFrame = 0;
this._fpsFrames = 0;
this._fpsElapsed = 0;
this.map = new MapView(
$("#map-viewport"),
$("#map-world"),
{
terrain: $("#layer-terrain"),
roads: $("#layer-roads"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
resources: $("#layer-resources"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
}
);
this.cityModal = new CityModal();
this.nationModal = new NationModal();
this.tileModal = new TileModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
this.mapModesModal = new MapModesModal();
this.capabilityModal = new CapabilityModal();
this.feed = new GameFeed($("#game-feed"));
this.$stackMenu = $("#stack-menu");
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
this._bindMap();
this._bindHud();
}
enter() {
this.$screen.addClass("active");
this._entered = true;
this.map._applyCamera();
this._lastFrame = performance.now();
audio.requestMusic();
requestAnimationFrame((t) => this._frame(t));
}
// Marks the screen as loading and makes sure the boot log is running. The real
// app begins the log before building the screen (so the WebGL context, shader
// and texture setup is logged too); this only starts it if that was skipped.
beginLoading() {
this._loading = true;
// The frame loop must not consider the map loaded before the first snapshot
// has been mirrored, or the textures finishing first would lift the overlay.
this._worldMirrored = false;
bootLog.bind($("#loading-overlay"));
if (!bootLog.active) bootLog.begin();
}
leave() {
this._entered = false;
this.map.cancelPathfinding();
this.$screen.removeClass("active");
this.feed.clear();
this._seenNews = null;
this._seenAlerts = null;
this.completions.reset();
this.bombing.reset();
this.airBombing.reset();
this._airStrikeState = null;
audio.stopMusic();
this._hideStackMenu();
this.mapModesModal.close();
$(document).off("mousedown.stackmenu");
}
// ------------------------------------------------------------- orders ---
sendOrder(order) {
this.network.sendOrder(order);
}
// ----------------------------------------------------------- snapshots ---
onState(state) {
this.snapshot = state;
this.localCiv = state.viewer;
setCurrency(state.viewerStats && state.viewerStats.currency);
setCurrencyBook(
state.viewerStats && state.viewerStats.currencies,
state.viewerStats && state.viewerStats.exchangeRates,
state.viewer
);
this.testing = !!(state.testing || (this.config && this.config.testing));
this.civilisations = state.civs.map(
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
);
this.protoUnits = state.protos.map(
(p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name }
);
// The big per-tile collections carry a version from the server. Rebuild
// their client-side maps only when that version moved; a snapshot with no
// versions (direct callers, tests) always rebuilds.
const versions = state.versions || null;
const versionChanged = (key, current) => !versions || versions[key] !== current;
const versionOf = (key) => (versions ? versions[key] : undefined);
if (versionChanged("population", this._populationVersion)) {
this._populationVersion = versionOf("population");
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
}
// The opening production per head of every land tile never changes, so it
// is built once and kept across the deltas that omit it.
if (versionChanged("productionBaseline", this._productionBaselineVersion)) {
this._productionBaselineVersion = versionOf("productionBaseline");
this._productionBaseline = new Map(
(state.productionBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
}
// Each region's production per head, before tile modifiers. It moves with
// production and prices, so it is rebuilt from every snapshot.
this._regionProductionBase = new Map((state.regionProductionBase || []).map((e) => [e[0], e[1]]));
if (versionChanged("ethnicity", this._ethnicityVersion)) {
this._ethnicityVersion = versionOf("ethnicity");
this._tileEthnicity = new Map(
(state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
}
if (versionChanged("territory", this._territoryVersion)) {
this._territoryVersion = versionOf("territory");
this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
}
if (versionChanged("regions", this._regionVersion)) {
this._regionVersion = versionOf("regions");
this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]]));
}
this._tileProductionPenalties = new Map(
(state.tileProductionPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
this._tileBattleProductionDeficits = new Map(
(state.tileBattleProductionDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
this._cityBuildings = new Map();
for (const city of state.cities) {
this._cityBuildings.set(city.id, { ...city.buildings });
}
this._researched = new Set(
(state.researched[this.localCiv] || []).map((index) => Number(index))
);
this._wars = new Set();
for (const entry of state.diplomacy || []) {
if (entry[2] !== "war") continue;
const a = entry[0];
const b = entry[1];
this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`);
}
this._conflicts = state.conflicts || [];
this._news = state.news || [];
this._updateFeed(this._news);
this._updateResourceAlerts(state.viewerStats);
this.map.setCatalogue(this.civilisations, this.protoUnits);
// Mirror the viewer's repeatable-research bonuses used by prediction.
this.map.railSpeed = (state.viewerStats || {}).railSpeed || 0;
this.map.applySnapshot(state);
// The delivery graph is delta-dropped while unchanged, so only replace the
// view's copy when the snapshot actually carried one.
if (state.resourceGraph) this.map.setResourceGraph(state.resourceGraph);
if (state.migrationGraph) this.map.setMigrationGraph(state.migrationGraph);
// Refresh an active economic map mode against the new figures.
this._refreshEconomicMode();
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
this.map.setSelectedUnits(this.selectedUnitIds);
this._updateTileHighlight();
this._updateTopPanel(state);
this._updateBottomPanel();
this._updateCityModal();
this._updateNationModal();
this._updateNewsModal();
// A finished building or freshly trained unit gets a chime. The first
// snapshot only sets the baseline, so joining never plays a burst of them.
const done = this.completions.update(state, this.localCiv);
if (!done.first && (done.building || done.unit)) audio.play("complete");
this._detectAirStrikes(state);
if (this._loading) {
this._worldMirrored = true;
bootLog.step("Updated the HUD");
// The world is mirrored, but the GPU renderer may still be uploading its
// textures. Only lift the loading overlay once the map is actually ready;
// otherwise wait for the uploads, polling each frame.
if (this.map.isReady()) this._finishLoading();
else this._awaitMapReady();
}
}
_finishLoading() {
this._loading = false;
bootLog.finish(() => this._reportCapabilities(this.snapshot));
}
// The map is not ready yet: give the frame loop a deadline so a stuck texture
// cannot leave the loading overlay up forever.
_awaitMapReady() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
this._loadingDeadline = now + 20000;
bootLog.step("Waiting for the terrain textures");
}
_pollLoading() {
if (!this._loading || !this._worldMirrored) return;
const ready = this.map.isReady();
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
if (!ready && now < this._loadingDeadline) return;
if (!ready) bootLog.step("Gave up waiting for the terrain textures");
this._finishLoading();
}
// When the GPU map renderer was not used, log and show the detailed list of
// WebGL/WASM features this client is missing. The report is built only on the
// fallback path (it probes a throwaway context) and shown at most once.
_reportCapabilities(state) {
if (this._capabilitiesReported || !this._entered) return;
// A forced "dom" mode (tests, benchmark) is a deliberate choice, not a
// compatibility problem, so it is never reported.
if (this.map.rendererMode === "dom" || this.map.glRenderer) return;
this._capabilitiesReported = true;
const mapSize = state && state.mapConfig ? state.mapConfig.mapSize : null;
const report = this.map.capabilityReport(mapSize);
if (typeof console !== "undefined" && console.warn) {
console.warn(formatCapabilityReport(report));
}
this.capabilityModal.show(report);
}
_bindMap() {
this.map.onUnitSelected = (id) => {
if (!id) {
this._selectUnits([]);
return;
}
const unit = this._unit(id);
if (!unit || unit.civ !== this.localCiv) {
this._selectUnits([]);
return;
}
// Clicking any member of a stack selects the whole stack, so the next
// order is given to every unit sharing the tile.
const stack = this.map.stackIds(unit.id).filter((stackId) => {
const data = this._unit(stackId);
return data && data.civ === this.localCiv;
});
this._selectUnits(stack.length > 0 ? stack : [unit.id]);
// Clicking a unit also reveals the tile (or city) it stands on.
this._inspectUnitTile(unit);
};
this.map.onCitySelected = (id) => this._selectCity(id);
// A double click goes one step further and opens the full city panel.
this.map.onCityOpened = (id) => {
if (this._selectCity(id)) this._openCityModal();
};
this.map.onTileRequested = (coords) => this._showTile(coords);
this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y);
this.map.onMoveOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
// Right-clicking is also how troops board a launch and how a loaded launch
// puts its passengers ashore.
if (this._tryTransportOrder(units, coords)) return null;
return this._orderMove(units, coords);
};
this.map.onAttackOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
return this._orderMove(units, coords, "attack");
};
this.map.onScheduleOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
return this._orderMove(units, coords, "schedule");
};
}
// A selected unit is a transport (launch) when its type carries capacity.
_isTransport(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.transportCapacity > 0);
}
// Right-click transport orders: infantry board a friendly launch they stand
// beside, and a loaded launch puts its passengers ashore on an adjacent land
// tile. Returns true when the click was consumed.
_tryTransportOrder(units, coords) {
const infantry = units.every((unit) => {
const proto = this.protoUnits[unit.proto];
return proto && proto.infantry;
});
if (infantry) {
const launch = (this.snapshot.units || []).find(
(unit) =>
unit.civ === this.localCiv &&
unit.coords[0] === coords.x &&
unit.coords[1] === coords.y &&
this._isTransport(unit)
);
if (launch) {
this.sendOrder({ type: "embark", units: units.map((unit) => unit.id), launch: launch.id });
return true;
}
}
const transports = units.every(
(unit) => this._isTransport(unit) && (unit.cargo || []).length > 0
);
if (transports) {
const tile = this.map.getTile(coords);
if (tile && tile.terrainClass === "Land") {
this.sendOrder({
type: "disembark",
units: units.map((unit) => unit.id),
coords: [coords.x, coords.y],
});
return true;
}
}
return false;
}
// Issues a move for the group, prompting first when the route would enter
// foreign territory at peace. `type` is "move", "attack" or "schedule".
async _orderMove(units, coords, type = "move") {
const airStrike = type === "attack" && this._airStrikeOrder(units, coords);
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
// Aircraft may only be based at one of our airports; any other move (an
// empty tile or a city without a runway) is refused up front.
if ((type === "move" || type === "schedule") &&
units.length > 0 && units.every((unit) => this._isAirUnit(unit)) &&
!this._isFriendlyAirport(coords)) {
this.confirmModal.show(
"No runway",
"Aircraft may only fly to one of your cities with an airport, or attack an enemy unit.",
null
);
return;
}
if (airStrike) {
const blocked = this._airStrikeBlocked(units);
if (blocked) {
this.confirmModal.show(blocked.title, blocked.message, null);
return;
}
}
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
// A strike is refused before it is ever sent when the target lies beyond
// the unit's reach, so the browser and the server never disagree. An air
// strike must fit the whole round trip, not just the flight out.
if (airStrike) {
const reason = this._airStrikeRangeError(units, coords);
if (reason) {
this.confirmModal.show("Out of range", reason, null);
return;
}
}
const outOfRange = units.some((unit) => {
const proto = this.protoUnits[unit.proto];
if (!proto) return false;
if (striking) {
const from = { x: unit.coords[0], y: unit.coords[1] };
return this.map.topology.tileDistance(from, coords) > (proto.range || 1);
}
const route = routes.get(unit.id);
if (!route) return false;
if (!proto.air) return false;
// A ferry flight is bounded by fuel, not the combat mission radius:
// endurance hours at the aircraft's speed.
const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1);
return route.length - 1 > ferryTiles;
});
if (outOfRange) {
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
return;
}
if (targets.length > 0) {
const names = targets.map((index) => this._civName(index)).join(", ");
const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
this.confirmModal.show(
"Declare war?",
`Marching into ${demonyms} territory will declare war on ${names}. Continue?`,
() => this._sendMove(units, coords, speed, type, routes)
);
return;
}
return this._sendMove(units, coords, speed, type, routes);
}
_sendMove(units, coords, speed = null, type = "move", routes = null) {
const ids = units.map((unit) => unit.id);
const order = { type, coords: [coords.x, coords.y] };
if (ids.length === 1) order.unit = ids[0];
else order.units = ids;
if (type === "schedule") {
order.goals = [[coords.x, coords.y]];
order.append = true;
}
this.sendOrder(order);
// A ranged attack is resolved instantly on the server without moving, so
// predicting a march there would only make the icon drift.
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
if (striking) return Promise.resolve();
const pending = [];
for (const unit of units) {
// An append joins the queue behind the leg already under way, so only an
// idle stack gets a fresh prediction.
const motion = this.map._unitMotion.get(unit.id);
if (type === "schedule" && motion && motion.predicting) continue;
// Reuse the route the war check already found instead of searching again.
const route = routes && routes.get(unit.id);
if (route) this.map.predictMove(unit, coords, speed, route);
else pending.push(unit);
}
// A fresh search cancels any other, so units still without a planned route
// wait their turn rather than fighting for the single search slot.
return pending.reduce(
(chain, unit) => chain.then(() => this.map.predictMove(unit, coords, speed)),
Promise.resolve()
);
}
// ------------------------------------------------------- pillage -------
_pillageSelected() {
const units = this._selectedUnits();
if (units.length === 0) return;
this.sendOrder({ type: "pillage", units: units.map((unit) => unit.id) });
}
// --------------------------------------------------------------- input ---
_onKey(event) {
if (!this._entered) return;
const step = event.shiftKey ? 120 : 40;
switch (event.key) {
case "w": case "ArrowUp": this.map.panBy(0, step); break;
case "s": case "ArrowDown": this.map.panBy(0, -step); break;
case "a": case "ArrowLeft": this.map.panBy(step, 0); break;
case "d": case "ArrowRight": this.map.panBy(-step, 0); break;
case "+": case "=":
this.map.camera.zoom = Math.min(2, this.map.camera.zoom * 1.1);
this.map._applyCamera();
break;
case "-":
this.map.camera.zoom = Math.max(0.2, this.map.camera.zoom / 1.1);
this.map._applyCamera();
break;
case "Escape": {
// ESC closes an open modal first; over the map it clears the selection
// and the inspected tile, dismissing both bottom panels.
const modalOpen = $(".modal").not(".hidden").length > 0;
this.cityModal.close();
this.nationModal.close();
this.tileModal.close();
this.confirmModal.close();
this.newsModal.close();
this.mapModesModal.close();
if (!modalOpen) this.map.clearSelection();
break;
}
default:
return;
}
event.preventDefault();
}
// -------------------------------------------------------------- sound ---
// Drives both bombardment effects. The ambient barrage plays while a ground
// or naval battery's target is on screen; an air strike's one-shot burst is
// started by `_detectAirStrikes` and simply runs down here.
_updateBombing(dtSeconds) {
const dtMs = dtSeconds * 1000;
this.bombing.update(dtMs, this._bombingInView(), zoomVolume(this.map.camera.zoom));
this.airBombing.update(dtMs);
}
_bombingInView() {
const units = (this.snapshot && this.snapshot.units) || [];
for (const unit of units) {
if (!unit.strikeTarget) continue;
// Aircraft do not rumble continuously: their sound waits for the bombs to
// actually fall (see `_detectAirStrikes`).
const proto = this.protoUnits[unit.proto];
if (proto && proto.air) continue;
if (this.map.tileInView({ x: unit.strikeTarget[0], y: unit.strikeTarget[1] })) {
return true;
}
}
return false;
}
// A plane clears its strike target the instant it reaches it and drops its
// bombs; that transition, seen while the target is on screen, is the actual
// bombing and gets one large burst. A plane shot down on approach is simply
// gone from the next snapshot, and one still flying keeps its target, so
// neither triggers anything. The first snapshot only sets the baseline.
_detectAirStrikes(state) {
const next = new Map();
for (const unit of state.units || []) {
const proto = this.protoUnits[unit.proto];
next.set(unit.id, {
air: !!(proto && proto.air),
target: unit.strikeTarget || null,
});
}
const previous = this._airStrikeState;
this._airStrikeState = next;
if (!previous) return;
const volume = zoomVolume(this.map.camera.zoom);
for (const [id, before] of previous) {
if (!before.air || !before.target) continue;
const after = next.get(id);
if (!after || after.target) continue;
if (this.map.tileInView({ x: before.target[0], y: before.target[1] })) {
this.airBombing.trigger(volume);
}
}
}
// --------------------------------------------------------------- frame ---
_frame(now) {
if (!this._entered) return;
// Schedule the next frame before doing any work: a one-off error in a
// renderer or a modal must never stop the animation loop for good.
requestAnimationFrame((t) => this._frame(t));
const dt = Math.min(0.1, (now - this._lastFrame) / 1000);
this._lastFrame = now;
try {
this.map.animate(dt);
this._updateBombing(dt);
if (this._loading) this._pollLoading();
} catch (error) {
if (typeof console !== "undefined" && console.error) {
console.error("Frame update failed:", error);
}
}
this._fpsFrames += 1;
this._fpsElapsed += dt;
if (this._fpsElapsed >= 0.25) {
const fps = Math.round(this._fpsFrames / this._fpsElapsed);
this._fpsElapsed = 0;
this._fpsFrames = 0;
const $label = $("#fps-label").text(`${fps} FPS`);
$label.css("color", fps < 30 ? "#f24" : fps < 50 ? "#fa4" : "#8fdc8f");
}
}
}
Object.assign(GameScreen.prototype, panelMethods);