From ce68470ab1bc84df7e091836a3d78082671c6a00 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 13:24:56 +1100 Subject: [PATCH] Another refactor --- src/engine/EntityManager.ts | 98 +++ src/engine/ProgressionManager.ts | 59 ++ src/engine/simulation/simulation.ts | 68 +- src/engine/world/generator.ts | 94 ++- src/engine/world/pathfinding.ts | 13 +- src/engine/world/world-logic.ts | 9 +- src/rendering/DungeonRenderer.ts | 30 +- .../__tests__/DungeonRenderer.test.ts | 5 +- src/scenes/GameScene.ts | 94 +-- src/ui/GameUI.ts | 743 ++---------------- src/ui/components/CharacterOverlay.ts | 79 ++ src/ui/components/DeathOverlay.ts | 66 ++ src/ui/components/HudComponent.ts | 60 ++ src/ui/components/InventoryOverlay.ts | 87 ++ src/ui/components/MenuComponent.ts | 61 ++ src/ui/components/OverlayComponent.ts | 40 + .../components/PersistentButtonsComponent.ts | 48 ++ 17 files changed, 853 insertions(+), 801 deletions(-) create mode 100644 src/engine/EntityManager.ts create mode 100644 src/engine/ProgressionManager.ts create mode 100644 src/ui/components/CharacterOverlay.ts create mode 100644 src/ui/components/DeathOverlay.ts create mode 100644 src/ui/components/HudComponent.ts create mode 100644 src/ui/components/InventoryOverlay.ts create mode 100644 src/ui/components/MenuComponent.ts create mode 100644 src/ui/components/OverlayComponent.ts create mode 100644 src/ui/components/PersistentButtonsComponent.ts diff --git a/src/engine/EntityManager.ts b/src/engine/EntityManager.ts new file mode 100644 index 0000000..f647c8a --- /dev/null +++ b/src/engine/EntityManager.ts @@ -0,0 +1,98 @@ +import { type World, type EntityId, type Actor, type Vec2 } from "../core/types"; +import { idx } from "./world/world-logic"; + +export class EntityManager { + private grid: Map = new Map(); + private actors: Map; + private world: World; + + constructor(world: World) { + this.world = world; + this.actors = world.actors; + this.rebuildGrid(); + } + + rebuildGrid() { + this.grid.clear(); + for (const actor of this.actors.values()) { + this.addToGrid(actor); + } + } + + private addToGrid(actor: Actor) { + const i = idx(this.world, actor.pos.x, actor.pos.y); + if (!this.grid.has(i)) { + this.grid.set(i, []); + } + this.grid.get(i)!.push(actor.id); + } + + private removeFromGrid(actor: Actor) { + const i = idx(this.world, actor.pos.x, actor.pos.y); + const ids = this.grid.get(i); + if (ids) { + const index = ids.indexOf(actor.id); + if (index !== -1) { + ids.splice(index, 1); + } + if (ids.length === 0) { + this.grid.delete(i); + } + } + } + + moveActor(actorId: EntityId, from: Vec2, to: Vec2) { + const actor = this.actors.get(actorId); + if (!actor) return; + + // Remove from old position + const oldIdx = idx(this.world, from.x, from.y); + const ids = this.grid.get(oldIdx); + if (ids) { + const index = ids.indexOf(actorId); + if (index !== -1) ids.splice(index, 1); + if (ids.length === 0) this.grid.delete(oldIdx); + } + + // Update position + actor.pos.x = to.x; + actor.pos.y = to.y; + + // Add to new position + const newIdx = idx(this.world, to.x, to.y); + if (!this.grid.has(newIdx)) this.grid.set(newIdx, []); + this.grid.get(newIdx)!.push(actorId); + } + + addActor(actor: Actor) { + this.actors.set(actor.id, actor); + this.addToGrid(actor); + } + + removeActor(actorId: EntityId) { + const actor = this.actors.get(actorId); + if (actor) { + this.removeFromGrid(actor); + this.actors.delete(actorId); + } + } + + getActorsAt(x: number, y: number): Actor[] { + const i = idx(this.world, x, y); + const ids = this.grid.get(i); + if (!ids) return []; + return ids.map(id => this.actors.get(id)!).filter(Boolean); + } + + isOccupied(x: number, y: number, ignoreType?: string): boolean { + const actors = this.getActorsAt(x, y); + if (ignoreType) { + return actors.some(a => a.type !== ignoreType); + } + return actors.length > 0; + } + + getNextId(): EntityId { + return Math.max(0, ...this.actors.keys()) + 1; + } +} diff --git a/src/engine/ProgressionManager.ts b/src/engine/ProgressionManager.ts new file mode 100644 index 0000000..28642b6 --- /dev/null +++ b/src/engine/ProgressionManager.ts @@ -0,0 +1,59 @@ +import { type CombatantActor, type Stats } from "../core/types"; + +export class ProgressionManager { + allocateStat(player: CombatantActor, statName: string) { + if (!player.stats || player.stats.statPoints <= 0) return; + + player.stats.statPoints--; + if (statName === "strength") { + player.stats.strength++; + player.stats.maxHp += 2; + player.stats.hp += 2; + player.stats.attack += 0.2; + } else if (statName === "dexterity") { + player.stats.dexterity++; + player.speed += 1; + } else if (statName === "intelligence") { + player.stats.intelligence++; + if (player.stats.intelligence % 5 === 0) { + player.stats.defense++; + } + } + } + + allocatePassive(player: CombatantActor, nodeId: string) { + if (!player.stats || player.stats.skillPoints <= 0) return; + if (player.stats.passiveNodes.includes(nodeId)) return; + + player.stats.skillPoints--; + player.stats.passiveNodes.push(nodeId); + + // Apply bonuses + switch (nodeId) { + case "off_1": + player.stats.attack += 2; + break; + case "off_2": + player.stats.attack += 4; + break; + case "def_1": + player.stats.maxHp += 10; + player.stats.hp += 10; + break; + case "def_2": + player.stats.defense += 2; + break; + case "util_1": + player.speed += 5; + break; + case "util_2": + player.stats.expToNextLevel = Math.floor(player.stats.expToNextLevel * 0.9); + break; + } + } + + calculateStats(baseStats: Stats): Stats { + return baseStats; + } +} + diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 2b6b006..a6901ad 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -1,10 +1,12 @@ import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types"; import { isBlocked } from "../world/world-logic"; +import { findPathAStar } from "../world/pathfinding"; import { GAME_CONFIG } from "../../core/config/GameConfig"; +import { type EntityManager } from "../EntityManager"; -export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] { +export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] { const actor = w.actors.get(actorId); if (!actor) return []; @@ -12,10 +14,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve switch (action.type) { case "move": - events.push(...handleMove(w, actor, action)); + events.push(...handleMove(w, actor, action, em)); break; case "attack": - events.push(...handleAttack(w, actor, action)); + events.push(...handleAttack(w, actor, action, em)); break; case "wait": default: @@ -31,7 +33,7 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve return events; } -function handleExpCollection(w: World, player: Actor, events: SimEvent[]) { +function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) { if (player.category !== "combatant") return; const orbs = [...w.actors.values()].filter(a => @@ -53,7 +55,8 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[]) { }); checkLevelUp(player, events); - w.actors.delete(orb.id); + if (em) em.removeActor(orb.id); + else w.actors.delete(orb.id); } } @@ -86,19 +89,23 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) { } -function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] { +function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, em?: EntityManager): SimEvent[] { const from = { ...actor.pos }; const nx = actor.pos.x + action.dx; const ny = actor.pos.y + action.dy; - if (!isBlocked(w, nx, ny)) { - actor.pos.x = nx; - actor.pos.y = ny; + if (!isBlocked(w, nx, ny, em)) { + if (em) { + em.moveActor(actor.id, from, { x: nx, y: ny }); + } else { + actor.pos.x = nx; + actor.pos.y = ny; + } const to = { ...actor.pos }; const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; if (actor.category === "combatant" && actor.isPlayer) { - handleExpCollection(w, actor, events); + handleExpCollection(w, actor, events, em); } return events; @@ -108,7 +115,8 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): } -function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] { + +function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] { const target = w.actors.get(action.targetId); if (target && target.category === "combatant" && actor.category === "combatant") { const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; @@ -183,19 +191,25 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S y: target.pos.y, victimType: target.type as ActorType }); - w.actors.delete(target.id); + if (em) em.removeActor(target.id); + else w.actors.delete(target.id); // Spawn EXP Orb const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""]; const expAmount = enemyDef?.expValue || 0; - const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1; - w.actors.set(orbId, { + const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1; + + const orb: CollectibleActor = { id: orbId, category: "collectible", type: "exp_orb", pos: { ...target.pos }, - expAmount // Explicit member in CollectibleActor - }); + expAmount + }; + + if (em) em.addActor(orb); + else w.actors.set(orbId, orb); + events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y }); } @@ -210,7 +224,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S * - if adjacent to player, attack * - else step toward player using greedy Manhattan */ -export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor): Action { +export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): Action { const dx = player.pos.x - enemy.pos.x; const dy = player.pos.y - enemy.pos.y; const dist = Math.abs(dx) + Math.abs(dy); @@ -219,7 +233,21 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba return { type: "attack", targetId: player.id }; } + // Use A* for smarter pathfinding + const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map + const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { ignoreBlockedTarget: true, ignoreSeen: true, em }); + + + if (path.length >= 2) { + const next = path[1]; + const adx = next.x - enemy.pos.x; + const ady = next.y - enemy.pos.y; + return { type: "move", dx: adx, dy: ady }; + } + + // Fallback to greedy if no path found const options: { dx: number; dy: number }[] = []; + if (Math.abs(dx) >= Math.abs(dy)) { options.push({ dx: Math.sign(dx), dy: 0 }); options.push({ dx: 0, dy: Math.sign(dy) }); @@ -243,7 +271,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba * Energy/speed scheduler: runs until it's the player's turn and the game needs input. * Returns enemy events accumulated along the way. */ -export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } { +export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } { const player = w.actors.get(playerId) as CombatantActor; if (!player || player.category !== "combatant") throw new Error("Player missing or invalid"); @@ -269,8 +297,8 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla return { awaitingPlayerId: actor.id, events }; } - const action = decideEnemyAction(w, actor, player); - events.push(...applyAction(w, actor.id, action)); + const action = decideEnemyAction(w, actor, player, em); + events.push(...applyAction(w, actor.id, action, em)); // Check if player was killed by this action if (!w.actors.has(playerId)) { diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index fd512a3..9509396 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -214,54 +214,68 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor; const enemyTypes = Object.keys(GAME_CONFIG.enemies); + const occupiedPositions = new Set(); - for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) { + for (let i = 0; i < numEnemies; i++) { + // Pick a random room (not the starting room 0) const roomIdx = 1 + Math.floor(random() * (rooms.length - 1)); const room = rooms[roomIdx]; - const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2)); - const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2)); - - const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies; - const enemyDef = GAME_CONFIG.enemies[type]; + // Try to find an empty spot in the room + for (let attempts = 0; attempts < 5; attempts++) { - const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor; - const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors; - - actors.set(enemyId, { - id: enemyId, - category: "combatant", - isPlayer: false, - type, - pos: { x: enemyX, y: enemyY }, - speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)), - energy: 0, - stats: { - maxHp: scaledHp + Math.floor(random() * 4), - hp: scaledHp + Math.floor(random() * 4), - attack: scaledAttack + Math.floor(random() * 2), - defense: enemyDef.baseDefense, - level: 0, - exp: 0, - expToNextLevel: 0, - statPoints: 0, - skillPoints: 0, - strength: 0, - dexterity: 0, - intelligence: 0, - critChance: 0, - critMultiplier: 100, - accuracy: 80, - lifesteal: 0, - evasion: 0, - blockChance: 0, - luck: 0, - passiveNodes: [] + const ex = room.x + 1 + Math.floor(random() * (room.width - 2)); + const ey = room.y + 1 + Math.floor(random() * (room.height - 2)); + const k = `${ex},${ey}`; + + if (!occupiedPositions.has(k)) { + const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies; + const enemyDef = GAME_CONFIG.enemies[type]; + + const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor; + const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors; + + actors.set(enemyId, { + id: enemyId, + category: "combatant", + isPlayer: false, + type, + pos: { x: ex, y: ey }, + speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)), + energy: 0, + stats: { + maxHp: scaledHp + Math.floor(random() * 4), + hp: scaledHp + Math.floor(random() * 4), + attack: scaledAttack + Math.floor(random() * 2), + defense: enemyDef.baseDefense, + level: 0, + exp: 0, + expToNextLevel: 0, + statPoints: 0, + skillPoints: 0, + strength: 0, + dexterity: 0, + intelligence: 0, + critChance: 0, + critMultiplier: 100, + accuracy: 80, + lifesteal: 0, + evasion: 0, + blockChance: 0, + luck: 0, + passiveNodes: [] + } + }); + + occupiedPositions.add(k); + enemyId++; + + break; } - }); - enemyId++; + } } } + export const makeTestWorld = generateWorld; diff --git a/src/engine/world/pathfinding.ts b/src/engine/world/pathfinding.ts index 073eb43..1bf10e3 100644 --- a/src/engine/world/pathfinding.ts +++ b/src/engine/world/pathfinding.ts @@ -2,6 +2,7 @@ import type { World, Vec2 } from "../../core/types"; import { key } from "../../core/utils"; import { manhattan } from "../../core/math"; import { inBounds, isWall, isBlocked, idx } from "./world-logic"; +import { type EntityManager } from "../EntityManager"; /** * Simple 4-dir A* pathfinding. @@ -11,14 +12,14 @@ import { inBounds, isWall, isBlocked, idx } from "./world-logic"; * - You cannot path THROUGH unseen tiles. * - You cannot path TO an unseen target tile. */ -export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean } = {}): Vec2[] { +export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}): Vec2[] { if (!inBounds(w, end.x, end.y)) return []; if (isWall(w, end.x, end.y)) return []; // If not ignoring target block, fail if blocked - if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return []; + if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return []; - if (seen[idx(w, end.x, end.y)] !== 1) return []; + if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return []; const open: Vec2[] = [start]; const cameFrom = new Map(); @@ -76,12 +77,12 @@ export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2 if (!inBounds(w, nx, ny)) continue; if (isWall(w, nx, ny)) continue; - // Exploration rule: cannot path through unseen (except start) - if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue; + // Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set) + if (!options.ignoreSeen && !(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue; // Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block) const isTarget = nx === end.x && ny === end.y; - if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny)) continue; + if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny, options.em)) continue; const nK = key(nx, ny); const tentativeG = (gScore.get(currentK) ?? Infinity) + 1; diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index 3f8150a..b5a7cba 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -1,6 +1,8 @@ import type { World, EntityId } from "../../core/types"; import { GAME_CONFIG } from "../../core/config/GameConfig"; +import { type EntityManager } from "../EntityManager"; + export function inBounds(w: World, x: number, y: number): boolean { return x >= 0 && y >= 0 && x < w.width && y < w.height; } @@ -14,10 +16,14 @@ export function isWall(w: World, x: number, y: number): boolean { return tile === GAME_CONFIG.terrain.wall || tile === GAME_CONFIG.terrain.wallDeco; } -export function isBlocked(w: World, x: number, y: number): boolean { +export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean { if (!inBounds(w, x, y)) return true; if (isWall(w, x, y)) return true; + if (em) { + return em.isOccupied(x, y, "exp_orb"); + } + for (const a of w.actors.values()) { if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true; } @@ -25,6 +31,7 @@ export function isBlocked(w: World, x: number, y: number): boolean { } + export function isPlayerOnExit(w: World, playerId: EntityId): boolean { const p = w.actors.get(playerId); if (!p) return false; diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index 0aadc8a..1c1d146 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -188,12 +188,25 @@ export class DungeonRenderer { if (a.category === "combatant") { if (a.isPlayer) { if (this.playerSprite) { - this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); + const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2; + const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2; + + if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) { + this.scene.tweens.add({ + targets: this.playerSprite, + x: tx, + y: ty, + duration: 120, + ease: 'Quad.easeOut', + overwrite: true + }); + } this.playerSprite.setVisible(true); } continue; } + if (!isVis) continue; activeEnemyIds.add(a.id); @@ -207,8 +220,21 @@ export class DungeonRenderer { this.enemySprites.set(a.id, sprite); } - sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); + const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2; + const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2; + + if (sprite.x !== tx || sprite.y !== ty) { + this.scene.tweens.add({ + targets: sprite, + x: tx, + y: ty, + duration: 120, + ease: 'Quad.easeOut', + overwrite: true + }); + } sprite.setVisible(true); + } else if (a.category === "collectible") { if (a.type === "exp_orb") { if (!isVis) continue; diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index b1db59d..e76ab33 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -106,9 +106,12 @@ describe('DungeonRenderer', () => { destroy: vi.fn(), }), }, - + tweens: { + add: vi.fn(), + }, }; + mockWorld = { width: 10, height: 10, diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index f2616d7..b7bc457 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -15,6 +15,8 @@ import { generateWorld } from "../engine/world/generator"; import { DungeonRenderer } from "../rendering/DungeonRenderer"; import { GAME_CONFIG } from "../core/config/GameConfig"; +import { EntityManager } from "../engine/EntityManager"; +import { ProgressionManager } from "../engine/ProgressionManager"; export class GameScene extends Phaser.Scene { private world!: World; @@ -38,6 +40,9 @@ export class GameScene extends Phaser.Scene { private isInventoryOpen = false; private isCharacterOpen = false; + private entityManager!: EntityManager; + private progressionManager: ProgressionManager = new ProgressionManager(); + constructor() { super("GameScene"); } @@ -121,13 +126,22 @@ export class GameScene extends Phaser.Scene { }); this.events.on("allocate-stat", (statName: string) => { - this.allocateStat(statName); + const player = this.world.actors.get(this.playerId) as CombatantActor; + if (player) { + this.progressionManager.allocateStat(player, statName); + this.emitUIUpdate(); + } }); this.events.on("allocate-passive", (nodeId: string) => { - this.allocatePassive(nodeId); + const player = this.world.actors.get(this.playerId) as CombatantActor; + if (player) { + this.progressionManager.allocatePassive(player, nodeId); + this.emitUIUpdate(); + } }); + // Mouse click -> compute path (only during player turn, and not while menu/minimap is open) this.input.on("pointerdown", (p: Phaser.Input.Pointer) => { if (!this.awaitingPlayer) return; @@ -146,14 +160,15 @@ export class GameScene extends Phaser.Scene { a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer ); - const player = this.world.actors.get(this.playerId) as CombatantActor; - const path = findPathAStar( - this.world, - this.dungeonRenderer.seenArray, - { ...player.pos }, - { x: tx, y: ty }, - { ignoreBlockedTarget: isEnemy } - ); + const player = this.world.actors.get(this.playerId) as CombatantActor; + const path = findPathAStar( + this.world, + this.dungeonRenderer.seenArray, + { ...player.pos }, + { x: tx, y: ty }, + { ignoreBlockedTarget: isEnemy } + ); + if (path.length >= 2) this.playerPath = path; this.dungeonRenderer.render(this.playerPath); @@ -176,7 +191,7 @@ export class GameScene extends Phaser.Scene { return; } - if (isBlocked(this.world, next.x, next.y)) { + if (isBlocked(this.world, next.x, next.y, this.entityManager)) { // Check if it's an enemy at 'next' const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer @@ -242,10 +257,11 @@ export class GameScene extends Phaser.Scene { private commitPlayerAction(action: Action) { this.awaitingPlayer = false; - const playerEvents = applyAction(this.world, this.playerId, action); - const enemyStep = stepUntilPlayerTurn(this.world, this.playerId); + const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager); + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; + // Process events for visual fx const allEvents = [...playerEvents, ...enemyStep.events]; for (const ev of allEvents) { @@ -306,6 +322,8 @@ export class GameScene extends Phaser.Scene { const { world, playerId } = generateWorld(floor, this.runState); this.world = world; this.playerId = playerId; + this.entityManager = new EntityManager(this.world); + // Reset transient state this.playerPath = []; @@ -318,9 +336,10 @@ export class GameScene extends Phaser.Scene { this.dungeonRenderer.initializeFloor(this.world); // Step until player turn - const enemyStep = stepUntilPlayerTurn(this.world, this.playerId); + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; + this.dungeonRenderer.computeFov(this.playerId); this.centerCameraOnPlayer(); this.dungeonRenderer.render(this.playerPath); @@ -355,50 +374,5 @@ export class GameScene extends Phaser.Scene { ); } - private allocateStat(statName: string) { - const p = this.world.actors.get(this.playerId) as CombatantActor; - if (!p || p.category !== "combatant" || !p.stats || p.stats.statPoints <= 0) return; - - p.stats.statPoints--; - if (statName === "strength") { - p.stats.strength++; - p.stats.maxHp += 2; - p.stats.hp += 2; - p.stats.attack += 0.2; // Small boost per Str - } else if (statName === "dexterity") { - p.stats.dexterity++; - p.speed += 1; - } else if (statName === "intelligence") { - p.stats.intelligence++; - // Maybe defense every 5 points? - if (p.stats.intelligence % 5 === 0) { - p.stats.defense++; - } - } - - this.emitUIUpdate(); - } - - private allocatePassive(nodeId: string) { - const p = this.world.actors.get(this.playerId) as CombatantActor; - if (!p || p.category !== "combatant" || !p.stats || p.stats.skillPoints <= 0) return; - - if (p.stats.passiveNodes.includes(nodeId)) return; - - p.stats.skillPoints--; - p.stats.passiveNodes.push(nodeId); - - // Apply bonuses - if (nodeId === "off_1") p.stats.attack += 2; - else if (nodeId === "off_2") p.stats.attack += 4; - else if (nodeId === "def_1") { - p.stats.maxHp += 10; - p.stats.hp += 10; - } - else if (nodeId === "def_2") p.stats.defense += 2; - else if (nodeId === "util_1") p.speed += 5; - else if (nodeId === "util_2") p.stats.expToNextLevel = Math.floor(p.stats.expToNextLevel * 0.9); - - this.emitUIUpdate(); - } } + diff --git a/src/ui/GameUI.ts b/src/ui/GameUI.ts index ee65417..7e2ef0e 100644 --- a/src/ui/GameUI.ts +++ b/src/ui/GameUI.ts @@ -1,699 +1,100 @@ import Phaser from "phaser"; -import { type World, type EntityId, type Stats, type CombatantActor } from "../core/types"; -import { GAME_CONFIG } from "../core/config/GameConfig"; +import { type World, type EntityId, type CombatantActor, type Stats } from "../core/types"; +import { HudComponent } from "./components/HudComponent"; +import { MenuComponent } from "./components/MenuComponent"; +import { InventoryOverlay } from "./components/InventoryOverlay"; +import { CharacterOverlay } from "./components/CharacterOverlay"; +import { DeathOverlay } from "./components/DeathOverlay"; +import { PersistentButtonsComponent } from "./components/PersistentButtonsComponent"; 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; - private characterButton!: Phaser.GameObjects.Container; - - // Inventory/Equipment Overlay - private inventoryOpen = false; - private invContainer!: Phaser.GameObjects.Container; - private equipmentSlots: Map = new Map(); - private backpackSlots: Phaser.GameObjects.Container[] = []; - - // Character Overlay - private characterOpen = false; - private charContainer!: Phaser.GameObjects.Container; - private attrText!: Phaser.GameObjects.Text; - private skillPointsText!: Phaser.GameObjects.Text; - private statPointsText!: Phaser.GameObjects.Text; - private charStatsText!: Phaser.GameObjects.Text; - - // Death Screen - private deathContainer!: Phaser.GameObjects.Container; - private deathText!: Phaser.GameObjects.Text; - private restartButton!: Phaser.GameObjects.Container; + private hud: HudComponent; + private menu: MenuComponent; + private inventory: InventoryOverlay; + private character: CharacterOverlay; + private death: DeathOverlay; + private persistentButtons: PersistentButtonsComponent; constructor() { super({ key: "GameUI" }); + this.hud = new HudComponent(this); + this.menu = new MenuComponent(this); + this.inventory = new InventoryOverlay(this); + this.character = new CharacterOverlay(this); + this.death = new DeathOverlay(this); + this.persistentButtons = new PersistentButtonsComponent(this); } - create() { - this.createHud(); - this.createMenu(); - this.createInventoryOverlay(); - this.createCharacterOverlay(); - this.createDeathScreen(); - // Listen for updates from GameScene + create() { + this.hud.create(); + this.menu.create(); + this.inventory.create(); + this.character.create(); + this.death.create(); + this.persistentButtons.create(); + const gameScene = this.scene.get("GameScene"); + + + // Listen for updates from 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("toggle-character", () => this.toggleCharacter()); + gameScene.events.on("toggle-menu", () => { + this.menu.toggle(); + this.emitMenuStates(); + }); + + gameScene.events.on("toggle-inventory", () => { + const open = this.inventory.toggle(); + if (open) { + this.menu.setVisible(false); + this.character.setVisible(false); + } + this.emitMenuStates(); + }); + + gameScene.events.on("toggle-character", () => { + const open = this.character.toggle(); + if (open) { + this.menu.setVisible(false); + this.inventory.setVisible(false); + } + this.emitMenuStates(); + }); + gameScene.events.on("close-menu", () => { - this.setMenuOpen(false); - this.setInventoryOpen(false); - this.setCharacterOpen(false); + this.menu.setVisible(false); + this.inventory.setVisible(false); + this.character.setVisible(false); + this.emitMenuStates(); + }); + + gameScene.events.on("restart-game", () => { + this.death.hide(); }); } - 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 emitMenuStates() { + const gameScene = this.scene.get("GameScene"); + gameScene.events.emit("menu-toggled", this.menu.isOpen); + gameScene.events.emit("inventory-toggled", this.inventory.isOpen); + gameScene.events.emit("character-toggled", this.character.isOpen); } - 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()); - - // Character Button (Right of Backpack) - const charBtnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8); - const charBtnLabel = this.add.text(0, 0, "Character", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5); - this.characterButton = this.add.container(0, 0, [charBtnBg, charBtnLabel]); - this.characterButton.setDepth(1000); - - const placeCharButton = () => { - this.characterButton.setPosition(btnW / 2 + 10 + btnW + 5, cam.height - btnH / 2 - 10); - }; - placeCharButton(); - this.scale.on("resize", placeCharButton); - - charBtnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleCharacter()); - - 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 toggleCharacter() { - this.setCharacterOpen(!this.characterOpen); - if (this.characterOpen) { - this.setMenuOpen(false); - this.setInventoryOpen(false); - const gameScene = this.scene.get("GameScene"); - gameScene.events.emit("request-ui-update"); - } - } - - private setCharacterOpen(open: boolean) { - this.characterOpen = open; - this.charContainer.setVisible(open); - - const gameScene = this.scene.get("GameScene"); - gameScene.events.emit("character-toggled", open); - } - - private createCharacterOverlay() { - const cam = this.cameras.main; - const panelW = 850; - const panelH = 550; - - const bg = this.add.graphics(); - bg.fillStyle(0x000000, 0.9); - bg.fillRect(-panelW / 2, -panelH / 2, panelW, panelH); - - // 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); - - 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, "CHARACTER", { - fontSize: "28px", - color: "#d4af37", - fontStyle: "bold", - shadow: { blur: 2, color: "#000000", fill: true, offsetY: 2 } - }).setOrigin(0.5); - - this.charContainer = this.add.container(0, 0, [bg, title]); - this.charContainer.setDepth(1001); - - // --- Attributes Section --- - const attrX = -300; - const attrY = -145; - const treeX = 50; - const treeY = 0; - - const attrTitle = this.add.text(attrX, attrY - 50, "ATTRIBUTES", { fontSize: "20px", color: "#d4af37", fontStyle: "bold" }).setOrigin(0.5); - this.charContainer.add(attrTitle); - - this.attrText = this.add.text(attrX - 20, attrY + 30, "", { fontSize: "16px", color: "#ffffff", lineSpacing: 40 }).setOrigin(1, 0.5); - this.charContainer.add(this.attrText); - - // Stat allocation buttons - const statsNames = ["strength", "dexterity", "intelligence"]; - statsNames.forEach((name, i) => { - const btn = this.add.text(attrX + 50, attrY - 25 + i * 56, "[ + ]", { fontSize: "16px", color: "#00ff00" }).setOrigin(0, 0.5); - btn.setInteractive({ useHandCursor: true }).on("pointerdown", () => { - const gameScene = this.scene.get("GameScene"); - gameScene.events.emit("allocate-stat", name); - }); - this.charContainer.add(btn); - }); - - this.statPointsText = this.add.text(attrX, attrY + 150, "Stat Points: 0", { fontSize: "16px", color: "#d4af37" }).setOrigin(0.5); - this.charContainer.add(this.statPointsText); - - this.skillPointsText = this.add.text(treeX, panelH / 2 - 40, "Skill Points: 0", { fontSize: "20px", color: "#d4af37", fontStyle: "bold" }).setOrigin(0.5); - this.charContainer.add(this.skillPointsText); - - // Derived Stats - this.charStatsText = this.add.text(-attrX, 0, "", { fontSize: "14px", color: "#ffffff", lineSpacing: 10 }).setOrigin(0.5); - this.charContainer.add(this.charStatsText); - - // --- Skill Tree Section --- - const treeTitle = this.add.text(treeX, -panelH / 2 + 80, "PASSIVE SKILL TREE", { fontSize: "20px", color: "#d4af37", fontStyle: "bold" }).setOrigin(0.5); - this.charContainer.add(treeTitle); - - // Simple Grid for Tree - const nodes = [ - { id: "off_1", label: "Martial Arts", x: treeX - 100, y: treeY - 100, color: 0xff4444 }, - { id: "off_2", label: "Brutality", x: treeX - 100, y: treeY + 100, color: 0xcc0000 }, - { id: "def_1", label: "Thick Skin", x: treeX + 100, y: treeY - 100, color: 0x44ff44 }, - { id: "def_2", label: "Juggernaut", x: treeX + 100, y: treeY + 100, color: 0x00cc00 }, - { id: "util_1", label: "Fleetfoot", x: treeX, y: treeY - 150, color: 0x4444ff }, - { id: "util_2", label: "Cunning", x: treeX, y: treeY + 150, color: 0x0000cc }, - ]; - - // Connections - const connections = [ - ["off_1", "off_2"], ["def_1", "def_2"], - ["util_1", "off_1"], ["util_1", "def_1"], - ["util_2", "off_2"], ["util_2", "def_2"] - ]; - - const treeLines = this.add.graphics(); - treeLines.lineStyle(2, 0x333333, 1); - - connections.forEach(conn => { - const n1 = nodes.find(n => n.id === conn[0])!; - const n2 = nodes.find(n => n.id === conn[1])!; - treeLines.lineBetween(n1.x, n1.y, n2.x, n2.y); - }); - this.charContainer.add(treeLines); - treeLines.setDepth(-1); // Behind nodes - - nodes.forEach(n => { - const circle = this.add.circle(n.x, n.y, 25, 0x1a1a1a).setStrokeStyle(2, n.color); - const label = this.add.text(n.x, n.y + 35, n.label, { fontSize: "12px", color: "#ffffff" }).setOrigin(0.5); - - circle.setInteractive({ useHandCursor: true }).on("pointerdown", () => { - const gameScene = this.scene.get("GameScene"); - gameScene.events.emit("allocate-passive", n.id); - }); - - this.charContainer.add([circle, label]); - }); - - const placeChar = () => { - this.charContainer.setPosition(cam.width / 2, cam.height / 2); - }; - placeChar(); - this.scale.on("resize", placeChar); - - this.setCharacterOpen(false); + this.death.show(data); } 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); - } - if (this.characterOpen) { - this.updateCharacterUI(world, playerId); - } - } + const player = world.actors.get(playerId) as CombatantActor; + if (!player) return; - private updateCharacterUI(world: World, playerId: EntityId) { - const p = world.actors.get(playerId) as CombatantActor; - if (!p || p.category !== "combatant" || !p.stats) return; - - const s = p.stats; - this.attrText.setText(`STR: ${s.strength}\nDEX: ${s.dexterity}\nINT: ${s.intelligence}`); - this.statPointsText.setText(`Unspent Points: ${s.statPoints}`); - this.skillPointsText.setText(`PASSIVE SKILL POINTS: ${s.skillPoints}`); - - const statsLines = [ - "SECONDARY STATS", - "", - `Max HP: ${s.maxHp}`, - `Attack: ${s.attack}`, - `Defense: ${s.defense}`, - `Speed: ${p.speed}`, - "", - `Accuracy: ${s.accuracy}%`, - `Crit Chance: ${s.critChance}%`, - `Crit Mult: ${s.critMultiplier}%`, - `Evasion: ${s.evasion}%`, - `Block: ${s.blockChance}%`, - `Lifesteal: ${s.lifesteal}%`, - `Luck: ${s.luck}`, - "", - `Passive Nodes: ${s.passiveNodes.length > 0 ? s.passiveNodes.join(", ") : "(none)"}` - ]; - this.charStatsText.setText(statsLines.join("\n")); - } - - private updateInventoryUI(world: World, playerId: EntityId) { - const p = world.actors.get(playerId) as CombatantActor; - if (!p || p.category !== "combatant") 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) as CombatantActor; - if (!p || p.category !== "combatant" || !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) as CombatantActor; - if (!p || p.category !== "combatant") return; - 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(` Crit: ${stats?.critChance ?? 0}%`); - lines.push(` Crit x: ${stats?.critMultiplier ?? 0}%`); - lines.push(` Accuracy: ${stats?.accuracy ?? 0}%`); - lines.push(` Evasion: ${stats?.evasion ?? 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")); + this.hud.update(player.stats, floorIndex); + this.inventory.update(player); + this.character.update(player); } } diff --git a/src/ui/components/CharacterOverlay.ts b/src/ui/components/CharacterOverlay.ts new file mode 100644 index 0000000..c38abd0 --- /dev/null +++ b/src/ui/components/CharacterOverlay.ts @@ -0,0 +1,79 @@ +import Phaser from "phaser"; +import { OverlayComponent } from "./OverlayComponent"; +import { type CombatantActor } from "../../core/types"; + +export class CharacterOverlay extends OverlayComponent { + private attrText!: Phaser.GameObjects.Text; + private statPointsText!: Phaser.GameObjects.Text; + private skillPointsText!: Phaser.GameObjects.Text; + private secondaryStatsText!: Phaser.GameObjects.Text; + + protected setupContent() { + const panelH = 500; + + const title = this.scene.add.text(0, -panelH / 2 + 25, "CHARACTER", { + fontSize: "28px", + color: "#d4af37", + fontStyle: "bold" + }).setOrigin(0.5); + this.container.add(title); + + this.createAttributesSection(); + this.createSecondaryStatsSection(); + this.createPassiveTreePreview(); + } + + private createAttributesSection() { + const attrX = -200; + const attrY = -50; + + this.attrText = this.scene.add.text(attrX, attrY, "", { fontSize: "16px", color: "#ffffff", lineSpacing: 20 }).setOrigin(0.5); + this.container.add(this.attrText); + + const statsNames = ["strength", "dexterity", "intelligence"]; + statsNames.forEach((name, i) => { + const btn = this.scene.add.text(attrX + 80, attrY - 20 + i * 40, "[ + ]", { fontSize: "16px", color: "#00ff00" }).setOrigin(0, 0.5); + btn.setInteractive({ useHandCursor: true }).on("pointerdown", () => { + this.scene.events.emit("allocate-stat", name); + }); + this.container.add(btn); + }); + + this.statPointsText = this.scene.add.text(attrX, attrY + 100, "Stat Points: 0", { fontSize: "16px", color: "#d4af37" }).setOrigin(0.5); + this.container.add(this.statPointsText); + } + + private createSecondaryStatsSection() { + const x = 200; + const y = 0; + this.secondaryStatsText = this.scene.add.text(x, y, "", { fontSize: "14px", color: "#ffffff", lineSpacing: 8 }).setOrigin(0.5); + this.container.add(this.secondaryStatsText); + } + + private createPassiveTreePreview() { + // Simplified tree for now + this.skillPointsText = this.scene.add.text(0, 200, "Skill Points: 0", { fontSize: "18px", color: "#d4af37", fontStyle: "bold" }).setOrigin(0.5); + this.container.add(this.skillPointsText); + } + + update(player: CombatantActor) { + const s = player.stats; + if (!s) return; + + this.attrText.setText(`STR: ${s.strength}\nDEX: ${s.dexterity}\nINT: ${s.intelligence}`); + this.statPointsText.setText(`Unspent: ${s.statPoints}`); + this.skillPointsText.setText(`Skill Points: ${s.skillPoints}`); + + const lines = [ + "SECONDARY STATS", + `Attack: ${s.attack}`, + `Defense: ${s.defense}`, + `Speed: ${player.speed}`, + `Crit: ${s.critChance}%`, + `Accuracy: ${s.accuracy}%`, + `Evasion: ${s.evasion}%`, + `Block: ${s.blockChance}%`, + ]; + this.secondaryStatsText.setText(lines.join("\n")); + } +} diff --git a/src/ui/components/DeathOverlay.ts b/src/ui/components/DeathOverlay.ts new file mode 100644 index 0000000..f132a6b --- /dev/null +++ b/src/ui/components/DeathOverlay.ts @@ -0,0 +1,66 @@ +import Phaser from "phaser"; +import { type Stats } from "../../core/types"; + +export class DeathOverlay { + private scene: Phaser.Scene; + private container!: Phaser.GameObjects.Container; + private deathText!: Phaser.GameObjects.Text; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + create() { + const { width, height } = this.scene.scale; + this.container = this.scene.add.container(width / 2, height / 2); + this.container.setScrollFactor(0).setDepth(3000).setVisible(false); + + const bg = this.scene.add.rectangle(0, 0, width, height, 0x000000, 0.85); + this.container.add(bg); + + const panel = this.scene.add.rectangle(0, 0, 400, 500, 0x000000, 0.9); + panel.setStrokeStyle(4, 0xff0000); + this.container.add(panel); + + const title = this.scene.add.text(0, -200, "YOU DIED", { + fontSize: "48px", + color: "#ff0000", + fontStyle: "bold" + }).setOrigin(0.5); + this.container.add(title); + + this.deathText = this.scene.add.text(0, -50, "", { + fontSize: "20px", + color: "#ffffff", + align: "center", + lineSpacing: 10 + }).setOrigin(0.5); + this.container.add(this.deathText); + + const restartBtn = this.scene.add.text(0, 180, "NEW GAME", { + fontSize: "24px", + color: "#ffffff", + backgroundColor: "#660000", + padding: { x: 20, y: 10 } + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + + restartBtn.on("pointerdown", () => this.scene.events.emit("restart-game")); + this.container.add(restartBtn); + } + + show(data: { floor: number; gold: number; stats: Stats }) { + const lines = [ + `Floor reached: ${data.floor}`, + `Gold: ${data.gold}`, + `Level: ${data.stats.level}`, + `Attack: ${data.stats.attack.toFixed(1)}`, + `Defense: ${data.stats.defense}` + ]; + this.deathText.setText(lines.join("\n")); + this.container.setVisible(true); + } + + hide() { + this.container.setVisible(false); + } +} diff --git a/src/ui/components/HudComponent.ts b/src/ui/components/HudComponent.ts new file mode 100644 index 0000000..6972969 --- /dev/null +++ b/src/ui/components/HudComponent.ts @@ -0,0 +1,60 @@ +import Phaser from "phaser"; +import { type Stats } from "../../core/types"; +import { GAME_CONFIG } from "../../core/config/GameConfig"; + +export class HudComponent { + private scene: Phaser.Scene; + private floorText!: Phaser.GameObjects.Text; + private healthBar!: Phaser.GameObjects.Graphics; + private expBar!: Phaser.GameObjects.Graphics; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + create() { + this.floorText = this.scene.add.text(20, 20, "Floor: 1", { + fontSize: "24px", + color: "#ffffff", + fontStyle: "bold", + stroke: "#000000", + strokeThickness: 4 + }).setScrollFactor(0).setDepth(1000); + + // Health Bar + this.scene.add.text(20, 55, "HP", { fontSize: "14px", color: "#ff8888", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000); + this.healthBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000); + + // EXP Bar + this.scene.add.text(20, 85, "EXP", { fontSize: "14px", color: "#8888ff", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000); + this.expBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000); + } + + update(stats: Stats, floorIndex: number) { + this.floorText.setText(`Floor: ${floorIndex}`); + + // Update Health Bar + this.healthBar.clear(); + this.healthBar.fillStyle(0x333333, 0.8); + this.healthBar.fillRect(60, 58, 200, 12); + + const healthPercent = Phaser.Math.Clamp(stats.hp / stats.maxHp, 0, 1); + const healthColor = healthPercent > 0.5 ? 0x33ff33 : (healthPercent > 0.2 ? 0xffff33 : 0xff3333); + + this.healthBar.fillStyle(healthColor, 1); + this.healthBar.fillRect(60, 58, 200 * healthPercent, 12); + this.healthBar.lineStyle(2, 0xffffff, 0.5); + this.healthBar.strokeRect(60, 58, 200, 12); + + // Update EXP Bar + this.expBar.clear(); + this.expBar.fillStyle(0x333333, 0.8); + this.expBar.fillRect(60, 88, 200, 8); + + const expPercent = Phaser.Math.Clamp(stats.exp / stats.expToNextLevel, 0, 1); + this.expBar.fillStyle(GAME_CONFIG.rendering.expOrbColor, 1); + this.expBar.fillRect(60, 88, 200 * expPercent, 8); + this.expBar.lineStyle(1, 0xffffff, 0.3); + this.expBar.strokeRect(60, 88, 200, 8); + } +} diff --git a/src/ui/components/InventoryOverlay.ts b/src/ui/components/InventoryOverlay.ts new file mode 100644 index 0000000..7b5daf7 --- /dev/null +++ b/src/ui/components/InventoryOverlay.ts @@ -0,0 +1,87 @@ +import Phaser from "phaser"; +import { OverlayComponent } from "./OverlayComponent"; +import { type CombatantActor } from "../../core/types"; + +export class InventoryOverlay extends OverlayComponent { + private equipmentSlots: Map = new Map(); + private backpackSlots: Phaser.GameObjects.Container[] = []; + + protected setupContent() { + const panelH = 500; + + const title = this.scene.add.text(0, -panelH / 2 + 25, "INVENTORY", { + fontSize: "28px", + color: "#d4af37", + fontStyle: "bold" + }).setOrigin(0.5); + this.container.add(title); + + this.createEquipmentSection(); + this.createBackpackSection(); + } + + private createEquipmentSection() { + const eqX = -180; + const eqY = 10; + + const createSlot = (x: number, y: number, w: number, h: number, label: string, key: string) => { + const g = this.scene.add.graphics(); + g.lineStyle(2, 0x444444, 1); + g.strokeRect(-w / 2, -h / 2, w, h); + g.fillStyle(0x1a1a1a, 1); + g.fillRect(-w / 2 + 1, -h / 2 + 1, w - 2, h - 2); + + const txt = this.scene.add.text(0, 0, label, { fontSize: "11px", color: "#666666", fontStyle: "bold" }).setOrigin(0.5); + const container = this.scene.add.container(x, y, [g, txt]); + + this.equipmentSlots.set(key, container); + this.container.add(container); + }; + + createSlot(eqX, eqY - 140, 70, 70, "Head", "helmet"); + createSlot(eqX, eqY - 20, 90, 130, "Body", "bodyArmour"); + createSlot(eqX, eqY + 80, 100, 36, "Belt", "belt"); + createSlot(eqX - 140, eqY - 50, 90, 160, "Main Hand", "mainHand"); + createSlot(eqX + 140, eqY - 50, 90, 160, "Off Hand", "offHand"); + createSlot(eqX - 80, eqY - 30, 54, 54, "Ring", "ringLeft"); + createSlot(eqX + 80, eqY - 30, 54, 54, "Ring", "ringRight"); + createSlot(eqX - 100, eqY + 70, 70, 70, "Hands", "gloves"); + createSlot(eqX + 100, eqY + 70, 70, 70, "Boots", "boots"); + } + + private createBackpackSection() { + const bpX = 100; + const bpY = -150; + const rows = 8; + const cols = 5; + const bpSlotSize = 40; + + const bpTitle = this.scene.add.text(bpX + (cols * 44) / 2 - 20, bpY - 40, "BACKPACK", { + fontSize: "18px", + color: "#d4af37", + fontStyle: "bold" + }).setOrigin(0.5); + this.container.add(bpTitle); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = bpX + c * 44; + const y = bpY + r * 44; + + const g = this.scene.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.scene.add.container(x, y, [g]); + this.container.add(container); + this.backpackSlots.push(container); + } + } + } + + update(_player: CombatantActor) { + // Future: update items in slots + } +} diff --git a/src/ui/components/MenuComponent.ts b/src/ui/components/MenuComponent.ts new file mode 100644 index 0000000..9ef07fc --- /dev/null +++ b/src/ui/components/MenuComponent.ts @@ -0,0 +1,61 @@ +import Phaser from "phaser"; + +export class MenuComponent { + private scene: Phaser.Scene; + private container!: Phaser.GameObjects.Container; + public isOpen: boolean = false; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + create() { + const { width, height } = this.scene.scale; + this.container = this.scene.add.container(width / 2, height / 2); + this.container.setScrollFactor(0).setDepth(2000).setVisible(false); + + const bg = this.scene.add.rectangle(0, 0, 300, 400, 0x000000, 0.85); + bg.setStrokeStyle(4, 0x444444); + this.container.add(bg); + + const title = this.scene.add.text(0, -170, "MENU", { + fontSize: "32px", + color: "#ffffff", + fontStyle: "bold" + }).setOrigin(0.5); + this.container.add(title); + + this.addButtons(); + } + + private addButtons() { + const btnStyle = { fontSize: "20px", color: "#ffffff", backgroundColor: "#333333", padding: { x: 10, y: 5 } }; + + const resumeBtn = this.scene.add.text(0, -80, "Resume (ESC)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true }); + resumeBtn.on("pointerdown", () => this.scene.events.emit("close-menu")); + this.container.add(resumeBtn); + + const inventoryBtn = this.scene.add.text(0, -20, "Inventory (I)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true }); + inventoryBtn.on("pointerdown", () => this.scene.events.emit("toggle-inventory")); + this.container.add(inventoryBtn); + + const characterBtn = this.scene.add.text(0, 40, "Stats (C)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true }); + characterBtn.on("pointerdown", () => this.scene.events.emit("toggle-character")); + this.container.add(characterBtn); + + const minimapBtn = this.scene.add.text(0, 100, "Map (M)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true }); + minimapBtn.on("pointerdown", () => this.scene.events.emit("toggle-minimap")); + this.container.add(minimapBtn); + } + + toggle() { + this.isOpen = !this.isOpen; + this.container.setVisible(this.isOpen); + return this.isOpen; + } + + setVisible(visible: boolean) { + this.isOpen = visible; + this.container.setVisible(visible); + } +} diff --git a/src/ui/components/OverlayComponent.ts b/src/ui/components/OverlayComponent.ts new file mode 100644 index 0000000..cd71093 --- /dev/null +++ b/src/ui/components/OverlayComponent.ts @@ -0,0 +1,40 @@ +import Phaser from "phaser"; + +export abstract class OverlayComponent { + protected scene: Phaser.Scene; + protected container!: Phaser.GameObjects.Container; + public isOpen: boolean = false; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + create() { + const { width, height } = this.scene.scale; + this.container = this.scene.add.container(width / 2, height / 2); + this.container.setScrollFactor(0).setDepth(2000).setVisible(false); + + const bg = this.scene.add.rectangle(0, 0, 700, 500, 0x000000, 0.9); + bg.setStrokeStyle(2, 0x666666); + this.container.add(bg); + + this.setupContent(); + } + + protected abstract setupContent(): void; + + toggle() { + this.isOpen = !this.isOpen; + this.container.setVisible(this.isOpen); + if (this.isOpen) this.onOpen(); + return this.isOpen; + } + + setVisible(visible: boolean) { + this.isOpen = visible; + this.container.setVisible(visible); + if (visible) this.onOpen(); + } + + protected onOpen() {} +} diff --git a/src/ui/components/PersistentButtonsComponent.ts b/src/ui/components/PersistentButtonsComponent.ts new file mode 100644 index 0000000..fe6fcb9 --- /dev/null +++ b/src/ui/components/PersistentButtonsComponent.ts @@ -0,0 +1,48 @@ +import Phaser from "phaser"; + +export class PersistentButtonsComponent { + private scene: Phaser.Scene; + private container!: Phaser.GameObjects.Container; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + create() { + const { height } = this.scene.scale; + this.container = this.scene.add.container(20, height - 20); + + this.container.setScrollFactor(0).setDepth(1500); + + const btnStyle = { + fontSize: "14px", + color: "#ffffff", + backgroundColor: "#1a1a1a", + padding: { x: 10, y: 6 }, + fontStyle: "bold" + }; + + const createBtn = (x: number, text: string, event: string) => { + const btn = this.scene.add.text(x, 0, text, btnStyle) + .setOrigin(0, 1) + .setInteractive({ useHandCursor: true }); + + btn.on("pointerover", () => btn.setBackgroundColor("#333333")); + btn.on("pointerout", () => btn.setBackgroundColor("#1a1a1a")); + btn.on("pointerdown", () => { + btn.setBackgroundColor("#444444"); + const gameScene = this.scene.scene.get("GameScene"); + gameScene.events.emit(event); + }); + btn.on("pointerup", () => btn.setBackgroundColor("#333333")); + + this.container.add(btn); + return btn; + }; + + createBtn(0, "MENU (ESC)", "toggle-menu"); + createBtn(105, "STATS (C)", "toggle-character"); + createBtn(200, "BACKPACK (I)", "toggle-inventory"); + createBtn(320, "MAP (M)", "toggle-minimap"); + } +}