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:
1 parent
7d03f5c5ca
commit
e49bc6de9a
49 files changed
+6028
-88
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -552,5 +552,8 @@ export function radioSpecialContext(state: ScriptState, scriptHost: ScriptHost):
|
||||
take: unused,
|
||||
flush: unused,
|
||||
selectMon: unused,
|
||||
playSFX: unused,
|
||||
cry: unused,
|
||||
waitSFX: unused,
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
+4
-3
@@ -415,8 +415,7 @@ function mailField(answer: (message: string) => void): HTMLElement[] {
|
||||
box.append(...lines);
|
||||
return [box, ok];
|
||||
}
|
||||
/** ReadAnyMail: the message at its two lines and the author below. The
|
||||
* stationery's graphics are not drawn. */
|
||||
/** ReadAnyMail: cartridge stationery plus accessible message text. */
|
||||
function showMail(world: GameWorld) {
|
||||
const mail = world.mailView;
|
||||
get("mail-view").hidden = !mail;
|
||||
@@ -424,6 +423,8 @@ function showMail(world: GameWorld) {
|
||||
get("mail-type").textContent = world.assets.pokemon.itemName(mail.type);
|
||||
get("mail-message").textContent = mail.message;
|
||||
get("mail-author").textContent = mail.author;
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind === "screen") drawTileScreen(get<HTMLCanvasElement>("mail-canvas"), choice.screen, 0);
|
||||
}
|
||||
type MartChoice = Extract<
|
||||
Choice,
|
||||
@@ -1349,7 +1350,7 @@ let tileScreenKey = "";
|
||||
* world's frames. */
|
||||
function showTileScreen(world: GameWorld) {
|
||||
const choice = world.dialogue?.choice;
|
||||
const screen = choice?.kind === "screen" ? choice.screen : undefined;
|
||||
const screen = choice?.kind === "screen" && !world.mailView ? choice.screen : undefined;
|
||||
get("tile-screen").hidden = !screen;
|
||||
get("tile-screen").classList.toggle("overlay", Boolean(screen?.overlay));
|
||||
if (!screen) {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
||||
</div>
|
||||
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
|
||||
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div>
|
||||
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><canvas id="mail-canvas" width="160" height="144"></canvas><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div>
|
||||
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
|
||||
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
|
||||
<div id="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div>
|
||||
|
||||
+15
-23
@@ -534,33 +534,25 @@ dialog h2 {
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
color: var(--ink);
|
||||
background: #f8f8f0;
|
||||
border: 8px double #a8b8d0;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
justify-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.mail-type {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
#mail-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.mail-message {
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
.mail-type, .mail-message, .mail-author {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mail-author {
|
||||
margin: 0;
|
||||
justify-self: end;
|
||||
}
|
||||
.mail-view button {
|
||||
justify-self: end;
|
||||
}
|
||||
.mail-view button { justify-self: end; }
|
||||
.stats-screen[data-page="1"] {
|
||||
--stats-page: #88c888;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user