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

793 lines
32 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 { flightDistanceTiles } from "../../shared/hex.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);
// Which economy model the game runs ("simple" or "hard"), taken from the
// snapshot so the view can render the matching UI.
this.economyModel = "simple";
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();
// Tiles crippled by sabotage, keyed "x,y".
this._tileSabotage = new Set();
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;
// Whether the next missile attack should carry a nuclear warhead (see the
// unit panel's toggle).
this._nuclearPayload = false;
// While set, the map is picking a ranged-strike target: { ids } of the
// units that will fire. See the Attack action in the unit panel.
this._targeting = null;
// The whole stack the unit panel is showing, so cards deselected in the
// panel stay visible (and can be re-ticked) instead of vanishing.
this._stackMembers = [];
// 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.$chat = $("#chat");
this.$chatLog = $("#chat-log");
this.$chatInput = $("#chat-input");
this.$chatTarget = $("#chat-target");
this._bindMap();
this._bindHud();
this._bindChat();
}
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");
this.closeChat();
}
// ------------------------------------------------------------- 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.economyModel = state.economyModel || (this.config && this.config.economyModel) || "simple";
this.civilisations = state.civs.map(
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
);
this._updateChatTargets(this.civilisations, this.localCiv);
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._tileSabotage = new Set((state.tileSabotage || []).map((e) => `${e[0]},${e[1]}`));
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);
};
// Shift-clicking a unit detaches (or re-attaches) just that unit to the
// moving group, leaving the rest of the stack behind: the ergonomic split.
this.map.onUnitToggled = (id) => {
const unit = this._unit(id);
if (!unit || unit.civ !== this.localCiv) return;
const next = this.selectedUnitIds.slice();
const at = next.indexOf(unit.id);
if (at >= 0) next.splice(at, 1);
else next.push(unit.id);
this._selectUnits(next);
};
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.onTargetChosen = (coords) => this._chooseTarget(coords);
this.map.onTargetCancelled = () => this._endTargeting();
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.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 (and spies) 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 embarkable = units.every((unit) => {
const proto = this.protoUnits[unit.proto];
return proto && (proto.infantry || proto.spy);
});
if (embarkable) {
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 strike a hostile unit or city.",
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);
// An attack on a neutral nation's unit or city declares war exactly as
// marching into its land does, so the browser asks first. The territory
// scan in `_groupMovePlan` only names owners a ground route crosses; a
// strike on an aircraft's straight line, or on a defenceless city, must be
// named here.
if (type === "attack") {
const defender = this._attackTargetCiv(coords);
if (defender >= 0 && !targets.includes(defender) &&
!this._isAtWar(this.localCiv, defender) &&
!this._isAllied(this.localCiv, defender)) {
targets.push(defender);
}
}
// 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] };
if (proto.globalRange) return false;
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. The flight is a straight line,
// so distance is measured along it rather than by counting path tiles.
const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1);
const from = { x: unit.coords[0], y: unit.coords[1] };
return flightDistanceTiles(from, coords, this.map.topology, this.map._maxStepLength) >
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;
// A missile volley carries the warhead the player armed: nuclear only when
// the toggle is on and every unit in the stack is a missile.
if (
type === "attack" &&
this._nuclearPayload &&
units.length > 0 &&
units.every((unit) => this._isMissileUnit(unit))
) {
order.nuclear = true;
}
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) });
}
// --------------------------------------------------------------- chat ---
// Wires the chat input: Enter sends the line, Escape closes the panel.
_bindChat() {
if (!this.$chatInput || !this.$chatInput.length) return;
this.$chatInput.on("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
const text = this.$chatInput.val();
this.$chatInput.val("");
this.network.sendChat(text, this._chatRecipient());
} else if (event.key === "Escape") {
event.preventDefault();
this.closeChat();
}
event.stopPropagation();
});
}
// T opens the chat: the log fades in at the bottom centre and the input takes
// focus so the player can type straight away.
openChat() {
if (!this.$chat || !this.$chat.length) return;
this.$chat.removeClass("hidden");
this.$chatInput.trigger("focus");
}
closeChat() {
if (!this.$chat || !this.$chat.length) return;
this.$chat.addClass("hidden");
this.$chatInput.trigger("blur");
}
// The nation a chat line is addressed to, or null for the public channel.
_chatRecipient() {
if (!this.$chatTarget || !this.$chatTarget.length) return null;
const value = this.$chatTarget.val();
if (value === "" || value === null || value === undefined || value === "public") return null;
const civ = Number(value);
return Number.isInteger(civ) && civ >= 0 ? civ : null;
}
// Rebuilds the chat recipient list: the public channel plus every other
// nation. Rebuilt only when the roster or the viewer changes.
_updateChatTargets(civs, localCiv) {
if (!this.$chatTarget || !this.$chatTarget.length) return;
const signature = `${localCiv}:${civs.map((civ) => civ.id).join(",")}`;
if (this._chatTargetSignature === signature) return;
this._chatTargetSignature = signature;
const previous = this.$chatTarget.val();
this.$chatTarget.empty();
this.$chatTarget.append($("<option value='public'></option>").text("Everyone"));
civs.forEach((civ, index) => {
if (index === localCiv) return;
this.$chatTarget.append($("<option></option>").attr("value", index).text(civ.name));
});
if (previous !== null && previous !== undefined) this.$chatTarget.val(previous);
}
// Appends one line to the chat log. Messages are read bottom to top: the
// newest sits by the input, the oldest scroll off the top, and the list is
// capped so a long game cannot grow it without bound.
addChat(message) {
if (!this.$chatLog || !this.$chatLog.length) return;
const system = !message.name;
const $line = $("<div class='chat-line'></div>");
if (system) $line.addClass("chat-system");
const to = Number.isInteger(message.to) ? message.to : null;
const $author = $("<span class='chat-author'></span>").text(system ? "·" : message.name);
if (to !== null) {
const target = this.civilisations[to];
$line.addClass("chat-private");
$author.text(`${message.name} → ${target ? target.name : `Nation ${to + 1}`}`);
}
$line.append($author);
$line.append($("<span class='chat-text'></span>").text(message.text || ""));
this.$chatLog.append($line);
const maxLines = 100;
const children = this.$chatLog.children();
if (children.length > maxLines) {
children.slice(0, children.length - maxLines).remove();
}
this.$chatLog.scrollTop(this.$chatLog[0].scrollHeight);
}
// --------------------------------------------------------------- input ---
_onKey(event) {
if (!this._entered) return;
// A text field owns its own keys, so typing never pans the map.
if ($(event.target).is("input, textarea")) return;
if (event.key === "t" || event.key === "T") {
this.openChat();
event.preventDefault();
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 leaves target-picking first; then it closes an open modal, and
// over the map it clears the selection and the inspected tile.
if (this._targeting) {
this._endTargeting();
event.preventDefault();
return;
}
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);