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();