From c4cc0e756cf0b2e7ece17b458d70a747cf87f015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Fri, 2 Oct 2026 10:47:11 +0200 Subject: [PATCH] Port the options screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPTION in the start menu runs _Option with the ROM's labels and settings, OptionsControl's cursor wrapping and each option's bit handling. The four option bytes live in the script state and saves, defaulting to DefaultOptions. The battle style now decides whether a trainer's next Pokémon brings an offer to switch, and the menu account whether the start menu describes its items; the other settings are kept for later. Closes #24 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/ARCHITECTURE.md | 3 +- docs/NATIVE_PORT.md | 5 +- docs/PLAN.md | 15 +- docs/SESSION_HANDOFF.md | 37 +++++ scripts/check-reference.mjs | 4 +- src/game/battle/battle.ts | 7 +- src/game/rom/profile.ts | 22 +++ src/game/scripts/interpreter.ts | 12 ++ src/game/scripts/options.ts | 245 +++++++++++++++++++++++++++++++ src/game/scripts/start-menu.ts | 14 +- src/game/world/constants.ts | 17 +++ src/game/world/world.ts | 35 ++++- src/main.ts | 39 ++++- src/ui/shell.ts | 1 + src/ui/styles.css | 38 ++++- tests/battle-switch.test.ts | 12 ++ tests/foundation-browser.spec.ts | 43 ++++++ tests/options.test.ts | 200 +++++++++++++++++++++++++ 19 files changed, 732 insertions(+), 21 deletions(-) create mode 100644 src/game/scripts/options.ts create mode 100644 tests/options.test.ts diff --git a/README.md b/README.md index 3ecf0b6..d87a620 100644 --- a/README.md +++ b/README.md @@ -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. -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. +Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon) and the menu account (the start menu's descriptions); text speed, battle scene, sound, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map. 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, 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. +Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex screen, Fly, Dig, Teleport, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. ## Run diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ee4b55b..b15bfdb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,7 +87,8 @@ 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. `scripts/mom.ts` runs `BankOfMom` as a special; its amount box is a `mombank` effect answered with buttons in the same way, and so are the -pages of `scripts/trainer-card.ts`. The world refreshes the card's play +pages of `scripts/trainer-card.ts` and the screen of `scripts/options.ts`, +whose option bytes live in the script state. The world refreshes the card's play time, colon and badge step every tick; `world/game-time.ts` is the play time (`wGameTime`), which the world advances two frames per tick. diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 7185f25..58be543 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -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 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/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. | +| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. | | `game/scripts/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/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. | @@ -289,8 +290,8 @@ the original. engine flags and the party, and `StartMenu.Items` supplies each item's string and description. The start menu runs as `StartMenuRun`, a native generator on a copy of the script state, so nothing commits before it -closes and a save meanwhile keeps the previous state. POKéDEX and OPTION -are not ported and give a notice; POKéGEAR and the trainer card run inside +closes and a save meanwhile keeps the previous state. POKéDEX is not +ported and gives a notice; POKéGEAR, the trainer card and OPTION run inside the start menu run. The party menu prints `PartyMenuStrings` for the `PARTYMENUACTION_*` in use. diff --git a/docs/PLAN.md b/docs/PLAN.md index 3bcdaf9..318eb57 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 -- 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. +- 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. - 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. - 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". @@ -217,11 +217,17 @@ 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. - The labels drawn as tiles ("ID NO", "STATUS" and "BADGES") are presented as text; the other strings come from the ROM. +## 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`. +- The four bytes (`wOptions`, `wTextboxFrame`, `wGBPrinterBrightness`, `wOptions2`) are kept in the script state, so they change on the start menu's draft and commit when it closes. Save format 3 gained `options`, migrated by absence to `DefaultOptions`. +- In effect: the battle style (`CheckWhetherToAskSwitch`: SET makes no offer when a trainer sends out its next Pokémon) and the menu account (`StartMenu`'s descriptions). Not yet: text speed (native text appears at once), the battle scene (no move animations), sound (no audio), printing and the textbox frame. The catching tutorial's temporary medium text speed has nothing to change. + ## 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. -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. +This is still not a complete new game. The Pokégear's radio, the Pokédex screen, 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 @@ -299,7 +305,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. -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. +Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, and the options. Next: the Fly map and Teleport/Dig warps. 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. @@ -335,7 +341,8 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba - [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] Trainer card: both pages, with the ROM's card picture, leader faces and animated badges -- [ ] Pokédex and options screens +- [x] Options screen, with the battle style and menu account in effect +- [ ] Pokédex screen - [ ] Scalable text, configurable controls and fuller accessibility - [ ] Save slots with migration, atomic writes and import previews - [ ] Installable web app with offline app shell, safe updates and quota handling diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 89e6020..5d56c47 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -929,3 +929,40 @@ physical-device run. Next: the options screen (board #24), then the Fly map and Teleport/Dig warps. + +The trainer card increment was committed as `4769856` (`Closes #23`; the +hook moved the card to Fait). Not pushed. + +## Options: 2026-10-01 + +Continued in the same session with board card #24. + +- `src/game/scripts/options.ts` (`OptionsMenu`): OPTION in the start menu + runs `_Option` on `state.options` (the start menu's draft) and returns to + the start menu. `options` effect answered with buttons; Start/B leave, + A on CANCEL leaves; `OptionsControl` wrapping; each option routine ported + from its bits (text speed's `.Save` keeps only the high nybble, so bit 3 + is lost and bit 4 kept). Labels from `StringOptions` (read with a longer + `readString` limit), settings from the routines' strings. +- `GameOptions` = `wOptions`, `wTextboxFrame`, `wGBPrinterBrightness`, + `wOptions2`; `defaultOptions()` is `DefaultOptions`. In `ScriptState` and + saves (migrated by absence; frame above 7 refused). +- Consumers: `Battle` takes `shift` (from `BATTLE_SHIFT`) and + `CheckWhetherToAskSwitch` honours it; `StartMenu` drops descriptions + without `MENU_ACCOUNT`. Text speed, battle scene, sound, print and frame + are stored only. +- New `OPTIONS` profile table and OW option constants, verified by + `check:reference` (decimal literals: the checker skips `0b` values, which + a deliberately wrong `TEXT_DELAY_MED` confirmed). +- UI: `#options` panel, labels in a fixed column; checked at desktop and + 390px widths. + +Checks: 413 unit tests with the local ROM (134 passed, 279 skipped without +it; new `tests/options.test.ts` and a Set-mode test in +`tests/battle-switch.test.ts`); 119 Chromium/WebKit browser tests passed, +19 skipped intentionally (a new options flow in both browsers); native and +reference builds; reference, vendor and release checks; `git diff --check`. +No emulator comparison; no CI or physical-device run. + +Next: the Fly map and Teleport/Dig/Escape Rope warps (board #25), which +need script commands after a warp; then Headbutt and Rock Smash (#26). diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index 6802789..8f4310d 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -202,8 +202,8 @@ for (const [key, symbol] of Object.entries(ramNames)) { if (!match || Number(match[1]) !== ram.get(symbol)) throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`); } -// 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", "TRAINER_CARD"]) { +// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD and OPTIONS entries are keyed by their PRET labels. +for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "OPTIONS"]) { const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1]; 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)) { diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index 2aca71b..b575e52 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -182,6 +182,8 @@ export interface BattleOptions { /** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much * of a trainer's prize goes to Mom. */ mom?: { saving: number; money: number }; + /** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */ + shift?: boolean; } /** BATTLERESULT_BOX_FULL: a catch filled the current box. */ @@ -2954,11 +2956,12 @@ export class Battle { this.curOTMon = index; this.loadEnemyMon(); } - /** CheckWhetherToAskSwitch: Shift mode (the only battle style so far) - * asks when the player has another Pokémon and its own is still up. */ + /** CheckWhetherToAskSwitch: the SHIFT battle style asks when the player + * has another Pokémon and its own is still up. */ private checkWhetherToAskSwitch() { return ( !this.battleHasJustStarted && + this.options.shift !== false && this.options.party.length > 1 && this.partyMon.hp > 0 ); diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index df24197..8fd9a78 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -749,6 +749,28 @@ export const TRAINER_CARD = { PredefPals: 0x9df6, } as const; +/** _Option (engine/menus/options_menu.asm): its screen and setting + * strings, keyed by their PRET labels. */ +export const OPTIONS = { + StringOptions: 0xe4241, + "Options_TextSpeed.Fast": 0xe4337, + "Options_TextSpeed.Mid": 0xe433c, + "Options_TextSpeed.Slow": 0xe4341, + "Options_BattleScene.On": 0xe4398, + "Options_BattleScene.Off": 0xe439c, + "Options_BattleStyle.Shift": 0xe43d1, + "Options_BattleStyle.Set": 0xe43d7, + "Options_Sound.Mono": 0xe4416, + "Options_Sound.Stereo": 0xe441d, + "Options_Print.Lightest": 0xe4464, + "Options_Print.Lighter": 0xe446d, + "Options_Print.Normal": 0xe4476, + "Options_Print.Darker": 0xe447f, + "Options_Print.Darkest": 0xe4488, + "Options_MenuAccount.Off": 0xe44f2, + "Options_MenuAccount.On": 0xe44f6, +} as const; + export const MAP_GROUP_COUNT = 26; export const NUM_POKEMON = 251; export const NUM_TRAINER_CLASSES = 67; diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 59076bc..d214d51 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -53,6 +53,11 @@ import { PcSession } from "./pc"; import { bankOfMom, type MomBankView } from "./mom"; import type { PokegearView } from "./pokegear"; import type { TrainerCardView } from "./trainer-card"; +import { + defaultOptions, + type GameOptions, + type OptionsView, +} from "./options"; import { defaultTextContext, readString, @@ -118,6 +123,8 @@ export interface ScriptState { /** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below) * and wRegisteredItem: the item Select uses; zeros when none. */ registeredItem: { which: number; item: number }; + /** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */ + options: GameOptions; } export const emptyScriptState = (): ScriptState => ({ decorations: emptyDecorations(), @@ -146,6 +153,7 @@ export const emptyScriptState = (): ScriptState => ({ flash: false, noWildEncounters: false, registeredItem: { which: 0, item: 0 }, + options: defaultOptions(), }); export const copyScriptState = (state: ScriptState): ScriptState => ({ decorations: { ...state.decorations }, @@ -177,6 +185,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({ flash: state.flash, noWildEncounters: state.noWildEncounters, registeredItem: { ...state.registeredItem }, + options: { ...state.options }, }); /** Object operands keep the script's numbering: 0 is the player, 2+ are map @@ -280,6 +289,9 @@ export type ScriptEffect = /** A trainer card page, answered with the button pressed; without a * view the card closes. */ | { kind: "trainercard"; view?: TrainerCardView } + /** The options screen, answered with the button pressed; without a + * view it closes. */ + | { kind: "options"; view?: OptionsView } /** BankOfMom's deposit or withdrawal box with the textbox's pages, * answered with the button pressed (MomBankButton). */ | { kind: "mombank"; pages: string[]; view: MomBankView } diff --git a/src/game/scripts/options.ts b/src/game/scripts/options.ts new file mode 100644 index 0000000..1667b5c --- /dev/null +++ b/src/game/scripts/options.ts @@ -0,0 +1,245 @@ +import type { RomReader } from "../rom/reader"; +import { OPTIONS } from "../rom/profile"; +import { OW } from "../world/constants"; +import type { ScriptEffect } from "./interpreter"; +import { readString, type TextContext } from "./text"; + +type Input = number | string | undefined; +type Gen = Generator; + +/** The option bytes as the original keeps them. */ +export interface GameOptions { + /** wOptions: the text delay in bits 0-2, then STEREO, BATTLE_SHIFT (set + * for SET) and BATTLE_SCENE (set for OFF). */ + options: number; + /** wTextboxFrame: FRAME_1 to FRAME_8. */ + textboxFrame: number; + /** wGBPrinterBrightness: a GBPRINTER_* value. */ + printerBrightness: number; + /** wOptions2: MENU_ACCOUNT. */ + options2: number; +} + +/** DefaultOptions: medium text, scene on, SHIFT, mono, frame 1, normal + * printing and the menu account on. */ +export const defaultOptions = (): GameOptions => ({ + options: OW.TEXT_DELAY_MED, + textboxFrame: OW.FRAME_1, + printerBrightness: OW.GBPRINTER_NORMAL, + options2: 1 << OW.MENU_ACCOUNT, +}); + +export function validOptions(value: unknown): value is GameOptions { + const options = value as GameOptions | undefined; + const byte = (value: unknown, max = 0xff) => + Number.isInteger(value) && Number(value) >= 0 && Number(value) <= max; + return ( + Boolean(options) && + typeof options === "object" && + byte(options!.options) && + byte(options!.textboxFrame, OW.NUM_FRAMES - 1) && + byte(options!.printerBrightness) && + byte(options!.options2) + ); +} + +/** CheckWhetherToAskSwitch's BATTLE_SHIFT test: SHIFT offers a switch. */ +export const shiftBattleStyle = (options: GameOptions) => + !(options.options & (1 << OW.BATTLE_SHIFT)); +/** StartMenu's MENU_ACCOUNT test: describe the highlighted item. */ +export const menuAccount = (options: GameOptions) => + Boolean(options.options2 & (1 << OW.MENU_ACCOUNT)); + +export const OPTIONS_BUTTONS: readonly string[] = [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right", +]; + +/** The options screen: each option's label and setting, and the ▶. */ +export interface OptionsView { + rows: { label: string; value: string }[]; + cursor: number; +} + +const OPT_TEXT_SPEED = 0, + OPT_BATTLE_SCENE = 1, + OPT_BATTLE_STYLE = 2, + OPT_SOUND = 3, + OPT_PRINT = 4, + OPT_MENU_ACCOUNT = 5, + OPT_FRAME = 6, + OPT_CANCEL = 7, + NUM_OPTIONS = 8; + +/** _Option (engine/menus/options_menu.asm). Start or B leaves; up and down + * move the cursor with OptionsControl's wrapping; left and right change + * the option under it; A on CANCEL leaves. It changes `options` in place, + * which is the start menu's draft. Each screen is an `options` effect + * answered with a button. */ +export class OptionsMenu { + /** wJumptableIndex: the option under the cursor. */ + private cursor = OPT_TEXT_SPEED; + + constructor( + private options: GameOptions, + private host: { reader: RomReader; text(): TextContext }, + ) {} + + *run(): Gen { + for (;;) { + const button = yield { kind: "options", view: this.view() }; + if (typeof button !== "string" || !OPTIONS_BUTTONS.includes(button)) + throw new Error("Invalid options button."); + if (button === "start" || button === "b") break; + if (button === "up" || button === "down") this.control(button); + else if (this.change(button)) break; + } + yield { kind: "options" }; + } + + private string(address: number, limit?: number) { + return readString(this.host.reader, address, this.host.text(), limit).trim(); + } + + /** OptionsControl. */ + private control(button: "up" | "down") { + if (button === "down") { + if (this.cursor === OPT_CANCEL) this.cursor = OPT_TEXT_SPEED; + // .CheckMenuAccount sets the index it already has. + else this.cursor++; + } else if (this.cursor === OPT_FRAME) this.cursor = OPT_MENU_ACCOUNT; + else this.cursor = (this.cursor || NUM_OPTIONS) - 1; + } + + /** GetTextSpeed: the OPT_TEXT_SPEED_* index and the delays left and + * right of it. */ + private textSpeed(): [number, number, number] { + const delay = this.options.options & OW.TEXT_DELAY_MASK; + if (delay === OW.TEXT_DELAY_SLOW) + return [2, OW.TEXT_DELAY_MED, OW.TEXT_DELAY_FAST]; + if (delay === OW.TEXT_DELAY_FAST) + return [0, OW.TEXT_DELAY_SLOW, OW.TEXT_DELAY_MED]; + return [1, OW.TEXT_DELAY_FAST, OW.TEXT_DELAY_SLOW]; + } + /** GetPrinterSetting, in the same way. */ + private printer(): [number, number, number] { + switch (this.options.printerBrightness) { + case OW.GBPRINTER_LIGHTEST: + return [0, OW.GBPRINTER_DARKEST, OW.GBPRINTER_LIGHTER]; + case OW.GBPRINTER_LIGHTER: + return [1, OW.GBPRINTER_LIGHTEST, OW.GBPRINTER_NORMAL]; + case OW.GBPRINTER_DARKER: + return [3, OW.GBPRINTER_NORMAL, OW.GBPRINTER_DARKEST]; + case OW.GBPRINTER_DARKEST: + return [4, OW.GBPRINTER_DARKER, OW.GBPRINTER_LIGHTEST]; + default: + return [2, OW.GBPRINTER_LIGHTER, OW.GBPRINTER_DARKER]; + } + } + private toggle(field: "options" | "options2", bit: number) { + this.options[field] ^= 1 << bit; + } + + /** The option's routine with left or right pressed; true leaves. */ + private change(button: string): boolean { + const left = button === "left", + right = button === "right"; + const o = this.options; + switch (this.cursor) { + case OPT_TEXT_SPEED: + if (left || right) { + const [, previous, next] = this.textSpeed(); + // .Save keeps the high nybble only. + o.options = (o.options & 0xf0) | (left ? previous : next); + } + return false; + // Each of these toggles with either direction. Sound restarts the + // map music, which has no audio yet. + case OPT_BATTLE_SCENE: + if (left || right) this.toggle("options", OW.BATTLE_SCENE); + return false; + case OPT_BATTLE_STYLE: + if (left || right) this.toggle("options", OW.BATTLE_SHIFT); + return false; + case OPT_SOUND: + if (left || right) this.toggle("options", OW.STEREO); + return false; + case OPT_PRINT: + if (left || right) { + const [, previous, next] = this.printer(); + o.printerBrightness = left ? previous : next; + } + return false; + case OPT_MENU_ACCOUNT: + if (left || right) this.toggle("options2", OW.MENU_ACCOUNT); + return false; + case OPT_FRAME: + if (left || right) + o.textboxFrame = + (o.textboxFrame + (right ? 1 : -1)) & (OW.NUM_FRAMES - 1); + return false; + default: + // Options_Cancel. + return button === "a"; + } + } + + private view(): OptionsView { + // StringOptions holds every label, beyond readString's usual limit. + const lines = this.string(OPTIONS.StringOptions, 256).split("\n"); + const o = this.options; + const bit = (bit: number) => Boolean(o.options & (1 << bit)); + const values = [ + this.string( + [ + OPTIONS["Options_TextSpeed.Fast"], + OPTIONS["Options_TextSpeed.Mid"], + OPTIONS["Options_TextSpeed.Slow"], + ][this.textSpeed()[0]], + ), + this.string( + bit(OW.BATTLE_SCENE) + ? OPTIONS["Options_BattleScene.Off"] + : OPTIONS["Options_BattleScene.On"], + ), + this.string( + bit(OW.BATTLE_SHIFT) + ? OPTIONS["Options_BattleStyle.Set"] + : OPTIONS["Options_BattleStyle.Shift"], + ), + this.string( + bit(OW.STEREO) + ? OPTIONS["Options_Sound.Stereo"] + : OPTIONS["Options_Sound.Mono"], + ), + this.string( + [ + OPTIONS["Options_Print.Lightest"], + OPTIONS["Options_Print.Lighter"], + OPTIONS["Options_Print.Normal"], + OPTIONS["Options_Print.Darker"], + OPTIONS["Options_Print.Darkest"], + ][this.printer()[0]], + ), + this.string( + menuAccount(o) + ? OPTIONS["Options_MenuAccount.On"] + : OPTIONS["Options_MenuAccount.Off"], + ), + // StringOptions' ":TYPE", then UpdateFrame's digit. + `${lines[13].trim().replace(/^:/, "")} ${o.textboxFrame + 1}`, + "", + ]; + return { + // Labels on even lines, the ":" lines between them. + rows: values.map((value, i) => ({ label: lines[i * 2].trim(), value })), + cursor: this.cursor, + }; + } +} diff --git a/src/game/scripts/start-menu.ts b/src/game/scripts/start-menu.ts index 213cdd1..69dc302 100644 --- a/src/game/scripts/start-menu.ts +++ b/src/game/scripts/start-menu.ts @@ -20,6 +20,7 @@ import { } from "./interpreter"; import { Pokegear } from "./pokegear"; import { TrainerCard } from "./trainer-card"; +import { OptionsMenu, menuAccount } from "./options"; import { readString, readText, type TextContext } from "./text"; type Input = number | string | undefined; @@ -278,8 +279,10 @@ export class StartMenuRun { const choice = yield* this.menu( [], items.map((item) => this.itemString(item, 1)), - // The menu account (on by default) describes the highlighted item. - items.map((item) => this.itemString(item, 2).replace("\n", " ")), + // The menu account (wOptions2) describes the highlighted item. + menuAccount(this.state.options) + ? items.map((item) => this.itemString(item, 2).replace("\n", " ")) + : undefined, Math.min(items.length - 1, Math.max(0, this.cursors.start - 1)), ); if (choice < 0) return; @@ -311,6 +314,13 @@ export class StartMenuRun { gameTime: () => this.host.gameTime(), }).run(); break; + case STARTMENUITEM_OPTION: + // StartMenu_Option returns to the start menu. + yield* new OptionsMenu(this.state.options, { + reader: this.reader, + text: () => this.text(), + }).run(); + break; case STARTMENUITEM_EXIT: return; case STARTMENUITEM_SAVE: diff --git a/src/game/world/constants.ts b/src/game/world/constants.ts index f97c60d..52c37fe 100644 --- a/src/game/world/constants.ts +++ b/src/game/world/constants.ts @@ -58,6 +58,23 @@ export const OW = { // wJohtoBadges (constants/engine_flags.asm) and the trainer card's // palettes (constants/scgb_constants.asm, constants/trainer_constants.asm). NUM_JOHTO_BADGES: 8, + // wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2 + // (constants/ram_constants.asm). + TEXT_DELAY_MASK: 7, + TEXT_DELAY_FAST: 1, + TEXT_DELAY_MED: 3, + TEXT_DELAY_SLOW: 5, + STEREO: 5, + BATTLE_SHIFT: 6, + BATTLE_SCENE: 7, + FRAME_1: 0, + NUM_FRAMES: 8, + GBPRINTER_LIGHTEST: 0x00, + GBPRINTER_LIGHTER: 0x20, + GBPRINTER_NORMAL: 0x40, + GBPRINTER_DARKER: 0x60, + GBPRINTER_DARKEST: 0x7f, + MENU_ACCOUNT: 0, PREDEFPAL_CGB_BADGE: 0x24, FALKNER: 1, WHITNEY: 2, diff --git a/src/game/world/world.ts b/src/game/world/world.ts index b8bddfd..6291246 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -26,6 +26,14 @@ import { refreshTrainerCard, type TrainerCardView, } from "../scripts/trainer-card"; +import { + OPTIONS_BUTTONS, + shiftBattleStyle, + validOptions, + defaultOptions, + type GameOptions, + type OptionsView, +} from "../scripts/options"; import { advanceGameTime, emptyGameTime, @@ -138,6 +146,8 @@ export interface NativeSave { noWildEncounters: boolean; /** wWhichRegisteredItem and wRegisteredItem, for Select. */ registeredItem: { which: number; item: number }; + /** The option bytes, saved with the game as sOptions is. */ + options: GameOptions; } /** A random byte from the platform's secure generator. */ @@ -259,6 +269,8 @@ export type Choice = | { kind: "mombank"; view: MomBankView; selected: 0 } /** A trainer card page, answered with the button pressed. */ | { kind: "trainercard"; view: TrainerCardView; selected: 0 } + /** The options screen, answered with the button pressed. */ + | { kind: "options"; view: OptionsView; selected: 0 } /** The mart's 1-99 quantity stepper; `selected` is the current amount. */ | { kind: "martquantity"; @@ -275,7 +287,8 @@ export type Choice = const buttonChoice = (choice: Choice) => choice.kind === "pokegear" || choice.kind === "mombank" || - choice.kind === "trainercard"; + choice.kind === "trainercard" || + choice.kind === "options"; export interface Dialogue { pages: string[]; index: number; @@ -2178,6 +2191,7 @@ export class GameWorld { saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)), money: draft.momsMoney, }, + shift: shiftBattleStyle(draft.options), }); this.battle = { engine, selected: 1 }; this.battleRun = engine.run(); @@ -2237,6 +2251,14 @@ export class GameWorld { choice: { kind: "trainercard", view: effect.view, selected: 0 }, }; return true; + case "options": + if (!effect.view) return false; + this.dialogue = { + pages: [""], + index: 0, + choice: { kind: "options", view: effect.view, selected: 0 }, + }; + return true; case "mombank": this.dialogue = { pages: effect.pages.length ? effect.pages : [""], @@ -3754,7 +3776,9 @@ export class GameWorld { (dialogue.choice.kind === "mombank" && !MOM_BANK_BUTTONS.includes(answer as never)) || (dialogue.choice.kind === "trainercard" && - !TRAINER_CARD_BUTTONS.includes(answer as never)) + !TRAINER_CARD_BUTTONS.includes(answer as never)) || + (dialogue.choice.kind === "options" && + !OPTIONS_BUTTONS.includes(answer as never)) ) return; this.townMap = false; @@ -3827,6 +3851,7 @@ export class GameWorld { flash: state.flash, noWildEncounters: state.noWildEncounters, registeredItem: { ...state.registeredItem }, + options: { ...state.options }, }; } restore(value: unknown) { @@ -3929,7 +3954,8 @@ export class GameWorld { typeof save.noWildEncounters !== "boolean" || !save.registeredItem || !integer(save.registeredItem.which, 0, 255) || - !integer(save.registeredItem.item, 0, 255) + !integer(save.registeredItem.item, 0, 255) || + !validOptions(save.options) ) { throw new Error( "Invalid native save. Emulator snapshots cannot be imported into this port.", @@ -3995,6 +4021,7 @@ export class GameWorld { flash: save.flash, noWildEncounters: save.noWildEncounters, registeredItem: { ...save.registeredItem }, + options: { ...save.options }, specialPhoneCall: save.specialPhoneCall, rivalName: save.rivalName, }; @@ -4105,6 +4132,7 @@ export function migrateSave( }), ...(!Object.hasOwn(save, "intro") && { intro: false }), ...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }), + ...(!Object.hasOwn(save, "options") && { options: defaultOptions() }), // Clocks saved before InitClock was ported follow the device time. ...(save.clock !== null && typeof save.clock === "object" && @@ -4151,5 +4179,6 @@ export function migrateSave( registeredItem: { which: 0, item: 0 }, intro: false, gameTime: emptyGameTime(), + options: defaultOptions(), } as unknown as NativeSave; } diff --git a/src/main.ts b/src/main.ts index 52ca361..d678927 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,6 +54,7 @@ function showWorld(world: GameWorld) { showStatsScreen(world); showPokegear(world); showTrainerCard(world); + showOptions(world); get("town-map-view").hidden = !world.townMap; if (world.townMap) drawTownMap(get("town-map-canvas"), world); @@ -70,6 +71,7 @@ function showWorld(world: GameWorld) { // The Pokégear's map and radio cards have no textbox. (dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) || dialogue.choice?.kind === "trainercard" || + dialogue.choice?.kind === "options" || Boolean(mart) || world.townMap || Boolean(world.statsScreen); @@ -123,8 +125,13 @@ function showChoice(choice: Choice | undefined) { return element; }; if (!choice) container.replaceChildren(); - else if (choice.kind === "pokegear" || choice.kind === "trainercard") { - // The Pokégear and the trainer card read the controls directly. + else if ( + choice.kind === "pokegear" || + choice.kind === "trainercard" || + choice.kind === "options" + ) { + // The Pokégear, the trainer card and the options read the controls + // directly. container.replaceChildren(); container.hidden = true; } else if (choice.kind === "yesno") @@ -952,6 +959,34 @@ function showPokegear(world: GameWorld) { } else body.replaceChildren(); } +let optionsKey = ""; +/** _Option's screen: each option with its setting, and the ▶ cursor. */ +function showOptions(world: GameWorld) { + const choice = world.dialogue?.choice; + const view = choice?.kind === "options" ? choice.view : undefined; + get("options").hidden = !view; + if (!view) { + optionsKey = ""; + return; + } + const key = JSON.stringify(view); + if (key === optionsKey) return; + optionsKey = key; + get("options-list").replaceChildren( + ...view.rows.map((row, index) => { + const item = document.createElement("li"), + label = document.createElement("span"), + value = document.createElement("strong"); + label.textContent = row.label; + value.textContent = row.value; + item.append(label, value); + item.dataset.mark = index === view.cursor ? "▶" : " "; + if (index === view.cursor) item.setAttribute("aria-current", "true"); + return item; + }), + ); +} + 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 diff --git a/src/ui/shell.ts b/src/ui/shell.ts index ed65a2c..1d42bc3 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -34,6 +34,7 @@ export function renderShell(root: HTMLElement) { +