diff --git a/src/@types/typed-event-target.ts b/src/@types/typed-event-target.ts new file mode 100644 index 00000000000..2c38a6812d6 --- /dev/null +++ b/src/@types/typed-event-target.ts @@ -0,0 +1,17 @@ +/** + * Interface restricting the events emitted by an {@linkcode EventTarget} to a certain kind of {@linkcode Event}. + * @typeParam T - The type to restrict the interface's access; must extend from {@linkcode Event} + */ +export interface TypedEventTarget extends EventTarget { + dispatchEvent(event: T): boolean; + addEventListener( + event: T["type"], + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + type: T["type"], + callback: EventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean, + ): void; +} diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 15c2cde1d58..4fadd4361bd 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -19,6 +19,7 @@ import { MoveTarget } from "#enums/move-target"; import { PokemonType } from "#enums/pokemon-type"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; +import { ArenaTagAddedEvent } from "#events/arena"; import type { Arena } from "#field/arena"; import type { Pokemon } from "#field/pokemon"; import type { @@ -729,7 +730,9 @@ export class IonDelugeTag extends ArenaTag { */ export abstract class ArenaTrapTag extends SerializableArenaTag { abstract readonly tagType: ArenaTrapTagType; + /** The tag's current number of layers. */ public layers: number; + /** The maximum number of layers this tag can have. */ public maxLayers: number; /** @@ -749,11 +752,13 @@ export abstract class ArenaTrapTag extends SerializableArenaTag { } onOverlap(arena: Arena, _source: Pokemon | null): void { - if (this.layers < this.maxLayers) { - this.layers++; - - this.onAdd(arena); + if (this.layers === this.maxLayers) { + return; } + // Add an extra layer of the current hazard, then + this.layers++; + this.onAdd(arena); + arena.eventTarget.dispatchEvent(new ArenaTagAddedEvent(this.tagType, this.side, 0, [this.layers, this.maxLayers])); } /** @@ -771,9 +776,13 @@ export abstract class ArenaTrapTag extends SerializableArenaTag { return this.activateTrap(pokemon, simulated); } - activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean { - return false; - } + /** + * Trigger this trap's effects on any Pokemon switching into battle. + * @param _pokemon - The {@linkcode Pokemon} entering the field + * @param _simulated - Whether the switch is simulated + * @returns `true` if the effect succeeded + */ + abstract activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean; getMatchupScoreMultiplier(pokemon: Pokemon): number { return pokemon.isGrounded() diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index ef3609d93e7..ebf079a916e 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -1,5 +1,7 @@ +import { globalScene } from "#app/global-scene"; import { DelayedAttackTag, type PositionalTag, WishTag } from "#data/positional-tags/positional-tag"; import { PositionalTagType } from "#enums/positional-tag-type"; +import { PositionalTagAddedEvent } from "#events/arena"; import type { ObjectValues } from "#types/type-helpers"; import type { Constructor } from "#utils/common"; @@ -23,17 +25,15 @@ export function loadPositionalTag({ * This function does not perform any checking if the added tag is valid. */ export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag; -export function loadPositionalTag({ - tagType, - ...rest -}: serializedPosTagMap[T]): posTagInstanceMap[T] { - // Note: We need 2 type assertions here: - // 1 because TS doesn't narrow the type of TagClass correctly based on `T`. - // It converts it into `new (DelayedAttackTag | WishTag) => DelayedAttackTag & WishTag` - const tagClass = posTagConstructorMap[tagType] as new (args: posTagParamMap[T]) => posTagInstanceMap[T]; - // 2 because TS doesn't narrow the type of `rest` correctly - // (from `Omit into `posTagParamMap[T]`) - return new tagClass(rest as unknown as posTagParamMap[T]); +export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag { + // Update the global arena flyout + globalScene.arena.eventTarget.dispatchEvent(new PositionalTagAddedEvent(tag)); + + // Create the new tag + // TODO: review how many type assertions we need here + const { tagType, ...rest } = tag; + const tagClass = posTagConstructorMap[tagType]; + return new tagClass(rest); } /** Const object mapping tag types to their constructors. */ diff --git a/src/data/positional-tags/positional-tag-manager.ts b/src/data/positional-tags/positional-tag-manager.ts index 7bf4d4995c6..b56c9686fbb 100644 --- a/src/data/positional-tags/positional-tag-manager.ts +++ b/src/data/positional-tags/positional-tag-manager.ts @@ -1,7 +1,9 @@ +import { globalScene } from "#app/global-scene"; import { loadPositionalTag } from "#data/positional-tags/load-positional-tag"; import type { PositionalTag } from "#data/positional-tags/positional-tag"; import type { BattlerIndex } from "#enums/battler-index"; import type { PositionalTagType } from "#enums/positional-tag-type"; +import { PositionalTagRemovedEvent } from "#events/arena"; /** A manager for the {@linkcode PositionalTag}s in the arena. */ export class PositionalTagManager { @@ -49,7 +51,16 @@ export class PositionalTagManager { if (tag.shouldTrigger()) { tag.trigger(); } + this.emitRemove(tag); } this.tags = leftoverTags; } + + /** + * Emit a {@linkcode PositionalTagRemovedEvent} whenever a tag is removed from the field. + * @param tag - The {@linkcode PositionalTag} being removed + */ + private emitRemove(tag: PositionalTag): void { + globalScene.arena.eventTarget.dispatchEvent(new PositionalTagRemovedEvent(tag.tagType, tag.targetIndex)); + } } diff --git a/src/enums/arena-tag-side.ts b/src/enums/arena-tag-side.ts index 3e326ce158a..b2649466b0d 100644 --- a/src/enums/arena-tag-side.ts +++ b/src/enums/arena-tag-side.ts @@ -1,5 +1,18 @@ +import type { ArenaTag } from "#data/arena-tag"; +import type { ArenaFlyout } from "#ui/arena-flyout"; + +/** + * Enum used to represent a given side of the field for the purposes of {@linkcode ArenaTag}s and + * the current {@linkcode ArenaFlyout}. + */ export enum ArenaTagSide { + /** + * The effect applies to both sides of the field (player & enemy). + * Also used for the purposes of displaying weather and other "field-based" effects in the flyout. + */ BOTH, + /** The effect applies exclusively to the player's side of the field. */ PLAYER, + /** The effect applies exclusively to the opposing side of the field. */ ENEMY } diff --git a/src/enums/field-position.ts b/src/enums/field-position.ts index 5b7f9c6c570..d11e5040f41 100644 --- a/src/enums/field-position.ts +++ b/src/enums/field-position.ts @@ -1,5 +1,34 @@ +import { globalScene } from "#app/global-scene"; +import { BattlerIndex } from "#enums/battler-index"; + export enum FieldPosition { CENTER, LEFT, RIGHT } + +/** + * Convert a {@linkcode BattlerIndex} into a field position. + * @param index - The {@linkcode BattlerIndex} to convert + * @returns The resultant field position. + */ +export function battlerIndexToFieldPosition(index: BattlerIndex): FieldPosition { + let pos: FieldPosition; + switch (index) { + case BattlerIndex.ATTACKER: + throw new Error("Cannot convert BattlerIndex.ATTACKER to a field position!") + case BattlerIndex.PLAYER: + case BattlerIndex.ENEMY: + pos = FieldPosition.LEFT; + break; + case BattlerIndex.PLAYER_2: + case BattlerIndex.ENEMY_2: + pos = FieldPosition.RIGHT; + break; + } + // In single battles, left positions become center + if (!globalScene.currentBattle.double && pos === FieldPosition.LEFT) { + pos = FieldPosition.CENTER + } + return pos; +} \ No newline at end of file diff --git a/src/events/arena.ts b/src/events/arena.ts index 5415b8eb026..565d405e12e 100644 --- a/src/events/arena.ts +++ b/src/events/arena.ts @@ -1,109 +1,187 @@ +import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag"; +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { PositionalTag } from "#data/positional-tags/positional-tag"; import type { TerrainType } from "#data/terrain"; import type { ArenaTagSide } from "#enums/arena-tag-side"; import type { ArenaTagType } from "#enums/arena-tag-type"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { PositionalTagType } from "#enums/positional-tag-type"; import type { WeatherType } from "#enums/weather-type"; -/** Alias for all {@linkcode ArenaEvent} type strings */ +/** Enum representing the types of all {@linkcode ArenaEvent}s that can be emitted. */ export enum ArenaEventType { - /** Triggers when a {@linkcode WeatherType} is added, overlapped, or removed */ + /** Emitted when a {@linkcode WeatherType} is added, overlapped, or removed */ WEATHER_CHANGED = "onWeatherChanged", - /** Triggers when a {@linkcode TerrainType} is added, overlapped, or removed */ + /** Emitted when a {@linkcode TerrainType} is added, overlapped, or removed */ TERRAIN_CHANGED = "onTerrainChanged", - /** Triggers when a {@linkcode ArenaTagType} is added */ - TAG_ADDED = "onTagAdded", - /** Triggers when a {@linkcode ArenaTagType} is removed */ - TAG_REMOVED = "onTagRemoved", + /** Emitted when a new {@linkcode ArenaTag} is added */ + ARENA_TAG_ADDED = "onArenaTagAdded", + /** Emitted when an existing {@linkcode ArenaTag} is removed */ + ARENA_TAG_REMOVED = "onArenaTagRemoved", + + /** Emitted when a new {@linkcode PositionalTag} is added */ + POSITIONAL_TAG_ADDED = "onPositionalTagAdded", + /** Emitted when an existing {@linkcode PositionalTag} is removed */ + POSITIONAL_TAG_REMOVED = "onPositionalTagRemoved", } /** - * Base container class for all {@linkcode ArenaEventType} events - * @extends Event + * Abstract container class for all {@linkcode ArenaEventType} events. */ -export class ArenaEvent extends Event { - /** The total duration of the {@linkcode ArenaEventType} */ - public duration: number; - constructor(eventType: ArenaEventType, duration: number) { - super(eventType); +abstract class ArenaEvent extends Event { + /** The {@linkcode ArenaEventType} being emitted. */ + public declare abstract readonly type: ArenaEventType; // that's a mouthful! + // biome-ignore lint/complexity/noUselessConstructor: changes the type of the type field + constructor(type: ArenaEventType) { + super(type); + } +} +export type { ArenaEvent }; + +/** + * Container class for {@linkcode ArenaEventType.WEATHER_CHANGED} events. \ + * Emitted whenever a weather effect starts, ends or is replaced. + */ +export class WeatherChangedEvent extends ArenaEvent { + declare type: ArenaEventType.WEATHER_CHANGED; + + /** The new {@linkcode WeatherType} being set. */ + public weatherType: WeatherType; + /** + * The new weather's initial duration. + * Unused if {@linkcode weatherType} is set to {@linkcode WeatherType.NONE}. + */ + public duration: number; + + constructor(weatherType: WeatherType, duration: number) { + super(ArenaEventType.WEATHER_CHANGED); + + this.weatherType = weatherType; this.duration = duration; } } -/** - * Container class for {@linkcode ArenaEventType.WEATHER_CHANGED} events - * @extends ArenaEvent - */ -export class WeatherChangedEvent extends ArenaEvent { - /** The {@linkcode WeatherType} being overridden */ - public oldWeatherType: WeatherType; - /** The {@linkcode WeatherType} being set */ - public newWeatherType: WeatherType; - constructor(oldWeatherType: WeatherType, newWeatherType: WeatherType, duration: number) { - super(ArenaEventType.WEATHER_CHANGED, duration); - this.oldWeatherType = oldWeatherType; - this.newWeatherType = newWeatherType; - } -} /** - * Container class for {@linkcode ArenaEventType.TERRAIN_CHANGED} events - * @extends ArenaEvent + * Container class for {@linkcode ArenaEventType.TERRAIN_CHANGED} events. \ + * Emitted whenever a terrain effect starts, ends or is replaced. */ export class TerrainChangedEvent extends ArenaEvent { - /** The {@linkcode TerrainType} being overridden */ - public oldTerrainType: TerrainType; - /** The {@linkcode TerrainType} being set */ - public newTerrainType: TerrainType; - constructor(oldTerrainType: TerrainType, newTerrainType: TerrainType, duration: number) { - super(ArenaEventType.TERRAIN_CHANGED, duration); + declare type: ArenaEventType.TERRAIN_CHANGED; - this.oldTerrainType = oldTerrainType; - this.newTerrainType = newTerrainType; + /** The new {@linkcode TerrainType} being set. */ + public terrainType: TerrainType; + /** + * The new terrain's initial duration. + * Unused if {@linkcode terrainType} is set to {@linkcode TerrainType.NONE}. + */ + public duration: number; + + constructor(terrainType: TerrainType, duration: number) { + super(ArenaEventType.TERRAIN_CHANGED); + + this.terrainType = terrainType; + this.duration = duration; } } /** - * Container class for {@linkcode ArenaEventType.TAG_ADDED} events - * @extends ArenaEvent + * Container class for {@linkcode ArenaEventType.ARENA_TAG_ADDED} events. \ + * Emitted whenever a new {@linkcode ArenaTag} is added to the arena, or whenever an existing + * {@linkcode ArenaTrapTag} overlaps and adds new layers. */ -export class TagAddedEvent extends ArenaEvent { - /** The {@linkcode ArenaTagType} being added */ - public arenaTagType: ArenaTagType; - /** The {@linkcode ArenaTagSide} the tag is being placed on */ - public arenaTagSide: ArenaTagSide; - /** The current number of layers of the arena trap. */ - public arenaTagLayers: number; - /** The maximum amount of layers of the arena trap. */ - public arenaTagMaxLayers: number; +export class ArenaTagAddedEvent extends ArenaEvent { + declare type: ArenaEventType.ARENA_TAG_ADDED; + + /** The {@linkcode ArenaTagType} of the tag being added */ + public tagType: ArenaTagType; + /** The {@linkcode ArenaTagSide} the tag is being added too */ + public side: ArenaTagSide; + /** The tag's initial duration. */ + public duration: number; + /** + * A tuple containing the current and maximum number of layers of the current {@linkcode ArenaTrapTag}, + * or `undefined` if the tag was not a trap. + */ + public trapLayers: [current: number, max: number] | undefined; constructor( - arenaTagType: ArenaTagType, + side: ArenaTagType, arenaTagSide: ArenaTagSide, duration: number, - arenaTagLayers?: number, - arenaTagMaxLayers?: number, + trapLayers?: [current: number, max: number], ) { - super(ArenaEventType.TAG_ADDED, duration); + super(ArenaEventType.ARENA_TAG_ADDED); - this.arenaTagType = arenaTagType; - this.arenaTagSide = arenaTagSide; - this.arenaTagLayers = arenaTagLayers!; // TODO: is this bang correct? - this.arenaTagMaxLayers = arenaTagMaxLayers!; // TODO: is this bang correct? + this.tagType = side; + this.side = arenaTagSide; + this.duration = duration; + this.trapLayers = trapLayers; } } + /** - * Container class for {@linkcode ArenaEventType.TAG_REMOVED} events - * @extends ArenaEvent + * Container class for {@linkcode ArenaEventType.ARENA_TAG_REMOVED} events. \ + * Emitted whenever an {@linkcode ArenaTag} is removed from the field for any reason. */ -export class TagRemovedEvent extends ArenaEvent { - /** The {@linkcode ArenaTagType} being removed */ - public arenaTagType: ArenaTagType; - /** The {@linkcode ArenaTagSide} the tag was being placed on */ - public arenaTagSide: ArenaTagSide; - constructor(arenaTagType: ArenaTagType, arenaTagSide: ArenaTagSide, duration: number) { - super(ArenaEventType.TAG_REMOVED, duration); +export class ArenaTagRemovedEvent extends ArenaEvent { + declare type: ArenaEventType.ARENA_TAG_REMOVED; - this.arenaTagType = arenaTagType; - this.arenaTagSide = arenaTagSide; + /** The {@linkcode ArenaTagType} of the tag being removed. */ + public tagType: ArenaTagType; + /** The {@linkcode ArenaTagSide} the removed tag affected. */ + public side: ArenaTagSide; + + constructor(tagType: ArenaTagType, side: ArenaTagSide) { + super(ArenaEventType.ARENA_TAG_REMOVED); + + this.tagType = tagType; + this.side = side; + } +} + +/** + * Container class for {@linkcode ArenaEventType.POSITIONAL_TAG_ADDED} events. \ + * Emitted whenever a new {@linkcode PositionalTag} is spawned and added to the arena. + */ +export class PositionalTagAddedEvent extends ArenaEvent { + declare type: ArenaEventType.POSITIONAL_TAG_ADDED; + + /** The {@linkcode SerializedPositionalTag} being added to the arena. */ + public tag: SerializedPositionalTag; + + /** The {@linkcode PositionalTagType} of the tag being added. */ + public tagType: PositionalTagType; + /** The {@linkcode BattlerIndex} targeted by the newly created tag. */ + public targetIndex: BattlerIndex; + /** The tag's current duration. */ + public duration: number; + + constructor(tag: SerializedPositionalTag) { + super(ArenaEventType.POSITIONAL_TAG_ADDED); + + this.tag = tag; + } +} + +/** + * Container class for {@linkcode ArenaEventType.POSITIONAL_TAG_REMOVED} events. \ + * Emitted whenever a currently-active {@linkcode PositionalTag} triggers (or disappears) + * and is removed from the arena. + */ +export class PositionalTagRemovedEvent extends ArenaEvent { + declare type: ArenaEventType.POSITIONAL_TAG_REMOVED; + + /** The {@linkcode PositionalTagType} of the tag being deleted. */ + public tagType: PositionalTagType; + /** The {@linkcode BattlerIndex} targeted by the newly removed tag. */ + public targetIndex: BattlerIndex; + + constructor(tagType: PositionalTagType, targetIndex: BattlerIndex) { + super(ArenaEventType.POSITIONAL_TAG_ADDED); + + this.tagType = tagType; + this.targetIndex = targetIndex; } } diff --git a/src/field/arena.ts b/src/field/arena.ts index 484450cc5df..7d1f2d637f5 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -31,11 +31,18 @@ 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 ArenaEvent, + ArenaTagAddedEvent, + ArenaTagRemovedEvent, + TerrainChangedEvent, + WeatherChangedEvent, +} from "#events/arena"; import type { Pokemon } from "#field/pokemon"; import { FieldEffectModifier } from "#modifiers/modifier"; import type { Move } from "#moves/move"; import type { AbstractConstructor } from "#types/type-helpers"; +import type { TypedEventTarget } from "#types/typed-event-target"; import { type Constructor, isNullOrUndefined, NumberHolder, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; @@ -45,10 +52,7 @@ export class Arena { 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. - */ + /** A manager for the currently-active {@linkcode PositionalTag}s on both sides of the field. */ public positionalTagManager: PositionalTagManager = new PositionalTagManager(); public bgm: string; @@ -66,7 +70,11 @@ export class Arena { private pokemonPool: PokemonPools; private trainerPool: BiomeTierTrainerPools; - public readonly eventTarget: EventTarget = new EventTarget(); + /** + * Event dispatcher for various {@linkcode ArenaEvent}s. + * Used primarily to update the arena flyout. + */ + public readonly eventTarget: TypedEventTarget = new EventTarget(); constructor(biome: BiomeId, bgm: string, playerFaints = 0) { this.biomeType = biome; @@ -345,9 +353,7 @@ export class Arena { } 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? + this.eventTarget.dispatchEvent(new WeatherChangedEvent(this.getWeatherType(), weatherDuration.value)); if (this.weather) { globalScene.phaseManager.unshiftNew( @@ -433,9 +439,7 @@ export class Arena { 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? + this.eventTarget.dispatchEvent(new TerrainChangedEvent(this.getTerrainType(), terrainDuration.value)); if (this.terrain) { if (!ignoreAnim) { @@ -708,26 +712,25 @@ export class Arena { 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, side); - if (newTag) { - newTag.onAdd(this, quiet); - this.tags.push(newTag); + const newTag = getArenaTag(tagType, turnCount, sourceMove, sourceId, side); + if (!newTag) { + return false; + } - const { layers = 0, maxLayers = 0 } = newTag instanceof ArenaTrapTag ? newTag : {}; + newTag.onAdd(this, quiet); + this.tags.push(newTag); - this.eventTarget.dispatchEvent( - new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, layers, maxLayers), + // Dispatch a TagAddedEvent to update the flyout. + if (newTag instanceof ArenaTrapTag) { + globalScene.arena.eventTarget.dispatchEvent( + new ArenaTagAddedEvent(tagType, side, turnCount, [newTag.layers, newTag.maxLayers]), ); + } else { + globalScene.arena.eventTarget.dispatchEvent(new ArenaTagAddedEvent(tagType, side, turnCount)); } return true; @@ -807,7 +810,7 @@ export class Arena { t.onRemove(this); this.tags.splice(this.tags.indexOf(t), 1); - this.eventTarget.dispatchEvent(new TagRemovedEvent(t.tagType, t.side, t.turnCount)); + this.eventTarget.dispatchEvent(new ArenaTagRemovedEvent(t.tagType, t.side)); }); } @@ -818,7 +821,7 @@ export class Arena { tag.onRemove(this); tags.splice(tags.indexOf(tag), 1); - this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); + this.eventTarget.dispatchEvent(new ArenaTagRemovedEvent(tag.tagType, tag.side)); } return !!tag; } @@ -829,20 +832,17 @@ export class Arena { tag.onRemove(this, quiet); this.tags.splice(this.tags.indexOf(tag), 1); - this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); + this.eventTarget.dispatchEvent(new ArenaTagRemovedEvent(tag.tagType, tag.side)); } 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); + for (const tag of this.tags) { + tag.onRemove(this); + this.eventTarget.dispatchEvent(new ArenaTagRemovedEvent(tag.tagType, tag.side)); } + this.tags = []; } /** diff --git a/src/system/game-data.ts b/src/system/game-data.ts index d899afa19ef..4072a0569ed 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -36,7 +36,7 @@ import { TrainerVariant } from "#enums/trainer-variant"; import { UiMode } from "#enums/ui-mode"; import { Unlockables } from "#enums/unlockables"; import { WeatherType } from "#enums/weather-type"; -import { TagAddedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#events/arena"; +import { ArenaTagAddedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#events/arena"; import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; // biome-ignore lint/performance/noNamespaceImport: Something weird is going on here and I don't want to touch it import * as Modifier from "#modifiers/modifier"; @@ -1064,36 +1064,30 @@ export class GameData { }); globalScene.arena.weather = sessionData.arena.weather; - globalScene.arena.eventTarget.dispatchEvent( - new WeatherChangedEvent( - WeatherType.NONE, - globalScene.arena.weather?.weatherType!, - globalScene.arena.weather?.turnsLeft!, - ), - ); // TODO: is this bang correct? + if (globalScene.arena.getWeatherType() !== WeatherType.NONE) { + globalScene.arena.eventTarget.dispatchEvent( + new WeatherChangedEvent(globalScene.arena.getWeatherType(), globalScene.arena.weather?.turnsLeft!), + ); + } globalScene.arena.terrain = sessionData.arena.terrain; - globalScene.arena.eventTarget.dispatchEvent( - new TerrainChangedEvent( - TerrainType.NONE, - globalScene.arena.terrain?.terrainType!, - globalScene.arena.terrain?.turnsLeft!, - ), - ); // TODO: is this bang correct? + if (globalScene.arena.getTerrainType() !== TerrainType.NONE) { + globalScene.arena.eventTarget.dispatchEvent( + new TerrainChangedEvent(globalScene.arena.getTerrainType(), globalScene.arena.terrain?.turnsLeft!), + ); + } globalScene.arena.playerTerasUsed = sessionData.arena.playerTerasUsed; globalScene.arena.tags = sessionData.arena.tags; - if (globalScene.arena.tags) { - for (const tag of globalScene.arena.tags) { - if (tag instanceof ArenaTrapTag) { - const { tagType, side, turnCount, layers, maxLayers } = tag as ArenaTrapTag; - globalScene.arena.eventTarget.dispatchEvent( - new TagAddedEvent(tagType, side, turnCount, layers, maxLayers), - ); - } else { - globalScene.arena.eventTarget.dispatchEvent(new TagAddedEvent(tag.tagType, tag.side, tag.turnCount)); - } + for (const tag of globalScene.arena.tags) { + if (tag instanceof ArenaTrapTag) { + const { tagType, side, turnCount, layers, maxLayers } = tag; + globalScene.arena.eventTarget.dispatchEvent( + new ArenaTagAddedEvent(tagType, side, turnCount, [layers, maxLayers]), + ); + } else { + globalScene.arena.eventTarget.dispatchEvent(new ArenaTagAddedEvent(tag.tagType, tag.side, tag.turnCount)); } } diff --git a/src/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index d2a45646690..33cdf69bf48 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -1,61 +1,124 @@ import { globalScene } from "#app/global-scene"; -import { ArenaTrapTag } from "#data/arena-tag"; -import { TerrainType } from "#data/terrain"; +// biome-ignore-start lint/correctness/noUnusedImports: TSDocs +import type { ArenaTag } from "#data/arena-tag"; +import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag"; +import type { PositionalTag } from "#data/positional-tags/positional-tag"; +import { type Terrain, TerrainType } from "#data/terrain"; +import type { Weather } from "#data/weather"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDocs import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerIndex } from "#enums/battler-index"; +import { battlerIndexToFieldPosition, FieldPosition } from "#enums/field-position"; +import { MoveId } from "#enums/move-id"; +import { PositionalTagType } from "#enums/positional-tag-type"; import { TextStyle } from "#enums/text-style"; import { WeatherType } from "#enums/weather-type"; -import type { ArenaEvent } from "#events/arena"; import { ArenaEventType, - TagAddedEvent, - TagRemovedEvent, - TerrainChangedEvent, - WeatherChangedEvent, + type ArenaTagAddedEvent, + type ArenaTagRemovedEvent, + type PositionalTagAddedEvent, + type PositionalTagRemovedEvent, + type TerrainChangedEvent, + type WeatherChangedEvent, } from "#events/arena"; -import type { TurnEndEvent } from "#events/battle-scene"; import { BattleSceneEventType } from "#events/battle-scene"; import { addTextObject } from "#ui/text"; import { TimeOfDayWidget } from "#ui/time-of-day-widget"; import { addWindow, WindowVariant } from "#ui/ui-theme"; import { fixedInt } from "#utils/common"; -import { toCamelCase, toTitleCase } from "#utils/strings"; -import type { ParseKeys } from "i18next"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; -/** Enum used to differentiate {@linkcode Arena} effects */ -enum ArenaEffectType { - PLAYER, - WEATHER, - TERRAIN, - FIELD, - ENEMY, -} -/** Container for info about an {@linkcode Arena}'s effects */ -interface ArenaEffectInfo { - /** The enum string representation of the effect */ - name: string; - /** {@linkcode ArenaEffectType} type of effect */ - effecType: ArenaEffectType; +// #region Interfaces - /** The maximum duration set by the effect */ +/** Base container for info about the currently active {@linkcode Weather}. */ +interface WeatherInfo { + /** The localized name of the weather. */ + name: string; + /** The initial duration of the weather effect, or `0` if it should last indefinitely. */ maxDuration: number; - /** The current duration left on the effect */ + /** The current duration left on the weather. */ duration: number; - /** The arena tag type being added */ + /** The current {@linkcode WeatherType}. */ + weatherType: WeatherType; +} + +/** Base container for info about the currently active {@linkcode Terrain}. */ +interface TerrainInfo { + /** The localized name of the terrain. */ + name: string; + /** The initial duration of the terrain effect, or `0` if it should last indefinitely. */ + maxDuration: number; + /** The current duration left on the terrain. */ + duration: number; + /** The current {@linkcode TerrainType}. */ + terrainType: TerrainType; +} + +/** Interface for info about an {@linkcode ArenaTag}'s effects */ +interface ArenaTagInfo { + /** The localized name of the tag. */ + name: string; + /** The {@linkcode ArenaTagSide} that the tag applies to. */ + side: ArenaTagSide; + /** The maximum duration of the tag, or `0` if it should last indefinitely. */ + maxDuration: number; + /** The current duration left on the tag. */ + duration: number; + /** The tag's {@linkcode ArenaTagType}. */ tagType?: ArenaTagType; } -export function getFieldEffectText(arenaTagType: string): string { - if (!arenaTagType || arenaTagType === ArenaTagType.NONE) { - return arenaTagType; - } - const effectName = toCamelCase(arenaTagType); - const i18nKey = `arenaFlyout:${effectName}` as ParseKeys; - const resultName = i18next.t(i18nKey); - return !resultName || resultName === i18nKey ? toTitleCase(arenaTagType) : resultName; +/** Container for info about pending {@linkcode PositionalTag}s. */ +interface PositionalTagInfo { + /** The localized name of the effect. */ + name: string; + /** The {@linkcode BattlerIndex} that the effect is slated to affect. */ + targetIndex: BattlerIndex; + /** The current duration of the effect. */ + duration: number; + /** The tag's {@linkcode PositionalTagType}. */ + tagType: PositionalTagType; } +// #endregion interfaces + +// #region String functions + +/** + * Return the localized text for a given effect. + * @param text - The raw text of the effect; assumed to be in `UPPER_SNAKE_CASE` from a reverse mapping. + * @returns The localized text for the effect. + */ +function localizeEffectName(text: string): string { + const effectName = toCamelCase(text); + const i18nKey = `arenaFlyout:${effectName}`; + const resultName = i18next.t(i18nKey); + return resultName; +} + +/** + * Return the localized name of a given {@linkcode PositionalTag}. + * @param tag - The raw serialized data for the given tag + * @returns The localized text to be displayed on-screen. + */ +function getPositionalTagDisplayName(tag: SerializedPositionalTag): string { + let tagName: string; + if ("sourceMove" in tag) { + // Delayed attacks will use the source move's name + tagName = MoveId[tag.sourceMove]; + } else { + tagName = PositionalTagType[tag.tagType]; + } + + return localizeEffectName(tagName); +} + +/** + * Class to display and update the on-screen arena flyout. + */ export class ArenaFlyout extends Phaser.GameObjects.Container { /** The restricted width of the flyout which should be drawn to */ private flyoutWidth = 170; @@ -88,24 +151,36 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { private flyoutTextHeaderPlayer: Phaser.GameObjects.Text; /** The {@linkcode Phaser.GameObjects.Text} header used to indicate the enemy's effects */ private flyoutTextHeaderEnemy: Phaser.GameObjects.Text; - /** The {@linkcode Phaser.GameObjects.Text} header used to indicate field effects */ + /** The {@linkcode Phaser.GameObjects.Text} header used to indicate neutral effects */ private flyoutTextHeaderField: Phaser.GameObjects.Text; /** The {@linkcode Phaser.GameObjects.Text} used to indicate the player's effects */ private flyoutTextPlayer: Phaser.GameObjects.Text; /** The {@linkcode Phaser.GameObjects.Text} used to indicate the enemy's effects */ private flyoutTextEnemy: Phaser.GameObjects.Text; - /** The {@linkcode Phaser.GameObjects.Text} used to indicate field effects */ + /** The {@linkcode Phaser.GameObjects.Text} used to indicate neutral effects */ private flyoutTextField: Phaser.GameObjects.Text; - /** Container for all field effects observed by this object */ - private readonly fieldEffectInfo: ArenaEffectInfo[] = []; + /** Holds info about the current active {@linkcode Weather}, if any are active. */ + private weatherInfo?: WeatherInfo; + /** Holds info about the current active {@linkcode Terrain}, if any are active. */ + private terrainInfo?: TerrainInfo; - // Stores callbacks in a variable so they can be unsubscribed from when destroyed - private readonly onNewArenaEvent = (event: Event) => this.onNewArena(event); - private readonly onTurnEndEvent = (event: Event) => this.onTurnEnd(event); + /** Container for all {@linkcode ArenaTag}s observed by this object. */ + private arenaTags: ArenaTagInfo[] = []; + /** Container for all {@linkcode PositionalTag}s observed by this object. */ + private positionalTags: PositionalTagInfo[] = []; - private readonly onFieldEffectChangedEvent = (event: Event) => this.onFieldEffectChanged(event); + // Store callbacks in variables so they can be unsubscribed from when destroyed + private readonly onNewArenaEvent = () => this.onNewArena(); + private readonly onTurnEndEvent = () => this.onTurnEnd(); + private readonly onWeatherChangedEvent = (event: WeatherChangedEvent) => this.onWeatherChanged(event); + private readonly onTerrainChangedEvent = (event: TerrainChangedEvent) => this.onTerrainChanged(event); + private readonly onArenaTagAddedEvent = (event: ArenaTagAddedEvent) => this.onArenaTagAdded(event); + private readonly onArenaTagRemovedEvent = (event: ArenaTagRemovedEvent) => this.onArenaTagRemoved(event); + private readonly onPositionalTagAddedEvent = (event: PositionalTagAddedEvent) => this.onPositionalTagAdded(event); + // biome-ignore format: Keeps lines in 1 piece + private readonly onPositionalTagRemovedEvent = (event: PositionalTagRemovedEvent) => this.onPositionalTagRemoved(event); constructor() { super(globalScene, 0, 0); @@ -165,7 +240,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { this.flyoutTextHeaderField = addTextObject( this.flyoutWidth / 2, 5, - i18next.t("arenaFlyout:field"), + i18next.t("arenaFlyout:neutral"), TextStyle.SUMMARY_GREEN, ); this.flyoutTextHeaderField.setFontSize(54); @@ -213,202 +288,185 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { this.name = "Fight Flyout"; this.flyoutParent.name = "Fight Flyout Parent"; - // Subscribes to required events available on game start + // Subscribe to required events available on game start globalScene.eventTarget.addEventListener(BattleSceneEventType.NEW_ARENA, this.onNewArenaEvent); globalScene.eventTarget.addEventListener(BattleSceneEventType.TURN_END, this.onTurnEndEvent); } - private onNewArena(_event: Event) { - this.fieldEffectInfo.length = 0; + /** + * Initialize listeners upon creating a new arena. + */ + private onNewArena() { + this.arenaTags = []; + this.positionalTags = []; - // Subscribes to required events available on battle start - globalScene.arena.eventTarget.addEventListener(ArenaEventType.WEATHER_CHANGED, this.onFieldEffectChangedEvent); - globalScene.arena.eventTarget.addEventListener(ArenaEventType.TERRAIN_CHANGED, this.onFieldEffectChangedEvent); - globalScene.arena.eventTarget.addEventListener(ArenaEventType.TAG_ADDED, this.onFieldEffectChangedEvent); - globalScene.arena.eventTarget.addEventListener(ArenaEventType.TAG_REMOVED, this.onFieldEffectChangedEvent); - } - - /** Clears out the current string stored in all arena effect texts */ - private clearText() { - this.flyoutTextPlayer.text = ""; - this.flyoutTextField.text = ""; - this.flyoutTextEnemy.text = ""; - } - - /** Parses through all set Arena Effects and puts them into the proper {@linkcode Phaser.GameObjects.Text} object */ - private updateFieldText() { - this.clearText(); - - this.fieldEffectInfo.sort((infoA, infoB) => infoA.duration - infoB.duration); - - for (let i = 0; i < this.fieldEffectInfo.length; i++) { - const fieldEffectInfo = this.fieldEffectInfo[i]; - - // Creates a proxy object to decide which text object needs to be updated - let textObject: Phaser.GameObjects.Text; - switch (fieldEffectInfo.effecType) { - case ArenaEffectType.PLAYER: - textObject = this.flyoutTextPlayer; - break; - - case ArenaEffectType.WEATHER: - case ArenaEffectType.TERRAIN: - case ArenaEffectType.FIELD: - textObject = this.flyoutTextField; - - break; - - case ArenaEffectType.ENEMY: - textObject = this.flyoutTextEnemy; - break; - } - - textObject.text += fieldEffectInfo.name; - - if (fieldEffectInfo.maxDuration !== 0) { - textObject.text += " " + fieldEffectInfo.duration + "/" + fieldEffectInfo.maxDuration; - } - - textObject.text += "\n"; - } + // Subscribe to required events available on battle start + // biome-ignore-start format: Keeps lines in 1 piece + globalScene.arena.eventTarget.addEventListener(ArenaEventType.WEATHER_CHANGED, this.onWeatherChangedEvent); + globalScene.arena.eventTarget.addEventListener(ArenaEventType.TERRAIN_CHANGED, this.onTerrainChangedEvent); + globalScene.arena.eventTarget.addEventListener(ArenaEventType.ARENA_TAG_ADDED, this.onArenaTagAddedEvent); + globalScene.arena.eventTarget.addEventListener(ArenaEventType.ARENA_TAG_REMOVED, this.onArenaTagRemovedEvent); + globalScene.arena.eventTarget.addEventListener(ArenaEventType.POSITIONAL_TAG_ADDED, this.onPositionalTagAddedEvent); + globalScene.arena.eventTarget.addEventListener(ArenaEventType.POSITIONAL_TAG_REMOVED, this.onPositionalTagRemovedEvent); + // biome-ignore-end format: Keeps lines in 1 piece } /** - * Parses the {@linkcode Event} being passed and updates the state of the fieldEffectInfo array - * @param event {@linkcode Event} being sent + * Iterate through all currently present field effects and decrement their durations. */ - private onFieldEffectChanged(event: Event) { - const arenaEffectChangedEvent = event as ArenaEvent; - if (!arenaEffectChangedEvent) { + private onTurnEnd() { + // Remove all objects with positive max durations and whose durations have expired. + this.arenaTags = this.arenaTags.filter(info => info.maxDuration === 0 || --info.duration >= 0); + this.positionalTags = this.positionalTags.filter(info => --info.duration >= 0); + + this.updateFieldText(); + } + + // #region ArenaTags + + /** + * Add a recently-created {@linkcode ArenaTag} to the flyout. + * @param event - The {@linkcode ArenaTagAddedEvent} having been emitted + */ + private onArenaTagAdded(event: ArenaTagAddedEvent): void { + const name = localizeEffectName(ArenaTagType[event.tagType]); + // Ternary used to avoid unneeded find + const existingTrapTag = + event.trapLayers !== undefined + ? this.arenaTags.find(e => e.tagType === event.tagType && e.side === event.side) + : undefined; + + // If we got signalled for a layer count update, update the existing trap's name. + // Otherwise, push it to the array. + if (event.trapLayers !== undefined && existingTrapTag) { + this.updateTrapLayers(existingTrapTag, event.trapLayers, name); + } else { + this.arenaTags.push({ + name, + side: event.side, + maxDuration: event.duration, + duration: event.duration, + tagType: event.tagType, + }); + } + this.updateFieldText(); + } + + /** + * Update an existing trap tag with an updated layer count whenever one is overlapped. + * @param existingTag - The existing {@linkcode ArenaTagInfo} to update text for + * @param layers - The base number of layers of the new tag + * @param maxLayers - The maximum number of layers of the new tag; will not show layer count if <=0 + * @param name - The name of the tag. + */ + private updateTrapLayers(existingTag: ArenaTagInfo, [layers, maxLayers]: [number, number], name: string): void { + const layerStr = maxLayers > 1 ? ` (${layers})` : ""; + existingTag.name = `${name}${layerStr}`; + } + + /** + * Remove a recently-culled {@linkcode ArenaTag} from the flyout. + * @param event - The {@linkcode ArenaTagRemovedEvent} having been emitted + */ + private onArenaTagRemoved(event: ArenaTagRemovedEvent): void { + const foundIndex = this.arenaTags.findIndex(info => info.tagType === event.tagType && info.side === event.side); + + if (foundIndex > -1) { + // If the tag was being tracked, remove it + this.arenaTags.splice(foundIndex, 1); + this.updateFieldText(); + } + } + + // #endregion ArenaTags + + // #region PositionalTags + + /** + * Add a recently-created {@linkcode PositionalTag} to the flyout. + * @param event - The {@linkcode PositionalTagAddedEvent} having been emitted + */ + private onPositionalTagAdded(event: PositionalTagAddedEvent): void { + const name = getPositionalTagDisplayName(event.tag); + + this.positionalTags.push({ + name, + targetIndex: event.tag.targetIndex, + duration: event.tag.turnCount, + tagType: event.tag.tagType, + }); + this.updateFieldText(); + } + + /** + * Remove a recently-activated {@linkcode PositionalTag} from the flyout. + * @param event - The {@linkcode PositionalTagRemovedEvent} having been emitted + */ + private onPositionalTagRemoved(event: PositionalTagRemovedEvent): void { + const foundIndex = this.positionalTags.findIndex( + info => info.tagType === event.tagType && info.targetIndex === event.targetIndex, + ); + + if (foundIndex > -1) { + // If the tag was being tracked, remove it + this.positionalTags.splice(foundIndex, 1); + this.updateFieldText(); + } + } + + // #endregion PositionalTags + + // #region Weather/Terrain + + /** + * Update the current weather text when the weather changes. + * @param event - The {@linkcode WeatherChangedEvent} having been emitted + */ + private onWeatherChanged(event: WeatherChangedEvent) { + // If weather was reset, clear the current data. + if (event.weatherType === WeatherType.NONE) { + this.weatherInfo = undefined; + this.updateFieldText(); return; } - let foundIndex: number; - switch (arenaEffectChangedEvent.constructor) { - case TagAddedEvent: { - const tagAddedEvent = arenaEffectChangedEvent as TagAddedEvent; - const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof ArenaTrapTag; - let arenaEffectType: ArenaEffectType; - - if (tagAddedEvent.arenaTagSide === ArenaTagSide.BOTH) { - arenaEffectType = ArenaEffectType.FIELD; - } else if (tagAddedEvent.arenaTagSide === ArenaTagSide.PLAYER) { - arenaEffectType = ArenaEffectType.PLAYER; - } else { - arenaEffectType = ArenaEffectType.ENEMY; - } - - const existingTrapTagIndex = isArenaTrapTag - ? this.fieldEffectInfo.findIndex( - e => tagAddedEvent.arenaTagType === e.tagType && arenaEffectType === e.effecType, - ) - : -1; - let name: string = getFieldEffectText(ArenaTagType[tagAddedEvent.arenaTagType]); - - if (isArenaTrapTag) { - if (existingTrapTagIndex !== -1) { - const layers = tagAddedEvent.arenaTagMaxLayers > 1 ? ` (${tagAddedEvent.arenaTagLayers})` : ""; - this.fieldEffectInfo[existingTrapTagIndex].name = `${name}${layers}`; - break; - } - if (tagAddedEvent.arenaTagMaxLayers > 1) { - name = `${name} (${tagAddedEvent.arenaTagLayers})`; - } - } - - this.fieldEffectInfo.push({ - name, - effecType: arenaEffectType, - maxDuration: tagAddedEvent.duration, - duration: tagAddedEvent.duration, - tagType: tagAddedEvent.arenaTagType, - }); - break; - } - case TagRemovedEvent: { - const tagRemovedEvent = arenaEffectChangedEvent as TagRemovedEvent; - foundIndex = this.fieldEffectInfo.findIndex(info => info.tagType === tagRemovedEvent.arenaTagType); - - if (foundIndex !== -1) { - // If the tag was being tracked, remove it - this.fieldEffectInfo.splice(foundIndex, 1); - } - break; - } - - case WeatherChangedEvent: - case TerrainChangedEvent: { - const fieldEffectChangedEvent = arenaEffectChangedEvent as WeatherChangedEvent | TerrainChangedEvent; - - // Stores the old Weather/Terrain name in case it's in the array already - const oldName = getFieldEffectText( - fieldEffectChangedEvent instanceof WeatherChangedEvent - ? WeatherType[fieldEffectChangedEvent.oldWeatherType] - : TerrainType[fieldEffectChangedEvent.oldTerrainType], - ); - // Stores the new Weather/Terrain info - const newInfo = { - name: getFieldEffectText( - fieldEffectChangedEvent instanceof WeatherChangedEvent - ? WeatherType[fieldEffectChangedEvent.newWeatherType] - : TerrainType[fieldEffectChangedEvent.newTerrainType], - ), - effecType: - fieldEffectChangedEvent instanceof WeatherChangedEvent ? ArenaEffectType.WEATHER : ArenaEffectType.TERRAIN, - maxDuration: fieldEffectChangedEvent.duration, - duration: fieldEffectChangedEvent.duration, - }; - - foundIndex = this.fieldEffectInfo.findIndex(info => [newInfo.name, oldName].includes(info.name)); - if (foundIndex === -1) { - if (newInfo.name !== undefined) { - this.fieldEffectInfo.push(newInfo); // Adds the info to the array if it doesn't already exist and is defined - } - } else if (!newInfo.name) { - this.fieldEffectInfo.splice(foundIndex, 1); // Removes the old info if the new one is undefined - } else { - this.fieldEffectInfo[foundIndex] = newInfo; // Otherwise, replace the old info - } - break; - } - } + this.weatherInfo = { + name: localizeEffectName(WeatherType[event.weatherType]), + maxDuration: event.duration, + duration: event.duration, + weatherType: event.weatherType, + }; this.updateFieldText(); } /** - * Iterates through the fieldEffectInfo array and decrements the duration of each item - * @param event {@linkcode Event} being sent + * Update the current terrain text when the terrain changes. + * @param event - The {@linkcode TerrainChangedEvent} having been emitted */ - private onTurnEnd(event: Event) { - const turnEndEvent = event as TurnEndEvent; - if (!turnEndEvent) { + private onTerrainChanged(event: TerrainChangedEvent) { + // If terrain was reset, clear the current data. + if (event.terrainType === TerrainType.NONE) { + this.terrainInfo = undefined; + this.updateFieldText(); return; } - const fieldEffectInfo: ArenaEffectInfo[] = []; - this.fieldEffectInfo.forEach(i => fieldEffectInfo.push(i)); - - for (let i = 0; i < fieldEffectInfo.length; i++) { - const info = fieldEffectInfo[i]; - - if (info.maxDuration === 0) { - continue; - } - - --info.duration; - if (info.duration <= 0) { - // Removes the item if the duration has expired - this.fieldEffectInfo.splice(this.fieldEffectInfo.indexOf(info), 1); - } - } + this.terrainInfo = { + name: localizeEffectName(TerrainType[event.terrainType]), + maxDuration: event.duration, + duration: event.duration, + terrainType: event.terrainType, + }; this.updateFieldText(); } + // #endregion Weather/Terrain + /** - * Animates the flyout to either show or hide it by applying a fade and translation - * @param visible Should the flyout be shown? + * Animate the flyout to either show or hide the modal. + * @param visible - Whether the the flyout should be shown */ public toggleFlyout(visible: boolean): void { globalScene.tweens.add({ @@ -421,15 +479,128 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { }); } + /** Destroy this element and remove all associated listeners. */ public destroy(fromScene?: boolean): void { globalScene.eventTarget.removeEventListener(BattleSceneEventType.NEW_ARENA, this.onNewArenaEvent); globalScene.eventTarget.removeEventListener(BattleSceneEventType.TURN_END, this.onTurnEndEvent); - globalScene.arena.eventTarget.removeEventListener(ArenaEventType.WEATHER_CHANGED, this.onFieldEffectChangedEvent); - globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TERRAIN_CHANGED, this.onFieldEffectChangedEvent); - globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TAG_ADDED, this.onFieldEffectChangedEvent); - globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TAG_REMOVED, this.onFieldEffectChangedEvent); + globalScene.arena.eventTarget.removeEventListener(ArenaEventType.WEATHER_CHANGED, this.onWeatherChanged); + globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TERRAIN_CHANGED, this.onTerrainChanged); + globalScene.arena.eventTarget.removeEventListener(ArenaEventType.ARENA_TAG_ADDED, this.onArenaTagAddedEvent); + globalScene.arena.eventTarget.removeEventListener(ArenaEventType.ARENA_TAG_REMOVED, this.onArenaTagRemovedEvent); + // biome-ignore format: Keeps lines in 1 piece + globalScene.arena.eventTarget.removeEventListener(ArenaEventType.POSITIONAL_TAG_ADDED, this.onPositionalTagAddedEvent); + // biome-ignore format: Keeps lines in 1 piece + globalScene.arena.eventTarget.removeEventListener(ArenaEventType.POSITIONAL_TAG_REMOVED, this.onPositionalTagRemovedEvent); super.destroy(fromScene); } + + /** Clear out the contents of all arena texts. */ + private clearText() { + this.flyoutTextPlayer.text = ""; + this.flyoutTextField.text = ""; + this.flyoutTextEnemy.text = ""; + } + + // #region Text display functions + + /** + * Iterate over all field effects and update the corresponding {@linkcode Phaser.GameObjects.Text} object. + */ + private updateFieldText(): void { + this.clearText(); + + // Weather and terrain go first + if (this.weatherInfo) { + this.updateTagText(this.weatherInfo); + } + if (this.terrainInfo) { + this.updateTagText(this.terrainInfo); + } + + // Sort and add all positional tags + this.positionalTags.sort( + // Sort based on tag name, breaking ties by ascending target index. + (infoA, infoB) => infoA.name.localeCompare(infoB.name) || infoA.targetIndex - infoB.targetIndex, + ); + for (const tag of this.positionalTags) { + this.updatePosTagText(tag); + } + + // Sort and update all arena tag text + this.arenaTags.sort((infoA, infoB) => infoA.duration - infoB.duration); + for (const tag of this.arenaTags) { + this.updateTagText(tag); + } + } + + /** + * Helper method to update the flyout box's text with a {@linkcode PositionalTag}'s info. + * @param info - The {@linkcode PositionalTagInfo} whose text is being updated + */ + private updatePosTagText(info: PositionalTagInfo): void { + const textObj = this.getPositionalTagTextObj(info); + + const targetPos = battlerIndexToFieldPosition(info.targetIndex); + const posText = localizeEffectName(FieldPosition[targetPos]); + + // Ex: "Future Sight (Center, 2)" + textObj.text += `${info.name} (${posText}, ${info.duration}\n`; + } + + /** + * Helper method to update the flyout box's text with an effect's info. + * @param info - The {@linkcode ArenaTagInfo}, {@linkcode TerrainInfo} or {@linkcode WeatherInfo} being updated + */ + private updateTagText(info: ArenaTagInfo | WeatherInfo | TerrainInfo): void { + // Weathers and terrains use the "field" box by default + const textObject = "tagType" in info ? this.getArenaTagTargetObj(info.side) : this.flyoutTextField; + + textObject.text += info.name; + + if (info.maxDuration > 0) { + textObject.text += ` ${info.duration}/ + ${info.maxDuration}`; + } + + textObject.text += "\n"; + } + + /** + * Helper method to select the text object needing to be updated depending on the current tag's side. + * @param side - The {@linkcode ArenaTagSide} of the tag being updated + * @returns The {@linkcode Phaser.GameObjects.Text} to be updated. + */ + private getArenaTagTargetObj(side: ArenaTagSide): Phaser.GameObjects.Text { + switch (side) { + case ArenaTagSide.PLAYER: + return this.flyoutTextPlayer; + case ArenaTagSide.ENEMY: + return this.flyoutTextEnemy; + case ArenaTagSide.BOTH: + return this.flyoutTextField; + } + } + + /** + * Choose which text object needs to be updated depending on the current tag's target. + * @param info - The {@linkcode PositionalTagInfo} being displayed + * @returns The {@linkcode Phaser.GameObjects.Text} to be updated. + */ + private getPositionalTagTextObj(info: PositionalTagInfo): Phaser.GameObjects.Text { + switch (info.targetIndex) { + case BattlerIndex.PLAYER: + case BattlerIndex.PLAYER_2: + return this.flyoutTextPlayer; + case BattlerIndex.ENEMY: + case BattlerIndex.ENEMY_2: + return this.flyoutTextEnemy; + case BattlerIndex.ATTACKER: + throw new Error("BattlerIndex.ATTACKER used as tag target index for arena flyout!"); + } + } + + private; + + // # endregion Text display functions }