GDP per capita had no natural growth, so a nation's economy only moved when population, buildings or war did. The summary stats also offered inconsistent hover text: a native browser tooltip for popularity and none at all for the budget. - Every land tile now gains a constant 0.01% of the GDP per capita it opened with, once a day. Because the amount is fixed the relative growth rate tapers over the years. Each tile's opening figure is snapshotted once and shipped to the browser, so the tile panel and the GDP-per-capita map mode stay in step with the server. - The population, GDP and GDP/capita tooltips show the relative growth: against 1 January 2000 through the first year, then year on year. The monthly captures now keep a rolling thirteen months for the baseline. - Every summary stat explains itself through the same custom bubble. Popularity's native title moved onto it, and Budget forecasts the next calendar month at the current hourly net, as a compact figure such as "+€3.28B/mo" (hoursInNextMonth follows the real calendar). - The tile GDP breakdown shows gains with a plus sign (natural growth, ports) rather than the old minus. Added tests for the constant daily gain, the falling relative rate, the first-year and year-on-year baselines, the snapshot baseline, the budget forecast and the calendar month length.
540 lines
21 KiB
JavaScript
540 lines
21 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";
|
|
|
|
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;
|
|
// 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();
|
|
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._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();
|
|
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();
|
|
}
|
|
|
|
// --------------------------------------------------------------- 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);
|
|
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);
|