import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import Overrides from "#app/overrides"; import { biomePokemonPools, biomeTrainerPools } from "#balance/biomes"; import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; import { EntryHazardTag, getArenaTag } from "#data/arena-tag"; import { getDailyForcedWaveBiomePoolTier } from "#data/daily-seed/daily-run"; import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import type { PokemonSpecies } from "#data/pokemon-species"; import type { PositionalTag } from "#data/positional-tags/positional-tag"; import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager"; import { getTerrainClearMessage, getTerrainStartMessage, Terrain, TerrainType } from "#data/terrain"; import { getLegendaryWeatherContinuesMessage, getWeatherClearMessage, getWeatherStartMessage, Weather, } from "#data/weather"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import type { ArenaTagType } from "#enums/arena-tag-type"; import type { BattlerIndex } from "#enums/battler-index"; import { BiomeId } from "#enums/biome-id"; import { BiomePoolTier } from "#enums/biome-pool-tier"; import { CommonAnim } from "#enums/move-anims-common"; import type { MoveId } from "#enums/move-id"; import type { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; import { TimeOfDay } from "#enums/time-of-day"; import { TrainerType } from "#enums/trainer-type"; import { WeatherType } from "#enums/weather-type"; import { TagAddedEvent, TagRemovedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#events/arena"; import type { Pokemon } from "#field/pokemon"; import { FieldEffectModifier } from "#modifiers/modifier"; import type { Move } from "#moves/move"; import type { BiomeTierTrainerPools, PokemonPools } from "#types/biomes"; import type { Constructor } from "#types/common"; import type { RGBArray } from "#types/sprite-types"; import type { AbstractConstructor } from "#types/type-helpers"; import { coerceArray } from "#utils/array"; import { NumberHolder, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { inSpeedOrder } from "#utils/speed-order-generator"; import type { NonEmptyTuple } from "type-fest"; export class Arena { public biomeId: BiomeId; public weather: Weather | null; public terrain: Terrain | null; /** All currently-active {@linkcode ArenaTag}s on both sides of the field. */ public tags: ArenaTag[] = []; /** All currently-active {@linkcode PositionalTag}s on both sides of the field, sorted by tag type. */ public readonly positionalTagManager: PositionalTagManager = new PositionalTagManager(); public bgm: string; public ignoreAbilities: boolean; public ignoringEffectSource: BattlerIndex | null; public playerTerasUsed = 0; /** * Saves the number of times a party pokemon faints during a arena encounter. \ * {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave). */ public playerFaints: number; private lastTimeOfDay: TimeOfDay; private pokemonPool: PokemonPools; private readonly trainerPool: BiomeTierTrainerPools; public readonly eventTarget: EventTarget = new EventTarget(); constructor(biome: BiomeId, playerFaints = 0) { this.biomeId = biome; this.bgm = BiomeId[biome].toLowerCase(); this.trainerPool = biomeTrainerPools[biome]; this.updatePoolsForTimeOfDay(); this.playerFaints = playerFaints; } // #region Getters public get weatherType(): WeatherType { return this.weather?.weatherType ?? WeatherType.NONE; } public get terrainType(): TerrainType { return this.terrain?.terrainType ?? TerrainType.NONE; } // #endregion // #region Misc Public Methods public init() { const biomeKey = getBiomeKey(this.biomeId); globalScene.arenaPlayer.setBiome(this.biomeId); globalScene.arenaPlayerTransition.setBiome(this.biomeId); globalScene.arenaEnemy.setBiome(this.biomeId); globalScene.arenaNextEnemy.setBiome(this.biomeId); globalScene.arenaBg.setTexture(`${biomeKey}_bg`); globalScene.arenaBgTransition.setTexture(`${biomeKey}_bg`); // Redo this on initialize because during save/load the current wave isn't always // set correctly during construction this.updatePoolsForTimeOfDay(); } public setIgnoreAbilities(ignoreAbilities: boolean, ignoringEffectSource: BattlerIndex | null = null): void { this.ignoreAbilities = ignoreAbilities; this.ignoringEffectSource = ignoreAbilities ? ignoringEffectSource : null; } public resetPlayerFaintCount(): void { this.playerFaints = 0; } /** Clears weather, terrain and arena tags when entering new biome or trainer battle. */ public resetArenaEffects(): void { // Don't reset weather if a Biome's permanent weather is active if (this.weather?.turnsLeft !== 0) { this.trySetWeather(WeatherType.NONE); } this.trySetTerrain(TerrainType.NONE, true); this.resetPlayerFaintCount(); this.removeAllTags(); } // #endregion // #region Weather /** @returns Whether or not the weather can be changed to the specified weather */ public canSetWeather(weather: WeatherType): boolean { return this.weatherType !== weather; } /** * Sets weather to the override specified in `overrides.ts` */ private overrideWeather(): void { const weather = Overrides.WEATHER_OVERRIDE; this.weather = new Weather(weather, 0); globalScene.phaseManager.unshiftNew("CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (weather - 1)); globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct? } /** * Attempts to set a new weather to the battle * @param weather {@linkcode WeatherType} new {@linkcode WeatherType} to set * @param user {@linkcode Pokemon} that caused the weather effect * @returns true if new weather set, false if no weather provided or attempting to set the same weather as currently in use */ public trySetWeather(weather: WeatherType, user?: Pokemon): boolean { if (Overrides.WEATHER_OVERRIDE) { this.overrideWeather(); return true; } if (!this.canSetWeather(weather)) { return false; } const oldWeatherType = this.weatherType; if ( this.weather?.isImmutable() && ![WeatherType.HARSH_SUN, WeatherType.HEAVY_RAIN, WeatherType.STRONG_WINDS, WeatherType.NONE].includes(weather) ) { globalScene.phaseManager.unshiftNew( "CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (oldWeatherType - 1), ); globalScene.phaseManager.queueMessage(getLegendaryWeatherContinuesMessage(oldWeatherType)!); return false; } const weatherDuration = new NumberHolder(0); if (user != null) { weatherDuration.value = 5; globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration); } this.weather = weather ? new Weather(weather, weatherDuration.value, weatherDuration.value) : null; this.eventTarget.dispatchEvent( new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!), ); // TODO: this `x?.y!` is dumb, fix this if (this.weather) { globalScene.phaseManager.unshiftNew("CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (weather - 1)); globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct? } else { globalScene.phaseManager.queueMessage(getWeatherClearMessage(oldWeatherType)!); // TODO: is this bang correct? } for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) { // TODO: Specify the type of tags which are being removed here pokemon.findAndRemoveTags( tag => "weatherTypes" in tag && !(tag.weatherTypes as WeatherType[]).find(t => t === weather), ); applyAbAttrs("PostWeatherChangeAbAttr", { pokemon, weather }); } return true; } public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean { return !!this.weather && !this.weather.isEffectSuppressed() && this.weather.isMoveWeatherCancelled(user, move); } /** * Function to trigger all weather based form changes * @param source - The Pokemon causing the changes by removing itself from the field */ public triggerWeatherBasedFormChanges(source?: Pokemon): void { for (const p of inSpeedOrder(ArenaTagSide.BOTH)) { // TODO - This is a bandaid. Abilities leaving the field needs a better approach than // calling this method for every switch out that happens if (p === source) { continue; } const isCastformWithForecast = p.hasAbility(AbilityId.FORECAST) && p.species.speciesId === SpeciesId.CASTFORM; const isCherrimWithFlowerGift = p.hasAbility(AbilityId.FLOWER_GIFT) && p.species.speciesId === SpeciesId.CHERRIM; if (isCastformWithForecast || isCherrimWithFlowerGift) { globalScene.triggerPokemonFormChange(p, SpeciesFormChangeWeatherTrigger); } } } /** Function to trigger all weather based form changes back into their normal forms */ public triggerWeatherBasedFormChangesToNormal(): void { for (const p of inSpeedOrder(ArenaTagSide.BOTH)) { const isCastformWithForecast = p.hasAbility(AbilityId.FORECAST, false, true) && p.species.speciesId === SpeciesId.CASTFORM; const isCherrimWithFlowerGift = p.hasAbility(AbilityId.FLOWER_GIFT, false, true) && p.species.speciesId === SpeciesId.CHERRIM; if (isCastformWithForecast || isCherrimWithFlowerGift) { globalScene.triggerPokemonFormChange(p, SpeciesFormChangeRevertWeatherFormTrigger); } } } // #endregion // #region Terrain /** @returns Whether or not the terrain can be set to the specified terrain */ public canSetTerrain(terrain: TerrainType): boolean { return this.terrainType !== terrain; } /** * Attempt to set the current terrain to the specified type. * @param terrain - The {@linkcode TerrainType} to try and set. * @param ignoreAnim - (Default `false`) Whether to prevent showing an the animation * @param user - (Optional) The {@linkcode Pokemon} creating the terrain * @returns Whether the terrain was successfully set. */ public trySetTerrain(terrain: TerrainType, ignoreAnim = false, user?: Pokemon): boolean { if (!this.canSetTerrain(terrain)) { return false; } const oldTerrainType = this.terrainType; const terrainDuration = new NumberHolder(0); if (user != null) { terrainDuration.value = 5; globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration); } this.terrain = terrain ? new Terrain(terrain, terrainDuration.value, terrainDuration.value) : null; this.eventTarget.dispatchEvent( new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!), ); // TODO: are those bangs correct? if (this.terrain) { if (!ignoreAnim) { globalScene.phaseManager.unshiftNew( "CommonAnimPhase", undefined, undefined, CommonAnim.MISTY_TERRAIN + (terrain - 1), ); } globalScene.phaseManager.queueMessage(getTerrainStartMessage(terrain)); } else { globalScene.phaseManager.queueMessage(getTerrainClearMessage(oldTerrainType)); } for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) { pokemon.findAndRemoveTags( t => "terrainTypes" in t && !(t.terrainTypes as TerrainType[]).find(t => t === terrain), ); applyAbAttrs("PostTerrainChangeAbAttr", { pokemon, terrain }); applyAbAttrs("TerrainEventTypeChangeAbAttr", { pokemon }); } return true; } /** Attempt to override the terrain to the value set inside {@linkcode Overrides.STARTING_TERRAIN_OVERRIDE}. */ public tryOverrideTerrain(): void { const terrain = Overrides.STARTING_TERRAIN_OVERRIDE; if (terrain === TerrainType.NONE) { return; } // TODO: Add a flag for permanent terrains this.terrain = new Terrain(terrain, 0); this.eventTarget.dispatchEvent( new TerrainChangedEvent(TerrainType.NONE, this.terrain.terrainType, this.terrain.turnsLeft), ); globalScene.phaseManager.unshiftNew( "CommonAnimPhase", undefined, undefined, CommonAnim.MISTY_TERRAIN + (terrain - 1), ); globalScene.phaseManager.queueMessage(getTerrainStartMessage(terrain) ?? ""); // TODO: Remove `?? ""` when terrain-fail-msg branch removes `null` from these signatures } public isMoveTerrainCancelled(user: Pokemon, targets: BattlerIndex[], move: Move): boolean { return !!this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move); } // #endregion // #region Trainers public randomTrainerType(waveIndex: number, isBoss = false): TrainerType { const isTrainerBoss = this.trainerPool[BiomePoolTier.BOSS].length > 0 && (globalScene.gameMode.isTrainerBoss(waveIndex, this.biomeId, globalScene.offsetGym) || isBoss); console.log(isBoss, this.trainerPool); const tierValue = randSeedInt(isTrainerBoss ? 64 : 512); let tier = isTrainerBoss ? tierValue >= 20 ? BiomePoolTier.BOSS : tierValue >= 6 ? BiomePoolTier.BOSS_RARE : tierValue >= 1 ? BiomePoolTier.BOSS_SUPER_RARE : BiomePoolTier.BOSS_ULTRA_RARE : tierValue >= 156 ? BiomePoolTier.COMMON : tierValue >= 32 ? BiomePoolTier.UNCOMMON : tierValue >= 6 ? BiomePoolTier.RARE : tierValue >= 1 ? BiomePoolTier.SUPER_RARE : BiomePoolTier.ULTRA_RARE; console.log(BiomePoolTier[tier]); while (tier && this.trainerPool[tier].length === 0) { console.log(`Downgraded trainer rarity tier from ${BiomePoolTier[tier]} to ${BiomePoolTier[tier - 1]}`); tier--; } const tierPool = this.trainerPool[tier] || []; return tierPool.length === 0 ? TrainerType.BREEDER : tierPool[randSeedInt(tierPool.length)]; } /** * Gets the denominator for the chance for a trainer spawn * @returns n where 1/n is the chance of a trainer battle */ public getTrainerChance(): number { switch (this.biomeId) { case BiomeId.METROPOLIS: case BiomeId.DOJO: return 4; case BiomeId.PLAINS: case BiomeId.GRASS: case BiomeId.BEACH: case BiomeId.LAKE: case BiomeId.CAVE: case BiomeId.DESERT: case BiomeId.CONSTRUCTION_SITE: case BiomeId.SLUM: return 6; case BiomeId.TALL_GRASS: case BiomeId.FOREST: case BiomeId.SWAMP: case BiomeId.MOUNTAIN: case BiomeId.BADLANDS: case BiomeId.MEADOW: case BiomeId.POWER_PLANT: case BiomeId.FACTORY: case BiomeId.SNOWY_FOREST: return 8; case BiomeId.SEA: case BiomeId.ICE_CAVE: case BiomeId.VOLCANO: case BiomeId.GRAVEYARD: case BiomeId.RUINS: case BiomeId.WASTELAND: case BiomeId.JUNGLE: case BiomeId.FAIRY_CAVE: case BiomeId.ISLAND: return 12; case BiomeId.SEABED: case BiomeId.ABYSS: case BiomeId.SPACE: case BiomeId.TEMPLE: case BiomeId.LABORATORY: return 16; default: return 0; } } // #endregion // #region Pokemon public updatePoolsForTimeOfDay(): void { const timeOfDay = this.getTimeOfDay(); if (timeOfDay !== this.lastTimeOfDay) { this.pokemonPool = {}; for (const tier of Object.keys(biomePokemonPools[this.biomeId])) { this.pokemonPool[tier] = Object.assign([], biomePokemonPools[this.biomeId][tier][TimeOfDay.ALL]).concat( biomePokemonPools[this.biomeId][tier][timeOfDay], ); } this.lastTimeOfDay = timeOfDay; } } public randomSpecies( waveIndex: number, level: number, attempt?: number, luckValue?: number, isBoss?: boolean, ): PokemonSpecies { const overrideSpecies = globalScene.gameMode.getOverrideSpecies(waveIndex); if (overrideSpecies) { return overrideSpecies; } const isBossSpecies = !!globalScene.getEncounterBossSegments(waveIndex, level) && this.pokemonPool[BiomePoolTier.BOSS].length > 0 && (this.biomeId !== BiomeId.END || globalScene.gameMode.isClassic || globalScene.gameMode.isWaveFinal(waveIndex)); const randVal = isBossSpecies ? 64 : 512; // luck influences encounter rarity let luckModifier = 0; if (typeof luckValue !== "undefined") { luckModifier = luckValue * (isBossSpecies ? 0.5 : 2); } const tierValue = randSeedInt(randVal - luckModifier); let tier = getDailyForcedWaveBiomePoolTier(waveIndex) ?? (isBossSpecies ? tierValue >= 20 ? BiomePoolTier.BOSS : tierValue >= 6 ? BiomePoolTier.BOSS_RARE : tierValue >= 1 ? BiomePoolTier.BOSS_SUPER_RARE : BiomePoolTier.BOSS_ULTRA_RARE : tierValue >= 156 ? BiomePoolTier.COMMON : tierValue >= 32 ? BiomePoolTier.UNCOMMON : tierValue >= 6 ? BiomePoolTier.RARE : tierValue >= 1 ? BiomePoolTier.SUPER_RARE : BiomePoolTier.ULTRA_RARE); console.log(BiomePoolTier[tier]); while (this.pokemonPool[tier]?.length === 0) { console.log(`Downgraded rarity tier from ${BiomePoolTier[tier]} to ${BiomePoolTier[tier - 1]}`); tier--; } const tierPool = this.pokemonPool[tier]; let ret: PokemonSpecies; let regen = false; if (tierPool.length === 0) { ret = globalScene.randomSpecies(waveIndex, level); } else { // TODO: should this use `randSeedItem`? const entry = tierPool[randSeedInt(tierPool.length)]; let species: SpeciesId; if (typeof entry === "number") { species = entry as SpeciesId; } else { const levelThresholds = Object.keys(entry); for (let l = levelThresholds.length - 1; l >= 0; l--) { const levelThreshold = Number.parseInt(levelThresholds[l]); if (level >= levelThreshold) { const speciesIds = entry[levelThreshold]; if (speciesIds.length > 1) { // TODO: should this use `randSeedItem`? species = speciesIds[randSeedInt(speciesIds.length)]; } else { species = speciesIds[0]; } break; } } } ret = getPokemonSpecies(species!); if (ret.subLegendary || ret.legendary || ret.mythical) { const waveDifficulty = globalScene.gameMode.getWaveForDifficulty(waveIndex, true); if (ret.baseTotal >= 660) { regen = waveDifficulty < 80; // Wave 50+ in daily (however, max Daily wave is 50 currently so not possible) } else { regen = waveDifficulty < 55; // Wave 25+ in daily } } } if (regen && (attempt || 0) < 10) { console.log("Incompatible level: regenerating..."); return this.randomSpecies(waveIndex, level, (attempt || 0) + 1); } const newSpeciesId = ret.getWildSpeciesForLevel(level, true, isBoss ?? isBossSpecies, globalScene.gameMode); if (newSpeciesId !== ret.speciesId) { console.log("Replaced", SpeciesId[ret.speciesId], "with", SpeciesId[newSpeciesId]); ret = getPokemonSpecies(newSpeciesId); } return ret; } // #endregion // #region Arena Tags /** * Applies each `ArenaTag` in this Arena, based on which side (self, enemy, or both) is passed in as a parameter * @param tagType - A constructor of an ArenaTag to filter tags by * @param side - The {@linkcode ArenaTagSide} dictating which side's arena tags to apply * @param args - Parameters for the tag * @privateRemarks * If you get errors mentioning incompatibility with overload signatures, review the arguments being passed * to ensure they are correct for the tag being used. */ public applyTagsForSide( tagType: Constructor | AbstractConstructor, side: ArenaTagSide, ...args: Parameters ): void; /** * Applies each `ArenaTag` in this Arena, based on which side (self, enemy, or both) is passed in as a parameter * @param tagType - The {@linkcode ArenaTagType} of the desired tag * @param side - The {@linkcode ArenaTagSide} dictating which side's arena tags to apply * @param args - Parameters for the tag */ public applyTagsForSide( tagType: T, side: ArenaTagSide, ...args: Parameters ): void; public applyTagsForSide( tagType: T["tagType"] | Constructor | AbstractConstructor, side: ArenaTagSide, ...args: Parameters ): void { /** A lambda function to filter out incompatible tag types. */ const sameTagType: (tag: ArenaTag) => tag is T = typeof tagType === "string" // formatting ? (t): t is T => t.tagType === tagType : (t): t is T => t instanceof tagType; for (const tag of this.tags) { if (!sameTagType(tag)) { continue; } if (side !== ArenaTagSide.BOTH && tag.side !== ArenaTagSide.BOTH && side !== tag.side) { continue; } tag.apply(...args); } } /** * Applies the specified tag to both sides (ie: both user and trainer's tag that match the Tag specified) * by calling {@linkcode applyTagsForSide()} * @param tagType - The {@linkcode ArenaTagType} of the desired tag * @param args - Parameters for the tag */ public applyTags(tagType: T, ...args: Parameters): void; /** * Applies the specified tag to both sides (ie: both user and trainer's tag that match the Tag specified) * by calling {@linkcode applyTagsForSide()} * @param tagType - A constructor of an ArenaTag to filter tags by * @param args - Parameters for the tag * @deprecated Use an `ArenaTagType` for `tagType` instead of an `ArenaTag` constructor */ public applyTags( tagType: Constructor | AbstractConstructor, ...args: Parameters ): void; public applyTags( tagType: T["tagType"] | Constructor | AbstractConstructor, ...args: Parameters ) { // @ts-expect-error - Overload resolution this.applyTagsForSide(tagType, ArenaTagSide.BOTH, ...args); } /** * Add a new {@linkcode ArenaTag} to the arena, triggering overlap effects on existing tags as applicable. * @param tagType - The {@linkcode ArenaTagType} of the tag to add. * @param turnCount - The number of turns the newly-added tag should last. * @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon creating the tag. * @param sourceMove - The {@linkcode MoveId} of the move creating the tag, or `undefined` if not from a move. * @param side - The {@linkcode ArenaTagSide}(s) to which the tag should apply; default `ArenaTagSide.BOTH`. * @param quiet - Whether to suppress messages produced by tag addition; default `false`. * @returns `true` if the tag was successfully added without overlapping. */ // TODO: Do we need the return value here? literally nothing uses it public addTag( tagType: ArenaTagType, turnCount: number, sourceMove: MoveId | undefined, sourceId: number, side: ArenaTagSide = ArenaTagSide.BOTH, quiet = false, ): boolean { const existingTag = this.getTagOnSide(tagType, side); if (existingTag) { existingTag.onOverlap(globalScene.getPokemonById(sourceId)); if (existingTag instanceof EntryHazardTag) { const { tagType, side, turnCount, maxDuration, layers, maxLayers } = existingTag as EntryHazardTag; this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, maxDuration, layers, maxLayers)); } return false; } // creates a new tag object const newTag = getArenaTag(tagType, turnCount, sourceMove, sourceId, side); if (newTag) { newTag.onAdd(quiet); this.tags.push(newTag); const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {}; this.eventTarget.dispatchEvent( new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, newTag.maxDuration, layers, maxLayers), ); } return true; } /** * Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides * @param tagType - The {@linkcode ArenaTagType} to retrieve * @returns The existing {@linkcode ArenaTag}, or `undefined` if not present. */ public getTag(tagType: ArenaTagType): ArenaTag | undefined; /** * Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides * @param tagType - The constructor of the {@linkcode ArenaTag} to retrieve * @returns The existing {@linkcode ArenaTag}, or `undefined` if not present. */ public getTag(tagType: Constructor | AbstractConstructor): T | undefined; public getTag(tagType: ArenaTagType | Constructor | AbstractConstructor): ArenaTag | undefined { return this.getTagOnSide(tagType, ArenaTagSide.BOTH); } /** * Check whether the Arena has any of the given tags. * @param tagTypes - One or more tag types to check * @returns Whether a tag exists on either side of the field with any of the given type(s). */ public hasTag(tagTypes: ArenaTagType | NonEmptyTuple): boolean { tagTypes = coerceArray(tagTypes); return this.tags.some(tag => tagTypes.includes(tag.tagType)); } /** * Attempts to get a tag from the Arena from a specific side (the tag passed in has to either apply to both sides, or the specific side only) * * eg: `MIST` only applies to the user's side, while `MUD_SPORT` applies to both user and enemy side * @param tagType The {@linkcode ArenaTagType} or {@linkcode ArenaTag} to get * @param side The {@linkcode ArenaTagSide} to look at * @returns either the {@linkcode ArenaTag}, or `undefined` if it isn't there */ public getTagOnSide( tagType: ArenaTagType | Constructor | AbstractConstructor, side: ArenaTagSide, ): ArenaTag | undefined { return typeof tagType === "string" ? this.tags.find( t => t.tagType === tagType && (side === ArenaTagSide.BOTH || t.side === ArenaTagSide.BOTH || t.side === side), ) : this.tags.find( t => t instanceof tagType && (side === ArenaTagSide.BOTH || t.side === ArenaTagSide.BOTH || t.side === side), ); } // TODO: Add an overload similar to `Array.prototype.find` if the predicate func is of the form // `(x): x is T` /** * Uses {@linkcode findTagsOnSide} to filter (using the parameter function) for specific tags that apply to both sides * @param tagPredicate a function mapping {@linkcode ArenaTag}s to `boolean`s * @returns array of {@linkcode ArenaTag}s from which the Arena's tags return true and apply to both sides */ public findTags(tagPredicate: (t: ArenaTag) => boolean): ArenaTag[] { return this.findTagsOnSide(tagPredicate, ArenaTagSide.BOTH); } /** * Returns specific tags from the arena that pass the `tagPredicate` function passed in as a parameter, and apply to the given side * @param tagPredicate a function mapping {@linkcode ArenaTag}s to `boolean`s * @param side The {@linkcode ArenaTagSide} to look at * @returns array of {@linkcode ArenaTag}s from which the Arena's tags return `true` and apply to the given side */ public findTagsOnSide(tagPredicate: (t: ArenaTag) => boolean, side: ArenaTagSide): ArenaTag[] { return this.tags.filter( t => tagPredicate(t) && (side === ArenaTagSide.BOTH || t.side === ArenaTagSide.BOTH || t.side === side), ); } public lapseTags(): void { this.tags .filter(t => !t.lapse()) .forEach(t => { t.onRemove(); this.tags.splice(this.tags.indexOf(t), 1); this.eventTarget.dispatchEvent(new TagRemovedEvent(t.tagType, t.side, t.turnCount)); }); } public removeTag(tagType: ArenaTagType): boolean { const tags = this.tags; const tag = tags.find(t => t.tagType === tagType); if (tag) { tag.onRemove(); tags.splice(tags.indexOf(tag), 1); this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); } return !!tag; } public removeTagOnSide(tagType: ArenaTagType, side: ArenaTagSide, quiet = false): boolean { const tag = this.getTagOnSide(tagType, side); if (tag) { tag.onRemove(quiet); this.tags.splice(this.tags.indexOf(tag), 1); this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); } return !!tag; } /** * Find and remove all {@linkcode ArenaTag}s with the given tag types on the given side of the field. * @param tagTypes - The {@linkcode ArenaTagType}s to remove * @param side - The {@linkcode ArenaTagSide} to remove the tags from (for side-based tags), or {@linkcode ArenaTagSide.BOTH} * to clear all tags on either side of the field * @param quiet - Whether to suppress removal messages from currently-present tags; default `false` * @todo Review the other tag manipulation functions to see if they can be migrated towards using this (more efficient + foolproof) */ public removeTagsOnSide(tagTypes: ArenaTagType[] | readonly ArenaTagType[], side: ArenaTagSide, quiet = false): void { const leftoverTags: ArenaTag[] = []; for (const tag of this.tags) { // Skip tags of different types or on the wrong side of the field if ( !tagTypes.includes(tag.tagType) || !(side === ArenaTagSide.BOTH || tag.side === ArenaTagSide.BOTH || tag.side === side) ) { leftoverTags.push(tag); continue; } tag.onRemove(quiet); this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); } this.tags = leftoverTags; } public removeAllTags(): void { while (this.tags.length > 0) { this.tags[0].onRemove(); this.eventTarget.dispatchEvent( new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount), ); this.tags.splice(0, 1); } } // #endregion // #region Time of Day public getTimeOfDay(): TimeOfDay { switch (this.biomeId) { case BiomeId.ABYSS: return TimeOfDay.NIGHT; } if (Overrides.TIME_OF_DAY_OVERRIDE !== null) { return Overrides.TIME_OF_DAY_OVERRIDE; } const waveCycle = ((globalScene.currentBattle?.waveIndex ?? 0) + globalScene.waveCycleOffset) % 40; if (waveCycle < 15) { return TimeOfDay.DAY; } if (waveCycle < 20) { return TimeOfDay.DUSK; } if (waveCycle < 35) { return TimeOfDay.NIGHT; } return TimeOfDay.DAWN; } /** * @returns Whether the current biome takes place "outdoors" * (for the purposes of time of day tints) */ public isOutside(): boolean { switch (this.biomeId) { case BiomeId.SEABED: case BiomeId.CAVE: case BiomeId.ICE_CAVE: case BiomeId.POWER_PLANT: case BiomeId.DOJO: case BiomeId.FACTORY: case BiomeId.ABYSS: case BiomeId.FAIRY_CAVE: case BiomeId.TEMPLE: case BiomeId.LABORATORY: return false; default: return true; } } public getDayTint(): RGBArray { switch (this.biomeId) { case BiomeId.ABYSS: return [64, 64, 64]; default: return [128, 128, 128]; } } public getDuskTint(): RGBArray { if (!this.isOutside()) { return [0, 0, 0]; } return [113, 88, 101]; } public getNightTint(): RGBArray { switch (this.biomeId) { case BiomeId.ABYSS: case BiomeId.SPACE: case BiomeId.END: return this.getDayTint(); } if (!this.isOutside()) { return [64, 64, 64]; } return [48, 48, 98]; } // #endregion // TODO: replace this getAttackTypeMultiplier(attackType: PokemonType, grounded: boolean): number { let weatherMultiplier = 1; if (this.weather && !this.weather.isEffectSuppressed()) { weatherMultiplier = this.weather.getAttackTypeMultiplier(attackType); } let terrainMultiplier = 1; if (this.terrain && grounded) { terrainMultiplier = this.terrain.getAttackTypeMultiplier(attackType); } return weatherMultiplier * terrainMultiplier; } } // #region Helper Functions export function getBiomeKey(biome: BiomeId): string { return BiomeId[biome].toLowerCase(); } export function getBiomeHasProps(biomeType: BiomeId): boolean { switch (biomeType) { case BiomeId.PLAINS: case BiomeId.METROPOLIS: case BiomeId.BEACH: case BiomeId.LAKE: case BiomeId.SEABED: case BiomeId.MOUNTAIN: case BiomeId.BADLANDS: case BiomeId.CAVE: case BiomeId.DESERT: case BiomeId.ICE_CAVE: case BiomeId.MEADOW: case BiomeId.POWER_PLANT: case BiomeId.VOLCANO: case BiomeId.GRAVEYARD: case BiomeId.FACTORY: case BiomeId.RUINS: case BiomeId.WASTELAND: case BiomeId.ABYSS: case BiomeId.CONSTRUCTION_SITE: case BiomeId.JUNGLE: case BiomeId.FAIRY_CAVE: case BiomeId.TEMPLE: case BiomeId.SNOWY_FOREST: case BiomeId.ISLAND: case BiomeId.LABORATORY: case BiomeId.END: return true; } return false; } export function getBgTerrainColorRatioForBiome(biomeId: BiomeId): number { switch (biomeId) { case BiomeId.SPACE: return 1; case BiomeId.END: return 0; } return 131 / 180; } /** The loop point of any given biome track, read as seconds and milliseconds. */ export function getArenaBgmLoopPoint(biomeId: BiomeId): number { switch (biomeId) { case BiomeId.TOWN: return 7.288; case BiomeId.PLAINS: return 17.485; case BiomeId.GRASS: return 1.995; case BiomeId.TALL_GRASS: return 9.608; case BiomeId.METROPOLIS: return 141.47; case BiomeId.FOREST: return 0.341; case BiomeId.SEA: return 0.024; case BiomeId.SWAMP: return 4.461; case BiomeId.BEACH: return 3.462; case BiomeId.LAKE: return 7.215; case BiomeId.SEABED: return 2.6; case BiomeId.MOUNTAIN: return 4.018; case BiomeId.BADLANDS: return 17.79; case BiomeId.CAVE: return 14.24; case BiomeId.DESERT: return 9.02; case BiomeId.ICE_CAVE: return 0.0; case BiomeId.MEADOW: return 3.891; case BiomeId.POWER_PLANT: return 9.447; case BiomeId.VOLCANO: return 17.637; case BiomeId.GRAVEYARD: return 13.711; case BiomeId.DOJO: return 6.205; case BiomeId.FACTORY: return 4.985; case BiomeId.RUINS: return 0.0; case BiomeId.WASTELAND: return 6.024; case BiomeId.ABYSS: return 20.113; case BiomeId.SPACE: return 20.036; case BiomeId.CONSTRUCTION_SITE: return 1.222; case BiomeId.JUNGLE: return 0.0; case BiomeId.FAIRY_CAVE: return 0.0; case BiomeId.TEMPLE: return 2.547; case BiomeId.ISLAND: return 2.751; case BiomeId.LABORATORY: return 114.862; case BiomeId.SLUM: return 0.0; case BiomeId.SNOWY_FOREST: return 3.814; case BiomeId.END: return 17.153; default: biomeId satisfies never; console.warn(`missing bgm loop-point for biome "${BiomeId[biomeId]}" (=${biomeId})`); return 0; } } // #endregion