The server sent the whole army on every snapshot and the client re-cloned the whole icon layer whenever a unit moved, so a settled world with a standing army drained the broadcast loop and the frame loop alike. - Ship only the units and city list that changed, keyed by a per-peer fingerprint and a serialised hash, and merge those deltas on the client. - Memoise the battlefield scan on a battle version bumped when a unit joins, leaves or changes side. - Skip the DOM writes for units whose snapshot object is unchanged, and cache the unit-view inner nodes. - Fold units, cities and tile markers onto the nearest world copy instead of mirroring the icon layers, which re-cloned thousands of views every frame; the small decoration layers still mirror, and only on a change. Frame work at full zoom-out with units moving fell from ~190ms to ~6.5ms.
719 lines
29 KiB
JavaScript
719 lines
29 KiB
JavaScript
// 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);
|
|
this.setConstructionSites(state.constructionSites || []);
|
|
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, cash] of improvements) {
|
|
this.tileImprovements.set(key(x, y), {
|
|
id, owner, hp, level: level || 0, cash: cash || 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);
|
|
}
|
|
},
|
|
|
|
// Mirrors the construction sites from the snapshot and draws one marker per
|
|
// tile: a spinner while materials are gathered, a red mark when no supplier
|
|
// can reach the work at all, and a progress bar while it builds.
|
|
setConstructionSites(sites) {
|
|
const flat = [];
|
|
for (const list of sites) for (const site of list || []) flat.push(site);
|
|
const signature = flat
|
|
.map((site) => `${site.coords[0]},${site.coords[1]}:${site.phase}:${(site.unreachable || []).length ? 1 : 0}:${Math.round(site.elapsedHours || 0)}`)
|
|
.join("|");
|
|
if (signature === this._siteSignature) return;
|
|
this._siteSignature = signature;
|
|
this._renderConstructionSites(flat);
|
|
},
|
|
|
|
_renderConstructionSites(sites) {
|
|
const container = this.$entities && this.$entities[0];
|
|
if (!container) return;
|
|
if (!this._siteViews) this._siteViews = new Map();
|
|
for (const view of this._siteViews.values()) view.remove();
|
|
this._siteViews.clear();
|
|
for (const site of sites) {
|
|
const [x, y] = site.coords;
|
|
const local = mapToLocal(x, y);
|
|
const el = document.createElement("div");
|
|
el.className = "construction-marker";
|
|
if (site.phase === "materials") {
|
|
if ((site.unreachable || []).length) {
|
|
el.classList.add("stalled");
|
|
el.textContent = "!";
|
|
} else {
|
|
const spin = document.createElement("div");
|
|
spin.className = "gather-spinner";
|
|
el.appendChild(spin);
|
|
}
|
|
} else {
|
|
const total = Math.max(site.totalHours || 0, 0.0001);
|
|
const pct = Math.max(0, Math.min(100, ((site.elapsedHours || 0) / total) * 100));
|
|
const bar = document.createElement("div");
|
|
bar.className = "construction-marker-bar";
|
|
const fill = document.createElement("div");
|
|
fill.style.width = `${pct}%`;
|
|
bar.appendChild(fill);
|
|
el.appendChild(bar);
|
|
}
|
|
el.style.left = `${local.x}px`;
|
|
el.style.top = `${local.y * CAMERA_TILT}px`;
|
|
el._localX = local.x;
|
|
el._localY = local.y;
|
|
container.appendChild(el);
|
|
this._siteViews.set(`${x},${y}`, el);
|
|
}
|
|
},
|
|
|
|
_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}px`;
|
|
el.style.top = `${local.y * CAMERA_TILT}px`;
|
|
el._localX = local.x;
|
|
el._localY = local.y;
|
|
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. The repeat copies draw it
|
|
// in the neighbouring periods (see `_syncWrapCopies`).
|
|
const groundX = local.x;
|
|
const groundY = local.y * CAMERA_TILT;
|
|
view.style.left = `${groundX}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 = `${groundX}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. The server
|
|
// sends a fresh object only for the units that changed, so a reference
|
|
// comparison tells us which views actually need re-writing; an idle army
|
|
// then costs one dictionary pass instead of thousands of DOM updates.
|
|
const changed = new Set();
|
|
for (const data of units) {
|
|
const id = data.id;
|
|
seen.add(id);
|
|
if (this._unitData.get(id) !== data) changed.add(id);
|
|
this._unitData.set(id, data);
|
|
if (!this._unitViews.has(id)) {
|
|
this._unitViews.set(id, this._createUnitView(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. Only a moving
|
|
// unit reads it, so a quiet broadcast builds nothing.
|
|
const occupancy = changed.size > 0 ? this._buildOccupancy(0) : null;
|
|
for (const data of units) {
|
|
const id = data.id;
|
|
const view = this._unitViews.get(id);
|
|
if (changed.has(id)) {
|
|
const proto = this.protoUnits[data.proto];
|
|
const civ = this.civilisations[data.civ];
|
|
view._body.style.background = civ ? civ.primaryColour : "#666";
|
|
view._bodyImg.src = proto ? `assets/${proto.icon}` : "assets/person.png";
|
|
if (view._flagImg) {
|
|
if (civ && civ.flag) {
|
|
view._flagImg.src = `assets/flags/${civ.flag}`;
|
|
view._flagImg.style.display = "";
|
|
} else {
|
|
view._flagImg.style.display = "none";
|
|
}
|
|
}
|
|
view._healthFill.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);
|
|
}
|
|
// Fog can lift on a unit whose own data did not change, so the cheap
|
|
// visibility test always runs; the DOM write is skipped while it holds.
|
|
const k = key(data.coords[0], data.coords[1]);
|
|
const shown = data.civ === this.localCiv || this.visible.has(k);
|
|
if (view._shown !== shown) {
|
|
view.style.display = shown ? "" : "none";
|
|
view._shown = shown;
|
|
}
|
|
}
|
|
let removed = false;
|
|
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);
|
|
removed = true;
|
|
}
|
|
// The stack badges only move when a unit did, so a quiet broadcast leaves
|
|
// the layout alone rather than re-walking the whole army.
|
|
if (changed.size > 0 || removed) 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()) {
|
|
if (view.style.visibility !== "") 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;
|
|
if (view._count === count) return;
|
|
view._count = count;
|
|
const badge = view._countEl || 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);
|
|
// Cache the inner nodes the per-snapshot sync writes to, so a moving army
|
|
// is not re-queried with six `querySelector` calls per unit per broadcast.
|
|
view._body = body;
|
|
view._bodyImg = img;
|
|
view._healthFill = fill;
|
|
view._flagImg = flag;
|
|
view._statusesEl = statuses;
|
|
view._countEl = count;
|
|
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._statusesEl || 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);
|
|
}
|
|
},
|
|
};
|