Files
Crystal/src/main.ts
T
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:52:30 +02:00

2510 lines
85 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { drawAtlas, drawTownMap } from "./game/rendering/town-map";
import { drawTileScreen } from "./game/rendering/tile-screen";
import type { AtlasRegion } from "./game/world/atlas";
import {
drawCardPic,
drawTrainerCardBadges,
} from "./game/rendering/trainer-card";
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex";
import {
RESERVED_KEYS,
TEXT_SCALES,
VOLUMES,
type Volume,
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";
import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world";
import type { PokegearCard } from "./game/scripts/pokegear";
import { drawTile } from "./game/rendering/pixels";
import { C } from "./game/battle/constants";
import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon";
import {
MAIL_LINE_LENGTH,
mailMessage,
validMailMessage,
} from "./game/party/mail";
import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
import { validateRom } from "./rom/validate";
import { SlotRepository, type SlotSummary } from "./storage/slots";
import { acquireSessionLock } from "./platform/session-lock";
import {
InstallableApp,
quotaExceeded,
requestPersistentStorage,
storageStatus,
} from "./platform/pwa";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const mapView = new LocalMapView(
get<HTMLCanvasElement>("minimap"),
get<HTMLCanvasElement>("world-map"),
);
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
function showWorld(world: GameWorld) {
mapView.update(world);
get("map-location").textContent =
world.map.name === world.map.location
? world.map.name
: `${world.map.name} · ${world.map.location}`;
get("map-position").textContent =
`You are at ${world.x}, ${world.y}. Red marks your position.`;
get("minimap-position").textContent = `${world.x}, ${world.y}`;
get("map-empty").hidden = true;
get("intro-screen").hidden = !world.intro;
get("location").textContent = world.map.name;
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
showBattle(world);
showStatsScreen(world);
showMail(world);
showPokegear(world);
showTrainerCard(world);
showPokedex(world);
showOptions(world);
showFlyMap(world);
showTileScreen(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
// While a script waits for its sound, the last page stays in the box.
const dialogue =
world.dialogue ??
(world.waitingPage !== undefined
? { pages: [world.waitingPage], index: 0, holdTicks: 0 }
: undefined);
showPicture(world);
// A page without text is a button wait while a picture is shown.
const pictureWait = Boolean(
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
);
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden =
!dialogue ||
pictureWait ||
// The Pokégear's map and radio cards have no textbox.
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
dialogue.choice?.kind === "trainercard" ||
dialogue.choice?.kind === "pokedex" ||
dialogue.choice?.kind === "options" ||
dialogue.choice?.kind === "flymap" ||
dialogue.choice?.kind === "screen" ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen) ||
Boolean(world.mailView);
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
dialogue && dialogue.pages.length > 1
? `${dialogue.index + 1} / ${dialogue.pages.length}`
: "";
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
// A house radio shows only its text box; A, B or Continue stops it.
const choice = last && !mart && dialogue.choice?.kind !== "mapradio" ? dialogue.choice : undefined;
// Timed pages advance themselves; choices replace Continue.
get("dialogue-next").hidden = Boolean(
choice || mart || (last && dialogue.holdTicks !== undefined),
);
showChoice(choice);
showMart(world, mart);
// Coordinates provide a stable accessible description and a test observation
// point, without exposing mutable game state or a production debug API.
canvas.dataset.map = world.mapId;
canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y);
canvas.dataset.activity = world.activity;
canvas.dataset.intro = String(world.intro);
canvas.setAttribute(
"aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. ${controlsSummary()}`,
);
}
/** The choice box floats above the text box, so it may only be as tall as
* the screen above the text box: a phone's start menu then scrolls instead
* of running off the top of the screen, where it cannot be tapped. */
function fitChoiceBox() {
const container = get("dialogue-choice");
if (container.hidden) return;
// The box's own 13px gap above the text box and the 12px inset the text
// box keeps from the screen's edges.
const room = get("native-dialogue").offsetTop - 13 - 12;
container.style.setProperty("--choice-room", `${Math.max(room, 0)}px`);
}
new ResizeObserver(fitChoiceBox).observe(get("native-dialogue"));
new ResizeObserver(fitChoiceBox).observe(canvas.parentElement!);
function showChoice(choice: Choice | undefined) {
const container = get("dialogue-choice");
const shown = !container.hidden;
container.hidden = !choice;
if (choice && !shown) fitChoiceBox();
const key = choice ? JSON.stringify(choice) : "";
if (container.dataset.choice === key) return;
container.dataset.choice = key;
container.dataset.kind = choice?.kind ?? "";
const button = (
label: string,
pressed: boolean | undefined,
action: () => void,
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (pressed !== undefined)
element.setAttribute("aria-pressed", String(pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
if (!choice) container.replaceChildren();
else if (
choice.kind === "pokegear" ||
choice.kind === "trainercard" ||
choice.kind === "pokedex" ||
choice.kind === "options" ||
choice.kind === "flymap" ||
choice.kind === "screen"
) {
// The Pokégear, the trainer card, the Pokédex, the options, the Fly
// map and tile screens read the controls directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
container.replaceChildren(
...balanceBox(choice.balance),
button("YES", choice.selected === 1, () => runtime.choose(1)),
button("NO", choice.selected === 0, () => runtime.choose(0)),
);
else if (choice.kind === "weekday") {
// The original weekday box: Up for the next day, Down for the previous.
const day = document.createElement("output");
day.textContent = choice.names[choice.selected];
day.setAttribute("aria-live", "polite");
container.replaceChildren(
button("▼", undefined, () => {
runtime.world?.moveCursor("down");
if (runtime.world) showWorld(runtime.world);
}),
day,
button("▲", undefined, () => {
runtime.world?.moveCursor("up");
if (runtime.world) showWorld(runtime.world);
}),
button("OK", true, () => runtime.choose(choice.selected)),
);
container.firstElementChild?.setAttribute("aria-label", "Previous day");
container.children[2]?.setAttribute("aria-label", "Next day");
} else if (choice.kind === "clock") {
// InitClock's hour or minutes: Up for later, Down for earlier.
const value = document.createElement("output");
value.textContent = choice.labels[choice.selected];
value.setAttribute("aria-live", "polite");
container.replaceChildren(
button("▼", undefined, () => {
runtime.world?.moveCursor("down");
if (runtime.world) showWorld(runtime.world);
}),
value,
button("▲", undefined, () => {
runtime.world?.moveCursor("up");
if (runtime.world) showWorld(runtime.world);
}),
button("OK", true, () => runtime.choose(choice.selected)),
);
container.firstElementChild?.setAttribute("aria-label", "Earlier");
container.children[2]?.setAttribute("aria-label", "Later");
} else if (choice.kind === "mombank") {
// Mom_ContinueMenuSetup's box: SAVED, HELD and the amount, whose digit
// under the cursor blinks. The buttons send the joypad's own presses.
const { view } = choice;
const box = document.createElement("div");
box.className = "mom-bank";
const row = (label: string, value: HTMLElement | string) => {
const line = document.createElement("p"),
name = document.createElement("span"),
amount = document.createElement("output");
name.textContent = label;
amount.append(value);
line.append(name, amount);
return line;
};
const digits = document.createElement("span");
digits.setAttribute("aria-label", `¥${view.amount}`);
digits.append(
"¥",
...[...String(view.amount).padStart(6, "0")].map((digit, index) => {
const cell = document.createElement("span");
cell.textContent = digit;
if (index === view.cursor) cell.className = "mom-bank-cursor";
return cell;
}),
);
box.append(
row(view.labels[0], `¥${view.saved}`),
row(view.labels[1], `¥${view.held}`),
row(view.labels[2], digits),
);
const press = (label: string, aria: string, name: string) => {
const element = button(label, undefined, () => runtime.choose(name));
element.setAttribute("aria-label", aria);
return element;
};
container.replaceChildren(
box,
press("◀", "Previous digit", "left"),
press("▼", "Decrease digit", "down"),
press("▲", "Increase digit", "up"),
press("▶", "Next digit", "right"),
press("OK", "OK", "a"),
press("CANCEL", "Cancel", "b"),
);
} else if (choice.kind === "name")
container.replaceChildren(
...(choice.mail
? mailField((message) => runtime.choose(message))
: nameField(choice.current, choice.max, (name) =>
runtime.choose(name),
)),
);
else if (choice.kind === "menu") {
// A vertical menu such as the elevator's floors; B cancels.
const list = document.createElement("div");
list.className = "dialogue-menu";
list.setAttribute("role", "listbox");
list.replaceChildren(
...choice.items.map((item, index) => {
const option = button(item, choice.selected === index, () =>
runtime.choose(index),
);
const detail = choice.details?.[index];
if (detail) {
const small = document.createElement("small");
small.textContent = detail;
option.append(small);
}
option.setAttribute("role", "option");
option.setAttribute("aria-label", detail ? `${item} ${detail}` : item);
option.setAttribute("aria-selected", String(choice.selected === index));
return option;
}),
);
// As in the original there is no CANCEL entry: B leaves the menu.
container.replaceChildren(...balanceBox(choice.balance), list);
// On a small screen the box scrolls; keep the highlighted entry in view.
list
.querySelector('[aria-selected="true"]')
?.scrollIntoView({ block: "nearest" });
}
}
/** The money or coin box a script placed beside a choice. */
function balanceBox(balance: string | undefined): HTMLElement[] {
if (!balance) return [];
const box = document.createElement("output");
box.className = "choice-balance";
box.textContent = balance;
return [box];
}
/** The naming screen as a text field and OK: an empty name keeps the
* current one. */
function nameField(
current: string,
max: number,
answer: (name: string) => void,
): HTMLElement[] {
const input = document.createElement("input");
input.maxLength = max;
input.placeholder = current;
input.setAttribute("aria-label", `Name (empty keeps ${current})`);
input.autocomplete = "off";
input.spellcheck = false;
const submit = () => {
const name = input.value.trim();
if (name && !NICKNAME_PATTERN.test(name)) {
input.setCustomValidity(
"Use letters, digits, spaces and simple punctuation.",
);
input.reportValidity();
return;
}
answer(name);
canvas.focus({ preventScroll: true });
};
input.addEventListener("input", () => input.setCustomValidity(""));
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") submit();
});
const ok = document.createElement("button");
ok.type = "button";
ok.textContent = "OK";
ok.addEventListener("click", () => {
if (session.state === "playing") submit();
});
queueMicrotask(() => input.focus({ preventScroll: true }));
return [input, ok];
}
/** _ComposeMailMessage's entry as two lines of 16 characters and OK. A
* second line starts once the first is full, so the first is padded. */
function mailField(answer: (message: string) => void): HTMLElement[] {
const lines = [1, 2].map((line) => {
const input = document.createElement("input");
input.maxLength = MAIL_LINE_LENGTH;
input.setAttribute("aria-label", `MAIL line ${line}`);
input.autocomplete = "off";
input.spellcheck = false;
input.addEventListener("input", () => input.setCustomValidity(""));
return input;
});
const submit = () => {
const message = mailMessage(lines[0].value, lines[1].value.trimEnd());
if (!validMailMessage(message)) {
lines[0].setCustomValidity(
"Use letters, digits, spaces and the MAIL's punctuation.",
);
lines[0].reportValidity();
return;
}
answer(message);
canvas.focus({ preventScroll: true });
};
lines[0].addEventListener("keydown", (event) => {
if (event.key === "Enter") lines[1].focus({ preventScroll: true });
});
lines[1].addEventListener("keydown", (event) => {
if (event.key === "Enter") submit();
});
const ok = document.createElement("button");
ok.type = "button";
ok.textContent = "OK";
ok.addEventListener("click", () => {
if (session.state === "playing") submit();
});
queueMicrotask(() => lines[0].focus({ preventScroll: true }));
const box = document.createElement("div");
box.className = "mail-lines";
box.append(...lines);
return [box, ok];
}
/** ReadAnyMail: cartridge stationery plus accessible message text. */
function showMail(world: GameWorld) {
const mail = world.mailView;
get("mail-view").hidden = !mail;
if (!mail) return;
get("mail-type").textContent = world.assets.pokemon.itemName(mail.type);
get("mail-message").textContent = mail.message;
get("mail-author").textContent = mail.author;
const choice = world.dialogue?.choice;
if (choice?.kind === "screen") drawTileScreen(get<HTMLCanvasElement>("mail-canvas"), choice.screen, 0);
}
type MartChoice = Extract<
Choice,
{ kind: "martmenu" | "martlist" | "martquantity" | "pack" }
>;
function martChoice(choice: Choice): MartChoice | undefined {
return choice.kind === "martmenu" ||
choice.kind === "martlist" ||
choice.kind === "martquantity" ||
choice.kind === "pack"
? choice
: undefined;
}
let martMenuKey = "";
/** The mart's BUY/SELL/QUIT box, item list or quantity stepper, plus a
* money display. Follows the battle menu's button-grid style. */
function showMart(world: GameWorld, mart: MartChoice | undefined) {
get("mart").hidden = !mart;
if (!mart) {
martMenuKey = "";
return;
}
// The PC's item storage and the pack reuse these lists without prices.
const shop =
mart.kind === "martmenu" ||
(mart.kind !== "pack" && (mart.mode === "buy" || mart.mode === "sell"));
get("mart").setAttribute(
"aria-label",
shop
? "Poké Mart"
: mart.kind === "pack"
? "Pack"
: mart.kind === "martquantity" && mart.mode === "quantity"
? "How many"
: "Item storage",
);
get("mart-money").hidden = !shop;
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
get("mart-prompt").textContent =
mart.kind === "martlist"
? ""
: mart.kind === "pack"
? (mart.prompt ?? "").replace(/\n/g, " ")
: mart.prompt;
const key = JSON.stringify(mart);
if (key === martMenuKey) return;
martMenuKey = key;
const menu = get("mart-menu"),
tabs = get("mart-tabs");
tabs.hidden = mart.kind !== "pack";
const button = (
label: string,
action: () => void,
options: { detail?: string; selected?: boolean; wide?: boolean } = {},
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (options.detail) {
const small = document.createElement("small");
small.textContent = options.detail;
element.append(small);
}
if (options.selected) element.classList.add("battle-selected");
if (options.wide) element.classList.add("wide");
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
if (mart.kind === "martmenu") {
menu.replaceChildren(
...(["BUY", "SELL", "QUIT"] as const).map((label, index) =>
button(label, () => runtime.choose(index), {
selected: mart.selected === index,
}),
),
);
return;
}
if (mart.kind === "pack") {
// Pocket tabs (left and right on the keyboard), then the pocket's items.
// While an item moves with Select, the pocket stays and the moving
// entry is marked; choosing another entry places it there.
const switching = mart.switching;
const pocketTabs = mart.pockets.map((pocket, index) => {
const tab = button(pocket.name, () => {
const world = runtime.world;
const choice = world?.dialogue?.choice;
if (choice?.kind !== "pack" || !world) return;
choice.pocket = index;
choice.selected = 0;
showWorld(world);
});
tab.setAttribute("aria-pressed", String(mart.pocket === index));
tab.disabled = switching !== undefined && mart.pocket !== index;
return tab;
});
const entries = mart.pockets[mart.pocket].entries;
const buttons = entries.map((entry, index) => {
const entryButton = button(
switching === index ? `▷${entry.name}` : entry.name,
() => runtime.choosePackEntry(mart.pocket, index),
{
detail: entry.owned === undefined ? undefined : `×${entry.owned}`,
selected: mart.selected === index,
},
);
if (switching === index) entryButton.setAttribute("aria-current", "true");
return entryButton;
});
if (!entries.length) {
const empty = button("Empty", () => {}, { wide: true });
empty.disabled = true;
buttons.push(empty);
}
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
tabs.replaceChildren(...pocketTabs);
menu.replaceChildren(...buttons);
return;
}
if (mart.kind === "martlist") {
const buttons = mart.entries.map((entry, index) =>
button(entry.name, () => runtime.choose(index), {
detail:
mart.mode === "sell"
? `have ${entry.owned}`
: mart.mode === "buy"
? `¥${entry.price}`
: `×${entry.owned}`,
selected: mart.selected === index,
}),
);
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
menu.replaceChildren(...buttons);
return;
}
// martquantity: a 1-99 stepper (±1 Up/Down, ±10 Left/Right, matching
// BuySellToss_InterpretJoypad), then BUY/SELL and CANCEL.
const total =
mart.mode === "sell"
? Math.floor((mart.price * mart.selected) / 2)
: mart.price * mart.selected;
const stepper = document.createElement("div");
stepper.className = "mart-quantity";
const step = (label: string, aria: string, direction: Direction) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
element.setAttribute("aria-label", aria);
element.addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.world?.moveCursor(direction);
if (runtime.world) showWorld(runtime.world);
canvas.focus({ preventScroll: true });
});
return element;
};
const output = document.createElement("output");
output.textContent = shop
? `${mart.name} ×${mart.selected} ¥${total}`
: `${mart.name} ×${mart.selected}`;
stepper.append(
step("◀◀", "Minus 10", "left"),
step("▼", "Minus 1", "down"),
output,
step("▲", "Plus 1", "up"),
step("▶▶", "Plus 10", "right"),
);
menu.replaceChildren(
stepper,
button(
mart.mode === "quantity" ? "OK" : mart.mode.toUpperCase(),
() => runtime.choose(mart.selected),
{ wide: true },
),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
const STATUS_NAMES: [number, string][] = [
[0x08, "PSN"],
[0x10, "BRN"],
[0x20, "FRZ"],
[0x40, "PAR"],
];
function statusName(status: number) {
if (status & 7) return "SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
function drawPic(
target: HTMLCanvasElement,
tiles: Uint8Array,
size: number,
palette: Palette,
flip = false,
) {
const context = target.getContext("2d")!;
const pixels = size * 8;
const image = context.createImageData(pixels, pixels);
for (let tile = 0; tile < size * size; tile++)
drawTile(
image.data,
pixels,
pixels,
tiles,
tile,
Math.floor(tile / size) * 8,
(tile % size) * 8,
palette,
{ flipX: flip },
);
context.putImageData(image, 0, 0);
}
const battlePics = { foe: "", player: "" };
let battleMenuKey = "";
let showMoves = false;
/** BattlePack, opened on the last pocket used (wLastPocket). */
let showPack = false;
let packPocket: "item" | "ball" = "item";
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
let partyChoice: number | undefined;
/** The stats screen and its page (pink, green, blue): opened from
* BattleMonMenu for a party slot, or by a script (Bill's PC) without one. */
let statsView: { slot?: number; page: number } | undefined;
let battleCursor = 0;
let battleSelectionKey = "";
/** Start opens the start menu and Select uses the registered item, while
* the player stands still (StartMenuScript and SelectMenuScript). */
function handleFieldInput(button: Button): boolean {
const world = runtime.world;
if (!world || world.battle || world.busy) return false;
if (button === "start") runtime.openStartMenu();
else if (button === "select") runtime.useSelect();
else return false;
return true;
}
/** Battle prompts answered with YES/NO. */
const yesNoPrompt = (prompt?: { kind: string }) =>
prompt?.kind === "yesno" || prompt?.kind === "contestswitch";
function battleButtons() {
return [
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
/** The enabled button next to `from` in the arrow's direction as drawn, so
* the cursor always follows the layout: a column moves with up and down, a
* row with left and right, and a grid with all four. */
function adjacentButton(
buttons: HTMLButtonElement[],
from: number,
arrow: "up" | "down" | "left" | "right",
): number | undefined {
const origin = buttons[from]?.getBoundingClientRect();
if (!origin) return undefined;
const vertical = arrow === "up" || arrow === "down";
let best: number | undefined;
let bestScore = Infinity;
buttons.forEach((candidate, index) => {
if (index === from) return;
const box = candidate.getBoundingClientRect();
const gap =
arrow === "up"
? origin.top - box.bottom
: arrow === "down"
? box.top - origin.bottom
: arrow === "left"
? origin.left - box.right
: box.left - origin.right;
if (gap < -1) return;
const offset = vertical
? Math.abs(box.left + box.width / 2 - (origin.left + origin.width / 2))
: Math.abs(box.top + box.height / 2 - (origin.top + origin.height / 2));
const score = Math.max(gap, 0) + offset * 2;
if (score < bestScore) {
bestScore = score;
best = index;
}
});
return best;
}
function highlightBattleChoice() {
battleButtons().forEach((button, index) => {
button.classList.toggle("battle-selected", index === battleCursor);
});
}
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
function handleBattleInput(button: Button): boolean {
if (handleFieldInput(button)) return true;
const world = runtime.world;
if (statsView && world) {
// StatsScreen_JoypadAction: left and right turn pages, B leaves.
// BattleMenuPKMN_ReturnFromStats reopens the party menu itself.
if (button === "b" && statsView.slot === undefined) world.advanceDialogue();
else if (button === "b") statsView = partyChoice = undefined;
else if (button === "left" || button === "up")
statsView.page = (statsView.page + 2) % 3;
else if (button === "right" || button === "down" || button === "a")
statsView.page = (statsView.page + 1) % 3;
showWorld(world);
return true;
}
if (!world?.battle) return false;
if (button === "b" && (showMoves || showPack)) {
showMoves = showPack = false;
return true;
}
if (button === "b" && partyChoice !== undefined) {
partyChoice = undefined;
showWorld(world);
return true;
}
const buttons = battleButtons();
if (!buttons.length) return false;
if (button === "a") {
buttons[battleCursor]?.click();
return true;
}
if (
button === "up" ||
button === "down" ||
button === "left" ||
button === "right"
) {
battleCursor =
adjacentButton(buttons, battleCursor, button) ?? battleCursor;
if (yesNoPrompt(world.battle.prompt))
world.battle.selected = battleCursor === 0 ? 1 : 0;
highlightBattleChoice();
return true;
}
return false;
}
/** The battle screen: pictures, HUDs, messages and the current choice. */
function showBattle(world: GameWorld) {
const view = world.battle;
const screen = get("battle");
screen.hidden = !view;
if (!view) {
battlePics.foe = battlePics.player = battleMenuKey = "";
showMoves = showPack = false;
battleCursor = 0;
battleSelectionKey = "";
return;
}
const data = world.assets.pokemon;
const { player, enemy } = view.engine;
const foe = enemy.mon,
own = player.mon;
if (foe) {
const key = `${foe.species}:${foe.dvs}`;
if (battlePics.foe !== key) {
battlePics.foe = key;
drawPic(
get("battle-foe-pic"),
data.frontPic(foe.species, PokemonData.unownLetter(foe.dvs)),
7,
data.palette(foe.species, PokemonData.shiny(foe.dvs)),
);
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
}
get("battle-foe-name").textContent = foe.nickname;
get("battle-foe-level").textContent = `:L${foe.level}`;
get("battle-foe-status").textContent = statusName(foe.status);
setHp(get("battle-foe-hp"), foe.hp, foe.maxHp);
}
get("battle-player").hidden = !own;
if (own) {
const key = `${own.species}:${own.dvs}`;
if (battlePics.player !== key) {
battlePics.player = key;
drawPic(
get("battle-player-pic"),
data.backPic(own.species, PokemonData.unownLetter(own.dvs)),
6,
data.palette(own.species, PokemonData.shiny(own.dvs)),
);
get("battle-player-pic").setAttribute("aria-label", own.nickname);
}
get("battle-player-name").textContent = own.nickname;
get("battle-player-level").textContent = `:L${own.level}`;
get("battle-player-status").textContent = statusName(own.status);
get("battle-player-hp-text").textContent = `${own.hp}/ ${own.maxHp}`;
setHp(get("battle-player-hp"), own.hp, own.maxHp);
}
const prompt = view.prompt;
get("battle-text").textContent =
prompt?.kind === "name"
? prompt.title
: prompt?.kind === "party" ||
prompt?.kind === "move" ||
prompt?.kind === "contestswitch"
? prompt.text
: (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
if (prompt?.kind !== "party") {
partyChoice = undefined;
if (statsView?.slot !== undefined) statsView = undefined;
}
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
if (selectionKey !== battleSelectionKey) {
battleCursor = 0;
battleSelectionKey = selectionKey;
}
if (yesNoPrompt(prompt)) battleCursor = view.selected === 1 ? 0 : 1;
const bag = world.liveState.bag;
// The Pokémon out has its live battle HP; the others their party values.
const party = world.liveState.party.map((mon, index) =>
index === view.engine.curBattleMon && own
? { ...mon, hp: own.hp, status: own.status }
: mon,
);
const key = JSON.stringify([
prompt,
view.selected,
showMoves,
showPack,
partyChoice,
packPocket,
own?.pp,
bag.items,
bag.balls,
prompt?.kind === "party" &&
party.map(({ hp, status, level }) => [hp, status, level]),
view.message?.prompt,
]);
if (key === battleMenuKey) return;
battleMenuKey = key;
const menu = get("battle-menu");
const button = (
label: string,
action: () => void,
options: {
detail?: string;
disabled?: string;
wide?: boolean;
pressed?: boolean;
} = {},
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (options.detail) {
const small = document.createElement("small");
small.textContent = options.detail;
element.append(small);
}
if (options.disabled) {
element.disabled = true;
element.title = options.disabled;
}
if (options.wide) element.classList.add("wide");
if (options.pressed !== undefined)
element.setAttribute("aria-pressed", String(options.pressed));
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
const buttons: HTMLButtonElement[] = [];
if (prompt?.kind === "action" && own) {
if (showMoves) {
own.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(
data.moveName(move),
() => {
showMoves = false;
runtime.battleAction({ action: "fight", move: index });
},
{ detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` },
),
);
});
buttons.push(
button(
"BACK",
() => {
showMoves = false;
showWorld(world);
},
{ wide: true },
),
);
} else if (showPack) {
for (const pocket of ["item", "ball"] as const)
buttons.push(
button(
data.pocketName(pocket),
() => {
packPocket = pocket;
showWorld(world);
},
{ pressed: packPocket === pocket },
),
);
const slots = packPocket === "item" ? bag.items : bag.balls;
slots.forEach(({ item, quantity }, slot) =>
buttons.push(
button(
data.itemName(item),
() => {
showPack = false;
runtime.battleAction({ action: "item", item, slot });
},
{ detail: `×${quantity}` },
),
),
);
if (!slots.length)
buttons.push(
button("Empty", () => {}, {
disabled: `The ${data.pocketName(packPocket)} is empty.`,
wide: true,
}),
);
buttons.push(
button(
"BACK",
() => {
showPack = false;
showWorld(world);
},
{ wide: true },
),
);
} else {
buttons.push(
button("FIGHT", () => {
if (view.engine.fightKeepsMove)
return runtime.battleAction({ action: "fight", move: 0 });
showMoves = true;
showWorld(world);
}),
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
// ContestBattleMenu: PARKBALL× with two digits throws a Park Ball.
prompt.parkBalls !== undefined
? button(
`PARKBALL×${String(prompt.parkBalls).padStart(2, "0")}`,
() =>
runtime.battleAction({
action: "item",
item: C.PARK_BALL,
slot: -1,
}),
)
: button("PACK", () => {
// BattleMenu_Pack.ItemsCantBeUsed: the engine says why.
if (prompt.noItems)
return runtime.battleAction({ action: "item", item: 0, slot: -1 });
showPack = true;
showWorld(world);
}),
button("RUN", () => runtime.battleAction({ action: "run" })),
);
}
} else if (prompt?.kind === "contestswitch") {
// DisplayCaughtContestMonStats: STOCK and THIS boxes, then Yes/No.
for (const [label, mon] of [
["STOCK", prompt.stock],
["THIS", prompt.caught],
] as const)
buttons.push(
button(`${label} ${mon.name} :L${mon.level}`, () => {}, {
detail: `HEALTH ${mon.maxHp}`,
disabled: " ",
wide: true,
}),
);
buttons.push(
button("YES", () => runtime.battleChoose(1), {
pressed: view.selected === 1,
}),
button("NO", () => runtime.battleChoose(0), {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "yesno") {
buttons.push(
button("YES", () => runtime.battleChoose(1), {
pressed: view.selected === 1,
}),
button("NO", () => runtime.battleChoose(0), {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "party" && partyChoice !== undefined) {
// BattleMonMenu. STATS opens the stats screen, which is not ported.
const slot = partyChoice;
buttons.push(
button("SWITCH", () => {
partyChoice = undefined;
runtime.battleChoose(slot);
}),
button("STATS", () => {
statsView = { slot, page: 0 };
showWorld(world);
}),
button("CANCEL", () => {
partyChoice = undefined;
showWorld(world);
}),
);
} else if (prompt?.kind === "party") {
party.forEach((mon, slot) =>
buttons.push(
button(
mon.nickname,
() => {
if (!prompt.menu) return runtime.battleChoose(slot);
partyChoice = slot;
showWorld(world);
},
{
// An Egg's row has no level or HP.
detail: mon.egg
? ""
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
mon.status ? ` ${statusName(mon.status)}` : ""
}`,
wide: true,
},
),
),
);
if (prompt.cancel)
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "move") {
prompt.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => runtime.battleChoose(index), {
detail: `PP ${prompt.pp[index] & 63}`,
}),
);
});
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(
button(data.moveName(move), () => runtime.battleChoose(index)),
),
);
buttons.push(
button("CANCEL", () => runtime.battleChoose(4), { wide: true }),
);
} else if (prompt?.kind === "name") {
menu.replaceChildren(
...nameField(prompt.current, prompt.max, (name) =>
runtime.battleName(name),
),
);
return;
} else if (prompt?.kind === "stats") {
prompt.labels.forEach((label, index) =>
buttons.push(
button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " }),
),
);
buttons.push(
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
);
} else if (view.message?.prompt) {
buttons.push(
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
);
}
// FIGHT/PKMN/PACK/RUN is the original's 2x2 grid; every other choice is a
// vertical list, moved with Up and Down as it is drawn.
const grid =
(prompt?.kind === "action" && !showMoves && !showPack) ||
prompt?.kind === "stats";
menu.dataset.layout = grid ? "grid" : "list";
menu.dataset.kind = prompt?.kind ?? "";
menu.replaceChildren(...buttons);
battleCursor = Math.min(
battleCursor,
Math.max(0, battleButtons().length - 1),
);
highlightBattleChoice();
}
let pokegearKey = "";
const POKEGEAR_CARDS: Record<PokegearCard, string> = {
clock: "CLOCK",
map: "MAP",
phone: "PHONE",
radio: "RADIO",
};
/** The Pokégear's cards (engine/pokegear/pokegear.asm): the card icons,
* then the clock, the Town Map with its cursor's landmark, the phone list
* or the radio. Its textbox is the native dialogue. */
function showPokegear(world: GameWorld) {
const view = world.pokegear;
get("pokegear").hidden = !view;
if (!view) {
pokegearKey = "";
return;
}
const key = JSON.stringify(view);
if (key === pokegearKey) return;
pokegearKey = key;
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
className = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
if (className) node.className = className;
return node;
};
get("pokegear-cards").replaceChildren(
...view.cards.map((card) => {
const icon = element("span", POKEGEAR_CARDS[card]);
icon.setAttribute("aria-current", String(card === view.card));
return icon;
}),
);
const body = get("pokegear-card");
if (view.clock) {
const clock = element("div", "", "pokegear-clock");
clock.append(element("span", view.clock.day), element("strong", view.clock.time));
body.replaceChildren(clock);
} else if (view.map) {
const map = element("div", "", "pokegear-map"),
canvas = element("canvas");
canvas.setAttribute("role", "img");
drawTownMap(canvas, world, view.map);
map.append(element("p", `▶ ${view.map.name}`), canvas);
body.replaceChildren(map);
} else if (view.phone) {
const phone = view.phone,
card = element("div", "", "pokegear-phone"),
list = element("ol");
list.setAttribute("aria-label", "Phone numbers");
list.replaceChildren(
...phone.rows.map((row, index) => {
const item = element("li", row);
item.dataset.mark =
phone.chosen === index
? "▷"
: phone.chosen === undefined && phone.cursor === index
? "▶"
: " ";
if (item.dataset.mark !== " ") item.setAttribute("aria-current", "true");
return item;
}),
);
card.append(
element("span", phone.signal ? "Signal ▮▮▮" : "Out of service", "pokegear-signal"),
list,
);
if (phone.submenu) {
const submenu = element("ol", "", "pokegear-submenu");
submenu.replaceChildren(
...phone.submenu.items.map((item, index) => {
const entry = element("li", item);
entry.dataset.mark = index === phone.submenu!.selected ? "▶" : " ";
return entry;
}),
);
card.append(submenu);
}
body.replaceChildren(card);
} else if (view.radio) {
// The tuning dial's frequency, the station's name and its text box.
const radio = view.radio,
card = element("div", "", "pokegear-radio"),
dial = element("p", `FM ${radio.frequency}`, "pokegear-frequency"),
box = element("div", "", "pokegear-radio-text");
box.setAttribute("aria-live", "polite");
box.append(element("span", radio.lines[0]), element("span", radio.lines[1]));
card.append(dial, element("strong", radio.name), box);
body.replaceChildren(card);
} else body.replaceChildren();
}
let pokedexKey = "";
/** Pokedex: the main list or an entry, from the world's current view. */
function showPokedex(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "pokedex" ? choice.view : undefined;
const panel = get("pokedex-screen");
panel.hidden = !view;
if (!view) {
pokedexKey = "";
return;
}
const key = JSON.stringify(view);
if (key === pokedexKey) return;
pokedexKey = key;
renderPokedexScreen(panel, view, world.assets);
}
let pokedexTabKey = "";
let pokedexTabSelection: number | undefined;
/** The Pokédex tab, from the loaded game's committed or draft state. */
function showPokedexTab() {
if (tabs.active() !== "pokedex") return;
const world = ready() ? runtime.world : undefined;
const state = world?.liveState;
const progress = state && {
owned: state.flags.has(ENGINE_FLAGS.pokedex),
seen: state.pokedex.seen,
caught: state.pokedex.caught,
firstUnownSeen: state.firstUnownSeen,
};
const key = JSON.stringify([
Boolean(world),
progress && [progress.owned, [...progress.seen], [...progress.caught], progress.firstUnownSeen],
pokedexTabSelection,
]);
if (key === pokedexTabKey) return;
pokedexTabKey = key;
renderPokedexTab(get("panel-pokedex"), world?.assets, progress, pokedexTabSelection, (species) => {
pokedexTabSelection = species;
showPokedexTab();
// On narrow screens the entry sits below the list.
get("panel-pokedex").querySelector(".pokedex-tab-entry")?.scrollIntoView({ block: "nearest" });
});
}
let atlasKey = "";
let atlasRegion: AtlasRegion | undefined;
/** The Map tab's region atlas, from the loaded game's live state. */
function showAtlas() {
if (tabs.active() !== "map") return;
const world = ready() ? runtime.world : undefined;
const panel = get("atlas");
panel.hidden = !world;
if (!world) {
atlasKey = "";
return;
}
const view = world.atlas(atlasRegion);
const key = JSON.stringify([view, world.avatar]);
if (key === atlasKey) return;
atlasKey = key;
const names = { johto: "Johto", kanto: "Kanto" } as const;
get("atlas-title").textContent = names[view.region];
get("atlas-regions").replaceChildren(
...(view.regions.length > 1 ? view.regions : []).map((region) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = names[region];
button.setAttribute("aria-pressed", String(region === view.region));
button.addEventListener("click", () => {
atlasRegion = region;
showAtlas();
});
return button;
}),
);
const canvas = get<HTMLCanvasElement>("atlas-map");
drawAtlas(canvas, world, view);
const here = view.landmarks.find((landmark) => landmark.landmark === view.player);
canvas.setAttribute("aria-label", `${names[view.region]} map${here ? `, you are in ${here.name}` : ""}`);
get("atlas-here").textContent = here
? `You are in ${here.name}.`
: `You are outside ${names[view.region]}.`;
const items = (list: string[], empty: string) =>
(list.length ? list : [empty]).map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
});
get("atlas-fly").replaceChildren(
...items(
view.landmarks.filter((landmark) => landmark.flypoint?.visited).map((landmark) => landmark.name),
"None yet",
),
);
get("atlas-open").replaceChildren(
...items(
view.landmarks.filter((landmark) => landmark.open).map((landmark) => landmark.name),
"None",
),
);
}
let optionsKey = "";
/** _Option's screen: each option with its setting, and the ▶ cursor. */
function showOptions(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "options" ? choice.view : undefined;
get("options").hidden = !view;
if (!view) {
optionsKey = "";
return;
}
const key = JSON.stringify(view);
if (key === optionsKey) return;
optionsKey = key;
get("options-list").replaceChildren(
...view.rows.map((row, index) => {
const item = document.createElement("li"),
label = document.createElement("span"),
value = document.createElement("strong");
label.textContent = row.label;
value.textContent = row.value;
item.append(label, value);
item.dataset.mark = index === view.cursor ? "▶" : " ";
if (index === view.cursor) item.setAttribute("aria-current", "true");
return item;
}),
);
}
let tileScreenKey = "";
/** A minigame or special's tile screen; blinking sprites follow the
* world's frames. */
function showTileScreen(world: GameWorld) {
const choice = world.dialogue?.choice;
const screen = choice?.kind === "screen" && !world.mailView ? choice.screen : undefined;
get("tile-screen").hidden = !screen;
get("tile-screen").classList.toggle("overlay", Boolean(screen?.overlay));
if (!screen) {
tileScreenKey = "";
return;
}
const blink = screen.sprites.some((sprite) => sprite.blink);
const frame = world.ticks * 2;
const key = JSON.stringify([
screen.tileset,
screen.map,
screen.attrs,
screen.sprites,
blink && frame & 0x10,
]);
if (key === tileScreenKey) return;
tileScreenKey = key;
drawTileScreen(get<HTMLCanvasElement>("tile-screen-canvas"), screen, frame);
}
let flyMapKey = "";
/** _FlyMap: TownMapBubble's "Where?" and flypoint name over the Town Map,
* with the Fly user's icon on the flypoint. */
function showFlyMap(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "flymap" ? choice.view : undefined;
get("fly-map").hidden = !view;
if (!view) {
flyMapKey = "";
return;
}
const key = JSON.stringify(view);
if (key === flyMapKey) return;
flyMapKey = key;
const where = document.createElement("span"),
arrows = document.createElement("span");
where.textContent = `${view.where}\n${view.name}`;
arrows.textContent = "▲▼";
arrows.setAttribute("aria-hidden", "true");
get("fly-map-bubble").replaceChildren(where, arrows);
drawTownMap(get<HTMLCanvasElement>("fly-map-canvas"), world, view);
}
let trainerCardKey = "";
/** TrainerCard (engine/menus/trainer_card.asm): the top half with the
* name, ID, money and card picture, then page 1's Pokédex count and play
* time or page 2's leaders and badges. */
function showTrainerCard(world: GameWorld) {
const view = world.trainerCard;
const panel = get("trainer-card");
panel.hidden = !view;
if (!view) {
trainerCardKey = "";
return;
}
const key = JSON.stringify(view);
if (key === trainerCardKey) return;
const first = !trainerCardKey;
trainerCardKey = key;
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
return node;
};
const rows = (list: [string, string][]) => {
const dl = element("dl");
for (const [term, value] of list)
dl.append(element("dt", term), element("dd", value));
return dl;
};
if (first) {
// The frame in the other gender's palette, the picture in the player's.
const frame = world.assets.pokemon.trainerPalette(view.female ? 0 : 1)[1];
panel.style.setProperty("--trainer-card-frame", `rgb(${frame.join(" ")})`);
const pic = get<HTMLCanvasElement>("trainer-card-pic");
drawCardPic(pic, world.assets, view.female);
pic.setAttribute("aria-label", view.female ? "Kris" : "Chris");
}
const { labels } = view;
get("trainer-card-info").replaceChildren(
...rows([
[labels.name, view.name],
["ID NO", view.id],
[labels.money, `¥${view.money}`],
]).childNodes,
);
const page = get("trainer-card-page");
if (view.page === 1) {
const { hours, minutes } = view.time;
const time = `${String(hours).padStart(4, " ")}${view.colon ? ":" : " "}${String(minutes).padStart(2, "0")}`;
const list: [string, string][] = [];
if (view.pokedex !== undefined)
list.push([labels.pokedex, String(view.pokedex).padStart(3, " ")]);
list.push([labels.playTime, time]);
const status = rows(list);
status.setAttribute(
"aria-label",
`Play time ${hours} hours ${minutes} minutes`,
);
page.replaceChildren(element("h3", "STATUS"), status, element("p", labels.badges));
} else {
const canvas = element("canvas");
canvas.setAttribute("role", "img");
let owned = 0;
for (let bit = 0; bit < 8; bit++) if (view.badges & (1 << bit)) owned++;
canvas.setAttribute("aria-label", `${owned} of 8 Johto badges`);
drawTrainerCardBadges(
canvas,
world.assets,
view.female,
view.badges,
view.badgeFrame,
);
page.replaceChildren(element("h3", labels.badges.replace("▶", "")), canvas);
}
}
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
* strings, type names and stat labels read from the ROM. */
function showStatsScreen(world: GameWorld) {
const panel = get("stats-screen");
const view = world.battle;
// A script's stats screen opens on the first page and closes with it.
if (world.statsScreen && !statsView) statsView = { page: 0 };
if (!world.statsScreen && statsView?.slot === undefined) statsView = undefined;
const slot = statsView?.slot;
const own = view?.engine.player.mon;
const stored =
slot === undefined ? world.statsScreen : world.liveState.party[slot];
panel.hidden = !statsView || !stored;
if (!statsView || !stored) return;
const data = world.assets.pokemon;
const mon =
view && slot === view.engine.curBattleMon && own
? { ...stored, hp: own.hp, status: own.status }
: stored;
const shiny = PokemonData.shiny(mon.dvs);
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
data.statsString(label);
if (mon.egg) {
// EggStatsScreen: the Egg's picture, "EGG", unknown ID and OT, and a
// hint picked by the egg cycles left.
drawPic(get("stats-pic"), data.eggPic(), 7, data.eggPalette());
get("stats-pic").setAttribute("aria-label", text("EggString"));
const strong = document.createElement("strong");
strong.textContent = text("EggString");
const unknown = text("FiveQMarkString");
const row = (...parts: (string | Node)[]) => {
const p = document.createElement("p");
p.append(...parts);
return p;
};
get("stats-header").replaceChildren(
row(strong),
row(`${text("IDNoString")}${unknown}`),
row(`${text("OTString")}${unknown}`),
);
const cycles = mon.happiness;
get("stats-page").replaceChildren(
row(
text(
cycles < 6
? "EggSoonString"
: cycles < 0xb
? "EggCloseString"
: cycles < 0x29
? "EggMoreTimeString"
: "EggALotMoreTimeString",
).replace(/\n/g, " "),
),
);
panel.dataset.page = "egg";
return;
}
drawPic(
get("stats-pic"),
data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)),
7,
data.palette(mon.species, shiny),
);
get("stats-pic").setAttribute("aria-label", data.speciesName(mon.species));
const gender = data.gender(mon.species, mon.dvs);
const line = (...parts: (string | Node)[]) => {
const p = document.createElement("p");
p.append(...parts);
return p;
};
const name = document.createElement("strong");
name.textContent = `${mon.nickname}${gender === "male" ? "♂" : gender === "female" ? "♀" : ""}`;
get("stats-header").replaceChildren(
line(
`№.${String(mon.species).padStart(3, "0")} :L${mon.level}${shiny ? " ★" : ""}`,
),
line(name),
line(`/${data.speciesName(mon.species)}`),
);
const list = (rows: [string, string][], wide = false) => {
const dl = document.createElement("dl");
if (wide) dl.classList.add("wide");
for (const [term, value] of rows) {
const dt = document.createElement("dt"),
dd = document.createElement("dd");
dt.textContent = term;
dd.textContent = value;
dl.append(dt, dd);
}
return dl;
};
const growth = data.baseData(mon.species).growthRate;
const page: Node[] = [];
if (statsView.page === 0) {
const [statusLabel, typeLabel] = text("LoadPinkPage.Status_Type").split(
"\n",
);
const types = [...new Set(data.baseData(mon.species).types)].map((type) =>
data.typeName(type),
);
const next = Math.min(mon.level + 1, 100);
page.push(
list([
["HP", `${mon.hp}/ ${mon.stats[0]}`],
[
statusLabel,
mon.pokerus & 0x0f
? text("LoadPinkPage.PkrsStr")
: !mon.hp
? "FNT"
: statusName(mon.status) || text("LoadPinkPage.OK_str").trim(),
],
[typeLabel, types.join(" / ")],
]),
list([
[text("LoadPinkPage.ExpPointStr"), String(mon.exp)],
[
text("LoadPinkPage.LevelUpStr"),
String(
mon.level >= 100 ? 0 : data.expAtLevel(growth, next) - mon.exp,
),
],
[text("LoadPinkPage.ToStr"), `:L${next}`],
]),
);
} else if (statsView.page === 1) {
page.push(
list(
[
[
text("LoadGreenPage.Item"),
mon.item
? data.itemName(mon.item)
: text("LoadGreenPage.ThreeDashes"),
],
...mon.moves.flatMap((move, index): [string, string][] =>
move
? [
[
index ? "" : text("LoadGreenPage.Move"),
`${data.moveName(move)} PP ${mon.pp[index] & 63}/${maxPP(data, move, mon.pp[index] >> 6) & 63}`,
],
]
: [],
),
],
true,
),
);
} else {
// The OT's gender comes from the caught data unless it is unset.
const caught = mon.caughtData & 0xff;
const otGender =
!caught || caught === 0x7f ? "" : caught & 0x80 ? "♀" : "♂";
const labels = data.statNames();
page.push(
list([
[text("IDNoString"), String(mon.otId).padStart(5, "0")],
[text("OTString"), `${mon.ot}${otGender}`],
]),
list(
labels.map((label, index): [string, string] => [
label,
String(mon.stats[[1, 2, 4, 5, 3][index]]),
]),
),
);
}
get("stats-page").replaceChildren(...page);
panel.dataset.page = String(statsView.page);
get("stats-page-number").textContent = `${statsView.page + 1} / 3`;
}
function setHp(bar: HTMLElement, hp: number, max: number) {
const fraction = max ? hp / max : 0;
bar.style.width = `${Math.round(fraction * 100)}%`;
bar.classList.toggle("low", fraction <= 0.5 && fraction > 0.2);
bar.classList.toggle("critical", fraction <= 0.2);
}
let shownPicture = "";
/** Draw a pokepic, or the introduction's trainer or player picture, from
* the ROM's picture tiles and palette. */
function showPicture(world: GameWorld) {
const box = get("pokepic");
const portrait = world.portrait;
box.hidden = world.picture === undefined && !portrait;
const key = JSON.stringify([world.picture, portrait]);
if (key === shownPicture) return;
shownPicture = key;
if (box.hidden) return;
const data = world.assets.pokemon;
const picture = get<HTMLCanvasElement>("pokepic-canvas");
const context = picture.getContext("2d")!;
const image = context.createImageData(56, 56);
let tiles: Uint8Array, palette: Palette, label: string;
if (world.picture !== undefined) {
tiles = data.frontPic(world.picture);
palette = data.palette(world.picture);
label = data.speciesName(world.picture);
} else if (portrait && "player" in portrait) {
// DrawIntroPlayerPic: CHRIS's palette, or KRIS's (class 1).
tiles = data.playerPic(portrait.player);
palette = data.trainerPalette(portrait.player ? 1 : 0);
label = portrait.player ? "Kris" : "Chris";
} else {
const trainer = (portrait as { trainer: number }).trainer;
tiles = data.trainerPic(trainer);
palette = data.trainerPalette(trainer);
label = "Professor Oak";
}
// Picture tiles are stored column by column.
for (let tile = 0; tile < 49; tile++)
drawTile(
image.data,
56,
56,
tiles,
tile,
Math.floor(tile / 7) * 8,
(tile % 7) * 8,
palette,
);
context.putImageData(image, 0, 0);
picture.setAttribute("aria-label", label);
}
const runtime = new NativeRuntime(
showWorld,
(error) => {
session.state = "error";
controls.release();
showError(error);
update();
},
handleBattleInput,
);
const session = new SlotSession(runtime, new SlotRepository("crystal-native-port"));
const input = new InputController((button, pressed) =>
runtime.setButton(button, pressed),
);
let preferences = loadPreferences();
let keys = keyMap(preferences);
applyTextScale(preferences.textScale);
runtime.setVolume(preferences.volume);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy && tabs.active() === "game",
() => {},
() => keys,
);
const ready = () => ["paused", "playing"].includes(session.state);
const tabs = bindTabs(
root,
() => {
pause();
controls.release();
update();
},
(id) => {
if (id === "pokedex") showPokedexTab();
if (id === "map") showAtlas();
if (id === "saves") {
showAppSettings();
void showSlots();
}
},
);
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of ["play", "save", "new-game", "backup", "import-save", "screen-resume"]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
get("slot-list")
.querySelectorAll<HTMLButtonElement>("button")
.forEach((button) => {
button.disabled =
busy || !ready() || button.dataset.locked === "true";
});
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("minimap-position").hidden = !ready();
get("world-map").hidden = !ready();
get("map-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
get<HTMLButtonElement>("dialogue-next").disabled =
busy || session.state !== "playing";
showPokedexTab();
showAtlas();
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
if (tabs.active() === "saves") void showSlots();
})
.catch((error) => {
saveStatus.textContent = quotaExceeded(error)
? "This device is out of space for saves. Export a backup, then free some storage."
: "Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
tabs.select("game");
session.play();
canvas.focus({ preventScroll: true });
message(
`Press ${buttonKeys(preferences, "a")} or A to talk and read. Up and Down change choices.`,
);
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease)
lockRelease = await acquireSessionLock("crystal-native-session");
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const { restored, recovered, skipped } = await session.load(rom, canvas);
tabs.select("game");
message(
[
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to explore your room and New Bark Town.",
recovered &&
`The last write of “${session.active?.name}” was damaged; its previous save was loaded.`,
...skipped.map(
(slot) => `“${slot.name}” could not be opened here and was kept unchanged.`,
),
]
.filter(Boolean)
.join(" "),
);
void showSlots();
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("pokepic-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("dialogue-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
if (session.active) void exportSlot(session.active.id);
});
async function exportSlot(id: string) {
try {
const { slot, text } = await session.exportSlot(id);
const name = slot.name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-|-$/g, "");
download(
text,
`crystal-${name ? `${name}-` : ""}${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message(`“${slot.name}” exported. Keep the backup somewhere safe.`);
} catch (error) {
showError(error);
}
}
/** Savegames: the slots made with this ROM, newest first. */
let slotsShown = 0;
async function showSlots() {
const list = get("slot-list");
const request = ++slotsShown;
let entries: SlotEntry[] = [];
try {
entries = ready() ? await session.slots() : [];
} catch (error) {
showError(error);
}
// A later refresh may have finished first.
if (request !== slotsShown) return;
// Autosaves refresh the list; keep the keyboard focus where it was.
const focused = list.contains(document.activeElement)
? (document.activeElement as HTMLElement)
: undefined;
const focusId = focused?.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId;
const focusAction = focused?.dataset.slotAction;
get("slots-empty").hidden = entries.length > 0;
list.hidden = entries.length === 0;
list.replaceChildren(...entries.map(slotItem));
update();
if (focusId && focusAction)
[...list.querySelectorAll<HTMLButtonElement>("button[data-slot-action]")]
.find(
(button) =>
button.dataset.slotAction === focusAction &&
button.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId === focusId,
)
?.focus();
}
function element<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
) {
const node = document.createElement(tag);
if (className) node.className = className;
node.textContent = text;
return node;
}
function summaryLines(summary?: SlotSummary) {
if (!summary) return ["No details available"];
if (summary.newGame) return ["New game", "The introduction is not finished"];
const badges = `${summary.badges} badge${summary.badges === 1 ? "" : "s"}`;
return [
`${summary.player} · ${summary.place}`,
`${summary.hours}:${String(summary.minutes).padStart(2, "0")} played · ${badges} · ${summary.caught} caught`,
];
}
const savedTime = (iso: string) =>
new Date(iso).toLocaleString([], {
dateStyle: "medium",
timeStyle: "short",
});
function slotItem({ slot, compatible, active }: SlotEntry) {
const item = element("li", "slot");
item.dataset.slotId = slot.id;
if (active) item.setAttribute("aria-current", "true");
const heading = element("h3", "slot-title");
heading.append(element("span", "slot-name", slot.name));
if (active) heading.append(element("span", "slot-tag", "Playing"));
if (!compatible) heading.append(element("span", "slot-tag warning", "Newer version"));
item.append(heading);
const lines = compatible
? summaryLines(slot.summary)
: ["Made by a newer version of Crystal. It is kept unchanged."];
for (const line of lines) item.append(element("p", "slot-detail", line));
item.append(element("p", "slot-saved", `Saved ${savedTime(slot.updatedAt)}`));
const actions = element("div", "slot-actions");
const action = (name: string, label: string, locked = false) => {
const button = element("button", "", label);
button.dataset.slotAction = name;
button.setAttribute("aria-label", `${label} ${slot.name}`);
if (locked) button.dataset.locked = "true";
actions.append(button);
};
if (compatible) {
if (!active) action("load", "Continue");
action("rename", "Rename");
action("duplicate", "Duplicate");
}
action("export", "Export");
action("delete", "Delete", active);
if (active)
actions.append(
element("p", "slot-hint", "Continue another save to delete this one."),
);
item.append(actions);
return item;
}
get("slot-list").addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
"button[data-slot-action]",
);
const id = button?.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId;
if (!button || !id || busy || button.disabled) return;
const name =
button.closest("li")?.querySelector(".slot-name")?.textContent ?? "";
void slotAction(button.dataset.slotAction!, id, name);
});
/** Runs one Savegames action with the session paused and saved. */
async function withSlots(work: () => Promise<string | undefined>) {
busy = true;
pause();
update();
try {
await saving;
const text = await work();
if (text) message(text);
} catch (error) {
showError(error);
} finally {
busy = false;
await showSlots();
}
}
async function slotAction(action: string, id: string, name: string) {
switch (action) {
case "load":
return withSlots(async () => {
const { recovered } = await session.switchTo(id);
return recovered
? `“${name}” is ready. Its last write was damaged; the previous save was loaded. Press Play to continue.`
: `“${name}” is ready. Press Play to continue.`;
});
case "rename": {
const newName = await askSlot({
title: `Rename “${name}”`,
text: "Names have 1 to 24 characters.",
confirm: "Rename",
value: name,
});
if (newName === undefined) return;
return withSlots(async () => {
const slot = await session.rename(id, newName);
return `Renamed to “${slot.name}”.`;
});
}
case "duplicate":
return withSlots(async () => {
const slot = await session.duplicate(id);
return `“${name}” copied to “${slot.name}”.`;
});
case "export":
return exportSlot(id);
case "delete": {
const confirmed = await askSlot({
title: `Delete “${name}”?`,
text: "Its progress is removed from this device. Export it first to keep a backup.",
confirm: "Delete",
});
if (confirmed === undefined) return;
return withSlots(async () => {
await session.delete(id);
return `“${name}” deleted.`;
});
}
}
}
get("new-game").addEventListener("click", () => {
void withSlots(async () => {
const slot = await session.newGame();
tabs.select("game");
return `“${slot.name}” created. Press Play to start your new game.`;
});
});
/** The rename and delete dialog. Resolves with the name entered (or "" for
* a confirmation), or undefined when cancelled. */
function askSlot(options: {
title: string;
text: string;
confirm: string;
value?: string;
}): Promise<string | undefined> {
const dialog = get<HTMLDialogElement>("slot-dialog");
const field = get<HTMLInputElement>("slot-name");
const error = get("slot-dialog-error");
get("slot-dialog-title").textContent = options.title;
get("slot-dialog-text").textContent = options.text;
get("slot-dialog-confirm").textContent = options.confirm;
get("slot-name-label").hidden = options.value === undefined;
field.required = options.value !== undefined;
field.value = options.value ?? "";
error.textContent = "";
dialog.showModal();
if (options.value !== undefined) field.select();
return new Promise((resolve) => {
const submit = (event: SubmitEvent) => {
const confirmed =
(event.submitter as HTMLButtonElement | null)?.value === "confirm";
if (confirmed && options.value !== undefined && !field.value.trim()) {
event.preventDefault();
error.textContent = "Enter a name.";
return;
}
dialog.removeEventListener("close", closed);
dialog.querySelector("form")!.removeEventListener("submit", submit);
resolve(confirmed ? field.value : undefined);
};
const closed = () => {
dialog.querySelector("form")!.removeEventListener("submit", submit);
resolve(undefined);
};
dialog.querySelector("form")!.addEventListener("submit", submit);
dialog.addEventListener("close", closed, { once: true });
});
}
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy || !ready()) return;
let preview: ImportPreview;
try {
preview =
file.size > 2_000_000
? { ok: false, reason: "This backup is too large." }
: await session.previewImport(await file.text());
} catch (error) {
showError(error);
return;
}
const target = await previewImport(file.name, preview);
if (!target || !preview.ok) return;
const ok = preview;
await withSlots(async () => {
const slot = await session.importBackup(ok, target);
saveStatus.textContent = `Imported into “${slot.name}” on this device.`;
saveStatus.classList.remove("error");
return "Backup restored. Press Play to continue.";
});
});
/** Shows what a backup holds and what importing it would replace.
* Resolves with "new", a slot ID to replace, or undefined. */
function previewImport(
file: string,
preview: ImportPreview,
): Promise<string | undefined> {
const dialog = get<HTMLDialogElement>("import-dialog");
const details = get("import-details");
const conflicts = get("import-conflicts");
const actions = get("import-actions");
const row = (term: string, value: string) => {
details.append(element("dt", "", term), element("dd", "", value));
};
details.replaceChildren();
conflicts.replaceChildren();
actions.replaceChildren();
const button = (value: string, label: string, primary = false, id = "") => {
const node = element("button", primary ? "menu-button" : "secondary-button", label);
node.value = value;
if (id) node.id = id;
actions.append(node);
};
if (!preview.ok) {
get("import-title").textContent = "This backup cannot be imported";
get("import-text").textContent = `${preview.reason} Your saves are unchanged.`;
row("File", file);
if (typeof preview.identity?.savedAt === "string")
row("Saved", savedTime(preview.identity.savedAt));
button("cancel", "Close", true);
} else {
const { summary } = preview;
get("import-title").textContent = summary.newGame
? "A new game"
: `${summary.player}'s game`;
get("import-text").textContent =
"Check the backup before importing it. Importing continues it in place of the running game, which is saved first.";
row("File", file);
row(
"Trainer",
summary.newGame
? "Not named yet"
: `${summary.player} · ID No. ${String(summary.playerId).padStart(5, "0")}`,
);
row("Place", summary.newGame ? "Introduction" : summary.place);
for (const line of summaryLines(summary).slice(1)) row("Progress", line);
row("Saved", savedTime(preview.record.savedAt));
row(
"Format",
preview.schema < 3
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
: `Native save, game data version ${preview.schema}`,
);
if (preview.conflicts.length) {
conflicts.append(element("h3", "", "Saves of the same trainer"));
const list = element("ul", "");
for (const { slot, identical } of preview.conflicts)
list.append(
element(
"li",
"",
`“${slot.name}”, saved ${savedTime(slot.updatedAt)}${identical ? ": the same progress as this backup" : slot.updatedAt > preview.record.savedAt ? ": saved after this backup" : ": saved before this backup"}`,
),
);
conflicts.append(list);
}
button("new", "Add as a new save", true, "import-new");
const active = session.active;
const replaceable = [
...(active ? [active] : []),
...preview.conflicts
.map((conflict) => conflict.slot)
.filter((slot) => slot.id !== active?.id),
];
for (const slot of replaceable)
button(
slot.id,
`Replace “${slot.name}”`,
false,
slot.id === active?.id ? "import-current" : "",
);
button("cancel", "Cancel");
}
dialog.returnValue = "";
dialog.showModal();
return new Promise((resolve) => {
dialog.addEventListener(
"close",
() => {
const value = dialog.returnValue;
resolve(value && value !== "cancel" ? value : undefined);
},
{ once: true },
);
});
}
/** The installable app: updates wait for the player, and the Savegames
* tab offers installation and persistent storage. */
const app = new InstallableApp({
onUpdateReady: () => {
get("update-banner").hidden = false;
},
onInstallChange: () => showAppSettings(),
});
void app.register();
// An installed app asks once at start; browsers usually grant it then.
if (app.installed) void requestPersistentStorage();
get("update-app").addEventListener("click", async () => {
// Save first: the reload ends the session.
pause();
await save()?.catch(() => {});
if (!app.update()) message("The update is no longer waiting. Reload the page to get it.");
});
function showAppSettings() {
const install = get<HTMLButtonElement>("install-app");
install.hidden = app.installed || !app.canPrompt;
get("install-status").textContent = app.installed
? "Crystal is installed. The app opens offline; select your ROM each time you open it."
: app.canPrompt
? "Install Crystal to open it from your home screen and play the app offline. Select your ROM each time you open it."
: app.appleDevice
? "To install Crystal, open this page in Safari, choose Share, then Add to Home Screen."
: "This browser does not offer to install Crystal. It still saves here and works online.";
void storageStatus().then((status) => {
const button = get<HTMLButtonElement>("persist-storage");
button.hidden = !status || status.persisted;
const used =
status?.usage !== undefined && status.quota
? ` Using ${(status.usage / 1_048_576).toFixed(1)} MB of ${(status.quota / 1_048_576).toFixed(0)} MB.`
: "";
get("storage-status").textContent = !status
? "This browser does not report its storage. Export backups to keep your progress safe."
: status.persisted
? `Saves are kept on this device even when space runs low.${used}`
: `The browser may clear saves when space runs low. Keep them, or export backups.${used}`;
});
}
get("install-app").addEventListener("click", async () => {
await app.install();
showAppSettings();
});
get("persist-storage").addEventListener("click", async () => {
const granted = await requestPersistentStorage();
if (!granted) message("The browser kept its storage rules. Export backups to keep your progress safe.");
showAppSettings();
});
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);
runtime.setVolume(preferences.volume);
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;
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="volume"]'))
radio.checked = Number(radio.value) === preferences.volume;
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()}.`);
});
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="volume"]'))
radio.addEventListener("change", () => {
const volume = Number(radio.value) as Volume;
if (VOLUMES.includes(volume))
setPreferences({ ...preferences, volume }, `Sound: ${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();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
get("mail-close").onclick = () => {
if (session.state !== "playing" || !runtime.world?.mailView) return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};
get("town-map-close").onclick = () => {
if (session.state !== "playing") return;
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};
for (const [id, turn] of [
["stats-prev", 2],
["stats-next", 1],
["stats-close", 0],
] as const)
get(id).onclick = () => {
const world = runtime.world;
if (session.state !== "playing" || !statsView || !world) return;
if (turn) statsView.page = (statsView.page + turn) % 3;
else if (statsView.slot === undefined) runtime.advanceDialogue();
else statsView = partyChoice = undefined;
showWorld(world);
canvas.focus({ preventScroll: true });
};