Compare commits

...

28 Commits

Author SHA1 Message Date
Wlowscha
f1438a6fd8
Merge 05a85bcda2 into f42237d415 2025-08-14 13:28:35 -04:00
Bertie690
f42237d415
[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
2025-08-14 10:25:44 -07:00
fabske0
b44f0a4176
[Refactor] Remove bgm param from arena constructor (#6254) 2025-08-14 16:52:56 +00:00
Sirz Benjie
076ef81691
[Bug] [UI/UX] [Beta] Fix icons not showing in save slot selection (#6262)
Fix icons not showing in save slot selection
2025-08-13 20:49:46 -05:00
fabske0
23271901cf
[Docs] Add locale key naming info to localization.md (#6260) 2025-08-14 01:12:00 +00:00
Inês Simões
1517e0512e
[UI/UX] [Feature] Save Management Tool (Rename/Delete Saves) (#5978)
* 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 <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* 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 <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* 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 <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor Sanitization for aesthetics
Deleting the input when closing the overlay for
aesthetics purpose

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>

* 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 <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Rebasing and conflict resolution

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>

* Lint fix

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>

* 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 <matheus.r.noya.alves@tecnico.ulisboa.pt>
Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
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>
2025-08-13 20:08:12 -05:00
Wlowscha
05a85bcda2
Merge branch 'move-discard-button' of https://github.com/Wlowscha/pokerogue into move-discard-button 2025-08-11 22:32:16 +02:00
Wlowscha
f07b94107a
Fainted pkmn slots displaying correctly 2025-08-11 22:31:11 +02:00
Wlowscha
65e36b276f
Merge branch 'beta' into move-discard-button 2025-08-11 22:08:18 +02:00
Wlowscha
fe30946f31
Update src/ui/party-ui-handler.ts
Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com>
2025-08-07 17:13:23 +02:00
Wlowscha
906a6c9c0a
Removed "magicNumbers" container, added multiple comments 2025-08-07 17:11:36 +02:00
Wlowscha
4c699edf21
Updated comment 2025-08-06 18:51:31 +02:00
Adri1
2a0da37e1d Optimized PNGs 2025-08-05 10:52:27 +02:00
Wlowscha
cc8312b24a
Merge branch 'beta' into move-discard-button 2025-08-03 15:52:34 +02:00
Wlowscha
e9318ec4c2
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>
2025-08-03 15:52:01 +02:00
Wlowscha
88ace888eb
Merge branch 'beta' into move-discard-button 2025-08-03 00:09:22 +02:00
Wlowscha
6bdadcc3e2
Changed background to fit new slot disposition 2025-08-03 00:02:36 +02:00
Wlowscha
a88157b1c8
Added smaller main slots for transfer mode in doubles 2025-08-02 22:47:34 +02:00
Wlowscha
11ba28b053
Laid out numbers for slot positions 2025-08-02 21:18:31 +02:00
Wlowscha
0ef60dd445
More magic numbers in const 2025-08-02 09:59:08 +02:00
Wlowscha
0f1785b97a
Introducing const object to store ui magic numbers 2025-08-02 08:24:57 +02:00
Wlowscha
403c2b6e9e
Offsets are relevant to the same thing 2025-08-02 00:24:49 +02:00
Wlowscha
57cc77a937
Also set y origin to 0 2025-08-02 00:07:25 +02:00
Wlowscha
910b4c0af9
Added isBenched property to slots; x origin of slotBg is now 0 2025-08-01 23:29:19 +02:00
Wlowscha
b1475d11b3
Small clean up 2025-08-01 09:40:56 +02:00
Wlowscha
6bf01622b2
Fixed the various .setOrigin(0,0) 2025-08-01 08:42:39 +02:00
Wlowscha
941c45f704
Moved transfer/discard button up in doubles 2025-08-01 00:46:15 +02:00
Wlowscha
25245d9772
constants for position of discard button 2025-08-01 00:19:43 +02:00
22 changed files with 802 additions and 249 deletions

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

After

Width:  |  Height:  |  Size: 799 B

View File

@ -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$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -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) {
@ -1513,8 +1515,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 = {
@ -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)) {

View File

@ -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) : "";
}

View File

@ -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;
}
});
}
}

View File

@ -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}`;
}
/**

View File

@ -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`),

View File

@ -38,6 +38,7 @@ export enum UiMode {
UNAVAILABLE,
CHALLENGE_SELECT,
RENAME_POKEMON,
RENAME_RUN,
RUN_HISTORY,
RUN_INFO,
TEST_DIALOGUE,

View File

@ -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;
}

View File

@ -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");
@ -447,7 +448,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);

View File

@ -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);
}
}

View File

@ -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,
@ -206,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;
}
@ -822,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;
@ -979,6 +981,54 @@ export class GameData {
});
}
async renameSession(slotId: number, newName: string): Promise<boolean> {
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<boolean> {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
return new Promise(async (resolve, reject) => {

View File

@ -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() {

View File

@ -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;
}
}

View File

@ -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";
@ -207,6 +208,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);
}
/**
@ -702,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;
}

View File

@ -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);
@ -427,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();
@ -441,7 +604,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 +627,33 @@ class SessionSlot extends Phaser.GameObjects.Container {
load(): Promise<boolean> {
return new Promise<boolean>(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;
this.setupWithData(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);
});
});
}
}

View File

@ -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);

View File

@ -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),

View File

@ -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);

View File

@ -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();
});
});
});