First dev commit, WIP

This commit is contained in:
VariableVince
2026-04-22 00:27:30 +02:00
parent 318d1e2c44
commit f3d7a3122a
11 changed files with 703 additions and 378 deletions
+3 -2
View File
@@ -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 sites 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
View File
@@ -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
View File
@@ -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)
);
}
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+15 -15
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
});
});