503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
import Phaser from "phaser";
|
|
import { type World, type EntityId, type Stats } from "../core/types";
|
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
|
|
|
export default class GameUI extends Phaser.Scene {
|
|
// HUD
|
|
private floorText!: Phaser.GameObjects.Text;
|
|
private healthBar!: Phaser.GameObjects.Graphics;
|
|
private expBar!: Phaser.GameObjects.Graphics;
|
|
|
|
|
|
// Menu
|
|
private menuOpen = false;
|
|
private menuContainer!: Phaser.GameObjects.Container;
|
|
private menuText!: Phaser.GameObjects.Text;
|
|
private menuBg!: Phaser.GameObjects.Rectangle;
|
|
private menuButton!: Phaser.GameObjects.Container;
|
|
private mapButton!: Phaser.GameObjects.Container;
|
|
private backpackButton!: Phaser.GameObjects.Container;
|
|
|
|
// Inventory/Equipment Overlay
|
|
private inventoryOpen = false;
|
|
private invContainer!: Phaser.GameObjects.Container;
|
|
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
|
|
private backpackSlots: Phaser.GameObjects.Container[] = [];
|
|
|
|
// Death Screen
|
|
private deathContainer!: Phaser.GameObjects.Container;
|
|
private deathText!: Phaser.GameObjects.Text;
|
|
private restartButton!: Phaser.GameObjects.Container;
|
|
|
|
constructor() {
|
|
super({ key: "GameUI" });
|
|
}
|
|
|
|
create() {
|
|
this.createHud();
|
|
this.createMenu();
|
|
this.createInventoryOverlay();
|
|
this.createDeathScreen();
|
|
|
|
// Listen for updates from GameScene
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
|
|
this.updateUI(data.world, data.playerId, data.floorIndex);
|
|
});
|
|
|
|
gameScene.events.on("toggle-menu", () => this.toggleMenu());
|
|
gameScene.events.on("toggle-inventory", () => this.toggleInventory());
|
|
gameScene.events.on("close-menu", () => {
|
|
this.setMenuOpen(false);
|
|
this.setInventoryOpen(false);
|
|
});
|
|
}
|
|
|
|
private createHud() {
|
|
this.floorText = this.add.text(10, 10, "Floor 1", {
|
|
fontSize: "20px",
|
|
color: "#ffffff",
|
|
fontStyle: "bold"
|
|
}).setDepth(100);
|
|
|
|
this.healthBar = this.add.graphics().setDepth(100);
|
|
this.expBar = this.add.graphics().setDepth(100);
|
|
}
|
|
|
|
private createMenu() {
|
|
const cam = this.cameras.main;
|
|
|
|
const btnW = 90;
|
|
const btnH = 28;
|
|
|
|
// Menu Button
|
|
const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
|
|
const btnLabel = this.add.text(0, 0, "Menu", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
|
|
|
|
this.menuButton = this.add.container(0, 0, [btnBg, btnLabel]);
|
|
this.menuButton.setDepth(1000);
|
|
|
|
const placeButton = () => {
|
|
this.menuButton.setPosition(cam.width - btnW / 2 - 10, btnH / 2 + 10);
|
|
};
|
|
placeButton();
|
|
this.scale.on("resize", placeButton);
|
|
|
|
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMenu());
|
|
|
|
// Map Button (left of Menu button)
|
|
const mapBtnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
|
|
const mapBtnLabel = this.add.text(0, 0, "Map", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
|
|
|
|
this.mapButton = this.add.container(0, 0, [mapBtnBg, mapBtnLabel]);
|
|
this.mapButton.setDepth(1000);
|
|
|
|
const placeMapButton = () => {
|
|
this.mapButton.setPosition(cam.width - btnW / 2 - 10 - btnW - 5, btnH / 2 + 10);
|
|
};
|
|
placeMapButton();
|
|
this.scale.on("resize", placeMapButton);
|
|
|
|
mapBtnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMap());
|
|
|
|
// Panel (center)
|
|
const panelW = GAME_CONFIG.ui.menuPanelWidth;
|
|
const panelH = GAME_CONFIG.ui.menuPanelHeight;
|
|
|
|
this.menuBg = this.add
|
|
.rectangle(0, 0, panelW, panelH, 0x000000, 0.8)
|
|
.setStrokeStyle(1, 0xffffff, 0.9)
|
|
.setInteractive(); // capture clicks
|
|
|
|
this.menuText = this.add
|
|
.text(-panelW / 2 + 14, -panelH / 2 + 12, "", {
|
|
fontSize: "14px",
|
|
color: "#ffffff",
|
|
wordWrap: { width: panelW - 28 }
|
|
})
|
|
.setOrigin(0, 0);
|
|
|
|
this.menuContainer = this.add.container(0, 0, [this.menuBg, this.menuText]);
|
|
this.menuContainer.setDepth(1001);
|
|
|
|
const placePanel = () => {
|
|
this.menuContainer.setPosition(cam.width / 2, cam.height / 2);
|
|
};
|
|
placePanel();
|
|
this.scale.on("resize", placePanel);
|
|
|
|
// Backpack Button (Bottom Left)
|
|
const bpBtnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
|
|
const bpBtnLabel = this.add.text(0, 0, "Backpack", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
|
|
this.backpackButton = this.add.container(0, 0, [bpBtnBg, bpBtnLabel]);
|
|
this.backpackButton.setDepth(1000);
|
|
|
|
const placeBpButton = () => {
|
|
this.backpackButton.setPosition(btnW / 2 + 10, cam.height - btnH / 2 - 10);
|
|
};
|
|
placeBpButton();
|
|
this.scale.on("resize", placeBpButton);
|
|
|
|
bpBtnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleInventory());
|
|
|
|
this.setMenuOpen(false);
|
|
}
|
|
|
|
private createInventoryOverlay() {
|
|
const cam = this.cameras.main;
|
|
const panelW = 850;
|
|
const panelH = 550;
|
|
|
|
// Premium Background with Gradient
|
|
const bg = this.add.graphics();
|
|
bg.fillStyle(0x000000, 0.9);
|
|
bg.fillRect(-panelW / 2, -panelH / 2, panelW, panelH);
|
|
|
|
// Make the area interactive to capture clicks
|
|
const hitArea = new Phaser.Geom.Rectangle(-panelW / 2, -panelH / 2, panelW, panelH);
|
|
this.add.zone(0, 0, panelW, panelH).setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
|
|
|
bg.lineStyle(3, 0x443322, 1);
|
|
bg.strokeRect(-panelW / 2, -panelH / 2, panelW, panelH);
|
|
|
|
// Subtle inner border
|
|
bg.lineStyle(1, 0x887766, 0.3);
|
|
bg.strokeRect(-panelW / 2 + 5, -panelH / 2 + 5, panelW - 10, panelH - 10);
|
|
|
|
const title = this.add.text(0, -panelH / 2 + 25, "INVENTORY", {
|
|
fontSize: "28px",
|
|
color: "#d4af37",
|
|
fontStyle: "bold",
|
|
shadow: { blur: 2, color: "#000000", fill: true, offsetY: 2 }
|
|
}).setOrigin(0.5);
|
|
|
|
this.invContainer = this.add.container(0, 0, [bg, title]);
|
|
this.invContainer.setDepth(1001);
|
|
|
|
// --- Equipment Section (PoE Style) ---
|
|
const eqX = -200;
|
|
const eqY = 10;
|
|
|
|
const createSlot = (x: number, y: number, w: number, h: number, label: string, key: string) => {
|
|
const g = this.add.graphics();
|
|
// Outer border
|
|
g.lineStyle(2, 0x444444, 1);
|
|
g.strokeRect(-w / 2, -h / 2, w, h);
|
|
|
|
// Inner gradient-like background
|
|
g.fillStyle(0x1a1a1a, 1);
|
|
g.fillRect(-w / 2 + 1, -h / 2 + 1, w - 2, h - 2);
|
|
|
|
// Bottom highlight
|
|
g.lineStyle(1, 0x333333, 1);
|
|
g.lineBetween(-w / 2 + 2, h / 2 - 2, w / 2 - 2, h / 2 - 2);
|
|
|
|
const txt = this.add.text(0, 0, label, { fontSize: "11px", color: "#666666", fontStyle: "bold" }).setOrigin(0.5);
|
|
const container = this.add.container(x, y, [g, txt]);
|
|
|
|
this.equipmentSlots.set(key, container);
|
|
this.invContainer.add(container);
|
|
return container;
|
|
};
|
|
|
|
// Sizes based on PoE proportions
|
|
const sSmall = 54;
|
|
const sMed = 70;
|
|
const sLargeW = 90;
|
|
const sLargeH = 160;
|
|
|
|
// Central Column
|
|
createSlot(eqX, eqY - 140, sMed, sMed, "Head", "helmet"); // Helmet
|
|
createSlot(eqX, eqY - 20, sLargeW, 130, "Body", "bodyArmour"); // Body Armour
|
|
createSlot(eqX, eqY + 80, 100, 36, "Belt", "belt"); // Belt
|
|
|
|
// Sides (Large)
|
|
createSlot(eqX - 140, eqY - 50, sLargeW, sLargeH, "Main Hand", "mainHand"); // Main Hand
|
|
createSlot(eqX + 140, eqY - 50, sLargeW, sLargeH, "Off Hand", "offHand"); // Off Hand
|
|
|
|
// Inner Column Left (Ring)
|
|
createSlot(eqX - 80, eqY - 30, sSmall, sSmall, "Ring", "ringLeft");
|
|
|
|
// Inner Column Right (Ring)
|
|
createSlot(eqX + 80, eqY - 30, sSmall, sSmall, "Ring", "ringRight");
|
|
|
|
// Bottom Corners
|
|
createSlot(eqX - 100, eqY + 70, sMed, sMed, "Hands", "gloves");
|
|
createSlot(eqX + 100, eqY + 70, sMed, sMed, "Boots", "boots");
|
|
|
|
// --- Backpack Section (Right Side) ---
|
|
const bpX = 120;
|
|
const bpY = -panelH / 2 + 100;
|
|
const rows = 10;
|
|
const cols = 6;
|
|
const bpSlotSize = 42;
|
|
|
|
const bpTitle = this.add.text(bpX + (cols * (bpSlotSize + 4)) / 2 - 20, bpY - 40, "BACKPACK", {
|
|
fontSize: "18px",
|
|
color: "#d4af37",
|
|
fontStyle: "bold"
|
|
}).setOrigin(0.5);
|
|
this.invContainer.add(bpTitle);
|
|
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
const x = bpX + c * (bpSlotSize + 4);
|
|
const y = bpY + r * (bpSlotSize + 4);
|
|
|
|
const g = this.add.graphics();
|
|
g.lineStyle(1, 0x333333, 1);
|
|
g.strokeRect(-bpSlotSize / 2, -bpSlotSize / 2, bpSlotSize, bpSlotSize);
|
|
g.fillStyle(0x0c0c0c, 1);
|
|
g.fillRect(-bpSlotSize / 2 + 0.5, -bpSlotSize / 2 + 0.5, bpSlotSize - 1, bpSlotSize - 1);
|
|
|
|
const container = this.add.container(x, y, [g]);
|
|
this.invContainer.add(container);
|
|
this.backpackSlots.push(container);
|
|
}
|
|
}
|
|
|
|
const placeInv = () => {
|
|
this.invContainer.setPosition(cam.width / 2, cam.height / 2);
|
|
};
|
|
placeInv();
|
|
this.scale.on("resize", placeInv);
|
|
|
|
this.setInventoryOpen(false);
|
|
}
|
|
|
|
private createDeathScreen() {
|
|
const cam = this.cameras.main;
|
|
const panelW = GAME_CONFIG.ui.menuPanelWidth + 40;
|
|
const panelH = GAME_CONFIG.ui.menuPanelHeight + 60;
|
|
|
|
const bg = this.add
|
|
.rectangle(0, 0, cam.width, cam.height, 0x000000, 0.85)
|
|
.setOrigin(0)
|
|
.setInteractive();
|
|
|
|
const panel = this.add
|
|
.rectangle(cam.width / 2, cam.height / 2, panelW, panelH, 0x000000, 0.9)
|
|
.setStrokeStyle(2, 0xff3333, 1);
|
|
|
|
const title = this.add
|
|
.text(cam.width / 2, cam.height / 2 - panelH / 2 + 30, "YOU HAVE PERISHED", {
|
|
fontSize: "28px",
|
|
color: "#ff3333",
|
|
fontStyle: "bold"
|
|
})
|
|
.setOrigin(0.5);
|
|
|
|
this.deathText = this.add
|
|
.text(cam.width / 2, cam.height / 2 - 20, "", {
|
|
fontSize: "16px",
|
|
color: "#ffffff",
|
|
align: "center",
|
|
lineSpacing: 10
|
|
})
|
|
.setOrigin(0.5);
|
|
|
|
// Restart Button
|
|
const btnW = 160;
|
|
const btnH = 40;
|
|
const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x440000, 1).setStrokeStyle(2, 0xff3333, 1);
|
|
const btnLabel = this.add.text(0, 0, "NEW GAME", { fontSize: "18px", color: "#ffffff", fontStyle: "bold" }).setOrigin(0.5);
|
|
|
|
this.restartButton = this.add.container(cam.width / 2, cam.height / 2 + panelH / 2 - 50, [btnBg, btnLabel]);
|
|
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => {
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.emit("restart-game");
|
|
this.hideDeathScreen();
|
|
});
|
|
|
|
this.deathContainer = this.add.container(0, 0, [bg, panel, title, this.deathText, this.restartButton]);
|
|
this.deathContainer.setDepth(2000);
|
|
this.deathContainer.setVisible(false);
|
|
}
|
|
|
|
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
|
const lines = [
|
|
`Dungeon Floor: ${data.floor}`,
|
|
`Gold Collected: ${data.gold}`,
|
|
|
|
"",
|
|
`Experience gained: ${data.stats.exp}`,
|
|
`Final HP: 0 / ${data.stats.maxHp}`,
|
|
`Attack: ${data.stats.attack}`,
|
|
`Defense: ${data.stats.defense}`
|
|
];
|
|
this.deathText.setText(lines.join("\n"));
|
|
this.deathContainer.setVisible(true);
|
|
|
|
// Disable other UI interactions
|
|
this.menuButton.setVisible(false);
|
|
this.mapButton.setVisible(false);
|
|
}
|
|
|
|
hideDeathScreen() {
|
|
this.deathContainer.setVisible(false);
|
|
this.menuButton.setVisible(true);
|
|
this.mapButton.setVisible(true);
|
|
}
|
|
|
|
private toggleMenu() {
|
|
this.setMenuOpen(!this.menuOpen);
|
|
// Request UI update when menu is opened to populate the text
|
|
if (this.menuOpen) {
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.emit("request-ui-update");
|
|
}
|
|
}
|
|
|
|
private setMenuOpen(open: boolean) {
|
|
this.menuOpen = open;
|
|
this.menuContainer.setVisible(open);
|
|
|
|
// Notify GameScene back?
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.emit("menu-toggled", open);
|
|
}
|
|
|
|
private toggleMap() {
|
|
// Close all and toggle minimap
|
|
this.setMenuOpen(false);
|
|
this.setInventoryOpen(false);
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.emit("toggle-minimap");
|
|
}
|
|
|
|
private toggleInventory() {
|
|
this.setInventoryOpen(!this.inventoryOpen);
|
|
if (this.inventoryOpen) {
|
|
this.setMenuOpen(false);
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.emit("request-ui-update");
|
|
}
|
|
}
|
|
|
|
private setInventoryOpen(open: boolean) {
|
|
this.inventoryOpen = open;
|
|
this.invContainer.setVisible(open);
|
|
|
|
const gameScene = this.scene.get("GameScene");
|
|
gameScene.events.emit("inventory-toggled", open);
|
|
}
|
|
|
|
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
|
|
this.updateHud(world, playerId, floorIndex);
|
|
if (this.menuOpen) {
|
|
this.updateMenuText(world, playerId, floorIndex);
|
|
}
|
|
if (this.inventoryOpen) {
|
|
this.updateInventoryUI(world, playerId);
|
|
}
|
|
}
|
|
|
|
private updateInventoryUI(world: World, playerId: EntityId) {
|
|
const p = world.actors.get(playerId);
|
|
if (!p) return;
|
|
|
|
// Clear existing item icons/text from slots if needed (future refinement)
|
|
// For now we just show names or placeholders
|
|
}
|
|
|
|
private updateHud(world: World, playerId: EntityId, floorIndex: number) {
|
|
this.floorText.setText(`Floor ${floorIndex}`);
|
|
|
|
|
|
const p = world.actors.get(playerId);
|
|
if (!p || !p.stats) return;
|
|
|
|
const barX = 40;
|
|
const barY = 40;
|
|
const barW = 180;
|
|
const barH = 16;
|
|
|
|
this.healthBar.clear();
|
|
|
|
// Heart Icon
|
|
const iconX = 20;
|
|
const iconY = barY + barH / 2;
|
|
this.healthBar.fillStyle(0xff0000, 1);
|
|
// Draw simple heart
|
|
this.healthBar.fillCircle(iconX - 4, iconY - 2, 5);
|
|
this.healthBar.fillCircle(iconX + 4, iconY - 2, 5);
|
|
this.healthBar.fillTriangle(iconX - 9, iconY - 1, iconX + 9, iconY - 1, iconX, iconY + 9);
|
|
|
|
this.healthBar.fillStyle(0x444444, 1);
|
|
this.healthBar.fillRect(barX, barY, barW, barH);
|
|
|
|
|
|
const hp = Math.max(0, p.stats.hp);
|
|
const maxHp = Math.max(1, p.stats.maxHp);
|
|
const pct = Phaser.Math.Clamp(hp / maxHp, 0, 1);
|
|
const fillW = Math.floor(barW * pct);
|
|
|
|
this.healthBar.fillStyle(0xff0000, 1);
|
|
this.healthBar.fillRect(barX, barY, fillW, barH);
|
|
|
|
this.healthBar.lineStyle(2, 0xffffff, 1);
|
|
this.healthBar.strokeRect(barX, barY, barW, barH);
|
|
|
|
// EXP Bar
|
|
const expY = barY + barH + 6;
|
|
const expH = 10;
|
|
this.expBar.clear();
|
|
|
|
// EXP Icon (Star/Orb)
|
|
const expIconY = expY + expH / 2;
|
|
this.expBar.fillStyle(GAME_CONFIG.rendering.expOrbColor, 1);
|
|
this.expBar.fillCircle(iconX, expIconY, 6);
|
|
this.expBar.fillStyle(0xffffff, 0.5);
|
|
this.expBar.fillCircle(iconX - 2, expIconY - 2, 2);
|
|
|
|
this.expBar.fillStyle(0x444444, 1);
|
|
this.expBar.fillRect(barX, expY, barW, expH);
|
|
|
|
const exp = p.stats.exp;
|
|
const nextExp = Math.max(1, p.stats.expToNextLevel);
|
|
|
|
const expPct = Phaser.Math.Clamp(exp / nextExp, 0, 1);
|
|
const expFillW = Math.floor(barW * expPct);
|
|
|
|
this.expBar.fillStyle(GAME_CONFIG.rendering.expOrbColor, 1);
|
|
this.expBar.fillRect(barX, expY, expFillW, expH);
|
|
|
|
this.expBar.lineStyle(1, 0xffffff, 0.8);
|
|
this.expBar.strokeRect(barX, expY, barW, expH);
|
|
|
|
}
|
|
|
|
|
|
private updateMenuText(world: World, playerId: EntityId, _floorIndex: number) {
|
|
|
|
|
|
const p = world.actors.get(playerId);
|
|
const stats = p?.stats;
|
|
const inv = p?.inventory;
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`Level ${stats?.level ?? 1}`);
|
|
lines.push("");
|
|
lines.push("Stats");
|
|
lines.push(` HP: ${stats?.hp ?? 0}/${stats?.maxHp ?? 0}`);
|
|
lines.push(` EXP: ${stats?.exp ?? 0}/${stats?.expToNextLevel ?? 0}`);
|
|
lines.push(` Attack: ${stats?.attack ?? 0}`);
|
|
lines.push(` Defense: ${stats?.defense ?? 0}`);
|
|
lines.push(` Speed: ${p?.speed ?? 0}`);
|
|
|
|
lines.push("");
|
|
lines.push("Inventory");
|
|
lines.push(` Gold: ${inv?.gold ?? 0}`);
|
|
lines.push(` Items: ${(inv?.items?.length ?? 0) === 0 ? "(none)" : ""}`);
|
|
|
|
if (inv?.items?.length) {
|
|
for (const it of inv.items) lines.push(` - ${it}`);
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push("Hotkeys: I to toggle, Esc to close");
|
|
|
|
this.menuText.setText(lines.join("\n"));
|
|
}
|
|
}
|