Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/client/HelpModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
import { Platform } from "./Platform";
import { TroubleshootingModal } from "./TroubleshootingModal";
import {
getKeyForCode,
loadKeyboardLayout,
subscribeToLayoutChange,
} from "./utilities/KeyboardLayout";

@customElement("help-modal")
export class HelpModal extends BaseModal {
Expand All @@ -16,6 +21,24 @@ export class HelpModal extends BaseModal {
@state() private keybinds: Record<string, string> = this.getKeybinds();
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;

private unsubscribeLayout: (() => void) | null = null;

connectedCallback() {
super.connectedCallback();
this.unsubscribeLayout = subscribeToLayoutChange(() => {
// Re-render so getKeyLabel picks up the new layout-mapped characters.
this.requestUpdate();
});
// Kick off the load if it hasn't happened yet (idempotent).
void loadKeyboardLayout();
}

disconnectedCallback() {
this.unsubscribeLayout?.();
this.unsubscribeLayout = null;
super.disconnectedCallback();
}

private getKeybinds(): Record<string, string> {
return new UserSettings().keybinds(Platform.isMac);
}
Expand Down Expand Up @@ -44,6 +67,12 @@ export class HelpModal extends BaseModal {
};

if (specialLabels[code]) return specialLabels[code];

// Use the user's actual keyboard layout when available (Chromium browsers).
// E.g. on AZERTY, "KeyW" → "Z" instead of the QWERTY-encoded "W".
const layoutChar = getKeyForCode(code);
if (layoutChar) return layoutChar.toUpperCase();

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)}`;
Expand Down
8 changes: 8 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
translateText,
} from "./Utils";
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";
import { loadKeyboardLayout } from "./utilities/KeyboardLayout";

import "./components/DesktopNavBar";
import "./components/Footer";
Expand Down Expand Up @@ -1073,6 +1074,13 @@ const bootstrap = () => {
// on iOS and can softlock the HUD. See issue #2330.
installSafariPinchZoomBlocker();

// Pre-load the keyboard layout map so keybind labels (settings, help
// modal, build-menu HUD) show the user's actual keyboard characters
// (e.g. "Z" on AZERTY instead of the QWERTY-encoded "W"). Fire-and-forget;
// any not-yet-loaded display falls back to QWERTY and re-renders when the
// map resolves. See issue #1071.
void loadKeyboardLayout();

initLayout();
new Client().initialize();
initNavigation();
Expand Down
18 changes: 18 additions & 0 deletions src/client/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { GameConfig } from "../core/Schemas";
import type { LangSelector } from "./LangSelector";
import { Platform } from "./Platform";
import { getKeyForCode } from "./utilities/KeyboardLayout";

export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";

Expand Down Expand Up @@ -310,6 +311,15 @@ export function formatPercentage(value: number): string {
* Formats a keyboard key code for user-friendly display.
* Handles empty values, spaces, and normalizes key codes like "Digit1" and "KeyA".
*
* When the [Keyboard Layout Map API][1] is available (Chromium browsers
* after {@link loadKeyboardLayout} resolves), letter and digit codes are
* resolved to the character produced by the user's actual keyboard layout
* — e.g. `"KeyW"` returns `"Z"` on AZERTY, `","` on Dvorak. Other browsers
* (Firefox, Safari) and not-yet-loaded states fall back to the QWERTY
* letter encoded in the code itself.
*
* [1]: https://developer.mozilla.org/en-US/docs/Web/API/Keyboard
*
* @param value - The key code to format (e.g., "Digit1", "KeyA", "Space")
* @returns The formatted key for display (e.g., "1", "A", "Space")
*
Expand All @@ -333,6 +343,14 @@ export function formatKeyForDisplay(value: string): string {
// Handle space character or "Space" key
if (value === " " || value === "Space") return "Space";

// Prefer the user's actual keyboard layout (e.g. "KeyW" -> "z" on AZERTY).
// Returns null on Firefox/Safari or while the map is still loading; we
// fall through to the QWERTY-style normalization below in that case.
const layoutChar = getKeyForCode(value);
if (layoutChar) {
return layoutChar.toUpperCase();
}

// Handle DigitN pattern (e.g., "Digit1" -> "1")
if (/^Digit\d$/.test(value)) {
return value.replace("Digit", "");
Expand Down
23 changes: 23 additions & 0 deletions src/client/components/baseComponents/setting/SettingKeybind.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { formatKeyForDisplay, translateText } from "../../../../client/Utils";
import {
loadKeyboardLayout,
subscribeToLayoutChange,
} from "../../../../client/utilities/KeyboardLayout";

@customElement("setting-keybind")
export class SettingKeybind extends LitElement {
Expand All @@ -12,10 +16,29 @@ export class SettingKeybind extends LitElement {
@property({ type: String }) display = "";
@property({ type: Boolean }) easter = false;

private unsubscribeLayout: (() => void) | null = null;

createRenderRoot() {
return this;
}

connectedCallback() {
super.connectedCallback();
// Re-render when the keyboard layout map loads, or the user switches
// layouts at the OS level, so the displayed character matches the
// user's actual keyboard.
this.unsubscribeLayout = subscribeToLayoutChange(() => {
this.requestUpdate();
});
void loadKeyboardLayout();
}

disconnectedCallback() {
this.unsubscribeLayout?.();
this.unsubscribeLayout = null;
super.disconnectedCallback();
}

private listening = false;

render() {
Expand Down
60 changes: 45 additions & 15 deletions src/client/hud/layers/UnitDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { ToggleStructureEvent } from "../../InputHandler";
import { UIState } from "../../UIState";
import { renderNumber, translateText } from "../../Utils";
import { formatKeyForDisplay, renderNumber, translateText } from "../../Utils";
import {
loadKeyboardLayout,
subscribeToLayoutChange,
} from "../../utilities/KeyboardLayout";
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
Expand All @@ -34,6 +38,7 @@ export class UnitDisplay extends LitElement implements Controller {
public uiState: UIState;
private playerBuildables: BuildableUnit[] | null = null;
private keybinds: Record<string, { value: string; key: string }> = {};
private unsubscribeLayout: (() => void) | null = null;
private _cities = 0;
private _warships = 0;
private _factories = 0;
Expand All @@ -55,9 +60,35 @@ export class UnitDisplay extends LitElement implements Controller {
this.keybinds = userSettings.parsedUserKeybinds();

this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
// Re-render when the keyboard layout map loads or the user switches
// layouts, so the hotkey hints (e.g. "1"/"2"/.../"W") match what the
// user has printed on their physical keys.
this.unsubscribeLayout?.();
this.unsubscribeLayout = subscribeToLayoutChange(() => {
this.requestUpdate();
});
void loadKeyboardLayout();
this.requestUpdate();
}

disconnectedCallback() {
this.unsubscribeLayout?.();
this.unsubscribeLayout = null;
super.disconnectedCallback();
}

/**
* Returns the character to display next to a build-menu icon.
* Prefers the user's own saved character when they have rebound the
* action; otherwise translates the default code (e.g. "Digit1") through
* the current keyboard layout via {@link formatKeyForDisplay}.
*/
private hotkeyLabel(action: string, defaultCode: string): string {
const userKey = this.keybinds[action]?.key;
if (userKey) return userKey.toUpperCase();
return formatKeyForDisplay(defaultCode);
}

private cost(item: UnitType): Gold {
for (const bu of this.playerBuildables ?? []) {
if (bu.type === item) {
Expand Down Expand Up @@ -128,70 +159,70 @@ export class UnitDisplay extends LitElement implements Controller {
this._cities,
UnitType.City,
"city",
this.keybinds["buildCity"]?.key ?? "1",
this.hotkeyLabel("buildCity", "Digit1"),
)}
${this.renderUnitItem(
factoryIcon,
this._factories,
UnitType.Factory,
"factory",
this.keybinds["buildFactory"]?.key ?? "2",
this.hotkeyLabel("buildFactory", "Digit2"),
)}
${this.renderUnitItem(
portIcon,
this._port,
UnitType.Port,
"port",
this.keybinds["buildPort"]?.key ?? "3",
this.hotkeyLabel("buildPort", "Digit3"),
)}
${this.renderUnitItem(
defensePostIcon,
this._defensePost,
UnitType.DefensePost,
"defense_post",
this.keybinds["buildDefensePost"]?.key ?? "4",
this.hotkeyLabel("buildDefensePost", "Digit4"),
)}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
UnitType.MissileSilo,
"missile_silo",
this.keybinds["buildMissileSilo"]?.key ?? "5",
this.hotkeyLabel("buildMissileSilo", "Digit5"),
)}
${this.renderUnitItem(
samLauncherIcon,
this._samLauncher,
UnitType.SAMLauncher,
"sam_launcher",
this.keybinds["buildSamLauncher"]?.key ?? "6",
this.hotkeyLabel("buildSamLauncher", "Digit6"),
)}
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "7",
this.hotkeyLabel("buildWarship", "Digit7"),
)}
${this.renderUnitItem(
atomBombIcon,
null,
UnitType.AtomBomb,
"atom_bomb",
this.keybinds["buildAtomBomb"]?.key ?? "8",
this.hotkeyLabel("buildAtomBomb", "Digit8"),
)}
${this.renderUnitItem(
hydrogenBombIcon,
null,
UnitType.HydrogenBomb,
"hydrogen_bomb",
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
this.hotkeyLabel("buildHydrogenBomb", "Digit9"),
)}
${this.renderUnitItem(
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
this.hotkeyLabel("buildMIRV", "Digit0"),
)}
</div>
</div>
Expand All @@ -210,10 +241,9 @@ export class UnitDisplay extends LitElement implements Controller {
}
const selected = this.uiState.ghostStructure === unitType;
const hovered = this._hoveredUnit === unitType;
const displayHotkey = hotkey
.replace("Digit", "")
.replace("Key", "")
.toUpperCase();
// hotkey already comes from hotkeyLabel() pre-formatted via the layout
// map; uppercase it here as a final-line normalization.
const displayHotkey = hotkey.toUpperCase();

return html`
<div
Expand Down
Loading
Loading