vbonnet commited on
Commit
c820e39
·
verified ·
1 Parent(s): 1261013

Show layout-aware keyboard labels

Browse files

Display keyboard tutorial labels for the active layout while keeping the existing physical bindings. Fall back to QWERTY/AZERTY labels when layout data is unavailable.

Tested with `npm test` and `npm run build`.

app/package.json CHANGED
@@ -5,6 +5,7 @@
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
 
8
  "build": "vite build",
9
  "preview": "vite preview"
10
  },
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
+ "test": "node --test \"test/**/*.test.js\"",
9
  "build": "vite build",
10
  "preview": "vite preview"
11
  },
app/src/ui/TitleMenu.jsx CHANGED
@@ -15,6 +15,7 @@ import { useGame } from "../store.js";
15
  import { signed } from "../game/signed.js";
16
  import { INK, ORANGE, MONO } from "../theme.js";
17
  import { ComicButton, ComicTitle, HalftoneRamp, ANTON } from "./comic.jsx";
 
18
 
19
  const rowIn = keyframes`
20
  from { transform: translateY(12px); opacity: 0; }
@@ -49,13 +50,14 @@ const Kbd = styled("kbd")(({ round }) => ({
49
  boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
50
  }));
51
 
 
52
  const TILES = {
53
  kb: [
54
- { caps: "cluster-arrows", name: "Move", hint: "arrows or ZQSD" },
55
- { caps: ["A", "E"], name: "Kick", hint: "left / right" },
56
- { caps: ["R"], name: "Sit", hint: "tap again to stand" },
57
- { caps: ["G"], name: "Pick up", hint: "beak to the ground" },
58
- { caps: ["C"], name: "Camera", hint: "toggle chase" },
59
  { caps: ["Space"], name: "Reset", hint: "fresh start" },
60
  ],
61
  pad: [
@@ -77,6 +79,33 @@ const HINTS = {
77
  touch: "drag to orbit \u00b7 pinch to zoom",
78
  };
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  function closeMenu() {
81
  useGame.setState({ menuOpen: false });
82
  if (!useGame.getState().entered) useGame.setState({ entered: true });
@@ -89,6 +118,9 @@ export default function TitleMenu() {
89
  const touchMode = useGame((s) => s.touchMode);
90
  const [closing, setClosing] = useState(false);
91
  const prevOpen = useRef(menuOpen);
 
 
 
92
 
93
  // Keep the overlay mounted through the 0.35 s closing fade.
94
  useEffect(() => {
@@ -126,9 +158,14 @@ export default function TitleMenu() {
126
 
127
  if (!menuOpen && !closing) return null;
128
 
129
- // A plugged-in gamepad wins over the touch tutorial.
130
- const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
131
- const tiles = TILES[tutorialVariant];
 
 
 
 
 
132
  const ctaLabel = entered ? "Resume" : "Waddle in";
133
 
134
  return (
@@ -320,8 +357,8 @@ export default function TitleMenu() {
320
  </Box>
321
  ) : (
322
  <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
323
- {t.caps.map((c) => (
324
- <Kbd key={c} round={t.round ? 1 : 0}>{c}</Kbd>
325
  ))}
326
  </Box>
327
  )}
 
15
  import { signed } from "../game/signed.js";
16
  import { INK, ORANGE, MONO } from "../theme.js";
17
  import { ComicButton, ComicTitle, HalftoneRamp, ANTON } from "./comic.jsx";
18
+ import { readLayoutMap, resolveKeycaps } from "./keyboard-layout.js";
19
 
20
  const rowIn = keyframes`
21
  from { transform: translateY(12px); opacity: 0; }
 
50
  boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
51
  }));
52
 
