Integrate battles with the overworld UI and validate browser flows
Checks / web (push) Canceled after 0s
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:
1 parent
c08d250cf6
commit
11d1153802
11 files changed
+462
-13
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user