623 lines
24 KiB
JavaScript
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);
|