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