diff --git a/README.md b/README.md index 6652598..367e458 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ npm run dev Open the printed local URL and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name. -Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. +Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys and the text size for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps. diff --git a/docs/PLAN.md b/docs/PLAN.md index e11b20c..e44c4ce 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -235,6 +235,13 @@ The numbered sections below keep their full scope. Each one opens with a checkli - "Open in this port" lists the landmarks with at least one map in `NATIVE_MAPS`, so the atlas never suggests travel through unported areas. - `tests/atlas.test.ts` compares every landmark position with `data/maps/landmarks.asm` and the flypoints with `data/maps/flypoints.asm`. +## Implemented: interface settings + +- Settings, beside Help, sets the text size (Standard, Large, Largest: 100, 125 and 150 %) and the keyboard key of each button. Every font size in the stylesheet scales with `--text-scale`; the dialogue box scrolls instead of growing past the screen. +- Change waits for the next key. Escape cancels, and Tab, Escape and Space stay reserved for focus, dialogs and scrolling. A key given to a new button leaves its old one, which then has no key until the player chooses one. Reset controls restores X, Z, Enter, Shift and the arrows. +- The preferences live in this browser's `localStorage` (`crystal-preferences-v1`), validated on load; without storage the defaults apply. They are not game state, so saves and backups do not carry them. The reference player keeps the default keys. +- The game screen's label, the Play message and Help's control list name the current keys. + ## Implemented: the options - OPTION in the start menu runs `_Option` and returns to the start menu. The labels come from `StringOptions` and the settings from each routine's strings. Up and down move the ▶ with `OptionsControl`'s wrapping, left and right change the option under it, A on CANCEL, B or Start leaves. Each routine is ported from its bit handling: text speed cycles FAST, MID and SLOW through `GetTextSpeed` (`.Save` keeps only the high nybble), the battle scene, battle style, sound and menu account toggle with either direction, printing cycles through the five `GBPRINTER_*` levels through `GetPrinterSetting`, and the frame wraps through `TYPE 1` to `8`. @@ -468,7 +475,8 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba - [x] Trainer card: both pages, with the ROM's card picture, leader faces and animated badges - [x] Options screen, with the battle style and menu account in effect - [x] Pokédex screen: New mode list and entries -- [ ] Scalable text, configurable controls and fuller accessibility +- [x] Scalable text and configurable keyboard controls +- [ ] Fuller accessibility: announcements for every native screen and touch control remapping - [ ] Save slots with migration, atomic writes and import previews - [ ] Installable web app with offline app shell, safe updates and quota handling - [ ] Acceptance on physical iPhone and Android devices diff --git a/src/input/bindings.ts b/src/input/bindings.ts index 871c9f2..c3ea4d2 100644 --- a/src/input/bindings.ts +++ b/src/input/bindings.ts @@ -1,23 +1,14 @@ import { type Button } from "./types"; import { InputController } from "./controller"; -const KEYS: Record = { - ArrowUp: "up", - ArrowDown: "down", - ArrowLeft: "left", - ArrowRight: "right", - KeyX: "a", - KeyZ: "b", - Enter: "start", - ShiftLeft: "select", - ShiftRight: "select", -}; - +/** `keys` maps keyboard codes to buttons; it is read on every key press, + * so a changed mapping applies at once. */ export function bindControls( root: HTMLElement, input: InputController, active: () => boolean, unlock: () => void, + keys: () => ReadonlyMap, ) { const abort = new AbortController(); const options = { signal: abort.signal }; @@ -130,7 +121,7 @@ export function bindControls( ) ) return; - const button = KEYS[event.code]; + const button = keys().get(event.code); if (!button) return; event.preventDefault(); unlock(); diff --git a/src/main.ts b/src/main.ts index acd27c9..31fcd7e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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("save-file").addEventListener("change", async (event) => { get("help").addEventListener("click", () => { pause(); + showHelpKeys(); get("help-dialog").showModal(); }); get("close-help").addEventListener("click", () => get("help-dialog").close(), ); + +const BUTTON_NAMES: Record = { + 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('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(`button[data-button="${button}"]`)?.focus(); + }); + action.append(change); + row.append(name, key, action); + return row; + }), + ); +} +const settingsDialog = get("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(`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('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(); diff --git a/src/reference/main.ts b/src/reference/main.ts index 084d839..81ce75d 100644 --- a/src/reference/main.ts +++ b/src/reference/main.ts @@ -1,4 +1,5 @@ import "../ui/reference.css"; +import { defaultPreferences, keyMap } from "../ui/preferences"; import { renderShell } from "./shell"; import { BinjgbEmulator } from "../emulator/binjgb"; import { GameSession } from "../app/session"; @@ -29,11 +30,14 @@ const session = new GameSession(emulator, new BrowserSaveRepository()); const input = new InputController((button, pressed) => emulator.setButton(button, pressed), ); +// The reference keeps the default keys; preferences belong to the native player. +const referenceKeys = keyMap(defaultPreferences()); const controls = bindControls( root, input, () => session.state === "playing" && !busy, () => emulator.unlockAudio(), + () => referenceKeys, ); const ready = () => ["paused", "playing"].includes(session.state); diff --git a/src/ui/preferences.ts b/src/ui/preferences.ts new file mode 100644 index 0000000..c87d3f6 --- /dev/null +++ b/src/ui/preferences.ts @@ -0,0 +1,148 @@ +import { BUTTONS, type Button } from "../input/types"; + +/** Interface preferences for this browser. They are not game state, so + * they stay out of saves and backups. */ +export interface Preferences { + /** The factor every font size is multiplied by. */ + textScale: TextScale; + /** The keyboard codes (KeyboardEvent.code) that press each button. */ + keys: Record; +} + +export const TEXT_SCALES = [1, 1.25, 1.5] as const; +export type TextScale = (typeof TEXT_SCALES)[number]; + +export const DEFAULT_KEYS: Readonly> = { + up: ["ArrowUp"], + down: ["ArrowDown"], + left: ["ArrowLeft"], + right: ["ArrowRight"], + a: ["KeyX"], + b: ["KeyZ"], + start: ["Enter"], + select: ["ShiftLeft", "ShiftRight"], +}; + +/** Keys that keep their browser role: focus moves, dialogs close and + * pages scroll with them, so they cannot press a game button. */ +export const RESERVED_KEYS: readonly string[] = ["Tab", "Escape", "Space"]; + +const STORAGE_KEY = "crystal-preferences-v1"; + +export const defaultPreferences = (): Preferences => ({ + textScale: 1, + keys: Object.fromEntries( + BUTTONS.map((button) => [button, [...DEFAULT_KEYS[button]]]), + ) as Record, +}); + +/** Accept stored preferences only when every part is valid; anything + * else falls back to the defaults for that part. */ +function parse(value: unknown): Preferences { + const preferences = defaultPreferences(); + if (!value || typeof value !== "object") return preferences; + const stored = value as Partial; + if (TEXT_SCALES.includes(stored.textScale as TextScale)) + preferences.textScale = stored.textScale as TextScale; + const keys = stored.keys; + if (keys && typeof keys === "object") { + const seen = new Set(); + const valid = BUTTONS.every((button) => { + const codes = (keys as Record)[button]; + return ( + Array.isArray(codes) && + codes.every( + (code) => + typeof code === "string" && + /^[A-Za-z0-9]{1,24}$/.test(code) && + !RESERVED_KEYS.includes(code) && + !seen.has(code) && + seen.add(code), + ) + ); + }); + if (valid) + for (const button of BUTTONS) + preferences.keys[button] = [ + ...(keys as Record)[button], + ]; + } + return preferences; +} + +/** Storage can be missing or refuse access; the defaults apply then. */ +export function loadPreferences(): Preferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return parse(raw ? JSON.parse(raw) : undefined); + } catch { + return defaultPreferences(); + } +} +export function savePreferences(preferences: Preferences) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch { + // The preferences still apply until the page reloads. + } +} + +/** Assign `code` to `button` alone: it leaves any other button it + * pressed, and replaces the button's previous keys. */ +export function rebind( + preferences: Preferences, + button: Button, + code: string, +): Preferences { + if (RESERVED_KEYS.includes(code)) return preferences; + const keys = { ...preferences.keys }; + for (const other of BUTTONS) + keys[other] = keys[other].filter((existing) => existing !== code); + keys[button] = [code]; + return { ...preferences, keys }; +} + +/** The keyboard map the controls read: code to button. */ +export function keyMap(preferences: Preferences): Map { + const map = new Map(); + for (const button of BUTTONS) + for (const code of preferences.keys[button]) map.set(code, button); + return map; +} + +const KEY_NAMES: Record = { + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + ShiftLeft: "Shift", + ShiftRight: "Shift", + ControlLeft: "Ctrl", + ControlRight: "Ctrl", + AltLeft: "Alt", + AltRight: "Alt", + Enter: "Enter", + Backspace: "Backspace", +}; + +/** A readable name for a keyboard code: "KeyX" is "X", "Digit1" is "1". */ +export function keyName(code: string): string { + return ( + KEY_NAMES[code] ?? + code + .replace(/^Key/, "") + .replace(/^Digit/, "") + .replace(/^Numpad/, "Numpad ") + ); +} + +/** The names of a button's keys, without repeats ("Shift" once). */ +export function buttonKeys(preferences: Preferences, button: Button): string { + const names = [...new Set(preferences.keys[button].map(keyName))]; + return names.length ? names.join(" / ") : "None"; +} + +/** Apply the text scale to the page. */ +export function applyTextScale(scale: TextScale) { + document.documentElement.style.setProperty("--text-scale", String(scale)); +} diff --git a/src/ui/shell.ts b/src/ui/shell.ts index add4e2a..9d1b068 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -8,7 +8,7 @@ export function renderShell(root: HTMLElement) { root.innerHTML = `