src/battle-scene.ts
Rama beta · réplica de lectura del repositorio original.
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { Animation } from "#app/animations";
import { Battle } from "#app/battle";
import {
ANTI_VARIANCE_WEIGHT_MODIFIER,
AVERAGE_ENCOUNTERS_PER_RUN_TARGET,
BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT,
MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT,
} from "#app/constants";
import { eventBus } from "#app/event-bus";
import type { GameMode } from "#app/game-mode";
import { getGameMode } from "#app/game-mode";
import { audioManager } from "#app/global-audio-manager";
import { timedEventManager } from "#app/global-event-manager";
import { globalScene, initGlobalScene } from "#app/global-scene";
import { settings } from "#app/global-settings-manager";
import { speciesDataRegistry } from "#app/global-species-data-registry";
import { InputsController } from "#app/inputs-controller";
import { LoadingScene } from "#app/loading-scene";
import { activeOverrides } from "#app/overrides";
import type { Phase } from "#app/phase";
import { PhaseManager } from "#app/phase-manager";
import { FieldSpriteBatchHandler, FieldSpriteSubmitter } from "#app/render-nodes/field-sprite";
import { SpriteBatchHandler, SpriteSubmitter } from "#app/render-nodes/sprite";
import { SceneBase } from "#app/scene-base";
import { TurnCommandManager } from "#app/turn-command-manager";
import { UiInputs } from "#app/ui-inputs";
import { STARTING_WAVE } from "#balance/misc";
import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#balance/starters";
import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets } from "#data/battle-anims";
import { getDailyMysteryEncounter } from "#data/daily-run";
import { allMoves, biomeDepths, modifierTypes } from "#data/data-lists";
import { classicFinalBossDialogue } from "#data/dialogue";
import type { SpeciesFormChangeTrigger } from "#data/form-change-triggers";
import { SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger } from "#data/form-change-triggers";
import { Gender } from "#data/gender";
import type { SpeciesFormChange } from "#data/pokemon-forms";
import type { PokemonSpecies, PokemonSpeciesFilter } from "#data/pokemon-species";
import { getTypeRgb } from "#data/type";
import { BattleType } from "#enums/battle-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { BiomeId } from "#enums/biome-id";
import { FormChangeItem } from "#enums/form-change-item";
import { GameModes } from "#enums/game-modes";
import { ModifierPoolType } from "#enums/modifier-pool-type";
import { MoveId } from "#enums/move-id";
import { MysteryEncounterMode } from "#enums/mystery-encounter-mode";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { Nature } from "#enums/nature";
import { PlayerGender } from "#enums/player-gender";
import { PokeballType } from "#enums/pokeball";
import type { PokemonAnimType } from "#enums/pokemon-anim-type";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { TextStyle } from "#enums/text-style";
import { TimeOfDay } from "#enums/time-of-day";
import type { TrainerSlot } from "#enums/trainer-slot";
import { TrainerType } from "#enums/trainer-type";
import { TrainerVariant } from "#enums/trainer-variant";
import type { UiWindowStyle } from "#enums/ui-window-style";
import { VolumeSetting } from "#enums/volume-setting";
import { NewArenaEvent } from "#events/battle-scene";
import { Arena, getBiomeHasProps, getBiomeKey } from "#field/arena";
import { ArenaBase } from "#field/arena-base";
import { DamageNumberHandler } from "#field/damage-number-handler";
import type { Pokemon } from "#field/pokemon";
import { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { PokemonSpriteTeraSparkleHandler } from "#field/pokemon-sprite-tera-sparkle-handler";
import { Trainer } from "#field/trainer";
import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "#modifiers/modifier";
import {
ConsumableModifier,
ConsumablePokemonModifier,
DoubleBattleChanceBoosterModifier,
ExpBalanceModifier,
ExpShareModifier,
FusePokemonModifier,
HealingBoosterModifier,
ModifierBar,
MultipleParticipantExpBonusModifier,
PersistentModifier,
PokemonExpBoosterModifier,
PokemonFormChangeItemModifier,
PokemonHeldItemModifier,
PokemonHpRestoreModifier,
PokemonIncrementingStatModifier,
RememberMoveModifier,
} from "#modifiers/modifier";
import {
getDefaultModifierTypeForTier,
getEnemyModifierTypesForWave,
getLuckString,
getLuckTextTint,
getPartyLuckValue,
type ModifierType,
PokemonHeldItemModifierType,
} from "#modifiers/modifier-type";
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { allMysteryEncounters, mysteryEncountersByBiome } from "#mystery-encounters/mystery-encounter-biomes";
import { MysteryEncounterSaveData } from "#mystery-encounters/mystery-encounter-save-data";
import { expSpriteKeys } from "#sprites/sprite-keys";
import { hasExpSprite } from "#sprites/sprite-utils";
import type { Variant } from "#sprites/variant";
import { clearVariantData, variantData } from "#sprites/variant";
import type { Achv } from "#system/achv";
import { achvs, ModifierAchv, MoneyAchv } from "#system/achv";
import { GameData } from "#system/game-data";
import { initGameSpeed } from "#system/game-speed";
import type { PokemonData } from "#system/pokemon-data";
import type { Voucher } from "#system/voucher";
import { vouchers } from "#system/voucher";
import { trainerConfigs } from "#trainers/trainer-config";
import type { Constructor } from "#types/common";
import type { SettingsUpdateEventArgs } from "#types/event-bus-types";
import type { HeldModifierConfig } from "#types/held-modifier-config";
import type { Localizable } from "#types/locales";
import type {
NewBattleConstructedProps,
NewBattleInitialProps,
NewBattleResolvedProps,
NewBattleSavedProps,
} from "#types/new-battle-props";
import type { SessionSaveData } from "#types/save-data";
import type { VolumeSettingsKey } from "#types/settings";
import { AbilityBar } from "#ui/ability-bar";
import { ArenaFlyout } from "#ui/arena-flyout";
import { CandyBar } from "#ui/candy-bar";
import { CharSprite } from "#ui/char-sprite";
import { PartyExpBar } from "#ui/party-exp-bar";
import { PokeballTray } from "#ui/pokeball-tray";
import { PokemonInfoContainer } from "#ui/pokemon-info-container";
import { addTextObject, getTextColor, RAINBOW_TINT } from "#ui/text";
import { UI } from "#ui/ui";
import { addUiThemeOverrides, updateWindowType } from "#ui/ui-theme";
import { playTween } from "#utils/anim-utils";
import {
BooleanHolder,
fixedInt,
formatMoney,
getBiomeName,
getIvsFromId,
isBetween,
NumberHolder,
randomString,
randSeedInt,
randSeedItem,
shiftCharCodes,
} from "#utils/common";
import { deepMergeSpriteData } from "#utils/data";
import { getEnumValues } from "#utils/enums";
import { cachedFetch } from "#utils/fetch-utils";
import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils";
import { decodeNickname } from "#utils/pokemon-utils";
import { capitalizeFirstLetterOnly } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
export type PokeballCounts = Record<Exclude<PokeballType, PokeballType.LUXURY_BALL>, number>;
export interface InfoToggle {
toggleInfo(force?: boolean): void;
isActive(): boolean;
}
/**
* The `BattleScene` is the primary scene for the game.
*
* Despite its name, it handles _everything_ other than initial asset loading,
* up to and including title menuing and settings handling.
*/
// TODO: Breakup into multiple scenes if possible/practical
export class BattleScene extends SceneBase {
public inputController: InputsController;
public uiInputs: UiInputs;
public sessionPlayTime: number | null = null;
public lastSavePlayTime: number | null = null;
public reroll = false;
public disableMenu = false;
public gameData: GameData;
/** The numeric slot number of the current save slot being played. */
public sessionSlotId: number;
/** Manager for the phases active in the battle scene */
public readonly phaseManager: PhaseManager = new PhaseManager();
/**
* Global state variable indicating AI moveset generation is in progress
*
* @remarks
* It is intended that this is set to `true` while movesets are being generated.
* Its purpose is to skip certain checks and effects that are not relevant during
* the moveset generation process, such as ability suppression checks.
*
* @defaultValue `false`
*/
public movesetGenInProgress = false;
/** A manager for the commands and moves used in the current battle. */
public readonly turnCommandManager: TurnCommandManager = new TurnCommandManager();
public field: Phaser.GameObjects.Container;
public fieldUI: Phaser.GameObjects.Container;
public charSprite: CharSprite;
public pbTray: PokeballTray;
public pbTrayEnemy: PokeballTray;
public abilityBar: AbilityBar;
public partyExpBar: PartyExpBar;
public candyBar: CandyBar;
public arenaBg: Phaser.GameObjects.Sprite;
public arenaBgTransition: Phaser.GameObjects.Sprite;
public arenaPlayer: ArenaBase;
public arenaPlayerTransition: ArenaBase;
public arenaEnemy: ArenaBase;
public arenaNextEnemy: ArenaBase;
private invertFilter: Phaser.Filters.ColorMatrix | null = null;
public arena: Arena;
public gameMode: GameMode;
public score: number;
public lockModifierTiers: boolean;
public trainer: Phaser.GameObjects.Sprite;
public lastEnemyTrainer: Trainer | null;
public currentBattle: Battle;
public pokeballCounts: PokeballCounts;
public money: number;
public pokemonInfoContainer: PokemonInfoContainer;
private party: PlayerPokemon[];
/** Session save data that pertains to Mystery Encounters */
public mysteryEncounterSaveData: MysteryEncounterSaveData = new MysteryEncounterSaveData();
/** If the previous wave was a MysteryEncounter, tracks the object with this variable. Mostly used for visual object cleanup */
public lastMysteryEncounter?: MysteryEncounter | undefined;
/** Combined Biome and Wave count text */
private biomeWaveText: Phaser.GameObjects.Text;
private moneyText: Phaser.GameObjects.Text;
private scoreText: Phaser.GameObjects.Text;
private luckLabelText: Phaser.GameObjects.Text;
private luckText: Phaser.GameObjects.Text;
private modifierBar: ModifierBar;
private enemyModifierBar: ModifierBar;
public arenaFlyout: ArenaFlyout;
private fieldOverlay: Phaser.GameObjects.Rectangle;
private shopOverlay: Phaser.GameObjects.Rectangle;
private shopOverlayShown = false;
public modifiers: PersistentModifier[];
private enemyModifiers: PersistentModifier[];
public uiContainer: Phaser.GameObjects.Container;
public ui: UI;
public seed: string;
public waveSeed: string;
public waveCycleOffset: number;
/**
* Whether to offset Gym Leader waves by 10 (30, 60, 90 instead of 20, 50, 80). \
* Determined at the start of the run, and is unused for non-Classic game modes.
*/
public offsetGym = false;
public damageNumberHandler: DamageNumberHandler;
private spriteTeraSparkleHandler: PokemonSpriteTeraSparkleHandler;
public fieldSpriteSubmitter: FieldSpriteSubmitter;
public fieldSpriteBatchHandler: FieldSpriteBatchHandler;
public spriteSubmitter: SpriteSubmitter;
public spriteBatchHandler: SpriteBatchHandler;
private playTimeTimer: Phaser.Time.TimerEvent;
public rngSeedOverride = "";
public rngOffset = 0;
public inputMethod: string;
private readonly infoToggles: InfoToggle[] = [];
/**
* Allows subscribers to listen for events
*
* Current Events:
* - {@linkcode BattleSceneEventType.MOVE_USED} {@linkcode MoveUsedEvent}
* - {@linkcode BattleSceneEventType.TURN_INIT} {@linkcode TurnInitEvent}
* - {@linkcode BattleSceneEventType.TURN_END} {@linkcode TurnEndEvent}
* - {@linkcode BattleSceneEventType.NEW_ARENA} {@linkcode NewArenaEvent}
*/
public readonly eventTarget: EventTarget = new EventTarget();
/** A helper class containing several animation-related functions. */
public readonly animations: Animation = new Animation();
declare renderer: Phaser.Renderer.WebGL.WebGLRenderer;
constructor() {
super("battle");
this.updateGameInfo();
initGlobalScene(this);
this.initSettingsEventListeners();
}
private initSettingsEventListeners(): void {
const updateSoundKeys = ["bgmVolume", "fieldVolume", "masterVolume", "soundEffectsVolume", "uiVolume"] as const;
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: necessary
eventBus.on("settings/update/success", ({ key, value }: SettingsUpdateEventArgs) => {
if (updateSoundKeys.includes(key as VolumeSettingsKey) && typeof value === "number") {
const keyMap = {
bgmVolume: VolumeSetting.BGM,
fieldVolume: VolumeSetting.FIELD,
masterVolume: VolumeSetting.MAIN,
soundEffectsVolume: VolumeSetting.SE,
uiVolume: VolumeSetting.UI,
} as const satisfies Record<VolumeSettingsKey, VolumeSetting>;
audioManager.setVolume(keyMap[key], value);
return;
}
if (key === "enableTouchControls") {
const touchControls = document.getElementById("touchControls");
if (touchControls && typeof value === "boolean") {
touchControls.classList.toggle("visible", value);
}
return;
}
if (key === "uiWindowStyle" && typeof value === "number") {
updateWindowType(value as UiWindowStyle);
return;
}
if (key === "playerGender" && typeof value === "number") {
const female = value === PlayerGender.FEMALE;
this.trainer.setTexture(this.trainer.texture.key.replace(female ? "m" : "f", female ? "f" : "m"));
return;
}
if (key === "moneyFormat" && typeof value === "number") {
this.updateMoneyText(false);
return;
}
if (key === "shopOverlayOpacity" && typeof value === "number") {
this.updateShopOverlayOpacity(value);
return;
}
});
}
public loadPokemonAtlas(key: string, atlasPath: string, experimental = settings.expSpritesEnabled): void {
const variant = atlasPath.includes("variant/") || /_[0-3]$/.test(atlasPath);
if (experimental) {
experimental = hasExpSprite(key);
}
if (variant) {
atlasPath = atlasPath.replace("variant/", "");
}
this.load.atlas(
key,
`images/pokemon/${variant ? "variant/" : ""}${experimental ? "exp/" : ""}${atlasPath}.png`,
`images/pokemon/${variant ? "variant/" : ""}${experimental ? "exp/" : ""}${atlasPath}.json`,
);
}
/**
* Load game assets necessary for the scene to run.
* Called by Phaser on new game start.
*/
public async preload(): Promise<void> {
/**
* These moves serve as fallback animations for other moves without loaded animations,
* and must be loaded prior to game start.
*/
const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE];
await Promise.all([
this.initExpSprites(),
this.initVariantData(),
initCommonAnims().then(() => loadCommonAnimAssets(true)),
Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)),
]).catch(reason => {
throw new Error(`Unexpected error during BattleScene preLoad!\nReason: ${reason}`);
});
}
/**
* Create game objects with loaded assets.
* Called by Phaser on new game start.
*/
public create(): void {
this.scene.remove(LoadingScene.KEY);
initGameSpeed(this);
this.inputController = new InputsController();
this.uiInputs = new UiInputs(this.inputController);
this.gameData = new GameData();
addUiThemeOverrides();
this.load.setBaseURL();
this.invertFilter = this.cameras.main.filters.external.addColorMatrix().setActive(false);
this.invertFilter.colorMatrix.negative();
// Condition check needed for when running headless mode during tests
if (this.renderer instanceof Phaser.Renderer.WebGL.WebGLRenderer) {
this.spriteSubmitter = new SpriteSubmitter(this.renderer.renderNodes);
this.renderer.renderNodes.addNode(SpriteSubmitter.NAME, this.spriteSubmitter);
this.spriteBatchHandler = new SpriteBatchHandler(this.renderer.renderNodes);
this.renderer.renderNodes.addNode(SpriteBatchHandler.NAME, this.spriteBatchHandler);
this.fieldSpriteSubmitter = new FieldSpriteSubmitter(this.renderer.renderNodes);
this.renderer.renderNodes.addNode(FieldSpriteSubmitter.NAME, this.fieldSpriteSubmitter);
this.fieldSpriteBatchHandler = new FieldSpriteBatchHandler(this.renderer.renderNodes);
this.renderer.renderNodes.addNode(FieldSpriteBatchHandler.NAME, this.fieldSpriteBatchHandler);
}
this.launchBattle();
}
update() {
this.ui?.update();
}
// TODO: Split this up into multiple sub-methods
launchBattle() {
const biome = activeOverrides.STARTING_BIOME_OVERRIDE || BiomeId.PLAINS;
const biomeKey = getBiomeKey(biome);
this.arenaBg = this.add
.sprite(0, 0, `${biomeKey}_bg`)
.setName("sprite-arena-bg")
.setRenderNodeRole("Submitter", this.fieldSpriteSubmitter)
.setRenderNodeRole("BatchHandler", this.fieldSpriteBatchHandler)
.setScale(6)
.setOrigin(0)
.setSize(320, 240);
this.arenaBgTransition = this.add
.sprite(0, 0, `${biomeKey}_bg`)
.setName("sprite-arena-bg-transition")
.setRenderNodeRole("Submitter", this.fieldSpriteSubmitter)
.setRenderNodeRole("BatchHandler", this.fieldSpriteBatchHandler)
.setScale(6)
.setOrigin(0)
.setSize(320, 240)
.setVisible(false);
this.field = this.add //
.container(0, 0)
.setName("field")
.setScale(6);
this.fieldUI = this.add //
.container(0, this.game.canvas.height)
.setName("field-ui")
.setDepth(1)
.setScale(6);
const transition = this.make.rexTransitionImagePack(
{
x: 0,
y: 0,
scale: 6,
key: "loading_bg",
origin: { x: 0, y: 0 },
},
true,
);
// TODO: fix the typing in a `.d.ts` file so the `ts-ignore` is no longer necessary
/* biome-ignore lint/suspicious/noTsIgnore: ts-ignore is necessary because `tsc` and `tsgo` require the directive to be on different lines,
* meaning `@ts-expect-error` is guaranteed to emit a diagnostic on one of the lines depending on which one is used
*/
// @ts-ignore
transition.transit({
mode: "blinds",
/* biome-ignore lint/suspicious/noTsIgnore: ts-ignore is necessary because `tsc` and `tsgo` require the directive to be on different lines,
* meaning `@ts-expect-error` is guaranteed to emit a diagnostic on one of the lines depending on which one is used
*/
// @ts-ignore
ease: "Cubic.easeInOut",
duration: 1250,
});
transition.once("complete", () => {
transition.destroy();
});
this.add.existing(transition);
this.uiContainer = this.add //
.container(0, 0)
.setName("ui")
.setDepth(2)
.setScale(6);
const overlayWidth = this.scaledCanvas.width;
const overlayHeight = this.scaledCanvas.height - 48;
this.fieldOverlay = this.add
.rectangle(0, overlayHeight * -1 - 48, overlayWidth, overlayHeight, 0x424242)
.setName("rect-field-overlay")
.setOrigin(0)
.setAlpha(0);
this.shopOverlay = this.add
.rectangle(0, overlayHeight * -1 - 48, overlayWidth, overlayHeight, 0x070707)
.setName("rect-shop-overlay")
.setOrigin(0)
.setAlpha(0);
this.modifiers = [];
this.enemyModifiers = [];
this.modifierBar = new ModifierBar() //
.setName("modifier-bar");
this.add.existing(this.modifierBar);
this.enemyModifierBar = new ModifierBar(true) //
.setName("enemy-modifier-bar");
this.add.existing(this.enemyModifierBar);
this.charSprite = new CharSprite() //
.setName("sprite-char")
.setup();
this.pbTray = new PokeballTray(true) //
.setName("pb-tray")
.setup();
this.pbTrayEnemy = new PokeballTray(false) //
.setName("enemy-pb-tray")
.setup();
this.abilityBar = new AbilityBar() //
.setName("ability-bar")
.setup();
this.partyExpBar = new PartyExpBar() //
.setName("party-exp-bar")
.setup();
this.candyBar = new CandyBar() //
.setName("candy-bar")
.setup();
this.biomeWaveText = addTextObject(this.scaledCanvas.width - 2, 0, STARTING_WAVE.toString(), TextStyle.BATTLE_INFO)
.setName("text-biome-wave")
.setOrigin(1, 0.5);
this.moneyText = addTextObject(this.scaledCanvas.width - 2, 0, "", TextStyle.MONEY)
.setName("text-money")
.setOrigin(1, 0.5);
this.scoreText = addTextObject(this.scaledCanvas.width - 2, 0, "", TextStyle.PARTY, { fontSize: "36px" })
.setName("text-score")
.setOrigin(1, 0.5);
this.luckText = addTextObject(this.scaledCanvas.width - 2, 0, "", TextStyle.PARTY, { fontSize: "36px" })
.setName("text-luck")
.setOrigin(1, 0.5)
.setVisible(false);
this.luckLabelText = addTextObject(
this.scaledCanvas.width - 2,
0,
i18next.t("common:luckIndicator"),
TextStyle.PARTY,
{ fontSize: "36px" },
)
.setName("text-luck-label")
.setOrigin(1, 0.5)
.setVisible(false);
this.arenaFlyout = new ArenaFlyout();
this.pokemonInfoContainer = new PokemonInfoContainer(this.scaledCanvas.width + 52, -this.scaledCanvas.height + 66) //
.setup();
this.updateUIPositions();
this.damageNumberHandler = new DamageNumberHandler();
this.spriteTeraSparkleHandler = new PokemonSpriteTeraSparkleHandler() //
.setup();
this.fieldUI
.add([
this.fieldOverlay,
this.shopOverlay,
this.charSprite,
this.pbTray,
this.pbTrayEnemy,
this.abilityBar,
this.partyExpBar,
this.candyBar,
this.biomeWaveText,
this.moneyText,
this.scoreText,
this.luckText,
this.luckLabelText,
this.arenaFlyout,
this.pokemonInfoContainer,
])
.moveBelow<Phaser.GameObjects.GameObject>(this.arenaFlyout, this.fieldOverlay);
this.uiContainer.add([this.modifierBar, this.enemyModifierBar]);
this.party = [];
this.arenaPlayer = new ArenaBase(true) //
.setName("arena-player");
this.arenaPlayerTransition = new ArenaBase(true) //
.setName("arena-player-transition")
.setVisible(false);
this.arenaEnemy = new ArenaBase(false) //
.setName("arena-enemy");
this.arenaNextEnemy = new ArenaBase(false) //
.setName("arena-next-enemy")
.setVisible(false);
this.field.add([this.arenaPlayer, this.arenaPlayerTransition, this.arenaEnemy, this.arenaNextEnemy]);
this.trainer = this.addFieldSprite(0, 0, `trainer_${settings.isPlayerFemale ? "f" : "m"}_back`)
.setOrigin(0.5, 1)
.setName("sprite-trainer");
this.field.add(this.trainer);
this.anims.create({
key: "prompt",
frames: this.anims.generateFrameNumbers("prompt", { start: 1, end: 4 }),
frameRate: 6,
repeat: -1,
showOnStart: true,
});
this.anims.create({
key: "tera_sparkle",
frames: this.anims.generateFrameNumbers("tera_sparkle", {
start: 0,
end: 12,
}),
frameRate: 18,
repeat: 0,
showOnStart: true,
hideOnComplete: true,
});
this.reset(false, false, true);
// Initialize UI-related aspects and then start the login phase.
this.ui = new UI();
this.uiContainer.add(this.ui);
this.ui.setup();
this.phaseManager.toTitleScreen(true);
this.phaseManager.shiftPhase();
}
initSession(): void {
if (this.sessionPlayTime === null) {
this.sessionPlayTime = 0;
}
if (this.lastSavePlayTime === null) {
this.lastSavePlayTime = 0;
}
if (this.playTimeTimer) {
this.playTimeTimer.destroy();
}
this.playTimeTimer = this.time.addEvent({
delay: fixedInt(1000),
repeat: -1,
callback: () => {
if (this.gameData) {
this.gameData.gameStats.playTime++;
}
if (this.sessionPlayTime !== null) {
this.sessionPlayTime++;
}
if (this.lastSavePlayTime !== null) {
this.lastSavePlayTime++;
}
},
});
this.updateBiomeWaveText();
this.updateMoneyText();
this.updateScoreText();
}
async initExpSprites(): Promise<void> {
if (expSpriteKeys.size > 0) {
return;
}
const res = await cachedFetch("./exp-sprites.json");
const keys = await res.json();
if (!Array.isArray(keys)) {
throw new Error("EXP Sprites were not array when fetched!");
}
// TODO: Optimize this
for (const k of keys) {
expSpriteKeys.add(k);
}
}
/**
* Initialize the variant data.
* If experimental sprites are enabled, their entries are replaced via this method.
*/
async initVariantData(): Promise<void> {
clearVariantData();
const otherVariantData = await cachedFetch("./images/pokemon/variant/_masterlist.json").then(r => r.json());
for (const k of Object.keys(otherVariantData)) {
variantData[k] = otherVariantData[k];
}
if (!settings.expSpritesEnabled) {
return;
}
const expVariantData = await cachedFetch("./images/pokemon/variant/_exp_masterlist.json").then(r => r.json());
deepMergeSpriteData(variantData, expVariantData);
}
// TODO: Add a `getPartyOnSide` function for getting the party of a pokemon
public getPlayerParty(): PlayerPokemon[] {
return this.party;
}
/**
* @returns An array of {@linkcode PlayerPokemon} filtered from the player's party
* that are {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
*/
public getPokemonAllowedInBattle(): PlayerPokemon[] {
return this.getPlayerParty().filter(p => p.isAllowedInBattle());
}
/**
* @returns The first {@linkcode PlayerPokemon} that is {@linkcode getPlayerField on the field}
* and {@linkcode PlayerPokemon.isActive is active}
* (aka {@linkcode PlayerPokemon.isAllowedInBattle is allowed in battle}),
* or `undefined` if there are no valid pokemon
* @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
*/
public getPlayerPokemon(includeSwitching = true): PlayerPokemon | undefined {
return this.getPlayerField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false));
}
// TODO: Add `undefined` to return type
/**
* Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not.
* @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon
* @returns array of {@linkcode PlayerPokemon}
*/
public getPlayerField(active = false): PlayerPokemon[] {
const party = this.getPlayerParty();
return party
.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1))
.filter(p => !active || p.isActive());
}
public getEnemyParty(): EnemyPokemon[] {
return this.currentBattle?.enemyParty ?? [];
}
/**
* @returns The first {@linkcode EnemyPokemon} that is {@linkcode getEnemyField | on the field}
* and {@linkcode EnemyPokemon.isActive | is active}
* (aka {@linkcode EnemyPokemon.isAllowedInBattle | is allowed in battle}),
* or `undefined` if there are no valid pokemon
* @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
*/
public getEnemyPokemon(includeSwitching = true): EnemyPokemon | undefined {
return this.getEnemyField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false));
}
/**
* Returns an array of EnemyPokemon of length 1 or 2 depending on if in a double battle or not.
* @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon
* @returns array of {@linkcode EnemyPokemon}
*/
public getEnemyField(active = false): EnemyPokemon[] {
const party = this.getEnemyParty();
return party
.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1))
.filter(p => !active || p.isActive());
}
/**
* Returns an array of Pokemon on both sides of the battle - player first, then enemy.
* Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type.
* @param activeOnly - Whether to consider only active pokemon (as described by {@linkcode Pokemon#isActive()}); default `false`.
* If `true`, will also remove all `null` values from the array.
* @returns An array of {@linkcode Pokemon}, as described above.
*
* @remarks
* This should *only* be used in instances where speed order is not relevant.
* If speed order matters, use {@linkcode inSpeedOrder}.
*/
public getField(activeOnly = false): Pokemon[] {
const ret: Pokemon[] = new Array(4).fill(null);
const playerField = this.getPlayerField();
const enemyField = this.getEnemyField();
ret.splice(0, playerField.length, ...playerField);
ret.splice(2, enemyField.length, ...enemyField);
return activeOnly ? ret.filter(p => p?.isActive()) : ret;
}
/**
* Attempt to redirect a move in double battles from a fainted/removed Pokemon to its ally.
* @param removedPokemon - The {@linkcode Pokemon} having been removed from the field.
* @param allyPokemon - The {@linkcode Pokemon} allied with the removed Pokemon; will have moves redirected to it
*/
redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
this.phaseManager.redirectMoves(removedPokemon, allyPokemon);
}
/**
* Returns the ModifierBar of this scene, which is declared private and therefore not accessible elsewhere
* @param isEnemy - Whether to return the enemy modifier bar instead of the player bar; default `false`
* @returns The {@linkcode ModifierBar} for the given side of the field
*/
getModifierBar(isEnemy = false): ModifierBar {
return isEnemy ? this.enemyModifierBar : this.modifierBar;
}
// store info toggles to be accessible by the ui
addInfoToggle(...infoToggles: InfoToggle[]): void {
this.infoToggles.push(...infoToggles);
}
// return the stored info toggles; used by ui-inputs
getInfoToggles(activeOnly = false): InfoToggle[] {
return activeOnly ? this.infoToggles.filter(t => t?.isActive()) : this.infoToggles;
}
/**
* Return the {@linkcode Pokemon} associated with the given ID.
* @param pokemonId - The PID whose Pokemon will be retrieved
* @returns The `Pokemon` associated with the given ID,
* or `undefined` if none is found in either team's party.
* @see {@linkcode Pokemon.id}
*/
// TODO: Remove `undefined` from signature
public getPokemonById(pokemonId: number | undefined): Pokemon | undefined {
if (pokemonId == null) {
// biome-ignore lint/complexity/noUselessUndefined: More explicit
return undefined;
}
const party = (this.getPlayerParty() as Pokemon[]).concat(this.getEnemyParty());
return party.find(p => p.id === pokemonId);
}
// biome-ignore lint/complexity/useMaxParams: will be fixed later
addPlayerPokemon(
species: PokemonSpecies,
level: number,
abilityIndex?: number,
formIndex?: number,
gender?: Gender,
shiny?: boolean,
variant?: Variant,
ivs?: number[],
nature?: Nature,
dataSource?: Pokemon | PokemonData,
postProcess?: (playerPokemon: PlayerPokemon) => void,
): PlayerPokemon {
const pokemon = new PlayerPokemon(
species,
level,
abilityIndex,
formIndex,
gender,
shiny,
variant,
ivs,
nature,
dataSource,
);
if (postProcess) {
postProcess(pokemon);
}
if (activeOverrides.IVS_OVERRIDE === null) {
// do nothing
} else if (Array.isArray(activeOverrides.IVS_OVERRIDE)) {
if (activeOverrides.IVS_OVERRIDE.length !== 6) {
throw new Error("The Player IVs override must be an array of length 6 or a number!");
}
if (activeOverrides.IVS_OVERRIDE.some(value => !isBetween(value, 0, 31))) {
throw new Error("All IVs in the player IV override must be between 0 and 31!");
}
pokemon.ivs = activeOverrides.IVS_OVERRIDE;
} else {
if (!isBetween(activeOverrides.IVS_OVERRIDE, 0, 31)) {
throw new Error("The Player IV override must be a value between 0 and 31!");
}
pokemon.ivs = new Array(6).fill(activeOverrides.IVS_OVERRIDE);
}
if (activeOverrides.NATURE_OVERRIDE !== null) {
pokemon.nature = activeOverrides.NATURE_OVERRIDE;
}
pokemon.init();
return pokemon;
}
// biome-ignore lint/complexity/useMaxParams: will be fixed later
addEnemyPokemon(
species: PokemonSpecies,
level: number,
trainerSlot: TrainerSlot,
boss = false,
shinyLock = false,
dataSource?: PokemonData,
postProcess?: (enemyPokemon: EnemyPokemon) => void,
forRival = false,
): EnemyPokemon {
if (activeOverrides.ENEMY_LEVEL_OVERRIDE > 0) {
level = activeOverrides.ENEMY_LEVEL_OVERRIDE;
}
if (activeOverrides.ENEMY_SPECIES_OVERRIDE) {
species = speciesDataRegistry.getSpecies(activeOverrides.ENEMY_SPECIES_OVERRIDE);
// The fact that a Pokemon is a boss or not can change based on its Species and level
boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1;
}
const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource, forRival);
if (activeOverrides.ENEMY_FUSION_OVERRIDE) {
pokemon.generateFusionSpecies();
}
if (boss && !dataSource && !this.gameMode.dailyConfig?.boss?.ivs) {
const secondaryIvs = getIvsFromId(randSeedInt(4294967296));
for (let s = 0; s < pokemon.ivs.length; s++) {
pokemon.ivs[s] = Math.round(
Phaser.Math.Linear(
Math.min(pokemon.ivs[s], secondaryIvs[s]),
Math.max(pokemon.ivs[s], secondaryIvs[s]),
0.75,
),
);
}
}
if (postProcess) {
postProcess(pokemon);
}
if (activeOverrides.ENEMY_IVS_OVERRIDE === null) {
// do nothing
} else if (Array.isArray(activeOverrides.ENEMY_IVS_OVERRIDE)) {
if (activeOverrides.ENEMY_IVS_OVERRIDE.length !== 6) {
throw new Error("The Enemy IVs override must be an array of length 6 or a number!");
}
if (activeOverrides.ENEMY_IVS_OVERRIDE.some(value => !isBetween(value, 0, 31))) {
throw new Error("All IVs in the enemy IV override must be between 0 and 31!");
}
pokemon.ivs = activeOverrides.ENEMY_IVS_OVERRIDE;
} else {
if (!isBetween(activeOverrides.ENEMY_IVS_OVERRIDE, 0, 31)) {
throw new Error("The Enemy IV override must be a value between 0 and 31!");
}
pokemon.ivs = new Array(6).fill(activeOverrides.ENEMY_IVS_OVERRIDE);
}
if (activeOverrides.ENEMY_NATURE_OVERRIDE !== null) {
pokemon.nature = activeOverrides.ENEMY_NATURE_OVERRIDE;
}
pokemon.init();
return pokemon;
}
/**
* Removes a {@linkcode PlayerPokemon} from the party, and clears modifiers for that Pokemon's id
* Useful for MEs/Challenges that remove Pokemon from the player party temporarily or permanently
* @param pokemon
* @param destroy Default true. If true, will destroy the {@linkcode PlayerPokemon} after removing
*/
removePokemonFromPlayerParty(pokemon: PlayerPokemon, destroy = true) {
if (!pokemon) {
return;
}
const partyIndex = this.party.indexOf(pokemon);
this.party.splice(partyIndex, 1);
if (destroy) {
this.field.remove(pokemon, true);
pokemon.destroy();
}
this.updateModifiers(true);
}
addPokemonIcon(
pokemon: Pokemon,
x: number,
y: number,
originX = 0.5,
originY = 0.5,
ignoreOverride = true,
useIllusion = false,
): Phaser.GameObjects.Container {
const container = this.add //
.container(x, y)
.setName(`${pokemon.name}-icon`);
const icon = this.add
.sprite(0, 0, pokemon.getIconAtlasKey(ignoreOverride, useIllusion))
.setName(`sprite-${pokemon.name}-icon`)
.setFrame(pokemon.getIconId(ignoreOverride, useIllusion))
.setOrigin(0.5, 0);
// Temporary fix to show pokemon's default icon if variant icon doesn't exist
if (icon.frame.name !== pokemon.getIconId(ignoreOverride, useIllusion)) {
console.log(`${pokemon.name}'s variant icon does not exist. Replacing with default.`);
const temp = pokemon.shiny;
pokemon.shiny = false;
icon.setTexture(pokemon.getIconAtlasKey(ignoreOverride, useIllusion));
icon.setFrame(pokemon.getIconId(true, useIllusion));
pokemon.shiny = temp;
}
icon.setOrigin(0.5, 0);
container.add(icon);
if (pokemon.isFusion(useIllusion)) {
const fusionIcon = this.add
.sprite(0, 0, pokemon.getFusionIconAtlasKey(ignoreOverride, useIllusion))
.setName("sprite-fusion-icon")
.setOrigin(0.5, 0)
.setFrame(pokemon.getFusionIconId(ignoreOverride, useIllusion));
const originalWidth = icon.width;
const originalHeight = icon.height;
const originalFrame = icon.frame;
const iconHeight = (icon.frame.cutHeight <= fusionIcon.frame.cutHeight ? Math.ceil : Math.floor)(
(icon.frame.cutHeight + fusionIcon.frame.cutHeight) / 4,
);
// Inefficient, but for some reason didn't work with only the unique properties as part of the name
const iconFrameId = `${icon.frame.name}f${fusionIcon.frame.name}`;
if (!icon.frame.texture.has(iconFrameId)) {
icon.frame.texture.add(
iconFrameId,
icon.frame.sourceIndex,
icon.frame.cutX,
icon.frame.cutY,
icon.frame.cutWidth,
iconHeight,
);
}
icon.setFrame(iconFrameId);
fusionIcon.y = icon.frame.cutHeight;
const originalFusionFrame = fusionIcon.frame;
const fusionIconY = fusionIcon.frame.cutY + icon.frame.cutHeight;
const fusionIconHeight = fusionIcon.frame.cutHeight - icon.frame.cutHeight;
// Inefficient, but for some reason didn't work with only the unique properties as part of the name
const fusionIconFrameId = `${fusionIcon.frame.name}f${icon.frame.name}`;
if (!fusionIcon.frame.texture.has(fusionIconFrameId)) {
fusionIcon.frame.texture.add(
fusionIconFrameId,
fusionIcon.frame.sourceIndex,
fusionIcon.frame.cutX,
fusionIconY,
fusionIcon.frame.cutWidth,
fusionIconHeight,
);
}
fusionIcon.setFrame(fusionIconFrameId);
const frameY = (originalFrame.y + originalFusionFrame.y) / 2;
icon.frame.y = frameY;
fusionIcon.frame.y = frameY;
container.add(fusionIcon);
if (originX !== 0.5) {
container.x -= originalWidth * (originX - 0.5);
}
if (originY !== 0) {
container.y -= originalHeight * originY;
}
} else {
if (originX !== 0.5) {
container.x -= icon.width * (originX - 0.5);
}
if (originY !== 0) {
container.y -= icon.height * originY;
}
}
return container;
}
setSeed(seed: string): void {
this.seed = seed;
this.waveCycleOffset = this.getGeneratedWaveCycleOffset();
this.offsetGym = this.gameMode.isClassic && this.getGeneratedOffsetGym();
}
/**
* Generates a random number using the current battle's seed
*
* This calls {@linkcode Battle.randSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle.ts`
* which calls {@linkcode randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`
*
* @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min}
* @param min The minimum integer to pick, default `0`
* @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1)
*/
randBattleSeedInt(range: number, min = 0): number {
return this.currentBattle?.randSeedInt(range, min);
}
// TODO: Break up function - this does far too much in 1 sitting
reset(clearScene = false, clearData = false, reloadI18n = false): void {
if (clearData) {
this.gameData = new GameData();
}
this.turnCommandManager.resetTurnOrder();
this.gameMode = getGameMode(GameModes.CLASSIC);
this.disableMenu = false;
this.score = 0;
this.money = 0;
this.lockModifierTiers = false;
if (activeOverrides.POKEBALL_OVERRIDE.active) {
this.pokeballCounts = activeOverrides.POKEBALL_OVERRIDE.pokeballs;
} else {
// TODO: Remove unused luxury balls and remove the `filter`
this.pokeballCounts = Object.fromEntries(
getEnumValues(PokeballType)
.filter(pt => pt !== PokeballType.LUXURY_BALL)
.map(t => [t, 0]),
);
this.pokeballCounts[PokeballType.POKEBALL] = 5;
}
this.modifiers = [];
this.enemyModifiers = [];
this.modifierBar.removeAll(true);
this.enemyModifierBar.removeAll(true);
for (const p of this.getPlayerParty()) {
p.destroy();
}
this.party = [];
for (const p of this.getEnemyParty()) {
p.destroy();
}
// If this is a ME, clear any residual visual sprites before reloading
if (this.currentBattle?.mysteryEncounter?.introVisuals) {
this.field.remove(this.currentBattle.mysteryEncounter?.introVisuals, true);
}
// Same for trainers, which would otherwise stay on the field into the next run
this.currentBattle?.trainer?.destroy();
this.lastEnemyTrainer?.destroy();
this.lastEnemyTrainer = null;
this.currentBattle = null!; // TODO: this should never be `null`, probably needs multiple scenes
// Reset RNG after end of game or save & quit.
// This needs to happen after clearing this.currentBattle or the seed will be affected by the last wave played
this.setSeed(activeOverrides.SEED_OVERRIDE || randomString(24));
console.log("Seed:", this.seed);
this.resetSeed();
this.biomeWaveText.setText(STARTING_WAVE.toString());
this.biomeWaveText.setVisible(false);
this.updateMoneyText();
this.moneyText.setVisible(false);
this.updateScoreText();
this.scoreText.setVisible(false);
[this.luckLabelText, this.luckText].forEach(t => {
t.setVisible(false);
});
this.newArena(activeOverrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN);
this.field.setVisible(true);
this.arenaBgTransition.setPosition(0, 0);
this.arenaPlayer.setPosition(300, 0);
this.arenaPlayerTransition.setPosition(0, 0);
this.arenaEnemy.setPosition(-280, 0);
this.arenaNextEnemy.setPosition(-280, 0);
this.arenaNextEnemy.setVisible(false);
this.arena.init();
this.trainer.setTexture(`trainer_${settings.isPlayerFemale ? "f" : "m"}_back`);
this.trainer.setPosition(406, 186);
this.trainer.setVisible(true);
this.mysteryEncounterSaveData = new MysteryEncounterSaveData();
this.updateGameInfo();
if (reloadI18n) {
const localizable: Localizable[] = [
...speciesDataRegistry.getAllSpecies(),
...allMoves,
...getEnumValues(ModifierPoolType)
.map(mpt => getModifierPoolForType(mpt))
.flatMap(mp =>
Object.values(mp)
.flat()
.map(mt => mt.modifierType)
.filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"),
),
];
for (const item of localizable) {
item.localize();
}
}
if (clearScene) {
// Reload variant data in case sprite set has changed
this.initVariantData();
audioManager.fadeOutBgm(250);
this.tweens.add({
targets: [this.uiContainer],
alpha: 0,
duration: 250,
ease: "Sine.easeInOut",
onComplete: () => {
this.ui.freeUIData();
this.uiContainer.remove(this.ui, true);
this.uiContainer.destroy();
this.children.removeAll(true);
// TODO: Do we even need this?
this.game.domContainer.innerHTML = "";
// TODO: `launchBattle` calls `reset(false, false, true)`
this.launchBattle();
},
});
}
}
// TODO: Invert the chances for this
private getDoubleBattleChance(newWaveIndex: number): number {
const doubleChance = new NumberHolder(newWaveIndex % 10 === 0 ? 32 : 8);
this.applyModifiers(DoubleBattleChanceBoosterModifier, true, doubleChance);
for (const p of this.getPlayerField()) {
// TODO: This passes `null` to `applyAbAttrs`
applyAbAttrs("DoubleBattleChanceAbAttr", { pokemon: p, chance: doubleChance });
}
return Math.max(doubleChance.value, 1);
}
isNewBiome(currentBattle = this.currentBattle) {
const isWaveIndexMultipleOfTen = !(currentBattle.waveIndex % 10);
const isEndlessOrDaily = this.gameMode.hasShortBiomes || this.gameMode.isDaily;
const isEndlessFifthWave = this.gameMode.hasShortBiomes && currentBattle.waveIndex % 5 === 0;
const isWaveIndexMultipleOfFiftyMinusOne = currentBattle.waveIndex % 50 === 49;
return isWaveIndexMultipleOfTen || isEndlessFifthWave || (isEndlessOrDaily && isWaveIndexMultipleOfFiftyMinusOne);
}
/**
* Create and initialize a new battle.
* @param fromSession - The {@linkcode SessionSaveData} being used to seed the battle. \
* Should be omitted if not loading an existing save file.
* @returns The newly created `Battle` instance.
*/
public newBattle(fromSession?: SessionSaveData): Battle {
const props = this.getNewBattleProps(fromSession);
const { waveIndex, mysteryEncounterType } = props;
const resolved: NewBattleInitialProps = { waveIndex, mysteryEncounterType };
// TODO: This _should_ be safe to move elsewhere but idk
this.resetSeed(waveIndex);
// Set attributes of the `resolved` object based on the type of battle being created.
if (fromSession) {
this.handleSavedBattle(resolved, props);
} else if (this.gameMode.isFixedBattle(waveIndex)) {
this.handleFixedBattle(resolved);
} else {
this.handleNonFixedBattle(resolved);
}
if (resolved.battleType == null) {
throw new Error(
"BattleScene.newBattle lacked battle type information inside new battle config!\nData:\n"
+ JSON.stringify(resolved),
);
}
resolved.double = this.checkIsDouble(resolved as NewBattleConstructedProps);
const lastBattle: Battle | null = this.currentBattle;
const maxExpLevel = this.getMaxExpLevel();
this.lastEnemyTrainer = lastBattle?.trainer ?? null;
this.lastMysteryEncounter = lastBattle?.mysteryEncounter;
// TODO: Is this even needed?
if (lastBattle?.double && !resolved.double) {
this.phaseManager.tryRemovePhase("SwitchPhase");
// TODO: We already do this later in the function
for (const p of this.getPlayerField()) {
p.lapseTag(BattlerTagType.COMMANDED);
}
}
this.executeWithSeedOffset(
() => {
// NB: Type assertion is fine as resolved should always be populated at this point
this.currentBattle = new Battle(this.gameMode, resolved as NewBattleResolvedProps);
},
waveIndex << 3, // TODO: Why use this specific bitshift?
this.waveSeed,
);
this.currentBattle.incrementTurn();
if (!fromSession?.waveIndex && lastBattle) {
this.doPostBattleCleanup(lastBattle, maxExpLevel);
}
return this.currentBattle;
}
/**
* Helper function to {@linkcode BattleScene.newBattle | newBattle} to initialize variables
* with defaults if no session data is provided.
* @param fromSession - The session data being used to initialize the battle
* @returns The new battle props
*/
// TODO: If or when the `resetSeed` call is (re)moved from `newBattle`, move this inline into `handleSavedBattle`
private getNewBattleProps(fromSession?: SessionSaveData): NewBattleSavedProps {
if (fromSession == null) {
return {
battleType: BattleType.WILD,
// Don't increment wave index when computing starting wave
waveIndex:
this.currentBattle == null
? (activeOverrides.STARTING_WAVE_OVERRIDE ?? STARTING_WAVE)
: this.currentBattle.waveIndex + 1,
};
}
const { waveIndex, battleType, trainer: trainerData, mysteryEncounterType: sessionMEType } = fromSession;
// TODO: Remove fallback once we stop using `-1` as a default value for session data fields (which wastes space)
const mysteryEncounterType = sessionMEType === -1 ? undefined : sessionMEType;
let fixedDouble: boolean;
// make sure illegal battle types don't occur due to save data corruption (e.g. from enum shifting)
if (
trainerData?.variant === TrainerVariant.DOUBLE
&& !trainerConfigs[trainerData.trainerType].hasDouble
&& !trainerConfigs[trainerData.trainerType].doubleOnly
) {
trainerData.variant = TrainerVariant.DEFAULT;
fixedDouble = false;
} else if (
trainerData
&& trainerData.variant !== TrainerVariant.DOUBLE
&& trainerConfigs[trainerData.trainerType].doubleOnly
) {
trainerData.variant = TrainerVariant.DOUBLE;
fixedDouble = true;
}
switch (battleType) {
case BattleType.WILD:
fixedDouble = fromSession.enemyParty.length > 1;
break;
case BattleType.TRAINER: {
const config = trainerConfigs[trainerData.trainerType];
fixedDouble = config.doubleOnly || (config.hasDouble && trainerData.variant === TrainerVariant.DOUBLE);
break;
}
case BattleType.MYSTERY_ENCOUNTER:
fixedDouble = false;
break;
}
return {
battleType,
mysteryEncounterType,
waveIndex,
trainerData,
double: fixedDouble,
} satisfies NewBattleSavedProps;
}
/**
* Sub-method of {@linkcode newBattle} that handles fixed trainer battles.
* @param resolved - The object to modify
*/
private handleFixedBattle(resolved: NewBattleInitialProps): void {
const { waveIndex } = resolved;
// Bang is justified as this code is only called when `isFixedBattle` is true
const battleConfig = this.gameMode.getFixedBattle(waveIndex)!;
resolved.double = battleConfig.double;
resolved.battleType = battleConfig.battleType;
// `!` tells TS this will always be defined; necessary due to block scoping from using `executeWithSeedOffset`
let trainer!: Trainer;
this.executeWithSeedOffset(
() => {
trainer = battleConfig.getTrainer();
},
// TODO: This is only used to ensure evil team leaders get pre-generated as the same team, which is EXTREMELY susceptible to internal RNG changes
// Instead, the save data can store the evil team/gym leader preset used upon run creation
(battleConfig.seedOffsetWaveIndex || waveIndex) << 8,
);
this.field.add(trainer);
resolved.trainer = trainer;
}
/**
* Sub-method of {@linkcode newBattle} that handles loading existing saved battles.
* @param resolved - The object to modify
* @param props - The {@linkcode NewBattleSavedProps} created from the save data
*/
private handleSavedBattle(resolved: NewBattleInitialProps, props: NewBattleSavedProps): void {
resolved.battleType = props.battleType;
resolved.double = props.double;
resolved.trainer = props.trainerData?.toTrainer();
if (resolved.trainer) {
this.field.add(resolved.trainer);
}
}
/**
* Sub-method of {@linkcode newBattle} that handles generating a new battle from scratch.
* @param resolved - The object to modify properties of
*/
private handleNonFixedBattle(resolved: NewBattleInitialProps): void {
const { waveIndex } = resolved;
resolved.battleType =
!this.gameMode.hasTrainers || activeOverrides.DISABLE_STANDARD_TRAINERS_OVERRIDE
? BattleType.WILD
: (activeOverrides.BATTLE_TYPE_OVERRIDE
?? (this.gameMode.isWaveTrainer(waveIndex) ? BattleType.TRAINER : BattleType.WILD));
// Check for mystery encounter
// Can only occur in place of a standard (non-boss) wild battle, waves 10-180
// NB: battle type checks are offloaded to `isWaveMysteryEncounter`
// TODO: This means MEs can generate when the override is set to `BattleType.WILD`
if (!activeOverrides.BATTLE_TYPE_OVERRIDE && this.isWaveMysteryEncounter(resolved.battleType, waveIndex)) {
resolved.battleType = BattleType.MYSTERY_ENCOUNTER;
// Reset to base spawn weight
this.mysteryEncounterSaveData.encounterSpawnChance = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT;
return;
}
if (resolved.battleType !== BattleType.TRAINER) {
return;
}
const trainer = this.generateNewBattleTrainer(waveIndex);
this.field.add(trainer);
resolved.trainer = trainer;
}
/**
* Helper function to randomly determine the attributes of a newly generated trainer.
* @param waveIndex - The wave number being generated
* @returns The generated trainer.
*/
private generateNewBattleTrainer(waveIndex: number): Trainer {
const trainerType = activeOverrides.RANDOM_TRAINER_OVERRIDE?.trainerType ?? this.arena.randomTrainerType(waveIndex);
const config = trainerConfigs[trainerType];
let doubleTrainer: boolean;
if (config.doubleOnly) {
doubleTrainer = true;
} else if (
// Add a check that special trainers can't be double except for tate and liza - they should use the normal double chance
// TODO: Review this
!config.hasDouble
|| (config.trainerTypeDouble && ![TrainerType.TATE, TrainerType.LIZA].includes(trainerType))
) {
doubleTrainer = false;
} else {
doubleTrainer = randSeedInt(this.getDoubleBattleChance(waveIndex)) === 0;
}
const overrideVariant = doubleTrainer
? TrainerVariant.DOUBLE
: activeOverrides.RANDOM_TRAINER_OVERRIDE?.trainerVariant;
const variant = overrideVariant ?? (randSeedInt(2) ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT);
return new Trainer(trainerType, variant);
}
/**
* Sub-method of {@linkcode newBattle} that returns whether the new battle is a double battle.
* @param __namedParameters - Needed for typedoc to function
* @returns Whether the battle should be a double battle.
*/
private checkIsDouble({ double: forcedDouble, battleType, waveIndex, trainer }: NewBattleConstructedProps): boolean {
// TODO: enforce using the proper override depending on whether it's a trainer or a wild battle
const doubleBattleOverride = this.doCheckDoubleOverride(waveIndex);
if (doubleBattleOverride != null) {
return doubleBattleOverride;
}
// Edge cases
if (
this.gameMode.isWaveFinal(waveIndex) // Endless bosses and classic mode finales are never double battles
|| this.gameMode.isEndlessBoss(waveIndex)
|| battleType === BattleType.MYSTERY_ENCOUNTER // MEs are never double battles
) {
return false;
}
if (forcedDouble != null) {
return forcedDouble;
}
// Standard wild battle chance
// TODO: Rework the calcs here - this is weird
if (battleType === BattleType.WILD) {
return randSeedInt(this.getDoubleBattleChance(waveIndex)) === 0;
}
return trainer?.variant === TrainerVariant.DOUBLE;
}
/**
* Check the double battle override for the current wave.
* @param waveIndex - The wave number of the newly generated wave
* @returns Whether the wave should be forced into being a double battle.
* Returns `undefined` if the override is `null`.
*/
private doCheckDoubleOverride(waveIndex: number): boolean | undefined {
switch (activeOverrides.FIELD_SIZE_OVERRIDE) {
case "double":
return true;
case "single":
return false;
case "even-doubles":
return waveIndex % 2 === 0;
case "odd-doubles":
return waveIndex % 2 === 1;
default:
activeOverrides.FIELD_SIZE_OVERRIDE satisfies null;
return;
}
}
// TODO: Split this up and move it to a "post battle phase"
private doPostBattleCleanup(lastBattle: Battle, maxExpLevel: number): void {
const isNewBiome = this.isNewBiome(lastBattle);
/** Whether to reset and recall pokemon */
const resetArenaState =
isNewBiome
|| [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.currentBattle.battleType)
|| this.currentBattle.isClassicFinalBoss;
for (const enemyPokemon of this.getEnemyParty()) {
enemyPokemon.destroy();
}
this.trySpreadPokerus();
if (!isNewBiome && this.currentBattle.waveIndex % 10 === 5) {
this.arena.updatePoolsForTimeOfDay();
}
// use the old value of `double` to ensure both combatants are handled properly when going from double to single battles
const playerField = this.getPlayerParty().slice(0, 1 + Number(lastBattle.double));
if (resetArenaState) {
// Remove the tag so that Tatsugiri is ejected, and will be recalled correctly
for (const pokemon of playerField) {
pokemon.lapseTag(BattlerTagType.COMMANDED);
}
}
// Recalls, repositions and sends out the player's Pokemon as needed for the new battle
this.phaseManager.unshiftNew("PartyReorderSwitchPhase", resetArenaState);
if (resetArenaState) {
this.arena.resetArenaEffects();
for (const pokemon of this.getPlayerParty()) {
pokemon.resetBattleAndWaveData();
pokemon.resetTera();
applyAbAttrs("PostBattleInitAbAttr", { pokemon });
// Terapagos resets tera on each fight
if (pokemon.hasSpecies(SpeciesId.TERAPAGOS)) {
this.arena.playerTerasUsed = 0;
}
}
if (!this.trainer.visible) {
this.phaseManager.pushNew("ShowTrainerPhase");
}
}
for (const pokemon of this.getPlayerParty()) {
this.triggerPokemonFormChange(pokemon, SpeciesFormChangeTimeOfDayTrigger);
}
if (!this.gameMode.hasRandomBiomes && !isNewBiome) {
this.phaseManager.pushNew("NextEncounterPhase");
} else {
this.phaseManager.pushNew("NewBiomeEncounterPhase");
const newMaxExpLevel = this.getMaxExpLevel();
if (newMaxExpLevel > maxExpLevel) {
this.phaseManager.pushNew("LevelCapPhase");
}
}
}
newArena(biome: BiomeId, playerFaints = 0): Arena {
this.arena = new Arena(biome, playerFaints);
this.eventTarget.dispatchEvent(new NewArenaEvent());
this.arenaBg.renderNodeData[globalScene.fieldSpriteSubmitter.name] = {
terrainColorRatio: this.arena.bgTerrainColorRatioForBiome,
};
return this.arena;
}
/**
* Loads the visual assets for a given biome, including background, arena layers, and props. \
* If the assets are already in the texture cache, it resolves immediately.
* @param biome - The {@linkcode BiomeId} of the biome to load assets for
* @returns A promise that resolves when the assets have finished loading
*/
public async loadBiomeAssets(biome: BiomeId): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
const btKey = getBiomeKey(biome);
// Already in texture cache — nothing to load
if (this.textures.exists(`${btKey}_bg`)) {
resolve();
return promise;
}
const isBaseAnimated = btKey === "end";
const baseAKey = `${btKey}_a`;
const baseBKey = `${btKey}_b`;
this.loadImage(`${btKey}_bg`, "arenas");
if (isBaseAnimated) {
this.loadAtlas(baseAKey, "arenas") //
.loadAtlas(baseBKey, "arenas");
} else {
this.loadImage(baseAKey, "arenas") //
.loadImage(baseBKey, "arenas");
}
if (getBiomeHasProps(biome)) {
for (let p = 1; p <= 3; p++) {
const isPropAnimated = p === 3 && ["power_plant", "end"].includes(btKey);
const propKey = `${btKey}_b_${p}`;
if (isPropAnimated) {
this.loadAtlas(propKey, "arenas");
} else {
this.loadImage(propKey, "arenas");
}
}
}
this.load.once(Phaser.Loader.Events.COMPLETE, resolve);
this.load.start();
return promise;
}
/**
* Clears the visual assets for a given biome from the texture cache to free up memory. \
* The "TOWN" biome is exempt from clearing as it is the base biome.
* @param biome - The {@linkcode BiomeId} of the biome to clear assets for
*/
public clearBiomeAssets(biome: BiomeId): void {
const btKey = getBiomeKey(biome);
// Don't clear TOWN — it's the starting biome
if (btKey === "town") {
return;
}
const keysToClear = [`${btKey}_bg`, `${btKey}_a`, `${btKey}_b`];
if (getBiomeHasProps(biome)) {
for (let p = 1; p <= 3; p++) {
keysToClear.push(`${btKey}_b_${p}`);
}
}
for (const key of keysToClear) {
if (this.anims.exists(key)) {
console.log(`Removing animation for key ${key}..`);
this.anims.remove(key);
}
if (this.textures.exists(key)) {
this.textures.remove(key);
}
}
}
updateFieldScale(): Promise<void> {
return new Promise(resolve => {
const fieldScale =
Math.floor(
Math.pow(
1
/ this.getField(true)
.map(p => p.getSpriteScale())
.reduce((highestScale: number, scale: number) => (highestScale = Math.max(scale, highestScale)), 0),
0.7,
) * 40,
) / 40;
this.setFieldScale(fieldScale).then(() => resolve());
});
}
setFieldScale(scale: number, instant = false): Promise<void> {
return new Promise(resolve => {
scale *= 6;
if (this.field.scale === scale) {
return resolve();
}
const defaultWidth = this.arenaBg.width * 6;
const defaultHeight = 132 * 6;
const scaledWidth = this.arenaBg.width * scale;
const scaledHeight = 132 * scale;
this.tweens.add({
targets: this.field,
scale,
x: (defaultWidth - scaledWidth) / 2,
y: defaultHeight - scaledHeight,
duration: instant ? 0 : fixedInt(Math.abs(this.field.scale - scale) * 200),
ease: "Sine.easeInOut",
onComplete: () => resolve(),
});
});
}
// TODO: break this up
public getSpeciesFormIndex(species: PokemonSpecies, gender?: Gender, nature?: Nature, ignoreArena = false): number {
if (species.forms == null) {
console.warn(`Form data missing for ${species.name}!\n`, species);
return 0;
}
if (species.forms.length === 0) {
return 0;
}
const isEggPhase =
this.phaseManager.getCurrentPhase().is("EggLapsePhase")
|| this.phaseManager.getCurrentPhase().is("EggHatchPhase");
const isTrainerBattle = this.currentBattle?.battleType === BattleType.TRAINER;
// Give trainers with specialty types an appropriately-typed form for Wormadam, Rotom, Arceus, Oricorio, Silvally, or Paldean Tauros.
if (!isEggPhase && isTrainerBattle && this.currentBattle.trainer?.config.hasSpecialtyType()) {
if (species.speciesId === SpeciesId.WORMADAM) {
switch (this.currentBattle.trainer.config.specialtyType) {
case PokemonType.GROUND:
return 1; // Sandy Cloak
case PokemonType.STEEL:
return 2; // Trash Cloak
case PokemonType.GRASS:
return 0; // Plant Cloak
}
}
if (species.speciesId === SpeciesId.ROTOM) {
switch (this.currentBattle.trainer.config.specialtyType) {
case PokemonType.FLYING:
return 4; // Fan Rotom
case PokemonType.GHOST:
return 0; // Lightbulb Rotom
case PokemonType.FIRE:
return 1; // Heat Rotom
case PokemonType.WATER:
return 2; // Wash Rotom
case PokemonType.GRASS:
return 5; // Mow Rotom
case PokemonType.ICE:
return 3; // Frost Rotom
}
}
if (species.speciesId === SpeciesId.ORICORIO) {
switch (this.currentBattle.trainer.config.specialtyType) {
case PokemonType.GHOST:
return 3; // Sensu Style
case PokemonType.FIRE:
return 0; // Baile Style
case PokemonType.ELECTRIC:
return 1; // Pom-Pom Style
case PokemonType.PSYCHIC:
return 2; // Pa'u Style
}
}
if (species.speciesId === SpeciesId.PALDEA_TAUROS) {
switch (this.currentBattle.trainer.config.specialtyType) {
case PokemonType.FIRE:
return 1; // Blaze Breed
case PokemonType.WATER:
return 2; // Aqua Breed
}
}
if (species.speciesId === SpeciesId.SILVALLY || species.speciesId === SpeciesId.ARCEUS) {
// Would probably never happen, but might as well
return this.currentBattle.trainer.config.specialtyType;
}
}
switch (species.speciesId) {
case SpeciesId.UNOWN:
case SpeciesId.SHELLOS:
case SpeciesId.GASTRODON:
case SpeciesId.ROTOM:
case SpeciesId.BASCULIN:
case SpeciesId.DEERLING:
case SpeciesId.SAWSBUCK:
case SpeciesId.SCATTERBUG:
case SpeciesId.SPEWPA:
case SpeciesId.VIVILLON:
case SpeciesId.FLABEBE:
case SpeciesId.FLOETTE:
case SpeciesId.FLORGES:
case SpeciesId.FURFROU:
case SpeciesId.PUMPKABOO:
case SpeciesId.GOURGEIST:
case SpeciesId.ORICORIO:
case SpeciesId.ZARUDE:
case SpeciesId.SQUAWKABILLY:
case SpeciesId.PALDEA_TAUROS:
return randSeedInt(species.forms.length);
case SpeciesId.SINISTEA:
case SpeciesId.POLTEAGEIST:
case SpeciesId.MAUSHOLD:
case SpeciesId.DUDUNSPARCE:
case SpeciesId.POLTCHAGEIST:
case SpeciesId.SINISTCHA:
return randSeedInt(16) ? 0 : 1;
case SpeciesId.PICHU:
return randSeedInt(8) ? 0 : 1;
case SpeciesId.PIKACHU:
if (isTrainerBattle && this.currentBattle?.waveIndex < 30) {
return 0; // Ban Cosplay and Partner Pika from Trainers before wave 30
}
return randSeedInt(8);
case SpeciesId.EEVEE:
if (isTrainerBattle && this.currentBattle?.waveIndex < 30 && !isEggPhase) {
return 0; // No Partner Eevee for Wave 12 Preschoolers
}
return randSeedInt(2);
case SpeciesId.MAGEARNA:
case SpeciesId.URSHIFU:
return randSeedInt(2);
case SpeciesId.TATSUGIRI:
return randSeedInt(3);
case SpeciesId.ZYGARDE:
return randSeedInt(4);
case SpeciesId.MINIOR:
return randSeedInt(7);
case SpeciesId.ALCREMIE:
return randSeedInt(9);
case SpeciesId.MEOWSTIC:
case SpeciesId.INDEEDEE:
case SpeciesId.BASCULEGION:
case SpeciesId.OINKOLOGNE:
return gender === Gender.FEMALE ? 1 : 0;
case SpeciesId.TOXTRICITY: {
const lowkeyNatures: readonly Nature[] = [
Nature.LONELY,
Nature.BOLD,
Nature.RELAXED,
Nature.TIMID,
Nature.SERIOUS,
Nature.MODEST,
Nature.MILD,
Nature.QUIET,
Nature.BASHFUL,
Nature.CALM,
Nature.GENTLE,
Nature.CAREFUL,
];
if (nature !== undefined && lowkeyNatures.includes(nature)) {
return 1;
}
return 0;
}
case SpeciesId.GIMMIGHOUL:
// In game modes with MEs (currently Classic only),
// chest form Gimmighoul is only allowed to appear in the Mysterious Chest Encounter
if (this.gameMode.hasMysteryEncounters && !isEggPhase) {
return 1; // Wandering form
}
return randSeedInt(species.forms.length);
case SpeciesId.BURMY:
case SpeciesId.WORMADAM:
if (ignoreArena) {
return randSeedInt(species.forms.length);
}
switch (this.arena.biomeId) {
case BiomeId.BEACH:
return 1;
case BiomeId.SLUM:
return 2;
}
return 0;
case SpeciesId.LYCANROC:
if (ignoreArena) {
return randSeedInt(species.forms.length);
}
switch (this.arena.getTimeOfDay()) {
case TimeOfDay.DAWN:
case TimeOfDay.DAY:
return 0;
case TimeOfDay.DUSK:
return 2;
case TimeOfDay.NIGHT:
return 1;
}
return 0;
}
return 0;
}
private getGeneratedOffsetGym(): boolean {
let ret = false;
this.executeWithSeedOffset(
() => {
ret = !randSeedInt(2);
},
0,
this.seed.toString(),
);
return ret;
}
private getGeneratedWaveCycleOffset(): number {
let ret = 0;
this.executeWithSeedOffset(
() => {
ret = randSeedInt(8) * 5;
},
0,
this.seed.toString(),
);
return ret;
}
getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number {
if (activeOverrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1) {
return activeOverrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE;
}
if (activeOverrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE === 1) {
// The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss
return 0;
}
if (this.gameMode.isDaily && this.gameMode.isWaveFinal(waveIndex)) {
if (this.gameMode.dailyConfig?.boss?.segments != null) {
return this.gameMode.dailyConfig.boss.segments;
}
return 5;
}
let isBoss: boolean | undefined;
if (forceBoss || (species && (species.subLegendary || species.legendary || species.mythical))) {
isBoss = true;
} else {
this.executeWithSeedOffset(() => {
isBoss =
waveIndex % 10 === 0
|| (this.gameMode.hasRandomBosses
&& randSeedInt(100) < Math.min(Math.max(Math.ceil((waveIndex - 250) / 50), 0) * 2, 30));
}, waveIndex << 2);
}
if (!isBoss) {
return 0;
}
let ret = 2;
if (level >= 100) {
ret++;
}
if (species && species.baseTotal >= 670) {
ret++;
}
ret += Math.floor(waveIndex / 250);
return ret;
}
trySpreadPokerus(): void {
const party = this.getPlayerParty();
const infectedIndexes: number[] = [];
const spread = (index: number, spreadTo: number) => {
const partyMember = party[index + spreadTo];
if (!partyMember.pokerus && !randSeedInt(10)) {
partyMember.pokerus = true;
infectedIndexes.push(index + spreadTo);
}
};
party.forEach((pokemon, p) => {
if (!pokemon.pokerus || infectedIndexes.indexOf(p) > -1) {
return;
}
this.executeWithSeedOffset(
() => {
if (p) {
spread(p, -1);
}
if (p < party.length - 1) {
spread(p, 1);
}
},
this.currentBattle.waveIndex + (p << 8),
);
});
}
// TODO: Refactor this and other RNG functions - these dearly need help
resetSeed(waveIndex?: number): void {
const wave = waveIndex ?? this.currentBattle?.waveIndex ?? 0;
this.waveSeed = shiftCharCodes(this.seed, wave);
Phaser.Math.RND.sow([this.waveSeed]);
console.log("Wave Seed:", this.waveSeed, wave);
}
executeWithSeedOffset(func: () => void, offset: number, seedOverride?: string): void {
if (!func) {
return;
}
const tempRngOffset = this.rngOffset;
const tempRngSeedOverride = this.rngSeedOverride;
const state = Phaser.Math.RND.state();
Phaser.Math.RND.sow([shiftCharCodes(seedOverride || this.seed, offset)]);
this.rngOffset = offset;
this.rngSeedOverride = seedOverride || "";
func();
Phaser.Math.RND.state(state);
this.rngOffset = tempRngOffset;
this.rngSeedOverride = tempRngSeedOverride;
}
addFieldSprite(
x: number,
y: number,
texture: string | Phaser.Textures.Texture,
frame?: string | number,
terrainColorRatio = 0,
): Phaser.GameObjects.Sprite {
return this.add
.sprite(x, y, texture, frame)
.setRenderNodeRole("Submitter", this.fieldSpriteSubmitter, terrainColorRatio ? { terrainColorRatio } : undefined)
.setRenderNodeRole("BatchHandler", this.fieldSpriteBatchHandler);
}
addPokemonSprite(
pokemon: Pokemon,
x: number,
y: number,
texture: string | Phaser.Textures.Texture,
frame?: string | number,
hasShadow = false,
ignoreOverride = false,
): Phaser.GameObjects.Sprite {
const ret = this.addFieldSprite(x, y, texture, frame);
this.initPokemonSprite(ret, pokemon, hasShadow, ignoreOverride);
return ret;
}
initPokemonSprite(
sprite: Phaser.GameObjects.Sprite,
pokemon?: Pokemon,
hasShadow = false,
ignoreOverride = false,
): Phaser.GameObjects.Sprite {
sprite
.setRenderNodeRole("Submitter", this.spriteSubmitter, {
hasShadow,
ignoreOverride,
teraColor: pokemon ? getTypeRgb(pokemon.getTeraType()) : undefined,
isTerastallized: pokemon ? pokemon.isTerastallized : false,
})
.setRenderNodeRole("BatchHandler", this.spriteBatchHandler);
this.spriteTeraSparkleHandler.add(sprite);
return sprite;
}
moveBelowOverlay<T extends Phaser.GameObjects.GameObject>(gameObject: T) {
this.fieldUI.moveBelow<any>(gameObject, this.fieldOverlay);
}
processInfoButton(pressed: boolean): void {
this.arenaFlyout.toggleFlyout(pressed);
}
public async showFieldOverlay(duration: number): Promise<void> {
await playTween(
{
targets: this.fieldOverlay,
alpha: 0.5,
ease: "Sine.easeOut",
duration,
},
this,
);
}
public async hideFieldOverlay(duration: number): Promise<void> {
await playTween(
{
targets: this.fieldOverlay,
alpha: 0,
duration,
ease: "Cubic.easeIn",
},
this,
);
}
public updateShopOverlayOpacity(value: number): void {
if (this.shopOverlayShown) {
this.shopOverlay.setAlpha(value);
}
}
public async showShopOverlay(duration: number): Promise<void> {
this.shopOverlayShown = true;
await playTween({
targets: this.shopOverlay,
alpha: settings.display.shopOverlayOpacity,
ease: "Sine.easeOut",
duration,
});
}
public async hideShopOverlay(duration: number): Promise<void> {
this.shopOverlayShown = false;
await playTween({ targets: this.shopOverlay, alpha: 0, duration, ease: "Cubic.easeIn" });
}
showEnemyModifierBar(): void {
this.enemyModifierBar.setVisible(true);
}
hideEnemyModifierBar(): void {
this.enemyModifierBar.setVisible(false);
}
updateBiomeWaveText(): void {
const isBoss = !(this.currentBattle.waveIndex % 10);
const biomeString: string = getBiomeName(this.arena.biomeId);
this.fieldUI.moveAbove(this.biomeWaveText, this.luckText);
this.biomeWaveText
.setText(biomeString + " - " + this.currentBattle.waveIndex.toString())
.setColor(isBoss ? "#f89890" : "#ffffff")
.setShadowColor(isBoss ? "#984038" : "#636363")
.setVisible(true);
}
updateMoneyText(forceVisible = true): void {
if (this.money === undefined) {
return;
}
const formattedMoney = formatMoney(settings.display.moneyFormat, this.money);
this.moneyText.setText(i18next.t("battleScene:moneyOwned", { formattedMoney }));
this.fieldUI.moveAbove(this.moneyText, this.luckText);
if (forceVisible) {
this.moneyText.setVisible(true);
}
}
animateMoneyChanged(positiveChange: boolean): void {
if (this.tweens.getTweensOf(this.moneyText).length > 0) {
return;
}
const deltaScale = this.moneyText.scale * 0.14 * +positiveChange;
this.moneyText.setShadowColor(positiveChange ? "#008000" : "#FF0000");
this.tweens.add({
targets: this.moneyText,
duration: 250,
scale: this.moneyText.scale + deltaScale,
loop: 0,
yoyo: true,
onComplete: _ => this.moneyText.setShadowColor(getTextColor(TextStyle.MONEY, true)),
});
}
updateScoreText(): void {
const { score } = this;
this.scoreText // formatting
.setText(i18next.t("battleScene:score", { score }))
.setVisible(this.gameMode.isDaily);
}
/**
* Displays the current luck value.
* @param duration The time for this label to fade in, if it is not already visible.
*/
updateAndShowText(duration: number): void {
const labels = [this.luckLabelText, this.luckText];
for (const label of labels) {
label.setAlpha(0);
}
const luckValue = getPartyLuckValue(this.getPlayerParty());
this.luckText.setText(getLuckString(luckValue));
if (luckValue < 14) {
this.luckText.setTint(getLuckTextTint(luckValue));
} else {
this.luckText.setTint(...RAINBOW_TINT);
}
this.luckLabelText.setX(this.scaledCanvas.width - 2 - (this.luckText.displayWidth + 2));
this.tweens.add({
targets: labels,
duration,
alpha: 1,
onComplete: () => {
for (const label of labels) {
label.setVisible(true);
}
},
});
}
hideLuckText(duration: number): void {
if (this.reroll) {
return;
}
const labels = [this.luckLabelText, this.luckText];
this.tweens.add({
targets: labels,
duration,
alpha: 0,
onComplete: () => {
for (const label of labels) {
label.setVisible(false);
}
},
});
}
updateUIPositions(): void {
const enemyModifierCount = this.enemyModifiers.filter(m => m.isIconVisible()).length;
const biomeWaveTextHeight = this.biomeWaveText.getBottomLeft().y - this.biomeWaveText.getTopLeft().y;
this.biomeWaveText.setY(
-this.scaledCanvas.height
+ (enemyModifierCount ? (enemyModifierCount <= 12 ? 15 : 24) : 0)
+ biomeWaveTextHeight / 2,
);
this.moneyText.setY(this.biomeWaveText.y + 10);
this.scoreText.setY(this.moneyText.y + 10);
[this.luckLabelText, this.luckText].map(l =>
l.setY((this.scoreText.visible ? this.scoreText : this.moneyText).y + 10),
);
const offsetY = (this.scoreText.visible ? this.scoreText : this.moneyText).y + 15;
this.partyExpBar.setY(offsetY);
this.candyBar.setY(offsetY + 15);
this.ui?.achvBar.setY(this.scaledCanvas.height + offsetY);
}
/**
* Pushes all {@linkcode Phaser.GameObjects.Text} objects in the top right to the bottom of the canvas
*/
sendTextToBack(): void {
this.fieldUI.sendToBack(this.biomeWaveText);
this.fieldUI.sendToBack(this.moneyText);
this.fieldUI.sendToBack(this.scoreText);
}
addFaintedEnemyScore(enemy: EnemyPokemon): void {
let scoreIncrease =
enemy.getSpeciesForm().getBaseExp()
* (enemy.level / this.getMaxExpLevel())
* ((enemy.ivs.reduce((iv: number, total: number) => (total += iv), 0) / 93) * 0.2 + 0.8);
this.findModifiers(m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemy.id, false).map(
m => (scoreIncrease *= (m as PokemonHeldItemModifier).getScoreMultiplier()),
);
if (enemy.isBoss()) {
scoreIncrease *= Math.sqrt(enemy.bossSegments);
}
this.currentBattle.battleScore += Math.ceil(scoreIncrease);
}
getMaxExpLevel(ignoreLevelCap = false): number {
const capOverride = activeOverrides.LEVEL_CAP_OVERRIDE ?? 0;
if (capOverride > 0) {
return capOverride;
}
if (ignoreLevelCap || capOverride < 0) {
return Number.MAX_SAFE_INTEGER;
}
const waveIndex = Math.ceil((this.currentBattle?.waveIndex || 1) / 10) * 10;
const difficultyWaveIndex = this.gameMode.getWaveForDifficulty(waveIndex);
const baseLevel = (1 + difficultyWaveIndex / 2 + Math.pow(difficultyWaveIndex / 25, 2)) * 1.2;
return Math.ceil(baseLevel / 2) * 2 + 2;
}
randomSpecies(
waveIndex: number,
level: number,
fromArenaPool = false,
speciesFilter?: PokemonSpeciesFilter,
filterAllEvolutions = false,
): PokemonSpecies {
if (fromArenaPool) {
return this.arena.randomSpecies(waveIndex, level, 0, getPartyLuckValue(this.party));
}
// TODO: simplify this?
const filteredSpecies = speciesFilter
? [
...new Set(
speciesDataRegistry
.getAllSpecies()
.filter(s => s.isCatchable() && speciesFilter(s))
.map(s => {
if (!filterAllEvolutions) {
while (speciesDataRegistry.hasPrevolution(s.speciesId)) {
s = speciesDataRegistry.getSpecies(speciesDataRegistry.getPrevolution(s.speciesId)!);
}
}
return s;
}),
),
]
: // TODO: Why is `filterAllEvolutions` only checked if there is a speciesFilter?
speciesDataRegistry.getAllSpecies().filter(s => s.isCatchable());
return randSeedItem(filteredSpecies);
}
generateRandomBiome(waveIndex: number): BiomeId {
const relWave = waveIndex % 250;
const biomes = Object.values(BiomeId).filter(b => b !== BiomeId.TOWN && b !== BiomeId.END);
const maxDepth = biomeDepths[BiomeId.END][0] - 2;
const depthWeights = new Array(maxDepth + 1)
.fill(null)
.map((_, i: number) => ((1 - Math.min(Math.abs(i / (maxDepth - 1) - relWave / 250) + 0.25, 1)) / 0.75) * 250);
const biomeThresholds: number[] = [];
let totalWeight = 0;
for (const biome of biomes) {
totalWeight += Math.ceil(depthWeights[biomeDepths[biome][0] - 1] / biomeDepths[biome][1]);
biomeThresholds.push(totalWeight);
}
const randInt = randSeedInt(totalWeight);
for (let i = 0; i < biomes.length; i++) {
if (randInt < biomeThresholds[i]) {
return biomes[i];
}
}
return randSeedItem(biomes);
}
toggleInvert(invert: boolean): void {
this.invertFilter?.setActive(invert);
}
addMoney(amount: number): void {
this.money = Math.min(this.money + amount, Number.MAX_SAFE_INTEGER);
this.updateMoneyText();
this.animateMoneyChanged(true);
this.validateAchvs(MoneyAchv);
}
getWaveMoneyAmount(moneyMultiplier: number): number {
const waveIndex = this.currentBattle.waveIndex;
const waveSetIndex = Math.ceil(waveIndex / 10) - 1;
const moneyValue =
Math.pow((waveSetIndex + 1 + (0.75 + (((waveIndex - 1) % 10) + 1) / 10)) * 100, 1 + 0.005 * waveSetIndex)
* moneyMultiplier;
return Math.floor(moneyValue / 10) * 10;
}
addModifier(
modifier: Modifier | null,
ignoreUpdate?: boolean,
playSound?: boolean,
virtual?: boolean,
instant?: boolean,
cost?: number,
): boolean {
// We check against modifier.type to stop a bug related to loading in a pokemon that has a form change item, which prior to some patch
// that changed form change modifiers worked, had previously set the `type` field to null.
// TODO: This is not the right place to check for this; it should ideally go in a session migrator.
if (!modifier || !modifier.type) {
return false;
}
let success = false;
const soundName = modifier.type.soundName;
this.validateAchvs(ModifierAchv, modifier);
const modifiersToRemove: PersistentModifier[] = [];
if (modifier instanceof PersistentModifier) {
if ((modifier as PersistentModifier).add(this.modifiers, !!virtual)) {
if (modifier instanceof PokemonFormChangeItemModifier) {
const pokemon = this.getPokemonById(modifier.pokemonId);
if (pokemon) {
success = modifier.apply(pokemon, true);
}
}
if (playSound && !this.sound.get(soundName)) {
audioManager.playSound(soundName);
}
} else if (!virtual) {
const defaultModifierType = getDefaultModifierTypeForTier(modifier.type.tier);
this.phaseManager.queueMessage(
i18next.t("battle:itemStackFull", {
fullItemName: modifier.type.name,
itemName: defaultModifierType.name,
}),
undefined,
false,
3000,
);
return this.addModifier(defaultModifierType.newModifier(), ignoreUpdate, playSound, false, instant);
}
for (const rm of modifiersToRemove) {
this.removeModifier(rm);
}
if (!ignoreUpdate && !virtual) {
this.updateModifiers(true, instant);
}
} else if (modifier instanceof ConsumableModifier) {
if (playSound && !this.sound.get(soundName)) {
audioManager.playSound(soundName);
}
if (modifier instanceof ConsumablePokemonModifier) {
for (const p in this.party) {
const pokemon = this.party[p];
const args: unknown[] = [];
if (modifier instanceof PokemonHpRestoreModifier) {
if ((modifier as PokemonHpRestoreModifier).fainted) {
args.push(1);
} else {
const hpRestoreMultiplier = new NumberHolder(1);
this.applyModifiers(HealingBoosterModifier, true, hpRestoreMultiplier);
args.push(hpRestoreMultiplier.value);
}
} else if (modifier instanceof FusePokemonModifier) {
args.push(this.getPokemonById(modifier.fusePokemonId) as PlayerPokemon);
} else if (modifier instanceof RememberMoveModifier && cost != null) {
args.push(cost);
}
if (modifier.shouldApply(pokemon, ...args)) {
const result = modifier.apply(pokemon, ...args);
success ||= result;
}
}
this.party.forEach(p => {
p.updateInfo(instant);
});
} else {
const args = [this];
if (modifier.shouldApply(...args)) {
const result = modifier.apply(...args);
success ||= result;
}
}
}
return success;
}
addEnemyModifier(modifier: PersistentModifier, ignoreUpdate?: boolean, instant?: boolean): Promise<void> {
return new Promise(resolve => {
const modifiersToRemove: PersistentModifier[] = [];
if ((modifier as PersistentModifier).add(this.enemyModifiers, false)) {
if (modifier instanceof PokemonFormChangeItemModifier) {
const pokemon = this.getPokemonById(modifier.pokemonId);
if (pokemon) {
modifier.apply(pokemon, true);
}
}
for (const rm of modifiersToRemove) {
this.removeModifier(rm, true);
}
}
if (!ignoreUpdate) {
this.updateModifiers(false, instant);
}
resolve();
});
}
/**
* Try to transfer a held item to another pokemon.
* If the recepient already has the maximum amount allowed for this item, the transfer is cancelled.
* The quantity to transfer is automatically capped at how much the recepient can take before reaching the maximum stack size for the item.
* A transfer that moves a quantity smaller than what is specified in the transferQuantity parameter is still considered successful.
* @param itemModifier {@linkcode PokemonHeldItemModifier} item to transfer (represents the whole stack)
* @param target {@linkcode Pokemon} recepient in this transfer
* @param playSound `true` to play a sound when transferring the item
* @param transferQuantity How many items of the stack to transfer. Optional, defaults to `1`
* @param instant ??? (Optional)
* @param ignoreUpdate ??? (Optional)
* @param itemLost If `true`, treat the item's current holder as losing the item (for now, this simply enables Unburden). Default is `true`.
* @returns `true` if the transfer was successful
*/
tryTransferHeldItemModifier(
itemModifier: PokemonHeldItemModifier,
target: Pokemon,
playSound: boolean,
transferQuantity = 1,
instant?: boolean,
ignoreUpdate?: boolean,
itemLost = true,
): boolean {
const source = itemModifier.pokemonId ? itemModifier.getPokemon() : null;
const cancelled = new BooleanHolder(false);
if (source && source.isPlayer() !== target.isPlayer()) {
applyAbAttrs("BlockItemTheftAbAttr", { pokemon: source, cancelled });
}
if (cancelled.value) {
return false;
}
const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier;
newItemModifier.pokemonId = target.id;
const matchingModifier = this.findModifier(
m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id,
target.isPlayer(),
) as PokemonHeldItemModifier;
if (matchingModifier) {
const maxStackCount = matchingModifier.getMaxStackCount();
if (matchingModifier.stackCount >= maxStackCount) {
return false;
}
const countTaken = Math.min(
transferQuantity,
itemModifier.stackCount,
maxStackCount - matchingModifier.stackCount,
);
itemModifier.stackCount -= countTaken;
newItemModifier.stackCount = matchingModifier.stackCount + countTaken;
} else {
const countTaken = Math.min(transferQuantity, itemModifier.stackCount);
itemModifier.stackCount -= countTaken;
newItemModifier.stackCount = countTaken;
}
const removeOld = itemModifier.stackCount === 0;
if (!removeOld || !source || this.removeModifier(itemModifier, source.isEnemy())) {
const addModifier = () => {
if (!matchingModifier || this.removeModifier(matchingModifier, target.isEnemy())) {
if (target.isPlayer()) {
this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant);
if (source && itemLost) {
applyAbAttrs("PostItemLostAbAttr", { pokemon: source });
}
return true;
}
this.addEnemyModifier(newItemModifier, ignoreUpdate, instant);
if (source && itemLost) {
applyAbAttrs("PostItemLostAbAttr", { pokemon: source });
}
return true;
}
return false;
};
if (source && source.isPlayer() !== target.isPlayer() && !ignoreUpdate) {
this.updateModifiers(source.isPlayer(), instant);
addModifier();
} else {
addModifier();
}
return true;
}
return false;
}
/**
* Attempt to discard one or more copies of a held item.
* @param itemModifier - The {@linkcode PokemonHeldItemModifier} being discarded
* @param discardQuantity - The number of copies to remove (up to the amount currently held); default `1`
* @returns Whether the item was successfully discarded.
* Removing fewer items than requested is still considered a success.
*/
tryDiscardHeldItemModifier(itemModifier: PokemonHeldItemModifier, discardQuantity = 1): boolean {
const countTaken = Math.min(discardQuantity, itemModifier.stackCount);
itemModifier.stackCount -= countTaken;
if (itemModifier.stackCount > 0) {
return true;
}
return this.removeModifier(itemModifier);
}
canTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, transferQuantity = 1): boolean {
const mod = itemModifier.clone() as PokemonHeldItemModifier;
const source = mod.pokemonId ? mod.getPokemon() : null;
const cancelled = new BooleanHolder(false);
if (source && source.isPlayer() !== target.isPlayer()) {
applyAbAttrs("BlockItemTheftAbAttr", { pokemon: source, cancelled });
}
if (cancelled.value) {
return false;
}
const matchingModifier = this.findModifier(
m => m instanceof PokemonHeldItemModifier && m.matchType(mod) && m.pokemonId === target.id,
target.isPlayer(),
) as PokemonHeldItemModifier;
if (matchingModifier) {
const maxStackCount = matchingModifier.getMaxStackCount();
if (matchingModifier.stackCount >= maxStackCount) {
return false;
}
const countTaken = Math.min(transferQuantity, mod.stackCount, maxStackCount - matchingModifier.stackCount);
mod.stackCount -= countTaken;
} else {
const countTaken = Math.min(transferQuantity, mod.stackCount);
mod.stackCount -= countTaken;
}
const removeOld = mod.stackCount === 0;
return !removeOld || !source || this.hasModifier(itemModifier, !source.isPlayer());
}
removePartyMemberModifiers(partyMemberIndex: number): Promise<void> {
return new Promise(resolve => {
const pokemonId = this.getPlayerParty()[partyMemberIndex].id;
const modifiersToRemove = this.modifiers.filter(
m => m instanceof PokemonHeldItemModifier && (m as PokemonHeldItemModifier).pokemonId === pokemonId,
);
for (const m of modifiersToRemove) {
this.modifiers.splice(this.modifiers.indexOf(m), 1);
}
this.updateModifiers();
resolve();
});
}
generateEnemyModifiers(heldModifiersConfigs?: HeldModifierConfig[][]): Promise<void> {
return new Promise(resolve => {
if (this.currentBattle.isClassicFinalBoss) {
return resolve();
}
const difficultyWaveIndex = this.gameMode.getWaveForDifficulty(this.currentBattle.waveIndex);
const isFinalBoss = this.gameMode.isWaveFinal(this.currentBattle.waveIndex);
let chances = Math.ceil(difficultyWaveIndex / 10);
if (isFinalBoss) {
chances = Math.ceil(chances * 2.5);
}
const party = this.getEnemyParty();
if (this.currentBattle.trainer) {
const modifiers = this.currentBattle.trainer.genModifiers(party);
for (const modifier of modifiers) {
this.addEnemyModifier(modifier, true, true);
}
}
party.forEach((enemyPokemon: EnemyPokemon, i: number) => {
if (heldModifiersConfigs && i < heldModifiersConfigs.length && heldModifiersConfigs[i]) {
for (const mt of heldModifiersConfigs[i]) {
let modifier: PokemonHeldItemModifier;
if (mt.modifier instanceof PokemonHeldItemModifierType) {
modifier = mt.modifier.newModifier(enemyPokemon);
} else {
modifier = mt.modifier as PokemonHeldItemModifier;
modifier.pokemonId = enemyPokemon.id;
}
modifier.stackCount = mt.stackCount ?? 1;
modifier.isTransferable = mt.isTransferable ?? modifier.isTransferable;
this.addEnemyModifier(modifier, true);
}
} else {
const isBoss =
enemyPokemon.isBoss()
|| (this.currentBattle.battleType === BattleType.TRAINER && !!this.currentBattle.trainer?.config.isBoss);
let upgradeChance = 32;
if (isBoss) {
upgradeChance /= 2;
}
if (isFinalBoss) {
upgradeChance /= 8;
}
let count = 0;
for (let c = 0; c < chances; c++) {
if (!randSeedInt(this.gameMode.getEnemyModifierChance(isBoss))) {
count++;
}
}
if (isBoss) {
count = Math.max(count, Math.floor(chances / 2));
}
getEnemyModifierTypesForWave(
difficultyWaveIndex,
count,
[enemyPokemon],
this.currentBattle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD,
upgradeChance,
).map(mt => mt.newModifier(enemyPokemon).add(this.enemyModifiers, false));
}
return true;
});
this.updateModifiers(false);
resolve();
});
}
/**
* Removes all modifiers from enemy pokemon of {@linkcode PersistentModifier} type
*/
clearEnemyModifiers(): void {
const modifiersToRemove = this.enemyModifiers.filter(m => m instanceof PersistentModifier);
for (const m of modifiersToRemove) {
this.enemyModifiers.splice(this.enemyModifiers.indexOf(m), 1);
}
this.updateModifiers(false);
this.updateUIPositions();
}
/**
* Removes all modifiers from enemy pokemon of {@linkcode PokemonHeldItemModifier} type
* @param pokemon - If specified, only removes held items from that {@linkcode Pokemon}
*/
clearEnemyHeldItemModifiers(pokemon?: Pokemon): void {
const modifiersToRemove = this.enemyModifiers.filter(
m => m instanceof PokemonHeldItemModifier && (!pokemon || m.getPokemon() === pokemon),
);
for (const m of modifiersToRemove) {
this.enemyModifiers.splice(this.enemyModifiers.indexOf(m), 1);
}
this.updateModifiers(false);
this.updateUIPositions();
}
setModifiersVisible(visible: boolean) {
[this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible));
}
// TODO: Document this
updateModifiers(player = true, instant?: boolean): void {
const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]);
for (let m = 0; m < modifiers.length; m++) {
const modifier = modifiers[m];
if (
modifier instanceof PokemonHeldItemModifier
&& !this.getPokemonById((modifier as PokemonHeldItemModifier).pokemonId)
) {
modifiers.splice(m--, 1);
}
if (
modifier instanceof PokemonHeldItemModifier
&& modifier.getSpecies() != null
&& !this.getPokemonById(modifier.pokemonId)?.hasSpecies(modifier.getSpecies()!)
) {
modifiers.splice(m--, 1);
}
}
for (const modifier of modifiers) {
if (modifier instanceof PersistentModifier) {
(modifier as PersistentModifier).virtualStackCount = 0;
}
}
const modifiersClone = modifiers.slice(0);
for (const modifier of modifiersClone) {
if (!modifier.getStackCount()) {
modifiers.splice(modifiers.indexOf(modifier), 1);
}
}
this.updatePartyForModifiers(player ? this.getPlayerParty() : this.getEnemyParty(), instant);
(player ? this.modifierBar : this.enemyModifierBar).updateModifiers(modifiers);
if (!player) {
this.updateUIPositions();
}
}
updatePartyForModifiers(party: Pokemon[], instant?: boolean): Promise<void> {
return new Promise(resolve => {
Promise.allSettled(
party.map(p => {
p.calculateStats();
return p.updateInfo(instant);
}),
).then(() => resolve());
});
}
hasModifier(modifier: PersistentModifier, enemy = false): boolean {
const modifiers = enemy ? this.enemyModifiers : this.modifiers;
return modifiers.indexOf(modifier) > -1;
}
/**
* Removes a currently owned item. If the item is stacked, the entire item stack
* gets removed. This function does NOT apply in-battle effects, such as Unburden.
* If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead.
* @param modifier The item to be removed.
* @param enemy `true` to remove an item owned by the enemy rather than the player; default `false`.
* @returns `true` if the item exists and was successfully removed, `false` otherwise
*/
removeModifier(modifier: PersistentModifier, enemy = false): boolean {
const modifiers = enemy ? this.enemyModifiers : this.modifiers;
const modifierIndex = modifiers.indexOf(modifier);
if (modifierIndex > -1) {
modifiers.splice(modifierIndex, 1);
if (modifier instanceof PokemonFormChangeItemModifier) {
const pokemon = this.getPokemonById(modifier.pokemonId);
if (pokemon) {
modifier.apply(pokemon, false);
}
}
return true;
}
return false;
}
/**
* Get all of the modifiers that match `modifierType`
* @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
* @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
* @returns the list of all modifiers that matched `modifierType`.
*/
getModifiers<T extends PersistentModifier>(modifierType: Constructor<T>, player = true): T[] {
return (player ? this.modifiers : this.enemyModifiers).filter((m): m is T => m instanceof modifierType);
}
/**
* Get all of the modifiers that pass the `modifierFilter` function
* @param modifierFilter The function used to filter a target's modifiers
* @param isPlayer Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
* @returns the list of all modifiers that passed the `modifierFilter` function
*/
findModifiers(modifierFilter: ModifierPredicate, isPlayer = true): PersistentModifier[] {
return (isPlayer ? this.modifiers : this.enemyModifiers).filter(modifierFilter);
}
/**
* Find the first modifier that pass the `modifierFilter` function
* @param modifierFilter The function used to filter a target's modifiers
* @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
* @returns the first modifier that passed the `modifierFilter` function; `undefined` if none passed
*/
findModifier(modifierFilter: ModifierPredicate, player = true): PersistentModifier | undefined {
return (player ? this.modifiers : this.enemyModifiers).find(modifierFilter);
}
/**
* Apply all modifiers that match `modifierType` in a random order
* @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
* @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
* @param args The list of arguments needed to invoke `modifierType.apply`
* @returns the list of all modifiers that matched `modifierType` and were applied.
*/
applyShuffledModifiers<T extends PersistentModifier>(
modifierType: Constructor<T>,
player = true,
...args: Parameters<T["apply"]>
): T[] {
let modifiers = (player ? this.modifiers : this.enemyModifiers).filter(
(m): m is T => m instanceof modifierType && m.shouldApply(...args),
);
this.executeWithSeedOffset(
() => {
const shuffleModifiers = mods => {
if (mods.length === 0) {
return mods;
}
const rand = randSeedInt(mods.length);
return [mods[rand], ...shuffleModifiers(mods.filter((_, i) => i !== rand))];
};
modifiers = shuffleModifiers(modifiers);
},
this.currentBattle.turn << 4,
this.waveSeed,
);
return this.applyModifiersInternal(modifiers, player, args);
}
/**
* Apply all modifiers that match `modifierType`
* @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
* @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
* @param args The list of arguments needed to invoke `modifierType.apply`
* @returns the list of all modifiers that matched `modifierType` and were applied.
*/
applyModifiers<T extends PersistentModifier>(
modifierType: Constructor<T>,
player = true,
...args: Parameters<T["apply"]>
): T[] {
const modifiers = (player ? this.modifiers : this.enemyModifiers).filter(
(m): m is T => m instanceof modifierType && m.shouldApply(...args),
);
return this.applyModifiersInternal(modifiers, player, args);
}
/** Helper function to apply all passed modifiers */
applyModifiersInternal<T extends PersistentModifier>(
modifiers: T[],
player: boolean,
args: Parameters<T["apply"]>,
): T[] {
const appliedModifiers: T[] = [];
for (const modifier of modifiers) {
if (modifier.apply(...args)) {
console.log("Applied", modifier.type.name, player ? "" : "(enemy)");
appliedModifiers.push(modifier);
}
}
return appliedModifiers;
}
/**
* Apply the first modifier that matches `modifierType`
* @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
* @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
* @param args The list of arguments needed to invoke `modifierType.apply`
* @returns the first modifier that matches `modifierType` and was applied; return `null` if none matched
*/
applyModifier<T extends PersistentModifier>(
modifierType: Constructor<T>,
player = true,
...args: Parameters<T["apply"]>
): T | null {
const modifiers = (player ? this.modifiers : this.enemyModifiers).filter(
(m): m is T => m instanceof modifierType && m.shouldApply(...args),
);
for (const modifier of modifiers) {
if (modifier.apply(...args)) {
console.log("Applied", modifier.type.name, player ? "" : "(enemy)");
return modifier;
}
}
return null;
}
triggerPokemonFormChange(
pokemon: Pokemon,
formChangeTriggerType: Constructor<SpeciesFormChangeTrigger>,
delayed = false,
modal = false,
): boolean {
if (speciesDataRegistry.hasFormChanges(pokemon.species.speciesId)) {
// in case this is NECROZMA, determine which forms this
const matchingFormChangeOpts = speciesDataRegistry
.getFormChanges(pokemon.species.speciesId)
.filter(fc => fc.findTrigger(formChangeTriggerType) && fc.canChange(pokemon));
let matchingFormChange: SpeciesFormChange | null;
if (pokemon.species.speciesId === SpeciesId.NECROZMA && matchingFormChangeOpts.length > 1) {
// Ultra Necrozma is changing its form back, so we need to figure out into which form it devolves.
const formChangeItemModifiers = (
this.findModifiers(
m => m instanceof PokemonFormChangeItemModifier && m.pokemonId === pokemon.id,
) as PokemonFormChangeItemModifier[]
)
.filter(m => m.active)
.map(m => m.formChangeItem);
matchingFormChange = formChangeItemModifiers.includes(FormChangeItem.N_LUNARIZER)
? matchingFormChangeOpts[0]
: formChangeItemModifiers.includes(FormChangeItem.N_SOLARIZER)
? matchingFormChangeOpts[1]
: null;
} else {
matchingFormChange = matchingFormChangeOpts[0];
}
if (matchingFormChange) {
let phase: Phase;
if (pokemon.isPlayer() && !matchingFormChange.quiet) {
phase = this.phaseManager.create("FormChangePhase", pokemon, matchingFormChange, modal);
} else {
phase = this.phaseManager.create("QuietFormChangePhase", pokemon, matchingFormChange);
}
if (pokemon.isPlayer() && !matchingFormChange.quiet && modal) {
this.phaseManager.overridePhase(phase);
} else if (delayed) {
this.phaseManager.pushPhase(phase);
} else {
this.phaseManager.unshiftPhase(phase);
}
return true;
}
}
return false;
}
triggerPokemonBattleAnim(
pokemon: Pokemon,
battleAnimType: PokemonAnimType,
fieldAssets?: Phaser.GameObjects.Sprite[],
delayed = false,
): boolean {
const phaseManager = this.phaseManager;
const phase: Phase = phaseManager.create("PokemonAnimPhase", battleAnimType, pokemon, fieldAssets);
if (delayed) {
phaseManager.pushPhase(phase);
} else {
phaseManager.unshiftPhase(phase);
}
return true;
}
validateAchvs<T extends Achv>(achvType: Constructor<T>, ...args: NonNullable<Parameters<T["validate"]>[0]>): void {
const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType);
for (const achv of filteredAchvs) {
this.validateAchv(achv, args);
}
}
validateAchv<T extends Achv>(achv: T, args?: Parameters<T["validate"]>[0]): boolean {
if (
(!Object.hasOwn(this.gameData.achvUnlocks, achv.id) || activeOverrides.ACHIEVEMENTS_REUNLOCK_OVERRIDE)
&& achv.validate(args)
) {
this.gameData.achvUnlocks[achv.id] = Date.now();
this.ui.achvBar.showAchv(achv);
if (Object.hasOwn(vouchers, achv.id)) {
this.validateVoucher(vouchers[achv.id]);
}
return true;
}
return false;
}
validateVoucher(voucher: Voucher, args?: unknown[]): boolean {
if (!Object.hasOwn(this.gameData.voucherUnlocks, voucher.id) && voucher.validate(args)) {
this.gameData.voucherUnlocks[voucher.id] = Date.now();
this.ui.achvBar.showAchv(voucher);
this.gameData.voucherCounts[voucher.voucherType]++;
return true;
}
return false;
}
public updateGameInfo(): void {
type GameInfo = {
/** @since 2.0.0 */
gameInfoVersion: string;
/** @since 1.1.0 */
playTime: number;
/** @since 1.0.0 */
gameMode: string;
/** @since 1.0.0 */
biome: string;
/** @since 1.0.0 */
wave: number;
/** @since 2.1.0 */
luck: number;
/** @since 1.0.0 */
party: PartyInfo[];
};
type PartyInfo = {
/** @since 1.0.0 */
name: string;
/** @since 2.0.0 */
nickname: string;
/** @since 2.0.0 */
gender: string;
/** @since 1.2.0 */
form: string;
/** @since 1.2.0 */
types: string[];
/** @since 2.0.0 */
tempTypes: string[];
/** @since 1.2.0 */
teraType: string;
/** @since 1.3.0 */
isTerastallized: boolean;
/** @since 1.0.0 */
level: number;
/** @since 1.2.0 */
currentHP: number;
/** @since 1.2.0 */
maxHP: number;
/** @since 1.2.0 */
status: string;
/** @since 2.0.0 */
moveset: string[];
/** @since 2.0.0 */
tempMoveset: string[];
/** @since 2.0.0 */
ability: string;
/** @since 2.0.0 */
tempAbility: string;
/** @since 2.0.0 */
passiveAbility: string;
/** @since 2.0.0 */
isPassiveEnabled: boolean;
/** @since 2.0.0 */
nature: string;
/** @since 2.0.0 */
baseStats: {
atk: number;
def: number;
spAtk: number;
spDef: number;
speed: number;
};
/** @since 2.0.0 */
tempStats:
| {
atk: number;
def: number;
spAtk: number;
spDef: number;
speed: number;
}
| {
atk?: never;
def?: never;
spAtk?: never;
spDef?: never;
speed?: never;
};
/** @since 2.0.0 */
statStages: {
atk: number;
def: number;
spAtk: number;
spDef: number;
speed: number;
acc: number;
eva: number;
};
/** @since 2.0.0 */
shiny: boolean;
/** @since 2.0.0 */
variant: string;
/** @since 2.0.0 */
isFusion: boolean;
};
const variantMap = {
[0]: "Normal",
[1]: "Rare",
[2]: "Epic",
};
const gameInfo: GameInfo = {
//! Make sure to update this in accordance with semver when the output is changed
// cf https://semver.org/
gameInfoVersion: "2.1.0",
playTime: this.sessionPlayTime ?? 0,
gameMode: this.currentBattle ? this.gameMode.getName() : "Title",
biome: this.currentBattle ? getBiomeName(this.arena.biomeId) : "",
wave: this.currentBattle?.waveIndex ?? 0,
luck: this.currentBattle ? getPartyLuckValue(this.party) : -1,
party:
this.party?.map(
p =>
({
name: p.name,
nickname: p.nickname ? decodeNickname(p.nickname, p.name) : "",
gender: capitalizeFirstLetterOnly(Gender[p.gender]),
form: p.getFormKey(),
// Does not include temporary changes, such as those from Transform, Forest's Curse, etc
// Ignores Tera type
types: p
.getTypes({ includeTeraType: false, bypassSummonData: true, ignoreThirdType: true })
.map(pType => capitalizeFirstLetterOnly(PokemonType[pType])),
// Includes temporary changes, such as those from Transform, Forest's Curse, etc
// Ignores Tera type
tempTypes:
p.summonData.types.length > 0 || p.summonData.addedType
? p.getTypes({ includeTeraType: false }).map(pType => capitalizeFirstLetterOnly(PokemonType[pType]))
: [],
teraType: capitalizeFirstLetterOnly(PokemonType[p.getTeraType()]),
isTerastallized: p.isTerastallized,
level: p.level,
currentHP: p.hp,
maxHP: p.getMaxHp(),
status: p.status?.effect ? capitalizeFirstLetterOnly(StatusEffect[p.status.effect]) : "",
// the pokemon's actual moveset
moveset: p.getMoveset(true).map(move => move.getName()),
// the pokemon's temporary moveset, e.g. from Transform
// biome-ignore lint/style/useExplicitLengthCheck: doubles as a null check
tempMoveset: p.summonData.moveset?.length ? p.getMoveset().map(move => move.getName()) : [],
// the pokemon's actual ability
ability: p.getAbility(true).name,
// the pokemon's temporary ability, e.g. from Transform or Skill Swap
tempAbility: p.summonData.ability ? p.getAbility().name : "",
passiveAbility: p.getPassiveAbility().name,
isPassiveEnabled: p.hasPassive(),
nature: capitalizeFirstLetterOnly(Nature[p.getNature()]),
baseStats: {
atk: p.getStat(Stat.ATK),
def: p.getStat(Stat.DEF),
spAtk: p.getStat(Stat.SPATK),
spDef: p.getStat(Stat.SPDEF),
speed: p.getStat(Stat.SPD),
},
// e.g. from Transform
tempStats: p.summonData.stats.some(v => v > 0)
? {
atk: p.getStat(Stat.ATK, false),
def: p.getStat(Stat.DEF, false),
spAtk: p.getStat(Stat.SPATK, false),
spDef: p.getStat(Stat.SPDEF, false),
speed: p.getStat(Stat.SPD, false),
}
: {},
statStages: {
atk: p.getStatStage(Stat.ATK),
def: p.getStatStage(Stat.DEF),
spAtk: p.getStatStage(Stat.SPATK),
spDef: p.getStatStage(Stat.SPDEF),
speed: p.getStatStage(Stat.SPD),
acc: p.getStatStage(Stat.ACC),
eva: p.getStatStage(Stat.EVA),
},
shiny: p.isShiny(),
variant: p.isShiny() ? variantMap[p.getVariant()] : "N/A",
isFusion: p.isFusion(),
}) as PartyInfo satisfies PartyInfo,
) ?? [],
};
// TODO: Don't store it here
window["gameInfo"] = gameInfo;
}
/**
* This function retrieves the sprite and audio keys for active Pokemon.
* Active Pokemon include both enemy and player Pokemon of the current wave.
* Note: Questions on garbage collection go to `@frutescens`
* @returns a string array of active sprite and audio keys that should not be deleted
*/
getActiveKeys(): string[] {
const keys: string[] = [];
let activePokemon: (PlayerPokemon | EnemyPokemon)[] = this.getPlayerParty();
activePokemon = activePokemon.concat(this.getEnemyParty());
for (const p of activePokemon) {
keys.push(p.getSpriteKey(true));
if (p.isPlayer()) {
keys.push(p.getBattleSpriteKey(true, true));
}
keys.push(p.species.getCryKey(p.formIndex));
if (p.fusionSpecies) {
keys.push(p.fusionSpecies.getCryKey(p.fusionFormIndex));
}
}
return keys;
}
/**
* Initialized the 2nd phase of the final boss (e.g. form-change for Eternatus)
* @param pokemon The (enemy) pokemon
*/
initFinalBossPhaseTwo(pokemon: Pokemon): void {
if (!pokemon.isEnemy() || !pokemon.isBoss() || pokemon.formIndex > 0 || pokemon.bossSegmentIndex >= 1) {
this.phaseManager.shiftPhase();
return;
}
audioManager.fadeOutBgm(2000, true);
this.ui.showDialogue(classicFinalBossDialogue.firstStageWin, pokemon.species.name, undefined, () => {
const finalBossMBH = getModifierType(modifierTypes.MINI_BLACK_HOLE).newModifier(
pokemon,
) as TurnHeldItemTransferModifier;
finalBossMBH.setTransferrableFalse();
this.addEnemyModifier(finalBossMBH, false, true);
pokemon.generateAndPopulateMoveset(false, 1);
this.setFieldScale(0.75);
this.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger, false);
this.currentBattle.double = true;
const availablePartyMembers = this.getPlayerParty().filter(p => p.isAllowedInBattle());
if (availablePartyMembers.length > 1) {
this.phaseManager.pushNew("ToggleDoublePositionPhase", true);
if (!availablePartyMembers[1].isOnField()) {
this.phaseManager.pushNew("SummonPhase", 1);
this.phaseManager.pushNew("PostSummonPhase", 1);
}
}
this.phaseManager.shiftPhase();
});
}
/**
* Updates Exp and level values for Player's party, adding new level up phases as required
* @param expValue raw value of exp to split among participants, OR the base multiplier to use with waveIndex
* @param pokemonDefeated If true, will increment Macho Brace stacks and give the party Pokemon friendship increases
* @param useWaveIndexMultiplier Default `false`. If true, will multiply expValue by a scaling waveIndex multiplier. Not needed if expValue is already scaled by level/wave
* @param participantIds - A `Set` containing the IDs of all Pokemon that will share the EXP earned.
* If omitted, will default to the current battle's participants.
*/
applyPartyExp(
expValue: number,
pokemonDefeated: boolean,
useWaveIndexMultiplier = false,
participantIds = this.currentBattle.playerParticipantIds,
): void {
// TODO: make this code actually not insane
const party = this.getPlayerParty();
const expShareModifier = this.findModifier(m => m instanceof ExpShareModifier) as ExpShareModifier;
const expBalanceModifier = this.findModifier(m => m instanceof ExpBalanceModifier) as ExpBalanceModifier;
const multipleParticipantExpBonusModifier = this.findModifier(
m => m instanceof MultipleParticipantExpBonusModifier,
) as MultipleParticipantExpBonusModifier;
const nonFaintedPartyMembers = party.filter(p => p.hp);
const expPartyMembers = nonFaintedPartyMembers.filter(p => p.level < this.getMaxExpLevel());
const partyMemberExp: number[] = [];
// EXP value calculation is based off Pokemon.getExpValue
if (useWaveIndexMultiplier) {
expValue = Math.floor((expValue * this.currentBattle.waveIndex) / 5 + 1);
}
if (participantIds.size > 0) {
if (
this.currentBattle.battleType === BattleType.TRAINER
|| this.currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE
) {
expValue = Math.floor(expValue * 1.5);
} else if (this.currentBattle.isBattleMysteryEncounter() && this.currentBattle.mysteryEncounter) {
expValue = Math.floor(expValue * this.currentBattle.mysteryEncounter.expMultiplier);
}
for (const partyMember of nonFaintedPartyMembers) {
const pId = partyMember.id;
const participated = participantIds.has(pId);
if (participated && pokemonDefeated) {
partyMember.addFriendship(FRIENDSHIP_GAIN_FROM_BATTLE);
const machoBraceModifier = partyMember.getHeldItems().find(m => m instanceof PokemonIncrementingStatModifier);
if (machoBraceModifier && machoBraceModifier.stackCount < machoBraceModifier.getMaxStackCount()) {
machoBraceModifier.stackCount++;
this.updateModifiers(true, true);
partyMember.updateInfo();
}
}
if (!expPartyMembers.includes(partyMember)) {
continue;
}
if (!participated && !expShareModifier) {
partyMemberExp.push(0);
continue;
}
let expMultiplier = 0;
if (participated) {
expMultiplier += 1 / participantIds.size;
if (participantIds.size > 1 && multipleParticipantExpBonusModifier) {
expMultiplier += multipleParticipantExpBonusModifier.getStackCount() * 0.2;
}
} else if (expShareModifier) {
expMultiplier += (expShareModifier.getStackCount() * 0.2) / participantIds.size;
}
if (partyMember.pokerus) {
expMultiplier *= 1.5;
}
if (activeOverrides.XP_MULTIPLIER_OVERRIDE !== null) {
expMultiplier = activeOverrides.XP_MULTIPLIER_OVERRIDE;
}
const pokemonExp = new NumberHolder(expValue * expMultiplier);
this.applyModifiers(PokemonExpBoosterModifier, true, partyMember, pokemonExp);
partyMemberExp.push(Math.floor(pokemonExp.value));
}
if (expBalanceModifier) {
let totalLevel = 0;
let totalExp = 0;
expPartyMembers.forEach((expPartyMember, epm) => {
totalExp += partyMemberExp[epm];
totalLevel += expPartyMember.level;
});
const medianLevel = Math.floor(totalLevel / expPartyMembers.length);
const recipientExpPartyMemberIndexes: number[] = [];
expPartyMembers.forEach((expPartyMember, epm) => {
if (expPartyMember.level <= medianLevel) {
recipientExpPartyMemberIndexes.push(epm);
}
});
const splitExp = Math.floor(totalExp / recipientExpPartyMemberIndexes.length);
expPartyMembers.forEach((_partyMember, pm) => {
partyMemberExp[pm] = Phaser.Math.Linear(
partyMemberExp[pm],
recipientExpPartyMemberIndexes.indexOf(pm) > -1 ? splitExp : 0,
0.2 * expBalanceModifier.getStackCount(),
);
});
}
for (let pm = 0; pm < expPartyMembers.length; pm++) {
const exp = partyMemberExp[pm];
if (exp) {
const partyMemberIndex = party.indexOf(expPartyMembers[pm]);
this.phaseManager.unshiftPhase(
expPartyMembers[pm].isOnField()
? this.phaseManager.create("ExpPhase", partyMemberIndex, exp)
: this.phaseManager.create("ShowPartyExpBarPhase", partyMemberIndex, exp),
);
}
}
}
}
/**
* Determine whether a wave should randomly generate a {@linkcode MysteryEncounter}.
* Currently, the only modes that MEs are allowed in are Classic and Challenge.
* Additionally, MEs cannot spawn outside of waves 10-180 in those modes
* @param battleType - The {@linkcode BattleType} of the newly created battle
* @param waveIndex - The wave number of the newly spawned wave
* @returns Whether a Mystery Encounter should be generated.
*/
private isWaveMysteryEncounter(battleType: BattleType, waveIndex: number): boolean {
if (getDailyMysteryEncounter(waveIndex) != null) {
return true;
}
if (!this.isMysteryEncounterValidForWave(battleType, waveIndex)) {
return false;
}
const [lowestMysteryEncounterWave, highestMysteryEncounterWave] = this.gameMode.getMysteryEncounterLegalWaves();
// Base spawn weight is BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT/256, and increases
// by WEIGHT_INCREMENT_ON_SPAWN_MISS/256 for each missed attempt at spawning an encounter on a valid floor
const sessionEncounterRate = this.mysteryEncounterSaveData.encounterSpawnChance;
const encounteredEvents = this.mysteryEncounterSaveData.encounteredEvents;
// MEs can only spawn 3 or more waves after the previous ME, barring overrides
const canSpawn =
activeOverrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE !== null
|| encounteredEvents.length === 0
|| waveIndex > 3 + encounteredEvents.at(-1)!.waveIndex; // Bang on `at()` is justified due to the check for length === 0
if (!canSpawn) {
return false;
}
// If total number of encounters is lower than expected for the run, slightly favor a new encounter spawn (reverse as well)
// Reduces occurrence of runs with total encounters significantly different from AVERAGE_ENCOUNTERS_PER_RUN_TARGET
// Favored rate changes can never exceed 50%. So if base rate is 15/256 and favored rate would add 200/256, result will be (15 + 128)/256
const expectedEncountersByFloor =
(AVERAGE_ENCOUNTERS_PER_RUN_TARGET / (highestMysteryEncounterWave - lowestMysteryEncounterWave))
* (waveIndex - lowestMysteryEncounterWave);
const currentRunDiffFromAvg = expectedEncountersByFloor - encounteredEvents.length;
const favoredEncounterRate =
sessionEncounterRate
+ Math.min(currentRunDiffFromAvg * ANTI_VARIANCE_WEIGHT_MODIFIER, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT / 2);
const successRate = activeOverrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE ?? favoredEncounterRate;
let roll = 0;
// Always rolls the check on the same offset to ensure no RNG changes from reloading session
this.executeWithSeedOffset(() => {
roll = randSeedInt(MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT);
}, waveIndex * 3000);
return roll < successRate;
}
/**
* Returns if a wave COULD spawn a {@linkcode MysteryEncounter}.
* @param battleType - The {@linkcode BattleType} of the newly created battle
* @param waveIndex - The wave number of the newly spawned wave
* @returns Whether an ME can legally spawn on the given wave.
* @see {@linkcode BattleScene.isWaveMysteryEncounter} - Function that rolls for ME creation on new wave start
*/
public isMysteryEncounterValidForWave(battleType: BattleType, waveIndex: number): boolean {
const [lowestMysteryEncounterWave, highestMysteryEncounterWave] = this.gameMode.getMysteryEncounterLegalWaves();
return (
this.gameMode.hasMysteryEncounters
&& battleType === BattleType.WILD
&& !this.gameMode.isBoss(waveIndex)
&& waveIndex % 10 !== 1
&& isBetween(waveIndex, lowestMysteryEncounterWave, highestMysteryEncounterWave)
);
}
/**
* Loads or generates a mystery encounter
* @param encounterType used to load session encounter when restarting game, etc.
* @param canBypass optional boolean to indicate that the request is coming from a function that needs to access a Mystery Encounter outside of gameplay requirements
* @returns
*/
getMysteryEncounter(encounterType?: MysteryEncounterType, canBypass?: boolean): MysteryEncounter {
// Loading override or session encounter
let encounter: MysteryEncounter | null;
if (
activeOverrides.MYSTERY_ENCOUNTER_OVERRIDE != null
&& Object.hasOwn(allMysteryEncounters, activeOverrides.MYSTERY_ENCOUNTER_OVERRIDE)
) {
encounter = allMysteryEncounters[activeOverrides.MYSTERY_ENCOUNTER_OVERRIDE];
if (canBypass) {
return encounter;
}
} else if (canBypass) {
encounter = allMysteryEncounters[encounterType ?? -1];
return encounter;
} else if (getDailyMysteryEncounter(this.currentBattle.waveIndex) == null) {
encounter = encounterType == null ? null : allMysteryEncounters[encounterType];
} else {
encounter = allMysteryEncounters[getDailyMysteryEncounter(this.currentBattle.waveIndex)!];
}
// Check for queued encounters first
// TODO: the inner `while` loop will never run, should this code be deleted or fixed?
const queuedEncounters = this.mysteryEncounterSaveData?.queuedEncounters ?? [];
if (!encounter && queuedEncounters.length > 0) {
let i = 0;
while (i < queuedEncounters.length && encounter) {
const candidate = queuedEncounters[i];
const forcedChance = candidate.spawnPercent;
if (randSeedInt(100) < forcedChance) {
encounter = allMysteryEncounters[candidate.type];
}
i++;
}
}
if (encounter) {
encounter = new MysteryEncounter(encounter);
encounter.populateDialogueTokensFromRequirements();
return encounter;
}
// See Enum values for base tier weights
const tierWeights = [
MysteryEncounterTier.COMMON,
MysteryEncounterTier.GREAT,
MysteryEncounterTier.ULTRA,
MysteryEncounterTier.ROGUE,
];
// Adjust tier weights by previously encountered events to lower odds of only Common/Great in run
for (const seenEncounterData of this.mysteryEncounterSaveData.encounteredEvents) {
if (seenEncounterData.tier === MysteryEncounterTier.COMMON) {
tierWeights[0] -= 6;
} else if (seenEncounterData.tier === MysteryEncounterTier.GREAT) {
tierWeights[1] -= 4;
}
}
const totalWeight = tierWeights.reduce((a, b) => a + b);
const tierValue = randSeedInt(totalWeight);
const commonThreshold = totalWeight - tierWeights[0];
const greatThreshold = totalWeight - tierWeights[0] - tierWeights[1];
const ultraThreshold = totalWeight - tierWeights[0] - tierWeights[1] - tierWeights[2];
let tier: MysteryEncounterTier | null =
tierValue > commonThreshold
? MysteryEncounterTier.COMMON
: tierValue > greatThreshold
? MysteryEncounterTier.GREAT
: tierValue > ultraThreshold
? MysteryEncounterTier.ULTRA
: MysteryEncounterTier.ROGUE;
if (activeOverrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE != null) {
tier = activeOverrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE;
}
let availableEncounters: MysteryEncounter[] = [];
const previousEncounter = this.mysteryEncounterSaveData.encounteredEvents.at(-1)?.type ?? null; // TODO: This being `null` is a bit weird
const disabledEncounters = timedEventManager.getEventMysteryEncountersDisabled();
const biomeMysteryEncounters =
mysteryEncountersByBiome.get(this.arena.biomeId)?.filter(enc => !disabledEncounters.includes(enc)) ?? [];
// If no valid encounters exist at tier, checks next tier down, continuing until there are some encounters available
while (availableEncounters.length === 0 && tier !== null) {
availableEncounters = biomeMysteryEncounters
.filter(encounterType => {
const encounterCandidate = allMysteryEncounters[encounterType];
if (!encounterCandidate) {
return false;
}
if (
timedEventManager.getMysteryEncounterTierForEvent(encounterType, encounterCandidate.encounterTier) !== tier
) {
return false;
}
const disallowedGameModes = encounterCandidate.disallowedGameModes;
if (
disallowedGameModes
&& disallowedGameModes.length > 0
&& disallowedGameModes.includes(this.gameMode.modeId)
) {
return false;
}
if (encounterCandidate.disallowedChallenges?.some(challenge => this.gameMode.hasChallenge(challenge))) {
return false;
}
if (!encounterCandidate.meetsRequirements()) {
return false;
}
if (previousEncounter !== null && encounterType === previousEncounter) {
return false;
}
return !(
this.mysteryEncounterSaveData.encounteredEvents.length > 0
&& encounterCandidate.maxAllowedEncounters
&& encounterCandidate.maxAllowedEncounters > 0
&& this.mysteryEncounterSaveData.encounteredEvents.filter(e => e.type === encounterType).length
>= encounterCandidate.maxAllowedEncounters
);
})
.map(m => allMysteryEncounters[m]);
// Decrement tier
if (tier === MysteryEncounterTier.ROGUE) {
tier = MysteryEncounterTier.ULTRA;
} else if (tier === MysteryEncounterTier.ULTRA) {
tier = MysteryEncounterTier.GREAT;
} else if (tier === MysteryEncounterTier.GREAT) {
tier = MysteryEncounterTier.COMMON;
} else {
tier = null; // Ends loop
}
}
// If absolutely no encounters are available, spawn 0th encounter
if (availableEncounters.length === 0) {
console.log("No Mystery Encounters found, falling back to Mysterious Challengers.");
return allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHALLENGERS];
}
// TODO: should this use `randSeedItem`?
encounter = availableEncounters[randSeedInt(availableEncounters.length)];
// New encounter object to not dirty flags
encounter = new MysteryEncounter(encounter);
encounter.populateDialogueTokensFromRequirements();
return encounter;
}
}