pokerogue/src/field/arena.ts
AJ Fontaine e3108603e3
[Refactor] Rework evolution conditions and descriptions (#5679)
* Refactor evo conditions and descriptions

* Fix test

* Fix Shedinja

* Simplify Gimmighoul evolution

* Primeape and Stantler evolve by using their move 10 times

* Basculin white stripe evolves by taking 294 recoil damage

* Primeape and Stantler use modifiers for tracking

* Basculin uses modifier too

* Remove evo count from pokemon data

* No more evo counter data, Gallade/Froslass

* Fix allmoves import

* Clamperl

* Struggle shouldn't count for Basc recoil

* Change to nicer type

* Apply Benjie's suggestions

Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>

* Address formatting

* Undo new evolution changes

* Remove unused imports

* Fix speciesid

* Fixed up descriptions a little

* Change a key name

* Fix Gimmighoul

* Apply Biome

* Apply Biome unsafe fixes

* Review suggestions

- Convert `EvoCondKey` enum to `const` object

- Use early returns in `SpeciesEvolutionCondition#description`
and `SpeciesFormEvolution#description`

- Replace `!!x.find` with `x.some`
and `y.indexOf() > -1` with `y.includes()`

- Implement `coerceArray`

- Fix Shelmet evolution condition
checking for Shelmet and not Karrablast

- Remove unnecessary type casting in `battle-scene.ts`

* Remove leftover enforce func loop

* Fix circular imports issue

- `getPokemonSpecies` moved to `src/utils/pokemon-utils.ts`
- `allSpecies` moved to `src/data/data-lists.ts`

---------

Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-06-16 05:57:51 -07:00

1069 lines
34 KiB
TypeScript

import { globalScene } from "#app/global-scene";
import type { BiomeTierTrainerPools, PokemonPools } from "#app/data/balance/biomes";
import { biomePokemonPools, BiomePoolTier, biomeTrainerPools } from "#app/data/balance/biomes";
import { randSeedInt, NumberHolder, isNullOrUndefined, type Constructor } from "#app/utils/common";
import type PokemonSpecies from "#app/data/pokemon-species";
import { getPokemonSpecies } from "#app/utils/pokemon-utils";
import {
getTerrainClearMessage,
getTerrainStartMessage,
getWeatherClearMessage,
getWeatherStartMessage,
getLegendaryWeatherContinuesMessage,
Weather,
} from "#app/data/weather";
import { CommonAnim } from "#enums/move-anims-common";
import type { PokemonType } from "#enums/pokemon-type";
import type Move from "#app/data/moves/move";
import type { ArenaTag } from "#app/data/arena-tag";
import { ArenaTrapTag, getArenaTag } from "#app/data/arena-tag";
import { ArenaTagSide } from "#enums/arena-tag-side";
import type { BattlerIndex } from "#enums/battler-index";
import { Terrain, TerrainType } from "#app/data/terrain";
import {
applyAbAttrs,
applyPostTerrainChangeAbAttrs,
applyPostWeatherChangeAbAttrs,
} from "#app/data/abilities/apply-ab-attrs";
import type Pokemon from "#app/field/pokemon";
import Overrides from "#app/overrides";
import { TagAddedEvent, TagRemovedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#app/events/arena";
import type { ArenaTagType } from "#enums/arena-tag-type";
import { BiomeId } from "#enums/biome-id";
import type { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { TimeOfDay } from "#enums/time-of-day";
import { TrainerType } from "#enums/trainer-type";
import { AbilityId } from "#enums/ability-id";
import {
SpeciesFormChangeRevertWeatherFormTrigger,
SpeciesFormChangeWeatherTrigger,
} from "#app/data/pokemon-forms/form-change-triggers";
import { WeatherType } from "#enums/weather-type";
import { FieldEffectModifier } from "#app/modifier/modifier";
export class Arena {
public biomeType: BiomeId;
public weather: Weather | null;
public terrain: Terrain | null;
public tags: ArenaTag[];
public bgm: string;
public ignoreAbilities: boolean;
public ignoringEffectSource: BattlerIndex | null;
public playerTerasUsed: number;
/**
* 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 trainerPool: BiomeTierTrainerPools;
public readonly eventTarget: EventTarget = new EventTarget();
constructor(biome: BiomeId, bgm: string, playerFaints = 0) {
this.biomeType = biome;
this.tags = [];
this.bgm = bgm;
this.trainerPool = biomeTrainerPools[biome];
this.updatePoolsForTimeOfDay();
this.playerTerasUsed = 0;
this.playerFaints = playerFaints;
}
init() {
const biomeKey = getBiomeKey(this.biomeType);
globalScene.arenaPlayer.setBiome(this.biomeType);
globalScene.arenaPlayerTransition.setBiome(this.biomeType);
globalScene.arenaEnemy.setBiome(this.biomeType);
globalScene.arenaNextEnemy.setBiome(this.biomeType);
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();
}
updatePoolsForTimeOfDay(): void {
const timeOfDay = this.getTimeOfDay();
if (timeOfDay !== this.lastTimeOfDay) {
this.pokemonPool = {};
for (const tier of Object.keys(biomePokemonPools[this.biomeType])) {
this.pokemonPool[tier] = Object.assign([], biomePokemonPools[this.biomeType][tier][TimeOfDay.ALL]).concat(
biomePokemonPools[this.biomeType][tier][timeOfDay],
);
}
this.lastTimeOfDay = timeOfDay;
}
}
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 &&
(this.biomeType !== 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 = !isBossSpecies
? tierValue >= 156
? BiomePoolTier.COMMON
: tierValue >= 32
? BiomePoolTier.UNCOMMON
: tierValue >= 6
? BiomePoolTier.RARE
: tierValue >= 1
? BiomePoolTier.SUPER_RARE
: BiomePoolTier.ULTRA_RARE
: tierValue >= 20
? BiomePoolTier.BOSS
: tierValue >= 6
? BiomePoolTier.BOSS_RARE
: tierValue >= 1
? BiomePoolTier.BOSS_SUPER_RARE
: BiomePoolTier.BOSS_ULTRA_RARE;
console.log(BiomePoolTier[tier]);
while (!this.pokemonPool[tier].length) {
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) {
ret = globalScene.randomSpecies(waveIndex, level);
} else {
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) {
species = speciesIds[randSeedInt(speciesIds.length)];
} else {
species = speciesIds[0];
}
break;
}
}
}
ret = getPokemonSpecies(species!);
if (ret.subLegendary || ret.legendary || ret.mythical) {
switch (true) {
case ret.baseTotal >= 720:
regen = level < 90;
break;
case ret.baseTotal >= 670:
regen = level < 70;
break;
case ret.baseTotal >= 580:
regen = level < 50;
break;
default:
regen = level < 30;
break;
}
}
}
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;
}
randomTrainerType(waveIndex: number, isBoss = false): TrainerType {
const isTrainerBoss =
!!this.trainerPool[BiomePoolTier.BOSS].length &&
(globalScene.gameMode.isTrainerBoss(waveIndex, this.biomeType, globalScene.offsetGym) || isBoss);
console.log(isBoss, this.trainerPool);
const tierValue = randSeedInt(!isTrainerBoss ? 512 : 64);
let tier = !isTrainerBoss
? tierValue >= 156
? BiomePoolTier.COMMON
: tierValue >= 32
? BiomePoolTier.UNCOMMON
: tierValue >= 6
? BiomePoolTier.RARE
: tierValue >= 1
? BiomePoolTier.SUPER_RARE
: BiomePoolTier.ULTRA_RARE
: tierValue >= 20
? BiomePoolTier.BOSS
: tierValue >= 6
? BiomePoolTier.BOSS_RARE
: tierValue >= 1
? BiomePoolTier.BOSS_SUPER_RARE
: BiomePoolTier.BOSS_ULTRA_RARE;
console.log(BiomePoolTier[tier]);
while (tier && !this.trainerPool[tier].length) {
console.log(`Downgraded trainer rarity tier from ${BiomePoolTier[tier]} to ${BiomePoolTier[tier - 1]}`);
tier--;
}
const tierPool = this.trainerPool[tier] || [];
return !tierPool.length ? TrainerType.BREEDER : tierPool[randSeedInt(tierPool.length)];
}
getSpeciesFormIndex(species: PokemonSpecies): number {
switch (species.speciesId) {
case SpeciesId.BURMY:
case SpeciesId.WORMADAM:
switch (this.biomeType) {
case BiomeId.BEACH:
return 1;
case BiomeId.SLUM:
return 2;
}
break;
case SpeciesId.ROTOM:
switch (this.biomeType) {
case BiomeId.VOLCANO:
return 1;
case BiomeId.SEA:
return 2;
case BiomeId.ICE_CAVE:
return 3;
case BiomeId.MOUNTAIN:
return 4;
case BiomeId.TALL_GRASS:
return 5;
}
break;
case SpeciesId.LYCANROC: {
const timeOfDay = this.getTimeOfDay();
switch (timeOfDay) {
case TimeOfDay.DAY:
case TimeOfDay.DAWN:
return 0;
case TimeOfDay.DUSK:
return 2;
case TimeOfDay.NIGHT:
return 1;
}
break;
}
}
return 0;
}
getBgTerrainColorRatioForBiome(): number {
switch (this.biomeType) {
case BiomeId.SPACE:
return 1;
case BiomeId.END:
return 0;
}
return 131 / 180;
}
/**
* Sets weather to the override specified in overrides.ts
* @param weather new {@linkcode WeatherType} to set
* @returns true to force trySetWeather to return true
*/
trySetWeatherOverride(weather: WeatherType): boolean {
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?
return true;
}
/** Returns weather or not the weather can be changed to {@linkcode weather} */
canSetWeather(weather: WeatherType): boolean {
return !(this.weather?.weatherType === (weather || undefined));
}
/**
* 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
*/
trySetWeather(weather: WeatherType, user?: Pokemon): boolean {
if (Overrides.WEATHER_OVERRIDE) {
return this.trySetWeatherOverride(Overrides.WEATHER_OVERRIDE);
}
if (!this.canSetWeather(weather)) {
return false;
}
const oldWeatherType = this.weather?.weatherType || WeatherType.NONE;
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),
true,
);
globalScene.phaseManager.queueMessage(getLegendaryWeatherContinuesMessage(oldWeatherType)!);
return false;
}
const weatherDuration = new NumberHolder(0);
if (!isNullOrUndefined(user)) {
weatherDuration.value = 5;
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration);
}
this.weather = weather ? new Weather(weather, weatherDuration.value) : null;
this.eventTarget.dispatchEvent(
new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!),
); // TODO: is this bang correct?
if (this.weather) {
globalScene.phaseManager.unshiftNew(
"CommonAnimPhase",
undefined,
undefined,
CommonAnim.SUNNY + (weather - 1),
true,
);
globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct?
} else {
globalScene.phaseManager.queueMessage(getWeatherClearMessage(oldWeatherType)!); // TODO: is this bang correct?
}
globalScene
.getField(true)
.filter(p => p.isOnField())
.map(pokemon => {
pokemon.findAndRemoveTags(
t => "weatherTypes" in t && !(t.weatherTypes as WeatherType[]).find(t => t === weather),
);
applyPostWeatherChangeAbAttrs("PostWeatherChangeAbAttr", pokemon, weather);
});
return true;
}
/**
* Function to trigger all weather based form changes
*/
triggerWeatherBasedFormChanges(): void {
globalScene.getField(true).forEach(p => {
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
*/
triggerWeatherBasedFormChangesToNormal(): void {
globalScene.getField(true).forEach(p => {
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) {
return globalScene.triggerPokemonFormChange(p, SpeciesFormChangeRevertWeatherFormTrigger);
}
});
}
/** Returns whether or not the terrain can be set to {@linkcode terrain} */
canSetTerrain(terrain: TerrainType): boolean {
return !(this.terrain?.terrainType === (terrain || undefined));
}
/**
* Attempts to set a new terrain effect to the battle
* @param terrain {@linkcode TerrainType} new {@linkcode TerrainType} to set
* @param ignoreAnim boolean if the terrain animation should be ignored
* @param user {@linkcode Pokemon} that caused the terrain effect
* @returns true if new terrain set, false if no terrain provided or attempting to set the same terrain as currently in use
*/
trySetTerrain(terrain: TerrainType, ignoreAnim = false, user?: Pokemon): boolean {
if (!this.canSetTerrain(terrain)) {
return false;
}
const oldTerrainType = this.terrain?.terrainType || TerrainType.NONE;
const terrainDuration = new NumberHolder(0);
if (!isNullOrUndefined(user)) {
terrainDuration.value = 5;
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration);
}
this.terrain = terrain ? new Terrain(terrain, 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)!); // TODO: is this bang correct?
} else {
globalScene.phaseManager.queueMessage(getTerrainClearMessage(oldTerrainType)!); // TODO: is this bang correct?
}
globalScene
.getField(true)
.filter(p => p.isOnField())
.map(pokemon => {
pokemon.findAndRemoveTags(
t => "terrainTypes" in t && !(t.terrainTypes as TerrainType[]).find(t => t === terrain),
);
applyPostTerrainChangeAbAttrs("PostTerrainChangeAbAttr", pokemon, terrain);
applyAbAttrs("TerrainEventTypeChangeAbAttr", pokemon, null, false);
});
return true;
}
public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean {
return !!this.weather && !this.weather.isEffectSuppressed() && this.weather.isMoveWeatherCancelled(user, move);
}
public isMoveTerrainCancelled(user: Pokemon, targets: BattlerIndex[], move: Move): boolean {
return !!this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move);
}
public getWeatherType(): WeatherType {
return this.weather?.weatherType ?? WeatherType.NONE;
}
public getTerrainType(): TerrainType {
return this.terrain?.terrainType ?? TerrainType.NONE;
}
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;
}
/**
* Gets the denominator for the chance for a trainer spawn
* @returns n where 1/n is the chance of a trainer battle
*/
getTrainerChance(): number {
switch (this.biomeType) {
case BiomeId.METROPOLIS:
return 2;
case BiomeId.SLUM:
case BiomeId.BEACH:
case BiomeId.DOJO:
case BiomeId.CONSTRUCTION_SITE:
return 4;
case BiomeId.PLAINS:
case BiomeId.GRASS:
case BiomeId.LAKE:
case BiomeId.CAVE:
return 6;
case BiomeId.TALL_GRASS:
case BiomeId.FOREST:
case BiomeId.SEA:
case BiomeId.SWAMP:
case BiomeId.MOUNTAIN:
case BiomeId.BADLANDS:
case BiomeId.DESERT:
case BiomeId.MEADOW:
case BiomeId.POWER_PLANT:
case BiomeId.GRAVEYARD:
case BiomeId.FACTORY:
case BiomeId.SNOWY_FOREST:
return 8;
case BiomeId.ICE_CAVE:
case BiomeId.VOLCANO:
case BiomeId.RUINS:
case BiomeId.WASTELAND:
case BiomeId.JUNGLE:
case BiomeId.FAIRY_CAVE:
return 12;
case BiomeId.SEABED:
case BiomeId.ABYSS:
case BiomeId.SPACE:
case BiomeId.TEMPLE:
return 16;
default:
return 0;
}
}
getTimeOfDay(): TimeOfDay {
switch (this.biomeType) {
case BiomeId.ABYSS:
return TimeOfDay.NIGHT;
}
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;
}
isOutside(): boolean {
switch (this.biomeType) {
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;
}
}
overrideTint(): [number, number, number] {
switch (Overrides.ARENA_TINT_OVERRIDE) {
case TimeOfDay.DUSK:
return [98, 48, 73].map(c => Math.round((c + 128) / 2)) as [number, number, number];
case TimeOfDay.NIGHT:
return [64, 64, 64];
case TimeOfDay.DAWN:
case TimeOfDay.DAY:
default:
return [128, 128, 128];
}
}
getDayTint(): [number, number, number] {
if (Overrides.ARENA_TINT_OVERRIDE !== null) {
return this.overrideTint();
}
switch (this.biomeType) {
case BiomeId.ABYSS:
return [64, 64, 64];
default:
return [128, 128, 128];
}
}
getDuskTint(): [number, number, number] {
if (Overrides.ARENA_TINT_OVERRIDE) {
return this.overrideTint();
}
if (!this.isOutside()) {
return [0, 0, 0];
}
switch (this.biomeType) {
default:
return [98, 48, 73].map(c => Math.round((c + 128) / 2)) as [number, number, number];
}
}
getNightTint(): [number, number, number] {
if (Overrides.ARENA_TINT_OVERRIDE) {
return this.overrideTint();
}
switch (this.biomeType) {
case BiomeId.ABYSS:
case BiomeId.SPACE:
case BiomeId.END:
return this.getDayTint();
}
if (!this.isOutside()) {
return [64, 64, 64];
}
switch (this.biomeType) {
default:
return [48, 48, 98];
}
}
setIgnoreAbilities(ignoreAbilities: boolean, ignoringEffectSource: BattlerIndex | null = null): void {
this.ignoreAbilities = ignoreAbilities;
this.ignoringEffectSource = ignoreAbilities ? ignoringEffectSource : null;
}
/**
* Applies each `ArenaTag` in this Arena, based on which side (self, enemy, or both) is passed in as a parameter
* @param tagType Either an {@linkcode ArenaTagType} string, or an actual {@linkcode ArenaTag} class to filter which ones to apply
* @param side {@linkcode ArenaTagSide} which side's arena tags to apply
* @param simulated if `true`, this applies arena tags without changing game state
* @param args array of parameters that the called upon tags may need
*/
applyTagsForSide(
tagType: ArenaTagType | Constructor<ArenaTag>,
side: ArenaTagSide,
simulated: boolean,
...args: unknown[]
): void {
let tags =
typeof tagType === "string"
? this.tags.filter(t => t.tagType === tagType)
: this.tags.filter(t => t instanceof tagType);
if (side !== ArenaTagSide.BOTH) {
tags = tags.filter(t => t.side === side);
}
tags.forEach(t => t.apply(this, simulated, ...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 Either an {@linkcode ArenaTagType} string, or an actual {@linkcode ArenaTag} class to filter which ones to apply
* @param simulated if `true`, this applies arena tags without changing game state
* @param args array of parameters that the called upon tags may need
*/
applyTags(tagType: ArenaTagType | Constructor<ArenaTag>, simulated: boolean, ...args: unknown[]): void {
this.applyTagsForSide(tagType, ArenaTagSide.BOTH, simulated, ...args);
}
/**
* Adds a new tag to the arena
* @param tagType {@linkcode ArenaTagType} the tag being added
* @param turnCount How many turns the tag lasts
* @param sourceMove {@linkcode MoveId} the move the tag came from, or `undefined` if not from a move
* @param sourceId The ID of the pokemon in play the tag came from (see {@linkcode BattleScene.getPokemonById})
* @param side {@linkcode ArenaTagSide} which side(s) the tag applies to
* @param quiet If a message should be queued on screen to announce the tag being added
* @param targetIndex The {@linkcode BattlerIndex} of the target pokemon
* @returns `false` if there already exists a tag of this type in the Arena
*/
addTag(
tagType: ArenaTagType,
turnCount: number,
sourceMove: MoveId | undefined,
sourceId: number,
side: ArenaTagSide = ArenaTagSide.BOTH,
quiet = false,
targetIndex?: BattlerIndex,
): boolean {
const existingTag = this.getTagOnSide(tagType, side);
if (existingTag) {
existingTag.onOverlap(this, globalScene.getPokemonById(sourceId));
if (existingTag instanceof ArenaTrapTag) {
const { tagType, side, turnCount, layers, maxLayers } = existingTag as ArenaTrapTag;
this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, layers, maxLayers));
}
return false;
}
// creates a new tag object
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, targetIndex, side);
if (newTag) {
newTag.onAdd(this, quiet);
this.tags.push(newTag);
const { layers = 0, maxLayers = 0 } = newTag instanceof ArenaTrapTag ? newTag : {};
this.eventTarget.dispatchEvent(
new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, layers, maxLayers),
);
}
return true;
}
/**
* Attempts to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType The {@linkcode ArenaTagType} or {@linkcode ArenaTag} to get
* @returns either the {@linkcode ArenaTag}, or `undefined` if it isn't there
*/
getTag(tagType: ArenaTagType | Constructor<ArenaTag>): ArenaTag | undefined {
return this.getTagOnSide(tagType, ArenaTagSide.BOTH);
}
hasTag(tagType: ArenaTagType): boolean {
return !!this.getTag(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
*/
getTagOnSide(tagType: ArenaTagType | Constructor<ArenaTag>, 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
*/
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
*/
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),
);
}
lapseTags(): void {
this.tags
.filter(t => !t.lapse(this))
.forEach(t => {
t.onRemove(this);
this.tags.splice(this.tags.indexOf(t), 1);
this.eventTarget.dispatchEvent(new TagRemovedEvent(t.tagType, t.side, t.turnCount));
});
}
removeTag(tagType: ArenaTagType): boolean {
const tags = this.tags;
const tag = tags.find(t => t.tagType === tagType);
if (tag) {
tag.onRemove(this);
tags.splice(tags.indexOf(tag), 1);
this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount));
}
return !!tag;
}
removeTagOnSide(tagType: ArenaTagType, side: ArenaTagSide, quiet = false): boolean {
const tag = this.getTagOnSide(tagType, side);
if (tag) {
tag.onRemove(this, quiet);
this.tags.splice(this.tags.indexOf(tag), 1);
this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount));
}
return !!tag;
}
removeAllTags(): void {
while (this.tags.length) {
this.tags[0].onRemove(this);
this.eventTarget.dispatchEvent(
new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount),
);
this.tags.splice(0, 1);
}
}
/**
* Clears weather, terrain and arena tags when entering new biome or trainer battle.
*/
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();
}
preloadBgm(): void {
globalScene.loadBgm(this.bgm);
}
/** The loop point of any given biome track, read as seconds and milliseconds. */
getBgmLoopPoint(): number {
switch (this.biomeType) {
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 1.143;
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.336;
case BiomeId.ABYSS:
return 5.13;
case BiomeId.SPACE:
return 20.036;
case BiomeId.CONSTRUCTION_SITE:
return 1.222;
case BiomeId.JUNGLE:
return 0.0;
case BiomeId.FAIRY_CAVE:
return 4.542;
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.047;
case BiomeId.END:
return 17.153;
default:
console.warn(`missing bgm loop-point for biome "${BiomeId[this.biomeType]}" (=${this.biomeType})`);
return 0;
}
}
resetPlayerFaintCount(): void {
this.playerFaints = 0;
}
}
export function getBiomeKey(biome: BiomeId): string {
return BiomeId[biome].toLowerCase();
}
export function getBiomeHasProps(biomeType: BiomeId): boolean {
switch (biomeType) {
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 class ArenaBase extends Phaser.GameObjects.Container {
public player: boolean;
public biome: BiomeId;
public propValue: number;
public base: Phaser.GameObjects.Sprite;
public props: Phaser.GameObjects.Sprite[];
constructor(player: boolean) {
super(globalScene, 0, 0);
this.player = player;
this.base = globalScene.addFieldSprite(0, 0, "plains_a", undefined, 1);
this.base.setOrigin(0, 0);
this.props = !player
? new Array(3).fill(null).map(() => {
const ret = globalScene.addFieldSprite(0, 0, "plains_b", undefined, 1);
ret.setOrigin(0, 0);
ret.setVisible(false);
return ret;
})
: [];
}
setBiome(biome: BiomeId, propValue?: number): void {
const hasProps = getBiomeHasProps(biome);
const biomeKey = getBiomeKey(biome);
const baseKey = `${biomeKey}_${this.player ? "a" : "b"}`;
if (biome !== this.biome) {
this.base.setTexture(baseKey);
if (this.base.texture.frameTotal > 1) {
const baseFrameNames = globalScene.anims.generateFrameNames(baseKey, {
zeroPad: 4,
suffix: ".png",
start: 1,
end: this.base.texture.frameTotal - 1,
});
if (!globalScene.anims.exists(baseKey)) {
globalScene.anims.create({
key: baseKey,
frames: baseFrameNames,
frameRate: 12,
repeat: -1,
});
}
this.base.play(baseKey);
} else {
this.base.stop();
}
this.add(this.base);
}
if (!this.player) {
globalScene.executeWithSeedOffset(
() => {
this.propValue = propValue === undefined ? (hasProps ? randSeedInt(8) : 0) : propValue;
this.props.forEach((prop, p) => {
const propKey = `${biomeKey}_b${hasProps ? `_${p + 1}` : ""}`;
prop.setTexture(propKey);
if (hasProps && prop.texture.frameTotal > 1) {
const propFrameNames = globalScene.anims.generateFrameNames(propKey, {
zeroPad: 4,
suffix: ".png",
start: 1,
end: prop.texture.frameTotal - 1,
});
if (!globalScene.anims.exists(propKey)) {
globalScene.anims.create({
key: propKey,
frames: propFrameNames,
frameRate: 12,
repeat: -1,
});
}
prop.play(propKey);
} else {
prop.stop();
}
prop.setVisible(hasProps && !!(this.propValue & (1 << p)));
this.add(prop);
});
},
globalScene.currentBattle?.waveIndex || 0,
globalScene.waveSeed,
);
}
}
}