diff --git a/create-test-boilerplate.js b/create-test-boilerplate.js
index 3e598384fee..d9cdbd4e7cf 100644
--- a/create-test-boilerplate.js
+++ b/create-test-boilerplate.js
@@ -70,7 +70,6 @@ const content = `import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
-import { SPLASH_ONLY } from "#test/utils/testUtils";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest";
@@ -95,7 +94,7 @@ describe("${description}", () => {
.moveset([Moves.SPLASH])
.battleType("single")
.enemyAbility(Abilities.BALL_FETCH)
- .enemyMoveset(SPLASH_ONLY);
+ .enemyMoveset(Moves.SPLASH);
});
it("test case", async () => {
diff --git a/src/battle-scene.ts b/src/battle-scene.ts
index ff4258a13f5..72778fa8589 100644
--- a/src/battle-scene.ts
+++ b/src/battle-scene.ts
@@ -2193,8 +2193,14 @@ export default class BattleScene extends SceneBase {
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
(phaseFilter: (phase: P) => boolean): P | undefined {
+ return this.phaseQueue.find(phaseFilter) as P;
}
tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean {
diff --git a/src/data/ability.ts b/src/data/ability.ts
index 1304f281285..6acf77cfca5 100755
--- a/src/data/ability.ts
+++ b/src/data/ability.ts
@@ -1595,8 +1595,8 @@ export class PostAttackAbAttr extends AbAttr {
private attackCondition: PokemonAttackCondition;
/** The default attackCondition requires that the selected move is a damaging move */
- constructor(attackCondition: PokemonAttackCondition = (user, target, move) => (move.category !== MoveCategory.STATUS)) {
- super();
+ constructor(attackCondition: PokemonAttackCondition = (user, target, move) => (move.category !== MoveCategory.STATUS), showAbility: boolean = true) {
+ super(showAbility);
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 {
+ if (simulated) {
+ return simulated;
+ }
+
+ if (pokemon.getTag(BattlerTagType.GORILLA_TACTICS)) {
+ return false;
+ }
+
+ pokemon.addTag(BattlerTagType.GORILLA_TACTICS);
+ return true;
+ }
+}
+
export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr {
private stealCondition: PokemonAttackCondition | null;
@@ -5597,7 +5631,7 @@ export function initAbilities() {
.bypassFaint()
.partial(),
new Ability(Abilities.GORILLA_TACTICS, 8)
- .unimplemented(),
+ .attr(GorillaTacticsAbAttr),
new Ability(Abilities.NEUTRALIZING_GAS, 8)
.attr(SuppressFieldAbilitiesAbAttr)
.attr(UncopiableAbilityAbAttr)
diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts
index 71385facb23..52e039ed874 100644
--- a/src/data/battler-tags.ts
+++ b/src/data/battler-tags.ts
@@ -119,7 +119,9 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag {
const move = phase.move;
if (this.isMoveRestricted(move.moveId)) {
- pokemon.scene.queueMessage(this.interruptedText(pokemon, move.moveId));
+ if (this.interruptedText(pokemon, move.moveId)) {
+ pokemon.scene.queueMessage(this.interruptedText(pokemon, move.moveId));
+ }
phase.cancel();
}
@@ -155,7 +157,9 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag {
* @param {Moves} move {@linkcode Moves} ID of the move being 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
*
- * 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.
*/
override onAdd(pokemon: Pokemon): void {
@@ -250,7 +254,12 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
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 {
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.
*/
@@ -2203,6 +2278,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new TarShotTag();
case BattlerTagType.THROAT_CHOPPED:
return new ThroatChoppedTag();
+ case BattlerTagType.GORILLA_TACTICS:
+ return new GorillaTacticsTag();
case BattlerTagType.NONE:
default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);
diff --git a/src/data/move.ts b/src/data/move.ts
index c26a50686ee..338491221e6 100644
--- a/src/data/move.ts
+++ b/src/data/move.ts
@@ -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((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 failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
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 cancelled = new Utils.BooleanHolder(false);
user.scene.getField(true).map(p=>applyAbAttrs(FieldPreventExplosiveMovesAbAttr, p, cancelled));
@@ -8010,7 +8040,10 @@ export function initMoves() {
.attr(AbilityGiveAttr),
new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5)
.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)
.soundBased()
.partial(),
diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts
index 7d559f32cb3..cb83ebf4882 100644
--- a/src/enums/battler-tag-type.ts
+++ b/src/enums/battler-tag-type.ts
@@ -73,6 +73,7 @@ export enum BattlerTagType {
SHELL_TRAP = "SHELL_TRAP",
DRAGON_CHEER = "DRAGON_CHEER",
NO_RETREAT = "NO_RETREAT",
+ GORILLA_TACTICS = "GORILLA_TACTICS",
THROAT_CHOPPED = "THROAT_CHOPPED",
TAR_SHOT = "TAR_SHOT",
}
diff --git a/src/locales/de/fight-ui-handler.json b/src/locales/de/fight-ui-handler.json
index 6965540c703..f803375e8de 100644
--- a/src/locales/de/fight-ui-handler.json
+++ b/src/locales/de/fight-ui-handler.json
@@ -3,5 +3,6 @@
"power": "Stärke",
"accuracy": "Genauigkeit",
"abilityFlyInText": "{{passive}}{{abilityName}} von {{pokemonName}} wirkt!",
- "passive": "Passive Fähigkeit "
+ "passive": "Passive Fähigkeit ",
+ "teraHover": "Tera-Typ {{type}}"
}
\ No newline at end of file
diff --git a/src/locales/de/move-trigger.json b/src/locales/de/move-trigger.json
index 36dc4d83081..efa460b1a10 100644
--- a/src/locales/de/move-trigger.json
+++ b/src/locales/de/move-trigger.json
@@ -67,5 +67,7 @@
"swapArenaTags": "{{pokemonName}} hat die Effekte, die auf den beiden Seiten des Kampffeldes wirken, miteinander getauscht!",
"trickOnSwap": "{{pokemonNameWithAffix}} tauscht Items mit dem Ziel!",
"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!"
}
diff --git a/src/locales/en/battle.json b/src/locales/en/battle.json
index 0aabaacd99c..217c77422d1 100644
--- a/src/locales/en/battle.json
+++ b/src/locales/en/battle.json
@@ -44,6 +44,7 @@
"moveNotImplemented": "{{moveName}} is not yet implemented and cannot be selected.",
"moveNoPP": "There's no PP left for\nthis move!",
"moveDisabled": "{{moveName}} is disabled!",
+ "canOnlyUseMove": "{{pokemonName}} can only use {{moveName}}!",
"moveCannotBeSelected": "{{moveName}} cannot be selected!",
"disableInterruptedMove": "{{pokemonNameWithAffix}}'s {{moveName}}\nis disabled!",
"throatChopInterruptedMove": "The effects of Throat Chop prevent\n{{pokemonName}} from using certain moves!",
diff --git a/src/locales/en/fight-ui-handler.json b/src/locales/en/fight-ui-handler.json
index 35b7f42772a..1b8bd1f5c71 100644
--- a/src/locales/en/fight-ui-handler.json
+++ b/src/locales/en/fight-ui-handler.json
@@ -3,5 +3,6 @@
"power": "Power",
"accuracy": "Accuracy",
"abilityFlyInText": " {{pokemonName}}'s {{passive}}{{abilityName}}",
- "passive": "Passive "
+ "passive": "Passive ",
+ "teraHover": "{{type}} Terastallized"
}
\ No newline at end of file
diff --git a/src/locales/en/move-trigger.json b/src/locales/en/move-trigger.json
index 373019aca10..940e767c872 100644
--- a/src/locales/en/move-trigger.json
+++ b/src/locales/en/move-trigger.json
@@ -68,5 +68,6 @@
"swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!",
"trickOnSwap": "{{pokemonNameWithAffix}} switched items with its target!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
- "safeguard": "{{targetName}} is protected by Safeguard!"
+ "safeguard": "{{targetName}} is protected by Safeguard!",
+ "afterYou": "{{pokemonName}} took the kind offer!"
}
diff --git a/src/test/abilities/gorilla_tactics.test.ts b/src/test/abilities/gorilla_tactics.test.ts
new file mode 100644
index 00000000000..df698194323
--- /dev/null
+++ b/src/test/abilities/gorilla_tactics.test.ts
@@ -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);
+});
diff --git a/src/test/moves/after_you.test.ts b/src/test/moves/after_you.test.ts
new file mode 100644
index 00000000000..efce1b28a17
--- /dev/null
+++ b/src/test/moves/after_you.test.ts
@@ -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);
+});
diff --git a/src/test/moves/power_shift.test.ts b/src/test/moves/power_shift.test.ts
index 350041d9e4e..3fda315193e 100644
--- a/src/test/moves/power_shift.test.ts
+++ b/src/test/moves/power_shift.test.ts
@@ -3,7 +3,6 @@ import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat";
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 } from "vitest";
@@ -29,7 +28,7 @@ describe("Moves - Power Shift", () => {
.battleType("single")
.ability(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 () => {
diff --git a/src/test/moves/tar_shot.test.ts b/src/test/moves/tar_shot.test.ts
index 15667122a37..2963f061fc6 100644
--- a/src/test/moves/tar_shot.test.ts
+++ b/src/test/moves/tar_shot.test.ts
@@ -5,7 +5,6 @@ import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat";
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";
@@ -29,7 +28,7 @@ describe("Moves - Tar Shot", () => {
game.override
.battleType("single")
.enemyAbility(Abilities.BALL_FETCH)
- .enemyMoveset(SPLASH_ONLY)
+ .enemyMoveset(Moves.SPLASH)
.enemySpecies(Species.TANGELA)
.enemyLevel(1000)
.moveset([Moves.TAR_SHOT, Moves.FIRE_PUNCH])
diff --git a/src/test/moves/throat_chop.test.ts b/src/test/moves/throat_chop.test.ts
index 151aec58b38..cb34b4bafff 100644
--- a/src/test/moves/throat_chop.test.ts
+++ b/src/test/moves/throat_chop.test.ts
@@ -36,12 +36,14 @@ describe("Moves - Throat Chop", () => {
it("prevents the target from using sound-based moves for two turns", async () => {
await game.classicMode.startBattle([Species.MAGIKARP]);
+ const enemy = game.scene.getEnemyPokemon()!;
+
game.move.select(Moves.GROWL);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
// First turn, move is interrupted
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
await game.toNextTurn();
@@ -50,6 +52,6 @@ describe("Moves - Throat Chop", () => {
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
- expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false);
+ expect(enemy.isFullHp()).toBe(false);
}, TIMEOUT);
});
diff --git a/src/test/utils/mocks/mocksContainer/mockContainer.ts b/src/test/utils/mocks/mocksContainer/mockContainer.ts
index d2cdd852257..94ae61a6ce4 100644
--- a/src/test/utils/mocks/mocksContainer/mockContainer.ts
+++ b/src/test/utils/mocks/mocksContainer/mockContainer.ts
@@ -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.
}
- setInteractive(hitArea?, callback?, dropZone?) {
- /// Sets the InteractiveObject to be a drop zone for a drag and drop operation.
- }
+ setInteractive = vi.fn();
+
setOrigin(x, y) {
this.x = x;
this.y = y;
diff --git a/src/test/utils/mocks/mocksContainer/mockSprite.ts b/src/test/utils/mocks/mocksContainer/mockSprite.ts
index 35cd2d5faab..ae43df46cf5 100644
--- a/src/test/utils/mocks/mocksContainer/mockSprite.ts
+++ b/src/test/utils/mocks/mocksContainer/mockSprite.ts
@@ -1,5 +1,6 @@
import Phaser from "phaser";
import { MockGameObject } from "../mockGameObject";
+import { vi } from "vitest";
import Sprite = Phaser.GameObjects.Sprite;
import Frame = Phaser.Textures.Frame;
@@ -101,9 +102,7 @@ export default class MockSprite implements MockGameObject {
return this.phaserSprite.stop();
}
- setInteractive(hitArea, hitAreaCallback, dropZone) {
- return null;
- }
+ setInteractive = vi.fn();
on(event, callback, source) {
return this.phaserSprite.on(event, callback, source);
diff --git a/src/test/utils/mocks/mocksContainer/mockText.ts b/src/test/utils/mocks/mocksContainer/mockText.ts
index 6b9ecf083fd..5a89432902b 100644
--- a/src/test/utils/mocks/mocksContainer/mockText.ts
+++ b/src/test/utils/mocks/mocksContainer/mockText.ts
@@ -197,6 +197,8 @@ export default class MockText implements MockGameObject {
this.color = color;
});
+ setInteractive = vi.fn();
+
setShadowColor(color) {
// Sets the shadow color.
// return this.phaserText.setShadowColor(color);
diff --git a/src/ui/battle-info.ts b/src/ui/battle-info.ts
index 05c634609f8..c7b82dc826e 100644
--- a/src/ui/battle-info.ts
+++ b/src/ui/battle-info.ts
@@ -323,7 +323,7 @@ export default class BattleInfo extends Phaser.GameObjects.Container {
this.teraIcon.setVisible(this.lastTeraType !== Type.UNKNOWN);
this.teraIcon.on("pointerover", () => {
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());
diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts
index 6b75c46bd45..e1269499b10 100644
--- a/src/ui/starter-select-ui-handler.ts
+++ b/src/ui/starter-select-ui-handler.ts
@@ -266,6 +266,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
private pokemonPassiveDisabledIcon: Phaser.GameObjects.Sprite;
private pokemonPassiveLockedIcon: Phaser.GameObjects.Sprite;
+ private activeTooltip: "ABILITY" | "PASSIVE" | "CANDY" | undefined;
private instructionsContainer: Phaser.GameObjects.Container;
private filterInstructionsContainer: Phaser.GameObjects.Container;
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.setOrigin(0, 0);
this.pokemonAbilityLabelText.setVisible(false);
+
this.starterSelectContainer.add(this.pokemonAbilityLabelText);
this.pokemonAbilityText = addTextObject(this.scene, starterInfoXPos, 127 + starterInfoYOffset, "", TextStyle.SUMMARY_ALT, { fontSize: starterInfoTextSize });
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.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.setOrigin(0, 0);
+ this.pokemonPassiveText.setInteractive(new Phaser.Geom.Rectangle(0, 0, 250, 55), Phaser.Geom.Rectangle.Contains);
this.starterSelectContainer.add(this.pokemonPassiveText);
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);
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);
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) {
this.speciesStarterDexEntry = species ? this.scene.gameData.dexData[species.speciesId] : null;
this.dexAttrCursor = species ? this.getCurrentDexProps(species.speciesId) : 0n;
this.abilityCursor = species ? this.scene.gameData.getStarterSpeciesDefaultAbilityIndex(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;
if (starterAttributes?.nature) {
@@ -2807,17 +2838,18 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonHatchedIcon.setVisible(true);
this.pokemonHatchedCountText.setVisible(true);
- let currentFriendship = this.scene.gameData.starterData[this.lastSpecies.speciesId].friendship;
- if (!currentFriendship || currentFriendship === undefined) {
- currentFriendship = 0;
- }
-
- const friendshipCap = getStarterValueFriendshipCap(speciesStarters[this.lastSpecies.speciesId]);
+ const { currentFriendship, friendshipCap } = this.getFriendship(this.lastSpecies.speciesId);
const candyCropY = 16 - (16 * (currentFriendship / friendshipCap));
if (this.pokemonCandyDarknessOverlay.visible) {
- this.pokemonCandyDarknessOverlay.on("pointerover", () => (this.scene as BattleScene).ui.showTooltip("", `${currentFriendship}/${friendshipCap}`, true));
- this.pokemonCandyDarknessOverlay.on("pointerout", () => (this.scene as BattleScene).ui.hideTooltip());
+ this.pokemonCandyDarknessOverlay.on("pointerover", () => {
+ 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);
@@ -2932,6 +2964,11 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.abilityCursor = -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 (female !== undefined) {
formIndex = female ? 1 : 0;
@@ -3081,8 +3118,8 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
}
if (dexEntry.caughtAttr) {
- const ability = this.lastSpecies.getAbility(abilityIndex!); // TODO: is this bang correct?
- this.pokemonAbilityText.setText(allAbilities[ability].name);
+ const ability = allAbilities[this.lastSpecies.getAbility(abilityIndex!)]; // TODO: is this bang correct?
+ this.pokemonAbilityText.setText(ability.name);
const isHidden = abilityIndex === (this.lastSpecies.ability2 ? 2 : 1);
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 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) {
const isUnlocked = !!(passiveAttr & PassiveAttr.UNLOCKED);
const isEnabled = !!(passiveAttr & PassiveAttr.ENABLED);
@@ -3107,6 +3159,21 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonPassiveText.setAlpha(textAlpha);
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 = {
x: this.pokemonPassiveText.x + this.pokemonPassiveText.displayWidth + 1,
y: this.pokemonPassiveText.y + this.pokemonPassiveText.displayHeight / 2
diff --git a/src/ui/ui.ts b/src/ui/ui.ts
index a9bcbbf0cb5..50fb240aad8 100644
--- a/src/ui/ui.ts
+++ b/src/ui/ui.ts
@@ -244,7 +244,7 @@ export default class UI extends Phaser.GameObjects.Container {
this.tooltipContent = addTextObject(this.scene, 6, 16, "", TextStyle.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.tooltipTitle);
@@ -368,14 +368,13 @@ export default class UI extends Phaser.GameObjects.Container {
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 {
this.tooltipContainer.setVisible(true);
- 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, 684);
- this.tooltipBg.height = (title ? 31 : 19) + 10.5 * (wrappedContent.split("\n").length - 1);
+ this.editTooltip(title, content);
if (overlap) {
(this.scene as BattleScene).uiContainer.moveAbove(this.tooltipContainer, this);
} 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 {
this.tooltipContainer.setVisible(false);
this.tooltipTitle.clearTint();
@@ -390,8 +398,12 @@ export default class UI extends Phaser.GameObjects.Container {
update(): void {
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;
- 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 xReverse = this.scene.game.input.mousePointer && this.scene.game.input.mousePointer.x >= this.scene.game.canvas.width - this.tooltipBg.width * 6 - 12;
+ 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,
+ );
}
}