Files
Battle-for-Tismo/client/js/map_view/terrain.js
T

542 lines
20 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 } 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 road network; drop the old one (and the cache
// signature) before the chunks are rebuilt, so setRoads always installs the
// new tiles.
this.roads = new Set();
this._roadsSignature = 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._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._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);
},
_refreshFog() {
if (this.glRenderer) {
this.glRenderer.buildKnowledge(this);
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;
},
// Installs the road network once. Roads never change once generated, so a
// length check is enough to skip rebuilding the on-screen chunks.
setRoads(entries) {
const list = entries || [];
const signature = String(list.length);
if (this._roadsSignature === signature) return;
this._roadsSignature = signature;
this.roads = new Set(list.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 a brown line from the centre of every road tile to the centre of each
// of its road neighbours. The work is paid per visible chunk, so panning over
// a huge network only ever builds the few hundred tiles on screen.
_buildRoads(rec) {
rec.roadsDirty = false;
this._recycleLayer(rec.roadsEl, this._roadPool);
if (!this.roads || this.roads.size === 0) return;
const width = 5;
const fragment = document.createDocumentFragment();
for (const k of rec.tileKeys) {
if (!this.roads.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)) {
if (!this.roads.has(key(neighbour.x, neighbour.y))) 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 el = this._acquireHex(this._roadPool);
el.className = "road-seg";
el.style.left = `${localCenter.x}px`;
el.style.top = `${localCenter.y - width / 2}px`;
el.style.width = `${length}px`;
el.style.height = `${width}px`;
el.style.transform = `rotate(${angle}rad)`;
fragment.appendChild(el);
}
}
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 };
},
_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;
},
};