431 lines
16 KiB
JavaScript
431 lines
16 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 { BATTLE_SIDE_OFFSET } from "./constants.js";
|
|
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
|
|
|
|
export const entityMethods = {
|
|
setCatalogue(civilisations, protoUnits) {
|
|
this.civilisations = civilisations;
|
|
this.protoUnits = protoUnits;
|
|
},
|
|
|
|
applySnapshot(state) {
|
|
this.ensureTerrain(state.seed, state.mapConfig || null);
|
|
this.setRoads(state.roads || []);
|
|
this.localCiv = state.viewer;
|
|
this._applyTerritory(state.territory);
|
|
this._applyKnowledge(state.explored, state.visible);
|
|
this._applyBattles(state.battles);
|
|
this._syncCities(state.cities);
|
|
this._syncUnits(state.units);
|
|
if (!this._centered) this._centerOnCapital(state.cities);
|
|
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);
|
|
}
|
|
},
|
|
|
|
_applyTerritory(entries) {
|
|
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.glRenderer) this.glRenderer.buildBorders(this);
|
|
this._drawOverview();
|
|
},
|
|
|
|
// 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;
|
|
this.explored = new Set(exploredEntries.map((e) => key(e[0], e[1])));
|
|
this.visible = new Set(visibleEntries.map((e) => key(e[0], e[1])));
|
|
|
|
this._refreshFog();
|
|
this._drawOverview();
|
|
},
|
|
|
|
_syncCities(cities) {
|
|
const seen = new Set();
|
|
this._cityCiv = new Map();
|
|
for (const data of cities) {
|
|
const id = data.id;
|
|
seen.add(id);
|
|
let view = this._cityViews.get(id);
|
|
if (!view) {
|
|
view = this._createCityView(data);
|
|
this._cityViews.set(id, view);
|
|
}
|
|
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;
|
|
const wrappedX = local.x + this._wrapShift(local.x);
|
|
view.style.left = `${wrappedX}px`;
|
|
view.style.top = `${local.y}px`;
|
|
const owned = data.civ === this.localCiv;
|
|
const label = view._label;
|
|
label.textContent = data.name;
|
|
label.classList.toggle("capital", !!data.isCapital);
|
|
label.style.left = `${wrappedX}px`;
|
|
label.style.top = `${local.y}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";
|
|
view.appendChild(marker);
|
|
view.appendChild(statuses);
|
|
marker.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (view.dataset.owned === "1") this.onCitySelected(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;
|
|
},
|
|
|
|
_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);
|
|
}
|
|
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);
|
|
// 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;
|
|
return motion.path[Math.min(index, 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);
|
|
});
|
|
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);
|
|
}
|
|
},
|
|
};
|