[Enhancement] Add Move Header phase and attributes (#2716)

* Create Move Header phase and attributes

* Fix move header persisting after run/ball

* Add mid-turn sleep test

* Fix status effect text in move header phase

* Remove preemptive non-volatile status check

* Process move headers in main loop of TurnStartPhase instead

* Fix merge issues in Focus Punch test

* Fix Focus Punch test + ESLint

* Add i18n key for Focus Punch header message

* Fix missing arg in i18n message

* Add Focus Punch message translations (DE, FR, KO, PT-BR, ZH)

Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com>
Co-authored-by: Lugiad' <adrien.grivel@hotmail.fr>
Co-authored-by: Enoch <enoch.jwsong@gmail.com>
Co-authored-by: José Ricardo Fleury Oliveira <josefleury@discente.ufg.br>
Co-authored-by: Sonny Ding <93831983+sonnyding1@users.noreply.github.com>

* Update src/locales/it/move-trigger.ts

Co-authored-by: Enoch <enoch.jwsong@gmail.com>

* Use new test helper functions + snooz's cleanup suggestions

* Add check for MoveHeaderPhase in switch test

* Add key to JA locale

* Add CA-ES locale key

* Fix strict-null checks in focus punch test

---------

Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com>
Co-authored-by: Lugiad' <adrien.grivel@hotmail.fr>
Co-authored-by: Enoch <enoch.jwsong@gmail.com>
Co-authored-by: José Ricardo Fleury Oliveira <josefleury@discente.ufg.br>
Co-authored-by: Sonny Ding <93831983+sonnyding1@users.noreply.github.com>
This commit is contained in:
innerthunder 2024-08-07 11:32:56 -07:00 committed by GitHub
parent f9d7b71fd6
commit 2b99f005dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 211 additions and 1 deletions

View File

@ -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)),

View File

@ -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!",

View File

@ -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!",

View File

@ -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!",

View File

@ -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!",

View File

@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = {
"isGlowing": "{{pokemonName}} est entouré\ndune lumière intense !",
"bellChimed": "Un grelot sonne !",
"foresawAnAttack": "{{pokemonName}}\nprévoit une attaque !",
"isTighteningFocus": "{{pokemonName}} se concentre\nau maximum !",
"hidUnderwater": "{{pokemonName}}\nse cache sous leau !",
"soothingAromaWaftedThroughArea": "Une odeur apaisante flotte dans lair !",
"sprangUp": "{{pokemonName}}\nse propulse dans les airs !",

View File

@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = {
"isGlowing": "{{pokemonName}} è avvolto da una luce intensa!",
"bellChimed": " Si sente suonare una campanella!",
"foresawAnAttack": "{{pokemonName}} presagisce\nlattacco imminente!",
"isTighteningFocus": "{{pokemonName}} si concentra al massimo!",
"hidUnderwater": "{{pokemonName}} sparisce\nsottacqua!",
"soothingAromaWaftedThroughArea": "Un gradevole profumo si diffonde nellaria!",
"sprangUp": "{{pokemonName}} spicca un gran balzo!",

View File

@ -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たかく とびはねた",

View File

@ -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높이 뛰어올랐다!",

View File

@ -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!",

View File

@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = {
"isGlowing": "强光包围了{{pokemonName}}\n",
"bellChimed": "铃声响彻四周!",
"foresawAnAttack": "{{pokemonName}}\n预知了未来的攻击",
"isTighteningFocus": "{{pokemonName}}正在集中注意力!",
"hidUnderwater": "{{pokemonName}}\n潜入了水中",
"soothingAromaWaftedThroughArea": "怡人的香气扩散了开来!",
"sprangUp": "{{pokemonName}}\n高高地跳了起来",

View File

@ -21,6 +21,7 @@ export const moveTriggers: SimpleTranslationEntries = {
"isGlowing": "強光包圍了\n{{pokemonName}}",
"bellChimed": "鈴聲響徹四周!",
"foresawAnAttack": "{{pokemonName}}\n預知了未來的攻擊",
"isTighteningFocus": "{{pokemonName}}正在集中注意力!",
"hidUnderwater": "{{pokemonName}}\n潛入了水中",
"soothingAromaWaftedThroughArea": "怡人的香氣擴散了開來!",
"sprangUp": "{{pokemonName}}\n高高地跳了起來",

View File

@ -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;

View File

@ -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
);
});