diff --git a/public/Tileset.tsx b/public/Tileset.tsx index 7f536c1..f5b0984 100644 --- a/public/Tileset.tsx +++ b/public/Tileset.tsx @@ -1,4 +1,4 @@ - + diff --git a/src/classes/Assets.ts b/src/classes/Assets.ts index ee2edbe..ea3f2c5 100644 --- a/src/classes/Assets.ts +++ b/src/classes/Assets.ts @@ -24,6 +24,7 @@ export default class GameAssets { public static SwordsTexture: PIXI.Texture; public static TitleTexture: PIXI.Texture; public static BannerGemsmith: PIXI.Texture; + public static EndScreenDialog: PIXI.Texture; public static PlayIconTexture: PIXI.Texture; public static PauseIconTexture: PIXI.Texture; @@ -96,6 +97,7 @@ export default class GameAssets { this.Load('./assets/gui/frame_green.png').then((texture) => (this.GreenBackground = texture)), this.Load('./assets/gui/frame_blue.png').then((texture) => (this.BlueBackground = texture)), this.Load('./assets/gui/banner_01.png').then((texture) => (this.BannerGemsmith = texture)), + this.Load('./assets/gui/note.png').then((texture) => (this.EndScreenDialog = texture)), this.Load('./assets/gui/heart.png').then((texture) => (this.HealthTexture = texture)), this.Load('./assets/gui/money.png').then((texture) => (this.GoldTexture = texture)), this.Load('./assets/gui/wave.png').then((texture) => (this.WaveTexture = texture)), diff --git a/src/classes/Events.ts b/src/classes/Events.ts index 67be16b..581ff8e 100644 --- a/src/classes/Events.ts +++ b/src/classes/Events.ts @@ -28,3 +28,9 @@ export enum StatsEvents { export enum GemEvents { TowerPanelSelectGem = 'towerTabSelectGem', } + +export enum EndMissionDialogEvents { + NextMission = 'nextMission', + RetryMission = 'retryMission', + MainMenu = 'mainMenu', +} diff --git a/src/classes/GameUIConstants.ts b/src/classes/GameUIConstants.ts index a875166..f78bf33 100644 --- a/src/classes/GameUIConstants.ts +++ b/src/classes/GameUIConstants.ts @@ -1,8 +1,10 @@ import * as PIXI from 'pixi.js'; import { Engine } from './Bastion'; export default class GameUIConstants { - public static SidebarRect; - public static ChangeRoundButtonRect; + public static SidebarRect: PIXI.Rectangle; + public static ChangeRoundButtonRect: PIXI.Rectangle; + public static EndGameDialogRect: PIXI.Rectangle; + public static init() { GameUIConstants.SidebarRect = new PIXI.Rectangle( Engine.app.canvas.width - 360, @@ -11,5 +13,13 @@ export default class GameUIConstants { Engine.app.canvas.height ); GameUIConstants.ChangeRoundButtonRect = new PIXI.Rectangle(50, Engine.app.canvas.height - 100, 310, 100); + const endGameDialogWidth = 600; + const endGameDialogHeight = 800; + GameUIConstants.EndGameDialogRect = new PIXI.Rectangle( + (Engine.app.canvas.width - endGameDialogWidth) / 2, + (Engine.app.canvas.height - endGameDialogHeight) / 2, + endGameDialogWidth, + endGameDialogHeight + ); } } diff --git a/src/classes/game/Creep.ts b/src/classes/game/Creep.ts index af4f4e1..37e75a0 100644 --- a/src/classes/game/Creep.ts +++ b/src/classes/game/Creep.ts @@ -51,6 +51,7 @@ export default class Creep extends GameObject { // Added + 32 to center them. this.x = path[0][1] * Engine.GridCellSize + Engine.GridCellSize / 2; this.y = path[0][0] * Engine.GridCellSize + Engine.GridCellSize / 2; + // TODO: Unsubscribe from events once the scene is destroyed Engine.GameScene.events.on(CreepEvents.TakenDamage, (creepID, damage) => { if (creepID != this.id) return; this.health -= damage; diff --git a/src/classes/game/KeyboardManager.ts b/src/classes/game/KeyboardManager.ts new file mode 100644 index 0000000..37ab5db --- /dev/null +++ b/src/classes/game/KeyboardManager.ts @@ -0,0 +1,52 @@ +/** + * Handles keyboard events. + */ +class KeyboardManager { + private static listeners: Map void)[]> = new Map(); + + public static init() { + window.addEventListener('keydown', KeyboardManager.handleKeyDown); + } + + /** + * Add a callback to be called when the specified key is pressed. + * Note: Calling preventDefault() on the event will prevent other callbacks from being called. + * @param key The key to listen for. + * @param callback The callback to call when the key is pressed. + * @returns A function that can be called to remove the callback. + */ + public static onKey(key: string, callback: (event: KeyboardEvent) => void) { + if (!KeyboardManager.listeners.has(key)) { + KeyboardManager.listeners.set(key, []); + } + KeyboardManager.listeners.get(key).push(callback); + return () => KeyboardManager.offKey(key, callback); + } + + /** + * Remove a callback from the specified key. + */ + public static offKey(key: string, callback: (event: KeyboardEvent) => void) { + if (KeyboardManager.listeners.has(key)) { + const index = KeyboardManager.listeners.get(key).indexOf(callback); + if (index >= 0) { + KeyboardManager.listeners.get(key).splice(index, 1); + } + } + } + + private static handleKeyDown(event: KeyboardEvent) { + if (KeyboardManager.listeners.has(event.key)) { + console.log(`Key down: ${event.key}`); + const callbacks = KeyboardManager.listeners.get(event.key); + for (let i = callbacks.length - 1; i >= 0; i--) { + callbacks[i](event); + if (event.defaultPrevented) { + break; + } + } + } + } +} + +export default KeyboardManager; diff --git a/src/classes/game/TowerManager.ts b/src/classes/game/TowerManager.ts index d63d0e6..1e425d9 100644 --- a/src/classes/game/TowerManager.ts +++ b/src/classes/game/TowerManager.ts @@ -23,6 +23,7 @@ export default class TowerManager { }); private towers: Tower[] = []; constructor() { + // TODO: Unsubscribe from events once the scene is destroyed Engine.TowerManager = this; Engine.GameScene.events.on(GridEvents.CellMouseOver, (cell: Cell) => { if (this.isPlacingTower) { diff --git a/src/classes/gui/Button.ts b/src/classes/gui/Button.ts index 05fb20a..fcf6dd5 100644 --- a/src/classes/gui/Button.ts +++ b/src/classes/gui/Button.ts @@ -22,6 +22,10 @@ export default class Button extends GuiObject { this.buttonText.text = caption; } + getCaption(): string { + return this.caption; + } + constructor(bounds: PIXI.Rectangle, caption: string, buttonTexture: ButtonTexture, enabled: boolean = true) { super(true); if (buttonTexture == ButtonTexture.Button01) this.buttonTexture = Assets.Button01Texture; diff --git a/src/classes/gui/EndGameDialog.ts b/src/classes/gui/EndGameDialog.ts new file mode 100644 index 0000000..43e67c5 --- /dev/null +++ b/src/classes/gui/EndGameDialog.ts @@ -0,0 +1,119 @@ +import * as PIXI from 'pixi.js'; +import GuiObject from '../GuiObject'; +import GameAssets from '../Assets'; +import GameUIConstants from '../GameUIConstants'; +import Button, { ButtonTexture } from './Button'; +import { Engine } from '../Bastion'; +import { EndMissionDialogEvents } from '../Events'; +import MessageBox from './MessageBox'; +import KeyboardManager from '../game/KeyboardManager'; + +export default class EndGameDialog extends GuiObject { + private dialogSprite: PIXI.NineSliceSprite; + private dialogCaption: PIXI.Text; + private nextMissionButton: Button; + private retryButton: Button; + private mainMenuButton: Button; + private overlay: PIXI.Graphics; + private lost: boolean; + private keyboardManagerUnsubscribe: () => void; + + constructor(bounds: PIXI.Rectangle, lost: boolean) { + super(); + this.lost = lost; + // Show overlay to prevent user from interacting with the game + this.overlay = new PIXI.Graphics(); + this.overlay.rect(0, 0, bounds.width, bounds.height); + this.overlay.fill({ color: 0x000000, alpha: 0.5 }); + // Prevent interaction with the underlying scene + this.overlay.interactive = true; + this.container.addChild(this.overlay); + + this.dialogSprite = new PIXI.NineSliceSprite({ + texture: GameAssets.EndScreenDialog, + leftWidth: 50, + topHeight: 100, + rightWidth: 50, + bottomHeight: 50, + }); + this.dialogSprite.x = GameUIConstants.EndGameDialogRect.x; + this.dialogSprite.y = GameUIConstants.EndGameDialogRect.y; + this.dialogSprite.width = GameUIConstants.EndGameDialogRect.width; + this.dialogSprite.height = GameUIConstants.EndGameDialogRect.height; + this.container.addChild(this.dialogSprite); + this.dialogCaption = new PIXI.Text({ + text: lost ? 'You lost!' : 'You won!', + style: new PIXI.TextStyle({ + fill: 0xffffff, + fontSize: 36, + }), + }); + this.container.addChild(this.dialogCaption); + this.dialogCaption.anchor.set(0.5, 0.5); + this.dialogCaption.x = GameUIConstants.EndGameDialogRect.x + GameUIConstants.EndGameDialogRect.width / 2; + this.dialogCaption.y = GameUIConstants.EndGameDialogRect.y + 50; + //this.setupButtons(lost); + this.keyboardManagerUnsubscribe = KeyboardManager.onKey('Escape', (e) => { + if (e.key === 'Escape') { + this.onMainMission(); + } + }); + this.container.on('destroyed', () => { + this.keyboardManagerUnsubscribe(); + }); + } + + private setupButtons(lost: boolean) { + const buttonContainer = new PIXI.Container(); + const buttonWidth = 200; + const buttonHeight = 80; + const buttonPadding = 10; + if (lost) { + this.retryButton = new Button( + new PIXI.Rectangle(0, 0, buttonWidth, buttonHeight), + 'Retry', + ButtonTexture.Button01 + ); + this.retryButton.onClick = () => { + Engine.GameScene.events.emit(EndMissionDialogEvents.RetryMission); + }; + buttonContainer.addChild(this.retryButton.container); + } else { + this.nextMissionButton = new Button( + new PIXI.Rectangle(0, 0, buttonWidth, buttonHeight), + 'Next Mission', + ButtonTexture.Button01 + ); + this.nextMissionButton.onClick = () => { + Engine.GameScene.events.emit(EndMissionDialogEvents.NextMission); + }; + buttonContainer.addChild(this.nextMissionButton.container); + } + this.mainMenuButton = new Button( + new PIXI.Rectangle(0, buttonHeight + buttonPadding, buttonWidth, buttonHeight), + 'Main Menu', + ButtonTexture.Button01 + ); + this.mainMenuButton.onClick = this.onMainMission.bind(this); + buttonContainer.addChild(this.mainMenuButton.container); + this.container.addChild(buttonContainer); + buttonContainer.x = + GameUIConstants.EndGameDialogRect.x + + GameUIConstants.EndGameDialogRect.width / 2 - + buttonContainer.width / 2; + buttonContainer.y = + GameUIConstants.EndGameDialogRect.y + GameUIConstants.EndGameDialogRect.height - buttonContainer.height; + } + + private showingMessageBox: boolean; + + private async onMainMission() { + if (this.showingMessageBox) return; + this.showingMessageBox = true; + const result = await MessageBox.show('Are you sure you want to return to the main menu?', ['Yes', 'No']); + this.showingMessageBox = false; + if (result === 'Yes') { + Engine.GameScene.events.emit(EndMissionDialogEvents.MainMenu); + } + } +} diff --git a/src/classes/gui/MessageBox.ts b/src/classes/gui/MessageBox.ts new file mode 100644 index 0000000..3583674 --- /dev/null +++ b/src/classes/gui/MessageBox.ts @@ -0,0 +1,143 @@ +import * as PIXI from 'pixi.js'; +import GuiObject from '../GuiObject'; +import Assets from '../Assets'; +import { Engine } from '../Bastion'; +import GameAssets from '../Assets'; +import Button, { ButtonTexture } from './Button'; +import KeyboardManager from '../game/KeyboardManager'; + +export default class MessageBox extends GuiObject { + private overlay: PIXI.Graphics; + private buttonPadding = 10; + private buttons: Button[] = []; + private escapeKeyIndex: number; + private keyboardManagerUnsubscribe: () => void; + + constructor(caption: string, buttons: string[], escapeKeyIndex: number = buttons.length - 1) { + super(); + console.log(`MessageBox(caption: ${caption}, buttons: ${buttons})`); + this.escapeKeyIndex = escapeKeyIndex; + // Show overlay to prevent user from interacting with the game + this.overlay = new PIXI.Graphics(); + this.overlay.rect(0, 0, Engine.app.canvas.width, Engine.app.canvas.height); + this.overlay.fill({ color: 0x000000, alpha: 0.5 }); + // Prevent interaction with the underlying scene + this.overlay.interactive = true; + this.container.addChild(this.overlay); + + const buttonDefs = buttons.map((btn) => ({ + caption: btn, + width: btn.length * 10 + 40, + height: 60, + click: () => this.buttonClicked(btn), + })); + let buttonTotalWidth = 0; + for (const buttonDef of buttonDefs) { + if (buttonTotalWidth > 0) buttonTotalWidth += this.buttonPadding; + buttonTotalWidth += buttonDef.width; + } + const captionWidth = caption.length * 10 + 100; + let width = Math.max(buttonTotalWidth, captionWidth); + + const height = 150; + const inputContainerBounds = new PIXI.Rectangle( + Engine.app.canvas.width / 2 - width / 2, + Engine.app.canvas.height / 2 - height / 2, + width, + height + ); + + const inputContainer = new PIXI.Container(); + inputContainer.x = inputContainerBounds.x; + inputContainer.y = inputContainerBounds.y; + inputContainer.width = inputContainerBounds.width; + inputContainer.height = inputContainerBounds.height; + + const background = new PIXI.NineSliceSprite({ + texture: GameAssets.Frame04Texture, + leftWidth: 200, + topHeight: 200, + rightWidth: 200, + bottomHeight: 200, + }); + background.x = 0; + background.y = 0; + background.width = inputContainerBounds.width; + background.height = inputContainerBounds.height; + inputContainer.addChild(background); + + const text = new PIXI.Text({ + text: caption, + style: new PIXI.TextStyle({ + fill: 0xffffff, + fontSize: 24, + }), + }); + text.anchor.set(0.5, 0.5); + text.x = inputContainerBounds.width / 2; + text.y = 40; + inputContainer.addChild(text); + + let buttonXPos = inputContainerBounds.width / 2 - buttonTotalWidth / 2; + for (const buttonDef of buttonDefs) { + const button = new Button( + new PIXI.Rectangle( + buttonXPos, + inputContainerBounds.height - buttonDef.height - 20, + buttonDef.width, + buttonDef.height + ), + buttonDef.caption, + ButtonTexture.Button01 + ); + button.onClick = buttonDef.click; + this.buttons.push(button); + inputContainer.addChild(button.container); + buttonXPos += buttonDef.width + this.buttonPadding; + } + this.container.addChild(inputContainer); + this.keyboardManagerUnsubscribe = KeyboardManager.onKey('Escape', this.onKeyPress.bind(this)); + } + + /** + * Event that is triggered when the button is clicked. + */ + public onButtonClicked: (button: string) => void; + + override destroy(): void { + this.keyboardManagerUnsubscribe(); + super.destroy(); + } + + private buttonClicked(button: string) { + if (this.onButtonClicked) this.onButtonClicked(button); + this.destroy(); + } + + private onKeyPress(event: KeyboardEvent) { + // Message box is modal, so we can safely prevent the default behavior + event.preventDefault(); + if (event.key === 'Escape') { + this.onButtonClicked(this.buttons[this.escapeKeyIndex].getCaption()); + } else if (event.key === 'Enter') { + this.onButtonClicked(this.buttons[0].getCaption()); + } + } + + /** + * Shows a message box with the specified caption and buttons. + * @param caption The caption of the message box. + * @param buttons The buttons to show. + * @returns A promise that resolves with the button that was clicked. + */ + public static show(caption: string, buttons: string[], escapeKeyButtonIndex: number = 0): Promise { + return new Promise((resolve, reject) => { + const messageBox = new MessageBox(caption, buttons); + Engine.app.stage.addChild(messageBox.container); + messageBox.onButtonClicked = (button) => { + messageBox.destroy(); + resolve(button); + }; + }); + } +} diff --git a/src/classes/gui/TextInput.ts b/src/classes/gui/TextInput.ts new file mode 100644 index 0000000..80595d4 --- /dev/null +++ b/src/classes/gui/TextInput.ts @@ -0,0 +1,53 @@ +import * as PIXI from 'pixi.js'; +import GuiObject from '../GuiObject'; +import Assets from '../Assets'; +import GameAssets from '../Assets'; + +export default class TextInput extends GuiObject { + private bounds: PIXI.Rectangle; + private backgroundSprite: PIXI.NineSliceSprite; + private text: PIXI.Text; + private maxLength: number; + + constructor(bounds: PIXI.Rectangle, maxLength: number) { + super(); + this.bounds = bounds; + this.maxLength = maxLength; + this.container.x = this.bounds.x; + this.container.y = this.bounds.y; + this.container.width = this.bounds.width; + this.container.height = this.bounds.height; + this.backgroundSprite = new PIXI.NineSliceSprite({ + texture: GameAssets.Frame01Texture, + leftWidth: 20, + topHeight: 20, + rightWidth: 20, + bottomHeight: 20, + }); + this.backgroundSprite.x = 0; + this.backgroundSprite.y = 0; + this.backgroundSprite.width = this.bounds.width; + this.backgroundSprite.height = this.bounds.height; + this.container.addChild(this.backgroundSprite); + this.container.x = this.bounds.x; + this.container.y = this.bounds.y; + this.text = new PIXI.Text({ + text: '', + style: new PIXI.TextStyle({ + fill: 0xffffff, + fontSize: 16, + }), + }); + this.text.x = 10; + this.text.y = 10; + this.container.addChild(this.text); + } + + private onKeyPress(event: KeyboardEvent) { + if (event.key == 'Backspace') { + this.text.text = this.text.text.slice(0, -1); + } else if (event.key.length == 1 && this.text.text.length < this.maxLength) { + this.text.text += event.key; + } + } +} diff --git a/src/main.ts b/src/main.ts index 39659c1..7b6a584 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { GameScene } from './scenes/Game'; import { AnimationManager } from './classes/game/AnimationManager'; import NotificationManager from './classes/game/NotificationManager'; import GameUIConstants from './classes/GameUIConstants'; +import MessageBox from './classes/gui/MessageBox'; +import KeyboardManager from './classes/game/KeyboardManager'; (async () => { const app = new PIXI.Application(); @@ -47,6 +49,7 @@ import GameUIConstants from './classes/GameUIConstants'; resize(); await Assets.LoadAssets(); GameUIConstants.init(); + KeyboardManager.init(); new GameMaster(); Engine.AnimationManager = new AnimationManager(); Engine.NotificationManager = new NotificationManager(); diff --git a/src/scenes/Game.ts b/src/scenes/Game.ts index fb01b7c..2655e6d 100644 --- a/src/scenes/Game.ts +++ b/src/scenes/Game.ts @@ -4,7 +4,7 @@ import { MissionDefinition } from '../classes/Definitions'; import Creep from '../classes/game/Creep'; import { Grid } from '../classes/game/Grid'; import WaveManager from '../classes/game/WaveManager'; -import { WaveManagerEvents, CreepEvents, GemEvents } from '../classes/Events'; +import { WaveManagerEvents, CreepEvents, GemEvents, EndMissionDialogEvents } from '../classes/Events'; import Sidebar from '../classes/gui/Sidebar'; import Button, { ButtonTexture } from '../classes/gui/Button'; import Scene from './Scene'; @@ -16,6 +16,7 @@ import GameUIConstants from '../classes/GameUIConstants'; import Tooltip from '../classes/gui/Tooltip'; import TowerPanel, { VisualGemSlot } from '../classes/gui/TowerPanel'; import Gem from '../classes/game/Gem'; +import EndGameDialog from '../classes/gui/EndGameDialog'; enum RoundMode { Purchase = 0, @@ -40,6 +41,7 @@ export class GameScene extends Scene { private playerWon: boolean = false; private destroyTicker: boolean = false; private offerGemsSprite: PIXI.NineSliceSprite; + private endGameDialog: EndGameDialog; private dimGraphics: PIXI.Graphics = new PIXI.Graphics({ x: 0, y: 0, @@ -117,6 +119,9 @@ export class GameScene extends Scene { } this.sidebar.gemTab.TowerPanelSelectingGem(gem, index, tower); }); + this.events.on(EndMissionDialogEvents.MainMenu, this.onEndMissionDialogMainMenuClicked.bind(this)); + this.events.on(EndMissionDialogEvents.RetryMission, this.onEndMissionDialogRetryMissionClicked.bind(this)); + this.events.on(EndMissionDialogEvents.NextMission, this.onEndMissionDialogNextMissionClicked.bind(this)); this.ticker = new PIXI.Ticker(); this.ticker.maxFPS = 60; this.ticker.minFPS = 30; @@ -130,6 +135,7 @@ export class GameScene extends Scene { }); this.ticker.start(); } + public update(elapsedMS) { if (this.isGameOver) { if (this.destroyTicker) { @@ -242,12 +248,9 @@ export class GameScene extends Scene { } private ShowScoreScreen(lost) { - // TODO: show to player for real (see how this.OfferPlayerGems() does it) - if (lost) { - console.log('LOSE!'); - } else { - console.log('WIN!'); - } + const bounds = new PIXI.Rectangle(0, 0, Engine.app.canvas.width, Engine.app.canvas.height); + this.endGameDialog = new EndGameDialog(bounds, lost); + Engine.GameMaster.currentScene.stage.addChild(this.endGameDialog.container); } public onCreepEscaped(creep: Creep) { @@ -263,6 +266,14 @@ export class GameScene extends Scene { } } + private onEndMissionDialogMainMenuClicked() { + this.ReturnToMain(); + } + + private onEndMissionDialogRetryMissionClicked() {} + + private onEndMissionDialogNextMissionClicked() {} + public destroy(): void { super.destroy(); this.isGameOver = true;