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

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;
},
};