import { EventBus, GameEvent } from "../core/EventBus"; import { PlayerBuildableUnitType, UnitType } from "../core/game/Game"; import { UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; import { UIState } from "./graphics/UIState"; import { Platform } from "./Platform"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class MouseUpEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class MouseOverEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class TouchEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } /** * Event emitted when a unit is selected or deselected */ export class UnitSelectionEvent implements GameEvent { constructor( public readonly unit: UnitView | null, public readonly isSelected: boolean, ) {} } export class MouseDownEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class MouseMoveEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class ContextMenuEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class ZoomEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, public readonly delta: number, ) {} } export class DragEvent implements GameEvent { constructor( public readonly deltaX: number, public readonly deltaY: number, ) {} } export class AlternateViewEvent implements GameEvent { constructor(public readonly alternateView: boolean) {} } export class CloseViewEvent implements GameEvent {} export class RefreshGraphicsEvent implements GameEvent {} export class TogglePerformanceOverlayEvent implements GameEvent {} export class ToggleStructureEvent implements GameEvent { constructor( public readonly structureTypes: PlayerBuildableUnitType[] | null, ) {} } export class GhostStructureChangedEvent implements GameEvent { constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {} } export class ConfirmGhostStructureEvent implements GameEvent {} export class SwapRocketDirectionEvent implements GameEvent { constructor(public readonly rocketDirectionUp: boolean) {} } export class ShowBuildMenuEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class ShowEmojiMenuEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class DoBoatAttackEvent implements GameEvent {} export class DoGroundAttackEvent implements GameEvent {} export class AttackRatioEvent implements GameEvent { constructor(public readonly attackRatio: number) {} } export class ReplaySpeedChangeEvent implements GameEvent { constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {} } export class CenterCameraEvent implements GameEvent { constructor() {} } export class AutoUpgradeEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, ) {} } export class ToggleCoordinateGridEvent implements GameEvent { constructor(public readonly enabled: boolean) {} } export class TickMetricsEvent implements GameEvent { constructor( public readonly tickExecutionDuration?: number, public readonly tickDelay?: number, ) {} } export class InputHandler { private lastPointerX: number = 0; private lastPointerY: number = 0; private lastPointerDownX: number = 0; private lastPointerDownY: number = 0; private pointers: Map = new Map(); private lastPinchDistance: number = 0; private pointerDown: boolean = false; private alternateView = false; private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); private keybinds: Record = {}; private coordinateGridEnabled = false; private readonly PAN_SPEED = 5; private readonly ZOOM_SPEED = 10; private readonly userSettings: UserSettings = new UserSettings(); constructor( public uiState: UIState, private canvas: HTMLCanvasElement, private eventBus: EventBus, ) {} initialize() { let saved: Record = {}; try { const parsed = JSON.parse( localStorage.getItem("settings.keybinds") ?? "{}", ); // flatten { key: {key, value} } → { key: value } and accept legacy string values saved = Object.fromEntries( Object.entries(parsed) .map(([k, v]) => { // Extract value from nested object or plain string let val: unknown; if (v && typeof v === "object" && "value" in v) { val = (v as { value: unknown }).value; } else { val = v; } // Map invalid values to undefined (filtered later) if (typeof val !== "string") { return [k, undefined]; } return [k, val]; }) .filter(([, v]) => typeof v === "string"), ) as Record; } catch (e) { console.warn("Invalid keybinds JSON:", e); } // Mac users might have different keybinds const isMac = Platform.isMac; this.keybinds = { toggleView: "Space", coordinateGrid: "KeyM", centerCamera: "KeyC", moveUp: "KeyW", moveDown: "KeyS", moveLeft: "KeyA", moveRight: "KeyD", zoomOut: "KeyQ", zoomIn: "KeyE", attackRatioDown: "KeyT", attackRatioUp: "KeyY", boatAttack: "KeyB", groundAttack: "KeyG", swapDirection: "KeyU", modifierKey: isMac ? "MetaLeft" : "ControlLeft", altKey: "AltLeft", buildCity: "Digit1", buildFactory: "Digit2", buildPort: "Digit3", buildDefensePost: "Digit4", buildMissileSilo: "Digit5", buildSamLauncher: "Digit6", buildWarship: "Digit7", buildAtomBomb: "Digit8", buildHydrogenBomb: "Digit9", buildMIRV: "Digit0", ...saved, }; this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); window.addEventListener("pointerup", (e) => this.onPointerUp(e)); this.canvas.addEventListener( "wheel", (e) => { this.onScroll(e); this.onShiftScroll(e); e.preventDefault(); }, { passive: false }, ); window.addEventListener("pointermove", this.onPointerMove.bind(this)); this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e)); window.addEventListener("mousemove", (e) => { if (e.movementX || e.movementY) { this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY)); } }); this.pointers.clear(); this.moveInterval = setInterval(() => { let deltaX = 0; let deltaY = 0; // Skip if shift is held down if ( this.activeKeys.has("ShiftLeft") || this.activeKeys.has("ShiftRight") ) { return; } if ( this.activeKeys.has(this.keybinds.moveUp) || this.activeKeys.has("ArrowUp") ) deltaY += this.PAN_SPEED; if ( this.activeKeys.has(this.keybinds.moveDown) || this.activeKeys.has("ArrowDown") ) deltaY -= this.PAN_SPEED; if ( this.activeKeys.has(this.keybinds.moveLeft) || this.activeKeys.has("ArrowLeft") ) deltaX += this.PAN_SPEED; if ( this.activeKeys.has(this.keybinds.moveRight) || this.activeKeys.has("ArrowRight") ) deltaX -= this.PAN_SPEED; if (deltaX || deltaY) { this.eventBus.emit(new DragEvent(deltaX, deltaY)); } const cx = window.innerWidth / 2; const cy = window.innerHeight / 2; if ( this.activeKeys.has(this.keybinds.zoomOut) || this.activeKeys.has("Minus") ) { this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED)); } if ( this.activeKeys.has(this.keybinds.zoomIn) || this.activeKeys.has("Equal") ) { this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED)); } }, 1); window.addEventListener("keydown", (e) => { const isTextInput = this.isTextInputTarget(e.target); if (isTextInput && e.code !== "Escape") { return; } if (e.code === this.keybinds.toggleView) { e.preventDefault(); if (!this.alternateView) { this.alternateView = true; this.eventBus.emit(new AlternateViewEvent(true)); } } if (e.code === this.keybinds.coordinateGrid && !e.repeat) { e.preventDefault(); this.coordinateGridEnabled = !this.coordinateGridEnabled; this.eventBus.emit( new ToggleCoordinateGridEvent(this.coordinateGridEnabled), ); } if (e.code === "Escape") { e.preventDefault(); this.eventBus.emit(new CloseViewEvent()); this.setGhostStructure(null); } if ( (e.code === "Enter" || e.code === "NumpadEnter") && this.uiState.ghostStructure !== null ) { e.preventDefault(); this.eventBus.emit(new ConfirmGhostStructureEvent()); } if ( [ this.keybinds.moveUp, this.keybinds.moveDown, this.keybinds.moveLeft, this.keybinds.moveRight, this.keybinds.zoomOut, this.keybinds.zoomIn, "ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight", "Minus", "Equal", this.keybinds.attackRatioDown, this.keybinds.attackRatioUp, this.keybinds.centerCamera, "ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", ].includes(e.code) ) { this.activeKeys.add(e.code); } }); window.addEventListener("keyup", (e) => { const isTextInput = this.isTextInputTarget(e.target); if (isTextInput && !this.activeKeys.has(e.code)) { return; } if (e.code === this.keybinds.toggleView) { e.preventDefault(); this.alternateView = false; this.eventBus.emit(new AlternateViewEvent(false)); } const resetKey = this.keybinds.resetGfx ?? "KeyR"; if (e.code === resetKey && this.isAltKeyHeld(e)) { e.preventDefault(); this.eventBus.emit(new RefreshGraphicsEvent()); } if (e.code === this.keybinds.boatAttack) { e.preventDefault(); this.eventBus.emit(new DoBoatAttackEvent()); } if (e.code === this.keybinds.groundAttack) { e.preventDefault(); this.eventBus.emit(new DoGroundAttackEvent()); } if (e.code === this.keybinds.attackRatioDown) { e.preventDefault(); const increment = this.userSettings.attackRatioIncrement(); this.eventBus.emit(new AttackRatioEvent(-increment)); } if (e.code === this.keybinds.attackRatioUp) { e.preventDefault(); const increment = this.userSettings.attackRatioIncrement(); this.eventBus.emit(new AttackRatioEvent(increment)); } if (e.code === this.keybinds.centerCamera) { e.preventDefault(); this.eventBus.emit(new CenterCameraEvent()); } // Two-phase build keybind matching: exact code match first, then digit/Numpad alias. const matchedBuild = this.resolveBuildKeybind(e.code); if (matchedBuild !== null) { e.preventDefault(); this.setGhostStructure(matchedBuild); } if (e.code === this.keybinds.swapDirection) { e.preventDefault(); const nextDirection = !this.uiState.rocketDirectionUp; this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection)); } // Shift-D to toggle performance overlay console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); if (e.code === "KeyD" && e.shiftKey) { e.preventDefault(); console.log("TogglePerformanceOverlayEvent"); this.eventBus.emit(new TogglePerformanceOverlayEvent()); } this.activeKeys.delete(e.code); }); } private onPointerDown(event: PointerEvent) { if (event.button === 1) { event.preventDefault(); this.eventBus.emit(new AutoUpgradeEvent(event.clientX, event.clientY)); return; } if (event.button > 0) { return; } this.pointerDown = true; this.pointers.set(event.pointerId, event); if (this.pointers.size === 1) { this.lastPointerX = event.clientX; this.lastPointerY = event.clientY; this.lastPointerDownX = event.clientX; this.lastPointerDownY = event.clientY; this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY)); } else if (this.pointers.size === 2) { this.lastPinchDistance = this.getPinchDistance(); } } onPointerUp(event: PointerEvent) { if (event.button === 1) { event.preventDefault(); return; } if (event.button > 0) { return; } this.pointerDown = false; this.pointers.clear(); if (this.isModifierKeyPressed(event)) { this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY)); return; } if (this.isAltKeyPressed(event)) { this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY)); return; } const dist = Math.abs(event.x - this.lastPointerDownX) + Math.abs(event.y - this.lastPointerDownY); if (dist < 10) { if (event.pointerType === "touch") { this.eventBus.emit(new TouchEvent(event.x, event.y)); event.preventDefault(); return; } if (!this.userSettings.leftClickOpensMenu() || event.shiftKey) { this.eventBus.emit(new MouseUpEvent(event.x, event.y)); } else { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } } } private onScroll(event: WheelEvent) { if (!event.shiftKey) { const realCtrl = this.activeKeys.has("ControlLeft") || this.activeKeys.has("ControlRight"); const ratio = event.ctrlKey && !realCtrl ? 10 : 1; // Compensate pinch-zoom low sensitivity this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY * ratio)); } } private onShiftScroll(event: WheelEvent) { if (event.shiftKey) { const scrollValue = event.deltaY === 0 ? event.deltaX : event.deltaY; const increment = this.userSettings.attackRatioIncrement(); const ratio = scrollValue > 0 ? -increment : increment; this.eventBus.emit(new AttackRatioEvent(ratio)); } } private onPointerMove(event: PointerEvent) { if (event.button === 1) { event.preventDefault(); return; } if (event.button > 0) { return; } this.pointers.set(event.pointerId, event); if (!this.pointerDown) { this.eventBus.emit(new MouseOverEvent(event.clientX, event.clientY)); return; } if (this.pointers.size === 1) { const deltaX = event.clientX - this.lastPointerX; const deltaY = event.clientY - this.lastPointerY; this.eventBus.emit(new DragEvent(deltaX, deltaY)); this.lastPointerX = event.clientX; this.lastPointerY = event.clientY; } else if (this.pointers.size === 2) { const currentPinchDistance = this.getPinchDistance(); const pinchDelta = currentPinchDistance - this.lastPinchDistance; if (Math.abs(pinchDelta) > 1) { const zoomCenter = this.getPinchCenter(); this.eventBus.emit( new ZoomEvent(zoomCenter.x, zoomCenter.y, -pinchDelta * 2), ); this.lastPinchDistance = currentPinchDistance; } } } private onContextMenu(event: MouseEvent) { event.preventDefault(); if (this.uiState.ghostStructure !== null) { this.setGhostStructure(null); return; } this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { this.uiState.ghostStructure = ghostStructure; this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure)); } /** * Extracts the digit character from KeyboardEvent.code. * Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and * "Numpad0".."Numpad9" (7 chars, digit at index 6). Returns null if not a digit key. */ private digitFromKeyCode(code: string): string | null { if ( code?.length === 6 && code.startsWith("Digit") && /^[0-9]$/.test(code[5]) ) return code[5]; if ( code?.length === 7 && code.startsWith("Numpad") && /^[0-9]$/.test(code[6]) ) return code[6]; return null; } /** Strict equality only: used for first-pass exact KeyboardEvent.code match. */ private buildKeybindMatches(code: string, keybindValue: string): boolean { return code === keybindValue; } /** Digit/Numpad alias match: used only when no exact match was found. */ private buildKeybindMatchesDigit( code: string, keybindValue: string, ): boolean { const digit = this.digitFromKeyCode(code); const bindDigit = this.digitFromKeyCode(keybindValue); return digit !== null && bindDigit !== null && digit === bindDigit; } /** * Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias. * Returns the UnitType to set as ghost, or null if no build keybind matched. */ private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null { const buildKeybinds: ReadonlyArray<{ key: string; type: PlayerBuildableUnitType; }> = [ { key: "buildCity", type: UnitType.City }, { key: "buildFactory", type: UnitType.Factory }, { key: "buildPort", type: UnitType.Port }, { key: "buildDefensePost", type: UnitType.DefensePost }, { key: "buildMissileSilo", type: UnitType.MissileSilo }, { key: "buildSamLauncher", type: UnitType.SAMLauncher }, { key: "buildAtomBomb", type: UnitType.AtomBomb }, { key: "buildHydrogenBomb", type: UnitType.HydrogenBomb }, { key: "buildWarship", type: UnitType.Warship }, { key: "buildMIRV", type: UnitType.MIRV }, ]; for (const { key, type } of buildKeybinds) { if (this.buildKeybindMatches(code, this.keybinds[key])) return type; } for (const { key, type } of buildKeybinds) { if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type; } return null; } private getPinchDistance(): number { const pointerEvents = Array.from(this.pointers.values()); const dx = pointerEvents[0].clientX - pointerEvents[1].clientX; const dy = pointerEvents[0].clientY - pointerEvents[1].clientY; return Math.sqrt(dx * dx + dy * dy); } private getPinchCenter(): { x: number; y: number } { const pointerEvents = Array.from(this.pointers.values()); return { x: (pointerEvents[0].clientX + pointerEvents[1].clientX) / 2, y: (pointerEvents[0].clientY + pointerEvents[1].clientY) / 2, }; } private isTextInputTarget(target: EventTarget | null): boolean { const element = target as HTMLElement | null; if (!element) return false; if (element.tagName === "TEXTAREA" || element.isContentEditable) { return true; } if (element.tagName === "INPUT") { const input = element as HTMLInputElement; if (input.type === "range") { return false; } return true; } return false; } destroy() { if (this.moveInterval !== null) { clearInterval(this.moveInterval); } this.activeKeys.clear(); } isModifierKeyPressed(event: PointerEvent): boolean { return ( ((this.keybinds.modifierKey === "AltLeft" || this.keybinds.modifierKey === "AltRight") && event.altKey) || ((this.keybinds.modifierKey === "ControlLeft" || this.keybinds.modifierKey === "ControlRight") && event.ctrlKey) || ((this.keybinds.modifierKey === "ShiftLeft" || this.keybinds.modifierKey === "ShiftRight") && event.shiftKey) || ((this.keybinds.modifierKey === "MetaLeft" || this.keybinds.modifierKey === "MetaRight") && event.metaKey) ); } private isAltKeyHeld(event: KeyboardEvent): boolean { if ( this.keybinds.altKey === "AltLeft" || this.keybinds.altKey === "AltRight" ) { return event.altKey && !event.ctrlKey; } if ( this.keybinds.altKey === "ControlLeft" || this.keybinds.altKey === "ControlRight" ) { return event.ctrlKey; } if ( this.keybinds.altKey === "ShiftLeft" || this.keybinds.altKey === "ShiftRight" ) { return event.shiftKey; } if ( this.keybinds.altKey === "MetaLeft" || this.keybinds.altKey === "MetaRight" ) { return event.metaKey; } return false; } isAltKeyPressed(event: PointerEvent): boolean { return ( ((this.keybinds.altKey === "AltLeft" || this.keybinds.altKey === "AltRight") && event.altKey) || ((this.keybinds.altKey === "ControlLeft" || this.keybinds.altKey === "ControlRight") && event.ctrlKey) || ((this.keybinds.altKey === "ShiftLeft" || this.keybinds.altKey === "ShiftRight") && event.shiftKey) || ((this.keybinds.altKey === "MetaLeft" || this.keybinds.altKey === "MetaRight") && event.metaKey) ); } }