From a25ccbcde6f4b0ebb86c89d54bd37d3ecc6f4ac3 Mon Sep 17 00:00:00 2001 From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:51:49 +0200 Subject: [PATCH] [UI] Make Egg List and Egg Summary scrollable (#4391) --- src/ui/egg-list-ui-handler.ts | 166 +++++++++++++---------- src/ui/egg-summary-ui-handler.ts | 197 ++++++++-------------------- src/ui/hatched-pokemon-container.ts | 136 +++++++++++++++++++ src/ui/scroll-bar.ts | 11 +- src/ui/scrollable-grid-handler.ts | 197 ++++++++++++++++++++++++++++ 5 files changed, 496 insertions(+), 211 deletions(-) create mode 100644 src/ui/hatched-pokemon-container.ts create mode 100644 src/ui/scrollable-grid-handler.ts diff --git a/src/ui/egg-list-ui-handler.ts b/src/ui/egg-list-ui-handler.ts index fd8444f73ef..0ebc0f8140e 100644 --- a/src/ui/egg-list-ui-handler.ts +++ b/src/ui/egg-list-ui-handler.ts @@ -1,16 +1,21 @@ -import BattleScene from "../battle-scene"; -import { Mode } from "./ui"; -import PokemonIconAnimHandler, { PokemonIconAnimMode } from "./pokemon-icon-anim-handler"; -import { TextStyle, addTextObject } from "./text"; -import MessageUiHandler from "./message-ui-handler"; -import { Egg } from "../data/egg"; -import { addWindow } from "./ui-theme"; +import BattleScene from "#app/battle-scene"; +import { Mode } from "#app/ui/ui"; +import PokemonIconAnimHandler, { PokemonIconAnimMode } from "#app/ui/pokemon-icon-anim-handler"; +import { TextStyle, addTextObject } from "#app/ui/text"; +import MessageUiHandler from "#app/ui/message-ui-handler"; +import { addWindow } from "#app/ui/ui-theme"; import {Button} from "#enums/buttons"; import i18next from "i18next"; +import ScrollableGridUiHandler from "#app/ui/scrollable-grid-handler"; +import { ScrollBar } from "#app/ui/scroll-bar"; export default class EggListUiHandler extends MessageUiHandler { + private readonly ROWS = 9; + private readonly COLUMNS = 11; + private eggListContainer: Phaser.GameObjects.Container; private eggListIconContainer: Phaser.GameObjects.Container; + private eggIcons: Phaser.GameObjects.Sprite[]; private eggSprite: Phaser.GameObjects.Sprite; private eggNameText: Phaser.GameObjects.Text; private eggDateText: Phaser.GameObjects.Text; @@ -19,6 +24,7 @@ export default class EggListUiHandler extends MessageUiHandler { private eggListMessageBoxContainer: Phaser.GameObjects.Container; private cursorObj: Phaser.GameObjects.Image; + private scrollGridHandler : ScrollableGridUiHandler; private iconAnimHandler: PokemonIconAnimHandler; @@ -64,7 +70,7 @@ export default class EggListUiHandler extends MessageUiHandler { this.eggGachaInfoText.setWordWrapWidth(540); this.eggListContainer.add(this.eggGachaInfoText); - this.eggListIconContainer = this.scene.add.container(115, 9); + this.eggListIconContainer = this.scene.add.container(113, 5); this.eggListContainer.add(this.eggListIconContainer); this.cursorObj = this.scene.add.image(0, 0, "select_cursor"); @@ -74,6 +80,14 @@ export default class EggListUiHandler extends MessageUiHandler { this.eggSprite = this.scene.add.sprite(54, 37, "egg"); this.eggListContainer.add(this.eggSprite); + const scrollBar = new ScrollBar(this.scene, 310, 5, 4, 170, this.ROWS); + this.eggListContainer.add(scrollBar); + + this.scrollGridHandler = new ScrollableGridUiHandler(this, this.ROWS, this.COLUMNS) + .withScrollBar(scrollBar) + .withUpdateGridCallBack(() => this.updateEggIcons()) + .withUpdateSingleElementCallback((i:number) => this.setEggDetails(i)); + this.eggListMessageBoxContainer = this.scene.add.container(0, this.scene.game.canvas.height / 6); this.eggListMessageBoxContainer.setVisible(false); this.eggListContainer.add(this.eggListMessageBoxContainer); @@ -92,76 +106,63 @@ export default class EggListUiHandler extends MessageUiHandler { show(args: any[]): boolean { super.show(args); + this.initEggIcons(); + this.getUi().bringToTop(this.eggListContainer); this.eggListContainer.setVisible(true); - let e = 0; - - for (const egg of this.scene.gameData.eggs) { - const x = (e % 11) * 18; - const y = Math.floor(e / 11) * 18; - const icon = this.scene.add.sprite(x - 2, y + 2, "egg_icons"); - icon.setScale(0.5); - icon.setOrigin(0, 0); - icon.setFrame(egg.getKey()); - this.eggListIconContainer.add(icon); - this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.NONE); - e++; - } + this.scrollGridHandler.setTotalElements(this.scene.gameData.eggs.length); + this.updateEggIcons(); this.setCursor(0); return true; } - processInput(button: Button): boolean { - const ui = this.getUi(); - - let success = false; - const error = false; - - if (button === Button.CANCEL) { - ui.revertMode(); - success = true; - } else { - const eggCount = this.eggListIconContainer.getAll().length; - const rows = Math.ceil(eggCount / 11); - const row = Math.floor(this.cursor / 11); - switch (button) { - case Button.UP: - if (row) { - success = this.setCursor(this.cursor - 11); - } - break; - case Button.DOWN: - if (row < rows - 2 || (row < rows - 1 && this.cursor % 11 <= (eggCount - 1) % 11)) { - success = this.setCursor(this.cursor + 11); - } - break; - case Button.LEFT: - if (this.cursor % 11) { - success = this.setCursor(this.cursor - 1); - } - break; - case Button.RIGHT: - if (this.cursor % 11 < (row < rows - 1 ? 10 : (eggCount - 1) % 11)) { - success = this.setCursor(this.cursor + 1); - } - break; - } + /** + * Create the grid of egg icons to display + */ + private initEggIcons() { + this.eggIcons = []; + for (let i = 0; i < Math.min(this.ROWS * this.COLUMNS, this.scene.gameData.eggs.length); i++) { + const x = (i % this.COLUMNS) * 18; + const y = Math.floor(i / this.COLUMNS) * 18; + const icon = this.scene.add.sprite(x - 2, y + 2, "egg_icons"); + icon.setScale(0.5); + icon.setOrigin(0, 0); + this.eggListIconContainer.add(icon); + this.eggIcons.push(icon); } - - if (success) { - ui.playSelect(); - } else if (error) { - ui.playError(); - } - - return success || error; } - setEggDetails(egg: Egg): void { + /** + * Show the grid of egg icons + */ + private updateEggIcons() { + const indexOffset = this.scrollGridHandler.getItemOffset(); + const eggsToShow = Math.min(this.eggIcons.length, this.scene.gameData.eggs.length - indexOffset); + + this.eggIcons.forEach((icon, i) => { + if (i !== this.cursor) { + this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.NONE); + } + if (i < eggsToShow) { + const egg = this.scene.gameData.eggs[i + indexOffset]; + icon.setFrame(egg.getKey()); + icon.setVisible(true); + } else { + icon.setVisible(false); + } + }); + } + + /** + * Update the information panel with the information of the given egg + * @param index which egg in the list to display the info for + */ + private setEggDetails(index: number): void { + const egg = this.scene.gameData.eggs[index]; this.eggSprite.setFrame(`egg_${egg.getKey()}`); this.eggNameText.setText(`${i18next.t("egg:egg")} (${egg.getEggDescriptor()})`); this.eggDateText.setText( @@ -176,7 +177,29 @@ export default class EggListUiHandler extends MessageUiHandler { this.eggGachaInfoText.setText(egg.getEggTypeDescriptor(this.scene)); } - setCursor(cursor: integer): boolean { + processInput(button: Button): boolean { + const ui = this.getUi(); + + let success = false; + const error = false; + + if (button === Button.CANCEL) { + ui.revertMode(); + success = true; + } else { + success = this.scrollGridHandler.processInput(button); + } + + if (success) { + ui.playSelect(); + } else if (error) { + ui.playError(); + } + + return success || error; + } + + setCursor(cursor: number): boolean { let changed = false; const lastCursor = this.cursor; @@ -184,14 +207,15 @@ export default class EggListUiHandler extends MessageUiHandler { changed = super.setCursor(cursor); if (changed) { - this.cursorObj.setPosition(114 + 18 * (cursor % 11), 10 + 18 * Math.floor(cursor / 11)); + const icon = this.eggIcons[cursor]; + this.cursorObj.setPositionRelative(icon, 114, 5); if (lastCursor > -1) { - this.iconAnimHandler.addOrUpdate(this.eggListIconContainer.getAt(lastCursor) as Phaser.GameObjects.Sprite, PokemonIconAnimMode.NONE); + this.iconAnimHandler.addOrUpdate(this.eggIcons[lastCursor], PokemonIconAnimMode.NONE); } - this.iconAnimHandler.addOrUpdate(this.eggListIconContainer.getAt(cursor) as Phaser.GameObjects.Sprite, PokemonIconAnimMode.ACTIVE); + this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.ACTIVE); - this.setEggDetails(this.scene.gameData.eggs[cursor]); + this.setEggDetails(cursor + this.scrollGridHandler.getItemOffset()); } return changed; @@ -199,9 +223,11 @@ export default class EggListUiHandler extends MessageUiHandler { clear(): void { super.clear(); + this.scrollGridHandler.reset(); this.cursor = -1; this.eggListContainer.setVisible(false); this.iconAnimHandler.removeAll(); this.eggListIconContainer.removeAll(true); + this.eggIcons = []; } } diff --git a/src/ui/egg-summary-ui-handler.ts b/src/ui/egg-summary-ui-handler.ts index 99fbccb4257..f4c3e056360 100644 --- a/src/ui/egg-summary-ui-handler.ts +++ b/src/ui/egg-summary-ui-handler.ts @@ -3,17 +3,17 @@ import { Mode } from "./ui"; import PokemonIconAnimHandler, { PokemonIconAnimMode } from "./pokemon-icon-anim-handler"; import MessageUiHandler from "./message-ui-handler"; import { getEggTierForSpecies } from "../data/egg"; -import {Button} from "#enums/buttons"; -import { Gender } from "#app/data/gender"; -import { getVariantTint } from "#app/data/variant"; -import { EggTier } from "#app/enums/egg-type"; +import { Button } from "#enums/buttons"; import PokemonHatchInfoContainer from "./pokemon-hatch-info-container"; import { EggSummaryPhase } from "#app/phases/egg-summary-phase"; -import { DexAttr } from "#app/system/game-data"; import { EggHatchData } from "#app/data/egg-hatch-data"; +import ScrollableGridUiHandler from "./scrollable-grid-handler"; +import { HatchedPokemonContainer } from "./hatched-pokemon-container"; +import { ScrollBar } from "#app/ui/scroll-bar"; -const iconContainerX = 115; +const iconContainerX = 112; const iconContainerY = 9; +const numRows = 9; const numCols = 11; const iconSize = 18; @@ -27,20 +27,20 @@ export default class EggSummaryUiHandler extends MessageUiHandler { private eggHatchContainer: Phaser.GameObjects.Container; /** holds the icon containers and info container */ private summaryContainer: Phaser.GameObjects.Container; - /** container for the mini pokemon sprites */ - private pokemonIconSpritesContainer: Phaser.GameObjects.Container; - /** container for the icons displayed on top of the mini pokemon sprites (e.g. shiny, HA capsule) */ + /** container for the each pokemon sprites and icons */ private pokemonIconsContainer: Phaser.GameObjects.Container; - /** container for the elements displayed behind the mini pokemon sprites (e.g. egg rarity bg) */ - private pokemonBackgroundContainer: Phaser.GameObjects.Container; + /** list of the containers added to pokemonIconsContainer for easier access */ + private pokemonContainers: HatchedPokemonContainer[]; + /** hatch info container that displays the current pokemon / hatch (main element on left hand side) */ private infoContainer: PokemonHatchInfoContainer; /** handles jumping animations for the pokemon sprite icons */ private iconAnimHandler: PokemonIconAnimHandler; private eggHatchBg: Phaser.GameObjects.Image; - private cursorObj: Phaser.GameObjects.Image; private eggHatchData: EggHatchData[]; + private scrollGridHandler : ScrollableGridUiHandler; + private cursorObj: Phaser.GameObjects.Image; /** * Allows subscribers to listen for events @@ -54,7 +54,6 @@ export default class EggSummaryUiHandler extends MessageUiHandler { super(scene, Mode.EGG_HATCH_SUMMARY); } - setup() { const ui = this.getUi(); @@ -77,11 +76,8 @@ export default class EggSummaryUiHandler extends MessageUiHandler { this.cursorObj.setOrigin(0, 0); this.summaryContainer.add(this.cursorObj); - this.pokemonIconSpritesContainer = this.scene.add.container(iconContainerX, iconContainerY); + this.pokemonContainers = []; this.pokemonIconsContainer = this.scene.add.container(iconContainerX, iconContainerY); - this.pokemonBackgroundContainer = this.scene.add.container(iconContainerX, iconContainerY); - this.summaryContainer.add(this.pokemonBackgroundContainer); - this.summaryContainer.add(this.pokemonIconSpritesContainer); this.summaryContainer.add(this.pokemonIconsContainer); this.infoContainer = new PokemonHatchInfoContainer(this.scene, this.summaryContainer); @@ -90,16 +86,24 @@ export default class EggSummaryUiHandler extends MessageUiHandler { this.infoContainer.setVisible(true); this.summaryContainer.add(this.infoContainer); + const scrollBar = new ScrollBar(this.scene, iconContainerX + numCols * iconSize, iconContainerY + 3, 4, this.scene.game.canvas.height / 6 - 20, numRows); + this.summaryContainer.add(scrollBar); + + this.scrollGridHandler = new ScrollableGridUiHandler(this, numRows, numCols) + .withScrollBar(scrollBar) + .withUpdateGridCallBack(() => this.updatePokemonIcons()) + .withUpdateSingleElementCallback((i: number) => this.infoContainer.showHatchInfo(this.eggHatchData[i])); + this.cursor = -1; } clear() { super.clear(); this.cursor = -1; + this.scrollGridHandler.reset(); this.summaryContainer.setVisible(false); - this.pokemonIconSpritesContainer.removeAll(true); this.pokemonIconsContainer.removeAll(true); - this.pokemonBackgroundContainer.removeAll(true); + this.pokemonContainers = []; this.eggHatchBg.setVisible(false); this.getUi().hideTooltip(); @@ -149,111 +153,51 @@ export default class EggSummaryUiHandler extends MessageUiHandler { return 0; } } - } - - ); + }); } this.getUi().bringToTop(this.summaryContainer); this.summaryContainer.setVisible(true); this.eggHatchContainer.setVisible(true); - this.pokemonIconsContainer.setVisible(true); this.eggHatchBg.setVisible(true); this.infoContainer.hideDisplayPokemon(); - this.eggHatchData.forEach( (value: EggHatchData, i: number) => { - const x = (i % numCols) * iconSize; - const y = Math.floor(i / numCols) * iconSize; - - const displayPokemon = value.pokemon; - const offset = 2; - const rightSideX = 12; - - const rarityBg = this.scene.add.image(x + 2, y + 5, "passive_bg"); - rarityBg.setOrigin(0, 0); - rarityBg.setScale(0.75); - rarityBg.setVisible(true); - this.pokemonBackgroundContainer.add(rarityBg); - - // set tint for passive bg - switch (getEggTierForSpecies(displayPokemon.species)) { - case EggTier.COMMON: - rarityBg.setVisible(false); - break; - case EggTier.GREAT: - rarityBg.setTint(0xabafff); - break; - case EggTier.ULTRA: - rarityBg.setTint(0xffffaa); - break; - case EggTier.MASTER: - rarityBg.setTint(0xdfffaf); - break; - } - const species = displayPokemon.species; - const female = displayPokemon.gender === Gender.FEMALE; - const formIndex = displayPokemon.formIndex; - const variant = displayPokemon.variant; - const isShiny = displayPokemon.shiny; - - // set pokemon icon (and replace with base sprite if there is a mismatch) - const pokemonIcon = this.scene.add.sprite(x - offset, y + offset, species.getIconAtlasKey(formIndex, isShiny, variant)); - pokemonIcon.setScale(0.5); - pokemonIcon.setOrigin(0, 0); - pokemonIcon.setFrame(species.getIconId(female, formIndex, isShiny, variant)); - - if (pokemonIcon.frame.name !== species.getIconId(female, formIndex, isShiny, variant)) { - console.log(`${species.name}'s variant icon does not exist. Replacing with default.`); - pokemonIcon.setTexture(species.getIconAtlasKey(formIndex, false, variant)); - pokemonIcon.setFrame(species.getIconId(female, formIndex, false, variant)); - } - this.pokemonIconSpritesContainer.add(pokemonIcon); - - const shinyIcon = this.scene.add.image(x + rightSideX, y + offset, "shiny_star_small"); - shinyIcon.setOrigin(0, 0); - shinyIcon.setScale(0.5); - shinyIcon.setVisible(displayPokemon.shiny); - shinyIcon.setTint(getVariantTint(displayPokemon.variant)); - this.pokemonIconsContainer.add(shinyIcon); - - const haIcon = this.scene.add.image(x + rightSideX, y + offset * 4, "ha_capsule"); - haIcon.setOrigin(0, 0); - haIcon.setScale(0.5); - haIcon.setVisible(displayPokemon.abilityIndex === 2); - this.pokemonIconsContainer.add(haIcon); - - const dexEntry = value.dexEntryBeforeUpdate; - const caughtAttr = dexEntry.caughtAttr; - const newShiny = BigInt(1 << (displayPokemon.shiny ? 1 : 0)); - const newVariant = BigInt(1 << (displayPokemon.variant + 4)); - const newShinyOrVariant = ((newShiny & caughtAttr) === BigInt(0)) || ((newVariant & caughtAttr) === BigInt(0)); - const newForm = (BigInt(1 << displayPokemon.formIndex) * DexAttr.DEFAULT_FORM & caughtAttr) === BigInt(0); - - const pokeballIcon = this.scene.add.image(x + rightSideX, y + offset * 7, "icon_owned"); - pokeballIcon.setOrigin(0, 0); - pokeballIcon.setScale(0.5); - pokeballIcon.setVisible(!caughtAttr || newForm); - this.pokemonIconsContainer.add(pokeballIcon); - - const eggMoveIcon = this.scene.add.image(x, y + offset, "icon_egg_move"); - eggMoveIcon.setOrigin(0, 0); - eggMoveIcon.setScale(0.5); - eggMoveIcon.setVisible(value.eggMoveUnlocked); - this.pokemonIconsContainer.add(eggMoveIcon); - - // add animation to the Pokemon sprite for new unlocks (new catch, new shiny or new form) - if (!caughtAttr || newShinyOrVariant || newForm) { - this.iconAnimHandler.addOrUpdate(pokemonIcon, PokemonIconAnimMode.PASSIVE); - } else { - this.iconAnimHandler.addOrUpdate(pokemonIcon, PokemonIconAnimMode.NONE); - } - }); + this.scrollGridHandler.setTotalElements(this.eggHatchData.length); + this.updatePokemonIcons(); this.setCursor(0); this.scene.playSoundWithoutBgm("evolution_fanfare"); return true; } + /** + * Show the grid of Pokemon icons + */ + private updatePokemonIcons(): void { + const itemOffset = this.scrollGridHandler.getItemOffset(); + const eggsToShow = Math.min(numRows * numCols, this.eggHatchData.length - itemOffset); + + for (let i = 0; i < numRows * numCols; i++) { + const hatchData = this.eggHatchData[i + itemOffset]; + let hatchContainer = this.pokemonContainers[i]; + + if (i < eggsToShow) { + if (!hatchContainer) { + const x = (i % numCols) * iconSize; + const y = Math.floor(i / numCols) * iconSize; + hatchContainer = new HatchedPokemonContainer(this.scene, x, y, hatchData).setVisible(false); + this.pokemonContainers.push(hatchContainer); + this.pokemonIconsContainer.add(hatchContainer); + } + hatchContainer.setVisible(true); + hatchContainer.updateAndAnimate(hatchData, this.iconAnimHandler); + } else if (hatchContainer) { + hatchContainer.setVisible(false); + this.iconAnimHandler.addOrUpdate(hatchContainer.icon, PokemonIconAnimMode.NONE); + } + } + } + processInput(button: Button): boolean { const ui = this.getUi(); @@ -266,31 +210,7 @@ export default class EggSummaryUiHandler extends MessageUiHandler { } success = true; } else { - const count = this.eggHatchData.length; - const rows = Math.ceil(count / numCols); - const row = Math.floor(this.cursor / numCols); - switch (button) { - case Button.UP: - if (row) { - success = this.setCursor(this.cursor - numCols); - } - break; - case Button.DOWN: - if (row < rows - 2 || (row < rows - 1 && this.cursor % numCols <= (count - 1) % numCols)) { - success = this.setCursor(this.cursor + numCols); - } - break; - case Button.LEFT: - if (this.cursor % numCols) { - success = this.setCursor(this.cursor - 1); - } - break; - case Button.RIGHT: - if (this.cursor % numCols < (row < rows - 1 ? 10 : (count - 1) % numCols)) { - success = this.setCursor(this.cursor + 1); - } - break; - } + this.scrollGridHandler.processInput(button); } if (success) { @@ -313,12 +233,11 @@ export default class EggSummaryUiHandler extends MessageUiHandler { this.cursorObj.setPosition(iconContainerX - 1 + iconSize * (cursor % numCols), iconContainerY + 1 + iconSize * Math.floor(cursor / numCols)); if (lastCursor > -1) { - this.iconAnimHandler.addOrUpdate(this.pokemonIconSpritesContainer.getAt(lastCursor) as Phaser.GameObjects.Sprite, PokemonIconAnimMode.NONE); + this.iconAnimHandler.addOrUpdate(this.pokemonContainers[lastCursor].icon, PokemonIconAnimMode.NONE); } - this.iconAnimHandler.addOrUpdate(this.pokemonIconSpritesContainer.getAt(cursor) as Phaser.GameObjects.Sprite, PokemonIconAnimMode.ACTIVE); - - this.infoContainer.showHatchInfo(this.eggHatchData[cursor]); + this.iconAnimHandler.addOrUpdate(this.pokemonContainers[cursor].icon, PokemonIconAnimMode.ACTIVE); + this.infoContainer.showHatchInfo(this.eggHatchData[cursor + this.scrollGridHandler.getItemOffset()]); } return changed; diff --git a/src/ui/hatched-pokemon-container.ts b/src/ui/hatched-pokemon-container.ts new file mode 100644 index 00000000000..9fb1fd26b30 --- /dev/null +++ b/src/ui/hatched-pokemon-container.ts @@ -0,0 +1,136 @@ +import { EggHatchData } from "#app/data/egg-hatch-data"; +import { Gender } from "#app/data/gender"; +import { getVariantTint } from "#app/data/variant"; +import { DexAttr } from "#app/system/game-data"; +import BattleScene from "#app/battle-scene"; +import PokemonSpecies from "#app/data/pokemon-species"; +import PokemonIconAnimHandler, { PokemonIconAnimMode } from "./pokemon-icon-anim-handler"; + +/** + * A container for a Pokemon's sprite and icons to get displayed in the egg summary screen + * Shows the Pokemon's sprite, surrounded by icons for: + * shiny variant, hidden ability, new egg move, new catch + */ +export class HatchedPokemonContainer extends Phaser.GameObjects.Container { + public scene: BattleScene; + public species: PokemonSpecies; + public icon: Phaser.GameObjects.Sprite; + public shinyIcon: Phaser.GameObjects.Image; + public hiddenAbilityIcon: Phaser.GameObjects.Image; + public pokeballIcon: Phaser.GameObjects.Image; + public eggMoveIcon: Phaser.GameObjects.Image; + + /** + * @param scene the current {@linkcode BattleScene} + * @param x x position + * @param y y position + * @param hatchData the {@linkcode EggHatchData} to load the icons and sprites for + */ + constructor(scene: BattleScene, x: number, y: number, hatchData: EggHatchData) { + super(scene, x, y); + + const displayPokemon = hatchData.pokemon; + this.species = displayPokemon.species; + + const offset = 2; + const rightSideX = 12; + const species = displayPokemon.species; + const female = displayPokemon.gender === Gender.FEMALE; + const formIndex = displayPokemon.formIndex; + const variant = displayPokemon.variant; + const isShiny = displayPokemon.shiny; + + // Pokemon sprite + const pokemonIcon = this.scene.add.sprite(-offset, offset, species.getIconAtlasKey(formIndex, isShiny, variant)); + pokemonIcon.setScale(0.5); + pokemonIcon.setOrigin(0, 0); + pokemonIcon.setFrame(species.getIconId(female, formIndex, isShiny, variant)); + this.icon = pokemonIcon; + this.checkIconId(female, formIndex, isShiny, variant); + this.add(this.icon); + + // Shiny icon + this.shinyIcon = this.scene.add.image(rightSideX, offset, "shiny_star_small"); + this.shinyIcon.setOrigin(0, 0); + this.shinyIcon.setScale(0.5); + this.add(this.shinyIcon); + + // Hidden ability icon + const haIcon = this.scene.add.image(rightSideX, offset * 4, "ha_capsule"); + haIcon.setOrigin(0, 0); + haIcon.setScale(0.5); + this.hiddenAbilityIcon = haIcon; + this.add(this.hiddenAbilityIcon); + + // Pokeball icon + const pokeballIcon = this.scene.add.image(rightSideX, offset * 7, "icon_owned"); + pokeballIcon.setOrigin(0, 0); + pokeballIcon.setScale(0.5); + this.pokeballIcon = pokeballIcon; + this.add(this.pokeballIcon); + + // Egg move icon + const eggMoveIcon = this.scene.add.image(0, offset, "icon_egg_move"); + eggMoveIcon.setOrigin(0, 0); + eggMoveIcon.setScale(0.5); + this.eggMoveIcon = eggMoveIcon; + this.add(this.eggMoveIcon); + } + + /** + * Update the Pokemon's sprite and icons based on new hatch data + * Animates the pokemon icon if it has a new form or shiny variant + * + * @param hatchData the {@linkcode EggHatchData} to base the icons on + * @param iconAnimHandler the {@linkcode PokemonIconAnimHandler} to use to animate the sprites + */ + updateAndAnimate(hatchData: EggHatchData, iconAnimHandler: PokemonIconAnimHandler) { + const displayPokemon = hatchData.pokemon; + this.species = displayPokemon.species; + + const dexEntry = hatchData.dexEntryBeforeUpdate; + const caughtAttr = dexEntry.caughtAttr; + const newShiny = BigInt(1 << (displayPokemon.shiny ? 1 : 0)); + const newVariant = BigInt(1 << (displayPokemon.variant + 4)); + const newShinyOrVariant = ((newShiny & caughtAttr) === BigInt(0)) || ((newVariant & caughtAttr) === BigInt(0)); + const newForm = (BigInt(1 << displayPokemon.formIndex) * DexAttr.DEFAULT_FORM & caughtAttr) === BigInt(0); + + const female = displayPokemon.gender === Gender.FEMALE; + const formIndex = displayPokemon.formIndex; + const variant = displayPokemon.variant; + const isShiny = displayPokemon.shiny; + + this.icon.setTexture(this.species.getIconAtlasKey(formIndex, isShiny, variant)); + this.icon.setFrame(this.species.getIconId(female, formIndex, isShiny, variant)); + this.checkIconId(female, formIndex, isShiny, variant); + + this.shinyIcon.setVisible(displayPokemon.shiny); + this.shinyIcon.setTint(getVariantTint(displayPokemon.variant)); + + this.eggMoveIcon.setVisible(hatchData.eggMoveUnlocked); + this.hiddenAbilityIcon.setVisible(displayPokemon.abilityIndex === 2); + this.pokeballIcon.setVisible(!caughtAttr || newForm); + + // add animation to the Pokemon sprite for new unlocks (new catch, new shiny or new form) + if (!caughtAttr || newShinyOrVariant || newForm) { + iconAnimHandler.addOrUpdate(this.icon, PokemonIconAnimMode.PASSIVE); + } else { + iconAnimHandler.addOrUpdate(this.icon, PokemonIconAnimMode.NONE); + } + } + + /** + * Check if the given Pokemon icon exists, otherwise replace it with a default one + * @param female `true` to get the female icon + * @param formIndex the form index + * @param shiny whether the Pokemon is shiny + * @param variant the shiny variant + */ + private checkIconId(female: boolean, formIndex: number, shiny: boolean, variant: number) { + if (this.icon.frame.name !== this.species.getIconId(female, formIndex, shiny, variant)) { + console.log(`${this.species.name}'s variant icon does not exist. Replacing with default.`); + this.icon.setTexture(this.species.getIconAtlasKey(formIndex, false, variant)); + this.icon.setFrame(this.species.getIconId(female, formIndex, false, variant)); + } + } +} diff --git a/src/ui/scroll-bar.ts b/src/ui/scroll-bar.ts index 5ed79d0cdad..9874be0f73a 100644 --- a/src/ui/scroll-bar.ts +++ b/src/ui/scroll-bar.ts @@ -22,6 +22,8 @@ export class ScrollBar extends Phaser.GameObjects.Container { super(scene, x, y); this.maxRows = maxRows; + this.totalRows = maxRows; + this.currentRow = 0; const borderSize = 2; width = Math.max(width, 4); @@ -46,8 +48,7 @@ export class ScrollBar extends Phaser.GameObjects.Container { */ setScrollCursor(scrollCursor: number): void { this.currentRow = scrollCursor; - this.handleBody.y = 1 + (this.bg.displayHeight - 1 - this.handleBottom.displayHeight) / this.totalRows * this.currentRow; - this.handleBottom.y = this.handleBody.y + this.handleBody.displayHeight; + this.updateHandlePosition(); } /** @@ -59,7 +60,13 @@ export class ScrollBar extends Phaser.GameObjects.Container { setTotalRows(rows: number): void { this.totalRows = rows; this.handleBody.height = (this.bg.displayHeight - 1 - this.handleBottom.displayHeight) * this.maxRows / this.totalRows; + this.updateHandlePosition(); this.setVisible(this.totalRows > this.maxRows); } + + private updateHandlePosition(): void { + this.handleBody.y = 1 + (this.bg.displayHeight - 1 - this.handleBottom.displayHeight) / this.totalRows * this.currentRow; + this.handleBottom.y = this.handleBody.y + this.handleBody.displayHeight; + } } diff --git a/src/ui/scrollable-grid-handler.ts b/src/ui/scrollable-grid-handler.ts new file mode 100644 index 00000000000..f96f3c995b7 --- /dev/null +++ b/src/ui/scrollable-grid-handler.ts @@ -0,0 +1,197 @@ +import { Button } from "#enums/buttons"; +import UiHandler from "#app/ui/ui-handler"; +import { ScrollBar } from "#app/ui/scroll-bar"; + +type UpdateGridCallbackFunction = () => void; +type UpdateDetailsCallbackFunction = (index: number) => void; + +/** + * A helper class to handle navigation through a grid of elements that can scroll vertically + * Needs to be used by a {@linkcode UiHandler} + * How to use: + * - in `UiHandler.setup`: Initialize with the {@linkcode UiHandler} that handles the grid, + * the number of rows and columns that can be shown at once, + * an optional {@linkcode ScrollBar}, and optional callbacks that will get called after scrolling + * - in `UiHandler.show`: Set `setTotalElements` to the total number of elements in the list to display + * - in `UiHandler.processInput`: call `processNavigationInput` to have it handle the cursor updates while calling the defined callbacks + * - in `UiHandler.clear`: call `reset` + */ +export default class ScrollableGridUiHandler { + private readonly ROWS: number; + private readonly COLUMNS: number; + private handler: UiHandler; + private totalElements: number; + private cursor: number; + private scrollCursor: number; + private scrollBar?: ScrollBar; + private updateGridCallback?: UpdateGridCallbackFunction; + private updateDetailsCallback?: UpdateDetailsCallbackFunction; + + /** + * @param scene the {@linkcode UiHandler} that needs its cursor updated based on the scrolling + * @param rows the maximum number of rows shown at once + * @param columns the maximum number of columns shown at once + * @param updateGridCallback optional function that will get called if the whole grid needs to get updated + * @param updateDetailsCallback optional function that will get called if a single element's information needs to get updated + */ + constructor(handler: UiHandler, rows: number, columns: number) { + this.handler = handler; + this.ROWS = rows; + this.COLUMNS = columns; + this.scrollCursor = 0; + this.cursor = 0; + this.totalElements = rows * columns; // default value for the number of elements + } + + /** + * Set a scrollBar to get updated with the scrolling + * @param scrollBar {@linkcode ScrollBar} + * @returns this + */ + withScrollBar(scrollBar: ScrollBar): ScrollableGridUiHandler { + this.scrollBar = scrollBar; + this.scrollBar.setTotalRows(Math.ceil(this.totalElements / this.COLUMNS)); + return this; + } + + /** + * Set function that will get called if the whole grid needs to get updated + * @param callback {@linkcode UpdateGridCallbackFunction} + * @returns this + */ + withUpdateGridCallBack(callback: UpdateGridCallbackFunction): ScrollableGridUiHandler { + this.updateGridCallback = callback; + return this; + } + + /** + * Set function that will get called if a single element in the grid needs to get updated + * @param callback {@linkcode UpdateDetailsCallbackFunction} + * @returns this + */ + withUpdateSingleElementCallback(callback: UpdateDetailsCallbackFunction): ScrollableGridUiHandler { + this.updateDetailsCallback = callback; + return this; + } + + /** + * @param totalElements the total number of elements that the grid needs to display + */ + setTotalElements(totalElements: number) { + this.totalElements = totalElements; + if (this.scrollBar) { + this.scrollBar.setTotalRows(Math.ceil(this.totalElements / this.COLUMNS)); + } + this.setScrollCursor(0); + } + + /** + * @returns how many elements are hidden due to scrolling + */ + getItemOffset(): number { + return this.scrollCursor * this.COLUMNS; + } + + /** + * Update the cursor and scrollCursor based on user input + * @param button the button that was pressed + * @returns `true` if either the cursor or scrollCursor was updated + */ + processInput(button: Button): boolean { + let success = false; + const onScreenRows = Math.min(this.ROWS, Math.ceil(this.totalElements / this.COLUMNS)); + const maxScrollCursor = Math.max(0, Math.ceil(this.totalElements / this.COLUMNS) - onScreenRows); + const currentRowIndex = Math.floor(this.cursor / this.COLUMNS); + const currentColumnIndex = this.cursor % this.COLUMNS; + const itemOffset = this.scrollCursor * this.COLUMNS; + const lastVisibleIndex = Math.min(this.totalElements - 1, this.totalElements - maxScrollCursor * this.COLUMNS - 1); + switch (button) { + case Button.UP: + if (currentRowIndex > 0) { + success = this.setCursor(this.cursor - this.COLUMNS); + } else if (this.scrollCursor > 0) { + success = this.setScrollCursor(this.scrollCursor - 1); + } else { + // wrap around to the last row + let newCursor = this.cursor + (onScreenRows - 1) * this.COLUMNS; + if (newCursor > lastVisibleIndex) { + newCursor -= this.COLUMNS; + } + success = this.setScrollCursor(maxScrollCursor, newCursor); + } + break; + case Button.DOWN: + if (currentRowIndex < onScreenRows - 1) { + // Go down one row + success = this.setCursor(Math.min(this.cursor + this.COLUMNS, this.totalElements - itemOffset - 1)); + } else if (this.scrollCursor < maxScrollCursor) { + // Scroll down one row + success = this.setScrollCursor(this.scrollCursor + 1); + } else { + // Wrap around to the top row + success = this.setScrollCursor(0, this.cursor % this.COLUMNS); + } + break; + case Button.LEFT: + if (currentColumnIndex > 0) { + success = this.setCursor(this.cursor - 1); + } else if (this.scrollCursor === maxScrollCursor && currentRowIndex === onScreenRows - 1) { + success = this.setCursor(lastVisibleIndex); + } else { + success = this.setCursor(this.cursor + this.COLUMNS - 1); + } + break; + case Button.RIGHT: + if (currentColumnIndex < this.COLUMNS - 1 && this.cursor + itemOffset < this.totalElements - 1) { + success = this.setCursor(this.cursor + 1); + } else { + success = this.setCursor(this.cursor - currentColumnIndex); + } + break; + } + return success; + } + + /** + * Reset the scrolling + */ + reset(): void { + this.setScrollCursor(0); + this.setCursor(0); + } + + private setCursor(cursor: number): boolean { + this.cursor = cursor; + return this.handler.setCursor(cursor); + } + + private setScrollCursor(scrollCursor: number, cursor?: number): boolean { + const scrollChanged = scrollCursor !== this.scrollCursor; + + // update the scrolling cursor + if (scrollChanged) { + this.scrollCursor = scrollCursor; + if (this.scrollBar) { + this.scrollBar.setScrollCursor(scrollCursor); + } + if (this.updateGridCallback) { + this.updateGridCallback(); + } + } + + let cursorChanged = false; + const newElementIndex = this.cursor + this.scrollCursor * this.COLUMNS; + if (cursor !== undefined) { + cursorChanged = this.setCursor(cursor); + } else if (newElementIndex >= this.totalElements) { + // make sure the cursor does not go past the end of the list + cursorChanged = this.setCursor(this.totalElements - this.scrollCursor * this.COLUMNS - 1); + } else if (scrollChanged && this.updateDetailsCallback) { + // scroll was changed but not the normal cursor, update the selected element + this.updateDetailsCallback(newElementIndex); + } + + return scrollChanged || cursorChanged; + } + +}