replace MapInteraction with HoverHighlightController; one input system

MapInteraction bound DOM events to the WebGL canvas, but the canvas has
pointer-events: none post-migration so its pointermove/down/up/wheel/
keydown listeners never fired — duplicating InputHandler (which owns
the inputOverlay div + EventBus pipeline) and leaving most features
dead. The one alive bit was hover→setHighlightOwner, which I'd
manually forwarded as a workaround.

Now there's a HoverHighlightController that listens to MouseMoveEvent,
computes the cursor's tile owner, and pushes setHighlightOwner. Delete
map-interaction.ts (418 lines) + keyboard-pan.ts, trim the DOM-binding
constructor + proxy methods (setFitZoomOnDoubleClick, setPanSpeed,
setZoomSpeed, etc.) out of GameView, and drop the ClientGameRunner
pointermove forwarder.

Input flows through one path: DOM → inputOverlay → InputHandler →
EventBus → controllers/renderer.
This commit is contained in:
evanpelle
2026-05-17 20:46:02 -07:00
parent fb45c27d82
commit 5a9694e2bd
7 changed files with 46 additions and 644 deletions
-7
View File
@@ -445,13 +445,6 @@ async function createClientGame(
(e) => applyDayNightMode((e as CustomEvent<string>).detail === "true"),
);
// The WebGL canvas has pointer-events: none so input flows through the
// overlay div. Forward pointermove to the WebGL view's MapInteraction so
// hover-driven features (highlight owner, etc.) still work.
inputOverlay.addEventListener("pointermove", (e) =>
view.handlePointerMove(e),
);
const gameRenderer = createRenderer(
inputOverlay,
gameView,
@@ -0,0 +1,44 @@
/**
* HoverHighlightController — pushes the cursor's tile-owner to the WebGL
* view so the territory + border passes can highlight the hovered player.
*
* Replaces the hover path inside the renderer's MapInteraction class (which
* was bound to the WebGL canvas; that canvas has pointer-events: none in the
* current input architecture so its listeners never fired). All input flows
* through InputHandler → MouseMoveEvent on the EventBus, so we just listen.
*/
import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { Controller } from "../Controller";
import { MouseMoveEvent } from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { OWNER_MASK } from "../render/gl/utils/tile-codec";
import { TransformHandler } from "../TransformHandler";
export class HoverHighlightController implements Controller {
private lastOwnerID = 0;
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private view: WebGLGameView,
) {}
init() {
this.eventBus.on(MouseMoveEvent, (e) => this.onMouseMove(e));
}
private onMouseMove(e: MouseMoveEvent): void {
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
let ownerID = 0;
if (this.game.isValidCoord(cell.x, cell.y)) {
const ref = this.game.ref(cell.x, cell.y);
ownerID = this.game.tileState(ref) & OWNER_MASK;
}
if (ownerID === this.lastOwnerID) return;
this.lastOwnerID = ownerID;
this.view.setHighlightOwner(ownerID);
}
}
+2
View File
@@ -6,6 +6,7 @@ import { GameStartingModal } from "../GameStartingModal";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { BuildPreviewController } from "../controllers/BuildPreviewController";
import { HoverHighlightController } from "../controllers/HoverHighlightController";
import { WarshipSelectionController } from "../controllers/WarshipSelectionController";
import { GameView as WebGLGameView } from "../render/gl";
import { FrameProfiler } from "./FrameProfiler";
@@ -261,6 +262,7 @@ export function createRenderer(
const layers: Controller[] = [
new WarshipSelectionController(game, eventBus, transformHandler, view),
new BuildPreviewController(game, eventBus, uiState, transformHandler, view),
new HoverHighlightController(game, eventBus, transformHandler, view),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
attacksDisplay,
-76
View File
@@ -29,8 +29,6 @@ import type {
GameViewEventType,
RadialMenuItem,
} from "./events";
import type { MapKeyBindings } from "./map-interaction";
import { MapInteraction } from "./map-interaction";
import type { SpawnCenter } from "./passes/spawn-overlay-pass";
import type { RenderSettings } from "./render-settings";
import { GPURenderer } from "./renderer";
@@ -38,7 +36,6 @@ import { GPURenderer } from "./renderer";
export class GameView {
private renderer: GPURenderer;
private resizeObs: ResizeObserver | null = null;
private interaction: MapInteraction;
private listeners = new Map<string, Set<(e: unknown) => void>>();
@@ -49,7 +46,6 @@ export class GameView {
paletteData: Float32Array,
raf?: typeof requestAnimationFrame,
caf?: typeof cancelAnimationFrame,
keyBindings?: MapKeyBindings,
) {
this.renderer = new GPURenderer(
canvas,
@@ -60,44 +56,6 @@ export class GameView {
caf,
);
// Create interaction handler and wire DOM events
this.interaction = new MapInteraction({
renderer: this.renderer,
emit: this.emit.bind(this),
raf: raf ?? requestAnimationFrame.bind(window),
caf: caf ?? cancelAnimationFrame.bind(window),
keyBindings,
});
canvas.addEventListener("pointerdown", (e) =>
this.interaction.handlePointerDown(e),
);
canvas.addEventListener("pointermove", (e) =>
this.interaction.handlePointerMove(e),
);
canvas.addEventListener("pointerup", (e) =>
this.interaction.handlePointerUp(e),
);
canvas.addEventListener("pointercancel", (e) =>
this.interaction.handlePointerUp(e),
);
canvas.addEventListener("wheel", (e) => this.interaction.handleWheel(e), {
passive: false,
});
canvas.addEventListener("contextmenu", (e) =>
this.interaction.handleContextMenu(e),
);
canvas.addEventListener("dblclick", (e) =>
this.interaction.handleDblClick(e),
);
canvas.addEventListener("auxclick", (e) =>
this.interaction.handleAuxClick(e),
);
document.addEventListener("keydown", (e) =>
this.interaction.handleKeyDown(e),
);
document.addEventListener("keyup", (e) => this.interaction.handleKeyUp(e));
this.resizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
@@ -110,18 +68,6 @@ export class GameView {
if (rect.width > 0) this.renderer.resize(rect.width, rect.height);
}
/**
* Forward a pointermove event into the MapInteraction handler. The WebGL
* canvas itself has pointer-events: none (input flows through a separate
* overlay div in the main client), so the listener bound to `canvas` in
* the constructor never actually fires for game-mode input. Callers that
* own the active input element forward pointermove events here so hover
* tracking + setHighlightOwner still work.
*/
handlePointerMove(e: PointerEvent): void {
this.interaction.handlePointerMove(e);
}
// ---- Event system ----
on<K extends GameViewEventType>(
@@ -161,25 +107,18 @@ export class GameView {
centerItem?: RadialMenuItem,
): void {
this.renderer.showRadialMenu(screenX, screenY, items, centerItem);
// Cursor is at anchor — center starts hovered (synced with RadialMenuPass)
this.interaction.setMenuHoveredSeg(
this.renderer.radialMenuHitTest(screenX, screenY),
);
}
hideRadialMenu(): void {
this.renderer.hideRadialMenu();
this.interaction.setMenuHoveredSeg(-1);
}
openRadialSubMenu(subItems: RadialMenuItem[]): void {
this.renderer.openRadialSubMenu(subItems);
this.interaction.setMenuHoveredSeg(-1);
}
goBackRadialMenu(): void {
this.renderer.goBackRadialMenu();
this.interaction.setMenuHoveredSeg(-1);
}
get radialMenuVisible(): boolean {
@@ -325,7 +264,6 @@ export class GameView {
/** Update ghost structure preview (build-mode visualization). null = clear. */
updateGhostPreview(data: GhostPreviewData | null): void {
this.interaction.setHasGhostPreview(data !== null);
this.renderer.updateGhostPreview(data);
}
@@ -380,21 +318,8 @@ export class GameView {
// ---- Other ----
setFitZoomOnDoubleClick(v: boolean): void {
this.interaction.fitZoomOnDoubleClick = v;
}
setDefaultGridView(v: boolean): void {
this.interaction.setDefaultGridView(v);
}
setLocalPlayerID(id: number): void {
this.renderer.setLocalPlayerID(id);
this.interaction.setLocalPlayerID(id);
}
setPanSpeed(speed: number): void {
this.interaction.setPanSpeed(speed);
}
setZoomSpeed(speed: number): void {
this.interaction.setZoomSpeed(speed);
}
setHighlightOwner(ownerID: number): void {
this.renderer.setHighlightOwner(ownerID);
@@ -418,7 +343,6 @@ export class GameView {
// ---- Lifecycle ----
dispose(): void {
this.interaction.dispose();
this.resizeObs?.disconnect();
this.resizeObs = null;
this.listeners.clear();
-2
View File
@@ -9,8 +9,6 @@ export type {
RadialMenuSelectEvent,
} from "./events";
export { GameView } from "./game-view";
export { REPLAY_KEY_BINDINGS } from "./map-interaction";
export type { MapKeyBindings } from "./map-interaction";
export type { SpawnCenter } from "./passes/spawn-overlay-pass";
export { createRenderSettings, dumpSettings } from "./render-settings";
export type { RenderSettings } from "./render-settings";
-141
View File
@@ -1,141 +0,0 @@
/**
* KeyboardPan — WASD camera panning, Q/E smooth zoom, and C fit-zoom.
*
* Tracks held keys and runs a requestAnimationFrame loop while any
* direction or zoom key is pressed. All movement is frame-rate
* independent. Pan speed is zoom-adaptive (faster when zoomed out).
*
* Skips all input when the user is typing in an input/textarea.
*/
const WASD = new Set(["w", "a", "s", "d"]);
const ZOOM_KEYS = new Set(["q", "e"]);
const DEFAULT_PAN_SPEED = 800; // tiles per second at zoom = 1
const DEFAULT_ZOOM_SPEED = 2.0; // zoom multiplier per second (e.g. 2× per second held)
interface KeyboardPanDeps {
panBy(dx: number, dy: number): void;
zoomBy(factor: number): void;
focusOwner(ownerID: number): void;
fitMap(): void;
readonly zoom: number;
}
export class KeyboardPan {
private deps: KeyboardPanDeps;
private raf: typeof requestAnimationFrame;
private caf: typeof cancelAnimationFrame;
private held = new Set<string>();
private animId: number | null = null;
private lastTime = 0;
private localPlayerID = 0;
private panSpeed = DEFAULT_PAN_SPEED;
private zoomSpeed = DEFAULT_ZOOM_SPEED;
constructor(
deps: KeyboardPanDeps,
raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window),
caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window),
) {
this.deps = deps;
this.raf = raf;
this.caf = caf;
}
handleKeyDown(e: KeyboardEvent): boolean {
if (isTyping()) return false;
const key = e.key.toLowerCase();
if (key === "c" && !e.repeat) {
if (this.localPlayerID > 0) this.deps.focusOwner(this.localPlayerID);
else this.deps.fitMap();
return true;
}
if (WASD.has(key) || ZOOM_KEYS.has(key)) {
this.held.add(key);
if (this.animId === null) this.startLoop();
return true;
}
return false;
}
handleKeyUp(e: KeyboardEvent): boolean {
const key = e.key.toLowerCase();
if (WASD.has(key) || ZOOM_KEYS.has(key)) {
this.held.delete(key);
if (this.held.size === 0) this.stopLoop();
return true;
}
return false;
}
setLocalPlayerID(id: number): void {
this.localPlayerID = id;
}
setPanSpeed(speed: number): void {
this.panSpeed = speed;
}
setZoomSpeed(speed: number): void {
this.zoomSpeed = speed;
}
dispose(): void {
this.stopLoop();
this.held.clear();
}
// ---- Animation loop ----
private startLoop(): void {
this.lastTime = performance.now();
this.animId = this.raf(this.loop);
}
private stopLoop(): void {
if (this.animId !== null) {
this.caf(this.animId);
this.animId = null;
}
}
private loop = (): void => {
const now = performance.now();
const dt = Math.min((now - this.lastTime) / 1000, 0.1); // cap at 100ms
this.lastTime = now;
const speed = this.panSpeed / this.deps.zoom;
let dx = 0;
let dy = 0;
if (this.held.has("a")) dx -= speed * dt;
if (this.held.has("d")) dx += speed * dt;
if (this.held.has("w")) dy -= speed * dt;
if (this.held.has("s")) dy += speed * dt;
if (dx !== 0 || dy !== 0) this.deps.panBy(dx, dy);
// Q/E smooth zoom: compute multiplicative factor for this frame
let zoomDir = 0;
if (this.held.has("e")) zoomDir += 1;
if (this.held.has("q")) zoomDir -= 1;
if (zoomDir !== 0) {
const factor = this.zoomSpeed ** (zoomDir * dt);
this.deps.zoomBy(factor);
}
if (this.held.size > 0) this.animId = this.raf(this.loop);
else this.animId = null;
};
}
function isTyping(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
-418
View File
@@ -1,418 +0,0 @@
/**
* MapInteraction — handles all DOM pointer and keyboard events for GameView.
*
* Owns:
* - Drag state: dragging, lastX/Y, downX/Y
* - Menu hover state: menuHoveredSeg
* - Timing guards: lastMenuDismissAt, lastGhostClickAt
* - Ghost preview flag: hasGhostPreview
* - Alt-view flag: altView (affiliation recoloring, configurable hold key)
* - Grid-view flag: gridView (coordinate grid, configurable toggle key)
* - Hover tracking: lastHoverOwner, lastHoverUnitId, lastHoverStructureId, lastHoverTileX/Y
*
* All handler methods (pointerdown, pointermove, pointerup, keydown, keyup, wheel, contextmenu, auxclick, dblclick)
* are defined here and bound by GameView.
*/
import type {
GameViewEventMap,
GameViewEventType,
MapPointerEvent,
} from "./events";
import { KeyboardPan } from "./keyboard-pan";
import type { GPURenderer } from "./renderer";
const HIT_RADIUS_PX = 16;
const CLICK_THRESHOLD_SQ = 100;
/** Describes a hold-key binding (key held = active, released = inactive). */
export interface HoldKeyBinding {
/** KeyboardEvent.code to match (e.g. "Space", "KeyM"). */
code: string;
/** Require shift modifier. Default false. */
shift?: boolean;
}
/** Describes a toggle-key binding (each press toggles). */
export interface ToggleKeyBinding {
/** KeyboardEvent.key to match (e.g. "m", "g"). */
key: string;
}
/** Configurable keybindings for MapInteraction. */
export interface MapKeyBindings {
/** Hold to peek alt-view (affiliation recoloring) + grid. */
altViewPeek: HoldKeyBinding;
/** Toggle grid overlay on/off. */
gridToggle: ToggleKeyBinding;
}
/** Extension default: Space hold for altView peek, 'm' toggle for grid. */
export const DEFAULT_KEY_BINDINGS: MapKeyBindings = {
altViewPeek: { code: "Space" },
gridToggle: { key: "m" },
};
/** Replay default: Shift+M hold for altView peek, 'm' toggle for grid. */
export const REPLAY_KEY_BINDINGS: MapKeyBindings = {
altViewPeek: { code: "KeyG", shift: true },
gridToggle: { key: "g" },
};
interface InteractionDeps {
renderer: GPURenderer;
emit: <K extends GameViewEventType>(
event: K,
payload: GameViewEventMap[K],
) => void;
raf: typeof requestAnimationFrame;
caf: typeof cancelAnimationFrame;
keyBindings?: MapKeyBindings;
}
export class MapInteraction {
private deps: InteractionDeps;
private keys: MapKeyBindings;
// Drag state
private dragging = false;
private lastX = 0;
private lastY = 0;
private downX = 0;
private downY = 0;
// Hover tracking
private lastHoverOwner = 0;
private lastHoverUnitId: number | null = null;
private lastHoverStructureId: number | null = null;
private lastHoverTileX = -1;
private lastHoverTileY = -1;
// Timing guards
private hasGhostPreview = false;
private lastGhostClickAt = 0;
private lastMenuDismissAt = 0;
// Menu hover
private menuHoveredSeg = -1;
// Grid-view: coordinate grid overlay. Toggled by configured key, persisted.
private gridViewBase = false;
private gridView = false;
// Alt-view: affiliation recoloring (no persistent toggle).
private altView = false;
// Alt-view peek hold state.
private peekHeld = false;
// Interaction settings (mutable — updated live by extension)
fitZoomOnDoubleClick = true;
// Keyboard camera control (WASD pan + C fit-zoom)
private keyboardPan: KeyboardPan;
constructor(deps: InteractionDeps) {
this.deps = deps;
this.keys = deps.keyBindings ?? DEFAULT_KEY_BINDINGS;
this.keyboardPan = new KeyboardPan(deps.renderer, deps.raf, deps.caf);
}
// ---- Pointer event handlers ----
handlePointerDown(e: PointerEvent): void {
if (e.button !== 0) return;
// If radial menu is open, clicking outside dismisses it
if (this.deps.renderer.radialMenuVisible) {
const hit = this.deps.renderer.radialMenuHitTest(e.clientX, e.clientY);
if (hit === -1) {
this.deps.renderer.hideRadialMenu();
this.menuHoveredSeg = -1;
this.lastMenuDismissAt = performance.now();
}
return; // consume the event either way — don't start dragging
}
if (this.hasGhostPreview) this.lastGhostClickAt = performance.now();
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.downX = e.clientX;
this.downY = e.clientY;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
handlePointerMove(e: PointerEvent): void {
// Update radial menu hover
if (this.deps.renderer.radialMenuVisible) {
const hit = this.deps.renderer.radialMenuHitTest(e.clientX, e.clientY);
if (hit !== this.menuHoveredSeg) {
this.menuHoveredSeg = hit;
this.deps.renderer.setRadialMenuHover(hit);
}
return; // don't pan or update game hover while menu is open
}
if (this.dragging) {
const dx = e.clientX - this.lastX;
const dy = e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
const dpr = window.devicePixelRatio || 1;
this.deps.renderer.panBy(
-(dx * dpr) / this.deps.renderer.zoom,
-(dy * dpr) / this.deps.renderer.zoom,
);
return;
}
this.updateHover(e);
}
handlePointerUp(e: PointerEvent): void {
if (e.button !== 0) return;
// If radial menu is open, a click on a segment or center selects it.
// Don't hide the menu here — the menuselect handler decides whether to
// close or open a submenu.
if (this.deps.renderer.radialMenuVisible) {
if (this.menuHoveredSeg !== -1) {
const item = this.deps.renderer.getRadialMenuItemAt(
this.menuHoveredSeg,
);
if (item && item.enabled) {
this.deps.emit("menuselect", {
index: this.menuHoveredSeg,
id: item.id,
});
}
if (!this.deps.renderer.radialMenuVisible) {
this.lastMenuDismissAt = performance.now();
}
this.menuHoveredSeg = -1;
}
return;
}
if (!this.dragging) return;
this.dragging = false;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
const dx = e.clientX - this.downX;
const dy = e.clientY - this.downY;
if (dx * dx + dy * dy < CLICK_THRESHOLD_SQ) {
this.deps.emit("click", this.buildEvent(e, 0));
}
}
// ---- Keyboard event handlers ----
handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape" && this.deps.renderer.radialMenuVisible) {
this.deps.renderer.hideRadialMenu();
this.menuHoveredSeg = -1;
this.lastMenuDismissAt = performance.now();
}
if (
this.matchesHold(e, this.keys.altViewPeek) &&
!e.repeat &&
!this.peekHeld
) {
e.preventDefault();
this.peekHeld = true;
this.applyAltView(true);
this.applyGridView(true);
this.deps.emit("altviewpeek", { active: true });
}
if (e.key === this.keys.gridToggle.key && !e.shiftKey && !e.repeat) {
this.gridViewBase = !this.gridViewBase;
this.applyGridView(this.gridViewBase);
this.deps.emit("gridviewtoggle", { active: this.gridViewBase });
}
this.keyboardPan.handleKeyDown(e);
}
handleKeyUp(e: KeyboardEvent): void {
if (e.code === this.keys.altViewPeek.code && this.peekHeld) {
e.preventDefault();
this.peekHeld = false;
this.applyAltView(false);
this.applyGridView(this.gridViewBase);
this.deps.emit("altviewpeek", { active: false });
}
this.keyboardPan.handleKeyUp(e);
}
private matchesHold(e: KeyboardEvent, binding: HoldKeyBinding): boolean {
return e.code === binding.code && (!binding.shift || e.shiftKey);
}
// ---- Other event handlers ----
handleWheel(e: WheelEvent): void {
e.preventDefault();
if (e.shiftKey || e.ctrlKey || e.altKey) {
this.deps.emit("scroll", {
deltaX: e.deltaX,
deltaY: e.deltaY,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
});
return;
}
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
this.deps.renderer.zoomAtScreen(factor, e.clientX, e.clientY);
}
handleContextMenu(e: MouseEvent): void {
e.preventDefault();
// Dismiss any open menu first — the external manager will decide whether to reopen
if (this.deps.renderer.radialMenuVisible) {
this.deps.renderer.hideRadialMenu();
this.menuHoveredSeg = -1;
this.lastMenuDismissAt = performance.now();
}
this.deps.emit("contextmenu", this.buildEvent(e, 2));
}
handleAuxClick(e: MouseEvent): void {
if (e.button !== 1) return;
e.preventDefault();
this.deps.emit("middleclick", this.buildEvent(e, 1));
}
handleDblClick(e: MouseEvent): void {
// Suppress fitzoom if menu is open or was recently open
if (this.deps.renderer.radialMenuVisible) return;
const now = performance.now();
if (now - this.lastMenuDismissAt < 500) return;
const evt = this.buildEvent(e, 0);
if (this.fitZoomOnDoubleClick && now - this.lastGhostClickAt > 500) {
if (evt.ownerID !== 0) this.deps.renderer.focusOwner(evt.ownerID);
else this.deps.renderer.fitMap();
}
this.deps.emit("dblclick", evt);
}
// ---- Hover tracking ----
private updateHover(e: PointerEvent): void {
const world = this.deps.renderer.screenToWorld(e.clientX, e.clientY);
const tileX = Math.floor(world.x);
const tileY = Math.floor(world.y);
const ownerID = this.deps.renderer.getOwnerAtWorld(world.x, world.y);
const hitRadius = HIT_RADIUS_PX / this.deps.renderer.zoom;
const unit = this.deps.renderer.getUnitAtWorld(world.x, world.y, hitRadius);
const structure = this.deps.renderer.getStructureAtWorld(
world.x,
world.y,
hitRadius,
);
const unitId = unit?.id ?? null;
const structureId = structure?.id ?? null;
if (
ownerID !== this.lastHoverOwner ||
unitId !== this.lastHoverUnitId ||
structureId !== this.lastHoverStructureId ||
tileX !== this.lastHoverTileX ||
tileY !== this.lastHoverTileY
) {
this.lastHoverOwner = ownerID;
this.lastHoverUnitId = unitId;
this.lastHoverStructureId = structureId;
this.lastHoverTileX = tileX;
this.lastHoverTileY = tileY;
this.deps.renderer.setHighlightOwner(ownerID);
this.deps.emit("hover", {
screenX: e.clientX,
screenY: e.clientY,
worldX: world.x,
worldY: world.y,
tileX,
tileY,
ownerID,
unit,
structure,
button: 0,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
altKey: e.altKey,
});
}
}
private buildEvent(e: MouseEvent, button: number): MapPointerEvent {
const world = this.deps.renderer.screenToWorld(e.clientX, e.clientY);
const hitRadius = HIT_RADIUS_PX / this.deps.renderer.zoom;
return {
screenX: e.clientX,
screenY: e.clientY,
worldX: world.x,
worldY: world.y,
tileX: Math.floor(world.x),
tileY: Math.floor(world.y),
ownerID: this.deps.renderer.getOwnerAtWorld(world.x, world.y),
unit: this.deps.renderer.getUnitAtWorld(world.x, world.y, hitRadius),
structure: this.deps.renderer.getStructureAtWorld(
world.x,
world.y,
hitRadius,
),
button,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
altKey: e.altKey,
};
}
// ---- View helpers ----
private applyAltView(active: boolean): void {
if (active === this.altView) return;
this.altView = active;
this.deps.renderer.setAltView(active);
}
private applyGridView(active: boolean): void {
if (active === this.gridView) return;
this.gridView = active;
this.deps.renderer.setGridView(active);
}
// ---- Public API ----
setDefaultGridView(v: boolean): void {
this.gridViewBase = v;
if (!this.peekHeld) this.applyGridView(v);
}
setHasGhostPreview(v: boolean): void {
this.hasGhostPreview = v;
}
getMenuHoveredSeg(): number {
return this.menuHoveredSeg;
}
setMenuHoveredSeg(v: number): void {
this.menuHoveredSeg = v;
}
setLocalPlayerID(id: number): void {
this.keyboardPan.setLocalPlayerID(id);
}
setPanSpeed(speed: number): void {
this.keyboardPan.setPanSpeed(speed);
}
setZoomSpeed(speed: number): void {
this.keyboardPan.setZoomSpeed(speed);
}
dispose(): void {
this.keyboardPan.dispose();
}
}