diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 65af0fb6ee8..579d068e882 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2311,6 +2311,11 @@ export class AutotomizedTag extends BattlerTag { } } +/** + * Tag implementing the {@link https://bulbapedia.bulbagarden.net/wiki/Substitute_(doll)#Effect | Substitute Doll} effect, + * for use with the moves Substitute and Shed Tail. Pokemon with this tag deflect most forms of received attack damage + * onto the tag. This tag also grants immunity to most Status moves and several move effects. + */ export class SubstituteTag extends BattlerTag { /** The substitute's remaining HP. If HP is depleted, the Substitute fades. */ public hp: number; @@ -2330,7 +2335,11 @@ export class SubstituteTag extends BattlerTag { // Queue battle animation and message pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.SUBSTITUTE_ADD); - pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), 1500); + if (this.sourceMove === Moves.SHED_TAIL) { + pokemon.scene.queueMessage(i18next.t("battlerTags:shedTailOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), 1500); + } else { + pokemon.scene.queueMessage(i18next.t("battlerTags:substituteOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), 1500); + } // Remove any binding effects from the user pokemon.findAndRemoveTags(tag => tag instanceof DamagingTrapTag); diff --git a/src/data/move.ts b/src/data/move.ts index 27c27696650..71d97e4fb5c 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -37,6 +37,7 @@ import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "./pokemon-forms"; import { GameMode } from "#app/game-mode"; import { applyChallenges, ChallengeType } from "./challenge"; +import { SwitchType } from "#enums/switch-type"; export enum MoveCategory { PHYSICAL, @@ -1476,8 +1477,13 @@ export class HalfSacrificialAttr extends MoveEffectAttr { * @see {@linkcode apply} */ export class AddSubstituteAttr extends MoveEffectAttr { - constructor() { + /** The ratio of the user's max HP that is required to apply this effect */ + private hpCost: number; + + constructor(hpCost: number = 0.25) { super(true); + + this.hpCost = hpCost; } /** @@ -1493,8 +1499,7 @@ export class AddSubstituteAttr extends MoveEffectAttr { return false; } - const hpCost = Math.floor(user.getMaxHp() / 4); - user.damageAndUpdate(hpCost, HitResult.OTHER, false, true, true); + user.damageAndUpdate(Math.floor(user.getMaxHp() * this.hpCost), HitResult.OTHER, false, true, true); user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id); return true; } @@ -1507,7 +1512,7 @@ export class AddSubstituteAttr extends MoveEffectAttr { } getCondition(): MoveConditionFunc { - return (user, target, move) => !user.getTag(SubstituteTag) && user.hp > Math.floor(user.getMaxHp() / 4) && user.getMaxHp() > 1; + return (user, target, move) => !user.getTag(SubstituteTag) && user.hp > Math.floor(user.getMaxHp() * this.hpCost) && user.getMaxHp() > 1; } getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { @@ -5151,9 +5156,9 @@ export class RevivalBlessingAttr extends MoveEffectAttr { if (user.scene.currentBattle.double && user.scene.getEnemyParty().length > 1) { const allyPokemon = user.getAlly(); if (slotIndex<=1) { - user.scene.unshiftPhase(new SwitchSummonPhase(user.scene, pokemon.getFieldIndex(), slotIndex, false, false, false)); + user.scene.unshiftPhase(new SwitchSummonPhase(user.scene, SwitchType.SWITCH, pokemon.getFieldIndex(), slotIndex, false, false)); } else if (allyPokemon.isFainted()) { - user.scene.unshiftPhase(new SwitchSummonPhase(user.scene, allyPokemon.getFieldIndex(), slotIndex, false, false, false)); + user.scene.unshiftPhase(new SwitchSummonPhase(user.scene, SwitchType.SWITCH, allyPokemon.getFieldIndex(), slotIndex, false, false)); } } resolve(true); @@ -5176,72 +5181,69 @@ export class RevivalBlessingAttr extends MoveEffectAttr { export class ForceSwitchOutAttr extends MoveEffectAttr { constructor( private selfSwitch: boolean = false, - private batonPass: boolean = false + private switchType: SwitchType = SwitchType.SWITCH ) { super(false, MoveEffectTrigger.POST_APPLY, false, true); } isBatonPass() { - return this.batonPass; + return this.switchType === SwitchType.BATON_PASS; } - // TODO: Why is this a Promise? - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { - return new Promise(resolve => { + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + // Check if the move category is not STATUS or if the switch out condition is not met + if (!this.getSwitchOutCondition()(user, target, move)) { + return false; + } - if (!this.getSwitchOutCondition()(user, target, move)) { - return resolve(false); + /** + * Move the switch out logic inside the conditional block + * This ensures that the switch out only happens when the conditions are met + */ + const switchOutTarget = this.selfSwitch ? user : target; + if (switchOutTarget instanceof PlayerPokemon) { + switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); + + if (switchOutTarget.hp > 0) { + user.scene.prependToPhase(new SwitchPhase(user.scene, this.switchType, switchOutTarget.getFieldIndex(), true, true), MoveEndPhase); + return true; + } + return false; + } else if (user.scene.currentBattle.battleType !== BattleType.WILD) { + // Switch out logic for trainer battles + switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); + + if (switchOutTarget.hp > 0) { + // for opponent switching out + user.scene.prependToPhase(new SwitchSummonPhase(user.scene, this.switchType, switchOutTarget.getFieldIndex(), + (user.scene.currentBattle.trainer ? user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), + false, false), MoveEndPhase); + } + } else { + // Switch out logic for everything else (eg: WILD battles) + switchOutTarget.leaveField(false); + + if (switchOutTarget.hp) { + user.scene.queueMessage(i18next.t("moveTriggers:fled", {pokemonName: getPokemonNameWithAffix(switchOutTarget)}), null, true, 500); + + // in double battles redirect potential moves off fled pokemon + if (switchOutTarget.scene.currentBattle.double) { + const allyPokemon = switchOutTarget.getAlly(); + switchOutTarget.scene.redirectPokemonMoves(switchOutTarget, allyPokemon); + } } - // Move the switch out logic inside the conditional block - // This ensures that the switch out only happens when the conditions are met - const switchOutTarget = this.selfSwitch ? user : target; - if (switchOutTarget instanceof PlayerPokemon) { - switchOutTarget.leaveField(!this.batonPass); - - if (switchOutTarget.hp > 0) { - user.scene.prependToPhase(new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), true, true), MoveEndPhase); - resolve(true); - } else { - resolve(false); - } - return; - } else if (user.scene.currentBattle.battleType !== BattleType.WILD) { - // Switch out logic for trainer battles - switchOutTarget.leaveField(!this.batonPass); - - if (switchOutTarget.hp > 0) { - // for opponent switching out - user.scene.prependToPhase(new SwitchSummonPhase(user.scene, switchOutTarget.getFieldIndex(), - (user.scene.currentBattle.trainer ? user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), - false, this.batonPass, false), MoveEndPhase); - } - } else { - // Switch out logic for everything else (eg: WILD battles) - switchOutTarget.leaveField(false); + if (!switchOutTarget.getAlly()?.isActive(true)) { + user.scene.clearEnemyHeldItemModifiers(); if (switchOutTarget.hp) { - user.scene.queueMessage(i18next.t("moveTriggers:fled", {pokemonName: getPokemonNameWithAffix(switchOutTarget)}), null, true, 500); - - // in double battles redirect potential moves off fled pokemon - if (switchOutTarget.scene.currentBattle.double) { - const allyPokemon = switchOutTarget.getAlly(); - switchOutTarget.scene.redirectPokemonMoves(switchOutTarget, allyPokemon); - } - } - - if (!switchOutTarget.getAlly()?.isActive(true)) { - user.scene.clearEnemyHeldItemModifiers(); - - if (switchOutTarget.hp) { - user.scene.pushPhase(new BattleEndPhase(user.scene)); - user.scene.pushPhase(new NewBattlePhase(user.scene)); - } + user.scene.pushPhase(new BattleEndPhase(user.scene)); + user.scene.pushPhase(new NewBattlePhase(user.scene)); } } + } - resolve(true); - }); + return true; } getCondition(): MoveConditionFunc { @@ -5270,7 +5272,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } if (!player && user.scene.currentBattle.battleType === BattleType.WILD) { - if (this.batonPass) { + if (this.isBatonPass()) { return false; } // Don't allow wild opponents to flee on the boss stage since it can ruin a run early on @@ -5291,7 +5293,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return -20; } let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move); - if (this.selfSwitch && this.batonPass) { + if (this.selfSwitch && this.isBatonPass()) { const statStageTotal = user.getStatStages().reduce((s: integer, total: integer) => total += s, 0); ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10)); } @@ -5301,10 +5303,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { export class ChillyReceptionAttr extends ForceSwitchOutAttr { - - // using inherited constructor - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { user.scene.arena.trySetWeather(WeatherType.SNOW, true); return super.apply(user, target, move, args); } @@ -7394,7 +7393,7 @@ export function initMoves() { new AttackMove(Moves.DRAGON_BREATH, Type.DRAGON, MoveCategory.SPECIAL, 60, 100, 20, 30, 0, 2) .attr(StatusEffectAttr, StatusEffect.PARALYSIS), new SelfStatusMove(Moves.BATON_PASS, Type.NORMAL, -1, 40, -1, 0, 2) - .attr(ForceSwitchOutAttr, true, true) + .attr(ForceSwitchOutAttr, true, SwitchType.BATON_PASS) .hidesUser(), new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) @@ -7820,7 +7819,7 @@ export function initMoves() { .makesContact(false) .target(MoveTarget.ATTACKER), new AttackMove(Moves.U_TURN, Type.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) - .attr(ForceSwitchOutAttr, true, false), + .attr(ForceSwitchOutAttr, true), new AttackMove(Moves.CLOSE_COMBAT, Type.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4) .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true), new AttackMove(Moves.PAYBACK, Type.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4) @@ -8253,7 +8252,7 @@ export function initMoves() { new AttackMove(Moves.GRASS_PLEDGE, Type.GRASS, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 5) .partial(), new AttackMove(Moves.VOLT_SWITCH, Type.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 20, -1, 0, 5) - .attr(ForceSwitchOutAttr, true, false), + .attr(ForceSwitchOutAttr, true), new AttackMove(Moves.STRUGGLE_BUG, Type.BUG, MoveCategory.SPECIAL, 50, 100, 20, 100, 0, 5) .attr(StatStageChangeAttr, [ Stat.SPATK ], -1) .target(MoveTarget.ALL_NEAR_ENEMIES), @@ -8421,7 +8420,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(Moves.PARTING_SHOT, Type.DARK, 100, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, null, true, true, MoveEffectTrigger.PRE_APPLY) - .attr(ForceSwitchOutAttr, true, false) + .attr(ForceSwitchOutAttr, true) .soundBased(), new StatusMove(Moves.TOPSY_TURVY, Type.DARK, -1, 20, -1, 0, 6) .attr(InvertStatsAttr), @@ -9176,7 +9175,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], 1) .target(MoveTarget.NEAR_ALLY), new AttackMove(Moves.FLIP_TURN, Type.WATER, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 8) - .attr(ForceSwitchOutAttr, true, false), + .attr(ForceSwitchOutAttr, true), new AttackMove(Moves.TRIPLE_AXEL, Type.ICE, MoveCategory.PHYSICAL, 20, 90, 10, -1, 0, 8) .attr(MultiHitAttr, MultiHitType._3) .attr(MultiHitPowerIncrementAttr, 3) @@ -9503,10 +9502,11 @@ export function initMoves() { .attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 5461/4096 : 1) .makesContact(), new SelfStatusMove(Moves.SHED_TAIL, Type.NORMAL, -1, 10, -1, 0, 9) - .unimplemented(), + .attr(AddSubstituteAttr, 0.5) + .attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL), new SelfStatusMove(Moves.CHILLY_RECEPTION, Type.ICE, -1, 10, -1, 0, 9) .attr(PreMoveMessageAttr, (user, move) => i18next.t("moveTriggers:chillyReception", {pokemonName: getPokemonNameWithAffix(user)})) - .attr(ChillyReceptionAttr, true, false), + .attr(ChillyReceptionAttr, true), new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true, null, true, true) .attr(RemoveArenaTrapAttr, true) diff --git a/src/enums/switch-type.ts b/src/enums/switch-type.ts new file mode 100644 index 00000000000..b25ba6ad119 --- /dev/null +++ b/src/enums/switch-type.ts @@ -0,0 +1,12 @@ +/** + * Indicates the type of switch functionality that a {@linkcode SwitchPhase} + * or {@linkcode SwitchSummonPhase} will carry out. + */ +export enum SwitchType { + /** Basic switchout where the Pokemon to switch in is selected */ + SWITCH, + /** Transfers stat stages and other effects from the returning Pokemon to the switched in Pokemon */ + BATON_PASS, + /** Transfers the returning Pokemon's Substitute to the switched in Pokemon */ + SHED_TAIL +} diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 14f93809414..21deb3790e1 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -61,6 +61,7 @@ import { Challenges } from "#enums/challenges"; import { PokemonAnimType } from "#app/enums/pokemon-anim-type"; import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; +import { SwitchType } from "#enums/switch-type"; export enum FieldPosition { CENTER, @@ -4003,16 +4004,17 @@ export class PlayerPokemon extends Pokemon { /** * Causes this mon to leave the field (via {@linkcode leaveField}) and then * opens the party switcher UI to switch a new mon in - * @param batonPass Indicates if this switch was caused by a baton pass (and - * thus should maintain active mon effects) + * @param switchType the {@linkcode SwitchType} for this switch-out. If this is + * `BATON_PASS` or `SHED_TAIL`, this Pokemon's effects are not cleared upon leaving + * the field. */ - switchOut(batonPass: boolean): Promise { + switchOut(switchType: SwitchType = SwitchType.SWITCH): Promise { return new Promise(resolve => { - this.leaveField(!batonPass); + this.leaveField(switchType === SwitchType.SWITCH); this.scene.ui.setMode(Mode.PARTY, PartyUiMode.FAINT_SWITCH, this.getFieldIndex(), (slotIndex: integer, option: PartyOption) => { if (slotIndex >= this.scene.currentBattle.getBattlerCount() && slotIndex < 6) { - this.scene.prependToPhase(new SwitchSummonPhase(this.scene, this.getFieldIndex(), slotIndex, false, batonPass), MoveEndPhase); + this.scene.prependToPhase(new SwitchSummonPhase(this.scene, switchType, this.getFieldIndex(), slotIndex, false), MoveEndPhase); } this.scene.ui.setMode(Mode.MESSAGE).then(resolve); }, PartyUiHandler.FilterNonFainted); @@ -4074,11 +4076,11 @@ export class PlayerPokemon extends Pokemon { const allyPokemon = this.getAlly(); if (slotIndex<=1) { // Revived ally pokemon - this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, pokemon.getFieldIndex(), slotIndex, false, false, true)); + this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, SwitchType.SWITCH, pokemon.getFieldIndex(), slotIndex, false, true)); this.scene.unshiftPhase(new ToggleDoublePositionPhase(this.scene, true)); } else if (allyPokemon.isFainted()) { // Revived party pokemon, and ally pokemon is fainted - this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, allyPokemon.getFieldIndex(), slotIndex, false, false, true)); + this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, SwitchType.SWITCH, allyPokemon.getFieldIndex(), slotIndex, false, true)); this.scene.unshiftPhase(new ToggleDoublePositionPhase(this.scene, true)); } } diff --git a/src/locales/de/battler-tags.json b/src/locales/de/battler-tags.json index 2f8a8d0c438..6cc0e93ffb2 100644 --- a/src/locales/de/battler-tags.json +++ b/src/locales/de/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": " {{moveName}} von {{pokemonNameWithAffix}} wurde blockiert!", "disabledLapse": "{{moveName}} von {{pokemonNameWithAffix}} ist nicht länger blockiert!", "tarShotOnAdd": "{{pokemonNameWithAffix}} ist nun schwach gegenüber Feuer-Attacken!", + "shedTailOnAdd": "{{pokemonNameWithAffix}} wirft seinen Schwanz ab, um eine Ablenkung zu schaffen!", "substituteOnAdd": "Ein Delegator von {{pokemonNameWithAffix}} ist erschienen!", "substituteOnHit": "Der Delegator steckt den Schlag für {{pokemonNameWithAffix}} ein!", "substituteOnRemove": "Der Delegator von {{pokemonNameWithAffix}} hört auf zu wirken!" diff --git a/src/locales/en/battler-tags.json b/src/locales/en/battler-tags.json index 6a5eeee2577..520ac2a6202 100644 --- a/src/locales/en/battler-tags.json +++ b/src/locales/en/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "{{pokemonNameWithAffix}}'s {{moveName}}\nwas disabled!", "disabledLapse": "{{pokemonNameWithAffix}}'s {{moveName}}\nis no longer disabled.", "tarShotOnAdd": "{{pokemonNameWithAffix}} became weaker to fire!", + "shedTailOnAdd": "{{pokemonNameWithAffix}} shed its tail to create a decoy!", "substituteOnAdd": "{{pokemonNameWithAffix}} put in a substitute!", "substituteOnHit": "The substitute took damage for {{pokemonNameWithAffix}}!", "substituteOnRemove": "{{pokemonNameWithAffix}}'s substitute faded!", diff --git a/src/locales/es/battler-tags.json b/src/locales/es/battler-tags.json index bb4f0fe6c8a..b4d26f590c3 100644 --- a/src/locales/es/battler-tags.json +++ b/src/locales/es/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "¡Se ha anulado el movimiento {{moveName}}\nde {{pokemonNameWithAffix}}!", "disabledLapse": "¡El movimiento {{moveName}} de {{pokemonNameWithAffix}} ya no está anulado!", "tarShotOnAdd": "¡{{pokemonNameWithAffix}} se ha vuelto débil ante el fuego!", + "shedTailOnAdd": "{{pokemonNameWithAffix}} se desprende\nde un segmento de su cuerpo y lo usa comoseñuelo!", "substituteOnAdd": "¡{{pokemonNameWithAffix}} creó un sustituto!", "substituteOnHit": "¡El sustituto recibe daño en lugar del {{pokemonNameWithAffix}}!", "substituteOnRemove": "¡El sustituto del {{pokemonNameWithAffix}} se debilitó!" diff --git a/src/locales/fr/battler-tags.json b/src/locales/fr/battler-tags.json index 93b70490ed6..b52f688320e 100644 --- a/src/locales/fr/battler-tags.json +++ b/src/locales/fr/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "La capacité {{moveName}}\nde {{pokemonNameWithAffix}} est mise sous entrave !", "disabledLapse": "La capacité {{moveName}}\nde {{pokemonNameWithAffix}} n’est plus sous entrave !", "tarShotOnAdd": "{{pokemonNameWithAffix}} est maintenant\nvulnérable au feu !", + "shedTailOnAdd": "{{pokemonNameWithAffix}} détache\nsa queue pour créer un leurre !", "substituteOnAdd": "{{pokemonNameWithAffix}}\ncrée un clone !", "substituteOnHit": "Le clone subit les dégâts à la place\nde {{pokemonNameWithAffix}} !", "substituteOnRemove": "Le clone de {{pokemonNameWithAffix}}\ndisparait…" diff --git a/src/locales/it/battler-tags.json b/src/locales/it/battler-tags.json index 6ab69f4efa2..a7062c46677 100644 --- a/src/locales/it/battler-tags.json +++ b/src/locales/it/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "La mossa {{moveName}} di\n{{pokemonNameWithAffix}} è stata bloccata!", "disabledLapse": "La mossa {{moveName}} di\n{{pokemonNameWithAffix}} non è più bloccata!", "tarShotOnAdd": "{{pokemonNameWithAffix}} è diventato vulnerabile\nal tipo Fuoco!", + "shedTailOnAdd": "{{pokemonNameWithAffix}} si taglia\nla coda e ne fa un sostituto!", "substituteOnAdd": "Appare un sostituto di {{pokemonNameWithAffix}}!", "substituteOnHit": "Il sostituto viene colpito al posto di {{pokemonNameWithAffix}}!", "substituteOnRemove": "Il sostituto di {{pokemonNameWithAffix}} svanisce!" diff --git a/src/locales/ja/battler-tags.json b/src/locales/ja/battler-tags.json index f8c6d44c0b4..fef8def1328 100644 --- a/src/locales/ja/battler-tags.json +++ b/src/locales/ja/battler-tags.json @@ -70,5 +70,6 @@ "stockpilingOnAdd": "{{pokemonNameWithAffix}}は {{stockpiledCount}}つ たくわえた!", "disabledOnAdd": "{{pokemonNameWithAffix}}の\n{{moveName}}\nを 封じこめた!", "disabledLapse": "{{pokemonNameWithAffix}}の\nかなしばりが 解けた!", - "tarShotOnAdd": "{{pokemonNameWithAffix}}は ほのおに 弱くなった!" + "tarShotOnAdd": "{{pokemonNameWithAffix}}は ほのおに 弱くなった!", + "shedTailOnAdd": "{{pokemonNameWithAffix}}は\nしっぽを 切って みがわりにした!" } diff --git a/src/locales/ko/battler-tags.json b/src/locales/ko/battler-tags.json index 1cd6c86377e..53fab7d10ba 100644 --- a/src/locales/ko/battler-tags.json +++ b/src/locales/ko/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "{{pokemonNameWithAffix}}의 {{moveName}}[[는]]\n사용할 수 없다!", "disabledLapse": "{{pokemonNameWithAffix}}의 {{moveName}}[[는]]\n이제 사용할 수 있다.", "tarShotOnAdd": "{{pokemonNameWithAffix}}[[는]] 불꽃에 약해졌다!", + "shedTailOnAdd": "{{pokemonNameWithAffix}}[[는]] 꼬리를 잘라 대타로 삼았다!", "substituteOnAdd": "{{pokemonNameWithAffix}}의\n대타가 나타났다!", "substituteOnHit": "{{pokemonNameWithAffix}}[[를]] 대신하여\n대타가 공격을 받았다!", "substituteOnRemove": "{{pokemonNameWithAffix}}의\n대타는 사라져 버렸다..." diff --git a/src/locales/zh_CN/battler-tags.json b/src/locales/zh_CN/battler-tags.json index a7859380b7a..565fa8e754e 100644 --- a/src/locales/zh_CN/battler-tags.json +++ b/src/locales/zh_CN/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "封住了{{pokemonNameWithAffix}}的\n{{moveName}}!", "disabledLapse": "{{pokemonNameWithAffix}}的\n定身法解除了!", "tarShotOnAdd": "{{pokemonNameWithAffix}}\n变得怕火了!", + "shedTailOnAdd": "{{pokemonNameWithAffix}}\n断掉尾巴并将其作为替身了!", "substituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出现了!", "substituteOnHit": "替身代替{{pokemonNameWithAffix}}\n承受了攻击!", "substituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……" diff --git a/src/locales/zh_TW/battler-tags.json b/src/locales/zh_TW/battler-tags.json index 49b19f5efdc..d6cb5d0aa43 100644 --- a/src/locales/zh_TW/battler-tags.json +++ b/src/locales/zh_TW/battler-tags.json @@ -71,6 +71,7 @@ "disabledOnAdd": "封住了{{pokemonNameWithAffix}}的\n{moveName}}!", "disabledLapse": "{{pokemonNameWithAffix}}的\n定身法解除了!", "tarShotOnAdd": "{{pokemonNameWithAffix}}\n變得怕火了!", + "shedTailOnAdd": "{{pokemonNameWithAffix}}\n截斷尾巴,把它做成了替身!", "substituteOnAdd": "{{pokemonNameWithAffix}}的\n替身出現了!", "substituteOnHit": "替身代替{{pokemonNameWithAffix}}承受了攻擊!", "substituteOnRemove": "{{pokemonNameWithAffix}}的\n替身消失了……" diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index a069ba224a2..8849d304435 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -8,6 +8,7 @@ import { BattlePhase } from "./battle-phase"; import { PostSummonPhase } from "./post-summon-phase"; import { SummonMissingPhase } from "./summon-missing-phase"; import { SwitchPhase } from "./switch-phase"; +import { SwitchType } from "#enums/switch-type"; export class CheckSwitchPhase extends BattlePhase { protected fieldIndex: integer; @@ -50,7 +51,7 @@ export class CheckSwitchPhase extends BattlePhase { this.scene.ui.setMode(Mode.CONFIRM, () => { this.scene.ui.setMode(Mode.MESSAGE); this.scene.tryRemovePhase(p => p instanceof PostSummonPhase && p.player && p.fieldIndex === this.fieldIndex); - this.scene.unshiftPhase(new SwitchPhase(this.scene, this.fieldIndex, false, true)); + this.scene.unshiftPhase(new SwitchPhase(this.scene, SwitchType.SWITCH, this.fieldIndex, false, true)); this.end(); }, () => { this.scene.ui.setMode(Mode.MESSAGE); diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 41384e5e491..ba25225f6e0 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -18,6 +18,7 @@ import { GameOverPhase } from "./game-over-phase"; import { SwitchPhase } from "./switch-phase"; import { VictoryPhase } from "./victory-phase"; import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; +import { SwitchType } from "#enums/switch-type"; export class FaintPhase extends PokemonPhase { private preventEndure: boolean; @@ -106,14 +107,14 @@ export class FaintPhase extends PokemonPhase { * If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field, * push a phase that prompts the player to summon a Pokemon from their party. */ - this.scene.pushPhase(new SwitchPhase(this.scene, this.fieldIndex, true, false)); + this.scene.pushPhase(new SwitchPhase(this.scene, SwitchType.SWITCH, this.fieldIndex, true, false)); } } else { this.scene.unshiftPhase(new VictoryPhase(this.scene, this.battlerIndex)); if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.scene.currentBattle.battleType)) { const hasReservePartyMember = !!this.scene.getEnemyParty().filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot).length; if (hasReservePartyMember) { - this.scene.pushPhase(new SwitchSummonPhase(this.scene, this.fieldIndex, -1, false, false, false)); + this.scene.pushPhase(new SwitchSummonPhase(this.scene, SwitchType.SWITCH, this.fieldIndex, -1, false, false)); } } } diff --git a/src/phases/mystery-encounter-phases.ts b/src/phases/mystery-encounter-phases.ts index 007b69650b9..60755095cca 100644 --- a/src/phases/mystery-encounter-phases.ts +++ b/src/phases/mystery-encounter-phases.ts @@ -24,6 +24,7 @@ import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { GameOverPhase } from "#app/phases/game-over-phase"; import { SwitchPhase } from "#app/phases/switch-phase"; import { SeenEncounterData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; +import { SwitchType } from "#enums/switch-type"; /** * Will handle (in order): @@ -241,7 +242,7 @@ export class MysteryEncounterBattleStartCleanupPhase extends Phase { const playerField = this.scene.getPlayerField(); playerField.forEach((pokemon, i) => { if (!pokemon.isAllowedInBattle() && legalPlayerPartyPokemon.length > i) { - this.scene.unshiftPhase(new SwitchPhase(this.scene, i, true, false)); + this.scene.unshiftPhase(new SwitchPhase(this.scene, SwitchType.SWITCH, i, true, false)); } }); diff --git a/src/phases/return-phase.ts b/src/phases/return-phase.ts index 19c73816b36..eb587201585 100644 --- a/src/phases/return-phase.ts +++ b/src/phases/return-phase.ts @@ -1,10 +1,11 @@ import BattleScene from "#app/battle-scene"; import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; +import { SwitchType } from "#enums/switch-type"; import { SwitchSummonPhase } from "./switch-summon-phase"; export class ReturnPhase extends SwitchSummonPhase { constructor(scene: BattleScene, fieldIndex: integer) { - super(scene, fieldIndex, -1, true, false); + super(scene, SwitchType.SWITCH, fieldIndex, -1, true); } switchAndSummon(): void { diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index b1a2e991ed8..f5ce2179715 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -1,4 +1,5 @@ import BattleScene from "#app/battle-scene"; +import { SwitchType } from "#enums/switch-type"; import PartyUiHandler, { PartyUiMode, PartyOption } from "#app/ui/party-ui-handler"; import { Mode } from "#app/ui/ui"; import { BattlePhase } from "./battle-phase"; @@ -9,22 +10,25 @@ import { SwitchSummonPhase } from "./switch-summon-phase"; * for the player (if a switch would be valid for the current battle state). */ export class SwitchPhase extends BattlePhase { - protected fieldIndex: integer; - private isModal: boolean; - private doReturn: boolean; + protected readonly fieldIndex: integer; + private readonly switchType: SwitchType; + private readonly isModal: boolean; + private readonly doReturn: boolean; /** * Creates a new SwitchPhase * @param scene {@linkcode BattleScene} Current battle scene + * @param switchType {@linkcode SwitchType} The type of switch logic this phase implements * @param fieldIndex Field index to switch out * @param isModal Indicates if the switch should be forced (true) or is * optional (false). * @param doReturn Indicates if the party member on the field should be * recalled to ball or has already left the field. Passed to {@linkcode SwitchSummonPhase}. */ - constructor(scene: BattleScene, fieldIndex: integer, isModal: boolean, doReturn: boolean) { + constructor(scene: BattleScene, switchType: SwitchType, fieldIndex: integer, isModal: boolean, doReturn: boolean) { super(scene); + this.switchType = switchType; this.fieldIndex = fieldIndex; this.isModal = isModal; this.doReturn = doReturn; @@ -38,11 +42,13 @@ export class SwitchPhase extends BattlePhase { return super.end(); } - // Skip if the fainted party member has been revived already. doReturn is - // only passed as `false` from FaintPhase (as opposed to other usages such - // as ForceSwitchOutAttr or CheckSwitchPhase), so we only want to check this - // if the mon should have already been returned but is still alive and well - // on the field. see also; battle.test.ts + /** + * Skip if the fainted party member has been revived already. doReturn is + * only passed as `false` from FaintPhase (as opposed to other usages such + * as ForceSwitchOutAttr or CheckSwitchPhase), so we only want to check this + * if the mon should have already been returned but is still alive and well + * on the field. see also; battle.test.ts + */ if (this.isModal && !this.doReturn && !this.scene.getParty()[this.fieldIndex].isFainted()) { return super.end(); } @@ -57,7 +63,8 @@ export class SwitchPhase extends BattlePhase { this.scene.ui.setMode(Mode.PARTY, this.isModal ? PartyUiMode.FAINT_SWITCH : PartyUiMode.POST_BATTLE_SWITCH, fieldIndex, (slotIndex: integer, option: PartyOption) => { if (slotIndex >= this.scene.currentBattle.getBattlerCount() && slotIndex < 6) { - this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, fieldIndex, slotIndex, this.doReturn, option === PartyOption.PASS_BATON)); + const switchType = (option === PartyOption.PASS_BATON) ? SwitchType.BATON_PASS : this.switchType; + this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, switchType, fieldIndex, slotIndex, this.doReturn)); } this.scene.ui.setMode(Mode.MESSAGE).then(() => super.end()); }, PartyUiHandler.FilterNonFainted); diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index 525f74e896f..eb1e089543b 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -12,29 +12,30 @@ import i18next from "i18next"; import { PostSummonPhase } from "./post-summon-phase"; import { SummonPhase } from "./summon-phase"; import { SubstituteTag } from "#app/data/battler-tags"; +import { SwitchType } from "#enums/switch-type"; export class SwitchSummonPhase extends SummonPhase { - private slotIndex: integer; - private doReturn: boolean; - private batonPass: boolean; + private readonly switchType: SwitchType; + private readonly slotIndex: integer; + private readonly doReturn: boolean; private lastPokemon: Pokemon; /** * Constructor for creating a new SwitchSummonPhase * @param scene {@linkcode BattleScene} the scene the phase is associated with + * @param switchType the type of switch behavior * @param fieldIndex integer representing position on the battle field * @param slotIndex integer for the index of pokemon (in party of 6) to switch into * @param doReturn boolean whether to render "comeback" dialogue - * @param batonPass boolean if the switch is from baton pass * @param player boolean if the switch is from the player */ - constructor(scene: BattleScene, fieldIndex: integer, slotIndex: integer, doReturn: boolean, batonPass: boolean, player?: boolean) { + constructor(scene: BattleScene, switchType: SwitchType, fieldIndex: integer, slotIndex: integer, doReturn: boolean, player?: boolean) { super(scene, fieldIndex, player !== undefined ? player : true); + this.switchType = switchType; this.slotIndex = slotIndex; this.doReturn = doReturn; - this.batonPass = batonPass; } start(): void { @@ -64,7 +65,7 @@ export class SwitchSummonPhase extends SummonPhase { const pokemon = this.getPokemon(); - if (!this.batonPass) { + if (this.switchType === SwitchType.SWITCH) { (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField()).forEach(enemyPokemon => enemyPokemon.removeTagsBySourceId(pokemon.id)); const substitute = pokemon.getTag(SubstituteTag); if (substitute) { @@ -94,7 +95,7 @@ export class SwitchSummonPhase extends SummonPhase { ease: "Sine.easeIn", scale: 0.5, onComplete: () => { - pokemon.leaveField(!this.batonPass, false); + pokemon.leaveField(this.switchType === SwitchType.SWITCH, false); this.scene.time.delayedCall(750, () => this.switchAndSummon()); } }); @@ -105,7 +106,7 @@ export class SwitchSummonPhase extends SummonPhase { const switchedInPokemon = party[this.slotIndex]; this.lastPokemon = this.getPokemon(); applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon); - if (this.batonPass && switchedInPokemon) { + if (this.switchType === SwitchType.BATON_PASS && switchedInPokemon) { (this.player ? this.scene.getEnemyField() : this.scene.getPlayerField()).forEach(enemyPokemon => enemyPokemon.transferTagsBySourceId(this.lastPokemon.id, switchedInPokemon.id)); if (!this.scene.findModifier(m => m instanceof SwitchEffectTransferModifier && (m as SwitchEffectTransferModifier).pokemonId === switchedInPokemon.id)) { const batonPassModifier = this.scene.findModifier(m => m instanceof SwitchEffectTransferModifier @@ -130,7 +131,7 @@ export class SwitchSummonPhase extends SummonPhase { * 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) { + if (this.switchType === SwitchType.BATON_PASS || this.switchType === SwitchType.SHED_TAIL) { const substitute = this.lastPokemon.getTag(SubstituteTag); if (substitute) { switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0]; @@ -174,8 +175,13 @@ export class SwitchSummonPhase extends SummonPhase { pokemon.battleSummonData.turnCount--; } - if (this.batonPass && pokemon) { + if (this.switchType === SwitchType.BATON_PASS && pokemon) { pokemon.transferSummon(this.lastPokemon); + } else if (this.switchType === SwitchType.SHED_TAIL && pokemon) { + const subTag = this.lastPokemon.getTag(SubstituteTag); + if (subTag) { + pokemon.summonData.tags.push(subTag); + } } this.lastPokemon?.resetSummonData(); diff --git a/src/phases/turn-init-phase.ts b/src/phases/turn-init-phase.ts index 92547878f12..2f1b539cdcf 100644 --- a/src/phases/turn-init-phase.ts +++ b/src/phases/turn-init-phase.ts @@ -32,7 +32,7 @@ export class TurnInitPhase extends FieldPhase { this.scene.unshiftPhase(new GameOverPhase(this.scene)); } else if (allowedPokemon.length >= this.scene.currentBattle.getBattlerCount() || (this.scene.currentBattle.double && !allowedPokemon[0].isActive(true))) { // If there is at least one pokemon in the back that is legal to switch in, force a switch. - p.switchOut(false); + p.switchOut(); } else { // If there are no pokemon in the back but we're not game overing, just hide the pokemon. // This should only happen in double battles. diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 5c1af4228c6..b070abb390a 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -19,6 +19,7 @@ import { TurnEndPhase } from "./turn-end-phase"; import { WeatherEffectPhase } from "./weather-effect-phase"; import { BattlerIndex } from "#app/battle"; import { TrickRoomTag } from "#app/data/arena-tag"; +import { SwitchType } from "#enums/switch-type"; export class TurnStartPhase extends FieldPhase { constructor(scene: BattleScene) { @@ -179,7 +180,8 @@ export class TurnStartPhase extends FieldPhase { this.scene.unshiftPhase(new AttemptCapturePhase(this.scene, turnCommand.targets![0] % 2, turnCommand.cursor!));//TODO: is the bang correct here? break; case Command.POKEMON: - this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, pokemon.getFieldIndex(), turnCommand.cursor!, true, turnCommand.args![0] as boolean, pokemon.isPlayer()));//TODO: is the bang correct here? + const switchType = turnCommand.args?.[0] ? SwitchType.BATON_PASS : SwitchType.SWITCH; + this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, switchType, pokemon.getFieldIndex(), turnCommand.cursor!, true, pokemon.isPlayer())); break; case Command.RUN: let runningPokemon = pokemon; diff --git a/src/test/abilities/zen_mode.test.ts b/src/test/abilities/zen_mode.test.ts index c7cbd9014e0..b4c60aa7a7f 100644 --- a/src/test/abilities/zen_mode.test.ts +++ b/src/test/abilities/zen_mode.test.ts @@ -18,6 +18,7 @@ import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { Status, StatusEffect } from "#app/data/status-effect"; +import { SwitchType } from "#enums/switch-type"; @@ -113,7 +114,7 @@ describe("Abilities - ZEN MODE", () => { await game.phaseInterceptor.run(EnemyCommandPhase); await game.phaseInterceptor.run(TurnStartPhase); game.onNextPrompt("SwitchPhase", Mode.PARTY, () => { - game.scene.unshiftPhase(new SwitchSummonPhase(game.scene, 0, 1, false, false)); + game.scene.unshiftPhase(new SwitchSummonPhase(game.scene, SwitchType.SWITCH, 0, 1, false)); game.scene.ui.setMode(Mode.MESSAGE); }); game.onNextPrompt("SwitchPhase", Mode.MESSAGE, () => { diff --git a/src/test/moves/shed_tail.test.ts b/src/test/moves/shed_tail.test.ts new file mode 100644 index 00000000000..a976a614792 --- /dev/null +++ b/src/test/moves/shed_tail.test.ts @@ -0,0 +1,56 @@ +import { SubstituteTag } from "#app/data/battler-tags"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; + +describe("Moves - Shed Tail", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset([Moves.SHED_TAIL]) + .battleType("single") + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("transfers a Substitute doll to the switched in Pokemon", async () => { + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + + const magikarp = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SHED_TAIL); + game.doSelectPartyPokemon(1); + + await game.phaseInterceptor.to("TurnEndPhase", false); + + const feebas = game.scene.getPlayerPokemon()!; + const substituteTag = feebas.getTag(SubstituteTag); + + expect(feebas).not.toBe(magikarp); + expect(feebas.hp).toBe(feebas.getMaxHp()); + // Note: Shed Tail's HP cost is currently not accurate to mainline, as it + // should cost ceil(maxHP / 2) instead of max(floor(maxHp / 2), 1). The current + // implementation is consistent with Substitute's HP cost logic, but that's not + // the case in mainline for some reason :regiDespair:. + expect(magikarp.hp).toBe(Math.ceil(magikarp.getMaxHp() / 2)); + expect(substituteTag).toBeDefined(); + expect(substituteTag?.hp).toBe(Math.floor(magikarp.getMaxHp() / 4)); + }); +});