From e75c8e569187c9502b92a2dc949286541e156d96 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:37:00 -0500 Subject: [PATCH 1/6] [Bug] [Ability] Fix stat boost message timing for quark drive / protosynthesis (#6529) Make proto and quark drive stat boost happen after proc instead of at end --- src/data/battler-tags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 1657363bdfa..c495cdaa604 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2159,7 +2159,7 @@ export class HighestStatBoostTag extends AbilityBattlerTag { null, false, null, - true, + false, ); } From 496d9a10d79e741fff51857d5851a105b6526378 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Wed, 10 Sep 2025 14:40:21 -0400 Subject: [PATCH 2/6] [Test] Added matcher to check message contents; added tests for Splash, Celebrate, Laser Focus (#6299) * Fixed laser focus test to not whiff instruct * fix test checking last hit twice in row lul --- src/data/abilities/ability.ts | 1 + src/field/pokemon.ts | 2 +- test/@types/vitest.d.ts | 13 ++- test/abilities/arena-trap.test.ts | 2 +- test/abilities/cud-chew.test.ts | 2 +- test/abilities/truant.test.ts | 2 +- test/matchers.setup.ts | 2 + test/moves/chilly-reception.test.ts | 6 +- test/moves/delayed-attack.test.ts | 12 +- test/moves/laser-focus.test.ts | 104 ++++++++++++++++++ test/moves/splash-celebrate.test.ts | 52 +++++++++ test/moves/wish.test.ts | 4 +- test/test-utils/helpers/overrides-helper.ts | 6 +- .../matchers/to-have-shown-message.ts | 43 ++++++++ 14 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 test/moves/laser-focus.test.ts create mode 100644 test/moves/splash-celebrate.test.ts create mode 100644 test/test-utils/matchers/to-have-shown-message.ts diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index f9878fa95f9..8bb2f30b243 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -7003,6 +7003,7 @@ export function initAbilities() { .attr(StatMultiplierAbAttr, Stat.SPATK, 1.5) .condition(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN)), new Ability(AbilityId.QUICK_FEET, 4) + // TODO: This should ignore the speed drop, not manually undo it .conditionalAttr(pokemon => pokemon.status ? pokemon.status.effect === StatusEffect.PARALYSIS : false, StatMultiplierAbAttr, Stat.SPD, 2) .conditionalAttr(pokemon => !!pokemon.status || pokemon.hasAbility(AbilityId.COMATOSE), StatMultiplierAbAttr, Stat.SPD, 1.5), new Ability(AbilityId.NORMALIZE, 4) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d039fb9d8ff..38161b34f42 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4067,7 +4067,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { */ getCriticalHitResult(source: Pokemon, move: Move): boolean { if (move.hasAttr("FixedDamageAttr")) { - // fixed damage moves (Dragon Rage, etc.) will nevet crit + // fixed damage moves (Dragon Rage, etc.) will never crit return false; } diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index b13d9e53101..9a6f07b4afb 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -27,6 +27,8 @@ import type { toHaveBattlerTagOptions } from "#test/test-utils/matchers/to-have- declare module "vitest" { interface Assertion { + // #region Generic Matchers + /** * Check whether an array contains EXACTLY the given items (in any order). * @@ -38,11 +40,20 @@ declare module "vitest" { */ toEqualArrayUnsorted(expected: T[]): void; + // #endregion Generic Matchers + + // #region GameManager Matchers + + /** + * Check if the {@linkcode GameManager} has shown the given message at least once in the current battle. + * @param expectedMessage - The expected message + */ + toHaveShownMessage(expectedMessage: string): void; /** - * Check if the currently-running {@linkcode Phase} is of the given type. * @param expectedPhase - The expected {@linkcode PhaseString} */ toBeAtPhase(expectedPhase: PhaseString): void; + // #endregion GameManager Matchers // #region Arena Matchers diff --git a/test/abilities/arena-trap.test.ts b/test/abilities/arena-trap.test.ts index d43148dce7b..5b426fd4f47 100644 --- a/test/abilities/arena-trap.test.ts +++ b/test/abilities/arena-trap.test.ts @@ -57,7 +57,7 @@ describe("Abilities - Arena Trap", () => { await game.phaseInterceptor.to("CommandPhase"); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("abilityTriggers:arenaTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(enemy), abilityName: allAbilities[AbilityId.ARENA_TRAP].name, diff --git a/test/abilities/cud-chew.test.ts b/test/abilities/cud-chew.test.ts index ae3b4ad8765..8d80ba119ca 100644 --- a/test/abilities/cud-chew.test.ts +++ b/test/abilities/cud-chew.test.ts @@ -99,7 +99,7 @@ describe("Abilities - Cud Chew", () => { expect(abDisplaySpy.mock.calls[1][2]).toBe(false); // should display messgae - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("battle:hpIsFull", { pokemonName: getPokemonNameWithAffix(farigiraf), }), diff --git a/test/abilities/truant.test.ts b/test/abilities/truant.test.ts index 0d71cd393b0..31098fa1a85 100644 --- a/test/abilities/truant.test.ts +++ b/test/abilities/truant.test.ts @@ -54,7 +54,7 @@ describe("Ability - Truant", () => { expect(player.getLastXMoves(1)[0]).toEqual(expect.objectContaining({ move: MoveId.NONE, result: MoveResult.FAIL })); expect(enemy.hp).toBe(enemy.getMaxHp()); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("battlerTags:truantLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(player), }), diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts index fe2135f4db4..53711ddc3a3 100644 --- a/test/matchers.setup.ts +++ b/test/matchers.setup.ts @@ -8,6 +8,7 @@ import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag"; +import { toHaveShownMessage } from "#test/test-utils/matchers/to-have-shown-message"; import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage"; import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect"; import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage"; @@ -25,6 +26,7 @@ import { expect } from "vitest"; expect.extend({ toEqualArrayUnsorted, + toHaveShownMessage, toBeAtPhase, toHaveWeather, toHaveTerrain, diff --git a/test/moves/chilly-reception.test.ts b/test/moves/chilly-reception.test.ts index 096454132f3..f9caea3d560 100644 --- a/test/moves/chilly-reception.test.ts +++ b/test/moves/chilly-reception.test.ts @@ -47,7 +47,7 @@ describe("Moves - Chilly Reception", () => { expect(game.field.getPlayerPokemon()).toBe(meowth); expect(slowking.isOnField()).toBe(false); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }), ); }); @@ -110,7 +110,7 @@ describe("Moves - Chilly Reception", () => { expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); expect(game.field.getPlayerPokemon()).toBe(slowking); expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }), ); }); @@ -129,7 +129,7 @@ describe("Moves - Chilly Reception", () => { expect(game.field.getPlayerPokemon()).toBe(meowth); expect(slowking.isOnField()).toBe(false); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.textInterceptor.logs).not.toContain( + expect(game).not.toHaveShownMessage( i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }), ); }); diff --git a/test/moves/delayed-attack.test.ts b/test/moves/delayed-attack.test.ts index 420ef6d1f00..6817c7fd17a 100644 --- a/test/moves/delayed-attack.test.ts +++ b/test/moves/delayed-attack.test.ts @@ -99,7 +99,7 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(0); const enemy = game.field.getEnemyPokemon(); expect(enemy).not.toHaveFullHp(); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy), moveName: allMoves[move].name, @@ -227,7 +227,7 @@ describe("Moves - Delayed Attacks", () => { expect(karp).toHaveFullHp(); expect(feebas).toHaveFullHp(); - expect(game.textInterceptor.logs).not.toContain( + expect(game).not.toHaveShownMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(karp), moveName: allMoves[MoveId.FUTURE_SIGHT].name, @@ -256,7 +256,7 @@ describe("Moves - Delayed Attacks", () => { await passTurns(2); expect(enemy1).not.toHaveFullHp(); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy1), moveName: allMoves[MoveId.FUTURE_SIGHT].name, @@ -284,7 +284,7 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(0); expect(enemy1).toHaveFullHp(); - expect(game.textInterceptor.logs).not.toContain( + expect(game).not.toHaveShownMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy1), moveName: allMoves[MoveId.FUTURE_SIGHT].name, @@ -321,7 +321,7 @@ describe("Moves - Delayed Attacks", () => { expect(enemy1).toHaveFullHp(); expect(enemy2).not.toHaveFullHp(); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy2), moveName: allMoves[MoveId.FUTURE_SIGHT].name, @@ -354,7 +354,7 @@ describe("Moves - Delayed Attacks", () => { // Player Normalize was not applied due to being off field const enemy = game.field.getEnemyPokemon(); expect(enemy).not.toHaveFullHp(); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy), moveName: allMoves[MoveId.DOOM_DESIRE].name, diff --git a/test/moves/laser-focus.test.ts b/test/moves/laser-focus.test.ts new file mode 100644 index 00000000000..7496e3ed87f --- /dev/null +++ b/test/moves/laser-focus.test.ts @@ -0,0 +1,104 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Move - Laser Focus", () => { + 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 + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it("should make the user's next attack a guaranteed critical hit", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.LASER_FOCUS); + await game.toNextTurn(); + + const feebas = game.field.getPlayerPokemon(); + expect(feebas).toHaveBattlerTag(BattlerTagType.ALWAYS_CRIT); + expect(game).toHaveShownMessage( + i18next.t("battlerTags:laserFocusOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(feebas), + }), + ); + + const enemy = game.field.getEnemyPokemon(); + const critSpy = vi.spyOn(enemy, "getCriticalHitResult"); + + game.move.use(MoveId.TACKLE); + await game.toEndOfTurn(); + + expect(critSpy).toHaveLastReturnedWith(true); + }); + + it("should disappear at the end of the next turn", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + + game.move.use(MoveId.LASER_FOCUS); + await game.toNextTurn(); + + expect(feebas).toHaveBattlerTag(BattlerTagType.ALWAYS_CRIT); + + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + expect(feebas).not.toHaveBattlerTag(BattlerTagType.ALWAYS_CRIT); + + const enemy = game.field.getEnemyPokemon(); + const critSpy = vi.spyOn(enemy, "getCriticalHitResult"); + + game.move.use(MoveId.TACKLE); + await game.toEndOfTurn(); + + expect(critSpy).toHaveLastReturnedWith(false); + }); + + it("should boost all attacks until the end of the next turn", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.LASER_FOCUS); + await game.toNextTurn(); + + const enemy = game.field.getEnemyPokemon(); + const critSpy = vi.spyOn(enemy, "getCriticalHitResult"); + + game.move.use(MoveId.TACKLE); + await game.move.forceEnemyMove(MoveId.INSTRUCT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(critSpy).toHaveReturnedTimes(2); + expect(critSpy).toHaveNthReturnedWith(1, true); + expect(critSpy).toHaveNthReturnedWith(2, true); + }); +}); diff --git a/test/moves/splash-celebrate.test.ts b/test/moves/splash-celebrate.test.ts new file mode 100644 index 00000000000..346ffedd12c --- /dev/null +++ b/test/moves/splash-celebrate.test.ts @@ -0,0 +1,52 @@ +import { loggedInUser } from "#app/account"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe.each<{ name: string; move: MoveId; message: () => string }>([ + { name: "Splash", move: MoveId.SPLASH, message: () => i18next.t("moveTriggers:splash") }, + { + name: "Celebrate", + move: MoveId.CELEBRATE, + message: () => i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username }), + }, +])("Move - $name", ({ move, message }) => { + 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 + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.TACKLE) + .startingLevel(100) + .enemyLevel(100); + }); + + it("should show a message on use", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(move); + await game.toEndOfTurn(); + + expect(game).toHaveShownMessage(message()); + }); +}); diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts index 55877edbfd4..1c1f3f3b8ba 100644 --- a/test/moves/wish.test.ts +++ b/test/moves/wish.test.ts @@ -55,7 +55,7 @@ describe("Move - Wish", () => { await game.toEndOfTurn(); expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); - expect(game.textInterceptor.logs).toContain( + expect(game).toHaveShownMessage( i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(alomomola), }), @@ -165,7 +165,7 @@ describe("Move - Wish", () => { // Wish went away without doing anything expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); - expect(game.textInterceptor.logs).not.toContain( + expect(game).not.toHaveShownMessage( i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(blissey), }), diff --git a/test/test-utils/helpers/overrides-helper.ts b/test/test-utils/helpers/overrides-helper.ts index 8b21a2be454..da0d75bf564 100644 --- a/test/test-utils/helpers/overrides-helper.ts +++ b/test/test-utils/helpers/overrides-helper.ts @@ -342,7 +342,11 @@ export class OverridesHelper extends GameManagerHelper { /** * Force random critical hit rolls to always or never suceed. * @param crits - `true` to guarantee crits on eligible moves, `false` to force rolls to fail, `null` to disable override - * @remarks This does not bypass effects that guarantee or block critical hits; it merely mocks the chance-based rolls. + * @remarks + * This does not change any effects that guarantee or block critical hits; + * it merely mocks any chance-based rolls not already at 100%. \ + * For instance, a Pokemon at +3 crit stages will still critically hit with the override set to `false`, + * whereas one at +2 crit stages (a 50% chance) will not. * @returns `this` */ public criticalHits(crits: boolean | null): this { diff --git a/test/test-utils/matchers/to-have-shown-message.ts b/test/test-utils/matchers/to-have-shown-message.ts new file mode 100644 index 00000000000..bf5576ee630 --- /dev/null +++ b/test/test-utils/matchers/to-have-shown-message.ts @@ -0,0 +1,43 @@ +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { GameManager } from "#test/test-utils/game-manager"; +import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils"; +import { truncateString } from "#utils/common"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +/** + * Matcher to check if the {@linkcode GameManager} has shown the given message at least once. + * @param received - The object to check. Should be the current {@linkcode GameManager}. + * @param expectedMessage - The expected message + * @returns The result of the matching + */ +export function toHaveShownMessage( + this: MatcherState, + received: unknown, + expectedMessage: string, +): SyncExpectationResult { + if (!isGameManagerInstance(received)) { + return { + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, + }; + } + + if (!received.textInterceptor) { + return { + pass: this.isNot, + message: () => "Expected GameManager.TextInterceptor to be defined!", + }; + } + + // Pass if any of the matching tags meet our criteria + const pass = received.textInterceptor.logs.includes(expectedMessage); + return { + pass, + message: () => + pass + ? `Expected the GameManager to NOT have shown the message ${truncateString(expectedMessage, 30)}, but it did!` + : `Expected the GameManager to have shown the message ${truncateString(expectedMessage, 30)}, but it didn't!`, + expected: expectedMessage, + actual: received.textInterceptor.logs, + }; +} From db52ff0f730fb1512bc46ebf9f13318718d2e23e Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:46:40 -0500 Subject: [PATCH 3/6] [Docs] Add interface to known tags in tsdoc --- tsdoc.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tsdoc.json b/tsdoc.json index 689f7a96c5c..c17030cdbe7 100644 --- a/tsdoc.json +++ b/tsdoc.json @@ -13,6 +13,10 @@ { "tagName": "@module", "syntaxKind": "modifier" + }, + { + "tagName": "@interface", + "syntaxKind": "modifier" } ] } From c09d43273afffa0643c009279bfa994e0b7e2529 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:47:53 -0500 Subject: [PATCH 4/6] [Docs] Remove stray import --- src/field/pokemon.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 38161b34f42..aebea28ca8d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5662,7 +5662,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Generates a random number using the current battle's seed, or the global seed if `globalScene.currentBattle` is falsy - * * This calls either {@linkcode BattleScene.randBattleSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle-scene.ts` * which calls {@linkcode Battle.randSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle.ts` * which calls {@linkcode randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`, From ff6de568af35b6016a2bf0353323ab9757d9e62a Mon Sep 17 00:00:00 2001 From: Fabi <192151969+fabske0@users.noreply.github.com> Date: Wed, 10 Sep 2025 20:53:57 +0200 Subject: [PATCH 5/6] [UI/UX] Add language selection to login screen (#6302) * add language selection * Move language option up * Move language list to seperate file * fix circular dependency * Move language selection to own icon * run biome * add icon * add icon to legacy ui --- public/images/ui/language_icon.png | Bin 0 -> 294 bytes public/images/ui/legacy/language_icon.png | Bin 0 -> 294 bytes src/loading-scene.ts | 1 + src/system/settings/settings-language.ts | 101 ++++++++++++++++++++++ src/system/settings/settings.ts | 94 +------------------- src/ui/handlers/login-form-ui-handler.ts | 46 +++++++--- 6 files changed, 140 insertions(+), 102 deletions(-) create mode 100644 public/images/ui/language_icon.png create mode 100644 public/images/ui/legacy/language_icon.png create mode 100644 src/system/settings/settings-language.ts diff --git a/public/images/ui/language_icon.png b/public/images/ui/language_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ebe0671ca5115c0e1d2d59107b03ef61bd395c38 GIT binary patch literal 294 zcmV+>0oneEP)ZA>fB+Oz^~OiPFpB$*|boWR`Zhx~Ap8y=e_Nlx!YbsydG+5!GaM04z% z3p>2vH}I-Tv-Yx^QqG4gEQ%Rh#9#Gm;rS$-4wpx|pZEF>uIJVg@y5%hDlcGOUGM>0 zl7InT)Q8G@;Rwc>=M4TB&h{Oz&&3o7ZRs5_UdhTuo51BkZ>L?8!B^lqa0oneEP)ZA>fB+Oz^~OiPFpB$*|boWR`Zhx~Ap8y=e_Nlx!YbsydG+5!GaM04z% z3p>2vH}I-Tv-Yx^QqG4gEQ%Rh#9#Gm;rS$-4wpx|pZEF>uIJVg@y5%hDlcGOUGM>0 zl7InT)Q8G@;Rwc>=M4TB&h{Oz&&3o7ZRs5_UdhTuo51BkZ>L?8!B^lqa { + globalScene.ui.revertMode(); + const handler = globalScene.ui.getHandler(); + // Reset the cursor to the current language, if in the settings menu + if (handler && typeof (handler as SettingsDisplayUiHandler).setOptionCursor === "function") { + (handler as SettingsDisplayUiHandler).setOptionCursor(-1, 0, true); + } +}; + +const changeLocaleHandler = (locale: string): boolean => { + try { + i18next.changeLanguage(locale); + localStorage.setItem("prLang", locale); + cancelHandler(); + // Reload the whole game to apply the new locale since also some constants are translated + window.location.reload(); + return true; + } catch (error) { + console.error("Error changing locale:", error); + return false; + } +}; + +export const languageOptions = [ + { + label: "English", + handler: () => changeLocaleHandler("en"), + }, + { + label: "Español (ES)", + handler: () => changeLocaleHandler("es-ES"), + }, + { + label: "Español (LATAM)", + handler: () => changeLocaleHandler("es-MX"), + }, + { + label: "Français", + handler: () => changeLocaleHandler("fr"), + }, + { + label: "Deutsch", + handler: () => changeLocaleHandler("de"), + }, + { + label: "Italiano", + handler: () => changeLocaleHandler("it"), + }, + { + label: "Português (BR)", + handler: () => changeLocaleHandler("pt-BR"), + }, + { + label: "한국어", + handler: () => changeLocaleHandler("ko"), + }, + { + label: "日本語", + handler: () => changeLocaleHandler("ja"), + }, + { + label: "简体中文", + handler: () => changeLocaleHandler("zh-CN"), + }, + { + label: "繁體中文", + handler: () => changeLocaleHandler("zh-TW"), + }, + { + label: "Català (Needs Help)", + handler: () => changeLocaleHandler("ca"), + }, + { + label: "Türkçe (Needs Help)", + handler: () => changeLocaleHandler("tr"), + }, + { + label: "Русский (Needs Help)", + handler: () => changeLocaleHandler("ru"), + }, + { + label: "Dansk (Needs Help)", + handler: () => changeLocaleHandler("da"), + }, + { + label: "Română (Needs Help)", + handler: () => changeLocaleHandler("ro"), + }, + { + label: "Tagalog (Needs Help)", + handler: () => changeLocaleHandler("tl"), + }, + { + label: i18next.t("settings:back"), + handler: () => cancelHandler(), + }, +]; diff --git a/src/system/settings/settings.ts b/src/system/settings/settings.ts index c44f31f0930..78b6044b0fc 100644 --- a/src/system/settings/settings.ts +++ b/src/system/settings/settings.ts @@ -6,10 +6,10 @@ import { PlayerGender } from "#enums/player-gender"; import { ShopCursorTarget } from "#enums/shop-cursor-target"; import { UiMode } from "#enums/ui-mode"; import { CandyUpgradeNotificationChangedEvent } from "#events/battle-scene"; -import type { SettingsUiHandler } from "#ui/settings-ui-handler"; import { updateWindowType } from "#ui/ui-theme"; import { isLocal } from "#utils/common"; import i18next from "i18next"; +import { languageOptions } from "./settings-language"; const VOLUME_OPTIONS: SettingOption[] = [ { @@ -911,98 +911,8 @@ export function setSetting(setting: string, value: number): boolean { break; case SettingKeys.Language: if (value && globalScene.ui) { - const cancelHandler = () => { - globalScene.ui.revertMode(); - (globalScene.ui.getHandler() as SettingsUiHandler).setOptionCursor(-1, 0, true); - }; - const changeLocaleHandler = (locale: string): boolean => { - try { - i18next.changeLanguage(locale); - localStorage.setItem("prLang", locale); - cancelHandler(); - // Reload the whole game to apply the new locale since also some constants are translated - window.location.reload(); - return true; - } catch (error) { - console.error("Error changing locale:", error); - return false; - } - }; globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, { - options: [ - { - label: "English", - handler: () => changeLocaleHandler("en"), - }, - { - label: "Español (ES)", - handler: () => changeLocaleHandler("es-ES"), - }, - { - label: "Español (LATAM)", - handler: () => changeLocaleHandler("es-MX"), - }, - { - label: "Français", - handler: () => changeLocaleHandler("fr"), - }, - { - label: "Deutsch", - handler: () => changeLocaleHandler("de"), - }, - { - label: "Italiano", - handler: () => changeLocaleHandler("it"), - }, - { - label: "Português (BR)", - handler: () => changeLocaleHandler("pt-BR"), - }, - { - label: "한국어", - handler: () => changeLocaleHandler("ko"), - }, - { - label: "日本語", - handler: () => changeLocaleHandler("ja"), - }, - { - label: "简体中文", - handler: () => changeLocaleHandler("zh-CN"), - }, - { - label: "繁體中文", - handler: () => changeLocaleHandler("zh-TW"), - }, - { - label: "Català (Needs Help)", - handler: () => changeLocaleHandler("ca"), - }, - { - label: "Türkçe (Needs Help)", - handler: () => changeLocaleHandler("tr"), - }, - { - label: "Русский (Needs Help)", - handler: () => changeLocaleHandler("ru"), - }, - { - label: "Dansk (Needs Help)", - handler: () => changeLocaleHandler("da"), - }, - { - label: "Română (Needs Help)", - handler: () => changeLocaleHandler("ro"), - }, - { - label: "Tagalog (Needs Help)", - handler: () => changeLocaleHandler("tl"), - }, - { - label: i18next.t("settings:back"), - handler: () => cancelHandler(), - }, - ], + options: languageOptions, maxOptions: 7, }); return false; diff --git a/src/ui/handlers/login-form-ui-handler.ts b/src/ui/handlers/login-form-ui-handler.ts index aeebd23ce43..0634ae36ba8 100644 --- a/src/ui/handlers/login-form-ui-handler.ts +++ b/src/ui/handlers/login-form-ui-handler.ts @@ -2,6 +2,7 @@ import { pokerogueApi } from "#api/pokerogue-api"; import { globalScene } from "#app/global-scene"; import { TextStyle } from "#enums/text-style"; import { UiMode } from "#enums/ui-mode"; +import { languageOptions } from "#system/settings-language"; import type { OptionSelectItem } from "#ui/handlers/abstract-option-select-ui-handler"; import type { InputFieldConfig } from "#ui/handlers/form-modal-ui-handler"; import { FormModalUiHandler } from "#ui/handlers/form-modal-ui-handler"; @@ -31,6 +32,7 @@ export class LoginFormUiHandler extends FormModalUiHandler { private discordImage: Phaser.GameObjects.Image; private usernameInfoImage: Phaser.GameObjects.Image; private saveDownloadImage: Phaser.GameObjects.Image; + private changeLanguageImage: Phaser.GameObjects.Image; private externalPartyContainer: Phaser.GameObjects.Container; private infoContainer: Phaser.GameObjects.Container; private externalPartyBg: Phaser.GameObjects.NineSlice; @@ -82,8 +84,14 @@ export class LoginFormUiHandler extends FormModalUiHandler { scale: 0.75, }); + this.changeLanguageImage = this.buildInteractableImage("language_icon", "change-language-icon", { + x: 40, + scale: 0.5, + }); + this.infoContainer.add(this.usernameInfoImage); this.infoContainer.add(this.saveDownloadImage); + this.infoContainer.add(this.changeLanguageImage); this.getUi().add(this.infoContainer); this.infoContainer.setVisible(false); this.infoContainer.disableInteractive(); @@ -163,13 +171,18 @@ export class LoginFormUiHandler extends FormModalUiHandler { const [usernameInput, passwordInput] = this.inputs; - pokerogueApi.account.login({ username: usernameInput.text, password: passwordInput.text }).then(error => { - if (!error && originalLoginAction) { - originalLoginAction(); - } else { - onFail(error); - } - }); + pokerogueApi.account + .login({ + username: usernameInput.text, + password: passwordInput.text, + }) + .then(error => { + if (!error && originalLoginAction) { + originalLoginAction(); + } else { + onFail(error); + } + }); } }; @@ -185,9 +198,13 @@ export class LoginFormUiHandler extends FormModalUiHandler { this.infoContainer.setVisible(false); this.setMouseCursorStyle("default"); //reset cursor - [this.discordImage, this.googleImage, this.usernameInfoImage, this.saveDownloadImage].forEach(img => - img.off("pointerdown"), - ); + [ + this.discordImage, + this.googleImage, + this.usernameInfoImage, + this.saveDownloadImage, + this.changeLanguageImage, + ].forEach(img => img.off("pointerdown")); } private processExternalProvider(config: ModalConfig): void { @@ -206,6 +223,7 @@ export class LoginFormUiHandler extends FormModalUiHandler { this.getUi().moveTo(this.infoContainer, this.getUi().length - 1); this.usernameInfoImage.setPositionRelative(this.infoContainer, 0, 0); this.saveDownloadImage.setPositionRelative(this.infoContainer, 20, 0); + this.changeLanguageImage.setPositionRelative(this.infoContainer, 40, 0); this.discordImage.on("pointerdown", () => { const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/discord/callback`); @@ -288,6 +306,14 @@ export class LoginFormUiHandler extends FormModalUiHandler { } }); + this.changeLanguageImage.on("pointerdown", () => { + globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, { + options: languageOptions, + maxOptions: 7, + delay: 1000, + }); + }); + this.externalPartyContainer.setAlpha(0); globalScene.tweens.add({ targets: this.externalPartyContainer, From d9d6163b07ba9805560757666fc0413f670acd4a Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:59:21 -0500 Subject: [PATCH 6/6] [Bug] Make touch events prevent pointer events (#6528) --- src/touch-controls.ts | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/touch-controls.ts b/src/touch-controls.ts index eb82cbcb23f..d031af9ef4c 100644 --- a/src/touch-controls.ts +++ b/src/touch-controls.ts @@ -5,9 +5,9 @@ import type Phaser from "phaser"; const repeatInputDelayMillis = 250; export class TouchControl { - events: Phaser.Events.EventEmitter; + readonly events: Phaser.Events.EventEmitter; private buttonLock: string[] = []; - private inputInterval: NodeJS.Timeout[] = []; + private readonly inputInterval: NodeJS.Timeout[] = []; /** Whether touch controls are disabled */ private disabled = false; /** Whether the last touch event has finished before disabling */ @@ -61,12 +61,46 @@ export class TouchControl { * event, removes the keydown state, and removes the 'active' class from the node and the last touched element. */ bindKey(node: HTMLElement, key: string) { + node.addEventListener("touchstart", (event: TouchEvent) => { + // Handle touch events for touch devices + this.touchButtonDown(node, key); + event.preventDefault(); + + // prevent pointer event from also firing (undefined just sets presence of custom attribute) + if (event.currentTarget instanceof HTMLElement) { + event.currentTarget.dataset.skipPointerEvent = undefined; + } + }); node.addEventListener("pointerdown", event => { + const currentTarget = event.currentTarget; + if (currentTarget instanceof HTMLElement && "skipPointerDown" in currentTarget.dataset) { + return; + } event.preventDefault(); this.touchButtonDown(node, key); }); + node.addEventListener("touchcancel", (event: TouchEvent) => { + if (event.currentTarget instanceof HTMLElement && "skipPointerDown" in event.currentTarget.dataset) { + delete event.currentTarget.dataset.skipPointerEvent; + } + }); + + node.addEventListener("touchend", (event: TouchEvent) => { + event.preventDefault(); + this.touchButtonUp(node, key, event.target?.["id"]); + if (event.currentTarget instanceof HTMLElement && "skipPointerDown" in event.currentTarget.dataset) { + // allow pointer event to once again fire + delete event.currentTarget.dataset.skipPointerEvent; + event.currentTarget.dataset.skipPointerUp = undefined; + } + }); + node.addEventListener("pointerup", event => { + if (event.currentTarget instanceof HTMLElement && "skipPointerUp" in event.currentTarget.dataset) { + delete event.currentTarget.dataset.skipPointerUp; + return; + } event.preventDefault(); this.touchButtonUp(node, key, event.target?.["id"]); });