Port capture and the Route 29 catching tutorial
Battle PACK opens the Balls pocket and runs PokeBallEffect: the Poké, Great, Ultra and Master Ball multipliers, the original HP arithmetic (including the overflow above 341 maximum HP), the sleep/freeze bonus (BRN/PSN/PAR add nothing), HELD_CATCH_CHANCE and the catch draw. GetPokeBallWobble draws per shake from WobbleProbabilities and picks the break-free text. Trainer battles block the ball but still use it and the turn. A catch reloads the Pokémon as LoadEnemyMon does (the wild item roll is redrawn, then the item restored), keeps its HP, status, item and DVs, restores the moves and PP it appeared with, adds it to the party with SetCaughtData, registers it and offers a nickname with InitName's default. A catch with six Pokémon needs the PC and is refused. catchtutorial runs BATTLETYPE_TUTORIAL as "DUDE": no send-out, an automatic catch that keeps nothing and prompts that continue on their own. Both Route 29 triggers, declining and the Dude's repeat offer run from the ROM. The text reader now handles TX_LOW. PLAN.md gains a status overview and done/remaining checklists; the README, help dialog, routine map and handoff describe the milestone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc10343520
commit
9ac111f262
20 files changed
+1101
-65
No files matched your search
+264
-6
@@ -1,7 +1,13 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import { BATTLE, RAM } from "../rom/profile";
|
||||
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { calcStats, type PartyMon } from "../party/pokemon";
|
||||
import type { Bag } from "../party/bag";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
calcStats,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
||||
import { BattleData } from "./data";
|
||||
import { C, COMMAND } from "./constants";
|
||||
@@ -91,9 +97,16 @@ export type BattleEffect =
|
||||
/** Choose a move to forget (0-3); 4 cancels, like B on the list. */
|
||||
| { kind: "forget"; moves: number[]; learning: number }
|
||||
/** The level-up stat box (PrintTempMonStats), acknowledged with a button. */
|
||||
| { kind: "stats"; labels: string[]; values: number[] };
|
||||
export type BattleAction = { action: "fight"; move: number } | { action: "run" };
|
||||
export type BattleInput = number | BattleAction | undefined;
|
||||
| { kind: "stats"; labels: string[]; values: number[] }
|
||||
/** The naming screen for a caught Pokémon: answered with the name, or ""
|
||||
* to keep `current`. */
|
||||
| { kind: "name"; title: string; current: string; max: number };
|
||||
export type BattleAction =
|
||||
| { action: "fight"; move: number }
|
||||
| { action: "run" }
|
||||
/** PACK: use the item in this slot of its pocket (only Balls so far). */
|
||||
| { action: "item"; item: number; slot: number };
|
||||
export type BattleInput = number | string | BattleAction | undefined;
|
||||
export type BattleOutcome = { result: number; prizeMoney?: number };
|
||||
|
||||
export interface BattleTrainer {
|
||||
@@ -125,6 +138,14 @@ export interface BattleOptions {
|
||||
trainer?: BattleTrainer;
|
||||
canLose?: boolean;
|
||||
text: () => TextContext;
|
||||
/** The player's pack, needed to throw Poké Balls. */
|
||||
bag?: Bag;
|
||||
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
|
||||
hasPokedex?: boolean;
|
||||
/** SetCaughtData's word for a Pokémon caught at this level. */
|
||||
caughtData?: (level: number) => number;
|
||||
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
|
||||
tutorial?: boolean;
|
||||
}
|
||||
|
||||
const SUB1 = 1,
|
||||
@@ -248,6 +269,43 @@ export function applyTypeMatchups(
|
||||
/** A native translation of Crystal's battle engine.
|
||||
* The move effect scripts are the ROM's own; each battle command is a
|
||||
* native translation. Unsupported AI, switching, contest and item paths are refused. */
|
||||
/** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in
|
||||
* the original 8- and 16-bit registers. Returns undefined when the
|
||||
* formula would divide by zero (a maximum HP of 342). */
|
||||
export function catchRate(input: {
|
||||
rate: number;
|
||||
ball: number;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
status: number;
|
||||
/** The player's battle Pokémon's held item effect. */
|
||||
held: { effect: number; parameter: number };
|
||||
}): number | undefined {
|
||||
let rate = input.rate;
|
||||
// BallMultiplierFunctionTable
|
||||
if (input.ball === C.ULTRA_BALL) rate = Math.min(0xff, rate << 1);
|
||||
else if (input.ball === C.GREAT_BALL) rate = Math.min(0xff, rate + (rate >> 1));
|
||||
// A maximum HP above 341 overflows the HP term (a kept bug).
|
||||
let hp = (input.hp << 1) & 0xffff,
|
||||
max = (input.maxHp * 3) & 0xffff;
|
||||
if (max >> 8) {
|
||||
max >>= 2;
|
||||
hp >>= 2;
|
||||
if (!(hp & 0xff)) hp |= 1;
|
||||
}
|
||||
const divisor = max & 0xff,
|
||||
remaining = hp & 0xff;
|
||||
if (!divisor) return undefined;
|
||||
let chance = Math.floor((rate * ((divisor - remaining) & 0xff)) / divisor) & 0xff;
|
||||
if (!chance) chance = 1;
|
||||
// Only sleep and freeze add to the chance; BRN/PSN/PAR do not (a kept bug).
|
||||
if (input.status & ((1 << C.FRZ) | C.SLP_MASK)) chance = Math.min(0xff, chance + 10);
|
||||
// HELD_CATCH_CHANCE adds its parameter, but no item has that effect.
|
||||
if (input.held.effect === C.HELD_CATCH_CHANCE)
|
||||
chance = Math.min(0xff, chance + input.held.parameter);
|
||||
return chance;
|
||||
}
|
||||
|
||||
export class Battle {
|
||||
readonly data: BattleData;
|
||||
readonly player = new Side();
|
||||
@@ -268,6 +326,14 @@ export class Battle {
|
||||
weather: number = C.WEATHER_NONE;
|
||||
/** wCurOTMon: the trainer's party index in battle. */
|
||||
curOTMon = 0;
|
||||
/** wWildMon after a ball: the caught species, or 0. */
|
||||
wildMon = 0;
|
||||
/** wFinalCatchRate from the last ball that ran the catch formula. */
|
||||
private finalCatchRate = 0;
|
||||
/** wWildMonMoves/wWildMonPP: the wild Pokémon's moves and PP when it
|
||||
* appeared, which a caught Pokémon keeps. */
|
||||
private wildMoves: number[] = [];
|
||||
private wildPP: number[] = [];
|
||||
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
|
||||
private enemySwitchMonIndex = 0;
|
||||
private battleHasJustStarted = 0;
|
||||
@@ -294,6 +360,7 @@ export class Battle {
|
||||
this.data = new BattleData(options.data.reader);
|
||||
this.matchups = this.data.typeMatchups();
|
||||
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
|
||||
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
|
||||
}
|
||||
private get reader(): RomReader {
|
||||
return this.options.data.reader;
|
||||
@@ -317,6 +384,11 @@ export class Battle {
|
||||
const base = this.options.text();
|
||||
return {
|
||||
...base,
|
||||
// CatchTutorial copies "DUDE" over wPlayerName for the battle.
|
||||
player:
|
||||
this.battleType === C.BATTLETYPE_TUTORIAL
|
||||
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
|
||||
: base.player,
|
||||
trainer: this.options.trainer
|
||||
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
||||
: base.trainer,
|
||||
@@ -339,8 +411,10 @@ export class Battle {
|
||||
/** StdBattleTextbox / BattleTextbox with a ROM text. */
|
||||
private *say(address: number): Gen {
|
||||
const { text, prompt } = readTextEnd(this.reader, address, this.context());
|
||||
// The tutorial's auto input presses A at every prompt.
|
||||
const waits = prompt && this.battleType !== C.BATTLETYPE_TUTORIAL;
|
||||
for (const page of text.split("\n\n").filter(Boolean))
|
||||
yield { kind: "text", text: page, prompt };
|
||||
yield { kind: "text", text: page, prompt: waits };
|
||||
}
|
||||
/** text_asm routines that battle texts run between fragments. */
|
||||
private textAsm(offset: number): number {
|
||||
@@ -381,6 +455,9 @@ export class Battle {
|
||||
return BATTLE.BattleMonNicknameText;
|
||||
case at("Text_1_2_and_Poof"):
|
||||
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
||||
case at("Text_GotchaMonWasCaught"):
|
||||
// Capture music (not ported), then WaitButtonText.
|
||||
return BATTLE.WaitButtonText;
|
||||
default:
|
||||
throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`);
|
||||
}
|
||||
@@ -472,6 +549,8 @@ export class Battle {
|
||||
};
|
||||
this.options.pokedex.seen.add(species);
|
||||
this.enemy.stats = [...this.enemy.mon.stats];
|
||||
this.wildMoves = [...moves];
|
||||
this.wildPP = [...this.enemy.mon.pp];
|
||||
}
|
||||
/** InitBattleMon from the party, then status penalties. */
|
||||
private initBattleMon() {
|
||||
@@ -543,10 +622,23 @@ export class Battle {
|
||||
)
|
||||
throw new UnsupportedBattle("Invalid trainer reward.");
|
||||
}
|
||||
if (this.options.tutorial && (trainer || !this.options.wild))
|
||||
throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon.");
|
||||
this.loadEnemyMon();
|
||||
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
|
||||
if (this.curBattleMon < 0)
|
||||
throw new UnsupportedBattle("No Pokémon can fight.");
|
||||
if (this.battleType === C.BATTLETYPE_TUTORIAL) {
|
||||
// DoBattle goes straight to BattleMenu without sending anyone out.
|
||||
// The auto input picks PACK, then TutorialPack's Balls pocket, and
|
||||
// BattleMenu_Pack uses the Dude's own POKé BALL.
|
||||
yield* this.say(BATTLE.WildPokemonAppearedText);
|
||||
yield this.hud();
|
||||
yield* this.pokeBallEffect(C.POKE_BALL, -1);
|
||||
if (!this.wildMon) throw new UnsupportedBattle("The tutorial ball did not catch.");
|
||||
this.battleResult &= 0xc0; // BattleMenu_Pack.UseItem's .run: WIN
|
||||
return { result: this.battleResult };
|
||||
}
|
||||
if (trainer) {
|
||||
yield* this.say(BATTLE.WantsToBattleText);
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
@@ -699,6 +791,20 @@ export class Battle {
|
||||
for (;;) {
|
||||
const input = yield { kind: "action" };
|
||||
if (!input || typeof input !== "object") continue;
|
||||
if (input.action === "item") {
|
||||
const used = yield* this.battleMenuPack(input.item, input.slot);
|
||||
if (this.wildMon) {
|
||||
// BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the
|
||||
// battle as a WIN, keeping the result's flag bits.
|
||||
this.wildMon = 0;
|
||||
this.battleResult &= 0xc0;
|
||||
this.battleEnded = true;
|
||||
return true;
|
||||
}
|
||||
if (!used) continue;
|
||||
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
|
||||
return false;
|
||||
}
|
||||
if (input.action === "run") {
|
||||
const escaped = yield* this.tryToRunAway();
|
||||
if (escaped) {
|
||||
@@ -714,6 +820,158 @@ export class Battle {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** BattleMenu_Pack with BattlePack's USE on a Balls-pocket slot. Returns
|
||||
* true if the item was used, which takes the player's turn. */
|
||||
private *battleMenuPack(item: number, slot: number): Gen<boolean> {
|
||||
const pocket = this.options.bag?.balls;
|
||||
if (!pocket || !Number.isInteger(slot) || pocket[slot]?.item !== item) return false;
|
||||
return yield* this.pokeBallEffect(item, slot);
|
||||
}
|
||||
|
||||
/** PokeBallEffect (engine/items/item_effects.asm). `slot` is the pocket
|
||||
* index the ball came from (wCurItemQuantity); the tutorial uses the
|
||||
* Dude's own ball and passes -1. Returns false if no ball was used. */
|
||||
private *pokeBallEffect(item: number, slot: number): Gen<boolean> {
|
||||
const data = this.options.data,
|
||||
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL;
|
||||
// _DoItemEffect: GetItemName, then CopyName1 into wStringBuffer2.
|
||||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||||
if (this.battleMode !== C.WILD_BATTLE) {
|
||||
// UseBallInTrainerBattle: the blocked-ball animation, then
|
||||
// UseDisposableItem. The turn is used.
|
||||
yield* this.say(BATTLE.BallBlockedText);
|
||||
yield* this.say(BATTLE.BallDontBeAThiefText);
|
||||
this.tossBall(item, slot);
|
||||
return true;
|
||||
}
|
||||
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
|
||||
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
|
||||
// A full party sends the Pokémon into the current box (SendMonIntoBox).
|
||||
// The tutorial never keeps its Pokémon, so it does not reach the box.
|
||||
if (!tutorial && this.options.party.length >= PARTY_LENGTH)
|
||||
throw new UnsupportedBattle("PC boxes are not ported yet.");
|
||||
this.wildMon = 0;
|
||||
yield* this.say(BATTLE.ItemUsedText);
|
||||
|
||||
let caught = tutorial || item === C.MASTER_BALL;
|
||||
if (!caught) {
|
||||
const held = this.data.heldEffect(this.player.mon?.item ?? 0);
|
||||
const chance = catchRate({
|
||||
rate: this.enemyBase.catchRate,
|
||||
ball: item,
|
||||
hp: this.enemy.mon.hp,
|
||||
maxHp: this.enemy.mon.maxHp,
|
||||
status: this.enemy.mon.status,
|
||||
held,
|
||||
});
|
||||
if (chance === undefined) throw new UnsupportedBattle("The catch rate divides by zero.");
|
||||
this.finalCatchRate = chance;
|
||||
caught = this.random() <= chance;
|
||||
}
|
||||
|
||||
// BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until
|
||||
// the ball clicks or the Pokémon breaks free.
|
||||
let wobbles = 0;
|
||||
for (;;) {
|
||||
wobbles++;
|
||||
if (wobbles === 4) break;
|
||||
if (caught) continue;
|
||||
let entry = BATTLE.WobbleProbabilities;
|
||||
while (this.reader.byte(entry) < this.finalCatchRate) entry += 2;
|
||||
if (this.random() >= this.reader.byte(entry + 1)) break;
|
||||
}
|
||||
|
||||
if (!caught) {
|
||||
yield* this.say(
|
||||
[
|
||||
BATTLE.BallBrokeFreeText,
|
||||
BATTLE.BallAppearedCaughtText,
|
||||
BATTLE.BallAlmostHadItText,
|
||||
BATTLE.BallSoCloseText,
|
||||
][wobbles - 1],
|
||||
);
|
||||
if (!tutorial) this.tossBall(item, slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Caught: LoadEnemyMon runs again with SUBSTATUS_TRANSFORMED set, which
|
||||
// keeps the DVs, redraws the wild item (restored afterwards) and
|
||||
// recalculates stats. Status, HP and item are kept; moves and PP return
|
||||
// to wWildMonMoves and wWildMonPP.
|
||||
if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED))
|
||||
throw new UnsupportedBattle("Catching a transformed Pokémon is not ported yet.");
|
||||
if (this.random() >= percent(75) + 1) this.random();
|
||||
const enemy = this.enemy.mon,
|
||||
species = enemy.species,
|
||||
level = enemy.level;
|
||||
this.wildMon = species;
|
||||
if (tutorial) {
|
||||
// .FinishTutorial: the Dude's Pokémon is not kept, and his ball is
|
||||
// not tossed.
|
||||
yield* this.say(BATTLE.Text_GotchaMonWasCaught);
|
||||
return true;
|
||||
}
|
||||
yield* this.say(BATTLE.Text_GotchaMonWasCaught);
|
||||
const pokedex = this.options.pokedex,
|
||||
caughtBefore = pokedex.caught.has(species);
|
||||
pokedex.seen.add(species);
|
||||
pokedex.caught.add(species);
|
||||
if (!caughtBefore && this.options.hasPokedex) {
|
||||
// NewPokedexEntry's Pokédex page is not presented yet.
|
||||
yield* this.say(BATTLE.NewDexDataText);
|
||||
}
|
||||
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
|
||||
// the wild Pokémon, then SetCaughtData.
|
||||
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
|
||||
const name = data.speciesName(species);
|
||||
const mon: PartyMon = {
|
||||
species,
|
||||
item: enemy.item,
|
||||
moves: [...this.wildMoves] as PartyMon["moves"],
|
||||
otId: this.options.playerId,
|
||||
ot: this.options.playerName,
|
||||
nickname: name,
|
||||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||||
statExp: [0, 0, 0, 0, 0],
|
||||
dvs: enemy.dvs,
|
||||
pp: [...this.wildPP] as PartyMon["pp"],
|
||||
happiness: C.BASE_HAPPINESS,
|
||||
pokerus: 0,
|
||||
caughtData: this.options.caughtData(level),
|
||||
level,
|
||||
status: enemy.status,
|
||||
hp: enemy.hp,
|
||||
stats: calcStats(data, species, enemy.dvs, level),
|
||||
};
|
||||
this.options.party.push(mon);
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
yield* this.say(BATTLE.AskGiveNicknameText);
|
||||
if (yield { kind: "yesno", text: "" }) {
|
||||
// NamingScreen (NAME_MON): "<SPECIES>'S" / "NICKNAME?".
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (this.reader.byte(second++) !== 0x50);
|
||||
const context = this.context(),
|
||||
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
|
||||
title = readString(this.reader, second, context);
|
||||
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
|
||||
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
|
||||
throw new UnsupportedBattle("Invalid nickname.");
|
||||
// InitName: a name of zero or more spaces takes the species name.
|
||||
if (!/^ *$/.test(answer)) mon.nickname = answer;
|
||||
}
|
||||
this.tossBall(item, slot);
|
||||
return true;
|
||||
}
|
||||
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
|
||||
* selected slot if it matches, otherwise from the first matching slot. */
|
||||
private tossBall(item: number, slot: number) {
|
||||
const pocket = this.options.bag?.balls;
|
||||
if (!pocket) throw new UnsupportedBattle("Missing pack.");
|
||||
let index = pocket[slot]?.item === item ? slot : pocket.findIndex((entry) => entry.item === item);
|
||||
if (index < 0) throw new UnsupportedBattle("The ball is not in the pack.");
|
||||
if (!--pocket[index].quantity) pocket.splice(index, 1);
|
||||
}
|
||||
|
||||
/** MoveSelectionScreen's checks for a chosen move. */
|
||||
private *moveSelection(index: number): Gen<boolean> {
|
||||
const mon = this.player.mon;
|
||||
|
||||
@@ -122,6 +122,10 @@ export const C = {
|
||||
DITTO: 132,
|
||||
UNOWN: 201,
|
||||
NO_ITEM: 0,
|
||||
MASTER_BALL: 1,
|
||||
ULTRA_BALL: 2,
|
||||
GREAT_BALL: 4,
|
||||
POKE_BALL: 5,
|
||||
LUCKY_PUNCH: 30,
|
||||
METAL_POWDER: 35,
|
||||
STICK: 105,
|
||||
@@ -145,6 +149,7 @@ export const C = {
|
||||
HELD_PREVENT_SLEEP: 23,
|
||||
HELD_PREVENT_PARALYZE: 24,
|
||||
HELD_PREVENT_CONFUSE: 25,
|
||||
HELD_CATCH_CHANCE: 70,
|
||||
HELD_ESCAPE: 72,
|
||||
HELD_CRITICAL_UP: 73,
|
||||
HELD_QUICK_CLAW: 74,
|
||||
@@ -230,6 +235,7 @@ export const C = {
|
||||
BASE_PRIORITY: 1,
|
||||
|
||||
// Battle modes, types and results
|
||||
BATTLEPLAYERACTION_USEITEM: 1,
|
||||
WILD_BATTLE: 1,
|
||||
TRAINER_BATTLE: 2,
|
||||
HELD_AMULET_COIN: 76,
|
||||
|
||||
@@ -104,6 +104,21 @@ export function initialMoves(
|
||||
return moves as PartyMon["moves"];
|
||||
}
|
||||
|
||||
/** SetCaughtData (SetBoxmonOrEggmonCaughtData): time of day and level,
|
||||
* then the player's gender bit and the map's landmark. */
|
||||
export function caughtData(
|
||||
timeOfDay: number,
|
||||
level: number,
|
||||
gender: number,
|
||||
landmark: number,
|
||||
): number {
|
||||
return (
|
||||
(((((timeOfDay + 1) & 3) << 6) | level) & 0xff) << 8 |
|
||||
(gender << 7) |
|
||||
landmark
|
||||
);
|
||||
}
|
||||
|
||||
/** GeneratePartyMonStats for a Pokémon given by a script (not a wild or
|
||||
* trainer battle): random DVs, level-up moves, full PP and HP. */
|
||||
export function generatePartyMon(
|
||||
|
||||
@@ -170,12 +170,26 @@ export const EVENTS = {
|
||||
|
||||
/** Engine flag indices named directly by translated routines. */
|
||||
export const ENGINE_FLAGS = {
|
||||
pokedex: 11,
|
||||
allFruitTrees: 84,
|
||||
} as const;
|
||||
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
ItemUsedText: 0xf83d,
|
||||
BallBrokeFreeText: 0xedb5,
|
||||
BallAppearedCaughtText: 0xedba,
|
||||
BallAlmostHadItText: 0xedbf,
|
||||
BallSoCloseText: 0xedc4,
|
||||
Text_GotchaMonWasCaught: 0xedc9,
|
||||
WaitButtonText: 0xede6,
|
||||
NewDexDataText: 0xedf0,
|
||||
AskGiveNicknameText: 0xedf5,
|
||||
BallBlockedText: 0xf824,
|
||||
BallDontBeAThiefText: 0xf829,
|
||||
WobbleProbabilities: 0xf9ba,
|
||||
"CatchTutorial.Dude": 0x4e5da,
|
||||
LightScreenEffectText: 0x814b4,
|
||||
ReflectEffectText: 0x814c7,
|
||||
BattleText_MonsLightScreenFell: 0x808e7,
|
||||
|
||||
@@ -88,6 +88,10 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.world?.battleChoose(answer);
|
||||
this.publish();
|
||||
}
|
||||
battleName(name: string) {
|
||||
this.world?.battleName(name);
|
||||
this.publish();
|
||||
}
|
||||
private tick = (now: number) => {
|
||||
try {
|
||||
const world = this.world!;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
caughtData,
|
||||
generatePartyMon,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
@@ -162,6 +163,8 @@ export type ScriptEffect =
|
||||
wild?: { species: number; level: number };
|
||||
trainer?: { classId: number; id: number; winText?: number; lossText?: number };
|
||||
canLose?: boolean;
|
||||
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
|
||||
tutorial?: boolean;
|
||||
}
|
||||
/** newloadmap: the world enters this map after the script ends. */
|
||||
| { kind: "warp"; map: string; x: number; y: number };
|
||||
@@ -674,6 +677,17 @@ export class ScriptRun {
|
||||
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
|
||||
break;
|
||||
}
|
||||
case 0x61: {
|
||||
// catchtutorial: CatchTutorial runs StartBattle as the Dude, then
|
||||
// Script_reloadmap. The battle result is not read.
|
||||
if (byte() !== C.BATTLETYPE_TUTORIAL || !wild || trainer)
|
||||
throw new UnsupportedScript(offset, opcode, " (battle type)");
|
||||
yield* flush();
|
||||
yield synchronous({ kind: "battle", wild, tutorial: true });
|
||||
wild = undefined;
|
||||
canLose = false;
|
||||
break;
|
||||
}
|
||||
case 0x60:
|
||||
// Script_reloadmapafterbattle: a loss continues with the whiteout.
|
||||
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
|
||||
@@ -772,11 +786,6 @@ export class ScriptRun {
|
||||
const player = this.host.player();
|
||||
// GeneratePartyMonStats draws the Attack/Defense byte first.
|
||||
const dvs = (this.host.random() << 8) | this.host.random();
|
||||
// SetCaughtData: time of day and level, then gender and landmark.
|
||||
const caughtData =
|
||||
((((((this.host.timeOfDay() + 1) & 3) << 6) | level) & 0xff) << 8) |
|
||||
(player.gender << 7) |
|
||||
this.host.landmark();
|
||||
const mon = generatePartyMon(data, {
|
||||
species,
|
||||
level,
|
||||
@@ -784,7 +793,12 @@ export class ScriptRun {
|
||||
otId: player.id,
|
||||
ot: this.host.text().player,
|
||||
dvs,
|
||||
caughtData,
|
||||
caughtData: caughtData(
|
||||
this.host.timeOfDay(),
|
||||
level,
|
||||
player.gender,
|
||||
this.host.landmark(),
|
||||
),
|
||||
});
|
||||
this.state.party.push(mon);
|
||||
this.state.pokedex.seen.add(species);
|
||||
@@ -1263,7 +1277,8 @@ export class ScriptRun {
|
||||
return number;
|
||||
case 0x10:
|
||||
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
|
||||
// deposited yet (no capture or PC), so the current box is empty.
|
||||
// deposited yet (catching with a full party is refused and the PC
|
||||
// is not ported), so the current box is empty.
|
||||
return MONS_PER_BOX;
|
||||
case 0x12:
|
||||
return this.host.position().x;
|
||||
|
||||
@@ -219,7 +219,9 @@ export function readTextEnd(
|
||||
// Button waits inside one message become page boundaries in the DOM.
|
||||
output = output.trimEnd() + "\n\n";
|
||||
break;
|
||||
case 0x05:
|
||||
case 0x07:
|
||||
// TX_LOW writes on the box's lower line; TX_SCROLL starts a new one.
|
||||
output += "\n";
|
||||
break;
|
||||
case 0x08: {
|
||||
|
||||
+24
-2
@@ -1,6 +1,6 @@
|
||||
import { CrystalAssets } from "../rom/assets";
|
||||
import { TrainerData } from "../rom/trainers";
|
||||
import { INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile";
|
||||
import { ENGINE_FLAGS, INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile";
|
||||
import {
|
||||
PLAYER,
|
||||
ScriptRun,
|
||||
@@ -15,6 +15,7 @@ import type { MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag } from "../party/bag";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
caughtData,
|
||||
validPartyMon,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
@@ -164,7 +165,7 @@ export interface BattleView {
|
||||
/** A choice the battle is waiting for, if any. */
|
||||
prompt?: Extract<
|
||||
BattleEffect,
|
||||
{ kind: "action" | "yesno" | "forget" | "stats" }
|
||||
{ kind: "action" | "yesno" | "forget" | "stats" | "name" }
|
||||
>;
|
||||
/** Yes/No cursor, as on the original boxes. */
|
||||
selected: 0 | 1;
|
||||
@@ -1098,6 +1099,16 @@ export class GameWorld {
|
||||
trainer,
|
||||
canLose: effect.canLose,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bag: draft.bag,
|
||||
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
|
||||
caughtData: (level) =>
|
||||
caughtData(
|
||||
this.clockTimeOfDay,
|
||||
level,
|
||||
this.avatar === "kris" ? 1 : 0,
|
||||
this.assets.map(this.mapId).landmark,
|
||||
),
|
||||
tutorial: effect.tutorial,
|
||||
});
|
||||
this.battle = { engine, selected: 1 };
|
||||
this.battleRun = engine.run();
|
||||
@@ -1346,6 +1357,17 @@ export class GameWorld {
|
||||
battleAction(action: BattleAction) {
|
||||
if (this.battle?.prompt?.kind === "action") this.pumpBattle(action);
|
||||
}
|
||||
/** Name a caught Pokémon ("" keeps the species name). */
|
||||
battleName(name: string) {
|
||||
const prompt = this.battle?.prompt;
|
||||
if (
|
||||
prompt?.kind !== "name" ||
|
||||
name.length > prompt.max ||
|
||||
(name !== "" && !NICKNAME_PATTERN.test(name))
|
||||
)
|
||||
return;
|
||||
this.pumpBattle(name);
|
||||
}
|
||||
/** Answer a battle Yes/No (1/0) or the move to forget (0-3, 4 cancels). */
|
||||
battleChoose(answer: number) {
|
||||
const prompt = this.battle?.prompt;
|
||||
|
||||
Reference in new issue
Block a user