868 lines
34 KiB
JavaScript
868 lines
34 KiB
JavaScript
// Terrain construction, world-space bounds and the chunked world-space layers.
|
|
//
|
|
// The detailed map is not one element per tile of the whole world. It is built
|
|
// from square CHUNK_SIZE x CHUNK_SIZE tile blocks that are materialised only
|
|
// while they intersect the viewport (plus a margin) and recycled the moment
|
|
// they leave it. Panning therefore costs the same on a 200x200 map as on a
|
|
// 20x20 one. The chunk wrappers live in the terrain, border and fog layers so
|
|
// the layer z-order is unchanged; tiles inside a wrapper are positioned
|
|
// relative to the chunk, and the wrapper itself is moved by `transform` and
|
|
// wrapped by a whole map period.
|
|
|
|
import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from "../../../shared/hex.js";
|
|
import { MapGenerator } from "../../../shared/map_generator.js";
|
|
import { TerrainStats } from "../../../shared/terrain_stats.js";
|
|
import { MAP_CONFIG } from "../../../shared/data.js";
|
|
import {
|
|
CHUNK_SIZE,
|
|
CHUNK_MARGIN,
|
|
CHUNK_CACHE_LIMIT,
|
|
CAMERA_TILT,
|
|
FOG_REBUILD_MS,
|
|
FOG_BAND_MARGIN,
|
|
WRAP_COPIES,
|
|
} from "./constants.js";
|
|
import { terrainLayers, textureRepeat } from "./textures.js";
|
|
import { bootLog } from "../loading.js";
|
|
|
|
export const terrainMethods = {
|
|
ensureTerrain(seed, mapConfig = null) {
|
|
// The snapshot carries the config the seed was generated with; fall back to
|
|
// the shared default for callers that predate it. A signature rather than a
|
|
// reference test keeps a fresh snapshot object from rebuilding the world.
|
|
const config = mapConfig || MAP_CONFIG;
|
|
const signature = `${seed}|${JSON.stringify(config)}`;
|
|
if (signature === this._terrainSignature) return;
|
|
this._terrainSignature = signature;
|
|
this.seed = seed;
|
|
this.mapConfig = config;
|
|
// A new world brings a new transport network; drop the old one (and the
|
|
// cache signature) before the chunks are rebuilt, so setImprovements always
|
|
// installs the new tiles.
|
|
this.roads = new Set();
|
|
this.railways = new Set();
|
|
this._improvementsSignature = null;
|
|
this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
|
|
const generator = new MapGenerator(config, this.topology);
|
|
bootLog.measure("Generated the terrain, settlements and roads", () => {
|
|
generator.generate(seed);
|
|
});
|
|
this.tiles = generator.tiles;
|
|
bootLog.measure("Indexed the terrain for pathfinding", () => {
|
|
this.terrainStats = new TerrainStats(this.tiles);
|
|
});
|
|
this._maxStepLength = this._computeMaxStepLength();
|
|
this._period = this.topology.periodPixels();
|
|
// World pixels per tiling-texture repeat, snapped to the wrap period.
|
|
this._textureRepeat = textureRepeat(this._period);
|
|
this._computeWorldBounds();
|
|
|
|
this._chunkSize = CHUNK_SIZE;
|
|
this._chunkCols = Math.ceil(this.topology.width / CHUNK_SIZE);
|
|
this._chunkRows = Math.ceil(this.topology.height / CHUNK_SIZE);
|
|
this._chunkOriginCache = new Map();
|
|
this._chunkCache = new Map();
|
|
this._chunkCacheLimit = CHUNK_CACHE_LIMIT;
|
|
this._destroyAllChunks();
|
|
this.$terrain.empty();
|
|
this.$roads.empty();
|
|
this.$borders.empty();
|
|
this.$fog.empty();
|
|
this.$terrain.addClass("detail-layer");
|
|
this.$roads.addClass("detail-layer");
|
|
this.$borders.addClass("detail-layer");
|
|
this.$fog.addClass("detail-layer");
|
|
|
|
this._territorySignature = "";
|
|
this._regionsSignature = null;
|
|
this._territoryVersion = undefined;
|
|
this._regionsVersion = undefined;
|
|
this._exploredCount = -1;
|
|
this._visibleSignature = null;
|
|
// Visibility is per-world; the snapshot reapplies it right after this.
|
|
this.explored = new Set();
|
|
this.visible = new Set();
|
|
this._fogDirty = false;
|
|
this._fogBuiltAt = 0;
|
|
this._chunkDirty = true;
|
|
this._chunkView = { x: null, y: null, zoom: null };
|
|
|
|
this.$entities.empty();
|
|
this.$labels.empty();
|
|
if (this.$politicalLabels) this.$politicalLabels.empty();
|
|
this._labelSvg = null;
|
|
this._labelEntries = [];
|
|
// The new world invalidates any political or economic colouring; the next
|
|
// camera sync re-applies the mode if the camera is still far out.
|
|
this.political = false;
|
|
this.economic = false;
|
|
this.economicValues = new Map();
|
|
this._economicSignatureDone = null;
|
|
this.$world.removeClass("political");
|
|
this.$world.removeClass("economic");
|
|
this._unitViews.clear();
|
|
this._unitMotion.clear();
|
|
this._unitData.clear();
|
|
this._cityViews.clear();
|
|
this._cityCiv.clear();
|
|
for (const view of this._battleViews.values()) view.remove();
|
|
this._battleViews.clear();
|
|
this._battles.clear();
|
|
// A new world invalidates the old tile outline; the game screen re-applies
|
|
// the current selection once the new snapshot has been read.
|
|
this.clearSelectedTile();
|
|
this._centered = false;
|
|
this._chunkDirty = true;
|
|
// A WebGL build reads the whole world out of the view, so hand it the new
|
|
// terrain before any of the per-layer update hooks run.
|
|
if (this.glRenderer) {
|
|
bootLog.measure("Built the map layers (WebGL)", () => this.glRenderer.setWorld(this));
|
|
}
|
|
bootLog.measure("Built the visible map chunks", () => this._syncChunks());
|
|
},
|
|
|
|
// Pixel bounds of the whole world, used to draw the wrapped copies of the
|
|
// terrain in the renderer.
|
|
_computeWorldBounds() {
|
|
let minX = Infinity;
|
|
let maxX = -Infinity;
|
|
let minY = Infinity;
|
|
let maxY = -Infinity;
|
|
for (const k in this.tiles) {
|
|
const coords = parseKey(k);
|
|
const local = mapToLocal(coords.x, coords.y);
|
|
minX = Math.min(minX, local.x - HEX_W / 2);
|
|
maxX = Math.max(maxX, local.x + HEX_W / 2);
|
|
minY = Math.min(minY, local.y - HEX_H / 2);
|
|
maxY = Math.max(maxY, local.y + HEX_H / 2);
|
|
}
|
|
if (!isFinite(minX)) {
|
|
minX = 0;
|
|
maxX = 0;
|
|
minY = 0;
|
|
maxY = 0;
|
|
}
|
|
this._worldMinX = minX;
|
|
this._worldMinY = minY;
|
|
this._worldWidth = maxX - minX;
|
|
this._worldHeight = maxY - minY;
|
|
},
|
|
|
|
getTile(coords) {
|
|
return this.tiles[key(coords.x, coords.y)] || null;
|
|
},
|
|
|
|
// ------------------------------------------------------------- chunks --
|
|
|
|
// Geometry of the chunk at column/row (cx, cy): the inclusive tile range it
|
|
// covers, plus its unwrapped pixel origin and size. Cached because culling
|
|
// walks every chunk cell on each camera move.
|
|
_chunkOrigin(cx, cy) {
|
|
const cacheKey = cx + "," + cy;
|
|
const cached = this._chunkOriginCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
const size = this._chunkSize;
|
|
const t = this.topology;
|
|
const x0 = t.originX + cx * size;
|
|
const y0 = t.originY + cy * size;
|
|
const x1 = Math.min(x0 + size - 1, t.originX + t.width - 1);
|
|
const y1 = Math.min(y0 + size - 1, t.originY + t.height - 1);
|
|
const center = mapToLocal(x0, y0);
|
|
const origin = {
|
|
x0,
|
|
y0,
|
|
x1,
|
|
y1,
|
|
x: center.x,
|
|
y: center.y,
|
|
px: (x1 - x0) * COL_STEP,
|
|
py: (y1 - y0) * HEX_H,
|
|
};
|
|
this._chunkOriginCache.set(cacheKey, origin);
|
|
return origin;
|
|
},
|
|
|
|
// Creates the empty wrappers that hold one chunk in each detailed layer.
|
|
_newChunk() {
|
|
const terrain = this._acquireWrapper();
|
|
const fog = this._acquireWrapper();
|
|
const borders = this._acquireWrapper();
|
|
const roads = this._acquireWrapper();
|
|
this.$terrain[0].appendChild(terrain);
|
|
this.$fog[0].appendChild(fog);
|
|
this.$roads[0].appendChild(roads);
|
|
this.$borders[0].appendChild(borders);
|
|
return { terrain, fog, borders, roads };
|
|
},
|
|
|
|
_createChunk(cx, cy) {
|
|
const chunkKey = cx + "," + cy;
|
|
const cached = this._chunkCache.get(chunkKey);
|
|
if (cached) {
|
|
// Reattach the already-built chunk; only the layers that changed while it
|
|
// was parked need rebuilding.
|
|
this._chunkCache.delete(chunkKey);
|
|
this.$terrain[0].appendChild(cached.terrainEl);
|
|
this.$fog[0].appendChild(cached.fogEl);
|
|
this.$roads[0].appendChild(cached.roadsEl);
|
|
this.$borders[0].appendChild(cached.borderEl);
|
|
this._chunks.set(chunkKey, cached);
|
|
if (cached.fogDirty) this._buildFog(cached);
|
|
if (cached.borderDirty) this._buildBorders(cached);
|
|
if (cached.roadsDirty) this._buildRoads(cached);
|
|
this._setChunkPosition(cached, this._wrapShift(cached.x));
|
|
return cached;
|
|
}
|
|
|
|
const origin = this._chunkOrigin(cx, cy);
|
|
const rec = {
|
|
cx,
|
|
cy,
|
|
x0: origin.x0,
|
|
y0: origin.y0,
|
|
x1: origin.x1,
|
|
y1: origin.y1,
|
|
x: origin.x,
|
|
y: origin.y,
|
|
px: origin.px,
|
|
py: origin.py,
|
|
shift: null,
|
|
tileKeys: [],
|
|
fogDirty: false,
|
|
borderDirty: false,
|
|
roadsDirty: false,
|
|
terrainEl: null,
|
|
fogEl: null,
|
|
borderEl: null,
|
|
roadsEl: null,
|
|
};
|
|
const wrappers = this._newChunk();
|
|
rec.terrainEl = wrappers.terrain;
|
|
rec.fogEl = wrappers.fog;
|
|
rec.borderEl = wrappers.borders;
|
|
rec.roadsEl = wrappers.roads;
|
|
this._chunks.set(chunkKey, rec);
|
|
this._buildTerrain(rec);
|
|
this._buildRoads(rec);
|
|
this._buildBorders(rec);
|
|
this._buildFog(rec);
|
|
this._setChunkPosition(rec, this._wrapShift(rec.x));
|
|
return rec;
|
|
},
|
|
|
|
_setChunkPosition(rec, shift) {
|
|
rec.shift = shift;
|
|
const transform = `translateX(${rec.x + shift}px) translateY(${rec.y}px)`;
|
|
rec.terrainEl.style.transform = transform;
|
|
rec.fogEl.style.transform = transform;
|
|
rec.borderEl.style.transform = transform;
|
|
rec.roadsEl.style.transform = transform;
|
|
},
|
|
|
|
// Parks a built chunk off-screen so panning back over it is a reattach rather
|
|
// than a rebuild. Old chunks are dropped (and their tiles pooled) once the
|
|
// cache is full.
|
|
_detachChunk(chunkKey, rec) {
|
|
rec.terrainEl.parentNode && rec.terrainEl.parentNode.removeChild(rec.terrainEl);
|
|
rec.fogEl.parentNode && rec.fogEl.parentNode.removeChild(rec.fogEl);
|
|
rec.roadsEl.parentNode && rec.roadsEl.parentNode.removeChild(rec.roadsEl);
|
|
rec.borderEl.parentNode && rec.borderEl.parentNode.removeChild(rec.borderEl);
|
|
this._chunks.delete(chunkKey);
|
|
this._chunkCache.set(chunkKey, rec);
|
|
while (this._chunkCache.size > this._chunkCacheLimit) {
|
|
const [oldKey, old] = this._chunkCache.entries().next().value;
|
|
this._chunkCache.delete(oldKey);
|
|
this._destroyChunk(old);
|
|
}
|
|
},
|
|
|
|
_destroyChunk(rec) {
|
|
this._recycleLayer(rec.terrainEl, this._hexPool);
|
|
this._recycleLayer(rec.fogEl, this._hexPool);
|
|
this._recycleLayer(rec.roadsEl, this._roadPool);
|
|
this._recycleLayer(rec.borderEl, this._borderPool);
|
|
this._releaseWrapper(rec.terrainEl);
|
|
this._releaseWrapper(rec.fogEl);
|
|
this._releaseWrapper(rec.roadsEl);
|
|
this._releaseWrapper(rec.borderEl);
|
|
},
|
|
|
|
_destroyAllChunks() {
|
|
if (this._chunks) {
|
|
for (const rec of this._chunks.values()) this._destroyChunk(rec);
|
|
this._chunks.clear();
|
|
}
|
|
if (this._chunkCache) {
|
|
for (const rec of this._chunkCache.values()) this._destroyChunk(rec);
|
|
this._chunkCache.clear();
|
|
}
|
|
},
|
|
|
|
// Materialises every chunk that overlaps the viewport (expanded by
|
|
// CHUNK_MARGIN blocks) and releases the rest. Cheap enough to run each frame:
|
|
// it walks the chunk grid, not the tiles.
|
|
_syncChunks() {
|
|
if (!this.topology || !this._chunks) return;
|
|
// With WebGL the map lives in one canvas, not in DOM chunks.
|
|
if (this.glRenderer) return;
|
|
const width = this.$viewport.width();
|
|
const height = this.$viewport.height();
|
|
if (!width || !height) return;
|
|
const zoom = this.camera.zoom || 1;
|
|
const cameraX = this.camera.x;
|
|
const cameraY = this.camera.y;
|
|
if (
|
|
!this._chunkDirty &&
|
|
cameraX === this._chunkView.x &&
|
|
cameraY === this._chunkView.y &&
|
|
zoom === this._chunkView.zoom
|
|
) {
|
|
return;
|
|
}
|
|
this._chunkDirty = false;
|
|
this._chunkView = { x: cameraX, y: cameraY, zoom };
|
|
|
|
const halfW = width / (2 * zoom);
|
|
// The tilt foreshortens the ground, so more world Y fits on screen.
|
|
const halfH = height / (2 * zoom * CAMERA_TILT);
|
|
const slackX = CHUNK_MARGIN * this._chunkSize * COL_STEP;
|
|
const slackY = CHUNK_MARGIN * this._chunkSize * HEX_H;
|
|
const left = cameraX - halfW - slackX;
|
|
const right = cameraX + halfW + slackX;
|
|
const top = cameraY - halfH - slackY;
|
|
const bottom = cameraY + halfH + slackY;
|
|
|
|
const wanted = new Set();
|
|
for (let cy = 0; cy < this._chunkRows; cy++) {
|
|
for (let cx = 0; cx < this._chunkCols; cx++) {
|
|
const origin = this._chunkOrigin(cx, cy);
|
|
const shift = this._wrapShift(origin.x);
|
|
const minX = origin.x + shift - HEX_W;
|
|
const maxX = origin.x + shift + origin.px + HEX_W;
|
|
const minY = origin.y - HEX_H;
|
|
const maxY = origin.y + origin.py + HEX_H;
|
|
if (minX > right || maxX < left || minY > bottom || maxY < top) continue;
|
|
const chunkKey = cx + "," + cy;
|
|
wanted.add(chunkKey);
|
|
let rec = this._chunks.get(chunkKey);
|
|
if (!rec) {
|
|
rec = this._createChunk(cx, cy);
|
|
} else if (rec.shift !== shift) {
|
|
this._setChunkPosition(rec, shift);
|
|
}
|
|
}
|
|
}
|
|
for (const [chunkKey, rec] of Array.from(this._chunks)) {
|
|
if (wanted.has(chunkKey)) continue;
|
|
this._detachChunk(chunkKey, rec);
|
|
}
|
|
},
|
|
|
|
// -------------------------------------------------------- chunk content --
|
|
|
|
_buildTerrain(rec) {
|
|
this._recycleLayer(rec.terrainEl, this._hexPool);
|
|
const fragment = document.createDocumentFragment();
|
|
rec.tileKeys = [];
|
|
for (let y = rec.y0; y <= rec.y1; y++) {
|
|
for (let x = rec.x0; x <= rec.x1; x++) {
|
|
const k = key(x, y);
|
|
const tile = this.tiles[k];
|
|
if (!tile) continue;
|
|
rec.tileKeys.push(k);
|
|
const local = mapToLocal(x, y);
|
|
const div = this._acquireHex(this._hexPool);
|
|
div.className = "hex terrain detail-layer";
|
|
div.style.left = `${local.x - rec.x - HEX_W / 2}px`;
|
|
div.style.top = `${local.y - rec.y - HEX_H / 2}px`;
|
|
if (this.economic) this._applyEconomicColour(div, tile, k);
|
|
else if (this.political) this._applyPoliticalColour(div, tile, k);
|
|
else this._applyTerrainTexture(div, tile, local);
|
|
fragment.appendChild(div);
|
|
}
|
|
}
|
|
rec.terrainEl.appendChild(fragment);
|
|
},
|
|
|
|
// Paints one terrain tile in the DOM fallback. The tiling textures are
|
|
// positioned from the tile's world origin, so two neighbours continue the
|
|
// same pattern; the legacy atlas provides the hexagonal alpha mask. Terrains
|
|
// without a tiling texture keep using the atlas art directly.
|
|
_applyTerrainTexture(div, tile, local) {
|
|
const left = local.x - HEX_W / 2;
|
|
const top = local.y - HEX_H / 2;
|
|
const repeat = this._textureRepeat || 0;
|
|
const layers = terrainLayers(tile.terrainType);
|
|
const maskPosition = `${-tile.col * HEX_W}px 0`;
|
|
if (layers.length && repeat > 0) {
|
|
const position = `${-left}px ${-top}px`;
|
|
div.style.backgroundImage = layers.map((src) => `url("${src}")`).join(", ");
|
|
div.style.backgroundSize = `${repeat}px ${repeat}px`;
|
|
// The overlay (if any) is the top layer and shares the base's position.
|
|
div.style.backgroundPosition = layers.map(() => position).join(", ");
|
|
} else {
|
|
div.style.backgroundImage = 'url("assets/terrain.png")';
|
|
div.style.backgroundSize = "441px 56px";
|
|
div.style.backgroundPosition = maskPosition;
|
|
}
|
|
div.style.maskPosition = maskPosition;
|
|
div.style.webkitMaskPosition = maskPosition;
|
|
},
|
|
|
|
// Fog is rebuilt per materialised chunk, so a change in visibility only costs
|
|
// the few hundred tiles actually on screen instead of the whole map.
|
|
_buildFog(rec) {
|
|
rec.fogDirty = false;
|
|
this._recycleLayer(rec.fogEl, this._hexPool);
|
|
const fragment = document.createDocumentFragment();
|
|
for (let y = rec.y0; y <= rec.y1; y++) {
|
|
for (let x = rec.x0; x <= rec.x1; x++) {
|
|
const k = key(x, y);
|
|
if (!this.tiles[k] || this.visible.has(k)) continue;
|
|
const local = mapToLocal(x, y);
|
|
const div = this._acquireHex(this._hexPool);
|
|
div.className = "hex fog detail-layer " +
|
|
(this.explored.has(k) ? "fog-explored" : "fog-unexplored");
|
|
div.style.left = `${local.x - rec.x - HEX_W / 2}px`;
|
|
div.style.top = `${local.y - rec.y - HEX_H / 2}px`;
|
|
fragment.appendChild(div);
|
|
}
|
|
}
|
|
rec.fogEl.appendChild(fragment);
|
|
},
|
|
|
|
// `changedTiles` is the set of tiles whose visibility just changed. The WebGL
|
|
// fog rebuilds only those (plus their rings), which is cheap enough to do
|
|
// without coalescing; anything else schedules the coalesced band rebuild.
|
|
_refreshFog(changedTiles = null) {
|
|
if (this.glRenderer) {
|
|
if (changedTiles && changedTiles.size > 0 && this._fogCover && !this._fogDirty &&
|
|
this.glRenderer.buildFogTiles) {
|
|
if (!this._fogChanged) this._fogChanged = new Set();
|
|
for (const k of changedTiles) this._fogChanged.add(k);
|
|
return;
|
|
}
|
|
// Do not rebuild the whole-map fog buffer on every snapshot: mark it and
|
|
// let the frame loop flush it at a bounded rate.
|
|
this._fogDirty = true;
|
|
return;
|
|
}
|
|
if (!this._chunks) return;
|
|
for (const rec of this._chunks.values()) this._buildFog(rec);
|
|
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true;
|
|
},
|
|
|
|
// The tile rows the viewport needs fog for, or null when the renderer cannot
|
|
// report them (a stub in tests, or a zero-sized viewport).
|
|
_fogVisibleRows() {
|
|
if (!this.glRenderer || !this.glRenderer._visibleRows) return null;
|
|
const width = this.$viewport.width();
|
|
const height = this.$viewport.height();
|
|
if (!width || !height) return null;
|
|
return this.glRenderer._visibleRows(this, width, height);
|
|
},
|
|
|
|
// Rebuilds the WebGL fog. A visibility change patches only the tiles that
|
|
// changed (see `buildFogTiles`); a pan outside the built rows, a mode change
|
|
// or the first snapshot builds the band on screen. Called once per frame.
|
|
_flushFog() {
|
|
if (!this.glRenderer) return;
|
|
// The cheap path first: the frontier of a moving unit. Bounded by the
|
|
// vision ring, so it does not need the rebuild throttle.
|
|
if (this._fogChanged && this._fogChanged.size > 0 && this.glRenderer.buildFogTiles) {
|
|
const changed = this._fogChanged;
|
|
this._fogChanged = null;
|
|
this.glRenderer.buildFogTiles(this, changed);
|
|
}
|
|
const rows = this._fogVisibleRows();
|
|
const cover = this._fogCover;
|
|
const covered = !!(rows && cover && rows.start >= cover.start && rows.end <= cover.end);
|
|
if (!this._fogDirty && covered) return;
|
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
// A pan that escaped the built band is covered at once, even if a previous
|
|
// rebuild is still inside the throttle window; an ordinary visibility change
|
|
// waits for the interval so a moving unit cannot rebuild every frame.
|
|
const escaping = !!(rows && !covered);
|
|
if (!escaping && this._fogBuiltAt && now - this._fogBuiltAt < FOG_REBUILD_MS) return;
|
|
this._fogDirty = false;
|
|
this._fogBuiltAt = now;
|
|
if (!rows) {
|
|
this._fogCover = null;
|
|
this.glRenderer.buildKnowledge(this);
|
|
return;
|
|
}
|
|
// An escaping pan builds a margin so the next frames do not rebuild again.
|
|
// The margin grows with the viewport so a long drag hits a rebuild only every
|
|
// `margin` rows, which keeps the per-row cost of panning bounded.
|
|
const away = rows.end - rows.start;
|
|
const margin = Math.max(FOG_BAND_MARGIN, away);
|
|
const build = { start: rows.start - margin, end: rows.end + margin };
|
|
// Only a pan can reuse the previous band: a mode change rebuilds the rows on
|
|
// screen even though they were already covered.
|
|
const previous = escaping ? this._fogCover : undefined;
|
|
this._fogCover = build;
|
|
if (!previous) {
|
|
this.glRenderer.buildKnowledge(this, build);
|
|
return;
|
|
}
|
|
// The rows the cover already holds are still valid (visibility patches keep
|
|
// them current), so a pan only builds the newly exposed rows.
|
|
if (build.start < previous.start) {
|
|
this.glRenderer.buildKnowledge(this, {
|
|
start: build.start,
|
|
end: Math.min(build.end, previous.start - 1),
|
|
});
|
|
}
|
|
if (build.end > previous.end) {
|
|
this.glRenderer.buildKnowledge(this, {
|
|
start: Math.max(build.start, previous.end + 1),
|
|
end: build.end,
|
|
});
|
|
}
|
|
},
|
|
|
|
// Installs the transport network (roads and railways). Both change as players
|
|
// build improvements, so a signature over the tile counts and the server's
|
|
// improvement version decides whether the on-screen chunks need rebuilding.
|
|
setImprovements(roads, railways, version) {
|
|
const roadList = roads || [];
|
|
const railList = railways || [];
|
|
const signature = `${roadList.length}|${railList.length}|${version === undefined ? "" : version}`;
|
|
if (this._improvementsSignature === signature) return;
|
|
this._improvementsSignature = signature;
|
|
this.roads = new Set(roadList.map((entry) => key(entry[0], entry[1])));
|
|
this.railways = new Set(railList.map((entry) => key(entry[0], entry[1])));
|
|
if (this.glRenderer) {
|
|
this.glRenderer.buildRoads(this);
|
|
return;
|
|
}
|
|
for (const rec of this._chunks.values()) this._buildRoads(rec);
|
|
if (this._chunkCache) {
|
|
for (const rec of this._chunkCache.values()) rec.roadsDirty = true;
|
|
}
|
|
},
|
|
|
|
// Draws the transport network as texture quads: a strip runs from the centre
|
|
// of every transport tile to the centre of each of its transport neighbours.
|
|
// Roads and railways share one graph, so a mixed link is drawn as two halves
|
|
// that meet at the border — road texture on the road side, rail on the other —
|
|
// and upgrading a tile never leaves a gap. The work is paid per visible chunk,
|
|
// so panning over a huge network only ever builds the tiles on screen.
|
|
_buildRoads(rec) {
|
|
rec.roadsDirty = false;
|
|
this._recycleLayer(rec.roadsEl, this._roadPool);
|
|
const kind = new Map();
|
|
for (const k of this.roads) kind.set(k, "road");
|
|
for (const k of this.railways) kind.set(k, "rail");
|
|
if (kind.size === 0) return;
|
|
const width = 10;
|
|
const fragment = document.createDocumentFragment();
|
|
// One rotated texture quad starting at `from`, running `length` pixels along
|
|
// the link direction, carrying the given road.png/rail.png.
|
|
const addSeg = (from, length, kindName, angle) => {
|
|
const el = this._acquireHex(this._roadPool);
|
|
el.className = kindName === "road" ? "road-seg" : "rail-seg";
|
|
el.style.left = `${from.x - width / 2}px`;
|
|
el.style.top = `${from.y}px`;
|
|
el.style.width = `${width}px`;
|
|
el.style.height = `${length}px`;
|
|
el.style.transformOrigin = "50% 0";
|
|
el.style.transform = `rotate(${angle - Math.PI / 2}rad)`;
|
|
el.style.backgroundImage = `url("assets/textures/${kindName}.png")`;
|
|
el.style.backgroundSize = "16px 16px";
|
|
el.style.backgroundPosition = "-3px 0";
|
|
el.style.backgroundRepeat = "repeat-y";
|
|
fragment.appendChild(el);
|
|
};
|
|
for (const k of rec.tileKeys) {
|
|
if (!kind.has(k)) continue;
|
|
const coords = parseKey(k);
|
|
const center = mapToLocal(coords.x, coords.y);
|
|
const localCenter = { x: center.x - rec.x, y: center.y - rec.y };
|
|
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (!kind.has(nk) || k >= nk) continue;
|
|
const delta = this.topology.pixelDelta(coords, neighbour);
|
|
const length = Math.hypot(delta.x, delta.y);
|
|
if (length <= 0) continue;
|
|
const angle = Math.atan2(delta.y, delta.x);
|
|
const here = kind.get(k);
|
|
const there = kind.get(nk);
|
|
if (here === there) {
|
|
addSeg(localCenter, length, here, angle);
|
|
} else {
|
|
const mid = { x: localCenter.x + delta.x / 2, y: localCenter.y + delta.y / 2 };
|
|
addSeg(localCenter, length / 2, here, angle);
|
|
addSeg(mid, length / 2, there, angle);
|
|
}
|
|
}
|
|
}
|
|
rec.roadsEl.appendChild(fragment);
|
|
},
|
|
|
|
// ------------------------------------------------------------ pooling --
|
|
|
|
_acquireWrapper() {
|
|
const el = this._wrapperPool.pop();
|
|
if (el) {
|
|
el.className = "chunk";
|
|
el.style.cssText = "";
|
|
return el;
|
|
}
|
|
return document.createElement("div");
|
|
},
|
|
|
|
_releaseWrapper(el) {
|
|
el.className = "";
|
|
el.style.cssText = "";
|
|
el.parentNode && el.parentNode.removeChild(el);
|
|
this._wrapperPool.push(el);
|
|
},
|
|
|
|
_acquireHex(pool) {
|
|
return pool.pop() || document.createElement("div");
|
|
},
|
|
|
|
_recycleLayer(wrapper, pool) {
|
|
let child = wrapper.firstChild;
|
|
while (child) {
|
|
const next = child.nextSibling;
|
|
wrapper.removeChild(child);
|
|
child.className = "";
|
|
child.style.cssText = "";
|
|
pool.push(child);
|
|
child = next;
|
|
}
|
|
},
|
|
|
|
// --------------------------------------------------------------- wrap --
|
|
|
|
// Pixel offset (a whole number of map periods) that moves an element sitting
|
|
// at `centerX` to the copy closest to the camera. Choosing the half-open band
|
|
// [camera - P/2, camera + P/2) keeps neighbouring tiles contiguous, so the
|
|
// world tiles seamlessly in both directions.
|
|
_wrapShift(centerX) {
|
|
if (!this._period) return 0;
|
|
const period = this._period;
|
|
return Math.ceil((this.camera.x - period / 2 - centerX) / period) * period;
|
|
},
|
|
|
|
_wrappedPosition(point) {
|
|
return { x: point.x + this._wrapShift(point.x), y: point.y };
|
|
},
|
|
|
|
// The copy of `point` nearest a reference point, used to draw a route or line
|
|
// between two places as their short wrapped link instead of across the seam.
|
|
_nearestCopy(point, reference) {
|
|
if (!this._period) return { x: point.x, y: point.y };
|
|
const k = Math.round((reference.x - point.x) / this._period);
|
|
return { x: point.x + k * this._period, y: point.y };
|
|
},
|
|
|
|
// The horizontal offsets (whole periods) at which the repeating world is
|
|
// drawn. The first is the copy the camera sits in, the rest its neighbours,
|
|
// capped at WRAP_COPIES. A flat map has a single offset, 0.
|
|
_visibleWrapOffsets() {
|
|
if (!this._period) return [0];
|
|
const width = this.$viewport.width();
|
|
const zoom = this.camera.zoom || 1;
|
|
if (!width) return [0];
|
|
const half = width / (2 * zoom);
|
|
const viewMin = this.camera.x - half;
|
|
const viewMax = this.camera.x + half;
|
|
const worldMin = this._worldMinX;
|
|
const worldMax = this._worldMinX + this._worldWidth;
|
|
const period = this._period;
|
|
const kMin = Math.ceil((viewMin - worldMax) / period);
|
|
const kMax = Math.floor((viewMax - worldMin) / period);
|
|
if (kMax < kMin) return [0];
|
|
const centre = Math.max(kMin, Math.min(kMax, Math.round(this.camera.x / period)));
|
|
const offsets = [centre * period];
|
|
for (let d = 1; offsets.length < WRAP_COPIES && (centre - d >= kMin || centre + d <= kMax); d++) {
|
|
if (centre - d >= kMin) offsets.push((centre - d) * period);
|
|
if (offsets.length < WRAP_COPIES && centre + d <= kMax) offsets.push((centre + d) * period);
|
|
}
|
|
return offsets;
|
|
},
|
|
|
|
// The map-local X of a world pixel, folded back into one period so a point
|
|
// just past the seam matches the tile it is drawn over. Used by hit-testing,
|
|
// which resolves clicks by tile rather than by which copy was clicked.
|
|
_canonicalX(x) {
|
|
if (!this._period) return x;
|
|
const base = COL_STEP * this.topology.originX;
|
|
const period = this._period;
|
|
return ((x - base) % period + period) % period + base;
|
|
},
|
|
|
|
// ---------------------------------------------------- repeating layers --
|
|
//
|
|
// A cylindrical world is drawn several times across the viewport. The ground
|
|
// repeats in the WebGL shader (or in cloned terrain chunks on the DOM
|
|
// fallback), and the small decoration layers (highlight, paths, targets,
|
|
// resources, country names) are cloned into a whole-period offset. The icon
|
|
// layers are NOT cloned: units and cities move every frame, and re-cloning a
|
|
// layer of thousands of views to follow them cost more than the whole rest of
|
|
// the frame. They are folded onto the copy nearest the camera instead (see
|
|
// `_applyEntityWrap`), so each icon is drawn once, over the right ground.
|
|
|
|
_setupWrapCopies() {
|
|
this._wrapCopies = [];
|
|
const layers = [
|
|
// With WebGL the ground repeats in the shader; the DOM fallback must
|
|
// repeat its chunked terrain in cloned containers too.
|
|
...(this.glRenderer
|
|
? []
|
|
: [
|
|
{ source: this.$terrain, interactive: false },
|
|
{ source: this.$roads, interactive: false },
|
|
{ source: this.$borders, interactive: false },
|
|
{ source: this.$fog, interactive: false },
|
|
]),
|
|
{ source: this.$highlight, interactive: false },
|
|
{ source: this.$paths, interactive: false },
|
|
{ source: this.$targets, interactive: false },
|
|
{ source: this.$strikeArcs, interactive: false },
|
|
{ source: this.$targeting, interactive: false },
|
|
{ source: this.$resources, interactive: false },
|
|
{ source: this.$politicalLabels, interactive: false },
|
|
];
|
|
for (const { source, interactive } of layers) {
|
|
if (!source || !source.length) continue;
|
|
const copies = [];
|
|
for (let i = 1; i < WRAP_COPIES; i++) {
|
|
const el = document.createElement("div");
|
|
el.className = source[0].className;
|
|
el.style.display = "none";
|
|
if (!interactive) el.style.pointerEvents = "none";
|
|
source[0].after(el);
|
|
copies.push(el);
|
|
}
|
|
this._wrapCopies.push({ source, copies, interactive });
|
|
}
|
|
// The extra copies are cloned from the source layers, but only when the
|
|
// content they mirror actually changed. An observer on the sources lets
|
|
// `_syncWrapCopies` skip the clone on a quiet frame; without it a zoomed-out
|
|
// cylindrical world re-cloned the whole icon layer (tens of thousands of
|
|
// nodes) every frame, which churned gigabytes of DOM against the collector.
|
|
const Observer = typeof MutationObserver !== "undefined"
|
|
? MutationObserver
|
|
: (typeof window !== "undefined" ? window.MutationObserver : null);
|
|
if (Observer) {
|
|
if (this._wrapObserver) this._wrapObserver.disconnect();
|
|
this._wrapSourceMap = new Map();
|
|
this._wrapDirty = new Set();
|
|
this._wrapObserver = new Observer(() => {});
|
|
for (const entry of this._wrapCopies) {
|
|
const el = entry.source[0];
|
|
this._wrapSourceMap.set(el, entry);
|
|
this._wrapObserver.observe(el, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ["style", "class", "src"],
|
|
characterData: true,
|
|
});
|
|
}
|
|
} else {
|
|
this._wrapObserver = null;
|
|
}
|
|
},
|
|
|
|
// Positions the live layers and mirrors their content into each extra copy.
|
|
// Runs once per frame; when the viewport fits inside one period (the common
|
|
// case on a large map) there are no extra copies and the clones are empty.
|
|
// A copy is only re-cloned when its source layer changed (tracked by the
|
|
// observer set up in `_setupWrapCopies`) or when the period it shows moved.
|
|
_syncWrapCopies() {
|
|
if (!this._wrapCopies || this._wrapCopies.length === 0) return;
|
|
const dirty = this._drainWrapDirty();
|
|
const offsets = this._visibleWrapOffsets();
|
|
const primary = offsets[0];
|
|
const extra = offsets.slice(1);
|
|
for (const entry of this._wrapCopies) {
|
|
const source = entry.source[0];
|
|
this._setWrapTransform(source, primary);
|
|
const entryDirty = dirty === null || dirty.has(entry);
|
|
for (let i = 0; i < entry.copies.length; i++) {
|
|
const copy = entry.copies[i];
|
|
const offset = extra[i];
|
|
if (offset === undefined) {
|
|
if (copy.style.display !== "none") {
|
|
copy.style.display = "none";
|
|
copy.textContent = "";
|
|
copy._wrapBuilt = false;
|
|
copy._wrapOffset = undefined;
|
|
}
|
|
continue;
|
|
}
|
|
copy.style.display = "";
|
|
this._setWrapTransform(copy, offset);
|
|
if (entryDirty || !copy._wrapBuilt || copy._wrapOffset !== offset) {
|
|
this._mirrorLayer(source, copy);
|
|
copy._wrapBuilt = true;
|
|
copy._wrapOffset = offset;
|
|
}
|
|
}
|
|
}
|
|
if (this._wrapDirty) this._wrapDirty.clear();
|
|
},
|
|
|
|
// Drains the mutations the observer queued since the last clone and maps each
|
|
// record back to the source layer it touched, so only the layers that really
|
|
// changed are re-cloned. Returns null when there is no observer, which makes
|
|
// the caller fall back to rebuilding every time (the previous behaviour).
|
|
_drainWrapDirty() {
|
|
if (!this._wrapObserver) return null;
|
|
const records = this._wrapObserver.takeRecords();
|
|
if (records.length === 0) return this._wrapDirty;
|
|
for (const record of records) {
|
|
// The wrap transform we put on the source container itself is our own
|
|
// bookkeeping, not a content change, so it must not schedule a re-clone.
|
|
if (record.type === "attributes" && record.attributeName === "style" &&
|
|
this._wrapSourceMap.has(record.target)) {
|
|
continue;
|
|
}
|
|
let node = record.target;
|
|
while (node && !this._wrapSourceMap.has(node)) node = node.parentNode;
|
|
if (node) {
|
|
this._wrapDirty.add(this._wrapSourceMap.get(node));
|
|
} else {
|
|
// A record from a node no longer under any source: rebuild them all.
|
|
for (const entry of this._wrapCopies) this._wrapDirty.add(entry);
|
|
}
|
|
}
|
|
return this._wrapDirty;
|
|
},
|
|
|
|
// The horizontal offset is applied with a transform so panning never costs a
|
|
// layout pass. Tilted layers keep their ground foreshortening in the same
|
|
// transform, since an inline transform would otherwise drop the class rule.
|
|
// The value is cached on the element: the layer transform changes far less
|
|
// often than the frame rate, and writing it re-dirties style on a container
|
|
// whose subtree the wrap observer watches.
|
|
_setWrapTransform(el, offset) {
|
|
const tilt = el.classList.contains("tilted") ? ` scaleY(${CAMERA_TILT})` : "";
|
|
const transform = `translateX(${offset}px)${tilt}`;
|
|
if (el._wrapTransform === transform) return;
|
|
el._wrapTransform = transform;
|
|
el.style.transform = transform;
|
|
},
|
|
|
|
_mirrorLayer(source, copy) {
|
|
const fragment = document.createDocumentFragment();
|
|
for (const node of source.childNodes) fragment.appendChild(node.cloneNode(true));
|
|
copy.textContent = "";
|
|
copy.appendChild(fragment);
|
|
},
|
|
|
|
_computeMaxStepLength() {
|
|
let longest = 0;
|
|
for (const neighbour of this.topology.neighbours(0, 0)) {
|
|
const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour);
|
|
longest = Math.max(longest, Math.hypot(delta.x, delta.y));
|
|
}
|
|
return longest > 0 ? longest : 1.0;
|
|
},
|
|
};
|