Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
+313
-13
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user