Skip to content

Commit 7a015e0

Browse files
SirzBenjieWlowschaDayKev
authored
[Bug] [Move] Add focus punch lost focus message (#5341)
* Add focus punch lost focus message * Rename attribute * Added automated test * Fix failedToTerrain being undefined * Update src/test/moves/focus_punch.test.ts Co-authored-by: Wlowscha <[email protected]> * Update src/data/move.ts Co-authored-by: NightKev <[email protected]> --------- Co-authored-by: Wlowscha <[email protected]> Co-authored-by: NightKev <[email protected]>
1 parent 180a9cc commit 7a015e0

File tree

4 files changed

+112
-36
lines changed

4 files changed

+112
-36
lines changed

src/data/ability.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6633,7 +6633,7 @@ export function initAbilities() {
66336633
.bypassFaint(),
66346634
new Ability(Abilities.CORROSION, 7)
66356635
.attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ Type.STEEL, Type.POISON ])
6636-
.edgeCase(), // Should interact correctly with magic coat/bounce (not yet implemented) + fling with toxic orb (not implemented yet)
6636+
.edgeCase(), // Should poison itself with toxic orb.
66376637
new Ability(Abilities.COMATOSE, 7)
66386638
.attr(UncopiableAbilityAbAttr)
66396639
.attr(UnswappableAbilityAbAttr)

src/data/move.ts

+67-17
Original file line numberDiff line numberDiff line change
@@ -692,19 +692,17 @@ export default class Move implements Localizable {
692692
/**
693693
* Sees if a move has a custom failure text (by looking at each {@linkcode MoveAttr} of this move)
694694
* @param user {@linkcode Pokemon} using the move
695-
* @param target {@linkcode Pokemon} receiving the move
696-
* @param move {@linkcode Move} using the move
697-
* @param cancelled {@linkcode Utils.BooleanHolder} to hold boolean value
695+
* @param target {@linkcode Pokemon} target of the move
696+
* @param move {@linkcode Move} with this attribute
698697
* @returns string of the custom failure text, or `null` if it uses the default text ("But it failed!")
699698
*/
700-
getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null {
699+
getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
701700
for (const attr of this.attrs) {
702-
const failedText = attr.getFailedText(user, target, move, cancelled);
703-
if (failedText !== null) {
701+
const failedText = attr.getFailedText(user, target, move);
702+
if (failedText) {
704703
return failedText;
705704
}
706705
}
707-
return null;
708706
}
709707

710708
/**
@@ -1089,11 +1087,10 @@ export abstract class MoveAttr {
10891087
* @param user {@linkcode Pokemon} using the move
10901088
* @param target {@linkcode Pokemon} target of the move
10911089
* @param move {@linkcode Move} with this attribute
1092-
* @param cancelled {@linkcode Utils.BooleanHolder} which stores if the move should fail
10931090
* @returns the string representing failure of this {@linkcode Move}
10941091
*/
1095-
getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null {
1096-
return null;
1092+
getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
1093+
return;
10971094
}
10981095

10991096
/**
@@ -1335,6 +1332,54 @@ export class PreMoveMessageAttr extends MoveAttr {
13351332
}
13361333
}
13371334

1335+
/**
1336+
* Attribute for moves that can be conditionally interrupted to be considered to
1337+
* have failed before their "useMove" message is displayed. Currently used by
1338+
* Focus Punch.
1339+
* @extends MoveAttr
1340+
*/
1341+
export class PreUseInterruptAttr extends MoveAttr {
1342+
protected message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string);
1343+
protected overridesFailedMessage: boolean;
1344+
protected conditionFunc: MoveConditionFunc;
1345+
1346+
/**
1347+
* Create a new MoveInterruptedMessageAttr.
1348+
* @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.
1349+
*/
1350+
constructor(message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc?: MoveConditionFunc) {
1351+
super();
1352+
this.message = message;
1353+
this.conditionFunc = conditionFunc ?? (() => true);
1354+
}
1355+
1356+
/**
1357+
* Message to display when a move is interrupted.
1358+
* @param user {@linkcode Pokemon} using the move
1359+
* @param target {@linkcode Pokemon} target of the move
1360+
* @param move {@linkcode Move} with this attribute
1361+
*/
1362+
override apply(user: Pokemon, target: Pokemon, move: Move): boolean {
1363+
return this.conditionFunc(user, target, move);
1364+
}
1365+
1366+
/**
1367+
* Message to display when a move is interrupted.
1368+
* @param user {@linkcode Pokemon} using the move
1369+
* @param target {@linkcode Pokemon} target of the move
1370+
* @param move {@linkcode Move} with this attribute
1371+
*/
1372+
override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
1373+
if (this.message && this.conditionFunc(user, target, move)) {
1374+
const message =
1375+
typeof this.message === "string"
1376+
? (this.message as string)
1377+
: this.message(user, target, move);
1378+
return message;
1379+
}
1380+
}
1381+
}
1382+
13381383
/**
13391384
* Attribute for Status moves that take attack type effectiveness
13401385
* into consideration (i.e. {@linkcode https://bulbapedia.bulbagarden.net/wiki/Thunder_Wave_(move) | Thunder Wave})
@@ -1754,13 +1799,16 @@ export class AddSubstituteAttr extends MoveEffectAttr {
17541799
return (user, target, move) => !user.getTag(SubstituteTag) && user.hp > Math.floor(user.getMaxHp() * this.hpCost) && user.getMaxHp() > 1;
17551800
}
17561801

1757-
getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null {
1802+
/**
1803+
* Get the substitute-specific failure message if one should be displayed.
1804+
* @param user The pokemon using the move.
1805+
* @returns The substitute-specific failure message if the conditions apply, otherwise `undefined`
1806+
*/
1807+
getFailedText(user: Pokemon, _target: Pokemon, _move: Move): string | undefined {
17581808
if (user.getTag(SubstituteTag)) {
17591809
return i18next.t("moveTriggers:substituteOnOverlap", { pokemonName: getPokemonNameWithAffix(user) });
17601810
} else if (user.hp <= Math.floor(user.getMaxHp() / 4) || user.getMaxHp() === 1) {
17611811
return i18next.t("moveTriggers:substituteNotEnoughHp");
1762-
} else {
1763-
return i18next.t("battle:attackFailed");
17641812
}
17651813
}
17661814
}
@@ -6230,10 +6278,12 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
62306278
return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move));
62316279
}
62326280

