pokerogue/src/ui/abstact-option-select-ui-handler.ts
Sirz Benjie 5854b21da0
[Refactor] Remove circular imports part 1 (#5663)
* Extract Mode enum out of UI and into its own file

Reduces circular imports from 909 to 773

* Move around utility files

Reduces cyclical dependencies from 773 to 765

* Remove starterColors and bypassLogin from battle-scene

Reduces cyclical dependencies from 765 to 623

* Fix test runner error

* Update import for bypassLogin in test

* Update mocks for utils in tests

* Fix broken tests

* Update selectWithTera override

* Update path for utils in ab-attr.ts

* Update path for utils in ability-class.ts

* Fix utils import path in healer.test.ts
2025-04-19 11:57:03 +00:00

437 lines
15 KiB
TypeScript

import { globalScene } from "#app/global-scene";
import { TextStyle, addBBCodeTextObject, getTextColor, getTextStyleOptions } from "./text";
import { UiMode } from "#enums/ui-mode";
import UiHandler from "./ui-handler";
import { addWindow } from "./ui-theme";
import { rgbHexToRgba, fixedInt } from "#app/utils/common";
import { argbFromRgba } from "@material/material-color-utilities";
import { Button } from "#enums/buttons";
import BBCodeText from "phaser3-rex-plugins/plugins/gameobjects/tagtext/bbcodetext/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 optionSelectTextContainer: 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 = 0;
protected fullCursor = 0;
protected scale = 0.1666666667;
private cursorObj: Phaser.GameObjects.Image | null;
protected unskippedIndices: number[] = [];
protected defaultTextStyle: TextStyle = TextStyle.WINDOW;
protected textContent: string;
constructor(mode: UiMode | 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 ? UiMode[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.optionSelectTextContainer = globalScene.add.container(0, 0);
this.optionSelectContainer.add(this.optionSelectTextContainer);
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() === UiMode.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.optionSelectTextContainer.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.optionSelectTextContainer.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.textContent = 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");
this.optionSelectText.setText(this.textContent);
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.optionSelectTextContainer.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.optionSelectTextContainer.add(itemOverlayIcon);
itemOverlayIcon.setPositionRelative(this.optionSelectText, 36 * this.scale, 7 + i * (114 * this.scale - 3));
if (option.itemArgs) {
itemIcon.setTint(argbFromRgba(rgbHexToRgba(option.itemArgs[0])));
itemOverlayIcon.setTint(argbFromRgba(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.optionSelectTextContainer.setAlpha(0.5);
this.cursorObj?.setAlpha(0.8);
globalScene.time.delayedCall(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() === UiMode.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.optionSelectTextContainer.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;
}
}