From 1517e0512e731361a39452f101660ab71500d152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Sim=C3=B5es?= Date: Thu, 14 Aug 2025 02:08:12 +0100 Subject: [PATCH 1/8] [UI/UX] [Feature] Save Management Tool (Rename/Delete Saves) (#5978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement Name Run Feat Modified load session ui component, adding a submenu when selecting a 3 slot. This menu has 4 options: Load Game -> Behaves as before, allowing the player to continue progress from the last saved state in the slot. Rename Run -> Overlays a rename form, allowing the player to type a name for the run, checking for string validity, with the option to cancel or confirm (Rename). Delete Run -> Prompts user confirmation to delete save data, removing the current save slot from the users save data. Cancel -> Hides menu overlay. Modified game data to implement a function to accept and store runNameText to the users data. Modified run info ui component, to display the chosen name when viewing run information. Example: When loading the game, the user can choose the Load Game menu option, then select a save slot, prompting the menu, then choose "Rename Run" and type the name "Monotype Water Run" then confirm, thus being able to better organize their save files. Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Implement Rename Input Design and Tests for Name Run Feat Created a test to verify Name Run Feature behaviour in the backend (rename_run.test.ts), checking possible errors and expected behaviours. Created a UiHandler RenameRunFormUiHandler (rename-run-ui-handler.ts), creating a frontend input overlay for the Name Run Feature. Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Fixed formating and best practices issues: Rewrote renameSession to be more inline with other API call funtions, removed debugging comments and whitespaces. Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Minor Sanitization for aesthetics Deleting the input when closing the overlay for aesthetics purpose Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Fixed minor rebase alterations. Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt * Implemented Default Name Logic Altered logic in save-slot-select-ui-handler.ts to support default naming of runs based on the run game mode with decideFallback function. In game-data.ts, to prevent inconsistent naming, added check for unfilled input, ignoring empty rename requests. Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt * Replace fallback name logic: use first active challenge instead of game mode Previously used game mode as the fallback name, updated to use the first active challenge instead (e.g. Monogen or Mono Type), which better reflects the run's theme. Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Rebasing and conflict resolution Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Lint fix Signed-off-by: Matheus Alves Co-authored-by: Inês Simões * Minor compile fix * Dependency resolved * Format name respected * Add all active challenges to default challenge session name if possible If more than 3 challenges are active, only the first 3 are added to the name (to prevent the text going off-screen) and then "..." is appended to the end to indicate there were more challenges active than the ones listed * Allow deleting malformed sessions --------- Signed-off-by: Matheus Alves Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt Co-authored-by: Matheus Alves Co-authored-by: Wlowscha <54003515+Wlowscha@users.noreply.github.com> Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> --- src/enums/ui-mode.ts | 1 + src/system/game-data.ts | 49 +++++ src/ui/rename-run-ui-handler.ts | 54 ++++++ src/ui/run-info-ui-handler.ts | 4 + src/ui/save-slot-select-ui-handler.ts | 257 ++++++++++++++++++++++---- src/ui/ui.ts | 3 + test/system/rename-run.test.ts | 82 ++++++++ 7 files changed, 410 insertions(+), 40 deletions(-) create mode 100644 src/ui/rename-run-ui-handler.ts create mode 100644 test/system/rename-run.test.ts diff --git a/src/enums/ui-mode.ts b/src/enums/ui-mode.ts index bc93e747be2..75c07a5f63c 100644 --- a/src/enums/ui-mode.ts +++ b/src/enums/ui-mode.ts @@ -38,6 +38,7 @@ export enum UiMode { UNAVAILABLE, CHALLENGE_SELECT, RENAME_POKEMON, + RENAME_RUN, RUN_HISTORY, RUN_INFO, TEST_DIALOGUE, diff --git a/src/system/game-data.ts b/src/system/game-data.ts index ae559072e35..0313d64dd80 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -127,6 +127,7 @@ export interface SessionSaveData { battleType: BattleType; trainer: TrainerData; gameVersion: string; + runNameText: string; timestamp: number; challenges: ChallengeData[]; mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, @@ -979,6 +980,54 @@ export class GameData { }); } + async renameSession(slotId: number, newName: string): Promise { + return new Promise(async resolve => { + if (slotId < 0) { + return resolve(false); + } + const sessionData: SessionSaveData | null = await this.getSession(slotId); + + if (!sessionData) { + return resolve(false); + } + + if (newName === "") { + return resolve(true); + } + + sessionData.runNameText = newName; + const updatedDataStr = JSON.stringify(sessionData); + const encrypted = encrypt(updatedDataStr, bypassLogin); + const secretId = this.secretId; + const trainerId = this.trainerId; + + if (bypassLogin) { + localStorage.setItem( + `sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, + encrypt(updatedDataStr, bypassLogin), + ); + resolve(true); + return; + } + pokerogueApi.savedata.session + .update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted) + .then(error => { + if (error) { + console.error("Failed to update session name:", error); + resolve(false); + } else { + localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted); + updateUserInfo().then(success => { + if (success !== null && !success) { + return resolve(false); + } + }); + resolve(true); + } + }); + }); + } + loadSession(slotId: number, sessionData?: SessionSaveData): Promise { // biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this return new Promise(async (resolve, reject) => { diff --git a/src/ui/rename-run-ui-handler.ts b/src/ui/rename-run-ui-handler.ts new file mode 100644 index 00000000000..23ba0137f2d --- /dev/null +++ b/src/ui/rename-run-ui-handler.ts @@ -0,0 +1,54 @@ +import i18next from "i18next"; +import type { InputFieldConfig } from "./form-modal-ui-handler"; +import { FormModalUiHandler } from "./form-modal-ui-handler"; +import type { ModalConfig } from "./modal-ui-handler"; + +export class RenameRunFormUiHandler extends FormModalUiHandler { + getModalTitle(_config?: ModalConfig): string { + return i18next.t("menu:renamerun"); + } + + getWidth(_config?: ModalConfig): number { + return 160; + } + + getMargin(_config?: ModalConfig): [number, number, number, number] { + return [0, 0, 48, 0]; + } + + getButtonLabels(_config?: ModalConfig): string[] { + return [i18next.t("menu:rename"), i18next.t("menu:cancel")]; + } + + getReadableErrorMessage(error: string): string { + const colonIndex = error?.indexOf(":"); + if (colonIndex > 0) { + error = error.slice(0, colonIndex); + } + + return super.getReadableErrorMessage(error); + } + + override getInputFieldConfigs(): InputFieldConfig[] { + return [{ label: i18next.t("menu:runName") }]; + } + + show(args: any[]): boolean { + if (!super.show(args)) { + return false; + } + if (this.inputs?.length) { + this.inputs.forEach(input => { + input.text = ""; + }); + } + const config = args[0] as ModalConfig; + this.submitAction = _ => { + this.sanitizeInputs(); + const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text)); + config.buttonActions[0](sanitizedName); + return true; + }; + return true; + } +} diff --git a/src/ui/run-info-ui-handler.ts b/src/ui/run-info-ui-handler.ts index 2def302c1d5..072eefad65a 100644 --- a/src/ui/run-info-ui-handler.ts +++ b/src/ui/run-info-ui-handler.ts @@ -207,6 +207,10 @@ export class RunInfoUiHandler extends UiHandler { headerText.setOrigin(0, 0); headerText.setPositionRelative(headerBg, 8, 4); this.runContainer.add(headerText); + const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW); + runName.setOrigin(0, 0); + runName.setPositionRelative(headerBg, 60, 4); + this.runContainer.add(runName); } /** diff --git a/src/ui/save-slot-select-ui-handler.ts b/src/ui/save-slot-select-ui-handler.ts index 9c2f8488b22..c86f2ea66bf 100644 --- a/src/ui/save-slot-select-ui-handler.ts +++ b/src/ui/save-slot-select-ui-handler.ts @@ -1,12 +1,14 @@ import { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { Button } from "#enums/buttons"; +import { GameModes } from "#enums/game-modes"; import { TextStyle } from "#enums/text-style"; import { UiMode } from "#enums/ui-mode"; // biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts` import * as Modifier from "#modifiers/modifier"; import type { SessionSaveData } from "#system/game-data"; import type { PokemonData } from "#system/pokemon-data"; +import type { OptionSelectConfig } from "#ui/abstract-option-select-ui-handler"; import { MessageUiHandler } from "#ui/message-ui-handler"; import { RunDisplayMode } from "#ui/run-info-ui-handler"; import { addTextObject } from "#ui/text"; @@ -15,7 +17,7 @@ import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } fro import i18next from "i18next"; const SESSION_SLOTS_COUNT = 5; -const SLOTS_ON_SCREEN = 3; +const SLOTS_ON_SCREEN = 2; export enum SaveSlotUiMode { LOAD, @@ -33,6 +35,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { private uiMode: SaveSlotUiMode; private saveSlotSelectCallback: SaveSlotSelectCallback | null; + protected manageDataConfig: OptionSelectConfig; private scrollCursor = 0; @@ -101,6 +104,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { processInput(button: Button): boolean { const ui = this.getUi(); + const manageDataOptions: any[] = []; let success = false; let error = false; @@ -109,14 +113,115 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { const originalCallback = this.saveSlotSelectCallback; if (button === Button.ACTION) { const cursor = this.cursor + this.scrollCursor; - if (this.uiMode === SaveSlotUiMode.LOAD && !this.sessionSlots[cursor].hasData) { + const sessionSlot = this.sessionSlots[cursor]; + if (this.uiMode === SaveSlotUiMode.LOAD && !sessionSlot.hasData) { error = true; } else { switch (this.uiMode) { case SaveSlotUiMode.LOAD: - this.saveSlotSelectCallback = null; - originalCallback?.(cursor); + if (!sessionSlot.malformed) { + manageDataOptions.push({ + label: i18next.t("menu:loadGame"), + handler: () => { + globalScene.ui.revertMode(); + originalCallback?.(cursor); + return true; + }, + keepOpen: false, + }); + + manageDataOptions.push({ + label: i18next.t("saveSlotSelectUiHandler:renameRun"), + handler: () => { + globalScene.ui.revertMode(); + ui.setOverlayMode( + UiMode.RENAME_RUN, + { + buttonActions: [ + (sanitizedName: string) => { + const name = decodeURIComponent(atob(sanitizedName)); + globalScene.gameData.renameSession(cursor, name).then(response => { + if (response[0] === false) { + globalScene.reset(true); + } else { + this.clearSessionSlots(); + this.cursorObj = null; + this.populateSessionSlots(); + this.setScrollCursor(0); + this.setCursor(0); + ui.revertMode(); + ui.showText("", 0); + } + }); + }, + () => { + ui.revertMode(); + }, + ], + }, + "", + ); + return true; + }, + }); + } + + this.manageDataConfig = { + xOffset: 0, + yOffset: 48, + options: manageDataOptions, + maxOptions: 4, + }; + + manageDataOptions.push({ + label: i18next.t("saveSlotSelectUiHandler:deleteRun"), + handler: () => { + globalScene.ui.revertMode(); + ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => { + ui.setOverlayMode( + UiMode.CONFIRM, + () => { + globalScene.gameData.tryClearSession(cursor).then(response => { + if (response[0] === false) { + globalScene.reset(true); + } else { + this.clearSessionSlots(); + this.cursorObj = null; + this.populateSessionSlots(); + this.setScrollCursor(0); + this.setCursor(0); + ui.revertMode(); + ui.showText("", 0); + } + }); + }, + () => { + ui.revertMode(); + ui.showText("", 0); + }, + false, + 0, + 19, + import.meta.env.DEV ? 300 : 2000, + ); + }); + return true; + }, + keepOpen: false, + }); + + manageDataOptions.push({ + label: i18next.t("menuUiHandler:cancel"), + handler: () => { + globalScene.ui.revertMode(); + return true; + }, + keepOpen: true, + }); + + ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig); break; + case SaveSlotUiMode.SAVE: { const saveAndCallback = () => { const originalCallback = this.saveSlotSelectCallback; @@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { } } else { this.saveSlotSelectCallback = null; + ui.showText("", 0); originalCallback?.(-1); success = true; } @@ -267,33 +373,34 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { this.cursorObj = globalScene.add.container(0, 0); const cursorBox = globalScene.add.nineslice( 0, - 0, + 15, "select_cursor_highlight_thick", undefined, - 296, - 44, + 294, + this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60, 6, 6, 6, 6, ); const rightArrow = globalScene.add.image(0, 0, "cursor"); - rightArrow.setPosition(160, 0); + rightArrow.setPosition(160, 15); rightArrow.setName("rightArrow"); this.cursorObj.add([cursorBox, rightArrow]); this.sessionSlotsContainer.add(this.cursorObj); } const cursorPosition = cursor + this.scrollCursor; - const cursorIncrement = cursorPosition * 56; + const cursorIncrement = cursorPosition * 76; if (this.sessionSlots[cursorPosition] && this.cursorObj) { - const hasData = this.sessionSlots[cursorPosition].hasData; + const session = this.sessionSlots[cursorPosition]; + const hasData = session.hasData && !session.malformed; // If the session slot lacks session data, it does not move from its default, central position. // Only session slots with session data will move leftwards and have a visible arrow. if (!hasData) { - this.cursorObj.setPosition(151, 26 + cursorIncrement); + this.cursorObj.setPosition(151, 20 + cursorIncrement); this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement); } else { - this.cursorObj.setPosition(145, 26 + cursorIncrement); + this.cursorObj.setPosition(145, 20 + cursorIncrement); this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement); } this.setArrowVisibility(hasData); @@ -311,7 +418,8 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { revertSessionSlot(slotIndex: number): void { const sessionSlot = this.sessionSlots[slotIndex]; if (sessionSlot) { - sessionSlot.setPosition(0, slotIndex * 56); + const valueHeight = 76; + sessionSlot.setPosition(0, slotIndex * valueHeight); } } @@ -340,7 +448,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { this.setCursor(this.cursor, prevSlotIndex); globalScene.tweens.add({ targets: this.sessionSlotsContainer, - y: this.sessionSlotsContainerInitialY - 56 * scrollCursor, + y: this.sessionSlotsContainerInitialY - 76 * scrollCursor, duration: fixedInt(325), ease: "Sine.easeInOut", }); @@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { class SessionSlot extends Phaser.GameObjects.Container { public slotId: number; public hasData: boolean; + /** Indicates the save slot ran into an error while being loaded */ + public malformed: boolean; + private slotWindow: Phaser.GameObjects.NineSlice; private loadingLabel: Phaser.GameObjects.Text; - public saveData: SessionSaveData; constructor(slotId: number) { - super(globalScene, 0, slotId * 56); + super(globalScene, 0, slotId * 76); this.slotId = slotId; @@ -387,32 +497,89 @@ class SessionSlot extends Phaser.GameObjects.Container { } setup() { - const slotWindow = addWindow(0, 0, 304, 52); - this.add(slotWindow); + this.slotWindow = addWindow(0, 0, 304, 70); + this.add(this.slotWindow); - this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW); + this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW); this.loadingLabel.setOrigin(0.5, 0.5); this.add(this.loadingLabel); } + /** + * Generates a name for sessions that don't have a name yet. + * @param data - The {@linkcode SessionSaveData} being checked + * @returns The default name for the given data. + */ + decideFallback(data: SessionSaveData): string { + let fallbackName = `${GameMode.getModeName(data.gameMode)}`; + switch (data.gameMode) { + case GameModes.CLASSIC: + fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`; + break; + case GameModes.ENDLESS: + case GameModes.SPLICED_ENDLESS: + fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`; + break; + case GameModes.DAILY: { + const runDay = new Date(data.timestamp).toLocaleDateString(); + fallbackName += ` (${runDay})`; + break; + } + case GameModes.CHALLENGE: { + const activeChallenges = data.challenges.filter(c => c.value !== 0); + if (activeChallenges.length === 0) { + break; + } + + fallbackName = ""; + for (const challenge of activeChallenges.slice(0, 3)) { + if (fallbackName !== "") { + fallbackName += ", "; + } + fallbackName += challenge.toChallenge().getName(); + } + + if (activeChallenges.length > 3) { + fallbackName += ", ..."; + } else if (fallbackName === "") { + // Something went wrong when retrieving the names of the active challenges, + // so fall back to just naming the run "Challenge" + fallbackName = `${GameMode.getModeName(data.gameMode)}`; + } + break; + } + } + return fallbackName; + } + async setupWithData(data: SessionSaveData) { + const hasName = data?.runNameText; this.remove(this.loadingLabel, true); + if (hasName) { + const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW); + this.add(nameLabel); + } else { + const fallbackName = this.decideFallback(data); + await globalScene.gameData.renameSession(this.slotId, fallbackName); + const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW); + this.add(nameLabel); + } const gameModeLabel = addTextObject( 8, - 5, + 19, `${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`, TextStyle.WINDOW, ); this.add(gameModeLabel); - const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW); + const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW); this.add(timestampLabel); - const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW); + const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW); this.add(playTimeLabel); - const pokemonIconsContainer = globalScene.add.container(144, 4); + const pokemonIconsContainer = globalScene.add.container(144, 16); data.party.forEach((p: PokemonData, i: number) => { const iconContainer = globalScene.add.container(26 * i, 0); iconContainer.setScale(0.75); @@ -441,7 +608,7 @@ class SessionSlot extends Phaser.GameObjects.Container { this.add(pokemonIconsContainer); - const modifierIconsContainer = globalScene.add.container(148, 30); + const modifierIconsContainer = globalScene.add.container(148, 38); modifierIconsContainer.setScale(0.5); let visibleModifierIndex = 0; for (const m of data.modifiers) { @@ -464,22 +631,32 @@ class SessionSlot extends Phaser.GameObjects.Container { load(): Promise { return new Promise(resolve => { - globalScene.gameData.getSession(this.slotId).then(async sessionData => { - // Ignore the results if the view was exited - if (!this.active) { - return; - } - if (!sessionData) { - this.hasData = false; - this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty")); - resolve(false); - return; - } - this.hasData = true; - this.saveData = sessionData; - await this.setupWithData(sessionData); - resolve(true); - }); + globalScene.gameData + .getSession(this.slotId) + .then(async sessionData => { + // Ignore the results if the view was exited + if (!this.active) { + return; + } + this.hasData = !!sessionData; + if (!sessionData) { + this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty")); + resolve(false); + return; + } + this.saveData = sessionData; + resolve(true); + }) + .catch(e => { + if (!this.active) { + return; + } + console.warn(`Failed to load session slot #${this.slotId}:`, e); + this.loadingLabel.setText(i18next.t("menu:failedToLoadSession")); + this.hasData = true; + this.malformed = true; + resolve(true); + }); }); } } diff --git a/src/ui/ui.ts b/src/ui/ui.ts index d5baea07ed5..e381d205b78 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -60,6 +60,7 @@ import { addWindow } from "#ui/ui-theme"; import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler"; import { executeIf } from "#utils/common"; import i18next from "i18next"; +import { RenameRunFormUiHandler } from "./rename-run-ui-handler"; const transitionModes = [ UiMode.SAVE_SLOT, @@ -98,6 +99,7 @@ const noTransitionModes = [ UiMode.SESSION_RELOAD, UiMode.UNAVAILABLE, UiMode.RENAME_POKEMON, + UiMode.RENAME_RUN, UiMode.TEST_DIALOGUE, UiMode.AUTO_COMPLETE, UiMode.ADMIN, @@ -168,6 +170,7 @@ export class UI extends Phaser.GameObjects.Container { new UnavailableModalUiHandler(), new GameChallengesUiHandler(), new RenameFormUiHandler(), + new RenameRunFormUiHandler(), new RunHistoryUiHandler(), new RunInfoUiHandler(), new TestDialogueUiHandler(UiMode.TEST_DIALOGUE), diff --git a/test/system/rename-run.test.ts b/test/system/rename-run.test.ts new file mode 100644 index 00000000000..5031d84245f --- /dev/null +++ b/test/system/rename-run.test.ts @@ -0,0 +1,82 @@ +import * as account from "#app/account"; +import * as bypassLoginModule from "#app/global-vars/bypass-login"; +import { pokerogueApi } from "#app/plugins/api/pokerogue-api"; +import type { SessionSaveData } from "#app/system/game-data"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("System - Rename Run", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset([MoveId.SPLASH]) + .battleStyle("single") + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + describe("renameSession", () => { + beforeEach(() => { + vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false); + vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]); + }); + + it("should return false if slotId < 0", async () => { + const result = await game.scene.gameData.renameSession(-1, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return false if getSession returns null", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData); + + const result = await game.scene.gameData.renameSession(-1, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return true if bypassLogin is true", async () => { + vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true); + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(true); + }); + + it("should return false if api returns error", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!"); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return true if api is succesfull", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue(""); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(true); + expect(account.updateUserInfo).toHaveBeenCalled(); + }); + }); +}); From 23271901cf3ed0292c198ecde17511ee38ceece2 Mon Sep 17 00:00:00 2001 From: fabske0 <192151969+fabske0@users.noreply.github.com> Date: Thu, 14 Aug 2025 03:12:00 +0200 Subject: [PATCH 2/8] [Docs] Add locale key naming info to `localization.md` (#6260) --- docs/localization.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index 0fe950a361d..c325aaf55a9 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -90,9 +90,13 @@ If this feature requires new text, the text should be integrated into the code w - For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text. [Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice. - You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response. -3. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes). -4. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`. -5. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment. +3. Your locales should use the following format: + - File names should be in `kebab-case`. Example: `trainer-names.json` + - Key names should be in `camelCase`. Example: `aceTrainer` + - If you make use of i18next's inbuilt [context support](https://www.i18next.com/translation-function/context), you need to use `snake_case` for the context key. Example: `aceTrainer_male` +4. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes). +5. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`. +6. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment. [^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates). If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle. From 076ef81691984df94d78212866145c505d221baa Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:49:46 -0500 Subject: [PATCH 3/8] [Bug] [UI/UX] [Beta] Fix icons not showing in save slot selection (#6262) Fix icons not showing in save slot selection --- src/ui/save-slot-select-ui-handler.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/ui/save-slot-select-ui-handler.ts b/src/ui/save-slot-select-ui-handler.ts index c86f2ea66bf..52e145e6439 100644 --- a/src/ui/save-slot-select-ui-handler.ts +++ b/src/ui/save-slot-select-ui-handler.ts @@ -594,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container { TextStyle.PARTY, { fontSize: "54px", color: "#f8f8f8" }, ); - text.setShadow(0, 0, undefined); - text.setStroke("#424242", 14); - text.setOrigin(1, 0); - - iconContainer.add(icon); - iconContainer.add(text); + text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0); + iconContainer.add([icon, text]); pokemonIconsContainer.add(iconContainer); pokemon.destroy(); @@ -645,6 +641,7 @@ class SessionSlot extends Phaser.GameObjects.Container { return; } this.saveData = sessionData; + this.setupWithData(sessionData); resolve(true); }) .catch(e => { From b44f0a4176a383c6f2427a3f8da4c80aadeb1534 Mon Sep 17 00:00:00 2001 From: fabske0 <192151969+fabske0@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:52:56 +0200 Subject: [PATCH 4/8] [Refactor] Remove bgm param from arena constructor (#6254) --- src/battle-scene.ts | 4 ++-- src/field/arena.ts | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 271cde1aaa9..52af0c1b706 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1513,8 +1513,8 @@ export class BattleScene extends SceneBase { return this.currentBattle; } - newArena(biome: BiomeId, playerFaints?: number): Arena { - this.arena = new Arena(biome, BiomeId[biome].toLowerCase(), playerFaints); + newArena(biome: BiomeId, playerFaints = 0): Arena { + this.arena = new Arena(biome, playerFaints); this.eventTarget.dispatchEvent(new NewArenaEvent()); this.arenaBg.pipelineData = { diff --git a/src/field/arena.ts b/src/field/arena.ts index 6f2310b95c2..2ce347b5337 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -54,7 +54,7 @@ export class Arena { public bgm: string; public ignoreAbilities: boolean; public ignoringEffectSource: BattlerIndex | null; - public playerTerasUsed: number; + public playerTerasUsed = 0; /** * Saves the number of times a party pokemon faints during a arena encounter. * {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave). @@ -68,12 +68,11 @@ export class Arena { public readonly eventTarget: EventTarget = new EventTarget(); - constructor(biome: BiomeId, bgm: string, playerFaints = 0) { + constructor(biome: BiomeId, playerFaints = 0) { this.biomeType = biome; - this.bgm = bgm; + this.bgm = BiomeId[biome].toLowerCase(); this.trainerPool = biomeTrainerPools[biome]; this.updatePoolsForTimeOfDay(); - this.playerTerasUsed = 0; this.playerFaints = playerFaints; } From f42237d415596f0f07b12a71524c7b1c2b145c7d Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:25:44 -0400 Subject: [PATCH 5/8] [Refactor] Removed `map(x => x)` (#6256) * Enforced a few usages of `toCamelCase` * Removed `map(x => x)` * Removed more maps and sufff * Update test/mystery-encounter/encounters/weird-dream-encounter.test.ts * Update game-data.ts types to work --- src/battle-scene.ts | 12 ++-- src/data/abilities/ability.ts | 9 +-- src/data/balance/pokemon-evolutions.ts | 9 ++- src/data/moves/move.ts | 18 +++-- .../pokemon-forms/form-change-triggers.ts | 7 +- src/loading-scene.ts | 4 +- src/modifier/modifier.ts | 4 +- src/system/game-data.ts | 67 ++++++++++--------- src/ui/run-info-ui-handler.ts | 6 +- src/ui/test-dialogue-ui-handler.ts | 2 +- .../encounters/weird-dream-encounter.test.ts | 2 +- 11 files changed, 72 insertions(+), 68 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 52af0c1b706..2a24d4144d8 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -104,6 +104,7 @@ import { getLuckString, getLuckTextTint, getPartyLuckValue, + type ModifierType, PokemonHeldItemModifierType, } from "#modifiers/modifier-type"; import { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; @@ -1203,7 +1204,9 @@ export class BattleScene extends SceneBase { this.updateScoreText(); this.scoreText.setVisible(false); - [this.luckLabelText, this.luckText].map(t => t.setVisible(false)); + [this.luckLabelText, this.luckText].forEach(t => { + t.setVisible(false); + }); this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN); @@ -1237,8 +1240,7 @@ export class BattleScene extends SceneBase { Object.values(mp) .flat() .map(mt => mt.modifierType) - .filter(mt => "localize" in mt) - .map(lpb => lpb as unknown as Localizable), + .filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"), ), ]; for (const item of localizable) { @@ -2711,7 +2713,9 @@ export class BattleScene extends SceneBase { } } - this.party.map(p => p.updateInfo(instant)); + this.party.forEach(p => { + p.updateInfo(instant); + }); } else { const args = [this]; if (modifier.shouldApply(...args)) { diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index c7c10d46d38..03670835dbd 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -74,6 +74,7 @@ import { randSeedItem, toDmgValue, } from "#utils/common"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; export class Ability implements Localizable { @@ -109,13 +110,9 @@ export class Ability implements Localizable { } localize(): void { - const i18nKey = AbilityId[this.id] - .split("_") - .filter(f => f) - .map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase())) - .join("") as string; + const i18nKey = toCamelCase(AbilityId[this.id]); - this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`) as string}${this.nameAppend}` : ""; + this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`)}${this.nameAppend}` : ""; this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : ""; } diff --git a/src/data/balance/pokemon-evolutions.ts b/src/data/balance/pokemon-evolutions.ts index ab535682e86..5d3537f4255 100644 --- a/src/data/balance/pokemon-evolutions.ts +++ b/src/data/balance/pokemon-evolutions.ts @@ -1866,17 +1866,16 @@ interface PokemonPrevolutions { export const pokemonPrevolutions: PokemonPrevolutions = {}; export function initPokemonPrevolutions(): void { - const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ].map(sfk => sfk as string); - const prevolutionKeys = Object.keys(pokemonEvolutions); - prevolutionKeys.forEach(pk => { - const evolutions = pokemonEvolutions[pk]; + // TODO: Why do we have empty strings in our array? + const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ]; + for (const [pk, evolutions] of Object.entries(pokemonEvolutions)) { for (const ev of evolutions) { if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) { continue; } pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId; } - }); + } } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 442b6fabb51..5c4061ec388 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -90,7 +90,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS import type { TurnMove } from "#types/turn-move"; import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; -import { toTitleCase } from "#utils/strings"; +import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { applyChallenges } from "#utils/challenge-utils"; @@ -162,10 +162,16 @@ export abstract class Move implements Localizable { } localize(): void { - const i18nKey = MoveId[this.id].split("_").filter(f => f).map((f, i) => i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()).join("") as unknown as string; + const i18nKey = toCamelCase(MoveId[this.id]) - this.name = this.id ? `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}` : ""; - this.effect = this.id ? `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}` : ""; + if (this.id === MoveId.NONE) { + this.name = ""; + this.effect = "" + return; + } + + this.name = `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}`; + this.effect = `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}`; } /** @@ -5926,8 +5932,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; diff --git a/src/data/pokemon-forms/form-change-triggers.ts b/src/data/pokemon-forms/form-change-triggers.ts index 75734bf085b..c24466eb5ec 100644 --- a/src/data/pokemon-forms/form-change-triggers.ts +++ b/src/data/pokemon-forms/form-change-triggers.ts @@ -12,6 +12,7 @@ import { WeatherType } from "#enums/weather-type"; import type { Pokemon } from "#field/pokemon"; import type { PokemonFormChangeItemModifier } from "#modifiers/modifier"; import { type Constructor, coerceArray } from "#utils/common"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; export abstract class SpeciesFormChangeTrigger { @@ -143,11 +144,7 @@ export class SpeciesFormChangeMoveLearnedTrigger extends SpeciesFormChangeTrigge super(); this.move = move; this.known = known; - const moveKey = MoveId[this.move] - .split("_") - .filter(f => f) - .map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase())) - .join("") as unknown as string; + const moveKey = toCamelCase(MoveId[this.move]); this.description = known ? i18next.t("pokemonEvolutions:Forms.moveLearned", { move: i18next.t(`move:${moveKey}.name`), diff --git a/src/loading-scene.ts b/src/loading-scene.ts index d2b4a76ef10..6ff39ef3dde 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -447,7 +447,9 @@ export class LoadingScene extends SceneBase { ); if (!mobile) { - loadingGraphics.map(g => g.setVisible(false)); + loadingGraphics.forEach(g => { + g.setVisible(false); + }); } const intro = this.add.video(0, 0); diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 6907b6907ca..75c18c67f38 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -121,8 +121,8 @@ export class ModifierBar extends Phaser.GameObjects.Container { } updateModifierOverflowVisibility(ignoreLimit: boolean) { - const modifierIcons = this.getAll().reverse(); - for (const modifier of modifierIcons.map(m => m as Phaser.GameObjects.Container).slice(iconOverflowIndex)) { + const modifierIcons = this.getAll().reverse() as Phaser.GameObjects.Container[]; + for (const modifier of modifierIcons.slice(iconOverflowIndex)) { modifier.setVisible(ignoreLimit); } } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 0313d64dd80..589e1271e3c 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -207,10 +207,12 @@ export interface StarterData { [key: number]: StarterDataEntry; } -export interface TutorialFlags { - [key: string]: boolean; -} +// TODO: Rework into a bitmask +export type TutorialFlags = { + [key in Tutorial]: boolean; +}; +// TODO: Rework into a bitmask export interface SeenDialogues { [key: string]: boolean; } @@ -823,52 +825,51 @@ export class GameData { return true; // TODO: is `true` the correct return value? } - private loadGamepadSettings(): boolean { - Object.values(SettingGamepad) - .map(setting => setting as SettingGamepad) - .forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting])); + private loadGamepadSettings(): void { + Object.values(SettingGamepad).forEach(setting => { + setSettingGamepad(setting, settingGamepadDefaults[setting]); + }); if (!localStorage.hasOwnProperty("settingsGamepad")) { - return false; + return; } const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct? for (const setting of Object.keys(settingsGamepad)) { setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]); } - - return true; // TODO: is `true` the correct return value? } - public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean { - const key = getDataTypeKey(GameDataType.TUTORIALS); - let tutorials: object = {}; - if (localStorage.hasOwnProperty(key)) { - tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct? + /** + * Save the specified tutorial as having the specified completion status. + * @param tutorial - The {@linkcode Tutorial} whose completion status is being saved + * @param status - The completion status to set + */ + public saveTutorialFlag(tutorial: Tutorial, status: boolean): void { + // Grab the prior save data tutorial + const saveDataKey = getDataTypeKey(GameDataType.TUTORIALS); + const tutorials: TutorialFlags = localStorage.hasOwnProperty(saveDataKey) + ? JSON.parse(localStorage.getItem(saveDataKey)!) + : {}; + + // TODO: We shouldn't be storing this like that + for (const key of Object.values(Tutorial)) { + if (key === tutorial) { + tutorials[key] = status; + } else { + tutorials[key] ??= false; + } } - Object.keys(Tutorial) - .map(t => t as Tutorial) - .forEach(t => { - const key = Tutorial[t]; - if (key === tutorial) { - tutorials[key] = flag; - } else { - tutorials[key] ??= false; - } - }); - - localStorage.setItem(key, JSON.stringify(tutorials)); - - return true; + localStorage.setItem(saveDataKey, JSON.stringify(tutorials)); } public getTutorialFlags(): TutorialFlags { const key = getDataTypeKey(GameDataType.TUTORIALS); - const ret: TutorialFlags = {}; - Object.values(Tutorial) - .map(tutorial => tutorial as Tutorial) - .forEach(tutorial => (ret[Tutorial[tutorial]] = false)); + const ret: TutorialFlags = Object.values(Tutorial).reduce((acc, tutorial) => { + acc[Tutorial[tutorial]] = false; + return acc; + }, {} as TutorialFlags); if (!localStorage.hasOwnProperty(key)) { return ret; diff --git a/src/ui/run-info-ui-handler.ts b/src/ui/run-info-ui-handler.ts index 072eefad65a..8facd8e73b1 100644 --- a/src/ui/run-info-ui-handler.ts +++ b/src/ui/run-info-ui-handler.ts @@ -26,6 +26,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text"; import { UiHandler } from "#ui/ui-handler"; import { addWindow } from "#ui/ui-theme"; import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle"; @@ -706,10 +707,7 @@ export class RunInfoUiHandler extends UiHandler { rules.push(i18next.t("challenges:inverseBattle.shortName")); break; default: { - const localizationKey = Challenges[this.runInfo.challenges[i].id] - .split("_") - .map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase())) - .join(""); + const localizationKey = toCamelCase(Challenges[this.runInfo.challenges[i].id]); rules.push(i18next.t(`challenges:${localizationKey}.name`)); break; } diff --git a/src/ui/test-dialogue-ui-handler.ts b/src/ui/test-dialogue-ui-handler.ts index 4f825ed95ea..6f7c79a151b 100644 --- a/src/ui/test-dialogue-ui-handler.ts +++ b/src/ui/test-dialogue-ui-handler.ts @@ -31,7 +31,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler { // we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key // Return in the format expected by i18next - return middleKey ? `${topKey}:${middleKey.map(m => m).join(".")}.${t}` : `${topKey}:${t}`; + return middleKey ? `${topKey}:${middleKey.join(".")}.${t}` : `${topKey}:${t}`; } }) .filter(t => t); diff --git a/test/mystery-encounter/encounters/weird-dream-encounter.test.ts b/test/mystery-encounter/encounters/weird-dream-encounter.test.ts index ed0d612e967..9b430ec046e 100644 --- a/test/mystery-encounter/encounters/weird-dream-encounter.test.ts +++ b/test/mystery-encounter/encounters/weird-dream-encounter.test.ts @@ -112,7 +112,7 @@ describe("Weird Dream - Mystery Encounter", () => { it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => { await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); - const pokemonPrior = scene.getPlayerParty().map(pokemon => pokemon); + const pokemonPrior = scene.getPlayerParty().slice(); const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal()); await runMysteryEncounterToEnd(game, 1); From 76d8357d0b51362c38e8cfc11c4a5b7824d8f2cc Mon Sep 17 00:00:00 2001 From: fabske0 <192151969+fabske0@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:06:24 +0200 Subject: [PATCH 6/8] [Dev] Rename `OPP_` overrides to `ENEMY_` (#6255) rename `OPP_` to `ENEMY_` --- CONTRIBUTING.md | 2 +- src/battle-scene.ts | 16 +++---- src/field/pokemon.ts | 42 +++++++++---------- src/modifier/modifier.ts | 4 +- src/overrides.ts | 33 +++++++-------- src/phases/encounter-phase.ts | 2 +- test/@types/vitest.d.ts | 2 +- test/test-utils/game-manager.ts | 2 +- .../helpers/challenge-mode-helper.ts | 2 +- .../test-utils/helpers/classic-mode-helper.ts | 2 +- test/test-utils/helpers/daily-mode-helper.ts | 2 +- test/test-utils/helpers/move-helper.ts | 8 ++-- test/test-utils/helpers/overrides-helper.ts | 26 ++++++------ test/test-utils/matchers/to-have-used-pp.ts | 2 +- 14 files changed, 72 insertions(+), 73 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d56b868cff..0217ebd28a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ For example, here is how you could test a scenario where the player Pokemon has ```typescript const overrides = { ABILITY_OVERRIDE: AbilityId.DROUGHT, - OPP_MOVESET_OVERRIDE: MoveId.WATER_GUN, + ENEMY_MOVESET_OVERRIDE: MoveId.WATER_GUN, } satisfies Partial>; ``` diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 2a24d4144d8..4a136a1696a 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -944,17 +944,17 @@ export class BattleScene extends SceneBase { dataSource?: PokemonData, postProcess?: (enemyPokemon: EnemyPokemon) => void, ): EnemyPokemon { - if (Overrides.OPP_LEVEL_OVERRIDE > 0) { - level = Overrides.OPP_LEVEL_OVERRIDE; + if (Overrides.ENEMY_LEVEL_OVERRIDE > 0) { + level = Overrides.ENEMY_LEVEL_OVERRIDE; } - if (Overrides.OPP_SPECIES_OVERRIDE) { - species = getPokemonSpecies(Overrides.OPP_SPECIES_OVERRIDE); + if (Overrides.ENEMY_SPECIES_OVERRIDE) { + species = getPokemonSpecies(Overrides.ENEMY_SPECIES_OVERRIDE); // The fact that a Pokemon is a boss or not can change based on its Species and level boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1; } const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource); - if (Overrides.OPP_FUSION_OVERRIDE) { + if (Overrides.ENEMY_FUSION_OVERRIDE) { pokemon.generateFusionSpecies(); } @@ -1766,10 +1766,10 @@ export class BattleScene extends SceneBase { } getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number { - if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1) { - return Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE; + if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1) { + return Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE; } - if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE === 1) { + if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE === 1) { // The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss return 0; } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 29f775ad094..7f13bf86e7d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1825,7 +1825,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { // Overrides moveset based on arrays specified in overrides.ts let overrideArray: MoveId | Array = this.isPlayer() ? Overrides.MOVESET_OVERRIDE - : Overrides.OPP_MOVESET_OVERRIDE; + : Overrides.ENEMY_MOVESET_OVERRIDE; overrideArray = coerceArray(overrideArray); if (overrideArray.length > 0) { if (!this.isPlayer()) { @@ -2030,8 +2030,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.ABILITY_OVERRIDE && this.isPlayer()) { return allAbilities[Overrides.ABILITY_OVERRIDE]; } - if (Overrides.OPP_ABILITY_OVERRIDE && this.isEnemy()) { - return allAbilities[Overrides.OPP_ABILITY_OVERRIDE]; + if (Overrides.ENEMY_ABILITY_OVERRIDE && this.isEnemy()) { + return allAbilities[Overrides.ENEMY_ABILITY_OVERRIDE]; } if (this.isFusion()) { if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) { @@ -2060,8 +2060,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.PASSIVE_ABILITY_OVERRIDE && this.isPlayer()) { return allAbilities[Overrides.PASSIVE_ABILITY_OVERRIDE]; } - if (Overrides.OPP_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { - return allAbilities[Overrides.OPP_PASSIVE_ABILITY_OVERRIDE]; + if (Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { + return allAbilities[Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE]; } if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) { return allAbilities[this.customPokemonData.passive]; @@ -2128,14 +2128,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { // returns override if valid for current case if ( (Overrides.HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isPlayer()) || - (Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy()) + (Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy()) ) { return false; } if ( ((Overrides.PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.HAS_PASSIVE_ABILITY_OVERRIDE) && this.isPlayer()) || - ((Overrides.OPP_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE) && + ((Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE) && this.isEnemy()) ) { return true; @@ -3001,8 +3001,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (forStarter && this.isPlayer() && Overrides.STARTER_FUSION_SPECIES_OVERRIDE) { fusionOverride = getPokemonSpecies(Overrides.STARTER_FUSION_SPECIES_OVERRIDE); - } else if (this.isEnemy() && Overrides.OPP_FUSION_SPECIES_OVERRIDE) { - fusionOverride = getPokemonSpecies(Overrides.OPP_FUSION_SPECIES_OVERRIDE); + } else if (this.isEnemy() && Overrides.ENEMY_FUSION_SPECIES_OVERRIDE) { + fusionOverride = getPokemonSpecies(Overrides.ENEMY_FUSION_SPECIES_OVERRIDE); } this.fusionSpecies = @@ -6241,22 +6241,22 @@ export class EnemyPokemon extends Pokemon { this.setBoss(boss, dataSource?.bossSegments); } - if (Overrides.OPP_STATUS_OVERRIDE) { - this.status = new Status(Overrides.OPP_STATUS_OVERRIDE, 0, 4); + if (Overrides.ENEMY_STATUS_OVERRIDE) { + this.status = new Status(Overrides.ENEMY_STATUS_OVERRIDE, 0, 4); } - if (Overrides.OPP_GENDER_OVERRIDE !== null) { - this.gender = Overrides.OPP_GENDER_OVERRIDE; + if (Overrides.ENEMY_GENDER_OVERRIDE !== null) { + this.gender = Overrides.ENEMY_GENDER_OVERRIDE; } const speciesId = this.species.speciesId; if ( - speciesId in Overrides.OPP_FORM_OVERRIDES && - !isNullOrUndefined(Overrides.OPP_FORM_OVERRIDES[speciesId]) && - this.species.forms[Overrides.OPP_FORM_OVERRIDES[speciesId]] + speciesId in Overrides.ENEMY_FORM_OVERRIDES && + !isNullOrUndefined(Overrides.ENEMY_FORM_OVERRIDES[speciesId]) && + this.species.forms[Overrides.ENEMY_FORM_OVERRIDES[speciesId]] ) { - this.formIndex = Overrides.OPP_FORM_OVERRIDES[speciesId]; + this.formIndex = Overrides.ENEMY_FORM_OVERRIDES[speciesId]; } else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) { const eventBoss = getDailyEventSeedBoss(globalScene.seed); if (!isNullOrUndefined(eventBoss)) { @@ -6266,21 +6266,21 @@ export class EnemyPokemon extends Pokemon { if (!dataSource) { this.generateAndPopulateMoveset(); - if (shinyLock || Overrides.OPP_SHINY_OVERRIDE === false) { + if (shinyLock || Overrides.ENEMY_SHINY_OVERRIDE === false) { this.shiny = false; } else { this.trySetShiny(); } - if (!this.shiny && Overrides.OPP_SHINY_OVERRIDE) { + if (!this.shiny && Overrides.ENEMY_SHINY_OVERRIDE) { this.shiny = true; this.initShinySparkle(); } if (this.shiny) { this.variant = this.generateShinyVariant(); - if (Overrides.OPP_VARIANT_OVERRIDE !== null) { - this.variant = Overrides.OPP_VARIANT_OVERRIDE; + if (Overrides.ENEMY_VARIANT_OVERRIDE !== null) { + this.variant = Overrides.ENEMY_VARIANT_OVERRIDE; } } diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 75c18c67f38..fb7243a7901 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -3755,7 +3755,7 @@ export class EnemyFusionChanceModifier extends EnemyPersistentModifier { export function overrideModifiers(isPlayer = true): void { const modifiersOverride: ModifierOverride[] = isPlayer ? Overrides.STARTING_MODIFIER_OVERRIDE - : Overrides.OPP_MODIFIER_OVERRIDE; + : Overrides.ENEMY_MODIFIER_OVERRIDE; if (!modifiersOverride || modifiersOverride.length === 0 || !globalScene) { return; } @@ -3797,7 +3797,7 @@ export function overrideModifiers(isPlayer = true): void { export function overrideHeldItems(pokemon: Pokemon, isPlayer = true): void { const heldItemsOverride: ModifierOverride[] = isPlayer ? Overrides.STARTING_HELD_ITEMS_OVERRIDE - : Overrides.OPP_HELD_ITEMS_OVERRIDE; + : Overrides.ENEMY_HELD_ITEMS_OVERRIDE; if (!heldItemsOverride || heldItemsOverride.length === 0 || !globalScene) { return; } diff --git a/src/overrides.ts b/src/overrides.ts index de0d1d3f30a..48d7428cad9 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -179,25 +179,24 @@ class DefaultOverrides { // -------------------------- // OPPONENT / ENEMY OVERRIDES // -------------------------- - // TODO: rename `OPP_` to `ENEMY_` - readonly OPP_SPECIES_OVERRIDE: SpeciesId | number = 0; + readonly ENEMY_SPECIES_OVERRIDE: SpeciesId | number = 0; /** * This will make all opponents fused Pokemon */ - readonly OPP_FUSION_OVERRIDE: boolean = false; + readonly ENEMY_FUSION_OVERRIDE: boolean = false; /** * This will override the species of the fusion only when the opponent is already a fusion */ - readonly OPP_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0; - readonly OPP_LEVEL_OVERRIDE: number = 0; - readonly OPP_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; - readonly OPP_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; - readonly OPP_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; - readonly OPP_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; - readonly OPP_GENDER_OVERRIDE: Gender | null = null; - readonly OPP_MOVESET_OVERRIDE: MoveId | Array = []; - readonly OPP_SHINY_OVERRIDE: boolean | null = null; - readonly OPP_VARIANT_OVERRIDE: Variant | null = null; + readonly ENEMY_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0; + readonly ENEMY_LEVEL_OVERRIDE: number = 0; + readonly ENEMY_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; + readonly ENEMY_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; + readonly ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; + readonly ENEMY_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; + readonly ENEMY_GENDER_OVERRIDE: Gender | null = null; + readonly ENEMY_MOVESET_OVERRIDE: MoveId | Array = []; + readonly ENEMY_SHINY_OVERRIDE: boolean | null = null; + readonly ENEMY_VARIANT_OVERRIDE: Variant | null = null; /** * Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`! * - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number. @@ -207,7 +206,7 @@ class DefaultOverrides { readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null; /** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */ readonly ENEMY_NATURE_OVERRIDE: Nature | null = null; - readonly OPP_FORM_OVERRIDES: Partial> = {}; + readonly ENEMY_FORM_OVERRIDES: Partial> = {}; /** * Override to give the enemy Pokemon a given amount of health segments * @@ -215,7 +214,7 @@ class DefaultOverrides { * 1: the Pokemon will have a single health segment and therefore will not be a boss * 2+: the Pokemon will be a boss with the given number of health segments */ - readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0; + readonly ENEMY_HEALTH_SEGMENTS_OVERRIDE: number = 0; // ------------- // EGG OVERRIDES @@ -277,12 +276,12 @@ class DefaultOverrides { * * Note that any previous modifiers are cleared. */ - readonly OPP_MODIFIER_OVERRIDE: ModifierOverride[] = []; + readonly ENEMY_MODIFIER_OVERRIDE: ModifierOverride[] = []; /** Override array of {@linkcode ModifierOverride}s used to provide held items to first party member when starting a new game. */ readonly STARTING_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; /** Override array of {@linkcode ModifierOverride}s used to provide held items to enemies on spawn. */ - readonly OPP_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; + readonly ENEMY_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; /** * Override array of {@linkcode ModifierOverride}s used to replace the generated item rolls after a wave. diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 79da7134e9a..b870f7f6e7a 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -229,7 +229,7 @@ export class EncounterPhase extends BattlePhase { }), ); } else { - const overridedBossSegments = Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1; + const overridedBossSegments = Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1; // for double battles, reduce the health segments for boss Pokemon unless there is an override if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) { for (const enemyPokemon of battle.enemyParty) { diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 7b756c45a57..dc686a12083 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -130,7 +130,7 @@ declare module "vitest" { * @param ppUsed - The numerical amount of PP that should have been consumed, * or `all` to indicate the move should be _out_ of PP * @remarks - * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE}, + * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE}, * does not contain {@linkcode expectedMove} * or contains the desired move more than once, this will fail the test. */ diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index f952557bb69..05b3be21d26 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -224,7 +224,7 @@ export class GameManager { // This will consider all battle entry dialog as seens and skip them vi.spyOn(this.scene.ui, "shouldSkipDialogue").mockReturnValue(true); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0) { this.removeEnemyHeldItems(); } diff --git a/test/test-utils/helpers/challenge-mode-helper.ts b/test/test-utils/helpers/challenge-mode-helper.ts index 3952685a560..a8a9ff89de6 100644 --- a/test/test-utils/helpers/challenge-mode-helper.ts +++ b/test/test-utils/helpers/challenge-mode-helper.ts @@ -50,7 +50,7 @@ export class ChallengeModeHelper extends GameManagerHelper { }); await this.game.phaseInterceptor.run(EncounterPhase); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { this.game.removeEnemyHeldItems(); } } diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index 5d73dc07615..008648fcd0d 100644 --- a/test/test-utils/helpers/classic-mode-helper.ts +++ b/test/test-utils/helpers/classic-mode-helper.ts @@ -53,7 +53,7 @@ export class ClassicModeHelper extends GameManagerHelper { }); await this.game.phaseInterceptor.to(EncounterPhase); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { this.game.removeEnemyHeldItems(); } } diff --git a/test/test-utils/helpers/daily-mode-helper.ts b/test/test-utils/helpers/daily-mode-helper.ts index 7aa1e699118..ca882eaf548 100644 --- a/test/test-utils/helpers/daily-mode-helper.ts +++ b/test/test-utils/helpers/daily-mode-helper.ts @@ -37,7 +37,7 @@ export class DailyModeHelper extends GameManagerHelper { await this.game.phaseInterceptor.to(EncounterPhase); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { this.game.removeEnemyHeldItems(); } } diff --git a/test/test-utils/helpers/move-helper.ts b/test/test-utils/helpers/move-helper.ts index 6a01e4110da..3d5e9ae6af9 100644 --- a/test/test-utils/helpers/move-helper.ts +++ b/test/test-utils/helpers/move-helper.ts @@ -228,8 +228,8 @@ export class MoveHelper extends GameManagerHelper { console.warn("Player moveset override disabled due to use of `game.move.changeMoveset`!"); } } else { - if (coerceArray(Overrides.OPP_MOVESET_OVERRIDE).length > 0) { - vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); + if (coerceArray(Overrides.ENEMY_MOVESET_OVERRIDE).length > 0) { + vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]); console.warn("Enemy moveset override disabled due to use of `game.move.changeMoveset`!"); } } @@ -302,8 +302,8 @@ export class MoveHelper extends GameManagerHelper { (this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex() ]; - if ([Overrides.OPP_MOVESET_OVERRIDE].flat().length > 0) { - vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); + if ([Overrides.ENEMY_MOVESET_OVERRIDE].flat().length > 0) { + vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]); console.warn( "Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!", ); diff --git a/test/test-utils/helpers/overrides-helper.ts b/test/test-utils/helpers/overrides-helper.ts index d67ceedf891..93b89688935 100644 --- a/test/test-utils/helpers/overrides-helper.ts +++ b/test/test-utils/helpers/overrides-helper.ts @@ -406,7 +406,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemySpecies(species: SpeciesId | number): this { - vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(species); + vi.spyOn(Overrides, "ENEMY_SPECIES_OVERRIDE", "get").mockReturnValue(species); this.log(`Enemy Pokemon species set to ${SpeciesId[species]} (=${species})!`); return this; } @@ -416,7 +416,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enableEnemyFusion(): this { - vi.spyOn(Overrides, "OPP_FUSION_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(Overrides, "ENEMY_FUSION_OVERRIDE", "get").mockReturnValue(true); this.log("Enemy Pokemon is a random fusion!"); return this; } @@ -427,7 +427,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyFusionSpecies(species: SpeciesId | number): this { - vi.spyOn(Overrides, "OPP_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species); + vi.spyOn(Overrides, "ENEMY_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species); this.log(`Enemy Pokemon fusion species set to ${SpeciesId[species]} (=${species})!`); return this; } @@ -438,7 +438,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyAbility(ability: AbilityId): this { - vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(ability); + vi.spyOn(Overrides, "ENEMY_ABILITY_OVERRIDE", "get").mockReturnValue(ability); this.log(`Enemy Pokemon ability set to ${AbilityId[ability]} (=${ability})!`); return this; } @@ -449,7 +449,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyPassiveAbility(passiveAbility: AbilityId): this { - vi.spyOn(Overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility); + vi.spyOn(Overrides, "ENEMY_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility); this.log(`Enemy Pokemon PASSIVE ability set to ${AbilityId[passiveAbility]} (=${passiveAbility})!`); return this; } @@ -460,7 +460,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyHasPassiveAbility(hasPassiveAbility: boolean | null): this { - vi.spyOn(Overrides, "OPP_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility); + vi.spyOn(Overrides, "ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility); if (hasPassiveAbility === null) { this.log("Enemy Pokemon PASSIVE ability no longer force enabled or disabled!"); } else { @@ -475,7 +475,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyMoveset(moveset: MoveId | MoveId[]): this { - vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); + vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); moveset = coerceArray(moveset); const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", "); this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`); @@ -488,7 +488,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyLevel(level: number): this { - vi.spyOn(Overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(level); + vi.spyOn(Overrides, "ENEMY_LEVEL_OVERRIDE", "get").mockReturnValue(level); this.log(`Enemy Pokemon level set to ${level}!`); return this; } @@ -499,7 +499,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyStatusEffect(statusEffect: StatusEffect): this { - vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); + vi.spyOn(Overrides, "ENEMY_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); this.log(`Enemy Pokemon status-effect set to ${StatusEffect[statusEffect]} (=${statusEffect})!`); return this; } @@ -510,7 +510,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyHeldItems(items: ModifierOverride[]): this { - vi.spyOn(Overrides, "OPP_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items); + vi.spyOn(Overrides, "ENEMY_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items); this.log("Enemy Pokemon held items set to:", items); return this; } @@ -571,7 +571,7 @@ export class OverridesHelper extends GameManagerHelper { * @param variant - (Optional) The enemy's shiny {@linkcode Variant}. */ enemyShiny(shininess: boolean | null, variant?: Variant): this { - vi.spyOn(Overrides, "OPP_SHINY_OVERRIDE", "get").mockReturnValue(shininess); + vi.spyOn(Overrides, "ENEMY_SHINY_OVERRIDE", "get").mockReturnValue(shininess); if (shininess === null) { this.log("Disabled enemy Pokemon shiny override!"); } else { @@ -579,7 +579,7 @@ export class OverridesHelper extends GameManagerHelper { } if (variant !== undefined) { - vi.spyOn(Overrides, "OPP_VARIANT_OVERRIDE", "get").mockReturnValue(variant); + vi.spyOn(Overrides, "ENEMY_VARIANT_OVERRIDE", "get").mockReturnValue(variant); this.log(`Set enemy shiny variant to be ${variant}!`); } return this; @@ -594,7 +594,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyHealthSegments(healthSegments: number): this { - vi.spyOn(Overrides, "OPP_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); + vi.spyOn(Overrides, "ENEMY_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); this.log("Enemy Pokemon health segments set to:", healthSegments); return this; } diff --git a/test/test-utils/matchers/to-have-used-pp.ts b/test/test-utils/matchers/to-have-used-pp.ts index 3b606a535bc..1a1b37ca665 100644 --- a/test/test-utils/matchers/to-have-used-pp.ts +++ b/test/test-utils/matchers/to-have-used-pp.ts @@ -33,7 +33,7 @@ export function toHaveUsedPP( }; } - const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE; + const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.ENEMY_MOVESET_OVERRIDE; if (coerceArray(override).length > 0) { return { pass: false, From 140e4ab142c4fb7333e13609a89e1dfb0e010c5c Mon Sep 17 00:00:00 2001 From: Wlowscha <54003515+Wlowscha@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:10:15 +0200 Subject: [PATCH 7/8] [UI/UX] Party slots refactor (#6199) * constants for position of discard button * Moved transfer/discard button up in doubles * Fixed the various `.setOrigin(0,0)` * Small clean up * Added `isBenched` property to slots; x origin of `slotBg` is now 0 * Also set y origin to 0 * Offsets are relevant to the same thing * Introducing const object to store ui magic numbers * More magic numbers in const * Laid out numbers for slot positions * Added smaller main slots for transfer mode in doubles * Changed background to fit new slot disposition * Apply suggestions from code review Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> * Optimized PNGs * Updated comment * Removed "magicNumbers" container, added multiple comments * Update src/ui/party-ui-handler.ts Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com> * Fainted pkmn slots displaying correctly --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> Co-authored-by: Adri1 Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com> Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> --- public/images/ui/party_bg_double_manage.png | Bin 837 -> 799 bytes public/images/ui/party_slot_main_short.json | 146 ++++++++++ public/images/ui/party_slot_main_short.png | Bin 0 -> 1034 bytes src/loading-scene.ts | 1 + src/ui/party-ui-handler.ts | 284 +++++++++++--------- 5 files changed, 305 insertions(+), 126 deletions(-) create mode 100644 public/images/ui/party_slot_main_short.json create mode 100644 public/images/ui/party_slot_main_short.png diff --git a/public/images/ui/party_bg_double_manage.png b/public/images/ui/party_bg_double_manage.png index e85413b5fb55d894dffaf267d5b291ae77c98894..f1561422867de2c364c897f54d660b085b001255 100644 GIT binary patch delta 700 zcmX@gHlJ-me*J7u7srr_TW{|iTzA<(z%9}FWr7zIYsLTc)W8W|OVciT2HpO7ZrQF` zNB3HN3fcF~UT?Yd^ZoOux6eQS>hxD9fkqY~4+Te-eclg#*6Te_y&}JXX{!N9@PZeU zvR>h0yPrPS+r=aqnNjcRSu4NHusDp5SwP{ek+|B9FIyuw%++oBr>B z@BZfNtM&&z2${}$?AreNsa1OdABcD}CBJfduz$T0Q~Wyi#*3wROf>T%QKdFhYG{|9;|Fu z{+sd5;p);$2U*Ov^Vh$z0IJJMWNPi^(*-J1W1i-iV7AaJK}mi`xJSX2ng?;u3k@qy z)O@Z#w)D$YPL&A<7>zIWa=g(|zI&%KTL1f@tye1__8DJdQCn}ysq*12Q?x|++sFjr z$LkKcp8hJ~`{3R8L!}dHAN>B*xUhA(?(ta8DFLbz93(TD_$N=yGpv`7*nNZjTHEJF zqf4K_fe9pT-22_A{G55MgRT$AFF+4HYdy;y-6*@gV87(cf|XOM+;{aBUhlgaXY4r!BEv^Z4l^v3vY3pZ+>%`)~_`!cWj6q5S6oz0Z}pPnp*mK%8-9ufux} z`G-L#tuNi1Shn&>(p(27V0bwEbodR#P^T{F`2q|d-?n$_Y%dhq1-RW>D&z=s#sftb zrIYzfH1tzuJFnD0` YhmoyOl;t=l!wQgqr>mdKI;Vst03h~8-v9sr delta 738 zcmbQwc9d;Ge*HmD7srr_TW{~)?YnFsz;?mmg+SA3u?K(WPwj3B(fY+a*<@SszS&t% zyWh#(yn8~)2jHs$YijY?QW62d2W?N0Z)VF0mGd1 zrwqs0wtY!H%zO2CLp|TrGM0$D#R-SMBI#Sjf5(670?7={_bc6Yl^-xfJuN^Fzt!eIH&KFSsrBGQ5`UYv9uV)059y{X5Ob z!Xcn=Kz3yGY_46h`lYn++qj?n zwM*)SO)qWz(wn*aqaVYc|GymW8_ikGzh-g1&uj0B&-)Bz8Opx01FgGYap3wDcC*O& z;lZ^FB-1#Vfi4L6+>rZy;yt+yU+pgaVo&?+k#ECn{X&^H;JX<(RYZK5c+LG5)Dhb@ zOEKlFaocrj(e7aGossep2WL8;Hoe3b-8<(LD3~r>ZkTk2-!t`x-b$YPDpMb>UShPz zQ|queP()`X&wQZB;w45k6GGa)PvQc`y|w4jysi!?I8paWcsINE+LQTiFBpKp)78&q Iol`;+0LhzHZ~y=R diff --git a/public/images/ui/party_slot_main_short.json b/public/images/ui/party_slot_main_short.json new file mode 100644 index 00000000000..d738d524a5b --- /dev/null +++ b/public/images/ui/party_slot_main_short.json @@ -0,0 +1,146 @@ +{ + "textures": [ + { + "image": "party_slot_main_short.png", + "format": "RGBA8888", + "size": { + "w": 110, + "h": 294 + }, + "scale": 1, + "frames": [ + { + "filename": "party_slot_main_short", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_sel", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 41, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_fnt", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 82, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_fnt_sel", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 123, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_swap", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 164, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_swap_sel", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 205, + "w": 110, + "h": 41 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:29685f2f538901cf5bf7f0ed2ea867c3:a080ea6c8cccd1e03244214053e79796:565f7afc5ca419b6ba8dbce51ea30818$" + } +} diff --git a/public/images/ui/party_slot_main_short.png b/public/images/ui/party_slot_main_short.png new file mode 100644 index 0000000000000000000000000000000000000000..4a4ef9ae9376d8098abb320628c77af59e7b3dee GIT binary patch literal 1034 zcmeAS@N?(olHy`uVBq!ia0vp^c|iP)gBeINY;bxGBohLBLR>-If)xxMHy9Q)Fs%5_ za9{#Q#srRr42}g290wK%+}Pmp<3qfJK!$`sgMh$-A3s(wIIM8c*r8EyksKtTn8iA9pk^*EqE+#xb94Cc|}1NoR~L#;l2wO*Dd;EH{sj%BBA8>8VrX5L|i&OW<@VK z`!76e>1C}S&(5;(DL#=_Y@4bpvSsS0YkaqUE;8J2-naYsX)&Oaw4hG90&*eOoCOCt zrRFR;sELL6yVSQ~+r_Wv=RPo9XMHgHjxp={k9&moY}9T=9+2xXR;p0$QvF1f95l9 z?wn{~1nvSl%MTb@!H1i(FMoKz!s~b8fe5ePMNDFymo&r1%TM3?eP}9HuQ)hYv*O@) zzYj_EQy1TF4z{s43MbA-=gv80^Y)gtmxCiuTVUdjA6hx@f$_F@Uf9K&%s=J@9EbNf z*)0%uiG0pilyP11e5w4c)8?PP{P=8RTmS5|b@_si1;JAM<|{uIOa@ZRJ{B~#aht>} zdMLy@J!6NF*Gj_Ke!2d?Z1K5Q^s)shb~Ty#h28yki}l{4eL(As zjX#LJ-+dsudi9>O)nGgHJ{EMgd)vf3|LgzwPhtL3S(E=4tzrUR8iF#lr>mdKI;Vst E0G4>^)Bpeg literal 0 HcmV?d00001 diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 6ff39ef3dde..248bd578290 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -122,6 +122,7 @@ export class LoadingScene extends SceneBase { this.loadImage("party_bg_double", "ui"); this.loadImage("party_bg_double_manage", "ui"); this.loadAtlas("party_slot_main", "ui"); + this.loadAtlas("party_slot_main_short", "ui"); this.loadAtlas("party_slot", "ui"); this.loadImage("party_slot_overlay_lv", "ui"); this.loadImage("party_slot_hp_bar", "ui"); diff --git a/src/ui/party-ui-handler.ts b/src/ui/party-ui-handler.ts index ff5e7246a6f..566eeee4e44 100644 --- a/src/ui/party-ui-handler.ts +++ b/src/ui/party-ui-handler.ts @@ -31,6 +31,11 @@ import { toTitleCase } from "#utils/strings"; import i18next from "i18next"; import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; +const DISCARD_BUTTON_X = 60; +const DISCARD_BUTTON_X_DOUBLES = 64; +const DISCARD_BUTTON_Y = -73; +const DISCARD_BUTTON_Y_DOUBLES = -58; + const defaultMessage = i18next.t("partyUiHandler:choosePokemon"); /** @@ -301,7 +306,7 @@ export class PartyUiHandler extends MessageUiHandler { const partyMessageText = addTextObject(10, 8, defaultMessage, TextStyle.WINDOW, { maxLines: 2 }); partyMessageText.setName("text-party-msg"); - partyMessageText.setOrigin(0, 0); + partyMessageText.setOrigin(0); partyMessageBoxContainer.add(partyMessageText); this.message = partyMessageText; @@ -317,10 +322,8 @@ export class PartyUiHandler extends MessageUiHandler { this.iconAnimHandler = new PokemonIconAnimHandler(); this.iconAnimHandler.setup(); - const partyDiscardModeButton = new PartyDiscardModeButton(60, -globalScene.game.canvas.height / 15 - 1, this); - + const partyDiscardModeButton = new PartyDiscardModeButton(DISCARD_BUTTON_X, DISCARD_BUTTON_Y, this); partyContainer.add(partyDiscardModeButton); - this.partyDiscardModeButton = partyDiscardModeButton; // prepare move overlay @@ -1233,7 +1236,7 @@ export class PartyUiHandler extends MessageUiHandler { } if (!this.optionsCursorObj) { this.optionsCursorObj = globalScene.add.image(0, 0, "cursor"); - this.optionsCursorObj.setOrigin(0, 0); + this.optionsCursorObj.setOrigin(0); this.optionsContainer.add(this.optionsCursorObj); } this.optionsCursorObj.setPosition( @@ -1605,7 +1608,7 @@ export class PartyUiHandler extends MessageUiHandler { optionText.setColor("#40c8f8"); optionText.setShadowColor("#006090"); } - optionText.setOrigin(0, 0); + optionText.setOrigin(0); /** For every item that has stack bigger than 1, display the current quantity selection */ const itemModifiers = this.getItemModifiers(pokemon); @@ -1802,6 +1805,7 @@ class PartySlot extends Phaser.GameObjects.Container { private selected: boolean; private transfer: boolean; private slotIndex: number; + private isBenched: boolean; private pokemon: PlayerPokemon; private slotBg: Phaser.GameObjects.Image; @@ -1812,6 +1816,7 @@ class PartySlot extends Phaser.GameObjects.Container { public slotHpText: Phaser.GameObjects.Text; public slotDescriptionLabel: Phaser.GameObjects.Text; // this is used to show text instead of the HP bar i.e. for showing "Able"/"Not Able" for TMs when you try to learn them + private slotBgKey: string; private pokemonIcon: Phaser.GameObjects.Container; private iconAnimHandler: PokemonIconAnimHandler; @@ -1822,19 +1827,34 @@ class PartySlot extends Phaser.GameObjects.Container { partyUiMode: PartyUiMode, tmMoveId: MoveId, ) { - super( - globalScene, - slotIndex >= globalScene.currentBattle.getBattlerCount() ? 230.5 : 64, - slotIndex >= globalScene.currentBattle.getBattlerCount() - ? -184 + - (globalScene.currentBattle.double ? -40 : 0) + - (28 + (globalScene.currentBattle.double ? 8 : 0)) * slotIndex - : partyUiMode === PartyUiMode.MODIFIER_TRANSFER - ? -124 + (globalScene.currentBattle.double ? -20 : 0) + slotIndex * 55 - : -124 + (globalScene.currentBattle.double ? -8 : 0) + slotIndex * 64, - ); + const isBenched = slotIndex >= globalScene.currentBattle.getBattlerCount(); + const isDoubleBattle = globalScene.currentBattle.double; + const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD; + + /* + * Here we determine the position of the slot. + * The x coordinate depends on whether the pokemon is on the field or in the bench. + * The y coordinate depends on various factors, such as the number of pokémon on the field, + * and whether the transfer/discard button is also on the screen. + */ + const slotPositionX = isBenched ? 143 : 9; + + let slotPositionY: number; + if (isBenched) { + slotPositionY = -196 + (isDoubleBattle ? -40 : 0); + slotPositionY += (28 + (isDoubleBattle ? 8 : 0)) * slotIndex; + } else { + slotPositionY = -148.5; + if (isDoubleBattle) { + slotPositionY += isItemManageMode ? -20 : -8; + } + slotPositionY += (isItemManageMode ? (isDoubleBattle ? 47 : 55) : 64) * slotIndex; + } + + super(globalScene, slotPositionX, slotPositionY); this.slotIndex = slotIndex; + this.isBenched = isBenched; this.pokemon = pokemon; this.iconAnimHandler = iconAnimHandler; @@ -1848,27 +1868,75 @@ class PartySlot extends Phaser.GameObjects.Container { setup(partyUiMode: PartyUiMode, tmMoveId: MoveId) { const currentLanguage = i18next.resolvedLanguage ?? "en"; const offsetJa = currentLanguage === "ja"; + const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD; - const battlerCount = globalScene.currentBattle.getBattlerCount(); + this.slotBgKey = this.isBenched + ? "party_slot" + : isItemManageMode && globalScene.currentBattle.double + ? "party_slot_main_short" + : "party_slot_main"; + const fullSlotBgKey = this.pokemon.hp ? this.slotBgKey : `${this.slotBgKey}${"_fnt"}`; + this.slotBg = globalScene.add.sprite(0, 0, this.slotBgKey, fullSlotBgKey); + this.slotBg.setOrigin(0); + this.add(this.slotBg); - const slotKey = `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`; + const genderSymbol = getGenderSymbol(this.pokemon.getGender(true)); + const isFusion = this.pokemon.isFusion(); - const slotBg = globalScene.add.sprite(0, 0, slotKey, `${slotKey}${this.pokemon.hp ? "" : "_fnt"}`); - this.slotBg = slotBg; + // Here we define positions and offsets + // Base values are for the active pokemon; they are changed for benched pokemon, + // or for active pokemon if in a double battle in item management mode. - this.add(slotBg); + // icon position relative to slot background + let slotPb = { x: 4, y: 4 }; + // name position relative to slot background + let namePosition = { x: 24, y: 10 + (offsetJa ? 2 : 0) }; + // maximum allowed length of name; must accomodate fusion symbol + let maxNameTextWidth = 76 - (isFusion ? 8 : 0); + // "Lv." label position relative to slot background + let levelLabelPosition = { x: 24 + 8, y: 10 + 12 }; + // offset from "Lv." to the level number; should not be changed. + const levelTextToLevelLabelOffset = { x: 9, y: offsetJa ? 1.5 : 0 }; + // offests from "Lv." to gender, spliced and status icons, these depend on the type of slot. + let genderTextToLevelLabelOffset = { x: 68 - (isFusion ? 8 : 0), y: -9 }; + let splicedIconToLevelLabelOffset = { x: 68, y: 3.5 - 12 }; + let statusIconToLevelLabelOffset = { x: 55, y: 0 }; + // offset from the name to the shiny icon (on the left); should not be changed. + const shinyIconToNameOffset = { x: -9, y: 3 }; + // hp bar position relative to slot background + let hpBarPosition = { x: 8, y: 31 }; + // offsets of hp bar overlay (showing the remaining hp) and number; should not be changed. + const hpOverlayToBarOffset = { x: 16, y: 2 }; + const hpTextToBarOffset = { x: -3, y: -2 + (offsetJa ? 2 : 0) }; + // description position relative to slot background + let descriptionLabelPosition = { x: 32, y: 46 }; - const slotPb = globalScene.add.sprite( - this.slotIndex >= battlerCount ? -85.5 : -51, - this.slotIndex >= battlerCount ? 0 : -20.5, - "party_pb", - ); - this.slotPb = slotPb; + // If in item management mode, the active slots are shorter + if (isItemManageMode && globalScene.currentBattle.double && !this.isBenched) { + namePosition.y -= 8; + levelLabelPosition.y -= 8; + hpBarPosition.y -= 8; + descriptionLabelPosition.y -= 8; + } - this.add(slotPb); + // Benched slots have significantly different parameters + if (this.isBenched) { + slotPb = { x: 2, y: 12 }; + namePosition = { x: 21, y: 2 + (offsetJa ? 2 : 0) }; + maxNameTextWidth = 52; + levelLabelPosition = { x: 21 + 8, y: 2 + 12 }; + genderTextToLevelLabelOffset = { x: 36, y: 0 }; + splicedIconToLevelLabelOffset = { x: 36 + (genderSymbol ? 8 : 0), y: 0.5 }; + statusIconToLevelLabelOffset = { x: 43, y: 0 }; + hpBarPosition = { x: 72, y: 6 }; + descriptionLabelPosition = { x: 94, y: 16 }; + } - this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, slotPb.x, slotPb.y, 0.5, 0.5, true); + this.slotPb = globalScene.add.sprite(0, 0, "party_pb"); + this.slotPb.setPosition(slotPb.x, slotPb.y); + this.add(this.slotPb); + this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, this.slotPb.x, this.slotPb.y, 0.5, 0.5, true); this.add(this.pokemonIcon); this.iconAnimHandler.addOrUpdate(this.pokemonIcon, PokemonIconAnimMode.PASSIVE); @@ -1882,7 +1950,7 @@ class PartySlot extends Phaser.GameObjects.Container { const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.PARTY); nameTextWidth = nameSizeTest.displayWidth; - while (nameTextWidth > (this.slotIndex >= battlerCount ? 52 : 76 - (this.pokemon.fusionSpecies ? 8 : 0))) { + while (nameTextWidth > maxNameTextWidth) { displayName = `${displayName.slice(0, displayName.endsWith(".") ? -2 : -1).trimEnd()}.`; nameSizeTest.setText(displayName); nameTextWidth = nameSizeTest.displayWidth; @@ -1891,78 +1959,59 @@ class PartySlot extends Phaser.GameObjects.Container { nameSizeTest.destroy(); this.slotName = addTextObject(0, 0, displayName, TextStyle.PARTY); - this.slotName.setPositionRelative( - slotBg, - this.slotIndex >= battlerCount ? 21 : 24, - (this.slotIndex >= battlerCount ? 2 : 10) + (offsetJa ? 2 : 0), - ); - this.slotName.setOrigin(0, 0); + this.slotName.setPositionRelative(this.slotBg, namePosition.x, namePosition.y); + this.slotName.setOrigin(0); - const slotLevelLabel = globalScene.add.image(0, 0, "party_slot_overlay_lv"); - slotLevelLabel.setPositionRelative( - slotBg, - (this.slotIndex >= battlerCount ? 21 : 24) + 8, - (this.slotIndex >= battlerCount ? 2 : 10) + 12, - ); - slotLevelLabel.setOrigin(0, 0); + const slotLevelLabel = globalScene.add + .image(0, 0, "party_slot_overlay_lv") + .setPositionRelative(this.slotBg, levelLabelPosition.x, levelLabelPosition.y) + .setOrigin(0); const slotLevelText = addTextObject( 0, 0, this.pokemon.level.toString(), this.pokemon.level < globalScene.getMaxExpLevel() ? TextStyle.PARTY : TextStyle.PARTY_RED, - ); - slotLevelText.setPositionRelative(slotLevelLabel, 9, offsetJa ? 1.5 : 0); - slotLevelText.setOrigin(0, 0.25); - + ) + .setPositionRelative(slotLevelLabel, levelTextToLevelLabelOffset.x, levelTextToLevelLabelOffset.y) + .setOrigin(0, 0.25); slotInfoContainer.add([this.slotName, slotLevelLabel, slotLevelText]); - const genderSymbol = getGenderSymbol(this.pokemon.getGender(true)); - if (genderSymbol) { - const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY); - slotGenderText.setColor(getGenderColor(this.pokemon.getGender(true))); - slotGenderText.setShadowColor(getGenderColor(this.pokemon.getGender(true), true)); - if (this.slotIndex >= battlerCount) { - slotGenderText.setPositionRelative(slotLevelLabel, 36, 0); - } else { - slotGenderText.setPositionRelative(this.slotName, 76 - (this.pokemon.fusionSpecies ? 8 : 0), 3); - } - slotGenderText.setOrigin(0, 0.25); - + const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY) + .setColor(getGenderColor(this.pokemon.getGender(true))) + .setShadowColor(getGenderColor(this.pokemon.getGender(true), true)) + .setPositionRelative(slotLevelLabel, genderTextToLevelLabelOffset.x, genderTextToLevelLabelOffset.y) + .setOrigin(0, 0.25); slotInfoContainer.add(slotGenderText); } - if (this.pokemon.fusionSpecies) { - const splicedIcon = globalScene.add.image(0, 0, "icon_spliced"); - splicedIcon.setScale(0.5); - splicedIcon.setOrigin(0, 0); - if (this.slotIndex >= battlerCount) { - splicedIcon.setPositionRelative(slotLevelLabel, 36 + (genderSymbol ? 8 : 0), 0.5); - } else { - splicedIcon.setPositionRelative(this.slotName, 76, 3.5); - } - + if (isFusion) { + const splicedIcon = globalScene.add + .image(0, 0, "icon_spliced") + .setScale(0.5) + .setOrigin(0) + .setPositionRelative(slotLevelLabel, splicedIconToLevelLabelOffset.x, splicedIconToLevelLabelOffset.y); slotInfoContainer.add(splicedIcon); } if (this.pokemon.status) { - const statusIndicator = globalScene.add.sprite(0, 0, getLocalizedSpriteKey("statuses")); - statusIndicator.setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()); - statusIndicator.setOrigin(0, 0); - statusIndicator.setPositionRelative(slotLevelLabel, this.slotIndex >= battlerCount ? 43 : 55, 0); - + const statusIndicator = globalScene.add + .sprite(0, 0, getLocalizedSpriteKey("statuses")) + .setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()) + .setOrigin(0) + .setPositionRelative(slotLevelLabel, statusIconToLevelLabelOffset.x, statusIconToLevelLabelOffset.y); slotInfoContainer.add(statusIndicator); } if (this.pokemon.isShiny()) { const doubleShiny = this.pokemon.isDoubleShiny(false); - const shinyStar = globalScene.add.image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`); - shinyStar.setOrigin(0, 0); - shinyStar.setPositionRelative(this.slotName, -9, 3); - shinyStar.setTint(getVariantTint(this.pokemon.getBaseVariant())); - + const shinyStar = globalScene.add + .image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`) + .setOrigin(0) + .setPositionRelative(this.slotName, shinyIconToNameOffset.x, shinyIconToNameOffset.y) + .setTint(getVariantTint(this.pokemon.getBaseVariant())); slotInfoContainer.add(shinyStar); if (doubleShiny) { @@ -1971,50 +2020,38 @@ class PartySlot extends Phaser.GameObjects.Container { .setOrigin(0) .setPosition(shinyStar.x, shinyStar.y) .setTint(getVariantTint(this.pokemon.fusionVariant)); - slotInfoContainer.add(fusionShinyStar); } } - this.slotHpBar = globalScene.add.image(0, 0, "party_slot_hp_bar"); - this.slotHpBar.setPositionRelative( - slotBg, - this.slotIndex >= battlerCount ? 72 : 8, - this.slotIndex >= battlerCount ? 6 : 31, - ); - this.slotHpBar.setOrigin(0, 0); - this.slotHpBar.setVisible(false); + this.slotHpBar = globalScene.add + .image(0, 0, "party_slot_hp_bar") + .setOrigin(0) + .setVisible(false) + .setPositionRelative(this.slotBg, hpBarPosition.x, hpBarPosition.y); const hpRatio = this.pokemon.getHpRatio(); - this.slotHpOverlay = globalScene.add.sprite( - 0, - 0, - "party_slot_hp_overlay", - hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low", - ); - this.slotHpOverlay.setPositionRelative(this.slotHpBar, 16, 2); - this.slotHpOverlay.setOrigin(0, 0); - this.slotHpOverlay.setScale(hpRatio, 1); - this.slotHpOverlay.setVisible(false); + this.slotHpOverlay = globalScene.add + .sprite(0, 0, "party_slot_hp_overlay", hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low") + .setOrigin(0) + .setPositionRelative(this.slotHpBar, hpOverlayToBarOffset.x, hpOverlayToBarOffset.y) + .setScale(hpRatio, 1) + .setVisible(false); - this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY); - this.slotHpText.setPositionRelative( - this.slotHpBar, - this.slotHpBar.width - 3, - this.slotHpBar.height - 2 + (offsetJa ? 2 : 0), - ); - this.slotHpText.setOrigin(1, 0); - this.slotHpText.setVisible(false); + this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY) + .setOrigin(1, 0) + .setPositionRelative( + this.slotHpBar, + this.slotHpBar.width + hpTextToBarOffset.x, + this.slotHpBar.height + hpTextToBarOffset.y, + ) // TODO: annoying because it contains the width + .setVisible(false); - this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE); - this.slotDescriptionLabel.setPositionRelative( - slotBg, - this.slotIndex >= battlerCount ? 94 : 32, - this.slotIndex >= battlerCount ? 16 : 46, - ); - this.slotDescriptionLabel.setOrigin(0, 1); - this.slotDescriptionLabel.setVisible(false); + this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE) + .setOrigin(0, 1) + .setVisible(false) + .setPositionRelative(this.slotBg, descriptionLabelPosition.x, descriptionLabelPosition.y); slotInfoContainer.add([this.slotHpBar, this.slotHpOverlay, this.slotHpText, this.slotDescriptionLabel]); @@ -2076,10 +2113,9 @@ class PartySlot extends Phaser.GameObjects.Container { } private updateSlotTexture(): void { - const battlerCount = globalScene.currentBattle.getBattlerCount(); this.slotBg.setTexture( - `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`, - `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`, + this.slotBgKey, + `${this.slotBgKey}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`, ); } } @@ -2198,10 +2234,6 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container { this.discardIcon.setVisible(false); this.textBox.setVisible(true); this.textBox.setText(i18next.t("partyUiHandler:TRANSFER")); - this.setPosition( - globalScene.currentBattle.double ? 64 : 60, - globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1, - ); this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3; break; case PartyUiMode.DISCARD: @@ -2209,13 +2241,13 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container { this.discardIcon.setVisible(true); this.textBox.setVisible(true); this.textBox.setText(i18next.t("partyUiHandler:DISCARD")); - this.setPosition( - globalScene.currentBattle.double ? 64 : 60, - globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1, - ); this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3; break; } + this.setPosition( + globalScene.currentBattle.double ? DISCARD_BUTTON_X_DOUBLES : DISCARD_BUTTON_X, + globalScene.currentBattle.double ? DISCARD_BUTTON_Y_DOUBLES : DISCARD_BUTTON_Y, + ); } clear() { From 30058ed70e810f03c3ce9ac83d9d58d98a3b6314 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:20:48 -0500 Subject: [PATCH 8/8] [Feature] Add per-species tracking for ribbons, show nuzlocke ribbon (#6246) * Add tracking for nuzlocke completion * Add ribbon to legacy ui folder * Add tracking for friendship ribbon * fix overlapping flag set * Replace mass getters with a single method * Add tracking for each generational ribbon * Add ribbons for each challenge * Apply Kev's suggestions from code review Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- public/images/ui/champion_ribbon_emerald.png | Bin 0 -> 225 bytes .../ui/legacy/champion_ribbon_emerald.png | Bin 0 -> 225 bytes src/@types/dex-data.ts | 3 + src/@types/helpers/type-helpers.ts | 9 + src/constants.ts | 6 + src/data/challenge.ts | 40 +++ src/data/egg-hatch-data.ts | 1 + src/field/pokemon.ts | 92 ++++--- src/loading-scene.ts | 1 + src/modifier/modifier.ts | 2 +- src/phases/game-over-phase.ts | 45 +++- src/system/achv.ts | 15 +- src/system/game-data.ts | 236 ++++++++++-------- src/system/ribbons/ribbon-data.ts | 148 +++++++++++ src/system/ribbons/ribbon-methods.ts | 20 ++ src/ui/starter-select-ui-handler.ts | 18 +- src/utils/challenge-utils.ts | 28 ++- 17 files changed, 490 insertions(+), 174 deletions(-) create mode 100644 public/images/ui/champion_ribbon_emerald.png create mode 100644 public/images/ui/legacy/champion_ribbon_emerald.png create mode 100644 src/system/ribbons/ribbon-data.ts create mode 100644 src/system/ribbons/ribbon-methods.ts diff --git a/public/images/ui/champion_ribbon_emerald.png b/public/images/ui/champion_ribbon_emerald.png new file mode 100644 index 0000000000000000000000000000000000000000..81b111a05a9b30ff889c4687269a0f9d17c701b4 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^>_E)P!3HG%MVKuHQjEnx?oJHr&dIz4avD8d978mM zlT#X+=E<`DcRVAlo@a0-^Tt7rq-M?uEz?Ui4o{x{T~cqZC385#v^!tZI3vV8m?F)S zZJ7`MU(PGXBPAwP(5Y@NXRu-Ij)M%-SUQ_T8dw||R2a7lFMD8frj&<=XSRfd#6v4b zAq7VJ6`l!mPwX;Kany2H_E)P!3HG%MVKuHQjEnx?oJHr&dIz4avD8d978mM zlT#X+=E<`DcRVAlo@a0-^Tt7rq-M?uEz?Ui4o{x{T~cqZC385#v^!tZI3vV8m?F)S zZJ7`MU(PGXBPAwP(5Y@NXRu-Ij)M%-SUQ_T8dw||R2a7lFMD8frj&<=XSRfd#6v4b zAq7VJ6`l!mPwX;Kany2H = { * @typeParam T - The type to render partial */ export type AtLeastOne = Partial & ObjectValues<{ [K in keyof T]: Pick, K> }>; + +/** Type helper that adds a brand to a type, used for nominal typing. + * + * @remarks + * Brands should be either a string or unique symbol. This prevents overlap with other types. + */ +export declare class Brander { + private __brand: B; +} diff --git a/src/constants.ts b/src/constants.ts index 6f9f4a6d2fb..17cf08aa7e2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -101,3 +101,9 @@ export const ANTI_VARIANCE_WEIGHT_MODIFIER = 15; * Default: `10000` (0.01%) */ export const FAKE_TITLE_LOGO_CHANCE = 10000; + +/** + * The ceiling on friendship amount that can be reached through the use of rare candies. + * Using rare candies will never increase friendship beyond this value. + */ +export const RARE_CANDY_FRIENDSHIP_CAP = 200; diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 724d1f302da..89435149d2f 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -20,6 +20,7 @@ import { Trainer } from "#field/trainer"; import type { ModifierTypeOption } from "#modifiers/modifier-type"; import { PokemonMove } from "#moves/pokemon-move"; import type { DexAttrProps, GameData } from "#system/game-data"; +import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data"; import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common"; import { deepCopy } from "#utils/data"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; @@ -42,6 +43,15 @@ export abstract class Challenge { public conditions: ChallengeCondition[]; + /** + * The Ribbon awarded on challenge completion, or 0 if the challenge has no ribbon or is not enabled + * + * @defaultValue 0 + */ + public get ribbonAwarded(): RibbonFlag { + return 0 as RibbonFlag; + } + /** * @param id {@link Challenges} The enum value for the challenge */ @@ -423,6 +433,12 @@ type ChallengeCondition = (data: GameData) => boolean; * Implements a mono generation challenge. */ export class SingleGenerationChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + // NOTE: This logic will not work for the eventual mono gen 10 ribbon, as + // as its flag will not be in sequence with the other mono gen ribbons. + return this.value ? ((RibbonData.MONO_GEN_1 << (this.value - 1)) as RibbonFlag) : 0; + } + constructor() { super(Challenges.SINGLE_GENERATION, 9); } @@ -686,6 +702,12 @@ interface monotypeOverride { * Implements a mono type challenge. */ export class SingleTypeChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + // `this.value` represents the 1-based index of pokemon type + // `RibbonData.MONO_NORMAL` starts the flag position for the types, + // and we shift it by 1 for the specific type. + return this.value ? ((RibbonData.MONO_NORMAL << (this.value - 1)) as RibbonFlag) : 0; + } private static TYPE_OVERRIDES: monotypeOverride[] = [ { species: SpeciesId.CASTFORM, type: PokemonType.NORMAL, fusion: false }, ]; @@ -755,6 +777,9 @@ export class SingleTypeChallenge extends Challenge { * Implements a fresh start challenge. */ export class FreshStartChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.FRESH_START : 0; + } constructor() { super(Challenges.FRESH_START, 2); } @@ -828,6 +853,9 @@ export class FreshStartChallenge extends Challenge { * Implements an inverse battle challenge. */ export class InverseBattleChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.INVERSE : 0; + } constructor() { super(Challenges.INVERSE_BATTLE, 1); } @@ -861,6 +889,9 @@ export class InverseBattleChallenge extends Challenge { * Implements a flip stat challenge. */ export class FlipStatChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.FLIP_STATS : 0; + } constructor() { super(Challenges.FLIP_STAT, 1); } @@ -941,6 +972,9 @@ export class LowerStarterPointsChallenge extends Challenge { * Implements a No Support challenge */ export class LimitedSupportChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? ((RibbonData.NO_HEAL << (this.value - 1)) as RibbonFlag) : 0; + } constructor() { super(Challenges.LIMITED_SUPPORT, 3); } @@ -973,6 +1007,9 @@ export class LimitedSupportChallenge extends Challenge { * Implements a Limited Catch challenge */ export class LimitedCatchChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.LIMITED_CATCH : 0; + } constructor() { super(Challenges.LIMITED_CATCH, 1); } @@ -997,6 +1034,9 @@ export class LimitedCatchChallenge extends Challenge { * Implements a Permanent Faint challenge */ export class HardcoreChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.HARDCORE : 0; + } constructor() { super(Challenges.HARDCORE, 1); } diff --git a/src/data/egg-hatch-data.ts b/src/data/egg-hatch-data.ts index 6aead19eb7f..e78dc4d7984 100644 --- a/src/data/egg-hatch-data.ts +++ b/src/data/egg-hatch-data.ts @@ -47,6 +47,7 @@ export class EggHatchData { caughtCount: currDexEntry.caughtCount, hatchedCount: currDexEntry.hatchedCount, ivs: [...currDexEntry.ivs], + ribbons: currDexEntry.ribbons, }; this.starterDataEntryBeforeUpdate = { moveset: currStarterDataEntry.moveset, diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 7f13bf86e7d..3a5d435fb36 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1,7 +1,7 @@ import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability"; import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; import type { AnySound, BattleScene } from "#app/battle-scene"; -import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; +import { PLAYER_PARTY_MAX_SIZE, RARE_CANDY_FRIENDSHIP_CAP } from "#app/constants"; import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -139,6 +139,8 @@ import { populateVariantColors, variantColorCache, variantData } from "#sprites/ import { achvs } from "#system/achv"; import type { StarterDataEntry, StarterMoveset } from "#system/game-data"; import type { PokemonData } from "#system/pokemon-data"; +import { RibbonData } from "#system/ribbons/ribbon-data"; +import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types"; import type { DamageCalculationResult, DamageResult } from "#types/damage-result"; import type { IllusionData } from "#types/illusion-data"; @@ -5822,45 +5824,59 @@ export class PlayerPokemon extends Pokemon { ); }); } - - addFriendship(friendship: number): void { - if (friendship > 0) { - const starterSpeciesId = this.species.getRootSpeciesId(); - const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0; - const starterData = [ - globalScene.gameData.starterData[starterSpeciesId], - fusionStarterSpeciesId ? globalScene.gameData.starterData[fusionStarterSpeciesId] : null, - ].filter(d => !!d); - const amount = new NumberHolder(friendship); - globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount); - const candyFriendshipMultiplier = globalScene.gameMode.isClassic - ? timedEventManager.getClassicFriendshipMultiplier() - : 1; - const fusionReduction = fusionStarterSpeciesId - ? timedEventManager.areFusionsBoosted() - ? 1.5 // Divide candy gain for fusions by 1.5 during events - : 2 // 2 for fusions outside events - : 1; // 1 for non-fused mons - const starterAmount = new NumberHolder(Math.floor((amount.value * candyFriendshipMultiplier) / fusionReduction)); - - // Add friendship to this PlayerPokemon - this.friendship = Math.min(this.friendship + amount.value, 255); - if (this.friendship === 255) { - globalScene.validateAchv(achvs.MAX_FRIENDSHIP); - } - // Add to candy progress for this mon's starter species and its fused species (if it has one) - starterData.forEach((sd: StarterDataEntry, i: number) => { - const speciesId = !i ? starterSpeciesId : (fusionStarterSpeciesId as SpeciesId); - sd.friendship = (sd.friendship || 0) + starterAmount.value; - if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[speciesId])) { - globalScene.gameData.addStarterCandy(getPokemonSpecies(speciesId), 1); - sd.friendship = 0; - } - }); - } else { - // Lose friendship upon fainting + /** + * Add friendship to this Pokemon + * + * @remarks + * This adds friendship to the pokemon's friendship stat (used for evolution, return, etc.) and candy progress. + * For fusions, candy progress for each species in the fusion is halved. + * + * @param friendship - The amount of friendship to add. Negative values will reduce friendship, though not below 0. + * @param capped - If true, don't allow the friendship gain to exceed 200. Used to cap friendship gains from rare candies. + */ + addFriendship(friendship: number, capped = false): void { + // Short-circuit friendship loss, which doesn't impact candy friendship + if (friendship <= 0) { this.friendship = Math.max(this.friendship + friendship, 0); + return; } + + const starterSpeciesId = this.species.getRootSpeciesId(); + const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0; + const starterGameData = globalScene.gameData.starterData; + const starterData: [StarterDataEntry, SpeciesId][] = [[starterGameData[starterSpeciesId], starterSpeciesId]]; + if (fusionStarterSpeciesId) { + starterData.push([starterGameData[fusionStarterSpeciesId], fusionStarterSpeciesId]); + } + const amount = new NumberHolder(friendship); + globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount); + friendship = amount.value; + + const newFriendship = this.friendship + friendship; + // If capped is true, only adjust friendship if the new friendship is less than or equal to 200. + if (!capped || newFriendship <= RARE_CANDY_FRIENDSHIP_CAP) { + this.friendship = Math.min(newFriendship, 255); + if (newFriendship >= 255) { + globalScene.validateAchv(achvs.MAX_FRIENDSHIP); + awardRibbonsToSpeciesLine(this.species.speciesId, RibbonData.FRIENDSHIP); + } + } + + let candyFriendshipMultiplier = globalScene.gameMode.isClassic + ? timedEventManager.getClassicFriendshipMultiplier() + : 1; + if (fusionStarterSpeciesId) { + candyFriendshipMultiplier /= timedEventManager.areFusionsBoosted() ? 1.5 : 2; + } + const candyFriendshipAmount = Math.floor(friendship * candyFriendshipMultiplier); + // Add to candy progress for this mon's starter species and its fused species (if it has one) + starterData.forEach(([sd, id]: [StarterDataEntry, SpeciesId]) => { + sd.friendship = (sd.friendship || 0) + candyFriendshipAmount; + if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[id])) { + globalScene.gameData.addStarterCandy(getPokemonSpecies(id), 1); + sd.friendship = 0; + } + }); } getPossibleEvolution(evolution: SpeciesFormEvolution | null): Promise { diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 248bd578290..bf4d87a99f3 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -90,6 +90,7 @@ export class LoadingScene extends SceneBase { this.loadAtlas("shiny_icons", "ui"); this.loadImage("ha_capsule", "ui", "ha_capsule.png"); this.loadImage("champion_ribbon", "ui", "champion_ribbon.png"); + this.loadImage("champion_ribbon_emerald", "ui", "champion_ribbon_emerald.png"); this.loadImage("icon_spliced", "ui"); this.loadImage("icon_lock", "ui", "icon_lock.png"); this.loadImage("icon_stop", "ui", "icon_stop.png"); diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index fb7243a7901..076e2656b5c 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -2304,7 +2304,7 @@ export class PokemonLevelIncrementModifier extends ConsumablePokemonModifier { playerPokemon.levelExp = 0; } - playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY); + playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY, true); globalScene.phaseManager.unshiftNew( "LevelUpPhase", diff --git a/src/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index d4562b5a237..25dfffaa582 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -19,8 +19,11 @@ import { ChallengeData } from "#system/challenge-data"; import type { SessionSaveData } from "#system/game-data"; import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { PokemonData } from "#system/pokemon-data"; +import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data"; +import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods"; import { TrainerData } from "#system/trainer-data"; import { trainerConfigs } from "#trainers/trainer-config"; +import { checkSpeciesValidForChallenge, isNuzlockeChallenge } from "#utils/challenge-utils"; import { isLocal, isLocalServerConnected } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -111,6 +114,40 @@ export class GameOverPhase extends BattlePhase { } } + /** + * Submethod of {@linkcode handleGameOver} that awards ribbons to Pokémon in the player's party based on the current + * game mode and challenges. + */ + private awardRibbons(): void { + let ribbonFlags = 0; + if (globalScene.gameMode.isClassic) { + ribbonFlags |= RibbonData.CLASSIC; + } + if (isNuzlockeChallenge()) { + ribbonFlags |= RibbonData.NUZLOCKE; + } + for (const challenge of globalScene.gameMode.challenges) { + const ribbon = challenge.ribbonAwarded; + if (challenge.value && ribbon) { + ribbonFlags |= ribbon; + } + } + // Award ribbons to all Pokémon in the player's party that are considered valid + // for the current game mode and challenges. + for (const pokemon of globalScene.getPlayerParty()) { + const species = pokemon.species; + if ( + checkSpeciesValidForChallenge( + species, + globalScene.gameData.getSpeciesDexAttrProps(species, pokemon.getDexAttr()), + false, + ) + ) { + awardRibbonsToSpeciesLine(species.speciesId, ribbonFlags as RibbonFlag); + } + } + } + handleGameOver(): void { const doGameOver = (newClear: boolean) => { globalScene.disableMenu = true; @@ -122,12 +159,12 @@ export class GameOverPhase extends BattlePhase { globalScene.validateAchv(achvs.UNEVOLVED_CLASSIC_VICTORY); globalScene.gameData.gameStats.sessionsWon++; for (const pokemon of globalScene.getPlayerParty()) { - this.awardRibbon(pokemon); - + this.awardFirstClassicCompletion(pokemon); if (pokemon.species.getRootSpeciesId() !== pokemon.species.getRootSpeciesId(true)) { - this.awardRibbon(pokemon, true); + this.awardFirstClassicCompletion(pokemon, true); } } + this.awardRibbons(); } else if (globalScene.gameMode.isDaily && newClear) { globalScene.gameData.gameStats.dailyRunSessionsWon++; } @@ -263,7 +300,7 @@ export class GameOverPhase extends BattlePhase { } } - awardRibbon(pokemon: Pokemon, forStarter = false): void { + awardFirstClassicCompletion(pokemon: Pokemon, forStarter = false): void { const speciesId = getPokemonSpecies(pokemon.species.speciesId); const speciesRibbonCount = globalScene.gameData.incrementRibbonCount(speciesId, forStarter); // first time classic win, award voucher diff --git a/src/system/achv.ts b/src/system/achv.ts index 383d07252e6..8e312e3d590 100644 --- a/src/system/achv.ts +++ b/src/system/achv.ts @@ -5,7 +5,6 @@ import { FlipStatChallenge, FreshStartChallenge, InverseBattleChallenge, - LimitedCatchChallenge, SingleGenerationChallenge, SingleTypeChallenge, } from "#data/challenge"; @@ -14,6 +13,7 @@ import { PlayerGender } from "#enums/player-gender"; import { getShortenedStatKey, Stat } from "#enums/stat"; import { TurnHeldItemTransferModifier } from "#modifiers/modifier"; import type { ConditionFn } from "#types/common"; +import { isNuzlockeChallenge } from "#utils/challenge-utils"; import { NumberHolder } from "#utils/common"; import i18next from "i18next"; import type { Modifier } from "typescript"; @@ -924,18 +924,7 @@ export const achvs = { globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0), ).setSecret(), // TODO: Decide on icon - NUZLOCKE: new ChallengeAchv( - "NUZLOCKE", - "", - "NUZLOCKE.description", - "leaf_stone", - 100, - c => - c instanceof LimitedCatchChallenge && - c.value > 0 && - globalScene.gameMode.challenges.some(c => c.id === Challenges.HARDCORE && c.value > 0) && - globalScene.gameMode.challenges.some(c => c.id === Challenges.FRESH_START && c.value > 0), - ), + NUZLOCKE: new ChallengeAchv("NUZLOCKE", "", "NUZLOCKE.description", "leaf_stone", 100, isNuzlockeChallenge), BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(), }; diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 589e1271e3c..a1213990053 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -48,6 +48,7 @@ import { EggData } from "#system/egg-data"; import { GameStats } from "#system/game-stats"; import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { PokemonData } from "#system/pokemon-data"; +import { RibbonData } from "#system/ribbons/ribbon-data"; import { resetSettings, SettingKeys, setSetting } from "#system/settings"; import { SettingGamepad, setSettingGamepad, settingGamepadDefaults } from "#system/settings-gamepad"; import type { SettingKeyboard } from "#system/settings-keyboard"; @@ -402,121 +403,121 @@ export class GameData { } public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise { - return new Promise(resolve => { - try { - let systemData = this.parseSystemData(systemDataStr); + const { promise, resolve } = Promise.withResolvers(); + try { + let systemData = this.parseSystemData(systemDataStr); - if (cachedSystemDataStr) { - const cachedSystemData = this.parseSystemData(cachedSystemDataStr); - if (cachedSystemData.timestamp > systemData.timestamp) { - console.debug("Use cached system"); - systemData = cachedSystemData; - systemDataStr = cachedSystemDataStr; - } else { - this.clearLocalData(); - } - } - - console.debug(systemData); - - localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin)); - - const lsItemKey = `runHistoryData_${loggedInUser?.username}`; - const lsItem = localStorage.getItem(lsItemKey); - if (!lsItem) { - localStorage.setItem(lsItemKey, ""); - } - - applySystemVersionMigration(systemData); - - this.trainerId = systemData.trainerId; - this.secretId = systemData.secretId; - - this.gender = systemData.gender; - - this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0); - - if (!systemData.starterData) { - this.initStarterData(); - - if (systemData["starterMoveData"]) { - const starterMoveData = systemData["starterMoveData"]; - for (const s of Object.keys(starterMoveData)) { - this.starterData[s].moveset = starterMoveData[s]; - } - } - - if (systemData["starterEggMoveData"]) { - const starterEggMoveData = systemData["starterEggMoveData"]; - for (const s of Object.keys(starterEggMoveData)) { - this.starterData[s].eggMoves = starterEggMoveData[s]; - } - } - - this.migrateStarterAbilities(systemData, this.starterData); - - const starterIds = Object.keys(this.starterData).map(s => Number.parseInt(s) as SpeciesId); - for (const s of starterIds) { - this.starterData[s].candyCount += systemData.dexData[s].caughtCount; - this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2; - if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) { - this.starterData[s].candyCount += 4; - } - } + if (cachedSystemDataStr) { + const cachedSystemData = this.parseSystemData(cachedSystemDataStr); + if (cachedSystemData.timestamp > systemData.timestamp) { + console.debug("Use cached system"); + systemData = cachedSystemData; + systemDataStr = cachedSystemDataStr; } else { - this.starterData = systemData.starterData; + this.clearLocalData(); } - - if (systemData.gameStats) { - this.gameStats = systemData.gameStats; - } - - if (systemData.unlocks) { - for (const key of Object.keys(systemData.unlocks)) { - if (this.unlocks.hasOwnProperty(key)) { - this.unlocks[key] = systemData.unlocks[key]; - } - } - } - - if (systemData.achvUnlocks) { - for (const a of Object.keys(systemData.achvUnlocks)) { - if (achvs.hasOwnProperty(a)) { - this.achvUnlocks[a] = systemData.achvUnlocks[a]; - } - } - } - - if (systemData.voucherUnlocks) { - for (const v of Object.keys(systemData.voucherUnlocks)) { - if (vouchers.hasOwnProperty(v)) { - this.voucherUnlocks[v] = systemData.voucherUnlocks[v]; - } - } - } - - if (systemData.voucherCounts) { - getEnumKeys(VoucherType).forEach(key => { - const index = VoucherType[key]; - this.voucherCounts[index] = systemData.voucherCounts[index] || 0; - }); - } - - this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : []; - - this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [0, 0, 0, 0]; - this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [0, 0, 0, 0]; - - this.dexData = Object.assign(this.dexData, systemData.dexData); - this.consolidateDexData(this.dexData); - this.defaultDexData = null; - - resolve(true); - } catch (err) { - console.error(err); - resolve(false); } - }); + + console.debug(systemData); + + localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin)); + + const lsItemKey = `runHistoryData_${loggedInUser?.username}`; + const lsItem = localStorage.getItem(lsItemKey); + if (!lsItem) { + localStorage.setItem(lsItemKey, ""); + } + + applySystemVersionMigration(systemData); + + this.trainerId = systemData.trainerId; + this.secretId = systemData.secretId; + + this.gender = systemData.gender; + + this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0); + + if (!systemData.starterData) { + this.initStarterData(); + + if (systemData["starterMoveData"]) { + const starterMoveData = systemData["starterMoveData"]; + for (const s of Object.keys(starterMoveData)) { + this.starterData[s].moveset = starterMoveData[s]; + } + } + + if (systemData["starterEggMoveData"]) { + const starterEggMoveData = systemData["starterEggMoveData"]; + for (const s of Object.keys(starterEggMoveData)) { + this.starterData[s].eggMoves = starterEggMoveData[s]; + } + } + + this.migrateStarterAbilities(systemData, this.starterData); + + const starterIds = Object.keys(this.starterData).map(s => Number.parseInt(s) as SpeciesId); + for (const s of starterIds) { + this.starterData[s].candyCount += systemData.dexData[s].caughtCount; + this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2; + if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) { + this.starterData[s].candyCount += 4; + } + } + } else { + this.starterData = systemData.starterData; + } + + if (systemData.gameStats) { + this.gameStats = systemData.gameStats; + } + + if (systemData.unlocks) { + for (const key of Object.keys(systemData.unlocks)) { + if (this.unlocks.hasOwnProperty(key)) { + this.unlocks[key] = systemData.unlocks[key]; + } + } + } + + if (systemData.achvUnlocks) { + for (const a of Object.keys(systemData.achvUnlocks)) { + if (achvs.hasOwnProperty(a)) { + this.achvUnlocks[a] = systemData.achvUnlocks[a]; + } + } + } + + if (systemData.voucherUnlocks) { + for (const v of Object.keys(systemData.voucherUnlocks)) { + if (vouchers.hasOwnProperty(v)) { + this.voucherUnlocks[v] = systemData.voucherUnlocks[v]; + } + } + } + + if (systemData.voucherCounts) { + getEnumKeys(VoucherType).forEach(key => { + const index = VoucherType[key]; + this.voucherCounts[index] = systemData.voucherCounts[index] || 0; + }); + } + + this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : []; + + this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [0, 0, 0, 0]; + this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [0, 0, 0, 0]; + + this.dexData = Object.assign(this.dexData, systemData.dexData); + this.consolidateDexData(this.dexData); + this.defaultDexData = null; + + resolve(true); + } catch (err) { + console.error(err); + resolve(false); + } + return promise; } /** @@ -627,6 +628,9 @@ export class GameData { } return ret; } + if (k === "ribbons") { + return RibbonData.fromJSON(v); + } return k.endsWith("Attr") && !["natureAttr", "abilityAttr", "passiveAttr"].includes(k) ? BigInt(v ?? 0) : v; }) as SystemSaveData; @@ -1634,6 +1638,7 @@ export class GameData { caughtCount: 0, hatchedCount: 0, ivs: [0, 0, 0, 0, 0, 0], + ribbons: new RibbonData(0), }; } @@ -1878,6 +1883,12 @@ export class GameData { }); } + /** + * Increase the number of classic ribbons won with this species. + * @param species - The species to increment the ribbon count for + * @param forStarter - If true, will increment the ribbon count for the root species of the given species + * @returns The number of classic wins after incrementing. + */ incrementRibbonCount(species: PokemonSpecies, forStarter = false): number { const speciesIdToIncrement: SpeciesId = species.getRootSpeciesId(forStarter); @@ -2177,6 +2188,9 @@ export class GameData { if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) { entry.natureAttr = this.defaultDexData?.[k].natureAttr || 1 << randInt(25, 1); } + if (!entry.hasOwnProperty("ribbons")) { + entry.ribbons = new RibbonData(0); + } } } diff --git a/src/system/ribbons/ribbon-data.ts b/src/system/ribbons/ribbon-data.ts new file mode 100644 index 00000000000..42c523afc0e --- /dev/null +++ b/src/system/ribbons/ribbon-data.ts @@ -0,0 +1,148 @@ +import type { Brander } from "#types/type-helpers"; + +export type RibbonFlag = (number & Brander<"RibbonFlag">) | 0; + +/** + * Class for ribbon data management. Usually constructed via the {@linkcode fromJSON} method. + * + * @remarks + * Stores information about the ribbons earned by a species using a bitfield. + */ +export class RibbonData { + /** Internal bitfield storing the unlock state for each ribbon */ + private payload: number; + + //#region Ribbons + //#region Monotype challenge ribbons + /** Ribbon for winning the normal monotype challenge */ + public static readonly MONO_NORMAL = 0x1 as RibbonFlag; + /** Ribbon for winning the fighting monotype challenge */ + public static readonly MONO_FIGHTING = 0x2 as RibbonFlag; + /** Ribbon for winning the flying monotype challenge */ + public static readonly MONO_FLYING = 0x4 as RibbonFlag; + /** Ribbon for winning the poision monotype challenge */ + public static readonly MONO_POISON = 0x8 as RibbonFlag; + /** Ribbon for winning the ground monotype challenge */ + public static readonly MONO_GROUND = 0x10 as RibbonFlag; + /** Ribbon for winning the rock monotype challenge */ + public static readonly MONO_ROCK = 0x20 as RibbonFlag; + /** Ribbon for winning the bug monotype challenge */ + public static readonly MONO_BUG = 0x40 as RibbonFlag; + /** Ribbon for winning the ghost monotype challenge */ + public static readonly MONO_GHOST = 0x80 as RibbonFlag; + /** Ribbon for winning the steel monotype challenge */ + public static readonly MONO_STEEL = 0x100 as RibbonFlag; + /** Ribbon for winning the fire monotype challenge */ + public static readonly MONO_FIRE = 0x200 as RibbonFlag; + /** Ribbon for winning the water monotype challenge */ + public static readonly MONO_WATER = 0x400 as RibbonFlag; + /** Ribbon for winning the grass monotype challenge */ + public static readonly MONO_GRASS = 0x800 as RibbonFlag; + /** Ribbon for winning the electric monotype challenge */ + public static readonly MONO_ELECTRIC = 0x1000 as RibbonFlag; + /** Ribbon for winning the psychic monotype challenge */ + public static readonly MONO_PSYCHIC = 0x2000 as RibbonFlag; + /** Ribbon for winning the ice monotype challenge */ + public static readonly MONO_ICE = 0x4000 as RibbonFlag; + /** Ribbon for winning the dragon monotype challenge */ + public static readonly MONO_DRAGON = 0x8000 as RibbonFlag; + /** Ribbon for winning the dark monotype challenge */ + public static readonly MONO_DARK = 0x10000 as RibbonFlag; + /** Ribbon for winning the fairy monotype challenge */ + public static readonly MONO_FAIRY = 0x20000 as RibbonFlag; + //#endregion Monotype ribbons + + //#region Monogen ribbons + /** Ribbon for winning the the mono gen 1 challenge */ + public static readonly MONO_GEN_1 = 0x40000 as RibbonFlag; + /** Ribbon for winning the the mono gen 2 challenge */ + public static readonly MONO_GEN_2 = 0x80000 as RibbonFlag; + /** Ribbon for winning the mono gen 3 challenge */ + public static readonly MONO_GEN_3 = 0x100000 as RibbonFlag; + /** Ribbon for winning the mono gen 4 challenge */ + public static readonly MONO_GEN_4 = 0x200000 as RibbonFlag; + /** Ribbon for winning the mono gen 5 challenge */ + public static readonly MONO_GEN_5 = 0x400000 as RibbonFlag; + /** Ribbon for winning the mono gen 6 challenge */ + public static readonly MONO_GEN_6 = 0x800000 as RibbonFlag; + /** Ribbon for winning the mono gen 7 challenge */ + public static readonly MONO_GEN_7 = 0x1000000 as RibbonFlag; + /** Ribbon for winning the mono gen 8 challenge */ + public static readonly MONO_GEN_8 = 0x2000000 as RibbonFlag; + /** Ribbon for winning the mono gen 9 challenge */ + public static readonly MONO_GEN_9 = 0x4000000 as RibbonFlag; + //#endregion Monogen ribbons + + /** Ribbon for winning classic */ + public static readonly CLASSIC = 0x8000000 as RibbonFlag; + /** Ribbon for winning the nuzzlocke challenge */ + public static readonly NUZLOCKE = 0x10000000 as RibbonFlag; + /** Ribbon for reaching max friendship */ + public static readonly FRIENDSHIP = 0x20000000 as RibbonFlag; + /** Ribbon for winning the flip stats challenge */ + public static readonly FLIP_STATS = 0x40000000 as RibbonFlag; + /** Ribbon for winning the inverse challenge */ + public static readonly INVERSE = 0x80000000 as RibbonFlag; + /** Ribbon for winning the fresh start challenge */ + public static readonly FRESH_START = 0x100000000 as RibbonFlag; + /** Ribbon for winning the hardcore challenge */ + public static readonly HARDCORE = 0x200000000 as RibbonFlag; + /** Ribbon for winning the limited catch challenge */ + public static readonly LIMITED_CATCH = 0x400000000 as RibbonFlag; + /** Ribbon for winning the limited support challenge set to no heal */ + public static readonly NO_HEAL = 0x800000000 as RibbonFlag; + /** Ribbon for winning the limited uspport challenge set to no shop */ + public static readonly NO_SHOP = 0x1000000000 as RibbonFlag; + /** Ribbon for winning the limited support challenge set to both*/ + public static readonly NO_SUPPORT = 0x2000000000 as RibbonFlag; + + // NOTE: max possible ribbon flag is 0x20000000000000 (53 total ribbons) + // Once this is exceeded, bitfield needs to be changed to a bigint or even a uint array + // Note that this has no impact on serialization as it is stored in hex. + + //#endregion Ribbons + + /** Create a new instance of RibbonData. Generally, {@linkcode fromJSON} is used instead. */ + constructor(value: number) { + this.payload = value; + } + + /** Serialize the bitfield payload as a hex encoded string */ + public toJSON(): string { + return this.payload.toString(16); + } + + /** + * Decode a hexadecimal string representation of the bitfield into a `RibbonData` instance + * + * @param value - Hexadecimal string representation of the bitfield (without the leading 0x) + * @returns A new instance of `RibbonData` initialized with the provided bitfield. + */ + public static fromJSON(value: string): RibbonData { + try { + return new RibbonData(Number.parseInt(value, 16)); + } catch { + return new RibbonData(0); + } + } + + /** + * Award one or more ribbons to the ribbon data by setting the corresponding flags in the bitfield. + * + * @param flags - The flags to set. Can be a single flag or multiple flags. + */ + public award(...flags: [RibbonFlag, ...RibbonFlag[]]): void { + for (const f of flags) { + this.payload |= f; + } + } + + /** + * Check if a specific ribbon has been awarded + * @param flag - The ribbon to check + * @returns Whether the specified flag has been awarded + */ + public has(flag: RibbonFlag): boolean { + return !!(this.payload & flag); + } +} diff --git a/src/system/ribbons/ribbon-methods.ts b/src/system/ribbons/ribbon-methods.ts new file mode 100644 index 00000000000..a465357ab8c --- /dev/null +++ b/src/system/ribbons/ribbon-methods.ts @@ -0,0 +1,20 @@ +import { globalScene } from "#app/global-scene"; +import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; +import type { SpeciesId } from "#enums/species-id"; +import type { RibbonFlag } from "#system/ribbons/ribbon-data"; +import { isNullOrUndefined } from "#utils/common"; + +/** + * Award one or more ribbons to a species and its pre-evolutions + * + * @param id - The ID of the species to award ribbons to + * @param ribbons - The ribbon(s) to award (use bitwise OR to combine multiple) + */ +export function awardRibbonsToSpeciesLine(id: SpeciesId, ribbons: RibbonFlag): void { + const dexData = globalScene.gameData.dexData; + dexData[id].ribbons.award(ribbons); + // Mark all pre-evolutions of the Pokémon with the same ribbon flags. + for (let prevoId = pokemonPrevolutions[id]; !isNullOrUndefined(prevoId); prevoId = pokemonPrevolutions[prevoId]) { + dexData[id].ribbons.award(ribbons); + } +} diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index fbcc6ae7e32..82467506720 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -45,6 +45,7 @@ import type { Variant } from "#sprites/variant"; import { getVariantIcon, getVariantTint } from "#sprites/variant"; import { achvs } from "#system/achv"; import type { DexAttrProps, StarterAttributes, StarterMoveset } from "#system/game-data"; +import { RibbonData } from "#system/ribbons/ribbon-data"; import { SettingKeyboard } from "#system/settings-keyboard"; import type { DexEntry } from "#types/dex-data"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; @@ -3226,6 +3227,8 @@ export class StarterSelectUiHandler extends MessageUiHandler { onScreenFirstIndex + maxRows * maxColumns - 1, ); + const gameData = globalScene.gameData; + this.starterSelectScrollBar.setScrollCursor(this.scrollCursor); let pokerusCursorIndex = 0; @@ -3265,9 +3268,9 @@ export class StarterSelectUiHandler extends MessageUiHandler { container.label.setVisible(true); const speciesVariants = - speciesId && globalScene.gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY + speciesId && gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY ? [DexAttr.DEFAULT_VARIANT, DexAttr.VARIANT_2, DexAttr.VARIANT_3].filter( - v => !!(globalScene.gameData.dexData[speciesId].caughtAttr & v), + v => !!(gameData.dexData[speciesId].caughtAttr & v), ) : []; for (let v = 0; v < 3; v++) { @@ -3282,12 +3285,15 @@ export class StarterSelectUiHandler extends MessageUiHandler { } } - container.starterPassiveBgs.setVisible(!!globalScene.gameData.starterData[speciesId].passiveAttr); + container.starterPassiveBgs.setVisible(!!gameData.starterData[speciesId].passiveAttr); container.hiddenAbilityIcon.setVisible( - !!globalScene.gameData.dexData[speciesId].caughtAttr && - !!(globalScene.gameData.starterData[speciesId].abilityAttr & 4), + !!gameData.dexData[speciesId].caughtAttr && !!(gameData.starterData[speciesId].abilityAttr & 4), ); - container.classicWinIcon.setVisible(globalScene.gameData.starterData[speciesId].classicWinCount > 0); + container.classicWinIcon + .setVisible(gameData.starterData[speciesId].classicWinCount > 0) + .setTexture( + gameData.dexData[speciesId].ribbons.has(RibbonData.NUZLOCKE) ? "champion_ribbon_emerald" : "champion_ribbon", + ); container.favoriteIcon.setVisible(this.starterPreferences[speciesId]?.favorite ?? false); // 'Candy Icon' mode diff --git a/src/utils/challenge-utils.ts b/src/utils/challenge-utils.ts index 43297027e04..c4fac3a0323 100644 --- a/src/utils/challenge-utils.ts +++ b/src/utils/challenge-utils.ts @@ -4,6 +4,7 @@ import { pokemonEvolutions } from "#balance/pokemon-evolutions"; import { pokemonFormChanges } from "#data/pokemon-forms"; import type { PokemonSpecies } from "#data/pokemon-species"; import { ChallengeType } from "#enums/challenge-type"; +import { Challenges } from "#enums/challenges"; import type { MoveId } from "#enums/move-id"; import type { MoveSourceType } from "#enums/move-source-type"; import type { SpeciesId } from "#enums/species-id"; @@ -378,7 +379,7 @@ export function checkStarterValidForChallenge(species: PokemonSpecies, props: De * @param soft - If `true`, allow it if it could become valid through a form change. * @returns `true` if the species is considered valid. */ -function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { +export function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { const isValidForChallenge = new BooleanHolder(true); applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) { @@ -407,3 +408,28 @@ function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrPr }); return result; } + +/** @returns Whether the current game mode meets the criteria to be considered a Nuzlocke challenge */ +export function isNuzlockeChallenge(): boolean { + let isFreshStart = false; + let isLimitedCatch = false; + let isHardcore = false; + for (const challenge of globalScene.gameMode.challenges) { + // value is 0 if challenge is not active + if (!challenge.value) { + continue; + } + switch (challenge.id) { + case Challenges.FRESH_START: + isFreshStart = true; + break; + case Challenges.LIMITED_CATCH: + isLimitedCatch = true; + break; + case Challenges.HARDCORE: + isHardcore = true; + break; + } + } + return isFreshStart && isLimitedCatch && isHardcore; +}