Battle PACK opens the Balls pocket and runs PokeBallEffect: the Poké, Great, Ultra and Master Ball multipliers, the original HP arithmetic (including the overflow above 341 maximum HP), the sleep/freeze bonus (BRN/PSN/PAR add nothing), HELD_CATCH_CHANCE and the catch draw. GetPokeBallWobble draws per shake from WobbleProbabilities and picks the break-free text. Trainer battles block the ball but still use it and the turn. A catch reloads the Pokémon as LoadEnemyMon does (the wild item roll is redrawn, then the item restored), keeps its HP, status, item and DVs, restores the moves and PP it appeared with, adds it to the party with SetCaughtData, registers it and offers a nickname with InitName's default. A catch with six Pokémon needs the PC and is refused. catchtutorial runs BATTLETYPE_TUTORIAL as "DUDE": no send-out, an automatic catch that keeps nothing and prompts that continue on their own. Both Route 29 triggers, declining and the Dude's repeat offer run from the ROM. The text reader now handles TX_LOW. PLAN.md gains a status overview and done/remaining checklists; the README, help dialog, routine map and handoff describe the milestone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
771 lines
26 KiB
TypeScript
771 lines
26 KiB
TypeScript
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 } 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<HTMLInputElement>("player-name").value = world.name;
|
||
get<HTMLSelectElement>("player-avatar").value = world.avatar;
|
||
get("location").textContent = world.map.name;
|
||
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
||
get("game-notice").textContent = world.notice;
|
||
showBattle(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);
|
||
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.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 === "name")
|
||
container.replaceChildren(
|
||
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
|
||
);
|
||
}
|
||
/** 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;
|
||
}
|
||
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}` : `¥${entry.price}`,
|
||
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 = `${mart.name} ×${mart.selected} ¥${total}`;
|
||
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 === "sell" ? "SELL" : "BUY", () => 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's Balls pocket, the only pocket usable in battle so far. */
|
||
let showPack = false;
|
||
let battleCursor = 0;
|
||
let battleSelectionKey = "";
|
||
|
||
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 {
|
||
const world = runtime.world;
|
||
if (!world?.battle) return false;
|
||
if (button === "b" && (showMoves || showPack)) {
|
||
showMoves = showPack = false;
|
||
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 : (view.message?.text ?? "");
|
||
if (prompt?.kind !== "action") showMoves = showPack = false;
|
||
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}`;
|
||
if (selectionKey !== battleSelectionKey) {
|
||
battleCursor = 0;
|
||
battleSelectionKey = selectionKey;
|
||
}
|
||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||
const balls = world.liveState.bag.balls;
|
||
const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, 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) {
|
||
balls.forEach(({ item, quantity }, slot) =>
|
||
buttons.push(
|
||
button(data.itemName(item), () => {
|
||
showPack = false;
|
||
runtime.battleAction({ action: "item", item, slot });
|
||
}, { detail: `×${quantity}` }),
|
||
),
|
||
);
|
||
if (!balls.length)
|
||
buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." }));
|
||
buttons.push(button("BACK", () => {
|
||
showPack = false;
|
||
showWorld(world);
|
||
}, { wide: true }));
|
||
} else {
|
||
buttons.push(
|
||
button("FIGHT", () => {
|
||
showMoves = true;
|
||
showWorld(world);
|
||
}),
|
||
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
|
||
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 === "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();
|
||
}
|
||
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: number | undefined;
|
||
/** Draw a pokepic from the ROM's front picture tiles and palette. */
|
||
function showPicture(world: GameWorld) {
|
||
const box = get("pokepic");
|
||
box.hidden = world.picture === undefined;
|
||
if (world.picture === shownPicture) return;
|
||
shownPicture = world.picture;
|
||
if (world.picture === undefined) return;
|
||
const data = world.assets.pokemon;
|
||
const picture = get<HTMLCanvasElement>("pokepic-canvas");
|
||
const context = picture.getContext("2d")!;
|
||
const image = context.createImageData(56, 56);
|
||
const tiles = data.frontPic(world.picture),
|
||
palette = data.palette(world.picture);
|
||
// 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", data.speciesName(world.picture));
|
||
}
|
||
const runtime = new NativeRuntime(
|
||
showWorld,
|
||
(error) => {
|
||
session.state = "error";
|
||
controls.release();
|
||
showError(error);
|
||
update();
|
||
},
|
||
() => ({
|
||
name:
|
||
get<HTMLInputElement>("player-name")
|
||
.value.trim()
|
||
.replace(/[^A-Za-z0-9 -]/g, "")
|
||
.slice(0, 7) || "CHRIS",
|
||
avatar:
|
||
get<HTMLSelectElement>("player-avatar").value === "kris"
|
||
? "kris"
|
||
: "chris",
|
||
}),
|
||
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<HTMLInputElement>("player-name").disabled = busy || ready();
|
||
get<HTMLSelectElement>("player-avatar").disabled = busy || ready();
|
||
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();
|