Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

+313 -13
View File
@@ -1,7 +1,13 @@
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime";
import type { GameWorld } from "./game/world/world";
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 { Palette } from "./game/world/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
@@ -14,33 +20,315 @@ 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;
get("native-dialogue").hidden = !world.dialogue;
get("dialogue-text").textContent =
world.dialogue?.pages[world.dialogue.index] ?? "";
get("dialogue-page").textContent = world.dialogue
? `${world.dialogue.index + 1} / ${world.dialogue.pages.length}`
: "";
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] === "",
);
get("native-dialogue").hidden = !dialogue || pictureWait;
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 ? dialogue.choice : undefined;
// Timed pages advance themselves; choices replace Continue.
get("dialogue-next").hidden = Boolean(
choice || (last && dialogue.holdTicks !== undefined),
);
showChoice(choice);
// 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 {
// 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 }));
}
}
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;
/** 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;
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 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);
}
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) => {
@@ -71,10 +359,15 @@ const input = new InputController((button, pressed) =>
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => 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 =
@@ -106,6 +399,9 @@ function update() {
: "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);
@@ -149,11 +445,10 @@ function pause() {
}
function play() {
if (busy) return;
tabs.select("game");
session.play();
canvas.focus({ preventScroll: true });
message(
"Native exploration is running. Story progression and battles are not ported yet.",
);
message("Press X or A to talk and read. Up and Down change choices.");
update();
}
@@ -177,6 +472,7 @@ get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
await save();
}
const restored = await session.load(rom, canvas);
tabs.select("game");
message(
restored
? "Your saved session is ready. Press Play to continue."
@@ -194,10 +490,14 @@ 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.world?.advanceDialogue();
if (runtime.world) showWorld(runtime.world);
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
});
get("save").addEventListener("click", () => {