Mostly working magic bounce and magic coat

This commit is contained in:
Sirz Benjie 2025-01-31 23:03:04 -06:00
parent 9e8d4850a8
commit a3db6d5552
No known key found for this signature in database
GPG Key ID: D4BFA840253CD6D7
7 changed files with 111 additions and 46 deletions

View File

@ -2352,14 +2352,14 @@ export default class BattleScene extends SceneBase {
}
/**
* Adds Phase to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
* @param phase {@linkcode Phase} the phase to add
* Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
* @param phases {@linkcode Phase} the phase(s) to add
*/
unshiftPhase(phase: Phase): void {
unshiftPhase(...phases: Phase[]): void {
if (this.phaseQueuePrependSpliceIndex === -1) {
this.phaseQueuePrepend.push(phase);
this.phaseQueuePrepend.push(...phases);
} else {
this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, phase);
this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, ...phases);
}
}
@ -2497,32 +2497,38 @@ export default class BattleScene extends SceneBase {
* @param targetPhase {@linkcode Phase} the type of phase to search for in phaseQueue
* @returns boolean if a targetPhase was found and added
*/
prependToPhase(phase: Phase, targetPhase: Constructor<Phase>): boolean {
prependToPhase(phase: Phase | Phase [], targetPhase: Constructor<Phase>): boolean {
if (!Array.isArray(phase)) {
phase = [ phase ];
}
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase);
if (targetIndex !== -1) {
this.phaseQueue.splice(targetIndex, 0, phase);
this.phaseQueue.splice(targetIndex, 0, ...phase);
return true;
} else {
this.unshiftPhase(phase);
this.unshiftPhase(...phase);
return false;
}
}
/**
* Tries to add the input phase to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()}
* @param phase {@linkcode Phase} the phase to be added
* Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()}
* @param phase {@linkcode Phase} the phase(s) to be added
* @param targetPhase {@linkcode Phase} the type of phase to search for in {@linkcode phaseQueue}
* @returns `true` if a `targetPhase` was found to append to
*/
appendToPhase(phase: Phase, targetPhase: Constructor<Phase>): boolean {
appendToPhase(phase: Phase | Phase[], targetPhase: Constructor<Phase>): boolean {
if (!Array.isArray(phase)) {
phase = [ phase ];
}
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase);
if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) {
this.phaseQueue.splice(targetIndex + 1, 0, phase);
this.phaseQueue.splice(targetIndex + 1, 0, ...phase);
return true;
} else {
this.unshiftPhase(phase);
this.unshiftPhase(...phase);
return false;
}
}

View File

@ -4493,6 +4493,13 @@ export class InfiltratorAbAttr extends AbAttr {
}
}
/**
* Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Magic_Bounce_(ability) | Magic Bounce}.
* Allows the source to bounce back {@linkcode MoveFlags.REFLECTABLE | Reflectable}
* moves as if the user had used {@linkcode Moves.MAGIC_COAT | Magic Coat}.
*/
export class ReflectStatusMoveAbAttr extends AbAttr { }
export class UncopiableAbilityAbAttr extends AbAttr {
constructor() {
super(false);
@ -5809,7 +5816,11 @@ export function initAbilities() {
}, Stat.SPD, 1)
.attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1),
new Ability(Abilities.MAGIC_BOUNCE, 5)
.attr(ReflectStatusMoveAbAttr)
.ignorable()
// Interactions with stomping tantrum, instruct, and other moves that
// rely on move history
.edgeCase()
.unimplemented(),
new Ability(Abilities.SAP_SIPPER, 5)
.attr(TypeImmunityStatStageChangeAbAttr, Type.GRASS, Stat.ATK, 1)

View File

@ -5343,6 +5343,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
case BattlerTagType.INGRAIN:
case BattlerTagType.IGNORE_ACCURACY:
case BattlerTagType.AQUA_RING:
case BattlerTagType.MAGIC_COAT:
return 3;
case BattlerTagType.PROTECTED:
case BattlerTagType.FLYING:
@ -9147,8 +9148,11 @@ export function initMoves() {
new AttackMove(Moves.SUPERPOWER, Type.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1, true),
new SelfStatusMove(Moves.MAGIC_COAT, Type.PSYCHIC, -1, 15, -1, 4, 3)
.attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true)
.unimplemented(),
.attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, false)
// Interactions with stomping tantrum, instruct, and other moves that
// rely on move history
// Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr
.edgeCase(),
new SelfStatusMove(Moves.RECYCLE, Type.NORMAL, -1, 10, -1, 0, 3)
.unimplemented(),
new AttackMove(Moves.REVENGE, Type.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, -4, 3)

View File

