Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve. That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone. Bundles the in-progress chart sizing for the central bank panel.
688 lines
28 KiB
JavaScript
688 lines
28 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 the market
|
|
// stalled the work, 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.stalled ? 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.stalled) {
|
|
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 + this._wrapShift(local.x)}px`;
|
|
el.style.top = `${local.y * CAMERA_TILT}px`;
|
|
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 + 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);
|
|
}
|
|
},
|
|
};
|