Port the remaining battle items, the stats screen and hBattleTurn at the menu
- Run every non-ball item effect usable in battle from the ROM's ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll. - Read happiness changes from HappinessChanges, including the larger level-up gain where a Pokémon was caught. - Model hBattleTurn at the battle menu, including the trainer AI's writes, and drop the status-heal refusal. - Port the stats screen for battles and Bill's PC. - Compare the send-out and withdraw texts, RestoreHealth, RestorePP, ChangeHappiness and CalcPlayerStats with the original routines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cfd083c16d
commit
b13e09ec20
22 files changed
+1547
-171
No files matched your search
+375
-107
@@ -14,6 +14,7 @@ import {
|
||||
PARTY_LENGTH,
|
||||
calcStats,
|
||||
levelForExp,
|
||||
maxPP,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
||||
@@ -112,7 +113,11 @@ export type BattleEffect =
|
||||
/** 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 };
|
||||
| { 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[] };
|
||||
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
|
||||
type ItemUse = { item: number; slot: number };
|
||||
export type BattleAction =
|
||||
| { action: "fight"; move: number }
|
||||
| { action: "run" }
|
||||
@@ -162,6 +167,8 @@ export interface BattleOptions {
|
||||
caughtData?: (level: number) => number;
|
||||
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
|
||||
tutorial?: boolean;
|
||||
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||||
landmark?: number;
|
||||
}
|
||||
|
||||
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
||||
@@ -324,6 +331,63 @@ export function catchRate(input: {
|
||||
return chance;
|
||||
}
|
||||
|
||||
/** HP × 25 / (max HP / 4) as a byte, as SendOutMonText and WithdrawMonText
|
||||
* compute it. Undefined when max HP / 4 is zero: the original divides by
|
||||
* zero and freezes. */
|
||||
function cheerPercent(hp: number, maxHp: number): number | undefined {
|
||||
const divisor = (maxHp >> 2) & 0xff;
|
||||
return divisor ? Math.floor((hp * 25) / divisor) & 0xff : undefined;
|
||||
}
|
||||
/** SendOutMonText's cheer from the enemy's remaining HP ("Go!" when it
|
||||
* has fainted). */
|
||||
export function sendOutText(hp: number, maxHp: number) {
|
||||
if (!hp) return "GoMonText" as const;
|
||||
const percent = cheerPercent(hp, maxHp);
|
||||
if (percent === undefined) return undefined;
|
||||
return percent >= 70
|
||||
? ("GoMonText" as const)
|
||||
: percent >= 40
|
||||
? ("DoItMonText" as const)
|
||||
: percent >= 10
|
||||
? ("GoForItMonText" as const)
|
||||
: ("YourFoesWeakGetmMonText" as const);
|
||||
}
|
||||
/** WithdrawMonText's choice from the HP lost since the send-out, in 16
|
||||
* bits. The multiplicand's high byte is left over from earlier arithmetic
|
||||
* in the original; it is taken as zero here. */
|
||||
export function withdrawText(hpAtSwitch: number, hp: number, maxHp: number) {
|
||||
const percent = cheerPercent((hpAtSwitch - hp) & 0xffff, maxHp);
|
||||
if (percent === undefined) return undefined;
|
||||
return !percent
|
||||
? ("ThatsEnoughComeBackText" as const)
|
||||
: percent < 30
|
||||
? ("ComeBackText" as const)
|
||||
: percent < 70
|
||||
? ("OKComeBackText" as const)
|
||||
: ("GoodComeBackText" as const);
|
||||
}
|
||||
/** RestoreHealth: a 16-bit sum that overflows or passes the maximum is
|
||||
* the maximum. */
|
||||
export function restoreHealth(hp: number, maxHp: number, amount: number) {
|
||||
const sum = hp + amount;
|
||||
return sum > 0xffff || sum > maxHp ? maxHp : sum;
|
||||
}
|
||||
/** RestorePP's new PP byte (PP Ups kept), or undefined when the move's PP
|
||||
* is already at `max` (GetMaxPPOfMove). MAX items fill the move; others add
|
||||
* 10, or 5 for the MysteryBerry. */
|
||||
export function restoredPP(pp: number, max: number, item: number) {
|
||||
const current = pp & C.PP_MASK;
|
||||
if (current >= max) return undefined;
|
||||
const full = item === C.MAX_ELIXER || item === C.MAX_ETHER;
|
||||
return (pp & ~C.PP_MASK) | (full ? max : Math.min(max, current + (item === C.MYSTERYBERRY ? 5 : 10)));
|
||||
}
|
||||
/** ChangeHappiness' arithmetic with a HappinessChanges byte: bytes from
|
||||
* $64 up (`cp $64`) are decreases that stop at zero. */
|
||||
export function changedHappiness(happiness: number, delta: number) {
|
||||
const sum = happiness + delta;
|
||||
return delta >= 0x64 ? (sum > 0xff ? sum & 0xff : 0) : Math.min(0xff, sum);
|
||||
}
|
||||
|
||||
export class Battle {
|
||||
readonly data: BattleData;
|
||||
readonly player = new Side();
|
||||
@@ -338,7 +402,10 @@ export class Battle {
|
||||
battlePlayerAction = 0;
|
||||
enemyGoesFirst = 0;
|
||||
numFleeAttempts = 0;
|
||||
/** wBattleParticipantsNotFainted, which shares the experience. */
|
||||
participants = 0;
|
||||
/** wBattleParticipantsIncludingFainted, which revives consult. */
|
||||
private participantsAll = 0;
|
||||
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
|
||||
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
|
||||
weather: number = C.WEATHER_NONE;
|
||||
@@ -357,6 +424,9 @@ export class Battle {
|
||||
private battleHasJustStarted = 0;
|
||||
/** wCurPartyMon: the party index a menu picked. */
|
||||
private curPartyMon = 0;
|
||||
/** wPartyMenuCursor: the 1-based cursor of the last party menu, which
|
||||
* no ported field menu sets before a battle. */
|
||||
private partyMenuCursor = 0;
|
||||
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
|
||||
lastPlayerMon = 0;
|
||||
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
|
||||
@@ -434,11 +504,12 @@ export class Battle {
|
||||
asm: (offset) => this.textAsm(offset),
|
||||
};
|
||||
}
|
||||
/** StdBattleTextbox / BattleTextbox with a ROM text. */
|
||||
private *say(address: number): Gen {
|
||||
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
|
||||
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
|
||||
private *say(address: number, button = false): Gen {
|
||||
const { text, prompt } = readTextEnd(this.reader, address, this.context());
|
||||
// The tutorial's auto input presses A at every prompt.
|
||||
const waits = prompt && this.battleType !== C.BATTLETYPE_TUTORIAL;
|
||||
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
|
||||
for (const page of text.split("\n\n").filter(Boolean))
|
||||
yield { kind: "text", text: page, prompt: waits };
|
||||
}
|
||||
@@ -483,18 +554,9 @@ export class Battle {
|
||||
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;
|
||||
const text = withdrawText(this.enemyHPAtSwitch, this.enemy.mon.hp, this.enemy.mon.maxHp);
|
||||
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||||
return BATTLE[text];
|
||||
}
|
||||
case at("Text_1_2_and_Poof"):
|
||||
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
||||
@@ -693,7 +755,7 @@ export class Battle {
|
||||
yield* this.say(BATTLE.WantsToBattleText);
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
} else yield* this.say(BATTLE.WildPokemonAppearedText);
|
||||
this.participants = 1 << this.curBattleMon;
|
||||
this.resetBattleParticipants();
|
||||
this.initBattleMon();
|
||||
const amuletCoin =
|
||||
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
|
||||
@@ -701,6 +763,7 @@ export class Battle {
|
||||
this.newStatus(this.enemy);
|
||||
yield* this.sendOutMonText();
|
||||
yield this.hud();
|
||||
this.turn = 0; // SetPlayerTurn before SpikesDamage
|
||||
yield* this.battleTurn();
|
||||
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
||||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
@@ -749,6 +812,7 @@ export class Battle {
|
||||
});
|
||||
this.enemy.moveNum = choice.index;
|
||||
this.enemy.curMove = choice.move;
|
||||
if (choice.enemyTurn) this.turn = 1;
|
||||
}
|
||||
if (!this.checkPlayerLockedIn()) {
|
||||
for (;;) {
|
||||
@@ -892,14 +956,65 @@ export class Battle {
|
||||
yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
||||
return false;
|
||||
}
|
||||
// ItemEffects: the routine this item runs.
|
||||
// ItemEffects: the routine this item runs, chosen by its ROM address.
|
||||
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);
|
||||
const use = { item, slot };
|
||||
if (context === C.ITEMMENU_PARTY) {
|
||||
// BattlePack's .BattleField: anything but success stays in the pack.
|
||||
switch (effect) {
|
||||
case BATTLE.RestoreHPEffect:
|
||||
return yield* this.statusHealerResult(yield* this.itemRestoreHP(use));
|
||||
case BATTLE.StatusHealingEffect:
|
||||
return yield* this.statusHealingEffect(use);
|
||||
case BATTLE.FullRestoreEffect:
|
||||
return yield* this.fullRestoreEffect(use);
|
||||
case BATTLE.ReviveEffect:
|
||||
return yield* this.reviveEffect(use, 0);
|
||||
case BATTLE.RevivalHerbEffect:
|
||||
return yield* this.reviveEffect(use, C.HAPPINESS_REVIVALHERB);
|
||||
case BATTLE.EnergypowderEffect:
|
||||
case BATTLE.EnergyRootEffect:
|
||||
return yield* this.bitterRestoreHP(use, effect === BATTLE.EnergyRootEffect ? C.HAPPINESS_ENERGYROOT : C.HAPPINESS_BITTERPOWDER);
|
||||
case BATTLE.HealPowderEffect:
|
||||
return yield* this.healPowderEffect(use);
|
||||
case BATTLE.BitterBerryEffect:
|
||||
return yield* this.bitterBerryEffect(use);
|
||||
case BATTLE.RestorePPEffect:
|
||||
return yield* this.restorePPEffect(use);
|
||||
}
|
||||
} else if (context !== C.ITEMMENU_CURRENT) {
|
||||
// BattlePack's .BattleOnly: failure gives Oak's warning, and the
|
||||
// "won't have any effect" value 2 returns to the pack.
|
||||
let result: number | undefined;
|
||||
switch (effect) {
|
||||
case BATTLE.XItemEffect:
|
||||
result = yield* this.xItemEffect(use);
|
||||
break;
|
||||
case BATTLE.XAccuracyEffect:
|
||||
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_X_ACCURACY);
|
||||
break;
|
||||
case BATTLE.GuardSpecEffect:
|
||||
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_MIST);
|
||||
break;
|
||||
case BATTLE.DireHitEffect:
|
||||
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_FOCUS_ENERGY);
|
||||
break;
|
||||
case BATTLE.PokeDollEffect:
|
||||
result = yield* this.pokeDollEffect(use);
|
||||
break;
|
||||
}
|
||||
if (result !== undefined) {
|
||||
if (!result) yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
||||
return result === 1;
|
||||
}
|
||||
}
|
||||
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
|
||||
}
|
||||
/** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */
|
||||
private *statusHealerResult(code: number): Gen<boolean> {
|
||||
if (code === 1) yield* this.say(BATTLE.ItemWontHaveEffectText);
|
||||
return code === 0;
|
||||
}
|
||||
/** 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> {
|
||||
@@ -911,77 +1026,231 @@ export class Battle {
|
||||
}
|
||||
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> {
|
||||
/** UseItemText: "<PLAYER> used <ITEM>!", a button, then UseDisposableItem. */
|
||||
private *useItemText({ item, slot }: ItemUse): Gen {
|
||||
yield* this.say(BATTLE.ItemUsedText, true);
|
||||
this.tossItem("items", item, slot);
|
||||
}
|
||||
/** ItemRestoreHP: HealingHPAmounts, up to the maximum. The Pokémon out
|
||||
* takes the party HP, which UpdateBattleMonInParty copied at the start of
|
||||
* the turn. Returns the StatusHealer_Jumptable value. */
|
||||
private *itemRestoreHP({ item, slot }: ItemUse): Gen<number> {
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
if (target < 0) return 2;
|
||||
const mon = this.options.party[target];
|
||||
if (!mon.hp || mon.hp >= mon.stats[0]) {
|
||||
yield* this.say(BATTLE.ItemWontHaveEffectText);
|
||||
return false;
|
||||
}
|
||||
if (!mon.hp || mon.hp >= mon.stats[0]) return 1;
|
||||
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);
|
||||
yield* this.restoreHP(target, restoreHealth(mon.hp, mon.stats[0], amount), C.PARTYMENUTEXT_HEAL_HP);
|
||||
this.tossItem("items", item, slot);
|
||||
return true;
|
||||
return 0;
|
||||
}
|
||||
/** StatusHealingEffect and UseStatusHealer, with StatusHealingActions. */
|
||||
private *statusHealingEffect(item: number, slot: number): Gen<boolean> {
|
||||
/** The HP change, BattlemonRestoreHealth and the party menu action text
|
||||
* with wCurHPAnimDeltaHP. */
|
||||
private *restoreHP(target: number, hp: number, text: number): Gen {
|
||||
const mon = this.options.party[target];
|
||||
this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp;
|
||||
mon.hp = hp;
|
||||
if (target === this.curBattleMon) this.player.mon.hp = hp;
|
||||
yield this.hud();
|
||||
yield* this.partyMenuActionText(target, text);
|
||||
}
|
||||
/** EnergypowderEffect and EnergyRootEffect: ItemRestoreHP, then the
|
||||
* bitter taste costs happiness. */
|
||||
private *bitterRestoreHP(use: ItemUse, happiness: number): Gen<boolean> {
|
||||
const code = yield* this.itemRestoreHP(use);
|
||||
if (!code) {
|
||||
this.changeHappiness(this.curPartyMon, happiness);
|
||||
yield* this.say(BATTLE.ItemLooksBitterText);
|
||||
}
|
||||
return yield* this.statusHealerResult(code);
|
||||
}
|
||||
/** StatusHealingEffect. */
|
||||
private *statusHealingEffect(use: ItemUse): Gen<boolean> {
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const mon = this.options.party[target],
|
||||
r = this.reader;
|
||||
return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target));
|
||||
}
|
||||
/** HealPowderEffect: a status heal that tastes bitter. */
|
||||
private *healPowderEffect(use: ItemUse): Gen<boolean> {
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const code = yield* this.useStatusHealer(use, target);
|
||||
if (!code) {
|
||||
this.changeHappiness(target, C.HAPPINESS_BITTERPOWDER);
|
||||
yield* this.say(BATTLE.ItemLooksBitterText);
|
||||
}
|
||||
return yield* this.statusHealerResult(code);
|
||||
}
|
||||
/** GetItemHealingAction: StatusHealingActions' text and status mask. */
|
||||
private healingAction(item: number) {
|
||||
const 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;
|
||||
}
|
||||
return { text: r.byte(p + 1), mask: r.byte(p + 2) };
|
||||
}
|
||||
/** UseStatusHealer: 0 if used, 1 if it would have no effect. */
|
||||
private *useStatusHealer({ item, slot }: ItemUse, target: number): Gen<number> {
|
||||
const mon = this.options.party[target];
|
||||
if (!mon.hp) return 1;
|
||||
let { text, mask } = this.healingAction(item);
|
||||
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;
|
||||
}
|
||||
if (!(target === this.curBattleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED)))
|
||||
return 1;
|
||||
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();
|
||||
}
|
||||
this.healStatus(target, mask);
|
||||
yield this.hud();
|
||||
yield* this.partyMenuActionText(target, text);
|
||||
this.tossItem("items", item, slot);
|
||||
return 0;
|
||||
}
|
||||
/** HealStatus: the Pokémon out loses its status, Toxic and Nightmare
|
||||
* (and confusion with a full heal), then CalcPlayerStats. */
|
||||
private healStatus(target: number, mask: number) {
|
||||
if (target !== this.curBattleMon) return;
|
||||
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();
|
||||
}
|
||||
/** FullRestoreEffect: full HP and no status; at full HP only the status. */
|
||||
private *fullRestoreEffect(use: ItemUse): Gen<boolean> {
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const mon = this.options.party[target];
|
||||
if (!mon.hp) return yield* this.statusHealerResult(1);
|
||||
if (mon.hp >= mon.stats[0]) return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target));
|
||||
mon.status = 0;
|
||||
this.healStatus(target, this.healingAction(use.item).mask);
|
||||
yield* this.restoreHP(target, mon.stats[0], C.PARTYMENUTEXT_HEAL_HP);
|
||||
this.tossItem("items", use.item, use.slot);
|
||||
return true;
|
||||
}
|
||||
/** ReviveEffect and RevivalHerbEffect (RevivePokemon). A revived
|
||||
* Pokémon that took part in the battle shares the experience again. */
|
||||
private *reviveEffect(use: ItemUse, happiness: number): Gen<boolean> {
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const mon = this.options.party[target];
|
||||
if (mon.hp) return yield* this.statusHealerResult(1);
|
||||
if (this.participantsAll & (1 << target)) this.participants |= 1 << target;
|
||||
// ReviveHalfHP halves the maximum HP; the others restore all of it.
|
||||
yield* this.restoreHP(target, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0], C.PARTYMENUTEXT_REVIVE);
|
||||
this.tossItem("items", use.item, use.slot);
|
||||
if (happiness) {
|
||||
this.changeHappiness(target, happiness);
|
||||
yield* this.say(BATTLE.ItemLooksBitterText);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/** BitterBerryEffect: cures the confusion of the Pokémon out. */
|
||||
private *bitterBerryEffect(use: ItemUse): Gen<boolean> {
|
||||
if (!this.player.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.statusHealerResult(1);
|
||||
this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||||
this.turn = 0;
|
||||
yield* this.useItemText(use);
|
||||
yield* this.say(BATTLE.ConfusedNoMoreText);
|
||||
return true;
|
||||
}
|
||||
/** RestorePPEffect for Ethers, Elixers and the MysteryBerry. Choosing no
|
||||
* move goes back to the party menu. */
|
||||
private *restorePPEffect(use: ItemUse): Gen<boolean> {
|
||||
const data = this.options.data,
|
||||
item = use.item;
|
||||
for (;;) {
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const mon = this.options.party[target];
|
||||
let restored = false;
|
||||
if (item === C.ELIXER || item === C.MAX_ELIXER) {
|
||||
for (let slot = 0; slot < 4; slot++)
|
||||
if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true;
|
||||
} else {
|
||||
const choice = yield {
|
||||
kind: "move",
|
||||
text: readTextEnd(this.reader, BATTLE.RestoreThePPOfWhichMoveText, this.context()).text,
|
||||
moves: [...mon.moves],
|
||||
pp: [...mon.pp],
|
||||
};
|
||||
if (typeof choice !== "number" || choice < 0 || choice > 3 || !mon.moves[choice]) continue;
|
||||
// GetMoveName and CopyName1: the move's name in wStringBuffer2.
|
||||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.moveName(mon.moves[choice]);
|
||||
restored = this.restorePP(mon, choice, item);
|
||||
}
|
||||
if (!restored) return yield* this.statusHealerResult(1);
|
||||
// BattleRestorePP: the Pokémon out takes the PP of matching moves,
|
||||
// unless it is transformed.
|
||||
if (target === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED))
|
||||
this.player.mon.moves.forEach((move, slot) => {
|
||||
if (move && move === mon.moves[slot]) this.player.mon.pp[slot] = mon.pp[slot];
|
||||
});
|
||||
yield* this.say(BATTLE.PPRestoredText);
|
||||
this.tossItem("items", item, use.slot);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/** RestorePP: MAX items fill the move; others add 10 (the MysteryBerry
|
||||
* 5) up to GetMaxPPOfMove. Returns false if the PP was already full. */
|
||||
private restorePP(mon: PartyMon, slot: number, item: number) {
|
||||
const max = maxPP(this.options.data, mon.moves[slot], mon.pp[slot] >> 6) & C.PP_MASK;
|
||||
const pp = restoredPP(mon.pp[slot], max, item);
|
||||
if (pp === undefined) return false;
|
||||
mon.pp[slot] = pp;
|
||||
return true;
|
||||
}
|
||||
/** XItemEffect: the item is used, then RaiseStat on the player's side,
|
||||
* even if the stat cannot rise, and a small happiness gain. */
|
||||
private *xItemEffect(use: ItemUse): Gen<number> {
|
||||
yield* this.useItemText(use);
|
||||
const r = this.reader;
|
||||
let p = BATTLE.XItemStats;
|
||||
while (r.byte(p) !== use.item) p += 2;
|
||||
this.turn = 0;
|
||||
this.attackMissed = 0;
|
||||
this.effectFailed = 0;
|
||||
this.raiseStat(r.byte(p + 1));
|
||||
yield this.hud();
|
||||
yield* this.statUpMessage();
|
||||
yield* this.statUpFailText();
|
||||
this.changeHappiness(this.curBattleMon, C.HAPPINESS_USEDXITEM);
|
||||
return 1;
|
||||
}
|
||||
/** XAccuracyEffect, GuardSpecEffect and DireHitEffect: a SubStatus4 flag
|
||||
* that can be set once (WontHaveAnyEffect_NotUsedMessage returns 2). */
|
||||
private *setPlayerSubstatusItem(use: ItemUse, bit: number): Gen<number> {
|
||||
if (this.player.has(SUB4, bit)) {
|
||||
yield* this.say(BATTLE.ItemWontHaveEffectText);
|
||||
return 2;
|
||||
}
|
||||
this.player.sub[SUB4] |= 1 << bit;
|
||||
yield* this.useItemText(use);
|
||||
return 1;
|
||||
}
|
||||
/** PokeDollEffect: ends a wild battle as a draw (wForcedSwitch); in a
|
||||
* trainer battle it fails. */
|
||||
private *pokeDollEffect(use: ItemUse): Gen<number> {
|
||||
if (this.battleMode !== C.WILD_BATTLE) return 0;
|
||||
this.battleResult = (this.battleResult & 0xc0) | C.DRAW;
|
||||
yield* this.useItemText(use);
|
||||
this.battleEnded = true;
|
||||
return 1;
|
||||
}
|
||||
/** 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. */
|
||||
* which is the enemy if hBattleTurn held the enemy's turn. At the battle
|
||||
* menu that happens after a full turn (HandleHealingItems ends on the
|
||||
* enemy) and after a trainer AI layer set it. */
|
||||
private calcPlayerStats() {
|
||||
const player = this.player;
|
||||
for (let stat = 0; stat < 5; stat++) {
|
||||
@@ -989,13 +1258,7 @@ export class Battle {
|
||||
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);
|
||||
this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy);
|
||||
}
|
||||
/** PrintPartyMenuActionText with the Pokémon's nickname. */
|
||||
private *partyMenuActionText(target: number, text: number): Gen {
|
||||
@@ -2590,7 +2853,7 @@ export class Battle {
|
||||
this.breakAttraction();
|
||||
if (setMode) yield* this.enemySwitchSetMode();
|
||||
else yield* this.enemySwitch();
|
||||
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
|
||||
this.resetBattleParticipants();
|
||||
this.turn = 1;
|
||||
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
|
||||
throw new UnsupportedBattle("Spikes are not ported yet.");
|
||||
@@ -2605,7 +2868,7 @@ export class Battle {
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
yield this.hud();
|
||||
if (!switching) return;
|
||||
this.participants = 0;
|
||||
this.participants = this.participantsAll = 0;
|
||||
this.battlePlayerAction = 0;
|
||||
this.enemyIsSwitching = 1;
|
||||
yield* this.playerSwitch();
|
||||
@@ -2662,7 +2925,10 @@ export class Battle {
|
||||
for (;;) {
|
||||
const answer = yield { kind: "party", text: prompt, menu, cancel };
|
||||
if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length)
|
||||
{
|
||||
this.partyMenuCursor = answer + 1; // PartyMenuSelect
|
||||
return (this.curPartyMon = answer);
|
||||
}
|
||||
if (cancel) return -1;
|
||||
}
|
||||
}
|
||||
@@ -2733,7 +2999,7 @@ export class Battle {
|
||||
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
|
||||
* PlayerPartyMonEntrance. */
|
||||
private *sendOutPlayerMon(): Gen {
|
||||
this.participants |= 1 << this.curBattleMon; // AddBattleParticipant
|
||||
this.addBattleParticipant();
|
||||
this.initBattleMon();
|
||||
yield* this.sendOutMonText();
|
||||
this.newStatus(this.player); // with ResetPlayerStatLevels
|
||||
@@ -2745,28 +3011,11 @@ export class Battle {
|
||||
}
|
||||
/** 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;
|
||||
const { hp, maxHp } = this.enemy.mon;
|
||||
if (hp) this.enemyHPAtSwitch = hp;
|
||||
const text = sendOutText(hp, maxHp);
|
||||
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||||
yield* this.say(BATTLE[text]);
|
||||
}
|
||||
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
|
||||
* with the first party Pokémon's Speed. Returns true if the player fled. */
|
||||
@@ -2945,7 +3194,7 @@ export class Battle {
|
||||
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
|
||||
yield* this.enemySwitch();
|
||||
this.participants = 1 << this.curBattleMon;
|
||||
this.resetBattleParticipants();
|
||||
this.battleHasJustStarted = 0;
|
||||
}
|
||||
|
||||
@@ -3129,17 +3378,32 @@ export class Battle {
|
||||
this.player.mon.status = 0;
|
||||
this.updateBattleMonInParty();
|
||||
// ChangeHappiness: fainting, or fainting to a much stronger foe.
|
||||
const mon = this.partyMon;
|
||||
const strong = this.enemy.mon.level >= this.player.mon.level + 30;
|
||||
const change = this.happinessChange(strong ? "beatenByStrongFoe" : "fainted", mon.happiness);
|
||||
mon.happiness = Math.max(0, Math.min(255, mon.happiness + change));
|
||||
this.changeHappiness(this.curBattleMon, strong ? C.HAPPINESS_BEATENBYSTRONGFOE : C.HAPPINESS_FAINTED);
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.LOSE;
|
||||
}
|
||||
/** HappinessChanges: changes by current happiness (<100, <200, else). */
|
||||
private happinessChange(kind: "fainted" | "beatenByStrongFoe" | "levelUp", happiness: number) {
|
||||
const tier = happiness < 100 ? 0 : happiness < 200 ? 1 : 2;
|
||||
const rows = { levelUp: [5, 3, 2], fainted: [-1, -1, -1], beatenByStrongFoe: [-5, -5, -10] };
|
||||
return rows[kind][tier];
|
||||
/** ChangeHappiness: HappinessChanges by tier (below 100, below 200,
|
||||
* above). Eggs are skipped. In battle the battle Pokémon's copy follows
|
||||
* only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
|
||||
private changeHappiness(index: number, action: number) {
|
||||
const mon = this.options.party[index];
|
||||
if (mon.species === C.EGG) return;
|
||||
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
|
||||
mon.happiness = changedHappiness(
|
||||
mon.happiness,
|
||||
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
|
||||
);
|
||||
if (index === this.partyMenuCursor) this.player.mon.happiness = mon.happiness;
|
||||
}
|
||||
/** ResetBattleParticipants: only the Pokémon out, in both sets. */
|
||||
private resetBattleParticipants() {
|
||||
this.participants = this.participantsAll = 0;
|
||||
this.addBattleParticipant();
|
||||
}
|
||||
/** AddBattleParticipant. */
|
||||
private addBattleParticipant() {
|
||||
this.participants |= 1 << this.curBattleMon;
|
||||
this.participantsAll |= 1 << this.curBattleMon;
|
||||
}
|
||||
private *lostBattle(): Gen {
|
||||
this.battleEnded = true;
|
||||
@@ -3210,7 +3474,11 @@ export class Battle {
|
||||
side.stats = mon.stats.slice(1);
|
||||
this.calcStats(side);
|
||||
}
|
||||
mon.happiness = Math.min(255, mon.happiness + this.happinessChange("levelUp", mon.happiness));
|
||||
// LevelUpHappinessMod: more where it was caught.
|
||||
this.changeHappiness(
|
||||
index,
|
||||
(mon.caughtData & 0x7f) === this.options.landmark ? C.HAPPINESS_GAINLEVELATHOME : C.HAPPINESS_GAINLEVEL,
|
||||
);
|
||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
this.numbers[RAM.curPartyLevel] = newLevel;
|
||||
yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel);
|
||||
@@ -3223,7 +3491,7 @@ export class Battle {
|
||||
};
|
||||
for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level);
|
||||
}
|
||||
this.participants = 1 << this.curBattleMon;
|
||||
this.resetBattleParticipants();
|
||||
}
|
||||
/** CalcLevel: the highest level whose exp does not exceed the total. */
|
||||
private levelForExp(species: number, exp: number) {
|
||||
|
||||
Reference in new issue
Block a user