Implement Substitute

Squashed commit from working branch
This commit is contained in:
innerthunder 2024-07-29 00:13:16 -07:00
parent c1595bf2b7
commit 978816b9ba
23 changed files with 1419 additions and 65 deletions

View File

@ -66,6 +66,8 @@ import { PlayerGender } from "#enums/player-gender";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { UiTheme } from "#enums/ui-theme"; import { UiTheme } from "#enums/ui-theme";
import { TimedEventManager } from "#app/timed-event-manager.js"; 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 i18next from "i18next";
import {TrainerType} from "#enums/trainer-type"; import {TrainerType} from "#enums/trainer-type";
import { battleSpecDialogue } from "./data/dialogue"; import { battleSpecDialogue } from "./data/dialogue";
@ -950,10 +952,12 @@ export default class BattleScene extends SceneBase {
this.enemyModifierBar.removeAll(true); this.enemyModifierBar.removeAll(true);
for (const p of this.getParty()) { for (const p of this.getParty()) {
p.destroySubstitute();
p.destroy(); p.destroy();
} }
this.party = []; this.party = [];
for (const p of this.getEnemyParty()) { for (const p of this.getEnemyParty()) {
p.destroySubstitute();
p.destroy(); p.destroy();
} }
@ -2582,6 +2586,16 @@ export default class BattleScene extends SceneBase {
return false; return false;
} }
triggerPokemonBattleAnim(pokemon: Pokemon, battleAnimType: PokemonAnimType, fieldAssets?: Phaser.GameObjects.Sprite[], delayed: boolean = false): boolean {
const phase: Phase = new PokemonAnimPhase(this, battleAnimType, pokemon, fieldAssets);
if (delayed) {
this.pushPhase(phase);
} else {
this.unshiftPhase(phase);
}
return true;
}
validateAchvs(achvType: Constructor<Achv>, ...args: unknown[]): void { validateAchvs(achvType: Constructor<Achv>, ...args: unknown[]): void {
const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType); const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType);
for (const achv of filteredAchvs) { for (const achv of filteredAchvs) {

View File

@ -1611,6 +1611,10 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr {
} }
applyPostAttackAfterMoveTypeCheck(pokemon: Pokemon, passive: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { 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.*/ /**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) { 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)]; 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) { if (this.intimidate) {
applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled); applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled);
applyAbAttrs(PostIntimidateStatChangeAbAttr, opponent, cancelled); applyAbAttrs(PostIntimidateStatChangeAbAttr, opponent, cancelled);
if (!!opponent.getTag(BattlerTagType.SUBSTITUTE)) {
cancelled.value = true;
}
} }
if (!cancelled.value) { if (!cancelled.value) {
const statChangePhase = new StatChangePhase(pokemon.scene, opponent.getBattlerIndex(), false, this.stats, this.levels); 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() { export function initAbilities() {
allAbilities.push( allAbilities.push(
new Ability(Abilities.STENCH, 3) 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) new Ability(Abilities.DRIZZLE, 3)
.attr(PostSummonWeatherChangeAbAttr, WeatherType.RAIN) .attr(PostSummonWeatherChangeAbAttr, WeatherType.RAIN)
.attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.RAIN), .attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.RAIN),

View File

@ -6,6 +6,7 @@ import * as Utils from "../utils";
import { BattlerIndex } from "../battle"; import { BattlerIndex } from "../battle";
import { Element } from "json-stable-stringify"; import { Element } from "json-stable-stringify";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { SubstituteTag } from "./battler-tags";
//import fs from 'vite-plugin-fs/browser'; //import fs from 'vite-plugin-fs/browser';
export enum AnimFrameTarget { export enum AnimFrameTarget {
@ -694,7 +695,7 @@ export abstract class BattleAnim {
return false; return false;
} }
private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[]): Map<integer, Map<AnimFrameTarget, GraphicFrameData>> { private getGraphicFrameData(scene: BattleScene, frames: AnimFrame[], onSubstitute?: boolean): Map<integer, Map<AnimFrameTarget, GraphicFrameData>> {
const ret: Map<integer, Map<AnimFrameTarget, GraphicFrameData>> = new Map([ const ret: Map<integer, Map<AnimFrameTarget, GraphicFrameData>> = new Map([
[AnimFrameTarget.GRAPHIC, new Map<AnimFrameTarget, GraphicFrameData>() ], [AnimFrameTarget.GRAPHIC, new Map<AnimFrameTarget, GraphicFrameData>() ],
[AnimFrameTarget.USER, new Map<AnimFrameTarget, GraphicFrameData>() ], [AnimFrameTarget.USER, new Map<AnimFrameTarget, GraphicFrameData>() ],
@ -705,12 +706,15 @@ export abstract class BattleAnim {
const user = !isOppAnim ? this.user : this.target; const user = !isOppAnim ? this.user : this.target;
const target = !isOppAnim ? this.target : this.user; const target = !isOppAnim ? this.target : this.user;
const targetSubstitute = (!!onSubstitute && user !== target) ? target.getTag(SubstituteTag) : null;
const userInitialX = user.x; const userInitialX = user.x;
const userInitialY = user.y; const userInitialY = user.y;
const userHalfHeight = user.getSprite().displayHeight / 2; const userHalfHeight = user.getSprite().displayHeight / 2;
const targetInitialX = target.x;
const targetInitialY = target.y; const targetInitialX = targetSubstitute?.sprite?.x ?? target.x;
const targetHalfHeight = target.getSprite().displayHeight / 2; const targetInitialY = targetSubstitute?.sprite?.y ?? target.y;
const targetHalfHeight = (targetSubstitute?.sprite ?? target.getSprite()).displayHeight / 2;
let g = 0; let g = 0;
let u = 0; let u = 0;
@ -748,7 +752,7 @@ export abstract class BattleAnim {
return ret; return ret;
} }
play(scene: BattleScene, callback?: Function) { play(scene: BattleScene, onSubstitute?: boolean, callback?: Function) {
const isOppAnim = this.isOppAnim(); const isOppAnim = this.isOppAnim();
const user = !isOppAnim ? this.user : this.target; const user = !isOppAnim ? this.user : this.target;
const target = !isOppAnim ? this.target : this.user; const target = !isOppAnim ? this.target : this.user;
@ -760,8 +764,10 @@ export abstract class BattleAnim {
return; return;
} }
const targetSubstitute = (!!onSubstitute && user !== target) ? target.getTag(SubstituteTag) : null;
const userSprite = user.getSprite(); const userSprite = user.getSprite();
const targetSprite = target.getSprite(); const targetSprite = targetSubstitute?.sprite ?? target.getSprite();
const spriteCache: SpriteCache = { const spriteCache: SpriteCache = {
[AnimFrameTarget.GRAPHIC]: [], [AnimFrameTarget.GRAPHIC]: [],
@ -776,9 +782,18 @@ export abstract class BattleAnim {
userSprite.setAlpha(1); userSprite.setAlpha(1);
userSprite.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ]; userSprite.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ];
userSprite.setAngle(0); userSprite.setAngle(0);
targetSprite.setPosition(0, 0); if (!targetSubstitute) {
targetSprite.setScale(1); targetSprite.setPosition(0, 0);
targetSprite.setAlpha(1); 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.pipelineData["tone"] = [ 0.0, 0.0, 0.0, 0.0 ];
targetSprite.setAngle(0); targetSprite.setAngle(0);
if (!this.isHideUser()) { if (!this.isHideUser()) {
@ -808,8 +823,8 @@ export abstract class BattleAnim {
const userInitialX = user.x; const userInitialX = user.x;
const userInitialY = user.y; const userInitialY = user.y;
const targetInitialX = target.x; const targetInitialX = targetSubstitute?.sprite?.x ?? target.x;
const targetInitialY = target.y; const targetInitialY = targetSubstitute?.sprite?.y ?? target.y;
this.srcLine = [ userFocusX, userFocusY, targetFocusX, targetFocusY ]; this.srcLine = [ userFocusX, userFocusY, targetFocusX, targetFocusY ];
this.dstLine = [ userInitialX, userInitialY, targetInitialX, targetInitialY ]; this.dstLine = [ userInitialX, userInitialY, targetInitialX, targetInitialY ];
@ -827,7 +842,7 @@ export abstract class BattleAnim {
} }
const spriteFrames = anim.frames[f]; 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 u = 0;
let t = 0; let t = 0;
let g = 0; let g = 0;
@ -840,24 +855,34 @@ export abstract class BattleAnim {
const sprites = spriteCache[isUser ? AnimFrameTarget.USER : AnimFrameTarget.TARGET]; const sprites = spriteCache[isUser ? AnimFrameTarget.USER : AnimFrameTarget.TARGET];
const spriteSource = isUser ? userSprite : targetSprite; const spriteSource = isUser ? userSprite : targetSprite;
if ((isUser ? u : t) === sprites.length) { if ((isUser ? u : t) === sprites.length) {
const sprite = scene.addPokemonSprite(isUser ? user : target, 0, 0, spriteSource.texture, spriteSource.frame.name, true); if (!isUser && !!targetSubstitute) {
[ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user : target).getSprite().pipelineData[k]); const sprite = scene.addPokemonSprite(isUser ? user : target, 0, 0, spriteSource.texture, spriteSource.frame.name, true);
sprite.setPipelineData("spriteKey", (isUser ? user : target).getBattleSpriteKey()); [ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = (isUser ? user : target).getSprite().pipelineData[k]);
sprite.setPipelineData("shiny", (isUser ? user : target).shiny); sprite.setPipelineData("spriteKey", (isUser ? user : target).getBattleSpriteKey());
sprite.setPipelineData("variant", (isUser ? user : target).variant); sprite.setPipelineData("shiny", (isUser ? user : target).shiny);
sprite.setPipelineData("ignoreFieldPos", true); sprite.setPipelineData("variant", (isUser ? user : target).variant);
spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame)); sprite.setPipelineData("ignoreFieldPos", true);
scene.field.add(sprite); spriteSource.on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame));
sprites.push(sprite); 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 spriteIndex = isUser ? u++ : t++;
const pokemonSprite = sprites[spriteIndex]; const pokemonSprite = sprites[spriteIndex];
const graphicFrameData = frameData.get(frame.target).get(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.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); pokemonSprite.setData("locked", frame.locked);

View File

@ -18,6 +18,7 @@ import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import i18next from "#app/plugins/i18n.js"; import i18next from "#app/plugins/i18n.js";
import { PokemonAnimType } from "#app/enums/pokemon-anim-type.js";
export enum BattlerTagLapseType { export enum BattlerTagLapseType {
FAINT, FAINT,
@ -26,6 +27,7 @@ export enum BattlerTagLapseType {
AFTER_MOVE, AFTER_MOVE,
MOVE_EFFECT, MOVE_EFFECT,
TURN_END, TURN_END,
HIT,
CUSTOM CUSTOM
} }
@ -126,8 +128,9 @@ export class TrappedTag extends BattlerTag {
canAdd(pokemon: Pokemon): boolean { canAdd(pokemon: Pokemon): boolean {
const isGhost = pokemon.isOfType(Type.GHOST); const isGhost = pokemon.isOfType(Type.GHOST);
const isTrapped = pokemon.getTag(BattlerTagType.TRAPPED); const isTrapped = pokemon.getTag(BattlerTagType.TRAPPED);
const hasSubstitute = pokemon.getTag(BattlerTagType.SUBSTITUTE);
return !isTrapped && !isGhost; return !isTrapped && !isGhost && !hasSubstitute;
} }
onAdd(pokemon: Pokemon): void { onAdd(pokemon: Pokemon): void {
@ -765,7 +768,7 @@ export abstract class DamagingTrapTag extends TrappedTag {
} }
canAdd(pokemon: Pokemon): boolean { 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 { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
@ -1558,7 +1561,6 @@ export class IceFaceTag extends BattlerTag {
} }
} }
/** /**
* Battler tag enabling the Stockpile mechanic. This tag handles: * Battler tag enabling the Stockpile mechanic. This tag handles:
* - Stack tracking, including max limit enforcement (which is replicated in Stockpile for redundancy). * - Stack tracking, including max limit enforcement (which is replicated in Stockpile for redundancy).
@ -1587,7 +1589,6 @@ export class StockpilingTag extends BattlerTag {
if (defChange) { if (defChange) {
this.statChangeCounts[BattleStat.DEF]++; this.statChangeCounts[BattleStat.DEF]++;
} }
if (spDefChange) { if (spDefChange) {
this.statChangeCounts[BattleStat.SPDEF]++; 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 { export function getBattlerTag(tagType: BattlerTagType, turnCount: number, sourceMove: Moves, sourceId: number): BattlerTag {
switch (tagType) { switch (tagType) {
case BattlerTagType.RECHARGING: case BattlerTagType.RECHARGING:
@ -1770,6 +1859,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new StockpilingTag(sourceMove); return new StockpilingTag(sourceMove);
case BattlerTagType.OCTOLOCK: case BattlerTagType.OCTOLOCK:
return new OctolockTag(sourceId); return new OctolockTag(sourceId);
case BattlerTagType.SUBSTITUTE:
return new SubstituteTag(sourceMove, sourceId);
case BattlerTagType.NONE: case BattlerTagType.NONE:
default: default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);

View File

@ -1,7 +1,7 @@
import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims"; import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims";
import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases"; import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases";
import { BattleStat, getBattleStatName } from "./battle-stat"; 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 { getPokemonMessage, getPokemonNameWithAffix } from "../messages";
import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon";
import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects} from "./status-effect"; 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 * 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 * 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; type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
@ -310,6 +311,18 @@ export default class Move implements Localizable {
return false; 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 * Adds a move condition to the move
* @param condition {@linkcode MoveCondition} or {@linkcode MoveConditionFunc}, appends to conditions array a new MoveCondition object * @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; 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 * Sets the {@linkcode MoveFlags.REDIRECT_COUNTER} flag for the calling Move
* @param redirectCounter The value (boolean) to set the flag to * @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 { export enum MultiHitType {
_2, _2,
_2_TO_5, _2_TO_5,
@ -1865,6 +1927,10 @@ export class StatusEffectAttr extends MoveEffectAttr {
} }
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { 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 moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance; const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance;
if (statusCheck) { if (statusCheck) {
@ -1956,6 +2022,9 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise<boolean>(resolve => { return new Promise<boolean>(resolve => {
if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) {
return resolve(false);
}
const rand = Phaser.Math.RND.realInRange(0, 1); const rand = Phaser.Math.RND.realInRange(0, 1);
if (rand >= this.chance) { if (rand >= this.chance) {
return resolve(false); return resolve(false);
@ -2025,6 +2094,10 @@ export class RemoveHeldItemAttr extends MoveEffectAttr {
return false; return false;
} }
if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) {
return false;
}
const cancelled = new Utils.BooleanHolder(false); const cancelled = new Utils.BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft 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 * @returns {boolean} true if the function succeeds
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { 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); const cancelled = new Utils.BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft
if (cancelled.value === true) { if (cancelled.value === true) {
@ -2195,6 +2271,10 @@ export class HealStatusEffectAttr extends MoveEffectAttr {
return false; return false;
} }
if (!this.selfTarget && !!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) {
return false;
}
// Special edge case for shield dust blocking Sparkling Aria curing burn // Special edge case for shield dust blocking Sparkling Aria curing burn
const moveTargets = getMoveTargets(user, move.id); const moveTargets = getMoveTargets(user, move.id);
if (target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && move.id === Moves.SPARKLING_ARIA && moveTargets.targets.length === 1) { 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); const lastMove = user.getLastXMoves().find(() => true);
if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && (this.sameTurn || lastMove.turn !== user.scene.currentBattle.turn))) { 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; (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))); user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
if (this.tagType) { if (this.tagType) {
user.addTag(this.tagType, 1, move.id, user.id); 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<boolean> { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise(resolve => { return new Promise(resolve => {
if (args.length < 2 || !args[1]) { 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; (args[0] as Utils.BooleanHolder).value = true;
user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER }); user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER });
@ -2518,6 +2598,10 @@ export class StatChangeAttr extends MoveEffectAttr {
return false; return false;
} }
if (!this.selfTarget && !!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) {
return false;
}
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) { if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) {
const levels = this.getLevels(user); const levels = this.getLevels(user);
@ -2724,6 +2808,10 @@ export class ResetStatsAttr extends MoveEffectAttr {
return false; return false;
} }
if (!!target.getTag(SubstituteTag) && !move.canIgnoreSubstitute(user)) {
return false;
}
for (let s = 0; s < target.summonData.battleStats.length; s++) { for (let s = 0; s < target.summonData.battleStats.length; s++) {
target.summonData.battleStats[s] = 0; target.summonData.battleStats[s] = 0;
} }
@ -4373,12 +4461,26 @@ export class FlinchAttr extends AddBattlerTagAttr {
constructor() { constructor() {
super(BattlerTagType.FLINCHED, false); 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 { export class ConfuseAttr extends AddBattlerTagAttr {
constructor(selfTarget?: boolean) { constructor(selfTarget?: boolean) {
super(BattlerTagType.CONFUSED, selfTarget, false, 2, 5); 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 { 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 * Attribute used when a move hits a {@linkcode BattlerTagType} for double damage
* @extends MoveAttr * @extends MoveAttr
@ -4837,6 +4955,10 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
const switchOutTarget = (this.user ? user : target); const switchOutTarget = (this.user ? user : target);
const player = switchOutTarget instanceof PlayerPokemon; 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())) { if (!this.user && move.category === MoveCategory.STATUS && (target.hasAbilityWithAttr(ForceSwitchOutImmunityAbAttr) || target.isMax())) {
return false; return false;
} }
@ -6026,6 +6148,7 @@ export function initMoves() {
new StatusMove(Moves.WHIRLWIND, Type.NORMAL, -1, 20, -1, -6, 1) new StatusMove(Moves.WHIRLWIND, Type.NORMAL, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr) .attr(ForceSwitchOutAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING, false) .attr(HitsTagAttr, BattlerTagType.FLYING, false)
.ignoresSubstitute()
.hidesTarget() .hidesTarget()
.windMove(), .windMove(),
new AttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1) new AttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1)
@ -6115,6 +6238,7 @@ export function initMoves() {
.attr(FixedDamageAttr, 20), .attr(FixedDamageAttr, 20),
new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1) new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1)
.attr(DisableMoveAttr) .attr(DisableMoveAttr)
.ignoresSubstitute()
.condition(failOnMaxCondition), .condition(failOnMaxCondition),
new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1) new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1)
.attr(StatChangeAttr, BattleStat.SPDEF, -1) .attr(StatChangeAttr, BattleStat.SPDEF, -1)
@ -6251,6 +6375,7 @@ export function initMoves() {
.attr(LevelDamageAttr), .attr(LevelDamageAttr),
new StatusMove(Moves.MIMIC, Type.NORMAL, -1, 10, -1, 0, 1) new StatusMove(Moves.MIMIC, Type.NORMAL, -1, 10, -1, 0, 1)
.attr(MovesetCopyMoveAttr) .attr(MovesetCopyMoveAttr)
.ignoresSubstitute()
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.SCREECH, Type.NORMAL, 85, 40, -1, 0, 1) new StatusMove(Moves.SCREECH, Type.NORMAL, 85, 40, -1, 0, 1)
.attr(StatChangeAttr, BattleStat.DEF, -2) .attr(StatChangeAttr, BattleStat.DEF, -2)
@ -6279,6 +6404,7 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.LIGHT_SCREEN, 5, true) .attr(AddArenaTagAttr, ArenaTagType.LIGHT_SCREEN, 5, true)
.target(MoveTarget.USER_SIDE), .target(MoveTarget.USER_SIDE),
new StatusMove(Moves.HAZE, Type.ICE, -1, 30, -1, 0, 1) new StatusMove(Moves.HAZE, Type.ICE, -1, 30, -1, 0, 1)
.ignoresSubstitute()
.target(MoveTarget.BOTH_SIDES) .target(MoveTarget.BOTH_SIDES)
.attr(ResetStatsAttr), .attr(ResetStatsAttr),
new StatusMove(Moves.REFLECT, Type.PSYCHIC, -1, 20, -1, 0, 1) new StatusMove(Moves.REFLECT, Type.PSYCHIC, -1, 20, -1, 0, 1)
@ -6422,14 +6548,15 @@ export function initMoves() {
.attr(HighCritAttr) .attr(HighCritAttr)
.slicingMove(), .slicingMove(),
new SelfStatusMove(Moves.SUBSTITUTE, Type.NORMAL, -1, 10, -1, 0, 1) new SelfStatusMove(Moves.SUBSTITUTE, Type.NORMAL, -1, 10, -1, 0, 1)
.attr(RecoilAttr) .attr(AddSubstituteAttr)
.unimplemented(), .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) new AttackMove(Moves.STRUGGLE, Type.NORMAL, MoveCategory.PHYSICAL, 50, -1, 1, -1, 0, 1)
.attr(RecoilAttr, true, 0.25, true) .attr(RecoilAttr, true, 0.25, true)
.attr(TypelessAttr) .attr(TypelessAttr)
.ignoresVirtual() .ignoresVirtual()
.target(MoveTarget.RANDOM_NEAR_ENEMY), .target(MoveTarget.RANDOM_NEAR_ENEMY),
new StatusMove(Moves.SKETCH, Type.NORMAL, -1, 1, -1, 0, 2) new StatusMove(Moves.SKETCH, Type.NORMAL, -1, 1, -1, 0, 2)
.ignoresSubstitute()
.attr(SketchAttr) .attr(SketchAttr)
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.TRIPLE_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 10, 90, 10, -1, 0, 2) new AttackMove(Moves.TRIPLE_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 10, 90, 10, -1, 0, 2)
@ -6455,12 +6582,14 @@ export function initMoves() {
.soundBased(), .soundBased(),
new StatusMove(Moves.CURSE, Type.GHOST, -1, 10, -1, 0, 2) new StatusMove(Moves.CURSE, Type.GHOST, -1, 10, -1, 0, 2)
.attr(CurseAttr) .attr(CurseAttr)
.ignoresSubstitute()
.ignoresProtect(true) .ignoresProtect(true)
.target(MoveTarget.CURSE), .target(MoveTarget.CURSE),
new AttackMove(Moves.FLAIL, Type.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2) new AttackMove(Moves.FLAIL, Type.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2)
.attr(LowHpPowerAttr), .attr(LowHpPowerAttr),
new StatusMove(Moves.CONVERSION_2, Type.NORMAL, -1, 30, -1, 0, 2) new StatusMove(Moves.CONVERSION_2, Type.NORMAL, -1, 30, -1, 0, 2)
.attr(ResistLastMoveTypeAttr) .attr(ResistLastMoveTypeAttr)
.ignoresSubstitute()
.partial(), // Checks the move's original typing and not if its type is changed through some other means .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) new AttackMove(Moves.AEROBLAST, Type.FLYING, MoveCategory.SPECIAL, 100, 95, 5, -1, 0, 2)
.windMove() .windMove()
@ -6472,6 +6601,7 @@ export function initMoves() {
new AttackMove(Moves.REVERSAL, Type.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2) new AttackMove(Moves.REVERSAL, Type.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2)
.attr(LowHpPowerAttr), .attr(LowHpPowerAttr),
new StatusMove(Moves.SPITE, Type.GHOST, 100, 10, -1, 0, 2) new StatusMove(Moves.SPITE, Type.GHOST, 100, 10, -1, 0, 2)
.ignoresSubstitute()
.attr(ReducePpMoveAttr, 4), .attr(ReducePpMoveAttr, 4),
new AttackMove(Moves.POWDER_SNOW, Type.ICE, MoveCategory.SPECIAL, 40, 100, 25, 10, 0, 2) new AttackMove(Moves.POWDER_SNOW, Type.ICE, MoveCategory.SPECIAL, 40, 100, 25, 10, 0, 2)
.attr(StatusEffectAttr, StatusEffect.FREEZE) .attr(StatusEffectAttr, StatusEffect.FREEZE)
@ -6502,6 +6632,7 @@ export function initMoves() {
.attr(StatusEffectAttr, StatusEffect.PARALYSIS) .attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.ballBombMove(), .ballBombMove(),
new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2) new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2) new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2)
.ignoresProtect() .ignoresProtect()
@ -6559,6 +6690,7 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1), .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1),
new StatusMove(Moves.ATTRACT, Type.NORMAL, 100, 15, -1, 0, 2) new StatusMove(Moves.ATTRACT, Type.NORMAL, 100, 15, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.INFATUATED) .attr(AddBattlerTagAttr, BattlerTagType.INFATUATED)
.ignoresSubstitute()
.condition((user, target, move) => user.isOppositeGender(target)), .condition((user, target, move) => user.isOppositeGender(target)),
new SelfStatusMove(Moves.SLEEP_TALK, Type.NORMAL, -1, 10, -1, 0, 2) new SelfStatusMove(Moves.SLEEP_TALK, Type.NORMAL, -1, 10, -1, 0, 2)
.attr(BypassSleepAttr) .attr(BypassSleepAttr)
@ -6604,6 +6736,7 @@ export function initMoves() {
.hidesUser(), .hidesUser(),
new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2) new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
.ignoresSubstitute()
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target)), .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)),
new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2)
.partial(), .partial(),
@ -6662,6 +6795,7 @@ export function initMoves() {
.attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2) .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2)
.target(MoveTarget.ATTACKER), .target(MoveTarget.ATTACKER),
new StatusMove(Moves.PSYCH_UP, Type.NORMAL, -1, 10, -1, 0, 2) new StatusMove(Moves.PSYCH_UP, Type.NORMAL, -1, 10, -1, 0, 2)
.ignoresSubstitute()
.attr(CopyStatsAttr), .attr(CopyStatsAttr),
new AttackMove(Moves.EXTREME_SPEED, Type.NORMAL, MoveCategory.PHYSICAL, 80, 100, 5, -1, 2, 2), 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) 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) .attr(WeatherChangeAttr, WeatherType.HAIL)
.target(MoveTarget.BOTH_SIDES), .target(MoveTarget.BOTH_SIDES),
new StatusMove(Moves.TORMENT, Type.DARK, 100, 15, -1, 0, 3) new StatusMove(Moves.TORMENT, Type.DARK, 100, 15, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new StatusMove(Moves.FLATTER, Type.DARK, 100, 15, -1, 0, 3) new StatusMove(Moves.FLATTER, Type.DARK, 100, 15, -1, 0, 3)
.attr(StatChangeAttr, BattleStat.SPATK, 1) .attr(StatChangeAttr, BattleStat.SPATK, 1)
@ -6738,13 +6873,16 @@ export function initMoves() {
.attr(StatChangeAttr, BattleStat.SPDEF, 1, true) .attr(StatChangeAttr, BattleStat.SPDEF, 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.CHARGED, true, false), .attr(AddBattlerTagAttr, BattlerTagType.CHARGED, true, false),
new StatusMove(Moves.TAUNT, Type.DARK, 100, 20, -1, 0, 3) new StatusMove(Moves.TAUNT, Type.DARK, 100, 20, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new StatusMove(Moves.HELPING_HAND, Type.NORMAL, -1, 20, -1, 5, 3) new StatusMove(Moves.HELPING_HAND, Type.NORMAL, -1, 20, -1, 5, 3)
.attr(AddBattlerTagAttr, BattlerTagType.HELPING_HAND) .attr(AddBattlerTagAttr, BattlerTagType.HELPING_HAND)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY), .target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.TRICK, Type.PSYCHIC, 100, 10, -1, 0, 3) new StatusMove(Moves.TRICK, Type.PSYCHIC, 100, 10, -1, 0, 3)
.unimplemented(), .unimplemented(),
new StatusMove(Moves.ROLE_PLAY, Type.PSYCHIC, -1, 10, -1, 0, 3) new StatusMove(Moves.ROLE_PLAY, Type.PSYCHIC, -1, 10, -1, 0, 3)
.ignoresSubstitute()
.attr(AbilityCopyAttr), .attr(AbilityCopyAttr),
new SelfStatusMove(Moves.WISH, Type.NORMAL, -1, 10, -1, 0, 3) new SelfStatusMove(Moves.WISH, Type.NORMAL, -1, 10, -1, 0, 3)
.triageMove() .triageMove()
@ -6777,8 +6915,10 @@ export function initMoves() {
.attr(HpPowerAttr) .attr(HpPowerAttr)
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(Moves.SKILL_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 3) new StatusMove(Moves.SKILL_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 3)
.ignoresSubstitute()
.attr(SwitchAbilitiesAttr), .attr(SwitchAbilitiesAttr),
new SelfStatusMove(Moves.IMPRISON, Type.PSYCHIC, -1, 10, -1, 0, 3) new SelfStatusMove(Moves.IMPRISON, Type.PSYCHIC, -1, 10, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new SelfStatusMove(Moves.REFRESH, Type.NORMAL, -1, 20, -1, 0, 3) new SelfStatusMove(Moves.REFRESH, Type.NORMAL, -1, 20, -1, 0, 3)
.attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN) .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(StatChangeAttr, BattleStat.SPATK, -2, true)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE), .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE),
new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3) new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3) new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3)
.attr(StatChangeAttr, BattleStat.SPD, -1) .attr(StatChangeAttr, BattleStat.SPD, -1)
@ -6969,6 +7110,7 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5) .attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.target(MoveTarget.BOTH_SIDES), .target(MoveTarget.BOTH_SIDES),
new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4) new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.WAKE_UP_SLAP, Type.FIGHTING, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 4) new AttackMove(Moves.WAKE_UP_SLAP, Type.FIGHTING, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 4)
.attr(MovePowerMultiplierAttr, (user, target, move) => targetSleptOrComatoseCondition(user, target, move) ? 2 : 1) .attr(MovePowerMultiplierAttr, (user, target, move) => targetSleptOrComatoseCondition(user, target, move) ? 2 : 1)
@ -7047,6 +7189,7 @@ export function initMoves() {
.target(MoveTarget.USER_SIDE) .target(MoveTarget.USER_SIDE)
.unimplemented(), .unimplemented(),
new StatusMove(Moves.ME_FIRST, Type.NORMAL, -1, 20, -1, 0, 4) new StatusMove(Moves.ME_FIRST, Type.NORMAL, -1, 20, -1, 0, 4)
.ignoresSubstitute()
.ignoresVirtual() .ignoresVirtual()
.target(MoveTarget.NEAR_ENEMY) .target(MoveTarget.NEAR_ENEMY)
.unimplemented(), .unimplemented(),
@ -7054,8 +7197,10 @@ export function initMoves() {
.attr(CopyMoveAttr) .attr(CopyMoveAttr)
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.POWER_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) new StatusMove(Moves.POWER_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new StatusMove(Moves.GUARD_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) new StatusMove(Moves.GUARD_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.PUNISHMENT, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4) new AttackMove(Moves.PUNISHMENT, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4)
.makesContact(true) .makesContact(true)
@ -7070,6 +7215,7 @@ export function initMoves() {
.attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES) .attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES)
.target(MoveTarget.ENEMY_SIDE), .target(MoveTarget.ENEMY_SIDE),
new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4) new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4)
.ignoresSubstitute()
.attr(SwapStatsAttr), .attr(SwapStatsAttr),
new SelfStatusMove(Moves.AQUA_RING, Type.WATER, -1, 20, -1, 0, 4) new SelfStatusMove(Moves.AQUA_RING, Type.WATER, -1, 20, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true),
@ -7352,6 +7498,7 @@ export function initMoves() {
.attr(AbilityGiveAttr), .attr(AbilityGiveAttr),
new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5) new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresProtect() .ignoresProtect()
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.ROUND, Type.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) new AttackMove(Moves.ROUND, Type.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5)
.soundBased() .soundBased()
@ -7389,6 +7536,7 @@ export function initMoves() {
new AttackMove(Moves.SKY_DROP, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) 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 .attr(ChargeAttr, ChargeAnim.SKY_DROP_CHARGING, i18next.t("moveTriggers:tookTargetIntoSky", {pokemonName: "{USER}", targetName: "{TARGET}"}), BattlerTagType.FLYING) // TODO: Add 2nd turn message
.condition(failOnGravityCondition) .condition(failOnGravityCondition)
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE))
.ignoresVirtual(), .ignoresVirtual(),
new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5) new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5)
.attr(StatChangeAttr, BattleStat.ATK, 1, true) .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) 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))), .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) new StatusMove(Moves.REFLECT_TYPE, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresSubstitute()
.attr(CopyTypeAttr), .attr(CopyTypeAttr),
new AttackMove(Moves.RETALIATE, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 5, -1, 0, 5) new AttackMove(Moves.RETALIATE, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 5, -1, 0, 5)
.partial(), .partial(),
@ -7411,6 +7560,7 @@ export function initMoves() {
.attr(SacrificialAttrOnHit), .attr(SacrificialAttrOnHit),
new StatusMove(Moves.BESTOW, Type.NORMAL, -1, 15, -1, 0, 5) new StatusMove(Moves.BESTOW, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresProtect() .ignoresProtect()
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.INFERNO, Type.FIRE, MoveCategory.SPECIAL, 100, 50, 5, 100, 0, 5) new AttackMove(Moves.INFERNO, Type.FIRE, MoveCategory.SPECIAL, 100, 50, 5, 100, 0, 5)
.attr(StatusEffectAttr, StatusEffect.BURN), .attr(StatusEffectAttr, StatusEffect.BURN),
@ -7616,12 +7766,14 @@ export function initMoves() {
.soundBased() .soundBased()
.target(MoveTarget.ALL_NEAR_OTHERS), .target(MoveTarget.ALL_NEAR_OTHERS),
new StatusMove(Moves.FAIRY_LOCK, Type.FAIRY, -1, 10, -1, 0, 6) new StatusMove(Moves.FAIRY_LOCK, Type.FAIRY, -1, 10, -1, 0, 6)
.ignoresSubstitute()
.target(MoveTarget.BOTH_SIDES) .target(MoveTarget.BOTH_SIDES)
.unimplemented(), .unimplemented(),
new SelfStatusMove(Moves.KINGS_SHIELD, Type.STEEL, -1, 10, -1, 4, 6) new SelfStatusMove(Moves.KINGS_SHIELD, Type.STEEL, -1, 10, -1, 4, 6)
.attr(ProtectAttr, BattlerTagType.KINGS_SHIELD), .attr(ProtectAttr, BattlerTagType.KINGS_SHIELD),
new StatusMove(Moves.PLAY_NICE, Type.NORMAL, -1, 20, -1, 0, 6) 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) new StatusMove(Moves.CONFIDE, Type.NORMAL, -1, 20, -1, 0, 6)
.attr(StatChangeAttr, BattleStat.SPATK, -1) .attr(StatChangeAttr, BattleStat.SPATK, -1)
.soundBased(), .soundBased(),
@ -7634,7 +7786,8 @@ export function initMoves() {
.attr(HealStatusEffectAttr, false, StatusEffect.FREEZE) .attr(HealStatusEffectAttr, false, StatusEffect.FREEZE)
.attr(StatusEffectAttr, StatusEffect.BURN), .attr(StatusEffectAttr, StatusEffect.BURN),
new AttackMove(Moves.HYPERSPACE_HOLE, Type.PSYCHIC, MoveCategory.SPECIAL, 80, -1, 5, -1, 0, 6) 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) new AttackMove(Moves.WATER_SHURIKEN, Type.WATER, MoveCategory.SPECIAL, 15, 100, 20, -1, 1, 6)
.attr(MultiHitAttr) .attr(MultiHitAttr)
.attr(WaterShurikenPowerAttr) .attr(WaterShurikenPowerAttr)
@ -7645,6 +7798,7 @@ export function initMoves() {
.attr(ProtectAttr, BattlerTagType.SPIKY_SHIELD), .attr(ProtectAttr, BattlerTagType.SPIKY_SHIELD),
new StatusMove(Moves.AROMATIC_MIST, Type.FAIRY, -1, 20, -1, 0, 6) new StatusMove(Moves.AROMATIC_MIST, Type.FAIRY, -1, 20, -1, 0, 6)
.attr(StatChangeAttr, BattleStat.SPDEF, 1) .attr(StatChangeAttr, BattleStat.SPDEF, 1)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY), .target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6) new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6)
.attr(StatChangeAttr, BattleStat.SPATK, -2), .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) .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), .target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6) new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6)
.ignoresSubstitute()
.powderMove() .powderMove()
.unimplemented(), .unimplemented(),
new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6) new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6)
@ -7660,6 +7815,7 @@ export function initMoves() {
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6) 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))) .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) .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)))), .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 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), .target(MoveTarget.ALL_NEAR_ENEMIES),
new SelfStatusMove(Moves.CELEBRATE, Type.NORMAL, -1, 40, -1, 0, 6), new SelfStatusMove(Moves.CELEBRATE, Type.NORMAL, -1, 40, -1, 0, 6),
new StatusMove(Moves.HOLD_HANDS, Type.NORMAL, -1, 40, -1, 0, 6) new StatusMove(Moves.HOLD_HANDS, Type.NORMAL, -1, 40, -1, 0, 6)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY), .target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.BABY_DOLL_EYES, Type.FAIRY, 100, 30, -1, 1, 6) new StatusMove(Moves.BABY_DOLL_EYES, Type.FAIRY, 100, 30, -1, 1, 6)
.attr(StatChangeAttr, BattleStat.ATK, -1), .attr(StatChangeAttr, BattleStat.ATK, -1),
@ -7717,6 +7874,7 @@ export function initMoves() {
.attr(StatChangeAttr, [ BattleStat.DEF, BattleStat.SPDEF ], -1, true), .attr(StatChangeAttr, [ BattleStat.DEF, BattleStat.SPDEF ], -1, true),
new AttackMove(Moves.HYPERSPACE_FURY, Type.DARK, MoveCategory.PHYSICAL, 100, -1, 5, -1, 0, 6) new AttackMove(Moves.HYPERSPACE_FURY, Type.DARK, MoveCategory.PHYSICAL, 100, -1, 5, -1, 0, 6)
.attr(StatChangeAttr, BattleStat.DEF, -1, true) .attr(StatChangeAttr, BattleStat.DEF, -1, true)
.ignoresSubstitute()
.makesContact(false) .makesContact(false)
.ignoresProtect(), .ignoresProtect(),
/* Unused */ /* Unused */
@ -7875,6 +8033,7 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false), .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false),
new StatusMove(Moves.GEAR_UP, Type.STEEL, -1, 20, -1, 0, 7) 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))) .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) .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)))), .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) 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)})); user.scene.queueMessage(i18next.t("moveTriggers:burnedItselfOut", {pokemonName: getPokemonNameWithAffix(user)}));
}), }),
new StatusMove(Moves.SPEED_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 7) new StatusMove(Moves.SPEED_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 7)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.SMART_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 7), 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) 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) new AttackMove(Moves.TROP_KICK, Type.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7)
.attr(StatChangeAttr, BattleStat.ATK, -1), .attr(StatChangeAttr, BattleStat.ATK, -1),
new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7) new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7)
.ignoresSubstitute()
.unimplemented(), .unimplemented(),
new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, 5, 7) 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) .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) new AttackMove(Moves.PRISMATIC_LASER, Type.PSYCHIC, MoveCategory.SPECIAL, 160, 100, 10, -1, 0, 7)
.attr(RechargeAttr), .attr(RechargeAttr),
new AttackMove(Moves.SPECTRAL_THIEF, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 7) new AttackMove(Moves.SPECTRAL_THIEF, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 7)
.ignoresSubstitute()
.partial(), .partial(),
new AttackMove(Moves.SUNSTEEL_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 7) new AttackMove(Moves.SUNSTEEL_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 7)
.ignoresAbilities() .ignoresAbilities()
@ -8646,7 +8808,8 @@ export function initMoves() {
.target(MoveTarget.BOTH_SIDES), .target(MoveTarget.BOTH_SIDES),
new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9) new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9)
.attr(StatChangeAttr, [ BattleStat.ATK, BattleStat.SPD ], 1, true, null, true, true) .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) new StatusMove(Moves.SNOWSCAPE, Type.ICE, -1, 10, -1, 0, 9)
.attr(WeatherChangeAttr, WeatherType.SNOW) .attr(WeatherChangeAttr, WeatherType.SNOW)
.target(MoveTarget.BOTH_SIDES), .target(MoveTarget.BOTH_SIDES),

