Merge branch 'beta' into move/tar_shot

This commit is contained in:
Adrian T. 2024-09-06 21:58:46 +08:00 committed by GitHub
commit 83579d8b33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 464 additions and 12421 deletions

View File

@ -0,0 +1,22 @@
{
"1": {
"529cc5": "8153c7",
"d65a94": "5ad662",
"3a73ad": "6b3aad",
"bd216b": "21bd69",
"5a193a": "195a2a",
"193a63": "391963",
"295a84": "472984"
},
"2": {
"529cc5": "ffedb6",
"d65a94": "e67d2f",
"3a73ad": "ebc582",
"bd216b": "b35131",
"31313a": "3d1519",
"5a193a": "752e2e",
"193a63": "705040",
"295a84": "ad875a",
"4a4a52": "57211a"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@ -1691,8 +1691,8 @@
],
"465": [
0,
2,
2
1,
1
],
"466": [
1,
@ -3980,6 +3980,11 @@
1,
1
],
"465": [
0,
1,
1
],
"592": [
1,
1,
@ -5690,7 +5695,7 @@
"465": [
0,
1,
2
1
],
"466": [
2,
@ -8008,6 +8013,11 @@
1,
1
],
"465": [
0,
1,
1
],
"592": [
1,
1,

View File

@ -8,5 +8,14 @@
"bd216b": "21bd69",
"31313a": "31313a",
"d65a94": "5ad662"
},
"2": {
"5a193a": "752e2e",
"31313a": "3d1519",
"d65a94": "e67d2f",
"3a73ad": "ebc582",
"295a84": "ad875a",
"bd216b": "b35131",
"193a63": "705040"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,21 @@
{
"1": {
"193a63": "391963",
"295a84": "472984",
"3a73ad": "6b3aad",
"000000": "000000",
"5a193a": "195a2a",
"bd216b": "21bd69",
"31313a": "31313a",
"d65a94": "5ad662"
},
"2": {
"5a193a": "752e2e",
"31313a": "3d1519",
"d65a94": "e67d2f",
"3a73ad": "ebc582",
"295a84": "ad875a",
"bd216b": "b35131",
"193a63": "705040"
}
}

View File

@ -0,0 +1,22 @@
{
"1": {
"529cc5": "8153c7",
"d65a94": "5ad662",
"3a73ad": "6b3aad",
"bd216b": "21bd69",
"5a193a": "195a2a",
"193a63": "391963",
"295a84": "472984"
},
"2": {
"529cc5": "ffedb6",
"d65a94": "e67d2f",
"3a73ad": "ebc582",
"bd216b": "b35131",
"31313a": "3d1519",
"5a193a": "752e2e",
"193a63": "705040",
"295a84": "ad875a",
"4a4a52": "57211a"
}
}

View File

@ -855,7 +855,7 @@ export default class BattleScene extends SceneBase {
overrideModifiers(this, false);
overrideHeldItems(this, pokemon, false);
if (boss && !dataSource) {
const secondaryIvs = Utils.getIvsFromId(Utils.randSeedInt(4294967295));
const secondaryIvs = Utils.getIvsFromId(Utils.randSeedInt(4294967296));
for (let s = 0; s < pokemon.ivs.length; s++) {
pokemon.ivs[s] = Math.round(Phaser.Math.Linear(Math.min(pokemon.ivs[s], secondaryIvs[s]), Math.max(pokemon.ivs[s], secondaryIvs[s]), 0.75));
@ -961,6 +961,16 @@ export default class BattleScene extends SceneBase {
this.offsetGym = this.gameMode.isClassic && this.getGeneratedOffsetGym();
}
/**
* Generates a random number using the current battle's seed
*
* This calls {@linkcode Battle.randSeedInt}(`scene`, {@linkcode range}, {@linkcode min}) in `src/battle.ts`
* which calls {@linkcode Utils.randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`
*
* @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min}
* @param min The minimum integer to pick, default `0`
* @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1)
*/
randBattleSeedInt(range: integer, min: integer = 0): integer {
return this.currentBattle?.randSeedInt(this, range, min);
}
@ -1112,7 +1122,8 @@ export default class BattleScene extends SceneBase {
doubleTrainer = false;
}
}
newTrainer = trainerData !== undefined ? trainerData.toTrainer(this) : new Trainer(this, trainerType, doubleTrainer ? TrainerVariant.DOUBLE : Utils.randSeedInt(2) ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT);
const variant = doubleTrainer ? TrainerVariant.DOUBLE : (Utils.randSeedInt(2) ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT);
newTrainer = trainerData !== undefined ? trainerData.toTrainer(this) : new Trainer(this, trainerType, variant);
this.field.add(newTrainer);
}
}
@ -2620,7 +2631,7 @@ export default class BattleScene extends SceneBase {
if (mods.length < 1) {
return mods;
}
const rand = Math.floor(Utils.randSeedInt(mods.length));
const rand = Utils.randSeedInt(mods.length);
return [mods[rand], ...shuffleModifiers(mods.filter((_, i) => i !== rand))];
};
modifiers = shuffleModifiers(modifiers);

View File

@ -354,6 +354,12 @@ export default class Battle {
return null;
}
/**
* Generates a random number using the current battle's seed. Calls {@linkcode Utils.randSeedInt}
* @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min}
* @param min The minimum integer to pick, default `0`
* @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1)
*/
randSeedInt(scene: BattleScene, range: number, min: number = 0): number {
if (range <= 1) {
return min;

View File

@ -2642,7 +2642,7 @@ export class ConfusionOnStatusEffectAbAttr extends PostAttackAbAttr {
if (simulated) {
return defender.canAddTag(BattlerTagType.CONFUSED);
} else {
return defender.addTag(BattlerTagType.CONFUSED, pokemon.randSeedInt(3, 2), move.id, defender.id);
return defender.addTag(BattlerTagType.CONFUSED, pokemon.randSeedIntRange(2, 5), move.id, defender.id);
}
}
return false;
@ -5333,8 +5333,10 @@ export function initAbilities() {
.attr(FieldMoveTypePowerBoostAbAttr, Type.FAIRY, 4 / 3),
new Ability(Abilities.AURA_BREAK, 6)
.ignorable()
.conditionalAttr(target => target.hasAbility(Abilities.DARK_AURA), FieldMoveTypePowerBoostAbAttr, Type.DARK, 9 / 16)
.conditionalAttr(target => target.hasAbility(Abilities.FAIRY_AURA), FieldMoveTypePowerBoostAbAttr, Type.FAIRY, 9 / 16),
.conditionalAttr(pokemon => pokemon.scene.getField(true).some(p => p.hasAbility(Abilities.DARK_AURA)), FieldMoveTypePowerBoostAbAttr, Type.DARK, 9 / 16)
.conditionalAttr(pokemon => pokemon.scene.getField(true).some(p => p.hasAbility(Abilities.FAIRY_AURA)), FieldMoveTypePowerBoostAbAttr, Type.FAIRY, 9 / 16)
.conditionalAttr(pokemon => pokemon.scene.getField(true).some(p => p.hasAbility(Abilities.DARK_AURA) || p.hasAbility(Abilities.FAIRY_AURA)),
PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAuraBreak", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })),
new Ability(Abilities.PRIMORDIAL_SEA, 6)
.attr(PostSummonWeatherChangeAbAttr, WeatherType.HEAVY_RAIN)
.attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.HEAVY_RAIN)

View File

@ -486,7 +486,7 @@ export class ConfusedTag extends BattlerTag {
if (pokemon.randSeedInt(3) === 0) {
const atk = pokemon.getEffectiveStat(Stat.ATK);
const def = pokemon.getEffectiveStat(Stat.DEF);
const damage = Utils.toDmgValue(((((2 * pokemon.level / 5 + 2) * 40 * atk / def) / 50) + 2) * (pokemon.randSeedInt(15, 85) / 100));
const damage = Utils.toDmgValue(((((2 * pokemon.level / 5 + 2) * 40 * atk / def) / 50) + 2) * (pokemon.randSeedIntRange(85, 100) / 100));
pokemon.scene.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself"));
pokemon.damageAndUpdate(damage);
pokemon.battleData.hitCount++;

View File

@ -757,7 +757,10 @@ export default class Move implements Localizable {
const fieldAuras = new Set(
source.scene.getField(true)
.map((p) => p.getAbilityAttrs(FieldMoveTypePowerBoostAbAttr) as FieldMoveTypePowerBoostAbAttr[])
.map((p) => p.getAbilityAttrs(FieldMoveTypePowerBoostAbAttr).filter(attr => {
const condition = attr.getCondition();
return (!condition || condition(p));
}) as FieldMoveTypePowerBoostAbAttr[])
.flat(),
);
for (const aura of fieldAuras) {
@ -4400,7 +4403,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) {
return (this.selfTarget ? user : target).addTag(this.tagType, user.randSeedInt(this.turnCountMax - this.turnCountMin, this.turnCountMin), move.id, user.id);
return (this.selfTarget ? user : target).addTag(this.tagType, user.randSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id);
}
return false;
@ -9072,8 +9075,7 @@ export function initMoves() {
.attr(TeraBlastCategoryAttr)
.attr(TeraBlastTypeAttr)
.attr(TeraBlastPowerAttr)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR))
.partial(),
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR)),
new SelfStatusMove(Moves.SILK_TRAP, Type.BUG, -1, 10, -1, 4, 9)
.attr(ProtectAttr, BattlerTagType.SILK_TRAP)
.condition(failIfLastCondition),

View File

@ -1724,7 +1724,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
};
this.fusionSpecies = this.scene.randomSpecies(this.scene.currentBattle?.waveIndex || 0, this.level, false, filter, true);
this.fusionAbilityIndex = (this.fusionSpecies.abilityHidden && hasHiddenAbility ? this.fusionSpecies.ability2 ? 2 : 1 : this.fusionSpecies.ability2 ? randAbilityIndex : 0);
this.fusionAbilityIndex = (this.fusionSpecies.abilityHidden && hasHiddenAbility ? 2 : this.fusionSpecies.ability2 !== this.fusionSpecies.ability1 ? randAbilityIndex : 0);
this.fusionShiny = this.shiny;
this.fusionVariant = this.variant;
@ -2282,7 +2282,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (!isTypeImmune) {
const levelMultiplier = (2 * source.level / 5 + 2);
const randomMultiplier = ((this.scene.randBattleSeedInt(16) + 85) / 100);
const randomMultiplier = (this.randSeedIntRange(85, 100) / 100);
damage.value = Utils.toDmgValue((((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2)
* stabMultiplier.value
* typeMultiplier
@ -3452,12 +3452,30 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
fusionCanvas.remove();
}
/**
* Generates a random number using the current battle's seed, or the global seed if `this.scene.currentBattle` is falsy
* <!-- @import "../battle".Battle -->
* This calls either {@linkcode BattleScene.randBattleSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle-scene.ts`
* which calls {@linkcode Battle.randSeedInt}(`scene`, {@linkcode range}, {@linkcode min}) in `src/battle.ts`
* which calls {@linkcode Utils.randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`,
* or it directly calls {@linkcode Utils.randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts` if there is no current battle
*
* @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min}
* @param min The minimum integer to pick, default `0`
* @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1)
*/
randSeedInt(range: integer, min: integer = 0): integer {
return this.scene.currentBattle
? this.scene.randBattleSeedInt(range, min)
: Utils.randSeedInt(range, min);
}
/**
* Generates a random number using the current battle's seed, or the global seed if `this.scene.currentBattle` is falsy
* @param min The minimum integer to generate
* @param max The maximum integer to generate
* @returns a random integer between {@linkcode min} and {@linkcode max} inclusive
*/
randSeedIntRange(min: integer, max: integer): integer {
return this.randSeedInt((max - min) + 1, min);
}

View File

@ -52,6 +52,7 @@
"postSummonTeravolt": "{{pokemonNameWithAffix}} is radiating a bursting aura!",
"postSummonDarkAura": "{{pokemonNameWithAffix}} is radiating a Dark Aura!",
"postSummonFairyAura": "{{pokemonNameWithAffix}} is radiating a Fairy Aura!",
"postSummonAuraBreak": "{{pokemonNameWithAffix}} reversed all other Pokémon's auras!",
"postSummonNeutralizingGas": "{{pokemonNameWithAffix}}'s Neutralizing Gas filled the area!",
"postSummonAsOneGlastrier": "{{pokemonNameWithAffix}} has two Abilities!",
"postSummonAsOneSpectrier": "{{pokemonNameWithAffix}} has two Abilities!",

View File

@ -51,5 +51,7 @@
"renamePokemon": "Rename Pokémon",
"rename": "Rename",
"nickname": "Nickname",
"errorServerDown": "Oops! There was an issue contacting the server.\n\nYou may leave this window open,\nthe game will automatically reconnect."
"errorServerDown": "Oops! There was an issue contacting the server.\n\nYou may leave this window open,\nthe game will automatically reconnect.",
"noSaves": "You don't have any save files on record!",
"tooManySaves": "You have too many save files on record!"
}

View File

@ -377,16 +377,16 @@ export class MoveEffectPhase extends PokemonPhase {
return false;
}
const moveAccuracy = this.move.getMove().calculateBattleAccuracy(user!, target); // TODO: is the bang correct here?
const moveAccuracy = this.move.getMove().calculateBattleAccuracy(user, target);
if (moveAccuracy === -1) {
return true;
}
const accuracyMultiplier = user.getAccuracyMultiplier(target, this.move.getMove());
const rand = user.randSeedInt(100, 1);
const rand = user.randSeedInt(100);
return rand <= moveAccuracy * (accuracyMultiplier!); // TODO: is this bang correct?
return rand < (moveAccuracy * accuracyMultiplier);
}
/** Returns the {@linkcode Pokemon} using this phase's invoked move */

View File

@ -857,6 +857,14 @@ export class GameData {
const settings = JSON.parse(localStorage.getItem("settings")!); // TODO: is this bang correct?
// TODO: Remove this block after save migration is implemented
if (settings.hasOwnProperty("REROLL_TARGET") && !settings.hasOwnProperty(SettingKeys.Shop_Cursor_Target)) {
settings[SettingKeys.Shop_Cursor_Target] = settings["REROLL_TARGET"];
delete settings["REROLL_TARGET"];
localStorage.setItem("settings", JSON.stringify(settings));
}
// End of block to remove
for (const setting of Object.keys(settings)) {
setSetting(this.scene, setting, settings[setting]);
}

View File

@ -1,5 +1,4 @@
import { allMoves } from "#app/data/move";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
@ -33,31 +32,45 @@ describe("Abilities - Aura Break", () => {
game.override.enemySpecies(Species.SHUCKLE);
});
it("reverses the effect of fairy aura", async () => {
it("reverses the effect of Fairy Aura", async () => {
const moveToCheck = allMoves[Moves.MOONBLAST];
const basePower = moveToCheck.power;
game.override.ability(Abilities.FAIRY_AURA);
vi.spyOn(moveToCheck, "calculateBattlePower");
await game.startBattle([Species.PIKACHU]);
await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.MOONBLAST);
await game.phaseInterceptor.to(MoveEffectPhase);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattlePower).toHaveReturnedWith(expect.closeTo(basePower * auraBreakMultiplier));
});
it("reverses the effect of dark aura", async () => {
it("reverses the effect of Dark Aura", async () => {
const moveToCheck = allMoves[Moves.DARK_PULSE];
const basePower = moveToCheck.power;
game.override.ability(Abilities.DARK_AURA);
vi.spyOn(moveToCheck, "calculateBattlePower");
await game.startBattle([Species.PIKACHU]);
await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.DARK_PULSE);
await game.phaseInterceptor.to(MoveEffectPhase);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattlePower).toHaveReturnedWith(expect.closeTo(basePower * auraBreakMultiplier));
});
it("has no effect if neither Fairy Aura nor Dark Aura are present", async () => {
const moveToCheck = allMoves[Moves.MOONBLAST];
const basePower = moveToCheck.power;
game.override.ability(Abilities.BALL_FETCH);
vi.spyOn(moveToCheck, "calculateBattlePower");
await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.MOONBLAST);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattlePower).toHaveReturnedWith(basePower);
});
});

View File

@ -0,0 +1,101 @@
import { BattlerIndex } from "#app/battle";
import { Type } from "#app/data/type";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import { Abilities } from "#enums/abilities";
import GameManager from "#test/utils/gameManager";
import { SPLASH_ONLY } from "#test/utils/testUtils";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Dragon Cheer", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const TIMEOUT = 20 * 1000;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleType("double")
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(SPLASH_ONLY)
.enemyLevel(20)
.moveset([Moves.DRAGON_CHEER, Moves.TACKLE, Moves.SPLASH]);
});
it("increases the user's allies' critical hit ratio by one stage", async () => {
await game.classicMode.startBattle([Species.DRAGONAIR, Species.MAGIKARP]);
const enemy = game.scene.getEnemyField()[0];
vi.spyOn(enemy, "getCritStage");
game.move.select(Moves.DRAGON_CHEER, 0);
game.move.select(Moves.TACKLE, 1, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
// After Tackle
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getCritStage).toHaveReturnedWith(1); // getCritStage is called on defender
}, TIMEOUT);
it("increases the user's Dragon-type allies' critical hit ratio by two stages", async () => {
await game.classicMode.startBattle([Species.MAGIKARP, Species.DRAGONAIR]);
const enemy = game.scene.getEnemyField()[0];
vi.spyOn(enemy, "getCritStage");
game.move.select(Moves.DRAGON_CHEER, 0);
game.move.select(Moves.TACKLE, 1, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
// After Tackle
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getCritStage).toHaveReturnedWith(2); // getCritStage is called on defender
}, TIMEOUT);
it("applies the effect based on the allies' type upon use of the move, and do not change if the allies' type changes later in battle", async () => {
await game.classicMode.startBattle([Species.DRAGONAIR, Species.MAGIKARP]);
const magikarp = game.scene.getPlayerField()[1];
const enemy = game.scene.getEnemyField()[0];
vi.spyOn(enemy, "getCritStage");
game.move.select(Moves.DRAGON_CHEER, 0);
game.move.select(Moves.TACKLE, 1, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
// After Tackle
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getCritStage).toHaveReturnedWith(1); // getCritStage is called on defender
await game.toNextTurn();
// Change Magikarp's type to Dragon
vi.spyOn(magikarp, "getTypes").mockReturnValue([Type.DRAGON]);
expect(magikarp.getTypes()).toEqual([Type.DRAGON]);
game.move.select(Moves.SPLASH, 0);
game.move.select(Moves.TACKLE, 1, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("MoveEndPhase");
expect(enemy.getCritStage).toHaveReturnedWith(1); // getCritStage is called on defender
}, TIMEOUT);
});

