Add text size and keyboard settings (#38)
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Successful in 14m40s

Settings sets the text size (100, 125 or 150 %) and the key of each
button, kept in this browser's storage and validated on load. Every font
size scales with --text-scale, and the dialogue box scrolls instead of
growing past the screen. The game screen's label, the Play message and
Help's control list name the current keys.

Screen reader announcements for every native screen and touch control
remapping remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samyandClaude Opus 5.5 committed 2026-10-03 20:42:59 +02:00
1 parent 29248172bd
commit f289615bd2
10 files changed
+588 -41

No files matched your search

+170 -2
View File
@@ -7,6 +7,21 @@ import {
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex";
import {
RESERVED_KEYS,
TEXT_SCALES,
applyTextScale,
buttonKeys,
defaultPreferences,
keyMap,
keyName,
loadPreferences,
rebind,
savePreferences,
type Preferences,
type TextScale,
} from "./ui/preferences";
import type { Button as GameButton } from "./input/types";
import { ENGINE_FLAGS } from "./game/rom/profile";
import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime";
@@ -113,7 +128,7 @@ function showWorld(world: GameWorld) {
canvas.dataset.intro = String(world.intro);
canvas.setAttribute(
"aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
`${world.map.name}. Position ${world.x}, ${world.y}. ${controlsSummary()}`,
);
}
function showChoice(choice: Choice | undefined) {
@@ -1555,11 +1570,15 @@ const session = new GameSession(
const input = new InputController((button, pressed) =>
runtime.setButton(button, pressed),
);
let preferences = loadPreferences();
let keys = keyMap(preferences);
applyTextScale(preferences.textScale);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy && tabs.active() === "game",
() => {},
() => keys,
);
const ready = () => ["paused", "playing"].includes(session.state);
const tabs = bindTabs(
@@ -1654,7 +1673,9 @@ function play() {
tabs.select("game");
session.play();
canvas.focus({ preventScroll: true });
message("Press X or A to talk and read. Up and Down change choices.");
message(
`Press ${buttonKeys(preferences, "a")} or A to talk and read. Up and Down change choices.`,
);
update();
}
@@ -1766,11 +1787,158 @@ get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
get("help").addEventListener("click", () => {
pause();
showHelpKeys();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
const BUTTON_NAMES: Record<GameButton, string> = {
a: "A",
b: "B",
start: "START",
select: "SELECT",
up: "Up",
down: "Down",
left: "Left",
right: "Right",
};
const BUTTON_ORDER: GameButton[] = ["a", "b", "start", "select", "up", "down", "left", "right"];
/** One sentence naming the current keys, for the game screen's label. */
function controlsSummary() {
const directions = (["up", "down", "left", "right"] as const)
.map((button) => buttonKeys(preferences, button))
.join(" ");
const buttons = (["a", "b", "start", "select"] as const)
.map((button) => `${buttonKeys(preferences, button)} is ${BUTTON_NAMES[button]}`)
.join(", ");
return `${directions} move. ${buttons}.`;
}
function showHelpKeys() {
const list = get("help-keys");
list.replaceChildren();
const add = (term: string, detail: string) => {
const dt = document.createElement("dt"),
dd = document.createElement("dd");
dt.textContent = term;
dd.textContent = detail;
list.append(dt, dd);
};
const key = (button: GameButton) => buttonKeys(preferences, button);
add("Move", (["up", "down", "left", "right"] as const).map(key).join(" "));
add("Interact / Continue", `${key("a")} (A)`);
add("Back / No", `${key("b")} (B)`);
add("Start menu", `${key("start")} (START)`);
add("Registered item", `${key("select")} (SELECT)`);
}
get("game-screen").setAttribute("aria-label", `Game screen. ${controlsSummary()}`);
/** Settings: the text size and the keyboard controls, kept for this
* browser only. */
let capturing: GameButton | undefined;
/** Escape ended a key change, so the dialog's cancel must not close it. */
let keepOpen = false;
function setPreferences(next: Preferences, status = "") {
preferences = next;
keys = keyMap(preferences);
savePreferences(preferences);
applyTextScale(preferences.textScale);
get("game-screen").setAttribute("aria-label", `Game screen. ${controlsSummary()}`);
showSettings();
get("settings-status").textContent = status;
}
function showSettings() {
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="text-scale"]'))
radio.checked = Number(radio.value) === preferences.textScale;
get("key-table").replaceChildren(
...BUTTON_ORDER.map((button) => {
const row = document.createElement("tr"),
name = document.createElement("th"),
key = document.createElement("td"),
action = document.createElement("td"),
change = document.createElement("button");
name.scope = "row";
name.textContent = BUTTON_NAMES[button];
key.textContent = capturing === button ? "Press a key…" : buttonKeys(preferences, button);
change.type = "button";
change.textContent = capturing === button ? "Cancel" : "Change";
change.dataset.button = button;
change.setAttribute("aria-label", `${change.textContent} the key for ${BUTTON_NAMES[button]}`);
change.addEventListener("click", () => {
capturing = capturing === button ? undefined : button;
showSettings();
get("settings-status").textContent = capturing
? `Press the key for ${BUTTON_NAMES[button]}.`
: "";
get("key-table").querySelector<HTMLButtonElement>(`button[data-button="${button}"]`)?.focus();
});
action.append(change);
row.append(name, key, action);
return row;
}),
);
}
const settingsDialog = get<HTMLDialogElement>("settings-dialog");
get("settings").addEventListener("click", () => {
pause();
capturing = undefined;
showSettings();
get("settings-status").textContent = "";
settingsDialog.showModal();
});
get("close-settings").addEventListener("click", () => settingsDialog.close());
settingsDialog.addEventListener("close", () => {
capturing = undefined;
});
// While a key is awaited, the next key press becomes the button's key.
settingsDialog.addEventListener(
"keydown",
(event) => {
const button = capturing;
if (!button) return;
if (event.code === "Tab") return;
event.preventDefault();
event.stopPropagation();
capturing = undefined;
if (event.code === "Escape") {
// Some browsers skip the cancel event once the key is handled here.
keepOpen = true;
requestAnimationFrame(() => (keepOpen = false));
showSettings();
get("settings-status").textContent = "Key change cancelled.";
} else if (RESERVED_KEYS.includes(event.code)) {
showSettings();
get("settings-status").textContent = `${keyName(event.code)} keeps its usual role. Choose another key.`;
} else {
const previous = BUTTON_ORDER.find(
(other) => other !== button && preferences.keys[other].includes(event.code),
);
setPreferences(
rebind(preferences, button, event.code),
`${BUTTON_NAMES[button]} is now ${keyName(event.code)}.` +
(previous ? ` ${BUTTON_NAMES[previous]} has no key until you choose one.` : ""),
);
}
get("key-table").querySelector<HTMLButtonElement>(`button[data-button="${button}"]`)?.focus();
},
true,
);
// Escape cancels a key change rather than closing the dialog.
settingsDialog.addEventListener("cancel", (event) => {
if (capturing || keepOpen) event.preventDefault();
keepOpen = false;
});
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="text-scale"]'))
radio.addEventListener("change", () => {
const scale = Number(radio.value) as TextScale;
if (TEXT_SCALES.includes(scale))
setPreferences({ ...preferences, textScale: scale }, `Text size: ${radio.parentElement?.textContent?.trim()}.`);
});
get("reset-keys").addEventListener("click", () =>
setPreferences({ ...preferences, keys: defaultPreferences().keys }, "Controls reset to the defaults."),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();