Run the forced-shiny, trap, forced-item, Celebi, Suicune, roaming and Bug-Catching Contest battle types. Roaming battles keep their struct's DVs and HP byte, and every other battle makes CleanUpBattleRAM's roaming roll. The contest shows PARKBALL×, counts down Park Balls, ends when they run out, keeps its catch outside the party and asks before switching. Scripts load the special types with loadvar, and while a contest runs, encounters use BugCatchingContestBattleScript and ContestMons. Validated with 596 unit tests, 155 browser tests (including the contest menu and switch screen), native/reference builds and reference/vendor/release checks. National Park's contest event, the roaming release and the Ilex Forest Celebi event come with their maps. Closes #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b547ca29ff
commit
8fae1f0c0f
15 files changed
+787
-42
No files matched your search
+182
-19
@@ -111,8 +111,12 @@ export type BattleEffect =
|
||||
| { kind: "text"; text: string; prompt: boolean }
|
||||
/** HP, status or level changed; renderers re-read the battle state. */
|
||||
| { kind: "hud" }
|
||||
/** The battle menu: answered with an action. */
|
||||
| { kind: "action" }
|
||||
/** The battle menu: answered with an action. In the Bug-Catching
|
||||
* Contest, PACK is PARKBALL× with this wParkBallsRemaining. */
|
||||
| { kind: "action"; parkBalls?: number }
|
||||
/** DisplayCaughtContestMonStats: the stocked contest Pokémon beside the
|
||||
* one just caught, with ContestAskSwitchText; answered Yes/No (1/0). */
|
||||
| { kind: "contestswitch"; text: string; stock: ContestMonSummary; caught: ContestMonSummary }
|
||||
| { kind: "yesno"; text: string }
|
||||
/** Choose a move to forget (0-3); 4 cancels, like B on the list. */
|
||||
| { kind: "forget"; moves: number[]; learning: number }
|
||||
@@ -127,6 +131,36 @@ export type BattleEffect =
|
||||
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
|
||||
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
|
||||
| { kind: "move"; text: string; moves: number[]; pp: number[] };
|
||||
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
|
||||
export type ContestMonSummary = { name: string; level: number; maxHp: number };
|
||||
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
|
||||
export interface BugContest {
|
||||
parkBalls: number;
|
||||
mon: PartyMon | null;
|
||||
}
|
||||
/** A wRoamMon struct: Raikou's, Entei's or Suicune's. An HP of 0 means
|
||||
* the DVs and HP are drawn at the next encounter. */
|
||||
export interface RoamMon {
|
||||
species: number;
|
||||
level: number;
|
||||
/** The map it roams, or null (GROUP_N_A/MAP_N_A) once defeated or caught. */
|
||||
mapId: string | null;
|
||||
/** The low byte of its HP. */
|
||||
hp: number;
|
||||
dvs: number;
|
||||
}
|
||||
/** BATTLERESULT_CAUGHT_CELEBI, which CheckCaughtCelebi reads. */
|
||||
export const BATTLERESULT_CAUGHT_CELEBI = 0x40;
|
||||
/** wBattleType values that BattleOptions.battleType accepts. */
|
||||
const SPECIAL_BATTLE_TYPES: readonly number[] = [
|
||||
C.BATTLETYPE_ROAMING,
|
||||
C.BATTLETYPE_CONTEST,
|
||||
C.BATTLETYPE_FORCESHINY,
|
||||
C.BATTLETYPE_TRAP,
|
||||
C.BATTLETYPE_FORCEITEM,
|
||||
C.BATTLETYPE_CELEBI,
|
||||
C.BATTLETYPE_SUICUNE,
|
||||
];
|
||||
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
|
||||
type ItemUse = { item: number; slot: number };
|
||||
export type BattleAction =
|
||||
@@ -134,7 +168,8 @@ export type BattleAction =
|
||||
| { action: "run" }
|
||||
/** PKMN: open the party menu to switch. */
|
||||
| { action: "pkmn" }
|
||||
/** PACK: use the item in this slot of its pocket (only Balls so far). */
|
||||
/** PACK: use the item in this slot of its pocket. In the Bug-Catching
|
||||
* Contest it throws a PARK_BALL (any slot). */
|
||||
| { action: "item"; item: number; slot: number };
|
||||
export type BattleInput = number | string | BattleAction | undefined;
|
||||
/** wBattleResult, the prize money and wEvolvableFlags (a bit per party
|
||||
@@ -147,6 +182,9 @@ export type BattleOutcome = {
|
||||
/** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */
|
||||
payDayMoney?: number;
|
||||
evolvable?: number;
|
||||
/** BattleEnd_HandleRoamMons called UpdateRoamMons: after a roaming
|
||||
* battle that was not won, and after one battle in sixteen otherwise. */
|
||||
updateRoamMons?: true;
|
||||
};
|
||||
|
||||
export interface BattleTrainer {
|
||||
@@ -195,6 +233,14 @@ export interface BattleOptions {
|
||||
tree?: boolean;
|
||||
/** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */
|
||||
fish?: boolean;
|
||||
/** wBattleType for the other wild battles: BATTLETYPE_FORCESHINY, TRAP,
|
||||
* FORCEITEM, CELEBI, SUICUNE, CONTEST (with `contest`) or ROAMING (with
|
||||
* `roamer`). */
|
||||
battleType?: number;
|
||||
/** The contest's Park Balls and caught Pokémon, which the battle updates. */
|
||||
contest?: BugContest;
|
||||
/** The roaming Pokémon's struct, which the battle updates. */
|
||||
roamer?: RoamMon;
|
||||
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||||
landmark?: number;
|
||||
/** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of
|
||||
@@ -561,6 +607,7 @@ export class 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;
|
||||
else if (options.battleType !== undefined) this.battleType = options.battleType;
|
||||
}
|
||||
private get reader(): RomReader {
|
||||
return this.options.data.reader;
|
||||
@@ -741,12 +788,24 @@ export class Battle {
|
||||
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.
|
||||
// 25% chance of an item: 23% the first, 2% the second. Ho-Oh, Lugia
|
||||
// and Snorlax (BATTLETYPE_FORCEITEM) always hold the first.
|
||||
let item = 0;
|
||||
if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
|
||||
if (this.battleType === C.BATTLETYPE_FORCEITEM) item = base.items[0];
|
||||
else if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
|
||||
// Roaming Pokémon keep their DVs, drawn (Speed/Special first) at the
|
||||
// first encounter; the Red Gyarados is shiny.
|
||||
const roamer = this.battleType === C.BATTLETYPE_ROAMING ? this.options.roamer! : undefined;
|
||||
let dvs: number;
|
||||
do dvs = (this.random() << 8) | this.random();
|
||||
while (species === C.MAGIKARP && this.rerollMagikarp(dvs));
|
||||
if (roamer) {
|
||||
if (!roamer.hp) {
|
||||
const low = this.random();
|
||||
roamer.dvs = (this.random() << 8) | low;
|
||||
}
|
||||
dvs = roamer.dvs;
|
||||
} else if (this.battleType === C.BATTLETYPE_FORCESHINY) dvs = 0xeaaa;
|
||||
else dvs = (this.random() << 8) | this.random();
|
||||
while (species === C.MAGIKARP && this.rerollMagikarp(dvs)) dvs = (this.random() << 8) | this.random();
|
||||
const stats = calcStats(data, species, dvs, level);
|
||||
const moves = this.levelMoves(species, level);
|
||||
this.enemySpecies = species;
|
||||
@@ -760,7 +819,8 @@ export class Battle {
|
||||
level,
|
||||
// .TreeMon: some Pokémon fall from trees asleep.
|
||||
status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0,
|
||||
hp: stats[0],
|
||||
// A roaming Pokémon's struct keeps only its HP's low byte.
|
||||
hp: roamer?.hp ? (stats[0] & 0xff00) | roamer.hp : stats[0],
|
||||
maxHp: stats[0],
|
||||
stats: stats.slice(1),
|
||||
types: [base.types[0], base.types[1]],
|
||||
@@ -771,6 +831,7 @@ export class Battle {
|
||||
catchRate: base.catchRate,
|
||||
exp: base.baseExp,
|
||||
};
|
||||
if (roamer && !roamer.hp) roamer.hp = stats[0] & 0xff;
|
||||
this.options.pokedex.seen.add(species);
|
||||
this.enemy.stats = [...this.enemy.mon.stats];
|
||||
this.wildMoves = [...moves];
|
||||
@@ -889,11 +950,27 @@ export class Battle {
|
||||
|
||||
/** StartBattle through ExitBattle. */
|
||||
*run(): Gen<BattleOutcome> {
|
||||
const outcome = yield* this.doBattle();
|
||||
// ExitBattle: CheckPayDay after a win.
|
||||
if (outcome.result & 0x0f) return outcome;
|
||||
const payDayMoney = yield* this.checkPayDay();
|
||||
return payDayMoney ? { ...outcome, payDayMoney } : outcome;
|
||||
let outcome = yield* this.doBattle();
|
||||
// ExitBattle: CheckPayDay after a win. EvolveAfterBattle follows in
|
||||
// the world, before CleanUpBattleRAM's roaming update below.
|
||||
if (!(outcome.result & 0x0f)) {
|
||||
const payDayMoney = yield* this.checkPayDay();
|
||||
if (payDayMoney) outcome = { ...outcome, payDayMoney };
|
||||
}
|
||||
return this.handleRoamMons() ? { ...outcome, updateRoamMons: true } : outcome;
|
||||
}
|
||||
/** BattleEnd_HandleRoamMons: a roaming Pokémon keeps its HP, or leaves
|
||||
* the map for good when defeated or caught. Other battles move the
|
||||
* roaming Pokémon one time in sixteen. True when UpdateRoamMons runs. */
|
||||
private handleRoamMons(): boolean {
|
||||
if (this.battleType !== C.BATTLETYPE_ROAMING) return !(this.random() & 0x0f);
|
||||
const roamer = this.options.roamer!;
|
||||
if (!(this.battleResult & 0x0f)) {
|
||||
Object.assign(roamer, { hp: 0, mapId: null, species: 0 });
|
||||
return false;
|
||||
}
|
||||
roamer.hp = this.enemy.mon.hp & 0xff;
|
||||
return true;
|
||||
}
|
||||
/** CheckPayDay: the coins, doubled by an Amulet Coin, for the wallet. */
|
||||
private *checkPayDay(): Gen<number> {
|
||||
@@ -927,6 +1004,15 @@ export class Battle {
|
||||
}
|
||||
if (this.options.tutorial && (trainer || !this.options.wild))
|
||||
throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon.");
|
||||
if (this.options.battleType !== undefined) {
|
||||
const type = this.options.battleType;
|
||||
if (!SPECIAL_BATTLE_TYPES.includes(type) || trainer || !this.options.wild)
|
||||
throw new UnsupportedBattle(`Battle type ${type} is not supported here.`);
|
||||
if (type === C.BATTLETYPE_CONTEST && !this.options.contest)
|
||||
throw new UnsupportedBattle("A contest battle needs the contest's state.");
|
||||
if (type === C.BATTLETYPE_ROAMING && this.options.roamer?.species !== this.options.wild.species)
|
||||
throw new UnsupportedBattle("A roaming battle needs its roaming Pokémon.");
|
||||
}
|
||||
this.loadEnemyMon();
|
||||
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
|
||||
if (this.curBattleMon < 0)
|
||||
@@ -951,7 +1037,9 @@ export class Battle {
|
||||
? BATTLE.HookedPokemonAttackedText
|
||||
: this.battleType === C.BATTLETYPE_TREE
|
||||
? BATTLE.PokemonFellFromTreeText
|
||||
: BATTLE.WildPokemonAppearedText,
|
||||
: this.battleType === C.BATTLETYPE_CELEBI
|
||||
? BATTLE.WildCelebiAppearedText
|
||||
: BATTLE.WildPokemonAppearedText,
|
||||
);
|
||||
this.resetBattleParticipants();
|
||||
this.initBattleMon();
|
||||
@@ -1026,6 +1114,11 @@ export class Battle {
|
||||
|
||||
private *battleTurn(): Gen {
|
||||
for (let turns = 0; turns < 1000; turns++) {
|
||||
// CheckContestBattleOver: out of Park Balls, the battle ends a draw.
|
||||
if (this.battleType === C.BATTLETYPE_CONTEST && !this.options.contest!.parkBalls) {
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
return;
|
||||
}
|
||||
this.curDamage = 0;
|
||||
this.enemyIsSwitching = 0;
|
||||
this.playerIsSwitching = 0;
|
||||
@@ -1146,10 +1239,14 @@ export class Battle {
|
||||
* the battle ended from the menu. */
|
||||
private *battleMenu(): Gen<boolean> {
|
||||
for (;;) {
|
||||
const input = yield { kind: "action" };
|
||||
const contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||||
const input = yield contest ? { kind: "action", parkBalls: contest.parkBalls } : { kind: "action" };
|
||||
if (!input || typeof input !== "object") continue;
|
||||
if (input.action === "item") {
|
||||
const used = yield* this.battleMenuPack(input.item, input.slot);
|
||||
// BattleMenu_Pack's .contest: PARKBALL× throws a Park Ball.
|
||||
const used = contest
|
||||
? yield* this.pokeBallEffect(C.PARK_BALL, -1)
|
||||
: yield* this.battleMenuPack(input.item, input.slot);
|
||||
if (this.wildMon) {
|
||||
// BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the
|
||||
// battle as a WIN, keeping the result's flag bits.
|
||||
@@ -1524,7 +1621,14 @@ export class Battle {
|
||||
* Dude's own ball and passes -1. Returns false if no ball was used. */
|
||||
private *pokeBallEffect(item: number, slot: number): Gen<boolean> {
|
||||
const data = this.options.data,
|
||||
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL;
|
||||
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL,
|
||||
contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||||
// .return_from_capture: the tutorial keeps the Dude's ball, the contest
|
||||
// counts down its Park Balls, and other balls leave the pack.
|
||||
const useBall = () => {
|
||||
if (contest) contest.parkBalls = (contest.parkBalls - 1) & 0xff;
|
||||
else if (!tutorial) this.tossItem("balls", item, slot);
|
||||
};
|
||||
// _DoItemEffect: GetItemName, then CopyName1 into wStringBuffer2.
|
||||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||||
if (this.battleMode !== C.WILD_BATTLE) {
|
||||
@@ -1545,6 +1649,8 @@ export class Battle {
|
||||
// The Dude's tutorial reaches this check too, and the original then
|
||||
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
|
||||
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
|
||||
// The contest's menu would go on to the player's move.
|
||||
if (contest) throw new UnsupportedBattle("A Park Ball cannot be used with a full party and box.");
|
||||
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
|
||||
yield* this.say(BATTLE.BallBoxFullText);
|
||||
return false;
|
||||
@@ -1568,7 +1674,7 @@ export class Battle {
|
||||
BATTLE.BallSoCloseText,
|
||||
][wobbles - 1],
|
||||
);
|
||||
if (!tutorial) this.tossItem("balls", item, slot);
|
||||
useBall();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1578,7 +1684,7 @@ export class Battle {
|
||||
// to wWildMonMoves and wWildMonPP. A transformed Pokémon is reloaded as
|
||||
// a Ditto with its backup DVs and keeps that Ditto's moves (a kept bug
|
||||
// for anything else).
|
||||
if (this.random() >= percent(75) + 1) this.random();
|
||||
if (this.battleType !== C.BATTLETYPE_FORCEITEM && this.random() >= percent(75) + 1) this.random();
|
||||
if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) {
|
||||
const moves = this.levelMoves(C.DITTO, this.enemy.mon.level);
|
||||
Object.assign(this.enemy.mon, {
|
||||
@@ -1610,6 +1716,12 @@ export class Battle {
|
||||
// NewPokedexEntry's Pokédex page is not presented yet.
|
||||
yield* this.say(BATTLE.NewDexDataText);
|
||||
}
|
||||
if (contest) {
|
||||
yield* this.setCaughtContestMon(contest, species, level);
|
||||
useBall();
|
||||
return true;
|
||||
}
|
||||
if (this.battleType === C.BATTLETYPE_CELEBI) this.battleResult |= BATTLERESULT_CAUGHT_CELEBI;
|
||||
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
|
||||
const name = data.speciesName(species);
|
||||
if (this.options.party.length >= PARTY_LENGTH) {
|
||||
@@ -1643,6 +1755,47 @@ export class Battle {
|
||||
this.tossItem("balls", item, slot);
|
||||
return true;
|
||||
}
|
||||
/** BugContest_SetCaughtContestMon: a second catch shows both Pokémon
|
||||
* (DisplayCaughtContestMonStats) and asks to switch. The kept one is
|
||||
* GeneratePartyMonStats' copy of the wild Pokémon, which has no caught
|
||||
* data; the contest names it later. */
|
||||
private *setCaughtContestMon(contest: BugContest, species: number, level: number): Gen {
|
||||
const data = this.options.data,
|
||||
enemy = this.enemy.mon;
|
||||
if (contest.mon) {
|
||||
const stock = contest.mon;
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(stock.species);
|
||||
yield* this.say(BATTLE["DisplayAlreadyCaughtText.ContestAlreadyCaughtText"]);
|
||||
const answer = yield {
|
||||
kind: "contestswitch",
|
||||
text: readTextEnd(this.reader, BATTLE.ContestAskSwitchText, this.context()).text,
|
||||
stock: { name: data.speciesName(stock.species), level: stock.level, maxHp: stock.stats[0] },
|
||||
caught: { name: enemy.nickname, level: enemy.level, maxHp: enemy.maxHp },
|
||||
};
|
||||
if (!answer) return;
|
||||
}
|
||||
contest.mon = {
|
||||
species,
|
||||
item: enemy.item,
|
||||
moves: [...this.wildMoves] as PartyMon["moves"],
|
||||
otId: this.options.playerId,
|
||||
ot: this.options.playerName,
|
||||
nickname: data.speciesName(species),
|
||||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||||
statExp: [0, 0, 0, 0, 0],
|
||||
dvs: enemy.dvs,
|
||||
pp: [...this.wildPP] as PartyMon["pp"],
|
||||
happiness: C.BASE_HAPPINESS,
|
||||
pokerus: 0,
|
||||
caughtData: 0,
|
||||
level,
|
||||
status: enemy.status,
|
||||
hp: enemy.hp,
|
||||
stats: calcStats(data, species, enemy.dvs, level),
|
||||
};
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(species);
|
||||
yield* this.say(BATTLE["BugContest_SetCaughtContestMon.ContestCaughtMonText"]);
|
||||
}
|
||||
/** PokeBallEffect's decision, before animation and caught-mon loading. */
|
||||
private rollCapture(item: number, tutorial: boolean) {
|
||||
let caught = tutorial || item === C.MASTER_BALL;
|
||||
@@ -2086,6 +2239,16 @@ export class Battle {
|
||||
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape.
|
||||
* The Speed compared is the battle Pokémon's, except after a faint. */
|
||||
private *tryToRunAway(playerSpeed = this.player.mon.stats[C.SPEED]): Gen<boolean> {
|
||||
// The contest always lets the player go; the event battles never do.
|
||||
if (this.battleType === C.BATTLETYPE_CONTEST) {
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
return true;
|
||||
}
|
||||
if (this.cannotFlee()) {
|
||||
yield* this.say(BATTLE.BattleText_CantEscape);
|
||||
return false;
|
||||
}
|
||||
if (this.options.trainer) {
|
||||
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
|
||||
return false;
|
||||
|
||||
@@ -385,6 +385,7 @@ export const C = {
|
||||
TRAINER_BATTLE: 2,
|
||||
HELD_AMULET_COIN: 76,
|
||||
BATTLETYPE_NORMAL: 0,
|
||||
BATTLETYPE_CANLOSE: 1,
|
||||
BATTLETYPE_DEBUG: 2,
|
||||
BATTLETYPE_TUTORIAL: 3,
|
||||
BATTLETYPE_FISH: 4,
|
||||
|
||||
@@ -160,6 +160,7 @@ export const RAM = {
|
||||
curHPAnimDeltaHP: 0xd1f3,
|
||||
visitedSpawns: 0xdca5,
|
||||
tempWildMonSpecies: 0xd22e,
|
||||
parkBallsRemaining: 0xdc79,
|
||||
coins: 0xd855,
|
||||
/** hBGMapMode: HRAM, like hMoneyTemp below. */
|
||||
bgMapMode: 0xffd4,
|
||||
@@ -193,6 +194,8 @@ export const ENGINE_FLAGS = {
|
||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||
* that `npm run check:reference` verifies each address by name. */
|
||||
export const OVERWORLD = {
|
||||
BugCatchingContestBattleScript: 0x135eb,
|
||||
ContestMons: 0x97d87,
|
||||
"OverworldTownMap": 0xc2c0,
|
||||
"TownMapGFX": 0xf8ba0,
|
||||
"JohtoMap": 0x91fff,
|
||||
@@ -593,6 +596,10 @@ export const BATTLE = {
|
||||
"HeavyBall_GetDexEntryBank.PokedexEntryBanks": 0xec4c,
|
||||
"HeavyBallMultiplier.WeightsTable": 0xecc4,
|
||||
FleeMons: 0x3c59a,
|
||||
WildCelebiAppearedText: 0x80793,
|
||||
ContestAskSwitchText: 0xcc0c2,
|
||||
"DisplayAlreadyCaughtText.ContestAlreadyCaughtText": 0xcc0d0,
|
||||
"BugContest_SetCaughtContestMon.ContestCaughtMonText": 0xe71d,
|
||||
|
||||
DownpourText: 0x817a3,
|
||||
SunGotBrightText: 0x817b8,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../rom/profile";
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { C } from "../battle/constants";
|
||||
import type { BugContest } from "../battle/battle";
|
||||
import {
|
||||
copyBag,
|
||||
emptyBag,
|
||||
@@ -140,6 +141,10 @@ export interface ScriptState {
|
||||
blueCardBalance: number;
|
||||
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
||||
options: GameOptions;
|
||||
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
|
||||
* runs (STATUSFLAGS2_BUG_CONTEST_TIMER_F). Nothing starts a contest yet,
|
||||
* so saves do not keep it. */
|
||||
bugContest?: BugContest;
|
||||
}
|
||||
export const emptyScriptState = (): ScriptState => ({
|
||||
decorations: emptyDecorations(),
|
||||
@@ -211,6 +216,12 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
coins: state.coins,
|
||||
blueCardBalance: state.blueCardBalance,
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: state.bugContest.parkBalls,
|
||||
mon: structuredClone(state.bugContest.mon),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** A field move's context from TrySurfOW, TryCutOW and similar: the party
|
||||
@@ -232,6 +243,17 @@ export interface FieldMoveContext {
|
||||
* objects. LAST_TALKED is resolved before an effect is emitted. */
|
||||
export const PLAYER = 0;
|
||||
const LAST_TALKED = 0xfe;
|
||||
/** The wBattleType values map scripts load (CelebiEvent_SetBattleType and
|
||||
* the encounter routines set the others). */
|
||||
const SCRIPT_BATTLE_TYPES: readonly number[] = [
|
||||
C.BATTLETYPE_NORMAL,
|
||||
C.BATTLETYPE_CANLOSE,
|
||||
C.BATTLETYPE_CONTEST,
|
||||
C.BATTLETYPE_FORCESHINY,
|
||||
C.BATTLETYPE_TRAP,
|
||||
C.BATTLETYPE_FORCEITEM,
|
||||
C.BATTLETYPE_SUICUNE,
|
||||
];
|
||||
/** Map steps for wPlayerDirection's DOWN, UP, LEFT and RIGHT. */
|
||||
const FACING_VECTORS = [
|
||||
[0, 1],
|
||||
@@ -331,6 +353,9 @@ export type ScriptEffect =
|
||||
tree?: boolean;
|
||||
/** BATTLETYPE_FISH: a Pokémon hooked on a fishing rod. */
|
||||
fish?: boolean;
|
||||
/** The other wBattleType values scripts load: FORCESHINY, TRAP,
|
||||
* FORCEITEM, SUICUNE and CONTEST. */
|
||||
battleType?: number;
|
||||
}
|
||||
/** loademote: the emote graphics (EMOTE_*) that show_emote displays
|
||||
* next, when they go to the emote bubble's tiles. */
|
||||
@@ -666,6 +691,7 @@ export class ScriptRun {
|
||||
let winText = this.tempTrainer?.beatenText || undefined,
|
||||
lossText = this.tempTrainer?.lostScript || undefined;
|
||||
let canLose = false;
|
||||
let battleType: number | undefined;
|
||||
const temp = (offset: number, opcode: number) => {
|
||||
if (!this.tempTrainer)
|
||||
throw new UnsupportedScript(offset, opcode, " (no map trainer)");
|
||||
@@ -1036,6 +1062,8 @@ export class ScriptRun {
|
||||
value = this.strengthSpecies;
|
||||
else if (address === RAM.tempWildMonSpecies)
|
||||
value = tempWild?.species ?? 0;
|
||||
else if (address === RAM.parkBallsRemaining && this.state.bugContest)
|
||||
value = this.state.bugContest.parkBalls;
|
||||
else throw new UnsupportedScript(offset, opcode);
|
||||
break;
|
||||
}
|
||||
@@ -1061,21 +1089,22 @@ export class ScriptRun {
|
||||
break;
|
||||
}
|
||||
case 0x1e: {
|
||||
// loadvar: VAR_BATTLETYPE's normal and CANLOSE modes, and
|
||||
// VAR_MOVEMENT (wPlayerState).
|
||||
// loadvar: VAR_BATTLETYPE (wBattleType) and VAR_MOVEMENT
|
||||
// (wPlayerState).
|
||||
const variable = byte(),
|
||||
setting = byte();
|
||||
if (variable === 8) {
|
||||
this.setPlayerState(setting, offset, opcode);
|
||||
break;
|
||||
}
|
||||
if (variable !== 3 || (setting !== 0 && setting !== 1))
|
||||
if (variable !== 3 || !SCRIPT_BATTLE_TYPES.includes(setting))
|
||||
throw new UnsupportedScript(
|
||||
offset,
|
||||
opcode,
|
||||
" (battle type or variable)",
|
||||
);
|
||||
canLose = setting === 1;
|
||||
canLose = setting === C.BATTLETYPE_CANLOSE;
|
||||
battleType = setting > C.BATTLETYPE_CANLOSE ? setting : undefined;
|
||||
break;
|
||||
}
|
||||
case 0x1d: {
|
||||
@@ -1172,6 +1201,7 @@ export class ScriptRun {
|
||||
canLose,
|
||||
tree: treeBattle || undefined,
|
||||
fish: fishBattle || undefined,
|
||||
battleType,
|
||||
});
|
||||
if (typeof result !== "number")
|
||||
throw new Error("Invalid battle result.");
|
||||
@@ -1179,6 +1209,7 @@ export class ScriptRun {
|
||||
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
|
||||
// CleanUpBattleRAM clears wBattleType and wTempWildMonSpecies.
|
||||
treeBattle = fishBattle = false;
|
||||
battleType = undefined;
|
||||
tempWild = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
+55
-4
@@ -348,7 +348,17 @@ export interface BattleView {
|
||||
/** A choice the battle is waiting for, if any. */
|
||||
prompt?: Extract<
|
||||
BattleEffect,
|
||||
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
|
||||
{
|
||||
kind:
|
||||
| "action"
|
||||
| "yesno"
|
||||
| "contestswitch"
|
||||
| "forget"
|
||||
| "stats"
|
||||
| "name"
|
||||
| "party"
|
||||
| "move";
|
||||
}
|
||||
>;
|
||||
/** Yes/No cursor, as on the original boxes. */
|
||||
selected: 0 | 1;
|
||||
@@ -1565,6 +1575,7 @@ export class GameWorld {
|
||||
)
|
||||
return;
|
||||
if (isIce(collision)) return;
|
||||
if (state.bugContest) return this.bugContestEncounter(previous, collision);
|
||||
const table = this.encounterTable(state);
|
||||
if (!(this.random() < table.rate)) return;
|
||||
const chosen = this.chooseWildEncounter(table);
|
||||
@@ -1581,6 +1592,40 @@ export class GameWorld {
|
||||
// The step replays after a reload, so it keeps its Repel step.
|
||||
if (state.repel && this.script) this.script.committed.repel++;
|
||||
}
|
||||
/** _TryWildEncounter_BugContest: 40% in super tall grass and 20%
|
||||
* elsewhere (halved by a Cleanse Tag; no radio music changes it yet),
|
||||
* a ContestMons pick, the Repel check, then
|
||||
* BugCatchingContestBattleScript. */
|
||||
private bugContestEncounter(previous: { x: number; y: number }, collision: number) {
|
||||
const state = this.state;
|
||||
let rate = Math.floor(
|
||||
((collision === OW.COLL_LONG_GRASS || collision === OW.COLL_LONG_GRASS_1C ? 40 : 20) * 255) / 100,
|
||||
);
|
||||
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
|
||||
if (!(this.random() < rate)) return;
|
||||
// ChooseWildEncounter_BugContest: 0-99, then percentages in ContestMons.
|
||||
let roll: number;
|
||||
do roll = this.random();
|
||||
while (roll >= 200);
|
||||
roll >>= 1;
|
||||
const r = this.assets.reader;
|
||||
let entry = OVERWORLD.ContestMons;
|
||||
while (roll >= r.byte(entry)) {
|
||||
roll -= r.byte(entry);
|
||||
entry += 4;
|
||||
}
|
||||
const species = r.byte(entry + 1),
|
||||
min = r.byte(entry + 2),
|
||||
span = r.byte(entry + 3) - min;
|
||||
const level = span ? min + (this.random() % (span + 1)) : min;
|
||||
const lead = state.party.find((mon) => mon.hp > 0);
|
||||
if (!lead || (state.repel && level < lead.level)) return;
|
||||
this.startScript(OVERWORLD.BugCatchingContestBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: { species, level },
|
||||
});
|
||||
if (state.repel && this.script) this.script.committed.repel++;
|
||||
}
|
||||
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
|
||||
* which a Cleanse Tag halves. */
|
||||
private encounterTable(state: ScriptState) {
|
||||
@@ -2456,6 +2501,8 @@ export class GameWorld {
|
||||
canLose: effect.canLose,
|
||||
tree: effect.tree,
|
||||
fish: effect.fish,
|
||||
battleType: effect.battleType,
|
||||
contest: draft.bugContest,
|
||||
mapId: this.mapId,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bag: draft.bag,
|
||||
@@ -3172,6 +3219,7 @@ export class GameWorld {
|
||||
const prompt = this.battle?.prompt;
|
||||
if (
|
||||
prompt?.kind === "yesno" ||
|
||||
prompt?.kind === "contestswitch" ||
|
||||
prompt?.kind === "forget" ||
|
||||
prompt?.kind === "party" ||
|
||||
prompt?.kind === "move"
|
||||
@@ -3182,7 +3230,8 @@ export class GameWorld {
|
||||
private battleA() {
|
||||
const view = this.battle!;
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "yesno") return this.pumpBattle(view.selected);
|
||||
if (prompt?.kind === "yesno" || prompt?.kind === "contestswitch")
|
||||
return this.pumpBattle(view.selected);
|
||||
if (prompt?.kind === "stats") return this.pumpBattle();
|
||||
if (!prompt && view.message?.prompt) this.pumpBattle();
|
||||
}
|
||||
@@ -3190,7 +3239,8 @@ export class GameWorld {
|
||||
private battleB() {
|
||||
const view = this.battle!;
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "yesno") return this.pumpBattle(0);
|
||||
if (prompt?.kind === "yesno" || prompt?.kind === "contestswitch")
|
||||
return this.pumpBattle(0);
|
||||
if (prompt?.kind === "forget") return this.pumpBattle(4);
|
||||
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
|
||||
if (prompt?.kind === "move") return this.pumpBattle(-1);
|
||||
@@ -4046,7 +4096,8 @@ export class GameWorld {
|
||||
}
|
||||
/** Change the highlighted answer (up/down on the original menus). */
|
||||
moveCursor(direction: Direction) {
|
||||
if (this.battle?.prompt?.kind === "yesno") {
|
||||
const battlePrompt = this.battle?.prompt?.kind;
|
||||
if (this.battle && (battlePrompt === "yesno" || battlePrompt === "contestswitch")) {
|
||||
if (direction === "up") this.battle.selected = 1;
|
||||
if (direction === "down") this.battle.selected = 0;
|
||||
return;
|
||||
|
||||
+45
-7
@@ -11,6 +11,7 @@ import { LocalMapView } from "./game/rendering/minimap";
|
||||
import type { Choice, GameWorld } from "./game/world/world";
|
||||
import type { PokegearCard } from "./game/scripts/pokegear";
|
||||
import { drawTile } from "./game/rendering/pixels";
|
||||
import { C } from "./game/battle/constants";
|
||||
import { PokemonData } from "./game/rom/pokemon";
|
||||
import type { Direction, Palette } from "./game/world/types";
|
||||
import type { Button } from "./input/types";
|
||||
@@ -589,6 +590,9 @@ function handleFieldInput(button: Button): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Battle prompts answered with YES/NO. */
|
||||
const yesNoPrompt = (prompt?: { kind: string }) =>
|
||||
prompt?.kind === "yesno" || prompt?.kind === "contestswitch";
|
||||
function battleButtons() {
|
||||
return [
|
||||
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
|
||||
@@ -636,7 +640,7 @@ function handleBattleInput(button: Button): boolean {
|
||||
if (["up", "down", "left", "right"].includes(button)) {
|
||||
const delta = button === "up" || button === "left" ? -1 : 1;
|
||||
battleCursor = (battleCursor + delta + buttons.length) % buttons.length;
|
||||
if (world.battle.prompt?.kind === "yesno")
|
||||
if (yesNoPrompt(world.battle.prompt))
|
||||
world.battle.selected = battleCursor === 0 ? 1 : 0;
|
||||
highlightBattleChoice();
|
||||
return true;
|
||||
@@ -699,7 +703,9 @@ function showBattle(world: GameWorld) {
|
||||
get("battle-text").textContent =
|
||||
prompt?.kind === "name"
|
||||
? prompt.title
|
||||
: prompt?.kind === "party" || prompt?.kind === "move"
|
||||
: prompt?.kind === "party" ||
|
||||
prompt?.kind === "move" ||
|
||||
prompt?.kind === "contestswitch"
|
||||
? prompt.text
|
||||
: (view.message?.text ?? "");
|
||||
if (prompt?.kind !== "action") showMoves = showPack = false;
|
||||
@@ -712,7 +718,7 @@ function showBattle(world: GameWorld) {
|
||||
battleCursor = 0;
|
||||
battleSelectionKey = selectionKey;
|
||||
}
|
||||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||||
if (yesNoPrompt(prompt)) battleCursor = view.selected === 1 ? 0 : 1;
|
||||
const bag = world.liveState.bag;
|
||||
// The Pokémon out has its live battle HP; the others their party values.
|
||||
const party = world.liveState.party.map((mon, index) =>
|
||||
@@ -846,13 +852,45 @@ function showBattle(world: GameWorld) {
|
||||
showWorld(world);
|
||||
}),
|
||||
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
|
||||
button("PACK", () => {
|
||||
showPack = true;
|
||||
showWorld(world);
|
||||
}),
|
||||
// ContestBattleMenu: PARKBALL× with two digits throws a Park Ball.
|
||||
prompt.parkBalls !== undefined
|
||||
? button(
|
||||
`PARKBALL×${String(prompt.parkBalls).padStart(2, "0")}`,
|
||||
() =>
|
||||
runtime.battleAction({
|
||||
action: "item",
|
||||
item: C.PARK_BALL,
|
||||
slot: -1,
|
||||
}),
|
||||
)
|
||||
: button("PACK", () => {
|
||||
showPack = true;
|
||||
showWorld(world);
|
||||
}),
|
||||
button("RUN", () => runtime.battleAction({ action: "run" })),
|
||||
);
|
||||
}
|
||||
} else if (prompt?.kind === "contestswitch") {
|
||||
// DisplayCaughtContestMonStats: STOCK and THIS boxes, then Yes/No.
|
||||
for (const [label, mon] of [
|
||||
["STOCK", prompt.stock],
|
||||
["THIS", prompt.caught],
|
||||
] as const)
|
||||
buttons.push(
|
||||
button(`${label} ${mon.name} :L${mon.level}`, () => {}, {
|
||||
detail: `HEALTH ${mon.maxHp}`,
|
||||
disabled: " ",
|
||||
wide: true,
|
||||
}),
|
||||
);
|
||||
buttons.push(
|
||||
button("YES", () => runtime.battleChoose(1), {
|
||||
pressed: view.selected === 1,
|
||||
}),
|
||||
button("NO", () => runtime.battleChoose(0), {
|
||||
pressed: view.selected === 0,
|
||||
}),
|
||||
);
|
||||
} else if (prompt?.kind === "yesno") {
|
||||
buttons.push(
|
||||
button("YES", () => runtime.battleChoose(1), {
|
||||
|
||||
Reference in new issue
Block a user