From dd61950cb1cf4fbe45539521b7efb63267559465 Mon Sep 17 00:00:00 2001 From: Lugiad Date: Fri, 13 Sep 2024 18:31:00 +0200 Subject: [PATCH 1/4] [Localization] Tiny FR adjustments (#4212) * Update tutorial.json * Update challenges.json --- src/locales/fr/challenges.json | 3 ++- src/locales/fr/tutorial.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/locales/fr/challenges.json b/src/locales/fr/challenges.json index a83ec2e0be4..86a21881a50 100644 --- a/src/locales/fr/challenges.json +++ b/src/locales/fr/challenges.json @@ -1,6 +1,7 @@ { "title": "Paramètres du Challenge", "illegalEvolution": "{{pokemon}} est devenu\ninéligible pour ce challenge !", + "noneSelected": "Aucun sélectionné", "singleGeneration": { "name": "Monogénération", "desc": "Vous ne pouvez choisir que des Pokémon de {{gen}} génération.", @@ -33,4 +34,4 @@ "value.0": "Non", "value.1": "Oui" } -} \ No newline at end of file +} diff --git a/src/locales/fr/tutorial.json b/src/locales/fr/tutorial.json index f15a7c7c6d4..7936987457f 100644 --- a/src/locales/fr/tutorial.json +++ b/src/locales/fr/tutorial.json @@ -2,9 +2,9 @@ "intro": "Bienvenue dans PokéRogue, un fangame axé sur les combats Pokémon avec des éléments roguelite !\n$Ce jeu n’est pas monétisé et nous ne prétendons à la propriété d’aucun élément sous copyright utilisé.\n$Bien qu’en développement permanent, PokéRogue reste entièrement jouable.\n$Tout signalement de bugs et d’erreurs quelconques passe par le serveur Discord.\n$Si le jeu est lent, vérifiez que l’Accélération Matérielle est activée dans les paramètres du navigateur.", "accessMenu": "Accédez au menu avec M ou Échap lors de l’attente d’une\naction.\n$Il contient les paramètres et diverses fonctionnalités.", "menu": "Vous pouvez accéder aux paramètres depuis ce menu.\n$Vous pouvez entre autres y changer la vitesse du jeu ou le style de fenêtre…\n$Mais également des tonnes d’autres fonctionnalités, jetez-y un œil !", - "starterSelect": "Choisissez vos starters depuis cet écran avec Z ou Espace.\nIls formeront votre équipe de départ.\n$Chacun possède une valeur. Votre équipe peut avoir jusqu’à 6 membres, sans dépasser un cout de 10.\n$Vous pouvez aussi choisir le sexe, le talent et la forme en\nfonction des variants déjà capturés ou éclos.\n$Les IV d’un starter sont les meilleurs de tous ceux de son espèce déjà possédés. Obtenez-en plusieurs !", + "starterSelect": "Choisissez vos starters depuis cet écran avec Z ou Espace.\nIls formeront votre équipe de départ.\n$Chacun possède une valeur. Votre équipe peut avoir\njusqu’à 6 membres, sans dépasser un cout de 10.\n$Vous pouvez aussi choisir le sexe, le talent et la forme en\nfonction des variants déjà capturés ou éclos.\n$Les IV d’un starter sont les meilleurs de tous ceux de\nson espèce déjà possédés. Obtenez-en plusieurs !", "pokerus": "Chaque jour, 3 starters tirés aléatoirement ont un contour violet.\n$Si un starter que vous possédez l’a, essayez de l’ajouter à votre équipe. Vérifiez bien son résumé !", - "statChange": "Les changements de stats persistent à travers les combats tant que le Pokémon n’est pas rappelé.\n$Vos Pokémon sont rappelés avant un combat de Dresseur et avant d’entrer dans un nouveau biome.\n$Vous pouvez voir en combat les changements de stats d’un Pokémon en maintenant C ou Maj.\n$Vous pouvez également voir les capacités de l’adversaire en maintenant V.\n$Seules les capacités que le Pokémon a utilisées dans ce combat sont consultables.", - "selectItem": "Après chaque combat, vous avez le choix entre 3 objets\ntirés au sort. Vous ne pouvez en prendre qu’un.\n$Cela peut être des objets consommables, des objets à\nfaire tenir, ou des objets passifs aux effets permanents.\n$La plupart des effets des objets non-consommables se cumuleront de diverses manières.\n$Certains objets n’apparaitront que s’ils ont une utilité immédiate, comme les objets d’évolution.\n$Vous pouvez aussi transférer des objets tenus entre Pokémon en utilisant l’option de transfert.\n$L’option de transfert apparait en bas à droite dès qu’un Pokémon de l’équipe porte un objet.\n$Vous pouvez acheter des consommables avec de l’argent.\nPlus vous progressez, plus le choix sera large.\n$Choisir un des objets gratuits déclenchera le prochain combat, donc faites bien tous vos achats avant.", + "statChange": "Les changements de stats persistent à travers\nles combats tant que le Pokémon n’est pas rappelé.\n$Vos Pokémon sont rappelés avant un combat de\nDresseur et avant d’entrer dans un nouveau biome.\n$Vous pouvez voir en combat les changements de stats\nd’un Pokémon en maintenant C ou Maj.\n$Vous pouvez également voir les capacités de l’adversaire\nen maintenant V.\n$Seules les capacités que le Pokémon a utilisées dans\nce combat sont consultables.", + "selectItem": "Après chaque combat, vous avez le choix entre 3 objets\ntirés au sort. Vous ne pouvez en prendre qu’un.\n$Cela peut être des objets consommables, des objets à\nfaire tenir, ou des objets passifs aux effets permanents.\n$La plupart des effets des objets non-consommables se cumuleront de diverses manières.\n$Certains objets n’apparaitront que s’ils ont une utilité immédiate, comme les objets d’évolution.\n$Vous pouvez aussi transférer des objets tenus entre\nPokémon en utilisant l’option de transfert.\n$L’option de transfert apparait en bas à droite dès\nqu’un Pokémon de l’équipe porte un objet.\n$Vous pouvez acheter des consommables avec de\nl’argent. Plus vous progressez, plus le choix sera large.\n$Choisir un des objets gratuits déclenchera le prochain\ncombat, donc faites bien tous vos achats avant.", "eggGacha": "Depuis cet écran, vous pouvez utiliser vos coupons\npour recevoir Œufs de Pokémon au hasard.\n$Les Œufs éclosent après avoir remporté un certain nombre de combats. Plus ils sont rares, plus ils mettent de temps.\n$Les Pokémon éclos ne rejoindront pas votre équipe, mais seront ajoutés à vos starters.\n$Les Pokémon issus d’Œufs ont généralement de meilleurs IV que les Pokémon sauvages.\n$Certains Pokémon ne peuvent être obtenus que dans des Œufs.\n$Il y a 3 différentes machines à actionner avec différents\nbonus, prenez celle qui vous convient le mieux !" } From 1cf075adc9e10ce65e1975c4a5c28305863f0834 Mon Sep 17 00:00:00 2001 From: "Amani H." <109637146+xsn34kzx@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:31:25 -0400 Subject: [PATCH 2/4] [Bug] Fix Guard Swap and Power Swap swapping all stats (#4213) * [Bug] Fix `SwapStatStagesAttr` Oversight * Remove SPLASH_ONLY Leftover --- src/data/move.ts | 2 +- src/test/moves/guard_swap.test.ts | 40 +++++++++++-------- src/test/moves/heart_swap.test.ts | 64 +++++++++++++++++++++++++++++++ src/test/moves/power_swap.test.ts | 41 ++++++++++++-------- 4 files changed, 112 insertions(+), 35 deletions(-) create mode 100644 src/test/moves/heart_swap.test.ts diff --git a/src/data/move.ts b/src/data/move.ts index 650725b311b..473e2e51f41 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -2836,7 +2836,7 @@ export class SwapStatStagesAttr extends MoveEffectAttr { */ apply(user: Pokemon, target: Pokemon, move: Move, args: any []): boolean { if (super.apply(user, target, move, args)) { - for (const s of BATTLE_STATS) { + for (const s of this.stats) { const temp = user.getStatStage(s); user.setStatStage(s, target.getStatStage(s)); target.setStatStage(s, temp); diff --git a/src/test/moves/guard_swap.test.ts b/src/test/moves/guard_swap.test.ts index a27afaaa7ba..0c24f69c32c 100644 --- a/src/test/moves/guard_swap.test.ts +++ b/src/test/moves/guard_swap.test.ts @@ -1,16 +1,17 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import Phaser from "phaser"; import GameManager from "#app/test/utils/gameManager"; import { Species } from "#enums/species"; import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { Moves } from "#enums/moves"; -import { Stat } from "#enums/stat"; +import { Stat, BATTLE_STATS } from "#enums/stat"; import { Abilities } from "#enums/abilities"; import { MoveEndPhase } from "#app/phases/move-end-phase"; describe("Moves - Guard Swap", () => { let phaserGame: Phaser.Game; let game: GameManager; + const TIMEOUT = 20 * 1000; beforeAll(() => { phaserGame = new Phaser.Game({ @@ -27,37 +28,42 @@ describe("Moves - Guard Swap", () => { game.override .battleType("single") .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset([Moves.SHELL_SMASH]) - .enemySpecies(Species.MEW) + .enemyMoveset(Moves.SPLASH) + .enemySpecies(Species.INDEEDEE) .enemyLevel(200) .moveset([ Moves.GUARD_SWAP ]) .ability(Abilities.NONE); }); - it("should swap the user's DEF AND SPDEF stat stages with the target's", async () => { - await game.startBattle([ + it("should swap the user's DEF and SPDEF stat stages with the target's", async () => { + await game.classicMode.startBattle([ Species.INDEEDEE ]); - // Should start with no stat stages const player = game.scene.getPlayerPokemon()!; - // After Shell Smash, should have +2 in ATK and SPATK, -1 in DEF and SPDEF const enemy = game.scene.getEnemyPokemon()!; + vi.spyOn(enemy.summonData, "statStages", "get").mockReturnValue(new Array(BATTLE_STATS.length).fill(1)); + game.move.select(Moves.GUARD_SWAP); await game.phaseInterceptor.to(MoveEndPhase); - expect(player.getStatStage(Stat.DEF)).toBe(0); - expect(player.getStatStage(Stat.SPDEF)).toBe(0); - expect(enemy.getStatStage(Stat.DEF)).toBe(-1); - expect(enemy.getStatStage(Stat.SPDEF)).toBe(-1); + for (const s of BATTLE_STATS) { + expect(player.getStatStage(s)).toBe(0); + expect(enemy.getStatStage(s)).toBe(1); + } await game.phaseInterceptor.to(TurnEndPhase); - expect(player.getStatStage(Stat.DEF)).toBe(-1); - expect(player.getStatStage(Stat.SPDEF)).toBe(-1); - expect(enemy.getStatStage(Stat.DEF)).toBe(0); - expect(enemy.getStatStage(Stat.SPDEF)).toBe(0); - }, 20000); + for (const s of BATTLE_STATS) { + if (s === Stat.DEF || s === Stat.SPDEF) { + expect(player.getStatStage(s)).toBe(1); + expect(enemy.getStatStage(s)).toBe(0); + } else { + expect(player.getStatStage(s)).toBe(0); + expect(enemy.getStatStage(s)).toBe(1); + } + } + }, TIMEOUT); }); diff --git a/src/test/moves/heart_swap.test.ts b/src/test/moves/heart_swap.test.ts new file mode 100644 index 00000000000..f658641d46f --- /dev/null +++ b/src/test/moves/heart_swap.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import { Species } from "#enums/species"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { Moves } from "#enums/moves"; +import { BATTLE_STATS } from "#enums/stat"; +import { Abilities } from "#enums/abilities"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; + +describe("Moves - Heart Swap", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const TIMEOUT = 20 * 1000; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .enemySpecies(Species.INDEEDEE) + .enemyLevel(200) + .moveset([ Moves.HEART_SWAP ]) + .ability(Abilities.NONE); + }); + + it("should swap all of the user's stat stages with the target's", async () => { + await game.classicMode.startBattle([ + Species.MANAPHY + ]); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + vi.spyOn(enemy.summonData, "statStages", "get").mockReturnValue(new Array(BATTLE_STATS.length).fill(1)); + + game.move.select(Moves.HEART_SWAP); + + await game.phaseInterceptor.to(MoveEndPhase); + + for (const s of BATTLE_STATS) { + expect(player.getStatStage(s)).toBe(0); + expect(enemy.getStatStage(s)).toBe(1); + } + + await game.phaseInterceptor.to(TurnEndPhase); + + for (const s of BATTLE_STATS) { + expect(enemy.getStatStage(s)).toBe(0); + expect(player.getStatStage(s)).toBe(1); + } + }, TIMEOUT); +}); diff --git a/src/test/moves/power_swap.test.ts b/src/test/moves/power_swap.test.ts index a3d4bfca19a..92cd786c050 100644 --- a/src/test/moves/power_swap.test.ts +++ b/src/test/moves/power_swap.test.ts @@ -1,16 +1,17 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import Phaser from "phaser"; import GameManager from "#app/test/utils/gameManager"; import { Species } from "#enums/species"; import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { Moves } from "#enums/moves"; -import { Stat } from "#enums/stat"; +import { Stat, BATTLE_STATS } from "#enums/stat"; import { Abilities } from "#enums/abilities"; import { MoveEndPhase } from "#app/phases/move-end-phase"; describe("Moves - Power Swap", () => { let phaserGame: Phaser.Game; let game: GameManager; + const TIMEOUT = 20 * 1000; beforeAll(() => { phaserGame = new Phaser.Game({ @@ -27,36 +28,42 @@ describe("Moves - Power Swap", () => { game.override .battleType("single") .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset([Moves.SHELL_SMASH]) - .enemySpecies(Species.MEW) + .enemyMoveset(Moves.SPLASH) + .enemySpecies(Species.INDEEDEE) .enemyLevel(200) .moveset([ Moves.POWER_SWAP ]) .ability(Abilities.NONE); }); - it("should swap the user's ATK AND SPATK stat stages with the target's", async () => { - await game.startBattle([ + it("should swap the user's ATK and SPATK stat stages with the target's", async () => { + await game.classicMode.startBattle([ Species.INDEEDEE ]); - // Should start with no stat stages const player = game.scene.getPlayerPokemon()!; - // After Shell Smash, should have +2 in ATK and SPATK, -1 in DEF and SPDEF const enemy = game.scene.getEnemyPokemon()!; + + vi.spyOn(enemy.summonData, "statStages", "get").mockReturnValue(new Array(BATTLE_STATS.length).fill(1)); + game.move.select(Moves.POWER_SWAP); await game.phaseInterceptor.to(MoveEndPhase); - expect(player.getStatStage(Stat.ATK)).toBe(0); - expect(player.getStatStage(Stat.SPATK)).toBe(0); - expect(enemy.getStatStage(Stat.ATK)).toBe(2); - expect(enemy.getStatStage(Stat.SPATK)).toBe(2); + for (const s of BATTLE_STATS) { + expect(player.getStatStage(s)).toBe(0); + expect(enemy.getStatStage(s)).toBe(1); + } await game.phaseInterceptor.to(TurnEndPhase); - expect(player.getStatStage(Stat.ATK)).toBe(2); - expect(player.getStatStage(Stat.SPATK)).toBe(2); - expect(enemy.getStatStage(Stat.ATK)).toBe(0); - expect(enemy.getStatStage(Stat.SPATK)).toBe(0); - }, 20000); + for (const s of BATTLE_STATS) { + if (s === Stat.ATK || s === Stat.SPATK) { + expect(player.getStatStage(s)).toBe(1); + expect(enemy.getStatStage(s)).toBe(0); + } else { + expect(player.getStatStage(s)).toBe(0); + expect(enemy.getStatStage(s)).toBe(1); + } + } + }, TIMEOUT); }); From 526f9ae2bc0a6e6ac718a90d7d358ec9681cafd3 Mon Sep 17 00:00:00 2001 From: Tempoanon <163687446+Tempo-anon@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:44:42 -0400 Subject: [PATCH 3/4] [Balance] Tweak evil boss teams, fix aesthetic details (#4214) * oops * Swap phero and nihi, fix typo * Actually make the change --- src/data/trainer-config.ts | 91 ++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/src/data/trainer-config.ts b/src/data/trainer-config.ts index 07722a5a206..ac33f26de9e 100644 --- a/src/data/trainer-config.ts +++ b/src/data/trainer-config.ts @@ -1530,18 +1530,18 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.PIDGEOT], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Pidgeot p.generateAndPopulateMoveset(); p.generateName(); })), [TrainerType.RED]: new TrainerConfig(++t).initForChampion(signatureSpecies["RED"], true).setBattleBgm("battle_johto_champion").setMixedBattleBgm("battle_johto_champion").setHasDouble("red_blue_double").setDoubleTrainerType(TrainerType.BLUE).setDoubleTitle("champion_double") .setPartyMemberFunc(0, getRandomPartyMemberFunc([Species.PIKACHU], TrainerSlot.TRAINER, true, p => { - p.formIndex = 8; + p.formIndex = 8; // G-Max Pikachu p.generateAndPopulateMoveset(); p.generateName(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.VENUSAUR, Species.CHARIZARD, Species.BLASTOISE], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Venusaur, Mega Charizard X, or Mega Blastoise p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1550,7 +1550,7 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.LATIAS, Species.LATIOS], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Latias or Mega Latios p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1559,7 +1559,7 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.METAGROSS], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Metagross p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1569,15 +1569,16 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.SWAMPERT], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Swampert p.generateAndPopulateMoveset(); + p.generateName(); })), [TrainerType.CYNTHIA]: new TrainerConfig(++t).initForChampion(signatureSpecies["CYNTHIA"], false).setBattleBgm("battle_sinnoh_champion").setMixedBattleBgm("battle_sinnoh_champion") .setPartyMemberFunc(0, getRandomPartyMemberFunc([Species.SPIRITOMB], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.GARCHOMP], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Garchomp p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1590,7 +1591,7 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.LAPRAS], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // G-Max Lapras p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1599,7 +1600,7 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.GARDEVOIR], TrainerSlot.TRAINER, true, p => { - p.formIndex = 1; + p.formIndex = 1; // Mega Gardevoir p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1612,7 +1613,7 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.CHARIZARD], TrainerSlot.TRAINER, true, p => { - p.formIndex = 3; + p.formIndex = 3; // G-Max Charizard p.generateAndPopulateMoveset(); p.generateName(); })), @@ -1688,7 +1689,7 @@ export const trainerConfigs: TrainerConfigs = { p.pokeball = PokeballType.MASTER_BALL; p.shiny = true; p.variant = 1; - p.formIndex = 1; + p.formIndex = 1; // Mega Rayquaza p.generateName(); })) .setGenModifiersFunc(party => { @@ -1706,7 +1707,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Kangaskhan p.generateName(); })), [TrainerType.ROCKET_BOSS_GIOVANNI_2]: new TrainerConfig(++t).setName("Giovanni").initForEvilTeamLeader("Rocket Boss", [], true).setMixedBattleBgm("battle_rocket_boss").setVictoryBgm("victory_team_plasma") @@ -1721,7 +1722,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Kangaskhan p.generateName(); })) .setPartyMemberFunc(4, getRandomPartyMemberFunc([Species.GASTRODON, Species.SEISMITOAD])) @@ -1740,7 +1741,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Camerupt p.generateName(); })), [TrainerType.MAXIE_2]: new TrainerConfig(++t).setName("Maxie").initForEvilTeamLeader("Magma Boss", [], true).setMixedBattleBgm("battle_aqua_magma_boss").setVictoryBgm("victory_team_plasma") @@ -1751,7 +1752,7 @@ export const trainerConfigs: TrainerConfigs = { })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([Species.TORKOAL, Species.NINETALES], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - p.abilityIndex = 2; // DROUGHT + p.abilityIndex = 2; // Drought })) .setPartyMemberFunc(2, getRandomPartyMemberFunc([Species.SHIFTRY, Species.SCOVILLAIN], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); @@ -1762,7 +1763,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Camerupt p.generateName(); })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([Species.GROUDON], TrainerSlot.TRAINER, true, p => { @@ -1780,7 +1781,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Sharpedo p.generateName(); })), [TrainerType.ARCHIE_2]: new TrainerConfig(++t).setName("Archie").initForEvilTeamLeader("Aqua Boss", [], true).setMixedBattleBgm("battle_aqua_magma_boss").setVictoryBgm("victory_team_plasma") @@ -1805,7 +1806,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Sharpedo p.generateName(); })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([Species.KYOGRE], TrainerSlot.TRAINER, true, p => { @@ -1821,7 +1822,7 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.HOUNDOOM ], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Houndoom p.generateName(); })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([Species.WEAVILE], TrainerSlot.TRAINER, true, p => { @@ -1839,7 +1840,7 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc(3, getRandomPartyMemberFunc([Species.HOUNDOOM], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Houndoom p.generateName(); })) .setPartyMemberFunc(4, getRandomPartyMemberFunc([Species.WEAVILE, Species.SNEASLER], TrainerSlot.TRAINER, true, p => { @@ -1867,8 +1868,8 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.GENESECT ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; - p.formIndex = Utils.randSeedInt(5); + p.pokeball = PokeballType.ULTRA_BALL; + p.formIndex = Utils.randSeedInt(5, 1); // Shock, Burn, Chill, or Douse Drive })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.BASCULEGION, Species.JELLICENT ], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); @@ -1900,7 +1901,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Gyarados p.generateName(); })), [TrainerType.LYSANDRE_2]: new TrainerConfig(++t).setName("Lysandre").initForEvilTeamLeader("Flare Boss", [], true).setMixedBattleBgm("battle_flare_boss").setVictoryBgm("victory_team_plasma") @@ -1919,7 +1920,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; - p.formIndex = 1; + p.formIndex = 1; // Mega Gyardos p.generateName(); })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([Species.YVELTAL], TrainerSlot.TRAINER, true, p => { @@ -1936,25 +1937,24 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.NIHILEGO ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ROGUE_BALL; })), [TrainerType.LUSAMINE_2]: new TrainerConfig(++t).setName("Lusamine").initForEvilTeamLeader("Aether Boss", [], true).setMixedBattleBgm("battle_aether_boss").setVictoryBgm("victory_team_plasma") - .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.CLEFABLE ], TrainerSlot.TRAINER, true, p => { + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.NIHILEGO ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ROGUE_BALL; })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.MILOTIC, Species.PRIMARINA ])) - .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.PHEROMOSA ], TrainerSlot.TRAINER, true, p => { - p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; - })) + .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.CLEFABLE ])) .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.STAKATAKA, Species.CELESTEELA, Species.GUZZLORD ], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; + p.pokeball = PokeballType.ROGUE_BALL; })) - .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.NIHILEGO ], TrainerSlot.TRAINER, true, p => { + .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.PHEROMOSA ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; + p.pokeball = PokeballType.ROGUE_BALL; })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.NECROZMA ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); @@ -1968,37 +1968,41 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.GALVANTULA, Species.VIKAVOLT])) .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.PINSIR ], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - p.formIndex = 1; + p.formIndex = 1; // Mega Pinsir + p.pokeball = PokeballType.ULTRA_BALL; p.generateName(); })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.GOLISOPOD ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ULTRA_BALL; })), [TrainerType.GUZMA_2]: new TrainerConfig(++t).setName("Guzma").initForEvilTeamLeader("Skull Boss", [], true).setMixedBattleBgm("battle_skull_boss").setVictoryBgm("victory_team_plasma") .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.GOLISOPOD ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); p.abilityIndex = 2; //Anticipation + p.pokeball = PokeballType.ULTRA_BALL; })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.HISUI_SAMUROTT, Species.CRAWDAUNT ], TrainerSlot.TRAINER, true, p => { - p.abilityIndex = 2; //Sharpness, Adaptability + p.abilityIndex = 2; //Sharpness Hisui Samurott, Adaptability Crawdaunt })) .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.SCIZOR, Species.KLEAVOR ])) .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.PINSIR ], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); p.formIndex = 1; p.generateName(); + p.pokeball = PokeballType.ULTRA_BALL; })) .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.BUZZWOLE ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; + p.pokeball = PokeballType.ROGUE_BALL; })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.XURKITREE ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; + p.pokeball = PokeballType.ROGUE_BALL; })), [TrainerType.ROSE]: new TrainerConfig(++t).setName("Rose").initForEvilTeamLeader("Macro Boss", []).setMixedBattleBgm("battle_macro_boss").setVictoryBgm("victory_team_plasma") .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.ARCHALUDON ])) @@ -2009,29 +2013,32 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.COPPERAJAH ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.formIndex = 1; + p.formIndex = 1; // G-Max Copperajah p.generateName(); + p.pokeball = PokeballType.ULTRA_BALL; })), [TrainerType.ROSE_2]: new TrainerConfig(++t).setName("Rose").initForEvilTeamLeader("Macro Boss", [], true).setMixedBattleBgm("battle_macro_boss").setVictoryBgm("victory_team_plasma") - .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.ARCHALUDON ], TrainerSlot.TRAINER, true, p => { + .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.MELMETAL ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); + p.pokeball = PokeballType.ULTRA_BALL; })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.AEGISLASH, Species.GHOLDENGO ])) .setPartyMemberFunc(2, getRandomPartyMemberFunc([ Species.DRACOVISH, Species.DRACOZOLT ], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - p.abilityIndex = 1; //Strong Jaw, Hustle + p.abilityIndex = 1; //Strong Jaw Dracovish, Hustle Dracozolt })) - .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.MELMETAL ])) + .setPartyMemberFunc(3, getRandomPartyMemberFunc([ Species.ARCHALUDON ])) .setPartyMemberFunc(4, getRandomPartyMemberFunc([ Species.GALAR_ARTICUNO, Species.GALAR_ZAPDOS, Species.GALAR_MOLTRES ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.pokeball = PokeballType.MASTER_BALL; + p.pokeball = PokeballType.ULTRA_BALL; })) .setPartyMemberFunc(5, getRandomPartyMemberFunc([ Species.COPPERAJAH ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - p.formIndex = 1; + p.formIndex = 1; // G-Max Copperajah p.generateName(); + p.pokeball = PokeballType.ULTRA_BALL; })), }; From 70295280da25292bde6f3f44b0d09d5145c7ab3a Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:46:22 -0700 Subject: [PATCH 4/4] [Move] Implement Substitute (#2559) * Implement Substitute Squashed commit from working branch * Fix integration test imports * Use Override Helper utils + Fix Baton Pass test * Update src/test/moves/substitute.test.ts Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> * Fix test imports + nits * Document RemoveAllSubstitutesAttr * Fix some strict-null issues * more strict-null fixes * Fix baton pass test * Reorganized Substitute translation keys * Added checks for substitute in contact logic * Clean up Unseen Fist contact logic * Remove misleading comment in Download attr * RIP phases.ts * Fix imports post-phase migration * Rewrite `move.canIgnoreSubstitute` to `move.hitsSubstitute` * Also fixed interactions with Shell Trap and Beak Blast * Removed some leftover `canIgnoreSubstitute`s * fix issues after beta merge * Status move effectiveness now accounts for substitute * More edge case tests (Counter test failing) * Fix Counter + Trap edge cases + add Fail messagesd * Fix leftover nit * Resolve leftover test issues * Fix Sub offset carrying over to Trainer fights * Hide substitute sprite during catch attempts * Make substitutes baton-passable again * Remove placeholder locale keys and SPLASH_ONLY * Fix imports and other nits Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * ESLint * Fix imports * Fix incorrect `resetSprite` timing * Fix substitute disappearing on hit (maybe?) * More animation fixes (mostly for Roar) --------- Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/battle-scene.ts | 12 + src/data/ability.ts | 11 +- src/data/battle-anims.ts | 88 +++- src/data/battler-tags.ts | 107 +++- src/data/move.ts | 235 ++++++++- src/enums/battler-tag-type.ts | 1 + src/enums/pokemon-anim-type.ts | 16 + src/field/pokemon.ts | 103 +++- src/locales/de/battler-tags.json | 5 +- src/locales/en/battler-tags.json | 5 +- src/locales/en/move-trigger.json | 4 +- src/locales/es/battler-tags.json | 5 +- src/locales/fr/battler-tags.json | 5 +- src/locales/it/battler-tags.json | 5 +- src/locales/ko/battler-tags.json | 5 +- src/locales/pt_BR/battler-tags.json | 5 +- src/locales/zh_CN/battler-tags.json | 5 +- src/locales/zh_TW/battler-tags.json | 5 +- src/phases/attempt-capture-phase.ts | 11 + src/phases/common-anim-phase.ts | 2 +- src/phases/damage-phase.ts | 2 +- src/phases/faint-phase.ts | 2 +- src/phases/move-anim-test-phase.ts | 4 +- src/phases/move-effect-phase.ts | 34 +- src/phases/move-phase.ts | 2 + src/phases/obtain-status-effect-phase.ts | 2 +- src/phases/pokemon-anim-phase.ts | 237 +++++++++ src/phases/post-turn-status-effect-phase.ts | 2 +- src/phases/return-phase.ts | 1 + src/phases/scan-ivs-phase.ts | 2 +- src/phases/switch-summon-phase.ts | 25 +- src/test/abilities/unseen_fist.test.ts | 44 +- src/test/battlerTags/substitute.test.ts | 234 +++++++++ src/test/moves/substitute.test.ts | 515 ++++++++++++++++++++ src/test/moves/tidy_up.test.ts | 22 +- src/ui/target-select-ui-handler.ts | 5 +- 36 files changed, 1652 insertions(+), 116 deletions(-) create mode 100644 src/enums/pokemon-anim-type.ts create mode 100644 src/phases/pokemon-anim-phase.ts create mode 100644 src/test/battlerTags/substitute.test.ts create mode 100644 src/test/moves/substitute.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index c9c8d6b788a..f06ae607b0e 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -64,6 +64,7 @@ import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import { UiTheme } from "#enums/ui-theme"; 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"; @@ -74,6 +75,7 @@ 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"; @@ -2721,6 +2723,16 @@ export default class BattleScene extends SceneBase { return false; } + triggerPokemonBattleAnim(pokemon: Pokemon, battleAnimType: PokemonAnimType, fieldAssets?: Phaser.GameObjects.Sprite[], delayed: boolean = false): boolean { + const phase: Phase = new PokemonAnimPhase(this, battleAnimType, pokemon, fieldAssets); + if (delayed) { + this.pushPhase(phase); + } else { + this.unshiftPhase(phase); + } + return true; + } + validateAchvs(achvType: Constructor, ...args: unknown[]): void { const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType); for (const achv of filteredAchvs) { diff --git a/src/data/ability.ts b/src/data/ability.ts index 6acf77cfca5..b38d9ea0fb3 100755 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1706,6 +1706,10 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr { } applyPostAttackAfterMoveTypeCheck(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { + if (pokemon !== attacker && move.hitsSubstitute(attacker, pokemon)) { + return false; + } + /**Status inflicted by abilities post attacking are also considered additional effects.*/ if (!attacker.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !simulated && pokemon !== attacker && (!this.contactRequired || move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)) && pokemon.randSeedInt(100) < this.chance && !pokemon.status) { const effect = this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randSeedInt(this.effects.length)]; @@ -2064,6 +2068,10 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr { if (this.intimidate) { applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled, simulated); applyAbAttrs(PostIntimidateStatStageChangeAbAttr, opponent, cancelled, simulated); + + if (opponent.getTag(BattlerTagType.SUBSTITUTE)) { + cancelled.value = true; + } } if (!cancelled.value) { pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, opponent.getBattlerIndex(), false, this.stats, this.stages)); @@ -2143,7 +2151,6 @@ export class DownloadAbAttr extends PostSummonAbAttr { private enemyCountTally: integer; private stats: BattleStat[]; - // TODO: Implement the Substitute feature(s) once move is implemented. /** * Checks to see if it is the opening turn (starting a new game), if so, Download won't work. This is because Download takes into account * vitamins and items, so it needs to use the Stat and the stat alone. @@ -4781,7 +4788,7 @@ export const allAbilities = [ new Ability(Abilities.NONE, 3) ]; export function initAbilities() { allAbilities.push( new Ability(Abilities.STENCH, 3) - .attr(PostAttackApplyBattlerTagAbAttr, false, (user, target, move) => !move.hasAttr(FlinchAttr) ? 10 : 0, BattlerTagType.FLINCHED), + .attr(PostAttackApplyBattlerTagAbAttr, false, (user, target, move) => !move.hasAttr(FlinchAttr) && !move.hitsSubstitute(user, target) ? 10 : 0, BattlerTagType.FLINCHED), new Ability(Abilities.DRIZZLE, 3) .attr(PostSummonWeatherChangeAbAttr, WeatherType.RAIN) .attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.RAIN), diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index da4e7f6a33b..102d435fc60 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -6,6 +6,7 @@ import * as Utils from "../utils"; import { BattlerIndex } from "../battle"; import { Element } from "json-stable-stringify"; import { Moves } from "#enums/moves"; +import { SubstituteTag } from "./battler-tags"; //import fs from 'vite-plugin-fs/browser'; export enum AnimFrameTarget { @@ -700,7 +701,7 @@ export abstract class BattleAnim { return false; } - private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[]): Map> { + private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[], onSubstitute?: boolean): Map> { const ret: Map> = new Map([ [AnimFrameTarget.GRAPHIC, new Map() ], [AnimFrameTarget.USER, new Map() ], @@ -711,12 +712,15 @@ export abstract class BattleAnim { const user = !isOppAnim ? this.user : this.target; const target = !isOppAnim ? this.target : this.user; + const targetSubstitute = (onSubstitute && user !== target) ? target!.getTag(SubstituteTag) : null; + const userInitialX = user!.x; // TODO: is this bang correct? const userInitialY = user!.y; // TODO: is this bang correct? const userHalfHeight = user!.getSprite().displayHeight! / 2; // TODO: is this bang correct? - const targetInitialX = target!.x; // TODO: is this bang correct? - const targetInitialY = target!.y; // TODO: is this bang correct? - const targetHalfHeight = target!.getSprite().displayHeight! / 2; // TODO: is this bang correct? + + const targetInitialX = targetSubstitute?.sprite?.x ?? target!.x; // TODO: is this bang correct? + const targetInitialY = targetSubstitute?.sprite?.y ?? target!.y; // TODO: is this bang correct? + const targetHalfHeight = (targetSubstitute?.sprite ?? target!.getSprite()).displayHeight! / 2; // TODO: is this bang correct? let g = 0; let u = 0; @@ -754,7 +758,7 @@ export abstract class BattleAnim { return ret; } - play(scene: BattleScene, callback?: Function) { + play(scene: BattleScene, onSubstitute?: boolean, callback?: Function) { const isOppAnim = this.isOppAnim(); const user = !isOppAnim ? this.user! : this.target!; // TODO: are those bangs correct? const target = !isOppAnim ? this.target : this.user; @@ -766,8 +770,10 @@ export abstract class BattleAnim { return; } + const targetSubstitute = (!!onSubstitute && user !== target) ? target.getTag(SubstituteTag) : null; + const userSprite = user.getSprite(); - const targetSprite = target.getSprite(); + const targetSprite = targetSubstitute?.sprite ?? target.getSprite(); const spriteCache: SpriteCache = { [AnimFrameTarget.GRAPHIC]: [], @@ -782,16 +788,34 @@ export abstract class BattleAnim { userSprite.setAlpha(1); userSprite.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ]; userSprite.setAngle(0); - targetSprite.setPosition(0, 0); - targetSprite.setScale(1); - targetSprite.setAlpha(1); + if (!targetSubstitute) { + targetSprite.setPosition(0, 0); + targetSprite.setScale(1); + targetSprite.setAlpha(1); + } else { + targetSprite.setPosition( + target.x - target.getSubstituteOffset()[0], + target.y - target.getSubstituteOffset()[1] + ); + targetSprite.setScale(target.getSpriteScale() * (target.isPlayer() ? 0.5 : 1)); + targetSprite.setAlpha(1); + } targetSprite.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ]; targetSprite.setAngle(0); - if (!this.isHideUser() && userSprite) { - this.user?.getSprite().setVisible(true); // using this.user to fix context loss due to isOppAnim swap (#481) + + /** + * This and `targetSpriteToShow` are used to restore context lost + * from the `isOppAnim` swap. Using these references instead of `this.user` + * and `this.target` prevent the target's Substitute doll from disappearing + * after being the target of an animation. + */ + const userSpriteToShow = !isOppAnim ? userSprite : targetSprite; + const targetSpriteToShow = !isOppAnim ? targetSprite : userSprite; + if (!this.isHideUser() && userSpriteToShow) { + userSpriteToShow.setVisible(true); } - if (!this.isHideTarget() && (targetSprite !== userSprite || !this.isHideUser())) { - this.target?.getSprite().setVisible(true); // using this.target to fix context loss due to isOppAnim swap (#481) + if (!this.isHideTarget() && (targetSpriteToShow !== userSpriteToShow || !this.isHideUser())) { + targetSpriteToShow.setVisible(true); } for (const ms of Object.values(spriteCache).flat()) { if (ms) { @@ -814,8 +838,8 @@ export abstract class BattleAnim { const userInitialX = user.x; const userInitialY = user.y; - const targetInitialX = target.x; - const targetInitialY = target.y; + const targetInitialX = targetSubstitute?.sprite?.x ?? target.x; + const targetInitialY = targetSubstitute?.sprite?.y ?? target.y; this.srcLine = [ userFocusX, userFocusY, targetFocusX, targetFocusY ]; this.dstLine = [ userInitialX, userInitialY, targetInitialX, targetInitialY ]; @@ -833,7 +857,7 @@ export abstract class BattleAnim { } const spriteFrames = anim!.frames[f]; // TODO: is the bang correcT? - const frameData = this.getGraphicFrameData(scene, anim!.frames[f]); // TODO: is the bang correct? + const frameData = this.getGraphicFrameData(scene, anim!.frames[f], onSubstitute); // TODO: is the bang correct? let u = 0; let t = 0; let g = 0; @@ -846,24 +870,34 @@ export abstract class BattleAnim { const sprites = spriteCache[isUser ? AnimFrameTarget.USER : AnimFrameTarget.TARGET]; const spriteSource = isUser ? userSprite : targetSprite; if ((isUser ? u : t) === sprites.length) { - const sprite = scene.addPokemonSprite(isUser ? user! : target, 0, 0, spriteSource!.texture, spriteSource!.frame.name, true); // TODO: are those bangs correct? - [ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user! : target).getSprite().pipelineData[k]); // TODO: are those bangs correct? - sprite.setPipelineData("spriteKey", (isUser ? user! : target).getBattleSpriteKey()); - sprite.setPipelineData("shiny", (isUser ? user : target).shiny); - sprite.setPipelineData("variant", (isUser ? user : target).variant); - sprite.setPipelineData("ignoreFieldPos", true); - spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame)); - scene.field.add(sprite); - sprites.push(sprite); + if (!isUser && !!targetSubstitute) { + const sprite = scene.addPokemonSprite(isUser ? user! : target, 0, 0, spriteSource!.texture, spriteSource!.frame.name, true); // TODO: are those bangs correct? + [ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user! : target).getSprite().pipelineData[k]); // TODO: are those bangs correct? + sprite.setPipelineData("spriteKey", (isUser ? user! : target).getBattleSpriteKey()); + sprite.setPipelineData("shiny", (isUser ? user : target).shiny); + sprite.setPipelineData("variant", (isUser ? user : target).variant); + sprite.setPipelineData("ignoreFieldPos", true); + spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame)); + scene.field.add(sprite); + sprites.push(sprite); + } else { + const sprite = scene.addFieldSprite(spriteSource.x, spriteSource.y, spriteSource.texture); + spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame)); + scene.field.add(sprite); + sprites.push(sprite); + } } const spriteIndex = isUser ? u++ : t++; const pokemonSprite = sprites[spriteIndex]; const graphicFrameData = frameData.get(frame.target)!.get(spriteIndex)!; // TODO: are the bangs correct? - pokemonSprite.setPosition(graphicFrameData.x, graphicFrameData.y - ((spriteSource.height / 2) * (spriteSource.parentContainer.scale - 1))); + const spriteSourceScale = (isUser || !targetSubstitute) + ? spriteSource.parentContainer.scale + : target.getSpriteScale() * (target.isPlayer() ? 0.5 : 1); + pokemonSprite.setPosition(graphicFrameData.x, graphicFrameData.y - ((spriteSource.height / 2) * (spriteSourceScale - 1))); pokemonSprite.setAngle(graphicFrameData.angle); - pokemonSprite.setScale(graphicFrameData.scaleX * spriteSource.parentContainer.scale, graphicFrameData.scaleY * spriteSource.parentContainer.scale); + pokemonSprite.setScale(graphicFrameData.scaleX * spriteSourceScale, graphicFrameData.scaleY * spriteSourceScale); pokemonSprite.setData("locked", frame.locked); diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 4685a4fc7e1..a43fa58ba1d 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -22,6 +22,7 @@ import { MovePhase } from "#app/phases/move-phase"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import { StatStageChangePhase, StatStageChangeCallback } from "#app/phases/stat-stage-change-phase"; +import { PokemonAnimType } from "#app/enums/pokemon-anim-type"; export enum BattlerTagLapseType { FAINT, @@ -30,6 +31,7 @@ export enum BattlerTagLapseType { AFTER_MOVE, MOVE_EFFECT, TURN_END, + HIT, CUSTOM } @@ -391,9 +393,11 @@ export class BeakBlastChargingTag extends BattlerTag { 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)) { + if (effectPhase instanceof MoveEffectPhase) { const attacker = effectPhase.getPokemon(); - attacker.trySetStatus(StatusEffect.BURN, true, pokemon); + if (effectPhase.move.getMove().checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)) { + attacker.trySetStatus(StatusEffect.BURN, true, pokemon); + } } return true; } @@ -451,10 +455,14 @@ export class TrappedTag extends BattlerTag { } canAdd(pokemon: Pokemon): boolean { + const source = pokemon.scene.getPokemonById(this.sourceId!)!; + const move = allMoves[this.sourceMove]; + const isGhost = pokemon.isOfType(Type.GHOST); const isTrapped = pokemon.getTag(TrappedTag); + const hasSubstitute = move.hitsSubstitute(source, pokemon); - return !isTrapped && !isGhost; + return !isTrapped && !isGhost && !hasSubstitute; } onAdd(pokemon: Pokemon): void { @@ -1121,7 +1129,7 @@ export abstract class DamagingTrapTag extends TrappedTag { } canAdd(pokemon: Pokemon): boolean { - return !pokemon.getTag(TrappedTag); + return !pokemon.getTag(TrappedTag) && !pokemon.getTag(BattlerTagType.SUBSTITUTE); } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { @@ -2007,7 +2015,6 @@ export class FormBlockDamageTag extends BattlerTag { pokemon.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger); } } - /** Provides the additional weather-based effects of the Ice Face ability */ export class IceFaceBlockDamageTag extends FormBlockDamageTag { constructor(tagType: BattlerTagType) { @@ -2055,7 +2062,6 @@ export class StockpilingTag extends BattlerTag { if (defChange) { this.statChangeCounts[Stat.DEF]++; } - if (spDefChange) { this.statChangeCounts[Stat.SPDEF]++; } @@ -2211,6 +2217,93 @@ export class TarShotTag extends BattlerTag { } } +export class SubstituteTag extends BattlerTag { + /** The substitute's remaining HP. If HP is depleted, the Substitute fades. */ + public hp: number; + /** A reference to the sprite representing the Substitute doll */ + public sprite: Phaser.GameObjects.Sprite; + /** Is the source Pokemon "in focus," i.e. is it fully visible on the field? */ + public sourceInFocus: boolean; + + constructor(sourceMove: Moves, sourceId: integer) { + super(BattlerTagType.SUBSTITUTE, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT], 0, sourceMove, sourceId, true); + } + + /** Sets the Substitute's HP and queues an on-add battle animation that initializes the Substitute's sprite. */ + onAdd(pokemon: Pokemon): void { + this.hp = Math.floor(pokemon.scene.getPokemonById(this.sourceId!)!.getMaxHp() / 4); + this.sourceInFocus = false; + + // Queue battle animation and message + pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_ADD); + pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), 1500); + + // Remove any trapping effects from the user + pokemon.findAndRemoveTags(tag => tag instanceof TrappedTag); + } + + /** Queues an on-remove battle animation that removes the Substitute's sprite. */ + onRemove(pokemon: Pokemon): void { + // Only play the animation if the cause of removal isn't from the source's own move + if (!this.sourceInFocus) { + pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_REMOVE, [this.sprite]); + } else { + this.sprite.destroy(); + } + pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnRemove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + } + + lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + switch (lapseType) { + case BattlerTagLapseType.PRE_MOVE: + this.onPreMove(pokemon); + break; + case BattlerTagLapseType.AFTER_MOVE: + this.onAfterMove(pokemon); + break; + case BattlerTagLapseType.HIT: + this.onHit(pokemon); + break; + } + return lapseType !== BattlerTagLapseType.CUSTOM; // only remove this tag on custom lapse + } + + /** Triggers an animation that brings the Pokemon into focus before it uses a move */ + onPreMove(pokemon: Pokemon): void { + pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_PRE_MOVE, [this.sprite]); + this.sourceInFocus = true; + } + + /** Triggers an animation that brings the Pokemon out of focus after it uses a move */ + onAfterMove(pokemon: Pokemon): void { + pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_POST_MOVE, [this.sprite]); + this.sourceInFocus = false; + } + + /** If the Substitute redirects damage, queue a message to indicate it. */ + onHit(pokemon: Pokemon): void { + const moveEffectPhase = pokemon.scene.getCurrentPhase(); + if (moveEffectPhase instanceof MoveEffectPhase) { + const attacker = moveEffectPhase.getUserPokemon()!; + const move = moveEffectPhase.move.getMove(); + const firstHit = (attacker.turnData.hitCount === attacker.turnData.hitsLeft); + + if (firstHit && move.hitsSubstitute(attacker, pokemon)) { + pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnHit", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + } + } + } + + /** + * When given a battler tag or json representing one, load the data for it. + * @param {BattlerTag | any} source A battler tag + */ + loadTag(source: BattlerTag | any): void { + super.loadTag(source); + this.hp = source.hp; + } +} + /** * Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID. * @@ -2370,6 +2463,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new ThroatChoppedTag(); case BattlerTagType.GORILLA_TACTICS: return new GorillaTacticsTag(); + case BattlerTagType.SUBSTITUTE: + return new SubstituteTag(sourceMove, sourceId); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index 473e2e51f41..1d1a788e768 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1,5 +1,5 @@ import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims"; -import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, ShellTrapTag, StockpilingTag, TrappedTag, TypeBoostTag } from "./battler-tags"; +import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, ShellTrapTag, StockpilingTag, TrappedTag, SubstituteTag, TypeBoostTag } from "./battler-tags"; import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects } from "./status-effect"; @@ -115,9 +115,11 @@ export enum MoveFlags { TRIAGE_MOVE = 1 << 15, IGNORE_ABILITIES = 1 << 16, /** Enables all hits of a multi-hit move to be accuracy checked individually */ - CHECK_ALL_HITS = 1 << 17, + CHECK_ALL_HITS = 1 << 17, + /** Indicates a move is able to bypass its target's Substitute (if the target has one) */ + IGNORE_SUBSTITUTE = 1 << 18, /** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */ - REDIRECT_COUNTER = 1 << 18, + REDIRECT_COUNTER = 1 << 19, } type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean; @@ -333,6 +335,22 @@ export default class Move implements Localizable { return false; } + /** + * Checks if the move would hit its target's Substitute instead of the target itself. + * @param user The {@linkcode Pokemon} using this move + * @param target The {@linkcode Pokemon} targeted by this move + * @returns `true` if the move can bypass the target's Substitute; `false` otherwise. + */ + hitsSubstitute(user: Pokemon, target: Pokemon | null): boolean { + if (this.moveTarget === MoveTarget.USER || !target?.getTag(BattlerTagType.SUBSTITUTE)) { + return false; + } + + return !user.hasAbility(Abilities.INFILTRATOR) + && !this.hasFlag(MoveFlags.SOUND_BASED) + && !this.hasFlag(MoveFlags.IGNORE_SUBSTITUTE); + } + /** * Adds a move condition to the move * @param condition {@linkcode MoveCondition} or {@linkcode MoveConditionFunc}, appends to conditions array a new MoveCondition object @@ -576,6 +594,17 @@ export default class Move implements Localizable { return this; } + /** + * Sets the {@linkcode MoveFlags.IGNORE_SUBSTITUTE} flag for the calling Move + * @param ignoresSubstitute The value (boolean) to set the flag to + * example: @see {@linkcode Moves.WHIRLWIND} + * @returns The {@linkcode Move} that called this function + */ + ignoresSubstitute(ignoresSubstitute: boolean = true): this { + this.setFlag(MoveFlags.IGNORE_SUBSTITUTE, ignoresSubstitute); + return this; + } + /** * Sets the {@linkcode MoveFlags.REDIRECT_COUNTER} flag for the calling Move * @param redirectCounter The value (boolean) to set the flag to @@ -598,7 +627,7 @@ export default class Move implements Localizable { // special cases below, eg: if the move flag is MAKES_CONTACT, and the user pokemon has an ability that ignores contact (like "Long Reach"), then overrides and move does not make contact switch (flag) { case MoveFlags.MAKES_CONTACT: - if (user.hasAbilityWithAttr(IgnoreContactAbAttr)) { + if (user.hasAbilityWithAttr(IgnoreContactAbAttr) || this.hitsSubstitute(user, target)) { return false; } break; @@ -612,8 +641,8 @@ export default class Move implements Localizable { } break; case MoveFlags.IGNORE_PROTECT: - if (user.hasAbilityWithAttr(IgnoreProtectOnContactAbAttr) && - this.checkFlag(MoveFlags.MAKES_CONTACT, user, target)) { + if (user.hasAbilityWithAttr(IgnoreProtectOnContactAbAttr) + && this.checkFlag(MoveFlags.MAKES_CONTACT, user, null)) { return true; } break; @@ -1446,6 +1475,58 @@ export class HalfSacrificialAttr extends MoveEffectAttr { } } +/** + * Attribute to put in a {@link https://bulbapedia.bulbagarden.net/wiki/Substitute_(doll) | Substitute Doll} + * for the user. + * @extends MoveEffectAttr + * @see {@linkcode apply} + */ +export class AddSubstituteAttr extends MoveEffectAttr { + constructor() { + super(true); + } + + /** + * Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user + * @param user the {@linkcode Pokemon} that used the move. + * @param target n/a + * @param move the {@linkcode Move} with this attribute. + * @param args n/a + * @returns true if the attribute successfully applies, false otherwise + */ + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!super.apply(user, target, move, args)) { + return false; + } + + const hpCost = Math.floor(user.getMaxHp() / 4); + user.damageAndUpdate(hpCost, HitResult.OTHER, false, true, true); + user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id); + return true; + } + + getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { + if (user.isBoss()) { + return -10; + } + return 5; + } + + getCondition(): MoveConditionFunc { + return (user, target, move) => !user.getTag(SubstituteTag) && user.hp > Math.floor(user.getMaxHp() / 4) && user.getMaxHp() > 1; + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + if (user.getTag(SubstituteTag)) { + return i18next.t("moveTriggers:substituteOnOverlap", { pokemonName: getPokemonNameWithAffix(user) }); + } else if (user.hp <= Math.floor(user.getMaxHp() / 4) || user.getMaxHp() === 1) { + return i18next.t("moveTriggers:substituteNotEnoughHp"); + } else { + return i18next.t("battle:attackFailed"); + } + } +} + export enum MultiHitType { _2, _2_TO_5, @@ -1949,6 +2030,10 @@ export class StatusEffectAttr extends MoveEffectAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!this.selfTarget && move.hitsSubstitute(user, target)) { + return false; + } + const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance; if (statusCheck) { @@ -2048,6 +2133,9 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { return new Promise(resolve => { + if (move.hitsSubstitute(user, target)) { + return resolve(false); + } const rand = Phaser.Math.RND.realInRange(0, 1); if (rand >= this.chance) { return resolve(false); @@ -2117,6 +2205,10 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { return false; } + if (move.hitsSubstitute(user, target)) { + return false; + } + const cancelled = new Utils.BooleanHolder(false); applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft @@ -2236,6 +2328,9 @@ export class StealEatBerryAttr extends EatBerryAttr { * @returns {boolean} true if the function succeeds */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (move.hitsSubstitute(user, target)) { + return false; + } const cancelled = new Utils.BooleanHolder(false); applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft if (cancelled.value === true) { @@ -2286,6 +2381,10 @@ export class HealStatusEffectAttr extends MoveEffectAttr { return false; } + if (!this.selfTarget && move.hitsSubstitute(user, target)) { + return false; + } + // Special edge case for shield dust blocking Sparkling Aria curing burn const moveTargets = getMoveTargets(user, move.id); if (target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && move.id === Moves.SPARKLING_ARIA && moveTargets.targets.length === 1) { @@ -2463,7 +2562,7 @@ export class ChargeAttr extends OverrideMoveEffectAttr { const lastMove = user.getLastXMoves().find(() => true); 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, () => { + new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => { user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); if (this.tagType) { user.addTag(this.tagType, 1, move.id, user.id); @@ -2563,7 +2662,7 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { return new Promise(resolve => { if (args.length < 2 || !args[1]) { - new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, () => { + new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => { (args[0] as Utils.BooleanHolder).value = true; user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER }); @@ -2597,6 +2696,10 @@ export class StatStageChangeAttr extends MoveEffectAttr { return false; } + if (!this.selfTarget && move.hitsSubstitute(user, target)) { + return false; + } + const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) { const stages = this.getLevels(user); @@ -2793,8 +2896,10 @@ export class ResetStatsAttr extends MoveEffectAttr { activePokemon.forEach(p => promises.push(this.resetStats(p))); target.scene.queueMessage(i18next.t("moveTriggers:statEliminated")); } else { // Affects only the single target when Clear Smog is used - promises.push(this.resetStats(target)); - target.scene.queueMessage(i18next.t("moveTriggers:resetStats", {pokemonName: getPokemonNameWithAffix(target)})); + if (!move.hitsSubstitute(user, target)) { + promises.push(this.resetStats(target)); + target.scene.queueMessage(i18next.t("moveTriggers:resetStats", {pokemonName: getPokemonNameWithAffix(target)})); + } } await Promise.all(promises); @@ -4621,6 +4726,13 @@ export class FlinchAttr extends AddBattlerTagAttr { constructor() { super(BattlerTagType.FLINCHED, false); } + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!move.hitsSubstitute(user, target)) { + return super.apply(user, target, move, args); + } + return false; + } } export class ConfuseAttr extends AddBattlerTagAttr { @@ -4636,7 +4748,10 @@ export class ConfuseAttr extends AddBattlerTagAttr { return false; } - return super.apply(user, target, move, args); + if (!move.hitsSubstitute(user, target)) { + return super.apply(user, target, move, args); + } + return false; } } @@ -4710,6 +4825,36 @@ export class FaintCountdownAttr extends AddBattlerTagAttr { } } +/** + * Attribute to remove all Substitutes from the field. + * @extends MoveEffectAttr + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Tidy_Up_(move) | Tidy Up} + * @see {@linkcode SubstituteTag} + */ +export class RemoveAllSubstitutesAttr extends MoveEffectAttr { + constructor() { + super(true); + } + + /** + * Remove's the Substitute Doll effect from all active Pokemon on the field + * @param user {@linkcode Pokemon} the Pokemon using this move + * @param target n/a + * @param move {@linkcode Move} the move applying this effect + * @param args n/a + * @returns `true` if the effect successfully applies + */ + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!super.apply(user, target, move, args)) { + return false; + } + + user.scene.getField(true).forEach(pokemon => + pokemon.findAndRemoveTags(tag => tag.tagType === BattlerTagType.SUBSTITUTE)); + return true; + } +} + /** * Attribute used when a move hits a {@linkcode BattlerTagType} for double damage * @extends MoveAttr @@ -5099,6 +5244,10 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { const switchOutTarget = (this.user ? user : target); const player = switchOutTarget instanceof PlayerPokemon; + if (!this.user && move.hitsSubstitute(user, target)) { + return false; + } + if (!this.user && move.category === MoveCategory.STATUS && (target.hasAbilityWithAttr(ForceSwitchOutImmunityAbAttr) || target.isMax())) { return false; } @@ -6615,6 +6764,7 @@ export function initMoves() { new StatusMove(Moves.WHIRLWIND, Type.NORMAL, -1, 20, -1, -6, 1) .attr(ForceSwitchOutAttr) .attr(HitsTagAttr, BattlerTagType.FLYING, false) + .ignoresSubstitute() .hidesTarget() .windMove(), new AttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1) @@ -6705,6 +6855,7 @@ export function initMoves() { new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1) .attr(AddBattlerTagAttr, BattlerTagType.DISABLED, false, true) .condition((user, target, move) => target.getMoveHistory().reverse().find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual) !== undefined) + .ignoresSubstitute() .condition(failOnMaxCondition), new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1) .attr(StatStageChangeAttr, [ Stat.SPDEF ], -1) @@ -6842,6 +6993,7 @@ export function initMoves() { .attr(LevelDamageAttr), new StatusMove(Moves.MIMIC, Type.NORMAL, -1, 10, -1, 0, 1) .attr(MovesetCopyMoveAttr) + .ignoresSubstitute() .ignoresVirtual(), new StatusMove(Moves.SCREECH, Type.NORMAL, 85, 40, -1, 0, 1) .attr(StatStageChangeAttr, [ Stat.DEF ], -2) @@ -6870,6 +7022,7 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.LIGHT_SCREEN, 5, true) .target(MoveTarget.USER_SIDE), new SelfStatusMove(Moves.HAZE, Type.ICE, -1, 30, -1, 0, 1) + .ignoresSubstitute() .attr(ResetStatsAttr, true), new StatusMove(Moves.REFLECT, Type.PSYCHIC, -1, 20, -1, 0, 1) .attr(AddArenaTagAttr, ArenaTagType.REFLECT, 5, true) @@ -7012,14 +7165,14 @@ export function initMoves() { .attr(HighCritAttr) .slicingMove(), new SelfStatusMove(Moves.SUBSTITUTE, Type.NORMAL, -1, 10, -1, 0, 1) - .attr(RecoilAttr) - .unimplemented(), + .attr(AddSubstituteAttr), new AttackMove(Moves.STRUGGLE, Type.NORMAL, MoveCategory.PHYSICAL, 50, -1, 1, -1, 0, 1) .attr(RecoilAttr, true, 0.25, true) .attr(TypelessAttr) .ignoresVirtual() .target(MoveTarget.RANDOM_NEAR_ENEMY), new StatusMove(Moves.SKETCH, Type.NORMAL, -1, 1, -1, 0, 2) + .ignoresSubstitute() .attr(SketchAttr) .ignoresVirtual(), new AttackMove(Moves.TRIPLE_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 10, 90, 10, -1, 0, 2) @@ -7045,12 +7198,14 @@ export function initMoves() { .soundBased(), new StatusMove(Moves.CURSE, Type.GHOST, -1, 10, -1, 0, 2) .attr(CurseAttr) + .ignoresSubstitute() .ignoresProtect(true) .target(MoveTarget.CURSE), new AttackMove(Moves.FLAIL, Type.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2) .attr(LowHpPowerAttr), new StatusMove(Moves.CONVERSION_2, Type.NORMAL, -1, 30, -1, 0, 2) .attr(ResistLastMoveTypeAttr) + .ignoresSubstitute() .partial(), // Checks the move's original typing and not if its type is changed through some other means new AttackMove(Moves.AEROBLAST, Type.FLYING, MoveCategory.SPECIAL, 100, 95, 5, -1, 0, 2) .windMove() @@ -7062,6 +7217,7 @@ export function initMoves() { new AttackMove(Moves.REVERSAL, Type.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2) .attr(LowHpPowerAttr), new StatusMove(Moves.SPITE, Type.GHOST, 100, 10, -1, 0, 2) + .ignoresSubstitute() .attr(ReducePpMoveAttr, 4), new AttackMove(Moves.POWDER_SNOW, Type.ICE, MoveCategory.SPECIAL, 40, 100, 25, 10, 0, 2) .attr(StatusEffectAttr, StatusEffect.FREEZE) @@ -7095,7 +7251,8 @@ export function initMoves() { .attr(StatusEffectAttr, StatusEffect.PARALYSIS) .ballBombMove(), new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2) - .attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST), + .attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST) + .ignoresSubstitute(), new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2) .ignoresProtect() .attr(DestinyBondAttr) @@ -7164,6 +7321,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1), new StatusMove(Moves.ATTRACT, Type.NORMAL, 100, 15, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.INFATUATED) + .ignoresSubstitute() .condition((user, target, move) => user.isOppositeGender(target)), new SelfStatusMove(Moves.SLEEP_TALK, Type.NORMAL, -1, 10, -1, 0, 2) .attr(BypassSleepAttr) @@ -7209,6 +7367,7 @@ export function initMoves() { .hidesUser(), new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) + .ignoresSubstitute() .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)), new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) .partial(), @@ -7267,6 +7426,7 @@ export function initMoves() { .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2) .target(MoveTarget.ATTACKER), new StatusMove(Moves.PSYCH_UP, Type.NORMAL, -1, 10, -1, 0, 2) + .ignoresSubstitute() .attr(CopyStatsAttr), new AttackMove(Moves.EXTREME_SPEED, Type.NORMAL, MoveCategory.PHYSICAL, 80, 100, 5, -1, 2, 2), new AttackMove(Moves.ANCIENT_POWER, Type.ROCK, MoveCategory.SPECIAL, 60, 100, 5, 10, 0, 2) @@ -7315,6 +7475,7 @@ export function initMoves() { .attr(WeatherChangeAttr, WeatherType.HAIL) .target(MoveTarget.BOTH_SIDES), new StatusMove(Moves.TORMENT, Type.DARK, 100, 15, -1, 0, 3) + .ignoresSubstitute() .unimplemented(), new StatusMove(Moves.FLATTER, Type.DARK, 100, 15, -1, 0, 3) .attr(StatStageChangeAttr, [ Stat.SPATK ], 1) @@ -7345,13 +7506,16 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPDEF ], 1, true) .attr(AddBattlerTagAttr, BattlerTagType.CHARGED, true, false), new StatusMove(Moves.TAUNT, Type.DARK, 100, 20, -1, 0, 3) + .ignoresSubstitute() .unimplemented(), new StatusMove(Moves.HELPING_HAND, Type.NORMAL, -1, 20, -1, 5, 3) .attr(AddBattlerTagAttr, BattlerTagType.HELPING_HAND) + .ignoresSubstitute() .target(MoveTarget.NEAR_ALLY), new StatusMove(Moves.TRICK, Type.PSYCHIC, 100, 10, -1, 0, 3) .unimplemented(), new StatusMove(Moves.ROLE_PLAY, Type.PSYCHIC, -1, 10, -1, 0, 3) + .ignoresSubstitute() .attr(AbilityCopyAttr), new SelfStatusMove(Moves.WISH, Type.NORMAL, -1, 10, -1, 0, 3) .triageMove() @@ -7384,8 +7548,10 @@ export function initMoves() { .attr(HpPowerAttr) .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(Moves.SKILL_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 3) + .ignoresSubstitute() .attr(SwitchAbilitiesAttr), new SelfStatusMove(Moves.IMPRISON, Type.PSYCHIC, -1, 10, -1, 0, 3) + .ignoresSubstitute() .unimplemented(), new SelfStatusMove(Moves.REFRESH, Type.NORMAL, -1, 20, -1, 0, 3) .attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN) @@ -7470,7 +7636,8 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE), new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3) - .attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST), + .attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST) + .ignoresSubstitute(), new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3) .attr(StatStageChangeAttr, [ Stat.SPD ], -1) .makesContact(false), @@ -7582,7 +7749,8 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5) .target(MoveTarget.BOTH_SIDES), new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4) - .attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK), + .attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK) + .ignoresSubstitute(), new AttackMove(Moves.WAKE_UP_SLAP, Type.FIGHTING, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 4) .attr(MovePowerMultiplierAttr, (user, target, move) => targetSleptOrComatoseCondition(user, target, move) ? 2 : 1) .attr(HealStatusEffectAttr, false, StatusEffect.SLEEP), @@ -7659,6 +7827,7 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.NO_CRIT, 5, true, true) .target(MoveTarget.USER_SIDE), new StatusMove(Moves.ME_FIRST, Type.NORMAL, -1, 20, -1, 0, 4) + .ignoresSubstitute() .ignoresVirtual() .target(MoveTarget.NEAR_ENEMY) .unimplemented(), @@ -7666,9 +7835,11 @@ export function initMoves() { .attr(CopyMoveAttr) .ignoresVirtual(), new StatusMove(Moves.POWER_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) - .attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ]), + .attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ]) + .ignoresSubstitute(), new StatusMove(Moves.GUARD_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) - .attr(SwapStatStagesAttr, [ Stat.DEF, Stat.SPDEF ]), + .attr(SwapStatStagesAttr, [ Stat.DEF, Stat.SPDEF ]) + .ignoresSubstitute(), new AttackMove(Moves.PUNISHMENT, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4) .makesContact(true) .attr(PunishmentPowerAttr), @@ -7682,7 +7853,8 @@ export function initMoves() { .attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES) .target(MoveTarget.ENEMY_SIDE), new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4) - .attr(SwapStatStagesAttr, BATTLE_STATS), + .attr(SwapStatStagesAttr, BATTLE_STATS) + .ignoresSubstitute(), new SelfStatusMove(Moves.AQUA_RING, Type.WATER, -1, 20, -1, 0, 4) .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), new SelfStatusMove(Moves.MAGNET_RISE, Type.ELECTRIC, -1, 10, -1, 0, 4) @@ -7965,6 +8137,7 @@ export function initMoves() { .attr(AbilityGiveAttr), new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5) .ignoresProtect() + .ignoresSubstitute() .target(MoveTarget.NEAR_OTHER) .condition(failIfSingleBattle) .condition((user, target, move) => !target.turnData.acted) @@ -8007,6 +8180,7 @@ export function initMoves() { .partial() // Should immobilize the target, Flying types should take no damage. cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/ .attr(ChargeAttr, ChargeAnim.SKY_DROP_CHARGING, i18next.t("moveTriggers:tookTargetIntoSky", {pokemonName: "{USER}", targetName: "{TARGET}"}), BattlerTagType.FLYING) // TODO: Add 2nd turn message .condition(failOnGravityCondition) + .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE)) .ignoresVirtual(), new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5) .attr(StatStageChangeAttr, [ Stat.ATK ], 1, true) @@ -8021,6 +8195,7 @@ export function initMoves() { new AttackMove(Moves.ACROBATICS, Type.FLYING, MoveCategory.PHYSICAL, 55, 100, 15, -1, 0, 5) .attr(MovePowerMultiplierAttr, (user, target, move) => Math.max(1, 2 - 0.2 * user.getHeldItems().filter(i => i.isTransferrable).reduce((v, m) => v + m.stackCount, 0))), new StatusMove(Moves.REFLECT_TYPE, Type.NORMAL, -1, 15, -1, 0, 5) + .ignoresSubstitute() .attr(CopyTypeAttr), new AttackMove(Moves.RETALIATE, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 5, -1, 0, 5) .attr(MovePowerMultiplierAttr, (user, target, move) => { @@ -8037,6 +8212,7 @@ export function initMoves() { .attr(SacrificialAttrOnHit), new StatusMove(Moves.BESTOW, Type.NORMAL, -1, 15, -1, 0, 5) .ignoresProtect() + .ignoresSubstitute() .unimplemented(), new AttackMove(Moves.INFERNO, Type.FIRE, MoveCategory.SPECIAL, 100, 50, 5, 100, 0, 5) .attr(StatusEffectAttr, StatusEffect.BURN), @@ -8246,13 +8422,15 @@ export function initMoves() { .soundBased() .target(MoveTarget.ALL_NEAR_OTHERS), new StatusMove(Moves.FAIRY_LOCK, Type.FAIRY, -1, 10, -1, 0, 6) + .ignoresSubstitute() .target(MoveTarget.BOTH_SIDES) .unimplemented(), new SelfStatusMove(Moves.KINGS_SHIELD, Type.STEEL, -1, 10, -1, 4, 6) .attr(ProtectAttr, BattlerTagType.KINGS_SHIELD) .condition(failIfLastCondition), new StatusMove(Moves.PLAY_NICE, Type.NORMAL, -1, 20, -1, 0, 6) - .attr(StatStageChangeAttr, [ Stat.ATK ], -1), + .attr(StatStageChangeAttr, [ Stat.ATK ], -1) + .ignoresSubstitute(), new StatusMove(Moves.CONFIDE, Type.NORMAL, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPATK ], -1) .soundBased(), @@ -8265,7 +8443,8 @@ export function initMoves() { .attr(HealStatusEffectAttr, false, StatusEffect.FREEZE) .attr(StatusEffectAttr, StatusEffect.BURN), new AttackMove(Moves.HYPERSPACE_HOLE, Type.PSYCHIC, MoveCategory.SPECIAL, 80, -1, 5, -1, 0, 6) - .ignoresProtect(), + .ignoresProtect() + .ignoresSubstitute(), new AttackMove(Moves.WATER_SHURIKEN, Type.WATER, MoveCategory.SPECIAL, 15, 100, 20, -1, 1, 6) .attr(MultiHitAttr) .attr(WaterShurikenPowerAttr) @@ -8277,6 +8456,7 @@ export function initMoves() { .condition(failIfLastCondition), new StatusMove(Moves.AROMATIC_MIST, Type.FAIRY, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPDEF ], 1) + .ignoresSubstitute() .target(MoveTarget.NEAR_ALLY), new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2), @@ -8284,6 +8464,7 @@ 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) + .ignoresSubstitute() .powderMove() .unimplemented(), new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6) @@ -8292,6 +8473,7 @@ export function initMoves() { .ignoresVirtual(), new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], 1, false, (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS].find(a => target.hasAbility(a, false))) + .ignoresSubstitute() .target(MoveTarget.USER_AND_ALLIES) .condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ Abilities.PLUS, Abilities.MINUS].find(a => p.hasAbility(a, false)))), new StatusMove(Moves.HAPPY_HOUR, Type.NORMAL, -1, 30, -1, 0, 6) // No animation @@ -8304,6 +8486,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new SelfStatusMove(Moves.CELEBRATE, Type.NORMAL, -1, 40, -1, 0, 6), new StatusMove(Moves.HOLD_HANDS, Type.NORMAL, -1, 40, -1, 0, 6) + .ignoresSubstitute() .target(MoveTarget.NEAR_ALLY), new StatusMove(Moves.BABY_DOLL_EYES, Type.FAIRY, 100, 30, -1, 1, 6) .attr(StatStageChangeAttr, [ Stat.ATK ], -1), @@ -8349,6 +8532,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true), new AttackMove(Moves.HYPERSPACE_FURY, Type.DARK, MoveCategory.PHYSICAL, 100, -1, 5, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true) + .ignoresSubstitute() .makesContact(false) .ignoresProtect(), /* Unused */ @@ -8508,6 +8692,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false), new StatusMove(Moves.GEAR_UP, Type.STEEL, -1, 20, -1, 0, 7) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS].find(a => target.hasAbility(a, false))) + .ignoresSubstitute() .target(MoveTarget.USER_AND_ALLIES) .condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ Abilities.PLUS, Abilities.MINUS].find(a => p.hasAbility(a, false)))), new AttackMove(Moves.THROAT_CHOP, Type.DARK, MoveCategory.PHYSICAL, 80, 100, 15, 100, 0, 7) @@ -8538,7 +8723,8 @@ export function initMoves() { user.scene.queueMessage(i18next.t("moveTriggers:burnedItselfOut", {pokemonName: getPokemonNameWithAffix(user)})); }), new StatusMove(Moves.SPEED_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 7) - .attr(SwapStatAttr, Stat.SPD), + .attr(SwapStatAttr, Stat.SPD) + .ignoresSubstitute(), new AttackMove(Moves.SMART_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 7), new StatusMove(Moves.PURIFY, Type.POISON, -1, 20, -1, 0, 7) .condition( @@ -8555,6 +8741,7 @@ export function initMoves() { new AttackMove(Moves.TROP_KICK, Type.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7) .attr(StatStageChangeAttr, [ Stat.ATK ], -1), new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7) + .ignoresSubstitute() .unimplemented(), new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, -3, 7) .attr(BeakBlastHeaderAttr) @@ -8622,6 +8809,7 @@ export function initMoves() { new AttackMove(Moves.PRISMATIC_LASER, Type.PSYCHIC, MoveCategory.SPECIAL, 160, 100, 10, -1, 0, 7) .attr(RechargeAttr), new AttackMove(Moves.SPECTRAL_THIEF, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 7) + .ignoresSubstitute() .partial(), new AttackMove(Moves.SUNSTEEL_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 7) .ignoresAbilities() @@ -9289,7 +9477,8 @@ export function initMoves() { .target(MoveTarget.BOTH_SIDES), new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true, null, true, true) - .attr(RemoveArenaTrapAttr, true), + .attr(RemoveArenaTrapAttr, true) + .attr(RemoveAllSubstitutesAttr), new StatusMove(Moves.SNOWSCAPE, Type.ICE, -1, 10, -1, 0, 9) .attr(WeatherChangeAttr, WeatherType.SNOW) .target(MoveTarget.BOTH_SIDES), diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 105f359df76..657f0d47375 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -65,6 +65,7 @@ export enum BattlerTagType { RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE", ALWAYS_GET_HIT = "ALWAYS_GET_HIT", DISABLED = "DISABLED", + SUBSTITUTE = "SUBSTITUTE", IGNORE_GHOST = "IGNORE_GHOST", IGNORE_DARK = "IGNORE_DARK", GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA", diff --git a/src/enums/pokemon-anim-type.ts b/src/enums/pokemon-anim-type.ts new file mode 100644 index 00000000000..5a0a0c2f622 --- /dev/null +++ b/src/enums/pokemon-anim-type.ts @@ -0,0 +1,16 @@ +export enum PokemonAnimType { + /** + * Adds a Substitute doll to the field in front of a Pokemon. + * The Pokemon then moves "out of focus" and becomes semi-transparent. + */ + SUBSTITUTE_ADD, + /** Brings a Pokemon with a Substitute "into focus" before using a move. */ + SUBSTITUTE_PRE_MOVE, + /** Brings a Pokemon with a Substitute "out of focus" after using a move. */ + SUBSTITUTE_POST_MOVE, + /** + * Removes a Pokemon's Substitute doll from the field. + * The Pokemon then moves back to its original position. + */ + SUBSTITUTE_REMOVE +} diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 566eecbfeb6..c5d8e039f9a 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -17,7 +17,7 @@ import { initMoveAnim, loadMoveAnimAssets } from "../data/battle-anims"; import { Status, StatusEffect, getRandomStatus } from "../data/status-effect"; import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEvolutionCondition, FusionSpeciesFormEvolution } from "../data/pokemon-evolutions"; import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms"; -import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag } from "../data/battler-tags"; +import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag } from "../data/battler-tags"; import { WeatherType } from "../data/weather"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag"; import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr } from "../data/ability"; @@ -58,6 +58,7 @@ import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; import { ToggleDoublePositionPhase } from "#app/phases/toggle-double-position-phase"; import { Challenges } from "#enums/challenges"; +import { PokemonAnimType } from "#app/enums/pokemon-anim-type"; import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; export enum FieldPosition { @@ -566,6 +567,23 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return 1; } + /** Resets the pokemon's field sprite properties, including position, alpha, and scale */ + resetSprite(): void { + // Resetting properties should not be shown on the field + this.setVisible(false); + + // Reset field position + this.setFieldPosition(FieldPosition.CENTER); + if (this.isOffsetBySubstitute()) { + this.x -= this.getSubstituteOffset()[0]; + this.y -= this.getSubstituteOffset()[1]; + } + + // Reset sprite display properties + this.setAlpha(1); + this.setScale(this.getSpriteScale()); + } + getHeldItems(): PokemonHeldItemModifier[] { if (!this.scene) { return []; @@ -640,6 +658,47 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } } + /** + * Returns the Pokemon's offset from its current field position in the event that + * it has a Substitute doll in effect. The offset is returned in `[ x, y ]` format. + * @see {@linkcode SubstituteTag} + * @see {@linkcode getFieldPositionOffset} + */ + getSubstituteOffset(): [ number, number ] { + return this.isPlayer() ? [-30, 10] : [30, -10]; + } + + /** + * Returns whether or not the Pokemon's position on the field is offset because + * the Pokemon has a Substitute active. + * @see {@linkcode SubstituteTag} + */ + isOffsetBySubstitute(): boolean { + const substitute = this.getTag(SubstituteTag); + if (substitute) { + if (substitute.sprite === undefined) { + return false; + } + + // During the Pokemon's MoveEffect phase, the offset is removed to put the Pokemon "in focus" + const currentPhase = this.scene.getCurrentPhase(); + if (currentPhase instanceof MoveEffectPhase && currentPhase.getPokemon() === this) { + return false; + } + return true; + } else { + return false; + } + } + + /** If this Pokemon has a Substitute on the field, removes its sprite from the field. */ + destroySubstitute(): void { + const substitute = this.getTag(SubstituteTag); + if (substitute && substitute.sprite) { + substitute.sprite.destroy(); + } + } + setFieldPosition(fieldPosition: FieldPosition, duration?: integer): Promise { return new Promise(resolve => { if (fieldPosition === this.fieldPosition) { @@ -1414,6 +1473,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyPreDefendAbAttrs(FullHpResistTypeAbAttr, this, source, move, cancelledHolder, simulated, typeMultiplier); } + if (move.category === MoveCategory.STATUS && move.hitsSubstitute(source, this)) { + typeMultiplier.value = 0; + } + return (!cancelledHolder.value ? typeMultiplier.value : 0) as TypeDamageMultiplier; } @@ -2385,6 +2448,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); if (damage.value) { + this.lapseTags(BattlerTagLapseType.HIT); + + const substitute = this.getTag(SubstituteTag); + if (substitute && move.hitsSubstitute(source, this)) { + substitute.hp -= damage.value; + damage.value = 0; + } if (this.isFullHp()) { applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, false, damage); } else if (!this.isPlayer() && damage.value >= this.hp) { @@ -2407,13 +2477,17 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.scene.gameData.gameStats.highestDamage = damage.value; } } - source.turnData.damageDealt += damage.value; - source.turnData.currDamageDealt = damage.value; - this.battleData.hitCount++; - const attackResult = { move: move.id, result: result as DamageResult, damage: damage.value, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() }; - this.turnData.attacksReceived.unshift(attackResult); - if (source.isPlayer() && !this.isPlayer()) { - this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage); + + if (damage.value > 0) { + source.turnData.damageDealt += damage.value; + source.turnData.currDamageDealt = damage.value; + this.battleData.hitCount++; + const attackResult = { move: move.id, result: result as DamageResult, damage: damage.value, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() }; + this.turnData.attacksReceived.unshift(attackResult); + + if (source.isPlayer() && !this.isPlayer()) { + this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage); + } } } @@ -2440,6 +2514,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { // set splice index here, so future scene queues happen before FaintedPhase this.scene.setPhaseQueueSplice(); this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); + this.destroySubstitute(); this.resetSummonData(); } @@ -2452,7 +2527,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (!cancelled.value && typeMultiplier === 0) { this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(this) })); } - result = (typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS; + result = (cancelled.value || typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS; break; } @@ -2499,6 +2574,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { */ this.scene.setPhaseQueueSplice(); this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), preventEndure)); + this.destroySubstitute(); this.resetSummonData(); } @@ -3124,6 +3200,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.summonData[k] = this.summonDataPrimer[k]; } } + // If this Pokemon has a Substitute when loading in, play an animation to add its sprite + if (this.getTag(SubstituteTag)) { + this.scene.triggerPokemonBattleAnim(this, PokemonAnimType.SUBSTITUTE_ADD); + this.getTag(SubstituteTag)!.sourceInFocus = false; + } this.summonDataPrimer = null; } this.updateInfo(); @@ -3503,21 +3584,23 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * info container. */ leaveField(clearEffects: boolean = true, hideInfo: boolean = true) { + this.resetSprite(); this.resetTurnData(); if (clearEffects) { + this.destroySubstitute(); this.resetSummonData(); this.resetBattleData(); } if (hideInfo) { this.hideInfo(); } - this.setVisible(false); this.scene.field.remove(this); this.scene.triggerPokemonFormChange(this, SpeciesFormChangeActiveTrigger, true); } destroy(): void { this.battleInfo?.destroy(); + this.destroySubstitute(); super.destroy(); } diff --git a/src/locales/de/battler-tags.json b/src/locales/de/battler-tags.json index 1a04d3d4486..2f8a8d0c438 100644 --- a/src/locales/de/battler-tags.json +++ b/src/locales/de/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}} hortet {{stockpiledCount}}!", "disabledOnAdd": " {{moveName}} von {{pokemonNameWithAffix}} wurde blockiert!", "disabledLapse": "{{moveName}} von {{pokemonNameWithAffix}} ist nicht länger blockiert!", - "tarShotOnAdd": "{{pokemonNameWithAffix}} ist nun schwach gegenüber Feuer-Attacken!" + "tarShotOnAdd": "{{pokemonNameWithAffix}} ist nun schwach gegenüber Feuer-Attacken!", + "substituteOnAdd": "Ein Delegator von {{pokemonNameWithAffix}} ist erschienen!", + "substituteOnHit": "Der Delegator steckt den Schlag für {{pokemonNameWithAffix}} ein!", + "substituteOnRemove": "Der Delegator von {{pokemonNameWithAffix}} hört auf zu wirken!" } diff --git a/src/locales/en/battler-tags.json b/src/locales/en/battler-tags.json index 5c351fc6961..b31826b0244 100644 --- a/src/locales/en/battler-tags.json +++ b/src/locales/en/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", "disabledOnAdd": "{{pokemonNameWithAffix}}'s {{moveName}}\nwas disabled!", "disabledLapse": "{{pokemonNameWithAffix}}'s {{moveName}}\nis no longer disabled.", - "tarShotOnAdd": "{{pokemonNameWithAffix}} became weaker to fire!" + "tarShotOnAdd": "{{pokemonNameWithAffix}} became weaker to fire!", + "substituteOnAdd": "{{pokemonNameWithAffix}} put in a substitute!", + "substituteOnHit": "The substitute took damage for {{pokemonNameWithAffix}}!", + "substituteOnRemove": "{{pokemonNameWithAffix}}'s substitute faded!" } diff --git a/src/locales/en/move-trigger.json b/src/locales/en/move-trigger.json index 375ea354d33..bc58e2878dd 100644 --- a/src/locales/en/move-trigger.json +++ b/src/locales/en/move-trigger.json @@ -68,5 +68,7 @@ "swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!", "exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!", "safeguard": "{{targetName}} is protected by Safeguard!", + "substituteOnOverlap": "{{pokemonName}} already\nhas a substitute!", + "substituteNotEnoughHp": "But it does not have enough HP\nleft to make a substitute!", "afterYou": "{{pokemonName}} took the kind offer!" -} +} \ No newline at end of file diff --git a/src/locales/es/battler-tags.json b/src/locales/es/battler-tags.json index 49efed6e8b4..bb4f0fe6c8a 100644 --- a/src/locales/es/battler-tags.json +++ b/src/locales/es/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "¡{{pokemonNameWithAffix}} ha reservado energía por {{stockpiledCount}}ª vez!", "disabledOnAdd": "¡Se ha anulado el movimiento {{moveName}}\nde {{pokemonNameWithAffix}}!", "disabledLapse": "¡El movimiento {{moveName}} de {{pokemonNameWithAffix}} ya no está anulado!", - "tarShotOnAdd": "¡{{pokemonNameWithAffix}} se ha vuelto débil ante el fuego!" + "tarShotOnAdd": "¡{{pokemonNameWithAffix}} se ha vuelto débil ante el fuego!", + "substituteOnAdd": "¡{{pokemonNameWithAffix}} creó un sustituto!", + "substituteOnHit": "¡El sustituto recibe daño en lugar del {{pokemonNameWithAffix}}!", + "substituteOnRemove": "¡El sustituto del {{pokemonNameWithAffix}} se debilitó!" } diff --git a/src/locales/fr/battler-tags.json b/src/locales/fr/battler-tags.json index c4a88bb91aa..4c5c7ea0df6 100644 --- a/src/locales/fr/battler-tags.json +++ b/src/locales/fr/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}} utilise\nla capacité Stockage {{stockpiledCount}} fois !", "disabledOnAdd": "La capacité {{moveName}}\nde {{pokemonNameWithAffix}} est mise sous entrave !", "disabledLapse": "La capacité {{moveName}}\nde {{pokemonNameWithAffix}} n’est plus sous entrave !", - "tarShotOnAdd": "{{pokemonNameWithAffix}} est maintenant\nvulnérable au feu !" + "tarShotOnAdd": "{{pokemonNameWithAffix}} est maintenant\nvulnérable au feu !", + "substituteOnAdd": "{{pokemonNameWithAffix}}\ncrée un clone !", + "substituteOnHit": "Le clone subit les dégâts à la place\nde {{pokemonNameWithAffix}} !", + "substituteOnRemove": "Le clone de {{pokemonNameWithAffix}}\ndisparait…" } diff --git a/src/locales/it/battler-tags.json b/src/locales/it/battler-tags.json index bd24f380f9e..6ab69f4efa2 100644 --- a/src/locales/it/battler-tags.json +++ b/src/locales/it/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}} ha usato Accumulo per la\n{{stockpiledCount}}ª volta!", "disabledOnAdd": "La mossa {{moveName}} di\n{{pokemonNameWithAffix}} è stata bloccata!", "disabledLapse": "La mossa {{moveName}} di\n{{pokemonNameWithAffix}} non è più bloccata!", - "tarShotOnAdd": "{{pokemonNameWithAffix}} è diventato vulnerabile\nal tipo Fuoco!" + "tarShotOnAdd": "{{pokemonNameWithAffix}} è diventato vulnerabile\nal tipo Fuoco!", + "substituteOnAdd": "Appare un sostituto di {{pokemonNameWithAffix}}!", + "substituteOnHit": "Il sostituto viene colpito al posto di {{pokemonNameWithAffix}}!", + "substituteOnRemove": "Il sostituto di {{pokemonNameWithAffix}} svanisce!" } diff --git a/src/locales/ko/battler-tags.json b/src/locales/ko/battler-tags.json index 21e548a01a6..1cd6c86377e 100644 --- a/src/locales/ko/battler-tags.json +++ b/src/locales/ko/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!", "disabledOnAdd": "{{pokemonNameWithAffix}}의 {{moveName}}[[는]]\n사용할 수 없다!", "disabledLapse": "{{pokemonNameWithAffix}}의 {{moveName}}[[는]]\n이제 사용할 수 있다.", - "tarShotOnAdd": "{{pokemonNameWithAffix}}[[는]] 불꽃에 약해졌다!" + "tarShotOnAdd": "{{pokemonNameWithAffix}}[[는]] 불꽃에 약해졌다!", + "substituteOnAdd": "{{pokemonNameWithAffix}}의\n대타가 나타났다!", + "substituteOnHit": "{{pokemonNameWithAffix}}[[를]] 대신하여\n대타가 공격을 받았다!", + "substituteOnRemove": "{{pokemonNameWithAffix}}의\n대타는 사라져 버렸다..." } diff --git a/src/locales/pt_BR/battler-tags.json b/src/locales/pt_BR/battler-tags.json index ec6559e12e5..ce645a3d24f 100644 --- a/src/locales/pt_BR/battler-tags.json +++ b/src/locales/pt_BR/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}} estocou {{stockpiledCount}}!", "disabledOnAdd": "{{moveName}} de {{pokemonNameWithAffix}}\nfoi desabilitado!", "disabledLapse": "{{moveName}} de {{pokemonNameWithAffix}}\nnão está mais desabilitado.", - "tarShotOnAdd": "{{pokemonNameWithAffix}} tornou-se mais fraco ao fogo!" + "tarShotOnAdd": "{{pokemonNameWithAffix}} tornou-se mais fraco ao fogo!", + "substituteOnAdd": "{{pokemonNameWithAffix}} colocou um substituto!", + "substituteOnHit": "O substituto tomou o dano pelo {{pokemonNameWithAffix}}!", + "substituteOnRemove": "O substituto de {{pokemonNameWithAffix}} desbotou!" } diff --git a/src/locales/zh_CN/battler-tags.json b/src/locales/zh_CN/battler-tags.json index 7a01f5dff23..a7859380b7a 100644 --- a/src/locales/zh_CN/battler-tags.json +++ b/src/locales/zh_CN/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!", "disabledOnAdd": "封住了{{pokemonNameWithAffix}}的\n{{moveName}}!", "disabledLapse": "{{pokemonNameWithAffix}}的\n定身法解除了!", - "tarShotOnAdd": "{{pokemonNameWithAffix}}\n变得怕火了!" + "tarShotOnAdd": "{{pokemonNameWithAffix}}\n变得怕火了!", + "substituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出现了!", + "substituteOnHit": "替身代替{{pokemonNameWithAffix}}\n承受了攻击!", + "substituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……" } diff --git a/src/locales/zh_TW/battler-tags.json b/src/locales/zh_TW/battler-tags.json index 9653db1077a..49b19f5efdc 100644 --- a/src/locales/zh_TW/battler-tags.json +++ b/src/locales/zh_TW/battler-tags.json @@ -70,5 +70,8 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!", "disabledOnAdd": "封住了{{pokemonNameWithAffix}}的\n{moveName}}!", "disabledLapse": "{{pokemonNameWithAffix}}的\n定身法解除了!", - "tarShotOnAdd": "{{pokemonNameWithAffix}}\n變得怕火了!" + "tarShotOnAdd": "{{pokemonNameWithAffix}}\n變得怕火了!", + "substituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出現了!", + "substituteOnHit": "替身代替{{pokemonNameWithAffix}}承受了攻擊!", + "substituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……" } diff --git a/src/phases/attempt-capture-phase.ts b/src/phases/attempt-capture-phase.ts index cf9ce997bfd..53723526c14 100644 --- a/src/phases/attempt-capture-phase.ts +++ b/src/phases/attempt-capture-phase.ts @@ -15,6 +15,7 @@ import { Mode } from "#app/ui/ui"; import i18next from "i18next"; import { PokemonPhase } from "./pokemon-phase"; import { VictoryPhase } from "./victory-phase"; +import { SubstituteTag } from "#app/data/battler-tags"; export class AttemptCapturePhase extends PokemonPhase { private pokeballType: PokeballType; @@ -36,6 +37,11 @@ export class AttemptCapturePhase extends PokemonPhase { return this.end(); } + const substitute = pokemon.getTag(SubstituteTag); + if (substitute) { + substitute.sprite.setVisible(false); + } + this.scene.pokeballCounts[this.pokeballType]--; this.originalY = pokemon.y; @@ -165,6 +171,11 @@ export class AttemptCapturePhase extends PokemonPhase { pokemon.setVisible(true); pokemon.untint(250, "Sine.easeOut"); + const substitute = pokemon.getTag(SubstituteTag); + if (substitute) { + substitute.sprite.setVisible(true); + } + const pokeballAtlasKey = getPokeballAtlasKey(this.pokeballType); this.pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); this.scene.time.delayedCall(17, () => this.pokeball.setTexture("pb", `${pokeballAtlasKey}_open`)); diff --git a/src/phases/common-anim-phase.ts b/src/phases/common-anim-phase.ts index a85cd7629d9..66299064bb2 100644 --- a/src/phases/common-anim-phase.ts +++ b/src/phases/common-anim-phase.ts @@ -19,7 +19,7 @@ export class CommonAnimPhase extends PokemonPhase { } start() { - new CommonBattleAnim(this.anim, this.getPokemon(), this.targetIndex !== undefined ? (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField())[this.targetIndex] : this.getPokemon()).play(this.scene, () => { + new CommonBattleAnim(this.anim, this.getPokemon(), this.targetIndex !== undefined ? (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField())[this.targetIndex] : this.getPokemon()).play(this.scene, false, () => { this.end(); }); } diff --git a/src/phases/damage-phase.ts b/src/phases/damage-phase.ts index 5add0345358..66b11512729 100644 --- a/src/phases/damage-phase.ts +++ b/src/phases/damage-phase.ts @@ -57,7 +57,7 @@ export class DamagePhase extends PokemonPhase { this.scene.damageNumberHandler.add(this.getPokemon(), this.amount, this.damageResult, this.critical); } - if (this.damageResult !== HitResult.OTHER) { + if (this.damageResult !== HitResult.OTHER && this.amount > 0) { const flashTimer = this.scene.time.addEvent({ delay: 100, repeat: 5, diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 169d667113a..2b63dcdd14b 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -139,7 +139,7 @@ export class FaintPhase extends PokemonPhase { y: pokemon.y + 150, ease: "Sine.easeIn", onComplete: () => { - pokemon.setVisible(false); + pokemon.resetSprite(); pokemon.y -= 150; pokemon.trySetStatus(StatusEffect.FAINT); if (pokemon.isPlayer()) { diff --git a/src/phases/move-anim-test-phase.ts b/src/phases/move-anim-test-phase.ts index 2d3b54bfd9a..a6ab90464b8 100644 --- a/src/phases/move-anim-test-phase.ts +++ b/src/phases/move-anim-test-phase.ts @@ -31,7 +31,9 @@ export class MoveAnimTestPhase extends BattlePhase { initMoveAnim(this.scene, moveId).then(() => { loadMoveAnimAssets(this.scene, [moveId], true) .then(() => { - new MoveAnim(moveId, player ? this.scene.getPlayerPokemon()! : this.scene.getEnemyPokemon()!, (player !== (allMoves[moveId] instanceof SelfStatusMove) ? this.scene.getEnemyPokemon()! : this.scene.getPlayerPokemon()!).getBattlerIndex()).play(this.scene, () => { // TODO: are the bangs correct here? + const user = player ? this.scene.getPlayerPokemon()! : this.scene.getEnemyPokemon()!; + const target = (player !== (allMoves[moveId] instanceof SelfStatusMove)) ? this.scene.getEnemyPokemon()! : this.scene.getPlayerPokemon()!; + new MoveAnim(moveId, user, target.getBattlerIndex()).play(this.scene, allMoves[moveId].hitsSubstitute(user, target), () => { // TODO: are the bangs correct here? if (player) { this.playMoveAnim(moveQueue, false); } else { diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 41fb03c4f4f..e3c8216b65a 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -3,7 +3,7 @@ import { BattlerIndex } from "#app/battle"; import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, applyPostDefendAbAttrs, PostDefendAbAttr, applyPostAttackAbAttrs, PostAttackAbAttr, MaxMultiHitAbAttr, AlwaysHitAbAttr } from "#app/data/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; -import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag } from "#app/data/battler-tags"; +import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr } from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { BattlerTagType } from "#app/enums/battler-tag-type"; @@ -120,7 +120,7 @@ export class MoveEffectPhase extends PokemonPhase { const applyAttrs: Promise[] = []; // Move animation only needs one target - new MoveAnim(move.id as Moves, user, this.getTarget()?.getBattlerIndex()!).play(this.scene, () => { // TODO: is the bang correct here? + new MoveAnim(move.id as Moves, user, this.getTarget()!.getBattlerIndex()).play(this.scene, move.hitsSubstitute(user, this.getTarget()!), () => { /** Has the move successfully hit a target (for damage) yet? */ let hasHit: boolean = false; for (const target of targets) { @@ -246,7 +246,7 @@ export class MoveEffectPhase extends PokemonPhase { * If the move hit, and the target doesn't have Shield Dust, * apply the chance to flinch the target gained from King's Rock */ - if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr)) { + if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) { const flinched = new Utils.BooleanHolder(false); user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); if (flinched.value) { @@ -258,14 +258,19 @@ export class MoveEffectPhase extends PokemonPhase { && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => { // Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them) return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { - // If the invoked move is an enemy attack, apply the enemy's status effect-inflicting tags and tokens + // Only apply the following effects if the move was not deflected by a substitute + if (move.hitsSubstitute(user, target)) { + return resolve(); + } + + // If the invoked move is an enemy attack, apply the enemy's status effect-inflicting tokens + if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { + user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); + } target.lapseTag(BattlerTagType.BEAK_BLAST_CHARGING); if (move.category === MoveCategory.PHYSICAL && user.isPlayer() !== target.isPlayer()) { target.lapseTag(BattlerTagType.SHELL_TRAP); } - if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { - user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); - } })).then(() => { // Apply the user's post-attack ability effects applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => { @@ -306,7 +311,20 @@ export class MoveEffectPhase extends PokemonPhase { } // Wait for all move effects to finish applying, then end this phase - Promise.allSettled(applyAttrs).then(() => this.end()); + Promise.allSettled(applyAttrs).then(() => { + /** + * Remove the target's substitute (if it exists and has expired) + * after all targeted effects have applied. + * This prevents blocked effects from applying until after this hit resolves. + */ + targets.forEach(target => { + const substitute = target.getTag(SubstituteTag); + if (!!substitute && substitute.hp <= 0) { + target.lapseTag(BattlerTagType.SUBSTITUTE); + } + }); + this.end(); + }); }); }); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 6089e7d3202..e63096360dd 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -167,6 +167,7 @@ export class MovePhase extends BattlePhase { 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(); } @@ -186,6 +187,7 @@ export class MovePhase extends BattlePhase { 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(); diff --git a/src/phases/obtain-status-effect-phase.ts b/src/phases/obtain-status-effect-phase.ts index bb06fafb1c9..93bf4cd41d5 100644 --- a/src/phases/obtain-status-effect-phase.ts +++ b/src/phases/obtain-status-effect-phase.ts @@ -31,7 +31,7 @@ export class ObtainStatusEffectPhase extends PokemonPhase { pokemon.status!.cureTurn = this.cureTurn; // TODO: is this bang correct? } pokemon.updateInfo(true); - new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(this.scene, () => { + new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(this.scene, false, () => { this.scene.queueMessage(getStatusEffectObtainText(this.statusEffect, getPokemonNameWithAffix(pokemon), this.sourceText ?? undefined)); if (pokemon.status?.isPostTurn()) { this.scene.pushPhase(new PostTurnStatusEffectPhase(this.scene, this.battlerIndex)); diff --git a/src/phases/pokemon-anim-phase.ts b/src/phases/pokemon-anim-phase.ts new file mode 100644 index 00000000000..50a62837f9c --- /dev/null +++ b/src/phases/pokemon-anim-phase.ts @@ -0,0 +1,237 @@ +import BattleScene from "#app/battle-scene"; +import { SubstituteTag } from "#app/data/battler-tags"; +import { PokemonAnimType } from "#enums/pokemon-anim-type"; +import Pokemon from "#app/field/pokemon"; +import { BattlePhase } from "#app/phases/battle-phase"; + + + +export class PokemonAnimPhase extends BattlePhase { + /** The type of animation to play in this phase */ + private key: PokemonAnimType; + /** The Pokemon to which this animation applies */ + private pokemon: Pokemon; + /** Any other field sprites affected by this animation */ + private fieldAssets: Phaser.GameObjects.Sprite[]; + + constructor(scene: BattleScene, key: PokemonAnimType, pokemon: Pokemon, fieldAssets?: Phaser.GameObjects.Sprite[]) { + super(scene); + + this.key = key; + this.pokemon = pokemon; + this.fieldAssets = fieldAssets ?? []; + } + + start(): void { + super.start(); + + switch (this.key) { + case PokemonAnimType.SUBSTITUTE_ADD: + this.doSubstituteAddAnim(); + break; + case PokemonAnimType.SUBSTITUTE_PRE_MOVE: + this.doSubstitutePreMoveAnim(); + break; + case PokemonAnimType.SUBSTITUTE_POST_MOVE: + this.doSubstitutePostMoveAnim(); + break; + case PokemonAnimType.SUBSTITUTE_REMOVE: + this.doSubstituteRemoveAnim(); + break; + default: + this.end(); + } + } + + doSubstituteAddAnim(): void { + const substitute = this.pokemon.getTag(SubstituteTag); + if (substitute === null) { + return this.end(); + } + + const getSprite = () => { + const sprite = this.scene.addFieldSprite( + this.pokemon.x + this.pokemon.getSprite().x, + this.pokemon.y + this.pokemon.getSprite().y, + `pkmn${this.pokemon.isPlayer() ? "__back": ""}__sub` + ); + sprite.setOrigin(0.5, 1); + this.scene.field.add(sprite); + return sprite; + }; + + const [ subSprite, subTintSprite ] = [ getSprite(), getSprite() ]; + const subScale = this.pokemon.getSpriteScale() * (this.pokemon.isPlayer() ? 0.5 : 1); + + subSprite.setVisible(false); + subSprite.setScale(subScale); + subTintSprite.setTintFill(0xFFFFFF); + subTintSprite.setScale(0.01); + + if (this.pokemon.isPlayer()) { + this.scene.field.bringToTop(this.pokemon); + } + + this.scene.playSound("PRSFX- Transform"); + + this.scene.tweens.add({ + targets: this.pokemon, + duration: 500, + x: this.pokemon.x + this.pokemon.getSubstituteOffset()[0], + y: this.pokemon.y + this.pokemon.getSubstituteOffset()[1], + alpha: 0.5, + ease: "Sine.easeIn" + }); + + this.scene.tweens.add({ + targets: subTintSprite, + delay: 250, + scale: subScale, + ease: "Cubic.easeInOut", + duration: 500, + onComplete: () => { + subSprite.setVisible(true); + this.pokemon.scene.tweens.add({ + targets: subTintSprite, + delay: 250, + alpha: 0, + ease: "Cubic.easeOut", + duration: 1000, + onComplete: () => { + subTintSprite.destroy(); + substitute.sprite = subSprite; + this.end(); + } + }); + } + }); + } + + doSubstitutePreMoveAnim(): void { + if (this.fieldAssets.length !== 1) { + return this.end(); + } + + const subSprite = this.fieldAssets[0]; + if (subSprite === undefined) { + return this.end(); + } + + this.scene.tweens.add({ + targets: subSprite, + alpha: 0, + ease: "Sine.easeInOut", + duration: 500 + }); + + this.scene.tweens.add({ + targets: this.pokemon, + x: subSprite.x, + y: subSprite.y, + alpha: 1, + ease: "Sine.easeInOut", + delay: 250, + duration: 500, + onComplete: () => this.end() + }); + } + + doSubstitutePostMoveAnim(): void { + if (this.fieldAssets.length !== 1) { + return this.end(); + } + + const subSprite = this.fieldAssets[0]; + if (subSprite === undefined) { + return this.end(); + } + + this.scene.tweens.add({ + targets: this.pokemon, + x: subSprite.x + this.pokemon.getSubstituteOffset()[0], + y: subSprite.y + this.pokemon.getSubstituteOffset()[1], + alpha: 0.5, + ease: "Sine.easeInOut", + duration: 500 + }); + + this.scene.tweens.add({ + targets: subSprite, + alpha: 1, + ease: "Sine.easeInOut", + delay: 250, + duration: 500, + onComplete: () => this.end() + }); + } + + doSubstituteRemoveAnim(): void { + if (this.fieldAssets.length !== 1) { + return this.end(); + } + + const subSprite = this.fieldAssets[0]; + if (subSprite === undefined) { + return this.end(); + } + + const getSprite = () => { + const sprite = this.scene.addFieldSprite( + subSprite.x, + subSprite.y, + `pkmn${this.pokemon.isPlayer() ? "__back": ""}__sub` + ); + sprite.setOrigin(0.5, 1); + this.scene.field.add(sprite); + return sprite; + }; + + const subTintSprite = getSprite(); + const subScale = this.pokemon.getSpriteScale() * (this.pokemon.isPlayer() ? 0.5 : 1); + subTintSprite.setAlpha(0); + subTintSprite.setTintFill(0xFFFFFF); + subTintSprite.setScale(subScale); + + this.scene.tweens.add({ + targets: subTintSprite, + alpha: 1, + ease: "Sine.easeInOut", + duration: 500, + onComplete: () => { + subSprite.destroy(); + const flashTimer = this.scene.time.addEvent({ + delay: 100, + repeat: 7, + startAt: 200, + callback: () => { + this.scene.playSound("PRSFX- Substitute2.wav"); + + subTintSprite.setVisible(flashTimer.repeatCount % 2 === 0); + if (!flashTimer.repeatCount) { + this.scene.tweens.add({ + targets: subTintSprite, + scale: 0.01, + ease: "Sine.cubicEaseIn", + duration: 500 + }); + + this.scene.tweens.add({ + targets: this.pokemon, + x: this.pokemon.x - this.pokemon.getSubstituteOffset()[0], + y: this.pokemon.y - this.pokemon.getSubstituteOffset()[1], + alpha: 1, + ease: "Sine.easeInOut", + delay: 250, + duration: 500, + onComplete: () => { + subTintSprite.destroy(); + this.end(); + } + }); + } + } + }); + } + }); + } +} diff --git a/src/phases/post-turn-status-effect-phase.ts b/src/phases/post-turn-status-effect-phase.ts index 413f9eae65e..285bbddde88 100644 --- a/src/phases/post-turn-status-effect-phase.ts +++ b/src/phases/post-turn-status-effect-phase.ts @@ -42,7 +42,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase { this.scene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true)); pokemon.updateInfo(); } - new CommonBattleAnim(CommonAnim.POISON + (pokemon.status.effect - 1), pokemon).play(this.scene, () => this.end()); + new CommonBattleAnim(CommonAnim.POISON + (pokemon.status.effect - 1), pokemon).play(this.scene, false, () => this.end()); } else { this.end(); } diff --git a/src/phases/return-phase.ts b/src/phases/return-phase.ts index dfc458eb817..19c73816b36 100644 --- a/src/phases/return-phase.ts +++ b/src/phases/return-phase.ts @@ -16,6 +16,7 @@ export class ReturnPhase extends SwitchSummonPhase { onEnd(): void { const pokemon = this.getPokemon(); + pokemon.resetSprite(); pokemon.resetTurnData(); pokemon.resetSummonData(); diff --git a/src/phases/scan-ivs-phase.ts b/src/phases/scan-ivs-phase.ts index ba27e4f1943..5ec61d5eec6 100644 --- a/src/phases/scan-ivs-phase.ts +++ b/src/phases/scan-ivs-phase.ts @@ -53,7 +53,7 @@ export class ScanIvsPhase extends PokemonPhase { this.scene.ui.setMode(Mode.CONFIRM, () => { this.scene.ui.setMode(Mode.MESSAGE); this.scene.ui.clearText(); - new CommonBattleAnim(CommonAnim.LOCK_ON, pokemon, pokemon).play(this.scene, () => { + new CommonBattleAnim(CommonAnim.LOCK_ON, pokemon, pokemon).play(this.scene, false, () => { this.scene.ui.getMessageHandler().promptIvs(pokemon.id, pokemon.ivs, this.shownIvs).then(() => this.end()); }); }, () => { diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index 2a5fd0cc3ac..525f74e896f 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -11,6 +11,7 @@ import { Command } from "#app/ui/command-ui-handler"; import i18next from "i18next"; import { PostSummonPhase } from "./post-summon-phase"; import { SummonPhase } from "./summon-phase"; +import { SubstituteTag } from "#app/data/battler-tags"; export class SwitchSummonPhase extends SummonPhase { private slotIndex: integer; @@ -65,6 +66,16 @@ export class SwitchSummonPhase extends SummonPhase { if (!this.batonPass) { (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField()).forEach(enemyPokemon => enemyPokemon.removeTagsBySourceId(pokemon.id)); + const substitute = pokemon.getTag(SubstituteTag); + if (substitute) { + this.scene.tweens.add({ + targets: substitute.sprite, + duration: 250, + scale: substitute.sprite.scale * 0.5, + ease: "Sine.easeIn", + onComplete: () => substitute.sprite.destroy() + }); + } } this.scene.ui.showText(this.player ? @@ -115,8 +126,18 @@ export class SwitchSummonPhase extends SummonPhase { pokemonName: this.getPokemon().getNameToRender() }) ); - // Ensure improperly persisted summon data (such as tags) is cleared upon switching - if (!this.batonPass) { + /** + * If this switch is passing a Substitute, make the switched Pokemon match the returned Pokemon's state as it left. + * Otherwise, clear any persisting tags on the returned Pokemon. + */ + if (this.batonPass) { + const substitute = this.lastPokemon.getTag(SubstituteTag); + if (substitute) { + switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0]; + switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1]; + switchedInPokemon.setAlpha(0.5); + } + } else { switchedInPokemon.resetBattleData(); switchedInPokemon.resetSummonData(); } diff --git a/src/test/abilities/unseen_fist.test.ts b/src/test/abilities/unseen_fist.test.ts index ea1996ec66b..813880c7326 100644 --- a/src/test/abilities/unseen_fist.test.ts +++ b/src/test/abilities/unseen_fist.test.ts @@ -4,7 +4,9 @@ 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, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { BerryPhase } from "#app/phases/berry-phase"; const TIMEOUT = 20 * 1000; @@ -32,37 +34,57 @@ describe("Abilities - Unseen Fist", () => { game.override.enemyLevel(100); }); - test( - "ability causes a contact move to ignore Protect", + it( + "should cause a contact move to ignore Protect", () => testUnseenFistHitResult(game, Moves.QUICK_ATTACK, Moves.PROTECT, true), TIMEOUT ); - test( - "ability does not cause a non-contact move to ignore Protect", + it( + "should not cause a non-contact move to ignore Protect", () => testUnseenFistHitResult(game, Moves.ABSORB, Moves.PROTECT, false), TIMEOUT ); - test( - "ability does not apply if the source has Long Reach", + it( + "should not apply if the source has Long Reach", () => { game.override.passiveAbility(Abilities.LONG_REACH); testUnseenFistHitResult(game, Moves.QUICK_ATTACK, Moves.PROTECT, false); }, TIMEOUT ); - test( - "ability causes a contact move to ignore Wide Guard", + it( + "should cause a contact move to ignore Wide Guard", () => testUnseenFistHitResult(game, Moves.BREAKING_SWIPE, Moves.WIDE_GUARD, true), TIMEOUT ); - test( - "ability does not cause a non-contact move to ignore Wide Guard", + it( + "should not cause a non-contact move to ignore Wide Guard", () => testUnseenFistHitResult(game, Moves.BULLDOZE, Moves.WIDE_GUARD, false), TIMEOUT ); + + it( + "should cause a contact move to ignore Protect, but not Substitute", + async () => { + game.override.enemyLevel(1); + game.override.moveset([Moves.TACKLE]); + + await game.startBattle(); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + enemyPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, enemyPokemon.id); + + game.move.select(Moves.TACKLE); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(enemyPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeUndefined(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); }); async function testUnseenFistHitResult(game: GameManager, attackMove: Moves, protectMove: Moves, shouldSucceed: boolean = true): Promise { diff --git a/src/test/battlerTags/substitute.test.ts b/src/test/battlerTags/substitute.test.ts new file mode 100644 index 00000000000..1ce81850c13 --- /dev/null +++ b/src/test/battlerTags/substitute.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Pokemon, { MoveResult, PokemonTurnData, TurnMove, PokemonMove } from "#app/field/pokemon"; +import BattleScene from "#app/battle-scene"; +import { BattlerTagLapseType, SubstituteTag, TrappedTag } from "#app/data/battler-tags"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { Moves } from "#app/enums/moves"; +import { PokemonAnimType } from "#app/enums/pokemon-anim-type"; +import * as messages from "#app/messages"; +import { allMoves } from "#app/data/move"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; + +vi.mock("#app/battle-scene.js"); + +const TIMEOUT = 5 * 1000; // 5 sec timeout + +describe("BattlerTag - SubstituteTag", () => { + let mockPokemon: Pokemon; + + describe("onAdd behavior", () => { + beforeEach(() => { + mockPokemon = { + scene: new BattleScene(), + hp: 101, + id: 0, + getMaxHp: vi.fn().mockReturnValue(101) as Pokemon["getMaxHp"], + findAndRemoveTags: vi.fn().mockImplementation((tagFilter) => { + // simulate a Trapped tag set by another Pokemon, then expect the filter to catch it. + const trapTag = new TrappedTag(BattlerTagType.TRAPPED, BattlerTagLapseType.CUSTOM, 0, Moves.NONE, 1); + expect(tagFilter(trapTag)).toBeTruthy(); + return true; + }) as Pokemon["findAndRemoveTags"] + } as Pokemon; + + vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue(""); + vi.spyOn(mockPokemon.scene, "getPokemonById").mockImplementation(pokemonId => mockPokemon.id === pokemonId ? mockPokemon : null); + }); + + it( + "sets the tag's HP to 1/4 of the source's max HP (rounded down)", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true); + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + subject.onAdd(mockPokemon); + + expect(subject.hp).toBe(25); + }, TIMEOUT + ); + + it( + "triggers on-add effects that bring the source out of focus", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation( + (pokemon, battleAnimType, fieldAssets?, delayed?) => { + expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_ADD); + return true; + } + ); + + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + subject.onAdd(mockPokemon); + + expect(subject.sourceInFocus).toBeFalsy(); + expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1); + expect(mockPokemon.scene.queueMessage).toHaveBeenCalledTimes(1); + }, TIMEOUT + ); + + it( + "removes effects that trap the source", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + subject.onAdd(mockPokemon); + expect(mockPokemon.findAndRemoveTags).toHaveBeenCalledTimes(1); + }, TIMEOUT + ); + }); + + describe("onRemove behavior", () => { + beforeEach(() => { + mockPokemon = { + scene: new BattleScene(), + hp: 101, + id: 0, + isFainted: vi.fn().mockReturnValue(false) as Pokemon["isFainted"] + } as Pokemon; + + vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue(""); + }); + + it( + "triggers on-remove animation and message", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + subject.sourceInFocus = false; + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation( + (pokemon, battleAnimType, fieldAssets?, delayed?) => { + expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_REMOVE); + return true; + } + ); + + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + subject.onRemove(mockPokemon); + + expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1); + expect(mockPokemon.scene.queueMessage).toHaveBeenCalledTimes(1); + }, TIMEOUT + ); + }); + + describe("lapse behavior", () => { + beforeEach(() => { + mockPokemon = { + scene: new BattleScene(), + hp: 101, + id: 0, + turnData: {acted: true} as PokemonTurnData, + getLastXMoves: vi.fn().mockReturnValue([{move: Moves.TACKLE, result: MoveResult.SUCCESS} as TurnMove]) as Pokemon["getLastXMoves"], + } as Pokemon; + + vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue(""); + }); + + it( + "PRE_MOVE lapse triggers pre-move animation", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation( + (pokemon, battleAnimType, fieldAssets?, delayed?) => { + expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_PRE_MOVE); + return true; + } + ); + + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + expect(subject.lapse(mockPokemon, BattlerTagLapseType.PRE_MOVE)).toBeTruthy(); + + expect(subject.sourceInFocus).toBeTruthy(); + expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1); + expect(mockPokemon.scene.queueMessage).not.toHaveBeenCalled(); + }, TIMEOUT + ); + + it( + "AFTER_MOVE lapse triggers post-move animation", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation( + (pokemon, battleAnimType, fieldAssets?, delayed?) => { + expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_POST_MOVE); + return true; + } + ); + + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + expect(subject.lapse(mockPokemon, BattlerTagLapseType.AFTER_MOVE)).toBeTruthy(); + + expect(subject.sourceInFocus).toBeFalsy(); + expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1); + expect(mockPokemon.scene.queueMessage).not.toHaveBeenCalled(); + }, TIMEOUT + ); + + /** TODO: Figure out how to mock a MoveEffectPhase correctly for this test */ + it.skip( + "HIT lapse triggers on-hit message", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true); + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + const pokemonMove = { + getMove: vi.fn().mockReturnValue(allMoves[Moves.TACKLE]) as PokemonMove["getMove"] + } as PokemonMove; + + const moveEffectPhase = { + move: pokemonMove, + getUserPokemon: vi.fn().mockReturnValue(undefined) as MoveEffectPhase["getUserPokemon"] + } as MoveEffectPhase; + + vi.spyOn(mockPokemon.scene, "getCurrentPhase").mockReturnValue(moveEffectPhase); + vi.spyOn(allMoves[Moves.TACKLE], "hitsSubstitute").mockReturnValue(true); + + expect(subject.lapse(mockPokemon, BattlerTagLapseType.HIT)).toBeTruthy(); + + expect(mockPokemon.scene.triggerPokemonBattleAnim).not.toHaveBeenCalled(); + expect(mockPokemon.scene.queueMessage).toHaveBeenCalledTimes(1); + }, TIMEOUT + ); + + it( + "CUSTOM lapse flags the tag for removal", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true); + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + expect(subject.lapse(mockPokemon, BattlerTagLapseType.CUSTOM)).toBeFalsy(); + }, TIMEOUT + ); + + it( + "Unsupported lapse type does nothing", + async () => { + const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id); + + vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true); + vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue(); + + expect(subject.lapse(mockPokemon, BattlerTagLapseType.TURN_END)).toBeTruthy(); + + expect(mockPokemon.scene.triggerPokemonBattleAnim).not.toHaveBeenCalled(); + expect(mockPokemon.scene.queueMessage).not.toHaveBeenCalled(); + } + ); + }); +}); diff --git a/src/test/moves/substitute.test.ts b/src/test/moves/substitute.test.ts new file mode 100644 index 00000000000..3976247d489 --- /dev/null +++ b/src/test/moves/substitute.test.ts @@ -0,0 +1,515 @@ +import { SubstituteTag, TrappedTag } from "#app/data/battler-tags"; +import { allMoves, StealHeldItemChanceAttr } from "#app/data/move"; +import { StatusEffect } from "#app/data/status-effect"; +import { Abilities } from "#app/enums/abilities"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { BerryType } from "#app/enums/berry-type"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import { Stat } from "#app/enums/stat"; +import { MoveResult } from "#app/field/pokemon"; +import { CommandPhase } from "#app/phases/command-phase"; +import GameManager from "#app/test/utils/gameManager"; +import { Command } from "#app/ui/command-ui-handler"; +import { Mode } from "#app/ui/ui"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + + +const TIMEOUT = 20 * 1000; // 20 sec timeout + +describe("Moves - Substitute", () => { + 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") + .moveset([Moves.SUBSTITUTE, Moves.SWORDS_DANCE, Moves.TACKLE, Moves.SPLASH]) + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.INSOMNIA) + .enemyMoveset(Moves.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it( + "should cause the user to take damage", + async () => { + await game.classicMode.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() * 3/4)); + }, TIMEOUT + ); + + it( + "should redirect enemy attack damage to the Substitute doll", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + + await game.classicMode.startBattle([Species.SKARMORY]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() * 3/4)); + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + const postSubHp = leadPokemon.hp; + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.hp).toBe(postSubHp); + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + }, TIMEOUT + ); + + it( + "should fade after redirecting more damage than its remaining HP", + async () => { + // Giga Impact OHKOs Magikarp if substitute isn't up + game.override.enemyMoveset(Array(4).fill(Moves.GIGA_IMPACT)); + vi.spyOn(allMoves[Moves.GIGA_IMPACT], "accuracy", "get").mockReturnValue(100); + + await game.classicMode.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() * 3/4)); + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + const postSubHp = leadPokemon.hp; + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.hp).toBe(postSubHp); + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeUndefined(); + }, TIMEOUT + ); + + it( + "should block stat changes from status moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.CHARM)); + + await game.classicMode.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + } + ); + + it( + "should be bypassed by sound-based moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.ECHOED_VOICE)); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + const postSubHp = leadPokemon.hp; + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + expect(leadPokemon.hp).toBeLessThan(postSubHp); + }, TIMEOUT + ); + + it( + "should be bypassed by attackers with Infiltrator", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.enemyAbility(Abilities.INFILTRATOR); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + const postSubHp = leadPokemon.hp; + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + expect(leadPokemon.hp).toBeLessThan(postSubHp); + }, TIMEOUT + ); + + it( + "shouldn't block the user's own status moves", + async () => { + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("MoveEndPhase"); + await game.toNextTurn(); + + game.move.select(Moves.SWORDS_DANCE); + + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); + }, TIMEOUT + ); + + it( + "should protect the user from flinching", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.FAKE_OUT)); + game.override.startingLevel(1); // Ensures the Substitute will break + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.TACKLE); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should protect the user from being trapped", + async () => { + vi.spyOn(allMoves[Moves.SAND_TOMB], "accuracy", "get").mockReturnValue(100); + game.override.enemyMoveset(Array(4).fill(Moves.SAND_TOMB)); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getTag(TrappedTag)).toBeUndefined(); + }, TIMEOUT + ); + + it( + "should prevent the user's stats from being lowered", + async () => { + vi.spyOn(allMoves[Moves.LIQUIDATION], "chance", "get").mockReturnValue(100); + game.override.enemyMoveset(Array(4).fill(Moves.LIQUIDATION)); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getStatStage(Stat.DEF)).toBe(0); + }, TIMEOUT + ); + + it( + "should protect the user from being afflicted with status effects", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.NUZZLE)); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.status?.effect).not.toBe(StatusEffect.PARALYSIS); + }, TIMEOUT + ); + + it( + "should prevent the user's items from being stolen", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.THIEF)); + vi.spyOn(allMoves[Moves.THIEF], "attrs", "get").mockReturnValue([new StealHeldItemChanceAttr(1.0)]); // give Thief 100% steal rate + game.override.startingHeldItems([{name: "BERRY", type: BerryType.SITRUS}]); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getHeldItems().length).toBe(1); + }, TIMEOUT + ); + + it( + "should prevent the user's items from being removed", + async () => { + game.override.moveset([Moves.KNOCK_OFF]); + game.override.enemyHeldItems([{name: "BERRY", type: BerryType.SITRUS}]); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + enemyPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, enemyPokemon.id); + const enemyNumItems = enemyPokemon.getHeldItems().length; + + game.move.select(Moves.KNOCK_OFF); + + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(enemyPokemon.getHeldItems().length).toBe(enemyNumItems); + }, TIMEOUT + ); + + it( + "move effect should prevent the user's berries from being stolen and eaten", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.BUG_BITE)); + game.override.startingHeldItems([{name: "BERRY", type: BerryType.SITRUS}]); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.TACKLE); + + await game.phaseInterceptor.to("MoveEndPhase", false); + const enemyPostAttackHp = enemyPokemon.hp; + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getHeldItems().length).toBe(1); + expect(enemyPokemon.hp).toBe(enemyPostAttackHp); + }, TIMEOUT + ); + + it( + "should prevent the user's stats from being reset by Clear Smog", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.CLEAR_SMOG)); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.SWORDS_DANCE); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); + }, TIMEOUT + ); + + it( + "should prevent the user from becoming confused", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.MAGICAL_TORQUE)); + vi.spyOn(allMoves[Moves.MAGICAL_TORQUE], "chance", "get").mockReturnValue(100); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + game.move.select(Moves.SWORDS_DANCE); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(leadPokemon.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); + } + ); + + it( + "should transfer to the switched in Pokemon when the source uses Baton Pass", + async () => { + game.override.moveset([Moves.SUBSTITUTE, Moves.BATON_PASS]); + + await game.classicMode.startBattle([Species.BLASTOISE, Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id); + + // Simulate a Baton switch for the player this turn + game.onNextPrompt("CommandPhase", Mode.COMMAND, () => { + (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.POKEMON, 1, true); + }); + + await game.phaseInterceptor.to("MovePhase", false); + + const switchedPokemon = game.scene.getPlayerPokemon()!; + const subTag = switchedPokemon.getTag(SubstituteTag)!; + expect(subTag).toBeDefined(); + expect(subTag.hp).toBe(Math.floor(leadPokemon.getMaxHp() * 1/4)); + }, TIMEOUT + ); + + it( + "should prevent the source's Rough Skin from activating when hit", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.ability(Abilities.ROUGH_SKIN); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SUBSTITUTE); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should prevent the source's Focus Punch from failing when hit", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.moveset([Moves.FOCUS_PUNCH]); + + // Make Focus Punch 40 power to avoid a KO + vi.spyOn(allMoves[Moves.FOCUS_PUNCH], "calculateBattlePower").mockReturnValue(40); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id); + + game.move.select(Moves.FOCUS_PUNCH); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should not allow Shell Trap to activate when attacked", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.moveset([Moves.SHELL_TRAP]); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id); + + game.move.select(Moves.SHELL_TRAP); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }, TIMEOUT + ); + + it( + "should not allow Beak Blast to burn opponents when hit", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.moveset([Moves.BEAK_BLAST]); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id); + + game.move.select(Moves.BEAK_BLAST); + + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(enemyPokemon.status?.effect).not.toBe(StatusEffect.BURN); + }, TIMEOUT + ); + + it( + "should cause incoming attacks to not activate Counter", + async() => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.moveset([Moves.COUNTER]); + + await game.classicMode.startBattle([Species.BLASTOISE]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id); + + game.move.select(Moves.COUNTER); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + } + ); +}); diff --git a/src/test/moves/tidy_up.test.ts b/src/test/moves/tidy_up.test.ts index 255fe948447..8a3a0f3be76 100644 --- a/src/test/moves/tidy_up.test.ts +++ b/src/test/moves/tidy_up.test.ts @@ -8,6 +8,7 @@ import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { SubstituteTag } from "#app/data/battler-tags"; describe("Moves - Tidy Up", () => { @@ -39,7 +40,7 @@ describe("Moves - Tidy Up", () => { it("spikes are cleared", async () => { game.override.moveset([Moves.SPIKES, Moves.TIDY_UP]); game.override.enemyMoveset([Moves.SPIKES, Moves.SPIKES, Moves.SPIKES, Moves.SPIKES]); - await game.startBattle(); + await game.classicMode.startBattle(); game.move.select(Moves.SPIKES); await game.phaseInterceptor.to(TurnEndPhase); @@ -52,7 +53,7 @@ describe("Moves - Tidy Up", () => { it("stealth rocks are cleared", async () => { game.override.moveset([Moves.STEALTH_ROCK, Moves.TIDY_UP]); game.override.enemyMoveset([Moves.STEALTH_ROCK, Moves.STEALTH_ROCK, Moves.STEALTH_ROCK, Moves.STEALTH_ROCK]); - await game.startBattle(); + await game.classicMode.startBattle(); game.move.select(Moves.STEALTH_ROCK); await game.phaseInterceptor.to(TurnEndPhase); @@ -64,7 +65,7 @@ describe("Moves - Tidy Up", () => { it("toxic spikes are cleared", async () => { game.override.moveset([Moves.TOXIC_SPIKES, Moves.TIDY_UP]); game.override.enemyMoveset([Moves.TOXIC_SPIKES, Moves.TOXIC_SPIKES, Moves.TOXIC_SPIKES, Moves.TOXIC_SPIKES]); - await game.startBattle(); + await game.classicMode.startBattle(); game.move.select(Moves.TOXIC_SPIKES); await game.phaseInterceptor.to(TurnEndPhase); @@ -77,7 +78,7 @@ describe("Moves - Tidy Up", () => { game.override.moveset([Moves.STICKY_WEB, Moves.TIDY_UP]); game.override.enemyMoveset([Moves.STICKY_WEB, Moves.STICKY_WEB, Moves.STICKY_WEB, Moves.STICKY_WEB]); - await game.startBattle(); + await game.classicMode.startBattle(); game.move.select(Moves.STICKY_WEB); await game.phaseInterceptor.to(TurnEndPhase); @@ -86,21 +87,26 @@ describe("Moves - Tidy Up", () => { expect(game.scene.arena.getTag(ArenaTagType.STICKY_WEB)).toBeUndefined(); }, 20000); - it.skip("substitutes are cleared", async () => { + it("substitutes are cleared", async () => { game.override.moveset([Moves.SUBSTITUTE, Moves.TIDY_UP]); game.override.enemyMoveset([Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE]); - await game.startBattle(); + await game.classicMode.startBattle(); game.move.select(Moves.SUBSTITUTE); await game.phaseInterceptor.to(TurnEndPhase); game.move.select(Moves.TIDY_UP); await game.phaseInterceptor.to(MoveEndPhase); - // TODO: check for subs here once the move is implemented + + const pokemon = [ game.scene.getPlayerPokemon()!, game.scene.getEnemyPokemon()! ]; + pokemon.forEach(p => { + expect(p).toBeDefined(); + expect(p!.getTag(SubstituteTag)).toBeUndefined(); + }); }, 20000); it("user's stats are raised with no traps set", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; diff --git a/src/ui/target-select-ui-handler.ts b/src/ui/target-select-ui-handler.ts index 6ca580dc2b2..3cdda984d3c 100644 --- a/src/ui/target-select-ui-handler.ts +++ b/src/ui/target-select-ui-handler.ts @@ -8,6 +8,7 @@ import {Button} from "#enums/buttons"; import { Moves } from "#enums/moves"; import Pokemon from "#app/field/pokemon"; import { ModifierBar } from "#app/modifier/modifier"; +import { SubstituteTag } from "#app/data/battler-tags"; export type TargetSelectCallback = (targets: BattlerIndex[]) => void; @@ -111,7 +112,7 @@ export default class TargetSelectUiHandler extends UiHandler { if (this.targetFlashTween) { this.targetFlashTween.stop(); for (const pokemon of multipleTargets) { - pokemon.setAlpha(1); + pokemon.setAlpha(!!pokemon.getTag(SubstituteTag) ? 0.5 : 1); this.highlightItems(pokemon.id, 1); } } @@ -162,7 +163,7 @@ export default class TargetSelectUiHandler extends UiHandler { } for (const pokemon of this.targetsHighlighted) { - pokemon.setAlpha(1); + pokemon.setAlpha(!!pokemon.getTag(SubstituteTag) ? 0.5 : 1); this.highlightItems(pokemon.id, 1); }