Files
Crystal/src/main.ts
T
Sébastien QUEROLandClaude Opus 5.5 1af2beacdc
Checks / web (push) Canceled after 0s
Port the start menu, the party menu and the pack outside battle
Start opens the original start menu. POKéMON shows the party with the
Pokémon submenu (stats, switching, move order, held items); PACK shows the
four pockets with the field item effects for healing, PP and the Bicycle,
plus giving and tossing. The menu runs on a script draft, and its cursors
persist until a battle clears them. Select keeps the Key Items shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:10:22 +02:00

1303 lines
42 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 { drawTownMap } from "./game/rendering/town-map";
import { OW } from "./game/world/constants";
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
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 { drawTile } from "./game/rendering/pixels";
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 { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
import { validateRom } from "./rom/validate";
import { BrowserSaveRepository, encodeSave } from "./storage/saves";
import { acquireSessionLock } from "./platform/session-lock";
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);
showFieldMenu(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
const dialogue = world.dialogue;
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 ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
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;
const choice = last && !mart ? 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}. Arrow keys move. X interacts. Z continues dialogue.`,
);
}
function showChoice(choice: Choice | undefined) {
const container = get("dialogue-choice");
container.hidden = !choice;
const key = choice ? JSON.stringify(choice) : "";
if (container.dataset.choice === key) return;
container.dataset.choice = key;
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 === "yesno")
container.replaceChildren(
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 === "name")
container.replaceChildren(
...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;
}),
);
container.replaceChildren(
list,
button("CANCEL", undefined, () => runtime.choose(-1)),
);
}
}
/** 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];
}
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" : "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;
const key = JSON.stringify(mart);
if (key === martMenuKey) return;
martMenuKey = key;
const menu = get("mart-menu");
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.
const tabs = 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));
return tab;
});
const entries = mart.pockets[mart.pocket].entries;
const buttons = entries.map((entry, index) =>
button(
entry.name,
() => runtime.choosePackEntry(mart.pocket, index),
{
detail: entry.owned === undefined ? undefined : `×${entry.owned}`,
selected: mart.selected === index,
},
),
);
if (!entries.length) {
const empty = button("Empty", () => {}, { wide: true });
empty.disabled = true;
buttons.push(empty);
}
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
menu.replaceChildren(...tabs, ...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.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 = "";
let fieldCursor = 0;
function fieldButtons() {
return [
...get("field-items").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
function showFieldMenu(world: GameWorld) {
const panel = get("field-menu");
panel.hidden = !runtime.fieldMenuOpen;
if (panel.hidden) return;
const buttons = world.state.bag.keyItems.map((item) => {
const button = document.createElement("button");
button.textContent = world.assets.pokemon.itemName(item);
button.disabled = item !== OW.BICYCLE;
button.onclick = () => {
if (session.state !== "playing") return;
runtime.useBicycle();
canvas.focus({ preventScroll: true });
};
return button;
});
const close = document.createElement("button");
close.textContent = "CLOSE";
close.onclick = () => {
runtime.fieldMenuOpen = false;
showFieldMenu(world);
canvas.focus();
};
get("field-items").replaceChildren(...buttons, close);
fieldButtons().forEach((button, i) =>
button.classList.toggle("battle-selected", i === fieldCursor),
);
}
function handleFieldInput(button: Button): boolean {
const world = runtime.world;
if (!world || world.battle) return false;
// Start opens the start menu; Select, standing in for a registered
// item, opens the Key Items shortcut.
if (button === "start" && !runtime.fieldMenuOpen && !world.busy) {
runtime.openStartMenu();
return true;
}
if (button === "select" && !world.busy) {
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
fieldCursor = 0;
return true;
}
if (!runtime.fieldMenuOpen) return false;
if (button === "b") runtime.fieldMenuOpen = false;
else if (button === "a") fieldButtons()[fieldCursor]?.click();
else if (["up", "down", "left", "right"].includes(button)) {
const length = fieldButtons().length;
fieldCursor =
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
length;
}
return true;
}
function battleButtons() {
return [
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
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 (["up", "down", "left", "right"].includes(button)) {
const delta = button === "up" || button === "left" ? -1 : 1;
battleCursor = (battleCursor + delta + buttons.length) % buttons.length;
if (world.battle.prompt?.kind === "yesno")
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),
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),
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.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 (prompt?.kind === "yesno") 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", () => {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
button("PACK", () => {
showPack = true;
showWorld(world);
}),
button("RUN", () => runtime.battleAction({ action: "run" })),
);
}
} 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);
},
{
detail: `: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 }),
);
}
menu.replaceChildren(...buttons);
battleCursor = Math.min(
battleCursor,
Math.max(0, battleButtons().length - 1),
);
highlightBattleChoice();
}
/** 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);
drawPic(
get("stats-pic"),
data.frontPic(mon.species),
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 GameSession(
runtime,
new BrowserSaveRepository("crystal-native-port"),
);
const input = new InputController((button, pressed) =>
runtime.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy && tabs.active() === "game",
() => {},
);
const ready = () => ["paused", "playing"].includes(session.state);
const tabs = bindTabs(root, () => {
pause();
controls.release();
update();
});
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", "backup", "import-save", "screen-resume"]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
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";
}
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");
})
.catch((error) => {
saveStatus.textContent =
"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 X 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 = 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.",
);
} 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", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
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) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
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("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 });
};