diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 312891bc98..f05f4a6b4d 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -247,6 +247,12 @@ export class InputHandler { private suppressNextTap: boolean = false; private readonly LONG_PRESS_MS = 800; + // Nuke hold-to-deploy parameters + private readonly NUKE_INITIAL_DELAY_MS = 150; // Delay before hold-to-deploy + private readonly NUKE_LAUNCH_DELAY_MS = 90; // hold-to-deploy firerate (multiplier affects this) + private nukeHoldTimer: ReturnType | null = null; + private nukeHoldInitialDelayTimer: ReturnType | null = null; + private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); private keybinds: Record = {}; @@ -456,6 +462,7 @@ export class InputHandler { (e) => { this.onScroll(e); this.onShiftScroll(e); + this.onAltScroll(e); e.preventDefault(); }, { passive: false }, @@ -511,6 +518,7 @@ export class InputHandler { } this.longPressActive = false; this.suppressNextTap = false; + this.stopNukeHoldDeployment(); if (this.selectionBoxActive || this.multiSelectionActive) { this.selectionBoxActive = false; this.multiSelectionActive = false; @@ -579,6 +587,10 @@ export class InputHandler { return; } + if (e.altKey || e.code === this.keybinds.altKey) { + e.preventDefault(); + } + if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { e.preventDefault(); if (!this.alternateView) { @@ -696,6 +708,10 @@ export class InputHandler { return; } + if (e.altKey || e.code === this.keybinds.altKey) { + e.preventDefault(); + } + // When the meta (cmd) or ctrl key is released, any keys that were held // simultaneously will have had their keyup swallowed by the browser // (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to @@ -760,6 +776,9 @@ export class InputHandler { this.lastPointerDownY = event.clientY; this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY)); + if (this.isNukeGhostActive()) { + this.startNukeHoldDeployment(); + } // Start long-press timer for touch devices if (event.pointerType === "touch") { @@ -807,6 +826,11 @@ export class InputHandler { this.pointerDown = false; this.pointers.clear(); + if (this.nukeHoldTimer !== null || this.nukeHoldInitialDelayTimer !== null) { + this.stopNukeHoldDeployment(); + return; + } + // Clean up long-press state if (this.longPressTimer !== null) { clearTimeout(this.longPressTimer); @@ -850,7 +874,9 @@ export class InputHandler { } if (this.activeKeys.has(this.keybinds.emojiMenuModifier)) { this.suppressNextTap = false; + if (this.uiState.ghostStructure === null) { this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY)); + } return; } @@ -883,32 +909,33 @@ export class InputHandler { } private onScroll(event: WheelEvent) { - if (!event.shiftKey) { - const realCtrl = - this.activeKeys.has("ControlLeft") || - this.activeKeys.has("ControlRight"); - if (event.ctrlKey) { - if (!realCtrl) { - // Pinch-to-zoom gesture (trackpad): small deltas, amplify. - // Ignore large deltas — those are browser zoom shortcuts (cmd+/cmd-) - // which fire synthetic wheel events we don't want to handle. - if (Math.abs(event.deltaY) <= 10) { - this.eventBus.emit( - new ZoomEvent(event.x, event.y, event.deltaY * 10), - ); - } + if (event.shiftKey || event.altKey){ + return; // Shift/Alt scroll is handled separately + } + const realCtrl = + this.activeKeys.has("ControlLeft") || + this.activeKeys.has("ControlRight"); + if (event.ctrlKey) { + if (!realCtrl) { + // Pinch-to-zoom gesture (trackpad): small deltas, amplify. + // Ignore large deltas — those are browser zoom shortcuts (cmd+/cmd-) + // which fire synthetic wheel events we don't want to handle. + if (Math.abs(event.deltaY) <= 10) { + this.eventBus.emit( + new ZoomEvent(event.x, event.y, event.deltaY * 10), + ); } - // Always return when ctrlKey is set — whether it's a real ctrl scroll, - // a pinch gesture, or a browser zoom event, none should reach the - // regular scroll path below. - return; } - // Regular scroll wheel: ignore tiny residual momentum events that macOS - // keeps sending after a gesture ends (especially after browser zoom changes - // devicePixelRatio, which can cause these to accumulate into runaway zoom). - if (Math.abs(event.deltaY) < 2) return; - this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY)); + // Always return when ctrlKey is set — whether it's a real ctrl scroll, + // a pinch gesture, or a browser zoom event, none should reach the + // regular scroll path below. + return; } + // Regular scroll wheel: ignore tiny residual momentum events that macOS + // keeps sending after a gesture ends (especially after browser zoom changes + // devicePixelRatio, which can cause these to accumulate into runaway zoom). + if (Math.abs(event.deltaY) < 2) return; + this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY)); } /** @@ -943,6 +970,15 @@ export class InputHandler { } } + private onAltScroll(event: WheelEvent) { + if (event.altKey) { + const scrollValue = event.deltaY === 0 ? event.deltaX : event.deltaY; + this.setGhostStructure(this.uiState.ghostStructure, + scrollValue > 0 ? "decrease" : "increase"); + } + } + + private onPointerMove(event: PointerEvent) { if (event.button === 1) { event.preventDefault(); @@ -1029,13 +1065,69 @@ export class InputHandler { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } - private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { + private isNukeGhostActive(): boolean { + return ( + this.uiState.ghostStructure === UnitType.AtomBomb); + } + + private startNukeHoldDeployment() { + if (!this.isNukeGhostActive()) return; + if (this.nukeHoldTimer !== null || this.nukeHoldInitialDelayTimer !== null) { + return; + } + + const emit = () => { + if (!this.pointerDown || !this.isNukeGhostActive()) { + this.stopNukeHoldDeployment(); + return; + } + this.eventBus.emit(new MouseUpEvent(this.lastPointerX, this.lastPointerY)); + }; + + emit(); + + this.nukeHoldInitialDelayTimer = setTimeout(() => { + this.nukeHoldInitialDelayTimer = null; + if (!this.pointerDown || !this.isNukeGhostActive()) { + this.stopNukeHoldDeployment(); + return; + } + this.nukeHoldTimer = setInterval(emit, this.NUKE_LAUNCH_DELAY_MS); + }, this.NUKE_INITIAL_DELAY_MS); + } + + private stopNukeHoldDeployment() { + if (this.nukeHoldInitialDelayTimer !== null) { + clearTimeout(this.nukeHoldInitialDelayTimer); + this.nukeHoldInitialDelayTimer = null; + } + if (this.nukeHoldTimer !== null) { + clearInterval(this.nukeHoldTimer); + this.nukeHoldTimer = null; + } + } + + private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null, source: "increase" | "decrease" | "hotkey" = "hotkey") { + this.stopNukeHoldDeployment(); if ( this.uiState.ghostStructure === ghostStructure && ghostStructure !== null ) { - this.uiState.upgradeMultiplier = - this.uiState.upgradeMultiplier === 1 ? 5 : 1; + const currentMultiplier = this.uiState.upgradeMultiplier ?? 1; + if (source === "hotkey") { + this.uiState.upgradeMultiplier = + currentMultiplier === 1 ? 5 : currentMultiplier + 5; + return; + } + if (source === "increase"){ + this.uiState.upgradeMultiplier = + currentMultiplier + 1; + } + if (source === "decrease"){ + this.uiState.upgradeMultiplier = + currentMultiplier > 1 ? currentMultiplier - 1 : 1; + } + } else { this.uiState.upgradeMultiplier = 1; this.uiState.ghostStructure = ghostStructure; diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 5d7c86aee3..64c67e4b69 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -3,6 +3,7 @@ import { ConfirmGhostStructureEvent, ContextMenuEvent, InputHandler, + MouseUpEvent, UnitSelectionEvent, WarshipSelectionBoxCancelEvent, WarshipSelectionBoxCompleteEvent, @@ -660,6 +661,70 @@ describe("InputHandler AutoUpgrade", () => { }); }); + describe("Alt key default prevention", () => { + test("prevents the browser's default action when Alt is pressed", () => { + const preventDefaultSpy = vi.spyOn( + KeyboardEvent.prototype, + "preventDefault", + ); + + window.dispatchEvent(new KeyboardEvent("keydown", { code: "AltLeft" })); + + expect(preventDefaultSpy).toHaveBeenCalled(); + preventDefaultSpy.mockRestore(); + }); + }); + + describe("Nuke click-and-hold deployment", () => { + test("fires repeated MouseUpEvents while a nuke ghost is held and suppresses the release repeat", () => { + vi.useFakeTimers(); + try { + const mockEmit = vi.spyOn(eventBus, "emit"); + inputHandler["uiState"].ghostStructure = UnitType.AtomBomb; + + inputHandler["onPointerDown"]( + new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 200, + pointerId: 1, + }), + ); + inputHandler["onPointerMove"]( + new PointerEvent("pointermove", { + button: 0, + clientX: 150, + clientY: 250, + pointerId: 1, + }), + ); + + vi.advanceTimersByTime(250); + + const mouseUpCalls = mockEmit.mock.calls.filter( + ([event]) => event instanceof MouseUpEvent, + ); + expect(mouseUpCalls.length).toBeGreaterThanOrEqual(2); + + inputHandler["onPointerUp"]( + new PointerEvent("pointerup", { + button: 0, + clientX: 150, + clientY: 250, + pointerId: 1, + }), + ); + + const afterRelease = mockEmit.mock.calls.filter( + ([event]) => event instanceof MouseUpEvent, + ); + expect(afterRelease).toHaveLength(mouseUpCalls.length); + } finally { + vi.useRealTimers(); + } + }); + }); + describe("Numpad number keys for build keybinds", () => { beforeEach(() => { inputHandler.destroy(); @@ -708,6 +773,20 @@ describe("InputHandler AutoUpgrade", () => { expect(inputHandler["uiState"].ghostStructure).toBeNull(); }); + + test("repeated taps increase the build multiplier by 5 each time", () => { + const uiState = inputHandler["uiState"]; + + inputHandler["setGhostStructure"](UnitType.AtomBomb); + expect(uiState.ghostStructure).toBe(UnitType.AtomBomb); + expect(uiState.upgradeMultiplier).toBe(1); + + inputHandler["setGhostStructure"](UnitType.AtomBomb); + expect(uiState.upgradeMultiplier).toBe(5); + + inputHandler["setGhostStructure"](UnitType.AtomBomb); + expect(uiState.upgradeMultiplier).toBe(10); + }); }); describe("Digit keys still set ghost structure when bound to Numpad", () => {