pokerogue/src/ui/dropdown.ts
MokaStitcher 0796a9fce8
[Enhancement] Improvements to starter selection and filtering user experience (#3325)
* [filter-ui] Improvements to starter selection and filtering user experience
Original messages of 14 squashed commits:
* final cleanup and code comments
* automatically go to the list of starters when closing filters
* FilterBar cleanup. Associate each DropDown with an id and access them through it
* reset all filters when creating a new game. Set different default gen filter for challenge mode
* start of code cleanup plus some documentation
* fix filter bar label coloring for legacy theme
* change generation filter default values to be all generations selected
* fix navigation between team and filtered Pokemon
* add missing localisation keys
* first pass at improving navigation between the UI elements
* have each filter group handle its default values instead of the filter bar
* revamp dropdown class. add possibility to display both a sprite and text label at the same time
* groundwork to be able to move around starter ui elements more easily
* add hybrid filtering type for Gen and Type filters, clean up implementation for radial type

* [loc][ko][zh] localisation of starter ui filters for Chinese and Korean

Co-authored-by: Enoch <enoch.jwsong@gmail.com>
Co-authored-by: Yonmaru40 <47717431+40chyan@users.noreply.github.com>

* [loc][de] German translations for the filters

Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com>

---------

Co-authored-by: Enoch <enoch.jwsong@gmail.com>
Co-authored-by: Yonmaru40 <47717431+40chyan@users.noreply.github.com>
Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com>
2024-08-04 02:50:13 -04:00

579 lines
18 KiB
TypeScript

import BattleScene from "#app/battle-scene.js";
import { SceneBase } from "#app/scene-base.js";
import { addTextObject, TextStyle } from "./text";
import { addWindow, WindowVariant } from "./ui-theme";
import i18next from "i18next";
export enum DropDownState {
ON = 0,
OFF = 1,
EXCLUDE = 2
}
export enum DropDownType {
SINGLE = 0,
MULTI = 1,
HYBRID = 2,
RADIAL = 3
}
export enum SortDirection {
ASC = -1,
DESC = 1
}
export class DropDownLabel {
public state: DropDownState;
public text: string;
public sprite?: Phaser.GameObjects.Sprite;
constructor(label: string, sprite?: Phaser.GameObjects.Sprite, state: DropDownState = DropDownState.ON) {
this.text = label || "";
this.sprite = sprite;
this.state = state || DropDownState.ON;
}
}
export class DropDownOption extends Phaser.GameObjects.Container {
public state: DropDownState = DropDownState.ON;
public toggle: Phaser.GameObjects.Sprite;
public text: Phaser.GameObjects.Text;
public val: any;
public dir: SortDirection = SortDirection.ASC;
private currentLabelIndex: number;
private labels: DropDownLabel[];
private onColor = 0x33bbff;
private offColor = 0x272727;
private excludeColor = 0xff5555;
constructor(scene: SceneBase, val: any, labels: DropDownLabel | DropDownLabel[]) {
super(scene);
this.val = val;
if (Array.isArray(labels)) {
this.labels = labels;
} else {
this.labels = labels? [ labels ] : [ new DropDownLabel("") ];
}
this.currentLabelIndex = 0;
const currentLabel = this.labels[this.currentLabelIndex];
this.state = currentLabel.state;
this.text = addTextObject(scene, 0, 0, currentLabel.text || "", TextStyle.TOOLTIP_CONTENT);
this.text.setOrigin(0, 0.5);
this.add(this.text);
// Add to container the sprite for each label if there is one
for (let i=0; i < this.labels.length; i++) {
const sprite = this.labels[i].sprite;
if (sprite) {
this.add(sprite);
sprite.setOrigin(0, 0.5);
if (i!== this.currentLabelIndex) {
sprite.setVisible(false);
}
}
}
}
/**
* Initialize the toggle icon based on the provided DropDownType
* For DropDownType.SINGLE: uses a cursor arrow icon
* For other types: uses a candy icon
* @param type the DropDownType to use
* @param visible whether the icon should be visible or not
*/
setupToggleIcon(type: DropDownType, visible: boolean): void {
if (type === DropDownType.SINGLE) {
this.toggle = this.scene.add.sprite(0, 0, "cursor");
this.toggle.setScale(0.5);
this.toggle.setOrigin(0, 0.5);
this.toggle.setRotation(Math.PI / 180 * -90);
} else {
this.toggle = this.scene.add.sprite(0, 0, "candy");
this.toggle.setScale(0.3);
this.toggle.setOrigin(0, 0.5);
}
this.add(this.toggle);
this.toggle.setVisible(visible);
this.updateToggleIconColor();
}
/**
* Set the toggle icon color based on the current state
*/
private updateToggleIconColor(): void {
switch (this.state) {
case DropDownState.ON:
this.toggle.setTint(this.onColor);
break;
case DropDownState.OFF:
this.toggle.setTint(this.offColor);
break;
case DropDownState.EXCLUDE:
this.toggle.setTint(this.excludeColor);
break;
}
}
/**
* Switch the option to its next state and update visuals
* If only ON/OFF are possible, toggle between the two
* For radials, move to the next state in the list
* @returns the updated DropDownState
*/
public toggleOptionState(): DropDownState {
if (this.labels.length > 1) {
return this.setCurrentLabel((this.currentLabelIndex + 1) % this.labels.length);
}
const newState = this.state === DropDownState.ON ? DropDownState.OFF : DropDownState.ON;
return this.setOptionState(newState);
}
/**
* Set the option to the given state and update visuals
* @param newState the state to switch to
* @returns the new DropDownState
*/
public setOptionState(newState: DropDownState): DropDownState {
const newLabelIndex = this.labels.findIndex(label => label.state === newState);
if (newLabelIndex !== -1 && newLabelIndex !== this.currentLabelIndex) {
return this.setCurrentLabel(newLabelIndex);
}
this.state = newState;
this.updateToggleIconColor();
return newState;
}
/**
* Change the option state to the one at the given index and update visuals
* @param index index of the state to switch to
* @returns the new DropDownState
*/
private setCurrentLabel(index: number): DropDownState {
const currentLabel = this.labels[this.currentLabelIndex];
const newLabel = this.labels[index];
if (!newLabel) {
return this.state;
}
this.currentLabelIndex = index;
// update state, sprite and text to fit the new label
this.state = newLabel.state;
this.updateToggleIconColor();
if (currentLabel.sprite) {
this.text.x -= currentLabel.sprite.displayWidth + 2;
currentLabel.sprite.setVisible(false);
}
if (newLabel.sprite) {
this.text.x += newLabel.sprite.displayWidth + 2;
newLabel.sprite.setVisible(true);
}
this.text.setText(newLabel.text);
return this.state;
}
/**
* Set the current SortDirection to the provided value and update icon accordingly
* @param SortDirection the new SortDirection to use
*/
public setDirection(dir: SortDirection): void {
this.dir = dir;
this.toggle.flipX = this.dir === SortDirection.DESC;
}
/**
* Toggle the current SortDirection value
*/
public toggleDirection(): void {
this.setDirection(this.dir * -1);
}
/**
* Place the label elements (text and sprite if there is one) to the provided x and y position
* @param x the horizontal position
* @param y the vertical position
*/
setLabelPosition(x: number, y: number) {
let textX = x;
for (let i=0; i < this.labels.length; i++) {
const label = this.labels[i];
if (label.sprite) {
label.sprite.x = x;
label.sprite.y = y;
if (i === this.currentLabelIndex) {
textX += label.sprite.displayWidth + 2;
}
}
}
if (this.text) {
this.text.x = textX;
this.text.y = y;
}
}
/**
* Place the toggle icon at the provided position
* @param x the horizontal position
* @param y the vertical position
*/
setTogglePosition(x: number, y: number) {
if (this.toggle) {
this.toggle.x = x;
this.toggle.y = y;
}
}
/**
* @returns the x position to use for the current label depending on if it has a sprite or not
*/
getCurrentLabelX(): number {
if (this.labels[this.currentLabelIndex].sprite) {
return this.labels[this.currentLabelIndex].sprite.x;
}
return this.text.x;
}
/**
* @returns max width needed to display all of the labels
*/
getWidth(): number {
let w = 0;
const currentText = this.text.text;
for (const label of this.labels) {
this.text.setText(label.text);
const spriteWidth = label.sprite? label.sprite.displayWidth + 2 : 0;
w = Math.max(w, this.text.displayWidth + spriteWidth);
}
this.text.setText(currentText);
return w;
}
}
export class DropDown extends Phaser.GameObjects.Container {
public options: DropDownOption[];
private window: Phaser.GameObjects.NineSlice;
private cursorObj: Phaser.GameObjects.Image;
private dropDownType: DropDownType = DropDownType.MULTI;
public cursor: number = 0;
public defaultCursor: number = 0;
private onChange: () => void;
private lastDir: SortDirection = SortDirection.ASC;
private defaultValues: any[];
constructor(scene: BattleScene, x: number, y: number, options: DropDownOption[], onChange: () => void, type: DropDownType = DropDownType.MULTI, optionSpacing: number = 2) {
const windowPadding = 5;
const optionHeight = 7;
const optionPaddingX = 4;
const optionPaddingY = 6;
const cursorOffset = 7;
const optionWidth = 100;
super(scene, x - cursorOffset - windowPadding, y);
this.options = options;
this.dropDownType = type;
this.onChange = onChange;
this.cursorObj = scene.add.image(optionPaddingX + 3, 0, "cursor");
this.cursorObj.setScale(0.5);
this.cursorObj.setOrigin(0, 0.5);
this.cursorObj.setVisible(false);
// For MULTI and HYBRID filter, add an ALL option at the top
if (this.dropDownType === DropDownType.MULTI || this.dropDownType === DropDownType.HYBRID) {
this.options.unshift(new DropDownOption(scene, "ALL", new DropDownLabel(i18next.t("filterBar:all"), undefined, this.checkForAllOn() ? DropDownState.ON : DropDownState.OFF)));
}
this.defaultValues = this.getVals();
// Place ui elements in the correct spot
options.forEach((option, index) => {
const toggleVisibility = type !== DropDownType.SINGLE || option.state === DropDownState.ON;
option.setupToggleIcon(type, toggleVisibility);
option.width = optionWidth;
option.y = index * optionHeight + index * optionSpacing + optionPaddingY;
const baseX = cursorOffset + optionPaddingX + 3;
const baseY = optionHeight / 2;
option.setLabelPosition(baseX + 8, baseY);
if (type === DropDownType.SINGLE) {
option.setTogglePosition(baseX + 3, baseY + 1);
} else {
option.setTogglePosition(baseX, baseY);
}
});
this.window = addWindow(scene, 0, 0, optionWidth, options[options.length - 1].y + optionHeight + optionPaddingY, false, false, null, null, WindowVariant.XTHIN);
this.add(this.window);
this.add(options);
this.add(this.cursorObj);
this.setVisible(false);
}
getWidth(): number {
return this.window? this.window.width : this.width;
}
toggleVisibility(): void {
this.setVisible(!this.visible);
}
setVisible(value: boolean): this {
super.setVisible(value);
if (value) {
this.autoSize();
}
return this;
}
resetCursor(): boolean {
// If we are an hybrid dropdown in "hover" mode, don't move the cursor back to 0
if (this.dropDownType === DropDownType.HYBRID && this.checkForAllOff() && this.cursor > 0) {
return false;
}
return this.setCursor(this.defaultCursor);
}
setCursor(cursor: integer): boolean {
this.cursor = cursor;
if (cursor < 0) {
cursor = 0;
this.cursorObj.setVisible(false);
return false;
} else if (cursor >= this.options.length) {
cursor = this.options.length - 1;
this.cursorObj.y = this.options[cursor].y + 3.5;
this.cursorObj.setVisible(true);
return false;
} else {
this.cursorObj.y = this.options[cursor].y + 3.5;
this.cursorObj.setVisible(true);
// If hydrid type, we need to update the filters when going up/down in the list
if (this.dropDownType === DropDownType.HYBRID) {
this.onChange();
}
}
return true;
}
/**
* Switch the option at the provided index to its next state and update visuals
* Update accordingly the other options if needed:
* - if "all" is toggled, also update all other options
* - for DropDownType.SINGLE, unselect the previously selected option if applicable
* @param index the index of the option for which to update the state
*/
toggleOptionState(index: number = this.cursor): void {
const option: DropDownOption = this.options[index];
if (this.dropDownType === DropDownType.MULTI || this.dropDownType === DropDownType.HYBRID) {
const newState = option.toggleOptionState();
if (index === 0) {
// we are on the All option > put all other options to the newState
this.setAllOptions(newState);
} else {
// select the "all" option if all others are selected, other unselect it
if (newState === DropDownState.ON && this.checkForAllOn()) {
this.options[0].setOptionState(DropDownState.ON);
} else {
this.options[0].setOptionState(DropDownState.OFF);
}
}
} else if (this.dropDownType === DropDownType.SINGLE) {
if (option.state === DropDownState.OFF) {
this.options.forEach((option) => {
option.setOptionState(DropDownState.OFF);
option.setDirection(SortDirection.ASC);
option.toggle.setVisible(false);
});
option.setOptionState(DropDownState.ON);
option.setDirection(this.lastDir);
option.toggle.setVisible(true);
} else {
option.toggleDirection();
this.lastDir = this.options[this.cursor].dir;
}
} else if (this.dropDownType === DropDownType.RADIAL) {
option.toggleOptionState();
}
this.onChange();
}
/**
* Check whether all options except the "ALL" one are ON
* @returns true if all options are set to DropDownState.ON, false otherwise
*/
checkForAllOn(): boolean {
return this.options.every((option, i) => i === 0 || option.state === DropDownState.ON);
}
/**
* Check whether all options except the "ALL" one are OFF
* @returns true if all options are set to DropDownState.OFF, false otherwise
*/
checkForAllOff(): boolean {
return this.options.every((option, i) => i === 0 || option.state === DropDownState.OFF);
}
/**
* Get the current selected values for each option
* @returns an array of values, depending on the DropDownType
* - if MULTI or HYBRID, an array of all the values of the options set to ON (except the ALL one)
* - if RADIAL, an array where the value for each option is of the form { val: any, state: DropDownState }
* - if SINGLE, a single object of the form { val: any, state: SortDirection }
*/
getVals(): any[] {
if (this.dropDownType === DropDownType.MULTI) {
return this.options.filter((option, i) => i > 0 && option.state === DropDownState.ON).map((option) => option.val);
} else if (this.dropDownType === DropDownType.HYBRID) {
const selected = this.options.filter((option, i) => i > 0 && option.state === DropDownState.ON).map((option) => option.val);
if (selected.length > 0) {
return selected;
}
// if nothing is selected and the ALL option is hovered, return all elements
if (this.cursor === 0) {
return this.options.filter((_, i) => i > 0).map(option => option.val);
}
// if nothing is selected and a single option is hovered, return that one
return [this.options[this.cursor].val];
} else if (this.dropDownType === DropDownType.RADIAL) {
return this.options.map((option) => {
return { val: option.val, state: option.state };
});
} else {
return this.options.filter(option => option.state === DropDownState.ON).map((option) => {
return { val: option.val, dir: option.dir };
});
}
}
/**
* Check whether the values of all options are the same as the default ones
* @returns true if they are the same, false otherwise
*/
public hasDefaultValues(): boolean {
const currentValues = this.getVals();
switch (this.dropDownType) {
case DropDownType.MULTI:
case DropDownType.HYBRID:
return currentValues.length === this.defaultValues.length && currentValues.every((value, index) => value === this.defaultValues[index]);
case DropDownType.RADIAL:
return currentValues.every((value, index) => value["val"] === this.defaultValues[index]["val"] && value["state"] === this.defaultValues[index]["state"]);
case DropDownType.SINGLE:
return currentValues[0]["dir"] === this.defaultValues[0]["dir"] && currentValues[0]["val"] === this.defaultValues[0]["val"];
default:
return false;
}
}
/**
* Set all values to their default state
*/
public resetToDefault(): void {
this.setCursor(this.defaultCursor);
for (let i = 0; i < this.options.length; i++) {
const option = this.options[i];
// reset values
switch (this.dropDownType) {
case DropDownType.HYBRID:
case DropDownType.MULTI:
if (this.defaultValues.includes(option.val)) {
option.setOptionState(DropDownState.ON);
} else {
option.setOptionState(DropDownState.OFF);
}
break;
case DropDownType.RADIAL:
const targetValue = this.defaultValues.find(value => value.val === option.val);
option.setOptionState(targetValue.state);
break;
case DropDownType.SINGLE:
if (option.val === this.defaultValues[0].val) {
if (option.state !== DropDownState.ON) {
this.toggleOptionState(i);
}
if (option.dir !== this.defaultValues[0].dir) {
this.toggleOptionState(i);
}
}
break;
}
}
// Select or unselect "ALL" button if applicable
if (this.dropDownType === DropDownType.MULTI || this.dropDownType === DropDownType.HYBRID) {
if (this.checkForAllOn()) {
this.options[0].setOptionState(DropDownState.ON);
} else {
this.options[0].setOptionState(DropDownState.OFF);
}
}
}
/**
* Set all options to a specific state
* @param state the DropDownState to assign to each option
*/
private setAllOptions(state: DropDownState) : void {
// For single type dropdown, setting all options is not relevant
if (this.dropDownType === DropDownType.SINGLE) {
return;
}
for (const option of this.options) {
option.setOptionState(state);
}
}
/**
* Set all options to their ON state
*/
public selectAllOptions() {
this.setAllOptions(DropDownState.ON);
}
/**
* Set all options to their OFF state
*/
public unselectAllOptions() {
this.setAllOptions(DropDownState.OFF);
}
/**
* Automatically set the width and position based on the size of options
*/
autoSize(): void {
let maxWidth = 0;
let x = 0;
for (let i = 0; i < this.options.length; i++) {
const optionWidth = this.options[i].getWidth();
if (optionWidth > maxWidth) {
maxWidth = optionWidth;
x = this.options[i].getCurrentLabelX();
}
}
this.window.width = maxWidth + x - this.window.x + 6;
if (this.x + this.window.width > this.parentContainer.width) {
this.x = this.parentContainer.width - this.window.width;
}
}
}