- 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>
2510 lines
85 KiB
TypeScript
2510 lines
85 KiB
TypeScript
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 });
|
||
};
|