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", () => {