Ring with trainers' calls, rematch them and play the radio (Closes #30)
Every registered trainer's callee and caller scripts run, with the missing specials and commands (RandomPhoneWildMon, RandomUnseenWildMon, swarms, gettrainerclassname, Kenji's break, the Blue Card and Buena's password variables). Fight counts are saved, so rematches and gifts work on every open map, and the daily reset clears the phone's flags. Random calls follow CheckPhoneCall and the 20/10/5/3-minute call timer; received calls ring twice with the caller's name, and calls play their ringing, hang-up and no-signal sounds. Grass and fishing swarms use the swarm tables. The radio ports PlayRadioShow and every station frame by frame, with its music: the Pokégear's radio card tunes by knob, house radios play through MapRadio, and Buena's show takes the day's password for Blue Card points that her receptionist exchanges for prizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f30c3f450
commit
7c12c81a27
21 files changed
+2021
-64
No files matched your search
@@ -12,7 +12,7 @@ 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. 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. POKéDEX lists Pokémon in Johto order up to the last one you have seen. Open a seen Pokémon to read its number, category and footprint and hear its cry (CRY plays it again); once caught, its height, weight and two pages of text appear. 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), the sound (mono or stereo) and the menu account (the start menu's descriptions); text speed, battle scene, 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, the phone and the radio. Call Mom, Professor Elm or the trainers who gave you their number; they call back now and then with news, rematches, gifts and swarms. Tune the radio to Oak's Pokémon Talk, the Pokédex Show, Pokémon Music, the Lucky Channel or, from 6 PM, Buena's Password, whose password earns Blue Card points at the Radio Tower. House radios play too. 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. POKéDEX lists Pokémon in Johto order up to the last one you have seen. Open a seen Pokémon to read its number, category and footprint and hear its cry (CRY plays it again); once caught, its height, weight and two pages of text appear. 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), the sound (mono or stereo) and the menu account (the start menu's descriptions); text speed, battle scene, 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.
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ On Route 40, the Battle Tower takes a party of three different Pokémon holding
|
|||||||
|
|
||||||
The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported.
|
The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported.
|
||||||
|
|
||||||
Kanto, the League, Olivine's port and the S.S. Aqua, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
Kanto, the League, Olivine's port and the S.S. Aqua, the Day-Care's Eggs, trade evolutions, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -33,7 +33,9 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
|||||||
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic`, room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (its sounds and waits; the machine's sprites are not drawn), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic`, room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (its sounds and waits; the machine's sprites are not drawn), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
||||||
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the MAIL BOX (`MailboxPC`) and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
|
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the MAIL BOX (`MailboxPC`) and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
|
||||||
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`), MAIL (`MonMailAction`, `ComposeMailMessage`) and `SwitchItemsInBag`. `party/mail.ts` holds the `mailmsg` struct. |
|
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`), MAIL (`MonMailAction`, `ComposeMailMessage`) and `SwitchItemsInBag`. `party/mail.ts` holds the `mailmsg` struct. |
|
||||||
| `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. The radio card (`PokegearRadio_Joypad`, `AnimateTuningKnob`, `UpdateRadioStation`, `RadioChannels`, `ExitPokegearRadio_HandleMusic`) runs every frame. Screens are native. |
|
||||||
|
| `game/scripts/radio.ts` | `engine/pokegear/radio.asm`, `engine/pokegear/pokegear.asm` (`LoadStation_*`, `PlayRadio`, `PlayRadioStationPointers`), `data/radio/`, `engine/events/specials.asm` (`MapRadio`) | `PlayRadioShow` with every station and segment; ROM texts and tables by label (`RADIO` in the profile, verified). Unit tests check each station's lines, music and Buena's password; the browser test tunes the card. |
|
||||||
|
| Phone calls and swarms | `engine/phone/phone.asm` (`CheckPhoneCall`, `GetAvailableCallers`, `ChooseRandomCaller`, `RingTwice_StartCall`, `HangUp`), `engine/overworld/time.asm` (`CheckReceiveCallTimer`, `CheckDailyResetTimer`), `engine/overworld/wildmons.asm` (`RandomPhoneWildMon`, `RandomUnseenWildMon`, `_SwarmWildmonCheck`), `engine/events/fish.asm`, `engine/events/buena.asm`, `buena_menu.asm` | Random calls, ringing, the daily reset of the phone flags and Kenji's break, swarms, and Buena's specials. A local sweep runs every caller and callee script without a refusal; `tests/phone.test.ts` drives every open map's phone trainer through rematches. |
|
||||||
| `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/pokedex.ts`, `game/rom/pokedex.ts` | `engine/pokedex/pokedex.asm`, `pokedex_2.asm` (`DisplayDexEntry`, `GetDexEntryPointer`), `pokedex_3.asm` (`LoadQuestionMarkPic`), `data/pokemon/dex_order_new.asm`, `dex_entry_pointers.asm`, `dex_entries/`, `gfx/footprints.asm`, `engine/gfx/cgb_layouts.asm` (`_CGB_Pokedex`) | New mode's main screen and entry screen with their joypad rules: `Pokedex_ListingHandleDPadInput`, `Pokedex_InitCursorPosition`, `Pokedex_NextOrPreviousDexEntry`, `Pokedex_MoveArrowCursor` and `Pokedex_Page`. Strings come from the ROM; screens are native. Tests compare the order, all 251 entries and every footprint with the PRET source. CRY and the entries' cries play; AREA, PRNT, the option and search screens, Old and A to Z modes and Unown mode are not ported. `ui/pokedex.ts` draws the screen and the Pokédex tab. |
|
| `game/scripts/pokedex.ts`, `game/rom/pokedex.ts` | `engine/pokedex/pokedex.asm`, `pokedex_2.asm` (`DisplayDexEntry`, `GetDexEntryPointer`), `pokedex_3.asm` (`LoadQuestionMarkPic`), `data/pokemon/dex_order_new.asm`, `dex_entry_pointers.asm`, `dex_entries/`, `gfx/footprints.asm`, `engine/gfx/cgb_layouts.asm` (`_CGB_Pokedex`) | New mode's main screen and entry screen with their joypad rules: `Pokedex_ListingHandleDPadInput`, `Pokedex_InitCursorPosition`, `Pokedex_NextOrPreviousDexEntry`, `Pokedex_MoveArrowCursor` and `Pokedex_Page`. Strings come from the ROM; screens are native. Tests compare the order, all 251 entries and every footprint with the PRET source. CRY and the entries' cries play; AREA, PRNT, the option and search screens, Old and A to Z modes and Unown mode are not ported. `ui/pokedex.ts` draws the screen and the Pokédex tab. |
|
||||||
| `game/world/atlas.ts` | `data/maps/landmarks.asm`, `data/maps/flypoints.asm`, `engine/pokegear/pokegear.asm` (`_FlyMap`, `CheckIfVisitedFlypoint`), `home/map.asm` (`GetWorldMapLocation`) | The Map tab's atlas: each region's landmarks with their Town Map positions and ROM names, the player's landmark, the visited flypoints and the landmarks with ported maps. Kanto follows the Fly map's Indigo Plateau rule. `rendering/town-map.ts` draws it. Tests compare positions and flypoints with the PRET source. |
|
| `game/world/atlas.ts` | `data/maps/landmarks.asm`, `data/maps/flypoints.asm`, `engine/pokegear/pokegear.asm` (`_FlyMap`, `CheckIfVisitedFlypoint`), `home/map.asm` (`GetWorldMapLocation`) | The Map tab's atlas: each region's landmarks with their Town Map positions and ROM names, the player's landmark, the visited flypoints and the landmarks with ported maps. Kanto follows the Fly map's Indigo Plateau rule. `rendering/town-map.ts` draws it. Tests compare positions and flypoints with the PRET source. |
|
||||||
|
|||||||
+15
-3
@@ -25,6 +25,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
|||||||
| The Battle Tower | Done |
|
| The Battle Tower | Done |
|
||||||
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
||||||
| Music and sound effects (Web Audio) | Done (no move animation sounds) |
|
| Music and sound effects (Web Audio) | Done (no move animation sounds) |
|
||||||
|
| The phone's trainers, random calls, rematches and the radio | Done |
|
||||||
| 1. Complete the overworld foundation | Done |
|
| 1. Complete the overworld foundation | Done |
|
||||||
| 2. Opening story and first battle slice | Mostly done |
|
| 2. Opening story and first battle slice | Mostly done |
|
||||||
| 3. Complete Crystal | In progress (Johto done) |
|
| 3. Complete Crystal | In progress (Johto done) |
|
||||||
@@ -207,7 +208,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Calls follow `PokegearPhone_MakePhoneCall` and `MakePhoneCallFromPokegear`: "You're out of the service area." without service; otherwise two "……" rings, then `PhoneOutOfAreaScript` outside the callee's hours, `PhoneScript_JustTalkToThem` on the callee's map, or the callee script from `PhoneContacts`. The script runs on the start menu's draft, so its changes commit with the menu. The call ends with `HangUp`'s "Click!" and boops. Elm's and Mom's callee scripts run; trainer contacts are not reachable yet.
|
- Calls follow `PokegearPhone_MakePhoneCall` and `MakePhoneCallFromPokegear`: "You're out of the service area." without service; otherwise two "……" rings, then `PhoneOutOfAreaScript` outside the callee's hours, `PhoneScript_JustTalkToThem` on the callee's map, or the callee script from `PhoneContacts`. The script runs on the start menu's draft, so its changes commit with the menu. The call ends with `HangUp`'s "Click!" and boops. Elm's and Mom's callee scripts run; trainer contacts are not reachable yet.
|
||||||
- Mom's savings: `wMomsMoney` is saved as `momsMoney` (migrated by absence to 0). The interpreter gained `givemoney`, `takemoney`, `checkmoney` and `getmoney` for both accounts, `getcurlandmarkname`, `getlandmarkname` and `random`. After the catching lesson, calling Mom runs her lecture, which sets `ENGINE_MOM_SAVING_MONEY` on a yes; later calls report where the player is and her balance. While she saves, a won trainer battle sends a quarter of the prize to her (`WinTrainerBattle.give_money`, "Sent some to MOM!"), unless her account is full.
|
- Mom's savings: `wMomsMoney` is saved as `momsMoney` (migrated by absence to 0). The interpreter gained `givemoney`, `takemoney`, `checkmoney` and `getmoney` for both accounts, `getcurlandmarkname`, `getlandmarkname` and `random`. After the catching lesson, calling Mom runs her lecture, which sets `ENGINE_MOM_SAVING_MONEY` on a yes; later calls report where the player is and her balance. While she saves, a won trainer battle sends a quarter of the prize to her (`WinTrainerBattle.give_money`, "Sent some to MOM!"), unless her account is full.
|
||||||
- `HangUp` now shows "Click!" for its 20 frames and the boops' "……" for the remaining 120, instead of "Click!" for twice the whole duration.
|
- `HangUp` now shows "Click!" for its 20 frames and the boops' "……" for the remaining 120, instead of "Click!" for twice the whole duration.
|
||||||
- Not ported: the radio card (it is only obtainable in Goldenrod; it gives a notice); trainer callee scripts; random incoming calls; ringtones, music and the original card graphics.
|
- Since board #30: the radio card, trainer callee and caller scripts, random incoming calls and the ringing are ported (see below); the original card graphics are not.
|
||||||
|
|
||||||
## Implemented: Mom's bank and her purchases
|
## Implemented: Mom's bank and her purchases
|
||||||
|
|
||||||
@@ -469,7 +470,18 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Scripts: `playmusic`, `musicfadeout`, `playmapmusic`, `dontrestartmapmusic`, `cry` and `waitsfx` (which wait, frozen, as the original's busy loops do), `playsound`, `warpsound`, `specialsound` and `encountermusic`; the specials `RestartMapMusic`, `FadeOutMusic`, `PlayMapMusic`, `WaitSFX`, `PlaySlowCry`, `PlayCurMonCry` and `HealMachineAnim` (a sound per Poké Ball, MUSIC_HEAL, and its 30-frame and 80-frame waits); the text commands' fanfares (`TextSFX`). Text is shown a page at a time, so a sound started after text plays when its page appears.
|
- Scripts: `playmusic`, `musicfadeout`, `playmapmusic`, `dontrestartmapmusic`, `cry` and `waitsfx` (which wait, frozen, as the original's busy loops do), `playsound`, `warpsound`, `specialsound` and `encountermusic`; the specials `RestartMapMusic`, `FadeOutMusic`, `PlayMapMusic`, `WaitSFX`, `PlaySlowCry`, `PlayCurMonCry` and `HealMachineAnim` (a sound per Poké Ball, MUSIC_HEAL, and its 30-frame and 80-frame waits); the text commands' fanfares (`TextSFX`). Text is shown a page at a time, so a sound started after text plays when its page appears.
|
||||||
- Battles: `PlayBattleMusic`'s choice (wild by time and region, Rockets, gym leaders, rivals, the Champion, the roaming beasts), the wild Pokémon's and each sent-out Pokémon's cry from its side, fainting (`SFX_KINESIS`, `SFX_FAINT`, the player's cry), `PlayVictoryMusic`, the capture music, `SFX_RUN`, the low HP tone (`CheckDanger` with `ComputeHPBarPixels`) and the map's music again after `reloadmapafterbattle`.
|
- Battles: `PlayBattleMusic`'s choice (wild by time and region, Rockets, gym leaders, rivals, the Champion, the roaming beasts), the wild Pokémon's and each sent-out Pokémon's cry from its side, fainting (`SFX_KINESIS`, `SFX_FAINT`, the player's cry), `PlayVictoryMusic`, the capture music, `SFX_RUN`, the low HP tone (`CheckDanger` with `ComputeHPBarPixels`) and the map's music again after `reloadmapafterbattle`.
|
||||||
- Elsewhere: the intro's music and Wooper's cry, the talk and menu clicks (`PlayTalkObject`, `PromptButton`, `MenuClickSound`), `SFX_MENU`, bumping into walls, ledges, warps, Strength's boulders, the Bicycle's and Surf's music, the evolution music, cries and jingles, the Pokédex's cries and CRY, the PCs' boot, choice and shutdown sounds, the marts' transactions and the healing items' sounds.
|
- Elsewhere: the intro's music and Wooper's cry, the talk and menu clicks (`PlayTalkObject`, `PromptButton`, `MenuClickSound`), `SFX_MENU`, bumping into walls, ledges, warps, Strength's boulders, the Bicycle's and Surf's music, the evolution music, cries and jingles, the Pokédex's cries and CRY, the PCs' boot, choice and shutdown sounds, the marts' transactions and the healing items' sounds.
|
||||||
- Not presented yet: move animations' sounds (there are no move animations), the experience bar, Poké Ball throws and wobbles, the Game Corner's and Unown puzzle's sounds, the battle transition, the radio and phone ringing (board #30), and the main menu and title music (there is no title screen). Physical phones are not tested (manual card).
|
- Not presented yet: move animations' sounds (there are no move animations), the experience bar, Poké Ball throws and wobbles, the Game Corner's and Unown puzzle's sounds, the battle transition, and the main menu and title music (there is no title screen). Physical phones are not tested (manual card).
|
||||||
|
|
||||||
|
## Implemented: the phone's trainers, random calls and the radio (board #30)
|
||||||
|
|
||||||
|
- Every registered trainer's callee and caller scripts run (`engine/phone/scripts`): their news, bragging and lost-Pokémon stories (`RandomPhoneMon`, `RandomPhoneWildMon`, `RandomUnseenWildMon` with its morning-list bug), rematch requests, gift offers, swarms (`swarm`, `ActivateFishingSwarm`), Kenji's break (`VAR_KENJI_BREAK`, a 3-6 day countdown at the daily reset) and Buena. New commands: `swarm`, `gettrainerclassname`; `readvar` reads the Blue Card balance, Buena's password and Kenji's break, and `writevar` sets the balance. A sweep runs every contact's callee and caller scripts under many flag and clock states without a refusal.
|
||||||
|
- Rematches: the trainers' fight counts (`w*FightCount`) are saved, and every registered trainer on an open map battles again, gives gifts or talks when ready (`tests/phone.test.ts` drives all of them through five fight counts). `CheckDailyResetTimer` now also clears the daily rematch, phone gift and phone time-of-day flags.
|
||||||
|
- Random calls (`CheckTimeEvents`' `CheckPhoneCall`): not on a doorway, after `CheckReceiveCallTimer`'s 20, 10, 5 then 3 minutes (restarted at every load, as `StartMap` does), half the time, with service, from a registered caller whose calling hours include now and who is not on this map. `Script_ReceivePhoneCall` rings twice (`RingTwice_StartCall`: `SFX_CALL` with the caller's name box shown, cleared and shown again), runs the caller script and hangs up with `SFX_HANG_UP`. Mom's and Bill's calls name their caller too. Outgoing calls ring with `SFX_CALL`, and `SFX_NO_SIGNAL` without service.
|
||||||
|
- Swarms: Dunsparce's and Yanma's grass swarms use `SwarmGrassWildMons` on the map a trainer named (`_SwarmWildmonCheck`), and the Qwilfish and Remoraid fishing swarms switch the fish group (`GetFishGroupIndex`).
|
||||||
|
- The radio (`game/scripts/radio.ts`): `PlayRadioShow` and every station frame by frame: Oak's Pokémon Talk (routes, Pokémon, adverbs and adjectives, the Pokémon Channel jingle), the Pokédex Show, Pokémon Music and Let's All Sing (march or lullaby by weekday), the Lucky Channel (with `CheckLuckyNumberShowFlag`), Places and People, Buena's Password (from 6 PM, the day's password; midnight and off-air texts), Team Rocket's broadcast during the takeover, and the Unown, Poké Flute and Lake of Rage stations' music. Lines show for 100 frames and scroll as `PrintRadioLine` and `RadioScroll` do. Station songs become the map music and keep playing after the radio is left, as in the original (`ExitPokegearRadio_HandleMusic`).
|
||||||
|
- The Pokégear's radio card runs every frame: up and down turn the knob by two, `RadioChannels` picks the station the place can hear (Johto, the Ruins, the Lake of Rage area, Kanto with the EXPN card). House radios (`Radio1Script`, `Radio2Script`) run `MapRadio`: the station's name in quotes for 100 frames, then the station until A or B.
|
||||||
|
- Buena's show at the Radio Tower: `AskRememberPassword`, `BuenasPassword` (the category's three words, B disabled) and `BuenaPrize` (prizes and points beside the Blue Card balance, confirm, pay, receive). Tests: `tests/phone.test.ts` (rematches, a random call, no call without a caller, the daily reset, swarms, each station's lines and music, the radio card, a house radio, Buena's password and prizes) and `tests/radio-browser.spec.ts` (the radio card in the browser).
|
||||||
|
- Not ported: the radio card's tuning dial graphics (the frequency is shown as text), holding up or down to turn the knob continuously, the Poké Flute channel waking Snorlax (Kanto is closed), and calls that use the mobile adapter.
|
||||||
|
|
||||||
## Native battles: current state
|
## Native battles: current state
|
||||||
|
|
||||||
@@ -587,7 +599,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
|
|||||||
- [x] Touch battle commands, move list, PKMN party menu, PACK (Items and Balls pockets) and naming fields
|
- [x] Touch battle commands, move list, PKMN party menu, PACK (Items and Balls pockets) and naming fields
|
||||||
- [x] Start menu, party menu and pack outside battle (switching, moves, held items, healing and PP items, tossing, the Bicycle)
|
- [x] Start menu, party menu and pack outside battle (switching, moves, held items, healing and PP items, tossing, the Bicycle)
|
||||||
- [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box
|
- [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box
|
||||||
- [x] Pokégear clock, map and phone cards (the radio card waits for its stations)
|
- [x] Pokégear clock, map, phone and radio cards
|
||||||
- [x] Mom's bank: the amount box with its digit cursor, on keyboard, D-pad and on-screen buttons
|
- [x] Mom's bank: the amount box with its digit cursor, on keyboard, D-pad and on-screen buttons
|
||||||
- [x] Trainer card: both pages, with the ROM's card picture, leader faces and animated badges
|
- [x] Trainer card: both pages, with the ROM's card picture, leader faces and animated badges
|
||||||
- [x] Options screen, with the battle style and menu account in effect
|
- [x] Options screen, with the battle style and menu account in effect
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ const ramNames = {
|
|||||||
monOrItemNameBuffer: "wMonOrItemNameBuffer",
|
monOrItemNameBuffer: "wMonOrItemNameBuffer",
|
||||||
curFruit: "wCurFruit",
|
curFruit: "wCurFruit",
|
||||||
dailyFlags1: "wDailyFlags1",
|
dailyFlags1: "wDailyFlags1",
|
||||||
|
dailyRematchFlags: "wDailyRematchFlags",
|
||||||
|
dailyPhoneTimeOfDayFlags: "wDailyPhoneTimeOfDayFlags",
|
||||||
unusedDailyFlag: "wUnusedDailyFlag",
|
unusedDailyFlag: "wUnusedDailyFlag",
|
||||||
itemQuantityChange: "wItemQuantityChange",
|
itemQuantityChange: "wItemQuantityChange",
|
||||||
movementBuffer: "wMovementBuffer",
|
movementBuffer: "wMovementBuffer",
|
||||||
@@ -227,7 +229,7 @@ for (const table of ["SAVED_WRAM", "WRAM"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO and AUDIO entries are keyed by their PRET labels.
|
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO and AUDIO entries are keyed by their PRET labels.
|
||||||
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO"]) {
|
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO", "PHONE", "RADIO"]) {
|
||||||
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
||||||
if (block === undefined) throw new Error(`${table} symbol table is missing.`);
|
if (block === undefined) throw new Error(`${table} symbol table is missing.`);
|
||||||
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
||||||
@@ -359,7 +361,7 @@ const pretConstants = new Map();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts", "src/game/audio/constants.ts", "src/game/audio/battle-music.ts"]
|
const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts", "src/game/audio/constants.ts", "src/game/audio/battle-music.ts", "src/game/scripts/radio.ts"]
|
||||||
.map((file) => readFileSync(file, "utf8"))
|
.map((file) => readFileSync(file, "utf8"))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) {
|
for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) {
|
||||||
@@ -380,6 +382,12 @@ if (!profile.includes(`bugContestTimer: ${pretConstants.get("ENGINE_BUG_CONTEST_
|
|||||||
for (const [key, name] of [
|
for (const [key, name] of [
|
||||||
["rocketsInRadioTower", "ENGINE_ROCKETS_IN_RADIO_TOWER"],
|
["rocketsInRadioTower", "ENGINE_ROCKETS_IN_RADIO_TOWER"],
|
||||||
["rocketsInMahogany", "ENGINE_ROCKETS_IN_MAHOGANY"],
|
["rocketsInMahogany", "ENGINE_ROCKETS_IN_MAHOGANY"],
|
||||||
|
["expnCard", "ENGINE_EXPN_CARD"],
|
||||||
|
["rocketSignal", "ENGINE_ROCKET_SIGNAL_ON_CH20"],
|
||||||
|
["fishSwarm", "ENGINE_QWILFISH_SWARM"],
|
||||||
|
["buenasPassword", "ENGINE_BUENAS_PASSWORD"],
|
||||||
|
["dunsparceSwarm", "ENGINE_DUNSPARCE_SWARM"],
|
||||||
|
["yanmaSwarm", "ENGINE_YANMA_SWARM"],
|
||||||
])
|
])
|
||||||
if (!profile.includes(`${key}: ${pretConstants.get(name)},`))
|
if (!profile.includes(`${key}: ${pretConstants.get(name)},`))
|
||||||
throw new Error(`${name} does not match the reference.`);
|
throw new Error(`${name} does not match the reference.`);
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,7 @@ import {
|
|||||||
NUM_ENGINE_FLAGS,
|
NUM_ENGINE_FLAGS,
|
||||||
NUM_SPRITEMOVEDATA,
|
NUM_SPRITEMOVEDATA,
|
||||||
OVERWORLD,
|
OVERWORLD,
|
||||||
|
PHONE,
|
||||||
RAM,
|
RAM,
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
@@ -387,9 +388,15 @@ export class CrystalAssets {
|
|||||||
grassWildMons(
|
grassWildMons(
|
||||||
id: string,
|
id: string,
|
||||||
kanto = false,
|
kanto = false,
|
||||||
|
/** SwarmGrassWildMons in place of the region's table. */
|
||||||
|
swarm = false,
|
||||||
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
||||||
const r = this.reader;
|
const r = this.reader;
|
||||||
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
|
const table = swarm
|
||||||
|
? PHONE.SwarmGrassWildMons
|
||||||
|
: kanto
|
||||||
|
? OVERWORLD.KantoGrassWildMons
|
||||||
|
: SYMBOLS.johtoGrassWildMons;
|
||||||
for (let p = table, i = 0; i < 256; i++, p += 47) {
|
for (let p = table, i = 0; i < 256; i++, p += 47) {
|
||||||
const group = r.byte(p);
|
const group = r.byte(p);
|
||||||
if (group === 0xff) return undefined;
|
if (group === 0xff) return undefined;
|
||||||
@@ -410,9 +417,15 @@ export class CrystalAssets {
|
|||||||
waterWildMons(
|
waterWildMons(
|
||||||
id: string,
|
id: string,
|
||||||
kanto = false,
|
kanto = false,
|
||||||
|
/** SwarmWaterWildMons in place of the region's table. */
|
||||||
|
swarm = false,
|
||||||
): { rate: number; slots: { level: number; species: number }[] } | undefined {
|
): { rate: number; slots: { level: number; species: number }[] } | undefined {
|
||||||
const r = this.reader;
|
const r = this.reader;
|
||||||
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
|
const table = swarm
|
||||||
|
? PHONE.SwarmWaterWildMons
|
||||||
|
: kanto
|
||||||
|
? OVERWORLD.KantoWaterWildMons
|
||||||
|
: OVERWORLD.JohtoWaterWildMons;
|
||||||
for (let p = table, i = 0; i < 256; i++, p += 9) {
|
for (let p = table, i = 0; i < 256; i++, p += 9) {
|
||||||
const group = r.byte(p);
|
const group = r.byte(p);
|
||||||
if (group === 0xff) return undefined;
|
if (group === 0xff) return undefined;
|
||||||
@@ -434,7 +447,12 @@ export class CrystalAssets {
|
|||||||
flags: number[] = [];
|
flags: number[] = [];
|
||||||
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
|
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
|
||||||
const address = r.word(SYMBOLS.engineFlags + index * 3);
|
const address = r.word(SYMBOLS.engineFlags + index * 3);
|
||||||
if (address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag)
|
// CheckDailyResetTimer: wDailyFlags1 to wUnusedDailyFlag, then the
|
||||||
|
// rematch, phone item and phone time-of-day flags (4 bytes each).
|
||||||
|
if (
|
||||||
|
(address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag) ||
|
||||||
|
(address >= RAM.dailyRematchFlags && address < RAM.dailyPhoneTimeOfDayFlags + 4)
|
||||||
|
)
|
||||||
flags.push(index);
|
flags.push(index);
|
||||||
}
|
}
|
||||||
return flags;
|
return flags;
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export const RAM = {
|
|||||||
curFruit: 0xd03f,
|
curFruit: 0xd03f,
|
||||||
dailyFlags1: 0xdc1e,
|
dailyFlags1: 0xdc1e,
|
||||||
unusedDailyFlag: 0xdc21,
|
unusedDailyFlag: 0xdc21,
|
||||||
|
dailyRematchFlags: 0xdc4c,
|
||||||
|
dailyPhoneTimeOfDayFlags: 0xdc54,
|
||||||
itemQuantityChange: 0xd10c,
|
itemQuantityChange: 0xd10c,
|
||||||
movementBuffer: 0xd007,
|
movementBuffer: 0xd007,
|
||||||
hiddenItemID: 0xd040,
|
hiddenItemID: 0xd040,
|
||||||
@@ -211,6 +213,12 @@ export const ENGINE_FLAGS = {
|
|||||||
bugContestTimer: 17,
|
bugContestTimer: 17,
|
||||||
rocketsInRadioTower: 19,
|
rocketsInRadioTower: 19,
|
||||||
rocketsInMahogany: 23,
|
rocketsInMahogany: 23,
|
||||||
|
expnCard: 3,
|
||||||
|
rocketSignal: 14,
|
||||||
|
fishSwarm: 82,
|
||||||
|
buenasPassword: 95,
|
||||||
|
dunsparceSwarm: 160,
|
||||||
|
yanmaSwarm: 161,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||||
@@ -1065,6 +1073,130 @@ export const OPTIONS = {
|
|||||||
"Options_MenuAccount.On": 0xe44f6,
|
"Options_MenuAccount.On": 0xe44f6,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** The phone's calls and their specials (engine/phone/phone.asm,
|
||||||
|
* engine/overworld/wildmons.asm, engine/events/specials.asm), keyed by
|
||||||
|
* their PRET labels. */
|
||||||
|
export const PHONE = {
|
||||||
|
ActivateFishingSwarm: 0xc3fc,
|
||||||
|
MapRadio: 0xc355,
|
||||||
|
InitCallReceiveDelay: 0x113e5,
|
||||||
|
RandomUnseenWildMon: 0x2a4ab,
|
||||||
|
"RandomUnseenWildMon.JustSawSomeRareMonText": 0x2a51a,
|
||||||
|
RandomPhoneWildMon: 0x2a51f,
|
||||||
|
JohtoGrassWildMons: 0x2a5e9,
|
||||||
|
KantoGrassWildMons: 0x2b274,
|
||||||
|
SwarmGrassWildMons: 0x2b8d0,
|
||||||
|
SwarmWaterWildMons: 0x2b92f,
|
||||||
|
Script_ReceivePhoneCall: 0x90241,
|
||||||
|
RingTwice_StartCall: 0x9026f,
|
||||||
|
HangUp: 0x902eb,
|
||||||
|
AskRememberPassword: 0x4ae12,
|
||||||
|
BuenasPassword: 0x8af6b,
|
||||||
|
BuenaPrize: 0x8afd4,
|
||||||
|
"BuenaPrize.BuenaAskWhichPrizeText": 0x8b072,
|
||||||
|
"BuenaPrize.BuenaIsThatRightText": 0x8b077,
|
||||||
|
"BuenaPrize.BuenaHereYouGoText": 0x8b07c,
|
||||||
|
"BuenaPrize.BuenaNotEnoughPointsText": 0x8b081,
|
||||||
|
"BuenaPrize.BuenaNoRoomText": 0x8b086,
|
||||||
|
"BuenaPrize.BuenaComeAgainText": 0x8b08b,
|
||||||
|
"PrintBlueCardBalance.Points_string": 0x8b0ca,
|
||||||
|
BuenaPrizeItems: 0x8b15e,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The radio (engine/pokegear/radio.asm, pokegear.asm's stations):
|
||||||
|
* its texts and tables, keyed by their PRET labels. */
|
||||||
|
export const RADIO = {
|
||||||
|
OPT_IntroText1: 0xb8820,
|
||||||
|
OPT_IntroText2: 0xb8825,
|
||||||
|
OPT_IntroText3: 0xb882a,
|
||||||
|
OPT_OakText1: 0xb882f,
|
||||||
|
OPT_OakText2: 0xb8834,
|
||||||
|
OPT_OakText3: 0xb8839,
|
||||||
|
OPT_MaryText1: 0xb884f,
|
||||||
|
"OaksPKMNTalk8.Adverbs": 0xb8869,
|
||||||
|
"OaksPKMNTalk9.Adjectives": 0xb88fe,
|
||||||
|
OPT_PokemonChannelText: 0xb898e,
|
||||||
|
OaksPKMNTalkRoutes: 0xb87f2,
|
||||||
|
"OaksPKMNTalkRoutes.End": 0xb8810,
|
||||||
|
PokedexShowText: 0xb8b30,
|
||||||
|
BenIntroText1: 0xb8baa,
|
||||||
|
BenIntroText2: 0xb8baf,
|
||||||
|
BenIntroText3: 0xb8bb4,
|
||||||
|
FernIntroText1: 0xb8bb9,
|
||||||
|
FernIntroText2: 0xb8bbe,
|
||||||
|
BenFernText1: 0xb8bc3,
|
||||||
|
BenFernText2A: 0xb8bc8,
|
||||||
|
BenFernText2B: 0xb8bcd,
|
||||||
|
BenFernText3A: 0xb8bd2,
|
||||||
|
BenFernText3B: 0xb8bd7,
|
||||||
|
LC_Text1: 0xb8c7e,
|
||||||
|
LC_Text2: 0xb8c83,
|
||||||
|
LC_Text3: 0xb8c88,
|
||||||
|
LC_Text4: 0xb8c8d,
|
||||||
|
LC_Text5: 0xb8c92,
|
||||||
|
LC_Text6: 0xb8c97,
|
||||||
|
LC_Text7: 0xb8c9c,
|
||||||
|
LC_Text8: 0xb8ca1,
|
||||||
|
LC_Text9: 0xb8ca6,
|
||||||
|
LC_Text10: 0xb8cab,
|
||||||
|
LC_Text11: 0xb8cb0,
|
||||||
|
LC_DragText1: 0xb8cb5,
|
||||||
|
LC_DragText2: 0xb8cba,
|
||||||
|
PnP_Text1: 0xb8ce3,
|
||||||
|
PnP_Text2: 0xb8ce8,
|
||||||
|
PnP_Text3: 0xb8ced,
|
||||||
|
PnP_Text4: 0xb8d51,
|
||||||
|
PnP_Text5: 0xb8e23,
|
||||||
|
"PeoplePlaces5.Adjectives": 0xb8d7d,
|
||||||
|
"PeoplePlaces7.Adjectives": 0xb8e52,
|
||||||
|
PnP_HiddenPeople: 0xb8d3e,
|
||||||
|
PnP_HiddenPeople_BeatE4: 0xb8d43,
|
||||||
|
PnP_HiddenPeople_BeatKanto: 0xb8d4b,
|
||||||
|
PnP_Places: 0xb8e11,
|
||||||
|
"PnP_Places.End": 0xb8e23,
|
||||||
|
RocketRadioText1: 0xb8ec5,
|
||||||
|
RocketRadioText2: 0xb8eca,
|
||||||
|
RocketRadioText3: 0xb8ecf,
|
||||||
|
RocketRadioText4: 0xb8ed4,
|
||||||
|
RocketRadioText5: 0xb8ed9,
|
||||||
|
RocketRadioText6: 0xb8ede,
|
||||||
|
RocketRadioText7: 0xb8ee3,
|
||||||
|
RocketRadioText8: 0xb8ee8,
|
||||||
|
RocketRadioText9: 0xb8eed,
|
||||||
|
RocketRadioText10: 0xb8ef2,
|
||||||
|
BuenaRadioText1: 0xb9182,
|
||||||
|
BuenaRadioText2: 0xb9187,
|
||||||
|
BuenaRadioText3: 0xb918c,
|
||||||
|
BuenaRadioText4: 0xb9191,
|
||||||
|
BuenaRadioText5: 0xb9196,
|
||||||
|
BuenaRadioText6: 0xb919b,
|
||||||
|
BuenaRadioText7: 0xb91a0,
|
||||||
|
BuenaRadioMidnightText1: 0xb91a5,
|
||||||
|
BuenaRadioMidnightText2: 0xb91aa,
|
||||||
|
BuenaRadioMidnightText3: 0xb91af,
|
||||||
|
BuenaRadioMidnightText4: 0xb91b4,
|
||||||
|
BuenaRadioMidnightText5: 0xb91b9,
|
||||||
|
BuenaRadioMidnightText6: 0xb91be,
|
||||||
|
BuenaRadioMidnightText7: 0xb91c3,
|
||||||
|
BuenaRadioMidnightText8: 0xb91c8,
|
||||||
|
BuenaRadioMidnightText9: 0xb91cd,
|
||||||
|
BuenaRadioMidnightText10: 0xb91d2,
|
||||||
|
BuenaOffTheAirText: 0xb91d7,
|
||||||
|
BuenasPasswordTable: 0xb8ff9,
|
||||||
|
BuenasPasswordChannelName: 0xb9171,
|
||||||
|
RadioChannelSongs: 0xb920b,
|
||||||
|
RadioChannels: 0x916ad,
|
||||||
|
OaksPKMNTalkName: 0x918bf,
|
||||||
|
PokedexShowName: 0x918cc,
|
||||||
|
PokemonMusicName: 0x918d6,
|
||||||
|
LuckyChannelName: 0x918e1,
|
||||||
|
UnownStationName: 0x918ef,
|
||||||
|
PlacesAndPeopleName: 0x918f5,
|
||||||
|
LetsAllSingName: 0x91905,
|
||||||
|
PokeFluteStationName: 0x91914,
|
||||||
|
BuenasPasswordName: 0x917c3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** The sound engine (audio/engine.asm) and its tables, keyed by their
|
/** The sound engine (audio/engine.asm) and its tables, keyed by their
|
||||||
* PRET labels. */
|
* PRET labels. */
|
||||||
export const AUDIO = {
|
export const AUDIO = {
|
||||||
@@ -1329,6 +1461,41 @@ export const JOHTO = {
|
|||||||
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
* writemem, loadmem and readvar), keyed by their PRET labels; the native
|
||||||
* save keeps them by label in `memory`. */
|
* save keeps them by label in `memory`. */
|
||||||
export const SAVED_WRAM = {
|
export const SAVED_WRAM = {
|
||||||
|
wJackFightCount: 0xd9f2,
|
||||||
|
wBeverlyFightCount: 0xd9f3,
|
||||||
|
wHueyFightCount: 0xd9f4,
|
||||||
|
wGavenFightCount: 0xd9f5,
|
||||||
|
wBethFightCount: 0xd9f6,
|
||||||
|
wJoseFightCount: 0xd9f7,
|
||||||
|
wReenaFightCount: 0xd9f8,
|
||||||
|
wJoeyFightCount: 0xd9f9,
|
||||||
|
wWadeFightCount: 0xd9fa,
|
||||||
|
wRalphFightCount: 0xd9fb,
|
||||||
|
wLizFightCount: 0xd9fc,
|
||||||
|
wAnthonyFightCount: 0xd9fd,
|
||||||
|
wToddFightCount: 0xd9fe,
|
||||||
|
wGinaFightCount: 0xd9ff,
|
||||||
|
wIrwinFightCount: 0xda00,
|
||||||
|
wArnieFightCount: 0xda01,
|
||||||
|
wAlanFightCount: 0xda02,
|
||||||
|
wDanaFightCount: 0xda03,
|
||||||
|
wChadFightCount: 0xda04,
|
||||||
|
wDerekFightCount: 0xda05,
|
||||||
|
wTullyFightCount: 0xda06,
|
||||||
|
wBrentFightCount: 0xda07,
|
||||||
|
wTiffanyFightCount: 0xda08,
|
||||||
|
wVanceFightCount: 0xda09,
|
||||||
|
wWiltonFightCount: 0xda0a,
|
||||||
|
wKenjiFightCount: 0xda0b,
|
||||||
|
wParryFightCount: 0xda0c,
|
||||||
|
wErinFightCount: 0xda0d,
|
||||||
|
wBuenasPassword: 0xdc4a,
|
||||||
|
wKenjiBreakTimer: 0xdc58,
|
||||||
|
wYanmaMapGroup: 0xdc5a,
|
||||||
|
wYanmaMapNumber: 0xdc5b,
|
||||||
|
wDunsparceMapGroup: 0xdfcc,
|
||||||
|
wDunsparceMapNumber: 0xdfcd,
|
||||||
|
wFishingSwarmFlag: 0xdfce,
|
||||||
wMooMooBerries: 0xd962,
|
wMooMooBerries: 0xd962,
|
||||||
wFarfetchdPosition: 0xd964,
|
wFarfetchdPosition: 0xd964,
|
||||||
wKurtApricornQuantity: 0xdca4,
|
wKurtApricornQuantity: 0xdca4,
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,7 @@
|
|||||||
import type { GameRuntime } from "../app/runtime";
|
import type { GameRuntime } from "../app/runtime";
|
||||||
import type { Button } from "../input/types";
|
import type { Button } from "../input/types";
|
||||||
import { CrystalAssets } from "./rom/assets";
|
import { CrystalAssets } from "./rom/assets";
|
||||||
import { GameWorld, TICKS_PER_SECOND } from "./world/world";
|
import { GameWorld, TICKS_PER_SECOND, realtimeChoice } from "./world/world";
|
||||||
import type { BattleAction } from "./battle/battle";
|
import type { BattleAction } from "./battle/battle";
|
||||||
import type { Direction } from "./world/types";
|
import type { Direction } from "./world/types";
|
||||||
import { WorldRenderer } from "./rendering/renderer";
|
import { WorldRenderer } from "./rendering/renderer";
|
||||||
@@ -84,8 +84,7 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
}
|
}
|
||||||
// A realtime screen (the Game Corner) reads every button itself, once
|
// A realtime screen (the Game Corner) reads every button itself, once
|
||||||
// per its own frame, rather than through the usual per-button actions.
|
// per its own frame, rather than through the usual per-button actions.
|
||||||
const choice = world.dialogue?.choice;
|
if (realtimeChoice(world.dialogue?.choice)) {
|
||||||
if (choice?.kind === "screen" && choice.realtime) {
|
|
||||||
world.bufferScreenButton(button);
|
world.bufferScreenButton(button);
|
||||||
this.publish();
|
this.publish();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const NITE_F = 2;
|
|||||||
* (FISHGROUP_*, never FISHGROUP_NONE). A bite when Random falls below the
|
* (FISHGROUP_*, never FISHGROUP_NONE). A bite when Random falls below the
|
||||||
* group's chance, then the first entry whose chance a second Random does
|
* group's chance, then the first entry whose chance a second Random does
|
||||||
* not exceed; species 0 picks the time of day's TimeFishGroups entry.
|
* not exceed; species 0 picks the time of day's TimeFishGroups entry.
|
||||||
* Undefined when nothing bites. GetFishGroupIndex's swarm groups need
|
* Undefined when nothing bites. GetFishGroupIndex's swarm groups (the
|
||||||
* DAILYFLAGS1_FISH_SWARM_F and wFishingSwarmFlag, which only trainer calls
|
* world passes them) need DAILYFLAGS1_FISH_SWARM_F and wFishingSwarmFlag,
|
||||||
* set; they are not ported, so the map's own group is used. */
|
* which trainers' calls set. */
|
||||||
export function fish(
|
export function fish(
|
||||||
r: RomReader,
|
r: RomReader,
|
||||||
group: number,
|
group: number,
|
||||||
|
|||||||
+234
-12
@@ -18,6 +18,7 @@ import {
|
|||||||
MOM,
|
MOM,
|
||||||
OVERWORLD,
|
OVERWORLD,
|
||||||
PC,
|
PC,
|
||||||
|
PHONE,
|
||||||
POKEGEAR,
|
POKEGEAR,
|
||||||
RAM,
|
RAM,
|
||||||
SAVED_WRAM,
|
SAVED_WRAM,
|
||||||
@@ -67,7 +68,8 @@ import { OW } from "../world/constants";
|
|||||||
import { readMovement, type MovementStep } from "./movement";
|
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 { callerClassAndName, radioSpecialContext, type PokegearView } from "./pokegear";
|
||||||
|
import { ENTER_MAP_MUSIC, RadioShow, buenasPassword, exitRadioMusic, mapRadioStation, type RadioHost } from "./radio";
|
||||||
import type { FlyMapView } from "./fly-map";
|
import type { FlyMapView } from "./fly-map";
|
||||||
import type { TileScreen } from "./screen";
|
import type { TileScreen } from "./screen";
|
||||||
import { unownPuzzle } from "./unown-puzzle";
|
import { unownPuzzle } from "./unown-puzzle";
|
||||||
@@ -550,6 +552,9 @@ export type ScriptEffect =
|
|||||||
| { kind: "waitsfx" }
|
| { kind: "waitsfx" }
|
||||||
/** DelayFrames inside a special: the map waits, frozen. */
|
/** DelayFrames inside a special: the map waits, frozen. */
|
||||||
| { kind: "delay"; ticks: number }
|
| { kind: "delay"; ticks: number }
|
||||||
|
/** PlayRadio's text box for one video frame, answered with the button
|
||||||
|
* pressed ("" for none). */
|
||||||
|
| { kind: "mapradio"; text: string }
|
||||||
/** A front picture in a box (pokepic); species 0 closes it. */
|
/** A front picture in a box (pokepic); species 0 closes it. */
|
||||||
| { kind: "picture"; species: number }
|
| { kind: "picture"; species: number }
|
||||||
/** The introduction's trainer or player picture; none closes it. */
|
/** The introduction's trainer or player picture; none closes it. */
|
||||||
@@ -703,7 +708,7 @@ export type ScriptEffect =
|
|||||||
| { kind: "warpcheck" }
|
| { kind: "warpcheck" }
|
||||||
/** LoadMemScript: a script (with a phone caller's script) that runs at
|
/** LoadMemScript: a script (with a phone caller's script) that runs at
|
||||||
* the next PlayerEvents, unless one is already queued. */
|
* the next PlayerEvents, unless one is already queued. */
|
||||||
| { kind: "memscript"; script: number; callerScript?: number }
|
| { kind: "memscript"; script: number; callerScript?: number; caller?: number }
|
||||||
/** changeblock: the block covering map tile (x, y) becomes `block`. */
|
/** changeblock: the block covering map tile (x, y) becomes `block`. */
|
||||||
| { kind: "changeblock"; x: number; y: number; block: number }
|
| { kind: "changeblock"; x: number; y: number; block: number }
|
||||||
/** changemapblocks: replace the map's block data from the ROM. */
|
/** changemapblocks: replace the map's block data from the ROM. */
|
||||||
@@ -763,6 +768,10 @@ export interface ScriptHost {
|
|||||||
/** The sound engine, and GetMapMusic_MaybeSpecial for this map. */
|
/** The sound engine, and GetMapMusic_MaybeSpecial for this map. */
|
||||||
sound?: GameSound;
|
sound?: GameSound;
|
||||||
mapMusic?(state: ScriptState): number;
|
mapMusic?(state: ScriptState): number;
|
||||||
|
/** InitCallReceiveDelay: the random-call timer starts over. */
|
||||||
|
initCallReceiveDelay?(): void;
|
||||||
|
/** GetWorldMapLocation for any map. */
|
||||||
|
landmarkOf?(mapId: string): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A sound a script started while its text was still to be shown: it
|
/** A sound a script started while its text was still to be shown: it
|
||||||
@@ -793,6 +802,12 @@ const savedByte = (address: number) =>
|
|||||||
);
|
);
|
||||||
/** VAR_FACING's values (DOWN, UP, LEFT, RIGHT) as directions. */
|
/** VAR_FACING's values (DOWN, UP, LEFT, RIGHT) as directions. */
|
||||||
const FACINGS: Direction[] = ["down", "up", "left", "right"];
|
const FACINGS: Direction[] = ["down", "up", "left", "right"];
|
||||||
|
/** GRASS_WILDDATA_LENGTH: map, three rates, then seven (level, species)
|
||||||
|
* slots for each time of day. */
|
||||||
|
const GRASS_MON_BYTES = 7 * 2;
|
||||||
|
/** NUM_BUENA_PRIZES */
|
||||||
|
const BUENA_PRIZES = 9;
|
||||||
|
const GRASS_WILDDATA_LENGTH = 2 + 3 + 3 * GRASS_MON_BYTES;
|
||||||
/** ITEM_FROM_MEM: take the item from wScriptVar. */
|
/** ITEM_FROM_MEM: take the item from wScriptVar. */
|
||||||
const ITEM_FROM_MEM = 0xff;
|
const ITEM_FROM_MEM = 0xff;
|
||||||
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
|
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
|
||||||
@@ -800,6 +815,8 @@ const PHONE_CONTACT_SCRIPT2_BANK = 9;
|
|||||||
/** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */
|
/** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */
|
||||||
const PHONE_CONTACT_SIZE = 12;
|
const PHONE_CONTACT_SIZE = 12;
|
||||||
const PHONE_BILL = 3;
|
const PHONE_BILL = 3;
|
||||||
|
/** PHONE_MOM: Mom's PhoneContacts entry. */
|
||||||
|
const PHONE_MOM = 1;
|
||||||
/** A momitem: trigger and cost (bigdt), then MOM_ITEM or MOM_DOLL and the
|
/** A momitem: trigger and cost (bigdt), then MOM_ITEM or MOM_DOLL and the
|
||||||
* item or decoration. */
|
* item or decoration. */
|
||||||
const MOMITEM_SIZE = 8,
|
const MOMITEM_SIZE = 8,
|
||||||
@@ -935,6 +952,8 @@ 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;
|
||||||
|
/** wCurCaller, as LoadCallerScript sets it for a received call. */
|
||||||
|
caller?: number;
|
||||||
fieldMove?: FieldMoveContext;
|
fieldMove?: FieldMoveContext;
|
||||||
/** 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. */
|
||||||
@@ -960,6 +979,7 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
if (options.trainer)
|
if (options.trainer)
|
||||||
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
|
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
|
||||||
|
if (options.caller !== undefined) this.curCaller = options.caller;
|
||||||
this.iterator = this.execute(start);
|
this.iterator = this.execute(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1306,8 +1326,9 @@ export class ScriptRun {
|
|||||||
receiveItem(data, this.state.bag, ball.item, ball.quantity),
|
receiveItem(data, this.state.bag, ball.item, ball.quantity),
|
||||||
);
|
);
|
||||||
} else if (routine === NATIVE_ROUTINES.hangUp) {
|
} else if (routine === NATIVE_ROUTINES.hangUp) {
|
||||||
// HangUp_Beep prints "Click!", then the hang-up tones pass
|
// HangUp_Beep prints "Click!" with SFX_HANG_UP, then the
|
||||||
// before closetext; no button is needed.
|
// hang-up tones pass before closetext; no button is needed.
|
||||||
|
this.host.sound?.playSFX(SOUND.SFX_HANG_UP);
|
||||||
write(SYMBOLS.phoneClickText);
|
write(SYMBOLS.phoneClickText);
|
||||||
yield {
|
yield {
|
||||||
kind: "text",
|
kind: "text",
|
||||||
@@ -1316,13 +1337,21 @@ export class ScriptRun {
|
|||||||
};
|
};
|
||||||
write(POKEGEAR.PhoneEllipseText);
|
write(POKEGEAR.PhoneEllipseText);
|
||||||
yield { kind: "text", pages: take(), holdTicks: HANG_UP.endTicks };
|
yield { kind: "text", pages: take(), holdTicks: HANG_UP.endTicks };
|
||||||
} else if (
|
} else if (routine === NATIVE_ROUTINES.ringTwiceStartCall) {
|
||||||
routine === NATIVE_ROUTINES.ringTwiceStartCall ||
|
// RingTwice_StartCall: twice SFX_CALL (after any sound effect)
|
||||||
routine === NATIVE_ROUTINES.initCallReceiveDelay
|
// with the caller's name box shown, cleared and shown again,
|
||||||
) {
|
// 20 frames each.
|
||||||
// The ringing tones and caller name box are not presented yet.
|
yield* flush();
|
||||||
// InitCallReceiveDelay restarts the random-call timers, and
|
const name = `☎ ${callerClassAndName(r, this.curCaller, this.text())}`;
|
||||||
// random calls are not ported.
|
for (let ring = 0; ring < 2; ring++) {
|
||||||
|
yield* waitSFX();
|
||||||
|
this.host.sound?.playSFX(SOUND.SFX_CALL);
|
||||||
|
for (const page of [name, " ", name])
|
||||||
|
yield { kind: "text", pages: [page], holdTicks: 10 };
|
||||||
|
}
|
||||||
|
} else if (routine === NATIVE_ROUTINES.initCallReceiveDelay) {
|
||||||
|
// InitCallReceiveDelay restarts the random-call timer.
|
||||||
|
this.host.initCallReceiveDelay?.();
|
||||||
} else if (routine === OVERWORLD.GetPartyNickname)
|
} else if (routine === OVERWORLD.GetPartyNickname)
|
||||||
this.partyNickname();
|
this.partyNickname();
|
||||||
else if (routine === OVERWORLD["AskCutScript.CheckMap"])
|
else if (routine === OVERWORLD["AskCutScript.CheckMap"])
|
||||||
@@ -1515,8 +1544,12 @@ export class ScriptRun {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0x1d: {
|
case 0x1d: {
|
||||||
// writevar: only VAR_MOVEMENT is writable natively.
|
// writevar: VAR_MOVEMENT and VAR_BLUECARDBALANCE are writable.
|
||||||
const variable = byte();
|
const variable = byte();
|
||||||
|
if (variable === 0x18) {
|
||||||
|
this.state.blueCardBalance = value & 0xff;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (variable !== 8)
|
if (variable !== 8)
|
||||||
throw new UnsupportedScript(
|
throw new UnsupportedScript(
|
||||||
offset,
|
offset,
|
||||||
@@ -1651,6 +1684,7 @@ export class ScriptRun {
|
|||||||
PHONE_BILL * PHONE_CONTACT_SIZE +
|
PHONE_BILL * PHONE_CONTACT_SIZE +
|
||||||
PHONE_CONTACT_SCRIPT2_BANK,
|
PHONE_CONTACT_SCRIPT2_BANK,
|
||||||
),
|
),
|
||||||
|
caller: PHONE_BILL,
|
||||||
});
|
});
|
||||||
canLose = false;
|
canLose = false;
|
||||||
break;
|
break;
|
||||||
@@ -1862,6 +1896,22 @@ export class ScriptRun {
|
|||||||
// itself is unchanged.
|
// itself is unchanged.
|
||||||
this.strings[buffer(byte())] = String(value);
|
this.strings[buffer(byte())] = String(value);
|
||||||
break;
|
break;
|
||||||
|
case 0xa0: {
|
||||||
|
// swarm: StoreSwarmMapIndices, Dunsparce's (0) or Yanma's map.
|
||||||
|
const which = byte(),
|
||||||
|
group = byte(),
|
||||||
|
number = byte();
|
||||||
|
const memory = this.state.memory;
|
||||||
|
if (which) Object.assign(memory, { wYanmaMapGroup: group, wYanmaMapNumber: number });
|
||||||
|
else Object.assign(memory, { wDunsparceMapGroup: group, wDunsparceMapNumber: number });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0xa6: {
|
||||||
|
// gettrainerclassname: GetName for TRAINER_NAME, the class's name.
|
||||||
|
const classId = byte();
|
||||||
|
this.strings[buffer(byte())] = new TrainerData(r).className(classId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 0xa4: {
|
case 0xa4: {
|
||||||
// battletowertext: BattleTowerText for the loaded opponent.
|
// battletowertext: BattleTowerText for the loaded opponent.
|
||||||
const which = byte();
|
const which = byte();
|
||||||
@@ -3184,6 +3234,7 @@ export class ScriptRun {
|
|||||||
callerScript: doll
|
callerScript: doll
|
||||||
? POKEGEAR["Mom_GetScriptPointer.DollScript"]
|
? POKEGEAR["Mom_GetScriptPointer.DollScript"]
|
||||||
: POKEGEAR["Mom_GetScriptPointer.ItemScript"],
|
: POKEGEAR["Mom_GetScriptPointer.ItemScript"],
|
||||||
|
caller: PHONE_MOM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3370,11 +3421,99 @@ export class ScriptRun {
|
|||||||
return this.state.unownDex.length;
|
return this.state.unownDex.length;
|
||||||
case 0x16:
|
case 0x16:
|
||||||
return this.state.memory.wKurtApricornQuantity ?? 0;
|
return this.state.memory.wKurtApricornQuantity ?? 0;
|
||||||
|
case 0x18:
|
||||||
|
return this.state.blueCardBalance;
|
||||||
|
case 0x19:
|
||||||
|
return this.state.memory.wBuenasPassword ?? 0;
|
||||||
|
case 0x1a:
|
||||||
|
// VAR_KENJI_BREAK: wKenjiBreakTimer's days left.
|
||||||
|
return this.state.memory.wKenjiBreakTimer ?? 0;
|
||||||
default:
|
default:
|
||||||
throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`);
|
throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** BuenaPrize: the prizes and their points beside the Blue Card's
|
||||||
|
* balance; a prize is confirmed, paid for and received, until B. */
|
||||||
|
private *buenaPrize(pages: string[], take: () => string[]): Generator<ScriptEffect, number, Input> {
|
||||||
|
const r = this.reader,
|
||||||
|
data = this.host.data;
|
||||||
|
const prizes = Array.from({ length: BUENA_PRIZES }, (_, index) => ({
|
||||||
|
item: r.byte(PHONE.BuenaPrizeItems + index * 2),
|
||||||
|
points: r.byte(PHONE.BuenaPrizeItems + index * 2 + 1),
|
||||||
|
}));
|
||||||
|
const say = (address: number) =>
|
||||||
|
pages.push(...readText(r, address, this.text()).split("\n\n").filter(Boolean));
|
||||||
|
const balance = () =>
|
||||||
|
`${readString(r, PHONE["PrintBlueCardBalance.Points_string"], this.text())} ${String(this.state.blueCardBalance).padStart(2, " ")}`;
|
||||||
|
let selected = 0;
|
||||||
|
say(PHONE["BuenaPrize.BuenaAskWhichPrizeText"]);
|
||||||
|
for (;;) {
|
||||||
|
const choice = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: take(),
|
||||||
|
items: prizes.map(({ item }) => data.itemName(item)),
|
||||||
|
details: prizes.map(({ points }) => String(points)),
|
||||||
|
selected,
|
||||||
|
balance: balance(),
|
||||||
|
};
|
||||||
|
if (typeof choice !== "number" || choice < 0 || choice >= prizes.length) break;
|
||||||
|
selected = choice;
|
||||||
|
const prize = prizes[choice];
|
||||||
|
this.strings[RAM.stringBuffer1] = data.itemName(prize.item);
|
||||||
|
say(PHONE["BuenaPrize.BuenaIsThatRightText"]);
|
||||||
|
if (yield { kind: "yesno", pages: take() }) {
|
||||||
|
if (this.state.blueCardBalance < prize.points) say(PHONE["BuenaPrize.BuenaNotEnoughPointsText"]);
|
||||||
|
else if (!receiveItem(data, this.state.bag, prize.item, 1)) say(PHONE["BuenaPrize.BuenaNoRoomText"]);
|
||||||
|
else {
|
||||||
|
this.state.blueCardBalance -= prize.points;
|
||||||
|
this.host.sound?.playSFX(SOUND.SFX_TRANSACTION);
|
||||||
|
say(PHONE["BuenaPrize.BuenaHereYouGoText"]);
|
||||||
|
}
|
||||||
|
yield { kind: "text", pages: take() };
|
||||||
|
}
|
||||||
|
say(PHONE["BuenaPrize.BuenaAskWhichPrizeText"]);
|
||||||
|
}
|
||||||
|
take();
|
||||||
|
say(PHONE["BuenaPrize.BuenaComeAgainText"]);
|
||||||
|
yield { kind: "text", pages: take() };
|
||||||
|
this.host.sound?.playSFX(SOUND.SFX_READ_TEXT_2);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The radio's view of this script: its draft, clock and sound. */
|
||||||
|
private radioHost(): RadioHost {
|
||||||
|
const host = this.host;
|
||||||
|
return {
|
||||||
|
reader: this.reader,
|
||||||
|
data: host.data,
|
||||||
|
dex: new PokedexData(this.reader),
|
||||||
|
state: this.state,
|
||||||
|
text: () => this.text(),
|
||||||
|
random: () => host.random(),
|
||||||
|
timeOfDay: () => host.timeOfDay(),
|
||||||
|
hours: () => host.clock().hours,
|
||||||
|
weekday: () => host.clock().weekday,
|
||||||
|
landmark: () => host.landmark(),
|
||||||
|
landmarkOf: (mapId) => host.landmarkOf?.(mapId) ?? host.landmark(),
|
||||||
|
special: () => radioSpecialContext(this.state, host),
|
||||||
|
sound: host.sound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetCallerLocation and LookUpWildmonsForMapDE: the caller's map's
|
||||||
|
* entry in JohtoGrassWildMons, else KantoGrassWildMons. */
|
||||||
|
private callerGrassTable(): number | undefined {
|
||||||
|
const r = this.reader,
|
||||||
|
contact = PC.PhoneContacts + this.curCaller * PHONE_CONTACT_SIZE;
|
||||||
|
const group = r.byte(contact + 2),
|
||||||
|
number = r.byte(contact + 3);
|
||||||
|
for (const table of [PHONE.JohtoGrassWildMons, PHONE.KantoGrassWildMons])
|
||||||
|
for (let entry = table; r.byte(entry) !== 0xff; entry += GRASS_WILDDATA_LENGTH)
|
||||||
|
if (r.byte(entry) === group && r.byte(entry + 1) === number) return entry;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Native translations of special routines, selected by routine address.
|
/** Native translations of special routines, selected by routine address.
|
||||||
* `value` is the script's current wScriptVar, in case a routine (like
|
* `value` is the script's current wScriptVar, in case a routine (like
|
||||||
* CheckPokerus) returns a new one; routines that leave it alone return it
|
* CheckPokerus) returns a new one; routines that leave it alone return it
|
||||||
@@ -3600,6 +3739,89 @@ export class ScriptRun {
|
|||||||
case JOHTO.PhotoStudio:
|
case JOHTO.PhotoStudio:
|
||||||
yield* photoStudio(this.specialContext(pages, take));
|
yield* photoStudio(this.specialContext(pages, take));
|
||||||
return value;
|
return value;
|
||||||
|
case PHONE.MapRadio: {
|
||||||
|
// MapRadio and PlayRadio: the station's name in quotes for 100
|
||||||
|
// frames, then the station, every frame until A or B.
|
||||||
|
if (this.options.callback) throw new Error("Map callbacks cannot present scenes.");
|
||||||
|
const sound = this.host.sound;
|
||||||
|
const radioHost = this.radioHost();
|
||||||
|
const station = mapRadioStation(
|
||||||
|
r,
|
||||||
|
value & 0xff,
|
||||||
|
{ landmark: this.host.landmark(), timeOfDay: this.host.timeOfDay() },
|
||||||
|
this.text(),
|
||||||
|
);
|
||||||
|
if (pages.length) yield { kind: "text", pages: take() };
|
||||||
|
const show = new RadioShow(radioHost, station.line, station.name);
|
||||||
|
let wait = 100;
|
||||||
|
let text = `“${station.name}”`;
|
||||||
|
for (;;) {
|
||||||
|
const button = yield { kind: "mapradio", text };
|
||||||
|
if (wait) {
|
||||||
|
wait--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (button === "a" || button === "b") break;
|
||||||
|
show.frame();
|
||||||
|
text = show.lines.join("\n");
|
||||||
|
}
|
||||||
|
exitRadioMusic(sound, show.musicPlaying || ENTER_MAP_MUSIC, this.state.playerState, this.host.mapMusic?.(this.state) ?? 0);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
case PHONE.AskRememberPassword: {
|
||||||
|
// AskRememberPassword: a YES/NO box; YES is 1.
|
||||||
|
const answer = yield { kind: "yesno", pages: take() };
|
||||||
|
return answer ? 1 : 0;
|
||||||
|
}
|
||||||
|
case PHONE.BuenasPassword: {
|
||||||
|
// BuenasPassword: the category's three words, B disabled; 1 when
|
||||||
|
// the chosen one is today's password.
|
||||||
|
const password = this.state.memory.wBuenasPassword ?? 0;
|
||||||
|
const items = [0, 1, 2].map((word) =>
|
||||||
|
buenasPassword(r, this.host.data, (password & 0xf0) + word, this.text()),
|
||||||
|
);
|
||||||
|
const shown = take();
|
||||||
|
for (;;) {
|
||||||
|
const choice = yield { kind: "menu", pages: shown.length ? shown : [""], items, selected: 0 };
|
||||||
|
if (typeof choice === "number" && choice >= 0 && choice < 3)
|
||||||
|
return Number(choice === (password & 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PHONE.BuenaPrize:
|
||||||
|
return yield* this.buenaPrize(pages, take);
|
||||||
|
case PHONE.ActivateFishingSwarm:
|
||||||
|
this.state.memory.wFishingSwarmFlag = value & 0xff;
|
||||||
|
return value;
|
||||||
|
case PHONE.RandomPhoneWildMon: {
|
||||||
|
// RandomPhoneWildMon: one of the four commonest grass Pokémon at
|
||||||
|
// this time of day on the caller's map, named in wStringBuffer4.
|
||||||
|
const entry = this.callerGrassTable();
|
||||||
|
const slot = this.host.random() & 3;
|
||||||
|
const species = entry === undefined
|
||||||
|
? 0
|
||||||
|
: r.byte(entry + 5 + this.host.timeOfDay() * GRASS_MON_BYTES + slot * 2 + 1);
|
||||||
|
this.strings[RAM.stringBuffer4] = this.host.data.speciesName(species);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
case PHONE.RandomUnseenWildMon: {
|
||||||
|
// RandomUnseenWildMon: one of the three rarest grass Pokémon on the
|
||||||
|
// caller's map at this time of day, compared with the morning's
|
||||||
|
// commonest four (the original's bug); one never seen gets a text.
|
||||||
|
const entry = this.callerGrassTable();
|
||||||
|
if (entry === undefined) return 1;
|
||||||
|
let roll: number;
|
||||||
|
do roll = this.host.random() & 3;
|
||||||
|
while (!roll);
|
||||||
|
const rare = r.byte(entry + 5 + 4 * 2 + this.host.timeOfDay() * GRASS_MON_BYTES + (roll - 1) * 2 + 1);
|
||||||
|
for (let slot = 0; slot < 4; slot++)
|
||||||
|
if (r.byte(entry + 5 + slot * 2 + 1) === rare) return 1;
|
||||||
|
if (this.state.pokedex.seen.has(rare)) return 1;
|
||||||
|
// CopyName1 keeps wStringBuffer1 in wStringBuffer2 first.
|
||||||
|
this.strings[RAM.stringBuffer2] = this.strings[RAM.stringBuffer1] ?? "";
|
||||||
|
this.strings[RAM.stringBuffer1] = this.host.data.speciesName(rare);
|
||||||
|
pages.push(...readText(r, PHONE["RandomUnseenWildMon.JustSawSomeRareMonText"], this.text()).split("\n\n"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
case JOHTO.PlaySlowCry: {
|
case JOHTO.PlaySlowCry: {
|
||||||
// PlaySlowCry: wScriptVar's cry, pitch - $140 and length + $60.
|
// PlaySlowCry: wScriptVar's cry, pitch - $140 and length + $60.
|
||||||
const species = value & 0xff;
|
const species = value & 0xff;
|
||||||
|
|||||||
+142
-18
@@ -1,6 +1,10 @@
|
|||||||
|
import { SOUND } from "../audio/constants";
|
||||||
|
import { ENTER_MAP_MUSIC, RadioShow, exitRadioMusic, radioStation, type RadioHost } from "./radio";
|
||||||
|
import { PokedexData } from "../rom/pokedex";
|
||||||
|
import type { SpecialContext } from "./special-context";
|
||||||
import type { RomReader } from "../rom/reader";
|
import type { RomReader } from "../rom/reader";
|
||||||
import type { PokemonData } from "../rom/pokemon";
|
import type { PokemonData } from "../rom/pokemon";
|
||||||
import { PC, POKEGEAR, SYMBOLS } from "../rom/profile";
|
import { ENGINE_FLAGS, PC, POKEGEAR, SYMBOLS } from "../rom/profile";
|
||||||
import { TrainerData } from "../rom/trainers";
|
import { TrainerData } from "../rom/trainers";
|
||||||
import { OW } from "../world/constants";
|
import { OW } from "../world/constants";
|
||||||
import {
|
import {
|
||||||
@@ -17,9 +21,24 @@ type Input = number | string | undefined;
|
|||||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||||
|
|
||||||
/** POKEGEARCARD_* in order. */
|
/** POKEGEARCARD_* in order. */
|
||||||
|
/** GetCallerClassAndName: "NAME:" over the trainer class, or the
|
||||||
|
* NonTrainerCallerNames entry (dashes for an empty slot). */
|
||||||
|
export function callerClassAndName(r: RomReader, contact: number, text: TextContext) {
|
||||||
|
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||||
|
const classId = r.byte(entry),
|
||||||
|
id = r.byte(entry + 1);
|
||||||
|
if (classId) {
|
||||||
|
const trainers = new TrainerData(r);
|
||||||
|
return `${trainers.trainer(classId, id).name}:\n ${trainers.className(classId)}`;
|
||||||
|
}
|
||||||
|
const pointer = POKEGEAR.NonTrainerCallerNames + id * 2;
|
||||||
|
return readString(r, r.near(pointer, r.bank(pointer)), text);
|
||||||
|
}
|
||||||
|
|
||||||
export type PokegearCard = "clock" | "map" | "phone" | "radio";
|
export type PokegearCard = "clock" | "map" | "phone" | "radio";
|
||||||
/** The buttons the Pokégear reads (hJoyLast and hJoyPressed). */
|
/** The buttons the Pokégear reads (hJoyLast and hJoyPressed). */
|
||||||
export type PokegearButton =
|
export type PokegearButton =
|
||||||
|
| ""
|
||||||
| "a"
|
| "a"
|
||||||
| "b"
|
| "b"
|
||||||
| "start"
|
| "start"
|
||||||
@@ -29,6 +48,8 @@ export type PokegearButton =
|
|||||||
| "left"
|
| "left"
|
||||||
| "right";
|
| "right";
|
||||||
export const POKEGEAR_BUTTONS: readonly string[] = [
|
export const POKEGEAR_BUTTONS: readonly string[] = [
|
||||||
|
// A frame without a button, for the radio card.
|
||||||
|
"",
|
||||||
"a",
|
"a",
|
||||||
"b",
|
"b",
|
||||||
"start",
|
"start",
|
||||||
@@ -60,6 +81,9 @@ export interface PokegearView {
|
|||||||
signal: boolean;
|
signal: boolean;
|
||||||
submenu?: { items: string[]; selected: number };
|
submenu?: { items: string[]; selected: number };
|
||||||
};
|
};
|
||||||
|
/** The radio card, which runs every frame: the tuning knob's frequency,
|
||||||
|
* the station's name and the text box's two rows. */
|
||||||
|
radio?: { frequency: string; name: string; lines: [string, string] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the Pokégear needs from the world. */
|
/** What the Pokégear needs from the world. */
|
||||||
@@ -71,6 +95,8 @@ export interface PokegearHost {
|
|||||||
scriptHost(): ScriptHost;
|
scriptHost(): ScriptHost;
|
||||||
/** GetWorldMapLocation for any map. */
|
/** GetWorldMapLocation for any map. */
|
||||||
landmarkOf(mapId: string): number;
|
landmarkOf(mapId: string): number;
|
||||||
|
/** wRadioTuningKnob, kept while the game runs. */
|
||||||
|
radioKnob?: { value: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHONE_DISPLAY_HEIGHT = 4;
|
const PHONE_DISPLAY_HEIGHT = 4;
|
||||||
@@ -263,17 +289,8 @@ export class Pokegear {
|
|||||||
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||||
return { classId: this.reader.byte(entry), id: this.reader.byte(entry + 1) };
|
return { classId: this.reader.byte(entry), id: this.reader.byte(entry + 1) };
|
||||||
}
|
}
|
||||||
/** GetCallerClassAndName: "NAME:" over the trainer class, or the
|
|
||||||
* NonTrainerCallerNames entry (dashes for an empty slot). */
|
|
||||||
private callerName(contact: number) {
|
private callerName(contact: number) {
|
||||||
const r = this.reader,
|
return callerClassAndName(this.reader, contact, this.host.text());
|
||||||
{ classId, id } = this.callerClass(contact);
|
|
||||||
if (classId) {
|
|
||||||
const trainers = new TrainerData(r);
|
|
||||||
return `${trainers.trainer(classId, id).name}:\n ${trainers.className(classId)}`;
|
|
||||||
}
|
|
||||||
const pointer = POKEGEAR.NonTrainerCallerNames + id * 2;
|
|
||||||
return readString(r, r.near(pointer, r.bank(pointer)), this.host.text());
|
|
||||||
}
|
}
|
||||||
private rows() {
|
private rows() {
|
||||||
return Array.from({ length: PHONE_DISPLAY_HEIGHT }, (_, row) =>
|
return Array.from({ length: PHONE_DISPLAY_HEIGHT }, (_, row) =>
|
||||||
@@ -379,8 +396,10 @@ export class Pokegear {
|
|||||||
private *call(contact: number): Gen {
|
private *call(contact: number): Gen {
|
||||||
const r = this.reader,
|
const r = this.reader,
|
||||||
scriptHost = this.host.scriptHost();
|
scriptHost = this.host.scriptHost();
|
||||||
|
const sound = scriptHost.sound;
|
||||||
if (scriptHost.phoneService()) {
|
if (scriptHost.phoneService()) {
|
||||||
// Phone_NoSignal: the three boops of Phone_CallEnd.
|
// Phone_NoSignal: SFX_NO_SIGNAL, then Phone_CallEnd's three boops.
|
||||||
|
sound?.playSFX(SOUND.SFX_NO_SIGNAL);
|
||||||
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
|
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
|
||||||
const pages = this.pages(
|
const pages = this.pages(
|
||||||
POKEGEAR["PokegearPhone_MakePhoneCall.GearOutOfServiceText"],
|
POKEGEAR["PokegearPhone_MakePhoneCall.GearOutOfServiceText"],
|
||||||
@@ -388,11 +407,14 @@ export class Pokegear {
|
|||||||
yield { kind: "text", pages };
|
yield { kind: "text", pages };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (let ring = 0; ring < 2; ring++)
|
// SFX_CALL with each "……", the second after the first has ended.
|
||||||
|
for (let ring = 0; ring < 2; ring++) {
|
||||||
|
sound?.queueSFX([SOUND.SFX_CALL]);
|
||||||
yield* this.hold(
|
yield* this.hold(
|
||||||
POKEGEAR["PokegearPhone_MakePhoneCall.GearEllipseText"],
|
POKEGEAR["PokegearPhone_MakePhoneCall.GearEllipseText"],
|
||||||
CALL_TICKS,
|
CALL_TICKS,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||||
// CheckPhoneContactTimeOfDay: the callee's time mask against wTimeOfDay.
|
// CheckPhoneContactTimeOfDay: the callee's time mask against wTimeOfDay.
|
||||||
const time = r.byte(entry + 4) & (OW.MORN | OW.DAY | OW.NITE);
|
const time = r.byte(entry + 4) & (OW.MORN | OW.DAY | OW.NITE);
|
||||||
@@ -409,24 +431,126 @@ export class Pokegear {
|
|||||||
throw new Error("A phone script deferred another script.");
|
throw new Error("A phone script deferred another script.");
|
||||||
Object.assign(this.state, run.state);
|
Object.assign(this.state, run.state);
|
||||||
// HangUp after A or B; the callee's last text waited for that button.
|
// HangUp after A or B; the callee's last text waited for that button.
|
||||||
|
sound?.playSFX(SOUND.SFX_HANG_UP);
|
||||||
yield* this.hold(SYMBOLS.phoneClickText, HANG_UP.clickTicks);
|
yield* this.hold(SYMBOLS.phoneClickText, HANG_UP.clickTicks);
|
||||||
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
|
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- radio
|
// ----------------------------------------------------------------- radio
|
||||||
|
|
||||||
/** PokegearRadio_Joypad: B exits and left changes cards. The stations
|
/** wPokegearRadioMusicPlaying, which ExitPokegearRadio_HandleMusic reads
|
||||||
* are not ported. */
|
* when the radio card is left. */
|
||||||
|
private radioMusic = 0;
|
||||||
|
private radioHost(): RadioHost {
|
||||||
|
const host = this.host,
|
||||||
|
scriptHost = host.scriptHost();
|
||||||
|
return {
|
||||||
|
reader: host.reader,
|
||||||
|
data: host.data,
|
||||||
|
dex: new PokedexData(host.reader),
|
||||||
|
state: this.state,
|
||||||
|
text: () => host.text(),
|
||||||
|
random: () => scriptHost.random(),
|
||||||
|
timeOfDay: () => scriptHost.timeOfDay(),
|
||||||
|
hours: () => scriptHost.clock().hours,
|
||||||
|
weekday: () => scriptHost.clock().weekday,
|
||||||
|
landmark: () => this.playerLandmark,
|
||||||
|
landmarkOf: (mapId) => host.landmarkOf(mapId),
|
||||||
|
special: () => radioSpecialContext(this.state, scriptHost),
|
||||||
|
sound: scriptHost.sound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** PokegearRadio_Init, then PokegearRadio_Joypad and AnimateTuningKnob
|
||||||
|
* once per video frame (the world answers "" for a frame without a
|
||||||
|
* button): B exits, left changes cards, up and down turn the knob by
|
||||||
|
* two (0 to 80) and retune; the station plays on. */
|
||||||
private *radio(): Gen<PokegearCard | undefined> {
|
private *radio(): Gen<PokegearCard | undefined> {
|
||||||
yield { kind: "notice", text: "The radio is not ported yet." };
|
const knob = (this.host.radioKnob ??= { value: 0 });
|
||||||
|
const radioHost = this.radioHost();
|
||||||
|
let show: RadioShow | undefined;
|
||||||
|
const tune = () => {
|
||||||
|
// UpdateRadioStation: the station at the knob, or NoRadioStation.
|
||||||
|
if (show) this.radioMusic = show.musicPlaying;
|
||||||
|
const station = radioStation(
|
||||||
|
this.reader,
|
||||||
|
knob.value,
|
||||||
|
{
|
||||||
|
landmark: this.playerLandmark,
|
||||||
|
timeOfDay: radioHost.timeOfDay(),
|
||||||
|
expnCard: this.has(ENGINE_FLAGS.expnCard),
|
||||||
|
rocketSignal: this.has(ENGINE_FLAGS.rocketSignal),
|
||||||
|
rocketsInRadioTower: this.has(ENGINE_FLAGS.rocketsInRadioTower),
|
||||||
|
},
|
||||||
|
this.host.text(),
|
||||||
|
);
|
||||||
|
show = station && new RadioShow(radioHost, station.line, station.name);
|
||||||
|
if (!station) {
|
||||||
|
// NoRadioMusic
|
||||||
|
this.radioMusic = ENTER_MAP_MUSIC;
|
||||||
|
radioHost.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tune();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const button = yield* this.show([]);
|
const view = {
|
||||||
if (button === "b") return undefined;
|
frequency: ((knob.value + 2) / 4).toFixed(1),
|
||||||
|
name: show?.name ?? "",
|
||||||
|
lines: show?.lines ?? (["", ""] as [string, string]),
|
||||||
|
};
|
||||||
|
const button = yield* this.show([], { radio: view });
|
||||||
|
if (show) this.radioMusic = show.musicPlaying || this.radioMusic;
|
||||||
|
if (button === "b") {
|
||||||
|
this.exitRadio();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (button === "left") {
|
if (button === "left") {
|
||||||
|
this.exitRadio();
|
||||||
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
|
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
|
||||||
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
|
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
|
||||||
return "clock";
|
return "clock";
|
||||||
}
|
}
|
||||||
|
if (button === "up" && knob.value < 80) {
|
||||||
|
knob.value += 2;
|
||||||
|
tune();
|
||||||
|
} else if (button === "down" && knob.value > 0) {
|
||||||
|
knob.value -= 2;
|
||||||
|
tune();
|
||||||
}
|
}
|
||||||
|
if (button === "" && show) show.frame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** ExitPokegearRadio_HandleMusic */
|
||||||
|
private exitRadio() {
|
||||||
|
const scriptHost = this.host.scriptHost();
|
||||||
|
exitRadioMusic(
|
||||||
|
scriptHost.sound,
|
||||||
|
this.radioMusic,
|
||||||
|
this.state.playerState,
|
||||||
|
scriptHost.mapMusic?.(this.state) ?? 0,
|
||||||
|
);
|
||||||
|
this.radioMusic = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Lucky Number routines' SpecialContext, for the Lucky Channel. */
|
||||||
|
export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost): SpecialContext {
|
||||||
|
const unused = () => {
|
||||||
|
throw new Error("The radio needs no such routine.");
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reader: undefined as never,
|
||||||
|
data: scriptHost.data,
|
||||||
|
state,
|
||||||
|
strings: {},
|
||||||
|
numbers: {},
|
||||||
|
text: () => scriptHost.text(),
|
||||||
|
random: () => scriptHost.random(),
|
||||||
|
player: () => ({ id: scriptHost.player().id, name: scriptHost.text().player }),
|
||||||
|
today: () => scriptHost.day?.() ?? 0,
|
||||||
|
weekday: () => scriptHost.clock().weekday,
|
||||||
|
write: unused,
|
||||||
|
take: unused,
|
||||||
|
flush: unused,
|
||||||
|
selectMon: unused,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
import type { RomReader } from "../rom/reader";
|
||||||
|
import type { PokemonData } from "../rom/pokemon";
|
||||||
|
import type { PokedexData } from "../rom/pokedex";
|
||||||
|
import { NUM_POKEMON, NUM_TRAINER_CLASSES, RADIO, RAM, ENGINE_FLAGS, SYMBOLS } from "../rom/profile";
|
||||||
|
import { TrainerData } from "../rom/trainers";
|
||||||
|
import { OW } from "../world/constants";
|
||||||
|
import { SOUND } from "../audio/constants";
|
||||||
|
import type { GameSound } from "../audio/sound";
|
||||||
|
import type { ScriptState } from "./interpreter";
|
||||||
|
import type { SpecialContext } from "./special-context";
|
||||||
|
import { readString, readText, type TextContext } from "./text";
|
||||||
|
import { landmarkName } from "./interpreter";
|
||||||
|
import { checkLuckyNumberShowFlag, resetLuckyNumberShowFlag } from "./goldenrod";
|
||||||
|
|
||||||
|
/** Radio channels and segments (constants/radio_constants.asm), verified
|
||||||
|
* by `npm run check:reference`. */
|
||||||
|
export const RADIO_LINES = {
|
||||||
|
OAKS_POKEMON_TALK: 0x00,
|
||||||
|
POKEDEX_SHOW: 0x01,
|
||||||
|
POKEMON_MUSIC: 0x02,
|
||||||
|
LUCKY_CHANNEL: 0x03,
|
||||||
|
BUENAS_PASSWORD: 0x04,
|
||||||
|
PLACES_AND_PEOPLE: 0x05,
|
||||||
|
LETS_ALL_SING: 0x06,
|
||||||
|
ROCKET_RADIO: 0x07,
|
||||||
|
POKE_FLUTE_RADIO: 0x08,
|
||||||
|
UNOWN_RADIO: 0x09,
|
||||||
|
EVOLUTION_RADIO: 0x0a,
|
||||||
|
OAKS_POKEMON_TALK_2: 0x0b,
|
||||||
|
OAKS_POKEMON_TALK_3: 0x0c,
|
||||||
|
OAKS_POKEMON_TALK_4: 0x0d,
|
||||||
|
OAKS_POKEMON_TALK_5: 0x0e,
|
||||||
|
OAKS_POKEMON_TALK_6: 0x0f,
|
||||||
|
OAKS_POKEMON_TALK_7: 0x10,
|
||||||
|
OAKS_POKEMON_TALK_8: 0x11,
|
||||||
|
OAKS_POKEMON_TALK_9: 0x12,
|
||||||
|
POKEDEX_SHOW_2: 0x13,
|
||||||
|
POKEDEX_SHOW_3: 0x14,
|
||||||
|
POKEDEX_SHOW_4: 0x15,
|
||||||
|
POKEDEX_SHOW_5: 0x16,
|
||||||
|
POKEMON_MUSIC_2: 0x17,
|
||||||
|
POKEMON_MUSIC_3: 0x18,
|
||||||
|
POKEMON_MUSIC_4: 0x19,
|
||||||
|
POKEMON_MUSIC_5: 0x1a,
|
||||||
|
POKEMON_MUSIC_6: 0x1b,
|
||||||
|
POKEMON_MUSIC_7: 0x1c,
|
||||||
|
LETS_ALL_SING_2: 0x1d,
|
||||||
|
LUCKY_NUMBER_SHOW_2: 0x1e,
|
||||||
|
LUCKY_NUMBER_SHOW_3: 0x1f,
|
||||||
|
LUCKY_NUMBER_SHOW_4: 0x20,
|
||||||
|
LUCKY_NUMBER_SHOW_5: 0x21,
|
||||||
|
LUCKY_NUMBER_SHOW_6: 0x22,
|
||||||
|
LUCKY_NUMBER_SHOW_7: 0x23,
|
||||||
|
LUCKY_NUMBER_SHOW_8: 0x24,
|
||||||
|
LUCKY_NUMBER_SHOW_9: 0x25,
|
||||||
|
LUCKY_NUMBER_SHOW_10: 0x26,
|
||||||
|
LUCKY_NUMBER_SHOW_11: 0x27,
|
||||||
|
LUCKY_NUMBER_SHOW_12: 0x28,
|
||||||
|
LUCKY_NUMBER_SHOW_13: 0x29,
|
||||||
|
LUCKY_NUMBER_SHOW_14: 0x2a,
|
||||||
|
LUCKY_NUMBER_SHOW_15: 0x2b,
|
||||||
|
PLACES_AND_PEOPLE_2: 0x2c,
|
||||||
|
PLACES_AND_PEOPLE_3: 0x2d,
|
||||||
|
PLACES_AND_PEOPLE_4: 0x2e,
|
||||||
|
PLACES_AND_PEOPLE_5: 0x2f,
|
||||||
|
PLACES_AND_PEOPLE_6: 0x30,
|
||||||
|
PLACES_AND_PEOPLE_7: 0x31,
|
||||||
|
ROCKET_RADIO_2: 0x32,
|
||||||
|
ROCKET_RADIO_10: 0x3a,
|
||||||
|
OAKS_POKEMON_TALK_10: 0x3b,
|
||||||
|
OAKS_POKEMON_TALK_11: 0x3c,
|
||||||
|
OAKS_POKEMON_TALK_12: 0x3d,
|
||||||
|
OAKS_POKEMON_TALK_13: 0x3e,
|
||||||
|
OAKS_POKEMON_TALK_14: 0x3f,
|
||||||
|
BUENAS_PASSWORD_2: 0x40,
|
||||||
|
BUENAS_PASSWORD_3: 0x41,
|
||||||
|
BUENAS_PASSWORD_4: 0x42,
|
||||||
|
BUENAS_PASSWORD_5: 0x43,
|
||||||
|
BUENAS_PASSWORD_6: 0x44,
|
||||||
|
BUENAS_PASSWORD_7: 0x45,
|
||||||
|
BUENAS_PASSWORD_8: 0x46,
|
||||||
|
BUENAS_PASSWORD_9: 0x47,
|
||||||
|
BUENAS_PASSWORD_19: 0x51,
|
||||||
|
BUENAS_PASSWORD_20: 0x52,
|
||||||
|
BUENAS_PASSWORD_21: 0x53,
|
||||||
|
RADIO_SCROLL: 0x54,
|
||||||
|
POKEDEX_SHOW_6: 0x55,
|
||||||
|
POKEDEX_SHOW_7: 0x56,
|
||||||
|
POKEDEX_SHOW_8: 0x57,
|
||||||
|
MAPRADIO_POKEMON_CHANNEL: 0,
|
||||||
|
MAPRADIO_OAKS_POKEMON_TALK: 1,
|
||||||
|
MAPRADIO_POKEDEX_SHOW: 2,
|
||||||
|
MAPRADIO_POKEMON_MUSIC: 3,
|
||||||
|
MAPRADIO_LUCKY_CHANNEL: 4,
|
||||||
|
MAPRADIO_UNOWN: 5,
|
||||||
|
MAPRADIO_PLACES_PEOPLE: 6,
|
||||||
|
MAPRADIO_LETS_ALL_SING: 7,
|
||||||
|
MAPRADIO_ROCKET: 8,
|
||||||
|
NUM_PASSWORD_CATEGORIES: 11,
|
||||||
|
NUM_PASSWORDS_PER_CATEGORY: 3,
|
||||||
|
BUENA_MON: 0,
|
||||||
|
BUENA_ITEM: 1,
|
||||||
|
BUENA_MOVE: 2,
|
||||||
|
BUENA_STRING: 3,
|
||||||
|
NITE_HOUR: 18,
|
||||||
|
LANDMARK_RUINS_OF_ALPH: 0x09,
|
||||||
|
LANDMARK_MAHOGANY_TOWN: 0x24,
|
||||||
|
LANDMARK_ROUTE_43: 0x25,
|
||||||
|
LANDMARK_LAKE_OF_RAGE: 0x26,
|
||||||
|
LANDMARK_FAST_SHIP: 95,
|
||||||
|
KANTO_LANDMARK: 47,
|
||||||
|
} as const;
|
||||||
|
const L = RADIO_LINES;
|
||||||
|
|
||||||
|
/** wPokegearRadioMusicPlaying's markers for ExitPokegearRadio_HandleMusic. */
|
||||||
|
export const RESTART_MAP_MUSIC = 0xfe;
|
||||||
|
export const ENTER_MAP_MUSIC = 0xff;
|
||||||
|
|
||||||
|
export interface RadioHost {
|
||||||
|
reader: RomReader;
|
||||||
|
data: PokemonData;
|
||||||
|
dex: PokedexData;
|
||||||
|
/** The draft the radio may change: the Lucky Number, Buena's password. */
|
||||||
|
state: ScriptState;
|
||||||
|
text(): TextContext;
|
||||||
|
random(): number;
|
||||||
|
/** wTimeOfDay, hHours and GetWeekday. */
|
||||||
|
timeOfDay(): number;
|
||||||
|
hours(): number;
|
||||||
|
weekday(): number;
|
||||||
|
/** The player's landmark (wPokegearMapPlayerIconLandmark, IsInJohto). */
|
||||||
|
landmark(): number;
|
||||||
|
/** GetWorldMapLocation for any map. */
|
||||||
|
landmarkOf(mapId: string): number;
|
||||||
|
/** The Lucky Number routines' context. */
|
||||||
|
special(): SpecialContext;
|
||||||
|
sound?: GameSound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radio station playing (engine/pokegear/radio.asm's PlayRadioShow and
|
||||||
|
* pokegear.asm's LoadStation_*): wCurRadioLine's segment runs once per
|
||||||
|
* video frame; each line stays 100 frames before the box scrolls up. The
|
||||||
|
* box shows `lines` (the top and bottom rows). */
|
||||||
|
export class RadioShow {
|
||||||
|
/** wCurRadioLine, wNextRadioLine, wRadioTextDelay, wNumRadioLinesPrinted. */
|
||||||
|
private line: number;
|
||||||
|
private next = 0;
|
||||||
|
private delay = 0;
|
||||||
|
private printed = 0;
|
||||||
|
/** wOaksPKMNTalkSegmentCounter and wCurPartySpecies. */
|
||||||
|
private segments = 0;
|
||||||
|
private species = 0;
|
||||||
|
/** The Pokédex show's lines still to come (wPokedexShowPointerAddr). */
|
||||||
|
private dexLines: string[] = [];
|
||||||
|
/** wRadioText: the line copied for printing. */
|
||||||
|
private pending = "";
|
||||||
|
private strings: Record<number, string> = {};
|
||||||
|
/** The text box's two rows. */
|
||||||
|
lines: [string, string] = ["", ""];
|
||||||
|
/** The station name LoadStation_* placed, or BuenasPassword1's. */
|
||||||
|
name: string;
|
||||||
|
/** wPokegearRadioMusicPlaying. */
|
||||||
|
musicPlaying = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private host: RadioHost,
|
||||||
|
station: number,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
this.line = station;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PlayRadioShow: one frame. */
|
||||||
|
frame() {
|
||||||
|
// Team Rocket broadcasts on every station from the tower, in Johto.
|
||||||
|
if (
|
||||||
|
this.line < L.POKE_FLUTE_RADIO &&
|
||||||
|
this.host.state.flags.has(ENGINE_FLAGS.rocketsInRadioTower) &&
|
||||||
|
inJohto(this.host.landmark())
|
||||||
|
)
|
||||||
|
this.line = L.ROCKET_RADIO;
|
||||||
|
this.segment(this.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
private textLine(address: number) {
|
||||||
|
const context = { ...this.host.text() };
|
||||||
|
context.strings = { ...context.strings, ...this.strings };
|
||||||
|
return readText(this.host.reader, address, context).replace(/\n+/g, " ").trim();
|
||||||
|
}
|
||||||
|
/** NextRadioLine: copy a text, then print it. */
|
||||||
|
private nextLine(address: number, next: number) {
|
||||||
|
this.pending = this.textLine(address);
|
||||||
|
this.printLine(next);
|
||||||
|
}
|
||||||
|
/** PrintRadioLine: the first two lines fill the box, later ones the
|
||||||
|
* bottom row; the box then waits 100 frames before scrolling. */
|
||||||
|
private printLine(next: number) {
|
||||||
|
this.next = next;
|
||||||
|
if (this.printed < 2) {
|
||||||
|
this.printed++;
|
||||||
|
if (this.printed === 1) this.lines = [this.pending, this.lines[1]];
|
||||||
|
else this.lines = [this.lines[0], this.pending];
|
||||||
|
} else this.lines = [this.lines[0], this.pending];
|
||||||
|
this.line = L.RADIO_SCROLL;
|
||||||
|
this.delay = 100;
|
||||||
|
}
|
||||||
|
/** RadioScroll */
|
||||||
|
private scroll() {
|
||||||
|
if (this.delay) {
|
||||||
|
this.delay--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.line = this.next;
|
||||||
|
if (this.printed !== 1) this.lines = [this.lines[1], ""];
|
||||||
|
else this.lines = [this.lines[0], ""];
|
||||||
|
}
|
||||||
|
/** RadioMusicRestartDE: the station's song, which also becomes the map
|
||||||
|
* music. */
|
||||||
|
private music(song: number) {
|
||||||
|
this.musicPlaying = song;
|
||||||
|
const sound = this.host.sound;
|
||||||
|
if (!sound) return;
|
||||||
|
sound.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
sound.engine.mapMusic = song;
|
||||||
|
sound.playMusic(song);
|
||||||
|
}
|
||||||
|
/** StartRadioStation: on the first line, an empty box and the channel's
|
||||||
|
* song (RadioChannelSongs). */
|
||||||
|
private start() {
|
||||||
|
if (this.printed) return;
|
||||||
|
this.lines = ["", ""];
|
||||||
|
const r = this.host.reader;
|
||||||
|
this.music(r.word(RADIO.RadioChannelSongs + this.line * 2));
|
||||||
|
}
|
||||||
|
private random() {
|
||||||
|
return this.host.random() & 0xff;
|
||||||
|
}
|
||||||
|
private far(table: number, index: number) {
|
||||||
|
const r = this.host.reader;
|
||||||
|
return r.near(table + index * 2, r.bank(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- segments
|
||||||
|
|
||||||
|
private segment(line: number) {
|
||||||
|
const r = this.host.reader,
|
||||||
|
data = this.host.data,
|
||||||
|
state = this.host.state;
|
||||||
|
switch (line) {
|
||||||
|
case L.RADIO_SCROLL:
|
||||||
|
return this.scroll();
|
||||||
|
// Oak's Pokémon Talk
|
||||||
|
case L.OAKS_POKEMON_TALK:
|
||||||
|
this.segments = 5;
|
||||||
|
this.start();
|
||||||
|
return this.nextLine(RADIO.OPT_IntroText1, L.OAKS_POKEMON_TALK_2);
|
||||||
|
case L.OAKS_POKEMON_TALK_2:
|
||||||
|
return this.nextLine(RADIO.OPT_IntroText2, L.OAKS_POKEMON_TALK_3);
|
||||||
|
case L.OAKS_POKEMON_TALK_3:
|
||||||
|
return this.nextLine(RADIO.OPT_IntroText3, L.OAKS_POKEMON_TALK_4);
|
||||||
|
case L.OAKS_POKEMON_TALK_4: {
|
||||||
|
// A random route, then a random Pokémon of its morning, day or
|
||||||
|
// night list's middle slots.
|
||||||
|
const routes = (RADIO["OaksPKMNTalkRoutes.End"] - RADIO.OaksPKMNTalkRoutes) / 2;
|
||||||
|
let route: number;
|
||||||
|
do route = this.random() & 0x1f;
|
||||||
|
while (route >= routes);
|
||||||
|
const group = r.byte(RADIO.OaksPKMNTalkRoutes + route * 2),
|
||||||
|
number = r.byte(RADIO.OaksPKMNTalkRoutes + route * 2 + 1);
|
||||||
|
let entry = SYMBOLS.johtoGrassWildMons;
|
||||||
|
while (r.byte(entry) !== 0xff && !(r.byte(entry) === group && r.byte(entry + 1) === number))
|
||||||
|
entry += 47;
|
||||||
|
// .overflow: the previous line prints again.
|
||||||
|
if (r.byte(entry) === 0xff) return this.printLine(L.OAKS_POKEMON_TALK);
|
||||||
|
let time: number;
|
||||||
|
do time = this.random() & 3;
|
||||||
|
while (time === 3);
|
||||||
|
let slot: number;
|
||||||
|
do slot = this.random() & 7;
|
||||||
|
while (slot < 2 || slot >= 5);
|
||||||
|
this.species = r.byte(entry + 5 + time * 14 + slot * 2 + 1);
|
||||||
|
this.strings[RAM.monOrItemNameBuffer] = data.speciesName(this.species);
|
||||||
|
this.strings[RAM.stringBuffer1] = landmarkName(r, this.host.landmarkOf(`${group}:${number}`), this.host.text());
|
||||||
|
this.pending = this.textLine(RADIO.OPT_OakText1);
|
||||||
|
return this.printLine(L.OAKS_POKEMON_TALK_5);
|
||||||
|
}
|
||||||
|
case L.OAKS_POKEMON_TALK_5:
|
||||||
|
return this.nextLine(RADIO.OPT_OakText2, L.OAKS_POKEMON_TALK_6);
|
||||||
|
case L.OAKS_POKEMON_TALK_6:
|
||||||
|
return this.nextLine(RADIO.OPT_OakText3, L.OAKS_POKEMON_TALK_7);
|
||||||
|
case L.OAKS_POKEMON_TALK_7:
|
||||||
|
this.strings[RAM.stringBuffer1] = data.speciesName(this.species);
|
||||||
|
return this.nextLine(RADIO.OPT_MaryText1, L.OAKS_POKEMON_TALK_8);
|
||||||
|
case L.OAKS_POKEMON_TALK_8:
|
||||||
|
return this.nextLine(this.far(RADIO["OaksPKMNTalk8.Adverbs"], this.random() & 15), L.OAKS_POKEMON_TALK_9);
|
||||||
|
case L.OAKS_POKEMON_TALK_9: {
|
||||||
|
const text = this.far(RADIO["OaksPKMNTalk9.Adjectives"], this.random() & 15);
|
||||||
|
let next: number = L.OAKS_POKEMON_TALK_4;
|
||||||
|
if (!--this.segments) {
|
||||||
|
this.segments = 5;
|
||||||
|
next = L.OAKS_POKEMON_TALK_10;
|
||||||
|
}
|
||||||
|
return this.nextLine(text, next);
|
||||||
|
}
|
||||||
|
case L.OAKS_POKEMON_TALK_10:
|
||||||
|
// RadioMusicRestartPokemonChannel, then "#MON" alone.
|
||||||
|
this.musicPlaying = RESTART_MAP_MUSIC;
|
||||||
|
this.host.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
this.host.sound?.playMusic(SOUND.MUSIC_POKEMON_CHANNEL);
|
||||||
|
this.lines = [this.textLine(RADIO.OPT_PokemonChannelText), ""];
|
||||||
|
this.line = L.OAKS_POKEMON_TALK_11;
|
||||||
|
this.delay = 100;
|
||||||
|
return;
|
||||||
|
case L.OAKS_POKEMON_TALK_11:
|
||||||
|
case L.OAKS_POKEMON_TALK_12:
|
||||||
|
case L.OAKS_POKEMON_TALK_13: {
|
||||||
|
if (--this.delay) return;
|
||||||
|
const pokemon = this.lines[0];
|
||||||
|
if (line === L.OAKS_POKEMON_TALK_11) this.lines = [`${pokemon} ${pokemon}`, ""];
|
||||||
|
else if (line === L.OAKS_POKEMON_TALK_12) this.lines = [this.lines[0], `${pokemon.split(" ")[0]} Channel`];
|
||||||
|
this.line = line + 1;
|
||||||
|
this.delay = 100;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case L.OAKS_POKEMON_TALK_14:
|
||||||
|
if (--this.delay) return;
|
||||||
|
this.music(SOUND.MUSIC_POKEMON_TALK);
|
||||||
|
this.lines = ["", ""];
|
||||||
|
this.next = L.OAKS_POKEMON_TALK_4;
|
||||||
|
this.printed = 0;
|
||||||
|
this.line = L.RADIO_SCROLL;
|
||||||
|
this.delay = 10;
|
||||||
|
return;
|
||||||
|
// The Pokédex Show
|
||||||
|
case L.POKEDEX_SHOW: {
|
||||||
|
this.start();
|
||||||
|
if (!state.pokedex.caught.size) return;
|
||||||
|
let species: number;
|
||||||
|
do species = this.random();
|
||||||
|
while (species >= NUM_POKEMON || !state.pokedex.caught.has(species + 1));
|
||||||
|
this.species = species + 1;
|
||||||
|
this.strings[RAM.stringBuffer1] = data.speciesName(this.species);
|
||||||
|
return this.nextLine(RADIO.PokedexShowText, L.POKEDEX_SHOW_2);
|
||||||
|
}
|
||||||
|
case L.POKEDEX_SHOW_2: {
|
||||||
|
// The species category, then the entry's six lines one by one.
|
||||||
|
const entry = this.host.dex.entry(this.species);
|
||||||
|
this.dexLines = [...entry.pages[0].split("\n"), ...entry.pages[1].split("\n")];
|
||||||
|
this.pending = entry.category;
|
||||||
|
return this.printLine(L.POKEDEX_SHOW_3);
|
||||||
|
}
|
||||||
|
case L.POKEDEX_SHOW_3:
|
||||||
|
case L.POKEDEX_SHOW_4:
|
||||||
|
case L.POKEDEX_SHOW_5:
|
||||||
|
case L.POKEDEX_SHOW_6:
|
||||||
|
case L.POKEDEX_SHOW_7:
|
||||||
|
case L.POKEDEX_SHOW_8: {
|
||||||
|
this.pending = this.dexLines.shift() ?? "";
|
||||||
|
const next =
|
||||||
|
line === L.POKEDEX_SHOW_5
|
||||||
|
? L.POKEDEX_SHOW_6
|
||||||
|
: line === L.POKEDEX_SHOW_8
|
||||||
|
? L.POKEDEX_SHOW
|
||||||
|
: line + 1;
|
||||||
|
return this.printLine(next);
|
||||||
|
}
|
||||||
|
// Pokémon Music and Let's All Sing
|
||||||
|
case L.POKEMON_MUSIC:
|
||||||
|
this.startMusicChannel();
|
||||||
|
return this.nextLine(RADIO.BenIntroText1, L.POKEMON_MUSIC_2);
|
||||||
|
case L.POKEMON_MUSIC_2:
|
||||||
|
return this.nextLine(RADIO.BenIntroText2, L.POKEMON_MUSIC_3);
|
||||||
|
case L.POKEMON_MUSIC_3:
|
||||||
|
return this.nextLine(RADIO.BenIntroText3, L.POKEMON_MUSIC_4);
|
||||||
|
case L.LETS_ALL_SING:
|
||||||
|
this.startMusicChannel();
|
||||||
|
return this.nextLine(RADIO.FernIntroText1, L.LETS_ALL_SING_2);
|
||||||
|
case L.LETS_ALL_SING_2:
|
||||||
|
return this.nextLine(RADIO.FernIntroText2, L.POKEMON_MUSIC_4);
|
||||||
|
case L.POKEMON_MUSIC_4:
|
||||||
|
return this.nextLine(RADIO.BenFernText1, L.POKEMON_MUSIC_5);
|
||||||
|
case L.POKEMON_MUSIC_5:
|
||||||
|
return this.nextLine(
|
||||||
|
this.host.weekday() & 1 ? RADIO.BenFernText2B : RADIO.BenFernText2A,
|
||||||
|
L.POKEMON_MUSIC_6,
|
||||||
|
);
|
||||||
|
case L.POKEMON_MUSIC_6:
|
||||||
|
return this.nextLine(
|
||||||
|
this.host.weekday() & 1 ? RADIO.BenFernText3B : RADIO.BenFernText3A,
|
||||||
|
L.POKEMON_MUSIC_7,
|
||||||
|
);
|
||||||
|
case L.POKEMON_MUSIC_7:
|
||||||
|
return;
|
||||||
|
// The Lucky Channel
|
||||||
|
case L.LUCKY_CHANNEL: {
|
||||||
|
this.start();
|
||||||
|
const special = this.host.special();
|
||||||
|
if (checkLuckyNumberShowFlag(special)) resetLuckyNumberShowFlag(special);
|
||||||
|
return this.nextLine(RADIO.LC_Text1, L.LUCKY_NUMBER_SHOW_2);
|
||||||
|
}
|
||||||
|
case L.LUCKY_NUMBER_SHOW_2:
|
||||||
|
case L.LUCKY_NUMBER_SHOW_3:
|
||||||
|
case L.LUCKY_NUMBER_SHOW_4:
|
||||||
|
case L.LUCKY_NUMBER_SHOW_5:
|
||||||
|
case L.LUCKY_NUMBER_SHOW_6: {
|
||||||
|
const texts = [RADIO.LC_Text2, RADIO.LC_Text3, RADIO.LC_Text4, RADIO.LC_Text5, RADIO.LC_Text6];
|
||||||
|
return this.nextLine(texts[line - L.LUCKY_NUMBER_SHOW_2], line + 1);
|
||||||
|
}
|
||||||
|
case L.LUCKY_NUMBER_SHOW_7:
|
||||||
|
return this.nextLine(RADIO.LC_Text7, L.LUCKY_NUMBER_SHOW_8);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_8:
|
||||||
|
this.strings[RAM.stringBuffer1] = String(state.luckyNumber.id).padStart(5, "0");
|
||||||
|
return this.nextLine(RADIO.LC_Text8, L.LUCKY_NUMBER_SHOW_9);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_9:
|
||||||
|
return this.nextLine(RADIO.LC_Text9, L.LUCKY_NUMBER_SHOW_10);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_10:
|
||||||
|
return this.nextLine(RADIO.LC_Text7, L.LUCKY_NUMBER_SHOW_11);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_11:
|
||||||
|
return this.nextLine(RADIO.LC_Text8, L.LUCKY_NUMBER_SHOW_12);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_12:
|
||||||
|
return this.nextLine(RADIO.LC_Text10, L.LUCKY_NUMBER_SHOW_13);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_13:
|
||||||
|
return this.nextLine(RADIO.LC_Text11, this.random() ? L.LUCKY_CHANNEL : L.LUCKY_NUMBER_SHOW_14);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_14:
|
||||||
|
return this.nextLine(RADIO.LC_DragText1, L.LUCKY_NUMBER_SHOW_15);
|
||||||
|
case L.LUCKY_NUMBER_SHOW_15:
|
||||||
|
return this.nextLine(RADIO.LC_DragText2, L.LUCKY_CHANNEL);
|
||||||
|
// Places and People
|
||||||
|
case L.PLACES_AND_PEOPLE:
|
||||||
|
this.start();
|
||||||
|
return this.nextLine(RADIO.PnP_Text1, L.PLACES_AND_PEOPLE_2);
|
||||||
|
case L.PLACES_AND_PEOPLE_2:
|
||||||
|
return this.nextLine(RADIO.PnP_Text2, L.PLACES_AND_PEOPLE_3);
|
||||||
|
case L.PLACES_AND_PEOPLE_3:
|
||||||
|
return this.nextLine(
|
||||||
|
RADIO.PnP_Text3,
|
||||||
|
this.random() < 123 ? L.PLACES_AND_PEOPLE_4 : L.PLACES_AND_PEOPLE_6,
|
||||||
|
);
|
||||||
|
case L.PLACES_AND_PEOPLE_4: {
|
||||||
|
// A random trainer class, but none the player has yet to meet.
|
||||||
|
const hidden = state.hallOfFame
|
||||||
|
? this.allKantoBadges()
|
||||||
|
? RADIO.PnP_HiddenPeople_BeatKanto
|
||||||
|
: RADIO.PnP_HiddenPeople_BeatE4
|
||||||
|
: RADIO.PnP_HiddenPeople;
|
||||||
|
const excluded: number[] = [];
|
||||||
|
for (let p = hidden; r.byte(p) !== 0xff; p++) excluded.push(r.byte(p));
|
||||||
|
let classId: number;
|
||||||
|
do classId = ((this.random() & 0x7f) + 1) & 0xff;
|
||||||
|
while (classId >= NUM_TRAINER_CLASSES || excluded.includes(classId));
|
||||||
|
const trainers = new TrainerData(r);
|
||||||
|
this.strings[RAM.stringBuffer2] = trainers.className(classId);
|
||||||
|
this.strings[RAM.stringBuffer1] = trainers.trainer(classId, 1).name;
|
||||||
|
return this.nextLine(RADIO.PnP_Text4, L.PLACES_AND_PEOPLE_5);
|
||||||
|
}
|
||||||
|
case L.PLACES_AND_PEOPLE_5:
|
||||||
|
case L.PLACES_AND_PEOPLE_7: {
|
||||||
|
const table = line === L.PLACES_AND_PEOPLE_5 ? RADIO["PeoplePlaces5.Adjectives"] : RADIO["PeoplePlaces7.Adjectives"];
|
||||||
|
const text = this.far(table, this.random() & 15);
|
||||||
|
const next =
|
||||||
|
this.random() < 10
|
||||||
|
? L.PLACES_AND_PEOPLE
|
||||||
|
: this.random() < 123
|
||||||
|
? L.PLACES_AND_PEOPLE_4
|
||||||
|
: L.PLACES_AND_PEOPLE_6;
|
||||||
|
return this.nextLine(text, next);
|
||||||
|
}
|
||||||
|
case L.PLACES_AND_PEOPLE_6: {
|
||||||
|
const places = (RADIO["PnP_Places.End"] - RADIO.PnP_Places) / 2;
|
||||||
|
let place: number;
|
||||||
|
do place = this.random();
|
||||||
|
while (place >= places);
|
||||||
|
const map = `${r.byte(RADIO.PnP_Places + place * 2)}:${r.byte(RADIO.PnP_Places + place * 2 + 1)}`;
|
||||||
|
this.strings[RAM.stringBuffer1] = landmarkName(r, this.host.landmarkOf(map), this.host.text());
|
||||||
|
return this.nextLine(RADIO.PnP_Text5, L.PLACES_AND_PEOPLE_7);
|
||||||
|
}
|
||||||
|
// Team Rocket's broadcast
|
||||||
|
case L.ROCKET_RADIO:
|
||||||
|
this.start();
|
||||||
|
return this.nextLine(RADIO.RocketRadioText1, L.ROCKET_RADIO_2);
|
||||||
|
// Stations with music only
|
||||||
|
case L.POKE_FLUTE_RADIO:
|
||||||
|
case L.UNOWN_RADIO:
|
||||||
|
case L.EVOLUTION_RADIO:
|
||||||
|
this.start();
|
||||||
|
this.printed = 1;
|
||||||
|
return;
|
||||||
|
// Buena's Password
|
||||||
|
case L.BUENAS_PASSWORD:
|
||||||
|
return this.buena1();
|
||||||
|
case L.BUENAS_PASSWORD_2:
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText2, L.BUENAS_PASSWORD_3);
|
||||||
|
case L.BUENAS_PASSWORD_3:
|
||||||
|
if (this.host.hours() < L.NITE_HOUR) return this.buenaAfterMidnight(RADIO.BuenaRadioText3);
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText3, L.BUENAS_PASSWORD_4);
|
||||||
|
case L.BUENAS_PASSWORD_4: {
|
||||||
|
if (this.host.hours() < L.NITE_HOUR) return this.buena8();
|
||||||
|
let password = state.memory.wBuenasPassword ?? 0;
|
||||||
|
// A new password once a day, kept in wBuenasPassword.
|
||||||
|
if (!state.flags.has(ENGINE_FLAGS.buenasPassword)) {
|
||||||
|
let category: number, word: number;
|
||||||
|
do category = this.random() & 15;
|
||||||
|
while (category >= L.NUM_PASSWORD_CATEGORIES);
|
||||||
|
do word = this.random() & 3;
|
||||||
|
while (word >= L.NUM_PASSWORDS_PER_CATEGORY);
|
||||||
|
password = (category << 4) + word;
|
||||||
|
state.memory.wBuenasPassword = password;
|
||||||
|
state.flags.add(ENGINE_FLAGS.buenasPassword);
|
||||||
|
}
|
||||||
|
this.strings[RAM.stringBuffer1] = buenasPassword(r, data, password, this.host.text());
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText4, L.BUENAS_PASSWORD_5);
|
||||||
|
}
|
||||||
|
case L.BUENAS_PASSWORD_5:
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText5, L.BUENAS_PASSWORD_6);
|
||||||
|
case L.BUENAS_PASSWORD_6:
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText6, L.BUENAS_PASSWORD_7);
|
||||||
|
case L.BUENAS_PASSWORD_7:
|
||||||
|
if (this.host.hours() < L.NITE_HOUR) return this.buenaAfterMidnight(RADIO.BuenaRadioText7);
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText7, L.BUENAS_PASSWORD);
|
||||||
|
case L.BUENAS_PASSWORD_8:
|
||||||
|
return this.buena8();
|
||||||
|
case L.BUENAS_PASSWORD_19:
|
||||||
|
return this.nextLine(RADIO.BuenaRadioMidnightText10, L.BUENAS_PASSWORD_20);
|
||||||
|
case L.BUENAS_PASSWORD_20:
|
||||||
|
return this.buena20();
|
||||||
|
case L.BUENAS_PASSWORD_21:
|
||||||
|
this.line = L.BUENAS_PASSWORD;
|
||||||
|
this.printed = 0;
|
||||||
|
if (this.host.hours() >= L.NITE_HOUR) return this.buena1();
|
||||||
|
return this.nextLine(RADIO.BuenaOffTheAirText, L.BUENAS_PASSWORD_21);
|
||||||
|
default:
|
||||||
|
if (line > L.ROCKET_RADIO_2 - 1 && line <= L.ROCKET_RADIO_10) {
|
||||||
|
// RocketRadio2-10: the texts in turn, then the first again.
|
||||||
|
const index = line - L.ROCKET_RADIO_2 + 2;
|
||||||
|
const texts = [
|
||||||
|
RADIO.RocketRadioText2, RADIO.RocketRadioText3, RADIO.RocketRadioText4, RADIO.RocketRadioText5,
|
||||||
|
RADIO.RocketRadioText6, RADIO.RocketRadioText7, RADIO.RocketRadioText8, RADIO.RocketRadioText9,
|
||||||
|
RADIO.RocketRadioText10,
|
||||||
|
];
|
||||||
|
return this.nextLine(texts[index - 2], line === L.ROCKET_RADIO_10 ? L.ROCKET_RADIO : line + 1);
|
||||||
|
}
|
||||||
|
if (line > L.BUENAS_PASSWORD_8 && line < L.BUENAS_PASSWORD_19) {
|
||||||
|
// BuenasPassword9-18: the midnight texts 1 to 10.
|
||||||
|
const texts = [
|
||||||
|
RADIO.BuenaRadioMidnightText1, RADIO.BuenaRadioMidnightText2, RADIO.BuenaRadioMidnightText3,
|
||||||
|
RADIO.BuenaRadioMidnightText4, RADIO.BuenaRadioMidnightText5, RADIO.BuenaRadioMidnightText6,
|
||||||
|
RADIO.BuenaRadioMidnightText7, RADIO.BuenaRadioMidnightText8, RADIO.BuenaRadioMidnightText9,
|
||||||
|
RADIO.BuenaRadioMidnightText10,
|
||||||
|
];
|
||||||
|
return this.nextLine(texts[line - L.BUENAS_PASSWORD_9], line + 1);
|
||||||
|
}
|
||||||
|
throw new Error(`Radio segment ${line} is unknown.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** StartPokemonMusicChannel: an empty box, then the Pokémon March on
|
||||||
|
* even weekdays and the Lullaby on odd ones. */
|
||||||
|
private startMusicChannel() {
|
||||||
|
this.lines = ["", ""];
|
||||||
|
this.music(this.host.weekday() & 1 ? SOUND.MUSIC_POKEMON_LULLABY : SOUND.MUSIC_POKEMON_MARCH);
|
||||||
|
}
|
||||||
|
private allKantoBadges() {
|
||||||
|
for (let bit = 0; bit < 8; bit++)
|
||||||
|
if (!this.host.state.flags.has(OW.ENGINE_ZEPHYRBADGE + 8 + bit)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BuenasPassword1: the show plays from 6 PM; before, the off-air or
|
||||||
|
* midnight texts. */
|
||||||
|
private buena1() {
|
||||||
|
if (this.host.hours() < L.NITE_HOUR) {
|
||||||
|
if (!this.printed) return this.buena20();
|
||||||
|
return this.buena8();
|
||||||
|
}
|
||||||
|
this.start();
|
||||||
|
this.name = readString(this.host.reader, RADIO.BuenasPasswordChannelName, this.host.text());
|
||||||
|
return this.nextLine(RADIO.BuenaRadioText1, L.BUENAS_PASSWORD_2);
|
||||||
|
}
|
||||||
|
private buenaAfterMidnight(text: number) {
|
||||||
|
this.host.state.flags.delete(ENGINE_FLAGS.buenasPassword);
|
||||||
|
return this.nextLine(text, L.BUENAS_PASSWORD_8);
|
||||||
|
}
|
||||||
|
private buena8() {
|
||||||
|
this.host.state.flags.delete(ENGINE_FLAGS.buenasPassword);
|
||||||
|
return this.nextLine(RADIO.BuenaRadioMidnightText10, L.BUENAS_PASSWORD_9);
|
||||||
|
}
|
||||||
|
private buena20() {
|
||||||
|
// NoRadioMusic and NoRadioName, then "off the air".
|
||||||
|
this.musicPlaying = ENTER_MAP_MUSIC;
|
||||||
|
this.host.sound?.playMusic(SOUND.MUSIC_NONE);
|
||||||
|
this.name = "";
|
||||||
|
this.lines = ["", ""];
|
||||||
|
this.host.state.flags.delete(ENGINE_FLAGS.buenasPassword);
|
||||||
|
this.line = L.BUENAS_PASSWORD;
|
||||||
|
this.printed = 0;
|
||||||
|
return this.nextLine(RADIO.BuenaOffTheAirText, L.BUENAS_PASSWORD_21);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IsInJohto, as the radio's stations test it: Johto's landmarks and the
|
||||||
|
* S.S. Aqua. */
|
||||||
|
export function inJohto(landmark: number) {
|
||||||
|
return landmark === L.LANDMARK_FAST_SHIP || landmark < L.KANTO_LANDMARK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetBuenasPassword: the word of wBuenasPassword's category (high nybble)
|
||||||
|
* and index (low nybble). */
|
||||||
|
export function buenasPassword(r: RomReader, data: PokemonData, password: number, text: TextContext) {
|
||||||
|
const category = r.near(RADIO.BuenasPasswordTable + ((password >> 4) & 0xf) * 2, r.bank(RADIO.BuenasPasswordTable));
|
||||||
|
const kind = r.byte(category),
|
||||||
|
index = password & 0xf;
|
||||||
|
const values = category + 2;
|
||||||
|
if (kind === L.BUENA_MON) return data.speciesName(r.byte(values + index));
|
||||||
|
if (kind === L.BUENA_ITEM) return data.itemName(r.byte(values + index));
|
||||||
|
if (kind === L.BUENA_MOVE) return data.moveName(r.byte(values + index));
|
||||||
|
let p = values;
|
||||||
|
for (let skip = 0; skip < index; skip++) while (r.byte(p++) !== 0x50);
|
||||||
|
return readString(r, p, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Pokégear's stations (RadioChannels): the tuning knob's value
|
||||||
|
* (4 × frequency − 2) and the station there, when it can be heard. */
|
||||||
|
export function radioStation(
|
||||||
|
r: RomReader,
|
||||||
|
knob: number,
|
||||||
|
where: { landmark: number; timeOfDay: number; expnCard: boolean; rocketSignal: boolean; rocketsInRadioTower: boolean },
|
||||||
|
text: TextContext,
|
||||||
|
): { line: number; name: string } | undefined {
|
||||||
|
const name = (label: keyof typeof RADIO) => readString(r, RADIO[label], text);
|
||||||
|
const johto = inJohto(where.landmark);
|
||||||
|
switch (knob) {
|
||||||
|
case 16:
|
||||||
|
if (!johto) return undefined;
|
||||||
|
return where.timeOfDay === 0
|
||||||
|
? { line: L.POKEDEX_SHOW, name: name("PokedexShowName") }
|
||||||
|
: { line: L.OAKS_POKEMON_TALK, name: name("OaksPKMNTalkName") };
|
||||||
|
case 28:
|
||||||
|
return johto ? { line: L.POKEMON_MUSIC, name: name("PokemonMusicName") } : undefined;
|
||||||
|
case 32:
|
||||||
|
return johto ? { line: L.LUCKY_CHANNEL, name: name("LuckyChannelName") } : undefined;
|
||||||
|
case 40:
|
||||||
|
// LoadStation_BuenasPassword names the station only while the
|
||||||
|
// Rockets hold the tower; the show names itself when it plays.
|
||||||
|
return johto
|
||||||
|
? { line: L.BUENAS_PASSWORD, name: where.rocketsInRadioTower ? name("BuenasPasswordName") : "" }
|
||||||
|
: undefined;
|
||||||
|
case 52:
|
||||||
|
return where.landmark === L.LANDMARK_RUINS_OF_ALPH
|
||||||
|
? { line: L.UNOWN_RADIO, name: name("UnownStationName") }
|
||||||
|
: undefined;
|
||||||
|
case 64:
|
||||||
|
return !johto && where.expnCard ? { line: L.PLACES_AND_PEOPLE, name: name("PlacesAndPeopleName") } : undefined;
|
||||||
|
case 72:
|
||||||
|
return !johto && where.expnCard ? { line: L.LETS_ALL_SING, name: name("LetsAllSingName") } : undefined;
|
||||||
|
case 78:
|
||||||
|
return !johto && where.expnCard ? { line: L.POKE_FLUTE_RADIO, name: name("PokeFluteStationName") } : undefined;
|
||||||
|
case 80:
|
||||||
|
return where.rocketSignal &&
|
||||||
|
[L.LANDMARK_MAHOGANY_TOWN, L.LANDMARK_ROUTE_43, L.LANDMARK_LAKE_OF_RAGE].includes(where.landmark as never)
|
||||||
|
? { line: L.EVOLUTION_RADIO, name: name("UnownStationName") }
|
||||||
|
: undefined;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PlayRadioStationPointers (MapRadio): the station a house radio plays. */
|
||||||
|
export function mapRadioStation(
|
||||||
|
r: RomReader,
|
||||||
|
station: number,
|
||||||
|
where: { landmark: number; timeOfDay: number },
|
||||||
|
text: TextContext,
|
||||||
|
): { line: number; name: string } {
|
||||||
|
const name = (label: keyof typeof RADIO) => readString(r, RADIO[label], text);
|
||||||
|
switch (station) {
|
||||||
|
case L.MAPRADIO_POKEMON_CHANNEL:
|
||||||
|
if (!inJohto(where.landmark)) return { line: L.PLACES_AND_PEOPLE, name: name("PlacesAndPeopleName") };
|
||||||
|
return where.timeOfDay === 0
|
||||||
|
? { line: L.POKEDEX_SHOW, name: name("PokedexShowName") }
|
||||||
|
: { line: L.OAKS_POKEMON_TALK, name: name("OaksPKMNTalkName") };
|
||||||
|
case L.MAPRADIO_OAKS_POKEMON_TALK:
|
||||||
|
return { line: L.OAKS_POKEMON_TALK, name: name("OaksPKMNTalkName") };
|
||||||
|
case L.MAPRADIO_POKEDEX_SHOW:
|
||||||
|
return { line: L.POKEDEX_SHOW, name: name("PokedexShowName") };
|
||||||
|
case L.MAPRADIO_POKEMON_MUSIC:
|
||||||
|
return { line: L.POKEMON_MUSIC, name: name("PokemonMusicName") };
|
||||||
|
case L.MAPRADIO_LUCKY_CHANNEL:
|
||||||
|
return { line: L.LUCKY_CHANNEL, name: name("LuckyChannelName") };
|
||||||
|
case L.MAPRADIO_UNOWN:
|
||||||
|
return { line: L.UNOWN_RADIO, name: name("UnownStationName") };
|
||||||
|
case L.MAPRADIO_PLACES_PEOPLE:
|
||||||
|
return { line: L.PLACES_AND_PEOPLE, name: name("PlacesAndPeopleName") };
|
||||||
|
case L.MAPRADIO_LETS_ALL_SING:
|
||||||
|
return { line: L.LETS_ALL_SING, name: name("LetsAllSingName") };
|
||||||
|
case L.MAPRADIO_ROCKET:
|
||||||
|
// LoadStation_RocketRadio uses the sing-along station's name.
|
||||||
|
return { line: L.ROCKET_RADIO, name: name("LetsAllSingName") };
|
||||||
|
default:
|
||||||
|
throw new Error(`Map radio station ${station} is unknown.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ExitPokegearRadio_HandleMusic: the map's music again after the
|
||||||
|
* Pokémon Channel's jingle or silence; a station's song keeps playing. */
|
||||||
|
export function exitRadioMusic(sound: GameSound | undefined, playing: number, playerState: number, mapMusic: number) {
|
||||||
|
if (!sound) return;
|
||||||
|
if (playing === RESTART_MAP_MUSIC) sound.restartMapMusic();
|
||||||
|
else if (playing === ENTER_MAP_MUSIC) sound.playMapMusicBike(mapMusic, playerState);
|
||||||
|
}
|
||||||
@@ -27,6 +27,13 @@ export const OW = {
|
|||||||
COLL_STAIRCASE: 0x7a,
|
COLL_STAIRCASE: 0x7a,
|
||||||
COLL_CAVE: 0x7b,
|
COLL_CAVE: 0x7b,
|
||||||
COLL_WARP_PANEL: 0x7c,
|
COLL_WARP_PANEL: 0x7c,
|
||||||
|
// Fish groups and swarms (map_data_constants.asm, script_constants.asm).
|
||||||
|
FISHGROUP_QWILFISH_SWARM: 6,
|
||||||
|
FISHGROUP_REMORAID_SWARM: 7,
|
||||||
|
FISHGROUP_QWILFISH: 11,
|
||||||
|
FISHGROUP_REMORAID: 12,
|
||||||
|
FISHSWARM_QWILFISH: 1,
|
||||||
|
FISHSWARM_REMORAID: 2,
|
||||||
COLL_WARP_CARPET_RIGHT: 0x7e,
|
COLL_WARP_CARPET_RIGHT: 0x7e,
|
||||||
COLL_COUNTER: 0x90,
|
COLL_COUNTER: 0x90,
|
||||||
HI_NYBBLE_CURRENT: 0x30,
|
HI_NYBBLE_CURRENT: 0x30,
|
||||||
|
|||||||
+136
-12
@@ -19,6 +19,7 @@ import {
|
|||||||
SAVED_WRAM,
|
SAVED_WRAM,
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
type SavedByte,
|
type SavedByte,
|
||||||
|
PHONE,
|
||||||
} from "../rom/profile";
|
} from "../rom/profile";
|
||||||
import { OW } from "./constants";
|
import { OW } from "./constants";
|
||||||
import {
|
import {
|
||||||
@@ -246,6 +247,16 @@ function randomByte(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One overworld tick is two video frames (MaxOverworldDelay). */
|
/** One overworld tick is two video frames (MaxOverworldDelay). */
|
||||||
|
/** A screen that runs every video frame, reading the buttons itself: the
|
||||||
|
* Game Corner's games and the Pokégear's radio card. */
|
||||||
|
export function realtimeChoice(choice: Choice | undefined) {
|
||||||
|
return (
|
||||||
|
(choice?.kind === "screen" && Boolean(choice.realtime)) ||
|
||||||
|
(choice?.kind === "pokegear" && Boolean(choice.view.radio)) ||
|
||||||
|
choice?.kind === "mapradio"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Choices answered through the menu routines that click (MenuClickSound). */
|
/** Choices answered through the menu routines that click (MenuClickSound). */
|
||||||
const CLICKING_CHOICES = new Set<string>([
|
const CLICKING_CHOICES = new Set<string>([
|
||||||
"yesno",
|
"yesno",
|
||||||
@@ -258,6 +269,9 @@ const CLICKING_CHOICES = new Set<string>([
|
|||||||
"pack",
|
"pack",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** PHONE_CONTACT_SIZE: a PhoneContacts entry. */
|
||||||
|
const PHONE_CONTACT_SIZE = 12;
|
||||||
|
|
||||||
/** Which map setup script loads a map, for its music. */
|
/** Which map setup script loads a map, for its music. */
|
||||||
type MapSetup = "warp" | "door" | "connection" | "continue" | "none";
|
type MapSetup = "warp" | "door" | "connection" | "continue" | "none";
|
||||||
|
|
||||||
@@ -354,6 +368,8 @@ export interface Pose {
|
|||||||
}
|
}
|
||||||
export type Choice =
|
export type Choice =
|
||||||
| { kind: "yesno"; selected: 0 | 1; balance?: string }
|
| { kind: "yesno"; selected: 0 | 1; balance?: string }
|
||||||
|
/** A house radio (MapRadio) playing in the text box; A or B stops it. */
|
||||||
|
| { kind: "mapradio"; selected: 0 }
|
||||||
| { kind: "weekday"; names: string[]; selected: number }
|
| { kind: "weekday"; names: string[]; selected: number }
|
||||||
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
||||||
| { kind: "clock"; labels: string[]; selected: number }
|
| { kind: "clock"; labels: string[]; selected: number }
|
||||||
@@ -432,7 +448,8 @@ const buttonChoice = (choice: Choice) =>
|
|||||||
choice.kind === "pokedex" ||
|
choice.kind === "pokedex" ||
|
||||||
choice.kind === "options" ||
|
choice.kind === "options" ||
|
||||||
choice.kind === "flymap" ||
|
choice.kind === "flymap" ||
|
||||||
choice.kind === "screen";
|
choice.kind === "screen" ||
|
||||||
|
choice.kind === "mapradio";
|
||||||
export interface Dialogue {
|
export interface Dialogue {
|
||||||
pages: string[];
|
pages: string[];
|
||||||
index: number;
|
index: number;
|
||||||
@@ -540,6 +557,8 @@ interface ActiveScript {
|
|||||||
interface MemScript {
|
interface MemScript {
|
||||||
script: number;
|
script: number;
|
||||||
callerScript?: number;
|
callerScript?: number;
|
||||||
|
/** wCurCaller, which LoadCallerScript set for a queued call. */
|
||||||
|
caller?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPPOSITE: Record<Direction, Direction> = {
|
const OPPOSITE: Record<Direction, Direction> = {
|
||||||
@@ -823,6 +842,7 @@ export class GameWorld {
|
|||||||
// original InitializeEventsScript, which supplies initial NPC flags.
|
// original InitializeEventsScript, which supplies initial NPC flags.
|
||||||
const entry = this.prepareMap(INITIAL_POSITION.map, "warp");
|
const entry = this.prepareMap(INITIAL_POSITION.map, "warp");
|
||||||
this.applyMap(entry, "none");
|
this.applyMap(entry, "none");
|
||||||
|
this.initCallReceiveDelay();
|
||||||
}
|
}
|
||||||
|
|
||||||
get x() {
|
get x() {
|
||||||
@@ -1710,6 +1730,7 @@ export class GameWorld {
|
|||||||
this.startScript(SYMBOLS.receiveSpecialPhoneCall, PLAYER, {
|
this.startScript(SYMBOLS.receiveSpecialPhoneCall, PLAYER, {
|
||||||
committedPosition: previous,
|
committedPosition: previous,
|
||||||
callerScript: r.far(entry + 3),
|
callerScript: r.far(entry + 3),
|
||||||
|
caller: r.byte(entry + 2),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1792,17 +1813,33 @@ export class GameWorld {
|
|||||||
const collision = this.assets.collision(this.map, this.x, this.y);
|
const collision = this.assets.collision(this.map, this.x, this.y);
|
||||||
const water = this.assets.permission(collision) === OW.WATER_TILE,
|
const water = this.assets.permission(collision) === OW.WATER_TILE,
|
||||||
kanto = this.inKanto();
|
kanto = this.inKanto();
|
||||||
|
// _SwarmWildmonCheck: the swarm table first, on the swarm's map.
|
||||||
|
const swarm = this.swarmMap(state);
|
||||||
const grass = water
|
const grass = water
|
||||||
? undefined
|
? undefined
|
||||||
: this.assets.grassWildMons(this.mapId, kanto);
|
: (swarm && this.assets.grassWildMons(this.mapId, kanto, true)) ||
|
||||||
|
this.assets.grassWildMons(this.mapId, kanto);
|
||||||
const sea = water
|
const sea = water
|
||||||
? this.assets.waterWildMons(this.mapId, kanto)
|
? (swarm && this.assets.waterWildMons(this.mapId, kanto, true)) ||
|
||||||
|
this.assets.waterWildMons(this.mapId, kanto)
|
||||||
: undefined;
|
: undefined;
|
||||||
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
|
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
|
||||||
// ApplyCleanseTagEffectOnEncounterRate
|
// ApplyCleanseTagEffectOnEncounterRate
|
||||||
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
||||||
return { water, grass, sea, rate };
|
return { water, grass, sea, rate };
|
||||||
}
|
}
|
||||||
|
/** _SwarmWildmonCheck's map test: Dunsparce's swarm (when on, only its
|
||||||
|
* map counts), else Yanma's, whose maps a trainer's call recorded. */
|
||||||
|
private swarmMap(state: ScriptState) {
|
||||||
|
const memory = state.memory;
|
||||||
|
const at = (group?: number, number?: number) => `${group ?? 0}:${number ?? 0}` === this.mapId;
|
||||||
|
if (state.flags.has(ENGINE_FLAGS.dunsparceSwarm) && at(memory.wDunsparceMapGroup, memory.wDunsparceMapNumber))
|
||||||
|
return true;
|
||||||
|
return (
|
||||||
|
state.flags.has(ENGINE_FLAGS.yanmaSwarm) &&
|
||||||
|
at(memory.wYanmaMapGroup, memory.wYanmaMapNumber)
|
||||||
|
);
|
||||||
|
}
|
||||||
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||||
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
||||||
* varies the level. Undefined when no valid Pokémon is chosen.
|
* varies the level. Undefined when no valid Pokémon is chosen.
|
||||||
@@ -2292,6 +2329,8 @@ export class GameWorld {
|
|||||||
random: () => this.random() & 0xff,
|
random: () => this.random() & 0xff,
|
||||||
sound: this.sound,
|
sound: this.sound,
|
||||||
mapMusic: (state) => this.mapMusicId(state),
|
mapMusic: (state) => this.mapMusicId(state),
|
||||||
|
initCallReceiveDelay: () => this.initCallReceiveDelay(),
|
||||||
|
landmarkOf: (id) => this.assets.map(id).landmark,
|
||||||
landmark: () => this.assets.map(mapId).landmark,
|
landmark: () => this.assets.map(mapId).landmark,
|
||||||
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
|
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
|
||||||
position: () => ({ x: this.player.x, y: this.player.y }),
|
position: () => ({ x: this.player.x, y: this.player.y }),
|
||||||
@@ -2330,8 +2369,14 @@ export class GameWorld {
|
|||||||
this.assets.collision(this.map, this.player.x + dx, this.player.y + dy) ?? 0;
|
this.assets.collision(this.map, this.player.x + dx, this.player.y + dy) ?? 0;
|
||||||
if (surfing || this.assets.permission(tile) !== OW.WATER_TILE)
|
if (surfing || this.assets.permission(tile) !== OW.WATER_TILE)
|
||||||
return { texts: [] };
|
return { texts: [] };
|
||||||
const fieldMove = { mon: 0 },
|
const fieldMove = { mon: 0 };
|
||||||
group = this.map.fishGroup;
|
// GetFishGroupIndex: a fishing swarm a trainer called about.
|
||||||
|
let group = this.map.fishGroup;
|
||||||
|
const swarm = state.flags.has(ENGINE_FLAGS.fishSwarm) ? state.memory.wFishingSwarmFlag : 0;
|
||||||
|
if (group === OW.FISHGROUP_QWILFISH && swarm === OW.FISHSWARM_QWILFISH)
|
||||||
|
group = OW.FISHGROUP_QWILFISH_SWARM;
|
||||||
|
else if (group === OW.FISHGROUP_REMORAID && swarm === OW.FISHSWARM_REMORAID)
|
||||||
|
group = OW.FISHGROUP_REMORAID_SWARM;
|
||||||
if (!group) return { script: OVERWORLD.Script_NotEvenANibble2, fieldMove };
|
if (!group) return { script: OVERWORLD.Script_NotEvenANibble2, fieldMove };
|
||||||
const hooked = fish(
|
const hooked = fish(
|
||||||
this.assets.reader,
|
this.assets.reader,
|
||||||
@@ -2386,6 +2431,7 @@ export class GameWorld {
|
|||||||
hiddenItem,
|
hiddenItem,
|
||||||
sighted,
|
sighted,
|
||||||
callerScript,
|
callerScript,
|
||||||
|
caller,
|
||||||
fieldMove,
|
fieldMove,
|
||||||
usedItem,
|
usedItem,
|
||||||
fish,
|
fish,
|
||||||
@@ -2405,6 +2451,8 @@ export class GameWorld {
|
|||||||
hiddenItem?: { event: number; item: number };
|
hiddenItem?: { event: number; item: number };
|
||||||
sighted?: number;
|
sighted?: number;
|
||||||
callerScript?: number;
|
callerScript?: number;
|
||||||
|
/** wCurCaller for a received call. */
|
||||||
|
caller?: number;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const committed = this.save();
|
const committed = this.save();
|
||||||
@@ -2426,6 +2474,7 @@ export class GameWorld {
|
|||||||
trainer,
|
trainer,
|
||||||
hiddenItem,
|
hiddenItem,
|
||||||
callerScript,
|
callerScript,
|
||||||
|
caller,
|
||||||
fieldMove,
|
fieldMove,
|
||||||
usedItem,
|
usedItem,
|
||||||
fish,
|
fish,
|
||||||
@@ -2465,7 +2514,7 @@ export class GameWorld {
|
|||||||
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
||||||
useSelect(): boolean {
|
useSelect(): boolean {
|
||||||
const choice = this.dialogue?.choice;
|
const choice = this.dialogue?.choice;
|
||||||
if (choice?.kind === "screen" && choice.realtime) {
|
if (realtimeChoice(choice)) {
|
||||||
this.bufferScreenButton("select");
|
this.bufferScreenButton("select");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2483,7 +2532,7 @@ export class GameWorld {
|
|||||||
* the player stands still. Its changes commit when it closes. */
|
* the player stands still. Its changes commit when it closes. */
|
||||||
openStartMenu(select = false): boolean {
|
openStartMenu(select = false): boolean {
|
||||||
const choice = this.dialogue?.choice;
|
const choice = this.dialogue?.choice;
|
||||||
if (!select && choice?.kind === "screen" && choice.realtime) {
|
if (!select && realtimeChoice(choice)) {
|
||||||
this.bufferScreenButton("start");
|
this.bufferScreenButton("start");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2941,6 +2990,7 @@ export class GameWorld {
|
|||||||
script.memScript ??= {
|
script.memScript ??= {
|
||||||
script: effect.script,
|
script: effect.script,
|
||||||
callerScript: effect.callerScript,
|
callerScript: effect.callerScript,
|
||||||
|
caller: effect.caller,
|
||||||
};
|
};
|
||||||
return false;
|
return false;
|
||||||
case "townmap":
|
case "townmap":
|
||||||
@@ -2955,6 +3005,13 @@ export class GameWorld {
|
|||||||
this.mailView = { ...effect.mail };
|
this.mailView = { ...effect.mail };
|
||||||
this.dialogue = { pages: ["MAIL"], index: 0 };
|
this.dialogue = { pages: ["MAIL"], index: 0 };
|
||||||
return true;
|
return true;
|
||||||
|
case "mapradio":
|
||||||
|
this.dialogue = {
|
||||||
|
pages: [effect.text],
|
||||||
|
index: 0,
|
||||||
|
choice: { kind: "mapradio", selected: 0 },
|
||||||
|
};
|
||||||
|
return true;
|
||||||
case "pokegear":
|
case "pokegear":
|
||||||
this.pokegear = effect.view;
|
this.pokegear = effect.view;
|
||||||
if (!effect.view) return false;
|
if (!effect.view) return false;
|
||||||
@@ -3692,8 +3749,71 @@ export class GameWorld {
|
|||||||
if (this.state.dailyReset === day) return;
|
if (this.state.dailyReset === day) return;
|
||||||
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
||||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||||
|
// Kenji's break: a countdown of 3 to 6 days, sampled again at zero.
|
||||||
|
const memory = this.state.memory,
|
||||||
|
kenji = memory.wKenjiBreakTimer ?? 0;
|
||||||
|
if (!kenji || !(memory.wKenjiBreakTimer = kenji - 1))
|
||||||
|
memory.wKenjiBreakTimer = (this.random() & 3) + 3;
|
||||||
this.state.dailyReset = day;
|
this.state.dailyReset = day;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** InitCallReceiveDelay's and CheckReceiveCallTimer's timer: work RAM
|
||||||
|
* that StartMap restarts whenever the game is loaded. */
|
||||||
|
private callTimer = { cycles: 0, minutes: 20, start: 0 };
|
||||||
|
/** InitCallReceiveDelay */
|
||||||
|
initCallReceiveDelay() {
|
||||||
|
this.callTimer.cycles = 0;
|
||||||
|
this.nextCallReceiveDelay();
|
||||||
|
}
|
||||||
|
/** NextCallReceiveDelay: 20, 10, 5, then 3 minutes between calls. */
|
||||||
|
private nextCallReceiveDelay() {
|
||||||
|
const timer = this.callTimer;
|
||||||
|
timer.minutes = [20, 10, 5, 3][Math.min(timer.cycles, 3)];
|
||||||
|
timer.start = Math.floor(this.gameNow(this.state.clock).getTime() / 60_000);
|
||||||
|
}
|
||||||
|
/** CheckReceiveCallTimer: true (carry) once the delay has passed, which
|
||||||
|
* shortens the next one. */
|
||||||
|
private checkReceiveCallTimer() {
|
||||||
|
const timer = this.callTimer;
|
||||||
|
const now = Math.floor(this.gameNow(this.state.clock).getTime() / 60_000);
|
||||||
|
if (now - timer.start < timer.minutes) return false;
|
||||||
|
timer.cycles = Math.min(timer.cycles + 1, 3);
|
||||||
|
this.nextCallReceiveDelay();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** CheckPhoneCall: off a doorway, once the timer allows, half the time,
|
||||||
|
* with a signal, a registered trainer or caller whose calling hours
|
||||||
|
* include now and who is not on this map rings with their caller
|
||||||
|
* script. */
|
||||||
|
private checkPhoneCall() {
|
||||||
|
const tile = this.assets.collision(this.map, this.player.x, this.player.y);
|
||||||
|
if (
|
||||||
|
tile === OW.COLL_DOOR ||
|
||||||
|
tile === OW.COLL_DOOR_79 ||
|
||||||
|
tile === OW.COLL_STAIRCASE ||
|
||||||
|
tile === OW.COLL_CAVE
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (!this.checkReceiveCallTimer()) return;
|
||||||
|
if (this.random() & 0x80) return;
|
||||||
|
if (this.map.phoneService) return;
|
||||||
|
const r = this.assets.reader,
|
||||||
|
time = 1 << this.clockTimeOfDay;
|
||||||
|
const callers = this.state.phone.filter((contact) => {
|
||||||
|
if (!contact) return false;
|
||||||
|
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||||
|
if (!(r.byte(entry + 8) & time)) return false;
|
||||||
|
return `${r.byte(entry + 2)}:${r.byte(entry + 3)}` !== this.mapId;
|
||||||
|
});
|
||||||
|
if (!callers.length) return;
|
||||||
|
// ChooseRandomCaller: hRandomAdd's swapped low five bits, modulo.
|
||||||
|
const roll = this.random();
|
||||||
|
const caller = callers[(((roll << 4) | (roll >> 4)) & 0x1f) % callers.length];
|
||||||
|
this.startScript(PHONE.Script_ReceivePhoneCall, PLAYER, {
|
||||||
|
callerScript: r.far(PC.PhoneContacts + caller * PHONE_CONTACT_SIZE + 9),
|
||||||
|
caller,
|
||||||
|
});
|
||||||
|
}
|
||||||
/** CheckBugContestTimer, from CheckTimeEvents while
|
/** CheckBugContestTimer, from CheckTimeEvents while
|
||||||
* STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline
|
* STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline
|
||||||
* replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see
|
* replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see
|
||||||
@@ -4107,7 +4227,7 @@ export class GameWorld {
|
|||||||
}
|
}
|
||||||
const dialogue = this.dialogue;
|
const dialogue = this.dialogue;
|
||||||
if (dialogue) {
|
if (dialogue) {
|
||||||
if (dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
if (realtimeChoice(dialogue.choice)) {
|
||||||
// SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once
|
// SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once
|
||||||
// per video frame; two frames run per overworld tick, as elsewhere.
|
// per video frame; two frames run per overworld tick, as elsewhere.
|
||||||
this.advanceScreenFrame();
|
this.advanceScreenFrame();
|
||||||
@@ -4203,6 +4323,7 @@ export class GameWorld {
|
|||||||
this.memScript = undefined;
|
this.memScript = undefined;
|
||||||
this.startScript(memScript.script, PLAYER, {
|
this.startScript(memScript.script, PLAYER, {
|
||||||
callerScript: memScript.callerScript,
|
callerScript: memScript.callerScript,
|
||||||
|
caller: memScript.caller,
|
||||||
});
|
});
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
}
|
}
|
||||||
@@ -4210,7 +4331,10 @@ export class GameWorld {
|
|||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
// CheckTimeEvents: a running contest skips the daily path entirely.
|
// CheckTimeEvents: a running contest skips the daily path entirely.
|
||||||
if (this.state.bugContest) this.checkBugContestTimer();
|
if (this.state.bugContest) this.checkBugContestTimer();
|
||||||
else this.checkDailyReset();
|
else {
|
||||||
|
this.checkDailyReset();
|
||||||
|
this.checkPhoneCall();
|
||||||
|
}
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
|
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
|
||||||
const stepped = this.beginStep(held);
|
const stepped = this.beginStep(held);
|
||||||
@@ -4756,9 +4880,7 @@ export class GameWorld {
|
|||||||
/** One video frame of a realtime screen (SlotsLoop, CardFlip's
|
/** One video frame of a realtime screen (SlotsLoop, CardFlip's
|
||||||
* MasterLoop): resume it with the next buffered button, or none. */
|
* MasterLoop): resume it with the next buffered button, or none. */
|
||||||
private advanceScreenFrame() {
|
private advanceScreenFrame() {
|
||||||
const choice = this.dialogue?.choice;
|
if (!this.script || !realtimeChoice(this.dialogue?.choice)) return;
|
||||||
if (!this.script || !choice || choice.kind !== "screen" || !choice.realtime)
|
|
||||||
return;
|
|
||||||
this.choose(this.screenInput.shift() ?? "");
|
this.choose(this.screenInput.shift() ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5145,6 +5267,8 @@ export class GameWorld {
|
|||||||
}
|
}
|
||||||
this.script = undefined;
|
this.script = undefined;
|
||||||
this.applyMap(entry, continued ? "continue" : "none");
|
this.applyMap(entry, continued ? "continue" : "none");
|
||||||
|
// StartMap's InitCallReceiveDelay.
|
||||||
|
this.initCallReceiveDelay();
|
||||||
for (const object of save.objects) {
|
for (const object of save.objects) {
|
||||||
const source = map.objects.find(
|
const source = map.objects.find(
|
||||||
(candidate) => candidate.id === object.id,
|
(candidate) => candidate.id === object.id,
|
||||||
|
|||||||
+12
-1
@@ -128,7 +128,8 @@ function showWorld(world: GameWorld) {
|
|||||||
? `${dialogue.index + 1} / ${dialogue.pages.length}`
|
? `${dialogue.index + 1} / ${dialogue.pages.length}`
|
||||||
: "";
|
: "";
|
||||||
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
|
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
|
||||||
const choice = last && !mart ? dialogue.choice : undefined;
|
// A house radio shows only its text box; A, B or Continue stops it.
|
||||||
|
const choice = last && !mart && dialogue.choice?.kind !== "mapradio" ? dialogue.choice : undefined;
|
||||||
// Timed pages advance themselves; choices replace Continue.
|
// Timed pages advance themselves; choices replace Continue.
|
||||||
get("dialogue-next").hidden = Boolean(
|
get("dialogue-next").hidden = Boolean(
|
||||||
choice || mart || (last && dialogue.holdTicks !== undefined),
|
choice || mart || (last && dialogue.holdTicks !== undefined),
|
||||||
@@ -1199,6 +1200,16 @@ function showPokegear(world: GameWorld) {
|
|||||||
card.append(submenu);
|
card.append(submenu);
|
||||||
}
|
}
|
||||||
body.replaceChildren(card);
|
body.replaceChildren(card);
|
||||||
|
} else if (view.radio) {
|
||||||
|
// The tuning dial's frequency, the station's name and its text box.
|
||||||
|
const radio = view.radio,
|
||||||
|
card = element("div", "", "pokegear-radio"),
|
||||||
|
dial = element("p", `FM ${radio.frequency}`, "pokegear-frequency"),
|
||||||
|
box = element("div", "", "pokegear-radio-text");
|
||||||
|
box.setAttribute("aria-live", "polite");
|
||||||
|
box.append(element("span", radio.lines[0]), element("span", radio.lines[1]));
|
||||||
|
card.append(dial, element("strong", radio.name), box);
|
||||||
|
body.replaceChildren(card);
|
||||||
} else body.replaceChildren();
|
} else body.replaceChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1926,3 +1926,27 @@ dialog dd {
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokegear-radio {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.pokegear-frequency {
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pokegear-radio strong {
|
||||||
|
min-height: 1.3em;
|
||||||
|
}
|
||||||
|
.pokegear-radio-text {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25em;
|
||||||
|
min-height: 2.8em;
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.pokegear-radio-text span {
|
||||||
|
min-height: 1.2em;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
import { GameWorld } from "../src/game/world/world";
|
import { GameWorld } from "../src/game/world/world";
|
||||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
import { settle, walkTo } from "./world-driver";
|
import { settle, walkTo , isRinging } from "./world-driver";
|
||||||
import { questFixtureWorld, questLabFixtureWorld, questRivalFixtureWorld } from "./quest-fixture";
|
import { questFixtureWorld, questLabFixtureWorld, questRivalFixtureWorld } from "./quest-fixture";
|
||||||
|
|
||||||
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
@@ -218,7 +218,14 @@ describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", ()
|
|||||||
const world = afterHouse();
|
const world = afterHouse();
|
||||||
world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] });
|
world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] });
|
||||||
expect(world.move("down")).toBe(true);
|
expect(world.move("down")).toBe(true);
|
||||||
for (let tick = 0; tick < 500 && !world.dialogue; tick++) world.tick();
|
// The phone rings first (RingTwice_StartCall), then Elm speaks.
|
||||||
|
const ringing = () => !world.dialogue || isRinging(world.dialogue.pages[world.dialogue.index]);
|
||||||
|
let rang = false;
|
||||||
|
for (let tick = 0; tick < 500 && ringing(); tick++) {
|
||||||
|
rang ||= Boolean(world.dialogue?.pages[0].startsWith("☎ PROF.ELM:"));
|
||||||
|
world.tick();
|
||||||
|
}
|
||||||
|
expect(rang).toBe(true);
|
||||||
expect(world.dialogue?.pages[0]).toContain("disaster");
|
expect(world.dialogue?.pages[0]).toContain("disaster");
|
||||||
const saved = world.save();
|
const saved = world.save();
|
||||||
expect(saved.specialPhoneCall).toBe(2);
|
expect(saved.specialPhoneCall).toBe(2);
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
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 { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { receiveItem } from "../src/game/party/bag";
|
||||||
|
import { ENGINE_FLAGS, PC, SYMBOLS } from "../src/game/rom/profile";
|
||||||
|
import { TrainerData } from "../src/game/rom/trainers";
|
||||||
|
import { PokedexData } from "../src/game/rom/pokedex";
|
||||||
|
import { RadioShow, RADIO_LINES as L, buenasPassword, radioStation, type RadioHost } from "../src/game/scripts/radio";
|
||||||
|
import { radioSpecialContext } from "../src/game/scripts/pokegear";
|
||||||
|
import { copyScriptState, type ScriptState } from "../src/game/scripts/interpreter";
|
||||||
|
import { SOUND } from "../src/game/audio/constants";
|
||||||
|
import { isRinging } from "./world-driver";
|
||||||
|
import { defaultTextContext } from "../src/game/scripts/text";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
const PHONE_CONTACT_SIZE = 12;
|
||||||
|
const PHONE_YOUNGSTER_JOEY = 15;
|
||||||
|
const ROUTE_29 = "24:3";
|
||||||
|
const ROUTE_30 = "26:1";
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("the phone and the radio with the local ROM", () => {
|
||||||
|
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const seeded = (seed: number) => () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||||
|
return (seed >>> 16) & 255;
|
||||||
|
};
|
||||||
|
const strongParty = (world: GameWorld) => {
|
||||||
|
for (const species of [157, 160, 154])
|
||||||
|
world.state.party.push(
|
||||||
|
generatePartyMon(world.assets.pokemon, {
|
||||||
|
species, level: 90, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/** Talk, then answer everything until the world is idle again; the
|
||||||
|
* texts shown, and any refusal. */
|
||||||
|
const drive = (world: GameWorld) => {
|
||||||
|
const texts: string[] = [];
|
||||||
|
for (let tick = 0; tick < 30000; tick++) {
|
||||||
|
if (world.notice) return { texts, refused: world.diagnostic || world.notice };
|
||||||
|
const battle = world.battle;
|
||||||
|
if (battle) {
|
||||||
|
const prompt = battle.prompt;
|
||||||
|
if (battle.message) texts.push(battle.message.text);
|
||||||
|
if (prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
|
||||||
|
// No switching when a trainer sends out the next Pokémon.
|
||||||
|
else if (prompt?.kind === "yesno") world.battleChoose(0);
|
||||||
|
else if (prompt?.kind === "party") world.battleChoose(prompt.cancel ? -1 : 0);
|
||||||
|
else if (prompt?.kind === "forget") world.battleChoose(4);
|
||||||
|
else if (prompt?.kind === "stats" || battle.message?.prompt) world.interact();
|
||||||
|
else world.tick();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dialogue = world.dialogue;
|
||||||
|
if (dialogue) {
|
||||||
|
const page = dialogue.pages[dialogue.index];
|
||||||
|
if (texts.at(-1) !== page && !isRinging(page)) texts.push(page);
|
||||||
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
||||||
|
world.choose(dialogue.choice.kind === "yesno" ? 1 : 0);
|
||||||
|
else if (dialogue.holdTicks !== undefined) world.tick();
|
||||||
|
else world.advanceDialogue();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!world.busy) return { texts, refused: "" };
|
||||||
|
world.tick();
|
||||||
|
}
|
||||||
|
throw new Error(`The world did not settle: ${JSON.stringify({ map: world.mapId, x: world.x, y: world.y, choice: world.dialogue?.choice?.kind, page: world.dialogue?.pages[world.dialogue.index], battle: world.battle?.prompt?.kind ?? world.battle?.message, texts: texts.slice(-4) })}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("lets every registered trainer rematch, give gifts and talk again on their own map", () => {
|
||||||
|
const rom = assets(),
|
||||||
|
r = rom.reader,
|
||||||
|
trainers = new TrainerData(r);
|
||||||
|
const rematchFlags = rom.dailyEngineFlags();
|
||||||
|
const refusals: string[] = [];
|
||||||
|
let battles = 0;
|
||||||
|
for (let contact = 5; contact <= 36; contact++) {
|
||||||
|
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||||
|
const classId = r.byte(entry);
|
||||||
|
if (!classId) continue;
|
||||||
|
const mapId = `${r.byte(entry + 2)}:${r.byte(entry + 3)}`;
|
||||||
|
if (!rom.isPorted(mapId)) continue;
|
||||||
|
const map = rom.map(mapId);
|
||||||
|
const objects = map.objects.filter((object) => {
|
||||||
|
if (object.kind !== 2) return false;
|
||||||
|
const header = trainers.header(object.script);
|
||||||
|
return header.classId === classId && trainers.trainer(header.classId, header.id).name ===
|
||||||
|
trainers.trainer(classId, r.byte(entry + 1)).name;
|
||||||
|
});
|
||||||
|
for (const object of objects)
|
||||||
|
for (const fights of [0, 1, 2, 3, 4]) {
|
||||||
|
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(contact * 7 + fights));
|
||||||
|
strongParty(world);
|
||||||
|
const header = trainers.header(object.script);
|
||||||
|
world.state.events.add(header.eventFlag);
|
||||||
|
// The trainer is out (Joey appears only once the Egg is found).
|
||||||
|
if (object.eventFlag !== 0xffff) world.state.events.delete(object.eventFlag);
|
||||||
|
world.state.phone.push(contact);
|
||||||
|
for (const flag of rematchFlags) world.state.flags.add(flag);
|
||||||
|
for (let badge = 0; badge < 16; badge++) world.state.flags.add(0x1b + badge);
|
||||||
|
for (const label of Object.keys(world.state.memory)) delete world.state.memory[label as never];
|
||||||
|
const counts = Object.keys(SAVED_FIGHTS);
|
||||||
|
for (const label of counts) (world.state.memory as Record<string, number>)[label] = fights;
|
||||||
|
const save = world.save();
|
||||||
|
// Stand next to the trainer, facing it.
|
||||||
|
let placed = false;
|
||||||
|
for (const [dx, dy, direction] of [[0, 1, "up"], [0, -1, "down"], [1, 0, "left"], [-1, 0, "right"]] as const) {
|
||||||
|
const x = object.x + dx,
|
||||||
|
y = object.y + dy;
|
||||||
|
if (x < 0 || y < 0 || x >= map.width || y >= map.height) continue;
|
||||||
|
if (rom.permission(rom.collision(map, x, y)) !== 0) continue;
|
||||||
|
try {
|
||||||
|
world.restore({ ...save, map: mapId, x, y, direction });
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) continue;
|
||||||
|
world.interact();
|
||||||
|
const { texts, refused } = drive(world);
|
||||||
|
if (refused) refusals.push(`${mapId} contact ${contact} fights ${fights}: ${refused}`);
|
||||||
|
if (texts.some((text) => text.includes("defeated") || text.includes("Wants to battle") || text.includes("would like to battle")))
|
||||||
|
battles++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(refusals).toEqual([]);
|
||||||
|
expect(battles).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rings with a registered trainer's call once the call timer runs out outdoors", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const clock = { now: new Date("2026-09-22T13:00:00") };
|
||||||
|
const world = new GameWorld(rom, () => clock.now, seeded(3));
|
||||||
|
strongParty(world);
|
||||||
|
world.state.phone.push(1, PHONE_YOUNGSTER_JOEY);
|
||||||
|
world.restore({ ...world.save(), map: ROUTE_29, x: 50, y: 10 });
|
||||||
|
// InitCallReceiveDelay's first wait is 20 minutes.
|
||||||
|
for (let tick = 0; tick < 50; tick++) world.tick();
|
||||||
|
expect(world.busy).toBe(false);
|
||||||
|
let calls = 0,
|
||||||
|
rang = false;
|
||||||
|
const pages: string[] = [];
|
||||||
|
for (let step = 0; step < 40 && !calls; step++) {
|
||||||
|
clock.now = new Date(clock.now.getTime() + 21 * 60_000);
|
||||||
|
for (let tick = 0; tick < 4 && !world.busy; tick++) world.tick();
|
||||||
|
if (!world.busy) continue;
|
||||||
|
calls++;
|
||||||
|
for (let tick = 0; tick < 4000 && world.busy; tick++) {
|
||||||
|
const dialogue = world.dialogue;
|
||||||
|
if (dialogue) {
|
||||||
|
const page = dialogue.pages[dialogue.index];
|
||||||
|
if (page.startsWith("☎ JOEY:")) rang = true;
|
||||||
|
if (!isRinging(page) && pages.at(-1) !== page) pages.push(page);
|
||||||
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) world.choose(1);
|
||||||
|
else if (dialogue.holdTicks !== undefined) world.tick();
|
||||||
|
else world.advanceDialogue();
|
||||||
|
} else world.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(rang).toBe(true);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(pages.slice(-2)).toEqual(["Click!", "……"]);
|
||||||
|
// Joey's caller script runs: a rematch request or news of a Pokémon.
|
||||||
|
expect(pages.some((page) => page.includes("JOEY"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never rings in a doorway, without a signal or with nobody to call", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const clock = { now: new Date("2026-09-22T13:00:00") };
|
||||||
|
const world = new GameWorld(rom, () => clock.now, seeded(5));
|
||||||
|
world.state.phone.push(1, 4); // Mom and Elm have no caller hours.
|
||||||
|
world.restore({ ...world.save(), map: ROUTE_29, x: 50, y: 10 });
|
||||||
|
for (let step = 0; step < 20; step++) {
|
||||||
|
clock.now = new Date(clock.now.getTime() + 21 * 60_000);
|
||||||
|
world.tick();
|
||||||
|
expect(world.busy).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the rematch and phone flags at the daily reset and counts Kenji's break down", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const clock = { now: new Date("2026-09-22T13:00:00") };
|
||||||
|
const world = new GameWorld(rom, () => clock.now, seeded(9));
|
||||||
|
world.tick();
|
||||||
|
const flags = rom.dailyEngineFlags();
|
||||||
|
const rematch = flags.filter((flag) => rom.reader.word(SYMBOLS.engineFlags + flag * 3) >= 0xdc4c);
|
||||||
|
expect(rematch.length).toBeGreaterThan(30);
|
||||||
|
for (const flag of rematch) world.state.flags.add(flag);
|
||||||
|
const kenji = world.state.memory.wKenjiBreakTimer!;
|
||||||
|
expect(kenji).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(kenji).toBeLessThanOrEqual(6);
|
||||||
|
clock.now = new Date("2026-09-23T13:00:00");
|
||||||
|
world.tick();
|
||||||
|
for (const flag of rematch) expect(world.state.flags.has(flag)).toBe(false);
|
||||||
|
expect(world.state.memory.wKenjiBreakTimer).toBe(kenji - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swarms: the Dunsparce swarm's table on its map, and the fishing swarms", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(11));
|
||||||
|
const table = (state: ScriptState) =>
|
||||||
|
(world as unknown as { encounterTable(state: ScriptState): { grass?: { slots: { species: number }[][] } } })
|
||||||
|
.encounterTable(state);
|
||||||
|
const darkCave = "3:78"; // DARK_CAVE_VIOLET_ENTRANCE
|
||||||
|
world.restore({ ...world.save(), map: darkCave, x: 3, y: 15 });
|
||||||
|
const plain = table(world.state).grass!;
|
||||||
|
const state = copyScriptState(world.state);
|
||||||
|
state.flags.add(ENGINE_FLAGS.dunsparceSwarm);
|
||||||
|
Object.assign(state.memory, { wDunsparceMapGroup: 3, wDunsparceMapNumber: 78 });
|
||||||
|
const swarming = table(state).grass!;
|
||||||
|
const DUNSPARCE = 206;
|
||||||
|
expect(plain.slots[1].filter((slot) => slot.species === DUNSPARCE).length).toBeLessThan(
|
||||||
|
swarming.slots[1].filter((slot) => slot.species === DUNSPARCE).length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the radio's stations (PlayRadioShow)", () => {
|
||||||
|
const run = (line: number, frames: number, setup: Partial<{ hours: number; state: ScriptState }> = {}) => {
|
||||||
|
const rom = assets();
|
||||||
|
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(13));
|
||||||
|
const state = setup.state ?? copyScriptState(world.state);
|
||||||
|
const random = seeded(17);
|
||||||
|
const music: number[] = [];
|
||||||
|
const playMusic = world.sound.playMusic.bind(world.sound);
|
||||||
|
world.sound.playMusic = (id) => {
|
||||||
|
if (id) music.push(id);
|
||||||
|
playMusic(id);
|
||||||
|
};
|
||||||
|
const scriptHost = (world as unknown as { host(id: number): never }).host(0) as Parameters<typeof radioSpecialContext>[1];
|
||||||
|
const host: RadioHost = {
|
||||||
|
reader: rom.reader,
|
||||||
|
data: rom.pokemon,
|
||||||
|
dex: new PokedexData(rom.reader),
|
||||||
|
state,
|
||||||
|
text: () => scriptHost.text(),
|
||||||
|
random,
|
||||||
|
timeOfDay: () => 1,
|
||||||
|
hours: () => setup.hours ?? 13,
|
||||||
|
weekday: () => 2,
|
||||||
|
landmark: () => rom.map(ROUTE_30).landmark,
|
||||||
|
landmarkOf: (id) => rom.map(id).landmark,
|
||||||
|
special: () => radioSpecialContext(state, scriptHost),
|
||||||
|
sound: world.sound,
|
||||||
|
};
|
||||||
|
const show = new RadioShow(host, line, "");
|
||||||
|
const shown: string[] = [];
|
||||||
|
for (let frame = 0; frame < frames; frame++) {
|
||||||
|
show.frame();
|
||||||
|
for (const text of show.lines) if (text && shown.at(-1) !== text && !shown.includes(text)) shown.push(text);
|
||||||
|
}
|
||||||
|
return { shown, music, state };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("Oak's Pokémon Talk names a route's Pokémon, then the Pokémon Channel jingle", () => {
|
||||||
|
const { shown, music } = run(L.OAKS_POKEMON_TALK, 101 * 40);
|
||||||
|
expect(shown[0]).toBe("MARY: PROF.OAK'S");
|
||||||
|
expect(shown.some((line) => line.startsWith("OAK: "))).toBe(true);
|
||||||
|
expect(shown).toContain("POKéMON Channel");
|
||||||
|
expect(music).toEqual([SOUND.MUSIC_POKEMON_TALK, SOUND.MUSIC_POKEMON_CHANNEL, SOUND.MUSIC_POKEMON_TALK]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Pokédex Show reads a caught Pokémon's entry line by line", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const state = copyScriptState(new GameWorld(rom).state);
|
||||||
|
state.pokedex.caught.add(1);
|
||||||
|
const { shown } = run(L.POKEDEX_SHOW, 101 * 9, { state });
|
||||||
|
const entry = new PokedexData(rom.reader).entry(1);
|
||||||
|
expect(shown).toContain(entry.category);
|
||||||
|
expect(shown).toContain(entry.pages[0].split("\n")[0]);
|
||||||
|
expect(shown).toContain(entry.pages[1].split("\n")[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Lucky Channel reads this week's number", () => {
|
||||||
|
const { shown, state } = run(L.LUCKY_CHANNEL, 101 * 10);
|
||||||
|
const number = String(state.luckyNumber.id).padStart(5, "0");
|
||||||
|
expect(shown.some((line) => line.includes(number))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Buena's Password is on the air from 6 PM with the day's password", () => {
|
||||||
|
const evening = run(L.BUENAS_PASSWORD, 101 * 6, { hours: 19 });
|
||||||
|
const password = evening.state.memory.wBuenasPassword!;
|
||||||
|
expect(evening.state.flags.has(ENGINE_FLAGS.buenasPassword)).toBe(true);
|
||||||
|
const rom = assets();
|
||||||
|
const word = buenasPassword(rom.reader, rom.pokemon, password, defaultTextContext("CHRIS"));
|
||||||
|
expect(word.length).toBeGreaterThan(0);
|
||||||
|
expect(evening.music).toEqual([SOUND.MUSIC_BUENAS_PASSWORD]);
|
||||||
|
const afternoon = run(L.BUENAS_PASSWORD, 101 * 3, { hours: 13 });
|
||||||
|
// Off the air: no music, and BuenaOffTheAirText is an empty line.
|
||||||
|
expect(afternoon.music).toEqual([]);
|
||||||
|
expect(afternoon.shown).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tunes in only the stations a place can hear", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const johto = { landmark: rom.map(ROUTE_30).landmark, timeOfDay: 1, expnCard: false, rocketSignal: false, rocketsInRadioTower: false };
|
||||||
|
const context = defaultTextContext("CHRIS");
|
||||||
|
expect(radioStation(rom.reader, 16, johto, context)?.line).toBe(L.OAKS_POKEMON_TALK);
|
||||||
|
expect(radioStation(rom.reader, 16, { ...johto, timeOfDay: 0 }, context)?.line).toBe(L.POKEDEX_SHOW);
|
||||||
|
expect(radioStation(rom.reader, 18, johto, context)).toBeUndefined();
|
||||||
|
expect(radioStation(rom.reader, 52, johto, context)).toBeUndefined();
|
||||||
|
expect(radioStation(rom.reader, 64, johto, context)).toBeUndefined();
|
||||||
|
expect(radioStation(rom.reader, 52, { ...johto, landmark: L.LANDMARK_RUINS_OF_ALPH }, context)?.line).toBe(L.UNOWN_RADIO);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays the Pokégear's radio card: tuning, the station and its music", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(19));
|
||||||
|
strongParty(world);
|
||||||
|
for (const flag of [0, 4]) world.state.flags.add(flag); // ENGINE_RADIO_CARD, ENGINE_POKEGEAR
|
||||||
|
world.restore({ ...world.save(), map: ROUTE_30, x: 7, y: 30 });
|
||||||
|
world.openStartMenu();
|
||||||
|
const menu = world.dialogue!.choice!;
|
||||||
|
expect(menu.kind).toBe("menu");
|
||||||
|
const items = (menu as { items: string[] }).items;
|
||||||
|
world.choose(items.indexOf("POKéGEAR"));
|
||||||
|
// The clock card first, then right to the radio.
|
||||||
|
world.choose("right");
|
||||||
|
expect(world.pokegear?.card).toBe("radio");
|
||||||
|
for (let press = 0; press < 8; press++) {
|
||||||
|
world.bufferScreenButton("up");
|
||||||
|
world.tick();
|
||||||
|
}
|
||||||
|
expect(world.pokegear?.radio?.frequency).toBe("4.5");
|
||||||
|
expect(world.pokegear?.radio?.name).toMatch(/Talk/);
|
||||||
|
for (let tick = 0; tick < 10; tick++) world.tick();
|
||||||
|
expect(world.pokegear?.radio?.lines[0]).toBe("MARY: PROF.OAK'S");
|
||||||
|
// A hundred frames later the next line comes.
|
||||||
|
for (let tick = 0; tick < 60; tick++) world.tick();
|
||||||
|
expect(world.pokegear?.radio?.lines[1]).toBe("POKéMON TALK!");
|
||||||
|
expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_POKEMON_TALK);
|
||||||
|
world.bufferScreenButton("b");
|
||||||
|
world.tick();
|
||||||
|
expect(world.pokegear?.card).not.toBe("radio");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays a house radio until a button stops it", () => {
|
||||||
|
const rom = assets();
|
||||||
|
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(23));
|
||||||
|
strongParty(world);
|
||||||
|
// Kurt's house radio (Radio1Script) at 6, 1.
|
||||||
|
world.restore({ ...world.save(), map: "8:4", x: 6, y: 2, direction: "up" });
|
||||||
|
world.interact();
|
||||||
|
expect(world.dialogue?.choice?.kind, world.diagnostic).toBe("mapradio");
|
||||||
|
expect(world.dialogue?.pages[0]).toMatch(/^“.+”$/);
|
||||||
|
for (let tick = 0; tick < 200; tick++) world.tick();
|
||||||
|
expect(world.dialogue?.pages[0]).not.toMatch(/^“/);
|
||||||
|
world.bufferScreenButton("a");
|
||||||
|
for (let tick = 0; tick < 100 && world.busy; tick++) {
|
||||||
|
if (world.dialogue && world.dialogue.choice?.kind !== "mapradio") world.advanceDialogue();
|
||||||
|
world.tick();
|
||||||
|
}
|
||||||
|
expect(world.busy).toBe(false);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("Buena's Password show with the local ROM", () => {
|
||||||
|
const RADIO_TOWER_2F = "3:18";
|
||||||
|
const EVENT_MET_BUENA = 829,
|
||||||
|
BLUE_CARD = 0x74;
|
||||||
|
const buena = (hour: number) => {
|
||||||
|
const rom = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const world = new GameWorld(rom, () => new Date(`2026-09-22T${hour}:00:00`));
|
||||||
|
world.state.party.push(
|
||||||
|
generatePartyMon(rom.pokemon, { species: 155, level: 20, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0, caughtData: 0 }),
|
||||||
|
);
|
||||||
|
world.state.events.add(EVENT_MET_BUENA);
|
||||||
|
receiveItem(rom.pokemon, world.state.bag, BLUE_CARD, 1);
|
||||||
|
return world;
|
||||||
|
};
|
||||||
|
const answer = (world: GameWorld, pick: (items: string[]) => number) => {
|
||||||
|
const pages: string[] = [];
|
||||||
|
for (let tick = 0; tick < 5000 && world.busy; tick++) {
|
||||||
|
const dialogue = world.dialogue;
|
||||||
|
if (!dialogue) {
|
||||||
|
world.tick();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pages.push(dialogue.pages[dialogue.index]);
|
||||||
|
const choice = dialogue.index === dialogue.pages.length - 1 ? dialogue.choice : undefined;
|
||||||
|
if (choice?.kind === "menu") world.choose(pick(choice.items));
|
||||||
|
else if (choice) world.choose(1);
|
||||||
|
else if (dialogue.holdTicks !== undefined) world.tick();
|
||||||
|
else world.advanceDialogue();
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("takes today's password for a Blue Card point once a day", () => {
|
||||||
|
const world = buena(19);
|
||||||
|
world.state.flags.add(ENGINE_FLAGS.buenasPassword);
|
||||||
|
world.state.memory.wBuenasPassword = 0x01; // JOHTO STARTERS: TOTODILE
|
||||||
|
world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" });
|
||||||
|
world.interact();
|
||||||
|
answer(world, (items) => items.indexOf("TOTODILE"));
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(world.state.blueCardBalance).toBe(1);
|
||||||
|
expect(world.state.flags.has(96)).toBe(true); // ENGINE_BUENAS_PASSWORD_2
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a wrong password, and sends the player to the radio before the show", () => {
|
||||||
|
const world = buena(19);
|
||||||
|
world.state.flags.add(ENGINE_FLAGS.buenasPassword);
|
||||||
|
world.state.memory.wBuenasPassword = 0x01;
|
||||||
|
world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" });
|
||||||
|
world.interact();
|
||||||
|
answer(world, (items) => items.indexOf("CYNDAQUIL"));
|
||||||
|
expect(world.state.blueCardBalance).toBe(0);
|
||||||
|
const early = buena(13);
|
||||||
|
early.restore({ ...early.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" });
|
||||||
|
early.interact();
|
||||||
|
answer(early, () => 0);
|
||||||
|
expect(early.notice, early.diagnostic).toBe("");
|
||||||
|
expect(early.state.blueCardBalance).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exchanges Blue Card points for prizes at the receptionist", () => {
|
||||||
|
const world = buena(13);
|
||||||
|
world.state.blueCardBalance = 3;
|
||||||
|
world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 13, y: 7, direction: "left" });
|
||||||
|
world.interact();
|
||||||
|
let bought = false;
|
||||||
|
answer(world, (items) => {
|
||||||
|
if (bought) return -1;
|
||||||
|
bought = true;
|
||||||
|
return items.indexOf("ULTRA BALL");
|
||||||
|
});
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(world.state.blueCardBalance).toBe(1);
|
||||||
|
expect(world.state.bag.balls.find((slot) => slot.item === 2)?.quantity).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The trainers' fight counts (SAVED_WRAM). */
|
||||||
|
const SAVED_FIGHTS = Object.fromEntries(
|
||||||
|
["Jack", "Beverly", "Huey", "Gaven", "Beth", "Jose", "Reena", "Joey", "Wade", "Ralph", "Liz", "Anthony", "Todd",
|
||||||
|
"Gina", "Irwin", "Arnie", "Alan", "Dana", "Chad", "Derek", "Tully", "Brent", "Tiffany", "Vance", "Wilton",
|
||||||
|
"Kenji", "Parry", "Erin"].map((name) => [`w${name}FightCount`, 0]),
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const romPath = resolve(
|
||||||
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||||
|
);
|
||||||
|
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
|
||||||
|
|
||||||
|
async function start(page: Page) {
|
||||||
|
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||||
|
const response = await route.fetch(),
|
||||||
|
source = await response.text();
|
||||||
|
const needle = "const world = new GameWorld(assets);";
|
||||||
|
expect(source).toContain(needle);
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
body: source.replace("this.world?.beginIntro();", "").replace(
|
||||||
|
needle,
|
||||||
|
`const { radioFixtureWorld } = await import('/tests/radio-fixture.ts'); const world = radioFixtureWorld(assets);`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator("#rom-file").setInputFiles(romPath);
|
||||||
|
await expect(page.locator("#status")).toContainText("verified");
|
||||||
|
await page.locator("#play").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the Pokégear's radio card tunes in Oak's Pokémon Talk and plays it", async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await start(page);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.locator("#dialogue-choice").getByRole("option", { name: /^POKéGEAR/ }).click();
|
||||||
|
await expect(page.locator("#pokegear")).toBeVisible();
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
const radio = page.locator(".pokegear-radio");
|
||||||
|
await expect(radio).toBeVisible();
|
||||||
|
await expect(radio.locator(".pokegear-frequency")).toHaveText("FM 0.5");
|
||||||
|
for (let press = 0; press < 8; press++) await page.keyboard.press("ArrowUp");
|
||||||
|
await expect(radio.locator(".pokegear-frequency")).toHaveText("FM 4.5");
|
||||||
|
await expect(radio.locator("strong")).toContainText("Talk");
|
||||||
|
await expect(radio.locator(".pokegear-radio-text")).toContainText("MARY: PROF.OAK'S");
|
||||||
|
await expect(radio.locator(".pokegear-radio-text")).toContainText("TALK!", { timeout: 10_000 });
|
||||||
|
await page.keyboard.press("KeyZ");
|
||||||
|
await expect(page.locator("#pokegear")).toBeHidden();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
|
||||||
|
/** Route 30 at noon with a Cyndaquil, the Pokégear and its radio card. */
|
||||||
|
export function radioFixtureWorld(assets: CrystalAssets) {
|
||||||
|
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0x42);
|
||||||
|
world.state.party = [
|
||||||
|
generatePartyMon(assets.pokemon, {
|
||||||
|
species: 155, level: 8, item: 0, otId: world.playerId,
|
||||||
|
ot: "CHRIS", dvs: 0x9abc, caughtData: 0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
for (const flag of [0, 4]) world.state.flags.add(flag); // ENGINE_RADIO_CARD, ENGINE_POKEGEAR
|
||||||
|
world.restore({ ...world.save(), map: "26:1", x: 7, y: 30, direction: "down", objects: [] });
|
||||||
|
return world;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isRinging } from "./world-driver";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
@@ -111,7 +112,7 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
|
|||||||
const dialogue = world.dialogue;
|
const dialogue = world.dialogue;
|
||||||
if (dialogue) {
|
if (dialogue) {
|
||||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||||
if (pages.at(-1) !== page) pages.push(page);
|
if (pages.at(-1) !== page && !isRinging(page)) pages.push(page);
|
||||||
if (dialogue.holdTicks !== undefined) world.tick();
|
if (dialogue.holdTicks !== undefined) world.tick();
|
||||||
else world.advanceDialogue();
|
else world.advanceDialogue();
|
||||||
} else if (world.busy || idle++ < 2) world.tick();
|
} else if (world.busy || idle++ < 2) world.tick();
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { Direction } from "../src/game/world/types";
|
|||||||
* answer choices in order (default YES, the first weekday or menu item, or
|
* answer choices in order (default YES, the first weekday or menu item, or
|
||||||
* the clock's shown value) and wait out timed
|
* the clock's shown value) and wait out timed
|
||||||
* effects. Returns every page that was shown. */
|
* effects. Returns every page that was shown. */
|
||||||
|
/** RingTwice_StartCall's pages: the caller's name box and the cleared box
|
||||||
|
* between rings. */
|
||||||
|
export const isRinging = (page: string) => page.startsWith("☎") || page === " ";
|
||||||
|
|
||||||
export function settle(
|
export function settle(
|
||||||
world: GameWorld,
|
world: GameWorld,
|
||||||
answers: (number | string)[] = [],
|
answers: (number | string)[] = [],
|
||||||
@@ -35,7 +39,8 @@ export function settle(
|
|||||||
: 0,
|
: 0,
|
||||||
);
|
);
|
||||||
} else if (last && dialogue.holdTicks !== undefined) {
|
} else if (last && dialogue.holdTicks !== undefined) {
|
||||||
if (page !== lastHeld) shown.push(page);
|
// A received call's ringing (the caller's name box) is not text.
|
||||||
|
if (page !== lastHeld && !isRinging(page)) shown.push(page);
|
||||||
lastHeld = page;
|
lastHeld = page;
|
||||||
world.tick();
|
world.tick();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in new issue
Block a user