Port the start menu, the party menu and the pack outside battle
Checks / web (push) Canceled after 0s

Start opens the original start menu. POKéMON shows the party with the
Pokémon submenu (stats, switching, move order, held items); PACK shows the
four pockets with the field item effects for healing, PP and the Bicycle,
plus giving and tossing. The menu runs on a script draft, and its cursors
persist until a battle clears them. Select keeps the Key Items shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 16:10:22 +02:00
1 parent 60e06ae678
commit 1af2beacdc
18 files changed
+1918 -49

No files matched your search

+111 -19
View File
@@ -27,6 +27,7 @@ import {
type ScriptState,
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
@@ -213,6 +214,13 @@ export type Choice =
entries: MartEntry[];
selected: number;
}
/** The pack's pockets; `selected` is an entry of pocket `pocket`. */
| {
kind: "pack";
pockets: { name: string; entries: MartEntry[] }[];
pocket: number;
selected: number;
}
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -250,7 +258,7 @@ export interface BattleView {
const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript {
run: ScriptRun | IntroRun;
run: ScriptRun | IntroRun | StartMenuRun;
effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave;
@@ -506,6 +514,8 @@ export class GameWorld {
* ChangeDirectionScript holds input for three ticks. */
private turnTo?: Direction;
private turnWait = 0;
/** Start menu, party menu and pack cursors (work RAM, not saved). */
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0 };
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
private bumping = false;
/** A screen shake object (earthquake, step_shake) in progress. */
@@ -1599,6 +1609,41 @@ export class GameWorld {
this.pump();
}
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(): boolean {
if (this.busy || this.introPending) return false;
this.clearNotice();
this.script = {
run: new StartMenuRun(
this.state,
{
reader: this.assets.reader,
data: this.assets.pokemon,
text: () => this.host(PLAYER).text(),
bicycleScript: (state) => this.bicycleScript(state),
},
this.menuCursors,
),
committed: this.save(),
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.pump();
return true;
}
/** NewGame's PlayerProfileSetup and OakSpeech, before the player
* appears in the bedroom. Saves made meanwhile start it again. */
beginIntro() {
@@ -1823,6 +1868,7 @@ export class GameWorld {
),
tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark,
partyMenuCursor: this.menuCursors.party,
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -1909,6 +1955,18 @@ export class GameWorld {
},
};
return true;
case "pack":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "pack",
pockets: effect.pockets,
pocket: effect.pocket,
selected: effect.selected,
},
};
return true;
case "martquantity":
this.dialogue = {
pages: [""],
@@ -2265,8 +2323,14 @@ export class GameWorld {
}
if (!script.scene || script.run.deferred !== undefined)
this.sceneCheck = true;
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
if (script.run.deferred !== undefined)
this.startScript(script.run.deferred, PLAYER);
this.startScript(script.run.deferred, PLAYER, {
usedItem:
script.run instanceof StartMenuRun
? script.run.deferredItem
: undefined,
});
}
private abortScript(error: unknown) {
const script = this.script!;
@@ -2316,6 +2380,8 @@ export class GameWorld {
);
this.battle = undefined;
this.battleRun = undefined;
// CleanUpBattleRAM
this.menuCursors = { start: 0, party: 0, pocket: 0 };
this.pump(next.value.result);
return;
}
@@ -2837,7 +2903,8 @@ export class GameWorld {
const choice = this.dialogue.choice;
if (choice && this.dialogue.index === this.dialogue.pages.length - 1) {
// The naming screen is answered with text through choose().
if (choice.kind !== "name") this.choose(choice.selected);
if (choice.kind === "pack") this.choosePackEntry();
else if (choice.kind !== "name") this.choose(choice.selected);
} else this.advanceDialogue();
return;
}
@@ -3065,8 +3132,15 @@ export class GameWorld {
!this.state.bag.keyItems.some((slot) => slot === OW.BICYCLE)
)
return false;
const map = this.map,
state = this.state;
const script = this.bicycleScript(this.state);
if (script === undefined) return false;
this.startScript(script, PLAYER, { usedItem: OW.BICYCLE });
return true;
}
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
* .CheckEnvironment or the player's state refuses the Bicycle. */
private bicycleScript(state: ScriptState): number | undefined {
const map = this.map;
const outdoors =
map.environment === OW.TOWN || map.environment === OW.ROUTE;
const allowed =
@@ -3075,20 +3149,14 @@ export class GameWorld {
map.environment === OW.GATE) &&
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
OW.LAND_TILE;
if (!allowed) return false;
const usedItem = OW.BICYCLE;
if (!allowed) return undefined;
if (state.playerState === OW.PLAYER_NORMAL)
this.startScript(OVERWORLD.Script_GetOnBike, PLAYER, { usedItem });
else if (state.playerState === OW.PLAYER_BIKE)
this.startScript(
state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: OVERWORLD.Script_GetOffBike,
PLAYER,
{ usedItem },
);
else return false;
return true;
return OVERWORLD.Script_GetOnBike;
if (state.playerState === OW.PLAYER_BIKE)
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: OVERWORLD.Script_GetOffBike;
return undefined;
}
/** GetPlayerSprite: Chris's or Kris's sprite for wPlayerState. */
@@ -3147,7 +3215,8 @@ export class GameWorld {
else if (
choice.kind === "martlist" ||
choice.kind === "martquantity" ||
choice.kind === "menu"
choice.kind === "menu" ||
choice.kind === "pack"
)
this.choose(-1);
return;
@@ -3191,6 +3260,20 @@ export class GameWorld {
if (direction === "up") choice.selected = (choice.selected + 1) % length;
if (direction === "down")
choice.selected = (choice.selected + length - 1) % length;
} else if (choice.kind === "pack") {
// Pack_InterpretJoypad: left and right change pockets.
const pockets = choice.pockets.length;
if (direction === "left" || direction === "right") {
choice.pocket =
(choice.pocket + (direction === "left" ? pockets - 1 : 1)) % pockets;
choice.selected = 0;
return;
}
const length = choice.pockets[choice.pocket].entries.length;
if (!length) return;
if (direction === "up") choice.selected = Math.max(0, choice.selected - 1);
if (direction === "down")
choice.selected = Math.min(length - 1, choice.selected + 1);
} else if (choice.kind === "martlist") {
const length = choice.entries.length;
if (!length) return;
@@ -3214,6 +3297,15 @@ export class GameWorld {
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
}
/** Choose the highlighted pack entry, if its pocket has one. */
choosePackEntry(pocket?: number, index?: number) {
const choice = this.dialogue?.choice;
if (choice?.kind !== "pack") return;
pocket ??= choice.pocket;
index ??= choice.selected;
if (!choice.pockets[pocket]?.entries[index]) return;
this.choose(pocket * 256 + index);
}
/** Answer the current choice: 1/0 for yes/no, a weekday index, or a
* name ("" keeps the current one). */
choose(answer: number | string) {