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

441 lines
16 KiB
JavaScript

// Camera, zoom/LOD switching, pointer input, selection and screen-to-tile
// hit-testing.
import { mapToLocal, key, parity, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js";
import { CAMERA_TILT, DRAG_THRESHOLD, HEIGHT_LIFT, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
import { CORNERS } from "./distortion.js";
export const inputMethods = {
// ------------------------------------------------------------- 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. The WebGL redraw still runs here, though, so the
// map canvas keeps pace with the icons the drag is moving. Panning and
// zooming from elsewhere apply the wrapped copies immediately too.
_applyCamera(applyWrap = true) {
const width = this.$viewport.width();
const height = this.$viewport.height();
const { x, y, zoom } = this.camera;
// The camera focus sits at the screen centre; the north-south axis is
// foreshortened by the camera tilt, so the vertical pan is scaled too.
const tx = width / 2 - x * zoom;
const ty = height / 2 - y * zoom * CAMERA_TILT;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
// A zoom step may have crossed into or out of the political map.
this._updatePoliticalMode();
if (applyWrap) this._syncChunks();
if (this._selectedTile) this._positionHighlight();
this._positionPoliticalLabels();
this._glRender();
},
// Whether a map tile falls inside the current viewport, optionally widened by
// `marginTiles`. The test runs in world space against the wrapped copy the
// entities are actually drawn at, so a target near the wrap seam is judged
// where it appears rather than at its unwrapped coordinate.
tileInView(coords, marginTiles = 0) {
if (!coords) return false;
const width = this.$viewport.width();
const height = this.$viewport.height();
if (!width || !height) return false;
const zoom = this.camera.zoom || 1;
const local = mapToLocal(coords.x, coords.y);
const wrapped = this._wrappedPosition(local);
const halfW = width / (2 * zoom) + marginTiles * HEX_W;
const halfH = height / (2 * zoom * CAMERA_TILT) + marginTiles * HEX_H;
return (
Math.abs(wrapped.x - this.camera.x) <= halfW &&
Math.abs(local.y - this.camera.y) <= halfH
);
},
_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 * CAMERA_TILT);
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, event);
});
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();
},
// ------------------------------------------------------------ clicks --
_onLeftClick(coords) {
const unitId = this.unitAt(coords);
if (unitId && !this._selectedUnitIds.has(unitId)) {
this.onUnitSelected(unitId);
return;
}
if (unitId) return;
const city = this.cityAt(coords);
if (city) {
this.onCitySelected(city.id);
return;
}
this.clearSelection();
},
// With a selection, right-clicking orders it where it clicked: Ctrl queues a
// scheduled destination, a hostile stack or city is attacked, and anything
// else -- including a friendly stack, which we merge into -- is a move. With
// no selection, right-clicking a friendly stack opens its menu and any other
// tile is inspected.
_onRightClick(coords, event) {
const selected = Array.from(this._selectedUnitIds);
if (selected.length > 0) {
if (event && (event.ctrlKey || event.metaKey)) {
return this.onScheduleOrdered(selected, coords);
}
if (this._isEnemyTarget(coords)) {
return this.onAttackOrdered(selected, coords);
}
return this.onMoveOrdered(selected, coords);
}
const friendly = this.unitIdsAt(coords).filter(
(id) => (this._unitData.get(id) || {}).civ === this.localCiv
);
if (friendly.length > 0) {
this.onStackMenu(coords, friendly, event ? event.clientX : 0, event ? event.clientY : 0);
return;
}
this.onTileRequested(coords);
},
// Ids of every unit of the same civilisation sharing the tile the given unit
// is drawn on, so clicking one icon selects the whole stack.
stackIds(unitId) {
const data = this._unitData.get(unitId);
if (!data) return [];
const motion = this._unitMotion.get(unitId);
const tile = motion
? this._currentTile(motion)
: { x: data.coords[0], y: data.coords[1] };
return this.unitIdsAt(tile).filter(
(id) => (this._unitData.get(id) || {}).civ === data.civ
);
},
// Whether the tile holds a hostile unit stack or an enemy city, so a right
// click with our stack selected becomes an attack rather than a march.
_isEnemyTarget(coords) {
const enemy = this.unitIdsAt(coords).some((id) => {
const data = this._unitData.get(id);
return data && data.civ !== this.localCiv;
});
if (enemy) return true;
const k = key(coords.x, coords.y);
if (!this.visible.has(k)) return false;
const cityCiv = this._cityCiv.get(k);
return cityCiv !== undefined && cityCiv !== this.localCiv;
},
// --------------------------------------------------------- selection --
setSelectedUnits(ids) {
this._selectedUnitIds = new Set((ids || []).filter((id) => id));
for (const [unitId, view] of this._unitViews) {
view.classList.toggle("selected", this._selectedUnitIds.has(unitId));
}
},
setSelectedUnit(id) {
this.setSelectedUnits(id ? [id] : []);
},
clearSelection() {
this.setSelectedUnits([]);
this.onUnitSelected(0);
},
// ---------------------------------------------------- tile highlight --
// Outlines the tile under the current selection or inspection. The outline
// follows the same warped hexagon the terrain draws, so it sits exactly on
// the distorted tile; on the DOM renderer (no warp) it is a plain hexagon.
setSelectedTile(coords) {
this._selectedTile = coords ? { x: coords.x, y: coords.y } : null;
this._selectedRegion = this._selectedTile && this.regions
? (this.regions.get(key(this._selectedTile.x, this._selectedTile.y)) ?? null)
: null;
if (!this._selectedTile || !this.topology) {
if (this._highlightEl) this._highlightEl.classList.remove("active");
if (this.glRenderer) this.glRenderer.buildRegionOverlay(this);
return;
}
this._ensureHighlight();
const local = mapToLocal(this._selectedTile.x, this._selectedTile.y);
const outline = this.glRenderer
? this.glRenderer.tileBoundary(this, this._selectedTile.x, this._selectedTile.y)
: CORNERS.map((corner) => ({ x: local.x + corner.x, y: local.y + corner.y }));
// The outline rides the same relief as the terrain. The layer is scaled on
// Y by the camera tilt, so dividing the world-space lift by the tilt makes
// the polygon rise by exactly the shader's screen-space lift.
const lift = (point) => this.glRenderer
? (this.glRenderer.pointHeight(point.x, point.y) * HEIGHT_LIFT) / CAMERA_TILT
: 0;
const corners = outline.map((corner) => ({
x: corner.x - local.x + HEX_W / 2,
y: corner.y - local.y + HEX_H / 2 - lift(corner),
}));
this._highlightPoly.setAttribute("points",
corners.map((corner) => `${corner.x.toFixed(2)},${corner.y.toFixed(2)}`).join(" "));
// A ridge that would hide part of the outline dottens that part, exactly
// like the national borders.
const hidden = this.glRenderer
? outline.map((corner) => this.glRenderer.isOccluded(corner.x, corner.y))
: outline.map(() => false);
this._highlightSolid.setAttribute("d", this._outlineRuns(corners, hidden, false));
this._highlightHidden.setAttribute("d", this._outlineRuns(corners, hidden, true));
this._highlightEl.classList.add("active");
this._positionHighlight();
if (this.glRenderer) this.glRenderer.buildRegionOverlay(this);
},
// Path data for one half of a closed outline: a run is a maximal set of
// consecutive segments whose hidden state matches `want`. The selected-tile
// outline draws the visible runs solid and the hidden ones dotted.
_outlineRuns(points, hidden, want) {
const n = points.length;
if (n < 2) return "";
let d = "";
let open = false;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
if ((hidden[i] || hidden[j]) !== want) {
open = false;
continue;
}
const a = points[i];
const b = points[j];
if (!open) {
d += `M${a.x.toFixed(2)} ${a.y.toFixed(2)}`;
open = true;
}
d += `L${b.x.toFixed(2)} ${b.y.toFixed(2)}`;
}
return d;
},
clearSelectedTile() {
this.setSelectedTile(null);
},
_ensureHighlight() {
if (this._highlightEl) return;
const ns = "http://www.w3.org/2000/svg";
const el = document.createElement("div");
el.className = "tile-selection";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", `0 0 ${HEX_W} ${HEX_H}`);
svg.setAttribute("preserveAspectRatio", "none");
const poly = document.createElementNS(ns, "polygon");
const solid = document.createElementNS(ns, "path");
solid.setAttribute("class", "tile-selection-visible");
const hidden = document.createElementNS(ns, "path");
hidden.setAttribute("class", "tile-selection-hidden");
svg.appendChild(poly);
svg.appendChild(solid);
svg.appendChild(hidden);
el.appendChild(svg);
this.$highlight[0].appendChild(el);
this._highlightEl = el;
this._highlightPoly = poly;
this._highlightSolid = solid;
this._highlightHidden = hidden;
},
// Keeps the (already measured) outline on the wrapped copy nearest the camera.
_positionHighlight() {
if (!this._highlightEl || !this._selectedTile) return;
const local = mapToLocal(this._selectedTile.x, this._selectedTile.y);
const wrapped = this._wrappedPosition(local);
this._highlightEl.style.left = `${wrapped.x - HEX_W / 2}px`;
this._highlightEl.style.top = `${wrapped.y - HEX_H / 2}px`;
},
// ------------------------------------------------------- hit-testing --
unitIdsAt(coords) {
const local = this._clickLocal(coords);
const result = [];
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 offset = motion.offset || { x: 0, y: 0 };
const wrapped = this._wrappedPosition(position);
if (Math.hypot(wrapped.x + offset.x - local.x, wrapped.y + offset.y - local.y) < HEX_W * 0.5) {
result.push(id);
}
}
return result;
},
unitAt(coords) {
return this.unitIdsAt(coords)[0] || 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);
// City views are placed on the foreshortened ground.
const cy = parseFloat(view.style.top);
if (Math.hypot(cx - local.x, cy - local.y * CAMERA_TILT) < 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 zoom = this.camera.zoom;
const worldX = (screenX - width / 2) / zoom + this.camera.x;
// The flat estimate, then walk south along the column to the surface the
// height lift actually puts under the pixel. The relief field folds the
// wrapped axis itself, so the unwrapped column is fine to sample.
const flatY = (screenY - height / 2) / (zoom * CAMERA_TILT) + this.camera.y;
return this._worldToHex(worldX, this._surfaceY(worldX, flatY));
},
// The relief at a world point, from the renderer (zero on the DOM path).
_pointHeight(x, y) {
return this.glRenderer ? this.glRenderer.pointHeight(x, y) : 0;
},
// Invert the renderer's height lift along one screen column. A surface at
// world y projects to the same pixel as the flat point `y - h*(HEIGHT_LIFT /
// tilt)` north of it, so the displayed surface is at most one lift south of
// the flat estimate. Over a ridge the projection is not monotonic, so scan
// the column and take the last crossing: the tile drawn last (the frontmost)
// is the one the pixel shows.
_surfaceY(x, flatY) {
const lift = HEIGHT_LIFT / CAMERA_TILT;
if (!(lift > 0)) return flatY;
const sample = (y) => (y - flatY) - this._pointHeight(x, y) * lift;
const steps = 16;
let prevY = flatY;
let prev = sample(prevY);
let hit = null;
for (let i = 1; i <= steps; i++) {
const y = flatY + (lift * i) / steps;
const value = sample(y);
if (prev <= 0 && value >= 0) hit = { a: prevY, b: y };
prevY = y;
prev = value;
}
if (!hit) return flatY;
let a = hit.a;
let b = hit.b;
for (let i = 0; i < 14; i++) {
const mid = (a + b) / 2;
if (sample(mid) >= 0) b = mid;
else a = mid;
}
return b;
},
_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;
},
};