View File

@ -63,5 +63,6 @@ export enum BattlerTagType {
ICE_FACE = "ICE_FACE", ICE_FACE = "ICE_FACE",
STOCKPILING = "STOCKPILING", STOCKPILING = "STOCKPILING",
RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE", RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE",
ALWAYS_GET_HIT = "ALWAYS_GET_HIT" ALWAYS_GET_HIT = "ALWAYS_GET_HIT",
SUBSTITUTE = "SUBSTITUTE"
} }

View File

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

View File

@ -19,7 +19,7 @@ import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEv
import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms"; import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms";
import { DamagePhase, FaintPhase, LearnMovePhase, MoveEffectPhase, ObtainStatusEffectPhase, StatChangePhase, SwitchSummonPhase, ToggleDoublePositionPhase, MoveEndPhase } from "../phases"; import { DamagePhase, FaintPhase, LearnMovePhase, MoveEffectPhase, ObtainStatusEffectPhase, StatChangePhase, SwitchSummonPhase, ToggleDoublePositionPhase, MoveEndPhase } from "../phases";
import { BattleStat } from "../data/battle-stat"; 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 { WeatherType } from "../data/weather";
import { TempBattleStat } from "../data/temp-battle-stat"; import { TempBattleStat } from "../data/temp-battle-stat";
import { ArenaTagSide, WeakenMoveScreenTag } from "../data/arena-tag"; import { ArenaTagSide, WeakenMoveScreenTag } from "../data/arena-tag";
@ -51,6 +51,7 @@ import { Biome } from "#enums/biome";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { getPokemonNameWithAffix } from "#app/messages.js"; import { getPokemonNameWithAffix } from "#app/messages.js";
import { PokemonAnimType } from "#app/enums/pokemon-anim-type.js";
export enum FieldPosition { export enum FieldPosition {
CENTER, CENTER,
@ -547,6 +548,23 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return 1; 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[] { getHeldItems(): PokemonHeldItemModifier[] {
if (!this.scene) { if (!this.scene) {
return []; 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<void> { setFieldPosition(fieldPosition: FieldPosition, duration?: integer): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {
if (fieldPosition === this.fieldPosition) { if (fieldPosition === this.fieldPosition) {
@ -2069,6 +2128,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND);
if (damage.value) { 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()) { if (this.isFullHp()) {
applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, damage); applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, damage);
} else if (!this.isPlayer() && damage.value >= this.hp) { } 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 // set splice index here, so future scene queues happen before FaintedPhase
this.scene.setPhaseQueueSplice(); this.scene.setPhaseQueueSplice();
this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo));
this.destroySubstitute();
this.resetSummonData(); this.resetSummonData();
} }
@ -2139,6 +2206,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (!typeless) { if (!typeless) {
applyPreDefendAbAttrs(TypeImmunityAbAttr, this, source, move, cancelled, typeMultiplier); applyPreDefendAbAttrs(TypeImmunityAbAttr, this, source, move, cancelled, typeMultiplier);
} }
if (!!this.getTag(SubstituteTag) && !move.canIgnoreSubstitute(source)) {
cancelled.value = true;
}
if (!cancelled.value) { if (!cancelled.value) {
applyPreDefendAbAttrs(MoveImmunityAbAttr, this, source, move, cancelled, typeMultiplier); applyPreDefendAbAttrs(MoveImmunityAbAttr, this, source, move, cancelled, typeMultiplier);
defendingSidePlayField.forEach((p) => applyPreDefendAbAttrs(FieldPriorityMoveImmunityAbAttr, p, 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.setPhaseQueueSplice();
this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), preventEndure)); this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), preventEndure));
this.destroySubstitute();
this.resetSummonData(); this.resetSummonData();
} }
@ -2765,6 +2836,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
this.summonData[k] = this.summonDataPrimer[k]; 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.summonDataPrimer = null;
} }
this.updateInfo(); this.updateInfo();

