mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:40:46 +00:00
First dev commit, WIP
This commit is contained in:
@@ -542,6 +542,7 @@
|
||||
"title": "Settings",
|
||||
"tab_basic": "Basic Settings",
|
||||
"tab_keybinds": "Keybinds",
|
||||
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
|
||||
"dark_mode_label": "Dark Mode",
|
||||
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
|
||||
"emojis_label": "Emojis",
|
||||
@@ -603,9 +604,9 @@
|
||||
"build_mirv_desc": "Build a MIRV under your cursor.",
|
||||
"menu_shortcuts": "Menu Shortcuts",
|
||||
"build_menu_modifier": "Build Menu Modifier",
|
||||
"build_menu_modifier_desc": "Hold this key while clicking to open the build menu.",
|
||||
"build_menu_modifier_desc": "Hold this key while clicking to open the legacy build menu. No Shift.",
|
||||
"emoji_menu_modifier": "Emoji Menu Modifier",
|
||||
"emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu.",
|
||||
"emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu. No Shift.",
|
||||
"pause_game": "Pause",
|
||||
"pause_game_desc": "Pause or resume the game (single player and custom games for host).",
|
||||
"game_speed_up": "Game Speed Up",
|
||||
|
||||
+12
-39
@@ -1,8 +1,12 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
|
||||
import {
|
||||
formatKeyForDisplay,
|
||||
translateText,
|
||||
TUTORIAL_VIDEO_URL,
|
||||
} from "../client/Utils";
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { KeybindAction, UserSettings } from "../core/game/UserSettings";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
@@ -11,46 +15,15 @@ import { TroubleshootingModal } from "./TroubleshootingModal";
|
||||
|
||||
@customElement("help-modal")
|
||||
export class HelpModal extends BaseModal {
|
||||
@state() private keybinds: Record<string, string> = this.getKeybinds();
|
||||
@state() private keybinds: Record<KeybindAction, string> = this.getKeybinds();
|
||||
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;
|
||||
|
||||
private getKeybinds(): Record<string, string> {
|
||||
private getKeybinds(): Record<KeybindAction, string> {
|
||||
return new UserSettings().keybinds(Platform.isMac);
|
||||
}
|
||||
|
||||
private getKeyLabel(code: string): string {
|
||||
if (!code) return "";
|
||||
|
||||
const specialLabels: Record<string, string> = {
|
||||
ShiftLeft: "⇧ Shift",
|
||||
ShiftRight: "⇧ Shift",
|
||||
ControlLeft: "Ctrl",
|
||||
ControlRight: "Ctrl",
|
||||
AltLeft: "Alt",
|
||||
AltRight: "Alt",
|
||||
MetaLeft: "⌘",
|
||||
MetaRight: "⌘",
|
||||
Space: "Space",
|
||||
Escape: "Esc",
|
||||
Enter: "↵ Return",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
Period: ">",
|
||||
Comma: "<",
|
||||
};
|
||||
|
||||
if (specialLabels[code]) return specialLabels[code];
|
||||
if (code.startsWith("Key") && code.length === 4) return code.slice(3);
|
||||
if (code.startsWith("Digit")) return code.slice(5);
|
||||
if (code.startsWith("Numpad")) return `Num ${code.slice(6)}`;
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
private renderKey(code: string) {
|
||||
const label = this.getKeyLabel(code);
|
||||
const label = formatKeyForDisplay(code);
|
||||
return html`<span
|
||||
class="inline-block min-w-[32px] text-center px-2 py-1 rounded bg-[#2a2a2a] border-b-2 border-[#1a1a1a] text-white font-mono text-xs font-bold mx-0.5"
|
||||
>${label}</span
|
||||
@@ -283,7 +256,7 @@ export class HelpModal extends BaseModal {
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
${this.renderKey(keybinds.modifierKey)}
|
||||
${this.renderKey(keybinds.buildMenuModifier)}
|
||||
<span class="text-white/40 font-bold">+</span>
|
||||
<div
|
||||
class="w-5 h-8 border border-white/40 rounded-full relative"
|
||||
@@ -304,7 +277,7 @@ export class HelpModal extends BaseModal {
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
${this.renderKey(keybinds.altKey)}
|
||||
${this.renderKey(keybinds.emojiMenuModifier)}
|
||||
<span class="text-white/40 font-bold">+</span>
|
||||
<div
|
||||
class="w-5 h-8 border border-white/40 rounded-full relative"
|
||||
@@ -411,7 +384,7 @@ export class HelpModal extends BaseModal {
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
${this.renderKey(keybinds.altKey)}
|
||||
${this.renderKey(keybinds.emojiMenuModifier)}
|
||||
<span class="text-white/40 font-bold">+</span>
|
||||
${this.renderKey(keybinds.resetGfx)}
|
||||
</div>
|
||||
|
||||
+89
-94
@@ -1,7 +1,7 @@
|
||||
import { EventBus, GameEvent } from "../core/EventBus";
|
||||
import { PlayerBuildableUnitType, UnitType } from "../core/game/Game";
|
||||
import { GameView, UnitView } from "../core/game/GameView";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { KeybindAction, UserSettings } from "../core/game/UserSettings";
|
||||
import { UIState } from "./graphics/UIState";
|
||||
import { Platform } from "./Platform";
|
||||
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
|
||||
@@ -168,7 +168,7 @@ export class InputHandler {
|
||||
|
||||
private moveInterval: NodeJS.Timeout | null = null;
|
||||
private activeKeys = new Set<string>();
|
||||
private keybinds: Record<string, string> = {};
|
||||
private keybinds: Record<KeybindAction, string>;
|
||||
private coordinateGridEnabled = false;
|
||||
|
||||
private readonly PAN_SPEED = 5;
|
||||
@@ -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(
|
||||
@@ -342,6 +345,8 @@ export class InputHandler {
|
||||
this.keybinds.attackRatioDown,
|
||||
this.keybinds.attackRatioUp,
|
||||
this.keybinds.centerCamera,
|
||||
this.keybinds.buildMenuModifier,
|
||||
this.keybinds.emojiMenuModifier,
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
"ShiftLeft",
|
||||
@@ -375,67 +380,69 @@ 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));
|
||||
}
|
||||
|
||||
const resetKey = this.keybinds.resetGfx ?? "KeyR";
|
||||
if (e.code === resetKey && this.isAltKeyHeld(e)) {
|
||||
if (this.keybindMatchesEvent(e, this.keybinds.resetGfx) && e.altKey) {
|
||||
e.preventDefault();
|
||||
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());
|
||||
}
|
||||
@@ -490,11 +497,11 @@ export class InputHandler {
|
||||
this.pointerDown = false;
|
||||
this.pointers.clear();
|
||||
|
||||
if (this.isModifierKeyPressed(event)) {
|
||||
if (this.isMenuModifierPressed(event, this.keybinds.buildMenuModifier)) {
|
||||
this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
if (this.isAltKeyPressed(event)) {
|
||||
if (this.isMenuModifierPressed(event, this.keybinds.emojiMenuModifier)) {
|
||||
this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
@@ -615,6 +622,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 +665,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,27 +691,32 @@ 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;
|
||||
action: KeybindAction;
|
||||
type: PlayerBuildableUnitType;
|
||||
}> = [
|
||||
{ key: "buildCity", type: UnitType.City },
|
||||
{ key: "buildFactory", type: UnitType.Factory },
|
||||
{ key: "buildPort", type: UnitType.Port },
|
||||
{ key: "buildDefensePost", type: UnitType.DefensePost },
|
||||
{ key: "buildMissileSilo", type: UnitType.MissileSilo },
|
||||
{ key: "buildSamLauncher", type: UnitType.SAMLauncher },
|
||||
{ key: "buildAtomBomb", type: UnitType.AtomBomb },
|
||||
{ key: "buildHydrogenBomb", type: UnitType.HydrogenBomb },
|
||||
{ key: "buildWarship", type: UnitType.Warship },
|
||||
{ key: "buildMIRV", type: UnitType.MIRV },
|
||||
{ action: KeybindAction.buildCity, type: UnitType.City },
|
||||
{ action: KeybindAction.buildFactory, type: UnitType.Factory },
|
||||
{ action: KeybindAction.buildPort, type: UnitType.Port },
|
||||
{ action: KeybindAction.buildDefensePost, type: UnitType.DefensePost },
|
||||
{ action: KeybindAction.buildMissileSilo, type: UnitType.MissileSilo },
|
||||
{ action: KeybindAction.buildSamLauncher, type: UnitType.SAMLauncher },
|
||||
{ action: KeybindAction.buildAtomBomb, type: UnitType.AtomBomb },
|
||||
{ action: KeybindAction.buildHydrogenBomb, type: UnitType.HydrogenBomb },
|
||||
{ action: KeybindAction.buildWarship, type: UnitType.Warship },
|
||||
{ action: KeybindAction.buildMIRV, type: UnitType.MIRV },
|
||||
];
|
||||
for (const { key, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
|
||||
for (const { action, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatches(code, shiftKey, this.keybinds[action]))
|
||||
return type;
|
||||
}
|
||||
for (const { key, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
|
||||
for (const { action, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[action]))
|
||||
return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -718,65 +759,19 @@ export class InputHandler {
|
||||
this.activeKeys.clear();
|
||||
}
|
||||
|
||||
isModifierKeyPressed(event: PointerEvent): boolean {
|
||||
private isMenuModifierPressed(event: PointerEvent, modifierBind: string): boolean {
|
||||
// Don't accept Shift: menus are meant to be modifier+click,
|
||||
// but Shift+click is hardcoded for attack when leftClickOpensMenu is false.
|
||||
// Do allow other keys than strict modifier keys by definition (like "keyU"),
|
||||
// because non-modifier keys could already be assigned in UserSettingModal.
|
||||
return (
|
||||
((this.keybinds.modifierKey === "AltLeft" ||
|
||||
this.keybinds.modifierKey === "AltRight") &&
|
||||
((modifierBind === "AltLeft" || modifierBind === "AltRight") &&
|
||||
event.altKey) ||
|
||||
((this.keybinds.modifierKey === "ControlLeft" ||
|
||||
this.keybinds.modifierKey === "ControlRight") &&
|
||||
((modifierBind === "ControlLeft" || modifierBind === "ControlRight") &&
|
||||
event.ctrlKey) ||
|
||||
((this.keybinds.modifierKey === "ShiftLeft" ||
|
||||
this.keybinds.modifierKey === "ShiftRight") &&
|
||||
event.shiftKey) ||
|
||||
((this.keybinds.modifierKey === "MetaLeft" ||
|
||||
this.keybinds.modifierKey === "MetaRight") &&
|
||||
event.metaKey)
|
||||
);
|
||||
}
|
||||
|
||||
private isAltKeyHeld(event: KeyboardEvent): boolean {
|
||||
if (
|
||||
this.keybinds.altKey === "AltLeft" ||
|
||||
this.keybinds.altKey === "AltRight"
|
||||
) {
|
||||
return event.altKey && !event.ctrlKey;
|
||||
}
|
||||
if (
|
||||
this.keybinds.altKey === "ControlLeft" ||
|
||||
this.keybinds.altKey === "ControlRight"
|
||||
) {
|
||||
return event.ctrlKey;
|
||||
}
|
||||
if (
|
||||
this.keybinds.altKey === "ShiftLeft" ||
|
||||
this.keybinds.altKey === "ShiftRight"
|
||||
) {
|
||||
return event.shiftKey;
|
||||
}
|
||||
if (
|
||||
this.keybinds.altKey === "MetaLeft" ||
|
||||
this.keybinds.altKey === "MetaRight"
|
||||
) {
|
||||
return event.metaKey;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isAltKeyPressed(event: PointerEvent): boolean {
|
||||
return (
|
||||
((this.keybinds.altKey === "AltLeft" ||
|
||||
this.keybinds.altKey === "AltRight") &&
|
||||
event.altKey) ||
|
||||
((this.keybinds.altKey === "ControlLeft" ||
|
||||
this.keybinds.altKey === "ControlRight") &&
|
||||
event.ctrlKey) ||
|
||||
((this.keybinds.altKey === "ShiftLeft" ||
|
||||
this.keybinds.altKey === "ShiftRight") &&
|
||||
event.shiftKey) ||
|
||||
((this.keybinds.altKey === "MetaLeft" ||
|
||||
this.keybinds.altKey === "MetaRight") &&
|
||||
event.metaKey)
|
||||
((modifierBind === "MetaLeft" || modifierBind === "MetaRight") &&
|
||||
event.metaKey) ||
|
||||
this.activeKeys.has(modifierBind)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import "./UsernameInput";
|
||||
import { genAnonUsername, UsernameInput } from "./UsernameInput";
|
||||
import {
|
||||
getDiscordAvatarUrl,
|
||||
getKeyboardLayoutMap,
|
||||
incrementGamesPlayed,
|
||||
isInIframe,
|
||||
translateText,
|
||||
@@ -257,6 +258,8 @@ class Client {
|
||||
// Prefetch turnstile token so it is available when
|
||||
// the user joins a lobby.
|
||||
this.turnstileTokenPromise = getTurnstileToken();
|
||||
// Prefetch keyboard layout for use by formatKeyForDisplay
|
||||
getKeyboardLayoutMap();
|
||||
|
||||
// Wait for components to render before setting version
|
||||
await customElements.whenDefined("mobile-nav-bar");
|
||||
|
||||
+171
-135
@@ -1,7 +1,12 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { formatKeyForDisplay, translateText } from "../client/Utils";
|
||||
import { getDefaultKeybinds, UserSettings } from "../core/game/UserSettings";
|
||||
import {
|
||||
getDefaultKeybinds,
|
||||
KeybindAction,
|
||||
KeyUnbound,
|
||||
UserSettings,
|
||||
} from "../core/game/UserSettings";
|
||||
import "./components/baseComponents/setting/SettingKeybind";
|
||||
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
|
||||
import "./components/baseComponents/setting/SettingNumber";
|
||||
@@ -22,9 +27,8 @@ export class UserSettingModal extends BaseModal {
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showEasterEggSettings = false;
|
||||
|
||||
@state() private userKeybinds: Record<
|
||||
string,
|
||||
{ value: string; key: string }
|
||||
@state() private userKeybinds: Partial<
|
||||
Record<KeybindAction, { value: string; key: string }>
|
||||
> = {};
|
||||
|
||||
connectedCallback() {
|
||||
@@ -44,7 +48,9 @@ export class UserSettingModal extends BaseModal {
|
||||
return;
|
||||
}
|
||||
|
||||
const validated: Record<string, { value: string; key: string }> = {};
|
||||
const validated: Partial<
|
||||
Record<KeybindAction, { value: string; key: string }>
|
||||
> = {};
|
||||
|
||||
for (const [action, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") {
|
||||
@@ -54,12 +60,12 @@ export class UserSettingModal extends BaseModal {
|
||||
entry !== null &&
|
||||
!Array.isArray(entry)
|
||||
) {
|
||||
const rawValue = (entry as any).value ?? "Null";
|
||||
const rawValue = entry.value ?? KeyUnbound;
|
||||
const value = Array.isArray(rawValue)
|
||||
? rawValue.find((v) => typeof v === "string")
|
||||
: rawValue;
|
||||
|
||||
const rawKey = (entry as any).key ?? value;
|
||||
const rawKey = entry.key ?? value;
|
||||
const key = Array.isArray(rawKey)
|
||||
? rawKey.find((v) => typeof v === "string")
|
||||
: rawKey;
|
||||
@@ -75,29 +81,38 @@ export class UserSettingModal extends BaseModal {
|
||||
|
||||
private handleKeybindChange(
|
||||
e: CustomEvent<{
|
||||
action: string;
|
||||
action: KeybindAction;
|
||||
value: string;
|
||||
key: string;
|
||||
prevValue?: string;
|
||||
}>,
|
||||
) {
|
||||
const { action, value, key, prevValue } = e.detail;
|
||||
let { action, value, key, prevValue } = e.detail;
|
||||
|
||||
console.info(
|
||||
"handleKeybindChange recieved value: " + value,
|
||||
", key: " + key,
|
||||
);
|
||||
|
||||
// Don't display "Dead" for Quote / Backquote https://en.wikipedia.org/wiki/QWERTY#US-International
|
||||
// nor "Unidentified" for some keys in Firefox ("" in Chrome). Empty the key to use value (key code).
|
||||
key = key === "Dead" || key === "Unidentified" ? "" : key;
|
||||
|
||||
const activeKeybinds = { ...this.defaultKeybinds };
|
||||
for (const [k, v] of Object.entries(this.userKeybinds)) {
|
||||
const normalizedValue = v.value;
|
||||
if (normalizedValue === "Null") {
|
||||
delete activeKeybinds[k];
|
||||
for (const [action, codeAndKey] of Object.entries(this.userKeybinds)) {
|
||||
const normalizedCode = codeAndKey.value;
|
||||
if (normalizedCode === KeyUnbound) {
|
||||
delete activeKeybinds[action];
|
||||
} else {
|
||||
activeKeybinds[k] = normalizedValue;
|
||||
activeKeybinds[action] = normalizedCode;
|
||||
}
|
||||
}
|
||||
|
||||
const values = Object.entries(activeKeybinds)
|
||||
.filter(([k]) => k !== action)
|
||||
.map(([, v]) => v);
|
||||
const codes = Object.entries(activeKeybinds)
|
||||
.filter(([a]) => a !== action)
|
||||
.map(([, code]) => code);
|
||||
|
||||
if (values.includes(value) && value !== "Null") {
|
||||
if (codes.includes(value) && value !== KeyUnbound) {
|
||||
const displayKey = formatKeyForDisplay(key || value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
@@ -142,7 +157,6 @@ export class UserSettingModal extends BaseModal {
|
||||
);
|
||||
if (element) {
|
||||
element.value = prevValue ?? this.defaultKeybinds[action] ?? "";
|
||||
element.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -151,18 +165,18 @@ export class UserSettingModal extends BaseModal {
|
||||
...this.userKeybinds,
|
||||
[action]: { value: value, key: key },
|
||||
};
|
||||
this.userSettings.setKeybinds(this.userKeybinds);
|
||||
this.userSettings.setUserKeybinds(this.userKeybinds);
|
||||
}
|
||||
|
||||
private getKeyValue(action: string): string | undefined {
|
||||
private getKeyValue(action: KeybindAction): string | undefined {
|
||||
const entry = this.userKeybinds[action];
|
||||
if (!entry) return undefined;
|
||||
const normalizedValue = entry.value;
|
||||
if (normalizedValue === "Null") return "";
|
||||
if (normalizedValue === KeyUnbound) return "";
|
||||
return normalizedValue || undefined;
|
||||
}
|
||||
|
||||
private getKeyChar(action: string): string {
|
||||
private getKeyChar(action: KeybindAction): string {
|
||||
const entry = this.userKeybinds[action];
|
||||
if (!entry) return "";
|
||||
return entry.key || "";
|
||||
@@ -383,6 +397,26 @@ export class UserSettingModal extends BaseModal {
|
||||
|
||||
private renderKeybindSettings() {
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 mb-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-300/70 text-xs"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5 shrink-0 opacity-70"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
${translateText("user_setting.keybinds_hint")}
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
@@ -390,22 +424,22 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="toggleView"
|
||||
action=${KeybindAction.toggleView}
|
||||
label=${translateText("user_setting.toggle_view")}
|
||||
description=${translateText("user_setting.toggle_view_desc")}
|
||||
defaultKey="Space"
|
||||
.value=${this.getKeyValue("toggleView")}
|
||||
.display=${this.getKeyChar("toggleView")}
|
||||
.defaultKey=${this.defaultKeybinds.toggleView}
|
||||
.value=${this.getKeyValue(KeybindAction.toggleView)}
|
||||
.display=${this.getKeyChar(KeybindAction.toggleView)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="coordinateGrid"
|
||||
action=${KeybindAction.coordinateGrid}
|
||||
label=${translateText("user_setting.coordinate_grid_label")}
|
||||
description=${translateText("user_setting.coordinate_grid_desc")}
|
||||
defaultKey=${this.defaultKeybinds.coordinateGrid}
|
||||
.value=${this.getKeyValue("coordinateGrid")}
|
||||
.display=${this.getKeyChar("coordinateGrid")}
|
||||
.defaultKey=${this.defaultKeybinds.coordinateGrid}
|
||||
.value=${this.getKeyValue(KeybindAction.coordinateGrid)}
|
||||
.display=${this.getKeyChar(KeybindAction.coordinateGrid)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -416,102 +450,102 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="buildCity"
|
||||
action=${KeybindAction.buildCity}
|
||||
label=${translateText("user_setting.build_city")}
|
||||
description=${translateText("user_setting.build_city_desc")}
|
||||
defaultKey="Digit1"
|
||||
.value=${this.getKeyValue("buildCity")}
|
||||
.display=${this.getKeyChar("buildCity")}
|
||||
.defaultKey=${this.defaultKeybinds.buildCity}
|
||||
.value=${this.getKeyValue(KeybindAction.buildCity)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildCity)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildFactory"
|
||||
action=${KeybindAction.buildFactory}
|
||||
label=${translateText("user_setting.build_factory")}
|
||||
description=${translateText("user_setting.build_factory_desc")}
|
||||
defaultKey="Digit2"
|
||||
.value=${this.getKeyValue("buildFactory")}
|
||||
.display=${this.getKeyChar("buildFactory")}
|
||||
.defaultKey=${this.defaultKeybinds.buildFactory}
|
||||
.value=${this.getKeyValue(KeybindAction.buildFactory)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildFactory)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildPort"
|
||||
action=${KeybindAction.buildPort}
|
||||
label=${translateText("user_setting.build_port")}
|
||||
description=${translateText("user_setting.build_port_desc")}
|
||||
defaultKey="Digit3"
|
||||
.value=${this.getKeyValue("buildPort")}
|
||||
.display=${this.getKeyChar("buildPort")}
|
||||
.defaultKey=${this.defaultKeybinds.buildPort}
|
||||
.value=${this.getKeyValue(KeybindAction.buildPort)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildPort)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildDefensePost"
|
||||
action=${KeybindAction.buildDefensePost}
|
||||
label=${translateText("user_setting.build_defense_post")}
|
||||
description=${translateText("user_setting.build_defense_post_desc")}
|
||||
defaultKey="Digit4"
|
||||
.value=${this.getKeyValue("buildDefensePost")}
|
||||
.display=${this.getKeyChar("buildDefensePost")}
|
||||
.defaultKey=${this.defaultKeybinds.buildDefensePost}
|
||||
.value=${this.getKeyValue(KeybindAction.buildDefensePost)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildDefensePost)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMissileSilo"
|
||||
action=${KeybindAction.buildMissileSilo}
|
||||
label=${translateText("user_setting.build_missile_silo")}
|
||||
description=${translateText("user_setting.build_missile_silo_desc")}
|
||||
defaultKey="Digit5"
|
||||
.value=${this.getKeyValue("buildMissileSilo")}
|
||||
.display=${this.getKeyChar("buildMissileSilo")}
|
||||
.defaultKey=${this.defaultKeybinds.buildMissileSilo}
|
||||
.value=${this.getKeyValue(KeybindAction.buildMissileSilo)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildMissileSilo)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildSamLauncher"
|
||||
action=${KeybindAction.buildSamLauncher}
|
||||
label=${translateText("user_setting.build_sam_launcher")}
|
||||
description=${translateText("user_setting.build_sam_launcher_desc")}
|
||||
defaultKey="Digit6"
|
||||
.value=${this.getKeyValue("buildSamLauncher")}
|
||||
.display=${this.getKeyChar("buildSamLauncher")}
|
||||
.defaultKey=${this.defaultKeybinds.buildSamLauncher}
|
||||
.value=${this.getKeyValue(KeybindAction.buildSamLauncher)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildSamLauncher)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildWarship"
|
||||
action=${KeybindAction.buildWarship}
|
||||
label=${translateText("user_setting.build_warship")}
|
||||
description=${translateText("user_setting.build_warship_desc")}
|
||||
defaultKey="Digit7"
|
||||
.value=${this.getKeyValue("buildWarship")}
|
||||
.display=${this.getKeyChar("buildWarship")}
|
||||
.defaultKey=${this.defaultKeybinds.buildWarship}
|
||||
.value=${this.getKeyValue(KeybindAction.buildWarship)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildWarship)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildAtomBomb"
|
||||
action=${KeybindAction.buildAtomBomb}
|
||||
label=${translateText("user_setting.build_atom_bomb")}
|
||||
description=${translateText("user_setting.build_atom_bomb_desc")}
|
||||
defaultKey="Digit8"
|
||||
.value=${this.getKeyValue("buildAtomBomb")}
|
||||
.display=${this.getKeyChar("buildAtomBomb")}
|
||||
.defaultKey=${this.defaultKeybinds.buildAtomBomb}
|
||||
.value=${this.getKeyValue(KeybindAction.buildAtomBomb)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildAtomBomb)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildHydrogenBomb"
|
||||
action=${KeybindAction.buildHydrogenBomb}
|
||||
label=${translateText("user_setting.build_hydrogen_bomb")}
|
||||
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
|
||||
defaultKey="Digit9"
|
||||
.value=${this.getKeyValue("buildHydrogenBomb")}
|
||||
.display=${this.getKeyChar("buildHydrogenBomb")}
|
||||
.defaultKey=${this.defaultKeybinds.buildHydrogenBomb}
|
||||
.value=${this.getKeyValue(KeybindAction.buildHydrogenBomb)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildHydrogenBomb)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMIRV"
|
||||
action=${KeybindAction.buildMIRV}
|
||||
label=${translateText("user_setting.build_mirv")}
|
||||
description=${translateText("user_setting.build_mirv_desc")}
|
||||
defaultKey="Digit0"
|
||||
.value=${this.getKeyValue("buildMIRV")}
|
||||
.display=${this.getKeyChar("buildMIRV")}
|
||||
.defaultKey=${this.defaultKeybinds.buildMIRV}
|
||||
.value=${this.getKeyValue(KeybindAction.buildMIRV)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildMIRV)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -522,52 +556,52 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="modifierKey"
|
||||
action=${KeybindAction.buildMenuModifier}
|
||||
label=${translateText("user_setting.build_menu_modifier")}
|
||||
description=${translateText("user_setting.build_menu_modifier_desc")}
|
||||
.defaultKey=${this.defaultKeybinds.modifierKey}
|
||||
.value=${this.getKeyValue("modifierKey")}
|
||||
.display=${this.getKeyChar("modifierKey")}
|
||||
.defaultKey=${this.defaultKeybinds.buildMenuModifier}
|
||||
.value=${this.getKeyValue(KeybindAction.buildMenuModifier)}
|
||||
.display=${this.getKeyChar(KeybindAction.buildMenuModifier)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="altKey"
|
||||
action=${KeybindAction.emojiMenuModifier}
|
||||
label=${translateText("user_setting.emoji_menu_modifier")}
|
||||
description=${translateText("user_setting.emoji_menu_modifier_desc")}
|
||||
.defaultKey=${this.defaultKeybinds.altKey}
|
||||
.value=${this.getKeyValue("altKey")}
|
||||
.display=${this.getKeyChar("altKey")}
|
||||
.defaultKey=${this.defaultKeybinds.emojiMenuModifier}
|
||||
.value=${this.getKeyValue(KeybindAction.emojiMenuModifier)}
|
||||
.display=${this.getKeyChar(KeybindAction.emojiMenuModifier)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="pauseGame"
|
||||
action=${KeybindAction.pauseGame}
|
||||
label=${translateText("user_setting.pause_game")}
|
||||
description=${translateText("user_setting.pause_game_desc")}
|
||||
.defaultKey=${this.defaultKeybinds.pauseGame}
|
||||
.value=${this.getKeyValue("pauseGame")}
|
||||
.display=${this.getKeyChar("pauseGame")}
|
||||
.value=${this.getKeyValue(KeybindAction.pauseGame)}
|
||||
.display=${this.getKeyChar(KeybindAction.pauseGame)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="gameSpeedUp"
|
||||
action=${KeybindAction.gameSpeedUp}
|
||||
label=${translateText("user_setting.game_speed_up")}
|
||||
description=${translateText("user_setting.game_speed_up_desc")}
|
||||
.defaultKey=${this.defaultKeybinds.gameSpeedUp}
|
||||
.value=${this.getKeyValue("gameSpeedUp")}
|
||||
.display=${this.getKeyChar("gameSpeedUp")}
|
||||
.value=${this.getKeyValue(KeybindAction.gameSpeedUp)}
|
||||
.display=${this.getKeyChar(KeybindAction.gameSpeedUp)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="gameSpeedDown"
|
||||
action=${KeybindAction.gameSpeedDown}
|
||||
label=${translateText("user_setting.game_speed_down")}
|
||||
description=${translateText("user_setting.game_speed_down_desc")}
|
||||
.defaultKey=${this.defaultKeybinds.gameSpeedDown}
|
||||
.value=${this.getKeyValue("gameSpeedDown")}
|
||||
.display=${this.getKeyChar("gameSpeedDown")}
|
||||
.value=${this.getKeyValue(KeybindAction.gameSpeedDown)}
|
||||
.display=${this.getKeyChar(KeybindAction.gameSpeedDown)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -578,26 +612,28 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="attackRatioDown"
|
||||
action=${KeybindAction.attackRatioDown}
|
||||
label=${translateText("user_setting.attack_ratio_down")}
|
||||
description=${translateText("user_setting.attack_ratio_down_desc", {
|
||||
amount: this.userSettings.attackRatioIncrement(),
|
||||
})}
|
||||
defaultKey="KeyT"
|
||||
.value=${this.getKeyValue("attackRatioDown")}
|
||||
.display=${this.getKeyChar("attackRatioDown")}
|
||||
.defaultKey=${this.defaultKeybinds.attackRatioDown}
|
||||
.value=${this.getKeyValue(KeybindAction.attackRatioDown)}
|
||||
.display=${this.getKeyChar(KeybindAction.attackRatioDown)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="attackRatioUp"
|
||||
action=${KeybindAction.attackRatioUp}
|
||||
label=${translateText("user_setting.attack_ratio_up")}
|
||||
description=${translateText("user_setting.attack_ratio_up_desc", {
|
||||
amount: this.userSettings.attackRatioIncrement(),
|
||||
})}
|
||||
defaultKey="KeyY"
|
||||
.value=${this.getKeyValue("attackRatioUp")}
|
||||
.display=${this.getKeyChar("attackRatioUp")}
|
||||
.defaultKey=${this.defaultKeybinds.attackRatioUp}
|
||||
.value=${this.getKeyValue(KeybindAction.attackRatioUp)}
|
||||
.display=${formatKeyForDisplay(
|
||||
this.getKeyValue(KeybindAction.attackRatioUp as KeybindAction) || "",
|
||||
)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -608,32 +644,32 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="boatAttack"
|
||||
action=${KeybindAction.boatAttack}
|
||||
label=${translateText("user_setting.boat_attack")}
|
||||
description=${translateText("user_setting.boat_attack_desc")}
|
||||
defaultKey="KeyB"
|
||||
.value=${this.getKeyValue("boatAttack")}
|
||||
.display=${this.getKeyChar("boatAttack")}
|
||||
.defaultKey=${this.defaultKeybinds.boatAttack}
|
||||
.value=${this.getKeyValue(KeybindAction.boatAttack)}
|
||||
.display=${this.getKeyChar(KeybindAction.boatAttack)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="groundAttack"
|
||||
action=${KeybindAction.groundAttack}
|
||||
label=${translateText("user_setting.ground_attack")}
|
||||
description=${translateText("user_setting.ground_attack_desc")}
|
||||
defaultKey="KeyG"
|
||||
.value=${this.getKeyValue("groundAttack")}
|
||||
.display=${this.getKeyChar("groundAttack")}
|
||||
.defaultKey=${this.defaultKeybinds.groundAttack}
|
||||
.value=${this.getKeyValue(KeybindAction.groundAttack)}
|
||||
.display=${this.getKeyChar(KeybindAction.groundAttack)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="swapDirection"
|
||||
action=${KeybindAction.swapDirection}
|
||||
label=${translateText("user_setting.swap_direction")}
|
||||
description=${translateText("user_setting.swap_direction_desc")}
|
||||
.defaultKey=${this.defaultKeybinds.swapDirection}
|
||||
.value=${this.getKeyValue("swapDirection")}
|
||||
.display=${this.getKeyChar("swapDirection")}
|
||||
.value=${this.getKeyValue(KeybindAction.swapDirection)}
|
||||
.display=${this.getKeyChar(KeybindAction.swapDirection)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -644,22 +680,22 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomOut"
|
||||
action=${KeybindAction.zoomOut}
|
||||
label=${translateText("user_setting.zoom_out")}
|
||||
description=${translateText("user_setting.zoom_out_desc")}
|
||||
defaultKey="KeyQ"
|
||||
.value=${this.getKeyValue("zoomOut")}
|
||||
.display=${this.getKeyChar("zoomOut")}
|
||||
.defaultKey=${this.defaultKeybinds.zoomOut}
|
||||
.value=${this.getKeyValue(KeybindAction.zoomOut)}
|
||||
.display=${this.getKeyChar(KeybindAction.zoomOut)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomIn"
|
||||
action=${KeybindAction.zoomIn}
|
||||
label=${translateText("user_setting.zoom_in")}
|
||||
description=${translateText("user_setting.zoom_in_desc")}
|
||||
defaultKey="KeyE"
|
||||
.value=${this.getKeyValue("zoomIn")}
|
||||
.display=${this.getKeyChar("zoomIn")}
|
||||
.defaultKey=${this.defaultKeybinds.zoomIn}
|
||||
.value=${this.getKeyValue(KeybindAction.zoomIn)}
|
||||
.display=${this.getKeyChar(KeybindAction.zoomIn)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -670,52 +706,52 @@ export class UserSettingModal extends BaseModal {
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="centerCamera"
|
||||
action=${KeybindAction.centerCamera}
|
||||
label=${translateText("user_setting.center_camera")}
|
||||
description=${translateText("user_setting.center_camera_desc")}
|
||||
defaultKey="KeyC"
|
||||
.value=${this.getKeyValue("centerCamera")}
|
||||
.display=${this.getKeyChar("centerCamera")}
|
||||
.defaultKey=${this.defaultKeybinds.centerCamera}
|
||||
.value=${this.getKeyValue(KeybindAction.centerCamera)}
|
||||
.display=${this.getKeyChar(KeybindAction.centerCamera)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveUp"
|
||||
action=${KeybindAction.moveUp}
|
||||
label=${translateText("user_setting.move_up")}
|
||||
description=${translateText("user_setting.move_up_desc")}
|
||||
defaultKey="KeyW"
|
||||
.value=${this.getKeyValue("moveUp")}
|
||||
.display=${this.getKeyChar("moveUp")}
|
||||
.defaultKey=${this.defaultKeybinds.moveUp}
|
||||
.value=${this.getKeyValue(KeybindAction.moveUp)}
|
||||
.display=${this.getKeyChar(KeybindAction.moveUp)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveLeft"
|
||||
action=${KeybindAction.moveLeft}
|
||||
label=${translateText("user_setting.move_left")}
|
||||
description=${translateText("user_setting.move_left_desc")}
|
||||
defaultKey="KeyA"
|
||||
.value=${this.getKeyValue("moveLeft")}
|
||||
.display=${this.getKeyChar("moveLeft")}
|
||||
.defaultKey=${this.defaultKeybinds.moveLeft}
|
||||
.value=${this.getKeyValue(KeybindAction.moveLeft)}
|
||||
.display=${this.getKeyChar(KeybindAction.moveLeft)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveDown"
|
||||
action=${KeybindAction.moveDown}
|
||||
label=${translateText("user_setting.move_down")}
|
||||
description=${translateText("user_setting.move_down_desc")}
|
||||
defaultKey="KeyS"
|
||||
.value=${this.getKeyValue("moveDown")}
|
||||
.display=${this.getKeyChar("moveDown")}
|
||||
.defaultKey=${this.defaultKeybinds.moveDown}
|
||||
.value=${this.getKeyValue(KeybindAction.moveDown)}
|
||||
.display=${this.getKeyChar(KeybindAction.moveDown)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveRight"
|
||||
action=${KeybindAction.moveRight}
|
||||
label=${translateText("user_setting.move_right")}
|
||||
description=${translateText("user_setting.move_right_desc")}
|
||||
defaultKey="KeyD"
|
||||
.value=${this.getKeyValue("moveRight")}
|
||||
.display=${this.getKeyChar("moveRight")}
|
||||
.defaultKey=${this.defaultKeybinds.moveRight}
|
||||
.value=${this.getKeyValue(KeybindAction.moveRight)}
|
||||
.display=${this.getKeyChar(KeybindAction.moveRight)}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
`;
|
||||
|
||||
+90
-8
@@ -295,6 +295,26 @@ export function formatPercentage(value: number): string {
|
||||
return perc.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
let cachedKeyboardLayoutMap: Map<string, string> | null = null;
|
||||
let triedGetKeyboardLayoutMap = false;
|
||||
|
||||
export async function getKeyboardLayoutMap(): Promise<Map<
|
||||
string,
|
||||
string
|
||||
> | null> {
|
||||
if (triedGetKeyboardLayoutMap) return cachedKeyboardLayoutMap;
|
||||
|
||||
triedGetKeyboardLayoutMap = true;
|
||||
if (navigator.keyboard) {
|
||||
try {
|
||||
cachedKeyboardLayoutMap = await navigator.keyboard.getLayoutMap();
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch keyboard layout map", e);
|
||||
}
|
||||
}
|
||||
return cachedKeyboardLayoutMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a keyboard key code for user-friendly display.
|
||||
* Handles empty values, spaces, and normalizes key codes like "Digit1" and "KeyA".
|
||||
@@ -311,12 +331,31 @@ export function formatPercentage(value: number): string {
|
||||
* formatKeyForDisplay("") // returns ""
|
||||
*/
|
||||
export function formatKeyForDisplay(value: string): string {
|
||||
// TODO remove after testing
|
||||
console.info("formatKeyForDisplay recieved: " + value);
|
||||
|
||||
// Handle empty string
|
||||
if (!value) return "";
|
||||
|
||||
// Keyboard API years old, only supported in Chromium
|
||||
// keyboardLayoutMap translates "KeyW" to "Z" on AZERTY for example
|
||||
// Or layouts, e.g. Thai keyboard, that we may not support in the code below
|
||||
// It doesn't know about Alt, AltGr, Ctrl etc. though.
|
||||
if (!triedGetKeyboardLayoutMap) {
|
||||
getKeyboardLayoutMap();
|
||||
} else if (cachedKeyboardLayoutMap) {
|
||||
const key = cachedKeyboardLayoutMap.get(value);
|
||||
if (key) return key;
|
||||
}
|
||||
|
||||
// Handle space character or "Space" key
|
||||
if (value === " " || value === "Space") return "Space";
|
||||
|
||||
// Handle Shift+ prefix: format as "Shift+X"
|
||||
if (value.startsWith("Shift+")) {
|
||||
return "Shift+" + formatKeyForDisplay(value.slice(6));
|
||||
}
|
||||
|
||||
// Handle DigitN pattern (e.g., "Digit1" -> "1")
|
||||
if (/^Digit\d$/.test(value)) {
|
||||
return value.replace("Digit", "");
|
||||
@@ -327,6 +366,57 @@ export function formatKeyForDisplay(value: string): string {
|
||||
return value.replace("Key", "");
|
||||
}
|
||||
|
||||
const physicalMap: Record<string, string> = {
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Backquote: "`",
|
||||
Quote: "'",
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
Semicolon: ";",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Backslash: "\\",
|
||||
Shift: "Shift ⇧",
|
||||
ShiftLeft: "Shift ⇧",
|
||||
ShiftRight: "Shift ⇧",
|
||||
Control: "Ctrl",
|
||||
// "Alt Gr" emits ControlLeft+Alt in Windows on many keyboard layouts and we catch the first code.
|
||||
// Is undiscernable normally from ControlLeft but user sees "alt gr" on the key so display it too
|
||||
ControlLeft: "Ctrl / Alt Gr",
|
||||
ControlRight: "Ctrl",
|
||||
Alt: Platform.isMac ? "⌥" : "Alt",
|
||||
AltLeft: Platform.isMac ? "⌥" : "Alt",
|
||||
AltRight: Platform.isMac ? "⌥" : "Alt",
|
||||
Metat: Platform.isMac ? "⌘" : "⊞",
|
||||
MetaLeft: Platform.isMac ? "⌘" : "⊞", //"⊞" is Windows key, "⌘" is Command key on Mac
|
||||
MetaRight: Platform.isMac ? "⌘" : "⊞",
|
||||
Escape: "Esc", // Cannot be bound to action by user, but used as reserved key
|
||||
Enter: Platform.isMac ? "↵ Return" : "↵ Enter", // Called "Return" on Mac, "Enter" on Windows
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
NumpadAdd: "Num +",
|
||||
NumpadMultiply: "Num *",
|
||||
NumpadSubtract: "Num -",
|
||||
NumpadDivide: "Num /",
|
||||
NumpadDecimal: "Num .",
|
||||
NumpadEnter: "Num Enter", // Called "Enter" on most Mac and Windows keyboards
|
||||
NumLock: "Num Lock",
|
||||
};
|
||||
|
||||
if (physicalMap[value]) {
|
||||
return physicalMap[value];
|
||||
}
|
||||
|
||||
// TODO: display Numpad digits as only 0-9, no Num prefix
|
||||
// Also handle Numpadx the same as Digitx in InputHandler, just like it already does for building structures
|
||||
if (value.startsWith("Numpad")) {
|
||||
return `Num ${value.slice(6)}`;
|
||||
}
|
||||
|
||||
// Fallback: capitalize first letter
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
@@ -528,14 +618,6 @@ export function getMessageTypeClasses(type: MessageType): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getModifierKey(): string {
|
||||
return Platform.isMac ? "⌘" : "Ctrl";
|
||||
}
|
||||
|
||||
export function getAltKey(): string {
|
||||
return Platform.isMac ? "⌥" : "Alt";
|
||||
}
|
||||
|
||||
export function getGamesPlayed(): number {
|
||||
try {
|
||||
return parseInt(localStorage.getItem("gamesPlayed") ?? "0", 10) || 0;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Platform } from "src/client/Platform";
|
||||
import { formatKeyForDisplay, translateText } from "../../../../client/Utils";
|
||||
import { KeybindAction, KeyUnbound } from "../../../../core/game/UserSettings";
|
||||
|
||||
@customElement("setting-keybind")
|
||||
export class SettingKeybind extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: String, reflect: true }) action = "";
|
||||
@property({ type: String, reflect: true }) action!: KeybindAction;
|
||||
@property({ type: String }) defaultKey = "";
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: String }) display = "";
|
||||
@@ -78,7 +80,7 @@ export class SettingKeybind extends LitElement {
|
||||
}
|
||||
|
||||
private displayKey(key: string): string {
|
||||
if (!key || key === "Null") return translateText("common.none");
|
||||
if (!key || key === KeyUnbound) return translateText("common.none");
|
||||
return formatKeyForDisplay(key);
|
||||
}
|
||||
|
||||
@@ -90,6 +92,7 @@ export class SettingKeybind extends LitElement {
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (!this.listening) return;
|
||||
|
||||
// TODO: add Enter, and ARROW KEYS (just like Alt+R, etc should be in new reserved keys enum in UserSettings or so)
|
||||
// Allow Tab and Escape to work normally (don't trap focus)
|
||||
if (e.key === "Tab" || e.key === "Escape") {
|
||||
if (e.key === "Escape") {
|
||||
@@ -100,17 +103,47 @@ export class SettingKeybind extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Keydown event:", e);
|
||||
|
||||
// On Windows, Meta (Win) key always opens Start Menu
|
||||
// Don't allow binding, this will lead to frustration
|
||||
// On Apple, Meta (Cmd) key is commonly used as modifier, so allow it
|
||||
if (
|
||||
Platform.isWindows &&
|
||||
(e.code === "MetaLeft" || e.code === "MetaRight")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// - Don't capture lone Shift — wait for the actual key
|
||||
// - Lone Meta (if not Windows), Ctrl and Alt are allowed: for buildMenuModifier and emojiMenuModifier,
|
||||
// and to prevent setting a Ctrl+key/Alt+key etc combos in the future if code further down would come to accept other combos,
|
||||
// (to prevent issues with browser combos like Ctrl+T or Alt+N which should keep working)
|
||||
// and to prevent e.g. AltGr+8 from confusingly showing as e.key "3/4", even when e.code is still just "Digit8", since AltGr is Ctrl+Alt in Windows
|
||||
if (e.code === "ShiftLeft" || e.code === "ShiftRight") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent default only for keys we're actually capturing
|
||||
e.preventDefault();
|
||||
|
||||
const code = e.code;
|
||||
// buildMenuModifier and emojiMenuModifier should not get combo key:
|
||||
// because they work as 'modifier'+left mouse click already, and
|
||||
// Shift+click is reserved for attack when leftClickOpensMenu is false.
|
||||
const noShiftModifier =
|
||||
this.action === KeybindAction.buildMenuModifier ||
|
||||
this.action === KeybindAction.emojiMenuModifier;
|
||||
|
||||
const code = !noShiftModifier && e.shiftKey ? `Shift+${e.code}` : e.code;
|
||||
const displayKey =
|
||||
!noShiftModifier && e.shiftKey ? `Shift+${e.key.toUpperCase()}` : e.key;
|
||||
const prevValue = this.value;
|
||||
|
||||
// Temporarily set the value to the new code for validation in parent
|
||||
this.value = code;
|
||||
this.value = code;
|
||||
|
||||
const event = new CustomEvent("change", {
|
||||
detail: { action: this.action, value: code, key: e.key, prevValue },
|
||||
detail: { action: this.action, value: code, key: displayKey, prevValue },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
@@ -142,12 +175,12 @@ export class SettingKeybind extends LitElement {
|
||||
}
|
||||
|
||||
private unbindKey() {
|
||||
this.value = "Null";
|
||||
this.value = KeyUnbound;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: {
|
||||
action: this.action,
|
||||
value: "Null",
|
||||
value: KeyUnbound,
|
||||
key: "",
|
||||
},
|
||||
bubbles: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { KeybindAction, UserSettings } from "../../../core/game/UserSettings";
|
||||
import {
|
||||
GhostStructureChangedEvent,
|
||||
ToggleStructureEvent,
|
||||
@@ -36,7 +36,9 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
public eventBus: EventBus;
|
||||
public uiState: UIState;
|
||||
private playerBuildables: BuildableUnit[] | null = null;
|
||||
private keybinds: Record<string, { value: string; key: string }> = {};
|
||||
private keybinds: Partial<
|
||||
Record<KeybindAction, { value: string; key: string }>
|
||||
> = {};
|
||||
private _cities = 0;
|
||||
private _warships = 0;
|
||||
private _factories = 0;
|
||||
@@ -53,9 +55,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
|
||||
init() {
|
||||
const config = this.game.config();
|
||||
const userSettings = new UserSettings();
|
||||
|
||||
this.keybinds = userSettings.parsedUserKeybinds();
|
||||
this.keybinds = new UserSettings().parsedUserKeybinds();
|
||||
|
||||
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
|
||||
this.requestUpdate();
|
||||
@@ -131,70 +131,70 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
this._cities,
|
||||
UnitType.City,
|
||||
"city",
|
||||
this.keybinds["buildCity"]?.key ?? "1",
|
||||
this.keybinds.buildCity?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
factoryIcon,
|
||||
this._factories,
|
||||
UnitType.Factory,
|
||||
"factory",
|
||||
this.keybinds["buildFactory"]?.key ?? "2",
|
||||
this.keybinds.buildFactory?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
portIcon,
|
||||
this._port,
|
||||
UnitType.Port,
|
||||
"port",
|
||||
this.keybinds["buildPort"]?.key ?? "3",
|
||||
this.keybinds.buildPort?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
defensePostIcon,
|
||||
this._defensePost,
|
||||
UnitType.DefensePost,
|
||||
"defense_post",
|
||||
this.keybinds["buildDefensePost"]?.key ?? "4",
|
||||
this.keybinds.buildDefensePost?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
missileSiloIcon,
|
||||
this._missileSilo,
|
||||
UnitType.MissileSilo,
|
||||
"missile_silo",
|
||||
this.keybinds["buildMissileSilo"]?.key ?? "5",
|
||||
this.keybinds.buildMissileSilo?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
samLauncherIcon,
|
||||
this._samLauncher,
|
||||
UnitType.SAMLauncher,
|
||||
"sam_launcher",
|
||||
this.keybinds["buildSamLauncher"]?.key ?? "6",
|
||||
this.keybinds.buildSamLauncher?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
warshipIcon,
|
||||
this._warships,
|
||||
UnitType.Warship,
|
||||
"warship",
|
||||
this.keybinds["buildWarship"]?.key ?? "7",
|
||||
this.keybinds.buildWarship?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
atomBombIcon,
|
||||
null,
|
||||
UnitType.AtomBomb,
|
||||
"atom_bomb",
|
||||
this.keybinds["buildAtomBomb"]?.key ?? "8",
|
||||
this.keybinds.buildAtomBomb?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
hydrogenBombIcon,
|
||||
null,
|
||||
UnitType.HydrogenBomb,
|
||||
"hydrogen_bomb",
|
||||
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
|
||||
this.keybinds.buildHydrogenBomb?.key ?? "",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
mirvIcon,
|
||||
null,
|
||||
UnitType.MIRV,
|
||||
"mirv",
|
||||
this.keybinds["buildMIRV"]?.key ?? "0",
|
||||
this.keybinds.buildMIRV?.key ?? "",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+9
@@ -34,3 +34,12 @@ declare module "*.webp" {
|
||||
const webpContent: string;
|
||||
export default webpContent;
|
||||
}
|
||||
|
||||
// keyboard API is 'Expirimental' even if 8 years old because only supported in Chromium
|
||||
// but we want to use it without having to cast 'as any', so define it here.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Keyboard_API
|
||||
interface Navigator {
|
||||
keyboard?: {
|
||||
getLayoutMap(): Promise<Map<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
+107
-58
@@ -1,39 +1,83 @@
|
||||
import { Cosmetics } from "../CosmeticSchemas";
|
||||
import { PlayerPattern } from "../Schemas";
|
||||
|
||||
export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
|
||||
// ADD Reserved keys here or in Utils.ts:
|
||||
// (maybe also comment about Shift+left quick being reserved for future devs, see HelpModal, code in onPointerUp in InputHandler: when leftClickOpensMenu is false, Shift+left click is hardcoded to be attack. So it should not be used elsewhere where modifier+click is expected)
|
||||
// Shift+D for performance overlay, Alt+R for reset gfx (NO not reserved anymore, can be changed with fallback removed), Escape for menu close and cancel ghost build, Enter for confirm Ghost build
|
||||
// But also browser combos: Ctrl+Shift+I for dev tools, etc. Shift+Tab for backwards tabbing through fields. Alt+N for new browser screen, etc. If we won't just suppress Alt/Ctrl etc alltogether or if used in combination (but then confusing things could still happen)
|
||||
// These keys won't be available for binding in UserSettingsModal.
|
||||
|
||||
export const KeyUnbound = "Null";
|
||||
|
||||
export enum KeybindAction {
|
||||
toggleView = "toggleView",
|
||||
coordinateGrid = "coordinateGrid",
|
||||
buildCity = "buildCity",
|
||||
buildFactory = "buildFactory",
|
||||
buildPort = "buildPort",
|
||||
buildDefensePost = "buildDefensePost",
|
||||
buildMissileSilo = "buildMissileSilo",
|
||||
buildSamLauncher = "buildSamLauncher",
|
||||
buildWarship = "buildWarship",
|
||||
buildAtomBomb = "buildAtomBomb",
|
||||
buildHydrogenBomb = "buildHydrogenBomb",
|
||||
buildMIRV = "buildMIRV",
|
||||
attackRatioDown = "attackRatioDown",
|
||||
attackRatioUp = "attackRatioUp",
|
||||
boatAttack = "boatAttack",
|
||||
groundAttack = "groundAttack",
|
||||
swapDirection = "swapDirection",
|
||||
zoomOut = "zoomOut",
|
||||
zoomIn = "zoomIn",
|
||||
centerCamera = "centerCamera",
|
||||
moveUp = "moveUp",
|
||||
moveLeft = "moveLeft",
|
||||
moveDown = "moveDown",
|
||||
moveRight = "moveRight",
|
||||
buildMenuModifier = "buildMenuModifier",
|
||||
emojiMenuModifier = "emojiMenuModifier",
|
||||
shiftKey = "shiftKey",
|
||||
resetGfx = "resetGfx",
|
||||
pauseGame = "pauseGame",
|
||||
gameSpeedUp = "gameSpeedUp",
|
||||
gameSpeedDown = "gameSpeedDown",
|
||||
}
|
||||
|
||||
export function getDefaultKeybinds(
|
||||
isMac: boolean,
|
||||
): Record<KeybindAction, string> {
|
||||
return {
|
||||
toggleView: "Space",
|
||||
coordinateGrid: "KeyM",
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Digit2",
|
||||
buildPort: "Digit3",
|
||||
buildDefensePost: "Digit4",
|
||||
buildMissileSilo: "Digit5",
|
||||
buildSamLauncher: "Digit6",
|
||||
buildWarship: "Digit7",
|
||||
buildAtomBomb: "Digit8",
|
||||
buildHydrogenBomb: "Digit9",
|
||||
buildMIRV: "Digit0",
|
||||
attackRatioDown: "KeyT",
|
||||
attackRatioUp: "KeyY",
|
||||
boatAttack: "KeyB",
|
||||
groundAttack: "KeyG",
|
||||
swapDirection: "KeyU",
|
||||
zoomOut: "KeyQ",
|
||||
zoomIn: "KeyE",
|
||||
centerCamera: "KeyC",
|
||||
moveUp: "KeyW",
|
||||
moveLeft: "KeyA",
|
||||
moveDown: "KeyS",
|
||||
moveRight: "KeyD",
|
||||
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
|
||||
altKey: "AltLeft",
|
||||
shiftKey: "ShiftLeft",
|
||||
resetGfx: "KeyR",
|
||||
pauseGame: "KeyP",
|
||||
gameSpeedUp: "Period",
|
||||
gameSpeedDown: "Comma",
|
||||
[KeybindAction.toggleView]: "Space",
|
||||
[KeybindAction.coordinateGrid]: "KeyM",
|
||||
[KeybindAction.buildCity]: "Digit1",
|
||||
[KeybindAction.buildFactory]: "Digit2",
|
||||
[KeybindAction.buildPort]: "Digit3",
|
||||
[KeybindAction.buildDefensePost]: "Digit4",
|
||||
[KeybindAction.buildMissileSilo]: "Digit5",
|
||||
[KeybindAction.buildSamLauncher]: "Digit6",
|
||||
[KeybindAction.buildWarship]: "Digit7",
|
||||
[KeybindAction.buildAtomBomb]: "Digit8",
|
||||
[KeybindAction.buildHydrogenBomb]: "Digit9",
|
||||
[KeybindAction.buildMIRV]: "Digit0",
|
||||
[KeybindAction.attackRatioDown]: "KeyT",
|
||||
[KeybindAction.attackRatioUp]: "KeyY",
|
||||
[KeybindAction.boatAttack]: "KeyB",
|
||||
[KeybindAction.groundAttack]: "KeyG",
|
||||
[KeybindAction.swapDirection]: "KeyU",
|
||||
[KeybindAction.zoomOut]: "KeyQ",
|
||||
[KeybindAction.zoomIn]: "KeyE",
|
||||
[KeybindAction.centerCamera]: "KeyC",
|
||||
[KeybindAction.moveUp]: "KeyW",
|
||||
[KeybindAction.moveLeft]: "KeyA",
|
||||
[KeybindAction.moveDown]: "KeyS",
|
||||
[KeybindAction.moveRight]: "KeyD",
|
||||
[KeybindAction.buildMenuModifier]: isMac ? "MetaLeft" : "ControlLeft",
|
||||
[KeybindAction.emojiMenuModifier]: "AltLeft",
|
||||
[KeybindAction.shiftKey]: "ShiftLeft",
|
||||
[KeybindAction.resetGfx]: "KeyR",
|
||||
[KeybindAction.pauseGame]: "KeyP",
|
||||
[KeybindAction.gameSpeedUp]: "Period",
|
||||
[KeybindAction.gameSpeedDown]: "Comma",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +121,7 @@ export class UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
public removeCached(key: string, emitChange: boolean = true) {
|
||||
private removeCached(key: string, emitChange: boolean = true) {
|
||||
localStorage.removeItem(key);
|
||||
UserSettings.cache.set(key, null);
|
||||
if (emitChange) {
|
||||
@@ -321,7 +365,7 @@ export class UserSettings {
|
||||
}
|
||||
|
||||
// In case localStorage was manually edited to be invalid, return an empty object
|
||||
parsedUserKeybinds(): Record<string, any> {
|
||||
parsedUserKeybinds(): Partial<Record<KeybindAction, any>> {
|
||||
const raw = this.getString(KEYBINDS_KEY, "{}");
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -334,49 +378,54 @@ export class UserSettings {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Returns a flat keybind map { action: "keyCode" }, handling nested objects and legacy strings
|
||||
private normalizedUserKeybinds(): Record<string, string> {
|
||||
// Returns a flat keybind map { action: "code" }, handling nested objects and legacy strings
|
||||
private normalizedUserKeybinds(): Record<KeybindAction, string> {
|
||||
const parsed = this.parsedUserKeybinds();
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed)
|
||||
// Extract value from nested object or plain string, filter out non-string values
|
||||
.map(([k, v]) => {
|
||||
let val = v;
|
||||
if (v && typeof v === "object" && !Array.isArray(v) && "value" in v) {
|
||||
val = v.value;
|
||||
.map(([action, codeAndKey]) => {
|
||||
let code = codeAndKey;
|
||||
if (
|
||||
codeAndKey &&
|
||||
typeof codeAndKey === "object" &&
|
||||
!Array.isArray(codeAndKey) &&
|
||||
"value" in codeAndKey
|
||||
) {
|
||||
code = codeAndKey.value;
|
||||
}
|
||||
if (Array.isArray(val) && typeof val[0] === "string") {
|
||||
val = val[0];
|
||||
if (Array.isArray(code) && typeof code[0] === "string") {
|
||||
code = code[0];
|
||||
}
|
||||
return [k, val];
|
||||
return [action, code];
|
||||
})
|
||||
.filter(([, v]) => typeof v === "string"),
|
||||
) as Record<string, string>;
|
||||
.filter(([, code]) => typeof code === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
keybinds(isMac: boolean): Record<string, string> {
|
||||
const merged = {
|
||||
keybinds(isMac: boolean): Record<KeybindAction, string> {
|
||||
const mergedKeybinds = {
|
||||
...getDefaultKeybinds(isMac),
|
||||
...this.normalizedUserKeybinds(),
|
||||
};
|
||||
// Actually unbind key: if Unbind is clicked in UserSettingsModal, eg. for Attack Ratio Up,
|
||||
// keybind is "Null". Even if it is in default kindbinds (Y), it should not work anymore.
|
||||
// keybind is KeyUnbound. Even if it is in default kindbinds (Y), it should not work anymore.
|
||||
// The key (Y) can now be bound to another action like Boat Attack, and no two actions listen to the same key.
|
||||
for (const k in merged) {
|
||||
if (merged[k] === "Null") {
|
||||
delete merged[k];
|
||||
for (const action in mergedKeybinds) {
|
||||
if (mergedKeybinds[action] === KeyUnbound) {
|
||||
delete mergedKeybinds[action];
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
return mergedKeybinds;
|
||||
}
|
||||
|
||||
setKeybinds(value: string | Record<string, any>): void {
|
||||
if (typeof value === "string") {
|
||||
this.setString(KEYBINDS_KEY, value);
|
||||
} else {
|
||||
this.setString(KEYBINDS_KEY, JSON.stringify(value));
|
||||
}
|
||||
setUserKeybinds(value: Record<string, any>): void {
|
||||
this.setString(KEYBINDS_KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
removeUserKeybinds(emitChange: boolean = true): void {
|
||||
this.removeCached(KEYBINDS_KEY, emitChange);
|
||||
}
|
||||
|
||||
soundEffectsVolume(): number {
|
||||
|
||||
+163
-19
@@ -7,7 +7,11 @@ import { UIState } from "../src/client/graphics/UIState";
|
||||
import { EventBus } from "../src/core/EventBus";
|
||||
import { UnitType } from "../src/core/game/Game";
|
||||
import { GameView } from "../src/core/game/GameView";
|
||||
import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings";
|
||||
import {
|
||||
KEYBINDS_KEY,
|
||||
KeyUnbound,
|
||||
UserSettings,
|
||||
} from "../src/core/game/UserSettings";
|
||||
|
||||
class MockPointerEvent {
|
||||
button: number;
|
||||
@@ -38,15 +42,23 @@ global.PointerEvent = MockPointerEvent as any;
|
||||
describe("InputHandler AutoUpgrade", () => {
|
||||
let inputHandler: InputHandler;
|
||||
let mockGameView: GameView;
|
||||
let mockUIState: UIState;
|
||||
let eventBus: EventBus;
|
||||
let mockCanvas: HTMLCanvasElement;
|
||||
let testSettings: UserSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
testSettings = new UserSettings();
|
||||
testSettings.removeCached(KEYBINDS_KEY, false);
|
||||
testSettings.removeUserKeybinds(false);
|
||||
|
||||
mockGameView = { inSpawnPhase: () => false } as GameView;
|
||||
mockUIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
};
|
||||
mockCanvas = document.createElement("canvas");
|
||||
mockCanvas.width = 800;
|
||||
mockCanvas.height = 600;
|
||||
@@ -55,13 +67,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
{
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
},
|
||||
mockUIState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
@@ -238,6 +244,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
mockGameView.inSpawnPhase = () => true;
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
|
||||
inputHandler.initialize();
|
||||
inputHandler["userSettings"].leftClickOpensMenu = () => true;
|
||||
|
||||
const pointerEvent = new PointerEvent("pointerup", {
|
||||
@@ -485,7 +492,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
const nested = {
|
||||
moveUp: { key: "moveUp", value: "KeyZ" },
|
||||
};
|
||||
testSettings.setKeybinds(nested);
|
||||
testSettings.setUserKeybinds(nested);
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
@@ -493,30 +500,33 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
});
|
||||
|
||||
test("accepts legacy string values", () => {
|
||||
testSettings.setKeybinds({ moveUp: "KeyX" });
|
||||
testSettings.setUserKeybinds({ moveUp: "KeyX" });
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
expect((inputHandler as any).keybinds.moveUp).toBe("KeyX");
|
||||
});
|
||||
|
||||
test("ignores non-string values and preserves defaults, removes 'Null' for unbound keys", () => {
|
||||
test("ignores non-string values and preserves defaults, removes KeyUnbound keys", () => {
|
||||
const mixed = {
|
||||
moveUp: { key: "moveUp", value: null },
|
||||
moveLeft: "Null",
|
||||
moveLeft: KeyUnbound,
|
||||
};
|
||||
testSettings.setKeybinds(mixed);
|
||||
testSettings.setUserKeybinds(mixed);
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
||||
// "Null" entries are removed entirely to indicate unbound keybind
|
||||
// KeyUnbound entries are removed entirely to indicate unbound keybind
|
||||
expect((inputHandler as any).keybinds.moveLeft).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles invalid JSON gracefully and warns", () => {
|
||||
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
testSettings.setKeybinds("not a json");
|
||||
// in case someone tried to directly save it in localStorage,
|
||||
// because it's not possible to send a non-Object to setKeybinds
|
||||
(UserSettings as any).cache.delete(KEYBINDS_KEY);
|
||||
localStorage.setItem(KEYBINDS_KEY, "not a json");
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
@@ -645,7 +655,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
});
|
||||
|
||||
test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => {
|
||||
testSettings.setKeybinds({
|
||||
testSettings.setUserKeybinds({
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Numpad1",
|
||||
});
|
||||
@@ -673,7 +683,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
});
|
||||
|
||||
test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => {
|
||||
testSettings.setKeybinds({
|
||||
testSettings.setUserKeybinds({
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Numpad1",
|
||||
});
|
||||
@@ -701,7 +711,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
});
|
||||
|
||||
test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => {
|
||||
testSettings.setKeybinds({ buildCity: "Digit1" });
|
||||
testSettings.setUserKeybinds({ buildCity: "Digit1" });
|
||||
inputHandler.destroy();
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
@@ -725,4 +735,138 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shift+ keybind support", () => {
|
||||
let uiState: UIState;
|
||||
|
||||
beforeEach(() => {
|
||||
inputHandler.destroy();
|
||||
uiState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
});
|
||||
|
||||
test("Shift+Digit1 sets City when buildCity is bound to Shift+Digit1", () => {
|
||||
testSettings.setUserKeybinds({ buildCity: "Shift+Digit1" });
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }),
|
||||
);
|
||||
|
||||
expect(uiState.ghostStructure).toBe(UnitType.City);
|
||||
});
|
||||
|
||||
test("plain Digit1 does NOT trigger buildCity when bound to Shift+Digit1", () => {
|
||||
testSettings.setUserKeybinds({ buildCity: "Shift+Digit1" });
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }),
|
||||
);
|
||||
|
||||
expect(uiState.ghostStructure).toBeNull();
|
||||
});
|
||||
|
||||
test("Shift+KeyB triggers boatAttack when bound to Shift+KeyB", () => {
|
||||
testSettings.setUserKeybinds({ boatAttack: "Shift+KeyB" });
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "KeyB", shiftKey: true }),
|
||||
);
|
||||
|
||||
const emittedTypes = mockEmit.mock.calls.map(
|
||||
(call) => call[0].constructor.name,
|
||||
);
|
||||
expect(emittedTypes).toContain("DoBoatAttackEvent");
|
||||
});
|
||||
|
||||
test("plain KeyB does NOT trigger boatAttack when bound to Shift+KeyB", () => {
|
||||
testSettings.setUserKeybinds({ boatAttack: "Shift+KeyB" });
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "KeyB", shiftKey: false }),
|
||||
);
|
||||
|
||||
const emittedTypes = mockEmit.mock.calls.map(
|
||||
(call) => call[0].constructor.name,
|
||||
);
|
||||
expect(emittedTypes).not.toContain("DoBoatAttackEvent");
|
||||
});
|
||||
|
||||
test("Shift+Digit1 and Digit1 can be bound to different actions without conflict", () => {
|
||||
testSettings.setUserKeybinds({
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Shift+Digit1",
|
||||
});
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }),
|
||||
);
|
||||
expect(uiState.ghostStructure).toBe(UnitType.City);
|
||||
|
||||
uiState.ghostStructure = null;
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }),
|
||||
);
|
||||
expect(uiState.ghostStructure).toBe(UnitType.Factory);
|
||||
});
|
||||
|
||||
test("Numpad alias works with Shift+Digit keybind", () => {
|
||||
testSettings.setUserKeybinds({ buildCity: "Shift+Digit1" });
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad1", shiftKey: true }),
|
||||
);
|
||||
|
||||
expect(uiState.ghostStructure).toBe(UnitType.City);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user