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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user