[Move] Implement Substitute (#2559)

* Implement Substitute

Squashed commit from working branch

* Fix integration test imports

* Use Override Helper utils + Fix Baton Pass test

* Update src/test/moves/substitute.test.ts

Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com>

* Fix test imports + nits

* Document RemoveAllSubstitutesAttr

* Fix some strict-null issues

* more strict-null fixes

* Fix baton pass test

* Reorganized Substitute translation keys

* Added checks for substitute in contact logic

* Clean up Unseen Fist contact logic

* Remove misleading comment in Download attr

* RIP phases.ts

* Fix imports post-phase migration

* Rewrite `move.canIgnoreSubstitute` to `move.hitsSubstitute`

* Also fixed interactions with Shell Trap and Beak Blast

* Removed some leftover `canIgnoreSubstitute`s

* fix issues after beta merge

* Status move effectiveness now accounts for substitute

* More edge case tests (Counter test failing)

* Fix Counter + Trap edge cases + add Fail messagesd

* Fix leftover nit

* Resolve leftover test issues

* Fix Sub offset carrying over to Trainer fights

* Hide substitute sprite during catch attempts

* Make substitutes baton-passable again

* Remove placeholder locale keys and SPLASH_ONLY

* Fix imports and other nits

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* ESLint

* Fix imports

* Fix incorrect `resetSprite` timing

* Fix substitute disappearing on hit (maybe?)

* More animation fixes (mostly for Roar)

---------

Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com>
Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
innerthunder 2024-09-13 09:46:22 -07:00 committed by GitHub
parent 526f9ae2bc
commit 70295280da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1652 additions and 116 deletions

View File

@ -64,6 +64,7 @@ import { PlayerGender } from "#enums/player-gender";
import { Species } from "#enums/species";
import { UiTheme } from "#enums/ui-theme";
import { TimedEventManager } from "#app/timed-event-manager";
import { PokemonAnimType } from "#enums/pokemon-anim-type";
import i18next from "i18next";
import { TrainerType } from "#enums/trainer-type";
import { battleSpecDialogue } from "./data/dialogue";
@ -74,6 +75,7 @@ import { MessagePhase } from "./phases/message-phase";
import { MovePhase } from "./phases/move-phase";
import { NewBiomeEncounterPhase } from "./phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "./phases/next-encounter-phase";
import { PokemonAnimPhase } from "./phases/pokemon-anim-phase";
import { QuietFormChangePhase } from "./phases/quiet-form-change-phase";
import { ReturnPhase } from "./phases/return-phase";
import { SelectBiomePhase } from "./phases/select-biome-phase";
@ -2721,6 +2723,16 @@ export default class BattleScene extends SceneBase {
return false;
}
triggerPokemonBattleAnim(pokemon: Pokemon, battleAnimType: PokemonAnimType, fieldAssets?: Phaser.GameObjects.Sprite[], delayed: boolean = false): boolean {
const phase: Phase = new PokemonAnimPhase(this, battleAnimType, pokemon, fieldAssets);
if (delayed) {
this.pushPhase(phase);
} else {
this.unshiftPhase(phase);
}
return true;
}
validateAchvs(achvType: Constructor<Achv>, ...args: unknown[]): void {
const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType);
for (const achv of filteredAchvs) {

View File

@ -1706,6 +1706,10 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr {
}
applyPostAttackAfterMoveTypeCheck(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
if (pokemon !== attacker && move.hitsSubstitute(attacker, pokemon)) {
return false;
}
/**Status inflicted by abilities post attacking are also considered additional effects.*/
if (!attacker.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !simulated && pokemon !== attacker && (!this.contactRequired || move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)) && pokemon.randSeedInt(100) < this.chance && !pokemon.status) {
const effect = this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randSeedInt(this.effects.length)];
@ -2064,6 +2068,10 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr {
if (this.intimidate) {
applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled, simulated);
applyAbAttrs(PostIntimidateStatStageChangeAbAttr, opponent, cancelled, simulated);
if (opponent.getTag(BattlerTagType.SUBSTITUTE)) {
cancelled.value = true;
}
}
if (!cancelled.value) {
pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, opponent.getBattlerIndex(), false, this.stats, this.stages));
@ -2143,7 +2151,6 @@ export class DownloadAbAttr extends PostSummonAbAttr {
private enemyCountTally: integer;
private stats: BattleStat[];
// TODO: Implement the Substitute feature(s) once move is implemented.
/**
* Checks to see if it is the opening turn (starting a new game), if so, Download won't work. This is because Download takes into account
* vitamins and items, so it needs to use the Stat and the stat alone.
@ -4781,7 +4788,7 @@ export const allAbilities = [ new Ability(Abilities.NONE, 3) ];
export function initAbilities() {
allAbilities.push(
new Ability(Abilities.STENCH, 3)
.attr(PostAttackApplyBattlerTagAbAttr, false, (user, target, move) => !move.hasAttr(FlinchAttr) ? 10 : 0, BattlerTagType.FLINCHED),
.attr(PostAttackApplyBattlerTagAbAttr, false, (user, target, move) => !move.hasAttr(FlinchAttr) && !move.hitsSubstitute(user, target) ? 10 : 0, BattlerTagType.FLINCHED),
new Ability(Abilities.DRIZZLE, 3)
.attr(PostSummonWeatherChangeAbAttr, WeatherType.RAIN)
.attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.RAIN),

View File

@ -6,6 +6,7 @@ import * as Utils from "../utils";
import { BattlerIndex } from "../battle";
import { Element } from "json-stable-stringify";
import { Moves } from "#enums/moves";
import { SubstituteTag } from "./battler-tags";
//import fs from 'vite-plugin-fs/browser';
export enum AnimFrameTarget {
@ -700,7 +701,7 @@ export abstract class BattleAnim {
return false;
}
private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[]): Map<integer, Map<AnimFrameTarget, GraphicFrameData>> {
private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[], onSubstitute?: boolean): Map<integer, Map<AnimFrameTarget, GraphicFrameData>> {
const ret: Map<integer, Map<AnimFrameTarget, GraphicFrameData>> = new Map([
[AnimFrameTarget.GRAPHIC, new Map<AnimFrameTarget, GraphicFrameData>() ],
[AnimFrameTarget.USER, new Map<AnimFrameTarget, GraphicFrameData>() ],
@ -711,12 +712,15 @@ export abstract class BattleAnim {
const user = !isOppAnim ? this.user : this.target;
const target = !isOppAnim ? this.target : this.user;
const targetSubstitute = (onSubstitute && user !== target) ? target!.getTag(SubstituteTag) : null;
const userInitialX = user!.x; // TODO: is this bang correct?
const userInitialY = user!.y; // TODO: is this bang correct?
const userHalfHeight = user!.getSprite().displayHeight! / 2; // TODO: is this bang correct?
const targetInitialX = target!.x; // TODO: is this bang correct?
const targetInitialY = target!.y; // TODO: is this bang correct?
const targetHalfHeight = target!.getSprite().displayHeight! / 2; // TODO: is this bang correct?
const targetInitialX = targetSubstitute?.sprite?.x ?? target!.x; // TODO: is this bang correct?
const targetInitialY = targetSubstitute?.sprite?.y ?? target!.y; // TODO: is this bang correct?
const targetHalfHeight = (targetSubstitute?.sprite ?? target!.getSprite()).displayHeight! / 2; // TODO: is this bang correct?
let g = 0;
let u = 0;
@ -754,7 +758,7 @@ export abstract class BattleAnim {
return ret;
}
play(scene: BattleScene, callback?: Function) {
play(scene: BattleScene, onSubstitute?: boolean, callback?: Function) {
const isOppAnim = this.isOppAnim();
const user = !isOppAnim ? this.user! : this.target!; // TODO: are those bangs correct?
const target = !isOppAnim ? this.target : this.user;
@ -766,8 +770,10 @@ export abstract class BattleAnim {
return;
}
const targetSubstitute = (!!onSubstitute && user !== target) ? target.getTag(SubstituteTag) : null;
const userSprite = user.getSprite();
const targetSprite = target.getSprite();
const targetSprite = targetSubstitute?.sprite ?? target.getSprite();
const spriteCache: SpriteCache = {
[AnimFrameTarget.GRAPHIC]: [],
@ -782,16 +788,34 @@ export abstract class BattleAnim {
userSprite.setAlpha(1);
userSprite.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ];
userSprite.setAngle(0);
targetSprite.setPosition(0, 0);
targetSprite.setScale(1);
targetSprite.setAlpha(1);
if (!targetSubstitute) {
targetSprite.setPosition(0, 0);
targetSprite.setScale(1);
targetSprite.setAlpha(1);
} else {
targetSprite.setPosition(
target.x - target.getSubstituteOffset()[0],
target.y - target.getSubstituteOffset()[1]
);
targetSprite.setScale(target.getSpriteScale() * (target.isPlayer() ? 0.5 : 1));
targetSprite.setAlpha(1);
}
targetSprite.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ];
targetSprite.setAngle(0);
if (!this.isHideUser() && userSprite) {
this.user?.getSprite().setVisible(true); // using this.user to fix context loss due to isOppAnim swap (#481)
/**
* This and `targetSpriteToShow` are used to restore context lost
* from the `isOppAnim` swap. Using these references instead of `this.user`
* and `this.target` prevent the target's Substitute doll from disappearing
* after being the target of an animation.
*/
const userSpriteToShow = !isOppAnim ? userSprite : targetSprite;
const targetSpriteToShow = !isOppAnim ? targetSprite : userSprite;
if (!this.isHideUser() && userSpriteToShow) {
userSpriteToShow.setVisible(true);
}
if (!this.isHideTarget() && (targetSprite !== userSprite || !this.isHideUser())) {
this.target?.getSprite().setVisible(true); // using this.target to fix context loss due to isOppAnim swap (#481)
if (!this.isHideTarget() && (targetSpriteToShow !== userSpriteToShow || !this.isHideUser())) {
targetSpriteToShow.setVisible(true);
}
for (const ms of Object.values(spriteCache).flat()) {
if (ms) {
@ -814,8 +838,8 @@ export abstract class BattleAnim {
const userInitialX = user.x;
const userInitialY = user.y;
const targetInitialX = target.x;
const targetInitialY = target.y;
const targetInitialX = targetSubstitute?.sprite?.x ?? target.x;
const targetInitialY = targetSubstitute?.sprite?.y ?? target.y;
this.srcLine = [ userFocusX, userFocusY, targetFocusX, targetFocusY ];
this.dstLine = [ userInitialX, userInitialY, targetInitialX, targetInitialY ];
@ -833,7 +857,7 @@ export abstract class BattleAnim {
}
const spriteFrames = anim!.frames[f]; // TODO: is the bang correcT?
const frameData = this.getGraphicFrameData(scene, anim!.frames[f]); // TODO: is the bang correct?
const frameData = this.getGraphicFrameData(scene, anim!.frames[f], onSubstitute); // TODO: is the bang correct?
let u = 0;
let t = 0;
let g = 0;
@ -846,24 +870,34 @@ export abstract class BattleAnim {
const sprites = spriteCache[isUser ? AnimFrameTarget.USER : AnimFrameTarget.TARGET];
const spriteSource = isUser ? userSprite : targetSprite;
if ((isUser ? u : t) === sprites.length) {
const sprite = scene.addPokemonSprite(isUser ? user! : target, 0, 0, spriteSource!.texture, spriteSource!.frame.name, true); // TODO: are those bangs correct?
[ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user! : target).getSprite().pipelineData[k]); // TODO: are those bangs correct?
sprite.setPipelineData("spriteKey", (isUser ? user! : target).getBattleSpriteKey());
sprite.setPipelineData("shiny", (isUser ? user : target).shiny);
sprite.setPipelineData("variant", (isUser ? user : target).variant);
sprite.setPipelineData("ignoreFieldPos", true);
spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame));
scene.field.add(sprite);
sprites.push(sprite);
if (!isUser && !!targetSubstitute) {
const sprite = scene.addPokemonSprite(isUser ? user! : target, 0, 0, spriteSource!.texture, spriteSource!.frame.name, true); // TODO: are those bangs correct?
[ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user! : target).getSprite().pipelineData[k]); // TODO: are those bangs correct?
sprite.setPipelineData("spriteKey", (isUser ? user! : target).getBattleSpriteKey());
sprite.setPipelineData("shiny", (isUser ? user : target).shiny);
sprite.setPipelineData("variant", (isUser ? user : target).variant);
sprite.setPipelineData("ignoreFieldPos", true);
spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame));
scene.field.add(sprite);
sprites.push(sprite);
} else {
const sprite = scene.addFieldSprite(spriteSource.x, spriteSource.y, spriteSource.texture);
spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame));
scene.field.add(sprite);
sprites.push(sprite);
}
}
const spriteIndex = isUser ? u++ : t++;
const pokemonSprite = sprites[spriteIndex];
const graphicFrameData = frameData.get(frame.target)!.get(spriteIndex)!; // TODO: are the bangs correct?
pokemonSprite.setPosition(graphicFrameData.x, graphicFrameData.y - ((spriteSource.height / 2) * (spriteSource.parentContainer.scale - 1)));
const spriteSourceScale = (isUser || !targetSubstitute)
? spriteSource.parentContainer.scale
: target.getSpriteScale() * (target.isPlayer() ? 0.5 : 1);
pokemonSprite.setPosition(graphicFrameData.x, graphicFrameData.y - ((spriteSource.height / 2) * (spriteSourceScale - 1)));
pokemonSprite.setAngle(graphicFrameData.angle);
pokemonSprite.setScale(graphicFrameData.scaleX * spriteSource.parentContainer.scale, graphicFrameData.scaleY * spriteSource.parentContainer.scale);
pokemonSprite.setScale(graphicFrameData.scaleX * spriteSourceScale, graphicFrameData.scaleY * spriteSourceScale);
pokemonSprite.setData("locked", frame.locked);

