diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 71f4dc7b9f6..9f8612633fc 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1,6 +1,6 @@ import Phaser from "phaser"; import UI from "./ui/ui"; -import { NextEncounterPhase, NewBiomeEncounterPhase, SelectBiomePhase, MessagePhase, TurnInitPhase, ReturnPhase, LevelCapPhase, ShowTrainerPhase, LoginPhase, MovePhase, TitlePhase, SwitchPhase } from "./phases"; +import { NextEncounterPhase, NewBiomeEncounterPhase, SelectBiomePhase, MessagePhase, TurnInitPhase, ReturnPhase, LevelCapPhase, ShowTrainerPhase, LoginPhase, MovePhase, TitlePhase, SwitchPhase, SummonPhase, ToggleDoublePositionPhase } from "./phases"; import Pokemon, { PlayerPokemon, EnemyPokemon } from "./field/pokemon"; import PokemonSpecies, { PokemonSpeciesFilter, allSpecies, getPokemonSpecies } from "./data/pokemon-species"; import { Constructor } from "#app/utils"; @@ -14,7 +14,7 @@ import { Arena, ArenaBase } from "./field/arena"; import { GameData } from "./system/game-data"; import { TextStyle, addTextObject, getTextColor } from "./ui/text"; import { allMoves } from "./data/move"; -import { ModifierPoolType, getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getPartyLuckValue } from "./modifier/modifier-type"; +import { ModifierPoolType, getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, modifierTypes } from "./modifier/modifier-type"; import AbilityBar from "./ui/ability-bar"; import { BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, IncrementMovePriorityAbAttr, PostBattleInitAbAttr, applyAbAttrs, applyPostBattleInitAbAttrs } from "./data/ability"; import { allAbilities } from "./data/ability"; @@ -68,6 +68,7 @@ import { UiTheme } from "#enums/ui-theme"; import { TimedEventManager } from "#app/timed-event-manager.js"; import i18next from "i18next"; import {TrainerType} from "#enums/trainer-type"; +import { battleSpecDialogue } from "./data/dialogue"; export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1"; @@ -2619,4 +2620,33 @@ export default class BattleScene extends SceneBase { }; (window as any).gameInfo = gameInfo; } + + /** + * Initialized the 2nd phase of the final boss (e.g. form-change for Eternatus) + * @param pokemon The (enemy) pokemon + */ + initFinalBossPhaseTwo(pokemon: Pokemon): void { + if (pokemon instanceof EnemyPokemon && pokemon.isBoss() && !pokemon.formIndex && pokemon.bossSegmentIndex < 1) { + this.fadeOutBgm(Utils.fixedInt(2000), false); + this.ui.showDialogue(battleSpecDialogue[BattleSpec.FINAL_BOSS].firstStageWin, pokemon.species.name, null, () => { + this.addEnemyModifier(getModifierType(modifierTypes.MINI_BLACK_HOLE).newModifier(pokemon) as PersistentModifier, false, true); + pokemon.generateAndPopulateMoveset(1); + this.setFieldScale(0.75); + this.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger, false); + this.currentBattle.double = true; + const availablePartyMembers = this.getParty().filter((p) => p.isAllowedInBattle()); + if (availablePartyMembers.length > 1) { + this.pushPhase(new ToggleDoublePositionPhase(this, true)); + if (!availablePartyMembers[1].isOnField()) { + this.pushPhase(new SummonPhase(this, 1)); + } + } + + this.shiftPhase(); + }); + return; + } + + this.shiftPhase(); + } } diff --git a/src/phases.ts b/src/phases.ts index dc5dcd0d167..9f927b0fcc1 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -5,7 +5,7 @@ import { allMoves, applyMoveAttrs, BypassSleepAttr, ChargeAttr, applyFilteredMov import { Mode } from "./ui/ui"; import { Command } from "./ui/command-ui-handler"; import { Stat } from "./data/pokemon-stat"; -import { BerryModifier, ContactHeldItemTransferChanceModifier, EnemyAttackStatusEffectChanceModifier, EnemyPersistentModifier, EnemyStatusEffectHealChanceModifier, EnemyTurnHealModifier, ExpBalanceModifier, ExpBoosterModifier, ExpShareModifier, ExtraModifierModifier, FlinchChanceModifier, HealingBoosterModifier, HitHealModifier, LapsingPersistentModifier, MapModifier, Modifier, MultipleParticipantExpBonusModifier, PersistentModifier, PokemonExpBoosterModifier, PokemonHeldItemModifier, PokemonInstantReviveModifier, SwitchEffectTransferModifier, TurnHealModifier, TurnHeldItemTransferModifier, MoneyMultiplierModifier, MoneyInterestModifier, IvScannerModifier, LapsingPokemonHeldItemModifier, PokemonMultiHitModifier, overrideModifiers, overrideHeldItems, BypassSpeedChanceModifier, TurnStatusEffectModifier, PokemonResetNegativeStatStageModifier } from "./modifier/modifier"; +import { BerryModifier, ContactHeldItemTransferChanceModifier, EnemyAttackStatusEffectChanceModifier, EnemyPersistentModifier, EnemyStatusEffectHealChanceModifier, EnemyTurnHealModifier, ExpBalanceModifier, ExpBoosterModifier, ExpShareModifier, ExtraModifierModifier, FlinchChanceModifier, HealingBoosterModifier, HitHealModifier, LapsingPersistentModifier, MapModifier, Modifier, MultipleParticipantExpBonusModifier, PokemonExpBoosterModifier, PokemonHeldItemModifier, PokemonInstantReviveModifier, SwitchEffectTransferModifier, TurnHealModifier, TurnHeldItemTransferModifier, MoneyMultiplierModifier, MoneyInterestModifier, IvScannerModifier, LapsingPokemonHeldItemModifier, PokemonMultiHitModifier, overrideModifiers, overrideHeldItems, BypassSpeedChanceModifier, TurnStatusEffectModifier, PokemonResetNegativeStatStageModifier } from "./modifier/modifier"; import PartyUiHandler, { PartyOption, PartyUiMode } from "./ui/party-ui-handler"; import { doPokeballBounceAnim, getPokeballAtlasKey, getPokeballCatchMultiplier, getPokeballTintColor, PokeballType } from "./data/pokeball"; import { CommonAnim, CommonBattleAnim, MoveAnim, initMoveAnim, loadMoveAnimAssets } from "./data/battle-anims"; @@ -37,7 +37,7 @@ import { vouchers } from "./system/voucher"; import { clientSessionId, loggedInUser, updateUserInfo } from "./account"; import { SessionSaveData } from "./system/game-data"; import { addPokeballCaptureStars, addPokeballOpenParticles } from "./field/anims"; -import { SpeciesFormChangeActiveTrigger, SpeciesFormChangeManualTrigger, SpeciesFormChangeMoveLearnedTrigger, SpeciesFormChangePostMoveTrigger, SpeciesFormChangePreMoveTrigger } from "./data/pokemon-forms"; +import { SpeciesFormChangeActiveTrigger, SpeciesFormChangeMoveLearnedTrigger, SpeciesFormChangePostMoveTrigger, SpeciesFormChangePreMoveTrigger } from "./data/pokemon-forms"; import { battleSpecDialogue, getCharVariantFromDialogue, miscDialogue } from "./data/dialogue"; import ModifierSelectUiHandler, { SHOP_OPTIONS_ROW_LIMIT } from "./ui/modifier-select-ui-handler"; import { SettingKeys } from "./system/settings/settings"; @@ -3598,6 +3598,14 @@ export class PostTurnStatusEffectPhase extends PokemonPhase { this.end(); } } + + override end() { + if (this.scene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { + this.scene.initFinalBossPhaseTwo(this.getPokemon()); + } else { + super.end(); + } + } } export class MessagePhase extends Phase { @@ -3705,34 +3713,12 @@ export class DamagePhase extends PokemonPhase { } } - end() { - switch (this.scene.currentBattle.battleSpec) { - case BattleSpec.FINAL_BOSS: - const pokemon = this.getPokemon(); - if (pokemon instanceof EnemyPokemon && pokemon.isBoss() && !pokemon.formIndex && pokemon.bossSegmentIndex < 1) { - this.scene.fadeOutBgm(Utils.fixedInt(2000), false); - this.scene.ui.showDialogue(battleSpecDialogue[BattleSpec.FINAL_BOSS].firstStageWin, pokemon.species.name, null, () => { - this.scene.addEnemyModifier(getModifierType(modifierTypes.MINI_BLACK_HOLE).newModifier(pokemon) as PersistentModifier, false, true); - pokemon.generateAndPopulateMoveset(1); - this.scene.setFieldScale(0.75); - this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger, false); - this.scene.currentBattle.double = true; - const availablePartyMembers = this.scene.getParty().filter(p => p.isAllowedInBattle()); - if (availablePartyMembers.length > 1) { - this.scene.pushPhase(new ToggleDoublePositionPhase(this.scene, true)); - if (!availablePartyMembers[1].isOnField()) { - this.scene.pushPhase(new SummonPhase(this.scene, 1)); - } - } - - super.end(); - }); - return; - } - break; + override end() { + if (this.scene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { + this.scene.initFinalBossPhaseTwo(this.getPokemon()); + } else { + super.end(); } - - super.end(); } } diff --git a/src/test/final_boss.test.ts b/src/test/final_boss.test.ts new file mode 100644 index 00000000000..bc950e45767 --- /dev/null +++ b/src/test/final_boss.test.ts @@ -0,0 +1,89 @@ +import { Biome } from "#app/enums/biome.js"; +import { Species } from "#app/enums/species.js"; +import { GameModes, getGameMode } from "#app/game-mode.js"; +import { EncounterPhase, SelectStarterPhase } from "#app/phases.js"; +import { Mode } from "#app/ui/ui.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import GameManager from "./utils/gameManager"; +import { generateStarter } from "./utils/gameManagerUtils"; + +const FinalWave = { + Classic: 200, +}; + +describe("Final Boss", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override.startingWave(FinalWave.Classic).startingBiome(Biome.END).disableCrits(); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + it("should spawn Eternatus on wave 200 in END biome", async () => { + await runToFinalBossEncounter(game, [Species.BIDOOF]); + + expect(game.scene.currentBattle.waveIndex).toBe(FinalWave.Classic); + expect(game.scene.arena.biomeType).toBe(Biome.END); + expect(game.scene.getEnemyPokemon().species.speciesId).toBe(Species.ETERNATUS); + }); + + it("should NOT spawn Eternatus before wave 200 in END biome", async () => { + game.override.startingWave(FinalWave.Classic - 1); + await runToFinalBossEncounter(game, [Species.BIDOOF]); + + expect(game.scene.currentBattle.waveIndex).not.toBe(FinalWave.Classic); + expect(game.scene.arena.biomeType).toBe(Biome.END); + expect(game.scene.getEnemyPokemon().species.speciesId).not.toBe(Species.ETERNATUS); + }); + + it("should NOT spawn Eternatus outside of END biome", async () => { + game.override.startingBiome(Biome.FOREST); + await runToFinalBossEncounter(game, [Species.BIDOOF]); + + expect(game.scene.currentBattle.waveIndex).toBe(FinalWave.Classic); + expect(game.scene.arena.biomeType).not.toBe(Biome.END); + expect(game.scene.getEnemyPokemon().species.speciesId).not.toBe(Species.ETERNATUS); + }); + + it.todo("should change form on direct hit down to last boss fragment", () => {}); +}); + +/** + * Helper function to run to the final boss encounter as it's a bit tricky due to extra dialogue + * @param game - The game manager + */ +async function runToFinalBossEncounter(game: GameManager, species: Species[]) { + console.log("===to final boss encounter==="); + await game.runToTitle(); + + game.onNextPrompt("TitlePhase", Mode.TITLE, () => { + game.scene.gameMode = getGameMode(GameModes.CLASSIC); + const starters = generateStarter(game.scene, species); + const selectStarterPhase = new SelectStarterPhase(game.scene); + game.scene.pushPhase(new EncounterPhase(game.scene, false)); + selectStarterPhase.initBattle(starters); + }); + + game.onNextPrompt("EncounterPhase", Mode.MESSAGE, async () => { + // This will skip all entry dialogue (I can't figure out a way to sequentially handle the 8 chained messages via 1 prompt handler) + game.setMode(Mode.MESSAGE); + const encounterPhase = game.scene.getCurrentPhase() as EncounterPhase; + + // No need to end phase, this will do it for you + encounterPhase.doEncounterCommon(false); + }); + + await game.phaseInterceptor.to(EncounterPhase, true); + console.log("===finished run to final boss encounter==="); +} diff --git a/src/test/utils/TextInterceptor.ts b/src/test/utils/TextInterceptor.ts index 34b55aa30ac..a49f41f6be0 100644 --- a/src/test/utils/TextInterceptor.ts +++ b/src/test/utils/TextInterceptor.ts @@ -11,6 +11,11 @@ export default class TextInterceptor { this.logs.push(text); } + showDialogue(text: string, name: string, delay?: integer, callback?: Function, callbackDelay?: integer, promptDelay?: integer): void { + console.log(name, text); + this.logs.push(name, text); + } + getLatestMessage(): string { return this.logs.pop(); } diff --git a/src/test/utils/mocks/mocksContainer/mockSprite.ts b/src/test/utils/mocks/mocksContainer/mockSprite.ts index 3a5de09b258..fb7f84741e8 100644 --- a/src/test/utils/mocks/mocksContainer/mockSprite.ts +++ b/src/test/utils/mocks/mocksContainer/mockSprite.ts @@ -31,6 +31,7 @@ export default class MockSprite { }; this.anims = { pause: () => null, + stop: () => null, }; } diff --git a/src/ui/ui.ts b/src/ui/ui.ts index ae1bc10a74f..b409b7c0d78 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -235,8 +235,8 @@ export default class UI extends Phaser.GameObjects.Container { (this.scene as BattleScene).uiContainer.add(this.tooltipContainer); } - getHandler(): UiHandler { - return this.handlers[this.mode]; + getHandler(): H { + return this.handlers[this.mode] as H; } getMessageHandler(): BattleMessageUiHandler {