From 349c1d052f901f0fee858f051e4ea1a3dfd745b2 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 1 Aug 2025 13:49:00 -0400 Subject: [PATCH 1/9] Added Positional Tags to the battle flyout again --- src/@types/typed-event-target.ts | 17 + src/data/arena-tag.ts | 23 +- .../positional-tags/load-positional-tag.ts | 12 +- .../positional-tags/positional-tag-manager.ts | 11 + src/enums/arena-tag-side.ts | 13 + src/enums/field-position.ts | 29 + src/events/arena.ts | 216 +++++-- src/field/arena.ts | 70 +- src/system/game-data.ts | 44 +- src/ui/arena-flyout.ts | 610 +++++++++++------- 10 files changed, 685 insertions(+), 360 deletions(-) create mode 100644 src/@types/typed-event-target.ts 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..eb79f04baf5 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,10 +25,12 @@ 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] { +export function loadPositionalTag(tag: serializedPosTagMap[T]): posTagInstanceMap[T] { + // Update the global arena flyout + + globalScene.arena.eventTarget.dispatchEvent(new PositionalTagAddedEvent(tag)); + + const { tagType, ...rest } = tag; // 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` 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..277806e0993 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -1,61 +1,123 @@ 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 { TextStyle } from "#enums/text-style"; +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 { 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 { addTextObject, TextStyle } 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 +150,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 +239,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 +287,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 +478,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 } From 2b72077af4fc06b7d49f01e71baeb7f69695695e Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:16:19 -0400 Subject: [PATCH 2/9] Maybe fixed typecheck error 0.5 --- src/data/positional-tags/load-positional-tag.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index eb79f04baf5..ebf079a916e 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -25,19 +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(tag: serializedPosTagMap[T]): posTagInstanceMap[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; - // 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]); + const tagClass = posTagConstructorMap[tagType]; + return new tagClass(rest); } /** Const object mapping tag types to their constructors. */ From dc849bcd080fb7a7f5f007b669283e0ab4dcf604 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:18:03 -0400 Subject: [PATCH 3/9] Fixed arena-flyout ts import error --- src/ui/arena-flyout.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index 277806e0993..33cdf69bf48 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -12,6 +12,7 @@ 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 { ArenaEventType, @@ -23,7 +24,7 @@ import { type WeatherChangedEvent, } from "#events/arena"; import { BattleSceneEventType } from "#events/battle-scene"; -import { addTextObject, TextStyle } from "#ui/text"; +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"; From a821fc2f80c177fee3d1cd3d679286f88294ca90 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 14 Aug 2025 18:36:23 -0400 Subject: [PATCH 4/9] Fixed type errors; made tag type a const object --- .../positional-tags/load-positional-tag.ts | 2 +- src/enums/arena-event-type.ts | 38 ++++++++ src/enums/field-position.ts | 29 ------ src/events/arena.ts | 38 +++----- src/ui/arena-flyout.ts | 95 ++++++++++++------- 5 files changed, 115 insertions(+), 87 deletions(-) create mode 100644 src/enums/arena-event-type.ts diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index ebf079a916e..97ad74971c2 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -30,9 +30,9 @@ export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag { 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]; + // @ts-expect-error - tagType always corresponds to the proper constructor for `rest` return new tagClass(rest); } diff --git a/src/enums/arena-event-type.ts b/src/enums/arena-event-type.ts new file mode 100644 index 00000000000..534bc7e756d --- /dev/null +++ b/src/enums/arena-event-type.ts @@ -0,0 +1,38 @@ +import type { ArenaTag } from "#data/arena-tag"; +import type { PositionalTag } from "#data/positional-tags/positional-tag"; +import type { TerrainType } from "#data/terrain"; +import type { WeatherType } from "#enums/weather-type"; +import type { ArenaEvent } from "#events/arena"; +import type { ObjectValues } from "#types/type-helpers"; + +/** + * Enum representing the types of all {@linkcode ArenaEvent}s that can be emitted. + * @eventProperty + * @enum + */ +export const ArenaEventType = { + /** Emitted when a {@linkcode WeatherType} is added, overlapped, or removed */ + WEATHER_CHANGED: "onWeatherChanged", + /** Emitted when a {@linkcode TerrainType} is added, overlapped, or removed */ + TERRAIN_CHANGED: "onTerrainChanged", + + /** 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", +} as const; + +export type ArenaEventType = ObjectValues; + +/** + Doc comment removal prevention block + {@linkcode WeatherType} + {@linkcode TerrainType} + {@linkcode PositionalTag} + {@linkcode ArenaTag} +*/ \ No newline at end of file diff --git a/src/enums/field-position.ts b/src/enums/field-position.ts index d11e5040f41..5b7f9c6c570 100644 --- a/src/enums/field-position.ts +++ b/src/enums/field-position.ts @@ -1,34 +1,5 @@ -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 565d405e12e..6674a5278cc 100644 --- a/src/events/arena.ts +++ b/src/events/arena.ts @@ -2,32 +2,16 @@ import type { SerializedPositionalTag } from "#data/positional-tags/load-positio // biome-ignore lint/correctness/noUnusedImports: TSDoc import type { PositionalTag } from "#data/positional-tags/positional-tag"; import type { TerrainType } from "#data/terrain"; +import { ArenaEventType } from "#enums/arena-event-type"; 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"; -/** Enum representing the types of all {@linkcode ArenaEvent}s that can be emitted. */ -export enum ArenaEventType { - /** Emitted when a {@linkcode WeatherType} is added, overlapped, or removed */ - WEATHER_CHANGED = "onWeatherChanged", - /** Emitted when a {@linkcode TerrainType} is added, overlapped, or removed */ - TERRAIN_CHANGED = "onTerrainChanged", - - /** 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", -} - /** * Abstract container class for all {@linkcode ArenaEventType} events. + * @eventProperty */ abstract class ArenaEvent extends Event { /** The {@linkcode ArenaEventType} being emitted. */ @@ -43,9 +27,10 @@ export type { ArenaEvent }; /** * Container class for {@linkcode ArenaEventType.WEATHER_CHANGED} events. \ * Emitted whenever a weather effect starts, ends or is replaced. + * @eventProperty */ export class WeatherChangedEvent extends ArenaEvent { - declare type: ArenaEventType.WEATHER_CHANGED; + declare type: typeof ArenaEventType.WEATHER_CHANGED; /** The new {@linkcode WeatherType} being set. */ public weatherType: WeatherType; @@ -66,9 +51,10 @@ export class WeatherChangedEvent extends ArenaEvent { /** * Container class for {@linkcode ArenaEventType.TERRAIN_CHANGED} events. \ * Emitted whenever a terrain effect starts, ends or is replaced. + * @eventProperty */ export class TerrainChangedEvent extends ArenaEvent { - declare type: ArenaEventType.TERRAIN_CHANGED; + declare type: typeof ArenaEventType.TERRAIN_CHANGED; /** The new {@linkcode TerrainType} being set. */ public terrainType: TerrainType; @@ -90,9 +76,10 @@ export class TerrainChangedEvent 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. + * @eventProperty */ export class ArenaTagAddedEvent extends ArenaEvent { - declare type: ArenaEventType.ARENA_TAG_ADDED; + declare type: typeof ArenaEventType.ARENA_TAG_ADDED; /** The {@linkcode ArenaTagType} of the tag being added */ public tagType: ArenaTagType; @@ -124,9 +111,10 @@ export class ArenaTagAddedEvent extends ArenaEvent { /** * Container class for {@linkcode ArenaEventType.ARENA_TAG_REMOVED} events. \ * Emitted whenever an {@linkcode ArenaTag} is removed from the field for any reason. + * @eventProperty */ export class ArenaTagRemovedEvent extends ArenaEvent { - declare type: ArenaEventType.ARENA_TAG_REMOVED; + declare type: typeof ArenaEventType.ARENA_TAG_REMOVED; /** The {@linkcode ArenaTagType} of the tag being removed. */ public tagType: ArenaTagType; @@ -144,9 +132,10 @@ export class ArenaTagRemovedEvent extends ArenaEvent { /** * Container class for {@linkcode ArenaEventType.POSITIONAL_TAG_ADDED} events. \ * Emitted whenever a new {@linkcode PositionalTag} is spawned and added to the arena. + * @eventProperty */ export class PositionalTagAddedEvent extends ArenaEvent { - declare type: ArenaEventType.POSITIONAL_TAG_ADDED; + declare type: typeof ArenaEventType.POSITIONAL_TAG_ADDED; /** The {@linkcode SerializedPositionalTag} being added to the arena. */ public tag: SerializedPositionalTag; @@ -169,9 +158,10 @@ export class PositionalTagAddedEvent extends ArenaEvent { * 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. + * @eventProperty */ export class PositionalTagRemovedEvent extends ArenaEvent { - declare type: ArenaEventType.POSITIONAL_TAG_REMOVED; + declare type: typeof ArenaEventType.POSITIONAL_TAG_REMOVED; /** The {@linkcode PositionalTagType} of the tag being deleted. */ public tagType: PositionalTagType; diff --git a/src/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index 33cdf69bf48..2337ed48d14 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -5,23 +5,23 @@ import type { SerializedPositionalTag } from "#data/positional-tags/load-positio import type { PositionalTag } from "#data/positional-tags/positional-tag"; import { type Terrain, TerrainType } from "#data/terrain"; import type { Weather } from "#data/weather"; +import { ArenaEventType } from "#enums/arena-event-type"; // 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 { 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 { - ArenaEventType, - type ArenaTagAddedEvent, - type ArenaTagRemovedEvent, - type PositionalTagAddedEvent, - type PositionalTagRemovedEvent, - type TerrainChangedEvent, - type WeatherChangedEvent, +import type { + ArenaTagAddedEvent, + ArenaTagRemovedEvent, + PositionalTagAddedEvent, + PositionalTagRemovedEvent, + TerrainChangedEvent, + WeatherChangedEvent, } from "#events/arena"; import { BattleSceneEventType } from "#events/battle-scene"; import { addTextObject } from "#ui/text"; @@ -92,7 +92,7 @@ interface PositionalTagInfo { * @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 { +export function localizeEffectName(text: string): string { const effectName = toCamelCase(text); const i18nKey = `arenaFlyout:${effectName}`; const resultName = i18next.t(i18nKey); @@ -103,11 +103,12 @@ function localizeEffectName(text: string): string { * 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. + * @package */ -function getPositionalTagDisplayName(tag: SerializedPositionalTag): string { +export function getPositionalTagDisplayName(tag: SerializedPositionalTag): string { let tagName: string; if ("sourceMove" in tag) { - // Delayed attacks will use the source move's name + // Delayed attacks will use the source move's name; other effects rely on type tagName = MoveId[tag.sourceMove]; } else { tagName = PositionalTagType[tag.tagType]; @@ -317,7 +318,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { 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.positionalTags = this.positionalTags.filter(info => --info.duration > 0); this.updateFieldText(); } @@ -354,9 +355,9 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { /** * 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 existingTag - The existing {@linkcode ArenaTagInfo} being updated * @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 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 { @@ -370,6 +371,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { */ private onArenaTagRemoved(event: ArenaTagRemovedEvent): void { const foundIndex = this.arenaTags.findIndex(info => info.tagType === event.tagType && info.side === event.side); + console.log(this.positionalTags, event); if (foundIndex > -1) { // If the tag was being tracked, remove it @@ -513,10 +515,10 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { // Weather and terrain go first if (this.weatherInfo) { - this.updateTagText(this.weatherInfo); + this.flyoutTextField.text += this.getTagText(this.weatherInfo); } if (this.terrainInfo) { - this.updateTagText(this.terrainInfo); + this.flyoutTextField.text += this.getTagText(this.terrainInfo); } // Sort and add all positional tags @@ -525,45 +527,48 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { (infoA, infoB) => infoA.name.localeCompare(infoB.name) || infoA.targetIndex - infoB.targetIndex, ); for (const tag of this.positionalTags) { - this.updatePosTagText(tag); + this.getPositionalTagTextObj(tag).text += this.getPosTagText(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); + this.getArenaTagTargetObj(tag.side).text += this.getTagText(tag); } } /** - * Helper method to update the flyout box's text with a {@linkcode PositionalTag}'s info. + * Helper method to retrieve the flyout text for a given {@linkcode PositionalTag}. * @param info - The {@linkcode PositionalTagInfo} whose text is being updated + * @returns The text to be added to the container */ - private updatePosTagText(info: PositionalTagInfo): void { - const textObj = this.getPositionalTagTextObj(info); + private getPosTagText(info: PositionalTagInfo): string { + // Avoud showing slot target for single battles + if (!globalScene.currentBattle.double) { + return `${info.name} (${info.duration})\n`; + } const targetPos = battlerIndexToFieldPosition(info.targetIndex); const posText = localizeEffectName(FieldPosition[targetPos]); // Ex: "Future Sight (Center, 2)" - textObj.text += `${info.name} (${posText}, ${info.duration}\n`; + return `${info.name} (${posText}, ${info.duration})\n`; } /** - * Helper method to update the flyout box's text with an effect's info. + * Helper method to retrieve the flyout text for a given effect's info. * @param info - The {@linkcode ArenaTagInfo}, {@linkcode TerrainInfo} or {@linkcode WeatherInfo} being updated + * @returns The text to be added to the container */ - 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; + private getTagText(info: ArenaTagInfo | WeatherInfo | TerrainInfo): string { + let text = info.name; if (info.maxDuration > 0) { - textObject.text += ` ${info.duration}/ + ${info.maxDuration}`; + text += ` ${info.duration}/${info.maxDuration}`; } - textObject.text += "\n"; + text += "\n"; + return text; } /** @@ -600,7 +605,31 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } } - private; - // # endregion Text display functions } + +/** + * Convert a {@linkcode BattlerIndex} into a field position. + * @param index - The {@linkcode BattlerIndex} to convert + * @returns The resultant field position. + */ +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; +} From 73993f25c9d9787e08900745b765b3f4ccf07ecc Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 14 Aug 2025 20:46:51 -0400 Subject: [PATCH 5/9] Added tests for the arena flyout + improved type inference on tags --- .../positional-tags/load-positional-tag.ts | 7 +- src/data/positional-tags/positional-tag.ts | 18 ++-- src/enums/arena-event-type.ts | 2 +- src/ui/arena-flyout.ts | 95 ++++++++----------- test/test-utils/test-utils.ts | 9 +- test/types/positional-tags.test-d.ts | 13 ++- test/ui/flyouts/arena-flyout.test.ts | 92 ++++++++++++++++++ 7 files changed, 156 insertions(+), 80 deletions(-) create mode 100644 test/ui/flyouts/arena-flyout.test.ts diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index 97ad74971c2..7ae396dc8f1 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -42,7 +42,7 @@ const posTagConstructorMap = Object.freeze({ [PositionalTagType.WISH]: WishTag, }) satisfies { // NB: This `satisfies` block ensures that all tag types have corresponding entries in the map. - [k in PositionalTagType]: Constructor; + [k in PositionalTagType]: Constructor; }; /** Type mapping positional tag types to their constructors. */ @@ -59,11 +59,12 @@ type posTagParamMap = { }; /** - * Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector. + * Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector. \ * Equivalent to their serialized representations. + * @interface */ export type serializedPosTagMap = { - [k in PositionalTagType]: posTagParamMap[k] & { tagType: k }; + [k in PositionalTagType]: posTagParamMap[k] & Pick; }; /** Union type containing all serialized {@linkcode PositionalTag}s. */ diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 77ca6f0e9eb..9f3af265c26 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -7,7 +7,7 @@ import { allMoves } from "#data/data-lists"; import type { BattlerIndex } from "#enums/battler-index"; import type { MoveId } from "#enums/move-id"; import { MoveUseMode } from "#enums/move-use-mode"; -import { PositionalTagType } from "#enums/positional-tag-type"; +import type { PositionalTagType } from "#enums/positional-tag-type"; import type { Pokemon } from "#field/pokemon"; import i18next from "i18next"; @@ -30,7 +30,7 @@ export interface PositionalTagBaseArgs { /** * The {@linkcode BattlerIndex} targeted by this effect. */ - targetIndex: BattlerIndex; + readonly targetIndex: BattlerIndex; } /** @@ -39,7 +39,7 @@ export interface PositionalTagBaseArgs { * Multiple tags of the same kind can stack with one another, provided they are affecting different targets. */ export abstract class PositionalTag implements PositionalTagBaseArgs { - /** This tag's {@linkcode PositionalTagType | type} */ + /** This tag's {@linkcode PositionalTagType | type}. */ public abstract readonly tagType: PositionalTagType; // These arguments have to be public to implement the interface, but are functionally private // outside this and the tag manager. @@ -76,9 +76,9 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs { /** * The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect. */ - sourceId: number; + readonly sourceId: number; /** The {@linkcode MoveId} that created this attack. */ - sourceMove: MoveId; + readonly sourceMove: MoveId; } /** @@ -87,7 +87,7 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs { * triggering against a certain slot after the turn count has elapsed. */ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { - public override readonly tagType = PositionalTagType.DELAYED_ATTACK; + public declare readonly tagType: PositionalTagType.DELAYED_ATTACK; public readonly sourceMove: MoveId; public readonly sourceId: number; @@ -133,16 +133,16 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs /** Interface containing arguments used to construct a {@linkcode WishTag}. */ interface WishArgs extends PositionalTagBaseArgs { /** The amount of {@linkcode Stat.HP | HP} to heal; set to 50% of the user's max HP during move usage. */ - healHp: number; + readonly healHp: number; /** The name of the {@linkcode Pokemon} having created the tag. */ - pokemonName: string; + readonly pokemonName: string; } /** * Tag to implement {@linkcode MoveId.WISH | Wish}. */ export class WishTag extends PositionalTag implements WishArgs { - public override readonly tagType = PositionalTagType.WISH; + public declare readonly tagType: PositionalTagType.WISH; public readonly pokemonName: string; public readonly healHp: number; diff --git a/src/enums/arena-event-type.ts b/src/enums/arena-event-type.ts index 534bc7e756d..c35828e9470 100644 --- a/src/enums/arena-event-type.ts +++ b/src/enums/arena-event-type.ts @@ -35,4 +35,4 @@ export type ArenaEventType = ObjectValues; {@linkcode TerrainType} {@linkcode PositionalTag} {@linkcode ArenaTag} -*/ \ No newline at end of file +*/ diff --git a/src/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index 2337ed48d14..4647eda8cbe 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -12,7 +12,7 @@ import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerIndex } from "#enums/battler-index"; import { FieldPosition } from "#enums/field-position"; import { MoveId } from "#enums/move-id"; -import { PositionalTagType } from "#enums/positional-tag-type"; +import type { PositionalTagType } from "#enums/positional-tag-type"; import { TextStyle } from "#enums/text-style"; import { WeatherType } from "#enums/weather-type"; import type { @@ -85,38 +85,6 @@ interface PositionalTagInfo { // #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. - */ -export 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. - * @package - */ -export function getPositionalTagDisplayName(tag: SerializedPositionalTag): string { - let tagName: string; - if ("sourceMove" in tag) { - // Delayed attacks will use the source move's name; other effects rely on type - tagName = MoveId[tag.sourceMove]; - } else { - tagName = PositionalTagType[tag.tagType]; - } - - return localizeEffectName(tagName); -} - /** * Class to display and update the on-screen arena flyout. */ @@ -330,7 +298,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { * @param event - The {@linkcode ArenaTagAddedEvent} having been emitted */ private onArenaTagAdded(event: ArenaTagAddedEvent): void { - const name = localizeEffectName(ArenaTagType[event.tagType]); + const name = this.localizeEffectName(ArenaTagType[event.tagType]); // Ternary used to avoid unneeded find const existingTrapTag = event.trapLayers !== undefined @@ -389,7 +357,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { * @param event - The {@linkcode PositionalTagAddedEvent} having been emitted */ private onPositionalTagAdded(event: PositionalTagAddedEvent): void { - const name = getPositionalTagDisplayName(event.tag); + const name = this.getPositionalTagDisplayName(event.tag); this.positionalTags.push({ name, @@ -433,7 +401,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } this.weatherInfo = { - name: localizeEffectName(WeatherType[event.weatherType]), + name: this.localizeEffectName(WeatherType[event.weatherType]), maxDuration: event.duration, duration: event.duration, weatherType: event.weatherType, @@ -455,7 +423,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } this.terrainInfo = { - name: localizeEffectName(TerrainType[event.terrainType]), + name: this.localizeEffectName(TerrainType[event.terrainType]), maxDuration: event.duration, duration: event.duration, terrainType: event.terrainType, @@ -549,7 +517,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } const targetPos = battlerIndexToFieldPosition(info.targetIndex); - const posText = localizeEffectName(FieldPosition[targetPos]); + const posText = this.localizeEffectName(FieldPosition[targetPos]); // Ex: "Future Sight (Center, 2)" return `${info.name} (${posText}, ${info.duration})\n`; @@ -606,6 +574,39 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } // # endregion Text display functions + + // #region Utilities + + /** + * 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. + */ + private 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. + */ + private getPositionalTagDisplayName(tag: SerializedPositionalTag): string { + let tagName: string; + if ("sourceMove" in tag) { + // Delayed attacks will use the source move's name; other effects use type directly + tagName = MoveId[tag.sourceMove]; + } else { + tagName = tag.tagType; + } + + return this.localizeEffectName(tagName); + } + + // #endregion Utility emthods } /** @@ -614,22 +615,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { * @returns The resultant field position. */ 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; - } + const pos = globalScene.getField()[index]?.fieldPosition; return pos; } diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 40e4bbe8775..7e095a323ae 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -1,13 +1,12 @@ import i18next, { type ParseKeys } from "i18next"; -import { vi } from "vitest"; +import { type MockInstance, vi } from "vitest"; /** - * Sets up the i18next mock. - * Includes a i18next.t mocked implementation only returning the raw key (`(key) => key`) + * Mock i18next's {@linkcode t} function to only produce the raw key. * - * @returns A spy/mock of i18next + * @returns A {@linkcode MockInstance} for `i18next.t` */ -export function mockI18next() { +export function mockI18next(): MockInstance<(typeof i18next)["t"]> { return vi.spyOn(i18next, "t").mockImplementation((key: ParseKeys) => key); } diff --git a/test/types/positional-tags.test-d.ts b/test/types/positional-tags.test-d.ts index a75cc291764..4965a0208ac 100644 --- a/test/types/positional-tags.test-d.ts +++ b/test/types/positional-tags.test-d.ts @@ -1,25 +1,24 @@ import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag"; import type { PositionalTagType } from "#enums/positional-tag-type"; -import type { Mutable, NonFunctionPropertiesRecursive } from "#types/type-helpers"; +import type { NonFunctionPropertiesRecursive } from "#types/type-helpers"; import { describe, expectTypeOf, it } from "vitest"; -// Needed to get around properties being readonly in certain classes -type NonFunctionMutable = Mutable>; - describe("serializedPositionalTagMap", () => { it("should contain representations of each tag's serialized form", () => { expectTypeOf().branded.toEqualTypeOf< - NonFunctionMutable + NonFunctionPropertiesRecursive + >(); + expectTypeOf().branded.toEqualTypeOf< + NonFunctionPropertiesRecursive >(); - expectTypeOf().branded.toEqualTypeOf>(); }); }); describe("SerializedPositionalTag", () => { it("should accept a union of all serialized tag forms", () => { expectTypeOf().branded.toEqualTypeOf< - NonFunctionMutable | NonFunctionMutable + NonFunctionPropertiesRecursive | NonFunctionPropertiesRecursive >(); }); it("should accept a union of all unserialized tag forms", () => { diff --git a/test/ui/flyouts/arena-flyout.test.ts b/test/ui/flyouts/arena-flyout.test.ts new file mode 100644 index 00000000000..1ca970d82f1 --- /dev/null +++ b/test/ui/flyouts/arena-flyout.test.ts @@ -0,0 +1,92 @@ +import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import { mockI18next } from "#test/test-utils/test-utils"; +import type { ArenaFlyout } from "#ui/arena-flyout"; +import type i18next from "i18next"; +import Phaser from "phaser"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +describe("UI - Arena Flyout", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let flyout: ArenaFlyout; + let tSpy: MockInstance<(typeof i18next)["t"]>; + + beforeAll(async () => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("double") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + flyout = game.scene.arenaFlyout; + }); + + beforeEach(() => { + // Reset i18n mock before each test + tSpy = mockI18next(); + }); + + afterAll(() => { + game.phaseInterceptor.restoreOg(); + }); + + describe("localizeEffectName", () => { + it("should retrieve locales from an effect name", () => { + const name = flyout["localizeEffectName"]("STEALTH_ROCK"); + expect(name).toBe("arenaFlyout:stealthRock"); + expect(tSpy).toHaveBeenCalledExactlyOnceWith("arenaFlyout:stealthRock"); + }); + }); + + // Helper type to get around unexportedness + type posTagInfo = (typeof flyout)["positionalTags"][number]; + + describe("getPositionalTagDisplayName", () => { + it.each([ + { tag: { tagType: PositionalTagType.WISH }, name: "arenaFlyout:wish" }, + { + tag: { sourceMove: MoveId.FUTURE_SIGHT, tagType: PositionalTagType.DELAYED_ATTACK }, + name: "arenaFlyout:futureSight", + }, + { + tag: { sourceMove: MoveId.DOOM_DESIRE, tagType: PositionalTagType.DELAYED_ATTACK }, + name: "arenaFlyout:doomDesire", + }, + ])("should get the name of a Positional Tag", ({ tag, name }) => { + const got = flyout["getPositionalTagDisplayName"](tag as SerializedPositionalTag); + expect(got).toBe(name); + }); + }); + + describe("getPosTagText", () => { + it.each<{ tag: Pick; output: string; double?: boolean }>([ + { tag: { duration: 2, name: "Wish", targetIndex: BattlerIndex.PLAYER }, output: "Wish (2)" }, + { + tag: { duration: 1, name: "Future Sight", targetIndex: BattlerIndex.ENEMY_2 }, + double: true, + output: "Future Sight (arenaFlyout:right, 1)", + }, + ])("should produce the correct text", ({ tag, output, double = false }) => { + vi.spyOn(game.scene.currentBattle, "double", "get").mockReturnValue(double); + const text = flyout["getPosTagText"](tag as posTagInfo); + expect(text).toBe(output + "\n"); + }); + }); +}); From 0a93c51c415aa7032cfaf82dff881c694f134c33 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 14 Aug 2025 21:08:26 -0400 Subject: [PATCH 6/9] Fixed doc on loading tags to work --- src/data/positional-tags/load-positional-tag.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index 7ae396dc8f1..41b112b84c2 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -7,16 +7,13 @@ import type { Constructor } from "#utils/common"; /** * Load the attributes of a {@linkcode PositionalTag}. - * @param tagType - The {@linkcode PositionalTagType} to create - * @param args - The arguments needed to instantize the given tag + * @param data - An object containing the {@linkcode PositionalTagType} to create, + * as well as the arguments needed to instantize the given tag * @returns The newly created tag. * @remarks * This function does not perform any checking if the added tag is valid. */ -export function loadPositionalTag({ - tagType, - ...args -}: serializedPosTagMap[T]): posTagInstanceMap[T]; +export function loadPositionalTag(data: serializedPosTagMap[T]): posTagInstanceMap[T]; /** * Load the attributes of a {@linkcode PositionalTag}. * @param tag - The {@linkcode SerializedPositionalTag} to instantiate From 57b8b466df7c2eb75a250e248f5d50a77aca5b01 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 14 Aug 2025 21:30:08 -0400 Subject: [PATCH 7/9] fixed issues with declare vs override oopsie --- src/data/positional-tags/positional-tag.ts | 8 +++++--- test/moves/heal-block.test.ts | 6 ++---- test/test-utils/matchers/to-have-positional-tag.ts | 6 ++++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 9f3af265c26..62d7fe3031f 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -2,12 +2,14 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; // biome-ignore-start lint/correctness/noUnusedImports: TSDoc import type { ArenaTag } from "#data/arena-tag"; +import type { Stat } from "#enums/stat"; // biome-ignore-end lint/correctness/noUnusedImports: TSDoc + import { allMoves } from "#data/data-lists"; import type { BattlerIndex } from "#enums/battler-index"; import type { MoveId } from "#enums/move-id"; import { MoveUseMode } from "#enums/move-use-mode"; -import type { PositionalTagType } from "#enums/positional-tag-type"; +import { PositionalTagType } from "#enums/positional-tag-type"; import type { Pokemon } from "#field/pokemon"; import i18next from "i18next"; @@ -87,7 +89,7 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs { * triggering against a certain slot after the turn count has elapsed. */ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { - public declare readonly tagType: PositionalTagType.DELAYED_ATTACK; + public override readonly tagType = PositionalTagType.DELAYED_ATTACK; public readonly sourceMove: MoveId; public readonly sourceId: number; @@ -142,7 +144,7 @@ interface WishArgs extends PositionalTagBaseArgs { * Tag to implement {@linkcode MoveId.WISH | Wish}. */ export class WishTag extends PositionalTag implements WishArgs { - public declare readonly tagType: PositionalTagType.WISH; + public override readonly tagType = PositionalTagType.WISH; public readonly pokemonName: string; public readonly healHp: number; diff --git a/test/moves/heal-block.test.ts b/test/moves/heal-block.test.ts index 4c8e6395171..613c57f05a7 100644 --- a/test/moves/heal-block.test.ts +++ b/test/moves/heal-block.test.ts @@ -77,15 +77,13 @@ describe("Moves - Heal Block", () => { game.move.use(MoveId.WISH); await game.toNextTurn(); - expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) // - .toHaveLength(1); + expect(game).toHavePositionalTag(PositionalTagType.WISH); game.move.use(MoveId.SPLASH); await game.toNextTurn(); // wish triggered, but did NOT heal the player - expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) // - .toHaveLength(0); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); expect(player.hp).toBe(1); }); diff --git a/test/test-utils/matchers/to-have-positional-tag.ts b/test/test-utils/matchers/to-have-positional-tag.ts index 448339d6a8d..f100354023d 100644 --- a/test/test-utils/matchers/to-have-positional-tag.ts +++ b/test/test-utils/matchers/to-have-positional-tag.ts @@ -1,4 +1,6 @@ // biome-ignore-start lint/correctness/noUnusedImports: TSDoc + +import type { PositionalTag } from "#data/positional-tags/positional-tag"; import type { GameManager } from "#test/test-utils/game-manager"; // biome-ignore-end lint/correctness/noUnusedImports: TSDoc @@ -17,8 +19,8 @@ export type toHavePositionalTagOptions

= OneOther Date: Thu, 14 Aug 2025 23:28:20 -0400 Subject: [PATCH 8/9] Added test for `getTagText` --- test/ui/flyouts/arena-flyout.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/ui/flyouts/arena-flyout.test.ts b/test/ui/flyouts/arena-flyout.test.ts index 1ca970d82f1..ed05cca5da0 100644 --- a/test/ui/flyouts/arena-flyout.test.ts +++ b/test/ui/flyouts/arena-flyout.test.ts @@ -56,8 +56,19 @@ describe("UI - Arena Flyout", () => { }); // Helper type to get around unexportedness + type infoType = Parameters<(typeof flyout)["getTagText"]>[0]; type posTagInfo = (typeof flyout)["positionalTags"][number]; + describe("getTagText", () => { + it.each<{ info: Pick; text: string }>([ + { info: { name: "Spikes (1)", duration: 0, maxDuration: 0 }, text: "Spikes (1)\n" }, + { info: { name: "Grassy Terrain", duration: 1, maxDuration: 5 }, text: "Grassy Terrain (1/5)\n" }, + ])("should get the name of an arena effect", ({ info, text }) => { + const got = flyout["getTagText"](info as infoType); + expect(got).toBe(text); + }); + }); + describe("getPositionalTagDisplayName", () => { it.each([ { tag: { tagType: PositionalTagType.WISH }, name: "arenaFlyout:wish" }, From 8383df1855b25087596102b10a5a10ca04f82101 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 14 Aug 2025 23:30:14 -0400 Subject: [PATCH 9/9] Reverted changes to positional tag battle flyout --- .../positional-tags/load-positional-tag.ts | 9 +- .../positional-tags/positional-tag-manager.ts | 11 -- src/enums/arena-event-type.ts | 5 - src/events/arena.ts | 52 ------ src/ui/arena-flyout.ts | 154 +----------------- test/ui/flyouts/arena-flyout.test.ts | 38 +---- 6 files changed, 4 insertions(+), 265 deletions(-) diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index 41b112b84c2..dc3a67879b8 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -1,7 +1,5 @@ -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"; @@ -22,12 +20,7 @@ export function loadPositionalTag(data: serializedP * This function does not perform any checking if the added tag is valid. */ export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag; -export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag { - // Update the global arena flyout - globalScene.arena.eventTarget.dispatchEvent(new PositionalTagAddedEvent(tag)); - - // Create the new tag - const { tagType, ...rest } = tag; +export function loadPositionalTag({ tagType, ...rest }: SerializedPositionalTag): PositionalTag { const tagClass = posTagConstructorMap[tagType]; // @ts-expect-error - tagType always corresponds to the proper constructor for `rest` return new tagClass(rest); diff --git a/src/data/positional-tags/positional-tag-manager.ts b/src/data/positional-tags/positional-tag-manager.ts index b56c9686fbb..7bf4d4995c6 100644 --- a/src/data/positional-tags/positional-tag-manager.ts +++ b/src/data/positional-tags/positional-tag-manager.ts @@ -1,9 +1,7 @@ -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 { @@ -51,16 +49,7 @@ 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-event-type.ts b/src/enums/arena-event-type.ts index c35828e9470..07e8f00d649 100644 --- a/src/enums/arena-event-type.ts +++ b/src/enums/arena-event-type.ts @@ -20,11 +20,6 @@ export const ArenaEventType = { 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", } as const; export type ArenaEventType = ObjectValues; diff --git a/src/events/arena.ts b/src/events/arena.ts index 6674a5278cc..ca5828c54b6 100644 --- a/src/events/arena.ts +++ b/src/events/arena.ts @@ -1,12 +1,7 @@ -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 { ArenaEventType } from "#enums/arena-event-type"; 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"; /** @@ -128,50 +123,3 @@ export class ArenaTagRemovedEvent extends ArenaEvent { 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. - * @eventProperty - */ -export class PositionalTagAddedEvent extends ArenaEvent { - declare type: typeof 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. - * @eventProperty - */ -export class PositionalTagRemovedEvent extends ArenaEvent { - declare type: typeof 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/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index 828f7c1506d..ab0501b1798 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -1,28 +1,15 @@ import { globalScene } from "#app/global-scene"; // 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"; import { ArenaEventType } from "#enums/arena-event-type"; // 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 { FieldPosition } from "#enums/field-position"; -import { MoveId } from "#enums/move-id"; -import type { PositionalTagType } from "#enums/positional-tag-type"; import { TextStyle } from "#enums/text-style"; import { WeatherType } from "#enums/weather-type"; -import type { - ArenaTagAddedEvent, - ArenaTagRemovedEvent, - PositionalTagAddedEvent, - PositionalTagRemovedEvent, - TerrainChangedEvent, - WeatherChangedEvent, -} from "#events/arena"; +import type { ArenaTagAddedEvent, ArenaTagRemovedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#events/arena"; import { BattleSceneEventType } from "#events/battle-scene"; import { addTextObject } from "#ui/text"; import { TimeOfDayWidget } from "#ui/time-of-day-widget"; @@ -71,18 +58,6 @@ interface ArenaTagInfo { tagType?: ArenaTagType; } -/** 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 /** @@ -137,19 +112,13 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { /** 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[] = []; - // 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); @@ -267,26 +236,20 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { */ private onNewArena() { this.arenaTags = []; - this.positionalTags = []; // 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 } /** - * Iterate through all currently present field effects and decrement their durations. + * Iterate through all currently present tags effects and decrement their durations. */ 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(); } @@ -339,7 +302,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { */ private onArenaTagRemoved(event: ArenaTagRemovedEvent): void { const foundIndex = this.arenaTags.findIndex(info => info.tagType === event.tagType && info.side === event.side); - console.log(this.positionalTags, event); if (foundIndex > -1) { // If the tag was being tracked, remove it @@ -350,42 +312,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { // #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 = this.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 /** @@ -458,10 +384,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { 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); } @@ -489,15 +411,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { this.flyoutTextField.text += this.getTagText(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.getPositionalTagTextObj(tag).text += this.getPosTagText(tag); - } - // Sort and update all arena tag text this.arenaTags.sort((infoA, infoB) => infoA.duration - infoB.duration); for (const tag of this.arenaTags) { @@ -505,24 +418,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } } - /** - * Helper method to retrieve the flyout text for a given {@linkcode PositionalTag}. - * @param info - The {@linkcode PositionalTagInfo} whose text is being updated - * @returns The text to be added to the container - */ - private getPosTagText(info: PositionalTagInfo): string { - // Avoud showing slot target for single battles - if (!globalScene.currentBattle.double) { - return `${info.name} (${info.duration})\n`; - } - - const targetPos = battlerIndexToFieldPosition(info.targetIndex); - const posText = this.localizeEffectName(FieldPosition[targetPos]); - - // Ex: "Future Sight (Center, 2)" - return `${info.name} (${posText}, ${info.duration})\n`; - } - /** * Helper method to retrieve the flyout text for a given effect's info. * @param info - The {@linkcode ArenaTagInfo}, {@linkcode TerrainInfo} or {@linkcode WeatherInfo} being updated @@ -555,24 +450,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } } - /** - * 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!"); - } - } - // # endregion Text display functions // #region Utilities @@ -589,32 +466,5 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { 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. - */ - private getPositionalTagDisplayName(tag: SerializedPositionalTag): string { - let tagName: string; - if ("sourceMove" in tag) { - // Delayed attacks will use the source move's name; other effects use type directly - tagName = MoveId[tag.sourceMove]; - } else { - tagName = tag.tagType; - } - - return this.localizeEffectName(tagName); - } - // #endregion Utility emthods } - -/** - * Convert a {@linkcode BattlerIndex} into a field position. - * @param index - The {@linkcode BattlerIndex} to convert - * @returns The resultant field position. - */ -function battlerIndexToFieldPosition(index: BattlerIndex): FieldPosition { - const pos = globalScene.getField()[index]?.fieldPosition; - return pos; -} diff --git a/test/ui/flyouts/arena-flyout.test.ts b/test/ui/flyouts/arena-flyout.test.ts index ed05cca5da0..2094b79baf1 100644 --- a/test/ui/flyouts/arena-flyout.test.ts +++ b/test/ui/flyouts/arena-flyout.test.ts @@ -1,15 +1,12 @@ -import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag"; import { AbilityId } from "#enums/ability-id"; -import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; -import { PositionalTagType } from "#enums/positional-tag-type"; import { SpeciesId } from "#enums/species-id"; import { GameManager } from "#test/test-utils/game-manager"; import { mockI18next } from "#test/test-utils/test-utils"; import type { ArenaFlyout } from "#ui/arena-flyout"; import type i18next from "i18next"; import Phaser from "phaser"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, type MockInstance } from "vitest"; describe("UI - Arena Flyout", () => { let phaserGame: Phaser.Game; @@ -57,7 +54,6 @@ describe("UI - Arena Flyout", () => { // Helper type to get around unexportedness type infoType = Parameters<(typeof flyout)["getTagText"]>[0]; - type posTagInfo = (typeof flyout)["positionalTags"][number]; describe("getTagText", () => { it.each<{ info: Pick; text: string }>([ @@ -68,36 +64,4 @@ describe("UI - Arena Flyout", () => { expect(got).toBe(text); }); }); - - describe("getPositionalTagDisplayName", () => { - it.each([ - { tag: { tagType: PositionalTagType.WISH }, name: "arenaFlyout:wish" }, - { - tag: { sourceMove: MoveId.FUTURE_SIGHT, tagType: PositionalTagType.DELAYED_ATTACK }, - name: "arenaFlyout:futureSight", - }, - { - tag: { sourceMove: MoveId.DOOM_DESIRE, tagType: PositionalTagType.DELAYED_ATTACK }, - name: "arenaFlyout:doomDesire", - }, - ])("should get the name of a Positional Tag", ({ tag, name }) => { - const got = flyout["getPositionalTagDisplayName"](tag as SerializedPositionalTag); - expect(got).toBe(name); - }); - }); - - describe("getPosTagText", () => { - it.each<{ tag: Pick; output: string; double?: boolean }>([ - { tag: { duration: 2, name: "Wish", targetIndex: BattlerIndex.PLAYER }, output: "Wish (2)" }, - { - tag: { duration: 1, name: "Future Sight", targetIndex: BattlerIndex.ENEMY_2 }, - double: true, - output: "Future Sight (arenaFlyout:right, 1)", - }, - ])("should produce the correct text", ({ tag, output, double = false }) => { - vi.spyOn(game.scene.currentBattle, "double", "get").mockReturnValue(double); - const text = flyout["getPosTagText"](tag as posTagInfo); - expect(text).toBe(output + "\n"); - }); - }); });