Port the PC and boxes
Checks / web (push) Canceled after 0s

Read TileCollisionStdScripts from the ROM so the Pokémon Center PC runs
PokemonCenterPC: Bill's PC (deposit, withdraw, release, change and name
boxes), the player's item storage (withdraw, deposit, toss, mail box) and
Oak's Pokédex rating. The house PC now opens the full player's PC menu.

A catch with a full party goes to the current box through SendMonIntoBox;
a full box keeps the ball, and filling it queues Bill's call as a memory
script after the battle. Saves gain boxes, box names and PC items,
migrated by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 17:12:26 +02:00
1 parent 451464f7c6
commit 3bdca0099e
21 files changed
+1999 -131

No files matched your search

+67 -15
View File
@@ -10,6 +10,7 @@ import {
INITIAL_POSITION,
NUM_FRUIT_TREES,
OVERWORLD,
PC,
SYMBOLS,
} from "../rom/profile";
import { OW } from "./constants";
@@ -19,12 +20,20 @@ import {
copyScriptState,
emptyScriptState,
type MartEntry,
type MartMode,
type ScriptEffect,
type ScriptHost,
type ScriptState,
} from "../scripts/interpreter";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag } from "../party/bag";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
copyStorage,
emptyStorage,
validPCItems,
validStorage,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
caughtData,
@@ -75,6 +84,8 @@ export interface NativeSave {
playerId: number;
party: PartyMon[];
bag: Bag;
storage: PokemonStorage;
pcItems: ItemSlot[];
pokedex: { seen: number[]; caught: number[] };
money: number;
fruitTrees: number[];
@@ -186,20 +197,20 @@ export type Choice =
/** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined }
/** A vertical menu; `selected` answers with that item's index. */
| { kind: "menu"; items: string[]; selected: number }
| { kind: "menu"; items: string[]; details?: string[]; selected: number }
/** The mart's BUY/SELL/QUIT box. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list; `selected` answers with that entry's index. */
| {
kind: "martlist";
mode: "buy" | "sell";
mode: MartMode;
entries: MartEntry[];
selected: number;
}
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
mode: "buy" | "sell";
mode: MartMode;
item: number;
name: string;
price: number;
@@ -263,10 +274,18 @@ interface ActiveScript {
warp?: { map: string; x: number; y: number; facing?: Direction };
/** warpcheck: the player's tile events run on the tick after the script. */
warpCheck?: boolean;
/** LoadMemScript: queued for the next PlayerEvents once this commits. */
memScript?: MemScript;
/** The trainer whose sight started this script, if any. */
sighted?: number;
}
/** wMapReentryScriptQueueFlag's script: work RAM, so it is not saved. */
interface MemScript {
script: number;
callerScript?: number;
}
const OPPOSITE: Record<Direction, Direction> = {
down: "up",
up: "down",
@@ -455,6 +474,7 @@ export class GameWorld {
notice = "";
diagnostic = "";
private actors = new Map<number, Actor>();
private memScript?: MemScript;
private currentMap?: GameMap;
/** wCmdQueue's CMDQUEUE_STONETABLE entries for the current map. */
private stoneTables: number[] = [];
@@ -1731,6 +1751,7 @@ export class GameWorld {
canLose: effect.canLose,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
storage: draft.storage,
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
caughtData: (level) =>
caughtData(
@@ -1757,6 +1778,16 @@ export class GameWorld {
case "warpcheck":
script.warpCheck = true;
return false;
case "notice":
this.notice = effect.text;
return false;
case "memscript":
// LoadMemScript keeps a script that is already queued.
script.memScript ??= {
script: effect.script,
callerScript: effect.callerScript,
};
return false;
case "townmap":
this.townMap = true;
this.dialogue = { pages: ["Town Map"], index: 0 };
@@ -1768,6 +1799,7 @@ export class GameWorld {
choice: {
kind: "menu",
items: effect.items,
...(effect.details && { details: effect.details }),
selected: effect.selected,
},
};
@@ -2153,6 +2185,7 @@ export class GameWorld {
this.arrival = { x: this.x, y: this.y };
}
this.script = undefined;
this.memScript ??= script.memScript;
this.townMap = false;
this.dialogue = undefined;
this.emote = undefined;
@@ -2686,6 +2719,15 @@ export class GameWorld {
}
if (this.busy || this.mapId !== map) return;
} else if (this.checkTrainers()) return;
// RunMemScript precedes RunSceneScript.
const memScript = this.memScript;
if (memScript) {
this.memScript = undefined;
this.startScript(memScript.script, PLAYER, {
callerScript: memScript.callerScript,
});
if (this.busy) return;
}
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
@@ -2791,17 +2833,16 @@ export class GameWorld {
return;
}
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.player.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
// CheckFacingTileForStdScript: TileCollisionStdScripts pairs a
// collision type with a std script index, in any facing direction.
const r = this.assets.reader;
for (let entry = PC.TileCollisionStdScripts; ; entry += 3) {
const type = r.byte(entry);
if (type === 0xff) break;
if (type !== collision) continue;
script = r.far(SYMBOLS.standardScripts + r.word(entry + 1) * 3);
break;
}
}
if (script === undefined) {
const [fx, fy] = VECTORS[this.player.direction];
@@ -3136,6 +3177,8 @@ export class GameWorld {
playerId: this.playerId,
party: structuredClone(state.party),
bag: structuredClone(state.bag),
storage: copyStorage(state.storage),
pcItems: structuredClone(state.pcItems),
pokedex: {
seen: [...state.pokedex.seen].sort((a, b) => a - b),
caught: [...state.pokedex.caught].sort((a, b) => a - b),
@@ -3215,6 +3258,8 @@ export class GameWorld {
save.party.length > 6 ||
!save.party.every((mon) => validPartyMon(this.assets.pokemon, mon)) ||
!validBag(this.assets.pokemon, save.bag) ||
!validStorage(save.storage) ||
!validPCItems(this.assets.pokemon, save.pcItems) ||
!save.pokedex ||
!species(save.pokedex.seen) ||
!species(save.pokedex.caught) ||
@@ -3288,6 +3333,8 @@ export class GameWorld {
clock: { ...save.clock },
party: structuredClone(save.party),
bag: structuredClone(save.bag),
storage: copyStorage(save.storage),
pcItems: structuredClone(save.pcItems),
pokedex: {
seen: new Set(save.pokedex.seen),
caught: new Set(save.pokedex.caught),
@@ -3351,6 +3398,7 @@ export class GameWorld {
this.picture = undefined;
this.battle = undefined;
this.battleRun = undefined;
this.memScript = undefined;
this.clearNotice();
}
}
@@ -3376,6 +3424,8 @@ export function migrateSave(
decorations: initialDecorations(),
}),
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
@@ -3407,6 +3457,8 @@ export function migrateSave(
playerId: newPlayerId(),
party: [],
bag: emptyBag(),
storage: emptyStorage(),
pcItems: [],
pokedex: { seen: [], caught: [] },
lastSpawnMap: null,
specialPhoneCall: 0,