diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 86d25abb59..c4174a7228 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -313,6 +313,7 @@ export class InputHandler { * `initializePointerAndKeyboardEvents()`. */ private listenerAbort: AbortController | null = null; private activeKeys = new Set(); + private shiftHeld = false; private keybinds: Record = {}; private keybindAndEvent: Array<[string, KeybindEntry]> = []; private coordinateGridEnabled = false; @@ -663,6 +664,7 @@ export class InputHandler { "blur", () => { this.activeKeys.clear(); + this.shiftHeld = false; if (this.alternateView) { this.alternateView = false; this.eventBus.emit(new AlternateViewEvent(false)); @@ -683,29 +685,34 @@ export class InputHandler { let deltaX = 0; let deltaY = 0; - // Skip if select warship modifier is held down - if (this.activeKeys.has(this.keybinds.boxSelectWarships)) { + // Skip movement if selection box drag is active or a non-Shift warship selector is held + if ( + this.selectionBoxActive || + (this.keybinds.boxSelectWarships !== "ShiftLeft" && + this.keybinds.boxSelectWarships !== "ShiftRight" && + this.activeKeys.has(this.keybinds.boxSelectWarships)) + ) { return; } if ( - this.activeKeys.has(this.keybinds.moveUp) || - this.activeKeys.has("ArrowUp") + this.isContinuousActionActive(this.keybinds.moveUp) || + this.isContinuousActionActive(this.keybinds.moveUpArrow) ) deltaY += this.PAN_SPEED; if ( - this.activeKeys.has(this.keybinds.moveDown) || - this.activeKeys.has("ArrowDown") + this.isContinuousActionActive(this.keybinds.moveDown) || + this.isContinuousActionActive(this.keybinds.moveDownArrow) ) deltaY -= this.PAN_SPEED; if ( - this.activeKeys.has(this.keybinds.moveLeft) || - this.activeKeys.has("ArrowLeft") + this.isContinuousActionActive(this.keybinds.moveLeft) || + this.isContinuousActionActive(this.keybinds.moveLeftArrow) ) deltaX += this.PAN_SPEED; if ( - this.activeKeys.has(this.keybinds.moveRight) || - this.activeKeys.has("ArrowRight") + this.isContinuousActionActive(this.keybinds.moveRight) || + this.isContinuousActionActive(this.keybinds.moveRightArrow) ) deltaX -= this.PAN_SPEED; @@ -717,16 +724,16 @@ export class InputHandler { const cy = window.innerHeight / 2; if ( - this.activeKeys.has(this.keybinds.zoomOut) || - this.activeKeys.has("Minus") || - this.activeKeys.has("NumpadSubtract") + this.isContinuousActionActive(this.keybinds.zoomOut) || + this.isContinuousActionActive(this.keybinds.zoomOutMinus) || + this.isContinuousActionActive(this.keybinds.zoomOutNumpad) ) { this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED)); } if ( - this.activeKeys.has(this.keybinds.zoomIn) || - this.activeKeys.has("Equal") || - this.activeKeys.has("NumpadAdd") + this.isContinuousActionActive(this.keybinds.zoomIn) || + this.isContinuousActionActive(this.keybinds.zoomInEqual) || + this.isContinuousActionActive(this.keybinds.zoomInNumpad) ) { this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED)); } @@ -827,41 +834,42 @@ export class InputHandler { e.code === "NumpadAdd" || e.code === "NumpadSubtract"); + const continuousBindings = this.getContinuousBindings(); + const isConfiguredKeybind = - Object.values(this.keybinds).includes(e.code) || - this.keybindAndEvent.some(([k]) => this.keybindMatchesEvent(e, k)); + Object.values(this.keybinds).some((k) => + this.keybindMatchesEvent(e, k), + ) || + this.keybindAndEvent.some(([k]) => this.keybindMatchesEvent(e, k)) || + continuousBindings.some((k) => this.keybindMatchesEvent(e, k)); if (isConfiguredKeybind && !isBrowserZoomCombo) { e.preventDefault(); } + this.shiftHeld = e.shiftKey; + + const continuousPhysicalCodes = continuousBindings.map( + (k) => this.parseKeybind(k).code, + ); + if ( !isBrowserZoomCombo && - [ - this.keybinds.moveUp, - this.keybinds.moveDown, - this.keybinds.moveLeft, - this.keybinds.moveRight, - this.keybinds.zoomOut, - this.keybinds.zoomIn, - "ArrowUp", - "ArrowLeft", - "ArrowDown", - "ArrowRight", - "Minus", - "Equal", - "NumpadAdd", - "NumpadSubtract", - this.keybinds.attackRatioDown, - this.keybinds.attackRatioUp, - this.keybinds.centerCamera, - "ControlLeft", - "ControlRight", - this.keybinds.boxSelectWarships, - this.keybinds.emojiMenuModifier, - this.keybinds.buildMenuModifier, - this.keybinds.altKey, - ].includes(e.code) + (continuousPhysicalCodes.includes(e.code) || + [ + "ShiftLeft", + "ShiftRight", + "ControlLeft", + "ControlRight", + "AltLeft", + "AltRight", + "MetaLeft", + "MetaRight", + this.keybinds.boxSelectWarships, + this.keybinds.emojiMenuModifier, + this.keybinds.buildMenuModifier, + this.keybinds.altKey, + ].includes(e.code)) ) { this.activeKeys.add(e.code); } @@ -885,6 +893,8 @@ export class InputHandler { return; } + this.shiftHeld = e.shiftKey; + // 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 @@ -899,8 +909,6 @@ export class InputHandler { this.activeKeys.delete("Equal"); this.activeKeys.delete("NumpadAdd"); this.activeKeys.delete("NumpadSubtract"); - this.activeKeys.delete(this.keybinds.zoomIn); - this.activeKeys.delete(this.keybinds.zoomOut); } if (this.emojiTableOpen) { @@ -1377,6 +1385,60 @@ export class InputHandler { return e.code === parsed.code && e.shiftKey === parsed.shift; } + private isShiftHeld(): boolean { + return this.shiftHeld; + } + + /** + * Evaluates whether a continuous movement or zoom action is currently active. + * + * A binding is active when its physical key is held in `activeKeys` and its + * Shift-modifier condition is satisfied: + * - Shift-modified bindings (e.g. "Shift+ArrowUp") require Shift to be held. + * Releasing Shift while the physical key remains held stops the action. + * Pressing Shift while the physical key is already held activates the action. + * - Unmodified bindings (e.g. "ArrowUp") require Shift NOT to be held. + * This ensures unmodified controls do not activate when Shift is held for + * other actions (such as warship box selection). + * - Unbound ("Null" or undefined) bindings never activate. + */ + private isContinuousActionActive(keybindValue: string | undefined): boolean { + if (!keybindValue || keybindValue === "Null") { + return false; + } + const { shift, code } = this.parseKeybind(keybindValue); + if (!this.activeKeys.has(code)) { + return false; + } + const shiftHeld = this.isShiftHeld(); + return shift ? shiftHeld : !shiftHeld; + } + + private getContinuousBindings(): string[] { + const bindings = [ + this.keybinds.moveUp, + this.keybinds.moveUpArrow, + this.keybinds.moveDown, + this.keybinds.moveDownArrow, + this.keybinds.moveLeft, + this.keybinds.moveLeftArrow, + this.keybinds.moveRight, + this.keybinds.moveRightArrow, + this.keybinds.zoomOut, + this.keybinds.zoomOutMinus, + this.keybinds.zoomOutNumpad, + this.keybinds.zoomIn, + this.keybinds.zoomInEqual, + this.keybinds.zoomInNumpad, + this.keybinds.attackRatioDown, + this.keybinds.attackRatioUp, + this.keybinds.centerCamera, + ]; + return bindings.filter( + (k): k is string => typeof k === "string" && k !== "" && k !== "Null", + ); + } + /** * Extracts the digit character from KeyboardEvent.code. * Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and @@ -1557,6 +1619,7 @@ export class InputHandler { // renderer has already removed. this.resetPointerState(); this.activeKeys.clear(); + this.shiftHeld = false; this.emojiTableOpen = false; this.emojiKeysDown.clear(); this.keybindAndEvent = []; diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index a315a6ca89..a91f76aed3 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -1589,6 +1589,26 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + + + + + + + +

@@ -1625,6 +1665,16 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + + + + + + + + `; } diff --git a/src/client/UserSettings.ts b/src/client/UserSettings.ts index 3f064d4f7d..57d088f54c 100644 --- a/src/client/UserSettings.ts +++ b/src/client/UserSettings.ts @@ -44,11 +44,19 @@ export function getDefaultKeybinds(isMac: boolean): Record { swapDirection: "KeyU", zoomOut: "KeyQ", zoomIn: "KeyE", + zoomOutMinus: "Minus", + zoomOutNumpad: "NumpadSubtract", + zoomInEqual: "Equal", + zoomInNumpad: "NumpadAdd", centerCamera: "KeyC", moveUp: "KeyW", moveLeft: "KeyA", moveDown: "KeyS", moveRight: "KeyD", + moveUpArrow: "ArrowUp", + moveLeftArrow: "ArrowLeft", + moveDownArrow: "ArrowDown", + moveRightArrow: "ArrowRight", buildMenuModifier: isMac ? "MetaLeft" : "ControlLeft", emojiMenuModifier: "AltLeft", boxSelectWarships: "ShiftLeft", diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 90c5a7f534..a3754b9097 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -23,6 +23,7 @@ import { WarshipSelectionBoxCancelEvent, WarshipSelectionBoxCompleteEvent, WarshipSelectionBoxUpdateEvent, + ZoomEvent, } from "../src/client/InputHandler"; import { Platform } from "../src/client/Platform"; import { UIState } from "../src/client/UIState"; @@ -922,6 +923,21 @@ describe("InputHandler AutoUpgrade", () => { }); describe("Keybinds JSON parsing", () => { + test("loads arrow movement and zoom fallback keys as configurable defaults", () => { + inputHandler.initialize(); + + expect((inputHandler as any).keybinds.moveUpArrow).toBe("ArrowUp"); + expect((inputHandler as any).keybinds.moveLeftArrow).toBe("ArrowLeft"); + expect((inputHandler as any).keybinds.moveDownArrow).toBe("ArrowDown"); + expect((inputHandler as any).keybinds.moveRightArrow).toBe("ArrowRight"); + expect((inputHandler as any).keybinds.zoomOutMinus).toBe("Minus"); + expect((inputHandler as any).keybinds.zoomOutNumpad).toBe( + "NumpadSubtract", + ); + expect((inputHandler as any).keybinds.zoomInEqual).toBe("Equal"); + expect((inputHandler as any).keybinds.zoomInNumpad).toBe("NumpadAdd"); + }); + test("parses nested object values and flattens them to strings", () => { const nested = { moveUp: { key: "moveUp", value: "KeyZ" }, @@ -2200,4 +2216,561 @@ describe("InputHandler teardown (OPE-411)", () => { vi.useRealTimers(); } }); + + describe("Shift-modified hold-to-pan and hold-to-zoom keybinds", () => { + let handler: InputHandler; + let settings: UserSettings; + let bus: EventBus; + + beforeEach(() => { + vi.useFakeTimers(); + settings = new UserSettings(); + settings.removeCached(KEYBINDS_KEY, false); + bus = new EventBus(); + handler = new InputHandler( + { + inSpawnPhase: () => false, + myPlayer: () => ({ isAlive: () => true }), + } as any, + { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + upgradeMultiplier: 1, + } as any, + document.createElement("canvas"), + bus, + ); + }); + + afterEach(() => { + handler.destroy(); + vi.useRealTimers(); + }); + + test("pans up when moveUp is bound to Shift+KeyW", () => { + settings.setKeybinds({ moveUp: "Shift+KeyW" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }), + ); + + vi.advanceTimersByTime(10); + const dragCalls = emitSpy.mock.calls.filter( + (c: unknown[]) => c[0] instanceof DragEvent, + ); + expect(dragCalls.length).toBeGreaterThan(0); + expect((dragCalls[0][0] as DragEvent).deltaY).toBe(handler["PAN_SPEED"]); + + // Releasing KeyW stops panning + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyW", shiftKey: true }), + ); + emitSpy.mockClear(); + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + }); + + test("pans in all 4 directions with Shift-modified bindings", () => { + settings.setKeybinds({ + moveUp: "Shift+KeyW", + moveDown: "Shift+KeyS", + moveLeft: "Shift+KeyA", + moveRight: "Shift+KeyD", + }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + + // Down + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyS", shiftKey: true }), + ); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof DragEvent && + (c[0] as DragEvent).deltaY === -handler["PAN_SPEED"], + ), + ).toBe(true); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyS", shiftKey: true }), + ); + emitSpy.mockClear(); + + // Left + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyA", shiftKey: true }), + ); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof DragEvent && + (c[0] as DragEvent).deltaX === handler["PAN_SPEED"], + ), + ).toBe(true); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyA", shiftKey: true }), + ); + emitSpy.mockClear(); + + // Right + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyD", shiftKey: true }), + ); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof DragEvent && + (c[0] as DragEvent).deltaX === -handler["PAN_SPEED"], + ), + ).toBe(true); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyD", shiftKey: true }), + ); + }); + + test("pans with Shift-modified arrow bindings (moveUpArrow etc.)", () => { + settings.setKeybinds({ + moveUpArrow: "Shift+ArrowUp", + moveDownArrow: "Shift+ArrowDown", + moveLeftArrow: "Shift+ArrowLeft", + moveRightArrow: "Shift+ArrowRight", + }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ArrowUp", shiftKey: true }), + ); + + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof DragEvent && + (c[0] as DragEvent).deltaY === handler["PAN_SPEED"], + ), + ).toBe(true); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "ArrowUp", shiftKey: true }), + ); + }); + + test("an unmodified press does NOT activate a Shift-only binding", () => { + settings.setKeybinds({ moveUp: "Shift+KeyW" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }), + ); + + vi.advanceTimersByTime(20); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + }); + + test("releasing Shift while the physical key remains held stops the action", () => { + settings.setKeybinds({ moveUp: "Shift+KeyW" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }), + ); + + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(true); + + // Release Shift while KeyW is still held + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "ShiftLeft", shiftKey: false }), + ); + emitSpy.mockClear(); + + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + }); + + test("pressing Shift while the physical key is already held activates the binding", () => { + settings.setKeybinds({ moveUp: "Shift+KeyW" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + // Press KeyW first without Shift + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }), + ); + + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + + // Now press Shift while KeyW is still held + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof DragEvent && + (c[0] as DragEvent).deltaY === handler["PAN_SPEED"], + ), + ).toBe(true); + }); + + test("zooms with Shift-modified zoom bindings (zoomIn, zoomOut)", () => { + settings.setKeybinds({ + zoomOut: "Shift+KeyQ", + zoomIn: "Shift+KeyE", + zoomOutNumpad: "Shift+NumpadSubtract", + zoomInNumpad: "Shift+NumpadAdd", + }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyQ", shiftKey: true }), + ); + + vi.advanceTimersByTime(5); + const zoomOutCalls = emitSpy.mock.calls.filter( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === handler["ZOOM_SPEED"], + ); + expect(zoomOutCalls.length).toBeGreaterThan(0); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyQ", shiftKey: true }), + ); + emitSpy.mockClear(); + + // zoomOutNumpad + window.dispatchEvent( + new KeyboardEvent("keydown", { + code: "NumpadSubtract", + shiftKey: true, + }), + ); + vi.advanceTimersByTime(5); + const zoomOutNumpadCalls = emitSpy.mock.calls.filter( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === handler["ZOOM_SPEED"], + ); + expect(zoomOutNumpadCalls.length).toBeGreaterThan(0); + + window.dispatchEvent( + new KeyboardEvent("keyup", { + code: "NumpadSubtract", + shiftKey: true, + }), + ); + emitSpy.mockClear(); + + // Zoom in + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyE", shiftKey: true }), + ); + vi.advanceTimersByTime(5); + const zoomInCalls = emitSpy.mock.calls.filter( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"], + ); + expect(zoomInCalls.length).toBeGreaterThan(0); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyE", shiftKey: true }), + ); + emitSpy.mockClear(); + + // zoomInNumpad + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "NumpadAdd", shiftKey: true }), + ); + vi.advanceTimersByTime(5); + const zoomInNumpadCalls = emitSpy.mock.calls.filter( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"], + ); + expect(zoomInNumpadCalls.length).toBeGreaterThan(0); + }); + + test("zooms with Shift-modified alternate zoom bindings (Equal, Minus)", () => { + settings.setKeybinds({ + zoomOutMinus: "Shift+Minus", + zoomInEqual: "Shift+Equal", + }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "Minus", shiftKey: true }), + ); + + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === handler["ZOOM_SPEED"], + ), + ).toBe(true); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Minus", shiftKey: true }), + ); + emitSpy.mockClear(); + + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "Equal", shiftKey: true }), + ); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"], + ), + ).toBe(true); + }); + + test("unmodified bindings work normally and do not trigger when Shift is held", () => { + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + // Plain KeyW pans + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }), + ); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(true); + + // Now press Shift (as in warship box selection mode) -> panning stops + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + emitSpy.mockClear(); + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + + // Release Shift -> panning resumes + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "ShiftLeft", shiftKey: false }), + ); + emitSpy.mockClear(); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(true); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyW", shiftKey: false }), + ); + }); + + test("browser zoom shortcuts (Ctrl/Cmd + Minus/Equal) do not trigger game zoom or preventDefault", () => { + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + const event = new KeyboardEvent("keydown", { + code: "Minus", + ctrlKey: true, + }); + const preventSpy = vi.spyOn(event, "preventDefault"); + window.dispatchEvent(event); + + vi.advanceTimersByTime(10); + expect(preventSpy).not.toHaveBeenCalled(); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof ZoomEvent), + ).toBe(false); + expect(handler["activeKeys"].has("Minus")).toBe(false); + }); + + test("window blur clears held action state", () => { + settings.setKeybinds({ moveUp: "Shift+KeyW" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }), + ); + + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(true); + + // Blur window + window.dispatchEvent(new Event("blur")); + emitSpy.mockClear(); + + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + expect(handler["activeKeys"].size).toBe(0); + }); + + test("unbound ('Null') bindings do not trigger continuous action", () => { + settings.setKeybinds({ moveUp: "Null" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }), + ); + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }), + ); + + vi.advanceTimersByTime(10); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + }); + + test("a held KeyE continues to emit zoom after Control or Meta is released", () => { + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + // Hold KeyE to zoom in + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyE", shiftKey: false }), + ); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"], + ), + ).toBe(true); + + // Press and release Control (e.g. modifier tapped or shortcuts used) + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ControlLeft", ctrlKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "ControlLeft", ctrlKey: false }), + ); + emitSpy.mockClear(); + + // KeyE is still held down, zoom should continue emitting + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof ZoomEvent && + (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"], + ), + ).toBe(true); + expect(handler["activeKeys"].has("KeyE")).toBe(true); + + // Release KeyE + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyE", shiftKey: false }), + ); + emitSpy.mockClear(); + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof ZoomEvent), + ).toBe(false); + }); + + test("holding ShiftRight and pressing another key does NOT add ShiftLeft to activeKeys", () => { + settings.setKeybinds({ moveUp: "Shift+KeyW" }); + handler.initialize(); + + const emitSpy = vi.spyOn(bus, "emit"); + // Press physical Right Shift + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "ShiftRight", shiftKey: true }), + ); + // Press KeyW with shiftKey: true + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }), + ); + + // activeKeys must not contain ShiftLeft (which would trigger boxSelectWarships) + expect(handler["activeKeys"].has("ShiftLeft")).toBe(false); + expect(handler["activeKeys"].has("ShiftRight")).toBe(true); + expect(handler["activeKeys"].has("KeyW")).toBe(true); + + vi.advanceTimersByTime(5); + expect( + emitSpy.mock.calls.some( + (c: unknown[]) => + c[0] instanceof DragEvent && + (c[0] as DragEvent).deltaY === handler["PAN_SPEED"], + ), + ).toBe(true); + + // Release ShiftRight while KeyW is held + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "ShiftRight", shiftKey: false }), + ); + expect(handler["activeKeys"].has("ShiftRight")).toBe(false); + emitSpy.mockClear(); + vi.advanceTimersByTime(10); + // Shift action should stop + expect( + emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + }); + + test("rebuilds continuous action bindings when keybind settings change", () => { + handler.initialize(); + expect(handler["keybinds"].moveUpArrow).toBe("ArrowUp"); + + settings.setKeybinds({ moveUpArrow: "Shift+ArrowUp" }); + expect(handler["keybinds"].moveUpArrow).toBe("Shift+ArrowUp"); + }); + }); }); diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts index 4c538f914e..4a5875ce9a 100644 --- a/tests/UserSettings.test.ts +++ b/tests/UserSettings.test.ts @@ -598,6 +598,14 @@ describe("getDefaultKeybinds", () => { expect(keybinds.quickChat).toBe("KeyR"); expect(keybinds.targetPlayer).toBe("KeyN"); expect(keybinds.buildMenuModifier).toBe("ControlLeft"); + expect(keybinds.moveUpArrow).toBe("ArrowUp"); + expect(keybinds.moveLeftArrow).toBe("ArrowLeft"); + expect(keybinds.moveDownArrow).toBe("ArrowDown"); + expect(keybinds.moveRightArrow).toBe("ArrowRight"); + expect(keybinds.zoomOutMinus).toBe("Minus"); + expect(keybinds.zoomOutNumpad).toBe("NumpadSubtract"); + expect(keybinds.zoomInEqual).toBe("Equal"); + expect(keybinds.zoomInNumpad).toBe("NumpadAdd"); }); it("never gives two actions the same letter key, except shared pairs", () => { @@ -707,6 +715,28 @@ describe("UserSettings keybinds with new defaults", () => { ).toBeUndefined(); }); + it("unbinds the new arrow and zoom defaults when saved bindings already use their keys", () => { + store({ + boatAttack: { value: "ArrowUp", key: "ArrowUp" }, + attackRatioUp: { value: "Equal", key: "=" }, + }); + const settings = new UserSettings(); + const keybinds = settings.keybinds(false); + + expect(keybinds.boatAttack).toBe("ArrowUp"); + expect(keybinds.moveUpArrow).toBeUndefined(); + expect(keybinds.attackRatioUp).toBe("Equal"); + expect(keybinds.zoomInEqual).toBeUndefined(); + expect(settings.parsedUserKeybinds().moveUpArrow).toEqual({ + value: "Null", + key: "", + }); + expect(settings.parsedUserKeybinds().zoomInEqual).toEqual({ + value: "Null", + key: "", + }); + }); + it("keeps quick chat on R when the player saved reset graphics there", () => { store({ resetGfx: { value: "KeyR", key: "r" } }); const keybinds = new UserSettings().keybinds(false);