Add the Johto and Kanto atlas to the Map tab
Checks / web (push) Failing after 17s
Private ROM checks / private-rom (push) Failing after 14m39s

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:
samyandClaude Opus 5.5 committed 2026-10-03 20:34:29 +02:00
1 parent cdee576818
commit 29248172bd
13 files changed
+536 -30

No files matched your search

+56 -22
View File
@@ -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";
+8
View File
@@ -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;
+104
View File
@@ -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,
};
}
+21
View File
@@ -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