Pokémon that levelled up evolve after a won battle, following EvolveAfterBattle's checks over the ROM's evolution bytes, with the evolution screen that B can stop and move learning outside battle. The pack uses Rare Candy, stones (with ABLE/NOT ABLE labels) and vitamins, and its pocket tabs now sit in their own row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1af2beacdc
commit
45275ba83b
19 files changed
+1032
-39
No files matched your search
@@ -126,7 +126,13 @@ export type BattleAction =
|
||||
/** 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;
|
||||
export type BattleOutcome = { result: number; prizeMoney?: number };
|
||||
/** wBattleResult, the prize money and wEvolvableFlags (a bit per party
|
||||
* index that levelled up). */
|
||||
export type BattleOutcome = {
|
||||
result: number;
|
||||
prizeMoney?: number;
|
||||
evolvable?: number;
|
||||
};
|
||||
|
||||
export interface BattleTrainer {
|
||||
name: string;
|
||||
@@ -406,6 +412,9 @@ export class Battle {
|
||||
numFleeAttempts = 0;
|
||||
/** wBattleParticipantsNotFainted, which shares the experience. */
|
||||
participants = 0;
|
||||
/** wEvolvableFlags: party members that levelled up, which
|
||||
* EvolveAfterBattle checks after a win. */
|
||||
private evolvable = 0;
|
||||
/** wBattleParticipantsIncludingFainted, which revives consult. */
|
||||
private participantsAll = 0;
|
||||
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
|
||||
@@ -790,11 +799,11 @@ export class Battle {
|
||||
const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1);
|
||||
this.numbers[RAM.battleReward] = prizeMoney;
|
||||
yield* this.say(BATTLE.GotMoneyForWinningText);
|
||||
return { result: this.battleResult, prizeMoney };
|
||||
return { result: this.battleResult, prizeMoney, evolvable: this.evolvable };
|
||||
}
|
||||
if (trainer && this.options.canLose && trainer.lossText)
|
||||
yield* this.say(trainer.lossText);
|
||||
return { result: this.battleResult };
|
||||
return { result: this.battleResult, evolvable: this.evolvable };
|
||||
}
|
||||
|
||||
private *battleTurn(): Gen {
|
||||
@@ -3503,6 +3512,7 @@ export class Battle {
|
||||
values: [mon.stats[1], mon.stats[2], mon.stats[4], mon.stats[5], mon.stats[3]],
|
||||
};
|
||||
for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level);
|
||||
this.evolvable |= 1 << index;
|
||||
}
|
||||
this.resetBattleParticipants();
|
||||
}
|
||||
|
||||
@@ -256,6 +256,29 @@ export const C = {
|
||||
KEY_ITEM_POCKET: 2,
|
||||
TM_HM_POCKET: 3,
|
||||
PP_UP: 0x3e,
|
||||
RARE_CANDY: 0x20,
|
||||
HP_UP: 0x1a,
|
||||
PROTEIN: 0x1b,
|
||||
IRON: 0x1c,
|
||||
CARBOS: 0x1d,
|
||||
CALCIUM: 0x1f,
|
||||
EVERSTONE: 0x70,
|
||||
PARTYMENUACTION_EVO_STONE: 5,
|
||||
PARTYMENUTEXT_LEVEL_UP: 0xf8,
|
||||
HAPPINESS_USEDITEM: 0x02,
|
||||
// Evolution (constants/pokemon_data_constants.asm)
|
||||
EVOLVE_LEVEL: 1,
|
||||
EVOLVE_ITEM: 2,
|
||||
EVOLVE_TRADE: 3,
|
||||
EVOLVE_HAPPINESS: 4,
|
||||
EVOLVE_STAT: 5,
|
||||
TR_ANYTIME: 1,
|
||||
TR_MORNDAY: 2,
|
||||
TR_NITE: 3,
|
||||
ATK_GT_DEF: 1,
|
||||
ATK_LT_DEF: 2,
|
||||
ATK_EQ_DEF: 3,
|
||||
HAPPINESS_TO_EVOLVE: 220,
|
||||
SKETCH: 0xa6,
|
||||
PP_UP_MASK: 0xc0,
|
||||
PP_UP_ONE: 0x40,
|
||||
|
||||
@@ -439,6 +439,19 @@ export const MENU = {
|
||||
PPIsMaxedOutText: 0xf72f,
|
||||
PPsIncreasedText: 0xf734,
|
||||
BicycleEffect: 0xee08,
|
||||
EvoStoneEffect: 0xee0f,
|
||||
VitaminEffect: 0xee3d,
|
||||
RareCandyEffect: 0xef14,
|
||||
ItemStatRoseText: 0xeea6,
|
||||
StatStrings: 0xeeab,
|
||||
StatExpItemPointerOffsets: 0xeeeb,
|
||||
"PlacePartyMonEvoStoneCompatibility.string_able": 0x502a3,
|
||||
"PlacePartyMonEvoStoneCompatibility.string_not_able": 0x502a8,
|
||||
EvosAttacksPointers: 0x425b1,
|
||||
EvolvingText: 0x42482,
|
||||
CongratulationsYourPokemonText: 0x42473,
|
||||
EvolvedIntoText: 0x42478,
|
||||
StoppedEvolvingText: 0x4247d,
|
||||
} as const;
|
||||
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { BATTLE, MENU, RAM } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import { calcStats, type PartyMon } from "../party/pokemon";
|
||||
import type { ScriptEffect, ScriptState } from "./interpreter";
|
||||
import { readText, type TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** DelayFrames 50 after EvolvingText, in overworld ticks. */
|
||||
const EVOLVING_TICKS = 25;
|
||||
|
||||
/** What evolution and move learning outside battle need from the world. */
|
||||
export interface EvolutionHost {
|
||||
reader: RomReader;
|
||||
data: PokemonData;
|
||||
/** wTimeOfDay: morning 0, day 1, night 2 (NITE_F). */
|
||||
timeOfDay(): number;
|
||||
/** The text context, before this module's RAM strings are added. */
|
||||
text(): TextContext;
|
||||
}
|
||||
|
||||
/** EvolveAfterBattle and EvolvePokemon (engine/pokemon/evolve.asm), with
|
||||
* LearnLevelMoves and LearnMove (engine/pokemon/learn.asm) outside battle.
|
||||
* The party and Pokédex are changed on the given draft state. */
|
||||
export class PartyEvolution {
|
||||
/** Native values for the RAM strings and numbers the texts read. */
|
||||
readonly strings: Record<number, string> = {};
|
||||
readonly numbers: Record<number, number> = {};
|
||||
|
||||
constructor(
|
||||
private state: ScriptState,
|
||||
private host: EvolutionHost,
|
||||
) {}
|
||||
|
||||
private get reader() {
|
||||
return this.host.reader;
|
||||
}
|
||||
private get data() {
|
||||
return this.host.data;
|
||||
}
|
||||
private text(): TextContext {
|
||||
const base = this.host.text();
|
||||
return {
|
||||
...base,
|
||||
rival: this.state.rivalName,
|
||||
strings: { ...base.strings, ...this.strings },
|
||||
numbers: { ...base.numbers, ...this.numbers },
|
||||
asm: (offset) => {
|
||||
// Text_1_2_and_Poof: SFX_SWITCH_POKEMON, then MoveForgotText.
|
||||
if (offset === BATTLE.Text_1_2_and_Poof + 4)
|
||||
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
||||
throw new Error(`Text routine at 0x${offset.toString(16)} is not ported.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
private pages(address: number) {
|
||||
return readText(this.reader, address, this.text())
|
||||
.split("\n\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
private *print(address: number, holdTicks?: number): Gen {
|
||||
const pages = this.pages(address);
|
||||
if (pages.length) yield { kind: "text", pages, holdTicks };
|
||||
}
|
||||
|
||||
/** EvolveAfterBattle for the party members whose bit is set in `flags`
|
||||
* (wEvolvableFlags). `forced` is wForceEvolution, set by evolution
|
||||
* stones, and `item` is wCurItem. Returns wMonTriedToEvolve. The
|
||||
* evolution data is walked byte by byte as the original walks it, so the
|
||||
* same entries are skipped when a forced evolution meets a four-byte
|
||||
* EVOLVE_STAT record. */
|
||||
*evolveAfterBattle(flags: number, forced = false, item = 0): Gen<boolean> {
|
||||
const r = this.reader,
|
||||
party = this.state.party;
|
||||
let tried = false;
|
||||
for (let slot = 0; slot < party.length; slot++) {
|
||||
if (!(flags & (1 << slot))) continue;
|
||||
const mon = party[slot];
|
||||
const oldSpecies = mon.species;
|
||||
// CopyMonToTempMon: the checks read this copy.
|
||||
const temp = structuredClone(mon);
|
||||
const everstone = () => mon.item === C.EVERSTONE;
|
||||
let p = r.near(
|
||||
MENU.EvosAttacksPointers + (oldSpecies - 1) * 2,
|
||||
r.bank(MENU.EvosAttacksPointers),
|
||||
);
|
||||
for (let guard = 0; ; guard++) {
|
||||
if (guard > 64) throw new Error("Invalid evolution data.");
|
||||
const method = r.byte(p++);
|
||||
if (!method) break;
|
||||
// No Link mode: trades never evolve here.
|
||||
if (method === C.EVOLVE_TRADE) {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (method === C.EVOLVE_ITEM) {
|
||||
const needed = r.byte(p++);
|
||||
if (item !== needed || !forced) {
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
} else if (forced) {
|
||||
p += 2;
|
||||
continue;
|
||||
} else if (method === C.EVOLVE_LEVEL) {
|
||||
const level = r.byte(p++);
|
||||
if (temp.level < level || everstone()) {
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
} else if (method === C.EVOLVE_HAPPINESS) {
|
||||
if (temp.happiness < C.HAPPINESS_TO_EVOLVE || everstone()) {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
const time = r.byte(p++);
|
||||
const night = this.host.timeOfDay() === C.NITE_F;
|
||||
if (
|
||||
(time === C.TR_MORNDAY && night) ||
|
||||
(time !== C.TR_ANYTIME && time !== C.TR_MORNDAY && !night)
|
||||
) {
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// EVOLVE_STAT (Tyrogue): level, then Attack against Defense.
|
||||
if (temp.level < r.byte(p) || everstone()) {
|
||||
p += 3;
|
||||
continue;
|
||||
}
|
||||
const [attack, defense] = [temp.stats[1], temp.stats[2]];
|
||||
const result =
|
||||
attack === defense
|
||||
? C.ATK_EQ_DEF
|
||||
: attack < defense
|
||||
? C.ATK_LT_DEF
|
||||
: C.ATK_GT_DEF;
|
||||
p++;
|
||||
if (r.byte(p) !== result) {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
tried = true;
|
||||
yield* this.evolve(slot, temp, r.byte(p), !forced);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tried;
|
||||
}
|
||||
|
||||
/** EvolveAfterBattle's .proceed: the animation (B stops it unless the
|
||||
* evolution is forced), then the new species, stats, moves and Pokédex. */
|
||||
private *evolve(
|
||||
slot: number,
|
||||
temp: PartyMon,
|
||||
species: number,
|
||||
cancelable: boolean,
|
||||
): Gen {
|
||||
const data = this.data,
|
||||
mon = this.state.party[slot];
|
||||
const oldSpecies = mon.species;
|
||||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] =
|
||||
mon.nickname;
|
||||
yield* this.print(MENU.EvolvingText, EVOLVING_TICKS);
|
||||
const stopped = yield {
|
||||
kind: "evolve",
|
||||
from: oldSpecies,
|
||||
to: species,
|
||||
cancelable,
|
||||
};
|
||||
if (stopped === 1) {
|
||||
yield* this.print(MENU.StoppedEvolvingText);
|
||||
yield { kind: "picture", species: 0 };
|
||||
return;
|
||||
}
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(species);
|
||||
yield {
|
||||
kind: "text",
|
||||
pages: [
|
||||
...this.pages(MENU.CongratulationsYourPokemonText),
|
||||
...this.pages(MENU.EvolvedIntoText),
|
||||
],
|
||||
};
|
||||
yield { kind: "picture", species: 0 };
|
||||
// UpdateSpeciesNameIfNotNicknamed: wBaseDexNo still names the old
|
||||
// species (the animation restores it).
|
||||
if (mon.nickname === data.speciesName(oldSpecies))
|
||||
temp.nickname = data.speciesName(species);
|
||||
// CalcMonStats with stat experience; the HP gains the max HP's rise.
|
||||
temp.species = species;
|
||||
const oldMax = mon.stats[0];
|
||||
temp.stats = calcStats(data, species, temp.dvs, temp.level, temp.statExp);
|
||||
temp.hp = (temp.hp + temp.stats[0] - oldMax) & 0xffff;
|
||||
Object.assign(mon, temp);
|
||||
yield* this.learnLevelMoves(slot, temp.level);
|
||||
// SetSeenAndCaughtMon
|
||||
this.state.pokedex.seen.add(species);
|
||||
this.state.pokedex.caught.add(species);
|
||||
}
|
||||
|
||||
/** LearnLevelMoves: the moves the species learns at exactly `level`,
|
||||
* unless the Pokémon knows them. */
|
||||
*learnLevelMoves(slot: number, level: number): Gen {
|
||||
const mon = this.state.party[slot];
|
||||
for (const learned of this.data.learnset(mon.species)) {
|
||||
if (learned.level !== level || mon.moves.includes(learned.move))
|
||||
continue;
|
||||
// GetMoveName and CopyName1
|
||||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] =
|
||||
this.data.moveName(learned.move);
|
||||
yield* this.learnMove(slot, learned.move);
|
||||
}
|
||||
}
|
||||
|
||||
/** LearnMove outside battle, with ForgetMove's choice. Returns true if
|
||||
* the move was learned. */
|
||||
*learnMove(slot: number, move: number): Gen<boolean> {
|
||||
const data = this.data,
|
||||
mon = this.state.party[slot];
|
||||
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
|
||||
for (;;) {
|
||||
let index = mon.moves.indexOf(0);
|
||||
if (index < 0) {
|
||||
index = yield* this.forgetMove(mon);
|
||||
if (index < 0) {
|
||||
const stop = yield {
|
||||
kind: "yesno",
|
||||
pages: this.pages(BATTLE.StopLearningMoveText),
|
||||
};
|
||||
if (!stop) continue;
|
||||
yield* this.print(BATTLE.DidNotLearnMoveText);
|
||||
return false;
|
||||
}
|
||||
this.strings[RAM.stringBuffer1] = data.moveName(mon.moves[index]);
|
||||
yield* this.print(BATTLE.Text_1_2_and_Poof);
|
||||
}
|
||||
mon.moves[index] = move;
|
||||
mon.pp[index] = data.move(move).pp;
|
||||
yield* this.print(BATTLE.LearnedMoveText);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/** ForgetMove: the move slot to replace, or -1 to stop. */
|
||||
private *forgetMove(mon: PartyMon): Gen<number> {
|
||||
const ask = yield {
|
||||
kind: "yesno",
|
||||
pages: this.pages(BATTLE.AskForgetMoveText),
|
||||
};
|
||||
if (!ask) return -1;
|
||||
const hmMoves = new Set<number>();
|
||||
for (let p = BATTLE["IsHMMove.HMMoves"]; this.reader.byte(p) !== 0xff; p++)
|
||||
hmMoves.add(this.reader.byte(p));
|
||||
for (;;) {
|
||||
const choice = yield {
|
||||
kind: "menu",
|
||||
pages: this.pages(BATTLE.MoveAskForgetText),
|
||||
items: mon.moves.map((move) => this.data.moveName(move)),
|
||||
selected: 0,
|
||||
};
|
||||
if (typeof choice !== "number" || choice < 0 || choice > 3) return -1;
|
||||
if (hmMoves.has(mon.moves[choice])) {
|
||||
yield* this.print(BATTLE.MoveCantForgetHMText);
|
||||
continue;
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** PlacePartyMonEvoStoneCompatibility: ABLE if an EVOLVE_ITEM entry needs
|
||||
* `item`. Like the original, it copies ten bytes and steps three at a time,
|
||||
* so a four-byte EVOLVE_STAT record or a fourth entry is misread; bytes
|
||||
* past the copy read as the end of the list here. */
|
||||
export function evoStoneCompatible(
|
||||
reader: RomReader,
|
||||
species: number,
|
||||
item: number,
|
||||
): boolean {
|
||||
const p = reader.near(
|
||||
MENU.EvosAttacksPointers + (species - 1) * 2,
|
||||
reader.bank(MENU.EvosAttacksPointers),
|
||||
);
|
||||
const buffer = Array.from({ length: 10 }, (_, i) => reader.byte(p + i));
|
||||
for (let i = 0; i < buffer.length; ) {
|
||||
const method = buffer[i++] ?? 0;
|
||||
if (!method) return false;
|
||||
i += 2;
|
||||
if (method === C.EVOLVE_ITEM && buffer[i - 2] === item) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -250,6 +250,9 @@ export type ScriptEffect =
|
||||
| { kind: "notice"; text: string }
|
||||
/** The Pokémon stats screen, closed with a button. */
|
||||
| { kind: "stats"; mon: PartyMon }
|
||||
/** EvolutionAnimation: `from` flashes into `to`. Answered with 1 when
|
||||
* B stopped it (only if `cancelable`), otherwise 0. */
|
||||
| { kind: "evolve"; from: number; to: number; cancelable: boolean }
|
||||
/** The pack's pockets (Pack and DepositSellPack), opened on `pocket`;
|
||||
* left and right change pockets. Answered with the pocket index × 256
|
||||
* plus the entry's index, or -1 for B. */
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
restoredPP,
|
||||
} from "../battle/battle";
|
||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||
import { maxPP, type PartyMon } from "../party/pokemon";
|
||||
import { calcStats, maxPP, type PartyMon } from "../party/pokemon";
|
||||
import { PartyEvolution, evoStoneCompatible } from "./evolution";
|
||||
import {
|
||||
copyScriptState,
|
||||
type MartEntry,
|
||||
@@ -86,6 +87,10 @@ export interface StartMenuHost {
|
||||
/** BikeFunction: the script that getting on or off the Bicycle queues
|
||||
* here, or undefined when .CheckEnvironment refuses it. */
|
||||
bicycleScript(state: ScriptState): number | undefined;
|
||||
/** wTimeOfDay: morning 0, day 1, night 2. */
|
||||
timeOfDay(): number;
|
||||
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||||
landmark(): number;
|
||||
}
|
||||
|
||||
/** The overworld start menu (engine/menus/start_menu.asm) with its
|
||||
@@ -269,7 +274,11 @@ export class StartMenuRun {
|
||||
|
||||
/** The party menu with PlacePartyMenuText's prompt. Returns the party
|
||||
* index, or -1 when cancelled. `from` marks SwitchPartyMons' source. */
|
||||
private *partyMenu(action: number, from?: number): Gen<number> {
|
||||
private *partyMenu(
|
||||
action: number,
|
||||
from?: number,
|
||||
item = 0,
|
||||
): Gen<number> {
|
||||
const r = this.reader,
|
||||
entry = MENU.PartyMenuStrings + action * 2;
|
||||
const prompt = this.string(r.near(entry, r.bank(entry)));
|
||||
@@ -277,9 +286,17 @@ export class StartMenuRun {
|
||||
const choice = yield* this.menu(
|
||||
[prompt],
|
||||
party.map((mon, slot) => (slot === from ? `▷${mon.nickname}` : mon.nickname)),
|
||||
party.map(
|
||||
(mon) =>
|
||||
`:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`,
|
||||
party.map((mon) =>
|
||||
// PlacePartyMonEvoStoneCompatibility replaces the HP bar.
|
||||
action === C.PARTYMENUACTION_EVO_STONE
|
||||
? mon.species === EGG
|
||||
? ""
|
||||
: this.string(
|
||||
evoStoneCompatible(r, mon.species, item)
|
||||
? MENU["PlacePartyMonEvoStoneCompatibility.string_able"]
|
||||
: MENU["PlacePartyMonEvoStoneCompatibility.string_not_able"],
|
||||
)
|
||||
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`,
|
||||
),
|
||||
// InitPartyMenuWithCancel: the last row chosen, if still listed.
|
||||
this.cursors.party >= 1 && this.cursors.party <= party.length
|
||||
@@ -698,6 +715,12 @@ export class StartMenuRun {
|
||||
}
|
||||
case BATTLE.RestorePPEffect:
|
||||
return yield* this.restorePPEffect(use);
|
||||
case MENU.RareCandyEffect:
|
||||
return yield* this.rareCandyEffect(use);
|
||||
case MENU.VitaminEffect:
|
||||
return yield* this.vitaminEffect(use);
|
||||
case MENU.EvoStoneEffect:
|
||||
return yield* this.evoStoneEffect(use);
|
||||
case MENU.BicycleEffect: {
|
||||
const script = this.host.bicycleScript(this.state);
|
||||
if (script === undefined) return false;
|
||||
@@ -712,8 +735,11 @@ export class StartMenuRun {
|
||||
|
||||
/** UseItem_SelectMon: the party menu for an item. The party index, or
|
||||
* -1 when cancelled or an Egg was chosen. */
|
||||
private *useItemSelectMon(): Gen<number> {
|
||||
const slot = yield* this.partyMenu(PARTYMENUACTION_HEALING_ITEM);
|
||||
private *useItemSelectMon(
|
||||
action: number = PARTYMENUACTION_HEALING_ITEM,
|
||||
item = 0,
|
||||
): Gen<number> {
|
||||
const slot = yield* this.partyMenu(action, undefined, item);
|
||||
if (slot < 0) return -1;
|
||||
if (this.state.party[slot].species === EGG) {
|
||||
yield* this.print(BATTLE.ItemCantUseOnEggText);
|
||||
@@ -825,6 +851,109 @@ export class StartMenuRun {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Evolution and move learning on this menu's draft. */
|
||||
private evolution() {
|
||||
return new PartyEvolution(this.state, this.host);
|
||||
}
|
||||
|
||||
/** RareCandyEffect: one level at the level's minimum experience, the
|
||||
* stats (and HP, even of a fainted Pokémon) raised, the level-up
|
||||
* happiness, new moves and a possible evolution. */
|
||||
private *rareCandyEffect(use: FieldItemUse): Gen<boolean> {
|
||||
const data = this.data;
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const mon = this.state.party[target];
|
||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
if (mon.level >= C.MAX_LEVEL) {
|
||||
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
||||
return false;
|
||||
}
|
||||
const level = ++mon.level;
|
||||
mon.exp = data.expAtLevel(data.baseData(mon.species).growthRate, level);
|
||||
const oldMax = mon.stats[0];
|
||||
mon.stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
|
||||
mon.hp = (mon.hp + mon.stats[0] - oldMax) & 0xffff;
|
||||
// LevelUpHappinessMod: more on the map where it was caught.
|
||||
this.changeHappiness(
|
||||
target,
|
||||
(mon.caughtData & 0x7f) === this.host.landmark()
|
||||
? C.HAPPINESS_GAINLEVELATHOME
|
||||
: C.HAPPINESS_GAINLEVEL,
|
||||
);
|
||||
this.numbers[RAM.curPartyLevel] = level;
|
||||
yield* this.itemActionText(target, C.PARTYMENUTEXT_LEVEL_UP);
|
||||
// PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def and Speed.
|
||||
const labels = readString(
|
||||
this.reader,
|
||||
BATTLE["PrintTempMonStats.StatNames"],
|
||||
this.text(),
|
||||
64,
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
const values = [1, 2, 4, 5, 3].map((stat) => mon.stats[stat]);
|
||||
yield {
|
||||
kind: "text",
|
||||
pages: [labels.map((label, i) => `${label} ${values[i]}`).join("\n")],
|
||||
};
|
||||
const evolution = this.evolution();
|
||||
yield* evolution.learnLevelMoves(target, level);
|
||||
// EvolvePokemon, not forced: a level or happiness evolution.
|
||||
yield* evolution.evolveAfterBattle(1 << target, false, use.item);
|
||||
this.useDisposableItem(use);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** VitaminEffect: ten more in the high byte of a stat's experience, up
|
||||
* to 100, then CalcMonStats (the current HP stays). */
|
||||
private *vitaminEffect(use: FieldItemUse): Gen<boolean> {
|
||||
const r = this.reader;
|
||||
const target = yield* this.useItemSelectMon();
|
||||
if (target < 0) return false;
|
||||
const mon = this.state.party[target];
|
||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
// GetStatExpRelativePointer: the stat experience word for the item.
|
||||
let p = MENU.StatExpItemPointerOffsets;
|
||||
while (r.byte(p) !== use.item) p += 2;
|
||||
const offset = r.byte(p + 1),
|
||||
stat = offset >> 1;
|
||||
if (mon.statExp[stat] >> 8 >= 100) {
|
||||
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
||||
return false;
|
||||
}
|
||||
mon.statExp[stat] += 10 << 8;
|
||||
mon.stats = calcStats(this.data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||||
const entry = MENU.StatStrings + offset;
|
||||
this.strings[RAM.stringBuffer2] = this.string(r.near(entry, r.bank(entry)));
|
||||
yield* this.print(MENU.ItemStatRoseText);
|
||||
this.changeHappiness(target, C.HAPPINESS_USEDITEM);
|
||||
this.useDisposableItem(use);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** EvoStoneEffect: a forced EvolvePokemon with the stone as wCurItem.
|
||||
* An Everstone or no matching evolution has no effect. */
|
||||
private *evoStoneEffect(use: FieldItemUse): Gen<boolean> {
|
||||
const target = yield* this.useItemSelectMon(
|
||||
C.PARTYMENUACTION_EVO_STONE,
|
||||
use.item,
|
||||
);
|
||||
if (target < 0) return false;
|
||||
if (this.state.party[target].item === C.EVERSTONE) {
|
||||
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!(yield* this.evolution().evolveAfterBattle(1 << target, true, use.item))
|
||||
) {
|
||||
yield* this.print(BATTLE.ItemWontHaveEffectText);
|
||||
return false;
|
||||
}
|
||||
this.useDisposableItem(use);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** RestorePPEffect for Ethers, Elixers, the MysteryBerry and PP Up. B on
|
||||
* the move list goes back to the party menu. */
|
||||
private *restorePPEffect(use: FieldItemUse): Gen<boolean> {
|
||||
|
||||
+111
-4
@@ -28,6 +28,7 @@ import {
|
||||
} from "../scripts/interpreter";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
|
||||
import { PartyEvolution } from "../scripts/evolution";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
import {
|
||||
@@ -254,6 +255,26 @@ export interface BattleView {
|
||||
/** Yes/No cursor, as on the original boxes. */
|
||||
selected: 0 | 1;
|
||||
}
|
||||
/** EvolutionAnimation in overworld ticks (two frames each): 80 frames on
|
||||
* the old species, then .AnimationSequence's seven rounds of a B-checking
|
||||
* wait (14, 12 … 2 frames) followed by that many flashes of the new and
|
||||
* old pictures (about four frames each). */
|
||||
function evolutionSchedule(from: number, to: number) {
|
||||
const frames: { species: number; cancel: boolean }[] = [];
|
||||
const show = (species: number, ticks: number, cancel = false) => {
|
||||
for (let i = 0; i < ticks; i++) frames.push({ species, cancel });
|
||||
};
|
||||
show(from, 40);
|
||||
for (let round = 1; round <= 7; round++) {
|
||||
show(from, 8 - round, true);
|
||||
for (let flash = 0; flash < round; flash++) {
|
||||
show(to, 2);
|
||||
show(from, 2);
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Ticks a `done` battle message stays before the battle continues. */
|
||||
const BATTLE_MESSAGE_TICKS = 30;
|
||||
|
||||
@@ -288,6 +309,12 @@ interface ActiveScript {
|
||||
warp?: { map: string; x: number; y: number; facing?: Direction };
|
||||
/** warpcheck: the player's tile events run on the tick after the script. */
|
||||
warpCheck?: boolean;
|
||||
/** Routines that run before the script resumes with `result`: the
|
||||
* evolutions ExitBattle starts after a won battle. */
|
||||
interlude?: {
|
||||
run: Generator<ScriptEffect, unknown, number | string | undefined>;
|
||||
result: number;
|
||||
};
|
||||
/** LoadMemScript: queued for the next PlayerEvents once this commits. */
|
||||
memScript?: MemScript;
|
||||
/** The trainer whose sight started this script, if any. */
|
||||
@@ -479,6 +506,17 @@ export class GameWorld {
|
||||
picture: number | undefined;
|
||||
/** The introduction's trainer or player picture, if any. */
|
||||
portrait: Portrait | undefined;
|
||||
/** An evolution animation in progress: the frame shown on each tick, and
|
||||
* whether B may stop it then. */
|
||||
evolution:
|
||||
| {
|
||||
from: number;
|
||||
to: number;
|
||||
cancelable: boolean;
|
||||
step: number;
|
||||
schedule: { species: number; cancel: boolean }[];
|
||||
}
|
||||
| undefined;
|
||||
/** True from a new game until the introduction finishes. */
|
||||
private introPending = false;
|
||||
/** The battle in progress, if any. */
|
||||
@@ -1609,6 +1647,14 @@ export class GameWorld {
|
||||
this.pump();
|
||||
}
|
||||
|
||||
private evolutionHost() {
|
||||
return {
|
||||
reader: this.assets.reader,
|
||||
data: this.assets.pokemon,
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
};
|
||||
}
|
||||
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(): boolean {
|
||||
@@ -1622,6 +1668,8 @@ export class GameWorld {
|
||||
data: this.assets.pokemon,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bicycleScript: (state) => this.bicycleScript(state),
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
landmark: () => this.assets.map(this.mapId).landmark,
|
||||
},
|
||||
this.menuCursors,
|
||||
),
|
||||
@@ -1679,8 +1727,21 @@ export class GameWorld {
|
||||
if (!script) return;
|
||||
try {
|
||||
for (;;) {
|
||||
const effect = script.run.resume(input);
|
||||
input = undefined;
|
||||
let effect: ScriptEffect | undefined;
|
||||
const interlude = script.interlude;
|
||||
if (interlude) {
|
||||
const next = interlude.run.next(input);
|
||||
input = undefined;
|
||||
if (next.done) {
|
||||
script.interlude = undefined;
|
||||
input = interlude.result;
|
||||
continue;
|
||||
}
|
||||
effect = next.value;
|
||||
} else {
|
||||
effect = script.run.resume(input);
|
||||
input = undefined;
|
||||
}
|
||||
script.effect = effect;
|
||||
if (!effect) {
|
||||
this.finishScript();
|
||||
@@ -1955,6 +2016,16 @@ export class GameWorld {
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "evolve":
|
||||
this.picture = effect.from;
|
||||
this.evolution = {
|
||||
from: effect.from,
|
||||
to: effect.to,
|
||||
cancelable: effect.cancelable,
|
||||
step: 0,
|
||||
schedule: evolutionSchedule(effect.from, effect.to),
|
||||
};
|
||||
return true;
|
||||
case "pack":
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
@@ -2350,6 +2421,7 @@ export class GameWorld {
|
||||
this.emote = undefined;
|
||||
this.picture = undefined;
|
||||
this.portrait = undefined;
|
||||
this.evolution = undefined;
|
||||
if (script.sighted !== undefined)
|
||||
this.refusedSighting = {
|
||||
map: this.mapId,
|
||||
@@ -2380,9 +2452,19 @@ export class GameWorld {
|
||||
);
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
// CleanUpBattleRAM
|
||||
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
|
||||
const { result, evolvable } = next.value;
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
this.pump(next.value.result);
|
||||
if (this.script && evolvable && !(result & 0x0f)) {
|
||||
this.script.interlude = {
|
||||
run: new PartyEvolution(
|
||||
this.script.run.state,
|
||||
this.evolutionHost(),
|
||||
).evolveAfterBattle(evolvable),
|
||||
result,
|
||||
};
|
||||
this.pump();
|
||||
} else this.pump(result);
|
||||
return;
|
||||
}
|
||||
const effect = next.value;
|
||||
@@ -2793,6 +2875,13 @@ export class GameWorld {
|
||||
}
|
||||
const script = this.script;
|
||||
if (script) {
|
||||
const evolution = this.evolution;
|
||||
if (evolution) {
|
||||
const frame = evolution.schedule[++evolution.step];
|
||||
if (frame) this.picture = frame.species;
|
||||
else this.finishEvolution(false);
|
||||
return;
|
||||
}
|
||||
const dialogue = this.dialogue;
|
||||
if (dialogue) {
|
||||
if (
|
||||
@@ -3198,9 +3287,25 @@ export class GameWorld {
|
||||
)
|
||||
state.playerState = OW.PLAYER_NORMAL;
|
||||
}
|
||||
/** EvolutionAnimation ends: the new species, or the old one if B
|
||||
* stopped it. */
|
||||
private finishEvolution(stopped: boolean) {
|
||||
const evolution = this.evolution!;
|
||||
this.evolution = undefined;
|
||||
this.picture = stopped ? evolution.from : evolution.to;
|
||||
this.pump(stopped ? 1 : 0);
|
||||
}
|
||||
/** B, or A on a page without a choice. */
|
||||
advanceDialogue() {
|
||||
if (this.battle) return this.battleB();
|
||||
const evolution = this.evolution;
|
||||
if (evolution) {
|
||||
// .WaitFrames_CheckPressedB: only between flashes, and never for a
|
||||
// forced (evolution stone) evolution.
|
||||
if (evolution.cancelable && evolution.schedule[evolution.step]?.cancel)
|
||||
this.finishEvolution(true);
|
||||
return;
|
||||
}
|
||||
const dialogue = this.dialogue;
|
||||
if (!dialogue || !this.script) return;
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
@@ -3587,9 +3692,11 @@ export class GameWorld {
|
||||
this.emote = undefined;
|
||||
this.picture = undefined;
|
||||
this.portrait = undefined;
|
||||
this.evolution = undefined;
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
this.memScript = undefined;
|
||||
this.menuCursors = { start: 0, party: 0, pocket: 0 };
|
||||
this.clearNotice();
|
||||
this.introPending = false;
|
||||
// A save made during the introduction starts it again.
|
||||
|
||||
Reference in new issue
Block a user