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:
1 parent
4cb4676c7d
commit
397dcef44c
20 files changed
+681
-47
No files matched your search
+200
-2
@@ -13,13 +13,16 @@ import {
|
||||
import { StartMenuRun } from "../src/game/scripts/start-menu";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
import { OVERWORLD } from "../src/game/rom/profile";
|
||||
import { close, useItem } from "./menu-driver";
|
||||
import { fish } from "../src/game/scripts/fish";
|
||||
import { Battle, magikarpLength } from "../src/game/battle/battle";
|
||||
import { close, open, pick, pickItem, useItem } from "./menu-driver";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const PLAYERS_ROOM = "24:7",
|
||||
NEW_BARK_TOWN = "24:4",
|
||||
ROUTE_30 = "26:1",
|
||||
RADIO_TOWER_3F = "3:19",
|
||||
GOLDENROD_UNDERGROUND = "3:53",
|
||||
@@ -38,7 +41,16 @@ const POTION = 0x12,
|
||||
ITEMFINDER = 0x37,
|
||||
CARD_KEY = 0x7f,
|
||||
BASEMENT_KEY = 0x85,
|
||||
SQUIRTBOTTLE = 0xaf;
|
||||
SQUIRTBOTTLE = 0xaf,
|
||||
OLD_ROD = 0x3a,
|
||||
GOOD_ROD = 0x3b,
|
||||
SUPER_ROD = 0x3d;
|
||||
const MAGIKARP = 129,
|
||||
KRABBY = 98,
|
||||
CORSOLA = 222,
|
||||
STARYU = 120;
|
||||
const FISHGROUP_SHORE = 1;
|
||||
const FACING_FISH_RIGHT = 0x13;
|
||||
const SPRITEMOVEDATA_SUDOWOODO = 0x17;
|
||||
const OAK_WARNING = "OAK: CHRIS! This isn't the time to use that!";
|
||||
|
||||
@@ -410,4 +422,190 @@ describe.skipIf(!existsSync(romPath))("field items", () => {
|
||||
"CHRIS used the\nSQUIRTBOTTLE.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fishes from FishGroups by rod and time of day", () => {
|
||||
const r = new CrystalAssets(new Uint8Array(readFileSync(romPath))).reader;
|
||||
const cast = (rod: number, time: number, ...draws: number[]) =>
|
||||
fish(r, FISHGROUP_SHORE, rod, time, () => {
|
||||
const draw = draws.shift();
|
||||
if (draw === undefined) throw new Error("No draw left.");
|
||||
return draw;
|
||||
});
|
||||
// A bite below 50 percent + 1 ($80), then the first entry the roll
|
||||
// does not exceed: 70 percent + 1, 85 percent + 1, 100 percent.
|
||||
expect(cast(0, 1, 127, 0)).toEqual({ species: MAGIKARP, level: 10 });
|
||||
expect(cast(0, 1, 128)).toBeUndefined();
|
||||
expect(cast(0, 1, 0, 179)).toEqual({ species: MAGIKARP, level: 10 });
|
||||
expect(cast(0, 1, 0, 255)).toEqual({ species: KRABBY, level: 10 });
|
||||
// The Good Rod's last entry is TimeFishGroups 0: Corsola by day,
|
||||
// Staryu from the night on.
|
||||
expect(cast(1, 0, 0, 255)).toEqual({ species: CORSOLA, level: 20 });
|
||||
expect(cast(1, 1, 0, 255)).toEqual({ species: CORSOLA, level: 20 });
|
||||
expect(cast(1, 2, 0, 255)).toEqual({ species: STARYU, level: 20 });
|
||||
expect(cast(2, 1, 0, 0)).toEqual({ species: KRABBY, level: 40 });
|
||||
});
|
||||
|
||||
it("draws a wild Magikarp's DVs again below four feet, except at the Lake of Rage", () => {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const id = 0x1234;
|
||||
const feet = (dvs: number) => magikarpLength(assets.reader, dvs, id).feet;
|
||||
// Nonzero, unlike the draws that follow.
|
||||
const short = [...Array(0x10000).keys()].find(
|
||||
(dvs) => dvs > 0x100 && feet(dvs) < 4,
|
||||
)!,
|
||||
long = [...Array(0x10000).keys()].find(
|
||||
(dvs) => dvs > 0x100 && feet(dvs) >= 4,
|
||||
)!;
|
||||
/** The wild Magikarp's DVs for these Random draws (then zeros). */
|
||||
const dvs = (mapId: string, ...draws: number[]) => {
|
||||
const game = new Battle({
|
||||
data: assets.pokemon,
|
||||
party: [
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species: 155,
|
||||
level: 5,
|
||||
item: 0,
|
||||
otId: id,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x823b,
|
||||
caughtData: 0,
|
||||
}),
|
||||
],
|
||||
playerId: id,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
random: () => draws.shift() ?? 0,
|
||||
wild: { species: MAGIKARP, level: 10 },
|
||||
fish: true,
|
||||
mapId,
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
});
|
||||
const first = game.run().next().value;
|
||||
expect(first && "text" in first && first.text).toBe(
|
||||
"The hooked\nMAGIKARP\nattacked!",
|
||||
);
|
||||
return game.enemy.mon.dvs;
|
||||
};
|
||||
const hi = (value: number) => value >> 8,
|
||||
lo = (value: number) => value & 0xff;
|
||||
// No item (0), the DVs, then 39 percent + 1 (100) skips the floor.
|
||||
expect(dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 99)).toBe(short);
|
||||
expect(
|
||||
dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 100, hi(long), lo(long), 100),
|
||||
).toBe(long);
|
||||
// The Lake of Rage (9:6) skips it without a draw, and so does any map
|
||||
// in group 9 or numbered 6.
|
||||
for (const map of ["9:6", "9:1", "24:6"])
|
||||
expect(dvs(map, 0, hi(short), lo(short), 255)).toBe(short);
|
||||
});
|
||||
|
||||
describe("fishing in New Bark Town", () => {
|
||||
/** Facing the sea, with a rod and a constant random byte. */
|
||||
function shore(random: number, rod = OLD_ROD) {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const world = new GameWorld(
|
||||
assets,
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
() => random,
|
||||
);
|
||||
world.state.party = [
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species: 155,
|
||||
level: 5,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x823b,
|
||||
caughtData: 0,
|
||||
}),
|
||||
];
|
||||
world.state.bag.keyItems = [rod];
|
||||
world.state.scenes[NEW_BARK_TOWN] = 1;
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: NEW_BARK_TOWN,
|
||||
x: 17,
|
||||
y: 7,
|
||||
direction: "right",
|
||||
objects: [],
|
||||
});
|
||||
return world;
|
||||
}
|
||||
/** USE from the pack, then tick until a page or a battle appears,
|
||||
* recording the player's facings and emotes meanwhile. */
|
||||
function cast(world: GameWorld, rod = OLD_ROD) {
|
||||
open(world);
|
||||
pick(world, "PACK");
|
||||
pickItem(world, rod);
|
||||
const items =
|
||||
world.dialogue?.choice?.kind === "menu"
|
||||
? world.dialogue.choice.items
|
||||
: [];
|
||||
world.choose(items.indexOf("USE"));
|
||||
const facings = new Set<number | undefined>(),
|
||||
emotes = new Set<number>();
|
||||
let ticks = 0;
|
||||
for (; ticks < 400 && !world.dialogue && !world.battle; ticks++) {
|
||||
world.tick();
|
||||
facings.add(world.poses().find((pose) => pose.id === 0)?.facing);
|
||||
if (world.emote) emotes.add(world.emote.emote);
|
||||
}
|
||||
return { facings, emotes, ticks };
|
||||
}
|
||||
|
||||
it("hooks a Pokémon that attacks", () => {
|
||||
const world = shore(0);
|
||||
const { facings, emotes } = cast(world);
|
||||
// Cast, four bites with the "!" bubble (EMOTE_SHOCK), then the rod
|
||||
// is pulled in.
|
||||
expect(facings).toContain(FACING_FISH_RIGHT);
|
||||
expect([...emotes]).toEqual([0]);
|
||||
expect(world.dialogue?.pages).toEqual(["Oh!\nA bite!"]);
|
||||
// .Movement_RestoreRod hid the bubble before the text.
|
||||
expect(world.emote).toBeUndefined();
|
||||
expect(world.poses().find((pose) => pose.id === 0)?.facing).toBe(
|
||||
FACING_FISH_RIGHT,
|
||||
);
|
||||
world.advanceDialogue();
|
||||
// PutTheRodAway, then the battle: BATTLETYPE_FISH.
|
||||
for (let tick = 0; tick < 100 && !world.battle?.message; tick++)
|
||||
world.tick();
|
||||
expect(
|
||||
world.poses().find((pose) => pose.id === 0)?.facing,
|
||||
).toBeUndefined();
|
||||
expect(world.battle?.message?.text).toBe(
|
||||
"The hooked\nMAGIKARP\nattacked!",
|
||||
);
|
||||
expect(world.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("gets no bite, or nothing at all where the map has no fish", () => {
|
||||
for (const group of [undefined, 0]) {
|
||||
const world = shore(200, SUPER_ROD);
|
||||
if (group !== undefined) world.map.fishGroup = group;
|
||||
const { facings, emotes } = cast(world, SUPER_ROD);
|
||||
expect(facings).toContain(FACING_FISH_RIGHT);
|
||||
expect(emotes.size).toBe(0);
|
||||
expect(world.dialogue?.pages).toEqual(["Not even a nibble!"]);
|
||||
expect(close(world)).toEqual(["Not even a nibble!"]);
|
||||
expect(
|
||||
world.poses().find((pose) => pose.id === 0)?.facing,
|
||||
).toBeUndefined();
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.state.bag.keyItems).toEqual([SUPER_ROD]);
|
||||
}
|
||||
});
|
||||
|
||||
it("needs water ahead and no surfing, or Oak objects", () => {
|
||||
const turned = shore(0, GOOD_ROD);
|
||||
turned.restore({ ...turned.save(), direction: "left" });
|
||||
expect(useItem(turned, GOOD_ROD)).toEqual([OAK_WARNING]);
|
||||
close(turned);
|
||||
const surfing = shore(0, GOOD_ROD);
|
||||
surfing.restore({ ...surfing.save(), x: 18, playerState: 4 });
|
||||
expect(useItem(surfing, GOOD_ROD)).toEqual([OAK_WARNING]);
|
||||
close(surfing);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user