1248 lines
43 KiB
JavaScript
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));
|
|
}
|