diff --git a/public/battle-anims/common-powder.json b/public/battle-anims/common-powder.json new file mode 100644 index 00000000000..a56b004c914 --- /dev/null +++ b/public/battle-anims/common-powder.json @@ -0,0 +1,111 @@ +{ + "graphic": "", + "frames": [ + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 0, + "graphicFrame": 0, + "priority": 1, + "focus": 2 + }, + { + "x": 128, + "y": -64, + "zoomX": 100, + "zoomY": 100, + "visible": true, + "target": 1, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 1 + } + ], + [ + { + "x": 0, + "y": 3, + "zoomX": 100, + "zoomY": 50, + "visible": true, + "target": 0, + "graphicFrame": 0, + "priority": 1, + "focus": 2 + }, + { + "x": 128, + "y": -64, + "zoomX": 100, + "zoomY": 50, + "visible": true, + "target": 1, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 1 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 50, + "visible": true, + "target": 0, + "graphicFrame": 0, + "priority": 1, + "focus": 2 + }, + { + "x": 128, + "y": -64, + "zoomX": 100, + "zoomY": 50, + "visible": true, + "target": 1, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 1 + } + ], + [ + { + "x": 0, + "y": 0, + "zoomX": 100, + "zoomY": 50, + "visible": true, + "target": 0, + "graphicFrame": 0, + "priority": 1, + "focus": 2 + }, + { + "x": 128, + "y": -64, + "zoomX": 100, + "zoomY": 50, + "visible": true, + "target": 1, + "graphicFrame": 0, + "opacity": 255, + "locked": true, + "priority": 1, + "focus": 1 + } + ] + ], + "position": 2, + "hue": 0 +} diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index d972e48df7c..242cbfd69d5 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -90,6 +90,7 @@ export enum CommonAnim { RAGING_BULL_FIRE, RAGING_BULL_WATER, SALT_CURE, + POWDER, SUNNY = 2100, RAIN, SANDSTORM, diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 32b3d54f302..84d3b2b0b8c 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -803,6 +803,55 @@ export class SeedTag extends BattlerTag { } } +/** + * BattlerTag representing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Powder_(move) | Powder}. + * When the afflicted Pokemon uses a Fire-type move, the move is cancelled, and the + * Pokemon takes damage equal to 1/4 of it's maximum HP (rounded down). + */ +export class PowderTag extends BattlerTag { + constructor() { + super(BattlerTagType.POWDER, [ BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END ], 1); + } + + onAdd(pokemon: Pokemon): void { + super.onAdd(pokemon); + + pokemon.scene.queueMessage(i18next.t("battlerTags:powderOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + } + + /** + * Applies Powder's effects before the tag owner uses a Fire-type move. + * Also causes the tag to expire at the end of turn. + * @param pokemon {@linkcode Pokemon} the owner of this tag + * @param lapseType {@linkcode BattlerTagLapseType} the type of lapse functionality to carry out + * @returns `true` if the tag should not expire after this lapse; `false` otherwise. + */ + lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + if (lapseType === BattlerTagLapseType.PRE_MOVE) { + const movePhase = pokemon.scene.getCurrentPhase(); + if (movePhase instanceof MovePhase) { + const move = movePhase.move.getMove(); + if (pokemon.getMoveType(move) === Type.FIRE) { + movePhase.cancel(); + + new CommonBattleAnim(CommonAnim.POWDER, pokemon).play(pokemon.scene); + + const cancelDamage = new Utils.BooleanHolder(false); + applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelDamage); + if (!cancelDamage.value) { + pokemon.damageAndUpdate(Math.floor(pokemon.getMaxHp() / 4), HitResult.OTHER); + } + + pokemon.scene.queueMessage(i18next.t("battlerTags:powderLapse")); + } + } + return true; + } else { + return super.lapse(pokemon, lapseType); + } + } +} + export class NightmareTag extends BattlerTag { constructor() { super(BattlerTagType.NIGHTMARE, BattlerTagLapseType.AFTER_MOVE, 1, Moves.NIGHTMARE); @@ -2354,6 +2403,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new InfatuatedTag(sourceMove, sourceId); case BattlerTagType.SEEDED: return new SeedTag(sourceId); + case BattlerTagType.POWDER: + return new PowderTag(); case BattlerTagType.NIGHTMARE: return new NightmareTag(); case BattlerTagType.FRENZY: diff --git a/src/data/move.ts b/src/data/move.ts index 3ca672b0a69..e712a461e4d 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8456,9 +8456,10 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], -1, false, (user, target, move) => target.status?.effect === StatusEffect.POISON || target.status?.effect === StatusEffect.TOXIC) .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6) + .attr(AddBattlerTagAttr, BattlerTagType.POWDER, false, true) .ignoresSubstitute() .powderMove() - .unimplemented(), + .partial(), // does not cancel Fire-type moves generated by Dancer new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6) .attr(ChargeAttr, ChargeAnim.GEOMANCY_CHARGING, i18next.t("moveTriggers:isChargingPower", {pokemonName: "{USER}"})) .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index f367b1b56c0..fc174274466 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -80,4 +80,5 @@ export enum BattlerTagType { BURNED_UP = "BURNED_UP", DOUBLE_SHOCKED = "DOUBLE_SHOCKED", MYSTERY_ENCOUNTER_POST_SUMMON = "MYSTERY_ENCOUNTER_POST_SUMMON", + POWDER = "POWDER", } diff --git a/src/locales/en/battler-tags.json b/src/locales/en/battler-tags.json index b31826b0244..60a8074ebf5 100644 --- a/src/locales/en/battler-tags.json +++ b/src/locales/en/battler-tags.json @@ -26,6 +26,8 @@ "seededOnAdd": "{{pokemonNameWithAffix}} was seeded!", "seededLapse": "{{pokemonNameWithAffix}}'s health is\nsapped by Leech Seed!", "seededLapseShed": "{{pokemonNameWithAffix}}'s Leech Seed\nsucked up the liquid ooze!", + "powderOnAdd": "{{pokemonNameWithAffix}} is covered in powder!", + "powderLapse": "When the flame touched the powder\non the Pokémon, it exploded!", "nightmareOnAdd": "{{pokemonNameWithAffix}} began\nhaving a Nightmare!", "nightmareOnOverlap": "{{pokemonNameWithAffix}} is\nalready locked in a Nightmare!", "nightmareLapse": "{{pokemonNameWithAffix}} is locked\nin a Nightmare!", diff --git a/src/test/moves/powder.test.ts b/src/test/moves/powder.test.ts new file mode 100644 index 00000000000..e38d508a002 --- /dev/null +++ b/src/test/moves/powder.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#test/utils/gameManager"; +import { Abilities } from "#app/enums/abilities"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import { BerryPhase } from "#app/phases/berry-phase"; +import { MoveResult } from "#app/field/pokemon"; +import { Type } from "#app/data/type"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; +import { StatusEffect } from "#app/enums/status-effect"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Powder", () => { + 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"); + + game.override + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyMoveset(Array(4).fill(Moves.EMBER)) + .enemyAbility(Abilities.INSOMNIA) + .startingLevel(100) + .moveset([Moves.POWDER, Moves.SPLASH, Moves.FIERY_DANCE]); + }); + + it( + "should cancel the target's Fire-type move and damage the target", + async () => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4)); + + await game.toNextTurn(); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4)); + }, TIMEOUT + ); + + it( + "should have no effect against Grass-type Pokemon", + async () => { + game.override.enemySpecies(Species.AMOONGUSS); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should have no effect against Pokemon with Overcoat", + async () => { + game.override.enemyAbility(Abilities.OVERCOAT); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should not damage the target if the target has Magic Guard", + async () => { + game.override.enemyAbility(Abilities.MAGIC_GUARD); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should not prevent the target from thawing out with Flame Wheel", + async () => { + game.override + .enemyMoveset(Array(4).fill(Moves.FLAME_WHEEL)) + .enemyStatusEffect(StatusEffect.FREEZE); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.status?.effect).not.toBe(StatusEffect.FREEZE); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4)); + } + ); + + it( + "should not allow a target with Protean to change to Fire type", + async () => { + game.override.enemyAbility(Abilities.PROTEAN); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(enemyPokemon.summonData?.types).not.toBe(Type.FIRE); + }, TIMEOUT + ); + + // TODO: Implement this interaction and enable this test + it.skip( + "should cancel Fire-type moves generated by the target's Dancer ability", + async () => { + game.override + .enemySpecies(Species.BLASTOISE) + .enemyAbility(Abilities.DANCER); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FIERY_DANCE); + + await game.phaseInterceptor.to(MoveEffectPhase); + const enemyStartingHp = enemyPokemon.hp; + + await game.phaseInterceptor.to(BerryPhase, false); + // player should not take damage + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + // enemy should have taken damage from player's Fiery Dance + 2 Powder procs + expect(enemyPokemon.hp).toBe(enemyStartingHp - 2*Math.floor(enemyPokemon.getMaxHp() / 4)); + }, TIMEOUT + ); + + it( + "should cancel Revelation Dance if it becomes a Fire-type move", + async () => { + game.override + .enemySpecies(Species.CHARIZARD) + .enemyMoveset(Array(4).fill(Moves.REVELATION_DANCE)); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4)); + }, TIMEOUT + ); + + it( + "should cancel Shell Trap and damage the target, even if the move would fail", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.SHELL_TRAP)); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.POWDER); + + await game.phaseInterceptor.to(BerryPhase, false); + expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4)); + }, TIMEOUT + ); +});