From b4a891cc713297d703fb4914e3faaddffab5e2af Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Thu, 8 Aug 2024 11:03:28 -0700 Subject: [PATCH] [Move] Reimplement Beak Blast (#3427) * Re-Implement Beak Blast * Fix charge animation loading issues --- src/data/battle-anims.ts | 10 ++- src/data/battler-tags.ts | 42 +++++++++- src/data/move.ts | 37 ++++---- src/enums/battler-tag-type.ts | 3 +- src/phases.ts | 1 + src/test/moves/beak_blast.test.ts | 135 ++++++++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 src/test/moves/beak_blast.test.ts diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index d9fc87c67c7..bcf3bd7bb40 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -1,6 +1,6 @@ //import { battleAnimRawData } from "./battle-anim-raw-data"; import BattleScene from "../battle-scene"; -import { AttackMove, ChargeAttr, DelayedAttackAttr, MoveFlags, SelfStatusMove, allMoves } from "./move"; +import { AttackMove, BeakBlastHeaderAttr, ChargeAttr, DelayedAttackAttr, MoveFlags, SelfStatusMove, allMoves } from "./move"; import Pokemon from "../field/pokemon"; import * as Utils from "../utils"; import { BattlerIndex } from "../battle"; @@ -499,7 +499,9 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise { } else { populateMoveAnim(move, ba); } - const chargeAttr = allMoves[move].getAttrs(ChargeAttr)[0] || allMoves[move].getAttrs(DelayedAttackAttr)[0]; + const chargeAttr = allMoves[move].getAttrs(ChargeAttr)[0] + || allMoves[move].getAttrs(DelayedAttackAttr)[0] + || allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]; if (chargeAttr) { initMoveChargeAnim(scene, chargeAttr.chargeAnim).then(() => resolve()); } else { @@ -570,7 +572,9 @@ export function loadMoveAnimAssets(scene: BattleScene, moveIds: Moves[], startLo return new Promise(resolve => { const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat(); for (const moveId of moveIds) { - const chargeAttr = allMoves[moveId].getAttrs(ChargeAttr)[0] || allMoves[moveId].getAttrs(DelayedAttackAttr)[0]; + const chargeAttr = allMoves[moveId].getAttrs(ChargeAttr)[0] + || allMoves[moveId].getAttrs(DelayedAttackAttr)[0] + || allMoves[moveId].getAttrs(BeakBlastHeaderAttr)[0]; if (chargeAttr) { const moveChargeAnims = chargeAnims.get(chargeAttr.chargeAnim); moveAnimations.push(moveChargeAnims instanceof AnimConfig ? moveChargeAnims : moveChargeAnims![0]); // TODO: is the bang correct? diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index c25407d0599..16dd4914ed4 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1,4 +1,4 @@ -import { CommonAnim, CommonBattleAnim } from "./battle-anims"; +import { ChargeAnim, CommonAnim, CommonBattleAnim, MoveChargeAnim } from "./battle-anims"; import { CommonAnimPhase, MoveEffectPhase, MovePhase, PokemonHealPhase, ShowAbilityPhase, StatChangeCallback, StatChangePhase } from "../phases"; import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { MoveResult, HitResult } from "../field/pokemon"; @@ -118,6 +118,44 @@ export class RechargingTag extends BattlerTag { } } +/** + * BattlerTag representing the "charge phase" of Beak Blast + * Pokemon with this tag will inflict BURN status on any attacker that makes contact. + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Beak_Blast_(move) | Beak Blast} + */ +export class BeakBlastChargingTag extends BattlerTag { + constructor() { + super(BattlerTagType.BEAK_BLAST_CHARGING, [ BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END ], 1, Moves.BEAK_BLAST); + } + + onAdd(pokemon: Pokemon): void { + // Play Beak Blast's charging animation + new MoveChargeAnim(ChargeAnim.BEAK_BLAST_CHARGING, this.sourceMove, pokemon).play(pokemon.scene); + + // Queue Beak Blast's header message + pokemon.scene.queueMessage(i18next.t("moveTriggers:startedHeatingUpBeak", { pokemonName: getPokemonNameWithAffix(pokemon) })); + } + + /** + * Inflicts `BURN` status on attackers that make contact, and causes this tag + * to be removed after the source makes a move (or the turn ends, whichever comes first) + * @param pokemon {@linkcode Pokemon} the owner of this tag + * @param lapseType {@linkcode BattlerTagLapseType} the type of functionality invoked in battle + * @returns `true` if invoked with the CUSTOM lapse type; `false` otherwise + */ + lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + if (lapseType === BattlerTagLapseType.CUSTOM) { + const effectPhase = pokemon.scene.getCurrentPhase(); + if (effectPhase instanceof MoveEffectPhase && effectPhase.move.getMove().hasFlag(MoveFlags.MAKES_CONTACT)) { + const attacker = effectPhase.getPokemon(); + attacker.trySetStatus(StatusEffect.BURN, true, pokemon); + } + return true; + } + return super.lapse(pokemon, lapseType); + } +} + export class TrappedTag extends BattlerTag { constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType, turnCount: number, sourceMove: Moves, sourceId: number) { super(tagType, lapseType, turnCount, sourceMove, sourceId); @@ -1738,6 +1776,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source switch (tagType) { case BattlerTagType.RECHARGING: return new RechargingTag(sourceMove); + case BattlerTagType.BEAK_BLAST_CHARGING: + return new BeakBlastChargingTag(); case BattlerTagType.FLINCHED: return new FlinchedTag(sourceMove); case BattlerTagType.INTERRUPTED: diff --git a/src/data/move.ts b/src/data/move.ts index 14e7738b948..c43150992bc 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1021,6 +1021,22 @@ export class MessageHeaderAttr extends MoveHeaderAttr { } } +/** + * Header attribute to implement the "charge phase" of Beak Blast at the + * beginning of a turn. + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Beak_Blast_(move) | Beak Blast} + * @see {@linkcode BeakBlastChargingTag} + */ +export class BeakBlastHeaderAttr extends MoveHeaderAttr { + /** Required to initialize Beak Blast's charge animation correctly */ + public chargeAnim = ChargeAnim.BEAK_BLAST_CHARGING; + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + user.addTag(BattlerTagType.BEAK_BLAST_CHARGING); + return true; + } +} + export class PreMoveMessageAttr extends MoveAttr { private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string); @@ -2391,24 +2407,21 @@ export class ChargeAttr extends OverrideMoveEffectAttr { private chargeText: string; private tagType: BattlerTagType | null; private chargeEffect: boolean; - public sameTurn: boolean; public followUpPriority: integer | null; - constructor(chargeAnim: ChargeAnim, chargeText: string, tagType?: BattlerTagType | null, chargeEffect: boolean = false, sameTurn: boolean = false, followUpPriority?: integer) { + constructor(chargeAnim: ChargeAnim, chargeText: string, tagType?: BattlerTagType | null, chargeEffect: boolean = false) { super(); this.chargeAnim = chargeAnim; this.chargeText = chargeText; this.tagType = tagType!; // TODO: is this bang correct? this.chargeEffect = chargeEffect; - this.sameTurn = sameTurn; - this.followUpPriority = followUpPriority!; // TODO: is this bang correct? } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { return new Promise(resolve => { const lastMove = user.getLastXMoves().find(() => true); - if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && (this.sameTurn || lastMove.turn !== user.scene.currentBattle.turn))) { + if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && lastMove.turn !== user.scene.currentBattle.turn)) { (args[0] as Utils.BooleanHolder).value = true; new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, () => { user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); @@ -2420,13 +2433,6 @@ export class ChargeAttr extends OverrideMoveEffectAttr { } user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER }); user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true }); - if (this.sameTurn) { - let movesetMove = user.moveset.find(m => m?.moveId === move.id); - if (!movesetMove) { // account for any move that calls a ChargeAttr move when the ChargeAttr move does not exist in moveset - movesetMove = new PokemonMove(move.id, 0, 0, true); - } - user.scene.pushMovePhase(new MovePhase(user.scene, user, [ target.getBattlerIndex() ], movesetMove, true), this.followUpPriority!); // TODO: is this bang correct? - } user.addTag(BattlerTagType.CHARGING, 1, move.id, user.id); resolve(true); }); @@ -8081,11 +8087,10 @@ export function initMoves() { .attr(StatChangeAttr, BattleStat.ATK, -1), new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7) .unimplemented(), - new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, 5, 7) - .attr(ChargeAttr, ChargeAnim.BEAK_BLAST_CHARGING, i18next.t("moveTriggers:startedHeatingUpBeak", {pokemonName: "{USER}"}), undefined, false, true, -3) + new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, -3, 7) + .attr(BeakBlastHeaderAttr) .ballBombMove() - .makesContact(false) - .partial(), + .makesContact(false), new AttackMove(Moves.CLANGING_SCALES, Type.DRAGON, MoveCategory.SPECIAL, 110, 100, 5, -1, 0, 7) .attr(StatChangeAttr, BattleStat.DEF, -1, true, null, true, false, MoveEffectTrigger.HIT, true) .soundBased() diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 405e8cc4822..fd1455eab6c 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -66,5 +66,6 @@ export enum BattlerTagType { IGNORE_GHOST = "IGNORE_GHOST", IGNORE_DARK = "IGNORE_DARK", GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA", - GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU" + GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU", + BEAK_BLAST_CHARGING = "BEAK_BLAST_CHARGING" } diff --git a/src/phases.ts b/src/phases.ts index 5d72ffef42a..ad591a368d3 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -3087,6 +3087,7 @@ export class MoveEffectPhase extends PokemonPhase { Utils.executeIf(!isProtected && !chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => { return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { + target.lapseTag(BattlerTagType.BEAK_BLAST_CHARGING); if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); } diff --git a/src/test/moves/beak_blast.test.ts b/src/test/moves/beak_blast.test.ts new file mode 100644 index 00000000000..61a022ac9eb --- /dev/null +++ b/src/test/moves/beak_blast.test.ts @@ -0,0 +1,135 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import GameManager from "#test/utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "#test/utils/gameManagerUtils"; +import { BerryPhase, MovePhase, TurnEndPhase } from "#app/phases"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; +import { StatusEffect } from "#app/enums/status-effect.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Beak Blast", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .ability(Abilities.UNNERVE) + .moveset([Moves.BEAK_BLAST]) + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.INSOMNIA) + .enemyMoveset(Array(4).fill(Moves.TACKLE)) + .startingLevel(100) + .enemyLevel(100); + }); + + it( + "should add a charge effect that burns attackers on contact", + async () => { + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.BEAK_BLAST)); + + await game.phaseInterceptor.to(MovePhase, false); + expect(leadPokemon.getTag(BattlerTagType.BEAK_BLAST_CHARGING)).toBeDefined(); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.status?.effect).toBe(StatusEffect.BURN); + }, TIMEOUT + ); + + it( + "should still charge and burn opponents if the user is sleeping", + async () => { + game.override.statusEffect(StatusEffect.SLEEP); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.BEAK_BLAST)); + + await game.phaseInterceptor.to(MovePhase, false); + expect(leadPokemon.getTag(BattlerTagType.BEAK_BLAST_CHARGING)).toBeDefined(); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.status?.effect).toBe(StatusEffect.BURN); + }, TIMEOUT + ); + + it( + "should not burn attackers that don't make contact", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.WATER_GUN)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.BEAK_BLAST)); + + await game.phaseInterceptor.to(MovePhase, false); + expect(leadPokemon.getTag(BattlerTagType.BEAK_BLAST_CHARGING)).toBeDefined(); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.status?.effect).not.toBe(StatusEffect.BURN); + }, TIMEOUT + ); + + it( + "should only hit twice with Multi-Lens", + async () => { + game.override.startingHeldItems([{name: "MULTI_LENS", count: 1}]); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.BEAK_BLAST)); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(leadPokemon.turnData.hitCount).toBe(2); + }, TIMEOUT + ); + + it( + "should be blocked by Protect", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.PROTECT)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.BEAK_BLAST)); + + await game.phaseInterceptor.to(MovePhase, false); + expect(leadPokemon.getTag(BattlerTagType.BEAK_BLAST_CHARGING)).toBeDefined(); + + await game.phaseInterceptor.to(TurnEndPhase); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(leadPokemon.getTag(BattlerTagType.BEAK_BLAST_CHARGING)).toBeUndefined(); + }, TIMEOUT + ); +});