Port the trainer card and the play time

The player's name in the start menu opens TrainerCard: page 1 with the
name, ID, money, Pokédex count and play time, page 2 with the Johto
leaders and the badges owned, turned and closed with the original
buttons. The card picture, leader faces and animated badges come from the
ROM with _CGB_TrainerCard's palettes. GameTimer now counts the play time
from the end of the introduction, capped at 999:59:59, and saves keep it.

Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 13:32:14 +02:00
1 parent 38b9562bd3
commit 47698565ab
24 files changed
+954 -30

No files matched your search

+2 -2
View File
@@ -12,11 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls. Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.
Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. 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. 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. 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 and trainer card screens, Fly, Dig, Teleport, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex screen, the options, Fly, Dig, Teleport, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run ## Run
+4 -1
View File
@@ -86,7 +86,10 @@ is a `pokegear` effect answered with a button, and a phone call runs the
callee's ROM script in the event interpreter on the same draft. The world callee's ROM script in the event interpreter on the same draft. The world
keeps the card on screen (`world.pokegear`) during the call's texts. keeps the card on screen (`world.pokegear`) during the call's texts.
`scripts/mom.ts` runs `BankOfMom` as a special; its amount box is a `scripts/mom.ts` runs `BankOfMom` as a special; its amount box is a
`mombank` effect answered with buttons in the same way. `mombank` effect answered with buttons in the same way, and so are the
pages of `scripts/trainer-card.ts`. The world refreshes the card's play
time, colon and badge step every tick; `world/game-time.ts` is the play
time (`wGameTime`), which the world advances two frames per tick.
`TilesetAnimator` translates the ROM animation command tables, advancing two `TilesetAnimator` translates the ROM animation command tables, advancing two
video frames per overworld tick. It owns mutable graphics separately from the video frames per overworld tick. It owns mutable graphics separately from the
+4 -2
View File
@@ -34,6 +34,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. | | `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL and the other field items give a notice. | | `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 and the other field items give a notice. |
| `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/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. | | `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. | | `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
@@ -288,8 +289,9 @@ the original.
engine flags and the party, and `StartMenu.Items` supplies each item's engine flags and the party, and `StartMenu.Items` supplies each item's
string and description. The start menu runs as `StartMenuRun`, a native string and description. The start menu runs as `StartMenuRun`, a native
generator on a copy of the script state, so nothing commits before it generator on a copy of the script state, so nothing commits before it
closes and a save meanwhile keeps the previous state. POKéDEX, POKéGEAR, closes and a save meanwhile keeps the previous state. POKéDEX and OPTION
the trainer card and OPTION are not ported and give a notice. are not ported and give a notice; POKéGEAR and the trainer card run inside
the start menu run.
The party menu prints `PartyMenuStrings` for the `PARTYMENUACTION_*` in use. The party menu prints `PartyMenuStrings` for the `PARTYMENUACTION_*` in use.
`GetMonSubmenuItems` reads `MonMenuOptions`: field moves first, then STATS, `GetMonSubmenuItems` reads `MonMenuOptions`: field moves first, then STATS,
+12 -4
View File
@@ -164,7 +164,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, POKéGEAR, the trainer card and OPTION give a notice; 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 and OPTION give a notice (POKéGEAR and the trainer card 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".
@@ -210,11 +210,18 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Save format 3 gained `momItem` and `momItemTriggerBalance` (`wWhichMomItem`, `wMomItemTriggerBalance`), migrated by absence to 0 and ¥2300. - Save format 3 gained `momItem` and `momItemTriggerBalance` (`wWhichMomItem`, `wMomItemTriggerBalance`), migrated by absence to 0 and ¥2300.
- The amount box has no key repeat: like the other native menus, a held direction counts once. - The amount box has no key repeat: like the other native menus, a held direction counts once.
## Implemented: the trainer card and the play time
- The player's name in the start menu runs `TrainerCard` and returns to the start menu. Page 1 shows `.Name_Money`'s NAME and MONEY, the ID number with leading zeros, the Pokédex's caught count (`CountSetBits` over the caught species; the line is cleared without the Pokédex), the play time and "BADGES▶". A or right turns to page 2, the eight Johto leaders with the badges owned; there A leaves and left turns back. B leaves from either page. The Kanto page is reachable only through unreferenced code and is not shown.
- The card picture (`GetCardPic`: Chris or Kris), the leader faces (`TrainerCard_Page2_3_PlaceLeadersFaces`) and the badges come from the ROM, coloured by `_CGB_TrainerCard`'s palettes and attribute map: the frame in the other gender's palette, the picture in the player's, each leader's own and `PREDEFPAL_CGB_BADGE` for the badges. The badges step through `TrainerCard_JohtoBadgesOAM` every eight frames (the Rising Badge mirrors on alternate cycles), and the play time's colon blinks every 32 frames. Both timings start when the page loads; the original's phase also depends on the VBlank counter.
- `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.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items.
This is still not a complete new game. The Pokégear's radio, the Pokédex and trainer card screens, Mom's bank, story progression beyond Route 30 and Mr. Pokémon's house, 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 screen, the options, story progression beyond Route 30 and Mr. Pokémon's house, 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.
## 1. Complete the overworld foundation ## 1. Complete the overworld foundation
@@ -292,7 +299,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases. Next: the trainer card and options. Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, and the trainer card with the play time. Next: the options.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
@@ -327,7 +334,8 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box - [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box
- [x] Pokégear clock, map and phone cards (the radio card waits for its stations) - [x] Pokégear clock, map and phone cards (the radio card waits for its stations)
- [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
- [ ] Pokédex, trainer card and options screens - [x] Trainer card: both pages, with the ROM's card picture, leader faces and animated badges
- [ ] Pokédex and options screens
- [ ] 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
+43
View File
@@ -886,3 +886,46 @@ run. Not committed.
Next: the trainer card and options; later the Fly map and Teleport/Dig Next: the trainer card and options; later the Fly map and Teleport/Dig
warps. warps.
## Trainer card and play time: 2026-10-01
Resumed with "continue the plan" after `38b9562` (clean tree), taking the
recorded next item (the trainer card, board card #23). The Pokégear and
Mom's bank above were committed as `209778c`.
- `src/game/scripts/trainer-card.ts` (`TrainerCard`): the player's name in
the start menu runs the card and returns to the start menu. Each page is
a `trainercard` effect answered with a button (a `buttonChoice` like the
Pokégear): page 1 A/right → page 2; page 2 A leaves, left → page 1; B
leaves from both. The Kanto page (unreferenced code) is not shown. Labels
from `.Name_Money`, `.Dex_PlayTime` and `.Badges`; the Pokédex line only
with `ENGINE_POKEDEX`; badges from the Johto badge engine flags.
- `src/game/world/game-time.ts`: `wGameTime` and `GameTimer` (60 frames a
second, capped at 999:59:59.00). `GameWorld.gameTime` advances two
frames per tick unless the introduction is pending; `beginIntro` resets
it. `save()` during a scene returns the committed state with the current
play time. Saves gained `gameTime`, migrated by absence to zero.
- The world refreshes the card's time, blinking colon (every 32 frames on
page 1) and badge step (every 8 frames on page 2, only with badges) each
tick, counting from the page load.
- `src/game/rendering/trainer-card.ts`: the Chris/Kris card picture
(tile $1c left blank, as the corner covers it) and page 2's leader faces
with the owned badges' sprites, coloured by `_CGB_TrainerCard`'s
attribute map. UI: `#trainer-card` panel, frame colour from the other
gender's palette; checked visually at desktop and 390px widths.
- New `TRAINER_CARD` profile table, OW constants (`ENGINE_MINERALBADGE`,
`NUM_JOHTO_BADGES`, `PREDEFPAL_CGB_BADGE`, the leaders' trainer classes).
`check:reference` now understands the `trainerclass` macro.
- Tests that compare a save before and after a refused or rolled-back
action now ignore the play time (`withoutPlayTime` in
`tests/world-driver.ts`), which keeps counting.
Checks: 407 unit tests with the local ROM (134 passed, 273 skipped without
it; new `tests/trainer-card.test.ts`); 117 Chromium/WebKit browser tests
passed, 19 skipped intentionally (a new trainer card flow in both
browsers); native and reference builds; reference, vendor and release
checks; `git diff --check`. No emulator comparison of the card; no CI or
physical-device run.
Next: the options screen (board #24), then the Fly map and Teleport/Dig
warps.
+14 -6
View File
@@ -202,8 +202,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 and MOM entries are keyed by their PRET labels. // BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM and TRAINER_CARD entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM"]) { for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD"]) {
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)) {
@@ -265,8 +265,9 @@ for (const [name, file] of [
]) ])
if (!profile.includes(`${name} = ${constantCount(file, name)};`)) if (!profile.includes(`${name} = ${constantCount(file, name)};`))
throw new Error(`${name} does not match the reference.`); throw new Error(`${name} does not match the reference.`);
// PRET constants: const_def/const/const_skip/const_next/shift_const and // PRET constants: const_def/const/const_skip/const_next/shift_const,
// numeric or aliased DEF ... EQU, plus the battle command macro list. // trainerclass and numeric or aliased DEF ... EQU, plus the battle command
// macro list.
const pretConstants = new Map(); const pretConstants = new Map();
{ {
const number = (text) => const number = (text) =>
@@ -284,7 +285,8 @@ const pretConstants = new Map();
]; ];
for (const file of files) { for (const file of files) {
let value = 0, let value = 0,
step = 1; step = 1,
trainerClass = 0;
for (const raw of readFileSync(file, "utf8").split("\n")) { for (const raw of readFileSync(file, "utf8").split("\n")) {
const line = raw.split(";")[0].trim(); const line = raw.split(";")[0].trim();
let match; let match;
@@ -293,7 +295,13 @@ const pretConstants = new Map();
step = match[2] ? Number(match[2]) : 1; step = match[2] ? Number(match[2]) : 1;
} else if ((match = /^const_next\s+([$%]?[0-9a-fA-F]+)$/.exec(line))) value = number(match[1]); } else if ((match = /^const_next\s+([$%]?[0-9a-fA-F]+)$/.exec(line))) value = number(match[1]);
else if ((match = /^const_skip(?:\s+(\d+))?$/.exec(line))) value += step * Number(match[1] ?? 1); else if ((match = /^const_skip(?:\s+(\d+))?$/.exec(line))) value += step * Number(match[1] ?? 1);
else if ((match = /^(const|shift_const|command)\s+(\w+)$/.exec(line))) { else if ((match = /^DEF __trainer_class__ = (\d+)$/.exec(line))) trainerClass = Number(match[1]);
else if ((match = /^trainerclass\s+(\w+)$/.exec(line))) {
// DEF \1 EQU __trainer_class__, then const_def 1 for its trainers.
pretConstants.set(match[1], trainerClass++);
value = 1;
step = 1;
} else if ((match = /^(const|shift_const|command)\s+(\w+)$/.exec(line))) {
const stored = match[1] === "shift_const" ? 1 << value : value; const stored = match[1] === "shift_const" ? 1 << value : value;
pretConstants.set(match[2], stored); pretConstants.set(match[2], stored);
if (match[1] === "shift_const") pretConstants.set(`${match[2]}_F`, value); if (match[1] === "shift_const") pretConstants.set(`${match[2]}_F`, value);
+132
View File
@@ -0,0 +1,132 @@
import type { CrystalAssets } from "../rom/assets";
import { TRAINER_CARD } from "../rom/profile";
import { OW } from "../world/constants";
import { drawTile } from "./pixels";
const CARD_PIC_TILES = 35;
const LEADER_TILES = 86;
const BADGE_TILES = 44;
/** CardRightCornerGFX replaces the picture's tile $1c, which the card's
* border corner covers. */
const CARD_CORNER_TILE = 0x1c;
/** _CGB_TrainerCard's palettes 1-7 by trainer class; Clair uses palette 1. */
const LEADER_PALETTES = [
OW.FALKNER,
OW.BUGSY,
OW.WHITNEY,
OW.MORTY,
OW.CHUCK,
OW.JASMINE,
OW.PRYCE,
OW.FALKNER,
];
function context(canvas: HTMLCanvasElement, width: number, height: number) {
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d")!;
const image = context.createImageData(width, height);
image.data.fill(255);
return { context, image };
}
/** GetCardPic's Chris or Kris picture, placed by PlaceGraphic column by
* column, in the player's own palette. */
export function drawCardPic(
canvas: HTMLCanvasElement,
assets: CrystalAssets,
female: boolean,
) {
const { context: target, image } = context(canvas, 40, 56);
const tiles = assets.reader.slice(
female ? TRAINER_CARD.KrisCardPic : TRAINER_CARD.ChrisCardPic,
CARD_PIC_TILES * 16,
);
const palette = assets.pokemon.trainerPalette(female ? 1 : 0);
for (let tile = 0; tile < CARD_PIC_TILES; tile++)
if (tile !== CARD_CORNER_TILE)
drawTile(
image.data,
40,
56,
tiles,
tile,
Math.floor(tile / 7) * 8,
(tile % 7) * 8,
palette,
);
target.putImageData(image, 0, 0);
}
/** Page 2's leaders and badges: tiles 2-17 of rows 10-15, as
* TrainerCard_Page2_3_InitObjectsAndStrings places the faces, with the
* attribute map of _CGB_TrainerCard and the badge sprites of
* TrainerCard_Page2_3_OAMUpdate for the badges owned at `frame`. */
export function drawTrainerCardBadges(
canvas: HTMLCanvasElement,
assets: CrystalAssets,
female: boolean,
badges: number,
frame: number,
) {
const r = assets.reader;
const width = 128,
height = 48;
const { context: target, image } = context(canvas, width, height);
const leaders = r.slice(TRAINER_CARD.LeaderGFX, LEADER_TILES * 16);
// The card fills the screen with the other gender's palette.
const border = assets.pokemon.trainerPalette(female ? 0 : 1);
for (let leader = 0; leader < 8; leader++) {
const base = (leader < 4 ? 0x29 : 0x51) + (leader % 4) * 10 - 0x29;
const x = (leader % 4) * 32,
y = leader < 4 ? 0 : 24;
const face = assets.pokemon.trainerPalette(LEADER_PALETTES[leader]);
// TrainerCard_Page2_3_PlaceLeadersFaces: four tiles, then two rows
// of three one tile to the right.
for (let i = 0; i < 4; i++)
drawTile(image.data, width, height, leaders, base + i, x + i * 8, y, border);
for (let row = 1; row <= 2; row++)
for (let i = 0; i < 3; i++)
drawTile(
image.data,
width,
height,
leaders,
base + 1 + row * 3 + i,
x + 8 + i * 8,
y + row * 8,
face,
);
}
const graphics = r.slice(TRAINER_CARD.BadgeGFX, BADGE_TILES * 16);
const palette = assets.palette(
TRAINER_CARD.PredefPals + OW.PREDEFPAL_CGB_BADGE * 8,
);
// dw wJohtoBadges, then y, x, palette and eight tile steps per badge.
for (let badge = 0; badge < OW.NUM_JOHTO_BADGES; badge++) {
if (!(badges & (1 << badge))) continue;
const entry = TRAINER_CARD.TrainerCard_JohtoBadgesOAM + 2 + badge * 11;
// OAM coordinates, less the hardware offsets and this area's origin.
const y = r.byte(entry) - 16 - 10 * 8,
x = r.byte(entry + 1) - 8 - 2 * 8;
const id = r.byte(entry + 3 + frame);
const flip = (id & 0x80) !== 0,
tile = id & 0x7f;
// .facing1, or .facing2's mirrored order.
const order = flip ? [1, 0, 3, 2] : [0, 1, 2, 3];
order.forEach((offset, i) =>
drawTile(
image.data,
width,
height,
graphics,
tile + offset,
x + (i % 2) * 8,
y + Math.floor(i / 2) * 8,
palette,
{ transparent: true, flipX: flip },
),
);
}
target.putImageData(image, 0, 0);
}
+14
View File
@@ -735,6 +735,20 @@ export const MOM = {
"BankOfMom_MenuHeader.MenuData": 0x166bd, "BankOfMom_MenuHeader.MenuData": 0x166bd,
} as const; } as const;
/** TrainerCard (engine/menus/trainer_card.asm): its strings, the badges'
* OAM templates and the card graphics, keyed by their PRET labels. */
export const TRAINER_CARD = {
"TrainerCard_PrintTopHalfOfCard.Name_Money": 0x252ec,
"TrainerCard_Page1_PrintDexCaught_GameTime.Dex_PlayTime": 0x2534c,
"TrainerCard_Page1_PrintDexCaught_GameTime.Badges": 0x2535c,
TrainerCard_JohtoBadgesOAM: 0x254c9,
LeaderGFX: 0x25583,
BadgeGFX: 0x26043,
ChrisCardPic: 0x88365,
KrisCardPic: 0x88595,
PredefPals: 0x9df6,
} as const;
export const MAP_GROUP_COUNT = 26; export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251; export const NUM_POKEMON = 251;
export const NUM_TRAINER_CLASSES = 67; export const NUM_TRAINER_CLASSES = 67;
+4
View File
@@ -52,6 +52,7 @@ import { readMovement, type MovementStep } from "./movement";
import { PcSession } from "./pc"; import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom"; import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear"; import type { PokegearView } from "./pokegear";
import type { TrainerCardView } from "./trainer-card";
import { import {
defaultTextContext, defaultTextContext,
readString, readString,
@@ -276,6 +277,9 @@ export type ScriptEffect =
/** A Pokégear card with the textbox's pages, answered with the button /** A Pokégear card with the textbox's pages, answered with the button
* pressed (PokegearButton); without a view the Pokégear closes. */ * pressed (PokegearButton); without a view the Pokégear closes. */
| { kind: "pokegear"; pages: string[]; view?: PokegearView } | { kind: "pokegear"; pages: string[]; view?: PokegearView }
/** A trainer card page, answered with the button pressed; without a
* view the card closes. */
| { kind: "trainercard"; view?: TrainerCardView }
/** BankOfMom's deposit or withdrawal box with the textbox's pages, /** BankOfMom's deposit or withdrawal box with the textbox's pages,
* answered with the button pressed (MomBankButton). */ * answered with the button pressed (MomBankButton). */
| { kind: "mombank"; pages: string[]; view: MomBankView } | { kind: "mombank"; pages: string[]; view: MomBankView }
+13
View File
@@ -9,6 +9,7 @@ import {
} from "../battle/battle"; } from "../battle/battle";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag"; import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { calcStats, maxPP, type PartyMon } from "../party/pokemon"; import { calcStats, maxPP, type PartyMon } from "../party/pokemon";
import type { GameTime } from "../world/game-time";
import { PartyEvolution, evoStoneCompatible } from "./evolution"; import { PartyEvolution, evoStoneCompatible } from "./evolution";
import { import {
copyScriptState, copyScriptState,
@@ -18,6 +19,7 @@ import {
type ScriptState, type ScriptState,
} from "./interpreter"; } from "./interpreter";
import { Pokegear } from "./pokegear"; import { Pokegear } from "./pokegear";
import { TrainerCard } from "./trainer-card";
import { readString, readText, type TextContext } from "./text"; import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined; type Input = number | string | undefined;
@@ -115,6 +117,8 @@ export interface StartMenuHost {
scriptHost(): ScriptHost; scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */ /** GetWorldMapLocation for any map. */
landmarkOf(mapId: string): number; landmarkOf(mapId: string): number;
/** wGameTime, for the trainer card. */
gameTime(): GameTime;
} }
/** The overworld start menu (engine/menus/start_menu.asm) with its /** The overworld start menu (engine/menus/start_menu.asm) with its
@@ -298,6 +302,15 @@ export class StartMenuRun {
landmarkOf: (mapId) => this.host.landmarkOf(mapId), landmarkOf: (mapId) => this.host.landmarkOf(mapId),
}).run(); }).run();
break; break;
case STARTMENUITEM_STATUS:
// StartMenu_Status returns to the start menu.
yield* new TrainerCard(this.state, {
reader: this.reader,
text: () => this.text(),
player: () => this.host.scriptHost().player(),
gameTime: () => this.host.gameTime(),
}).run();
break;
case STARTMENUITEM_EXIT: case STARTMENUITEM_EXIT:
return; return;
case STARTMENUITEM_SAVE: case STARTMENUITEM_SAVE:
+146
View File
@@ -0,0 +1,146 @@
import type { RomReader } from "../rom/reader";
import { ENGINE_FLAGS, TRAINER_CARD } from "../rom/profile";
import { OW } from "../world/constants";
import type { GameTime } from "../world/game-time";
import type { ScriptEffect, ScriptState } from "./interpreter";
import { readString, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** The buttons the card reads from hJoyLast. */
export const TRAINER_CARD_BUTTONS: readonly string[] = [
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
/** What the trainer card shows. The original draws its own tilemap; here
* the text is presented natively and the pictures come from the ROM. */
export interface TrainerCardView {
/** TRAINERCARDSTATE_PAGE1 or PAGE2 (page 3 is unreachable). */
page: 1 | 2;
/** wPlayerGender: the card picture and the palettes follow it. */
female: boolean;
name: string;
/** wPlayerID with leading zeros, five digits. */
id: string;
money: number;
/** wPokedexCaught's set bits; absent without the Pokédex, whose line
* TrainerCard_Page1_PrintDexCaught_GameTime clears. */
pokedex?: number;
/** wGameTimeHours and wGameTimeMinutes, reprinted every frame. */
time: { hours: number; minutes: number };
/** The small colon between them, toggled every 32 frames. */
colon: boolean;
/** wJohtoBadges. */
badges: number;
/** wTrainerCardBadgeFrameCounter: the badges' animation step. */
badgeFrame: number;
/** .Name_Money, .Dex_PlayTime and .Badges from the ROM. */
labels: {
name: string;
money: string;
pokedex: string;
playTime: string;
badges: string;
};
}
/** What the card needs from the world. */
export interface TrainerCardHost {
reader: RomReader;
text(): TextContext;
player(): { id: number; gender: 0 | 1 };
gameTime(): GameTime;
}
/** The per-frame parts of the card, `frames` after its page loaded:
* TrainerCard_Page1_PrintGameTime's time and colon, and
* TrainerCard_Page2_3_AnimateBadges' step every eight frames. */
export function refreshTrainerCard(
view: TrainerCardView,
frames: number,
time: GameTime,
) {
view.time = { hours: time.hours, minutes: time.minutes };
view.colon = view.page === 1 && Math.floor(frames / 32) % 2 === 1;
// Without badges no sprite moves, so the step is not shown.
view.badgeFrame =
view.page === 2 && view.badges ? Math.floor(frames / 8) % 8 : 0;
}
/** TrainerCard (engine/menus/trainer_card.asm): page 1 shows the Pokédex
* count and the play time, page 2 the Johto leaders and badges. B leaves
* from either page; A or right turns to page 2, where A leaves and left
* turns back. The Kanto page is only reachable through unreferenced code. */
export class TrainerCard {
constructor(
private state: ScriptState,
private host: TrainerCardHost,
) {}
*run(): Gen {
let page: 1 | 2 = 1;
for (;;) {
const button = yield {
kind: "trainercard",
view: this.view(page),
};
if (typeof button !== "string" || !TRAINER_CARD_BUTTONS.includes(button))
throw new Error("Invalid trainer card button.");
if (button === "b") break;
if (page === 1 && (button === "a" || button === "right")) page = 2;
else if (page === 2 && button === "a") break;
else if (page === 2 && button === "left") page = 1;
}
yield { kind: "trainercard" };
}
private string(address: number) {
return readString(this.host.reader, address, this.host.text());
}
private view(page: 1 | 2): TrainerCardView {
const player = this.host.player();
const [name, , money] = this.string(
TRAINER_CARD["TrainerCard_PrintTopHalfOfCard.Name_Money"],
).split("\n");
const [pokedex, playTime] = this.string(
TRAINER_CARD["TrainerCard_Page1_PrintDexCaught_GameTime.Dex_PlayTime"],
).split("\n");
let badges = 0;
for (let bit = 0; bit < OW.NUM_JOHTO_BADGES; bit++)
if (this.state.flags.has(OW.ENGINE_ZEPHYRBADGE + bit)) badges |= 1 << bit;
const view: TrainerCardView = {
page,
female: player.gender === 1,
name: this.host.text().player,
id: String(player.id).padStart(5, "0"),
money: this.state.money,
...(this.state.flags.has(ENGINE_FLAGS.pokedex) && {
pokedex: this.state.pokedex.caught.size,
}),
time: { hours: 0, minutes: 0 },
colon: false,
badges,
badgeFrame: 0,
labels: {
name,
money,
pokedex,
playTime,
badges: this.string(
TRAINER_CARD["TrainerCard_Page1_PrintDexCaught_GameTime.Badges"],
).trim(),
},
};
refreshTrainerCard(view, 0, this.host.gameTime());
return view;
}
}
+12
View File
@@ -51,9 +51,21 @@ export const OW = {
ENGINE_HIVEBADGE: 28, ENGINE_HIVEBADGE: 28,
ENGINE_PLAINBADGE: 29, ENGINE_PLAINBADGE: 29,
ENGINE_FOGBADGE: 30, ENGINE_FOGBADGE: 30,
ENGINE_MINERALBADGE: 31,
ENGINE_STORMBADGE: 32, ENGINE_STORMBADGE: 32,
ENGINE_GLACIERBADGE: 33, ENGINE_GLACIERBADGE: 33,
ENGINE_RISINGBADGE: 34, ENGINE_RISINGBADGE: 34,
// wJohtoBadges (constants/engine_flags.asm) and the trainer card's
// palettes (constants/scgb_constants.asm, constants/trainer_constants.asm).
NUM_JOHTO_BADGES: 8,
PREDEFPAL_CGB_BADGE: 0x24,
FALKNER: 1,
WHITNEY: 2,
BUGSY: 3,
MORTY: 4,
PRYCE: 5,
JASMINE: 6,
CHUCK: 7,
// Map environments (constants/map_data_constants.asm). // Map environments (constants/map_data_constants.asm).
TOWN: 1, TOWN: 1,
ROUTE: 2, ROUTE: 2,
+51
View File
@@ -0,0 +1,51 @@
/** wGameTimeHours, wGameTimeMinutes, wGameTimeSeconds and wGameTimeFrames,
* with wGameTimeCap's GAME_TIME_CAPPED bit. */
export interface GameTime {
hours: number;
minutes: number;
seconds: number;
frames: number;
capped: boolean;
}
/** ResetGameTime. */
export const emptyGameTime = (): GameTime => ({
hours: 0,
minutes: 0,
seconds: 0,
frames: 0,
capped: false,
});
/** GameTimer.Function (home/game_time.asm): one frame, 60 to the second.
* Reaching 1000 hours caps the timer at 999:59:59.00 for good. */
export function advanceGameTime(time: GameTime) {
if (time.capped) return;
if (++time.frames < 60) return;
time.frames = 0;
if (++time.seconds < 60) return;
time.seconds = 0;
if (++time.minutes < 60) return;
time.minutes = 0;
if (time.hours + 1 >= 1000) {
time.capped = true;
time.minutes = time.seconds = 59;
return;
}
time.hours++;
}
export function validGameTime(value: unknown): value is GameTime {
const time = value as GameTime | undefined;
const integer = (value: unknown, max: number) =>
Number.isInteger(value) && Number(value) >= 0 && Number(value) <= max;
return (
Boolean(time) &&
typeof time === "object" &&
integer(time!.hours, 999) &&
integer(time!.minutes, 59) &&
integer(time!.seconds, 59) &&
integer(time!.frames, 59) &&
typeof time!.capped === "boolean"
);
}
+69 -3
View File
@@ -21,6 +21,17 @@ import {
type PokegearView, type PokegearView,
} from "../scripts/pokegear"; } from "../scripts/pokegear";
import { MOM_BANK_BUTTONS, type MomBankView } from "../scripts/mom"; import { MOM_BANK_BUTTONS, type MomBankView } from "../scripts/mom";
import {
TRAINER_CARD_BUTTONS,
refreshTrainerCard,
type TrainerCardView,
} from "../scripts/trainer-card";
import {
advanceGameTime,
emptyGameTime,
validGameTime,
type GameTime,
} from "./game-time";
import { import {
PLAYER, PLAYER,
ScriptRun, ScriptRun,
@@ -84,6 +95,8 @@ export interface NativeSave {
name: string; name: string;
avatar: "chris" | "kris"; avatar: "chris" | "kris";
tilesWalked: number; tilesWalked: number;
/** wGameTime: the play time the trainer card shows. */
gameTime: GameTime;
events: number[]; events: number[];
flags: number[]; flags: number[];
variables: Record<number, number>; variables: Record<number, number>;
@@ -244,6 +257,8 @@ export type Choice =
| { kind: "pokegear"; view: PokegearView; selected: 0 } | { kind: "pokegear"; view: PokegearView; selected: 0 }
/** BankOfMom's amount box, answered with the button pressed. */ /** BankOfMom's amount box, answered with the button pressed. */
| { kind: "mombank"; view: MomBankView; selected: 0 } | { kind: "mombank"; view: MomBankView; selected: 0 }
/** A trainer card page, answered with the button pressed. */
| { kind: "trainercard"; view: TrainerCardView; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */ /** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| { | {
kind: "martquantity"; kind: "martquantity";
@@ -258,7 +273,9 @@ export type Choice =
/** Choices that read the buttons themselves and are answered with the /** Choices that read the buttons themselves and are answered with the
* button's name. */ * button's name. */
const buttonChoice = (choice: Choice) => const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" || choice.kind === "mombank"; choice.kind === "pokegear" ||
choice.kind === "mombank" ||
choice.kind === "trainercard";
export interface Dialogue { export interface Dialogue {
pages: string[]; pages: string[];
index: number; index: number;
@@ -525,6 +542,9 @@ export class GameWorld {
name = "CHRIS"; name = "CHRIS";
avatar: "chris" | "kris" = "chris"; avatar: "chris" | "kris" = "chris";
tilesWalked = 0; tilesWalked = 0;
/** wGameTime. It counts from the end of the introduction, through
* scenes, menus and battles, as GameTimer does in every VBlank. */
gameTime: GameTime = emptyGameTime();
state: ScriptState = emptyScriptState(); state: ScriptState = emptyScriptState();
dialogue: Dialogue | undefined; dialogue: Dialogue | undefined;
emote: { object: number; emote: number } | undefined; emote: { object: number; emote: number } | undefined;
@@ -1888,6 +1908,7 @@ export class GameWorld {
this.menuFieldMove(state, mon, move), this.menuFieldMove(state, mon, move),
scriptHost: () => this.host(PLAYER), scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark, landmarkOf: (mapId) => this.assets.map(mapId).landmark,
gameTime: () => this.gameTime,
}, },
this.menuCursors, this.menuCursors,
select, select,
@@ -1916,6 +1937,8 @@ export class GameWorld {
beginIntro() { beginIntro() {
if (this.script) throw new Error("The introduction needs an idle world."); if (this.script) throw new Error("The introduction needs an idle world.");
this.introPending = true; this.introPending = true;
// A new game's ResetGameTime; the timer starts when it finishes.
this.gameTime = emptyGameTime();
this.script = { this.script = {
run: new IntroRun(this.state, { run: new IntroRun(this.state, {
reader: this.assets.reader, reader: this.assets.reader,
@@ -2199,6 +2222,21 @@ export class GameWorld {
choice: { kind: "pokegear", view: effect.view, selected: 0 }, choice: { kind: "pokegear", view: effect.view, selected: 0 },
}; };
return true; return true;
case "trainercard":
if (!effect.view) {
this.trainerCard = undefined;
return false;
}
// Each page starts its colon and badge timing when it loads.
if (this.trainerCard?.page !== effect.view.page) this.cardFrames = 0;
this.trainerCard = effect.view;
refreshTrainerCard(effect.view, this.cardFrames, this.gameTime);
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "trainercard", view: effect.view, selected: 0 },
};
return true;
case "mombank": case "mombank":
this.dialogue = { this.dialogue = {
pages: effect.pages.length ? effect.pages : [""], pages: effect.pages.length ? effect.pages : [""],
@@ -2625,6 +2663,7 @@ export class GameWorld {
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.pokegear = undefined; this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
@@ -2665,6 +2704,7 @@ export class GameWorld {
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.pokegear = undefined; this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
@@ -3107,10 +3147,18 @@ export class GameWorld {
/** The Pokégear card on screen while the Pokégear is open, including /** The Pokégear card on screen while the Pokégear is open, including
* during the phone calls it makes. */ * during the phone calls it makes. */
pokegear: PokegearView | undefined; pokegear: PokegearView | undefined;
/** The trainer card page on screen, and the frames since it loaded. */
trainerCard: TrainerCardView | undefined;
private cardFrames = 0;
ticks = 0; ticks = 0;
tick(held?: Direction) { tick(held?: Direction) {
this.ticks++; this.ticks++;
// GameTimer: two frames per overworld tick, once the game has started.
if (!this.introPending) {
advanceGameTime(this.gameTime);
advanceGameTime(this.gameTime);
}
// StepFunction_ScreenShake counts down once per tick of the map loop. // StepFunction_ScreenShake counts down once per tick of the map loop.
if (this.shake && !this.battle && --this.shake.ticks <= 0) if (this.shake && !this.battle && --this.shake.ticks <= 0)
this.shake = undefined; this.shake = undefined;
@@ -3138,6 +3186,11 @@ export class GameWorld {
minutes, minutes,
); );
} }
const card = this.trainerCard;
if (card) {
this.cardFrames += 2;
refreshTrainerCard(card, this.cardFrames, this.gameTime);
}
const evolution = this.evolution; const evolution = this.evolution;
if (evolution) { if (evolution) {
const frame = evolution.schedule[++evolution.step]; const frame = evolution.schedule[++evolution.step];
@@ -3699,7 +3752,9 @@ export class GameWorld {
(dialogue.choice.kind === "pokegear" && (dialogue.choice.kind === "pokegear" &&
!POKEGEAR_BUTTONS.includes(answer as never)) || !POKEGEAR_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "mombank" && (dialogue.choice.kind === "mombank" &&
!MOM_BANK_BUTTONS.includes(answer as never)) !MOM_BANK_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "trainercard" &&
!TRAINER_CARD_BUTTONS.includes(answer as never))
) )
return; return;
this.townMap = false; this.townMap = false;
@@ -3712,7 +3767,12 @@ export class GameWorld {
save(): NativeSave { save(): NativeSave {
// Incomplete interactions are replayed after reload; no partial quest writes. // Incomplete interactions are replayed after reload; no partial quest writes.
if (this.script) return structuredClone(this.script.committed); // The play time keeps counting through scenes, like GameTimer.
if (this.script)
return {
...structuredClone(this.script.committed),
gameTime: { ...this.gameTime },
};
const state = this.state; const state = this.state;
return { return {
format: "crystal-native", format: "crystal-native",
@@ -3726,6 +3786,7 @@ export class GameWorld {
name: this.name, name: this.name,
avatar: this.avatar, avatar: this.avatar,
tilesWalked: this.tilesWalked, tilesWalked: this.tilesWalked,
gameTime: { ...this.gameTime },
events: [...state.events].sort((a, b) => a - b), events: [...state.events].sort((a, b) => a - b),
flags: [...state.flags].sort((a, b) => a - b), flags: [...state.flags].sort((a, b) => a - b),
variables: { ...state.variables }, variables: { ...state.variables },
@@ -3802,6 +3863,7 @@ export class GameWorld {
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) || !/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!["chris", "kris"].includes(save.avatar) || !["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) || !integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!validGameTime(save.gameTime) ||
!ids(save.events) || !ids(save.events) ||
!ids(save.flags) || !ids(save.flags) ||
!new DecorationData(this.assets.reader, this.assets.pokemon).valid( !new DecorationData(this.assets.reader, this.assets.pokemon).valid(
@@ -3977,10 +4039,12 @@ export class GameWorld {
this.name = save.name; this.name = save.name;
this.avatar = save.avatar; this.avatar = save.avatar;
this.tilesWalked = save.tilesWalked; this.tilesWalked = save.tilesWalked;
this.gameTime = { ...save.gameTime };
this.playerId = save.playerId; this.playerId = save.playerId;
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.pokegear = undefined; this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
@@ -4040,6 +4104,7 @@ export function migrateSave(
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
}), }),
...(!Object.hasOwn(save, "intro") && { intro: false }), ...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
// Clocks saved before InitClock was ported follow the device time. // Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null && ...(save.clock !== null &&
typeof save.clock === "object" && typeof save.clock === "object" &&
@@ -4085,5 +4150,6 @@ export function migrateSave(
noWildEncounters: false, noWildEncounters: false,
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
intro: false, intro: false,
gameTime: emptyGameTime(),
} as unknown as NativeSave; } as unknown as NativeSave;
} }
+85 -2
View File
@@ -1,4 +1,8 @@
import { drawTownMap } from "./game/rendering/town-map"; import { drawTownMap } from "./game/rendering/town-map";
import {
drawCardPic,
drawTrainerCardBadges,
} from "./game/rendering/trainer-card";
import "./ui/styles.css"; import "./ui/styles.css";
import { renderShell } from "./ui/shell"; import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs"; import { bindTabs } from "./ui/tabs";
@@ -49,6 +53,7 @@ function showWorld(world: GameWorld) {
showBattle(world); showBattle(world);
showStatsScreen(world); showStatsScreen(world);
showPokegear(world); showPokegear(world);
showTrainerCard(world);
get("town-map-view").hidden = !world.townMap; get("town-map-view").hidden = !world.townMap;
if (world.townMap) if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world); drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -64,6 +69,7 @@ function showWorld(world: GameWorld) {
pictureWait || pictureWait ||
// 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" ||
Boolean(mart) || Boolean(mart) ||
world.townMap || world.townMap ||
Boolean(world.statsScreen); Boolean(world.statsScreen);
@@ -117,8 +123,8 @@ function showChoice(choice: Choice | undefined) {
return element; return element;
}; };
if (!choice) container.replaceChildren(); if (!choice) container.replaceChildren();
else if (choice.kind === "pokegear") { else if (choice.kind === "pokegear" || choice.kind === "trainercard") {
// The Pokégear reads the controls directly. // The Pokégear and the trainer card read the controls directly.
container.replaceChildren(); container.replaceChildren();
container.hidden = true; container.hidden = true;
} else if (choice.kind === "yesno") } else if (choice.kind === "yesno")
@@ -946,6 +952,83 @@ function showPokegear(world: GameWorld) {
} else body.replaceChildren(); } else body.replaceChildren();
} }
let trainerCardKey = "";
/** TrainerCard (engine/menus/trainer_card.asm): the top half with the
* name, ID, money and card picture, then page 1's Pokédex count and play
* time or page 2's leaders and badges. */
function showTrainerCard(world: GameWorld) {
const view = world.trainerCard;
const panel = get("trainer-card");
panel.hidden = !view;
if (!view) {
trainerCardKey = "";
return;
}
const key = JSON.stringify(view);
if (key === trainerCardKey) return;
const first = !trainerCardKey;
trainerCardKey = key;
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
return node;
};
const rows = (list: [string, string][]) => {
const dl = element("dl");
for (const [term, value] of list)
dl.append(element("dt", term), element("dd", value));
return dl;
};
if (first) {
// The frame in the other gender's palette, the picture in the player's.
const frame = world.assets.pokemon.trainerPalette(view.female ? 0 : 1)[1];
panel.style.setProperty("--trainer-card-frame", `rgb(${frame.join(" ")})`);
const pic = get<HTMLCanvasElement>("trainer-card-pic");
drawCardPic(pic, world.assets, view.female);
pic.setAttribute("aria-label", view.female ? "Kris" : "Chris");
}
const { labels } = view;
get("trainer-card-info").replaceChildren(
...rows([
[labels.name, view.name],
["ID NO", view.id],
[labels.money, `¥${view.money}`],
]).childNodes,
);
const page = get("trainer-card-page");
if (view.page === 1) {
const { hours, minutes } = view.time;
const time = `${String(hours).padStart(4, " ")}${view.colon ? ":" : " "}${String(minutes).padStart(2, "0")}`;
const list: [string, string][] = [];
if (view.pokedex !== undefined)
list.push([labels.pokedex, String(view.pokedex).padStart(3, " ")]);
list.push([labels.playTime, time]);
const status = rows(list);
status.setAttribute(
"aria-label",
`Play time ${hours} hours ${minutes} minutes`,
);
page.replaceChildren(element("h3", "STATUS"), status, element("p", labels.badges));
} else {
const canvas = element("canvas");
canvas.setAttribute("role", "img");
let owned = 0;
for (let bit = 0; bit < 8; bit++) if (view.badges & (1 << bit)) owned++;
canvas.setAttribute("aria-label", `${owned} of 8 Johto badges`);
drawTrainerCardBadges(
canvas,
world.assets,
view.female,
view.badges,
view.badgeFrame,
);
page.replaceChildren(element("h3", labels.badges.replace("▶", "")), canvas);
}
}
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its /** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
* strings, type names and stat labels read from the ROM. */ * strings, type names and stat labels read from the ROM. */
function showStatsScreen(world: GameWorld) { function showStatsScreen(world: GameWorld) {
+1
View File
@@ -33,6 +33,7 @@ export function renderShell(root: HTMLElement) {
</div> </div>
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div> <div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></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="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="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div> <div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart"> <div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p> <p id="mart-money" class="mart-money"></p>
+66 -1
View File
@@ -539,7 +539,8 @@ dialog h2 {
display: none; display: none;
} }
.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 {
display: none; display: none;
} }
.pokepic { .pokepic {
@@ -1261,3 +1262,67 @@ dialog dd {
padding: 4px 8px; padding: 4px 8px;
border: 3px double var(--ink); border: 3px double var(--ink);
} }
/* TrainerCard: the card's frame takes the other gender's palette. */
.trainer-card {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
padding: 10px;
overflow-y: auto;
color: var(--ink);
background: var(--trainer-card-frame, var(--blue-dark));
}
.trainer-card-top,
.trainer-card-page {
padding: 8px 12px;
background: #fff;
border: 5px double var(--ink);
font-variant-numeric: tabular-nums;
}
.trainer-card-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.trainer-card dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 12px;
margin: 0;
}
.trainer-card dt,
.trainer-card dd {
margin: 0;
white-space: pre;
}
.trainer-card-top canvas {
width: min(22vw, 100px);
aspect-ratio: 40 / 56;
image-rendering: pixelated;
}
.trainer-card-page {
display: grid;
align-content: start;
gap: 8px;
}
.trainer-card-page h3 {
margin: 0;
font-size: 0.85rem;
letter-spacing: 0.08em;
}
.trainer-card-page p {
margin: 0;
justify-self: end;
}
.trainer-card-page canvas {
width: 100%;
max-width: 512px;
justify-self: center;
aspect-ratio: 128 / 48;
image-rendering: pixelated;
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture"; import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
import type { GameWorld } from "../src/game/world/world"; import type { GameWorld } from "../src/game/world/world";
import { generatePartyMon, calcStats } from "../src/game/party/pokemon"; import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
import { withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = (scenario: BattleScenario = "win") => const fixture = (scenario: BattleScenario = "win") =>
@@ -161,7 +162,7 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
if (world.battle?.message?.prompt) world.interact(); if (world.battle?.message?.prompt) world.interact();
else world.tick(); else world.tick();
} }
expect(world.save()).toEqual(committed); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
world.restore(committed); world.restore(committed);
expect(world.battle).toBeUndefined(); expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(committed); expect(world.save()).toEqual(committed);
+34
View File
@@ -354,6 +354,40 @@ test("the Pokégear shows its cards and calls Elm", async ({ page }) => {
await expect(page.locator("#game-notice")).toHaveText(""); await expect(page.locator("#game-notice")).toHaveText("");
}); });
test("the trainer card turns to the badges and back", async ({ page }) => {
await start(page, "pokegear");
const choices = page.locator("#dialogue-choice"),
card = page.locator("#trainer-card"),
info = page.locator("#trainer-card-info"),
body = page.locator("#trainer-card-page");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^CHRIS/ }).click();
await expect(card).toBeVisible();
await expect(page.locator("#native-dialogue")).toBeHidden();
await expect(info).toContainText("NAME/");
await expect(info).toContainText("CHRIS");
await expect(info).toContainText("ID NO");
await expect(info).toContainText("MONEY");
await expect(page.locator("#trainer-card-pic")).toHaveAttribute(
"aria-label",
"Chris",
);
await expect(body).toContainText("PLAY TIME");
await expect(body).toContainText("BADGES▶");
await page.keyboard.press("ArrowRight");
await expect(body.locator("canvas")).toHaveAttribute(
"aria-label",
"0 of 8 Johto badges",
);
await page.keyboard.press("ArrowLeft");
await expect(body).toContainText("PLAY TIME");
await page.keyboard.press("KeyZ");
await expect(card).toBeHidden();
await choices.getByRole("option", { name: /^EXIT/ }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
});
test("Mom keeps the player's money with the digit box", async ({ page }) => { test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom"); await start(page, "mom");
const text = page.locator("#dialogue-text"), const text = page.locator("#dialogue-text"),
+4 -4
View File
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets"; import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants"; import { C } from "../src/game/battle/constants";
import { martFixtureWorld, talkToClerk } from "./mart-fixture"; import { martFixtureWorld, talkToClerk } from "./mart-fixture";
import { settle } from "./world-driver"; import { settle, withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = () => martFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath)))); const fixture = () => martFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))));
@@ -34,7 +34,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", ()
const shown = settle(world, [0, 0, 1, 1, -1, 2]); const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe(""); expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/enough money/i); expect(shown.join("\n")).toMatch(/enough money/i);
expect(world.save()).toEqual(committed); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
}); });
it("refuses to buy into a full pocket, changing nothing", () => { it("refuses to buy into a full pocket, changing nothing", () => {
@@ -55,7 +55,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", ()
const shown = settle(world, [0, 0, 1, 1, -1, 2]); const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe(""); expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/can't carry/i); expect(shown.join("\n")).toMatch(/can't carry/i);
expect(world.save()).toEqual(committed); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
}); });
it("sells items at half the ROM price", () => { it("sells items at half the ROM price", () => {
@@ -116,7 +116,7 @@ describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", ()
talkToClerk(world); talkToClerk(world);
settle(world, [2]); settle(world, [2]);
expect(world.notice, world.diagnostic).toBe(""); expect(world.notice, world.diagnostic).toBe("");
expect(world.save()).toEqual(before); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
}); });
it("a mid-interaction save restores the committed state and replays it", () => { it("a mid-interaction save restores the committed state and replays it", () => {
+2 -2
View File
@@ -15,7 +15,7 @@ import { generatePartyMon } from "../src/game/party/pokemon";
import type { Evolution } from "../src/game/rom/pokemon"; import type { Evolution } from "../src/game/rom/pokemon";
import { readMovement } from "../src/game/scripts/movement"; import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text"; import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver"; import { settle, walkTo, withoutPlayTime } from "./world-driver";
import { drawTile } from "../src/game/rendering/pixels"; import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate"; import { CRYSTAL_ROM } from "../src/rom/validate";
import { VECTORS, type Direction } from "../src/game/world/types"; import { VECTORS, type Direction } from "../src/game/world/types";
@@ -393,7 +393,7 @@ describe.skipIf(!existsSync(romPath))(
"ELM: Think it over\ncarefully.", "ELM: Think it over\ncarefully.",
"Your partner is\nimportant.", "Your partner is\nimportant.",
]); ]);
expect(world.save()).toEqual(before); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
}); });
it("jumps over representative Route 29 ledges from the actual collision data", () => { it("jumps over representative Route 29 ledges from the actual collision data", () => {
const assets = load(); const assets = load();
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { OW } from "../src/game/world/constants";
import {
advanceGameTime,
emptyGameTime,
type GameTime,
} from "../src/game/world/game-time";
import type { TrainerCardView } from "../src/game/scripts/trainer-card";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK_TOWN = "24:4",
CYNDAQUIL = 155;
describe("the game timer", () => {
const frames = (time: GameTime, count: number) => {
for (let i = 0; i < count; i++) advanceGameTime(time);
return time;
};
it("counts 60 frames to the second and carries into hours", () => {
expect(frames(emptyGameTime(), 59)).toEqual({ ...emptyGameTime(), frames: 59 });
expect(frames(emptyGameTime(), 60)).toEqual({ ...emptyGameTime(), seconds: 1 });
const time = { hours: 4, minutes: 59, seconds: 59, frames: 59, capped: false };
expect(frames(time, 1)).toEqual({ ...emptyGameTime(), hours: 5 });
});
it("caps at 999:59:59.00 when it would reach 1000 hours", () => {
const time = { hours: 999, minutes: 59, seconds: 59, frames: 58, capped: false };
frames(time, 1);
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 59, capped: false });
frames(time, 1);
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true });
frames(time, 5000);
expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true });
});
});
describe.skipIf(!existsSync(romPath))("the trainer card", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
function fixture(avatar: "chris" | "kris" = "chris") {
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: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
];
world.state.money = 3456;
world.restore({
...world.save(),
map: NEW_BARK_TOWN,
x: 13,
y: 7,
direction: "down",
objects: [],
name: avatar === "kris" ? "KRIS" : "GOLD",
avatar,
playerId: 417,
});
return world;
}
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.");
// StartMenu.StatusString is <PLAYER>.
const index = choice.items.indexOf(world.name);
expect(index).toBeGreaterThan(-1);
world.choose(index);
return view(world);
}
function view(world: GameWorld): TrainerCardView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "trainercard")
throw new Error(`Expected the trainer card, got ${choice?.kind}`);
expect(world.trainerCard).toBe(choice.view);
return choice.view;
}
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);
}
it("shows the name, ID, money and play time with ROM labels", () => {
const world = fixture();
world.gameTime = { hours: 12, minutes: 7, seconds: 3, frames: 0, capped: false };
const card = open(world);
expect(card).toMatchObject({
page: 1,
female: false,
name: "GOLD",
id: "00417",
money: 3456,
time: { hours: 12, minutes: 7 },
colon: false,
badges: 0,
labels: {
name: "NAME/",
money: "MONEY",
pokedex: "POKéDEX",
playTime: "PLAY TIME",
badges: "BADGES▶",
},
});
// Without the Pokédex its line is cleared.
expect(card.pokedex).toBeUndefined();
});
it("counts caught Pokémon once the Pokédex is owned", () => {
const world = fixture("kris");
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.pokedex.seen = new Set([1, 4, 7, 155]);
world.state.pokedex.caught = new Set([155, 7]);
const card = open(world);
expect(card.pokedex).toBe(2);
expect(card.female).toBe(true);
expect(card.name).toBe("KRIS");
});
it("turns pages and leaves with the original buttons", () => {
const world = fixture();
open(world);
// Left, up and down do nothing on page 1; A or right turns the page.
for (const button of ["left", "up", "down"] as const) {
press(world, button);
expect(view(world).page).toBe(1);
}
press(world, "right");
expect(view(world).page).toBe(2);
// Right does nothing on page 2; left turns back.
press(world, "right");
expect(view(world).page).toBe(2);
press(world, "left");
expect(view(world).page).toBe(1);
press(world, "a");
expect(view(world).page).toBe(2);
// A on page 2 leaves, back to the start menu.
press(world, "a");
expect(world.trainerCard).toBeUndefined();
expect(world.dialogue?.choice?.kind).toBe("menu");
// B leaves from page 1.
world.choose((world.dialogue!.choice as { items: string[] }).items.indexOf("GOLD"));
expect(view(world).page).toBe(1);
press(world, "b");
expect(world.trainerCard).toBeUndefined();
expect(world.dialogue?.choice?.kind).toBe("menu");
settle(world, [-1]);
expect(world.busy).toBe(false);
expect(world.diagnostic).toBe("");
});
it("keeps play time running, blinks the colon and animates owned badges", () => {
const world = fixture();
world.state.flags.add(OW.ENGINE_ZEPHYRBADGE);
world.state.flags.add(OW.ENGINE_MINERALBADGE);
world.gameTime = { hours: 0, minutes: 0, seconds: 59, frames: 40, capped: false };
const card = open(world);
expect(card.badges).toBe(0b10001);
// The colon appears after 32 frames (16 ticks) and goes after 64.
for (let tick = 0; tick < 15; tick++) world.tick();
expect(view(world).colon).toBe(false);
world.tick();
expect(view(world)).toMatchObject({ colon: true, time: { hours: 0, minutes: 1 } });
for (let tick = 0; tick < 16; tick++) world.tick();
expect(view(world).colon).toBe(false);
// Page 2 restarts the timing; the badges step every eight frames.
press(world, "right");
expect(view(world).badgeFrame).toBe(0);
const steps = [];
for (let tick = 0; tick < 36; tick++) {
world.tick();
steps.push(view(world).badgeFrame);
}
expect(steps.filter((_, i) => i % 4 === 3)).toEqual([1, 2, 3, 4, 5, 6, 7, 0, 1]);
expect(view(world).colon).toBe(false);
// Two frames per tick were counted throughout: 68 ticks from 0:59.40.
expect(world.gameTime).toMatchObject({ minutes: 1, seconds: 1, frames: 56 });
});
it("shows no badge animation without badges", () => {
const world = fixture();
open(world);
press(world, "right");
for (let tick = 0; tick < 20; tick++) world.tick();
expect(view(world)).toMatchObject({ badges: 0, badgeFrame: 0 });
});
it("does not count during the introduction", () => {
const world = fixture();
world.gameTime = { hours: 3, minutes: 0, seconds: 0, frames: 0, capped: false };
world.beginIntro();
expect(world.gameTime).toEqual(emptyGameTime());
for (let tick = 0; tick < 100; tick++) world.tick();
expect(world.gameTime).toEqual(emptyGameTime());
});
it("saves the play time, including time spent in a scene", () => {
const world = fixture();
world.gameTime = { hours: 7, minutes: 30, seconds: 0, frames: 0, capped: false };
open(world);
for (let tick = 0; tick < 30; tick++) world.tick();
const save = world.save();
expect(save.gameTime).toEqual({ hours: 7, minutes: 30, seconds: 1, frames: 0, capped: false });
const other = fixture();
other.restore(save);
expect(other.gameTime).toEqual(save.gameTime);
// Older saves start from zero; invalid times are refused.
const { gameTime: _, ...old } = save;
expect(migrateSave(old, () => 0)?.gameTime).toEqual(emptyGameTime());
expect(() =>
other.restore({ ...save, gameTime: { ...save.gameTime, minutes: 60 } }),
).toThrow(/Invalid native save/);
});
});
+3 -2
View File
@@ -6,6 +6,7 @@ import { OW } from "../src/game/world/constants";
import { trainerFixtureWorld } from "./trainer-fixture"; import { trainerFixtureWorld } from "./trainer-fixture";
import { migrateSave, type GameWorld } from "../src/game/world/world"; import { migrateSave, type GameWorld } from "../src/game/world/world";
import { DecorationData } from "../src/game/world/decorations"; import { DecorationData } from "../src/game/world/decorations";
import { withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
@@ -208,7 +209,7 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
world.interact(); world.interact();
finish(world); finish(world);
expect(world.notice).toContain("not been ported"); expect(world.notice).toContain("not been ported");
expect(world.save()).toEqual(before); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
}); });
it("restores a midbattle save without partially paying the reward", () => { it("restores a midbattle save without partially paying the reward", () => {
@@ -219,7 +220,7 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
if (world.battle?.message?.prompt) world.interact(); if (world.battle?.message?.prompt) world.interact();
else world.tick(); else world.tick();
} }
expect(world.save()).toEqual(before); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
world.restore(before); world.restore(before);
expect(world.battle).toBeUndefined(); expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(before); expect(world.save()).toEqual(before);
+7
View File
@@ -132,3 +132,10 @@ export function walkTo(
} }
throw new Error(`Could not reach ${targetX},${targetY}`); throw new Error(`Could not reach ${targetX},${targetY}`);
} }
/** A save without the play time, which keeps counting while refused or
* rolled-back actions leave everything else unchanged. */
export function withoutPlayTime<T extends { gameTime?: unknown }>(save: T) {
const { gameTime: _, ...rest } = save;
return rest;
}