// 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 = $('
'); 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 = $(''); 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 = $(''); 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 = $(''); // 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 );