diff --git a/public/images/mystery-encounters/berry_bush.json b/public/images/mystery-encounters/berry_bush.json new file mode 100644 index 00000000000..397538d8af2 --- /dev/null +++ b/public/images/mystery-encounters/berry_bush.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "berry_bush.png", + "format": "RGBA8888", + "size": { + "w": 49, + "h": 53 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 49, + "h": 53 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 49, + "h": 53 + }, + "frame": { + "x": 0, + "y": 0, + "w": 49, + "h": 53 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:d5f83625477b5f98b726343f4a3a396f:f4665258986e97345cfeee041b4b8bcf:e7781fcc447e6d12deb2af78c9493c7f$" + } +} diff --git a/public/images/mystery-encounters/berry_bush.png b/public/images/mystery-encounters/berry_bush.png new file mode 100644 index 00000000000..e9be20b4863 Binary files /dev/null and b/public/images/mystery-encounters/berry_bush.png differ diff --git a/public/images/mystery-encounters/starry_background.png b/public/images/mystery-encounters/starry_background.png deleted file mode 100644 index 759c624ddc1..00000000000 Binary files a/public/images/mystery-encounters/starry_background.png and /dev/null differ diff --git a/public/images/mystery-encounters/teleporter.json b/public/images/mystery-encounters/teleporter.json new file mode 100644 index 00000000000..e267c9a3dde --- /dev/null +++ b/public/images/mystery-encounters/teleporter.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "teleporter.png", + "format": "RGBA8888", + "size": { + "w": 64, + "h": 78 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 64, + "h": 78 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 78 + }, + "frame": { + "x": 0, + "y": 0, + "w": 64, + "h": 78 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:a8e006630c2838130468b0d5c9aeb8a6:684c1813cb6c86e395c18027a593ed28:ce1615396ce7b0a146766d50b319bb81$" + } +} diff --git a/public/images/mystery-encounters/teleporter.png b/public/images/mystery-encounters/teleporter.png new file mode 100644 index 00000000000..e71170ff184 Binary files /dev/null and b/public/images/mystery-encounters/teleporter.png differ diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts index 62ef5631736..7a464a5fd55 100644 --- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -46,96 +46,7 @@ export const BerriesAboundEncounter: MysteryEncounter = .withSceneWaveRangeRequirement(10, 180) // waves 10 to 180 .withCatchAllowed(true) .withHideWildIntroMessage(true) - .withIntroSpriteConfigs([ - { - spriteKey: "lum_berry", - fileRoot: "items", - isItem: true, - x: 7, - y: -14, - disableAnimation: true - }, - { - spriteKey: "salac_berry", - fileRoot: "items", - isItem: true, - x: 2, - y: 4, - disableAnimation: true - }, - { - spriteKey: "lansat_berry", - fileRoot: "items", - isItem: true, - x: 32, - y: 5, - disableAnimation: true - }, - { - spriteKey: "liechi_berry", - fileRoot: "items", - isItem: true, - x: 6, - y: -5, - disableAnimation: true - }, - { - spriteKey: "sitrus_berry", - fileRoot: "items", - isItem: true, - x: 7, - y: 8, - disableAnimation: true - }, - { - spriteKey: "enigma_berry", - fileRoot: "items", - isItem: true, - x: 26, - y: -4, - disableAnimation: true - }, - { - spriteKey: "leppa_berry", - fileRoot: "items", - isItem: true, - x: 16, - y: -27, - disableAnimation: true - }, - { - spriteKey: "petaya_berry", - fileRoot: "items", - isItem: true, - x: 30, - y: -17, - disableAnimation: true - }, - { - spriteKey: "ganlon_berry", - fileRoot: "items", - isItem: true, - x: 16, - y: -11, - disableAnimation: true - }, - { - spriteKey: "apicot_berry", - fileRoot: "items", - isItem: true, - x: 14, - y: -2, - disableAnimation: true - }, - { - spriteKey: "starf_berry", - fileRoot: "items", - isItem: true, - x: 18, - y: 9, - disableAnimation: true - }, - ]) // Set in onInit() + .withIntroSpriteConfigs([]) // Set in onInit() .withIntroDialogue([ { text: `${namespace}.intro`, @@ -145,12 +56,14 @@ export const BerriesAboundEncounter: MysteryEncounter = const encounter = scene.currentBattle.mysteryEncounter; // Calculate boss mon - const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, scene.currentBattle.waveIndex, 0, getPartyLuckValue(scene.getParty()), true); - const bossPokemon = new EnemyPokemon(scene, bossSpecies, scene.currentBattle.waveIndex, TrainerSlot.NONE, true); + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); const config: EnemyPartyConfig = { levelAdditiveMultiplier: 1, pokemonConfigs: [{ + level: level, species: bossSpecies, dataSource: new PokemonData(bossPokemon), isBoss: true @@ -168,15 +81,26 @@ export const BerriesAboundEncounter: MysteryEncounter = encounter.misc = { numBerries }; const { spriteKey, fileRoot } = getSpriteKeysFromPokemon(bossPokemon); - encounter.spriteConfigs.push({ - spriteKey: spriteKey, - fileRoot: fileRoot, - hasShadow: true, - tint: 0.25, - x: -5, - repeat: true, - isPokemon: true - }); + encounter.spriteConfigs = [ + { + spriteKey: "berry_bush", + fileRoot: "mystery-encounters", + x: 25, + y: -6, + yShadow: -7, + disableAnimation: true, + hasShadow: true + }, + { + spriteKey: spriteKey, + fileRoot: fileRoot, + hasShadow: true, + tint: 0.25, + x: -5, + repeat: true, + isPokemon: true + } + ]; // Get fastest party pokemon for option 2 const fastestPokemon = getHighestStatPlayerPokemon(scene, Stat.SPD, true); @@ -238,7 +162,7 @@ export const BerriesAboundEncounter: MysteryEncounter = const encounter = scene.currentBattle.mysteryEncounter; const fastestPokemon = encounter.misc.fastestPokemon; const enemySpeed = encounter.misc.enemySpeed; - const speedDiff = fastestPokemon.getStat(Stat.SPD) / enemySpeed; + const speedDiff = fastestPokemon.getStat(Stat.SPD) / (enemySpeed * 1.1); const numBerries = encounter.misc.numBerries; const shopOptions: ModifierTypeOption[] = []; @@ -272,8 +196,8 @@ export const BerriesAboundEncounter: MysteryEncounter = await initBattleWithEnemyConfig(scene, config); return; } else { - // Gains 1 berry for every 10% faster the player's pokemon is than the enemy, up to a max of numBerries, minimum of 1 - const numBerriesGrabbed = Math.max(Math.min(Math.round((speedDiff - 1)/0.1), numBerries), 1); + // Gains 1 berry for every 10% faster the player's pokemon is than the enemy, up to a max of numBerries, minimum of 2 + const numBerriesGrabbed = Math.max(Math.min(Math.round((speedDiff - 1)/0.08), numBerries), 2); encounter.setDialogueToken("numBerries", String(numBerriesGrabbed)); const doFasterBerryRewards = async () => { const berryText = numBerriesGrabbed + " " + i18next.t(`${namespace}.berries`); diff --git a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts index 1eea328927b..fe5cf320401 100644 --- a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts +++ b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts @@ -1,5 +1,5 @@ import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; -import { EnemyPartyConfig, generateModifierTypeOption, initBattleWithEnemyConfig, loadCustomMovesForEncounter, leaveEncounterWithoutBattle, setEncounterExp, setEncounterRewards, transitionMysteryEncounterIntroVisuals } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { EnemyPartyConfig, initBattleWithEnemyConfig, loadCustomMovesForEncounter, leaveEncounterWithoutBattle, setEncounterExp, setEncounterRewards, transitionMysteryEncounterIntroVisuals, generateModifierType } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; import { AttackTypeBoosterModifierType, modifierTypes, } from "#app/modifier/modifier-type"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import BattleScene from "#app/battle-scene"; @@ -243,7 +243,7 @@ function giveLeadPokemonCharcoal(scene: BattleScene) { // Give first party pokemon Charcoal for free at end of battle const leadPokemon = scene.getParty()?.[0]; if (leadPokemon) { - const charcoal = generateModifierTypeOption(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.FIRE]).type as AttackTypeBoosterModifierType; + const charcoal = generateModifierType(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.FIRE]) as AttackTypeBoosterModifierType; applyModifierTypeToPlayerPokemon(scene, leadPokemon, charcoal); scene.currentBattle.mysteryEncounter.setDialogueToken("leadPokemon", leadPokemon.getNameToRender()); queueEncounterMessage(scene, `${namespace}.found_charcoal`); diff --git a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts index 616c81880df..a7aeefe2db5 100644 --- a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts +++ b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts @@ -49,11 +49,13 @@ export const FightOrFlightEncounter: MysteryEncounter = const encounter = scene.currentBattle.mysteryEncounter; // Calculate boss mon - const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, scene.currentBattle.waveIndex, 0, getPartyLuckValue(scene.getParty()), true); - const bossPokemon = new EnemyPokemon(scene, bossSpecies, scene.currentBattle.waveIndex, TrainerSlot.NONE, true); + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); const config: EnemyPartyConfig = { levelAdditiveMultiplier: 1, pokemonConfigs: [{ + level: level, species: bossSpecies, dataSource: new PokemonData(bossPokemon), isBoss: true diff --git a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts new file mode 100644 index 00000000000..e4bc8b499bb --- /dev/null +++ b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts @@ -0,0 +1,234 @@ +import { EnemyPartyConfig, generateModifierTypeOption, initBattleWithEnemyConfig, setEncounterExp, setEncounterRewards, transitionMysteryEncounterIntroVisuals, updatePlayerMoney, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { randSeedInt } from "#app/utils"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "#app/battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder } from "../mystery-encounter"; +import { MoneyRequirement, WaveModulusRequirement } from "../mystery-encounter-requirements"; +import Pokemon, { EnemyPokemon } from "#app/field/pokemon"; +import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import PokemonData from "#app/system/pokemon-data"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { Biome } from "#enums/biome"; +import { getBiomeKey } from "#app/field/arena"; +import { Type } from "#app/data/type"; +import { getPartyLuckValue, modifierTypes } from "#app/modifier/modifier-type"; +import { TrainerSlot } from "#app/data/trainer-config"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { StatChangePhase } from "#app/phases/stat-change-phase"; +import { BattleStat } from "#app/data/battle-stat"; +import { getPokemonNameWithAffix } from "#app/messages"; + +/** the i18n namespace for this encounter */ +const namespace = "mysteryEncounter:teleportingHijinks"; + +const MONEY_COST_MULTIPLIER = 2.5; +const BIOME_CANDIDATES = [Biome.SPACE, Biome.FAIRY_CAVE, Biome.LABORATORY, Biome.ISLAND]; + +/** + * Teleporting Hijinks encounter. + * @see {@link https://github.com/AsdarDevelops/PokeRogue-Events/issues/119 | GitHub Issue #119} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const TeleportingHijinksEncounter: MysteryEncounter = + MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.TELEPORTING_HIJINKS) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(10, 180) + .withSceneRequirement(new WaveModulusRequirement([1, 2, 3], 10)) // Must be in first 3 waves after boss wave + .withSceneRequirement(new MoneyRequirement(undefined, MONEY_COST_MULTIPLIER)) // Must be able to pay teleport cost + .withAutoHideIntroVisuals(false) + .withCatchAllowed(true) + .withIntroSpriteConfigs([ + { + spriteKey: "teleporter", + fileRoot: "mystery-encounters", + hasShadow: true, + y: 4 + } + ]) + .withIntroDialogue([ + { + text: `${namespace}.intro`, + } + ]) + .withTitle(`${namespace}.title`) + .withDescription(`${namespace}.description`) + .withQuery(`${namespace}.query`) + .withOnInit((scene: BattleScene) => { + const encounter = scene.currentBattle.mysteryEncounter; + const price = scene.getWaveMoneyAmount(MONEY_COST_MULTIPLIER); + encounter.setDialogueToken("price", price.toString()); + encounter.misc = { + price + }; + + return true; + }) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withSceneMoneyRequirement(undefined, MONEY_COST_MULTIPLIER) // Must be able to pay teleport cost + .withDialogue({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + } + ], + }) + .withPreOptionPhase(async (scene: BattleScene) => { + // Update money + updatePlayerMoney(scene, -scene.currentBattle.mysteryEncounter.misc.price, true, false); + }) + .withOptionPhase(async (scene: BattleScene) => { + const config: EnemyPartyConfig = await doBiomeTransitionDialogueAndBattleInit(scene); + setEncounterRewards(scene, { fillRemaining: true }); + await initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withOption( + MysteryEncounterOptionBuilder + .newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL) + .withPokemonTypeRequirement([Type.ELECTRIC, Type.STEEL], true, 1) // Must have Steel or Electric type + .withDialogue({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + } + ], + }) + .withOptionPhase(async (scene: BattleScene) => { + const config: EnemyPartyConfig = await doBiomeTransitionDialogueAndBattleInit(scene); + setEncounterRewards(scene, { fillRemaining: true }); + setEncounterExp(scene, scene.currentBattle.mysteryEncounter.selectedOption!.primaryPokemon!.id, 100); + await initBattleWithEnemyConfig(scene, config); + }) + .build() + ) + .withSimpleOption( + { + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }, + async (scene: BattleScene) => { + // Inspect the Machine + const encounter = scene.currentBattle.mysteryEncounter; + + // Init enemy + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); + encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); + const config: EnemyPartyConfig = { + pokemonConfigs: [{ + level: level, + species: bossSpecies, + dataSource: new PokemonData(bossPokemon), + isBoss: true, + }], + }; + + const magnet = generateModifierTypeOption(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.STEEL]); + const metalCoat = generateModifierTypeOption(scene, modifierTypes.ATTACK_TYPE_BOOSTER, [Type.ELECTRIC]); + setEncounterRewards(scene, { guaranteedModifierTypeOptions: [magnet, metalCoat], fillRemaining: true }); + setEncounterExp(scene, encounter.selectedOption!.primaryPokemon!.id, 100); + transitionMysteryEncounterIntroVisuals(scene, true, true); + await initBattleWithEnemyConfig(scene, config); + } + ) + .build(); + +async function doBiomeTransitionDialogueAndBattleInit(scene: BattleScene) { + const encounter = scene.currentBattle.mysteryEncounter; + + // Calculate new biome (cannot be current biome) + const filteredBiomes = BIOME_CANDIDATES.filter(b => scene.arena.biomeType !== b); + const newBiome = filteredBiomes[randSeedInt(filteredBiomes.length)]; + + // Show dialogue and transition biome + await showEncounterText(scene, `${namespace}.transport`); + await Promise.all([animateBiomeChange(scene, newBiome), transitionMysteryEncounterIntroVisuals(scene)]); + scene.playBgm(); + await showEncounterText(scene, `${namespace}.attacked`); + + // Init enemy + const level = (scene.currentBattle.enemyLevels?.[0] ?? scene.currentBattle.waveIndex) + Math.max(Math.round((scene.currentBattle.waveIndex / 10)), 0); + const bossSpecies = scene.arena.randomSpecies(scene.currentBattle.waveIndex, level, 0, getPartyLuckValue(scene.getParty()), true); + const bossPokemon = new EnemyPokemon(scene, bossSpecies, level, TrainerSlot.NONE, true); + encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); + const config: EnemyPartyConfig = { + pokemonConfigs: [{ + level: level, + species: bossSpecies, + dataSource: new PokemonData(bossPokemon), + isBoss: true, + tags: [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON], + mysteryEncounterBattleEffects: (pokemon: Pokemon) => { + queueEncounterMessage(pokemon.scene, `${namespace}.boss_enraged`); + pokemon.scene.unshiftPhase(new StatChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [BattleStat.ATK, BattleStat.DEF, BattleStat.SPATK, BattleStat.SPDEF, BattleStat.SPD], 1)); + } + }], + }; + + return config; +} + +async function animateBiomeChange(scene: BattleScene, nextBiome: Biome) { + return new Promise(resolve => { + scene.tweens.add({ + targets: [scene.arenaEnemy, scene.lastEnemyTrainer], + x: "+=300", + duration: 2000, + onComplete: () => { + scene.newArena(nextBiome); + + const biomeKey = getBiomeKey(nextBiome); + const bgTexture = `${biomeKey}_bg`; + scene.arenaBgTransition.setTexture(bgTexture); + scene.arenaBgTransition.setAlpha(0); + scene.arenaBgTransition.setVisible(true); + scene.arenaPlayerTransition.setBiome(nextBiome); + scene.arenaPlayerTransition.setAlpha(0); + scene.arenaPlayerTransition.setVisible(true); + + scene.tweens.add({ + targets: [scene.arenaPlayer, scene.arenaBgTransition, scene.arenaPlayerTransition], + duration: 1000, + ease: "Sine.easeInOut", + alpha: (target: any) => target === scene.arenaPlayer ? 0 : 1, + onComplete: () => { + scene.arenaBg.setTexture(bgTexture); + scene.arenaPlayer.setBiome(nextBiome); + scene.arenaPlayer.setAlpha(1); + scene.arenaEnemy.setBiome(nextBiome); + scene.arenaEnemy.setAlpha(1); + scene.arenaNextEnemy.setBiome(nextBiome); + scene.arenaBgTransition.setVisible(false); + scene.arenaPlayerTransition.setVisible(false); + if (scene.lastEnemyTrainer) { + scene.lastEnemyTrainer.destroy(); + } + + resolve(); + + scene.tweens.add({ + targets: scene.arenaEnemy, + x: "-=300", + }); + } + }); + } + }); + }); +} diff --git a/src/data/mystery-encounters/mystery-encounter-requirements.ts b/src/data/mystery-encounters/mystery-encounter-requirements.ts index 4163ece8cd8..042f967a23d 100644 --- a/src/data/mystery-encounters/mystery-encounter-requirements.ts +++ b/src/data/mystery-encounters/mystery-encounter-requirements.ts @@ -153,7 +153,36 @@ export class WaveRangeRequirement extends EncounterSceneRequirement { } getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { - return ["waveCount", scene.currentBattle.waveIndex.toString()]; + return ["waveIndex", scene.currentBattle.waveIndex.toString()]; + } +} + +export class WaveModulusRequirement extends EncounterSceneRequirement { + waveModuli: number[]; + modulusValue: number; + + /** + * Used for specifying a modulus requirement on the wave index + * For example, can be used to require the wave index to end with 1, 2, or 3 + * @param waveModuli - number[], the allowed modulus results + * @param modulusValue - number, the modulus calculation value + * + * Example: + * new WaveModulusRequirement([1, 2, 3], 10) will check for 1st/2nd/3rd waves that are immediately after a multiple of 10 wave + * So waves 21, 32, 53 all return true. 58, 14, 99 return false. + */ + constructor(waveModuli: number[], modulusValue: number) { + super(); + this.waveModuli = waveModuli; + this.modulusValue = modulusValue; + } + + meetsRequirement(scene: BattleScene): boolean { + return this.waveModuli.includes(scene.currentBattle.waveIndex % this.modulusValue); + } + + getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] { + return ["waveIndex", scene.currentBattle.waveIndex.toString()]; } } diff --git a/src/data/mystery-encounters/mystery-encounters.ts b/src/data/mystery-encounters/mystery-encounters.ts index b9e15302fe4..79a3b6ed635 100644 --- a/src/data/mystery-encounters/mystery-encounters.ts +++ b/src/data/mystery-encounters/mystery-encounters.ts @@ -26,6 +26,7 @@ import { PartTimerEncounter } from "#app/data/mystery-encounters/encounters/part import { DancingLessonsEncounter } from "#app/data/mystery-encounters/encounters/dancing-lessons-encounter"; import { WeirdDreamEncounter } from "#app/data/mystery-encounters/encounters/weird-dream-encounter"; import { TheWinstrateChallengeEncounter } from "#app/data/mystery-encounters/encounters/the-winstrate-challenge-encounter"; +import { TeleportingHijinksEncounter } from "#app/data/mystery-encounters/encounters/teleporting-hijinks-encounter"; // Spawn chance: (BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT + WIGHT_INCREMENT_ON_SPAWN_MISS * ) / 256 export const BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT = 1; @@ -169,7 +170,8 @@ const anyBiomeEncounters: MysteryEncounterType[] = [ MysteryEncounterType.TRASH_TO_TREASURE, MysteryEncounterType.BERRIES_ABOUND, MysteryEncounterType.CLOWNING_AROUND, - MysteryEncounterType.WEIRD_DREAM + MysteryEncounterType.WEIRD_DREAM, + MysteryEncounterType.TELEPORTING_HIJINKS ]; /** @@ -273,6 +275,7 @@ export function initMysteryEncounters() { allMysteryEncounters[MysteryEncounterType.DANCING_LESSONS] = DancingLessonsEncounter; allMysteryEncounters[MysteryEncounterType.WEIRD_DREAM] = WeirdDreamEncounter; allMysteryEncounters[MysteryEncounterType.THE_WINSTRATE_CHALLENGE] = TheWinstrateChallengeEncounter; + allMysteryEncounters[MysteryEncounterType.TELEPORTING_HIJINKS] = TeleportingHijinksEncounter; // Add extreme encounters to biome map extremeBiomeEncounters.forEach(encounter => { diff --git a/src/enums/mystery-encounter-type.ts b/src/enums/mystery-encounter-type.ts index f9871a1a3dd..b36a2c4ce41 100644 --- a/src/enums/mystery-encounter-type.ts +++ b/src/enums/mystery-encounter-type.ts @@ -23,5 +23,6 @@ export enum MysteryEncounterType { PART_TIMER, DANCING_LESSONS, WEIRD_DREAM, - THE_WINSTRATE_CHALLENGE + THE_WINSTRATE_CHALLENGE, + TELEPORTING_HIJINKS } diff --git a/src/locales/en/mystery-encounter.ts b/src/locales/en/mystery-encounter.ts index 2779900eeff..bb3bc043e75 100644 --- a/src/locales/en/mystery-encounter.ts +++ b/src/locales/en/mystery-encounter.ts @@ -23,6 +23,7 @@ import { partTimerDialogue } from "#app/locales/en/mystery-encounters/part-timer import { dancingLessonsDialogue } from "#app/locales/en/mystery-encounters/dancing-lessons-dialogue"; import { weirdDreamDialogue } from "#app/locales/en/mystery-encounters/weird-dream-dialogue"; import { theWinstrateChallengeDialogue } from "#app/locales/en/mystery-encounters/the-winstrate-challenge-dialogue"; +import { teleportingHijinksDialogue } from "#app/locales/en/mystery-encounters/teleporting-hijinks-dialogue"; /** * Injection patterns that can be used: @@ -73,5 +74,6 @@ export const mysteryEncounter = { partTimer: partTimerDialogue, dancingLessons: dancingLessonsDialogue, weirdDream: weirdDreamDialogue, - theWinstrateChallenge: theWinstrateChallengeDialogue + theWinstrateChallenge: theWinstrateChallengeDialogue, + teleportingHijinks: teleportingHijinksDialogue } as const; diff --git a/src/locales/en/mystery-encounters/teleporting-hijinks-dialogue.ts b/src/locales/en/mystery-encounters/teleporting-hijinks-dialogue.ts new file mode 100644 index 00000000000..2cf13e21882 --- /dev/null +++ b/src/locales/en/mystery-encounters/teleporting-hijinks-dialogue.ts @@ -0,0 +1,31 @@ +export const teleportingHijinksDialogue = { + intro: "It's a strange machine, whirring noisily...", + title: "Teleportating Hijinks", + description: "The machine has a sign on it that reads:\n \"To use, insert money then step into the capsule.\"\n\nPerhaps it can transport you somewhere...", + query: "What will you do?", + option: { + 1: { + label: "Put Money In", + tooltip: "(-) Pay {{price, money}}\n(?) Teleport to New Biome", + selected: "You insert some money, and the capsule opens.\nYou step inside...", + }, + 2: { + label: "A Pokémon Helps", + tooltip: "(-) {{option2PrimaryName}} Helps\n(+) {{option2PrimaryName}} gains EXP\n(?) Teleport to New Biome", + disabled_tooltip: "You need a Steel or Electric Type Pokémon to choose this", + selected: `{{option2PrimaryName}} uses its typing and overloads the machine! + $The capsule opens, and you step inside...` + }, + 3: { + label: "Inspect the Machine", + tooltip: "(-) Pokémon Battle", + selected: `You are drawn in by the blinking lights\nand strange noises coming from the machine... + $You don't even notice as a wild\nPokémon sneaks up and ambushes you!`, + }, + }, + transport: `The machine shakes violently,\nmaking all sorts of strange noises! + $Just as soon as it had started, it quiets once more.`, + attacked: `You step out into a completely new area, startling a wild Pokémon! + $The wild Pokémon attacks!`, + boss_enraged: "The opposing {{enemyPokemon}} has become enraged!" +}; diff --git a/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts b/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts new file mode 100644 index 00000000000..cd88846de7b --- /dev/null +++ b/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts @@ -0,0 +1,300 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounterTestUtils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import { TeleportingHijinksEncounter } from "#app/data/mystery-encounters/encounters/teleporting-hijinks-encounter"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; + +const namespace = "mysteryEncounter:teleportingHijinks"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Teleporting Hijinks - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + scene.money = 20000; + game.override.mysteryEncounterChance(100); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.TELEPORTING_HIJINKS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + + expect(TeleportingHijinksEncounter.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + expect(TeleportingHijinksEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(TeleportingHijinksEncounter.dialogue).toBeDefined(); + expect(TeleportingHijinksEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(TeleportingHijinksEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(TeleportingHijinksEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(TeleportingHijinksEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(TeleportingHijinksEncounter.options.length).toBe(3); + }); + + it("should not run below wave 10", async () => { + game.override.startingWave(9); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should run in waves that are X1", async () => { + game.override.startingWave(11); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should run in waves that are X2", async () => { + game.override.startingWave(32); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should run in waves that are X3", async () => { + game.override.startingWave(23); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should NOT run in waves that are not X1, X2, or X3", async () => { + game.override.startingWave(54); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).not.toBe(MysteryEncounterType.TELEPORTING_HIJINKS); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = TeleportingHijinksEncounter; + + const { onInit } = TeleportingHijinksEncounter; + + expect(TeleportingHijinksEncounter.onInit).toBeDefined(); + + TeleportingHijinksEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + + expect(TeleportingHijinksEncounter.misc.price).toBeDefined(); + expect(TeleportingHijinksEncounter.dialogueTokens.price).toBeDefined(); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Pay Money", () => { + it("should have the correct properties", () => { + const option = TeleportingHijinksEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should NOT be selectable if the player doesn't have enough money", async () => { + game.scene.money = 0; + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + scene.getParty().forEach(p => p.moveset = []); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 1); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should be selectable if the player has enough money", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + }); + + it("should transport to a new area", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + + const previousBiome = scene.arena.biomeType; + + await runMysteryEncounterToEnd(game, 1, undefined, true); + + expect(previousBiome).not.toBe(scene.arena.biomeType); + expect([Biome.SPACE, Biome.ISLAND, Biome.LABORATORY, Biome.FAIRY_CAVE]).toContain(scene.arena.biomeType); + }); + + it("should start a battle against an enraged boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + const enemyField = scene.getEnemyField(); + expect(enemyField[0].summonData.battleStats).toEqual([1, 1, 1, 1, 1, 0, 0]); + expect(enemyField[0].isBoss()).toBe(true); + }); + }); + + describe("Option 2 - Attempt to Steal", () => { + it("should have the correct properties", () => { + const option = TeleportingHijinksEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_SPECIAL); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + disabledButtonTooltip: `${namespace}.option.2.disabled_tooltip`, + selected: [ + { + text: `${namespace}.option.2.selected`, + } + ], + }); + }); + + it("should NOT be selectable if the player doesn't the right type pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.BLASTOISE]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should be selectable if the player has the right type pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.METAGROSS]); + await runMysteryEncounterToEnd(game, 2, undefined, true); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + }); + + it("should transport to a new area", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.PIKACHU]); + + const previousBiome = scene.arena.biomeType; + + await runMysteryEncounterToEnd(game, 2, undefined, true); + + expect(previousBiome).not.toBe(scene.arena.biomeType); + expect([Biome.SPACE, Biome.ISLAND, Biome.LABORATORY, Biome.FAIRY_CAVE]).toContain(scene.arena.biomeType); + }); + + it("should start a battle against an enraged boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, [Species.PIKACHU]); + await runMysteryEncounterToEnd(game, 2, undefined, true); + const enemyField = scene.getEnemyField(); + expect(enemyField[0].summonData.battleStats).toEqual([1, 1, 1, 1, 1, 0, 0]); + expect(enemyField[0].isBoss()).toBe(true); + }); + }); + + describe("Option 3 - Inspect the Machine", () => { + it("should have the correct properties", () => { + const option = TeleportingHijinksEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + text: `${namespace}.option.3.selected`, + }, + ], + }); + }); + + it("should start a battle against a boss", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 3, undefined, true); + const enemyField = scene.getEnemyField(); + expect(enemyField[0].summonData.battleStats).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(enemyField[0].isBoss()).toBe(true); + }); + + it("should have Magnet and Metal Coat in rewards after battle", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.TELEPORTING_HIJINKS, defaultParty); + await runMysteryEncounterToEnd(game, 3, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === "Metal Coat")).toBe(true); + expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === "Magnet")).toBe(true); + }); + }); +});