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);
|
||||
|
||||
@@ -236,6 +236,12 @@ export const C = {
|
||||
|
||||
// Battle modes, types and results
|
||||
BATTLEPLAYERACTION_USEITEM: 1,
|
||||
BATTLEPLAYERACTION_SWITCH: 2,
|
||||
ITEMMENU_CURRENT: 4,
|
||||
ITEMMENU_PARTY: 5,
|
||||
PARTYMENUTEXT_HEAL_HP: 0xf5,
|
||||
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
|
||||
EGG: 0xfd,
|
||||
WILD_BATTLE: 1,
|
||||
TRAINER_BATTLE: 2,
|
||||
HELD_AMULET_COIN: 76,
|
||||
|
||||
@@ -38,6 +38,25 @@ export interface MoveData {
|
||||
/** Item types, which select a bag pocket (constants/item_data_constants.asm). */
|
||||
export type Pocket = "item" | "key" | "ball" | "tm";
|
||||
const POCKETS: readonly Pocket[] = ["item", "key", "ball", "tm"];
|
||||
/** One entry of a species' evolution list (data/pokemon/evos_attacks.asm).
|
||||
* A trade's `item` is null for the ROM's -1 (no held item needed). */
|
||||
export type Evolution =
|
||||
| { method: "level"; level: number; species: number }
|
||||
| { method: "item"; item: number; species: number }
|
||||
| { method: "trade"; item: number | null; species: number }
|
||||
| {
|
||||
method: "happiness";
|
||||
time: "anytime" | "mornday" | "nite";
|
||||
species: number;
|
||||
}
|
||||
| {
|
||||
method: "stat";
|
||||
level: number;
|
||||
attack: "gt" | "lt" | "eq";
|
||||
species: number;
|
||||
};
|
||||
const HAPPINESS_TIMES = ["anytime", "mornday", "nite"] as const;
|
||||
const STAT_TRIGGERS = ["gt", "lt", "eq"] as const;
|
||||
|
||||
/** Decoders for species, moves and items. All data is read from the
|
||||
* user's ROM on demand and cached in memory only. */
|
||||
@@ -175,6 +194,53 @@ export class PokemonData {
|
||||
};
|
||||
}
|
||||
|
||||
/** The evolution list before the learnset, in ROM order (EvolveAfterBattle
|
||||
* tries entries in this order). EVOLVE_STAT records are four bytes long;
|
||||
* the others three. */
|
||||
evolutions(species: number): Evolution[] {
|
||||
this.checkSpecies(species);
|
||||
const r = this.reader;
|
||||
let p = r.near(
|
||||
SYMBOLS.evosAttacks + (species - 1) * 2,
|
||||
r.bank(SYMBOLS.evosAttacks),
|
||||
);
|
||||
const evolutions: Evolution[] = [];
|
||||
for (let method = r.byte(p); method !== 0; method = r.byte(p)) {
|
||||
if (evolutions.length > 8) throw new Error("Invalid evolution data.");
|
||||
const a = r.byte(p + 1);
|
||||
let evolution: Evolution;
|
||||
if (method === 1)
|
||||
evolution = { method: "level", level: a, species: r.byte(p + 2) };
|
||||
else if (method === 2)
|
||||
evolution = { method: "item", item: a, species: r.byte(p + 2) };
|
||||
else if (method === 3)
|
||||
evolution = {
|
||||
method: "trade",
|
||||
item: a === 0xff ? null : a,
|
||||
species: r.byte(p + 2),
|
||||
};
|
||||
else if (method === 4) {
|
||||
const time = HAPPINESS_TIMES[a - 1];
|
||||
if (!time) throw new Error("Invalid evolution data.");
|
||||
evolution = { method: "happiness", time, species: r.byte(p + 2) };
|
||||
} else if (method === 5) {
|
||||
const attack = STAT_TRIGGERS[r.byte(p + 2) - 1];
|
||||
if (!attack) throw new Error("Invalid evolution data.");
|
||||
evolution = {
|
||||
method: "stat",
|
||||
level: a,
|
||||
attack,
|
||||
species: r.byte(p + 3),
|
||||
};
|
||||
p++;
|
||||
} else throw new Error("Invalid evolution data.");
|
||||
this.checkSpecies(evolution.species);
|
||||
evolutions.push(evolution);
|
||||
p += 3;
|
||||
}
|
||||
return evolutions;
|
||||
}
|
||||
|
||||
/** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the
|
||||
* evolution list is skipped up to its terminating zero byte. */
|
||||
learnset(species: number): { level: number; move: number }[] {
|
||||
|
||||
@@ -157,6 +157,7 @@ export const RAM = {
|
||||
callerContact: 0xd03f,
|
||||
surfingPlayerState: 0xd1eb,
|
||||
strengthSpecies: 0xd1ef,
|
||||
curHPAnimDeltaHP: 0xd1f3,
|
||||
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
||||
moneyTemp: 0xffc3,
|
||||
} as const;
|
||||
@@ -468,6 +469,32 @@ export const BATTLE = {
|
||||
HasNoPPLeftText: 0x80fba,
|
||||
HeldStatusHealingEffects: 0x3de44,
|
||||
HPIsFullText: 0x81534,
|
||||
DoItMonText: 0x3f2d8,
|
||||
GoForItMonText: 0x3f2df,
|
||||
YourFoesWeakGetmMonText: 0x3f2e6,
|
||||
"WithdrawMonText.WithdrawMonText": 0x3f2fa,
|
||||
ThatsEnoughComeBackText: 0x3f348,
|
||||
OKComeBackText: 0x3f34d,
|
||||
GoodComeBackText: 0x3f352,
|
||||
ComeBackText: 0x3f35b,
|
||||
BattleText_UseNextMon: 0x80a83,
|
||||
BattleText_MonIsAlreadyOut: 0x80c0d,
|
||||
BattleText_MonCantBeRecalled: 0x80c22,
|
||||
BattleText_TheresNoWillToBattle: 0x80b0b,
|
||||
BattleText_AnEGGCantBattle: 0x80b26,
|
||||
BattleText_EnemyIsAboutToUseWillPlayerChangeMon: 0x80aca,
|
||||
ChooseAMonString: 0x504e4,
|
||||
WhichPKMNString: 0x50504,
|
||||
ItemEffects: 0xe73c,
|
||||
RestoreHPEffect: 0xf186,
|
||||
StatusHealingEffect: 0xefcc,
|
||||
StatusHealingActions: 0xf071,
|
||||
HealingHPAmounts: 0xf3af,
|
||||
"PrintPartyMenuActionText.MenuActionTexts": 0x5057b,
|
||||
ItemWontHaveEffectText: 0xf81f,
|
||||
ItemCantUseOnEggText: 0xf810,
|
||||
OakThisIsntTheTimeText: 0x10af3,
|
||||
UseOnWhichPKMNString: 0x504f3,
|
||||
HungOnText: 0x80e99,
|
||||
HurtByBurnText: 0x807f8,
|
||||
HurtByPoisonText: 0x807e2,
|
||||
|
||||
@@ -241,7 +241,7 @@ export interface BattleView {
|
||||
/** A choice the battle is waiting for, if any. */
|
||||
prompt?: Extract<
|
||||
BattleEffect,
|
||||
{ kind: "action" | "yesno" | "forget" | "stats" | "name" }
|
||||
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" }
|
||||
>;
|
||||
/** Yes/No cursor, as on the original boxes. */
|
||||
selected: 0 | 1;
|
||||
@@ -2346,10 +2346,15 @@ export class GameWorld {
|
||||
return;
|
||||
this.pumpBattle(name);
|
||||
}
|
||||
/** Answer a battle Yes/No (1/0) or the move to forget (0-3, 4 cancels). */
|
||||
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels)
|
||||
* or a party menu (a party index, or -1 to go back). */
|
||||
battleChoose(answer: number) {
|
||||
const prompt = this.battle?.prompt;
|
||||
if (prompt?.kind === "yesno" || prompt?.kind === "forget")
|
||||
if (
|
||||
prompt?.kind === "yesno" ||
|
||||
prompt?.kind === "forget" ||
|
||||
prompt?.kind === "party"
|
||||
)
|
||||
this.pumpBattle(answer);
|
||||
}
|
||||
/** A in battle: continue a prompt message, confirm Yes/No or the stat box. */
|
||||
@@ -2366,6 +2371,7 @@ export class GameWorld {
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "yesno") 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 === "stats") return this.pumpBattle();
|
||||
if (!prompt && view.message?.prompt) this.pumpBattle();
|
||||
}
|
||||
|
||||
+86
-12
@@ -379,8 +379,11 @@ function drawPic(
|
||||
const battlePics = { foe: "", player: "" };
|
||||
let battleMenuKey = "";
|
||||
let showMoves = false;
|
||||
/** BattlePack's Balls pocket, the only pocket usable in battle so far. */
|
||||
/** BattlePack, opened on the last pocket used (wLastPocket). */
|
||||
let showPack = false;
|
||||
let packPocket: "item" | "ball" = "item";
|
||||
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
|
||||
let partyChoice: number | undefined;
|
||||
let battleCursor = 0;
|
||||
let battleSelectionKey = "";
|
||||
|
||||
@@ -460,6 +463,11 @@ function handleBattleInput(button: Button): boolean {
|
||||
showMoves = showPack = false;
|
||||
return true;
|
||||
}
|
||||
if (button === "b" && partyChoice !== undefined) {
|
||||
partyChoice = undefined;
|
||||
showWorld(world);
|
||||
return true;
|
||||
}
|
||||
const buttons = battleButtons();
|
||||
if (!buttons.length) return false;
|
||||
if (button === "a") {
|
||||
@@ -530,22 +538,38 @@ function showBattle(world: GameWorld) {
|
||||
}
|
||||
const prompt = view.prompt;
|
||||
get("battle-text").textContent =
|
||||
prompt?.kind === "name" ? prompt.title : (view.message?.text ?? "");
|
||||
prompt?.kind === "name"
|
||||
? prompt.title
|
||||
: prompt?.kind === "party"
|
||||
? prompt.text
|
||||
: (view.message?.text ?? "");
|
||||
if (prompt?.kind !== "action") showMoves = showPack = false;
|
||||
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}`;
|
||||
if (prompt?.kind !== "party") partyChoice = undefined;
|
||||
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
|
||||
if (selectionKey !== battleSelectionKey) {
|
||||
battleCursor = 0;
|
||||
battleSelectionKey = selectionKey;
|
||||
}
|
||||
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
|
||||
const balls = world.liveState.bag.balls;
|
||||
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) =>
|
||||
index === view.engine.curBattleMon && own
|
||||
? { ...mon, hp: own.hp, status: own.status }
|
||||
: mon,
|
||||
);
|
||||
const key = JSON.stringify([
|
||||
prompt,
|
||||
view.selected,
|
||||
showMoves,
|
||||
showPack,
|
||||
partyChoice,
|
||||
packPocket,
|
||||
own?.pp,
|
||||
balls,
|
||||
bag.items,
|
||||
bag.balls,
|
||||
prompt?.kind === "party" &&
|
||||
party.map(({ hp, status, level }) => [hp, status, level]),
|
||||
view.message?.prompt,
|
||||
]);
|
||||
if (key === battleMenuKey) return;
|
||||
@@ -610,7 +634,19 @@ function showBattle(world: GameWorld) {
|
||||
),
|
||||
);
|
||||
} else if (showPack) {
|
||||
balls.forEach(({ item, quantity }, slot) =>
|
||||
for (const pocket of ["item", "ball"] as const)
|
||||
buttons.push(
|
||||
button(
|
||||
data.pocketName(pocket),
|
||||
() => {
|
||||
packPocket = pocket;
|
||||
showWorld(world);
|
||||
},
|
||||
{ pressed: packPocket === pocket },
|
||||
),
|
||||
);
|
||||
const slots = packPocket === "item" ? bag.items : bag.balls;
|
||||
slots.forEach(({ item, quantity }, slot) =>
|
||||
buttons.push(
|
||||
button(
|
||||
data.itemName(item),
|
||||
@@ -622,10 +658,11 @@ function showBattle(world: GameWorld) {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!balls.length)
|
||||
if (!slots.length)
|
||||
buttons.push(
|
||||
button("No BALLS", () => {}, {
|
||||
disabled: "The BALL pocket is empty.",
|
||||
button("Empty", () => {}, {
|
||||
disabled: `The ${data.pocketName(packPocket)} is empty.`,
|
||||
wide: true,
|
||||
}),
|
||||
);
|
||||
buttons.push(
|
||||
@@ -644,9 +681,7 @@ function showBattle(world: GameWorld) {
|
||||
showMoves = true;
|
||||
showWorld(world);
|
||||
}),
|
||||
button("PKMN", () => {}, {
|
||||
disabled: "Switching Pokémon is not ported yet.",
|
||||
}),
|
||||
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
|
||||
button("PACK", () => {
|
||||
showPack = true;
|
||||
showWorld(world);
|
||||
@@ -663,6 +698,45 @@ function showBattle(world: GameWorld) {
|
||||
pressed: view.selected === 0,
|
||||
}),
|
||||
);
|
||||
} else if (prompt?.kind === "party" && partyChoice !== undefined) {
|
||||
// BattleMonMenu. STATS opens the stats screen, which is not ported.
|
||||
const slot = partyChoice;
|
||||
buttons.push(
|
||||
button("SWITCH", () => {
|
||||
partyChoice = undefined;
|
||||
runtime.battleChoose(slot);
|
||||
}),
|
||||
button("STATS", () => {}, {
|
||||
disabled: "The stats screen is not ported yet.",
|
||||
}),
|
||||
button("CANCEL", () => {
|
||||
partyChoice = undefined;
|
||||
showWorld(world);
|
||||
}),
|
||||
);
|
||||
} else if (prompt?.kind === "party") {
|
||||
party.forEach((mon, slot) =>
|
||||
buttons.push(
|
||||
button(
|
||||
mon.nickname,
|
||||
() => {
|
||||
if (!prompt.menu) return runtime.battleChoose(slot);
|
||||
partyChoice = slot;
|
||||
showWorld(world);
|
||||
},
|
||||
{
|
||||
detail: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
|
||||
mon.status ? ` ${statusName(mon.status)}` : ""
|
||||
}`,
|
||||
wide: true,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (prompt.cancel)
|
||||
buttons.push(
|
||||
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
|
||||
);
|
||||
} else if (prompt?.kind === "forget") {
|
||||
prompt.moves.forEach((move, index) =>
|
||||
buttons.push(
|
||||
|
||||
+1
-1
@@ -73,5 +73,5 @@ export function renderShell(root: HTMLElement) {
|
||||
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
|
||||
</div>
|
||||
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
|
||||
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
|
||||
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls and heal with Potions from the battle PACK, and switch Pokémon with PKMN. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
|
||||
}
|
||||
Reference in new issue
Block a user