View File

@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} wurde durch {{moveName}} verletzt!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} wurde durch {{moveName}} verletzt!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} nimmt einen Teil seiner KP und legt einen Fluch auf {{pokemonName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} nimmt einen Teil seiner KP und legt einen Fluch auf {{pokemonName}}!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} wurde durch den Fluch verletzt!", "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; } as const;

View File

@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", "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; } as const;

View File

@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", "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; } as const;

View File

@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} sacrifie des PV\net lance une malédiction sur {{pokemonName}} !", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} sacrifie des PV\net lance une malédiction sur {{pokemonName}} !",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} est touché par la malédiction !", "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; } as const;

View File

@ -155,5 +155,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} ha sacrificato metà dei suoi PS per\nlanciare una maledizione su {{pokemonName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} ha sacrificato metà dei suoi PS per\nlanciare una maledizione su {{pokemonName}}!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} subisce la maledizione!", "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; } as const;

View File

@ -156,4 +156,7 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!", "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!",
"battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}의\n대타가 나타났다!",
"battlerTagsSubstituteOnHit": "{{pokemonNameWithAffix}}[[를]] 대신하여\n대타가 공격을 받았다!",
"battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}의\n대타는 사라져 버렸다..."
} as const; } as const;

View File

@ -156,4 +156,7 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} estocou {{stockpiledCount}}!", "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; } as const;