View File

@ -22,6 +22,7 @@ import { MovePhase } from "#app/phases/move-phase";
import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase";
import { ShowAbilityPhase } from "#app/phases/show-ability-phase";
import { StatStageChangePhase, StatStageChangeCallback } from "#app/phases/stat-stage-change-phase";
import { PokemonAnimType } from "#app/enums/pokemon-anim-type";
export enum BattlerTagLapseType {
FAINT,
@ -30,6 +31,7 @@ export enum BattlerTagLapseType {
AFTER_MOVE,
MOVE_EFFECT,
TURN_END,
HIT,
CUSTOM
}
@ -391,9 +393,11 @@ export class BeakBlastChargingTag extends BattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM) {
const effectPhase = pokemon.scene.getCurrentPhase();
if (effectPhase instanceof MoveEffectPhase && effectPhase.move.getMove().hasFlag(MoveFlags.MAKES_CONTACT)) {
if (effectPhase instanceof MoveEffectPhase) {
const attacker = effectPhase.getPokemon();
attacker.trySetStatus(StatusEffect.BURN, true, pokemon);
if (effectPhase.move.getMove().checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)) {
attacker.trySetStatus(StatusEffect.BURN, true, pokemon);
}
}
return true;
}
@ -451,10 +455,14 @@ export class TrappedTag extends BattlerTag {
}
canAdd(pokemon: Pokemon): boolean {
const source = pokemon.scene.getPokemonById(this.sourceId!)!;
const move = allMoves[this.sourceMove];
const isGhost = pokemon.isOfType(Type.GHOST);
const isTrapped = pokemon.getTag(TrappedTag);
const hasSubstitute = move.hitsSubstitute(source, pokemon);
return !isTrapped && !isGhost;
return !isTrapped && !isGhost && !hasSubstitute;
}
onAdd(pokemon: Pokemon): void {
@ -1121,7 +1129,7 @@ export abstract class DamagingTrapTag extends TrappedTag {
}
canAdd(pokemon: Pokemon): boolean {
return !pokemon.getTag(TrappedTag);
return !pokemon.getTag(TrappedTag) && !pokemon.getTag(BattlerTagType.SUBSTITUTE);
}
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
@ -2007,7 +2015,6 @@ export class FormBlockDamageTag extends BattlerTag {
pokemon.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger);
}
}
/** Provides the additional weather-based effects of the Ice Face ability */
export class IceFaceBlockDamageTag extends FormBlockDamageTag {
constructor(tagType: BattlerTagType) {
@ -2055,7 +2062,6 @@ export class StockpilingTag extends BattlerTag {
if (defChange) {
this.statChangeCounts[Stat.DEF]++;
}
if (spDefChange) {
this.statChangeCounts[Stat.SPDEF]++;
}
@ -2211,6 +2217,93 @@ export class TarShotTag extends BattlerTag {
}
}
export class SubstituteTag extends BattlerTag {
/** The substitute's remaining HP. If HP is depleted, the Substitute fades. */
public hp: number;
/** A reference to the sprite representing the Substitute doll */
public sprite: Phaser.GameObjects.Sprite;
/** Is the source Pokemon "in focus," i.e. is it fully visible on the field? */
public sourceInFocus: boolean;
constructor(sourceMove: Moves, sourceId: integer) {
super(BattlerTagType.SUBSTITUTE, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT], 0, sourceMove, sourceId, true);
}
/** Sets the Substitute's HP and queues an on-add battle animation that initializes the Substitute's sprite. */
onAdd(pokemon: Pokemon): void {
this.hp = Math.floor(pokemon.scene.getPokemonById(this.sourceId!)!.getMaxHp() / 4);
this.sourceInFocus = false;
// Queue battle animation and message
pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_ADD);
pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), 1500);
// Remove any trapping effects from the user
pokemon.findAndRemoveTags(tag => tag instanceof TrappedTag);
}
/** Queues an on-remove battle animation that removes the Substitute's sprite. */
onRemove(pokemon: Pokemon): void {
// Only play the animation if the cause of removal isn't from the source's own move
if (!this.sourceInFocus) {
pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_REMOVE, [this.sprite]);
} else {
this.sprite.destroy();
}
pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnRemove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }));
}
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
switch (lapseType) {
case BattlerTagLapseType.PRE_MOVE:
this.onPreMove(pokemon);
break;
case BattlerTagLapseType.AFTER_MOVE:
this.onAfterMove(pokemon);
break;
case BattlerTagLapseType.HIT:
this.onHit(pokemon);
break;
}
return lapseType !== BattlerTagLapseType.CUSTOM; // only remove this tag on custom lapse
}
/** Triggers an animation that brings the Pokemon into focus before it uses a move */
onPreMove(pokemon: Pokemon): void {
pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_PRE_MOVE, [this.sprite]);
this.sourceInFocus = true;
}
/** Triggers an animation that brings the Pokemon out of focus after it uses a move */
onAfterMove(pokemon: Pokemon): void {
pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_POST_MOVE, [this.sprite]);
this.sourceInFocus = false;
}
/** If the Substitute redirects damage, queue a message to indicate it. */
onHit(pokemon: Pokemon): void {
const moveEffectPhase = pokemon.scene.getCurrentPhase();
if (moveEffectPhase instanceof MoveEffectPhase) {
const attacker = moveEffectPhase.getUserPokemon()!;
const move = moveEffectPhase.move.getMove();
const firstHit = (attacker.turnData.hitCount === attacker.turnData.hitsLeft);
if (firstHit && move.hitsSubstitute(attacker, pokemon)) {
pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnHit", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }));
}
}
}
/**
* When given a battler tag or json representing one, load the data for it.
* @param {BattlerTag | any} source A battler tag
*/
loadTag(source: BattlerTag | any): void {
super.loadTag(source);
this.hp = source.hp;
}
}
/**
* Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID.
*
@ -2370,6 +2463,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new ThroatChoppedTag();
case BattlerTagType.GORILLA_TACTICS:
return new GorillaTacticsTag();
case BattlerTagType.SUBSTITUTE:
return new SubstituteTag(sourceMove, sourceId);
case BattlerTagType.NONE:
default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);

View File

@ -1,5 +1,5 @@
import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims";
import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, ShellTrapTag, StockpilingTag, TrappedTag, TypeBoostTag } from "./battler-tags";
import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, ShellTrapTag, StockpilingTag, TrappedTag, SubstituteTag, TypeBoostTag } from "./battler-tags";
import { getPokemonNameWithAffix } from "../messages";
import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon";
import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects } from "./status-effect";
@ -115,9 +115,11 @@ export enum MoveFlags {
TRIAGE_MOVE = 1 << 15,
IGNORE_ABILITIES = 1 << 16,
/** Enables all hits of a multi-hit move to be accuracy checked individually */
CHECK_ALL_HITS = 1 << 17,
CHECK_ALL_HITS = 1 << 17,
/** Indicates a move is able to bypass its target's Substitute (if the target has one) */
IGNORE_SUBSTITUTE = 1 << 18,
/** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */
REDIRECT_COUNTER = 1 << 18,
REDIRECT_COUNTER = 1 << 19,
}
type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
@ -333,6 +335,22 @@ export default class Move implements Localizable {
return false;
}
/**
* Checks if the move would hit its target's Substitute instead of the target itself.
* @param user The {@linkcode Pokemon} using this move
* @param target The {@linkcode Pokemon} targeted by this move
* @returns `true` if the move can bypass the target's Substitute; `false` otherwise.
*/
hitsSubstitute(user: Pokemon, target: Pokemon | null): boolean {
if (this.moveTarget === MoveTarget.USER || !target?.getTag(BattlerTagType.SUBSTITUTE)) {
return false;
}
return !user.hasAbility(Abilities.INFILTRATOR)
&& !this.hasFlag(MoveFlags.SOUND_BASED)
&& !this.hasFlag(MoveFlags.IGNORE_SUBSTITUTE);
}
/**
* Adds a move condition to the move
* @param condition {@linkcode MoveCondition} or {@linkcode MoveConditionFunc}, appends to conditions array a new MoveCondition object
@ -576,6 +594,17 @@ export default class Move implements Localizable {
return this;
}
/**
* Sets the {@linkcode MoveFlags.IGNORE_SUBSTITUTE} flag for the calling Move
* @param ignoresSubstitute The value (boolean) to set the flag to
* example: @see {@linkcode Moves.WHIRLWIND}
* @returns The {@linkcode Move} that called this function
*/
ignoresSubstitute(ignoresSubstitute: boolean = true): this {
this.setFlag(MoveFlags.IGNORE_SUBSTITUTE, ignoresSubstitute);
return this;
}
/**
* Sets the {@linkcode MoveFlags.REDIRECT_COUNTER} flag for the calling Move
* @param redirectCounter The value (boolean) to set the flag to
@ -598,7 +627,7 @@ export default class Move implements Localizable {
// special cases below, eg: if the move flag is MAKES_CONTACT, and the user pokemon has an ability that ignores contact (like "Long Reach"), then overrides and move does not make contact
switch (flag) {
case MoveFlags.MAKES_CONTACT:
if (user.hasAbilityWithAttr(IgnoreContactAbAttr)) {
if (user.hasAbilityWithAttr(IgnoreContactAbAttr) || this.hitsSubstitute(user, target)) {
return false;
}
break;
@ -612,8 +641,8 @@ export default class Move implements Localizable {
}
break;
case MoveFlags.IGNORE_PROTECT:
if (user.hasAbilityWithAttr(IgnoreProtectOnContactAbAttr) &&
this.checkFlag(MoveFlags.MAKES_CONTACT, user, target)) {
if (user.hasAbilityWithAttr(IgnoreProtectOnContactAbAttr)
&& this.checkFlag(MoveFlags.MAKES_CONTACT, user, null)) {
return true;
}
break;
@ -1446,6 +1475,58 @@ export class HalfSacrificialAttr extends MoveEffectAttr {
}
}
/**
* Attribute to put in a {@link https://bulbapedia.bulbagarden.net/wiki/Substitute_(doll) | Substitute Doll}
* for the user.
* @extends MoveEffectAttr
* @see {@linkcode apply}
*/
export class AddSubstituteAttr extends MoveEffectAttr {
constructor() {
super(true);
}
/**
* Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user
* @param user the {@linkcode Pokemon} that used the move.
* @param target n/a
* @param move the {@linkcode Move} with this attribute.
* @param args n/a
* @returns true if the attribute successfully applies, false otherwise
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.apply(user, target, move, args)) {
return false;
}
const hpCost = Math.floor(user.getMaxHp() / 4);
user.damageAndUpdate(hpCost, HitResult.OTHER, false, true, true);
user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id);
return true;
}
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
if (user.isBoss()) {
return -10;
}
return 5;
}
getCondition(): MoveConditionFunc {
return (user, target, move) => !user.getTag(SubstituteTag) && user.hp > Math.floor(user.getMaxHp() / 4) && user.getMaxHp() > 1;
}
getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null {
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");
}
}
}
export enum MultiHitType {
_2,
_2_TO_5,
@ -1949,6 +2030,10 @@ export class StatusEffectAttr extends MoveEffectAttr {
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!this.selfTarget && move.hitsSubstitute(user, target)) {
return false;
}
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance;
if (statusCheck) {
@ -2048,6 +2133,9 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise<boolean>(resolve => {
if (move.hitsSubstitute(user, target)) {
return resolve(false);
}
const rand = Phaser.Math.RND.realInRange(0, 1);
if (rand >= this.chance) {
return resolve(false);
@ -2117,6 +2205,10 @@ export class RemoveHeldItemAttr extends MoveEffectAttr {
return false;
}
if (move.hitsSubstitute(user, target)) {
return false;
}
const cancelled = new Utils.BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft
@ -2236,6 +2328,9 @@ export class StealEatBerryAttr extends EatBerryAttr {
* @returns {boolean} true if the function succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (move.hitsSubstitute(user, target)) {
return false;
}
const cancelled = new Utils.BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft
if (cancelled.value === true) {
@ -2286,6 +2381,10 @@ export class HealStatusEffectAttr extends MoveEffectAttr {
return false;
}
if (!this.selfTarget && move.hitsSubstitute(user, target)) {
return false;
}
// Special edge case for shield dust blocking Sparkling Aria curing burn
const moveTargets = getMoveTargets(user, move.id);
if (target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && move.id === Moves.SPARKLING_ARIA && moveTargets.targets.length === 1) {
@ -2463,7 +2562,7 @@ export class ChargeAttr extends OverrideMoveEffectAttr {
const lastMove = user.getLastXMoves().find(() => true);
if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && lastMove.turn !== user.scene.currentBattle.turn)) {
(args[0] as Utils.BooleanHolder).value = true;
new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, () => {
new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => {
user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
if (this.tagType) {
user.addTag(this.tagType, 1, move.id, user.id);
@ -2563,7 +2662,7 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise(resolve => {
if (args.length < 2 || !args[1]) {
new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, () => {
new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => {
(args[0] as Utils.BooleanHolder).value = true;
user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER });
@ -2597,6 +2696,10 @@ export class StatStageChangeAttr extends MoveEffectAttr {
return false;
}
if (!this.selfTarget && move.hitsSubstitute(user, target)) {
return false;
}
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) {
const stages = this.getLevels(user);
@ -2793,8 +2896,10 @@ export class ResetStatsAttr extends MoveEffectAttr {
activePokemon.forEach(p => promises.push(this.resetStats(p)));
target.scene.queueMessage(i18next.t("moveTriggers:statEliminated"));
} else { // Affects only the single target when Clear Smog is used
promises.push(this.resetStats(target));
target.scene.queueMessage(i18next.t("moveTriggers:resetStats", {pokemonName: getPokemonNameWithAffix(target)}));
if (!move.hitsSubstitute(user, target)) {
promises.push(this.resetStats(target));
target.scene.queueMessage(i18next.t("moveTriggers:resetStats", {pokemonName: getPokemonNameWithAffix(target)}));
}
}
await Promise.all(promises);
@ -4621,6 +4726,13 @@ export class FlinchAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.FLINCHED, false);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!move.hitsSubstitute(user, target)) {
return super.apply(user, target, move, args);
}
return false;
}
}
export class ConfuseAttr extends AddBattlerTagAttr {
@ -4636,7 +4748,10 @@ export class ConfuseAttr extends AddBattlerTagAttr {
return false;
}
return super.apply(user, target, move, args);
if (!move.hitsSubstitute(user, target)) {
return super.apply(user, target, move, args);
}
return false;
}
}
@ -4710,6 +4825,36 @@ export class FaintCountdownAttr extends AddBattlerTagAttr {
}
}
/**
* Attribute to remove all Substitutes from the field.
* @extends MoveEffectAttr
* @see {@link https://bulbapedia.bulbagarden.net/wiki/Tidy_Up_(move) | Tidy Up}
* @see {@linkcode SubstituteTag}
*/
export class RemoveAllSubstitutesAttr extends MoveEffectAttr {
constructor() {
super(true);
}
/**
* Remove's the Substitute Doll effect from all active Pokemon on the field
* @param user {@linkcode Pokemon} the Pokemon using this move
* @param target n/a
* @param move {@linkcode Move} the move applying this effect
* @param args n/a
* @returns `true` if the effect successfully applies
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.apply(user, target, move, args)) {
return false;
}
user.scene.getField(true).forEach(pokemon =>
pokemon.findAndRemoveTags(tag => tag.tagType === BattlerTagType.SUBSTITUTE));
return true;
}
}
/**
* Attribute used when a move hits a {@linkcode BattlerTagType} for double damage
* @extends MoveAttr
@ -5099,6 +5244,10 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
const switchOutTarget = (this.user ? user : target);
const player = switchOutTarget instanceof PlayerPokemon;
if (!this.user && move.hitsSubstitute(user, target)) {
return false;
}
if (!this.user && move.category === MoveCategory.STATUS && (target.hasAbilityWithAttr(ForceSwitchOutImmunityAbAttr) || target.isMax())) {
return false;
}
@ -6615,6 +6764,7 @@ export function initMoves() {
new StatusMove(Moves.WHIRLWIND, Type.NORMAL, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING, false)
.ignoresSubstitute()
.hidesTarget()
.windMove(),
new AttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1)
@ -6705,6 +6855,7 @@ export function initMoves() {
new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1)
.attr(AddBattlerTagAttr, BattlerTagType.DISABLED, false, true)
.condition((user, target, move) => target.getMoveHistory().reverse().find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual) !== undefined)
.ignoresSubstitute()
.condition(failOnMaxCondition),
new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -1)
@ -6842,6 +6993,7 @@ export function initMoves() {
.attr(LevelDamageAttr),
new StatusMove(Moves.MIMIC, Type.NORMAL, -1, 10, -1, 0, 1)
.attr(MovesetCopyMoveAttr)
.ignoresSubstitute()
.ignoresVirtual(),
new StatusMove(Moves.SCREECH, Type.NORMAL, 85, 40, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], -2)
@ -6870,6 +7022,7 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.LIGHT_SCREEN, 5, true)
.target(MoveTarget.USER_SIDE),
new SelfStatusMove(Moves.HAZE, Type.ICE, -1, 30, -1, 0, 1)
.ignoresSubstitute()
.attr(ResetStatsAttr, true),
new StatusMove(Moves.REFLECT, Type.PSYCHIC, -1, 20, -1, 0, 1)
.attr(AddArenaTagAttr, ArenaTagType.REFLECT, 5, true)
@ -7012,14 +7165,14 @@ export function initMoves() {
.attr(HighCritAttr)
.slicingMove(),
new SelfStatusMove(Moves.SUBSTITUTE, Type.NORMAL, -1, 10, -1, 0, 1)
.attr(RecoilAttr)
.unimplemented(),
.attr(AddSubstituteAttr),
new AttackMove(Moves.STRUGGLE, Type.NORMAL, MoveCategory.PHYSICAL, 50, -1, 1, -1, 0, 1)
.attr(RecoilAttr, true, 0.25, true)
.attr(TypelessAttr)
.ignoresVirtual()
.target(MoveTarget.RANDOM_NEAR_ENEMY),
new StatusMove(Moves.SKETCH, Type.NORMAL, -1, 1, -1, 0, 2)
.ignoresSubstitute()
.attr(SketchAttr)
.ignoresVirtual(),
new AttackMove(Moves.TRIPLE_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 10, 90, 10, -1, 0, 2)
@ -7045,12 +7198,14 @@ export function initMoves() {
.soundBased(),
new StatusMove(Moves.CURSE, Type.GHOST, -1, 10, -1, 0, 2)
.attr(CurseAttr)
.ignoresSubstitute()
.ignoresProtect(true)
.target(MoveTarget.CURSE),
new AttackMove(Moves.FLAIL, Type.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2)
.attr(LowHpPowerAttr),
new StatusMove(Moves.CONVERSION_2, Type.NORMAL, -1, 30, -1, 0, 2)
.attr(ResistLastMoveTypeAttr)
.ignoresSubstitute()
.partial(), // Checks the move's original typing and not if its type is changed through some other means
new AttackMove(Moves.AEROBLAST, Type.FLYING, MoveCategory.SPECIAL, 100, 95, 5, -1, 0, 2)
.windMove()
@ -7062,6 +7217,7 @@ export function initMoves() {
new AttackMove(Moves.REVERSAL, Type.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2)
.attr(LowHpPowerAttr),
new StatusMove(Moves.SPITE, Type.GHOST, 100, 10, -1, 0, 2)
.ignoresSubstitute()
.attr(ReducePpMoveAttr, 4),
new AttackMove(Moves.POWDER_SNOW, Type.ICE, MoveCategory.SPECIAL, 40, 100, 25, 10, 0, 2)
.attr(StatusEffectAttr, StatusEffect.FREEZE)
@ -7095,7 +7251,8 @@ export function initMoves() {
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.ballBombMove(),
new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST),
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST)
.ignoresSubstitute(),
new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2)
.ignoresProtect()
.attr(DestinyBondAttr)
@ -7164,6 +7321,7 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1),
new StatusMove(Moves.ATTRACT, Type.NORMAL, 100, 15, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.INFATUATED)
.ignoresSubstitute()
.condition((user, target, move) => user.isOppositeGender(target)),
new SelfStatusMove(Moves.SLEEP_TALK, Type.NORMAL, -1, 10, -1, 0, 2)
.attr(BypassSleepAttr)
@ -7209,6 +7367,7 @@ export function initMoves() {
.hidesUser(),
new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
.ignoresSubstitute()
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target)),
new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2)
.partial(),
@ -7267,6 +7426,7 @@ export function initMoves() {
.attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2)
.target(MoveTarget.ATTACKER),
new StatusMove(Moves.PSYCH_UP, Type.NORMAL, -1, 10, -1, 0, 2)
.ignoresSubstitute()
.attr(CopyStatsAttr),
new AttackMove(Moves.EXTREME_SPEED, Type.NORMAL, MoveCategory.PHYSICAL, 80, 100, 5, -1, 2, 2),
new AttackMove(Moves.ANCIENT_POWER, Type.ROCK, MoveCategory.SPECIAL, 60, 100, 5, 10, 0, 2)
@ -7315,6 +7475,7 @@ export function initMoves() {
.attr(WeatherChangeAttr, WeatherType.HAIL)
.target(MoveTarget.BOTH_SIDES),
new StatusMove(Moves.TORMENT, Type.DARK, 100, 15, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(),
new StatusMove(Moves.FLATTER, Type.DARK, 100, 15, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPATK ], 1)
@ -7345,13 +7506,16 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPDEF ], 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.CHARGED, true, false),
new StatusMove(Moves.TAUNT, Type.DARK, 100, 20, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(),
new StatusMove(Moves.HELPING_HAND, Type.NORMAL, -1, 20, -1, 5, 3)
.attr(AddBattlerTagAttr, BattlerTagType.HELPING_HAND)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.TRICK, Type.PSYCHIC, 100, 10, -1, 0, 3)
.unimplemented(),
new StatusMove(Moves.ROLE_PLAY, Type.PSYCHIC, -1, 10, -1, 0, 3)
.ignoresSubstitute()
.attr(AbilityCopyAttr),
new SelfStatusMove(Moves.WISH, Type.NORMAL, -1, 10, -1, 0, 3)
.triageMove()
@ -7384,8 +7548,10 @@ export function initMoves() {
.attr(HpPowerAttr)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(Moves.SKILL_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 3)
.ignoresSubstitute()
.attr(SwitchAbilitiesAttr),
new SelfStatusMove(Moves.IMPRISON, Type.PSYCHIC, -1, 10, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(),
new SelfStatusMove(Moves.REFRESH, Type.NORMAL, -1, 20, -1, 0, 3)
.attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN)
@ -7470,7 +7636,8 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE),
new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST),
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST)
.ignoresSubstitute(),
new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.makesContact(false),
@ -7582,7 +7749,8 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.target(MoveTarget.BOTH_SIDES),
new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK),
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK)
.ignoresSubstitute(),
new AttackMove(Moves.WAKE_UP_SLAP, Type.FIGHTING, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 4)
.attr(MovePowerMultiplierAttr, (user, target, move) => targetSleptOrComatoseCondition(user, target, move) ? 2 : 1)
.attr(HealStatusEffectAttr, false, StatusEffect.SLEEP),
@ -7659,6 +7827,7 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.NO_CRIT, 5, true, true)
.target(MoveTarget.USER_SIDE),
new StatusMove(Moves.ME_FIRST, Type.NORMAL, -1, 20, -1, 0, 4)
.ignoresSubstitute()
.ignoresVirtual()
.target(MoveTarget.NEAR_ENEMY)
.unimplemented(),
@ -7666,9 +7835,11 @@ export function initMoves() {
.attr(CopyMoveAttr)
.ignoresVirtual(),
new StatusMove(Moves.POWER_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4)
.attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ]),
.attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ])
.ignoresSubstitute(),
new StatusMove(Moves.GUARD_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4)
.attr(SwapStatStagesAttr, [ Stat.DEF, Stat.SPDEF ]),
.attr(SwapStatStagesAttr, [ Stat.DEF, Stat.SPDEF ])
.ignoresSubstitute(),
new AttackMove(Moves.PUNISHMENT, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4)
.makesContact(true)
.attr(PunishmentPowerAttr),
@ -7682,7 +7853,8 @@ export function initMoves() {
.attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES)
.target(MoveTarget.ENEMY_SIDE),
new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4)
.attr(SwapStatStagesAttr, BATTLE_STATS),
.attr(SwapStatStagesAttr, BATTLE_STATS)
.ignoresSubstitute(),
new SelfStatusMove(Moves.AQUA_RING, Type.WATER, -1, 20, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true),
new SelfStatusMove(Moves.MAGNET_RISE, Type.ELECTRIC, -1, 10, -1, 0, 4)
@ -7965,6 +8137,7 @@ export function initMoves() {
.attr(AbilityGiveAttr),
new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresProtect()
.ignoresSubstitute()
.target(MoveTarget.NEAR_OTHER)
.condition(failIfSingleBattle)
.condition((user, target, move) => !target.turnData.acted)
@ -8007,6 +8180,7 @@ export function initMoves() {
.partial() // Should immobilize the target, Flying types should take no damage. cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/
.attr(ChargeAttr, ChargeAnim.SKY_DROP_CHARGING, i18next.t("moveTriggers:tookTargetIntoSky", {pokemonName: "{USER}", targetName: "{TARGET}"}), BattlerTagType.FLYING) // TODO: Add 2nd turn message
.condition(failOnGravityCondition)
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE))
.ignoresVirtual(),
new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5)
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true)
@ -8021,6 +8195,7 @@ export function initMoves() {
new AttackMove(Moves.ACROBATICS, Type.FLYING, MoveCategory.PHYSICAL, 55, 100, 15, -1, 0, 5)
.attr(MovePowerMultiplierAttr, (user, target, move) => Math.max(1, 2 - 0.2 * user.getHeldItems().filter(i => i.isTransferrable).reduce((v, m) => v + m.stackCount, 0))),
new StatusMove(Moves.REFLECT_TYPE, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresSubstitute()
.attr(CopyTypeAttr),
new AttackMove(Moves.RETALIATE, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 5, -1, 0, 5)
.attr(MovePowerMultiplierAttr, (user, target, move) => {
@ -8037,6 +8212,7 @@ export function initMoves() {
.attr(SacrificialAttrOnHit),
new StatusMove(Moves.BESTOW, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresProtect()
.ignoresSubstitute()
.unimplemented(),
new AttackMove(Moves.INFERNO, Type.FIRE, MoveCategory.SPECIAL, 100, 50, 5, 100, 0, 5)
.attr(StatusEffectAttr, StatusEffect.BURN),
@ -8246,13 +8422,15 @@ export function initMoves() {
.soundBased()
.target(MoveTarget.ALL_NEAR_OTHERS),
new StatusMove(Moves.FAIRY_LOCK, Type.FAIRY, -1, 10, -1, 0, 6)
.ignoresSubstitute()
.target(MoveTarget.BOTH_SIDES)
.unimplemented(),
new SelfStatusMove(Moves.KINGS_SHIELD, Type.STEEL, -1, 10, -1, 4, 6)
.attr(ProtectAttr, BattlerTagType.KINGS_SHIELD)
.condition(failIfLastCondition),
new StatusMove(Moves.PLAY_NICE, Type.NORMAL, -1, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
.attr(StatStageChangeAttr, [ Stat.ATK ], -1)
.ignoresSubstitute(),
new StatusMove(Moves.CONFIDE, Type.NORMAL, -1, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -1)
.soundBased(),
@ -8265,7 +8443,8 @@ export function initMoves() {
.attr(HealStatusEffectAttr, false, StatusEffect.FREEZE)
.attr(StatusEffectAttr, StatusEffect.BURN),
new AttackMove(Moves.HYPERSPACE_HOLE, Type.PSYCHIC, MoveCategory.SPECIAL, 80, -1, 5, -1, 0, 6)
.ignoresProtect(),
.ignoresProtect()
.ignoresSubstitute(),
new AttackMove(Moves.WATER_SHURIKEN, Type.WATER, MoveCategory.SPECIAL, 15, 100, 20, -1, 1, 6)
.attr(MultiHitAttr)
.attr(WaterShurikenPowerAttr)
@ -8277,6 +8456,7 @@ export function initMoves() {
.condition(failIfLastCondition),
new StatusMove(Moves.AROMATIC_MIST, Type.FAIRY, -1, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], 1)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2),
@ -8284,6 +8464,7 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], -1, false, (user, target, move) => target.status?.effect === StatusEffect.POISON || target.status?.effect === StatusEffect.TOXIC)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6)
.ignoresSubstitute()
.powderMove()
.unimplemented(),
new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6)
@ -8292,6 +8473,7 @@ export function initMoves() {
.ignoresVirtual(),
new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], 1, false, (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS].find(a => target.hasAbility(a, false)))
.ignoresSubstitute()
.target(MoveTarget.USER_AND_ALLIES)
.condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ Abilities.PLUS, Abilities.MINUS].find(a => p.hasAbility(a, false)))),
new StatusMove(Moves.HAPPY_HOUR, Type.NORMAL, -1, 30, -1, 0, 6) // No animation
@ -8304,6 +8486,7 @@ export function initMoves() {
.target(MoveTarget.ALL_NEAR_ENEMIES),
new SelfStatusMove(Moves.CELEBRATE, Type.NORMAL, -1, 40, -1, 0, 6),
new StatusMove(Moves.HOLD_HANDS, Type.NORMAL, -1, 40, -1, 0, 6)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.BABY_DOLL_EYES, Type.FAIRY, 100, 30, -1, 1, 6)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
@ -8349,6 +8532,7 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true),
new AttackMove(Moves.HYPERSPACE_FURY, Type.DARK, MoveCategory.PHYSICAL, 100, -1, 5, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1, true)
.ignoresSubstitute()
.makesContact(false)
.ignoresProtect(),
/* Unused */
@ -8508,6 +8692,7 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false),
new StatusMove(Moves.GEAR_UP, Type.STEEL, -1, 20, -1, 0, 7)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS].find(a => target.hasAbility(a, false)))
.ignoresSubstitute()
.target(MoveTarget.USER_AND_ALLIES)
.condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ Abilities.PLUS, Abilities.MINUS].find(a => p.hasAbility(a, false)))),
new AttackMove(Moves.THROAT_CHOP, Type.DARK, MoveCategory.PHYSICAL, 80, 100, 15, 100, 0, 7)
@ -8538,7 +8723,8 @@ export function initMoves() {
user.scene.queueMessage(i18next.t("moveTriggers:burnedItselfOut", {pokemonName: getPokemonNameWithAffix(user)}));
}),
new StatusMove(Moves.SPEED_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 7)
.attr(SwapStatAttr, Stat.SPD),
.attr(SwapStatAttr, Stat.SPD)
.ignoresSubstitute(),
new AttackMove(Moves.SMART_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 7),
new StatusMove(Moves.PURIFY, Type.POISON, -1, 20, -1, 0, 7)
.condition(
@ -8555,6 +8741,7 @@ export function initMoves() {
new AttackMove(Moves.TROP_KICK, Type.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7)
.ignoresSubstitute()
.unimplemented(),
new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, -3, 7)
.attr(BeakBlastHeaderAttr)
@ -8622,6 +8809,7 @@ export function initMoves() {
new AttackMove(Moves.PRISMATIC_LASER, Type.PSYCHIC, MoveCategory.SPECIAL, 160, 100, 10, -1, 0, 7)
.attr(RechargeAttr),
new AttackMove(Moves.SPECTRAL_THIEF, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 7)
.ignoresSubstitute()
.partial(),
new AttackMove(Moves.SUNSTEEL_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 7)
.ignoresAbilities()
@ -9289,7 +9477,8 @@ export function initMoves() {
.target(MoveTarget.BOTH_SIDES),
new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true, null, true, true)
.attr(RemoveArenaTrapAttr, true),
.attr(RemoveArenaTrapAttr, true)
.attr(RemoveAllSubstitutesAttr),
new StatusMove(Moves.SNOWSCAPE, Type.ICE, -1, 10, -1, 0, 9)
.attr(WeatherChangeAttr, WeatherType.SNOW)
.target(MoveTarget.BOTH_SIDES),

View File

@ -65,6 +65,7 @@ export enum BattlerTagType {
RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE",
ALWAYS_GET_HIT = "ALWAYS_GET_HIT",
DISABLED = "DISABLED",
SUBSTITUTE = "SUBSTITUTE",
IGNORE_GHOST = "IGNORE_GHOST",
IGNORE_DARK = "IGNORE_DARK",
GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA",

View File

@ -0,0 +1,16 @@
export enum PokemonAnimType {
/**
* Adds a Substitute doll to the field in front of a Pokemon.
* The Pokemon then moves "out of focus" and becomes semi-transparent.
*/
SUBSTITUTE_ADD,
/** Brings a Pokemon with a Substitute "into focus" before using a move. */
SUBSTITUTE_PRE_MOVE,
/** Brings a Pokemon with a Substitute "out of focus" after using a move. */
SUBSTITUTE_POST_MOVE,
/**
* Removes a Pokemon's Substitute doll from the field.
* The Pokemon then moves back to its original position.
*/
SUBSTITUTE_REMOVE
}

View File

@ -17,7 +17,7 @@ import { initMoveAnim, loadMoveAnimAssets } from "../data/battle-anims";
import { Status, StatusEffect, getRandomStatus } from "../data/status-effect";
import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEvolutionCondition, FusionSpeciesFormEvolution } from "../data/pokemon-evolutions";
import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms";
import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag } from "../data/battler-tags";
import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag } from "../data/battler-tags";
import { WeatherType } from "../data/weather";
import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag";
import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr } from "../data/ability";
@ -58,6 +58,7 @@ import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase";
import { SwitchSummonPhase } from "#app/phases/switch-summon-phase";
import { ToggleDoublePositionPhase } from "#app/phases/toggle-double-position-phase";
import { Challenges } from "#enums/challenges";
import { PokemonAnimType } from "#app/enums/pokemon-anim-type";
import { PLAYER_PARTY_MAX_SIZE } from "#app/constants";
export enum FieldPosition {
@ -566,6 +567,23 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return 1;
}
/** Resets the pokemon's field sprite properties, including position, alpha, and scale */
resetSprite(): void {
// Resetting properties should not be shown on the field
this.setVisible(false);
// Reset field position
this.setFieldPosition(FieldPosition.CENTER);
if (this.isOffsetBySubstitute()) {
this.x -= this.getSubstituteOffset()[0];
this.y -= this.getSubstituteOffset()[1];
}
// Reset sprite display properties
this.setAlpha(1);
this.setScale(this.getSpriteScale());
}
getHeldItems(): PokemonHeldItemModifier[] {
if (!this.scene) {
return [];
@ -640,6 +658,47 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
}
/**
* Returns the Pokemon's offset from its current field position in the event that
* it has a Substitute doll in effect. The offset is returned in `[ x, y ]` format.
* @see {@linkcode SubstituteTag}
* @see {@linkcode getFieldPositionOffset}
*/
getSubstituteOffset(): [ number, number ] {
return this.isPlayer() ? [-30, 10] : [30, -10];
}
/**
* Returns whether or not the Pokemon's position on the field is offset because
* the Pokemon has a Substitute active.
* @see {@linkcode SubstituteTag}
*/
isOffsetBySubstitute(): boolean {
const substitute = this.getTag(SubstituteTag);
if (substitute) {
if (substitute.sprite === undefined) {
return false;
}
// During the Pokemon's MoveEffect phase, the offset is removed to put the Pokemon "in focus"
const currentPhase = this.scene.getCurrentPhase();
if (currentPhase instanceof MoveEffectPhase && currentPhase.getPokemon() === this) {
return false;
}
return true;
} else {
return false;
}
}
/** If this Pokemon has a Substitute on the field, removes its sprite from the field. */
destroySubstitute(): void {
const substitute = this.getTag(SubstituteTag);
if (substitute && substitute.sprite) {
substitute.sprite.destroy();
}
}
setFieldPosition(fieldPosition: FieldPosition, duration?: integer): Promise<void> {
return new Promise(resolve => {
if (fieldPosition === this.fieldPosition) {
@ -1414,6 +1473,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
applyPreDefendAbAttrs(FullHpResistTypeAbAttr, this, source, move, cancelledHolder, simulated, typeMultiplier);
}
if (move.category === MoveCategory.STATUS && move.hitsSubstitute(source, this)) {
typeMultiplier.value = 0;
}
return (!cancelledHolder.value ? typeMultiplier.value : 0) as TypeDamageMultiplier;
}
@ -2385,6 +2448,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND);
if (damage.value) {
this.lapseTags(BattlerTagLapseType.HIT);
const substitute = this.getTag(SubstituteTag);
if (substitute && move.hitsSubstitute(source, this)) {
substitute.hp -= damage.value;
damage.value = 0;
}
if (this.isFullHp()) {
applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, false, damage);
} else if (!this.isPlayer() && damage.value >= this.hp) {
@ -2407,13 +2477,17 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
this.scene.gameData.gameStats.highestDamage = damage.value;
}
}
source.turnData.damageDealt += damage.value;
source.turnData.currDamageDealt = damage.value;
this.battleData.hitCount++;
const attackResult = { move: move.id, result: result as DamageResult, damage: damage.value, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() };
this.turnData.attacksReceived.unshift(attackResult);
if (source.isPlayer() && !this.isPlayer()) {
this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage);
if (damage.value > 0) {
source.turnData.damageDealt += damage.value;
source.turnData.currDamageDealt = damage.value;
this.battleData.hitCount++;
const attackResult = { move: move.id, result: result as DamageResult, damage: damage.value, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() };
this.turnData.attacksReceived.unshift(attackResult);
if (source.isPlayer() && !this.isPlayer()) {
this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage);
}
}
}
@ -2440,6 +2514,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
// set splice index here, so future scene queues happen before FaintedPhase
this.scene.setPhaseQueueSplice();
this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo));
this.destroySubstitute();
this.resetSummonData();
}
@ -2452,7 +2527,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (!cancelled.value && typeMultiplier === 0) {
this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(this) }));
}
result = (typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS;
result = (cancelled.value || typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS;
break;
}
@ -2499,6 +2574,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
*/
this.scene.setPhaseQueueSplice();
this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), preventEndure));
this.destroySubstitute();
this.resetSummonData();
}
@ -3124,6 +3200,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
this.summonData[k] = this.summonDataPrimer[k];
}
}
// If this Pokemon has a Substitute when loading in, play an animation to add its sprite
if (this.getTag(SubstituteTag)) {
this.scene.triggerPokemonBattleAnim(this, PokemonAnimType.SUBSTITUTE_ADD);
this.getTag(SubstituteTag)!.sourceInFocus = false;
}
this.summonDataPrimer = null;
}
this.updateInfo();
@ -3503,21 +3584,23 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* info container.
*/
leaveField(clearEffects: boolean = true, hideInfo: boolean = true) {
this.resetSprite();
this.resetTurnData();
if (clearEffects) {
this.destroySubstitute();
this.resetSummonData();
this.resetBattleData();
}
if (hideInfo) {
this.hideInfo();
}
this.setVisible(false);
this.scene.field.remove(this);
this.scene.triggerPokemonFormChange(this, SpeciesFormChangeActiveTrigger, true);
}
destroy(): void {
this.battleInfo?.destroy();
this.destroySubstitute();
super.destroy();
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}} hortet {{stockpiledCount}}!",
"disabledOnAdd": " {{moveName}} von {{pokemonNameWithAffix}} wurde blockiert!",
"disabledLapse": "{{moveName}} von {{pokemonNameWithAffix}} ist nicht länger blockiert!",
"tarShotOnAdd": "{{pokemonNameWithAffix}} ist nun schwach gegenüber Feuer-Attacken!"
"tarShotOnAdd": "{{pokemonNameWithAffix}} ist nun schwach gegenüber Feuer-Attacken!",
"substituteOnAdd": "Ein Delegator von {{pokemonNameWithAffix}} ist erschienen!",
"substituteOnHit": "Der Delegator steckt den Schlag für {{pokemonNameWithAffix}} ein!",
"substituteOnRemove": "Der Delegator von {{pokemonNameWithAffix}} hört auf zu wirken!"
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!",
"disabledOnAdd": "{{pokemonNameWithAffix}}'s {{moveName}}\nwas disabled!",
"disabledLapse": "{{pokemonNameWithAffix}}'s {{moveName}}\nis no longer disabled.",
"tarShotOnAdd": "{{pokemonNameWithAffix}} became weaker to fire!"
"tarShotOnAdd": "{{pokemonNameWithAffix}} became weaker to fire!",
"substituteOnAdd": "{{pokemonNameWithAffix}} put in a substitute!",
"substituteOnHit": "The substitute took damage for {{pokemonNameWithAffix}}!",
"substituteOnRemove": "{{pokemonNameWithAffix}}'s substitute faded!"
}

