diff --git a/src/data/move.ts b/src/data/move.ts index 1fe4af0db06..2117d832d74 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -984,6 +984,43 @@ export class MoveEffectAttr extends MoveAttr { } } +/** + * Base class defining all Move Header attributes. + * Move Header effects apply at the beginning of a turn before any moves are resolved. + * They can be used to apply effects to the field (e.g. queueing a message) or to the user + * (e.g. adding a battler tag). + */ +export class MoveHeaderAttr extends MoveAttr { + constructor() { + super(true); + } +} + +/** + * Header attribute to queue a message at the beginning of a turn. + * @see {@link MoveHeaderAttr} + */ +export class MessageHeaderAttr extends MoveHeaderAttr { + private message: string | ((user: Pokemon, move: Move) => string); + + constructor(message: string | ((user: Pokemon, move: Move) => string)) { + super(); + this.message = message; + } + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const message = typeof this.message === "string" + ? this.message + : this.message(user, move); + + if (message) { + user.scene.queueMessage(message); + return true; + } + return false; + } +} + export class PreMoveMessageAttr extends MoveAttr { private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string); @@ -6837,6 +6874,7 @@ export function initMoves() { && (user.status.effect === StatusEffect.BURN || user.status.effect === StatusEffect.POISON || user.status.effect === StatusEffect.TOXIC || user.status.effect === StatusEffect.PARALYSIS) ? 2 : 1) .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() .ignoresVirtual() .condition((user, target, move) => !user.turnData.attacksReceived.find(r => r.damage)), diff --git a/src/locales/ca-ES/move-trigger.ts b/src/locales/ca-ES/move-trigger.ts index 7977cca29d4..b85f27228be 100644 --- a/src/locales/ca-ES/move-trigger.ts +++ b/src/locales/ca-ES/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} became cloaked in a harsh light!", "bellChimed": "A bell chimed!", "foresawAnAttack": "{{pokemonName}} foresaw\nan attack!", + "isTighteningFocus": "{{pokemonName}} is\ntightening its focus!", "hidUnderwater": "{{pokemonName}} hid\nunderwater!", "soothingAromaWaftedThroughArea": "A soothing aroma wafted through the area!", "sprangUp": "{{pokemonName}} sprang up!", diff --git a/src/locales/de/move-trigger.ts b/src/locales/de/move-trigger.ts index e8f3298308a..53c9c847b38 100644 --- a/src/locales/de/move-trigger.ts +++ b/src/locales/de/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} leuchtet grell!", "bellChimed": "Eine Glocke läutet!", "foresawAnAttack": "{{pokemonName}} sieht einen Angriff voraus!", + "isTighteningFocus": "{{pokemonName}} konzentriert sich!", "hidUnderwater": "{{pokemonName}} taucht unter!", "soothingAromaWaftedThroughArea": "Ein wohltuendes Aroma breitet sich aus!", "sprangUp": "{{pokemonName}} springt hoch in die Luft!", diff --git a/src/locales/en/move-trigger.ts b/src/locales/en/move-trigger.ts index 7977cca29d4..b85f27228be 100644 --- a/src/locales/en/move-trigger.ts +++ b/src/locales/en/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} became cloaked in a harsh light!", "bellChimed": "A bell chimed!", "foresawAnAttack": "{{pokemonName}} foresaw\nan attack!", + "isTighteningFocus": "{{pokemonName}} is\ntightening its focus!", "hidUnderwater": "{{pokemonName}} hid\nunderwater!", "soothingAromaWaftedThroughArea": "A soothing aroma wafted through the area!", "sprangUp": "{{pokemonName}} sprang up!", diff --git a/src/locales/es/move-trigger.ts b/src/locales/es/move-trigger.ts index 9fef0194b87..a1d9f2e3185 100644 --- a/src/locales/es/move-trigger.ts +++ b/src/locales/es/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} became cloaked in a harsh light!", "bellChimed": "A bell chimed!", "foresawAnAttack": "{{pokemonName}} foresaw\nan attack!", + "isTighteningFocus": "{{pokemonName}} is\ntightening its focus!", "hidUnderwater": "{{pokemonName}} hid\nunderwater!", "soothingAromaWaftedThroughArea": "A soothing aroma wafted through the area!", "sprangUp": "{{pokemonName}} sprang up!", diff --git a/src/locales/fr/move-trigger.ts b/src/locales/fr/move-trigger.ts index d611e19d61a..7f9f3a471c6 100644 --- a/src/locales/fr/move-trigger.ts +++ b/src/locales/fr/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} est entouré\nd’une lumière intense !", "bellChimed": "Un grelot sonne !", "foresawAnAttack": "{{pokemonName}}\nprévoit une attaque !", + "isTighteningFocus": "{{pokemonName}} se concentre\nau maximum !", "hidUnderwater": "{{pokemonName}}\nse cache sous l’eau !", "soothingAromaWaftedThroughArea": "Une odeur apaisante flotte dans l’air !", "sprangUp": "{{pokemonName}}\nse propulse dans les airs !", diff --git a/src/locales/it/move-trigger.ts b/src/locales/it/move-trigger.ts index 98fd37defc4..badf2777d6f 100644 --- a/src/locales/it/move-trigger.ts +++ b/src/locales/it/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} è avvolto da una luce intensa!", "bellChimed": " Si sente suonare una campanella!", "foresawAnAttack": "{{pokemonName}} presagisce\nl’attacco imminente!", + "isTighteningFocus": "{{pokemonName}} si concentra al massimo!", "hidUnderwater": "{{pokemonName}} sparisce\nsott’acqua!", "soothingAromaWaftedThroughArea": "Un gradevole profumo si diffonde nell’aria!", "sprangUp": "{{pokemonName}} spicca un gran balzo!", diff --git a/src/locales/ja/move-trigger.ts b/src/locales/ja/move-trigger.ts index c0d06b78bf9..7c794379f55 100644 --- a/src/locales/ja/move-trigger.ts +++ b/src/locales/ja/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}}を\nはげしいひかりが つつむ!", "bellChimed": "すずのおとが ひびきわたった!", "foresawAnAttack": "{{pokemonName}}は\nみらいに こうげきを よちした!", + "isTighteningFocus": "{{pokemonName}} is\ntightening its focus!", "hidUnderwater": "{{pokemonName}}は\nすいちゅうに みをひそめた!", "soothingAromaWaftedThroughArea": "ここちよい かおりが ひろがった!", "sprangUp": "{{pokemonName}}は\nたかく とびはねた!", diff --git a/src/locales/ko/move-trigger.ts b/src/locales/ko/move-trigger.ts index 364cb730889..4c32e5b5b9f 100644 --- a/src/locales/ko/move-trigger.ts +++ b/src/locales/ko/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}}를(을)\n강렬한 빛이 감쌌다!", "bellChimed": "방울소리가 울려 퍼졌다!", "foresawAnAttack": "{{pokemonName}}는(은)\n미래의 공격을 예지했다!", + "isTighteningFocus": "{{pokemonName}}[[는]]\n집중력을 높이고 있다!", "hidUnderwater": "{{pokemonName}}는(은)\n물속에 몸을 숨겼다!", "soothingAromaWaftedThroughArea": "기분 좋은 향기가 퍼졌다!", "sprangUp": "{{pokemonName}}는(은)\n높이 뛰어올랐다!", diff --git a/src/locales/pt_BR/move-trigger.ts b/src/locales/pt_BR/move-trigger.ts index 579638b00c3..a96cdb27953 100644 --- a/src/locales/pt_BR/move-trigger.ts +++ b/src/locales/pt_BR/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "{{pokemonName}} ficou envolto em uma luz forte!", "bellChimed": "Um sino tocou!", "foresawAnAttack": "{{pokemonName}} previu/num ataque!", + "isTighteningFocus": "{{pokemonName}} está\naumentando seu foco!", "hidUnderwater": "{{pokemonName}} se escondeu/nembaixo d'água!", "soothingAromaWaftedThroughArea": "Um aroma suave se espalhou pelo ambiente!", "sprangUp": "{{pokemonName}} se levantou!", diff --git a/src/locales/zh_CN/move-trigger.ts b/src/locales/zh_CN/move-trigger.ts index 23ded8cfb25..d46ef1e313e 100644 --- a/src/locales/zh_CN/move-trigger.ts +++ b/src/locales/zh_CN/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "强光包围了{{pokemonName}}\n!", "bellChimed": "铃声响彻四周!", "foresawAnAttack": "{{pokemonName}}\n预知了未来的攻击!", + "isTighteningFocus": "{{pokemonName}}正在集中注意力!", "hidUnderwater": "{{pokemonName}}\n潜入了水中!", "soothingAromaWaftedThroughArea": "怡人的香气扩散了开来!", "sprangUp": "{{pokemonName}}\n高高地跳了起来!", diff --git a/src/locales/zh_TW/move-trigger.ts b/src/locales/zh_TW/move-trigger.ts index ea355515f71..0831ef82637 100644 --- a/src/locales/zh_TW/move-trigger.ts +++ b/src/locales/zh_TW/move-trigger.ts @@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = { "isGlowing": "強光包圍了\n{{pokemonName}}!", "bellChimed": "鈴聲響徹四周!", "foresawAnAttack": "{{pokemonName}}\n預知了未來的攻擊!", + "isTighteningFocus": "{{pokemonName}}正在集中注意力!", "hidUnderwater": "{{pokemonName}}\n潛入了水中!", "soothingAromaWaftedThroughArea": "怡人的香氣擴散了開來!", "sprangUp": "{{pokemonName}}\n高高地跳了起來!", diff --git a/src/phases.ts b/src/phases.ts index 7397974846f..ac6da0db148 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -1,7 +1,7 @@ import BattleScene, { bypassLogin } from "./battle-scene"; import { default as Pokemon, PlayerPokemon, EnemyPokemon, PokemonMove, MoveResult, DamageResult, FieldPosition, HitResult, TurnMove } from "./field/pokemon"; import * as Utils from "./utils"; -import { allMoves, applyMoveAttrs, BypassSleepAttr, ChargeAttr, applyFilteredMoveAttrs, HitsTagAttr, MissEffectAttr, MoveAttr, MoveEffectAttr, MoveFlags, MultiHitAttr, OverrideMoveEffectAttr, MoveTarget, getMoveTargets, MoveTargetSet, MoveEffectTrigger, CopyMoveAttr, AttackMove, SelfStatusMove, PreMoveMessageAttr, HealStatusEffectAttr, NoEffectAttr, BypassRedirectAttr, FixedDamageAttr, PostVictoryStatChangeAttr, ForceSwitchOutAttr, VariableTargetAttr, IncrementMovePriorityAttr } from "./data/move"; +import { allMoves, applyMoveAttrs, BypassSleepAttr, ChargeAttr, applyFilteredMoveAttrs, HitsTagAttr, MissEffectAttr, MoveAttr, MoveEffectAttr, MoveFlags, MultiHitAttr, OverrideMoveEffectAttr, MoveTarget, getMoveTargets, MoveTargetSet, MoveEffectTrigger, CopyMoveAttr, AttackMove, SelfStatusMove, PreMoveMessageAttr, HealStatusEffectAttr, NoEffectAttr, BypassRedirectAttr, FixedDamageAttr, PostVictoryStatChangeAttr, ForceSwitchOutAttr, VariableTargetAttr, IncrementMovePriorityAttr, MoveHeaderAttr } from "./data/move"; import { Mode } from "./ui/ui"; import { Command } from "./ui/command-ui-handler"; import { Stat } from "./data/pokemon-stat"; @@ -2353,6 +2353,9 @@ export class TurnStartPhase extends FieldPhase { continue; } const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move) || new PokemonMove(queuedMove.move); + if (move.getMove().hasAttr(MoveHeaderAttr)) { + this.scene.unshiftPhase(new MoveHeaderPhase(this.scene, pokemon, move)); + } if (pokemon.isPlayer()) { if (turnCommand.cursor === -1) { this.scene.pushPhase(new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move));//TODO: is the bang correct here? @@ -2597,6 +2600,32 @@ export class CommonAnimPhase extends PokemonPhase { } } +export class MoveHeaderPhase extends BattlePhase { + public pokemon: Pokemon; + public move: PokemonMove; + + constructor(scene: BattleScene, pokemon: Pokemon, move: PokemonMove) { + super(scene); + + this.pokemon = pokemon; + this.move = move; + } + + canMove(): boolean { + return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon); + } + + start() { + super.start(); + + if (this.canMove()) { + applyMoveAttrs(MoveHeaderAttr, this.pokemon, null, this.move.getMove()).then(() => this.end()); + } else { + this.end(); + } + } +} + export class MovePhase extends BattlePhase { public pokemon: Pokemon; public move: PokemonMove; diff --git a/src/test/moves/focus_punch.test.ts b/src/test/moves/focus_punch.test.ts new file mode 100644 index 00000000000..f5cf85ffae0 --- /dev/null +++ b/src/test/moves/focus_punch.test.ts @@ -0,0 +1,132 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import GameManager from "#test/utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "#test/utils/gameManagerUtils"; +import { BerryPhase, MessagePhase, MoveHeaderPhase, SwitchSummonPhase, TurnStartPhase } from "#app/phases"; +import { SPLASH_ONLY } from "#test/utils/testUtils"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Focus Punch", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .ability(Abilities.UNNERVE) + .moveset([Moves.FOCUS_PUNCH]) + .enemySpecies(Species.GROUDON) + .enemyAbility(Abilities.INSOMNIA) + .enemyMoveset(SPLASH_ONLY) + .startingLevel(100) + .enemyLevel(100); + }); + + it( + "should deal damage at the end of turn if uninterrupted", + async () => { + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.FOCUS_PUNCH)); + + await game.phaseInterceptor.to(MessagePhase); + + expect(enemyPokemon.hp).toBe(enemyStartingHp); + expect(leadPokemon.getMoveHistory().length).toBe(0); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); + expect(leadPokemon.getMoveHistory().length).toBe(1); + expect(leadPokemon.turnData.damageDealt).toBe(enemyStartingHp - enemyPokemon.hp); + }, TIMEOUT + ); + + it( + "should fail if the user is hit", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.FOCUS_PUNCH)); + + await game.phaseInterceptor.to(MessagePhase); + + expect(enemyPokemon.hp).toBe(enemyStartingHp); + expect(leadPokemon.getMoveHistory().length).toBe(0); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(enemyPokemon.hp).toBe(enemyStartingHp); + expect(leadPokemon.getMoveHistory().length).toBe(1); + expect(leadPokemon.turnData.damageDealt).toBe(0); + }, TIMEOUT + ); + + it( + "should be cancelled if the user falls asleep mid-turn", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.SPORE)); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.FOCUS_PUNCH)); + + await game.phaseInterceptor.to(MessagePhase); // Header message + + expect(leadPokemon.getMoveHistory().length).toBe(0); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.getMoveHistory().length).toBe(1); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }, TIMEOUT + ); + + it( + "should not queue its pre-move message before an enemy switches", + async () => { + /** Guarantee a Trainer battle with multiple enemy Pokemon */ + game.override.startingWave(25); + + await game.startBattle([Species.CHARIZARD]); + + game.forceOpponentToSwitch(); + game.doAttack(getMovePosition(game.scene, 0, Moves.FOCUS_PUNCH)); + + await game.phaseInterceptor.to(TurnStartPhase); + + expect(game.scene.getCurrentPhase() instanceof SwitchSummonPhase).toBeTruthy(); + expect(game.scene.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined(); + }, TIMEOUT + ); +});