Checks / web (push) Canceled after 0s
- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings. - Run item balls through FindItemInBallScript and fruit trees through FruitTreeScript, with a native daily reset of the daily engine flags. - Port the Pokémon Center nurse (HealParty with the PP Up clamp, CheckPokerus, phone-call checks) and record the whiteout spawn on entering a Pokémon Center, as EnterMapWarp does. - Port standard Poké Marts with keyboard, D-pad and pointer menus. - Add the recoil battle command (Struggle) and VAR_BOXSPACE. - Save fruit trees, the daily reset day and the last spawn map; older version-3 backups migrate by absence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
734 lines
25 KiB
TypeScript
734 lines
25 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") {
|
||
// The naming screen: an empty name keeps the current one.
|
||
const input = document.createElement("input");
|
||
input.maxLength = choice.max;
|
||
input.placeholder = choice.current;
|
||
input.setAttribute("aria-label", `Nickname for ${choice.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;
|
||
}
|
||
runtime.choose(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();
|
||
});
|
||
container.replaceChildren(input, ok);
|
||
queueMicrotask(() => input.focus({ preventScroll: true }));
|
||
}
|
||
}
|
||
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;
|
||
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) {
|
||
showMoves = 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 = 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);
|
||
}
|
||
get("battle-text").textContent = view.message?.text ?? "";
|
||
const prompt = view.prompt;
|
||
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}`;
|
||
if (selectionKey !== battleSelectionKey) {
|
||
battleCursor = 0;
|
||
battleSelectionKey = selectionKey;
|
||
}
|
||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||
const key = JSON.stringify([prompt, view.selected, showMoves, own?.pp, 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 {
|
||
buttons.push(
|
||
button("FIGHT", () => {
|
||
showMoves = true;
|
||
showWorld(world);
|
||
}),
|
||
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
|
||
button("PACK", () => {}, { disabled: "Using items in battle is not ported yet." }),
|
||
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 === "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();
|