Port the Pokédex list, entries and tab (#35)
Checks / web (push) Failing after 21s
Private ROM checks / private-rom (push) Failing after 14m27s

The start menu's POKéDEX runs Pokedex in New mode: the list up to the
last seen Pokémon, its cursor and page moves, and DisplayDexEntry with
the category, number, footprint, and the height, weight and pages once
caught. The Pokédex tab shows the same records from the loaded game.
Order, entries and footprints are compared with the pinned PRET source.

AREA, CRY, PRNT, the option and search screens, Old and A to Z modes
and Unown mode remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samyandClaude Opus 5.5 committed 2026-10-03 20:19:24 +02:00
1 parent 03aa913a94
commit cdee576818
20 files changed
+1624 -29

No files matched your search

+3 -3
View File
@@ -2,7 +2,7 @@
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included. A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
**Current milestone: from the opening to Route 30.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. Save, export and import work now. Pokédex, Link and Friends show their planned scope without placeholder data. **Current milestone: from the opening to Route 30.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming. Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
@@ -12,11 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls. Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.
Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. Fly opens the Fly map to choose a town you have visited, Teleport returns to the town of the last Pokémon Center you entered, and Dig or an Escape Rope leads out of a cave through the entrance you last used. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon) and the menu account (the start menu's descriptions); text speed, battle scene, sound, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map. 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; 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) and the menu account (the start menu's descriptions); text speed, battle scene, sound, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map.
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations. The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex screen, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run ## Run
+1
View File
@@ -35,6 +35,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `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. Screens are native; the radio's stations are not ported. |
| `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. | | `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. |
| `game/scripts/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. AREA, CRY, 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/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. | | `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. |
| `game/scripts/fly-map.ts` | `engine/pokegear/pokegear.asm` (`_FlyMap`, `FlyMap`, `TownMapBubble`, `CheckIfVisitedFlypoint`), `data/maps/flypoints.asm`, `engine/events/field_moves.asm` (`FlyFromAnim`, `FlyToAnim`), `engine/sprite_anims/functions.asm` | The Fly map's region choice (Kanto only after Indigo Plateau, without the player's icon otherwise), the default flypoint and up/down scrolling over visited flypoints (`wVisitedSpawns` through the ROM's `EngineFlags`). The flights follow `SpriteAnimFunc_FlyFrom` and `SpriteAnimFunc_FlyTo` frame by frame with the party icon. The Town Map, bubble and icons are drawn natively; the leaves are not presented. | | `game/scripts/fly-map.ts` | `engine/pokegear/pokegear.asm` (`_FlyMap`, `FlyMap`, `TownMapBubble`, `CheckIfVisitedFlypoint`), `data/maps/flypoints.asm`, `engine/events/field_moves.asm` (`FlyFromAnim`, `FlyToAnim`), `engine/sprite_anims/functions.asm` | The Fly map's region choice (Kanto only after Indigo Plateau, without the player's icon otherwise), the default flypoint and up/down scrolling over visited flypoints (`wVisitedSpawns` through the ROM's `EngineFlags`). The flights follow `SpriteAnimFunc_FlyFrom` and `SpriteAnimFunc_FlyTo` frame by frame with the party icon. The Town Map, bubble and icons are drawn natively; the leaves are not presented. |
| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
+17 -6
View File
@@ -21,7 +21,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
| 2. Opening story and first battle slice | Mostly done | | 2. Opening story and first battle slice | Mostly done |
| 3. Complete Crystal | Early | | 3. Complete Crystal | Early |
| 4. Modern mobile presentation and delivery | First increment done | | 4. Modern mobile presentation and delivery | First increment done |
| 5. World map and Pokédex | Not started (current-area minimap only) | | 5. World map and Pokédex | Early (Pokédex list, entries and tab) |
| 6. Internet Link and friends | Not started | | 6. Internet Link and friends | Not started |
| 7. Optional Pokéwalker-style egg assistance | Not started | | 7. Optional Pokéwalker-style egg assistance | Not started |
@@ -37,7 +37,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Original initial event flags, readable signs, bedroom radio, bookshelves and supported conversations. Unknown interactions abort without committing partial changes. - Original initial event flags, readable signs, bedroom radio, bookshelves and supported conversations. Unknown interactions abort without committing partial changes.
- Native DOM dialogue, name/avatar choice, touch/keyboard controls and pause/resume. (The name and character are now chosen in the introduction; see below.) - Native DOM dialogue, name/avatar choice, touch/keyboard controls and pause/resume. (The name and character are now chosen in the introduction; see below.)
- A minimal Crystal-style shell with Game, Savegames, Map, Pokédex, Link and Friends tabs across the top. The game uses the available native canvas, and leaving Game releases input and pauses play. - A minimal Crystal-style shell with Game, Savegames, Map, Pokédex, Link and Friends tabs across the top. The game uses the available native canvas, and leaving Game releases input and pauses play.
- Existing save, backup export and backup import actions in Savegames. Map shares a live local minimap with the game and shows the current supported area, player position and coordinates. Pokédex, Link and Friends are clearly marked as planned rather than populated with placeholder data. - Existing save, backup export and backup import actions in Savegames. Map shares a live local minimap with the game and shows the current supported area, player position and coordinates. Link and Friends are clearly marked as planned rather than populated with placeholder data. (The Pokédex tab now shows the game's records; see the Pokédex section.)
- Native JSON state inside the backup envelope, separate IndexedDB storage and a separate tab lock. No emulator snapshot or cartridge save migration is implied. - Native JSON state inside the backup envelope, separate IndexedDB storage and a separate tab lock. No emulator snapshot or cartridge save migration is implied.
- Synthetic tests, local-ROM tests, browser checks, and comparisons against the original decompression routine running in the reference emulator. - Synthetic tests, local-ROM tests, browser checks, and comparisons against the original decompression routine running in the reference emulator.
- Native production output excludes the emulator and original game assets. - Native production output excludes the emulator and original game assets.
@@ -165,7 +165,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
## Implemented: the start menu, party menu and pack ## Implemented: the start menu, party menu and pack
- Start opens `StartMenu` with the items `SetUpMenuItems` lists for the player's progress (POKéDEX, POKéMON, PACK, POKéGEAR, the player's name, SAVE, OPTION, EXIT) and their ROM descriptions. POKéDEX gives a notice (POKéGEAR, the trainer card and OPTION run since the sections below); SAVE points to the automatic session saves. - Start opens `StartMenu` with the items `SetUpMenuItems` lists for the player's progress (POKéDEX, POKéMON, PACK, POKéGEAR, the player's name, SAVE, OPTION, EXIT) and their ROM descriptions. POKéDEX opens the Pokédex (see the Pokédex section below; POKéGEAR, the trainer card and OPTION run since the sections below); SAVE points to the automatic session saves.
- POKéMON: the party menu with the ROM's prompts and `GetMonSubmenuItems`: field moves the Pokémon knows (from `MonMenuOptions`), STATS, SWITCH, MOVE, ITEM or MAIL, and CANCEL when there is room. STATS opens the stats screen; SWITCH asks "Move to where?" and swaps the two Pokémon; MOVE (`MoveScreenLoop`) swaps two moves with their PP; ITEM gives an item from the pack (key items and untossable items "can't be held"), swaps it with the held item or takes it back, with the pack-full checks of `TryGiveItemToPartymon` and `TakePartyItem`. - POKéMON: the party menu with the ROM's prompts and `GetMonSubmenuItems`: field moves the Pokémon knows (from `MonMenuOptions`), STATS, SWITCH, MOVE, ITEM or MAIL, and CANCEL when there is room. STATS opens the stats screen; SWITCH asks "Move to where?" and swaps the two Pokémon; MOVE (`MoveScreenLoop`) swaps two moves with their PP; ITEM gives an item from the pack (key items and untossable items "can't be held"), swaps it with the held item or takes it back, with the pack-full checks of `TryGiveItemToPartymon` and `TakePartyItem`.
- PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte. - PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte.
- PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out". - PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out".
@@ -218,6 +218,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- `wGameTime` is new: `GameTimer` counts 60 frames to the second, two per overworld tick, from the end of the introduction through scenes, menus and battles, and caps at 999:59:59. A save made during a scene keeps the current play time; everything else in it is still the state before the scene. Save format 3 gained `gameTime`, migrated by absence to zero. The save screen and the Hall of Fame, which pause it in the original, are not ported. - `wGameTime` is new: `GameTimer` counts 60 frames to the second, two per overworld tick, from the end of the introduction through scenes, menus and battles, and caps at 999:59:59. A save made during a scene keeps the current play time; everything else in it is still the state before the scene. Save format 3 gained `gameTime`, migrated by absence to zero. The save screen and the Hall of Fame, which pause it in the original, are not ported.
- The labels drawn as tiles ("ID NO", "STATUS" and "BADGES") are presented as text; the other strings come from the ROM. - The labels drawn as tiles ("ID NO", "STATUS" and "BADGES") are presented as text; the other strings come from the ROM.
## Implemented: the Pokédex
- POKéDEX in the start menu runs `Pokedex` in New mode and returns to the start menu. `StartMenu_Pokedex` needs a party. The list follows `NewPokedexOrder` up to the last seen Pokémon (`.FindLastSeen`), seven rows at a time. Unseen rows show `NameNotSeen` ("-----") and owned ones the ball. The picture beside the list is the selected Pokémon or `LoadQuestionMarkPic`, in `PokedexQuestionMarkPalette`, with the SEEN and OWN counts.
- Up and down move a row and left and right a page, as `Pokedex_ListingHandleDPadInput` does. A opens a seen Pokémon's entry and B leaves. The cursor reopens on `wPrevDexEntry` through `Pokedex_InitCursorPosition`. `wPrevDexEntry` lives with the menu cursors in the world's memory, survives battles and is not saved.
- `DisplayDexEntry` shows the name, category and number of every seen Pokémon. The height, weight and page text appear only once it is caught; otherwise `.Height` and `.Weight` keep their question marks. The picture takes the species' palette, and the footprint comes from `Footprints`. Left and right move the arrow over PAGE, AREA, CRY and PRNT without wrapping. A on PAGE turns the page. Up and down step to the previous or next seen Pokémon, back on page 1 with the arrow on PAGE. B returns to the list where the entries left the cursor.
- The Pokédex tab reads the loaded game's state. It lists New mode's order up to the last seen Pokémon and shows a selected entry with the same rules: unseen Pokémon stay unnamed, and uncaught ones show no measurements or text. Without a ROM or before the Pokédex is received, it says so.
- `tests/pokedex.test.ts` compares the order, all 251 entries and every footprint with the pinned PRET source.
- Not ported: AREA (`Pokedex_GetArea`), CRY (no audio), PRNT (no printer), the option screen (SELECT), the search screen (START), Old and A to Z modes (`wLastDexMode` stays New), Unown mode and the arrow's blink. Screens are native; the strings, entries and graphics come from the ROM.
## Implemented: the options ## Implemented: the options
- OPTION in the start menu runs `_Option` and returns to the start menu. The labels come from `StringOptions` and the settings from each routine's strings. Up and down move the ▶ with `OptionsControl`'s wrapping, left and right change the option under it, A on CANCEL, B or Start leaves. Each routine is ported from its bit handling: text speed cycles FAST, MID and SLOW through `GetTextSpeed` (`.Save` keeps only the high nybble), the battle scene, battle style, sound and menu account toggle with either direction, printing cycles through the five `GBPRINTER_*` levels through `GetPrinterSetting`, and the frame wraps through `TYPE 1` to `8`. - OPTION in the start menu runs `_Option` and returns to the start menu. The labels come from `StringOptions` and the settings from each routine's strings. Up and down move the ▶ with `OptionsControl`'s wrapping, left and right change the option under it, A on CANCEL, B or Start leaves. Each routine is ported from its bit handling: text speed cycles FAST, MID and SLOW through `GetTextSpeed` (`.Save` keeps only the high nybble), the battle scene, battle style, sound and menu account toggle with either direction, printing cycles through the five `GBPRINTER_*` levels through `GetPrinterSetting`, and the frame wraps through `TYPE 1` to `8`.
@@ -335,7 +344,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
This is still not a complete new game. The Pokégear's radio, the Pokédex screen, story progression beyond Violet City and Falkner's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later. This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Violet City and Falkner's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, online Link, friends and real-world steps come later.
## 1. Complete the overworld foundation ## 1. Complete the overworld foundation
@@ -450,7 +459,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [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
- [ ] Pokédex screen - [x] Pokédex screen: New mode list and entries
- [ ] Scalable text, configurable controls and fuller accessibility - [ ] Scalable text, configurable controls and fuller accessibility
- [ ] Save slots with migration, atomic writes and import previews - [ ] Save slots with migration, atomic writes and import previews
- [ ] Installable web app with offline app shell, safe updates and quota handling - [ ] Installable web app with offline app shell, safe updates and quota handling
@@ -475,7 +484,9 @@ Acceptance: installation, offline startup, portrait/landscape, touch/audio, back
- [x] Current-area minimap in the game and Map tab - [x] Current-area minimap in the game and Map tab
- [x] Seen and caught sets saved from real game state - [x] Seen and caught sets saved from real game state
- [ ] Johto/Kanto atlas with visited areas and Fly destinations - [ ] Johto/Kanto atlas with visited areas and Fly destinations
- [ ] Pokédex tab with ROM-derived records and habitat views - [x] Start menu Pokédex and Pokédex tab with ROM-derived records
- [ ] Habitat (AREA) view from the encounter tables
- [ ] Old and A to Z modes, type search and Unown mode
Promote the current-area minimap into a Johto/Kanto atlas after all map groups, connections and location names can be decoded from the selected ROM. Derive region geometry, visited areas, player location and Fly destinations from native map and story state. Do not ship copied map artwork or claim navigation through maps whose collisions and events are still unsupported. Promote the current-area minimap into a Johto/Kanto atlas after all map groups, connections and location names can be decoded from the selected ROM. Derive region geometry, visited areas, player location and Fly destinations from native map and story state. Do not ship copied map artwork or claim navigation through maps whose collisions and events are still unsupported.
+2 -2
View File
@@ -216,8 +216,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
if (!match || Number(match[1]) !== ram.get(symbol)) if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`); throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
} }
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD and OPTIONS entries are keyed by their PRET labels. // BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX and OPTIONS entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "OPTIONS"]) { for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS"]) {
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) throw new Error(`${table} symbol table is missing.`); if (!block) 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)) {
+3
View File
@@ -12,6 +12,7 @@ import {
} from "./profile"; } from "./profile";
import { defaultTextContext, readString } from "../scripts/text"; import { defaultTextContext, readString } from "../scripts/text";
import { PokemonData } from "./pokemon"; import { PokemonData } from "./pokemon";
import { PokedexData } from "./pokedex";
import { OW } from "../world/constants"; import { OW } from "../world/constants";
import type { import type {
Direction, Direction,
@@ -27,6 +28,7 @@ import type {
export class CrystalAssets { export class CrystalAssets {
readonly reader: RomReader; readonly reader: RomReader;
readonly pokemon: PokemonData; readonly pokemon: PokemonData;
readonly pokedex: PokedexData;
private maps = new Map<string, GameMap>(); private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>(); private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>(); private sprites = new Map<number, Sprite>();
@@ -35,6 +37,7 @@ export class CrystalAssets {
constructor(bytes: Uint8Array) { constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes); this.reader = new RomReader(bytes);
this.pokemon = new PokemonData(this.reader); this.pokemon = new PokemonData(this.reader);
this.pokedex = new PokedexData(this.reader);
} }
/** Areas whose rules and scripts are ported. Other maps can be decoded, /** Areas whose rules and scripts are ported. Other maps can be decoded,
+174
View File
@@ -0,0 +1,174 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import { NUM_POKEMON, POKEDEX } from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
import type { Palette } from "../world/types";
/** A Pokédex entry (data/pokemon/dex_entries): the species category,
* height and weight, and the two pages of text. */
export interface DexEntry {
/** The species name DisplayDexEntry prints under the name, as "SEED". */
category: string;
/** Feet and inches as stored: 204 is 2'04". Zero leaves "?'??". */
height: number;
/** Tenths of a pound. Zero leaves "???". */
weight: number;
/** Each page's lines, joined by "\n" as <NEXT> breaks them. */
pages: [string, string];
}
/** The Pokédex font's own tiles in the strings Pokedex_PlaceString draws
* (gfx/pokedex/pokedex.png): the foot and inch marks and the bottom bar's
* left edge. */
const DEX_TILES: Record<number, string> = {
0x3b: "",
0x5e: "'",
0x5f: '"',
};
/** The Pokédex's ROM data: its strings, the New mode order, the entries,
* footprints and the question mark picture. */
export class PokedexData {
private entries = new Map<number, DexEntry>();
private order?: number[];
constructor(readonly reader: RomReader) {}
private checkSpecies(species: number) {
if (!Number.isInteger(species) || species < 1 || species > NUM_POKEMON)
throw new Error("Unknown Pokémon species.");
}
/** A "@"-terminated string in the standard font. */
private string(address: number, limit = 32) {
return readString(this.reader, address, defaultTextContext(""), limit);
}
/** A string Pokedex_PlaceString draws, ended by -1, mixing the standard
* font with the Pokédex's tiles. */
dexString(label: keyof typeof POKEDEX): string {
const r = this.reader;
let output = "";
for (let p = POKEDEX[label], end = p + 32; p < end; p++) {
const code = r.byte(p);
if (code === 0xff) return output;
output +=
DEX_TILES[code] ??
readString(
new RomReader(new Uint8Array([code, 0x50])),
0,
defaultTextContext(""),
);
}
throw new Error("Unterminated Pokédex string.");
}
/** Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen. */
notSeenName(): string {
return this.string(
POKEDEX["Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen"],
);
}
/** NewPokedexOrder: the species in New mode's (Johto) order. */
newOrder(): readonly number[] {
if (!this.order) {
const order = [
...this.reader.slice(POKEDEX.NewPokedexOrder, NUM_POKEMON),
];
const sorted = [...order].sort((a, b) => a - b);
if (sorted.some((species, index) => species !== index + 1))
throw new Error("The New Pokédex order is not a permutation.");
this.order = order;
}
return this.order;
}
/** GetDexEntryPointer: the bank comes from the species' top two bits,
* one bank per 64 species. */
private entryAddress(species: number) {
const r = this.reader;
const index = species - 1;
const bank = r.byte(
POKEDEX["GetDexEntryPointer.PokedexEntryBanks"] + ((index >> 6) & 3),
);
return r.near(POKEDEX.PokedexDataPointerTable + index * 2, bank);
}
/** DisplayDexEntry and GetDexEntryPagePointer: the category, the height
* and weight words, then each page up to its "@". */
entry(species: number): DexEntry {
this.checkSpecies(species);
const cached = this.entries.get(species);
if (cached) return cached;
const r = this.reader;
let p = this.entryAddress(species);
const category = this.string(p, 16);
while (r.byte(p) !== 0x50) p++;
p++;
const height = r.word(p),
weight = r.word(p + 2);
p += 4;
const page1 = p;
while (r.byte(p) !== 0x50) p++;
const page2 = p + 1;
const entry: DexEntry = {
category,
height,
weight,
pages: [this.string(page1, 128), this.string(page2, 128)],
};
this.entries.set(species, entry);
return entry;
}
/** Pokedex_LoadAnyFootprint: two 1bpp tiles from the footprint sheet's
* row for the species, then the two below them, 16 tiles further. */
footprint(species: number): Uint8Array {
this.checkSpecies(species);
const index = species - 1;
const top = POKEDEX.Footprints + (index >> 3) * 0x100 + (index & 7) * 0x10;
const tiles = new Uint8Array(32);
tiles.set(this.reader.slice(top, 16), 0);
tiles.set(this.reader.slice(top + 0x80, 16), 16);
return tiles;
}
/** LoadQuestionMarkPic: the 7x7 picture of an unseen Pokémon, column by
* column like a front picture. */
questionMarkPic(): Uint8Array {
const tiles = decompress(
this.reader,
POKEDEX["LoadQuestionMarkPic.QuestionMarkLZ"],
0x2000,
);
if (tiles.length < 49 * 16) throw new Error("Picture data is truncated.");
return tiles.slice(0, 49 * 16);
}
/** PokedexQuestionMarkPalette: _CGB_Pokedex's picture palette while the
* list shows (wCurPartySpecies is -1). */
questionMarkPalette(): Palette {
const r = this.reader;
return [0, 1, 2, 3].map((i) => {
const value = r.word(POKEDEX.PokedexQuestionMarkPalette + i * 2);
return [
Math.round(((value & 31) * 255) / 31),
Math.round((((value >> 5) & 31) * 255) / 31),
Math.round((((value >> 10) & 31) * 255) / 31),
] as const;
});
}
}
/** DisplayDexEntry's height: PrintNum with two of four digits before the
* point, which becomes the foot mark, written over the ".Height" string. */
export function dexHeight(template: string, height: number): string {
if (!height) return template;
const feet = String(Math.floor(height / 100)).padStart(2, " ");
const inches = String(height % 100).padStart(2, "0");
// The number starts three tiles into the string (hlcoord 12, 7 after 9, 7).
return template.slice(0, 3) + `${feet}'${inches}` + template.slice(8);
}
/** DisplayDexEntry's weight: PrintNum with four of five digits before the
* point, written over the ".Weight" string's question marks. */
export function dexWeight(template: string, weight: number): string {
if (!weight) return template;
const pounds = String(Math.floor(weight / 10)).padStart(4, " ");
// The number starts two tiles into the string (hlcoord 11, 9 after 9, 9).
return template.slice(0, 2) + `${pounds}.${weight % 10}` + template.slice(8);
}
+18
View File
@@ -999,6 +999,24 @@ export const TRAINER_CARD = {
PredefPals: 0x9df6, PredefPals: 0x9df6,
} as const; } as const;
/** Pokedex (engine/pokedex/pokedex.asm, pokedex_2.asm, pokedex_3.asm):
* its strings, the New mode order, the entries and their graphics, keyed
* by their PRET labels. */
export const POKEDEX = {
String_SEEN: 0x407e1,
String_OWN: 0x407e6,
"Pokedex_DrawDexEntryScreenBG.Height": 0x40852,
"Pokedex_DrawDexEntryScreenBG.Weight": 0x4085c,
"Pokedex_DrawDexEntryScreenBG.MenuItems": 0x40867,
"Pokedex_PlaceDefaultStringIfNotSeen.NameNotSeen": 0x40b9a,
NewPokedexOrder: 0x40d60,
"GetDexEntryPointer.PokedexEntryBanks": 0x44351,
PokedexDataPointerTable: 0x44378,
Footprints: 0xf9434,
"LoadQuestionMarkPic.QuestionMarkLZ": 0x1de0e1,
PokedexQuestionMarkPalette: 0x8fba,
} as const;
/** _Option (engine/menus/options_menu.asm): its screen and setting /** _Option (engine/menus/options_menu.asm): its screen and setting
* strings, keyed by their PRET labels. */ * strings, keyed by their PRET labels. */
export const OPTIONS = { export const OPTIONS = {
+2
View File
@@ -59,6 +59,7 @@ import type { PokegearView } from "./pokegear";
import type { FlyMapView } from "./fly-map"; import type { FlyMapView } from "./fly-map";
import { rockMonEncounter, treeMonEncounter } from "./treemons"; import { rockMonEncounter, treeMonEncounter } from "./treemons";
import type { TrainerCardView } from "./trainer-card"; import type { TrainerCardView } from "./trainer-card";
import type { PokedexView } from "./pokedex";
import { import {
defaultOptions, defaultOptions,
type GameOptions, type GameOptions,
@@ -441,6 +442,7 @@ export type ScriptEffect =
/** A trainer card page, answered with the button pressed; without a /** A trainer card page, answered with the button pressed; without a
* view the card closes. */ * view the card closes. */
| { kind: "trainercard"; view?: TrainerCardView } | { kind: "trainercard"; view?: TrainerCardView }
| { kind: "pokedex"; view?: PokedexView }
/** The options screen, answered with the button pressed; without a /** The options screen, answered with the button pressed; without a
* view it closes. */ * view it closes. */
| { kind: "options"; view?: OptionsView } | { kind: "options"; view?: OptionsView }
+327
View File
@@ -0,0 +1,327 @@
import { NUM_POKEMON } from "../rom/profile";
import type { PokedexData } from "../rom/pokedex";
import { dexHeight, dexWeight } from "../rom/pokedex";
import type { PokemonData } from "../rom/pokemon";
import type { ScriptEffect } from "./interpreter";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** The buttons the Pokédex reads from hJoyPressed and hJoyLast. */
export const POKEDEX_BUTTONS: readonly string[] = [
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
/** wDexListingHeight on the main screen. */
const LISTING_HEIGHT = 7;
/** DexEntryScreen_ArrowCursorData: PAGE, AREA, CRY and PRNT. */
const ENTRY_ACTIONS = 4;
/** One line of the main screen's list. Rows past the last seen Pokémon
* still print, as "-----". */
export interface PokedexRow {
name: string;
seen: boolean;
caught: boolean;
}
/** What the Pokédex shows. The original draws its own tilemap; here the
* text is presented natively and the pictures come from the ROM. */
export interface PokedexView {
screen: "list" | "entry";
/** The species whose front picture shows, or undefined for the question
* mark. The list draws it in PokedexQuestionMarkPalette, the entry in
* the species' palette. */
picture?: number;
/** wPokedexSeen and wPokedexCaught's set bits. */
seen: number;
own: number;
/** wDexListingHeight rows from wDexListingScrollOffset. */
rows: PokedexRow[];
/** wDexListingCursor. */
cursor: number;
/** wDexListingScrollOffset and wDexListingEnd, for the scroll bar. */
scroll: number;
end: number;
entry?: PokedexEntryView;
/** String_SEEN and String_OWN from the ROM. */
labels: { seen: string; own: string };
}
/** DisplayDexEntry: the number, name and category always; the height,
* weight and page text only once the species is caught. */
export interface PokedexEntryView {
species: number;
name: string;
category: string;
/** The national number with leading zeros. */
number: string;
/** ".Height" and ".Weight", with the numbers printed over them. */
height: string;
weight: string;
caught: boolean;
/** wPokedexStatus: page 1 or 2. */
page: 1 | 2;
text?: string;
/** ".MenuItems": PAGE, AREA, CRY and PRNT. */
actions: string[];
/** wDexArrowCursorPosIndex. */
action: number;
}
/** What the Pokédex needs from the world. */
export interface PokedexHost {
data: PokemonData;
dex: PokedexData;
seen: ReadonlySet<number>;
caught: ReadonlySet<number>;
}
/** Pokedex (engine/pokedex/pokedex.asm) in New mode: the main list and
* the entry screen. B leaves the list; A opens a seen Pokémon's entry,
* where left and right move the arrow cursor, up and down step through
* seen Pokémon, A runs the action under the cursor and B returns. Only
* PAGE acts: AREA, CRY and PRNT, the option (SELECT) and search (START)
* screens and the Unown mode are not ported. */
export class Pokedex {
/** wPokedexOrder, in DEXMODE_NEW. */
private order: readonly number[];
/** wDexListingEnd: the position of the last seen Pokémon, plus one. */
private end: number;
private cursor = 0;
private scroll = 0;
/** wPrevDexEntry: the last entry shown, which places the cursor when
* the Pokédex opens again. */
prevEntry: number;
constructor(
private host: PokedexHost,
prevEntry = 0,
) {
this.prevEntry = prevEntry;
this.order = host.dex.newOrder();
this.end = this.findLastSeen();
this.initCursorPosition();
}
*run(): Gen {
for (;;) {
const button = yield* this.button(this.listView());
if (button === "b") break;
if (button === "a") {
if (!this.host.seen.has(this.selected())) continue;
yield* this.entry();
continue;
}
// SELECT opens the option screen and START the search screen.
if (button === "select" || button === "start") continue;
this.listingInput(button);
}
yield { kind: "pokedex" };
}
private *button(view: PokedexView): Gen<string> {
const button = yield { kind: "pokedex", view };
if (typeof button !== "string" || !POKEDEX_BUTTONS.includes(button))
throw new Error("Invalid Pokédex button.");
return button;
}
/** Pokedex_InitDexEntryScreen and Pokedex_UpdateDexEntryScreen. */
private *entry(): Gen {
let page: 1 | 2 = 1;
let action = 0;
this.prevEntry = this.selected();
for (;;) {
const button = yield* this.button(this.entryView(page, action));
// Pokedex_MoveArrowCursor reads left and right only.
if (button === "left" && action > 0) action--;
else if (button === "right" && action < ENTRY_ACTIONS - 1) action++;
if (button === "b") return;
if (button === "a") {
// Pokedex_Page; .Area, .Cry and .Print are not ported.
if (action === 0) {
page = page === 1 ? 2 : 1;
this.prevEntry = this.selected();
}
continue;
}
if (
(button === "up" || button === "down") &&
this.nextOrPrevious(button)
) {
// Pokedex_ReinitDexEntryScreen: page 1 and the cursor on PAGE.
page = 1;
action = 0;
this.prevEntry = this.selected();
}
}
}
/** Pokedex_GetSelectedMon. */
private selected() {
return this.order[this.cursor + this.scroll] ?? 0;
}
/** Pokedex_OrderMonsByMode.FindLastSeen. */
private findLastSeen() {
let end = NUM_POKEMON;
while (end > 0 && !this.host.seen.has(this.order[end - 1])) end--;
return end;
}
/** Pokedex_InitCursorPosition: scroll, then move the cursor, until the
* row holds wPrevDexEntry. It gives up after the last page's start and
* seven rows, as the original's counters do. */
private initCursorPosition() {
const species = this.prevEntry;
if (!species || species > NUM_POKEMON) return;
let index = 0;
if (this.end >= 8) {
for (let c = this.end - 7; c > 0; c--) {
if (this.order[index] === species) return;
index++;
this.scroll++;
}
}
for (let c = LISTING_HEIGHT; c > 0; c--) {
if (this.order[index] === species) return;
index++;
this.cursor++;
}
}
/** Pokedex_ListingHandleDPadInput: up and down move a row, left and
* right a page, but only when the list is longer than one. */
private listingInput(button: string): boolean {
if (button === "up") return this.moveUp();
if (button === "down") return this.moveDown();
if (LISTING_HEIGHT >= this.end) return false;
if (button === "left") {
// Pokedex_ListingMoveUpOnePage.
if (!this.scroll) return false;
this.scroll =
this.scroll < LISTING_HEIGHT ? 0 : this.scroll - LISTING_HEIGHT;
return true;
}
if (button === "right") {
// Pokedex_ListingMoveDownOnePage, whose sum wraps at 256.
const sum = 2 * LISTING_HEIGHT + this.scroll;
this.scroll =
sum > 0xff || sum >= this.end
? this.end - LISTING_HEIGHT
: this.scroll + LISTING_HEIGHT;
return true;
}
return false;
}
/** Pokedex_ListingMoveCursorUp. */
private moveUp() {
if (this.cursor) this.cursor--;
else if (this.scroll) this.scroll--;
else return false;
return true;
}
/** Pokedex_ListingMoveCursorDown. */
private moveDown() {
const next = this.cursor + 1;
if (next >= this.end) return false;
if (next < LISTING_HEIGHT) this.cursor = next;
else if (next + this.scroll < this.end) this.scroll++;
else return false;
return true;
}
/** Pokedex_NextOrPreviousDexEntry: move until a seen Pokémon, or stay
* where the cursor was. */
private nextOrPrevious(button: "up" | "down"): boolean {
const cursor = this.cursor,
scroll = this.scroll;
for (;;) {
const moved = button === "up" ? this.moveUp() : this.moveDown();
if (!moved) {
this.cursor = cursor;
this.scroll = scroll;
return false;
}
if (this.host.seen.has(this.selected())) return true;
}
}
private common() {
const { dex } = this.host;
return {
seen: this.host.seen.size,
own: this.host.caught.size,
cursor: this.cursor,
scroll: this.scroll,
end: this.end,
labels: {
seen: dex.dexString("String_SEEN"),
own: dex.dexString("String_OWN"),
},
};
}
/** Pokedex_PrintListing and Pokedex_LoadSelectedMonTiles. */
private listView(): PokedexView {
const { data, dex, seen, caught } = this.host;
const rows: PokedexRow[] = [];
for (let row = 0; row < LISTING_HEIGHT; row++) {
const species = this.order[this.scroll + row];
if (!species) continue;
const shown = seen.has(species);
rows.push({
name: shown ? data.speciesName(species) : dex.notSeenName(),
seen: shown,
caught: shown && caught.has(species),
});
}
const species = this.selected();
return {
screen: "list",
...(seen.has(species) && { picture: species }),
rows,
...this.common(),
};
}
/** DisplayDexEntry. */
private entryView(page: 1 | 2, action: number): PokedexView {
const { data, dex, caught } = this.host;
const species = this.selected();
const entry = dex.entry(species);
const owned = caught.has(species);
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
weight = dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight");
return {
screen: "entry",
picture: species,
rows: [],
...this.common(),
entry: {
species,
name: data.speciesName(species),
category: entry.category,
number: String(species).padStart(3, "0"),
height: owned ? dexHeight(height, entry.height) : height,
weight: owned ? dexWeight(weight, entry.weight) : weight,
caught: owned,
page,
...(owned && { text: entry.pages[page - 1] }),
actions: dex
.dexString("Pokedex_DrawDexEntryScreenBG.MenuItems")
.trim()
.split(/\s+/),
action,
},
};
}
}
+23 -2
View File
@@ -1,5 +1,6 @@
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 type { PokedexData } from "../rom/pokedex";
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile"; import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
import { C } from "../battle/constants"; import { C } from "../battle/constants";
import { import {
@@ -22,6 +23,7 @@ import {
} from "./interpreter"; } from "./interpreter";
import { Pokegear } from "./pokegear"; import { Pokegear } from "./pokegear";
import { TrainerCard } from "./trainer-card"; import { TrainerCard } from "./trainer-card";
import { Pokedex } from "./pokedex";
import { OptionsMenu, menuAccount } from "./options"; import { OptionsMenu, menuAccount } from "./options";
import { flyMap } from "./fly-map"; import { flyMap } from "./fly-map";
import { OW } from "../world/constants"; import { OW } from "../world/constants";
@@ -102,11 +104,13 @@ const FACING_VECTORS = [
/** Menu cursors in work RAM, which CleanUpBattleRAM clears after every /** Menu cursors in work RAM, which CleanUpBattleRAM clears after every
* battle: wBattleMenuCursorPosition (the start menu's 1-based position, * battle: wBattleMenuCursorPosition (the start menu's 1-based position,
* shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket. */ * shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket.
* wPrevDexEntry, the Pokédex's last entry, survives battles. */
export interface MenuCursors { export interface MenuCursors {
start: number; start: number;
party: number; party: number;
pocket: number; pocket: number;
dex: number;
} }
/** The world's verdict on a field move chosen from the party menu: a /** The world's verdict on a field move chosen from the party menu: a
@@ -131,6 +135,7 @@ export type MenuFieldMove =
export interface StartMenuHost { export interface StartMenuHost {
reader: RomReader; reader: RomReader;
data: PokemonData; data: PokemonData;
dex: PokedexData;
text(): TextContext; text(): TextContext;
/** BikeFunction: the script that getting on or off the Bicycle queues /** BikeFunction: the script that getting on or off the Bicycle queues
* here, or undefined when .CheckEnvironment refuses it. */ * here, or undefined when .CheckEnvironment refuses it. */
@@ -187,7 +192,7 @@ export class StartMenuRun {
constructor( constructor(
state: ScriptState, state: ScriptState,
private host: StartMenuHost, private host: StartMenuHost,
cursors: MenuCursors = { start: 0, party: 0, pocket: 0 }, cursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 },
select = false, select = false,
) { ) {
this.state = copyScriptState(state); this.state = copyScriptState(state);
@@ -323,6 +328,22 @@ export class StartMenuRun {
this.cursors.start = choice + 1; this.cursors.start = choice + 1;
const item = items[choice]; const item = items[choice];
switch (item) { switch (item) {
case STARTMENUITEM_POKEDEX: {
// StartMenu_Pokedex needs a party and returns to the start menu.
if (!this.state.party.length) break;
const dex = new Pokedex(
{
data: this.data,
dex: this.host.dex,
seen: this.state.pokedex.seen,
caught: this.state.pokedex.caught,
},
this.cursors.dex,
);
yield* dex.run();
this.cursors.dex = dex.prevEntry;
break;
}
case STARTMENUITEM_POKEMON: case STARTMENUITEM_POKEMON:
if (yield* this.pokemon()) return; if (yield* this.pokemon()) return;
break; break;
+23 -3
View File
@@ -56,6 +56,7 @@ import {
} from "../scripts/interpreter"; } from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro"; import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map"; import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
import { import {
StartMenuRun, StartMenuRun,
type MenuCursors, type MenuCursors,
@@ -311,6 +312,8 @@ export type Choice =
| { kind: "mombank"; view: MomBankView; selected: 0 } | { kind: "mombank"; view: MomBankView; selected: 0 }
/** A trainer card page, answered with the button pressed. */ /** A trainer card page, answered with the button pressed. */
| { kind: "trainercard"; view: TrainerCardView; selected: 0 } | { kind: "trainercard"; view: TrainerCardView; selected: 0 }
/** A Pokédex screen, answered with the button pressed. */
| { kind: "pokedex"; view: PokedexView; selected: 0 }
/** The options screen, answered with the button pressed. */ /** The options screen, answered with the button pressed. */
| { kind: "options"; view: OptionsView; selected: 0 } | { kind: "options"; view: OptionsView; selected: 0 }
/** The Fly map, answered with the button pressed. */ /** The Fly map, answered with the button pressed. */
@@ -332,6 +335,7 @@ const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" || choice.kind === "pokegear" ||
choice.kind === "mombank" || choice.kind === "mombank" ||
choice.kind === "trainercard" || choice.kind === "trainercard" ||
choice.kind === "pokedex" ||
choice.kind === "options" || choice.kind === "options" ||
choice.kind === "flymap"; choice.kind === "flymap";
export interface Dialogue { export interface Dialogue {
@@ -683,7 +687,7 @@ export class GameWorld {
private turnTo?: Direction; private turnTo?: Direction;
private turnWait = 0; private turnWait = 0;
/** Start menu, party menu and pack cursors (work RAM, not saved). */ /** Start menu, party menu and pack cursors (work RAM, not saved). */
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0 }; private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */ /** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
private bumping = false; private bumping = false;
/** A screen shake object (earthquake, step_shake) in progress. */ /** A screen shake object (earthquake, step_shake) in progress. */
@@ -2230,6 +2234,7 @@ export class GameWorld {
{ {
reader: this.assets.reader, reader: this.assets.reader,
data: this.assets.pokemon, data: this.assets.pokemon,
dex: this.assets.pokedex,
text: () => this.host(PLAYER).text(), text: () => this.host(PLAYER).text(),
bicycleScript: (state, select) => this.bicycleScript(state, select), bicycleScript: (state, select) => this.bicycleScript(state, select),
timeOfDay: () => this.clockTimeOfDay, timeOfDay: () => this.clockTimeOfDay,
@@ -2619,6 +2624,14 @@ export class GameWorld {
choice: { kind: "trainercard", view: effect.view, selected: 0 }, choice: { kind: "trainercard", view: effect.view, selected: 0 },
}; };
return true; return true;
case "pokedex":
if (!effect.view) return false;
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "pokedex", view: effect.view, selected: 0 },
};
return true;
case "options": case "options":
if (!effect.view) return false; if (!effect.view) return false;
this.dialogue = { this.dialogue = {
@@ -3168,7 +3181,12 @@ export class GameWorld {
this.battleRun = undefined; this.battleRun = undefined;
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM. // ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
const { result, evolvable } = next.value; const { result, evolvable } = next.value;
this.menuCursors = { start: 0, party: 0, pocket: 0 }; this.menuCursors = {
start: 0,
party: 0,
pocket: 0,
dex: this.menuCursors.dex,
};
if (this.script && evolvable && !(result & 0x0f)) { if (this.script && evolvable && !(result & 0x0f)) {
this.script.interlude = { this.script.interlude = {
run: new PartyEvolution( run: new PartyEvolution(
@@ -4202,6 +4220,8 @@ export class GameWorld {
!MOM_BANK_BUTTONS.includes(answer as never)) || !MOM_BANK_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "trainercard" && (dialogue.choice.kind === "trainercard" &&
!TRAINER_CARD_BUTTONS.includes(answer as never)) || !TRAINER_CARD_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "pokedex" &&
!POKEDEX_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "options" && (dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never)) || !OPTIONS_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "flymap" && (dialogue.choice.kind === "flymap" &&
@@ -4538,7 +4558,7 @@ export class GameWorld {
this.battle = undefined; this.battle = undefined;
this.battleRun = undefined; this.battleRun = undefined;
this.memScript = undefined; this.memScript = undefined;
this.menuCursors = { start: 0, party: 0, pocket: 0 }; this.menuCursors = { start: 0, party: 0, pocket: 0, dex: 0 };
this.clearNotice(); this.clearNotice();
this.introPending = false; this.introPending = false;
// A save made during the introduction starts it again. // A save made during the introduction starts it again.
+63 -7
View File
@@ -5,6 +5,8 @@ import {
} from "./game/rendering/trainer-card"; } from "./game/rendering/trainer-card";
import "./ui/styles.css"; import "./ui/styles.css";
import { renderShell } from "./ui/shell"; import { renderShell } from "./ui/shell";
import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex";
import { ENGINE_FLAGS } from "./game/rom/profile";
import { bindTabs } from "./ui/tabs"; import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime"; import { NativeRuntime } from "./game/runtime";
import { LocalMapView } from "./game/rendering/minimap"; import { LocalMapView } from "./game/rendering/minimap";
@@ -61,6 +63,7 @@ function showWorld(world: GameWorld) {
showMail(world); showMail(world);
showPokegear(world); showPokegear(world);
showTrainerCard(world); showTrainerCard(world);
showPokedex(world);
showOptions(world); showOptions(world);
showFlyMap(world); showFlyMap(world);
get("town-map-view").hidden = !world.townMap; get("town-map-view").hidden = !world.townMap;
@@ -79,6 +82,7 @@ function showWorld(world: GameWorld) {
// The Pokégear's map and radio cards have no textbox. // The Pokégear's map and radio cards have no textbox.
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) || (dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
dialogue.choice?.kind === "trainercard" || dialogue.choice?.kind === "trainercard" ||
dialogue.choice?.kind === "pokedex" ||
dialogue.choice?.kind === "options" || dialogue.choice?.kind === "options" ||
dialogue.choice?.kind === "flymap" || dialogue.choice?.kind === "flymap" ||
Boolean(mart) || Boolean(mart) ||
@@ -138,11 +142,12 @@ function showChoice(choice: Choice | undefined) {
else if ( else if (
choice.kind === "pokegear" || choice.kind === "pokegear" ||
choice.kind === "trainercard" || choice.kind === "trainercard" ||
choice.kind === "pokedex" ||
choice.kind === "options" || choice.kind === "options" ||
choice.kind === "flymap" choice.kind === "flymap"
) { ) {
// The Pokégear, the trainer card, the options and the Fly map read // The Pokégear, the trainer card, the Pokédex, the options and the
// the controls directly. // Fly map read the controls directly.
container.replaceChildren(); container.replaceChildren();
container.hidden = true; container.hidden = true;
} else if (choice.kind === "yesno") } else if (choice.kind === "yesno")
@@ -1078,6 +1083,50 @@ function showPokegear(world: GameWorld) {
} else body.replaceChildren(); } else body.replaceChildren();
} }
let pokedexKey = "";
/** Pokedex: the main list or an entry, from the world's current view. */
function showPokedex(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "pokedex" ? choice.view : undefined;
const panel = get("pokedex-screen");
panel.hidden = !view;
if (!view) {
pokedexKey = "";
return;
}
const key = JSON.stringify(view);
if (key === pokedexKey) return;
pokedexKey = key;
renderPokedexScreen(panel, view, world.assets);
}
let pokedexTabKey = "";
let pokedexTabSelection: number | undefined;
/** The Pokédex tab, from the loaded game's committed or draft state. */
function showPokedexTab() {
if (tabs.active() !== "pokedex") return;
const world = ready() ? runtime.world : undefined;
const state = world?.liveState;
const progress = state && {
owned: state.flags.has(ENGINE_FLAGS.pokedex),
seen: state.pokedex.seen,
caught: state.pokedex.caught,
};
const key = JSON.stringify([
Boolean(world),
progress && [progress.owned, [...progress.seen], [...progress.caught]],
pokedexTabSelection,
]);
if (key === pokedexTabKey) return;
pokedexTabKey = key;
renderPokedexTab(get("panel-pokedex"), world?.assets, progress, pokedexTabSelection, (species) => {
pokedexTabSelection = species;
showPokedexTab();
// On narrow screens the entry sits below the list.
get("panel-pokedex").querySelector(".pokedex-tab-entry")?.scrollIntoView({ block: "nearest" });
});
}
let optionsKey = ""; let optionsKey = "";
/** _Option's screen: each option with its setting, and the ▶ cursor. */ /** _Option's screen: each option with its setting, and the ▶ cursor. */
function showOptions(world: GameWorld) { function showOptions(world: GameWorld) {
@@ -1454,11 +1503,17 @@ const controls = bindControls(
() => {}, () => {},
); );
const ready = () => ["paused", "playing"].includes(session.state); const ready = () => ["paused", "playing"].includes(session.state);
const tabs = bindTabs(root, () => { const tabs = bindTabs(
pause(); root,
controls.release(); () => {
update(); pause();
}); controls.release();
update();
},
(id) => {
if (id === "pokedex") showPokedexTab();
},
);
function showError(error: unknown) { function showError(error: unknown) {
status.textContent = status.textContent =
@@ -1498,6 +1553,7 @@ function update() {
get("rom-label").classList.toggle("disabled", busy); get("rom-label").classList.toggle("disabled", busy);
get<HTMLButtonElement>("dialogue-next").disabled = get<HTMLButtonElement>("dialogue-next").disabled =
busy || session.state !== "playing"; busy || session.state !== "playing";
showPokedexTab();
} }
async function save() { async function save() {
+290
View File
@@ -0,0 +1,290 @@
import type { CrystalAssets } from "../game/rom/assets";
import { dexHeight, dexWeight } from "../game/rom/pokedex";
import { drawTile } from "../game/rendering/pixels";
import type { PokedexView } from "../game/scripts/pokedex";
import type { Palette } from "../game/world/types";
/** The ink and paper the footprint's 1bpp tiles are drawn with. */
const FOOTPRINT_INK: Palette = [
[40, 40, 49],
[255, 251, 234],
];
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
className = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
if (className) node.className = className;
return node;
};
/** Pokedex_LoadSelectedMonTiles: a seen Pokémon's front picture, or the
* question mark. The list draws it in the question mark's palette. */
function dexPicture(
assets: CrystalAssets,
species: number | undefined,
ownPalette: boolean,
) {
const canvas = element("canvas", "", "pokedex-pic");
canvas.width = canvas.height = 56;
canvas.setAttribute("role", "img");
const tiles =
species === undefined
? assets.pokedex.questionMarkPic()
: assets.pokemon.frontPic(species);
const palette =
species !== undefined && ownPalette
? assets.pokemon.palette(species)
: assets.pokedex.questionMarkPalette();
const context = canvas.getContext("2d")!;
const image = context.createImageData(56, 56);
// Picture tiles are stored column by column.
for (let tile = 0; tile < 49; tile++)
drawTile(
image.data,
56,
56,
tiles,
tile,
Math.floor(tile / 7) * 8,
(tile % 7) * 8,
palette,
);
context.putImageData(image, 0, 0);
canvas.setAttribute(
"aria-label",
species === undefined
? "Unknown Pokémon"
: assets.pokemon.speciesName(species),
);
return canvas;
}
/** Pokedex_DrawFootprint: the 2x2 1bpp tiles, whose clear bits are the
* print, like the Pokédex's inverted font. */
function footprint(assets: CrystalAssets, species: number) {
const canvas = element("canvas", "", "pokedex-footprint");
canvas.width = canvas.height = 16;
canvas.setAttribute("aria-hidden", "true");
const tiles = assets.pokedex.footprint(species);
const context = canvas.getContext("2d")!;
const image = context.createImageData(16, 16);
for (let tile = 0; tile < 4; tile++)
for (let row = 0; row < 8; row++) {
const bits = tiles[tile * 8 + row];
for (let col = 0; col < 8; col++) {
const color = FOOTPRINT_INK[(bits >> (7 - col)) & 1];
const i = (((tile >> 1) * 8 + row) * 16 + (tile & 1) * 8 + col) * 4;
image.data.set([...color, 255], i);
}
}
context.putImageData(image, 0, 0);
return canvas;
}
/** The ball DisplayDexEntry's list marks owned Pokémon with (tile $4f). */
function ownedMark(owned: boolean) {
const mark = element("span", "", "pokedex-ball");
if (owned) mark.setAttribute("aria-label", "Owned");
else mark.classList.add("empty");
return mark;
}
/** DisplayDexEntry's top half: the picture and footprint, the name and
* category, the number and the measurements. */
function entryHeader(
assets: CrystalAssets,
entry: {
species: number;
name: string;
category: string;
number: string;
height: string;
weight: string;
},
) {
const header = element("div", "", "pokedex-entry-top");
const details = element("div", "", "pokedex-entry-details");
details.append(
element("strong", entry.name),
element("span", entry.category),
element("span", `№.${entry.number}`),
element("span", entry.height),
element("span", entry.weight),
);
header.append(
dexPicture(assets, entry.species, true),
details,
footprint(assets, entry.species),
);
return header;
}
/** Pokedex (engine/pokedex/pokedex.asm): the main list with the picture
* and counts beside it, or a Pokémon's entry. */
export function renderPokedexScreen(
container: HTMLElement,
view: PokedexView,
assets: CrystalAssets,
) {
container.dataset.screen = view.screen;
const entry = view.entry;
if (view.screen === "entry" && entry) {
const text = element("p", entry.text ?? "", "pokedex-text");
text.setAttribute("aria-live", "polite");
const page = element("span", `P.${entry.page}`, "pokedex-page");
const actions = element("ol", "", "pokedex-actions");
entry.actions.forEach((label, index) => {
const item = element("li", label);
item.dataset.mark = index === entry.action ? "▶" : " ";
if (index === entry.action) item.setAttribute("aria-current", "true");
actions.append(item);
});
container.replaceChildren(
entryHeader(assets, entry),
element("div", "", "pokedex-text-box"),
actions,
);
container.querySelector(".pokedex-text-box")!.append(page, text);
return;
}
const side = element("div", "", "pokedex-side");
const counts = element("dl", "", "pokedex-counts");
counts.append(
element("dt", view.labels.seen),
element("dd", String(view.seen).padStart(3, " ")),
element("dt", view.labels.own),
element("dd", String(view.own).padStart(3, " ")),
);
side.append(dexPicture(assets, view.picture, false), counts);
const list = element("ol", "", "pokedex-list");
view.rows.forEach((row, index) => {
const item = element("li");
item.dataset.mark = index === view.cursor ? "▶" : " ";
if (index === view.cursor) item.setAttribute("aria-current", "true");
item.append(ownedMark(row.caught), element("span", row.name));
list.append(item);
});
// Pokedex_PutScrollbarOAM: the bar's position along the seen range.
const bar = element("div", "", "pokedex-scrollbar");
const thumb = element("i");
const range = Math.max(1, view.end - view.rows.length);
const percent = Math.min(100, (view.scroll / range) * 100);
// The 12px thumb stays inside the bar at either end.
thumb.style.top = `calc(${percent}% - ${(percent * 12) / 100}px)`;
bar.setAttribute("aria-hidden", "true");
bar.append(thumb);
container.replaceChildren(side, list, bar);
}
/** What the Pokédex tab reads from a loaded game. */
export interface PokedexProgress {
owned: boolean;
seen: ReadonlySet<number>;
caught: ReadonlySet<number>;
}
/** The Pokédex tab: New mode's list up to the last seen Pokémon, and the
* selected entry. Unseen Pokémon stay unnamed and uncaught ones show no
* measurements or text, as in the game. */
export function renderPokedexTab(
panel: HTMLElement,
assets: CrystalAssets | undefined,
progress: PokedexProgress | undefined,
selected: number | undefined,
select: (species: number) => void,
) {
const status = element("p", "", "pokedex-tab-status");
const header = element("header");
header.append(element("p", "POKÉDEX", "menu-title"));
if (!assets || !progress) {
header.append(element("h2", "No game loaded"));
status.textContent =
"Choose a ROM to read the Pokémon you have seen and caught.";
panel.replaceChildren(header, status);
return;
}
if (!progress.owned) {
header.append(element("h2", "No Pokédex yet"));
status.textContent =
"Your records appear here once you receive the Pokédex in the game.";
panel.replaceChildren(header, status);
return;
}
const dex = assets.pokedex;
header.append(
element(
"h2",
`${dex.dexString("String_SEEN")} ${progress.seen.size} · ${dex.dexString("String_OWN")} ${progress.caught.size}`,
),
);
const order = dex.newOrder();
let end = order.length;
while (end > 0 && !progress.seen.has(order[end - 1])) end--;
const list = element("ol", "", "pokedex-tab-list");
list.setAttribute("aria-label", "New mode order");
for (const species of order.slice(0, end)) {
const item = element("li");
const seen = progress.seen.has(species);
if (seen) {
const button = element("button");
button.type = "button";
button.append(
ownedMark(progress.caught.has(species)),
element("span", assets.pokemon.speciesName(species)),
);
button.setAttribute("aria-pressed", String(species === selected));
button.addEventListener("click", () => select(species));
item.append(button);
} else item.append(ownedMark(false), element("span", dex.notSeenName()));
list.append(item);
}
const detail = element("section", "", "pokedex-tab-entry");
detail.setAttribute("aria-live", "polite");
const species =
selected !== undefined && progress.seen.has(selected)
? selected
: undefined;
if (species === undefined) {
detail.append(
element(
"p",
end
? "Select a Pokémon you have seen to read its entry."
: "You have not seen any Pokémon yet.",
),
);
} else {
const entry = dex.entry(species);
const caught = progress.caught.has(species);
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
weight = dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight");
detail.append(
entryHeader(assets, {
species,
name: assets.pokemon.speciesName(species),
category: entry.category,
number: String(species).padStart(3, "0"),
height: caught ? dexHeight(height, entry.height) : height,
weight: caught ? dexWeight(weight, entry.weight) : weight,
}),
);
if (caught)
for (const page of entry.pages)
detail.append(element("p", page, "pokedex-text"));
else
detail.append(
element(
"p",
"Catch this Pokémon to read its entry.",
"pokedex-tab-status",
),
);
}
const body = element("div", "", "pokedex-tab-body");
body.append(list, detail);
panel.replaceChildren(header, body);
}
+2 -1
View File
@@ -35,6 +35,7 @@ export function renderShell(root: HTMLElement) {
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div> <div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div>
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div> <div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div> <div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div> <div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
<div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></div> <div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div> <div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
@@ -67,7 +68,7 @@ export function renderShell(root: HTMLElement) {
<header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header> <header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header>
<div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area will appear here after loading a game. A wider Johto and Kanto atlas is planned.</p></div> <div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area will appear here after loading a game. A wider Johto and Kanto atlas is planned.</p></div>
</section> </section>
<section id="panel-pokedex" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><p class="menu-title">POKÉDEX</p><h2>Planned</h2><p>Track the Pokémon you have seen and caught. Available after encounters and catching arrive.</p></section> <section id="panel-pokedex" class="tab-panel menu-panel pokedex-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><header><p class="menu-title">POKÉDEX</p><h2>No game loaded</h2></header><p class="pokedex-tab-status">Choose a ROM to read the Pokémon you have seen and caught.</p></section>
<section id="panel-link" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-link" data-panel="link" hidden><p class="menu-title">LINK</p><h2>Planned</h2><p>Trade and battle with other players. Online Link is planned.</p></section> <section id="panel-link" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-link" data-panel="link" hidden><p class="menu-title">LINK</p><h2>Planned</h2><p>Trade and battle with other players. Online Link is planned.</p></section>
<section id="panel-friends" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-friends" data-panel="friends" hidden><p class="menu-title">FRIENDS</p><h2>Planned</h2><p>Keep your friends together and send private Link invites. Friends are planned.</p></section> <section id="panel-friends" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-friends" data-panel="friends" hidden><p class="menu-title">FRIENDS</p><h2>Planned</h2><p>Keep your friends together and send private Link invites. Friends are planned.</p></section>
</main> </main>
+214
View File
@@ -583,6 +583,7 @@ dialog h2 {
.intro-screen:not([hidden]) ~ .minimap-overlay, .intro-screen:not([hidden]) ~ .minimap-overlay,
.pokegear:not([hidden]) ~ .minimap-overlay, .pokegear:not([hidden]) ~ .minimap-overlay,
.trainer-card:not([hidden]) ~ .minimap-overlay, .trainer-card:not([hidden]) ~ .minimap-overlay,
.pokedex-screen:not([hidden]) ~ .minimap-overlay,
.options-screen:not([hidden]) ~ .minimap-overlay, .options-screen:not([hidden]) ~ .minimap-overlay,
.fly-map:not([hidden]) ~ .minimap-overlay { .fly-map:not([hidden]) ~ .minimap-overlay {
display: none; display: none;
@@ -1371,6 +1372,219 @@ dialog dd {
image-rendering: pixelated; image-rendering: pixelated;
} }
/* Pokedex: PREDEFPAL_POKEDEX's red frame around the list or the entry. */
.pokedex-screen {
--dex-red: #d65231;
position: absolute;
inset: 0;
z-index: 2;
display: grid;
gap: 8px;
padding: 10px;
overflow-y: auto;
color: var(--ink);
background: var(--dex-red);
font-variant-numeric: tabular-nums;
}
.pokedex-screen[data-screen="list"] {
grid-template-columns: auto minmax(0, 1fr) 8px;
}
.pokedex-screen[data-screen="entry"] {
grid-template-rows: auto 1fr auto;
}
.pokedex-entry-top .pokedex-pic {
width: min(22vw, 112px);
}
.pokedex-side,
.pokedex-list,
.pokedex-entry-top,
.pokedex-text-box,
.pokedex-actions {
margin: 0;
padding: 8px 10px;
background: var(--cream);
border: 5px double var(--ink);
}
.pokedex-side {
display: grid;
align-content: start;
justify-items: center;
gap: 10px;
}
.pokedex-pic {
width: min(28vw, 112px);
aspect-ratio: 1;
image-rendering: pixelated;
}
.pokedex-counts {
display: grid;
grid-template-columns: auto auto;
gap: 2px 10px;
margin: 0;
}
.pokedex-counts dt,
.pokedex-counts dd {
margin: 0;
white-space: pre;
}
.pokedex-list {
display: grid;
align-content: start;
gap: 6px;
list-style: none;
}
.pokedex-list li,
.pokedex-tab-list li,
.pokedex-tab-list button {
display: flex;
align-items: center;
gap: 6px;
white-space: pre;
}
.pokedex-list li::before,
.pokedex-actions li::before {
content: attr(data-mark);
width: 1.2em;
}
/* The owned mark: a Poké Ball, or its empty space. */
.pokedex-ball {
flex: none;
width: 0.8em;
height: 0.8em;
border: 2px solid var(--ink);
border-radius: 50%;
background: linear-gradient(var(--dex-red, #d65231) 45%, var(--ink) 45% 55%, #fff 55%);
}
.pokedex-ball.empty {
visibility: hidden;
}
.pokedex-scrollbar {
position: relative;
background: var(--cream);
border: 2px solid var(--ink);
}
.pokedex-scrollbar i {
position: absolute;
left: 0;
right: 0;
height: 12px;
background: var(--ink);
}
.pokedex-entry-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
}
.pokedex-entry-details {
display: grid;
gap: 2px;
white-space: pre;
}
.pokedex-footprint {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.pokedex-text-box {
display: grid;
grid-template-columns: auto 1fr;
align-content: start;
gap: 10px;
}
.pokedex-text {
margin: 0;
white-space: pre-line;
}
.pokedex-actions {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
list-style: none;
}
.pokedex-actions li {
display: flex;
}
/* The Pokédex tab: the New mode list beside the selected entry. */
.pokedex-panel {
--dex-red: #d65231;
}
.pokedex-tab-status {
max-width: 680px;
font-size: 15px;
line-height: 1.8;
}
.pokedex-tab-body {
display: grid;
grid-template-columns: minmax(180px, 260px) minmax(0, 1fr);
align-items: start;
gap: 18px;
margin-top: 18px;
}
.pokedex-tab-list {
display: grid;
gap: 2px;
margin: 0;
padding: 8px;
max-height: 60vh;
overflow-y: auto;
list-style: none;
background: var(--cream);
border: 5px double var(--ink);
}
.pokedex-tab-list li {
min-height: 32px;
padding: 0 6px;
}
.pokedex-tab-list button {
width: 100%;
min-height: 32px;
padding: 0;
font: inherit;
color: inherit;
text-align: left;
background: none;
border: 0;
cursor: pointer;
}
.pokedex-tab-list button[aria-pressed="true"]::after {
content: " ◀";
}
.pokedex-tab-entry {
display: grid;
gap: 12px;
padding: 12px;
background: var(--cream);
border: 5px double var(--ink);
}
.pokedex-tab-entry .pokedex-entry-top {
padding: 0;
border: 0;
}
@media (max-width: 480px) {
.pokedex-screen {
gap: 4px;
padding: 6px;
}
.pokedex-side,
.pokedex-list,
.pokedex-entry-top,
.pokedex-text-box,
.pokedex-actions {
padding: 4px 8px;
border-width: 4px;
}
}
@media (max-width: 720px) {
.pokedex-tab-body {
grid-template-columns: minmax(0, 1fr);
}
.pokedex-tab-list {
max-height: 40vh;
}
}
/* _Option: one framed box of options, settings beside their labels. */ /* _Option: one framed box of options, settings beside their labels. */
.options-screen { .options-screen {
position: absolute; position: absolute;
+6 -1
View File
@@ -1,7 +1,11 @@
const tabIds = ["game", "saves", "map", "pokedex", "link", "friends"] as const; const tabIds = ["game", "saves", "map", "pokedex", "link", "friends"] as const;
type TabId = (typeof tabIds)[number]; type TabId = (typeof tabIds)[number];
export function bindTabs(root: HTMLElement, onLeaveGame: () => void) { export function bindTabs(
root: HTMLElement,
onLeaveGame: () => void,
onSelect: (id: TabId) => void = () => {},
) {
const tabs = tabIds.map((id) => const tabs = tabIds.map((id) =>
root.querySelector<HTMLButtonElement>(`#tab-${id}`), root.querySelector<HTMLButtonElement>(`#tab-${id}`),
); );
@@ -25,6 +29,7 @@ export function bindTabs(root: HTMLElement, onLeaveGame: () => void) {
button.innerHTML = `${selected ? '<span aria-hidden="true">▶</span>' : ""}${button.textContent?.replace(/^▶/, "").trim() ?? ""}`; button.innerHTML = `${selected ? '<span aria-hidden="true">▶</span>' : ""}${button.textContent?.replace(/^▶/, "").trim() ?? ""}`;
panels[index]!.hidden = !selected; panels[index]!.hidden = !selected;
}); });
onSelect(current);
}; };
tabs.forEach((tab, index) => { tabs.forEach((tab, index) => {
+4 -4
View File
@@ -71,10 +71,10 @@ test("game menu tabs expose real tools and honest planned states with keyboard n
await page.keyboard.press("Home"); await page.keyboard.press("Home");
await expect(game).toBeFocused(); await expect(game).toBeFocused();
await expect(page.locator("#panel-game")).toBeVisible(); await expect(page.locator("#panel-game")).toBeVisible();
for (const id of ["pokedex", "link"]) { await page.locator("#tab-pokedex").click();
await page.locator(`#tab-${id}`).click(); await expect(page.locator("#panel-pokedex")).toContainText("Choose a ROM");
await expect(page.locator(`#panel-${id}`)).toContainText(/planned/i); await page.locator("#tab-link").click();
} await expect(page.locator("#panel-link")).toContainText(/planned/i);
}); });
test("actual WASM executes an original cartridge, reads input and round-trips state", async ({ test("actual WASM executes an original cartridge, reads input and round-trips state", async ({
+91
View File
@@ -0,0 +1,91 @@
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);
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({
response,
body: source.replace("this.world?.beginIntro();", "").replace(
needle,
`const { pokedexFixtureWorld } = await import('/tests/pokedex-fixture.ts'); const world = pokedexFixtureWorld(assets);`,
),
});
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
test("the start menu's POKéDEX lists, opens entries and returns", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await start(page);
const dex = page.locator("#pokedex-screen");
await page.keyboard.press("Enter");
await page.locator("#dialogue-choice").getByRole("option", { name: /^POKéDEX/ }).click();
await expect(dex).toBeVisible();
await expect(page.locator("#native-dialogue")).toBeHidden();
const rows = dex.locator(".pokedex-list li");
await expect(rows).toHaveCount(7);
await expect(rows.first()).toHaveText(/CHIKORITA/);
await expect(rows.first()).toHaveAttribute("aria-current", "true");
await expect(rows.nth(1)).toHaveText(/-----/);
await expect(dex.locator(".pokedex-counts")).toContainText("SEEN");
await expect(dex.locator(".pokedex-pic")).toHaveAttribute("aria-label", "CHIKORITA");
// Down three rows to CYNDAQUIL, which is owned.
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowDown");
await expect(rows.nth(3)).toHaveAttribute("aria-current", "true");
await expect(rows.nth(3).locator(".pokedex-ball")).toHaveAttribute("aria-label", "Owned");
await page.keyboard.press("KeyX");
await expect(dex).toHaveAttribute("data-screen", "entry");
await expect(dex.locator(".pokedex-entry-details")).toContainText("FIRE MOUSE");
await expect(dex.locator(".pokedex-entry-details")).toContainText(`HT 1'08"`);
await expect(dex.locator(".pokedex-page")).toHaveText("P.1");
await page.keyboard.press("KeyX");
await expect(dex.locator(".pokedex-page")).toHaveText("P.2");
// Up steps back to the previous seen Pokémon, which is not caught.
await page.keyboard.press("ArrowUp");
await expect(dex.locator(".pokedex-entry-details")).toContainText("CHIKORITA");
await expect(dex.locator(".pokedex-entry-details")).toContainText(`HT ?'??"`);
await expect(dex.locator(".pokedex-text")).toHaveText("");
// B returns to the list, then to the start menu.
await page.keyboard.press("KeyZ");
await expect(dex).toHaveAttribute("data-screen", "list");
await page.keyboard.press("KeyZ");
await expect(dex).toBeHidden();
await expect(page.locator("#dialogue-choice").getByRole("option", { name: /^POKéDEX/ })).toBeVisible();
expect(errors).toEqual([]);
});
test("the Pokédex tab shows seen and owned records from the game", async ({ page }) => {
await start(page);
await page.locator("#tab-pokedex").click();
const panel = page.locator("#panel-pokedex");
await expect(panel.locator("h2")).toHaveText("SEEN 3 · OWN 1");
const list = panel.locator(".pokedex-tab-list li");
// PIDGEY is tenth in New mode.
await expect(list).toHaveCount(10);
await expect(list.nth(1)).toHaveText(/-----/);
await panel.getByRole("button", { name: /CYNDAQUIL/ }).click();
await expect(panel.locator(".pokedex-tab-entry")).toContainText("FIRE MOUSE");
await expect(panel.locator(".pokedex-tab-entry .pokedex-text")).toHaveCount(2);
await panel.getByRole("button", { name: /PIDGEY/ }).click();
await expect(panel.locator(".pokedex-tab-entry")).toContainText("TINY BIRD");
await expect(panel.locator(".pokedex-tab-entry")).toContainText("Catch this Pokémon");
await expect(panel.locator(".pokedex-tab-entry .pokedex-text")).toHaveCount(0);
});
+21
View File
@@ -0,0 +1,21 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { GameWorld } from "../src/game/world/world";
/** New Bark Town with a Cyndaquil, the Pokédex, and three Pokémon seen:
* CHIKORITA, CYNDAQUIL (caught) and PIDGEY. */
export function pokedexFixtureWorld(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,
}),
];
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.pokedex.seen = new Set([152, 155, 16]);
world.state.pokedex.caught = new Set([155]);
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
return world;
}
+340
View File
@@ -0,0 +1,340 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { dexHeight, dexWeight } from "../src/game/rom/pokedex";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import type { PokedexView } from "../src/game/scripts/pokedex";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const pret = ".reference/pokecrystal";
const NEW_BARK_TOWN = "24:4",
BULBASAUR = 1,
PIDGEY = 16,
CHIKORITA = 152,
BAYLEEF = 153,
CYNDAQUIL = 155,
QUILAVA = 156,
TOTODILE = 158,
SENTRET = 161,
HOOTHOOT = 163;
describe("the Pokédex number formats", () => {
it("prints height and weight over the ROM templates", () => {
const height = `HT ?'??"`,
weight = "WT ???lb";
expect(dexHeight(height, 204)).toBe(`HT 2'04"`);
expect(dexHeight(height, 8)).toBe(`HT 0'08"`);
expect(dexHeight(height, 1701)).toBe(`HT 17'01"`);
expect(dexWeight(weight, 150)).toBe("WT 15.0lb");
expect(dexWeight(weight, 1)).toBe("WT 0.1lb");
expect(dexWeight(weight, 4760)).toBe("WT 476.0lb");
// DisplayDexEntry skips a zero word and leaves the question marks.
expect(dexHeight(height, 0)).toBe(height);
expect(dexWeight(weight, 0)).toBe(weight);
});
});
describe.skipIf(!existsSync(romPath))("the Pokédex data", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const dex = assets.pokedex;
it("reads its strings and the New mode order", () => {
expect(dex.dexString("String_SEEN")).toBe("SEEN");
expect(dex.dexString("String_OWN")).toBe("OWN");
expect(dex.dexString("Pokedex_DrawDexEntryScreenBG.Height")).toBe(`HT ?'??"`);
expect(dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight")).toBe("WT ???lb");
expect(dex.dexString("Pokedex_DrawDexEntryScreenBG.MenuItems").trim()).toBe(
"PAGE AREA CRY PRNT",
);
expect(dex.notSeenName()).toBe("-----");
const order = dex.newOrder();
expect(order.slice(0, 3)).toEqual([CHIKORITA, BAYLEEF, 154]);
expect([...order].sort((a, b) => a - b)).toEqual(
Array.from({ length: 251 }, (_, i) => i + 1),
);
});
it("reads an entry's category, measurements and pages", () => {
const entry = dex.entry(BULBASAUR);
expect(entry).toMatchObject({ category: "SEED", height: 204, weight: 150 });
expect(entry.pages[0].split("\n")).toHaveLength(3);
expect(entry.pages[1].split("\n")).toHaveLength(3);
for (let species = 1; species <= 251; species++) dex.entry(species);
});
it("decodes the question mark picture and its palette", () => {
expect(dex.questionMarkPic()).toHaveLength(49 * 16);
// RGB 11, 23, 00 from gfx/pokedex/question_mark.pal.
expect(dex.questionMarkPalette()[0]).toEqual([90, 189, 0]);
});
});
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/pokemon/dex_order_new.asm`))(
"the Pokédex data against the pinned PRET source",
() => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const dex = assets.pokedex;
const read = (path: string) => readFileSync(`${pret}/${path}`, "utf8");
// constants/pokemon_constants.asm numbers species from 1.
const species = new Map(
[...read("constants/pokemon_constants.asm").matchAll(/^\s+const (\w+)/gm)].map(
([, name], index) => [name, index + 1],
),
);
it("matches NewPokedexOrder", () => {
const order = [...read("data/pokemon/dex_order_new.asm").matchAll(/^\s+db (\w+)/gm)].map(
([, name]) => species.get(name),
);
expect(dex.newOrder()).toEqual(order);
});
it("matches every entry in data/pokemon/dex_entries", () => {
const files = new Map(
[...read("data/pokemon/dex_entries.asm").matchAll(/^(\w+)::\s+INCLUDE "([^"]+)"/gm)].map(
([, label, file]) => [label, file],
),
);
const labels = [
...read("data/pokemon/dex_entry_pointers.asm").matchAll(/^\s+dw (\w+)/gm),
].map(([, label]) => label);
expect(labels).toHaveLength(251);
labels.forEach((label, index) => {
const source = read(files.get(label)!);
const [, category] = /db "([^"]*)@"/.exec(source)!;
const [, height, weight] = /dw (\d+), (\d+)/.exec(source)!;
const pages: string[][] = [[]];
for (const [, command, text] of source.matchAll(/^\s+(db|next|page)\s+"([^"]*)"/gm)) {
if (text.endsWith("@") && command === "db" && !pages[0].length && text === `${category}@`)
continue;
if (command === "page") pages.push([]);
pages.at(-1)!.push(text.replace(/@$/, "").replaceAll("#", "POKé"));
}
expect(dex.entry(index + 1)).toEqual({
category,
height: Number(height),
weight: Number(weight),
pages: pages.map((lines) => lines.join("\n")),
});
});
});
it("matches each species' footprint", () => {
const tops = [
...read("gfx/footprints.asm").matchAll(/^INCBIN "([^"]+)",\s+footprint_top/gm),
].map(([, file]) => file);
for (let index = 0; index < 251; index++) {
const bytes = readFileSync(`${pret}/${tops[index]}`);
expect(dex.footprint(index + 1), tops[index]).toEqual(
new Uint8Array(bytes.subarray(0, 32)),
);
}
});
},
);
describe.skipIf(!existsSync(romPath))("the Pokédex screen", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
function fixture(seen: number[], caught: number[]) {
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => 0x42,
);
world.state.party = [
generatePartyMon(world.assets.pokemon, {
species: CYNDAQUIL,
level: 8,
item: 0,
otId: world.playerId,
ot: "GOLD",
dvs: 0x9abc,
caughtData: 0,
}),
];
world.restore({
...world.save(),
map: NEW_BARK_TOWN,
x: 13,
y: 7,
direction: "down",
objects: [],
});
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.pokedex.seen = new Set(seen);
world.state.pokedex.caught = new Set(caught);
return world;
}
function view(world: GameWorld): PokedexView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pokedex")
throw new Error(`Expected the Pokédex, got ${choice?.kind}`);
return choice.view;
}
function open(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
const index = choice.items.indexOf("POKéDEX");
expect(index).toBe(0);
world.choose(index);
return view(world);
}
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
if (button === "a") world.interact();
else if (button === "b") world.advanceDialogue();
else world.moveCursor(button);
}
const names = (dex: PokedexView) => dex.rows.map((row) => row.name);
it("lists the New mode order up to the last seen Pokémon", () => {
const world = fixture([CHIKORITA, CYNDAQUIL, QUILAVA, PIDGEY], [CYNDAQUIL]);
const dex = open(world);
expect(dex).toMatchObject({
screen: "list",
seen: 4,
own: 1,
cursor: 0,
scroll: 0,
labels: { seen: "SEEN", own: "OWN" },
picture: CHIKORITA,
});
// PIDGEY is tenth in New mode, so the list ends there.
expect(dex.end).toBe(10);
expect(names(dex)).toEqual([
"CHIKORITA",
"-----",
"-----",
"CYNDAQUIL",
"QUILAVA",
"-----",
"-----",
]);
expect(dex.rows.map((row) => row.caught)).toEqual([
false, false, false, true, false, false, false,
]);
// The cursor stops at the last seen Pokémon; an unseen one shows "?".
press(world, "down");
expect(view(world)).toMatchObject({ cursor: 1 });
expect(view(world).picture).toBeUndefined();
for (let i = 0; i < 12; i++) press(world, "down");
expect(view(world)).toMatchObject({ cursor: 6, scroll: 3, picture: PIDGEY });
// Left goes up a page, at most to the top; right goes back down.
press(world, "left");
expect(view(world)).toMatchObject({ cursor: 6, scroll: 0 });
press(world, "right");
expect(view(world)).toMatchObject({ cursor: 6, scroll: 3 });
});
it("ignores A on an unseen Pokémon and opens a seen one's entry", () => {
const world = fixture([CHIKORITA, CYNDAQUIL], [CYNDAQUIL]);
open(world);
press(world, "down");
press(world, "a");
expect(view(world).screen).toBe("list");
press(world, "down");
press(world, "down");
press(world, "a");
const dex = view(world);
expect(dex.screen).toBe("entry");
expect(dex.picture).toBe(CYNDAQUIL);
expect(dex.entry).toMatchObject({
species: CYNDAQUIL,
name: "CYNDAQUIL",
category: "FIRE MOUSE",
number: "155",
height: `HT 1'08"`,
weight: "WT 17.0lb",
caught: true,
page: 1,
actions: ["PAGE", "AREA", "CRY", "PRNT"],
action: 0,
});
expect(dex.entry!.text).toBe(world.assets.pokedex.entry(CYNDAQUIL).pages[0]);
});
it("hides measurements and text until the Pokémon is caught", () => {
const world = fixture([CHIKORITA], []);
open(world);
press(world, "a");
const entry = view(world).entry!;
expect(entry).toMatchObject({
name: "CHIKORITA",
category: "LEAF",
number: "152",
height: `HT ?'??"`,
weight: "WT ???lb",
caught: false,
});
expect(entry.text).toBeUndefined();
});
it("turns pages, moves the arrow and steps through seen entries", () => {
const world = fixture(
[CHIKORITA, CYNDAQUIL, TOTODILE, SENTRET],
[CHIKORITA, CYNDAQUIL, TOTODILE, SENTRET],
);
open(world);
press(world, "a");
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 2 });
expect(view(world).entry!.text).toBe(world.assets.pokedex.entry(CHIKORITA).pages[1]);
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 1 });
// The arrow stops at PRNT and at PAGE; AREA, CRY and PRNT do nothing.
for (let i = 0; i < 5; i++) press(world, "right");
expect(view(world).entry).toMatchObject({ action: 3 });
press(world, "a");
expect(view(world).entry).toMatchObject({ action: 3, page: 1 });
press(world, "left");
expect(view(world).entry).toMatchObject({ action: 2 });
press(world, "a");
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 1 });
// Down skips unseen Pokémon and starts the next entry on page 1 with
// the arrow on PAGE.
press(world, "down");
expect(view(world).entry).toMatchObject({ species: CYNDAQUIL, page: 1, action: 0 });
press(world, "down");
expect(view(world).entry).toMatchObject({ species: TOTODILE });
press(world, "down");
expect(view(world).entry).toMatchObject({ species: SENTRET });
// SENTRET is 19th in New mode.
expect(view(world)).toMatchObject({ cursor: 6, scroll: 12 });
// Past the last seen Pokémon nothing moves.
press(world, "down");
expect(view(world).entry).toMatchObject({ species: SENTRET });
press(world, "up");
expect(view(world).entry).toMatchObject({ species: TOTODILE });
// B returns to the list where the entries left the cursor.
press(world, "b");
expect(view(world)).toMatchObject({ screen: "list", cursor: 0, scroll: 6 });
});
it("leaves to the start menu and reopens on the last entry", () => {
const world = fixture(
[CHIKORITA, CYNDAQUIL, PIDGEY, HOOTHOOT],
[CYNDAQUIL],
);
open(world);
for (let i = 0; i < 9; i++) press(world, "down");
expect(view(world)).toMatchObject({ picture: PIDGEY });
press(world, "a");
press(world, "b");
press(world, "b");
expect(world.dialogue?.choice?.kind).toBe("menu");
// Pokedex_InitCursorPosition scrolls as far as the last page allows
// (HOOTHOOT is 15th), then moves the cursor to PIDGEY, 10th.
world.choose(0);
expect(view(world)).toMatchObject({ screen: "list", picture: PIDGEY, scroll: 8, cursor: 1 });
// The draft changes nothing, and closing the menu commits the cursor.
press(world, "b");
press(world, "b");
expect(world.dialogue).toBeUndefined();
expect(open(world)).toMatchObject({ picture: PIDGEY });
});
});