Open the Battle Tower and add the badges' type boosts (Closes #32)
The Route 40 gate, the Battle Tower and its rooms are open with the offline challenge: the rules, the save before entering, the level menu with its level and uber checks, seven trainers drawn from BattleTowerTrainers with Pokémon from BattleTowerMons, BattleTowerText, save and quit with the receptionist resuming it, cancelling, losing and the vitamin reward. After each battle the party and Pokédex return to the quick save's, as LoadPokemonData does. Battle Tower battles turn off obedience, badge boosts, experience, items, AI items and prize money, use Falkner's AI attributes, keep Sleep to one to three turns and never fail the AI's status moves at random. New script command battletowertext; new specials BattleTowerAction (offline actions), BattleTowerRoomMenu, CheckForBattleTowerRules, BattleTowerBattle, LoadOpponentTrainerAndPokemonWithOTSprite, Menu_ChallengeExplanationCancel, TryQuickSave and Reset. DoBadgeTypeBoosts was missing from every battle; it now adds an eighth of the damage for the first badge matching the move's type on the player's turn, checked against the original routine. This completes Johto from Azalea to Blackthorn: Kanto, the League and the S.S. Aqua continue on #33; radio, rematches and Buena's password on #30. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3fbc6798eb
commit
fe2b0262c5
13 files changed
+1267
-14
No files matched your search
@@ -115,7 +115,9 @@ export type BattleEffect =
|
||||
| { kind: "hud" }
|
||||
/** The battle menu: answered with an action. In the Bug-Catching
|
||||
* Contest, PACK is PARKBALL× with this wParkBallsRemaining. */
|
||||
| { kind: "action"; parkBalls?: number }
|
||||
/** BattleMenu. `noItems`: PACK only says items can't be used (link and
|
||||
* Battle Tower battles), without opening the pack. */
|
||||
| { kind: "action"; parkBalls?: number; noItems?: boolean }
|
||||
/** DisplayCaughtContestMonStats: the stocked contest Pokémon beside the
|
||||
* one just caught, with ContestAskSwitchText; answered Yes/No (1/0). */
|
||||
| { kind: "contestswitch"; text: string; stock: ContestMonSummary; caught: ContestMonSummary }
|
||||
@@ -219,8 +221,14 @@ export interface BattleOptions {
|
||||
pokedex: { seen: Set<number>; caught: Set<number> };
|
||||
playerId: number;
|
||||
playerName: string;
|
||||
/** wJohtoBadges, for traded Pokémon obedience. */
|
||||
/** wJohtoBadges, for traded Pokémon obedience and the badges' boosts. */
|
||||
johtoBadges?: number;
|
||||
/** wKantoBadges, for DoBadgeTypeBoosts. */
|
||||
kantoBadges?: number;
|
||||
/** wInBattleTowerBattle: a Battle Tower battle (no obedience, badge
|
||||
* boosts, experience, items, prize money or whiteout; the AI's status
|
||||
* moves never fail at random; Sleep lasts one to three turns). */
|
||||
battleTower?: boolean;
|
||||
/** wTimeOfDay: morning 0, day 1, night 2. */
|
||||
timeOfDay: number;
|
||||
random: () => number;
|
||||
@@ -964,6 +972,7 @@ export class Battle {
|
||||
* Special Defense boost tests the register that BoostStat leaves after
|
||||
* Special Attack, so it depends on that stat (a kept bug). */
|
||||
private badgeStatBoosts() {
|
||||
if (this.options.battleTower) return;
|
||||
const badges = this.options.johtoBadges ?? 0;
|
||||
// ZEPHYR, HIVE, FOG, STORM, GLACIER and RISING stay; PLAIN (bit 2)
|
||||
// moves to bit 4 and MINERAL (bit 4) to bit 2.
|
||||
@@ -1128,6 +1137,15 @@ export class Battle {
|
||||
yield this.hud();
|
||||
this.turn = 0; // SetPlayerTurn before SpikesDamage
|
||||
yield* this.battleTurn();
|
||||
if (trainer && this.options.battleTower) {
|
||||
// WinTrainerBattle and LostBattle's .battle_tower: BattleTowerText
|
||||
// instead of the prize money or the whiteout.
|
||||
const won = (this.battleResult & 0x3f) === C.WIN;
|
||||
if (won) yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
const text = won ? trainer.winText : trainer.lossText;
|
||||
if (text) yield* this.say(text);
|
||||
return { result: this.battleResult, evolvable: this.evolvable };
|
||||
}
|
||||
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
||||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
if (this.options.canLose) {
|
||||
@@ -1318,8 +1336,17 @@ export class Battle {
|
||||
private *battleMenu(): Gen<boolean> {
|
||||
for (;;) {
|
||||
const contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||||
const input = yield contest ? { kind: "action", parkBalls: contest.parkBalls } : { kind: "action" };
|
||||
const input = yield contest
|
||||
? { kind: "action", parkBalls: contest.parkBalls }
|
||||
: this.options.battleTower
|
||||
? { kind: "action", noItems: true }
|
||||
: { kind: "action" };
|
||||
if (!input || typeof input !== "object") continue;
|
||||
if (input.action === "item" && this.options.battleTower) {
|
||||
// BattleMenu_Pack.ItemsCantBeUsed.
|
||||
yield* this.say(BATTLE.BattleText_ItemsCantBeUsedHere);
|
||||
continue;
|
||||
}
|
||||
if (input.action === "item") {
|
||||
// BattleMenu_Pack's .contest: PARKBALL× throws a Park Ball.
|
||||
const used = contest
|
||||
@@ -2903,6 +2930,7 @@ export class Battle {
|
||||
/** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */
|
||||
private *checkObedience(): Gen {
|
||||
if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return;
|
||||
if (this.options.battleTower) return;
|
||||
if (this.partyMon.otId === this.options.playerId) return;
|
||||
const user = this.user, badges = this.options.johtoBadges ?? 0;
|
||||
const limit = badges & 0x80 ? 101 : badges & 0x20 ? 70 : badges & 8 ? 50 : badges & 2 ? 30 : 10;
|
||||
@@ -3157,7 +3185,10 @@ export class Battle {
|
||||
target = this.target;
|
||||
if (user.move.anim === C.STRUGGLE) return;
|
||||
const result = applyTypeMatchups(this.matchups, {
|
||||
damage: this.weatherModifier(this.curDamage, user.move.type, user.move.effect),
|
||||
damage: this.badgeTypeBoost(
|
||||
this.weatherModifier(this.curDamage, user.move.type, user.move.effect),
|
||||
user.move.type,
|
||||
),
|
||||
modifier: this.typeModifier,
|
||||
moveType: user.move.type,
|
||||
userTypes: user.mon.types,
|
||||
@@ -3183,6 +3214,24 @@ export class Battle {
|
||||
const value = Math.floor((damage * multiplier) / 10);
|
||||
return value > 0xffff ? 0xffff : value || 1;
|
||||
}
|
||||
/** DoBadgeTypeBoosts, after the weather: on the player's turn, the first
|
||||
* badge (Johto, then Kanto) whose BadgeTypeBoosts type is the move's adds
|
||||
* an eighth of the damage (at least 1, to at most $ffff). */
|
||||
private badgeTypeBoost(damage: number, type: number) {
|
||||
if (this.options.battleTower || this.turn !== 0) return damage;
|
||||
const r = this.reader;
|
||||
let johto = this.options.johtoBadges ?? 0,
|
||||
kanto = this.options.kantoBadges ?? 0;
|
||||
for (let p = BATTLE.BadgeTypeBoosts; r.byte(p) !== 0xff; p++) {
|
||||
// srl b / rr c: the Johto byte's low bit, then Kanto's shifted in.
|
||||
const carry = johto & 1;
|
||||
johto = (johto >> 1) | ((kanto & 1) << 7);
|
||||
kanto >>= 1;
|
||||
if (carry && r.byte(p) === type)
|
||||
return Math.min(0xffff, damage + ((damage >> 3) || 1));
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
/** BattleCheckTypeMatchup: wTypeMatchup for the move against the target. */
|
||||
private battleCheckTypeMatchup() {
|
||||
const target = this.target;
|
||||
@@ -4817,7 +4866,8 @@ export class Battle {
|
||||
}
|
||||
/** The AI's 25% failure for status moves (not for the player). */
|
||||
private aiRandomFail() {
|
||||
if (this.turn === 0 || this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false;
|
||||
if (this.turn === 0 || this.options.battleTower) return false;
|
||||
if (this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false;
|
||||
return this.random() < percent(25) + 1;
|
||||
}
|
||||
private *didntAffect2(): Gen {
|
||||
@@ -4834,8 +4884,10 @@ export class Battle {
|
||||
if (this.attackMissed) return yield* this.didntAffect2();
|
||||
if (this.aiRandomFail() || target.mon.status || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
|
||||
return yield* this.say(BATTLE.DidntAffect1Text);
|
||||
// The Battle Tower keeps the sleep counter to %011.
|
||||
const mask = this.options.battleTower ? 0b011 : C.SLP_MASK;
|
||||
let turns: number;
|
||||
do turns = this.random() & C.SLP_MASK;
|
||||
do turns = this.random() & mask;
|
||||
while (!turns || turns === C.SLP_MASK);
|
||||
target.mon.status = turns + 1;
|
||||
this.updateOpponentInParty();
|
||||
@@ -6264,6 +6316,8 @@ export class Battle {
|
||||
}
|
||||
}
|
||||
private *giveExperience(shared = false): Gen {
|
||||
// GiveExperiencePoints: none in the Battle Tower.
|
||||
if (this.options.battleTower) return;
|
||||
const party = this.options.party,
|
||||
data = this.options.data;
|
||||
const count = [0, 1, 2, 3, 4, 5].filter((index) => this.participants & (1 << index)).length;
|
||||
|
||||
@@ -770,6 +770,8 @@ export const BATTLE = {
|
||||
BattleText_MonsReflectFaded: 0x80905,
|
||||
WantsToBattleText: 0x807a9,
|
||||
BattleText_EnemyWasDefeated: 0x809da,
|
||||
BattleText_ItemsCantBeUsedHere: 0x80bf3,
|
||||
BadgeTypeBoosts: 0xfbe80,
|
||||
GotMoneyForWinningText: 0x809be,
|
||||
SentSomeToMomText: 0x809fc,
|
||||
SentHalfToMomText: 0x80a2a,
|
||||
@@ -1070,6 +1072,38 @@ export const JOHTO = {
|
||||
PlayCurMonCry: 0xc472,
|
||||
GiveDratini: 0x8b170,
|
||||
BeastsCheck: 0x4a6e8,
|
||||
BattleTowerRoomMenu: 0x1700b0,
|
||||
CheckForBattleTowerRules: 0x170bd3,
|
||||
BattleTowerBattle: 0x170215,
|
||||
LoadOpponentTrainerAndPokemonWithOTSprite: 0x170b44,
|
||||
BattleTowerFade: 0x8c092,
|
||||
LoadMapPalettes: 0x1047eb,
|
||||
Menu_ChallengeExplanationCancel: 0x17d224,
|
||||
TryQuickSave: 0x29e66,
|
||||
Reset: 0x150,
|
||||
BattleTowerMons: 0x1f8450,
|
||||
BattleTowerTrainers: 0x1f814e,
|
||||
BTTrainerClassSprites: 0x170b90,
|
||||
BTTrainerClassGenders: 0x11f2f0,
|
||||
BTMaleTrainerTexts: 0x11f332,
|
||||
BTFemaleTrainerTexts: 0x11f3ce,
|
||||
Text_WhatLevelDoYouWantToChallenge: 0x11aba5,
|
||||
Text_PartyMonTopsThisLevel: 0x11aaf0,
|
||||
Text_UberRestriction: 0x11ab0f,
|
||||
Text_CancelBattleRoomChallenge: 0x11ab4a,
|
||||
Strings_L10ToL100: 0x119d0c,
|
||||
Strings_Ll0ToL40: 0x119d64,
|
||||
ExcuseMeYoureNotReadyText: 0x8b22c,
|
||||
OnlyThreeMonMayBeEnteredText: 0x8b247,
|
||||
TheMonMustAllBeDifferentKindsText: 0x8b24c,
|
||||
TheMonMustNotHoldTheSameItemsText: 0x8b251,
|
||||
YouCantTakeAnEggText: 0x8b256,
|
||||
"BattleTower_PleaseReturnWhenReady.BattleTowerReturnWhenReadyText": 0x8b238,
|
||||
MenuData_ChallengeExplanationCancel: 0x17d297,
|
||||
"BattleTowerHallwayChooseBattleRoomScript.asm_load_battle_room": 0x9f5cb,
|
||||
AlreadyASaveFileText: 0x15292,
|
||||
SavingDontTurnOffThePowerText: 0x15288,
|
||||
SavedTheGameText: 0x1528d,
|
||||
MonCheck: 0x4a711,
|
||||
"GiveDratini.Movesets": 0x8b1c4,
|
||||
MoveDeletion: 0x2c547,
|
||||
@@ -1299,6 +1333,8 @@ export const WRAM = {
|
||||
wBugContestSecondPlaceScore: 0xd008,
|
||||
wBugContestThirdPlaceScore: 0xd00c,
|
||||
wMagikarpRecordHoldersName: 0xdfea,
|
||||
wcd49: 0xcd49,
|
||||
wNrOfBeatenBattleTowerTrainers: 0xcf64,
|
||||
} as const;
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
@@ -1394,6 +1430,12 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"3:46": "Olivine Lighthouse 5F",
|
||||
"3:47": "Olivine Lighthouse 6F",
|
||||
"22:1": "Route 40",
|
||||
"22:11": "Battle Tower 1F",
|
||||
"22:12": "Battle Room",
|
||||
"22:13": "Battle Tower elevator",
|
||||
"22:14": "Battle Tower hallway",
|
||||
"22:15": "Route 40 Battle Tower gate",
|
||||
"22:16": "Battle Tower",
|
||||
"22:2": "Route 41",
|
||||
"22:3": "Cianwood City",
|
||||
"22:4": "Manias House",
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { JOHTO, RAM, WRAM } from "../rom/profile";
|
||||
import { maxPP, validPartyMon, type PartyMon } from "../party/pokemon";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
import { readString } from "./text";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
|
||||
/** BATTLETOWER_* (constants/battle_tower_constants.asm). */
|
||||
export const BATTLE_TOWER = {
|
||||
partyLength: 3,
|
||||
streakLength: 7,
|
||||
uniqueMons: 21,
|
||||
uniqueTrainers: 70,
|
||||
rewardQuantity: 5,
|
||||
} as const;
|
||||
/** sBattleTowerChallengeState values. */
|
||||
export const CHALLENGE = {
|
||||
none: 0,
|
||||
savedAndLeft: 1,
|
||||
inProgress: 2,
|
||||
won: 3,
|
||||
receivedReward: 4,
|
||||
} as const;
|
||||
/** BATTLETOWERACTION_* (wScriptVar for the BattleTowerAction special). */
|
||||
export const BATTLE_TOWER_ACTION = {
|
||||
checkExplanationRead: 0x00,
|
||||
setExplanationRead: 0x01,
|
||||
getChallengeState: 0x02,
|
||||
saveAndQuit: 0x03,
|
||||
challengeCanceled: 0x04,
|
||||
action05: 0x05,
|
||||
action06: 0x06,
|
||||
saveLevelGroup: 0x07,
|
||||
loadLevelGroup: 0x08,
|
||||
checkSaveFileIsYours: 0x09,
|
||||
action0A: 0x0a,
|
||||
gsBall: 0x0b,
|
||||
action11: 0x11,
|
||||
action12: 0x12,
|
||||
action13: 0x13,
|
||||
resetData: 0x1a,
|
||||
giveReward: 0x1b,
|
||||
action1C: 0x1c,
|
||||
action1D: 0x1d,
|
||||
chooseReward: 0x1e,
|
||||
saveOptions: 0x1f,
|
||||
} as const;
|
||||
|
||||
/** party_struct plus a nickname (NICKNAMED_MON_STRUCT_LENGTH). */
|
||||
const MON_STRUCT = 48,
|
||||
NICKNAMED_MON = MON_STRUCT + 11;
|
||||
/** BattleTowerTrainers' entries: a 10-character name and the class. */
|
||||
const TRAINER_ENTRY = 11;
|
||||
const HP_UP = 0x1a,
|
||||
CALCIUM = 0x1f,
|
||||
LUCKY_PUNCH = 0x1e,
|
||||
POTION = 0x12;
|
||||
const MEW = 151,
|
||||
MEWTWO = 150,
|
||||
LUGIA = 249,
|
||||
NUM_POKEMON = 251;
|
||||
/** MAX_ITEMS and MAX_ITEM_STACK (constants/item_data_constants.asm). */
|
||||
const MAX_ITEMS = 20,
|
||||
MAX_ITEM_STACK = 99;
|
||||
|
||||
/** The opponent LoadOpponentTrainerAndPokemon wrote to wBT_OTTrainer. */
|
||||
export interface BattleTowerOpponent {
|
||||
/** BattleTowerTrainers' index, as sBTTrainers records it. */
|
||||
trainer: number;
|
||||
name: string;
|
||||
trainerClass: number;
|
||||
party: PartyMon[];
|
||||
}
|
||||
|
||||
/** What LoadPokemonData reads back from the save made before the Battle
|
||||
* Room (TryQuickSave), and where that save put the player. */
|
||||
export interface BattleTowerQuickSave {
|
||||
map: string;
|
||||
x: number;
|
||||
y: number;
|
||||
direction: Direction;
|
||||
party: PartyMon[];
|
||||
seen: number[];
|
||||
caught: number[];
|
||||
unownDex: number[];
|
||||
firstUnownSeen: number;
|
||||
}
|
||||
|
||||
/** The Battle Tower's SRAM (bank 5) and the work RAM its scripts keep from
|
||||
* one special to the next. */
|
||||
export interface BattleTowerState {
|
||||
/** sBattleTowerChallengeState. */
|
||||
challenge: number;
|
||||
/** sNrOfBeatenBattleTowerTrainers: raised as each battle starts. */
|
||||
beaten: number;
|
||||
/** sBTTrainers: the BattleTowerTrainers of this streak, $ff unused. */
|
||||
trainers: number[];
|
||||
/** sBTMonPrevTrainer1-3 and sBTMonPrevPrevTrainer1-3: species. */
|
||||
prevMons: number[];
|
||||
prevPrevMons: number[];
|
||||
/** sBTChoiceOfLevelGroup. */
|
||||
savedLevelGroup: number;
|
||||
/** sBattleTowerReward. */
|
||||
reward: number;
|
||||
/** sBattleTowerSaveFileFlags: bit 1 once the explanation was offered. */
|
||||
fileFlags: number;
|
||||
/** s5_aa8d, which BATTLETOWERACTION_11 and _12 set. */
|
||||
aa8d: number;
|
||||
/** wBTChoiceOfLvlGroup (1-10). */
|
||||
levelGroup: number;
|
||||
/** wNrOfBeatenBattleTowerTrainers. */
|
||||
beatenNow: number;
|
||||
/** wBT_TrainerTextIndex: the greeting's index, reused for the end texts. */
|
||||
textIndex: number;
|
||||
opponent?: BattleTowerOpponent;
|
||||
quickSave?: BattleTowerQuickSave;
|
||||
}
|
||||
|
||||
export const emptyBattleTowerState = (): BattleTowerState => ({
|
||||
challenge: CHALLENGE.none,
|
||||
beaten: 0,
|
||||
trainers: Array.from({ length: BATTLE_TOWER.streakLength }, () => 0xff),
|
||||
prevMons: [0, 0, 0],
|
||||
prevPrevMons: [0, 0, 0],
|
||||
savedLevelGroup: 0,
|
||||
reward: 0,
|
||||
fileFlags: 0,
|
||||
aa8d: 0,
|
||||
levelGroup: 0,
|
||||
beatenNow: 0,
|
||||
textIndex: 0,
|
||||
});
|
||||
export const copyBattleTowerState = (state: BattleTowerState): BattleTowerState =>
|
||||
structuredClone(state);
|
||||
|
||||
/** HealPartyMon for each party member but Eggs. */
|
||||
export function healParty(c: SpecialContext) {
|
||||
for (const mon of c.state.party) {
|
||||
if (mon.egg) continue;
|
||||
mon.status = 0;
|
||||
mon.hp = mon.stats[0];
|
||||
mon.pp = mon.moves.map((move, slot) =>
|
||||
move ? maxPP(c.data, move, mon.pp[slot] >> 6) : 0,
|
||||
) as PartyMon["pp"];
|
||||
}
|
||||
}
|
||||
|
||||
/** A BattleTowerMons entry: a party_struct (big-endian words) and the
|
||||
* nickname, which LoadRandomBattleTowerMon replaces with the species
|
||||
* name. */
|
||||
export function battleTowerMon(c: SpecialContext, address: number): PartyMon {
|
||||
const r = c.reader,
|
||||
byte = (offset: number) => r.byte(address + offset),
|
||||
word = (offset: number) => (byte(offset) << 8) | byte(offset + 1);
|
||||
const species = byte(0);
|
||||
return {
|
||||
species,
|
||||
item: byte(1),
|
||||
moves: [byte(2), byte(3), byte(4), byte(5)],
|
||||
otId: word(6),
|
||||
ot: "",
|
||||
nickname: c.data.speciesName(species),
|
||||
exp: (byte(8) << 16) | word(9),
|
||||
statExp: [word(11), word(13), word(15), word(17), word(19)],
|
||||
dvs: word(21),
|
||||
pp: [byte(23), byte(24), byte(25), byte(26)],
|
||||
happiness: byte(27),
|
||||
pokerus: byte(28),
|
||||
caughtData: word(29),
|
||||
level: byte(31),
|
||||
status: byte(32),
|
||||
hp: word(34),
|
||||
stats: [word(36), word(38), word(40), word(42), word(44), word(46)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Random's hRandomAdd folded into a running byte, as both of
|
||||
* LoadOpponentTrainerAndPokemon's samplers do: `count` values below a
|
||||
* power-of-two mask, drawn again while too large or `reject`ed. After a
|
||||
* rejection the trainer sampler keeps the index as its running byte; the
|
||||
* Pokémon sampler starts again from hRandomAdd (`restart`). */
|
||||
function sample(
|
||||
c: SpecialContext,
|
||||
count: number,
|
||||
reject: (index: number) => boolean,
|
||||
restart = false,
|
||||
) {
|
||||
const mask = (1 << Math.ceil(Math.log2(count))) - 1;
|
||||
let b = c.random();
|
||||
for (let tries = 0; tries < 100_000; tries++) {
|
||||
b = (b + c.random()) & 0xff;
|
||||
const index = b & mask;
|
||||
if (index >= count) continue;
|
||||
if (reject(index)) {
|
||||
b = restart ? c.random() : index;
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
throw new Error("Battle Tower sampling did not end.");
|
||||
}
|
||||
|
||||
/** LoadOpponentTrainerAndPokemon (engine/events/battle_tower/load_trainer.asm):
|
||||
* a trainer this streak has not met, recorded in sBTTrainers, then three
|
||||
* Pokémon of the level group, none sharing a species or item with the
|
||||
* others and none of the last two trainers' species. */
|
||||
export function loadOpponent(c: SpecialContext, bt: BattleTowerState): BattleTowerOpponent {
|
||||
const r = c.reader;
|
||||
const trainer = sample(c, BATTLE_TOWER.uniqueTrainers, (index) => bt.trainers.includes(index));
|
||||
bt.trainers[bt.beaten] = trainer;
|
||||
const entry = JOHTO.BattleTowerTrainers + trainer * TRAINER_ENTRY;
|
||||
const name = readString(r, entry, c.text(), TRAINER_ENTRY - 1);
|
||||
const trainerClass = r.byte(entry + TRAINER_ENTRY - 1);
|
||||
// LoadRandomBattleTowerMon: wBT_OTMon1-3 start as species 0 and item $ff.
|
||||
const species = [0, 0, 0],
|
||||
items = [0xff, 0xff, 0xff];
|
||||
const party: PartyMon[] = [];
|
||||
const group =
|
||||
JOHTO.BattleTowerMons +
|
||||
((bt.levelGroup - 1) & 0xff) * BATTLE_TOWER.uniqueMons * NICKNAMED_MON;
|
||||
for (let slot = 0; slot < BATTLE_TOWER.partyLength; slot++) {
|
||||
const index = sample(c, BATTLE_TOWER.uniqueMons, (i) => {
|
||||
const at = group + i * NICKNAMED_MON,
|
||||
s = r.byte(at),
|
||||
item = r.byte(at + 1);
|
||||
return (
|
||||
species.includes(s) ||
|
||||
items.includes(item) ||
|
||||
bt.prevMons.includes(s) ||
|
||||
bt.prevPrevMons.includes(s)
|
||||
);
|
||||
}, true);
|
||||
const mon = battleTowerMon(c, group + index * NICKNAMED_MON);
|
||||
species[slot] = mon.species;
|
||||
items[slot] = mon.item;
|
||||
party.push(mon);
|
||||
}
|
||||
bt.prevPrevMons = [...bt.prevMons];
|
||||
bt.prevMons = [...species];
|
||||
return { trainer, name, trainerClass, party };
|
||||
}
|
||||
|
||||
/** BTTrainerClassSprites: the overworld sprite of a trainer class. */
|
||||
export const battleTowerSprite = (c: SpecialContext, trainerClass: number) =>
|
||||
c.reader.byte(JOHTO.BTTrainerClassSprites + trainerClass - 1);
|
||||
|
||||
/** BattleTowerText: which = 1 picks a greeting at random (hRandomAdd, 0-24
|
||||
* for a male class and 0-14 for a female one) and keeps the index; 2 (the
|
||||
* player lost) and 3 (the player won) reuse it. Returns the text. */
|
||||
export function battleTowerText(c: SpecialContext, bt: BattleTowerState, which: 1 | 2 | 3) {
|
||||
const r = c.reader;
|
||||
const trainerClass = bt.opponent?.trainerClass ?? 0;
|
||||
const female = r.byte(JOHTO.BTTrainerClassGenders + ((trainerClass - 1) & 0xff)) !== 0;
|
||||
const table = female ? JOHTO.BTFemaleTrainerTexts : JOHTO.BTMaleTrainerTexts;
|
||||
if (which === 1) {
|
||||
const roll = c.random();
|
||||
bt.textIndex = female
|
||||
? (roll & 0x0f) < 15 ? roll & 0x0f : (roll & 0x0f) - 15
|
||||
: (roll & 0x1f) < 25 ? roll & 0x1f : (roll & 0x1f) - 25;
|
||||
}
|
||||
const bank = r.bank(table);
|
||||
const list = r.near(table + (which - 1) * 2, bank);
|
||||
return r.near(list + bt.textIndex * 2, bank);
|
||||
}
|
||||
|
||||
/** _CheckForBattleTowerRules: three Pokémon, all of different species and
|
||||
* holding different items (Eggs and empty item slots aside), and no Egg.
|
||||
* Every broken rule prints its text after a first "Excuse me"; returns 1
|
||||
* when the party may not enter. */
|
||||
export function* checkBattleTowerRules(c: SpecialContext): Gen<number> {
|
||||
const party = c.state.party;
|
||||
c.strings[RAM.stringBuffer2] = "3";
|
||||
/** CheckPartyValueIsUnique: each non-Egg value against the non-Egg
|
||||
* values after it; a zero value is never compared. */
|
||||
const unique = (value: (mon: PartyMon) => number) =>
|
||||
!party.some(
|
||||
(mon, i) =>
|
||||
!mon.egg &&
|
||||
value(mon) !== 0 &&
|
||||
party.slice(i + 1).some((other) => !other.egg && value(other) === value(mon)),
|
||||
);
|
||||
const rules: [boolean, number][] = [
|
||||
[party.length !== BATTLE_TOWER.partyLength, JOHTO.OnlyThreeMonMayBeEnteredText],
|
||||
[!unique((mon) => mon.species), JOHTO.TheMonMustAllBeDifferentKindsText],
|
||||
[!unique((mon) => mon.item), JOHTO.TheMonMustNotHoldTheSameItemsText],
|
||||
[party.some((mon) => mon.egg), JOHTO.YouCantTakeAnEggText],
|
||||
];
|
||||
const failures = rules.filter(([broken]) => broken).map(([, text]) => text);
|
||||
if (!failures.length) return 0;
|
||||
c.write(JOHTO.ExcuseMeYoureNotReadyText);
|
||||
for (const text of failures) c.write(text);
|
||||
c.write(JOHTO["BattleTower_PleaseReturnWhenReady.BattleTowerReturnWhenReadyText"]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Menu_ChallengeExplanationCancel (English): the three-item menu; the
|
||||
* item's number, or 4 for B. */
|
||||
export function* challengeMenu(c: SpecialContext): Gen<number> {
|
||||
const r = c.reader,
|
||||
data = JOHTO.MenuData_ChallengeExplanationCancel;
|
||||
const items: string[] = [];
|
||||
let p = data + 2;
|
||||
for (let i = 0; i < r.byte(data + 1); i++) {
|
||||
items.push(readString(r, p, c.text()));
|
||||
while (r.byte(p++) !== 0x50);
|
||||
}
|
||||
const choice = yield { kind: "menu", pages: c.take(), items, selected: 0 };
|
||||
return typeof choice === "number" && choice >= 0 && choice < items.length
|
||||
? choice + 1
|
||||
: 4;
|
||||
}
|
||||
|
||||
/** BattleTowerRoomMenu without a mobile adapter (_BattleTowerRoomMenu's
|
||||
* states 0-12): the level picker, L:10 to L:40, or to L:100 after the
|
||||
* Hall of Fame. A party Pokémon above the level, or Mewtwo, Mew, Lugia,
|
||||
* Ho-Oh or Celebi below level 70 in a room under Lv.70, stops the choice
|
||||
* with a message and the menu starts again. CANCEL or B asks whether to
|
||||
* cancel. Returns 0 with wBTChoiceOfLvlGroup set, or $a when cancelled. */
|
||||
export function* battleTowerRoomMenu(c: SpecialContext, bt: BattleTowerState): Gen<number> {
|
||||
const r = c.reader;
|
||||
// InitBattleTowerChallengeRAM.
|
||||
bt.beatenNow = 0;
|
||||
const levels = c.state.hallOfFame ? 10 : 4;
|
||||
const strings = c.state.hallOfFame ? JOHTO.Strings_L10ToL100 : JOHTO.Strings_Ll0ToL40;
|
||||
const items = Array.from({ length: levels + 1 }, (_, i) =>
|
||||
readString(r, strings + i * 8, c.text()).trim(),
|
||||
);
|
||||
for (let round = 0; round < 1000; round++) {
|
||||
c.write(JOHTO.Text_WhatLevelDoYouWantToChallenge);
|
||||
const choice = yield { kind: "menu", pages: c.take(), items, selected: 0 };
|
||||
const group = typeof choice === "number" && choice >= 0 && choice < levels ? choice + 1 : 0;
|
||||
if (group) {
|
||||
// BattleTower_LevelCheck: every party slot, Eggs included.
|
||||
if (c.state.party.some((mon) => mon.level > group * 10)) {
|
||||
c.write(JOHTO.Text_PartyMonTopsThisLevel);
|
||||
// BattleTowerRoomMenu_DelayRestartMenu: $80 frames.
|
||||
yield { kind: "text", pages: c.take(), holdTicks: 64 };
|
||||
continue;
|
||||
}
|
||||
// BattleTower_UbersCheck reads wPartySpecies, where an Egg is EGG.
|
||||
const uber =
|
||||
group < 7 &&
|
||||
c.state.party.find(
|
||||
(mon) =>
|
||||
!mon.egg &&
|
||||
(mon.species === MEWTWO ||
|
||||
mon.species === MEW ||
|
||||
(mon.species >= LUGIA && mon.species <= NUM_POKEMON)) &&
|
||||
mon.level < 70,
|
||||
);
|
||||
if (uber) {
|
||||
c.strings[WRAM.wcd49] = c.data.speciesName(uber.species);
|
||||
c.write(JOHTO.Text_UberRestriction);
|
||||
yield { kind: "text", pages: c.take(), holdTicks: 64 };
|
||||
continue;
|
||||
}
|
||||
bt.levelGroup = group;
|
||||
return 0;
|
||||
}
|
||||
// BattleTowerRoomMenu_QuitMessage, then its YES/NO box.
|
||||
c.write(JOHTO.Text_CancelBattleRoomChallenge);
|
||||
if (yield { kind: "yesno", pages: c.take() }) return 0x0a;
|
||||
}
|
||||
throw new Error("The Battle Tower menu did not end.");
|
||||
}
|
||||
|
||||
/** TryQuickSave (Link_SaveGame): the overwrite question for the player's
|
||||
* own save, then the saving texts. The native game saves itself; the
|
||||
* quick save keeps what LoadPokemonData and Reset return to. */
|
||||
export function* tryQuickSave(
|
||||
c: SpecialContext,
|
||||
bt: BattleTowerState,
|
||||
position: { map: string; x: number; y: number; direction: Direction },
|
||||
): Gen<number> {
|
||||
c.write(JOHTO.AlreadyASaveFileText);
|
||||
if (!(yield { kind: "yesno", pages: c.take() })) return 0;
|
||||
c.write(JOHTO.SavingDontTurnOffThePowerText);
|
||||
c.write(JOHTO.SavedTheGameText);
|
||||
yield* c.flush();
|
||||
bt.quickSave = {
|
||||
...position,
|
||||
party: structuredClone(c.state.party),
|
||||
seen: [...c.state.pokedex.seen],
|
||||
caught: [...c.state.pokedex.caught],
|
||||
unownDex: [...c.state.unownDex],
|
||||
firstUnownSeen: c.state.firstUnownSeen,
|
||||
};
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** LoadPokemonData after each Battle Tower battle: the party and the
|
||||
* Pokédex return to the quick save's. */
|
||||
export function loadPokemonData(c: SpecialContext, bt: BattleTowerState) {
|
||||
const save = bt.quickSave;
|
||||
if (!save) return;
|
||||
c.state.party = structuredClone(save.party);
|
||||
c.state.pokedex.seen = new Set(save.seen);
|
||||
c.state.pokedex.caught = new Set(save.caught);
|
||||
c.state.unownDex = [...save.unownDex];
|
||||
c.state.firstUnownSeen = save.firstUnownSeen;
|
||||
}
|
||||
|
||||
/** BattleTower_RandomlyChooseReward: HP Up to Calcium, not Lucky Punch. */
|
||||
function chooseReward(c: SpecialContext) {
|
||||
for (let tries = 0; tries < 100_000; tries++) {
|
||||
const span = CALCIUM - HP_UP + 1;
|
||||
let item = c.random() & 7;
|
||||
if (item >= span) item -= span;
|
||||
item += HP_UP;
|
||||
if (item !== LUCKY_PUNCH) return item;
|
||||
}
|
||||
throw new Error("The reward draw did not end.");
|
||||
}
|
||||
|
||||
/** BattleTowerAction for the actions the Battle Tower's maps use offline.
|
||||
* Returns wScriptVar, or undefined for an action that is not ported. */
|
||||
export function battleTowerAction(
|
||||
c: SpecialContext,
|
||||
bt: BattleTowerState,
|
||||
action: number,
|
||||
): number | undefined {
|
||||
const A = BATTLE_TOWER_ACTION;
|
||||
switch (action) {
|
||||
case A.checkExplanationRead:
|
||||
// BattleTower_CheckSaveFileExistsAndIsYours: the native save is.
|
||||
return bt.fileFlags & 2;
|
||||
case A.setExplanationRead:
|
||||
bt.fileFlags |= 2;
|
||||
return action;
|
||||
case A.getChallengeState:
|
||||
return bt.challenge;
|
||||
case A.saveAndQuit:
|
||||
bt.challenge = CHALLENGE.savedAndLeft;
|
||||
return action;
|
||||
case A.challengeCanceled:
|
||||
bt.challenge = CHALLENGE.none;
|
||||
return action;
|
||||
case A.action06:
|
||||
// Clears s5_be46/s5_aa8b/s5_aa8c, which only the mobile record sets.
|
||||
return action;
|
||||
case A.saveLevelGroup:
|
||||
bt.savedLevelGroup = bt.levelGroup;
|
||||
return action;
|
||||
case A.loadLevelGroup:
|
||||
bt.levelGroup = bt.savedLevelGroup;
|
||||
return action;
|
||||
case A.checkSaveFileIsYours:
|
||||
return 1;
|
||||
case A.action0A:
|
||||
// wMusicFade and MaxVolume: audio.
|
||||
return action;
|
||||
case A.action11:
|
||||
case A.action12:
|
||||
bt.aa8d = action === A.action12 ? 1 : 0;
|
||||
return action;
|
||||
case A.action13:
|
||||
return bt.aa8d;
|
||||
case A.resetData:
|
||||
bt.trainers = bt.trainers.map(() => 0xff);
|
||||
bt.beaten = 0;
|
||||
return action;
|
||||
case A.giveReward: {
|
||||
// BattleTower_GiveReward: POTION stands for a pack with no room for
|
||||
// five more of the reward.
|
||||
const items = c.state.bag.items;
|
||||
if (items.length < MAX_ITEMS) return bt.reward;
|
||||
const room = items.some(
|
||||
(slot) =>
|
||||
slot.item === bt.reward &&
|
||||
slot.quantity < MAX_ITEM_STACK - BATTLE_TOWER.rewardQuantity + 1,
|
||||
);
|
||||
return room ? bt.reward : POTION;
|
||||
}
|
||||
case A.action1C:
|
||||
bt.challenge = CHALLENGE.won;
|
||||
return action;
|
||||
case A.action1D:
|
||||
bt.challenge = CHALLENGE.receivedReward;
|
||||
return action;
|
||||
case A.chooseReward:
|
||||
bt.reward = chooseReward(c);
|
||||
return action;
|
||||
case A.saveOptions:
|
||||
// SaveOptions: the options are saved with the native game.
|
||||
return action;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether a saved Battle Tower state is well formed. */
|
||||
export function validBattleTower(data: PokemonData, value: unknown): value is BattleTowerState {
|
||||
const bt = value as BattleTowerState;
|
||||
const byte = (n: unknown) => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 0xff;
|
||||
const bytes = (list: unknown, length: number) =>
|
||||
Array.isArray(list) && list.length === length && list.every(byte);
|
||||
const species = (list: unknown) =>
|
||||
Array.isArray(list) &&
|
||||
list.length <= 251 &&
|
||||
list.every((n) => Number.isInteger(n) && n >= 1 && n <= 251);
|
||||
const opponent = bt?.opponent,
|
||||
save = bt?.quickSave;
|
||||
return (
|
||||
Boolean(bt) &&
|
||||
typeof bt === "object" &&
|
||||
[
|
||||
bt.challenge,
|
||||
bt.beaten,
|
||||
bt.savedLevelGroup,
|
||||
bt.reward,
|
||||
bt.fileFlags,
|
||||
bt.aa8d,
|
||||
bt.levelGroup,
|
||||
bt.beatenNow,
|
||||
bt.textIndex,
|
||||
].every(byte) &&
|
||||
bytes(bt.trainers, BATTLE_TOWER.streakLength) &&
|
||||
bytes(bt.prevMons, BATTLE_TOWER.partyLength) &&
|
||||
bytes(bt.prevPrevMons, BATTLE_TOWER.partyLength) &&
|
||||
(opponent === undefined ||
|
||||
(Boolean(opponent) &&
|
||||
Number.isInteger(opponent.trainer) &&
|
||||
opponent.trainer >= 0 &&
|
||||
opponent.trainer < BATTLE_TOWER.uniqueTrainers &&
|
||||
typeof opponent.name === "string" &&
|
||||
opponent.name.length <= 10 &&
|
||||
byte(opponent.trainerClass) &&
|
||||
Array.isArray(opponent.party) &&
|
||||
opponent.party.length === BATTLE_TOWER.partyLength &&
|
||||
opponent.party.every((mon) => validPartyMon(data, mon)))) &&
|
||||
(save === undefined ||
|
||||
(Boolean(save) &&
|
||||
typeof save.map === "string" &&
|
||||
/^\d{1,2}:\d{1,3}$/.test(save.map) &&
|
||||
byte(save.x) &&
|
||||
byte(save.y) &&
|
||||
DIRECTION_ORDER.includes(save.direction) &&
|
||||
Array.isArray(save.party) &&
|
||||
save.party.length >= 1 &&
|
||||
save.party.length <= 6 &&
|
||||
save.party.every((mon) => validPartyMon(data, mon)) &&
|
||||
species(save.seen) &&
|
||||
species(save.caught) &&
|
||||
Array.isArray(save.unownDex) &&
|
||||
save.unownDex.length <= 26 &&
|
||||
save.unownDex.every((n) => Number.isInteger(n) && n >= 1 && n <= 26) &&
|
||||
Number.isInteger(save.firstUnownSeen) &&
|
||||
save.firstUnownSeen >= 0 &&
|
||||
save.firstUnownSeen <= 26))
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,23 @@ import { slotMachine } from "./slot-machine";
|
||||
import { cardFlip } from "./card-flip";
|
||||
import { giveDratini, moveDeletion } from "./blackthorn";
|
||||
import { beastsCheck, monCheck } from "./tin-tower";
|
||||
import {
|
||||
battleTowerAction,
|
||||
battleTowerRoomMenu,
|
||||
battleTowerSprite,
|
||||
battleTowerText,
|
||||
CHALLENGE,
|
||||
challengeMenu,
|
||||
checkBattleTowerRules,
|
||||
copyBattleTowerState,
|
||||
emptyBattleTowerState,
|
||||
healParty,
|
||||
loadOpponent,
|
||||
loadPokemonData,
|
||||
tryQuickSave,
|
||||
type BattleTowerOpponent,
|
||||
type BattleTowerState,
|
||||
} from "./battle-tower";
|
||||
import {
|
||||
checkMagikarpLength,
|
||||
findPartyMonThatSpecies,
|
||||
@@ -198,6 +215,8 @@ export interface ScriptState {
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** wBestMagikarpLengthFeet/Inches and wMagikarpRecordHoldersName. */
|
||||
magikarpRecord: MagikarpRecord;
|
||||
/** The Battle Tower's SRAM and the work RAM its scripts share. */
|
||||
battleTower: BattleTowerState;
|
||||
/** wRoamMon1/2/3 (Raikou, Entei, Suicune): InitRoamMons starts the first
|
||||
* two roaming; CheckEncounterRoamMon, UpdateRoamMons and JumpRoamMons
|
||||
* move or encounter them (see ../world/roam-mons). */
|
||||
@@ -267,6 +286,7 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
magikarpRecord: initialMagikarpRecord(),
|
||||
battleTower: emptyBattleTowerState(),
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
options: defaultOptions(),
|
||||
@@ -316,6 +336,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
magikarpRecord: { ...state.magikarpRecord },
|
||||
battleTower: copyBattleTowerState(state.battleTower),
|
||||
roamMons: state.roamMons.map((mon) => ({ ...mon })),
|
||||
roamMonsMap: state.roamMonsMap,
|
||||
options: { ...state.options },
|
||||
@@ -584,7 +605,13 @@ export type ScriptEffect =
|
||||
* SUICUNE and CONTEST (scripts load these) or ROAMING
|
||||
* (CheckEncounterRoamMon sets it before the script runs). */
|
||||
battleType?: number;
|
||||
/** BattleTowerBattle: the opponent of wBT_OTTemp and the texts
|
||||
* BattleTowerText shows when the player loses or wins. */
|
||||
tower?: BattleTowerOpponent & { winText: number; lossText: number };
|
||||
}
|
||||
/** LoadOpponentTrainerAndPokemonWithOTSprite: the map object's sprite
|
||||
* becomes `sprite` until the map loads again. */
|
||||
| { kind: "objectsprite"; object: number; sprite: number }
|
||||
/** loademote: the emote graphics (EMOTE_*) that show_emote displays
|
||||
* next, when they go to the emote bubble's tiles. */
|
||||
| { kind: "loademote"; emote: number }
|
||||
@@ -747,6 +774,8 @@ const savedByte = (address: number) =>
|
||||
(Object.keys(SAVED_WRAM) as SavedByte[]).find(
|
||||
(label) => SAVED_WRAM[label] === address,
|
||||
);
|
||||
/** VAR_FACING's values (DOWN, UP, LEFT, RIGHT) as directions. */
|
||||
const FACINGS: Direction[] = ["down", "up", "left", "right"];
|
||||
/** ITEM_FROM_MEM: take the item from wScriptVar. */
|
||||
const ITEM_FROM_MEM = 0xff;
|
||||
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
|
||||
@@ -850,6 +879,8 @@ export class ScriptRun {
|
||||
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
|
||||
private fruitTree = 0;
|
||||
private fruit = 0;
|
||||
/** Set by the Reset special: where the quick save left the player. */
|
||||
private resetTo?: BattleTowerState["quickSave"];
|
||||
/** wTempTrainer, copied from a map trainer's header by LoadTrainer_continue. */
|
||||
private tempTrainer?: TrainerHeader;
|
||||
/** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1
|
||||
@@ -1118,6 +1149,14 @@ export class ScriptRun {
|
||||
if (result === undefined)
|
||||
throw new UnsupportedScript(offset, opcode, ` (special ${index})`);
|
||||
value = result;
|
||||
if (this.resetTo) {
|
||||
// Reset: the script stops and the quick save's place returns.
|
||||
const save = this.resetTo;
|
||||
this.resetTo = undefined;
|
||||
yield* flush();
|
||||
yield synchronous({ kind: "warp", map: save.map, x: save.x, y: save.y, facing: save.direction });
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x11:
|
||||
@@ -1176,6 +1215,8 @@ export class ScriptRun {
|
||||
value = Number(this.state.fruitTrees.has(this.fruitTree));
|
||||
else if (routine === NATIVE_ROUTINES.pickedFruitTree)
|
||||
this.state.fruitTrees.add(this.fruitTree);
|
||||
else if (routine === JOHTO["BattleTowerHallwayChooseBattleRoomScript.asm_load_battle_room"])
|
||||
value = this.state.battleTower.levelGroup;
|
||||
else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn)
|
||||
spawn = findSpawn(this.state.lastSpawnMap);
|
||||
else if (routine === NATIVE_ROUTINES.setMemEvent) {
|
||||
@@ -1354,6 +1395,8 @@ export class ScriptRun {
|
||||
value = tempWild?.species ?? 0;
|
||||
else if (address === RAM.parkBallsRemaining && this.state.bugContest)
|
||||
value = this.state.bugContest.parkBalls;
|
||||
else if (address === WRAM.wNrOfBeatenBattleTowerTrainers)
|
||||
value = this.state.battleTower.beatenNow;
|
||||
else if (savedByte(address))
|
||||
value = this.state.memory[savedByte(address)!] ?? 0;
|
||||
else throw new UnsupportedScript(offset, opcode);
|
||||
@@ -1760,6 +1803,16 @@ export class ScriptRun {
|
||||
// itself is unchanged.
|
||||
this.strings[buffer(byte())] = String(value);
|
||||
break;
|
||||
case 0xa4: {
|
||||
// battletowertext: BattleTowerText for the loaded opponent.
|
||||
const which = byte();
|
||||
if (which < 1 || which > 3)
|
||||
throw new UnsupportedScript(offset, opcode, " (text)");
|
||||
write(
|
||||
battleTowerText(this.specialContext(pages, take), this.state.battleTower, which as 1 | 2 | 3),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 0x42:
|
||||
case 0xa5: {
|
||||
// getcurlandmarkname and getlandmarkname (whose landmark operand
|
||||
@@ -3483,10 +3536,83 @@ export class ScriptRun {
|
||||
return yield* checkForLuckyNumberWinners(this.specialContext(pages, take));
|
||||
case JOHTO.BattleTowerAction:
|
||||
// BATTLETOWERACTION_GSBALL reads sGSBallFlag, which only the
|
||||
// mobile/Virtual Console event sets. The Battle Tower's other
|
||||
// actions are not ported yet.
|
||||
// mobile/Virtual Console event sets.
|
||||
if (value === BATTLETOWERACTION_GSBALL) return 0;
|
||||
return undefined;
|
||||
return battleTowerAction(this.specialContext(pages, take), this.state.battleTower, value);
|
||||
case JOHTO.BattleTowerRoomMenu:
|
||||
return yield* battleTowerRoomMenu(this.specialContext(pages, take), this.state.battleTower);
|
||||
case JOHTO.CheckForBattleTowerRules:
|
||||
return yield* checkBattleTowerRules(this.specialContext(pages, take));
|
||||
case JOHTO.Menu_ChallengeExplanationCancel:
|
||||
// The English menu (wScriptVar TRUE); FALSE is the Japanese one
|
||||
// with the mobile News items.
|
||||
if (!value) return undefined;
|
||||
return yield* challengeMenu(this.specialContext(pages, take));
|
||||
case JOHTO.TryQuickSave: {
|
||||
const { x, y } = this.host.position();
|
||||
return yield* tryQuickSave(this.specialContext(pages, take), this.state.battleTower, {
|
||||
map: this.host.mapId,
|
||||
x,
|
||||
y,
|
||||
direction: FACINGS[this.host.facing() & 3],
|
||||
});
|
||||
}
|
||||
case JOHTO.LoadOpponentTrainerAndPokemonWithOTSprite: {
|
||||
const c = this.specialContext(pages, take),
|
||||
bt = this.state.battleTower;
|
||||
bt.opponent = loadOpponent(c, bt);
|
||||
yield {
|
||||
kind: "objectsprite",
|
||||
object: value,
|
||||
sprite: battleTowerSprite(c, bt.opponent.trainerClass),
|
||||
};
|
||||
return value;
|
||||
}
|
||||
case JOHTO.BattleTowerBattle: {
|
||||
// RunBattleTowerTrainer: the party is healed, the opponent copied
|
||||
// to wBT_OTTemp (marking the challenge in progress and counting
|
||||
// the battle), and the battle runs in the SET style. LoadPokemonData
|
||||
// then reads the party and Pokédex back from the quick save.
|
||||
const c = this.specialContext(pages, take),
|
||||
bt = this.state.battleTower,
|
||||
opponent = bt.opponent;
|
||||
if (!opponent) throw new Error("No Battle Tower opponent is loaded.");
|
||||
healParty(c);
|
||||
bt.challenge = CHALLENGE.inProgress;
|
||||
bt.beaten = (bt.beaten + 1) & 0xff;
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
const result = yield {
|
||||
kind: "battle",
|
||||
tower: {
|
||||
...opponent,
|
||||
winText: battleTowerText(c, bt, 2),
|
||||
lossText: battleTowerText(c, bt, 3),
|
||||
},
|
||||
};
|
||||
if (typeof result !== "number") throw new Error("Invalid battle result.");
|
||||
loadPokemonData(c, bt);
|
||||
healParty(c);
|
||||
if (!(result & 0x3f)) {
|
||||
bt.beatenNow = bt.beaten;
|
||||
this.strings[RAM.stringBuffer3] = String(bt.beatenNow + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case JOHTO.BattleTowerFade:
|
||||
case JOHTO.LoadMapPalettes:
|
||||
// Palette fades and reloads: presentation.
|
||||
return value;
|
||||
case JOHTO.Reset: {
|
||||
// After BATTLETOWERACTION_SAVE_AND_QUIT the game resets without
|
||||
// saving: Continue loads the quick save made before the Battle
|
||||
// Room, whose scene checks the challenge state.
|
||||
const save = this.state.battleTower.quickSave;
|
||||
if (!save) return undefined;
|
||||
const owner = this.host.sceneOwner(save.map);
|
||||
if (owner) this.state.scenes[owner] = 0;
|
||||
this.resetTo = save;
|
||||
return value;
|
||||
}
|
||||
case JOHTO.GameboyCheck:
|
||||
// The port is a Game Boy Color.
|
||||
return GBCHECK_CGB;
|
||||
|
||||
+53
-3
@@ -59,6 +59,12 @@ import {
|
||||
type DayTimerName,
|
||||
} from "../scripts/interpreter";
|
||||
import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage";
|
||||
import {
|
||||
copyBattleTowerState,
|
||||
emptyBattleTowerState,
|
||||
validBattleTower,
|
||||
type BattleTowerState,
|
||||
} from "../scripts/battle-tower";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
|
||||
@@ -211,6 +217,8 @@ export interface NativeSave {
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** The Magikarp length record and its holder. */
|
||||
magikarpRecord: MagikarpRecord;
|
||||
/** The Battle Tower's SRAM and its scripts' work RAM. */
|
||||
battleTower: BattleTowerState;
|
||||
/** wRoamMon1/2/3 (Raikou, Entei, Suicune). */
|
||||
roamMons: RoamMon[];
|
||||
/** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or
|
||||
@@ -282,6 +290,9 @@ interface Actor {
|
||||
/** An appear or disappear this visit: its visibility, not its flag,
|
||||
* is what a save keeps. */
|
||||
toggled?: boolean;
|
||||
/** A map object sprite a script set (LoadOpponentTrainerAndPokemonWith-
|
||||
* OTSprite) until the map loads again. */
|
||||
sprite?: number;
|
||||
/** OBJECT_ACTION_FISHING, from fish_cast_rod or fish_got_bite until
|
||||
* PutTheRodAway: the fishing frames replace the standing ones. */
|
||||
fishing?: boolean;
|
||||
@@ -932,6 +943,8 @@ export class GameWorld {
|
||||
}
|
||||
/** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites. */
|
||||
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_VARS) return object.sprite;
|
||||
return state.variables[object.sprite - OW.SPRITE_VARS] ?? 0;
|
||||
}
|
||||
@@ -2628,13 +2641,37 @@ export class GameWorld {
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "objectsprite": {
|
||||
const object = this.map.objects.find((candidate) => candidate.id === effect.object);
|
||||
if (!object) throw new Error("Script names an unknown object.");
|
||||
this.actor(object).sprite = effect.sprite;
|
||||
return false;
|
||||
}
|
||||
case "battle": {
|
||||
const draft = script.run.state;
|
||||
const trainers = new TrainerData(this.assets.reader);
|
||||
const opponent =
|
||||
effect.trainer &&
|
||||
trainers.trainer(effect.trainer.classId, effect.trainer.id);
|
||||
const trainer = opponent && {
|
||||
// A Battle Tower trainer: its own name and Pokémon, and the first
|
||||
// class's (Falkner's) AI attributes, which the AI layers and
|
||||
// AI_SwitchOrTryItem read in the Battle Tower; no items or prize.
|
||||
const tower = effect.tower;
|
||||
const falkner = tower && trainers.attributes(1);
|
||||
const towerTrainer = tower &&
|
||||
falkner && {
|
||||
name: tower.name,
|
||||
className: trainers.className(tower.trainerClass),
|
||||
party: structuredClone(tower.party),
|
||||
aiFlags: falkner.aiMoveFlags,
|
||||
switchFlags: falkner.aiItemSwitchFlags,
|
||||
items: [0, 0] as [number, number],
|
||||
baseReward: 0,
|
||||
reward: 0,
|
||||
winText: tower.lossText,
|
||||
lossText: tower.winText,
|
||||
};
|
||||
const trainer = towerTrainer ?? (opponent && {
|
||||
name: opponent.name,
|
||||
className: opponent.className,
|
||||
party: trainers.createParty(opponent, {
|
||||
@@ -2648,7 +2685,7 @@ export class GameWorld {
|
||||
reward: opponent.reward,
|
||||
winText: effect.trainer?.winText,
|
||||
lossText: effect.trainer?.lossText,
|
||||
};
|
||||
});
|
||||
const engine = new Battle({
|
||||
data: this.assets.pokemon,
|
||||
party: draft.party,
|
||||
@@ -2658,6 +2695,10 @@ export class GameWorld {
|
||||
johtoBadges: Array.from({ length: 8 }, (_, bit) =>
|
||||
draft.flags.has(OW.ENGINE_ZEPHYRBADGE + bit) ? 1 << bit : 0,
|
||||
).reduce((bits, badge) => bits | badge, 0),
|
||||
// wKantoBadges: ENGINE_BOULDERBADGE follows the Johto badges.
|
||||
kantoBadges: Array.from({ length: 8 }, (_, bit) =>
|
||||
draft.flags.has(OW.ENGINE_ZEPHYRBADGE + 8 + bit) ? 1 << bit : 0,
|
||||
).reduce((bits, badge) => bits | badge, 0),
|
||||
timeOfDay: this.clockTimeOfDay,
|
||||
random: () => this.random() & 0xff,
|
||||
wild: effect.wild,
|
||||
@@ -2694,7 +2735,9 @@ export class GameWorld {
|
||||
saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)),
|
||||
money: draft.momsMoney,
|
||||
},
|
||||
shift: shiftBattleStyle(draft.options),
|
||||
// RunBattleTowerTrainer sets BATTLE_SHIFT: the SET style.
|
||||
shift: tower ? false : shiftBattleStyle(draft.options),
|
||||
battleTower: Boolean(tower),
|
||||
battleScene: battleSceneOn(draft.options),
|
||||
unown: draft,
|
||||
// wUnlockedUnowns: the ENGINE_UNLOCKED_UNOWNS_* flags' bits.
|
||||
@@ -4576,6 +4619,7 @@ export class GameWorld {
|
||||
timers: structuredClone(state.timers),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
magikarpRecord: { ...state.magikarpRecord },
|
||||
battleTower: copyBattleTowerState(state.battleTower),
|
||||
roamMons: structuredClone(state.roamMons),
|
||||
roamMonsMap: state.roamMonsMap,
|
||||
options: { ...state.options },
|
||||
@@ -4737,6 +4781,7 @@ export class GameWorld {
|
||||
!integer(save.magikarpRecord.inches, 0, 0xff) ||
|
||||
typeof save.magikarpRecord.holder !== "string" ||
|
||||
save.magikarpRecord.holder.length > 10 ||
|
||||
!validBattleTower(this.assets.pokemon, save.battleTower) ||
|
||||
!Array.isArray(save.roamMons) ||
|
||||
save.roamMons.length !== 3 ||
|
||||
!save.roamMons.every(validRoamMon) ||
|
||||
@@ -4839,6 +4884,7 @@ export class GameWorld {
|
||||
timers: structuredClone(save.timers),
|
||||
luckyNumber: { ...save.luckyNumber },
|
||||
magikarpRecord: { ...save.magikarpRecord },
|
||||
battleTower: copyBattleTowerState(save.battleTower),
|
||||
roamMons: structuredClone(save.roamMons),
|
||||
roamMonsMap: save.roamMonsMap,
|
||||
options: { ...save.options },
|
||||
@@ -5001,6 +5047,9 @@ export function migrateSave(
|
||||
...(!Object.hasOwn(save, "magikarpRecord") && {
|
||||
magikarpRecord: initialMagikarpRecord(),
|
||||
}),
|
||||
...(!Object.hasOwn(save, "battleTower") && {
|
||||
battleTower: emptyBattleTowerState(),
|
||||
}),
|
||||
...(!Object.hasOwn(save, "roamMons") && {
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
@@ -5065,6 +5114,7 @@ export function migrateSave(
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
magikarpRecord: initialMagikarpRecord(),
|
||||
battleTower: emptyBattleTowerState(),
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
intro: false,
|
||||
|
||||
@@ -971,6 +971,9 @@ function showBattle(world: GameWorld) {
|
||||
}),
|
||||
)
|
||||
: button("PACK", () => {
|
||||
// BattleMenu_Pack.ItemsCantBeUsed: the engine says why.
|
||||
if (prompt.noItems)
|
||||
return runtime.battleAction({ action: "item", item: 0, slot: -1 });
|
||||
showPack = true;
|
||||
showWorld(world);
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user