Rewrite move phase

Co-authored-by: innerthunder <brandonerickson98@gmail.com>
This commit is contained in:
Sirz Benjie 2025-04-12 14:50:08 -05:00
parent 607dc70b9b
commit f85e008202
No known key found for this signature in database
GPG Key ID: 4A524B4D196C759E
9 changed files with 797 additions and 750 deletions

View File

@ -7,7 +7,7 @@ import { MoveTarget } from "#enums/MoveTarget";
import { MoveCategory } from "#enums/MoveCategory";
import { getPokemonNameWithAffix } from "#app/messages";
import type Pokemon from "#app/field/pokemon";
import { HitResult, PokemonMove } from "#app/field/pokemon";
import { HitResult } from "#app/field/pokemon";
import { StatusEffect } from "#enums/status-effect";
import type { BattlerIndex } from "#app/battle";
import {
@ -335,7 +335,7 @@ export class ConditionalProtectTag extends ArenaTag {
* @param arena the {@linkcode Arena} containing this tag
* @param simulated `true` if the tag is applied quietly; `false` otherwise.
* @param isProtected a {@linkcode BooleanHolder} used to flag if the move is protected against
* @param attacker the attacking {@linkcode Pokemon}
* @param _attacker the attacking {@linkcode Pokemon}
* @param defender the defending {@linkcode Pokemon}
* @param moveId the {@linkcode Moves | identifier} for the move being used
* @param ignoresProtectBypass a {@linkcode BooleanHolder} used to flag if a protection effect supercedes effects that ignore protection
@ -345,7 +345,7 @@ export class ConditionalProtectTag extends ArenaTag {
arena: Arena,
simulated: boolean,
isProtected: BooleanHolder,
attacker: Pokemon,
_attacker: Pokemon,
defender: Pokemon,
moveId: Moves,
ignoresProtectBypass: BooleanHolder,
@ -354,8 +354,6 @@ export class ConditionalProtectTag extends ArenaTag {
if (!isProtected.value) {
isProtected.value = true;
if (!simulated) {
attacker.stopMultiHit(defender);
new CommonBattleAnim(CommonAnim.PROTECT, defender).play();
globalScene.queueMessage(
i18next.t("arenaTag:conditionalProtectApply", {
@ -899,7 +897,7 @@ export class DelayedAttackTag extends ArenaTag {
if (!ret) {
globalScene.unshiftPhase(
new MoveEffectPhase(this.sourceId!, [this.targetIndex], new PokemonMove(this.sourceMove!, 0, 0, true)),
new MoveEffectPhase(this.sourceId!, [this.targetIndex], allMoves[this.sourceMove!], false, true),
); // TODO: are those bangs correct?
}

View File

@ -2637,7 +2637,7 @@ export class GulpMissileTag extends BattlerTag {
return false;
}
if (moveEffectPhase.move.getMove().hitsSubstitute(attacker, pokemon)) {
if (moveEffectPhase.move.hitsSubstitute(attacker, pokemon)) {
return true;
}
@ -2993,7 +2993,7 @@ export class SubstituteTag extends BattlerTag {
if (!attacker) {
return;
}
const move = moveEffectPhase.move.getMove();
const move = moveEffectPhase.move;
const firstHit = attacker.turnData.hitCount === attacker.turnData.hitsLeft;
if (firstHit && move.hitsSubstitute(attacker, pokemon)) {
@ -3681,7 +3681,7 @@ function getMoveEffectPhaseData(_pokemon: Pokemon): { phase: MoveEffectPhase; at
return {
phase: phase,
attacker: phase.getPokemon(),
move: phase.move.getMove(),
move: phase.move,
};
}
return null;

View File

@ -0,0 +1,20 @@
import { MoveTarget } from "#enums/MoveTarget";
import type Move from "./move";
/**
* Return whether the move targets the field
*
* Examples include
* - Hazard moves like spikes
* - Weather moves like rain dance
* - User side moves like reflect and safeguard
*/
export function isFieldTargeted(move: Move): boolean {
switch (move.moveTarget) {
case MoveTarget.BOTH_SIDES:
case MoveTarget.USER_SIDE:
case MoveTarget.ENEMY_SIDE:
return true;
}
return false;
}

View File

@ -60,6 +60,7 @@ import {
MoveTypeChangeAbAttr,
PostDamageForceSwitchAbAttr,
PostItemLostAbAttr,
ReflectStatusMoveAbAttr,
ReverseDrainAbAttr,
UserFieldMoveTypePowerBoostAbAttr,
VariableMovePowerAbAttr,
@ -665,6 +666,17 @@ export default class Move implements Localizable {
return true;
}
break;
case MoveFlags.REFLECTABLE:
// If the target is not semi-invulnerable and either has magic coat active or an unignored magic bounce ability
if (
target?.getTag(SemiInvulnerableTag) &&
(target.getTag(BattlerTagType.MAGIC_COAT) ||
(!this.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) &&
target.hasAbilityWithAttr(ReflectStatusMoveAbAttr)))
) {
return false;
}
break;
}
return !!(this.flags & flag);
@ -1716,7 +1728,7 @@ export class SacrificialAttr extends MoveEffectAttr {
**/
export class SacrificialAttrOnHit extends MoveEffectAttr {
constructor() {
super(true, { trigger: MoveEffectTrigger.HIT });
super(true);
}
/**
@ -1955,6 +1967,14 @@ export class PartyStatusCureAttr extends MoveEffectAttr {
* @extends MoveEffectAttr
*/
export class FlameBurstAttr extends MoveEffectAttr {
constructor() {
/**
* This is self-targeted to bypass immunity to target-facing secondary
* effects when the target has an active Substitute doll.
* TODO: Find a more intuitive way to implement Substitute bypassing.
*/
super(true);
}
/**
* @param user - n/a
* @param target - The target Pokémon.
@ -2177,7 +2197,7 @@ export class HitHealAttr extends MoveEffectAttr {
private healStat: EffectiveStat | null;
constructor(healRatio?: number | null, healStat?: EffectiveStat) {
super(true, { trigger: MoveEffectTrigger.HIT });
super(true);
this.healRatio = healRatio ?? 0.5;
this.healStat = healStat ?? null;
@ -2426,7 +2446,7 @@ export class StatusEffectAttr extends MoveEffectAttr {
public overrideStatus: boolean = false;
constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) {
super(selfTarget, { trigger: MoveEffectTrigger.HIT });
super(selfTarget);
this.effect = effect;
this.turnsRemaining = turnsRemaining;
@ -2434,10 +2454,6 @@ export class StatusEffectAttr extends MoveEffectAttr {
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!this.selfTarget && move.hitsSubstitute(user, target)) {
return false;
}
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance;
if (statusCheck) {
@ -2495,7 +2511,7 @@ export class MultiStatusEffectAttr extends StatusEffectAttr {
export class PsychoShiftEffectAttr extends MoveEffectAttr {
constructor() {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
}
/**
@ -2534,15 +2550,11 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
private chance: number;
constructor(chance: number) {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
this.chance = chance;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (move.hitsSubstitute(user, target)) {
return false;
}
const rand = Phaser.Math.RND.realInRange(0, 1);
if (rand >= this.chance) {
return false;
@ -2590,7 +2602,7 @@ export class RemoveHeldItemAttr extends MoveEffectAttr {
private berriesOnly: boolean;
constructor(berriesOnly: boolean) {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
this.berriesOnly = berriesOnly;
}
@ -2600,17 +2612,13 @@ export class RemoveHeldItemAttr extends MoveEffectAttr {
* @param target Target {@linkcode Pokemon} that the moves applies to
* @param move {@linkcode Move} that is used
* @param args N/A
* @returns {boolean} True if an item was removed
* @returns True if an item was removed
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!this.berriesOnly && target.isPlayer()) { // "Wild Pokemon cannot knock off Player Pokemon's held items" (See Bulbapedia)
return false;
}
if (move.hitsSubstitute(user, target)) {
return false;
}
const cancelled = new BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft
@ -2664,8 +2672,8 @@ export class RemoveHeldItemAttr extends MoveEffectAttr {
*/
export class EatBerryAttr extends MoveEffectAttr {
protected chosenBerry: BerryModifier | undefined;
constructor() {
super(true, { trigger: MoveEffectTrigger.HIT });
constructor(selfTarget: boolean) {
super(selfTarget);
}
/**
* Causes the target to eat a berry.
@ -2680,17 +2688,20 @@ export class EatBerryAttr extends MoveEffectAttr {
return false;
}
const heldBerries = this.getTargetHeldBerries(target);
const pokemon = this.selfTarget ? user : target;
const heldBerries = this.getTargetHeldBerries(pokemon);
if (heldBerries.length <= 0) {
return false;
}
this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)];
const preserve = new BooleanHolder(false);
globalScene.applyModifiers(PreserveBerryModifier, target.isPlayer(), target, preserve); // check for berry pouch preservation
// check for berry pouch preservation
globalScene.applyModifiers(PreserveBerryModifier, pokemon.isPlayer(), pokemon, preserve);
if (!preserve.value) {
this.reduceBerryModifier(target);
this.reduceBerryModifier(pokemon);
}
this.eatBerry(target);
this.eatBerry(pokemon);
return true;
}
@ -2718,7 +2729,7 @@ export class EatBerryAttr extends MoveEffectAttr {
*/
export class StealEatBerryAttr extends EatBerryAttr {
constructor() {
super();
super(true);
}
/**
* User steals a random berry from the target and then eats it.
@ -2729,9 +2740,6 @@ export class StealEatBerryAttr extends EatBerryAttr {
* @returns {boolean} true if the function succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (move.hitsSubstitute(user, target)) {
return false;
}
const cancelled = new BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft
if (cancelled.value === true) {
@ -2782,10 +2790,6 @@ export class HealStatusEffectAttr extends MoveEffectAttr {
return false;
}
if (!this.selfTarget && move.hitsSubstitute(user, target)) {
return false;
}
// Special edge case for shield dust blocking Sparkling Aria curing burn
const moveTargets = getMoveTargets(user, move.id);
if (target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && move.id === Moves.SPARKLING_ARIA && moveTargets.targets.length === 1) {
@ -3162,15 +3166,7 @@ export class StatStageChangeAttr extends MoveEffectAttr {
private get showMessage () {
return this.options?.showMessage ?? true;
}
/**
* Indicates when the stat change should trigger
* @default MoveEffectTrigger.HIT
*/
public override get trigger () {
return this.options?.trigger ?? MoveEffectTrigger.HIT;
}
/**
* Attempts to change stats of the user or target (depending on value of selfTarget) if conditions are met
* @param user {@linkcode Pokemon} the user of the move
@ -3184,10 +3180,6 @@ export class StatStageChangeAttr extends MoveEffectAttr {
return false;
}
if (!this.selfTarget && move.hitsSubstitute(user, target)) {
return false;
}
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) {
const stages = this.getLevels(user);
@ -3471,7 +3463,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr {
*/
export class OrderUpStatBoostAttr extends MoveEffectAttr {
constructor() {
super(true, { trigger: MoveEffectTrigger.HIT });
super(true);
}
override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean {
@ -3548,17 +3540,15 @@ export class ResetStatsAttr extends MoveEffectAttr {
this.targetAllPokemon = targetAllPokemon;
}
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
override apply(_user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
if (this.targetAllPokemon) {
// Target all pokemon on the field when Freezy Frost or Haze are used
const activePokemon = globalScene.getField(true);
activePokemon.forEach((p) => this.resetStats(p));
globalScene.queueMessage(i18next.t("moveTriggers:statEliminated"));
} else { // Affects only the single target when Clear Smog is used
if (!move.hitsSubstitute(user, target)) {
this.resetStats(target);
globalScene.queueMessage(i18next.t("moveTriggers:resetStats", { pokemonName: getPokemonNameWithAffix(target) }));
}
this.resetStats(target);
globalScene.queueMessage(i18next.t("moveTriggers:resetStats", { pokemonName: getPokemonNameWithAffix(target) }));
}
return true;
}
@ -4217,7 +4207,8 @@ export class PresentPowerAttr extends VariablePowerAttr {
(args[0] as NumberHolder).value = 120;
} else if (80 < powerSeed && powerSeed <= 100) {
// If this move is multi-hit, disable all other hits
user.stopMultiHit();
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
globalScene.unshiftPhase(new PokemonHealPhase(target.getBattlerIndex(),
toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) }), true));
}
@ -5371,7 +5362,7 @@ export class BypassRedirectAttr extends MoveAttr {
export class FrenzyAttr extends MoveEffectAttr {
constructor() {
super(true, { trigger: MoveEffectTrigger.HIT, lastHitOnly: true });
super(true, { lastHitOnly: true });
}
canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]) {
@ -5443,22 +5434,20 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
protected cancelOnFail: boolean;
private failOnOverlap: boolean;
constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false, cancelOnFail: boolean = false) {
constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false) {
super(selfTarget, { lastHitOnly: lastHitOnly });
this.tagType = tagType;
this.turnCountMin = turnCountMin;
this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin;
this.failOnOverlap = !!failOnOverlap;
this.cancelOnFail = cancelOnFail;
}
canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.canApply(user, target, move, args) || (this.cancelOnFail === true && user.getLastXMoves(1)[0]?.result === MoveResult.FAIL)) {
if (!super.canApply(user, target, move, args)) {
return false;
} else {
return true;
}
return true;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
@ -5549,19 +5538,6 @@ export class LeechSeedAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.SEEDED);
}
/**
* Adds a Seeding effect to the target if the target does not have an active Substitute.
* @param user the {@linkcode Pokemon} using the move
* @param target the {@linkcode Pokemon} targeted by the move
* @param move the {@linkcode Move} invoking this effect
* @param args n/a
* @returns `true` if the effect successfully applies; `false` otherwise
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
return !move.hitsSubstitute(user, target)
&& super.apply(user, target, move, args);
}
}
/**
@ -5737,13 +5713,6 @@ export class FlinchAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.FLINCHED, false);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!move.hitsSubstitute(user, target)) {
return super.apply(user, target, move, args);
}
return false;
}
}
export class ConfuseAttr extends AddBattlerTagAttr {
@ -5759,16 +5728,13 @@ export class ConfuseAttr extends AddBattlerTagAttr {
return false;
}
if (!move.hitsSubstitute(user, target)) {
return super.apply(user, target, move, args);
}
return false;
return super.apply(user, target, move, args);
}
}
export class RechargeAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.RECHARGING, true, false, 1, 1, true, true);
super(BattlerTagType.RECHARGING, true, false, 1, 1, true);
}
}
@ -6151,7 +6117,7 @@ export class AddPledgeEffectAttr extends AddArenaTagAttr {
* @see {@linkcode apply}
*/
export class RevivalBlessingAttr extends MoveEffectAttr {
constructor(user?: boolean) {
constructor() {
super(true);
}
@ -6392,10 +6358,6 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
const player = switchOutTarget instanceof PlayerPokemon;
if (!this.selfSwitch) {
if (move.hitsSubstitute(user, target)) {
return false;
}
// Dondozo with an allied Tatsugiri in its mouth cannot be forced out
const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED);
if (commandedTag?.getSourcePokemon()?.isActive(true)) {
@ -6650,7 +6612,7 @@ export class ChangeTypeAttr extends MoveEffectAttr {
private type: PokemonType;
constructor(type: PokemonType) {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
this.type = type;
}
@ -6673,7 +6635,7 @@ export class AddTypeAttr extends MoveEffectAttr {
private type: PokemonType;
constructor(type: PokemonType) {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
this.type = type;
}
@ -7369,7 +7331,7 @@ export class AbilityChangeAttr extends MoveEffectAttr {
public ability: Abilities;
constructor(ability: Abilities, selfTarget?: boolean) {
super(selfTarget, { trigger: MoveEffectTrigger.HIT });
super(selfTarget);
this.ability = ability;
}
@ -7400,7 +7362,7 @@ export class AbilityCopyAttr extends MoveEffectAttr {
public copyToPartner: boolean;
constructor(copyToPartner: boolean = false) {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
this.copyToPartner = copyToPartner;
}
@ -7441,7 +7403,7 @@ export class AbilityGiveAttr extends MoveEffectAttr {
public copyToPartner: boolean;
constructor() {
super(false, { trigger: MoveEffectTrigger.HIT });
super(false);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
@ -7720,7 +7682,7 @@ export class DiscourageFrequentUseAttr extends MoveAttr {
export class MoneyAttr extends MoveEffectAttr {
constructor() {
super(true, { trigger: MoveEffectTrigger.HIT, firstHitOnly: true });
super(true, {firstHitOnly: true });
}
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
@ -7787,7 +7749,7 @@ export class StatusIfBoostedAttr extends MoveEffectAttr {
public effect: StatusEffect;
constructor(effect: StatusEffect) {
super(true, { trigger: MoveEffectTrigger.HIT });
super(true);
this.effect = effect;
}
@ -10566,7 +10528,7 @@ export function initMoves() {
.attr(JawLockAttr)
.bitingMove(),
new SelfStatusMove(Moves.STUFF_CHEEKS, PokemonType.NORMAL, -1, 10, -1, 0, 8)
.attr(EatBerryAttr)
.attr(EatBerryAttr, true)
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true)
.condition((user) => {
const userBerries = globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer());
@ -10590,7 +10552,7 @@ export function initMoves() {
.makesContact(false)
.partial(), // smart targetting is unimplemented
new StatusMove(Moves.TEATIME, PokemonType.NORMAL, -1, 10, -1, 0, 8)
.attr(EatBerryAttr)
.attr(EatBerryAttr, false)
.target(MoveTarget.ALL),
new StatusMove(Moves.OCTOLOCK, PokemonType.FIGHTING, 100, 15, -1, 0, 8)
.condition(failIfGhostTypeCondition)

View File

@ -1,4 +1,4 @@
/** Represent the result of a hit check against a target. */
/** The result of a hit check calculation */
export const HitCheckResult = {
/** Hit checks haven't been evaluated yet in this pass */
PENDING: 0,
@ -6,14 +6,18 @@ export const HitCheckResult = {
HIT: 1,
/** The move has no effect on the target */
NO_EFFECT: 2,
/** The move has no effect on the target, but doesn't proc the default "no effect" message. */
/** The move has no effect on the target, but doesn't proc the default "no effect" message */
NO_EFFECT_NO_MESSAGE: 3,
/** The target protected itself against the move */
PROTECTED: 4,
/** The move missed the target */
MISS: 5,
/** The move is reflected by magic coat or magic bounce */
REFLECTED: 6,
/** The target is no longer on the field */
TARGET_NOT_ON_FIELD: 7,
/** The move failed unexpectedly */
ERROR: 6,
ERROR: 8,
} as const;
export type HitCheckResult = typeof HitCheckResult[keyof typeof HitCheckResult];

View File

@ -4600,212 +4600,36 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
};
}
/**
* Applies the results of a move to this pokemon
* @param source The {@linkcode Pokemon} using the move
* @param move The {@linkcode Move} being used
* @returns The {@linkcode HitResult} of the attack
*/
apply(source: Pokemon, move: Move): HitResult {
const defendingSide = this.isPlayer()
? ArenaTagSide.PLAYER
: ArenaTagSide.ENEMY;
const moveCategory = new NumberHolder(move.category);
applyMoveAttrs(VariableMoveCategoryAttr, source, this, move, moveCategory);
if (moveCategory.value === MoveCategory.STATUS) {
const cancelled = new BooleanHolder(false);
const typeMultiplier = this.getMoveEffectiveness(
source,
move,
false,
false,
cancelled,
);
if (!cancelled.value && typeMultiplier === 0) {
globalScene.queueMessage(
i18next.t("battle:hitResultNoEffect", {
pokemonName: getPokemonNameWithAffix(this),
}),
);
}
return typeMultiplier === 0 ? HitResult.NO_EFFECT : HitResult.STATUS;
/** Calculate whether the given move critically hits this pokemon
* @param source - The {@linkcode Pokemon} using the move
* @param move - The {@linkcode Move} being used
* @param simulated - If `true`, suppresses changes to game state during calculation (defaults to `true`)
* @returns whether the move critically hits the pokemon
*/
getCriticalHitResult(source: Pokemon, move: Move, simulated: boolean = true): boolean {
const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
const noCritTag = globalScene.arena.getTagOnSide(NoCritTag, defendingSide);
if (noCritTag || Overrides.NEVER_CRIT_OVERRIDE || move.hasAttr(FixedDamageAttr)) {
return false;
}
/** Determines whether the attack critically hits */
let isCritical: boolean;
const critOnly = new BooleanHolder(false);
const critAlways = source.getTag(BattlerTagType.ALWAYS_CRIT);
applyMoveAttrs(CritOnlyAttr, source, this, move, critOnly);
applyAbAttrs(
ConditionalCritAbAttr,
source,
null,
false,
critOnly,
this,
move,
);
if (critOnly.value || critAlways) {
isCritical = true;
} else {
const isCritical = new BooleanHolder(false);
if (source.getTag(BattlerTagType.ALWAYS_CRIT)) {
isCritical.value = true;
}
applyMoveAttrs(CritOnlyAttr, source, this, move, isCritical);
applyAbAttrs(ConditionalCritAbAttr, source, null, simulated, isCritical, this, move);
if (!isCritical.value) {
const critChance = [24, 8, 2, 1][
Math.max(0, Math.min(this.getCritStage(source, move), 3))
];
isCritical =
critChance === 1 || !globalScene.randBattleSeedInt(critChance);
isCritical.value = critChance === 1 || !globalScene.randBattleSeedInt(critChance);
}
const noCritTag = globalScene.arena.getTagOnSide(NoCritTag, defendingSide);
const blockCrit = new BooleanHolder(false);
applyAbAttrs(BlockCritAbAttr, this, null, false, blockCrit);
if (noCritTag || blockCrit.value || Overrides.NEVER_CRIT_OVERRIDE) {
isCritical = false;
}
applyAbAttrs(BlockCritAbAttr, this, null, simulated, isCritical);
/**
* Applies stat changes from {@linkcode move} and gives it to {@linkcode source}
* before damage calculation
*/
applyMoveAttrs(StatChangeBeforeDmgCalcAttr, source, this, move);
const {
cancelled,
result,
damage: dmg,
} = this.getAttackDamage(
{source, move, isCritical, simulated: false});
const typeBoost = source.findTag(
t =>
t instanceof TypeBoostTag && t.boostedType === source.getMoveType(move),
) as TypeBoostTag;
if (typeBoost?.oneUse) {
source.removeTag(typeBoost.tagType);
}
if (
cancelled ||
result === HitResult.IMMUNE ||
result === HitResult.NO_EFFECT
) {
source.stopMultiHit(this);
if (!cancelled) {
if (result === HitResult.IMMUNE) {
globalScene.queueMessage(
i18next.t("battle:hitResultImmune", {
pokemonName: getPokemonNameWithAffix(this),
}),
);
} else {
globalScene.queueMessage(
i18next.t("battle:hitResultNoEffect", {
pokemonName: getPokemonNameWithAffix(this),
}),
);
}
}
return result;
}
// In case of fatal damage, this tag would have gotten cleared before we could lapse it.
const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND);
const grudgeTag = this.getTag(BattlerTagType.GRUDGE);
if (dmg) {
this.lapseTags(BattlerTagLapseType.HIT);
const substitute = this.getTag(SubstituteTag);
const isBlockedBySubstitute =
!!substitute && move.hitsSubstitute(source, this);
if (isBlockedBySubstitute) {
substitute.hp -= dmg;
}
if (!this.isPlayer() && dmg >= this.hp) {
globalScene.applyModifiers(EnemyEndureChanceModifier, false, this);
}
/**
* We explicitly require to ignore the faint phase here, as we want to show the messages
* about the critical hit and the super effective/not very effective messages before the faint phase.
*/
const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg,
{
result: result as DamageResult,
isCritical,
ignoreFaintPhase: true,
source
});
if (damage > 0) {
if (source.isPlayer()) {
globalScene.validateAchvs(DamageAchv, new NumberHolder(damage));
if (damage > globalScene.gameData.gameStats.highestDamage) {
globalScene.gameData.gameStats.highestDamage = damage;
}
}
source.turnData.totalDamageDealt += damage;
source.turnData.singleHitDamageDealt = damage;
this.turnData.damageTaken += damage;
this.battleData.hitCount++;
const attackResult = {
move: move.id,
result: result as DamageResult,
damage: damage,
critical: isCritical,
sourceId: source.id,
sourceBattlerIndex: source.getBattlerIndex(),
};
this.turnData.attacksReceived.unshift(attackResult);
if (source.isPlayer() && !this.isPlayer()) {
globalScene.applyModifiers(
DamageMoneyRewardModifier,
true,
source,
new NumberHolder(damage),
);
}
}
}
if (isCritical) {
globalScene.queueMessage(i18next.t("battle:hitResultCriticalHit"));
}
// want to include is.Fainted() in case multi hit move ends early, still want to render message
if (source.turnData.hitsLeft === 1 || this.isFainted()) {
switch (result) {
case HitResult.SUPER_EFFECTIVE:
globalScene.queueMessage(i18next.t("battle:hitResultSuperEffective"));
break;
case HitResult.NOT_VERY_EFFECTIVE:
globalScene.queueMessage(
i18next.t("battle:hitResultNotVeryEffective"),
);
break;
case HitResult.ONE_HIT_KO:
globalScene.queueMessage(i18next.t("battle:hitResultOneHitKO"));
break;
}
}
if (this.isFainted()) {
// set splice index here, so future scene queues happen before FaintedPhase
globalScene.setPhaseQueueSplice();
globalScene.unshiftPhase(
new FaintPhase(
this.getBattlerIndex(),
false,
source,
),
);
this.destroySubstitute();
this.lapseTag(BattlerTagType.COMMANDED);
}
return result;
return isCritical.value;
}
/**
@ -4869,7 +4693,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Called by apply(), given the damage, adds a new DamagePhase and actually updates HP values, etc.
* Given the damage, adds a new DamagePhase and update HP values, etc.
*
* Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly
* @param damage integer - passed to damage()
* @param result an enum if it's super effective, not very, etc.
@ -5172,8 +4997,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Gets whether the given move is currently disabled for this Pokemon.
*
* @param {Moves} moveId {@linkcode Moves} ID of the move to check
* @returns {boolean} `true` if the move is disabled for this Pokemon, otherwise `false`
* @param moveId - The {@linkcode Moves} ID of the move to check
* @returns `true` if the move is disabled for this Pokemon, otherwise `false`
*
* @see {@linkcode MoveRestrictionBattlerTag}
*/
@ -5184,9 +5009,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Gets whether the given move is currently disabled for the user based on the player's target selection
*
* @param {Moves} moveId {@linkcode Moves} ID of the move to check
* @param {Pokemon} user {@linkcode Pokemon} the move user
* @param {Pokemon} target {@linkcode Pokemon} the target of the move
* @param moveId - The {@linkcode Moves} ID of the move to check
* @param user - The move user
* @param target - The target of the move
*
* @returns {boolean} `true` if the move is disabled for this Pokemon due to the player's target selection
*
@ -5216,10 +5041,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Gets the {@link MoveRestrictionBattlerTag} that is restricting a move, if it exists.
*
* @param {Moves} moveId {@linkcode Moves} ID of the move to check
* @param {Pokemon} user {@linkcode Pokemon} the move user, optional and used when the target is a factor in the move's restricted status
* @param {Pokemon} target {@linkcode Pokemon} the target of the move, optional and used when the target is a factor in the move's restricted status
* @returns {MoveRestrictionBattlerTag | null} the first tag on this Pokemon that restricts the move, or `null` if the move is not restricted.
* @param moveId - {@linkcode Moves} ID of the move to check
* @param user - {@linkcode Pokemon} the move user, optional and used when the target is a factor in the move's restricted status
* @param target - {@linkcode Pokemon} the target of the move, optional and used when the target is a factor in the move's restricted status
* @returns The first tag on this Pokemon that restricts the move, or `null` if the move is not restricted.
*/
getRestrictingTag(
moveId: Moves,
@ -5281,20 +5106,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return this.summonData.moveQueue;
}
/**
* If this Pokemon is using a multi-hit move, cancels all subsequent strikes
* @param {Pokemon} target If specified, this only cancels subsequent strikes against the given target
*/
stopMultiHit(target?: Pokemon): void {
const effectPhase = globalScene.getCurrentPhase();
if (
effectPhase instanceof MoveEffectPhase &&
effectPhase.getUserPokemon() === this
) {
effectPhase.stopMultiHit(target);
}
}
changeForm(formChange: SpeciesFormChange): Promise<void> {
return new Promise(resolve => {
this.formIndex = Math.max(
@ -5712,7 +5523,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* cancel the attack's subsequent hits.
*/
if (effect === StatusEffect.SLEEP || effect === StatusEffect.FREEZE) {
this.stopMultiHit();
const currentPhase = globalScene.getCurrentPhase();
if (currentPhase instanceof MoveEffectPhase && currentPhase.getUserPokemon() === this) {
this.turnData.hitCount = 1;
this.turnData.hitsLeft = 1;
}
}
if (asPhase) {

File diff suppressed because it is too large Load Diff

View File

@ -404,9 +404,10 @@ export class MovePhase extends BattlePhase {
* if the move fails.
*/
if (success) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
const move = this.move.getMove();
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, move);
globalScene.unshiftPhase(
new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move, this.reflected),
new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, move, this.reflected, this.move.virtual),
);
} else {
if ([Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE].includes(this.move.moveId)) {

View File

@ -50,7 +50,7 @@ describe("Moves - Dynamax Cannon", () => {
game.move.select(dynamaxCannon.id);
await game.phaseInterceptor.to(MoveEffectPhase, false);
expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(dynamaxCannon.id);
expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(dynamaxCannon.id);
await game.phaseInterceptor.to(DamageAnimPhase, false);
expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(100);
}, 20000);
@ -62,7 +62,7 @@ describe("Moves - Dynamax Cannon", () => {
game.move.select(dynamaxCannon.id);
await game.phaseInterceptor.to(MoveEffectPhase, false);
expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(dynamaxCannon.id);
expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(dynamaxCannon.id);
await game.phaseInterceptor.to(DamageAnimPhase, false);
expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(100);
}, 20000);
@ -75,7 +75,7 @@ describe("Moves - Dynamax Cannon", () => {
await game.phaseInterceptor.to(MoveEffectPhase, false);
const phase = game.scene.getCurrentPhase() as MoveEffectPhase;
expect(phase.move.moveId).toBe(dynamaxCannon.id);
expect(phase.move.id).toBe(dynamaxCannon.id);
// Force level cap to be 100
vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100);
await game.phaseInterceptor.to(DamageAnimPhase, false);
@ -90,7 +90,7 @@ describe("Moves - Dynamax Cannon", () => {
await game.phaseInterceptor.to(MoveEffectPhase, false);
const phase = game.scene.getCurrentPhase() as MoveEffectPhase;
expect(phase.move.moveId).toBe(dynamaxCannon.id);
expect(phase.move.id).toBe(dynamaxCannon.id);
// Force level cap to be 100
vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100);
await game.phaseInterceptor.to(DamageAnimPhase, false);
@ -105,7 +105,7 @@ describe("Moves - Dynamax Cannon", () => {
await game.phaseInterceptor.to(MoveEffectPhase, false);
const phase = game.scene.getCurrentPhase() as MoveEffectPhase;
expect(phase.move.moveId).toBe(dynamaxCannon.id);
expect(phase.move.id).toBe(dynamaxCannon.id);
// Force level cap to be 100
vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100);
await game.phaseInterceptor.to(DamageAnimPhase, false);
@ -120,7 +120,7 @@ describe("Moves - Dynamax Cannon", () => {
await game.phaseInterceptor.to(MoveEffectPhase, false);
const phase = game.scene.getCurrentPhase() as MoveEffectPhase;
expect(phase.move.moveId).toBe(dynamaxCannon.id);
expect(phase.move.id).toBe(dynamaxCannon.id);
// Force level cap to be 100
vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100);
await game.phaseInterceptor.to(DamageAnimPhase, false);
@ -135,7 +135,7 @@ describe("Moves - Dynamax Cannon", () => {
await game.phaseInterceptor.to(MoveEffectPhase, false);
const phase = game.scene.getCurrentPhase() as MoveEffectPhase;
expect(phase.move.moveId).toBe(dynamaxCannon.id);
expect(phase.move.id).toBe(dynamaxCannon.id);
// Force level cap to be 100
vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100);
await game.phaseInterceptor.to(DamageAnimPhase, false);
@ -150,7 +150,7 @@ describe("Moves - Dynamax Cannon", () => {
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase, false);
expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(dynamaxCannon.id);
expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(dynamaxCannon.id);
await game.phaseInterceptor.to(DamageAnimPhase, false);
expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(200);
}, 20000);