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

+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,
}
: {},
);