// Turning a server snapshot into DOM: territory borders, fog, cities and units, // plus the stack and battle layout of unit markers. import { key, mapToLocal, parseKey } from "../../../shared/hex.js"; import { statusById } from "../../../shared/data.js"; import { tileImprovementById } from "../../../shared/data/improvements.js"; import { BATTLE_SIDE_OFFSET, CAMERA_TILT } from "./constants.js"; import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js"; import { bootLog } from "../loading.js"; export const entityMethods = { setCatalogue(civilisations, protoUnits) { this.civilisations = civilisations; this.protoUnits = protoUnits; // The game screen rebuilds this catalogue object on every snapshot, so a // signature keeps the political names and colours from being rebuilt each // time even though the values did not change. const signature = (civilisations || []) .map((civ) => `${civ.id}:${civ.name}:${civ.primaryColour}`) .join("|"); if (signature === this._catalogueSignature) return; this._catalogueSignature = signature; if (this.political) this._refreshMapColours(); }, applySnapshot(state) { bootLog.step("Snapshot received from the server"); this.ensureTerrain(state.seed, state.mapConfig || null); this.setImprovements(state.roads || [], state.railways || [], state.versions && state.versions.improvements); this.setWarfare(state.trenches || [], state.tileImprovements || [], state.versions && state.versions.warfare); bootLog.step("Mirrored the transport network"); this.localCiv = state.viewer; this._applyTerritory(state.territory, state.versions && state.versions.territory); bootLog.step("Mirrored the territory and borders"); this._applyRegions(state.regions || [], state.versions && state.versions.regions); this._applyKnowledge(state.explored, state.visible); bootLog.step("Mirrored the fog of war"); this._applyBattles(state.battles); this._syncCities(state.cities, state.training); this._syncUnits(state.units); bootLog.step("Placed the cities and units"); if (!this._centered) this._centerOnCapital(state.cities); // A new world resets the mode flags, so the first snapshot after one // repaints the political/economic surface; later snapshots only do so when // the flags actually change. if (this._syncModeState() && (this.political || this.economic)) { this._repaintMode(); } this.animate(0); }, // Mirrors the battlefields in the snapshot: which tile is being fought over, // and which side defends it. Also keeps one battle marker element per tile. _applyBattles(entries) { this._battles = new Map(); const seen = new Set(); for (const entry of entries || []) { const k = key(entry[0], entry[1]); seen.add(k); this._battles.set(k, { defender: entry[2], attacker: entry[3] }); let marker = this._battleViews.get(k); if (!marker) { marker = document.createElement("div"); marker.className = "battle-marker"; const icon = document.createElement("img"); icon.src = "assets/icon_battle.svg"; icon.alt = "Battle"; marker.appendChild(icon); this.$entities[0].appendChild(marker); this._battleViews.set(k, marker); } const local = mapToLocal(entry[0], entry[1]); marker._localX = local.x; marker._localY = local.y; } for (const [k, marker] of Array.from(this._battleViews)) { if (seen.has(k)) continue; marker.remove(); this._battleViews.delete(k); } }, // Mirrors the trenches and military improvements from the snapshot and draws // one marker per tile. A signature keeps the markers from being rebuilt when // nothing moved. setWarfare(trenches, improvements, version = undefined) { const signature = `${version}:${trenches.length}:${improvements.length}`; if (version !== undefined && signature === this._warfareSignature) return; this._warfareSignature = signature; this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1]))); this.tileImprovements = new Map(); for (const [x, y, id, owner, hp, level] of improvements) { this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 }); } this._renderWarfare(); }, _renderWarfare() { const container = this.$entities && this.$entities[0]; if (!container) return; for (const view of this._warfareViews.values()) view.remove(); this._warfareViews.clear(); for (const k of this.trenches) this._addWarfareMarker(container, k, "trench", null); for (const [k, info] of this.tileImprovements) { this._addWarfareMarker(container, k, "improvement", info); } }, _addWarfareMarker(container, k, kind, info) { const coords = parseKey(k); const local = mapToLocal(coords.x, coords.y); const el = document.createElement("div"); el.className = kind === "trench" ? "trench-marker" : "tile-improvement-marker"; if (kind === "improvement") { const proto = tileImprovementById(info.id); const img = document.createElement("img"); img.alt = proto ? proto.name : info.id; img.src = `assets/${proto ? proto.icon : "icon_battle.svg"}`; el.dataset.owner = String(info.owner); el.appendChild(img); } el.style.left = `${local.x + this._wrapShift(local.x)}px`; el.style.top = `${local.y * CAMERA_TILT}px`; container.appendChild(el); this._warfareViews.set(`${kind}:${k}`, el); }, _applyTerritory(entries, version = undefined) { if (version !== undefined) { // A server version is exact and free to compare, unlike hashing the whole // territory on every snapshot. if (version === this._territoryVersion) return; this._territoryVersion = version; } else { const signature = hashEntries(entries); if (signature === this._territorySignature) return; this._territorySignature = signature; } this.territory = new Map(); for (const entry of entries) { this.territory.set(key(entry[0], entry[1]), entry[2]); } this._renderBorders(); if (this.political) { // The country colours and names both follow ownership. this._refreshMapColours(); } else if (this.glRenderer) { this.glRenderer.buildBorders(this); } }, // Mirrors each land tile's region (the city that claims it). The region of a // selected or inspected tile is drawn as a whole, so this only has to refresh // the overlay; nothing is drawn for an unselected map. _applyRegions(entries, version = undefined) { if (version !== undefined) { if (version === this._regionsVersion) return; this._regionsVersion = version; } else { const signature = hashEntries(entries); if (signature === this._regionsSignature) return; this._regionsSignature = signature; } this.regions = new Map(entries.map((entry) => [key(entry[0], entry[1]), entry[2]])); if (this.glRenderer) this.glRenderer.buildRegionOverlay(this); }, // Draws a thick tinted segment along every edge shared with a different owner // (or the open sea). Each segment is // nudged towards its owner's tile by half its width so two neighbours' borders // sit side by side instead of painting over each other on the shared edge. // Only the chunks currently on screen are rebuilt. _renderBorders() { if (!this._chunks) return; for (const rec of this._chunks.values()) this._buildBorders(rec); if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.borderDirty = true; }, _buildBorders(rec) { rec.borderDirty = false; this._recycleLayer(rec.borderEl, this._borderPool); const fragment = document.createDocumentFragment(); for (const k of rec.tileKeys) { const owner = this.territory.get(k); if (owner === undefined) continue; const civ = this.civilisations[owner]; if (!civ) 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 (this.territory.has(nk) && this.territory.get(nk) === owner) continue; const delta = this.topology.pixelDelta(coords, neighbour); const target = { x: localCenter.x + delta.x, y: localCenter.y + delta.y }; const [a, b] = nearestCorners(localCenter, target); const width = 6; const inward = inwardOffset(a, b, localCenter, width / 2); const el = this._acquireHex(this._borderPool); const dx = b.x - a.x; const dy = b.y - a.y; const length = Math.hypot(dx, dy); const angle = Math.atan2(dy, dx); el.className = "border-seg"; el.style.left = `${a.x}px`; el.style.top = `${a.y - width / 2}px`; el.style.width = `${length}px`; el.style.height = `${width}px`; el.style.background = civ.primaryColour; el.style.transform = inward === 0 ? `rotate(${angle}rad)` : `rotate(${angle}rad) translateY(${inward}px)`; el.style.borderRadius = `${width / 2}px`; fragment.appendChild(el); // A dot at each end of the (inward-nudged) segment fills the gaps that // the rounded, offset edges would otherwise leave at hex corners where // two borders meet. const nx = length > 0 ? -dy / length : 0; const ny = length > 0 ? dx / length : 0; const corner = width * 1.5; for (const point of [a, b]) { const dot = this._acquireHex(this._borderPool); dot.className = "border-corner"; dot.style.left = `${point.x + nx * inward - corner / 2}px`; dot.style.top = `${point.y + ny * inward - corner / 2}px`; dot.style.width = `${corner}px`; dot.style.height = `${corner}px`; dot.style.background = civ.primaryColour; fragment.appendChild(dot); } } } rec.borderEl.appendChild(fragment); }, _applyKnowledge(exploredEntries, visibleEntries) { // `explored` is append-only, so a length change is a cheap exact test for it. // Visibility changes membership without changing size, so it is hashed. Both // matter to fog, so either change rebuilds the sets and the on-screen fog. const visibleSignature = hashEntries(visibleEntries); const changed = exploredEntries.length !== this._exploredCount || visibleSignature !== this._visibleSignature; if (!changed) return; this._exploredCount = exploredEntries.length; this._visibleSignature = visibleSignature; const previousExplored = this.explored; const previousVisible = this.visible; const nextExplored = new Set(exploredEntries.map((e) => key(e[0], e[1]))); const nextVisible = new Set(visibleEntries.map((e) => key(e[0], e[1]))); // The WebGL fog is built per tile, so hand it exactly the tiles whose level // changed instead of letting it rebuild the whole visible band. A tile's // geometry only depends on its own level and its ring's, so the frontier of // a moving unit is all that has to be revisited. let fogChanged = null; if (previousExplored && previousVisible) { fogChanged = new Set(); for (const k of previousVisible) if (!nextVisible.has(k)) fogChanged.add(k); for (const k of nextVisible) if (!previousVisible.has(k)) fogChanged.add(k); for (const k of nextExplored) if (!previousExplored.has(k)) fogChanged.add(k); if (fogChanged.size === 0) fogChanged = null; } this.explored = nextExplored; this.visible = nextVisible; this._refreshFog(fogChanged); }, _syncCities(cities, training = null) { const seen = new Set(); this._cityCiv = new Map(); for (const data of cities) { const id = data.id; seen.add(id); const owned = data.civ === this.localCiv; let view = this._cityViews.get(id); if (!view) { view = this._createCityView(data); this._cityViews.set(id, view); } // A foreign city never shows its production: even if a queue slipped // through, the progress bar stays hidden. this._updateCityProgress(view, owned && training ? training[id] : null); this._syncCityBuildings(view, data); const coords = { x: data.coords[0], y: data.coords[1] }; this._cityCiv.set(key(coords.x, coords.y), data.civ); const local = mapToLocal(coords.x, coords.y); view._localX = local.x; view._localY = local.y; // The sprite layers are not tilted, so a city stands on the foreshortened // ground position rather than the raw world Y. const wrappedX = local.x + this._wrapShift(local.x); const groundY = local.y * CAMERA_TILT; view.style.left = `${wrappedX}px`; view.style.top = `${groundY}px`; view.dataset.cityId = String(id); view.dataset.civ = String(data.civ); const label = view._label; label.textContent = data.name; label.classList.toggle("capital", !!data.isCapital); label.style.left = `${wrappedX}px`; label.style.top = `${groundY}px`; const marker = view.querySelector(".marker"); marker.style.background = this.civilisations[data.civ] ? this.civilisations[data.civ].primaryColour : "#444"; marker.style.cursor = owned ? "pointer" : "default"; view.dataset.owned = owned ? "1" : "0"; this._syncStatusIcons(view.querySelector(".city-statuses"), data.statuses || []); // Foreign cities are only shown when their tile has been explored. const k = key(coords.x, coords.y); const shown = owned || this.explored.has(k); view.style.display = shown ? "" : "none"; label.style.display = shown ? "" : "none"; } for (const [id, view] of Array.from(this._cityViews)) { if (seen.has(id)) continue; view.remove(); if (view._label) view._label.remove(); this._cityViews.delete(id); } }, _createCityView(data) { const view = document.createElement("div"); view.className = "entity city"; const marker = document.createElement("div"); marker.className = "marker"; marker.style.backgroundImage = "url(assets/icon_modern_city.svg)"; const statuses = document.createElement("div"); statuses.className = "city-statuses"; // One small square per building level, scattered around the icon. const buildings = document.createElement("div"); buildings.className = "city-buildings"; // A discreet bar under the city name showing the current build or training. const progress = document.createElement("div"); progress.className = "city-progress"; const fill = document.createElement("div"); fill.className = "fill"; progress.appendChild(fill); progress.style.display = "none"; view.appendChild(marker); view.appendChild(buildings); view.appendChild(progress); view.appendChild(statuses); marker.addEventListener("click", (event) => { event.stopPropagation(); if (view.dataset.owned === "1") this.onCitySelected(data.id); }); marker.addEventListener("dblclick", (event) => { event.stopPropagation(); if (view.dataset.owned === "1") this.onCityOpened(data.id); }); this.$entities[0].appendChild(view); const label = document.createElement("div"); label.className = "city-label"; this.$labels[0].appendChild(label); view._label = label; return view; }, // The little progress bar under a city's name: the first queued unit or // building and how far along it is. Hidden while the city is idle. _updateCityProgress(view, queue) { const bar = view.querySelector(".city-progress"); if (!bar) return; const entry = queue && queue[0]; if (!entry) { bar.style.display = "none"; return; } const total = Math.max(entry.totalHours || 0, 0.0001); const elapsed = entry.elapsedHours || 0; bar.style.display = "block"; bar.firstChild.style.width = `${Math.max(0, Math.min(100, (elapsed / total) * 100))}%`; bar.classList.toggle("building", entry.kind === "building"); }, // One small square per building level around the city icon. The scatter is a // hash of the map seed, the city and the square's index, so the same city // always grows the same recognisable pattern. _syncCityBuildings(view, data) { const container = view.querySelector(".city-buildings"); if (!container) return; const total = Object.values(data.buildings || {}).reduce( (sum, level) => sum + (level || 0), 0 ); if (container._count === total) return; container._count = total; container.textContent = ""; for (let i = 0; i < total; i++) { const square = document.createElement("div"); square.className = "city-building-square"; const angle = this._cityHash(data.id, i, 0) * Math.PI * 2; const radius = 0.55 + this._cityHash(data.id, i, 1) * 0.4; square.style.left = `${50 + Math.cos(angle) * radius * 100}%`; square.style.top = `${50 + Math.sin(angle) * radius * 100}%`; container.appendChild(square); } }, // A stable 0..1 pseudo-random value from the seed, a city id and an index. _cityHash(id, index, salt) { let h = (this.seed | 0) ^ Math.imul(id, 2654435761) ^ Math.imul(index + 1, 40503) ^ Math.imul(salt + 1, 2246822519); h = Math.imul(h ^ (h >>> 15), 2246822519); h = Math.imul(h ^ (h >>> 13), 3266489917); h ^= h >>> 16; return (h >>> 0) / 4294967296; }, _syncUnits(units) { const seen = new Set(); // Store every unit's authoritative data first, so motion prediction can // see where all of them are before any single one is advanced. for (const data of units) { seen.add(data.id); this._unitData.set(data.id, data); } // One bucket per tile for the whole snapshot: truncation tests every step // of every route against the units ahead, and scanning the whole army for // each of those steps made a large marching stack quadratic. const occupancy = this._buildOccupancy(0); for (const data of units) { const id = data.id; let view = this._unitViews.get(id); if (!view) { view = this._createUnitView(data); this._unitViews.set(id, view); } const proto = this.protoUnits[data.proto]; const civ = this.civilisations[data.civ]; view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666"; view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png"; const flag = view.querySelector(".unit-flag"); if (flag) { if (civ && civ.flag) { flag.src = `assets/flags/${civ.flag}`; flag.style.display = ""; } else { flag.style.display = "none"; } } view.querySelector(".health .fill").style.width = `${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`; view.classList.toggle("selected", this._selectedUnitIds.has(id)); this._syncUnitStatuses(view, data); this._updateUnitMotion(data, occupancy); // Foreign units are only shown inside currently visible tiles. const k = key(data.coords[0], data.coords[1]); view.style.display = data.civ === this.localCiv || this.visible.has(k) ? "" : "none"; } for (const [id, view] of Array.from(this._unitViews)) { if (seen.has(id)) continue; view.remove(); this._unitViews.delete(id); this._unitMotion.delete(id); this._unitData.delete(id); } this._assignStackOffsets(); }, // Spreads units sharing a tile around a small circle so every member of a // stack stays visible and clickable. On a battlefield the two stacks are // drawn half-size, pushed to opposite sides of the tile with the battle icon // between them. // Stacks are drawn as a single icon: the first unit's view stays in place // and carries a badge with the stack size, while the rest are hidden but kept // for hit-testing. On a battlefield the two stacks are drawn half-size, // pushed to opposite sides of the tile with the battle icon between them. _assignStackOffsets() { for (const view of this._unitViews.values()) { view.style.visibility = ""; this._setStackCount(view, 0); } const byTile = new Map(); for (const [id, motion] of this._unitMotion) { const tile = this._currentTile(motion); const k = key(tile.x, tile.y); if (!byTile.has(k)) byTile.set(k, []); byTile.get(k).push(id); } for (const [k, ids] of byTile) { const battle = this._battles.get(k); if (battle) { this._assignBattleOffsets(ids, battle); continue; } ids.forEach((id, index) => { const motion = this._unitMotion.get(id); if (motion) motion.offset = { x: 0, y: 0 }; const view = this._unitViews.get(id); if (!view) return; view.classList.toggle("in-battle", false); const leader = index === 0; this._setStackCount(view, leader ? ids.length : 0); view.style.visibility = leader ? "" : "hidden"; }); } }, // Writes the stack size into the badge on a unit icon, hiding it for 0 or 1. _setStackCount(view, count) { if (!view) return; const badge = view.querySelector(".unit-count"); if (!badge) return; if (count > 1) { badge.textContent = String(count); badge.style.display = "block"; } else { badge.textContent = ""; badge.style.display = "none"; } }, _assignBattleOffsets(ids, battle) { const sides = new Map(); for (const id of ids) { const data = this._unitData.get(id); const side = data && data.civ === battle.attacker ? "attacker" : "defender"; if (!sides.has(side)) sides.set(side, []); sides.get(side).push(id); } for (const [side, members] of sides) { const sign = side === "attacker" ? 1 : -1; members.forEach((id, index) => { const motion = this._unitMotion.get(id); if (!motion) return; // A whole side is drawn as one icon with a count badge; the rest stay // hidden but are kept for hit-testing, so a 100-unit battle is two // markers instead of a column taller than the screen. motion.offset = { x: sign * BATTLE_SIDE_OFFSET, y: 0 }; const view = this._unitViews.get(id); if (!view) return; view.classList.toggle("in-battle", true); view.dataset.battleSide = side; const leader = index === 0; this._setStackCount(view, leader ? members.length : 0); view.style.visibility = leader ? "" : "hidden"; }); } }, _currentTile(motion) { if (motion.path.length >= 2) { const index = pathProgress(motion).index; // A unit that has been ordered off a tile no longer belongs to that // tile's stack: it is grouped with the tile it is heading to, so the // stack splits the moment one of its members marches away. return motion.path[Math.min(index + 1, motion.path.length - 1)]; } return motion.coords; }, _createUnitView(data) { const view = document.createElement("div"); view.className = "entity unit"; const body = document.createElement("div"); body.className = "body"; body.style.background = this.civilisations[data.civ] ? this.civilisations[data.civ].primaryColour : "#666"; const img = document.createElement("img"); img.alt = ""; body.appendChild(img); const health = document.createElement("div"); health.className = "health"; const fill = document.createElement("div"); fill.className = "fill"; health.appendChild(fill); const count = document.createElement("div"); count.className = "unit-count"; count.style.display = "none"; const statuses = document.createElement("div"); statuses.className = "unit-statuses"; const flag = document.createElement("img"); flag.className = "unit-flag"; flag.alt = ""; view.appendChild(flag); view.appendChild(body); view.appendChild(health); view.appendChild(count); view.appendChild(statuses); view.addEventListener("mousedown", (event) => { // A left click on the icon picks the unit and stops there so the map does // not re-handle it. Right-clicks fall through to the viewport so the // selected stack can be ordered onto this tile (move, stack or attack). if (event.button !== 0) return; event.stopPropagation(); this.onUnitSelected(data.id); }); view.addEventListener("dblclick", (event) => { // A unit standing on one of our cities yields to it: double-clicking the // unit opens the city, exactly as double-clicking the city icon would. const current = this._unitData.get(data.id) || data; const city = this.cityAt({ x: current.coords[0], y: current.coords[1] }); if (!city) return; event.stopPropagation(); this.onCityOpened(city.id); }); this.$entities[0].appendChild(view); return view; }, // Draws one small icon per active status over the unit. The layout is driven // by the snapshot's status ids and the shared catalogue, so a new status // needs only a catalogue entry and an icon file. _syncUnitStatuses(view, data) { this._syncStatusIcons(view.querySelector(".unit-statuses"), data.statuses || []); }, _syncStatusIcons(container, ids) { if (!container) return; const signature = ids.join(","); if (container._statusSignature === signature) return; container._statusSignature = signature; container.textContent = ""; for (const id of ids) { const def = statusById(id); if (!def) continue; const icon = document.createElement("img"); icon.className = "status-icon"; icon.src = `assets/${def.icon}`; icon.alt = def.label; icon.title = def.description || def.label; container.appendChild(icon); } }, };