pokerogue/src/phases/stat-stage-change-phase.ts
Sirz Benjie 89c209f53e
Update abattr callsites in battler-tags
Also removed stat drop ability application from cancelling ME stat boost effects
2025-06-18 12:55:15 -06:00

398 lines
14 KiB
TypeScript

import { globalScene } from "#app/global-scene";
import type { BattlerIndex } from "#enums/battler-index";
import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import { MistTag } from "#app/data/arena-tag";
import { ArenaTagSide } from "#enums/arena-tag-side";
import type { ArenaTag } from "#app/data/arena-tag";
import type Pokemon from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages";
import { ResetNegativeStatStageModifier } from "#app/modifier/modifier";
import { handleTutorial, Tutorial } from "#app/tutorial";
import { NumberHolder, BooleanHolder, isNullOrUndefined } from "#app/utils/common";
import i18next from "i18next";
import { PokemonPhase } from "./pokemon-phase";
import { Stat, type BattleStat, getStatKey, getStatStageChangeDescriptionKey } from "#enums/stat";
import { OctolockTag } from "#app/data/battler-tags";
import { ArenaTagType } from "#app/enums/arena-tag-type";
import type {
ConditionalUserFieldProtectStatAbAttrParams,
PreStatStageChangeAbAttrParams,
} from "#app/@types/ability-types";
export type StatStageChangeCallback = (
target: Pokemon | null,
changed: BattleStat[],
relativeChanges: number[],
) => void;
export class StatStageChangePhase extends PokemonPhase {
public readonly phaseName = "StatStageChangePhase";
private stats: BattleStat[];
private selfTarget: boolean;
private stages: number;
private showMessage: boolean;
private ignoreAbilities: boolean;
private canBeCopied: boolean;
private onChange: StatStageChangeCallback | null;
private comingFromMirrorArmorUser: boolean;
private comingFromStickyWeb: boolean;
constructor(
battlerIndex: BattlerIndex,
selfTarget: boolean,
stats: BattleStat[],
stages: number,
showMessage = true,
ignoreAbilities = false,
canBeCopied = true,
onChange: StatStageChangeCallback | null = null,
comingFromMirrorArmorUser = false,
comingFromStickyWeb = false,
) {
super(battlerIndex);
this.selfTarget = selfTarget;
this.stats = stats;
this.stages = stages;
this.showMessage = showMessage;
this.ignoreAbilities = ignoreAbilities;
this.canBeCopied = canBeCopied;
this.onChange = onChange;
this.comingFromMirrorArmorUser = comingFromMirrorArmorUser;
this.comingFromStickyWeb = comingFromStickyWeb;
}
start() {
// Check if multiple stats are being changed at the same time, then run SSCPhase for each of them
if (this.stats.length > 1) {
for (let i = 0; i < this.stats.length; i++) {
const stat = [this.stats[i]];
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
this.battlerIndex,
this.selfTarget,
stat,
this.stages,
this.showMessage,
this.ignoreAbilities,
this.canBeCopied,
this.onChange,
this.comingFromMirrorArmorUser,
);
}
return this.end();
}
const pokemon = this.getPokemon();
let opponentPokemon: Pokemon | undefined;
/** Gets the position of last enemy or player pokemon that used ability or move, primarily for double battles involving Mirror Armor */
if (pokemon.isPlayer()) {
/** If this SSCP is not from sticky web, then we find the opponent pokemon that last did something */
if (!this.comingFromStickyWeb) {
opponentPokemon = globalScene.getEnemyField()[globalScene.currentBattle.lastEnemyInvolved];
} else {
/** If this SSCP is from sticky web, then check if pokemon that last sucessfully used sticky web is on field */
const stickyTagID = globalScene.arena.findTagsOnSide(
(t: ArenaTag) => t.tagType === ArenaTagType.STICKY_WEB,
ArenaTagSide.PLAYER,
)[0].sourceId;
globalScene.getEnemyField().forEach(e => {
if (e.id === stickyTagID) {
opponentPokemon = e;
}
});
}
} else {
if (!this.comingFromStickyWeb) {
opponentPokemon = globalScene.getPlayerField()[globalScene.currentBattle.lastPlayerInvolved];
} else {
const stickyTagID = globalScene.arena.findTagsOnSide(
(t: ArenaTag) => t.tagType === ArenaTagType.STICKY_WEB,
ArenaTagSide.ENEMY,
)[0].sourceId;
globalScene.getPlayerField().forEach(e => {
if (e.id === stickyTagID) {
opponentPokemon = e;
}
});
}
}
if (!pokemon.isActive(true)) {
return this.end();
}
const stages = new NumberHolder(this.stages);
if (!this.ignoreAbilities) {
applyAbAttrs("StatStageChangeMultiplierAbAttr", { pokemon, numStages: stages });
}
let simulate = false;
const filteredStats = this.stats.filter(stat => {
const cancelled = new BooleanHolder(false);
if (!this.selfTarget && stages.value < 0) {
// TODO: add a reference to the source of the stat change to fix Infiltrator interaction
globalScene.arena.applyTagsForSide(
MistTag,
pokemon.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY,
false,
null,
cancelled,
);
}
if (!cancelled.value && !this.selfTarget && stages.value < 0) {
const abAttrParams: PreStatStageChangeAbAttrParams & ConditionalUserFieldProtectStatAbAttrParams = {
pokemon,
stat,
cancelled,
simulated: simulate,
target: pokemon,
stages: this.stages,
};
applyAbAttrs("ProtectStatAbAttr", abAttrParams);
applyAbAttrs("ConditionalUserFieldProtectStatAbAttr", abAttrParams);
// TODO: Consider skipping this call if `cancelled` is false.
const ally = pokemon.getAlly();
if (!isNullOrUndefined(ally)) {
applyAbAttrs("ConditionalUserFieldProtectStatAbAttr", { ...abAttrParams, pokemon: ally });
}
/** Potential stat reflection due to Mirror Armor, does not apply to Octolock end of turn effect */
if (
opponentPokemon !== undefined &&
// TODO: investigate whether this is stoping mirror armor from applying to non-octolock
// reasons for stat drops if the user has the Octolock tag
!pokemon.findTag(t => t instanceof OctolockTag) &&
!this.comingFromMirrorArmorUser
) {
applyAbAttrs("ReflectStatStageChangeAbAttr", {
pokemon,
stat,
cancelled,
simulated: simulate,
source: opponentPokemon,
stages: this.stages,
});
}
}
// If one stat stage decrease is cancelled, simulate the rest of the applications
if (cancelled.value) {
simulate = true;
}
return !cancelled.value;
});
const relLevels = filteredStats.map(
s =>
(stages.value >= 1
? Math.min(pokemon.getStatStage(s) + stages.value, 6)
: Math.max(pokemon.getStatStage(s) + stages.value, -6)) - pokemon.getStatStage(s),
);
this.onChange?.(this.getPokemon(), filteredStats, relLevels);
const end = () => {
if (this.showMessage) {
const messages = this.getStatStageChangeMessages(filteredStats, stages.value, relLevels);
for (const message of messages) {
globalScene.phaseManager.queueMessage(message);
}
}
for (const s of filteredStats) {
if (stages.value > 0 && pokemon.getStatStage(s) < 6) {
pokemon.turnData.statStagesIncreased = true;
} else if (stages.value < 0 && pokemon.getStatStage(s) > -6) {
pokemon.turnData.statStagesDecreased = true;
}
pokemon.setStatStage(s, pokemon.getStatStage(s) + stages.value);
}
if (stages.value > 0 && this.canBeCopied) {
for (const opponent of pokemon.getOpponents()) {
applyAbAttrs("StatStageChangeCopyAbAttr", { pokemon: opponent, stats: this.stats, numStages: stages.value });
}
}
applyAbAttrs("PostStatStageChangeAbAttr", {
pokemon,
stats: filteredStats,
stages: this.stages,
selfTarget: this.selfTarget,
});
// Look for any other stat change phases; if this is the last one, do White Herb check
const existingPhase = globalScene.phaseManager.findPhase(
p => p.is("StatStageChangePhase") && p.battlerIndex === this.battlerIndex,
);
if (!existingPhase?.is("StatStageChangePhase")) {
// Apply White Herb if needed
const whiteHerb = globalScene.applyModifier(
ResetNegativeStatStageModifier,
this.player,
pokemon,
) as ResetNegativeStatStageModifier;
// If the White Herb was applied, consume it
if (whiteHerb) {
pokemon.loseHeldItem(whiteHerb);
globalScene.updateModifiers(this.player);
}
}
pokemon.updateInfo();
handleTutorial(Tutorial.Stat_Change).then(() => super.end());
};
if (relLevels.filter(l => l).length && globalScene.moveAnimations) {
pokemon.enableMask();
const pokemonMaskSprite = pokemon.maskSprite;
const tileX = (this.player ? 106 : 236) * pokemon.getSpriteScale() * globalScene.field.scale;
const tileY =
((this.player ? 148 : 84) + (stages.value >= 1 ? 160 : 0)) * pokemon.getSpriteScale() * globalScene.field.scale;
const tileWidth = 156 * globalScene.field.scale * pokemon.getSpriteScale();
const tileHeight = 316 * globalScene.field.scale * pokemon.getSpriteScale();
// On increase, show the red sprite located at ATK
// On decrease, show the blue sprite located at SPD
const spriteColor = stages.value >= 1 ? Stat[Stat.ATK].toLowerCase() : Stat[Stat.SPD].toLowerCase();
const statSprite = globalScene.add.tileSprite(tileX, tileY, tileWidth, tileHeight, "battle_stats", spriteColor);
statSprite.setPipeline(globalScene.fieldSpritePipeline);
statSprite.setAlpha(0);
statSprite.setScale(6);
statSprite.setOrigin(0.5, 1);
globalScene.playSound(`se/stat_${stages.value >= 1 ? "up" : "down"}`);
statSprite.setMask(new Phaser.Display.Masks.BitmapMask(globalScene, pokemonMaskSprite ?? undefined));
globalScene.tweens.add({
targets: statSprite,
duration: 250,
alpha: 0.8375,
onComplete: () => {
globalScene.tweens.add({
targets: statSprite,
delay: 1000,
duration: 250,
alpha: 0,
});
},
});
globalScene.tweens.add({
targets: statSprite,
duration: 1500,
y: `${stages.value >= 1 ? "-" : "+"}=${160 * 6}`,
});
globalScene.time.delayedCall(1750, () => {
pokemon.disableMask();
end();
});
} else {
end();
}
}
aggregateStatStageChanges(): void {
const accEva: BattleStat[] = [Stat.ACC, Stat.EVA];
const isAccEva = accEva.some(s => this.stats.includes(s));
let existingPhase: StatStageChangePhase;
if (this.stats.length === 1) {
while (
(existingPhase = globalScene.phaseManager.findPhase(
p =>
p.is("StatStageChangePhase") &&
p.battlerIndex === this.battlerIndex &&
p.stats.length === 1 &&
p.stats[0] === this.stats[0] &&
p.selfTarget === this.selfTarget &&
p.showMessage === this.showMessage &&
p.ignoreAbilities === this.ignoreAbilities,
) as StatStageChangePhase)
) {
this.stages += existingPhase.stages;
if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) {
break;
}
}
}
while (
(existingPhase = globalScene.phaseManager.findPhase(
p =>
p.is("StatStageChangePhase") &&
p.battlerIndex === this.battlerIndex &&
p.selfTarget === this.selfTarget &&
accEva.some(s => p.stats.includes(s)) === isAccEva &&
p.stages === this.stages &&
p.showMessage === this.showMessage &&
p.ignoreAbilities === this.ignoreAbilities,
) as StatStageChangePhase)
) {
this.stats.push(...existingPhase.stats);
if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) {
break;
}
}
}
getStatStageChangeMessages(stats: BattleStat[], stages: number, relStages: number[]): string[] {
const messages: string[] = [];
const relStageStatIndexes = {};
for (let rl = 0; rl < relStages.length; rl++) {
const relStage = relStages[rl];
if (!relStageStatIndexes[relStage]) {
relStageStatIndexes[relStage] = [];
}
relStageStatIndexes[relStage].push(rl);
}
Object.keys(relStageStatIndexes).forEach(rl => {
const relStageStats = stats.filter((_, i) => relStageStatIndexes[rl].includes(i));
let statsFragment = "";
if (relStageStats.length > 1) {
statsFragment =
relStageStats.length >= 5
? i18next.t("battle:stats")
: `${relStageStats
.slice(0, -1)
.map(s => i18next.t(getStatKey(s)))
.join(
", ",
)}${relStageStats.length > 2 ? "," : ""} ${i18next.t("battle:statsAnd")} ${i18next.t(getStatKey(relStageStats[relStageStats.length - 1]))}`;
messages.push(
i18next.t(getStatStageChangeDescriptionKey(Math.abs(Number.parseInt(rl)), stages >= 1), {
pokemonNameWithAffix: getPokemonNameWithAffix(this.getPokemon()),
stats: statsFragment,
count: relStageStats.length,
}),
);
} else {
statsFragment = i18next.t(getStatKey(relStageStats[0]));
messages.push(
i18next.t(getStatStageChangeDescriptionKey(Math.abs(Number.parseInt(rl)), stages >= 1), {
pokemonNameWithAffix: getPokemonNameWithAffix(this.getPokemon()),
stats: statsFragment,
count: relStageStats.length,
}),
);
}
});
return messages;
}
}