Another refactor
This commit is contained in:
98
src/engine/EntityManager.ts
Normal file
98
src/engine/EntityManager.ts
Normal file
@@ -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<number, EntityId[]> = new Map();
|
||||||
|
private actors: Map<EntityId, Actor>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/engine/ProgressionManager.ts
Normal file
59
src/engine/ProgressionManager.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
||||||
|
|
||||||
import { isBlocked } from "../world/world-logic";
|
import { isBlocked } from "../world/world-logic";
|
||||||
|
import { findPathAStar } from "../world/pathfinding";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
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);
|
const actor = w.actors.get(actorId);
|
||||||
if (!actor) return [];
|
if (!actor) return [];
|
||||||
|
|
||||||
@@ -12,10 +14,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
|||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move":
|
case "move":
|
||||||
events.push(...handleMove(w, actor, action));
|
events.push(...handleMove(w, actor, action, em));
|
||||||
break;
|
break;
|
||||||
case "attack":
|
case "attack":
|
||||||
events.push(...handleAttack(w, actor, action));
|
events.push(...handleAttack(w, actor, action, em));
|
||||||
break;
|
break;
|
||||||
case "wait":
|
case "wait":
|
||||||
default:
|
default:
|
||||||
@@ -31,7 +33,7 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
|||||||
return events;
|
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;
|
if (player.category !== "combatant") return;
|
||||||
|
|
||||||
const orbs = [...w.actors.values()].filter(a =>
|
const orbs = [...w.actors.values()].filter(a =>
|
||||||
@@ -53,7 +55,8 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
checkLevelUp(player, events);
|
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 from = { ...actor.pos };
|
||||||
const nx = actor.pos.x + action.dx;
|
const nx = actor.pos.x + action.dx;
|
||||||
const ny = actor.pos.y + action.dy;
|
const ny = actor.pos.y + action.dy;
|
||||||
|
|
||||||
if (!isBlocked(w, nx, ny)) {
|
if (!isBlocked(w, nx, ny, em)) {
|
||||||
actor.pos.x = nx;
|
if (em) {
|
||||||
actor.pos.y = ny;
|
em.moveActor(actor.id, from, { x: nx, y: ny });
|
||||||
|
} else {
|
||||||
|
actor.pos.x = nx;
|
||||||
|
actor.pos.y = ny;
|
||||||
|
}
|
||||||
const to = { ...actor.pos };
|
const to = { ...actor.pos };
|
||||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
||||||
|
|
||||||
if (actor.category === "combatant" && actor.isPlayer) {
|
if (actor.category === "combatant" && actor.isPlayer) {
|
||||||
handleExpCollection(w, actor, events);
|
handleExpCollection(w, actor, events, em);
|
||||||
}
|
}
|
||||||
|
|
||||||
return events;
|
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);
|
const target = w.actors.get(action.targetId);
|
||||||
if (target && target.category === "combatant" && actor.category === "combatant") {
|
if (target && target.category === "combatant" && actor.category === "combatant") {
|
||||||
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
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,
|
y: target.pos.y,
|
||||||
victimType: target.type as ActorType
|
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
|
// Spawn EXP Orb
|
||||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||||
const expAmount = enemyDef?.expValue || 0;
|
const expAmount = enemyDef?.expValue || 0;
|
||||||
const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1;
|
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
|
||||||
w.actors.set(orbId, {
|
|
||||||
|
const orb: CollectibleActor = {
|
||||||
id: orbId,
|
id: orbId,
|
||||||
category: "collectible",
|
category: "collectible",
|
||||||
type: "exp_orb",
|
type: "exp_orb",
|
||||||
pos: { ...target.pos },
|
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 });
|
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
|
* - if adjacent to player, attack
|
||||||
* - else step toward player using greedy Manhattan
|
* - 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 dx = player.pos.x - enemy.pos.x;
|
||||||
const dy = player.pos.y - enemy.pos.y;
|
const dy = player.pos.y - enemy.pos.y;
|
||||||
const dist = Math.abs(dx) + Math.abs(dy);
|
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 };
|
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 }[] = [];
|
const options: { dx: number; dy: number }[] = [];
|
||||||
|
|
||||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
options.push({ dx: Math.sign(dx), dy: 0 });
|
||||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
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.
|
* Energy/speed scheduler: runs until it's the player's turn and the game needs input.
|
||||||
* Returns enemy events accumulated along the way.
|
* 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;
|
const player = w.actors.get(playerId) as CombatantActor;
|
||||||
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
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 };
|
return { awaitingPlayerId: actor.id, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = decideEnemyAction(w, actor, player);
|
const action = decideEnemyAction(w, actor, player, em);
|
||||||
events.push(...applyAction(w, actor.id, action));
|
events.push(...applyAction(w, actor.id, action, em));
|
||||||
|
|
||||||
// Check if player was killed by this action
|
// Check if player was killed by this action
|
||||||
if (!w.actors.has(playerId)) {
|
if (!w.actors.has(playerId)) {
|
||||||
|
|||||||
@@ -214,54 +214,68 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
|||||||
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
||||||
|
|
||||||
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
||||||
|
const occupiedPositions = new Set<string>();
|
||||||
|
|
||||||
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 roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||||
const room = rooms[roomIdx];
|
const room = rooms[roomIdx];
|
||||||
|
|
||||||
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
// Try to find an empty spot in the room
|
||||||
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
|
for (let attempts = 0; attempts < 5; attempts++) {
|
||||||
|
|
||||||
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||||
const enemyDef = GAME_CONFIG.enemies[type];
|
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||||
|
const k = `${ex},${ey}`;
|
||||||
|
|
||||||
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
if (!occupiedPositions.has(k)) {
|
||||||
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
||||||
|
const enemyDef = GAME_CONFIG.enemies[type];
|
||||||
|
|
||||||
actors.set(enemyId, {
|
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||||
id: enemyId,
|
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||||
category: "combatant",
|
|
||||||
isPlayer: false,
|
actors.set(enemyId, {
|
||||||
type,
|
id: enemyId,
|
||||||
pos: { x: enemyX, y: enemyY },
|
category: "combatant",
|
||||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
isPlayer: false,
|
||||||
energy: 0,
|
type,
|
||||||
stats: {
|
pos: { x: ex, y: ey },
|
||||||
maxHp: scaledHp + Math.floor(random() * 4),
|
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||||
hp: scaledHp + Math.floor(random() * 4),
|
energy: 0,
|
||||||
attack: scaledAttack + Math.floor(random() * 2),
|
stats: {
|
||||||
defense: enemyDef.baseDefense,
|
maxHp: scaledHp + Math.floor(random() * 4),
|
||||||
level: 0,
|
hp: scaledHp + Math.floor(random() * 4),
|
||||||
exp: 0,
|
attack: scaledAttack + Math.floor(random() * 2),
|
||||||
expToNextLevel: 0,
|
defense: enemyDef.baseDefense,
|
||||||
statPoints: 0,
|
level: 0,
|
||||||
skillPoints: 0,
|
exp: 0,
|
||||||
strength: 0,
|
expToNextLevel: 0,
|
||||||
dexterity: 0,
|
statPoints: 0,
|
||||||
intelligence: 0,
|
skillPoints: 0,
|
||||||
critChance: 0,
|
strength: 0,
|
||||||
critMultiplier: 100,
|
dexterity: 0,
|
||||||
accuracy: 80,
|
intelligence: 0,
|
||||||
lifesteal: 0,
|
critChance: 0,
|
||||||
evasion: 0,
|
critMultiplier: 100,
|
||||||
blockChance: 0,
|
accuracy: 80,
|
||||||
luck: 0,
|
lifesteal: 0,
|
||||||
passiveNodes: []
|
evasion: 0,
|
||||||
|
blockChance: 0,
|
||||||
|
luck: 0,
|
||||||
|
passiveNodes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
occupiedPositions.add(k);
|
||||||
|
enemyId++;
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
enemyId++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const makeTestWorld = generateWorld;
|
export const makeTestWorld = generateWorld;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { World, Vec2 } from "../../core/types";
|
|||||||
import { key } from "../../core/utils";
|
import { key } from "../../core/utils";
|
||||||
import { manhattan } from "../../core/math";
|
import { manhattan } from "../../core/math";
|
||||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple 4-dir A* pathfinding.
|
* 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 THROUGH unseen tiles.
|
||||||
* - You cannot path TO an unseen target tile.
|
* - 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 (!inBounds(w, end.x, end.y)) return [];
|
||||||
if (isWall(w, end.x, end.y)) return [];
|
if (isWall(w, end.x, end.y)) return [];
|
||||||
|
|
||||||
// If not ignoring target block, fail if blocked
|
// 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 open: Vec2[] = [start];
|
||||||
const cameFrom = new Map<string, string>();
|
const cameFrom = new Map<string, string>();
|
||||||
@@ -76,12 +77,12 @@ export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2
|
|||||||
if (!inBounds(w, nx, ny)) continue;
|
if (!inBounds(w, nx, ny)) continue;
|
||||||
if (isWall(w, nx, ny)) continue;
|
if (isWall(w, nx, ny)) continue;
|
||||||
|
|
||||||
// Exploration rule: cannot path through unseen (except start)
|
// Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set)
|
||||||
if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
|
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)
|
// 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;
|
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 nK = key(nx, ny);
|
||||||
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
|
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { World, EntityId } from "../../core/types";
|
import type { World, EntityId } from "../../core/types";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
|
||||||
export function inBounds(w: World, x: number, y: number): boolean {
|
export function inBounds(w: World, x: number, y: number): boolean {
|
||||||
return x >= 0 && y >= 0 && x < w.width && y < w.height;
|
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;
|
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 (!inBounds(w, x, y)) return true;
|
||||||
if (isWall(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()) {
|
for (const a of w.actors.values()) {
|
||||||
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
|
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 {
|
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||||
const p = w.actors.get(playerId);
|
const p = w.actors.get(playerId);
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
|
|||||||
@@ -188,12 +188,25 @@ export class DungeonRenderer {
|
|||||||
if (a.category === "combatant") {
|
if (a.category === "combatant") {
|
||||||
if (a.isPlayer) {
|
if (a.isPlayer) {
|
||||||
if (this.playerSprite) {
|
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);
|
this.playerSprite.setVisible(true);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!isVis) continue;
|
if (!isVis) continue;
|
||||||
|
|
||||||
activeEnemyIds.add(a.id);
|
activeEnemyIds.add(a.id);
|
||||||
@@ -207,8 +220,21 @@ export class DungeonRenderer {
|
|||||||
this.enemySprites.set(a.id, sprite);
|
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);
|
sprite.setVisible(true);
|
||||||
|
|
||||||
} else if (a.category === "collectible") {
|
} else if (a.category === "collectible") {
|
||||||
if (a.type === "exp_orb") {
|
if (a.type === "exp_orb") {
|
||||||
if (!isVis) continue;
|
if (!isVis) continue;
|
||||||
|
|||||||
@@ -106,9 +106,12 @@ describe('DungeonRenderer', () => {
|
|||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
tweens: {
|
||||||
|
add: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
mockWorld = {
|
mockWorld = {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { generateWorld } from "../engine/world/generator";
|
|||||||
|
|
||||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
import { EntityManager } from "../engine/EntityManager";
|
||||||
|
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
private world!: World;
|
||||||
@@ -38,6 +40,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private isInventoryOpen = false;
|
private isInventoryOpen = false;
|
||||||
private isCharacterOpen = false;
|
private isCharacterOpen = false;
|
||||||
|
|
||||||
|
private entityManager!: EntityManager;
|
||||||
|
private progressionManager: ProgressionManager = new ProgressionManager();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("GameScene");
|
super("GameScene");
|
||||||
}
|
}
|
||||||
@@ -121,13 +126,22 @@ export class GameScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.events.on("allocate-stat", (statName: string) => {
|
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.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)
|
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
|
||||||
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
||||||
if (!this.awaitingPlayer) return;
|
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
|
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
|
||||||
);
|
);
|
||||||
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
const path = findPathAStar(
|
const path = findPathAStar(
|
||||||
this.world,
|
this.world,
|
||||||
this.dungeonRenderer.seenArray,
|
this.dungeonRenderer.seenArray,
|
||||||
{ ...player.pos },
|
{ ...player.pos },
|
||||||
{ x: tx, y: ty },
|
{ x: tx, y: ty },
|
||||||
{ ignoreBlockedTarget: isEnemy }
|
{ ignoreBlockedTarget: isEnemy }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
if (path.length >= 2) this.playerPath = path;
|
if (path.length >= 2) this.playerPath = path;
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
this.dungeonRenderer.render(this.playerPath);
|
||||||
@@ -176,7 +191,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
return;
|
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'
|
// Check if it's an enemy at 'next'
|
||||||
const targetId = [...this.world.actors.values()].find(
|
const targetId = [...this.world.actors.values()].find(
|
||||||
a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
|
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) {
|
private commitPlayerAction(action: Action) {
|
||||||
this.awaitingPlayer = false;
|
this.awaitingPlayer = false;
|
||||||
|
|
||||||
const playerEvents = applyAction(this.world, this.playerId, action);
|
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
|
||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
|
||||||
// Process events for visual fx
|
// Process events for visual fx
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
for (const ev of allEvents) {
|
for (const ev of allEvents) {
|
||||||
@@ -306,6 +322,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const { world, playerId } = generateWorld(floor, this.runState);
|
const { world, playerId } = generateWorld(floor, this.runState);
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
|
this.entityManager = new EntityManager(this.world);
|
||||||
|
|
||||||
|
|
||||||
// Reset transient state
|
// Reset transient state
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
@@ -318,9 +336,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.dungeonRenderer.initializeFloor(this.world);
|
this.dungeonRenderer.initializeFloor(this.world);
|
||||||
|
|
||||||
// Step until player turn
|
// 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.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
|
||||||
this.dungeonRenderer.computeFov(this.playerId);
|
this.dungeonRenderer.computeFov(this.playerId);
|
||||||
this.centerCameraOnPlayer();
|
this.centerCameraOnPlayer();
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
741
src/ui/GameUI.ts
741
src/ui/GameUI.ts
@@ -1,699 +1,100 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type EntityId, type Stats, type CombatantActor } from "../core/types";
|
import { type World, type EntityId, type CombatantActor, type Stats } from "../core/types";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
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 {
|
export default class GameUI extends Phaser.Scene {
|
||||||
// HUD
|
private hud: HudComponent;
|
||||||
private floorText!: Phaser.GameObjects.Text;
|
private menu: MenuComponent;
|
||||||
private healthBar!: Phaser.GameObjects.Graphics;
|
private inventory: InventoryOverlay;
|
||||||
private expBar!: Phaser.GameObjects.Graphics;
|
private character: CharacterOverlay;
|
||||||
|
private death: DeathOverlay;
|
||||||
|
private persistentButtons: PersistentButtonsComponent;
|
||||||
// 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<string, Phaser.GameObjects.Container> = 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;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: "GameUI" });
|
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() {
|
create() {
|
||||||
this.createHud();
|
this.hud.create();
|
||||||
this.createMenu();
|
this.menu.create();
|
||||||
this.createInventoryOverlay();
|
this.inventory.create();
|
||||||
this.createCharacterOverlay();
|
this.character.create();
|
||||||
this.createDeathScreen();
|
this.death.create();
|
||||||
|
this.persistentButtons.create();
|
||||||
|
|
||||||
|
const gameScene = this.scene.get("GameScene");
|
||||||
|
|
||||||
|
|
||||||
// Listen for updates from GameScene
|
// Listen for updates from GameScene
|
||||||
const gameScene = this.scene.get("GameScene");
|
|
||||||
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
|
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
|
||||||
this.updateUI(data.world, data.playerId, data.floorIndex);
|
this.updateUI(data.world, data.playerId, data.floorIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
gameScene.events.on("toggle-menu", () => this.toggleMenu());
|
gameScene.events.on("toggle-menu", () => {
|
||||||
gameScene.events.on("toggle-inventory", () => this.toggleInventory());
|
this.menu.toggle();
|
||||||
gameScene.events.on("toggle-character", () => this.toggleCharacter());
|
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", () => {
|
gameScene.events.on("close-menu", () => {
|
||||||
this.setMenuOpen(false);
|
this.menu.setVisible(false);
|
||||||
this.setInventoryOpen(false);
|
this.inventory.setVisible(false);
|
||||||
this.setCharacterOpen(false);
|
this.character.setVisible(false);
|
||||||
|
this.emitMenuStates();
|
||||||
|
});
|
||||||
|
|
||||||
|
gameScene.events.on("restart-game", () => {
|
||||||
|
this.death.hide();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createHud() {
|
private emitMenuStates() {
|
||||||
this.floorText = this.add.text(10, 10, "Floor 1", {
|
const gameScene = this.scene.get("GameScene");
|
||||||
fontSize: "20px",
|
gameScene.events.emit("menu-toggled", this.menu.isOpen);
|
||||||
color: "#ffffff",
|
gameScene.events.emit("inventory-toggled", this.inventory.isOpen);
|
||||||
fontStyle: "bold"
|
gameScene.events.emit("character-toggled", this.character.isOpen);
|
||||||
}).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());
|
|
||||||
|
|
||||||
// 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 }) {
|
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
||||||
const lines = [
|
this.death.show(data);
|
||||||
`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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
|
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
|
||||||
this.updateHud(world, playerId, floorIndex);
|
const player = world.actors.get(playerId) as CombatantActor;
|
||||||
if (this.menuOpen) {
|
if (!player) return;
|
||||||
this.updateMenuText(world, playerId, floorIndex);
|
|
||||||
}
|
|
||||||
if (this.inventoryOpen) {
|
|
||||||
this.updateInventoryUI(world, playerId);
|
|
||||||
}
|
|
||||||
if (this.characterOpen) {
|
|
||||||
this.updateCharacterUI(world, playerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateCharacterUI(world: World, playerId: EntityId) {
|
this.hud.update(player.stats, floorIndex);
|
||||||
const p = world.actors.get(playerId) as CombatantActor;
|
this.inventory.update(player);
|
||||||
if (!p || p.category !== "combatant" || !p.stats) return;
|
this.character.update(player);
|
||||||
|
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/ui/components/CharacterOverlay.ts
Normal file
79
src/ui/components/CharacterOverlay.ts
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/ui/components/DeathOverlay.ts
Normal file
66
src/ui/components/DeathOverlay.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/ui/components/HudComponent.ts
Normal file
60
src/ui/components/HudComponent.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/ui/components/InventoryOverlay.ts
Normal file
87
src/ui/components/InventoryOverlay.ts
Normal file
@@ -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<string, Phaser.GameObjects.Container> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ui/components/MenuComponent.ts
Normal file
61
src/ui/components/MenuComponent.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/ui/components/OverlayComponent.ts
Normal file
40
src/ui/components/OverlayComponent.ts
Normal file
@@ -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() {}
|
||||||
|
}
|
||||||
48
src/ui/components/PersistentButtonsComponent.ts
Normal file
48
src/ui/components/PersistentButtonsComponent.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user