View File

@ -68,5 +68,7 @@
"swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
"safeguard": "{{targetName}} is protected by Safeguard!",
"substituteOnOverlap": "{{pokemonName}} already\nhas a substitute!",
"substituteNotEnoughHp": "But it does not have enough HP\nleft to make a substitute!",
"afterYou": "{{pokemonName}} took the kind offer!"
}
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "¡{{pokemonNameWithAffix}} ha reservado energía por {{stockpiledCount}}ª vez!",
"disabledOnAdd": "¡Se ha anulado el movimiento {{moveName}}\nde {{pokemonNameWithAffix}}!",
"disabledLapse": "¡El movimiento {{moveName}} de {{pokemonNameWithAffix}} ya no está anulado!",
"tarShotOnAdd": "¡{{pokemonNameWithAffix}} se ha vuelto débil ante el fuego!"
"tarShotOnAdd": "¡{{pokemonNameWithAffix}} se ha vuelto débil ante el fuego!",
"substituteOnAdd": "¡{{pokemonNameWithAffix}} creó un sustituto!",
"substituteOnHit": "¡El sustituto recibe daño en lugar del {{pokemonNameWithAffix}}!",
"substituteOnRemove": "¡El sustituto del {{pokemonNameWithAffix}} se debilitó!"
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}} utilise\nla capacité Stockage {{stockpiledCount}} fois !",
"disabledOnAdd": "La capacité {{moveName}}\nde {{pokemonNameWithAffix}} est mise sous entrave !",
"disabledLapse": "La capacité {{moveName}}\nde {{pokemonNameWithAffix}} nest plus sous entrave !",
"tarShotOnAdd": "{{pokemonNameWithAffix}} est maintenant\nvulnérable au feu !"
"tarShotOnAdd": "{{pokemonNameWithAffix}} est maintenant\nvulnérable au feu !",
"substituteOnAdd": "{{pokemonNameWithAffix}}\ncrée un clone !",
"substituteOnHit": "Le clone subit les dégâts à la place\nde {{pokemonNameWithAffix}} !",
"substituteOnRemove": "Le clone de {{pokemonNameWithAffix}}\ndisparait…"
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}} ha usato Accumulo per la\n{{stockpiledCount}}ª volta!",
"disabledOnAdd": "La mossa {{moveName}} di\n{{pokemonNameWithAffix}} è stata bloccata!",
"disabledLapse": "La mossa {{moveName}} di\n{{pokemonNameWithAffix}} non è più bloccata!",
"tarShotOnAdd": "{{pokemonNameWithAffix}} è diventato vulnerabile\nal tipo Fuoco!"
"tarShotOnAdd": "{{pokemonNameWithAffix}} è diventato vulnerabile\nal tipo Fuoco!",
"substituteOnAdd": "Appare un sostituto di {{pokemonNameWithAffix}}!",
"substituteOnHit": "Il sostituto viene colpito al posto di {{pokemonNameWithAffix}}!",
"substituteOnRemove": "Il sostituto di {{pokemonNameWithAffix}} svanisce!"
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!",
"disabledOnAdd": "{{pokemonNameWithAffix}}의 {{moveName}}[[는]]\n사용할 수 없다!",
"disabledLapse": "{{pokemonNameWithAffix}}의 {{moveName}}[[는]]\n이제 사용할 수 있다.",
"tarShotOnAdd": "{{pokemonNameWithAffix}}[[는]] 불꽃에 약해졌다!"
"tarShotOnAdd": "{{pokemonNameWithAffix}}[[는]] 불꽃에 약해졌다!",
"substituteOnAdd": "{{pokemonNameWithAffix}}의\n대타가 나타났다!",
"substituteOnHit": "{{pokemonNameWithAffix}}[[를]] 대신하여\n대타가 공격을 받았다!",
"substituteOnRemove": "{{pokemonNameWithAffix}}의\n대타는 사라져 버렸다..."
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}} estocou {{stockpiledCount}}!",
"disabledOnAdd": "{{moveName}} de {{pokemonNameWithAffix}}\nfoi desabilitado!",
"disabledLapse": "{{moveName}} de {{pokemonNameWithAffix}}\nnão está mais desabilitado.",
"tarShotOnAdd": "{{pokemonNameWithAffix}} tornou-se mais fraco ao fogo!"
"tarShotOnAdd": "{{pokemonNameWithAffix}} tornou-se mais fraco ao fogo!",
"substituteOnAdd": "{{pokemonNameWithAffix}} colocou um substituto!",
"substituteOnHit": "O substituto tomou o dano pelo {{pokemonNameWithAffix}}!",
"substituteOnRemove": "O substituto de {{pokemonNameWithAffix}} desbotou!"
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!",
"disabledOnAdd": "封住了{{pokemonNameWithAffix}}的\n{{moveName}}",
"disabledLapse": "{{pokemonNameWithAffix}}的\n定身法解除了",
"tarShotOnAdd": "{{pokemonNameWithAffix}}\n变得怕火了"
"tarShotOnAdd": "{{pokemonNameWithAffix}}\n变得怕火了",
"substituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出现了",
"substituteOnHit": "替身代替{{pokemonNameWithAffix}}\n承受了攻击",
"substituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……"
}

