Port Fly, Dig, Teleport and the Escape Rope

FLY opens _FlyMap in the party menu: the region's Town Map with "Where?",
the visited flypoints in turn and the Fly user's icon, then FlyScript flies
the icon off along SpriteAnimFunc_FlyFrom's path, enters the spawn point and
lands it with SpriteAnimFunc_FlyTo. Teleport returns to the last Pokémon
Center's town through IsSpawnPoint. Dig and the Escape Rope lead out of a
cave to the warp EnterMapWarp's SaveDigWarp recorded, which saves now keep.

newloadmap stops its script as StopScript does and the world resumes it on
the new map, which the landing and return movements need. A destination
that is not ported yet gives a notice.

Closes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 11:18:57 +02:00
1 parent c4cc0e756c
commit a7aceda089
21 files changed
+1166 -97

No files matched your search

+23
View File
@@ -177,6 +177,29 @@ export class WorldRenderer {
{ transparent: true },
);
}
const flying = world.flyingIcon;
if (flying) {
// SPRITE_ANIM_OBJ_RED_WALK with the icon's tiles: each frame shows
// for nine video frames, the second one mirrored on every other pass.
const graphics = this.assets.monIcon(flying.species),
phase = Math.floor(flying.frames / 9) % 4,
flip = phase === 3,
palette = this.assets.objectPalette(0, time);
for (let tile = 0; tile < 4; tile++) {
const column = tile & 1;
drawTile(
this.image.data,
this.width,
this.height,
graphics,
(phase & 1) * 4 + tile,
player.x - cameraX + flying.x + (flip ? 8 - column * 8 : column * 8),
player.y - cameraY - 4 + flying.y + (tile >> 1) * 8,
palette,
{ transparent: true, flipX: flip },
);
}
}
this.context.putImageData(this.image, 0, 0);
}
private actor(
+37 -10
View File
@@ -3,18 +3,25 @@ import { OVERWORLD, SYMBOLS } from "../rom/profile";
import { decompress } from "../rom/lz";
import { drawTile } from "./pixels";
/** The ROM's Town Map, used by wall maps, the bedroom poster and the
/** 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. */
* 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 },
card?: {
kanto: boolean;
player?: number;
cursor: number;
name: string;
species?: number;
},
) {
const { assets } = world,
r = assets.reader;
const landmark = card?.player ?? world.map.landmark;
const kanto = card?.kanto ?? landmark >= 47;
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 =
@@ -48,10 +55,30 @@ export function drawTownMap(
const p = SYMBOLS.landmarks + index * 4;
return { x: r.byte(p) - 8, y: r.byte(p + 1) - 16 };
};
const player = at(landmark);
context.fillStyle = "#b32628";
context.fillRect(player.x - 2, player.y - 2, 4, 4);
if (card) {
if (landmark !== undefined) {
const player = at(landmark);
context.fillStyle = "#b32628";
context.fillRect(player.x - 2, player.y - 2, 4, 4);
}
if (card?.species !== undefined) {
// TownMapMon: the icon's first frame, centred on the landmark.
const cursor = at(card.cursor),
icon = assets.monIcon(card.species),
iconImage = context.getImageData(cursor.x - 8, cursor.y - 8, 16, 16);
for (let tile = 0; tile < 4; tile++)
drawTile(
iconImage.data,
16,
16,
icon,
tile,
(tile & 1) * 8,
(tile >> 1) * 8,
assets.objectPalette(0, world.timeOfDay),
{ transparent: true },
);
context.putImageData(iconImage, cursor.x - 8, cursor.y - 8);
} else if (card) {
// The Pokégear's arrow cursor, drawn as a frame around the landmark.
const cursor = at(card.cursor);
context.strokeStyle = "#2366a5";
@@ -61,7 +88,7 @@ export function drawTownMap(
canvas.setAttribute(
"aria-label",
card
? `Town Map: ${card.name}${card.cursor === card.player ? ", you are here" : ""}`
? `${card.species !== undefined ? "Fly map" : "Town Map"}: ${card.name}${card.cursor === card.player ? ", you are here" : ""}`
: `Town Map: ${world.map.location}`,
);
}
+51 -3
View File
@@ -30,6 +30,7 @@ export class CrystalAssets {
private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>();
private spawnFlags?: Map<number, number>;
private sceneOwners?: Map<string, string>;
constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes);
@@ -432,6 +433,44 @@ export class CrystalAssets {
}
return flags;
}
/** The engine flag (ENGINE_FLYPOINT_*) of a wVisitedSpawns bit, from the
* ROM's EngineFlags; undefined for spawn points without one. */
spawnEngineFlag(spawn: number): number | undefined {
if (!this.spawnFlags) {
const r = this.reader;
this.spawnFlags = new Map();
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
const entry = SYMBOLS.engineFlags + index * 3,
offset = r.word(entry) - RAM.visitedSpawns,
mask = r.byte(entry + 2);
if (offset >= 0 && offset < 4)
this.spawnFlags.set(offset * 8 + Math.log2(mask), index);
}
}
return this.spawnFlags.get(spawn);
}
/** SpawnPoints: a SPAWN_* point's map and position. */
spawnPoint(spawn: number): { map: string; x: number; y: number } {
const r = this.reader,
entry = SYMBOLS.spawnPoints + spawn * 4;
return {
map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
x: r.byte(entry + 2),
y: r.byte(entry + 3),
};
}
/** IsSpawnPoint: the SPAWN_* index of a map, or -1. */
spawnIndex(map: string | null): number {
const r = this.reader;
if (map)
for (
let entry = SYMBOLS.spawnPoints, index = 0;
r.byte(entry) !== 0xff;
entry += 4, index++
)
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === map) return index;
return -1;
}
/** SpriteMovementData for a SPRITEMOVEDATA_* type: the movement
* function, flags1 (FIXED_FACING, NOCLIP_*, ...) and palette flags. */
spriteMovement(type: number): {
@@ -461,6 +500,17 @@ export class CrystalAssets {
r.byte(entry + 2),
);
}
/** GetIcon: a species' two-frame party menu icon (ReadMonMenuIcon and
* IconPointers), eight tiles. */
monIcon(species: number): Uint8Array {
const r = this.reader;
const icon = r.byte(OVERWORLD.MonMenuIcons + species - 1);
const pointer = r.near(
OVERWORLD.IconPointers + icon * 2,
r.bank(OVERWORLD.IconPointers),
);
return r.slice(pointer, 8 * 16);
}
/** 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. */
@@ -472,9 +522,7 @@ export class CrystalAssets {
// 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 };
const sprite = { graphics: this.monIcon(species), palette: 0, walking: true };
this.sprites.set(id, sprite);
return sprite;
}
+16
View File
@@ -158,6 +158,7 @@ export const RAM = {
surfingPlayerState: 0xd1eb,
strengthSpecies: 0xd1ef,
curHPAnimDeltaHP: 0xd1f3,
visitedSpawns: 0xdca5,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -170,6 +171,7 @@ export const EVENTS = {
playersHouseDoll2: 1859,
playersHouseBigDoll: 1860,
wallOpenedInAerodactylChamber: 809,
wallOpenedInKabutoChamber: 807,
} as const;
/** Engine flag indices named directly by translated routines. */
@@ -238,6 +240,19 @@ export const OVERWORLD = {
AlreadySurfingText: 0xc9b3,
"EscapeRopeOrDig.CantUseDigText": 0xcc26,
"TeleportFunction.CantUseTeleportText": 0xccb6,
"FlyFunction.FlyScript": 0xcaa3,
"FlyFunction.ReturnFromFly": 0xcacb,
"EscapeRopeOrDig.UsedEscapeRopeScript": 0xcc2b,
"EscapeRopeOrDig.UsedDigScript": 0xcc35,
"TeleportFunction.TeleportScript": 0xccbb,
FlyFromAnim: 0x8caed,
FlyToAnim: 0x8cb33,
HideSprites: 0x3016,
SkipUpdateMapSprites: 0x154f1,
WaitSFX: 0x3c55,
Flypoints: 0x91c5e,
"TownMapBubble.Where": 0x91bf4,
RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0,
"Softboiled_MilkDrinkFunction.ItemCantUseOnMonText": 0xf44a,
RuinsOfAlphAerodactylChamber_MapAttributes: 0x958e8,
AskCutScript: 0xd1a9,
@@ -474,6 +489,7 @@ export const MENU = {
PPsIncreasedText: 0xf734,
BicycleEffect: 0xee08,
EvoStoneEffect: 0xee0f,
EscapeRopeEffect: 0xf44f,
VitaminEffect: 0xee3d,
RareCandyEffect: 0xef14,
ItemStatRoseText: 0xeea6,
+103
View File
@@ -0,0 +1,103 @@
import type { RomReader } from "../rom/reader";
import { OVERWORLD } from "../rom/profile";
import { OW } from "../world/constants";
import { landmarkName, type ScriptEffect } from "./interpreter";
import { readString, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** The buttons _FlyMap reads: A, B, and up and down (hJoyLast). */
export const FLY_MAP_BUTTONS: readonly string[] = ["a", "b", "up", "down"];
/** What the Fly map shows. The original draws the Town Map and its bubble
* in tiles; here they are presented natively. */
export interface FlyMapView {
kanto: boolean;
/** TownMapBubble: "Where?" and the selected flypoint's landmark name. */
where: string;
name: string;
/** The selected flypoint's landmark, where TownMapMon's icon stands
* (GetMapCursorCoordinates). */
cursor: number;
/** The Fly user, drawn as its party icon. */
species: number;
/** TownMapPlayerIcon's landmark; absent on the Johto map shown in Kanto
* before Indigo Plateau, which skips the player icon. */
player?: number;
}
/** What the Fly map needs from the world. */
export interface FlyMapHost {
reader: RomReader;
text(): TextContext;
/** GetWorldMapLocation for the current map, with the backup map's
* landmark for LANDMARK_SPECIAL rooms (FlyMap). */
landmark(): number;
/** HasVisitedSpawn: the wVisitedSpawns bit of a SPAWN_* point. */
visited(spawn: number): boolean;
}
/** Flypoints: a flypoint's landmark and spawn point. */
const flypoint = (reader: RomReader, index: number) => ({
landmark: reader.byte(OVERWORLD.Flypoints + index * 2),
spawn: reader.byte(OVERWORLD.Flypoints + index * 2 + 1),
});
/** _FlyMap and FlyMap (engine/pokegear/pokegear.asm): the region's Town
* Map with the flypoints visited so far. Up and down cycle through them
* with wrapping, A flies to the selected one and B cancels. Returns the
* SPAWN_* point, or -1 for B. Each screen is a `flymap` effect answered
* with a button. */
export function* flyMap(host: FlyMapHost, species: number): Gen<number> {
const r = host.reader;
const landmark = host.landmark();
// .CheckRegion: landmarks from KANTO_LANDMARK on are Kanto's.
let kanto = landmark >= OW.KANTO_LANDMARK;
let player: number | undefined = landmark;
// .KantoFlyMap: Kanto's map needs Indigo Plateau's flypoint; otherwise
// .NoKanto shows Johto's, without the player's icon.
if (kanto && !host.visited(OW.SPAWN_INDIGO)) {
kanto = false;
player = undefined;
}
const [start, end] = kanto
? [OW.KANTO_FLYPOINT, OW.NUM_FLYPOINTS - 1]
: [OW.JOHTO_FLYPOINT, OW.KANTO_FLYPOINT - 1];
// New Bark Town or Indigo Plateau is the default, visited or not.
let cursor = kanto ? end : start;
const where = readString(r, OVERWORLD["TownMapBubble.Where"], host.text());
// .ScrollNext and .ScrollPrev skip flypoints not visited yet. The
// original loops forever when none is; the default's region always has
// one by the time Fly can be used, so that case is refused instead.
const scroll = (step: number) => {
for (let count = 0; count <= end - start; count++) {
cursor =
step > 0
? cursor === end
? start
: cursor + 1
: cursor === start
? end
: cursor - 1;
if (host.visited(flypoint(r, cursor).spawn)) return;
}
throw new Error("No flypoint has been visited.");
};
for (;;) {
const point = flypoint(r, cursor);
const view: FlyMapView = {
kanto,
where,
name: landmarkName(r, point.landmark, host.text()),
cursor: point.landmark,
species,
...(player !== undefined && { player }),
};
const button = yield { kind: "flymap", view };
if (button === "b") return -1;
if (button === "a") return point.spawn;
if (button === "up") scroll(1);
else if (button === "down") scroll(-1);
}
}
+98 -19
View File
@@ -52,6 +52,7 @@ import { readMovement, type MovementStep } from "./movement";
import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear";
import type { FlyMapView } from "./fly-map";
import type { TrainerCardView } from "./trainer-card";
import {
defaultOptions,
@@ -116,6 +117,10 @@ export interface ScriptState {
/** wBackupWarpNumber/wBackupMapGroup/wBackupMapNumber: where a warp
* numbered -1 leads, recorded on arrival or by warpmod and elevators. */
backupWarp: { map: string; warp: number } | null;
/** wDigWarpNumber/wDigMapGroup/wDigMapNumber: the outdoor warp last
* used to enter an indoor map (EnterMapWarp's SaveDigWarp), where Dig
* and the Escape Rope lead. */
digWarp: { 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;
@@ -150,6 +155,7 @@ export const emptyScriptState = (): ScriptState => ({
rivalName: "???",
playerState: 0,
backupWarp: null,
digWarp: null,
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
@@ -182,12 +188,28 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
rivalName: state.rivalName,
playerState: state.playerState,
backupWarp: state.backupWarp && { ...state.backupWarp },
digWarp: state.digWarp && { ...state.digWarp },
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
options: { ...state.options },
});
/** 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). */
export interface FieldMoveContext {
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
/** wDefaultSpawnpoint, chosen on the Fly map or by Teleport. */
spawn?: number;
/** wNextWarp and its map, copied from wDigWarpNumber by Dig and the
* Escape Rope, at the position GetWarpDestCoords gives it. */
warp?: { map: string; x: number; y: number };
}
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
* objects. LAST_TALKED is resolved before an effect is emitted. */
export const PLAYER = 0;
@@ -265,9 +287,25 @@ export type ScriptEffect =
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
tutorial?: boolean;
}
/** newloadmap and warp: the world enters this map after the script
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). */
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction }
/** warp and newloadmap: the world enters this map after the script
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown).
* MAPSETUP_DOOR (`door`) keeps the facing unless the arrival tile turns
* the player down (CheckWarpFacingDown). newloadmap stops the script as
* StopScript does; when more than an end follows, it resumes at `resume`
* on the new map (ScriptEvents restarts it after EnterMap). */
| {
kind: "warp";
map: string;
x: number;
y: number;
facing?: Direction;
door?: boolean;
resume?: number;
}
/** FlyFromAnim and FlyToAnim: the Fly user's party icon flies off from
* the player's place or lands on it, with the leaves; HideSprites and
* the animation loops leave no other object on screen meanwhile. */
| { kind: "fly"; landing: boolean; species: number }
/** A vertical menu (the elevator's floors); answered with the chosen
* item's index, or -1 when cancelled with B. */
| {
@@ -292,6 +330,8 @@ export type ScriptEffect =
/** The options screen, answered with the button pressed; without a
* view it closes. */
| { kind: "options"; view?: OptionsView }
/** _FlyMap: answered with the button pressed. */
| { kind: "flymap"; view: FlyMapView }
/** BankOfMom's deposit or withdrawal box with the textbox's pages,
* answered with the button pressed (MomBankButton). */
| { kind: "mombank"; pages: string[]; view: MomBankView }
@@ -495,15 +535,7 @@ export class ScriptRun {
/** wCallerContact's second script, copied by CheckSpecialPhoneCall
* from the queued SpecialPhoneCallList entry. */
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 };
};
fieldMove?: FieldMoveContext;
/** An item used from the pack: UseItem names it in wStringBuffer1 and
* CopyName1 copies it to wStringBuffer2. */
usedItem?: number;
@@ -556,7 +588,8 @@ export class ScriptRun {
let p = start,
value = 0,
battleResult = 0,
spawn = 0;
// wDefaultSpawnpoint: set by Fly and Teleport before their scripts.
spawn = this.options.fieldMove?.spawn ?? 0;
let wild = this.options.wild;
let trainer: { classId: number; id: number } | undefined;
// LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too.
@@ -849,6 +882,24 @@ export class ScriptRun {
value = Number(this.options.wild !== undefined);
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
} else if (
routine === OVERWORLD.FlyFromAnim ||
routine === OVERWORLD.FlyToAnim
) {
yield* flush();
yield synchronous({
kind: "fly",
landing: routine === OVERWORLD.FlyToAnim,
species: this.state.party[this.curPartyMon]?.species ?? 0,
});
} else if (
routine === OVERWORLD.HideSprites ||
routine === OVERWORLD.SkipUpdateMapSprites ||
routine === OVERWORLD["FlyFunction.ReturnFromFly"]
) {
// Sprite and font reloading around the Fly animations: the
// `fly` effect hides the objects while it plays, and the
// player stands again afterwards (RespawnPlayer).
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
throw new UnsupportedScript(offset, opcode);
break;
@@ -1046,15 +1097,42 @@ export class ScriptRun {
canLose = false;
break;
case 0x8a: {
byte(); // newloadmap MAPSETUP_WARP
const entry = SYMBOLS.spawnPoints + spawn * 4;
// newloadmap: StopScript, then the map setup the byte names.
const method = byte();
let destination: { map: string; x: number; y: number };
if (method === OW.MAPSETUP_DOOR) {
// EnterMapWarp with the wNextWarp that Dig and the Escape Rope
// copied from wDigWarpNumber.
const warp = this.options.fieldMove?.warp;
if (!warp)
throw new UnsupportedScript(offset, opcode, " (no next warp)");
destination = warp;
} else if (
method === OW.MAPSETUP_WARP ||
method === OW.MAPSETUP_TELEPORT ||
method === OW.MAPSETUP_FLY
) {
// EnterMapSpawnPoint: SpawnPoints[wDefaultSpawnpoint].
const entry = SYMBOLS.spawnPoints + spawn * 4;
destination = {
map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
x: r.byte(entry + 2),
y: r.byte(entry + 3),
};
} else throw new UnsupportedScript(offset, opcode, " (map setup)");
// The script resumes where it stopped; a caller's stack is not
// carried over to the new map here.
if (stack.length)
throw new UnsupportedScript(offset, opcode, " (inside a call)");
yield* flush();
yield synchronous({
kind: "warp",
map: `${r.byte(entry)}:${r.byte(entry + 1)}`,
x: r.byte(entry + 2),
y: r.byte(entry + 3),
...destination,
door: method === OW.MAPSETUP_DOOR || undefined,
resume:
r.byte(p) === 0x91 || r.byte(p) === 0x93 ? undefined : p,
});
break;
return;
}
case 0x1c:
value = this.readVar(byte(), offset);
@@ -2130,6 +2208,7 @@ export class ScriptRun {
case OVERWORLD.UpdatePlayerSprite:
case OVERWORLD.PlayMapMusic:
case OVERWORLD.UpdateTimePals:
case OVERWORLD.WaitSFX:
// The native renderer draws the player from wPlayerState each frame;
// music and palette fades are presentation.
return value;
+53 -9
View File
@@ -13,6 +13,7 @@ import type { GameTime } from "../world/game-time";
import { PartyEvolution, evoStoneCompatible } from "./evolution";
import {
copyScriptState,
type FieldMoveContext,
type MartEntry,
type ScriptEffect,
type ScriptHost,
@@ -21,6 +22,8 @@ import {
import { Pokegear } from "./pokegear";
import { TrainerCard } from "./trainer-card";
import { OptionsMenu, menuAccount } from "./options";
import { flyMap } from "./fly-map";
import { OW } from "../world/constants";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
@@ -86,19 +89,17 @@ export interface MenuCursors {
/** The world's verdict on a field move chosen from the party menu: a
* script to run once the menu closes (with the context the world checked),
* the messages of a failed attempt, or a notice for what is not ported. */
* the messages of a failed attempt, a notice for what is not ported, or
* Fly's map to choose a destination from. */
export type MenuFieldMove =
| {
script: number;
fieldMove: {
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
};
fieldMove: FieldMoveContext;
wild?: { species: number; level: number };
}
| { texts: number[] }
| { notice: string };
| { notice: string }
| { flyMap: true };
/** What the start menu needs from the world. */
export interface StartMenuHost {
@@ -114,6 +115,12 @@ export interface StartMenuHost {
landmark(): number;
/** A field move's checks for party slot `mon`, on the menu's draft. */
fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove;
/** FlyFunction after the Fly map: flying to SPAWN_* point `spawn`. */
flyTo(state: ScriptState, mon: number, spawn: number): MenuFieldMove;
/** EscapeRopeFunction's checks, on the menu's draft. */
escapeRope(state: ScriptState): MenuFieldMove;
/** HasVisitedSpawn: the wVisitedSpawns bit of a SPAWN_* point. */
visitedSpawn(state: ScriptState, spawn: number): boolean;
/** The script host a Pokégear phone call runs with. */
scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */
@@ -512,14 +519,36 @@ export class StartMenuRun {
yield* this.softboiledMilkDrink(slot);
return false;
}
const result = this.host.fieldMove(this.state, slot, move);
let result = this.host.fieldMove(this.state, slot, move);
if ("flyMap" in result) {
// FlyFunction.TryFly: B on the map (.illegal) returns to the party
// menu like a failure.
const spawn = yield* flyMap(
{
reader: this.reader,
text: () => this.text(),
landmark: () => {
const landmark = this.host.landmark();
const backup = this.state.backupWarp?.map;
return landmark === OW.LANDMARK_SPECIAL && backup
? this.host.landmarkOf(backup)
: landmark;
},
visited: (spawn) => this.host.visitedSpawn(this.state, spawn),
},
this.state.party[slot].species,
);
if (spawn < 0) return false;
result = this.host.flyTo(this.state, slot, spawn);
}
if ("script" in result) {
this.deferred = result.script;
this.deferredMove = result;
return true;
}
if ("notice" in result) yield* this.notice(result.notice);
else for (const text of result.texts) yield* this.print(text);
else if ("texts" in result)
for (const text of result.texts) yield* this.print(text);
return false;
}
@@ -893,6 +922,21 @@ export class StartMenuRun {
return yield* this.vitaminEffect(use);
case MENU.EvoStoneEffect:
return yield* this.evoStoneEffect(use);
case MENU.EscapeRopeEffect: {
// EscapeRopeEffect: UseDisposableItem once EscapeRopeOrDig queued
// its script; a failure prints nothing before Oak's message.
const result = this.host.escapeRope(this.state);
if ("notice" in result) {
yield* this.notice(result.notice);
return "unported";
}
if (!("script" in result)) return false;
this.useDisposableItem(use);
this.deferred = result.script;
this.deferredItem = item;
this.deferredMove = result;
return true;
}
case MENU.BicycleEffect: {
const script = this.host.bicycleScript(this.state, this.usingSelect);
if (script === undefined) return false;
+9
View File
@@ -167,6 +167,15 @@ export const OW = {
FLASH: 0x94,
SWEET_SCENT: 0xe6,
ROCK_SMASH: 0xf9,
MAPSETUP_WARP: 0xf1,
MAPSETUP_TELEPORT: 0xf4,
MAPSETUP_DOOR: 0xf5,
MAPSETUP_FLY: 0xfc,
NUM_SPAWNS: 28,
SPAWN_INDIGO: 13,
JOHTO_FLYPOINT: 0,
KANTO_FLYPOINT: 12,
NUM_FLYPOINTS: 24,
// SpriteMovementData (constants/map_object_constants.asm).
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
BICYCLE: 7,
+208 -38
View File
@@ -48,11 +48,13 @@ import {
type MartEntry,
type MartMode,
type ScriptEffect,
type FieldMoveContext,
type ScriptHost,
type Portrait,
type ScriptState,
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import {
StartMenuRun,
type MenuCursors,
@@ -141,6 +143,8 @@ export interface NativeSave {
playerState: number;
/** Where a warp numbered -1 leads (wBackupWarpNumber and its map). */
backupWarp: { map: string; warp: number } | null;
/** Where Dig and the Escape Rope lead (wDigWarpNumber and its map). */
digWarp: { map: string; warp: number } | null;
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
flash: boolean;
noWildEncounters: boolean;
@@ -271,6 +275,8 @@ export type Choice =
| { kind: "trainercard"; view: TrainerCardView; selected: 0 }
/** The options screen, answered with the button pressed. */
| { kind: "options"; view: OptionsView; selected: 0 }
/** The Fly map, answered with the button pressed. */
| { kind: "flymap"; view: FlyMapView; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -288,7 +294,8 @@ const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" ||
choice.kind === "mombank" ||
choice.kind === "trainercard" ||
choice.kind === "options";
choice.kind === "options" ||
choice.kind === "flymap";
export interface Dialogue {
pages: string[];
index: number;
@@ -362,7 +369,10 @@ interface ActiveScript {
/** Scene scripts that change nothing are not re-run until state changes. */
scene: boolean;
/** newloadmap, warp or warpfacing: entered after the script commits. */
warp?: { map: string; x: number; y: number; facing?: Direction };
warp?: Extract<ScriptEffect, { kind: "warp" }>;
/** The field move context, kept for a script resumed after newloadmap
* (wCurPartyMon and the rest stay in RAM across the map change). */
fieldMove?: FieldMoveContext;
/** warpcheck: the player's tile events run on the tick after the script. */
warpCheck?: boolean;
/** Routines that run before the script resumes with `result`: the
@@ -406,6 +416,9 @@ const WARP_DIRECTIONS: Partial<Record<number, Direction>> = {
0x78: "up",
0x7e: "right",
};
/** MOUNT_MOON_SQUARE and TIN_TOWER_ROOF (GROUP_MOUNT_MOON_SQUARE 15): outdoor
* maps inside indoor ones, which SaveDigWarp skips. */
const DIG_WARP_EXCLUDED = ["15:10", "15:12"];
/** CheckWarpFacingDown, also including the original unused variants. */
const FACE_DOWN_ON_WARP = new Set([
0x71, 0x79, 0x7a, 0x73, 0x7b, 0x74, 0x7c, 0x75, 0x7d,
@@ -537,7 +550,8 @@ const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) =>
effect?.kind === "move" ||
effect?.kind === "emote" ||
effect?.kind === "pause";
effect?.kind === "pause" ||
effect?.kind === "fly";
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
@@ -623,6 +637,8 @@ export class GameWorld {
* remainder, given here) runs on the first tick in the new map, which
* reads no input. */
private warpSettle?: { script?: number };
/** FlyFromAnim or FlyToAnim while it plays, with the frames shown. */
private flight?: { landing: boolean; species: number; frames: number };
constructor(
readonly assets: CrystalAssets,
@@ -773,8 +789,49 @@ export class GameWorld {
];
});
}
/** The Fly user's party icon while FlyFromAnim or FlyToAnim plays: its
* species and its offset from where the player's sprite stands, from
* SpriteAnimFunc_FlyFrom and SpriteAnimFunc_FlyTo frame by frame. */
get flyingIcon():
| { species: number; x: number; y: number; frames: number }
| undefined {
const flight = this.flight;
if (!flight) return undefined;
// Both start at the player's place (depixel 10, 10, 4, 0), FlyToAnim
// 168 pixels higher (depixel 31: OAM Y 252 wraps to above the screen).
let y = flight.landing ? 252 : 84,
radius = flight.landing ? 11 * 8 : 0,
angle = 0,
x = 0;
for (let frame = 0; frame < flight.frames; frame++) {
if (flight.landing) {
if (y === 84) break;
y = (y + 2) & 0xff;
const d = radius;
if (radius) radius -= 2;
x = this.sine(d, angle++ + 0x10);
} else {
if (!y) break;
// VAR2: the icon waits 64 frames, then rises two pixels a frame.
if (frame < 0x40) continue;
y -= 2;
const d = radius;
if (radius < 0x40) radius += 8;
x = this.sine(d, angle++ + 0x10);
}
}
return {
species: flight.species,
x,
y: ((y - 84 + 128) & 0xff) - 128,
frames: flight.frames,
};
}
/** Pixel positions for drawing, including partial steps. */
poses(): Pose[] {
// HideSprites and the Fly animations' loops leave only their icon.
if (this.flight) return [];
const pose = (
actor: Actor,
id: number,
@@ -1229,12 +1286,25 @@ export class GameWorld {
if (!arrival || !this.walkable(target, arrival.x, arrival.y))
throw new Error("Warp destination is invalid.");
let sourceState = this.state;
const outdoors =
this.map.environment === OW.TOWN || this.map.environment === OW.ROUTE,
indoors = [OW.INDOOR, OW.CAVE, OW.DUNGEON, OW.GATE].includes(
target.environment as never,
);
// EnterMapWarp's SaveDigWarp: any warp from an outdoor map into an
// indoor one (CheckIndoorMap) is where Dig and the Escape Rope lead,
// except from the outdoor Mount Moon Square and Tin Tower roof.
if (outdoors && indoors && !DIG_WARP_EXCLUDED.includes(this.mapId))
sourceState = {
...copyScriptState(sourceState),
digWarp: { map: this.mapId, warp: this.map.warps.indexOf(warp) + 1 },
};
// EnterMapWarp's SetSpawn: walking from an outdoor map into a Pokémon
// Center records that outdoor map as the whiteout/blackout spawn.
if (
(target.tileset === OW.TILESET_POKECENTER ||
target.tileset === OW.TILESET_POKECOM_CENTER) &&
(this.map.environment === OW.TOWN || this.map.environment === OW.ROUTE)
outdoors
)
sourceState = {
...copyScriptState(sourceState),
@@ -1602,18 +1672,28 @@ export class GameWorld {
: failed;
}
case OW.FLY:
// .TryFly: the Fly map follows outdoors (flyTo).
return (
badge(OW.ENGINE_STORMBADGE) ??
(outdoors ? { notice: "The Fly map is not ported yet." } : failed)
badge(OW.ENGINE_STORMBADGE) ?? (outdoors ? { flyMap: true } : failed)
);
case OW.DIG:
return env === OW.CAVE || env === OW.DUNGEON
? { notice: "Digging out of a cave is not ported yet." }
: { texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]] };
case OW.TELEPORT:
return outdoors && state.lastSpawnMap !== null
? { notice: "Teleport is not ported yet." }
: { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
return (
this.digWarpMove(state, mon, false) ?? {
texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]],
}
);
case OW.TELEPORT: {
// .TryTeleport: outdoors, when wLastSpawnMap is a spawn point.
const spawn = outdoors ? this.assets.spawnIndex(state.lastSpawnMap) : -1;
if (spawn < 0)
return { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
return (
this.unportedSpawn(spawn) ?? {
script: OVERWORLD["TeleportFunction.TeleportScript"],
fieldMove: { mon, spawn },
}
);
}
case OW.HEADBUTT:
return tile === OW.COLL_HEADBUTT_TREE ||
tile === OW.COLL_HEADBUTT_TREE_1D
@@ -1641,6 +1721,57 @@ export class GameWorld {
return { notice: "This field move is not ported yet." };
}
/** FlyFunction after _FlyMap returned SPAWN_* point `spawn`. */
private flyTo(mon: number, spawn: number): MenuFieldMove {
// .illegal: no flypoint leads beyond NUM_SPAWNS.
if (spawn >= OW.NUM_SPAWNS) return { texts: [] };
return (
this.unportedSpawn(spawn) ?? {
script: OVERWORLD["FlyFunction.FlyScript"],
fieldMove: { mon, spawn },
}
);
}
private unportedSpawn(spawn: number): MenuFieldMove | undefined {
return this.assets.isPorted(this.assets.spawnPoint(spawn).map)
? undefined
: { notice: "This destination is not ported yet." };
}
/** EscapeRopeOrDig: in a cave or dungeon with a wDigWarpNumber, the
* Escape Rope (`rope`) or Dig leads back out through MAPSETUP_DOOR.
* Undefined when it cannot be used here. */
private digWarpMove(
state: ScriptState,
mon: number,
rope: boolean,
): MenuFieldMove | undefined {
const env = this.map.environment,
dig = state.digWarp;
if ((env !== OW.CAVE && env !== OW.DUNGEON) || !dig) return undefined;
if (!this.assets.isPorted(dig.map))
return { notice: "This destination is not ported yet." };
// GetWarpDestCoords: the warp's own position on its map.
const arrival = this.assets.map(dig.map).warps[dig.warp - 1];
if (!arrival || arrival.targetWarp === 0xff)
throw new Error("The dig warp's destination is not supported.");
// .escaperope: SpecialKabutoChamber opens the chamber's wall.
if (
rope &&
this.map.attributes ===
OVERWORLD.RuinsOfAlphKabutoChamber_MapAttributes
)
state.events.add(EVENTS.wallOpenedInKabutoChamber);
return {
script: rope
? OVERWORLD["EscapeRopeOrDig.UsedEscapeRopeScript"]
: OVERWORLD["EscapeRopeOrDig.UsedDigScript"],
fieldMove: {
mon,
warp: { map: dig.map, x: arrival.x, y: arrival.y },
},
};
}
/** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship
* counts as Johto; LANDMARK_SPECIAL rooms use the backup map's landmark. */
private inKanto(): boolean {
@@ -1825,11 +1956,7 @@ export class GameWorld {
usedItem,
}: {
usedItem?: number;
fieldMove?: {
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
};
fieldMove?: FieldMoveContext;
committedPosition?: { x: number; y: number };
scene?: boolean;
wild?: { species: number; level: number };
@@ -1878,6 +2005,7 @@ export class GameWorld {
moving: new Map(),
scene,
sighted,
fieldMove,
};
this.pump();
}
@@ -1919,6 +2047,14 @@ export class GameWorld {
landmark: () => this.assets.map(this.mapId).landmark,
fieldMove: (state, mon, move) =>
this.menuFieldMove(state, mon, move),
flyTo: (_, mon, spawn) => this.flyTo(mon, spawn),
// EscapeRopeOrDig's .failescaperope prints nothing.
escapeRope: (state) =>
this.digWarpMove(state, 0, true) ?? { texts: [] },
visitedSpawn: (state, spawn) => {
const flag = this.assets.spawnEngineFlag(spawn);
return flag !== undefined && state.flags.has(flag);
},
scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark,
gameTime: () => this.gameTime,
@@ -2199,13 +2335,21 @@ export class GameWorld {
return true;
}
case "warp":
script.warp = {
map: effect.map,
x: effect.x,
y: effect.y,
facing: effect.facing,
};
script.warp = effect;
return false;
case "flymap":
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "flymap", view: effect.view, selected: 0 },
};
return true;
case "fly":
// FlyFromAnim's loop runs 129 frames after a DelayFrame, FlyToAnim's
// 65: 65 and 33 overworld ticks.
this.flight = { ...effect, frames: 0 };
script.waitTicks = effect.landing ? 33 : 65;
return true;
case "warpcheck":
script.warpCheck = true;
return false;
@@ -2653,19 +2797,24 @@ export class GameWorld {
private finishScript() {
const script = this.script!;
if (script.warp) {
const { map, x, y } = script.warp;
if (
!this.assets.isPorted(map) ||
!this.walkable(this.assets.map(map), x, y)
)
const { map, x, y, facing, door } = script.warp;
const target = this.assets.isPorted(map)
? this.assets.map(map)
: undefined;
if (!target || !this.walkable(target, x, y))
throw new Error("Script warp destination is not supported.");
// Whiteout healing/money and destination callbacks commit together.
const entry = this.prepareMap(map, "warp", script.run.state);
// MAPSETUP_DOOR has no SpawnInFacingDown: only the arrival tile
// turns the player down (CheckWarpFacingDown in RefreshPlayerSprite).
const keep =
door &&
!FACE_DOWN_ON_WARP.has(this.assets.collision(target, x, y) ?? 0);
this.player = {
...this.player,
x,
y,
direction: script.warp.facing ?? "down",
direction: facing ?? (keep ? this.player.direction : "down"),
motion: undefined,
};
this.applyMap(entry);
@@ -2697,6 +2846,14 @@ export class GameWorld {
}
if (!script.scene || script.run.deferred !== undefined)
this.sceneCheck = true;
// newloadmap stopped the script: ScriptEvents restarts it on the new
// map before the map's own scene scripts.
if (script.warp?.resume !== undefined) {
this.startScript(script.warp.resume, PLAYER, {
fieldMove: script.fieldMove,
});
return;
}
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
if (script.run.deferred !== undefined)
this.startScript(
@@ -2732,6 +2889,7 @@ export class GameWorld {
this.picture = undefined;
this.portrait = undefined;
this.evolution = undefined;
this.flight = undefined;
if (script.sighted !== undefined)
this.refusedSighting = {
map: this.mapId,
@@ -3244,10 +3402,13 @@ export class GameWorld {
else this.advanceMovement(1);
} else if (isTimed(script.effect)) {
// Pauses and emotes keep the map loop running; applymovement and
// open text freeze other objects.
this.stepObjects();
// open text freeze other objects, and the Fly animations run their
// own loops.
if (this.flight) this.flight.frames += 2;
else this.stepObjects();
if (--script.waitTicks <= 0) {
if (script.effect?.kind === "emote") this.emote = undefined;
this.flight = undefined;
this.pump();
}
}
@@ -3778,7 +3939,9 @@ export class GameWorld {
(dialogue.choice.kind === "trainercard" &&
!TRAINER_CARD_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never))
!OPTIONS_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "flymap" &&
!FLY_MAP_BUTTONS.includes(answer as never))
)
return;
this.townMap = false;
@@ -3848,6 +4011,7 @@ export class GameWorld {
lastSpawnMap: state.lastSpawnMap,
playerState: state.playerState,
backupWarp: state.backupWarp && { ...state.backupWarp },
digWarp: state.digWarp && { ...state.digWarp },
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
@@ -3945,11 +4109,14 @@ export class GameWorld {
OW.PLAYER_SURF,
OW.PLAYER_SURF_PIKA,
].includes(save.playerState as never) ||
(save.backupWarp !== null &&
(!save.backupWarp ||
typeof save.backupWarp.map !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.backupWarp.map) ||
!integer(save.backupWarp.warp, 1, 254))) ||
[save.backupWarp, save.digWarp].some(
(warp) =>
warp !== null &&
(!warp ||
typeof warp.map !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(warp.map) ||
!integer(warp.warp, 1, 254)),
) ||
typeof save.flash !== "boolean" ||
typeof save.noWildEncounters !== "boolean" ||
!save.registeredItem ||
@@ -4018,6 +4185,7 @@ export class GameWorld {
lastSpawnMap: save.lastSpawnMap,
playerState: save.playerState,
backupWarp: save.backupWarp && { ...save.backupWarp },
digWarp: save.digWarp && { ...save.digWarp },
flash: save.flash,
noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem },
@@ -4123,6 +4291,7 @@ export function migrateSave(
...(!Object.hasOwn(save, "rivalName") && { rivalName: "???" }),
...(!Object.hasOwn(save, "playerState") && { playerState: 0 }),
...(!Object.hasOwn(save, "backupWarp") && { backupWarp: null }),
...(!Object.hasOwn(save, "digWarp") && { digWarp: null }),
...(!Object.hasOwn(save, "flash") && { flash: false }),
...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false,
@@ -4174,6 +4343,7 @@ export function migrateSave(
dailyReset: null,
playerState: 0,
backupWarp: null,
digWarp: null,
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
+29 -3
View File
@@ -55,6 +55,7 @@ function showWorld(world: GameWorld) {
showPokegear(world);
showTrainerCard(world);
showOptions(world);
showFlyMap(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -72,6 +73,7 @@ function showWorld(world: GameWorld) {
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
dialogue.choice?.kind === "trainercard" ||
dialogue.choice?.kind === "options" ||
dialogue.choice?.kind === "flymap" ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
@@ -128,10 +130,11 @@ function showChoice(choice: Choice | undefined) {
else if (
choice.kind === "pokegear" ||
choice.kind === "trainercard" ||
choice.kind === "options"
choice.kind === "options" ||
choice.kind === "flymap"
) {
// The Pokégear, the trainer card and the options read the controls
// directly.
// The Pokégear, the trainer card, the options and the Fly map read
// the controls directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
@@ -987,6 +990,29 @@ function showOptions(world: GameWorld) {
);
}
let flyMapKey = "";
/** _FlyMap: TownMapBubble's "Where?" and flypoint name over the Town Map,
* with the Fly user's icon on the flypoint. */
function showFlyMap(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "flymap" ? choice.view : undefined;
get("fly-map").hidden = !view;
if (!view) {
flyMapKey = "";
return;
}
const key = JSON.stringify(view);
if (key === flyMapKey) return;
flyMapKey = key;
const where = document.createElement("span"),
arrows = document.createElement("span");
where.textContent = `${view.where}\n${view.name}`;
arrows.textContent = "▲▼";
arrows.setAttribute("aria-hidden", "true");
get("fly-map-bubble").replaceChildren(where, arrows);
drawTownMap(get<HTMLCanvasElement>("fly-map-canvas"), world, view);
}
let trainerCardKey = "";
/** TrainerCard (engine/menus/trainer_card.asm): the top half with the
* name, ID, money and card picture, then page 1's Pokédex count and play
+1
View File
@@ -35,6 +35,7 @@ export function renderShell(root: HTMLElement) {
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
<div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></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="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p>
+40 -1
View File
@@ -541,7 +541,8 @@ dialog h2 {
.intro-screen:not([hidden]) ~ .minimap-overlay,
.pokegear:not([hidden]) ~ .minimap-overlay,
.trainer-card:not([hidden]) ~ .minimap-overlay,
.options-screen:not([hidden]) ~ .minimap-overlay {
.options-screen:not([hidden]) ~ .minimap-overlay,
.fly-map:not([hidden]) ~ .minimap-overlay {
display: none;
}
.pokepic {
@@ -1348,6 +1349,44 @@ dialog dd {
align-content: start;
gap: 8px;
}
/* _FlyMap: the Town Map under TownMapBubble's "Where?" and the
selected flypoint, with the up/down arrows. */
.fly-map {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
gap: 8px;
padding: 10px;
color: var(--ink);
background: var(--cream);
}
.fly-map[hidden] {
display: none;
}
.fly-map-bubble {
justify-self: stretch;
display: grid;
grid-template-columns: 1fr auto;
margin: 0;
padding: 4px 10px;
border: 5px double var(--ink);
white-space: pre;
}
.fly-map-bubble span:last-child {
grid-row: span 2;
align-self: center;
}
.fly-map canvas {
width: auto;
height: 100%;
max-width: 100%;
min-height: 0;
image-rendering: pixelated;
object-fit: contain;
}
.options-screen li {
display: grid;
grid-template-columns: 1.4em 13ch auto;