@ -94,5 +94,5 @@ export enum BattlerTagType {
PSYCHO_SHIFT = "PSYCHO_SHIFT",
ENDURE_TOKEN = "ENDURE_TOKEN",
POWDER = "POWDER",
MAGIC_COAT = "MAGIC_COAT"
MAGIC_COAT = "MAGIC_COAT",
}

View File

@ -12,6 +12,7 @@ import {
PostAttackAbAttr,
PostDamageAbAttr,
PostDefendAbAttr,
ReflectStatusMoveAbAttr,
TypeImmunityAbAttr,
} from "#app/data/ability";
import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag";
@ -31,6 +32,7 @@ import {
AttackMove,
DelayedAttackAttr,
FlinchAttr,
getMoveTargets,
HitsTagAttr,
MissEffectAttr,
MoveCategory,
@ -47,7 +49,7 @@ import {
} from "#app/data/move";
import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms";
import { Type } from "#enums/type";
import type { PokemonMove } from "#app/field/pokemon";
import { PokemonMove } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon";
import { HitResult, MoveResult } from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages";
@ -63,14 +65,25 @@ import { BooleanHolder, executeIf, isNullOrUndefined, NumberHolder } from "#app/
import { BattlerTagType } from "#enums/battler-tag-type";
import type { Moves } from "#enums/moves";
import i18next from "i18next";
import { Stat } from "#app/enums/stat";
import { ArenaTagType } from "#app/enums/arena-tag-type";
import { MessagePhase } from "./message-phase";
import type { Phase } from "#app/phase";
import { ShowAbilityPhase } from "./show-ability-phase";
import { MovePhase } from "./move-phase";
export class MoveEffectPhase extends PokemonPhase {
public move: PokemonMove;
protected targets: BattlerIndex[];
protected reflected: boolean = false;
constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove) {
/**
* @param reflected Indicates that the move was reflected by the user due to magic coat or magic bounce
*/
constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove, reflected: boolean = false) {
super(battlerIndex);
this.move = move;
this.reflected = reflected;
/**
* In double battles, if the right Pokemon selects a spread move and the left Pokemon dies
* with no party members available to switch in, then the right Pokemon takes the index
@ -177,12 +190,14 @@ export class MoveEffectPhase extends PokemonPhase {
&& (targets[0]?.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move))
&& !targets[0]?.getTag(SemiInvulnerableTag);
const mayBounce = move.hasFlag(MoveFlags.REFLECTABLE) && !this.reflected;
/**
* If no targets are left for the move to hit (FAIL), or the invoked move is single-target
* If no targets are left for the move to hit (FAIL), or the invoked move is non-reflectable, single-target
* (and not random target) and failed the hit check against its target (MISS), log the move
* as FAILed or MISSed (depending on the conditions above) and end this phase.
*/
if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) {
if (!hasActiveTargets || (mayBounce && !move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) {
this.stopMultiHit();
if (hasActiveTargets) {
globalScene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" }));
@ -204,12 +219,30 @@ export class MoveEffectPhase extends PokemonPhase {
new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex(), playOnEmptyField).play(move.hitsSubstitute(user, this.getFirstTarget()!), () => {
/** Has the move successfully hit a target (for damage) yet? */
let hasHit: boolean = false;
for (const target of targets) {
// Prevent ENEMY_SIDE targeted moves from occurring twice in double battles
if (move.moveTarget === MoveTarget.ENEMY_SIDE && target !== targets[targets.length - 1]) {
continue;
// Prevent ENEMY_SIDE targeted moves from occurring twice in double battles
// and determine which enemy will magic bounce based on speed order, respecting trick room
const trueTargets: Pokemon[] = move.moveTarget !== MoveTarget.ENEMY_SIDE ? targets : (() => {
const magicCoatTargets = targets.filter(t => t.getTag(BattlerTagType.MAGIC_COAT) !== null);
// only magic coat effect cares about order
if (!mayBounce || magicCoatTargets.length === 0) {
return [ targets[0] ];
}
const reversed = globalScene.arena.hasTag(ArenaTagType.TRICK_ROOM);
const sortedTargets = targets
.sort((a: Pokemon, b: Pokemon) => {
const aSpeed = a?.getEffectiveStat(Stat.SPD) ?? 0;
const bSpeed = b?.getEffectiveStat(Stat.SPD) ?? 0;
return reversed ? aSpeed - bSpeed : bSpeed - aSpeed;
})
.filter(t => t.getTag(BattlerTagType.MAGIC_COAT) !== null);
return sortedTargets.length === 1 ? sortedTargets : [ sortedTargets[globalScene.randBattleSeedInt(sortedTargets.length)] ];
})();
const queuedPhases: Phase[] = [];
for (const target of trueTargets) {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
@ -231,13 +264,34 @@ export class MoveEffectPhase extends PokemonPhase {
|| (this.move.getMove().category !== MoveCategory.STATUS
&& target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType))));
/** Is the target hidden by the effects of its Commander ability? */
const isCommanding = globalScene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target;
/** Is the target reflecting status moves from the magic coat move? */
const isReflecting = target.getTag(BattlerTagType.MAGIC_COAT) !== null;
/** Is the target's magic bounce ability not ignored and able to reflect this move? */
const canMagicBounce = !(isReflecting || move.checkFlag(MoveFlags.IGNORE_ABILITIES, user, target) && target.hasAbilityWithAttr(ReflectStatusMoveAbAttr));
/** Is the target protected and reflecting the effect */
const willBounce = !isProtected && !this.reflected && !isCommanding && (isReflecting || canMagicBounce);
/** If the move will bounce, then queue the bounce and move on to the next target*/
if (!target.switchOutStatus && willBounce) {
const newTargets = move.isMultiTarget() ? getMoveTargets(target, move.id).targets : [ user.getBattlerIndex() ];
if (!isReflecting && canMagicBounce) {
queuedPhases.push(new ShowAbilityPhase(target.getBattlerIndex(), target.getPassiveAbility().hasAttr(ReflectStatusMoveAbAttr)));
}
queuedPhases.push(new MessagePhase(i18next.t("battle:magicCoatBounce", { pokemonNameWithAffix: getPokemonNameWithAffix(target) })));
queuedPhases.push(new MovePhase(target, newTargets, new PokemonMove(move.id, 0, 0, true), true, true, true));
}
/** Is the pokemon immune due to an ablility, and also not in a semi invulnerable state? */
const isImmune = target.hasAbilityWithAttr(TypeImmunityAbAttr)
&& (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move))
&& !target.getTag(SemiInvulnerableTag);
/** Is the target hidden by the effects of its Commander ability? */
const isCommanding = globalScene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target;
/**
* If the move missed a target, stop all future hits against that target
@ -364,6 +418,8 @@ export class MoveEffectPhase extends PokemonPhase {
applyAttrs.push(k);
}
// Apply queued phases
queuedPhases.forEach(p => globalScene.unshiftPhase(p));
// Apply the move's POST_TARGET effects on the move's last hit, after all targeted effects have resolved
const postTarget = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) ?
applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, user, null, move) :

View File

@ -58,6 +58,7 @@ export class MovePhase extends BattlePhase {
protected ignorePp: boolean;
protected failed: boolean = false;
protected cancelled: boolean = false;
protected reflected: boolean = false;
public get pokemon(): Pokemon {
return this._pokemon;
@ -84,10 +85,12 @@ export class MovePhase extends BattlePhase {
}
/**
* @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer.
* @param followUp Indicates that the move being used is a "follow-up" - for example, a move being used by Metronome or Dancer.
* Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc.
* @param reflected Indicates that the move was reflected by Magic Coat or Magic Bounce.
* Reflected moves cannot be reflected again and will not trigger Dancer.
*/
constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) {
constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false, reflected: boolean = false) {
super();
this.pokemon = pokemon;
@ -95,6 +98,7 @@ export class MovePhase extends BattlePhase {
this.move = move;
this.followUp = followUp;
this.ignorePp = ignorePp;
this.reflected = reflected;
}
/**
@ -140,7 +144,7 @@ export class MovePhase extends BattlePhase {
}
// Check move to see if arena.ignoreAbilities should be true.
if (!this.followUp) {
if (!this.followUp || this.reflected) {
if (this.move.getMove().checkFlag(MoveFlags.IGNORE_ABILITIES, this.pokemon, null)) {
globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex());
}
@ -335,7 +339,7 @@ export class MovePhase extends BattlePhase {
*/
if (success) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move));
globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move, this.reflected));
} else {
if ([ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) {

View File

@ -148,22 +148,6 @@ describe("Abilities - Magic Bounce", () => {
expect(game.scene.getEnemyPokemon()!.getTag(BattlerTagType.CURSED)).toBeDefined();
});
// todo: a move reflected by magic bounce counts as though it failed.
it("should not count the bounced move as the last move used", async () => {
game.override.enemyMoveset([ Moves.INSTRUCT, Moves.GROWL, Moves.SPLASH ]);
game.override.battleType("double");
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
game.move.select(Moves.GROWL, 0);
game.move.select(Moves.SPLASH, 1);
game.forceEnemyMove(Moves.SPLASH);
game.forceEnemyMove(Moves.INSTRUCT);
game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2 ]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].move).toEqual([ Moves.SPLASH ]);
});
it("should cause stomping tantrum to double in power if the bounced move fails", async () => {
game.override.moveset([ Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);