Finish the opening milestone's switching, items and acceptance run

- Decode evolution lists from the ROM; all 251 match the pinned PRET source.
- Compare CalcMonStats and CalcExpAtLevel against the original routines in
  the reference emulator.
- Add a continuous opening playthrough from the intro to the catching
  tutorial, reloading a JSON save after every stage.
- Port player switching in battle: the PKMN menu, forced choices after a
  faint, Shift mode's offer and double faints, with the original texts.
- Port HP and status healing items in battle through the ROM's ItemEffects,
  HealingHPAmounts and StatusHealingActions tables.
- Add the battle party menu and the Items pocket to the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 11:04:11 +02:00
1 parent eb03a20d8f
commit f8e0f9c65a
20 files changed
+1902 -96

No files matched your search

+403 -36
View File
@@ -108,10 +108,16 @@ export type BattleEffect =
| { kind: "stats"; labels: string[]; values: number[] }
/** The naming screen for a caught Pokémon: answered with the name, or ""
* to keep `current`. */
| { kind: "name"; title: string; current: string; max: number };
| { kind: "name"; title: string; current: string; max: number }
/** The battle party menu: answered with a party index, or -1 to go back
* when `cancel` allows it. With `menu`, the choice goes through the
* SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */
| { kind: "party"; text: string; menu: boolean; cancel: boolean };
export type BattleAction =
| { action: "fight"; move: number }
| { 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). */
| { action: "item"; item: number; slot: number };
export type BattleInput = number | string | BattleAction | undefined;
@@ -349,6 +355,14 @@ export class Battle {
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0;
/** wCurPartyMon: the party index a menu picked. */
private curPartyMon = 0;
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
lastPlayerMon = 0;
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
private enemyIsSwitching = 0;
/** wEnemyHPAtTimeOfPlayerSwitch, which WithdrawMonText compares with. */
private enemyHPAtSwitch = 0;
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
private playerUsedMoves = [0, 0, 0, 0];
private curDamage = 0;
@@ -464,7 +478,24 @@ export class Battle {
case at("Text_MonGainedExpPoint"):
return this.numbers[RAM.stringBuffer2 + 2] ? BATTLE.BoostedExpPointsText : BATTLE.ExpPointsText;
case at("GoMonText"):
case at("DoItMonText"):
case at("GoForItMonText"):
case at("YourFoesWeakGetmMonText"):
return BATTLE.BattleMonNicknameText;
case at("WithdrawMonText.WithdrawMonText"): {
// The enemy's HP lost since the send-out, as a percentage. The
// multiplicand's high byte is left over from earlier arithmetic;
// it is taken as zero here.
const lost = (this.enemyHPAtSwitch - this.enemy.mon.hp) & 0xffff;
const percent = this.hpPercent(lost);
return !percent
? BATTLE.ThatsEnoughComeBackText
: percent < 30
? BATTLE.ComeBackText
: percent < 70
? BATTLE.OKComeBackText
: BATTLE.GoodComeBackText;
}
case at("Text_1_2_and_Poof"):
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
case at("Text_GotchaMonWasCaught"):
@@ -593,8 +624,15 @@ export class Battle {
if (mon.status & (1 << C.PAR)) mon.stats[C.SPEED] = Math.max(1, mon.stats[C.SPEED] >> 2);
if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1);
}
/** NewBattleMonStatus (with ResetPlayerStatLevels) or NewEnemyMonStatus
* at the start of a battle. */
private newStatus(side: Side) {
if (side === this.player) this.playerUsedMoves = [0, 0, 0, 0];
if (side === this.player) {
this.playerUsedMoves = [0, 0, 0, 0];
this.enemy.lastCounterMove = 0;
this.enemy.wrapCount = 0;
this.enemy.reset(SUB5, C.SUBSTATUS_CANT_RUN);
}
side.sub = [0, 0, 0, 0, 0, 0];
side.levels = [7, 7, 7, 7, 7, 7, 7];
side.lastMove = 0;
@@ -661,7 +699,7 @@ export class Battle {
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
this.newStatus(this.player);
this.newStatus(this.enemy);
yield* this.say(BATTLE.GoMonText);
yield* this.sendOutMonText();
yield this.hud();
yield* this.battleTurn();
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
@@ -696,6 +734,7 @@ export class Battle {
private *battleTurn(): Gen {
for (let turns = 0; turns < 1000; turns++) {
this.curDamage = 0;
this.enemyIsSwitching = 0;
this.player.justGotFrozen = 0;
this.enemy.justGotFrozen = 0;
this.updateBattleMonInParty();
@@ -817,6 +856,10 @@ export class Battle {
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
return false;
}
if (input.action === "pkmn") {
if (yield* this.battleMenuPKMN()) return false;
continue;
}
if (input.action === "run") {
const escaped = yield* this.tryToRunAway();
if (escaped) {
@@ -832,12 +875,141 @@ export class Battle {
return false;
}
}
/** BattleMenu_Pack with BattlePack's USE on a Balls-pocket slot. Returns
* true if the item was used, which takes the player's turn. */
/** BattleMenu_Pack with BattlePack's USE on a Balls- or Items-pocket
* slot. Returns true if the item was used, which takes the player's turn
* (wItemEffectSucceeded shares wBattlePlayerAction's byte). */
private *battleMenuPack(item: number, slot: number): Gen<boolean> {
const pocket = this.options.bag?.balls;
const data = this.options.data,
kind = data.itemPocket(item);
const pocket = kind === "ball" ? this.options.bag?.balls : kind === "item" ? this.options.bag?.items : undefined;
if (!pocket || !Number.isInteger(slot) || pocket[slot]?.item !== item) return false;
return yield* this.pokeBallEffect(item, slot);
if (kind === "ball") return yield* this.pokeBallEffect(item, slot);
// _DoItemEffect names the item in wStringBuffer1 and wStringBuffer2.
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
// CheckItemContext: the low nibble of the item's help byte.
const context = this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 6) & 0x0f;
if (context < C.ITEMMENU_CURRENT) {
yield* this.say(BATTLE.OakThisIsntTheTimeText);
return false;
}
// ItemEffects: the routine this item runs.
const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects));
if (context === C.ITEMMENU_PARTY && effect === BATTLE.RestoreHPEffect)
return yield* this.restoreHPEffect(item, slot);
if (context === C.ITEMMENU_PARTY && effect === BATTLE.StatusHealingEffect)
return yield* this.statusHealingEffect(item, slot);
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
}
/** UseItem_SelectMon: the party menu for an item. Returns the party
* index, or -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon(): Gen<number> {
const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true);
if (slot < 0) return -1;
if (this.options.party[slot].species === C.EGG) {
yield* this.say(BATTLE.ItemCantUseOnEggText);
return -1;
}
return slot;
}
/** ItemRestoreHP (RestoreHPEffect): HealingHPAmounts, up to the maximum.
* The Pokémon out takes the party HP, which UpdateBattleMonInParty
* copied at the start of the turn. */
private *restoreHPEffect(item: number, slot: number): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target];
if (!mon.hp || mon.hp >= mon.stats[0]) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
const r = this.reader;
let amount: number | undefined;
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
if (r.byte(p) === item) amount = r.word(p + 1);
if (amount === undefined) throw new UnsupportedBattle("Missing healing amount.");
// RestoreHealth: a 16-bit sum that overflows or passes the maximum is full.
const before = mon.hp,
sum = mon.hp + amount;
mon.hp = sum > 0xffff || sum > mon.stats[0] ? mon.stats[0] : sum;
if (target === this.curBattleMon) this.player.mon.hp = mon.hp;
yield this.hud();
this.numbers[RAM.curHPAnimDeltaHP] = mon.hp - before;
yield* this.partyMenuActionText(target, C.PARTYMENUTEXT_HEAL_HP);
this.tossItem("items", item, slot);
return true;
}
/** StatusHealingEffect and UseStatusHealer, with StatusHealingActions. */
private *statusHealingEffect(item: number, slot: number): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target],
r = this.reader;
let p = BATTLE.StatusHealingActions;
while (r.byte(p) !== item) {
if (r.byte(p) === 0xff) throw new UnsupportedBattle("Missing status healing action.");
p += 3;
}
let text = r.byte(p + 1);
const mask = r.byte(p + 2),
battleMon = target === this.curBattleMon;
if (!mon.hp) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
if (!(mon.status & mask)) {
// IsItemUsedOnConfusedMon: a full heal still cures the confusion of
// the Pokémon out.
if (!(battleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED))) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
text = C.PARTYMENUTEXT_HEAL_CONFUSION;
}
mon.status = 0;
if (battleMon) {
// HealStatus
this.player.mon.status = 0;
this.player.reset(SUB5, C.SUBSTATUS_TOXIC);
this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
this.calcPlayerStats();
}
yield this.hud();
yield* this.partyMenuActionText(target, text);
this.tossItem("items", item, slot);
return true;
}
/** CalcPlayerStats: stat stages, then the paralysis and burn penalties
* for the side that hBattleTurn names after BattleCommand_SwitchTurn,
* which is the enemy if hBattleTurn held the enemy's turn. */
private calcPlayerStats() {
const player = this.player;
for (let stat = 0; stat < 5; stat++) {
const [numerator, denominator] = this.data.statMultiplier(player.levels[stat]);
const value = Math.floor((player.stats[stat] * numerator) / denominator);
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
}
const side = this.turn === 0 ? this.player : this.enemy;
// hBattleTurn at the battle menu is not modelled everywhere (AIDamageCalc
// leaves it on the enemy), so a penalty that would hit the enemy again
// is refused rather than guessed.
if (side === this.enemy && this.enemy.mon.status & ((1 << C.PAR) | (1 << C.BRN)))
throw new UnsupportedBattle("Healing status while the enemy is paralyzed or burned is not ported yet.");
this.applyStatusEffects(side);
}
/** PrintPartyMenuActionText with the Pokémon's nickname. */
private *partyMenuActionText(target: number, text: number): Gen {
this.strings[RAM.stringBuffer1] = this.options.party[target].nickname;
const table = BATTLE["PrintPartyMenuActionText.MenuActionTexts"];
yield* this.say(this.reader.near(table + (text & 0x0f) * 2, this.reader.bank(table)));
}
/** UseDisposableItem: toss one from the pocket slot it was used from. */
private tossItem(pocketName: "items" | "balls", item: number, slot: number) {
const pocket = this.options.bag?.[pocketName];
if (!pocket) throw new UnsupportedBattle("Missing pack.");
const index = pocket[slot]?.item === item ? slot : pocket.findIndex((entry) => entry.item === item);
if (index < 0) throw new UnsupportedBattle("The item is not in the pack.");
if (!--pocket[index].quantity) pocket.splice(index, 1);
}
/** PokeBallEffect (engine/items/item_effects.asm). `slot` is the pocket
@@ -853,7 +1025,7 @@ export class Battle {
// UseDisposableItem. The turn is used.
yield* this.say(BATTLE.BallBlockedText);
yield* this.say(BATTLE.BallDontBeAThiefText);
this.tossBall(item, slot);
this.tossItem("balls", item, slot);
return true;
}
// With a full party the Pokémon would go to the current box; if that is
@@ -912,7 +1084,7 @@ export class Battle {
BATTLE.BallSoCloseText,
][wobbles - 1],
);
if (!tutorial) this.tossBall(item, slot);
if (!tutorial) this.tossItem("balls", item, slot);
return true;
}
@@ -946,7 +1118,7 @@ export class Battle {
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
this.tossBall(item, slot);
this.tossItem("balls", item, slot);
return true;
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
@@ -972,7 +1144,7 @@ export class Battle {
};
this.options.party.push(mon);
mon.nickname = yield* this.askNickname(name);
this.tossBall(item, slot);
this.tossItem("balls", item, slot);
return true;
}
/** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the
@@ -1026,13 +1198,6 @@ export class Battle {
}
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
* selected slot if it matches, otherwise from the first matching slot. */
private tossBall(item: number, slot: number) {
const pocket = this.options.bag?.balls;
if (!pocket) throw new UnsupportedBattle("Missing pack.");
let index = pocket[slot]?.item === item ? slot : pocket.findIndex((entry) => entry.item === item);
if (index < 0) throw new UnsupportedBattle("The ball is not in the pack.");
if (!--pocket[index].quantity) pocket.splice(index, 1);
}
/** MoveSelectionScreen's checks for a chosen move. */
private *moveSelection(index: number): Gen<boolean> {
@@ -1077,6 +1242,14 @@ export class Battle {
const player = this.player;
if (this.checkPlayerLockedIn() || player.has(SUB5, C.SUBSTATUS_ENCORED) || player.has(SUB3, C.SUBSTATUS_BIDE))
throw new UnsupportedBattle("Locked-in, encored and Bide moves are not ported yet.");
if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) {
// PlayerSwitch already ran ParseEnemyAction.
player.furyCutterCount = 0;
player.protectCount = 0;
player.rageCounter = 0;
player.reset(SUB4, C.SUBSTATUS_RAGE);
return true;
}
if (this.battlePlayerAction === 0) {
this.turn = 0;
this.updateMoveData();
@@ -1089,6 +1262,7 @@ export class Battle {
if (player.move.effect !== C.EFFECT_PROTECT && player.move.effect !== C.EFFECT_ENDURE)
player.protectCount = 0;
} else {
player.reset(SUB3, C.SUBSTATUS_BIDE);
player.furyCutterCount = 0;
player.protectCount = 0;
player.rageCounter = 0;
@@ -1099,6 +1273,7 @@ export class Battle {
}
/** ParseEnemyAction: use the trainer AI selection or a random wild move. */
private parseEnemyAction() {
if (this.enemyIsSwitching) return;
const enemy = this.enemy,
mon = enemy.mon;
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn())
@@ -1279,8 +1454,9 @@ export class Battle {
this.battleEnded = true;
}
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape. */
private *tryToRunAway(): Gen<boolean> {
/** 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> {
if (this.options.trainer) {
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
return false;
@@ -1292,8 +1468,7 @@ export class Battle {
if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_ESCAPE)
throw new UnsupportedBattle("Escape items are not ported yet.");
this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff;
const playerSpeed = this.player.mon.stats[C.SPEED],
enemySpeed = this.enemy.mon.stats[C.SPEED];
const enemySpeed = this.enemy.mon.stats[C.SPEED];
let escaped = playerSpeed >= enemySpeed;
if (!escaped) {
const divisor = (enemySpeed >> 2) & 0xff;
@@ -2390,36 +2565,61 @@ export class Battle {
this.battleEnded = true;
return;
}
// AskUseNextPokemon and ForcePlayerMonChoice.
if (!this.player.mon.hp) throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
if (!this.player.mon.hp) {
// Trainer battles never ask, so both sides send out a new Pokémon.
yield* this.askUseNextPokemon();
yield* this.forcePlayerMonChoice();
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
return yield* this.doubleSwitch();
}
// HandleEnemySwitch: the player's Pokémon stays in.
yield* this.enemyPartyMonEntrance();
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
yield* this.enemyPartyMonEntrance(false);
this.battlePlayerAction = 0;
}
/** CheckEnemyTrainerDefeated: every party HP is zero. */
private enemyTrainerDefeated() {
return this.options.trainer!.party.every((mon) => mon.hp === 0);
}
/** EnemyPartyMonEntrance after a faint, while the player's Pokémon stands. */
private *enemyPartyMonEntrance(): Gen {
/** EnemyPartyMonEntrance. `setMode` skips Shift mode's offer, as after a
* double faint. */
private *enemyPartyMonEntrance(setMode: boolean): Gen {
this.enemySwitchMonIndex = 0;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels
this.breakAttraction();
yield* this.enemySwitch();
if (setMode) yield* this.enemySwitchSetMode();
else yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
this.turn = 1;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
this.enemy.move.anim = 0;
this.battlePlayerAction = 0;
}
/** EnemySwitch and EnemySwitch_SetMode. The Shift-mode offer to switch
* too (CheckWhetherToAskSwitch) needs player switching and is refused. */
/** EnemySwitch: in Shift mode the player may switch too (OfferSwitch). */
private *enemySwitch(): Gen {
if (!this.battleHasJustStarted && this.options.party.length > 1 && this.partyMon.hp)
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
if (!this.checkWhetherToAskSwitch()) return yield* this.enemySwitchSetMode();
this.loadEnemyMonToSwitchTo();
const switching = yield* this.offerSwitch();
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
if (!switching) return;
this.participants = 0;
this.battlePlayerAction = 0;
this.enemyIsSwitching = 1;
yield* this.playerSwitch();
}
private *enemySwitchSetMode(): Gen {
this.loadEnemyMonToSwitchTo();
this.enemyIsSwitching = 1;
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
}
/** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the
* type-matchup choice) and LoadEnemyMonToSwitchTo. */
private loadEnemyMonToSwitchTo() {
this.resetEnemyBattleVars();
// CheckWhetherSwitchmonIsPredetermined, else the type-matchup choice.
const index = this.enemySwitchMonIndex
? this.enemySwitchMonIndex - 1
: this.battleHasJustStarted
@@ -2427,11 +2627,172 @@ export class Battle {
: this.findMonInOTPartyToSwitchIntoBattle();
if (!this.options.trainer!.party[index]?.hp)
throw new UnsupportedBattle("Invalid trainer switch.");
// LoadEnemyMonToSwitchTo: LoadEnemyMon records wCurOTMon.
// LoadEnemyMon records wCurOTMon.
this.curOTMon = index;
this.loadEnemyMon();
yield* this.say(BATTLE.BattleText_EnemySentOut);
}
/** CheckWhetherToAskSwitch: Shift mode (the only battle style so far)
* asks when the player has another Pokémon and its own is still up. */
private checkWhetherToAskSwitch() {
return (
!this.battleHasJustStarted &&
this.options.party.length > 1 &&
this.partyMon.hp > 0
);
}
/** OfferSwitch. Returns true if the player chose a Pokémon to send out. */
private *offerSwitch(): Gen<boolean> {
yield* this.say(BATTLE.BattleText_EnemyIsAboutToUseWillPlayerChangeMon);
if (!(yield { kind: "yesno", text: "" })) return false;
const slot = yield* this.pickSwitchMon();
if (slot < 0) return false;
this.lastPlayerMon = this.curBattleMon;
this.curBattleMon = slot;
this.enemy.curMove = 0;
this.player.curMove = 0;
return true;
}
// ------------------------------------------------------------ switching
/** The battle party menu. Returns a party index, or -1 if cancelled;
* without `cancel`, B is refused (ForcePickPartyMonInBattle's SFX_WRONG). */
private *partyMenu(text: number, menu: boolean, cancel: boolean): Gen<number> {
const prompt = readString(this.reader, text, this.context());
for (;;) {
const answer = yield { kind: "party", text: prompt, menu, cancel };
if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length)
return (this.curPartyMon = answer);
if (cancel) return -1;
}
}
/** CheckIfCurPartyMonIsFitToFight, with its texts. */
private *checkFitToFight(slot: number): Gen<boolean> {
const mon = this.options.party[slot];
if (mon.hp) return true;
if (!this.battleHasJustStarted)
yield* this.say(mon.species === C.EGG ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
return false;
}
/** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */
private *pickSwitchMon(): Gen<number> {
for (;;) {
const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, true);
if (slot < 0) return -1;
if (!(yield* this.checkFitToFight(slot))) continue;
if (slot === this.curBattleMon) {
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
continue;
}
return slot;
}
}
/** BattleMenu_PKMN with BattleMonMenu's SWITCH, then TryPlayerSwitch.
* Returns true if the player switched, which takes the turn. STATS is
* a presentation screen, and CANCEL returns to the party menu. */
private *battleMenuPKMN(): Gen<boolean> {
for (;;) {
const slot = yield* this.partyMenu(BATTLE.ChooseAMonString, true, true);
if (slot < 0) return false;
if (slot === this.curBattleMon) {
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
continue;
}
if (this.player.wrapCount || this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN)) {
yield* this.say(BATTLE.BattleText_MonCantBeRecalled);
continue;
}
if (!(yield* this.checkFitToFight(slot))) continue;
this.lastPlayerMon = this.curBattleMon;
this.battlePlayerAction = C.BATTLEPLAYERACTION_SWITCH;
this.curBattleMon = slot;
yield* this.playerSwitch();
return true;
}
}
/** PlayerSwitch: the enemy chooses its move, then BattleMonEntrance. */
private *playerSwitch(): Gen {
this.parseEnemyAction();
yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]);
this.player.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 1;
// PursuitSwitch reads the effect of wCurEnemyMove as GetMoveEffect
// does, even for no move (0 reads past the table).
const r = this.reader;
if (r.byte(SYMBOLS.moves + ((this.enemy.curMove - 1) & 0xff) * 7 + 1) === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
this.curPartyMon = this.curBattleMon;
yield* this.sendOutPlayerMon();
}
/** PlayerPartyMonEntrance: send out wCurPartyMon. */
private *playerPartyMonEntrance(): Gen {
this.lastPlayerMon = this.curBattleMon;
this.curBattleMon = this.curPartyMon;
yield* this.sendOutPlayerMon();
}
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
* PlayerPartyMonEntrance. */
private *sendOutPlayerMon(): Gen {
this.participants |= 1 << this.curBattleMon; // AddBattleParticipant
this.initBattleMon();
yield* this.sendOutMonText();
this.newStatus(this.player); // with ResetPlayerStatLevels
this.breakAttraction();
yield this.hud();
this.turn = 0;
if (this.player.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
}
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
private *sendOutMonText(): Gen {
const hp = this.enemy.mon.hp;
let text: number = BATTLE.GoMonText;
if (hp) {
this.enemyHPAtSwitch = hp;
const percent = this.hpPercent(hp);
if (percent < 70)
text =
percent >= 40
? BATTLE.DoItMonText
: percent >= 10
? BATTLE.GoForItMonText
: BATTLE.YourFoesWeakGetmMonText;
}
yield* this.say(text);
}
/** HP × 25 / (max HP / 4), low byte, as the send-out and withdraw texts
* compute it. A maximum HP below 4 divides by zero, which freezes the
* original. */
private hpPercent(hp: number) {
const divisor = (this.enemy.mon.maxHp >> 2) & 0xff;
if (!divisor) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
return Math.floor((hp * 25) / divisor) & 0xff;
}
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
* with the first party Pokémon's Speed. Returns true if the player fled. */
private *askUseNextPokemon(): Gen<boolean> {
if (this.battleMode !== C.WILD_BATTLE) return false;
yield* this.say(BATTLE.BattleText_UseNextMon);
if (yield { kind: "yesno", text: "" }) return false;
return yield* this.tryToRunAway(this.options.party[0].stats[3]);
}
/** ForcePlayerMonChoice. Returns false when the enemy fainted too: the
* choice is then sent out by DoubleSwitch. */
private *forcePlayerMonChoice(): Gen<boolean> {
for (;;) {
const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, false);
if (yield* this.checkFitToFight(slot)) break;
}
this.battlePlayerAction = 0;
if (!this.enemy.mon.hp) return false;
yield* this.playerPartyMonEntrance();
return true;
}
/** DoubleSwitch: the player's choice, then the trainer's next Pokémon. */
private *doubleSwitch(): Gen {
yield* this.playerPartyMonEntrance();
yield* this.enemyPartyMonEntrance(true);
this.battlePlayerAction = 0;
}
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
private resetEnemyBattleVars() {
@@ -2753,7 +3114,13 @@ export class Battle {
return;
}
}
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
if (yield* this.askUseNextPokemon()) {
this.battleEnded = true;
return;
}
if (yield* this.forcePlayerMonChoice()) return;
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
yield* this.doubleSwitch();
}
private updateFaintedPlayerMon() {
this.participants &= ~(1 << this.curBattleMon);