Port forced movement (turn delays, bumps, ice, currents, pits), dynamic return warps, elevators, Surf, Cut, Strength and cycling rules, working map blocks with changeblock/changemapblocks, and the remaining NPC movement types and movement commands. Add tileset animation from the ROM's command tables, grass sprite priority and special map palettes; room decorations with ownership, the bedroom PC and save migration; and Key Items access to the Bicycle. Fourteen original-engine traces compare positions, facing, actors, event flags and scripted poses. Tile animation matches original VRAM for four tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now keeps the $60 sprite offset its step function leaves behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5526
-416
No files matched your search
@@ -12,7 +12,9 @@ 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.
|
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.
|
||||||
|
|
||||||
Route 31 and beyond, the PC, party and bag menus, other items in battle, decorations and audio are still being ported. Catching with six Pokémon in your party needs the PC and is refused for now. Unported neighboring areas appear at the edges but cannot be entered yet.
|
Use Start or Select for Key Items, including the Bicycle when owned. The bedroom PC lets you arrange owned decorations, and the poster opens the Town Map.
|
||||||
|
|
||||||
|
Route 31 and beyond, PC storage, full party and bag menus, other items in battle and audio are still being ported. Catching with six Pokémon in your party needs the PC and is refused for now. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -34,7 +34,7 @@ flowchart LR
|
|||||||
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
|
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
|
||||||
| `reference/public` | Licensed emulator files for the reference build only |
|
| `reference/public` | Licensed emulator files for the reference build only |
|
||||||
|
|
||||||
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets and money. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
|
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets, money and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
|
||||||
|
|
||||||
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
|
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
|
||||||
|
|
||||||
@@ -49,3 +49,24 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO
|
|||||||
The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling.
|
The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling.
|
||||||
|
|
||||||
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Battles receive the script's draft party, bag and Pokédex, so a catch, a thrown ball or a nickname commits only with the script that started the battle. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
|
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Battles receive the script's draft party, bag and Pokédex, so a catch, a thrown ball or a nickname commits only with the script that started the battle. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
|
||||||
|
|
||||||
|
|
||||||
|
The overworld extension keeps player travel mode, backup-warp data and Flash
|
||||||
|
state in version-3 saves, with defaults for older saves. Forced terrain and
|
||||||
|
turn-in-place timing run through the idle movement path. Dynamic warps and
|
||||||
|
elevators retain their return destination. Block mutations use a per-map working
|
||||||
|
copy and a revision counter, so rollback and the minimap see the same terrain.
|
||||||
|
Surf, Cut and Strength use the ROM scripts. Start/Select opens the Key Items
|
||||||
|
menu, pausing overworld ticks while choosing the Bicycle. The bedroom PC edits
|
||||||
|
decorations on the script draft and reloads the room when the menu commits.
|
||||||
|
The wall/poster Town Map is a ROM-backed canvas presented by a script effect.
|
||||||
|
|
||||||
|
`TilesetAnimator` translates the ROM animation command tables, advancing two
|
||||||
|
video frames per overworld tick. It owns mutable graphics separately from the
|
||||||
|
asset cache. The renderer records background color indices for grass sprite
|
||||||
|
priority and applies special tileset palettes. Animation comparisons cover
|
||||||
|
Johto, Ice Path, restless Ilex Forest and Tin Tower. The live forest engine
|
||||||
|
flag selects the Celebi animation; the later story remains gated. Palette
|
||||||
|
comparisons execute the original LoadMapPals routine across 192 cases.
|
||||||
|
Movement traces compare event flags and check destinations across map-entry
|
||||||
|
boundaries; controlled scenes also compare scripted pose visibility and lift.
|
||||||
+5
-5
@@ -25,12 +25,12 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
|||||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. |
|
| `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. |
|
||||||
| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only maps explicitly listed in NATIVE_MAPS can be entered. Location names, scene-variable owners and connection offsets come from the ROM. |
|
| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only maps explicitly listed in NATIVE_MAPS can be entered. Location names, scene-variable owners and connection offsets come from the ROM. |
|
||||||
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Terrain animation and other special palettes pending. |
|
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Native tileset animation and special palettes; Johto, Ice Path, restless Ilex Forest and Tin Tower each match 96 reference VRAM frames. 192 palette cases match the original routine. |
|
||||||
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Dynamic Pokémon icons and room decorations pending. |
|
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Variable sprites and Pokémon icons decoded; grass priority supported. Owned room decorations support selection, removal, slot placement, descriptions and validated saves. |
|
||||||
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Surfing remains absent; turn timing still needs comparison. |
|
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Forced movement, Surf, Cut, Strength, cycling rules, pits and elevators implemented. Fourteen reference traces compare turn timing, actors and event flags; map-entry boundaries verify the completed destination. Bicycle access uses the Key Items menu. |
|
||||||
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
|
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
|
||||||
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` moves the follower into the leader's previous square. |
|
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. |
|
||||||
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), the empty-room decoration toggles, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, the decoration PC and Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
||||||
| `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, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes. |
|
| `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, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes. |
|
||||||
| `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`. PC boxes, 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`. PC boxes, TMs and HMs are refused. |
|
||||||
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
||||||
|
|||||||
+43
-12
@@ -16,7 +16,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
|||||||
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
|
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
|
||||||
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
|
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
|
||||||
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
||||||
| 1. Complete the overworld foundation | In progress |
|
| 1. Complete the overworld foundation | Done |
|
||||||
| 2. Opening story and first battle slice | Mostly done |
|
| 2. Opening story and first battle slice | Mostly done |
|
||||||
| 3. Complete Crystal | Early |
|
| 3. Complete Crystal | Early |
|
||||||
| 4. Modern mobile presentation and delivery | First increment done |
|
| 4. Modern mobile presentation and delivery | First increment done |
|
||||||
@@ -124,7 +124,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
|
|
||||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage and type effectiveness. 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, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching, items other than balls in battle and the PC remain unfinished.
|
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 and type effectiveness. 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, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching, items other than balls in battle and the PC remain unfinished.
|
||||||
|
|
||||||
This is still not a complete new game. Party/bag/Pokégear menus, the PC, story progression beyond Route 30 and Mr. Pokémon's house, decorations, animated terrain, surfing and audio remain unfinished. 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. Full party/bag/Pokégear menus, PC storage, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the bedroom PC supports 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
|
||||||
|
|
||||||
@@ -134,19 +134,50 @@ This is still not a complete new game. Party/bag/Pokégear menus, the PC, story
|
|||||||
- [x] NPC random walks and spins, trainer sight lines
|
- [x] NPC random walks and spins, trainer sight lines
|
||||||
- [x] Item balls, hidden items and fruit trees with the daily reset
|
- [x] Item balls, hidden items and fruit trees with the daily reset
|
||||||
- [x] Connected transitions for every enabled map (New Bark Town to Route 30)
|
- [x] Connected transitions for every enabled map (New Bark Town to Route 30)
|
||||||
- [ ] Dynamic warps, elevators, ice, currents and the remaining forced movement
|
- [x] Dynamic warps, elevators, ice, currents and forced movement
|
||||||
- [ ] Surfing, cycling, Cut trees and Strength boulders
|
- [x] Native Surf, Cut and Strength interactions, cycling rules and boulder holes
|
||||||
- [ ] Decorations and tile changes (`changeblock`)
|
- [x] Bicycle access from the Key Items menu with keyboard and touch controls
|
||||||
- [ ] Remaining NPC movement types (followers, bouncing) and movement commands
|
- [x] Tile changes (`changeblock`, `changemapblocks`) with rollback and minimap refresh
|
||||||
- [ ] Terrain animation, layering and special palettes
|
- [x] Room decorations, ownership, selection, removal, save validation and migration
|
||||||
- [ ] Reference comparison of player turn timing
|
- [x] Additional NPC movement types, variable sprites, Pokémon icons and movement-command decoding
|
||||||
- [ ] Acceptance: outdoor, interior, cave and connected-map traces match the reference engine
|
- [x] Expanded original-engine coverage for scripted NPC poses and movement
|
||||||
|
- [x] Tileset animation, grass sprite priority and special map palettes
|
||||||
|
- [x] Restless forest animation and broader palette/animation reference comparisons
|
||||||
|
- [x] Reference comparison of player turn timing
|
||||||
|
- [x] Acceptance: outdoor, interior, cave and connected-map traces match the reference engine
|
||||||
|
|
||||||
Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison.
|
Movement now covers turn-in-place delays, bumps, ice, currents, forced tiles,
|
||||||
|
dynamic return warps, elevator menus, pits, Surf, Cut, Strength and cycling
|
||||||
|
rules. Map block changes use a working copy and roll back with a refused script.
|
||||||
|
Local-ROM tests exercise elevator travel, Burned Tower pits, Ice Path boulders,
|
||||||
|
field moves and cave/water encounters without enabling those later areas.
|
||||||
|
|
||||||
Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders) and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
|
Fourteen original-engine traces cover outdoor turns and bumps, connections in
|
||||||
|
both directions, ledges, house stairs and doors, the teacher's follow scene,
|
||||||
|
Ice Path, and eight controlled movement sequences: spin, teleport out/in,
|
||||||
|
skyfall, skyfall departure, tree shake, fishing, flicker and spin-flicker. They compare positions, facing,
|
||||||
|
selected actors and event flags. Map-entry ticks compare the destination
|
||||||
|
against the original's completed transition; other ticks compare directly.
|
||||||
|
Pose checks compare direction, visibility and vertical offset during scripted
|
||||||
|
movement, excluding blocking-text boundaries and subsequent random NPC behavior.
|
||||||
|
|
||||||
Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded.
|
Tileset animation runs from ROM command tables on a private graphics copy.
|
||||||
|
Johto, Ice Path, restless Ilex Forest and Tin Tower animation each match 96
|
||||||
|
original VRAM frames. All 36 tilesets run for 2,048 frames without modifying
|
||||||
|
cached graphics. An executed original palette routine matches 192 native
|
||||||
|
cases. These checks establish sampled engine behavior, not exhaustive
|
||||||
|
pixel-perfect screenshots or full Celebi story support.
|
||||||
|
|
||||||
|
The bedroom PC selects and puts away owned decorations, including left/right
|
||||||
|
doll placement. Room blocks, sprites and descriptions reflect saved choices.
|
||||||
|
The Town Map poster opens the native ROM-backed map. Start/Select opens Key
|
||||||
|
Items; using the Bicycle follows the native outdoor/indoor rules. Browser
|
||||||
|
checks cover both controls and decoration flows in Chromium and WebKit.
|
||||||
|
Full item/Pokémon PC storage and the other bag pockets belong to later work.
|
||||||
|
|
||||||
|
The foundation milestone is complete within these acceptance boundaries.
|
||||||
|
Next: finish the opening-story milestone below. Later maps remain gated until
|
||||||
|
their scripts and story progression are supported.
|
||||||
|
|
||||||
## 2. Opening story and first battle slice
|
## 2. Opening story and first battle slice
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Recovered Claude session
|
# Recovered Claude session
|
||||||
|
|
||||||
|
Latest continuation: see **Overworld foundation completed: 2026-09-25** at
|
||||||
|
the end of this file. Earlier sections are historical snapshots.
|
||||||
|
|
||||||
Recovered on 2026-09-23 from the local Claude Code transcript
|
Recovered on 2026-09-23 from the local Claude Code transcript
|
||||||
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
|
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
|
||||||
The session stopped at its usage limit at 09:54 UTC, during research, without a final handoff.
|
The session stopped at its usage limit at 09:54 UTC, during research, without a final handoff.
|
||||||
@@ -330,3 +333,102 @@ Next: the PC and boxes (catching with a full party, `SendMonIntoBox`, Bill's
|
|||||||
call), then player switching and other items in battle, and party and bag
|
call), then player switching and other items in battle, and party and bag
|
||||||
menus. The Dude's back picture, ball animation and Pokédex page are not
|
menus. The Dude's back picture, ball animation and Pokédex page are not
|
||||||
presented.
|
presented.
|
||||||
|
|
||||||
|
|
||||||
|
## Overworld foundation recovery: 2026-09-25
|
||||||
|
|
||||||
|
Resumed Claude Code session `5d15e2b5-2020-4f49-83e7-eddee08c2315`, whose user
|
||||||
|
request was “Complete the overworld foundation.” Its last successful check
|
||||||
|
compared Johto and Ice Path animation with the original VRAM; the session then
|
||||||
|
hit its usage limit. The previous handoff ended at capture and was stale.
|
||||||
|
|
||||||
|
Preserved and validated Claude's uncommitted implementation:
|
||||||
|
|
||||||
|
- Turn delays, bumps, ice and currents, forced movement, dynamic return warps,
|
||||||
|
elevator menus, pits and arrival timing. Six original-engine traces cover
|
||||||
|
outdoor, connected, ledge, interior, scripted-follow and cave movement.
|
||||||
|
- Surf, Cut, Strength, Whirlpool/Waterfall interaction paths and cycling rules;
|
||||||
|
Strength boulders can fall into script-controlled holes. Later maps are
|
||||||
|
enabled only inside tests, not in the production map list.
|
||||||
|
- Working map blocks, `changeblock`, `changemapblocks`, rollback and minimap
|
||||||
|
invalidation; cave/water/Kanto encounter tables and `wildoff`.
|
||||||
|
- Additional NPC movement, variable sprites and Pokémon icons; all movement
|
||||||
|
command decoding and teleport/fishing/skyfall/shake/flicker presentation.
|
||||||
|
- Tileset animations on private graphics, grass sprite priority and special
|
||||||
|
map palettes. Johto and Ice Path match 96 original VRAM frames after matching
|
||||||
|
the reference animation state, including the scrolling scratch buffer.
|
||||||
|
|
||||||
|
Recovery changes:
|
||||||
|
|
||||||
|
- Removed `zz-grid.test.ts` and `zz-explore.spec.ts`, temporary diagnostics with
|
||||||
|
no assertions. The grid diagnostic also read a private ROM unconditionally.
|
||||||
|
- Added grass-priority/transparency and special-palette regressions, and ran
|
||||||
|
all 36 tilesets for 2,048 animation frames each while checking that cached
|
||||||
|
ROM graphics stay unchanged. `NUM_TILESETS` is verified by `check:reference`.
|
||||||
|
- Reconciled PLAN, ARCHITECTURE and NATIVE_PORT with the actual implementation
|
||||||
|
and its remaining gaps.
|
||||||
|
|
||||||
|
Validation: 271 unit tests passed with the local ROM. With an absent
|
||||||
|
`CRYSTAL_ROM_PATH`, 127 passed and 144 skipped. The full Chromium/WebKit run
|
||||||
|
passed 64 tests with 8 intentional WebKit skips; two of those passes were the
|
||||||
|
subsequently removed exploratory test, leaving 62 substantive browser checks.
|
||||||
|
The eight new original-engine comparisons run in Chromium only. Native and
|
||||||
|
reference builds, reference addresses/constants, release isolation and vendor
|
||||||
|
checks passed. No physical-device or CI run was performed. Changes remain
|
||||||
|
uncommitted; no push was made.
|
||||||
|
|
||||||
|
The whole foundation milestone is not complete. Continue with:
|
||||||
|
|
||||||
|
1. Room decorations: the two decoration specials still implement empty slots;
|
||||||
|
stored choices, ownership and the selection flow are absent.
|
||||||
|
2. Player bag-menu access to `GameWorld.useBicycle()`. Its rules are tested,
|
||||||
|
but it is not reachable through the current game controls.
|
||||||
|
3. Remaining reference acceptance: movement traces exclude the original's
|
||||||
|
map-entry tick and compare position/facing/selected actor positions, not
|
||||||
|
event flags. Most special poses and palettes lack executed-reference or
|
||||||
|
screenshot comparisons. The Celebi forest animation stays on frame one.
|
||||||
|
|
||||||
|
Do not clear those checklist items or enable later maps based on the existing
|
||||||
|
movement and rendering tests alone.
|
||||||
|
|
||||||
|
|
||||||
|
## Overworld foundation completed: 2026-09-25 (resumed from Codex)
|
||||||
|
|
||||||
|
After the recovery above, Codex continued with "Complete the foundation" and
|
||||||
|
stopped during its final validation, before updating this file. It had
|
||||||
|
already marked the milestone done in PLAN.md. Its work:
|
||||||
|
|
||||||
|
- Room decorations (`world/decorations.ts`): the initial bed and Town Map
|
||||||
|
poster, ownership through `DecorationAttributes` event flags, the bedroom
|
||||||
|
PC's selection and put-away flows (including left/right doll placement),
|
||||||
|
room blocks and variable sprites from saved choices, save validation and
|
||||||
|
migration of saves without decorations. The poster opens the native map.
|
||||||
|
- Start/Select opens Key Items; the Bicycle follows the native indoor and
|
||||||
|
outdoor rules, with keyboard and touch controls (`foundation-browser.spec.ts`).
|
||||||
|
- Movement traces now compare event flags as well as positions. Pose checks
|
||||||
|
compare direction, visibility and vertical offset during scripted
|
||||||
|
movement. An executed `LoadMapPals` matches 192 native palette cases.
|
||||||
|
Restless Ilex Forest and Tin Tower animation match original VRAM.
|
||||||
|
- Fixed fishing and flicker pose timing against the original routines.
|
||||||
|
|
||||||
|
Claude Code finished the increment. Codex had just added two pose traces.
|
||||||
|
Tree shake passed. Skyfall departure (`skyfall_top`) failed:
|
||||||
|
`StepFunction_SkyfallTop` leaves `OBJECT_SPRITE_Y_OFFSET` at $60 on the tick
|
||||||
|
that ends the command, and the offset persists through later steps. Actors
|
||||||
|
now keep a `yOffset`. Skyfall, teleports, the fishing bite and jumps clear it,
|
||||||
|
because those routines write the offset again. In the game, only the player
|
||||||
|
uses `skyfall_top` (Ruins of Alph chambers, Burned Tower), just before a warp.
|
||||||
|
The traces now number fourteen. PLAN and NATIVE_PORT were updated to match.
|
||||||
|
|
||||||
|
Checks: 276 unit tests with the local ROM (127 passed, 149 skipped without
|
||||||
|
it); 81 Chromium/WebKit browser tests passed and 19 were skipped intentionally
|
||||||
|
(the engine parity tests run in Chromium only); native and
|
||||||
|
reference builds; reference, vendor and release checks; `git diff --check`.
|
||||||
|
Not committed.
|
||||||
|
|
||||||
|
Next (PLAN section 2): the PC and boxes (a catch with a full party,
|
||||||
|
`SendMonIntoBox`, Bill's call), then the introduction's naming and clock
|
||||||
|
screens. Also open there: player switching and non-ball items in battle,
|
||||||
|
controlled-seed comparisons beyond damage and type effectiveness, and a
|
||||||
|
continuous opening-quest playthrough with save/reload. Later maps stay gated
|
||||||
|
until their scripts and story are supported.
|
||||||
@@ -186,6 +186,8 @@ const ramNames = {
|
|||||||
hiddenItemID: "wHiddenItemID",
|
hiddenItemID: "wHiddenItemID",
|
||||||
runningTrainerBattleScript: "wRunningTrainerBattleScript",
|
runningTrainerBattleScript: "wRunningTrainerBattleScript",
|
||||||
callerContact: "wCallerContact",
|
callerContact: "wCallerContact",
|
||||||
|
surfingPlayerState: "wSurfingPlayerState",
|
||||||
|
strengthSpecies: "wStrengthSpecies",
|
||||||
moneyTemp: "hMoneyTemp",
|
moneyTemp: "hMoneyTemp",
|
||||||
};
|
};
|
||||||
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
|
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
|
||||||
@@ -199,14 +201,14 @@ 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 entries are keyed by their PRET labels.
|
// BATTLE and OVERWORLD entries are keyed by their PRET labels.
|
||||||
{
|
for (const table of ["BATTLE", "OVERWORLD"]) {
|
||||||
const block = /export const BATTLE = \{([\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("Battle 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)) {
|
||||||
const label = quoted ?? bare;
|
const label = quoted ?? bare;
|
||||||
if (symbols.get(label) !== Number(literal))
|
if (symbols.get(label) !== Number(literal))
|
||||||
throw new Error(`Battle symbol mismatch: ${label}`);
|
throw new Error(`${table} symbol mismatch: ${label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!profile.includes(`NUM_TRAINER_CLASSES = ${(symbols.get("Trainers") - symbols.get("TrainerGroups")) / 2};`))
|
if (!profile.includes(`NUM_TRAINER_CLASSES = ${(symbols.get("Trainers") - symbols.get("TrainerGroups")) / 2};`))
|
||||||
@@ -294,6 +296,9 @@ const pretConstants = new Map();
|
|||||||
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);
|
||||||
value += step;
|
value += step;
|
||||||
|
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+const_value\s*-\s*(\w+)$/.exec(line))) {
|
||||||
|
const offset = /^\d+$/.test(match[2]) ? Number(match[2]) : pretConstants.get(match[2]);
|
||||||
|
if (offset !== undefined) pretConstants.set(match[1], value - offset);
|
||||||
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+([$%]?[0-9a-fA-F]+|\w+)$/.exec(line))) {
|
} else if ((match = /^DEF\s+(\w+)\s+EQU\s+([$%]?[0-9a-fA-F]+|\w+)$/.exec(line))) {
|
||||||
const target = match[2];
|
const target = match[2];
|
||||||
const resolved =
|
const resolved =
|
||||||
@@ -307,7 +312,9 @@ const pretConstants = new Map();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const battleConstants = readFileSync("src/game/battle/constants.ts", "utf8");
|
const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts"]
|
||||||
|
.map((file) => readFileSync(file, "utf8"))
|
||||||
|
.join("\n");
|
||||||
for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) {
|
for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) {
|
||||||
for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim)) {
|
for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim)) {
|
||||||
const expected = pretConstants.get(name);
|
const expected = pretConstants.get(name);
|
||||||
|
|||||||
@@ -262,6 +262,24 @@ export class BinjgbEmulator implements Emulator {
|
|||||||
this.canvas!.putImageData(this.frame!, 0, 0);
|
this.canvas!.putImageData(this.frame!, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Development-only: run a bounded number of CPU cycles, to align frame
|
||||||
|
* sampling with a scanline when comparing against native routines. */
|
||||||
|
advanceCycles(cycles: number) {
|
||||||
|
if (
|
||||||
|
!this.handle ||
|
||||||
|
this.raf ||
|
||||||
|
!Number.isInteger(cycles) ||
|
||||||
|
cycles < 1 ||
|
||||||
|
cycles > 70224
|
||||||
|
)
|
||||||
|
throw new Error("Pause the reference before bounded cycle stepping.");
|
||||||
|
const module = this.module!;
|
||||||
|
const target = module._emulator_get_ticks_f64(this.handle) + cycles;
|
||||||
|
for (let events = 0; events < 100; events++)
|
||||||
|
if (module._emulator_run_until_f64(this.handle, target) & 4) return;
|
||||||
|
throw new Error("Reference cycle budget exceeded.");
|
||||||
|
}
|
||||||
|
|
||||||
private fileData(
|
private fileData(
|
||||||
kind: "state" | "ext_ram",
|
kind: "state" | "ext_ram",
|
||||||
action: (pointer: number, bytes: Uint8Array) => void,
|
action: (pointer: number, bytes: Uint8Array) => void,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class LocalMapView {
|
|||||||
|
|
||||||
update(world: GameWorld) {
|
update(world: GameWorld) {
|
||||||
const map = world.map;
|
const map = world.map;
|
||||||
const key = `${map.id}:${world.timeOfDay}`;
|
const key = `${map.id}:${world.timeOfDay}:${world.blockRevision}`;
|
||||||
if (this.assets !== world.assets || this.mapKey !== key) {
|
if (this.assets !== world.assets || this.mapKey !== key) {
|
||||||
this.buildTerrain(world);
|
this.buildTerrain(world);
|
||||||
this.assets = world.assets;
|
this.assets = world.assets;
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ export function drawTile(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
palette: Palette,
|
palette: Palette,
|
||||||
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
|
options: {
|
||||||
|
transparent?: boolean;
|
||||||
|
flipX?: boolean;
|
||||||
|
flipY?: boolean;
|
||||||
|
/** Background color indices per pixel: a background tile records them;
|
||||||
|
* an OAM_PRIO sprite (`behind`) only draws over color 0. */
|
||||||
|
indices?: Uint8Array;
|
||||||
|
behind?: boolean;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const offset = tile * 16;
|
const offset = tile * 16;
|
||||||
if (offset < 0 || offset + 16 > graphics.length) return;
|
if (offset < 0 || offset + 16 > graphics.length) return;
|
||||||
@@ -25,6 +33,11 @@ export function drawTile(
|
|||||||
const px = x + col,
|
const px = x + col,
|
||||||
py = y + row;
|
py = y + row;
|
||||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||||
|
if (options.indices) {
|
||||||
|
if (options.behind) {
|
||||||
|
if (options.indices[py * width + px]) continue;
|
||||||
|
} else if (!options.transparent) options.indices[py * width + px] = index;
|
||||||
|
}
|
||||||
const destination = (py * width + px) * 4,
|
const destination = (py * width + px) * 4,
|
||||||
color = palette[index];
|
color = palette[index];
|
||||||
target[destination] = color[0];
|
target[destination] = color[0];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { OW } from "../world/constants";
|
||||||
import { CrystalAssets } from "../rom/assets";
|
import { CrystalAssets } from "../rom/assets";
|
||||||
import { GameWorld } from "../world/world";
|
import { GameWorld } from "../world/world";
|
||||||
import type { Direction } from "../world/types";
|
import type { Direction } from "../world/types";
|
||||||
import { drawTile } from "./pixels";
|
import { drawTile } from "./pixels";
|
||||||
|
import { TilesetAnimator } from "./tileset-animation";
|
||||||
import {
|
import {
|
||||||
BASE_VIEWPORT,
|
BASE_VIEWPORT,
|
||||||
renderViewportForBox,
|
renderViewportForBox,
|
||||||
@@ -14,6 +16,11 @@ export class WorldRenderer {
|
|||||||
private context: CanvasRenderingContext2D;
|
private context: CanvasRenderingContext2D;
|
||||||
private image: ImageData;
|
private image: ImageData;
|
||||||
private canvas: HTMLCanvasElement;
|
private canvas: HTMLCanvasElement;
|
||||||
|
/** Background color indices of the current frame, for OAM priority. */
|
||||||
|
private indices = new Uint8Array(0);
|
||||||
|
/** The current tileset's animation, advanced two video frames per tick. */
|
||||||
|
private animator?: TilesetAnimator;
|
||||||
|
private animated = { key: "", ticks: 0 };
|
||||||
constructor(
|
constructor(
|
||||||
private assets: CrystalAssets,
|
private assets: CrystalAssets,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -59,8 +66,19 @@ export class WorldRenderer {
|
|||||||
}
|
}
|
||||||
render(world: GameWorld) {
|
render(world: GameWorld) {
|
||||||
const map = world.map,
|
const map = world.map,
|
||||||
tileset = this.assets.tileset(map),
|
tileset = this.assets.tileset(map);
|
||||||
palettes = this.assets.palettes(map, world.timeOfDay);
|
const key = `${map.tileset}:${map.group}`;
|
||||||
|
if (this.animated.key !== key) {
|
||||||
|
this.animator = new TilesetAnimator(this.assets, map, tileset.graphics);
|
||||||
|
this.animated = { key, ticks: world.ticks };
|
||||||
|
}
|
||||||
|
const animator = this.animator!;
|
||||||
|
// _AnimateTileset runs in VBlank: two video frames per overworld tick.
|
||||||
|
animator.advance(Math.min(240, (world.ticks - this.animated.ticks) * 2), world.timeOfDay === 3, world.liveState.flags.has(OW.ENGINE_FOREST_IS_RESTLESS));
|
||||||
|
this.animated.ticks = world.ticks;
|
||||||
|
const palettes = animator.palettes(this.assets.palettes(map, world.timeOfDay));
|
||||||
|
if (this.indices.length !== this.width * this.height)
|
||||||
|
this.indices = new Uint8Array(this.width * this.height);
|
||||||
const poses = world.poses();
|
const poses = world.poses();
|
||||||
const player = poses.find((pose) => pose.id === 0) ?? {
|
const player = poses.find((pose) => pose.id === 0) ?? {
|
||||||
x: world.x * 16,
|
x: world.x * 16,
|
||||||
@@ -69,8 +87,9 @@ export class WorldRenderer {
|
|||||||
// Keep the camera player-centered as the original engine does. Beyond the
|
// Keep the camera player-centered as the original engine does. Beyond the
|
||||||
// edges, connected maps are drawn like the original connection strips;
|
// edges, connected maps are drawn like the original connection strips;
|
||||||
// elsewhere the map's own border block fills the view.
|
// elsewhere the map's own border block fills the view.
|
||||||
|
// An earthquake shakes the view vertically (StepFunction_ScreenShake).
|
||||||
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
|
const cameraX = Math.round(player.x - (this.width / 2 - 8)),
|
||||||
cameraY = Math.round(player.y - (this.height / 2 - 8));
|
cameraY = Math.round(player.y - (this.height / 2 - 8)) + world.screenShake;
|
||||||
this.image.data.fill(255);
|
this.image.data.fill(255);
|
||||||
for (
|
for (
|
||||||
let tileY = Math.floor(cameraY / 8);
|
let tileY = Math.floor(cameraY / 8);
|
||||||
@@ -97,11 +116,12 @@ export class WorldRenderer {
|
|||||||
this.image.data,
|
this.image.data,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
tileset.graphics,
|
animator.graphics,
|
||||||
tile,
|
tile,
|
||||||
tileX * 8 - cameraX,
|
tileX * 8 - cameraX,
|
||||||
tileY * 8 - cameraY,
|
tileY * 8 - cameraY,
|
||||||
palettes[attribute & 7],
|
palettes[attribute & 7],
|
||||||
|
{ indices: this.indices },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +156,8 @@ export class WorldRenderer {
|
|||||||
pose.frame,
|
pose.frame,
|
||||||
time,
|
time,
|
||||||
pose.palette,
|
pose.palette,
|
||||||
|
pose.facing,
|
||||||
|
pose.inGrass,
|
||||||
);
|
);
|
||||||
const emote =
|
const emote =
|
||||||
world.emote && poses.find((pose) => pose.id === world.emote!.object);
|
world.emote && poses.find((pose) => pose.id === world.emote!.object);
|
||||||
@@ -165,6 +187,8 @@ export class WorldRenderer {
|
|||||||
frame: number,
|
frame: number,
|
||||||
time: number,
|
time: number,
|
||||||
paletteIndex?: number,
|
paletteIndex?: number,
|
||||||
|
facing?: number,
|
||||||
|
inGrass = false,
|
||||||
) {
|
) {
|
||||||
const sprite = this.assets.sprite(id);
|
const sprite = this.assets.sprite(id);
|
||||||
if (!sprite) return;
|
if (!sprite) return;
|
||||||
@@ -172,10 +196,11 @@ export class WorldRenderer {
|
|||||||
paletteIndex ?? sprite.palette,
|
paletteIndex ?? sprite.palette,
|
||||||
time,
|
time,
|
||||||
);
|
);
|
||||||
for (const piece of this.assets.facing(
|
const pieces =
|
||||||
direction,
|
facing !== undefined
|
||||||
sprite.walking ? frame : 0,
|
? this.assets.facingTiles(facing)
|
||||||
)) {
|
: this.assets.facing(direction, sprite.walking ? frame : 0);
|
||||||
|
for (const piece of pieces) {
|
||||||
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
|
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
|
||||||
drawTile(
|
drawTile(
|
||||||
this.image.data,
|
this.image.data,
|
||||||
@@ -190,6 +215,10 @@ export class WorldRenderer {
|
|||||||
transparent: true,
|
transparent: true,
|
||||||
flipX: Boolean(piece.attributes & 0x20),
|
flipX: Boolean(piece.attributes & 0x20),
|
||||||
flipY: Boolean(piece.attributes & 0x40),
|
flipY: Boolean(piece.attributes & 0x40),
|
||||||
|
// RELATIVE_ATTRIBUTES pieces (the lower half) take OAM_PRIO from
|
||||||
|
// IN_GRASS_F and hide behind the grass's colored pixels.
|
||||||
|
indices: this.indices,
|
||||||
|
behind: inGrass && Boolean(piece.attributes & 0x02),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { CrystalAssets } from "../rom/assets";
|
||||||
|
import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||||
|
import type { GameMap, Palette } from "../world/types";
|
||||||
|
|
||||||
|
/** vTiles2 in VRAM bank 0: animations write tileset tiles from here. */
|
||||||
|
const VTILES2 = 0x9000;
|
||||||
|
/** wTileAnimBuffer, the scratch tile that scrolling animations use. */
|
||||||
|
const TILE_ANIM_BUFFER = 0xcf41;
|
||||||
|
/** Tile animation routines by ROM address (engine/tilesets/tileset_anims.asm). */
|
||||||
|
const R = OVERWORLD;
|
||||||
|
|
||||||
|
/** _AnimateTileset (engine/tilesets/tileset_anims.asm): the tileset's own
|
||||||
|
* list of (parameter, routine) pairs from data/tileset_anims.asm, run one
|
||||||
|
* entry per video frame. Routines are selected by their ROM address and
|
||||||
|
* translated natively; tiles are written to a private copy of the tileset
|
||||||
|
* graphics, and the palette animations to color 0 of two palettes. */
|
||||||
|
export class TilesetAnimator {
|
||||||
|
readonly graphics: Uint8Array;
|
||||||
|
private readonly script: number;
|
||||||
|
/** hTileAnimFrame, wTileAnimationTimer and hVBlankCounter. */
|
||||||
|
private frame = 0;
|
||||||
|
private timer = 0;
|
||||||
|
private vblank = 0;
|
||||||
|
private buffer = new Uint8Array(16);
|
||||||
|
/** Which of the water palette's colors 0-2 replaces color 0, and the
|
||||||
|
* dark cave entrance's yellow color 0 or 1. */
|
||||||
|
private water = 0;
|
||||||
|
private cave?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private assets: CrystalAssets,
|
||||||
|
map: GameMap,
|
||||||
|
graphics: Uint8Array,
|
||||||
|
) {
|
||||||
|
const r = assets.reader;
|
||||||
|
this.graphics = new Uint8Array(graphics);
|
||||||
|
const tileset = SYMBOLS.tilesets + map.tileset * 15;
|
||||||
|
this.script = r.near(tileset + 9, r.bank(R.WaitTileAnimation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `frames` video frames; `dark` is wTimeOfDayPalset's darkness. */
|
||||||
|
advance(frames: number, dark: boolean, restless = false) {
|
||||||
|
for (let count = 0; count < frames; count++) {
|
||||||
|
this.vblank = (this.vblank + 1) & 0xff;
|
||||||
|
this.step(dark, restless);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the palette animations to the map's palettes. */
|
||||||
|
palettes(palettes: Palette[]): Palette[] {
|
||||||
|
const result = [...palettes];
|
||||||
|
// AnimateWaterPalette: PAL_BG_WATER color 0 cycles through colors 0-2.
|
||||||
|
if (this.water) result[3] = [palettes[3][this.water], ...palettes[3].slice(1)];
|
||||||
|
if (this.cave !== undefined)
|
||||||
|
result[4] = [palettes[4][this.cave], ...palettes[4].slice(1)];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private step(dark: boolean, restless: boolean) {
|
||||||
|
const r = this.assets.reader,
|
||||||
|
entry = this.script + this.frame * 4;
|
||||||
|
this.frame = (this.frame + 1) & 0xff;
|
||||||
|
const parameter = r.word(entry),
|
||||||
|
routine = r.address(r.bank(R.WaitTileAnimation), r.word(entry + 2));
|
||||||
|
const bank = r.bank(R.WaitTileAnimation);
|
||||||
|
const at = (address: number) => r.address(bank, address);
|
||||||
|
switch (routine) {
|
||||||
|
case R.WaitTileAnimation:
|
||||||
|
return;
|
||||||
|
case R.DoneTileAnimation:
|
||||||
|
this.frame = 0;
|
||||||
|
return;
|
||||||
|
case R.StandingTileFrame8:
|
||||||
|
this.timer = (this.timer + 1) & 7;
|
||||||
|
return;
|
||||||
|
case R.StandingTileFrame:
|
||||||
|
this.timer = (this.timer + 1) & 0xff;
|
||||||
|
return;
|
||||||
|
case R.ScrollTileRightLeft:
|
||||||
|
this.timer = (this.timer + 1) & 7;
|
||||||
|
return this.scroll(parameter, this.timer & 4 ? "left" : "right");
|
||||||
|
case R.ScrollTileUpDown:
|
||||||
|
this.timer = (this.timer + 1) & 7;
|
||||||
|
return this.scroll(parameter, this.timer & 4 ? "down" : "up");
|
||||||
|
case R.ScrollTileLeft:
|
||||||
|
return this.scroll(parameter, "left");
|
||||||
|
case R.ScrollTileRight:
|
||||||
|
return this.scroll(parameter, "right");
|
||||||
|
case R.ScrollTileUp:
|
||||||
|
return this.scroll(parameter, "up");
|
||||||
|
case R.ScrollTileDown:
|
||||||
|
return this.scroll(parameter, "down");
|
||||||
|
case R.ReadTileToAnimBuffer:
|
||||||
|
this.buffer.set(this.memory(parameter));
|
||||||
|
return;
|
||||||
|
case R.WriteTileFromAnimBuffer:
|
||||||
|
this.memory(parameter).set(this.buffer);
|
||||||
|
return;
|
||||||
|
case R.AnimateWaterTile:
|
||||||
|
// A cycle of 4 frames, updating every other tick.
|
||||||
|
return this.write(parameter, R["AnimateWaterTile.WaterTileFrames"] + (this.timer & 6) * 8);
|
||||||
|
case R.AnimateFountainTile: {
|
||||||
|
const table = R["AnimateFountainTile.FountainTileFramePointers"];
|
||||||
|
return this.write(parameter, at(r.word(table + (this.timer & 7) * 2)));
|
||||||
|
}
|
||||||
|
case R.AnimateFlowerTile:
|
||||||
|
// CGB flower frames follow the DMG ones in .FlowerTileFrames.
|
||||||
|
return this.write(VTILES2 + 0x03 * 16, R["AnimateFlowerTile.FlowerTileFrames"] + ((this.timer & 2) + 1) * 16);
|
||||||
|
case R.ForestTreeLeftAnimation:
|
||||||
|
case R.ForestTreeLeftAnimation2:
|
||||||
|
case R.ForestTreeRightAnimation:
|
||||||
|
case R.ForestTreeRightAnimation2: {
|
||||||
|
const right = routine === R.ForestTreeRightAnimation || routine === R.ForestTreeRightAnimation2;
|
||||||
|
const alternate = routine === R.ForestTreeLeftAnimation2 || routine === R.ForestTreeRightAnimation2;
|
||||||
|
// GetForestTreeFrame returns the odd frame for all timer values >= 7.
|
||||||
|
const frame = restless ? ((this.timer >= 7 || (this.timer & 1) ? 1 : 0) ^ Number(alternate)) : 0;
|
||||||
|
return this.write(VTILES2 + (right ? 0x0f : 0x0c) * 16,
|
||||||
|
(right ? R.ForestTreeRightFrames : R.ForestTreeLeftFrames) + frame * 16);
|
||||||
|
}
|
||||||
|
case R.AnimateLavaBubbleTile1:
|
||||||
|
return this.write(VTILES2 + 0x5b * 16, R.LavaBubbleTileFrames + ((((this.timer & 6) >> 1) + 2) & 3) * 16);
|
||||||
|
case R.AnimateLavaBubbleTile2:
|
||||||
|
return this.write(VTILES2 + 0x38 * 16, R.LavaBubbleTileFrames + (this.timer & 6) * 8);
|
||||||
|
case R.AnimateTowerPillarTile: {
|
||||||
|
const pointer = at(parameter);
|
||||||
|
const offset = r.byte(R["AnimateTowerPillarTile.TowerPillarTileFrameOffsets"] + (this.timer & 7));
|
||||||
|
return this.write(r.word(pointer), at(r.word(pointer + 2)) + offset);
|
||||||
|
}
|
||||||
|
case R.AnimateWhirlpoolTile: {
|
||||||
|
const pointer = at(parameter);
|
||||||
|
return this.write(r.word(pointer), at(r.word(pointer + 2)) + (this.timer & 3) * 16);
|
||||||
|
}
|
||||||
|
case R.AnimateWaterPalette:
|
||||||
|
// Only on even ticks: colors 0, 1, 2, 1.
|
||||||
|
if (this.timer & 1) return;
|
||||||
|
this.water = (this.timer & 6) === 0 ? 0 : (this.timer & 6) === 4 ? 2 : 1;
|
||||||
|
return;
|
||||||
|
case R.FlickeringCaveEntrancePalette:
|
||||||
|
// Only in a dark cave: yellow color 0 alternates with color 1.
|
||||||
|
this.cave = dark ? (this.vblank & 2 ? 1 : 0) : undefined;
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error("Tileset animation routine is not ported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tile in vTiles2 (bank 0), or wTileAnimBuffer. */
|
||||||
|
private memory(address: number): Uint8Array {
|
||||||
|
if (address === TILE_ANIM_BUFFER) return this.buffer;
|
||||||
|
const tile = (address - VTILES2) >> 4;
|
||||||
|
if (address < VTILES2 || (address & 15) || tile >= 0x80)
|
||||||
|
throw new Error("Tileset animation writes outside the tiles.");
|
||||||
|
return this.graphics.subarray(tile * 16, tile * 16 + 16);
|
||||||
|
}
|
||||||
|
private write(destination: number, source: number) {
|
||||||
|
this.memory(destination).set(this.assets.reader.slice(source, 16));
|
||||||
|
}
|
||||||
|
/** ScrollTile*: bytes rotate for left/right; rows rotate for up/down. */
|
||||||
|
private scroll(address: number, direction: "left" | "right" | "up" | "down") {
|
||||||
|
const tile = this.memory(address);
|
||||||
|
if (direction === "left" || direction === "right")
|
||||||
|
for (let i = 0; i < 16; i++)
|
||||||
|
tile[i] =
|
||||||
|
direction === "left"
|
||||||
|
? ((tile[i] << 1) | (tile[i] >> 7)) & 0xff
|
||||||
|
: ((tile[i] >> 1) | (tile[i] << 7)) & 0xff;
|
||||||
|
else {
|
||||||
|
const rows = Array.from({ length: 8 }, (_, row) => tile.slice(row * 2, row * 2 + 2));
|
||||||
|
for (let row = 0; row < 8; row++)
|
||||||
|
tile.set(rows[direction === "up" ? (row + 1) % 8 : (row + 7) % 8], row * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { GameWorld } from "../world/world";
|
||||||
|
import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||||
|
import { decompress } from "../rom/lz";
|
||||||
|
import { drawTile } from "./pixels";
|
||||||
|
|
||||||
|
/** The ROM's Town Map, used by wall maps and the bedroom poster. */
|
||||||
|
export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
|
||||||
|
const { assets } = world,
|
||||||
|
r = assets.reader;
|
||||||
|
const landmark = world.map.landmark;
|
||||||
|
const map = landmark >= 47 ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
|
||||||
|
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
|
||||||
|
const palette =
|
||||||
|
world.avatar === "kris"
|
||||||
|
? OVERWORLD.FemalePokegearPals
|
||||||
|
: OVERWORLD.MalePokegearPals;
|
||||||
|
canvas.width = 160;
|
||||||
|
canvas.height = 144;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
const image = context.createImageData(160, 144);
|
||||||
|
image.data.fill(255);
|
||||||
|
for (let i = 0; i < 360; i++) {
|
||||||
|
const tile = r.byte(map + i);
|
||||||
|
const packed =
|
||||||
|
tile < 0x60 ? r.byte(OVERWORLD["TownMapPals.PalMap"] + (tile >> 1)) : 0;
|
||||||
|
const color = (tile & 1 ? packed >> 4 : packed) & 7;
|
||||||
|
drawTile(
|
||||||
|
image.data,
|
||||||
|
160,
|
||||||
|
144,
|
||||||
|
graphics,
|
||||||
|
tile,
|
||||||
|
(i % 20) * 8,
|
||||||
|
Math.floor(i / 20) * 8,
|
||||||
|
assets.palette(palette + color * 8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.putImageData(image, 0, 0);
|
||||||
|
// Landmark coordinates are the same ones TownMapPlayerIcon uses.
|
||||||
|
const p = SYMBOLS.landmarks + landmark * 4;
|
||||||
|
context.fillStyle = "#b32628";
|
||||||
|
context.fillRect(r.byte(p) - 10, r.byte(p + 1) - 18, 4, 4);
|
||||||
|
canvas.setAttribute("aria-label", `Town Map: ${world.map.location}`);
|
||||||
|
}
|
||||||
+86
-14
@@ -6,11 +6,13 @@ import {
|
|||||||
NATIVE_MAPS,
|
NATIVE_MAPS,
|
||||||
NUM_ENGINE_FLAGS,
|
NUM_ENGINE_FLAGS,
|
||||||
NUM_SPRITEMOVEDATA,
|
NUM_SPRITEMOVEDATA,
|
||||||
|
OVERWORLD,
|
||||||
RAM,
|
RAM,
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
import { defaultTextContext, readString } from "../scripts/text";
|
import { defaultTextContext, readString } from "../scripts/text";
|
||||||
import { PokemonData } from "./pokemon";
|
import { PokemonData } from "./pokemon";
|
||||||
|
import { OW } from "../world/constants";
|
||||||
import type {
|
import type {
|
||||||
Direction,
|
Direction,
|
||||||
GameMap,
|
GameMap,
|
||||||
@@ -308,6 +310,35 @@ export class CrystalAssets {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/** LoadSpecialMapPalette: tilesets with their own eight palettes, which
|
||||||
|
* do not follow the time of day. */
|
||||||
|
private specialPalettes(map: GameMap): Palette[] | undefined {
|
||||||
|
const eight = (address: number) =>
|
||||||
|
Array.from({ length: 8 }, (_, i) => this.palette(address + i * 8));
|
||||||
|
switch (map.tileset) {
|
||||||
|
case OW.TILESET_POKECOM_CENTER:
|
||||||
|
return eight(OVERWORLD.PokeComPalette);
|
||||||
|
case OW.TILESET_BATTLE_TOWER_INSIDE:
|
||||||
|
return eight(OVERWORLD.BattleTowerInsidePalette);
|
||||||
|
case OW.TILESET_ICE_PATH:
|
||||||
|
// The Hall of Fame shares the tileset but is INDOOR.
|
||||||
|
return (map.environment & 7) === OW.INDOOR ? undefined : eight(OVERWORLD.IcePathPalette);
|
||||||
|
case OW.TILESET_HOUSE:
|
||||||
|
return eight(SYMBOLS.housePalettes);
|
||||||
|
case OW.TILESET_RADIO_TOWER:
|
||||||
|
return eight(OVERWORLD.RadioTowerPalette);
|
||||||
|
case OW.TILESET_MANSION: {
|
||||||
|
// LoadMansionPalette: MansionPalette1, then yellow from
|
||||||
|
// MansionPalette2, water from palette 6 and roof from palette 8.
|
||||||
|
const palettes = eight(OVERWORLD.MansionPalette1);
|
||||||
|
palettes[4] = this.palette(OVERWORLD.MansionPalette2);
|
||||||
|
palettes[3] = this.palette(OVERWORLD.MansionPalette1 + 6 * 8);
|
||||||
|
palettes[6] = this.palette(OVERWORLD.MansionPalette1 + 8 * 8);
|
||||||
|
return palettes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
palettes(map: GameMap, time: number): Palette[] {
|
palettes(map: GameMap, time: number): Palette[] {
|
||||||
const r = this.reader;
|
const r = this.reader;
|
||||||
const group =
|
const group =
|
||||||
@@ -316,12 +347,10 @@ export class CrystalAssets {
|
|||||||
r.bank(SYMBOLS.environmentColors),
|
r.bank(SYMBOLS.environmentColors),
|
||||||
) +
|
) +
|
||||||
time * 8;
|
time * 8;
|
||||||
const palettes = Array.from({ length: 8 }, (_, i) =>
|
const palettes =
|
||||||
this.palette(
|
this.specialPalettes(map) ??
|
||||||
map.tileset === 5
|
Array.from({ length: 8 }, (_, i) =>
|
||||||
? SYMBOLS.housePalettes + i * 8
|
this.palette(SYMBOLS.backgroundPalettes + r.byte(group + i) * 8),
|
||||||
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (map.environment === 1 || map.environment === 2) {
|
if (map.environment === 1 || map.environment === 2) {
|
||||||
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
|
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
|
||||||
@@ -344,13 +373,15 @@ export class CrystalAssets {
|
|||||||
}
|
}
|
||||||
throw new Error("Grass collision list is unterminated.");
|
throw new Error("Grass collision list is unterminated.");
|
||||||
}
|
}
|
||||||
/** JohtoGrassWildMons for a map: encounter rates and seven slots per
|
/** JohtoGrassWildMons or KantoGrassWildMons for a map: encounter rates
|
||||||
* time of day (data/wild/johto_grass.asm). */
|
* and seven slots per time of day (data/wild/johto_grass.asm). */
|
||||||
grassWildMons(
|
grassWildMons(
|
||||||
id: string,
|
id: string,
|
||||||
|
kanto = false,
|
||||||
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
): { rates: number[]; slots: { level: number; species: number }[][] } | undefined {
|
||||||
const r = this.reader;
|
const r = this.reader;
|
||||||
for (let p = SYMBOLS.johtoGrassWildMons, i = 0; i < 256; i++, p += 47) {
|
const table = kanto ? OVERWORLD.KantoGrassWildMons : SYMBOLS.johtoGrassWildMons;
|
||||||
|
for (let p = table, i = 0; i < 256; i++, p += 47) {
|
||||||
const group = r.byte(p);
|
const group = r.byte(p);
|
||||||
if (group === 0xff) return undefined;
|
if (group === 0xff) return undefined;
|
||||||
if (`${group}:${r.byte(p + 1)}` !== id) continue;
|
if (`${group}:${r.byte(p + 1)}` !== id) continue;
|
||||||
@@ -365,6 +396,28 @@ export class CrystalAssets {
|
|||||||
}
|
}
|
||||||
throw new Error("Wild encounter data is unterminated.");
|
throw new Error("Wild encounter data is unterminated.");
|
||||||
}
|
}
|
||||||
|
/** JohtoWaterWildMons or KantoWaterWildMons: one rate and three slots
|
||||||
|
* (data/wild/johto_water.asm). */
|
||||||
|
waterWildMons(
|
||||||
|
id: string,
|
||||||
|
kanto = false,
|
||||||
|
): { rate: number; slots: { level: number; species: number }[] } | undefined {
|
||||||
|
const r = this.reader;
|
||||||
|
const table = kanto ? OVERWORLD.KantoWaterWildMons : OVERWORLD.JohtoWaterWildMons;
|
||||||
|
for (let p = table, i = 0; i < 256; i++, p += 9) {
|
||||||
|
const group = r.byte(p);
|
||||||
|
if (group === 0xff) return undefined;
|
||||||
|
if (`${group}:${r.byte(p + 1)}` !== id) continue;
|
||||||
|
return {
|
||||||
|
rate: r.byte(p + 2),
|
||||||
|
slots: Array.from({ length: 3 }, (_, slot) => ({
|
||||||
|
level: r.byte(p + 3 + slot * 2),
|
||||||
|
species: r.byte(p + 4 + slot * 2),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Wild encounter data is unterminated.");
|
||||||
|
}
|
||||||
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
|
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
|
||||||
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
|
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
|
||||||
dailyEngineFlags(): number[] {
|
dailyEngineFlags(): number[] {
|
||||||
@@ -381,13 +434,20 @@ export class CrystalAssets {
|
|||||||
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
|
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
|
||||||
spriteMovement(type: number): {
|
spriteMovement(type: number): {
|
||||||
fn: number;
|
fn: number;
|
||||||
|
/** OBJECT_ACTION_* the object starts with. */
|
||||||
|
action: number;
|
||||||
flags: number;
|
flags: number;
|
||||||
paletteFlags: number;
|
paletteFlags: number;
|
||||||
} {
|
} {
|
||||||
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
|
if (type >= NUM_SPRITEMOVEDATA) throw new Error("Unknown movement type.");
|
||||||
const r = this.reader,
|
const r = this.reader,
|
||||||
p = SYMBOLS.spriteMovementData + type * 6;
|
p = SYMBOLS.spriteMovementData + type * 6;
|
||||||
return { fn: r.byte(p), flags: r.byte(p + 3), paletteFlags: r.byte(p + 5) };
|
return {
|
||||||
|
fn: r.byte(p),
|
||||||
|
action: r.byte(p + 2),
|
||||||
|
flags: r.byte(p + 3),
|
||||||
|
paletteFlags: r.byte(p + 5),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/** Emote bubble graphics (data/sprites/emotes.asm). */
|
/** Emote bubble graphics (data/sprites/emotes.asm). */
|
||||||
emote(index: number): Uint8Array {
|
emote(index: number): Uint8Array {
|
||||||
@@ -399,13 +459,25 @@ export class CrystalAssets {
|
|||||||
r.byte(entry + 2),
|
r.byte(entry + 2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/** GetSprite: overworld sprites, and Pokémon drawn from their party
|
||||||
|
* menu icons (GetMonSprite). Variable sprites are resolved by the world
|
||||||
|
* from wVariableSprites first; day-care Pokémon are not ported. */
|
||||||
sprite(id: number): Sprite | undefined {
|
sprite(id: number): Sprite | undefined {
|
||||||
// Variable decorations and Pokémon icons need separate decoders.
|
|
||||||
if (id < 1 || id >= 0xa0) return undefined;
|
|
||||||
const cached = this.sprites.get(id);
|
const cached = this.sprites.get(id);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const r = this.reader,
|
const r = this.reader;
|
||||||
p = SYMBOLS.sprites + (id - 1) * 6;
|
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
|
||||||
|
// LoadOverworldMonIcon: eight tiles, two frames in the down and up
|
||||||
|
// standing slots, with palette 0.
|
||||||
|
const species = r.byte(OVERWORLD.SpriteMons + id - OW.SPRITE_POKEMON);
|
||||||
|
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
|
||||||
|
const pointer = r.near(OVERWORLD.IconPointers + icon * 2, r.bank(OVERWORLD.IconPointers));
|
||||||
|
const sprite = { graphics: r.slice(pointer, 8 * 16), palette: 0, walking: true };
|
||||||
|
this.sprites.set(id, sprite);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
if (id < 1 || id > OW.NUM_OVERWORLD_SPRITES) return undefined;
|
||||||
|
const p = SYMBOLS.sprites + (id - 1) * 6;
|
||||||
const length = r.byte(p + 2),
|
const length = r.byte(p + 2),
|
||||||
walking = r.byte(p + 4) === 1;
|
walking = r.byte(p + 4) === 1;
|
||||||
const sprite = {
|
const sprite = {
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export const RAM = {
|
|||||||
hiddenItemID: 0xd040,
|
hiddenItemID: 0xd040,
|
||||||
runningTrainerBattleScript: 0xd04d,
|
runningTrainerBattleScript: 0xd04d,
|
||||||
callerContact: 0xd03f,
|
callerContact: 0xd03f,
|
||||||
|
surfingPlayerState: 0xd1eb,
|
||||||
|
strengthSpecies: 0xd1ef,
|
||||||
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
||||||
moneyTemp: 0xffc3,
|
moneyTemp: 0xffc3,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -174,6 +176,111 @@ export const ENGINE_FLAGS = {
|
|||||||
allFruitTrees: 84,
|
allFruitTrees: 84,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||||
|
* that `npm run check:reference` verifies each address by name. */
|
||||||
|
export const OVERWORLD = {
|
||||||
|
"OverworldTownMap": 0xc2c0,
|
||||||
|
"TownMapGFX": 0xf8ba0,
|
||||||
|
"JohtoMap": 0x91fff,
|
||||||
|
"KantoMap": 0x92168,
|
||||||
|
"TownMapPals.PalMap": 0x91f4b,
|
||||||
|
"MalePokegearPals": 0xb729,
|
||||||
|
"FemalePokegearPals": 0xb759,
|
||||||
|
|
||||||
|
"DecorationAttributes": 0x26a4f,
|
||||||
|
"DecorationNames": 0x26b8d,
|
||||||
|
"_PlayerDecorationMenu.category_pointers": 0x267aa,
|
||||||
|
"PlayersHousePC": 0xc2e7,
|
||||||
|
"PutAwayTheDecoText": 0x26ed1,
|
||||||
|
"NothingToPutAwayText": 0x26ed6,
|
||||||
|
"SetUpTheDecoText": 0x26edb,
|
||||||
|
"PutAwayAndSetUpText": 0x26ee0,
|
||||||
|
"AlreadySetUpText": 0x26ee5,
|
||||||
|
"WhichSidePutOnText": 0x26e41,
|
||||||
|
"WhichSidePutAwayText": 0x26e6b,
|
||||||
|
"DecorationDesc_PosterPointers": 0x26f84,
|
||||||
|
"DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript": 0x26fd5,
|
||||||
|
"DecorationDesc_GiantOrnament.BigDollScript": 0x26fe3,
|
||||||
|
|
||||||
|
Script_ForcedMovement: 0x1253d,
|
||||||
|
FallIntoMapScript: 0x96c38,
|
||||||
|
"_Sine.sinetable_u10": 0x850b,
|
||||||
|
AskFloorElevatorText: 0x1350d,
|
||||||
|
Elevator_CurrentFloorText: 0x13537,
|
||||||
|
ElevatorFloorNames: 0x13583,
|
||||||
|
JohtoWaterWildMons: 0x2b11d,
|
||||||
|
KantoGrassWildMons: 0x2b274,
|
||||||
|
KantoWaterWildMons: 0x2b7f7,
|
||||||
|
WaterMonProbTable: 0x2a1d9,
|
||||||
|
SpriteMons: 0x14495,
|
||||||
|
MonMenuIcons: 0x8eac4,
|
||||||
|
IconPointers: 0x8ebbf,
|
||||||
|
AskSurfScript: 0xca2c,
|
||||||
|
"UsedSurfScript.stubbed_fn": 0xc9a2,
|
||||||
|
AskCutScript: 0xd1a9,
|
||||||
|
"AskCutScript.CheckMap": 0xd1ba,
|
||||||
|
CantCutScript: 0xd1cd,
|
||||||
|
CutDownTreeOrGrass: 0xc810,
|
||||||
|
GetPartyNickname: 0xc706,
|
||||||
|
CutTreeBlockPointers: 0xc862,
|
||||||
|
WhirlpoolBlockPointers: 0xc8a4,
|
||||||
|
TryStrengthOW: 0xcd78,
|
||||||
|
SetStrengthFlag: 0xcd12,
|
||||||
|
Script_AskWaterfall: 0xcb86,
|
||||||
|
Script_CantDoWaterfall: 0xcb7e,
|
||||||
|
"Script_UsedWaterfall.CheckContinueWaterfall": 0xcb38,
|
||||||
|
Script_AskWhirlpoolOW: 0xce6e,
|
||||||
|
Script_MightyWhirlpool: 0xce66,
|
||||||
|
DisappearWhirlpool: 0xce1d,
|
||||||
|
ChrisStateSprites: 0x141b7,
|
||||||
|
KrisStateSprites: 0x141c0,
|
||||||
|
Script_GetOnBike: 0xd13e,
|
||||||
|
Script_GetOffBike: 0xd158,
|
||||||
|
Script_CantGetOffBike: 0xd171,
|
||||||
|
UpdatePlayerSprite: 0xe4a,
|
||||||
|
PlayMapMusic: 0x3cdf,
|
||||||
|
SurfStartStep: 0x8379,
|
||||||
|
UpdateTimePals: 0x485,
|
||||||
|
WaitTileAnimation: 0xfc2fe,
|
||||||
|
DoneTileAnimation: 0xfc2fb,
|
||||||
|
StandingTileFrame8: 0xfc2ff,
|
||||||
|
StandingTileFrame: 0xfc673,
|
||||||
|
AnimateWaterTile: 0xfc402,
|
||||||
|
"AnimateWaterTile.WaterTileFrames": 0xfc41c,
|
||||||
|
AnimateWaterPalette: 0xfc6d7,
|
||||||
|
AnimateFlowerTile: 0xfc56d,
|
||||||
|
"AnimateFlowerTile.FlowerTileFrames": 0xfc58c,
|
||||||
|
AnimateFountainTile: 0xfc387,
|
||||||
|
"AnimateFountainTile.FountainTileFramePointers": 0xfc3a2,
|
||||||
|
ForestTreeLeftAnimation: 0xfc45c,
|
||||||
|
ForestTreeRightAnimation: 0xfc4c4,
|
||||||
|
ForestTreeLeftAnimation2: 0xfc4f2,
|
||||||
|
ForestTreeRightAnimation2: 0xfc51c,
|
||||||
|
ForestTreeLeftFrames: 0xfc484,
|
||||||
|
ForestTreeRightFrames: 0xfc4a4,
|
||||||
|
AnimateLavaBubbleTile1: 0xfc5cc,
|
||||||
|
AnimateLavaBubbleTile2: 0xfc5eb,
|
||||||
|
LavaBubbleTileFrames: 0xfc605,
|
||||||
|
AnimateTowerPillarTile: 0xfc645,
|
||||||
|
"AnimateTowerPillarTile.TowerPillarTileFrameOffsets": 0xfc66b,
|
||||||
|
AnimateWhirlpoolTile: 0xfc678,
|
||||||
|
ReadTileToAnimBuffer: 0xfc6a2,
|
||||||
|
WriteTileFromAnimBuffer: 0xfc696,
|
||||||
|
ScrollTileRightLeft: 0xfc309,
|
||||||
|
ScrollTileUpDown: 0xfc318,
|
||||||
|
ScrollTileLeft: 0xfc327,
|
||||||
|
ScrollTileRight: 0xfc33b,
|
||||||
|
ScrollTileUp: 0xfc34f,
|
||||||
|
ScrollTileDown: 0xfc36a,
|
||||||
|
FlickeringCaveEntrancePalette: 0xfc71e,
|
||||||
|
PokeComPalette: 0x49501,
|
||||||
|
BattleTowerInsidePalette: 0x49550,
|
||||||
|
IcePathPalette: 0x4959f,
|
||||||
|
RadioTowerPalette: 0x4963d,
|
||||||
|
MansionPalette1: 0x4967d,
|
||||||
|
MansionPalette2: 0x496fe,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||||
* \`npm run check:reference\` can verify each address by name. */
|
* \`npm run check:reference\` can verify each address by name. */
|
||||||
export const BATTLE = {
|
export const BATTLE = {
|
||||||
|
|||||||
+15
-4
@@ -9,6 +9,7 @@ import { WorldRenderer } from "./rendering/renderer";
|
|||||||
export class NativeRuntime implements GameRuntime {
|
export class NativeRuntime implements GameRuntime {
|
||||||
readonly id = "crystal-native-world-v1";
|
readonly id = "crystal-native-world-v1";
|
||||||
world?: GameWorld;
|
world?: GameWorld;
|
||||||
|
fieldMenuOpen = false;
|
||||||
private renderer?: WorldRenderer;
|
private renderer?: WorldRenderer;
|
||||||
private pressed = new Set<Button>();
|
private pressed = new Set<Button>();
|
||||||
private raf = 0;
|
private raf = 0;
|
||||||
@@ -26,6 +27,7 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
) {}
|
) {}
|
||||||
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
|
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
|
||||||
this.dispose();
|
this.dispose();
|
||||||
|
this.fieldMenuOpen = false;
|
||||||
const assets = new CrystalAssets(bytes);
|
const assets = new CrystalAssets(bytes);
|
||||||
const world = new GameWorld(assets);
|
const world = new GameWorld(assets);
|
||||||
const player = this.player();
|
const player = this.player();
|
||||||
@@ -57,20 +59,28 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
if (this.pressed.has(button) || !this.world) return;
|
if (this.pressed.has(button) || !this.world) return;
|
||||||
this.pressed.add(button);
|
this.pressed.add(button);
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
if (world.battle && this.battleInput?.(button)) {
|
if (this.battleInput?.(button)) {
|
||||||
this.publish();
|
this.publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (button === "a") world.interact();
|
if (button === "a") world.interact();
|
||||||
if (button === "b") world.advanceDialogue();
|
if (button === "b") world.advanceDialogue();
|
||||||
if (["up", "down", "left", "right"].includes(button) &&
|
if (
|
||||||
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
|
["up", "down", "left", "right"].includes(button) &&
|
||||||
|
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno")
|
||||||
|
)
|
||||||
world.moveCursor(button as Direction);
|
world.moveCursor(button as Direction);
|
||||||
if ((button === "start" || button === "select") && !world.busy)
|
if ((button === "start" || button === "select") && !world.busy)
|
||||||
world.notice =
|
world.notice =
|
||||||
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
|
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
useBicycle() {
|
||||||
|
this.fieldMenuOpen = false;
|
||||||
|
if (this.world && !this.world.useBicycle())
|
||||||
|
this.world.notice = "You can’t use the BICYCLE here.";
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
/** Answer a choice from a pointer or keyboard control outside the canvas. */
|
/** Answer a choice from a pointer or keyboard control outside the canvas. */
|
||||||
choose(answer: number | string) {
|
choose(answer: number | string) {
|
||||||
this.world?.choose(answer);
|
this.world?.choose(answer);
|
||||||
@@ -108,7 +118,7 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
let ticks = 0;
|
let ticks = 0;
|
||||||
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
|
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
|
||||||
this.pending -= 1 / TICKS_PER_SECOND;
|
this.pending -= 1 / TICKS_PER_SECOND;
|
||||||
world.tick(held);
|
if (!this.fieldMenuOpen) world.tick(held);
|
||||||
ticks++;
|
ticks++;
|
||||||
}
|
}
|
||||||
if (ticks === 6) this.pending = 0;
|
if (ticks === 6) this.pending = 0;
|
||||||
@@ -225,6 +235,7 @@ export class NativeRuntime implements GameRuntime {
|
|||||||
restoreState(bytes: Uint8Array) {
|
restoreState(bytes: Uint8Array) {
|
||||||
if (!this.world || bytes.length > 64_000)
|
if (!this.world || bytes.length > 64_000)
|
||||||
throw new Error("Invalid native save size.");
|
throw new Error("Invalid native save size.");
|
||||||
|
this.fieldMenuOpen = false;
|
||||||
this.world.restore(
|
this.world.restore(
|
||||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||||
);
|
);
|
||||||
|
|||||||
+615
-42
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
DecorationData,
|
||||||
|
emptyDecorations,
|
||||||
|
type Decorations,
|
||||||
|
} from "../world/decorations";
|
||||||
import { RomReader } from "../rom/reader";
|
import { RomReader } from "../rom/reader";
|
||||||
import {
|
import {
|
||||||
EMPTY_ROUTINES,
|
EMPTY_ROUTINES,
|
||||||
@@ -5,6 +10,7 @@ import {
|
|||||||
NATIVE_ROUTINES,
|
NATIVE_ROUTINES,
|
||||||
NUM_FRUIT_TREES,
|
NUM_FRUIT_TREES,
|
||||||
EVENTS,
|
EVENTS,
|
||||||
|
OVERWORLD,
|
||||||
RAM,
|
RAM,
|
||||||
SPECIAL_ROUTINES,
|
SPECIAL_ROUTINES,
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
@@ -28,6 +34,7 @@ import {
|
|||||||
} from "../party/pokemon";
|
} from "../party/pokemon";
|
||||||
import { TrainerData, type TrainerHeader } from "../rom/trainers";
|
import { TrainerData, type TrainerHeader } from "../rom/trainers";
|
||||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||||
|
import { OW } from "../world/constants";
|
||||||
import { readMovement, type MovementStep } from "./movement";
|
import { readMovement, type MovementStep } from "./movement";
|
||||||
import {
|
import {
|
||||||
defaultTextContext,
|
defaultTextContext,
|
||||||
@@ -42,6 +49,7 @@ export interface ClockSettings {
|
|||||||
dst: boolean;
|
dst: boolean;
|
||||||
}
|
}
|
||||||
export interface ScriptState {
|
export interface ScriptState {
|
||||||
|
decorations: Decorations;
|
||||||
events: Set<number>;
|
events: Set<number>;
|
||||||
flags: Set<number>;
|
flags: Set<number>;
|
||||||
variables: Record<number, number>;
|
variables: Record<number, number>;
|
||||||
@@ -66,8 +74,18 @@ export interface ScriptState {
|
|||||||
/** wSpecialPhoneCallID: nonzero while a saved story call is pending. */
|
/** wSpecialPhoneCallID: nonzero while a saved story call is pending. */
|
||||||
specialPhoneCall: number;
|
specialPhoneCall: number;
|
||||||
rivalName: string;
|
rivalName: string;
|
||||||
|
/** wPlayerState (VAR_MOVEMENT): walking, biking, skating or surfing. */
|
||||||
|
playerState: number;
|
||||||
|
/** wBackupWarpNumber/wBackupMapGroup/wBackupMapNumber: where a warp
|
||||||
|
* numbered -1 leads, recorded on arrival or by warpmod and elevators. */
|
||||||
|
backupWarp: { map: string; warp: number } | null;
|
||||||
|
/** wStatusFlags bits without engine flag numbers: STATUSFLAGS_FLASH_F
|
||||||
|
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
|
||||||
|
flash: boolean;
|
||||||
|
noWildEncounters: boolean;
|
||||||
}
|
}
|
||||||
export const emptyScriptState = (): ScriptState => ({
|
export const emptyScriptState = (): ScriptState => ({
|
||||||
|
decorations: emptyDecorations(),
|
||||||
events: new Set(),
|
events: new Set(),
|
||||||
flags: new Set(),
|
flags: new Set(),
|
||||||
variables: {},
|
variables: {},
|
||||||
@@ -83,8 +101,13 @@ export const emptyScriptState = (): ScriptState => ({
|
|||||||
lastSpawnMap: null,
|
lastSpawnMap: null,
|
||||||
specialPhoneCall: 0,
|
specialPhoneCall: 0,
|
||||||
rivalName: "???",
|
rivalName: "???",
|
||||||
|
playerState: 0,
|
||||||
|
backupWarp: null,
|
||||||
|
flash: false,
|
||||||
|
noWildEncounters: false,
|
||||||
});
|
});
|
||||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||||
|
decorations: { ...state.decorations },
|
||||||
events: new Set(state.events),
|
events: new Set(state.events),
|
||||||
flags: new Set(state.flags),
|
flags: new Set(state.flags),
|
||||||
variables: { ...state.variables },
|
variables: { ...state.variables },
|
||||||
@@ -103,6 +126,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
|||||||
lastSpawnMap: state.lastSpawnMap,
|
lastSpawnMap: state.lastSpawnMap,
|
||||||
specialPhoneCall: state.specialPhoneCall,
|
specialPhoneCall: state.specialPhoneCall,
|
||||||
rivalName: state.rivalName,
|
rivalName: state.rivalName,
|
||||||
|
playerState: state.playerState,
|
||||||
|
backupWarp: state.backupWarp && { ...state.backupWarp },
|
||||||
|
flash: state.flash,
|
||||||
|
noWildEncounters: state.noWildEncounters,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
|
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
|
||||||
@@ -121,6 +148,7 @@ export interface MartEntry {
|
|||||||
owned?: number;
|
owned?: number;
|
||||||
}
|
}
|
||||||
export type ScriptEffect =
|
export type ScriptEffect =
|
||||||
|
| { kind: "townmap" }
|
||||||
/** Pages the player acknowledges. With holdTicks the last page instead
|
/** Pages the player acknowledges. With holdTicks the last page instead
|
||||||
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
||||||
| { kind: "text"; pages: string[]; holdTicks?: number }
|
| { kind: "text"; pages: string[]; holdTicks?: number }
|
||||||
@@ -144,7 +172,12 @@ export type ScriptEffect =
|
|||||||
| { kind: "martmenu"; prompt: string; selected: number }
|
| { kind: "martmenu"; prompt: string; selected: number }
|
||||||
/** The mart's item list (buy: full stock; sell: the bag's pockets);
|
/** The mart's item list (buy: full stock; sell: the bag's pockets);
|
||||||
* answered with the chosen entry's index, or a negative number to cancel. */
|
* answered with the chosen entry's index, or a negative number to cancel. */
|
||||||
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
|
| {
|
||||||
|
kind: "martlist";
|
||||||
|
mode: "buy" | "sell";
|
||||||
|
entries: MartEntry[];
|
||||||
|
selected: number;
|
||||||
|
}
|
||||||
/** The 1-99 quantity stepper; answered with the chosen amount, or a
|
/** The 1-99 quantity stepper; answered with the chosen amount, or a
|
||||||
* number below 1 to cancel back to the item list. */
|
* number below 1 to cancel back to the item list. */
|
||||||
| {
|
| {
|
||||||
@@ -161,13 +194,31 @@ export type ScriptEffect =
|
|||||||
| {
|
| {
|
||||||
kind: "battle";
|
kind: "battle";
|
||||||
wild?: { species: number; level: number };
|
wild?: { species: number; level: number };
|
||||||
trainer?: { classId: number; id: number; winText?: number; lossText?: number };
|
trainer?: {
|
||||||
|
classId: number;
|
||||||
|
id: number;
|
||||||
|
winText?: number;
|
||||||
|
lossText?: number;
|
||||||
|
};
|
||||||
canLose?: boolean;
|
canLose?: boolean;
|
||||||
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
|
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
|
||||||
tutorial?: boolean;
|
tutorial?: boolean;
|
||||||
}
|
}
|
||||||
/** newloadmap: the world enters this map after the script ends. */
|
/** newloadmap and warp: the world enters this map after the script
|
||||||
| { kind: "warp"; map: string; x: number; y: number };
|
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). */
|
||||||
|
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction }
|
||||||
|
/** A vertical menu (the elevator's floors); answered with the chosen
|
||||||
|
* item's index, or -1 when cancelled with B. */
|
||||||
|
| { kind: "menu"; pages: string[]; items: string[]; selected: number }
|
||||||
|
/** warpcheck: the player's tile events run again after the script. */
|
||||||
|
| { kind: "warpcheck" }
|
||||||
|
/** changeblock: the block covering map tile (x, y) becomes `block`. */
|
||||||
|
| { kind: "changeblock"; x: number; y: number; block: number }
|
||||||
|
/** changemapblocks: replace the map's block data from the ROM. */
|
||||||
|
| { kind: "mapblocks"; address: number }
|
||||||
|
/** writecmdqueue with CMDQUEUE_STONETABLE: the map's stone table
|
||||||
|
* (warp, object, script) that boulders falling into pits trigger. */
|
||||||
|
| { kind: "stonetable"; address: number };
|
||||||
|
|
||||||
/** What the world supplies about the present moment. */
|
/** What the world supplies about the present moment. */
|
||||||
export interface ScriptHost {
|
export interface ScriptHost {
|
||||||
@@ -192,6 +243,9 @@ export interface ScriptHost {
|
|||||||
position(): { x: number; y: number };
|
position(): { x: number; y: number };
|
||||||
/** OBJECT_MAP_X/Y of a visible object (script numbering), if any. */
|
/** OBJECT_MAP_X/Y of a visible object (script numbering), if any. */
|
||||||
objectPosition(id: number): { x: number; y: number } | undefined;
|
objectPosition(id: number): { x: number; y: number } | undefined;
|
||||||
|
/** wPlayerTileCollision and wEnvironment. */
|
||||||
|
playerCollision(): number;
|
||||||
|
environment(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnsupportedScript extends Error {
|
export class UnsupportedScript extends Error {
|
||||||
@@ -240,7 +294,16 @@ export function trainerWalkBuffer(
|
|||||||
v = DOWN;
|
v = DOWN;
|
||||||
}
|
}
|
||||||
// Swapped only when the y distance is the smaller one.
|
// Swapped only when the y distance is the smaller one.
|
||||||
const path: [number, number][] = y < x ? [[v, y], [h, x]] : [[h, x], [v, y]];
|
const path: [number, number][] =
|
||||||
|
y < x
|
||||||
|
? [
|
||||||
|
[v, y],
|
||||||
|
[h, x],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
[h, x],
|
||||||
|
[v, y],
|
||||||
|
];
|
||||||
for (const [direction, count] of path)
|
for (const [direction, count] of path)
|
||||||
for (let i = 0; i < count; i++) buffer.push(STEP | direction);
|
for (let i = 0; i < count; i++) buffer.push(STEP | direction);
|
||||||
buffer.pop(); // DecrementMovementBufferCount
|
buffer.pop(); // DecrementMovementBufferCount
|
||||||
@@ -274,8 +337,11 @@ export class ScriptRun {
|
|||||||
/** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1
|
/** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1
|
||||||
* after the battle so the trainer's after-script ends at once. */
|
* after the battle so the trainer's after-script ends at once. */
|
||||||
private runningTrainerBattleScript = 0;
|
private runningTrainerBattleScript = 0;
|
||||||
/** wMovementBuffer, filled by TrainerWalkToPlayer. */
|
/** wMovementBuffer, filled by TrainerWalkToPlayer and SurfStartStep. */
|
||||||
private movementBuffer?: number[];
|
private movementBuffer?: number[];
|
||||||
|
/** wCurPartyMon as CheckPartyMove leaves it, and wStrengthSpecies. */
|
||||||
|
private curPartyMon = 0;
|
||||||
|
private strengthSpecies = 0;
|
||||||
private iterator: Generator<ScriptEffect, void, Input>;
|
private iterator: Generator<ScriptEffect, void, Input>;
|
||||||
private instructions = 0;
|
private instructions = 0;
|
||||||
|
|
||||||
@@ -299,10 +365,30 @@ export class ScriptRun {
|
|||||||
/** wCallerContact's second script, copied by CheckSpecialPhoneCall
|
/** wCallerContact's second script, copied by CheckSpecialPhoneCall
|
||||||
* from the queued SpecialPhoneCallList entry. */
|
* from the queued SpecialPhoneCallList entry. */
|
||||||
callerScript?: number;
|
callerScript?: number;
|
||||||
|
/** A field move's context from TrySurfOW, TryCutOW and similar: the
|
||||||
|
* party member CheckPartyMove found (wCurPartyMon), GetSurfType's
|
||||||
|
* wSurfingPlayerState, and the block Cut or Whirlpool replaces
|
||||||
|
* (wCutWhirlpoolOverworldBlockAddr and its replacement). */
|
||||||
|
fieldMove?: {
|
||||||
|
mon: number;
|
||||||
|
surfState?: number;
|
||||||
|
block?: { x: number; y: number; block: number };
|
||||||
|
};
|
||||||
|
/** An item used from the pack: UseItem names it in wStringBuffer1 and
|
||||||
|
* CopyName1 copies it to wStringBuffer2. */
|
||||||
|
usedItem?: number;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
this.state = copyScriptState(state);
|
this.state = copyScriptState(state);
|
||||||
this.lastTalked = host.lastTalked;
|
this.lastTalked = host.lastTalked;
|
||||||
|
this.curPartyMon = options.fieldMove?.mon ?? 0;
|
||||||
|
// TrySurfOW and similar name the Pokémon before calling their script.
|
||||||
|
if (options.fieldMove) this.partyNickname();
|
||||||
|
if (options.usedItem !== undefined) {
|
||||||
|
const name = host.data.itemName(options.usedItem);
|
||||||
|
this.strings[RAM.stringBuffer1] = name;
|
||||||
|
this.strings[RAM.stringBuffer2] = name;
|
||||||
|
}
|
||||||
if (options.trainer)
|
if (options.trainer)
|
||||||
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
|
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
|
||||||
this.iterator = this.execute(start);
|
this.iterator = this.execute(start);
|
||||||
@@ -384,7 +470,15 @@ export class ScriptRun {
|
|||||||
const synchronous = (effect: ScriptEffect) => {
|
const synchronous = (effect: ScriptEffect) => {
|
||||||
if (
|
if (
|
||||||
this.options.callback &&
|
this.options.callback &&
|
||||||
!["turn", "appear", "disappear", "moveobject"].includes(effect.kind)
|
![
|
||||||
|
"turn",
|
||||||
|
"appear",
|
||||||
|
"disappear",
|
||||||
|
"moveobject",
|
||||||
|
"changeblock",
|
||||||
|
"mapblocks",
|
||||||
|
"stonetable",
|
||||||
|
].includes(effect.kind)
|
||||||
)
|
)
|
||||||
throw new Error("Map callbacks cannot present scenes.");
|
throw new Error("Map callbacks cannot present scenes.");
|
||||||
return effect;
|
return effect;
|
||||||
@@ -394,7 +488,11 @@ export class ScriptRun {
|
|||||||
// spawn map, or SPAWN_HOME when unset or no longer a listed spawn point.
|
// spawn map, or SPAWN_HOME when unset or no longer a listed spawn point.
|
||||||
const findSpawn = (mapId: string | null) => {
|
const findSpawn = (mapId: string | null) => {
|
||||||
if (mapId)
|
if (mapId)
|
||||||
for (let entry = SYMBOLS.spawnPoints, index = 0; r.byte(entry) !== 0xff; entry += 4, index++)
|
for (
|
||||||
|
let entry = SYMBOLS.spawnPoints, index = 0;
|
||||||
|
r.byte(entry) !== 0xff;
|
||||||
|
entry += 4, index++
|
||||||
|
)
|
||||||
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === mapId) return index;
|
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === mapId) return index;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
@@ -421,7 +519,10 @@ export class ScriptRun {
|
|||||||
// memcall: only Script_ReceivePhoneCall's caller script is known.
|
// memcall: only Script_ReceivePhoneCall's caller script is known.
|
||||||
const address = word();
|
const address = word();
|
||||||
const script = this.options.callerScript;
|
const script = this.options.callerScript;
|
||||||
if (address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK || script === undefined)
|
if (
|
||||||
|
address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK ||
|
||||||
|
script === undefined
|
||||||
|
)
|
||||||
throw new UnsupportedScript(offset, opcode);
|
throw new UnsupportedScript(offset, opcode);
|
||||||
stack.push(p);
|
stack.push(p);
|
||||||
p = script;
|
p = script;
|
||||||
@@ -526,21 +627,26 @@ export class ScriptRun {
|
|||||||
else if (routine === NATIVE_ROUTINES.setMemEvent) {
|
else if (routine === NATIVE_ROUTINES.setMemEvent) {
|
||||||
// SetMemEvent: the hidden item's wHiddenItemEvent flag.
|
// SetMemEvent: the hidden item's wHiddenItemEvent flag.
|
||||||
const hidden = this.options.hiddenItem;
|
const hidden = this.options.hiddenItem;
|
||||||
if (!hidden) throw new UnsupportedScript(offset, opcode, " (no hidden item)");
|
if (!hidden)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (no hidden item)");
|
||||||
this.state.events.add(hidden.event);
|
this.state.events.add(hidden.event);
|
||||||
} else if (routine === NATIVE_ROUTINES.trainerWalkToPlayer) {
|
} else if (routine === NATIVE_ROUTINES.trainerWalkToPlayer) {
|
||||||
const distance = this.options.trainer?.distance,
|
const distance = this.options.trainer?.distance,
|
||||||
from = this.host.objectPosition(this.lastTalked);
|
from = this.host.objectPosition(this.lastTalked);
|
||||||
if (distance === undefined || !from || this.lastTalked === PLAYER)
|
if (distance === undefined || !from || this.lastTalked === PLAYER)
|
||||||
throw new UnsupportedScript(offset, opcode, " (no seen trainer)");
|
throw new UnsupportedScript(offset, opcode, " (no seen trainer)");
|
||||||
this.movementBuffer = trainerWalkBuffer(from, this.host.position(), distance);
|
this.movementBuffer = trainerWalkBuffer(
|
||||||
}
|
from,
|
||||||
else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
|
this.host.position(),
|
||||||
|
distance,
|
||||||
|
);
|
||||||
|
} else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
|
||||||
// FindItemInBallScript.TryReceiveItem: give the ball's item and
|
// FindItemInBallScript.TryReceiveItem: give the ball's item and
|
||||||
// name it for FoundItemText (wStringBuffer3), matching GetItemName
|
// name it for FoundItemText (wStringBuffer3), matching GetItemName
|
||||||
// + CopyName2. wScriptVar (value) reports success for iffalse.
|
// + CopyName2. wScriptVar (value) reports success for iffalse.
|
||||||
const ball = this.options.itemBall;
|
const ball = this.options.itemBall;
|
||||||
if (!ball) throw new UnsupportedScript(offset, opcode, " (no item ball)");
|
if (!ball)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (no item ball)");
|
||||||
const name = data.itemName(ball.item);
|
const name = data.itemName(ball.item);
|
||||||
this.strings[RAM.stringBuffer1] = name;
|
this.strings[RAM.stringBuffer1] = name;
|
||||||
this.strings[RAM.stringBuffer3] = name;
|
this.strings[RAM.stringBuffer3] = name;
|
||||||
@@ -560,6 +666,41 @@ export class ScriptRun {
|
|||||||
// The ringing tones and caller name box are not presented yet.
|
// The ringing tones and caller name box are not presented yet.
|
||||||
// InitCallReceiveDelay restarts the random-call timers, and
|
// InitCallReceiveDelay restarts the random-call timers, and
|
||||||
// random calls are not ported.
|
// random calls are not ported.
|
||||||
|
} else if (routine === OVERWORLD.GetPartyNickname)
|
||||||
|
this.partyNickname();
|
||||||
|
else if (routine === OVERWORLD["AskCutScript.CheckMap"])
|
||||||
|
// CheckMapForSomethingToCut, evaluated by the world beforehand.
|
||||||
|
value = Number(this.options.fieldMove?.block !== undefined);
|
||||||
|
else if (
|
||||||
|
routine === OVERWORLD.CutDownTreeOrGrass ||
|
||||||
|
routine === OVERWORLD.DisappearWhirlpool
|
||||||
|
) {
|
||||||
|
const block = this.options.fieldMove?.block;
|
||||||
|
if (!block)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (nothing to cut)");
|
||||||
|
yield synchronous({ kind: "changeblock", ...block });
|
||||||
|
} else if (routine === OVERWORLD.TryStrengthOW) {
|
||||||
|
// 0 asks to use Strength, 1 lacks the move or badge, 2 is active.
|
||||||
|
const mon = this.partyMove(OW.STRENGTH);
|
||||||
|
value =
|
||||||
|
mon < 0 || !this.state.flags.has(OW.ENGINE_PLAINBADGE)
|
||||||
|
? 1
|
||||||
|
: this.state.flags.has(OW.ENGINE_STRENGTH_ACTIVE)
|
||||||
|
? 2
|
||||||
|
: 0;
|
||||||
|
if (mon >= 0) this.curPartyMon = mon;
|
||||||
|
} else if (routine === OVERWORLD.SetStrengthFlag) {
|
||||||
|
this.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
|
||||||
|
this.strengthSpecies =
|
||||||
|
this.state.party[this.curPartyMon]?.species ?? 0;
|
||||||
|
this.partyNickname();
|
||||||
|
} else if (
|
||||||
|
routine === OVERWORLD["Script_UsedWaterfall.CheckContinueWaterfall"]
|
||||||
|
) {
|
||||||
|
const tile = this.host.playerCollision();
|
||||||
|
value = Number(!(tile === 0x33 || tile === 0x3b)); // CheckWaterfallTile
|
||||||
|
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
|
||||||
|
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
|
||||||
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
|
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
|
||||||
throw new UnsupportedScript(offset, opcode);
|
throw new UnsupportedScript(offset, opcode);
|
||||||
break;
|
break;
|
||||||
@@ -570,6 +711,13 @@ export class ScriptRun {
|
|||||||
if (address === RAM.curFruit) value = this.fruit;
|
if (address === RAM.curFruit) value = this.fruit;
|
||||||
else if (address === RAM.hiddenItemID && this.options.hiddenItem)
|
else if (address === RAM.hiddenItemID && this.options.hiddenItem)
|
||||||
value = this.options.hiddenItem.item;
|
value = this.options.hiddenItem.item;
|
||||||
|
else if (
|
||||||
|
address === RAM.surfingPlayerState &&
|
||||||
|
this.options.fieldMove?.surfState !== undefined
|
||||||
|
)
|
||||||
|
value = this.options.fieldMove.surfState;
|
||||||
|
else if (address === RAM.strengthSpecies)
|
||||||
|
value = this.strengthSpecies;
|
||||||
else throw new UnsupportedScript(offset, opcode);
|
else throw new UnsupportedScript(offset, opcode);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -586,19 +734,42 @@ export class ScriptRun {
|
|||||||
case 0x9b: {
|
case 0x9b: {
|
||||||
// Script_fruittree jumps to FruitTreeScript for FRUITTREE_* id.
|
// Script_fruittree jumps to FruitTreeScript for FRUITTREE_* id.
|
||||||
const tree = byte();
|
const tree = byte();
|
||||||
if (tree < 1 || tree > NUM_FRUIT_TREES) throw new Error("Invalid fruit tree.");
|
if (tree < 1 || tree > NUM_FRUIT_TREES)
|
||||||
|
throw new Error("Invalid fruit tree.");
|
||||||
this.fruitTree = tree;
|
this.fruitTree = tree;
|
||||||
p = SYMBOLS.fruitTreeScript;
|
p = SYMBOLS.fruitTreeScript;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0x1e: {
|
case 0x1e: {
|
||||||
// Only VAR_BATTLETYPE's normal and CANLOSE modes are translated.
|
// loadvar: VAR_BATTLETYPE's normal and CANLOSE modes, and
|
||||||
const variable = byte(), setting = byte();
|
// VAR_MOVEMENT (wPlayerState).
|
||||||
|
const variable = byte(),
|
||||||
|
setting = byte();
|
||||||
|
if (variable === 8) {
|
||||||
|
this.setPlayerState(setting, offset, opcode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (variable !== 3 || (setting !== 0 && setting !== 1))
|
if (variable !== 3 || (setting !== 0 && setting !== 1))
|
||||||
throw new UnsupportedScript(offset, opcode, " (battle type or variable)");
|
throw new UnsupportedScript(
|
||||||
|
offset,
|
||||||
|
opcode,
|
||||||
|
" (battle type or variable)",
|
||||||
|
);
|
||||||
canLose = setting === 1;
|
canLose = setting === 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 0x1d: {
|
||||||
|
// writevar: only VAR_MOVEMENT is writable natively.
|
||||||
|
const variable = byte();
|
||||||
|
if (variable !== 8)
|
||||||
|
throw new UnsupportedScript(
|
||||||
|
offset,
|
||||||
|
opcode,
|
||||||
|
` (variable ${variable})`,
|
||||||
|
);
|
||||||
|
this.setPlayerState(value, offset, opcode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 0x5b:
|
case 0x5b:
|
||||||
trainer = undefined;
|
trainer = undefined;
|
||||||
wild = this.options.wild;
|
wild = this.options.wild;
|
||||||
@@ -621,7 +792,9 @@ export class ScriptRun {
|
|||||||
case 0x62: {
|
case 0x62: {
|
||||||
// trainertext: seen, beaten or loss text from wTempTrainer.
|
// trainertext: seen, beaten or loss text from wTempTrainer.
|
||||||
const header = temp(offset, opcode),
|
const header = temp(offset, opcode),
|
||||||
text = [header.seenText, header.beatenText, header.lostScript][byte()];
|
text = [header.seenText, header.beatenText, header.lostScript][
|
||||||
|
byte()
|
||||||
|
];
|
||||||
if (!text) throw new UnsupportedScript(offset, opcode, " (text)");
|
if (!text) throw new UnsupportedScript(offset, opcode, " (text)");
|
||||||
write(text);
|
write(text);
|
||||||
break;
|
break;
|
||||||
@@ -640,7 +813,8 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
case 0x65: {
|
case 0x65: {
|
||||||
const after = temp(offset, opcode).afterScript;
|
const after = temp(offset, opcode).afterScript;
|
||||||
if (!after) throw new UnsupportedScript(offset, opcode, " (no script)");
|
if (!after)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (no script)");
|
||||||
p = after;
|
p = after;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -660,19 +834,25 @@ export class ScriptRun {
|
|||||||
break;
|
break;
|
||||||
case 0x64: {
|
case 0x64: {
|
||||||
// Null pointers request the battle engine's default end text.
|
// Null pointers request the battle engine's default end text.
|
||||||
const bank = r.bank(p), won = word(), lost = word();
|
const bank = r.bank(p),
|
||||||
|
won = word(),
|
||||||
|
lost = word();
|
||||||
winText = won ? r.address(bank, won) : undefined;
|
winText = won ? r.address(bank, won) : undefined;
|
||||||
lossText = lost ? r.address(bank, lost) : undefined;
|
lossText = lost ? r.address(bank, lost) : undefined;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0x5f: {
|
case 0x5f: {
|
||||||
if (!wild && !trainer) throw new UnsupportedScript(offset, opcode, " (no opponent)");
|
if (!wild && !trainer)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (no opponent)");
|
||||||
yield* flush();
|
yield* flush();
|
||||||
const result = yield synchronous({
|
const result = yield synchronous({
|
||||||
kind: "battle", wild,
|
kind: "battle",
|
||||||
trainer: trainer && { ...trainer, winText, lossText }, canLose,
|
wild,
|
||||||
|
trainer: trainer && { ...trainer, winText, lossText },
|
||||||
|
canLose,
|
||||||
});
|
});
|
||||||
if (typeof result !== "number") throw new Error("Invalid battle result.");
|
if (typeof result !== "number")
|
||||||
|
throw new Error("Invalid battle result.");
|
||||||
battleResult = result;
|
battleResult = result;
|
||||||
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
|
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
|
||||||
break;
|
break;
|
||||||
@@ -719,6 +899,91 @@ export class ScriptRun {
|
|||||||
// blackoutmod writes wLastSpawnMapGroup/wLastSpawnMapNumber.
|
// blackoutmod writes wLastSpawnMapGroup/wLastSpawnMapNumber.
|
||||||
this.state.lastSpawnMap = `${byte()}:${byte()}`;
|
this.state.lastSpawnMap = `${byte()}:${byte()}`;
|
||||||
break;
|
break;
|
||||||
|
case 0x37:
|
||||||
|
case 0x38:
|
||||||
|
// wildon/wildoff: STATUSFLAGS_NO_WILD_ENCOUNTERS_F.
|
||||||
|
this.state.noWildEncounters = opcode === 0x38;
|
||||||
|
break;
|
||||||
|
case 0x3a: {
|
||||||
|
// warpmod: wBackupWarpNumber and wBackupMapGroup/Number.
|
||||||
|
const warp = byte();
|
||||||
|
this.state.backupWarp = { warp, map: `${byte()}:${byte()}` };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x3c:
|
||||||
|
case 0xa3: {
|
||||||
|
// warp and warpfacing: MAPSETUP_WARP with SPAWN_N_A. A zero map
|
||||||
|
// group is the BADWARP fallback, which is not ported.
|
||||||
|
const facing =
|
||||||
|
opcode === 0xa3 ? DIRECTION_ORDER[byte() & 3] : undefined;
|
||||||
|
const group = byte(),
|
||||||
|
number = byte(),
|
||||||
|
x = byte(),
|
||||||
|
y = byte();
|
||||||
|
if (!group)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (bad warp)");
|
||||||
|
yield synchronous({
|
||||||
|
kind: "warp",
|
||||||
|
map: `${group}:${number}`,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
facing,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x8c: {
|
||||||
|
// deactivatefacing: SCRIPT_WAIT for the given number of ticks.
|
||||||
|
const ticks = byte();
|
||||||
|
yield* flush();
|
||||||
|
yield synchronous({ kind: "pause", ticks: ticks || 1 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x8e:
|
||||||
|
yield synchronous({ kind: "warpcheck" });
|
||||||
|
break;
|
||||||
|
case 0x8f: {
|
||||||
|
// stopandsjump: the script resumes at the target on the next tick.
|
||||||
|
const target = near();
|
||||||
|
yield* flush();
|
||||||
|
yield synchronous({ kind: "pause", ticks: 1 });
|
||||||
|
p = target;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x95: {
|
||||||
|
// elevator: Elevator (engine/events/elevator.asm).
|
||||||
|
value = 0;
|
||||||
|
const floors = this.elevatorFloors(near());
|
||||||
|
const origin = floors.findIndex(
|
||||||
|
(floor) => floor.map === this.state.backupWarp?.map,
|
||||||
|
);
|
||||||
|
if (origin < 0) break;
|
||||||
|
write(OVERWORLD.AskFloorElevatorText);
|
||||||
|
const now = readString(
|
||||||
|
r,
|
||||||
|
OVERWORLD.Elevator_CurrentFloorText,
|
||||||
|
this.text(),
|
||||||
|
);
|
||||||
|
const answer = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: [...take(), `${now} ${floors[origin].name}`],
|
||||||
|
items: floors.map((floor) => floor.name),
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof answer !== "number" ||
|
||||||
|
!Number.isInteger(answer) ||
|
||||||
|
answer >= floors.length
|
||||||
|
)
|
||||||
|
throw new Error("Invalid elevator floor.");
|
||||||
|
if (answer < 0 || answer === origin) break;
|
||||||
|
// Elevator_GoToFloor copies the floor's warp and map to the backup.
|
||||||
|
this.state.backupWarp = {
|
||||||
|
warp: floors[answer].warp,
|
||||||
|
map: floors[answer].map,
|
||||||
|
};
|
||||||
|
value = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 0x9c:
|
case 0x9c:
|
||||||
this.state.specialPhoneCall = word();
|
this.state.specialPhoneCall = word();
|
||||||
break;
|
break;
|
||||||
@@ -901,6 +1166,32 @@ export class ScriptRun {
|
|||||||
case 0x36:
|
case 0x36:
|
||||||
this.state.flags.add(word());
|
this.state.flags.add(word());
|
||||||
break;
|
break;
|
||||||
|
case 0x9a: {
|
||||||
|
const which = byte(),
|
||||||
|
data = new DecorationData(r, this.host.data);
|
||||||
|
if (which === 0) {
|
||||||
|
const id = this.state.decorations.poster;
|
||||||
|
let table = OVERWORLD.DecorationDesc_PosterPointers;
|
||||||
|
while (r.byte(table) !== 0xff && r.byte(table) !== id) table += 3;
|
||||||
|
if (r.byte(table) === 0xff) return;
|
||||||
|
p = r.near(table + 1, r.bank(table));
|
||||||
|
} else if (which === 3)
|
||||||
|
p = OVERWORLD["DecorationDesc_GiantOrnament.BigDollScript"];
|
||||||
|
else {
|
||||||
|
const slot = ({ 1: "left", 2: "right", 4: "console" } as const)[
|
||||||
|
which as 1 | 2 | 4
|
||||||
|
];
|
||||||
|
if (!slot) throw new UnsupportedScript(offset, opcode);
|
||||||
|
this.strings[RAM.stringBuffer3] = data.name(
|
||||||
|
this.state.decorations[slot],
|
||||||
|
);
|
||||||
|
p =
|
||||||
|
OVERWORLD[
|
||||||
|
"DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 0x44: {
|
case 0x44: {
|
||||||
const source = near(),
|
const source = near(),
|
||||||
buffer = byte();
|
buffer = byte();
|
||||||
@@ -960,7 +1251,8 @@ export class ScriptRun {
|
|||||||
if (r.word(p) === RAM.movementBuffer) {
|
if (r.word(p) === RAM.movementBuffer) {
|
||||||
word();
|
word();
|
||||||
const buffer = this.movementBuffer;
|
const buffer = this.movementBuffer;
|
||||||
if (!buffer) throw new UnsupportedScript(offset, opcode, " (movement buffer)");
|
if (!buffer)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (movement buffer)");
|
||||||
steps = readMovement({ byte: (index) => buffer[index] }, 0);
|
steps = readMovement({ byte: (index) => buffer[index] }, 0);
|
||||||
} else steps = readMovement(r, near());
|
} else steps = readMovement(r, near());
|
||||||
yield* flush();
|
yield* flush();
|
||||||
@@ -1015,7 +1307,11 @@ export class ScriptRun {
|
|||||||
position = this.host.objectPosition(target);
|
position = this.host.objectPosition(target);
|
||||||
if (target === PLAYER || !position)
|
if (target === PLAYER || !position)
|
||||||
throw new UnsupportedScript(offset, opcode, " (object)");
|
throw new UnsupportedScript(offset, opcode, " (object)");
|
||||||
yield synchronous({ kind: "moveobject", object: target, ...position });
|
yield synchronous({
|
||||||
|
kind: "moveobject",
|
||||||
|
object: target,
|
||||||
|
...position,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0x75: {
|
case 0x75: {
|
||||||
@@ -1038,6 +1334,45 @@ export class ScriptRun {
|
|||||||
yield synchronous({ kind: "turn", object: target, direction });
|
yield synchronous({ kind: "turn", object: target, direction });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 0x79:
|
||||||
|
yield synchronous({ kind: "mapblocks", address: far() });
|
||||||
|
break;
|
||||||
|
case 0x7a: {
|
||||||
|
const x = byte(),
|
||||||
|
y = byte();
|
||||||
|
yield synchronous({ kind: "changeblock", x, y, block: byte() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x7c:
|
||||||
|
break; // refreshmap redraws the screen; native draws every tick.
|
||||||
|
case 0x7d: {
|
||||||
|
// writecmdqueue: the cmdqueue macro's type and data pointer. Only
|
||||||
|
// CMDQUEUE_STONETABLE is used by the game's maps.
|
||||||
|
const entry = near(),
|
||||||
|
type = r.byte(entry);
|
||||||
|
if (type !== 2)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (queue type)");
|
||||||
|
yield synchronous({
|
||||||
|
kind: "stonetable",
|
||||||
|
address: r.near(entry + 1, r.bank(entry)),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x78: {
|
||||||
|
// earthquake: applymovement PLAYER with EarthquakeMovement, its
|
||||||
|
// step_shake set to the operand and step_sleep to its low 6 bits.
|
||||||
|
const displacement = byte();
|
||||||
|
yield* flush();
|
||||||
|
yield synchronous({
|
||||||
|
kind: "move",
|
||||||
|
object: PLAYER,
|
||||||
|
steps: [
|
||||||
|
{ kind: "shake", displacement },
|
||||||
|
{ kind: "sleep", ticks: displacement & 0x3f || 256 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Audio commands are consumed; native sound sequencing is pending.
|
// Audio commands are consumed; native sound sequencing is pending.
|
||||||
case 0x7f:
|
case 0x7f:
|
||||||
case 0x84:
|
case 0x84:
|
||||||
@@ -1159,7 +1494,12 @@ export class ScriptRun {
|
|||||||
name: data.itemName(item),
|
name: data.itemName(item),
|
||||||
price: data.itemPrice(item),
|
price: data.itemPrice(item),
|
||||||
}));
|
}));
|
||||||
const selection = yield { kind: "martlist", mode: "buy", entries, selected: 0 };
|
const selection = yield {
|
||||||
|
kind: "martlist",
|
||||||
|
mode: "buy",
|
||||||
|
entries,
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
const item = typeof selection === "number" ? stock[selection] : undefined;
|
const item = typeof selection === "number" ? stock[selection] : undefined;
|
||||||
if (item === undefined) return;
|
if (item === undefined) return;
|
||||||
const price = data.itemPrice(item);
|
const price = data.itemPrice(item);
|
||||||
@@ -1219,7 +1559,12 @@ export class ScriptRun {
|
|||||||
price: data.itemPrice(slot.item),
|
price: data.itemPrice(slot.item),
|
||||||
owned: slot.quantity,
|
owned: slot.quantity,
|
||||||
}));
|
}));
|
||||||
const selection = yield { kind: "martlist", mode: "sell", entries, selected: 0 };
|
const selection = yield {
|
||||||
|
kind: "martlist",
|
||||||
|
mode: "sell",
|
||||||
|
entries,
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
const slot = typeof selection === "number" ? slots[selection] : undefined;
|
const slot = typeof selection === "number" ? slots[selection] : undefined;
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
if (data.itemUnsellable(slot.item)) {
|
if (data.itemUnsellable(slot.item)) {
|
||||||
@@ -1252,6 +1597,152 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** _PlayerDecorationMenu: owned categories, replacement and ornament sides. */
|
||||||
|
private *decorationMenu(): Generator<
|
||||||
|
ScriptEffect,
|
||||||
|
boolean,
|
||||||
|
number | string | undefined
|
||||||
|
> {
|
||||||
|
const data = new DecorationData(this.reader, this.host.data);
|
||||||
|
const categories = [
|
||||||
|
{ slot: "bed", action: 1, index: 0 },
|
||||||
|
{ slot: "carpet", action: 3, index: 1 },
|
||||||
|
{ slot: "plant", action: 5, index: 2 },
|
||||||
|
{ slot: "poster", action: 7, index: 3 },
|
||||||
|
{ slot: "console", action: 9, index: 4 },
|
||||||
|
{ slot: "left", action: 13, index: 5 },
|
||||||
|
{ slot: "bigDoll", action: 11, index: 6 },
|
||||||
|
] as const;
|
||||||
|
let changed = false;
|
||||||
|
for (;;) {
|
||||||
|
const owned = categories
|
||||||
|
.map((category) => ({
|
||||||
|
...category,
|
||||||
|
ids: data.owned(this.state.events, category.action),
|
||||||
|
}))
|
||||||
|
.filter((category) => category.ids.length);
|
||||||
|
const names = owned.map((category) => {
|
||||||
|
const p =
|
||||||
|
OVERWORLD["_PlayerDecorationMenu.category_pointers"] +
|
||||||
|
category.index * 4 +
|
||||||
|
2;
|
||||||
|
return readString(
|
||||||
|
this.reader,
|
||||||
|
this.reader.near(p, this.reader.bank(p)),
|
||||||
|
this.text(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const choice = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: ["DECORATION"],
|
||||||
|
items: [...names, "EXIT"],
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
|
if (typeof choice !== "number" || choice < 0 || choice >= owned.length)
|
||||||
|
return changed;
|
||||||
|
const category = owned[choice];
|
||||||
|
const picked = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: [names[choice]],
|
||||||
|
items: [
|
||||||
|
...category.ids.map((id) => data.name(id)),
|
||||||
|
data.name(1),
|
||||||
|
data.name(0),
|
||||||
|
],
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof picked !== "number" ||
|
||||||
|
picked < 0 ||
|
||||||
|
picked > category.ids.length
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const id = category.ids[picked] ?? 0;
|
||||||
|
let slot: keyof Decorations = category.slot;
|
||||||
|
if (slot === "left") {
|
||||||
|
const side = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: [
|
||||||
|
readText(
|
||||||
|
this.reader,
|
||||||
|
id
|
||||||
|
? OVERWORLD.WhichSidePutOnText
|
||||||
|
: OVERWORLD.WhichSidePutAwayText,
|
||||||
|
this.text(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
items: ["RIGHT SIDE", "LEFT SIDE", "CANCEL"],
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
|
if (side !== 0 && side !== 1) continue;
|
||||||
|
slot = side === 0 ? "right" : "left";
|
||||||
|
}
|
||||||
|
const old = this.state.decorations[slot];
|
||||||
|
let text: number;
|
||||||
|
if (id === old)
|
||||||
|
text = id ? OVERWORLD.AlreadySetUpText : OVERWORLD.NothingToPutAwayText;
|
||||||
|
else {
|
||||||
|
this.state.decorations[slot] = id;
|
||||||
|
if (id && (slot === "left" || slot === "right")) {
|
||||||
|
const other = slot === "left" ? "right" : "left";
|
||||||
|
if (this.state.decorations[other] === id)
|
||||||
|
this.state.decorations[other] = 0;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
this.strings[RAM.stringBuffer3] = data.name(old || id);
|
||||||
|
this.strings[RAM.stringBuffer4] = data.name(id);
|
||||||
|
text = !id
|
||||||
|
? OVERWORLD.PutAwayTheDecoText
|
||||||
|
: old
|
||||||
|
? OVERWORLD.PutAwayAndSetUpText
|
||||||
|
: OVERWORLD.SetUpTheDecoText;
|
||||||
|
}
|
||||||
|
yield { kind: "text", pages: [readText(this.reader, text, this.text())] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VAR_MOVEMENT: only the states GetPlayerSprite knows. */
|
||||||
|
private setPlayerState(state: number, offset: number, opcode: number) {
|
||||||
|
if (![0, 1, 2, 4, 8].includes(state))
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (player state)");
|
||||||
|
this.state.playerState = state;
|
||||||
|
}
|
||||||
|
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
|
||||||
|
private partyMove(move: number): number {
|
||||||
|
return this.state.party.findIndex(
|
||||||
|
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** GetPartyNickname: wCurPartyMon's nickname into wStringBuffer1-3. */
|
||||||
|
private partyNickname() {
|
||||||
|
const name = this.state.party[this.curPartyMon]?.nickname ?? "";
|
||||||
|
this.strings[RAM.stringBuffer1] = name;
|
||||||
|
this.strings[RAM.stringBuffer2] = name;
|
||||||
|
this.strings[RAM.stringBuffer3] = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An elevator's floors (elevfloor: floor, warp, map), from the count
|
||||||
|
* that precedes them; names come from ElevatorFloorNames. */
|
||||||
|
private elevatorFloors(data: number) {
|
||||||
|
const r = this.reader,
|
||||||
|
count = r.byte(data);
|
||||||
|
if (!count || count > 15) throw new Error("Invalid elevator data.");
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const entry = data + 1 + index * 4,
|
||||||
|
floor = r.byte(entry);
|
||||||
|
const names = OVERWORLD.ElevatorFloorNames;
|
||||||
|
return {
|
||||||
|
name: readString(
|
||||||
|
r,
|
||||||
|
r.near(names + floor * 2, r.bank(names)),
|
||||||
|
this.text(),
|
||||||
|
),
|
||||||
|
warp: r.byte(entry + 1),
|
||||||
|
map: `${r.byte(entry + 2)}:${r.byte(entry + 3)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** readvar: the script variables that native state can answer. */
|
/** readvar: the script variables that native state can answer. */
|
||||||
private readVar(id: number, offset: number): number {
|
private readVar(id: number, offset: number): number {
|
||||||
const clock = this.host.clock();
|
const clock = this.host.clock();
|
||||||
@@ -1261,6 +1752,21 @@ export class ScriptRun {
|
|||||||
return this.state.party.length;
|
return this.state.party.length;
|
||||||
case 0x04:
|
case 0x04:
|
||||||
return this.host.timeOfDay();
|
return this.host.timeOfDay();
|
||||||
|
case 0x07: {
|
||||||
|
// VAR_BADGES: Johto and Kanto badges together.
|
||||||
|
let badges = 0;
|
||||||
|
for (
|
||||||
|
let flag = OW.ENGINE_ZEPHYRBADGE;
|
||||||
|
flag < OW.ENGINE_ZEPHYRBADGE + 16;
|
||||||
|
flag++
|
||||||
|
)
|
||||||
|
if (this.state.flags.has(flag)) badges++;
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
case 0x08:
|
||||||
|
return this.state.playerState;
|
||||||
|
case 0x0f:
|
||||||
|
return this.host.environment();
|
||||||
case 0x05:
|
case 0x05:
|
||||||
return this.state.pokedex.caught.size & 0xff;
|
return this.state.pokedex.caught.size & 0xff;
|
||||||
case 0x06:
|
case 0x06:
|
||||||
@@ -1308,11 +1814,18 @@ export class ScriptRun {
|
|||||||
const fallback = readString(r, SYMBOLS.rivalDefaultName, this.text());
|
const fallback = readString(r, SYMBOLS.rivalDefaultName, this.text());
|
||||||
const name = yield {
|
const name = yield {
|
||||||
kind: "name",
|
kind: "name",
|
||||||
pages: [...take(), readString(r, SYMBOLS.rivalNameString, this.text())],
|
pages: [
|
||||||
|
...take(),
|
||||||
|
readString(r, SYMBOLS.rivalNameString, this.text()),
|
||||||
|
],
|
||||||
current: fallback,
|
current: fallback,
|
||||||
max: 7,
|
max: 7,
|
||||||
};
|
};
|
||||||
if (typeof name !== "string" || name.length > 7 || (name && !NICKNAME_PATTERN.test(name)))
|
if (
|
||||||
|
typeof name !== "string" ||
|
||||||
|
name.length > 7 ||
|
||||||
|
(name && !NICKNAME_PATTERN.test(name))
|
||||||
|
)
|
||||||
throw new Error("Invalid rival name.");
|
throw new Error("Invalid rival name.");
|
||||||
// InitName: a name of zero or more spaces takes the default.
|
// InitName: a name of zero or more spaces takes the default.
|
||||||
this.state.rivalName = /^ *$/.test(name) ? fallback : name;
|
this.state.rivalName = /^ *$/.test(name) ? fallback : name;
|
||||||
@@ -1327,6 +1840,16 @@ export class ScriptRun {
|
|||||||
// StubbedTrainerRankings_Healings begins with `ret`: the Trainer
|
// StubbedTrainerRankings_Healings begins with `ret`: the Trainer
|
||||||
// Rankings feature (Pokémon News) was cut, leaving a dead stub.
|
// Rankings feature (Pokémon News) was cut, leaving a dead stub.
|
||||||
return value;
|
return value;
|
||||||
|
case OVERWORLD.UpdatePlayerSprite:
|
||||||
|
case OVERWORLD.PlayMapMusic:
|
||||||
|
case OVERWORLD.UpdateTimePals:
|
||||||
|
// The native renderer draws the player from wPlayerState each frame;
|
||||||
|
// music and palette fades are presentation.
|
||||||
|
return value;
|
||||||
|
case OVERWORLD.SurfStartStep:
|
||||||
|
// slow_step in the facing direction, then step_end.
|
||||||
|
this.movementBuffer = [0x08 | this.host.facing(), 0x47];
|
||||||
|
return value;
|
||||||
case SPECIAL_ROUTINES.healMachineAnim:
|
case SPECIAL_ROUTINES.healMachineAnim:
|
||||||
case SPECIAL_ROUTINES.fadeOutToBlack:
|
case SPECIAL_ROUTINES.fadeOutToBlack:
|
||||||
case SPECIAL_ROUTINES.fadeInFromBlack:
|
case SPECIAL_ROUTINES.fadeInFromBlack:
|
||||||
@@ -1345,7 +1868,9 @@ export class ScriptRun {
|
|||||||
base = this.host.data.move(move).pp;
|
base = this.host.data.move(move).pp;
|
||||||
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
|
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
|
||||||
// maxes at 61 instead of overflowing the packed byte at 64.
|
// maxes at 61 instead of overflowing the packed byte at 64.
|
||||||
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
|
return (
|
||||||
|
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
|
||||||
|
);
|
||||||
}) as PartyMon["pp"];
|
}) as PartyMon["pp"];
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
@@ -1409,22 +1934,68 @@ export class ScriptRun {
|
|||||||
pages.push(`${time}${text.startsWith(",") ? "" : " "}${text}`);
|
pages.push(`${time}${text.startsWith(",") ? "" : " "}${text}`);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
case SPECIAL_ROUTINES.toggleDecorationsVisibility:
|
case OVERWORLD.OverworldTownMap:
|
||||||
// No decorations are placed until the native decoration system exists,
|
yield { kind: "townmap" };
|
||||||
// so every decoration object is hidden, as with empty wDeco* values.
|
return value;
|
||||||
for (const event of [
|
case OVERWORLD.PlayersHousePC: {
|
||||||
|
take();
|
||||||
|
const changed = yield* this.decorationMenu();
|
||||||
|
if (changed)
|
||||||
|
yield {
|
||||||
|
kind: "warp",
|
||||||
|
map: this.host.mapId,
|
||||||
|
...this.host.position(),
|
||||||
|
facing: DIRECTION_ORDER[this.host.facing()],
|
||||||
|
};
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case SPECIAL_ROUTINES.toggleDecorationsVisibility: {
|
||||||
|
const data = new DecorationData(r, this.host.data);
|
||||||
|
const slots = ["console", "left", "right", "bigDoll"] as const;
|
||||||
|
const events = [
|
||||||
EVENTS.playersHouseConsole,
|
EVENTS.playersHouseConsole,
|
||||||
EVENTS.playersHouseDoll1,
|
EVENTS.playersHouseDoll1,
|
||||||
EVENTS.playersHouseDoll2,
|
EVENTS.playersHouseDoll2,
|
||||||
EVENTS.playersHouseBigDoll,
|
EVENTS.playersHouseBigDoll,
|
||||||
|
];
|
||||||
|
slots.forEach((slot, i) => {
|
||||||
|
const id = this.state.decorations[slot];
|
||||||
|
if (id) {
|
||||||
|
this.state.events.delete(events[i]);
|
||||||
|
this.state.variables[i] = data.attribute(id).sprite;
|
||||||
|
} else this.state.events.add(events[i]);
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
case SPECIAL_ROUTINES.toggleMaptileDecorations: {
|
||||||
|
const data = new DecorationData(r, this.host.data),
|
||||||
|
deco = this.state.decorations;
|
||||||
|
for (const [slot, x, y] of [
|
||||||
|
["bed", 0, 4],
|
||||||
|
["plant", 7, 4],
|
||||||
|
["poster", 6, 0],
|
||||||
|
] as const)
|
||||||
|
if (deco[slot])
|
||||||
|
yield {
|
||||||
|
kind: "changeblock",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
block: data.attribute(deco[slot]).sprite,
|
||||||
|
};
|
||||||
|
if (deco.poster) this.state.events.add(EVENTS.playersRoomPoster);
|
||||||
|
else this.state.events.delete(EVENTS.playersRoomPoster);
|
||||||
|
if (deco.carpet) {
|
||||||
|
const block = data.attribute(deco.carpet).sprite;
|
||||||
|
for (const [x, y, delta] of [
|
||||||
|
[0, 0, 0],
|
||||||
|
[0, 2, 1],
|
||||||
|
[2, 2, 2],
|
||||||
|
[4, 2, 1],
|
||||||
])
|
])
|
||||||
this.state.events.add(event);
|
yield { kind: "changeblock", x, y, block: block + delta };
|
||||||
return value;
|
}
|
||||||
case SPECIAL_ROUTINES.toggleMaptileDecorations:
|
|
||||||
// Empty decoration slots leave the room blocks unchanged and clear
|
|
||||||
// the poster's conditional event.
|
|
||||||
this.state.events.delete(EVENTS.playersRoomPoster);
|
|
||||||
return value;
|
return value;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -1454,6 +2025,8 @@ export function runScript(
|
|||||||
facing: () => 0,
|
facing: () => 0,
|
||||||
position: () => ({ x: 0, y: 0 }),
|
position: () => ({ x: 0, y: 0 }),
|
||||||
objectPosition: () => undefined,
|
objectPosition: () => undefined,
|
||||||
|
playerCollision: () => 0,
|
||||||
|
environment: () => 0,
|
||||||
});
|
});
|
||||||
const pages: string[] = [];
|
const pages: string[] = [];
|
||||||
for (let effect = run.resume(); effect; effect = run.resume()) {
|
for (let effect = run.resume(); effect; effect = run.resume()) {
|
||||||
|
|||||||
@@ -1,18 +1,64 @@
|
|||||||
import { RomReader } from "../rom/reader";
|
import { RomReader } from "../rom/reader";
|
||||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||||
|
|
||||||
|
/** Poses a movement command holds for a number of ticks (OBJECT_ACTION and
|
||||||
|
* the step functions that drive it in engine/overworld/map_objects.asm). */
|
||||||
|
export type MovementPose =
|
||||||
|
/** step_dig: SetFacingCounterclockwiseSpin in place. */
|
||||||
|
| "spin"
|
||||||
|
/** return_dig: StepFunction_DigTo alternates spinning and flickering. */
|
||||||
|
| "spin-flicker"
|
||||||
|
/** rock_smash: StepFunction_RockSmash flickers the object. */
|
||||||
|
| "flicker"
|
||||||
|
/** tree_shake: OBJECT_ACTION_WEIRD_TREE. */
|
||||||
|
| "tree"
|
||||||
|
/** fish_got_bite: StepFunction_GotBite jiggles the rod. */
|
||||||
|
| "fish-bite"
|
||||||
|
/** fish_cast_rod: OBJECT_ACTION_FISHING for one tick. */
|
||||||
|
| "fish-cast"
|
||||||
|
/** skyfall: invisible for 15 ticks, then falls from above in 16. */
|
||||||
|
| "skyfall"
|
||||||
|
/** skyfall_top: 16 ticks in the air (SetFacingSkyfall). */
|
||||||
|
| "skyfall-top"
|
||||||
|
/** teleport_from: spins for 16 ticks, then spins while rising for 16. */
|
||||||
|
| "teleport-from"
|
||||||
|
/** teleport_to: waits 16, descends spinning 16, then spins 16. */
|
||||||
|
| "teleport-to";
|
||||||
|
|
||||||
/** One command from the game's own movement data (macros/scripts/movement.asm).
|
/** One command from the game's own movement data (macros/scripts/movement.asm).
|
||||||
* Durations are overworld ticks: the original loop advances objects once per
|
* Durations are overworld ticks: the original loop advances objects once per
|
||||||
* two video frames. Commands without a duration take effect immediately. */
|
* two video frames. Commands without a duration take effect immediately. */
|
||||||
export type MovementStep =
|
export type MovementStep =
|
||||||
| { kind: "turn"; direction: Direction; ticks: 1 }
|
| { kind: "turn"; direction: Direction; ticks: 1 }
|
||||||
| { kind: "step"; direction: Direction; ticks: number; animate: boolean }
|
/** turn_step: StepFunction_Turn faces the new direction after one tick
|
||||||
|
* and reads the next command on the fourth. */
|
||||||
|
| { kind: "turn-step"; direction: Direction; ticks: 3 }
|
||||||
|
/** Steps, slides (`animate` false) and the spinning turn_away, turn_in and
|
||||||
|
* turn_waterfall steps (`spin`). */
|
||||||
|
| { kind: "step"; direction: Direction; ticks: number; animate: boolean; spin?: boolean }
|
||||||
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
|
/** Two tiles in two phases of `ticks` each, with a shadow and arc. */
|
||||||
| { kind: "jump"; direction: Direction; ticks: number }
|
| { kind: "jump"; direction: Direction; ticks: number }
|
||||||
| { kind: "sleep"; ticks: number }
|
| { kind: "sleep"; ticks: number }
|
||||||
|
/** A pose held in place for `ticks`. */
|
||||||
|
| { kind: "pose"; pose: MovementPose; ticks: number }
|
||||||
|
/** step_bump: one tick walking in place. */
|
||||||
|
| { kind: "bump"; ticks: 1 }
|
||||||
| { kind: "fix-facing"; fixed: boolean }
|
| { kind: "fix-facing"; fixed: boolean }
|
||||||
|
/** set_sliding/remove_sliding: SLIDING_F shows no walking frames. */
|
||||||
|
| { kind: "sliding"; sliding: boolean }
|
||||||
| { kind: "visibility"; hidden: boolean }
|
| { kind: "visibility"; hidden: boolean }
|
||||||
| { kind: "emote"; shown: boolean };
|
| { kind: "emote"; shown: boolean }
|
||||||
|
/** step_shake: ShakeScreen with the given displacement byte. */
|
||||||
|
| { kind: "shake"; displacement: number }
|
||||||
|
/** step_wait_end: the script continues at once; the object sleeps, then
|
||||||
|
* returns to its own movement. */
|
||||||
|
| { kind: "wait-end"; ticks: number }
|
||||||
|
/** remove_object: DeleteMapObject; the script continues. */
|
||||||
|
| { kind: "remove" }
|
||||||
|
/** step_stop: the object stands for good; the script continues. */
|
||||||
|
| { kind: "stop" }
|
||||||
|
/** step_loop: start the movement data over, forever. */
|
||||||
|
| { kind: "loop" };
|
||||||
|
|
||||||
export class UnsupportedMovement extends Error {
|
export class UnsupportedMovement extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -27,9 +73,10 @@ export class UnsupportedMovement extends Error {
|
|||||||
|
|
||||||
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
|
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
|
||||||
|
|
||||||
/** Decode movement data until step_end. Unknown commands abort the script
|
/** Decode movement data until step_end or another command that ends it.
|
||||||
* rather than approximating a jump, slide or special effect. The source is
|
* Unknown commands abort the script rather than approximating an effect.
|
||||||
* usually the ROM; wMovementBuffer data built natively decodes the same way. */
|
* The source is usually the ROM; wMovementBuffer data built natively
|
||||||
|
* decodes the same way. */
|
||||||
export function readMovement(
|
export function readMovement(
|
||||||
reader: Pick<RomReader, "byte">,
|
reader: Pick<RomReader, "byte">,
|
||||||
start: number,
|
start: number,
|
||||||
@@ -41,14 +88,16 @@ export function readMovement(
|
|||||||
command = reader.byte(p++);
|
command = reader.byte(p++);
|
||||||
const direction = DIRECTION_ORDER[command & 3];
|
const direction = DIRECTION_ORDER[command & 3];
|
||||||
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
|
if (command < 0x04) steps.push({ kind: "turn", direction, ticks: 1 });
|
||||||
else if (command >= 0x08 && command < 0x14)
|
else if (command < 0x08)
|
||||||
|
steps.push({ kind: "turn-step", direction, ticks: 3 });
|
||||||
|
else if (command < 0x14)
|
||||||
steps.push({
|
steps.push({
|
||||||
kind: "step",
|
kind: "step",
|
||||||
direction,
|
direction,
|
||||||
ticks: STEP_TICKS[(command - 0x08) >> 2],
|
ticks: STEP_TICKS[(command - 0x08) >> 2],
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
else if (command >= 0x14 && command < 0x20)
|
else if (command < 0x20)
|
||||||
// Slides move without the walking animation (e.g. being pushed).
|
// Slides move without the walking animation (e.g. being pushed).
|
||||||
steps.push({
|
steps.push({
|
||||||
kind: "step",
|
kind: "step",
|
||||||
@@ -56,12 +105,23 @@ export function readMovement(
|
|||||||
ticks: STEP_TICKS[(command - 0x14) >> 2],
|
ticks: STEP_TICKS[(command - 0x14) >> 2],
|
||||||
animate: false,
|
animate: false,
|
||||||
});
|
});
|
||||||
else if (command >= 0x2c && command < 0x38)
|
else if (command < 0x2c)
|
||||||
|
// turn_away, turn_in, turn_waterfall: TurningStep spins while moving.
|
||||||
|
steps.push({
|
||||||
|
kind: "step",
|
||||||
|
direction,
|
||||||
|
ticks: STEP_TICKS[(command - 0x20) >> 2],
|
||||||
|
animate: true,
|
||||||
|
spin: true,
|
||||||
|
});
|
||||||
|
else if (command < 0x38)
|
||||||
steps.push({
|
steps.push({
|
||||||
kind: "jump",
|
kind: "jump",
|
||||||
direction,
|
direction,
|
||||||
ticks: STEP_TICKS[(command - 0x2c) >> 2],
|
ticks: STEP_TICKS[(command - 0x2c) >> 2],
|
||||||
});
|
});
|
||||||
|
else if (command === 0x38 || command === 0x39)
|
||||||
|
steps.push({ kind: "sliding", sliding: command === 0x39 });
|
||||||
else if (command === 0x3a || command === 0x3b)
|
else if (command === 0x3a || command === 0x3b)
|
||||||
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
|
steps.push({ kind: "fix-facing", fixed: command === 0x3b });
|
||||||
else if (command === 0x3c || command === 0x3d)
|
else if (command === 0x3c || command === 0x3d)
|
||||||
@@ -69,10 +129,45 @@ export function readMovement(
|
|||||||
else if (command >= 0x3e && command <= 0x45)
|
else if (command >= 0x3e && command <= 0x45)
|
||||||
steps.push({ kind: "sleep", ticks: command - 0x3d });
|
steps.push({ kind: "sleep", ticks: command - 0x3d });
|
||||||
else if (command === 0x46)
|
else if (command === 0x46)
|
||||||
steps.push({ kind: "sleep", ticks: reader.byte(p++) });
|
steps.push({ kind: "sleep", ticks: reader.byte(p++) || 256 });
|
||||||
else if (command === 0x47) return steps;
|
else if (command === 0x47) return steps;
|
||||||
|
else if (command === 0x48) {
|
||||||
|
steps.push({ kind: "wait-end", ticks: reader.byte(p++) || 256 });
|
||||||
|
return steps;
|
||||||
|
} else if (command === 0x49) {
|
||||||
|
steps.push({ kind: "remove" });
|
||||||
|
return steps;
|
||||||
|
} else if (command === 0x4a) {
|
||||||
|
steps.push({ kind: "loop" });
|
||||||
|
return steps;
|
||||||
|
} else if (command === 0x4b) {
|
||||||
|
steps.push({ kind: "stop" });
|
||||||
|
return steps;
|
||||||
|
} else if (command === 0x4c)
|
||||||
|
steps.push({ kind: "pose", pose: "teleport-from", ticks: 32 });
|
||||||
|
else if (command === 0x4d)
|
||||||
|
steps.push({ kind: "pose", pose: "teleport-to", ticks: 49 });
|
||||||
|
else if (command === 0x4e)
|
||||||
|
steps.push({ kind: "pose", pose: "skyfall", ticks: 31 });
|
||||||
|
else if (command === 0x4f)
|
||||||
|
steps.push({ kind: "pose", pose: "spin", ticks: reader.byte(p++) || 256 });
|
||||||
|
else if (command === 0x50) steps.push({ kind: "bump", ticks: 1 });
|
||||||
|
else if (command === 0x51)
|
||||||
|
steps.push({ kind: "pose", pose: "fish-bite", ticks: 8 });
|
||||||
|
else if (command === 0x52)
|
||||||
|
steps.push({ kind: "pose", pose: "fish-cast", ticks: 1 });
|
||||||
else if (command === 0x53 || command === 0x54)
|
else if (command === 0x53 || command === 0x54)
|
||||||
steps.push({ kind: "emote", shown: command === 0x54 });
|
steps.push({ kind: "emote", shown: command === 0x54 });
|
||||||
|
else if (command === 0x55)
|
||||||
|
steps.push({ kind: "shake", displacement: reader.byte(p++) });
|
||||||
|
else if (command === 0x56)
|
||||||
|
steps.push({ kind: "pose", pose: "tree", ticks: 24 });
|
||||||
|
else if (command === 0x57)
|
||||||
|
steps.push({ kind: "pose", pose: "flicker", ticks: reader.byte(p++) || 256 });
|
||||||
|
else if (command === 0x58)
|
||||||
|
steps.push({ kind: "pose", pose: "spin-flicker", ticks: reader.byte(p++) || 256 });
|
||||||
|
else if (command === 0x59)
|
||||||
|
steps.push({ kind: "pose", pose: "skyfall-top", ticks: 16 });
|
||||||
else throw new UnsupportedMovement(offset, command);
|
else throw new UnsupportedMovement(offset, command);
|
||||||
}
|
}
|
||||||
throw new Error("Movement data is unterminated.");
|
throw new Error("Movement data is unterminated.");
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/** Overworld constants keyed by their PRET names, so that
|
||||||
|
* `npm run check:reference` verifies every value against the pinned
|
||||||
|
* disassembly (constants/*.asm). */
|
||||||
|
export const OW = {
|
||||||
|
// Collision types (constants/collision_constants.asm).
|
||||||
|
COLL_CUT_TREE: 0x12,
|
||||||
|
COLL_CUT_TREE_1A: 0x1a,
|
||||||
|
COLL_TALL_GRASS_10: 0x10,
|
||||||
|
COLL_TALL_GRASS: 0x18,
|
||||||
|
COLL_LONG_GRASS: 0x14,
|
||||||
|
COLL_LONG_GRASS_1C: 0x1c,
|
||||||
|
COLL_HEADBUTT_TREE: 0x15,
|
||||||
|
COLL_HEADBUTT_TREE_1D: 0x1d,
|
||||||
|
COLL_ICE: 0x23,
|
||||||
|
COLL_ICE_2B: 0x2b,
|
||||||
|
COLL_WHIRLPOOL: 0x24,
|
||||||
|
COLL_WHIRLPOOL_2C: 0x2c,
|
||||||
|
COLL_WATERFALL: 0x33,
|
||||||
|
COLL_CURRENT_DOWN: 0x3b,
|
||||||
|
COLL_PIT: 0x60,
|
||||||
|
COLL_PIT_68: 0x68,
|
||||||
|
COLL_WARP_CARPET_DOWN: 0x70,
|
||||||
|
COLL_DOOR: 0x71,
|
||||||
|
COLL_WARP_CARPET_LEFT: 0x76,
|
||||||
|
COLL_WARP_CARPET_UP: 0x78,
|
||||||
|
COLL_DOOR_79: 0x79,
|
||||||
|
COLL_STAIRCASE: 0x7a,
|
||||||
|
COLL_CAVE: 0x7b,
|
||||||
|
COLL_WARP_CARPET_RIGHT: 0x7e,
|
||||||
|
COLL_COUNTER: 0x90,
|
||||||
|
HI_NYBBLE_CURRENT: 0x30,
|
||||||
|
HI_NYBBLE_WALK: 0x40,
|
||||||
|
HI_NYBBLE_WALK_ALT: 0x50,
|
||||||
|
HI_NYBBLE_WARPS: 0x70,
|
||||||
|
HI_NYBBLE_LEDGES: 0xa0,
|
||||||
|
HI_NYBBLE_SIDE_WALLS: 0xb0,
|
||||||
|
LAND_TILE: 0x00,
|
||||||
|
WATER_TILE: 0x01,
|
||||||
|
// wPlayerState (constants/ram_constants.asm).
|
||||||
|
PLAYER_NORMAL: 0,
|
||||||
|
PLAYER_BIKE: 1,
|
||||||
|
PLAYER_SKATE: 2,
|
||||||
|
PLAYER_SURF: 4,
|
||||||
|
PLAYER_SURF_PIKA: 8,
|
||||||
|
// Engine flags backed by wBikeFlags (constants/engine_flags.asm).
|
||||||
|
ENGINE_FOREST_IS_RESTLESS: 100,
|
||||||
|
ENGINE_STRENGTH_ACTIVE: 24,
|
||||||
|
ENGINE_ALWAYS_ON_BIKE: 25,
|
||||||
|
ENGINE_DOWNHILL: 26,
|
||||||
|
ENGINE_ZEPHYRBADGE: 27,
|
||||||
|
ENGINE_HIVEBADGE: 28,
|
||||||
|
ENGINE_PLAINBADGE: 29,
|
||||||
|
ENGINE_FOGBADGE: 30,
|
||||||
|
ENGINE_GLACIERBADGE: 33,
|
||||||
|
ENGINE_RISINGBADGE: 34,
|
||||||
|
// Map environments (constants/map_data_constants.asm).
|
||||||
|
TOWN: 1,
|
||||||
|
ROUTE: 2,
|
||||||
|
INDOOR: 3,
|
||||||
|
CAVE: 4,
|
||||||
|
ENVIRONMENT_5: 5,
|
||||||
|
GATE: 6,
|
||||||
|
DUNGEON: 7,
|
||||||
|
// Tilesets (constants/tileset_constants.asm).
|
||||||
|
NUM_TILESETS: 36,
|
||||||
|
TILESET_JOHTO: 1,
|
||||||
|
TILESET_JOHTO_MODERN: 2,
|
||||||
|
TILESET_KANTO: 3,
|
||||||
|
TILESET_HOUSE: 5,
|
||||||
|
TILESET_POKECENTER: 7,
|
||||||
|
TILESET_PORT: 9,
|
||||||
|
TILESET_FOREST: 31,
|
||||||
|
TILESET_PARK: 25,
|
||||||
|
TILESET_POKECOM_CENTER: 21,
|
||||||
|
TILESET_BATTLE_TOWER_INSIDE: 22,
|
||||||
|
TILESET_RADIO_TOWER: 27,
|
||||||
|
TILESET_MANSION: 13,
|
||||||
|
TILESET_ICE_PATH: 29,
|
||||||
|
TILESET_ELITE_FOUR_ROOM: 15,
|
||||||
|
TILESET_TOWER: 23,
|
||||||
|
TILESET_CAVE: 24,
|
||||||
|
TILESET_DARK_CAVE: 30,
|
||||||
|
// Map palettes (constants/map_data_constants.asm).
|
||||||
|
PALETTE_DARK: 4,
|
||||||
|
// Regions (constants/landmark_constants.asm).
|
||||||
|
LANDMARK_SPECIAL: 0,
|
||||||
|
LANDMARK_FAST_SHIP: 95,
|
||||||
|
KANTO_LANDMARK: 47,
|
||||||
|
COLL_WATER: 0x29,
|
||||||
|
// Sprite ranges (constants/sprite_constants.asm).
|
||||||
|
NUM_OVERWORLD_SPRITES: 102,
|
||||||
|
SPRITE_POKEMON: 0x80,
|
||||||
|
NUM_POKEMON_SPRITES: 35,
|
||||||
|
SPRITE_VARS: 0xf0,
|
||||||
|
// Moves used as field moves (constants/move_constants.asm).
|
||||||
|
CUT: 0x0f,
|
||||||
|
SURF: 0x39,
|
||||||
|
STRENGTH: 0x46,
|
||||||
|
WHIRLPOOL: 250,
|
||||||
|
WATERFALL: 127,
|
||||||
|
HEADBUTT: 29,
|
||||||
|
BICYCLE: 7,
|
||||||
|
PIKACHU: 25,
|
||||||
|
EGG: 0xfd,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Directions in the original's order (DOWN, UP, LEFT, RIGHT). */
|
||||||
|
export const DOWN = 0,
|
||||||
|
UP = 1,
|
||||||
|
LEFT = 2,
|
||||||
|
RIGHT = 3;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { RomReader } from "../rom/reader";
|
||||||
|
import { OVERWORLD } from "../rom/profile";
|
||||||
|
import { PokemonData } from "../rom/pokemon";
|
||||||
|
import { defaultTextContext, readString } from "../scripts/text";
|
||||||
|
|
||||||
|
export const DECORATION_SLOTS = [
|
||||||
|
"bed",
|
||||||
|
"carpet",
|
||||||
|
"plant",
|
||||||
|
"poster",
|
||||||
|
"console",
|
||||||
|
"bigDoll",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
] as const;
|
||||||
|
export type DecorationSlot = (typeof DECORATION_SLOTS)[number];
|
||||||
|
export type Decorations = Record<DecorationSlot, number>;
|
||||||
|
export const emptyDecorations = (): Decorations => ({
|
||||||
|
bed: 0,
|
||||||
|
carpet: 0,
|
||||||
|
plant: 0,
|
||||||
|
poster: 0,
|
||||||
|
console: 0,
|
||||||
|
bigDoll: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
});
|
||||||
|
export const initialDecorations = (): Decorations => ({
|
||||||
|
...emptyDecorations(),
|
||||||
|
bed: 2,
|
||||||
|
poster: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DecorationAttributes and GetDecoName, read from the user's ROM. */
|
||||||
|
export class DecorationData {
|
||||||
|
constructor(
|
||||||
|
private reader: RomReader,
|
||||||
|
private pokemon: PokemonData,
|
||||||
|
) {}
|
||||||
|
attribute(id: number) {
|
||||||
|
if (!Number.isInteger(id) || id < 0 || id > 52)
|
||||||
|
throw new Error("Invalid decoration.");
|
||||||
|
const p = OVERWORLD.DecorationAttributes + id * 6,
|
||||||
|
r = this.reader;
|
||||||
|
return {
|
||||||
|
type: r.byte(p),
|
||||||
|
name: r.byte(p + 1),
|
||||||
|
action: r.byte(p + 2),
|
||||||
|
event: r.word(p + 3),
|
||||||
|
sprite: r.byte(p + 5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private word(index: number) {
|
||||||
|
let p = OVERWORLD.DecorationNames;
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
while (this.reader.byte(p++) !== 0x50) {}
|
||||||
|
}
|
||||||
|
return readString(this.reader, p, defaultTextContext("PLAYER"));
|
||||||
|
}
|
||||||
|
name(id: number) {
|
||||||
|
const a = this.attribute(id);
|
||||||
|
if (a.type === 1) return this.word(a.name);
|
||||||
|
if (a.type === 2 || a.type === 3)
|
||||||
|
return this.word(a.name) + this.word(a.type === 2 ? 13 : 14);
|
||||||
|
const mon = this.pokemon.speciesName(a.name);
|
||||||
|
return a.type === 6
|
||||||
|
? this.word(17) + mon
|
||||||
|
: mon + this.word(a.type === 4 ? 15 : 16);
|
||||||
|
}
|
||||||
|
owned(events: Set<number>, action: number) {
|
||||||
|
return Array.from({ length: 52 }, (_, i) => i + 1).filter((id) => {
|
||||||
|
const a = this.attribute(id);
|
||||||
|
return a.action === action && events.has(a.event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
valid(value: unknown, events: Set<number>): value is Decorations {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== "object" ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
Object.keys(value).length !== 8
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const slots = value as Decorations;
|
||||||
|
return (
|
||||||
|
DECORATION_SLOTS.every((slot, i) => {
|
||||||
|
const id = slots[slot];
|
||||||
|
return (
|
||||||
|
Number.isInteger(id) &&
|
||||||
|
id >= 0 &&
|
||||||
|
id <= 52 &&
|
||||||
|
(id === 0 ||
|
||||||
|
(this.attribute(id).action === (i < 6 ? i * 2 + 1 : 13) &&
|
||||||
|
events.has(this.attribute(id).event)))
|
||||||
|
);
|
||||||
|
}) &&
|
||||||
|
(!slots.left || slots.left !== slots.right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1483
-188
File diff suppressed because it is too large.
Load diff
+216
-29
@@ -1,3 +1,5 @@
|
|||||||
|
import { drawTownMap } from "./game/rendering/town-map";
|
||||||
|
import { OW } from "./game/world/constants";
|
||||||
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";
|
||||||
@@ -46,6 +48,10 @@ function showWorld(world: GameWorld) {
|
|||||||
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
||||||
get("game-notice").textContent = world.notice;
|
get("game-notice").textContent = world.notice;
|
||||||
showBattle(world);
|
showBattle(world);
|
||||||
|
showFieldMenu(world);
|
||||||
|
get("town-map-view").hidden = !world.townMap;
|
||||||
|
if (world.townMap)
|
||||||
|
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
|
||||||
const dialogue = world.dialogue;
|
const dialogue = world.dialogue;
|
||||||
showPicture(world);
|
showPicture(world);
|
||||||
// A page without text is a button wait while a picture is shown.
|
// A page without text is a button wait while a picture is shown.
|
||||||
@@ -53,7 +59,8 @@ function showWorld(world: GameWorld) {
|
|||||||
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
|
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
|
||||||
);
|
);
|
||||||
const mart = dialogue?.choice && martChoice(dialogue.choice);
|
const mart = dialogue?.choice && martChoice(dialogue.choice);
|
||||||
get("native-dialogue").hidden = !dialogue || pictureWait || Boolean(mart);
|
get("native-dialogue").hidden =
|
||||||
|
!dialogue || pictureWait || Boolean(mart) || world.townMap;
|
||||||
get("pokepic-next").hidden = !pictureWait;
|
get("pokepic-next").hidden = !pictureWait;
|
||||||
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
|
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
|
||||||
get("dialogue-page").textContent =
|
get("dialogue-page").textContent =
|
||||||
@@ -131,6 +138,27 @@ function showChoice(choice: Choice | undefined) {
|
|||||||
container.replaceChildren(
|
container.replaceChildren(
|
||||||
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
|
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
|
||||||
);
|
);
|
||||||
|
else if (choice.kind === "menu") {
|
||||||
|
// A vertical menu such as the elevator's floors; B cancels.
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "dialogue-menu";
|
||||||
|
list.setAttribute("role", "listbox");
|
||||||
|
list.replaceChildren(
|
||||||
|
...choice.items.map((item, index) => {
|
||||||
|
const option = button(item, choice.selected === index, () =>
|
||||||
|
runtime.choose(index),
|
||||||
|
);
|
||||||
|
option.setAttribute("role", "option");
|
||||||
|
option.setAttribute("aria-label", item);
|
||||||
|
option.setAttribute("aria-selected", String(choice.selected === index));
|
||||||
|
return option;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
container.replaceChildren(
|
||||||
|
list,
|
||||||
|
button("CANCEL", undefined, () => runtime.choose(-1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/** The naming screen as a text field and OK: an empty name keeps the
|
/** The naming screen as a text field and OK: an empty name keeps the
|
||||||
* current one. */
|
* current one. */
|
||||||
@@ -272,7 +300,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
|
|||||||
);
|
);
|
||||||
menu.replaceChildren(
|
menu.replaceChildren(
|
||||||
stepper,
|
stepper,
|
||||||
button(mart.mode === "sell" ? "SELL" : "BUY", () => runtime.choose(mart.selected), { wide: true }),
|
button(
|
||||||
|
mart.mode === "sell" ? "SELL" : "BUY",
|
||||||
|
() => runtime.choose(mart.selected),
|
||||||
|
{ wide: true },
|
||||||
|
),
|
||||||
button("CANCEL", () => runtime.choose(-1), { wide: true }),
|
button("CANCEL", () => runtime.choose(-1), { wide: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,12 +318,28 @@ function statusName(status: number) {
|
|||||||
if (status & 7) return "SLP";
|
if (status & 7) return "SLP";
|
||||||
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
||||||
}
|
}
|
||||||
function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, palette: Palette, flip = false) {
|
function drawPic(
|
||||||
|
target: HTMLCanvasElement,
|
||||||
|
tiles: Uint8Array,
|
||||||
|
size: number,
|
||||||
|
palette: Palette,
|
||||||
|
flip = false,
|
||||||
|
) {
|
||||||
const context = target.getContext("2d")!;
|
const context = target.getContext("2d")!;
|
||||||
const pixels = size * 8;
|
const pixels = size * 8;
|
||||||
const image = context.createImageData(pixels, pixels);
|
const image = context.createImageData(pixels, pixels);
|
||||||
for (let tile = 0; tile < size * size; tile++)
|
for (let tile = 0; tile < size * size; tile++)
|
||||||
drawTile(image.data, pixels, pixels, tiles, tile, Math.floor(tile / size) * 8, (tile % size) * 8, palette, { flipX: flip });
|
drawTile(
|
||||||
|
image.data,
|
||||||
|
pixels,
|
||||||
|
pixels,
|
||||||
|
tiles,
|
||||||
|
tile,
|
||||||
|
Math.floor(tile / size) * 8,
|
||||||
|
(tile % size) * 8,
|
||||||
|
palette,
|
||||||
|
{ flipX: flip },
|
||||||
|
);
|
||||||
context.putImageData(image, 0, 0);
|
context.putImageData(image, 0, 0);
|
||||||
}
|
}
|
||||||
const battlePics = { foe: "", player: "" };
|
const battlePics = { foe: "", player: "" };
|
||||||
@@ -302,8 +350,67 @@ let showPack = false;
|
|||||||
let battleCursor = 0;
|
let battleCursor = 0;
|
||||||
let battleSelectionKey = "";
|
let battleSelectionKey = "";
|
||||||
|
|
||||||
|
let fieldCursor = 0;
|
||||||
|
function fieldButtons() {
|
||||||
|
return [
|
||||||
|
...get("field-items").querySelectorAll<HTMLButtonElement>(
|
||||||
|
"button:not(:disabled)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function showFieldMenu(world: GameWorld) {
|
||||||
|
const panel = get("field-menu");
|
||||||
|
panel.hidden = !runtime.fieldMenuOpen;
|
||||||
|
if (panel.hidden) return;
|
||||||
|
const buttons = world.state.bag.keyItems.map((item) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.textContent = world.assets.pokemon.itemName(item);
|
||||||
|
button.disabled = item !== OW.BICYCLE;
|
||||||
|
button.onclick = () => {
|
||||||
|
if (session.state !== "playing") return;
|
||||||
|
runtime.useBicycle();
|
||||||
|
canvas.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.textContent = "CLOSE";
|
||||||
|
close.onclick = () => {
|
||||||
|
runtime.fieldMenuOpen = false;
|
||||||
|
showFieldMenu(world);
|
||||||
|
canvas.focus();
|
||||||
|
};
|
||||||
|
get("field-items").replaceChildren(...buttons, close);
|
||||||
|
fieldButtons().forEach((button, i) =>
|
||||||
|
button.classList.toggle("battle-selected", i === fieldCursor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function handleFieldInput(button: Button): boolean {
|
||||||
|
const world = runtime.world;
|
||||||
|
if (!world || world.battle) return false;
|
||||||
|
if ((button === "start" || button === "select") && !world.busy) {
|
||||||
|
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
|
||||||
|
fieldCursor = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!runtime.fieldMenuOpen) return false;
|
||||||
|
if (button === "b") runtime.fieldMenuOpen = false;
|
||||||
|
else if (button === "a") fieldButtons()[fieldCursor]?.click();
|
||||||
|
else if (["up", "down", "left", "right"].includes(button)) {
|
||||||
|
const length = fieldButtons().length;
|
||||||
|
fieldCursor =
|
||||||
|
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
|
||||||
|
length;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function battleButtons() {
|
function battleButtons() {
|
||||||
return [...get("battle-menu").querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
|
return [
|
||||||
|
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
|
||||||
|
"button:not(:disabled)",
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
function highlightBattleChoice() {
|
function highlightBattleChoice() {
|
||||||
battleButtons().forEach((button, index) => {
|
battleButtons().forEach((button, index) => {
|
||||||
@@ -312,6 +419,7 @@ function highlightBattleChoice() {
|
|||||||
}
|
}
|
||||||
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
|
/** Keyboard and on-screen D-pad share the same menu buttons as pointer input. */
|
||||||
function handleBattleInput(button: Button): boolean {
|
function handleBattleInput(button: Button): boolean {
|
||||||
|
if (handleFieldInput(button)) return true;
|
||||||
const world = runtime.world;
|
const world = runtime.world;
|
||||||
if (!world?.battle) return false;
|
if (!world?.battle) return false;
|
||||||
if (button === "b" && (showMoves || showPack)) {
|
if (button === "b" && (showMoves || showPack)) {
|
||||||
@@ -354,7 +462,12 @@ function showBattle(world: GameWorld) {
|
|||||||
const key = `${foe.species}:${foe.dvs}`;
|
const key = `${foe.species}:${foe.dvs}`;
|
||||||
if (battlePics.foe !== key) {
|
if (battlePics.foe !== key) {
|
||||||
battlePics.foe = key;
|
battlePics.foe = key;
|
||||||
drawPic(get("battle-foe-pic"), data.frontPic(foe.species), 7, data.palette(foe.species, PokemonData.shiny(foe.dvs)));
|
drawPic(
|
||||||
|
get("battle-foe-pic"),
|
||||||
|
data.frontPic(foe.species),
|
||||||
|
7,
|
||||||
|
data.palette(foe.species, PokemonData.shiny(foe.dvs)),
|
||||||
|
);
|
||||||
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
|
get("battle-foe-pic").setAttribute("aria-label", foe.nickname);
|
||||||
}
|
}
|
||||||
get("battle-foe-name").textContent = foe.nickname;
|
get("battle-foe-name").textContent = foe.nickname;
|
||||||
@@ -367,7 +480,12 @@ function showBattle(world: GameWorld) {
|
|||||||
const key = `${own.species}:${own.dvs}`;
|
const key = `${own.species}:${own.dvs}`;
|
||||||
if (battlePics.player !== key) {
|
if (battlePics.player !== key) {
|
||||||
battlePics.player = key;
|
battlePics.player = key;
|
||||||
drawPic(get("battle-player-pic"), data.backPic(own.species), 6, data.palette(own.species, PokemonData.shiny(own.dvs)));
|
drawPic(
|
||||||
|
get("battle-player-pic"),
|
||||||
|
data.backPic(own.species),
|
||||||
|
6,
|
||||||
|
data.palette(own.species, PokemonData.shiny(own.dvs)),
|
||||||
|
);
|
||||||
get("battle-player-pic").setAttribute("aria-label", own.nickname);
|
get("battle-player-pic").setAttribute("aria-label", own.nickname);
|
||||||
}
|
}
|
||||||
get("battle-player-name").textContent = own.nickname;
|
get("battle-player-name").textContent = own.nickname;
|
||||||
@@ -387,11 +505,28 @@ function showBattle(world: GameWorld) {
|
|||||||
}
|
}
|
||||||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||||||
const balls = world.liveState.bag.balls;
|
const balls = world.liveState.bag.balls;
|
||||||
const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, view.message?.prompt]);
|
const key = JSON.stringify([
|
||||||
|
prompt,
|
||||||
|
view.selected,
|
||||||
|
showMoves,
|
||||||
|
showPack,
|
||||||
|
own?.pp,
|
||||||
|
balls,
|
||||||
|
view.message?.prompt,
|
||||||
|
]);
|
||||||
if (key === battleMenuKey) return;
|
if (key === battleMenuKey) return;
|
||||||
battleMenuKey = key;
|
battleMenuKey = key;
|
||||||
const menu = get("battle-menu");
|
const menu = get("battle-menu");
|
||||||
const button = (label: string, action: () => void, options: { detail?: string; disabled?: string; wide?: boolean; pressed?: boolean } = {}) => {
|
const button = (
|
||||||
|
label: string,
|
||||||
|
action: () => void,
|
||||||
|
options: {
|
||||||
|
detail?: string;
|
||||||
|
disabled?: string;
|
||||||
|
wide?: boolean;
|
||||||
|
pressed?: boolean;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
const element = document.createElement("button");
|
const element = document.createElement("button");
|
||||||
element.type = "button";
|
element.type = "button";
|
||||||
element.textContent = label;
|
element.textContent = label;
|
||||||
@@ -405,7 +540,8 @@ function showBattle(world: GameWorld) {
|
|||||||
element.title = options.disabled;
|
element.title = options.disabled;
|
||||||
}
|
}
|
||||||
if (options.wide) element.classList.add("wide");
|
if (options.wide) element.classList.add("wide");
|
||||||
if (options.pressed !== undefined) element.setAttribute("aria-pressed", String(options.pressed));
|
if (options.pressed !== undefined)
|
||||||
|
element.setAttribute("aria-pressed", String(options.pressed));
|
||||||
element.addEventListener("click", () => {
|
element.addEventListener("click", () => {
|
||||||
if (session.state !== "playing") return;
|
if (session.state !== "playing") return;
|
||||||
action();
|
action();
|
||||||
@@ -419,38 +555,64 @@ function showBattle(world: GameWorld) {
|
|||||||
own.moves.forEach((move, index) => {
|
own.moves.forEach((move, index) => {
|
||||||
if (!move) return;
|
if (!move) return;
|
||||||
buttons.push(
|
buttons.push(
|
||||||
button(data.moveName(move), () => {
|
button(
|
||||||
|
data.moveName(move),
|
||||||
|
() => {
|
||||||
showMoves = false;
|
showMoves = false;
|
||||||
runtime.battleAction({ action: "fight", move: index });
|
runtime.battleAction({ action: "fight", move: index });
|
||||||
}, { detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` }),
|
},
|
||||||
|
{ detail: `PP ${own.pp[index] & 63}/${data.move(move).pp}` },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
buttons.push(button("BACK", () => {
|
buttons.push(
|
||||||
|
button(
|
||||||
|
"BACK",
|
||||||
|
() => {
|
||||||
showMoves = false;
|
showMoves = false;
|
||||||
showWorld(world);
|
showWorld(world);
|
||||||
}, { wide: true }));
|
},
|
||||||
|
{ wide: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (showPack) {
|
} else if (showPack) {
|
||||||
balls.forEach(({ item, quantity }, slot) =>
|
balls.forEach(({ item, quantity }, slot) =>
|
||||||
buttons.push(
|
buttons.push(
|
||||||
button(data.itemName(item), () => {
|
button(
|
||||||
|
data.itemName(item),
|
||||||
|
() => {
|
||||||
showPack = false;
|
showPack = false;
|
||||||
runtime.battleAction({ action: "item", item, slot });
|
runtime.battleAction({ action: "item", item, slot });
|
||||||
}, { detail: `×${quantity}` }),
|
},
|
||||||
|
{ detail: `×${quantity}` },
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!balls.length)
|
if (!balls.length)
|
||||||
buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." }));
|
buttons.push(
|
||||||
buttons.push(button("BACK", () => {
|
button("No BALLS", () => {}, {
|
||||||
|
disabled: "The BALL pocket is empty.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
buttons.push(
|
||||||
|
button(
|
||||||
|
"BACK",
|
||||||
|
() => {
|
||||||
showPack = false;
|
showPack = false;
|
||||||
showWorld(world);
|
showWorld(world);
|
||||||
}, { wide: true }));
|
},
|
||||||
|
{ wide: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
button("FIGHT", () => {
|
button("FIGHT", () => {
|
||||||
showMoves = true;
|
showMoves = true;
|
||||||
showWorld(world);
|
showWorld(world);
|
||||||
}),
|
}),
|
||||||
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
|
button("PKMN", () => {}, {
|
||||||
|
disabled: "Switching Pokémon is not ported yet.",
|
||||||
|
}),
|
||||||
button("PACK", () => {
|
button("PACK", () => {
|
||||||
showPack = true;
|
showPack = true;
|
||||||
showWorld(world);
|
showWorld(world);
|
||||||
@@ -460,29 +622,48 @@ function showBattle(world: GameWorld) {
|
|||||||
}
|
}
|
||||||
} else if (prompt?.kind === "yesno") {
|
} else if (prompt?.kind === "yesno") {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
button("YES", () => runtime.battleChoose(1), { pressed: view.selected === 1 }),
|
button("YES", () => runtime.battleChoose(1), {
|
||||||
button("NO", () => runtime.battleChoose(0), { pressed: view.selected === 0 }),
|
pressed: view.selected === 1,
|
||||||
|
}),
|
||||||
|
button("NO", () => runtime.battleChoose(0), {
|
||||||
|
pressed: view.selected === 0,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} else if (prompt?.kind === "forget") {
|
} else if (prompt?.kind === "forget") {
|
||||||
prompt.moves.forEach((move, index) =>
|
prompt.moves.forEach((move, index) =>
|
||||||
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
|
buttons.push(
|
||||||
|
button(data.moveName(move), () => runtime.battleChoose(index)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
buttons.push(
|
||||||
|
button("CANCEL", () => runtime.battleChoose(4), { wide: true }),
|
||||||
);
|
);
|
||||||
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
|
|
||||||
} else if (prompt?.kind === "name") {
|
} else if (prompt?.kind === "name") {
|
||||||
menu.replaceChildren(
|
menu.replaceChildren(
|
||||||
...nameField(prompt.current, prompt.max, (name) => runtime.battleName(name)),
|
...nameField(prompt.current, prompt.max, (name) =>
|
||||||
|
runtime.battleName(name),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (prompt?.kind === "stats") {
|
} else if (prompt?.kind === "stats") {
|
||||||
prompt.labels.forEach((label, index) =>
|
prompt.labels.forEach((label, index) =>
|
||||||
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
|
buttons.push(
|
||||||
|
button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
buttons.push(
|
||||||
|
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
|
||||||
);
|
);
|
||||||
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
|
|
||||||
} else if (view.message?.prompt) {
|
} else if (view.message?.prompt) {
|
||||||
buttons.push(button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }));
|
buttons.push(
|
||||||
|
button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
menu.replaceChildren(...buttons);
|
menu.replaceChildren(...buttons);
|
||||||
battleCursor = Math.min(battleCursor, Math.max(0, battleButtons().length - 1));
|
battleCursor = Math.min(
|
||||||
|
battleCursor,
|
||||||
|
Math.max(0, battleButtons().length - 1),
|
||||||
|
);
|
||||||
highlightBattleChoice();
|
highlightBattleChoice();
|
||||||
}
|
}
|
||||||
function setHp(bar: HTMLElement, hp: number, max: number) {
|
function setHp(bar: HTMLElement, hp: number, max: number) {
|
||||||
@@ -768,3 +949,9 @@ setInterval(() => {
|
|||||||
if (ready() && !busy) void save().catch(() => {});
|
if (ready() && !busy) void save().catch(() => {});
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
update();
|
update();
|
||||||
|
|
||||||
|
get("town-map-close").onclick = () => {
|
||||||
|
if (session.state !== "playing") return;
|
||||||
|
runtime.advanceDialogue();
|
||||||
|
canvas.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@ export function renderShell(root: HTMLElement) {
|
|||||||
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
|
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
|
||||||
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
||||||
</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="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></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>
|
||||||
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
|
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
|
||||||
|
|||||||
@@ -341,6 +341,13 @@ dialog h2 {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
.dialogue-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 9.5em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
.dialogue-choice input {
|
.dialogue-choice input {
|
||||||
flex: 1 1 12ch;
|
flex: 1 1 12ch;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1024,3 +1031,47 @@ dialog dd {
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-menu {
|
||||||
|
position: absolute;
|
||||||
|
inset: 1rem 1rem auto auto;
|
||||||
|
z-index: 6;
|
||||||
|
max-height: calc(100% - 2rem);
|
||||||
|
overflow: auto;
|
||||||
|
min-width: 10rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 2px solid #27332a;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: #f8f4df;
|
||||||
|
color: #27332a;
|
||||||
|
}
|
||||||
|
.field-menu[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.field-menu p {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
#field-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-map-view {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 7;
|
||||||
|
background: #f8f4df;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.town-map-view[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#town-map-canvas {
|
||||||
|
width: auto;
|
||||||
|
height: calc(100% - 3rem);
|
||||||
|
max-width: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
@@ -25,8 +25,10 @@ async function start(page: Page, scenario: BattleScenario = "win") {
|
|||||||
await page.locator("#play").click();
|
await page.locator("#play").click();
|
||||||
for (const direction of ENCOUNTER_WALK) {
|
for (const direction of ENCOUNTER_WALK) {
|
||||||
const key = `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`;
|
const key = `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`;
|
||||||
|
// Long enough to turn in place first (4 ticks) and start one step,
|
||||||
|
// short of a second step when already facing that way.
|
||||||
await page.keyboard.down(key);
|
await page.keyboard.down(key);
|
||||||
await page.clock.runFor(70);
|
await page.clock.runFor(250);
|
||||||
await page.keyboard.up(key);
|
await page.keyboard.up(key);
|
||||||
await page.clock.runFor(300);
|
await page.clock.runFor(300);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import {
|
||||||
|
DecorationData,
|
||||||
|
initialDecorations,
|
||||||
|
} from "../src/game/world/decorations";
|
||||||
|
import { EVENTS } from "../src/game/rom/profile";
|
||||||
|
import { settle } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
function fixture() {
|
||||||
|
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const world = new GameWorld(
|
||||||
|
assets,
|
||||||
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
world,
|
||||||
|
data: new DecorationData(assets.reader, assets.pokemon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function pc(world: GameWorld) {
|
||||||
|
const save = world.save();
|
||||||
|
world.restore({
|
||||||
|
...save,
|
||||||
|
map: "24:7",
|
||||||
|
x: 2,
|
||||||
|
y: 2,
|
||||||
|
direction: "up",
|
||||||
|
objects: [],
|
||||||
|
});
|
||||||
|
world.interact();
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||||
|
}
|
||||||
|
function choose(world: GameWorld, name: string) {
|
||||||
|
const choice = world.dialogue?.choice;
|
||||||
|
if (choice?.kind !== "menu") throw new Error("Expected decoration menu");
|
||||||
|
const index = choice.items.indexOf(name);
|
||||||
|
expect(index, name).toBeGreaterThanOrEqual(0);
|
||||||
|
world.choose(index);
|
||||||
|
}
|
||||||
|
function acknowledge(world: GameWorld) {
|
||||||
|
for (let i = 0; world.dialogue && !world.dialogue.choice && i < 20; i++)
|
||||||
|
world.advanceDialogue();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("room decorations", () => {
|
||||||
|
it("starts with the original bed and Town Map and migrates old saves", () => {
|
||||||
|
const { world, assets } = fixture();
|
||||||
|
expect(world.state.decorations).toEqual(initialDecorations());
|
||||||
|
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
|
||||||
|
expect(assets.block(world.map, 6, 0)).toBe(0x1f);
|
||||||
|
expect(world.state.events.has(EVENTS.playersRoomPoster)).toBe(true);
|
||||||
|
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
|
||||||
|
delete old.decorations;
|
||||||
|
world.restore(old);
|
||||||
|
expect(world.state.decorations).toEqual(initialDecorations());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PC puts away and restores a bed; mid-menu saves retain committed choices", () => {
|
||||||
|
const { world, assets } = fixture();
|
||||||
|
pc(world);
|
||||||
|
choose(world, "BED");
|
||||||
|
choose(world, "PUT IT AWAY");
|
||||||
|
expect(world.liveState.decorations.bed).toBe(0);
|
||||||
|
expect(world.save().decorations.bed).toBe(2);
|
||||||
|
acknowledge(world);
|
||||||
|
choose(world, "EXIT");
|
||||||
|
settle(world);
|
||||||
|
expect(world.state.decorations.bed).toBe(0);
|
||||||
|
expect(assets.block(world.map, 0, 4)).toBe(
|
||||||
|
assets.block(assets.map("24:7"), 0, 4),
|
||||||
|
);
|
||||||
|
world.restore(world.save());
|
||||||
|
pc(world);
|
||||||
|
choose(world, "BED");
|
||||||
|
choose(world, "FEATHERY BED");
|
||||||
|
acknowledge(world);
|
||||||
|
choose(world, "EXIT");
|
||||||
|
settle(world);
|
||||||
|
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owned ornaments move between sides without duplication and reload as sprites", () => {
|
||||||
|
const { world, data } = fixture();
|
||||||
|
world.state.events.add(data.attribute(30).event);
|
||||||
|
pc(world);
|
||||||
|
choose(world, "ORNAMENT");
|
||||||
|
choose(world, "PIKACHU DOLL");
|
||||||
|
choose(world, "RIGHT SIDE");
|
||||||
|
acknowledge(world);
|
||||||
|
choose(world, "ORNAMENT");
|
||||||
|
choose(world, "PIKACHU DOLL");
|
||||||
|
choose(world, "LEFT SIDE");
|
||||||
|
acknowledge(world);
|
||||||
|
choose(world, "EXIT");
|
||||||
|
settle(world);
|
||||||
|
expect([
|
||||||
|
world.state.decorations.left,
|
||||||
|
world.state.decorations.right,
|
||||||
|
]).toEqual([30, 0]);
|
||||||
|
expect(world.objects().some((object) => object.id === 3)).toBe(true);
|
||||||
|
expect(world.objects().some((object) => object.id === 4)).toBe(false);
|
||||||
|
world.restore(world.save());
|
||||||
|
expect(world.state.variables[1]).toBe(data.attribute(30).sprite);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates ownership and categories before changing the current game", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
const before = world.save();
|
||||||
|
for (const decorations of [
|
||||||
|
null,
|
||||||
|
{ ...before.decorations, bed: 30 },
|
||||||
|
{ ...before.decorations, bed: 3 },
|
||||||
|
{ ...before.decorations, poster: 999 },
|
||||||
|
]) {
|
||||||
|
expect(() => world.restore({ ...before, decorations })).toThrow(
|
||||||
|
/Invalid native save/,
|
||||||
|
);
|
||||||
|
expect(world.save()).toEqual(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets every tile category and sprite category from the owned ROM attributes", () => {
|
||||||
|
const { world, assets, data } = fixture();
|
||||||
|
for (let id = 1; id <= 52; id++) {
|
||||||
|
const a = data.attribute(id);
|
||||||
|
if (a.action & 1) world.state.events.add(a.event);
|
||||||
|
}
|
||||||
|
world.state.decorations = {
|
||||||
|
bed: 5,
|
||||||
|
carpet: 10,
|
||||||
|
plant: 14,
|
||||||
|
poster: 19,
|
||||||
|
console: 24,
|
||||||
|
bigDoll: 28,
|
||||||
|
left: 51,
|
||||||
|
right: 52,
|
||||||
|
};
|
||||||
|
world.warpTo("24:7", 2, 2);
|
||||||
|
expect(
|
||||||
|
[
|
||||||
|
[0, 4],
|
||||||
|
[7, 4],
|
||||||
|
[6, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 2],
|
||||||
|
[2, 2],
|
||||||
|
[4, 2],
|
||||||
|
].map(([x, y]) => assets.block(world.map, x, y)),
|
||||||
|
).toEqual([0x1e, 0x22, 0x25, 0x11, 0x12, 0x13, 0x12]);
|
||||||
|
expect([0, 1, 2, 3].map((i) => world.state.variables[i])).toEqual(
|
||||||
|
[24, 51, 52, 28].map((id) => data.attribute(id).sprite),
|
||||||
|
);
|
||||||
|
expect(world.objects()).toHaveLength(4);
|
||||||
|
world.restore(world.save());
|
||||||
|
expect(world.objects()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { WALK_TICKS } from "../src/game/world/world";
|
||||||
|
import { OW } from "../src/game/world/constants";
|
||||||
|
import { SYMBOLS } from "../src/game/rom/profile";
|
||||||
|
import type { Direction } from "../src/game/world/types";
|
||||||
|
import { fixture } from "./movement-fixture";
|
||||||
|
import { settle } from "./world-driver";
|
||||||
|
|
||||||
|
const pose = (world: ReturnType<typeof fixture>["world"]) =>
|
||||||
|
world.poses().find((candidate) => candidate.id === 0)!;
|
||||||
|
|
||||||
|
/** Hold a direction for `ticks` ticks and record the tick numbers on which
|
||||||
|
* the player's tile changed. */
|
||||||
|
function hold(
|
||||||
|
world: ReturnType<typeof fixture>["world"],
|
||||||
|
direction: Direction | undefined,
|
||||||
|
ticks: number,
|
||||||
|
) {
|
||||||
|
const moved: number[] = [];
|
||||||
|
for (let tick = 1; tick <= ticks; tick++) {
|
||||||
|
const at = `${world.x},${world.y}`;
|
||||||
|
world.tick(direction);
|
||||||
|
if (`${world.x},${world.y}` !== at) moved.push(tick);
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("turning in place (DoPlayerMovement .CheckTurning)", () => {
|
||||||
|
it("turns on the next tick and walks on the fourth tick after a turn", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
expect(world.direction).toBe("down");
|
||||||
|
world.tick("right");
|
||||||
|
// StepFunction_Turn shows the new facing a tick later.
|
||||||
|
expect([world.x, world.direction, world.busy]).toEqual([3, "down", true]);
|
||||||
|
world.tick("right");
|
||||||
|
expect(world.direction).toBe("right");
|
||||||
|
expect(pose(world).frame).toBe(1);
|
||||||
|
// ChangeDirectionScript's deactivatefacing 3 holds input until then.
|
||||||
|
expect(hold(world, "right", 2)).toEqual([]);
|
||||||
|
expect(hold(world, "right", 1)).toEqual([1]);
|
||||||
|
// Continuous walking needs no further turns.
|
||||||
|
expect(hold(world, "right", WALK_TICKS * 2)).toEqual([WALK_TICKS, WALK_TICKS * 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns without walking when the direction is only tapped", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
world.tick("left");
|
||||||
|
expect(hold(world, undefined, 8)).toEqual([]);
|
||||||
|
expect([world.x, world.y, world.direction, world.busy]).toEqual([3, 3, "left", false]);
|
||||||
|
// Facing that way now, the next press walks at once.
|
||||||
|
expect(hold(world, "left", 1)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes direction without turning while walking, and turns again after stopping", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
|
||||||
|
// Still walking: the new direction steps on the next decision.
|
||||||
|
expect(hold(world, "right", 1)).toEqual([1]);
|
||||||
|
expect(world.direction).toBe("right");
|
||||||
|
hold(world, undefined, WALK_TICKS + 1);
|
||||||
|
expect(hold(world, "up", 4)).toEqual([]);
|
||||||
|
expect(world.direction).toBe("up");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bumps into walls in place at half animation speed and must turn after a bump", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.tile(3, 4, 7);
|
||||||
|
const frames: number[] = [];
|
||||||
|
for (let tick = 0; tick < 16; tick++) {
|
||||||
|
world.tick("down");
|
||||||
|
frames.push(pose(world).frame);
|
||||||
|
}
|
||||||
|
expect([world.x, world.y]).toEqual([3, 3]);
|
||||||
|
// SetFacingBumpAction: OBJECT_STEP_FRAME >> 3.
|
||||||
|
expect(frames).toEqual([0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2]);
|
||||||
|
// The bump cleared wPlayerTurningDirection, so a new direction turns first.
|
||||||
|
expect(hold(world, "left", 4)).toEqual([]);
|
||||||
|
expect(hold(world, "left", 1)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walking into a wall in a new direction bumps without facing it, then turns", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.tile(4, 4, 7);
|
||||||
|
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
|
||||||
|
// Mid-walk turning is not needed, but the blocked step does not turn.
|
||||||
|
world.tick("right");
|
||||||
|
expect([world.x, world.y, world.direction]).toEqual([3, 4, "down"]);
|
||||||
|
world.tick("right");
|
||||||
|
world.tick("right");
|
||||||
|
expect(world.direction).toBe("right");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not turn toward the direction the player already faces", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
expect(hold(world, "down", 1)).toEqual([1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("forced terrain (DoPlayerMovement .CheckTile)", () => {
|
||||||
|
it.each<[number, Direction | undefined]>([
|
||||||
|
[0x40, undefined],
|
||||||
|
[0x41, "right"],
|
||||||
|
[0x42, "left"],
|
||||||
|
[0x43, "up"],
|
||||||
|
[0x44, "down"],
|
||||||
|
[0x45, undefined],
|
||||||
|
[0x50, "right"],
|
||||||
|
[0x51, "left"],
|
||||||
|
[0x52, "up"],
|
||||||
|
[0x53, "down"],
|
||||||
|
[0x54, undefined],
|
||||||
|
])("walk tile %i moves the player %s without input", (tile, direction) => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.tile(3, 3, tile);
|
||||||
|
world.tick();
|
||||||
|
if (!direction) {
|
||||||
|
expect([world.x, world.y, world.busy]).toEqual([3, 3, false]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vectors = { right: [4, 3], left: [2, 3], up: [3, 2], down: [3, 4] };
|
||||||
|
expect([world.x, world.y, world.direction]).toEqual([...vectors[direction], direction]);
|
||||||
|
expect(pose(world).frame).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forced steps ignore held input and walls, like .continue_walk", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.tile(3, 3, 0x41);
|
||||||
|
assets.tile(4, 3, 7);
|
||||||
|
world.tick("left");
|
||||||
|
expect([world.x, world.y]).toEqual([4, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[number, Direction]>([
|
||||||
|
[0x30, "right"],
|
||||||
|
[0x31, "left"],
|
||||||
|
[0x32, "up"],
|
||||||
|
[0x33, "down"],
|
||||||
|
[0x38, "right"],
|
||||||
|
[0x3b, "down"],
|
||||||
|
])("currents and waterfalls (%i) carry a surfer %s", (tile, direction) => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
world.state.playerState = OW.PLAYER_SURF;
|
||||||
|
assets.tile(3, 3, tile);
|
||||||
|
world.tick();
|
||||||
|
expect(world.direction).toBe(direction);
|
||||||
|
expect(world.activity).toBe("walking");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ice (STEP_ICE and .CheckForced)", () => {
|
||||||
|
it("slides at four ticks per tile until something blocks the slide", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
|
||||||
|
assets.tile(7, 3, 7);
|
||||||
|
world.restore({ ...world.save(), direction: "right" });
|
||||||
|
// Stepping from floor onto ice is still a walk; here we start on ice.
|
||||||
|
const moved = hold(world, "right", 1);
|
||||||
|
expect(moved).toEqual([1]);
|
||||||
|
expect(pose(world).frame).toBe(0);
|
||||||
|
// Input no longer matters while sliding.
|
||||||
|
expect(hold(world, "up", 16)).toEqual([4, 8]);
|
||||||
|
expect([world.x, world.y]).toEqual([6, 3]);
|
||||||
|
// The wall stopped the slide with a bump, so the player turned up during
|
||||||
|
// the last hold; still on ice, the step off it is another four-tick slide.
|
||||||
|
expect(world.direction).toBe("up");
|
||||||
|
expect(hold(world, "up", 5)).toEqual([1, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow button actions while sliding", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
|
||||||
|
world.restore({ ...world.save(), direction: "right" });
|
||||||
|
hold(world, "right", 4);
|
||||||
|
world.interact();
|
||||||
|
expect(world.notice).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks normally from floor onto ice", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.tile(3, 4, OW.COLL_ICE);
|
||||||
|
const moved = hold(world, "down", WALK_TICKS);
|
||||||
|
expect(moved).toEqual([1]);
|
||||||
|
expect(pose(world).frame).not.toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bikes, surfing and side buoys", () => {
|
||||||
|
it("rides a bike at four ticks per tile", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
world.state.playerState = OW.PLAYER_BIKE;
|
||||||
|
expect(hold(world, "down", 8)).toEqual([1, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rides downhill without input and slowly uphill", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
world.state.playerState = OW.PLAYER_BIKE;
|
||||||
|
world.state.flags.add(OW.ENGINE_DOWNHILL);
|
||||||
|
world.restore({ ...world.save(), y: 1 });
|
||||||
|
world.state.playerState = OW.PLAYER_BIKE;
|
||||||
|
world.state.flags.add(OW.ENGINE_DOWNHILL);
|
||||||
|
expect(hold(world, undefined, 8)).toEqual([1, 5]);
|
||||||
|
// Still moving, so no turn: uphill against the slope is a walk.
|
||||||
|
expect(hold(world, "up", 16)).toEqual([1, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfs over water, refuses walls and gets out onto land", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
world.state.playerState = OW.PLAYER_SURF;
|
||||||
|
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
|
||||||
|
assets.tile(3, 5, 0x27);
|
||||||
|
assets.tile(4, 4, 0);
|
||||||
|
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
|
||||||
|
expect(world.state.playerState).toBe(OW.PLAYER_SURF);
|
||||||
|
expect(hold(world, "down", 4)).toEqual([]);
|
||||||
|
world.tick("right");
|
||||||
|
world.tick("right");
|
||||||
|
world.tick("right");
|
||||||
|
world.tick("right");
|
||||||
|
expect(hold(world, "right", 1)).toEqual([1]);
|
||||||
|
expect(world.state.playerState).toBe(OW.PLAYER_NORMAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buoys block surfing like side walls", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
world.state.playerState = OW.PLAYER_SURF;
|
||||||
|
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
|
||||||
|
// COLL_UP_BUOY below the player blocks a downward move into it.
|
||||||
|
assets.tile(3, 4, 0xc2);
|
||||||
|
expect(hold(world, "down", 4)).toEqual([]);
|
||||||
|
assets.tile(3, 4, 0xc3);
|
||||||
|
expect(hold(world, "down", 1)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walking cannot enter water", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.tile(3, 4, 0x29);
|
||||||
|
expect(hold(world, "down", 4)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dynamic warps (-1)", () => {
|
||||||
|
it("remembers the entrance on arrival and returns through a -1 warp", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
// 24:7's warp 1 at (3,4) leads to 24:6 warp 1, a -1 warp back.
|
||||||
|
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 1 });
|
||||||
|
assets.map("24:7").warps.push({ x: 5, y: 4, targetMap: "24:6", targetWarp: 1 });
|
||||||
|
assets.map("24:6").warps.push({ x: 2, y: 2, targetMap: "24:6", targetWarp: 0xff });
|
||||||
|
assets.tile(3, 4, 0x72);
|
||||||
|
assets.tile(5, 4, 0x72);
|
||||||
|
assets.tile(2, 3, 0x72, "24:6");
|
||||||
|
assets.map("24:6").warps.push({ x: 2, y: 3, targetMap: "24:6", targetWarp: 0xff });
|
||||||
|
world.restore({ ...world.save(), x: 5 });
|
||||||
|
expect(world.move("down")).toBe(true);
|
||||||
|
expect([world.mapId, world.x, world.y]).toEqual(["24:6", 2, 2]);
|
||||||
|
expect(world.state.backupWarp).toEqual({ map: "24:7", warp: 2 });
|
||||||
|
const saved = world.save();
|
||||||
|
expect(saved.backupWarp).toEqual({ map: "24:7", warp: 2 });
|
||||||
|
world.restore(saved);
|
||||||
|
expect(world.move("down")).toBe(true);
|
||||||
|
expect([world.mapId, world.x, world.y]).toEqual(["24:7", 5, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a -1 warp without a recorded destination", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 0xff });
|
||||||
|
assets.tile(3, 4, 0x72);
|
||||||
|
expect(world.move("down")).toBe(false);
|
||||||
|
expect(world.notice).toMatch(/not ported/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed backup warps in saves", () => {
|
||||||
|
const { world } = fixture();
|
||||||
|
for (const backupWarp of [{ map: "x", warp: 1 }, { map: "24:6", warp: 0 }, 5])
|
||||||
|
expect(() => world.restore({ ...world.save(), backupWarp })).toThrow("Invalid native save");
|
||||||
|
expect(() => world.restore({ ...world.save(), playerState: 3 })).toThrow("Invalid native save");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Strength boulders", () => {
|
||||||
|
it("pushes a boulder one tile over sixteen ticks while the player bumps", () => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
const type = 19; // SPRITEMOVEDATA_STRENGTH_BOULDER
|
||||||
|
const entry = SYMBOLS.spriteMovementData + type * 6;
|
||||||
|
// MovementFunction_Strength; WONT_DELETE | FIXED_FACING | SLIDING |
|
||||||
|
// MOVE_ANYWHERE; STRENGTH_BOULDER.
|
||||||
|
assets.bytes.set([0x11, 0, 1, 0x2e, 0, 0x40], entry);
|
||||||
|
assets.map("24:7").objects.push({
|
||||||
|
id: 2, x: 3, y: 4, sprite: 1, movement: type, radius: 0,
|
||||||
|
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
|
||||||
|
});
|
||||||
|
world.restore(world.save());
|
||||||
|
// Without Strength the boulder does not move.
|
||||||
|
hold(world, "down", 20);
|
||||||
|
expect(world.objects()[0]).toMatchObject({ x: 3, y: 4 });
|
||||||
|
world.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
|
||||||
|
world.tick("down");
|
||||||
|
expect(world.objects()[0]).toMatchObject({ x: 3, y: 5 });
|
||||||
|
expect([world.x, world.y]).toEqual([3, 3]);
|
||||||
|
hold(world, undefined, 15);
|
||||||
|
expect(world.save().objects).toEqual([{ id: 2, x: 3, y: 5, direction: "down" }]);
|
||||||
|
// It stops at walls.
|
||||||
|
assets.tile(3, 7, 7);
|
||||||
|
hold(world, "down", 60);
|
||||||
|
expect(world.objects()[0]).toMatchObject({ x: 3, y: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changeblock", () => {
|
||||||
|
const trigger = (script: number[]) => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
assets.bytes.set(script, 0x100);
|
||||||
|
assets.map("24:7").coordinateEvents.push({ x: 3, y: 4, scene: 0xff, script: 0x100 });
|
||||||
|
return { assets, world };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("changes the working block data until the map loads again", () => {
|
||||||
|
// changeblock 2, 2, $05; end
|
||||||
|
const { world } = trigger([0x7a, 2, 2, 5, 0x91]);
|
||||||
|
expect(world.move("down")).toBe(true);
|
||||||
|
settle(world);
|
||||||
|
expect(world.notice).toBe("");
|
||||||
|
expect(world.map.blocks[1 * 4 + 1]).toBe(5);
|
||||||
|
expect(world.blockRevision).toBe(1);
|
||||||
|
world.warpTo("24:7", 3, 3);
|
||||||
|
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the blocks when a script that changed them is refused", () => {
|
||||||
|
// changeblock 2, 2, $05; an unported command
|
||||||
|
const { world } = trigger([0x7a, 2, 2, 5, 0xff]);
|
||||||
|
world.move("down");
|
||||||
|
settle(world);
|
||||||
|
expect(world.notice).toMatch(/not been ported/);
|
||||||
|
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses blocks outside the map or beyond the tileset", () => {
|
||||||
|
for (const script of [[0x7a, 8, 2, 5, 0x91], [0x7a, 2, 2, 0x80, 0x91]]) {
|
||||||
|
const { world } = trigger(script);
|
||||||
|
world.move("down");
|
||||||
|
settle(world);
|
||||||
|
expect(world.notice).toMatch(/not been ported/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NPC movement functions", () => {
|
||||||
|
const withObject = (fn: number, action = 1, flags = 0, sprite = 1) => {
|
||||||
|
const { assets, world } = fixture();
|
||||||
|
const type = 30;
|
||||||
|
assets.bytes.set([fn, 0, action, flags, 0, 0], SYMBOLS.spriteMovementData + type * 6);
|
||||||
|
assets.map("24:7").objects.push({
|
||||||
|
id: 2, x: 5, y: 3, sprite, movement: type, radius: 0,
|
||||||
|
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
|
||||||
|
});
|
||||||
|
world.restore(world.save());
|
||||||
|
return { assets, world };
|
||||||
|
};
|
||||||
|
const facingOf = (world: ReturnType<typeof fixture>["world"]) =>
|
||||||
|
world.objects().find((object) => object.id === 2)!.direction;
|
||||||
|
|
||||||
|
it.each<[number, Direction[]]>([
|
||||||
|
[0x18, ["down", "left", "up", "right", "down"]],
|
||||||
|
[0x19, ["down", "right", "up", "left", "down"]],
|
||||||
|
])("SPRITEMOVEFN %i turns every sixteen ticks", (fn, expected) => {
|
||||||
|
const { world } = withObject(fn);
|
||||||
|
const seen: Direction[] = [];
|
||||||
|
for (let tick = 0; tick < 16 * 5; tick++) {
|
||||||
|
world.tick();
|
||||||
|
if (tick % 16 === 0) seen.push(facingOf(world));
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounces between the down and up standing frames every eight ticks", () => {
|
||||||
|
const { world } = withObject(0x16, 0x0a, 0x2e);
|
||||||
|
const facings: (number | undefined)[] = [];
|
||||||
|
for (let tick = 0; tick < 17; tick++) {
|
||||||
|
world.tick();
|
||||||
|
facings.push(world.poses().find((pose) => pose.id === 2)?.facing);
|
||||||
|
}
|
||||||
|
expect(facings.slice(0, 7).every((facing) => facing === 0)).toBe(true);
|
||||||
|
expect(facings.slice(7, 15).every((facing) => facing === 4)).toBe(true);
|
||||||
|
expect(facings[15]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws big dolls with their own facing layouts", () => {
|
||||||
|
const { world } = withObject(0x15, 0x09);
|
||||||
|
world.tick();
|
||||||
|
expect(world.poses().find((pose) => pose.id === 2)?.facing).toBe(0x17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves variable sprites from wVariableSprites and hides unset ones", () => {
|
||||||
|
const { world } = withObject(0x06, 1, 0, 0xf3);
|
||||||
|
expect(world.objects()).toHaveLength(0);
|
||||||
|
world.state.variables[3] = 0x2a;
|
||||||
|
world.restore(world.save());
|
||||||
|
expect(world.poses().find((pose) => pose.id === 2)?.sprite).toBe(0x2a);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
const romPath = resolve(
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||||
|
);
|
||||||
|
test.beforeEach(() =>
|
||||||
|
test.skip(!existsSync(romPath), "Requires the local user ROM."),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function start(page: Page, room: boolean | "poster" = false) {
|
||||||
|
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||||
|
const response = await route.fetch(),
|
||||||
|
source = await response.text();
|
||||||
|
const needle = "const world = new GameWorld(assets);";
|
||||||
|
expect(source).toContain(needle);
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
body: source.replace(
|
||||||
|
needle,
|
||||||
|
`const { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator("#rom-file").setInputFiles(romPath);
|
||||||
|
await expect(page.locator("#status")).toContainText("verified");
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
|
||||||
|
await page.locator("#play").click();
|
||||||
|
}
|
||||||
|
async function backup(page: Page) {
|
||||||
|
await page.locator("#tab-saves").click();
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await page.locator("#backup").click();
|
||||||
|
const path = (await (await download).path())!;
|
||||||
|
const record = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("Start opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await start(page);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.locator("#field-menu")).toBeVisible();
|
||||||
|
await page.keyboard.down("ArrowDown");
|
||||||
|
await page.clock.runFor(1000);
|
||||||
|
await page.keyboard.up("ArrowDown");
|
||||||
|
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "6");
|
||||||
|
await page
|
||||||
|
.locator("#field-items")
|
||||||
|
.getByRole("button", { name: "BICYCLE", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press("KeyX");
|
||||||
|
await page.clock.runFor(500);
|
||||||
|
await expect(page.locator("#field-menu")).toBeHidden();
|
||||||
|
await expect(page.locator("#game-notice")).toHaveText("");
|
||||||
|
expect((await backup(page)).state.playerState).toBe(1);
|
||||||
|
});
|
||||||
|
test("the bedroom PC puts away a decoration and saves the result", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await start(page, true);
|
||||||
|
await page.keyboard.press("KeyX");
|
||||||
|
await expect(page.locator("#game-notice")).toHaveText("");
|
||||||
|
await expect(page.locator("#dialogue-text")).toHaveText("DECORATION");
|
||||||
|
const choices = page.locator("#dialogue-choice");
|
||||||
|
await choices.getByRole("option", { name: "BED", exact: true }).click();
|
||||||
|
await choices
|
||||||
|
.getByRole("option", { name: "PUT IT AWAY", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press("KeyX");
|
||||||
|
await choices.getByRole("option", { name: "EXIT", exact: true }).click();
|
||||||
|
await page.clock.runFor(500);
|
||||||
|
await expect(page.locator("#game-notice")).toHaveText("");
|
||||||
|
expect((await backup(page)).state.decorations.bed).toBe(0);
|
||||||
|
});
|
||||||
|
test("B cancels the pack and an indoor Bicycle attempt leaves the player walking", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await start(page, true);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.keyboard.press("KeyZ");
|
||||||
|
await expect(page.locator("#field-menu")).toBeHidden();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page
|
||||||
|
.locator("#field-items")
|
||||||
|
.getByRole("button", { name: "BICYCLE", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator("#game-notice")).toContainText("can’t use");
|
||||||
|
expect((await backup(page)).state.playerState).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Town Map poster opens a native map and closes with B", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 320, height: 568 });
|
||||||
|
await start(page, "poster");
|
||||||
|
await page.keyboard.press("KeyX");
|
||||||
|
await expect(page.locator("#dialogue-text")).toContainText("TOWN MAP");
|
||||||
|
await page.keyboard.press("KeyX");
|
||||||
|
await expect(page.locator("#town-map-view")).toBeVisible();
|
||||||
|
const colors = await page
|
||||||
|
.locator("#town-map-canvas")
|
||||||
|
.evaluate(
|
||||||
|
(canvas: HTMLCanvasElement) =>
|
||||||
|
new Set(canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data)
|
||||||
|
.size,
|
||||||
|
);
|
||||||
|
expect(colors).toBeGreaterThan(8);
|
||||||
|
await page.keyboard.press("KeyZ");
|
||||||
|
await expect(page.locator("#town-map-view")).toBeHidden();
|
||||||
|
await expect(page.locator("#game-notice")).toHaveText("");
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import { OW } from "../src/game/world/constants";
|
||||||
|
|
||||||
|
export function foundationFixture(
|
||||||
|
assets: CrystalAssets,
|
||||||
|
room: boolean | "poster",
|
||||||
|
) {
|
||||||
|
const world = new GameWorld(
|
||||||
|
assets,
|
||||||
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
world.state.scenes["24:4"] = 1;
|
||||||
|
world.state.bag.keyItems.push(OW.BICYCLE);
|
||||||
|
const save = world.save();
|
||||||
|
world.restore({
|
||||||
|
...save,
|
||||||
|
map: room ? "24:7" : "24:4",
|
||||||
|
x: room === "poster" ? 6 : room ? 2 : 9,
|
||||||
|
y: room === "poster" ? 1 : room ? 2 : 6,
|
||||||
|
direction: "up",
|
||||||
|
objects: [],
|
||||||
|
});
|
||||||
|
return world;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { emptyDecorations } from "../src/game/world/decorations";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import type { GameMap } from "../src/game/world/types";
|
||||||
|
|
||||||
|
/** Synthetic maps and script bytes: these tests run without a private ROM. */
|
||||||
|
export class MovementAssets extends CrystalAssets {
|
||||||
|
readonly fixtures = new Map<string, GameMap>();
|
||||||
|
readonly tiles = new Map<string, number>();
|
||||||
|
readonly bytes: Uint8Array;
|
||||||
|
constructor() {
|
||||||
|
const bytes = new Uint8Array(0xa0000);
|
||||||
|
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
|
||||||
|
super(bytes);
|
||||||
|
this.bytes = bytes;
|
||||||
|
for (const id of ["24:7", "24:6"]) {
|
||||||
|
this.fixtures.set(id, {
|
||||||
|
id,
|
||||||
|
group: 24,
|
||||||
|
number: Number(id.split(":")[1]),
|
||||||
|
name: id,
|
||||||
|
location: id,
|
||||||
|
landmark: 1,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
border: 0,
|
||||||
|
tileset: 1,
|
||||||
|
environment: 1,
|
||||||
|
paletteMode: 1,
|
||||||
|
blocks: new Uint8Array(16),
|
||||||
|
warps: [],
|
||||||
|
backgroundEvents: [],
|
||||||
|
objects: [],
|
||||||
|
connections: [],
|
||||||
|
sceneScripts: [],
|
||||||
|
callbacks: [],
|
||||||
|
coordinateEvents: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override map(id: string) {
|
||||||
|
return this.fixtures.get(id)!;
|
||||||
|
}
|
||||||
|
override isPorted(id: string) {
|
||||||
|
return this.fixtures.has(id);
|
||||||
|
}
|
||||||
|
override sceneOwner() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
override collision(map: GameMap, x: number, y: number) {
|
||||||
|
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
|
||||||
|
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
|
||||||
|
}
|
||||||
|
/** CollisionPermissionTable in brief: walls, then the water rows
|
||||||
|
* ($2x, $3x, buoys $cx) apart from ice and the wall-like buoy tiles. */
|
||||||
|
override permission(collision: number) {
|
||||||
|
if (collision === 7 || collision === 0x27 || collision === 0x2f) return 15;
|
||||||
|
if (collision === 0x23 || collision === 0x2b) return 0;
|
||||||
|
return [0x20, 0x30, 0xc0].includes(collision & 0xf0) ? 1 : 0;
|
||||||
|
}
|
||||||
|
override grassCollisions() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
override sprite(id: number) {
|
||||||
|
return id ? { graphics: new Uint8Array(0), palette: 0, walking: false } : undefined;
|
||||||
|
}
|
||||||
|
tile(x: number, y: number, collision: number, map = "24:7") {
|
||||||
|
this.tiles.set(`${map}:${x},${y}`, collision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fixture() {
|
||||||
|
const assets = new MovementAssets();
|
||||||
|
const world = new GameWorld(
|
||||||
|
assets,
|
||||||
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
world.state.decorations = emptyDecorations();
|
||||||
|
return { assets, world };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectWarp(assets: MovementAssets, x = 3, y = 4) {
|
||||||
|
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
|
||||||
|
assets
|
||||||
|
.map("24:6")
|
||||||
|
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
|
||||||
|
assets.tile(2, 2, 0x70, "24:6");
|
||||||
|
}
|
||||||
|
|
||||||
+23
-90
@@ -1,88 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
|
||||||
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
|
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
|
||||||
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
|
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
|
||||||
import { settle } from "./world-driver";
|
import { settle } from "./world-driver";
|
||||||
|
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
|
||||||
/** Synthetic maps and script bytes: these tests run without a private ROM. */
|
|
||||||
class MovementAssets extends CrystalAssets {
|
|
||||||
readonly fixtures = new Map<string, GameMap>();
|
|
||||||
readonly tiles = new Map<string, number>();
|
|
||||||
readonly bytes: Uint8Array;
|
|
||||||
constructor() {
|
|
||||||
const bytes = new Uint8Array(0xa0000);
|
|
||||||
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
|
|
||||||
super(bytes);
|
|
||||||
this.bytes = bytes;
|
|
||||||
for (const id of ["24:7", "24:6"]) {
|
|
||||||
this.fixtures.set(id, {
|
|
||||||
id,
|
|
||||||
group: 24,
|
|
||||||
number: Number(id.split(":")[1]),
|
|
||||||
name: id,
|
|
||||||
location: id,
|
|
||||||
landmark: 1,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
border: 0,
|
|
||||||
tileset: 1,
|
|
||||||
environment: 1,
|
|
||||||
paletteMode: 1,
|
|
||||||
blocks: new Uint8Array(16),
|
|
||||||
warps: [],
|
|
||||||
backgroundEvents: [],
|
|
||||||
objects: [],
|
|
||||||
connections: [],
|
|
||||||
sceneScripts: [],
|
|
||||||
callbacks: [],
|
|
||||||
coordinateEvents: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override map(id: string) {
|
|
||||||
return this.fixtures.get(id)!;
|
|
||||||
}
|
|
||||||
override isPorted(id: string) {
|
|
||||||
return this.fixtures.has(id);
|
|
||||||
}
|
|
||||||
override sceneOwner() {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
override collision(map: GameMap, x: number, y: number) {
|
|
||||||
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
|
|
||||||
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
|
|
||||||
}
|
|
||||||
override permission(collision: number) {
|
|
||||||
return collision === 7 ? 15 : 0;
|
|
||||||
}
|
|
||||||
override grassCollisions() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
override sprite() {
|
|
||||||
return { graphics: new Uint8Array(0), palette: 0, walking: false };
|
|
||||||
}
|
|
||||||
tile(x: number, y: number, collision: number, map = "24:7") {
|
|
||||||
this.tiles.set(`${map}:${x},${y}`, collision);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixture() {
|
|
||||||
const assets = new MovementAssets();
|
|
||||||
const world = new GameWorld(
|
|
||||||
assets,
|
|
||||||
() => new Date("2026-09-22T12:00:00"),
|
|
||||||
() => 0,
|
|
||||||
);
|
|
||||||
return { assets, world };
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWarp(assets: MovementAssets, x = 3, y = 4) {
|
|
||||||
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
|
|
||||||
assets
|
|
||||||
.map("24:6")
|
|
||||||
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
|
|
||||||
assets.tile(2, 2, 0x70, "24:6");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("native ledge movement", () => {
|
describe("native ledge movement", () => {
|
||||||
it.each<[number, Direction]>([
|
it.each<[number, Direction]>([
|
||||||
@@ -105,8 +25,12 @@ describe("native ledge movement", () => {
|
|||||||
const [dx, dy] = VECTORS[direction];
|
const [dx, dy] = VECTORS[direction];
|
||||||
assets.tile(3, 3, tile);
|
assets.tile(3, 3, tile);
|
||||||
assets.tile(3 + dx, 3 + dy, 7);
|
assets.tile(3 + dx, 3 + dy, 7);
|
||||||
|
// Facing the ledge already, so no turn in place precedes the jump.
|
||||||
|
world.restore({ ...world.save(), direction });
|
||||||
world.tick(direction);
|
world.tick(direction);
|
||||||
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
|
// StepFunction_PlayerJump: the map position reaches the tile jumped
|
||||||
|
// over first, and the landing tile halfway through.
|
||||||
|
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
|
||||||
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
|
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
|
||||||
x: 48 + dx * 2,
|
x: 48 + dx * 2,
|
||||||
y: 48 + dy * 2,
|
y: 48 + dy * 2,
|
||||||
@@ -114,12 +38,20 @@ describe("native ledge movement", () => {
|
|||||||
shadow: dx ? 12 : 14,
|
shadow: dx ? 12 : 14,
|
||||||
});
|
});
|
||||||
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
|
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
|
||||||
for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
|
for (let tick = 1; tick < WALK_TICKS; tick++) world.tick();
|
||||||
expect(world.busy).toBe(true);
|
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
|
||||||
|
world.tick();
|
||||||
|
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
|
||||||
|
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
|
||||||
|
for (let tick = WALK_TICKS + 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
|
||||||
|
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeDefined();
|
||||||
|
world.tick();
|
||||||
|
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
|
||||||
|
// The landing's tile events run on the next tick (PLAYERSTEP_STOP).
|
||||||
|
expect([world.busy, world.tilesWalked]).toEqual([true, 0]);
|
||||||
world.tick();
|
world.tick();
|
||||||
expect(world.busy).toBe(false);
|
expect(world.busy).toBe(false);
|
||||||
expect(world.tilesWalked).toBe(1);
|
expect(world.tilesWalked).toBe(1);
|
||||||
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,17 +66,15 @@ describe("native ledge movement", () => {
|
|||||||
expect(world.tilesWalked).toBe(2);
|
expect(world.tilesWalked).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => {
|
it("refuses the wrong direction, blocked landings and map-edge jumps", () => {
|
||||||
const { assets, world } = fixture();
|
const { assets, world } = fixture();
|
||||||
assets.tile(3, 3, 0xa3);
|
assets.tile(3, 3, 0xa3);
|
||||||
assets.tile(3, 2, 7);
|
assets.tile(3, 2, 7);
|
||||||
expect(world.move("up")).toBe(false);
|
expect(world.move("up")).toBe(false);
|
||||||
assets.tile(3, 4, 7);
|
assets.tile(3, 4, 7);
|
||||||
for (const landing of [7, 0x30, 0x40, 0x60]) {
|
assets.tile(3, 5, 7);
|
||||||
assets.tile(3, 5, landing);
|
|
||||||
expect(world.move("down")).toBe(false);
|
expect(world.move("down")).toBe(false);
|
||||||
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
|
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
|
||||||
}
|
|
||||||
assets.tile(3, 6, 0xa3);
|
assets.tile(3, 6, 0xa3);
|
||||||
assets.tile(3, 7, 7);
|
assets.tile(3, 7, 7);
|
||||||
world.restore({ ...world.save(), y: 6 });
|
world.restore({ ...world.save(), y: 6 });
|
||||||
@@ -262,6 +192,9 @@ describe("native warp movement", () => {
|
|||||||
2,
|
2,
|
||||||
"down",
|
"down",
|
||||||
]);
|
]);
|
||||||
|
// WarpToNewMapScript's `end` takes the first tick in the new map.
|
||||||
|
world.tick("up");
|
||||||
|
expect([world.x, world.y, world.busy]).toEqual([2, 2, false]);
|
||||||
world.tick("up"); // Forced exit overrides held input.
|
world.tick("up"); // Forced exit overrides held input.
|
||||||
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
|
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
|
||||||
const save = world.save();
|
const save = world.save();
|
||||||
@@ -294,7 +227,7 @@ describe("native warp movement", () => {
|
|||||||
assets
|
assets
|
||||||
.map("24:7")
|
.map("24:7")
|
||||||
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
|
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
|
||||||
world.restore({ ...world.save(), x: 7 });
|
world.restore({ ...world.save(), x: 7, direction: "right" });
|
||||||
world.tick("right");
|
world.tick("right");
|
||||||
const save = world.save();
|
const save = world.save();
|
||||||
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
|
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
|
||||||
|
|||||||
+19
-3
@@ -106,7 +106,10 @@ describe("native ROM formats", () => {
|
|||||||
it("decodes movement data and refuses unported movement commands", () => {
|
it("decodes movement data and refuses unported movement commands", () => {
|
||||||
const reader = new RomReader(
|
const reader = new RomReader(
|
||||||
Uint8Array.from([
|
Uint8Array.from([
|
||||||
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x4c, 0x47,
|
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x5a, 0x47,
|
||||||
|
// turn_step, turn_in, set_sliding, step_dig 16, skyfall,
|
||||||
|
// step_shake $54, fish_got_bite, step_bump, step_wait_end 5
|
||||||
|
0x05, 0x26, 0x39, 0x4f, 16, 0x4e, 0x55, 0x54, 0x51, 0x50, 0x48, 5,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(readMovement(reader, 0)).toEqual([
|
expect(readMovement(reader, 0)).toEqual([
|
||||||
@@ -119,6 +122,17 @@ describe("native ROM formats", () => {
|
|||||||
{ kind: "sleep", ticks: 12 },
|
{ kind: "sleep", ticks: 12 },
|
||||||
]);
|
]);
|
||||||
expect(() => readMovement(reader, 9)).toThrow("not ported");
|
expect(() => readMovement(reader, 9)).toThrow("not ported");
|
||||||
|
expect(readMovement(reader, 11)).toEqual([
|
||||||
|
{ kind: "turn-step", direction: "up", ticks: 3 },
|
||||||
|
{ kind: "step", direction: "left", ticks: 8, animate: true, spin: true },
|
||||||
|
{ kind: "sliding", sliding: true },
|
||||||
|
{ kind: "pose", pose: "spin", ticks: 16 },
|
||||||
|
{ kind: "pose", pose: "skyfall", ticks: 31 },
|
||||||
|
{ kind: "shake", displacement: 0x54 },
|
||||||
|
{ kind: "pose", pose: "fish-bite", ticks: 8 },
|
||||||
|
{ kind: "bump", ticks: 1 },
|
||||||
|
{ kind: "wait-end", ticks: 5 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
it("reads RAM-backed names and string buffers only from native values", () => {
|
it("reads RAM-backed names and string buffers only from native values", () => {
|
||||||
// text_ram wStringBuffer4, " ", text_buffer 0, done
|
// text_ram wStringBuffer4, " ", text_buffer 0, done
|
||||||
@@ -192,7 +206,7 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
load(),
|
load(),
|
||||||
() => new Date("2026-09-22T12:00:00"),
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
);
|
);
|
||||||
world.restore({ ...world.save(), x: 3, y: 2, direction: "down" });
|
world.restore({ ...world.save(), x: 3, y: 2, direction: "right" });
|
||||||
const ticks: number[] = [];
|
const ticks: number[] = [];
|
||||||
for (let tick = 1; tick <= 40 && world.x < 7; tick++) {
|
for (let tick = 1; tick <= 40 && world.x < 7; tick++) {
|
||||||
const x = world.x;
|
const x = world.x;
|
||||||
@@ -415,11 +429,13 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
objects: [],
|
objects: [],
|
||||||
});
|
});
|
||||||
world.tick(direction);
|
world.tick(direction);
|
||||||
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
|
// The first half of the jump occupies the tile jumped over.
|
||||||
|
expect([world.x, world.y]).toEqual([x + dx, y + dy]);
|
||||||
expect(
|
expect(
|
||||||
world.poses().find((pose) => pose.id === 0)?.lift,
|
world.poses().find((pose) => pose.id === 0)?.lift,
|
||||||
).toBeLessThan(0);
|
).toBeLessThan(0);
|
||||||
settle(world);
|
settle(world);
|
||||||
|
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
|
||||||
expect(world.notice).toBe("");
|
expect(world.notice).toBe("");
|
||||||
checked.add(collision);
|
checked.add(collision);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { initialDecorations } from "../src/game/world/decorations";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import { emptyScriptState } from "../src/game/scripts/interpreter";
|
||||||
|
import type { Direction } from "../src/game/world/types";
|
||||||
|
import type { ReferenceTick } from "./reference-overworld";
|
||||||
|
import { TilesetAnimator } from "../src/game/rendering/tileset-animation";
|
||||||
|
|
||||||
|
const romPath = resolve(
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||||
|
);
|
||||||
|
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
|
||||||
|
|
||||||
|
/** Labels the reference driver reads or calls. */
|
||||||
|
const LABELS = [
|
||||||
|
"ResetWRAM",
|
||||||
|
"NewBarkTown_TeacherRunsToYouMovement1",
|
||||||
|
"FinishContinueFunction",
|
||||||
|
"wDefaultSpawnpoint",
|
||||||
|
"wMapGroup",
|
||||||
|
"wMapNumber",
|
||||||
|
"wXCoord",
|
||||||
|
"wYCoord",
|
||||||
|
"hMapEntryMethod",
|
||||||
|
"wPlayerMapX",
|
||||||
|
"wPlayerMapY",
|
||||||
|
"wPlayerDirection",
|
||||||
|
"wMapStatus",
|
||||||
|
"wOverworldDelay",
|
||||||
|
"wStatusFlags",
|
||||||
|
"wTimeOfDay",
|
||||||
|
"wNewBarkTownSceneID",
|
||||||
|
"wPlayersHouse1FSceneID",
|
||||||
|
"wPlayerStruct",
|
||||||
|
"wTileAnimationTimer",
|
||||||
|
"hTileAnimFrame",
|
||||||
|
"wTileAnimBuffer",
|
||||||
|
"wEventFlags",
|
||||||
|
"wCelebiEvent",
|
||||||
|
"wTinTower1FSceneID",
|
||||||
|
];
|
||||||
|
|
||||||
|
function referenceSymbols() {
|
||||||
|
const table: Record<string, { bank: number; address: number }> = {};
|
||||||
|
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
|
||||||
|
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
|
||||||
|
if (match && LABELS.includes(match[3]))
|
||||||
|
table[match[3]] = {
|
||||||
|
bank: parseInt(match[1], 16),
|
||||||
|
address: parseInt(match[2], 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Inputs = (Direction | undefined)[];
|
||||||
|
/** Hold `direction` (or nothing) for `ticks` overworld ticks. */
|
||||||
|
const run = (direction: Direction | undefined, ticks: number): Inputs =>
|
||||||
|
Array.from({ length: ticks }, () => direction);
|
||||||
|
|
||||||
|
interface Scenario {
|
||||||
|
name: string;
|
||||||
|
start: { map: string; x: number; y: number };
|
||||||
|
inputs: Inputs;
|
||||||
|
/** Maps entered on the way that are not enabled in normal play. */
|
||||||
|
extraMaps?: string[];
|
||||||
|
/** Scene variables set in both engines, keyed by the owning map and its
|
||||||
|
* wram label, so that coordinate events stay quiet. */
|
||||||
|
scenes?: { map: string; label: string; value: number }[];
|
||||||
|
/** Map objects whose positions are compared too (a scene's actors). */
|
||||||
|
objects?: number[];
|
||||||
|
patchMovement?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeTrace(bytes: Uint8Array, scenario: Scenario, timeOfDay: number) {
|
||||||
|
const assets = new CrystalAssets(bytes);
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
const extra = new Set(scenario.extraMaps ?? []);
|
||||||
|
assets.isPorted = (id) => ported(id) || extra.has(id);
|
||||||
|
// The original's clock decides which timed objects appear; match it.
|
||||||
|
const hour = [7, 12, 22][timeOfDay] ?? 12;
|
||||||
|
const now = () =>
|
||||||
|
new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`);
|
||||||
|
const world = new GameWorld(assets, now, () => 0);
|
||||||
|
// _ResetWRAM clears every event and engine flag before this warp.
|
||||||
|
world.state = emptyScriptState();
|
||||||
|
world.state.decorations = initialDecorations();
|
||||||
|
for (const scene of scenario.scenes ?? [])
|
||||||
|
world.state.scenes[scene.map] = scene.value;
|
||||||
|
world.warpTo(scenario.start.map, scenario.start.x, scenario.start.y);
|
||||||
|
return scenario.inputs.map((held) => {
|
||||||
|
world.tick(held);
|
||||||
|
if (world.notice) throw new Error(`${world.notice} ${world.diagnostic}`);
|
||||||
|
const objects: Record<number, [number, number]> = {};
|
||||||
|
for (const id of scenario.objects ?? []) {
|
||||||
|
const object = world.objects().find((candidate) => candidate.id === id);
|
||||||
|
if (object) objects[id] = [object.x, object.y];
|
||||||
|
}
|
||||||
|
const poses: ReferenceTick["poses"] = {};
|
||||||
|
// Blocking text is sampled before ApplyObjectFacing in the original
|
||||||
|
// and after it natively. Compare poses only while movement is running.
|
||||||
|
if (scenario.patchMovement && world.activity === "scene" && !world.dialogue)
|
||||||
|
for (const id of scenario.objects ?? []) {
|
||||||
|
const object = world.objects().find((o) => o.id === id);
|
||||||
|
const pose = world.poses().find((p) => p.id === id);
|
||||||
|
if (object)
|
||||||
|
poses[id] = {
|
||||||
|
direction: object.direction,
|
||||||
|
visible: Boolean(pose),
|
||||||
|
lift: pose?.lift ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// The original samples a tick that waits in text before the text, so
|
||||||
|
// native state is taken before pressing A through the pages; the rest
|
||||||
|
// of the script then runs within the same tick in both engines.
|
||||||
|
const sample = {
|
||||||
|
map: world.mapId,
|
||||||
|
x: world.x,
|
||||||
|
y: world.y,
|
||||||
|
facing: world.direction,
|
||||||
|
objects,
|
||||||
|
poses,
|
||||||
|
events: [...world.liveState.events].sort((a, b) => a - b),
|
||||||
|
};
|
||||||
|
for (let page = 0; world.dialogue && page < 64; page++) {
|
||||||
|
if (world.dialogue.holdTicks !== undefined) break;
|
||||||
|
if (world.dialogue.choice) world.interact();
|
||||||
|
else world.advanceDialogue();
|
||||||
|
}
|
||||||
|
return sample;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function referenceTrace(
|
||||||
|
page: Page,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
scenario: Scenario,
|
||||||
|
) {
|
||||||
|
const symbols = referenceSymbols();
|
||||||
|
return page.evaluate(
|
||||||
|
async ({ rom, symbols, scenario }) => {
|
||||||
|
const driver = await import("/tests/reference-overworld.ts");
|
||||||
|
const start = await driver.bootReference(
|
||||||
|
new Uint8Array(rom),
|
||||||
|
symbols,
|
||||||
|
scenario.start,
|
||||||
|
(scenario.scenes ?? []).map(({ label, value }) => ({ label, value })),
|
||||||
|
);
|
||||||
|
const trace = driver.traceReference(scenario.inputs);
|
||||||
|
driver.disposeReference();
|
||||||
|
return { start, trace };
|
||||||
|
},
|
||||||
|
{ rom: [...bytes], symbols, scenario },
|
||||||
|
) as Promise<{
|
||||||
|
start: ReferenceTick & { timeOfDay: number };
|
||||||
|
trace: ReferenceTick[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tick that began a warp or connection is still sampled on the old map
|
||||||
|
* in the original, before map setup runs; native enters the map at once.
|
||||||
|
* Those ticks are skipped; every other tick must match exactly. */
|
||||||
|
function compare(
|
||||||
|
native: ReturnType<typeof nativeTrace>,
|
||||||
|
reference: ReferenceTick[],
|
||||||
|
) {
|
||||||
|
const differences: string[] = [];
|
||||||
|
reference.forEach((original, tick) => {
|
||||||
|
if (original.status === 1) {
|
||||||
|
// Map setup blocks inside the original tick. Compare its arrival at
|
||||||
|
// the first sample after setup with the native transition result.
|
||||||
|
const arrival = reference
|
||||||
|
.slice(tick + 1)
|
||||||
|
.find((sample) => sample.status !== 1);
|
||||||
|
if (!arrival)
|
||||||
|
throw new Error("Trace ends before a map transition completes.");
|
||||||
|
if (native[tick].map !== arrival.map)
|
||||||
|
differences.push(
|
||||||
|
`transition ${tick + 1}: native ${native[tick].map}, original destination ${arrival.map}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
status: _status,
|
||||||
|
objects: all,
|
||||||
|
poses: allPoses,
|
||||||
|
...player
|
||||||
|
} = original;
|
||||||
|
const expected = {
|
||||||
|
...player,
|
||||||
|
poses: Object.fromEntries(
|
||||||
|
Object.keys(native[tick].poses).map((id) => {
|
||||||
|
const pose = allPoses[Number(id)];
|
||||||
|
return [id, { ...pose, lift: pose.visible ? pose.lift : 0 }];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
objects: Object.fromEntries(
|
||||||
|
Object.keys(native[tick].objects).map((id) => [id, all[Number(id)]]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (!isDeepStrictEqual(native[tick], expected))
|
||||||
|
differences.push(
|
||||||
|
`tick ${tick + 1}: native ${JSON.stringify(native[tick])}, original ${JSON.stringify(expected)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return differences;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUIET_TOWN = { map: "24:4", label: "wNewBarkTownSceneID", value: 1 };
|
||||||
|
const QUIET_HOUSE = { map: "24:6", label: "wPlayersHouse1FSceneID", value: 1 };
|
||||||
|
|
||||||
|
const SCENARIOS: Scenario[] = [
|
||||||
|
{
|
||||||
|
name: "New Bark Town: turning, tapping, walking and bumping",
|
||||||
|
start: { map: "24:4", x: 9, y: 6 },
|
||||||
|
inputs: [
|
||||||
|
...run("right", 14),
|
||||||
|
...run(undefined, 6),
|
||||||
|
...run("left", 1),
|
||||||
|
...run(undefined, 6),
|
||||||
|
...run("up", 2),
|
||||||
|
...run(undefined, 3),
|
||||||
|
...run("down", 10),
|
||||||
|
...run("left", 9),
|
||||||
|
...run(undefined, 12),
|
||||||
|
...run("up", 20),
|
||||||
|
...run("right", 3),
|
||||||
|
...run(undefined, 4),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "New Bark Town to Route 29 and back across the connection",
|
||||||
|
start: { map: "24:4", x: 4, y: 9 },
|
||||||
|
scenes: [QUIET_TOWN],
|
||||||
|
inputs: [
|
||||||
|
...run("left", 60),
|
||||||
|
...run(undefined, 3),
|
||||||
|
...run("right", 50),
|
||||||
|
...run(undefined, 10),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Route 29 ledge jump and walking on",
|
||||||
|
start: { map: "24:3", x: 25, y: 6 },
|
||||||
|
inputs: [
|
||||||
|
...run("down", 44),
|
||||||
|
...run(undefined, 20),
|
||||||
|
...run("up", 12),
|
||||||
|
...run(undefined, 6),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Interior: the house's stairs, the bedroom and the front door",
|
||||||
|
start: { map: "24:6", x: 8, y: 5 },
|
||||||
|
scenes: [QUIET_HOUSE, QUIET_TOWN],
|
||||||
|
inputs: [
|
||||||
|
...run("up", 40),
|
||||||
|
...run("right", 6),
|
||||||
|
...run("up", 12),
|
||||||
|
...run(undefined, 30),
|
||||||
|
...run("down", 6),
|
||||||
|
...run("left", 12),
|
||||||
|
...run("up", 20),
|
||||||
|
...run("right", 30),
|
||||||
|
...run(undefined, 30),
|
||||||
|
...run("left", 6),
|
||||||
|
...run("down", 40),
|
||||||
|
...run("left", 10),
|
||||||
|
...run("down", 40),
|
||||||
|
...run(undefined, 30),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scene: the teacher stops the player at New Bark Town's west edge",
|
||||||
|
start: { map: "24:4", x: 4, y: 8 },
|
||||||
|
objects: [2],
|
||||||
|
inputs: [...run("left", 30), ...run(undefined, 60)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cave: Ice Path slides, bumps and turns on ice",
|
||||||
|
start: { map: "3:61", x: 15, y: 14 },
|
||||||
|
extraMaps: ["3:61"],
|
||||||
|
inputs: [
|
||||||
|
...run("up", 30),
|
||||||
|
...run("left", 40),
|
||||||
|
...run(undefined, 10),
|
||||||
|
...run("down", 20),
|
||||||
|
...run("right", 30),
|
||||||
|
...run(undefined, 10),
|
||||||
|
...run("up", 3),
|
||||||
|
...run(undefined, 20),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, movement] of [
|
||||||
|
["scripted spin", [0x4f, 16, 0x47]],
|
||||||
|
["teleport out and in", [0x4c, 0x4d, 0x47]],
|
||||||
|
["skyfall", [0x4e, 0x47]],
|
||||||
|
["skyfall departure", [0x59, 0x47]],
|
||||||
|
["tree shake", [0x56, 0x47]],
|
||||||
|
["fishing", [0x51, 0x52, 0x47]],
|
||||||
|
["flicker", [0x57, 8, 0x47]],
|
||||||
|
["spin flicker", [0x58, 8, 0x47]],
|
||||||
|
] as const)
|
||||||
|
SCENARIOS.push({
|
||||||
|
name: `Scene movement: ${name}`,
|
||||||
|
start: { map: "24:4", x: 4, y: 8 },
|
||||||
|
objects: [2],
|
||||||
|
patchMovement: [...movement],
|
||||||
|
inputs: [...run("left", 30), ...run(undefined, 140)],
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("overworld traces match the original engine", () => {
|
||||||
|
test.skip(
|
||||||
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||||
|
"Requires the local user ROM and the private PRET rebuild.",
|
||||||
|
);
|
||||||
|
for (const scenario of SCENARIOS)
|
||||||
|
test(scenario.name, async ({ page }, info) => {
|
||||||
|
test.skip(
|
||||||
|
info.project.name !== "chromium",
|
||||||
|
"Engine traces are browser-independent.",
|
||||||
|
);
|
||||||
|
await page.goto("/reference.html");
|
||||||
|
const bytes = new Uint8Array(readFileSync(romPath));
|
||||||
|
if (scenario.patchMovement) {
|
||||||
|
// Replace only a five-byte movement list in the in-memory test ROM.
|
||||||
|
// Both engines execute the same scene and movement bytecode.
|
||||||
|
const symbol = referenceSymbols().NewBarkTown_TeacherRunsToYouMovement1;
|
||||||
|
bytes.set(
|
||||||
|
scenario.patchMovement,
|
||||||
|
symbol.bank * 0x4000 + symbol.address - 0x4000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { start, trace: reference } = await referenceTrace(
|
||||||
|
page,
|
||||||
|
bytes,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
expect([start.map, start.x, start.y]).toEqual([
|
||||||
|
scenario.start.map,
|
||||||
|
scenario.start.x,
|
||||||
|
scenario.start.y,
|
||||||
|
]);
|
||||||
|
const native = nativeTrace(bytes, scenario, start.timeOfDay);
|
||||||
|
const differences = compare(native, reference);
|
||||||
|
if (differences.length && process.env.TRACE)
|
||||||
|
console.log(
|
||||||
|
reference
|
||||||
|
.map(
|
||||||
|
(original, tick) =>
|
||||||
|
`${tick + 1} ${JSON.stringify(scenario.inputs[tick] ?? "-")} ref ${original.x},${original.y},${original.facing} ${JSON.stringify(Object.fromEntries((scenario.objects ?? []).map((id) => [id, original.objects[id]])))} nat ${native[tick].x},${native[tick].y},${native[tick].facing} ${JSON.stringify(native[tick].objects)}`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
expect(differences.slice(0, 8)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("tile animation matches the original's VRAM", () => {
|
||||||
|
test.skip(
|
||||||
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||||
|
"Requires the local user ROM and the private PRET rebuild.",
|
||||||
|
);
|
||||||
|
for (const start of [
|
||||||
|
{ name: "Johto water and flowers", map: "24:4", x: 9, y: 6 },
|
||||||
|
{ name: "Ice Path's scrolling cave water", map: "3:61", x: 15, y: 14 },
|
||||||
|
{
|
||||||
|
name: "Ilex Forest restless trees",
|
||||||
|
map: "3:52",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
restless: true,
|
||||||
|
},
|
||||||
|
{ name: "Tin Tower pillars", map: "3:5", x: 9, y: 7 },
|
||||||
|
])
|
||||||
|
test(start.name, async ({ page }, info) => {
|
||||||
|
test.skip(
|
||||||
|
info.project.name !== "chromium",
|
||||||
|
"Engine traces are browser-independent.",
|
||||||
|
);
|
||||||
|
await page.goto("/reference.html");
|
||||||
|
const bytes = new Uint8Array(readFileSync(romPath));
|
||||||
|
const symbols = referenceSymbols();
|
||||||
|
const reference = await page.evaluate(
|
||||||
|
async ({ rom, symbols, start }) => {
|
||||||
|
const driver = await import("/tests/reference-overworld.ts");
|
||||||
|
await driver.bootReference(
|
||||||
|
new Uint8Array(rom),
|
||||||
|
symbols,
|
||||||
|
start,
|
||||||
|
start.restless
|
||||||
|
? [{ label: "wCelebiEvent", value: 4 }]
|
||||||
|
: start.map === "3:4"
|
||||||
|
? [{ label: "wTinTower1FSceneID", value: 1 }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const peek = driver.peek();
|
||||||
|
const state = {
|
||||||
|
frame: peek(symbols.hTileAnimFrame.address),
|
||||||
|
timer: peek(symbols.wTileAnimationTimer.address),
|
||||||
|
buffer: Array.from({ length: 16 }, (_, i) =>
|
||||||
|
peek(symbols.wTileAnimBuffer.address + i),
|
||||||
|
),
|
||||||
|
tiles: driver.readTiles(),
|
||||||
|
};
|
||||||
|
const frames: number[][] = [];
|
||||||
|
for (let frame = 0; frame < 96; frame++) {
|
||||||
|
driver.stepFrame();
|
||||||
|
frames.push(driver.readTiles());
|
||||||
|
}
|
||||||
|
driver.disposeReference();
|
||||||
|
return { state, frames };
|
||||||
|
},
|
||||||
|
{ rom: [...bytes], symbols, start },
|
||||||
|
);
|
||||||
|
const assets = new CrystalAssets(bytes);
|
||||||
|
const animator = new TilesetAnimator(
|
||||||
|
assets,
|
||||||
|
assets.map(start.map),
|
||||||
|
Uint8Array.from(reference.state.tiles),
|
||||||
|
);
|
||||||
|
// Start from the original's animation state at the same moment.
|
||||||
|
Object.assign(animator, {
|
||||||
|
frame: reference.state.frame,
|
||||||
|
timer: reference.state.timer,
|
||||||
|
buffer: Uint8Array.from(reference.state.buffer),
|
||||||
|
});
|
||||||
|
const mismatches: number[] = [];
|
||||||
|
reference.frames.forEach((tiles, frame) => {
|
||||||
|
animator.advance(1, false, start.restless ?? false);
|
||||||
|
if (!tiles.every((value, index) => animator.graphics[index] === value))
|
||||||
|
mismatches.push(frame);
|
||||||
|
});
|
||||||
|
expect(mismatches).toEqual([]);
|
||||||
|
// Something actually animated.
|
||||||
|
expect(reference.frames[0]).not.toEqual(reference.frames[47]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import { emptyScriptState } from "../src/game/scripts/interpreter";
|
||||||
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import type { Direction } from "../src/game/world/types";
|
||||||
|
import { settle, walkTo } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
const BURNED_TOWER_1F = "3:13",
|
||||||
|
BURNED_TOWER_B1F = "3:14";
|
||||||
|
const EVENT_HOLE_IN_BURNED_TOWER = 818;
|
||||||
|
|
||||||
|
/** A world on maps that normal play does not enable yet. */
|
||||||
|
function fixture(maps: string[]) {
|
||||||
|
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
assets.isPorted = (id) => ported(id) || maps.includes(id);
|
||||||
|
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0);
|
||||||
|
world.state = emptyScriptState();
|
||||||
|
return { assets, world };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hold(world: GameWorld, direction: Direction | undefined, ticks: number) {
|
||||||
|
for (let tick = 0; tick < ticks; tick++) world.tick(direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("overworld foundation with the local ROM", () => {
|
||||||
|
it("changeblock in Burned Tower's TILES callback closes the hole until the event is set", () => {
|
||||||
|
const { assets, world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
|
||||||
|
world.state.scenes[BURNED_TOWER_1F] = 2; // SCENE_BURNEDTOWER1F_NOOP
|
||||||
|
world.warpTo(BURNED_TOWER_1F, 10, 10);
|
||||||
|
expect(assets.permission(assets.collision(world.map, 10, 9))).toBe(0);
|
||||||
|
expect(assets.collision(world.map, 10, 9)).not.toBe(0x60);
|
||||||
|
// The ROM's own block data still has the hole; only the working copy changed.
|
||||||
|
expect(assets.collision(assets.map(BURNED_TOWER_1F), 10, 9)).toBe(0x60);
|
||||||
|
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
|
||||||
|
world.warpTo(BURNED_TOWER_1F, 10, 10);
|
||||||
|
expect(assets.collision(world.map, 10, 9)).toBe(0x60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through a pit with FallIntoMapScript's skyfall and earthquake", () => {
|
||||||
|
const { world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
|
||||||
|
world.state.scenes[BURNED_TOWER_1F] = 2;
|
||||||
|
world.state.scenes[BURNED_TOWER_B1F] = 1;
|
||||||
|
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
|
||||||
|
world.warpTo(BURNED_TOWER_1F, 10, 10);
|
||||||
|
// Turn, then walk up onto the hole.
|
||||||
|
hold(world, "up", 4);
|
||||||
|
hold(world, "up", 8);
|
||||||
|
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_1F, 10, 9]);
|
||||||
|
// The arrival tick falls through; the next starts the rest of the script.
|
||||||
|
hold(world, undefined, 2);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_B1F, 10, 9]);
|
||||||
|
expect(world.activity).toBe("scene");
|
||||||
|
// Invisible above the screen at first, then falling into view.
|
||||||
|
expect(world.poses().some((pose) => pose.id === 0)).toBe(false);
|
||||||
|
let shook = false;
|
||||||
|
for (let tick = 0; tick < 200 && world.busy; tick++) {
|
||||||
|
world.tick();
|
||||||
|
if (world.screenShake) shook = true;
|
||||||
|
}
|
||||||
|
expect(world.busy).toBe(false);
|
||||||
|
expect(shook).toBe(true);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ x: 160, y: 144 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rides the Goldenrod Dept. Store elevator through its menu and -1 warps", () => {
|
||||||
|
const [store1F, store3F, elevator] = ["11:11", "11:13", "11:17"];
|
||||||
|
const { world } = fixture([store1F, store3F, elevator]);
|
||||||
|
world.warpTo(store1F, 2, 1);
|
||||||
|
walkTo(world, 2, 0);
|
||||||
|
expect([world.mapId, world.x, world.y]).toEqual([elevator, 1, 3]);
|
||||||
|
// The arrival warp is numbered -1, so the entrance became the backup.
|
||||||
|
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
|
||||||
|
walkTo(world, 3, 1);
|
||||||
|
world.move("up");
|
||||||
|
world.interact();
|
||||||
|
const menu = world.dialogue!;
|
||||||
|
const shown = settle(world, [3]);
|
||||||
|
expect(shown.join("\n")).toContain("Now on: 1F");
|
||||||
|
expect(menu.choice).toMatchObject({ kind: "menu" });
|
||||||
|
expect((menu.choice as { items: string[] }).items).toEqual([
|
||||||
|
"B1F", "1F", "2F", "3F", "4F", "5F", "6F",
|
||||||
|
]);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(world.state.backupWarp).toEqual({ map: store3F, warp: 3 });
|
||||||
|
walkTo(world, 2, 3);
|
||||||
|
world.move("down");
|
||||||
|
settle(world);
|
||||||
|
// Arrival at warp 3 (2, 0), then the doorway's forced step down.
|
||||||
|
expect([world.mapId, world.x, world.y]).toEqual([store3F, 2, 1]);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the floor when the elevator menu is cancelled or the same floor is chosen", () => {
|
||||||
|
const [store1F, elevator] = ["11:11", "11:17"];
|
||||||
|
const { world } = fixture([store1F, elevator]);
|
||||||
|
world.warpTo(store1F, 2, 1);
|
||||||
|
walkTo(world, 2, 0);
|
||||||
|
walkTo(world, 3, 1);
|
||||||
|
world.move("up");
|
||||||
|
for (const answer of [-1, 1]) {
|
||||||
|
world.interact();
|
||||||
|
settle(world, [answer]);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wild encounters (CanEncounterWildMon and ChooseWildEncounter)", () => {
|
||||||
|
const UNION_CAVE_1F = "3:37";
|
||||||
|
const cave = () => {
|
||||||
|
const { assets, world } = fixture([UNION_CAVE_1F]);
|
||||||
|
world.state.party = [
|
||||||
|
generatePartyMon(assets.pokemon, {
|
||||||
|
species: 155, level: 20, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return { assets, world };
|
||||||
|
};
|
||||||
|
/** Walk back and forth until a battle starts or the steps run out. */
|
||||||
|
const pace = (world: GameWorld, steps: number, directions: Direction[]) => {
|
||||||
|
for (let step = 0; step < steps && !world.battle; step++) {
|
||||||
|
world.move(directions[step % directions.length]);
|
||||||
|
for (let tick = 0; tick < 4 && !world.battle; tick++) world.tick();
|
||||||
|
}
|
||||||
|
return world.battle?.engine;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("starts on any cave floor after the five-step cooldown", () => {
|
||||||
|
const { world } = cave();
|
||||||
|
world.warpTo(UNION_CAVE_1F, 9, 6);
|
||||||
|
const battle = pace(world, 12, ["right", "left"]);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(battle).toBeDefined();
|
||||||
|
expect(world.tilesWalked).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays off with wildoff", () => {
|
||||||
|
const { world } = cave();
|
||||||
|
world.state.noWildEncounters = true;
|
||||||
|
world.warpTo(UNION_CAVE_1F, 9, 6);
|
||||||
|
expect(pace(world, 12, ["right", "left"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the water table's rate and slots while surfing", () => {
|
||||||
|
const { assets, world } = cave();
|
||||||
|
const sea = assets.waterWildMons(UNION_CAVE_1F)!;
|
||||||
|
expect(sea).toBeDefined();
|
||||||
|
world.state.playerState = 4; // PLAYER_SURF
|
||||||
|
world.restore({ ...world.save(), map: UNION_CAVE_1F, x: 7, y: 4, direction: "right", objects: [] });
|
||||||
|
const battle = pace(world, 12, ["right", "left"]);
|
||||||
|
expect(battle).toBeDefined();
|
||||||
|
// Random is always 0: the first slot, with no level variety.
|
||||||
|
expect(battle!.enemy.mon).toMatchObject({
|
||||||
|
species: sea.slots[0].species,
|
||||||
|
level: sea.slots[0].level,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws overworld Pokémon from their party menu icons (GetMonSprite)", () => {
|
||||||
|
const { assets } = fixture([]);
|
||||||
|
const unown = assets.sprite(0x80)!; // SPRITE_UNOWN
|
||||||
|
expect(unown.walking).toBe(true);
|
||||||
|
expect(unown.palette).toBe(0);
|
||||||
|
expect(unown.graphics).toHaveLength(8 * 16);
|
||||||
|
expect(assets.sprite(0x80 + 35)).toBeUndefined();
|
||||||
|
expect(assets.sprite(103)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfing onto a whirlpool spins the player back with Script_ForcedMovement", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
// Find a whirlpool with water on the side a surfer comes from.
|
||||||
|
let found: { map: string; x: number; y: number } | undefined;
|
||||||
|
for (let group = 1; group <= 26 && !found; group++)
|
||||||
|
for (let number = 1; number <= 40 && !found; number++) {
|
||||||
|
let map;
|
||||||
|
try {
|
||||||
|
map = assets.map(`${group}:${number}`);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let y = 1; y < map.height - 1 && !found; y++)
|
||||||
|
for (let x = 0; x < map.width && !found; x++)
|
||||||
|
if (
|
||||||
|
assets.collision(map, x, y) === 0x24 &&
|
||||||
|
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
|
||||||
|
assets.permission(assets.collision(map, x, y + 2)) === 1 &&
|
||||||
|
!map.objects.some((o) => o.x === x && (o.y === y + 1 || o.y === y + 2))
|
||||||
|
)
|
||||||
|
found = { map: map.id, x, y };
|
||||||
|
}
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
const { map, x, y } = found!;
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
assets.isPorted = (id) => ported(id) || id === map;
|
||||||
|
world.state.playerState = 4; // PLAYER_SURF
|
||||||
|
world.restore({ ...world.save(), map, x, y: y + 2, direction: "up", objects: [] });
|
||||||
|
hold(world, "up", 8);
|
||||||
|
expect([world.x, world.y]).toEqual([x, y + 1]);
|
||||||
|
hold(world, "up", 9);
|
||||||
|
expect([world.x, world.y]).toEqual([x, y]);
|
||||||
|
expect(world.activity).toBe("scene");
|
||||||
|
const directions = new Set<string>();
|
||||||
|
for (let tick = 0; tick < 120 && world.busy; tick++) {
|
||||||
|
world.tick();
|
||||||
|
directions.add(world.direction);
|
||||||
|
}
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
// .MovementData_up: spin, turn_in DOWN back off the whirlpool, spin,
|
||||||
|
// then face down.
|
||||||
|
expect([world.x, world.y, world.direction]).toEqual([x, y + 1, "down"]);
|
||||||
|
expect(directions.size).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
|
||||||
|
const TOTODILE = 158;
|
||||||
|
const partyWith = (assets: CrystalAssets, moves: number[], species = TOTODILE) => {
|
||||||
|
const mon = generatePartyMon(assets.pokemon, {
|
||||||
|
species, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
|
||||||
|
});
|
||||||
|
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
|
||||||
|
mon.pp = [10, 10, 10, 10];
|
||||||
|
mon.nickname = "SWIMMY";
|
||||||
|
return [mon];
|
||||||
|
};
|
||||||
|
/** Find a tile on some map, searching every group. */
|
||||||
|
const find = (
|
||||||
|
assets: CrystalAssets,
|
||||||
|
test: (map: ReturnType<CrystalAssets["map"]>, x: number, y: number) => boolean,
|
||||||
|
) => {
|
||||||
|
for (let group = 1; group <= 26; group++)
|
||||||
|
for (let number = 1; number <= 60; number++) {
|
||||||
|
let map;
|
||||||
|
try {
|
||||||
|
map = assets.map(`${group}:${number}`);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let y = 1; y < map.height - 1; y++)
|
||||||
|
for (let x = 1; x < map.width - 1; x++) if (test(map, x, y)) return { map: map.id, x, y };
|
||||||
|
}
|
||||||
|
throw new Error("No suitable tile.");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("surfs from New Bark Town's shore through AskSurfScript and lands again", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
world.state.party = partyWith(assets, [0x39]); // SURF
|
||||||
|
world.state.flags.add(30); // ENGINE_FOGBADGE
|
||||||
|
world.state.scenes["24:4"] = 1;
|
||||||
|
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
|
||||||
|
world.interact();
|
||||||
|
const shown = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(shown).toContain("SWIMMY used\nSURF!");
|
||||||
|
expect(world.state.playerState).toBe(4);
|
||||||
|
expect([world.x, world.y]).toEqual([18, 7]);
|
||||||
|
// The surfer sprite (SPRITE_SURF) replaces Chris.
|
||||||
|
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(0x53);
|
||||||
|
// Water saves and reloads with the surfer on it.
|
||||||
|
world.restore(world.save());
|
||||||
|
expect(world.state.playerState).toBe(4);
|
||||||
|
world.move("left");
|
||||||
|
expect([world.x, world.y, world.state.playerState]).toEqual([17, 7, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer Surf without the Fog Badge or a Pokémon that knows it", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
world.state.party = partyWith(assets, [0x39]);
|
||||||
|
world.state.scenes["24:4"] = 1;
|
||||||
|
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
|
||||||
|
world.interact();
|
||||||
|
expect(world.dialogue).toBeUndefined();
|
||||||
|
world.state.flags.add(30);
|
||||||
|
world.state.party = partyWith(assets, [0x0f]);
|
||||||
|
world.interact();
|
||||||
|
expect(world.dialogue).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts a tree with AskCutScript until the map loads again", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
const spot = find(assets, (map, x, y) =>
|
||||||
|
[1, 2, 3, 12, 25].includes(map.tileset) &&
|
||||||
|
assets.collision(map, x, y) === 0x12 &&
|
||||||
|
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
|
||||||
|
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
|
||||||
|
map.callbacks.length === 0,
|
||||||
|
);
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
assets.isPorted = (id) => ported(id) || id === spot.map;
|
||||||
|
world.state.party = partyWith(assets, [0x0f]); // CUT
|
||||||
|
world.state.flags.add(28); // ENGINE_HIVEBADGE
|
||||||
|
world.warpTo(spot.map, spot.x, spot.y + 1);
|
||||||
|
world.restore({ ...world.save(), direction: "up" });
|
||||||
|
const before = world.map.blocks.slice();
|
||||||
|
world.interact();
|
||||||
|
const shown = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(shown).toContain("SWIMMY used\nCUT!");
|
||||||
|
expect(world.map.blocks).not.toEqual(before);
|
||||||
|
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).toBe(0);
|
||||||
|
world.move("up");
|
||||||
|
expect([world.x, world.y]).toEqual([spot.x, spot.y]);
|
||||||
|
world.warpTo(spot.map, spot.x, spot.y + 1);
|
||||||
|
expect(world.map.blocks).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains a Cut tree without the move", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
const spot = find(assets, (map, x, y) =>
|
||||||
|
assets.collision(map, x, y) === 0x12 &&
|
||||||
|
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
|
||||||
|
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
|
||||||
|
map.callbacks.length === 0,
|
||||||
|
);
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
assets.isPorted = (id) => ported(id) || id === spot.map;
|
||||||
|
world.state.party = partyWith(assets, [0x21]);
|
||||||
|
world.warpTo(spot.map, spot.x, spot.y + 1);
|
||||||
|
world.restore({ ...world.save(), direction: "up" });
|
||||||
|
world.interact();
|
||||||
|
const shown = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(shown.length).toBeGreaterThan(0);
|
||||||
|
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).not.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Strength through AskStrengthScript and pushes a boulder", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
// A Strength boulder object with floor below it and two floor tiles above.
|
||||||
|
const spot = find(assets, (map, x, y) => {
|
||||||
|
const boulder = map.objects.find(
|
||||||
|
(o) => o.x === x && o.y === y && o.movement === 25 && o.eventFlag === 0xffff,
|
||||||
|
);
|
||||||
|
return Boolean(
|
||||||
|
boulder &&
|
||||||
|
map.callbacks.length === 0 &&
|
||||||
|
[y + 1, y - 1, y - 2].every(
|
||||||
|
(row) =>
|
||||||
|
assets.permission(assets.collision(map, x, row)) === 0 &&
|
||||||
|
!map.objects.some((o) => o !== boulder && o.x === x && o.y === row),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
assets.isPorted = (id) => ported(id) || id === spot.map;
|
||||||
|
world.state.party = partyWith(assets, [0x46]); // STRENGTH
|
||||||
|
world.state.flags.add(29); // ENGINE_PLAINBADGE
|
||||||
|
world.warpTo(spot.map, spot.x, spot.y + 1);
|
||||||
|
world.restore({ ...world.save(), direction: "up" });
|
||||||
|
world.interact();
|
||||||
|
const shown = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
|
||||||
|
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
|
||||||
|
for (let tick = 0; tick < 20; tick++) world.tick("up");
|
||||||
|
const boulder = world.objects().find((o) => o.movement === 25 && o.x === spot.x);
|
||||||
|
expect(boulder?.y).toBe(spot.y - 1);
|
||||||
|
// Talking to it again: "Boulders may now be moved!"
|
||||||
|
settle(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rides the Bicycle on a route and gets off indoors", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
world.state.party = partyWith(assets, [0x21]);
|
||||||
|
world.state.bag.keyItems.push(7); // BICYCLE
|
||||||
|
world.state.scenes["24:4"] = 1;
|
||||||
|
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
|
||||||
|
expect(world.useBicycle()).toBe(true);
|
||||||
|
const shown = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(shown).toContain("got on the\nBICYCLE.");
|
||||||
|
expect(world.state.playerState).toBe(1);
|
||||||
|
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(2); // SPRITE_CHRIS_BIKE
|
||||||
|
// Turn left (four ticks), then four ticks per tile on the bike.
|
||||||
|
for (let tick = 0; tick < 4; tick++) world.tick("left");
|
||||||
|
world.tick("left");
|
||||||
|
expect(world.x).toBe(12);
|
||||||
|
for (let tick = 0; tick < 4; tick++) world.tick("left");
|
||||||
|
expect(world.x).toBe(11);
|
||||||
|
// Entering the house (indoors) gets the player off (CheckUpdatePlayerSprite).
|
||||||
|
settle(world);
|
||||||
|
walkTo(world, 13, 5);
|
||||||
|
expect(world.mapId).toBe("24:6");
|
||||||
|
expect(world.state.playerState).toBe(0);
|
||||||
|
expect(world.useBicycle()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Whirlpool and Waterfall only with the move and badge", () => {
|
||||||
|
const { assets, world } = fixture([]);
|
||||||
|
world.state.party = partyWith(assets, [0x39]);
|
||||||
|
const whirlpool = find(assets, (map, x, y) =>
|
||||||
|
assets.collision(map, x, y) === 0x24 &&
|
||||||
|
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
|
||||||
|
!map.objects.some((o) => o.x === x && o.y === y + 1),
|
||||||
|
);
|
||||||
|
const ported = assets.isPorted.bind(assets);
|
||||||
|
assets.isPorted = (id) => ported(id) || id === whirlpool.map;
|
||||||
|
world.state.playerState = 4;
|
||||||
|
world.restore({ ...world.save(), map: whirlpool.map, x: whirlpool.x, y: whirlpool.y + 1, direction: "up", objects: [] });
|
||||||
|
world.interact();
|
||||||
|
const mighty = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(mighty.length).toBeGreaterThan(0);
|
||||||
|
world.state.party = partyWith(assets, [0x39, 250]); // SURF, WHIRLPOOL
|
||||||
|
world.state.flags.add(33); // ENGINE_GLACIERBADGE
|
||||||
|
const before = world.map.blocks.slice();
|
||||||
|
world.interact();
|
||||||
|
const used = settle(world).join("\n");
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(used).toContain("used\nWHIRLPOOL!");
|
||||||
|
expect(world.map.blocks).not.toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
|
||||||
|
it("drops a boulder pushed into Ice Path B1F's hole through its stone table script", () => {
|
||||||
|
const ICE_PATH_B1F = "3:62";
|
||||||
|
const EVENT_BOULDER_IN_ICE_PATH_2A = 1806;
|
||||||
|
const { assets, world } = fixture([ICE_PATH_B1F]);
|
||||||
|
const mon = generatePartyMon(assets.pokemon, {
|
||||||
|
species: 158, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
|
||||||
|
});
|
||||||
|
world.state.party = [mon];
|
||||||
|
world.warpTo(ICE_PATH_B1F, 4, 5);
|
||||||
|
world.state.flags.add(24); // ENGINE_STRENGTH_ACTIVE
|
||||||
|
world.state.events.add(EVENT_BOULDER_IN_ICE_PATH_2A);
|
||||||
|
world.restore({
|
||||||
|
...world.save(),
|
||||||
|
x: 4,
|
||||||
|
y: 5,
|
||||||
|
direction: "down",
|
||||||
|
objects: [{ id: 3, x: 4, y: 6, direction: "down" }],
|
||||||
|
});
|
||||||
|
world.state.flags.add(24);
|
||||||
|
expect(world.objects().some((o) => o.id === 3)).toBe(true);
|
||||||
|
world.tick("down");
|
||||||
|
const shown: string[] = [];
|
||||||
|
for (let tick = 0; tick < 40 && world.activity !== "scene"; tick++) world.tick();
|
||||||
|
expect(world.activity).toBe("scene");
|
||||||
|
shown.push(...settle(world));
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(shown.join("\n")).toMatch(/fell\s+through/i);
|
||||||
|
expect(world.objects().some((o) => o.id === 3)).toBe(false);
|
||||||
|
expect(world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
|
||||||
|
test("all tileset palettes match the original LoadMapPals routine", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
test.skip(
|
||||||
|
info.project.name !== "chromium" ||
|
||||||
|
!existsSync(romPath) ||
|
||||||
|
!existsSync(symbolsPath),
|
||||||
|
"Requires the local ROM/reference; engine comparison runs in Chromium.",
|
||||||
|
);
|
||||||
|
const symbols: Record<string, { bank: number; address: number }> = {};
|
||||||
|
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
|
||||||
|
const m = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
|
||||||
|
if (m)
|
||||||
|
symbols[m[3]] = { bank: parseInt(m[1], 16), address: parseInt(m[2], 16) };
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(readFileSync(romPath));
|
||||||
|
const cases = Array.from({ length: 36 * 4 }, (_, i) => ({
|
||||||
|
tileset: 1 + Math.floor(i / 4),
|
||||||
|
time: i % 4,
|
||||||
|
environment: 3,
|
||||||
|
group: 24,
|
||||||
|
}));
|
||||||
|
for (const environment of [1, 2, 4, 7])
|
||||||
|
for (const time of [0, 1, 2, 3])
|
||||||
|
for (const tileset of [1, 29, 30])
|
||||||
|
cases.push({ tileset, time, environment, group: 24 });
|
||||||
|
await page.goto("/reference.html");
|
||||||
|
const original = await page.evaluate(
|
||||||
|
async ({ rom, symbols, cases }) => {
|
||||||
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||||
|
const emu = new BinjgbEmulator();
|
||||||
|
await emu.load(new Uint8Array(rom), document.createElement("canvas"));
|
||||||
|
const write = (name: string, value: number) =>
|
||||||
|
emu.writeMemory(symbols[name].address, value);
|
||||||
|
const target = symbols.LoadMapPals;
|
||||||
|
const program = [
|
||||||
|
0xf3,
|
||||||
|
0x31,
|
||||||
|
0xfe,
|
||||||
|
0xc7,
|
||||||
|
0x3e,
|
||||||
|
target.bank,
|
||||||
|
0xe0,
|
||||||
|
0x9d,
|
||||||
|
0xea,
|
||||||
|
0,
|
||||||
|
0x20,
|
||||||
|
0xcd,
|
||||||
|
target.address & 255,
|
||||||
|
target.address >> 8,
|
||||||
|
0x3e,
|
||||||
|
0x42,
|
||||||
|
0xea,
|
||||||
|
0xff,
|
||||||
|
0xc6,
|
||||||
|
0x18,
|
||||||
|
0xfe,
|
||||||
|
];
|
||||||
|
const output = cases.map((c) => {
|
||||||
|
emu.writeMemory(0xff70, 1);
|
||||||
|
write("wMapTileset", c.tileset);
|
||||||
|
write("wEnvironment", c.environment);
|
||||||
|
write("wTimeOfDayPal", c.time);
|
||||||
|
write("wMapGroup", c.group);
|
||||||
|
emu.writeMemory(0xc6ff, 0);
|
||||||
|
program.forEach((v, i) => emu.writeMemory(0xc000 + i, v));
|
||||||
|
emu.setProgramCounter(0xc000);
|
||||||
|
emu.advanceFrames(2);
|
||||||
|
if (emu.readMemory(0xc6ff) !== 0x42)
|
||||||
|
throw new Error("LoadMapPals did not return");
|
||||||
|
emu.writeMemory(0xff70, symbols.wBGPals1.bank);
|
||||||
|
return Array.from({ length: 32 }, (_, i) => {
|
||||||
|
const p = symbols.wBGPals1.address + i * 2;
|
||||||
|
const value = emu.readMemory(p) | (emu.readMemory(p + 1) << 8);
|
||||||
|
return [0, 5, 10].map((shift) =>
|
||||||
|
Math.round((((value >> shift) & 31) * 255) / 31),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
emu.dispose();
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
{ rom: [...bytes], symbols, cases },
|
||||||
|
);
|
||||||
|
const assets = new CrystalAssets(bytes),
|
||||||
|
base = assets.map("24:6");
|
||||||
|
cases.forEach((c, i) =>
|
||||||
|
expect(
|
||||||
|
assets.palettes({ ...base, ...c }, c.time).flat(),
|
||||||
|
JSON.stringify(c),
|
||||||
|
).toEqual(original[i]),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -60,6 +60,14 @@ async function drive(page: Page, name = "") {
|
|||||||
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
|
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
|
||||||
await page.clock.runFor(500);
|
await page.clock.runFor(500);
|
||||||
} else {
|
} else {
|
||||||
|
// A scene script starts a tick after the tick that ends a warp, so a
|
||||||
|
// single idle observation is not the end of the scene.
|
||||||
|
await page.clock.runFor(200);
|
||||||
|
if (
|
||||||
|
(await page.locator("#game-screen").getAttribute("data-activity")) !== "idle" ||
|
||||||
|
(await page.locator("#native-dialogue").isVisible())
|
||||||
|
)
|
||||||
|
continue;
|
||||||
await expect(page.locator("#game-notice")).toHaveText("");
|
await expect(page.locator("#game-notice")).toHaveText("");
|
||||||
return messages.join("\n");
|
return messages.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { BinjgbEmulator } from "../src/emulator/binjgb";
|
||||||
|
import type { Direction } from "../src/game/world/types";
|
||||||
|
|
||||||
|
/** CPU addresses (and banks for ROM routines) from the private PRET build's
|
||||||
|
* symbol file, read by the Node side of the test. */
|
||||||
|
export interface ReferenceSymbols {
|
||||||
|
[label: string]: { bank: number; address: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One overworld tick of the original engine, sampled after HandleMap ran. */
|
||||||
|
export interface ReferenceTick {
|
||||||
|
map: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
facing: Direction;
|
||||||
|
/** wMapStatus: 1 (MAPSTATUS_ENTER) when this tick began a map change. */
|
||||||
|
status: number;
|
||||||
|
/** Map objects with an object struct: OBJECT_MAP_X/Y by script object
|
||||||
|
* number (OBJECT_MAP_OBJECT_INDEX + 1, as object_const_def starts at 2). */
|
||||||
|
objects: Record<number, [number, number]>;
|
||||||
|
events: number[];
|
||||||
|
poses: Record<
|
||||||
|
number,
|
||||||
|
{ direction: Direction; visible: boolean; lift: number }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACINGS: Direction[] = ["down", "up", "left", "right"];
|
||||||
|
let emulator: BinjgbEmulator | undefined;
|
||||||
|
let symbols: ReferenceSymbols;
|
||||||
|
|
||||||
|
const at = (label: string) => {
|
||||||
|
const symbol = symbols[label];
|
||||||
|
if (!symbol) throw new Error(`Missing reference symbol ${label}.`);
|
||||||
|
return symbol.address;
|
||||||
|
};
|
||||||
|
const read = (label: string) => emulator!.readMemory(at(label));
|
||||||
|
|
||||||
|
function sample(): ReferenceTick {
|
||||||
|
const objects: Record<number, [number, number]> = {};
|
||||||
|
const poses: ReferenceTick["poses"] = {};
|
||||||
|
// wObjectStructs after the player: OBJECT_SPRITE, OBJECT_MAP_OBJECT_INDEX,
|
||||||
|
// OBJECT_MAP_X/Y at $10/$11, $28 bytes each.
|
||||||
|
for (let slot = 1; slot < 13; slot++) {
|
||||||
|
const base = at("wPlayerStruct") + slot * 0x28;
|
||||||
|
if (!emulator!.readMemory(base)) continue;
|
||||||
|
const id = emulator!.readMemory(base + 1) + 1;
|
||||||
|
const offset = emulator!.readMemory(base + 0x1a);
|
||||||
|
poses[id] = {
|
||||||
|
direction: FACINGS[(emulator!.readMemory(base + 8) >> 2) & 3],
|
||||||
|
visible: emulator!.readMemory(base + 0xb) !== 0,
|
||||||
|
lift: offset < 128 ? offset : offset - 256,
|
||||||
|
};
|
||||||
|
objects[id] = [
|
||||||
|
emulator!.readMemory(base + 0x10) - 4,
|
||||||
|
emulator!.readMemory(base + 0x11) - 4,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
map: `${read("wMapGroup")}:${read("wMapNumber")}`,
|
||||||
|
// OBJECT_MAP_X/Y include the four-tile border offset.
|
||||||
|
x: read("wPlayerMapX") - 4,
|
||||||
|
y: read("wPlayerMapY") - 4,
|
||||||
|
facing: FACINGS[(read("wPlayerDirection") >> 2) & 3],
|
||||||
|
status: read("wMapStatus"),
|
||||||
|
objects,
|
||||||
|
poses,
|
||||||
|
events: Array.from({ length: 2048 }, (_, event) => event).filter((event) =>
|
||||||
|
Boolean(
|
||||||
|
emulator!.readMemory(at("wEventFlags") + (event >> 3)) &
|
||||||
|
(1 << (event & 7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance until HandleMap has run once more. ResetOverworldDelay sets
|
||||||
|
* wOverworldDelay to 2 at the start of each HandleMap and VBlank counts it
|
||||||
|
* down, so a sampled 2 marks a new overworld tick. Map setup between ticks
|
||||||
|
* (warps, connections) takes frames that are not ticks. */
|
||||||
|
/** Advance to the next time rLY reads 143, just before VBlank: HandleMap's
|
||||||
|
* work for the tick is done and VBlank has not yet counted wOverworldDelay
|
||||||
|
* down. Map setup turns the LCD off (rLY stays 0), so the phase is found
|
||||||
|
* again on every frame rather than once. */
|
||||||
|
function nextFrame() {
|
||||||
|
const line = emulator!.readMemory(0xff44);
|
||||||
|
emulator!.advanceCycles(
|
||||||
|
(line === 143 ? 154 : (143 - line + 154) % 154) * 456,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTick() {
|
||||||
|
let pressing = false;
|
||||||
|
for (let frame = 0; frame < 3000; frame++) {
|
||||||
|
// Text waits inside one tick of the original; after a few frames with no
|
||||||
|
// tick, press A in pulses until the script moves on.
|
||||||
|
if (frame >= 8 && frame % 2 === 0) {
|
||||||
|
pressing = !pressing;
|
||||||
|
emulator!.setButton("a", pressing);
|
||||||
|
}
|
||||||
|
nextFrame();
|
||||||
|
const status = read("wMapStatus");
|
||||||
|
if (read("wOverworldDelay") === 2 && (status === 2 || status === 1)) {
|
||||||
|
emulator!.setButton("a", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("The reference overworld did not advance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hold(held: Direction | undefined) {
|
||||||
|
for (const direction of FACINGS)
|
||||||
|
emulator!.setButton(direction, direction === held);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boot the user's ROM, then run the original ResetWRAM and enter a map
|
||||||
|
* through FinishContinueFunction with MAPSETUP_WARP and SPAWN_N_A, from a
|
||||||
|
* WRAM trampoline on the stack page (which _ResetWRAM leaves alone). */
|
||||||
|
export async function bootReference(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
table: ReferenceSymbols,
|
||||||
|
start: { map: string; x: number; y: number },
|
||||||
|
/** Work RAM written after ResetWRAM, such as scene variables. */
|
||||||
|
writes: { label: string; value: number }[] = [],
|
||||||
|
) {
|
||||||
|
emulator?.dispose();
|
||||||
|
symbols = table;
|
||||||
|
emulator = new BinjgbEmulator();
|
||||||
|
await emulator.load(bytes, document.createElement("canvas"));
|
||||||
|
emulator.advanceFrames(400);
|
||||||
|
const [group, number] = start.map.split(":").map(Number);
|
||||||
|
const routine = (label: string) => {
|
||||||
|
const { bank, address } = table[label];
|
||||||
|
return [0x3e, bank, 0x21, address & 255, address >> 8, 0xcf]; // rst FarCall
|
||||||
|
};
|
||||||
|
const store = (value: number, label: string) => {
|
||||||
|
const address = at(label);
|
||||||
|
return [0x3e, value, 0xea, address & 255, address >> 8];
|
||||||
|
};
|
||||||
|
const hram = (value: number, label: string) => [
|
||||||
|
0x3e,
|
||||||
|
value,
|
||||||
|
0xe0,
|
||||||
|
at(label) & 255,
|
||||||
|
];
|
||||||
|
const program = [
|
||||||
|
0xf3, // di
|
||||||
|
0x3e,
|
||||||
|
0x01,
|
||||||
|
0xe0,
|
||||||
|
0x70, // rSVBK = 1
|
||||||
|
0x31,
|
||||||
|
0xff,
|
||||||
|
0xc0, // ld sp, wStackTop
|
||||||
|
0xfb, // ei
|
||||||
|
...routine("ResetWRAM"),
|
||||||
|
...store(0xff, "wDefaultSpawnpoint"), // SPAWN_N_A
|
||||||
|
...store(group, "wMapGroup"),
|
||||||
|
...store(number, "wMapNumber"),
|
||||||
|
...store(start.x, "wXCoord"),
|
||||||
|
...store(start.y, "wYCoord"),
|
||||||
|
// STATUSFLAGS_NO_WILD_ENCOUNTERS_F: traces must not start battles.
|
||||||
|
...store(1 << 5, "wStatusFlags"),
|
||||||
|
...writes.flatMap(({ label, value }) => store(value, label)),
|
||||||
|
...hram(0xf1, "hMapEntryMethod"), // MAPSETUP_WARP
|
||||||
|
...routine("FinishContinueFunction"),
|
||||||
|
0x18,
|
||||||
|
0xfe,
|
||||||
|
];
|
||||||
|
// The trampoline shares the stack page, whose top the game uses.
|
||||||
|
if (program.length > 0x80) throw new Error("Trampoline too long.");
|
||||||
|
program.forEach((value, index) =>
|
||||||
|
emulator!.writeMemory(0xc000 + index, value),
|
||||||
|
);
|
||||||
|
emulator.setProgramCounter(0xc000);
|
||||||
|
hold(undefined);
|
||||||
|
nextTick();
|
||||||
|
// Let the map name sign and any entry scene settle before tracing.
|
||||||
|
for (let tick = 0; tick < 8; tick++) nextTick();
|
||||||
|
return { ...sample(), timeOfDay: read("wTimeOfDay") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hold the given direction (or nothing) for one tick each. */
|
||||||
|
export function traceReference(
|
||||||
|
inputs: (Direction | undefined)[],
|
||||||
|
): ReferenceTick[] {
|
||||||
|
return inputs.map((held) => {
|
||||||
|
hold(held);
|
||||||
|
nextTick();
|
||||||
|
return sample();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Development aid: read reference memory directly. */
|
||||||
|
export function peek() {
|
||||||
|
return (address: number) => emulator!.readMemory(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One video frame, sampled just before VBlank like the ticks. */
|
||||||
|
export function stepFrame() {
|
||||||
|
nextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** vTiles2 in VRAM bank 0 as the original has animated it: 128 tiles. */
|
||||||
|
export function readTiles(): number[] {
|
||||||
|
const bank = emulator!.readMemory(0xff4f);
|
||||||
|
emulator!.writeMemory(0xff4f, 0);
|
||||||
|
const tiles = Array.from({ length: 0x800 }, (_, index) =>
|
||||||
|
emulator!.readMemory(0x9000 + index),
|
||||||
|
);
|
||||||
|
emulator!.writeMemory(0xff4f, bank);
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeReference() {
|
||||||
|
emulator?.dispose();
|
||||||
|
emulator = undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { drawTile } from "../src/game/rendering/pixels";
|
||||||
|
import { TilesetAnimator } from "../src/game/rendering/tileset-animation";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { OW } from "../src/game/world/constants";
|
||||||
|
import type { Palette } from "../src/game/world/types";
|
||||||
|
|
||||||
|
it("grass priority uses background indices, preserving transparent sprite pixels", () => {
|
||||||
|
const palette: Palette = [
|
||||||
|
[255, 255, 255],
|
||||||
|
[80, 160, 80],
|
||||||
|
[32, 64, 32],
|
||||||
|
[0, 0, 0],
|
||||||
|
];
|
||||||
|
const target = new Uint8ClampedArray(8 * 8 * 4);
|
||||||
|
const indices = new Uint8Array(8 * 8);
|
||||||
|
const background = new Uint8Array(16);
|
||||||
|
background[0] = 0x80; // Colored grass at x=0; color zero elsewhere.
|
||||||
|
drawTile(target, 8, 8, background, 0, 0, 0, palette, { indices });
|
||||||
|
const sprite = new Uint8Array(16);
|
||||||
|
sprite[1] = 0xc0; // Sprite color 2 at x=0 and x=1, transparent at x=2.
|
||||||
|
drawTile(target, 8, 8, sprite, 0, 0, 0, palette, {
|
||||||
|
indices,
|
||||||
|
behind: true,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
expect([...target.slice(0, 12)]).toEqual([
|
||||||
|
80, 160, 80, 255, 32, 64, 32, 255, 255, 255, 255, 255,
|
||||||
|
]);
|
||||||
|
expect([...indices.slice(0, 3)]).toEqual([1, 0, 0]);
|
||||||
|
drawTile(target, 8, 8, sprite, 0, 0, 0, palette, {
|
||||||
|
indices,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
expect([...target.slice(0, 4)]).toEqual([32, 64, 32, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
describe.skipIf(!existsSync(romPath))(
|
||||||
|
"overworld rendering with the local ROM",
|
||||||
|
() => {
|
||||||
|
it("runs every tileset animation without changing the cached ROM graphics", () => {
|
||||||
|
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const base = assets.map("24:4");
|
||||||
|
for (let tileset = 1; tileset <= OW.NUM_TILESETS; tileset++) {
|
||||||
|
const map = { ...base, tileset };
|
||||||
|
const graphics = assets.tileset(map).graphics;
|
||||||
|
const original = graphics.slice();
|
||||||
|
const animator = new TilesetAnimator(assets, map, graphics);
|
||||||
|
animator.advance(1024, false);
|
||||||
|
animator.advance(1024, true);
|
||||||
|
expect(graphics, `tileset ${tileset}`).toEqual(original);
|
||||||
|
expect(animator.graphics.length).toBe(original.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps special indoor palettes fixed and uses normal palettes for the Hall of Fame", () => {
|
||||||
|
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const base = assets.map("24:6");
|
||||||
|
for (const tileset of [
|
||||||
|
OW.TILESET_POKECOM_CENTER,
|
||||||
|
OW.TILESET_BATTLE_TOWER_INSIDE,
|
||||||
|
OW.TILESET_HOUSE,
|
||||||
|
OW.TILESET_RADIO_TOWER,
|
||||||
|
OW.TILESET_MANSION,
|
||||||
|
]) {
|
||||||
|
const map = { ...base, tileset };
|
||||||
|
expect(assets.palettes(map, 0)).toEqual(assets.palettes(map, 2));
|
||||||
|
expect(assets.palettes(map, 0)).toHaveLength(8);
|
||||||
|
}
|
||||||
|
const ice = {
|
||||||
|
...base,
|
||||||
|
tileset: OW.TILESET_ICE_PATH,
|
||||||
|
environment: OW.CAVE,
|
||||||
|
};
|
||||||
|
expect(assets.palettes(ice, 0)).toEqual(assets.palettes(ice, 2));
|
||||||
|
const hall = { ...ice, environment: OW.INDOOR };
|
||||||
|
expect(assets.palettes(hall, 1)).not.toEqual(assets.palettes(ice, 1));
|
||||||
|
expect(assets.palettes(hall, 1)).toEqual(
|
||||||
|
assets.palettes({ ...hall, tileset: OW.TILESET_POKECENTER }, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -104,7 +104,8 @@ export function walkTo(
|
|||||||
targetY: number,
|
targetY: number,
|
||||||
answers: (number | string)[] = [],
|
answers: (number | string)[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
const shown: string[] = [];
|
// Finish anything in progress first, such as the tick after a warp.
|
||||||
|
const shown: string[] = settle(world, answers);
|
||||||
for (let attempt = 0; attempt < 20; attempt++) {
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
let blocked = false;
|
let blocked = false;
|
||||||
for (const direction of findPath(world, targetX, targetY)) {
|
for (const direction of findPath(world, targetX, targetY)) {
|
||||||
|
|||||||
Reference in new issue
Block a user