Port the fishing rods and wild Magikarp

The Old, Good and Super Rods run FishFunction facing water: the map's fish
group and Fish pick no fish, no bite, or a Pokémon from FishGroups and the
time of day's TimeFishGroups. The player casts and jiggles with the
fishing frames drawn from LoadFishingGFX's tiles, shows "!" on a bite, and
the hooked Pokémon attacks as BATTLETYPE_FISH. Facing pieces now use
signed offsets, which the rod above and left of the player needs.

Wild Magikarp no longer stop a battle: LoadEnemyMon redraws their DVs
through CalcMagikarpLength and its filters, as the original does. The
length routine matches the original in the reference emulator.

Part of #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 00:49:35 +02:00
1 parent 4cb4676c7d
commit 397dcef44c
20 files changed
+681 -47

No files matched your search

+73 -6
View File
@@ -177,8 +177,13 @@ export interface BattleOptions {
tutorial?: boolean;
/** BATTLETYPE_TREE: a wild Pokémon that fell from a Headbutt tree. */
tree?: boolean;
/** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */
fish?: boolean;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark?: number;
/** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of
* Rage for wild Magikarp. */
mapId?: string;
/** wPartyMenuCursor as the last party menu outside battle left it. */
partyMenuCursor?: number;
/** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much
@@ -219,6 +224,40 @@ export interface TypeMatchup {
/** BattleCommand_DamageCalc's arithmetic. `boost` is a type-boosting held
* item's percentage; `current` is the damage already in wCurDamage.
* Returns undefined when the original leaves wCurDamage unchanged. */
/** MAP_LAKE_OF_RAGE (constants/map_constants.asm). */
const LAKE_OF_RAGE = { group: 9, number: 6 };
/** CalcMagikarpLength (engine/events/magikarp.asm): a length in feet and
* inches from the DVs and the trainer ID. The index is rrc(rrc(DV byte))
* xor rrc(ID byte), per byte. Its .BCLessThanDE compares only the high
* bytes, so MagikarpLengths' thresholds act on the high byte alone and
* the difference below them wraps around 16 bits, as in the original. */
export function magikarpLength(
r: RomReader,
dvs: number,
playerId: number,
): { feet: number; inches: number } {
const rrc = (value: number) => ((value >> 1) | (value << 7)) & 0xff;
const b = rrc(rrc(dvs >> 8)) ^ rrc((playerId >> 8) & 0xff),
c = rrc(rrc(dvs & 0xff)) ^ rrc(playerId & 0xff),
bc = (b << 8) | c;
let mm: number | undefined;
let p = BATTLE.MagikarpLengths;
if (bc < 10) mm = bc + 190;
else
for (let z = 2; z < 16; z++, p += 3)
if (b < r.byte(p + 1)) {
const quotient = Math.floor(((bc - r.word(p)) & 0xffff) / r.byte(p + 2));
mm = ((quotient & 0xff) + 100 * z) & 0xffff;
break;
}
// Past the table: bc minus its last threshold, plus 1600.
mm ??= (((bc - r.word(p - 3)) & 0xffff) + 1600) & 0xffff;
// Millimetres × 10 / 254 in a byte, then feet and inches.
const total = Math.floor(((mm * 10) & 0xffff) / 254) & 0xff;
return { feet: Math.floor(total / 12), inches: total % 12 };
}
export function calculateDamage(input: {
power: number;
level: number;
@@ -479,6 +518,7 @@ export class Battle {
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
else if (options.tree) this.battleType = C.BATTLETYPE_TREE;
else if (options.fish) this.battleType = C.BATTLETYPE_FISH;
}
private get reader(): RomReader {
return this.options.data.reader;
@@ -637,14 +677,16 @@ export class Battle {
}
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
const { species, level } = this.options.wild;
if (species === C.UNOWN || species === C.MAGIKARP)
throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet.");
if (species === C.UNOWN)
throw new UnsupportedBattle("Unown encounters are not ported yet.");
const data = this.options.data,
base = data.baseData(species);
// 25% chance of an item: 23% the first, 2% the second.
let item = 0;
if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
const dvs = (this.random() << 8) | this.random();
let dvs: number;
do dvs = (this.random() << 8) | this.random();
while (species === C.MAGIKARP && this.rerollMagikarp(dvs));
const stats = calcStats(data, species, dvs, level);
const moves: number[] = [];
for (const learned of data.learnset(species)) {
@@ -680,6 +722,29 @@ export class Battle {
this.wildMoves = [...moves];
this.wildPP = [...this.enemy.mon.pp];
}
/** LoadEnemyMon's .Magikarp filters, true when the DVs are drawn again.
* Their limits are in millimetres but CalcMagikarpLength gives feet and
* inches: the two size checks need 6 feet, which no Magikarp reaches
* (they only draw from Random), and the floor of 1024 mm compares
* feet with 4. The Lake of Rage test matches its map group or its map
* number on their own, and skips the floor there. */
private rerollMagikarp(dvs: number): boolean {
const { feet, inches } = magikarpLength(
this.reader,
dvs,
this.options.playerId,
);
if (feet === 1536 >> 8 && this.random() >= percent(5)) {
if (inches >= (1616 & 0xff)) return true;
if (this.random() >= percent(20) - 1 && inches >= (1600 & 0xff))
return true;
}
const [group, number] = (this.options.mapId ?? "0:0").split(":").map(Number);
if (group === LAKE_OF_RAGE.group || number === LAKE_OF_RAGE.number)
return false;
if (this.random() < percent(39) + 1) return false;
return feet < 1024 >> 8;
}
/** CheckSleepingTreeMon: in a tree battle, the time of day's
* AsleepTreeMons list names the species that sleep. */
private sleepingTreeMon(species: number) {
@@ -794,9 +859,11 @@ export class Battle {
yield* this.say(BATTLE.BattleText_EnemySentOut);
} else
yield* this.say(
this.battleType === C.BATTLETYPE_TREE
? BATTLE.PokemonFellFromTreeText
: BATTLE.WildPokemonAppearedText,
this.battleType === C.BATTLETYPE_FISH
? BATTLE.HookedPokemonAttackedText
: this.battleType === C.BATTLETYPE_TREE
? BATTLE.PokemonFellFromTreeText
: BATTLE.WildPokemonAppearedText,
);
this.resetBattleParticipants();
this.initBattleMon();
+1
View File
@@ -311,6 +311,7 @@ export const C = {
BATTLETYPE_NORMAL: 0,
BATTLETYPE_DEBUG: 2,
BATTLETYPE_TUTORIAL: 3,
BATTLETYPE_FISH: 4,
BATTLETYPE_ROAMING: 5,
BATTLETYPE_CONTEST: 6,
BATTLETYPE_FORCESHINY: 7,
+25 -3
View File
@@ -14,6 +14,11 @@ import {
* Headbutt tree back: HideHeadbuttTree's WaitBGMap and the 32 shaking
* frames. */
const HEADBUTT_HIDDEN_FRAMES = 36;
/** FACING_FISH_DOWN, followed by the up, left and right fishing frames. */
const FACING_FISH_DOWN = 0x10;
/** A FACING_* piece's ABSOLUTE_TILE_ID flag: the tile ignores the
* object's first tile. */
const ABSOLUTE_TILE_ID = 0x04;
export class WorldRenderer {
width: number = BASE_VIEWPORT.width;
@@ -161,7 +166,13 @@ export class WorldRenderer {
{ transparent: true, flipX },
);
}
for (const pose of [...poses].sort((a, b) => a.y - b.y))
for (const pose of [...poses].sort((a, b) => a.y - b.y)) {
// The player's fishing frames use LoadFishingGFX's tiles.
const fishing =
pose.id === 0 &&
pose.facing !== undefined &&
pose.facing >= FACING_FISH_DOWN &&
pose.facing <= FACING_FISH_DOWN + 3;
this.actor(
pose.sprite,
pose.x - cameraX,
@@ -172,7 +183,9 @@ export class WorldRenderer {
pose.palette,
pose.facing,
pose.inGrass,
fishing ? world.avatar : undefined,
);
}
const emote =
world.emote && poses.find((pose) => pose.id === world.emote!.object);
if (emote) {
@@ -251,9 +264,13 @@ export class WorldRenderer {
paletteIndex?: number,
facing?: number,
inGrass = false,
fishing?: "chris" | "kris",
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
const graphics = fishing
? this.assets.fishingSprite(sprite.graphics, fishing === "kris")
: sprite.graphics;
const palette = this.assets.objectPalette(
paletteIndex ?? sprite.palette,
time,
@@ -263,12 +280,17 @@ export class WorldRenderer {
? this.assets.facingTiles(facing)
: this.assets.facing(direction, sprite.walking ? frame : 0);
for (const piece of pieces) {
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
const tile =
fishing && piece.attributes & ABSOLUTE_TILE_ID
? 24 + piece.tile - 0xfc
: piece.tile >= 0x80
? piece.tile - 0x80 + 12
: piece.tile;
drawTile(
this.image.data,
this.width,
this.height,
sprite.graphics,
graphics,
tile,
x + piece.x,
y + piece.y,
+23 -2
View File
@@ -126,6 +126,7 @@ export class CrystalAssets {
environment: r.byte(header + 2),
paletteMode: r.byte(header + 7) & 15,
phoneService: r.byte(header + 7) >> 4,
fishGroup: r.byte(header + 8),
attributes,
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
warps: [],
@@ -500,6 +501,24 @@ export class CrystalAssets {
r.byte(entry + 2),
);
}
/** The first vTiles0 tile an emote's graphics are loaded to. */
emoteTile(index: number): number {
if (index < 0 || index > 11) throw new Error("Unknown emote.");
return (this.reader.word(SYMBOLS.emotes + index * 6 + 4) - 0x8000) >> 4;
}
/** LoadFishingGFX: the player's sprite (24 tiles) with the lower halves
* of the standing frames replaced by FishingGFX or KrisFishingGFX, then
* the two rod tiles that the fishing frames place at $fc and $fd. All
* of them are in VRAM bank 1, as the player's sprite is. */
fishingSprite(graphics: Uint8Array, kris: boolean): Uint8Array {
const source = kris ? OVERWORLD.KrisFishingGFX : OVERWORLD.FishingGFX;
const result = new Uint8Array(26 * 16);
result.set(graphics.subarray(0, 24 * 16));
[2, 6, 10, 24].forEach((tile, chunk) =>
result.set(this.reader.slice(source + chunk * 32, 32), tile * 16),
);
return result;
}
/** HeadbuttTreeGFX: the shaking tree's two 2x2 frames, eight tiles. */
headbuttTree(): Uint8Array {
return this.reader.slice(OVERWORLD.HeadbuttTreeGFX, 8 * 16);
@@ -553,9 +572,11 @@ export class CrystalAssets {
pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings));
return Array.from({ length: r.byte(pointer) }, (_, i) => {
const p = pointer + 1 + i * 4;
// The offsets are added to the sprite's position modulo 256, so
// they act as signed bytes.
return {
y: r.byte(p),
x: r.byte(p + 1),
y: (r.byte(p) << 24) >> 24,
x: (r.byte(p + 1) << 24) >> 24,
attributes: r.byte(p + 2),
tile: r.byte(p + 3),
};
+17
View File
@@ -161,6 +161,8 @@ export const RAM = {
visitedSpawns: 0xdca5,
tempWildMonSpecies: 0xd22e,
coins: 0xd855,
/** hBGMapMode: HRAM, like hMoneyTemp below. */
bgMapMode: 0xffd4,
blueCardBalance: 0xdc4b,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
@@ -274,6 +276,16 @@ export const OVERWORLD = {
"_BasementKey.BasementKeyScript": 0x507e1,
"_Squirtbottle.SquirtbottleScript": 0x5073c,
"_Squirtbottle.CheckCanUseSquirtbottle": 0x50753,
FishGroups: 0x92488,
TimeFishGroups: 0x9266f,
Script_GotABite: 0xd035,
Script_NotEvenANibble: 0xd01e,
Script_NotEvenANibble2: 0xd027,
Fishing_CheckFacingUp: 0xd06c,
LoadFishingGFX: 0xb84b3,
PutTheRodAway: 0xd095,
FishingGFX: 0xb84f2,
KrisFishingGFX: 0xb8582,
Flypoints: 0x91c5e,
"TownMapBubble.Where": 0x91bf4,
RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0,
@@ -531,6 +543,9 @@ export const MENU = {
CardKeyEffect: 0xf745,
BasementKeyEffect: 0xf74c,
SquirtbottleEffect: 0xf73e,
OldRodEffect: 0xf5a5,
GoodRodEffect: 0xf5a9,
SuperRodEffect: 0xf5ad,
VitaminEffect: 0xee3d,
RareCandyEffect: 0xef14,
ItemStatRoseText: 0xeea6,
@@ -548,6 +563,8 @@ export const MENU = {
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
HookedPokemonAttackedText: 0x8075c,
MagikarpLengths: 0xfbca8,
ItemUsedText: 0xf83d,
BallBrokeFreeText: 0xedb5,
BallAppearedCaughtText: 0xedba,
+45
View File
@@ -0,0 +1,45 @@
import type { RomReader } from "../rom/reader";
import { OVERWORLD } from "../rom/profile";
/** The fishing rods' encounters (engine/events/fish.asm and
* data/wild/fish.asm). */
export interface FishMon {
species: number;
level: number;
}
/** FISHGROUP_DATA_LENGTH: the bite chance, then a table per rod. */
const FISHGROUP_DATA_LENGTH = 7;
/** NITE_F: TimeFishGroups' second species from the night on. */
const NITE_F = 2;
/** Fish: rod 0 (Old), 1 (Good) or 2 (Super) in FishGroups entry `group`
* (FISHGROUP_*, never FISHGROUP_NONE). A bite when Random falls below the
* group's chance, then the first entry whose chance a second Random does
* not exceed; species 0 picks the time of day's TimeFishGroups entry.
* Undefined when nothing bites. GetFishGroupIndex's swarm groups need
* DAILYFLAGS1_FISH_SWARM_F and wFishingSwarmFlag, which only trainer calls
* set; they are not ported, so the map's own group is used. */
export function fish(
r: RomReader,
group: number,
rod: number,
timeOfDay: number,
random: () => number,
): FishMon | undefined {
const entry = OVERWORLD.FishGroups + (group - 1) * FISHGROUP_DATA_LENGTH;
if (random() >= r.byte(entry)) return undefined;
let p = r.near(entry + 1 + rod * 2, r.bank(entry));
const roll = random();
while (roll > r.byte(p)) p += 3;
let species = r.byte(p + 1),
level = r.byte(p + 2);
if (!species) {
// .TimeEncounter: the level byte indexes TimeFishGroups.
const time =
OVERWORLD.TimeFishGroups + level * 4 + (timeOfDay >= NITE_F ? 2 : 0);
species = r.byte(time);
level = r.byte(time + 1);
}
return { species, level };
}
+40 -4
View File
@@ -234,6 +234,10 @@ const FACING_VECTORS = [
[-1, 0],
[1, 0],
] as const;
/** EMOTE_FROM_MEM (constants/script_constants.asm). */
const EMOTE_FROM_MEM = 0xff;
/** wPlayerDirection's UP, as the host's facing() numbers it. */
const UP = 1;
/** MAP_ROUTE_36 (constants/map_constants.asm). */
const ROUTE_36 = "10:3";
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
@@ -310,7 +314,14 @@ export type ScriptEffect =
tutorial?: boolean;
/** BATTLETYPE_TREE: a Pokémon that fell from a Headbutt tree. */
tree?: boolean;
/** BATTLETYPE_FISH: a Pokémon hooked on a fishing rod. */
fish?: boolean;
}
/** loademote: the emote graphics (EMOTE_*) that show_emote displays
* next, when they go to the emote bubble's tiles. */
| { kind: "loademote"; emote: number }
/** PutTheRodAway: the player stops fishing (UpdatePlayerSprite). */
| { kind: "putrodaway" }
/** warp and newloadmap: the world enters this map after the script
* ends, facing `facing` (warpfacing) or down (SpawnInFacingDown).
* MAPSETUP_DOOR (`door`) keeps the facing unless the arrival tile turns
@@ -568,6 +579,9 @@ export class ScriptRun {
/** An item used from the pack: UseItem names it in wStringBuffer1 and
* CopyName1 copies it to wStringBuffer2. */
usedItem?: number;
/** wBattleType BATTLETYPE_FISH, which FishFunction sets with the
* hooked Pokémon (`wild`). */
fish?: boolean;
} = {},
) {
this.state = copyScriptState(state);
@@ -621,9 +635,11 @@ export class ScriptRun {
spawn = this.options.fieldMove?.spawn ?? 0;
let wild = this.options.wild;
// wTempWildMonSpecies/wCurPartyLevel, as the encounter routines leave
// them for randomwildmon, and wBattleType's BATTLETYPE_TREE.
// them for randomwildmon, and wBattleType's BATTLETYPE_TREE and
// BATTLETYPE_FISH.
let tempWild = this.options.wild,
treeBattle = false;
treeBattle = false,
fishBattle = this.options.fish ?? false;
let trainer: { classId: number; id: number } | undefined;
// LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too.
let winText = this.tempTrainer?.beatenText || undefined,
@@ -950,6 +966,14 @@ export class ScriptRun {
sudowoodo = facing?.movement === OW.SPRITEMOVEDATA_SUDOWOODO;
}
value = Number(sudowoodo);
} else if (routine === OVERWORLD.LoadFishingGFX) {
// The renderer draws the fishing frames from FishingGFX.
} else if (routine === OVERWORLD.Fishing_CheckFacingUp) {
value = Number(this.host.facing() === UP);
} else if (routine === OVERWORLD.PutTheRodAway) {
// After the rod's text, which ends with a prompt.
yield* flush();
yield synchronous({ kind: "putrodaway" });
} else if (routine === OVERWORLD["ItemFinder.ItemfinderSound"]) {
// Four rounds of the Itemfinder's two sound effects.
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
@@ -995,9 +1019,11 @@ export class ScriptRun {
break;
}
case 0x1b: {
// loadmem: only the trainer script's wRunningTrainerBattleScript.
// loadmem: the trainer script's wRunningTrainerBattleScript, and
// hBGMapMode, which only affects how the screen is redrawn.
const address = word(),
stored = byte();
if (address === RAM.bgMapMode) break;
if (address !== RAM.runningTrainerBattleScript)
throw new UnsupportedScript(offset, opcode);
temp(offset, opcode);
@@ -1124,13 +1150,14 @@ export class ScriptRun {
trainer: trainer && { ...trainer, winText, lossText },
canLose,
tree: treeBattle || undefined,
fish: fishBattle || undefined,
});
if (typeof result !== "number")
throw new Error("Invalid battle result.");
battleResult = result;
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
// CleanUpBattleRAM clears wBattleType and wTempWildMonSpecies.
treeBattle = false;
treeBattle = fishBattle = false;
tempWild = undefined;
break;
}
@@ -1677,6 +1704,15 @@ export class ScriptRun {
});
break;
}
case 0x74: {
// loademote: EMOTE_FROM_MEM takes the emote from wScriptVar.
const operand = byte();
yield synchronous({
kind: "loademote",
emote: operand === EMOTE_FROM_MEM ? value : operand,
});
break;
}
case 0x75: {
const emote = byte(),
target = byte(),
+18
View File
@@ -111,6 +111,8 @@ export type MenuFieldMove =
wild?: { species: number; level: number };
/** hLastTalked, when the move acts on a facing object. */
lastTalked?: number;
/** BATTLETYPE_FISH: the wild Pokémon is on a fishing rod's hook. */
fish?: boolean;
}
| { texts: number[] }
| { notice: string }
@@ -144,6 +146,8 @@ export interface StartMenuHost {
gameTime(): GameTime;
/** CheckForHiddenItems: an unfound hidden item on the screen. */
hiddenItemOnScreen(state: ScriptState): boolean;
/** FishFunction with rod 0-2: a script, or a silent failure. */
fish(state: ScriptState, rod: number): MenuFieldMove;
}
/** The overworld start menu (engine/menus/start_menu.asm) with its
@@ -989,6 +993,20 @@ export class StartMenuRun {
this.deferred = MENU.SacredAshScript;
this.deferredItem = item;
return true;
case MENU.OldRodEffect:
case MENU.GoodRodEffect:
case MENU.SuperRodEffect: {
// UseRod: FishFunction queues a script unless it fails.
const result = this.host.fish(
this.state,
effect === MENU.OldRodEffect ? 0 : effect === MENU.GoodRodEffect ? 1 : 2,
);
if (!("script" in result)) return false;
this.deferred = result.script;
this.deferredItem = item;
this.deferredMove = result;
return true;
}
case MENU.ItemfinderEffect:
// ItemFinder: one script or the other, and always a success.
this.deferred = this.host.hiddenItemOnScreen(this.state)
+3
View File
@@ -60,6 +60,9 @@ export interface GameMap {
/** The palette byte's high nybble: nonzero where the phone has no
* signal (GetMapPhoneService). */
phoneService: number;
/** MAP_FISHGROUP: the FishGroups entry the rods fish from, or
* FISHGROUP_NONE. */
fishGroup: number;
/** The map attributes' ROM offset, which some routines compare. */
attributes?: number;
blocks: Uint8Array;
+61 -2
View File
@@ -61,6 +61,7 @@ import {
type MenuFieldMove,
} from "../scripts/start-menu";
import { PartyEvolution } from "../scripts/evolution";
import { fish } from "../scripts/fish";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
@@ -211,6 +212,9 @@ interface Actor {
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?: {
@@ -476,6 +480,8 @@ const forcedDirection = (tile: number): Direction | undefined => {
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. */
@@ -653,6 +659,9 @@ export class GameWorld {
/** 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,
@@ -877,7 +886,9 @@ export class GameWorld {
: {};
const posed = actor.pose && this.posePresentation(actor.pose);
const poseFacing =
actor.pose?.pose === "fish-bite" || actor.pose?.pose === "fish-cast"
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)
@@ -1977,6 +1988,34 @@ export class GameWorld {
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];
@@ -2021,8 +2060,11 @@ export class GameWorld {
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;
@@ -2055,6 +2097,7 @@ export class GameWorld {
callerScript,
fieldMove,
usedItem,
fish,
},
),
committed,
@@ -2126,6 +2169,7 @@ export class GameWorld {
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,
@@ -2321,6 +2365,7 @@ export class GameWorld {
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":
@@ -2376,6 +2421,8 @@ export class GameWorld {
trainer,
canLose: effect.canLose,
tree: effect.tree,
fish: effect.fish,
mapId: this.mapId,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
storage: draft.storage,
@@ -2419,6 +2466,15 @@ export class GameWorld {
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 = {
@@ -2663,6 +2719,8 @@ export class GameWorld {
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;
@@ -2680,7 +2738,7 @@ export class GameWorld {
return false;
case "emote":
this.emote = step.shown
? { object: id, emote: this.emote?.emote ?? 0 }
? { object: id, emote: this.bubbleEmote }
: undefined;
return false;
case "shake":
@@ -2945,6 +3003,7 @@ export class GameWorld {
usedItem: script.run.deferredItem,
fieldMove: script.run.deferredMove?.fieldMove,
wild: script.run.deferredMove?.wild,
fish: script.run.deferredMove?.fish,
}
: {},
);