Files
Battle-for-Tismo/client/js/map_view.js
T
adrien 6f73e2cc51 Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.

Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.

Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
2026-09-22 17:00:38 +02:00

319 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: [] };
// 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
);