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