View File

@ -147,5 +147,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!" "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!",
"battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出现了",
"battlerTagsSubstituteOnHit": "替身代替{{pokemonNameWithAffix}}\n承受了攻击",
"battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……"
} as const; } as const;

View File

@ -144,5 +144,8 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!",
"battlerTagsSubstituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出現了",
"battlerTagsSubstituteOnHit": "替身代替{{pokemonNameWithAffix}}承受了攻擊!",
"battlerTagsSubstituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……"
} as const; } as const;

View File

@ -19,7 +19,7 @@ import { biomeLinks, getBiomeName } from "./data/biomes";
import { ModifierTier } from "./modifier/modifier-tier"; 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 { 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 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 { getPokemonNameWithAffix } from "./messages";
import { Starter } from "./ui/starter-select-ui-handler"; import { Starter } from "./ui/starter-select-ui-handler";
import { Gender } from "./data/gender"; import { Gender } from "./data/gender";
@ -1601,6 +1601,16 @@ export class SwitchSummonPhase extends SummonPhase {
if (!this.batonPass) { if (!this.batonPass) {
(this.player ? this.scene.getEnemyField() : this.scene.getPlayerField()).forEach(enemyPokemon => enemyPokemon.removeTagsBySourceId(pokemon.id)); (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 ? this.scene.ui.showText(this.player ?
@ -1619,7 +1629,7 @@ export class SwitchSummonPhase extends SummonPhase {
ease: "Sine.easeIn", ease: "Sine.easeIn",
scale: 0.5, scale: 0.5,
onComplete: () => { onComplete: () => {
pokemon.setVisible(false); pokemon.resetSprite();
this.scene.field.remove(pokemon); this.scene.field.remove(pokemon);
this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
this.scene.time.delayedCall(750, () => this.switchAndSummon()); this.scene.time.delayedCall(750, () => this.switchAndSummon());
@ -1653,8 +1663,18 @@ export class SwitchSummonPhase extends SummonPhase {
pokemonName: getPokemonNameWithAffix(this.getPokemon()) 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].resetBattleData();
party[this.fieldIndex].resetSummonData(); party[this.fieldIndex].resetSummonData();
} }
@ -2571,7 +2591,7 @@ export class CommonAnimPhase extends PokemonPhase {
} }
start() { 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(); this.end();
}); });
} }
@ -2723,6 +2743,8 @@ export class MovePhase extends BattlePhase {
this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); 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.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 moveQueue.shift(); // Remove the second turn of charge moves
return this.end(); return this.end();
} }
@ -2742,6 +2764,7 @@ export class MovePhase extends BattlePhase {
this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); 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.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc.
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
moveQueue.shift(); moveQueue.shift();
return this.end(); return this.end();
@ -2952,7 +2975,7 @@ export class MoveEffectPhase extends PokemonPhase {
const applyAttrs: Promise<void>[] = []; const applyAttrs: Promise<void>[] = [];
// Move animation only needs one target // 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) { for (const target of targets) {
if (!targetHitChecks[target.getBattlerIndex()]) { if (!targetHitChecks[target.getBattlerIndex()]) {
this.stopMultiHit(target); this.stopMultiHit(target);
@ -2995,7 +3018,7 @@ export class MoveEffectPhase extends PokemonPhase {
if (hitResult !== HitResult.NO_EFFECT) { if (hitResult !== HitResult.NO_EFFECT) {
applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY 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(() => { && !(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); const flinched = new Utils.BooleanHolder(false);
user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
if (flinched.value) { 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(() => { initMoveAnim(this.scene, moveId).then(() => {
loadMoveAnimAssets(this.scene, [moveId], true) loadMoveAnimAssets(this.scene, [moveId], true)
.then(() => { .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) { if (player) {
this.playMoveAnim(moveQueue, false); this.playMoveAnim(moveQueue, false);
} else { } else {
@ -3542,7 +3580,7 @@ export class ObtainStatusEffectPhase extends PokemonPhase {
pokemon.status.cureTurn = this.cureTurn; pokemon.status.cureTurn = this.cureTurn;
} }
pokemon.updateInfo(true); 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)); this.scene.queueMessage(getStatusEffectObtainText(this.statusEffect, getPokemonNameWithAffix(pokemon), this.sourceText));
if (pokemon.status.isPostTurn()) { if (pokemon.status.isPostTurn()) {
this.scene.pushPhase(new PostTurnStatusEffectPhase(this.scene, this.battlerIndex)); 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)); this.scene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage, false, true));
pokemon.updateInfo(); 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 { } else {
this.end(); this.end();
} }
@ -3696,7 +3734,7 @@ export class DamagePhase extends PokemonPhase {
this.scene.damageNumberHandler.add(this.getPokemon(), this.amount, this.damageResult, this.critical); 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({ const flashTimer = this.scene.time.addEvent({
delay: 100, delay: 100,
repeat: 5, repeat: 5,
@ -3832,7 +3870,7 @@ export class FaintPhase extends PokemonPhase {
y: pokemon.y + 150, y: pokemon.y + 150,
ease: "Sine.easeIn", ease: "Sine.easeIn",
onComplete: () => { onComplete: () => {
pokemon.setVisible(false); pokemon.resetSprite();
pokemon.y -= 150; pokemon.y -= 150;
pokemon.trySetStatus(StatusEffect.FAINT); pokemon.trySetStatus(StatusEffect.FAINT);
if (pokemon.isPlayer()) { if (pokemon.isPlayer()) {
@ -5495,7 +5533,7 @@ export class ScanIvsPhase extends PokemonPhase {
this.scene.ui.setMode(Mode.CONFIRM, () => { this.scene.ui.setMode(Mode.CONFIRM, () => {
this.scene.ui.setMode(Mode.MESSAGE); this.scene.ui.setMode(Mode.MESSAGE);
this.scene.ui.clearText(); 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()); this.scene.ui.getMessageHandler().promptIvs(pokemon.id, pokemon.ivs, this.shownIvs).then(() => this.end());
}); });
}, () => { }, () => {

237
src/pokemon-anim-phase.ts Normal file
View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { Species } from "#enums/species";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { SPLASH_ONLY } from "../utils/testUtils"; import { SPLASH_ONLY } from "../utils/testUtils";
import { SubstituteTag } from "#app/data/battler-tags.js";
describe("Moves - Tidy Up", () => { describe("Moves - Tidy Up", () => {
@ -90,7 +91,7 @@ describe("Moves - Tidy Up", () => {
}, 20000); }, 20000);
it.skip("substitutes are cleared", async() => { it("substitutes are cleared", async() => {
game.override.moveset([Moves.SUBSTITUTE, Moves.TIDY_UP]); game.override.moveset([Moves.SUBSTITUTE, Moves.TIDY_UP]);
game.override.enemyMoveset([Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE]); game.override.enemyMoveset([Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE, Moves.SUBSTITUTE]);
@ -100,8 +101,12 @@ describe("Moves - Tidy Up", () => {
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
game.doAttack(getMovePosition(game.scene, 0, Moves.TIDY_UP)); game.doAttack(getMovePosition(game.scene, 0, Moves.TIDY_UP));
await game.phaseInterceptor.to(MoveEndPhase); 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); }, 20000);
it("user's stats are raised with no traps set", async() => { it("user's stats are raised with no traps set", async() => {

View File

@ -7,6 +7,7 @@ import { getMoveTargets } from "../data/move";
import {Button} from "#enums/buttons"; import {Button} from "#enums/buttons";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import Pokemon from "#app/field/pokemon.js"; import Pokemon from "#app/field/pokemon.js";
import { SubstituteTag } from "#app/data/battler-tags.js";
export type TargetSelectCallback = (targets: BattlerIndex[]) => void; export type TargetSelectCallback = (targets: BattlerIndex[]) => void;
@ -107,7 +108,7 @@ export default class TargetSelectUiHandler extends UiHandler {
if (this.targetFlashTween) { if (this.targetFlashTween) {
this.targetFlashTween.stop(); this.targetFlashTween.stop();
for (const pokemon of multipleTargets) { 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; this.targetFlashTween = null;
} }
for (const pokemon of this.targetsHighlighted) { for (const pokemon of this.targetsHighlighted) {
pokemon.setAlpha(1); pokemon.setAlpha(!!pokemon.getTag(SubstituteTag) ? 0.5 : 1);
} }
if (this.targetBattleInfoMoveTween.length >= 1) { if (this.targetBattleInfoMoveTween.length >= 1) {