Add text size and keyboard settings (#38)
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:
1 parent
29248172bd
commit
f289615bd2
10 files changed
+588
-41
No files matched your search
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -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.
|
- "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`.
|
- `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
|
## 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`.
|
- 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] 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] Options screen, with the battle style and menu account in effect
|
||||||
- [x] Pokédex screen: New mode list and entries
|
- [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
|
- [ ] Save slots with migration, atomic writes and import previews
|
||||||
- [ ] Installable web app with offline app shell, safe updates and quota handling
|
- [ ] Installable web app with offline app shell, safe updates and quota handling
|
||||||
- [ ] Acceptance on physical iPhone and Android devices
|
- [ ] Acceptance on physical iPhone and Android devices
|
||||||
|
|||||||
+4
-13
@@ -1,23 +1,14 @@
|
|||||||
import { type Button } from "./types";
|
import { type Button } from "./types";
|
||||||
import { InputController } from "./controller";
|
import { InputController } from "./controller";
|
||||||
|
|
||||||
const KEYS: Record<string, Button> = {
|
/** `keys` maps keyboard codes to buttons; it is read on every key press,
|
||||||
ArrowUp: "up",
|
* so a changed mapping applies at once. */
|
||||||
ArrowDown: "down",
|
|
||||||
ArrowLeft: "left",
|
|
||||||
ArrowRight: "right",
|
|
||||||
KeyX: "a",
|
|
||||||
KeyZ: "b",
|
|
||||||
Enter: "start",
|
|
||||||
ShiftLeft: "select",
|
|
||||||
ShiftRight: "select",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function bindControls(
|
export function bindControls(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
input: InputController,
|
input: InputController,
|
||||||
active: () => boolean,
|
active: () => boolean,
|
||||||
unlock: () => void,
|
unlock: () => void,
|
||||||
|
keys: () => ReadonlyMap<string, Button>,
|
||||||
) {
|
) {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const options = { signal: abort.signal };
|
const options = { signal: abort.signal };
|
||||||
@@ -130,7 +121,7 @@ export function bindControls(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const button = KEYS[event.code];
|
const button = keys().get(event.code);
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
unlock();
|
unlock();
|
||||||
|
|||||||
+170
-2
@@ -7,6 +7,21 @@ import {
|
|||||||
import "./ui/styles.css";
|
import "./ui/styles.css";
|
||||||
import { renderShell } from "./ui/shell";
|
import { renderShell } from "./ui/shell";
|
||||||
import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex";
|
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 { ENGINE_FLAGS } from "./game/rom/profile";
|
||||||
import { bindTabs } from "./ui/tabs";
|
import { bindTabs } from "./ui/tabs";
|
||||||
import { NativeRuntime } from "./game/runtime";
|
import { NativeRuntime } from "./game/runtime";
|
||||||
@@ -113,7 +128,7 @@ function showWorld(world: GameWorld) {
|
|||||||
canvas.dataset.intro = String(world.intro);
|
canvas.dataset.intro = String(world.intro);
|
||||||
canvas.setAttribute(
|
canvas.setAttribute(
|
||||||
"aria-label",
|
"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) {
|
function showChoice(choice: Choice | undefined) {
|
||||||
@@ -1555,11 +1570,15 @@ const session = new GameSession(
|
|||||||
const input = new InputController((button, pressed) =>
|
const input = new InputController((button, pressed) =>
|
||||||
runtime.setButton(button, pressed),
|
runtime.setButton(button, pressed),
|
||||||
);
|
);
|
||||||
|
let preferences = loadPreferences();
|
||||||
|
let keys = keyMap(preferences);
|
||||||
|
applyTextScale(preferences.textScale);
|
||||||
const controls = bindControls(
|
const controls = bindControls(
|
||||||
root,
|
root,
|
||||||
input,
|
input,
|
||||||
() => session.state === "playing" && !busy && tabs.active() === "game",
|
() => session.state === "playing" && !busy && tabs.active() === "game",
|
||||||
() => {},
|
() => {},
|
||||||
|
() => keys,
|
||||||
);
|
);
|
||||||
const ready = () => ["paused", "playing"].includes(session.state);
|
const ready = () => ["paused", "playing"].includes(session.state);
|
||||||
const tabs = bindTabs(
|
const tabs = bindTabs(
|
||||||
@@ -1654,7 +1673,9 @@ function play() {
|
|||||||
tabs.select("game");
|
tabs.select("game");
|
||||||
session.play();
|
session.play();
|
||||||
canvas.focus({ preventScroll: true });
|
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();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1766,11 +1787,158 @@ get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
|
|||||||
|
|
||||||
get("help").addEventListener("click", () => {
|
get("help").addEventListener("click", () => {
|
||||||
pause();
|
pause();
|
||||||
|
showHelpKeys();
|
||||||
get<HTMLDialogElement>("help-dialog").showModal();
|
get<HTMLDialogElement>("help-dialog").showModal();
|
||||||
});
|
});
|
||||||
get("close-help").addEventListener("click", () =>
|
get("close-help").addEventListener("click", () =>
|
||||||
get<HTMLDialogElement>("help-dialog").close(),
|
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);
|
window.addEventListener("blur", pause);
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.hidden) pause();
|
if (document.hidden) pause();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "../ui/reference.css";
|
import "../ui/reference.css";
|
||||||
|
import { defaultPreferences, keyMap } from "../ui/preferences";
|
||||||
import { renderShell } from "./shell";
|
import { renderShell } from "./shell";
|
||||||
import { BinjgbEmulator } from "../emulator/binjgb";
|
import { BinjgbEmulator } from "../emulator/binjgb";
|
||||||
import { GameSession } from "../app/session";
|
import { GameSession } from "../app/session";
|
||||||
@@ -29,11 +30,14 @@ const session = new GameSession(emulator, new BrowserSaveRepository());
|
|||||||
const input = new InputController((button, pressed) =>
|
const input = new InputController((button, pressed) =>
|
||||||
emulator.setButton(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(
|
const controls = bindControls(
|
||||||
root,
|
root,
|
||||||
input,
|
input,
|
||||||
() => session.state === "playing" && !busy,
|
() => session.state === "playing" && !busy,
|
||||||
() => emulator.unlockAudio(),
|
() => emulator.unlockAudio(),
|
||||||
|
() => referenceKeys,
|
||||||
);
|
);
|
||||||
const ready = () => ["paused", "playing"].includes(session.state);
|
const ready = () => ["paused", "playing"].includes(session.state);
|
||||||
|
|
||||||
|
|||||||
@@ -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<Button, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEXT_SCALES = [1, 1.25, 1.5] as const;
|
||||||
|
export type TextScale = (typeof TEXT_SCALES)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_KEYS: Readonly<Record<Button, readonly string[]>> = {
|
||||||
|
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<Button, string[]>,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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<Preferences>;
|
||||||
|
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<string>();
|
||||||
|
const valid = BUTTONS.every((button) => {
|
||||||
|
const codes = (keys as Record<string, unknown>)[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, string[]>)[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<string, Button> {
|
||||||
|
const map = new Map<string, Button>();
|
||||||
|
for (const button of BUTTONS)
|
||||||
|
for (const code of preferences.keys[button]) map.set(code, button);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_NAMES: Record<string, string> = {
|
||||||
|
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));
|
||||||
|
}
|
||||||
+4
-3
@@ -8,7 +8,7 @@ export function renderShell(root: HTMLElement) {
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="brand" href="./" aria-label="Crystal home">CRYSTAL</a>
|
<a class="brand" href="./" aria-label="Crystal home">CRYSTAL</a>
|
||||||
<div class="header-state"><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span><button id="help" class="text-button">Help</button></div>
|
<div class="header-state"><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span><button id="settings" class="text-button">Settings</button><button id="help" class="text-button">Help</button></div>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<nav class="tabs" role="tablist" aria-label="Crystal sections">
|
<nav class="tabs" role="tablist" aria-label="Crystal sections">
|
||||||
@@ -18,7 +18,7 @@ export function renderShell(root: HTMLElement) {
|
|||||||
<div class="console">
|
<div class="console">
|
||||||
<div class="screen-bezel">
|
<div class="screen-bezel">
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
|
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen"></canvas>
|
||||||
<div id="screen-empty" class="screen-empty">
|
<div id="screen-empty" class="screen-empty">
|
||||||
<form class="load-card" onsubmit="return false">
|
<form class="load-card" onsubmit="return false">
|
||||||
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p>
|
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p>
|
||||||
@@ -80,5 +80,6 @@ export function renderShell(root: HTMLElement) {
|
|||||||
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
|
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
|
||||||
</div>
|
</div>
|
||||||
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
|
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
|
||||||
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls and heal with Potions from the battle PACK, and switch Pokémon with PKMN. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
|
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
|
||||||
|
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
|
||||||
}
|
}
|
||||||
+83
-21
@@ -1,10 +1,11 @@
|
|||||||
:root {
|
:root {
|
||||||
|
--text-scale: 1;
|
||||||
font-family:
|
font-family:
|
||||||
"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
color: #282831;
|
color: #282831;
|
||||||
background: #d9e2dd;
|
background: #d9e2dd;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
font-size: 14px;
|
font-size: calc(14px * var(--text-scale));
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
--cream: #fffbea;
|
--cream: #fffbea;
|
||||||
--cream-dark: #eee8ce;
|
--cream-dark: #eee8ce;
|
||||||
@@ -95,7 +96,7 @@ label:focus-within {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.power {
|
.power {
|
||||||
font-size: 10px;
|
font-size: calc(10px * var(--text-scale));
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.power i {
|
.power i {
|
||||||
@@ -142,7 +143,7 @@ label:focus-within {
|
|||||||
border: 2px solid #566675;
|
border: 2px solid #566675;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
color: #27394a;
|
color: #27394a;
|
||||||
font-size: 12px;
|
font-size: calc(12px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.tabs button[aria-selected="true"] {
|
.tabs button[aria-selected="true"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -153,7 +154,7 @@ label:focus-within {
|
|||||||
}
|
}
|
||||||
.tabs button span {
|
.tabs button span {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
font-size: 9px;
|
font-size: calc(9px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.tab-panel {
|
.tab-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -203,7 +204,7 @@ label:focus-within {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: #c5dced;
|
color: #c5dced;
|
||||||
font-size: 9px;
|
font-size: calc(9px * var(--text-scale));
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +235,7 @@ label:focus-within {
|
|||||||
.menu-title {
|
.menu-title {
|
||||||
margin: 0 0 7px;
|
margin: 0 0 7px;
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
font-size: 11px;
|
font-size: calc(11px * var(--text-scale));
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
}
|
}
|
||||||
@@ -248,12 +249,12 @@ p {
|
|||||||
.menu-panel h2,
|
.menu-panel h2,
|
||||||
dialog h2 {
|
dialog h2 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: clamp(17px, 2.5vw, 25px);
|
font-size: calc(clamp(17px, 2.5vw, 25px) * var(--text-scale));
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
.load-card > p:not(.menu-title) {
|
.load-card > p:not(.menu-title) {
|
||||||
margin: 7px 0 12px;
|
margin: 7px 0 12px;
|
||||||
font-size: 11px;
|
font-size: calc(11px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.menu-button {
|
.menu-button {
|
||||||
@@ -294,6 +295,9 @@ dialog h2 {
|
|||||||
left: 12px;
|
left: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
|
/* Large text never pushes the box above the screen; it scrolls. */
|
||||||
|
max-height: calc(100% - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--cream);
|
background: var(--cream);
|
||||||
@@ -801,7 +805,7 @@ dialog h2 {
|
|||||||
max-width: 128px;
|
max-width: 128px;
|
||||||
margin: 3px 0 0;
|
margin: 3px 0 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 8px;
|
font-size: calc(8px * var(--text-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -876,7 +880,7 @@ dialog h2 {
|
|||||||
}
|
}
|
||||||
.system-keys .game-key {
|
.system-keys .game-key {
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
font-size: 8px;
|
font-size: calc(8px * var(--text-scale));
|
||||||
background: #6d7478;
|
background: #6d7478;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,7 +1010,7 @@ dialog h2 {
|
|||||||
}
|
}
|
||||||
.atlas-legend {
|
.atlas-legend {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: calc(12px * var(--text-scale));
|
||||||
color: #5f6763;
|
color: #5f6763;
|
||||||
}
|
}
|
||||||
.atlas-legend span {
|
.atlas-legend span {
|
||||||
@@ -1033,7 +1037,7 @@ dialog h2 {
|
|||||||
}
|
}
|
||||||
.planned-panel > p:last-child {
|
.planned-panel > p:last-child {
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
font-size: 15px;
|
font-size: calc(15px * var(--text-scale));
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,13 +1060,13 @@ dialog h2 {
|
|||||||
.status {
|
.status {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: calc(11px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.game-notice {
|
.game-notice {
|
||||||
grid-area: notice;
|
grid-area: notice;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #b8d4e6;
|
color: #b8d4e6;
|
||||||
font-size: 10px;
|
font-size: calc(10px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.game-notice:empty {
|
.game-notice:empty {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -1073,7 +1077,7 @@ dialog h2 {
|
|||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: #b8c7ce;
|
color: #b8c7ce;
|
||||||
font-size: 9px;
|
font-size: calc(9px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #ffb0a1 !important;
|
color: #ffb0a1 !important;
|
||||||
@@ -1101,10 +1105,68 @@ dialog dl {
|
|||||||
dialog dd {
|
dialog dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Read by screen readers, not shown. */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Settings: text size choices and the key table. */
|
||||||
|
#settings-dialog {
|
||||||
|
max-height: calc(100% - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.text-size {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 18px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
.text-size label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
.key-settings h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.key-settings table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.key-settings th,
|
||||||
|
.key-settings td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--cream-dark);
|
||||||
|
}
|
||||||
|
.key-settings td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.key-settings td button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--cream-dark);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
.settings-status {
|
||||||
|
min-height: 1.5em;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
.dialog-close {
|
.dialog-close {
|
||||||
float: right;
|
float: right;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 22px;
|
font-size: calc(22px * var(--text-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -1117,7 +1179,7 @@ dialog dd {
|
|||||||
.tabs button {
|
.tabs button {
|
||||||
min-width: 83px;
|
min-width: 83px;
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
font-size: 10px;
|
font-size: calc(10px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.console {
|
.console {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1177,7 +1239,7 @@ dialog dd {
|
|||||||
box-shadow: 3px 3px 0 #2d6685;
|
box-shadow: 3px 3px 0 #2d6685;
|
||||||
}
|
}
|
||||||
.load-card h1 {
|
.load-card h1 {
|
||||||
font-size: 16px;
|
font-size: calc(16px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.minimap-overlay {
|
.minimap-overlay {
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -1195,10 +1257,10 @@ dialog dd {
|
|||||||
padding-block: 4px;
|
padding-block: 4px;
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
font-size: 9px;
|
font-size: calc(9px * var(--text-scale));
|
||||||
}
|
}
|
||||||
.save-status {
|
.save-status {
|
||||||
font-size: 8px;
|
font-size: calc(8px * var(--text-scale));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1598,7 +1660,7 @@ dialog dd {
|
|||||||
}
|
}
|
||||||
.pokedex-tab-status {
|
.pokedex-tab-status {
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
font-size: 15px;
|
font-size: calc(15px * var(--text-scale));
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
.pokedex-tab-body {
|
.pokedex-tab-body {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
buttonKeys,
|
||||||
|
defaultPreferences,
|
||||||
|
keyMap,
|
||||||
|
keyName,
|
||||||
|
loadPreferences,
|
||||||
|
rebind,
|
||||||
|
savePreferences,
|
||||||
|
} from "../src/ui/preferences";
|
||||||
|
|
||||||
|
function storage(initial: Record<string, string> = {}) {
|
||||||
|
const values = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => void values.set(key, value),
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("interface preferences", () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("defaults to the original keys and standard text", () => {
|
||||||
|
const preferences = defaultPreferences();
|
||||||
|
expect(preferences.textScale).toBe(1);
|
||||||
|
const map = keyMap(preferences);
|
||||||
|
expect(map.get("KeyX")).toBe("a");
|
||||||
|
expect(map.get("KeyZ")).toBe("b");
|
||||||
|
expect(map.get("Enter")).toBe("start");
|
||||||
|
expect(map.get("ShiftRight")).toBe("select");
|
||||||
|
expect(buttonKeys(preferences, "select")).toBe("Shift");
|
||||||
|
expect(buttonKeys(preferences, "up")).toBe("↑");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names keys readably", () => {
|
||||||
|
expect(keyName("KeyQ")).toBe("Q");
|
||||||
|
expect(keyName("Digit4")).toBe("4");
|
||||||
|
expect(keyName("ArrowLeft")).toBe("←");
|
||||||
|
expect(keyName("Semicolon")).toBe("Semicolon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a key to its new button and refuses reserved keys", () => {
|
||||||
|
let preferences = rebind(defaultPreferences(), "a", "KeyZ");
|
||||||
|
expect(preferences.keys.a).toEqual(["KeyZ"]);
|
||||||
|
expect(preferences.keys.b).toEqual([]);
|
||||||
|
expect(buttonKeys(preferences, "b")).toBe("None");
|
||||||
|
preferences = rebind(preferences, "start", "Tab");
|
||||||
|
expect(preferences.keys.start).toEqual(["Enter"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through storage and rejects invalid entries", () => {
|
||||||
|
const store = storage();
|
||||||
|
vi.stubGlobal("localStorage", store);
|
||||||
|
const saved = { ...rebind(defaultPreferences(), "start", "KeyQ"), textScale: 1.5 as const };
|
||||||
|
savePreferences(saved);
|
||||||
|
expect(loadPreferences()).toEqual(saved);
|
||||||
|
// A duplicated key or an unknown scale falls back to the defaults.
|
||||||
|
store.values.set(
|
||||||
|
"crystal-preferences-v1",
|
||||||
|
JSON.stringify({ textScale: 3, keys: { ...saved.keys, b: ["KeyQ"] } }),
|
||||||
|
);
|
||||||
|
expect(loadPreferences()).toEqual(defaultPreferences());
|
||||||
|
store.values.set("crystal-preferences-v1", "{not json");
|
||||||
|
expect(loadPreferences()).toEqual(defaultPreferences());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the defaults without storage", () => {
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("blocked");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("blocked");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadPreferences()).toEqual(defaultPreferences());
|
||||||
|
expect(() => savePreferences(defaultPreferences())).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const romPath = resolve(
|
||||||
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||||
|
);
|
||||||
|
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
|
||||||
|
|
||||||
|
async function start(page: Page) {
|
||||||
|
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||||
|
const response = await route.fetch(),
|
||||||
|
source = await response.text();
|
||||||
|
const needle = "const world = new GameWorld(assets);";
|
||||||
|
expect(source).toContain(needle);
|
||||||
|
// Fixture worlds stand in for a saved session, so no introduction runs.
|
||||||
|
expect(source).toContain("this.world?.beginIntro();");
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
body: source.replace("this.world?.beginIntro();", "").replace(
|
||||||
|
needle,
|
||||||
|
`const { pokedexFixtureWorld } = await import('/tests/pokedex-fixture.ts'); const world = pokedexFixtureWorld(assets);`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator("#rom-file").setInputFiles(romPath);
|
||||||
|
await expect(page.locator("#status")).toContainText("verified");
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
|
||||||
|
await page.locator("#play").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("settings rebind a key, scale the text and survive a reload", async ({ page }) => {
|
||||||
|
await start(page);
|
||||||
|
const dialog = page.locator("#settings-dialog");
|
||||||
|
await page.locator("#settings").click();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const startRow = dialog.locator("#key-table tr", { hasText: "START" });
|
||||||
|
await expect(startRow).toContainText("Enter");
|
||||||
|
// Escape cancels a key change without closing the dialog.
|
||||||
|
await startRow.getByRole("button", { name: /Change the key for START/ }).click();
|
||||||
|
await expect(startRow).toContainText("Press a key");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(page.locator("#settings-status")).toHaveText("Key change cancelled.");
|
||||||
|
// Space is reserved; Q becomes START.
|
||||||
|
await startRow.getByRole("button", { name: /Change the key for START/ }).click();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await expect(page.locator("#settings-status")).toContainText("keeps its usual role");
|
||||||
|
await startRow.getByRole("button", { name: /Change the key for START/ }).click();
|
||||||
|
await page.keyboard.press("KeyQ");
|
||||||
|
await expect(startRow).toContainText("Q");
|
||||||
|
await expect(page.locator("#settings-status")).toHaveText("START is now Q.");
|
||||||
|
await dialog.getByLabel("Large", { exact: true }).check();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("style", /--text-scale: 1.25/);
|
||||||
|
await page.locator("#close-settings").click();
|
||||||
|
await expect(page.locator("#game-screen")).toHaveAttribute("aria-label", /Q is START/);
|
||||||
|
|
||||||
|
// Enter no longer opens the start menu; Q does.
|
||||||
|
await page.locator("#play").click();
|
||||||
|
await page.locator("#game-screen").focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.clock.runFor(200);
|
||||||
|
await expect(page.locator("#dialogue-choice")).toBeHidden();
|
||||||
|
await page.keyboard.press("KeyQ");
|
||||||
|
await expect(page.locator("#dialogue-choice").getByRole("option", { name: /^POKéDEX/ })).toBeVisible();
|
||||||
|
|
||||||
|
// The choices stay after a reload.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("style", /--text-scale: 1.25/);
|
||||||
|
await page.locator("#settings").click();
|
||||||
|
await expect(page.locator("#key-table tr", { hasText: "START" })).toContainText("Q");
|
||||||
|
await page.locator("#reset-keys").click();
|
||||||
|
await expect(page.locator("#key-table tr", { hasText: "START" })).toContainText("Enter");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("help lists the current keys", async ({ page }) => {
|
||||||
|
await start(page);
|
||||||
|
await page.locator("#help").click();
|
||||||
|
const keys = page.locator("#help-keys");
|
||||||
|
await expect(keys).toContainText("Interact / Continue");
|
||||||
|
await expect(keys).toContainText("X (A)");
|
||||||
|
await expect(keys).toContainText("Enter (START)");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user