Port evolution, Rare Candy, evolution stones and vitamins
Checks / web (push) Canceled after 0s

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:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 16:21:59 +02:00
1 parent 1af2beacdc
commit 45275ba83b
19 files changed
+1032 -39

No files matched your search

+13 -3
View File
@@ -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();
}
+23
View File
@@ -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,
+13
View File
@@ -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
+296
View File
@@ -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;
}
+3
View File
@@ -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. */
+136 -7
View File
@@ -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
View File
@@ -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.
+6 -3
View File
@@ -264,7 +264,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
const key = JSON.stringify(mart);
if (key === martMenuKey) return;
martMenuKey = key;
const menu = get("mart-menu");
const menu = get("mart-menu"),
tabs = get("mart-tabs");
tabs.hidden = mart.kind !== "pack";
const button = (
label: string,
action: () => void,
@@ -299,7 +301,7 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
}
if (mart.kind === "pack") {
// Pocket tabs (left and right on the keyboard), then the pocket's items.
const tabs = mart.pockets.map((pocket, index) => {
const pocketTabs = mart.pockets.map((pocket, index) => {
const tab = button(pocket.name, () => {
const world = runtime.world;
const choice = world?.dialogue?.choice;
@@ -328,7 +330,8 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
buttons.push(empty);
}
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
menu.replaceChildren(...tabs, ...buttons);
tabs.replaceChildren(...pocketTabs);
menu.replaceChildren(...buttons);
return;
}
if (mart.kind === "martlist") {
+1 -1
View File
@@ -36,7 +36,7 @@ export function renderShell(root: HTMLElement) {
<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">
<p id="mart-money" class="mart-money"></p>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-tabs" class="pack-tabs" aria-label="Pockets" hidden></div><div id="mart-menu" class="mart-menu"></div></div>
</div>
<div id="intro-screen" class="intro-screen" hidden></div>
<div id="pokepic" class="pokepic" hidden><canvas id="pokepic-canvas" width="56" height="56" role="img"></canvas><button id="pokepic-next" hidden>Continue <span aria-hidden="true">▶</span></button></div>
+17
View File
@@ -597,6 +597,23 @@ dialog h2 {
margin: 0 0 6px;
white-space: pre-line;
}
.pack-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
margin-bottom: 8px;
}
.pack-tabs button {
padding: 4px;
font-size: 0.75rem;
color: var(--ink);
background: var(--cream-dark);
border: 3px double var(--ink);
}
.pack-tabs button[aria-pressed="true"] {
color: #fff;
background: var(--blue);
}
.mart-menu {
display: grid;
gap: 6px;