View File

@ -1,13 +1,12 @@
import { allMoves } from "#app/data/move";
import { Abilities } from "#app/enums/abilities";
import { DamagePhase } from "#app/phases/damage-phase";
import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
const TIMEOUT = 20 * 1000;
describe("Moves - Glaive Rush", () => {
let phaserGame: Phaser.Game;
@ -25,131 +24,142 @@ describe("Moves - Glaive Rush", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.battleType("single");
game.override.disableCrits();
game.override.enemySpecies(Species.MAGIKARP);
game.override.enemyAbility(Abilities.BALL_FETCH);
game.override.enemyMoveset(Array(4).fill(Moves.GLAIVE_RUSH));
game.override.starterSpecies(Species.KLINK);
game.override.ability(Abilities.UNNERVE);
game.override.passiveAbility(Abilities.FUR_COAT);
game.override.moveset([Moves.SHADOW_SNEAK, Moves.AVALANCHE, Moves.SPLASH, Moves.GLAIVE_RUSH]);
game.override
.battleType("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Array(4).fill(Moves.GLAIVE_RUSH))
.starterSpecies(Species.KLINK)
.ability(Abilities.BALL_FETCH)
.moveset([Moves.SHADOW_SNEAK, Moves.AVALANCHE, Moves.SPLASH, Moves.GLAIVE_RUSH]);
});
it("takes double damage from attacks", async () => {
await game.startBattle();
await game.classicMode.startBattle();
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1000;
vi.spyOn(game.scene, "randBattleSeedInt").mockReturnValue(0);
game.move.select(Moves.SHADOW_SNEAK);
await game.phaseInterceptor.to(DamagePhase);
await game.phaseInterceptor.to("DamagePhase");
const damageDealt = 1000 - enemy.hp;
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.select(Moves.SHADOW_SNEAK);
await game.phaseInterceptor.to(DamagePhase);
await game.phaseInterceptor.to("DamagePhase");
expect(enemy.hp).toBeLessThanOrEqual(1001 - (damageDealt * 3));
}, 5000); // TODO: revert back to 20s
}, TIMEOUT);
it("always gets hit by attacks", async () => {
await game.startBattle();
await game.classicMode.startBattle();
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1000;
allMoves[Moves.AVALANCHE].accuracy = 0;
game.move.select(Moves.AVALANCHE);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.hp).toBeLessThan(1000);
}, 20000);
}, TIMEOUT);
it("interacts properly with multi-lens", async () => {
game.override.startingHeldItems([{ name: "MULTI_LENS", count: 2 }]);
game.override.enemyMoveset(Array(4).fill(Moves.AVALANCHE));
await game.startBattle();
game.override
.startingHeldItems([{ name: "MULTI_LENS", count: 2 }])
.enemyMoveset(Array(4).fill(Moves.AVALANCHE));
await game.classicMode.startBattle();
const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1000;
player.hp = 1000;
allMoves[Moves.AVALANCHE].accuracy = 0;
game.move.select(Moves.GLAIVE_RUSH);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.hp).toBeLessThan(1000);
player.hp = 1000;
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.hp).toBe(1000);
}, 20000);
}, TIMEOUT);
it("secondary effects only last until next move", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.SHADOW_SNEAK));
await game.startBattle();
await game.classicMode.startBattle();
const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1000;
player.hp = 1000;
allMoves[Moves.SHADOW_SNEAK].accuracy = 0;
game.move.select(Moves.GLAIVE_RUSH);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.hp).toBe(1000);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
const damagedHp = player.hp;
expect(player.hp).toBeLessThan(1000);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.hp).toBe(damagedHp);
}, 20000);
}, TIMEOUT);
it("secondary effects are removed upon switching", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.SHADOW_SNEAK));
game.override.starterSpecies(0);
await game.startBattle([Species.KLINK, Species.FEEBAS]);
game.override
.enemyMoveset(Array(4).fill(Moves.SHADOW_SNEAK))
.starterSpecies(0);
await game.classicMode.startBattle([Species.KLINK, Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1000;
allMoves[Moves.SHADOW_SNEAK].accuracy = 0;
game.move.select(Moves.GLAIVE_RUSH);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.hp).toBe(player.getMaxHp());
game.doSwitchPokemon(1);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
game.doSwitchPokemon(1);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.hp).toBe(player.getMaxHp());
}, 20000);
}, TIMEOUT);
it("secondary effects don't activate if move fails", async () => {
game.override.moveset([Moves.SHADOW_SNEAK, Moves.PROTECT, Moves.SPLASH, Moves.GLAIVE_RUSH]);
await game.startBattle();
await game.classicMode.startBattle();
const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1000;
player.hp = 1000;
game.move.select(Moves.PROTECT);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.select(Moves.SHADOW_SNEAK);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
game.override.enemyMoveset(Array(4).fill(Moves.SPLASH));
const damagedHP1 = 1000 - enemy.hp;
enemy.hp = 1000;
game.move.select(Moves.SHADOW_SNEAK);
await game.phaseInterceptor.to(TurnEndPhase);
await game.phaseInterceptor.to("TurnEndPhase");
const damagedHP2 = 1000 - enemy.hp;
expect(damagedHP2).toBeGreaterThanOrEqual((damagedHP1 * 2) - 1);
}, 20000);
}, TIMEOUT);
});

