import { BattlerIndex } from "#app/battle"; import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; import { SubstituteTag } from "#app/data/battler-tags"; import { doPokeballBounceAnim, getPokeballAtlasKey, getPokeballCatchMultiplier, getPokeballTintColor, getCriticalCaptureChance } from "#app/data/pokeball"; import { getStatusEffectCatchRateMultiplier } from "#app/data/status-effect"; import { addPokeballCaptureStars, addPokeballOpenParticles } from "#app/field/anims"; import type { EnemyPokemon } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { PokemonHeldItemModifier } from "#app/modifier/modifier"; import { PokemonPhase } from "#app/phases/pokemon-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; import { achvs } from "#app/system/achv"; import type { PartyOption } from "#app/ui/party-ui-handler"; import { PartyUiMode } from "#app/ui/party-ui-handler"; import { SummaryUiMode } from "#app/ui/summary-ui-handler"; import { Mode } from "#app/ui/ui"; import type { PokeballType } from "#enums/pokeball"; import { StatusEffect } from "#enums/status-effect"; import i18next from "i18next"; import { globalScene } from "#app/global-scene"; export class AttemptCapturePhase extends PokemonPhase { private pokeballType: PokeballType; private pokeball: Phaser.GameObjects.Sprite; private originalY: number; constructor(targetIndex: integer, pokeballType: PokeballType) { super(BattlerIndex.ENEMY + targetIndex); this.pokeballType = pokeballType; } start() { super.start(); const pokemon = this.getPokemon() as EnemyPokemon; if (!pokemon?.hp) { return this.end(); } const substitute = pokemon.getTag(SubstituteTag); if (substitute) { substitute.sprite.setVisible(false); } globalScene.pokeballCounts[this.pokeballType]--; this.originalY = pokemon.y; const _3m = 3 * pokemon.getMaxHp(); const _2h = 2 * pokemon.hp; const catchRate = pokemon.species.catchRate; const pokeballMultiplier = getPokeballCatchMultiplier(this.pokeballType); const statusMultiplier = pokemon.status ? getStatusEffectCatchRateMultiplier(pokemon.status.effect) : 1; const modifiedCatchRate = Math.round((((_3m - _2h) * catchRate * pokeballMultiplier) / _3m) * statusMultiplier); const shakeProbability = Math.round(65536 / Math.pow((255 / modifiedCatchRate), 0.1875)); // Formula taken from gen 6 const criticalCaptureChance = getCriticalCaptureChance(modifiedCatchRate); const isCritical = pokemon.randSeedInt(256) < criticalCaptureChance; const fpOffset = pokemon.getFieldPositionOffset(); const pokeballAtlasKey = getPokeballAtlasKey(this.pokeballType); this.pokeball = globalScene.addFieldSprite(16, 80, "pb", pokeballAtlasKey); this.pokeball.setOrigin(0.5, 0.625); globalScene.field.add(this.pokeball); globalScene.playSound(isCritical ? "se/crit_throw" : "se/pb_throw"); globalScene.time.delayedCall(300, () => { globalScene.field.moveBelow(this.pokeball as Phaser.GameObjects.GameObject, pokemon); }); globalScene.tweens.add({ // Throw animation targets: this.pokeball, x: { value: 236 + fpOffset[0], ease: "Linear" }, y: { value: 16 + fpOffset[1], ease: "Cubic.easeOut" }, duration: 500, onComplete: () => { // Ball opens this.pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); globalScene.time.delayedCall(17, () => this.pokeball.setTexture("pb", `${pokeballAtlasKey}_open`)); globalScene.playSound("se/pb_rel"); pokemon.tint(getPokeballTintColor(this.pokeballType)); addPokeballOpenParticles(this.pokeball.x, this.pokeball.y, this.pokeballType); globalScene.tweens.add({ // Mon enters ball targets: pokemon, duration: 500, ease: "Sine.easeIn", scale: 0.25, y: 20, onComplete: () => { // Ball closes this.pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); pokemon.setVisible(false); globalScene.playSound("se/pb_catch"); globalScene.time.delayedCall(17, () => this.pokeball.setTexture("pb", `${pokeballAtlasKey}`)); const doShake = () => { // After the overall catch rate check, the game does 3 shake checks before confirming the catch. let shakeCount = 0; const pbX = this.pokeball.x; const shakeCounter = globalScene.tweens.addCounter({ from: 0, to: 1, repeat: isCritical ? 2 : 4, // Critical captures only perform 1 shake check yoyo: true, ease: "Cubic.easeOut", duration: 250, repeatDelay: 500, onUpdate: t => { if (shakeCount && shakeCount < (isCritical ? 2 : 4)) { const value = t.getValue(); const directionMultiplier = shakeCount % 2 === 1 ? 1 : -1; this.pokeball.setX(pbX + value * 4 * directionMultiplier); this.pokeball.setAngle(value * 27.5 * directionMultiplier); } }, onRepeat: () => { if (!pokemon.species.isObtainable()) { shakeCounter.stop(); this.failCatch(shakeCount); } else if (shakeCount++ < (isCritical ? 1 : 3)) { // Shake check (skip check for critical or guaranteed captures, but still play the sound) if (pokeballMultiplier === -1 || isCritical || modifiedCatchRate >= 255 || pokemon.randSeedInt(65536) < shakeProbability) { globalScene.playSound("se/pb_move"); } else { shakeCounter.stop(); this.failCatch(shakeCount); } } else if (isCritical && pokemon.randSeedInt(65536) >= shakeProbability) { // Above, perform the one shake check for critical captures after the ball shakes once shakeCounter.stop(); this.failCatch(shakeCount); } else { globalScene.playSound("se/pb_lock"); addPokeballCaptureStars(this.pokeball); const pbTint = globalScene.add.sprite(this.pokeball.x, this.pokeball.y, "pb", "pb"); pbTint.setOrigin(this.pokeball.originX, this.pokeball.originY); pbTint.setTintFill(0); pbTint.setAlpha(0); globalScene.field.add(pbTint); globalScene.tweens.add({ targets: pbTint, alpha: 0.375, duration: 200, easing: "Sine.easeOut", onComplete: () => { globalScene.tweens.add({ targets: pbTint, alpha: 0, duration: 200, easing: "Sine.easeIn", onComplete: () => pbTint.destroy() }); } }); } }, onComplete: () => { this.catch(); } }); }; // Ball bounces (handled in pokemon.ts) globalScene.time.delayedCall(250, () => doPokeballBounceAnim(this.pokeball, 16, 72, 350, doShake, isCritical)); } }); } }); } failCatch(shakeCount: integer) { const pokemon = this.getPokemon(); globalScene.playSound("se/pb_rel"); pokemon.setY(this.originalY); if (pokemon.status?.effect !== StatusEffect.SLEEP) { pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 }); } pokemon.tint(getPokeballTintColor(this.pokeballType)); pokemon.setVisible(true); pokemon.untint(250, "Sine.easeOut"); const substitute = pokemon.getTag(SubstituteTag); if (substitute) { substitute.sprite.setVisible(true); } const pokeballAtlasKey = getPokeballAtlasKey(this.pokeballType); this.pokeball.setTexture("pb", `${pokeballAtlasKey}_opening`); globalScene.time.delayedCall(17, () => this.pokeball.setTexture("pb", `${pokeballAtlasKey}_open`)); globalScene.tweens.add({ targets: pokemon, duration: 250, ease: "Sine.easeOut", scale: 1 }); globalScene.currentBattle.lastUsedPokeball = this.pokeballType; this.removePb(); this.end(); } catch() { const pokemon = this.getPokemon() as EnemyPokemon; const speciesForm = !pokemon.fusionSpecies ? pokemon.getSpeciesForm() : pokemon.getFusionSpeciesForm(); if (speciesForm.abilityHidden && (pokemon.fusionSpecies ? pokemon.fusionAbilityIndex : pokemon.abilityIndex) === speciesForm.getAbilityCount() - 1) { globalScene.validateAchv(achvs.HIDDEN_ABILITY); } if (pokemon.species.subLegendary) { globalScene.validateAchv(achvs.CATCH_SUB_LEGENDARY); } if (pokemon.species.legendary) { globalScene.validateAchv(achvs.CATCH_LEGENDARY); } if (pokemon.species.mythical) { globalScene.validateAchv(achvs.CATCH_MYTHICAL); } globalScene.pokemonInfoContainer.show(pokemon, true); globalScene.gameData.updateSpeciesDexIvs(pokemon.species.getRootSpeciesId(true), pokemon.ivs); globalScene.ui.showText(i18next.t("battle:pokemonCaught", { pokemonName: getPokemonNameWithAffix(pokemon) }), null, () => { const end = () => { globalScene.unshiftPhase(new VictoryPhase(this.battlerIndex)); globalScene.pokemonInfoContainer.hide(); this.removePb(); this.end(); }; const removePokemon = () => { globalScene.addFaintedEnemyScore(pokemon); globalScene.getPlayerField().filter(p => p.isActive(true)).forEach(playerPokemon => playerPokemon.removeTagsBySourceId(pokemon.id)); pokemon.hp = 0; pokemon.trySetStatus(StatusEffect.FAINT); globalScene.clearEnemyHeldItemModifiers(); globalScene.field.remove(pokemon, true); }; const addToParty = (slotIndex?: number) => { const newPokemon = pokemon.addToParty(this.pokeballType, slotIndex); const modifiers = globalScene.findModifiers(m => m instanceof PokemonHeldItemModifier, false); if (globalScene.getPlayerParty().filter(p => p.isShiny()).length === PLAYER_PARTY_MAX_SIZE) { globalScene.validateAchv(achvs.SHINY_PARTY); } Promise.all(modifiers.map(m => globalScene.addModifier(m, true))).then(() => { globalScene.updateModifiers(true); removePokemon(); if (newPokemon) { newPokemon.loadAssets().then(end); } else { end(); } }); }; Promise.all([ pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon) ]).then(() => { if (globalScene.getPlayerParty().length === PLAYER_PARTY_MAX_SIZE) { const promptRelease = () => { globalScene.ui.showText(i18next.t("battle:partyFull", { pokemonName: pokemon.getNameToRender() }), null, () => { globalScene.pokemonInfoContainer.makeRoomForConfirmUi(1, true); globalScene.ui.setMode(Mode.CONFIRM, () => { const newPokemon = globalScene.addPlayerPokemon(pokemon.species, pokemon.level, pokemon.abilityIndex, pokemon.formIndex, pokemon.gender, pokemon.shiny, pokemon.variant, pokemon.ivs, pokemon.nature, pokemon); globalScene.ui.setMode(Mode.SUMMARY, newPokemon, 0, SummaryUiMode.DEFAULT, () => { globalScene.ui.setMode(Mode.MESSAGE).then(() => { promptRelease(); }); }, false); }, () => { globalScene.ui.setMode(Mode.PARTY, PartyUiMode.RELEASE, this.fieldIndex, (slotIndex: integer, _option: PartyOption) => { globalScene.ui.setMode(Mode.MESSAGE).then(() => { if (slotIndex < 6) { addToParty(slotIndex); } else { promptRelease(); } }); }); }, () => { globalScene.ui.setMode(Mode.MESSAGE).then(() => { removePokemon(); end(); }); }, "fullParty"); }); }; promptRelease(); } else { addToParty(); } }); }, 0, true); } removePb() { globalScene.tweens.add({ targets: this.pokeball, duration: 250, delay: 250, ease: "Sine.easeIn", alpha: 0, onComplete: () => this.pokeball.destroy() }); } }