Files
Crystal/src/game/world/world.ts
T
Sébastien QUEROL 32d736ecae
Checks / web (push) Canceled after 0s
Port weather, copying moves, held items and obedience (#28)
Complete the weather, Substitute, Transform, called-move and forced-switch effects. Add held-item behavior, Exp. Share awards and badge-based traded Pokemon obedience, and persist trainer berry consumption.

Validated with 576 passing unit tests and 149 browser tests, native/reference builds, reference/vendor/release checks and 320 Metal Powder emulator comparisons per browser. Smart AI and special balls/battle types remain on #28.
2026-10-03 15:03:19 +02:00

4598 lines
163 KiB
TypeScript

import {
DecorationData,
initialDecorations,
type Decorations,
} from "./decorations";
import { CrystalAssets } from "../rom/assets";
import { TrainerData } from "../rom/trainers";
import {
ENGINE_FLAGS,
EVENTS,
INITIAL_POSITION,
NUM_FRUIT_TREES,
OVERWORLD,
PC,
SYMBOLS,
} from "../rom/profile";
import { OW } from "./constants";
import {
POKEGEAR_BUTTONS,
pokegearClock,
type PokegearView,
} from "../scripts/pokegear";
import { MOM_BANK_BUTTONS, type MomBankView } from "../scripts/mom";
import {
TRAINER_CARD_BUTTONS,
refreshTrainerCard,
type TrainerCardView,
} from "../scripts/trainer-card";
import {
OPTIONS_BUTTONS,
shiftBattleStyle,
battleSceneOn,
validOptions,
defaultOptions,
type GameOptions,
type OptionsView,
} from "../scripts/options";
import {
advanceGameTime,
emptyGameTime,
validGameTime,
type GameTime,
} from "./game-time";
import {
PLAYER,
ScriptRun,
copyScriptState,
emptyScriptState,
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,
type MenuFieldMove,
} from "../scripts/start-menu";
import { PartyEvolution } from "../scripts/evolution";
import { fish } from "../scripts/fish";
import {
copyMail,
MAILBOX_CAPACITY,
validMail,
validMailMessage,
type Mail,
} from "../party/mail";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
copyStorage,
emptyStorage,
validPCItems,
validStorage,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
caughtData,
validPartyMon,
type PartyMon,
} from "../party/pokemon";
import { defaultTextContext, readString } from "../scripts/text";
import {
Battle,
type BattleAction,
type BattleEffect,
type BattleInput,
type BattleOutcome,
} from "../battle/battle";
import { C } from "../battle/constants";
import {
DIRECTION_ORDER,
VECTORS,
type Direction,
type GameMap,
type MapObject,
type Warp,
} from "./types";
export interface NativeSave {
decorations: Decorations;
format: "crystal-native";
version: 3;
map: string;
x: number;
y: number;
direction: Direction;
name: string;
avatar: "chris" | "kris";
tilesWalked: number;
/** wGameTime: the play time the trainer card shows. */
gameTime: GameTime;
events: number[];
flags: number[];
variables: Record<number, number>;
scenes: Record<string, number>;
phone: number[];
/** Queued special call, persisted until its script completes. */
specialPhoneCall: number;
rivalName: string;
clock: { minuteOffset: number; weekdayOffset: number; dst: boolean };
/** The introduction has not finished: it starts again on load. */
intro: boolean;
/** Current-map objects moved or turned by scripts, as saved object structs. */
objects: { id: number; x: number; y: number; direction: Direction }[];
/** wPlayerID, drawn at a new game. */
playerId: number;
party: PartyMon[];
bag: Bag;
storage: PokemonStorage;
pcItems: ItemSlot[];
pokedex: { seen: number[]; caught: number[] };
money: number;
/** wMomsMoney: Mom's savings. */
momsMoney: number;
/** wWhichMomItem and wMomItemTriggerBalance: Mom's purchases. */
momItem: number;
momItemTriggerBalance: number;
fruitTrees: number[];
dailyReset: number | null;
/** wLastSpawnMapGroup/Number: the outdoor map last entered through a
* Pokémon Center door, for the whiteout/blackout spawn. Null on early
* version-3 backups and before any Center has been entered. */
lastSpawnMap: string | null;
/** wPlayerState: 0 walking, 1 biking, 2 skating, 4 or 8 surfing. */
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;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
/** sMailboxes: the PC's MAIL. */
mailbox: Mail[];
/** wRepelEffect, wCoins and wBlueCardBalance. */
repel: number;
coins: number;
blueCardBalance: number;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
}
/** A random byte from the platform's secure generator. */
function randomByte(): number {
return crypto.getRandomValues(new Uint8Array(1))[0];
}
/** One overworld tick is two video frames (MaxOverworldDelay). */
export const TICKS_PER_SECOND = 59.7275 / 2;
/** STEP_WALK: 8 ticks at 2 pixels per tick. */
export const WALK_TICKS = 8;
interface Motion {
/** OBJECT_LAST_MAP_X/Y: the tile being left, which still blocks others. */
fromX: number;
fromY: number;
/** Where the movement began and ends, for drawing and replayed saves. A
* jump crosses two tiles: its map position reaches the middle tile first
* and the landing only in the second half (StepFunction_PlayerJump). */
originX: number;
originY: number;
toX: number;
toY: number;
elapsed: number;
duration: number;
animate: boolean;
jump?: boolean;
}
/** UpdateJumpPosition: sprite heights over one jump. */
const JUMP_HEIGHTS = [
-4, -6, -8, -10, -11, -12, -12, -12, -11, -10, -9, -8, -6, -4, 0, 0,
];
/** A movement command's pose in progress, with OBJECT_STEP_FRAME for spins. */
interface PoseState {
pose: MovementPose;
elapsed: number;
spinFrame: number;
}
interface Actor {
x: number;
y: number;
direction: Direction;
motion?: Motion;
pose?: PoseState;
/** SLIDING_F from set_sliding/remove_sliding; unset uses the object's
* SpriteMovementData flags. Sliding objects show no walking frames. */
sliding?: boolean;
/** OBJECT_STEP_FRAME: advances every tick of a walking animation. */
stepFrame: number;
/** OBJECT_SPRITE_Y_OFFSET left behind by StepFunction_SkyfallTop ($60).
* It persists until another step function writes the offset. */
yOffset?: number;
hidden: boolean;
fixedFacing: boolean;
moved: boolean;
/** OBJECT_ACTION_FISHING, from fish_cast_rod or fish_got_bite until
* PutTheRodAway: the fishing frames replace the standing ones. */
fishing?: boolean;
/** HandleNPCStep state for map objects: OBJECT_INIT_X/Y, the step type
* and OBJECT_STEP_DURATION. "reset" runs the movement function next. */
npc?: {
homeX: number;
homeY: number;
step: "reset" | "sleep" | "walk" | "stand" | "boulder" | "bounce";
wait: number;
/** _MovementSpinInit ran: later calls turn before sleeping again. */
spun?: boolean;
/** BOULDER_MOVING_F and the push direction (OBJECT_RANGE low bits). */
boulder?: Direction;
};
}
export interface VisibleObject extends MapObject {
direction: Direction;
}
/** A drawable actor in world pixels, for renderers. */
export interface Pose {
id: number;
sprite: number;
x: number;
y: number;
direction: Direction;
frame: number;
palette?: number;
/** Height above the ground while jumping, and the shadow's offset. */
lift?: number;
shadow?: number;
/** Not drawn this tick (OBJECT_ACTION_00 or a flicker). */
hidden?: boolean;
/** A FACING_* layout that replaces the direction and frame. */
facing?: number;
/** IN_GRASS_F: the lower half is drawn behind the grass. */
inGrass?: boolean;
}
export type Choice =
| { kind: "yesno"; selected: 0 | 1 }
| { kind: "weekday"; names: string[]; selected: number }
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
| { kind: "clock"; labels: string[]; selected: number }
/** The naming screen: an empty answer keeps `current`. With `mail`, the
* mail entry's two lines. */
| {
kind: "name";
current: string;
max: number;
mail?: boolean;
selected?: undefined;
}
/** A vertical menu; `selected` answers with that item's index. */
| {
kind: "menu";
items: string[];
details?: string[];
selected: number;
/** Left and right answer "left" or "right". */
sides?: boolean;
}
/** The mart's BUY/SELL/QUIT box. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list; `selected` answers with that entry's index. */
| {
kind: "martlist";
mode: MartMode;
entries: MartEntry[];
selected: number;
}
/** The pack's pockets; `selected` is an entry of pocket `pocket`, and
* `switching` the one being moved with Select. */
| {
kind: "pack";
pockets: { name: string; entries: MartEntry[] }[];
pocket: number;
selected: number;
switching?: number;
prompt?: string;
}
/** A Pokégear card, answered with the button pressed. */
| { kind: "pokegear"; view: PokegearView; selected: 0 }
/** BankOfMom's amount box, answered with the button pressed. */
| { kind: "mombank"; view: MomBankView; selected: 0 }
/** A trainer card page, answered with the button pressed. */
| { 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";
mode: MartMode;
item: number;
name: string;
price: number;
max: number;
prompt: string;
selected: number;
};
/** Choices that read the buttons themselves and are answered with the
* button's name. */
const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" ||
choice.kind === "mombank" ||
choice.kind === "trainercard" ||
choice.kind === "options" ||
choice.kind === "flymap";
export interface Dialogue {
pages: string[];
index: number;
/** Shown with the last page; the answer resumes the script. */
choice?: Choice;
/** Remaining ticks for a timed page that ignores buttons. */
holdTicks?: number;
}
/** What the battle screen shows and waits for. */
export interface BattleView {
engine: Battle;
/** The current message; prompt messages wait for a button. */
message?: { text: string; prompt: boolean };
/** A choice the battle is waiting for, if any. */
prompt?: Extract<
BattleEffect,
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
>;
/** Yes/No cursor, as on the original boxes. */
selected: 0 | 1;
}
/** EvolutionAnimation in overworld ticks (two frames each): 80 frames on
* the old species, then .AnimationSequence's seven rounds of a B-checking
* wait (14, 12 … 2 frames) followed by that many flashes of the new and
* old pictures (about four frames each). */
function evolutionSchedule(from: number, to: number) {
const frames: { species: number; cancel: boolean }[] = [];
const show = (species: number, ticks: number, cancel = false) => {
for (let i = 0; i < ticks; i++) frames.push({ species, cancel });
};
show(from, 40);
for (let round = 1; round <= 7; round++) {
show(from, 8 - round, true);
for (let flash = 0; flash < round; flash++) {
show(to, 2);
show(from, 2);
}
}
return frames;
}
/** Ticks a `done` battle message stays before the battle continues. */
const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript {
run: ScriptRun | IntroRun | StartMenuRun;
effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave;
rollback: {
player: Actor;
actors: Map<number, Actor>;
spawned: Set<number>;
state: ScriptState;
mapId: string;
blocks: Uint8Array;
};
waitTicks: number;
moving: Map<
number,
{ steps: MovementStep[]; index: number; wait: number; turnTo?: Direction }
>;
/** follow: the follower replays the leader's steps (wFollowMovementQueue).
* A follower whose object struct comes first reads each step a tick late. */
follow?: {
leader: number;
follower: number;
pending?: { x: number; y: number; ticks: number };
};
/** Scene scripts that change nothing are not re-run until state changes. */
scene: boolean;
/** newloadmap, warp or warpfacing: entered after the script commits. */
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
* evolutions ExitBattle starts after a won battle. */
interlude?: {
run: Generator<ScriptEffect, unknown, number | string | undefined>;
result: number;
};
/** LoadMemScript: queued for the next PlayerEvents once this commits. */
memScript?: MemScript;
/** The trainer whose sight started this script, if any. */
sighted?: number;
}
/** wMapReentryScriptQueueFlag's script: work RAM, so it is not saved. */
interface MemScript {
script: number;
callerScript?: number;
}
const OPPOSITE: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
/** .ledge_table and side-wall direction codes in player_movement.asm. */
const EDGE_DIRECTIONS: readonly (readonly Direction[])[] = [
["right"],
["left"],
["up"],
["down"],
["down", "right"],
["down", "left"],
["up", "right"],
["up", "left"],
];
const WARP_DIRECTIONS: Partial<Record<number, Direction>> = {
0x70: "down",
0x76: "left",
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,
]);
const isPit = (collision: number) =>
collision === OW.COLL_PIT || collision === OW.COLL_PIT_68;
/** CheckWarpCollision without the carpets, which wait for input
* (CheckDirectionalWarp). Pits are warps the player falls through. */
const automaticWarp = (collision: number) =>
isPit(collision) ||
((collision & 0xf0) === OW.HI_NYBBLE_WARPS && !WARP_DIRECTIONS[collision]);
/** SetTallGrassFlags: CheckSuperTallGrassTile, then CheckGrassTile ($1x
* and $2x rows with the low three bits clear). */
const isTallGrass = (collision: number) =>
collision === OW.COLL_LONG_GRASS ||
collision === OW.COLL_LONG_GRASS_1C ||
(((collision & 0xf0) === 0x10 || (collision & 0xf0) === 0x20) &&
!(collision & 7));
const isIce = (collision: number) =>
collision === OW.COLL_ICE || collision === OW.COLL_ICE_2B;
const isWhirlpool = (collision: number) =>
collision === OW.COLL_WHIRLPOOL || collision === OW.COLL_WHIRLPOOL_2C;
/** Side walls ($b0) and buoys ($c0) block these directions
* (GetMovementPermissions). */
const edgeDirections = (tile: number): readonly Direction[] =>
(tile & 0xf0) === OW.HI_NYBBLE_SIDE_WALLS || (tile & 0xf0) === 0xc0
? EDGE_DIRECTIONS[tile & 7]
: [];
/** .CheckTile's .water_table, .land1_table and .land2_table, and the
* downward exit from doors, stairs and caves. */
const forcedDirection = (tile: number): Direction | undefined => {
switch (tile & 0xf0) {
case OW.HI_NYBBLE_CURRENT:
return (["right", "left", "up", "down"] as const)[tile & 3];
case OW.HI_NYBBLE_WALK:
return ([undefined, "right", "left", "up", "down"] as const)[tile & 7];
case OW.HI_NYBBLE_WALK_ALT:
return (["right", "left", "up", "down"] as const)[tile & 7];
case OW.HI_NYBBLE_WARPS:
return tile === OW.COLL_DOOR ||
tile === OW.COLL_DOOR_79 ||
tile === OW.COLL_STAIRCASE ||
tile === OW.COLL_CAVE
? "down"
: undefined;
}
return undefined;
};
/** ChangeDirectionScript's `deactivatefacing 3`: after a turn in place,
* input is read again on the fourth tick. */
const TURN_WAIT_TICKS = 3;
/** STEP_BIKE: 4 ticks at 4 pixels per tick (bikes and ice). */
const BIKE_TICKS = 4;
/** The emote bubble's first tile (Emotes' vTiles0 tile $f8). */
const EMOTE_BUBBLE_TILE = 0xf8;
/** BGEVENT_ITEM: a hidden item's bg_event (constants/script_constants.asm). */
const BGEVENT_ITEM = 7;
/** An object's state as LoadMapObjects creates it from the map data. */
const spawnActor = (object: MapObject): Actor => ({
x: object.x,
y: object.y,
direction: DIRECTION_ORDER[object.movement - 6] ?? "down",
stepFrame: 0,
hidden: false,
fixedFacing: false,
moved: false,
npc: { homeX: object.x, homeY: object.y, step: "reset", wait: 0 },
});
const copyActor = (actor: Actor): Actor => ({
...actor,
motion: actor.motion && { ...actor.motion },
pose: actor.pose && { ...actor.pose },
npc: actor.npc && { ...actor.npc },
});
/** SetFacingCounterclockwiseSpin's .facings, by OBJECT_STEP_FRAME bits 4-5. */
const SPIN_FACINGS: Direction[] = ["down", "right", "up", "left"];
/** SpriteMovementData flags1: SLIDING. */
const SLIDING = 1 << 3;
/** SPRITEMOVEFN_* functions that move an object on their own. */
const RANDOM_WALK_Y = 1,
RANDOM_WALK_X = 2,
RANDOM_WALK_XY = 3,
SLOW_RANDOM_SPIN = 4,
FAST_RANDOM_SPIN = 5,
STRENGTH = 0x11,
BIG_STANDING = 0x15,
BOUNCING = 0x16,
SPIN_CLOCKWISE = 0x18,
SPIN_COUNTERCLOCKWISE = 0x19;
/** _MovementSpinTurnRight/_MovementSpinTurnLeft's facing tables. */
const SPIN_CLOCKWISE_NEXT: Record<Direction, Direction> = {
down: "left",
up: "right",
left: "up",
right: "down",
};
const SPIN_COUNTERCLOCKWISE_NEXT: Record<Direction, Direction> = {
down: "right",
up: "left",
left: "down",
right: "up",
};
/** OBJECT_ACTION_* values with their own facings (map_object_action.asm). */
const ACTION_BIG_DOLL_SYM = 0x09,
ACTION_BOUNCE = 0x0a,
ACTION_WEIRD_TREE = 0x0b,
ACTION_BIG_DOLL_ASYM = 0x0c,
ACTION_BIG_DOLL = 0x0d;
/** FACING_* layouts (constants/map_object_constants.asm). */
const FACING_STEP_UP_0 = 0x04,
FACING_BIG_DOLL_ASYM = 0x16,
FACING_BIG_DOLL_SYM = 0x17,
FACING_WEIRD_TREE_0 = 0x18;
/** SPRITE_BIG_SNORLAX and SPRITE_BIG_LAPRAS are drawn symmetrically. */
const SYMMETRIC_BIG_DOLLS = new Set([0x33, 0x47]);
/** SpriteMovementData flags1 and palette flag bits. */
const WONT_DELETE = 1 << 1,
FIXED_FACING = 1 << 2,
NOCLIP_TILES = 1 << 4,
MOVE_ANYWHERE = 1 << 5,
NOCLIP_OBJS = 1 << 6,
SWIMMING = 1 << 5;
/** SpriteMovementData palette flag: a boulder Strength can push. */
const STRENGTH_BOULDER = 1 << 6;
const SPRITEMOVEDATA_STRENGTH_BOULDER = 25;
const OBJECTTYPE_TRAINER = 2;
/** Map environments (map_data_constants.asm) and the special call count. */
const TOWN = 1,
ROUTE = 2;
const NUM_SPECIALCALLS = 8;
/** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) =>
effect?.kind === "move" ||
effect?.kind === "emote" ||
effect?.kind === "pause" ||
effect?.kind === "fly" ||
effect?.kind === "headbutt";
/** ShakeHeadbuttTree after its graphics load: HideHeadbuttTree's WaitBGMap
* (4 frames), 32 frames of shaking, then the WaitBGMap that restores the
* tree, 40 frames in all. */
const HEADBUTT_TICKS = 20;
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
export class GameWorld {
mapId: string = INITIAL_POSITION.map;
private player: Actor = {
x: INITIAL_POSITION.x,
y: INITIAL_POSITION.y,
direction: "down",
stepFrame: 0,
hidden: false,
fixedFacing: false,
moved: false,
};
name = "CHRIS";
avatar: "chris" | "kris" = "chris";
tilesWalked = 0;
/** wGameTime. It counts from the end of the introduction, through
* scenes, menus and battles, as GameTimer does in every VBlank. */
gameTime: GameTime = emptyGameTime();
state: ScriptState = emptyScriptState();
dialogue: Dialogue | undefined;
emote: { object: number; emote: number } | undefined;
/** Species whose front picture is shown (pokepic), if any. */
picture: number | undefined;
/** The introduction's trainer or player picture, if any. */
portrait: Portrait | undefined;
/** An evolution animation in progress: the frame shown on each tick, and
* whether B may stop it then. */
evolution:
| {
from: number;
to: number;
cancelable: boolean;
step: number;
schedule: { species: number; cancel: boolean }[];
}
| undefined;
/** True from a new game until the introduction finishes. */
private introPending = false;
/** The battle in progress, if any. */
battle: BattleView | undefined;
private battleRun?: Generator<BattleEffect, BattleOutcome, BattleInput>;
private battleHold = 0;
/** wWildEncounterCooldown: set to five steps when a map is entered. */
private encounterCooldown = 5;
/** wPlayerID: two random bytes, as a new game draws them. */
playerId: number;
notice = "";
diagnostic = "";
private actors = new Map<number, Actor>();
private memScript?: MemScript;
private currentMap?: GameMap;
/** wCmdQueue's CMDQUEUE_STONETABLE entries for the current map. */
private stoneTables: number[] = [];
/** Objects spawned when the map loaded. As in the original, later event
* flag changes do not remove visible objects; appear/disappear do. */
private spawned = new Set<number>();
private script?: ActiveScript;
/** Scene scripts are re-evaluated only after the map or state changes. */
private sceneCheck = true;
private dailyFlags?: number[];
/** A trainer sighting whose script was refused. Rollback leaves the player
* in view; without this the trainer would restart the same refused
* script every tick until the player could never move away. */
private refusedSighting?: { map: string; id: number; x: number; y: number };
/** wPlayerTurningDirection: the last step's direction | $80, or 0 once
* the player stands or bumps. A standing player turns before walking. */
private turning = 0;
/** A turn in place: the new facing shows on the next tick, and
* ChangeDirectionScript holds input for three ticks. */
private turnTo?: Direction;
private turnWait = 0;
/** Start menu, party menu and pack cursors (work RAM, not saved). */
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0 };
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
private bumping = false;
/** A screen shake object (earthquake, step_shake) in progress. */
private shake?: { ticks: number; magnitude: number };
/** A finished step whose tile events run at the start of the next tick. */
private arrival?: { x: number; y: number };
/** After a warp, WarpToNewMapScript's `end` (or FallIntoMapScript's
* 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 };
/** ShakeHeadbuttTree while it plays: the facing square and the frames
* shown. */
private shakingTree?: { x: number; y: number; frames: number };
/** The EMOTE_* whose graphics are in the emote bubble's tiles, which
* show_emote displays. */
private bubbleEmote = 0;
constructor(
readonly assets: CrystalAssets,
private now: () => Date = () => new Date(),
/** A source of random bytes, like the game's Random routine. */
private random: () => number = randomByte,
) {
this.state.decorations = initialDecorations();
this.playerId = (this.random() << 8) | this.random();
// A new game warps into the bedroom. Its NEWMAP callback runs the
// original InitializeEventsScript, which supplies initial NPC flags.
const entry = this.prepareMap(INITIAL_POSITION.map, "warp");
this.applyMap(entry);
}
get x() {
return this.player.x;
}
get y() {
return this.player.y;
}
get direction() {
return this.player.direction;
}
/** The current map with its working copy of the block data
* (wOverworldMapBlocks), which changeblock and field moves modify until
* the map is loaded again. */
get map(): GameMap {
if (this.currentMap?.id !== this.mapId)
this.currentMap = this.workingMap(
this.mapId,
new Uint8Array(this.assets.map(this.mapId).blocks),
);
return this.currentMap;
}
private workingMap(id: string, blocks: Uint8Array): GameMap {
return { ...this.assets.map(id), blocks };
}
/** changeblock: the block covering map tile (x, y), via GetBlockLocation. */
private changeBlock(map: GameMap, x: number, y: number, block: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height)
throw new Error("changeblock outside the map is not ported.");
if (block >= 128) throw new Error("changeblock names an invalid block.");
map.blocks[Math.floor(y / 2) * (map.width / 2) + Math.floor(x / 2)] = block;
this.blockRevision++;
}
/** changemapblocks: ChangeMap copies new block data from the ROM. */
private replaceBlocks(map: GameMap, address: number) {
map.blocks.set(this.assets.reader.slice(address, map.blocks.length));
this.blockRevision++;
}
/** Counts block changes, so cached map drawings know to redraw. */
blockRevision = 0;
/** The state scripts see: a running script's draft, otherwise committed. */
get liveState(): ScriptState {
return this.script?.run.state ?? this.state;
}
/** What the player is waiting for: a scene, a step, or nothing. */
get activity(): "scene" | "walking" | "idle" {
return this.script
? "scene"
: this.player.motion || this.turnWait || this.arrival || this.warpSettle
? "walking"
: "idle";
}
/** True while a script, dialogue, step or turn is in progress. */
get busy(): boolean {
return Boolean(
this.script ||
this.dialogue ||
this.player.motion ||
this.battle ||
this.turnWait ||
this.arrival ||
this.warpSettle,
);
}
/** Palette time for the map: fixed palettes ignore the clock. */
get timeOfDay(): number {
const mode = this.map.paletteMode;
if (mode === 1) return 1;
if (mode === 2) return 2;
if (mode === 3) return 0;
// ReplaceTimeOfDayPals: PALETTE_DARK needs Flash, which gives night.
if (mode === 4) return this.liveState.flash ? 2 : 3;
return this.clockTimeOfDay;
}
/** The introduction is running, or has yet to run after a load. */
get intro() {
return this.introPending;
}
/** The game clock: the device time plus the offset InitClock chose. */
private gameNow(clock = this.liveState.clock) {
return new Date(this.now().getTime() + clock.minuteOffset * 60_000);
}
/** wTimeOfDay from the game clock: morning 4-10, day 10-18, night. */
get clockTimeOfDay(): number {
const hour = this.gameNow().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
}
clock() {
const now = this.gameNow();
return {
weekday: (now.getDay() + this.liveState.clock.weekdayOffset) % 7,
hours: now.getHours(),
minutes: now.getMinutes(),
};
}
private actor(object: MapObject): Actor {
let actor = this.actors.get(object.id);
if (!actor) {
actor = spawnActor(object);
this.actors.set(object.id, actor);
}
return actor;
}
private actorById(id: number): Actor | undefined {
if (id === PLAYER) return this.player;
const object = this.map.objects.find((candidate) => candidate.id === id);
return object && this.actor(object);
}
private visible(object: MapObject, state: ScriptState): boolean {
if (object.eventFlag !== 0xffff && state.events.has(object.eventFlag))
return false;
if (!this.assets.sprite(this.spriteOf(object, state))) return false;
const hour = this.gameNow().getHours(),
timeMask = 1 << this.clockTimeOfDay;
if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
if (object.hour1 === object.hour2) return true;
return object.hour1 < object.hour2
? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2;
}
/** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites. */
private spriteOf(object: MapObject, state = this.liveState): number {
if (object.sprite < OW.SPRITE_VARS) return object.sprite;
return state.variables[object.sprite - OW.SPRITE_VARS] ?? 0;
}
objects(): VisibleObject[] {
return this.map.objects.flatMap((object) => {
if (!this.spawned.has(object.id)) return [];
const actor = this.actor(object);
if (actor.hidden) return [];
return [
{ ...object, x: actor.x, y: actor.y, direction: actor.direction },
];
});
}
/** 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,
};
}
/** The tree ShakeHeadbuttTree shakes: its map square and the video
* frames since the shaking sprite appeared. */
get headbuttTree(): { x: number; y: number; frames: number } | undefined {
return this.shakingTree && { ...this.shakingTree };
}
/** 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,
sprite: number,
palette?: number,
): Pose => {
const motion = actor.motion;
const progress = motion ? motion.elapsed / motion.duration : 1;
const x = motion
? motion.originX * 16 + (motion.toX - motion.originX) * 16 * progress
: actor.x * 16;
const y = motion
? motion.originY * 16 + (motion.toY - motion.originY) * 16 * progress
: actor.y * 16;
const jump = motion?.jump
? {
lift: JUMP_HEIGHTS[
Math.min(15, Math.floor((motion.elapsed * 16) / motion.duration))
],
// MovementFunction_Shadow: lower for vertical jumps.
shadow: motion.originX === motion.toX ? 14 : 12,
}
: {};
const posed = actor.pose && this.posePresentation(actor.pose);
const poseFacing =
actor.pose?.pose === "fish-bite" ||
actor.pose?.pose === "fish-cast" ||
(!actor.pose && actor.fishing)
? 0x10 + DIRECTION_ORDER.indexOf(actor.direction)
: actor.pose?.pose === "tree"
? FACING_WEIRD_TREE_0 + ((actor.stepFrame >> 2) & 3)
: actor.pose?.pose === "skyfall-top"
? DIRECTION_ORDER.indexOf(actor.direction) * 4 +
((actor.stepFrame >> 2) & 3)
: undefined;
// IN_GRASS_F: set on arriving in tall grass, kept at the start of a
// step only into more grass, and cleared by a jump (SetTallGrassFlags).
const grass = (x: number, y: number) =>
isTallGrass(this.assets.collision(this.map, x, y));
const inGrass = motion
? !motion.jump &&
grass(motion.originX, motion.originY) &&
grass(motion.toX, motion.toY)
: grass(actor.x, actor.y);
return {
id,
sprite,
x: Math.round(x),
y: Math.round(y),
direction: actor.direction,
...(poseFacing !== undefined && { facing: poseFacing }),
...(posed?.hidden && { hidden: true }),
...((posed?.lift ?? actor.yOffset) !== undefined && {
lift: posed?.lift ?? actor.yOffset,
}),
...(inGrass && { inGrass: true }),
frame: motion?.animate
? (actor.stepFrame >> 2) & 3
: actor === this.player && !this.script
? // SetFacingStepAction during a turn; SetFacingBumpAction
// walks in place at half speed against an obstacle.
this.turnWait === 1 || this.turnWait === 2
? 1
: this.bumping
? (actor.stepFrame >> 3) & 3
: 0
: 0,
palette,
...jump,
};
};
const poses = this.objects().map((object) => {
const actor = this.actor(object),
sprite = this.spriteOf(object);
const drawn = pose(
actor,
object.id,
sprite,
object.palette ? object.palette - 1 : undefined,
);
const facing = actor.pose
? undefined
: this.actionFacing(object, actor, sprite);
return facing === undefined ? drawn : { ...drawn, facing };
});
if (!this.player.hidden)
poses.push(pose(this.player, PLAYER, this.playerSprite()));
return poses.filter((candidate) => !candidate.hidden);
}
/** Facings chosen by an object's own action rather than its direction:
* SetFacingBounce, SetFacingBigDoll* and SetFacingWeirdTree. */
private actionFacing(
object: MapObject,
actor: Actor,
sprite: number,
): number | undefined {
switch (this.assets.spriteMovement(object.movement).action) {
case ACTION_BOUNCE:
return actor.stepFrame & 8 ? FACING_STEP_UP_0 : 0;
case ACTION_BIG_DOLL_SYM:
return FACING_BIG_DOLL_SYM;
case ACTION_BIG_DOLL_ASYM:
return FACING_BIG_DOLL_ASYM;
case ACTION_BIG_DOLL:
return SYMMETRIC_BIG_DOLLS.has(sprite)
? FACING_BIG_DOLL_SYM
: FACING_BIG_DOLL_ASYM;
case ACTION_WEIRD_TREE:
return FACING_WEIRD_TREE_0 + ((actor.stepFrame >> 2) & 3);
default:
return undefined;
}
}
/** How a movement pose draws this tick: OBJECT_ACTION_00 hides the
* object; skyfall and teleports offset it with the ROM's sine table. */
private posePresentation(state: PoseState): {
hidden?: boolean;
lift?: number;
} {
const t = state.elapsed;
switch (state.pose) {
case "skyfall":
// .Init/.Step: invisible; .Fall: OBJECT_JUMP_HEIGHT 1-16.
return t < 15
? { hidden: true }
: { lift: this.sine(0x60, t - 14) - 0x60 };
case "teleport-from":
// .DoSpinRise: OBJECT_JUMP_HEIGHT counts up from $11.
return t < 16 ? {} : { lift: this.sine(0x60, 0x10 + t - 15) - 0x60 };
case "teleport-to":
if (t <= 16) return { hidden: true };
return t <= 32 ? { lift: this.sine(0x60, t - 16) - 0x60 } : {};
case "skyfall-top":
// .Run sets OBJECT_SPRITE_Y_OFFSET on its sixteenth tick, the one
// that also ends the command.
return t >= 15 ? { lift: 0x60 } : {};
case "fish-bite":
return { lift: t ? (t + 1) & 1 : 0 };
case "flicker":
return t & 1 ? { hidden: true } : {};
default:
return {};
}
}
/** Sine (home/sine.asm): amplitude * sin(a * pi/32), from the ROM table. */
private sine(amplitude: number, a: number): number {
const index = a & 0x3f,
entry = this.assets.reader.word(
OVERWORLD["_Sine.sinetable_u10"] + (index & 0x1f) * 2,
);
const value = (amplitude * entry) >> 8;
return index >= 0x20 ? -value : value;
}
private clearNotice() {
this.notice = "";
this.diagnostic = "";
}
/** IsNPCAtCoord: an object blocks its tile and, mid-step, the tile it
* is leaving (OBJECT_LAST_MAP_X/Y). */
private occupied(x: number, y: number, except?: number) {
return this.objects().some((object) => {
if (object.id === except) return false;
const motion = this.actor(object).motion;
return (
(object.x === x && object.y === y) ||
(motion !== undefined && motion.fromX === x && motion.fromY === y)
);
});
}
private walkable(map = this.map, x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return false;
return this.assets.permission(this.assets.collision(map, x, y)) === 0;
}
// ---------------------------------------------------------------- stepping
/** Complete one step immediately, including a directional warp. A test and
* tooling helper: the player faces `direction` at once instead of first
* turning in place, as held input from a standstill does. */
move(direction: Direction): boolean {
if (this.busy) return false;
if (!this.player.fixedFacing) this.player.direction = direction;
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
if (!this.beginStep(direction)) return false;
const motion = this.player.motion;
if (motion) {
this.player.motion = undefined;
this.player.x = motion.toX;
this.player.y = motion.toY;
this.finishStep({ x: motion.originX, y: motion.originY });
}
return true;
}
/** Refuse unsupported destinations before starting a movement. */
private beginStep(direction: Direction | undefined): boolean {
// Idle ticks also run DoPlayerMovement; only input dismisses a notice.
if (direction) this.clearNotice();
this.bumping = false;
try {
return this.playerMovement(direction);
} catch (error) {
this.refuse(error);
return false;
}
}
/** DoPlayerMovement (engine/overworld/player_movement.asm): forced
* terrain, turning in place, then a step, jump or directional warp.
* Returns true when a step, jump, turn or warp began. */
private playerMovement(held: Direction | undefined): boolean {
const player = this.player,
state = this.state,
tile = this.assets.collision(this.map, player.x, player.y);
// .GetDPad: standing on a downhill slope rides down.
if (!held && state.flags.has(OW.ENGINE_DOWNHILL)) held = "down";
// .CheckForced: a slide on ice keeps the last step's direction.
if (this.standingOnIce(tile)) held = DIRECTION_ORDER[this.turning & 3];
// .CheckTile: whirlpools spin the player back; currents, walk tiles
// and entrances move them regardless of input.
if (isWhirlpool(tile)) {
this.startScript(OVERWORLD.Script_ForcedMovement, PLAYER);
return true;
}
const forced = forcedDirection(tile);
if (forced) return this.forcedStep(forced);
// .CheckTurning: a standing player first faces a new direction.
if (!this.turning && held && held !== player.direction) {
this.turning = 0x80 | DIRECTION_ORDER.indexOf(held);
this.turnTo = held;
this.turnWait = TURN_WAIT_TICKS;
return true;
}
const surfing =
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA;
if (held) {
if (surfing ? this.trySurf(held) : this.tryStep(held, tile)) return true;
if (!surfing && this.tryJump(held, tile)) return true;
if (!surfing && this.checkEdgeWarp(held, tile)) return true;
}
// .NotMoving: bump into the obstacle (not into an edge warp), or stand.
this.turning = 0;
this.bumping = Boolean(held) && WARP_DIRECTIONS[tile] !== held;
if (this.bumping) player.stepFrame = (player.stepFrame + 1) & 0xff;
return false;
}
/** .CheckStrengthBoulder: with Strength active, walking into a standing
* boulder sets BOULDER_MOVING_F with the walking direction. The player
* still bumps; the boulder's own movement function then pushes it. */
private pushBoulder(x: number, y: number, direction: Direction) {
if (!this.state.flags.has(OW.ENGINE_STRENGTH_ACTIVE)) return;
const object = this.objects().find((o) => o.x === x && o.y === y);
if (!object) return;
const actor = this.actor(object);
if (actor.motion || !actor.npc) return;
if (
!(
this.assets.spriteMovement(object.movement).paletteFlags &
STRENGTH_BOULDER
)
)
return;
actor.npc.boulder = direction;
}
/** CheckStandingOnIce: after a step (wPlayerTurningDirection set), on an
* ice tile or skating. */
private standingOnIce(tile: number): boolean {
if (!this.turning || this.turning === 0xf0) return false;
return isIce(tile) || this.state.playerState === OW.PLAYER_SKATE;
}
/** .continue_walk: a STEP_WALK in the forced direction, without the
* permission checks of an ordinary step. */
private forcedStep(direction: Direction): boolean {
const player = this.player,
[dx, dy] = VECTORS[direction],
x = player.x + dx,
y = player.y + dy;
if (!this.enterable(x, y)) return false;
if (!player.fixedFacing) player.direction = direction;
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
this.startMotion(player, x, y, WALK_TICKS, true);
return true;
}
/** Validate a step's destination before it starts: a connected map must
* be ported and its callbacks supported; an automatic warp must lead
* somewhere ported. */
private enterable(x: number, y: number): boolean {
const map = this.map;
if (x >= 0 && y >= 0 && x < map.width && y < map.height)
return this.validateTileWarp(x, y, this.assets.collision(map, x, y));
const target = this.assets.connected(map, x, y);
if (!target) return false;
if (!this.assets.isPorted(target.map.id)) {
this.notice = `${target.map.location} is not ported yet.`;
return false;
}
this.prepareMap(target.map.id, "connection");
return true;
}
/** The collision a step toward (x, y) lands on: beyond the edge, the
* connected map's, as the original's connection strips provide. */
private collisionAt(x: number, y: number): number | undefined {
const map = this.map;
if (x >= 0 && y >= 0 && x < map.width && y < map.height)
return this.assets.collision(map, x, y);
const target = this.assets.connected(map, x, y);
return target && this.assets.collision(target.map, target.x, target.y);
}
/** GetMovementPermissions: side walls and buoys on the current tile, and
* on the destination facing back, block the step. */
private sideBlocked(source: number, target: number, direction: Direction) {
return (
edgeDirections(source).includes(direction) ||
edgeDirections(target).includes(OPPOSITE[direction])
);
}
/** .TryStep: land permission and objects, then the step speed: a slide
* off ice, bike speed (slower uphill on a downhill slope), or a walk. */
private tryStep(direction: Direction, tile: number): boolean {
const player = this.player,
[dx, dy] = VECTORS[direction],
x = player.x + dx,
y = player.y + dy;
const target = this.collisionAt(x, y);
if (
target === undefined ||
this.sideBlocked(tile, target, direction) ||
this.assets.permission(target) !== OW.LAND_TILE
)
return false;
if (this.occupied(x, y)) {
this.pushBoulder(x, y, direction);
return false;
}
const playerState = this.state.playerState;
let ticks = WALK_TICKS,
animate = true;
if (isIce(tile)) {
ticks = BIKE_TICKS; // STEP_ICE: fast_slide_step
animate = false;
} else if (
(playerState === OW.PLAYER_BIKE || playerState === OW.PLAYER_SKATE) &&
!(this.state.flags.has(OW.ENGINE_DOWNHILL) && direction !== "down")
)
ticks = BIKE_TICKS;
if (!this.enterable(x, y)) return false;
if (!player.fixedFacing) player.direction = direction;
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
this.startMotion(player, x, y, ticks, animate);
return true;
}
/** .TrySurf: water continues the surf; land ends it (GetOutOfWater). */
private trySurf(direction: Direction): boolean {
const player = this.player,
[dx, dy] = VECTORS[direction],
x = player.x + dx,
y = player.y + dy;
const target = this.collisionAt(x, y);
if (target === undefined) return false;
const tile = this.assets.collision(this.map, player.x, player.y);
const permission = this.assets.permission(target);
if (
this.sideBlocked(tile, target, direction) ||
(permission !== OW.WATER_TILE && permission !== OW.LAND_TILE) ||
this.occupied(x, y)
)
return false;
if (!this.enterable(x, y)) return false;
if (permission === OW.LAND_TILE) this.state.playerState = OW.PLAYER_NORMAL;
if (!player.fixedFacing) player.direction = direction;
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
this.startMotion(player, x, y, WALK_TICKS, true);
return true;
}
/** .TryJump: a ledge under the player permits hopping in its direction. */
private tryJump(direction: Direction, tile: number): boolean {
if ((tile & 0xf0) !== OW.HI_NYBBLE_LEDGES) return false;
if (!EDGE_DIRECTIONS[tile & 7].includes(direction)) return false;
const player = this.player,
[dx, dy] = VECTORS[direction],
x = player.x + dx,
y = player.y + dy,
landingX = player.x + dx * 2,
landingY = player.y + dy * 2,
map = this.map;
// Keep unported/malformed destinations and occupied landing tiles closed.
if (
!this.walkable(map, landingX, landingY) ||
this.occupied(x, y) ||
this.occupied(landingX, landingY)
)
return false;
const landing = this.assets.collision(map, landingX, landingY);
if (!this.validateTileWarp(landingX, landingY, landing)) return false;
if (!player.fixedFacing) player.direction = direction;
this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction);
// SetMinTwoStepWildEncounterCooldown is a no-op in Crystal.
this.startMotion(player, landingX, landingY, WALK_TICKS * 2, true, true);
return true;
}
/** .CheckWarp: a carpet warps when the player faces its exit direction
* and walks that way. */
private checkEdgeWarp(direction: Direction, tile: number): boolean {
if (WARP_DIRECTIONS[tile] !== direction) return false;
if (this.player.direction !== direction) return false;
const warp = this.map.warps.find(
(warp) => warp.x === this.x && warp.y === this.y,
);
if (!warp || !this.enterWarp(warp)) return false;
return true;
}
private validateTileWarp(x: number, y: number, collision: number): boolean {
if (!automaticWarp(collision)) return true;
const warp = this.map.warps.find((warp) => warp.x === x && warp.y === y);
return !warp || Boolean(this.prepareWarp(warp));
}
/** CopyWarpData: a warp numbered -1 leads back to wBackupWarpNumber and
* its map, which the arrival warp of an elevator, Pokémon Center 2F or
* similar room recorded (GetWarpDestCoords). */
private warpDestination(warp: Warp, state = this.state) {
if (warp.targetWarp !== 0xff)
return { map: warp.targetMap, warp: warp.targetWarp };
return state.backupWarp ?? undefined;
}
private prepareWarp(warp: Warp) {
const destination = this.warpDestination(warp);
if (
!destination ||
!this.assets.isPorted(destination.map) ||
destination.warp === 0 ||
destination.warp === 255
) {
this.notice = "This destination is not ported yet.";
return undefined;
}
const target = this.assets.map(destination.map);
const arrival = target.warps[destination.warp - 1];
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) &&
outdoors
)
sourceState = {
...copyScriptState(sourceState),
lastSpawnMap: this.mapId,
};
// GetWarpDestCoords: an arrival warp numbered -1 remembers where the
// player came from (wPrevWarp and wPrevMapGroup/Number).
if (arrival.targetWarp === 0xff) {
const index = this.map.warps.indexOf(warp) + 1;
sourceState = {
...copyScriptState(sourceState),
backupWarp: { map: this.mapId, warp: index },
};
}
const entry = this.prepareMap(target.id, "warp", sourceState);
return {
entry,
arrival,
collision: this.assets.collision(target, arrival.x, arrival.y),
};
}
private enterWarp(warp: Warp): boolean {
const destination = this.prepareWarp(warp);
if (!destination) return false;
this.player.x = destination.arrival.x;
this.player.y = destination.arrival.y;
this.player.motion = undefined;
if (FACE_DOWN_ON_WARP.has(destination.collision))
this.player.direction = "down";
this.applyMap(destination.entry);
// RefreshMapSprites: a warp leaves the player standing, with the sprite
// CheckUpdatePlayerSprite chooses.
this.turning = 0;
this.checkUpdatePlayerSprite(this.state);
this.warpSettle = {};
return true;
}
/** MAPSETUP_WARP to a position (Script_warp with SPAWN_N_A): the map's
* NEWMAP, TILES and OBJECTS callbacks run, and the player arrives
* standing, facing down (SpawnInFacingDown). */
warpTo(mapId: string, x: number, y: number) {
if (this.busy) throw new Error("Cannot warp during a scene or step.");
if (!this.assets.isPorted(mapId))
throw new Error("This map is not ported yet.");
const map = this.assets.map(mapId);
if (x < 0 || y < 0 || x >= map.width || y >= map.height)
throw new Error("The warp position is outside the map.");
const entry = this.prepareMap(mapId, "warp");
this.player = {
...this.player,
x,
y,
direction: "down",
motion: undefined,
};
this.applyMap(entry);
this.turning = 0;
this.checkUpdatePlayerSprite(this.state);
}
private refuse(error: unknown) {
this.notice =
"This area needs game logic that has not been ported yet. Your progress is unchanged.";
this.diagnostic = error instanceof Error ? error.message : String(error);
}
private startMotion(
actor: Actor,
x: number,
y: number,
duration: number,
animate: boolean,
jump = false,
) {
actor.motion = {
fromX: actor.x,
fromY: actor.y,
originX: actor.x,
originY: actor.y,
toX: x,
toY: y,
elapsed: 0,
duration,
animate,
jump,
};
if (jump) {
// UpdateJumpPosition rewrites OBJECT_SPRITE_Y_OFFSET.
actor.yOffset = undefined;
// The first half of a jump occupies the tile being jumped over.
actor.x += Math.sign(x - actor.x);
actor.y += Math.sign(y - actor.y);
} else {
actor.x = x;
actor.y = y;
}
}
/** Arrival: connections, warps, then coordinate events (CheckTileEvent). */
private finishStep(previous: { x: number; y: number }) {
const player = this.player,
map = this.map;
this.tilesWalked++;
if (
player.x < 0 ||
player.y < 0 ||
player.x >= map.width ||
player.y >= map.height
) {
const target = this.assets.connected(map, player.x, player.y);
if (!target) throw new Error("Connection destination is invalid.");
const entry = this.prepareMap(target.map.id, "connection");
player.x = target.x;
player.y = target.y;
this.applyMap(entry);
return;
}
// PlayerEvents: CheckTrainerEvent precedes CheckTileEvent, and a seen
// player's warp, coordinate event and step count are then skipped.
if (this.checkTrainers()) return;
const warp = map.warps.find(
(warp) => warp.x === player.x && warp.y === player.y,
);
const tile = this.assets.collision(map, player.x, player.y);
if (warp && automaticWarp(tile)) {
// PLAYEREVENT_FALL: FallIntoMapScript's newloadmap MAPSETUP_FALL is
// the warp itself; the skyfall and earthquake then run on arrival.
if (this.enterWarp(warp) && isPit(tile))
this.warpSettle = { script: OVERWORLD.FallIntoMapScript + 2 };
return;
}
const owner = this.assets.sceneOwner(this.mapId);
const scene = owner ? (this.state.scenes[owner] ?? 0) : 0xff;
const event = map.coordinateEvents.find(
(event) =>
(event.scene === scene || event.scene === 0xff) &&
event.x === player.x &&
event.y === player.y,
);
if (event)
this.startScript(event.script, PLAYER, { committedPosition: previous });
else if (!this.checkSpecialPhoneCall(previous) && !this.repelStep(previous))
this.randomEncounter(previous);
}
/** CountStep's DoRepelStep: a Repel loses a step, and the step that ends
* it runs RepelWoreOffScript instead of the encounter check. A save
* during that text replays the step with the Repel's last step. */
private repelStep(previous: { x: number; y: number }): boolean {
const state = this.state;
if (!state.repel) return false;
if (--state.repel) return false;
this.startScript(OVERWORLD.RepelWoreOffScript, PLAYER, {
committedPosition: previous,
});
if (this.script) this.script.committed.repel = 1;
return true;
}
/** CountStep / CheckSpecialPhoneCall: a queued call waits for a step
* after trainer, warp and coordinate events, before encounters. Its
* SpecialPhoneCallList entry supplies the condition and caller script,
* which runs inside the ROM's receive-call script; the caller script
* commits the cleared queue and any story flag together. */
private checkSpecialPhoneCall(previous: { x: number; y: number }): boolean {
const id = this.state.specialPhoneCall;
if (id < 1 || id > NUM_SPECIALCALLS) return false;
const r = this.assets.reader,
entry = SYMBOLS.specialPhoneCallList + (id - 1) * 6;
const condition = r.near(entry, r.bank(entry));
if (condition === SYMBOLS.specialCallOnlyWhenOutside) {
if (this.map.environment !== TOWN && this.map.environment !== ROUTE)
return false;
} else if (condition !== SYMBOLS.specialCallWhereverYouAre) return false;
this.startScript(SYMBOLS.receiveSpecialPhoneCall, PLAYER, {
committedPosition: previous,
callerScript: r.far(entry + 3),
});
return true;
}
/** RandomEncounter: cooldown, grass, the map's rate, then a slot. */
private randomEncounter(previous: { x: number; y: number }) {
if (this.encounterCooldown) {
this.encounterCooldown--;
if (this.encounterCooldown) return;
}
const map = this.map,
player = this.player,
state = this.state;
const collision = this.assets.collision(map, player.x, player.y);
// CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
// on ice.
if (state.noWildEncounters) return;
if (
map.environment !== OW.CAVE &&
map.environment !== OW.DUNGEON &&
!this.assets.grassCollisions().includes(collision)
)
return;
if (isIce(collision)) return;
const table = this.encounterTable(state);
if (!(this.random() < table.rate)) return;
const chosen = this.chooseWildEncounter(table);
if (!chosen) return;
// CheckRepelEffect: under a Repel, only Pokémon at least as high in
// level as the first one that has not fainted.
const lead = state.party.find((mon) => mon.hp > 0);
if (!lead) return;
if (state.repel && chosen.level < lead.level) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
});
// The step replays after a reload, so it keeps its Repel step.
if (state.repel && this.script) this.script.committed.repel++;
}
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
* which a Cleanse Tag halves. */
private encounterTable(state: ScriptState) {
const collision = this.assets.collision(this.map, this.x, this.y);
const water = this.assets.permission(collision) === OW.WATER_TILE,
kanto = this.inKanto();
const grass = water
? undefined
: this.assets.grassWildMons(this.mapId, kanto);
const sea = water
? this.assets.waterWildMons(this.mapId, kanto)
: undefined;
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
// ApplyCleanseTagEffectOnEncounterRate
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
return { water, grass, sea, rate };
}
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
* varies the level. Undefined when no valid Pokémon is chosen. */
private chooseWildEncounter({
water,
grass,
sea,
}: ReturnType<GameWorld["encounterTable"]>) {
if (!grass && !sea) return undefined;
let roll: number;
do roll = this.random();
while (roll >= 100);
roll++;
const r = this.assets.reader;
let entry = water ? OVERWORLD.WaterMonProbTable : SYMBOLS.grassMonProbTable;
while (r.byte(entry) < roll) entry += 2;
const slot = r.byte(entry + 1) >> 1;
const chosen = {
...(sea ? sea.slots[slot] : grass!.slots[this.clockTimeOfDay][slot]),
};
if (water) {
// Surfing encounters vary their level: +1 to +4 above 35%.
const variety = this.random();
for (const threshold of [35, 65, 85, 95])
if (variety >= Math.floor((threshold * 255) / 100)) chosen.level++;
}
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
return undefined;
return chosen;
}
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
* on ice. */
private canEncounterWildMon(state: ScriptState) {
const map = this.map,
collision = this.assets.collision(map, this.x, this.y);
return (
!state.noWildEncounters &&
(map.environment === OW.CAVE ||
map.environment === OW.DUNGEON ||
this.assets.grassCollisions().includes(collision)) &&
!isIce(collision)
);
}
/** A field move chosen from the party menu (CutFunction, SurfFunction and
* the rest in engine/events/overworld.asm), checked on the menu's draft
* for party slot `mon`: a script queued for when the menu closes, the
* messages of a failed attempt, or a notice for what is not ported. */
private menuFieldMove(
state: ScriptState,
mon: number,
move: number,
): MenuFieldMove {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
const tile = this.assets.collision(this.map, x, y) ?? 0;
const env = this.map.environment,
outdoors = env === OW.TOWN || env === OW.ROUTE;
const failed = { texts: [OVERWORLD["FieldMoveFailed.CantUseItemText"]] };
const badge = (flag: number) =>
state.flags.has(flag)
? undefined
: { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] };
switch (move) {
case OW.CUT: {
const block = this.fieldMoveBlock(x, y, tile, "cut");
return (
badge(OW.ENGINE_HIVEBADGE) ??
(block
? {
script: OVERWORLD.Script_CutFromMenu,
fieldMove: { mon, block },
}
: { texts: [OVERWORLD.CutNothingText] })
);
}
case OW.SURF: {
const noBadge = badge(OW.ENGINE_FOGBADGE);
if (noBadge) return noBadge;
const cant = { texts: [OVERWORLD.CantSurfText] };
if (state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) return cant;
if (
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA
)
return { texts: [OVERWORLD.AlreadySurfingText] };
const current = this.assets.collision(this.map, this.x, this.y);
if (
this.assets.permission(tile) !== OW.WATER_TILE ||
this.sideBlocked(current, tile, this.player.direction) ||
this.facingObject()
)
return cant;
return {
script: OVERWORLD.SurfFromMenuScript,
fieldMove: {
mon,
// GetSurfType: Pikachu surfs on its own sprite.
surfState:
state.party[mon].species === OW.PIKACHU
? OW.PLAYER_SURF_PIKA
: OW.PLAYER_SURF,
},
};
}
case OW.STRENGTH:
return (
badge(OW.ENGINE_PLAINBADGE) ?? {
script: OVERWORLD.Script_StrengthFromMenu,
fieldMove: { mon },
}
);
case OW.FLASH: {
const noBadge = badge(OW.ENGINE_ZEPHYRBADGE);
if (noBadge) return noBadge;
// SpecialAerodactylChamber opens its wall; otherwise the map must
// be dark (DARKNESS_PALSET).
const chamber =
this.map.attributes ===
OVERWORLD.RuinsOfAlphAerodactylChamber_MapAttributes;
if (chamber) state.events.add(EVENTS.wallOpenedInAerodactylChamber);
return chamber || this.timeOfDay === 3
? { script: OVERWORLD.Script_UseFlash, fieldMove: { mon } }
: failed;
}
case OW.WHIRLPOOL: {
const noBadge = badge(OW.ENGINE_GLACIERBADGE);
if (noBadge) return noBadge;
const block = isWhirlpool(tile)
? this.fieldMoveBlock(x, y, tile, "whirlpool")
: undefined;
return block
? {
script: OVERWORLD.Script_WhirlpoolFromMenu,
fieldMove: { mon, block },
}
: failed;
}
case OW.WATERFALL: {
const noBadge = badge(OW.ENGINE_RISINGBADGE);
if (noBadge) return noBadge;
// CheckMapCanWaterfall: facing up at a waterfall tile.
return this.player.direction === "up" &&
(tile === OW.COLL_WATERFALL || tile === OW.COLL_CURRENT_DOWN)
? { script: OVERWORLD.Script_WaterfallFromMenu, fieldMove: { mon } }
: failed;
}
case OW.FLY:
// .TryFly: the Fly map follows outdoors (flyTo).
return (
badge(OW.ENGINE_STORMBADGE) ?? (outdoors ? { flyMap: true } : failed)
);
case OW.DIG:
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
? { script: OVERWORLD.HeadbuttFromMenuScript, fieldMove: { mon } }
: failed;
case OW.ROCK_SMASH: {
// GetFacingObject leaves the rock in hLastTalked.
const rock = this.facingObject();
return rock?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? {
script: OVERWORLD.RockSmashFromMenuScript,
fieldMove: { mon },
lastTalked: rock.id,
}
: failed;
}
case OW.SWEET_SCENT: {
// SweetScentEncounter, drawn now: no rate roll, and no battle
// without a rate or a valid Pokémon.
let wild: { species: number; level: number } | undefined;
if (this.canEncounterWildMon(state)) {
const table = this.encounterTable(state);
if (table.rate) wild = this.chooseWildEncounter(table);
}
return {
script: OVERWORLD["SweetScentFromMenu.SweetScent"],
fieldMove: { mon },
wild,
};
}
}
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 {
let landmark = this.map.landmark;
if (landmark === OW.LANDMARK_FAST_SHIP) return false;
if (landmark === OW.LANDMARK_SPECIAL) {
const backup = this.state.backupWarp?.map;
landmark = backup ? this.assets.map(backup).landmark : 0;
}
return landmark >= OW.KANTO_LANDMARK;
}
// ------------------------------------------------------------ map entry
/** Evaluate map callbacks on a copy, so an unsupported callback leaves the
* current map untouched. Methods follow the original map setup scripts. */
private prepareMap(
id: string,
method: "warp" | "connection" | "continue",
sourceState = this.state,
) {
const map = this.workingMap(id, new Uint8Array(this.assets.map(id).blocks));
const state = copyScriptState(sourceState);
const actors = new Map<number, Actor>();
if (method !== "continue") {
// HandleNewMap: ResetMapBufferEventFlags, ResetFlashIfOutOfCave and
// ResetBikeFlags, before the NEWMAP callbacks.
for (let event = 0; event < 8; event++) state.events.delete(event);
if (map.environment === OW.TOWN || map.environment === OW.ROUTE)
state.flash = false;
for (const flag of [
OW.ENGINE_STRENGTH_ACTIVE,
OW.ENGINE_ALWAYS_ON_BIKE,
OW.ENGINE_DOWNHILL,
])
state.flags.delete(flag);
}
// HandleNewMap runs NEWMAP, then HandleContinueMap the CMDQUEUE callback
// (after ClearCmdQueue); TILES and OBJECTS follow. Continue skips NEWMAP.
const kinds = method === "continue" ? [3, 1] : [5, 3, 1, 2];
const stoneTables: number[] = [];
let draft = state;
for (const kind of kinds) {
for (const callback of map.callbacks.filter(
(callback) => callback.kind === kind,
)) {
const run = new ScriptRun(
this.assets.reader,
callback.script,
draft,
this.host(PLAYER, id),
{
callback: true,
},
);
for (let effect = run.resume(); effect; effect = run.resume())
if (effect.kind === "stonetable") stoneTables.push(effect.address);
else this.applyCallbackEffect(map, actors, effect, run.state);
draft = run.state;
}
}
return { id, state: draft, actors, map, stoneTables };
}
private applyCallbackEffect(
map: GameMap,
actors: Map<number, Actor>,
effect: ScriptEffect,
state: ScriptState,
) {
if (effect.kind === "changeblock")
return this.changeBlock(map, effect.x, effect.y, effect.block);
if (effect.kind === "mapblocks")
return this.replaceBlocks(map, effect.address);
const object = map.objects.find(
(candidate) => "object" in effect && candidate.id === effect.object,
);
if (!object) throw new Error("Callback names an unknown object.");
let actor = actors.get(object.id);
if (!actor) {
actor = spawnActor(object);
actors.set(object.id, actor);
}
if (effect.kind === "moveobject") {
actor.x = effect.x;
actor.y = effect.y;
// moveobject writes the map object's coordinates: its new home.
if (actor.npc)
Object.assign(actor.npc, { homeX: effect.x, homeY: effect.y });
actor.moved = true;
} else if (effect.kind === "turn") {
actor.direction = effect.direction;
actor.moved = true;
} else if (effect.kind === "appear" || effect.kind === "disappear") {
if (object.eventFlag !== 0xffff) {
if (effect.kind === "appear") state.events.delete(object.eventFlag);
else state.events.add(object.eventFlag);
}
}
}
private applyMap(entry: {
id: string;
state: ScriptState;
actors: Map<number, Actor>;
map: GameMap;
stoneTables: number[];
}) {
this.mapId = entry.id;
this.currentMap = entry.map;
this.stoneTables = entry.stoneTables;
this.state = entry.state;
this.actors = entry.actors;
this.spawned = new Set(
this.map.objects
.filter((object) => this.visible(object, entry.state))
.map((object) => object.id),
);
this.sceneCheck = true;
this.encounterCooldown = 5; // SetUpFiveStepWildEncounterCooldown
}
// -------------------------------------------------------------- scripts
private host(lastTalked: number, mapId = this.mapId): ScriptHost {
return {
mapId,
lastTalked,
sceneOwner: (id) => this.assets.sceneOwner(id),
timeOfDay: () => this.clockTimeOfDay,
clock: () => this.clock(),
clockDay: () => this.gameNow().getDay(),
text: () => ({
...defaultTextContext(this.name),
rival: this.state.rivalName,
weekday: this.weekdayName(this.clock().weekday),
}),
data: this.assets.pokemon,
player: () => ({
id: this.playerId,
gender: this.avatar === "kris" ? 1 : 0,
}),
random: () => this.random() & 0xff,
landmark: () => this.assets.map(mapId).landmark,
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
position: () => ({ x: this.player.x, y: this.player.y }),
objectPosition: (id) => {
if (id === PLAYER) return { x: this.player.x, y: this.player.y };
const object = this.map.objects.find(
(candidate) => candidate.id === id,
);
if (!object || !this.spawned.has(id)) return undefined;
const actor = this.actor(object);
return { x: actor.x, y: actor.y };
},
playerCollision: () =>
this.assets.collision(this.map, this.player.x, this.player.y),
environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService,
facingObject: () => this.facingObject(),
};
}
/** FishFunction with rod 0-2 (Old, Good, Super): never while surfing,
* and only facing water (the facing tile may hold an object, as in the
* original). The map's fish group decides the script: nothing at all,
* no bite, or a bite with the hooked Pokémon. A failure (.FailFish)
* prints nothing before Oak's warning. */
private fishFunction(state: ScriptState, rod: number): MenuFieldMove {
const surfing =
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA;
const [dx, dy] = VECTORS[this.player.direction];
const tile =
this.assets.collision(this.map, this.player.x + dx, this.player.y + dy) ?? 0;
if (surfing || this.assets.permission(tile) !== OW.WATER_TILE)
return { texts: [] };
const fieldMove = { mon: 0 },
group = this.map.fishGroup;
if (!group) return { script: OVERWORLD.Script_NotEvenANibble2, fieldMove };
const hooked = fish(
this.assets.reader,
group,
rod,
this.clockTimeOfDay,
() => this.random() & 0xff,
);
return hooked
? { script: OVERWORLD.Script_GotABite, fieldMove, wild: hooked, fish: true }
: { script: OVERWORLD.Script_NotEvenANibble, fieldMove };
}
/** GetFacingObject: the visible object on the tile the player faces. */
private facingObject() {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
return this.objects().find((object) => object.x === x && object.y === y);
}
/** CheckForHiddenItems: a BGEVENT_ITEM not found yet on the screen
* around the player, from four squares left of and above the player to
* five right of and four below it. */
private hiddenItemOnScreen(state: ScriptState) {
const r = this.assets.reader,
{ x, y } = this.player;
return this.map.backgroundEvents.some(
(event) =>
event.kind === BGEVENT_ITEM &&
((y + 4 - event.y) & 0xff) < 9 &&
((x + 5 - event.x) & 0xff) < 10 &&
!state.events.has(r.word(event.script)),
);
}
private weekdayName(day: number) {
const r = this.assets.reader;
return readString(
r,
r.near(SYMBOLS.weekdayNames + day * 2, r.bank(SYMBOLS.weekdayNames)),
defaultTextContext(this.name),
).trim();
}
private startScript(
start: number,
lastTalked: number,
{
committedPosition,
scene = false,
wild,
itemBall,
trainer,
hiddenItem,
sighted,
callerScript,
fieldMove,
usedItem,
fish,
}: {
usedItem?: number;
/** BATTLETYPE_FISH, set by FishFunction for a hooked Pokémon. */
fish?: boolean;
fieldMove?: FieldMoveContext;
committedPosition?: { x: number; y: number };
scene?: boolean;
wild?: { species: number; level: number };
itemBall?: { item: number; quantity: number };
trainer?: { header: number; distance: number };
hiddenItem?: { event: number; item: number };
sighted?: number;
callerScript?: number;
} = {},
) {
const committed = this.save();
if (committedPosition) {
// Coordinate events replay from the tile before the triggering step.
committed.x = committedPosition.x;
committed.y = committedPosition.y;
committed.tilesWalked--;
}
this.script = {
run: new ScriptRun(
this.assets.reader,
start,
this.state,
this.host(lastTalked),
{
wild,
itemBall,
trainer,
hiddenItem,
callerScript,
fieldMove,
usedItem,
fish,
},
),
committed,
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene,
sighted,
fieldMove,
};
this.pump();
}
private evolutionHost() {
return {
reader: this.assets.reader,
data: this.assets.pokemon,
timeOfDay: () => this.clockTimeOfDay,
text: () => this.host(PLAYER).text(),
};
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
if (this.dialogue?.choice?.kind === "pokegear") {
this.choose("select");
return true;
}
if (this.dialogue?.choice?.kind === "pack") {
this.choosePackEntry(undefined, undefined, true);
return true;
}
return this.openStartMenu(true);
}
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(select = false): boolean {
if (!select && this.dialogue?.choice?.kind === "pokegear") {
this.choose("start");
return true;
}
if (this.busy || this.introPending) return false;
this.clearNotice();
this.script = {
run: new StartMenuRun(
this.state,
{
reader: this.assets.reader,
data: this.assets.pokemon,
text: () => this.host(PLAYER).text(),
bicycleScript: (state, select) => this.bicycleScript(state, select),
timeOfDay: () => this.clockTimeOfDay,
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,
hiddenItemOnScreen: (state) => this.hiddenItemOnScreen(state),
fish: (state, rod) => this.fishFunction(state, rod),
},
this.menuCursors,
select,
),
committed: this.save(),
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.pump();
return true;
}
/** NewGame's PlayerProfileSetup and OakSpeech, before the player
* appears in the bedroom. Saves made meanwhile start it again. */
beginIntro() {
if (this.script) throw new Error("The introduction needs an idle world.");
this.introPending = true;
// A new game's ResetGameTime; the timer starts when it finishes.
this.gameTime = emptyGameTime();
this.script = {
run: new IntroRun(this.state, {
reader: this.assets.reader,
now: () => this.now(),
text: (player) => ({ ...this.host(PLAYER).text(), player }),
}),
committed: this.save(),
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.pump();
}
/** Run the active script until it needs presentation time or input. */
private pump(input?: number | string) {
const script = this.script;
if (!script) return;
try {
for (;;) {
let effect: ScriptEffect | undefined;
const interlude = script.interlude;
if (interlude) {
const next = interlude.run.next(input);
input = undefined;
if (next.done) {
script.interlude = undefined;
input = interlude.result;
continue;
}
effect = next.value;
} else {
effect = script.run.resume(input);
input = undefined;
}
script.effect = effect;
if (!effect) {
this.finishScript();
return;
}
if (this.present(effect)) return;
}
} catch (error) {
this.abortScript(error);
}
}
/** Apply an effect. Returns true when the script must wait. */
private present(effect: ScriptEffect): boolean {
const script = this.script!;
// Commands after a warp would run in the new map in the original.
if (script.warp)
throw new Error("Script commands after a warp are not ported.");
switch (effect.kind) {
case "text":
if (!effect.pages.length) return false;
this.dialogue = {
pages: effect.pages,
index: 0,
holdTicks: effect.holdTicks,
};
return true;
case "yesno":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "yesno", selected: 1 },
};
return true;
case "weekday":
this.dialogue = {
pages: effect.pages,
index: 0,
choice: { kind: "weekday", names: effect.names, selected: 0 },
};
return true;
case "turn": {
const actor = this.actorById(effect.object);
if (actor && !actor.fixedFacing) {
actor.direction = effect.direction;
actor.moved = true;
}
return false;
}
case "face": {
const actor = this.actorById(effect.object),
target = this.actorById(effect.target);
if (!actor || !target || actor.fixedFacing) return false;
const dx = target.x - actor.x,
dy = target.y - actor.y;
if (dx === 0 && dy === 0) return false;
actor.direction =
Math.abs(dx) > Math.abs(dy)
? dx > 0
? "right"
: "left"
: dy > 0
? "down"
: "up";
actor.moved = true;
return false;
}
case "appear":
case "disappear": {
const object = this.map.objects.find(
(candidate) => candidate.id === effect.object,
);
if (!object) throw new Error("Script names an unknown object.");
if (object.eventFlag !== 0xffff) {
if (effect.kind === "appear")
script.run.state.events.delete(object.eventFlag);
else script.run.state.events.add(object.eventFlag);
}
if (effect.kind === "appear") {
this.actor(object).hidden = false;
if (this.assets.sprite(this.spriteOf(object)))
this.spawned.add(object.id);
} else this.spawned.delete(object.id);
return false;
}
case "moveobject": {
const actor = this.actorById(effect.object);
if (!actor || effect.object === PLAYER)
throw new Error("moveobject needs a map object.");
actor.x = effect.x;
actor.y = effect.y;
actor.motion = undefined;
// moveobject writes the map object's coordinates: its new home.
if (actor.npc)
Object.assign(actor.npc, {
homeX: effect.x,
homeY: effect.y,
step: "reset",
});
actor.moved = true;
return false;
}
case "follow":
script.follow = { leader: effect.leader, follower: effect.follower };
return false;
case "stopfollow":
script.follow = undefined;
return false;
case "move":
if (!this.actorById(effect.object))
throw new Error("Movement names an unknown object.");
script.moving.set(effect.object, {
steps: effect.steps,
index: 0,
wait: 0,
});
this.advanceMovement(0);
return script.moving.size > 0;
case "emote":
// ShowEmoteScript: show (1 tick), pause N ticks, hide (1 tick).
this.emote = { object: effect.object, emote: effect.emote };
this.bubbleEmote = effect.emote;
script.waitTicks = effect.ticks + 2;
return true;
case "pause":
script.waitTicks = effect.ticks;
return true;
case "picture":
this.picture = effect.species || undefined;
return false;
case "portrait":
this.portrait = effect.portrait;
return false;
case "clock":
this.dialogue = {
pages: effect.pages,
index: 0,
choice: {
kind: "clock",
labels: effect.labels,
selected: effect.selected,
},
};
return true;
case "battle": {
const draft = script.run.state;
const trainers = new TrainerData(this.assets.reader);
const opponent =
effect.trainer &&
trainers.trainer(effect.trainer.classId, effect.trainer.id);
if (opponent?.attributes.items.some((item) => item !== 0))
throw new Error("Trainer item AI is not ported.");
const trainer = opponent && {
name: opponent.name,
className: opponent.className,
party: trainers.createParty(opponent, {
otId: this.playerId,
ot: this.name,
}),
aiFlags: opponent.attributes.aiMoveFlags,
switchFlags: opponent.attributes.aiItemSwitchFlags,
reward: opponent.reward,
winText: effect.trainer?.winText,
lossText: effect.trainer?.lossText,
};
const engine = new Battle({
data: this.assets.pokemon,
party: draft.party,
pokedex: draft.pokedex,
playerId: this.playerId,
playerName: this.name,
johtoBadges: Array.from({ length: 8 }, (_, bit) =>
draft.flags.has(OW.ENGINE_ZEPHYRBADGE + bit) ? 1 << bit : 0,
).reduce((bits, badge) => bits | badge, 0),
timeOfDay: this.clockTimeOfDay,
random: () => this.random() & 0xff,
wild: effect.wild,
trainer,
canLose: effect.canLose,
tree: effect.tree,
fish: effect.fish,
mapId: this.mapId,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
storage: draft.storage,
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
caughtData: (level) =>
caughtData(
this.clockTimeOfDay,
level,
this.avatar === "kris" ? 1 : 0,
this.assets.map(this.mapId).landmark,
),
tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark,
partyMenuCursor: this.menuCursors.party,
// Only MOM_SAVING_SOME_MONEY_F has an engine flag; nothing in the
// ROM sets the half and all bits.
mom: {
saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)),
money: draft.momsMoney,
},
shift: shiftBattleStyle(draft.options),
battleScene: battleSceneOn(draft.options),
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
this.pumpBattle();
return true;
}
case "warp":
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 "loademote":
// Emotes bound for the bubble's tiles ($f8) replace its graphics;
// the others (the rod, the shadow, dust) go elsewhere.
if (this.assets.emoteTile(effect.emote) === EMOTE_BUBBLE_TILE)
this.bubbleEmote = effect.emote;
return false;
case "putrodaway":
this.player.fishing = false;
return false;
case "headbutt": {
const [dx, dy] = VECTORS[this.player.direction];
this.shakingTree = {
x: this.player.x + dx,
y: this.player.y + dy,
frames: 0,
};
script.waitTicks = HEADBUTT_TICKS;
return true;
}
case "warpcheck":
script.warpCheck = true;
return false;
case "notice":
this.notice = effect.text;
return false;
case "memscript":
// LoadMemScript keeps a script that is already queued.
script.memScript ??= {
script: effect.script,
callerScript: effect.callerScript,
};
return false;
case "townmap":
this.townMap = true;
this.dialogue = { pages: ["Town Map"], index: 0 };
return true;
case "stats":
this.statsScreen = effect.mon;
this.dialogue = { pages: ["Stats"], index: 0 };
return true;
case "mail":
this.mailView = { ...effect.mail };
this.dialogue = { pages: ["MAIL"], index: 0 };
return true;
case "pokegear":
this.pokegear = effect.view;
if (!effect.view) return false;
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "pokegear", view: effect.view, selected: 0 },
};
return true;
case "trainercard":
if (!effect.view) {
this.trainerCard = undefined;
return false;
}
// Each page starts its colon and badge timing when it loads.
if (this.trainerCard?.page !== effect.view.page) this.cardFrames = 0;
this.trainerCard = effect.view;
refreshTrainerCard(effect.view, this.cardFrames, this.gameTime);
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "trainercard", view: effect.view, selected: 0 },
};
return true;
case "options":
if (!effect.view) return false;
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "options", view: effect.view, selected: 0 },
};
return true;
case "mombank":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "mombank", view: effect.view, selected: 0 },
};
return true;
case "menu":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: {
kind: "menu",
items: effect.items,
...(effect.details && { details: effect.details }),
selected: effect.selected,
...(effect.sides && { sides: true }),
},
};
return true;
case "changeblock":
this.changeBlock(this.map, effect.x, effect.y, effect.block);
return false;
case "mapblocks":
this.replaceBlocks(this.map, effect.address);
return false;
case "stonetable":
this.stoneTables.push(effect.address);
return false;
case "name":
this.dialogue = {
pages: effect.pages,
index: 0,
choice: {
kind: "name",
current: effect.current,
max: effect.max,
...(effect.mail && { mail: true }),
},
};
return true;
case "martmenu":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martmenu",
prompt: effect.prompt,
selected: effect.selected,
},
};
return true;
case "martlist":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martlist",
mode: effect.mode,
entries: effect.entries,
selected: effect.selected,
},
};
return true;
case "evolve":
this.picture = effect.from;
this.evolution = {
from: effect.from,
to: effect.to,
cancelable: effect.cancelable,
step: 0,
schedule: evolutionSchedule(effect.from, effect.to),
};
return true;
case "pack":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "pack",
pockets: effect.pockets,
pocket: effect.pocket,
selected: effect.selected,
...(effect.switching !== undefined && {
switching: effect.switching,
prompt: effect.prompt,
}),
},
};
return true;
case "martquantity":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martquantity",
mode: effect.mode,
item: effect.item,
name: effect.name,
price: effect.price,
max: effect.max,
prompt: effect.prompt,
selected: effect.selected,
},
};
return true;
}
}
/** Advance scripted movement by one tick (0 only reads pending commands).
* A command read on tick N first moves the object on tick N + 1, so
* consecutive steps are continuous, as in the original object loop. */
private advanceMovement(ticks: number) {
const script = this.script!;
if (ticks) {
this.animate(this.player);
for (const actor of this.actors.values()) this.animate(actor);
for (const id of script.moving.keys())
this.animatePose(this.actorById(id)!);
// GetFollowerNextMovementIndex: a follower processed before its leader
// takes the queued step on the following tick.
const follow = script.follow,
pending = follow?.pending;
if (follow && pending) {
follow.pending = undefined;
this.followerStep(follow.follower, pending);
}
}
for (const [id, movement] of script.moving) {
const actor = this.actorById(id)!;
movement.wait = Math.max(0, movement.wait - ticks);
// StepFunction_Turn shows the new facing on the command's second tick.
if (movement.turnTo && movement.wait === 2) {
if (!actor.fixedFacing) actor.direction = movement.turnTo;
actor.moved = true;
movement.turnTo = undefined;
}
if (movement.wait) continue;
const finishedPose = actor.pose;
actor.pose = undefined;
// StepFunction_SkyfallTop.Run leaves the sprite $60 pixels lower.
if (finishedPose?.pose === "skyfall-top") actor.yOffset = 0x60;
// Instant commands continue reading within the same tick.
for (let reads = 0; ; reads++) {
if (reads > 256) throw new Error("Movement loops without waiting.");
const step = movement.steps[movement.index++];
if (!step) {
// step_end preserves OBJECT_ACTION until the next movement update.
if (finishedPose?.pose === "flicker")
actor.pose = { ...finishedPose, elapsed: finishedPose.elapsed - 1 };
script.moving.delete(id);
// step_end returns the object to its own movement function, from
// its start (OBJECT_MOVEMENT_INDEX 0).
if (actor.npc)
Object.assign(actor.npc, { step: "reset", spun: false });
break;
}
if (this.movementCommand(id, actor, step, movement)) break;
}
}
}
/** Run one movement command. Returns true when it takes time or ends the
* movement (the command loop then stops for this tick). */
private movementCommand(
id: number,
actor: Actor,
step: MovementStep,
movement: {
steps: MovementStep[];
index: number;
wait: number;
turnTo?: Direction;
},
): boolean {
const script = this.script!;
switch (step.kind) {
case "turn":
if (!actor.fixedFacing) actor.direction = step.direction;
actor.moved = true;
movement.wait = step.ticks;
return true;
case "turn-step":
movement.turnTo = step.direction;
movement.wait = step.ticks;
return true;
case "sleep":
movement.wait = step.ticks;
return true;
case "pose":
actor.pose = this.startPose(actor, step.pose);
if (step.pose === "fish-cast" || step.pose === "fish-bite")
actor.fishing = true;
if (step.pose === "tree") actor.stepFrame = (actor.stepFrame + 1) & 15;
movement.wait = step.ticks;
return true;
case "bump":
movement.wait = step.ticks;
return true;
case "fix-facing":
actor.fixedFacing = step.fixed;
return false;
case "sliding":
actor.sliding = step.sliding;
return false;
case "visibility":
actor.hidden = step.hidden;
return false;
case "emote":
this.emote = step.shown
? { object: id, emote: this.bubbleEmote }
: undefined;
return false;
case "shake":
this.startShake(step.displacement);
return false;
case "loop":
movement.index = 0;
return false;
case "wait-end":
// Movement_step_wait_end: the script resumes while the object sleeps.
script.moving.delete(id);
if (actor.npc)
Object.assign(actor.npc, { step: "sleep", wait: step.ticks });
return true;
case "remove":
script.moving.delete(id);
if (id !== PLAYER) this.spawned.delete(id);
return true;
case "stop":
script.moving.delete(id);
if (actor.npc) actor.npc.step = "stand";
return true;
case "step":
case "jump": {
const [dx, dy] = VECTORS[step.direction];
const fromX = actor.x,
fromY = actor.y;
if (!actor.fixedFacing) actor.direction = step.direction;
const animate =
step.kind === "jump" || (step.animate && !this.isSliding(id, actor));
if (step.kind === "jump") {
this.startMotion(
actor,
actor.x + dx * 2,
actor.y + dy * 2,
step.ticks * 2,
animate,
true,
);
actor.moved = true;
movement.wait = step.ticks * 2;
return true;
}
this.startMotion(
actor,
actor.x + dx,
actor.y + dy,
step.ticks,
animate,
);
// TurningStep: OBJECT_ACTION_SPIN turns the object while it moves.
if (step.spin) actor.pose = this.startPose(actor, "spin");
actor.moved = true;
const follow = script.follow;
if (follow?.leader === id) {
// The follower walks into the square the leader just left: first
// QueueFollowerFirstStep's step, then the leader's own steps.
const target = { x: fromX, y: fromY, ticks: step.ticks };
// Object structs are processed in order, the player's first.
if (follow.follower < id) follow.pending = target;
else this.followerStep(follow.follower, target);
}
movement.wait = step.ticks;
return true;
}
}
}
private followerStep(
id: number,
target: { x: number; y: number; ticks: number },
) {
const follower = this.actorById(id);
if (!follower || (follower.x === target.x && follower.y === target.y))
return;
const fx = target.x - follower.x,
fy = target.y - follower.y;
follower.direction =
Math.abs(fx) > Math.abs(fy)
? fx > 0
? "right"
: "left"
: fy > 0
? "down"
: "up";
// The original queues movement directions, not a teleport to the
// leader's old coordinate. A separated follower still takes one step.
const [dx, dy] = VECTORS[follower.direction];
this.startMotion(
follower,
follower.x + dx,
follower.y + dy,
target.ticks,
true,
);
follower.moved = true;
}
/** SLIDING_F: set by movement or by the object's SpriteMovementData. */
private isSliding(id: number, actor: Actor): boolean {
if (actor.sliding !== undefined) return actor.sliding;
if (id === PLAYER) return false;
const object = this.map.objects.find((candidate) => candidate.id === id);
return Boolean(
object && this.assets.spriteMovement(object.movement).flags & SLIDING,
);
}
/** Movement_step_dig and similar: OBJECT_STEP_FRAME starts from the
* facing (GetSpriteDirection, rlca twice). */
private startPose(actor: Actor, pose: MovementPose): PoseState {
// These step functions rewrite OBJECT_SPRITE_Y_OFFSET and end at 0.
if (
pose === "skyfall" ||
pose === "teleport-from" ||
pose === "teleport-to" ||
pose === "fish-bite"
)
actor.yOffset = undefined;
return {
pose,
elapsed: 0,
spinFrame:
pose === "teleport-from" || pose === "teleport-to"
? 1
: (DIRECTION_ORDER.indexOf(actor.direction) << 4) | 1,
};
}
/** One tick of a pose: spins turn counterclockwise every four ticks
* (CounterclockwiseSpinAction), which also changes OBJECT_DIRECTION. */
private animatePose(actor: Actor) {
const pose = actor.pose;
if (!pose) return;
pose.elapsed++;
if (pose.pose === "tree") actor.stepFrame = (actor.stepFrame + 1) & 15;
if (pose.pose === "skyfall-top")
actor.stepFrame = (actor.stepFrame + 2) & 15;
const spinning =
pose.pose === "spin" ||
pose.pose === "spin-flicker" ||
pose.pose === "teleport-from" ||
(pose.pose === "teleport-to" && pose.elapsed > 16);
if (!spinning) return;
// Each teleport phase initializes OBJECT_STEP_FRAME before its first
// spin action, independently of the direction it arrived facing.
if (
(pose.pose === "teleport-from" && pose.elapsed === 16) ||
(pose.pose === "teleport-to" && pose.elapsed === 17)
)
pose.spinFrame = 0;
let timer = (pose.spinFrame & 0x0f) + 1,
facing = pose.spinFrame & 0x30;
if (timer >= 4) {
timer = 0;
facing = (facing + 0x10) & 0x30;
}
pose.spinFrame = facing | timer;
actor.direction = SPIN_FACINGS[facing >> 4];
}
/** ShakeScreen: MovementFunction_ScreenShake's duration is the low six
* bits; the displacement doubles with each of the top two bits. */
private startShake(displacement: number) {
this.shake = {
ticks: displacement & 0x3f,
magnitude: 1 << (displacement >> 6),
};
}
/** Vertical screen offset of an earthquake this tick (StepFunction_ScreenShake). */
get screenShake(): number {
const shake = this.shake;
if (!shake || !shake.ticks) return 0;
return shake.ticks & 1 ? -shake.magnitude : shake.magnitude;
}
private animate(actor: Actor) {
const motion = actor.motion;
if (!motion) return;
motion.elapsed++;
if (motion.animate) actor.stepFrame = (actor.stepFrame + 1) & 15;
if (motion.jump && motion.elapsed * 2 === motion.duration) {
// The first half ends: CopyCoordsTileToLastCoordsTile.
motion.fromX = actor.x;
motion.fromY = actor.y;
} else if (motion.jump && motion.elapsed * 2 === motion.duration + 2) {
// .initland: GetNextTile moves on to the landing tile.
actor.x = motion.toX;
actor.y = motion.toY;
}
if (motion.elapsed >= motion.duration) actor.motion = undefined;
}
private finishScript() {
const script = this.script!;
if (script.warp) {
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: facing ?? (keep ? this.player.direction : "down"),
motion: undefined,
};
this.applyMap(entry);
this.turning = 0;
this.checkUpdatePlayerSprite(this.state);
} else {
this.state = script.run.state;
// WarpCheck on a warp tile enables events for the next tick.
if (
script.warpCheck &&
automaticWarp(this.assets.collision(this.map, this.x, this.y))
)
this.arrival = { x: this.x, y: this.y };
}
this.script = undefined;
this.memScript ??= script.memScript;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
this.portrait = undefined;
if (script.run instanceof IntroRun) {
this.name = script.run.name;
this.avatar = script.run.avatar;
this.introPending = false;
}
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(
script.run.deferred,
(script.run instanceof StartMenuRun &&
script.run.deferredMove?.lastTalked) ||
PLAYER,
script.run instanceof StartMenuRun
? {
usedItem: script.run.deferredItem,
fieldMove: script.run.deferredMove?.fieldMove,
wild: script.run.deferredMove?.wild,
fish: script.run.deferredMove?.fish,
}
: {},
);
}
private abortScript(error: unknown) {
const script = this.script!;
this.battle = undefined;
this.battleRun = undefined;
this.state = script.rollback.state;
this.mapId = script.rollback.mapId;
this.map.blocks.set(script.rollback.blocks);
this.blockRevision++;
this.player = script.rollback.player;
this.actors = script.rollback.actors;
this.spawned = script.rollback.spawned;
this.script = undefined;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
this.portrait = undefined;
this.evolution = undefined;
this.flight = undefined;
this.shakingTree = undefined;
if (script.sighted !== undefined)
this.refusedSighting = {
map: this.mapId,
id: script.sighted,
x: this.player.x,
y: this.player.y,
};
this.refuse(error);
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
}
/** Drive the battle until it needs presentation time or input. */
private pumpBattle(input?: BattleInput) {
const view = this.battle,
run = this.battleRun;
if (!view || !run) return;
try {
for (;;) {
const next = run.next(input);
input = undefined;
view.prompt = undefined;
if (next.done) {
if (this.script) {
const draft = this.script.run.state,
{ prizeMoney = 0, momMoney = 0, payDayMoney = 0 } = next.value;
draft.money = Math.min(
C.MAX_MONEY,
draft.money + prizeMoney + payDayMoney,
);
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
}
this.battle = undefined;
this.battleRun = undefined;
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
const { result, evolvable } = next.value;
this.menuCursors = { start: 0, party: 0, pocket: 0 };
if (this.script && evolvable && !(result & 0x0f)) {
this.script.interlude = {
run: new PartyEvolution(
this.script.run.state,
this.evolutionHost(),
).evolveAfterBattle(evolvable),
result,
};
this.pump();
} else this.pump(result);
return;
}
const effect = next.value;
if (effect.kind === "hud") continue;
if (effect.kind === "text") {
view.message = { text: effect.text, prompt: effect.prompt };
this.battleHold = effect.prompt ? 0 : BATTLE_MESSAGE_TICKS;
return;
}
// The battle menu replaces the text box; other choices keep it.
if (effect.kind === "action") view.message = undefined;
view.prompt = effect;
view.selected = 1;
return;
}
} catch (error) {
this.abortScript(error);
this.notice =
"This battle needs game logic that has not been ported yet. Your progress is unchanged.";
}
}
/** FIGHT with a move slot, or RUN, from the battle menu. */
battleAction(action: BattleAction) {
if (this.battle?.prompt?.kind === "action") this.pumpBattle(action);
}
/** Name a caught Pokémon ("" keeps the species name). */
battleName(name: string) {
const prompt = this.battle?.prompt;
if (
prompt?.kind !== "name" ||
name.length > prompt.max ||
(name !== "" && !NICKNAME_PATTERN.test(name))
)
return;
this.pumpBattle(name);
}
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels),
* a party menu (a party index, or -1 to go back) or an item's move
* choice (0-3, or -1 to go back). */
battleChoose(answer: number) {
const prompt = this.battle?.prompt;
if (
prompt?.kind === "yesno" ||
prompt?.kind === "forget" ||
prompt?.kind === "party" ||
prompt?.kind === "move"
)
this.pumpBattle(answer);
}
/** A in battle: continue a prompt message, confirm Yes/No or the stat box. */
private battleA() {
const view = this.battle!;
const prompt = view.prompt;
if (prompt?.kind === "yesno") return this.pumpBattle(view.selected);
if (prompt?.kind === "stats") return this.pumpBattle();
if (!prompt && view.message?.prompt) this.pumpBattle();
}
/** B in battle: continue a message, answer No, or cancel forgetting. */
private battleB() {
const view = this.battle!;
const prompt = view.prompt;
if (prompt?.kind === "yesno") return this.pumpBattle(0);
if (prompt?.kind === "forget") return this.pumpBattle(4);
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
if (prompt?.kind === "move") return this.pumpBattle(-1);
if (prompt?.kind === "stats") return this.pumpBattle();
if (!prompt && view.message?.prompt) this.pumpBattle();
}
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
* first idle check on a new game day clears the daily engine flags. */
private checkDailyReset() {
const now = this.gameNow(this.state.clock);
const day =
Math.floor(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
) + this.state.clock.weekdayOffset;
if (this.state.dailyReset === day) return;
this.dailyFlags ??= this.assets.dailyEngineFlags();
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
this.state.dailyReset = day;
}
// ---------------------------------------------------------------- NPCs
/** HandleNPCStep: every spawned object runs its step type once per tick
* (engine/overworld/map_objects.asm). */
private stepObjects() {
for (const object of this.map.objects) {
if (!this.spawned.has(object.id)) continue;
const actor = this.actor(object),
npc = actor.npc;
if (!npc || !this.stillVisible(object, actor)) continue;
actor.pose = undefined;
if (npc.step === "walk") {
// StepFunction_ContinueWalk, then RandomStepDuration_Slow.
this.animate(actor);
if (!actor.motion) this.randomSleep(actor, 0x7f, false);
} else if (npc.step === "boulder") {
// StepFunction_StrengthBoulder: CopyDECoordsToMapObject on arrival.
this.animate(actor);
if (!actor.motion) {
Object.assign(npc, { homeX: actor.x, homeY: actor.y, step: "reset" });
actor.moved = true;
}
} else if (npc.step === "bounce") {
// SetFacingBounce: OBJECT_STEP_FRAME counts every tick.
actor.stepFrame = (actor.stepFrame + 1) & 15;
} else if (npc.step === "sleep") this.sleep(actor);
else if (npc.step === "reset") this.movementFunction(object, actor);
}
}
/** CheckObjectStillVisible: an object whose tile and home are both beyond
* the object window is deleted, and respawns at home when it returns. */
private stillVisible(object: MapObject, actor: Actor): boolean {
const npc = actor.npc!;
const inWindow = (x: number, y: number) =>
x >= this.player.x - 5 &&
x <= this.player.x + 6 &&
y >= this.player.y - 5 &&
y <= this.player.y + 5;
if (inWindow(actor.x, actor.y)) return true;
if (inWindow(npc.homeX, npc.homeY)) return true;
if (this.assets.spriteMovement(object.movement).flags & WONT_DELETE)
return true;
if (actor.x !== npc.homeX || actor.y !== npc.homeY || npc.step !== "reset")
Object.assign(actor, {
...spawnActor(object),
npc: { ...npc, step: "reset", wait: 0 },
x: npc.homeX,
y: npc.homeY,
moved: actor.moved,
});
return false;
}
/** StepFunction_Sleep: OBJECT_STEP_DURATION counts down as a byte. */
private sleep(actor: Actor) {
const npc = actor.npc!;
npc.wait = (npc.wait - 1) & 0xff;
if (!npc.wait) npc.step = "reset";
}
/** RandomStepDuration_Slow/_Fast. From a movement function the new sleep
* step also runs in the same tick (HandleStepType's jumptable call). */
private randomSleep(actor: Actor, mask: number, now: boolean) {
const npc = actor.npc!;
npc.step = "sleep";
npc.wait = this.random() & mask;
if (now) this.sleep(actor);
}
/** StepFunction_FromMovement for the random walk and spin functions;
* other functions leave the object standing. */
private movementFunction(object: MapObject, actor: Actor) {
const npc = actor.npc!,
movement = this.assets.spriteMovement(object.movement);
switch (movement.fn) {
case RANDOM_WALK_Y:
case RANDOM_WALK_X:
case RANDOM_WALK_XY: {
const roll = this.random();
const index =
movement.fn === RANDOM_WALK_Y
? roll & 1
: movement.fn === RANDOM_WALK_X
? (roll & 1) | 2
: roll & 3;
const direction = DIRECTION_ORDER[index];
// InitStep turns the object even when the step is then refused.
if (!(movement.flags & FIXED_FACING) && !actor.fixedFacing)
actor.direction = direction;
if (!this.canObjectMove(object, actor, direction, movement)) {
this.randomSleep(actor, 0x7f, true);
return;
}
const [dx, dy] = VECTORS[direction];
this.startMotion(
actor,
actor.x + dx,
actor.y + dy,
NPC_STEP_TICKS,
true,
);
npc.step = "walk";
this.animate(actor);
return;
}
case SLOW_RANDOM_SPIN:
actor.direction = DIRECTION_ORDER[(this.random() & 0x0c) >> 2];
this.randomSleep(actor, 0x7f, true);
return;
case FAST_RANDOM_SPIN: {
const current = DIRECTION_ORDER.indexOf(actor.direction) << 2;
let facing = this.random() & 0x0c;
if (facing === current) facing ^= 0x0c;
actor.direction = DIRECTION_ORDER[facing >> 2];
this.randomSleep(actor, 0x1f, true);
return;
}
case BIG_STANDING:
npc.step = "stand";
return;
case BOUNCING:
// EndSpriteMovement, then SetFacingBounce in the same tick.
npc.step = "bounce";
actor.stepFrame = 1;
return;
case SPIN_CLOCKWISE:
case SPIN_COUNTERCLOCKWISE: {
// _MovementSpinInit, then every 16 ticks _MovementSpinTurn*.
if (npc.spun && !actor.fixedFacing)
actor.direction = (
movement.fn === SPIN_CLOCKWISE
? SPIN_CLOCKWISE_NEXT
: SPIN_COUNTERCLOCKWISE_NEXT
)[actor.direction];
npc.spun = true;
npc.step = "sleep";
npc.wait = 16;
this.sleep(actor);
return;
}
case STRENGTH: {
// MovementFunction_Strength: a boulder on a pit stops for good.
if (isPit(this.assets.collision(this.map, actor.x, actor.y))) {
npc.step = "stand";
return;
}
const direction = npc.boulder;
npc.boulder = undefined;
if (
!direction ||
!this.canObjectMove(object, actor, direction, movement)
)
return;
const [dx, dy] = VECTORS[direction];
// InitStep at STEP_SLOW; SLIDING boulders show no walking frames.
this.startMotion(
actor,
actor.x + dx,
actor.y + dy,
NPC_STEP_TICKS,
false,
);
npc.step = "boulder";
this.animate(actor);
return;
}
default:
npc.step = "stand";
}
}
/** CanObjectMoveInDirection (engine/overworld/npc_movement.asm). */
private canObjectMove(
object: MapObject,
actor: Actor,
direction: Direction,
movement: { flags: number; paletteFlags: number },
): boolean {
const [dx, dy] = VECTORS[direction],
x = actor.x + dx,
y = actor.y + dy,
map = this.map;
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return false;
const source = this.assets.collision(map, actor.x, actor.y),
target = this.assets.collision(map, x, y);
const swimming = Boolean(movement.paletteFlags & SWIMMING);
if (swimming || !(movement.flags & NOCLIP_TILES)) {
// WillObjectBumpIntoWater/Land, CanObjectLeaveTile, WillObjectBumpIntoTile.
const edges = (tile: number) =>
(tile & 0xf0) === 0xb0 ? EDGE_DIRECTIONS[tile & 7] : [];
if (edges(source).includes(direction)) return false;
if (this.assets.permission(target) !== (swimming ? 1 : 0)) return false;
if (edges(target).includes(OPPOSITE[direction])) return false;
}
if (!(movement.flags & NOCLIP_OBJS)) {
const player = this.player,
from = player.motion;
if (
(player.x === x && player.y === y) ||
(from && from.fromX === x && from.fromY === y) ||
this.occupied(x, y, object.id)
)
return false;
}
if (movement.flags & MOVE_ANYWHERE) return true;
// HasObjectReachedMovementLimit: .InitRadius stored each nibble plus
// one, so the tile at home ± (radius + 1) is refused.
const npc = actor.npc!,
rx = ((object.radius & 0x0f) + 1) & 0x0f,
ry = ((object.radius >> 4) + 1) & 0x0f;
if (rx && (x === npc.homeX - rx || x === npc.homeX + rx)) return false;
if (ry && (y === npc.homeY - ry || y === npc.homeY + ry)) return false;
// IsObjectMovingOffEdgeOfScreen: stay within the visible screen.
return (
x >= this.player.x - 4 &&
x <= this.player.x + 5 &&
y >= this.player.y - 4 &&
y <= this.player.y + 4
);
}
/** HandleCmdQueue's CmdQueue_StoneTable, before MapEvents every tick: a
* standing Strength boulder on a pit that is one of the map's warps runs
* the stone table's script for that warp and object, unless a script is
* already running (CallMapScript). */
private checkStoneTables(): boolean {
if (!this.stoneTables.length || this.script) return false;
const r = this.assets.reader;
for (const object of this.map.objects) {
if (
!this.spawned.has(object.id) ||
object.movement !== SPRITEMOVEDATA_STRENGTH_BOULDER
)
continue;
const actor = this.actor(object);
if (
actor.motion ||
!isPit(this.assets.collision(this.map, actor.x, actor.y))
)
continue;
const warp =
this.map.warps.findIndex((w) => w.x === actor.x && w.y === actor.y) + 1;
if (!warp) continue;
for (const table of this.stoneTables)
for (let p = table; r.byte(p) !== 0xff; p += 4)
if (r.byte(p) === warp && r.byte(p + 1) === object.id) {
this.startScript(r.near(p + 2, r.bank(table)), PLAYER);
return true;
}
}
return false;
}
/** _CheckTrainerBattle (home/trainers.asm): the first map trainer, in
* object order, that faces the player in a straight line within its sight
* range and whose event flag is clear starts SeenByTrainerScript. Walls and
* objects do not block sight. Sight ranges (at most 5) never reach beyond
* the object window, so an in-range trainer always has an object struct. */
private checkTrainers(): boolean {
const player = this.player,
r = this.assets.reader,
refused = this.refusedSighting;
if (
refused &&
(refused.map !== this.mapId ||
refused.x !== player.x ||
refused.y !== player.y)
)
this.refusedSighting = undefined;
for (const object of this.map.objects) {
if (object.kind !== OBJECTTYPE_TRAINER || !this.spawned.has(object.id))
continue;
if (this.refusedSighting?.id === object.id) continue;
const actor = this.actor(object);
// FacingPlayerDistance: same row or column, facing the player.
let distance: number, facing: Direction;
if (actor.x === player.x && actor.y !== player.y) {
distance = Math.abs(player.y - actor.y);
facing = player.y > actor.y ? "down" : "up";
} else if (actor.y === player.y && actor.x !== player.x) {
distance = Math.abs(player.x - actor.x);
facing = player.x > actor.x ? "right" : "left";
} else continue;
if (actor.direction !== facing || distance > (object.sightRange ?? 0))
continue;
if (this.state.events.has(r.word(object.script))) continue;
this.startScript(SYMBOLS.seenByTrainerScript, object.id, {
trainer: { header: object.script, distance },
sighted: object.id,
});
return true;
}
return false;
}
/** Scene scripts run while the player is idle (RunSceneScript). */
private checkScene() {
if (!this.sceneCheck || this.busy) return;
this.sceneCheck = false;
const owner = this.assets.sceneOwner(this.mapId);
if (!owner) return;
const script = this.map.sceneScripts[this.state.scenes[owner] ?? 0];
if (script !== undefined) this.startScript(script, PLAYER, { scene: true });
}
// ---------------------------------------------------------------- input
/** One overworld tick. `held` is the direction the player is holding. */
/** Overworld ticks run so far, for presentation such as tile animation. */
townMap = false;
/** The Pokémon a script's stats screen shows (Bill's PC). */
statsScreen: PartyMon | undefined;
/** A letter ReadAnyMail shows, until a button closes it. */
mailView: Mail | undefined;
/** The Pokégear card on screen while the Pokégear is open, including
* during the phone calls it makes. */
pokegear: PokegearView | undefined;
/** The trainer card page on screen, and the frames since it loaded. */
trainerCard: TrainerCardView | undefined;
private cardFrames = 0;
ticks = 0;
tick(held?: Direction) {
this.ticks++;
// GameTimer: two frames per overworld tick, once the game has started.
if (!this.introPending) {
advanceGameTime(this.gameTime);
advanceGameTime(this.gameTime);
}
// StepFunction_ScreenShake counts down once per tick of the map loop.
if (this.shake && !this.battle && --this.shake.ticks <= 0)
this.shake = undefined;
if (this.battle) {
const view = this.battle;
if (
view.message &&
!view.message.prompt &&
!view.prompt &&
--this.battleHold <= 0
)
this.pumpBattle();
return;
}
const script = this.script;
if (script) {
// Pokegear_UpdateClock runs every frame while the clock card shows.
const gear = this.pokegear;
if (gear?.clock) {
const { hours, minutes } = this.clock();
gear.clock = pokegearClock(
this.assets.reader,
this.host(PLAYER).text(),
hours,
minutes,
);
}
const card = this.trainerCard;
if (card) {
this.cardFrames += 2;
refreshTrainerCard(card, this.cardFrames, this.gameTime);
}
const evolution = this.evolution;
if (evolution) {
const frame = evolution.schedule[++evolution.step];
if (frame) this.picture = frame.species;
else this.finishEvolution(false);
return;
}
const dialogue = this.dialogue;
if (dialogue) {
if (
dialogue.holdTicks !== undefined &&
dialogue.index === dialogue.pages.length - 1
) {
if (--dialogue.holdTicks <= 0) {
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.dialogue = undefined;
this.pump();
}
}
return;
}
if (script.effect?.kind === "move") {
// WaitScriptMovement runs in ScriptEvents, before HandleMapObjects:
// a movement whose step_end was read last tick resumes the script
// now, and a new movement then starts moving in this same tick.
if (!script.moving.size && !this.anyMotion() && !script.follow?.pending)
this.pump();
else this.advanceMovement(1);
} else if (isTimed(script.effect)) {
// Pauses and emotes keep the map loop running; applymovement and
// open text freeze other objects, and the Fly animations and the
// Headbutt shake run their own loops.
if (this.flight) this.flight.frames += 2;
else if (this.shakingTree) this.shakingTree.frames += 2;
else this.stepObjects();
if (--script.waitTicks <= 0) {
if (script.effect?.kind === "emote") this.emote = undefined;
this.flight = undefined;
this.shakingTree = undefined;
this.pump();
}
}
return;
}
const player = this.player;
if (!this.dialogue && this.checkStoneTables()) return;
if (player.motion) {
const previous = { x: player.motion.originX, y: player.motion.originY };
// HandleMapObjects: HandleNPCStep precedes _HandlePlayerStep.
this.stepObjects();
this.animate(player);
// PLAYERSTEP_STOP: CheckPlayerState enables PlayerEvents for the next
// tick, which runs the arrival's tile events before reading input.
if (!player.motion) this.arrival = previous;
return;
}
if (this.dialogue) return;
const settle = this.warpSettle;
if (settle) {
this.warpSettle = undefined;
if (settle.script !== undefined) this.startScript(settle.script, PLAYER);
else this.stepObjects();
return;
}
if (this.turnWait) {
// ChangeDirectionScript holds PlayerEvents while StepFunction_Turn
// shows the new facing on the tick after the turn began.
this.stepObjects();
if (
this.turnWait === TURN_WAIT_TICKS &&
this.turnTo &&
!player.fixedFacing
)
player.direction = this.turnTo;
if (--this.turnWait === 0) this.turnTo = undefined;
return;
}
const arrival = this.arrival;
if (arrival) {
// CheckTrainerEvent, then CheckTileEvent: a connection or warp ends
// this tick (HandleMap returns once wMapStatus changes).
this.arrival = undefined;
const map = this.mapId;
try {
this.finishStep(arrival);
} catch (error) {
this.refuse(error);
}
if (this.busy || this.mapId !== map) return;
} else if (this.checkTrainers()) return;
// RunMemScript precedes RunSceneScript.
const memScript = this.memScript;
if (memScript) {
this.memScript = undefined;
this.startScript(memScript.script, PLAYER, {
callerScript: memScript.callerScript,
});
if (this.busy) return;
}
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
const stepped = this.beginStep(held);
if (this.script || this.battle || this.dialogue) return;
this.stepObjects();
if (stepped && this.player.motion) this.animate(this.player);
}
private anyMotion() {
return (
this.player.motion !== undefined ||
[...this.actors.values()].some((actor) => actor.motion)
);
}
interact() {
if (this.battle) return this.battleA();
if (this.dialogue) {
const choice = this.dialogue.choice;
if (choice && this.dialogue.index === this.dialogue.pages.length - 1) {
// The naming screen is answered with text through choose().
if (choice.kind === "pack") this.choosePackEntry();
else if (buttonChoice(choice)) this.choose("a");
else if (choice.kind !== "name") this.choose(choice.selected);
} else this.advanceDialogue();
return;
}
if (this.busy) return;
this.clearNotice();
// CheckTrainerEvent precedes OWPlayerInput in PlayerEvents.
if (this.checkTrainers()) return;
// OWPlayerInput: no button actions while sliding on ice.
if (this.standingOnIce(this.assets.collision(this.map, this.x, this.y)))
return;
const [dx, dy] = VECTORS[this.player.direction];
let x = this.player.x + dx,
y = this.player.y + dy;
const collision = this.assets.collision(this.map, x, y);
// Counters let the player talk across them.
if (collision === 0x90) {
x += dx;
y += dy;
}
const object = this.objects().find(
(object) => object.x === x && object.y === y,
);
let script: number | undefined;
if (object) {
if (object.kind === 1) {
// OBJECTTYPE_ITEMBALL: the script field names raw data (the itemball
// macro's item, quantity), not a script; FindItemInBallScript always
// runs, with hLastTalked left on this object as TryObjectEvent does.
const r = this.assets.reader;
this.startScript(SYMBOLS.findItemInBallScript, object.id, {
itemBall: {
item: r.byte(object.script),
quantity: r.byte(object.script + 1),
},
});
return;
}
if (object.kind === OBJECTTYPE_TRAINER) {
// TalkToTrainer: wSeenTrainerDistance 1, then TalkToTrainerScript.
this.startScript(SYMBOLS.talkToTrainerScript, object.id, {
trainer: { header: object.script, distance: 1 },
});
return;
}
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
}
this.startScript(object.script, object.id);
return;
}
const event = this.map.backgroundEvents.find(
(event) => event.x === x && event.y === y,
);
if (event) {
const r = this.assets.reader;
if (event.kind >= 1 && event.kind <= 4) {
const required = ["up", "down", "right", "left"][event.kind - 1];
if (required !== this.player.direction) return;
script = event.script;
} else if (event.kind === 5 || event.kind === 6) {
// BGEVENT_IFSET / IFNOTSET: an event flag guards the script pointer.
const set = this.state.events.has(r.word(event.script));
if (set !== (event.kind === 5)) return;
script = r.near(event.script + 2, r.bank(event.script));
} else if (event.kind === 0) script = event.script;
else if (event.kind === BGEVENT_ITEM) {
// BGEVENT_ITEM: hiddenitem data (event flag, item) for HiddenItemScript.
const hidden = {
event: r.word(event.script),
item: r.byte(event.script + 2),
};
if (this.state.events.has(hidden.event)) return;
this.startScript(SYMBOLS.hiddenItemScript, PLAYER, {
hiddenItem: hidden,
});
return;
} else {
this.notice = "This interaction is not ported yet.";
return;
}
} else {
// CheckFacingTileForStdScript: TileCollisionStdScripts pairs a
// collision type with a std script index, in any facing direction.
const r = this.assets.reader;
for (let entry = PC.TileCollisionStdScripts; ; entry += 3) {
const type = r.byte(entry);
if (type === 0xff) break;
if (type !== collision) continue;
script = r.far(SYMBOLS.standardScripts + r.word(entry + 1) * 3);
break;
}
}
if (script === undefined) {
const [fx, fy] = VECTORS[this.player.direction];
if (this.tryTileCollisionEvent(this.x + fx, this.y + fy, collision))
return;
this.notice = "Nothing to interact with here.";
return;
}
this.startScript(script, PLAYER);
}
// ---------------------------------------------------------- field moves
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
private partyMove(move: number): number {
return this.state.party.findIndex(
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
);
}
/** TryTileCollisionEvent after CheckFacingTileForStdScript: Cut trees,
* whirlpools, waterfalls, Headbutt trees, then water for Surf. */
private tryTileCollisionEvent(x: number, y: number, tile: number): boolean {
const flags = this.state.flags;
if (tile === OW.COLL_CUT_TREE || tile === OW.COLL_CUT_TREE_1A) {
// TryCutOW
const mon = this.partyMove(OW.CUT);
if (mon < 0 || !flags.has(OW.ENGINE_HIVEBADGE))
this.startScript(OVERWORLD.CantCutScript, PLAYER);
else
this.startScript(OVERWORLD.AskCutScript, PLAYER, {
fieldMove: { mon, block: this.fieldMoveBlock(x, y, tile, "cut") },
});
return true;
}
if (isWhirlpool(tile)) {
// TryWhirlpoolOW with TryWhirlpoolMenu
const mon = this.partyMove(OW.WHIRLPOOL),
block = this.fieldMoveBlock(x, y, tile, "whirlpool");
if (mon < 0 || !flags.has(OW.ENGINE_GLACIERBADGE) || !block)
this.startScript(OVERWORLD.Script_MightyWhirlpool, PLAYER);
else
this.startScript(OVERWORLD.Script_AskWhirlpoolOW, PLAYER, {
fieldMove: { mon, block },
});
return true;
}
if (tile === OW.COLL_WATERFALL || tile === OW.COLL_CURRENT_DOWN) {
// TryWaterfallOW: CheckMapCanWaterfall needs the player facing up.
const mon = this.partyMove(OW.WATERFALL);
if (
mon < 0 ||
!flags.has(OW.ENGINE_RISINGBADGE) ||
this.player.direction !== "up"
)
this.startScript(OVERWORLD.Script_CantDoWaterfall, PLAYER);
else
this.startScript(OVERWORLD.Script_AskWaterfall, PLAYER, {
fieldMove: { mon },
});
return true;
}
if (tile === OW.COLL_HEADBUTT_TREE || tile === OW.COLL_HEADBUTT_TREE_1D) {
// TryHeadbuttOW: without Headbutt nothing happens.
const mon = this.partyMove(OW.HEADBUTT);
if (mon < 0) return false;
this.startScript(OVERWORLD.AskHeadbuttScript, PLAYER, {
fieldMove: { mon },
});
return true;
}
return this.trySurfOW(tile);
}
/** TrySurfOW: facing water, with the Fog Badge, a Pokémon that knows Surf
* and no forced cycling; AskSurfScript then asks and starts the surf. */
private trySurfOW(tile: number): boolean {
const state = this.state;
if (
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA
)
return false;
if (this.assets.permission(tile) !== OW.WATER_TILE) return false;
const current = this.assets.collision(this.map, this.x, this.y);
if (this.sideBlocked(current, tile, this.player.direction)) return false;
if (!state.flags.has(OW.ENGINE_FOGBADGE)) return false;
const mon = this.partyMove(OW.SURF);
if (mon < 0 || state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) return false;
// GetSurfType: Pikachu surfs on its own sprite.
const surfState =
state.party[mon].species === OW.PIKACHU
? OW.PLAYER_SURF_PIKA
: OW.PLAYER_SURF;
this.startScript(OVERWORLD.AskSurfScript, PLAYER, {
fieldMove: { mon, surfState },
});
return true;
}
/** CheckMapForSomethingToCut and TryWhirlpoolMenu: the block covering the
* facing tile, looked up by tileset in CutTreeBlockPointers or
* WhirlpoolBlockPointers (CheckOverworldTileArrays). */
private fieldMoveBlock(
x: number,
y: number,
tile: number,
kind: "cut" | "whirlpool",
): { x: number; y: number; block: number } | undefined {
const map = this.map;
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return undefined;
if (kind === "cut" && ![0x12, 0x1a, 0x10, 0x18, 0x14, 0x1c].includes(tile))
return undefined;
const r = this.assets.reader,
facing = this.assets.block(map, x, y);
const table =
kind === "cut"
? OVERWORLD.CutTreeBlockPointers
: OVERWORLD.WhirlpoolBlockPointers;
for (let entry = table; r.byte(entry) !== 0xff; entry += 3) {
if (r.byte(entry) !== map.tileset) continue;
for (let p = r.near(entry + 1, r.bank(table)); r.byte(p) !== 0xff; p += 3)
if (r.byte(p) === facing) return { x, y, block: r.byte(p + 1) };
return undefined;
}
return undefined;
}
/** The item Bicycle (BikeFunction): only outdoors, in caves or gates, on
* land; ALWAYS_ON_BIKE keeps the player riding. Returns false when the
* bike cannot be used here. */
useBicycle(): boolean {
if (
this.busy ||
!this.state.bag.keyItems.some((slot) => slot === OW.BICYCLE)
)
return false;
const script = this.bicycleScript(this.state);
if (script === undefined) return false;
this.startScript(script, PLAYER, { usedItem: OW.BICYCLE });
return true;
}
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
* .CheckEnvironment or the player's state refuses the Bicycle. */
private bicycleScript(state: ScriptState, select = false): number | undefined {
const map = this.map;
const outdoors =
map.environment === OW.TOWN || map.environment === OW.ROUTE;
const allowed =
(outdoors ||
map.environment === OW.CAVE ||
map.environment === OW.GATE) &&
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
OW.LAND_TILE;
if (!allowed) return undefined;
// .CheckIfRegistered: Select uses the scripts without a message.
if (state.playerState === OW.PLAYER_NORMAL)
return select
? OVERWORLD.Script_GetOnBike_Register
: OVERWORLD.Script_GetOnBike;
if (state.playerState === OW.PLAYER_BIKE)
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: select
? OVERWORLD.Script_GetOffBike_Register
: OVERWORLD.Script_GetOffBike;
return undefined;
}
/** GetPlayerSprite: Chris's or Kris's sprite for wPlayerState. */
private playerSprite(): number {
const r = this.assets.reader;
const table =
this.avatar === "kris"
? OVERWORLD.KrisStateSprites
: OVERWORLD.ChrisStateSprites;
for (let p = table; r.byte(p) !== 0xff; p += 2)
if (r.byte(p) === this.liveState.playerState) return r.byte(p + 1);
return r.byte(table + 1);
}
/** CheckUpdatePlayerSprite after a warp: forced cycling, surfing when the
* player arrives on water, and no surfing or biking where they cannot. */
private checkUpdatePlayerSprite(state: ScriptState) {
const map = this.map;
if (state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) {
state.playerState = OW.PLAYER_BIKE;
return;
}
const surfing =
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA;
if (
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
OW.WATER_TILE
) {
if (!surfing) state.playerState = OW.PLAYER_SURF;
return;
}
if (surfing) state.playerState = OW.PLAYER_NORMAL;
else if (
state.playerState === OW.PLAYER_BIKE &&
[OW.INDOOR, OW.ENVIRONMENT_5, OW.DUNGEON].includes(
map.environment as never,
)
)
state.playerState = OW.PLAYER_NORMAL;
}
/** EvolutionAnimation ends: the new species, or the old one if B
* stopped it. */
private finishEvolution(stopped: boolean) {
const evolution = this.evolution!;
this.evolution = undefined;
this.picture = stopped ? evolution.from : evolution.to;
this.pump(stopped ? 1 : 0);
}
/** B, or A on a page without a choice. */
advanceDialogue() {
if (this.battle) return this.battleB();
const evolution = this.evolution;
if (evolution) {
// .WaitFrames_CheckPressedB: only between flashes, and never for a
// forced (evolution stone) evolution.
if (evolution.cancelable && evolution.schedule[evolution.step]?.cancel)
this.finishEvolution(true);
return;
}
const dialogue = this.dialogue;
if (!dialogue || !this.script) return;
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.holdTicks !== undefined) return;
if (last && dialogue.choice) {
// B answers NO on a yes/no box, quits the mart menu (matching the
// ROM's VerticalMenu carry) or backs out of a mart list/quantity
// screen; other choices need their own answer.
const choice = dialogue.choice;
if (choice.kind === "yesno") this.choose(0);
else if (choice.kind === "martmenu") this.choose(2);
else if (buttonChoice(choice)) this.choose("b");
else if (
choice.kind === "martlist" ||
choice.kind === "martquantity" ||
choice.kind === "menu" ||
choice.kind === "pack"
)
this.choose(-1);
return;
}
if (!last) {
dialogue.index++;
return;
}
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.dialogue = undefined;
this.pump();
}
/** Change the highlighted answer (up/down on the original menus). */
moveCursor(direction: Direction) {
if (this.battle?.prompt?.kind === "yesno") {
if (direction === "up") this.battle.selected = 1;
if (direction === "down") this.battle.selected = 0;
return;
}
const choice = this.dialogue?.choice;
if (!choice || this.dialogue!.index !== this.dialogue!.pages.length - 1)
return;
if (choice.kind === "name") return;
if (buttonChoice(choice)) return this.choose(direction);
if (choice.kind === "yesno") {
if (direction === "up") choice.selected = 1;
if (direction === "down") choice.selected = 0;
} else if (choice.kind === "martmenu") {
if (direction === "up") choice.selected = (choice.selected + 2) % 3;
if (direction === "down") choice.selected = (choice.selected + 1) % 3;
} else if (choice.kind === "menu") {
if ((direction === "left" || direction === "right") && choice.sides)
return this.choose(direction);
if (direction === "up")
choice.selected = Math.max(0, choice.selected - 1);
if (direction === "down")
choice.selected = Math.min(
choice.items.length - 1,
choice.selected + 1,
);
} else if (choice.kind === "clock") {
const length = choice.labels.length;
if (direction === "up") choice.selected = (choice.selected + 1) % length;
if (direction === "down")
choice.selected = (choice.selected + length - 1) % length;
} else if (choice.kind === "pack") {
// Pack_InterpretJoypad: left and right change pockets, except while
// an item is being moved (.switching_item).
const pockets = choice.pockets.length;
if (direction === "left" || direction === "right") {
if (choice.switching !== undefined) return;
choice.pocket =
(choice.pocket + (direction === "left" ? pockets - 1 : 1)) % pockets;
choice.selected = 0;
return;
}
const length = choice.pockets[choice.pocket].entries.length;
if (!length) return;
if (direction === "up") choice.selected = Math.max(0, choice.selected - 1);
if (direction === "down")
choice.selected = Math.min(length - 1, choice.selected + 1);
} else if (choice.kind === "martlist") {
const length = choice.entries.length;
if (!length) return;
if (direction === "up")
choice.selected = (choice.selected + length - 1) % length;
if (direction === "down")
choice.selected = (choice.selected + 1) % length;
} else if (choice.kind === "martquantity") {
// BuySellToss_InterpretJoypad: Up/Down step by 1 and wrap; Left/Right
// step by 10 and clamp to the 1-max range.
if (direction === "down")
choice.selected =
choice.selected > 1 ? choice.selected - 1 : choice.max;
if (direction === "up")
choice.selected =
choice.selected < choice.max ? choice.selected + 1 : 1;
if (direction === "left")
choice.selected = Math.max(1, choice.selected - 10);
if (direction === "right")
choice.selected = Math.min(choice.max, choice.selected + 10);
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
}
/** Choose the highlighted pack entry, if its pocket has one, with A or
* (`select`) with the Select button. */
choosePackEntry(pocket?: number, index?: number, select = false) {
const choice = this.dialogue?.choice;
if (choice?.kind !== "pack") return;
pocket ??= choice.pocket;
index ??= choice.selected;
if (!choice.pockets[pocket]?.entries[index]) return;
this.choose(select ? `select:${pocket * 256 + index}` : pocket * 256 + index);
}
/** Answer the current choice: 1/0 for yes/no, a weekday index, or a
* name ("" keeps the current one). */
choose(answer: number | string) {
const dialogue = this.dialogue;
if (!dialogue?.choice || !this.script) return;
// The naming screen only offers its own characters; keep it open.
if (
dialogue.choice.kind === "name" &&
(dialogue.choice.mail
? !validMailMessage(answer)
: typeof answer !== "string" ||
answer.length > dialogue.choice.max ||
(answer !== "" && !NICKNAME_PATTERN.test(answer)))
)
return;
if (
(dialogue.choice.kind === "pokegear" &&
!POKEGEAR_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "mombank" &&
!MOM_BANK_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "trainercard" &&
!TRAINER_CARD_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "flymap" &&
!FLY_MAP_BUTTONS.includes(answer as never))
)
return;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.dialogue = undefined;
this.pump(answer);
}
// ---------------------------------------------------------------- saves
save(): NativeSave {
// Incomplete interactions are replayed after reload; no partial quest writes.
// The play time keeps counting through scenes, like GameTimer.
if (this.script)
return {
...structuredClone(this.script.committed),
gameTime: { ...this.gameTime },
};
const state = this.state;
return {
format: "crystal-native",
version: 3,
map: this.mapId,
// A partial step, or one whose tile events have not run yet, is
// replayed after reload.
x: this.player.motion?.originX ?? this.arrival?.x ?? this.player.x,
y: this.player.motion?.originY ?? this.arrival?.y ?? this.player.y,
direction: this.player.direction,
name: this.name,
avatar: this.avatar,
tilesWalked: this.tilesWalked,
gameTime: { ...this.gameTime },
events: [...state.events].sort((a, b) => a - b),
flags: [...state.flags].sort((a, b) => a - b),
variables: { ...state.variables },
scenes: { ...state.scenes },
phone: [...state.phone],
specialPhoneCall: state.specialPhoneCall,
rivalName: state.rivalName,
clock: { ...state.clock },
intro: this.introPending,
objects: [...this.actors]
.filter(([, actor]) => actor.moved)
.map(([id, actor]) => ({
id,
x: actor.x,
y: actor.y,
direction: actor.direction,
}))
.sort((a, b) => a.id - b.id),
playerId: this.playerId,
party: structuredClone(state.party),
bag: structuredClone(state.bag),
storage: copyStorage(state.storage),
pcItems: structuredClone(state.pcItems),
pokedex: {
seen: [...state.pokedex.seen].sort((a, b) => a - b),
caught: [...state.pokedex.caught].sort((a, b) => a - b),
},
money: state.money,
momsMoney: state.momsMoney,
momItem: state.momItem,
momItemTriggerBalance: state.momItemTriggerBalance,
decorations: { ...state.decorations },
fruitTrees: [...state.fruitTrees].sort((a, b) => a - b),
dailyReset: state.dailyReset,
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 },
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
options: { ...state.options },
};
}
restore(value: unknown) {
const save = migrateSave(value, () => (this.random() << 8) | this.random());
const integer = (
value: unknown,
min: number,
max: number,
): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
const species = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.every((id) => integer(id, 1, 251)) &&
new Set(value).size === value.length;
const ids = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.length <= 2048 &&
value.every((value) => integer(value, 0, 0xffff)) &&
new Set(value).size === value.length;
const record = (value: unknown, key: RegExp, max: number) =>
Boolean(value) &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.entries(value as object).every(
([id, value]) => key.test(id) && integer(value, 0, max),
);
if (
!save ||
!this.assets.isPorted(save.map) ||
!integer(save.x, 0, 127) ||
!integer(save.y, 0, 127) ||
!DIRECTION_ORDER.includes(save.direction) ||
typeof save.name !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!validGameTime(save.gameTime) ||
!ids(save.events) ||
!ids(save.flags) ||
!new DecorationData(this.assets.reader, this.assets.pokemon).valid(
save.decorations,
new Set(save.events),
) ||
!record(save.variables, /^(?:[1-9]\d{0,2}|0)$/, 255) ||
Object.keys(save.variables).some((id) => Number(id) > 255) ||
!record(save.scenes, /^\d{1,2}:\d{1,3}$/, 255) ||
Object.keys(save.scenes).some(
(id) => this.assets.sceneOwner(id) !== id,
) ||
!integer(save.specialPhoneCall, 0, 0xffff) ||
typeof save.rivalName !== "string" ||
save.rivalName.length > 7 ||
!NICKNAME_PATTERN.test(save.rivalName) ||
!Array.isArray(save.phone) ||
save.phone.length > 10 ||
!save.phone.every((id) => integer(id, 0, 255)) ||
new Set(save.phone).size !== save.phone.length ||
!save.clock ||
!integer(save.clock.minuteOffset, -720, 719) ||
!integer(save.clock.weekdayOffset, 0, 6) ||
typeof save.intro !== "boolean" ||
typeof save.clock.dst !== "boolean" ||
!Array.isArray(save.objects) ||
save.objects.length > 16 ||
!integer(save.playerId, 0, 0xffff) ||
!Array.isArray(save.party) ||
save.party.length > 6 ||
!save.party.every(
(mon) =>
validPartyMon(this.assets.pokemon, mon) &&
// sPartyMail: a message exactly while MAIL is held.
(this.assets.pokemon.itemIsMail(mon.item)
? validMail(this.assets.pokemon, mon.mail)
: !Object.hasOwn(mon, "mail")),
) ||
!Array.isArray(save.mailbox) ||
save.mailbox.length > MAILBOX_CAPACITY ||
!save.mailbox.every((mail) => validMail(this.assets.pokemon, mail)) ||
!validBag(this.assets.pokemon, save.bag) ||
!validStorage(save.storage) ||
!validPCItems(this.assets.pokemon, save.pcItems) ||
!save.pokedex ||
!species(save.pokedex.seen) ||
!species(save.pokedex.caught) ||
!save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) ||
!integer(save.money, 0, C.MAX_MONEY) ||
!integer(save.momsMoney, 0, C.MAX_MONEY) ||
!integer(save.momItem, 0, 10) ||
!integer(save.momItemTriggerBalance, 0, 0xffffff) ||
!Array.isArray(save.fruitTrees) ||
!save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) ||
new Set(save.fruitTrees).size !== save.fruitTrees.length ||
!(save.dailyReset === null || integer(save.dailyReset, 0, 0xffffff)) ||
(save.lastSpawnMap !== null &&
(typeof save.lastSpawnMap !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.lastSpawnMap))) ||
![
OW.PLAYER_NORMAL,
OW.PLAYER_BIKE,
OW.PLAYER_SKATE,
OW.PLAYER_SURF,
OW.PLAYER_SURF_PIKA,
].includes(save.playerState as never) ||
[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 ||
!integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) ||
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.blueCardBalance, 0, 255) ||
!validOptions(save.options)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
);
}
const map = this.assets.map(save.map);
for (const object of save.objects) {
if (
!object ||
!map.objects.some((candidate) => candidate.id === object.id) ||
!integer(object.x, 0, 127) ||
!integer(object.y, 0, 127) ||
!DIRECTION_ORDER.includes(object.direction)
)
throw new Error(
"Invalid native save. A saved object is not on this map.",
);
}
// A surfer is saved on water; everyone else on land.
const surfing =
save.playerState === OW.PLAYER_SURF ||
save.playerState === OW.PLAYER_SURF_PIKA;
if (
save.x >= map.width ||
save.y >= map.height ||
this.assets.permission(this.assets.collision(map, save.x, save.y)) !==
(surfing ? OW.WATER_TILE : OW.LAND_TILE)
)
throw new Error("The saved position is outside the walkable map.");
const previous = {
mapId: this.mapId,
player: this.player,
state: this.state,
actors: this.actors,
};
// Validate completely, including the continue callbacks, before
// changing live state.
this.state = {
decorations: { ...save.decorations },
events: new Set(save.events),
flags: new Set(save.flags),
variables: { ...save.variables },
scenes: { ...save.scenes },
phone: [...save.phone],
clock: { ...save.clock },
party: structuredClone(save.party),
bag: structuredClone(save.bag),
storage: copyStorage(save.storage),
pcItems: structuredClone(save.pcItems),
pokedex: {
seen: new Set(save.pokedex.seen),
caught: new Set(save.pokedex.caught),
},
money: save.money,
momsMoney: save.momsMoney,
momItem: save.momItem,
momItemTriggerBalance: save.momItemTriggerBalance,
fruitTrees: new Set(save.fruitTrees),
dailyReset: save.dailyReset,
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 },
mailbox: save.mailbox.map(copyMail),
repel: save.repel,
coins: save.coins,
blueCardBalance: save.blueCardBalance,
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
};
let entry;
try {
entry = this.prepareMap(save.map, "continue");
} catch (error) {
this.state = previous.state;
throw error;
}
this.script = undefined;
this.applyMap(entry);
for (const object of save.objects) {
const source = map.objects.find(
(candidate) => candidate.id === object.id,
)!;
const actor = this.actor(source);
actor.x = object.x;
actor.y = object.y;
actor.direction = object.direction;
actor.moved = true;
// Saved objects were placed by scripts; they roam from there.
if (actor.npc)
Object.assign(actor.npc, { homeX: object.x, homeY: object.y });
}
this.player = {
x: save.x,
y: save.y,
direction: save.direction,
stepFrame: 0,
hidden: false,
fixedFacing: false,
moved: false,
};
// A continued game starts standing (RefreshPlayerSprite).
this.arrival = undefined;
this.warpSettle = undefined;
this.turning = 0;
this.turnTo = undefined;
this.turnWait = 0;
this.bumping = false;
this.name = save.name;
this.avatar = save.avatar;
this.tilesWalked = save.tilesWalked;
this.gameTime = { ...save.gameTime };
this.playerId = save.playerId;
this.townMap = false;
this.statsScreen = undefined;
this.mailView = undefined;
this.pokegear = undefined;
this.trainerCard = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
this.portrait = undefined;
this.evolution = undefined;
this.battle = undefined;
this.battleRun = undefined;
this.memScript = undefined;
this.menuCursors = { start: 0, party: 0, pocket: 0 };
this.clearNotice();
this.introPending = false;
// A save made during the introduction starts it again.
if (save.intro) this.beginIntro();
}
}
/** Upgrade older native saves with new-game defaults for later fields.
* Version 1 predates scenes, the phone, the game clock and saved object
* positions. Versions 1 and 2 predate the player ID, party, bag and Pokédex;
* a missing player ID is drawn as a new game would draw it. */
export function migrateSave(
value: unknown,
newPlayerId: () => number,
): NativeSave | undefined {
const save = value as Record<string, unknown> | undefined;
if (!save || typeof save !== "object" || save.format !== "crystal-native")
return undefined;
if (save.version === 3) {
// Early version-3 backups predate battle rewards, the money field, daily
// state and the recorded whiteout spawn. Only absence migrates; an
// explicitly invalid value must still be rejected.
return {
...save,
...(!Object.hasOwn(save, "decorations") && {
decorations: initialDecorations(),
}),
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "momsMoney") && { momsMoney: 0 }),
...(!Object.hasOwn(save, "momItem") && {
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
}),
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
...(!Object.hasOwn(save, "specialPhoneCall") && { specialPhoneCall: 0 }),
...(!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,
}),
...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 },
}),
...(!Object.hasOwn(save, "mailbox") && { mailbox: [] }),
...(!Object.hasOwn(save, "repel") && {
repel: 0,
coins: 0,
blueCardBalance: 0,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
typeof save.clock === "object" &&
!Object.hasOwn(save.clock, "minuteOffset") && {
clock: { minuteOffset: 0, ...(save.clock as object) },
}),
} as unknown as NativeSave;
}
if (save.version !== 1 && save.version !== 2) return undefined;
const previous =
save.version === 1
? {
...save,
scenes: {},
phone: [],
clock: { minuteOffset: 0, weekdayOffset: 0, dst: false },
objects: [],
}
: save;
return {
...previous,
version: 3,
clock: { minuteOffset: 0, ...(previous.clock as Record<string, unknown>) },
decorations: initialDecorations(),
playerId: newPlayerId(),
party: [],
bag: emptyBag(),
storage: emptyStorage(),
pcItems: [],
pokedex: { seen: [], caught: [] },
lastSpawnMap: null,
specialPhoneCall: 0,
rivalName: "???",
money: C.START_MONEY,
momsMoney: 0,
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
fruitTrees: [],
dailyReset: null,
playerState: 0,
backupWarp: null,
digWarp: null,
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
mailbox: [],
repel: 0,
coins: 0,
blueCardBalance: 0,
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),
} as unknown as NativeSave;
}