diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 93b05a39ecb..2a87dd49317 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -66,6 +66,8 @@ import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import { UiTheme } from "#enums/ui-theme"; import { TimedEventManager } from "#app/timed-event-manager.js"; +import { PokemonAnimPhase } from "./pokemon-anim-phase"; +import { PokemonAnimType } from "./enums/pokemon-anim-type"; import i18next from "i18next"; import {TrainerType} from "#enums/trainer-type"; import { battleSpecDialogue } from "./data/dialogue"; @@ -950,10 +952,12 @@ export default class BattleScene extends SceneBase { this.enemyModifierBar.removeAll(true); for (const p of this.getParty()) { + p.destroySubstitute(); p.destroy(); } this.party = []; for (const p of this.getEnemyParty()) { + p.destroySubstitute(); p.destroy(); } @@ -2582,6 +2586,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, ...args: unknown[]): void { const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType); for (const achv of filteredAchvs) { diff --git a/src/data/ability.ts b/src/data/ability.ts index 491a14ba621..56f631f2fe0 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1611,6 +1611,10 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr { } applyPostAttackAfterMoveTypeCheck(pokemon: Pokemon, passive: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { + if (pokemon !== attacker && !!pokemon.getTag(BattlerTagType.SUBSTITUTE) && !move.canIgnoreSubstitute(attacker)) { + return false; + } + /**Status inflicted by abilities post attacking are also considered additional effects.*/ if (!attacker.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && 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)]; @@ -1965,6 +1969,10 @@ export class PostSummonStatChangeAbAttr extends PostSummonAbAttr { if (this.intimidate) { applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled); applyAbAttrs(PostIntimidateStatChangeAbAttr, opponent, cancelled); + + if (!!opponent.getTag(BattlerTagType.SUBSTITUTE)) { + cancelled.value = true; + } } if (!cancelled.value) { const statChangePhase = new StatChangePhase(pokemon.scene, opponent.getBattlerIndex(), false, this.stats, this.levels); @@ -4303,7 +4311,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) && (!target.getTag(BattlerTagType.SUBSTITUTE) || move.canIgnoreSubstitute(user)) ? 10 : 0, BattlerTagType.FLINCHED), new Ability(Abilities.DRIZZLE, 3) .attr(PostSummonWeatherChangeAbAttr, WeatherType.RAIN) .attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.RAIN), diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index d4dbb8ec350..43a5201b802 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -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 { @@ -694,7 +695,7 @@ export abstract class BattleAnim { return false; } - private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[]): Map> { + private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[], onSubstitute?: boolean): Map> { const ret: Map> = new Map([ [AnimFrameTarget.GRAPHIC, new Map() ], [AnimFrameTarget.USER, new Map() ], @@ -705,12 +706,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; const userInitialY = user.y; const userHalfHeight = user.getSprite().displayHeight / 2; - const targetInitialX = target.x; - const targetInitialY = target.y; - const targetHalfHeight = target.getSprite().displayHeight / 2; + + const targetInitialX = targetSubstitute?.sprite?.x ?? target.x; + const targetInitialY = targetSubstitute?.sprite?.y ?? target.y; + const targetHalfHeight = (targetSubstitute?.sprite ?? target.getSprite()).displayHeight / 2; let g = 0; let u = 0; @@ -748,7 +752,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; const target = !isOppAnim ? this.target : this.user; @@ -760,8 +764,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]: [], @@ -776,9 +782,18 @@ 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()) { @@ -808,8 +823,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 ]; @@ -827,7 +842,7 @@ export abstract class BattleAnim { } const spriteFrames = anim.frames[f]; - const frameData = this.getGraphicFrameData(scene, anim.frames[f]); + const frameData = this.getGraphicFrameData(scene, anim.frames[f], onSubstitute); let u = 0; let t = 0; let g = 0; @@ -840,24 +855,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); - [ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user : target).getSprite().pipelineData[k]); - 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); + [ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user : target).getSprite().pipelineData[k]); + 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); - 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); diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index ce70240df8c..a822c581f7d 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -18,6 +18,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import i18next from "#app/plugins/i18n.js"; +import { PokemonAnimType } from "#app/enums/pokemon-anim-type.js"; export enum BattlerTagLapseType { FAINT, @@ -26,6 +27,7 @@ export enum BattlerTagLapseType { AFTER_MOVE, MOVE_EFFECT, TURN_END, + HIT, CUSTOM } @@ -126,8 +128,9 @@ export class TrappedTag extends BattlerTag { canAdd(pokemon: Pokemon): boolean { const isGhost = pokemon.isOfType(Type.GHOST); const isTrapped = pokemon.getTag(BattlerTagType.TRAPPED); + const hasSubstitute = pokemon.getTag(BattlerTagType.SUBSTITUTE); - return !isTrapped && !isGhost; + return !isTrapped && !isGhost && !hasSubstitute; } onAdd(pokemon: Pokemon): void { @@ -765,7 +768,7 @@ export abstract class DamagingTrapTag extends TrappedTag { } canAdd(pokemon: Pokemon): boolean { - return !pokemon.isOfType(Type.GHOST) && !pokemon.findTag(t => t instanceof DamagingTrapTag); + return !pokemon.isOfType(Type.GHOST) && !pokemon.findTag(t => t instanceof DamagingTrapTag) && !pokemon.getTag(BattlerTagType.SUBSTITUTE); } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { @@ -1558,7 +1561,6 @@ export class IceFaceTag extends BattlerTag { } } - /** * Battler tag enabling the Stockpile mechanic. This tag handles: * - Stack tracking, including max limit enforcement (which is replicated in Stockpile for redundancy). @@ -1587,7 +1589,6 @@ export class StockpilingTag extends BattlerTag { if (defChange) { this.statChangeCounts[BattleStat.DEF]++; } - if (spDefChange) { this.statChangeCounts[BattleStat.SPDEF]++; } @@ -1647,6 +1648,94 @@ export class StockpilingTag 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); + } + + /** 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("battle:battlerTagsSubstituteOnAdd", { 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("battle:battlerTagsSubstituteOnRemove", { 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.canIgnoreSubstitute(attacker)) { + pokemon.scene.queueMessage(i18next.t("battle:battlerTagsSubstituteOnHit", { 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; + // TODO: load this tag's sprite (or generate a new one upon loading a game) + } +} + export function getBattlerTag(tagType: BattlerTagType, turnCount: number, sourceMove: Moves, sourceId: number): BattlerTag { switch (tagType) { case BattlerTagType.RECHARGING: @@ -1770,6 +1859,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new StockpilingTag(sourceMove); case BattlerTagType.OCTOLOCK: return new OctolockTag(sourceId); + case BattlerTagType.SUBSTITUTE: + return new SubstituteTag(sourceMove, sourceId); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index f90aef585d6..422bc473913 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1,7 +1,7 @@ import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims"; import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases"; import { BattleStat, getBattleStatName } from "./battle-stat"; -import { EncoreTag, HelpingHandTag, SemiInvulnerableTag, StockpilingTag, TypeBoostTag } from "./battler-tags"; +import { EncoreTag, HelpingHandTag, SemiInvulnerableTag, StockpilingTag, SubstituteTag, TypeBoostTag } from "./battler-tags"; import { getPokemonMessage, getPokemonNameWithAffix } from "../messages"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects} from "./status-effect"; @@ -91,11 +91,12 @@ export enum MoveFlags { /** * Enables all hits of a multi-hit move to be accuracy checked individually */ - CHECK_ALL_HITS = 1 << 17, + CHECK_ALL_HITS = 1 << 17, + 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; @@ -310,6 +311,18 @@ export default class Move implements Localizable { return false; } + /** + * Checks if the move can bypass Substitute to directly hit its target + * @param user The {@linkcode Pokemon} using this move + * @returns `true` if the move can bypass the target's Substitute; `false` otherwise. + */ + canIgnoreSubstitute(user: Pokemon): boolean { + return this.moveTarget === MoveTarget.USER + || 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 @@ -553,6 +566,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): 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 @@ -1351,6 +1375,44 @@ 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() || user.getHpRatio() < 0.25) { + return -10; + } + return Math.ceil(user.getHpRatio() * 10); + } +} + export enum MultiHitType { _2, _2_TO_5, @@ -1865,6 +1927,10 @@ export class StatusEffectAttr extends MoveEffectAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!this.selfTarget && !!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return false; + } + const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance; if (statusCheck) { @@ -1956,6 +2022,9 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { return new Promise(resolve => { + if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return resolve(false); + } const rand = Phaser.Math.RND.realInRange(0, 1); if (rand >= this.chance) { return resolve(false); @@ -2025,6 +2094,10 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { return false; } + if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return false; + } + const cancelled = new Utils.BooleanHolder(false); applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft @@ -2145,6 +2218,9 @@ export class StealEatBerryAttr extends EatBerryAttr { * @returns {boolean} true if the function succeeds */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return false; + } const cancelled = new Utils.BooleanHolder(false); applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft if (cancelled.value === true) { @@ -2195,6 +2271,10 @@ export class HealStatusEffectAttr extends MoveEffectAttr { return false; } + if (!this.selfTarget && !!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + 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) { @@ -2375,7 +2455,7 @@ export class ChargeAttr extends OverrideMoveEffectAttr { const lastMove = user.getLastXMoves().find(() => true); if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && (this.sameTurn || 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); @@ -2482,7 +2562,7 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { 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 }); @@ -2518,6 +2598,10 @@ export class StatChangeAttr extends MoveEffectAttr { return false; } + if (!this.selfTarget && !!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return false; + } + const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) { const levels = this.getLevels(user); @@ -2724,6 +2808,10 @@ export class ResetStatsAttr extends MoveEffectAttr { return false; } + if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return false; + } + for (let s = 0; s < target.summonData.battleStats.length; s++) { target.summonData.battleStats[s] = 0; } @@ -4373,12 +4461,26 @@ export class FlinchAttr extends AddBattlerTagAttr { constructor() { super(BattlerTagType.FLINCHED, false); } + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!target.getTag(SubstituteTag) || move.canIgnoreSubstitute(user)) { + return super.apply(user, target, move, args); + } + return false; + } } export class ConfuseAttr extends AddBattlerTagAttr { constructor(selfTarget?: boolean) { super(BattlerTagType.CONFUSED, selfTarget, false, 2, 5); } + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!target.getTag(SubstituteTag) || move.canIgnoreSubstitute(user)) { + return super.apply(user, target, move, args); + } + return false; + } } export class RechargeAttr extends AddBattlerTagAttr { @@ -4451,6 +4553,22 @@ export class FaintCountdownAttr extends AddBattlerTagAttr { } } +export class RemoveAllSubstitutesAttr extends MoveEffectAttr { + constructor() { + super(true); + } + + 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 @@ -4837,6 +4955,10 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { const switchOutTarget = (this.user ? user : target); const player = switchOutTarget instanceof PlayerPokemon; + if (!this.user && !!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) { + return false; + } + if (!this.user && move.category === MoveCategory.STATUS && (target.hasAbilityWithAttr(ForceSwitchOutImmunityAbAttr) || target.isMax())) { return false; } @@ -6026,6 +6148,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) @@ -6115,6 +6238,7 @@ export function initMoves() { .attr(FixedDamageAttr, 20), new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1) .attr(DisableMoveAttr) + .ignoresSubstitute() .condition(failOnMaxCondition), new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1) .attr(StatChangeAttr, BattleStat.SPDEF, -1) @@ -6251,6 +6375,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(StatChangeAttr, BattleStat.DEF, -2) @@ -6279,6 +6404,7 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.LIGHT_SCREEN, 5, true) .target(MoveTarget.USER_SIDE), new StatusMove(Moves.HAZE, Type.ICE, -1, 30, -1, 0, 1) + .ignoresSubstitute() .target(MoveTarget.BOTH_SIDES) .attr(ResetStatsAttr), new StatusMove(Moves.REFLECT, Type.PSYCHIC, -1, 20, -1, 0, 1) @@ -6422,14 +6548,15 @@ export function initMoves() { .attr(HighCritAttr) .slicingMove(), new SelfStatusMove(Moves.SUBSTITUTE, Type.NORMAL, -1, 10, -1, 0, 1) - .attr(RecoilAttr) - .unimplemented(), + .attr(AddSubstituteAttr) + .condition((user, target, move) => !user.getTag(SubstituteTag) && user.getHpRatio() > 0.25 && user.getMaxHp() > 1), 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) @@ -6455,12 +6582,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() @@ -6472,6 +6601,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) @@ -6502,6 +6632,7 @@ export function initMoves() { .attr(StatusEffectAttr, StatusEffect.PARALYSIS) .ballBombMove(), new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2) + .ignoresSubstitute() .unimplemented(), new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2) .ignoresProtect() @@ -6559,6 +6690,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) @@ -6604,6 +6736,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(), @@ -6662,6 +6795,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) @@ -6709,6 +6843,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(StatChangeAttr, BattleStat.SPATK, 1) @@ -6738,13 +6873,16 @@ export function initMoves() { .attr(StatChangeAttr, BattleStat.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() @@ -6777,8 +6915,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) @@ -6860,6 +7000,7 @@ export function initMoves() { .attr(StatChangeAttr, BattleStat.SPATK, -2, true) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE), new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3) + .ignoresSubstitute() .unimplemented(), new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3) .attr(StatChangeAttr, BattleStat.SPD, -1) @@ -6969,6 +7110,7 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5) .target(MoveTarget.BOTH_SIDES), new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4) + .ignoresSubstitute() .unimplemented(), 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) @@ -7047,6 +7189,7 @@ export function initMoves() { .target(MoveTarget.USER_SIDE) .unimplemented(), new StatusMove(Moves.ME_FIRST, Type.NORMAL, -1, 20, -1, 0, 4) + .ignoresSubstitute() .ignoresVirtual() .target(MoveTarget.NEAR_ENEMY) .unimplemented(), @@ -7054,8 +7197,10 @@ export function initMoves() { .attr(CopyMoveAttr) .ignoresVirtual(), new StatusMove(Moves.POWER_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) + .ignoresSubstitute() .unimplemented(), new StatusMove(Moves.GUARD_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) + .ignoresSubstitute() .unimplemented(), new AttackMove(Moves.PUNISHMENT, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4) .makesContact(true) @@ -7070,6 +7215,7 @@ export function initMoves() { .attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES) .target(MoveTarget.ENEMY_SIDE), new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4) + .ignoresSubstitute() .attr(SwapStatsAttr), new SelfStatusMove(Moves.AQUA_RING, Type.WATER, -1, 20, -1, 0, 4) .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), @@ -7352,6 +7498,7 @@ export function initMoves() { .attr(AbilityGiveAttr), new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5) .ignoresProtect() + .ignoresSubstitute() .unimplemented(), new AttackMove(Moves.ROUND, Type.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) .soundBased() @@ -7389,6 +7536,7 @@ export function initMoves() { new AttackMove(Moves.SKY_DROP, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) .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(StatChangeAttr, BattleStat.ATK, 1, true) @@ -7403,6 +7551,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) .partial(), @@ -7411,6 +7560,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), @@ -7616,12 +7766,14 @@ 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), new StatusMove(Moves.PLAY_NICE, Type.NORMAL, -1, 20, -1, 0, 6) - .attr(StatChangeAttr, BattleStat.ATK, -1), + .attr(StatChangeAttr, BattleStat.ATK, -1) + .ignoresSubstitute(), new StatusMove(Moves.CONFIDE, Type.NORMAL, -1, 20, -1, 0, 6) .attr(StatChangeAttr, BattleStat.SPATK, -1) .soundBased(), @@ -7634,7 +7786,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) @@ -7645,6 +7798,7 @@ export function initMoves() { .attr(ProtectAttr, BattlerTagType.SPIKY_SHIELD), new StatusMove(Moves.AROMATIC_MIST, Type.FAIRY, -1, 20, -1, 0, 6) .attr(StatChangeAttr, BattleStat.SPDEF, 1) + .ignoresSubstitute() .target(MoveTarget.NEAR_ALLY), new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6) .attr(StatChangeAttr, BattleStat.SPATK, -2), @@ -7652,6 +7806,7 @@ export function initMoves() { .attr(StatChangeAttr, [ BattleStat.ATK, BattleStat.SPATK, BattleStat.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) @@ -7660,6 +7815,7 @@ export function initMoves() { .ignoresVirtual(), new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6) .attr(StatChangeAttr, [ BattleStat.DEF, BattleStat.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 @@ -7672,6 +7828,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(StatChangeAttr, BattleStat.ATK, -1), @@ -7717,6 +7874,7 @@ export function initMoves() { .attr(StatChangeAttr, [ BattleStat.DEF, BattleStat.SPDEF ], -1, true), new AttackMove(Moves.HYPERSPACE_FURY, Type.DARK, MoveCategory.PHYSICAL, 100, -1, 5, -1, 0, 6) .attr(StatChangeAttr, BattleStat.DEF, -1, true) + .ignoresSubstitute() .makesContact(false) .ignoresProtect(), /* Unused */ @@ -7875,6 +8033,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false), new StatusMove(Moves.GEAR_UP, Type.STEEL, -1, 20, -1, 0, 7) .attr(StatChangeAttr, [ BattleStat.ATK, BattleStat.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) @@ -7904,6 +8063,7 @@ 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) + .ignoresSubstitute() .unimplemented(), 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) @@ -7921,6 +8081,7 @@ export function initMoves() { new AttackMove(Moves.TROP_KICK, Type.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7) .attr(StatChangeAttr, BattleStat.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, 5, 7) .attr(ChargeAttr, ChargeAnim.BEAK_BLAST_CHARGING, i18next.t("moveTriggers:startedHeatingUpBeak", {pokemonName: "{USER}"}), undefined, false, true, -3) @@ -7987,6 +8148,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() @@ -8646,7 +8808,8 @@ export function initMoves() { .target(MoveTarget.BOTH_SIDES), new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9) .attr(StatChangeAttr, [ BattleStat.ATK, BattleStat.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), diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 52f6402861e..8c9ce65b568 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -63,5 +63,6 @@ export enum BattlerTagType { ICE_FACE = "ICE_FACE", STOCKPILING = "STOCKPILING", RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE", - ALWAYS_GET_HIT = "ALWAYS_GET_HIT" + ALWAYS_GET_HIT = "ALWAYS_GET_HIT", + SUBSTITUTE = "SUBSTITUTE" } diff --git a/src/enums/pokemon-anim-type.ts b/src/enums/pokemon-anim-type.ts new file mode 100644 index 00000000000..5a0a0c2f622 --- /dev/null +++ b/src/enums/pokemon-anim-type.ts @@ -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 +} diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 71e89d60cbd..bc2c0f9056b 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -19,7 +19,7 @@ import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEv import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms"; import { DamagePhase, FaintPhase, LearnMovePhase, MoveEffectPhase, ObtainStatusEffectPhase, StatChangePhase, SwitchSummonPhase, ToggleDoublePositionPhase, MoveEndPhase } from "../phases"; import { BattleStat } from "../data/battle-stat"; -import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag } from "../data/battler-tags"; +import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag } from "../data/battler-tags"; import { WeatherType } from "../data/weather"; import { TempBattleStat } from "../data/temp-battle-stat"; import { ArenaTagSide, WeakenMoveScreenTag } from "../data/arena-tag"; @@ -51,6 +51,7 @@ import { Biome } from "#enums/biome"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { getPokemonNameWithAffix } from "#app/messages.js"; +import { PokemonAnimType } from "#app/enums/pokemon-anim-type.js"; export enum FieldPosition { CENTER, @@ -547,6 +548,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 []; @@ -621,6 +639,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 { return new Promise(resolve => { if (fieldPosition === this.fieldPosition) { @@ -2069,6 +2128,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.canIgnoreSubstitute(source)) { + substitute.hp -= damage.value; + damage.value = 0; + } if (this.isFullHp()) { applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, damage); } else if (!this.isPlayer() && damage.value >= this.hp) { @@ -2126,6 +2192,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(); } @@ -2139,6 +2206,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (!typeless) { applyPreDefendAbAttrs(TypeImmunityAbAttr, this, source, move, cancelled, typeMultiplier); } + if (!!this.getTag(SubstituteTag) && !move.canIgnoreSubstitute(source)) { + cancelled.value = true; + } if (!cancelled.value) { applyPreDefendAbAttrs(MoveImmunityAbAttr, this, source, move, cancelled, typeMultiplier); defendingSidePlayField.forEach((p) => applyPreDefendAbAttrs(FieldPriorityMoveImmunityAbAttr, p, source, move, cancelled, typeMultiplier)); @@ -2193,6 +2263,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(); } @@ -2765,6 +2836,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(); diff --git a/src/locales/de/battle.ts b/src/locales/de/battle.ts index a9686da7524..f5981120c66 100644 --- a/src/locales/de/battle.ts +++ b/src/locales/de/battle.ts @@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} wurde durch {{moveName}} verletzt!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} nimmt einen Teil seiner KP und legt einen Fluch auf {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} wurde durch den Fluch verletzt!", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "battlerTagsSubstituteOnAdd": "Ein Delegator von {{pokemonNameWithAffix}} ist erschienen!", + "battlerTagsSubstituteOnHit": "Der Delegator steckt den Schlag für {{pokemonNameWithAffix}} ein!", + "battlerTagsSubstituteOnRemove": "Der Delegator von {{pokemonNameWithAffix}} hört auf zu wirken!" } as const; diff --git a/src/locales/en/battle.ts b/src/locales/en/battle.ts index 12a0f2c99c6..17b3e37581f 100644 --- a/src/locales/en/battle.ts +++ b/src/locales/en/battle.ts @@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}} put in a substitute!", + "battlerTagsSubstituteOnHit": "The substitute took damage for {{pokemonNameWithAffix}}!", + "battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}'s substitute faded!" } as const; diff --git a/src/locales/es/battle.ts b/src/locales/es/battle.ts index 7f29060c5d3..21080569cde 100644 --- a/src/locales/es/battle.ts +++ b/src/locales/es/battle.ts @@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "battlerTagsSubstituteOnAdd": "¡{{pokemonNameWithAffix}} creó un sustituto!", + "battlerTagsSubstituteOnHit": "¡El sustituto recibe daño en lugar del {{pokemonNameWithAffix}}!", + "battlerTagsSubstituteOnRemove": "¡El sustituto del {{pokemonNameWithAffix}} se debilitó!" } as const; diff --git a/src/locales/fr/battle.ts b/src/locales/fr/battle.ts index 861dc6fd73c..00bc3b7f1e9 100644 --- a/src/locales/fr/battle.ts +++ b/src/locales/fr/battle.ts @@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} sacrifie des PV\net lance une malédiction sur {{pokemonName}} !", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} est touché par la malédiction !", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}\ncrée un clone !", + "battlerTagsSubstituteOnHit": "Le clone subit les dégâts à la place\nde {{pokemonNameWithAffix}} !", + "battlerTagsSubstituteOnRemove": "Le clone de {{pokemonNameWithAffix}}\ndisparait…" } as const; diff --git a/src/locales/it/battle.ts b/src/locales/it/battle.ts index 954f52e4a7f..5497e1d32f0 100644 --- a/src/locales/it/battle.ts +++ b/src/locales/it/battle.ts @@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} ha sacrificato metà dei suoi PS per\nlanciare una maledizione su {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} subisce la maledizione!", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} ha usato Accumulo per la\n{{stockpiledCount}}ª volta!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} ha usato Accumulo per la\n{{stockpiledCount}}ª volta!", + "battlerTagsSubstituteOnAdd": "Appare un sostituto di {{pokemonNameWithAffix}}!", + "battlerTagsSubstituteOnHit": "Il sostituto viene colpito al posto di {{pokemonNameWithAffix}}!", + "battlerTagsSubstituteOnRemove": "Il sostituto di {{pokemonNameWithAffix}} svanisce!" } as const; diff --git a/src/locales/ko/battle.ts b/src/locales/ko/battle.ts index 10f3e1b5853..2b76ea2f97f 100644 --- a/src/locales/ko/battle.ts +++ b/src/locales/ko/battle.ts @@ -156,4 +156,7 @@ export const battle: SimpleTranslationEntries = { "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!", "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!", + "battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}의\n대타가 나타났다!", + "battlerTagsSubstituteOnHit": "{{pokemonNameWithAffix}}[[를]] 대신하여\n대타가 공격을 받았다!", + "battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}의\n대타는 사라져 버렸다..." } as const; diff --git a/src/locales/pt_BR/battle.ts b/src/locales/pt_BR/battle.ts index 824b069f0a4..3682ebf810a 100644 --- a/src/locales/pt_BR/battle.ts +++ b/src/locales/pt_BR/battle.ts @@ -156,4 +156,7 @@ export const battle: SimpleTranslationEntries = { "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!", "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} estocou {{stockpiledCount}}!", + "battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}} colocou um substituto!", + "battlerTagsSubstituteOnHit": "O substituto tomou o dano pelo {{pokemonNameWithAffix}}!", + "battlerTagsSubstituteOnRemove": "O substituto de {{pokemonNameWithAffix}} desbotou!" } as const; diff --git a/src/locales/zh_CN/battle.ts b/src/locales/zh_CN/battle.ts index b07cb79e258..2bddd3e76b7 100644 --- a/src/locales/zh_CN/battle.ts +++ b/src/locales/zh_CN/battle.ts @@ -147,5 +147,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒!", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!", + "battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出现了!", + "battlerTagsSubstituteOnHit": "替身代替{{pokemonNameWithAffix}}\n承受了攻击!", + "battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……" } as const; diff --git a/src/locales/zh_TW/battle.ts b/src/locales/zh_TW/battle.ts index 4673474d313..0cc7470e0ac 100644 --- a/src/locales/zh_TW/battle.ts +++ b/src/locales/zh_TW/battle.ts @@ -144,5 +144,8 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!", - "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出現了!", + "battlerTagsSubstituteOnHit": "替身代替{{pokemonNameWithAffix}}承受了攻擊!", + "battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……" } as const; diff --git a/src/phases.ts b/src/phases.ts index d77ff7c29df..dd8bdcfe8e1 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -19,7 +19,7 @@ import { biomeLinks, getBiomeName } from "./data/biomes"; import { ModifierTier } from "./modifier/modifier-tier"; import { FusePokemonModifierType, ModifierPoolType, ModifierType, ModifierTypeFunc, ModifierTypeOption, PokemonModifierType, PokemonMoveModifierType, PokemonPpRestoreModifierType, PokemonPpUpModifierType, RememberMoveModifierType, TmModifierType, getDailyRunStarterModifiers, getEnemyBuffModifierForWave, getModifierType, getPlayerModifierTypeOptions, getPlayerShopModifierTypeOptionsForWave, modifierTypes, regenerateModifierPoolThresholds } from "./modifier/modifier-type"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; -import { BattlerTagLapseType, CenterOfAttentionTag, EncoreTag, ProtectedTag, SemiInvulnerableTag, TrappedTag } from "./data/battler-tags"; +import { BattlerTagLapseType, CenterOfAttentionTag, EncoreTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TrappedTag } from "./data/battler-tags"; import { getPokemonNameWithAffix } from "./messages"; import { Starter } from "./ui/starter-select-ui-handler"; import { Gender } from "./data/gender"; @@ -1601,6 +1601,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 ? @@ -1619,7 +1629,7 @@ export class SwitchSummonPhase extends SummonPhase { ease: "Sine.easeIn", scale: 0.5, onComplete: () => { - pokemon.setVisible(false); + pokemon.resetSprite(); this.scene.field.remove(pokemon); this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); this.scene.time.delayedCall(750, () => this.switchAndSummon()); @@ -1653,8 +1663,18 @@ export class SwitchSummonPhase extends SummonPhase { pokemonName: getPokemonNameWithAffix(this.getPokemon()) }) ); - // 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) { + switchedPokemon.x += switchedPokemon.getSubstituteOffset()[0]; + switchedPokemon.y += switchedPokemon.getSubstituteOffset()[1]; + switchedPokemon.setAlpha(0.5); + } + } else { party[this.fieldIndex].resetBattleData(); party[this.fieldIndex].resetSummonData(); } @@ -2571,7 +2591,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(); }); } @@ -2723,6 +2743,8 @@ 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(); } @@ -2742,6 +2764,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(); @@ -2952,7 +2975,7 @@ export class MoveEffectPhase extends PokemonPhase { const applyAttrs: Promise[] = []; // Move animation only needs one target - new MoveAnim(move.id as Moves, user, this.getTarget()?.getBattlerIndex()).play(this.scene, () => { + new MoveAnim(move.id as Moves, user, this.getTarget()?.getBattlerIndex()).play(this.scene, !move.canIgnoreSubstitute(user), () => { for (const target of targets) { if (!targetHitChecks[target.getBattlerIndex()]) { this.stopMultiHit(target); @@ -2995,7 +3018,7 @@ export class MoveEffectPhase extends PokemonPhase { if (hitResult !== HitResult.NO_EFFECT) { applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { - if (hitResult < HitResult.NO_EFFECT && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr)) { + if (hitResult < HitResult.NO_EFFECT && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && (!target.getTag(BattlerTagType.SUBSTITUTE) || move.canIgnoreSubstitute(user))) { const flinched = new Utils.BooleanHolder(false); user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); if (flinched.value) { @@ -3042,7 +3065,20 @@ export class MoveEffectPhase extends PokemonPhase { } } - 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(); + }); }); }); } @@ -3203,7 +3239,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, () => { + 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].canIgnoreSubstitute(user), () => { if (player) { this.playMoveAnim(moveQueue, false); } else { @@ -3542,7 +3580,7 @@ export class ObtainStatusEffectPhase extends PokemonPhase { pokemon.status.cureTurn = this.cureTurn; } 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)); if (pokemon.status.isPostTurn()) { this.scene.pushPhase(new PostTurnStatusEffectPhase(this.scene, this.battlerIndex)); @@ -3590,7 +3628,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase { this.scene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage, 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(); } @@ -3696,7 +3734,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, @@ -3832,7 +3870,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()) { @@ -5495,7 +5533,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()); }); }, () => { diff --git a/src/pokemon-anim-phase.ts b/src/pokemon-anim-phase.ts new file mode 100644 index 00000000000..722300d6d0d --- /dev/null +++ b/src/pokemon-anim-phase.ts @@ -0,0 +1,237 @@ +import BattleScene from "./battle-scene"; +import { SubstituteTag } from "./data/battler-tags"; +import { PokemonAnimType } from "./enums/pokemon-anim-type"; +import Pokemon from "./field/pokemon"; +import { BattlePhase } from "./phases"; + + + +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(); + } + }); + } + } + }); + } + }); + } +} diff --git a/src/test/battlerTags/substitute.test.ts b/src/test/battlerTags/substitute.test.ts new file mode 100644 index 00000000000..7be3e9e89d4 --- /dev/null +++ b/src/test/battlerTags/substitute.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Pokemon, { MoveResult, PokemonTurnData, TurnMove, PokemonMove } from "#app/field/pokemon.js"; +import BattleScene from "#app/battle-scene.js"; +import { BattlerTagLapseType, SubstituteTag, TrappedTag } from "#app/data/battler-tags.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; +import { Moves } from "#app/enums/moves.js"; +import { PokemonAnimType } from "#app/enums/pokemon-anim-type.js"; +import * as messages from "#app/messages.js"; +import { MoveEffectPhase } from "#app/phases.js"; +import { allMoves } from "#app/data/move.js"; + +jest.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 : undefined); + }); + + 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], "canIgnoreSubstitute").mockReturnValue(false); + + 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(); + } + ); + }); +}); diff --git a/src/test/moves/substitute.test.ts b/src/test/moves/substitute.test.ts new file mode 100644 index 00000000000..50ea5c8e222 --- /dev/null +++ b/src/test/moves/substitute.test.ts @@ -0,0 +1,418 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import Overrides from "#app/overrides"; +import { Species } from "#app/enums/species.js"; +import { Abilities } from "#app/enums/abilities.js"; +import { Moves } from "#app/enums/moves.js"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase, MoveEffectPhase, MoveEndPhase } from "#app/phases.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; +import { BattleStat } from "#app/data/battle-stat.js"; +import { allMoves, StealHeldItemChanceAttr } from "#app/data/move.js"; +import { SubstituteTag, TrappedTag } from "#app/data/battler-tags.js"; +import { StatusEffect } from "#app/data/status-effect.js"; +import { BerryType } from "#app/enums/berry-type.js"; +import { Mode } from "#app/ui/ui.js"; +import PartyUiHandler from "#app/ui/party-ui-handler.js"; +import { Button } from "#app/enums/buttons.js"; + +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); + + vi.spyOn(Overrides, "BATTLE_TYPE_OVERRIDE", "get").mockReturnValue("single"); + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SUBSTITUTE, Moves.SWORDS_DANCE, Moves.TACKLE, Moves.SPLASH]); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.INSOMNIA); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.SPLASH)); + vi.spyOn(Overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(Overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(100); + }); + + it( + "should cause the user to take damage", + async () => { + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.TACKLE)); + + await game.startBattle([Species.SKARMORY]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, 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 + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.GIGA_IMPACT)); + vi.spyOn(allMoves[Moves.GIGA_IMPACT], "accuracy", "get").mockReturnValue(100); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.CHARM)); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SUBSTITUTE)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(0); + expect(leadPokemon.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + } + ); + + it( + "should be bypassed by sound-based moves", + async () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.ECHOED_VOICE)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.TACKLE)); + vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.INFILTRATOR); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, 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.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SUBSTITUTE)); + + await game.phaseInterceptor.to(MoveEndPhase); + await game.toNextTurn(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SWORDS_DANCE)); + + await game.phaseInterceptor.to(MoveEndPhase, false); + + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(2); + }, TIMEOUT + ); + + it( + "should protect the user from flinching", + async () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.FAKE_OUT)); + vi.spyOn(Overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(1); // Ensures the Substitute will break + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + const enemyPokemon = game.scene.getEnemyPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, 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); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.SAND_TOMB)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, 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); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.LIQUIDATION)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.summonData.battleStats[BattleStat.DEF]).toBe(0); + }, TIMEOUT + ); + + it( + "should protect the user from being afflicted with status effects", + async () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.NUZZLE)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.THIEF)); + vi.spyOn(allMoves[Moves.THIEF], "attrs", "get").mockReturnValue([new StealHeldItemChanceAttr(1.0)]); // give Thief 100% steal rate + vi.spyOn(Overrides, "STARTING_HELD_ITEMS_OVERRIDE", "get").mockReturnValue([{name: "BERRY", type: BerryType.SITRUS}]); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.KNOCK_OFF]); + vi.spyOn(Overrides, "OPP_HELD_ITEMS_OVERRIDE", "get").mockReturnValue([{name: "BERRY", type: BerryType.SITRUS}]); + + await game.startBattle([Species.BLASTOISE]); + + const enemyPokemon = game.scene.getEnemyPokemon(); + + enemyPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, enemyPokemon.id); + const enemyNumItems = enemyPokemon.getHeldItems().length; + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.BUG_BITE)); + vi.spyOn(Overrides, "STARTING_HELD_ITEMS_OVERRIDE", "get").mockReturnValue([{name: "BERRY", type: BerryType.SITRUS}]); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + const enemyPokemon = game.scene.getEnemyPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, 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 () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.CLEAR_SMOG)); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SWORDS_DANCE)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(2); + }, TIMEOUT + ); + + it( + "should prevent the user from becoming confused", + async () => { + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.MAGICAL_TORQUE)); + vi.spyOn(allMoves[Moves.MAGICAL_TORQUE], "chance", "get").mockReturnValue(100); + + await game.startBattle([Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SWORDS_DANCE)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(2); + } + ); + + it.skip( + "should transfer to the switched in Pokemon when the source uses Baton Pass", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SUBSTITUTE, Moves.BATON_PASS]); + + await game.startBattle([Species.BLASTOISE, Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + + leadPokemon.addTag(BattlerTagType.SUBSTITUTE, null, null, leadPokemon.id); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BATON_PASS)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + + // TODO: Figure out how to navigate out of the Party UI + game.onNextPrompt("MoveEffectPhase", Mode.PARTY, () => { + const handler = game.scene.ui.getHandler() as PartyUiHandler; + handler.setCursor(1); + handler.processInput(Button.ACTION); + handler.setCursor(0); + handler.processInput(Button.ACTION); + handler.processInput(Button.ACTION); + }); + + await game.phaseInterceptor.to(BerryPhase, 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 + ); +}); diff --git a/src/test/moves/tidy_up.test.ts b/src/test/moves/tidy_up.test.ts index b1292de0d27..76b6b99f55e 100644 --- a/src/test/moves/tidy_up.test.ts +++ b/src/test/moves/tidy_up.test.ts @@ -9,6 +9,7 @@ import { Species } from "#enums/species"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { SPLASH_ONLY } from "../utils/testUtils"; +import { SubstituteTag } from "#app/data/battler-tags.js"; describe("Moves - Tidy Up", () => { @@ -90,7 +91,7 @@ describe("Moves - Tidy Up", () => { }, 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]); @@ -100,8 +101,12 @@ describe("Moves - Tidy Up", () => { await game.phaseInterceptor.to(TurnEndPhase); game.doAttack(getMovePosition(game.scene, 0, 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() => { diff --git a/src/ui/target-select-ui-handler.ts b/src/ui/target-select-ui-handler.ts index 48799473343..87d5bc54e8b 100644 --- a/src/ui/target-select-ui-handler.ts +++ b/src/ui/target-select-ui-handler.ts @@ -7,6 +7,7 @@ import { getMoveTargets } from "../data/move"; import {Button} from "#enums/buttons"; import { Moves } from "#enums/moves"; import Pokemon from "#app/field/pokemon.js"; +import { SubstituteTag } from "#app/data/battler-tags.js"; export type TargetSelectCallback = (targets: BattlerIndex[]) => void; @@ -107,7 +108,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); } } @@ -153,7 +154,7 @@ export default class TargetSelectUiHandler extends UiHandler { this.targetFlashTween = null; } for (const pokemon of this.targetsHighlighted) { - pokemon.setAlpha(1); + pokemon.setAlpha(!!pokemon.getTag(SubstituteTag) ? 0.5 : 1); } if (this.targetBattleInfoMoveTween.length >= 1) {