diff --git a/src/classes/GameUIConstants.ts b/src/classes/GameUIConstants.ts index abae08f..060dff4 100644 --- a/src/classes/GameUIConstants.ts +++ b/src/classes/GameUIConstants.ts @@ -3,8 +3,6 @@ import { Engine } from './Bastion'; export default class GameUIConstants { public static SidebarRect: PIXI.Rectangle; public static ChangeRoundButtonRect: PIXI.Rectangle; - public static StandardDialogWidth: number; - public static StandardDialogHeight: number; public static MaximumPlayerNameLength = 20; public static init() { @@ -15,7 +13,5 @@ export default class GameUIConstants { Engine.app.canvas.height ); GameUIConstants.ChangeRoundButtonRect = new PIXI.Rectangle(50, Engine.app.canvas.height - 100, 310, 100); - GameUIConstants.StandardDialogWidth = 600; - GameUIConstants.StandardDialogHeight = 800; } } diff --git a/src/classes/game/HighScoreManager.ts b/src/classes/game/HighScoreManager.ts new file mode 100644 index 0000000..3015554 --- /dev/null +++ b/src/classes/game/HighScoreManager.ts @@ -0,0 +1,71 @@ +/** + * Handles the high score system. + */ +export class HighScoreManager { + private static readonly STORAGE_KEY_PREFIX = 'highscore_'; + private static readonly MAX_SCORES = 10; + + public readonly missionName: string; + private scores: PlayerScore[]; + + constructor(missionName: string) { + this.missionName = missionName; + this.scores = this.loadScores(); + this.scores.sort((a, b) => b.score - a.score || a.timestamp - b.timestamp); + } + + private loadScores(): PlayerScore[] { + const storedScores = localStorage.getItem(HighScoreManager.STORAGE_KEY_PREFIX + this.missionName); + return HighScoreManager.parseStoredScores(storedScores); + } + + private saveScores(): void { + localStorage.setItem(HighScoreManager.STORAGE_KEY_PREFIX + this.missionName, JSON.stringify(this.scores)); + } + + public addScore(playerScore: PlayerScore): void { + this.scores.push(playerScore); + this.scores.sort((a, b) => b.score - a.score); + if (this.scores.length > HighScoreManager.MAX_SCORES) { + this.scores.length = HighScoreManager.MAX_SCORES; + } + this.saveScores(); + } + + public getScores(): PlayerScore[] { + return this.scores; + } + + private static parseStoredScores(storedScores: string | null): PlayerScore[] { + if (!storedScores) { + return []; + } + try { + const parsedScores = JSON.parse(storedScores); + if ( + Array.isArray(parsedScores) && + parsedScores.every( + (score) => + typeof score.playerName === 'string' && + typeof score.score === 'number' && + typeof score.timestamp === 'number' + ) + ) { + return parsedScores.map((score) => ({ + playerName: score.playerName, + score: score.score, + timestamp: score.timestamp, + })); + } + } catch (e) { + console.error('Failed to parse stored scores:', e); + } + return []; + } +} + +export type PlayerScore = { + playerName: string; + score: number; + timestamp: number; +}; diff --git a/src/classes/game/MissionStats.ts b/src/classes/game/MissionStats.ts index f7b894c..d9074f2 100644 --- a/src/classes/game/MissionStats.ts +++ b/src/classes/game/MissionStats.ts @@ -8,14 +8,16 @@ import Gem from './Gem'; export default class MissionStats extends GameObject { private hp: number = 100; private gold: number = 0; + private goldEarned: number = 0; + private goldSpent: number = 0; + private wavesSurvived: number = 0; + private damageDealt: number = 0; + private creepsKilled: number = 0; private goldText: PIXI.Text; private healthText: PIXI.Text; private waveText: PIXI.Text; private inventory: Gem[] = []; - // TODO: implement score keeping for leaderboards. - private score: number = 0; - public getHP() { return this.hp; } @@ -147,5 +149,32 @@ export default class MissionStats extends GameObject { }); } + public getStats() { + return { + hp: this.hp, + gold: this.gold, + wavesSurvived: this.wavesSurvived, + goldEarned: this.goldEarned, + goldSpent: this.goldSpent, + score: this.calculateScore(), + }; + } + + private calculateScore() { + const uniqueGems = []; + for (const gem of this.inventory) { + if (!uniqueGems.includes(gem.definition.name)) { + uniqueGems.push(gem.definition.name); + } + } + return ( + this.damageDealt * 2 + + this.hp * 10 + + (this.goldEarned - this.goldSpent) * 3 + + this.wavesSurvived * 100 + + uniqueGems.length * 100 + ); + } + public update() {} } diff --git a/src/classes/gui/EndGameDialog.ts b/src/classes/gui/EndGameDialog.ts index 99e0e86..b0d0c52 100644 --- a/src/classes/gui/EndGameDialog.ts +++ b/src/classes/gui/EndGameDialog.ts @@ -4,6 +4,8 @@ import GameUIConstants from '../GameUIConstants'; import ModalDialogBase from './ModalDialog'; import TextInput from './TextInput'; import MessageBox from './MessageBox'; +import { HighScoreManager } from '../game/HighScoreManager'; +import MissionStats from '../game/MissionStats'; export const EndGameDialogButtons = { Confirm: 'OK', @@ -14,14 +16,18 @@ export default class EndGameDialog extends ModalDialogBase { private dialogCaption: PIXI.Text; private playerNameTextInput: TextInput; private lost: boolean; + private highScore: HighScoreManager; + private missionStats: MissionStats; - constructor(lost: boolean) { + constructor(missionName: string, missionStats: MissionStats, lost: boolean) { super( [EndGameDialogButtons.Confirm, EndGameDialogButtons.Skip], EndGameDialogButtons.Confirm, EndGameDialogButtons.Skip ); this.lost = lost; + this.highScore = new HighScoreManager(missionName); + this.missionStats = missionStats; } protected override generate(): void { @@ -31,61 +37,118 @@ export default class EndGameDialog extends ModalDialogBase { style: new PIXI.TextStyle({ fill: 0xffffff, fontSize: 36, + stroke: { color: 0x000000, width: 2 }, + dropShadow: { + color: 0x000000, + blur: 8, + distance: 0, + }, }), }); - this.dialogContainer.addChild(this.dialogCaption); this.dialogCaption.anchor.set(0.5, 0.5); this.dialogCaption.x = this.dialogContainer.width / 2; this.dialogCaption.y = 50; + this.dialogContainer.addChild(this.dialogCaption); } - protected override createDialogBackground(width: number, height: number): PIXI.Container { - const background = new PIXI.NineSliceSprite({ + protected override createDialogBackground(): PIXI.NineSliceSprite { + return new PIXI.NineSliceSprite({ texture: GameAssets.EndScreenDialog, leftWidth: 50, topHeight: 100, rightWidth: 50, bottomHeight: 50, }); - background.x = 0; - background.y = 0; - background.width = width; - background.height = height; - return background; } protected override createContent(): PIXI.Container { const container = new PIXI.Container(); - const caption = new PIXI.Text({ - text: 'Enter your name:', - style: new PIXI.TextStyle({ - fill: 0xffffff, - fontSize: 24, - }), - }); - container.addChild(caption); - this.playerNameTextInput = new TextInput( - GameUIConstants.MaximumPlayerNameLength * 20, - GameUIConstants.MaximumPlayerNameLength - ); - this.playerNameTextInput.container.y = caption.height + 10; + const lineHeight = 35; + const lblScore = this.createText('Mission details:', '#fee', true); + container.addChild(lblScore); + const stats = this.missionStats.getStats(); + const width = this.getWidth() - this.background.leftWidth - this.background.rightWidth - 20; + const labels = [ + this.createText('HP:'), + this.createText('Gold:'), + this.createText('Waves Survived:'), + this.createText('Gold Earned:'), + this.createText('Gold Spent:'), + this.createText('----'), + this.createText('Score:'), + ]; + const values = [ + this.createText(stats.hp.toString(), 'yellow'), + this.createText(stats.gold.toString(), 'yellow'), + this.createText(stats.wavesSurvived.toString(), 'yellow'), + this.createText(stats.goldEarned.toString(), 'yellow'), + this.createText(stats.goldSpent.toString(), 'yellow'), + this.createText('----', 'yellow'), + this.createText(stats.score.toString(), 'yellow'), + ]; + const valueX = 300; + for (let i = 0; i < labels.length; i++) { + if (labels[i].text === '----') { + const line = new PIXI.Graphics(); + const y = lblScore.y + lblScore.height + 10 + i * lineHeight + lineHeight / 2; + line.moveTo(10, y); + line.lineTo(width, y); + line.stroke({ color: 'yellow', width: 2 }); + container.addChild(line); + } else { + labels[i].x = 10; + labels[i].y = lblScore.y + lblScore.height + 10 + i * lineHeight; + container.addChild(labels[i]); + + values[i].x = valueX; + values[i].y = lblScore.y + lblScore.height + 10 + i * lineHeight; + container.addChild(values[i]); + } + } + const offsetY = values[values.length - 1].y + lineHeight + 80; + + const lblName = this.createText('Enter your name:'); + lblName.y = offsetY; + container.addChild(lblName); + this.playerNameTextInput = new TextInput(width, GameUIConstants.MaximumPlayerNameLength); + this.playerNameTextInput.container.y = lblName.y + lblName.height + 10; container.addChild(this.playerNameTextInput.container); return container; } override close(button?: string): void { - if (button === EndGameDialogButtons.Confirm && this.playerNameTextInput.getText().length == 0) { - MessageBox.show('Please enter your name.', ['OK']); + if (button === EndGameDialogButtons.Confirm) { + if (this.playerNameTextInput.getText().length == 0) { + MessageBox.show('Please enter your name.', ['OK']); + } else { + this.highScore.addScore({ + playerName: this.playerNameTextInput.getText(), + score: this.missionStats.getStats().score, + timestamp: Date.now(), + }); + super.close(button); + } } else { super.close(button); } } + private createText(caption: string, color: string = '#fff', bold = false): PIXI.Text { + return new PIXI.Text({ + text: caption, + style: new PIXI.TextStyle({ + fill: color, + fontSize: 24, + fontWeight: bold ? 'bold' : 'normal', + }), + }); + } + protected override getWidth(): number | undefined { - return GameUIConstants.StandardDialogWidth; + return 600; } protected override getHeight(): number | undefined { - return GameUIConstants.StandardDialogHeight; + return 800; } } diff --git a/src/classes/gui/HighScoreDialog.ts b/src/classes/gui/HighScoreDialog.ts index e07980a..082631a 100644 --- a/src/classes/gui/HighScoreDialog.ts +++ b/src/classes/gui/HighScoreDialog.ts @@ -1,8 +1,7 @@ import * as PIXI from 'pixi.js'; import GameAssets from '../Assets'; -import GameUIConstants from '../GameUIConstants'; import ModalDialogBase from './ModalDialog'; -import TextInput from './TextInput'; +import { HighScoreManager } from '../game/HighScoreManager'; export const HighScoreDialogButtons = { Retry: 'Retry', @@ -12,10 +11,9 @@ export const HighScoreDialogButtons = { export default class HighScoreDialog extends ModalDialogBase { private dialogCaption: PIXI.Text; - private playerNameTextInput: TextInput; - private lost: boolean; + private highScore: HighScoreManager; - constructor(nextMissionAvailable: boolean) { + constructor(missionName: string, nextMissionAvailable: boolean) { super( nextMissionAvailable ? [HighScoreDialogButtons.Retry, HighScoreDialogButtons.NextMission, HighScoreDialogButtons.MainMenu] @@ -23,6 +21,7 @@ export default class HighScoreDialog extends ModalDialogBase { nextMissionAvailable ? HighScoreDialogButtons.NextMission : HighScoreDialogButtons.Retry, HighScoreDialogButtons.MainMenu ); + this.highScore = new HighScoreManager(missionName); } protected override generate(): void { @@ -32,47 +31,86 @@ export default class HighScoreDialog extends ModalDialogBase { style: new PIXI.TextStyle({ fill: 0xffffff, fontSize: 36, + stroke: { color: 0x000000, width: 2 }, + dropShadow: { + color: 0x000000, + blur: 8, + distance: 0, + }, }), }); - this.dialogContainer.addChild(this.dialogCaption); this.dialogCaption.anchor.set(0.5, 0.5); this.dialogCaption.x = this.dialogContainer.width / 2; this.dialogCaption.y = 50; + this.dialogContainer.addChild(this.dialogCaption); } - protected override createDialogBackground(width: number, height: number): PIXI.Container { - const background = new PIXI.NineSliceSprite({ + protected override createDialogBackground(): PIXI.NineSliceSprite { + return new PIXI.NineSliceSprite({ texture: GameAssets.EndScreenDialog, leftWidth: 50, topHeight: 100, rightWidth: 50, bottomHeight: 50, }); - background.x = 0; - background.y = 0; - background.width = width; - background.height = height; - return background; } protected override createContent(): PIXI.Container { const container = new PIXI.Container(); - const caption = new PIXI.Text({ - text: 'Leaderboard:', - style: new PIXI.TextStyle({ - fill: 0xffffff, - fontSize: 24, - }), - }); + const caption = this.createText('Mission: ' + this.highScore.missionName, '#fee', true); container.addChild(caption); + const lineHeight = 35; + const scores = this.highScore.getScores(); + while (scores.length < 10) { + scores.push({ playerName: '---', score: 0, timestamp: 0 }); + } + + const numberTexts = [ + this.createText('#', '#fee'), + ...scores.map((_, i) => this.createText((i + 1).toString())), + ]; + const playerTexts = [ + this.createText('Player', '#fee', true), + ...scores.map((score) => this.createText(score.playerName)), + ]; + const scoreTexts = [ + this.createText('Score', '#fee', true), + ...scores.map((score) => this.createText(score.score.toString())), + ]; + const playerX = numberTexts.reduce((maxX, text) => Math.max(maxX, text.width), 0) + 20; + const scoreX = playerX + playerTexts.reduce((maxX, text) => Math.max(maxX, text.width), 0) + 20; + for (let i = 0; i < playerTexts.length; i++) { + numberTexts[i].x = 10; + numberTexts[i].y = lineHeight + 10 + i * lineHeight; + container.addChild(numberTexts[i]); + + playerTexts[i].x = playerX; + playerTexts[i].y = lineHeight + 10 + i * lineHeight; + container.addChild(playerTexts[i]); + + scoreTexts[i].x = scoreX; + scoreTexts[i].y = lineHeight + 10 + i * lineHeight; + container.addChild(scoreTexts[i]); + } return container; } + private createText(caption: string, color: string = '#fff', bold = false): PIXI.Text { + return new PIXI.Text({ + text: caption, + style: new PIXI.TextStyle({ + fill: color, + fontSize: 24, + fontWeight: bold ? 'bold' : 'normal', + }), + }); + } + protected override getWidth(): number | undefined { - return GameUIConstants.StandardDialogWidth; + return 600; } protected override getHeight(): number | undefined { - return GameUIConstants.StandardDialogHeight; + return 800; } } diff --git a/src/classes/gui/ModalDialog.ts b/src/classes/gui/ModalDialog.ts index c7a9495..8f682b4 100644 --- a/src/classes/gui/ModalDialog.ts +++ b/src/classes/gui/ModalDialog.ts @@ -8,15 +8,12 @@ import KeyboardManager from '../game/KeyboardManager'; export default abstract class ModalDialogBase extends GuiObject { protected overlay: PIXI.Graphics; - protected dialogPadding = 40; - protected contentPadding = 10; - protected buttonPadding = 10; - protected buttonAreaHeight = 40; - protected buttonHeight = 60; + protected buttonHeight = 65; protected buttonCaptions: string[]; protected buttons: Button[] = []; protected dialogContent: PIXI.Container; protected dialogContainer: PIXI.Container; + protected background: PIXI.NineSliceSprite; private generated = false; private escapeKeyButton?: string | null; @@ -43,8 +40,14 @@ export default abstract class ModalDialogBase extends GuiObject { */ public show(): Promise { this.generate(); + const dialogBounds = `x: ${Math.round(this.dialogContainer.x)}, y: ${Math.round( + this.dialogContainer.y + )}, width: ${Math.round(this.dialogContainer.width)}, height: ${Math.round(this.dialogContainer.height)}`; + const contentBounds = `x: ${Math.round(this.dialogContent.x)}, y: ${Math.round( + this.dialogContent.y + )}, width: ${Math.round(this.dialogContent.width)}, height: ${Math.round(this.dialogContent.height)}`; console.debug( - `ModalDialogBase.show(content: ${this.dialogContainer.width}x${this.dialogContainer.height}, buttons: ${this.buttonCaptions})` + `ModalDialogBase.show(dialog: ${dialogBounds}, content: ${contentBounds}, buttons: ${this.buttonCaptions})` ); return new Promise((resolve, reject) => { Engine.app.stage.addChild(this.container); @@ -69,19 +72,14 @@ export default abstract class ModalDialogBase extends GuiObject { /** * Creates dialog background. */ - protected createDialogBackground(width: number, height: number): PIXI.Container { - const background = new PIXI.NineSliceSprite({ + protected createDialogBackground(): PIXI.NineSliceSprite { + return new PIXI.NineSliceSprite({ texture: GameAssets.Frame04Texture, leftWidth: 60, topHeight: 60, rightWidth: 60, bottomHeight: 60, }); - background.x = 0; - background.y = 0; - background.width = width; - background.height = height; - return background; } /** @@ -110,23 +108,31 @@ export default abstract class ModalDialogBase extends GuiObject { // Prevent interaction with the underlying scene this.overlay.interactive = true; this.container.addChild(this.overlay); - - this.dialogContent = this.createContent(); const buttonDefs = this.buttonCaptions.map((btnCaption) => ({ caption: btnCaption, - width: btnCaption.length * 16 + 40, + width: btnCaption.length * 14 + 60, height: this.buttonHeight, click: () => this.close(btnCaption), })); + + this.background = this.createDialogBackground(); + this.dialogContent = this.createContent(); + let buttonTotalWidth = 0; for (const buttonDef of buttonDefs) { - if (buttonTotalWidth > 0) buttonTotalWidth += this.buttonPadding; + if (buttonTotalWidth > 0) buttonTotalWidth += 10; buttonTotalWidth += buttonDef.width; } - const contentWidth = this.dialogContent.width + this.contentPadding * 2; - const contentHeight = this.dialogContent.height + this.contentPadding * 2; - let width = this.getWidth() || Math.max(buttonTotalWidth, contentWidth) + this.dialogPadding * 2; - let height = this.getHeight() || contentHeight + this.buttonAreaHeight + this.dialogPadding * 2; + const buttonAreaHeight = this.buttonCaptions.length > 0 ? this.buttonHeight + 10 : 0; + + let width = + this.getWidth() || + Math.max(buttonTotalWidth, this.dialogContent.width) + + this.background.leftWidth + + this.background.rightWidth; + let height = + this.getHeight() || + this.dialogContent.height + buttonAreaHeight + this.background.topHeight + this.background.bottomHeight; const modalBounds = new PIXI.Rectangle( Engine.app.canvas.width / 2 - width / 2, Engine.app.canvas.height / 2 - height / 2, @@ -137,14 +143,24 @@ export default abstract class ModalDialogBase extends GuiObject { this.dialogContainer = new PIXI.Container(); this.dialogContainer.x = modalBounds.x; this.dialogContainer.y = modalBounds.y; + this.background.width = width; + this.background.height = height; + this.dialogContainer.addChild(this.background); - const background = this.createDialogBackground(modalBounds.width, modalBounds.height); - this.dialogContainer.addChild(background); - - if (this.dialogContent.width < modalBounds.width) + if (this.dialogContent.width < modalBounds.width) { this.dialogContent.x = modalBounds.width / 2 - this.dialogContent.width / 2; - if (this.dialogContent.height < modalBounds.height - this.buttonAreaHeight) - this.dialogContent.y = (modalBounds.height - this.buttonAreaHeight) / 2 - this.dialogContent.height / 2; + } + if ( + this.dialogContent.height < + modalBounds.height - buttonAreaHeight - this.background.topHeight - this.background.bottomHeight + ) { + this.dialogContent.y = + this.background.topHeight + + (modalBounds.height - buttonAreaHeight - this.background.topHeight - this.background.bottomHeight) / 2 - + this.dialogContent.height / 2; + } else { + this.dialogContent.y = this.background.topHeight; + } this.dialogContainer.addChild(this.dialogContent); let buttonXPos = modalBounds.width / 2 - buttonTotalWidth / 2; @@ -152,7 +168,7 @@ export default abstract class ModalDialogBase extends GuiObject { const button = new Button( new PIXI.Rectangle( buttonXPos, - modalBounds.height - this.buttonAreaHeight - this.dialogPadding, + modalBounds.height - this.buttonHeight - this.background.bottomHeight, buttonDef.width, buttonDef.height ), @@ -162,7 +178,7 @@ export default abstract class ModalDialogBase extends GuiObject { button.onClick = buttonDef.click; this.buttons.push(button); this.dialogContainer.addChild(button.container); - buttonXPos += buttonDef.width + this.buttonPadding; + buttonXPos += buttonDef.width + 10; } this.container.addChild(this.dialogContainer); } diff --git a/src/classes/gui/TextInput.ts b/src/classes/gui/TextInput.ts index 6843f94..8c95640 100644 --- a/src/classes/gui/TextInput.ts +++ b/src/classes/gui/TextInput.ts @@ -27,7 +27,7 @@ export default class TextInput extends GuiObject { this.backgroundSprite.x = 0; this.backgroundSprite.y = 0; this.backgroundSprite.width = width; - this.backgroundSprite.height = 60; + this.backgroundSprite.height = 80; this.container.addChild(this.backgroundSprite); this.text = new PIXI.Text({ text: '', @@ -36,8 +36,8 @@ export default class TextInput extends GuiObject { fontSize: 24, }), }); - this.text.x = 20; - this.text.y = 20; + this.text.x = 30; + this.text.y = 25; this.container.addChild(this.text); this.keyboardManagerUnsubscribe = KeyboardManager.onKeyPressed(this.onKeyPress.bind(this)); } diff --git a/src/scenes/Game.ts b/src/scenes/Game.ts index 05c3b7c..92d90f6 100644 --- a/src/scenes/Game.ts +++ b/src/scenes/Game.ts @@ -245,15 +245,17 @@ export class GameScene extends Scene { } private async ShowEndgameDialog(lost) { - const endGameDialog = new EndGameDialog(lost); + const endGameDialog = new EndGameDialog(this.mission.name, this.MissionStats, lost); await endGameDialog.show(); - const highScore = new HighScoreDialog(this.missionIndex + 1 < GameAssets.Missions.length); + const highScore = new HighScoreDialog(this.mission.name, this.missionIndex + 1 < GameAssets.Missions.length); const result = await highScore.show(); if (result === HighScoreDialogButtons.MainMenu) { this.ReturnToMain(); } else if (result === HighScoreDialogButtons.NextMission) { + this.destroy(); Engine.GameMaster.changeScene(new GameScene(GameAssets.Missions[this.missionIndex + 1].name)); } else if (result === HighScoreDialogButtons.Retry) { + this.destroy(); Engine.GameMaster.changeScene(new GameScene(this.mission.name)); } }