-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Build multiplier [DO NOT MERGE] #5282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
10b908b
4bc01e7
e802dd5
abb9d68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<typeof setInterval> | null = null; | ||
| private nukeHoldInitialDelayTimer: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| private moveInterval: NodeJS.Timeout | null = null; | ||
| private activeKeys = new Set<string>(); | ||
| private keybinds: Record<string, string> = {}; | ||
|
|
@@ -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(); | ||
|
Comment on lines
+779
to
+780
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Stop nuke deployment when a second pointer starts. A second pointer leaves Call 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 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; | ||
|
Comment on lines
+829
to
+831
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Clean up touch long-press state before this return. If a touch AtomBomb hold ends before Clear the long-press timer and reset its state before returning. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| }), | ||
| ); | ||
|
Comment on lines
+682
to
+692
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Test deployment through the core simulation. This test spies on Use 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Advance fake timers after pointer release. This assertion runs immediately after Advance more than Proposed test change inputHandler["onPointerUp"](
new PointerEvent("pointerup", {
button: 0,
clientX: 150,
clientY: 250,
pointerId: 1,
}),
);
+ vi.advanceTimersByTime(100);
const afterRelease = mockEmit.mock.calls.filter(🤖 Prompt for AI Agents |
||
| } 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", () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Cancel nuke timers during
destroy().destroy()clearsmoveIntervalbut does not clear these new timers. If the handler is destroyed during an AtomBomb hold, the interval can continue to emit deployment events after disposal.Call
stopNukeHoldDeployment()at the start ofdestroy().🤖 Prompt for AI Agents