pokerogue/src/ui/abstact-option-select-ui-handler.ts
Wlowscha 66c70b07a7
[UI/UX] In-Game Pokedex (#5083)
* Working ui, missing logic, logs

* Filtering starters by name is working

* Filtering moves and abilities correctly

* Opening starter page on button.action

* Removed ugly leftover from title

* Added container for text with different colors and titles

* Showing all species in pokedex with no decorations and shinies

* Filtering includes extra forms; moving cursor from filterText to starters does not reset scrollIndex; toggle button for decorations

* Can access evolution page

* Abilities are colored properly (still missing info overlay)

* Biome filter; displays for baseStats, biomes and evolutions

* Removed lockable select ui handler, replaced by changes to standard ui handler.

* Evolutions are selectable from list and displayed properly

* Keeps shiny variant, gender and form when switching to evolutions; show ability descriptions; properly displaying sprites for megas and other forms

* Listing prevolutions and base forms

* Fixed filtering of baby forms with no biome assigned; Caught filter is ALL by default

* Highlighting text filters, resetting all filters when starting up

* No error messag when cursor on uncaught species, showing sprite again after toggling stats

* Simplified Pokemon Scan logic, accepts separate words as input

* Dynamically resizing ability box, showing ability description on first hover. Removed debug logs.

* Removed some more debug messages.

* Filter bar can adjust cursorOffset and x padding

* Fixed some type definitions

* Fixed more warnings; added localization strings in the pokedex scan overlay.

* Fixed fatal bug due to using Object.keys

* Removed debug messages

* Added try catch construct to prevent error that was breaking reloadHelper tests

* Added filter for starters / evolutions

* Biome filter option for uncatchable mons

* C and V buttons snap cursor to filters

* Changing background to make instructions visible

* Can buy candy upgrades through pokedex

* Displaying base stats as bars in an overlay

* Including baby forms among uncatchable mons

* Including evolutions when filtering by biome

* Working logic for select ui handler with skips and scroll

* -Pokedex page showing biomes from prevolutions; displaying correct biomes for forms of Rotom, Burmy and Lycanroc

* Fixed bug in base stats overlay

* Regional forms display name of region in evolutions and prevolutions

* Better messages for evolution conditions

* Showing proper descriptions for menu

* Adding sound effects to menu, and pokemon cry when opening page

* Changing menu colors to textstyle options supporting a legacy version.

* Fix to getStarterSpeciesId to work with all-unlocks files

* Passing a TextStyle to option select ui handler to allow for shadowed text

* Fixed bug of overlapping labels in text filters

* Fixed bug with supportHover and skipped indices in option select ui handler

* Localization of pokemon number label

* Fix to pokemon number localization

* Fix to pokemon number localization

* Adding some comments, removing useless elements

* More cleanup

* Removed candy upgrade instructions from evolved pokemon; attempting to buy candies from evolution now gives error sound instead of crashing the game

* Attempting to exit from filter text is now allowed if current option is empty

* UI changes to make dex pages work in legacy style

* Pokemon name shown while in alt form is no more capitalized

* Handling uncaught pokemon

* Showing types on Pokémon page

* Introducing globalScene everywhere

* Showing evolution requirements in message box

* Displaying form changing items; now using pokemonFormChanges to only show reachable forms

* Playing correct cry

* Pokemon cry in setSpeciesDetails

* Left and right buttons to turn previous or next pokedex page

* Cleaned up "last" from this.species; turning pages now preserves memories of unlocks

* Pokerus cursor is now treated as decoration

* Correctly displaying prevolutions for Pikachu and Gholdengo

* Uncaught forms can be cycled through (with black sprite and no options available)

* Filtering by moves now shows icons to distinguish egg and tm moves

* Added icons for passive abilities

* Added icons to legacy mode; fixed bug that caused game to hang when switching to or from legacy mode

* Pokedex entries are accessible through party screen

* Adding sort criteria for consistency with starter select screen

* Added options to cost reduction filter for consistency with starter select screen

* Updating optionSelectUiHandler to simplify logic and fix bug of autocomplete showing options incorrectly

* Adding Pokedéx option in starter select screen

* Prevolutions are shown properly again; battle forms are considered caught as long as the base form is caught

* Small fixes to evolution and form change descriptions

* Reworked evolutions menu to incorporate condition descriptions

* Moving evolution condition description logic entirely to the SpeciesEvolution class

* Removed extra Miraidon and Koraidon forms

* Properly showing evolution text for Dunsparce and Maushold

* Displaying uncaught forms for Dudunsparce and Maushold properly

* Displaying correct forms for Urshifu and Toxicitry after evolution

* Cleared up comments

* Updating test for tandemaus evolution

* Localized labels for egg moves and abilities

* Added button to show back sprites

* Back to showing only caught battleforms; added dexForDevs option

* Merging shiny and variant buttons

* Uncaught battle forms options are shown in dark text, like evolutions

* Showing proper gender for mons that can only be (or have only caught in) one gender

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Removed unused options from base-stats-overlay

* Fixed import of BaseStatsOverlay

* Displaying form-specific TMs properly; adjusting for passives rework

* Removed logging messages

* resetting containers to prevent memory leaks

* Updating integer to number in pokedex

* Implemented suggestion

* Removed some stray comments

* Fixed logic for cursor coming down from filter bar

* Transition from filters to dex box now works in a visually pleasing way

---------

Co-authored-by: Lugiad <2070109+Adri1@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: damocleas <damocleas25@gmail.com>
2025-02-08 11:48:06 -05:00

409 lines
14 KiB
TypeScript

import { globalScene } from "#app/global-scene";
import { TextStyle, addBBCodeTextObject, getTextColor, getTextStyleOptions } from "./text";
import { Mode } from "./ui";
import UiHandler from "./ui-handler";
import { addWindow } from "./ui-theme";
import * as Utils from "../utils";
import { argbFromRgba } from "@material/material-color-utilities";
import { Button } from "#enums/buttons";
import BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext";
export interface OptionSelectConfig {
xOffset?: number;
yOffset?: number;
options: OptionSelectItem[];
maxOptions?: number;
delay?: number;
noCancel?: boolean;
supportHover?: boolean;
}
export interface OptionSelectItem {
label: string;
handler: () => boolean;
onHover?: () => void;
skip?: boolean;
keepOpen?: boolean;
overrideSound?: boolean;
style?: TextStyle;
item?: string;
itemArgs?: any[];
}
const scrollUpLabel = "↑";
const scrollDownLabel = "↓";
export default abstract class AbstractOptionSelectUiHandler extends UiHandler {
protected optionSelectContainer: Phaser.GameObjects.Container;
protected optionSelectBg: Phaser.GameObjects.NineSlice;
protected optionSelectText: BBCodeText;
protected optionSelectIcons: Phaser.GameObjects.Sprite[];
protected config: OptionSelectConfig | null;
protected blockInput: boolean;
protected scrollCursor: number = 0;
protected fullCursor: number = 0;
protected scale: number = 0.1666666667;
private cursorObj: Phaser.GameObjects.Image | null;
protected unskippedIndices: number[] = [];
protected defaultTextStyle: TextStyle = TextStyle.WINDOW;
constructor(mode: Mode | null) {
super(mode);
}
abstract getWindowWidth(): number;
getWindowHeight(): number {
return (Math.min((this.config?.options || []).length, this.config?.maxOptions || 99) + 1) * 96 * this.scale;
}
setup() {
const ui = this.getUi();
this.optionSelectContainer = globalScene.add.container((globalScene.game.canvas.width / 6) - 1, -48);
this.optionSelectContainer.setName(`option-select-${this.mode ? Mode[this.mode] : "UNKNOWN"}`);
this.optionSelectContainer.setVisible(false);
ui.add(this.optionSelectContainer);
this.optionSelectBg = addWindow(0, 0, this.getWindowWidth(), this.getWindowHeight());
this.optionSelectBg.setName("option-select-bg");
this.optionSelectBg.setOrigin(1, 1);
this.optionSelectContainer.add(this.optionSelectBg);
this.optionSelectIcons = [];
this.scale = getTextStyleOptions(TextStyle.WINDOW, globalScene.uiTheme).scale;
this.setCursor(0);
}
protected setupOptions() {
const configOptions = this.config?.options ?? [];
const options: OptionSelectItem[] = configOptions;
this.unskippedIndices = this.getUnskippedIndices(configOptions);
if (this.optionSelectText) {
if (this.optionSelectText instanceof BBCodeText) {
try {
this.optionSelectText.destroy();
} catch (error) {
console.error("Error while destroying optionSelectText:", error);
}
} else {
console.warn("optionSelectText is not an instance of BBCodeText.");
}
}
if (this.optionSelectIcons?.length) {
this.optionSelectIcons.map(i => i.destroy());
this.optionSelectIcons.splice(0, this.optionSelectIcons.length);
}
const optionsWithScroll = (this.config?.options && this.config?.options.length > (this.config?.maxOptions!)) ? this.getOptionsWithScroll() : options;
// Setting the initial text to establish the width of the select object. We consider all options, even ones that are not displayed,
// Except in the case of autocomplete, where we don't want to set up a text element with potentially hundreds of lines.
const optionsForWidth = globalScene.ui.getMode() === Mode.AUTO_COMPLETE ? optionsWithScroll : options;
this.optionSelectText = addBBCodeTextObject(
0, 0, optionsForWidth.map(o => o.item
? `[shadow=${getTextColor(o.style ?? this.defaultTextStyle, true, globalScene.uiTheme)}][color=${getTextColor(o.style ?? TextStyle.WINDOW, false, globalScene.uiTheme)}] ${o.label}[/color][/shadow]`
: `[shadow=${getTextColor(o.style ?? this.defaultTextStyle, true, globalScene.uiTheme)}][color=${getTextColor(o.style ?? TextStyle.WINDOW, false, globalScene.uiTheme)}]${o.label}[/color][/shadow]`
).join("\n"),
TextStyle.WINDOW, { maxLines: options.length, lineSpacing: 12 }
);
this.optionSelectText.setOrigin(0, 0);
this.optionSelectText.setName("text-option-select");
this.optionSelectContainer.add(this.optionSelectText);
this.optionSelectContainer.setPosition((globalScene.game.canvas.width / 6) - 1 - (this.config?.xOffset || 0), -48 + (this.config?.yOffset || 0));
this.optionSelectBg.width = Math.max(this.optionSelectText.displayWidth + 24, this.getWindowWidth());
this.optionSelectBg.height = this.getWindowHeight();
this.optionSelectText.setPosition(this.optionSelectBg.x - this.optionSelectBg.width + 12 + 24 * this.scale, this.optionSelectBg.y - this.optionSelectBg.height + 2 + 42 * this.scale);
// Now that the container and background widths are established, we can set up the proper text restricted to visible options
this.optionSelectText.setText(optionsWithScroll.map(o => o.item
? `[shadow=${getTextColor(o.style ?? this.defaultTextStyle, true, globalScene.uiTheme)}][color=${getTextColor(o.style ?? TextStyle.WINDOW, false, globalScene.uiTheme)}] ${o.label}[/color][/shadow]`
: `[shadow=${getTextColor(o.style ?? this.defaultTextStyle, true, globalScene.uiTheme)}][color=${getTextColor(o.style ?? TextStyle.WINDOW, false, globalScene.uiTheme)}]${o.label}[/color][/shadow]`
).join("\n")
);
options.forEach((option: OptionSelectItem, i: number) => {
if (option.item) {
const itemIcon = globalScene.add.sprite(0, 0, "items", option.item);
itemIcon.setScale(3 * this.scale);
this.optionSelectIcons.push(itemIcon);
this.optionSelectContainer.add(itemIcon);
itemIcon.setPositionRelative(this.optionSelectText, 36 * this.scale, 7 + i * (114 * this.scale - 3));
if (option.item === "candy") {
const itemOverlayIcon = globalScene.add.sprite(0, 0, "items", "candy_overlay");
itemOverlayIcon.setScale(3 * this.scale);
this.optionSelectIcons.push(itemOverlayIcon);
this.optionSelectContainer.add(itemOverlayIcon);
itemOverlayIcon.setPositionRelative(this.optionSelectText, 36 * this.scale, 7 + i * (114 * this.scale - 3));
if (option.itemArgs) {
itemIcon.setTint(argbFromRgba(Utils.rgbHexToRgba(option.itemArgs[0])));
itemOverlayIcon.setTint(argbFromRgba(Utils.rgbHexToRgba(option.itemArgs[1])));
}
}
}
});
}
show(args: any[]): boolean {
if (!args.length || !args[0].hasOwnProperty("options") || !args[0].options.length) {
return false;
}
super.show(args);
this.config = args[0] as OptionSelectConfig;
this.setupOptions();
globalScene.ui.bringToTop(this.optionSelectContainer);
this.optionSelectContainer.setVisible(true);
this.scrollCursor = 0;
this.fullCursor = 0;
this.setCursor(0);
if (this.config.delay) {
this.blockInput = true;
this.optionSelectText.setAlpha(0.5);
this.cursorObj?.setAlpha(0.8);
globalScene.time.delayedCall(Utils.fixedInt(this.config.delay), () => this.unblockInput());
}
if (this.config?.supportHover) {
// handle hover code if the element supports hover-handlers and the option has the optional hover-handler set.
this.config?.options[this.unskippedIndices[this.fullCursor]]?.onHover?.();
}
return true;
}
processInput(button: Button): boolean {
const ui = this.getUi();
let success = false;
let playSound = true;
if (button === Button.ACTION || button === Button.CANCEL) {
if (this.blockInput) {
ui.playError();
return false;
}
success = true;
if (button === Button.CANCEL) {
if (this.config?.maxOptions && this.config.options.length > this.config.maxOptions) {
this.setCursor(this.unskippedIndices.length - 1);
} else if (!this.config?.noCancel) {
this.setCursor(this.unskippedIndices.length - 1);
} else {
return false;
}
}
const option = this.config?.options[this.unskippedIndices[this.fullCursor]];
if (option?.handler()) {
if (!option.keepOpen) {
this.clear();
}
playSound = !option.overrideSound;
} else {
ui.playError();
}
} else if (button === Button.SUBMIT && ui.getMode() === Mode.AUTO_COMPLETE) {
// this is here to differentiate between a Button.SUBMIT vs Button.ACTION within the autocomplete handler
// this is here because Button.ACTION is picked up as z on the keyboard, meaning if you're typing and hit z, it'll select the option you've chosen
success = true;
const option = this.config?.options[this.unskippedIndices[this.fullCursor]];
if (option?.handler()) {
if (!option.keepOpen) {
this.clear();
}
playSound = !option.overrideSound;
} else {
ui.playError();
}
} else {
switch (button) {
case Button.UP:
if (this.fullCursor === 0) {
success = this.setCursor(this.unskippedIndices.length - 1);
} else if (this.fullCursor) {
success = this.setCursor(this.fullCursor - 1);
}
break;
case Button.DOWN:
if (this.fullCursor < this.unskippedIndices.length - 1) {
success = this.setCursor(this.fullCursor + 1);
} else {
success = this.setCursor(0);
}
break;
}
if (this.config?.supportHover) {
// handle hover code if the element supports hover-handlers and the option has the optional hover-handler set.
this.config?.options[this.unskippedIndices[this.fullCursor]]?.onHover?.();
}
}
if (success && playSound) {
ui.playSelect();
}
return success;
}
unblockInput(): void {
if (!this.blockInput) {
return;
}
this.blockInput = false;
this.optionSelectText.setAlpha(1);
this.cursorObj?.setAlpha(1);
}
getOptionsWithScroll(): OptionSelectItem[] {
if (!this.config) {
return [];
}
const options = this.config.options.slice(0);
if (!this.config.maxOptions || this.config.options.length < this.config.maxOptions) {
return options;
}
const optionsScrollTotal = options.length;
const optionStartIndex = this.scrollCursor;
const optionEndIndex = Math.min(optionsScrollTotal, optionStartIndex +
(!optionStartIndex || this.scrollCursor + (this.config.maxOptions - 1) >= optionsScrollTotal ? this.config.maxOptions - 1 : this.config.maxOptions - 2)
);
if (this.config?.maxOptions && options.length > this.config.maxOptions) {
options.splice(optionEndIndex, optionsScrollTotal);
options.splice(0, optionStartIndex);
if (optionStartIndex) {
options.unshift({
label: scrollUpLabel,
handler: () => true,
style: this.defaultTextStyle
});
}
if (optionEndIndex < optionsScrollTotal) {
options.push({
label: scrollDownLabel,
handler: () => true,
style: this.defaultTextStyle
});
}
}
return options;
}
getUnskippedIndices(options: OptionSelectItem[]) {
const unskippedIndices = options
.map((option, index) => (option.skip ? null : index)) // Map to index or null if skipped
.filter(index => index !== null) as number[];
return unskippedIndices;
}
setCursor(fullCursor: number): boolean {
const changed = this.fullCursor !== fullCursor;
if (changed && this.config?.maxOptions && this.config.options.length > this.config.maxOptions) {
// If the fullCursor is the last possible value, we go to the bottom
if (fullCursor === this.unskippedIndices.length - 1) {
this.fullCursor = fullCursor;
this.cursor = this.config.maxOptions - (this.config.options.length - this.unskippedIndices[fullCursor]);
this.scrollCursor = this.config.options.length - this.config.maxOptions + 1;
// If the fullCursor is the first possible value, we go to the top
} else if (fullCursor === 0) {
this.fullCursor = fullCursor;
this.cursor = this.unskippedIndices[fullCursor];
this.scrollCursor = 0;
} else {
const isDown = fullCursor && fullCursor > this.fullCursor;
if (isDown) {
// If there are skipped options under the next selection, we show them
const jumpFromCurrent = this.unskippedIndices[fullCursor] - this.unskippedIndices[this.fullCursor];
const skipsFromNext = this.unskippedIndices[fullCursor + 1] - this.unskippedIndices[fullCursor] - 1;
if (this.cursor + jumpFromCurrent + skipsFromNext >= this.config.maxOptions - 1) {
this.fullCursor = fullCursor;
this.cursor = this.config.maxOptions - 2 - skipsFromNext;
this.scrollCursor = this.unskippedIndices[this.fullCursor] - this.cursor + 1;
} else {
this.fullCursor = fullCursor;
this.cursor = this.unskippedIndices[fullCursor] - this.scrollCursor + (this.scrollCursor ? 1 : 0);
}
} else {
const jumpFromPrevious = this.unskippedIndices[fullCursor] - this.unskippedIndices[fullCursor - 1];
if (this.cursor - jumpFromPrevious < 1) {
this.fullCursor = fullCursor;
this.cursor = 1;
this.scrollCursor = this.unskippedIndices[this.fullCursor] - this.cursor + 1;
} else {
this.fullCursor = fullCursor;
this.cursor = this.unskippedIndices[fullCursor] - this.scrollCursor + (this.scrollCursor ? 1 : 0);
}
}
}
} else {
this.fullCursor = fullCursor;
this.cursor = this.unskippedIndices[fullCursor];
}
this.setupOptions();
if (!this.cursorObj) {
this.cursorObj = globalScene.add.image(0, 0, "cursor");
this.optionSelectContainer.add(this.cursorObj);
}
this.cursorObj.setScale(this.scale * 6);
this.cursorObj.setPositionRelative(this.optionSelectBg, 12, 102 * this.scale + this.cursor * (114 * this.scale - 3));
return changed;
}
clear() {
super.clear();
this.config = null;
this.optionSelectContainer.setVisible(false);
this.fullCursor = 0;
this.scrollCursor = 0;
this.eraseCursor();
}
eraseCursor() {
if (this.cursorObj) {
this.cursorObj.destroy();
}
this.cursorObj = null;
}
}