diff --git a/resources/lang/en.json b/resources/lang/en.json index 8df63ae837..b472daab00 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -2045,6 +2045,8 @@ "build_port_desc": "Build a Port under your cursor.", "build_sam_launcher": "Build SAM Launcher", "build_sam_launcher_desc": "Build a SAM Launcher under your cursor.", + "build_scroll_modifier": "Build Scroll Modifier", + "build_scroll_modifier_desc": "Hold this key and scroll to change build amount.", "build_warship": "Build Warship", "build_warship_desc": "Build a Warship under your cursor.", "camera_movement": "Camera Movement", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 09852b58ba..c7bbfeb9d3 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1,10 +1,16 @@ import { EventBus, GameEvent } from "../core/EventBus"; -import { PlayerBuildableUnitType, UnitType } from "../core/game/Game"; + +import { + MAX_UPGRADE_AMOUNT, + PlayerBuildableUnitType, + UnitType, +} from "../core/game/Game"; import { KEYBINDS_KEY, USER_SETTINGS_CHANGED_EVENT, UserSettings, } from "../core/game/UserSettings"; + import { Platform } from "./Platform"; import { UIState } from "./UIState"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; @@ -481,7 +487,6 @@ export class InputHandler { "wheel", (e) => { this.onScroll(e); - this.onShiftScroll(e); e.preventDefault(); }, { passive: false }, @@ -604,6 +609,12 @@ export class InputHandler { if (isTextInput && e.code !== "Escape") { return; } + // for hotkey usage, mostly an issue on Firefox. + // we specifically prevent the Left Alt key to avoid browser menu triggers, + // but allow Right Alt (often AltGr) to preserve international character input. + if (e.code === "AltLeft") { + e.preventDefault(); + } if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { e.preventDefault(); @@ -701,6 +712,7 @@ export class InputHandler { this.keybinds.boxSelectWarships, this.keybinds.emojiMenuModifier, this.keybinds.buildMenuModifier, + this.keybinds.buildScrollModifier, this.keybinds.altKey, ].includes(e.code) ) { @@ -721,6 +733,12 @@ export class InputHandler { if (isTextInput && !this.activeKeys.has(e.code)) { return; } + // for hotkey usage, mostly an issue on Firefox. + // we specifically prevent the Left Alt key to avoid browser menu triggers, + // but allow Right Alt (often AltGr) to preserve international character input. + if (e.code === "AltLeft") { + 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 @@ -909,32 +927,54 @@ 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), - ); - } + const scrollValue = event.deltaY === 0 ? event.deltaX : event.deltaY; + // The hardcoded shift scroll attack ratio changing takes priority. + if (event.shiftKey) { + const increment = this.userSettings.attackRatioIncrement(); + const ratio = scrollValue > 0 ? -increment : increment; + this.eventBus.emit(new AttackRatioEvent(ratio)); + return; + } + + if (this.activeKeys.has(this.keybinds.buildScrollModifier)) { + if (Math.abs(scrollValue) > 2) { + this.setGhostStructure( + this.uiState.ghostStructure, + scrollValue > 0 ? "decrease" : "increase", + ); + } + // Prevent zooming if the build scroll modifier is active. + return; + } + + // Any alt also blocks zooming, to match behavior of Ctrl / Shift + if (event.altKey) { + return; + } + + 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)); } /** @@ -960,15 +1000,6 @@ export class InputHandler { this.eventBus.emit(new ZoomEvent(event.clientX, event.clientY, delta)); } - 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(); @@ -1055,13 +1086,38 @@ export class InputHandler { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } - private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { + private setGhostStructure( + ghostStructure: PlayerBuildableUnitType | null, + source: "increase" | "decrease" | "hotkey" = "hotkey", + ) { if ( this.uiState.ghostStructure === ghostStructure && ghostStructure !== null ) { - this.uiState.upgradeMultiplier = - this.uiState.upgradeMultiplier === 1 ? 5 : 1; + const currentMultiplier = this.uiState.upgradeMultiplier ?? 1; + switch (source) { + case "hotkey": + // first jump goes 1 -> 5 as before + this.uiState.upgradeMultiplier = + currentMultiplier === 1 ? 5 : currentMultiplier + 5; + // allow going back to 1 quickly by using hotkey. + if (this.uiState.upgradeMultiplier > MAX_UPGRADE_AMOUNT) { + this.uiState.upgradeMultiplier = 1; + } + break; + case "increase": + this.uiState.upgradeMultiplier = currentMultiplier + 1; + // clamp mouse wheel users to max + if (this.uiState.upgradeMultiplier > MAX_UPGRADE_AMOUNT) { + this.uiState.upgradeMultiplier = MAX_UPGRADE_AMOUNT; + } + break; + case "decrease": + // decrease only if above 1, we do not clear ghosts with scrollDown + this.uiState.upgradeMultiplier = + currentMultiplier > 1 ? currentMultiplier - 1 : 1; + break; + } } else { this.uiState.upgradeMultiplier = 1; this.uiState.ghostStructure = ghostStructure; diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 108fad41f5..855f22b7a3 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -247,7 +247,13 @@ export class UserSettingModal extends BaseModal { actions: [string, string]; keyPrefix: string; }> = [ - { actions: ["emojiMenuModifier", "altKey"], keyPrefix: "Alt" }, + // This is a tad ugly, but it's the only solution I found. + { + actions: ["buildScrollModifier", "emojiMenuModifier"], + keyPrefix: "Alt", + }, + { actions: ["altKey", "emojiMenuModifier"], keyPrefix: "Alt" }, + { actions: ["buildScrollModifier", "altKey"], keyPrefix: "Alt" }, { actions: ["boxSelectWarships", "shiftKey"], keyPrefix: "Shift" }, ]; @@ -1228,6 +1234,16 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + + { moveDown: "KeyS", moveRight: "KeyD", buildMenuModifier: isMac ? "MetaLeft" : "ControlLeft", + buildScrollModifier: "AltLeft", emojiMenuModifier: "AltLeft", boxSelectWarships: "ShiftLeft", shiftKey: "ShiftLeft", diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 5d7c86aee3..a98d1185e7 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1,4 +1,5 @@ import { + AttackRatioEvent, AutoUpgradeEvent, ConfirmGhostStructureEvent, ContextMenuEvent, @@ -11,7 +12,7 @@ import { import { UIState } from "../src/client/UIState"; import { GameView, PlayerView, UnitView } from "../src/client/view"; import { EventBus } from "../src/core/EventBus"; -import { UnitType } from "../src/core/game/Game"; +import { MAX_UPGRADE_AMOUNT, UnitType } from "../src/core/game/Game"; import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings"; class MockPointerEvent { @@ -660,6 +661,30 @@ describe("InputHandler AutoUpgrade", () => { }); }); + describe("Alt key default prevention", () => { + test("prevents the browser's default action when leftAlt is pressed", () => { + const event = new KeyboardEvent("keydown", { + code: "AltLeft", + altKey: true, + cancelable: true, + }); + + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + test("does not prevent the browser's default action when rightAlt is pressed", () => { + const event = new KeyboardEvent("keydown", { + code: "AltRight", + altKey: true, + cancelable: true, + }); + + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + }); + describe("Numpad number keys for build keybinds", () => { beforeEach(() => { inputHandler.destroy(); @@ -1294,3 +1319,155 @@ describe("InputHandler right-click cancels unit selection (#4692)", () => { ).toBe(true); }); }); + +describe("GhostStructure Hotkeys tapping/Scrolling", () => { + let inputHandler: InputHandler; + let eventBus: EventBus; + let mockCanvas: HTMLCanvasElement; + let uiState: UIState; + let testSettings: UserSettings; + let mockGameView: GameView; + + beforeEach(() => { + mockGameView = { + inSpawnPhase: () => false, + myPlayer: () => ({ isAlive: () => true }), + } as GameView; + testSettings = new UserSettings(); + testSettings.removeCached(KEYBINDS_KEY, false); + mockCanvas = document.createElement("canvas"); + eventBus = new EventBus(); + uiState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + upgradeMultiplier: 1, + } as UIState; + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + // Intentionally non-existing keys as keybinds. + testSettings.setKeybinds({ + buildAtomBomb: "F14", + buildScrollModifier: "F13", + }); + inputHandler.initialize(); + }); + + afterEach(() => { + inputHandler.destroy(); + }); + + test("repeated hotkey taps increase the build multiplier by 5 each time and loop", () => { + // First tap sets ghostStructure and resets multiplier to 1 + window.dispatchEvent(new KeyboardEvent("keyup", { code: "F14" })); + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.AtomBomb); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(1); + + // Second tap: 1 -> 5 + window.dispatchEvent(new KeyboardEvent("keyup", { code: "F14" })); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(5); + + // Third tap: 5 -> 10 + window.dispatchEvent(new KeyboardEvent("keyup", { code: "F14" })); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(10); + + // Verify loop back to 1 after exceeding MAX_UPGRADE_AMOUNT + inputHandler["uiState"].upgradeMultiplier = MAX_UPGRADE_AMOUNT; + window.dispatchEvent(new KeyboardEvent("keyup", { code: "F14" })); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(1); + }); + + test("wheel scroll up increases upgrade multiplier", () => { + uiState.ghostStructure = UnitType.City; + // Use the actual buildScrollModifier keybind + inputHandler["activeKeys"].add( + inputHandler["keybinds"].buildScrollModifier, + ); + + mockCanvas.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, // Scroll up + altKey: false, + }), + ); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(2); + }); + + test("wheel scroll down decreases upgrade multiplier", () => { + uiState.ghostStructure = UnitType.City; + uiState.upgradeMultiplier = 5; + inputHandler["activeKeys"].add( + inputHandler["keybinds"].buildScrollModifier, + ); + + mockCanvas.dispatchEvent( + new WheelEvent("wheel", { + deltaY: 100, // Scroll down + altKey: false, + }), + ); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(4); + }); + + test("wheel scroll doesn't go below 1", () => { + uiState.ghostStructure = UnitType.City; + uiState.upgradeMultiplier = 1; + inputHandler["activeKeys"].add( + inputHandler["keybinds"].buildScrollModifier, + ); + + mockCanvas.dispatchEvent( + new WheelEvent("wheel", { + deltaY: 100, + altKey: false, + }), + ); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(1); + }); + + test("wheel scroll doesn't exceed MAX_UPGRADE_AMOUNT", () => { + uiState.ghostStructure = UnitType.City; + uiState.upgradeMultiplier = MAX_UPGRADE_AMOUNT; + inputHandler["activeKeys"].add( + inputHandler["keybinds"].buildScrollModifier, + ); + + mockCanvas.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, + altKey: false, + }), + ); + expect(inputHandler["uiState"].upgradeMultiplier).toBe(MAX_UPGRADE_AMOUNT); + }); + + test("shift + scroll wheel changes attack ratio", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + + mockCanvas.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, + shiftKey: true, + }), + ); + + mockCanvas.dispatchEvent( + new WheelEvent("wheel", { + deltaY: 100, + shiftKey: true, + }), + ); + + const ratioEvents = mockEmit.mock.calls + .map((call) => call[0]) + .filter((e) => e instanceof AttackRatioEvent) as AttackRatioEvent[]; + + expect(ratioEvents.length).toBe(2); + expect(ratioEvents[0].attackRatio).toBeGreaterThan(0); + expect(ratioEvents[1].attackRatio).toBeLessThan(0); + }); +}); diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts index f77b73647c..2f78713314 100644 --- a/tests/UserSettings.test.ts +++ b/tests/UserSettings.test.ts @@ -571,6 +571,10 @@ describe("getDefaultKeybinds", () => { expect(keybinds.resetGfx).toBe("KeyR"); expect(keybinds.selectAllWarships).toBe("KeyF"); expect(keybinds.buildMenuModifier).toBe("ControlLeft"); + expect(keybinds.buildScrollModifier).toBe("AltLeft"); + expect(keybinds.emojiMenuModifier).toBe("AltLeft"); + // Note: this is related to user_setting.graphics_refresh_modifier, not the actual alt key. + expect(keybinds.altKey).toBe("AltLeft"); }); it("handles Mac-specific modifier keys correctly", () => {