mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-29 03:44:40 +00:00
feat: add Shift+ modifier support for keybinds (#3679)
## Description: This PR adds support for `Shift+<key>` keybind combinations across the entire keybind system. Previously, keybinds only supported a single key (e.g. `KeyB` for boat attack). Now any keybind can be configured as `Shift+KeyB`, which will only trigger when Shift is held down simultaneously. Enables to use Shift + A for "select all" feature from #3677 **Changes:** - `InputHandler.ts`: Added `parseKeybind()` helper that parses `"Shift+KeyB"` → `{ shift: true, code: "KeyB" }`. Added `keybindMatchesEvent()` for consistent matching across all keyup/keydown handlers. Updated `resolveBuildKeybind()` and all keybind comparisons to respect the shift modifier. - `SettingKeybind.ts`: When recording a keybind, lone modifier keys (Shift, Ctrl, etc.) are skipped — the component waits for the actual key. If Shift is held when the key is pressed, the value is stored as `"Shift+<code>"`. - `Utils.ts`: `formatKeyForDisplay()` now handles the `Shift+` prefix, displaying e.g. `"Shift+B"`. - `tests/InputHandler.test.ts`: Added 6 tests covering Shift+ keybind matching, negative cases (plain key not triggering Shift-bound action), coexistence of `Digit1` and `Shift+Digit1` on different actions, and Numpad alias support with Shift. ## 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 ## UI changes: <img width="2255" height="2070" alt="CleanShot 2026-04-15 at 20 23 25@2x" src="https://github.com/user-attachments/assets/96c19fc3-6294-40b7-82eb-3fde52b71618" /> ## Please put your Discord username so you can be contacted if a bug or regression is found: fghjk_60845
This commit is contained in:
+59
-19
@@ -281,7 +281,7 @@ export class InputHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.toggleView) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
|
||||
e.preventDefault();
|
||||
if (!this.alternateView) {
|
||||
this.alternateView = true;
|
||||
@@ -289,7 +289,10 @@ export class InputHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
|
||||
if (
|
||||
this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) &&
|
||||
!e.repeat
|
||||
) {
|
||||
e.preventDefault();
|
||||
this.coordinateGridEnabled = !this.coordinateGridEnabled;
|
||||
this.eventBus.emit(
|
||||
@@ -375,7 +378,7 @@ export class InputHandler {
|
||||
this.activeKeys.delete(this.keybinds.zoomOut);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.toggleView) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
|
||||
e.preventDefault();
|
||||
this.alternateView = false;
|
||||
this.eventBus.emit(new AlternateViewEvent(false));
|
||||
@@ -387,55 +390,58 @@ export class InputHandler {
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.boatAttack) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.boatAttack)) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new DoBoatAttackEvent());
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.groundAttack) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.groundAttack)) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new DoGroundAttackEvent());
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.attackRatioDown) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioDown)) {
|
||||
e.preventDefault();
|
||||
const increment = this.userSettings.attackRatioIncrement();
|
||||
this.eventBus.emit(new AttackRatioEvent(-increment));
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.attackRatioUp) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioUp)) {
|
||||
e.preventDefault();
|
||||
const increment = this.userSettings.attackRatioIncrement();
|
||||
this.eventBus.emit(new AttackRatioEvent(increment));
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.centerCamera) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.centerCamera)) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new CenterCameraEvent());
|
||||
}
|
||||
|
||||
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
|
||||
const matchedBuild = this.resolveBuildKeybind(e.code);
|
||||
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
|
||||
if (matchedBuild !== null) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(matchedBuild);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.swapDirection) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.swapDirection)) {
|
||||
e.preventDefault();
|
||||
const nextDirection = !this.uiState.rocketDirectionUp;
|
||||
this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection));
|
||||
}
|
||||
|
||||
if (!e.repeat && e.code === this.keybinds.pauseGame) {
|
||||
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.pauseGame)) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new TogglePauseIntentEvent());
|
||||
}
|
||||
if (!e.repeat && e.code === this.keybinds.gameSpeedUp) {
|
||||
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.gameSpeedUp)) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new GameSpeedUpIntentEvent());
|
||||
}
|
||||
if (!e.repeat && e.code === this.keybinds.gameSpeedDown) {
|
||||
if (
|
||||
!e.repeat &&
|
||||
this.keybindMatchesEvent(e, this.keybinds.gameSpeedDown)
|
||||
) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new GameSpeedDownIntentEvent());
|
||||
}
|
||||
@@ -615,6 +621,27 @@ export class InputHandler {
|
||||
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a keybind value that may include a "Shift+" prefix.
|
||||
* e.g. "Shift+KeyB" → { shift: true, code: "KeyB" }
|
||||
* "KeyB" → { shift: false, code: "KeyB" }
|
||||
*/
|
||||
private parseKeybind(value: string): { shift: boolean; code: string } {
|
||||
if (value?.startsWith("Shift+")) {
|
||||
return { shift: true, code: value.slice(6) };
|
||||
}
|
||||
return { shift: false, code: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the keyboard event matches the given keybind value,
|
||||
* including optional Shift+ prefix support.
|
||||
*/
|
||||
private keybindMatchesEvent(e: KeyboardEvent, keybindValue: string): boolean {
|
||||
const parsed = this.parseKeybind(keybindValue);
|
||||
return e.code === parsed.code && e.shiftKey === parsed.shift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the digit character from KeyboardEvent.code.
|
||||
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
|
||||
@@ -637,17 +664,25 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
|
||||
private buildKeybindMatches(code: string, keybindValue: string): boolean {
|
||||
return code === keybindValue;
|
||||
private buildKeybindMatches(
|
||||
code: string,
|
||||
shiftKey: boolean,
|
||||
keybindValue: string,
|
||||
): boolean {
|
||||
const parsed = this.parseKeybind(keybindValue);
|
||||
return code === parsed.code && shiftKey === parsed.shift;
|
||||
}
|
||||
|
||||
/** Digit/Numpad alias match: used only when no exact match was found. */
|
||||
private buildKeybindMatchesDigit(
|
||||
code: string,
|
||||
shiftKey: boolean,
|
||||
keybindValue: string,
|
||||
): boolean {
|
||||
const parsed = this.parseKeybind(keybindValue);
|
||||
if (shiftKey !== parsed.shift) return false;
|
||||
const digit = this.digitFromKeyCode(code);
|
||||
const bindDigit = this.digitFromKeyCode(keybindValue);
|
||||
const bindDigit = this.digitFromKeyCode(parsed.code);
|
||||
return digit !== null && bindDigit !== null && digit === bindDigit;
|
||||
}
|
||||
|
||||
@@ -655,7 +690,10 @@ export class InputHandler {
|
||||
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
|
||||
* Returns the UnitType to set as ghost, or null if no build keybind matched.
|
||||
*/
|
||||
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
|
||||
private resolveBuildKeybind(
|
||||
code: string,
|
||||
shiftKey: boolean,
|
||||
): PlayerBuildableUnitType | null {
|
||||
const buildKeybinds: ReadonlyArray<{
|
||||
key: string;
|
||||
type: PlayerBuildableUnitType;
|
||||
@@ -672,10 +710,12 @@ export class InputHandler {
|
||||
{ key: "buildMIRV", type: UnitType.MIRV },
|
||||
];
|
||||
for (const { key, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
|
||||
if (this.buildKeybindMatches(code, shiftKey, this.keybinds[key]))
|
||||
return type;
|
||||
}
|
||||
for (const { key, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
|
||||
if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[key]))
|
||||
return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user