295 lines
10 KiB
JavaScript
295 lines
10 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 { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
|
|
import { clamp } from "./utils.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();
|
|
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._syncChunks();
|
|
this._glRender();
|
|
},
|
|
|
|
// 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);
|
|
if (lod) {
|
|
this._clearChunks();
|
|
this._drawOverview(true);
|
|
} else {
|
|
this._chunkDirty = 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, 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);
|
|
},
|
|
|
|
// ------------------------------------------------------- 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);
|
|
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;
|
|
},
|
|
};
|