View File

@ -76,7 +76,7 @@ export default class GameManager {
constructor(phaserGame: Phaser.Game, bypassLogin: boolean = true) {
localStorage.clear();
ErrorInterceptor.getInstance().clear();
BattleScene.prototype.randBattleSeedInt = (arg) => arg-1;
BattleScene.prototype.randBattleSeedInt = (range, min: number = 0) => min + range - 1; // This simulates a max roll
this.gameWrapper = new GameWrapper(phaserGame, bypassLogin);
this.scene = new BattleScene();
this.phaseInterceptor = new PhaseInterceptor(this.scene);

View File

@ -208,4 +208,5 @@ export default class MockContainer implements MockGameObject {
return this.list;
}
disableInteractive = vi.fn();
}

View File

@ -8,7 +8,21 @@ import { addTextObject, TextStyle } from "./text";
import { addWindow } from "./ui-theme";
import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler";
interface BuildInteractableImageOpts {
scale?: number;
x?: number;
y?: number;
origin?: { x: number; y: number };
}
export default class LoginFormUiHandler extends FormModalUiHandler {
private readonly ERR_USERNAME: string = "invalid username";
private readonly ERR_PASSWORD: string = "invalid password";
private readonly ERR_ACCOUNT_EXIST: string = "account doesn't exist";
private readonly ERR_PASSWORD_MATCH: string = "password doesn't match";
private readonly ERR_NO_SAVES: string = "No save files found";
private readonly ERR_TOO_MANY_SAVES: string = "Too many save files found";
private googleImage: Phaser.GameObjects.Image;
private discordImage: Phaser.GameObjects.Image;
private usernameInfoImage: Phaser.GameObjects.Image;
@ -21,8 +35,23 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
}
setup(): void {
super.setup();
this.buildExternalPartyContainer();
this.infoContainer = this.scene.add.container(0, 0);
this.usernameInfoImage = this.buildInteractableImage("settings_icon", "username-info-icon", {
x: 20,
scale: 0.5
});
this.infoContainer.add(this.usernameInfoImage);
this.getUi().add(this.infoContainer);
this.infoContainer.setVisible(false);
this.infoContainer.disableInteractive();
}
private buildExternalPartyContainer() {
this.externalPartyContainer = this.scene.add.container(0, 0);
this.externalPartyContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, this.scene.game.canvas.width / 12, this.scene.game.canvas.height / 12), Phaser.Geom.Rectangle.Contains);
this.externalPartyTitle = addTextObject(this.scene, 0, 4, "", TextStyle.SETTINGS_LABEL);
@ -31,23 +60,8 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
this.externalPartyContainer.add(this.externalPartyBg);
this.externalPartyContainer.add(this.externalPartyTitle);
this.infoContainer = this.scene.add.container(0, 0);
this.infoContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, this.scene.game.canvas.width / 12, this.scene.game.canvas.height / 12), Phaser.Geom.Rectangle.Contains);
const googleImage = this.scene.add.image(0, 0, "google");
googleImage.setOrigin(0, 0);
googleImage.setScale(0.07);
googleImage.setInteractive();
googleImage.setName("google-icon");
this.googleImage = googleImage;
const discordImage = this.scene.add.image(20, 0, "discord");
discordImage.setOrigin(0, 0);
discordImage.setScale(0.07);
discordImage.setInteractive();
discordImage.setName("discord-icon");
this.discordImage = discordImage;
this.googleImage = this.buildInteractableImage("google", "google-icon");
this.discordImage = this.buildInteractableImage("discord", "discord-icon");
this.externalPartyContainer.add(this.googleImage);
this.externalPartyContainer.add(this.discordImage);
@ -55,59 +69,52 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
this.externalPartyContainer.add(this.googleImage);
this.externalPartyContainer.add(this.discordImage);
this.externalPartyContainer.setVisible(false);
const usernameInfoImage = this.scene.add.image(20, 0, "settings_icon");
usernameInfoImage.setOrigin(0, 0);
usernameInfoImage.setScale(0.5);
usernameInfoImage.setInteractive();
usernameInfoImage.setName("username-info-icon");
this.usernameInfoImage = usernameInfoImage;
this.infoContainer.add(this.usernameInfoImage);
this.getUi().add(this.infoContainer);
this.infoContainer.setVisible(false);
}
getModalTitle(config?: ModalConfig): string {
override getModalTitle(_config?: ModalConfig): string {
return i18next.t("menu:login");
}
getFields(config?: ModalConfig): string[] {
override getFields(_config?: ModalConfig): string[] {
return [ i18next.t("menu:username"), i18next.t("menu:password") ];
}
getWidth(config?: ModalConfig): number {
override getWidth(_config?: ModalConfig): number {
return 160;
}
getMargin(config?: ModalConfig): [number, number, number, number] {
override getMargin(_config?: ModalConfig): [number, number, number, number] {
return [ 0, 0, 48, 0 ];
}
getButtonLabels(config?: ModalConfig): string[] {
override getButtonLabels(_config?: ModalConfig): string[] {
return [ i18next.t("menu:login"), i18next.t("menu:register")];
}
getReadableErrorMessage(error: string): string {
override getReadableErrorMessage(error: string): string {
const colonIndex = error?.indexOf(":");
if (colonIndex > 0) {
error = error.slice(0, colonIndex);
}
switch (error) {
case "invalid username":
case this.ERR_USERNAME:
return i18next.t("menu:invalidLoginUsername");
case "invalid password":
case this.ERR_PASSWORD:
return i18next.t("menu:invalidLoginPassword");
case "account doesn't exist":
case this.ERR_ACCOUNT_EXIST:
return i18next.t("menu:accountNonExistent");
case "password doesn't match":
case this.ERR_PASSWORD_MATCH:
return i18next.t("menu:unmatchingPassword");
case this.ERR_NO_SAVES:
return i18next.t("menu:noSaves");
case this.ERR_TOO_MANY_SAVES:
return i18next.t("menu:tooManySaves");
}
return super.getReadableErrorMessage(error);
}
show(args: any[]): boolean {
override show(args: any[]): boolean {
if (super.show(args)) {
const config = args[0] as ModalConfig;
@ -148,17 +155,16 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
return false;
}
clear() {
override clear() {
super.clear();
this.externalPartyContainer.setVisible(false);
this.infoContainer.setVisible(false);
this.setMouseCursorStyle("default"); //reset cursor
this.discordImage.off("pointerdown");
this.googleImage.off("pointerdown");
this.usernameInfoImage.off("pointerdown");
[this.discordImage, this.googleImage, this.usernameInfoImage].forEach((img) => img.off("pointerdown"));
}
processExternalProvider(config: ModalConfig) : void {
private processExternalProvider(config: ModalConfig) : void {
this.externalPartyTitle.setText(i18next.t("menu:orUse") ?? "");
this.externalPartyTitle.setX(20+this.externalPartyTitle.text.length);
this.externalPartyTitle.setVisible(true);
@ -205,6 +211,7 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
label: dataKeys[i].replace(keyToFind, ""),
handler: () => {
this.scene.ui.revertMode();
this.infoContainer.disableInteractive();
return true;
}
});
@ -213,8 +220,13 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
options: options,
delay: 1000
});
this.infoContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, this.scene.game.canvas.width, this.scene.game.canvas.height), Phaser.Geom.Rectangle.Contains);
} else {
return onFail("You have too many save files to use this");
if (dataKeys.length > 2) {
return onFail(this.ERR_TOO_MANY_SAVES);
} else {
return onFail(this.ERR_NO_SAVES);
}
}
});
@ -236,4 +248,21 @@ export default class LoginFormUiHandler extends FormModalUiHandler {
alpha: 1
});
}
private buildInteractableImage(texture: string, name: string, opts: BuildInteractableImageOpts = {}) {
const {
scale = 0.07,
x = 0,
y = 0,
origin = { x: 0, y: 0 }
} = opts;
const img = this.scene.add.image(x, y, texture);
img.setName(name);
img.setOrigin(origin.x, origin.y);
img.setScale(scale);
img.setInteractive();
this.addInteractionHoverEffect(img);
return img;
}
}

