Files
Battle-for-Tismo/client/js/map_view.js
T
adrien 1324128245 Showed idle trade routes and flagged migrant flows on the economic maps
The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.

The population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph.
2026-09-22 21:44:29 +02:00

321 lines
13 KiB
JavaScript

// The world map view. It owns no game logic: the server generates and
// simulates the world, and this class rebuilds the terrain once from the shared
// seed and mirrors units, cities, territory and fog from each snapshot, then
// turns pointer input into selection and move orders. All rendering is done
// with ordinary DOM elements, as required.
//
// The behaviour is split across the modules in ./map_view/ and composed onto the
// prototype below, so each concern (terrain, entities, motion, input) stays in
// its own file.
import { terrainMethods } from "./map_view/terrain.js";
import { entityMethods } from "./map_view/entities.js";
import { motionMethods } from "./map_view/motion.js";
import { inputMethods } from "./map_view/input.js";
import { politicalMethods } from "./map_view/political.js";
import { modeMethods } from "./map_view/modes.js";
import { createGLMapRenderer } from "./map_view/webgl.js";
import { detectCapabilities } from "./capabilities.js";
import { CAMERA_TILT } from "./map_view/constants.js";
// Re-exported for tests and callers that use the border geometry directly.
export { inwardOffset } from "./map_view/utils.js";
export class MapView {
constructor($viewport, $world, layers, options = {}) {
this.$viewport = $viewport;
this.$world = $world;
this.$terrain = layers.terrain;
this.$roads = layers.roads && layers.roads.length
? layers.roads
: $("#layer-roads");
this.$borders = layers.borders;
this.$fog = layers.fog;
// The outline drawn over the selected or inspected tile. It sits above the
// terrain and fog but below the unit, target and city icons.
this.$highlight = layers.highlight && layers.highlight.length
? layers.highlight
: $("#layer-highlight");
if (!this.$highlight.length) {
this.$highlight = $('<div id="layer-highlight" class="layer"></div>');
if (this.$fog && this.$fog.length) this.$fog.after(this.$highlight);
else this.$world.append(this.$highlight);
}
this.$paths = layers.paths;
// Bombardment target lines and rings, drawn over the terrain and paths but
// under the unit and city icons.
this.$targets = layers.targets && layers.targets.length
? layers.targets
: $("#layer-targets");
// Resource storage nodes and delivery legs, drawn in the economic map
// modes so the delivery graph is never visible on the political map.
this.$resources = layers.resources && layers.resources.length
? layers.resources
: $("#layer-resources");
if (!this.$resources.length) {
this.$resources = $('<div id="layer-resources" class="layer"></div>');
if (this.$targets && this.$targets.length) this.$targets.after(this.$resources);
else this.$world.append(this.$resources);
}
this.resourceGraph = { nodes: [], links: [], edges: [] };
// The merchandise icons riding the delivery legs, repositioned every frame.
this._deliveryMovers = [];
// City-to-city migration over the last month, drawn instead of the delivery
// graph while the population map is active.
this.migrationGraph = { links: [] };
this.$entities = layers.entities;
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
this.$labels = layers.labels && layers.labels.length
? layers.labels
: $("#layer-labels");
if (!this.$labels.length) {
this.$labels = $('<div id="layer-labels" class="layer city-label-layer"></div>');
this.$world.append(this.$labels);
}
// Country names for the political map live below the city labels, so a city
// name is never hidden behind a (much larger) country name.
this.$politicalLabels = layers.politicalLabels && layers.politicalLabels.length
? layers.politicalLabels
: $("#layer-political-labels");
if (!this.$politicalLabels.length) {
this.$politicalLabels = $('<div id="layer-political-labels" class="layer country-label-layer"></div>');
// Keep the names under the icon layers even when the layer was missing
// from the page: insert before the first overlay rather than on top.
if (this.$highlight && this.$highlight.length) {
this.$politicalLabels.insertBefore(this.$highlight);
} else {
this.$world.append(this.$politicalLabels);
}
}
// The orthographic camera tilts the ground plane, so the terrain and every
// ground overlay are scaled on Y while the sprite layers stay upright.
this.$viewport[0].style.setProperty("--camera-tilt", String(CAMERA_TILT));
for (const $layer of [
this.$terrain, this.$roads, this.$borders, this.$fog,
this.$highlight, this.$paths, this.$targets, this.$resources,
]) {
if ($layer && $layer.length) $layer.addClass("tilted");
}
this.seed = -1;
// Signature of the terrain currently built, so a snapshot with the same
// seed and config does not rebuild the world.
this._terrainSignature = null;
this.mapConfig = null;
this.topology = null;
this.tiles = {};
this.terrainStats = null;
this.explored = new Set();
this.visible = new Set();
this.territory = new Map();
// Each land tile's region, keyed "x,y" and mapped to the region's city id.
this.regions = new Map();
// Pre-generated and player-built road tiles, keyed "x,y".
this.roads = new Set();
// Player-built railway tiles, keyed "x,y". A tile is a road or a railway.
this.railways = new Set();
// Trenches, and military improvements keyed "x,y" -> { id, owner, hp }.
this.trenches = new Set();
this.tileImprovements = new Map();
this._warfareSignature = null;
this._warfareViews = new Map();
this.civilisations = [];
this.localCiv = -1;
this.protoUnits = [];
// Battlefield tiles, keyed "x,y" and mapped to { defender, attacker }.
this._battles = new Map();
// The active map mode, plus the effective fills derived from it. `political`
// is also true while terrain mode is zoomed far out; `economic` is true for
// the GDP/population modes and comes with a per-tile value map and range.
this.mapMode = options.mapMode || "terrain";
this.political = false;
this.economic = false;
this.economicKind = null;
this.economicValues = new Map();
this.economicRange = { min: 0, max: 0 };
// Value transform for the active economic mode (population reads logs).
this.economicScale = null;
// The viewer's trains-technology rail bonus, mirrored so predicted movement
// along railways matches the server.
this.railSpeed = 0;
this._economicSignatureDone = null;
// WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`),
// and only for the band of rows on screen.
this._fogDirty = false;
this._fogBuiltAt = 0;
// The row band the fog mesh currently covers, so a pan inside it needs no
// rebuild.
this._fogCover = undefined;
// Tiles whose visibility changed since the last flush, patched per tile.
this._fogChanged = null;
// The colour scale shown while an economic mode is active.
this.$legend = $("#map-legend");
this.$legendGraph = $("#map-legend-graph");
this.$legendGraphTitle = $("#map-legend-graph-title");
this.$legendResources = $("#map-legend-resources");
this._labelSvg = null;
this._labelEntries = [];
this._countryLabels = [];
this.camera = { x: 0, y: 0, zoom: 1.0 };
this._centered = false;
this._selectedUnitIds = new Set();
this._selectedTile = null;
// Region (city id) of the selected/inspected tile, highlighted as a whole.
this._selectedRegion = null;
// Lazily built SVG outline for the selected/inspected tile.
this._highlightEl = null;
this._highlightPoly = null;
this._highlightSolid = null;
this._highlightHidden = null;
this._unitViews = new Map();
this._unitMotion = new Map();
this._unitData = new Map();
this._cityViews = new Map();
this._cityCiv = new Map();
this._battleViews = new Map();
// Reused nodes for the bombardment overlay, plus the throttle state that
// keeps it from being rebuilt every animation frame (see `_drawStrikeTargets`).
this._targetViews = new Map();
this._targetSvg = null;
this._strikeTick = null;
this._strikeSignature = "";
// In-flight pathfinding job, cancelled when a newer request supersedes it.
this._pathToken = null;
this._maxStepLength = 1.0;
this._territorySignature = "";
this._regionsSignature = null;
this._territoryVersion = undefined;
this._regionsVersion = undefined;
this._improvementsSignature = null;
this._exploredCount = -1;
this._visibleSignature = null;
// Cylindrical wrap state. `_period` is the horizontal pixel period (0 on a
// flat map); every entity is shifted to the copy nearest the camera.
this._period = 0;
this._textureRepeat = 0;
this._worldWidth = 0;
this._worldHeight = 0;
this._worldMinX = 0;
this._worldMinY = 0;
// Chunked detailed layers: only the chunks near the viewport exist as DOM.
// The tile wrappers are pooled so panning recycles nodes instead of
// allocating a fresh one for every tile that scrolls in.
this._chunkSize = 0;
this._chunkCols = 0;
this._chunkRows = 0;
this._chunks = new Map();
this._chunkCache = new Map();
this._chunkCacheLimit = 0;
this._chunkOriginCache = new Map();
this._chunkView = { x: null, y: null, zoom: null };
this._chunkDirty = true;
this._hexPool = [];
this._borderPool = [];
this._roadPool = [];
this._wrapperPool = [];
this.onUnitSelected = () => {};
this.onCitySelected = () => {};
this.onCityOpened = () => {};
this.onTileRequested = () => {};
this.onMoveOrdered = () => {};
this.onAttackOrdered = () => {};
this.onScheduleOrdered = () => {};
this.onStackMenu = () => {};
// The map (terrain, roads, borders, fog) is drawn on a WebGL canvas when
// the browser provides a context; the icon layers stay DOM.
// "dom" forces the classic renderer (tests, benchmark, troubleshooting).
this.glRenderer = null;
this.glCanvas = null;
this.rendererMode = options.renderer || "auto";
// Set when the GPU renderer is not used: "webgl-unavailable" (no context, a
// shader/limit failure) or "software" (a CPU rasteriser, slower than DOM).
this.rendererFallback = null;
// Lazily built capability report for the fallback notice (see
// `capabilityReport`); null until asked for.
this.capabilities = null;
if (this.rendererMode !== "dom") this._setupGLRenderer();
this._setupCameraInput();
}
_setupGLRenderer() {
// The viewport element survives between games; drop any canvas a previous
// MapView left behind so contexts do not accumulate.
const previous = this.$viewport[0].querySelector("#map-canvas");
if (previous) previous.remove();
const canvas = document.createElement("canvas");
canvas.id = "map-canvas";
const world = this.$world[0];
if (world.parentNode) world.parentNode.insertBefore(canvas, world);
const renderer = createGLMapRenderer(canvas);
if (!renderer) {
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
this.rendererFallback = "webgl-unavailable";
return;
}
// A CPU rasteriser (no usable GPU) can be slower than the DOM renderer.
// In auto mode, keep the DOM path there; "webgl" forces the canvas.
if (this.rendererMode === "auto" && renderer.softwareRenderer()) {
renderer.dispose();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
this.rendererFallback = "software";
return;
}
this.glRenderer = renderer;
this.glCanvas = canvas;
this.$world.addClass("gl-render");
}
// A detailed, cached report of every WebGL/WASM feature this client needs.
// Only meaningful when the GPU renderer was not used; it probes a throwaway
// context, so it is built on demand (when the fallback notice appears) rather
// than in the constructor. `mapSize` sharpens the texture-size requirement to
// the map being played; it falls back to the world already built, if any.
capabilityReport(mapSize) {
if (!this.capabilities) {
const size = mapSize || (this.mapConfig && this.mapConfig.mapSize) || null;
this.capabilities = detectCapabilities({ mapSize: size });
}
return this.capabilities;
}
// Draws the map canvas from the current view state, if WebGL is active. Safe
// to call every frame: the renderer skips untouched camera/content state.
_glRender() {
if (!this.glRenderer) return;
this.glRenderer.render(this, this.$viewport.width(), this.$viewport.height());
}
// Whether the map is fully loaded and safe to reveal. The DOM renderer is
// ready as soon as its chunks are synced; the WebGL renderer waits until its
// terrain textures have finished uploading, so the loading screen does not
// lift onto a half-drawn map.
isReady() {
return !this.glRenderer || this.glRenderer.isReady();
}
// Texture-upload progress for the loading log, or null on the DOM renderer.
loadingProgress() {
if (!this.glRenderer) return null;
return { loaded: this.glRenderer.texLoaded, total: this.glRenderer.texTotal };
}
}
Object.assign(
MapView.prototype,
terrainMethods,
entityMethods,
motionMethods,
inputMethods,
politicalMethods,
modeMethods
);