View File

@ -70,5 +70,8 @@
"stockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!",
"disabledOnAdd": "封住了{{pokemonNameWithAffix}}的\n{moveName}}",
"disabledLapse": "{{pokemonNameWithAffix}}的\n定身法解除了",
"tarShotOnAdd": "{{pokemonNameWithAffix}}\n變得怕火了"
"tarShotOnAdd": "{{pokemonNameWithAffix}}\n變得怕火了",
"substituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出現了",
"substituteOnHit": "替身代替{{pokemonNameWithAffix}}承受了攻擊!",
"substituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……"
}

View File

@ -15,6 +15,7 @@ import { Mode } from "#app/ui/ui";
import i18next from "i18next";
import { PokemonPhase } from "./pokemon-phase";
import { VictoryPhase } from "./victory-phase";
import { SubstituteTag } from "#app/data/battler-tags";
export class AttemptCapturePhase extends PokemonPhase {
private pokeballType: PokeballType;
@ -36,6 +37,11 @@ export class AttemptCapturePhase extends PokemonPhase {
return this.end();
}
const substitute = pokemon.getTag(SubstituteTag);
if (substitute) {
substitute.sprite.setVisible(false);
}
this.scene.pokeballCounts[this.pokeballType]--;
this.originalY = pokemon.y;
@ -165,6 +171,11 @@ export class AttemptCapturePhase extends PokemonPhase {
pokemon.setVisible(true);
pokemon.untint(250, "Sine.easeOut");
const substitute = pokemon.getTag(SubstituteTag);
if (substitute) {
substitute.sprite.setVisible(true);
}
const pokeballAtlasKey = getPokeballAtlasKey(this.pokeballType);
this.pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`);
this.scene.time.delayedCall(17, () => this.pokeball.setTexture("pb", `${pokeballAtlasKey}_open`));

View File

@ -19,7 +19,7 @@ export class CommonAnimPhase extends PokemonPhase {
}
start() {
new CommonBattleAnim(this.anim, this.getPokemon(), this.targetIndex !== undefined ? (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField())[this.targetIndex] : this.getPokemon()).play(this.scene, () => {
new CommonBattleAnim(this.anim, this.getPokemon(), this.targetIndex !== undefined ? (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField())[this.targetIndex] : this.getPokemon()).play(this.scene, false, () => {
this.end();
});
}

View File

@ -57,7 +57,7 @@ export class DamagePhase extends PokemonPhase {
this.scene.damageNumberHandler.add(this.getPokemon(), this.amount, this.damageResult, this.critical);
}
if (this.damageResult !== HitResult.OTHER) {
if (this.damageResult !== HitResult.OTHER && this.amount > 0) {
const flashTimer = this.scene.time.addEvent({
delay: 100,
repeat: 5,

View File

@ -139,7 +139,7 @@ export class FaintPhase extends PokemonPhase {
y: pokemon.y + 150,
ease: "Sine.easeIn",
onComplete: () => {
pokemon.setVisible(false);
pokemon.resetSprite();
pokemon.y -= 150;
pokemon.trySetStatus(StatusEffect.FAINT);
if (pokemon.isPlayer()) {

View File

@ -31,7 +31,9 @@ export class MoveAnimTestPhase extends BattlePhase {
initMoveAnim(this.scene, moveId).then(() => {
loadMoveAnimAssets(this.scene, [moveId], true)
.then(() => {
new MoveAnim(moveId, player ? this.scene.getPlayerPokemon()! : this.scene.getEnemyPokemon()!, (player !== (allMoves[moveId] instanceof SelfStatusMove) ? this.scene.getEnemyPokemon()! : this.scene.getPlayerPokemon()!).getBattlerIndex()).play(this.scene, () => { // TODO: are the bangs correct here?
const user = player ? this.scene.getPlayerPokemon()! : this.scene.getEnemyPokemon()!;
const target = (player !== (allMoves[moveId] instanceof SelfStatusMove)) ? this.scene.getEnemyPokemon()! : this.scene.getPlayerPokemon()!;
new MoveAnim(moveId, user, target.getBattlerIndex()).play(this.scene, allMoves[moveId].hitsSubstitute(user, target), () => { // TODO: are the bangs correct here?
if (player) {
this.playMoveAnim(moveQueue, false);
} else {

View File

@ -3,7 +3,7 @@ import { BattlerIndex } from "#app/battle";
import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, applyPostDefendAbAttrs, PostDefendAbAttr, applyPostAttackAbAttrs, PostAttackAbAttr, MaxMultiHitAbAttr, AlwaysHitAbAttr } from "#app/data/ability";
import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag";
import { MoveAnim } from "#app/data/battle-anims";
import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag } from "#app/data/battler-tags";
import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags";
import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr } from "#app/data/move";
import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms";
import { BattlerTagType } from "#app/enums/battler-tag-type";
@ -120,7 +120,7 @@ export class MoveEffectPhase extends PokemonPhase {
const applyAttrs: Promise<void>[] = [];
// Move animation only needs one target
new MoveAnim(move.id as Moves, user, this.getTarget()?.getBattlerIndex()!).play(this.scene, () => { // TODO: is the bang correct here?
new MoveAnim(move.id as Moves, user, this.getTarget()!.getBattlerIndex()).play(this.scene, move.hitsSubstitute(user, this.getTarget()!), () => {
/** Has the move successfully hit a target (for damage) yet? */
let hasHit: boolean = false;
for (const target of targets) {
@ -246,7 +246,7 @@ export class MoveEffectPhase extends PokemonPhase {
* If the move hit, and the target doesn't have Shield Dust,
* apply the chance to flinch the target gained from King's Rock
*/
if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr)) {
if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) {
const flinched = new Utils.BooleanHolder(false);
user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
if (flinched.value) {
@ -258,14 +258,19 @@ export class MoveEffectPhase extends PokemonPhase {
&& (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => {
// Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them)
return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => {
// If the invoked move is an enemy attack, apply the enemy's status effect-inflicting tags and tokens
// Only apply the following effects if the move was not deflected by a substitute
if (move.hitsSubstitute(user, target)) {
return resolve();
}
// If the invoked move is an enemy attack, apply the enemy's status effect-inflicting tokens
if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) {
user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target);
}
target.lapseTag(BattlerTagType.BEAK_BLAST_CHARGING);
if (move.category === MoveCategory.PHYSICAL && user.isPlayer() !== target.isPlayer()) {
target.lapseTag(BattlerTagType.SHELL_TRAP);
}
if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) {
user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target);
}
})).then(() => {
// Apply the user's post-attack ability effects
applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => {
@ -306,7 +311,20 @@ export class MoveEffectPhase extends PokemonPhase {
}
// Wait for all move effects to finish applying, then end this phase
Promise.allSettled(applyAttrs).then(() => this.end());
Promise.allSettled(applyAttrs).then(() => {
/**
* Remove the target's substitute (if it exists and has expired)
* after all targeted effects have applied.
* This prevents blocked effects from applying until after this hit resolves.
*/
targets.forEach(target => {
const substitute = target.getTag(SubstituteTag);
if (!!substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
});
this.end();
});
});
});
}

View File

@ -167,6 +167,7 @@ export class MovePhase extends BattlePhase {
this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL });
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc.
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
moveQueue.shift(); // Remove the second turn of charge moves
return this.end();
}
@ -186,6 +187,7 @@ export class MovePhase extends BattlePhase {
this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL });
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc.
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
moveQueue.shift();
return this.end();

View File

@ -31,7 +31,7 @@ export class ObtainStatusEffectPhase extends PokemonPhase {
pokemon.status!.cureTurn = this.cureTurn; // TODO: is this bang correct?
}
pokemon.updateInfo(true);
new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(this.scene, () => {
new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(this.scene, false, () => {
this.scene.queueMessage(getStatusEffectObtainText(this.statusEffect, getPokemonNameWithAffix(pokemon), this.sourceText ?? undefined));
if (pokemon.status?.isPostTurn()) {
this.scene.pushPhase(new PostTurnStatusEffectPhase(this.scene, this.battlerIndex));

View File

@ -0,0 +1,237 @@
import BattleScene from "#app/battle-scene";
import { SubstituteTag } from "#app/data/battler-tags";
import { PokemonAnimType } from "#enums/pokemon-anim-type";
import Pokemon from "#app/field/pokemon";
import { BattlePhase } from "#app/phases/battle-phase";
export class PokemonAnimPhase extends BattlePhase {
/** The type of animation to play in this phase */
private key: PokemonAnimType;
/** The Pokemon to which this animation applies */
private pokemon: Pokemon;
/** Any other field sprites affected by this animation */
private fieldAssets: Phaser.GameObjects.Sprite[];
constructor(scene: BattleScene, key: PokemonAnimType, pokemon: Pokemon, fieldAssets?: Phaser.GameObjects.Sprite[]) {
super(scene);
this.key = key;
this.pokemon = pokemon;
this.fieldAssets = fieldAssets ?? [];
}
start(): void {
super.start();
switch (this.key) {
case PokemonAnimType.SUBSTITUTE_ADD:
this.doSubstituteAddAnim();
break;
case PokemonAnimType.SUBSTITUTE_PRE_MOVE:
this.doSubstitutePreMoveAnim();
break;
case PokemonAnimType.SUBSTITUTE_POST_MOVE:
this.doSubstitutePostMoveAnim();
break;
case PokemonAnimType.SUBSTITUTE_REMOVE:
this.doSubstituteRemoveAnim();
break;
default:
this.end();
}
}
doSubstituteAddAnim(): void {
const substitute = this.pokemon.getTag(SubstituteTag);
if (substitute === null) {
return this.end();
}
const getSprite = () => {
const sprite = this.scene.addFieldSprite(
this.pokemon.x + this.pokemon.getSprite().x,
this.pokemon.y + this.pokemon.getSprite().y,
`pkmn${this.pokemon.isPlayer() ? "__back": ""}__sub`
);
sprite.setOrigin(0.5, 1);
this.scene.field.add(sprite);
return sprite;
};
const [ subSprite, subTintSprite ] = [ getSprite(), getSprite() ];
const subScale = this.pokemon.getSpriteScale() * (this.pokemon.isPlayer() ? 0.5 : 1);
subSprite.setVisible(false);
subSprite.setScale(subScale);
subTintSprite.setTintFill(0xFFFFFF);
subTintSprite.setScale(0.01);
if (this.pokemon.isPlayer()) {
this.scene.field.bringToTop(this.pokemon);
}
this.scene.playSound("PRSFX- Transform");
this.scene.tweens.add({
targets: this.pokemon,
duration: 500,
x: this.pokemon.x + this.pokemon.getSubstituteOffset()[0],
y: this.pokemon.y + this.pokemon.getSubstituteOffset()[1],
alpha: 0.5,
ease: "Sine.easeIn"
});
this.scene.tweens.add({
targets: subTintSprite,
delay: 250,
scale: subScale,
ease: "Cubic.easeInOut",
duration: 500,
onComplete: () => {
subSprite.setVisible(true);
this.pokemon.scene.tweens.add({
targets: subTintSprite,
delay: 250,
alpha: 0,
ease: "Cubic.easeOut",
duration: 1000,
onComplete: () => {
subTintSprite.destroy();
substitute.sprite = subSprite;
this.end();
}
});
}
});
}
doSubstitutePreMoveAnim(): void {
if (this.fieldAssets.length !== 1) {
return this.end();
}
const subSprite = this.fieldAssets[0];
if (subSprite === undefined) {
return this.end();
}
this.scene.tweens.add({
targets: subSprite,
alpha: 0,
ease: "Sine.easeInOut",
duration: 500
});
this.scene.tweens.add({
targets: this.pokemon,
x: subSprite.x,
y: subSprite.y,
alpha: 1,
ease: "Sine.easeInOut",
delay: 250,
duration: 500,
onComplete: () => this.end()
});
}
doSubstitutePostMoveAnim(): void {
if (this.fieldAssets.length !== 1) {
return this.end();
}
const subSprite = this.fieldAssets[0];
if (subSprite === undefined) {
return this.end();
}
this.scene.tweens.add({
targets: this.pokemon,
x: subSprite.x + this.pokemon.getSubstituteOffset()[0],
y: subSprite.y + this.pokemon.getSubstituteOffset()[1],
alpha: 0.5,
ease: "Sine.easeInOut",
duration: 500
});
this.scene.tweens.add({
targets: subSprite,
alpha: 1,
ease: "Sine.easeInOut",
delay: 250,
duration: 500,
onComplete: () => this.end()
});
}
doSubstituteRemoveAnim(): void {
if (this.fieldAssets.length !== 1) {
return this.end();
}
const subSprite = this.fieldAssets[0];
if (subSprite === undefined) {
return this.end();
}
const getSprite = () => {
const sprite = this.scene.addFieldSprite(
subSprite.x,
subSprite.y,
`pkmn${this.pokemon.isPlayer() ? "__back": ""}__sub`
);
sprite.setOrigin(0.5, 1);
this.scene.field.add(sprite);
return sprite;
};
const subTintSprite = getSprite();
const subScale = this.pokemon.getSpriteScale() * (this.pokemon.isPlayer() ? 0.5 : 1);
subTintSprite.setAlpha(0);
subTintSprite.setTintFill(0xFFFFFF);
subTintSprite.setScale(subScale);
this.scene.tweens.add({
targets: subTintSprite,
alpha: 1,
ease: "Sine.easeInOut",
duration: 500,
onComplete: () => {
subSprite.destroy();
const flashTimer = this.scene.time.addEvent({
delay: 100,
repeat: 7,
startAt: 200,
callback: () => {
this.scene.playSound("PRSFX- Substitute2.wav");
subTintSprite.setVisible(flashTimer.repeatCount % 2 === 0);
if (!flashTimer.repeatCount) {
this.scene.tweens.add({
targets: subTintSprite,
scale: 0.01,
ease: "Sine.cubicEaseIn",
duration: 500
});
this.scene.tweens.add({
targets: this.pokemon,
x: this.pokemon.x - this.pokemon.getSubstituteOffset()[0],
y: this.pokemon.y - this.pokemon.getSubstituteOffset()[1],
alpha: 1,
ease: "Sine.easeInOut",
delay: 250,
duration: 500,
onComplete: () => {
subTintSprite.destroy();
this.end();
}
});
}
}
});
}
});
}
}

View File

@ -42,7 +42,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase {
this.scene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true));
pokemon.updateInfo();
}
new CommonBattleAnim(CommonAnim.POISON + (pokemon.status.effect - 1), pokemon).play(this.scene, () => this.end());
new CommonBattleAnim(CommonAnim.POISON + (pokemon.status.effect - 1), pokemon).play(this.scene, false, () => this.end());
} else {
this.end();
}

View File

@ -16,6 +16,7 @@ export class ReturnPhase extends SwitchSummonPhase {
onEnd(): void {
const pokemon = this.getPokemon();
pokemon.resetSprite();
pokemon.resetTurnData();
pokemon.resetSummonData();

View File

@ -53,7 +53,7 @@ export class ScanIvsPhase extends PokemonPhase {
this.scene.ui.setMode(Mode.CONFIRM, () => {
this.scene.ui.setMode(Mode.MESSAGE);
this.scene.ui.clearText();
new CommonBattleAnim(CommonAnim.LOCK_ON, pokemon, pokemon).play(this.scene, () => {
new CommonBattleAnim(CommonAnim.LOCK_ON, pokemon, pokemon).play(this.scene, false, () => {
this.scene.ui.getMessageHandler().promptIvs(pokemon.id, pokemon.ivs, this.shownIvs).then(() => this.end());
});
}, () => {

View File

@ -11,6 +11,7 @@ import { Command } from "#app/ui/command-ui-handler";
import i18next from "i18next";
import { PostSummonPhase } from "./post-summon-phase";
import { SummonPhase } from "./summon-phase";
import { SubstituteTag } from "#app/data/battler-tags";
export class SwitchSummonPhase extends SummonPhase {
private slotIndex: integer;
@ -65,6 +66,16 @@ export class SwitchSummonPhase extends SummonPhase {
if (!this.batonPass) {
(this.player ? this.scene.getEnemyField() : this.scene.getPlayerField()).forEach(enemyPokemon => enemyPokemon.removeTagsBySourceId(pokemon.id));
const substitute = pokemon.getTag(SubstituteTag);
if (substitute) {
this.scene.tweens.add({
targets: substitute.sprite,
duration: 250,
scale: substitute.sprite.scale * 0.5,
ease: "Sine.easeIn",
onComplete: () => substitute.sprite.destroy()
});
}
}
this.scene.ui.showText(this.player ?
@ -115,8 +126,18 @@ export class SwitchSummonPhase extends SummonPhase {
pokemonName: this.getPokemon().getNameToRender()
})
);
// Ensure improperly persisted summon data (such as tags) is cleared upon switching
if (!this.batonPass) {
/**
* If this switch is passing a Substitute, make the switched Pokemon match the returned Pokemon's state as it left.
* Otherwise, clear any persisting tags on the returned Pokemon.
*/
if (this.batonPass) {
const substitute = this.lastPokemon.getTag(SubstituteTag);
if (substitute) {
switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0];
switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1];
switchedInPokemon.setAlpha(0.5);
}
} else {
switchedInPokemon.resetBattleData();
switchedInPokemon.resetSummonData();
}

View File

@ -4,7 +4,9 @@ import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { BerryPhase } from "#app/phases/berry-phase";
const TIMEOUT = 20 * 1000;
@ -32,37 +34,57 @@ describe("Abilities - Unseen Fist", () => {
game.override.enemyLevel(100);
});
test(
"ability causes a contact move to ignore Protect",
it(
"should cause a contact move to ignore Protect",
() => testUnseenFistHitResult(game, Moves.QUICK_ATTACK, Moves.PROTECT, true),
TIMEOUT
);
test(
"ability does not cause a non-contact move to ignore Protect",
it(
"should not cause a non-contact move to ignore Protect",
() => testUnseenFistHitResult(game, Moves.ABSORB, Moves.PROTECT, false),
TIMEOUT
);
test(
"ability does not apply if the source has Long Reach",
it(
"should not apply if the source has Long Reach",
() => {
game.override.passiveAbility(Abilities.LONG_REACH);
testUnseenFistHitResult(game, Moves.QUICK_ATTACK, Moves.PROTECT, false);
}, TIMEOUT
);
test(
"ability causes a contact move to ignore Wide Guard",
it(
"should cause a contact move to ignore Wide Guard",
() => testUnseenFistHitResult(game, Moves.BREAKING_SWIPE, Moves.WIDE_GUARD, true),
TIMEOUT
);
test(
"ability does not cause a non-contact move to ignore Wide Guard",
it(
"should not cause a non-contact move to ignore Wide Guard",
() => testUnseenFistHitResult(game, Moves.BULLDOZE, Moves.WIDE_GUARD, false),
TIMEOUT
);
it(
"should cause a contact move to ignore Protect, but not Substitute",
async () => {
game.override.enemyLevel(1);
game.override.moveset([Moves.TACKLE]);
await game.startBattle();
const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, enemyPokemon.id);
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeUndefined();
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
}, TIMEOUT
);
});
async function testUnseenFistHitResult(game: GameManager, attackMove: Moves, protectMove: Moves, shouldSucceed: boolean = true): Promise<void> {

View File

@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import Pokemon, { MoveResult, PokemonTurnData, TurnMove, PokemonMove } from "#app/field/pokemon";
import BattleScene from "#app/battle-scene";
import { BattlerTagLapseType, SubstituteTag, TrappedTag } from "#app/data/battler-tags";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Moves } from "#app/enums/moves";
import { PokemonAnimType } from "#app/enums/pokemon-anim-type";
import * as messages from "#app/messages";
import { allMoves } from "#app/data/move";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
vi.mock("#app/battle-scene.js");
const TIMEOUT = 5 * 1000; // 5 sec timeout
describe("BattlerTag - SubstituteTag", () => {
let mockPokemon: Pokemon;
describe("onAdd behavior", () => {
beforeEach(() => {
mockPokemon = {
scene: new BattleScene(),
hp: 101,
id: 0,
getMaxHp: vi.fn().mockReturnValue(101) as Pokemon["getMaxHp"],
findAndRemoveTags: vi.fn().mockImplementation((tagFilter) => {
// simulate a Trapped tag set by another Pokemon, then expect the filter to catch it.
const trapTag = new TrappedTag(BattlerTagType.TRAPPED, BattlerTagLapseType.CUSTOM, 0, Moves.NONE, 1);
expect(tagFilter(trapTag)).toBeTruthy();
return true;
}) as Pokemon["findAndRemoveTags"]
} as Pokemon;
vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue("");
vi.spyOn(mockPokemon.scene, "getPokemonById").mockImplementation(pokemonId => mockPokemon.id === pokemonId ? mockPokemon : null);
});
it(
"sets the tag's HP to 1/4 of the source's max HP (rounded down)",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
subject.onAdd(mockPokemon);
expect(subject.hp).toBe(25);
}, TIMEOUT
);
it(
"triggers on-add effects that bring the source out of focus",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation(
(pokemon, battleAnimType, fieldAssets?, delayed?) => {
expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_ADD);
return true;
}
);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
subject.onAdd(mockPokemon);
expect(subject.sourceInFocus).toBeFalsy();
expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1);
expect(mockPokemon.scene.queueMessage).toHaveBeenCalledTimes(1);
}, TIMEOUT
);
it(
"removes effects that trap the source",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
subject.onAdd(mockPokemon);
expect(mockPokemon.findAndRemoveTags).toHaveBeenCalledTimes(1);
}, TIMEOUT
);
});
describe("onRemove behavior", () => {
beforeEach(() => {
mockPokemon = {
scene: new BattleScene(),
hp: 101,
id: 0,
isFainted: vi.fn().mockReturnValue(false) as Pokemon["isFainted"]
} as Pokemon;
vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue("");
});
it(
"triggers on-remove animation and message",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
subject.sourceInFocus = false;
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation(
(pokemon, battleAnimType, fieldAssets?, delayed?) => {
expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_REMOVE);
return true;
}
);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
subject.onRemove(mockPokemon);
expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1);
expect(mockPokemon.scene.queueMessage).toHaveBeenCalledTimes(1);
}, TIMEOUT
);
});
describe("lapse behavior", () => {
beforeEach(() => {
mockPokemon = {
scene: new BattleScene(),
hp: 101,
id: 0,
turnData: {acted: true} as PokemonTurnData,
getLastXMoves: vi.fn().mockReturnValue([{move: Moves.TACKLE, result: MoveResult.SUCCESS} as TurnMove]) as Pokemon["getLastXMoves"],
} as Pokemon;
vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue("");
});
it(
"PRE_MOVE lapse triggers pre-move animation",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation(
(pokemon, battleAnimType, fieldAssets?, delayed?) => {
expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_PRE_MOVE);
return true;
}
);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
expect(subject.lapse(mockPokemon, BattlerTagLapseType.PRE_MOVE)).toBeTruthy();
expect(subject.sourceInFocus).toBeTruthy();
expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1);
expect(mockPokemon.scene.queueMessage).not.toHaveBeenCalled();
}, TIMEOUT
);
it(
"AFTER_MOVE lapse triggers post-move animation",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockImplementation(
(pokemon, battleAnimType, fieldAssets?, delayed?) => {
expect(battleAnimType).toBe(PokemonAnimType.SUBSTITUTE_POST_MOVE);
return true;
}
);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
expect(subject.lapse(mockPokemon, BattlerTagLapseType.AFTER_MOVE)).toBeTruthy();
expect(subject.sourceInFocus).toBeFalsy();
expect(mockPokemon.scene.triggerPokemonBattleAnim).toHaveBeenCalledTimes(1);
expect(mockPokemon.scene.queueMessage).not.toHaveBeenCalled();
}, TIMEOUT
);
/** TODO: Figure out how to mock a MoveEffectPhase correctly for this test */
it.skip(
"HIT lapse triggers on-hit message",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
const pokemonMove = {
getMove: vi.fn().mockReturnValue(allMoves[Moves.TACKLE]) as PokemonMove["getMove"]
} as PokemonMove;
const moveEffectPhase = {
move: pokemonMove,
getUserPokemon: vi.fn().mockReturnValue(undefined) as MoveEffectPhase["getUserPokemon"]
} as MoveEffectPhase;
vi.spyOn(mockPokemon.scene, "getCurrentPhase").mockReturnValue(moveEffectPhase);
vi.spyOn(allMoves[Moves.TACKLE], "hitsSubstitute").mockReturnValue(true);
expect(subject.lapse(mockPokemon, BattlerTagLapseType.HIT)).toBeTruthy();
expect(mockPokemon.scene.triggerPokemonBattleAnim).not.toHaveBeenCalled();
expect(mockPokemon.scene.queueMessage).toHaveBeenCalledTimes(1);
}, TIMEOUT
);
it(
"CUSTOM lapse flags the tag for removal",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
expect(subject.lapse(mockPokemon, BattlerTagLapseType.CUSTOM)).toBeFalsy();
}, TIMEOUT
);
it(
"Unsupported lapse type does nothing",
async () => {
const subject = new SubstituteTag(Moves.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene, "triggerPokemonBattleAnim").mockReturnValue(true);
vi.spyOn(mockPokemon.scene, "queueMessage").mockReturnValue();
expect(subject.lapse(mockPokemon, BattlerTagLapseType.TURN_END)).toBeTruthy();
expect(mockPokemon.scene.triggerPokemonBattleAnim).not.toHaveBeenCalled();
expect(mockPokemon.scene.queueMessage).not.toHaveBeenCalled();
}
);
});
});

View File

@ -0,0 +1,515 @@
import { SubstituteTag, TrappedTag } from "#app/data/battler-tags";
import { allMoves, StealHeldItemChanceAttr } from "#app/data/move";
import { StatusEffect } from "#app/data/status-effect";
import { Abilities } from "#app/enums/abilities";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { BerryType } from "#app/enums/berry-type";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat";
import { MoveResult } from "#app/field/pokemon";
import { CommandPhase } from "#app/phases/command-phase";
import GameManager from "#app/test/utils/gameManager";
import { Command } from "#app/ui/command-ui-handler";
import { Mode } from "#app/ui/ui";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const TIMEOUT = 20 * 1000; // 20 sec timeout
describe("Moves - Substitute", () => {
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")
.moveset([Moves.SUBSTITUTE, Moves.SWORDS_DANCE, Moves.TACKLE, Moves.SPLASH])
.enemySpecies(Species.SNORLAX)
.enemyAbility(Abilities.INSOMNIA)
.enemyMoveset(Moves.SPLASH)
.startingLevel(100)
.enemyLevel(100);
});
it(
"should cause the user to take damage",
async () => {
await game.classicMode.startBattle([Species.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() * 3/4));
}, TIMEOUT
);
it(
"should redirect enemy attack damage to the Substitute doll",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
await game.classicMode.startBattle([Species.SKARMORY]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() * 3/4));
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
const postSubHp = leadPokemon.hp;
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.hp).toBe(postSubHp);
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
}, TIMEOUT
);
it(
"should fade after redirecting more damage than its remaining HP",
async () => {
// Giga Impact OHKOs Magikarp if substitute isn't up
game.override.enemyMoveset(Array(4).fill(Moves.GIGA_IMPACT));
vi.spyOn(allMoves[Moves.GIGA_IMPACT], "accuracy", "get").mockReturnValue(100);
await game.classicMode.startBattle([Species.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() * 3/4));
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
const postSubHp = leadPokemon.hp;
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.hp).toBe(postSubHp);
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeUndefined();
}, TIMEOUT
);
it(
"should block stat changes from status moves",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.CHARM));
await game.classicMode.startBattle([Species.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0);
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
}
);
it(
"should be bypassed by sound-based moves",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.ECHOED_VOICE));
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("MoveEndPhase");
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
const postSubHp = leadPokemon.hp;
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
expect(leadPokemon.hp).toBeLessThan(postSubHp);
}, TIMEOUT
);
it(
"should be bypassed by attackers with Infiltrator",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
game.override.enemyAbility(Abilities.INFILTRATOR);
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("MoveEndPhase");
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
const postSubHp = leadPokemon.hp;
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
expect(leadPokemon.hp).toBeLessThan(postSubHp);
}, TIMEOUT
);
it(
"shouldn't block the user's own status moves",
async () => {
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("MoveEndPhase");
await game.toNextTurn();
game.move.select(Moves.SWORDS_DANCE);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2);
}, TIMEOUT
);
it(
"should protect the user from flinching",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.FAKE_OUT));
game.override.startingLevel(1); // Ensures the Substitute will break
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
}, TIMEOUT
);
it(
"should protect the user from being trapped",
async () => {
vi.spyOn(allMoves[Moves.SAND_TOMB], "accuracy", "get").mockReturnValue(100);
game.override.enemyMoveset(Array(4).fill(Moves.SAND_TOMB));
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getTag(TrappedTag)).toBeUndefined();
}, TIMEOUT
);
it(
"should prevent the user's stats from being lowered",
async () => {
vi.spyOn(allMoves[Moves.LIQUIDATION], "chance", "get").mockReturnValue(100);
game.override.enemyMoveset(Array(4).fill(Moves.LIQUIDATION));
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getStatStage(Stat.DEF)).toBe(0);
}, TIMEOUT
);
it(
"should protect the user from being afflicted with status effects",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.NUZZLE));
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.status?.effect).not.toBe(StatusEffect.PARALYSIS);
}, TIMEOUT
);
it(
"should prevent the user's items from being stolen",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.THIEF));
vi.spyOn(allMoves[Moves.THIEF], "attrs", "get").mockReturnValue([new StealHeldItemChanceAttr(1.0)]); // give Thief 100% steal rate
game.override.startingHeldItems([{name: "BERRY", type: BerryType.SITRUS}]);
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getHeldItems().length).toBe(1);
}, TIMEOUT
);
it(
"should prevent the user's items from being removed",
async () => {
game.override.moveset([Moves.KNOCK_OFF]);
game.override.enemyHeldItems([{name: "BERRY", type: BerryType.SITRUS}]);
await game.classicMode.startBattle([Species.BLASTOISE]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, enemyPokemon.id);
const enemyNumItems = enemyPokemon.getHeldItems().length;
game.move.select(Moves.KNOCK_OFF);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(enemyPokemon.getHeldItems().length).toBe(enemyNumItems);
}, TIMEOUT
);
it(
"move effect should prevent the user's berries from being stolen and eaten",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.BUG_BITE));
game.override.startingHeldItems([{name: "BERRY", type: BerryType.SITRUS}]);
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("MoveEndPhase", false);
const enemyPostAttackHp = enemyPokemon.hp;
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getHeldItems().length).toBe(1);
expect(enemyPokemon.hp).toBe(enemyPostAttackHp);
}, TIMEOUT
);
it(
"should prevent the user's stats from being reset by Clear Smog",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.CLEAR_SMOG));
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.SWORDS_DANCE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2);
}, TIMEOUT
);
it(
"should prevent the user from becoming confused",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.MAGICAL_TORQUE));
vi.spyOn(allMoves[Moves.MAGICAL_TORQUE], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([Species.BLASTOISE]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
game.move.select(Moves.SWORDS_DANCE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getTag(BattlerTagType.CONFUSED)).toBeUndefined();
expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2);
}
);
it(
"should transfer to the switched in Pokemon when the source uses Baton Pass",
async () => {
game.override.moveset([Moves.SUBSTITUTE, Moves.BATON_PASS]);
await game.classicMode.startBattle([Species.BLASTOISE, Species.CHARIZARD]);
const leadPokemon = game.scene.getPlayerPokemon()!;
leadPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, leadPokemon.id);
// Simulate a Baton switch for the player this turn
game.onNextPrompt("CommandPhase", Mode.COMMAND, () => {
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.POKEMON, 1, true);
});
await game.phaseInterceptor.to("MovePhase", false);
const switchedPokemon = game.scene.getPlayerPokemon()!;
const subTag = switchedPokemon.getTag(SubstituteTag)!;
expect(subTag).toBeDefined();
expect(subTag.hp).toBe(Math.floor(leadPokemon.getMaxHp() * 1/4));
}, TIMEOUT
);
it(
"should prevent the source's Rough Skin from activating when hit",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
game.override.ability(Abilities.ROUGH_SKIN);
await game.classicMode.startBattle([Species.BLASTOISE]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
}, TIMEOUT
);
it(
"should prevent the source's Focus Punch from failing when hit",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
game.override.moveset([Moves.FOCUS_PUNCH]);
// Make Focus Punch 40 power to avoid a KO
vi.spyOn(allMoves[Moves.FOCUS_PUNCH], "calculateBattlePower").mockReturnValue(40);
await game.classicMode.startBattle([Species.BLASTOISE]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id);
game.move.select(Moves.FOCUS_PUNCH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
}, TIMEOUT
);
it(
"should not allow Shell Trap to activate when attacked",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
game.override.moveset([Moves.SHELL_TRAP]);
await game.classicMode.startBattle([Species.BLASTOISE]);
const playerPokemon = game.scene.getPlayerPokemon()!;
playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id);
game.move.select(Moves.SHELL_TRAP);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
}, TIMEOUT
);
it(
"should not allow Beak Blast to burn opponents when hit",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
game.override.moveset([Moves.BEAK_BLAST]);
await game.classicMode.startBattle([Species.BLASTOISE]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id);
game.move.select(Moves.BEAK_BLAST);
await game.phaseInterceptor.to("MoveEndPhase");
expect(enemyPokemon.status?.effect).not.toBe(StatusEffect.BURN);
}, TIMEOUT
);
it(
"should cause incoming attacks to not activate Counter",
async() => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
game.override.moveset([Moves.COUNTER]);
await game.classicMode.startBattle([Species.BLASTOISE]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
playerPokemon.addTag(BattlerTagType.SUBSTITUTE, 0, Moves.NONE, playerPokemon.id);
game.move.select(Moves.COUNTER);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
}
);
});

View File

@ -8,6 +8,7 @@ import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { SubstituteTag } from "#app/data/battler-tags";
describe("Moves - Tidy Up", () => {
@ -39,7 +40,7 @@ describe("Moves - Tidy Up", () => {
it("spikes are cleared", async () => {
game.override.moveset([Moves.SPIKES, Moves.TIDY_UP]);
game.override.enemyMoveset([Moves.SPIKES, Moves.SPIKES, Moves.SPIKES, Moves.SPIKES]);
await game.startBattle();
await game.classicMode.startBattle();
game.move.select(Moves.SPIKES);
await game.phaseInterceptor.to(TurnEndPhase);
@ -52,7 +53,7 @@ describe("Moves - Tidy Up", () => {
it("stealth rocks are cleared", async () => {
game.override.moveset([Moves.STEALTH_ROCK, Moves.TIDY_UP]);
game.override.enemyMoveset([Moves.STEALTH_ROCK, Moves.STEALTH_ROCK, Moves.STEALTH_ROCK, Moves.STEALTH_ROCK]);
await game.startBattle();
await game.classicMode.startBattle();
game.move.select(Moves.STEALTH_ROCK);
await game.phaseInterceptor.to(TurnEndPhase);
@ -64,7 +65,7 @@ describe("Moves - Tidy Up", () => {
it("toxic spikes are cleared", async () => {
game.override.moveset([Moves.TOXIC_SPIKES, Moves.TIDY_UP]);
game.override.enemyMoveset([Moves.TOXIC_SPIKES, Moves.TOXIC_SPIKES, Moves.TOXIC_SPIKES, Moves.TOXIC_SPIKES]);
await game.startBattle();
await game.classicMode.startBattle();
game.move.select(Moves.TOXIC_SPIKES);
await game.phaseInterceptor.to(TurnEndPhase);
@ -77,7 +78,7 @@ describe("Moves - Tidy Up", () => {
game.override.moveset([Moves.STICKY_WEB, Moves.TIDY_UP]);
game.override.enemyMoveset([Moves.STICKY_WEB, Moves.STICKY_WEB, Moves.STICKY_WEB, Moves.STICKY_WEB]);
await game.startBattle();
await game.classicMode.startBattle();
game.move.select(Moves.STICKY_WEB);
await game.phaseInterceptor.to(TurnEndPhase);
@ -86,21 +87,26 @@ describe("Moves - Tidy Up", () => {
expect(game.scene.arena.getTag(ArenaTagType.STICKY_WEB)).toBeUndefined();
}, 20000);
it.skip("substitutes are cleared", async () => {
it("substitutes are cleared", async () => {
game.override.moveset([Moves.SUBSTITUTE, Moves.TIDY_UP]);
game.override.enemyMoveset([Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE]);
await game.startBattle();
await game.classicMode.startBattle();
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to(TurnEndPhase);
game.move.select(Moves.TIDY_UP);
await game.phaseInterceptor.to(MoveEndPhase);
// TODO: check for subs here once the move is implemented
const pokemon = [ game.scene.getPlayerPokemon()!, game.scene.getEnemyPokemon()! ];
pokemon.forEach(p => {
expect(p).toBeDefined();
expect(p!.getTag(SubstituteTag)).toBeUndefined();
});
}, 20000);
it("user's stats are raised with no traps set", async () => {
await game.startBattle();
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;

View File

@ -8,6 +8,7 @@ import {Button} from "#enums/buttons";
import { Moves } from "#enums/moves";
import Pokemon from "#app/field/pokemon";
import { ModifierBar } from "#app/modifier/modifier";
import { SubstituteTag } from "#app/data/battler-tags";
export type TargetSelectCallback = (targets: BattlerIndex[]) => void;
@ -111,7 +112,7 @@ export default class TargetSelectUiHandler extends UiHandler {
if (this.targetFlashTween) {
this.targetFlashTween.stop();
for (const pokemon of multipleTargets) {
pokemon.setAlpha(1);
pokemon.setAlpha(!!pokemon.getTag(SubstituteTag) ? 0.5 : 1);
this.highlightItems(pokemon.id, 1);
}
}
@ -162,7 +163,7 @@ export default class TargetSelectUiHandler extends UiHandler {
}
for (const pokemon of this.targetsHighlighted) {
pokemon.setAlpha(1);
pokemon.setAlpha(!!pokemon.getTag(SubstituteTag) ? 0.5 : 1);
this.highlightItems(pokemon.id, 1);
}