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:
1 parent
eb03a20d8f
commit
f8e0f9c65a
20 files changed
+1902
-96
No files matched your search
+403
-36
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user