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

+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 },