Files
Battle-for-Tismo/client/js/map_view/entities.js
T

450 lines
17 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, CAMERA_TILT } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
export const entityMethods = {
setCatalogue(civilisations, protoUnits) {
this.civilisations = civilisations;
this.protoUnits = protoUnits;
if (this.political) this._refreshMapColours();
},
applySnapshot(state) {
this.ensureTerrain(state.seed, state.mapConfig || null);
this.setRoads(state.roads || []);
this.localCiv = state.viewer;
this._applyTerritory(state.territory);
this._applyRegions(state.regions || []);
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);
// A new world or snapshot may have arrived while a non-terrain mode was
// active, so recompute the effective fills and rebuild them.
this._syncModeState();
if (this.political || this.economic) this._refreshMapColours();
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);
if (this.political) this._refreshMapColours();
},
// 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) {
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;
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();
},
_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;
// 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`;
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 = `${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";
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);
}
},
};