Integrate battles with the overworld UI and validate browser flows
Checks / web (push) Canceled after 0s

- Drive wild battles through the real UI in Chromium and WebKit using a
  test-only encounter fixture: victory with level-up, escape, whiteout,
  mid-battle backup restore and unsupported-effect rollback.
- Add keyboard and on-screen D-pad navigation for battle menus.
- Accept pending stat experience in saves (awarded without CalcMonStats).
- Commit whiteout healing, money loss and destination callbacks together;
  aborted scripts restore state and map.
- Give long notices their own status-bar row on narrow screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 16:37:07 +02:00
1 parent c08d250cf6
commit 11d1153802
11 files changed
+462 -13

No files matched your search

+8 -3
View File
@@ -181,9 +181,14 @@ export function validPartyMon(
!integer(mon.hp, 0, mon.stats[0])
)
return false;
// Stored stats must be the ones the game would calculate.
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
return stats.every((stat, index) => stat === mon.stats[index]);
// GiveExperiencePoints awards stat experience after every victory, but
// CalcMonStats only runs on a level-up (or a later PC recalculation).
// Preserve legitimate pending gains instead of silently applying them on
// load. Stored stats must stay inside the ROM-derived possible range.
const minimum = calcStats(data, mon.species, mon.dvs, mon.level);
const maximum = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
return mon.stats.every((stat, index) =>
stat >= minimum[index] && stat <= maximum[index]);
} catch {
return false;
}
+8 -1
View File
@@ -21,6 +21,8 @@ export class NativeRuntime implements GameRuntime {
private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
/** Presentation handles menu navigation; battle rules stay in the world. */
private battleInput?: (button: Button) => boolean,
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
@@ -55,9 +57,14 @@ export class NativeRuntime implements GameRuntime {
if (this.pressed.has(button) || !this.world) return;
this.pressed.add(button);
const world = this.world;
if (world.battle && this.battleInput?.(button)) {
this.publish();
return;
}
if (button === "a") world.interact();
if (button === "b") world.advanceDialogue();
if ((button === "up" || button === "down") && world.dialogue?.choice)
if ((button === "up" || button === "down") &&
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
world.moveCursor(button);
if ((button === "start" || button === "select") && !world.busy)
world.notice =
+22 -6
View File
@@ -143,7 +143,13 @@ interface ActiveScript {
effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave;
rollback: { player: Actor; actors: Map<number, Actor>; spawned: Set<number> };
rollback: {
player: Actor;
actors: Map<number, Actor>;
spawned: Set<number>;
state: ScriptState;
mapId: string;
};
waitTicks: number;
moving: Map<number, { steps: MovementStep[]; index: number; wait: number }>;
follow?: { leader: number; follower: number };
@@ -644,9 +650,13 @@ export class GameWorld {
/** Evaluate map callbacks on a copy, so an unsupported callback leaves the
* current map untouched. Methods follow the original map setup scripts. */
private prepareMap(id: string, method: "warp" | "connection" | "continue") {
private prepareMap(
id: string,
method: "warp" | "connection" | "continue",
sourceState = this.state,
) {
const map = this.assets.map(id);
const state = copyScriptState(this.state);
const state = copyScriptState(sourceState);
const actors = new Map<number, Actor>();
if (method !== "continue")
for (let event = 0; event < 8; event++) state.events.delete(event);
@@ -779,6 +789,8 @@ export class GameWorld {
),
committed,
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
@@ -1045,10 +1057,12 @@ export class GameWorld {
private finishScript() {
const script = this.script!;
this.state = script.run.state;
if (script.warp) {
const { map, x, y } = script.warp;
const entry = this.prepareMap(map, "warp");
if (!this.assets.isPorted(map) || !this.walkable(this.assets.map(map), x, y))
throw new Error("Script warp destination is not supported.");
// Whiteout healing/money and destination callbacks commit together.
const entry = this.prepareMap(map, "warp", script.run.state);
this.player = {
...this.player,
x,
@@ -1057,7 +1071,7 @@ export class GameWorld {
motion: undefined,
};
this.applyMap(entry);
}
} else this.state = script.run.state;
this.script = undefined;
this.dialogue = undefined;
this.emote = undefined;
@@ -1071,6 +1085,8 @@ export class GameWorld {
const script = this.script!;
this.battle = undefined;
this.battleRun = undefined;
this.state = script.rollback.state;
this.mapId = script.rollback.mapId;
this.player = script.rollback.player;
this.actors = script.rollback.actors;
this.spawned = script.rollback.spawned;
+47
View File
@@ -7,6 +7,7 @@ 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 type { Button } from "./input/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
@@ -179,6 +180,41 @@ function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, pal
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;
@@ -187,6 +223,8 @@ function showBattle(world: GameWorld) {
if (!view) {
battlePics.foe = battlePics.player = battleMenuKey = "";
showMoves = false;
battleCursor = 0;
battleSelectionKey = "";
return;
}
const data = world.assets.pokemon;
@@ -221,6 +259,12 @@ function showBattle(world: GameWorld) {
}
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;
@@ -293,6 +337,8 @@ function showBattle(world: GameWorld) {
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;
@@ -348,6 +394,7 @@ const runtime = new NativeRuntime(
? "kris"
: "chris",
}),
handleBattleInput,
);
const session = new GameSession(
runtime,
+7 -1
View File
@@ -448,6 +448,10 @@ dialog h2 {
.battle-menu button:disabled {
opacity: 0.5;
}
.battle-menu button.battle-selected {
outline: 2px solid currentColor;
outline-offset: -4px;
}
.battle-menu button small {
display: block;
font-size: 0.75rem;
@@ -806,7 +810,9 @@ dialog dd {
.global-bar {
min-height: 76px;
grid-template-columns: auto 1fr;
grid-template-areas: "play status" "notice save";
/* The notice gets its own row: in the button's auto column, a long
notice would widen it and crush the status text. */
grid-template-areas: "play status" "notice notice" "save save";
gap: 3px 9px;
}
.save-status {