[Move] Reimplement Beak Blast (#3427)

* Re-Implement Beak Blast

* Fix charge animation loading issues
This commit is contained in:
innerthunder 2024-08-08 11:03:28 -07:00 committed by GitHub
parent b5a40bfdfc
commit b4a891cc71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 207 additions and 21 deletions

View File

@ -1,6 +1,6 @@
//import { battleAnimRawData } from "./battle-anim-raw-data"; //import { battleAnimRawData } from "./battle-anim-raw-data";
import BattleScene from "../battle-scene"; 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 Pokemon from "../field/pokemon";
import * as Utils from "../utils"; import * as Utils from "../utils";
import { BattlerIndex } from "../battle"; import { BattlerIndex } from "../battle";
@ -499,7 +499,9 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise<void> {
} else { } else {
populateMoveAnim(move, ba); 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) { if (chargeAttr) {
initMoveChargeAnim(scene, chargeAttr.chargeAnim).then(() => resolve()); initMoveChargeAnim(scene, chargeAttr.chargeAnim).then(() => resolve());
} else { } else {
@ -570,7 +572,9 @@ export function loadMoveAnimAssets(scene: BattleScene, moveIds: Moves[], startLo
return new Promise(resolve => { return new Promise(resolve => {
const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat(); const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat();
for (const moveId of moveIds) { 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) { if (chargeAttr) {
const moveChargeAnims = chargeAnims.get(chargeAttr.chargeAnim); const moveChargeAnims = chargeAnims.get(chargeAttr.chargeAnim);
moveAnimations.push(moveChargeAnims instanceof AnimConfig ? moveChargeAnims : moveChargeAnims![0]); // TODO: is the bang correct? moveAnimations.push(moveChargeAnims instanceof AnimConfig ? moveChargeAnims : moveChargeAnims![0]); // TODO: is the bang correct?

View File

@ -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 { CommonAnimPhase, MoveEffectPhase, MovePhase, PokemonHealPhase, ShowAbilityPhase, StatChangeCallback, StatChangePhase } from "../phases";
import { getPokemonNameWithAffix } from "../messages"; import { getPokemonNameWithAffix } from "../messages";
import Pokemon, { MoveResult, HitResult } from "../field/pokemon"; 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 { export class TrappedTag extends BattlerTag {
constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType, turnCount: number, sourceMove: Moves, sourceId: number) { constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType, turnCount: number, sourceMove: Moves, sourceId: number) {
super(tagType, lapseType, turnCount, sourceMove, sourceId); super(tagType, lapseType, turnCount, sourceMove, sourceId);
@ -1738,6 +1776,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
switch (tagType) { switch (tagType) {
case BattlerTagType.RECHARGING: case BattlerTagType.RECHARGING:
return new RechargingTag(sourceMove); return new RechargingTag(sourceMove);
case BattlerTagType.BEAK_BLAST_CHARGING:
return new BeakBlastChargingTag();
case BattlerTagType.FLINCHED: case BattlerTagType.FLINCHED:
return new FlinchedTag(sourceMove); return new FlinchedTag(sourceMove);
case BattlerTagType.INTERRUPTED: case BattlerTagType.INTERRUPTED:

View File

@ -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 { export class PreMoveMessageAttr extends MoveAttr {
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string); private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string);
@ -2391,24 +2407,21 @@ export class ChargeAttr extends OverrideMoveEffectAttr {
private chargeText: string; private chargeText: string;
private tagType: BattlerTagType | null; private tagType: BattlerTagType | null;
private chargeEffect: boolean; private chargeEffect: boolean;
public sameTurn: boolean;
public followUpPriority: integer | null; 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(); super();
this.chargeAnim = chargeAnim; this.chargeAnim = chargeAnim;
this.chargeText = chargeText; this.chargeText = chargeText;
this.tagType = tagType!; // TODO: is this bang correct? this.tagType = tagType!; // TODO: is this bang correct?
this.chargeEffect = chargeEffect; this.chargeEffect = chargeEffect;
this.sameTurn = sameTurn;
this.followUpPriority = followUpPriority!; // TODO: is this bang correct?
} }
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise(resolve => { return new Promise(resolve => {
const lastMove = user.getLastXMoves().find(() => true); 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; (args[0] as Utils.BooleanHolder).value = true;
new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, () => { new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, () => {
user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); 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.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER });
user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true }); 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); user.addTag(BattlerTagType.CHARGING, 1, move.id, user.id);
resolve(true); resolve(true);
}); });
@ -8081,11 +8087,10 @@ export function initMoves() {
.attr(StatChangeAttr, BattleStat.ATK, -1), .attr(StatChangeAttr, BattleStat.ATK, -1),
new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7) new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7)
.unimplemented(), .unimplemented(),
new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, 5, 7) new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, -3, 7)
.attr(ChargeAttr, ChargeAnim.BEAK_BLAST_CHARGING, i18next.t("moveTriggers:startedHeatingUpBeak", {pokemonName: "{USER}"}), undefined, false, true, -3) .attr(BeakBlastHeaderAttr)
.ballBombMove() .ballBombMove()
.makesContact(false) .makesContact(false),
.partial(),
new AttackMove(Moves.CLANGING_SCALES, Type.DRAGON, MoveCategory.SPECIAL, 110, 100, 5, -1, 0, 7) 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) .attr(StatChangeAttr, BattleStat.DEF, -1, true, null, true, false, MoveEffectTrigger.HIT, true)
.soundBased() .soundBased()

View File

@ -66,5 +66,6 @@ export enum BattlerTagType {
IGNORE_GHOST = "IGNORE_GHOST", IGNORE_GHOST = "IGNORE_GHOST",
IGNORE_DARK = "IGNORE_DARK", IGNORE_DARK = "IGNORE_DARK",
GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA", GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA",
GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU" GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU",
BEAK_BLAST_CHARGING = "BEAK_BLAST_CHARGING"
} }

View File

@ -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 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(() => { && (!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(() => { 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) { if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) {
user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target);
} }

View File

@ -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
);
});