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

1248 lines
43 KiB
JavaScript

// The world map view. It owns no game logic: the server generates and
// simulates the world, and this class rebuilds the terrain once from the shared
// seed and mirrors units, cities, territory and fog from each snapshot, then
// turns pointer input into selection and move orders. All rendering is done
// with ordinary DOM elements, as required.
import { MapTopology, mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../shared/hex.js";
import { MapGenerator } from "../../shared/map_generator.js";
import { TerrainStats } from "../../shared/terrain_stats.js";
import { HexPathfinder } from "../../shared/hex_pathfinder.js";
import { MAP_CONFIG, protoUnitById, statusById } from "../../shared/data.js";
const RESYNC_HOURS = 0.75;
const PREDICTION_GRACE_HOURS = 2.0;
const DRAG_THRESHOLD = 5;
const ZOOM_MIN = 0.2;
const ZOOM_MAX = 2.0;
// The zoomed-out overview groups this many tiles per side into one block, so a
// full world is a few hundred flat elements instead of tens of thousands of
// textured ones.
const OVERVIEW_BLOCK = 4;
// Flat colours used by the simplified overview, keyed by terrain type. The
// full-resolution view keeps the textured hexes; this is only the LOD stand-in.
const OVERVIEW_COLOURS = {
Forest: "#2f6b3a",
Hills: "#7a6a44",
Mountain: "#7d7469",
Ice: "#dcecf5",
Land: "#4f8a4f",
Sea: "#1b3f6b",
};
const OVERVIEW_SEA = "#1b3f6b";
const OVERVIEW_FOG = "#04040a";
export class MapView {
constructor($viewport, $world, layers) {
this.$viewport = $viewport;
this.$world = $world;
this.$terrain = layers.terrain;
this.$territory = layers.territory;
this.$borders = layers.borders;
this.$fog = layers.fog;
this.$paths = layers.paths;
this.$entities = layers.entities;
// The simplified map used when zoomed out. It is created on demand so
// callers (and tests) that predate it keep working unchanged.
this.$overview = layers.overview && layers.overview.length
? layers.overview
: $("#layer-overview");
if (!this.$overview.length) {
this.$overview = $('<div id="layer-overview" class="layer overview"></div>');
this.$world.prepend(this.$overview);
}
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
this.$labels = layers.labels && layers.labels.length
? layers.labels
: $("#layer-labels");
if (!this.$labels.length) {
this.$labels = $('<div id="layer-labels" class="layer city-label-layer"></div>');
this.$world.append(this.$labels);
}
this.seed = -1;
this.topology = null;
this.tiles = {};
this.terrainStats = null;
this.explored = new Set();
this.visible = new Set();
this.territory = new Map();
this.civilisations = [];
this.localCiv = -1;
this.protoUnits = [];
this.camera = { x: 0, y: 0, zoom: 1.0 };
this._centered = false;
this._selectedUnitId = 0;
this._selectedTile = null;
this._unitViews = new Map();
this._unitMotion = new Map();
this._unitData = new Map();
this._cityViews = new Map();
this._cityCiv = new Map();
this._predictNeighbourCache = new Map();
this._maxStepLength = 1.0;
this._territorySignature = "";
this._fogSignature = "";
// Cylindrical wrap state. `_period` is the horizontal pixel period (0 on a
// flat map); every static element remembers its unwrapped position and is
// shifted by a whole period so it stays next to the camera.
this._period = 0;
this._worldWidth = 0;
this._worldHeight = 0;
this._worldMinX = 0;
this._worldMinY = 0;
this._terrainWrap = [];
this._territoryWrap = [];
this._borderWrap = [];
this._fogWrap = [];
this._wrapCameraX = null;
this.lod = false;
this.onUnitSelected = () => {};
this.onCitySelected = () => {};
this.onTileRequested = () => {};
this.onMoveOrdered = () => {};
this._setupCameraInput();
}
// ------------------------------------------------------------- terrain --
ensureTerrain(seed) {
if (seed === this.seed) return;
this.seed = seed;
this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical");
const generator = new MapGenerator(MAP_CONFIG, this.topology);
generator.generate(seed);
this.tiles = generator.tiles;
this.terrainStats = new TerrainStats(this.tiles);
this._maxStepLength = this._computeMaxStepLength();
this._period = this.topology.periodPixels();
this._computeWorldBounds();
this._terrainWrap = [];
const fragment = document.createDocumentFragment();
for (const k in this.tiles) {
const coords = parseKey(k);
const tile = this.tiles[k];
const local = mapToLocal(coords.x, coords.y);
const div = document.createElement("div");
div.className = "hex terrain detail-layer";
div.style.left = `${local.x - HEX_W / 2}px`;
div.style.top = `${local.y - HEX_H / 2}px`;
div.style.backgroundPosition = `${-tile.col * HEX_W}px 0`;
fragment.appendChild(div);
this._terrainWrap.push({
el: div,
centerX: local.x,
shift: null,
baseTransform: "",
});
}
this.$terrain.empty()[0].appendChild(fragment);
this.$terrain.addClass("detail-layer");
this._territorySignature = "";
this._fogSignature = "";
this._territoryWrap = [];
this._borderWrap = [];
this._fogWrap = [];
this._wrapCameraX = null;
this.$territory.empty();
this.$borders.empty();
this.$fog.empty();
this.$entities.empty();
this.$labels.empty();
this._unitViews.clear();
this._unitMotion.clear();
this._unitData.clear();
this._cityViews.clear();
this._cityCiv.clear();
this._centered = false;
this._drawOverview(true);
this._applyWrap(true);
}
// Pixel bounds of the whole world, used to lay out the overview and clamp the
// camera when the simplified map is on screen.
_computeWorldBounds() {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const k in this.tiles) {
const coords = parseKey(k);
const local = mapToLocal(coords.x, coords.y);
minX = Math.min(minX, local.x - HEX_W / 2);
maxX = Math.max(maxX, local.x + HEX_W / 2);
minY = Math.min(minY, local.y - HEX_H / 2);
maxY = Math.max(maxY, local.y + HEX_H / 2);
}
if (!isFinite(minX)) {
minX = 0;
maxX = 0;
minY = 0;
maxY = 0;
}
this._worldMinX = minX;
this._worldMinY = minY;
this._worldWidth = maxX - minX;
this._worldHeight = maxY - minY;
}
getTile(coords) {
return this.tiles[key(coords.x, coords.y)] || null;
}
// ------------------------------------------------------------ snapshot --
setCatalogue(civilisations, protoUnits) {
this.civilisations = civilisations;
this.protoUnits = protoUnits;
}
applySnapshot(state) {
this.ensureTerrain(state.seed);
this.localCiv = state.viewer;
this._applyTerritory(state.territory);
this._applyKnowledge(state.explored, state.visible);
this._syncCities(state.cities);
this._syncUnits(state.units);
if (!this._centered) this._centerOnCapital(state.cities);
this.animate(0);
}
_applyTerritory(entries) {
const signature = hashEntries(entries);
if (signature === this._territorySignature) return;
this._territorySignature = signature;
this.territory = new Map();
this._territoryWrap = [];
const territoryFragment = document.createDocumentFragment();
for (const entry of entries) {
const x = entry[0];
const y = entry[1];
const owner = entry[2];
this.territory.set(key(x, y), owner);
if (owner < 0 || !this.civilisations[owner]) continue;
const local = mapToLocal(x, y);
const div = document.createElement("div");
div.className = "hex territory detail-layer";
div.style.left = `${local.x - HEX_W / 2}px`;
div.style.top = `${local.y - HEX_H / 2}px`;
div.style.background = this.civilisations[owner].primaryColour;
div.style.opacity = "0.28";
territoryFragment.appendChild(div);
this._territoryWrap.push({
el: div,
centerX: local.x,
shift: null,
baseTransform: "",
});
}
this.$territory.empty()[0].appendChild(territoryFragment);
this.$territory.addClass("detail-layer");
this._renderBorders();
this._drawOverview();
this._applyWrap(true);
}
// Draws a thick tinted segment along every edge shared with a different owner
// (or the open sea), matching the territory overlay. 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.
_renderBorders() {
const fragment = document.createDocumentFragment();
this._borderWrap = [];
for (const [k, owner] of this.territory) {
const civ = this.civilisations[owner];
if (!civ) continue;
const coords = parseKey(k);
const center = mapToLocal(coords.x, coords.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: center.x + delta.x, y: center.y + delta.y };
const [a, b] = nearestCorners(center, target);
const width = 6;
const inward = inwardOffset(a, b, center, width / 2);
const el = makeSegment(a, b, civ.primaryColour, width, inward);
fragment.appendChild(el);
this._borderWrap.push({
el,
centerX: center.x,
shift: null,
baseTransform: el.style.transform,
});
}
}
this.$borders.empty()[0].appendChild(fragment);
this.$borders.addClass("detail-layer");
}
_applyKnowledge(exploredEntries, visibleEntries) {
const explored = new Set(exploredEntries.map((e) => key(e[0], e[1])));
const visible = new Set(visibleEntries.map((e) => key(e[0], e[1])));
const signature = `e${hashEntries(exploredEntries)}v${hashEntries(visibleEntries)}`;
this.explored = explored;
this.visible = visible;
if (signature === this._fogSignature) return;
this._fogSignature = signature;
this._fogWrap = [];
const fragment = document.createDocumentFragment();
for (const k in this.tiles) {
if (visible.has(k)) continue;
const coords = parseKey(k);
const local = mapToLocal(coords.x, coords.y);
const div = document.createElement("div");
div.className = "hex fog detail-layer " + (explored.has(k) ? "fog-explored" : "fog-unexplored");
div.style.left = `${local.x - HEX_W / 2}px`;
div.style.top = `${local.y - HEX_H / 2}px`;
fragment.appendChild(div);
this._fogWrap.push({
el: div,
centerX: local.x,
shift: null,
baseTransform: "",
});
}
this.$fog.empty()[0].appendChild(fragment);
this.$fog.addClass("detail-layer");
this._drawOverview();
this._applyWrap(true);
}
_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.isCapital ? `${data.name} (capital)` : data.name;
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();
for (const data of units) {
const id = data.id;
seen.add(id);
this._unitData.set(id, data);
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";
view.querySelector(".health .fill").style.width =
`${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`;
view.classList.toggle("selected", id === this._selectedUnitId);
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);
}
}
_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 statuses = document.createElement("div");
statuses.className = "unit-statuses";
view.appendChild(body);
view.appendChild(health);
view.appendChild(statuses);
view.addEventListener("mousedown", (event) => {
event.stopPropagation();
if (event.button === 0) 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);
}
}
// ------------------------------------------------------------- motion ---
_updateUnitMotion(data) {
const id = data.id;
const path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
const segmentHours = data.segmentHours || [];
const existing = this._unitMotion.get(id);
if (path.length < 2 || segmentHours.length === 0) {
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
return;
}
this._unitMotion.set(id, {
path: [],
segmentHours: [],
pathHours: 0,
coords: { x: data.coords[0], y: data.coords[1] },
predicting: false,
predictAge: 0,
});
return;
}
const authoritative = hoursAlong(data, segmentHours);
let pathHours = authoritative;
if (existing && samePath(existing.path, path)) {
pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS
? authoritative
: existing.pathHours;
}
this._unitMotion.set(id, {
path,
segmentHours,
pathHours,
coords: { x: data.coords[0], y: data.coords[1] },
predicting: false,
predictAge: 0,
});
}
animate(dtHours) {
for (const [, motion] of this._unitMotion) {
if (motion.path.length < 2) continue;
motion.pathHours += dtHours;
if (motion.predicting) motion.predictAge += dtHours;
}
this._applyWrap(false);
this._applyEntityWrap();
this._drawPaths();
}
// Repositions every unit and city at the wrapped copy nearest the camera.
_applyEntityWrap() {
for (const [id, motion] of this._unitMotion) {
const view = this._unitViews.get(id);
if (!view) continue;
const position = motion.path.length >= 2
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
const wrapped = this._wrappedPosition(position);
view.style.left = `${wrapped.x}px`;
view.style.top = `${wrapped.y}px`;
}
for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue;
const left = view._localX + this._wrapShift(view._localX);
view.style.left = `${left}px`;
view.style.top = `${view._localY}px`;
if (view._label) {
view._label.style.left = `${left}px`;
view._label.style.top = `${view._localY}px`;
}
}
}
_positionAlongPath(motion) {
const path = motion.path;
const segmentHours = motion.segmentHours;
let remaining = motion.pathHours;
let index = 0;
while (index < segmentHours.length) {
const step = segmentHours[index];
if (step > 0 && remaining < step) break;
remaining -= step;
index += 1;
}
if (index >= segmentHours.length) {
index = segmentHours.length - 1;
if (index < 0) {
const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y);
return local;
}
remaining = segmentHours[index];
}
const from = path[index];
const to = path[index + 1] || from;
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
const step = segmentHours[index] || 1;
const t = Math.max(0, Math.min(1, remaining / step));
return { x: a.x + delta.x * t, y: a.y + delta.y * t };
}
_drawPaths() {
const fragment = document.createDocumentFragment();
for (const [id, motion] of this._unitMotion) {
if (id !== this._selectedUnitId) continue;
if (motion.path.length < 2) continue;
const index = pathProgress(motion).index;
const base = mapToLocal(motion.path[index].x, motion.path[index].y);
const current = this._positionAlongPath(motion);
let previous = base;
for (let i = index + 1; i < motion.path.length; i++) {
const from = motion.path[i - 1];
const to = motion.path[i];
const delta = this.topology.pixelDelta(from, to);
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
const start = i === index + 1 ? current : previous;
const a = this._wrappedPosition(start);
const b = this._wrappedPosition(next);
fragment.appendChild(makeSegment(a, b, "#ffffff", 4));
previous = next;
}
}
this.$paths.empty()[0].appendChild(fragment);
}
// -------------------------------------------------------------- wrap ------
// Pixel offset (a whole number of map periods) that moves an element sitting
// at `centerX` to the copy closest to the camera. Choosing the half-open band
// [camera - P/2, camera + P/2) keeps neighbouring tiles contiguous, so the
// world tiles seamlessly in both directions.
_wrapShift(centerX) {
if (!this._period || this.lod) return 0;
const period = this._period;
return Math.ceil((this.camera.x - period / 2 - centerX) / period) * period;
}
_wrappedPosition(point) {
return { x: point.x + this._wrapShift(point.x), y: point.y };
}
// Shifts the static layers (terrain, territory, borders, fog) so they cover
// the visible period. Only elements whose period index changed are touched.
_applyWrap(force = false) {
if (force) this._wrapCameraX = null;
// The detailed layers are hidden on the overview, so their wrapped offsets
// do not need refreshing until the player zooms back in.
if (this.lod) return;
if (this._wrapCameraX === this.camera.x) return;
this._wrapCameraX = this.camera.x;
for (const list of [this._terrainWrap, this._territoryWrap, this._borderWrap, this._fogWrap]) {
for (const item of list) {
const shift = this._wrapShift(item.centerX);
if (shift === item.shift) continue;
item.shift = shift;
// Moving with `transform` keeps this off the layout path, so dragging
// never forces the browser to lay the map out again.
if (item.baseTransform) {
item.el.style.transform = shift
? `translateX(${shift}px) ${item.baseTransform}`
: item.baseTransform;
} else {
item.el.style.transform = shift ? `translateX(${shift}px)` : "";
}
}
}
}
// -------------------------------------------------------------- LOD ------
// Builds the simplified map as a coarse grid: one flat block per
// OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred
// elements replace the thousands of textured hex, fog and border elements, so
// seeing the entire map stays cheap.
_drawOverview(force = false) {
if (!this.$overview || !this.$overview.length || !this.topology) return;
// While the detailed view is on screen the overview is hidden; keep the
// redraw for the moment the player zooms out rather than paying for it on
// every hourly snapshot.
if (!force && !this.lod) return;
const step = OVERVIEW_BLOCK;
const startX = this.topology.originX;
const startY = this.topology.originY;
const endX = startX + this.topology.width;
const endY = startY + this.topology.height;
const fragment = document.createDocumentFragment();
for (let by = startY; by < endY; by += step) {
for (let bx = startX; bx < endX; bx += step) {
const block = this._overviewBlock(bx, by, step);
if (block) fragment.appendChild(block);
}
}
this.$overview.empty()[0].appendChild(fragment);
}
_overviewBlock(bx, by, step) {
const endX = Math.min(bx + step, this.topology.originX + this.topology.width);
const endY = Math.min(by + step, this.topology.originY + this.topology.height);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let representative = null;
let land = null;
let owner = -1;
let visible = false;
let explored = false;
for (let y = by; y < endY; y++) {
for (let x = bx; x < endX; x++) {
const k = key(x, y);
const tile = this.tiles[k];
if (!tile) continue;
if (!representative) representative = tile;
if (tile.terrainClass !== "Sea" && !land) land = tile;
const local = mapToLocal(x, y);
minX = Math.min(minX, local.x - HEX_W / 2);
maxX = Math.max(maxX, local.x + HEX_W / 2);
minY = Math.min(minY, local.y - HEX_H / 2);
maxY = Math.max(maxY, local.y + HEX_H / 2);
const tileOwner = this.territory.get(k);
if (tileOwner !== undefined && tileOwner >= 0 && this.civilisations[tileOwner]) {
owner = tileOwner;
}
if (this.visible.has(k)) visible = true;
if (this.explored.has(k)) explored = true;
}
}
if (!representative) return null;
let colour = overviewColour(land || representative);
if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45);
if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1);
const div = document.createElement("div");
div.className = "overview-block";
div.style.left = `${minX}px`;
div.style.top = `${minY}px`;
div.style.width = `${maxX - minX}px`;
div.style.height = `${maxY - minY}px`;
div.style.background = colour;
return div;
}
// ------------------------------------------------------------- camera ---
centerOn(coords) {
const local = mapToLocal(coords.x, coords.y);
this.camera.x = local.x;
this.camera.y = local.y;
this._applyCamera();
}
_centerOnCapital(cities) {
for (const city of cities) {
if (city.civ === this.localCiv && city.isCapital) {
this.centerOn({ x: city.coords[0], y: city.coords[1] });
this._centered = true;
return;
}
}
}
// `applyWrap` is false while dragging: the per-element wrapped copies are
// refreshed once per animation frame in `animate`, so a high pointer rate
// cannot multiply the work. Panning and zooming from elsewhere apply them
// immediately so the view is always consistent when read.
_applyCamera(applyWrap = true) {
const width = this.$viewport.width();
const height = this.$viewport.height();
this._updateLod(width, height);
if (this.lod) {
// With the whole world on screen there is nothing to scroll to; keep the
// camera over the map so the overview never drifts into the void.
this.camera.x = clamp(this.camera.x, this._worldMinX, this._worldMinX + this._worldWidth);
this.camera.y = clamp(this.camera.y, this._worldMinY, this._worldMinY + this._worldHeight);
}
const { x, y, zoom } = this.camera;
const tx = width / 2 - x * zoom;
const ty = height / 2 - y * zoom;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
if (applyWrap) this._applyWrap(false);
}
// Switches between the textured hex view and the simplified overview. The
// detailed view only makes sense while the visible world stays within one
// horizontal period (so wrapping is seamless); past that -- when the player
// is zoomed out to see the whole map -- the coarse overview takes over.
_updateLod(width, height) {
let detailedWidth = this._worldWidth;
if (this._period > 0) detailedWidth = Math.min(detailedWidth, this._period) - HEX_W;
const detailed = detailedWidth > 0 &&
width / this.camera.zoom <= detailedWidth &&
height / this.camera.zoom <= this._worldHeight + HEX_H;
const lod = !detailed;
if (lod === this.lod) return;
this.lod = lod;
this.$world.toggleClass("lod", lod);
this._wrapCameraX = null;
if (lod) this._drawOverview(true);
}
_setupCameraInput() {
let dragging = false;
let dragged = false;
let pressX = 0;
let pressY = 0;
let lastX = 0;
let lastY = 0;
let pressButton = -1;
this.$viewport.on("mousedown", (event) => {
if (event.button !== 0 && event.button !== 2) return;
dragging = true;
dragged = false;
pressButton = event.button;
pressX = event.clientX;
pressY = event.clientY;
lastX = event.clientX;
lastY = event.clientY;
if (event.button === 0) this.$viewport.addClass("dragging");
});
$(window).on("mousemove", (event) => {
if (!dragging) return;
if (Math.abs(event.clientX - pressX) > DRAG_THRESHOLD ||
Math.abs(event.clientY - pressY) > DRAG_THRESHOLD) {
dragged = true;
}
if (dragged) {
this.camera.x -= (event.clientX - lastX) / this.camera.zoom;
this.camera.y -= (event.clientY - lastY) / this.camera.zoom;
this._applyCamera(false);
}
lastX = event.clientX;
lastY = event.clientY;
});
$(window).on("mouseup", (event) => {
if (!dragging) return;
dragging = false;
this.$viewport.removeClass("dragging");
if (dragged) return;
const coords = this.tileAtScreen(event.clientX, event.clientY);
if (!coords) return;
if (pressButton === 0) this._onLeftClick(coords);
else if (pressButton === 2) this._onRightClick(coords);
});
this.$viewport.on("contextmenu", (event) => event.preventDefault());
this.$viewport.on("wheel", (event) => {
event.preventDefault();
const factor = event.originalEvent.deltaY < 0 ? 1.1 : 1 / 1.1;
this.camera.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.camera.zoom * factor));
this._applyCamera();
});
}
panBy(dx, dy) {
this.camera.x += dx;
this.camera.y += dy;
this._applyCamera();
}
_onLeftClick(coords) {
const unitId = this.unitAt(coords);
if (unitId && this._selectedUnitId !== unitId) {
this.onUnitSelected(unitId);
return;
}
if (unitId) return;
const city = this.cityAt(coords);
if (city) {
this.onCitySelected(city.id);
return;
}
this.clearSelection();
}
_onRightClick(coords) {
if (this._selectedUnitId) this.onMoveOrdered(this._selectedUnitId, coords);
else this.onTileRequested(coords);
}
setSelectedUnit(id) {
this._selectedUnitId = id;
for (const [unitId, view] of this._unitViews) {
view.classList.toggle("selected", unitId === id);
}
}
clearSelection() {
this.setSelectedUnit(0);
this.onUnitSelected(0);
}
unitAt(coords) {
for (const [id, motion] of this._unitMotion) {
const view = this._unitViews.get(id);
if (view && view.style.display === "none") continue;
const position = motion.path.length >= 2
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
const local = this._clickLocal(coords);
if (Math.hypot(position.x - local.x, position.y - local.y) < HEX_W * 0.5) return id;
}
return 0;
}
cityAt(coords) {
for (const [id, view] of this._cityViews) {
if (view.style.display === "none") continue;
const local = this._clickLocal(coords);
const cx = parseFloat(view.style.left);
const cy = parseFloat(view.style.top);
if (Math.hypot(cx - local.x, cy - local.y) < HEX_W * 0.5) return { id };
}
return null;
}
// World position of a clicked tile at the wrapped copy nearest the camera, so
// hit-testing works across the seam.
_clickLocal(coords) {
return this._wrappedPosition(mapToLocal(coords.x, coords.y));
}
tileAtScreen(clientX, clientY) {
const rect = this.$viewport[0].getBoundingClientRect();
const screenX = clientX - rect.left;
const screenY = clientY - rect.top;
const width = this.$viewport.width();
const height = this.$viewport.height();
const worldX = (screenX - (width / 2 - this.camera.x * this.camera.zoom)) / this.camera.zoom;
const worldY = (screenY - (height / 2 - this.camera.y * this.camera.zoom)) / this.camera.zoom;
return this._worldToHex(worldX, worldY);
}
_worldToHex(worldX, worldY) {
const column = Math.round(worldX / COL_STEP);
let best = null;
let bestDistance = Infinity;
for (let dx = -1; dx <= 1; dx++) {
const x = column + dx;
const baseY = Math.round(worldY / HEX_H - 0.5 * parity(x));
for (let dy = -1; dy <= 1; dy++) {
const y = baseY + dy;
const local = mapToLocal(x, y);
const distance = Math.hypot(local.x - worldX, local.y - worldY);
if (distance < bestDistance) {
bestDistance = distance;
best = { x, y };
}
}
}
return best ? this.topology.wrapCoords(best.x, best.y) : null;
}
// -------------------------------------------------------- move preview --
// Mirrors GameState.find_path closely enough that a click feels instant; the
// next snapshot confirms or corrects the route.
predictMove(unitData, goal) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !proto.moveable || !this.topology) return null;
const civ = unitData.civ;
const start = this._unitCoords(unitData.id) || { x: unitData.coords[0], y: unitData.coords[1] };
if (start.x === goal.x && start.y === goal.y) return null;
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
this._predictNeighbourCache.clear();
const path = new HexPathfinder().findPath(
start,
goal,
(coords) => {
const k = key(coords.x, coords.y);
if (!this._predictNeighbourCache.has(k)) {
this._predictNeighbourCache.set(k, this.topology.neighbours(coords.x, coords.y));
}
return this._predictNeighbourCache.get(k);
},
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
if (!enterCache.has(k)) {
enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal));
}
return enterCache.get(k);
},
(_from, to) => {
const k = key(to.x, to.y);
if (!costCache.has(k)) costCache.set(k, this._stepHours(proto, civ, to));
return costCache.get(k);
},
(from, to) => {
const k = key(from.x, from.y);
if (!heuristicCache.has(k)) {
const delta = this.topology.pixelDelta(from, to);
const fastest = this.terrainStats.minMovementCostMultiplier() /
(Math.max(proto.speed, 0.0001) * 1.5);
heuristicCache.set(k, (Math.hypot(delta.x, delta.y) / this._maxStepLength) * fastest);
}
return heuristicCache.get(k);
}
);
if (path.length < 2) return null;
// The server holds a unit as soon as its target is in range and never
// steps onto a living enemy's tile. Mirror that so a ranged unit stops at
// its firing distance and a melee one stops beside its opponent.
const range = Math.max(1, proto.range || 1);
const goalOccupant = this._unitAtCoords(goal, unitData.id);
const goalIsEnemy = !!goalOccupant && goalOccupant.civ !== civ &&
proto.military === true && this._isMilitaryData(goalOccupant);
let route;
if (goalIsEnemy) {
if (this.topology.tileDistance(start, goal) <= range) return null;
const approach = path.slice(0, path.length - 1);
let stop = approach.length - 1;
for (let i = 1; i < approach.length; i++) {
if (this.topology.tileDistance(approach[i], goal) <= range) {
stop = i;
break;
}
}
route = approach.slice(0, stop + 1);
} else {
const block = this._firstOccupiedStep(path, unitData.id);
route = block >= 1 ? path.slice(0, block) : path;
}
if (route.length < 2) return null;
const segmentHours = [];
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, civ, route[i]));
}
this._unitMotion.set(unitData.id, {
path: route,
segmentHours,
pathHours: 0,
coords: start,
predicting: true,
predictAge: 0,
});
this.animate(0);
return route;
}
_isMilitaryData(data) {
const proto = this.protoUnits[data.proto];
return !!(proto && proto.military);
}
_firstOccupiedStep(path, moverId) {
for (let i = 1; i < path.length; i++) {
if (this._unitAtCoords(path[i], moverId)) return i;
}
return -1;
}
_unitAtCoords(coords, ignoreId = 0) {
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
if (tile.x === coords.x && tile.y === coords.y) return data;
}
return null;
}
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
if (motion.path.length >= 2) {
const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)];
}
return motion.coords;
}
// Mirrors GameState._canUnitEnter: a hostile unit or city is only a valid
// destination (`isGoal`), never a tile to pass through.
_canEnter(civ, proto, coords, moverId = 0, isGoal = false) {
const k = key(coords.x, coords.y);
if (!this.explored.has(k)) return false;
const tile = this.tiles[k];
if (!tile) return false;
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
const occupant = this._unitAtCoords(coords, moverId);
if (occupant) {
if (occupant.civ === civ) return false;
if (!isGoal) return false;
const occProto = this.protoUnits[occupant.proto];
if (proto.military !== true || !occProto || occProto.military !== true) return false;
}
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== civ) {
if (!isGoal) return false;
if (proto.military !== true) return false;
}
return true;
}
_stepHours(proto, civ, coords) {
const k = key(coords.x, coords.y);
let speed = Math.max(proto.speed, 0.0001);
if (this.territory.get(k) === civ) speed *= 1.5;
return this.terrainStats.movementCostMultiplier(coords) / speed;
}
_computeMaxStepLength() {
let longest = 0;
for (const neighbour of this.topology.neighbours(0, 0)) {
const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour);
longest = Math.max(longest, Math.hypot(delta.x, delta.y));
}
return longest > 0 ? longest : 1.0;
}
}
// ------------------------------------------------------------------ utils --
function parity(x) {
return x & 1;
}
function parseKey(k) {
const i = k.indexOf(",");
return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
}
// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip
// rebuilding a layer when the snapshot did not actually change it.
function hashEntries(entries) {
let hash = 2166136261;
for (const entry of entries) {
hash = Math.imul(hash ^ (entry[0] + 4096), 16777619);
hash = Math.imul(hash ^ (entry[1] + 4096), 16777619);
hash = Math.imul(hash ^ (entry.length > 2 ? entry[2] + 8 : 0), 16777619);
}
return `${entries.length}:${(hash >>> 0).toString(36)}`;
}
function hoursAlong(data, segmentHours) {
const index = Math.min(data.pathIndex || 0, segmentHours.length);
let total = 0;
for (let i = 0; i < index; i++) total += segmentHours[i];
return total + (data.progressHours || 0);
}
function samePath(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].x !== b[i].x || a[i].y !== b[i].y) return false;
}
return true;
}
function pathProgress(motion) {
let remaining = motion.pathHours;
let index = 0;
while (index < motion.segmentHours.length) {
const step = motion.segmentHours[index];
if (step <= 0) {
index += 1;
continue;
}
if (remaining < step) break;
remaining -= step;
index += 1;
}
return { index, remaining };
}
function nearestCorners(center, target) {
const half = { x: HEX_W / 2, y: HEX_H / 2 };
const corners = [
{ x: half.x, y: 0 },
{ x: half.x * 0.5, y: -half.y },
{ x: -half.x * 0.5, y: -half.y },
{ x: -half.x, y: 0 },
{ x: -half.x * 0.5, y: half.y },
{ x: half.x * 0.5, y: half.y },
];
let first = 0;
let second = 1;
let firstDistance = Infinity;
let secondDistance = Infinity;
for (let i = 0; i < corners.length; i++) {
const point = { x: center.x + corners[i].x, y: center.y + corners[i].y };
const distance = (point.x - target.x) ** 2 + (point.y - target.y) ** 2;
if (distance < firstDistance) {
secondDistance = firstDistance;
second = first;
firstDistance = distance;
first = i;
} else if (distance < secondDistance) {
secondDistance = distance;
second = i;
}
}
return [
{ x: center.x + corners[first].x, y: center.y + corners[first].y },
{ x: center.x + corners[second].x, y: center.y + corners[second].y },
];
}
// Signed distance to nudge an edge segment along its normal so it moves towards
// `center` (the owning tile). Used to keep neighbours' borders side by side
// instead of overlapping on the shared edge.
export function inwardOffset(a, b, center, distance) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const dot = (center.x - midX) * nx + (center.y - midY) * ny;
return dot >= 0 ? distance : -distance;
}
function makeSegment(a, b, colour, width, offset = 0) {
const div = document.createElement("div");
div.className = "border-seg";
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
div.style.left = `${a.x}px`;
div.style.top = `${a.y - width / 2}px`;
div.style.width = `${length}px`;
div.style.height = `${width}px`;
div.style.background = colour;
div.style.transform = offset === 0
? `rotate(${angle}rad)`
: `rotate(${angle}rad) translateY(${offset}px)`;
div.style.borderRadius = `${width / 2}px`;
return div;
}
function overviewColour(tile) {
if (tile.terrainType && OVERVIEW_COLOURS[tile.terrainType]) {
return OVERVIEW_COLOURS[tile.terrainType];
}
return tile.terrainClass === "Sea" ? OVERVIEW_SEA : OVERVIEW_COLOURS.Land;
}
// Mixes two #rrggbb colours, `t` being how much of `b` to use.
function blendColours(a, b, t) {
const ca = parseColour(a);
const cb = parseColour(b);
const mix = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * t);
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
}
function parseColour(value) {
const hex = value.charAt(0) === "#" ? value.slice(1) : value;
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
];
}
function clamp(value, min, max) {
if (min > max) return (min + max) / 2;
return Math.max(min, Math.min(max, value));
}