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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user