View File

@ -57,29 +57,35 @@ export abstract class ModalUiHandler extends UiHandler {
const buttonLabels = this.getButtonLabels();
const buttonTopMargin = this.getButtonTopMargin();
for (const label of buttonLabels) {
const buttonLabel = addTextObject(this.scene, 0, 8, label, TextStyle.TOOLTIP_CONTENT);
buttonLabel.setOrigin(0.5, 0.5);
const buttonBg = addWindow(this.scene, 0, 0, buttonLabel.getBounds().width + 8, 16, false, false, 0, 0, WindowVariant.THIN);
buttonBg.setOrigin(0.5, 0);
buttonBg.setInteractive(new Phaser.Geom.Rectangle(0, 0, buttonBg.width, buttonBg.height), Phaser.Geom.Rectangle.Contains);
const buttonContainer = this.scene.add.container(0, buttonTopMargin);
this.buttonBgs.push(buttonBg);
this.buttonContainers.push(buttonContainer);
buttonContainer.add(buttonBg);
buttonContainer.add(buttonLabel);
this.modalContainer.add(buttonContainer);
this.addButton(label);
}
this.modalContainer.setVisible(false);
}
private addButton(label: string) {
const buttonTopMargin = this.getButtonTopMargin();
const buttonLabel = addTextObject(this.scene, 0, 8, label, TextStyle.TOOLTIP_CONTENT);
buttonLabel.setOrigin(0.5, 0.5);
const buttonBg = addWindow(this.scene, 0, 0, buttonLabel.getBounds().width + 8, 16, false, false, 0, 0, WindowVariant.THIN);
buttonBg.setOrigin(0.5, 0);
buttonBg.setInteractive(new Phaser.Geom.Rectangle(0, 0, buttonBg.width, buttonBg.height), Phaser.Geom.Rectangle.Contains);
const buttonContainer = this.scene.add.container(0, buttonTopMargin);
this.buttonBgs.push(buttonBg);
this.buttonContainers.push(buttonContainer);
buttonContainer.add(buttonBg);
buttonContainer.add(buttonLabel);
this.addInteractionHoverEffect(buttonBg);
this.modalContainer.add(buttonContainer);
}
show(args: any[]): boolean {
if (args.length >= 1 && "buttonActions" in args[0]) {
super.show(args);
@ -135,4 +141,20 @@ export abstract class ModalUiHandler extends UiHandler {
this.buttonBgs.map(bg => bg.off("pointerdown"));
}
/**
* Adds a hover effect to a game object which changes the cursor to a `pointer` and tints it slighly
* @param gameObject the game object to add hover events/effects to
*/
protected addInteractionHoverEffect(gameObject: Phaser.GameObjects.Image | Phaser.GameObjects.NineSlice | Phaser.GameObjects.Sprite) {
gameObject.on("pointerover", () => {
this.setMouseCursorStyle("pointer");
gameObject.setTint(0xbbbbbb);
});
gameObject.on("pointerout", () => {
this.setMouseCursorStyle("default");
gameObject.clearTint();
});
}
}

View File

@ -52,6 +52,15 @@ export default abstract class UiHandler {
return changed;
}
/**
* Changes the style of the mouse cursor.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/cursor}
* @param cursorStyle cursor style to apply
*/
protected setMouseCursorStyle(cursorStyle: "pointer" | "default") {
this.scene.input.manager.canvas.style.cursor = cursorStyle;
}
clear() {
this.active = false;
}

View File

@ -1,5 +1,5 @@
import i18next from "i18next";
import { MoneyFormat } from "#enums/money-format";
import i18next from "i18next";
export const MissingTextureKey = "__MISSING";
@ -82,6 +82,12 @@ export function randInt(range: integer, min: integer = 0): integer {
return Math.floor(Math.random() * range) + min;
}
/**
* Generates a random number using the global seed, or the current battle's seed if called via `Battle.randSeedInt`
* @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min}
* @param min The minimum integer to pick, default `0`
* @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1)
*/
export function randSeedInt(range: integer, min: integer = 0): integer {
if (range <= 1) {
return min;