6233-
getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null {
6281+
getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined {
62346282
const blockedByAbility = new Utils.BooleanHolder(false);
62356283
applyAbAttrs(ForceSwitchOutImmunityAbAttr, target, blockedByAbility);
6236-
return blockedByAbility.value ? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }) : null;
6284+
if (blockedByAbility.value) {
6285+
return i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) });
6286+
}
62376287
}
62386288

62396289
getSwitchOutCondition(): MoveConditionFunc {
@@ -9185,8 +9235,8 @@ export function initMoves() {
91859235
.attr(BypassBurnDamageReductionAttr),
91869236
new AttackMove(Moves.FOCUS_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 20, -1, -3, 3)
91879237
.attr(MessageHeaderAttr, (user, move) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) }))
9188-
.punchingMove()
9189-
.condition((user, target, move) => !user.turnData.attacksReceived.find(r => r.damage)),
9238+
.attr(PreUseInterruptAttr, i18next.t("moveTriggers:lostFocus"), user => !!user.turnData.attacksReceived.find(r => r.damage))
9239+
.punchingMove(),
91909240
new AttackMove(Moves.SMELLING_SALTS, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 3)
91919241
.attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1)
91929242
.attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS),

src/phases/move-phase.ts

+27-13
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
PokemonTypeChangeAbAttr,
1010
PostMoveUsedAbAttr,
1111
RedirectMoveAbAttr,
12-
ReduceStatusEffectDurationAbAttr
12+
ReduceStatusEffectDurationAbAttr,
1313
} from "#app/data/ability";
1414
import type { DelayedAttackTag } from "#app/data/arena-tag";
1515
import { CommonAnim } from "#app/data/battle-anims";
@@ -24,7 +24,8 @@ import {
2424
frenzyMissFunc,
2525
HealStatusEffectAttr,
2626
MoveFlags,
27-
PreMoveMessageAttr
27+
PreMoveMessageAttr,
28+
PreUseInterruptAttr,
2829
} from "#app/data/move";
2930
import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms";
3031
import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect";
@@ -42,7 +43,7 @@ import { MoveChargePhase } from "#app/phases/move-charge-phase";
4243
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
4344
import { MoveEndPhase } from "#app/phases/move-end-phase";
4445
import { ShowAbilityPhase } from "#app/phases/show-ability-phase";
45-
import { BooleanHolder, NumberHolder } from "#app/utils";
46+
import { NumberHolder } from "#app/utils";
4647
import { Abilities } from "#enums/abilities";
4748
import { ArenaTagType } from "#enums/arena-tag-type";
4849
import { BattlerTagType } from "#enums/battler-tag-type";
@@ -293,7 +294,18 @@ export class MovePhase extends BattlePhase {
293294
}
294295
}
295296

296-
this.showMoveText();
297+
let success: boolean = true;
298+
// Check if there are any attributes that can interrupt the move, overriding the fail message.
299+
for (const move of this.move.getMove().getAttrs(PreUseInterruptAttr)) {
300+
if (move.apply(this.pokemon, targets[0], this.move.getMove())) {
301+
success = false;
302+
break;
303+
}
304+
}
305+
306+
if (success) {
307+
this.showMoveText();
308+
}
297309

