420 lines
15 KiB
JavaScript
420 lines
15 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. 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();
|
|
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();
|
|
},
|
|
|
|
_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;
|
|
},
|
|
};
|