Add the Johto and Kanto atlas to the Map tab
The Map tab draws the region's Town Map from the ROM with the player's landmark, the visited flypoints as Fly destinations and the landmarks whose maps are ported. Kanto opens with the Fly map's Indigo Plateau rule. Landmark positions and flypoints are compared with the pinned PRET source. Closes #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cdee576818
commit
29248172bd
13 files changed
+536
-30
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
||||
|
||||
**Current milestone: from the opening to Route 30.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
**Current milestone: from the opening to Route 30.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
|
||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Scripts are generators that evaluate against a private draft and yield presentat
|
||||
|
||||
Asset caches and the ROM reader remain in memory for lazy extraction. Neither ROMs nor extracted assets enter IndexedDB, the build or a backend. Vite blocks serving the root ROM and private reference checkout during development.
|
||||
|
||||
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. Below it, the Map tab draws the region atlas (`world/atlas.ts`): the ROM's Town Map with the player's landmark, the visited flypoints and the areas open in this port. 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.
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. |
|
||||
| `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. |
|
||||
| `game/scripts/pokedex.ts`, `game/rom/pokedex.ts` | `engine/pokedex/pokedex.asm`, `pokedex_2.asm` (`DisplayDexEntry`, `GetDexEntryPointer`), `pokedex_3.asm` (`LoadQuestionMarkPic`), `data/pokemon/dex_order_new.asm`, `dex_entry_pointers.asm`, `dex_entries/`, `gfx/footprints.asm`, `engine/gfx/cgb_layouts.asm` (`_CGB_Pokedex`) | New mode's main screen and entry screen with their joypad rules: `Pokedex_ListingHandleDPadInput`, `Pokedex_InitCursorPosition`, `Pokedex_NextOrPreviousDexEntry`, `Pokedex_MoveArrowCursor` and `Pokedex_Page`. Strings come from the ROM; screens are native. Tests compare the order, all 251 entries and every footprint with the PRET source. AREA, CRY, PRNT, the option and search screens, Old and A to Z modes and Unown mode are not ported. `ui/pokedex.ts` draws the screen and the Pokédex tab. |
|
||||
| `game/world/atlas.ts` | `data/maps/landmarks.asm`, `data/maps/flypoints.asm`, `engine/pokegear/pokegear.asm` (`_FlyMap`, `CheckIfVisitedFlypoint`), `home/map.asm` (`GetWorldMapLocation`) | The Map tab's atlas: each region's landmarks with their Town Map positions and ROM names, the player's landmark, the visited flypoints and the landmarks with ported maps. Kanto follows the Fly map's Indigo Plateau rule. `rendering/town-map.ts` draws it. Tests compare positions and flypoints with the PRET source. |
|
||||
| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. |
|
||||
| `game/scripts/fly-map.ts` | `engine/pokegear/pokegear.asm` (`_FlyMap`, `FlyMap`, `TownMapBubble`, `CheckIfVisitedFlypoint`), `data/maps/flypoints.asm`, `engine/events/field_moves.asm` (`FlyFromAnim`, `FlyToAnim`), `engine/sprite_anims/functions.asm` | The Fly map's region choice (Kanto only after Indigo Plateau, without the player's icon otherwise), the default flypoint and up/down scrolling over visited flypoints (`wVisitedSpawns` through the ROM's `EngineFlags`). The flights follow `SpriteAnimFunc_FlyFrom` and `SpriteAnimFunc_FlyTo` frame by frame with the party icon. The Town Map, bubble and icons are drawn natively; the leaves are not presented. |
|
||||
| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
|
||||
|
||||
+11
-3
@@ -21,7 +21,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
||||
| 2. Opening story and first battle slice | Mostly done |
|
||||
| 3. Complete Crystal | Early |
|
||||
| 4. Modern mobile presentation and delivery | First increment done |
|
||||
| 5. World map and Pokédex | Early (Pokédex list, entries and tab) |
|
||||
| 5. World map and Pokédex | Early (atlas, Pokédex list and entries) |
|
||||
| 6. Internet Link and friends | Not started |
|
||||
| 7. Optional Pokéwalker-style egg assistance | Not started |
|
||||
|
||||
@@ -227,6 +227,14 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- `tests/pokedex.test.ts` compares the order, all 251 entries and every footprint with the pinned PRET source.
|
||||
- Not ported: AREA (`Pokedex_GetArea`), CRY (no audio), PRNT (no printer), the option screen (SELECT), the search screen (START), Old and A to Z modes (`wLastDexMode` stays New), Unown mode and the arrow's blink. Screens are native; the strings, entries and graphics come from the ROM.
|
||||
|
||||
## Implemented: the region atlas
|
||||
|
||||
- The Map tab shows the region's Town Map from the ROM under the local map, in the player's Pokégear palettes. Johto is always available. Kanto becomes available once the player stands there or has visited Indigo Plateau, as `_FlyMap` requires.
|
||||
- The player's landmark follows `GetWorldMapLocation`, with the backup map's landmark in `LANDMARK_SPECIAL` rooms. The Fast Ship's landmark lies outside both regions, so no marker shows there.
|
||||
- Visited areas are the visited flypoints (`wVisitedSpawns` through `EngineFlags`). The original records no other visits, so the atlas adds no save data. Visited flypoints are the Fly destinations, listed by their ROM landmark names.
|
||||
- "Open in this port" lists the landmarks with at least one map in `NATIVE_MAPS`, so the atlas never suggests travel through unported areas.
|
||||
- `tests/atlas.test.ts` compares every landmark position with `data/maps/landmarks.asm` and the flypoints with `data/maps/flypoints.asm`.
|
||||
|
||||
## Implemented: the options
|
||||
|
||||
- OPTION in the start menu runs `_Option` and returns to the start menu. The labels come from `StringOptions` and the settings from each routine's strings. Up and down move the ▶ with `OptionsControl`'s wrapping, left and right change the option under it, A on CANCEL, B or Start leaves. Each routine is ported from its bit handling: text speed cycles FAST, MID and SLOW through `GetTextSpeed` (`.Save` keeps only the high nybble), the battle scene, battle style, sound and menu account toggle with either direction, printing cycles through the five `GBPRINTER_*` levels through `GetPrinterSetting`, and the frame wraps through `TYPE 1` to `8`.
|
||||
@@ -344,7 +352,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||
|
||||
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Violet City and Falkner's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, online Link, friends and real-world steps come later.
|
||||
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Violet City and Falkner's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
||||
|
||||
## 1. Complete the overworld foundation
|
||||
|
||||
@@ -483,7 +491,7 @@ Acceptance: installation, offline startup, portrait/landscape, touch/audio, back
|
||||
|
||||
- [x] Current-area minimap in the game and Map tab
|
||||
- [x] Seen and caught sets saved from real game state
|
||||
- [ ] Johto/Kanto atlas with visited areas and Fly destinations
|
||||
- [x] Johto/Kanto atlas with visited areas and Fly destinations
|
||||
- [x] Start menu Pokédex and Pokédex tab with ROM-derived records
|
||||
- [ ] Habitat (AREA) view from the encounter tables
|
||||
- [ ] Old and A to Z modes, type search and Unown mode
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import type { GameWorld } from "../world/world";
|
||||
import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||
import { OVERWORLD } from "../rom/profile";
|
||||
import { landmarkPosition, type AtlasView } from "../world/atlas";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { drawTile } from "./pixels";
|
||||
|
||||
/** The ROM's Town Map, used by wall maps, the bedroom poster, the
|
||||
* Pokégear's map card, which chooses the region and marks the landmark
|
||||
* under its cursor, and the Fly map, which shows the Fly user's party icon
|
||||
* on the selected flypoint (`species`) and may leave out the player. */
|
||||
export function drawTownMap(
|
||||
canvas: HTMLCanvasElement,
|
||||
world: GameWorld,
|
||||
card?: {
|
||||
kanto: boolean;
|
||||
player?: number;
|
||||
cursor: number;
|
||||
name: string;
|
||||
species?: number;
|
||||
},
|
||||
) {
|
||||
/** The region's Town Map tiles from the ROM, in the player's Pokégear
|
||||
* palettes (Kris's for the female avatar). */
|
||||
function drawRegion(canvas: HTMLCanvasElement, world: GameWorld, kanto: boolean) {
|
||||
const { assets } = world,
|
||||
r = assets.reader;
|
||||
const landmark = card ? card.player : world.map.landmark;
|
||||
const kanto = card?.kanto ?? world.map.landmark >= 47;
|
||||
const map = kanto ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
|
||||
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
|
||||
const palette =
|
||||
@@ -50,11 +37,58 @@ export function drawTownMap(
|
||||
);
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The Map tab's atlas: the region's Town Map with a blue disc on each
|
||||
* visited flypoint and the player as a red square, both outlined in white
|
||||
* to stand out from the map's own town tiles. */
|
||||
export function drawAtlas(
|
||||
canvas: HTMLCanvasElement,
|
||||
world: GameWorld,
|
||||
view: AtlasView,
|
||||
) {
|
||||
const context = drawRegion(canvas, world, view.region === "kanto");
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = "#fff";
|
||||
for (const landmark of view.landmarks) {
|
||||
if (!landmark.flypoint?.visited) continue;
|
||||
context.fillStyle = "#2366a5";
|
||||
context.beginPath();
|
||||
context.arc(landmark.x, landmark.y, 3.5, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
if (view.player !== undefined) {
|
||||
const player = landmarkPosition(world.assets.reader, view.player);
|
||||
context.fillStyle = "#b32628";
|
||||
context.fillRect(player.x - 3, player.y - 3, 6, 6);
|
||||
context.strokeRect(player.x - 3.5, player.y - 3.5, 7, 7);
|
||||
}
|
||||
}
|
||||
|
||||
/** The ROM's Town Map, used by wall maps, the bedroom poster, the
|
||||
* Pokégear's map card, which chooses the region and marks the landmark
|
||||
* under its cursor, and the Fly map, which shows the Fly user's party icon
|
||||
* on the selected flypoint (`species`) and may leave out the player. */
|
||||
export function drawTownMap(
|
||||
canvas: HTMLCanvasElement,
|
||||
world: GameWorld,
|
||||
card?: {
|
||||
kanto: boolean;
|
||||
player?: number;
|
||||
cursor: number;
|
||||
name: string;
|
||||
species?: number;
|
||||
},
|
||||
) {
|
||||
const r = world.assets.reader;
|
||||
const landmark = card ? card.player : world.map.landmark;
|
||||
const kanto = card?.kanto ?? world.map.landmark >= 47;
|
||||
const context = drawRegion(canvas, world, kanto);
|
||||
const { assets } = world;
|
||||
// Landmark coordinates are the same ones TownMapPlayerIcon uses.
|
||||
const at = (index: number) => {
|
||||
const p = SYMBOLS.landmarks + index * 4;
|
||||
return { x: r.byte(p) - 8, y: r.byte(p + 1) - 16 };
|
||||
};
|
||||
const at = (index: number) => landmarkPosition(r, index);
|
||||
if (landmark !== undefined) {
|
||||
const player = at(landmark);
|
||||
context.fillStyle = "#b32628";
|
||||
|
||||
@@ -33,6 +33,7 @@ export class CrystalAssets {
|
||||
private tilesets = new Map<string, Tileset>();
|
||||
private sprites = new Map<number, Sprite>();
|
||||
private spawnFlags?: Map<number, number>;
|
||||
private openLandmarks?: Set<number>;
|
||||
private sceneOwners?: Map<string, string>;
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.reader = new RomReader(bytes);
|
||||
@@ -439,6 +440,13 @@ export class CrystalAssets {
|
||||
}
|
||||
/** The engine flag (ENGINE_FLYPOINT_*) of a wVisitedSpawns bit, from the
|
||||
* ROM's EngineFlags; undefined for spawn points without one. */
|
||||
/** The landmarks with at least one ported map. */
|
||||
portedLandmarks(): ReadonlySet<number> {
|
||||
this.openLandmarks ??= new Set(
|
||||
Object.keys(NATIVE_MAPS).map((id) => this.map(id).landmark),
|
||||
);
|
||||
return this.openLandmarks;
|
||||
}
|
||||
spawnEngineFlag(spawn: number): number | undefined {
|
||||
if (!this.spawnFlags) {
|
||||
const r = this.reader;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { OVERWORLD, SYMBOLS } from "../rom/profile";
|
||||
import { landmarkName } from "../scripts/interpreter";
|
||||
import type { TextContext } from "../scripts/text";
|
||||
import { OW } from "./constants";
|
||||
|
||||
export type AtlasRegion = "johto" | "kanto";
|
||||
|
||||
/** A landmark on the region's Town Map. */
|
||||
export interface AtlasLandmark {
|
||||
landmark: number;
|
||||
name: string;
|
||||
/** Town Map pixel coordinates, as TownMapPlayerIcon places its icon. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** At least one of its maps is ported and can be entered. */
|
||||
open: boolean;
|
||||
/** Its Flypoints entry: the spawn point and whether wVisitedSpawns has
|
||||
* it, which makes it a Fly destination. */
|
||||
flypoint?: { spawn: number; visited: boolean };
|
||||
}
|
||||
|
||||
/** The Map tab's region atlas: the ROM's Town Map with every landmark,
|
||||
* the player's landmark and the visited flypoints. */
|
||||
export interface AtlasView {
|
||||
region: AtlasRegion;
|
||||
/** The regions the player may look at. */
|
||||
regions: AtlasRegion[];
|
||||
/** The player's landmark, when it lies in the region shown. */
|
||||
player?: number;
|
||||
landmarks: AtlasLandmark[];
|
||||
}
|
||||
|
||||
/** What the atlas needs from the world. */
|
||||
export interface AtlasHost {
|
||||
reader: RomReader;
|
||||
text(): TextContext;
|
||||
/** GetWorldMapLocation for the current map, with the backup map's
|
||||
* landmark in LANDMARK_SPECIAL rooms. */
|
||||
landmark: number;
|
||||
/** HasVisitedSpawn. */
|
||||
visited(spawn: number): boolean;
|
||||
/** Landmarks with a ported map. */
|
||||
open: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
const inKanto = (landmark: number) =>
|
||||
landmark >= OW.KANTO_LANDMARK && landmark <= OW.KANTO_LANDMARK_LAST;
|
||||
|
||||
/** Landmarks: the Town Map position of a landmark. */
|
||||
export function landmarkPosition(reader: RomReader, landmark: number) {
|
||||
const p = SYMBOLS.landmarks + landmark * 4;
|
||||
return { x: reader.byte(p) - 8, y: reader.byte(p + 1) - 16 };
|
||||
}
|
||||
|
||||
/** Flypoints: each flypoint's landmark and spawn point. */
|
||||
export function flypoints(reader: RomReader) {
|
||||
return Array.from({ length: OW.NUM_FLYPOINTS }, (_, index) => ({
|
||||
landmark: reader.byte(OVERWORLD.Flypoints + index * 2),
|
||||
spawn: reader.byte(OVERWORLD.Flypoints + index * 2 + 1),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The region atlas. The player sees Johto, and Kanto once they stand
|
||||
* there or have visited Indigo Plateau, as the Fly map allows. */
|
||||
export function regionAtlas(host: AtlasHost, region?: AtlasRegion): AtlasView {
|
||||
const r = host.reader;
|
||||
const regions: AtlasRegion[] = ["johto"];
|
||||
if (inKanto(host.landmark) || host.visited(OW.SPAWN_INDIGO))
|
||||
regions.push("kanto");
|
||||
const shown =
|
||||
region && regions.includes(region)
|
||||
? region
|
||||
: inKanto(host.landmark)
|
||||
? "kanto"
|
||||
: "johto";
|
||||
const [first, last] =
|
||||
shown === "kanto"
|
||||
? [OW.KANTO_LANDMARK, OW.KANTO_LANDMARK_LAST]
|
||||
: [OW.JOHTO_LANDMARK, OW.JOHTO_LANDMARK_LAST];
|
||||
const points = new Map(
|
||||
flypoints(r).map((point) => [point.landmark, point.spawn]),
|
||||
);
|
||||
const landmarks: AtlasLandmark[] = [];
|
||||
for (let landmark = first; landmark <= last; landmark++) {
|
||||
const spawn = points.get(landmark);
|
||||
landmarks.push({
|
||||
landmark,
|
||||
name: landmarkName(r, landmark, host.text()).replace(/\s+/g, " "),
|
||||
...landmarkPosition(r, landmark),
|
||||
open: host.open.has(landmark),
|
||||
...(spawn !== undefined && {
|
||||
flypoint: { spawn, visited: host.visited(spawn) },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
region: shown,
|
||||
regions,
|
||||
...(host.landmark >= first &&
|
||||
host.landmark <= last && { player: host.landmark }),
|
||||
landmarks,
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
||||
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
|
||||
import {
|
||||
StartMenuRun,
|
||||
type MenuCursors,
|
||||
@@ -763,6 +764,26 @@ export class GameWorld {
|
||||
get liveState(): ScriptState {
|
||||
return this.script?.run.state ?? this.state;
|
||||
}
|
||||
/** The Map tab's region atlas, from the live state. */
|
||||
atlas(region?: AtlasRegion): AtlasView {
|
||||
const state = this.liveState;
|
||||
let landmark = this.map.landmark;
|
||||
if (landmark === OW.LANDMARK_SPECIAL && state.backupWarp)
|
||||
landmark = this.assets.map(state.backupWarp.map).landmark;
|
||||
return regionAtlas(
|
||||
{
|
||||
reader: this.assets.reader,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
landmark,
|
||||
visited: (spawn) => {
|
||||
const flag = this.assets.spawnEngineFlag(spawn);
|
||||
return flag !== undefined && state.flags.has(flag);
|
||||
},
|
||||
open: this.assets.portedLandmarks(),
|
||||
},
|
||||
region,
|
||||
);
|
||||
}
|
||||
/** What the player is waiting for: a scene, a step, or nothing. */
|
||||
get activity(): "scene" | "walking" | "idle" {
|
||||
return this.script
|
||||
|
||||
+62
-1
@@ -1,4 +1,5 @@
|
||||
import { drawTownMap } from "./game/rendering/town-map";
|
||||
import { drawAtlas, drawTownMap } from "./game/rendering/town-map";
|
||||
import type { AtlasRegion } from "./game/world/atlas";
|
||||
import {
|
||||
drawCardPic,
|
||||
drawTrainerCardBadges,
|
||||
@@ -1127,6 +1128,64 @@ function showPokedexTab() {
|
||||
});
|
||||
}
|
||||
|
||||
let atlasKey = "";
|
||||
let atlasRegion: AtlasRegion | undefined;
|
||||
/** The Map tab's region atlas, from the loaded game's live state. */
|
||||
function showAtlas() {
|
||||
if (tabs.active() !== "map") return;
|
||||
const world = ready() ? runtime.world : undefined;
|
||||
const panel = get("atlas");
|
||||
panel.hidden = !world;
|
||||
if (!world) {
|
||||
atlasKey = "";
|
||||
return;
|
||||
}
|
||||
const view = world.atlas(atlasRegion);
|
||||
const key = JSON.stringify([view, world.avatar]);
|
||||
if (key === atlasKey) return;
|
||||
atlasKey = key;
|
||||
const names = { johto: "Johto", kanto: "Kanto" } as const;
|
||||
get("atlas-title").textContent = names[view.region];
|
||||
get("atlas-regions").replaceChildren(
|
||||
...(view.regions.length > 1 ? view.regions : []).map((region) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = names[region];
|
||||
button.setAttribute("aria-pressed", String(region === view.region));
|
||||
button.addEventListener("click", () => {
|
||||
atlasRegion = region;
|
||||
showAtlas();
|
||||
});
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const canvas = get<HTMLCanvasElement>("atlas-map");
|
||||
drawAtlas(canvas, world, view);
|
||||
const here = view.landmarks.find((landmark) => landmark.landmark === view.player);
|
||||
canvas.setAttribute("aria-label", `${names[view.region]} map${here ? `, you are in ${here.name}` : ""}`);
|
||||
get("atlas-here").textContent = here
|
||||
? `You are in ${here.name}.`
|
||||
: `You are outside ${names[view.region]}.`;
|
||||
const items = (list: string[], empty: string) =>
|
||||
(list.length ? list : [empty]).map((text) => {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = text;
|
||||
return item;
|
||||
});
|
||||
get("atlas-fly").replaceChildren(
|
||||
...items(
|
||||
view.landmarks.filter((landmark) => landmark.flypoint?.visited).map((landmark) => landmark.name),
|
||||
"None yet",
|
||||
),
|
||||
);
|
||||
get("atlas-open").replaceChildren(
|
||||
...items(
|
||||
view.landmarks.filter((landmark) => landmark.open).map((landmark) => landmark.name),
|
||||
"None",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let optionsKey = "";
|
||||
/** _Option's screen: each option with its setting, and the ▶ cursor. */
|
||||
function showOptions(world: GameWorld) {
|
||||
@@ -1512,6 +1571,7 @@ const tabs = bindTabs(
|
||||
},
|
||||
(id) => {
|
||||
if (id === "pokedex") showPokedexTab();
|
||||
if (id === "map") showAtlas();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1554,6 +1614,7 @@ function update() {
|
||||
get<HTMLButtonElement>("dialogue-next").disabled =
|
||||
busy || session.state !== "playing";
|
||||
showPokedexTab();
|
||||
showAtlas();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
|
||||
+2
-1
@@ -66,7 +66,8 @@ export function renderShell(root: HTMLElement) {
|
||||
</section>
|
||||
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
|
||||
<header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header>
|
||||
<div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area will appear here after loading a game. A wider Johto and Kanto atlas is planned.</p></div>
|
||||
<div class="map-panel-body"><div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area and the region map will appear here after loading a game.</p></div>
|
||||
<section id="atlas" class="atlas" hidden aria-labelledby="atlas-title"><header class="atlas-header"><h3 id="atlas-title">Region</h3><div id="atlas-regions" class="atlas-regions"></div></header><canvas id="atlas-map" width="160" height="144" role="img"></canvas><p id="atlas-here" class="atlas-here"></p><div class="atlas-lists"><div><h4>Fly destinations</h4><ul id="atlas-fly"></ul></div><div><h4>Open in this port</h4><ul id="atlas-open"></ul></div></div><p class="atlas-legend"><span class="legend-player"></span> You <span class="legend-visited"></span> Visited flypoint</p></section></div>
|
||||
</section>
|
||||
<section id="panel-pokedex" class="tab-panel menu-panel pokedex-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><header><p class="menu-title">POKÉDEX</p><h2>No game loaded</h2></header><p class="pokedex-tab-status">Choose a ROM to read the Pokémon you have seen and caught.</p></section>
|
||||
<section id="panel-link" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-link" data-panel="link" hidden><p class="menu-title">LINK</p><h2>Planned</h2><p>Trade and battle with other players. Online Link is planned.</p></section>
|
||||
|
||||
+87
-1
@@ -917,9 +917,16 @@ dialog h2 {
|
||||
.replace-rom .menu-button {
|
||||
width: fit-content;
|
||||
}
|
||||
.map-panel-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.world-map-frame {
|
||||
min-height: 180px;
|
||||
height: calc(100% - 100px);
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-top: 16px;
|
||||
@@ -942,6 +949,85 @@ dialog h2 {
|
||||
text-align: center;
|
||||
color: #5f6763;
|
||||
}
|
||||
/* The region atlas: the ROM's Town Map with flypoints and the player. */
|
||||
.atlas {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--cream);
|
||||
border: 4px double var(--ink);
|
||||
}
|
||||
.atlas-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.atlas-header h3,
|
||||
.atlas h4 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.atlas-regions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.atlas-regions button {
|
||||
min-height: 32px;
|
||||
padding: 2px 10px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--cream-dark);
|
||||
border: 2px solid var(--ink);
|
||||
}
|
||||
.atlas-regions button[aria-pressed="true"] {
|
||||
color: #fff;
|
||||
background: var(--blue);
|
||||
}
|
||||
#atlas-map {
|
||||
justify-self: center;
|
||||
width: min(100%, 480px);
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.atlas-here {
|
||||
margin: 0;
|
||||
}
|
||||
.atlas-lists {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.atlas ul {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.atlas-legend {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #5f6763;
|
||||
}
|
||||
.atlas-legend span {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.legend-player {
|
||||
background: #b32628;
|
||||
}
|
||||
.legend-visited {
|
||||
background: var(--blue);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.map-panel-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.planned-panel {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const romPath = resolve(
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||
);
|
||||
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
|
||||
|
||||
async function start(page: Page) {
|
||||
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||
const response = await route.fetch(),
|
||||
source = await response.text();
|
||||
const needle = "const world = new GameWorld(assets);";
|
||||
expect(source).toContain(needle);
|
||||
// Fixture worlds stand in for a saved session, so no introduction runs.
|
||||
expect(source).toContain("this.world?.beginIntro();");
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: source.replace("this.world?.beginIntro();", "").replace(
|
||||
needle,
|
||||
`const { pokedexFixtureWorld } = await import('/tests/pokedex-fixture.ts'); const world = pokedexFixtureWorld(assets);`,
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
|
||||
await page.goto("/");
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("verified");
|
||||
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
|
||||
await page.locator("#play").click();
|
||||
}
|
||||
|
||||
test("the Map tab shows the Johto atlas with the player and open areas", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await start(page);
|
||||
await page.locator("#tab-map").click();
|
||||
const atlas = page.locator("#atlas");
|
||||
await expect(atlas).toBeVisible();
|
||||
await expect(page.locator("#atlas-title")).toHaveText("Johto");
|
||||
await expect(page.locator("#atlas-regions button")).toHaveCount(0);
|
||||
await expect(page.locator("#atlas-here")).toHaveText("You are in NEW BARK TOWN.");
|
||||
await expect(page.locator("#atlas-map")).toHaveAttribute("aria-label", "Johto map, you are in NEW BARK TOWN");
|
||||
const open = page.locator("#atlas-open li");
|
||||
await expect(open.first()).toHaveText("NEW BARK TOWN");
|
||||
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
|
||||
await expect(page.locator("#atlas-open")).not.toContainText("AZALEA TOWN");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { flypoints, landmarkPosition, regionAtlas } from "../src/game/world/atlas";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const pret = ".reference/pokecrystal";
|
||||
const NEW_BARK_TOWN = "24:4",
|
||||
LANDMARK_NEW_BARK_TOWN = 1,
|
||||
LANDMARK_ROUTE_29 = 2,
|
||||
LANDMARK_CHERRYGROVE_CITY = 3,
|
||||
LANDMARK_VIOLET_CITY = 6,
|
||||
LANDMARK_AZALEA_TOWN = 12,
|
||||
LANDMARK_PALLET_TOWN = 47;
|
||||
|
||||
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
|
||||
"the atlas data against the pinned PRET source",
|
||||
() => {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const read = (path: string) => readFileSync(`${pret}/${path}`, "utf8");
|
||||
// const_def counts from 0, or from the value given.
|
||||
const constants = (path: string, prefix: string) => {
|
||||
const values = new Map<string, number>();
|
||||
let next = 0;
|
||||
for (const [, start, name] of read(path).matchAll(/^\s+(?:const_def(?: (\d+))?|const (\w+))/gm)) {
|
||||
if (name === undefined) next = Number(start ?? 0);
|
||||
else values.set(name, next++);
|
||||
}
|
||||
return new Map([...values].filter(([name]) => name.startsWith(prefix)));
|
||||
};
|
||||
|
||||
it("places every landmark where data/maps/landmarks.asm does", () => {
|
||||
const rows = [...read("data/maps/landmarks.asm").matchAll(/^\s+landmark\s+(-?\d+),\s*(-?\d+),/gm)];
|
||||
expect(rows).toHaveLength(OW.LANDMARK_FAST_SHIP + 1);
|
||||
rows.forEach(([, x, y], landmark) =>
|
||||
expect(landmarkPosition(assets.reader, landmark), String(landmark)).toEqual({
|
||||
x: Number(x),
|
||||
y: Number(y),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the flypoints of data/maps/flypoints.asm", () => {
|
||||
const landmarks = constants("constants/landmark_constants.asm", "LANDMARK_");
|
||||
const spawns = constants("constants/map_data_constants.asm", "SPAWN_");
|
||||
const rows = [...read("data/maps/flypoints.asm").matchAll(/^\s+db (LANDMARK_\w+),\s*(SPAWN_\w+)/gm)].map(
|
||||
([, landmark, spawn]) => ({ landmark: landmarks.get(landmark), spawn: spawns.get(spawn) }),
|
||||
);
|
||||
expect(rows).toHaveLength(OW.NUM_FLYPOINTS);
|
||||
expect(flypoints(assets.reader)).toEqual(rows);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
function fixture(visited: number[]) {
|
||||
const world = new GameWorld(assets, () => new Date("2026-09-25T12:00:00"), () => 0x42);
|
||||
world.state.party = [
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species: 155, level: 8, item: 0, otId: world.playerId,
|
||||
ot: "GOLD", dvs: 0x9abc, caughtData: 0,
|
||||
}),
|
||||
];
|
||||
world.restore({ ...world.save(), map: NEW_BARK_TOWN, x: 13, y: 7, direction: "down", objects: [] });
|
||||
for (const spawn of visited) world.state.flags.add(assets.spawnEngineFlag(spawn)!);
|
||||
return world;
|
||||
}
|
||||
const spawnOf = (landmark: number) =>
|
||||
flypoints(assets.reader).find((point) => point.landmark === landmark)!.spawn;
|
||||
|
||||
it("shows Johto with the player, the visited flypoints and the open areas", () => {
|
||||
const world = fixture([spawnOf(LANDMARK_NEW_BARK_TOWN), spawnOf(LANDMARK_CHERRYGROVE_CITY)]);
|
||||
const atlas = world.atlas();
|
||||
expect(atlas).toMatchObject({ region: "johto", regions: ["johto"], player: LANDMARK_NEW_BARK_TOWN });
|
||||
expect(atlas.landmarks.map((landmark) => landmark.landmark)).toEqual(
|
||||
Array.from({ length: OW.JOHTO_LANDMARK_LAST }, (_, i) => i + 1),
|
||||
);
|
||||
const at = (landmark: number) => atlas.landmarks[landmark - 1];
|
||||
expect(at(LANDMARK_NEW_BARK_TOWN)).toMatchObject({
|
||||
name: "NEW BARK TOWN",
|
||||
open: true,
|
||||
flypoint: { visited: true },
|
||||
});
|
||||
expect(at(LANDMARK_CHERRYGROVE_CITY).flypoint?.visited).toBe(true);
|
||||
expect(at(LANDMARK_VIOLET_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
|
||||
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
|
||||
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: false, flypoint: { visited: false } });
|
||||
// Kanto stays closed, so asking for it shows Johto.
|
||||
expect(world.atlas("kanto").region).toBe("johto");
|
||||
});
|
||||
|
||||
it("opens Kanto once Indigo Plateau is visited, without the player", () => {
|
||||
const world = fixture([OW.SPAWN_INDIGO]);
|
||||
expect(world.atlas().regions).toEqual(["johto", "kanto"]);
|
||||
const kanto = world.atlas("kanto");
|
||||
expect(kanto.region).toBe("kanto");
|
||||
expect(kanto.player).toBeUndefined();
|
||||
expect(kanto.landmarks[0]).toMatchObject({ landmark: LANDMARK_PALLET_TOWN, open: false });
|
||||
expect(kanto.landmarks).toHaveLength(OW.KANTO_LANDMARK_LAST - OW.KANTO_LANDMARK + 1);
|
||||
});
|
||||
|
||||
it("starts on Kanto when the player stands there", () => {
|
||||
const atlas = regionAtlas({
|
||||
reader: assets.reader,
|
||||
text: () => defaultTextContext("GOLD"),
|
||||
landmark: LANDMARK_PALLET_TOWN,
|
||||
visited: () => false,
|
||||
open: new Set(),
|
||||
});
|
||||
expect(atlas).toMatchObject({ region: "kanto", regions: ["johto", "kanto"], player: LANDMARK_PALLET_TOWN });
|
||||
});
|
||||
|
||||
it("leaves the Fast Ship's landmark outside both regions", () => {
|
||||
const ship = regionAtlas({
|
||||
reader: assets.reader,
|
||||
text: () => defaultTextContext("GOLD"),
|
||||
landmark: OW.LANDMARK_FAST_SHIP,
|
||||
visited: () => false,
|
||||
open: new Set(),
|
||||
});
|
||||
expect(ship.region).toBe("johto");
|
||||
expect(ship.player).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user