53
+ // Letter keycaps are resolved from physical codes for the active layout.
54
  const TILES = {
55
  kb: [
56
+ { caps: "cluster-arrows", name: "Move" },
57
+ { codes: ["KeyQ", "KeyE"], name: "Kick", hint: "left / right" },
58
+ { codes: ["KeyR"], name: "Sit", hint: "tap again to stand" },
59
+ { codes: ["KeyG"], name: "Pick up", hint: "beak to the ground" },
60
+ { codes: ["KeyC"], name: "Camera", hint: "toggle chase" },
61
  { caps: ["Space"], name: "Reset", hint: "fresh start" },
62
  ],
63
  pad: [
 
79
  touch: "drag to orbit \u00b7 pinch to zoom",
80
  };
81
 
82
+ const KB_CODES = TILES.kb.flatMap((t) => t.codes ?? []);
83
+
84
+ function useKeycaps(enabled) {
85
+ const [keycaps, setKeycaps] = useState(() => resolveKeycaps(KB_CODES, null));
86
+
87
+ useEffect(() => {
88
+ if (!enabled) return;
89
+ let cancelled = false;
90
+ let latest = 0;
91
+ const read = async () => {
92
+ const ticket = ++latest;
93
+ const map = await readLayoutMap();
94
+ // Overlapping reads can settle out of order; only the newest paints.
95
+ if (!cancelled && ticket === latest) setKeycaps(resolveKeycaps(KB_CODES, map));
96
+ };
97
+ read();
98
+ const keyboard = navigator.keyboard;
99
+ keyboard?.addEventListener?.("layoutchange", read);
100
+ return () => {
101
+ cancelled = true;
102
+ keyboard?.removeEventListener?.("layoutchange", read);
103
+ };
104
+ }, [enabled]);
105
+
106
+ return keycaps;
107
+ }
108
+
109
  function closeMenu() {
110
  useGame.setState({ menuOpen: false });
111
  if (!useGame.getState().entered) useGame.setState({ entered: true });
 
118
  const touchMode = useGame((s) => s.touchMode);
119
  const [closing, setClosing] = useState(false);
120
  const prevOpen = useRef(menuOpen);
121
+ // A plugged-in gamepad wins over the touch tutorial.
122
+ const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
123
+ const { labels, moveHint } = useKeycaps(tutorialVariant === "kb");
124
 
125
  // Keep the overlay mounted through the 0.35 s closing fade.
126
  useEffect(() => {
 
158
 
159
  if (!menuOpen && !closing) return null;
160
 
161
+ const tiles =
162
+ tutorialVariant === "kb"
163
+ ? TILES.kb.map((t) => ({
164
+ ...t,
165
+ caps: t.codes ? t.codes.map((code) => labels[code]) : t.caps,
166
+ hint: t.hint ?? moveHint,
167
+ }))
168
+ : TILES[tutorialVariant];
169
  const ctaLabel = entered ? "Resume" : "Waddle in";
170
 
171
  return (
 
357
  </Box>
358
  ) : (
359
  <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
360
+ {t.caps.map((c, i) => (
361
+ <Kbd key={i} round={t.round ? 1 : 0}>{c}</Kbd>
362
  ))}
363
  </Box>
364
  )}
app/src/ui/keyboard-layout.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Controls use physical KeyboardEvent.code positions; getLayoutMap() provides
2
+ // the characters printed on those keys for the active layout.
3
+ const FALLBACK_LABELS = Object.freeze({ KeyW: "W/Z", KeyA: "A/Q", KeyQ: "Q/A" });
4
+
5
+ const MOVEMENT_CODES = ["KeyW", "KeyA", "KeyS", "KeyD"];
6
+ const MOVEMENT_FALLBACK_HINT = "arrows or WASD / ZQSD";
7
+
8
+ function keycap(value) {
9
+ if (typeof value !== "string") return null;
10
+ const glyph = value.trim();
11
+ if ([...glyph].length !== 1 || /[\p{M}\p{C}\p{Z}]/u.test(glyph)) return null;
12
+ const upper = glyph.toUpperCase();
13
+ return [...upper].length === 1 ? upper : glyph; // "ß" must not become "SS"
14
+ }
15
+
16
+ export function resolveKeycaps(codes, layoutMap) {
17
+ const labels = {};
18
+ const fromLayout = new Set();
19
+ for (const code of new Set([...MOVEMENT_CODES, ...codes])) {
20
+ let label = null;
21
+ try {
22
+ label = keycap(layoutMap?.get?.(code));
23
+ } catch {
24
+ label = null;
25
+ }
26
+ if (label) fromLayout.add(code);
27
+ labels[code] = label ?? FALLBACK_LABELS[code] ?? code.replace(/^Key/, "");
28
+ }
29
+ const moveHint = MOVEMENT_CODES.every((code) => fromLayout.has(code))
30
+ ? `arrows or ${MOVEMENT_CODES.map((code) => labels[code]).join("")}`
31
+ : MOVEMENT_FALLBACK_HINT;
32
+ return { labels, moveHint };
33
+ }
34
+
35
+ export async function readLayoutMap(nav = globalThis.navigator) {
36
+ try {
37
+ return (await nav?.keyboard?.getLayoutMap?.()) ?? null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
app/test/keyboard-layout.test.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { readLayoutMap, resolveKeycaps } from "../src/ui/keyboard-layout.js";
5
+
6
+ const AZERTY = new Map(
7
+ Object.entries({ KeyW: "z", KeyA: "q", KeyS: "s", KeyD: "d", KeyQ: "a", KeyE: "e" }),
8
+ );
9
+ const QWERTY = new Map(
10
+ Object.entries({ KeyW: "w", KeyA: "a", KeyS: "s", KeyD: "d", KeyQ: "q", KeyE: "e" }),
11
+ );
12
+ const DVORAK = new Map(
13
+ Object.entries({ KeyW: ",", KeyA: "a", KeyS: "o", KeyD: "e", KeyQ: "'", KeyE: "." }),
14
+ );
15
+
16
+ const CODES = ["KeyQ", "KeyE"];
17
+
18
+ test("resolves caps and the movement hint from the active layout", () => {
19
+ assert.deepEqual(resolveKeycaps(CODES, QWERTY), {
20
+ labels: { KeyW: "W", KeyA: "A", KeyS: "S", KeyD: "D", KeyQ: "Q", KeyE: "E" },
21
+ moveHint: "arrows or WASD",
22
+ });
23
+ const azerty = resolveKeycaps(CODES, AZERTY);
24
+ assert.deepEqual(azerty.labels, {
25
+ KeyW: "Z", KeyA: "Q", KeyS: "S", KeyD: "D", KeyQ: "A", KeyE: "E",
26
+ });
27
+ assert.equal(azerty.moveHint, "arrows or ZQSD");
28
+
29
+ const dvorak = resolveKeycaps(CODES, DVORAK);
30
+ assert.equal(dvorak.labels.KeyQ, "'");
31
+ assert.equal(dvorak.moveHint, "arrows or ,AOE");
32
+ });
33
+
34
+ test("falls back to labels naming both layouts without a map", () => {
35
+ assert.deepEqual(resolveKeycaps(CODES, null), {
36
+ labels: { KeyW: "W/Z", KeyA: "A/Q", KeyS: "S", KeyD: "D", KeyQ: "Q/A", KeyE: "E" },
37
+ moveHint: "arrows or WASD / ZQSD",
38
+ });
39
+ assert.equal(resolveKeycaps(["KeyX"], null).labels.KeyX, "X");
40
+ });
41
+
42
+ test("keeps the inclusive hint when the map misses a movement key", () => {
43
+ const partial = new Map([["KeyW", "z"], ["KeyA", "q"], ["KeyS", "s"]]);
44
+ const { labels, moveHint } = resolveKeycaps(CODES, partial);
45
+ assert.equal(labels.KeyW, "Z");
46
+ assert.equal(labels.KeyD, "D");
47
+ assert.equal(moveHint, "arrows or WASD / ZQSD");
48
+ });
49
+
50
+ test("rejects layout values that cannot sit on a keycap", () => {
51
+ const junk = new Map(
52
+ Object.entries({ KeyQ: "", KeyE: " ", KeyW: "Dead", KeyA: "́", KeyS: 7 }),
53
+ );
54
+ const { labels } = resolveKeycaps(CODES, junk);
55
+ assert.deepEqual(labels, {
56
+ KeyW: "W/Z", KeyA: "A/Q", KeyS: "S", KeyD: "D", KeyQ: "Q/A", KeyE: "E",
57
+ });
58
+ });
59
+
60
+ test("a throwing map cannot break the menu", () => {
61
+ const hostile = { get: () => { throw new Error("nope"); } };
62
+ assert.equal(resolveKeycaps(CODES, hostile).labels.KeyQ, "Q/A");
63
+ });
64
+
65
+ test("readLayoutMap yields null when the API is absent or refuses", async () => {
66
+ assert.equal(await readLayoutMap({}), null);
67
+ assert.equal(await readLayoutMap({ keyboard: {} }), null);
68
+ const rejects = { keyboard: { getLayoutMap: () => Promise.reject(new Error("blocked")) } };
69
+ assert.equal(await readLayoutMap(rejects), null);
70
+ const throws = { keyboard: { getLayoutMap: () => { throw new Error("framed"); } } };
71
+ assert.equal(await readLayoutMap(throws), null);
72
+ });
73
+
74
+ test("readLayoutMap returns the map where the API is supported", async () => {
75
+ const nav = { keyboard: { getLayoutMap: async () => AZERTY } };
76
+ assert.equal(await readLayoutMap(nav), AZERTY);
77
+ });