Marching into unexplored or enemy land reveals a tile every step, and each reveal rebuilt the whole-map fog mesh: about 1.8M vertices and 50 MB on a 160x160 map, regenerated at the rebuild throttle. Walking through already-explored home land reveals nothing, which is why the stutter only showed up on a border crossing, with a single unit. - buildKnowledge now takes a row range and builds only the rows on screen; the row table stays full length so the existing row-banded draw keeps working. - _flushFog rebuilds on a visibility change or a pan to a new row band, still coalesced, and the draw clamps to the band that was built. - A mode/zoom change schedules the band rebuild instead of building the whole mesh, and uniform interior tiles skip the per-vertex blur kernel.
298 lines
12 KiB
JavaScript
298 lines
12 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");
|
|
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,
|
|
]) {
|
|
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;
|
|
this._fogBand = undefined;
|
|
// The colour scale shown while an economic mode is active.
|
|
this.$legend = $("#map-legend");
|
|
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
|
|
);
|