298310
if (moveQueue.length > 0) {
299311
// Using .shift here clears out two turn moves once they've been used
@@ -329,11 +341,14 @@ export class MovePhase extends BattlePhase {
329341
* Move conditions assume the move has a single target
330342
* TODO: is this sustainable?
331343
*/
332-
const passesConditions = move.applyConditions(this.pokemon, targets[0], move);
333-
const failedDueToWeather: boolean = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
334-
const failedDueToTerrain: boolean = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
344+
let failedDueToTerrain: boolean = false;
345+
if (success) {
346+
const passesConditions = move.applyConditions(this.pokemon, targets[0], move);
347+
const failedDueToWeather: boolean = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
348+
failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
349+
success = passesConditions && !failedDueToWeather && !failedDueToTerrain;
350+
}
335351

336-
const success = passesConditions && !failedDueToWeather && !failedDueToTerrain;
337352

338353
// Update the battle's "last move" pointer, unless we're currently mimicking a move.
339354
if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) {
@@ -360,9 +375,8 @@ export class MovePhase extends BattlePhase {
360375

361376
this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual });
362377

378+
const failureMessage = move.getFailedText(this.pokemon, targets[0], move);
363379
let failedText: string | undefined;
364-
const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false));
365-
366380
if (failureMessage) {
367381
failedText = failureMessage;
368382
} else if (failedDueToTerrain) {
@@ -398,7 +412,7 @@ export class MovePhase extends BattlePhase {
398412
} else {
399413
this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual });
400414

401-
const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false));
415+
const failureMessage = move.getFailedText(this.pokemon, targets[0], move);
402416
this.showMoveText();
403417
this.showFailedText(failureMessage ?? undefined);
404418

@@ -566,7 +580,7 @@ export class MovePhase extends BattlePhase {
566580
applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents()[0], this.move.getMove());
567581
}
568582

569-
public showFailedText(failedText?: string): void {
570-
globalScene.queueMessage(failedText ?? i18next.t("battle:attackFailed"));
583+
public showFailedText(failedText: string = i18next.t("battle:attackFailed")): void {
584+
globalScene.queueMessage(failedText);
571585
}
572586
}

src/test/moves/focus_punch.test.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { Abilities } from "#enums/abilities";
77
import { Moves } from "#enums/moves";
88
import { Species } from "#enums/species";
99
import GameManager from "#test/utils/gameManager";
10+
import i18next from "i18next";
1011
import Phaser from "phaser";
11-
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
12+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
1213

1314

1415
describe("Moves - Focus Punch", () => {
@@ -41,7 +42,7 @@ describe("Moves - Focus Punch", () => {
4142
it(
4243
"should deal damage at the end of turn if uninterrupted",
4344
async () => {
44-
await game.startBattle([ Species.CHARIZARD ]);
45+
await game.classicMode.startBattle([ Species.CHARIZARD ]);
4546

4647
const leadPokemon = game.scene.getPlayerPokemon()!;
4748
const enemyPokemon = game.scene.getEnemyPokemon()!;
@@ -68,7 +69,7 @@ describe("Moves - Focus Punch", () => {
6869
async () => {
6970
game.override.enemyMoveset([ Moves.TACKLE ]);
7071

71-
await game.startBattle([ Species.CHARIZARD ]);
72+
await game.classicMode.startBattle([ Species.CHARIZARD ]);
7273

7374
const leadPokemon = game.scene.getPlayerPokemon()!;
7475
const enemyPokemon = game.scene.getEnemyPokemon()!;
@@ -95,7 +96,7 @@ describe("Moves - Focus Punch", () => {
9596
async () => {
9697
game.override.enemyMoveset([ Moves.SPORE ]);
9798

98-
await game.startBattle([ Species.CHARIZARD ]);
99+
await game.classicMode.startBattle([ Species.CHARIZARD ]);
99100

100101
const leadPokemon = game.scene.getPlayerPokemon()!;
101102
const enemyPokemon = game.scene.getEnemyPokemon()!;
@@ -119,7 +120,7 @@ describe("Moves - Focus Punch", () => {
119120
/** Guarantee a Trainer battle with multiple enemy Pokemon */
120121
game.override.startingWave(25);
121122

122-
await game.startBattle([ Species.CHARIZARD ]);
123+
await game.classicMode.startBattle([ Species.CHARIZARD ]);
123124

124125
game.forceEnemyToSwitch();
125126
game.move.select(Moves.FOCUS_PUNCH);
@@ -130,4 +131,15 @@ describe("Moves - Focus Punch", () => {
130131
expect(game.scene.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined();
131132
}
132133
);
134+
it("should replace the 'but it failed' text when the user gets hit", async () => {
135+
game.override.enemyMoveset([ Moves.TACKLE ]);
136+
await game.classicMode.startBattle([ Species.CHARIZARD ]);
137+
138+
game.move.select(Moves.FOCUS_PUNCH);
139+
await game.phaseInterceptor.to("MoveEndPhase", true);
140+
await game.phaseInterceptor.to("MessagePhase", false);
141+
const consoleSpy = vi.spyOn(console, "log");
142+
await game.phaseInterceptor.to("MoveEndPhase", true);
143+
expect(consoleSpy).nthCalledWith(1, i18next.t("moveTriggers:lostFocus"));
144+
});
133145
});

0 commit comments

Comments
 (0)