Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)

- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 08:52:30 +02:00
1 parent 7d03f5c5ca
commit e49bc6de9a
49 files changed
+6028 -88

No files matched your search

+2 -2
View File
@@ -3622,8 +3622,8 @@ export class Battle {
}
const mon = party[this.beatUpMon];
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party.");
if (party.some((member) => member.egg))
throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet.");
// Beat Up reads the party struct, never wPartySpecies: Eggs can hit
// if their stored HP and status allow it (an original quirk).
// The player's members go by nickname, a trainer's by species name.
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
const skip = () => this.skipTo(COMMAND.buildopponentrage);
+549
View File
@@ -0,0 +1,549 @@
import { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, JOHTO, SYMBOLS } from "../rom/profile";
import { defaultTextContext, readString } from "../scripts/text";
import {
calcStats,
initialMoves,
levelForExp,
maxPP,
validBoxFields,
type PartyMon,
} from "./pokemon";
import { toBoxMon, type BoxMon } from "./storage";
/** The Day-Care's bytes (wDayCareMan through wEggMon in ram/wram.asm).
* The three engine flags it also holds (ENGINE_DAY_CARE_MAN_HAS_EGG,
* ENGINE_DAY_CARE_MAN_HAS_MON and ENGINE_DAY_CARE_LADY_HAS_MON, bits of
* wDayCareMan and wDayCareLady) live in the script state's flags, where
* checkflag, setflag and clearflag find them; `mon1`/`mon2` keep
* both in step. The Pokémon are kept with their nickname and OT, as
* wBreedMon1Nickname/OT/box_struct are. */
export interface DayCareState {
/** wDayCareMan bit 7 (DAYCAREMAN_ACTIVE_F): the man has been spoken to. */
manActive: boolean;
/** wDayCareLady bit 7 (DAYCARELADY_ACTIVE_F): so has the lady. */
ladyActive: boolean;
/** wDayCareMan bit 5 (DAYCAREMAN_MONS_COMPATIBLE_F): an egg may come. */
compatible: boolean;
/** wStepsToEgg: DayCareStep counts it down while the Pokémon are
* compatible. */
stepsToEgg: number;
/** wBreedMotherOrNonDitto: 0 when wBreedMon1 is the mother (or the non-
* Ditto parent), else 1. Kept after the Pokémon leave, as in WRAM. */
motherOrNonDitto: number;
/** wBreedMon1 (the man's) and wBreedMon2 (the lady's); null while the
* ENGINE_DAY_CARE_*_HAS_MON flag is clear. */
mon1: BoxMon | null;
mon2: BoxMon | null;
/** wEggMon, with its nickname "EGG" and the player's name as OT: the egg
* InitBreeding made, which the man hands over; null before the first. */
egg: BoxMon | null;
}
export const emptyDayCare = (): DayCareState => ({
manActive: false,
ladyActive: false,
compatible: false,
stepsToEgg: 0,
motherOrNonDitto: 0,
mon1: null,
mon2: null,
egg: null,
});
export const copyDayCare = (dayCare: DayCareState): DayCareState =>
structuredClone(dayCare);
/** What the Day-Care routines read of the script state. */
export interface DayCareHost {
flags: Set<number>;
dayCare: DayCareState;
}
const DITTO = 132;
const NIDORAN_F = 29;
const NIDORAN_M = 32;
/** EGG_LEVEL, EGG_NONE * $11 and MAX_LEVEL. */
export const EGG_LEVEL = 5;
const EGG_NONE_GROUPS = 0xff;
const MAX_LEVEL = 100;
/** MAX_DAY_CARE_EXP >> 8, as its top byte is compared. */
const MAX_DAY_CARE_EXP_HIGH = 0x50;
/** The three ENGINE_DAY_CARE_* flags. */
export const DAY_CARE_FLAGS = [
ENGINE_FLAGS.dayCareManHasEgg,
ENGINE_FLAGS.dayCareManHasMon,
ENGINE_FLAGS.dayCareLadyHasMon,
] as const;
/** A stored Pokémon's moves, as the 4-slot tuple the structs hold. */
type Moves = BoxMon["moves"];
/** CheckBreedmonCompatibility's .CheckBreedingGroupCompatibility: either
* Pokémon in the No Eggs group (EGG_NONE) rules breeding out; a Ditto
* suits every other group; otherwise some egg group must be shared. */
function sharesEggGroup(
data: PokemonData,
species1: number,
species2: number,
): boolean {
const groups1 = data.baseData(species1).eggGroups,
groups2 = data.baseData(species2).eggGroups;
if (groups2 === EGG_NONE_GROUPS || groups1 === EGG_NONE_GROUPS) return false;
if (species2 === DITTO) return true;
const b = groups2 & 15,
c = groups2 >> 4;
if (species1 === DITTO) return true;
const d = groups1 & 15,
e = groups1 >> 4;
return d === b || d === c || e === b || e === c;
}
/** CheckBreedmonCompatibility (wBreedingCompatibility): 0 when they will
* not breed, 255 when their DVs are too alike (shown as "brimming with
* energy", and treated as no breeding by DayCare_InitBreeding), else 254
* for the same species and 128 for different ones, less 77 when the OT IDs
* match. Pokémon of the same gender are compared as if genderless. */
export function breedingCompatibility(
data: PokemonData,
mon1: BoxMon,
mon2: BoxMon,
): number {
if (!sharesEggGroup(data, mon1.species, mon2.species)) return 0;
const gender1 = data.gender(mon1.species, mon1.dvs);
const gender2 = data.gender(mon2.species, mon2.dvs);
// A pair of differing genders goes straight to .compute; everything
// else (genderless, or the same gender) needs a Ditto.
if (gender1 === undefined || gender2 === undefined || gender1 === gender2) {
if (mon1.species === DITTO) {
if (mon2.species === DITTO) return 0;
} else if (mon2.species !== DITTO) return 0;
}
// .CheckDVs: equal Defense DVs and equal low 3 bits of the Special DVs.
if (
(mon1.dvs & 0x0f00) === (mon2.dvs & 0x0f00) &&
(mon1.dvs & 7) === (mon2.dvs & 7)
)
return 255;
let compatibility = mon1.species === mon2.species ? 254 : 128;
if (mon1.otId === mon2.otId) compatibility -= 77;
return compatibility;
}
/** GetHeritableMoves: whose moves the egg may learn from (the father's;
* a Ditto's own, if it is the "father"). */
export function heritableMoves(
data: PokemonData,
dayCare: DayCareState,
): Moves {
const mon1 = dayCare.mon1!,
mon2 = dayCare.mon2!;
if (mon1.species === DITTO)
return data.gender(mon2.species, mon2.dvs) === "female"
? mon1.moves
: mon2.moves;
if (mon2.species === DITTO)
return data.gender(mon1.species, mon1.dvs) === "female"
? mon2.moves
: mon1.moves;
return dayCare.motherOrNonDitto === 0 ? mon2.moves : mon1.moves;
}
/** GetBreedmonMovePointer: the Ditto's moves if there is one (although
* the mother is the other parent), else the mother's. */
function motherMoves(dayCare: DayCareState): Moves {
const mon1 = dayCare.mon1!,
mon2 = dayCare.mon2!;
if (mon1.species === DITTO) return mon1.moves;
if (mon2.species === DITTO) return mon2.moves;
return dayCare.motherOrNonDitto === 0 ? mon1.moves : mon2.moves;
}
/** GetEggMove: whether the egg inherits a move of the father's. It must be
* an egg move of the baby, or a level-up move of the baby that the mother
* knows too, or a TM, HM or tutor move the baby can learn. */
function inheritsMove(
data: PokemonData,
dayCare: DayCareState,
species: number,
move: number,
): boolean {
if (data.eggMoves(species).includes(move)) return true;
if (
motherMoves(dayCare).includes(move) &&
data.learnset(species).some((entry) => entry.move === move)
)
return true;
return data.canLearnTMHM(species, move);
}
/** LoadEggMove: into the first empty slot, else the moves shift up and the
* new one takes the last. */
function loadEggMove(moves: number[], move: number) {
const slot = moves.indexOf(0);
if (slot >= 0) moves[slot] = move;
else moves.splice(0, 4, moves[1], moves[2], moves[3], move);
}
/** FillPP: the base PP of each move, 0 for an empty slot. */
const basePP = (data: PokemonData, moves: readonly number[]) =>
moves.map((move) => (move ? data.move(move).pp : 0)) as BoxMon["pp"];
/** InitEggMoves: the father's moves (up to the first empty slot) that the
* egg does not know and GetEggMove accepts. */
function initEggMoves(data: PokemonData, dayCare: DayCareState, egg: BoxMon) {
const moves: number[] = [...egg.moves];
for (const move of heritableMoves(data, dayCare)) {
if (!move) break;
if (moves.includes(move)) continue;
if (inheritsMove(data, dayCare, egg.species, move))
loadEggMove(moves, move);
}
egg.moves = moves as Moves;
egg.pp = basePP(data, moves);
}
/** DayCare_InitBreeding's .UselessJump onwards: the egg for these two
* Pokémon. Draws for the egg species (Nidoran♀ only) and the DVs, in the
* original's order. */
function makeEgg(
data: PokemonData,
dayCare: DayCareState,
player: { id: number; name: string },
random: () => number,
): BoxMon {
const mon1 = dayCare.mon1!,
mon2 = dayCare.mon2!;
// wBreedMotherOrNonDitto: 1 if wBreedMon2 is the mother (or the non-
// Ditto parent), else 0; z (female) in GetGender is the mother.
if (mon1.species === DITTO) dayCare.motherOrNonDitto = 1;
else if (mon2.species === DITTO) dayCare.motherOrNonDitto = 0;
else
dayCare.motherOrNonDitto =
data.gender(mon1.species, mon1.dvs) === "female" ? 0 : 1;
// The mother's species, then GetPreEvolution twice.
let species = dayCare.motherOrNonDitto === 0 ? mon1.species : mon2.species;
species = data.preEvolution(data.preEvolution(species));
// Nidoran♀ can give birth to either gender of Nidoran.
if (species === NIDORAN_F && !(random() < 0x80)) species = NIDORAN_M;
const base = data.baseData(species);
const egg: BoxMon = {
species,
item: 0,
moves: initialMoves(data, species, EGG_LEVEL),
otId: player.id,
ot: player.name,
nickname: readString(
data.reader,
SYMBOLS.stringEgg,
defaultTextContext(""),
),
exp: data.expAtLevel(base.growthRate, EGG_LEVEL),
statExp: [0, 0, 0, 0, 0],
dvs: 0,
pp: [0, 0, 0, 0],
happiness: base.eggCycles,
pokerus: 0,
caughtData: 0,
level: EGG_LEVEL,
egg: true,
};
initEggMoves(data, dayCare, egg);
// Two random bytes of DVs; the Defense DV and the low 3 bits of the
// Special DVs then come from one parent (.GotDVs).
const attackDefense = random(),
speedSpecial = random();
let dvs = (attackDefense << 8) | speedSpecial;
let parent: BoxMon | undefined;
if (mon1.species === DITTO) parent = mon1;
else if (mon2.species === DITTO) parent = mon2;
else {
const gender = data.gender(species, dvs);
// A genderless egg (.SkipDVs) keeps its random DVs; a female egg takes
// them from the father, a male egg from the mother.
if (gender === "female")
parent = dayCare.motherOrNonDitto !== 0 ? mon1 : mon2;
else if (gender === "male")
parent = dayCare.motherOrNonDitto === 0 ? mon1 : mon2;
}
if (parent) dvs = (dvs & 0xf0f8) | (parent.dvs & 0x0f00) | (parent.dvs & 7);
egg.dvs = dvs;
return egg;
}
/** DayCare_InitBreeding: when both are in and will breed (not 0 or 255),
* the steps to the next egg check (a draw of at least 150) and the egg
* are prepared, replacing any earlier. */
export function initBreeding(
data: PokemonData,
host: DayCareHost,
player: { id: number; name: string },
random: () => number,
) {
const dayCare = host.dayCare;
if (!host.flags.has(ENGINE_FLAGS.dayCareLadyHasMon)) return;
if (!host.flags.has(ENGINE_FLAGS.dayCareManHasMon)) return;
const compatibility = breedingCompatibility(
data,
dayCare.mon1!,
dayCare.mon2!,
);
if (compatibility === 0 || compatibility === 255) return;
dayCare.compatible = true;
let steps: number;
do steps = random();
while (steps < 150);
dayCare.stepsToEgg = steps;
dayCare.egg = makeEgg(data, dayCare, player, random);
}
/** The percent chance DayCareStep checks (as a byte to beat) for each
* band of compatibility: 31 percent + 1, 16, 12 and 4 percent. */
function eggChance(compatibility: number) {
if (compatibility >= 230) return Math.floor((31 * 0xff) / 100) + 1;
if (compatibility >= 170) return Math.floor((16 * 0xff) / 100);
if (compatibility >= 110) return Math.floor((12 * 0xff) / 100);
return Math.floor((4 * 0xff) / 100);
}
/** One step's experience for a Day-Care Pokémon: the 24-bit increment of
* DayCareStep, with its (unreachable) cap on the top byte. */
function gainStepExp(mon: BoxMon) {
if (mon.level >= MAX_LEVEL) return;
let low = (mon.exp & 0xff) + 1,
middle = (mon.exp >> 8) & 0xff,
high = mon.exp >> 16;
if (low > 0xff) {
low = 0;
if (++middle > 0xff) {
middle = 0;
high = (high + 1) & 0xff;
if (high >= MAX_DAY_CARE_EXP_HIGH) high = MAX_DAY_CARE_EXP_HIGH;
}
}
mon.exp = (high << 16) | (middle << 8) | low;
}
/** DayCareStep (engine/events/happiness_egg.asm): +1 experience for each
* Pokémon, then, while the pair is compatible, the egg check every
* wStepsToEgg steps. */
export function dayCareStep(
data: PokemonData,
host: DayCareHost,
random: () => number,
) {
const dayCare = host.dayCare;
if (host.flags.has(ENGINE_FLAGS.dayCareManHasMon) && dayCare.mon1)
gainStepExp(dayCare.mon1);
if (host.flags.has(ENGINE_FLAGS.dayCareLadyHasMon) && dayCare.mon2)
gainStepExp(dayCare.mon2);
if (!dayCare.compatible) return;
dayCare.stepsToEgg = (dayCare.stepsToEgg - 1) & 0xff;
if (dayCare.stepsToEgg) return;
dayCare.stepsToEgg = random();
const compatibility = breedingCompatibility(
data,
dayCare.mon1!,
dayCare.mon2!,
);
if (random() >= eggChance(compatibility)) return;
dayCare.compatible = false;
host.flags.add(ENGINE_FLAGS.dayCareManHasEgg);
}
/** GetBreedMon1LevelGrowth: the stored level, the level the experience now
* gives (CalcLevel) and the difference (a byte). */
export function breedLevelGrowth(data: PokemonData, mon: BoxMon) {
const level = levelForExp(data, mon.species, mon.exp);
return { oldLevel: mon.level, level, growth: (level - mon.level) & 0xff };
}
/** GetPriceToRetrieveBreedmon: ¥100, and ¥100 for every level gained. */
export const retrievalPrice = (growth: number) => (growth * 100 + 100) & 0xffff;
/** DepositBreedmon: the box_struct, nickname and OT of a party Pokémon. */
export const depositedMon = (mon: PartyMon): BoxMon => toBoxMon(mon);
/** FillMoves with wSkipMovesBeforeLevelUp set: the level-up moves above
* `previous` up to `level`, each shifting out the first move when all four
* slots are full. wSkipMovesBeforeLevelUp is the byte wEvolutionOldSpecies
* too, which RetrieveBreedmon leaves at TRUE: so FillMoves shifts the PP
* bytes with the moves and gives a learned move its base PP, as it does
* during an evolution. */
function learnMovesBetween(
data: PokemonData,
moves: Moves,
pp: BoxMon["pp"],
species: number,
previous: number,
level: number,
) {
const learned: number[] = [...moves],
points: number[] = [...pp];
for (const entry of data.learnset(species)) {
if (entry.level > level) break;
if (entry.level <= previous || learned.includes(entry.move)) continue;
let slot = learned.indexOf(0);
if (slot < 0) {
learned.shift();
points.shift();
learned.push(0);
points.push(0);
slot = 3;
}
learned[slot] = entry.move;
points[slot] = data.move(entry.move).pp;
}
return { moves: learned as Moves, pp: points as BoxMon["pp"] };
}
/** RetrieveBreedmon: the Pokémon rejoins the party at its new level with
* stats from its stat experience, the moves it learned meanwhile, full HP
* and PP (HealPartyMon), and its experience set to the new level's
* minimum: the original loses the experience beyond it ("Pokémon
* deposited in the Day-Care might lose experience"). */
export function retrievedMon(
data: PokemonData,
stored: BoxMon,
previousLevel: number,
level: number,
): PartyMon {
const mon = structuredClone(stored);
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
const learned = learnMovesBetween(
data,
mon.moves,
mon.pp,
mon.species,
previousLevel,
level,
);
return {
...mon,
moves: learned.moves,
// RestoreAllPP: full PP, keeping each move's PP Up bits.
pp: learned.moves.map((move, slot) =>
move ? maxPP(data, move, learned.pp[slot] >> 6) : learned.pp[slot],
) as BoxMon["pp"],
exp: data.expAtLevel(data.baseData(mon.species).growthRate, level),
level,
status: 0,
stats,
hp: stats[0],
};
}
/** DayCare_GiveEgg: wEggMon joins the party as an Egg: stats without stat
* experience and no HP. */
export function eggAsPartyMon(data: PokemonData, egg: BoxMon): PartyMon {
return {
...structuredClone(egg),
egg: true,
status: 0,
stats: calcStats(data, egg.species, egg.dvs, egg.level),
hp: 0,
};
}
/** NICKNAMED_MON_STRUCT_LENGTH: party_struct and a nickname. */
const NICKNAMED_MON_STRUCT_LENGTH = 48 + 11;
/** A party_struct from the ROM (data/events/odd_eggs.asm): big-endian
* words, as the structs in WRAM. */
function partyMonAt(data: PokemonData, p: number): PartyMon {
const r = data.reader,
word = (at: number) => (r.byte(at) << 8) | r.byte(at + 1);
const moves = [0, 1, 2, 3].map((i) => r.byte(p + 2 + i)) as Moves;
return {
species: r.byte(p),
item: r.byte(p + 1),
moves,
otId: word(p + 6),
ot: "",
nickname: readString(r, p + 48, defaultTextContext("")),
exp: (r.byte(p + 8) << 16) | word(p + 9),
statExp: [0, 1, 2, 3, 4].map((i) =>
word(p + 11 + i * 2),
) as PartyMon["statExp"],
dvs: word(p + 21),
pp: [0, 1, 2, 3].map((i) => r.byte(p + 23 + i)) as BoxMon["pp"],
happiness: r.byte(p + 27),
pokerus: r.byte(p + 28),
caughtData: word(p + 29),
level: r.byte(p + 31),
status: r.byte(p + 32),
hp: word(p + 34),
stats: [0, 1, 2, 3, 4, 5].map((i) =>
word(p + 36 + i * 2),
) as PartyMon["stats"],
};
}
/** _GiveOddEgg: one Random call gives hRandomSub and hRandomAdd; the first
* OddEggProbabilities entry at or above that word picks the OddEggs row.
* The Egg's OT is "ODD" and its name EGG, as AddMobileMonToParty copies
* them; the row is a ready-made party_struct (OT IDs of 1 to 16, the
* `dw` values' bytes swapped by the big-endian reading). */
export function oddEgg(data: PokemonData, random: () => number): PartyMon {
const r = data.reader;
const sub = random(),
add = random();
let index = 0;
for (; ; index++) {
const entry = JOHTO.OddEggProbabilities + index * 2;
const low = r.byte(entry),
high = r.byte(entry + 1);
if (high === 0xff && low === 0xff) break;
if (sub < high) break;
if (sub === high && add <= low) break;
}
const mon = partyMonAt(
data,
JOHTO.OddEggs + index * NICKNAMED_MON_STRUCT_LENGTH,
);
mon.ot = readString(r, JOHTO["_GiveOddEgg.Odd"], defaultTextContext(""));
mon.egg = true;
return mon;
}
/** The structure of the saved Day-Care, checked against the flags that
* mirror it. */
export function validDayCare(
data: PokemonData,
value: unknown,
flags: ReadonlySet<number>,
): value is DayCareState {
const dayCare = value as DayCareState;
const integer = (value: unknown, min: number, max: number) =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
try {
const mon = (stored: unknown, present: boolean, egg: boolean) =>
present
? validBoxFields(stored) &&
Object.keys(stored as object).length === (egg ? 15 : 14) &&
(egg ? (stored as BoxMon).egg === true : !(stored as BoxMon).egg) &&
data.baseData((stored as BoxMon).species) !== undefined
: stored === null;
return (
Boolean(dayCare) &&
typeof dayCare === "object" &&
typeof dayCare.manActive === "boolean" &&
typeof dayCare.ladyActive === "boolean" &&
typeof dayCare.compatible === "boolean" &&
integer(dayCare.stepsToEgg, 0, 255) &&
integer(dayCare.motherOrNonDitto, 0, 1) &&
mon(dayCare.mon1, flags.has(ENGINE_FLAGS.dayCareManHasMon), false) &&
mon(dayCare.mon2, flags.has(ENGINE_FLAGS.dayCareLadyHasMon), false) &&
// An egg waiting needs the egg itself; nothing is compatible without
// both Pokémon.
mon(dayCare.egg, dayCare.egg !== null, true) &&
(dayCare.egg !== null || !flags.has(ENGINE_FLAGS.dayCareManHasEgg)) &&
(!dayCare.compatible ||
(dayCare.mon1 !== null &&
dayCare.mon2 !== null &&
dayCare.egg !== null &&
!flags.has(ENGINE_FLAGS.dayCareManHasEgg)))
);
} catch {
return false;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { PartyMon } from "./pokemon";
/** The happiness a Pokémon has just after hatching (HatchEggs: `ld [hl], $78`),
* which replaces the Egg's remaining cycles. */
export const HATCHED_HAPPINESS = 0x78;
/** The counters CountStep keeps in work RAM (wStepCount,
* wPoisonStepCount) and StepHappiness's toggle (wHappinessStepCount). */
export interface StepCounters {
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
}
/** StepHappiness (engine/events/happiness_egg.asm): called each time
* wStepCount wraps (every 256 steps), it raises the happiness of every
* Pokémon that is not an Egg by one on every other call, so once per 512
* steps, up to 255. It does not go through ChangeHappiness: no bonus and
* no cap other than the byte's, and a Pokémon already at 255 stays there. */
export function stepHappiness(
counters: Pick<StepCounters, "happinessStepCount">,
party: PartyMon[],
) {
counters.happinessStepCount = (counters.happinessStepCount + 1) & 1;
if (counters.happinessStepCount) return;
for (const mon of party)
if (!mon.egg) mon.happiness = Math.min(0xff, mon.happiness + 1);
}
/** DoEggStep (engine/pokemon/breeding.asm): from the first slot, each Egg's
* cycles (kept in its happiness byte) drop by one until one reaches zero,
* which ends the loop and reports that an Egg is ready to hatch; the Eggs
* after it are not counted down on that step. A byte already at zero
* wraps to 255 (`dec [hl]` has no floor) and does not hatch. */
export function doEggStep(party: PartyMon[]): boolean {
for (const mon of party) {
if (!mon.egg) continue;
mon.happiness = (mon.happiness - 1) & 0xff;
if (!mon.happiness) return true;
}
return false;
}
+108
View File
@@ -0,0 +1,108 @@
import { NUM_POKEMON } from "../rom/profile";
import { NICKNAME_PATTERN, PARTY_LENGTH, type PartyMon } from "./pokemon";
/** NUM_HOF_TEAMS: the teams sHallOfFame keeps; HOF_MASTER_COUNT: where
* wHallOfFameCount stops (constants/misc_constants.asm). */
export const NUM_HOF_TEAMS = 30;
export const HOF_MASTER_COUNT = 200;
/** HOF_MON_LENGTH: only the fields GetHallOfFameParty copies. */
export interface HallOfFameMon {
species: number;
otId: number;
dvs: number;
level: number;
nickname: string;
}
/** One sHallOfFame entry (HOF_LENGTH): the win it records and the team
* that won, without its Eggs. */
export interface HallOfFameTeam {
/** wHallOfFameCount when the team entered, capped at HOF_MASTER_COUNT. */
win: number;
mons: HallOfFameMon[];
}
export interface HallOfFameRecords {
/** wHallOfFameCount: the League wins, up to HOF_MASTER_COUNT. */
hallOfFameCount: number;
/** sHallOfFame, newest first. Separate from STATUSFLAGS_HALL_OF_FAME_F. */
hallOfFameTeams: HallOfFameTeam[];
}
/** GetHallOfFameParty: skip Eggs, copy the six recorded fields, and keep
* party order. An all-Egg party produces an empty team. */
export function getHallOfFameParty(
party: readonly PartyMon[],
win: number,
): HallOfFameTeam {
return {
win,
mons: party
.filter((mon) => !mon.egg)
.map(({ species, otId, dvs, level, nickname }) => ({
species,
otId,
dvs,
level,
nickname,
})),
};
}
/** HallOfFame's capped count increment, then AddHallOfFameEntry: the
* newest team goes first and the oldest of a full sHallOfFame drops off.
* The League's ceremony is not ported, so nothing calls this yet; it
* leaves STATUSFLAGS_HALL_OF_FAME_F (`hallOfFame`) to its caller. */
export function addHallOfFameEntry(
records: HallOfFameRecords,
party: readonly PartyMon[],
) {
records.hallOfFameCount = Math.min(
HOF_MASTER_COUNT,
records.hallOfFameCount + 1,
);
records.hallOfFameTeams.unshift(
getHallOfFameParty(party, records.hallOfFameCount),
);
records.hallOfFameTeams.length = Math.min(
NUM_HOF_TEAMS,
records.hallOfFameTeams.length,
);
}
/** Structural records validation; no stats, party ownership or current
* species are required of a historical entry. */
export function validHallOfFame(
count: unknown,
value: unknown,
): value is HallOfFameTeam[] {
const integer = (n: unknown, min: number, max: number): n is number =>
Number.isInteger(n) && Number(n) >= min && Number(n) <= max;
if (
!integer(count, 0, HOF_MASTER_COUNT) ||
!Array.isArray(value) ||
value.length > NUM_HOF_TEAMS
)
return false;
if ((count === 0) !== (value.length === 0) || value.length > count)
return false;
return value.every(
(team, index) =>
team !== null &&
typeof team === "object" &&
integer(team.win, 1, count) &&
(index === 0 ? team.win === count : team.win <= value[index - 1].win) &&
Array.isArray(team.mons) &&
team.mons.length <= PARTY_LENGTH &&
team.mons.every(
(mon: HallOfFameMon) =>
mon !== null &&
typeof mon === "object" &&
integer(mon.species, 1, NUM_POKEMON) &&
integer(mon.otId, 0, 65535) &&
integer(mon.dvs, 0, 65535) &&
integer(mon.level, 1, 100) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname),
),
);
}
+4 -3
View File
@@ -22,11 +22,12 @@ export function drawTileScreen(
// background's own color index to clip against; a screen with none
// pays nothing beyond the allocation.
const indices = new Uint8Array(width * height);
const scrollX = screen.scroll?.x ?? 0;
const scrollY = screen.scroll?.y ?? 0;
for (let row = scrollY < 0 ? -1 : 0; row < SCREEN_HEIGHT + (scrollY > 0 ? 1 : 0); row++)
for (let col = 0; col < SCREEN_WIDTH; col++) {
for (let col = scrollX < 0 ? -1 : 0; col < SCREEN_WIDTH + (scrollX > 0 ? 1 : 0); col++) {
const cell = row * SCREEN_WIDTH + col;
const inside = row >= 0 && row < SCREEN_HEIGHT;
const inside = row >= 0 && row < SCREEN_HEIGHT && col >= 0 && col < SCREEN_WIDTH;
const tile = inside ? screen.map[cell] : screen.scroll!.fill;
if (tile < 0) continue;
drawTile(
@@ -35,7 +36,7 @@ export function drawTileScreen(
height,
screen.tiles,
tile,
col * 8,
col * 8 - scrollX,
row * 8 - scrollY,
(inside && screen.palettes[screen.attrs[cell]]) || screen.palettes[0],
{ indices },
+16 -1
View File
@@ -5,6 +5,7 @@ import {
MAP_GROUP_COUNT,
NATIVE_MAPS,
NUM_ENGINE_FLAGS,
NUM_POKEMON,
NUM_SPRITEMOVEDATA,
OVERWORLD,
PHONE,
@@ -24,6 +25,10 @@ import type {
FacingTile,
} from "../world/types";
/** Sprite ids above the one-byte range ask for a Pokémon's overworld icon by
* species (MON_SPRITE_ID + species). */
export const MON_SPRITE_ID = 0x100;
/** The only layer that knows where Crystal stores its data. Everything is
* decoded from the supplied ROM, on demand, and cached only in memory. */
export class CrystalAssets {
@@ -566,11 +571,21 @@ export class CrystalAssets {
}
/** GetSprite: overworld sprites, and Pokémon drawn from their party
* menu icons (GetMonSprite). Variable sprites are resolved by the world
* from wVariableSprites first; day-care Pokémon are not ported. */
* from wVariableSprites first. A Day-Care Pokémon (SPRITE_DAY_CARE_MON_1
* and 2, which GetMonSprite resolves to wBreedMon1/2's species) is
* asked for as MON_SPRITE_ID + its species, as LoadOverworldMonIcon
* draws it. */
sprite(id: number): Sprite | undefined {
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader;
if (id > MON_SPRITE_ID) {
const species = id - MON_SPRITE_ID;
if (species > NUM_POKEMON) return undefined;
const sprite = { graphics: this.monIcon(species), palette: 0, walking: true };
this.sprites.set(id, sprite);
return sprite;
}
if (id >= OW.SPRITE_POKEMON && id < OW.SPRITE_POKEMON + OW.NUM_POKEMON_SPRITES) {
// LoadOverworldMonIcon: eight tiles, two frames in the down and up
// standing slots, with palette 0.
+2
View File
@@ -43,6 +43,8 @@ export function fontTiles(reader: RomReader, frame: number) {
* (constants/charmap.asm). */
const CODES: Record<string, number> = {
" ": 0x7f,
"“": 0x72,
"”": 0x73,
"(": 0x9a,
")": 0x9b,
":": 0x9c,
+26 -2
View File
@@ -359,6 +359,30 @@ export class PokemonData {
return evolutions;
}
/** GetPreEvolution: the first species (in dex order) with an evolution
* into this one, or the species itself. */
preEvolution(species: number): number {
this.checkSpecies(species);
for (let from = 1; from <= NUM_POKEMON; from++)
if (this.evolutions(from).some((evolution) => evolution.species === species))
return from;
return species;
}
/** EggMovePointers' list for a species (data/pokemon/egg_moves.asm), up
* to its -1 terminator; the shared NoEggMoves list is empty. */
eggMoves(species: number): number[] {
this.checkSpecies(species);
const r = this.reader,
table = JOHTO.EggMovePointers + (species - 1) * 2;
const moves: number[] = [];
for (let p = r.near(table, r.bank(table)); r.byte(p) !== 0xff; p++) {
if (moves.length > 16) throw new Error("Invalid egg moves.");
moves.push(r.byte(p));
}
return moves;
}
/** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the
* evolution list is skipped up to its terminating zero byte. */
learnset(species: number): { level: number; move: number }[] {
@@ -439,8 +463,8 @@ export class PokemonData {
padded.set(tiles.slice(column * 80, (column + 1) * 80), ((column + 1) * 7 + 2) * 16);
return padded;
}
eggPalette(): Palette {
return this.middleColors(SYMBOLS.pokemonPalettes + 0xfd * 8);
eggPalette(shiny = false): Palette {
return this.middleColors(SYMBOLS.pokemonPalettes + 0xfd * 8 + (shiny ? 4 : 0));
}
/** Front picture tiles, padded into the 7x7 box as PadFrontpic does:
* column-major, bottom-aligned, with a blank left column for 5 and 6. */
+88
View File
@@ -202,6 +202,8 @@ export const EVENTS = {
bugCatchingContestant8A: 1821,
bugCatchingContestant9A: 1822,
bugCatchingContestant10A: 1823,
/** HatchEggs sets this when a Togepi hatches. */
togepiHatched: 84,
} as const;
/** Engine flag indices named directly by translated routines. */
@@ -209,10 +211,14 @@ export const ENGINE_FLAGS = {
pokegear: 4,
pokedex: 11,
allFruitTrees: 84,
bikeShopCall: 20,
/** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */
bugContestTimer: 17,
rocketsInRadioTower: 19,
rocketsInMahogany: 23,
dayCareManHasEgg: 5,
dayCareManHasMon: 6,
dayCareLadyHasMon: 7,
expnCard: 3,
rocketSignal: 14,
fishSwarm: 82,
@@ -1211,9 +1217,23 @@ export const AUDIO = {
PokemonCries: 0xf2787,
} as const;
/** Egg hatching (engine/pokemon/breeding.asm and the sprite animations it
* uses), keyed by PRET labels for `npm run check:reference`. */
export const EGGS = {
Text_BreedHuh: 0x1c0db0,
_BreedClearboxText: 0x1c0db8,
_BreedEggHatchText: 0x1c0dba,
_BreedAskNicknameText: 0x1c0dd8,
EggHatchGFX: 0x17393,
"Hatch_InitShellFragments.SpriteData": 0x173ef,
BattleObjectPals: 0x979c,
} as const;
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
* PRET labels for `npm run check:reference`. */
export const JOHTO = {
"_HallOfFamePC.TimeFamer": 0x8670c,
FontBattleExtra: 0xf8600,
CheckMagikarpLength: 0xfbb32,
MagikarpHouseSign: 0xfbcd2,
FindPartyMonThatSpecies: 0xc276,
@@ -1270,6 +1290,30 @@ export const JOHTO = {
"PokeSeer": 0x4f0bc,
"PhotoStudio": 0x16dc7,
"PlaySlowCry": 0xfb841,
DayCareMan: 0x166d6,
DayCareLady: 0x1672a,
DayCareManOutside: 0x16936,
DayCareMon1: 0x17421,
DayCareMon2: 0x17440,
GiveOddEgg: 0x117656,
"PrintDayCareText.TextTable": 0x168aa,
"DayCareManOutside.NotYetText": 0x16944,
"DayCareManOutside.FoundAnEggText": 0x16993,
"DayCareManOutside.ReceivedEggText": 0x16998,
"DayCareManOutside.TakeGoodCareOfEggText": 0x1699d,
"DayCareManOutside.IllKeepItThanksText": 0x169a2,
"DayCareManOutside.NoRoomForEggText": 0x169a7,
LeftWithDayCareLadyText: 0x17462,
LeftWithDayCareManText: 0x17467,
"DayCareMonCompatibilityText.BreedBrimmingWithEnergyText": 0x1749c,
"DayCareMonCompatibilityText.BreedNoInterestText": 0x174a1,
"DayCareMonCompatibilityText.BreedAppearsToCareForText": 0x174a6,
"DayCareMonCompatibilityText.BreedFriendlyText": 0x174ab,
"DayCareMonCompatibilityText.BreedShowsInterestText": 0x174b0,
EggMovePointers: 0x23b11,
OddEggProbabilities: 0x1fb552,
OddEggs: 0x1fb56e,
"_GiveOddEgg.Odd": 0x1fb546,
"SpecialShuckleOT": 0x7370,
"SpecialShuckleNickname": 0x7376,
"SeerSeeAllText": 0x4f28c,
@@ -1518,6 +1562,8 @@ export const WRAM = {
wMagikarpRecordHoldersName: 0xdfea,
wcd49: 0xcd49,
wNrOfBeatenBattleTowerTrainers: 0xcf64,
wBreedMon1Nickname: 0xdef6,
wBreedMon2Nickname: 0xdf2f,
} as const;
export const MAP_GROUP_COUNT = 26;
@@ -1743,3 +1789,45 @@ export const NATIVE_MAPS: Record<string, string> = {
"3:16": "National Park (Bug-Catching Contest)",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
/** ReadAnyMail stationery graphics and palettes. */
export const MAIL = {
"MorphMailDividerGFX": 0xb9926,
"BlueSkyMailGrassGFX": 0xb992e,
"PortraitMailSmallPokeballGFX": 0xb9936,
"MorphMailBorderGFX": 0xb993e,
"MusicMailSmallNoteGFX": 0xb9946,
"SurfMailWaveGFX": 0xb994e,
"PortraitMailUnderlineGFX": 0xb995e,
"LovelyMailUnderlineGFX": 0xb9966,
"LovelyMailSmallHeartGFX": 0xb996e,
"SurfLiteBlueMailSmallShapesGFX": 0xb9976,
"EonMailBorder1GFX": 0xb997e,
"EonMailBorder2GFX": 0xb998e,
"MailNatuGFX": 0xb9996,
"MailDratiniGFX": 0xb99c6,
"MailPoliwagGFX": 0xb99f6,
"MailLaprasGFX": 0xb9a26,
"MailEeveeGFX": 0xb9a56,
"MailDittoGFX": 0xb9a86,
"MailMewGFX": 0xb9ab6,
"MailDragoniteAndSentretGFX": 0xb9b46,
"PortraitMailLargePokeballGFX": 0xb9bfe,
"MailOddishGFX": 0xb9c1e,
"SurfLiteBlueMailLargeShapesGFX": 0xb9c3e,
"LovelyMailLargeHeartGFX": 0xb9c5e,
"MorphMailBorderCornerGFX": 0xb9c7e,
"MailLargeCircleGFX": 0xb9c9e,
"FlowerMailFlowerGFX": 0xb9cbe,
"MusicMailLargeNoteGFX": 0xb9cfe,
"MailCloudGFX": 0xb9d16,
"SurfMailBorderGFX": 0xb9d46,
"FlowerMailBorderGFX": 0xb9d86,
"LiteBlueMailBorderGFX": 0xb9dc6,
"MusicMailBorderGFX": 0xb9e06,
"LovelyMailBorderGFX": 0xb9e26,
"PortraitMailBorderGFX": 0xb9e4e,
"MailGFXPointers": 0xb92f8,
"LoadMailPalettes.MailPals": 0x8d05,
"StandardEnglishFont": 0x1de5e6,
} as const;
+2 -3
View File
@@ -1,3 +1,4 @@
import { printerDisconnected } from "./printer";
import { JOHTO, WRAM } from "../rom/profile";
import { generatePartyMon } from "../party/pokemon";
import { readString } from "./text";
@@ -93,9 +94,7 @@ export function* photoStudio(c: SpecialContext): Gen {
if (slot >= 0) {
c.write(JOHTO["PhotoStudio.HoldStillText"]);
yield* c.flush();
const status = readString(c.reader, JOHTO.GBPrinterString_PrinterError2, c.text(), 64);
const cancel = readString(c.reader, JOHTO.String_PressBToCancel, c.text());
yield { kind: "text", pages: [`${status}\n\n${cancel}`] };
yield* printerDisconnected(c.reader, c.state.options.textboxFrame);
}
c.write(JOHTO["PhotoStudio.NoPhotoText"]);
}
+272
View File
@@ -0,0 +1,272 @@
import { JOHTO, RAM, WRAM, ENGINE_FLAGS } from "../rom/profile";
import { SOUND } from "../audio/constants";
import { tossItem } from "../party/bag";
import {
breedLevelGrowth,
breedingCompatibility,
depositedMon,
eggAsPartyMon,
initBreeding,
oddEgg,
retrievalPrice,
retrievedMon,
type DayCareState,
} from "../party/day-care";
import { PARTY_LENGTH } from "../party/pokemon";
import type { Gen, SpecialContext } from "./special-context";
/** PrintDayCareText.TextTable's indexes (DAYCARETEXT_*). */
const TEXT = {
manIntro: 0,
ladyIntro: 2,
whichOne: 4,
deposit: 5,
cantBreedEgg: 6,
lastMon: 7,
lastAliveMon: 8,
comeBackLater: 9,
removeMail: 10,
geniuses: 11,
askWithdraw: 12,
withdraw: 13,
gotBack: 14,
tooSoon: 15,
partyFull: 16,
notEnoughMoney: 17,
ohFine: 18,
comeAgain: 19,
} as const;
/** PARTYMENUACTION_GIVE_MON and EGG_TICKET. */
const PARTYMENUACTION_GIVE_MON = 6,
EGG_TICKET = 0x81;
/** PrintDayCareText: the text the table's index names. */
function say(c: SpecialContext, index: number) {
const table = JOHTO["PrintDayCareText.TextTable"];
c.write(c.reader.near(table + index * 2, c.reader.bank(table)));
}
/** The pair of Day-Care keepers differ in which Pokémon, flag and intro
* they use. */
const KEEPERS = {
man: {
hasMon: ENGINE_FLAGS.dayCareManHasMon,
mon: "mon1",
nickname: WRAM.wBreedMon1Nickname,
},
lady: {
hasMon: ENGINE_FLAGS.dayCareLadyHasMon,
mon: "mon2",
nickname: WRAM.wBreedMon2Nickname,
},
} as const;
/** DayCareAskDepositPokemon: the slot to leave, or the text to print. */
function* askDeposit(
c: SpecialContext,
): Gen<{ slot: number } | { text: number }> {
const party = c.state.party;
if (party.length < 2) return { text: TEXT.lastMon };
say(c, TEXT.whichOne);
const slot = yield* c.selectMon(PARTYMENUACTION_GIVE_MON);
if (slot < 0) return { text: TEXT.ohFine };
const mon = party[slot];
if (mon.egg) return { text: TEXT.cantBreedEgg };
// CheckCurPartyMonFainted: any other member with HP will do (an Egg has
// none).
if (!party.some((other, index) => index !== slot && other.hp > 0))
return { text: TEXT.lastAliveMon };
if (c.data.itemIsMail(mon.item)) return { text: TEXT.removeMail };
c.strings[RAM.stringBuffer1] = mon.nickname;
return { slot };
}
/** DayCareMan and DayCareLady: ask to raise a Pokémon, or to give it back
* for ¥100 and ¥100 a level. */
function* keeper(c: SpecialContext, who: keyof typeof KEEPERS): Gen {
const { state, data } = c,
dayCare = state.dayCare,
info = KEEPERS[who];
if (!state.flags.has(info.hasMon)) {
// DayCareManIntroText sets its active bit first; DayCareLadyIntroText
// takes the longer Egg introduction once, and sets hers.
let text: number;
if (who === "man") {
dayCare.manActive = true;
text = TEXT.manIntro;
} else {
text = TEXT.ladyIntro + Number(!dayCare.ladyActive);
dayCare.ladyActive = true;
}
say(c, text);
if (!(yield { kind: "yesno", pages: c.take() }))
return say(c, TEXT.comeAgain);
const choice = yield* askDeposit(c);
if ("text" in choice) {
say(c, choice.text);
return say(c, TEXT.comeAgain);
}
// DepositMonWithDayCareMan/Lady, then the text and cry.
const [mon] = state.party.splice(choice.slot, 1);
dayCare[info.mon] = depositedMon(mon);
state.flags.add(info.hasMon);
say(c, TEXT.deposit);
yield* c.cry(mon.species);
say(c, TEXT.comeBackLater);
initBreeding(data, state, c.player(), c.random);
return;
}
// GetBreedMon1LevelGrowth and GetPriceToRetrieveBreedmon: the price and
// the nickname go to wStringBuffer2 and 1.
const stored = dayCare[info.mon]!;
const { oldLevel, level, growth } = breedLevelGrowth(data, stored);
const price = retrievalPrice(growth);
c.strings[RAM.stringBuffer1] = stored.nickname;
c.numbers[RAM.stringBuffer2 + 1] = growth;
c.numbers[RAM.stringBuffer2 + 2] = price;
// DayCare_AskWithdrawBreedMon.
let refusal: number | undefined;
if (growth) {
say(c, TEXT.geniuses);
if (!(yield { kind: "yesno", pages: c.take() })) refusal = TEXT.ohFine;
else {
say(c, TEXT.askWithdraw);
if (!(yield { kind: "yesno", pages: c.take() })) refusal = TEXT.ohFine;
}
} else {
say(c, TEXT.tooSoon);
if (!(yield { kind: "yesno", pages: c.take() })) refusal = TEXT.ohFine;
}
if (refusal === undefined) {
if (state.money < price) refusal = TEXT.notEnoughMoney;
else if (state.party.length >= PARTY_LENGTH) refusal = TEXT.partyFull;
}
if (refusal !== undefined) {
say(c, refusal);
return say(c, TEXT.comeAgain);
}
// RetrieveMonFromDayCareMan/Lady: SFX_TRANSACTION, then the Pokémon.
c.playSFX(SOUND.SFX_TRANSACTION);
yield* c.waitSFX();
state.party.push(retrievedMon(data, stored, oldLevel, level));
// DayCare_GetBackMonForMoney.
state.money -= price;
say(c, TEXT.withdraw);
yield* c.cry(stored.species);
say(c, TEXT.gotBack);
dayCare[info.mon] = null;
state.flags.delete(info.hasMon);
dayCare.compatible = false;
say(c, TEXT.comeAgain);
}
export const dayCareMan = (c: SpecialContext) => keeper(c, "man");
export const dayCareLady = (c: SpecialContext) => keeper(c, "lady");
/** DayCareManOutside (the man on Route 34): the Egg he found, which the
* player may take if the party has room. Returns TRUE when it was refused
* for want of room; FALSE otherwise, even if the player said no (the
* script then clears ENGINE_DAY_CARE_MAN_HAS_EGG all the same). Without an
* Egg he leaves wScriptVar alone. */
export function* dayCareManOutside(
c: SpecialContext,
value: number,
): Gen<number> {
const { state, data } = c,
dayCare = state.dayCare;
const text = (name: string) =>
c.write(
JOHTO[`DayCareManOutside.${name}` as "DayCareManOutside.NotYetText"],
);
if (!state.flags.has(ENGINE_FLAGS.dayCareManHasEgg)) {
text("NotYetText");
return value;
}
text("FoundAnEggText");
if (!(yield { kind: "yesno", pages: c.take() })) {
text("IllKeepItThanksText");
return 0;
}
if (state.party.length >= PARTY_LENGTH) {
text("NoRoomForEggText");
return 1;
}
// DayCare_GiveEgg, then the next egg is prepared for the pair.
if (!dayCare.egg) throw new Error("The Day-Care has no Egg to give.");
state.party.push(eggAsPartyMon(data, dayCare.egg));
state.flags.delete(ENGINE_FLAGS.dayCareManHasEgg);
initBreeding(data, state, c.player(), c.random);
text("ReceivedEggText");
// SFX_GET_EGG and DelayFrames 120 sound while the text is shown.
c.playSFX(SOUND.SFX_GET_EGG);
text("TakeGoodCareOfEggText");
return 0;
}
/** DayCareMon1 and DayCareMon2 (the Pokémon shown in Route 34's fence):
* whose it is, its cry, and, if the other is in too, how they get on
* (DayCareMonCompatibilityText, from CheckBreedmonCompatibility). */
function* fenceMon(
c: SpecialContext,
which: 1 | 2,
value: number,
): Gen<number> {
const dayCare: DayCareState = c.state.dayCare;
const mon = which === 1 ? dayCare.mon1 : dayCare.mon2,
other = which === 1 ? dayCare.mon2 : dayCare.mon1;
if (!mon) return value;
// The text names both nicknames: its own through text_ram, the other's
// is copied to wStringBuffer1.
c.strings[which === 1 ? WRAM.wBreedMon1Nickname : WRAM.wBreedMon2Nickname] =
mon.nickname;
c.write(
which === 1 ? JOHTO.LeftWithDayCareManText : JOHTO.LeftWithDayCareLadyText,
);
yield* c.cry(mon.species);
const hasOther =
c.state.flags.has(
which === 1
? ENGINE_FLAGS.dayCareLadyHasMon
: ENGINE_FLAGS.dayCareManHasMon,
) && other;
// DayCareMonCursor: with no other Pokémon, a button ends the text.
if (!hasOther) {
yield* c.flush();
return value;
}
c.strings[RAM.stringBuffer1] = other.nickname;
const compatibility = breedingCompatibility(
c.data,
dayCare.mon1!,
dayCare.mon2!,
);
const group = "DayCareMonCompatibilityText.";
c.write(
compatibility === 255
? JOHTO[`${group}BreedBrimmingWithEnergyText`]
: compatibility === 0
? JOHTO[`${group}BreedNoInterestText`]
: compatibility >= 230
? JOHTO[`${group}BreedAppearsToCareForText`]
: compatibility >= 70
? JOHTO[`${group}BreedFriendlyText`]
: JOHTO[`${group}BreedShowsInterestText`],
);
return value;
}
export const dayCareMon1 = (c: SpecialContext, value: number) =>
fenceMon(c, 1, value);
export const dayCareMon2 = (c: SpecialContext, value: number) =>
fenceMon(c, 2, value);
/** GiveOddEgg: the Egg Ticket is used up if the player has one, and one of
* the Odd Eggs joins the party. The script checks for room first. */
export function giveOddEgg(c: SpecialContext) {
const { state, data } = c;
const egg = oddEgg(data, c.random);
tossItem(state.bag, EGG_TICKET, 1);
if (state.party.length >= PARTY_LENGTH)
throw new Error("The party has no room for the Odd Egg.");
state.party.push(egg);
}
+113
View File
@@ -0,0 +1,113 @@
import type { HallOfFameTeam } from "../party/hall-of-fame";
import { PokemonData } from "../rom/pokemon";
import { JOHTO } from "../rom/profile";
import { fontTiles, FRAME_TILE, SPACE_TILE, textTiles } from "../rom/font";
import type { ScriptEffect } from "./interpreter";
import { blankScreen, SCREEN_BUTTONS, type TileScreen } from "./screen";
import { defaultTextContext, readString } from "./text";
import type { GameSound } from "../audio/sound";
/** _HallOfFamePC.DisplayMonAndStrings: DisplayHOFMon's page for one
* Pokémon of a team, over _LoadFontsBattleExtra's tiles, in
* SCGB_PLAYER_OR_MON_FRONTPIC_PALS's single palette. The picture is shown
* at rest: HOF_AnimateFrontpic's ANIM_MON_HOF animation is not ported. */
export function hallOfFameScreen(
data: PokemonData,
team: HallOfFameTeam,
slot: number,
frame: number,
): TileScreen {
const mon = team.mons[slot],
r = data.reader;
const tiles = fontTiles(r, frame);
tiles.set(r.slice(JOHTO.FontBattleExtra, 25 * 16), 0x60 * 16);
tiles.set(data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)));
const title = readString(
r,
JOHTO["_HallOfFamePC.TimeFamer"],
defaultTextContext(""),
);
const screen = blankScreen(
tiles,
`hof-${mon.species}-${mon.dvs}-${frame}`,
[data.palette(mon.species, PokemonData.shiny(mon.dvs))],
`${team.win}${title.trimStart()}: ${mon.nickname}, ${data.speciesName(mon.species)}, level ${mon.level}, ID ${String(mon.otId).padStart(5, "0")}`,
);
screen.map.fill(SPACE_TILE);
const put = (x: number, y: number, values: readonly number[]) =>
screen.map.splice(y * 20 + x, values.length, ...values);
const text = (x: number, y: number, value: string) =>
put(
x,
y,
[...value].flatMap((character) =>
character === '"' ? [0xe1] : textTiles(character),
),
);
// Textbox with 18 interior columns and three/four interior rows.
const box = (y: number, height: number) => {
put(0, y, [FRAME_TILE, ...Array(18).fill(FRAME_TILE + 1), FRAME_TILE + 2]);
for (let row = y + 1; row <= y + height; row++) {
put(0, row, [FRAME_TILE + 3]);
put(19, row, [FRAME_TILE + 3]);
}
put(0, y + height + 1, [
FRAME_TILE + 4,
...Array(18).fill(FRAME_TILE + 1),
FRAME_TILE + 5,
]);
};
box(0, 3);
box(12, 4);
// "HOF Master!" needs a count of HOF_MASTER_COUNT + 1, which the capped
// count never reaches: the original's bug (docs/bugs_and_glitches.md)
// leaves every team "-Time Famer".
text(1, 2, title);
text(2, 2, String(team.win).padStart(3, " "));
for (let x = 0; x < 7; x++)
for (let y = 0; y < 7; y++) put(x + 6, y + 5, [x * 7 + y]);
put(1, 13, [0x74, 0xf2]); // № and <DOT>.
text(3, 13, String(mon.species).padStart(3, "0"));
// GetBasePokemonName drops the Nidorans' gender sign.
text(7, 13, data.speciesName(mon.species).split(/[♂♀]/)[0]);
const gender = data.gender(mon.species, mon.dvs);
text(18, 13, gender === "male" ? "♂" : gender === "female" ? "♀" : " ");
text(8, 14, `/${mon.nickname}`);
// PrintLevel: <LV> and two digits, or three digits over the <LV>.
if (mon.level < 100) {
put(1, 16, [0x6e]);
text(2, 16, String(mon.level));
} else text(1, 16, String(mon.level));
put(7, 16, [0x73, 0x74, 0xf3]); // <ID>, № and /.
text(10, 16, String(mon.otId).padStart(5, "0"));
return screen;
}
/** _HallOfFamePC: LoadHOFTeam takes the teams newest first until an absent
* one (or NUM_HOF_TEAMS); A shows the team's next Pokémon, and after the
* last one the next team, Start skips to the next team and B leaves. Each
* page cries as ANIM_MON_HOF's CryNoWait does. */
export function* hallOfFamePC(
data: PokemonData,
teams: readonly HallOfFameTeam[],
frame: number,
sound?: GameSound,
): Generator<ScriptEffect, void, number | string | undefined> {
for (const team of teams) {
for (let slot = 0; slot < team.mons.length; slot++) {
sound?.playMonCry2(team.mons[slot].species);
const screen = hallOfFameScreen(data, team, slot, frame);
for (;;) {
const button = yield { kind: "screen", screen };
if (typeof button !== "string" || !SCREEN_BUTTONS.includes(button))
throw new Error("Invalid Hall of Fame button.");
if (button === "b") return;
if (button === "start") {
slot = team.mons.length;
break;
}
if (button === "a") break;
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import { PokemonData } from "../rom/pokemon";
import { EGGS, OVERWORLD, SYMBOLS } from "../rom/profile";
import type { Palette } from "../world/types";
import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants";
import {
blankScreen,
romPalette,
type TileScreen,
type ScreenSprite,
} from "./screen";
import type { ScriptEffect } from "./interpreter";
/** The hatching screen's VRAM layout, with sprites in a separate tile bank. */
const OBJ = 0x100;
/** SpriteAnimFunc_RevealNewMon. VAR1 grows by eight each animation frame;
* the angle flips by half a turn each time. DeinitializeSprite still draws
* the last frame once (PlaySpriteAnimations calls UpdateAnimFrame after it). */
export function shellSprites(data: PokemonData, frame: number): ScreenSprite[] {
if (frame > 16) return [];
const r = data.reader;
const sine = (angle: number, amplitude: number) => {
const index = angle & 63;
const value =
(amplitude *
r.word(OVERWORLD["_Sine.sinetable_u10"] + (index & 31) * 2)) >>
8;
return index >= 32 ? -value : value;
};
const sprites: ScreenSprite[] = [];
for (
let p = EGGS["Hatch_InitShellFragments.SpriteData"];
r.byte(p) !== 255;
p += 4
) {
const f = Math.min(frame, 15);
const angle = r.byte(p + 3) ^ (f % 2 === 0 ? 32 : 0);
const flip = r.byte(p + 2);
// _InitSpriteAnimStruct stores E as X and D as Y. The OAM table
// contributes -4, with flipped offsets becoming -8 - offset.
sprites.push({
x: ((r.byte(p) + sine(angle + 16, f * 8) - 4) & 255) - 8,
y: ((r.byte(p + 1) + sine(angle, f * 8) - 4) & 255) - 16,
tile: OBJ + 1,
palette: 0,
flipX: Boolean(flip & 1),
flipY: Boolean(flip & 2),
});
}
return sprites;
}
/** Hatch_UpdateFrontpicBGMapCenter and _CGB_Evolution. The tilemap is
* column-major PlaceGraphic over a cleared space-tile background. */
export function hatchScreen(
data: PokemonData,
species: number,
dvs: number,
hatched: boolean,
overworldPalettes: Palette[] = [0, 1].map((i) =>
romPalette(data.reader, SYMBOLS.objectPalettes + 64 + i * 8),
),
): TileScreen {
const tiles = new Uint8Array(0x102 * 16);
// GetEggFrontpic/GetHatchlingFrontpic incorrectly use stale battle DVs
// for Unown's letter. The port has no persistent battle-DV buffer: A.
tiles.set(data.frontPic(species), 0);
tiles.set(data.eggPic(), 0x31 * 16);
tiles.set(data.reader.slice(EGGS.EggHatchGFX, 32), OBJ * 16);
const screen = blankScreen(
tiles,
`hatch:${species}:${dvs}`,
[
hatched
? data.palette(species, PokemonData.shiny(dvs))
: data.eggPalette(PokemonData.shiny(dvs)),
],
hatched ? data.speciesName(species) : "EGG",
);
// _CGB_Evolution replaces palettes 2-7 only. Cracks/fragments use 0,
// retaining the overworld color instead of the newly loaded gray.
screen.objPalettes = [
...overworldPalettes.slice(0, 2),
...Array.from({ length: 6 }, (_, i) =>
romPalette(data.reader, EGGS.BattleObjectPals + i * 8),
),
];
screen.map.fill(0x7f);
const x = hatched ? 6 : 7,
y = hatched ? 3 : 4,
base = hatched ? 0 : 0x31;
for (let col = 0; col < 7; col++)
for (let row = 0; row < 7; row++)
screen.map[(y + row) * 20 + x + col] = base + col * 7 + row;
return screen;
}
/** EggHatch_AnimationSequence through Hatch_ShellFragmentLoop. Realtime
* screens advance at one video frame; buttons cannot cancel the hatch.
* The final species-specific AnimateFrontpic program is not translated. */
export function* hatchAnimation(
data: PokemonData,
species: number,
dvs: number,
sound?: GameSound,
overworldPalettes?: Palette[],
): Generator<ScriptEffect, void, number | string | undefined> {
const screen = hatchScreen(data, species, dvs, false, overworldPalettes);
const show = function* (
frames: number,
): Generator<ScriptEffect, void, number | string | undefined> {
for (let i = 0; i < frames; i++)
yield { kind: "screen", screen, realtime: true };
};
sound?.playMusic(SOUND.MUSIC_EVOLUTION);
yield* show(80);
const cracks: ScreenSprite[] = [];
for (let cycle = 0; cycle < 8; cycle++) {
for (let wobble = 0; wobble <= cycle; wobble++) {
for (const x of [2, -2]) {
screen.scroll = { x, y: 0, fill: 0x7f };
screen.sprites = cracks.map((sprite) => ({
...sprite,
x: sprite.x - x,
}));
yield* show(3);
}
}
yield* show(16);
if (cycle !== 7 && cycle & 1) {
cracks.push({
x: 88 - 12,
y: 76 + (cycle >> 1) * 8 - 20,
tile: OBJ,
palette: 0,
});
sound?.playSFX(SOUND.SFX_EGG_CRACK);
}
}
sound?.playSFX(SOUND.SFX_EGG_HATCH);
// Hatch_InitShellFragments draws once before changing the BG picture.
screen.scroll = { x: 0, y: 0, fill: 0x7f };
screen.sprites = shellSprites(data, 0);
yield* show(1);
const hatched = hatchScreen(data, species, dvs, true, overworldPalettes);
for (let frame = 1; frame <= 129; frame++) {
hatched.sprites = shellSprites(data, frame);
yield { kind: "screen", screen: hatched, realtime: true };
}
sound?.playMonCry2(species);
}
+190
View File
@@ -0,0 +1,190 @@
import { hatchAnimation } from "./hatch-animation";
import type { Palette } from "../world/types";
import type { GameSound } from "../audio/sound";
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { EGGS, EVENTS, RAM, SYMBOLS } from "../rom/profile";
import { HATCHED_HAPPINESS } from "../party/eggs";
import { NICKNAME_PATTERN, calcStats, caughtData } from "../party/pokemon";
import {
copyScriptState,
type ScriptEffect,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** TOGEPI (constants/pokemon_constants.asm). */
const TOGEPI = 175;
/** CAUGHT_EGG_LEVEL: the level SetEggMonCaughtData stores. */
const CAUGHT_EGG_LEVEL = 1;
/** NICKNAME_LENGTH - 1 */
const NICKNAME_MAX = 10;
/** What the hatching routines need from the world. */
export interface HatchHost {
reader: RomReader;
data: PokemonData;
/** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay(): number;
/** The landmark GetWorldMapLocation finds for the current map. */
landmark(): number;
/** wPlayerID and wPlayerGender (1 for Kris). */
player(): { id: number; gender: 0 | 1 };
/** The text context; `player` is wPlayerName. */
text(): TextContext;
sound?: GameSound;
/** _CGB_Evolution retains overworld object palettes 0 and 1. */
objectPalettes?: Palette[];
}
/** OverworldHatchEgg and HatchEggs (engine/pokemon/breeding.asm): every
* Egg in the party whose cycles have run out (happiness 0) hatches, in
* party order, and each is followed by its nickname question. The party,
* Pokédex and events are changed on a draft of the state, which the world
* commits when the run ends; a save made meanwhile has none of it. */
export class HatchRun {
readonly state: ScriptState;
/** Hatching never defers another script. */
readonly deferred = undefined;
private iterator: Gen;
private done = false;
/** wStringBuffer1: the hatchling's species name. */
private readonly strings: Record<number, string> = {};
constructor(
state: ScriptState,
private host: HatchHost,
) {
this.state = copyScriptState(state);
this.iterator = this.run();
}
/** Advance until the next effect, or undefined when hatching is over. */
resume(input?: Input): ScriptEffect | undefined {
if (this.done) return undefined;
const next = this.iterator.next(input);
if (next.done) {
this.done = true;
return undefined;
}
return next.value;
}
private text(): TextContext {
const base = this.host.text();
return { ...base, strings: { ...base.strings, ...this.strings } };
}
/** A ROM text as pages, with the sounds it plays attached to their pages
* the way a script's text carries them. */
private pages(address: number): string[] {
const sounds: { sfx: number; at: number }[] = [];
const content = readText(this.host.reader, address, {
...this.text(),
onSound: (sfx, at) => sounds.push({ sfx, at }),
});
const pages = content.split("\n\n").filter(Boolean);
const sound = this.host.sound;
if (sound && sounds.length)
Object.defineProperty(pages, "cues", {
value: sounds.map(({ sfx, at }) => ({
page: Math.min(
content.slice(0, at).split("\n\n").length - 1,
pages.length - 1,
),
play: () => sound.playSFX(sfx),
})),
});
return pages;
}
private *print(address: number): Gen {
const pages = this.pages(address);
if (pages.length) yield { kind: "text", pages };
}
private *run(): Gen {
const party = this.state.party;
// HatchEggs walks wPartySpecies, which holds EGG for each Egg; one
// whose cycles are not exactly zero is passed over.
for (let slot = 0; slot < party.length; slot++)
if (party[slot].egg && party[slot].happiness === 0)
yield* this.hatch(slot);
// OverworldHatchEgg: ExitAllMenus, RestartMapMusic, CloseText.
this.host.sound?.restartMapMusic();
}
/** One Egg's turn of HatchEggs. */
private *hatch(slot: number): Gen {
const state = this.state,
data = this.host.data,
mon = state.party[slot];
const player = this.host.player();
mon.happiness = HATCHED_HAPPINESS;
// SetEggMonCaughtData: the time of day and place of the hatching, with
// the level CAUGHT_EGG_LEVEL.
mon.caughtData = caughtData(
this.host.timeOfDay(),
CAUGHT_EGG_LEVEL,
player.gender,
this.host.landmark(),
);
// SetSeenAndCaughtMon, then Togepi's event.
state.pokedex.seen.add(mon.species);
state.pokedex.caught.add(mon.species);
if (mon.species === TOGEPI) state.events.add(EVENTS.togepiHatched);
// The slot's species is no longer EGG; GetPokemonName names it.
delete mon.egg;
const name = data.speciesName(mon.species);
this.strings[RAM.stringBuffer1] = name;
// CalcMonStats (b = FALSE, ignoring stat experience), full HP, no status; the
// player becomes the original trainer.
mon.stats = calcStats(data, mon.species, mon.dvs, mon.level);
mon.hp = mon.stats[0];
mon.status = 0;
mon.otId = player.id;
mon.ot = this.host.text().player;
// Text_HatchEgg: "Huh?", the animation, then the new name.
yield* this.print(EGGS.Text_BreedHuh);
yield* hatchAnimation(
data,
mon.species,
mon.dvs,
this.host.sound,
this.host.objectPalettes,
);
yield* this.print(EGGS._BreedEggHatchText);
// BreedAskNicknameText and YesNoBox; NamingScreen (NAME_MON) if yes.
const wants = yield {
kind: "yesno" as const,
pages: this.pages(EGGS._BreedAskNicknameText),
};
if (!wants) {
mon.nickname = name;
return;
}
const r = this.host.reader,
context = this.text();
let second = SYMBOLS.nicknameStrings;
while (r.byte(second++) !== 0x50);
const suffix = readString(r, SYMBOLS.nicknameStrings, context),
title = readString(r, second, context);
const answer = yield {
kind: "name" as const,
pages: [`${name}${suffix}\n${title}`],
current: name,
max: NICKNAME_MAX,
};
if (
typeof answer !== "string" ||
answer.length > NICKNAME_MAX ||
(answer && !NICKNAME_PATTERN.test(answer))
)
throw new Error("Invalid nickname.");
// InitName: a blank answer keeps the species name.
mon.nickname = /^ *$/.test(answer) ? name : answer;
}
}
+70 -3
View File
@@ -7,6 +7,16 @@ import { RomReader } from "../rom/reader";
import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants";
import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood";
import {
dayCareLady,
dayCareMan,
dayCareManOutside,
dayCareMon1,
dayCareMon2,
giveOddEgg,
} from "./day-care";
import type { HallOfFameTeam } from "../party/hall-of-fame";
import { copyDayCare, emptyDayCare, type DayCareState } from "../party/day-care";
import {
EMPTY_ROUTINES,
ENGINE_FLAGS,
@@ -193,6 +203,9 @@ export interface ScriptState {
noWildEncounters: boolean;
/** STATUSFLAGS_HALL_OF_FAME_F: the League has been beaten. */
hallOfFame: boolean;
/** wHallOfFameCount and sHallOfFame (see party/hall-of-fame.ts). */
hallOfFameCount: number;
hallOfFameTeams: HallOfFameTeam[];
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number };
@@ -200,6 +213,12 @@ export interface ScriptState {
mailbox: Mail[];
/** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */
repel: number;
/** wStepCount, wPoisonStepCount and wHappinessStepCount: what CountStep
* counts (see ../party/eggs), and DoBikeStep's wBikeStep. */
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
bikeStep: number;
/** wCoins: the Coin Case's coins. */
coins: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
@@ -233,6 +252,9 @@ export interface ScriptState {
roamMonsMap: string | null;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions;
/** The Day-Care: wDayCareMan, wDayCareLady, wBreedMon1/2, wStepsToEgg,
* wBreedMotherOrNonDitto and wEggMon (see ../party/day-care). */
dayCare: DayCareState;
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
* runs (ENGINE_BUG_CONTEST_TIMER/STATUSFLAGS2_BUG_CONTEST_TIMER_F). */
bugContest?: BugContest;
@@ -278,9 +300,15 @@ export const emptyScriptState = (): ScriptState => ({
flash: false,
noWildEncounters: false,
hallOfFame: false,
hallOfFameCount: 0,
hallOfFameTeams: [],
registeredItem: { which: 0, item: 0 },
mailbox: [],
repel: 0,
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
coins: 0,
tradeFlags: 0,
blueCardBalance: 0,
@@ -294,6 +322,7 @@ export const emptyScriptState = (): ScriptState => ({
roamMons: emptyRoamMons(),
roamMonsMap: null,
options: defaultOptions(),
dayCare: emptyDayCare(),
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
decorations: { ...state.decorations },
@@ -326,9 +355,15 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
flash: state.flash,
noWildEncounters: state.noWildEncounters,
hallOfFame: state.hallOfFame,
hallOfFameCount: state.hallOfFameCount,
hallOfFameTeams: structuredClone(state.hallOfFameTeams),
registeredItem: { ...state.registeredItem },
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
stepCount: state.stepCount,
poisonStepCount: state.poisonStepCount,
happinessStepCount: state.happinessStepCount,
bikeStep: state.bikeStep,
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
@@ -344,6 +379,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
roamMons: state.roamMons.map((mon) => ({ ...mon })),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
dayCare: copyDayCare(state.dayCare),
...(state.bugContest && {
bugContest: {
parkBalls: state.bugContest.parkBalls,
@@ -572,7 +608,7 @@ export type ScriptEffect =
mail?: boolean;
}
/** ReadAnyMail: a letter, closed with a button. */
| { kind: "mail"; mail: Mail }
| { kind: "mail"; mail: Mail; screen: TileScreen }
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list (buy: full stock; sell: the bag's pockets);
@@ -1661,13 +1697,19 @@ export class ScriptRun {
yield* flush();
yield synchronous({ kind: "battle", wild, tutorial: true });
reloadMapMusic();
// Script_reloadmap: EnterMap zeroes wPoisonStepCount.
this.state.poisonStepCount = 0;
wild = undefined;
canLose = false;
break;
}
case 0x60:
// Script_reloadmapafterbattle: a loss continues with the whiteout.
if ((battleResult & 0x3f) !== 1) reloadMapMusic();
if ((battleResult & 0x3f) !== 1) {
reloadMapMusic();
// Script_reloadmap: EnterMap zeroes wPoisonStepCount.
this.state.poisonStepCount = 0;
}
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
// BATTLESCRIPT_WILD_F is set by trainer battles, which run
// MomTriesToBuySomething. Without a purchase it changes nothing.
@@ -1693,6 +1735,8 @@ export class ScriptRun {
// re-running callbacks or rebuilding objects. Native presentation
// already retains the map; clear wBattleScriptFlags equivalents.
reloadMapMusic();
// EnterMap zeroes wPoisonStepCount for MAPSETUP_RELOADMAP.
this.state.poisonStepCount = 0;
wild = undefined;
trainer = undefined;
canLose = false;
@@ -3056,9 +3100,17 @@ export class ScriptRun {
flush: function* () {
if (pages.length) yield { kind: "text", pages: take() };
},
selectMon: () => this.selectMonFromParty(take()),
selectMon: (action) => this.selectMonFromParty(take(), action),
playSFX: (id) => this.cue(() => this.host.sound!.playSFX(id)),
cry: (species) => this.monCry(species),
waitSFX: () => this.waitSFX(),
};
}
/** PlayMonCry: it plays with the pending page, else now, and waits. */
private *monCry(species: number): Generator<ScriptEffect, void, Input> {
this.cue(() => this.host.sound!.playMonCry2(species));
yield* this.waitSFX();
}
/** PlaceMoneyTopRight's box: the money with the ¥ sign. */
private moneyBalance() {
return `${readString(this.reader, JOHTO.MoneyString, this.text())} ¥${this.state.money}`;
@@ -3729,6 +3781,21 @@ export class ScriptRun {
case JOHTO.NameRater:
yield* nameRater(this.specialContext(pages, take));
return value;
case JOHTO.DayCareMan:
yield* dayCareMan(this.specialContext(pages, take));
return value;
case JOHTO.DayCareLady:
yield* dayCareLady(this.specialContext(pages, take));
return value;
case JOHTO.DayCareManOutside:
return yield* dayCareManOutside(this.specialContext(pages, take), value);
case JOHTO.DayCareMon1:
return yield* dayCareMon1(this.specialContext(pages, take), value);
case JOHTO.DayCareMon2:
return yield* dayCareMon2(this.specialContext(pages, take), value);
case JOHTO.GiveOddEgg:
giveOddEgg(this.specialContext(pages, take));
return value;
case JOHTO.GiveShuckle:
return giveShuckle(this.specialContext(pages, take));
case JOHTO.ReturnShuckie:
+340
View File
@@ -0,0 +1,340 @@
import type { Mail } from "../party/mail";
import type { PokemonData } from "../rom/pokemon";
import { MAIL, JOHTO } from "../rom/profile";
import { fontTiles, textTiles } from "../rom/font";
import { blankScreen, romPalette, type TileScreen } from "./screen";
import { printerDisconnected } from "./printer";
import type { Gen } from "./special-context";
type Graphic = keyof typeof MAIL;
/** ReadAnyMail.LoadGFX and the ten Load*MailGFX routines. Tiles retain
* the cartridge indices; each 1bpp graphic is expanded into its original
* color plane. Portrait MAIL uses Unown A regardless of the holder's DVs. */
export function mailScreen(data: PokemonData, mail: Mail): TileScreen {
const r = data.reader;
let index = 0;
while (index < 10 && r.byte(MAIL.MailGFXPointers + index * 3) !== mail.type)
index++;
if (index === 10) index = 0;
const tiles = fontTiles(r, 0);
tiles.set(r.slice(JOHTO.FontExtra + 3 * 16, 22 * 16), 0x63 * 16);
for (let i = 0; i < 1024; i++) {
const value = r.byte(MAIL.StandardEnglishFont + i);
tiles[0x800 + i * 2] = value;
tiles[0x801 + i * 2] = value;
}
const screen = blankScreen(
tiles,
`mail:${mail.type}:${mail.species}`,
[romPalette(r, MAIL["LoadMailPalettes.MailPals"] + index * 8)],
`${data.itemName(mail.type)}\n${mail.message}\n${mail.author}`,
);
screen.map.fill(0x7f);
let next = 0x31;
const gfx = (name: Graphic, count: number, color: number, offset = 0) => {
for (let i = 0; i < count * 8; i++) {
const v = r.byte(MAIL[name] + offset * 8 + i);
tiles[next * 16 + i * 2] = color & 1 ? v : 0;
tiles[next * 16 + i * 2 + 1] = color & 2 ? v : 0;
}
next += count;
};
const solid = (count: number, color: number) => {
for (let i = 0; i < count * 8; i++) {
tiles[next * 16 + i * 2] = color & 1 ? 255 : 0;
tiles[next * 16 + i * 2 + 1] = color & 2 ? 255 : 0;
}
next += count;
};
const put = (x: number, y: number, tile: number) => {
screen.map[y * 20 + x] = tile;
};
const row = (x: number, y: number, n: number, tile: number) => {
for (let i = 0; i < n; i++) put(x + i, y, tile);
};
const col = (x: number, y: number, n: number, tile: number) => {
for (let i = 0; i < n; i++) put(x, y + i, tile);
};
const graphic = (x: number, y: number, tile: number, width = 2) => {
for (let j = 0; j < 2; j++)
for (let i = 0; i < width; i++) put(x + i, y + j, tile + j * width + i);
};
const dots = (tile: number, positions: number[][]) =>
positions.forEach(([x, y]) => put(x, y, tile));
const icons = () => {
for (const [x, y] of [
[2, 2],
[16, 2],
[9, 4],
[2, 11],
[6, 12],
[12, 11],
])
graphic(x, y, 0x3d);
dots(0x41, [
[5, 4],
[6, 2],
[12, 4],
[14, 2],
[3, 13],
[9, 11],
[16, 12],
]);
};
const border = (second = false) => {
put(0, 0, 0x31);
row(1, 0, 18, 0x32);
put(19, 0, second ? 0x31 : 0x33);
col(0, 1, 16, second ? 0x33 : 0x34);
col(19, 1, 16, 0x35);
put(0, 17, second ? 0x31 : 0x36);
row(1, 17, 18, second ? 0x34 : 0x37);
put(19, 17, second ? 0x31 : 0x38);
};
// Alternating rows/columns place one extra tile after their loop.
const alternating = (
x: number,
y: number,
n: number,
tile: number,
vertical = false,
) => {
for (let i = 0; i <= n; i++)
put(x + (vertical ? 0 : i), y + (vertical ? i : 0), tile + (i & 1));
};
switch (index) {
case 0:
gfx("FlowerMailBorderGFX", 8, 1);
gfx("MailOddishGFX", 4, 3);
gfx("FlowerMailBorderGFX", 1, 2, 6);
gfx("FlowerMailFlowerGFX", 4, 1);
gfx("FlowerMailFlowerGFX", 4, 2, 4);
border();
row(2, 15, 16, 0x3d);
graphic(16, 13, 0x39);
graphic(2, 13, 0x39);
for (const [x, y] of [
[2, 2],
[5, 3],
[10, 2],
[16, 3],
[5, 11],
[16, 10],
])
graphic(x, y, 0x3e);
for (const [x, y] of [
[3, 4],
[12, 3],
[14, 2],
[2, 10],
[14, 11],
])
graphic(x, y, 0x42);
break;
case 1:
case 2:
gfx(index === 1 ? "SurfMailBorderGFX" : "LiteBlueMailBorderGFX", 8, 2);
gfx(index === 1 ? "MailLaprasGFX" : "MailDratiniGFX", 6, 3);
gfx(index === 1 ? "SurfMailWaveGFX" : "PortraitMailUnderlineGFX", 1, 2);
gfx("SurfLiteBlueMailSmallShapesGFX", 2, 2);
gfx("SurfLiteBlueMailSmallShapesGFX", 2, 1, 2);
gfx("SurfLiteBlueMailLargeShapesGFX", 8, 1);
gfx("SurfLiteBlueMailLargeShapesGFX", 8, 2, 8);
border();
row(2, 15, 16, 0x3f);
graphic(15, 14, 0x39, 3);
graphic(2, 2, 0x44);
graphic(15, 11, 0x44);
graphic(3, 12, 0x4c);
graphic(15, 2, 0x4c);
graphic(6, 3, 0x50);
dots(0x40, [
[13, 2],
[6, 14],
]);
dots(0x41, [
[4, 5],
[17, 5],
[13, 12],
]);
dots(0x42, [
[9, 2],
[14, 5],
[3, 10],
]);
put(6, 11, 0x43);
break;
case 3:
gfx("PortraitMailBorderGFX", 5, 2);
gfx("PortraitMailUnderlineGFX", 1, 2);
next = 0x3d;
gfx("PortraitMailLargePokeballGFX", 4, 1);
gfx("PortraitMailSmallPokeballGFX", 1, 2);
border(true);
row(8, 15, 10, 0x36);
icons();
// PrepMonFrontpic sets wBoxAlignment: reverse each row's bits,
// then PlaceGraphic lays the tile columns out right to left.
const picture = data.frontPic(mail.species, 1);
for (let i = 0; i < picture.length; i++) {
let value = picture[i],
reversed = 0;
for (let bit = 0; bit < 8; bit++) {
reversed = (reversed << 1) | (value & 1);
value >>= 1;
}
tiles[i] = reversed;
}
for (let x = 0; x < 7; x++)
for (let y = 0; y < 7; y++) put(1 + x, 10 + y, (6 - x) * 7 + y);
break;
case 4:
gfx("LovelyMailBorderGFX", 5, 2);
gfx("MailPoliwagGFX", 6, 3);
gfx("LovelyMailUnderlineGFX", 1, 2);
gfx("LovelyMailLargeHeartGFX", 4, 2);
gfx("LovelyMailSmallHeartGFX", 1, 1);
border(true);
row(2, 15, 16, 0x3c);
graphic(15, 14, 0x36, 3);
icons();
break;
case 5:
gfx("EonMailBorder1GFX", 1, 2);
gfx("EonMailBorder2GFX", 1, 1);
gfx("EonMailBorder2GFX", 1, 1);
gfx("EonMailBorder1GFX", 1, 2);
gfx("SurfMailBorderGFX", 1, 2, 6);
gfx("MailEeveeGFX", 6, 3);
next = 0x3d;
gfx("MailLargeCircleGFX", 4, 1);
gfx("EonMailBorder2GFX", 1, 2);
alternating(0, 0, 18, 0x31);
alternating(1, 17, 18, 0x31);
alternating(0, 1, 16, 0x33, true);
alternating(19, 0, 16, 0x33, true);
row(2, 15, 16, 0x35);
graphic(15, 14, 0x36, 3);
icons();
break;
case 6:
// MailGFX_GenerateMonochromeTilesColor2 uses 5 * TILE_1BPP_SIZE
// iterations (40 rows), filling five 2bpp tiles.
solid(5, 2);
gfx("MorphMailBorderCornerGFX", 1, 2, 3);
gfx("MorphMailBorderCornerGFX", 1, 2);
gfx("MorphMailBorderGFX", 1, 2);
gfx("EonMailBorder1GFX", 1, 1);
gfx("MorphMailDividerGFX", 1, 2);
gfx("MailDittoGFX", 6, 3);
border(true);
graphic(1, 1, 0x31);
graphic(17, 15, 0x31);
dots(0x31, [
[1, 3],
[3, 1],
[16, 16],
[18, 14],
]);
dots(0x36, [
[1, 4],
[2, 3],
[3, 2],
[4, 1],
]);
dots(0x37, [
[15, 16],
[16, 15],
[17, 14],
[18, 13],
]);
row(2, 15, 14, 0x38);
row(2, 11, 16, 0x39);
row(2, 5, 16, 0x39);
row(6, 1, 13, 0x3a);
row(1, 16, 13, 0x3a);
graphic(3, 13, 0x3b, 3);
break;
case 7:
gfx("EonMailBorder1GFX", 1, 2);
solid(1, 3);
gfx("BlueSkyMailGrassGFX", 1, 3);
gfx("MailDragoniteAndSentretGFX", 23, 3);
gfx("MailCloudGFX", 6, 1);
gfx("FlowerMailBorderGFX", 1, 1, 6);
gfx("MailCloudGFX", 1, 1);
gfx("MailCloudGFX", 2, 1, 2);
gfx("MailCloudGFX", 1, 1, 5);
row(0, 0, 20, 0x31);
col(0, 1, 16, 0x31);
col(19, 1, 16, 0x31);
row(0, 17, 20, 0x32);
row(0, 16, 20, 0x33);
for (let j = 0; j < 3; j++)
for (let i = 0; i < 6; i++) put(2 + j + i, 2 + j, 0x34 + j * 6 + i);
put(9, 4, 0x7f);
graphic(15, 14, 0x45);
put(15, 16, 0x49);
put(16, 16, 0x4a);
graphic(12, 1, 0x4b, 3);
graphic(15, 4, 0x4b, 3);
row(2, 11, 16, 0x51);
graphic(10, 3, 0x52);
break;
case 8:
gfx("MusicMailBorderGFX", 4, 2);
gfx("MorphMailBorderGFX", 2, 2);
gfx("MailNatuGFX", 6, 3);
solid(1, 0);
gfx("MusicMailLargeNoteGFX", 3, 1);
gfx("MusicMailSmallNoteGFX", 1, 1);
alternating(0, 0, 18, 0x31);
alternating(1, 17, 18, 0x31);
alternating(0, 1, 16, 0x33, true);
alternating(19, 0, 16, 0x33, true);
alternating(2, 15, 14, 0x35);
graphic(15, 14, 0x37, 3);
icons();
break;
case 9:
solid(5, 2);
gfx("BlueSkyMailGrassGFX", 1, 2);
gfx("MailMewGFX", 18, 2);
gfx("LiteBlueMailBorderGFX", 1, 1, 1);
gfx("LiteBlueMailBorderGFX", 1, 1, 6);
border(true);
row(1, 16, 18, 0x36);
graphic(15, 14, 0x37, 3);
put(15, 16, 0x3d);
put(16, 16, 0x3e);
alternating(1, 1, 18, 0x3f);
alternating(0, 2, 14, 0x41, true);
alternating(19, 2, 14, 0x43, true);
put(0, 1, 0x45);
put(19, 1, 0x46);
put(0, 16, 0x47);
put(19, 16, 0x48);
row(2, 5, 16, 0x49);
row(2, 11, 16, 0x4a);
}
// PlaceString's <NEXT> advances two tile rows.
mail.message
.split("\n")
.forEach((line, y) =>
textTiles(line).forEach((tile, x) => put(2 + x, 7 + y * 2, tile)),
);
textTiles(mail.author).forEach((tile, x) =>
put((index === 3 ? 8 : index === 6 ? 6 : 5) + x, 14, tile),
);
return screen;
}
/** ReadAnyMail.loop: A/B return; Start prints and returns to the letter. */
export function* readMail(data: PokemonData, mail: Mail, frame = 0): Gen {
const screen = mailScreen(data, mail);
for (;;) {
const button = yield { kind: "mail", mail: { ...mail }, screen };
if (button === "a" || button === "b") return;
if (button === "start") yield* printerDisconnected(data.reader, frame);
}
}
+20 -10
View File
@@ -3,7 +3,9 @@ import { SOUND } from "../audio/constants";
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
import { copyMail } from "../party/mail";
import { hallOfFamePC } from "./hall-of-fame";
import { readMail } from "./mail";
import { printerDisconnected } from "./printer";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import {
BOX_NAME_PATTERN,
@@ -47,6 +49,7 @@ export interface PcHost {
const PCPCITEM_PLAYERS_PC = 0,
PCPCITEM_BILLS_PC = 1,
PCPCITEM_OAKS_PC = 2,
PCPCITEM_HALL_OF_FAME = 3,
PCPCITEM_TURN_OFF = 4;
/** _PlayersPC's menu items (PLAYERSPCITEM_*). */
const PLAYERSPCITEM_WITHDRAW_ITEM = 0,
@@ -154,9 +157,11 @@ export class PcSession {
const whose = this.pages(PC.PokecenterPCWhoseText);
let cursor = 0;
for (;;) {
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX or PCPC_BEFORE_HOF.
// No Hall of Fame entry exists before the League is ported.
const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? 1 : 0;
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX, PCPC_BEFORE_HOF, or
// PCPC_POSTGAME once wHallOfFameCount (not the League flag) is set.
const set = this.state.flags.has(ENGINE_FLAGS.pokedex)
? this.state.hallOfFameCount ? 2 : 1
: 0;
const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set);
const choice = yield* this.menu(
whose,
@@ -185,11 +190,19 @@ export class PcSession {
yield* this.print(PC.PokecenterOaksPCText);
yield* this.oaksPC();
break;
case PCPCITEM_HALL_OF_FAME:
yield* hallOfFamePC(
this.host.data,
this.state.hallOfFameTeams,
this.state.options.textboxFrame,
this.host.sound,
);
break;
case PCPCITEM_TURN_OFF:
yield* this.print(PC.PokecenterPCOaksClosedText);
return;
default:
throw new Error("The Hall of Fame PC is not ported.");
throw new Error("Unknown Pokémon Center PC entry.");
}
}
}
@@ -261,7 +274,7 @@ export class PcSession {
if (index < 0 || index >= mailbox.length) return;
cursor = index;
const action = yield* this.menu([], actions);
if (action === 0) yield { kind: "mail", mail: copyMail(mailbox[index]) };
if (action === 0) yield* readMail(this.host.data, mailbox[index], this.state.options.textboxFrame);
else if (action === 1) yield* this.putMailInPack(index);
else if (action === 2) yield* this.attachMail(index);
}
@@ -824,10 +837,7 @@ export class PcSession {
this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]),
);
else
yield {
kind: "notice",
text: "Printing needs a Game Boy Printer, which is not supported.",
};
yield* printerDisconnected(this.host.reader, this.state.options.textboxFrame);
}
}
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { printerDisconnected } from "./printer";
import type { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants";
import { NUM_POKEMON } from "../rom/profile";
@@ -96,7 +97,7 @@ export interface PokedexHost {
* the entry screen. B leaves the list; A opens a seen Pokémon's entry,
* where left and right move the arrow cursor, up and down step through
* seen Pokémon, A runs the action under the cursor and B returns. PAGE
* and CRY act: AREA and PRNT, the option (SELECT) and search (START)
* and CRY act; PRNT reaches the no-printer status. AREA, the option (SELECT) and search (START)
* screens and the Unown mode are not ported. */
export class Pokedex {
/** wPokedexOrder, in DEXMODE_NEW. */
@@ -176,11 +177,12 @@ export class Pokedex {
else if (button === "right" && action < ENTRY_ACTIONS - 1) action++;
if (button === "b") return;
if (button === "a") {
// Pokedex_Page and .Cry; .Area and .Print are not ported.
// Pokedex_Page, .Cry and the absent-printer path of .Print.
if (action === 0) {
page = page === 1 ? 2 : 1;
this.prevEntry = this.selected();
} else if (action === 2) this.host.sound?.playCry(this.selected());
else if (action === 3) yield* printerDisconnected(this.host.data.reader);
continue;
}
if (
+3
View File
@@ -552,5 +552,8 @@ export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost):
take: unused,
flush: unused,
selectMon: unused,
playSFX: unused,
cry: unused,
waitSFX: unused,
};
}
+45
View File
@@ -0,0 +1,45 @@
import type { RomReader } from "../rom/reader";
import { JOHTO } from "../rom/profile";
import { fontTiles, textTiles } from "../rom/font";
import { blankScreen } from "./screen";
import { defaultTextContext, readString } from "./text";
import type { Gen } from "./special-context";
/** SendScreenToPrinter/CheckPrinterStatus with no peripheral connected:
* PRINTER_ERROR_2 persists until CheckCancelPrint receives B. The native
* status screen omits the print preview and the link retry animation. */
export function* printerDisconnected(reader: RomReader, frame = 0): Gen {
const context = defaultTextContext("");
const status = readString(
reader,
JOHTO.GBPrinterString_PrinterError2,
context,
64,
);
const cancel = readString(reader, JOHTO.String_PressBToCancel, context);
const screen = blankScreen(
fontTiles(reader, frame),
`printer:${frame}`,
[
[
[255, 255, 255],
[170, 170, 170],
[85, 85, 85],
[0, 0, 0],
],
],
`${status}\n${cancel}`,
);
screen.map.fill(0x7f);
for (const [index, line] of [
...status.split("\n"),
"",
...cancel.split("\n"),
].entries())
textTiles(line).forEach((tile, x) => {
screen.map[(5 + index) * 20 + 1 + x] = tile;
});
while ((yield { kind: "screen", screen }) !== "b") {
/* CheckCancelPrint */
}
}
+3 -3
View File
@@ -53,10 +53,10 @@ export interface TileScreen {
label: string;
/** Drawn over the overworld, like a menu box, rather than replacing it. */
overlay?: boolean;
/** hSCY as a signed pixel offset: the background (not the sprites)
* moves up by `y`, and rows scrolled in from beyond the tilemap show
/** hSCX/hSCY as signed pixel offsets: the background (not sprites)
* moves left by `x` and up by `y`, and rows scrolled in from beyond the tilemap show
* `fill` (what the screen left in the rest of the 32-row BG map). */
scroll?: { y: number; fill: number };
scroll?: { x?: number; y: number; fill: number };
}
/** A GBC color (RGB555, little-endian word) as RGB. */
+9 -2
View File
@@ -31,6 +31,13 @@ export interface SpecialContext {
take(): string[];
/** Show the pending pages and wait for a button, if there are any. */
flush(): Gen;
/** SelectMonFromParty after the pending pages: a party index, or -1. */
selectMon(): Gen<number>;
/** SelectMonFromParty after the pending pages: a party index, or -1.
* With a PARTYMENUACTION_*, SelectTradeOrDayCareMon's prompt. */
selectMon(action?: number): Gen<number>;
/** PlaySFX: now, or with the pending page. */
playSFX(id: number): void;
/** PlayMonCry: with the pending page, else now and waited for. */
cry(species: number): Gen;
/** WaitSFX, when no page is pending. */
waitSFX(): Gen;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { readMail } from "./mail";
import { SOUND } from "../audio/constants";
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
@@ -789,7 +790,7 @@ export class StartMenuRun {
/** ReadPartyMonMail: the letter, until a button. */
private *readMail(mail: Mail | undefined): Gen {
if (!mail) throw new Error("The Pokémon holds MAIL without a message.");
yield { kind: "mail", mail: { ...mail } };
yield* readMail(this.data, mail, this.state.options.textboxFrame);
}
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
if (pocket === TM_HM_POCKET) {
+2 -1
View File
@@ -19,7 +19,8 @@ const sameOT = (ot: string, player: string) => {
/** CheckOwnMonAnywhere (engine/pokemon/search_owned.asm): a Pokémon of
* `species` with the player's ID and OT in the party or a box. An empty
* party finds nothing; the Day-Care is not searched. The party struct's
* party finds nothing; the original bug omits the Day-Care (see
* engine/pokemon/search_owned.asm), even when its Pokémon belong to us. The party struct's
* species is read, so an Egg counts as its species. */
export function ownsMon(c: SpecialContext, species: number) {
const { party, storage } = c.state;
+2
View File
@@ -166,6 +166,8 @@ export const OW = {
NUM_OVERWORLD_SPRITES: 102,
SPRITE_POKEMON: 0x80,
NUM_POKEMON_SPRITES: 35,
SPRITE_DAY_CARE_MON_1: 0xe0,
SPRITE_DAY_CARE_MON_2: 0xe1,
SPRITE_VARS: 0xf0,
// Moves used as field moves (constants/move_constants.asm).
CUT: 0x0f,
+205 -6
View File
@@ -3,7 +3,8 @@ import {
initialDecorations,
type Decorations,
} from "./decorations";
import { CrystalAssets } from "../rom/assets";
import { validHallOfFame, type HallOfFameTeam } from "../party/hall-of-fame";
import { CrystalAssets, MON_SPRITE_ID } from "../rom/assets";
import { GameSound } from "../audio/sound";
import { SOUND } from "../audio/constants";
import { battleMusic, hpBarPixels, inKanto, victoryMusic } from "../audio/battle-music";
@@ -73,6 +74,8 @@ import {
type BattleTowerState,
} from "../scripts/battle-tower";
import { IntroRun } from "../scripts/intro";
import { HatchRun } from "../scripts/hatching";
import { doEggStep, stepHappiness } from "../party/eggs";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
@@ -101,6 +104,13 @@ import {
} from "../party/mail";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
copyDayCare,
dayCareStep as stepDayCare,
emptyDayCare,
validDayCare,
type DayCareState,
} from "../party/day-care";
import { NUM_TMS_HMS } from "../rom/pokemon";
import {
copyStorage,
@@ -206,12 +216,21 @@ export interface NativeSave {
noWildEncounters: boolean;
/** STATUSFLAGS_HALL_OF_FAME_F. */
hallOfFame: boolean;
/** wHallOfFameCount and sHallOfFame, newest team first. */
hallOfFameCount: number;
hallOfFameTeams: HallOfFameTeam[];
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
/** sMailboxes: the PC's MAIL. */
mailbox: Mail[];
/** wRepelEffect, wCoins and wBlueCardBalance. */
repel: number;
/** wStepCount, wPoisonStepCount and wHappinessStepCount (CountStep), and
* DoBikeStep's wBikeStep. */
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
bikeStep: number;
coins: number;
blueCardBalance: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
@@ -235,6 +254,9 @@ export interface NativeSave {
roamMonsMap: string | null;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
/** The Day-Care's bytes (wDayCareMan to wEggMon); an empty Day-Care in
* saves from before it was ported. */
dayCare: DayCareState;
/** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline,
* while a Bug-Catching Contest runs; absent otherwise. */
bugContest?: { parkBalls: number; mon: PartyMon | null };
@@ -510,7 +532,7 @@ function evolutionSchedule(from: number, to: number) {
const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript {
run: ScriptRun | IntroRun | StartMenuRun;
run: ScriptRun | IntroRun | StartMenuRun | HatchRun;
effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave;
@@ -521,6 +543,8 @@ interface ActiveScript {
state: ScriptState;
mapId: string;
blocks: Uint8Array;
/** A hatch refusal restores the triggering walking step as well. */
tilesWalked?: number;
};
waitTicks: number;
moving: Map<
@@ -719,6 +743,8 @@ const OBJECTTYPE_TRAINER = 2;
const TOWN = 1,
ROUTE = 2;
const NUM_SPECIALCALLS = 8;
/** SPECIALCALL_BIKESHOP (constants/phone_constants.asm). */
const SPECIALCALL_BIKESHOP = 6;
/** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) =>
@@ -992,10 +1018,17 @@ export class GameWorld {
? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2;
}
/** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites. */
/** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites, and
* GetMonSprite draws SPRITE_DAY_CARE_MON_1 and 2 as the species of
* wBreedMon1 and 2 (none: no sprite). */
private spriteOf(object: MapObject, state = this.liveState): number {
const override = this.actors?.get(object.id)?.sprite;
if (override !== undefined) return override;
if (object.sprite === OW.SPRITE_DAY_CARE_MON_1 || object.sprite === OW.SPRITE_DAY_CARE_MON_2) {
const mon =
object.sprite === OW.SPRITE_DAY_CARE_MON_1 ? state.dayCare.mon1 : state.dayCare.mon2;
return mon ? MON_SPRITE_ID + mon.species : 0;
}
if (object.sprite < OW.SPRITE_VARS) return object.sprite;
return state.variables[object.sprite - OW.SPRITE_VARS] ?? 0;
}
@@ -1701,8 +1734,132 @@ export class GameWorld {
);
if (event)
this.startScript(event.script, PLAYER, { committedPosition: previous });
else if (!this.checkSpecialPhoneCall(previous) && !this.repelStep(previous))
this.randomEncounter(previous);
else if (!this.countStep(previous)) this.randomEncounter(previous);
}
/** CountStep (engine/overworld/events.asm), after the coordinate events
* and before RandomEncounter: a queued special call or the end of a
* Repel runs its script and the step is not counted; otherwise the step
* is counted for poison and in total, StepHappiness runs when the total
* wraps (every 256 steps), DoEggStep at $80 (so a hatching Egg's script
* replaces the Day-Care, poison and bike checks), then DayCareStep, the
* poison check every fourth step and DoBikeStep. Returns true when it
* started a script. */
private countStep(previous: { x: number; y: number }): boolean {
if (this.checkSpecialPhoneCall(previous) || this.repelStep(previous))
return true;
const state = this.state;
// A step that starts a script replays after a reload from the tile
// before it, so that script's saved state keeps what the step changed.
const before = {
stepCount: state.stepCount,
poisonStepCount: state.poisonStepCount,
happinessStepCount: state.happinessStepCount,
happiness: state.party.map((mon) => mon.happiness),
};
state.poisonStepCount = (state.poisonStepCount + 1) & 0xff;
state.stepCount = (state.stepCount + 1) & 0xff;
if (!state.stepCount) stepHappiness(state, state.party);
if (state.stepCount === 0x80 && doEggStep(state.party)) {
this.startHatch(previous, before);
return true;
}
this.dayCareStep();
// Every fourth step DoPoisonStep runs. It is not ported: poison does
// not wear down the party on the overworld, nor faint it.
if (state.poisonStepCount >= 4) state.poisonStepCount = 0;
this.bikeStep();
return false;
}
/** CountStep's farcall DayCareStep (engine/events/happiness_egg.asm): a
* counted step is one experience point for each Pokémon in the Day-Care
* and, while they are compatible, brings the egg check nearer. */
private dayCareStep() {
stepDayCare(this.assets.pokemon, this.state, () => this.random() & 0xff);
}
/** DoBikeStep: once the Bike Shop owner has the player's number
* (ENGINE_BIKE_SHOP_CALL_ENABLED), the steps taken on the Bicycle where
* the phone has service are counted, up to 65535; from 1024 the owner
* calls (SPECIALCALL_BIKESHOP) unless another call is already queued,
* and the flag is cleared. */
private bikeStep() {
const state = this.state;
if (
!state.flags.has(ENGINE_FLAGS.bikeShopCall) ||
state.playerState !== OW.PLAYER_BIKE ||
this.map.phoneService
)
return;
if (state.bikeStep !== 0xffff) state.bikeStep++;
// `cp HIGH(1024)` on the high byte, and the call ID's low byte only.
if (state.bikeStep < 1024 || (state.specialPhoneCall & 0xff)) return;
state.specialPhoneCall = SPECIALCALL_BIKESHOP;
state.flags.delete(ENGINE_FLAGS.bikeShopCall);
}
/** HatchEggScript (callasm OverworldHatchEgg) for PLAYEREVENT_HATCH. The
* script's draft has the step's counts and the Eggs' cycles; its saved
* state, like every step event's, is from before the step (see
* `startScript`), so a reload during the hatching walks the step again. */
private startHatch(
previous: { x: number; y: number },
before: {
stepCount: number;
poisonStepCount: number;
happinessStepCount: number;
happiness: number[];
},
) {
const committed = this.save();
committed.x = previous.x;
committed.y = previous.y;
committed.tilesWalked--;
committed.stepCount = before.stepCount;
committed.poisonStepCount = before.poisonStepCount;
committed.happinessStepCount = before.happinessStepCount;
committed.party.forEach((mon, index) => (mon.happiness = before.happiness[index]));
// RepelWoreOff did not run; a Repel that is still active lost a step.
if (this.state.repel) committed.repel++;
const rollbackState = copyScriptState(this.state);
rollbackState.stepCount = before.stepCount;
rollbackState.poisonStepCount = before.poisonStepCount;
rollbackState.happinessStepCount = before.happinessStepCount;
rollbackState.party.forEach((mon, index) => (mon.happiness = before.happiness[index]));
rollbackState.repel = committed.repel;
this.script = {
run: new HatchRun(this.state, {
objectPalettes: [0, 1].map((index) => this.assets.objectPalette(index, this.timeOfDay)),
reader: this.assets.reader,
data: this.assets.pokemon,
timeOfDay: () => this.clockTimeOfDay,
landmark: () => this.assets.map(this.mapId).landmark,
player: () => ({
id: this.playerId,
gender: this.avatar === "kris" ? 1 : 0,
}),
text: () => this.host(PLAYER).text(),
sound: this.sound,
}),
committed,
rollback: {
state: rollbackState,
tilesWalked: committed.tilesWalked,
mapId: this.mapId,
player: { ...copyActor(this.player), x: previous.x, y: previous.y },
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.screenInput = [];
this.pump();
}
/** CountStep's DoRepelStep: a Repel loses a step, and the step that ends
@@ -2246,6 +2403,10 @@ export class GameWorld {
this.currentMap = entry.map;
this.stoneTables = entry.stoneTables;
this.state = entry.state;
// EnterMap clears the poison clock on an actual map transition.
// Native save restoration keeps its saved phase for exact replay.
if (setup === "warp" || setup === "door" || setup === "connection")
this.state.poisonStepCount = 0;
this.actors = entry.actors;
this.spawned = new Set(
this.map.objects
@@ -3010,7 +3171,7 @@ export class GameWorld {
return true;
case "mail":
this.mailView = { ...effect.mail };
this.dialogue = { pages: ["MAIL"], index: 0 };
this.dialogue = { pages: ["MAIL"], index: 0, choice: { kind: "screen", screen: effect.screen, selected: 0 } };
return true;
case "mapradio":
this.dialogue = {
@@ -3563,6 +3724,7 @@ export class GameWorld {
this.battle = undefined;
this.battleRun = undefined;
this.state = script.rollback.state;
if (script.rollback.tilesWalked !== undefined) this.tilesWalked = script.rollback.tilesWalked;
this.mapId = script.rollback.mapId;
this.map.blocks.set(script.rollback.blocks);
this.blockRevision++;
@@ -4989,9 +5151,15 @@ export class GameWorld {
flash: state.flash,
noWildEncounters: state.noWildEncounters,
hallOfFame: state.hallOfFame,
hallOfFameCount: state.hallOfFameCount,
hallOfFameTeams: structuredClone(state.hallOfFameTeams),
registeredItem: { ...state.registeredItem },
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
stepCount: state.stepCount,
poisonStepCount: state.poisonStepCount,
happinessStepCount: state.happinessStepCount,
bikeStep: state.bikeStep,
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
@@ -5005,6 +5173,7 @@ export class GameWorld {
roamMons: structuredClone(state.roamMons),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
dayCare: copyDayCare(state.dayCare),
...(state.bugContest && {
bugContest: {
parkBalls: state.bugContest.parkBalls,
@@ -5132,10 +5301,15 @@ export class GameWorld {
typeof save.flash !== "boolean" ||
typeof save.noWildEncounters !== "boolean" ||
typeof save.hallOfFame !== "boolean" ||
!validHallOfFame(save.hallOfFameCount, save.hallOfFameTeams) ||
!save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) ||
!integer(save.stepCount, 0, 255) ||
!integer(save.poisonStepCount, 0, 255) ||
!integer(save.happinessStepCount, 0, 1) ||
!integer(save.bikeStep, 0, 0xffff) ||
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.tradeFlags, 0, 0x7f) ||
!integer(save.blueCardBalance, 0, 255) ||
@@ -5171,6 +5345,7 @@ export class GameWorld {
(typeof save.roamMonsMap !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) ||
!validOptions(save.options) ||
!validDayCare(this.assets.pokemon, save.dayCare, new Set(save.flags)) ||
(save.bugContest === undefined) !== (save.bugContestEnd === undefined) ||
(save.bugContest !== undefined &&
(typeof save.bugContest !== "object" ||
@@ -5254,9 +5429,15 @@ export class GameWorld {
flash: save.flash,
noWildEncounters: save.noWildEncounters,
hallOfFame: save.hallOfFame,
hallOfFameCount: save.hallOfFameCount,
hallOfFameTeams: structuredClone(save.hallOfFameTeams),
registeredItem: { ...save.registeredItem },
mailbox: save.mailbox.map(copyMail),
repel: save.repel,
stepCount: save.stepCount,
poisonStepCount: save.poisonStepCount,
happinessStepCount: save.happinessStepCount,
bikeStep: save.bikeStep,
coins: save.coins,
tradeFlags: save.tradeFlags,
blueCardBalance: save.blueCardBalance,
@@ -5270,6 +5451,7 @@ export class GameWorld {
roamMons: structuredClone(save.roamMons),
roamMonsMap: save.roamMonsMap,
options: { ...save.options },
dayCare: copyDayCare(save.dayCare),
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
...(save.bugContest && {
@@ -5397,6 +5579,8 @@ export function migrateSave(
...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false,
}),
...(!Object.hasOwn(save, "hallOfFameCount") && { hallOfFameCount: 0 }),
...(!Object.hasOwn(save, "hallOfFameTeams") && { hallOfFameTeams: [] }),
...(!Object.hasOwn(save, "hallOfFame") && { hallOfFame: false }),
...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 },
@@ -5407,6 +5591,13 @@ export function migrateSave(
coins: 0,
blueCardBalance: 0,
}),
// Saves before CountStep was ported start the step counters at 0.
...(!Object.hasOwn(save, "stepCount") && {
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
}),
// Packs saved before the TM/HM pocket had no TMs or HMs.
...(typeof save.bag === "object" &&
save.bag !== null &&
@@ -5441,6 +5632,7 @@ export function migrateSave(
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
...(!Object.hasOwn(save, "dayCare") && { dayCare: emptyDayCare() }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
typeof save.clock === "object" &&
@@ -5486,9 +5678,15 @@ export function migrateSave(
flash: false,
noWildEncounters: false,
hallOfFame: false,
hallOfFameCount: 0,
hallOfFameTeams: [],
registeredItem: { which: 0, item: 0 },
mailbox: [],
repel: 0,
stepCount: 0,
poisonStepCount: 0,
happinessStepCount: 0,
bikeStep: 0,
coins: 0,
blueCardBalance: 0,
tradeFlags: 0,
@@ -5504,5 +5702,6 @@ export function migrateSave(
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),
dayCare: emptyDayCare(),
} as unknown as NativeSave;
}