Port Repels and the other non-fishing field items

Repel, Super Repel and Max Repel count down 100, 200 or 250 steps where
CountStep does, keep weaker wild Pokémon away from the first party member
that has not fainted, and print "REPEL's effect wore off." on the last
step. The Coin Case and the Blue Card show their balances, Sacred Ash
revives the party once one Pokémon has fainted, and the Normal and
Gorgeous Boxes send their trophy home.

The Itemfinder looks for unfound hidden items on the original's screen
around the player. The Card Key, the Basement Key and the Squirtbottle
run the original's checks for their doors and Sudowoodo, whose maps are
not open yet. Saves gain the Repel's steps, the coins and the Blue Card's
points.

Part of #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 00:32:33 +02:00
1 parent 10e1560212
commit 4cb4676c7d
13 files changed
+821 -21

No files matched your search

+4
View File
@@ -175,6 +175,7 @@ export const C = {
BASE_HAPPINESS: 70,
START_MONEY: 3000,
MAX_MONEY: 999999,
MAX_COINS: 9999,
// Status bits and masks
SLP_MASK: 7,
@@ -240,6 +241,9 @@ export const C = {
ITEMMENU_CURRENT: 4,
ITEMMENU_PARTY: 5,
ITEMMENU_CLOSE: 6,
// Trophy dolls from the Normal and Gorgeous Box (constants/deco_constants.asm)
DECOFLAG_GOLD_TROPHY_DOLL: 43,
DECOFLAG_SILVER_TROPHY_DOLL: 44,
// Menus outside battle (constants/menu_constants.asm)
MONMENU_FIELD_MOVE: 0,
MONMENUITEM_STATS: 15,
+29
View File
@@ -160,6 +160,8 @@ export const RAM = {
curHPAnimDeltaHP: 0xd1f3,
visitedSpawns: 0xdca5,
tempWildMonSpecies: 0xd22e,
coins: 0xd855,
blueCardBalance: 0xdc4b,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -195,6 +197,7 @@ export const OVERWORLD = {
"DecorationAttributes": 0x26a4f,
"DecorationNames": 0x26b8d,
"DecorationIDs": 0x26f2b,
"_PlayerDecorationMenu.category_pointers": 0x267aa,
"PlayersHousePC": 0xc2e7,
"PutAwayTheDecoText": 0x26ed1,
@@ -262,6 +265,15 @@ export const OVERWORLD = {
HideSprites: 0x3016,
SkipUpdateMapSprites: 0x154f1,
WaitSFX: 0x3c55,
FadeInFromWhite: 0x8c079,
RepelWoreOffScript: 0x13619,
"ItemFinder.Script_FoundSomething": 0x125ad,
"ItemFinder.Script_FoundNothing": 0x125ba,
"ItemFinder.ItemfinderSound": 0x12599,
"_CardKey.CardKeyScript": 0x507af,
"_BasementKey.BasementKeyScript": 0x507e1,
"_Squirtbottle.SquirtbottleScript": 0x5073c,
"_Squirtbottle.CheckCanUseSquirtbottle": 0x50753,
Flypoints: 0x91c5e,
"TownMapBubble.Where": 0x91bf4,
RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0,
@@ -502,6 +514,23 @@ export const MENU = {
BicycleEffect: 0xee08,
EvoStoneEffect: 0xee0f,
EscapeRopeEffect: 0xf44f,
RepelEffect: 0xf46a,
SuperRepelEffect: 0xf462,
MaxRepelEffect: 0xf466,
RepelUsedEarlierIsStillInEffectText: 0xf47d,
CoinCaseEffect: 0xf59a,
"CoinCaseEffect.CoinCaseCountText": 0xf5a0,
BlueCardEffect: 0xf58f,
"BlueCardEffect.BlueCardBalanceText": 0xf595,
SacredAshEffect: 0xf753,
SacredAshScript: 0x50821,
NormalBoxEffect: 0xf763,
GorgeousBoxEffect: 0xf767,
"OpenBox.SentTrophyHomeText": 0xf778,
ItemfinderEffect: 0xf5b8,
CardKeyEffect: 0xf745,
BasementKeyEffect: 0xf74c,
SquirtbottleEffect: 0xf73e,
VitaminEffect: 0xee3d,
RareCandyEffect: 0xef14,
ItemStatRoseText: 0xeea6,
+33
View File
@@ -129,6 +129,12 @@ export interface ScriptState {
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number };
/** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */
repel: number;
/** wCoins: the Coin Case's coins. */
coins: number;
/** wBlueCardBalance: the Blue Card's points. */
blueCardBalance: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions;
}
@@ -160,6 +166,9 @@ export const emptyScriptState = (): ScriptState => ({
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
repel: 0,
coins: 0,
blueCardBalance: 0,
options: defaultOptions(),
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
@@ -193,6 +202,9 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
options: { ...state.options },
});
@@ -222,6 +234,8 @@ const FACING_VECTORS = [
[-1, 0],
[1, 0],
] as const;
/** MAP_ROUTE_36 (constants/map_constants.asm). */
const ROUTE_36 = "10:3";
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
@@ -401,6 +415,9 @@ export interface ScriptHost {
environment(): number;
/** GetMapPhoneService: nonzero where the phone has no signal. */
phoneService(): number;
/** GetFacingObject: the visible object on the facing tile, with its
* MAPOBJECT_MOVEMENT. */
facingObject(): { id: number; movement: number } | undefined;
}
export class UnsupportedScript extends Error {
@@ -921,6 +938,20 @@ export class ScriptRun {
const mon = this.partyMove(OW.ROCK_SMASH);
if (mon >= 0) this.curPartyMon = mon;
value = mon < 0 ? 1 : 0;
} else if (
routine === OVERWORLD["_Squirtbottle.CheckCanUseSquirtbottle"]
) {
// Route 36's Sudowoodo; GetFacingObject leaves the facing object
// in hLastTalked for WateredWeirdTreeScript.
let sudowoodo = false;
if (this.host.mapId === ROUTE_36) {
const facing = this.host.facingObject();
if (facing) this.lastTalked = facing.id;
sudowoodo = facing?.movement === OW.SPRITEMOVEDATA_SUDOWOODO;
}
value = Number(sudowoodo);
} else if (routine === OVERWORLD["ItemFinder.ItemfinderSound"]) {
// Four rounds of the Itemfinder's two sound effects.
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
} else if (
@@ -2256,6 +2287,7 @@ export class ScriptRun {
case OVERWORLD.PlayMapMusic:
case OVERWORLD.UpdateTimePals:
case OVERWORLD.WaitSFX:
case OVERWORLD.FadeInFromWhite:
// The native renderer draws the player from wPlayerState each frame;
// music and palette fades are presentation.
return value;
@@ -2448,6 +2480,7 @@ export function runScript(
playerCollision: () => 0,
environment: () => 0,
phoneService: () => 0,
facingObject: () => undefined,
});
const pages: string[] = [];
for (let effect = run.resume(); effect; effect = run.resume()) {
+133
View File
@@ -24,6 +24,7 @@ import { TrainerCard } from "./trainer-card";
import { OptionsMenu, menuAccount } from "./options";
import { flyMap } from "./fly-map";
import { OW } from "../world/constants";
import { DecorationData } from "../world/decorations";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
@@ -77,6 +78,18 @@ const {
PP_UP_ONE,
} = C;
const NOT_PORTED = "is not ported yet.";
/** MAP_RADIO_TOWER_3F and MAP_GOLDENROD_UNDERGROUND
* (constants/map_constants.asm). */
const RADIO_TOWER_3F = "3:19",
GOLDENROD_UNDERGROUND = "3:53";
/** wPlayerDirection's UP, as the host's facing() numbers it. */
const FACING_UP = 1;
const FACING_VECTORS = [
[0, 1],
[0, -1],
[-1, 0],
[1, 0],
] as const;
/** Menu cursors in work RAM, which CleanUpBattleRAM clears after every
* battle: wBattleMenuCursorPosition (the start menu's 1-based position,
@@ -129,6 +142,8 @@ export interface StartMenuHost {
landmarkOf(mapId: string): number;
/** wGameTime, for the trainer card. */
gameTime(): GameTime;
/** CheckForHiddenItems: an unfound hidden item on the screen. */
hiddenItemOnScreen(state: ScriptState): boolean;
}
/** The overworld start menu (engine/menus/start_menu.asm) with its
@@ -946,11 +961,129 @@ export class StartMenuRun {
this.deferredItem = item;
return true;
}
case MENU.RepelEffect:
case MENU.SuperRepelEffect:
case MENU.MaxRepelEffect:
return yield* this.useRepel(
use,
effect === MENU.RepelEffect
? 100
: effect === MENU.SuperRepelEffect
? 200
: 250,
);
case MENU.CoinCaseEffect:
this.numbers[RAM.coins] = this.state.coins;
yield* this.print(MENU["CoinCaseEffect.CoinCaseCountText"]);
return true;
case MENU.BlueCardEffect:
this.numbers[RAM.blueCardBalance] = this.state.blueCardBalance;
yield* this.print(MENU["BlueCardEffect.BlueCardBalanceText"]);
return true;
case MENU.SacredAshEffect:
// _SacredAsh: SacredAshScript heals the party once the menu closes,
// if a Pokémon that is not an Egg has fainted (CheckAnyFaintedMon).
if (!this.state.party.some((mon) => mon.species !== EGG && !mon.hp))
return false;
this.useDisposableItem(use);
this.deferred = MENU.SacredAshScript;
this.deferredItem = item;
return true;
case MENU.ItemfinderEffect:
// ItemFinder: one script or the other, and always a success.
this.deferred = this.host.hiddenItemOnScreen(this.state)
? OVERWORLD["ItemFinder.Script_FoundSomething"]
: OVERWORLD["ItemFinder.Script_FoundNothing"];
this.deferredItem = item;
return true;
case MENU.CardKeyEffect:
// _CardKey: facing up at the slot on Radio Tower 3F.
return this.useKey(
item,
RADIO_TOWER_3F,
{ x: 18, y: 6 },
OVERWORLD["_CardKey.CardKeyScript"],
FACING_UP,
);
case MENU.BasementKeyEffect:
// _BasementKey: facing the door in the Goldenrod Underground.
return this.useKey(
item,
GOLDENROD_UNDERGROUND,
{ x: 22, y: 10 },
OVERWORLD["_BasementKey.BasementKeyScript"],
);
case MENU.SquirtbottleEffect:
// _Squirtbottle: its script checks for Sudowoodo.
this.deferred = OVERWORLD["_Squirtbottle.SquirtbottleScript"];
this.deferredItem = item;
return true;
case MENU.NormalBoxEffect:
case MENU.GorgeousBoxEffect:
return yield* this.openBox(
use,
effect === MENU.NormalBoxEffect
? C.DECOFLAG_SILVER_TROPHY_DOLL
: C.DECOFLAG_GOLD_TROPHY_DOLL,
);
}
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
return "unported";
}
/** _CardKey and _BasementKey: the key's script runs once the menu closes
* if the player faces `tile` (in wPlayerMapX/Y coordinates, which count
* the map's border) on `mapId`, and `facing` if given. */
private useKey(
item: number,
mapId: string,
tile: { x: number; y: number },
script: number,
facing?: number,
): boolean {
const host = this.host.scriptHost(),
direction = host.facing(),
[dx, dy] = FACING_VECTORS[direction],
{ x, y } = host.position();
if (
host.mapId !== mapId ||
(facing !== undefined && direction !== facing) ||
x + dx + 4 !== tile.x ||
y + dy + 4 !== tile.y
)
return false;
this.deferred = script;
this.deferredItem = item;
return true;
}
/** UseRepel: a Repel for `steps` steps, unless one is in effect. */
private *useRepel(use: FieldItemUse, steps: number): Gen<boolean> {
if (this.state.repel) {
yield* this.print(MENU.RepelUsedEarlierIsStillInEffectText);
return false;
}
this.state.repel = steps;
yield* this.useItemText(use);
return true;
}
/** UseItemText: "<PLAYER> used the …", then UseDisposableItem. */
private *useItemText(use: FieldItemUse): Gen {
yield* this.print(BATTLE.ItemUsedText);
this.useDisposableItem(use);
}
/** OpenBox: SetSpecificDecorationFlag gives the trophy doll, which the
* player sends home. */
private *openBox(use: FieldItemUse, flag: number): Gen<boolean> {
const r = this.reader,
decoration = r.byte(OVERWORLD.DecorationIDs + flag);
this.state.events.add(
new DecorationData(r, this.data).attribute(decoration).event,
);
yield* this.print(MENU["OpenBox.SentTrophyHomeText"]);
this.useDisposableItem(use);
return true;
}
/** UseItem_SelectMon: the party menu for an item. The party index, or
* -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon(
+9 -1
View File
@@ -181,7 +181,15 @@ export function readTextEnd(
// Only the outermost text is trimmed; nested fragments keep their spacing.
const finish = (value: string) => (depth === 0 ? value.trim() : value);
const ram = (address: number) => {
const value = context.strings[address];
const value =
context.strings[address] ??
(address === RAM.playerName
? context.player
: address === RAM.rivalName
? context.rival
: address === RAM.momsName
? context.mom
: undefined);
if (value === undefined)
throw new Error(
`Text reads RAM 0x${address.toString(16)}, which has no native value.`,
+1
View File
@@ -178,6 +178,7 @@ export const OW = {
NUM_FLYPOINTS: 24,
// SpriteMovementData (constants/map_object_constants.asm).
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
SPRITEMOVEDATA_SUDOWOODO: 0x17,
// Headbutt and Rock Smash sets (constants/pokemon_data_constants.asm).
NUM_TREEMON_SETS: 8,
BICYCLE: 7,
+71 -7
View File
@@ -150,6 +150,10 @@ export interface NativeSave {
noWildEncounters: boolean;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
/** wRepelEffect, wCoins and wBlueCardBalance. */
repel: number;
coins: number;
blueCardBalance: number;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
}
@@ -472,6 +476,8 @@ const forcedDirection = (tile: number): Direction | undefined => {
const TURN_WAIT_TICKS = 3;
/** STEP_BIKE: 4 ticks at 4 pixels per tick (bikes and ice). */
const BIKE_TICKS = 4;
/** BGEVENT_ITEM: a hidden item's bg_event (constants/script_constants.asm). */
const BGEVENT_ITEM = 7;
/** An object's state as LoadMapObjects creates it from the map data. */
const spawnActor = (object: MapObject): Actor => ({
x: object.x,
@@ -1461,10 +1467,24 @@ export class GameWorld {
);
if (event)
this.startScript(event.script, PLAYER, { committedPosition: previous });
else if (!this.checkSpecialPhoneCall(previous))
else if (!this.checkSpecialPhoneCall(previous) && !this.repelStep(previous))
this.randomEncounter(previous);
}
/** CountStep's DoRepelStep: a Repel loses a step, and the step that ends
* it runs RepelWoreOffScript instead of the encounter check. A save
* during that text replays the step with the Repel's last step. */
private repelStep(previous: { x: number; y: number }): boolean {
const state = this.state;
if (!state.repel) return false;
if (--state.repel) return false;
this.startScript(OVERWORLD.RepelWoreOffScript, PLAYER, {
committedPosition: previous,
});
if (this.script) this.script.committed.repel = 1;
return true;
}
/** CountStep / CheckSpecialPhoneCall: a queued call waits for a step
* after trainer, warp and coordinate events, before encounters. Its
* SpecialPhoneCallList entry supplies the condition and caller script,
@@ -1511,11 +1531,17 @@ export class GameWorld {
if (!(this.random() < table.rate)) return;
const chosen = this.chooseWildEncounter(table);
if (!chosen) return;
if (!state.party.some((mon) => mon.hp > 0)) return;
// CheckRepelEffect: under a Repel, only Pokémon at least as high in
// level as the first one that has not fainted.
const lead = state.party.find((mon) => mon.hp > 0);
if (!lead) return;
if (state.repel && chosen.level < lead.level) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
});
// The step replays after a reload, so it keeps its Repel step.
if (state.repel && this.script) this.script.committed.repel++;
}
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
* which a Cleanse Tag halves. */
@@ -1599,8 +1625,6 @@ export class GameWorld {
state.flags.has(flag)
? undefined
: { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] };
const facingObject = () =>
this.objects().find((object) => object.x === x && object.y === y);
switch (move) {
case OW.CUT: {
const block = this.fieldMoveBlock(x, y, tile, "cut");
@@ -1628,7 +1652,7 @@ export class GameWorld {
if (
this.assets.permission(tile) !== OW.WATER_TILE ||
this.sideBlocked(current, tile, this.player.direction) ||
facingObject()
this.facingObject()
)
return cant;
return {
@@ -1715,7 +1739,7 @@ export class GameWorld {
: failed;
case OW.ROCK_SMASH: {
// GetFacingObject leaves the rock in hLastTalked.
const rock = facingObject();
const rock = this.facingObject();
return rock?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? {
script: OVERWORLD.RockSmashFromMenuScript,
@@ -1950,8 +1974,30 @@ export class GameWorld {
this.assets.collision(this.map, this.player.x, this.player.y),
environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService,
facingObject: () => this.facingObject(),
};
}
/** GetFacingObject: the visible object on the tile the player faces. */
private facingObject() {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
return this.objects().find((object) => object.x === x && object.y === y);
}
/** CheckForHiddenItems: a BGEVENT_ITEM not found yet on the screen
* around the player, from four squares left of and above the player to
* five right of and four below it. */
private hiddenItemOnScreen(state: ScriptState) {
const r = this.assets.reader,
{ x, y } = this.player;
return this.map.backgroundEvents.some(
(event) =>
event.kind === BGEVENT_ITEM &&
((y + 4 - event.y) & 0xff) < 9 &&
((x + 5 - event.x) & 0xff) < 10 &&
!state.events.has(r.word(event.script)),
);
}
private weekdayName(day: number) {
const r = this.assets.reader;
return readString(
@@ -2079,6 +2125,7 @@ export class GameWorld {
scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark,
gameTime: () => this.gameTime,
hiddenItemOnScreen: (state) => this.hiddenItemOnScreen(state),
},
this.menuCursors,
select,
@@ -3597,7 +3644,7 @@ export class GameWorld {
if (set !== (event.kind === 5)) return;
script = r.near(event.script + 2, r.bank(event.script));
} else if (event.kind === 0) script = event.script;
else if (event.kind === 7) {
else if (event.kind === BGEVENT_ITEM) {
// BGEVENT_ITEM: hiddenitem data (event flag, item) for HiddenItemScript.
const hidden = {
event: r.word(event.script),
@@ -4054,6 +4101,9 @@ export class GameWorld {
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
options: { ...state.options },
};
}
@@ -4161,6 +4211,9 @@ export class GameWorld {
!save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) ||
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.blueCardBalance, 0, 255) ||
!validOptions(save.options)
) {
throw new Error(
@@ -4228,6 +4281,9 @@ export class GameWorld {
flash: save.flash,
noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem },
repel: save.repel,
coins: save.coins,
blueCardBalance: save.blueCardBalance,
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
@@ -4338,6 +4394,11 @@ export function migrateSave(
...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 },
}),
...(!Object.hasOwn(save, "repel") && {
repel: 0,
coins: 0,
blueCardBalance: 0,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4386,6 +4447,9 @@ export function migrateSave(
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
repel: 0,
coins: 0,
blueCardBalance: 0,
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),