diff --git a/src/data/ability.ts b/src/data/ability.ts index 65da3753cde..131b7d0ff7a 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -6633,7 +6633,7 @@ export function initAbilities() { .bypassFaint(), new Ability(Abilities.CORROSION, 7) .attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ Type.STEEL, Type.POISON ]) - .edgeCase(), // Should interact correctly with magic coat/bounce (not yet implemented) + fling with toxic orb (not implemented yet) + .edgeCase(), // Should poison itself with toxic orb. new Ability(Abilities.COMATOSE, 7) .attr(UncopiableAbilityAbAttr) .attr(UnswappableAbilityAbAttr) diff --git a/src/data/move.ts b/src/data/move.ts index 658534eb48f..30c5ef75491 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -692,19 +692,17 @@ export default class Move implements Localizable { /** * Sees if a move has a custom failure text (by looking at each {@linkcode MoveAttr} of this move) * @param user {@linkcode Pokemon} using the move - * @param target {@linkcode Pokemon} receiving the move - * @param move {@linkcode Move} using the move - * @param cancelled {@linkcode Utils.BooleanHolder} to hold boolean value + * @param target {@linkcode Pokemon} target of the move + * @param move {@linkcode Move} with this attribute * @returns string of the custom failure text, or `null` if it uses the default text ("But it failed!") */ - getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined { for (const attr of this.attrs) { - const failedText = attr.getFailedText(user, target, move, cancelled); - if (failedText !== null) { + const failedText = attr.getFailedText(user, target, move); + if (failedText) { return failedText; } } - return null; } /** @@ -1089,11 +1087,10 @@ export abstract class MoveAttr { * @param user {@linkcode Pokemon} using the move * @param target {@linkcode Pokemon} target of the move * @param move {@linkcode Move} with this attribute - * @param cancelled {@linkcode Utils.BooleanHolder} which stores if the move should fail * @returns the string representing failure of this {@linkcode Move} */ - getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { - return null; + getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined { + return; } /** @@ -1335,6 +1332,54 @@ export class PreMoveMessageAttr extends MoveAttr { } } +/** + * Attribute for moves that can be conditionally interrupted to be considered to + * have failed before their "useMove" message is displayed. Currently used by + * Focus Punch. + * @extends MoveAttr + */ +export class PreUseInterruptAttr extends MoveAttr { + protected message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string); + protected overridesFailedMessage: boolean; + protected conditionFunc: MoveConditionFunc; + + /** + * Create a new MoveInterruptedMessageAttr. + * @param message The message to display when the move is interrupted, or a function that formats the message based on the user, target, and move. + */ + constructor(message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc?: MoveConditionFunc) { + super(); + this.message = message; + this.conditionFunc = conditionFunc ?? (() => true); + } + + /** + * Message to display when a move is interrupted. + * @param user {@linkcode Pokemon} using the move + * @param target {@linkcode Pokemon} target of the move + * @param move {@linkcode Move} with this attribute + */ + override apply(user: Pokemon, target: Pokemon, move: Move): boolean { + return this.conditionFunc(user, target, move); + } + + /** + * Message to display when a move is interrupted. + * @param user {@linkcode Pokemon} using the move + * @param target {@linkcode Pokemon} target of the move + * @param move {@linkcode Move} with this attribute + */ + override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined { + if (this.message && this.conditionFunc(user, target, move)) { + const message = + typeof this.message === "string" + ? (this.message as string) + : this.message(user, target, move); + return message; + } + } +} + /** * Attribute for Status moves that take attack type effectiveness * into consideration (i.e. {@linkcode https://bulbapedia.bulbagarden.net/wiki/Thunder_Wave_(move) | Thunder Wave}) @@ -1754,13 +1799,16 @@ export class AddSubstituteAttr extends MoveEffectAttr { return (user, target, move) => !user.getTag(SubstituteTag) && user.hp > Math.floor(user.getMaxHp() * this.hpCost) && user.getMaxHp() > 1; } - getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + /** + * Get the substitute-specific failure message if one should be displayed. + * @param user The pokemon using the move. + * @returns The substitute-specific failure message if the conditions apply, otherwise `undefined` + */ + getFailedText(user: Pokemon, _target: Pokemon, _move: Move): string | undefined { 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"); } } } @@ -6230,10 +6278,12 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); } - getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined { const blockedByAbility = new Utils.BooleanHolder(false); applyAbAttrs(ForceSwitchOutImmunityAbAttr, target, blockedByAbility); - return blockedByAbility.value ? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }) : null; + if (blockedByAbility.value) { + return i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }); + } } getSwitchOutCondition(): MoveConditionFunc { @@ -9185,8 +9235,8 @@ export function initMoves() { .attr(BypassBurnDamageReductionAttr), new AttackMove(Moves.FOCUS_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 20, -1, -3, 3) .attr(MessageHeaderAttr, (user, move) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) })) - .punchingMove() - .condition((user, target, move) => !user.turnData.attacksReceived.find(r => r.damage)), + .attr(PreUseInterruptAttr, i18next.t("moveTriggers:lostFocus"), user => !!user.turnData.attacksReceived.find(r => r.damage)) + .punchingMove(), new AttackMove(Moves.SMELLING_SALTS, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 3) .attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1) .attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS), diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index c1e4aeaf497..d58c052812f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -9,7 +9,7 @@ import { PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr, - ReduceStatusEffectDurationAbAttr + ReduceStatusEffectDurationAbAttr, } from "#app/data/ability"; import type { DelayedAttackTag } from "#app/data/arena-tag"; import { CommonAnim } from "#app/data/battle-anims"; @@ -24,7 +24,8 @@ import { frenzyMissFunc, HealStatusEffectAttr, MoveFlags, - PreMoveMessageAttr + PreMoveMessageAttr, + PreUseInterruptAttr, } from "#app/data/move"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; @@ -42,7 +43,7 @@ import { MoveChargePhase } from "#app/phases/move-charge-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; -import { BooleanHolder, NumberHolder } from "#app/utils"; +import { NumberHolder } from "#app/utils"; import { Abilities } from "#enums/abilities"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -293,7 +294,18 @@ export class MovePhase extends BattlePhase { } } - this.showMoveText(); + let success: boolean = true; + // Check if there are any attributes that can interrupt the move, overriding the fail message. + for (const move of this.move.getMove().getAttrs(PreUseInterruptAttr)) { + if (move.apply(this.pokemon, targets[0], this.move.getMove())) { + success = false; + break; + } + } + + if (success) { + this.showMoveText(); + } if (moveQueue.length > 0) { // Using .shift here clears out two turn moves once they've been used @@ -329,11 +341,14 @@ export class MovePhase extends BattlePhase { * Move conditions assume the move has a single target * TODO: is this sustainable? */ - const passesConditions = move.applyConditions(this.pokemon, targets[0], move); - const failedDueToWeather: boolean = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); - const failedDueToTerrain: boolean = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); + let failedDueToTerrain: boolean = false; + if (success) { + const passesConditions = move.applyConditions(this.pokemon, targets[0], move); + const failedDueToWeather: boolean = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); + failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); + success = passesConditions && !failedDueToWeather && !failedDueToTerrain; + } - const success = passesConditions && !failedDueToWeather && !failedDueToTerrain; // Update the battle's "last move" pointer, unless we're currently mimicking a move. if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) { @@ -360,9 +375,8 @@ export class MovePhase extends BattlePhase { this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); + const failureMessage = move.getFailedText(this.pokemon, targets[0], move); let failedText: string | undefined; - const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false)); - if (failureMessage) { failedText = failureMessage; } else if (failedDueToTerrain) { @@ -398,7 +412,7 @@ export class MovePhase extends BattlePhase { } else { this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); - const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false)); + const failureMessage = move.getFailedText(this.pokemon, targets[0], move); this.showMoveText(); this.showFailedText(failureMessage ?? undefined); @@ -566,7 +580,7 @@ export class MovePhase extends BattlePhase { applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents()[0], this.move.getMove()); } - public showFailedText(failedText?: string): void { - globalScene.queueMessage(failedText ?? i18next.t("battle:attackFailed")); + public showFailedText(failedText: string = i18next.t("battle:attackFailed")): void { + globalScene.queueMessage(failedText); } } diff --git a/src/test/moves/focus_punch.test.ts b/src/test/moves/focus_punch.test.ts index 352e3b60aa4..44d9c92cac1 100644 --- a/src/test/moves/focus_punch.test.ts +++ b/src/test/moves/focus_punch.test.ts @@ -7,8 +7,9 @@ import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; +import i18next from "i18next"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; describe("Moves - Focus Punch", () => { @@ -41,7 +42,7 @@ describe("Moves - Focus Punch", () => { it( "should deal damage at the end of turn if uninterrupted", async () => { - await game.startBattle([ Species.CHARIZARD ]); + await game.classicMode.startBattle([ Species.CHARIZARD ]); const leadPokemon = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; @@ -68,7 +69,7 @@ describe("Moves - Focus Punch", () => { async () => { game.override.enemyMoveset([ Moves.TACKLE ]); - await game.startBattle([ Species.CHARIZARD ]); + await game.classicMode.startBattle([ Species.CHARIZARD ]); const leadPokemon = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; @@ -95,7 +96,7 @@ describe("Moves - Focus Punch", () => { async () => { game.override.enemyMoveset([ Moves.SPORE ]); - await game.startBattle([ Species.CHARIZARD ]); + await game.classicMode.startBattle([ Species.CHARIZARD ]); const leadPokemon = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; @@ -119,7 +120,7 @@ describe("Moves - Focus Punch", () => { /** Guarantee a Trainer battle with multiple enemy Pokemon */ game.override.startingWave(25); - await game.startBattle([ Species.CHARIZARD ]); + await game.classicMode.startBattle([ Species.CHARIZARD ]); game.forceEnemyToSwitch(); game.move.select(Moves.FOCUS_PUNCH); @@ -130,4 +131,15 @@ describe("Moves - Focus Punch", () => { expect(game.scene.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined(); } ); + it("should replace the 'but it failed' text when the user gets hit", async () => { + game.override.enemyMoveset([ Moves.TACKLE ]); + await game.classicMode.startBattle([ Species.CHARIZARD ]); + + game.move.select(Moves.FOCUS_PUNCH); + await game.phaseInterceptor.to("MoveEndPhase", true); + await game.phaseInterceptor.to("MessagePhase", false); + const consoleSpy = vi.spyOn(console, "log"); + await game.phaseInterceptor.to("MoveEndPhase", true); + expect(consoleSpy).nthCalledWith(1, i18next.t("moveTriggers:lostFocus")); + }); });