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

623 lines
24 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, 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";
export class GameScreen {
constructor(network, config) {
this.network = network;
this.config = config;
this.$screen = $("#screen-game");
this.snapshot = null;
this.localCiv = 0;
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._gdpBaseline = new Map();
this._tileEthnicity = new Map();
this._populationVersion = undefined;
this._gdpBaselineVersion = undefined;
this._ethnicityVersion = undefined;
this._territoryVersion = undefined;
this._regionVersion = undefined;
this._tileGdpPenalties = new Map();
this._tileBattleGdpDeficits = 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._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"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
}
);
this.cityModal = new CityModal();
this.nationModal = new NationModal();
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.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);
}
sendSetup() {
const ids = this.config.civilisations.map((c) => c.id);
const playerIndex = Math.max(0, ids.indexOf(this.config.playerCivId));
this.network.sendGameSetup({
seed: this.config.seed || 0,
player_civ: playerIndex,
civilisations: ids,
testing: !!this.config.testing,
});
}
// ----------------------------------------------------------- snapshots ---
onState(state) {
this.snapshot = state;
this.localCiv = state.viewer;
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 GDP per capita of every land tile never changes, so it is
// built once and kept across the deltas that omit it.
if (versionChanged("gdpBaseline", this._gdpBaselineVersion)) {
this._gdpBaselineVersion = versionOf("gdpBaseline");
this._gdpBaseline = new Map(
(state.gdpBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
}
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._tileGdpPenalties = new Map(
(state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
this._tileBattleGdpDeficits = new Map(
(state.tileBattleGdpDeficits || []).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.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);
// 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.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);