487 lines
17 KiB
JavaScript
487 lines
17 KiB
JavaScript
// Terrain construction, world-space bounds, the coarse overview used at low
|
|
// zoom, 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 { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT } from "./constants.js";
|
|
import { overviewColour, blendColours } from "./utils.js";
|
|
|
|
export const terrainMethods = {
|
|
ensureTerrain(seed) {
|
|
if (seed === this.seed) return;
|
|
this.seed = seed;
|
|
this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical");
|
|
const generator = new MapGenerator(MAP_CONFIG, this.topology);
|
|
generator.generate(seed);
|
|
this.tiles = generator.tiles;
|
|
this.terrainStats = new TerrainStats(this.tiles);
|
|
this._maxStepLength = this._computeMaxStepLength();
|
|
this._period = this.topology.periodPixels();
|
|
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.$borders.empty();
|
|
this.$fog.empty();
|
|
this.$terrain.addClass("detail-layer");
|
|
this.$borders.addClass("detail-layer");
|
|
this.$fog.addClass("detail-layer");
|
|
|
|
this._territorySignature = "";
|
|
this._exploredCount = -1;
|
|
this._visibleSignature = null;
|
|
this._chunkDirty = true;
|
|
this._chunkView = { x: null, y: null, zoom: null };
|
|
|
|
this.$entities.empty();
|
|
this.$labels.empty();
|
|
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();
|
|
this._centered = false;
|
|
this._chunkDirty = true;
|
|
// The overview is only built when the player is (or becomes) zoomed out;
|
|
// keeping it out of startup leaves the initial view to the visible chunks.
|
|
if (this.lod) this._drawOverview(true);
|
|
this._syncChunks();
|
|
},
|
|
|
|
// Pixel bounds of the whole world, used to lay out the overview and clamp the
|
|
// camera when the simplified map is on screen.
|
|
_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();
|
|
this.$terrain[0].appendChild(terrain);
|
|
this.$fog[0].appendChild(fog);
|
|
this.$borders[0].appendChild(borders);
|
|
return { terrain, fog, borders };
|
|
},
|
|
|
|
_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.$borders[0].appendChild(cached.borderEl);
|
|
this._chunks.set(chunkKey, cached);
|
|
if (cached.fogDirty) this._buildFog(cached);
|
|
if (cached.borderDirty) this._buildBorders(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,
|
|
terrainEl: null,
|
|
fogEl: null,
|
|
borderEl: null,
|
|
};
|
|
const wrappers = this._newChunk();
|
|
rec.terrainEl = wrappers.terrain;
|
|
rec.fogEl = wrappers.fog;
|
|
rec.borderEl = wrappers.borders;
|
|
this._chunks.set(chunkKey, rec);
|
|
this._buildTerrain(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;
|
|
},
|
|
|
|
// 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.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.borderEl, this._borderPool);
|
|
this._releaseWrapper(rec.terrainEl);
|
|
this._releaseWrapper(rec.fogEl);
|
|
this._releaseWrapper(rec.borderEl);
|
|
},
|
|
|
|
// Parks every materialised chunk (used when the overview takes over) so
|
|
// zooming back in is cheap.
|
|
_clearChunks() {
|
|
if (!this._chunks || this._chunks.size === 0) return;
|
|
for (const [chunkKey, rec] of Array.from(this._chunks)) this._detachChunk(chunkKey, rec);
|
|
},
|
|
|
|
_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 || this.lod) 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);
|
|
const halfH = height / (2 * zoom);
|
|
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`;
|
|
div.style.backgroundPosition = `${-tile.col * HEX_W}px 0`;
|
|
fragment.appendChild(div);
|
|
}
|
|
}
|
|
rec.terrainEl.appendChild(fragment);
|
|
},
|
|
|
|
// 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._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;
|
|
},
|
|
|
|
// ------------------------------------------------------------ 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 || this.lod) 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 };
|
|
},
|
|
|
|
// ---------------------------------------------------------------- LOD --
|
|
|
|
// Builds the simplified map as a coarse grid: one flat block per
|
|
// OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred
|
|
// elements replace the detailed chunks, so seeing the entire map stays cheap.
|
|
_drawOverview(force = false) {
|
|
if (!this.$overview || !this.$overview.length || !this.topology) return;
|
|
// While the detailed view is on screen the overview is hidden; keep the
|
|
// redraw for the moment the player zooms out rather than paying for it on
|
|
// every hourly snapshot.
|
|
if (!force && !this.lod) return;
|
|
|
|
const step = OVERVIEW_BLOCK;
|
|
const startX = this.topology.originX;
|
|
const startY = this.topology.originY;
|
|
const endX = startX + this.topology.width;
|
|
const endY = startY + this.topology.height;
|
|
const fragment = document.createDocumentFragment();
|
|
for (let by = startY; by < endY; by += step) {
|
|
for (let bx = startX; bx < endX; bx += step) {
|
|
const block = this._overviewBlock(bx, by, step);
|
|
if (block) fragment.appendChild(block);
|
|
}
|
|
}
|
|
this.$overview.empty()[0].appendChild(fragment);
|
|
},
|
|
|
|
_overviewBlock(bx, by, step) {
|
|
const endX = Math.min(bx + step, this.topology.originX + this.topology.width);
|
|
const endY = Math.min(by + step, this.topology.originY + this.topology.height);
|
|
let minX = Infinity;
|
|
let maxX = -Infinity;
|
|
let minY = Infinity;
|
|
let maxY = -Infinity;
|
|
let representative = null;
|
|
let land = null;
|
|
let owner = -1;
|
|
let visible = false;
|
|
let explored = false;
|
|
for (let y = by; y < endY; y++) {
|
|
for (let x = bx; x < endX; x++) {
|
|
const k = key(x, y);
|
|
const tile = this.tiles[k];
|
|
if (!tile) continue;
|
|
if (!representative) representative = tile;
|
|
if (tile.terrainClass !== "Sea" && !land) land = tile;
|
|
const local = mapToLocal(x, 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);
|
|
const tileOwner = this.territory.get(k);
|
|
if (tileOwner !== undefined && tileOwner >= 0 && this.civilisations[tileOwner]) {
|
|
owner = tileOwner;
|
|
}
|
|
if (this.visible.has(k)) visible = true;
|
|
if (this.explored.has(k)) explored = true;
|
|
}
|
|
}
|
|
if (!representative) return null;
|
|
|
|
let colour = overviewColour(land || representative);
|
|
if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45);
|
|
if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1);
|
|
|
|
const div = document.createElement("div");
|
|
div.className = "overview-block";
|
|
div.style.left = `${minX}px`;
|
|
div.style.top = `${minY}px`;
|
|
div.style.width = `${maxX - minX}px`;
|
|
div.style.height = `${maxY - minY}px`;
|
|
div.style.background = colour;
|
|
return div;
|
|
},
|
|
|
|
_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;
|
|
},
|
|
};
|