From 22442d3aa066f3ebb6700193c7b9d930cb08712c Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Fri, 4 Oct 2024 07:50:03 -0700 Subject: [PATCH] [Refactor] Refactor move phase and add documentation (#3974) * Refactor `MovePhase` to improve readability/maintainability Add tsdocs/comments all over Mark all functions/fields with public/etc Fix multi-hit moves called from Metronome/etc, fixes #3914 Remove unused function `BattleScene.pushMovePhase` Don't use failure text as a condition for move success A move defining potential failure text doesn't mean it failed Replace relative imports with absolute imports in `battle-scene.ts` Change some fields from optional to default `false` * Fix Whirlwind test * Fix linting --- src/battle-scene.ts | 149 ++++----- src/field/arena.ts | 12 +- src/field/pokemon.ts | 10 +- src/phases/move-effect-phase.ts | 2 +- src/phases/move-phase.ts | 553 +++++++++++++++++++------------ src/test/moves/whirlwind.test.ts | 10 +- 6 files changed, 431 insertions(+), 305 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 7f17a666280..cc6934f20d1 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1,57 +1,57 @@ import Phaser from "phaser"; -import UI from "./ui/ui"; -import Pokemon, { EnemyPokemon, PlayerPokemon } from "./field/pokemon"; -import PokemonSpecies, { allSpecies, getPokemonSpecies, PokemonSpeciesFilter } from "./data/pokemon-species"; +import UI from "#app/ui/ui"; +import Pokemon, { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import PokemonSpecies, { allSpecies, getPokemonSpecies, PokemonSpeciesFilter } from "#app/data/pokemon-species"; import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils"; -import * as Utils from "./utils"; +import * as Utils from "#app/utils"; import { ConsumableModifier, ConsumablePokemonModifier, DoubleBattleChanceBoosterModifier, ExpBalanceModifier, ExpShareModifier, FusePokemonModifier, HealingBoosterModifier, Modifier, ModifierBar, ModifierPredicate, MultipleParticipantExpBonusModifier, overrideHeldItems, overrideModifiers, PersistentModifier, PokemonExpBoosterModifier, PokemonFormChangeItemModifier, PokemonHeldItemModifier, PokemonHpRestoreModifier, PokemonIncrementingStatModifier, TerastallizeModifier, TurnHeldItemTransferModifier } from "./modifier/modifier"; -import { PokeballType } from "./data/pokeball"; -import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets, populateAnims } from "./data/battle-anims"; -import { Phase } from "./phase"; -import { initGameSpeed } from "./system/game-speed"; -import { Arena, ArenaBase } from "./field/arena"; -import { GameData } from "./system/game-data"; -import { addTextObject, getTextColor, TextStyle } from "./ui/text"; -import { allMoves } from "./data/move"; -import { getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, ModifierPoolType, modifierTypes, PokemonHeldItemModifierType } from "./modifier/modifier-type"; -import AbilityBar from "./ui/ability-bar"; -import { allAbilities, applyAbAttrs, applyPostBattleInitAbAttrs, BlockItemTheftAbAttr, ChangeMovePriorityAbAttr, DoubleBattleChanceAbAttr, PostBattleInitAbAttr } from "./data/ability"; -import Battle, { BattleType, FixedBattleConfig } from "./battle"; -import { GameMode, GameModes, getGameMode } from "./game-mode"; -import FieldSpritePipeline from "./pipelines/field-sprite"; -import SpritePipeline from "./pipelines/sprite"; -import PartyExpBar from "./ui/party-exp-bar"; -import { trainerConfigs, TrainerSlot } from "./data/trainer-config"; -import Trainer, { TrainerVariant } from "./field/trainer"; -import TrainerData from "./system/trainer-data"; +import { PokeballType } from "#app/data/pokeball"; +import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets, populateAnims } from "#app/data/battle-anims"; +import { Phase } from "#app/phase"; +import { initGameSpeed } from "#app/system/game-speed"; +import { Arena, ArenaBase } from "#app/field/arena"; +import { GameData } from "#app/system/game-data"; +import { addTextObject, getTextColor, TextStyle } from "#app/ui/text"; +import { allMoves } from "#app/data/move"; +import { getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, ModifierPoolType, modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import AbilityBar from "#app/ui/ability-bar"; +import { allAbilities, applyAbAttrs, applyPostBattleInitAbAttrs, BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, PostBattleInitAbAttr } from "#app/data/ability"; +import Battle, { BattleType, FixedBattleConfig } from "#app/battle"; +import { GameMode, GameModes, getGameMode } from "#app/game-mode"; +import FieldSpritePipeline from "#app/pipelines/field-sprite"; +import SpritePipeline from "#app/pipelines/sprite"; +import PartyExpBar from "#app/ui/party-exp-bar"; +import { trainerConfigs, TrainerSlot } from "#app/data/trainer-config"; +import Trainer, { TrainerVariant } from "#app/field/trainer"; +import TrainerData from "#app/system/trainer-data"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; -import { pokemonPrevolutions } from "./data/balance/pokemon-evolutions"; -import PokeballTray from "./ui/pokeball-tray"; -import InvertPostFX from "./pipelines/invert"; -import { Achv, achvs, ModifierAchv, MoneyAchv } from "./system/achv"; -import { Voucher, vouchers } from "./system/voucher"; -import { Gender } from "./data/gender"; +import { pokemonPrevolutions } from "#app/data/balance/pokemon-evolutions"; +import PokeballTray from "#app/ui/pokeball-tray"; +import InvertPostFX from "#app/pipelines/invert"; +import { Achv, achvs, ModifierAchv, MoneyAchv } from "#app/system/achv"; +import { Voucher, vouchers } from "#app/system/voucher"; +import { Gender } from "#app/data/gender"; import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-plugin"; -import { addUiThemeOverrides } from "./ui/ui-theme"; -import PokemonData from "./system/pokemon-data"; -import { Nature } from "./data/nature"; -import { FormChangeItem, pokemonFormChanges, SpeciesFormChange, SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger, SpeciesFormChangeTrigger } from "./data/pokemon-forms"; -import { FormChangePhase } from "./phases/form-change-phase"; -import { getTypeRgb } from "./data/type"; -import PokemonSpriteSparkleHandler from "./field/pokemon-sprite-sparkle-handler"; -import CharSprite from "./ui/char-sprite"; -import DamageNumberHandler from "./field/damage-number-handler"; -import PokemonInfoContainer from "./ui/pokemon-info-container"; -import { biomeDepths, getBiomeName } from "./data/balance/biomes"; -import { SceneBase } from "./scene-base"; -import CandyBar from "./ui/candy-bar"; -import { Variant, variantData } from "./data/variant"; +import { addUiThemeOverrides } from "#app/ui/ui-theme"; +import PokemonData from "#app/system/pokemon-data"; +import { Nature } from "#app/data/nature"; +import { FormChangeItem, pokemonFormChanges, SpeciesFormChange, SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger, SpeciesFormChangeTrigger } from "#app/data/pokemon-forms"; +import { FormChangePhase } from "#app/phases/form-change-phase"; +import { getTypeRgb } from "#app/data/type"; +import PokemonSpriteSparkleHandler from "#app/field/pokemon-sprite-sparkle-handler"; +import CharSprite from "#app/ui/char-sprite"; +import DamageNumberHandler from "#app/field/damage-number-handler"; +import PokemonInfoContainer from "#app/ui/pokemon-info-container"; +import { biomeDepths, getBiomeName } from "#app/data/balance/biomes"; +import { SceneBase } from "#app/scene-base"; +import CandyBar from "#app/ui/candy-bar"; +import { Variant, variantData } from "#app/data/variant"; import { Localizable } from "#app/interfaces/locales"; import Overrides from "#app/overrides"; -import { InputsController } from "./inputs-controller"; -import { UiInputs } from "./ui-inputs"; -import { NewArenaEvent } from "./events/battle-scene"; -import { ArenaFlyout } from "./ui/arena-flyout"; +import { InputsController } from "#app/inputs-controller"; +import { UiInputs } from "#app/ui-inputs"; +import { NewArenaEvent } from "#app/events/battle-scene"; +import { ArenaFlyout } from "#app/ui/arena-flyout"; import { EaseType } from "#enums/ease-type"; import { BattleSpec } from "#enums/battle-spec"; import { BattleStyle } from "#enums/battle-style"; @@ -66,27 +66,27 @@ import { TimedEventManager } from "#app/timed-event-manager"; import { PokemonAnimType } from "#enums/pokemon-anim-type"; import i18next from "i18next"; import { TrainerType } from "#enums/trainer-type"; -import { battleSpecDialogue } from "./data/dialogue"; -import { LoadingScene } from "./loading-scene"; -import { LevelCapPhase } from "./phases/level-cap-phase"; -import { LoginPhase } from "./phases/login-phase"; -import { MessagePhase } from "./phases/message-phase"; -import { MovePhase } from "./phases/move-phase"; -import { NewBiomeEncounterPhase } from "./phases/new-biome-encounter-phase"; -import { NextEncounterPhase } from "./phases/next-encounter-phase"; -import { PokemonAnimPhase } from "./phases/pokemon-anim-phase"; -import { QuietFormChangePhase } from "./phases/quiet-form-change-phase"; -import { ReturnPhase } from "./phases/return-phase"; -import { SelectBiomePhase } from "./phases/select-biome-phase"; -import { ShowTrainerPhase } from "./phases/show-trainer-phase"; -import { SummonPhase } from "./phases/summon-phase"; -import { SwitchPhase } from "./phases/switch-phase"; -import { TitlePhase } from "./phases/title-phase"; -import { ToggleDoublePositionPhase } from "./phases/toggle-double-position-phase"; -import { TurnInitPhase } from "./phases/turn-init-phase"; -import { ShopCursorTarget } from "./enums/shop-cursor-target"; -import MysteryEncounter from "./data/mystery-encounters/mystery-encounter"; -import { allMysteryEncounters, ANTI_VARIANCE_WEIGHT_MODIFIER, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, mysteryEncountersByBiome, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "./data/mystery-encounters/mystery-encounters"; +import { battleSpecDialogue } from "#app/data/dialogue"; +import { LoadingScene } from "#app/loading-scene"; +import { LevelCapPhase } from "#app/phases/level-cap-phase"; +import { LoginPhase } from "#app/phases/login-phase"; +import { MessagePhase } from "#app/phases/message-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { NewBiomeEncounterPhase } from "#app/phases/new-biome-encounter-phase"; +import { NextEncounterPhase } from "#app/phases/next-encounter-phase"; +import { PokemonAnimPhase } from "#app/phases/pokemon-anim-phase"; +import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase"; +import { ReturnPhase } from "#app/phases/return-phase"; +import { SelectBiomePhase } from "#app/phases/select-biome-phase"; +import { ShowTrainerPhase } from "#app/phases/show-trainer-phase"; +import { SummonPhase } from "#app/phases/summon-phase"; +import { SwitchPhase } from "#app/phases/switch-phase"; +import { TitlePhase } from "#app/phases/title-phase"; +import { ToggleDoublePositionPhase } from "#app/phases/toggle-double-position-phase"; +import { TurnInitPhase } from "#app/phases/turn-init-phase"; +import { ShopCursorTarget } from "#app/enums/shop-cursor-target"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { allMysteryEncounters, ANTI_VARIANCE_WEIGHT_MODIFIER, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, mysteryEncountersByBiome, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "#app/data/mystery-encounters/mystery-encounters"; import { MysteryEncounterSaveData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -94,7 +94,7 @@ import HeldModifierConfig from "#app/interfaces/held-modifier-config"; import { ExpPhase } from "#app/phases/exp-phase"; import { ShowPartyExpBarPhase } from "#app/phases/show-party-exp-bar-phase"; import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; -import { ExpGainsSpeed } from "./enums/exp-gains-speed"; +import { ExpGainsSpeed } from "#enums/exp-gains-speed"; export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1"; @@ -2359,17 +2359,6 @@ export default class BattleScene extends SceneBase { return false; } - pushMovePhase(movePhase: MovePhase, priorityOverride?: integer): void { - const movePriority = new Utils.IntegerHolder(priorityOverride !== undefined ? priorityOverride : movePhase.move.getMove().priority); - applyAbAttrs(ChangeMovePriorityAbAttr, movePhase.pokemon, null, false, movePhase.move.getMove(), movePriority); - const lowerPriorityPhase = this.phaseQueue.find(p => p instanceof MovePhase && p.move.getMove().priority < movePriority.value); - if (lowerPriorityPhase) { - this.phaseQueue.splice(this.phaseQueue.indexOf(lowerPriorityPhase), 0, movePhase); - } else { - this.pushPhase(movePhase); - } - } - /** * Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase() * @param phase {@linkcode Phase} the phase to be added diff --git a/src/field/arena.ts b/src/field/arena.ts index 9d5f1eb0a4e..1e164903e9d 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -392,16 +392,16 @@ export class Arena { return true; } - isMoveWeatherCancelled(user: Pokemon, move: Move) { - return this.weather && !this.weather.isEffectSuppressed(this.scene) && this.weather.isMoveWeatherCancelled(user, move); + public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean { + return !!this.weather && !this.weather.isEffectSuppressed(this.scene) && this.weather.isMoveWeatherCancelled(user, move); } - isMoveTerrainCancelled(user: Pokemon, targets: BattlerIndex[], move: Move) { - return this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move); + public isMoveTerrainCancelled(user: Pokemon, targets: BattlerIndex[], move: Move): boolean { + return !!this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move); } - getTerrainType() : TerrainType { - return this.terrain?.terrainType || TerrainType.NONE; + public getTerrainType(): TerrainType { + return this.terrain?.terrainType ?? TerrainType.NONE; } getAttackTypeMultiplier(attackType: Type, grounded: boolean): number { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index fed91d05fd5..05567491a1a 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5027,8 +5027,12 @@ export class PokemonBattleSummonData { export class PokemonTurnData { public flinched: boolean = false; public acted: boolean = false; - public hitCount: number; - public hitsLeft: number; + public hitCount: number = 0; + /** + * - `-1` = Calculate how many hits are left + * - `0` = Move is finished + */ + public hitsLeft: number = -1; public damageDealt: number = 0; public currDamageDealt: number = 0; public damageTaken: number = 0; @@ -5114,7 +5118,7 @@ export class PokemonMove { * @param {boolean} ignoreRestrictionTags If `true`, skips the check for move restriction tags (see {@link MoveRestrictionBattlerTag}) * @returns `true` if the move can be selected and used by the Pokemon, otherwise `false`. */ - isUsable(pokemon: Pokemon, ignorePp?: boolean, ignoreRestrictionTags?: boolean): boolean { + isUsable(pokemon: Pokemon, ignorePp: boolean = false, ignoreRestrictionTags: boolean = false): boolean { if (this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId)) { return false; } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 93466babb77..b2d429a4313 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -70,7 +70,7 @@ export class MoveEffectPhase extends PokemonPhase { * resolve the move's total hit count. This block combines the * effects of the move itself, Parental Bond, and Multi-Lens to do so. */ - if (user.turnData.hitsLeft === undefined) { + if (user.turnData.hitsLeft === -1) { const hitCount = new Utils.IntegerHolder(1); // Assume single target for multi hit applyMoveAttrs(MultiHitAttr, user, this.getTarget() ?? null, move, hitCount); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 154fbbe410d..807f194bad5 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,5 +1,5 @@ -import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; +import BattleScene from "#app/battle-scene"; import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr } from "#app/data/ability"; import { CommonAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags"; @@ -15,236 +15,149 @@ import { StatusEffect } from "#app/enums/status-effect"; import { MoveUsedEvent } from "#app/events/battle-scene"; import Pokemon, { MoveResult, PokemonMove, TurnMove } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; +import { BattlePhase } from "#app/phases/battle-phase"; +import { CommonAnimPhase } from "#app/phases/common-anim-phase"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; +import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import * as Utils from "#app/utils"; import i18next from "i18next"; -import { BattlePhase } from "./battle-phase"; -import { CommonAnimPhase } from "./common-anim-phase"; -import { MoveEffectPhase } from "./move-effect-phase"; -import { MoveEndPhase } from "./move-end-phase"; -import { ShowAbilityPhase } from "./show-ability-phase"; export class MovePhase extends BattlePhase { - public pokemon: Pokemon; - public move: PokemonMove; - public targets: BattlerIndex[]; + protected _pokemon: Pokemon; + protected _move: PokemonMove; + protected _targets: BattlerIndex[]; protected followUp: boolean; protected ignorePp: boolean; - protected failed: boolean; - protected cancelled: boolean; + protected failed: boolean = false; + protected cancelled: boolean = false; - constructor(scene: BattleScene, pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp?: boolean, ignorePp?: boolean) { + public get pokemon(): Pokemon { + return this._pokemon; + } + + protected set pokemon(pokemon: Pokemon) { + this._pokemon = pokemon; + } + + public get move(): PokemonMove { + return this._move; + } + + protected set move(move: PokemonMove) { + this._move = move; + } + + public get targets(): BattlerIndex[] { + return this._targets; + } + + protected set targets(targets: BattlerIndex[]) { + this._targets = targets; + } + + /** + * @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer. + * Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc. + */ + constructor(scene: BattleScene, pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) { super(scene); this.pokemon = pokemon; this.targets = targets; this.move = move; - this.followUp = followUp ?? false; - this.ignorePp = ignorePp ?? false; - this.failed = false; - this.cancelled = false; + this.followUp = followUp; + this.ignorePp = ignorePp; } - canMove(ignoreDisableTags?: boolean): boolean { + /** + * Checks if the pokemon is active, if the move is usable, and that the move is targetting something. + * @param ignoreDisableTags `true` to not check if the move is disabled + * @returns `true` if all the checks pass + */ + public canMove(ignoreDisableTags: boolean = false): boolean { return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon, this.ignorePp, ignoreDisableTags) && !!this.targets.length; } /**Signifies the current move should fail but still use PP */ - fail(): void { + public fail(): void { this.failed = true; } /**Signifies the current move should cancel and retain PP */ - cancel(): void { + public cancel(): void { this.cancelled = true; } - start() { + public start() { super.start(); console.log(Moves[this.move.moveId]); + // Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite). if (!this.canMove(true)) { - if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) { // if the move PP was reduced from Spite or otherwise, the move fails + if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) { this.fail(); this.showMoveText(); this.showFailedText(); } + return this.end(); } + this.pokemon.turnData.acted = true; + + // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) + if (this.followUp) { + this.pokemon.turnData.hitsLeft = -1; + this.pokemon.turnData.hitCount = 0; + } + + // Check move to see if arena.ignoreAbilities should be true. if (!this.followUp) { if (this.move.getMove().checkFlag(MoveFlags.IGNORE_ABILITIES, this.pokemon, null)) { this.scene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex()); } + } + + this.resolveRedirectTarget(); + + this.resolveCounterAttackTarget(); + + this.resolvePreMoveStatusEffects(); + + this.lapsePreMoveAndMoveTags(); + + this.resolveFinalPreMoveCancellationChecks(); + + if (this.cancelled || this.failed) { + this.handlePreMoveFailures(); } else { - this.pokemon.turnData.hitsLeft = 0; // TODO: is `0` correct? - this.pokemon.turnData.hitCount = 0; // TODO: is `0` correct? + this.useMove(); } - // Move redirection abilities (ie. Storm Drain) only support single target moves - const moveTarget = this.targets.length === 1 - ? new Utils.IntegerHolder(this.targets[0]) - : null; - if (moveTarget) { - const oldTarget = moveTarget.value; - this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, false, this.move.moveId, moveTarget)); - this.pokemon.getOpponents().forEach(p => { - const redirectTag = p.getTag(CenterOfAttentionTag) as CenterOfAttentionTag; - if (redirectTag && (!redirectTag.powder || (!this.pokemon.isOfType(Type.GRASS) && !this.pokemon.hasAbility(Abilities.OVERCOAT)))) { - moveTarget.value = p.getBattlerIndex(); - } - }); - //Check if this move is immune to being redirected, and restore its target to the intended target if it is. - if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) || this.move.getMove().hasAttr(BypassRedirectAttr))) { - //If an ability prevented this move from being redirected, display its ability pop up. - if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) && !this.move.getMove().hasAttr(BypassRedirectAttr)) && oldTarget !== moveTarget.value) { - this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); - } - moveTarget.value = oldTarget; - } - this.targets[0] = moveTarget.value; + this.end(); + } + + /** Check for cancellation edge cases - no targets remaining, or {@linkcode Moves.NONE} is in the queue */ + protected resolveFinalPreMoveCancellationChecks() { + const targets = this.getActiveTargetPokemon(); + const moveQueue = this.pokemon.getMoveQueue(); + + if (targets.length === 0 || (moveQueue.length && moveQueue[0].move === Moves.NONE)) { + this.showFailedText(); + this.cancelled = true; } + } - // Check for counterattack moves to switch target - if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) { - if (this.pokemon.turnData.attacksReceived.length) { - const attack = this.pokemon.turnData.attacksReceived[0]; - this.targets[0] = attack.sourceBattlerIndex; - - // account for metal burst and comeuppance hitting remaining targets in double battles - // counterattack will redirect to remaining ally if original attacker faints - if (this.scene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) { - if (this.scene.getField()[this.targets[0]].hp === 0) { - const opposingField = this.pokemon.isPlayer() ? this.scene.getEnemyField() : this.scene.getPlayerField(); - //@ts-ignore - this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex(); //TODO: fix ts-ignore - } - } - } - if (this.targets[0] === BattlerIndex.ATTACKER) { - this.fail(); // Marks the move as failed for later in doMove - this.showMoveText(); - this.showFailedText(); - } - } - - const targets = this.scene.getField(true).filter(p => { - if (this.targets.indexOf(p.getBattlerIndex()) > -1) { - return true; - } - return false; - }); - - const doMove = () => { - this.pokemon.turnData.acted = true; // Record that the move was attempted, even if it fails - - this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); - - let ppUsed = 1; - // Filter all opponents to include only those this move is targeting - const targetedOpponents = this.pokemon.getOpponents().filter(o => this.targets.includes(o.getBattlerIndex())); - for (const opponent of targetedOpponents) { - if (this.move.ppUsed + ppUsed >= this.move.getMovePp()) { // If we're already at max PP usage, stop checking - break; - } - if (opponent.hasAbilityWithAttr(IncreasePpAbAttr)) { // Accounting for abilities like Pressure - ppUsed++; - } - } - - if (!this.followUp && this.canMove() && !this.cancelled) { - this.pokemon.lapseTags(BattlerTagLapseType.MOVE); - } - - const moveQueue = this.pokemon.getMoveQueue(); - if (this.cancelled || this.failed) { - if (this.failed) { - this.move.usePp(ppUsed); // Only use PP if the move failed - this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed)); - } - - // Record a failed move so Abilities like Truant don't trigger next turn and soft-lock - this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); - - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc. - this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); - moveQueue.shift(); // Remove the second turn of charge moves - return this.end(); - } - - this.scene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - - if (this.move.moveId) { - this.showMoveText(); - } - - // This should only happen when there are no valid targets left on the field - if ((moveQueue.length && moveQueue[0].move === Moves.NONE) || !targets.length) { - this.showFailedText(); - this.cancel(); - - // Record a failed move so Abilities like Truant don't trigger next turn and soft-lock - this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); - - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc. - this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); - - moveQueue.shift(); - return this.end(); - } - - if ((!moveQueue.length || !moveQueue.shift()?.ignorePP) && !this.ignorePp) { // using .shift here clears out two turn moves once they've been used - this.move.usePp(ppUsed); - this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed)); - } - - if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) { - this.scene.currentBattle.lastMove = this.move.moveId; - } - - // Assume conditions affecting targets only apply to moves with a single target - let success = this.move.getMove().applyConditions(this.pokemon, targets[0], this.move.getMove()); - const cancelled = new Utils.BooleanHolder(false); - let failedText = this.move.getMove().getFailedText(this.pokemon, targets[0], this.move.getMove(), cancelled); - if (success && this.scene.arena.isMoveWeatherCancelled(this.pokemon, this.move.getMove())) { - success = false; - } else if (success && this.scene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, this.move.getMove())) { - success = false; - if (failedText === null) { - failedText = getTerrainBlockMessage(targets[0], this.scene.arena.terrain?.terrainType!); // TODO: is this bang correct? - } - } - - /** - * Trigger pokemon type change before playing the move animation - * Will still change the user's type when using Roar, Whirlwind, Trick-or-Treat, and Forest's Curse, - * regardless of whether the move successfully executes or not. - */ - if (success || [ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) { - applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); - } - - if (success) { - this.scene.unshiftPhase(this.getEffectPhase()); - } else { - this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); - if (!cancelled.value) { - this.showFailedText(failedText); - } - } - // Checks if Dancer ability is triggered - if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !this.followUp) { - // Pokemon with Dancer can be on either side of the battle so we check in both cases - this.scene.getPlayerField().forEach(pokemon => { - applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets); - }); - this.scene.getEnemyField().forEach(pokemon => { - applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets); - }); - } - this.end(); - }; + public getActiveTargetPokemon() { + return this.scene.getField(true).filter(p => this.targets.includes(p.getBattlerIndex())); + } + /** + * Handles {@link StatusEffect.SLEEP Sleep}/{@link StatusEffect.PARALYSIS Paralysis}/{@link StatusEffect.FREEZE Freeze} rolls and side effects. + */ + protected resolvePreMoveStatusEffects() { if (!this.followUp && this.pokemon.status && !this.pokemon.status.isPostTurn()) { this.pokemon.status.incrementTurn(); let activated = false; @@ -273,25 +186,257 @@ export class MovePhase extends BattlePhase { if (activated) { this.scene.queueMessage(getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon))); this.scene.unshiftPhase(new CommonAnimPhase(this.scene, this.pokemon.getBattlerIndex(), undefined, CommonAnim.POISON + (this.pokemon.status.effect - 1))); - doMove(); - } else { - if (healed) { - this.scene.queueMessage(getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon))); - this.pokemon.resetStatus(); - this.pokemon.updateInfo(); - } - doMove(); + } else if (healed) { + this.scene.queueMessage(getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon))); + this.pokemon.resetStatus(); + this.pokemon.updateInfo(); } - } else { - doMove(); } } - getEffectPhase(): MoveEffectPhase { - return new MoveEffectPhase(this.scene, this.pokemon.getBattlerIndex(), this.targets, this.move); + /** + * Lapse {@linkcode BattlerTagLapseType.PRE_MOVE PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed. + * Also lapse {@linkcode BattlerTagLapseType.MOVE MOVE} tags if the move should be successful. + */ + protected lapsePreMoveAndMoveTags() { + this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); + + // TODO: does this intentionally happen before the no targets/Moves.NONE on queue cancellation case is checked? + if (!this.followUp && this.canMove() && !this.cancelled) { + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + } } - showMoveText(): void { + protected useMove() { + const targets = this.getActiveTargetPokemon(); + const moveQueue = this.pokemon.getMoveQueue(); + + // form changes happen even before we know that the move wll execute. + this.scene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); + + this.showMoveText(); + + // TODO: Clean up implementation of two-turn moves. + if (moveQueue.length > 0) { // Using .shift here clears out two turn moves once they've been used + this.ignorePp = moveQueue.shift()?.ignorePP ?? false; + } + + // "commit" to using the move, deducting PP. + if (!this.ignorePp) { + const ppUsed = 1 + this.getPpIncreaseFromPressure(targets); + + this.move.usePp(ppUsed); + this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); + } + + // Update the battle's "last move" pointer, unless we're currently mimicking a move. + if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) { + this.scene.currentBattle.lastMove = this.move.moveId; + } + + /** + * Determine if the move is successful (meaning that its damage/effects can be attempted) + * by checking that all of the following are true: + * - Conditional attributes of the move are all met + * - The target's `ForceSwitchOutImmunityAbAttr` is not triggered (see {@linkcode Move.prototype.applyConditions}) + * - Weather does not block the move + * - Terrain does not block the move + * + * TODO: These steps are straightforward, but the implementation below is extremely convoluted. + */ + + const move = this.move.getMove(); + + /** + * Move conditions assume the move has a single target + * TODO: is this sustainable? + */ + const passesConditions = move.applyConditions(this.pokemon, targets[0], move); + const failedDueToWeather: boolean = this.scene.arena.isMoveWeatherCancelled(this.pokemon, move); + const failedDueToTerrain: boolean = this.scene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); + + const success = passesConditions && !failedDueToWeather && !failedDueToTerrain; + + /** + * If the move has not failed, trigger ability-based user type changes and then execute it. + * + * Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger these type changes even + * if the move fails. + */ + if (success) { + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + this.scene.unshiftPhase(new MoveEffectPhase(this.scene, this.pokemon.getBattlerIndex(), this.targets, this.move)); + + } else { + if ([ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) { + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + } + + this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); + + let failedText: string | undefined; + const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new Utils.BooleanHolder(false)); + + if (failureMessage) { + failedText = failureMessage; + } else if (failedDueToTerrain) { + failedText = getTerrainBlockMessage(this.pokemon, this.scene.arena.getTerrainType()); + } + + this.showFailedText(failedText); + } + + // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). + // Note that the `!this.followUp` check here prevents an infinite Dancer loop. + if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !this.followUp) { + this.scene.getField(true).forEach(pokemon => { + applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets); + }); + } + } + + /** + * Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`, + * then ends the phase. + */ + public end() { + if (!this.followUp && this.canMove()) { + this.scene.unshiftPhase(new MoveEndPhase(this.scene, this.pokemon.getBattlerIndex())); + } + + super.end(); + } + + /** + * Applies PP increasing abilities (currently only {@link Abilities.PRESSURE Pressure}) if they exist on the target pokemon. + * Note that targets must include only active pokemon. + * + * TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability. + */ + public getPpIncreaseFromPressure(targets: Pokemon[]) { + const foesWithPressure = this.pokemon.getOpponents().filter(o => targets.includes(o) && o.isActive(true) && o.hasAbilityWithAttr(IncreasePpAbAttr)); + return foesWithPressure.length; + } + + /** + * Modifies `this.targets` in place, based upon: + * - Move redirection abilities, effects, etc. + * - Counterattacks, which pass a special value into the `targets` constructor param (`[`{@linkcode BattlerIndex.ATTACKER}`]`). + */ + protected resolveRedirectTarget() { + if (this.targets.length === 1) { + const currentTarget = this.targets[0]; + const redirectTarget = new Utils.NumberHolder(currentTarget); + + // check move redirection abilities of every pokemon *except* the user. + this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, false, this.move.moveId, redirectTarget)); + + // check for center-of-attention tags (note that this will override redirect abilities) + this.pokemon.getOpponents().forEach(p => { + const redirectTag = p.getTag(CenterOfAttentionTag) as CenterOfAttentionTag; + + // TODO: don't hardcode this interaction. + // Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect) + if (redirectTag && (!redirectTag.powder || (!this.pokemon.isOfType(Type.GRASS) && !this.pokemon.hasAbility(Abilities.OVERCOAT)))) { + redirectTarget.value = p.getBattlerIndex(); + } + }); + + if (currentTarget !== redirectTarget.value) { + if (this.move.getMove().hasAttr(BypassRedirectAttr)) { + redirectTarget.value = currentTarget; + + } else if (this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr)) { + redirectTarget.value = currentTarget; + this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); + } + + this.targets[0] = redirectTarget.value; + } + } + } + + /** + * Counter-attacking moves pass in `[`{@linkcode BattlerIndex.ATTACKER}`]` into the constructor's `targets` param. + * This function modifies `this.targets` to reflect the actual battler index of the user's last + * attacker. + * + * If there is no last attacker, or they are no longer on the field, a message is displayed and the + * move is marked for failure. + */ + protected resolveCounterAttackTarget() { + if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) { + if (this.pokemon.turnData.attacksReceived.length) { + const attacker = this.pokemon.scene.getPokemonById(this.pokemon.turnData.attacksReceived[0].sourceId); + + if (attacker?.isActive(true)) { + this.targets[0] = attacker.getBattlerIndex(); + } + + // account for metal burst and comeuppance hitting remaining targets in double battles + // counterattack will redirect to remaining ally if original attacker faints + if (this.scene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) { + if (this.scene.getField()[this.targets[0]].hp === 0) { + const opposingField = this.pokemon.isPlayer() ? this.scene.getEnemyField() : this.scene.getPlayerField(); + this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; + } + } + } + + if (this.targets[0] === BattlerIndex.ATTACKER) { + this.fail(); + this.showMoveText(); + this.showFailedText(); + } + } + } + + /** + * Handles the case where the move was cancelled or failed: + * - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link Abilities.PRESSURE Pressure}) + * - Records a cancelled OR failed move in move history, so abilities like {@link Abilities.TRUANT Truant} don't trigger on the + * next turn and soft-lock. + * - Lapses `MOVE_EFFECT` tags: + * - Semi-invulnerable battler tags (Fly/Dive/etc.) are intended to lapse on move effects, but also need + * to lapse on move failure/cancellation. + * + * TODO: ...this seems weird. + * - Lapses `AFTER_MOVE` tags: + * - This handles the effects of {@link Moves.SUBSTITUTE Substitute} + * - Removes the second turn of charge moves + * + * TODO: handle charge moves more gracefully + */ + protected handlePreMoveFailures() { + if (this.cancelled || this.failed) { + if (this.failed) { + const ppUsed = this.ignorePp ? 0 : 1; + + if (ppUsed) { + this.move.usePp(); + } + + this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); + } + + this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); + + this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); + + this.pokemon.getMoveQueue().shift(); + } + } + + /** + * Displays the move's usage text to the player, unless it's a charge turn (ie: {@link Moves.SOLAR_BEAM Solar Beam}), + * the pokemon is on a recharge turn (ie: {@link Moves.HYPER_BEAM Hyper Beam}), or a 2-turn move was interrupted (ie: {@link Moves.FLY Fly}). + */ + protected showMoveText(): void { + if (this.move.moveId === Moves.NONE) { + return; + } + if (this.move.getMove().hasAttr(ChargeAttr)) { const lastMove = this.pokemon.getLastXMoves() as TurnMove[]; if (!lastMove.length || lastMove[0].move !== this.move.getMove().id || lastMove[0].result !== MoveResult.OTHER) { @@ -311,18 +456,10 @@ export class MovePhase extends BattlePhase { pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon), moveName: this.move.getName() }), 500); - applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents().find(() => true)!, this.move.getMove()); //TODO: is the bang correct here? + applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents()[0], this.move.getMove()); } - showFailedText(failedText: string | null = null): void { - this.scene.queueMessage(failedText || i18next.t("battle:attackFailed")); - } - - end() { - if (!this.followUp && this.canMove()) { - this.scene.unshiftPhase(new MoveEndPhase(this.scene, this.pokemon.getBattlerIndex())); - } - - super.end(); + protected showFailedText(failedText?: string): void { + this.scene.queueMessage(failedText ?? i18next.t("battle:attackFailed")); } } diff --git a/src/test/moves/whirlwind.test.ts b/src/test/moves/whirlwind.test.ts index c8ad29a23d7..cc31b2591a2 100644 --- a/src/test/moves/whirlwind.test.ts +++ b/src/test/moves/whirlwind.test.ts @@ -1,12 +1,11 @@ -import { BattlerIndex } from "#app/battle"; -import { allMoves } from "#app/data/move"; import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { MoveResult } from "#app/field/pokemon"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Whirlwind", () => { let phaserGame: Phaser.Game; @@ -40,14 +39,11 @@ describe("Moves - Whirlwind", () => { await game.classicMode.startBattle([ Species.STARAPTOR ]); const staraptor = game.scene.getPlayerPokemon()!; - const whirlwind = allMoves[Moves.WHIRLWIND]; - vi.spyOn(whirlwind, "getFailedText"); game.move.select(move); - await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); await game.toNextTurn(); expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined(); - expect(whirlwind.getFailedText).toHaveBeenCalledOnce(); + expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); }); });