From f356f09f81abe30984d8a8ca40cc42ed4a57a8c3 Mon Sep 17 00:00:00 2001 From: bijx Date: Mon, 16 Mar 2026 19:16:59 -0400 Subject: [PATCH] Feat: Game Speed + Pause keybinds (#3397) ## Description: Can't tell you how many times I've been playing solo, I try to go change the speed from `Max` to `x1` and before I've opened the speed controls and clicked on one the AI completely wipes me. But not to worry, we now have a pause and speed toggle up/down keybinds! https://github.com/user-attachments/assets/48692c27-888f-40fb-837a-45e26f262441 Keybinds were added to "Menu Shortcuts" submenu: image Tested on Solo, custom match, and public lobbies. Pause intent works correctly on solo and private match (only if you are host), and neither feature works in public matches, as expected. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx --- resources/lang/en.json | 8 +++++ src/client/HelpModal.ts | 24 +++++++++++++ src/client/InputHandler.ts | 23 ++++++++++++- src/client/LocalServer.ts | 34 ++++++++++++++++++- src/client/UserSettingModal.ts | 33 ++++++++++++++++++ .../graphics/layers/GameRightSidebar.ts | 9 +++++ src/client/graphics/layers/ReplayPanel.ts | 7 ++++ 7 files changed, 136 insertions(+), 2 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index f0ad48559..fdb3e53b5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -97,6 +97,8 @@ "action_build": "Open build menu", "action_emote": "Open emote menu", "action_center": "Center camera on player", + "action_pause_game": "Pause / Resume game", + "action_game_speed": "Game speed down / up (single player)", "action_zoom": "Zoom out/in", "action_move_camera": "Move camera", "action_ratio_change": "Decrease/Increase attack ratio", @@ -571,6 +573,12 @@ "build_menu_modifier_desc": "Hold this key while clicking to open the build menu.", "emoji_menu_modifier": "Emoji Menu Modifier", "emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu.", + "pause_game": "Pause", + "pause_game_desc": "Pause or resume the game (single player and custom games for host).", + "game_speed_up": "Game Speed Up", + "game_speed_up_desc": "Cycle to next game speed (0.5, 1, 2, max). Single player only.", + "game_speed_down": "Game Speed Down", + "game_speed_down_desc": "Cycle to previous game speed. Single player only.", "attack_ratio_controls": "Attack Ratio Controls", "attack_ratio_up": "Increase Attack Ratio", "attack_ratio_up_desc": "Increase attack ratio by {amount}%", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index c98ce7705..d70accd7e 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -58,6 +58,9 @@ export class HelpModal extends BaseModal { modifierKey: isMac ? "MetaLeft" : "ControlLeft", altKey: "AltLeft", resetGfx: "KeyR", + pauseGame: "KeyP", + gameSpeedUp: "Period", + gameSpeedDown: "Comma", ...saved, }; } @@ -81,6 +84,8 @@ export class HelpModal extends BaseModal { ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", + Period: ">", + Comma: "<", }; if (specialLabels[code]) return specialLabels[code]; @@ -372,6 +377,25 @@ export class HelpModal extends BaseModal { ${translateText("help_modal.action_center")} + + + ${this.renderKey(keybinds.pauseGame)} + + + ${translateText("help_modal.action_pause_game")} + + + + +
+ ${this.renderKey(keybinds.gameSpeedDown)} + ${this.renderKey(keybinds.gameSpeedUp)} +
+ + + ${translateText("help_modal.action_game_speed")} + +
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 778d284c4..e42d62241 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -123,6 +123,12 @@ export class ReplaySpeedChangeEvent implements GameEvent { constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {} } +export class TogglePauseIntentEvent implements GameEvent {} + +export class GameSpeedUpIntentEvent implements GameEvent {} + +export class GameSpeedDownIntentEvent implements GameEvent {} + export class CenterCameraEvent implements GameEvent { constructor() {} } @@ -236,6 +242,9 @@ export class InputHandler { buildAtomBomb: "Digit8", buildHydrogenBomb: "Digit9", buildMIRV: "Digit0", + pauseGame: "KeyP", + gameSpeedUp: "Period", + gameSpeedDown: "Comma", ...saved, }; @@ -433,8 +442,20 @@ export class InputHandler { this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection)); } + if (!e.repeat && e.code === this.keybinds.pauseGame) { + e.preventDefault(); + this.eventBus.emit(new TogglePauseIntentEvent()); + } + if (!e.repeat && e.code === this.keybinds.gameSpeedUp) { + e.preventDefault(); + this.eventBus.emit(new GameSpeedUpIntentEvent()); + } + if (!e.repeat && e.code === this.keybinds.gameSpeedDown) { + e.preventDefault(); + this.eventBus.emit(new GameSpeedDownIntentEvent()); + } + // Shift-D to toggle performance overlay - console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); if (e.code === "KeyD" && e.shiftKey) { e.preventDefault(); console.log("TogglePerformanceOverlayEvent"); diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 58c43306c..e2805235f 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -20,12 +20,24 @@ import { } from "../core/Util"; import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; -import { ReplaySpeedChangeEvent } from "./InputHandler"; +import { + GameSpeedDownIntentEvent, + GameSpeedUpIntentEvent, + ReplaySpeedChangeEvent, +} from "./InputHandler"; import { defaultReplaySpeedMultiplier, ReplaySpeedMultiplier, } from "./utilities/ReplaySpeedMultiplier"; +// Order: 0.5, 1, 2, max (same as ReplayPanel) +const SPEED_ORDER: ReplaySpeedMultiplier[] = [ + ReplaySpeedMultiplier.slow, + ReplaySpeedMultiplier.normal, + ReplaySpeedMultiplier.fast, + ReplaySpeedMultiplier.fastest, +]; + // build a small backlog so MAX can catch up. const MAX_REPLAY_BACKLOG_TURNS = 60; @@ -94,6 +106,26 @@ export class LocalServer { this.replaySpeedMultiplier = event.replaySpeedMultiplier; }); + if (!this.isReplay) { + this.eventBus.on(GameSpeedUpIntentEvent, () => { + const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier); + if (idx < 0 || idx >= SPEED_ORDER.length - 1) return; + this.replaySpeedMultiplier = SPEED_ORDER[idx + 1]; + this.eventBus.emit( + new ReplaySpeedChangeEvent(this.replaySpeedMultiplier), + ); + }); + + this.eventBus.on(GameSpeedDownIntentEvent, () => { + const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier); + if (idx <= 0) return; + this.replaySpeedMultiplier = SPEED_ORDER[idx - 1]; + this.eventBus.emit( + new ReplaySpeedChangeEvent(this.replaySpeedMultiplier), + ); + }); + } + this.startedAt = Date.now(); this.clientConnect(); if (this.lobbyConfig.gameRecord) { diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 7bf6612d3..cb47ee320 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -47,6 +47,9 @@ const DefaultKeybinds: Record = { moveRight: "KeyD", modifierKey: isMac ? "MetaLeft" : "ControlLeft", altKey: "AltLeft", + pauseGame: "KeyP", + gameSpeedUp: "Period", + gameSpeedDown: "Comma", }; @customElement("user-setting") @@ -634,6 +637,36 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + + + + + +

diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 2d2f78891..f3ddc8278 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -4,6 +4,7 @@ import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; +import { TogglePauseIntentEvent } from "../../InputHandler"; import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; @@ -67,6 +68,14 @@ export class GameRightSidebar extends LitElement implements Layer { this.requestUpdate(); }); + this.eventBus.on(TogglePauseIntentEvent, () => { + const isReplayOrSingleplayer = + this._isSinglePlayer || this.game?.config()?.isReplay(); + if (isReplayOrSingleplayer || this.isLobbyCreator) { + this.onPauseButtonClick(); + } + }); + this.requestUpdate(); } diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index fbef9051d..8c214c2ef 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -41,6 +41,13 @@ export class ReplayPanel extends LitElement implements Layer { this.visible = event.visible; this.isSingleplayer = event.isSingleplayer; }); + this.eventBus.on( + ReplaySpeedChangeEvent, + (event: ReplaySpeedChangeEvent) => { + this._replaySpeedMultiplier = event.replaySpeedMultiplier; + this.requestUpdate(); + }, + ); } }