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:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 11:24:55 +02:00
1 parent cfd083c16d
commit b13e09ec20
22 files changed
+1547 -171

No files matched your search

+24 -8
View File
@@ -156,9 +156,17 @@ export function chooseTrainerMove(input: TrainerAIInput): {
move: number;
index: number;
scores: number[];
/** Whether AI_Types, AI_Status or AIDamageCalc set hBattleTurn to the
* enemy, which the battle menu then still holds. */
enemyTurn: boolean;
} {
validateTrainerAI(input.weights);
const { player, enemy, data, battleData, weights, random } = input;
let enemyTurn = false;
const damage = (move: number) => {
enemyTurn = true;
return input.damage(move);
};
const moves = enemy.mon.moves.slice(0, 4);
const end = moves.indexOf(0);
if (end >= 0) moves.length = end;
@@ -213,7 +221,9 @@ export function chooseTrainerMove(input: TrainerAIInput): {
if (random() >= 128) add(i, -2);
} else if (random() >= 30) add(i, 2);
});
if (weights & 4)
if (weights & 4) {
// AI_Types sets hBattleTurn before each move's matchup.
enemyTurn = true;
attributes.forEach((move, i) => {
const value = matchup(i);
if (!value) add(i, 10);
@@ -224,6 +234,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
)
add(i, 1);
});
}
if (weights & 8)
attributes.forEach((move, i) => {
if (!move.power) add(i, 2);
@@ -250,9 +261,9 @@ export function chooseTrainerMove(input: TrainerAIInput): {
highest = 0;
attributes.forEach((move, i) => {
if (!move.power) return;
const damage = input.damage(moves[i]);
if (damage >= highest) {
highest = damage;
const value = damage(moves[i]);
if (value >= highest) {
highest = value;
strongest = i;
}
});
@@ -287,8 +298,13 @@ export function chooseTrainerMove(input: TrainerAIInput): {
move.effect === C.EFFECT_PARALYZE ||
move.power
) {
if ((poison && player.mon.types.includes(C.POISON)) || !matchup(i))
add(i, 10);
// Every path but a poison move against a Poison type sets
// hBattleTurn before BattleCheckTypeMatchup.
if (poison && player.mon.types.includes(C.POISON)) add(i, 10);
else {
enemyTurn = true;
if (!matchup(i)) add(i, 10);
}
}
});
if (weights & 512) {
@@ -302,7 +318,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
}
// The original subtracts damage from HP and requires a borrow, so exact
// damage equality is not treated as a knockout here.
if (input.damage(moves[i]) > player.mon.hp) add(i, -5);
if (damage(moves[i]) > player.mon.hp) add(i, -5);
});
}
// A zero score would wrap to 255 on the first decrement in the ROM.
@@ -311,7 +327,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
for (let attempt = 0; attempt < 4096; attempt++) {
const index = random() & 3;
if (index < moves.length && effective[index] === minimum)
return { move: moves[index], index, scores };
return { move: moves[index], index, scores, enemyTurn };
}
throw new UnsupportedBattle(
"Trainer move selection random source did not produce a candidate.",
+375 -107
View File
@@ -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) {
+14
View File
@@ -241,6 +241,20 @@ export const C = {
ITEMMENU_PARTY: 5,
PARTYMENUTEXT_HEAL_HP: 0xf5,
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
PARTYMENUTEXT_REVIVE: 0xf7,
HAPPINESS_GAINLEVEL: 0x01,
HAPPINESS_USEDXITEM: 0x03,
HAPPINESS_FAINTED: 0x06,
HAPPINESS_BEATENBYSTRONGFOE: 0x08,
HAPPINESS_BITTERPOWDER: 0x0f,
HAPPINESS_ENERGYROOT: 0x10,
HAPPINESS_REVIVALHERB: 0x11,
HAPPINESS_GAINLEVELATHOME: 0x13,
REVIVE: 0x27,
ELIXER: 0x41,
MAX_ELIXER: 0x15,
MAX_ETHER: 0x40,
MYSTERYBERRY: 0x96,
EGG: 0xfd,
WILD_BATTLE: 1,
TRAINER_BATTLE: 2,
+39
View File
@@ -1,10 +1,12 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import {
BATTLE,
INTRO,
NUM_ITEMS,
NUM_POKEMON,
NUM_TRAINER_CLASSES,
STATS,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
@@ -178,6 +180,43 @@ export class PokemonData {
);
}
/** PrintType: the name of a type from TypeNames. */
typeName(type: number): string {
if (!Number.isInteger(type) || type < 0 || type > 27)
throw new Error("Unknown type.");
const r = this.reader;
return readString(
r,
r.near(STATS.TypeNames + type * 2, r.bank(STATS.TypeNames)),
defaultTextContext(""),
);
}
/** GetGender: the Attack and Speed DVs against the species' ratio;
* undefined for genderless species. */
gender(species: number, dvs: number): "male" | "female" | undefined {
const ratio = this.baseData(species).genderRatio;
if (ratio === 0xff) return undefined; // GENDER_UNKNOWN
if (!ratio) return "male"; // GENDER_F0
if (ratio === 0xfe) return "female"; // GENDER_F100
const value = ((dvs >> 8) & 0xf0) | ((dvs >> 4) & 0x0f);
return ratio < value ? "male" : "female";
}
/** PrintTempMonStats' labels: Attack, Defense, Sp. Atk, Sp. Def, Speed. */
statNames(): string[] {
return readString(
this.reader,
BATTLE["PrintTempMonStats.StatNames"],
defaultTextContext(""),
64,
)
.split("\n")
.filter(Boolean);
}
/** A string of the Pokémon stats screen. */
statsString(label: keyof typeof STATS): string {
return readString(this.reader, STATS[label], defaultTextContext(""));
}
move(move: number): MoveData {
if (!Number.isInteger(move) || move < 1 || move > 251)
throw new Error("Unknown move.");
+35
View File
@@ -320,6 +320,23 @@ export const INTRO = {
"GetTimeOfDayString.day_string": 0x90b7b,
} as const;
/** The Pokémon stats screen's strings and the type names, keyed by their
* PRET labels so that \`npm run check:reference\` can verify each address. */
export const STATS = {
"LoadPinkPage.Status_Type": 0x4e119,
"LoadPinkPage.OK_str": 0x4e127,
"LoadPinkPage.ExpPointStr": 0x4e12b,
"LoadPinkPage.LevelUpStr": 0x4e136,
"LoadPinkPage.ToStr": 0x4e13f,
"LoadPinkPage.PkrsStr": 0x4e142,
"LoadGreenPage.Item": 0x4e1a0,
"LoadGreenPage.ThreeDashes": 0x4e1a5,
"LoadGreenPage.Move": 0x4e1a9,
IDNoString: 0x4e21e,
OTString: 0x4e222,
TypeNames: 0x5097b,
} as const;
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
* data, strings and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
@@ -495,6 +512,24 @@ export const BATTLE = {
ItemCantUseOnEggText: 0xf810,
OakThisIsntTheTimeText: 0x10af3,
UseOnWhichPKMNString: 0x504f3,
FullRestoreEffect: 0xf128,
ReviveEffect: 0xf0c8,
RevivalHerbEffect: 0xf0a9,
EnergypowderEffect: 0xf18c,
EnergyRootEffect: 0xf190,
HealPowderEffect: 0xefad,
BitterBerryEffect: 0xf16a,
RestorePPEffect: 0xf5bf,
XItemEffect: 0xf4c5,
XAccuracyEffect: 0xf482,
GuardSpecEffect: 0xf4ab,
DireHitEffect: 0xf4b8,
PokeDollEffect: 0xf48f,
XItemStats: 0xf504,
HappinessChanges: 0x7221,
ItemLooksBitterText: 0xf80b,
RestoreThePPOfWhichMoveText: 0xf72a,
PPRestoredText: 0xf739,
HungOnText: 0x80e99,
HurtByBurnText: 0x807f8,
HurtByPoisonText: 0x807e2,
+2
View File
@@ -248,6 +248,8 @@ export type ScriptEffect =
/** A native message for presentation the port does not have yet; the
* script continues. */
| { kind: "notice"; text: string }
/** The Pokémon stats screen, closed with a button. */
| { kind: "stats"; mon: PartyMon }
/** warpcheck: the player's tile events run again after the script. */
| { kind: "warpcheck" }
/** LoadMemScript: a script (with a phone caller's script) that runs at
+3 -2
View File
@@ -494,7 +494,7 @@ export class PcSession {
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
yield { kind: "stats", mon: structuredClone(mon) };
continue;
}
if (action !== SUBMENU_RELEASE) break;
@@ -547,7 +547,8 @@ export class PcSession {
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
// CalcTempmonStats: stats from stat experience, full HP, no status.
yield { kind: "stats", mon: withdrawnMon(this.host.data, mon) };
continue;
}
if (action !== SUBMENU_RELEASE) break;
+20 -4
View File
@@ -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" | "party" }
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
>;
/** Yes/No cursor, as on the original boxes. */
selected: 0 | 1;
@@ -1822,6 +1822,7 @@ export class GameWorld {
this.assets.map(this.mapId).landmark,
),
tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark,
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -1853,6 +1854,10 @@ export class GameWorld {
this.townMap = true;
this.dialogue = { pages: ["Town Map"], index: 0 };
return true;
case "stats":
this.statsScreen = effect.mon;
this.dialogue = { pages: ["Stats"], index: 0 };
return true;
case "menu":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
@@ -2248,6 +2253,7 @@ export class GameWorld {
this.script = undefined;
this.memScript ??= script.memScript;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -2275,6 +2281,7 @@ export class GameWorld {
this.spawned = script.rollback.spawned;
this.script = undefined;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -2346,14 +2353,16 @@ export class GameWorld {
return;
this.pumpBattle(name);
}
/** 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). */
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels),
* a party menu (a party index, or -1 to go back) or an item's move
* choice (0-3, or -1 to go back). */
battleChoose(answer: number) {
const prompt = this.battle?.prompt;
if (
prompt?.kind === "yesno" ||
prompt?.kind === "forget" ||
prompt?.kind === "party"
prompt?.kind === "party" ||
prompt?.kind === "move"
)
this.pumpBattle(answer);
}
@@ -2372,6 +2381,7 @@ export class GameWorld {
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 === "move") return this.pumpBattle(-1);
if (prompt?.kind === "stats") return this.pumpBattle();
if (!prompt && view.message?.prompt) this.pumpBattle();
}
@@ -2695,6 +2705,8 @@ export class GameWorld {
/** One overworld tick. `held` is the direction the player is holding. */
/** Overworld ticks run so far, for presentation such as tile animation. */
townMap = false;
/** The Pokémon a script's stats screen shows (Bill's PC). */
statsScreen: PartyMon | undefined;
ticks = 0;
tick(held?: Direction) {
@@ -2723,6 +2735,7 @@ export class GameWorld {
) {
if (--dialogue.holdTicks <= 0) {
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.pump();
}
@@ -3144,6 +3157,7 @@ export class GameWorld {
return;
}
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.pump();
}
@@ -3214,6 +3228,7 @@ export class GameWorld {
)
return;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.pump(answer);
}
@@ -3475,6 +3490,7 @@ export class GameWorld {
this.tilesWalked = save.tilesWalked;
this.playerId = save.playerId;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
+193 -6
View File
@@ -10,7 +10,7 @@ import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
@@ -47,6 +47,7 @@ function showWorld(world: GameWorld) {
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
showBattle(world);
showStatsScreen(world);
showFieldMenu(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
@@ -59,7 +60,11 @@ function showWorld(world: GameWorld) {
);
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden =
!dialogue || pictureWait || Boolean(mart) || world.townMap;
!dialogue ||
pictureWait ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
@@ -384,6 +389,9 @@ let showPack = false;
let packPocket: "item" | "ball" = "item";
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
let partyChoice: number | undefined;
/** The stats screen and its page (pink, green, blue): opened from
* BattleMonMenu for a party slot, or by a script (Bill's PC) without one. */
let statsView: { slot?: number; page: number } | undefined;
let battleCursor = 0;
let battleSelectionKey = "";
@@ -458,6 +466,18 @@ function highlightBattleChoice() {
function handleBattleInput(button: Button): boolean {
if (handleFieldInput(button)) return true;
const world = runtime.world;
if (statsView && world) {
// StatsScreen_JoypadAction: left and right turn pages, B leaves.
// BattleMenuPKMN_ReturnFromStats reopens the party menu itself.
if (button === "b" && statsView.slot === undefined) world.advanceDialogue();
else if (button === "b") statsView = partyChoice = undefined;
else if (button === "left" || button === "up")
statsView.page = (statsView.page + 2) % 3;
else if (button === "right" || button === "down" || button === "a")
statsView.page = (statsView.page + 1) % 3;
showWorld(world);
return true;
}
if (!world?.battle) return false;
if (button === "b" && (showMoves || showPack)) {
showMoves = showPack = false;
@@ -540,11 +560,14 @@ function showBattle(world: GameWorld) {
get("battle-text").textContent =
prompt?.kind === "name"
? prompt.title
: prompt?.kind === "party"
: prompt?.kind === "party" || prompt?.kind === "move"
? prompt.text
: (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
if (prompt?.kind !== "party") partyChoice = undefined;
if (prompt?.kind !== "party") {
partyChoice = undefined;
if (statsView?.slot !== undefined) statsView = undefined;
}
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
if (selectionKey !== battleSelectionKey) {
battleCursor = 0;
@@ -706,8 +729,9 @@ function showBattle(world: GameWorld) {
partyChoice = undefined;
runtime.battleChoose(slot);
}),
button("STATS", () => {}, {
disabled: "The stats screen is not ported yet.",
button("STATS", () => {
statsView = { slot, page: 0 };
showWorld(world);
}),
button("CANCEL", () => {
partyChoice = undefined;
@@ -737,6 +761,18 @@ function showBattle(world: GameWorld) {
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "move") {
prompt.moves.forEach((move, index) => {
if (!move) return;
buttons.push(
button(data.moveName(move), () => runtime.battleChoose(index), {
detail: `PP ${prompt.pp[index] & 63}`,
}),
);
});
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(
@@ -774,6 +810,143 @@ function showBattle(world: GameWorld) {
);
highlightBattleChoice();
}
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
* strings, type names and stat labels read from the ROM. */
function showStatsScreen(world: GameWorld) {
const panel = get("stats-screen");
const view = world.battle;
// A script's stats screen opens on the first page and closes with it.
if (world.statsScreen && !statsView) statsView = { page: 0 };
if (!world.statsScreen && statsView?.slot === undefined) statsView = undefined;
const slot = statsView?.slot;
const own = view?.engine.player.mon;
const stored =
slot === undefined ? world.statsScreen : world.liveState.party[slot];
panel.hidden = !statsView || !stored;
if (!statsView || !stored) return;
const data = world.assets.pokemon;
const mon =
view && slot === view.engine.curBattleMon && own
? { ...stored, hp: own.hp, status: own.status }
: stored;
const shiny = PokemonData.shiny(mon.dvs);
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
data.statsString(label);
drawPic(
get("stats-pic"),
data.frontPic(mon.species),
7,
data.palette(mon.species, shiny),
);
get("stats-pic").setAttribute("aria-label", data.speciesName(mon.species));
const gender = data.gender(mon.species, mon.dvs);
const line = (...parts: (string | Node)[]) => {
const p = document.createElement("p");
p.append(...parts);
return p;
};
const name = document.createElement("strong");
name.textContent = `${mon.nickname}${gender === "male" ? "♂" : gender === "female" ? "♀" : ""}`;
get("stats-header").replaceChildren(
line(
`№.${String(mon.species).padStart(3, "0")} :L${mon.level}${shiny ? " ★" : ""}`,
),
line(name),
line(`/${data.speciesName(mon.species)}`),
);
const list = (rows: [string, string][], wide = false) => {
const dl = document.createElement("dl");
if (wide) dl.classList.add("wide");
for (const [term, value] of rows) {
const dt = document.createElement("dt"),
dd = document.createElement("dd");
dt.textContent = term;
dd.textContent = value;
dl.append(dt, dd);
}
return dl;
};
const growth = data.baseData(mon.species).growthRate;
const page: Node[] = [];
if (statsView.page === 0) {
const [statusLabel, typeLabel] = text("LoadPinkPage.Status_Type").split(
"\n",
);
const types = [...new Set(data.baseData(mon.species).types)].map((type) =>
data.typeName(type),
);
const next = Math.min(mon.level + 1, 100);
page.push(
list([
["HP", `${mon.hp}/ ${mon.stats[0]}`],
[
statusLabel,
mon.pokerus & 0x0f
? text("LoadPinkPage.PkrsStr")
: !mon.hp
? "FNT"
: statusName(mon.status) || text("LoadPinkPage.OK_str").trim(),
],
[typeLabel, types.join(" / ")],
]),
list([
[text("LoadPinkPage.ExpPointStr"), String(mon.exp)],
[
text("LoadPinkPage.LevelUpStr"),
String(
mon.level >= 100 ? 0 : data.expAtLevel(growth, next) - mon.exp,
),
],
[text("LoadPinkPage.ToStr"), `:L${next}`],
]),
);
} else if (statsView.page === 1) {
page.push(
list(
[
[
text("LoadGreenPage.Item"),
mon.item
? data.itemName(mon.item)
: text("LoadGreenPage.ThreeDashes"),
],
...mon.moves.flatMap((move, index): [string, string][] =>
move
? [
[
index ? "" : text("LoadGreenPage.Move"),
`${data.moveName(move)} PP ${mon.pp[index] & 63}/${maxPP(data, move, mon.pp[index] >> 6) & 63}`,
],
]
: [],
),
],
true,
),
);
} else {
// The OT's gender comes from the caught data unless it is unset.
const caught = mon.caughtData & 0xff;
const otGender =
!caught || caught === 0x7f ? "" : caught & 0x80 ? "♀" : "♂";
const labels = data.statNames();
page.push(
list([
[text("IDNoString"), String(mon.otId).padStart(5, "0")],
[text("OTString"), `${mon.ot}${otGender}`],
]),
list(
labels.map((label, index): [string, string] => [
label,
String(mon.stats[[1, 2, 4, 5, 3][index]]),
]),
),
);
}
get("stats-page").replaceChildren(...page);
panel.dataset.page = String(statsView.page);
get("stats-page-number").textContent = `${statsView.page + 1} / 3`;
}
function setHp(bar: HTMLElement, hp: number, max: number) {
const fraction = max ? hp / max : 0;
bar.style.width = `${Math.round(fraction * 100)}%`;
@@ -1067,3 +1240,17 @@ get("town-map-close").onclick = () => {
runtime.advanceDialogue();
canvas.focus({ preventScroll: true });
};
for (const [id, turn] of [
["stats-prev", 2],
["stats-next", 1],
["stats-close", 0],
] as const)
get(id).onclick = () => {
const world = runtime.world;
if (session.state !== "playing" || !statsView || !world) return;
if (turn) statsView.page = (statsView.page + turn) % 3;
else if (statsView.slot === undefined) runtime.advanceDialogue();
else statsView = partyChoice = undefined;
showWorld(world);
canvas.focus({ preventScroll: true });
};
+1
View File
@@ -31,6 +31,7 @@ export function renderShell(root: HTMLElement) {
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div>
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart">
+83
View File
@@ -444,6 +444,89 @@ dialog h2 {
.battle-menu .wide {
grid-column: 1 / -1;
}
/* The Pokémon stats screen over the battle: three pages, as the pink,
green and blue pages of StatsScreenMain. */
.stats-screen {
position: absolute;
inset: 0;
z-index: 4;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 8px;
padding: 10px;
overflow-y: auto;
color: var(--ink);
background: #f8f8f0;
border-top: 8px solid var(--stats-page, #e8a0b8);
}
.stats-screen[data-page="1"] {
--stats-page: #88c888;
}
.stats-screen[data-page="2"] {
--stats-page: #88a8e0;
}
.stats-top {
display: flex;
align-items: center;
gap: 12px;
}
.stats-top canvas {
width: min(28vw, 112px);
aspect-ratio: 1;
image-rendering: pixelated;
}
.stats-header p,
.stats-page p {
margin: 0;
white-space: pre-line;
}
.stats-header strong {
font-size: 1.1rem;
}
.stats-page {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 16px;
align-content: start;
padding: 10px 12px;
background: var(--cream);
border: 5px double var(--ink);
font-variant-numeric: tabular-nums;
}
.stats-page dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 2px 10px;
margin: 0;
}
.stats-page dt {
font-weight: 800;
}
.stats-page dd {
margin: 0;
}
.stats-page .wide {
grid-column: 1 / -1;
}
.stats-nav {
display: flex;
align-items: center;
gap: 8px;
}
.stats-nav button {
padding: 6px 12px;
color: var(--ink);
background: var(--cream-dark);
border: 3px double var(--ink);
}
.stats-nav #stats-close {
margin-left: auto;
}
@media (max-width: 480px) {
.stats-page {
grid-template-columns: minmax(0, 1fr);
}
}
/* NewGame's screens: a plain background over the world until the
introduction ends. */
.intro-screen {