Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
Field moves chosen in the party menu run their original checks and the ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key item that Select then uses, replacing the Key Items shortcut, and Bill's PC moves Pokémon between the party and every box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45275ba83b
commit
3456aa4e11
25 files changed
+1129
-186
No files matched your search
+232
-24
@@ -7,6 +7,7 @@ import { CrystalAssets } from "../rom/assets";
|
||||
import { TrainerData } from "../rom/trainers";
|
||||
import {
|
||||
ENGINE_FLAGS,
|
||||
EVENTS,
|
||||
INITIAL_POSITION,
|
||||
NUM_FRUIT_TREES,
|
||||
OVERWORLD,
|
||||
@@ -27,7 +28,11 @@ import {
|
||||
type ScriptState,
|
||||
} from "../scripts/interpreter";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
|
||||
import {
|
||||
StartMenuRun,
|
||||
type MenuCursors,
|
||||
type MenuFieldMove,
|
||||
} from "../scripts/start-menu";
|
||||
import { PartyEvolution } from "../scripts/evolution";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
@@ -107,6 +112,8 @@ export interface NativeSave {
|
||||
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
|
||||
flash: boolean;
|
||||
noWildEncounters: boolean;
|
||||
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
|
||||
registeredItem: { which: number; item: number };
|
||||
}
|
||||
|
||||
/** A random byte from the platform's secure generator. */
|
||||
@@ -1364,7 +1371,20 @@ export class GameWorld {
|
||||
)
|
||||
return;
|
||||
if (isIce(collision)) return;
|
||||
// GetMapEncounterRate: CheckOnWater picks the water rate.
|
||||
const table = this.encounterTable(state);
|
||||
if (!(this.random() < table.rate)) return;
|
||||
const chosen = this.chooseWildEncounter(table);
|
||||
if (!chosen) return;
|
||||
if (!state.party.some((mon) => mon.hp > 0)) return;
|
||||
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: chosen,
|
||||
});
|
||||
}
|
||||
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
|
||||
* which a Cleanse Tag halves. */
|
||||
private encounterTable(state: ScriptState) {
|
||||
const collision = this.assets.collision(this.map, this.x, this.y);
|
||||
const water = this.assets.permission(collision) === OW.WATER_TILE,
|
||||
kanto = this.inKanto();
|
||||
const grass = water
|
||||
@@ -1376,10 +1396,17 @@ export class GameWorld {
|
||||
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
|
||||
// ApplyCleanseTagEffectOnEncounterRate
|
||||
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
||||
if (!(this.random() < rate)) return;
|
||||
// ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
// WaterMonProbTable, whose entries are (percent, slot offset).
|
||||
if (!grass && !sea) return;
|
||||
return { water, grass, sea, rate };
|
||||
}
|
||||
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
||||
* varies the level. Undefined when no valid Pokémon is chosen. */
|
||||
private chooseWildEncounter({
|
||||
water,
|
||||
grass,
|
||||
sea,
|
||||
}: ReturnType<GameWorld["encounterTable"]>) {
|
||||
if (!grass && !sea) return undefined;
|
||||
let roll: number;
|
||||
do roll = this.random();
|
||||
while (roll >= 100);
|
||||
@@ -1399,12 +1426,167 @@ export class GameWorld {
|
||||
}
|
||||
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
|
||||
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
|
||||
return;
|
||||
if (!state.party.some((mon) => mon.hp > 0)) return;
|
||||
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: chosen,
|
||||
});
|
||||
return undefined;
|
||||
return chosen;
|
||||
}
|
||||
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
|
||||
* on ice. */
|
||||
private canEncounterWildMon(state: ScriptState) {
|
||||
const map = this.map,
|
||||
collision = this.assets.collision(map, this.x, this.y);
|
||||
return (
|
||||
!state.noWildEncounters &&
|
||||
(map.environment === OW.CAVE ||
|
||||
map.environment === OW.DUNGEON ||
|
||||
this.assets.grassCollisions().includes(collision)) &&
|
||||
!isIce(collision)
|
||||
);
|
||||
}
|
||||
|
||||
/** A field move chosen from the party menu (CutFunction, SurfFunction and
|
||||
* the rest in engine/events/overworld.asm), checked on the menu's draft
|
||||
* for party slot `mon`: a script queued for when the menu closes, the
|
||||
* messages of a failed attempt, or a notice for what is not ported. */
|
||||
private menuFieldMove(
|
||||
state: ScriptState,
|
||||
mon: number,
|
||||
move: number,
|
||||
): MenuFieldMove {
|
||||
const [dx, dy] = VECTORS[this.player.direction];
|
||||
const x = this.player.x + dx,
|
||||
y = this.player.y + dy;
|
||||
const tile = this.assets.collision(this.map, x, y) ?? 0;
|
||||
const env = this.map.environment,
|
||||
outdoors = env === OW.TOWN || env === OW.ROUTE;
|
||||
const failed = { texts: [OVERWORLD["FieldMoveFailed.CantUseItemText"]] };
|
||||
const badge = (flag: number) =>
|
||||
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");
|
||||
return (
|
||||
badge(OW.ENGINE_HIVEBADGE) ??
|
||||
(block
|
||||
? {
|
||||
script: OVERWORLD.Script_CutFromMenu,
|
||||
fieldMove: { mon, block },
|
||||
}
|
||||
: { texts: [OVERWORLD.CutNothingText] })
|
||||
);
|
||||
}
|
||||
case OW.SURF: {
|
||||
const noBadge = badge(OW.ENGINE_FOGBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
const cant = { texts: [OVERWORLD.CantSurfText] };
|
||||
if (state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) return cant;
|
||||
if (
|
||||
state.playerState === OW.PLAYER_SURF ||
|
||||
state.playerState === OW.PLAYER_SURF_PIKA
|
||||
)
|
||||
return { texts: [OVERWORLD.AlreadySurfingText] };
|
||||
const current = this.assets.collision(this.map, this.x, this.y);
|
||||
if (
|
||||
this.assets.permission(tile) !== OW.WATER_TILE ||
|
||||
this.sideBlocked(current, tile, this.player.direction) ||
|
||||
facingObject()
|
||||
)
|
||||
return cant;
|
||||
return {
|
||||
script: OVERWORLD.SurfFromMenuScript,
|
||||
fieldMove: {
|
||||
mon,
|
||||
// GetSurfType: Pikachu surfs on its own sprite.
|
||||
surfState:
|
||||
state.party[mon].species === OW.PIKACHU
|
||||
? OW.PLAYER_SURF_PIKA
|
||||
: OW.PLAYER_SURF,
|
||||
},
|
||||
};
|
||||
}
|
||||
case OW.STRENGTH:
|
||||
return (
|
||||
badge(OW.ENGINE_PLAINBADGE) ?? {
|
||||
script: OVERWORLD.Script_StrengthFromMenu,
|
||||
fieldMove: { mon },
|
||||
}
|
||||
);
|
||||
case OW.FLASH: {
|
||||
const noBadge = badge(OW.ENGINE_ZEPHYRBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
// SpecialAerodactylChamber opens its wall; otherwise the map must
|
||||
// be dark (DARKNESS_PALSET).
|
||||
const chamber =
|
||||
this.map.attributes ===
|
||||
OVERWORLD.RuinsOfAlphAerodactylChamber_MapAttributes;
|
||||
if (chamber) state.events.add(EVENTS.wallOpenedInAerodactylChamber);
|
||||
return chamber || this.timeOfDay === 3
|
||||
? { script: OVERWORLD.Script_UseFlash, fieldMove: { mon } }
|
||||
: failed;
|
||||
}
|
||||
case OW.WHIRLPOOL: {
|
||||
const noBadge = badge(OW.ENGINE_GLACIERBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
const block = isWhirlpool(tile)
|
||||
? this.fieldMoveBlock(x, y, tile, "whirlpool")
|
||||
: undefined;
|
||||
return block
|
||||
? {
|
||||
script: OVERWORLD.Script_WhirlpoolFromMenu,
|
||||
fieldMove: { mon, block },
|
||||
}
|
||||
: failed;
|
||||
}
|
||||
case OW.WATERFALL: {
|
||||
const noBadge = badge(OW.ENGINE_RISINGBADGE);
|
||||
if (noBadge) return noBadge;
|
||||
// CheckMapCanWaterfall: facing up at a waterfall tile.
|
||||
return this.player.direction === "up" &&
|
||||
(tile === OW.COLL_WATERFALL || tile === OW.COLL_CURRENT_DOWN)
|
||||
? { script: OVERWORLD.Script_WaterfallFromMenu, fieldMove: { mon } }
|
||||
: failed;
|
||||
}
|
||||
case OW.FLY:
|
||||
return (
|
||||
badge(OW.ENGINE_STORMBADGE) ??
|
||||
(outdoors ? { notice: "The Fly map is not ported yet." } : failed)
|
||||
);
|
||||
case OW.DIG:
|
||||
return env === OW.CAVE || env === OW.DUNGEON
|
||||
? { notice: "Digging out of a cave is not ported yet." }
|
||||
: { texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]] };
|
||||
case OW.TELEPORT:
|
||||
return outdoors && state.lastSpawnMap !== null
|
||||
? { notice: "Teleport is not ported yet." }
|
||||
: { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
|
||||
case OW.HEADBUTT:
|
||||
return tile === OW.COLL_HEADBUTT_TREE ||
|
||||
tile === OW.COLL_HEADBUTT_TREE_1D
|
||||
? { notice: "Headbutt trees are not ported yet." }
|
||||
: failed;
|
||||
case OW.ROCK_SMASH:
|
||||
return facingObject()?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
|
||||
? { notice: "Smashing rocks is not ported yet." }
|
||||
: failed;
|
||||
case OW.SWEET_SCENT: {
|
||||
// SweetScentEncounter, drawn now: no rate roll, and no battle
|
||||
// without a rate or a valid Pokémon.
|
||||
let wild: { species: number; level: number } | undefined;
|
||||
if (this.canEncounterWildMon(state)) {
|
||||
const table = this.encounterTable(state);
|
||||
if (table.rate) wild = this.chooseWildEncounter(table);
|
||||
}
|
||||
return {
|
||||
script: OVERWORLD["SweetScentFromMenu.SweetScent"],
|
||||
fieldMove: { mon },
|
||||
wild,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { notice: "This field move is not ported yet." };
|
||||
}
|
||||
|
||||
/** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship
|
||||
@@ -1655,9 +1837,13 @@ export class GameWorld {
|
||||
text: () => this.host(PLAYER).text(),
|
||||
};
|
||||
}
|
||||
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
||||
useSelect(): boolean {
|
||||
return this.openStartMenu(true);
|
||||
}
|
||||
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(): boolean {
|
||||
openStartMenu(select = false): boolean {
|
||||
if (this.busy || this.introPending) return false;
|
||||
this.clearNotice();
|
||||
this.script = {
|
||||
@@ -1667,11 +1853,14 @@ export class GameWorld {
|
||||
reader: this.assets.reader,
|
||||
data: this.assets.pokemon,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bicycleScript: (state) => this.bicycleScript(state),
|
||||
bicycleScript: (state, select) => this.bicycleScript(state, select),
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
landmark: () => this.assets.map(this.mapId).landmark,
|
||||
fieldMove: (state, mon, move) =>
|
||||
this.menuFieldMove(state, mon, move),
|
||||
},
|
||||
this.menuCursors,
|
||||
select,
|
||||
),
|
||||
committed: this.save(),
|
||||
rollback: {
|
||||
@@ -2396,12 +2585,17 @@ export class GameWorld {
|
||||
this.sceneCheck = true;
|
||||
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
|
||||
if (script.run.deferred !== undefined)
|
||||
this.startScript(script.run.deferred, PLAYER, {
|
||||
usedItem:
|
||||
script.run instanceof StartMenuRun
|
||||
? script.run.deferredItem
|
||||
: undefined,
|
||||
});
|
||||
this.startScript(
|
||||
script.run.deferred,
|
||||
PLAYER,
|
||||
script.run instanceof StartMenuRun
|
||||
? {
|
||||
usedItem: script.run.deferredItem,
|
||||
fieldMove: script.run.deferredMove?.fieldMove,
|
||||
wild: script.run.deferredMove?.wild,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
}
|
||||
private abortScript(error: unknown) {
|
||||
const script = this.script!;
|
||||
@@ -3228,7 +3422,7 @@ export class GameWorld {
|
||||
}
|
||||
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
|
||||
* .CheckEnvironment or the player's state refuses the Bicycle. */
|
||||
private bicycleScript(state: ScriptState): number | undefined {
|
||||
private bicycleScript(state: ScriptState, select = false): number | undefined {
|
||||
const map = this.map;
|
||||
const outdoors =
|
||||
map.environment === OW.TOWN || map.environment === OW.ROUTE;
|
||||
@@ -3239,12 +3433,17 @@ export class GameWorld {
|
||||
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
|
||||
OW.LAND_TILE;
|
||||
if (!allowed) return undefined;
|
||||
// .CheckIfRegistered: Select uses the scripts without a message.
|
||||
if (state.playerState === OW.PLAYER_NORMAL)
|
||||
return OVERWORLD.Script_GetOnBike;
|
||||
return select
|
||||
? OVERWORLD.Script_GetOnBike_Register
|
||||
: OVERWORLD.Script_GetOnBike;
|
||||
if (state.playerState === OW.PLAYER_BIKE)
|
||||
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
|
||||
? OVERWORLD.Script_CantGetOffBike
|
||||
: OVERWORLD.Script_GetOffBike;
|
||||
: select
|
||||
? OVERWORLD.Script_GetOffBike_Register
|
||||
: OVERWORLD.Script_GetOffBike;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3484,6 +3683,7 @@ export class GameWorld {
|
||||
backupWarp: state.backupWarp && { ...state.backupWarp },
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
};
|
||||
}
|
||||
restore(value: unknown) {
|
||||
@@ -3579,7 +3779,10 @@ export class GameWorld {
|
||||
!/^\d{1,2}:\d{1,3}$/.test(save.backupWarp.map) ||
|
||||
!integer(save.backupWarp.warp, 1, 254))) ||
|
||||
typeof save.flash !== "boolean" ||
|
||||
typeof save.noWildEncounters !== "boolean"
|
||||
typeof save.noWildEncounters !== "boolean" ||
|
||||
!save.registeredItem ||
|
||||
!integer(save.registeredItem.which, 0, 255) ||
|
||||
!integer(save.registeredItem.item, 0, 255)
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid native save. Emulator snapshots cannot be imported into this port.",
|
||||
@@ -3641,6 +3844,7 @@ export class GameWorld {
|
||||
backupWarp: save.backupWarp && { ...save.backupWarp },
|
||||
flash: save.flash,
|
||||
noWildEncounters: save.noWildEncounters,
|
||||
registeredItem: { ...save.registeredItem },
|
||||
specialPhoneCall: save.specialPhoneCall,
|
||||
rivalName: save.rivalName,
|
||||
};
|
||||
@@ -3738,6 +3942,9 @@ export function migrateSave(
|
||||
...(!Object.hasOwn(save, "noWildEncounters") && {
|
||||
noWildEncounters: false,
|
||||
}),
|
||||
...(!Object.hasOwn(save, "registeredItem") && {
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
// Clocks saved before InitClock was ported follow the device time.
|
||||
...(save.clock !== null &&
|
||||
@@ -3779,6 +3986,7 @@ export function migrateSave(
|
||||
backupWarp: null,
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
intro: false,
|
||||
} as unknown as NativeSave;
|
||||
}
|
||||
Reference in new issue
Block a user