mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user