diff --git a/src/ui/game-stats-ui-handler.ts b/src/ui/game-stats-ui-handler.ts index f9087ba7dfa..a9de827e258 100644 --- a/src/ui/game-stats-ui-handler.ts +++ b/src/ui/game-stats-ui-handler.ts @@ -2,7 +2,6 @@ import { globalScene } from "#app/global-scene"; import { speciesStarterCosts } from "#balance/starters"; import { Button } from "#enums/buttons"; import { DexAttr } from "#enums/dex-attr"; -import type { UiMode } from "#enums/ui-mode"; import { UiTheme } from "#enums/ui-theme"; import type { GameData } from "#system/game-data"; import { addTextObject, TextStyle } from "#ui/text"; @@ -217,152 +216,207 @@ export class GameStatsUiHandler extends UiHandler { private gameStatsContainer: Phaser.GameObjects.Container; private statsContainer: Phaser.GameObjects.Container; - private statLabels: Phaser.GameObjects.Text[]; - private statValues: Phaser.GameObjects.Text[]; + /** The number of rows enabled per page. */ + private static readonly ROWS_PER_PAGE = 9; + + private statLabels: Phaser.GameObjects.Text[] = []; + private statValues: Phaser.GameObjects.Text[] = []; private arrowUp: Phaser.GameObjects.Sprite; private arrowDown: Phaser.GameObjects.Sprite; - constructor(mode: UiMode | null = null) { - super(mode); - - this.statLabels = []; - this.statValues = []; + /** Whether the UI is single column mode */ + private get singleCol(): boolean { + const resolvedLang = i18next.resolvedLanguage ?? "en"; + // NOTE TO TRANSLATION TEAM: Add more languages that want to display + // in a single-column inside of the `[]` (e.g. `["ru", "fr"]`) + return ["ru"].includes(resolvedLang); } + /** The number of columns used by this menu in the resolved language */ + private get columnCount(): 1 | 2 { + return this.singleCol ? 1 : 2; + } + + // #region Columnar-specific properties + + /** The with of each column in the stats view */ + private get colWidth(): number { + return (globalScene.scaledCanvas.width - 2) / this.columnCount; + } + + /** THe width of a column's background window */ + private get colBgWidth(): number { + return this.colWidth - 2; + } + + /** + * Calculate the `x` position of the stat label based on its index. + * + * @remarks + * Should be used for stat labels (e.g. stat name, not its value). For stat value, use {@linkcode calcTextX}. + * @param index - The index of the stat label + * @returns The `x` position for the stat label + */ + private calcLabelX(index: number): number { + if (this.singleCol || !(index & 1)) { + return 8; + } + return 8 + (index & 1 ? this.colBgWidth : 0); + } + + /** + * Calculate the `y` position of the stat label/text based on its index. + * @param index - The index of the stat label + * @returns The `y` position for the stat label + */ + private calcEntryY(index: number): number { + if (!this.singleCol) { + // Floor division by 2 as we want 1 to go to 0 + index >>= 1; + } + return 28 + index * 16; + } + + /** + * Calculate the `x` position of the stat value based on its index. + * @param index - The index of the stat value + * @returns The calculated `x` position + */ + private calcTextX(index: number): number { + if (this.singleCol || !(index & 1)) { + return this.colBgWidth - 8; + } + return this.colBgWidth * 2 - 8; + } + + /** The number of stats on screen at one time (varies with column count) */ + private get statsPerPage(): number { + return GameStatsUiHandler.ROWS_PER_PAGE * this.columnCount; + } + + // #endregion Columnar-specific properties setup() { const ui = this.getUi(); - this.gameStatsContainer = globalScene.add.container(1, -(globalScene.game.canvas.height / 6) + 1); + /** The scaled width of the global canvas */ + const sWidth = globalScene.scaledCanvas.width; + /** The scaled height of the global canvas */ + const sHeight = globalScene.scaledCanvas.height; + + const gameStatsContainer = globalScene.add.container(1, -sHeight + 1); + this.gameStatsContainer = gameStatsContainer; this.gameStatsContainer.setInteractive( - new Phaser.Geom.Rectangle(0, 0, globalScene.game.canvas.width / 6, globalScene.game.canvas.height / 6), + new Phaser.Geom.Rectangle(0, 0, sWidth, sHeight), Phaser.Geom.Rectangle.Contains, ); - const headerBg = addWindow(0, 0, globalScene.game.canvas.width / 6 - 2, 24); - headerBg.setOrigin(0, 0); + const headerBg = addWindow(0, 0, sWidth - 2, 24).setOrigin(0); - const headerText = addTextObject(0, 0, i18next.t("gameStatsUiHandler:stats"), TextStyle.HEADER_LABEL); - headerText.setOrigin(0, 0); - headerText.setPositionRelative(headerBg, 8, 4); + const headerText = addTextObject(0, 0, i18next.t("gameStatsUiHandler:stats"), TextStyle.HEADER_LABEL) + .setOrigin(0) + .setPositionRelative(headerBg, 8, 4); - const statsBgWidth = (globalScene.game.canvas.width / 6 - 2) / 2; - const [statsBgLeft, statsBgRight] = new Array(2).fill(null).map((_, i) => { - const width = statsBgWidth + 2; - const height = Math.floor(globalScene.game.canvas.height / 6 - headerBg.height - 2); - const statsBg = addWindow( - (statsBgWidth - 2) * i, - headerBg.height, - width, - height, - false, - false, - i > 0 ? -3 : 0, - 1, - ); - statsBg.setOrigin(0, 0); - return statsBg; - }); + this.gameStatsContainer.add([headerBg, headerText]); - this.statsContainer = globalScene.add.container(0, 0); + const colWidth = this.colWidth; - for (let i = 0; i < 18; i++) { - const statLabel = addTextObject( - 8 + (i % 2 === 1 ? statsBgWidth : 0), - 28 + Math.floor(i / 2) * 16, - "", - TextStyle.STATS_LABEL, - ); - statLabel.setOrigin(0, 0); - this.statsContainer.add(statLabel); - this.statLabels.push(statLabel); - - const statValue = addTextObject(statsBgWidth * ((i % 2) + 1) - 8, statLabel.y, "", TextStyle.STATS_VALUE); - statValue.setOrigin(1, 0); - this.statsContainer.add(statValue); - this.statValues.push(statValue); + { + const columnCount = this.columnCount; + const headerHeight = headerBg.height; + const statsBgHeight = Math.floor(globalScene.scaledCanvas.height - headerBg.height - 2); + const maskOffsetX = columnCount === 1 ? 0 : -3; + for (let i = 0; i < columnCount; i++) { + gameStatsContainer.add( + addWindow(i * this.colBgWidth, headerHeight, colWidth, statsBgHeight, false, false, maskOffsetX, 1, undefined) // formatting + .setOrigin(0), + ); + } } - this.gameStatsContainer.add(headerBg); - this.gameStatsContainer.add(headerText); - this.gameStatsContainer.add(statsBgLeft); - this.gameStatsContainer.add(statsBgRight); + const length = this.statsPerPage; + this.statLabels = Array.from({ length }, (_, i) => + addTextObject(this.calcLabelX(i), this.calcEntryY(i), "", TextStyle.STATS_LABEL).setOrigin(0), + ); + + this.statValues = Array.from({ length }, (_, i) => + addTextObject(this.calcTextX(i), this.calcEntryY(i), "", TextStyle.STATS_VALUE).setOrigin(1, 0), + ); + this.statsContainer = globalScene.add.container(0, 0, [...this.statLabels, ...this.statValues]); + this.gameStatsContainer.add(this.statsContainer); // arrows to show that we can scroll through the stats const isLegacyTheme = globalScene.uiTheme === UiTheme.LEGACY; - this.arrowDown = globalScene.add.sprite( - statsBgWidth, - globalScene.game.canvas.height / 6 - (isLegacyTheme ? 9 : 5), - "prompt", - ); - this.gameStatsContainer.add(this.arrowDown); - this.arrowUp = globalScene.add.sprite(statsBgWidth, headerBg.height + (isLegacyTheme ? 7 : 3), "prompt"); - this.arrowUp.flipY = true; - this.gameStatsContainer.add(this.arrowUp); + const arrowX = this.singleCol ? colWidth / 2 : colWidth; + this.arrowDown = globalScene.add.sprite(arrowX, sHeight - (isLegacyTheme ? 9 : 5), "prompt"); + + this.arrowUp = globalScene.add + .sprite(arrowX, headerBg.height + (isLegacyTheme ? 7 : 3), "prompt") // + .setFlipY(true); + + this.gameStatsContainer.add([this.arrowDown, this.arrowUp]); ui.add(this.gameStatsContainer); this.setCursor(0); - this.gameStatsContainer.setVisible(false); } show(args: any[]): boolean { super.show(args); + this.gameStatsContainer.setActive(true).setVisible(true); - this.setCursor(0); - - this.updateStats(); - - this.arrowUp.play("prompt"); - this.arrowDown.play("prompt"); + this.arrowUp.setActive(true).play("prompt").setVisible(false); + this.arrowDown.setActive(true).play("prompt"); + /* `setCursor` handles updating stats if the position is different from before. + When opening this UI, we want to update stats regardless of the prior position. */ + if (!this.setCursor(0)) { + this.updateStats(); + } if (globalScene.uiTheme === UiTheme.LEGACY) { this.arrowUp.setTint(0x484848); this.arrowDown.setTint(0x484848); } - this.updateArrows(); - - this.gameStatsContainer.setVisible(true); - - this.getUi().moveTo(this.gameStatsContainer, this.getUi().length - 1); - - this.getUi().hideTooltip(); + this.getUi() + .moveTo(this.gameStatsContainer, this.getUi().length - 1) + .hideTooltip(); return true; } - updateStats(): void { - const statKeys = Object.keys(displayStats).slice(this.cursor * 2, this.cursor * 2 + 18); + /** + * Update the stat labels and values to reflect the current cursor position. + * + * @remarks + * + * Invokes each stat's {@linkcode DisplayStat.sourceFunc | sourceFunc} to obtain its value. + * Stat labels are shown as `???` if the stat is marked as hidden and its value is zero. + */ + private updateStats(): void { + const perPage = this.statsPerPage; + const columns = this.columnCount; + const statKeys = Object.keys(displayStats).slice(this.cursor * columns, this.cursor * columns + perPage); statKeys.forEach((key, s) => { const stat = displayStats[key] as DisplayStat; - const value = stat.sourceFunc!(globalScene.gameData); // TODO: is this bang correct? + const value = stat.sourceFunc?.(globalScene.gameData) ?? "-"; + const valAsInt = Number.parseInt(value); this.statLabels[s].setText( - !stat.hidden || Number.isNaN(Number.parseInt(value)) || Number.parseInt(value) - ? i18next.t(`gameStatsUiHandler:${stat.label_key}`) - : "???", + !stat.hidden || Number.isNaN(value) || valAsInt ? i18next.t(`gameStatsUiHandler:${stat.label_key}`) : "???", ); this.statValues[s].setText(value); }); - if (statKeys.length < 18) { - for (let s = statKeys.length; s < 18; s++) { - this.statLabels[s].setText(""); - this.statValues[s].setText(""); - } + for (let s = statKeys.length; s < perPage; s++) { + this.statLabels[s].setText(""); + this.statValues[s].setText(""); } } - /** - * Show arrows at the top / bottom of the page if it's possible to scroll in that direction - */ - updateArrows(): void { - const showUpArrow = this.cursor > 0; - this.arrowUp.setVisible(showUpArrow); - - const showDownArrow = this.cursor < Math.ceil((Object.keys(displayStats).length - 18) / 2); - this.arrowDown.setVisible(showDownArrow); + /** The maximum cursor position */ + private get maxCursorPos(): number { + return Math.ceil((Object.keys(displayStats).length - this.statsPerPage) / this.columnCount); } processInput(button: Button): boolean { @@ -370,45 +424,59 @@ export class GameStatsUiHandler extends UiHandler { let success = false; - if (button === Button.CANCEL) { - success = true; - globalScene.ui.revertMode(); - } else { - switch (button) { - case Button.UP: - if (this.cursor) { - success = this.setCursor(this.cursor - 1); - } - break; - case Button.DOWN: - if (this.cursor < Math.ceil((Object.keys(displayStats).length - 18) / 2)) { - success = this.setCursor(this.cursor + 1); - } - break; - } + /** The direction to move the cursor (up/down) */ + let dir: 1 | -1 = 1; + switch (button) { + case Button.CANCEL: + success = true; + globalScene.ui.revertMode(); + break; + // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional + case Button.UP: + dir = -1; + case Button.DOWN: + success = this.setCursor(this.cursor + dir); } if (success) { ui.playSelect(); + return true; } - return success; + return false; } - setCursor(cursor: number): boolean { - const ret = super.setCursor(cursor); - - if (ret) { - this.updateStats(); - this.updateArrows(); + /** + * Set the cursor to the specified position, if able and update the stats display. + * + * @remarks + * + * If `newCursor` is not between `0` and {@linkcode maxCursorPos}, or if it is the same as {@linkcode newCursor} + * then no updates happen and `false` is returned. + * + * Otherwise, updates the up/down arrow visibility and calls {@linkcode updateStats} + * + * @param newCursor - The position to set the cursor to. + * @returns Whether the cursor successfully moved to a new position + */ + override setCursor(newCursor: number): boolean { + if (newCursor < 0 || newCursor > this.maxCursorPos || this.cursor === newCursor) { + return false; } - return ret; + this.cursor = newCursor; + + this.updateStats(); + // NOTE: Do not toggle the arrows' "active" property here, as this would cause their animations to desync + this.arrowUp.setVisible(this.cursor > 0); + this.arrowDown.setVisible(this.cursor < this.maxCursorPos); + + return true; } clear() { super.clear(); - this.gameStatsContainer.setVisible(false); + this.gameStatsContainer.setVisible(false).setActive(false); } } diff --git a/test/testUtils/mocks/mocksContainer/mockSprite.ts b/test/testUtils/mocks/mocksContainer/mockSprite.ts index bea1db21629..15995a59977 100644 --- a/test/testUtils/mocks/mocksContainer/mockSprite.ts +++ b/test/testUtils/mocks/mocksContainer/mockSprite.ts @@ -154,6 +154,17 @@ export class MockSprite implements MockGameObject { return this; } + setFlipY(flip: boolean): this { + // Sets the vertical flip state of this Game Object. + this.phaserSprite.setFlipY(flip); + return this; + } + + setFlipX(flip: boolean): this { + this.phaserSprite.setFlipX(flip); + return this; + } + setCrop(x: number, y: number, width: number, height: number): this { // Sets the crop size of this Game Object. this.phaserSprite.setCrop(x, y, width, height);