Merge branch 'beta' into trick

This commit is contained in:
Eucalyptus 2024-09-09 23:57:36 -04:00 committed by GitHub
commit 876d0894be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 431 additions and 48 deletions

View File

@ -70,7 +70,6 @@ const content = `import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager"; import GameManager from "#test/utils/gameManager";
import { SPLASH_ONLY } from "#test/utils/testUtils";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest";
@ -95,7 +94,7 @@ describe("${description}", () => {
.moveset([Moves.SPLASH]) .moveset([Moves.SPLASH])
.battleType("single") .battleType("single")
.enemyAbility(Abilities.BALL_FETCH) .enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(SPLASH_ONLY); .enemyMoveset(Moves.SPLASH);
}); });
it("test case", async () => { it("test case", async () => {

View File

@ -2193,8 +2193,14 @@ export default class BattleScene extends SceneBase {
return true; return true;
} }
findPhase(phaseFilter: (phase: Phase) => boolean): Phase | undefined { /**
return this.phaseQueue.find(phaseFilter); * Find a specific {@linkcode Phase} in the phase queue.
*
* @param phaseFilter filter function to use to find the wanted phase
* @returns the found phase or undefined if none found
*/
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
return this.phaseQueue.find(phaseFilter) as P;
} }
tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean { tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean {

View File

@ -1595,8 +1595,8 @@ export class PostAttackAbAttr extends AbAttr {
private attackCondition: PokemonAttackCondition; private attackCondition: PokemonAttackCondition;
/** The default attackCondition requires that the selected move is a damaging move */ /** The default attackCondition requires that the selected move is a damaging move */
constructor(attackCondition: PokemonAttackCondition = (user, target, move) => (move.category !== MoveCategory.STATUS)) { constructor(attackCondition: PokemonAttackCondition = (user, target, move) => (move.category !== MoveCategory.STATUS), showAbility: boolean = true) {
super(); super(showAbility);
this.attackCondition = attackCondition; this.attackCondition = attackCondition;
} }
@ -1624,6 +1624,40 @@ export class PostAttackAbAttr extends AbAttr {
} }
} }
/**
* Ability attribute for Gorilla Tactics
* @extends PostAttackAbAttr
*/
export class GorillaTacticsAbAttr extends PostAttackAbAttr {
constructor() {
super((user, target, move) => true, false);
}
/**
*
* @param {Pokemon} pokemon the {@linkcode Pokemon} with this ability
* @param passive n/a
* @param simulated whether the ability is being simulated
* @param defender n/a
* @param move n/a
* @param hitResult n/a
* @param args n/a
* @returns `true` if the ability is applied
*/
applyPostAttackAfterMoveTypeCheck(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean | Promise<boolean> {
if (simulated) {
return simulated;
}
if (pokemon.getTag(BattlerTagType.GORILLA_TACTICS)) {
return false;
}
pokemon.addTag(BattlerTagType.GORILLA_TACTICS);
return true;
}
}
export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr {
private stealCondition: PokemonAttackCondition | null; private stealCondition: PokemonAttackCondition | null;
@ -5597,7 +5631,7 @@ export function initAbilities() {
.bypassFaint() .bypassFaint()
.partial(), .partial(),
new Ability(Abilities.GORILLA_TACTICS, 8) new Ability(Abilities.GORILLA_TACTICS, 8)
.unimplemented(), .attr(GorillaTacticsAbAttr),
new Ability(Abilities.NEUTRALIZING_GAS, 8) new Ability(Abilities.NEUTRALIZING_GAS, 8)
.attr(SuppressFieldAbilitiesAbAttr) .attr(SuppressFieldAbilitiesAbAttr)
.attr(UncopiableAbilityAbAttr) .attr(UncopiableAbilityAbAttr)

View File

@ -119,7 +119,9 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag {
const move = phase.move; const move = phase.move;
if (this.isMoveRestricted(move.moveId)) { if (this.isMoveRestricted(move.moveId)) {
if (this.interruptedText(pokemon, move.moveId)) {
pokemon.scene.queueMessage(this.interruptedText(pokemon, move.moveId)); pokemon.scene.queueMessage(this.interruptedText(pokemon, move.moveId));
}
phase.cancel(); phase.cancel();
} }
@ -155,7 +157,9 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag {
* @param {Moves} move {@linkcode Moves} ID of the move being interrupted * @param {Moves} move {@linkcode Moves} ID of the move being interrupted
* @returns {string} text to display when the move is interrupted * @returns {string} text to display when the move is interrupted
*/ */
abstract interruptedText(pokemon: Pokemon, move: Moves): string; interruptedText(pokemon: Pokemon, move: Moves): string {
return "";
}
} }
/** /**
@ -221,7 +225,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
/** /**
* @override * @override
* *
* Ensures that move history exists on `pokemon` and has a valid move. If so, sets the {@link moveId} and shows a message. * Ensures that move history exists on `pokemon` and has a valid move. If so, sets the {@linkcode moveId} and shows a message.
* Otherwise the move ID will not get assigned and this tag will get removed next turn. * Otherwise the move ID will not get assigned and this tag will get removed next turn.
*/ */
override onAdd(pokemon: Pokemon): void { override onAdd(pokemon: Pokemon): void {
@ -250,7 +254,12 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
return i18next.t("battle:moveDisabled", { moveName: allMoves[move].name }); return i18next.t("battle:moveDisabled", { moveName: allMoves[move].name });
} }
/** @override */ /**
* @override
* @param {Pokemon} pokemon {@linkcode Pokemon} attempting to use the restricted move
* @param {Moves} move {@linkcode Moves} ID of the move being interrupted
* @returns {string} text to display when the move is interrupted
*/
override interruptedText(pokemon: Pokemon, move: Moves): string { override interruptedText(pokemon: Pokemon, move: Moves): string {
return i18next.t("battle:disableInterruptedMove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: allMoves[move].name }); return i18next.t("battle:disableInterruptedMove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: allMoves[move].name });
} }
@ -262,6 +271,72 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
} }
} }
/**
* Tag used by Gorilla Tactics to restrict the user to using only one move.
* @extends MoveRestrictionBattlerTag
*/
export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
private moveId = Moves.NONE;
constructor() {
super(BattlerTagType.GORILLA_TACTICS, BattlerTagLapseType.CUSTOM, 0);
}
/** @override */
override isMoveRestricted(move: Moves): boolean {
return move !== this.moveId;
}
/**
* @override
* @param {Pokemon} pokemon the {@linkcode Pokemon} to check if the tag can be added
* @returns `true` if the pokemon has a valid move and no existing {@linkcode GorillaTacticsTag}; `false` otherwise
*/
override canAdd(pokemon: Pokemon): boolean {
return (this.getLastValidMove(pokemon) !== undefined) && !pokemon.getTag(GorillaTacticsTag);
}
/**
* Ensures that move history exists on {@linkcode Pokemon} and has a valid move.
* If so, sets the {@linkcode moveId} and increases the user's Attack by 50%.
* @override
* @param {Pokemon} pokemon the {@linkcode Pokemon} to add the tag to
*/
override onAdd(pokemon: Pokemon): void {
const lastValidMove = this.getLastValidMove(pokemon);
if (!lastValidMove) {
return;
}
this.moveId = lastValidMove;
pokemon.setStat(Stat.ATK, pokemon.getStat(Stat.ATK, false) * 1.5, false);
}
/**
*
* @override
* @param {Pokemon} pokemon n/a
* @param {Moves} move {@linkcode Moves} ID of the move being denied
* @returns {string} text to display when the move is denied
*/
override selectionDeniedText(pokemon: Pokemon, move: Moves): string {
return i18next.t("battle:canOnlyUseMove", { moveName: allMoves[this.moveId].name, pokemonName: getPokemonNameWithAffix(pokemon) });
}
/**
* Gets the last valid move from the pokemon's move history.
* @param {Pokemon} pokemon {@linkcode Pokemon} to get the last valid move from
* @returns {Moves | undefined} the last valid move from the pokemon's move history
*/
getLastValidMove(pokemon: Pokemon): Moves | undefined {
const move = pokemon.getLastXMoves()
.find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual);
return move?.move;
}
}
/** /**
* BattlerTag that represents the "recharge" effects of moves like Hyper Beam. * BattlerTag that represents the "recharge" effects of moves like Hyper Beam.
*/ */
@ -2203,6 +2278,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new TarShotTag(); return new TarShotTag();
case BattlerTagType.THROAT_CHOPPED: case BattlerTagType.THROAT_CHOPPED:
return new ThroatChoppedTag(); return new ThroatChoppedTag();
case BattlerTagType.GORILLA_TACTICS:
return new GorillaTacticsTag();
case BattlerTagType.NONE: case BattlerTagType.NONE:
default: default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);

View File

@ -6273,12 +6273,42 @@ export class VariableTargetAttr extends MoveAttr {
} }
} }
/**
* Attribute for {@linkcode Moves.AFTER_YOU}
*
* [After You - Move | Bulbapedia](https://bulbapedia.bulbagarden.net/wiki/After_You_(move))
*/
export class AfterYouAttr extends MoveEffectAttr {
/**
* Allows the target of this move to act right after the user.
*
* @param user {@linkcode Pokemon} that is using the move.
* @param target {@linkcode Pokemon} that will move right after this move is used.
* @param move {@linkcode Move} {@linkcode Moves.AFTER_YOU}
* @param _args N/A
* @returns true
*/
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
user.scene.queueMessage(i18next.t("moveTriggers:afterYou", {targetName: getPokemonNameWithAffix(target)}));
//Will find next acting phase of the targeted pokémon, delete it and queue it next on successful delete.
const nextAttackPhase = target.scene.findPhase<MovePhase>((phase) => phase.pokemon === target);
if (nextAttackPhase && target.scene.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
target.scene.prependToPhase(new MovePhase(target.scene, target, [...nextAttackPhase.targets], nextAttackPhase.move), MovePhase);
}
return true;
}
}
const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !user.scene.arena.getTag(ArenaTagType.GRAVITY); const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !user.scene.arena.getTag(ArenaTagType.GRAVITY);
const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune(); const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
const failOnMaxCondition: MoveConditionFunc = (user, target, move) => !target.isMax(); const failOnMaxCondition: MoveConditionFunc = (user, target, move) => !target.isMax();
const failIfSingleBattle: MoveConditionFunc = (user, target, move) => user.scene.currentBattle.double;
const failIfDampCondition: MoveConditionFunc = (user, target, move) => { const failIfDampCondition: MoveConditionFunc = (user, target, move) => {
const cancelled = new Utils.BooleanHolder(false); const cancelled = new Utils.BooleanHolder(false);
user.scene.getField(true).map(p=>applyAbAttrs(FieldPreventExplosiveMovesAbAttr, p, cancelled)); user.scene.getField(true).map(p=>applyAbAttrs(FieldPreventExplosiveMovesAbAttr, p, cancelled));
@ -8010,7 +8040,10 @@ export function initMoves() {
.attr(AbilityGiveAttr), .attr(AbilityGiveAttr),
new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5) new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresProtect() .ignoresProtect()
.unimplemented(), .target(MoveTarget.NEAR_OTHER)
.condition(failIfSingleBattle)
.condition((user, target, move) => !target.turnData.acted)
.attr(AfterYouAttr),
new AttackMove(Moves.ROUND, Type.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) new AttackMove(Moves.ROUND, Type.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5)
.soundBased() .soundBased()
.partial(), .partial(),

View File

@ -73,6 +73,7 @@ export enum BattlerTagType {
SHELL_TRAP = "SHELL_TRAP", SHELL_TRAP = "SHELL_TRAP",
DRAGON_CHEER = "DRAGON_CHEER", DRAGON_CHEER = "DRAGON_CHEER",
NO_RETREAT = "NO_RETREAT", NO_RETREAT = "NO_RETREAT",
GORILLA_TACTICS = "GORILLA_TACTICS",
THROAT_CHOPPED = "THROAT_CHOPPED", THROAT_CHOPPED = "THROAT_CHOPPED",
TAR_SHOT = "TAR_SHOT", TAR_SHOT = "TAR_SHOT",
} }

View File

@ -3,5 +3,6 @@
"power": "Stärke", "power": "Stärke",
"accuracy": "Genauigkeit", "accuracy": "Genauigkeit",
"abilityFlyInText": "{{passive}}{{abilityName}} von {{pokemonName}} wirkt!", "abilityFlyInText": "{{passive}}{{abilityName}} von {{pokemonName}} wirkt!",
"passive": "Passive Fähigkeit " "passive": "Passive Fähigkeit ",
"teraHover": "Tera-Typ {{type}}"
} }

View File

@ -67,5 +67,7 @@
"swapArenaTags": "{{pokemonName}} hat die Effekte, die auf den beiden Seiten des Kampffeldes wirken, miteinander getauscht!", "swapArenaTags": "{{pokemonName}} hat die Effekte, die auf den beiden Seiten des Kampffeldes wirken, miteinander getauscht!",
"trickOnSwap": "{{pokemonNameWithAffix}} tauscht Items mit dem Ziel!", "trickOnSwap": "{{pokemonNameWithAffix}} tauscht Items mit dem Ziel!",
"trickFoeNewItem": "{{pokemonNameWithAffix}} erhält {{itemName}}.", "trickFoeNewItem": "{{pokemonNameWithAffix}} erhält {{itemName}}.",
"safeguard": "{{targetName}} wird durch Bodyguard geschützt!" "exposedMove": "{{pokemonName}} erkennt {{targetPokemonName}}!",
"safeguard": "{{targetName}} wird durch Bodyguard geschützt!",
"afterYou": "{{targetName}} lässt sich auf Galanterie ein!"
} }

View File

@ -44,6 +44,7 @@
"moveNotImplemented": "{{moveName}} is not yet implemented and cannot be selected.", "moveNotImplemented": "{{moveName}} is not yet implemented and cannot be selected.",
"moveNoPP": "There's no PP left for\nthis move!", "moveNoPP": "There's no PP left for\nthis move!",
"moveDisabled": "{{moveName}} is disabled!", "moveDisabled": "{{moveName}} is disabled!",
"canOnlyUseMove": "{{pokemonName}} can only use {{moveName}}!",
"moveCannotBeSelected": "{{moveName}} cannot be selected!", "moveCannotBeSelected": "{{moveName}} cannot be selected!",
"disableInterruptedMove": "{{pokemonNameWithAffix}}'s {{moveName}}\nis disabled!", "disableInterruptedMove": "{{pokemonNameWithAffix}}'s {{moveName}}\nis disabled!",
"throatChopInterruptedMove": "The effects of Throat Chop prevent\n{{pokemonName}} from using certain moves!", "throatChopInterruptedMove": "The effects of Throat Chop prevent\n{{pokemonName}} from using certain moves!",

View File

@ -3,5 +3,6 @@
"power": "Power", "power": "Power",
"accuracy": "Accuracy", "accuracy": "Accuracy",
"abilityFlyInText": " {{pokemonName}}'s {{passive}}{{abilityName}}", "abilityFlyInText": " {{pokemonName}}'s {{passive}}{{abilityName}}",
"passive": "Passive " "passive": "Passive ",
"teraHover": "{{type}} Terastallized"
} }

View File

@ -68,5 +68,6 @@
"swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!", "swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!",
"trickOnSwap": "{{pokemonNameWithAffix}} switched items with its target!", "trickOnSwap": "{{pokemonNameWithAffix}} switched items with its target!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!", "exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
"safeguard": "{{targetName}} is protected by Safeguard!" "safeguard": "{{targetName}} is protected by Safeguard!",
"afterYou": "{{pokemonName}} took the kind offer!"
} }

View File

@ -0,0 +1,83 @@
import { BattlerIndex } from "#app/battle";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat";
import { Abilities } from "#enums/abilities";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Gorilla Tactics", () => {
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("single")
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset([Moves.SPLASH, Moves.DISABLE])
.enemySpecies(Species.MAGIKARP)
.enemyLevel(30)
.moveset([Moves.SPLASH, Moves.TACKLE, Moves.GROWL])
.ability(Abilities.GORILLA_TACTICS);
});
it("boosts the Pokémon's Attack by 50%, but limits the Pokémon to using only one move", async () => {
await game.classicMode.startBattle([Species.GALAR_DARMANITAN]);
const darmanitan = game.scene.getPlayerPokemon()!;
const initialAtkStat = darmanitan.getStat(Stat.ATK);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(darmanitan.getStat(Stat.ATK, false)).toBeCloseTo(initialAtkStat * 1.5);
// Other moves should be restricted
expect(darmanitan.isMoveRestricted(Moves.TACKLE)).toBe(true);
expect(darmanitan.isMoveRestricted(Moves.SPLASH)).toBe(false);
}, TIMEOUT);
it("should struggle if the only usable move is disabled", async () => {
await game.classicMode.startBattle([Species.GALAR_DARMANITAN]);
const darmanitan = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
// First turn, lock move to Growl
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.SPLASH);
// Second turn, Growl is interrupted by Disable
await game.toNextTurn();
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.DISABLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getStatStage(Stat.ATK)).toBe(-1); // Only the effect of the first Growl should be applied
// Third turn, Struggle is used
await game.toNextTurn();
game.move.select(Moves.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
expect(darmanitan.hp).toBeLessThan(darmanitan.getMaxHp());
}, TIMEOUT);
});

View File

@ -0,0 +1,65 @@
import { BattlerIndex } from "#app/battle";
import { Abilities } from "#app/enums/abilities";
import { MoveResult } from "#app/field/pokemon";
import { MovePhase } from "#app/phases/move-phase";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
const TIMEOUT = 20 * 1000;
describe("Moves - After You", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleType("double")
.enemyLevel(5)
.enemySpecies(Species.PIKACHU)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH)
.ability(Abilities.BALL_FETCH)
.moveset([Moves.AFTER_YOU, Moves.SPLASH]);
});
it("makes the target move immediately after the user", async () => {
await game.classicMode.startBattle([Species.REGIELEKI, Species.SHUCKLE]);
game.move.select(Moves.AFTER_YOU, 0, BattlerIndex.PLAYER_2);
game.move.select(Moves.SPLASH, 1);
await game.phaseInterceptor.to("MoveEffectPhase");
await game.phaseInterceptor.to(MovePhase, false);
const phase = game.scene.getCurrentPhase() as MovePhase;
expect(phase.pokemon).toBe(game.scene.getPlayerField()[1]);
await game.phaseInterceptor.to("MoveEndPhase");
}, TIMEOUT);
it("fails if target already moved", async () => {
game.override.enemySpecies(Species.SHUCKLE);
await game.classicMode.startBattle([Species.REGIELEKI, Species.PIKACHU]);
game.move.select(Moves.SPLASH);
game.move.select(Moves.AFTER_YOU, 1, BattlerIndex.PLAYER);
await game.phaseInterceptor.to("MoveEndPhase");
await game.phaseInterceptor.to("MoveEndPhase");
await game.phaseInterceptor.to(MovePhase);
expect(game.scene.getPlayerField()[1].getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
}, TIMEOUT);
});

View File

@ -3,7 +3,6 @@ import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat"; import { Stat } from "#app/enums/stat";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import GameManager from "#test/utils/gameManager"; import GameManager from "#test/utils/gameManager";
import { SPLASH_ONLY } from "#test/utils/testUtils";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -29,7 +28,7 @@ describe("Moves - Power Shift", () => {
.battleType("single") .battleType("single")
.ability(Abilities.BALL_FETCH) .ability(Abilities.BALL_FETCH)
.enemyAbility(Abilities.BALL_FETCH) .enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(SPLASH_ONLY); .enemyMoveset(Moves.SPLASH);
}); });
it("switches the user's raw Attack stat with its raw Defense stat", async () => { it("switches the user's raw Attack stat with its raw Defense stat", async () => {

View File

@ -5,7 +5,6 @@ import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat"; import { Stat } from "#app/enums/stat";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import GameManager from "#test/utils/gameManager"; import GameManager from "#test/utils/gameManager";
import { SPLASH_ONLY } from "#test/utils/testUtils";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -29,7 +28,7 @@ describe("Moves - Tar Shot", () => {
game.override game.override
.battleType("single") .battleType("single")
.enemyAbility(Abilities.BALL_FETCH) .enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(SPLASH_ONLY) .enemyMoveset(Moves.SPLASH)
.enemySpecies(Species.TANGELA) .enemySpecies(Species.TANGELA)
.enemyLevel(1000) .enemyLevel(1000)
.moveset([Moves.TAR_SHOT, Moves.FIRE_PUNCH]) .moveset([Moves.TAR_SHOT, Moves.FIRE_PUNCH])

View File

@ -36,12 +36,14 @@ describe("Moves - Throat Chop", () => {
it("prevents the target from using sound-based moves for two turns", async () => { it("prevents the target from using sound-based moves for two turns", async () => {
await game.classicMode.startBattle([Species.MAGIKARP]); await game.classicMode.startBattle([Species.MAGIKARP]);
const enemy = game.scene.getEnemyPokemon()!;
game.move.select(Moves.GROWL); game.move.select(Moves.GROWL);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
// First turn, move is interrupted // First turn, move is interrupted
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.ATK)).toBe(0); expect(enemy.getStatStage(Stat.ATK)).toBe(0);
// Second turn, struggle if no valid moves // Second turn, struggle if no valid moves
await game.toNextTurn(); await game.toNextTurn();
@ -50,6 +52,6 @@ describe("Moves - Throat Chop", () => {
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase"); await game.phaseInterceptor.to("MoveEndPhase");
expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false); expect(enemy.isFullHp()).toBe(false);
}, TIMEOUT); }, TIMEOUT);
}); });

View File

@ -52,9 +52,8 @@ export default class MockContainer implements MockGameObject {
/// Sets the position of this Game Object to be a relative position from the source Game Object. /// Sets the position of this Game Object to be a relative position from the source Game Object.
} }
setInteractive(hitArea?, callback?, dropZone?) { setInteractive = vi.fn();
/// Sets the InteractiveObject to be a drop zone for a drag and drop operation.
}
setOrigin(x, y) { setOrigin(x, y) {
this.x = x; this.x = x;
this.y = y; this.y = y;

View File

@ -1,5 +1,6 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { MockGameObject } from "../mockGameObject"; import { MockGameObject } from "../mockGameObject";
import { vi } from "vitest";
import Sprite = Phaser.GameObjects.Sprite; import Sprite = Phaser.GameObjects.Sprite;
import Frame = Phaser.Textures.Frame; import Frame = Phaser.Textures.Frame;
@ -101,9 +102,7 @@ export default class MockSprite implements MockGameObject {
return this.phaserSprite.stop(); return this.phaserSprite.stop();
} }
setInteractive(hitArea, hitAreaCallback, dropZone) { setInteractive = vi.fn();
return null;
}
on(event, callback, source) { on(event, callback, source) {
return this.phaserSprite.on(event, callback, source); return this.phaserSprite.on(event, callback, source);

View File

@ -197,6 +197,8 @@ export default class MockText implements MockGameObject {
this.color = color; this.color = color;
}); });
setInteractive = vi.fn();
setShadowColor(color) { setShadowColor(color) {
// Sets the shadow color. // Sets the shadow color.
// return this.phaserText.setShadowColor(color); // return this.phaserText.setShadowColor(color);

View File

@ -323,7 +323,7 @@ export default class BattleInfo extends Phaser.GameObjects.Container {
this.teraIcon.setVisible(this.lastTeraType !== Type.UNKNOWN); this.teraIcon.setVisible(this.lastTeraType !== Type.UNKNOWN);
this.teraIcon.on("pointerover", () => { this.teraIcon.on("pointerover", () => {
if (this.lastTeraType !== Type.UNKNOWN) { if (this.lastTeraType !== Type.UNKNOWN) {
(this.scene as BattleScene).ui.showTooltip("", `${Utils.toReadableString(Type[this.lastTeraType])} Terastallized`); (this.scene as BattleScene).ui.showTooltip("", i18next.t("fightUiHandler:teraHover", {type: i18next.t(`pokemonInfo:Type.${Type[this.lastTeraType]}`) }));
} }
}); });
this.teraIcon.on("pointerout", () => (this.scene as BattleScene).ui.hideTooltip()); this.teraIcon.on("pointerout", () => (this.scene as BattleScene).ui.hideTooltip());

View File

@ -266,6 +266,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
private pokemonPassiveDisabledIcon: Phaser.GameObjects.Sprite; private pokemonPassiveDisabledIcon: Phaser.GameObjects.Sprite;
private pokemonPassiveLockedIcon: Phaser.GameObjects.Sprite; private pokemonPassiveLockedIcon: Phaser.GameObjects.Sprite;
private activeTooltip: "ABILITY" | "PASSIVE" | "CANDY" | undefined;
private instructionsContainer: Phaser.GameObjects.Container; private instructionsContainer: Phaser.GameObjects.Container;
private filterInstructionsContainer: Phaser.GameObjects.Container; private filterInstructionsContainer: Phaser.GameObjects.Container;
private shinyIconElement: Phaser.GameObjects.Sprite; private shinyIconElement: Phaser.GameObjects.Sprite;
@ -561,10 +562,13 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonAbilityLabelText = addTextObject(this.scene, 6, 127 + starterInfoYOffset, i18next.t("starterSelectUiHandler:ability"), TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize }); this.pokemonAbilityLabelText = addTextObject(this.scene, 6, 127 + starterInfoYOffset, i18next.t("starterSelectUiHandler:ability"), TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize });
this.pokemonAbilityLabelText.setOrigin(0, 0); this.pokemonAbilityLabelText.setOrigin(0, 0);
this.pokemonAbilityLabelText.setVisible(false); this.pokemonAbilityLabelText.setVisible(false);
this.starterSelectContainer.add(this.pokemonAbilityLabelText); this.starterSelectContainer.add(this.pokemonAbilityLabelText);
this.pokemonAbilityText = addTextObject(this.scene, starterInfoXPos, 127 + starterInfoYOffset, "", TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize }); this.pokemonAbilityText = addTextObject(this.scene, starterInfoXPos, 127 + starterInfoYOffset, "", TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize });
this.pokemonAbilityText.setOrigin(0, 0); this.pokemonAbilityText.setOrigin(0, 0);
this.pokemonAbilityText.setInteractive(new Phaser.Geom.Rectangle(0, 0, 250, 55), Phaser.Geom.Rectangle.Contains);
this.starterSelectContainer.add(this.pokemonAbilityText); this.starterSelectContainer.add(this.pokemonAbilityText);
this.pokemonPassiveLabelText = addTextObject(this.scene, 6, 136 + starterInfoYOffset, i18next.t("starterSelectUiHandler:passive"), TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize }); this.pokemonPassiveLabelText = addTextObject(this.scene, 6, 136 + starterInfoYOffset, i18next.t("starterSelectUiHandler:passive"), TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize });
@ -574,6 +578,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonPassiveText = addTextObject(this.scene, starterInfoXPos, 136 + starterInfoYOffset, "", TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize }); this.pokemonPassiveText = addTextObject(this.scene, starterInfoXPos, 136 + starterInfoYOffset, "", TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize });
this.pokemonPassiveText.setOrigin(0, 0); this.pokemonPassiveText.setOrigin(0, 0);
this.pokemonPassiveText.setInteractive(new Phaser.Geom.Rectangle(0, 0, 250, 55), Phaser.Geom.Rectangle.Contains);
this.starterSelectContainer.add(this.pokemonPassiveText); this.starterSelectContainer.add(this.pokemonPassiveText);
this.pokemonPassiveDisabledIcon = this.scene.add.sprite(starterInfoXPos, 137 + starterInfoYOffset, "icon_stop"); this.pokemonPassiveDisabledIcon = this.scene.add.sprite(starterInfoXPos, 137 + starterInfoYOffset, "icon_stop");
@ -1921,6 +1926,14 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
} }
} while (newAbilityIndex !== this.abilityCursor); } while (newAbilityIndex !== this.abilityCursor);
starterAttributes.ability = newAbilityIndex; // store the selected ability starterAttributes.ability = newAbilityIndex; // store the selected ability
const { visible: tooltipVisible } = this.scene.ui.getTooltip();
if (tooltipVisible && this.activeTooltip === "ABILITY") {
const newAbility = allAbilities[this.lastSpecies.getAbility(newAbilityIndex)];
this.scene.ui.editTooltip(`${newAbility.name}`, `${newAbility.description}`);
}
this.setSpeciesDetails(this.lastSpecies, undefined, undefined, undefined, undefined, newAbilityIndex, undefined); this.setSpeciesDetails(this.lastSpecies, undefined, undefined, undefined, undefined, newAbilityIndex, undefined);
success = true; success = true;
} }
@ -2687,12 +2700,30 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
} }
} }
getFriendship(speciesId: number) {
let currentFriendship = this.scene.gameData.starterData[speciesId].friendship;
if (!currentFriendship || currentFriendship === undefined) {
currentFriendship = 0;
}
const friendshipCap = getStarterValueFriendshipCap(speciesStarters[speciesId]);
return { currentFriendship, friendshipCap };
}
setSpecies(species: PokemonSpecies | null) { setSpecies(species: PokemonSpecies | null) {
this.speciesStarterDexEntry = species ? this.scene.gameData.dexData[species.speciesId] : null; this.speciesStarterDexEntry = species ? this.scene.gameData.dexData[species.speciesId] : null;
this.dexAttrCursor = species ? this.getCurrentDexProps(species.speciesId) : 0n; this.dexAttrCursor = species ? this.getCurrentDexProps(species.speciesId) : 0n;
this.abilityCursor = species ? this.scene.gameData.getStarterSpeciesDefaultAbilityIndex(species) : 0; this.abilityCursor = species ? this.scene.gameData.getStarterSpeciesDefaultAbilityIndex(species) : 0;
this.natureCursor = species ? this.scene.gameData.getSpeciesDefaultNature(species) : 0; this.natureCursor = species ? this.scene.gameData.getSpeciesDefaultNature(species) : 0;
if (!species && this.scene.ui.getTooltip().visible) {
this.scene.ui.hideTooltip();
}
this.pokemonAbilityText.off("pointerover");
this.pokemonPassiveText.off("pointerover");
const starterAttributes : StarterAttributes | null = species ? {...this.starterPreferences[species.speciesId]} : null; const starterAttributes : StarterAttributes | null = species ? {...this.starterPreferences[species.speciesId]} : null;
if (starterAttributes?.nature) { if (starterAttributes?.nature) {
@ -2807,17 +2838,18 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonHatchedIcon.setVisible(true); this.pokemonHatchedIcon.setVisible(true);
this.pokemonHatchedCountText.setVisible(true); this.pokemonHatchedCountText.setVisible(true);
let currentFriendship = this.scene.gameData.starterData[this.lastSpecies.speciesId].friendship; const { currentFriendship, friendshipCap } = this.getFriendship(this.lastSpecies.speciesId);
if (!currentFriendship || currentFriendship === undefined) {
currentFriendship = 0;
}
const friendshipCap = getStarterValueFriendshipCap(speciesStarters[this.lastSpecies.speciesId]);
const candyCropY = 16 - (16 * (currentFriendship / friendshipCap)); const candyCropY = 16 - (16 * (currentFriendship / friendshipCap));
if (this.pokemonCandyDarknessOverlay.visible) { if (this.pokemonCandyDarknessOverlay.visible) {
this.pokemonCandyDarknessOverlay.on("pointerover", () => (this.scene as BattleScene).ui.showTooltip("", `${currentFriendship}/${friendshipCap}`, true)); this.pokemonCandyDarknessOverlay.on("pointerover", () => {
this.pokemonCandyDarknessOverlay.on("pointerout", () => (this.scene as BattleScene).ui.hideTooltip()); this.scene.ui.showTooltip("", `${currentFriendship}/${friendshipCap}`, true);
this.activeTooltip = "CANDY";
});
this.pokemonCandyDarknessOverlay.on("pointerout", () => {
this.scene.ui.hideTooltip();
this.activeTooltip = undefined;
});
} }
this.pokemonCandyDarknessOverlay.setCrop(0, 0, 16, candyCropY); this.pokemonCandyDarknessOverlay.setCrop(0, 0, 16, candyCropY);
@ -2932,6 +2964,11 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.abilityCursor = -1; this.abilityCursor = -1;
this.natureCursor = -1; this.natureCursor = -1;
if (this.activeTooltip === "CANDY") {
const { currentFriendship, friendshipCap } = this.getFriendship(this.lastSpecies.speciesId);
this.scene.ui.editTooltip("", `${currentFriendship}/${friendshipCap}`);
}
if (species?.forms?.find(f => f.formKey === "female")) { if (species?.forms?.find(f => f.formKey === "female")) {
if (female !== undefined) { if (female !== undefined) {
formIndex = female ? 1 : 0; formIndex = female ? 1 : 0;
@ -3081,8 +3118,8 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
} }
if (dexEntry.caughtAttr) { if (dexEntry.caughtAttr) {
const ability = this.lastSpecies.getAbility(abilityIndex!); // TODO: is this bang correct? const ability = allAbilities[this.lastSpecies.getAbility(abilityIndex!)]; // TODO: is this bang correct?
this.pokemonAbilityText.setText(allAbilities[ability].name); this.pokemonAbilityText.setText(ability.name);
const isHidden = abilityIndex === (this.lastSpecies.ability2 ? 2 : 1); const isHidden = abilityIndex === (this.lastSpecies.ability2 ? 2 : 1);
this.pokemonAbilityText.setColor(this.getTextColor(!isHidden ? TextStyle.SUMMARY_ALT : TextStyle.SUMMARY_GOLD)); this.pokemonAbilityText.setColor(this.getTextColor(!isHidden ? TextStyle.SUMMARY_ALT : TextStyle.SUMMARY_GOLD));
@ -3091,6 +3128,21 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
const passiveAttr = this.scene.gameData.starterData[species.speciesId].passiveAttr; const passiveAttr = this.scene.gameData.starterData[species.speciesId].passiveAttr;
const passiveAbility = allAbilities[starterPassiveAbilities[this.lastSpecies.speciesId]]; const passiveAbility = allAbilities[starterPassiveAbilities[this.lastSpecies.speciesId]];
if (this.pokemonAbilityText.visible) {
if (this.activeTooltip === "ABILITY") {
this.scene.ui.editTooltip(`${ability.name}`, `${ability.description}`);
}
this.pokemonAbilityText.on("pointerover", () => {
this.scene.ui.showTooltip(`${ability.name}`, `${ability.description}`, true);
this.activeTooltip = "ABILITY";
});
this.pokemonAbilityText.on("pointerout", () => {
this.scene.ui.hideTooltip();
this.activeTooltip = undefined;
});
}
if (passiveAbility) { if (passiveAbility) {
const isUnlocked = !!(passiveAttr & PassiveAttr.UNLOCKED); const isUnlocked = !!(passiveAttr & PassiveAttr.UNLOCKED);
const isEnabled = !!(passiveAttr & PassiveAttr.ENABLED); const isEnabled = !!(passiveAttr & PassiveAttr.ENABLED);
@ -3107,6 +3159,21 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonPassiveText.setAlpha(textAlpha); this.pokemonPassiveText.setAlpha(textAlpha);
this.pokemonPassiveText.setShadowColor(this.getTextColor(textStyle, true)); this.pokemonPassiveText.setShadowColor(this.getTextColor(textStyle, true));
if (this.activeTooltip === "PASSIVE") {
this.scene.ui.editTooltip(`${passiveAbility.name}`, `${passiveAbility.description}`);
}
if (this.pokemonPassiveText.visible) {
this.pokemonPassiveText.on("pointerover", () => {
this.scene.ui.showTooltip(`${passiveAbility.name}`, `${passiveAbility.description}`, true);
this.activeTooltip = "PASSIVE";
});
this.pokemonPassiveText.on("pointerout", () => {
this.scene.ui.hideTooltip();
this.activeTooltip = undefined;
});
}
const iconPosition = { const iconPosition = {
x: this.pokemonPassiveText.x + this.pokemonPassiveText.displayWidth + 1, x: this.pokemonPassiveText.x + this.pokemonPassiveText.displayWidth + 1,
y: this.pokemonPassiveText.y + this.pokemonPassiveText.displayHeight / 2 y: this.pokemonPassiveText.y + this.pokemonPassiveText.displayHeight / 2

View File

@ -244,7 +244,7 @@ export default class UI extends Phaser.GameObjects.Container {
this.tooltipContent = addTextObject(this.scene, 6, 16, "", TextStyle.TOOLTIP_CONTENT); this.tooltipContent = addTextObject(this.scene, 6, 16, "", TextStyle.TOOLTIP_CONTENT);
this.tooltipContent.setName("text-tooltip-content"); this.tooltipContent.setName("text-tooltip-content");
this.tooltipContent.setWordWrapWidth(696); this.tooltipContent.setWordWrapWidth(850);
this.tooltipContainer.add(this.tooltipBg); this.tooltipContainer.add(this.tooltipBg);
this.tooltipContainer.add(this.tooltipTitle); this.tooltipContainer.add(this.tooltipTitle);
@ -368,14 +368,13 @@ export default class UI extends Phaser.GameObjects.Container {
return false; return false;
} }
getTooltip(): { visible: boolean; title: string; content: string } {
return { visible: this.tooltipContainer.visible, title: this.tooltipTitle.text, content: this.tooltipContent.text };
}
showTooltip(title: string, content: string, overlap?: boolean): void { showTooltip(title: string, content: string, overlap?: boolean): void {
this.tooltipContainer.setVisible(true); this.tooltipContainer.setVisible(true);
this.tooltipTitle.setText(title || ""); this.editTooltip(title, content);
const wrappedContent = this.tooltipContent.runWordWrap(content);
this.tooltipContent.setText(wrappedContent);
this.tooltipContent.y = title ? 16 : 4;
this.tooltipBg.width = Math.min(Math.max(this.tooltipTitle.displayWidth, this.tooltipContent.displayWidth) + 12, 684);
this.tooltipBg.height = (title ? 31 : 19) + 10.5 * (wrappedContent.split("\n").length - 1);
if (overlap) { if (overlap) {
(this.scene as BattleScene).uiContainer.moveAbove(this.tooltipContainer, this); (this.scene as BattleScene).uiContainer.moveAbove(this.tooltipContainer, this);
} else { } else {
@ -383,6 +382,15 @@ export default class UI extends Phaser.GameObjects.Container {
} }
} }
editTooltip(title: string, content: string): void {
this.tooltipTitle.setText(title || "");
const wrappedContent = this.tooltipContent.runWordWrap(content);
this.tooltipContent.setText(wrappedContent);
this.tooltipContent.y = title ? 16 : 4;
this.tooltipBg.width = Math.min(Math.max(this.tooltipTitle.displayWidth, this.tooltipContent.displayWidth) + 12, 838);
this.tooltipBg.height = (title ? 31 : 19) + 10.5 * (wrappedContent.split("\n").length - 1);
}
hideTooltip(): void { hideTooltip(): void {
this.tooltipContainer.setVisible(false); this.tooltipContainer.setVisible(false);
this.tooltipTitle.clearTint(); this.tooltipTitle.clearTint();
@ -390,8 +398,12 @@ export default class UI extends Phaser.GameObjects.Container {
update(): void { update(): void {
if (this.tooltipContainer.visible) { if (this.tooltipContainer.visible) {
const reverse = this.scene.game.input.mousePointer && this.scene.game.input.mousePointer.x >= this.scene.game.canvas.width - this.tooltipBg.width * 6 - 12; const xReverse = this.scene.game.input.mousePointer && this.scene.game.input.mousePointer.x >= this.scene.game.canvas.width - this.tooltipBg.width * 6 - 12;
this.tooltipContainer.setPosition(!reverse ? this.scene.game.input.mousePointer!.x / 6 + 2 : this.scene.game.input.mousePointer!.x / 6 - this.tooltipBg.width - 2, this.scene.game.input.mousePointer!.y / 6 + 2); // TODO: are these bangs correct? const yReverse = this.scene.game.input.mousePointer && this.scene.game.input.mousePointer.y >= this.scene.game.canvas.height - this.tooltipBg.height * 6 - 12;
this.tooltipContainer.setPosition(
!xReverse ? this.scene.game.input.mousePointer!.x / 6 + 2 : this.scene.game.input.mousePointer!.x / 6 - this.tooltipBg.width - 2,
!yReverse ? this.scene.game.input.mousePointer!.y / 6 + 2 : this.scene.game.input.mousePointer!.y / 6 - this.tooltipBg.height - 2,
);
} }
} }