From 0796a9fce8c34ba5a9e788b5a1ab05ea14ff41f5 Mon Sep 17 00:00:00 2001 From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com> Date: Sun, 4 Aug 2024 08:50:13 +0200 Subject: [PATCH] [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 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 Co-authored-by: Yonmaru40 <47717431+40chyan@users.noreply.github.com> Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com> --- src/locales/de/filter-bar.ts | 5 +- src/locales/en/filter-bar.ts | 9 +- src/locales/es/filter-bar.ts | 5 +- src/locales/fr/filter-bar.ts | 11 +- src/locales/it/filter-bar.ts | 9 +- src/locales/ko/filter-bar.ts | 11 +- src/locales/pt_BR/filter-bar.ts | 9 +- src/locales/zh_CN/filter-bar.ts | 9 +- src/locales/zh_TW/filter-bar.ts | 9 +- src/ui/dropdown.ts | 567 ++++++++++++++++++++-------- src/ui/filter-bar.ts | 152 ++++---- src/ui/starter-select-ui-handler.ts | 298 ++++++++++----- 12 files changed, 746 insertions(+), 348 deletions(-) diff --git a/src/locales/de/filter-bar.ts b/src/locales/de/filter-bar.ts index 31c6fee20d4..9c1009171e2 100644 --- a/src/locales/de/filter-bar.ts +++ b/src/locales/de/filter-bar.ts @@ -3,14 +3,17 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "Gen.", "typeFilter": "Typ", + "dexFilter": "Dex.", "unlocksFilter": "Freisch.", - "winFilter": "Abschluss", + "miscFilter": "Sonst.", "sortFilter": "Sort.", "all": "Alle", "normal": "Normal", "uncaught": "Nicht gefangen", + "passive": "Passive", "passiveUnlocked": "Passive freigeschaltet", "passiveLocked": "Passive gesperrt", + "ribbon": "Band", "hasWon": "Hat Klassik-Modus gewonnen", "hasNotWon": "Hat Klassik-Modus nicht gewonnen", "sortByNumber": "Pokédex-Nummer", diff --git a/src/locales/en/filter-bar.ts b/src/locales/en/filter-bar.ts index 60c6ffb1bbc..18b6ba77e21 100644 --- a/src/locales/en/filter-bar.ts +++ b/src/locales/en/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "Gen", "typeFilter": "Type", + "dexFilter": "Dex", "unlocksFilter": "Unlocks", - "winFilter": "Win", + "miscFilter": "Misc", "sortFilter": "Sort", "all": "All", "normal": "Normal", "uncaught": "Uncaught", + "passive": "Passive", "passiveUnlocked": "Passive Unlocked", "passiveLocked": "Passive Locked", - "hasWon": "Yes", - "hasNotWon": "No", + "ribbon": "Ribbon", + "hasWon": "Ribbon - Yes", + "hasNotWon": "Ribbon - No", "sortByNumber": "No.", "sortByCost": "Cost", "sortByCandies": "Candy Count", diff --git a/src/locales/es/filter-bar.ts b/src/locales/es/filter-bar.ts index 50826ba0502..33b60cfa427 100644 --- a/src/locales/es/filter-bar.ts +++ b/src/locales/es/filter-bar.ts @@ -3,14 +3,17 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "Gen.", "typeFilter": "Tipo", + "dexFilter": "Dex", "unlocksFilter": "Otros", - "winFilter": "Vic.", + "miscFilter": "Misc", "sortFilter": "Orden", "all": "Todo", "normal": "Normal", "uncaught": "No Capt.", + "passive": "Passive", "passiveUnlocked": "Pasiva Desbloq.", "passiveLocked": "Pasiva Bloq.", + "ribbon": "Ribbon", "hasWon": "Ya ha ganado", "hasNotWon": "Aún no ha ganado", "sortByNumber": "Núm.", diff --git a/src/locales/fr/filter-bar.ts b/src/locales/fr/filter-bar.ts index de0be450ad6..acbb34e18e8 100644 --- a/src/locales/fr/filter-bar.ts +++ b/src/locales/fr/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "Gen", "typeFilter": "Type", - "unlocksFilter": "Autres", - "winFilter": "Victoires", + "dexFilter": "Dex", + "unlocksFilter": "Débloq.", + "miscFilter": "Divers", "sortFilter": "Tri", "all": "Tous", "normal": "Normal", "uncaught": "Non-capturé", + "passive": "Passif", "passiveUnlocked": "Passif débloqué", "passiveLocked": "Passif verrouillé", - "hasWon": "Oui", - "hasNotWon": "Aucune", + "ribbon": "Médaille", + "hasWon": "Médaille - Oui", + "hasNotWon": "Médaille - Non", "sortByNumber": "Par N°", "sortByCost": "Par cout", "sortByCandies": "Par # bonbons", diff --git a/src/locales/it/filter-bar.ts b/src/locales/it/filter-bar.ts index 979b52f1729..88745d2c4f8 100644 --- a/src/locales/it/filter-bar.ts +++ b/src/locales/it/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "Gen", "typeFilter": "Tipo", + "dexFilter": "Dex", "unlocksFilter": "Altro", - "winFilter": "Vinto", + "miscFilter": "Misc", "sortFilter": "Ordina", "all": "Tutto", "normal": "Normale", "uncaught": "Mancante", + "passive": "Passive", "passiveUnlocked": "Passiva sbloccata", "passiveLocked": "Passiva bloccata", - "hasWon": "Si", - "hasNotWon": "No", + "ribbon": "Ribbon", + "hasWon": "Ribbon - Yes", + "hasNotWon": "Ribbon - No", "sortByNumber": "Num. Dex", "sortByCost": "Costo", "sortByCandies": "Caramelle", diff --git a/src/locales/ko/filter-bar.ts b/src/locales/ko/filter-bar.ts index 7f2dbf89db8..821a80d78ee 100644 --- a/src/locales/ko/filter-bar.ts +++ b/src/locales/ko/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "세대", "typeFilter": "타입", - "unlocksFilter": "등록", - "winFilter": "클리어", + "dexFilter": "도감", + "unlocksFilter": "해금", + "miscFilter": "기타", "sortFilter": "정렬", "all": "전체", "normal": "기본", "uncaught": "미포획", + "passive": "패시브", "passiveUnlocked": "패시브 해금", "passiveLocked": "패시브 잠김", - "hasWon": "완료", - "hasNotWon": "미완료", + "ribbon": "클리어 여부", + "hasWon": "클리어 함", + "hasNotwon": "클리어 안함", "sortByNumber": "도감번호", "sortByCost": "코스트", "sortByCandies": "사탕 수", diff --git a/src/locales/pt_BR/filter-bar.ts b/src/locales/pt_BR/filter-bar.ts index 5e3ab7114da..0c6a8e9ae50 100644 --- a/src/locales/pt_BR/filter-bar.ts +++ b/src/locales/pt_BR/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "Ger.", "typeFilter": "Tipo", + "dexFilter": "Dex", "unlocksFilter": "Outros", - "winFilter": "Vit.", + "miscFilter": "Misc", "sortFilter": "Ordem", "all": "Tudo", "normal": "Normal", "uncaught": "Não Capturado", + "passive": "Passive", "passiveUnlocked": "Passiva Desbloq.", "passiveLocked": "Passiva Bloq.", - "hasWon": "Sim", - "hasNotWon": "Não", + "ribbon": "Ribbon", + "hasWon": "Ribbon - Yes", + "hasNotWon": "Ribbon - No", "sortByNumber": "Núm.", "sortByCost": "Custo", "sortByCandies": "# Doces", diff --git a/src/locales/zh_CN/filter-bar.ts b/src/locales/zh_CN/filter-bar.ts index 581c7bf6b8c..5ccc5b8d9d9 100644 --- a/src/locales/zh_CN/filter-bar.ts +++ b/src/locales/zh_CN/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "世代", "typeFilter": "属性", + "dexFilter": "Dex", "unlocksFilter": "解锁", - "winFilter": "通关", + "miscFilter": "混合", "sortFilter": "排序", "all": "全部", "normal": "无闪光", "uncaught": "未捕获", + "passive": "被动", "passiveUnlocked": "被动解锁", "passiveLocked": "被动未解锁", - "hasWon": "已通关", - "hasNotWon": "未通关", + "ribbon": "缎带", + "hasWon": "有缎带", + "hasNotWon": "无缎带", "sortByNumber": "编号", "sortByCost": "费用", "sortByCandies": "糖果", diff --git a/src/locales/zh_TW/filter-bar.ts b/src/locales/zh_TW/filter-bar.ts index 1f562ffb7ba..0290bda62de 100644 --- a/src/locales/zh_TW/filter-bar.ts +++ b/src/locales/zh_TW/filter-bar.ts @@ -3,16 +3,19 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; export const filterBar: SimpleTranslationEntries = { "genFilter": "世代", "typeFilter": "屬性", + "dexFilter": "Dex", "unlocksFilter": "解鎖", - "winFilter": "通關", + "miscFilter": "混合", "sortFilter": "排序", "all": "全部", "normal": "通常", "uncaught": "未捕獲", + "passive": "被動", "passiveUnlocked": "被動解鎖", "passiveLocked": "被動未解鎖", - "hasWon": "已通關", - "hasNotWon": "未通關", + "ribbon": "緞帶", + "hasWon": "有緞帶", + "hasNotWon": "無緞帶", "sortByNumber": "編號", "sortByCost": "花費", "sortByCandies": "糖果", diff --git a/src/ui/dropdown.ts b/src/ui/dropdown.ts index 4f330de0588..4338e11e0c6 100644 --- a/src/ui/dropdown.ts +++ b/src/ui/dropdown.ts @@ -7,14 +7,14 @@ import i18next from "i18next"; export enum DropDownState { ON = 0, OFF = 1, - INCLUDE = 2, - EXCLUDE = 3, + EXCLUDE = 2 } export enum DropDownType { - MULTI = 0, - SINGLE = 1, - TRI = 2 + SINGLE = 0, + MULTI = 1, + HYBRID = 2, + RADIAL = 3 } export enum SortDirection { @@ -22,130 +22,252 @@ export enum SortDirection { 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 sprite?: Phaser.GameObjects.Sprite; public val: any; public dir: SortDirection = SortDirection.ASC; - public offStateLabel: string; // label for OFF state in TRI dropdown - public includeStateLabel: string; // label for INCLUDE state in TRI dropdown - public excludeStateLabel: string; // label for EXCLUDE state in TRI dropdown - private onColor = 0x55ff55; + private currentLabelIndex: number; + private labels: DropDownLabel[]; + private onColor = 0x33bbff; private offColor = 0x272727; - private includeColor = 0x55ff55; private excludeColor = 0xff5555; - - constructor(scene: SceneBase, val: any, text: string | string[], sprite?: Phaser.GameObjects.Sprite, state: DropDownState = DropDownState.ON) { + constructor(scene: SceneBase, val: any, labels: DropDownLabel | DropDownLabel[]) { super(scene); this.val = val; - this.state = state; - if (text) { - if (Array.isArray(text)) { - this.offStateLabel = text[0]; - this.includeStateLabel = text[1]; - this.excludeStateLabel = text[2]; - text = text[0]; - } else { - this.offStateLabel = undefined; - this.includeStateLabel = undefined; - this.excludeStateLabel = undefined; - } - this.text = addTextObject(scene, 0, 0, text, TextStyle.TOOLTIP_CONTENT); - this.text.setOrigin(0, 0.5); - this.add(this.text); - } - if (sprite) { - this.sprite = sprite.setOrigin(0, 0.5); - this.add(this.sprite); + 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); + } + } } } - public setupToggle(type: DropDownType): void { - if (type === DropDownType.MULTI || type === DropDownType.TRI) { - this.toggle = this.scene.add.sprite(0, 0, "candy"); - this.toggle.setScale(0.3); - this.toggle.setOrigin(0, 0.5); - } else { + /** + * 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(); } - public setOptionState(type: DropDownType, state: DropDownState): DropDownState { - this.state = state; - // if type is MULTI or SINGLE, set the color of the toggle based on the state - if (type === DropDownType.MULTI || type === DropDownType.SINGLE) { - if (this.state === DropDownState.OFF) { - this.toggle.setTint(this.offColor); - } else if (this.state === DropDownState.ON) { - this.toggle.setTint(this.onColor); - } - } else if (type === DropDownType.TRI) { - if (this.state === DropDownState.OFF) { - this.text.setText(this.offStateLabel); - this.toggle.setTint(this.offColor); - } else if (this.state === DropDownState.INCLUDE) { - this.text.setText(this.includeStateLabel); - this.toggle.setTint(this.includeColor); - } else if (this.state === DropDownState.EXCLUDE) { - this.text.setText(this.excludeStateLabel); - this.toggle.setTint(this.excludeColor); - } + /** + * 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; } - public toggleOptionState(type: DropDownType): DropDownState { - if (type === DropDownType.TRI) { - switch (this.state) { - case DropDownState.OFF: - this.state = DropDownState.INCLUDE; - break; - case DropDownState.INCLUDE: - this.state = DropDownState.EXCLUDE; - break; - case DropDownState.EXCLUDE: - this.state = DropDownState.OFF; - break; - } - } else { - switch (this.state) { - case DropDownState.ON: - this.state = DropDownState.OFF; - break; - case DropDownState.OFF: - this.state = DropDownState.ON; - break; - } - } - return this.setOptionState(type, 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: integer = 0; + 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; @@ -165,36 +287,31 @@ export class DropDown extends Phaser.GameObjects.Container { this.cursorObj.setOrigin(0, 0.5); this.cursorObj.setVisible(false); - if (this.dropDownType === DropDownType.MULTI) { - this.options.unshift(new DropDownOption(scene, "ALL", i18next.t("filterBar:all"), null, this.checkForAllOn() ? DropDownState.ON : DropDownState.OFF)); + // 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) => { - option.setupToggle(type); - if (type === DropDownType.SINGLE && option.state === DropDownState.OFF) { - option.toggle.setVisible(false); - } - option.setOptionState(type, option.state); + const toggleVisibility = type !== DropDownType.SINGLE || option.state === DropDownState.ON; + option.setupToggleIcon(type, toggleVisibility); option.width = optionWidth; option.y = index * optionHeight + index * optionSpacing + optionPaddingY; - if (option.text) { - option.text.x = cursorOffset + optionPaddingX + 3 + 8; - option.text.y = optionHeight / 2; - } - if (option.sprite) { - option.sprite.x = cursorOffset + optionPaddingX + 3 + 8; - option.sprite.y = optionHeight / 2; - } + const baseX = cursorOffset + optionPaddingX + 3; + const baseY = optionHeight / 2; + option.setLabelPosition(baseX + 8, baseY); if (type === DropDownType.SINGLE) { - option.toggle.x = cursorOffset + optionPaddingX + 3 + 3; - option.toggle.y = optionHeight / 2 + 1; + option.setTogglePosition(baseX + 3, baseY + 1); } else { - option.toggle.x = cursorOffset + optionPaddingX + 3; - option.toggle.y = optionHeight / 2; + 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); @@ -202,10 +319,32 @@ export class DropDown extends Phaser.GameObjects.Container { this.setVisible(false); } - toggle(): void { + 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) { @@ -220,96 +359,213 @@ export class DropDown extends Phaser.GameObjects.Container { } 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; } - toggleOptionState(): void { - if (this.dropDownType === DropDownType.MULTI) { - const newState = this.options[this.cursor].toggleOptionState(this.dropDownType); - if (this.cursor === 0) { - this.options.forEach((option, index) => { - if (index !== this.cursor) { - option.setOptionState(this.dropDownType, newState); - } - }); + /** + * 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 { - if (this.checkForAllOff()) { - this.options[0].setOptionState(this.dropDownType, DropDownState.OFF); - } else if (this.checkForAllOn()) { - this.options[0].setOptionState(this.dropDownType, DropDownState.ON); + // 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(this.dropDownType, DropDownState.OFF); + this.options[0].setOptionState(DropDownState.OFF); } } } else if (this.dropDownType === DropDownType.SINGLE) { - if (this.options[this.cursor].state === DropDownState.OFF) { + if (option.state === DropDownState.OFF) { this.options.forEach((option) => { - option.setOptionState(this.dropDownType, DropDownState.OFF); + option.setOptionState(DropDownState.OFF); option.setDirection(SortDirection.ASC); option.toggle.setVisible(false); }); - this.options[this.cursor].setOptionState(this.dropDownType, DropDownState.ON); - this.options[this.cursor].setDirection(this.lastDir); - this.options[this.cursor].toggle.setVisible(true); + option.setOptionState(DropDownState.ON); + option.setDirection(this.lastDir); + option.toggle.setVisible(true); } else { - this.options[this.cursor].toggleDirection(); + option.toggleDirection(); this.lastDir = this.options[this.cursor].dir; } - } else if (this.dropDownType === DropDownType.TRI) { - this.options[this.cursor].toggleOptionState(this.dropDownType); - this.autoSize(); + } else if (this.dropDownType === DropDownType.RADIAL) { + option.toggleOptionState(); } this.onChange(); } - setVisible(value: boolean): this { - super.setVisible(value); - - if (value) { - this.autoSize(); - } - - return this; - } - + /** + * 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); - // in TRI dropdown, if state is ON, return the "ON" with the value, if state is OFF, return the "OFF" with the value, if state is TRI, return the "TRI" with the value - } else if (this.dropDownType === DropDownType.TRI) { - return this.options.filter((option, i) => option.state === DropDownState.OFF || option.state === DropDownState.INCLUDE || option.state === DropDownState.EXCLUDE).map((option) => { - return {val: option.val, state: option.state}; + } 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, i) => option.state === DropDownState.ON).map((option) => { - return {val: option.val, dir: option.dir}; + 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++) { - if (this.options[i].sprite) { - if (this.options[i].sprite.displayWidth > maxWidth) { - maxWidth = this.options[i].sprite.displayWidth; - x = this.options[i].sprite.x; - } - } else { - if (this.options[i].text.displayWidth > maxWidth) { - maxWidth = this.options[i].text.displayWidth; - x = this.options[i].text.x; - } + 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; @@ -319,7 +575,4 @@ export class DropDown extends Phaser.GameObjects.Container { } } - isActive(): boolean { - return this.options.some((option) => option.state === DropDownState.ON); - } } diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index fd661901c78..e163284bad3 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -1,13 +1,14 @@ import BattleScene from "#app/battle-scene.js"; import { DropDown } from "./dropdown"; import { StarterContainer } from "./starter-container"; -import { addTextObject, TextStyle } from "./text"; +import { addTextObject, getTextColor, TextStyle } from "./text"; +import { UiTheme } from "#enums/ui-theme"; import { addWindow, WindowVariant } from "./ui-theme"; export enum DropDownColumn { GEN, TYPES, - SHINY, + DEX, UNLOCKS, MISC, SORT @@ -15,18 +16,14 @@ export enum DropDownColumn { export class FilterBar extends Phaser.GameObjects.Container { private window: Phaser.GameObjects.NineSlice; - public labels: Phaser.GameObjects.Text[] = []; - public dropDowns: DropDown[] = []; + private labels: Phaser.GameObjects.Text[] = []; + private dropDowns: DropDown[] = []; + private columns: DropDownColumn[] = []; public cursorObj: Phaser.GameObjects.Image; public numFilters: number = 0; public openDropDown: boolean = false; private lastCursor: number = -1; - public defaultGenVals: any[] = []; - public defaultTypeVals: any[] = []; - public defaultShinyVals: any[] = []; - public defaultUnlocksVals: any[] = []; - public defaultMiscVals: any[] = []; - public defaultSortVals: any[] = []; + private uiTheme: UiTheme; constructor(scene: BattleScene, x: number, y: number, width: number, height: number) { super(scene, x, y); @@ -42,10 +39,26 @@ export class FilterBar extends Phaser.GameObjects.Container { this.cursorObj.setVisible(false); this.cursorObj.setOrigin(0, 0); this.add(this.cursorObj); + + this.uiTheme = scene.uiTheme; } - addFilter(text: string, dropDown: DropDown): void { - const filterTypesLabel = addTextObject(this.scene, 0, 3, text, TextStyle.TOOLTIP_CONTENT); + /** + * Add a new filter to the FilterBar, as long that a unique DropDownColumn is provided + * @param column the DropDownColumn that will be used to access the filter values + * @param title the string that will get displayed in the filter bar + * @param dropDown the DropDown with all options for this filter + * @returns true if successful, false if the provided column was already in use for another filter + */ + addFilter(column: DropDownColumn, title: string, dropDown: DropDown): boolean { + // The column should be unique to each filter, + if (this.columns.includes(column)) { + return false; + } + + this.columns.push(column); + + const filterTypesLabel = addTextObject(this.scene, 0, 3, title, TextStyle.TOOLTIP_CONTENT); this.labels.push(filterTypesLabel); this.add(filterTypesLabel); this.dropDowns.push(dropDown); @@ -53,69 +66,39 @@ export class FilterBar extends Phaser.GameObjects.Container { this.calcFilterPositions(); this.numFilters++; + + return true; } + /** + * Get the DropDown associated to a given filter + * @param col the DropDownColumn used to register the filter to retrieve + * @returns the associated DropDown if it exists, undefined otherwise + */ + getFilter(col: DropDownColumn) : DropDown { + return this.dropDowns[this.columns.indexOf(col)]; + } + /** + * Highlight the labels of the FilterBar if the filters are different from their default values + */ updateFilterLabels(): void { - const genVals = this.getVals(DropDownColumn.GEN); - const typeVals = this.getVals(DropDownColumn.TYPES); - const shinyVals = this.getVals(DropDownColumn.SHINY); - const unlocksVals = this.getVals(DropDownColumn.UNLOCKS); - const miscVals = this.getVals(DropDownColumn.MISC); - const sortVals = this.getVals(DropDownColumn.SORT); - - // onColor is Yellow, offColor is White - const onColor = 0xffef5c; - const offColor = 0xffffff; - - // if genVals and defaultGenVals has same elements, set the label to offColor else set it to onColor - if (genVals.length === this.defaultGenVals.length && genVals.every((value, index) => value === this.defaultGenVals[index])) { - this.labels[DropDownColumn.GEN].setTint(offColor); - } else { - this.labels[DropDownColumn.GEN].setTint(onColor); - } - - // if typeVals and defaultTypeVals has same elements, set the label to offColor else set it to onColor - if (typeVals.length === this.defaultTypeVals.length && typeVals.every((value, index) => value === this.defaultTypeVals[index])) { - this.labels[DropDownColumn.TYPES].setTint(offColor); - } else { - this.labels[DropDownColumn.TYPES].setTint(onColor); - } - - // if shinyVals and defaultShinyVals has same elements, set the label to offColor else set it to onColor - if (shinyVals.length === this.defaultShinyVals.length && shinyVals.every((value, index) => value === this.defaultShinyVals[index])) { - this.labels[DropDownColumn.SHINY].setTint(offColor); - } else { - this.labels[DropDownColumn.SHINY].setTint(onColor); - } - - // if unlocksVals and defaultUnlocksVals has same elements, set the label to offColor else set it to onColor - if (unlocksVals.every((value, index) => value["val"] === this.defaultUnlocksVals[index]["val"] && value["state"] === this.defaultUnlocksVals[index]["state"])) { - this.labels[DropDownColumn.UNLOCKS].setTint(offColor); - } else { - this.labels[DropDownColumn.UNLOCKS].setTint(onColor); - } - - // if miscVals and defaultMiscVals has same elements, set the label to offColor else set it to onColor - if (miscVals.every((value, index) => value["val"] === this.defaultMiscVals[index]["val"] && value["state"] === this.defaultMiscVals[index]["state"])) { - this.labels[DropDownColumn.MISC].setTint(offColor); - } else { - this.labels[DropDownColumn.MISC].setTint(onColor); - } - - // if sortVals and defaultSortVals has same value and dir, set the label to offColor else set it to onColor - if (sortVals[0]["dir"] === this.defaultSortVals[0]["dir"] && sortVals[0]["val"] === this.defaultSortVals[0]["val"]) { - this.labels[DropDownColumn.SORT].setTint(offColor); - } else { - this.labels[DropDownColumn.SORT].setTint(onColor); + for (let i = 0; i < this.numFilters; i++) { + if (this.dropDowns[i].hasDefaultValues()) { + this.labels[i].setColor(getTextColor(TextStyle.TOOLTIP_CONTENT, false, this.uiTheme)); + } else { + this.labels[i].setColor(getTextColor(TextStyle.STATS_LABEL, false, this.uiTheme)); + } } } - calcFilterPositions(): void { + /** + * Position the filter dropdowns evenly across the width of the container + */ + private calcFilterPositions(): void { const paddingX = 6; const cursorOffset = 8; - // position labels with even space across the width of the container let totalWidth = paddingX * 2 + cursorOffset; this.labels.forEach(label => { totalWidth += label.displayWidth + cursorOffset; @@ -134,12 +117,23 @@ export class FilterBar extends Phaser.GameObjects.Container { } } + /** + * Move the leftmost dropdown to the left of the FilterBar instead of below it + */ + offsetFirstFilter(): void { + if (this.dropDowns[0]) { + this.dropDowns[0].autoSize(); + this.dropDowns[0].x -= this.dropDowns[0].getWidth(); + this.dropDowns[0].y = 0; + } + } + setCursor(cursor: number): void { if (this.lastCursor > -1) { if (this.dropDowns[this.lastCursor].visible) { this.dropDowns[this.lastCursor].setVisible(false); this.dropDowns[cursor].setVisible(true); - this.dropDowns[cursor].setCursor(0); + this.dropDowns[cursor].resetCursor(); } } @@ -149,9 +143,9 @@ export class FilterBar extends Phaser.GameObjects.Container { } toggleDropDown(index: number): void { - this.dropDowns[index].toggle(); + this.dropDowns[index].toggleVisibility(); this.openDropDown = this.dropDowns[index].visible; - this.dropDowns[index].setCursor(0); + this.dropDowns[index].resetCursor(); } hideDropDowns(): void { @@ -182,11 +176,22 @@ export class FilterBar extends Phaser.GameObjects.Container { } getVals(col: DropDownColumn): any[] { - return this.dropDowns[col].getVals(); + return this.getFilter(col).getVals(); } + setValsToDefault(): void { + for (const dropDown of this.dropDowns) { + dropDown.resetToDefault(); + } + } + + /** + * Find the nearest filter to the provided container + * @param container the StarterContainer to compare position against + * @returns the index of the closest filter + */ getNearestFilter(container: StarterContainer): number { - // find the nearest filter to the x position + const midx = container.x + container.icon.displayWidth / 2; let nearest = 0; let nearestDist = 1000; @@ -201,11 +206,4 @@ export class FilterBar extends Phaser.GameObjects.Container { return nearest; } - getLastFilterX(): number { - return this.labels[this.lastCursor].x + this.labels[this.lastCursor].displayWidth / 2; - } - - isFilterActive(index: number) { - return this.dropDowns[index].isActive(); - } } diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index ff7b7c19e47..3249ae668a1 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -40,7 +40,7 @@ import { Species } from "#enums/species"; import {Button} from "#enums/buttons"; import { EggSourceType } from "#app/enums/egg-source-types.js"; import AwaitableUiHandler from "./awaitable-ui-handler"; -import { DropDown, DropDownOption, DropDownState, DropDownType } from "./dropdown"; +import { DropDown, DropDownLabel, DropDownOption, DropDownState, DropDownType } from "./dropdown"; import { StarterContainer } from "./starter-container"; import { DropDownColumn, FilterBar } from "./filter-bar"; import { ScrollBar } from "./scroll-bar"; @@ -119,6 +119,14 @@ const starterCandyCosts: { passive: integer, costReduction: [integer, integer], { passive: 10, costReduction: [3, 10], egg: 10 }, // 10 ]; +// Position of UI elements +const filterBarHeight = 17; +const speciesContainerX = 109; // if team on the RIGHT: 109 / if on the LEFT: 143 +const teamWindowX = 285; // if team on the RIGHT: 285 / if on the LEFT: 109 +const teamWindowY = 18; +const teamWindowWidth = 34; +const teamWindowHeight = 132; + function getPassiveCandyCount(baseValue: integer): integer { return starterCandyCosts[baseValue - 1].passive; } @@ -145,6 +153,57 @@ function calcStarterPosition(index: number, scrollCursor:number = 0): {x: number return {x: x, y: y}; } +/** + * Calculates the y position for the icon of stater pokemon selected for the team + * @param index index of the Pokemon in the team (0-5) + * @returns the y position to use for the icon + */ +function calcStarterIconY(index: number) { + const starterSpacing = teamWindowHeight / 7; + const firstStarterY = teamWindowY + starterSpacing / 2; + return Math.round(firstStarterY + starterSpacing * index); +} + +/** + * Finds the index of the team Pokemon closest vertically to the given y position + * @param y the y position to find closest starter Pokemon + * @param teamSize how many Pokemon are in the team (0-6) + * @returns index of the closest Pokemon in the team container + */ +function findClosestStarterIndex(y: number, teamSize: number = 6): number { + let smallestDistance = teamWindowHeight; + let closestStarterIndex = 0; + for (let i = 0; i < teamSize; i++) { + const distance = Math.abs(y - (calcStarterIconY(i) - 13)); + if (distance < smallestDistance) { + closestStarterIndex = i; + smallestDistance = distance; + } + } + return closestStarterIndex; +} + +/** + * Finds the row of the filtered Pokemon closest vertically to the given Pokemon in the team + * @param index index of the Pokemon in the team (0-5) + * @param numberOfRows the number of rows to check against + * @returns index of the row closest vertically to the given Pokemon + */ +function findClosestStarterRow(index: number, numberOfRows: number) { + const currentY = calcStarterIconY(index) - 13; + let smallestDistance = teamWindowHeight; + let closestRowIndex = 0; + for (let i=0; i < numberOfRows; i++) { + const distance = Math.abs(currentY - calcStarterPosition(i * 9).y); + if (distance < smallestDistance) { + closestRowIndex = i; + smallestDistance = distance; + } + } + return closestRowIndex; +} + + export default class StarterSelectUiHandler extends MessageUiHandler { private starterSelectContainer: Phaser.GameObjects.Container; private starterSelectScrollBar: ScrollBar; @@ -297,40 +356,33 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.shinyOverlay.setVisible(false); this.starterSelectContainer.add(this.shinyOverlay); - const starterContainerWindow = addWindow(this.scene, 109, 18, 175, 161); - const starterContainerBg = this.scene.add.image(110, 19, "starter_container_bg"); + const starterContainerWindow = addWindow(this.scene, speciesContainerX, filterBarHeight + 1, 175, 161); + const starterContainerBg = this.scene.add.image(speciesContainerX+1, filterBarHeight + 2, "starter_container_bg"); starterContainerBg.setOrigin(0, 0); this.starterSelectContainer.add(starterContainerBg); - this.starterSelectContainer.add(addWindow(this.scene, 285, 59, 34, 91)); - this.starterSelectContainer.add(addWindow(this.scene, 285, 145, 34, 34, true)); + this.starterSelectContainer.add(addWindow(this.scene, teamWindowX, teamWindowY, teamWindowWidth, teamWindowHeight)); + this.starterSelectContainer.add(addWindow(this.scene, teamWindowX, teamWindowY + teamWindowHeight - 5, teamWindowWidth, teamWindowWidth, true)); this.starterSelectContainer.add(starterContainerWindow); + // Create and initialise filter bar this.filterBarContainer = this.scene.add.container(0, 0); - - // this.filterBar = new FilterBar(this.scene, 143, 1, 175, 17); - this.filterBar = new FilterBar(this.scene, 109, 1, 175, 17); + this.filterBar = new FilterBar(this.scene, Math.min(speciesContainerX, teamWindowX), 1, 210, filterBarHeight); // gen filter const genOptions: DropDownOption[] = [ - new DropDownOption(this.scene, 1, i18next.t("starterSelectUiHandler:gen1"), null, DropDownState.ON), - new DropDownOption(this.scene, 2, i18next.t("starterSelectUiHandler:gen2"), null, DropDownState.ON), - new DropDownOption(this.scene, 3, i18next.t("starterSelectUiHandler:gen3"), null, DropDownState.ON), - new DropDownOption(this.scene, 4, i18next.t("starterSelectUiHandler:gen4"), null, DropDownState.ON), - new DropDownOption(this.scene, 5, i18next.t("starterSelectUiHandler:gen5"), null, DropDownState.ON), - new DropDownOption(this.scene, 6, i18next.t("starterSelectUiHandler:gen6"), null, DropDownState.ON), - new DropDownOption(this.scene, 7, i18next.t("starterSelectUiHandler:gen7"), null, DropDownState.ON), - new DropDownOption(this.scene, 8, i18next.t("starterSelectUiHandler:gen8"), null, DropDownState.ON), - new DropDownOption(this.scene, 9, i18next.t("starterSelectUiHandler:gen9"), null, DropDownState.ON), + new DropDownOption(this.scene, 1, new DropDownLabel(i18next.t("starterSelectUiHandler:gen1"))), + new DropDownOption(this.scene, 2, new DropDownLabel(i18next.t("starterSelectUiHandler:gen2"))), + new DropDownOption(this.scene, 3, new DropDownLabel(i18next.t("starterSelectUiHandler:gen3"))), + new DropDownOption(this.scene, 4, new DropDownLabel(i18next.t("starterSelectUiHandler:gen4"))), + new DropDownOption(this.scene, 5, new DropDownLabel(i18next.t("starterSelectUiHandler:gen5"))), + new DropDownOption(this.scene, 6, new DropDownLabel(i18next.t("starterSelectUiHandler:gen6"))), + new DropDownOption(this.scene, 7, new DropDownLabel(i18next.t("starterSelectUiHandler:gen7"))), + new DropDownOption(this.scene, 8, new DropDownLabel(i18next.t("starterSelectUiHandler:gen8"))), + new DropDownOption(this.scene, 9, new DropDownLabel(i18next.t("starterSelectUiHandler:gen9"))), ]; - this.filterBar.addFilter(i18next.t("filterBar:genFilter"), new DropDown(this.scene, 0, 0, genOptions, this.updateStarters, DropDownType.MULTI)); - this.filterBar.defaultGenVals = this.filterBar.getVals(DropDownColumn.GEN); - // set gen filter to all off except for the I GEN - for (const option of genOptions) { - if (option.val !== 1) { - option.setOptionState(DropDownType.MULTI ,DropDownState.OFF); - } - } + const genDropDown: DropDown = new DropDown(this.scene, 0, 0, genOptions, this.updateStarters, DropDownType.HYBRID); + this.filterBar.addFilter(DropDownColumn.GEN, i18next.t("filterBar:genFilter"), genDropDown); // type filter const typeKeys = Object.keys(Type).filter(v => isNaN(Number(v))); @@ -342,10 +394,9 @@ export default class StarterSelectUiHandler extends MessageUiHandler { const typeSprite = this.scene.add.sprite(0, 0, `types${Utils.verifyLang(i18next.resolvedLanguage) ? `_${i18next.resolvedLanguage}` : ""}`); typeSprite.setScale(0.5); typeSprite.setFrame(type.toLowerCase()); - typeOptions.push(new DropDownOption(this.scene, index, null, typeSprite)); + typeOptions.push(new DropDownOption(this.scene, index, new DropDownLabel("", typeSprite))); }); - this.filterBar.addFilter(i18next.t("filterBar:typeFilter"), new DropDown(this.scene, 0, 0, typeOptions, this.updateStarters, DropDownType.MULTI, 0.5)); - this.filterBar.defaultTypeVals = this.filterBar.getVals(DropDownColumn.TYPES); + this.filterBar.addFilter(DropDownColumn.TYPES, i18next.t("filterBar:typeFilter"), new DropDown(this.scene, 0, 0, typeOptions, this.updateStarters, DropDownType.HYBRID, 0.5)); // shiny filter const shiny1Sprite = this.scene.add.sprite(0, 0, "shiny_icons"); @@ -365,45 +416,54 @@ export default class StarterSelectUiHandler extends MessageUiHandler { shiny3Sprite.setTint(getVariantTint(2)); const shinyOptions = [ - new DropDownOption(this.scene, "SHINY3", null, shiny3Sprite), - new DropDownOption(this.scene, "SHINY2", null, shiny2Sprite), - new DropDownOption(this.scene, "SHINY", null, shiny1Sprite), - new DropDownOption(this.scene, "NORMAL", i18next.t("filterBar:normal")), - new DropDownOption(this.scene, "UNCAUGHT", i18next.t("filterBar:uncaught")), + new DropDownOption(this.scene, "SHINY3", new DropDownLabel("", shiny3Sprite)), + new DropDownOption(this.scene, "SHINY2", new DropDownLabel("", shiny2Sprite)), + new DropDownOption(this.scene, "SHINY", new DropDownLabel("", shiny1Sprite)), + new DropDownOption(this.scene, "NORMAL", new DropDownLabel(i18next.t("filterBar:normal"))), + new DropDownOption(this.scene, "UNCAUGHT", new DropDownLabel(i18next.t("filterBar:uncaught"))) ]; - this.filterBar.addFilter("Owned", new DropDown(this.scene, 0, 0, shinyOptions, this.updateStarters, DropDownType.MULTI)); - this.filterBar.defaultShinyVals = this.filterBar.getVals(DropDownColumn.SHINY); - + this.filterBar.addFilter(DropDownColumn.DEX, i18next.t("filterBar:dexFilter"), new DropDown(this.scene, 0, 0, shinyOptions, this.updateStarters, DropDownType.HYBRID)); // unlocks filter + const passiveLabels = [ + new DropDownLabel(i18next.t("filterBar:passive"), undefined, DropDownState.OFF), + new DropDownLabel(i18next.t("filterBar:passiveUnlocked"), undefined, DropDownState.ON), + new DropDownLabel(i18next.t("filterBar:passiveLocked"), undefined, DropDownState.EXCLUDE), + ]; const unlocksOptions = [ - new DropDownOption(this.scene, "PASSIVE", ["Passive", i18next.t("filterBar:passiveUnlocked"), i18next.t("filterBar:passiveLocked")], null, DropDownState.OFF), + new DropDownOption(this.scene, "PASSIVE", passiveLabels), ]; - this.filterBar.addFilter(i18next.t("filterBar:unlocksFilter"), new DropDown(this.scene, 0, 0, unlocksOptions, this.updateStarters, DropDownType.TRI)); - this.filterBar.defaultUnlocksVals = this.filterBar.getVals(DropDownColumn.UNLOCKS); + this.filterBar.addFilter(DropDownColumn.UNLOCKS, i18next.t("filterBar:unlocksFilter"), new DropDown(this.scene, 0, 0, unlocksOptions, this.updateStarters, DropDownType.RADIAL)); // misc filter - const miscOptions = [ - new DropDownOption(this.scene, "WIN", ["Win", "Win - Yes", "Win - No"], null, DropDownState.OFF), + const winLabels = [ + new DropDownLabel(i18next.t("filterBar:ribbon"), undefined, DropDownState.OFF), + new DropDownLabel(i18next.t("filterBar:hasWon"), undefined, DropDownState.ON), + new DropDownLabel(i18next.t("filterBar:hasNotWon"), undefined, DropDownState.EXCLUDE), ]; - this.filterBar.addFilter("Misc", new DropDown(this.scene, 0, 0, miscOptions, this.updateStarters, DropDownType.TRI)); - this.filterBar.defaultMiscVals = this.filterBar.getVals(DropDownColumn.MISC); + const miscOptions = [ + new DropDownOption(this.scene, "WIN", winLabels), + ]; + this.filterBar.addFilter(DropDownColumn.MISC, i18next.t("filterBar:miscFilter"), new DropDown(this.scene, 0, 0, miscOptions, this.updateStarters, DropDownType.RADIAL)); // sort filter const sortOptions = [ - new DropDownOption(this.scene, 0, i18next.t("filterBar:sortByNumber")), - new DropDownOption(this.scene, 1, i18next.t("filterBar:sortByCost"), null, DropDownState.OFF), - new DropDownOption(this.scene, 2, i18next.t("filterBar:sortByCandies"), null, DropDownState.OFF), - new DropDownOption(this.scene, 3, i18next.t("filterBar:sortByIVs"), null, DropDownState.OFF), - new DropDownOption(this.scene, 4, i18next.t("filterBar:sortByName"), null, DropDownState.OFF)]; - this.filterBar.addFilter(i18next.t("filterBar:sortFilter"), new DropDown(this.scene, 0, 0, sortOptions, this.updateStarters, DropDownType.SINGLE)); + new DropDownOption(this.scene, 0, new DropDownLabel(i18next.t("filterBar:sortByNumber"))), + new DropDownOption(this.scene, 1, new DropDownLabel(i18next.t("filterBar:sortByCost"), undefined, DropDownState.OFF)), + new DropDownOption(this.scene, 2, new DropDownLabel(i18next.t("filterBar:sortByCandies"), undefined, DropDownState.OFF)), + new DropDownOption(this.scene, 3, new DropDownLabel(i18next.t("filterBar:sortByIVs"), undefined, DropDownState.OFF)), + new DropDownOption(this.scene, 4, new DropDownLabel(i18next.t("filterBar:sortByName"), undefined, DropDownState.OFF)) + ]; + this.filterBar.addFilter(DropDownColumn.SORT, i18next.t("filterBar:sortFilter"), new DropDown(this.scene, 0, 0, sortOptions, this.updateStarters, DropDownType.SINGLE)); this.filterBarContainer.add(this.filterBar); - this.filterBar.defaultSortVals = this.filterBar.getVals(DropDownColumn.SORT); this.starterSelectContainer.add(this.filterBarContainer); + // Offset the generation filter dropdown to avoid covering the filtered pokemon + this.filterBar.offsetFirstFilter(); + if (!this.scene.uiTheme) { starterContainerWindow.setVisible(false); } @@ -479,22 +539,22 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.pokemonEggMoveBgs = []; this.pokemonEggMoveLabels = []; - this.valueLimitLabel = addTextObject(this.scene, 302, 150, "0/10", TextStyle.TOOLTIP_CONTENT); + this.valueLimitLabel = addTextObject(this.scene, teamWindowX+17, 150, "0/10", TextStyle.TOOLTIP_CONTENT); this.valueLimitLabel.setOrigin(0.5, 0); this.starterSelectContainer.add(this.valueLimitLabel); - const startLabel = addTextObject(this.scene, 302, 162, i18next.t("common:start"), TextStyle.TOOLTIP_CONTENT); + const startLabel = addTextObject(this.scene, teamWindowX+17, 162, i18next.t("common:start"), TextStyle.TOOLTIP_CONTENT); startLabel.setOrigin(0.5, 0); this.starterSelectContainer.add(startLabel); - this.startCursorObj = this.scene.add.nineslice(289, 160, "select_cursor", null, 26, 15, 6, 6, 6, 6); + this.startCursorObj = this.scene.add.nineslice(teamWindowX+4, 160, "select_cursor", null, 26, 15, 6, 6, 6, 6); this.startCursorObj.setVisible(false); this.startCursorObj.setOrigin(0, 0); this.starterSelectContainer.add(this.startCursorObj); const starterSpecies: Species[] = []; - const starterBoxContainer = this.scene.add.container(115, 9); + const starterBoxContainer = this.scene.add.container(speciesContainerX + 6, 9); //115 this.starterSelectScrollBar = new ScrollBar(this.scene, 161, 12, 0); @@ -544,7 +604,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.starterSelectContainer.add(starterBoxContainer); this.starterIcons = new Array(6).fill(null).map((_, i) => { - const icon = this.scene.add.sprite(292, 63 + 13 * i, "pokemon_icons_0"); + const icon = this.scene.add.sprite(teamWindowX + 7, calcStarterIconY(i), "pokemon_icons_0"); icon.setScale(0.5); icon.setOrigin(0, 0); icon.setFrame("unknown"); @@ -833,6 +893,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.setUpgradeAnimation(icon, species); }); + this.resetFilters(); this.updateStarters(); this.setFilterMode(false); @@ -848,6 +909,27 @@ export default class StarterSelectUiHandler extends MessageUiHandler { return false; } + /** + * Set the selections for all filters to their default starting value + */ + resetFilters() : void { + const genDropDown: DropDown = this.filterBar.getFilter(DropDownColumn.GEN); + if (this.scene.gameMode.isChallenge) { + // In challenge mode all gens are selected by default + genDropDown.defaultCursor = 0; + } else { + // in other modes, gen 1 is selected by default, and all options disabled + genDropDown.defaultCursor = 1; + } + + this.filterBar.setValsToDefault(); + + // for all modes except challenge, disable all gen options to enable hovering behavior + if (!this.scene.gameMode.isChallenge) { + genDropDown.unselectAllOptions(); + } + } + showText(text: string, delay?: integer, callback?: Function, callbackDelay?: integer, prompt?: boolean, promptDelay?: integer) { super.showText(text, delay, callback, callbackDelay, prompt, promptDelay); @@ -1035,11 +1117,8 @@ export default class StarterSelectUiHandler extends MessageUiHandler { const onScreenLastIndex = Math.min(this.filteredStarterContainers.length - 1, onScreenFirstIndex + maxRows * maxColumns - 1); // this is the last starter index on the screen const onScreenNumberOfStarters = onScreenLastIndex - onScreenFirstIndex + 1; const onScreenNumberOfRows = Math.ceil(onScreenNumberOfStarters / maxColumns); - // const onScreenFirstRow = Math.floor(onScreenFirstIndex / maxColumns); const onScreenCurrentRow = Math.floor((this.cursor - onScreenFirstIndex) / maxColumns); - // console.log("this.cursor: ", this.cursor, "this.scrollCursor" , this.scrollCursor, "numberOfStarters: ", numberOfStarters, "numOfRows: ", numOfRows, "currentRow: ", currentRow, "onScreenFirstIndex: ", onScreenFirstIndex, "onScreenLastIndex: ", onScreenLastIndex, "onScreenNumberOfStarters: ", onScreenNumberOfStarters, "onScreenNumberOfRow: ", onScreenNumberOfRows, "onScreenCurrentRow: ", onScreenCurrentRow); - const ui = this.getUi(); let success = false; @@ -1053,8 +1132,18 @@ export default class StarterSelectUiHandler extends MessageUiHandler { } } else if (button === Button.CANCEL) { if (this.filterMode && this.filterBar.openDropDown) { + // CANCEL with a filter menu open > close it this.filterBar.toggleDropDown(this.filterBarCursor); + + // if there are possible starters go the first one of the list + if (numberOfStarters > 0) { + this.setFilterMode(false); + this.scrollCursor = 0; + this.updateScroll(); + this.setCursor(0); + } success = true; + } else if (this.statsMode) { this.toggleStatsMode(false); success = true; @@ -1081,6 +1170,9 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.starterIconsCursorIndex = this.starterSpecies.length - 1; this.moveStarterIconsCursor(this.starterIconsCursorIndex); } else { + // up from start button with no Pokemon in the team > go to filter + this.startCursorObj.setVisible(false); + this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); this.setFilterMode(true); } success = true; @@ -1091,6 +1183,9 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.starterIconsCursorIndex = 0; this.moveStarterIconsCursor(this.starterIconsCursorIndex); } else { + // down from start button with no Pokemon in the team > go to filter + this.startCursorObj.setVisible(false); + this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); this.setFilterMode(true); } success = true; @@ -1129,27 +1224,31 @@ export default class StarterSelectUiHandler extends MessageUiHandler { success = this.filterBar.decDropDownCursor(); // else if there is filtered starters } else if (numberOfStarters > 0) { + // UP from filter bar to bottom of Pokemon list this.setFilterMode(false); this.scrollCursor = Math.max(0,numOfRows - 9); this.updateScroll(); const proportion = (this.filterBarCursor + 0.5) / this.filterBar.numFilters; - const targetCol = Math.floor(proportion * 9); + const targetCol = Math.min(8, Math.floor(proportion * 11)); if (numberOfStarters % 9 > targetCol) { - success = this.setCursor(numberOfStarters - (numberOfStarters) % 9 + targetCol); + this.setCursor(numberOfStarters - (numberOfStarters) % 9 + targetCol); } else { - success = this.setCursor(Math.max(numberOfStarters - (numberOfStarters) % 9 + targetCol - 9,0)); + this.setCursor(Math.max(numberOfStarters - (numberOfStarters) % 9 + targetCol - 9, 0)); } + success = true; } break; case Button.DOWN: if (this.filterBar.openDropDown) { success = this.filterBar.incDropDownCursor(); } else if (numberOfStarters > 0) { + // DOWN from filter bar to top of Pokemon list this.setFilterMode(false); this.scrollCursor = 0; this.updateScroll(); const proportion = this.filterBarCursor / Math.max(1, this.filterBar.numFilters - 1); - this.setCursor(Math.round(proportion * (Math.min(9, numberOfStarters) - 1))); + const targetCol = Math.min(8, Math.floor(proportion * 11)); + this.setCursor(Math.min(targetCol, numberOfStarters)); success = true; } break; @@ -1669,9 +1768,11 @@ export default class StarterSelectUiHandler extends MessageUiHandler { } } else { if (this.starterIconsCursorIndex === 0) { + // Up from first Pokemon in the team > go to filter this.starterIconsCursorObj.setVisible(false); this.setSpecies(null); - this.startCursorObj.setVisible(true); + this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); + this.setFilterMode(true); } else { this.starterIconsCursorIndex--; this.moveStarterIconsCursor(this.starterIconsCursorIndex); @@ -1687,7 +1788,14 @@ export default class StarterSelectUiHandler extends MessageUiHandler { } success = this.setCursor(this.cursor + 9); this.updateScroll(); - } else { // last row + } else if (numOfRows > 1) { + // DOWN from last row of Pokemon > Wrap around to first row + this.scrollCursor = 0; + this.updateScroll(); + success = this.setCursor(this.cursor % 9); + } else { + // DOWN from single row of Pokemon > Go to filters + this.filterBarCursor = this.filterBar.getNearestFilter(this.filteredStarterContainers[this.cursor]); this.setFilterMode(true); success = true; } @@ -1708,29 +1816,36 @@ export default class StarterSelectUiHandler extends MessageUiHandler { if (this.cursor % 9 !== 0) { success = this.setCursor(this.cursor - 1); } else { + // LEFT from filtered Pokemon, on the left edge + if (this.starterSpecies.length === 0) { - // just wrap around to the last column + // no starter in team > wrap around to the last column success = this.setCursor(this.cursor + Math.min(8, numberOfStarters - this.cursor)); - } else if (onScreenCurrentRow < 3) { - // always to the first starter - this.cursorObj.setVisible(false); - this.starterIconsCursorIndex = 0; - this.moveStarterIconsCursor(this.starterIconsCursorIndex); + } else if (onScreenCurrentRow < 7) { + // at least one pokemon in team > for the first 7 rows, go to closest starter this.cursorObj.setVisible(false); - this.starterIconsCursorIndex = Math.min(onScreenCurrentRow-2, this.starterSpecies.length - 1); + this.starterIconsCursorIndex = findClosestStarterIndex(this.cursorObj.y - 1, this.starterSpecies.length); this.moveStarterIconsCursor(this.starterIconsCursorIndex); + } else { + // at least one pokemon in team > from the bottom 2 rows, go to start run button this.cursorObj.setVisible(false); this.setSpecies(null); this.startCursorObj.setVisible(true); } success = true; } - } else { + } else if (numberOfStarters > 0) { + // LEFT from team > Go to closest filtered Pokemon + const closestRowIndex = findClosestStarterRow(this.starterIconsCursorIndex, onScreenNumberOfRows); this.starterIconsCursorObj.setVisible(false); this.cursorObj.setVisible(true); - success = this.setCursor(Math.min(onScreenFirstIndex + (this.starterIconsCursorIndex + 2) * 9 + 8,onScreenLastIndex)); // set last column + this.setCursor(Math.min(onScreenFirstIndex + closestRowIndex * 9 + 8, onScreenLastIndex)); + success = true; + } else { + // LEFT from team and no Pokemon in filter > do nothing + success = false; } break; case Button.RIGHT: @@ -1739,33 +1854,37 @@ export default class StarterSelectUiHandler extends MessageUiHandler { if (this.cursor % 9 < (currentRow < numOfRows - 1 ? 8 : (numberOfStarters - 1) % 9)) { success = this.setCursor(this.cursor + 1); } else { - // in right edge + // RIGHT from filtered Pokemon, on the right edge if (this.starterSpecies.length === 0) { - // just wrap around to the first column + // no selected starter in team > wrap around to the first column success = this.setCursor(this.cursor - Math.min(8, this.cursor % 9)); - } else if (onScreenCurrentRow < 3) { - // always to the first starter - this.cursorObj.setVisible(false); - this.starterIconsCursorIndex = 0; - this.moveStarterIconsCursor(this.starterIconsCursorIndex); + } else if (onScreenCurrentRow < 7) { + // at least one pokemon in team > for the first 7 rows, go to closest starter this.cursorObj.setVisible(false); - this.starterIconsCursorIndex = Math.min(onScreenCurrentRow-2, this.starterSpecies.length - 1); + this.starterIconsCursorIndex = findClosestStarterIndex(this.cursorObj.y - 1, this.starterSpecies.length); this.moveStarterIconsCursor(this.starterIconsCursorIndex); + } else { + // at least one pokemon in team > from the bottom 2 rows, go to start run button this.cursorObj.setVisible(false); this.setSpecies(null); this.startCursorObj.setVisible(true); } success = true; } - break; - } else { + } else if (numberOfStarters > 0) { + // RIGHT from team > Go to closest filtered Pokemon + const closestRowIndex = findClosestStarterRow(this.starterIconsCursorIndex, onScreenNumberOfRows); this.starterIconsCursorObj.setVisible(false); this.cursorObj.setVisible(true); - success = this.setCursor(Math.min(onScreenFirstIndex + (this.starterIconsCursorIndex + 2) * 9, onScreenLastIndex - (onScreenLastIndex % 9))); // set first column - break; + this.setCursor(Math.min(onScreenFirstIndex + closestRowIndex * 9, onScreenLastIndex - (onScreenLastIndex % 9))); + success = true; + } else { + // RIGHT from team and no Pokemon in filter > do nothing + success = false; } + break; } } } @@ -1959,7 +2078,6 @@ export default class StarterSelectUiHandler extends MessageUiHandler { // pre filter for challenges if (this.scene.gameMode.modeId === GameModes.CHALLENGE) { - console.log("this.scene.gameMode.modeId", this.scene.gameMode.modeId); this.starterContainers.forEach(container => { const isValidForChallenge = new Utils.BooleanHolder(true); Challenge.applyChallenges(this.scene.gameMode, Challenge.ChallengeType.STARTER_CHOICE, container.species, isValidForChallenge, this.scene.gameData.getSpeciesDexAttrProps(container.species, this.scene.gameData.getSpeciesDefaultDexAttr(container.species, false, true)), true); @@ -1997,7 +2115,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { const fitsType = this.filterBar.getVals(DropDownColumn.TYPES).some(type => container.species.isOfType((type as number) - 1)); - const fitsShiny = this.filterBar.getVals(DropDownColumn.SHINY).some(variant => { + const fitsShiny = this.filterBar.getVals(DropDownColumn.DEX).some(variant => { if (variant === "SHINY3") { return isVariant3Caught; } else if (variant === "SHINY2") { @@ -2012,7 +2130,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { }); const fitsPassive = this.filterBar.getVals(DropDownColumn.UNLOCKS).some(unlocks => { - if (unlocks.val === "PASSIVE" && unlocks.state === DropDownState.INCLUDE) { + if (unlocks.val === "PASSIVE" && unlocks.state === DropDownState.ON) { return isPassiveUnlocked; } else if (unlocks.val === "PASSIVE" && unlocks.state === DropDownState.EXCLUDE) { return !isPassiveUnlocked; @@ -2024,7 +2142,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { const fitsWin = this.filterBar.getVals(DropDownColumn.MISC).some(misc => { if (container.species.speciesId < 10) { } - if (misc.val === "WIN" && misc.state === DropDownState.INCLUDE) { + if (misc.val === "WIN" && misc.state === DropDownState.ON) { return isWin; } else if (misc.val === "WIN" && misc.state === DropDownState.EXCLUDE) { return isNotWin || isUndefined; @@ -2749,8 +2867,10 @@ export default class StarterSelectUiHandler extends MessageUiHandler { if (this.starterSpecies.length > 0) { this.starterIconsCursorIndex--; } else { + // No more Pokemon selected, go back to filters this.starterIconsCursorObj.setVisible(false); this.setSpecies(null); + this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); this.setFilterMode(true); } }