- Run every non-ball item effect usable in battle from the ROM's ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll. - Read happiness changes from HappinessChanges, including the larger level-up gain where a Pokémon was caught. - Model hBattleTurn at the battle menu, including the trainer AI's writes, and drop the status-heal refusal. - Port the stats screen for battles and Bill's PC. - Compare the send-out and withdraw texts, RestoreHealth, RestorePP, ChangeHappiness and CalcPlayerStats with the original routines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1257 lines
40 KiB
TypeScript
1257 lines
40 KiB
TypeScript
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" }
|
||
>;
|
||
function martChoice(choice: Choice): MartChoice | undefined {
|
||
return choice.kind === "martmenu" ||
|
||
choice.kind === "martlist" ||
|
||
choice.kind === "martquantity"
|
||
? 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 reuses these lists without prices.
|
||
const shop =
|
||
mart.kind === "martmenu" || mart.mode === "buy" || mart.mode === "sell";
|
||
get("mart").setAttribute("aria-label", shop ? "Poké Mart" : "Item storage");
|
||
get("mart-money").hidden = !shop;
|
||
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
|
||
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : 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 === "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;
|
||
if ((button === "start" || 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 });
|
||
};
|