diff --git a/src/data/move.ts b/src/data/move.ts index f4a98ca8f8e..7a470fbca92 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -433,6 +433,7 @@ export abstract class MoveAttr { export enum MoveEffectTrigger { PRE_APPLY, + PRE_DAMAGE, POST_APPLY, HIT } @@ -2769,7 +2770,7 @@ export class RemoveScreensAttr extends MoveEffectAttr { private targetBothSides: boolean; constructor(targetBothSides: boolean = false) { - super(true, MoveEffectTrigger.PRE_APPLY); + super(true, MoveEffectTrigger.PRE_DAMAGE); this.targetBothSides = targetBothSides; } @@ -2778,6 +2779,9 @@ export class RemoveScreensAttr extends MoveEffectAttr { if (!super.apply(user, target, move, args)) return false; + if ((args[0] as Utils.BooleanHolder).value) // isTypeImmune + return false; + if(this.targetBothSides){ user.scene.arena.removeTagOnSide(ArenaTagType.REFLECT, ArenaTagSide.PLAYER); user.scene.arena.removeTagOnSide(ArenaTagType.LIGHT_SCREEN, ArenaTagSide.PLAYER); @@ -2798,6 +2802,51 @@ export class RemoveScreensAttr extends MoveEffectAttr { } } +export class StealStatBoostsAttr extends MoveEffectAttr { + + constructor() { + super(false, MoveEffectTrigger.PRE_DAMAGE); + } + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!super.apply(user, target, move, args)) + return false; + + if ((args[0] as Utils.BooleanHolder).value) // isTypeImmune + return false; + + let stoleStats = false; + let stolenStatBoosts = target.summonData.battleStats.map(stat => { + if (stat > 0) { + stoleStats = true; + return stat; + } + return 0; + }); + + if (!stoleStats) + return false; + + // directly set to 0 as opposed to "change" them to ignore abilities + for (let s = 0; s < target.summonData.battleStats.length; s++) + target.summonData.battleStats[s] = Math.min(target.summonData.battleStats[s], 0); + target.scene.queueMessage(getPokemonMessage(target, `'s stat boosts\nwere eliminated!`)); + + // accounting for StatChangePhase requiring a list of stats and a single amount to change by + let statsByLevel = {1: [], 2: [], 3: [], 4: [], 5: [], 6: []}; + for (let [stat, boost] of stolenStatBoosts.entries()) { + statsByLevel[boost]?.push(stat); + } + for (let level = 1; level <= 6; level++) { + if(statsByLevel[level].empty) + continue + // stat change must be immediate to be ready for damage calculation + user.scene.unshiftPhase(new StatChangePhase(user.scene, user.getBattlerIndex(), true, statsByLevel[level], level, true, false, true)); + } + return true; + } +} + export class ForceSwitchOutAttr extends MoveEffectAttr { private user: boolean; private batonPass: boolean; @@ -3818,7 +3867,7 @@ export function initMoves() { new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1) .attr(StatChangeAttr, BattleStat.SPDEF, -1) .target(MoveTarget.ALL_NEAR_ENEMIES), - new AttackMove(Moves.EMBER, Type.FIRE, MoveCategory.SPECIAL, 40, 100, 25, 10, 0, 1) + new AttackMove(Moves.EMBER, Type.FIRE, MoveCategory.SPECIAL, 40, 100, 25, 100, 0, 1) .attr(StatusEffectAttr, StatusEffect.BURN), new AttackMove(Moves.FLAMETHROWER, Type.FIRE, MoveCategory.SPECIAL, 90, 100, 15, 10, 0, 1) .attr(StatusEffectAttr, StatusEffect.BURN), @@ -5592,7 +5641,7 @@ export function initMoves() { new AttackMove(Moves.PRISMATIC_LASER, Type.PSYCHIC, MoveCategory.SPECIAL, 160, 100, 10, -1, 0, 7) .attr(RechargeAttr), new AttackMove(Moves.SPECTRAL_THIEF, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 7) - .partial(), + .attr(StealStatBoostsAttr), new AttackMove(Moves.SUNSTEEL_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 7) .ignoresAbilities() .partial(), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 0989c769f0f..63997b74819 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4,7 +4,7 @@ import { Variant, VariantSet, variantColorCache } from '#app/data/variant'; import { variantData } from '#app/data/variant'; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from '../ui/battle-info'; import { Moves } from "../data/enums/moves"; -import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, VariablePowerAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, MultiHitAttr, StatusMoveTypeImmunityAttr, MoveTarget, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatChangesAttr, SacrificialAttr, VariableMoveTypeAttr, VariableMoveCategoryAttr } from "../data/move"; +import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, FixedDamageAttr, VariableAtkAttr, VariablePowerAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, MultiHitAttr, StatusMoveTypeImmunityAttr, MoveTarget, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatChangesAttr, SacrificialAttr, VariableMoveTypeAttr, VariableMoveCategoryAttr, MoveEffectTrigger } from "../data/move"; import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, starterPassiveAbilities } from '../data/pokemon-species'; import * as Utils from '../utils'; import { Type, TypeDamageMultiplier, getTypeDamageMultiplier, getTypeRgb } from '../data/type'; @@ -1199,7 +1199,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return (this.isPlayer() ? this.scene.getPlayerField() : this.scene.getEnemyField())[this.getFieldIndex() ? 0 : 1]; } - apply(source: Pokemon, battlerMove: PokemonMove): HitResult { + apply(source: Pokemon, battlerMove: PokemonMove, firstHit: boolean): HitResult { let result: HitResult; const move = battlerMove.getMove(); let damage = new Utils.NumberHolder(0); @@ -1298,14 +1298,16 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { isCritical = false; } } + const isTypeImmune = new Utils.BooleanHolder((typeMultiplier.value * arenaAttackTypeMultiplier) === 0); + applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.PRE_DAMAGE && (!attr.firstHitOnly || firstHit), source, this, move, isTypeImmune); + const sourceAtk = new Utils.IntegerHolder(source.getBattleStat(isPhysical ? Stat.ATK : Stat.SPATK, this, null, isCritical)); const targetDef = new Utils.IntegerHolder(this.getBattleStat(isPhysical ? Stat.DEF : Stat.SPDEF, source, move, isCritical)); const criticalMultiplier = isCritical ? 1.5 : 1; const screenMultiplier = new Utils.NumberHolder(1); if (!isCritical) { this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY, move.category, this.scene.currentBattle.double, screenMultiplier); - } - const isTypeImmune = (typeMultiplier.value * arenaAttackTypeMultiplier) === 0; + } const sourceTypes = source.getTypes(); const matchesSourceType = sourceTypes[0] === type || (sourceTypes.length > 1 && sourceTypes[1] === type); let stabMultiplier = new Utils.NumberHolder(1); @@ -1322,7 +1324,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyMoveAttrs(VariableAtkAttr, source, this, move, sourceAtk); applyMoveAttrs(VariableDefAttr, source, this, move, targetDef); - if (!isTypeImmune) { + if (!isTypeImmune.value) { damage.value = Math.ceil(((((2 * source.level / 5 + 2) * power.value * sourceAtk.value / targetDef.value) / 50) + 2) * stabMultiplier.value * typeMultiplier.value * arenaAttackTypeMultiplier * screenMultiplier.value * ((this.scene.randBattleSeedInt(15) + 85) / 100) * criticalMultiplier); if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) { const burnDamageReductionCancelled = new Utils.BooleanHolder(false); @@ -1341,7 +1343,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { const fixedDamage = new Utils.IntegerHolder(0); applyMoveAttrs(FixedDamageAttr, source, this, move, fixedDamage); - if (!isTypeImmune && fixedDamage.value) { + if (!isTypeImmune.value && fixedDamage.value) { damage.value = fixedDamage.value; isCritical = false; result = HitResult.EFFECTIVE; diff --git a/src/phases.ts b/src/phases.ts index deb1c2fb860..698ad6fc48e 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -2425,7 +2425,7 @@ export class MoveEffectPhase extends PokemonPhase { moveHistoryEntry.result = MoveResult.SUCCESS; - const hitResult = !isProtected ? target.apply(user, this.move) : HitResult.NO_EFFECT; + const hitResult = !isProtected ? target.apply(user, this.move, firstHit) : HitResult.NO_EFFECT; this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); @@ -2648,62 +2648,78 @@ export class ShowAbilityPhase extends PokemonPhase { export class StatChangePhase extends PokemonPhase { private stats: BattleStat[]; private selfTarget: boolean; - private levels: integer; + private levels: Utils.IntegerHolder; private showMessage: boolean; private ignoreAbilities: boolean; + private doChangeSynchronously: boolean; - constructor(scene: BattleScene, battlerIndex: BattlerIndex, selfTarget: boolean, stats: BattleStat[], levels: integer, showMessage: boolean = true, ignoreAbilities: boolean = false) { + private relLevels: number[]; + private filteredStats: BattleStat[]; + + /** + * @param doChangeSynchronously save the stat changes before the phase gets queued, only tested for use with Moves.SPECTRAL_THIEF + */ + constructor(scene: BattleScene, battlerIndex: BattlerIndex, selfTarget: boolean, stats: BattleStat[], levels: integer, showMessage: boolean = true, ignoreAbilities: boolean = false, doChangeSynchronously: boolean = false) { super(scene, battlerIndex); this.selfTarget = selfTarget; this.stats = stats; - this.levels = levels; + this.levels = new Utils.IntegerHolder(levels); this.showMessage = showMessage; this.ignoreAbilities = ignoreAbilities; + this.doChangeSynchronously = doChangeSynchronously; + if (doChangeSynchronously) + this.applyChanges() } - start() { + applyChanges() { const pokemon = this.getPokemon(); if (!pokemon.isActive(true)) return this.end(); const allStats = Utils.getEnumValues(BattleStat); - const filteredStats = this.stats.map(s => s !== BattleStat.RAND ? s : allStats[pokemon.randSeedInt(BattleStat.SPD + 1)]).filter(stat => { + this.filteredStats = this.stats.map(s => s !== BattleStat.RAND ? s : allStats[pokemon.randSeedInt(BattleStat.SPD + 1)]).filter(stat => { const cancelled = new Utils.BooleanHolder(false); - if (!this.selfTarget && this.levels < 0) + if (!this.selfTarget && this.levels.value < 0) this.scene.arena.applyTagsForSide(MistTag, pokemon.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY, cancelled); - if (!cancelled.value && !this.selfTarget && this.levels < 0) + if (!cancelled.value && !this.selfTarget && this.levels.value < 0) applyPreStatChangeAbAttrs(ProtectStatAbAttr, this.getPokemon(), stat, cancelled); return !cancelled.value; }); - const levels = new Utils.IntegerHolder(this.levels); - if (!this.ignoreAbilities) - applyAbAttrs(StatChangeMultiplierAbAttr, pokemon, null, levels); + applyAbAttrs(StatChangeMultiplierAbAttr, pokemon, null, this.levels); const battleStats = this.getPokemon().summonData.battleStats; - const relLevels = filteredStats.map(stat => (levels.value >= 1 ? Math.min(battleStats[stat] + levels.value, 6) : Math.max(battleStats[stat] + levels.value, -6)) - battleStats[stat]); + this.relLevels = this.filteredStats.map(stat => (this.levels.value >= 1 ? Math.min(battleStats[stat] + this.levels.value, 6) : Math.max(battleStats[stat] + this.levels.value, -6)) - battleStats[stat]); + + for (let stat of this.filteredStats) + pokemon.summonData.battleStats[stat] = Math.max(Math.min(pokemon.summonData.battleStats[stat] + this.levels.value, 6), -6); + + } + + start() { + const pokemon = this.getPokemon(); + const levels = this.levels + if(!this.doChangeSynchronously) + this.applyChanges(); const end = () => { if (this.showMessage) { - const messages = this.getStatChangeMessages(filteredStats, levels.value, relLevels); + const messages = this.getStatChangeMessages(this.filteredStats, levels.value, this.relLevels); for (let message of messages) this.scene.queueMessage(message); } - for (let stat of filteredStats) - pokemon.summonData.battleStats[stat] = Math.max(Math.min(pokemon.summonData.battleStats[stat] + levels.value, 6), -6); - - applyPostStatChangeAbAttrs(PostStatChangeAbAttr, pokemon, filteredStats, this.levels, this.selfTarget) + applyPostStatChangeAbAttrs(PostStatChangeAbAttr, pokemon, this.filteredStats, levels.value, this.selfTarget) this.end(); }; - if (relLevels.filter(l => l).length && this.scene.moveAnimations) { + if (this.relLevels.filter(l => l).length && this.scene.moveAnimations) { pokemon.enableMask(); const pokemonMaskSprite = pokemon.maskSprite; @@ -2712,7 +2728,7 @@ export class StatChangePhase extends PokemonPhase { const tileWidth = 156 * this.scene.field.scale * pokemon.getSpriteScale(); const tileHeight = 316 * this.scene.field.scale * pokemon.getSpriteScale(); - const statSprite = this.scene.add.tileSprite(tileX, tileY, tileWidth, tileHeight, 'battle_stats', filteredStats.length > 1 ? 'mix' : BattleStat[filteredStats[0]].toLowerCase()); + const statSprite = this.scene.add.tileSprite(tileX, tileY, tileWidth, tileHeight, 'battle_stats', this.filteredStats.length > 1 ? 'mix' : BattleStat[this.filteredStats[0]].toLowerCase()); statSprite.setPipeline(this.scene.fieldSpritePipeline); statSprite.setAlpha(0); statSprite.setScale(6);