Port Fly, Dig, Teleport and the Escape Rope

FLY opens _FlyMap in the party menu: the region's Town Map with "Where?",
the visited flypoints in turn and the Fly user's icon, then FlyScript flies
the icon off along SpriteAnimFunc_FlyFrom's path, enters the spawn point and
lands it with SpriteAnimFunc_FlyTo. Teleport returns to the last Pokémon
Center's town through IsSpawnPoint. Dig and the Escape Rope lead out of a
cave to the warp EnterMapWarp's SaveDigWarp recorded, which saves now keep.

newloadmap stops its script as StopScript does and the world resumes it on
the new map, which the landing and return movements need. A destination
that is not ported yet gives a notice.

Closes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 11:18:57 +02:00
1 parent c4cc0e756c
commit a7aceda089
21 files changed
+1161 -92

No files matched your search

+2 -2
View File
@@ -12,11 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls. Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.
Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon) and the menu account (the start menu's descriptions); text speed, battle scene, sound, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map. Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. Fly opens the Fly map to choose a town you have visited, Teleport returns to the town of the last Pokémon Center you entered, and Dig or an Escape Rope leads out of a cave through the entrance you last used. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon) and the menu account (the start menu's descriptions); text speed, battle scene, sound, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map.
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations. The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex screen, Fly, Dig, Teleport, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex screen, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run ## Run
+9 -1
View File
@@ -88,10 +88,18 @@ keeps the card on screen (`world.pokegear`) during the call's texts.
`scripts/mom.ts` runs `BankOfMom` as a special; its amount box is a `scripts/mom.ts` runs `BankOfMom` as a special; its amount box is a
`mombank` effect answered with buttons in the same way, and so are the `mombank` effect answered with buttons in the same way, and so are the
pages of `scripts/trainer-card.ts` and the screen of `scripts/options.ts`, pages of `scripts/trainer-card.ts` and the screen of `scripts/options.ts`,
whose option bytes live in the script state. The world refreshes the card's play whose option bytes live in the script state, and of `scripts/fly-map.ts`,
which Fly opens before the world queues its script. The world refreshes the card's play
time, colon and badge step every tick; `world/game-time.ts` is the play time, colon and badge step every tick; `world/game-time.ts` is the play
time (`wGameTime`), which the world advances two frames per tick. time (`wGameTime`), which the world advances two frames per tick.
`newloadmap` stops a script as `StopScript` does: the `warp` effect carries
the offset after it, and once the world has entered the new map it starts
the rest of the script there with the same field-move context (Fly's
landing, Teleport's and Dig's return movements). A `fly` effect hides every
object while the party icon flies (`world.flyingIcon`, drawn by the
renderer).
`TilesetAnimator` translates the ROM animation command tables, advancing two `TilesetAnimator` translates the ROM animation command tables, advancing two
video frames per overworld tick. It owns mutable graphics separately from the video frames per overworld tick. It owns mutable graphics separately from the
asset cache. The renderer records background color indices for grass sprite asset cache. The renderer records background color indices for grass sprite
+24 -4
View File
@@ -36,6 +36,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. | | `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. |
| `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. | | `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. |
| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. | | `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. |
| `game/scripts/fly-map.ts` | `engine/pokegear/pokegear.asm` (`_FlyMap`, `FlyMap`, `TownMapBubble`, `CheckIfVisitedFlypoint`), `data/maps/flypoints.asm`, `engine/events/field_moves.asm` (`FlyFromAnim`, `FlyToAnim`), `engine/sprite_anims/functions.asm` | The Fly map's region choice (Kanto only after Indigo Plateau, without the player's icon otherwise), the default flypoint and up/down scrolling over visited flypoints (`wVisitedSpawns` through the ROM's `EngineFlags`). The flights follow `SpriteAnimFunc_FlyFrom` and `SpriteAnimFunc_FlyTo` frame by frame with the party icon. The Town Map, bubble and icons are drawn natively; the leaves are not presented. |
| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. | | `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. | | `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
@@ -400,8 +401,11 @@ Badge, forced cycling, already surfing, water permission, `CheckDirection`
and `CheckFacingObject`), `StrengthFunction`, `FlashFunction` and `CheckFacingObject`), `StrengthFunction`, `FlashFunction`
(`SpecialAerodactylChamber` by map attributes, which sets (`SpecialAerodactylChamber` by map attributes, which sets
`EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER`, or `DARKNESS_PALSET`), `EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER`, or `DARKNESS_PALSET`),
`TryWhirlpoolMenu`, `CheckMapCanWaterfall`, and the checks of Fly, Dig, `TryWhirlpoolMenu`, `CheckMapCanWaterfall`, `FlyFunction` (the Storm
Teleport, Headbutt and Rock Smash. A success queues `Script_CutFromMenu`, Badge, an outdoor map, then the Fly map), `EscapeRopeOrDig` (a cave or
dungeon with a `wDigWarpNumber`) and `TeleportFunction` (an outdoor map and
`IsSpawnPoint` on `wLastSpawnMapGroup`), and the checks of Headbutt and Rock
Smash. A success queues `Script_CutFromMenu`,
`SurfFromMenuScript`, `Script_StrengthFromMenu`, `Script_UseFlash`, `SurfFromMenuScript`, `Script_StrengthFromMenu`, `Script_UseFlash`,
`Script_WhirlpoolFromMenu`, `Script_WaterfallFromMenu` or `Script_WhirlpoolFromMenu`, `Script_WaterfallFromMenu` or
`SweetScentFromMenu.SweetScent` with the field-move context the interpreter `SweetScentFromMenu.SweetScent` with the field-move context the interpreter
@@ -409,8 +413,24 @@ already reads (`GetPartyNickname`, the replacement block, the surfing
state). New interpreter support: `callasm BlindingFlash` (sets the Flash state). New interpreter support: `callasm BlindingFlash` (sets the Flash
flag), `callasm SweetScentEncounter` (the encounter the world drew with flag), `callasm SweetScentEncounter` (the encounter the world drew with
`CanEncounterWildMon`, `GetMapEncounterRate` and `ChooseWildEncounter`, no `CanEncounterWildMon`, `GetMapEncounterRate` and `ChooseWildEncounter`, no
rate roll) and `UseFlashTextScript`'s sound-only `text_asm`. Fly's map, Dig rate roll) and `UseFlashTextScript`'s sound-only `text_asm`.
and Teleport's warps, `TreeMonEncounter` and `RockMonEncounter` are not `FlyFunction.FlyScript`, `TeleportFunction.TeleportScript` and
`EscapeRopeOrDig.UsedDigScript` run with the chosen `wDefaultSpawnpoint` or
the dig warp's position. `newloadmap` handles `MAPSETUP_WARP`,
`MAPSETUP_TELEPORT` and `MAPSETUP_FLY` (`EnterMapSpawnPoint` from
`SpawnPoints`) and `MAPSETUP_DOOR` (`EnterMapWarp` to the warp Dig copies
into `wNextWarp`; the player keeps facing unless `CheckWarpFacingDown`),
then stops the script, which resumes on the new map. `EnterMapWarp`'s
`SaveDigWarp` records any warp from an outdoor map into an indoor one
(`CheckIndoorMap`), except from Mount Moon Square and Tin Tower's roof; saves
keep it as `digWarp`. `FlyFromAnim`, `FlyToAnim`, `HideSprites`,
`SkipUpdateMapSprites`, `.ReturnFromFly` and `WaitSFX` are accepted, the Fly
animations as timed `fly` effects (65 and 33 ticks). The Escape Rope
(`EscapeRopeEffect`) runs `EscapeRopeOrDig` from the pack, opening the Kabuto
chamber's wall there (`SpecialKabutoChamber`) and using up one rope; a
failure prints nothing before Oak's warning. `JumpRoamMons` (no roaming
Pokémon yet) and the fades are not modelled. A destination map that is not
ported gives a notice. `TreeMonEncounter` and `RockMonEncounter` are not
ported and give a notice after their checks. ported and give a notice after their checks.
`Softboiled_MilkDrinkFunction` runs in the menu with `GetOneFifthMaxHP`, `Softboiled_MilkDrinkFunction` runs in the menu with `GetOneFifthMaxHP`,
`RemoveHP` and `RestoreHealth`, refusing the user itself and Pokémon that `RemoveHP` and `RestoreHealth`, refusing the user itself and Pokémon that
+12 -3
View File
@@ -169,7 +169,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte. - PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte.
- PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out". - PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out".
- The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`. - The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`.
- Not ported: MAIL, moving a Pokémon's moves to another party member with left and right, item reordering with Select and the remaining field items such as Repel and Escape Rope (each gives a notice and changes nothing). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it. - Not ported: MAIL, moving a Pokémon's moves to another party member with left and right, item reordering with Select and the remaining field items such as Repel (each gives a notice and changes nothing; the Escape Rope runs since the Fly section below). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it.
## Implemented: evolution ## Implemented: evolution
@@ -185,7 +185,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Field moves in the Pokémon submenu run their menu functions: the world checks the badge (`CheckBadge`: "Sorry! A new BADGE is required."), the facing tile, water or object, then the menu closes and the ROM's `…FromMenu` script runs for the chosen Pokémon (no "Want to …?" question): Cut (`CheckMapForSomethingToCut`), Surf (including "already surfing" and forced cycling), Strength, Flash (a `PALETTE_DARK` map or the Aerodactyl chamber; `BlindingFlash` sets the Flash flag), Whirlpool and Waterfall. Failures print the ROM's messages and return to the party menu. - Field moves in the Pokémon submenu run their menu functions: the world checks the badge (`CheckBadge`: "Sorry! A new BADGE is required."), the facing tile, water or object, then the menu closes and the ROM's `…FromMenu` script runs for the chosen Pokémon (no "Want to …?" question): Cut (`CheckMapForSomethingToCut`), Surf (including "already surfing" and forced cycling), Strength, Flash (a `PALETTE_DARK` map or the Aerodactyl chamber; `BlindingFlash` sets the Flash flag), Whirlpool and Waterfall. Failures print the ROM's messages and return to the party menu.
- Sweet Scent runs `SweetScentFromMenu`: where wild Pokémon can appear and the map has a rate, `ChooseWildEncounter` picks one without the rate roll and a battle starts; elsewhere "Looks like there's nothing here…". - Sweet Scent runs `SweetScentFromMenu`: where wild Pokémon can appear and the map has a rate, `ChooseWildEncounter` picks one without the rate roll and a battle starts; elsewhere "Looks like there's nothing here…".
- Softboiled and Milk Drink give a fifth of the user's maximum HP to another Pokémon that has not fainted and is not at full HP; the user needs more than that fifth. - Softboiled and Milk Drink give a fifth of the user's maximum HP to another Pokémon that has not fainted and is not at full HP; the user needs more than that fifth.
- Fly (outdoors with the Storm Badge), Dig (in caves), Teleport (outdoors with a spawn point), Headbutt (facing a tree) and Rock Smash (facing a rock) run their checks and messages, then give a notice: the Fly map, dungeon and spawn warps, tree encounters and rock encounters are not ported. - Headbutt (facing a tree) and Rock Smash (facing a rock) run their checks and messages, then give a notice: tree and rock encounters are not ported. Fly, Dig and Teleport run since the section below.
- SEL registers an item that `CANT_SELECT` allows (in practice key items) in `wWhichRegisteredItem` and `wRegisteredItem`, which saves keep. Select runs `SelectMenu`: `CheckRegisteredItem` (a slot that no longer holds the item clears the registration; a registered ball is never found, as in the original) and `UseRegisteredItem` by field menu. The Bicycle uses `Script_GetOnBike_Register` and `Script_GetOffBike_Register`, without a message; elsewhere Oak objects. Without a registration Select explains SEL. This replaces the earlier Key Items shortcut. - SEL registers an item that `CANT_SELECT` allows (in practice key items) in `wWhichRegisteredItem` and `wRegisteredItem`, which saves keep. Select runs `SelectMenu`: `CheckRegisteredItem` (a slot that no longer holds the item clears the registration; a registered ball is never found, as in the original) and `UseRegisteredItem` by field menu. The Bicycle uses `Script_GetOnBike_Register` and `Script_GetOffBike_Register`, without a message; elsewhere Oak objects. Without a registration Select explains SEL. This replaces the earlier Key Items shortcut.
- Bill's PC's MOVE PKMN W/O MAIL: refused while a party Pokémon holds MAIL, then "Each time you move a POKéMON, data will be saved. OK?". Choose the party or a box, a Pokémon (MOVE, STATS, CANCEL), then a box and the position to insert before. The party keeps its last Pokémon and its last healthy one, a different full box or party has "no room", a Pokémon moved down its own list lands one place earlier (`.CheckTrivialMove`), a boxed Pokémon joining the party gets its stats with full HP and no status (`CalcBufferMonStats`), and one entering a box gets its PP restored. The original's left/right box switching is presented as a list of boxes; after "no room" the box is chosen again rather than the position. - Bill's PC's MOVE PKMN W/O MAIL: refused while a party Pokémon holds MAIL, then "Each time you move a POKéMON, data will be saved. OK?". Choose the party or a box, a Pokémon (MOVE, STATS, CANCEL), then a box and the position to insert before. The party keeps its last Pokémon and its last healthy one, a different full box or party has "no room", a Pokémon moved down its own list lands one place earlier (`.CheckTrivialMove`), a boxed Pokémon joining the party gets its stats with full HP and no status (`CalcBufferMonStats`), and one entering a box gets its PP restored. The original's left/right box switching is presented as a list of boxes; after "no room" the box is chosen again rather than the position.
- Save format 3 gained `registeredItem`, migrated by absence to none. - Save format 3 gained `registeredItem`, migrated by absence to none.
@@ -223,6 +223,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- The four bytes (`wOptions`, `wTextboxFrame`, `wGBPrinterBrightness`, `wOptions2`) are kept in the script state, so they change on the start menu's draft and commit when it closes. Save format 3 gained `options`, migrated by absence to `DefaultOptions`. - The four bytes (`wOptions`, `wTextboxFrame`, `wGBPrinterBrightness`, `wOptions2`) are kept in the script state, so they change on the start menu's draft and commit when it closes. Save format 3 gained `options`, migrated by absence to `DefaultOptions`.
- In effect: the battle style (`CheckWhetherToAskSwitch`: SET makes no offer when a trainer sends out its next Pokémon) and the menu account (`StartMenu`'s descriptions). Not yet: text speed (native text appears at once), the battle scene (no move animations), sound (no audio), printing and the textbox frame. The catching tutorial's temporary medium text speed has nothing to change. - In effect: the battle style (`CheckWhetherToAskSwitch`: SET makes no offer when a trainer sends out its next Pokémon) and the menu account (`StartMenu`'s descriptions). Not yet: text speed (native text appears at once), the battle scene (no move animations), sound (no audio), printing and the textbox frame. The catching tutorial's temporary medium text speed has nothing to change.
## Implemented: Fly, Dig, Teleport and the Escape Rope
- Fly needs the Storm Badge and an outdoor map, then opens `_FlyMap`: the Johto map (Kanto's only once Indigo Plateau's flypoint is visited; before that the Johto map shows no player icon), "Where?" with the flypoint's name, the player's place and the Fly user's party icon on the selected flypoint. New Bark Town (or Indigo Plateau in Kanto) is the default, visited or not; up and down move to the next or previous visited flypoint with wrapping, A flies there, B returns to the party menu. Flypoints are read from `Flypoints` and visits from `wVisitedSpawns`, the `ENGINE_FLYPOINT_*` flags that town callbacks set.
- `FlyScript`: `FlyFromAnim` hides every object while the party icon waits 64 frames and rises off the screen, swaying (`SpriteAnimFunc_FlyFrom`), the player arrives at the flypoint's `SpawnPoints` entry facing down (`MAPSETUP_FLY`), and `FlyToAnim` brings the icon down onto the player (`SpriteAnimFunc_FlyTo`). The icon flaps its wings with `SPRITE_ANIM_OBJ_RED_WALK`'s frames. The leaves and the sound are not presented.
- Teleport works outdoors when `wLastSpawnMapGroup/Number` is a spawn point (`IsSpawnPoint`): "Return to the last POKéMON CENTER.", `teleport_from`, the spawn point facing down, then `teleport_to`. Otherwise "Can't use that here.".
- Dig and the Escape Rope work in caves and dungeons once `wDigWarpNumber` is set: `EnterMapWarp`'s `SaveDigWarp` records the warp of any step from an outdoor map into an indoor one (houses included, as in the original; not from Mount Moon Square or Tin Tower's roof). The ROM's `UsedDigScript` or `UsedEscapeRopeScript` digs out (`step_dig`, hidden), arrives on that warp through `MAPSETUP_DOOR` and returns (`return_dig`); a cave entrance then walks the player out. Dig elsewhere says "Can't use that here."; the Escape Rope fails silently, then Oak warns, and is used up only on success. In the Kabuto chamber the rope opens the wall (`SpecialKabutoChamber`).
- `newloadmap` now stops its script and resumes it on the new map, which these scripts need for their landing movements. Save format 3 gained `digWarp`, migrated by absence to none.
- Unreachable in normal play so far: the Storm Badge, caves (Dark Cave is beyond Route 31) and flypoints beyond Cherrygrove. A destination that is not ported gives a notice. `JumpRoamMons` waits for roaming Pokémon.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items.
@@ -305,7 +314,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, and the options. Next: the Fly map and Teleport/Dig warps. Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, and Fly, Dig, Teleport and the Escape Rope. Next: Headbutt and Rock Smash encounters.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+51
View File
@@ -966,3 +966,54 @@ No emulator comparison; no CI or physical-device run.
Next: the Fly map and Teleport/Dig/Escape Rope warps (board #25), which Next: the Fly map and Teleport/Dig/Escape Rope warps (board #25), which
need script commands after a warp; then Headbutt and Rock Smash (#26). need script commands after a warp; then Headbutt and Rock Smash (#26).
The options increment was committed as `c4cc0e7` (`Closes #24`). Not pushed.
## Fly, Dig, Teleport and the Escape Rope: 2026-10-02
Resumed with "continue" on a clean tree after `c4cc0e7`, taking board card
#25.
- `src/game/scripts/fly-map.ts` (`flyMap`): `_FlyMap`/`FlyMap` inside the
start menu run, before the world queues `FlyFunction.FlyScript`. Region
by the current landmark (Kanto only with Indigo Plateau's flypoint, else
Johto without the player icon), default New Bark/Indigo, up/down over
visited flypoints with wrapping (refused if none is visited, where the
original hangs), A returns the spawn, B goes back to the party menu.
`flymap` effect answered with buttons (`FLY_MAP_BUTTONS`).
- Visits: `assets.spawnEngineFlag` inverts the ROM's `EngineFlags` over
`wVisitedSpawns` (Union Cave and the Fast Ship have no flag).
`assets.spawnPoint`/`spawnIndex` read `SpawnPoints` (`IsSpawnPoint`).
- Interpreter: `newloadmap` handles `MAPSETUP_WARP`/`TELEPORT`/`FLY`
(spawn from `fieldMove.spawn` or `GetWhiteoutSpawn`) and `MAPSETUP_DOOR`
(`fieldMove.warp`, resolved by the world), then stops; the `warp` effect
carries `resume` unless only `end`/`endall` follows, and refuses inside a
call. The world's `finishScript` starts the rest of the script right after
entering the map, with the same field-move context (`ActiveScript.fieldMove`).
`door` warps keep the facing unless `FACE_DOWN_ON_WARP`.
`FieldMoveContext` is now one exported type.
- `fly` effect (`FlyFromAnim` 65 ticks, `FlyToAnim` 33): `poses()` is empty,
`world.flyingIcon` replays `SpriteAnimFunc_FlyFrom`/`FlyTo` with the
ROM sine table, and the renderer draws `assets.monIcon` with
`RED_WALK`'s nine-frame flaps in object palette 0. Leaves not drawn.
- World: `SaveDigWarp` in `prepareWarp` (outdoor to `CheckIndoorMap`, not
from 15:10/15:12); `digWarpMove` for Dig and the Escape Rope (with
`SpecialKabutoChamber`); Teleport through `IsSpawnPoint`; an unported
destination gives a notice. Saves gained `digWarp` (absent → null).
- Start menu: `{ flyMap: true }` verdict, `host.flyTo`, `host.escapeRope`
(`MENU.EscapeRopeEffect`: `UseDisposableItem` only on success; failure
falls to Oak's warning), `host.visitedSpawn`.
- UI: `#fly-map` with the bubble and the Town Map (`drawTownMap` takes an
optional `player` and a `species` icon). Checked visually at desktop and
390px widths, including the flight and landing.
Checks: 423 unit tests with the local ROM (134 passed, 289 skipped without
it; new `tests/fly-dig-teleport.test.ts`); 121 Chromium/WebKit browser tests
passed, 19 skipped intentionally (a new Fly flow in both browsers, with a
"fly" fixture room); native and reference builds; reference, vendor and
release checks; `git diff --check`. No emulator comparison of the flight
paths or warps; no CI or physical-device run. None of it is reachable in
normal play yet (no Storm Badge, caves or further flypoints); Teleport
works after a Pokémon Center visit for a Pokémon that knows it.
Next: Headbutt and Rock Smash encounters (board #26).
+2
View File
@@ -189,6 +189,7 @@ const ramNames = {
surfingPlayerState: "wSurfingPlayerState", surfingPlayerState: "wSurfingPlayerState",
strengthSpecies: "wStrengthSpecies", strengthSpecies: "wStrengthSpecies",
curHPAnimDeltaHP: "wCurHPAnimDeltaHP", curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
visitedSpawns: "wVisitedSpawns",
moneyTemp: "hMoneyTemp", moneyTemp: "hMoneyTemp",
}; };
const profile = readFileSync("src/game/rom/profile.ts", "utf8"); const profile = readFileSync("src/game/rom/profile.ts", "utf8");
@@ -239,6 +240,7 @@ const eventNames = {
playersHouseDoll2: "EVENT_PLAYERS_HOUSE_2F_DOLL_2", playersHouseDoll2: "EVENT_PLAYERS_HOUSE_2F_DOLL_2",
playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL", playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL",
wallOpenedInAerodactylChamber: "EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER", wallOpenedInAerodactylChamber: "EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER",
wallOpenedInKabutoChamber: "EVENT_WALL_OPENED_IN_KABUTO_CHAMBER",
}; };
for (const [key, name] of Object.entries(eventNames)) { for (const [key, name] of Object.entries(eventNames)) {
const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile); const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile);
+23
View File
@@ -177,6 +177,29 @@ export class WorldRenderer {
{ transparent: true }, { transparent: true },
); );
} }
const flying = world.flyingIcon;
if (flying) {
// SPRITE_ANIM_OBJ_RED_WALK with the icon's tiles: each frame shows
// for nine video frames, the second one mirrored on every other pass.
const graphics = this.assets.monIcon(flying.species),
phase = Math.floor(flying.frames / 9) % 4,
flip = phase === 3,
palette = this.assets.objectPalette(0, time);
for (let tile = 0; tile < 4; tile++) {
const column = tile & 1;
drawTile(
this.image.data,
this.width,
this.height,
graphics,
(phase & 1) * 4 + tile,
player.x - cameraX + flying.x + (flip ? 8 - column * 8 : column * 8),
player.y - cameraY - 4 + flying.y + (tile >> 1) * 8,
palette,
{ transparent: true, flipX: flip },
);
}
}
this.context.putImageData(this.image, 0, 0); this.context.putImageData(this.image, 0, 0);
} }
private actor( private actor(
+34 -7
View File
@@ -3,18 +3,25 @@ import { OVERWORLD, SYMBOLS } from "../rom/profile";
import { decompress } from "../rom/lz"; import { decompress } from "../rom/lz";
import { drawTile } from "./pixels"; import { drawTile } from "./pixels";
/** The ROM's Town Map, used by wall maps, the bedroom poster and the /** The ROM's Town Map, used by wall maps, the bedroom poster, the
* Pokégear's map card, which chooses the region and marks the landmark * Pokégear's map card, which chooses the region and marks the landmark
* under its cursor. */ * under its cursor, and the Fly map, which shows the Fly user's party icon
* on the selected flypoint (`species`) and may leave out the player. */
export function drawTownMap( export function drawTownMap(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
world: GameWorld, world: GameWorld,
card?: { kanto: boolean; player: number; cursor: number; name: string }, card?: {
kanto: boolean;
player?: number;
cursor: number;
name: string;
species?: number;
},
) { ) {
const { assets } = world, const { assets } = world,
r = assets.reader; r = assets.reader;
const landmark = card?.player ?? world.map.landmark; const landmark = card ? card.player : world.map.landmark;
const kanto = card?.kanto ?? landmark >= 47; const kanto = card?.kanto ?? world.map.landmark >= 47;
const map = kanto ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap; const map = kanto ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000); const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
const palette = const palette =
@@ -48,10 +55,30 @@ export function drawTownMap(
const p = SYMBOLS.landmarks + index * 4; const p = SYMBOLS.landmarks + index * 4;
return { x: r.byte(p) - 8, y: r.byte(p + 1) - 16 }; return { x: r.byte(p) - 8, y: r.byte(p + 1) - 16 };
}; };
if (landmark !== undefined) {
const player = at(landmark); const player = at(landmark);
context.fillStyle = "#b32628"; context.fillStyle = "#b32628";
context.fillRect(player.x - 2, player.y - 2, 4, 4); context.fillRect(player.x - 2, player.y - 2, 4, 4);
if (card) { }
if (card?.species !== undefined) {
// TownMapMon: the icon's first frame, centred on the landmark.
const cursor = at(card.cursor),
icon = assets.monIcon(card.species),
iconImage = context.getImageData(cursor.x - 8, cursor.y - 8, 16, 16);
for (let tile = 0; tile < 4; tile++)
drawTile(
iconImage.data,
16,
16,
icon,
tile,
(tile & 1) * 8,
(tile >> 1) * 8,
assets.objectPalette(0, world.timeOfDay),
{ transparent: true },
);
context.putImageData(iconImage, cursor.x - 8, cursor.y - 8);
} else if (card) {
// The Pokégear's arrow cursor, drawn as a frame around the landmark. // The Pokégear's arrow cursor, drawn as a frame around the landmark.
const cursor = at(card.cursor); const cursor = at(card.cursor);
context.strokeStyle = "#2366a5"; context.strokeStyle = "#2366a5";
@@ -61,7 +88,7 @@ export function drawTownMap(
canvas.setAttribute( canvas.setAttribute(
"aria-label", "aria-label",
card card
? `Town Map: ${card.name}${card.cursor === card.player ? ", you are here" : ""}` ? `${card.species !== undefined ? "Fly map" : "Town Map"}: ${card.name}${card.cursor === card.player ? ", you are here" : ""}`
: `Town Map: ${world.map.location}`, : `Town Map: ${world.map.location}`,
); );
} }
+51 -3
View File
@@ -30,6 +30,7 @@ export class CrystalAssets {
private maps = new Map<string, GameMap>(); private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>(); private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>(); private sprites = new Map<number, Sprite>();
private spawnFlags?: Map<number, number>;
private sceneOwners?: Map<string, string>; private sceneOwners?: Map<string, string>;
constructor(bytes: Uint8Array) { constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes); this.reader = new RomReader(bytes);
@@ -432,6 +433,44 @@ export class CrystalAssets {
} }
return flags; return flags;
} }
/** The engine flag (ENGINE_FLYPOINT_*) of a wVisitedSpawns bit, from the
* ROM's EngineFlags; undefined for spawn points without one. */
spawnEngineFlag(spawn: number): number | undefined {
if (!this.spawnFlags) {
const r = this.reader;
this.spawnFlags = new Map();
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
const entry = SYMBOLS.engineFlags + index * 3,
offset = r.word(entry) - RAM.visitedSpawns,
mask = r.byte(entry + 2);
if (offset >= 0 && offset < 4)
this.spawnFlags.set(offset * 8 + Math.log2(mask), index);
}
}
return this.spawnFlags.get(spawn);
}
/** SpawnPoints: a SPAWN_* point's map and position. */
spawnPoint(spawn: number): { map: string; x: number; y: number } {
const r = this.reader,
entry = SYMBOLS.spawnPoints + spawn * 4;
return {
map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
x: r.byte(entry + 2),
y: r.byte(entry + 3),
};
}
/** IsSpawnPoint: the SPAWN_* index of a map, or -1. */
spawnIndex(map: string | null): number {
const r = this.reader;
if (map)
for (
let entry = SYMBOLS.spawnPoints, index = 0;
r.byte(entry) !== 0xff;
entry += 4, index++
)
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === map) return index;
return -1;
}
/** SpriteMovementData for a SPRITEMOVEDATA_* type: the movement /** SpriteMovementData for a SPRITEMOVEDATA_* type: the movement
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */ * function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
spriteMovement(type: number): { spriteMovement(type: number): {
@@ -461,6 +500,17 @@ export class CrystalAssets {
r.byte(entry + 2), r.byte(entry + 2),
); );
} }
/** GetIcon: a species' two-frame party menu icon (ReadMonMenuIcon and
* IconPointers), eight tiles. */
monIcon(species: number): Uint8Array {
const r = this.reader;
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
const pointer = r.near(
OVERWORLD.IconPointers + icon * 2,
r.bank(OVERWORLD.IconPointers),
);
return r.slice(pointer, 8 * 16);
}
/** GetSprite: overworld sprites, and Pokémon drawn from their party /** GetSprite: overworld sprites, and Pokémon drawn from their party
* menu icons (GetMonSprite). Variable sprites are resolved by the world * menu icons (GetMonSprite). Variable sprites are resolved by the world
* from wVariableSprites first; day-care Pokémon are not ported. */ * from wVariableSprites first; day-care Pokémon are not ported. */
@@ -472,9 +522,7 @@ export class CrystalAssets {
// LoadOverworldMonIcon: eight tiles, two frames in the down and up // LoadOverworldMonIcon: eight tiles, two frames in the down and up
// standing slots, with palette 0. // standing slots, with palette 0.
const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON); const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON);
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1); const sprite = { graphics: this.monIcon(species), palette: 0, walking: true };
const pointer = r.near(OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers));
const sprite = { graphics: r.slice(pointer, 8 * 16), palette: 0, walking: true };
this.sprites.set(id, sprite); this.sprites.set(id, sprite);
return sprite; return sprite;
} }
+16
View File
@@ -158,6 +158,7 @@ export const RAM = {
surfingPlayerState: 0xd1eb, surfingPlayerState: 0xd1eb,
strengthSpecies: 0xd1ef, strengthSpecies: 0xd1ef,
curHPAnimDeltaHP: 0xd1f3, curHPAnimDeltaHP: 0xd1f3,
visitedSpawns: 0xdca5,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */ /** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3, moneyTemp: 0xffc3,
} as const; } as const;
@@ -170,6 +171,7 @@ export const EVENTS = {
playersHouseDoll2: 1859, playersHouseDoll2: 1859,
playersHouseBigDoll: 1860, playersHouseBigDoll: 1860,
wallOpenedInAerodactylChamber: 809, wallOpenedInAerodactylChamber: 809,
wallOpenedInKabutoChamber: 807,
} as const; } as const;
/** Engine flag indices named directly by translated routines. */ /** Engine flag indices named directly by translated routines. */
@@ -238,6 +240,19 @@ export const OVERWORLD = {
AlreadySurfingText: 0xc9b3, AlreadySurfingText: 0xc9b3,
"EscapeRopeOrDig.CantUseDigText": 0xcc26, "EscapeRopeOrDig.CantUseDigText": 0xcc26,
"TeleportFunction.CantUseTeleportText": 0xccb6, "TeleportFunction.CantUseTeleportText": 0xccb6,
"FlyFunction.FlyScript": 0xcaa3,
"FlyFunction.ReturnFromFly": 0xcacb,
"EscapeRopeOrDig.UsedEscapeRopeScript": 0xcc2b,
"EscapeRopeOrDig.UsedDigScript": 0xcc35,
"TeleportFunction.TeleportScript": 0xccbb,
FlyFromAnim: 0x8caed,
FlyToAnim: 0x8cb33,
HideSprites: 0x3016,
SkipUpdateMapSprites: 0x154f1,
WaitSFX: 0x3c55,
Flypoints: 0x91c5e,
"TownMapBubble.Where": 0x91bf4,
RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0,
"Softboiled_MilkDrinkFunction.ItemCantUseOnMonText": 0xf44a, "Softboiled_MilkDrinkFunction.ItemCantUseOnMonText": 0xf44a,
RuinsOfAlphAerodactylChamber_MapAttributes: 0x958e8, RuinsOfAlphAerodactylChamber_MapAttributes: 0x958e8,
AskCutScript: 0xd1a9, AskCutScript: 0xd1a9,
@@ -474,6 +489,7 @@ export const MENU = {
PPsIncreasedText: 0xf734, PPsIncreasedText: 0xf734,
BicycleEffect: 0xee08, BicycleEffect: 0xee08,
EvoStoneEffect: 0xee0f, EvoStoneEffect: 0xee0f,
EscapeRopeEffect: 0xf44f,
VitaminEffect: 0xee3d, VitaminEffect: 0xee3d,
RareCandyEffect: 0xef14, RareCandyEffect: 0xef14,
ItemStatRoseText: 0xeea6, ItemStatRoseText: 0xeea6,
+103
View File
@@ -0,0 +1,103 @@
import type { RomReader } from "../rom/reader";
import { OVERWORLD } from "../rom/profile";
import { OW } from "../world/constants";
import { landmarkName, type ScriptEffect } from "./interpreter";
import { readString, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** The buttons _FlyMap reads: A, B, and up and down (hJoyLast). */
export const FLY_MAP_BUTTONS: readonly string[] = ["a", "b", "up", "down"];
/** What the Fly map shows. The original draws the Town Map and its bubble
* in tiles; here they are presented natively. */
export interface FlyMapView {
kanto: boolean;
/** TownMapBubble: "Where?" and the selected flypoint's landmark name. */
where: string;
name: string;
/** The selected flypoint's landmark, where TownMapMon's icon stands
* (GetMapCursorCoordinates). */
cursor: number;
/** The Fly user, drawn as its party icon. */
species: number;
/** TownMapPlayerIcon's landmark; absent on the Johto map shown in Kanto
* before Indigo Plateau, which skips the player icon. */
player?: number;
}
/** What the Fly map needs from the world. */
export interface FlyMapHost {
reader: RomReader;
text(): TextContext;
/** GetWorldMapLocation for the current map, with the backup map's
* landmark for LANDMARK_SPECIAL rooms (FlyMap). */
landmark(): number;
/** HasVisitedSpawn: the wVisitedSpawns bit of a SPAWN_* point. */
visited(spawn: number): boolean;
}
/** Flypoints: a flypoint's landmark and spawn point. */
const flypoint = (reader: RomReader, index: number) => ({
landmark: reader.byte(OVERWORLD.Flypoints + index * 2),
spawn: reader.byte(OVERWORLD.Flypoints + index * 2 + 1),
});
/** _FlyMap and FlyMap (engine/pokegear/pokegear.asm): the region's Town
* Map with the flypoints visited so far. Up and down cycle through them
* with wrapping, A flies to the selected one and B cancels. Returns the
* SPAWN_* point, or -1 for B. Each screen is a `flymap` effect answered
* with a button. */
export function* flyMap(host: FlyMapHost, species: number): Gen<number> {
const r = host.reader;
const landmark = host.landmark();
// .CheckRegion: landmarks from KANTO_LANDMARK on are Kanto's.
let kanto = landmark >= OW.KANTO_LANDMARK;
let player: number | undefined = landmark;
// .KantoFlyMap: Kanto's map needs Indigo Plateau's flypoint; otherwise
// .NoKanto shows Johto's, without the player's icon.
if (kanto && !host.visited(OW.SPAWN_INDIGO)) {
kanto = false;
player = undefined;
}
const [start, end] = kanto
? [OW.KANTO_FLYPOINT, OW.NUM_FLYPOINTS - 1]
: [OW.JOHTO_FLYPOINT, OW.KANTO_FLYPOINT - 1];
// New Bark Town or Indigo Plateau is the default, visited or not.
let cursor = kanto ? end : start;
const where = readString(r, OVERWORLD["TownMapBubble.Where"], host.text());
// .ScrollNext and .ScrollPrev skip flypoints not visited yet. The
// original loops forever when none is; the default's region always has
// one by the time Fly can be used, so that case is refused instead.
const scroll = (step: number) => {
for (let count = 0; count <= end - start; count++) {
cursor =
step > 0
? cursor === end
? start
: cursor + 1
: cursor === start
? end
: cursor - 1;
if (host.visited(flypoint(r, cursor).spawn)) return;
}
throw new Error("No flypoint has been visited.");
};
for (;;) {
const point = flypoint(r, cursor);
const view: FlyMapView = {
kanto,
where,
name: landmarkName(r, point.landmark, host.text()),
cursor: point.landmark,
species,
...(player !== undefined && { player }),
};
const button = yield { kind: "flymap", view };
if (button === "b") return -1;
if (button === "a") return point.spawn;
if (button === "up") scroll(1);
else if (button === "down") scroll(-1);
}
}
+96 -17
View File
@@ -52,6 +52,7 @@ import { readMovement, type MovementStep } from "./movement";
import { PcSession } from "./pc"; import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom"; import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear"; import type { PokegearView } from "./pokegear";
import type { FlyMapView } from "./fly-map";
import type { TrainerCardView } from "./trainer-card"; import type { TrainerCardView } from "./trainer-card";
import { import {
defaultOptions, defaultOptions,
@@ -116,6 +117,10 @@ export interface ScriptState {
/** wBackupWarpNumber/wBackupMapGroup/wBackupMapNumber: where a warp /** wBackupWarpNumber/wBackupMapGroup/wBackupMapNumber: where a warp
* numbered -1 leads, recorded on arrival or by warpmod and elevators. */ * numbered -1 leads, recorded on arrival or by warpmod and elevators. */
backupWarp: { map: string; warp: number } | null; backupWarp: { map: string; warp: number } | null;
/** wDigWarpNumber/wDigMapGroup/wDigMapNumber: the outdoor warp last
* used to enter an indoor map (EnterMapWarp's SaveDigWarp), where Dig
* and the Escape Rope lead. */
digWarp: { map: string; warp: number } | null;
/** wStatusFlags bits without engine flag numbers: STATUSFLAGS_FLASH_F /** wStatusFlags bits without engine flag numbers: STATUSFLAGS_FLASH_F
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */ * (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
flash: boolean; flash: boolean;
@@ -150,6 +155,7 @@ export const emptyScriptState = (): ScriptState => ({
rivalName: "???", rivalName: "???",
playerState: 0, playerState: 0,
backupWarp: null, backupWarp: null,
digWarp: null,
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
@@ -182,12 +188,28 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
rivalName: state.rivalName, rivalName: state.rivalName,
playerState: state.playerState, playerState: state.playerState,
backupWarp: state.backupWarp && { ...state.backupWarp }, backupWarp: state.backupWarp && { ...state.backupWarp },
digWarp: state.digWarp && { ...state.digWarp },
flash: state.flash, flash: state.flash,
noWildEncounters: state.noWildEncounters, noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem }, registeredItem: { ...state.registeredItem },
options: { ...state.options }, options: { ...state.options },
}); });
/** A field move's context from TrySurfOW, TryCutOW and similar: the party
* member CheckPartyMove found (wCurPartyMon), GetSurfType's
* wSurfingPlayerState, and the block Cut or Whirlpool replaces
* (wCutWhirlpoolOverworldBlockAddr and its replacement). */
export interface FieldMoveContext {
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
/** wDefaultSpawnpoint, chosen on the Fly map or by Teleport. */
spawn?: number;
/** wNextWarp and its map, copied from wDigWarpNumber by Dig and the
* Escape Rope, at the position GetWarpDestCoords gives it. */
warp?: { map: string; x: number; y: number };
}
/** Object operands keep the script's numbering: 0 is the player, 2+ are map /** Object operands keep the script's numbering: 0 is the player, 2+ are map
* objects. LAST_TALKED is resolved before an effect is emitted. */ * objects. LAST_TALKED is resolved before an effect is emitted. */
export const PLAYER = 0; export const PLAYER = 0;
@@ -265,9 +287,25 @@ export type ScriptEffect =
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */ /** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
tutorial?: boolean; tutorial?: boolean;
} }
/** newloadmap and warp: the world enters this map after the script /** warp and newloadmap: the world enters this map after the script
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). */ * ends, facing `facing` (warpfacing) or down (SpawnInFacingDown).
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction } * MAPSETUP_DOOR (`door`) keeps the facing unless the arrival tile turns
* the player down (CheckWarpFacingDown). newloadmap stops the script as
* StopScript does; when more than an end follows, it resumes at `resume`
* on the new map (ScriptEvents restarts it after EnterMap). */
| {
kind: "warp";
map: string;
x: number;
y: number;
facing?: Direction;
door?: boolean;
resume?: number;
}
/** FlyFromAnim and FlyToAnim: the Fly user's party icon flies off from
* the player's place or lands on it, with the leaves; HideSprites and
* the animation loops leave no other object on screen meanwhile. */
| { kind: "fly"; landing: boolean; species: number }
/** A vertical menu (the elevator's floors); answered with the chosen /** A vertical menu (the elevator's floors); answered with the chosen
* item's index, or -1 when cancelled with B. */ * item's index, or -1 when cancelled with B. */
| { | {
@@ -292,6 +330,8 @@ export type ScriptEffect =
/** The options screen, answered with the button pressed; without a /** The options screen, answered with the button pressed; without a
* view it closes. */ * view it closes. */
| { kind: "options"; view?: OptionsView } | { kind: "options"; view?: OptionsView }
/** _FlyMap: answered with the button pressed. */
| { kind: "flymap"; view: FlyMapView }
/** BankOfMom's deposit or withdrawal box with the textbox's pages, /** BankOfMom's deposit or withdrawal box with the textbox's pages,
* answered with the button pressed (MomBankButton). */ * answered with the button pressed (MomBankButton). */
| { kind: "mombank"; pages: string[]; view: MomBankView } | { kind: "mombank"; pages: string[]; view: MomBankView }
@@ -495,15 +535,7 @@ export class ScriptRun {
/** wCallerContact's second script, copied by CheckSpecialPhoneCall /** wCallerContact's second script, copied by CheckSpecialPhoneCall
* from the queued SpecialPhoneCallList entry. */ * from the queued SpecialPhoneCallList entry. */
callerScript?: number; callerScript?: number;
/** A field move's context from TrySurfOW, TryCutOW and similar: the fieldMove?: FieldMoveContext;
* party member CheckPartyMove found (wCurPartyMon), GetSurfType's
* wSurfingPlayerState, and the block Cut or Whirlpool replaces
* (wCutWhirlpoolOverworldBlockAddr and its replacement). */
fieldMove?: {
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
};
/** An item used from the pack: UseItem names it in wStringBuffer1 and /** An item used from the pack: UseItem names it in wStringBuffer1 and
* CopyName1 copies it to wStringBuffer2. */ * CopyName1 copies it to wStringBuffer2. */
usedItem?: number; usedItem?: number;
@@ -556,7 +588,8 @@ export class ScriptRun {
let p = start, let p = start,
value = 0, value = 0,
battleResult = 0, battleResult = 0,
spawn = 0; // wDefaultSpawnpoint: set by Fly and Teleport before their scripts.
spawn = this.options.fieldMove?.spawn ?? 0;
let wild = this.options.wild; let wild = this.options.wild;
let trainer: { classId: number; id: number } | undefined; let trainer: { classId: number; id: number } | undefined;
// LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too. // LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too.
@@ -849,6 +882,24 @@ export class ScriptRun {
value = Number(this.options.wild !== undefined); value = Number(this.options.wild !== undefined);
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) { } else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature. // StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
} else if (
routine === OVERWORLD.FlyFromAnim ||
routine === OVERWORLD.FlyToAnim
) {
yield* flush();
yield synchronous({
kind: "fly",
landing: routine === OVERWORLD.FlyToAnim,
species: this.state.party[this.curPartyMon]?.species ?? 0,
});
} else if (
routine === OVERWORLD.HideSprites ||
routine === OVERWORLD.SkipUpdateMapSprites ||
routine === OVERWORLD["FlyFunction.ReturnFromFly"]
) {
// Sprite and font reloading around the Fly animations: the
// `fly` effect hides the objects while it plays, and the
// player stands again afterwards (RespawnPlayer).
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never)) } else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
throw new UnsupportedScript(offset, opcode); throw new UnsupportedScript(offset, opcode);
break; break;
@@ -1046,15 +1097,42 @@ export class ScriptRun {
canLose = false; canLose = false;
break; break;
case 0x8a: { case 0x8a: {
byte(); // newloadmap MAPSETUP_WARP // newloadmap: StopScript, then the map setup the byte names.
const method = byte();
let destination: { map: string; x: number; y: number };
if (method === OW.MAPSETUP_DOOR) {
// EnterMapWarp with the wNextWarp that Dig and the Escape Rope
// copied from wDigWarpNumber.
const warp = this.options.fieldMove?.warp;
if (!warp)
throw new UnsupportedScript(offset, opcode, " (no next warp)");
destination = warp;
} else if (
method === OW.MAPSETUP_WARP ||
method === OW.MAPSETUP_TELEPORT ||
method === OW.MAPSETUP_FLY
) {
// EnterMapSpawnPoint: SpawnPoints[wDefaultSpawnpoint].
const entry = SYMBOLS.spawnPoints + spawn * 4; const entry = SYMBOLS.spawnPoints + spawn * 4;
yield synchronous({ destination = {
kind: "warp",
map: `${r.byte(entry)}:${r.byte(entry + 1)}`, map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
x: r.byte(entry + 2), x: r.byte(entry + 2),
y: r.byte(entry + 3), y: r.byte(entry + 3),
};
} else throw new UnsupportedScript(offset, opcode, " (map setup)");
// The script resumes where it stopped; a caller's stack is not
// carried over to the new map here.
if (stack.length)
throw new UnsupportedScript(offset, opcode, " (inside a call)");
yield* flush();
yield synchronous({
kind: "warp",
...destination,
door: method === OW.MAPSETUP_DOOR || undefined,
resume:
r.byte(p) === 0x91 || r.byte(p) === 0x93 ? undefined : p,
}); });
break; return;
} }
case 0x1c: case 0x1c:
value = this.readVar(byte(), offset); value = this.readVar(byte(), offset);
@@ -2130,6 +2208,7 @@ export class ScriptRun {
case OVERWORLD.UpdatePlayerSprite: case OVERWORLD.UpdatePlayerSprite:
case OVERWORLD.PlayMapMusic: case OVERWORLD.PlayMapMusic:
case OVERWORLD.UpdateTimePals: case OVERWORLD.UpdateTimePals:
case OVERWORLD.WaitSFX:
// The native renderer draws the player from wPlayerState each frame; // The native renderer draws the player from wPlayerState each frame;
// music and palette fades are presentation. // music and palette fades are presentation.
return value; return value;
+53 -9
View File
@@ -13,6 +13,7 @@ import type { GameTime } from "../world/game-time";
import { PartyEvolution, evoStoneCompatible } from "./evolution"; import { PartyEvolution, evoStoneCompatible } from "./evolution";
import { import {
copyScriptState, copyScriptState,
type FieldMoveContext,
type MartEntry, type MartEntry,
type ScriptEffect, type ScriptEffect,
type ScriptHost, type ScriptHost,
@@ -21,6 +22,8 @@ import {
import { Pokegear } from "./pokegear"; import { Pokegear } from "./pokegear";
import { TrainerCard } from "./trainer-card"; import { TrainerCard } from "./trainer-card";
import { OptionsMenu, menuAccount } from "./options"; import { OptionsMenu, menuAccount } from "./options";
import { flyMap } from "./fly-map";
import { OW } from "../world/constants";
import { readString, readText, type TextContext } from "./text"; import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined; type Input = number | string | undefined;
@@ -86,19 +89,17 @@ export interface MenuCursors {
/** The world's verdict on a field move chosen from the party menu: a /** The world's verdict on a field move chosen from the party menu: a
* script to run once the menu closes (with the context the world checked), * script to run once the menu closes (with the context the world checked),
* the messages of a failed attempt, or a notice for what is not ported. */ * the messages of a failed attempt, a notice for what is not ported, or
* Fly's map to choose a destination from. */
export type MenuFieldMove = export type MenuFieldMove =
| { | {
script: number; script: number;
fieldMove: { fieldMove: FieldMoveContext;
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
};
wild?: { species: number; level: number }; wild?: { species: number; level: number };
} }
| { texts: number[] } | { texts: number[] }
| { notice: string }; | { notice: string }
| { flyMap: true };
/** What the start menu needs from the world. */ /** What the start menu needs from the world. */
export interface StartMenuHost { export interface StartMenuHost {
@@ -114,6 +115,12 @@ export interface StartMenuHost {
landmark(): number; landmark(): number;
/** A field move's checks for party slot `mon`, on the menu's draft. */ /** A field move's checks for party slot `mon`, on the menu's draft. */
fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove; fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove;
/** FlyFunction after the Fly map: flying to SPAWN_* point `spawn`. */
flyTo(state: ScriptState, mon: number, spawn: number): MenuFieldMove;
/** EscapeRopeFunction's checks, on the menu's draft. */
escapeRope(state: ScriptState): MenuFieldMove;
/** HasVisitedSpawn: the wVisitedSpawns bit of a SPAWN_* point. */
visitedSpawn(state: ScriptState, spawn: number): boolean;
/** The script host a Pokégear phone call runs with. */ /** The script host a Pokégear phone call runs with. */
scriptHost(): ScriptHost; scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */ /** GetWorldMapLocation for any map. */
@@ -512,14 +519,36 @@ export class StartMenuRun {
yield* this.softboiledMilkDrink(slot); yield* this.softboiledMilkDrink(slot);
return false; return false;
} }
const result = this.host.fieldMove(this.state, slot, move); let result = this.host.fieldMove(this.state, slot, move);
if ("flyMap" in result) {
// FlyFunction.TryFly: B on the map (.illegal) returns to the party
// menu like a failure.
const spawn = yield* flyMap(
{
reader: this.reader,
text: () => this.text(),
landmark: () => {
const landmark = this.host.landmark();
const backup = this.state.backupWarp?.map;
return landmark === OW.LANDMARK_SPECIAL && backup
? this.host.landmarkOf(backup)
: landmark;
},
visited: (spawn) => this.host.visitedSpawn(this.state, spawn),
},
this.state.party[slot].species,
);
if (spawn < 0) return false;
result = this.host.flyTo(this.state, slot, spawn);
}
if ("script" in result) { if ("script" in result) {
this.deferred = result.script; this.deferred = result.script;
this.deferredMove = result; this.deferredMove = result;
return true; return true;
} }
if ("notice" in result) yield* this.notice(result.notice); if ("notice" in result) yield* this.notice(result.notice);
else for (const text of result.texts) yield* this.print(text); else if ("texts" in result)
for (const text of result.texts) yield* this.print(text);
return false; return false;
} }
@@ -893,6 +922,21 @@ export class StartMenuRun {
return yield* this.vitaminEffect(use); return yield* this.vitaminEffect(use);
case MENU.EvoStoneEffect: case MENU.EvoStoneEffect:
return yield* this.evoStoneEffect(use); return yield* this.evoStoneEffect(use);
case MENU.EscapeRopeEffect: {
// EscapeRopeEffect: UseDisposableItem once EscapeRopeOrDig queued
// its script; a failure prints nothing before Oak's message.
const result = this.host.escapeRope(this.state);
if ("notice" in result) {
yield* this.notice(result.notice);
return "unported";
}
if (!("script" in result)) return false;
this.useDisposableItem(use);
this.deferred = result.script;
this.deferredItem = item;
this.deferredMove = result;
return true;
}
case MENU.BicycleEffect: { case MENU.BicycleEffect: {
const script = this.host.bicycleScript(this.state, this.usingSelect); const script = this.host.bicycleScript(this.state, this.usingSelect);
if (script === undefined) return false; if (script === undefined) return false;
+9
View File
@@ -167,6 +167,15 @@ export const OW = {
FLASH: 0x94, FLASH: 0x94,
SWEET_SCENT: 0xe6, SWEET_SCENT: 0xe6,
ROCK_SMASH: 0xf9, ROCK_SMASH: 0xf9,
MAPSETUP_WARP: 0xf1,
MAPSETUP_TELEPORT: 0xf4,
MAPSETUP_DOOR: 0xf5,
MAPSETUP_FLY: 0xfc,
NUM_SPAWNS: 28,
SPAWN_INDIGO: 13,
JOHTO_FLYPOINT: 0,
KANTO_FLYPOINT: 12,
NUM_FLYPOINTS: 24,
// SpriteMovementData (constants/map_object_constants.asm). // SpriteMovementData (constants/map_object_constants.asm).
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18, SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
BICYCLE: 7, BICYCLE: 7,
+208 -38
View File
@@ -48,11 +48,13 @@ import {
type MartEntry, type MartEntry,
type MartMode, type MartMode,
type ScriptEffect, type ScriptEffect,
type FieldMoveContext,
type ScriptHost, type ScriptHost,
type Portrait, type Portrait,
type ScriptState, type ScriptState,
} from "../scripts/interpreter"; } from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro"; import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { import {
StartMenuRun, StartMenuRun,
type MenuCursors, type MenuCursors,
@@ -141,6 +143,8 @@ export interface NativeSave {
playerState: number; playerState: number;
/** Where a warp numbered -1 leads (wBackupWarpNumber and its map). */ /** Where a warp numbered -1 leads (wBackupWarpNumber and its map). */
backupWarp: { map: string; warp: number } | null; backupWarp: { map: string; warp: number } | null;
/** Where Dig and the Escape Rope lead (wDigWarpNumber and its map). */
digWarp: { map: string; warp: number } | null;
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */ /** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
flash: boolean; flash: boolean;
noWildEncounters: boolean; noWildEncounters: boolean;
@@ -271,6 +275,8 @@ export type Choice =
| { kind: "trainercard"; view: TrainerCardView; selected: 0 } | { kind: "trainercard"; view: TrainerCardView; selected: 0 }
/** The options screen, answered with the button pressed. */ /** The options screen, answered with the button pressed. */
| { kind: "options"; view: OptionsView; selected: 0 } | { kind: "options"; view: OptionsView; selected: 0 }
/** The Fly map, answered with the button pressed. */
| { kind: "flymap"; view: FlyMapView; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */ /** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| { | {
kind: "martquantity"; kind: "martquantity";
@@ -288,7 +294,8 @@ const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" || choice.kind === "pokegear" ||
choice.kind === "mombank" || choice.kind === "mombank" ||
choice.kind === "trainercard" || choice.kind === "trainercard" ||
choice.kind === "options"; choice.kind === "options" ||
choice.kind === "flymap";
export interface Dialogue { export interface Dialogue {
pages: string[]; pages: string[];
index: number; index: number;
@@ -362,7 +369,10 @@ interface ActiveScript {
/** Scene scripts that change nothing are not re-run until state changes. */ /** Scene scripts that change nothing are not re-run until state changes. */
scene: boolean; scene: boolean;
/** newloadmap, warp or warpfacing: entered after the script commits. */ /** newloadmap, warp or warpfacing: entered after the script commits. */
warp?: { map: string; x: number; y: number; facing?: Direction }; warp?: Extract<ScriptEffect, { kind: "warp" }>;
/** The field move context, kept for a script resumed after newloadmap
* (wCurPartyMon and the rest stay in RAM across the map change). */
fieldMove?: FieldMoveContext;
/** warpcheck: the player's tile events run on the tick after the script. */ /** warpcheck: the player's tile events run on the tick after the script. */
warpCheck?: boolean; warpCheck?: boolean;
/** Routines that run before the script resumes with `result`: the /** Routines that run before the script resumes with `result`: the
@@ -406,6 +416,9 @@ const WARP_DIRECTIONS: Partial<Record<number, Direction>> = {
0x78: "up", 0x78: "up",
0x7e: "right", 0x7e: "right",
}; };
/** MOUNT_MOON_SQUARE and TIN_TOWER_ROOF (GROUP_MOUNT_MOON_SQUARE 15): outdoor
* maps inside indoor ones, which SaveDigWarp skips. */
const DIG_WARP_EXCLUDED = ["15:10", "15:12"];
/** CheckWarpFacingDown, also including the original unused variants. */ /** CheckWarpFacingDown, also including the original unused variants. */
const FACE_DOWN_ON_WARP = new Set([ const FACE_DOWN_ON_WARP = new Set([
0x71, 0x79, 0x7a, 0x73, 0x7b, 0x74, 0x7c, 0x75, 0x7d, 0x71, 0x79, 0x7a, 0x73, 0x7b, 0x74, 0x7c, 0x75, 0x7d,
@@ -537,7 +550,8 @@ const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) => const isTimed = (effect: ScriptEffect | undefined) =>
effect?.kind === "move" || effect?.kind === "move" ||
effect?.kind === "emote" || effect?.kind === "emote" ||
effect?.kind === "pause"; effect?.kind === "pause" ||
effect?.kind === "fly";
/** Deterministic native world rules. Rendering, browser APIs and emulator /** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */ * registers are deliberately absent; callers provide time and input. */
@@ -623,6 +637,8 @@ export class GameWorld {
* remainder, given here) runs on the first tick in the new map, which * remainder, given here) runs on the first tick in the new map, which
* reads no input. */ * reads no input. */
private warpSettle?: { script?: number }; private warpSettle?: { script?: number };
/** FlyFromAnim or FlyToAnim while it plays, with the frames shown. */
private flight?: { landing: boolean; species: number; frames: number };
constructor( constructor(
readonly assets: CrystalAssets, readonly assets: CrystalAssets,
@@ -773,8 +789,49 @@ export class GameWorld {
]; ];
}); });
} }
/** The Fly user's party icon while FlyFromAnim or FlyToAnim plays: its
* species and its offset from where the player's sprite stands, from
* SpriteAnimFunc_FlyFrom and SpriteAnimFunc_FlyTo frame by frame. */
get flyingIcon():
| { species: number; x: number; y: number; frames: number }
| undefined {
const flight = this.flight;
if (!flight) return undefined;
// Both start at the player's place (depixel 10, 10, 4, 0), FlyToAnim
// 168 pixels higher (depixel 31: OAM Y 252 wraps to above the screen).
let y = flight.landing ? 252 : 84,
radius = flight.landing ? 11 * 8 : 0,
angle = 0,
x = 0;
for (let frame = 0; frame < flight.frames; frame++) {
if (flight.landing) {
if (y === 84) break;
y = (y + 2) & 0xff;
const d = radius;
if (radius) radius -= 2;
x = this.sine(d, angle++ + 0x10);
} else {
if (!y) break;
// VAR2: the icon waits 64 frames, then rises two pixels a frame.
if (frame < 0x40) continue;
y -= 2;
const d = radius;
if (radius < 0x40) radius += 8;
x = this.sine(d, angle++ + 0x10);
}
}
return {
species: flight.species,
x,
y: ((y - 84 + 128) & 0xff) - 128,
frames: flight.frames,
};
}
/** Pixel positions for drawing, including partial steps. */ /** Pixel positions for drawing, including partial steps. */
poses(): Pose[] { poses(): Pose[] {
// HideSprites and the Fly animations' loops leave only their icon.
if (this.flight) return [];
const pose = ( const pose = (
actor: Actor, actor: Actor,
id: number, id: number,
@@ -1229,12 +1286,25 @@ export class GameWorld {
if (!arrival || !this.walkable(target, arrival.x, arrival.y)) if (!arrival || !this.walkable(target, arrival.x, arrival.y))
throw new Error("Warp destination is invalid."); throw new Error("Warp destination is invalid.");
let sourceState = this.state; let sourceState = this.state;
const outdoors =
this.map.environment === OW.TOWN || this.map.environment === OW.ROUTE,
indoors = [OW.INDOOR, OW.CAVE, OW.DUNGEON, OW.GATE].includes(
target.environment as never,
);
// EnterMapWarp's SaveDigWarp: any warp from an outdoor map into an
// indoor one (CheckIndoorMap) is where Dig and the Escape Rope lead,
// except from the outdoor Mount Moon Square and Tin Tower roof.
if (outdoors && indoors && !DIG_WARP_EXCLUDED.includes(this.mapId))
sourceState = {
...copyScriptState(sourceState),
digWarp: { map: this.mapId, warp: this.map.warps.indexOf(warp) + 1 },
};
// EnterMapWarp's SetSpawn: walking from an outdoor map into a Pokémon // EnterMapWarp's SetSpawn: walking from an outdoor map into a Pokémon
// Center records that outdoor map as the whiteout/blackout spawn. // Center records that outdoor map as the whiteout/blackout spawn.
if ( if (
(target.tileset === OW.TILESET_POKECENTER || (target.tileset === OW.TILESET_POKECENTER ||
target.tileset === OW.TILESET_POKECOM_CENTER) && target.tileset === OW.TILESET_POKECOM_CENTER) &&
(this.map.environment === OW.TOWN || this.map.environment === OW.ROUTE) outdoors
) )
sourceState = { sourceState = {
...copyScriptState(sourceState), ...copyScriptState(sourceState),
@@ -1602,18 +1672,28 @@ export class GameWorld {
: failed; : failed;
} }
case OW.FLY: case OW.FLY:
// .TryFly: the Fly map follows outdoors (flyTo).
return ( return (
badge(OW.ENGINE_STORMBADGE) ?? badge(OW.ENGINE_STORMBADGE) ?? (outdoors ? { flyMap: true } : failed)
(outdoors ? { notice: "The Fly map is not ported yet." } : failed)
); );
case OW.DIG: case OW.DIG:
return env === OW.CAVE || env === OW.DUNGEON return (
? { notice: "Digging out of a cave is not ported yet." } this.digWarpMove(state, mon, false) ?? {
: { texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]] }; texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]],
case OW.TELEPORT: }
return outdoors && state.lastSpawnMap !== null );
? { notice: "Teleport is not ported yet." } case OW.TELEPORT: {
: { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] }; // .TryTeleport: outdoors, when wLastSpawnMap is a spawn point.
const spawn = outdoors ? this.assets.spawnIndex(state.lastSpawnMap) : -1;
if (spawn < 0)
return { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
return (
this.unportedSpawn(spawn) ?? {
script: OVERWORLD["TeleportFunction.TeleportScript"],
fieldMove: { mon, spawn },
}
);
}
case OW.HEADBUTT: case OW.HEADBUTT:
return tile === OW.COLL_HEADBUTT_TREE || return tile === OW.COLL_HEADBUTT_TREE ||
tile === OW.COLL_HEADBUTT_TREE_1D tile === OW.COLL_HEADBUTT_TREE_1D
@@ -1641,6 +1721,57 @@ export class GameWorld {
return { notice: "This field move is not ported yet." }; return { notice: "This field move is not ported yet." };
} }
/** FlyFunction after _FlyMap returned SPAWN_* point `spawn`. */
private flyTo(mon: number, spawn: number): MenuFieldMove {
// .illegal: no flypoint leads beyond NUM_SPAWNS.
if (spawn >= OW.NUM_SPAWNS) return { texts: [] };
return (
this.unportedSpawn(spawn) ?? {
script: OVERWORLD["FlyFunction.FlyScript"],
fieldMove: { mon, spawn },
}
);
}
private unportedSpawn(spawn: number): MenuFieldMove | undefined {
return this.assets.isPorted(this.assets.spawnPoint(spawn).map)
? undefined
: { notice: "This destination is not ported yet." };
}
/** EscapeRopeOrDig: in a cave or dungeon with a wDigWarpNumber, the
* Escape Rope (`rope`) or Dig leads back out through MAPSETUP_DOOR.
* Undefined when it cannot be used here. */
private digWarpMove(
state: ScriptState,
mon: number,
rope: boolean,
): MenuFieldMove | undefined {
const env = this.map.environment,
dig = state.digWarp;
if ((env !== OW.CAVE && env !== OW.DUNGEON) || !dig) return undefined;
if (!this.assets.isPorted(dig.map))
return { notice: "This destination is not ported yet." };
// GetWarpDestCoords: the warp's own position on its map.
const arrival = this.assets.map(dig.map).warps[dig.warp - 1];
if (!arrival || arrival.targetWarp === 0xff)
throw new Error("The dig warp's destination is not supported.");
// .escaperope: SpecialKabutoChamber opens the chamber's wall.
if (
rope &&
this.map.attributes ===
OVERWORLD.RuinsOfAlphKabutoChamber_MapAttributes
)
state.events.add(EVENTS.wallOpenedInKabutoChamber);
return {
script: rope
? OVERWORLD["EscapeRopeOrDig.UsedEscapeRopeScript"]
: OVERWORLD["EscapeRopeOrDig.UsedDigScript"],
fieldMove: {
mon,
warp: { map: dig.map, x: arrival.x, y: arrival.y },
},
};
}
/** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship /** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship
* counts as Johto; LANDMARK_SPECIAL rooms use the backup map's landmark. */ * counts as Johto; LANDMARK_SPECIAL rooms use the backup map's landmark. */
private inKanto(): boolean { private inKanto(): boolean {
@@ -1825,11 +1956,7 @@ export class GameWorld {
usedItem, usedItem,
}: { }: {
usedItem?: number; usedItem?: number;
fieldMove?: { fieldMove?: FieldMoveContext;
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
};
committedPosition?: { x: number; y: number }; committedPosition?: { x: number; y: number };
scene?: boolean; scene?: boolean;
wild?: { species: number; level: number }; wild?: { species: number; level: number };
@@ -1878,6 +2005,7 @@ export class GameWorld {
moving: new Map(), moving: new Map(),
scene, scene,
sighted, sighted,
fieldMove,
}; };
this.pump(); this.pump();
} }
@@ -1919,6 +2047,14 @@ export class GameWorld {
landmark: () => this.assets.map(this.mapId).landmark, landmark: () => this.assets.map(this.mapId).landmark,
fieldMove: (state, mon, move) => fieldMove: (state, mon, move) =>
this.menuFieldMove(state, mon, move), this.menuFieldMove(state, mon, move),
flyTo: (_, mon, spawn) => this.flyTo(mon, spawn),
// EscapeRopeOrDig's .failescaperope prints nothing.
escapeRope: (state) =>
this.digWarpMove(state, 0, true) ?? { texts: [] },
visitedSpawn: (state, spawn) => {
const flag = this.assets.spawnEngineFlag(spawn);
return flag !== undefined && state.flags.has(flag);
},
scriptHost: () => this.host(PLAYER), scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark, landmarkOf: (mapId) => this.assets.map(mapId).landmark,
gameTime: () => this.gameTime, gameTime: () => this.gameTime,
@@ -2199,13 +2335,21 @@ export class GameWorld {
return true; return true;
} }
case "warp": case "warp":
script.warp = { script.warp = effect;
map: effect.map,
x: effect.x,
y: effect.y,
facing: effect.facing,
};
return false; return false;
case "flymap":
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "flymap", view: effect.view, selected: 0 },
};
return true;
case "fly":
// FlyFromAnim's loop runs 129 frames after a DelayFrame, FlyToAnim's
// 65: 65 and 33 overworld ticks.
this.flight = { ...effect, frames: 0 };
script.waitTicks = effect.landing ? 33 : 65;
return true;
case "warpcheck": case "warpcheck":
script.warpCheck = true; script.warpCheck = true;
return false; return false;
@@ -2653,19 +2797,24 @@ export class GameWorld {
private finishScript() { private finishScript() {
const script = this.script!; const script = this.script!;
if (script.warp) { if (script.warp) {
const { map, x, y } = script.warp; const { map, x, y, facing, door } = script.warp;
if ( const target = this.assets.isPorted(map)
!this.assets.isPorted(map) || ? this.assets.map(map)
!this.walkable(this.assets.map(map), x, y) : undefined;
) if (!target || !this.walkable(target, x, y))
throw new Error("Script warp destination is not supported."); throw new Error("Script warp destination is not supported.");
// Whiteout healing/money and destination callbacks commit together. // Whiteout healing/money and destination callbacks commit together.
const entry = this.prepareMap(map, "warp", script.run.state); const entry = this.prepareMap(map, "warp", script.run.state);
// MAPSETUP_DOOR has no SpawnInFacingDown: only the arrival tile
// turns the player down (CheckWarpFacingDown in RefreshPlayerSprite).
const keep =
door &&
!FACE_DOWN_ON_WARP.has(this.assets.collision(target, x, y) ?? 0);
this.player = { this.player = {
...this.player, ...this.player,
x, x,
y, y,
direction: script.warp.facing ?? "down", direction: facing ?? (keep ? this.player.direction : "down"),
motion: undefined, motion: undefined,
}; };
this.applyMap(entry); this.applyMap(entry);
@@ -2697,6 +2846,14 @@ export class GameWorld {
} }
if (!script.scene || script.run.deferred !== undefined) if (!script.scene || script.run.deferred !== undefined)
this.sceneCheck = true; this.sceneCheck = true;
// newloadmap stopped the script: ScriptEvents restarts it on the new
// map before the map's own scene scripts.
if (script.warp?.resume !== undefined) {
this.startScript(script.warp.resume, PLAYER, {
fieldMove: script.fieldMove,
});
return;
}
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors; if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
if (script.run.deferred !== undefined) if (script.run.deferred !== undefined)
this.startScript( this.startScript(
@@ -2732,6 +2889,7 @@ export class GameWorld {
this.picture = undefined; this.picture = undefined;
this.portrait = undefined; this.portrait = undefined;
this.evolution = undefined; this.evolution = undefined;
this.flight = undefined;
if (script.sighted !== undefined) if (script.sighted !== undefined)
this.refusedSighting = { this.refusedSighting = {
map: this.mapId, map: this.mapId,
@@ -3244,10 +3402,13 @@ export class GameWorld {
else this.advanceMovement(1); else this.advanceMovement(1);
} else if (isTimed(script.effect)) { } else if (isTimed(script.effect)) {
// Pauses and emotes keep the map loop running; applymovement and // Pauses and emotes keep the map loop running; applymovement and
// open text freeze other objects. // open text freeze other objects, and the Fly animations run their
this.stepObjects(); // own loops.
if (this.flight) this.flight.frames += 2;
else this.stepObjects();
if (--script.waitTicks <= 0) { if (--script.waitTicks <= 0) {
if (script.effect?.kind === "emote") this.emote = undefined; if (script.effect?.kind === "emote") this.emote = undefined;
this.flight = undefined;
this.pump(); this.pump();
} }
} }
@@ -3778,7 +3939,9 @@ export class GameWorld {
(dialogue.choice.kind === "trainercard" && (dialogue.choice.kind === "trainercard" &&
!TRAINER_CARD_BUTTONS.includes(answer as never)) || !TRAINER_CARD_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "options" && (dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never)) !OPTIONS_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "flymap" &&
!FLY_MAP_BUTTONS.includes(answer as never))
) )
return; return;
this.townMap = false; this.townMap = false;
@@ -3848,6 +4011,7 @@ export class GameWorld {
lastSpawnMap: state.lastSpawnMap, lastSpawnMap: state.lastSpawnMap,
playerState: state.playerState, playerState: state.playerState,
backupWarp: state.backupWarp && { ...state.backupWarp }, backupWarp: state.backupWarp && { ...state.backupWarp },
digWarp: state.digWarp && { ...state.digWarp },
flash: state.flash, flash: state.flash,
noWildEncounters: state.noWildEncounters, noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem }, registeredItem: { ...state.registeredItem },
@@ -3945,11 +4109,14 @@ export class GameWorld {
OW.PLAYER_SURF, OW.PLAYER_SURF,
OW.PLAYER_SURF_PIKA, OW.PLAYER_SURF_PIKA,
].includes(save.playerState as never) || ].includes(save.playerState as never) ||
(save.backupWarp !== null && [save.backupWarp, save.digWarp].some(
(!save.backupWarp || (warp) =>
typeof save.backupWarp.map !== "string" || warp !== null &&
!/^\d{1,2}:\d{1,3}$/.test(save.backupWarp.map) || (!warp ||
!integer(save.backupWarp.warp, 1, 254))) || typeof warp.map !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(warp.map) ||
!integer(warp.warp, 1, 254)),
) ||
typeof save.flash !== "boolean" || typeof save.flash !== "boolean" ||
typeof save.noWildEncounters !== "boolean" || typeof save.noWildEncounters !== "boolean" ||
!save.registeredItem || !save.registeredItem ||
@@ -4018,6 +4185,7 @@ export class GameWorld {
lastSpawnMap: save.lastSpawnMap, lastSpawnMap: save.lastSpawnMap,
playerState: save.playerState, playerState: save.playerState,
backupWarp: save.backupWarp && { ...save.backupWarp }, backupWarp: save.backupWarp && { ...save.backupWarp },
digWarp: save.digWarp && { ...save.digWarp },
flash: save.flash, flash: save.flash,
noWildEncounters: save.noWildEncounters, noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem }, registeredItem: { ...save.registeredItem },
@@ -4123,6 +4291,7 @@ export function migrateSave(
...(!Object.hasOwn(save, "rivalName") && { rivalName: "???" }), ...(!Object.hasOwn(save, "rivalName") && { rivalName: "???" }),
...(!Object.hasOwn(save, "playerState") && { playerState: 0 }), ...(!Object.hasOwn(save, "playerState") && { playerState: 0 }),
...(!Object.hasOwn(save, "backupWarp") && { backupWarp: null }), ...(!Object.hasOwn(save, "backupWarp") && { backupWarp: null }),
...(!Object.hasOwn(save, "digWarp") && { digWarp: null }),
...(!Object.hasOwn(save, "flash") && { flash: false }), ...(!Object.hasOwn(save, "flash") && { flash: false }),
...(!Object.hasOwn(save, "noWildEncounters") && { ...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false, noWildEncounters: false,
@@ -4174,6 +4343,7 @@ export function migrateSave(
dailyReset: null, dailyReset: null,
playerState: 0, playerState: 0,
backupWarp: null, backupWarp: null,
digWarp: null,
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
+29 -3
View File
@@ -55,6 +55,7 @@ function showWorld(world: GameWorld) {
showPokegear(world); showPokegear(world);
showTrainerCard(world); showTrainerCard(world);
showOptions(world); showOptions(world);
showFlyMap(world);
get("town-map-view").hidden = !world.townMap; get("town-map-view").hidden = !world.townMap;
if (world.townMap) if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world); drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -72,6 +73,7 @@ function showWorld(world: GameWorld) {
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) || (dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
dialogue.choice?.kind === "trainercard" || dialogue.choice?.kind === "trainercard" ||
dialogue.choice?.kind === "options" || dialogue.choice?.kind === "options" ||
dialogue.choice?.kind === "flymap" ||
Boolean(mart) || Boolean(mart) ||
world.townMap || world.townMap ||
Boolean(world.statsScreen); Boolean(world.statsScreen);
@@ -128,10 +130,11 @@ function showChoice(choice: Choice | undefined) {
else if ( else if (
choice.kind === "pokegear" || choice.kind === "pokegear" ||
choice.kind === "trainercard" || choice.kind === "trainercard" ||
choice.kind === "options" choice.kind === "options" ||
choice.kind === "flymap"
) { ) {
// The Pokégear, the trainer card and the options read the controls // The Pokégear, the trainer card, the options and the Fly map read
// directly. // the controls directly.
container.replaceChildren(); container.replaceChildren();
container.hidden = true; container.hidden = true;
} else if (choice.kind === "yesno") } else if (choice.kind === "yesno")
@@ -987,6 +990,29 @@ function showOptions(world: GameWorld) {
); );
} }
let flyMapKey = "";
/** _FlyMap: TownMapBubble's "Where?" and flypoint name over the Town Map,
* with the Fly user's icon on the flypoint. */
function showFlyMap(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "flymap" ? choice.view : undefined;
get("fly-map").hidden = !view;
if (!view) {
flyMapKey = "";
return;
}
const key = JSON.stringify(view);
if (key === flyMapKey) return;
flyMapKey = key;
const where = document.createElement("span"),
arrows = document.createElement("span");
where.textContent = `${view.where}\n${view.name}`;
arrows.textContent = "▲▼";
arrows.setAttribute("aria-hidden", "true");
get("fly-map-bubble").replaceChildren(where, arrows);
drawTownMap(get<HTMLCanvasElement>("fly-map-canvas"), world, view);
}
let trainerCardKey = ""; let trainerCardKey = "";
/** TrainerCard (engine/menus/trainer_card.asm): the top half with the /** TrainerCard (engine/menus/trainer_card.asm): the top half with the
* name, ID, money and card picture, then page 1's Pokédex count and play * name, ID, money and card picture, then page 1's Pokédex count and play
+1
View File
@@ -35,6 +35,7 @@ export function renderShell(root: HTMLElement) {
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div> <div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div> <div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div> <div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
<div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div> <div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart"> <div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p> <p id="mart-money" class="mart-money"></p>
+40 -1
View File
@@ -541,7 +541,8 @@ dialog h2 {
.intro-screen:not([hidden]) ~ .minimap-overlay, .intro-screen:not([hidden]) ~ .minimap-overlay,
.pokegear:not([hidden]) ~ .minimap-overlay, .pokegear:not([hidden]) ~ .minimap-overlay,
.trainer-card:not([hidden]) ~ .minimap-overlay, .trainer-card:not([hidden]) ~ .minimap-overlay,
.options-screen:not([hidden]) ~ .minimap-overlay { .options-screen:not([hidden]) ~ .minimap-overlay,
.fly-map:not([hidden]) ~ .minimap-overlay {
display: none; display: none;
} }
.pokepic { .pokepic {
@@ -1348,6 +1349,44 @@ dialog dd {
align-content: start; align-content: start;
gap: 8px; gap: 8px;
} }
/* _FlyMap: the Town Map under TownMapBubble's "Where?" and the
selected flypoint, with the up/down arrows. */
.fly-map {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
gap: 8px;
padding: 10px;
color: var(--ink);
background: var(--cream);
}
.fly-map[hidden] {
display: none;
}
.fly-map-bubble {
justify-self: stretch;
display: grid;
grid-template-columns: 1fr auto;
margin: 0;
padding: 4px 10px;
border: 5px double var(--ink);
white-space: pre;
}
.fly-map-bubble span:last-child {
grid-row: span 2;
align-self: center;
}
.fly-map canvas {
width: auto;
height: 100%;
max-width: 100%;
min-height: 0;
image-rendering: pixelated;
object-fit: contain;
}
.options-screen li { .options-screen li {
display: grid; display: grid;
grid-template-columns: 1.4em 13ch auto; grid-template-columns: 1.4em 13ch auto;
+326
View File
@@ -0,0 +1,326 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK_TOWN = "24:4",
PLAYERS_HOUSE_1F = "24:6",
ROUTE_29 = "24:3",
CHERRYGROVE_CITY = "26:3",
ROUTE_31 = "26:2",
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
const SPAWN_NEW_BARK = 14,
SPAWN_CHERRYGROVE = 15,
SPAWN_VIOLET = 16;
const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13,
DIG = 0x5b,
TELEPORT = 0x64;
const PIDGEOT = 18,
ESCAPE_ROPE = 0x13;
describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope", () => {
/** A world with one Pokémon that knows `moves`, on `map`. */
function fixture(
moves: number[],
map: string,
x: number,
y: number,
direction: Direction = "down",
ported: string[] = [],
) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const isPorted = assets.isPorted.bind(assets);
assets.isPorted = (id) => isPorted(id) || ported.includes(id);
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
world.state = emptyScriptState();
const mon = generatePartyMon(assets.pokemon, {
species: PIDGEOT,
level: 40,
item: 0,
otId: 1,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
});
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
mon.pp = [10, 10, 10, 10];
mon.nickname = "BIRDY";
world.state.party = [mon];
world.state.scenes[NEW_BARK_TOWN] = 1;
// Route 31's NEWMAP callback would otherwise queue Mom's worried call.
world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
world.restore({ ...world.save(), map, x, y, direction, objects: [] });
return { assets, world };
}
const visit = (world: GameWorld, ...spawns: number[]) => {
for (const spawn of spawns)
world.state.flags.add(world.assets.spawnEngineFlag(spawn)!);
};
/** Acknowledge pages up to the next choice; returns the pages seen. */
function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 500; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index];
if (page && pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
function pick(world: GameWorld, label: string) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`);
const index = choice.items.findIndex((item) => item.startsWith(label));
expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
/** Start → POKéMON → BIRDY → `move`. */
function useMove(world: GameWorld, move: string) {
expect(world.openStartMenu()).toBe(true);
until(world);
pick(world, "POKéMON");
pick(world, "BIRDY");
return pick(world, move);
}
/** B out of every menu, then let any queued script finish. */
function close(world: GameWorld) {
for (let i = 0; i < 10 && world.dialogue?.choice; i++)
world.advanceDialogue();
return settle(world);
}
const flyMap = (world: GameWorld) => {
const choice = world.dialogue?.choice;
return choice?.kind === "flymap" ? choice.view : undefined;
};
it("needs the Storm Badge and an outdoor map to open the Fly map", () => {
const { world } = fixture([FLY], PLAYERS_HOUSE_1F, 7, 4);
// A failure prints its message and returns to the party menu.
expect(useMove(world, "FLY")).toEqual([
"Sorry! A new BADGE\nis required.",
"Choose a POKéMON.",
]);
close(world);
world.state.flags.add(ENGINE_STORMBADGE);
expect(useMove(world, "FLY")[0]).toBe("Can't use that\nhere.");
close(world);
expect(world.mapId).toBe(PLAYERS_HOUSE_1F);
expect(world.notice, world.diagnostic).toBe("");
});
it("cycles through visited flypoints on the Fly map, and B returns to the party", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE);
useMove(world, "FLY");
// New Bark Town is the default, with the player's icon on Route 29.
expect(flyMap(world)).toMatchObject({
kanto: false,
where: "Where?",
name: "NEW BARK TOWN",
cursor: 1,
player: world.map.landmark,
species: PIDGEOT,
});
// Up goes to the next visited flypoint, down back; both wrap around.
world.moveCursor("up");
expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY");
world.moveCursor("up");
expect(flyMap(world)?.name).toBe("NEW BARK TOWN");
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY");
// Other buttons do nothing.
world.choose("start");
expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY");
world.advanceDialogue(); // B
expect(world.dialogue?.choice?.kind).toBe("menu");
close(world);
expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]);
expect(world.notice, world.diagnostic).toBe("");
});
it("flies to a flypoint with the party icon's animations", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10, "left");
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE);
useMove(world, "FLY");
world.moveCursor("up");
world.choose("a");
// The menu closes and FlyScript starts: the icon replaces every object.
expect(world.dialogue).toBeUndefined();
expect(world.flyingIcon).toMatchObject({ species: PIDGEOT, x: 0, y: 0 });
expect(world.poses()).toEqual([]);
const heights: number[] = [];
while (world.busy && world.mapId === ROUTE_29) {
heights.push(world.flyingIcon?.y ?? 0);
world.tick();
}
// SpriteAnimFunc_FlyFrom: 64 frames in place, then two pixels a frame
// up to the top of the screen.
expect(heights.slice(0, 32).every((y) => y === 0)).toBe(true);
expect(Math.min(...heights)).toBe(-84);
// MAPSETUP_FLY: Cherrygrove's spawn point, facing down; the icon then
// comes down from above the screen onto the player.
expect([world.mapId, world.x, world.y]).toEqual([CHERRYGROVE_CITY, 29, 4]);
expect(world.flyingIcon?.y).toBeLessThan(-80);
const landing: number[] = [];
while (world.busy) {
landing.push(world.flyingIcon?.y ?? 0);
world.tick();
}
expect(landing.at(-1)).toBe(0);
expect(world.flyingIcon).toBeUndefined();
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
direction: "down",
});
expect(world.notice, world.diagnostic).toBe("");
// The flight commits: a save is on Cherrygrove.
expect(world.save()).toMatchObject({ map: CHERRYGROVE_CITY, x: 29, y: 4 });
});
it("refuses to fly to a flypoint that is not ported", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_VIOLET);
useMove(world, "FLY");
world.moveCursor("down"); // wraps to Violet City
expect(flyMap(world)?.name).toBe("VIOLET CITY");
world.choose("a");
until(world);
expect(world.notice).toBe("This destination is not ported yet.");
close(world);
expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]);
});
it("teleports outdoors to the last Pokémon Center's town", () => {
const { world } = fixture([TELEPORT], ROUTE_29, 50, 10);
// No spawn recorded yet, or indoors: the move cannot be used.
expect(useMove(world, "TELEPORT")[0]).toBe("Can't use that\nhere.");
close(world);
world.state.lastSpawnMap = CHERRYGROVE_CITY;
const shown = [...useMove(world, "TELEPORT"), ...close(world)];
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("Return to the last\nPOKéMON CENTER.");
expect([world.mapId, world.x, world.y]).toEqual([CHERRYGROVE_CITY, 29, 4]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
direction: "down",
});
expect(world.busy).toBe(false);
});
it("refuses Teleport indoors", () => {
const { world } = fixture([TELEPORT], PLAYERS_HOUSE_1F, 7, 4);
world.state.lastSpawnMap = CHERRYGROVE_CITY;
expect(useMove(world, "TELEPORT")[0]).toBe("Can't use that\nhere.");
close(world);
expect(world.mapId).toBe(PLAYERS_HOUSE_1F);
});
/** Walk from Route 31 into Dark Cave, which records the dig warp. */
function enterDarkCave(moves: number[]) {
const fixtureWorld = fixture(moves, ROUTE_31, 34, 6, "up", [
ROUTE_31,
DARK_CAVE_VIOLET_ENTRANCE,
]);
const { world } = fixtureWorld;
for (let tick = 0; tick < 40 && world.mapId === ROUTE_31; tick++)
world.tick("up");
settle(world);
expect(world.mapId).toBe(DARK_CAVE_VIOLET_ENTRANCE);
// EnterMapWarp's SaveDigWarp: Route 31's third warp, the cave entrance.
expect(world.state.digWarp).toEqual({ map: ROUTE_31, warp: 3 });
return fixtureWorld;
}
it("digs out of a cave to the entrance last used", () => {
const { world } = enterDarkCave([DIG]);
// The dig warp is saved with the game.
const saved = world.save();
expect(saved.digWarp).toEqual({ map: ROUTE_31, warp: 3 });
world.restore(saved);
const shown = [...useMove(world, "DIG"), ...close(world)];
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("BIRDY used\nDIG!");
// MAPSETUP_DOOR onto the cave entrance, which faces the player down;
// standing on it, the player then walks out.
expect(world.mapId).toBe(ROUTE_31);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
direction: "down",
});
for (let tick = 0; tick < 40 && world.y < 6; tick++) world.tick();
expect([world.x, world.y]).toEqual([34, 6]);
});
it("explains that Dig only works in caves", () => {
const { world } = fixture([DIG], ROUTE_29, 50, 10);
world.state.digWarp = { map: ROUTE_31, warp: 3 };
expect(useMove(world, "DIG")[0]).toMatch(/^Can't use that/);
close(world);
expect(world.mapId).toBe(ROUTE_29);
});
it("uses an Escape Rope in a cave, and only there", () => {
const { world } = enterDarkCave([]);
world.state.bag.items = [{ item: ESCAPE_ROPE, quantity: 2 }];
const useRope = () => {
expect(world.openStartMenu()).toBe(true);
until(world);
pick(world, "PACK");
const choice = world.dialogue?.choice;
if (choice?.kind !== "pack") throw new Error("Expected the pack.");
const pocket = choice.pockets.findIndex(({ entries }) =>
entries.some((entry) => entry.item === ESCAPE_ROPE),
);
world.choosePackEntry(
pocket,
choice.pockets[pocket].entries.findIndex(
(entry) => entry.item === ESCAPE_ROPE,
),
);
until(world);
return pick(world, "USE");
};
const shown = [...useRope(), ...close(world)];
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/used an\nESCAPE ROPE/);
expect(world.mapId).toBe(ROUTE_31);
expect(world.state.bag.items).toEqual([{ item: ESCAPE_ROPE, quantity: 1 }]);
// Outdoors it fails silently, then Oak warns; the rope is kept.
for (let tick = 0; tick < 40 && world.busy; tick++) world.tick();
expect(useRope()).toEqual([
"OAK: CHRIS!\nThis isn't the\ntime to use that!",
]);
close(world);
expect(world.state.bag.items).toEqual([{ item: ESCAPE_ROPE, quantity: 1 }]);
});
it("does not record a dig warp into Mount Moon Square or Tin Tower's roof", () => {
const { assets } = fixture([], ROUTE_29, 50, 10);
// SaveDigWarp's exclusions name these two outdoor maps.
expect(assets.map("15:10").location).toBe("Mt.Moon");
expect(assets.map("15:12").location).toBe("Tin Tower");
expect(assets.map("15:10").environment).not.toBe(3);
});
});
+50 -1
View File
@@ -11,7 +11,14 @@ test.beforeEach(() =>
async function start( async function start(
page: Page, page: Page,
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" = false, room:
| boolean
| "poster"
| "center"
| "candy"
| "pokegear"
| "mom"
| "fly" = false,
) { ) {
await page.route("**/src/game/runtime.ts", async (route) => { await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(), const response = await route.fetch(),
@@ -431,6 +438,48 @@ test("the options change the battle style and the menu account", async ({
}); });
}); });
test("Fly picks a visited town on the Fly map and lands there", async ({
page,
}) => {
await start(page, "fly");
const choices = page.locator("#dialogue-choice"),
flyMap = page.locator("#fly-map"),
bubble = page.locator("#fly-map-bubble");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^POKéMON/ }).click();
await choices.getByRole("option", { name: /^PIDGEOT/ }).click();
await choices.getByRole("option", { name: "FLY", exact: true }).click();
await expect(flyMap).toBeVisible();
await expect(page.locator("#native-dialogue")).toBeHidden();
await expect(bubble).toContainText("Where?");
await expect(bubble).toContainText("NEW BARK TOWN");
await expect(page.locator("#fly-map-canvas")).toHaveAttribute(
"aria-label",
"Fly map: NEW BARK TOWN, you are here",
);
// Up skips to the next visited flypoint; B returns to the party menu.
await page.keyboard.press("ArrowUp");
await expect(bubble).toContainText("CHERRYGROVE CITY");
await page.keyboard.press("KeyZ");
await expect(flyMap).toBeHidden();
await expect(page.locator("#dialogue-text")).toHaveText("Choose a POKéMON.");
await choices.getByRole("option", { name: /^PIDGEOT/ }).click();
await choices.getByRole("option", { name: "FLY", exact: true }).click();
await page.keyboard.press("ArrowUp");
await expect(bubble).toContainText("CHERRYGROVE CITY");
await page.keyboard.press("KeyX");
await expect(flyMap).toBeHidden();
await page.clock.runFor(5_000);
const screen = page.locator("#game-screen");
await expect(screen).toHaveAttribute("data-map", "26:3");
await expect(screen).toHaveAttribute("data-x", "29");
await expect(screen).toHaveAttribute("data-y", "4");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#game-notice")).toHaveText("");
const { state } = await backup(page);
expect([state.map, state.x, state.y]).toEqual(["26:3", 29, 4]);
});
test("Mom keeps the player's money with the digit box", async ({ page }) => { test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom"); await start(page, "mom");
const text = page.locator("#dialogue-text"), const text = page.locator("#dialogue-text"),
+22 -3
View File
@@ -8,10 +8,11 @@ import { generatePartyMon } from "../src/game/party/pokemon";
* level 13, with a Rare Candy); otherwise outdoors ("pokegear": with the * level 13, with a Rare Candy); otherwise outdoors ("pokegear": with the
* Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom" * Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom"
* faces Mom in the living room after the Mystery Egg, with her bank open * faces Mom in the living room after the Mystery Egg, with her bank open
* and ¥2300 saved. */ * and ¥2300 saved. "fly" stands outdoors with a Pidgeot that knows Fly,
* the Storm Badge, and New Bark Town and Cherrygrove City visited. */
export function foundationFixture( export function foundationFixture(
assets: CrystalAssets, assets: CrystalAssets,
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom", room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" | "fly",
) { ) {
const world = new GameWorld( const world = new GameWorld(
assets, assets,
@@ -54,6 +55,24 @@ export function foundationFixture(
// Walking outside clears the bedroom's temporary events. // Walking outside clears the bedroom's temporary events.
for (let event = 0; event < 8; event++) world.state.events.delete(event); for (let event = 0; event < 8; event++) world.state.events.delete(event);
} }
if (room === "fly") {
const mon = generatePartyMon(assets.pokemon, {
species: 18, // PIDGEOT
level: 40,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
mon.moves = [OW.FLY, 0, 0, 0];
mon.pp = [15, 0, 0, 0];
world.state.party = [mon];
world.state.flags.add(OW.ENGINE_STORMBADGE);
for (const spawn of [14, 15]) // SPAWN_NEW_BARK, SPAWN_CHERRYGROVE
world.state.flags.add(assets.spawnEngineFlag(spawn)!);
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "mom") { if (room === "mom") {
// EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM // EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM
// and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST. // and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST.
@@ -70,7 +89,7 @@ export function foundationFixture(
? ["24:7", 6, 1] ? ["24:7", 6, 1]
: room === "mom" : room === "mom"
? ["24:6", 7, 3] ? ["24:6", 7, 3]
: room && room !== "pokegear" : room && room !== "pokegear" && room !== "fly"
? ["24:7", 2, 2] ? ["24:7", 2, 2]
: ["24:4", 9, 6]; : ["24:4", 9, 6];
world.restore({ world.restore({