[UI/UX] Add option to see ribbons in Pokédex (#6596)

* Added various ribbon utils

* Added ribbon tray to pokédex page

* V button in Pokédex toggles IVs

* Introduced visibility toggle

* Added ribbons (and full ivs) to unlocks file

* For real this time

* Added descriptions to the ribbons

* Fixed bug of tray not opening with visibility option on

* Minor cleanup of ribbon tray

* Use unique ribbon icons

* Make achv use image instead of sprite

* Tweak size of ribbons

* Improve clarity on comment

---------

Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
This commit is contained in:
Wlowscha 2025-10-08 06:29:19 +02:00 committed by GitHub
parent 07c1491649
commit 06fe3c7b76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 392 additions and 47 deletions

View File

@ -182,6 +182,7 @@ export class BattleScene extends SceneBase {
public shopCursorTarget: number = ShopCursorTarget.REWARDS;
public commandCursorMemory = false;
public dexForDevs = false;
public showMissingRibbons = false;
public showMovesetFlyout = true;
public showArenaFlyout = true;
public showTimeOfDayWidget = true;

View File

@ -103,7 +103,7 @@ export class RibbonData {
//#endregion Ribbons
/** Create a new instance of RibbonData. Generally, {@linkcode fromJSON} is used instead. */
constructor(value: number) {
constructor(value: number | bigint) {
this.payload = BigInt(value);
}
@ -145,4 +145,12 @@ export class RibbonData {
public has(flag: RibbonFlag): boolean {
return !!(this.payload & flag);
}
/**
* Allow access to the bigint of ribbons
* @returns The ribbons as a bigint
*/
public getRibbons(): bigint {
return this.payload;
}
}

View File

@ -1,7 +1,7 @@
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 { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
/**
* Award one or more ribbons to a species and its pre-evolutions
@ -17,3 +17,43 @@ export function awardRibbonsToSpeciesLine(id: SpeciesId, ribbons: RibbonFlag): v
dexData[prevoId].ribbons.award(ribbons);
}
}
export function ribbonFlagToAssetKey(flag: RibbonFlag): Phaser.GameObjects.Sprite | Phaser.GameObjects.Image {
let imageKey: string;
switch (flag) {
// biome-ignore-start lint/suspicious/noFallthroughSwitchClause: intentional
case RibbonData.MONO_GEN_1:
imageKey = "ribbon_gen1";
case RibbonData.MONO_GEN_2:
imageKey ??= "ribbon_gen2";
case RibbonData.MONO_GEN_3:
imageKey ??= "ribbon_gen3";
case RibbonData.MONO_GEN_4:
imageKey ??= "ribbon_gen4";
case RibbonData.MONO_GEN_5:
imageKey ??= "ribbon_gen5";
case RibbonData.MONO_GEN_6:
imageKey ??= "ribbon_gen6";
case RibbonData.MONO_GEN_7:
imageKey ??= "ribbon_gen7";
case RibbonData.MONO_GEN_8:
imageKey ??= "ribbon_gen8";
case RibbonData.MONO_GEN_9:
imageKey ??= "ribbon_gen9";
return globalScene.add.image(0, 0, "items", imageKey).setDisplaySize(16, 16);
// biome-ignore-end lint/suspicious/noFallthroughSwitchClause: done with fallthrough
// Ribbons that don't use the items atlas
// biome-ignore-start lint/suspicious/noFallthroughSwitchClause: Another fallthrough block
case RibbonData.NUZLOCKE:
imageKey = "champion_ribbon_emerald";
default:
imageKey ??= "champion_ribbon";
{
const img = globalScene.add.image(0, 0, imageKey);
const target = 12;
const scale = Math.min(target / img.width, target / img.height);
return img.setScale(scale);
}
// biome-ignore-end lint/suspicious/noFallthroughSwitchClause: End fallthrough block
}
}

View File

@ -180,6 +180,7 @@ export const SettingKeys = {
Battle_Music: "BATTLE_MUSIC",
Show_BGM_Bar: "SHOW_BGM_BAR",
Hide_Username: "HIDE_USERNAME",
Show_Missing_Ribbons: "SHOW_MISSING_RIBBONS",
Move_Touch_Controls: "MOVE_TOUCH_CONTROLS",
Shop_Overlay_Opacity: "SHOP_OVERLAY_OPACITY",
};
@ -642,6 +643,13 @@ export const Setting: Array<Setting> = [
default: 0,
type: SettingType.DISPLAY,
},
{
key: SettingKeys.Show_Missing_Ribbons,
label: i18next.t("settings:showMissingRibbons"),
options: OFF_ON,
default: 0,
type: SettingType.DISPLAY,
},
{
key: SettingKeys.Master_Volume,
label: i18next.t("settings:masterVolume"),
@ -874,6 +882,9 @@ export function setSetting(setting: string, value: number): boolean {
case SettingKeys.Dex_For_Devs:
globalScene.dexForDevs = Setting[index].options[value].value === "On";
break;
case SettingKeys.Show_Missing_Ribbons:
globalScene.showMissingRibbons = Setting[index].options[value].value === "On";
break;
case SettingKeys.EXP_Gains_Speed:
globalScene.expGainsSpeed = value;
break;

View File

@ -10,13 +10,13 @@ export class AchvBar extends Phaser.GameObjects.Container {
private defaultHeight: number;
private bg: Phaser.GameObjects.NineSlice;
private icon: Phaser.GameObjects.Sprite;
private icon: Phaser.GameObjects.Image;
private titleText: Phaser.GameObjects.Text;
private scoreText: Phaser.GameObjects.Text;
private descriptionText: Phaser.GameObjects.Text;
private queue: (Achv | Voucher)[] = [];
private playerGender: PlayerGender;
private readonly queue: (Achv | Voucher)[] = [];
private readonly playerGender: PlayerGender;
public shown: boolean;
@ -29,47 +29,31 @@ export class AchvBar extends Phaser.GameObjects.Container {
this.defaultWidth = 200;
this.defaultHeight = 40;
this.bg = globalScene.add.nineslice(
0,
0,
"achv_bar",
undefined,
this.defaultWidth,
this.defaultHeight,
41,
6,
16,
4,
);
this.bg.setOrigin(0, 0);
this.bg = globalScene.add
.nineslice(0, 0, "achv_bar", undefined, this.defaultWidth, this.defaultHeight, 41, 6, 16, 4)
.setOrigin(0);
this.add(this.bg);
this.icon = globalScene.add.sprite(4, 4, "items");
this.icon.setOrigin(0, 0);
this.icon = globalScene.add.image(4, 4, "items").setOrigin(0);
this.add(this.icon);
this.titleText = addTextObject(40, 3, "", TextStyle.MESSAGE, {
fontSize: "72px",
});
this.titleText.setOrigin(0, 0);
}).setOrigin(0);
this.add(this.titleText);
this.scoreText = addTextObject(150, 3, "", TextStyle.MESSAGE, {
fontSize: "72px",
});
this.scoreText.setOrigin(1, 0);
}).setOrigin(1, 0);
this.add(this.scoreText);
this.descriptionText = addTextObject(43, 16, "", TextStyle.WINDOW_ALT, {
fontSize: "72px",
});
this.descriptionText.setOrigin(0, 0);
this.descriptionText.setOrigin(0).setWordWrapWidth(664).setLineSpacing(-5);
this.add(this.descriptionText);
this.descriptionText.setWordWrapWidth(664);
this.descriptionText.setLineSpacing(-5);
this.setScale(0.5);
this.shown = false;

View File

@ -0,0 +1,165 @@
import { globalScene } from "#app/global-scene";
import type { PokemonSpecies } from "#data/pokemon-species";
import { Button } from "#enums/buttons";
import type { RibbonData, RibbonFlag } from "#system/ribbons/ribbon-data";
import { ribbonFlagToAssetKey } from "#system/ribbons/ribbon-methods";
import type { MessageUiHandler } from "#ui/message-ui-handler";
import { addWindow } from "#ui/ui-theme";
import { getAvailableRibbons, getRibbonKey } from "#utils/ribbon-utils";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export class RibbonTray extends Phaser.GameObjects.Container {
private trayBg: Phaser.GameObjects.NineSlice;
private ribbons: RibbonFlag[] = [];
private trayIcons: Phaser.GameObjects.Image[] = [];
private trayNumIcons: number;
private trayRows: number;
private trayColumns: number;
private trayCursorObj: Phaser.GameObjects.Image;
private trayCursor = 0;
private readonly handler: MessageUiHandler;
private ribbonData: RibbonData;
private readonly maxColumns = 6;
constructor(handler: MessageUiHandler, x: number, y: number) {
super(globalScene, x, y);
this.handler = handler;
this.setup();
}
setup() {
this.trayBg = addWindow(0, 0, 0, 0).setOrigin(0);
this.add(this.trayBg);
this.trayCursorObj = globalScene.add.image(0, 0, "select_cursor").setOrigin(0);
this.add(this.trayCursorObj);
}
processInput(button: Button) {
let success = false;
const numberOfIcons = this.trayIcons.length;
const numOfRows = Math.ceil(numberOfIcons / this.maxColumns);
const currentTrayRow = Math.floor(this.trayCursor / this.maxColumns);
switch (button) {
case Button.UP:
if (currentTrayRow > 0) {
success = this.setTrayCursor(this.trayCursor - this.maxColumns);
} else {
const targetCol = this.trayCursor;
if (numberOfIcons % this.maxColumns > targetCol) {
success = this.setTrayCursor(numberOfIcons - (numberOfIcons % this.maxColumns) + targetCol);
} else {
success = this.setTrayCursor(
Math.max(numberOfIcons - (numberOfIcons % this.maxColumns) + targetCol - this.maxColumns, 0),
);
}
}
break;
case Button.DOWN:
if (currentTrayRow < numOfRows - 1 && this.trayCursor + this.maxColumns < numberOfIcons) {
success = this.setTrayCursor(this.trayCursor + this.maxColumns);
} else {
success = this.setTrayCursor(this.trayCursor % this.maxColumns);
}
break;
case Button.LEFT:
if (this.trayCursor % this.maxColumns !== 0) {
success = this.setTrayCursor(this.trayCursor - 1);
} else {
success = this.setTrayCursor(
currentTrayRow < numOfRows - 1 ? (currentTrayRow + 1) * this.maxColumns - 1 : numberOfIcons - 1,
);
}
break;
case Button.RIGHT:
if (
this.trayCursor % this.maxColumns
< (currentTrayRow < numOfRows - 1 ? 8 : (numberOfIcons - 1) % this.maxColumns)
) {
success = this.setTrayCursor(this.trayCursor + 1);
} else {
success = this.setTrayCursor(currentTrayRow * this.maxColumns);
}
break;
case Button.CANCEL:
success = this.close();
break;
}
return success;
}
setTrayCursor(cursor: number): boolean {
cursor = Phaser.Math.Clamp(this.trayIcons.length - 1, cursor, 0);
const changed = this.trayCursor !== cursor;
if (changed) {
this.trayCursor = cursor;
}
this.trayCursorObj.setPosition(5 + (cursor % this.maxColumns) * 18, 4 + Math.floor(cursor / this.maxColumns) * 17);
const ribbonDescription = i18next.t(`ribbons:${toCamelCase(getRibbonKey(this.ribbons[cursor]))}`);
this.handler.showText(ribbonDescription);
return changed;
}
open(species: PokemonSpecies): boolean {
this.ribbons = getAvailableRibbons(species);
this.ribbonData = globalScene.gameData.dexData[species.speciesId].ribbons;
this.trayNumIcons = this.ribbons.length;
this.trayRows =
Math.floor(this.trayNumIcons / this.maxColumns) + (this.trayNumIcons % this.maxColumns === 0 ? 0 : 1);
this.trayColumns = Math.min(this.trayNumIcons, this.maxColumns);
this.trayBg.setSize(15 + this.trayColumns * 17, 8 + this.trayRows * 18);
this.trayIcons = [];
let index = 0;
for (const ribbon of this.ribbons) {
const hasRibbon = this.ribbonData.has(ribbon);
const icon = ribbonFlagToAssetKey(ribbon);
if (hasRibbon || globalScene.dexForDevs) {
icon.clearTint();
} else {
icon.setTint(0);
}
if (hasRibbon || globalScene.dexForDevs || globalScene.showMissingRibbons) {
icon.setPosition(14 + (index % this.maxColumns) * 18, 14 + Math.floor(index / this.maxColumns) * 17);
this.add(icon);
this.trayIcons.push(icon);
index++;
}
}
this.setVisible(true).setTrayCursor(0);
return true;
}
close(): boolean {
this.trayIcons.forEach(obj => {
this.remove(obj, true); // Removes from container and destroys it
});
this.trayIcons = [];
this.ribbons = [];
this.setVisible(false);
// this.exitCallback();
return true;
}
}

View File

@ -51,6 +51,7 @@ import { BaseStatsOverlay } from "#ui/base-stats-overlay";
import { MessageUiHandler } from "#ui/message-ui-handler";
import { MoveInfoOverlay } from "#ui/move-info-overlay";
import { PokedexInfoOverlay } from "#ui/pokedex-info-overlay";
import { RibbonTray } from "#ui/ribbon-tray-container";
import { StatsContainer } from "#ui/stats-container";
import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions } from "#ui/text";
import { addWindow } from "#ui/ui-theme";
@ -168,7 +169,7 @@ enum MenuOptions {
TM_MOVES,
BIOMES,
NATURES,
TOGGLE_IVS,
RIBBONS,
EVOLUTIONS,
}
@ -206,11 +207,11 @@ export class PokedexPageUiHandler extends MessageUiHandler {
private shinyIconElement: Phaser.GameObjects.Sprite;
private formIconElement: Phaser.GameObjects.Sprite;
private genderIconElement: Phaser.GameObjects.Sprite;
private variantIconElement: Phaser.GameObjects.Sprite;
private ivIconElement: Phaser.GameObjects.Sprite;
private shinyLabel: Phaser.GameObjects.Text;
private formLabel: Phaser.GameObjects.Text;
private genderLabel: Phaser.GameObjects.Text;
private variantLabel: Phaser.GameObjects.Text;
private ivLabel: Phaser.GameObjects.Text;
private candyUpgradeIconElement: Phaser.GameObjects.Sprite;
private candyUpgradeLabel: Phaser.GameObjects.Text;
private showBackSpriteIconElement: Phaser.GameObjects.Sprite;
@ -288,6 +289,10 @@ export class PokedexPageUiHandler extends MessageUiHandler {
private canUseCandies: boolean;
private exitCallback;
// Ribbons
private ribbonContainer: RibbonTray;
private isRibbonTrayOpen = false;
constructor() {
super(UiMode.POKEDEX_PAGE);
}
@ -563,24 +568,24 @@ export class PokedexPageUiHandler extends MessageUiHandler {
);
this.genderLabel.setName("text-gender-label");
this.variantIconElement = new Phaser.GameObjects.Sprite(
this.ivIconElement = new Phaser.GameObjects.Sprite(
globalScene,
this.instructionRowX,
this.instructionRowY,
"keyboard",
"V.png",
);
this.variantIconElement.setName("sprite-variant-icon-element");
this.variantIconElement.setScale(0.675);
this.variantIconElement.setOrigin(0.0, 0.0);
this.variantLabel = addTextObject(
this.ivIconElement.setName("sprite-variant-icon-element");
this.ivIconElement.setScale(0.675);
this.ivIconElement.setOrigin(0.0, 0.0);
this.ivLabel = addTextObject(
this.instructionRowX + this.instructionRowTextOffset,
this.instructionRowY,
i18next.t("pokedexUiHandler:cycleVariant"),
i18next.t("pokedexUiHandler:toggleIVs"),
TextStyle.INSTRUCTIONS_TEXT,
{ fontSize: instructionTextSize },
);
this.variantLabel.setName("text-variant-label");
this.ivLabel.setName("text-iv-label");
this.showBackSpriteIconElement = new Phaser.GameObjects.Sprite(globalScene, 50, 7, "keyboard", "E.png");
this.showBackSpriteIconElement.setName("show-backSprite-icon-element");
@ -656,7 +661,7 @@ export class PokedexPageUiHandler extends MessageUiHandler {
i18next.t("pokedexUiHandler:showTmMoves"),
i18next.t("pokedexUiHandler:showBiomes"),
i18next.t("pokedexUiHandler:showNatures"),
i18next.t("pokedexUiHandler:toggleIVs"),
i18next.t("pokedexUiHandler:showRibbons"),
i18next.t("pokedexUiHandler:showEvolutions"),
];
@ -698,6 +703,10 @@ export class PokedexPageUiHandler extends MessageUiHandler {
});
this.starterSelectContainer.add(this.infoOverlay);
this.ribbonContainer = new RibbonTray(this, 192, 0);
this.starterSelectContainer.add(this.ribbonContainer);
this.ribbonContainer.setVisible(false);
// Filter bar sits above everything, except the message box
this.starterSelectContainer.bringToTop(this.starterSelectMessageBoxContainer);
@ -763,8 +772,11 @@ export class PokedexPageUiHandler extends MessageUiHandler {
const label = i18next.t(`pokedexUiHandler:${toCamelCase(`menu${MenuOptions[o]}`)}`);
const isDark =
!isSeen
|| (!isStarterCaught && (o === MenuOptions.TOGGLE_IVS || o === MenuOptions.NATURES))
|| (this.tmMoves.length === 0 && o === MenuOptions.TM_MOVES);
|| (!isStarterCaught && (o === MenuOptions.NATURES || o === MenuOptions.RIBBONS))
|| (this.tmMoves.length === 0 && o === MenuOptions.TM_MOVES)
|| (!globalScene.gameData.dexData[this.species.speciesId].ribbons.getRibbons()
&& o === MenuOptions.RIBBONS
&& !globalScene.showMissingRibbons);
const color = getTextColor(isDark ? TextStyle.SHADOW_TEXT : TextStyle.SETTINGS_VALUE, false);
const shadow = getTextColor(isDark ? TextStyle.SHADOW_TEXT : TextStyle.SETTINGS_VALUE, true);
return `[shadow=${shadow}][color=${color}]${label}[/color][/shadow]`;
@ -1152,6 +1164,17 @@ export class PokedexPageUiHandler extends MessageUiHandler {
const isSeen = this.isSeen();
const isStarterCaught = !!this.isCaught(this.getStarterSpecies(this.species));
if (this.isRibbonTrayOpen) {
if (button === Button.CANCEL) {
this.isRibbonTrayOpen = false;
this.ribbonContainer.close();
this.setCursor(MenuOptions.RIBBONS);
ui.playSelect();
return success;
}
return this.ribbonContainer.processInput(button);
}
if (this.blockInputOverlay) {
if (button === Button.CANCEL || button === Button.ACTION) {
this.blockInputOverlay = false;
@ -1749,12 +1772,18 @@ export class PokedexPageUiHandler extends MessageUiHandler {
}
break;
case MenuOptions.TOGGLE_IVS:
case MenuOptions.RIBBONS:
if (!isStarterCaught) {
error = true;
} else if (
!globalScene.gameData.dexData[this.species.speciesId].ribbons.getRibbons()
&& !globalScene.showMissingRibbons
) {
ui.showText(i18next.t("pokedexUiHandler:noRibbons"));
error = true;
} else {
this.toggleStatsMode();
ui.setMode(UiMode.POKEDEX_PAGE, "refresh");
this.isRibbonTrayOpen = true;
this.ribbonContainer.open(this.species);
success = true;
}
break;
@ -1903,6 +1932,15 @@ export class PokedexPageUiHandler extends MessageUiHandler {
success = true;
}
break;
case Button.CYCLE_TERA:
if (isStarterCaught) {
this.toggleStatsMode();
ui.setMode(UiMode.POKEDEX_PAGE, "refresh");
success = true;
} else {
error = true;
}
break;
case Button.STATS:
if (!isCaught || !isFormCaught || !this.canUseCandies) {
error = true;
@ -2171,6 +2209,9 @@ export class PokedexPageUiHandler extends MessageUiHandler {
case SettingKeyboard.Button_Cycle_Ability:
iconPath = "E.png";
break;
case SettingKeyboard.Button_Cycle_Tera:
iconPath = "V.png";
break;
default:
break;
}
@ -2246,6 +2287,7 @@ export class PokedexPageUiHandler extends MessageUiHandler {
if (this.canCycleForm) {
this.updateButtonIcon(SettingKeyboard.Button_Cycle_Form, gamepadType, this.formIconElement, this.formLabel);
}
this.updateButtonIcon(SettingKeyboard.Button_Cycle_Tera, gamepadType, this.ivIconElement, this.ivLabel);
}
}
@ -2826,8 +2868,8 @@ export class PokedexPageUiHandler extends MessageUiHandler {
this.formLabel.setVisible(false);
this.genderIconElement.setVisible(false);
this.genderLabel.setVisible(false);
this.variantIconElement.setVisible(false);
this.variantLabel.setVisible(false);
this.ivIconElement.setVisible(false);
this.ivLabel.setVisible(false);
}
clear(): void {

94
src/utils/ribbon-utils.ts Normal file
View File

@ -0,0 +1,94 @@
import { pokemonEvolutions } from "#balance/pokemon-evolutions";
import type { PokemonSpecies } from "#data/pokemon-species";
import { PokemonType } from "#enums/pokemon-type";
import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
import { getPokemonSpecies } from "./pokemon-utils";
export function getRibbonForType(type: PokemonType): RibbonFlag {
// Valid types: 017, excluding UNKNOWN (-1) and STELLAR (19)
if (type < PokemonType.NORMAL || type > PokemonType.FAIRY) {
return 0n;
}
return (1n << BigInt(type)) as RibbonFlag;
}
export function getRibbonForGeneration(gen: number): RibbonFlag {
// Valid generations: 19
if (gen < 1 || gen > 9) {
return 0n;
}
return (1n << BigInt(17 + gen)) as RibbonFlag;
}
export function extractRibbons(data: bigint): RibbonFlag[] {
const ribbons: RibbonFlag[] = [];
let bit = 1n;
while (bit <= data) {
if ((data & bit) !== 0n) {
ribbons.push(bit as RibbonFlag);
}
bit <<= 1n; // move to the next bit
}
return ribbons;
}
export function getAvailableRibbons(species: PokemonSpecies): RibbonFlag[] {
const ribbons: RibbonFlag[] = [
RibbonData.CLASSIC,
RibbonData.NUZLOCKE,
RibbonData.FRIENDSHIP,
RibbonData.FLIP_STATS,
RibbonData.INVERSE,
RibbonData.FRESH_START,
RibbonData.HARDCORE,
RibbonData.LIMITED_CATCH,
RibbonData.NO_HEAL,
RibbonData.NO_SHOP,
RibbonData.NO_SUPPORT,
];
let data = 0n;
const speciesToCheck = [species.speciesId];
while (speciesToCheck.length > 0) {
const checking = speciesToCheck.pop();
if (checking == null) {
continue;
}
const checkingSpecies = getPokemonSpecies(checking);
data |= getRibbonForGeneration(checkingSpecies.generation);
data |= getRibbonForType(checkingSpecies.type1);
if (checkingSpecies.type2 != null) {
data |= getRibbonForType(checkingSpecies.type2);
}
for (const form of species.forms) {
data |= getRibbonForType(form.type1);
if (form.type2 != null) {
data |= getRibbonForType(form.type2);
}
}
if (checking && pokemonEvolutions.hasOwnProperty(checking)) {
pokemonEvolutions[checking].forEach(e => {
speciesToCheck.push(e.speciesId);
});
}
}
const extraRibbons = extractRibbons(data);
return ribbons.concat(extraRibbons);
}
export function getRibbonKey(flag: RibbonFlag): string {
for (const [key, value] of Object.entries(RibbonData)) {
if (typeof value === "bigint" && value === flag) {
return key;
}
}
return "";
}

File diff suppressed because one or more lines are too long