This commit is contained in:
Bertie690 2025-08-15 12:09:50 -04:00 committed by GitHub
commit 5404d67e71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 580 additions and 405 deletions

View File

@ -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<T extends Event = never> 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;
}

View File

@ -19,6 +19,7 @@ import { MoveTarget } from "#enums/move-target";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { Stat } from "#enums/stat"; import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import { ArenaTagAddedEvent } from "#events/arena";
import type { Arena } from "#field/arena"; import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import type { import type {
@ -729,7 +730,9 @@ export class IonDelugeTag extends ArenaTag {
*/ */
export abstract class ArenaTrapTag extends SerializableArenaTag { export abstract class ArenaTrapTag extends SerializableArenaTag {
abstract readonly tagType: ArenaTrapTagType; abstract readonly tagType: ArenaTrapTagType;
/** The tag's current number of layers. */
public layers: number; public layers: number;
/** The maximum number of layers this tag can have. */
public maxLayers: number; public maxLayers: number;
/** /**
@ -749,11 +752,13 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
} }
onOverlap(arena: Arena, _source: Pokemon | null): void { onOverlap(arena: Arena, _source: Pokemon | null): void {
if (this.layers < this.maxLayers) { if (this.layers === this.maxLayers) {
this.layers++; return;
this.onAdd(arena);
} }
// 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); 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 { getMatchupScoreMultiplier(pokemon: Pokemon): number {
return pokemon.isGrounded() return pokemon.isGrounded()

View File

@ -5,16 +5,13 @@ import type { Constructor } from "#utils/common";
/** /**
* Load the attributes of a {@linkcode PositionalTag}. * Load the attributes of a {@linkcode PositionalTag}.
* @param tagType - The {@linkcode PositionalTagType} to create * @param data - An object containing the {@linkcode PositionalTagType} to create,
* @param args - The arguments needed to instantize the given tag * as well as the arguments needed to instantize the given tag
* @returns The newly created tag. * @returns The newly created tag.
* @remarks * @remarks
* This function does not perform any checking if the added tag is valid. * This function does not perform any checking if the added tag is valid.
*/ */
export function loadPositionalTag<T extends PositionalTagType>({ export function loadPositionalTag<T extends PositionalTagType>(data: serializedPosTagMap[T]): posTagInstanceMap[T];
tagType,
...args
}: serializedPosTagMap[T]): posTagInstanceMap[T];
/** /**
* Load the attributes of a {@linkcode PositionalTag}. * Load the attributes of a {@linkcode PositionalTag}.
* @param tag - The {@linkcode SerializedPositionalTag} to instantiate * @param tag - The {@linkcode SerializedPositionalTag} to instantiate
@ -23,17 +20,10 @@ export function loadPositionalTag<T extends PositionalTagType>({
* This function does not perform any checking if the added tag is valid. * 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;
export function loadPositionalTag<T extends PositionalTagType>({ export function loadPositionalTag({ tagType, ...rest }: SerializedPositionalTag): PositionalTag {
tagType, const tagClass = posTagConstructorMap[tagType];
...rest // @ts-expect-error - tagType always corresponds to the proper constructor for `rest`
}: serializedPosTagMap[T]): posTagInstanceMap[T] { return new tagClass(rest);
// 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<serializedPosTagParamMap[T], "tagType"> into `posTagParamMap[T]`)
return new tagClass(rest as unknown as posTagParamMap[T]);
} }
/** Const object mapping tag types to their constructors. */ /** Const object mapping tag types to their constructors. */
@ -42,7 +32,7 @@ const posTagConstructorMap = Object.freeze({
[PositionalTagType.WISH]: WishTag, [PositionalTagType.WISH]: WishTag,
}) satisfies { }) satisfies {
// NB: This `satisfies` block ensures that all tag types have corresponding entries in the map. // NB: This `satisfies` block ensures that all tag types have corresponding entries in the map.
[k in PositionalTagType]: Constructor<PositionalTag & { tagType: k }>; [k in PositionalTagType]: Constructor<PositionalTag & { readonly tagType: k }>;
}; };
/** Type mapping positional tag types to their constructors. */ /** Type mapping positional tag types to their constructors. */
@ -59,11 +49,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. * Equivalent to their serialized representations.
* @interface
*/ */
export type serializedPosTagMap = { export type serializedPosTagMap = {
[k in PositionalTagType]: posTagParamMap[k] & { tagType: k }; [k in PositionalTagType]: posTagParamMap[k] & Pick<posTagInstanceMap[k], "tagType">;
}; };
/** Union type containing all serialized {@linkcode PositionalTag}s. */ /** Union type containing all serialized {@linkcode PositionalTag}s. */

View File

@ -2,7 +2,9 @@ import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc // biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { ArenaTag } from "#data/arena-tag"; import type { ArenaTag } from "#data/arena-tag";
import type { Stat } from "#enums/stat";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc // biome-ignore-end lint/correctness/noUnusedImports: TSDoc
import { allMoves } from "#data/data-lists"; import { allMoves } from "#data/data-lists";
import type { BattlerIndex } from "#enums/battler-index"; import type { BattlerIndex } from "#enums/battler-index";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
@ -30,7 +32,7 @@ export interface PositionalTagBaseArgs {
/** /**
* The {@linkcode BattlerIndex} targeted by this effect. * The {@linkcode BattlerIndex} targeted by this effect.
*/ */
targetIndex: BattlerIndex; readonly targetIndex: BattlerIndex;
} }
/** /**
@ -39,7 +41,7 @@ export interface PositionalTagBaseArgs {
* Multiple tags of the same kind can stack with one another, provided they are affecting different targets. * Multiple tags of the same kind can stack with one another, provided they are affecting different targets.
*/ */
export abstract class PositionalTag implements PositionalTagBaseArgs { export abstract class PositionalTag implements PositionalTagBaseArgs {
/** This tag's {@linkcode PositionalTagType | type} */ /** This tag's {@linkcode PositionalTagType | type}. */
public abstract readonly tagType: PositionalTagType; public abstract readonly tagType: PositionalTagType;
// These arguments have to be public to implement the interface, but are functionally private // These arguments have to be public to implement the interface, but are functionally private
// outside this and the tag manager. // outside this and the tag manager.
@ -76,9 +78,9 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs {
/** /**
* The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect. * 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. */ /** The {@linkcode MoveId} that created this attack. */
sourceMove: MoveId; readonly sourceMove: MoveId;
} }
/** /**
@ -133,9 +135,9 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs
/** Interface containing arguments used to construct a {@linkcode WishTag}. */ /** Interface containing arguments used to construct a {@linkcode WishTag}. */
interface WishArgs extends PositionalTagBaseArgs { 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. */ /** 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. */ /** The name of the {@linkcode Pokemon} having created the tag. */
pokemonName: string; readonly pokemonName: string;
} }
/** /**

View File

@ -0,0 +1,33 @@
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",
} as const;
export type ArenaEventType = ObjectValues<typeof ArenaEventType>;
/**
Doc comment removal prevention block
{@linkcode WeatherType}
{@linkcode TerrainType}
{@linkcode PositionalTag}
{@linkcode ArenaTag}
*/

View File

@ -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 { 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, BOTH,
/** The effect applies exclusively to the player's side of the field. */
PLAYER, PLAYER,
/** The effect applies exclusively to the opposing side of the field. */
ENEMY ENEMY
} }

View File

@ -1,109 +1,125 @@
import type { TerrainType } from "#data/terrain"; import type { TerrainType } from "#data/terrain";
import { ArenaEventType } from "#enums/arena-event-type";
import type { ArenaTagSide } from "#enums/arena-tag-side"; import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { ArenaTagType } from "#enums/arena-tag-type"; import type { ArenaTagType } from "#enums/arena-tag-type";
import type { WeatherType } from "#enums/weather-type"; import type { WeatherType } from "#enums/weather-type";
/** Alias for all {@linkcode ArenaEvent} type strings */ /**
export enum ArenaEventType { * Abstract container class for all {@linkcode ArenaEventType} events.
/** Triggers when a {@linkcode WeatherType} is added, overlapped, or removed */ * @eventProperty
WEATHER_CHANGED = "onWeatherChanged", */
/** Triggers when a {@linkcode TerrainType} is added, overlapped, or removed */ abstract class ArenaEvent extends Event {
TERRAIN_CHANGED = "onTerrainChanged", /** The {@linkcode ArenaEventType} being emitted. */
public declare abstract readonly type: ArenaEventType; // that's a mouthful!
/** Triggers when a {@linkcode ArenaTagType} is added */ // biome-ignore lint/complexity/noUselessConstructor: changes the type of the type field
TAG_ADDED = "onTagAdded", constructor(type: ArenaEventType) {
/** Triggers when a {@linkcode ArenaTagType} is removed */ super(type);
TAG_REMOVED = "onTagRemoved", }
} }
/** export type { ArenaEvent };
* Base container class for all {@linkcode ArenaEventType} events
* @extends Event
*/
export class ArenaEvent extends Event {
/** The total duration of the {@linkcode ArenaEventType} */
public duration: number;
constructor(eventType: ArenaEventType, duration: number) {
super(eventType);
/**
* 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: typeof 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; 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 * Container class for {@linkcode ArenaEventType.TERRAIN_CHANGED} events. \
* @extends ArenaEvent * Emitted whenever a terrain effect starts, ends or is replaced.
* @eventProperty
*/ */
export class TerrainChangedEvent extends ArenaEvent { export class TerrainChangedEvent extends ArenaEvent {
/** The {@linkcode TerrainType} being overridden */ declare type: typeof ArenaEventType.TERRAIN_CHANGED;
public oldTerrainType: TerrainType;
/** The {@linkcode TerrainType} being set */
public newTerrainType: TerrainType;
constructor(oldTerrainType: TerrainType, newTerrainType: TerrainType, duration: number) {
super(ArenaEventType.TERRAIN_CHANGED, duration);
this.oldTerrainType = oldTerrainType; /** The new {@linkcode TerrainType} being set. */
this.newTerrainType = newTerrainType; 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 * Container class for {@linkcode ArenaEventType.ARENA_TAG_ADDED} events. \
* @extends ArenaEvent * 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 TagAddedEvent extends ArenaEvent { export class ArenaTagAddedEvent extends ArenaEvent {
/** The {@linkcode ArenaTagType} being added */ declare type: typeof ArenaEventType.ARENA_TAG_ADDED;
public arenaTagType: ArenaTagType;
/** The {@linkcode ArenaTagSide} the tag is being placed on */ /** The {@linkcode ArenaTagType} of the tag being added */
public arenaTagSide: ArenaTagSide; public tagType: ArenaTagType;
/** The current number of layers of the arena trap. */ /** The {@linkcode ArenaTagSide} the tag is being added too */
public arenaTagLayers: number; public side: ArenaTagSide;
/** The maximum amount of layers of the arena trap. */ /** The tag's initial duration. */
public arenaTagMaxLayers: number; 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( constructor(
arenaTagType: ArenaTagType, side: ArenaTagType,
arenaTagSide: ArenaTagSide, arenaTagSide: ArenaTagSide,
duration: number, duration: number,
arenaTagLayers?: number, trapLayers?: [current: number, max: number],
arenaTagMaxLayers?: number,
) { ) {
super(ArenaEventType.TAG_ADDED, duration); super(ArenaEventType.ARENA_TAG_ADDED);
this.arenaTagType = arenaTagType; this.tagType = side;
this.arenaTagSide = arenaTagSide; this.side = arenaTagSide;
this.arenaTagLayers = arenaTagLayers!; // TODO: is this bang correct? this.duration = duration;
this.arenaTagMaxLayers = arenaTagMaxLayers!; // TODO: is this bang correct? this.trapLayers = trapLayers;
} }
} }
/** /**
* Container class for {@linkcode ArenaEventType.TAG_REMOVED} events * Container class for {@linkcode ArenaEventType.ARENA_TAG_REMOVED} events. \
* @extends ArenaEvent * Emitted whenever an {@linkcode ArenaTag} is removed from the field for any reason.
* @eventProperty
*/ */
export class TagRemovedEvent extends ArenaEvent { export class ArenaTagRemovedEvent extends ArenaEvent {
/** The {@linkcode ArenaTagType} being removed */ declare type: typeof ArenaEventType.ARENA_TAG_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);
this.arenaTagType = arenaTagType; /** The {@linkcode ArenaTagType} of the tag being removed. */
this.arenaTagSide = arenaTagSide; 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;
} }
} }

View File

@ -31,11 +31,18 @@ import { SpeciesId } from "#enums/species-id";
import { TimeOfDay } from "#enums/time-of-day"; import { TimeOfDay } from "#enums/time-of-day";
import { TrainerType } from "#enums/trainer-type"; import { TrainerType } from "#enums/trainer-type";
import { WeatherType } from "#enums/weather-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 type { Pokemon } from "#field/pokemon";
import { FieldEffectModifier } from "#modifiers/modifier"; import { FieldEffectModifier } from "#modifiers/modifier";
import type { Move } from "#moves/move"; import type { Move } from "#moves/move";
import type { AbstractConstructor } from "#types/type-helpers"; 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 { type Constructor, isNullOrUndefined, NumberHolder, randSeedInt } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils"; import { getPokemonSpecies } from "#utils/pokemon-utils";
@ -45,10 +52,7 @@ export class Arena {
public terrain: Terrain | null; public terrain: Terrain | null;
/** All currently-active {@linkcode ArenaTag}s on both sides of the field. */ /** All currently-active {@linkcode ArenaTag}s on both sides of the field. */
public tags: ArenaTag[] = []; public tags: ArenaTag[] = [];
/** /** A manager for the currently-active {@linkcode PositionalTag}s on both sides of the field. */
* All currently-active {@linkcode PositionalTag}s on both sides of the field,
* sorted by tag type.
*/
public positionalTagManager: PositionalTagManager = new PositionalTagManager(); public positionalTagManager: PositionalTagManager = new PositionalTagManager();
public bgm: string; public bgm: string;
@ -66,7 +70,11 @@ export class Arena {
private pokemonPool: PokemonPools; private pokemonPool: PokemonPools;
private trainerPool: BiomeTierTrainerPools; 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<ArenaEvent> = new EventTarget();
constructor(biome: BiomeId, playerFaints = 0) { constructor(biome: BiomeId, playerFaints = 0) {
this.biomeType = biome; this.biomeType = biome;
@ -344,9 +352,7 @@ export class Arena {
} }
this.weather = weather ? new Weather(weather, weatherDuration.value) : null; this.weather = weather ? new Weather(weather, weatherDuration.value) : null;
this.eventTarget.dispatchEvent( this.eventTarget.dispatchEvent(new WeatherChangedEvent(this.getWeatherType(), weatherDuration.value));
new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!),
); // TODO: is this bang correct?
if (this.weather) { if (this.weather) {
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
@ -432,9 +438,7 @@ export class Arena {
this.terrain = terrain ? new Terrain(terrain, terrainDuration.value) : null; this.terrain = terrain ? new Terrain(terrain, terrainDuration.value) : null;
this.eventTarget.dispatchEvent( this.eventTarget.dispatchEvent(new TerrainChangedEvent(this.getTerrainType(), terrainDuration.value));
new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!),
); // TODO: are those bangs correct?
if (this.terrain) { if (this.terrain) {
if (!ignoreAnim) { if (!ignoreAnim) {
@ -708,26 +712,25 @@ export class Arena {
const existingTag = this.getTagOnSide(tagType, side); const existingTag = this.getTagOnSide(tagType, side);
if (existingTag) { if (existingTag) {
existingTag.onOverlap(this, globalScene.getPokemonById(sourceId)); 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; return false;
} }
// creates a new tag object // creates a new tag object
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side); const newTag = getArenaTag(tagType, turnCount, sourceMove, sourceId, side);
if (newTag) { if (!newTag) {
return false;
}
newTag.onAdd(this, quiet); newTag.onAdd(this, quiet);
this.tags.push(newTag); this.tags.push(newTag);
const { layers = 0, maxLayers = 0 } = newTag instanceof ArenaTrapTag ? newTag : {}; // Dispatch a TagAddedEvent to update the flyout.
if (newTag instanceof ArenaTrapTag) {
this.eventTarget.dispatchEvent( globalScene.arena.eventTarget.dispatchEvent(
new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, layers, maxLayers), new ArenaTagAddedEvent(tagType, side, turnCount, [newTag.layers, newTag.maxLayers]),
); );
} else {
globalScene.arena.eventTarget.dispatchEvent(new ArenaTagAddedEvent(tagType, side, turnCount));
} }
return true; return true;
@ -807,7 +810,7 @@ export class Arena {
t.onRemove(this); t.onRemove(this);
this.tags.splice(this.tags.indexOf(t), 1); 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); tag.onRemove(this);
tags.splice(tags.indexOf(tag), 1); 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; return !!tag;
} }
@ -829,20 +832,17 @@ export class Arena {
tag.onRemove(this, quiet); tag.onRemove(this, quiet);
this.tags.splice(this.tags.indexOf(tag), 1); 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; return !!tag;
} }
removeAllTags(): void { removeAllTags(): void {
while (this.tags.length) { for (const tag of this.tags) {
this.tags[0].onRemove(this); tag.onRemove(this);
this.eventTarget.dispatchEvent( this.eventTarget.dispatchEvent(new ArenaTagRemovedEvent(tag.tagType, tag.side));
new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount),
);
this.tags.splice(0, 1);
} }
this.tags = [];
} }
/** /**

View File

@ -35,7 +35,7 @@ import { TrainerVariant } from "#enums/trainer-variant";
import { UiMode } from "#enums/ui-mode"; import { UiMode } from "#enums/ui-mode";
import { Unlockables } from "#enums/unlockables"; import { Unlockables } from "#enums/unlockables";
import { WeatherType } from "#enums/weather-type"; 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"; 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 // 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"; import * as Modifier from "#modifiers/modifier";
@ -1113,36 +1113,30 @@ export class GameData {
}); });
globalScene.arena.weather = sessionData.arena.weather; globalScene.arena.weather = sessionData.arena.weather;
if (globalScene.arena.getWeatherType() !== WeatherType.NONE) {
globalScene.arena.eventTarget.dispatchEvent( globalScene.arena.eventTarget.dispatchEvent(
new WeatherChangedEvent( new WeatherChangedEvent(globalScene.arena.getWeatherType(), globalScene.arena.weather?.turnsLeft!),
WeatherType.NONE, );
globalScene.arena.weather?.weatherType!, }
globalScene.arena.weather?.turnsLeft!,
),
); // TODO: is this bang correct?
globalScene.arena.terrain = sessionData.arena.terrain; globalScene.arena.terrain = sessionData.arena.terrain;
if (globalScene.arena.getTerrainType() !== TerrainType.NONE) {
globalScene.arena.eventTarget.dispatchEvent( globalScene.arena.eventTarget.dispatchEvent(
new TerrainChangedEvent( new TerrainChangedEvent(globalScene.arena.getTerrainType(), globalScene.arena.terrain?.turnsLeft!),
TerrainType.NONE, );
globalScene.arena.terrain?.terrainType!, }
globalScene.arena.terrain?.turnsLeft!,
),
); // TODO: is this bang correct?
globalScene.arena.playerTerasUsed = sessionData.arena.playerTerasUsed; globalScene.arena.playerTerasUsed = sessionData.arena.playerTerasUsed;
globalScene.arena.tags = sessionData.arena.tags; globalScene.arena.tags = sessionData.arena.tags;
if (globalScene.arena.tags) {
for (const tag of globalScene.arena.tags) { for (const tag of globalScene.arena.tags) {
if (tag instanceof ArenaTrapTag) { if (tag instanceof ArenaTrapTag) {
const { tagType, side, turnCount, layers, maxLayers } = tag as ArenaTrapTag; const { tagType, side, turnCount, layers, maxLayers } = tag;
globalScene.arena.eventTarget.dispatchEvent( globalScene.arena.eventTarget.dispatchEvent(
new TagAddedEvent(tagType, side, turnCount, layers, maxLayers), new ArenaTagAddedEvent(tagType, side, turnCount, [layers, maxLayers]),
); );
} else { } else {
globalScene.arena.eventTarget.dispatchEvent(new TagAddedEvent(tag.tagType, tag.side, tag.turnCount)); globalScene.arena.eventTarget.dispatchEvent(new ArenaTagAddedEvent(tag.tagType, tag.side, tag.turnCount));
}
} }
} }

View File

@ -1,61 +1,68 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { ArenaTrapTag } from "#data/arena-tag"; // biome-ignore-start lint/correctness/noUnusedImports: TSDocs
import { TerrainType } from "#data/terrain"; import type { ArenaTag } from "#data/arena-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 { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { TextStyle } from "#enums/text-style"; import { TextStyle } from "#enums/text-style";
import { WeatherType } from "#enums/weather-type"; import { WeatherType } from "#enums/weather-type";
import type { ArenaEvent } from "#events/arena"; import type { ArenaTagAddedEvent, ArenaTagRemovedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#events/arena";
import {
ArenaEventType,
TagAddedEvent,
TagRemovedEvent,
TerrainChangedEvent,
WeatherChangedEvent,
} from "#events/arena";
import type { TurnEndEvent } from "#events/battle-scene";
import { BattleSceneEventType } from "#events/battle-scene"; import { BattleSceneEventType } from "#events/battle-scene";
import { addTextObject } from "#ui/text"; import { addTextObject } from "#ui/text";
import { TimeOfDayWidget } from "#ui/time-of-day-widget"; import { TimeOfDayWidget } from "#ui/time-of-day-widget";
import { addWindow, WindowVariant } from "#ui/ui-theme"; import { addWindow, WindowVariant } from "#ui/ui-theme";
import { fixedInt } from "#utils/common"; import { fixedInt } from "#utils/common";
import { toCamelCase, toTitleCase } from "#utils/strings"; import { toCamelCase } from "#utils/strings";
import type { ParseKeys } from "i18next";
import i18next from "i18next"; import i18next from "i18next";
/** Enum used to differentiate {@linkcode Arena} effects */ // #region Interfaces
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 */
effectType: ArenaEffectType;
/** 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; maxDuration: number;
/** The current duration left on the effect */ /** The current duration left on the weather. */
duration: number; 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; tagType?: ArenaTagType;
} }
export function getFieldEffectText(arenaTagType: string): string { // #endregion interfaces
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;
}
/**
* Class to display and update the on-screen arena flyout.
*/
export class ArenaFlyout extends Phaser.GameObjects.Container { export class ArenaFlyout extends Phaser.GameObjects.Container {
/** The restricted width of the flyout which should be drawn to */ /** The restricted width of the flyout which should be drawn to */
private flyoutWidth = 170; private flyoutWidth = 170;
@ -98,14 +105,20 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
/** The {@linkcode Phaser.GameObjects.Text} used to indicate field effects */ /** The {@linkcode Phaser.GameObjects.Text} used to indicate field effects */
private flyoutTextField: Phaser.GameObjects.Text; private flyoutTextField: Phaser.GameObjects.Text;
/** Container for all field effects observed by this object */ /** Holds info about the current active {@linkcode Weather}, if any are active. */
private readonly fieldEffectInfo: ArenaEffectInfo[] = []; 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 /** Container for all {@linkcode ArenaTag}s observed by this object. */
private readonly onNewArenaEvent = (event: Event) => this.onNewArena(event); private arenaTags: ArenaTagInfo[] = [];
private readonly onTurnEndEvent = (event: Event) => this.onTurnEnd(event);
private readonly onFieldEffectChangedEvent = (event: Event) => this.onFieldEffectChanged(event); 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);
constructor() { constructor() {
super(globalScene, 0, 0); super(globalScene, 0, 0);
@ -213,202 +226,143 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
this.name = "Fight Flyout"; this.name = "Fight Flyout";
this.flyoutParent.name = "Fight Flyout Parent"; 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.NEW_ARENA, this.onNewArenaEvent);
globalScene.eventTarget.addEventListener(BattleSceneEventType.TURN_END, this.onTurnEndEvent); 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 = [];
// Subscribes to required events available on battle start // Subscribe to required events available on battle start
globalScene.arena.eventTarget.addEventListener(ArenaEventType.WEATHER_CHANGED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.addEventListener(ArenaEventType.WEATHER_CHANGED, this.onWeatherChangedEvent);
globalScene.arena.eventTarget.addEventListener(ArenaEventType.TERRAIN_CHANGED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.addEventListener(ArenaEventType.TERRAIN_CHANGED, this.onTerrainChangedEvent);
globalScene.arena.eventTarget.addEventListener(ArenaEventType.TAG_ADDED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.addEventListener(ArenaEventType.ARENA_TAG_ADDED, this.onArenaTagAddedEvent);
globalScene.arena.eventTarget.addEventListener(ArenaEventType.TAG_REMOVED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.addEventListener(ArenaEventType.ARENA_TAG_REMOVED, this.onArenaTagRemovedEvent);
}
/** 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.effectType) {
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";
}
} }
/** /**
* Parses the {@linkcode Event} being passed and updates the state of the fieldEffectInfo array * Iterate through all currently present tags effects and decrement their durations.
* @param event {@linkcode Event} being sent
*/ */
private onFieldEffectChanged(event: Event) { private onTurnEnd() {
const arenaEffectChangedEvent = event as ArenaEvent; // Remove all objects with positive max durations and whose durations have expired.
if (!arenaEffectChangedEvent) { this.arenaTags = this.arenaTags.filter(info => info.maxDuration === 0 || --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 = this.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} 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 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 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; return;
} }
let foundIndex: number; this.weatherInfo = {
switch (arenaEffectChangedEvent.constructor) { name: this.localizeEffectName(WeatherType[event.weatherType]),
case TagAddedEvent: { maxDuration: event.duration,
const tagAddedEvent = arenaEffectChangedEvent as TagAddedEvent; duration: event.duration,
const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof ArenaTrapTag; weatherType: event.weatherType,
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.effectType,
)
: -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,
effectType: 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],
),
effectType:
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.updateFieldText(); this.updateFieldText();
} }
/** /**
* Iterates through the fieldEffectInfo array and decrements the duration of each item * Update the current terrain text when the terrain changes.
* @param event {@linkcode Event} being sent * @param event - The {@linkcode TerrainChangedEvent} having been emitted
*/ */
private onTurnEnd(event: Event) { private onTerrainChanged(event: TerrainChangedEvent) {
const turnEndEvent = event as TurnEndEvent; // If terrain was reset, clear the current data.
if (!turnEndEvent) { if (event.terrainType === TerrainType.NONE) {
this.terrainInfo = undefined;
this.updateFieldText();
return; return;
} }
const fieldEffectInfo: ArenaEffectInfo[] = []; this.terrainInfo = {
this.fieldEffectInfo.forEach(i => fieldEffectInfo.push(i)); name: this.localizeEffectName(TerrainType[event.terrainType]),
maxDuration: event.duration,
for (let i = 0; i < fieldEffectInfo.length; i++) { duration: event.duration,
const info = fieldEffectInfo[i]; terrainType: event.terrainType,
};
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.updateFieldText(); this.updateFieldText();
} }
// #endregion Weather/Terrain
/** /**
* Animates the flyout to either show or hide it by applying a fade and translation * Animate the flyout to either show or hide the modal.
* @param visible Should the flyout be shown? * @param visible - Whether the the flyout should be shown
*/ */
public toggleFlyout(visible: boolean): void { public toggleFlyout(visible: boolean): void {
globalScene.tweens.add({ globalScene.tweens.add({
@ -421,15 +375,96 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
}); });
} }
/** Destroy this element and remove all associated listeners. */
public destroy(fromScene?: boolean): void { public destroy(fromScene?: boolean): void {
globalScene.eventTarget.removeEventListener(BattleSceneEventType.NEW_ARENA, this.onNewArenaEvent); globalScene.eventTarget.removeEventListener(BattleSceneEventType.NEW_ARENA, this.onNewArenaEvent);
globalScene.eventTarget.removeEventListener(BattleSceneEventType.TURN_END, this.onTurnEndEvent); globalScene.eventTarget.removeEventListener(BattleSceneEventType.TURN_END, this.onTurnEndEvent);
globalScene.arena.eventTarget.removeEventListener(ArenaEventType.WEATHER_CHANGED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.removeEventListener(ArenaEventType.WEATHER_CHANGED, this.onWeatherChanged);
globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TERRAIN_CHANGED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TERRAIN_CHANGED, this.onTerrainChanged);
globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TAG_ADDED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.removeEventListener(ArenaEventType.ARENA_TAG_ADDED, this.onArenaTagAddedEvent);
globalScene.arena.eventTarget.removeEventListener(ArenaEventType.TAG_REMOVED, this.onFieldEffectChangedEvent); globalScene.arena.eventTarget.removeEventListener(ArenaEventType.ARENA_TAG_REMOVED, this.onArenaTagRemovedEvent);
super.destroy(fromScene); 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.flyoutTextField.text += this.getTagText(this.weatherInfo);
}
if (this.terrainInfo) {
this.flyoutTextField.text += this.getTagText(this.terrainInfo);
}
// Sort and update all arena tag text
this.arenaTags.sort((infoA, infoB) => infoA.duration - infoB.duration);
for (const tag of this.arenaTags) {
this.getArenaTagTargetObj(tag.side).text += this.getTagText(tag);
}
}
/**
* 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 getTagText(info: ArenaTagInfo | WeatherInfo | TerrainInfo): string {
let text = info.name;
if (info.maxDuration > 0) {
text += ` ${info.duration}/${info.maxDuration}`;
}
text += "\n";
return text;
}
/**
* 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;
}
}
// # 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;
}
// #endregion Utility emthods
} }

View File

@ -77,15 +77,13 @@ describe("Moves - Heal Block", () => {
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toNextTurn(); await game.toNextTurn();
expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) // expect(game).toHavePositionalTag(PositionalTagType.WISH);
.toHaveLength(1);
game.move.use(MoveId.SPLASH); game.move.use(MoveId.SPLASH);
await game.toNextTurn(); await game.toNextTurn();
// wish triggered, but did NOT heal the player // wish triggered, but did NOT heal the player
expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) // expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
.toHaveLength(0);
expect(player.hp).toBe(1); expect(player.hp).toBe(1);
}); });

View File

@ -1,4 +1,6 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc // 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"; import type { GameManager } from "#test/test-utils/game-manager";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc // biome-ignore-end lint/correctness/noUnusedImports: TSDoc
@ -17,8 +19,8 @@ export type toHavePositionalTagOptions<P extends PositionalTagType> = OneOther<s
/** /**
* Matcher to check if the {@linkcode Arena} has a certain number of {@linkcode PositionalTag}s active. * Matcher to check if the {@linkcode Arena} has a certain number of {@linkcode PositionalTag}s active.
* @param received - The object to check. Should be the current {@linkcode GameManager} * @param received - The object to check. Should be the current {@linkcode GameManager}
* @param expectedTag - The {@linkcode PositionalTagType} of the desired tag, or a partially-filled {@linkcode PositionalTag} * @param expectedTag - The {@linkcode PositionalTagType} of the desired tag, or a partially-filled
* containing the desired properties * {@linkcode PositionalTag} containing the desired properties
* @param count - The number of tags that should be active; defaults to `1` and must be within the range `[0, 4]` * @param count - The number of tags that should be active; defaults to `1` and must be within the range `[0, 4]`
* @returns The result of the matching * @returns The result of the matching
*/ */

View File

@ -1,15 +1,14 @@
import { Pokemon } from "#field/pokemon"; import { Pokemon } from "#field/pokemon";
import type { GameManager } from "#test/test-utils/game-manager"; import type { GameManager } from "#test/test-utils/game-manager";
import i18next, { type ParseKeys } from "i18next"; import i18next, { type ParseKeys } from "i18next";
import { vi } from "vitest"; import { type MockInstance, vi } from "vitest";
/** /**
* Sets up the i18next mock. * Mock i18next's {@linkcode t} function to only produce the raw key.
* Includes a i18next.t mocked implementation only returning the raw key (`(key) => 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); return vi.spyOn(i18next, "t").mockImplementation((key: ParseKeys) => key);
} }

View File

@ -1,25 +1,24 @@
import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag";
import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag"; import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag";
import type { PositionalTagType } from "#enums/positional-tag-type"; 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"; import { describe, expectTypeOf, it } from "vitest";
// Needed to get around properties being readonly in certain classes
type NonFunctionMutable<T> = Mutable<NonFunctionPropertiesRecursive<T>>;
describe("serializedPositionalTagMap", () => { describe("serializedPositionalTagMap", () => {
it("should contain representations of each tag's serialized form", () => { it("should contain representations of each tag's serialized form", () => {
expectTypeOf<serializedPosTagMap[PositionalTagType.DELAYED_ATTACK]>().branded.toEqualTypeOf< expectTypeOf<serializedPosTagMap[PositionalTagType.DELAYED_ATTACK]>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> NonFunctionPropertiesRecursive<DelayedAttackTag>
>();
expectTypeOf<serializedPosTagMap[PositionalTagType.WISH]>().branded.toEqualTypeOf<
NonFunctionPropertiesRecursive<WishTag>
>(); >();
expectTypeOf<serializedPosTagMap[PositionalTagType.WISH]>().branded.toEqualTypeOf<NonFunctionMutable<WishTag>>();
}); });
}); });
describe("SerializedPositionalTag", () => { describe("SerializedPositionalTag", () => {
it("should accept a union of all serialized tag forms", () => { it("should accept a union of all serialized tag forms", () => {
expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf< expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> | NonFunctionMutable<WishTag> NonFunctionPropertiesRecursive<DelayedAttackTag> | NonFunctionPropertiesRecursive<WishTag>
>(); >();
}); });
it("should accept a union of all unserialized tag forms", () => { it("should accept a union of all unserialized tag forms", () => {

View File

@ -0,0 +1,67 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
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 } 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 infoType = Parameters<(typeof flyout)["getTagText"]>[0];
describe("getTagText", () => {
it.each<{ info: Pick<infoType, "name" | "duration" | "maxDuration">; 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);
});
});
});