import { DecorationData, initialDecorations, type Decorations, } from "./decorations"; import { CrystalAssets } from "../rom/assets"; import { GameSound } from "../audio/sound"; import { SOUND } from "../audio/constants"; import { battleMusic, hpBarPixels, inKanto, victoryMusic } from "../audio/battle-music"; import { TrainerData } from "../rom/trainers"; import { ENGINE_FLAGS, EVENTS, INITIAL_POSITION, JOHTO, NUM_FRUIT_TREES, OVERWORLD, PC, SAVED_WRAM, SYMBOLS, type SavedByte, PHONE, } 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, type DayTimerName, pageCues, type SoundCue, landmarkName, } from "../scripts/interpreter"; import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage"; import { copyBattleTowerState, emptyBattleTowerState, validBattleTower, type BattleTowerState, } from "../scripts/battle-tower"; import { IntroRun } from "../scripts/intro"; import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map"; import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen"; import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers"; import { checkEncounterRoamMon, emptyRoamMons, jumpRoamMons, updateRoamMons, validRoamMon, } from "./roam-mons"; import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex"; import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas"; 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 { NUM_TMS_HMS } from "../rom/pokemon"; import { copyStorage, emptyStorage, validPCItems, validStorage, type PokemonStorage, } from "../party/storage"; import { NICKNAME_PATTERN, PARTY_LENGTH, caughtData, validPartyMon, type PartyMon, } from "../party/pokemon"; import { defaultTextContext, readString } from "../scripts/text"; import { Battle, type BattleAction, type BattleEffect, type BattleInput, type BattleOutcome, type RoamMon, type BattleSound, } from "../battle/battle"; import { C } from "../battle/constants"; import type { SlotSummary } from "../../storage/slots"; import { DIRECTION_ORDER, VECTORS, type Direction, type GameMap, type MapObject, type Warp, } from "./types"; /** The day countdowns a save may hold. */ const DAY_TIMERS: readonly DayTimerName[] = ["luckyNumber"]; 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; scenes: Record; 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. * `hidden` marks an object whose flags would show it but that a script * made disappear (one without an event flag); `shown`, one that appeared * although its flag is set. Continue keeps the object structs as saved. */ objects: { id: number; x: number; y: number; direction: Direction; hidden?: true; shown?: true; }[]; /** 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; /** STATUSFLAGS_HALL_OF_FAME_F. */ hallOfFame: 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; /** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */ tradeFlags: number; /** Saved WRAM bytes scripts use directly, by PRET label (SAVED_WRAM). */ memory: Partial>; /** wUnownDex and wFirstUnownSeen. */ unownDex: number[]; firstUnownSeen: number; /** RTC day countdowns by name, and the Lucky Number show's number. */ timers: Partial>; luckyNumber: { id: number; day: number }; /** The Magikarp length record and its holder. */ magikarpRecord: MagikarpRecord; /** The Battle Tower's SRAM and its scripts' work RAM. */ battleTower: BattleTowerState; /** wRoamMon1/2/3 (Raikou, Entei, Suicune). */ roamMons: RoamMon[]; /** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or * JumpRoamMons call. Null before the first one (see ScriptState). */ roamMonsMap: string | null; /** The option bytes, saved with the game as sOptions is. */ options: GameOptions; /** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline, * while a Bug-Catching Contest runs; absent otherwise. */ bugContest?: { parkBalls: number; mon: PartyMon | null }; bugContestEnd?: number; /** The party slots a running contest hid (see ScriptState). */ contestLeftParty?: PartyMon[]; } /** 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). */ /** A screen that runs every video frame, reading the buttons itself: the * Game Corner's games and the Pokégear's radio card. */ export function realtimeChoice(choice: Choice | undefined) { return ( (choice?.kind === "screen" && Boolean(choice.realtime)) || (choice?.kind === "pokegear" && Boolean(choice.view.radio)) || choice?.kind === "mapradio" ); } /** Choices answered through the menu routines that click (MenuClickSound). */ const CLICKING_CHOICES = new Set([ "yesno", "weekday", "menu", "martmenu", "martlist", "martquantity", "list", "pack", ]); /** PHONE_CONTACT_SIZE: a PhoneContacts entry. */ const PHONE_CONTACT_SIZE = 12; /** Which map setup script loads a map, for its music. */ type MapSetup = "warp" | "door" | "connection" | "continue" | "none"; 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; /** An appear or disappear this visit: its visibility, not its flag, * is what a save keeps. */ toggled?: boolean; /** A map object sprite a script set (LoadOpponentTrainerAndPokemonWith- * OTSprite) until the map loads again. */ sprite?: number; /** 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; balance?: string } /** A house radio (MapRadio) playing in the text box; A or B stops it. */ | { kind: "mapradio"; selected: 0 } | { 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; /** A money or coin box shown with the menu. */ balance?: string; } /** 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 } /** A Pokédex screen, answered with the button pressed. */ | { kind: "pokedex"; view: PokedexView; 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 } /** A tile screen (a minigame), answered with the button pressed. A * `realtime` screen (the Game Corner) advances on its own every tick * instead of waiting for an answer; see `bufferScreenButton`. */ | { kind: "screen"; screen: TileScreen; selected: 0; realtime?: boolean } /** 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 === "pokedex" || choice.kind === "options" || choice.kind === "flymap" || choice.kind === "screen" || choice.kind === "mapradio"; 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" | "contestswitch" | "forget" | "stats" | "name" | "party" | "move"; } >; /** Yes/No cursor, as on the original boxes. */ selected: 0 | 1; /** wOtherTrainerClass, for PlayVictoryMusic; absent in a wild battle. */ trainerClass?: number; } /** 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; spawned: Set; 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; /** 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; 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; /** wCurCaller, which LoadCallerScript set for a queued call. */ caller?: number; } const OPPOSITE: Record = { 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> = { 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 = { down: "left", up: "right", left: "up", right: "down", }; const SPIN_COUNTERCLOCKWISE_NEXT: Record = { 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; 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(); 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(); 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, dex: 0 }; private seerMemory = { caughtLevel: 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; /** Buttons pressed while a realtime screen (the Game Corner) is open, * consumed one per screen frame instead of answering it immediately, as * SlotsLoop and CardFlip's MasterLoop poll the joypad once per their own * video frame. */ private screenInput: string[] = []; 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.sound = new GameSound(assets.reader); 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, "none"); this.initCallReceiveDelay(); } /** A new world on the same ROM, clock and random source. */ fresh() { return new GameWorld(this.assets, this.now, this.random); } 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; } /** The Map tab's region atlas, from the live state. */ atlas(region?: AtlasRegion): AtlasView { const state = this.liveState; let landmark = this.map.landmark; if (landmark === OW.LANDMARK_SPECIAL && state.backupWarp) landmark = this.assets.map(state.backupWarp.map).landmark; return regionAtlas( { reader: this.assets.reader, text: () => this.host(PLAYER).text(), landmark, visited: (spawn) => { const flag = this.assets.spawnEngineFlag(spawn); return flag !== undefined && state.flags.has(flag); }, open: this.assets.portedLandmarks(), }, region, ); } /** What the player is waiting for: a scene, a step, or nothing. */ get activity(): "scene" | "walking" | "idle" { return this.script ? "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 { const override = this.actors?.get(object.id)?.sprite; if (override !== undefined) return override; 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; // .BumpSound, unless a sound effect already plays. if (!this.sound.sfxPlaying) this.sound.playSFX(SOUND.SFX_BUMP); } 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) { // .ExitWater: GetOutOfWater, then PlayMapMusic. this.state.playerState = OW.PLAYER_NORMAL; this.sound.playMapMusic(this.mapMusicId()); } 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.sound.playSFX(SOUND.SFX_JUMP_OVER_LEDGE); 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; // WarpToNewMapScript's warpsound: GetWarpSFX for the tile left. const left = this.assets.collision(this.map, this.player.x, this.player.y); // A pit's FallIntoMapScript plays its own sounds on arrival. if (!isPit(left)) this.sound.playSFX( left === OW.COLL_DOOR ? SOUND.SFX_ENTER_DOOR : left === OW.COLL_WARP_PANEL ? SOUND.SFX_WARP_TO : SOUND.SFX_EXIT_BUILDING, ); 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, "door"); // WarpToNewMapScript's MAPSETUP_DOOR and FallIntoMapScript's // MAPSETUP_FALL both fall through into MAPSETUP_TRAIN's own // mapsetup UpdateRoamMons. this.applyRoamMons(this.state, destination.entry.id, false); // 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, "warp"); 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, "connection"); // MapSetupScript_Connection's own mapsetup UpdateRoamMons. this.applyRoamMons(this.state, target.map.id, false); 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), caller: r.byte(entry + 2), }); 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; if (state.bugContest) return this.bugContestEncounter(previous, collision); 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: { species: chosen.species, level: chosen.level }, roaming: Boolean(chosen.roamer), }); // The step replays after a reload, so it keeps its Repel step. if (state.repel && this.script) this.script.committed.repel++; } /** _TryWildEncounter_BugContest: 40% in super tall grass and 20% * elsewhere (halved by a Cleanse Tag; no radio music changes it yet), * a ContestMons pick, the Repel check, then * BugCatchingContestBattleScript. */ private bugContestEncounter(previous: { x: number; y: number }, collision: number) { const state = this.state; let rate = Math.floor( ((collision === OW.COLL_LONG_GRASS || collision === OW.COLL_LONG_GRASS_1C ? 40 : 20) * 255) / 100, ); if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1; if (!(this.random() < rate)) return; // ChooseWildEncounter_BugContest: 0-99, then percentages in ContestMons. let roll: number; do roll = this.random(); while (roll >= 200); roll >>= 1; const r = this.assets.reader; let entry = OVERWORLD.ContestMons; while (roll >= r.byte(entry)) { roll -= r.byte(entry); entry += 4; } const species = r.byte(entry + 1), min = r.byte(entry + 2), span = r.byte(entry + 3) - min; const level = span ? min + (this.random() % (span + 1)) : min; const lead = state.party.find((mon) => mon.hp > 0); if (!lead || (state.repel && level < lead.level)) return; this.startScript(OVERWORLD.BugCatchingContestBattleScript, PLAYER, { committedPosition: previous, wild: { species, level }, }); 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(); // _SwarmWildmonCheck: the swarm table first, on the swarm's map. const swarm = this.swarmMap(state); const grass = water ? undefined : (swarm && this.assets.grassWildMons(this.mapId, kanto, true)) || this.assets.grassWildMons(this.mapId, kanto); const sea = water ? (swarm && this.assets.waterWildMons(this.mapId, kanto, true)) || 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 }; } /** _SwarmWildmonCheck's map test: Dunsparce's swarm (when on, only its * map counts), else Yanma's, whose maps a trainer's call recorded. */ private swarmMap(state: ScriptState) { const memory = state.memory; const at = (group?: number, number?: number) => `${group ?? 0}:${number ?? 0}` === this.mapId; if (state.flags.has(ENGINE_FLAGS.dunsparceSwarm) && at(memory.wDunsparceMapGroup, memory.wDunsparceMapNumber)) return true; return ( state.flags.has(ENGINE_FLAGS.yanmaSwarm) && at(memory.wYanmaMapGroup, memory.wYanmaMapNumber) ); } /** 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. * CheckEncounterRoamMon runs first (never on water) and, when a roaming * beast is standing on this map, can hijack the whole pick. */ private chooseWildEncounter({ water, grass, sea, }: ReturnType): { species: number; level: number; roamer?: RoamMon } | undefined { if (!grass && !sea) return undefined; if (!water) { const roamer = checkEncounterRoamMon(this.state.roamMons, this.mapId, () => this.random(), ); if (roamer) return { species: roamer.species, level: roamer.level, roamer }; } 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; no Unown before a letter set is unlocked. if (!chosen.species || chosen.species > 251) return undefined; if ( chosen.species === C.UNOWN && !Array.from({ length: 8 }, (_, bit) => OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit).some( (flag) => this.state.flags.has(flag), ) ) return undefined; return chosen; } /** UpdateRoamMons (`jump` false) or JumpRoamMons (`jump` true), then * _BackUpMapIndices: `currentMap` becomes `roamMonsMap` for the next * call, regardless of whether any roaming Pokémon actually moved. */ private applyRoamMons( state: ScriptState, currentMap: string, jump: boolean, ) { const r = this.assets.reader, random = () => this.random(); state.roamMons = jump ? jumpRoamMons(r, state.roamMons, currentMap, random) : updateRoamMons(r, state.roamMons, state.roamMonsMap, currentMap, random); state.roamMonsMap = currentMap; } /** 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(); 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, 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; map: GameMap; stoneTables: number[]; }, setup: MapSetup, ) { 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 this.mapSetupMusic(setup); } /** Sounds the script started after the text it is showing. */ private soundCues: SoundCue[] = []; /** The last page a script showed, kept on screen during sound waits. */ private lastPage?: string; /** The page left in the text box while a script waits for its sound * (WaitSFX) or a delay: presentation only, it takes no buttons. */ waitingPage: string | undefined = undefined; /** Play the sounds whose page has come up. */ private playCues(page: number) { const due = this.soundCues.filter((entry) => entry.page <= page); if (!due.length) return; this.soundCues = this.soundCues.filter((entry) => entry.page > page); for (const entry of due) entry.play(); } /** The map setup scripts' music (data/maps/setup_scripts.asm). */ private mapSetupMusic(setup: MapSetup) { const sound = this.sound, music = this.mapMusicId(); sound.setPlayerState(this.state.playerState); if (setup === "warp") { // MAPSETUP_WARP, _TELEPORT and _FLY: PlayMapMusicBike, FadeInToMusic. sound.playMapMusicBike(music, this.state.playerState); sound.fadeInToMusic(); } else if (setup === "door") { // MAPSETUP_DOOR, _FALL and _TRAIN: FadeOutMapMusic, FadeToMapMusic. sound.skipMusic(6); sound.fadeToMapMusic(music); } else if (setup === "connection") sound.fadeToMapMusic(music); else if (setup === "continue") sound.playMapMusicBike(music, this.state.playerState); } /** GetMapMusic_MaybeSpecial: Surf's music, the contest gates' ranking * music, then the map's own, which the Rockets can change. */ mapMusicId(state = this.state, map = this.map) { if (state.playerState === OW.PLAYER_SURF || state.playerState === OW.PLAYER_SURF_PIKA) return SOUND.MUSIC_SURF; if ( state.flags.has(ENGINE_FLAGS.bugContestTimer) && (map.id === "10:15" || map.id === "10:17") ) return SOUND.MUSIC_BUG_CATCHING_CONTEST_RANKING; const music = map.music; if (music === SOUND.MUSIC_MAHOGANY_MART) return state.flags.has(ENGINE_FLAGS.rocketsInMahogany) ? SOUND.MUSIC_ROCKET_HIDEOUT : SOUND.MUSIC_CHERRYGROVE_CITY; if (music & 0x80) return state.flags.has(ENGINE_FLAGS.rocketsInRadioTower) ? SOUND.MUSIC_ROCKET_OVERTURE : music & 0x7f; return music; } // -------------------------------------------------------------- scripts private host(lastTalked: number, mapId = this.mapId): ScriptHost { return { seerMemory: this.seerMemory, mapId, lastTalked, sceneOwner: (id) => this.assets.sceneOwner(id), timeOfDay: () => this.clockTimeOfDay, clock: () => this.clock(), clockDay: () => this.gameNow().getDay(), gameNow: () => this.gameNow().getTime(), 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, sound: this.sound, mapMusic: (state) => this.mapMusicId(state), initCallReceiveDelay: () => this.initCallReceiveDelay(), landmarkOf: (id) => this.assets.map(id).landmark, 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(), day: () => this.gameDay(), tileset: () => ({ graphics: this.assets.tileset(this.map).graphics, palettes: this.assets.palettes(this.map, this.timeOfDay), }), }; } /** 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 }; // GetFishGroupIndex: a fishing swarm a trainer called about. let group = this.map.fishGroup; const swarm = state.flags.has(ENGINE_FLAGS.fishSwarm) ? state.memory.wFishingSwarmFlag : 0; if (group === OW.FISHGROUP_QWILFISH && swarm === OW.FISHSWARM_QWILFISH) group = OW.FISHGROUP_QWILFISH_SWARM; else if (group === OW.FISHGROUP_REMORAID && swarm === OW.FISHSWARM_REMORAID) group = OW.FISHGROUP_REMORAID_SWARM; 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, caller, fieldMove, usedItem, fish, roaming, }: { usedItem?: number; /** BATTLETYPE_FISH, set by FishFunction for a hooked Pokémon. */ fish?: boolean; /** BATTLETYPE_ROAMING, set by CheckEncounterRoamMon. */ roaming?: 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; /** wCurCaller for a received call. */ caller?: 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, caller, fieldMove, usedItem, fish, roaming, }, ), 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, }; // Any button buffered for an earlier realtime screen cannot still apply. this.screenInput = []; 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 { const choice = this.dialogue?.choice; if (realtimeChoice(choice)) { this.bufferScreenButton("select"); return true; } if (choice?.kind === "pokegear" || choice?.kind === "screen") { 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 { const choice = this.dialogue?.choice; if (!select && realtimeChoice(choice)) { this.bufferScreenButton("start"); return true; } if ( !select && (choice?.kind === "pokegear" || choice?.kind === "screen") ) { this.choose("start"); return true; } if (this.busy || this.introPending) return false; this.clearNotice(); // StartMenu's SFX_MENU; SelectMenu's PlayTalkObject. this.sound.playSFX(select ? SOUND.SFX_READ_TEXT_2 : SOUND.SFX_MENU); this.script = { run: new StartMenuRun( this.state, { reader: this.assets.reader, data: this.assets.pokemon, dex: this.assets.pokedex, 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, sound: this.sound, 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."); const pages = (effect as { pages?: string[] }).pages; if (pages) { this.soundCues = pageCues(pages); this.playCues(0); this.lastPage = pages.at(-1); } else if (effect.kind !== "waitsfx" && effect.kind !== "delay") this.lastPage = undefined; switch (effect.kind) { case "waitsfx": case "delay": { script.waitTicks = effect.kind === "delay" ? effect.ticks : 0; if (effect.kind === "waitsfx" && !this.sound.sfxPlaying) return false; // The text box keeps its last page meanwhile, as in the original. this.waitingPage = this.lastPage; return true; } 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, ...(effect.balance && { balance: effect.balance }), }, }; 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); } this.actor(object).toggled = true; 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 "objectsprite": { const object = this.map.objects.find((candidate) => candidate.id === effect.object); if (!object) throw new Error("Script names an unknown object."); this.actor(object).sprite = effect.sprite; return false; } 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); // A Battle Tower trainer: its own name and Pokémon, and the first // class's (Falkner's) AI attributes, which the AI layers and // AI_SwitchOrTryItem read in the Battle Tower; no items or prize. const tower = effect.tower; const falkner = tower && trainers.attributes(1); const towerTrainer = tower && falkner && { name: tower.name, className: trainers.className(tower.trainerClass), party: structuredClone(tower.party), aiFlags: falkner.aiMoveFlags, switchFlags: falkner.aiItemSwitchFlags, items: [0, 0] as [number, number], baseReward: 0, reward: 0, winText: tower.lossText, lossText: tower.winText, }; const trainer = towerTrainer ?? (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, items: [...opponent.attributes.items] as [number, number], baseReward: opponent.attributes.baseReward, reward: opponent.reward, winText: effect.trainer?.winText, lossText: effect.trainer?.lossText, }); const engine = new Battle({ data: this.assets.pokemon, sound: (sound) => this.battleSound(sound), 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), // wKantoBadges: ENGINE_BOULDERBADGE follows the Johto badges. kantoBadges: Array.from({ length: 8 }, (_, bit) => draft.flags.has(OW.ENGINE_ZEPHYRBADGE + 8 + 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, battleType: effect.battleType, contest: draft.bugContest, // GetRoamMonSpecies and the like identify a roaming battle's own // struct by species, exactly as the original's helpers do. roamer: effect.battleType === C.BATTLETYPE_ROAMING ? draft.roamMons.find((mon) => mon.species === effect.wild?.species) : undefined, 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, }, // RunBattleTowerTrainer sets BATTLE_SHIFT: the SET style. shift: tower ? false : shiftBattleStyle(draft.options), battleTower: Boolean(tower), battleScene: battleSceneOn(draft.options), unown: draft, // wUnlockedUnowns: the ENGINE_UNLOCKED_UNOWNS_* flags' bits. unlockedUnowns: Array.from({ length: 8 }, (_, bit) => draft.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit) ? 1 << bit : 0, ).reduce((bits, bit) => bits | bit, 0), }); const trainerClass = tower ? tower.trainerClass : effect.trainer?.classId; this.battle = { engine, selected: 1, trainerClass }; this.battleRun = engine.run(); // PlayBattleMusic, before the battle transition. this.sound.playBattleMusic( battleMusic({ battleType: effect.battleType, trainerClass, trainerId: effect.trainer?.id, night: this.clockTimeOfDay === 2, kanto: inKanto(this.assets.map(this.mapId).landmark), }), ); 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 "screen": this.dialogue = { pages: [""], index: 0, choice: { kind: "screen", screen: effect.screen, selected: 0, ...(effect.realtime && { realtime: true }), }, }; 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, caller: effect.caller, }; 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 "mapradio": this.dialogue = { pages: [effect.text], index: 0, choice: { kind: "mapradio", selected: 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 "pokedex": if (!effect.view) return false; this.dialogue = { pages: [""], index: 0, choice: { kind: "pokedex", 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 }), ...(effect.balance && { balance: effect.balance }), }, }; 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": { // EvolutionAnimation: silence, the old species' cry (not when it // is asleep or frozen), then MUSIC_EVOLUTION. const mon = script.run.state.party.find((candidate) => candidate.species === effect.from); const sound = this.sound; sound.clearQueue(); sound.playMusic(SOUND.MUSIC_NONE); if (!mon || !(mon.status & (C.SLP_MASK | (1 << C.FRZ)))) sound.playMonCry2(effect.from); sound.afterSFX(() => sound.playMusic(SOUND.MUSIC_EVOLUTION)); 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!; this.soundCues = []; this.waitingPage = undefined; if (script.warp) { const { map, x, y, facing, door, roam } = 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, door ? "door" : "warp"); if (roam) this.applyRoamMons(this.state, map, roam === "jump"); 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; // ShrinkPlayer's SFX_ESCAPE_ROPE, then the new game's MAPSETUP_WARP. this.sound.playSFX(SOUND.SFX_ESCAPE_ROPE); this.mapSetupMusic("warp"); } 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.soundCues = []; this.waitingPage = undefined; 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, updateRoamMons, } = next.value; draft.money = Math.min( C.MAX_MONEY, draft.money + prizeMoney + payDayMoney, ); draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney); // CleanUpBattleRAM's callfar UpdateRoamMons, which the original // runs after EvolveAfterBattle below; the two touch unrelated // state, so committing it here first changes nothing observable. if (updateRoamMons) this.applyRoamMons(draft, this.mapId, false); } this.battle = undefined; this.battleRun = undefined; this.sound.setDanger(false); // ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM. const { result, evolvable } = next.value; this.menuCursors = { start: 0, party: 0, pocket: 0, dex: this.menuCursors.dex, }; 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") { this.checkDanger(); 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."; } } /** A battle's sound: PlayStereoCry, PlaySFX, PlayVictoryMusic. */ private battleSound(sound: BattleSound) { const view = this.battle; if (!view) return; if ("cry" in sound) this.sound.playStereoCry(sound.cry, sound.side === "player" ? 0xf0 : 0x0f); else if ("sfx" in sound) this.sound.queueSFX(sound.sfx); else if ("stopDanger" in sound) this.sound.setDanger(false); else if (sound.music === "capture") this.sound.playMusicAfterFrame(SOUND.MUSIC_CAPTURE); else this.sound.playMusicAfterFrame(victoryMusic(view.trainerClass)); } /** CheckDanger: the low HP tone while the player's HP bar is red. */ private checkDanger() { const view = this.battle; if (!view) return; const mon = view.engine.player.mon; const red = !!mon?.hp && hpBarPixels(mon.hp, mon.maxHp) < 10 && !view.engine.lowHealthAlarmOff; this.sound.setDanger(red); } /** 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 === "contestswitch" || 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" || prompt?.kind === "contestswitch") 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" || prompt?.kind === "contestswitch") 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. */ /** The game clock's day number, which moves with SetDayOfWeek's * offset as the RTC's days do. */ private gameDay(clock = this.liveState.clock) { const now = this.gameNow(clock); return ( Math.floor( Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000, ) + clock.weekdayOffset ); } private checkDailyReset() { const day = this.gameDay(this.state.clock); if (this.state.dailyReset === day) return; this.dailyFlags ??= this.assets.dailyEngineFlags(); for (const flag of this.dailyFlags) this.state.flags.delete(flag); // Kenji's break: a countdown of 3 to 6 days, sampled again at zero. const memory = this.state.memory, kenji = memory.wKenjiBreakTimer ?? 0; if (!kenji || !(memory.wKenjiBreakTimer = kenji - 1)) memory.wKenjiBreakTimer = (this.random() & 3) + 3; this.state.dailyReset = day; } /** InitCallReceiveDelay's and CheckReceiveCallTimer's timer: work RAM * that StartMap restarts whenever the game is loaded. */ private callTimer = { cycles: 0, minutes: 20, start: 0 }; /** InitCallReceiveDelay */ initCallReceiveDelay() { this.callTimer.cycles = 0; this.nextCallReceiveDelay(); } /** NextCallReceiveDelay: 20, 10, 5, then 3 minutes between calls. */ private nextCallReceiveDelay() { const timer = this.callTimer; timer.minutes = [20, 10, 5, 3][Math.min(timer.cycles, 3)]; timer.start = Math.floor(this.gameNow(this.state.clock).getTime() / 60_000); } /** CheckReceiveCallTimer: true (carry) once the delay has passed, which * shortens the next one. */ private checkReceiveCallTimer() { const timer = this.callTimer; const now = Math.floor(this.gameNow(this.state.clock).getTime() / 60_000); if (now - timer.start < timer.minutes) return false; timer.cycles = Math.min(timer.cycles + 1, 3); this.nextCallReceiveDelay(); return true; } /** CheckPhoneCall: off a doorway, once the timer allows, half the time, * with a signal, a registered trainer or caller whose calling hours * include now and who is not on this map rings with their caller * script. */ private checkPhoneCall() { const tile = this.assets.collision(this.map, this.player.x, this.player.y); if ( tile === OW.COLL_DOOR || tile === OW.COLL_DOOR_79 || tile === OW.COLL_STAIRCASE || tile === OW.COLL_CAVE ) return; if (!this.checkReceiveCallTimer()) return; if (this.random() & 0x80) return; if (this.map.phoneService) return; const r = this.assets.reader, time = 1 << this.clockTimeOfDay; const callers = this.state.phone.filter((contact) => { if (!contact) return false; const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE; if (!(r.byte(entry + 8) & time)) return false; return `${r.byte(entry + 2)}:${r.byte(entry + 3)}` !== this.mapId; }); if (!callers.length) return; // ChooseRandomCaller: hRandomAdd's swapped low five bits, modulo. const roll = this.random(); const caller = callers[(((roll << 4) | (roll >> 4)) & 0x1f) % callers.length]; this.startScript(PHONE.Script_ReceivePhoneCall, PLAYER, { callerScript: r.far(PC.PhoneContacts + caller * PHONE_CONTACT_SIZE + 9), caller, }); } /** CheckBugContestTimer, from CheckTimeEvents while * STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline * replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see * ScriptState.bugContestEnd); past it, BugCatchingContestOverScript * runs in place of the player's own input this tick. */ private checkBugContestTimer() { const end = this.state.bugContestEnd; if (end === undefined || this.gameNow(this.state.clock).getTime() < end) return; this.startScript(JOHTO.BugCatchingContestOverScript, PLAYER); } // ---------------------------------------------------------------- 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.sound.playSFX(SOUND.SFX_STRENGTH); 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; // BurnedTower1F's rival is a trainer whose script is ObjectEvent, not // a TrainerHeader. Once its battle has set its own flag, it still // faces the player beside the hole, and the original's "beaten" check // reads ObjectEvent's bytes as flag $f251: through echo RAM, bit 1 of // wNumKeyItems, which is set with the two or three key items usual // here (BICYCLE, SQUIRTBOTTLE, COIN CASE). The port skips it as that // case does. With other counts the original sights it and prints // ObjectEvent's bytes as seen text, whose first byte runs text // command $7f beyond TextCommands: a jump into home code that the // port cannot reproduce (tests/reference-trainer-sight.spec.ts). if ( object.script === OVERWORLD.ObjectEvent && object.eventFlag !== 0xffff && this.state.events.has(object.eventFlag) ) 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; /** The sound engine and the home routines that drive it. */ readonly sound: GameSound; tick(held?: Direction) { this.ticks++; // VBlank's UpdateSound, once per video frame. this.sound.setPlayerState(this.state.playerState); this.sound.setStereo(((this.state.options.options >> OW.STEREO) & 1) === 1); this.sound.frame(); this.sound.frame(); // 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); } if (script.effect?.kind === "delay" || script.effect?.kind === "waitsfx") { // WaitSFX holds everything but the sound; a long wait gives up. const done = script.effect.kind === "delay" ? --script.waitTicks <= 0 : !this.sound.sfxPlaying || ++script.waitTicks > 900; if (done) { this.waitingPage = undefined; this.pump(); } return; } 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 (realtimeChoice(dialogue.choice)) { // SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once // per video frame; two frames run per overworld tick, as elsewhere. this.advanceScreenFrame(); this.advanceScreenFrame(); return; } 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, caller: memScript.caller, }); if (this.busy) return; } this.checkScene(); if (this.busy) return; // CheckTimeEvents: a running contest skips the daily path entirely. if (this.state.bugContest) this.checkBugContestTimer(); else { this.checkDailyReset(); this.checkPhoneCall(); } if (this.busy) return; // 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; // PlayTalkObject (and PlayClickSFX for tile scripts) as an event starts. const talk = () => this.sound.playSFX(SOUND.SFX_READ_TEXT_2); if (object) { if (object.kind !== 0 && object.kind !== 1 && object.kind !== OBJECTTYPE_TRAINER) { this.notice = "This interaction is not ported yet."; return; } talk(); 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; talk(); 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)) { if (this.script) talk(); return; } this.notice = "Nothing to interact with here."; return; } talk(); 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.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) { // .GetOnBike: MUSIC_BICYCLE at once, as wMapMusic. this.sound.engine.musicFade = 0; this.sound.playMusicAfterFrame(SOUND.MUSIC_BICYCLE); this.sound.engine.maxVolume(); this.sound.engine.mapMusic = SOUND.MUSIC_BICYCLE; 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; // .PlayEvolvedSFX and the new picture's cry, or the old one's when // stopped; then EvolvePokemon's jingle under EvolvedIntoText. Outside // a battle's aftermath, ReturnToMap's RestartMapMusic follows. const sound = this.sound, afterBattle = Boolean(this.script?.interlude); sound.clearQueue(); if (stopped) sound.playMonCry2(evolution.from); else sound.afterSFX( () => sound.playSFX(SOUND.SFX_EVOLVED), () => sound.playMonCry2(evolution.to), () => { sound.playMusic(SOUND.MUSIC_NONE); sound.playSFX(SOUND.SFX_CAUGHT_MON); }, ); if (!afterBattle) sound.afterSFX(() => sound.restartMapMusic()); 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) { // PromptButton's SFX_READ_TEXT_2 after the button. this.sound.playSFX(SOUND.SFX_READ_TEXT_2); dialogue.index++; this.playCues(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) { const battlePrompt = this.battle?.prompt?.kind; if (this.battle && (battlePrompt === "yesno" || battlePrompt === "contestswitch")) { 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 === "pokedex" && !POKEDEX_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)) || (dialogue.choice.kind === "screen" && !SCREEN_BUTTONS.includes(answer as never) && // A realtime screen's own frame-advance passes "" for a frame with // no button pressed, which SCREEN_BUTTONS does not list. !(dialogue.choice.realtime && answer === "")) ) return; // MenuClickSound: menus click on A or B. if (CLICKING_CHOICES.has(dialogue.choice.kind)) this.sound.playSFX(SOUND.SFX_READ_TEXT_2); this.townMap = false; this.statsScreen = undefined; this.mailView = undefined; this.dialogue = undefined; this.pump(answer); } /** A button pressed while a realtime screen is open: buffered for its * next frame rather than answered at once. Bounded so a screen left open * while busy elsewhere cannot grow this without limit. */ bufferScreenButton(button: string) { if (this.screenInput.length < 8) this.screenInput.push(button); } /** One video frame of a realtime screen (SlotsLoop, CardFlip's * MasterLoop): resume it with the next buffered button, or none. */ private advanceScreenFrame() { if (!this.script || !realtimeChoice(this.dialogue?.choice)) return; this.choose(this.screenInput.shift() ?? ""); } // ---------------------------------------------------------------- saves /** The Savegames list entry for a save: the trainer, the landmark (the * backup map's in LANDMARK_SPECIAL rooms), play time, Johto badges and * caught count. */ describe(save: NativeSave = this.save()): SlotSummary { let landmark = this.assets.map(save.map).landmark; if (landmark === OW.LANDMARK_SPECIAL && save.backupWarp) landmark = this.assets.map(save.backupWarp.map).landmark; let badges = 0; for (let bit = 0; bit < OW.NUM_JOHTO_BADGES; bit++) if (save.flags.includes(OW.ENGINE_ZEPHYRBADGE + bit)) badges++; return { player: save.name, playerId: save.playerId, place: landmarkName(this.assets.reader, landmark, this.host(PLAYER).text()), hours: save.gameTime.hours, minutes: save.gameTime.minutes, badges, caught: save.pokedex.caught.length, newGame: save.intro, }; } 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.map.objects .flatMap((object) => { const actor = this.actors.get(object.id); const spawned = this.spawned.has(object.id), flagged = this.visible(object, state), differs = Boolean(actor?.toggled) && spawned !== flagged; if (!actor?.moved && !differs) return []; const { x, y, direction } = actor ?? spawnActor(object); return [ { id: object.id, x, y, direction, ...(differs && !spawned && { hidden: true as const }), ...(differs && spawned && { shown: true as const }), }, ]; }) .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, hallOfFame: state.hallOfFame, registeredItem: { ...state.registeredItem }, mailbox: state.mailbox.map(copyMail), repel: state.repel, coins: state.coins, tradeFlags: state.tradeFlags, blueCardBalance: state.blueCardBalance, memory: { ...state.memory }, unownDex: [...state.unownDex], firstUnownSeen: state.firstUnownSeen, timers: structuredClone(state.timers), luckyNumber: { ...state.luckyNumber }, magikarpRecord: { ...state.magikarpRecord }, battleTower: copyBattleTowerState(state.battleTower), roamMons: structuredClone(state.roamMons), roamMonsMap: state.roamMonsMap, options: { ...state.options }, ...(state.bugContest && { bugContest: { parkBalls: state.bugContest.parkBalls, mon: structuredClone(state.bugContest.mon), }, bugContestEnd: state.bugContestEnd, }), ...(state.contestLeftParty && { contestLeftParty: structuredClone(state.contestLeftParty), }), }; } /** Load a save. `continued` is Continue from the title menu, which * relocates the roaming beasts; a rollback to a captured state passes * false so the state comes back exactly. */ restore(value: unknown, continued = true) { 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" || typeof save.hallOfFame !== "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.tradeFlags, 0, 0x7f) || !integer(save.blueCardBalance, 0, 255) || !save.memory || typeof save.memory !== "object" || Object.entries(save.memory).some( ([label, byte]) => !Object.hasOwn(SAVED_WRAM, label) || !integer(byte, 0, 255), ) || !Array.isArray(save.unownDex) || save.unownDex.length > 26 || !save.unownDex.every((letter) => integer(letter, 1, 26)) || new Set(save.unownDex).size !== save.unownDex.length || !integer(save.firstUnownSeen, 0, 26) || !save.timers || typeof save.timers !== "object" || Object.entries(save.timers).some( ([name, timer]) => !DAY_TIMERS.includes(name as never) || !validDayTimer(timer), ) || !save.luckyNumber || !integer(save.luckyNumber.id, 0, 0xffff) || !integer(save.luckyNumber.day, 0, RTC_DAYS) || !save.magikarpRecord || !integer(save.magikarpRecord.feet, 0, 0xff) || !integer(save.magikarpRecord.inches, 0, 0xff) || typeof save.magikarpRecord.holder !== "string" || save.magikarpRecord.holder.length > 10 || !validBattleTower(this.assets.pokemon, save.battleTower) || !Array.isArray(save.roamMons) || save.roamMons.length !== 3 || !save.roamMons.every(validRoamMon) || (save.roamMonsMap !== null && (typeof save.roamMonsMap !== "string" || !/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) || !validOptions(save.options) || (save.bugContest === undefined) !== (save.bugContestEnd === undefined) || (save.bugContest !== undefined && (typeof save.bugContest !== "object" || save.bugContest === null || !integer(save.bugContest.parkBalls, 0, 20) || (save.bugContest.mon !== null && !validPartyMon(this.assets.pokemon, save.bugContest.mon)))) || (save.bugContestEnd !== undefined && !integer(save.bugContestEnd, 0, Number.MAX_SAFE_INTEGER)) || (save.contestLeftParty !== undefined && (!Array.isArray(save.contestLeftParty) || save.contestLeftParty.length > PARTY_LENGTH - 1 || !save.contestLeftParty.every((mon) => validPartyMon(this.assets.pokemon, mon), ))) ) { 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) || (Object.hasOwn(object, "hidden") && object.hidden !== true) || (Object.hasOwn(object, "shown") && object.shown !== true) ) 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, hallOfFame: save.hallOfFame, registeredItem: { ...save.registeredItem }, mailbox: save.mailbox.map(copyMail), repel: save.repel, coins: save.coins, tradeFlags: save.tradeFlags, blueCardBalance: save.blueCardBalance, memory: { ...save.memory }, unownDex: [...save.unownDex], firstUnownSeen: save.firstUnownSeen, timers: structuredClone(save.timers), luckyNumber: { ...save.luckyNumber }, magikarpRecord: { ...save.magikarpRecord }, battleTower: copyBattleTowerState(save.battleTower), roamMons: structuredClone(save.roamMons), roamMonsMap: save.roamMonsMap, options: { ...save.options }, specialPhoneCall: save.specialPhoneCall, rivalName: save.rivalName, ...(save.bugContest && { bugContest: { parkBalls: save.bugContest.parkBalls, mon: structuredClone(save.bugContest.mon), }, bugContestEnd: save.bugContestEnd, }), ...(save.contestLeftParty && { contestLeftParty: structuredClone(save.contestLeftParty), }), }; // Continue's own farcall JumpRoamMons, run directly before // MAPSETUP_CONTINUE itself: the well-known trick of saving and // reloading the game to relocate the roaming beasts. if (continued) this.applyRoamMons(this.state, save.map, true); let entry; try { entry = this.prepareMap(save.map, "continue"); } catch (error) { this.state = previous.state; throw error; } this.script = undefined; this.applyMap(entry, continued ? "continue" : "none"); // StartMap's InitCallReceiveDelay. this.initCallReceiveDelay(); 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 }); if (object.hidden) this.spawned.delete(object.id); if (object.shown) this.spawned.add(object.id); if (object.hidden || object.shown) actor.toggled = true; } 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, dex: 0 }; this.seerMemory = { caughtLevel: 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 | 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, "hallOfFame") && { hallOfFame: 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, }), // Packs saved before the TM/HM pocket had no TMs or HMs. ...(typeof save.bag === "object" && save.bag !== null && !Object.hasOwn(save.bag, "tmsHms") ? { bag: { ...(save.bag as object), tmsHms: Array(NUM_TMS_HMS).fill(0), }, } : {}), ...(!Object.hasOwn(save, "tradeFlags") && { tradeFlags: 0 }), ...(!Object.hasOwn(save, "memory") && { memory: {} }), ...(!Object.hasOwn(save, "unownDex") && { unownDex: [], firstUnownSeen: 0, }), ...(!Object.hasOwn(save, "timers") && { timers: {}, luckyNumber: { id: 0, day: 0 }, }), ...(!Object.hasOwn(save, "magikarpRecord") && { magikarpRecord: initialMagikarpRecord(), }), ...(!Object.hasOwn(save, "battleTower") && { battleTower: emptyBattleTowerState(), }), ...(!Object.hasOwn(save, "roamMons") && { roamMons: emptyRoamMons(), roamMonsMap: null, }), ...(!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) }, 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, hallOfFame: false, registeredItem: { which: 0, item: 0 }, mailbox: [], repel: 0, coins: 0, blueCardBalance: 0, tradeFlags: 0, memory: {}, unownDex: [], firstUnownSeen: 0, timers: {}, luckyNumber: { id: 0, day: 0 }, magikarpRecord: initialMagikarpRecord(), battleTower: emptyBattleTowerState(), roamMons: emptyRoamMons(), roamMonsMap: null, intro: false, gameTime: emptyGameTime(), options: defaultOptions(), } as unknown as NativeSave; }