From 29e46093f5e886d166961cb34a0ba282228f6a18 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 4 Jan 2026 18:36:31 +1100 Subject: [PATCH] Add levelling up mechanics through experience gained via killing enemies --- src/core/config/GameConfig.ts | 34 +++++-- src/core/types.ts | 16 +++- src/engine/__tests__/world.test.ts | 17 ++-- src/engine/simulation/simulation.ts | 85 ++++++++++++++++- src/engine/world/generator.ts | 52 +++++----- src/engine/world/world-logic.ts | 3 +- src/rendering/DungeonRenderer.ts | 94 ++++++++++++++++++- .../__tests__/DungeonRenderer.test.ts | 21 ++++- src/scenes/GameScene.ts | 39 +++++--- src/scenes/__tests__/GameScene.test.ts | 12 ++- src/ui/GameUI.ts | 84 +++++++++++++---- 11 files changed, 373 insertions(+), 84 deletions(-) diff --git a/src/core/config/GameConfig.ts b/src/core/config/GameConfig.ts index 23621e7..42b7ba7 100644 --- a/src/core/config/GameConfig.ts +++ b/src/core/config/GameConfig.ts @@ -1,9 +1,19 @@ export const GAME_CONFIG = { player: { - initialStats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, + initialStats: { + maxHp: 20, + hp: 20, + attack: 5, + defense: 2, + level: 1, + exp: 0, + expToNextLevel: 10 + }, speed: 100, viewRadius: 8 }, + + map: { width: 60, @@ -17,18 +27,26 @@ export const GAME_CONFIG = { }, enemy: { - baseHpPerLevel: 2, baseHp: 8, baseAttack: 3, - attackPerTwoLevels: 1, minSpeed: 80, maxSpeed: 130, - maxDefense: 2, - baseCountPerLevel: 1, + baseHpPerFloor: 5, + attackPerTwoFloors: 1, baseCount: 3, - randomBonus: 4 + baseCountPerFloor: 3, + ratExp: 5, + batExp: 8 }, + leveling: { + baseExpToNextLevel: 10, + expMultiplier: 1.5, + hpGainPerLevel: 5, + attackGainPerLevel: 1 + }, + + rendering: { tileSize: 16, cameraZoom: 2, @@ -38,6 +56,9 @@ export const GAME_CONFIG = { playerColor: 0x66ff66, enemyColor: 0xff6666, pathPreviewColor: 0x3355ff, + expOrbColor: 0x33ccff, + expTextColor: 0x33ccff, + levelUpColor: 0xffff00, fogAlphaFloor: 0.15, fogAlphaWall: 0.35, visibleMinAlpha: 0.35, @@ -45,6 +66,7 @@ export const GAME_CONFIG = { visibleStrengthFactor: 0.65 }, + terrain: { empty: 1, wall: 4, diff --git a/src/core/types.ts b/src/core/types.ts index 6739e2f..55a8100 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,16 +13,25 @@ export type SimEvent = | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } | { type: "attacked"; attackerId: EntityId; targetId: EntityId } | { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number } - | { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" } - | { type: "waited"; actorId: EntityId }; + | { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" | "exp_orb" } + + | { type: "waited"; actorId: EntityId } + | { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number } + | { type: "orb-spawned"; orbId: EntityId; x: number; y: number } + | { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }; + export type Stats = { maxHp: number; hp: number; attack: number; defense: number; + level: number; + exp: number; + expToNextLevel: number; }; + export type Inventory = { gold: number; items: string[]; @@ -36,8 +45,9 @@ export type RunState = { export type Actor = { id: EntityId; isPlayer: boolean; - type?: "player" | "rat" | "bat"; + type?: "player" | "rat" | "bat" | "exp_orb"; pos: Vec2; + speed: number; energy: number; diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index e77a41c..db9bc73 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { idx, inBounds, isWall, isBlocked } from '../world/world-logic'; import { type World, type Tile } from '../../core/types'; +import { GAME_CONFIG } from '../../core/config/GameConfig'; + describe('World Utilities', () => { const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({ @@ -44,9 +46,10 @@ describe('World Utilities', () => { describe('isWall', () => { it('should return true for wall tiles', () => { - const tiles: Tile[] = new Array(100).fill(0); - tiles[0] = 1; // wall at 0,0 - tiles[55] = 1; // wall at 5,5 + const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); + tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0 + tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5 + const world = createTestWorld(10, 10, tiles); @@ -55,11 +58,12 @@ describe('World Utilities', () => { }); it('should return false for floor tiles', () => { - const tiles: Tile[] = new Array(100).fill(0); + const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); const world = createTestWorld(10, 10, tiles); expect(isWall(world, 3, 3)).toBe(false); expect(isWall(world, 7, 7)).toBe(false); + }); it('should return false for out of bounds coordinates', () => { @@ -72,8 +76,9 @@ describe('World Utilities', () => { describe('isBlocked', () => { it('should return true for walls', () => { - const tiles: Tile[] = new Array(100).fill(0); - tiles[55] = 1; // wall at 5,5 + const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); + tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5 + const world = createTestWorld(10, 10, tiles); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 4db0d01..937e3a3 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -1,6 +1,8 @@ -import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants"; import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types"; + import { isBlocked } from "../world/world-logic"; +import { GAME_CONFIG } from "../../core/config/GameConfig"; + export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] { const actor = w.actors.get(actorId); @@ -22,11 +24,59 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve } // Spend energy for any action (move/wait/attack) - actor.energy -= ACTION_COST; + actor.energy -= GAME_CONFIG.gameplay.actionCost; return events; } +function handleExpCollection(w: World, player: Actor, events: SimEvent[]) { + const orbs = [...w.actors.values()].filter(a => a.type === "exp_orb" && a.pos.x === player.pos.x && a.pos.y === player.pos.y); + + for (const orb of orbs) { + const amount = (orb as any).expAmount || 0; + if (player.stats) { + player.stats.exp += amount; + events.push({ + type: "exp-collected", + actorId: player.id, + amount, + x: player.pos.x, + y: player.pos.y + }); + + checkLevelUp(player, events); + } + w.actors.delete(orb.id); + } +} + +function checkLevelUp(player: Actor, events: SimEvent[]) { + if (!player.stats) return; + const s = player.stats; + + while (s.exp >= s.expToNextLevel) { + s.level++; + s.exp -= s.expToNextLevel; + + // Growth + s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel; + s.hp = s.maxHp; // Heal on level up + s.attack += GAME_CONFIG.leveling.attackGainPerLevel; + + // Scale requirement + s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier); + + events.push({ + type: "leveled-up", + actorId: player.id, + level: s.level, + x: player.pos.x, + y: player.pos.y + }); + } +} + + function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] { const from = { ...actor.pos }; const nx = actor.pos.x + action.dx; @@ -36,12 +86,19 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): actor.pos.x = nx; actor.pos.y = ny; const to = { ...actor.pos }; - return [{ type: "moved", actorId: actor.id, from, to }]; + const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; + + if (actor.isPlayer) { + handleExpCollection(w, actor, events); + } + + return events; } else { return [{ type: "waited", actorId: actor.id }]; } } + function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] { const target = w.actors.get(action.targetId); if (target && target.stats && actor.stats) { @@ -69,12 +126,29 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S victimType: target.type }); w.actors.delete(target.id); + + // Spawn EXP Orb + const expAmount = target.type === "rat" ? GAME_CONFIG.enemy.ratExp : GAME_CONFIG.enemy.batExp; + const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1; + w.actors.set(orbId, { + + id: orbId, + isPlayer: false, + type: "exp_orb", + pos: { ...target.pos }, + speed: 0, + energy: 0, + expAmount // Hidden property for simulation + } as any); + + events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y }); } return events; } return [{ type: "waited", actorId: actor.id }]; } + /** * Very basic enemy AI: * - if adjacent to player, attack @@ -120,11 +194,12 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla const events: SimEvent[] = []; while (true) { - while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) { + while (![...w.actors.values()].some(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold)) { for (const a of w.actors.values()) a.energy += a.speed; } - const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD); + const ready = [...w.actors.values()].filter(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold); + ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id)); const actor = ready[0]; diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index d3ecf54..f2af278 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -12,33 +12,27 @@ interface Room { /** * Generates a procedural dungeon world with rooms and corridors - * @param level The level number (affects difficulty and randomness seed) - * @param runState Player's persistent state across levels + * @param floor The floor number (affects difficulty) + * @param runState Player's persistent state across floors * @returns Generated world and player ID */ -export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } { +export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } { const width = GAME_CONFIG.map.width; const height = GAME_CONFIG.map.height; - const tiles: Tile[] = new Array(width * height).fill(GAME_CONFIG.terrain.wall); // Start with all walls + const tiles: Tile[] = new Array(width * height).fill(GAME_CONFIG.terrain.wall); + + const random = seededRandom(floor * 12345); - const random = seededRandom(level * 12345); - const rooms = generateRooms(width, height, tiles, random); // Place player in first room const firstRoom = rooms[0]; const playerX = firstRoom.x + Math.floor(firstRoom.width / 2); const playerY = firstRoom.y + Math.floor(firstRoom.height / 2); - - // Place exit in last room - const lastRoom = rooms[rooms.length - 1]; - const exitX = lastRoom.x + Math.floor(lastRoom.width / 2); - const exitY = lastRoom.y + Math.floor(lastRoom.height / 2); - const exit: Vec2 = { x: exitX, y: exitY }; - + const actors = new Map(); - const playerId = 1; + actors.set(playerId, { id: playerId, isPlayer: true, @@ -50,13 +44,23 @@ export function generateWorld(level: number, runState: RunState): { world: World inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] } }); - placeEnemies(level, rooms, actors, random); + // Place exit in last room + const lastRoom = rooms[rooms.length - 1]; + const exit: Vec2 = { + x: lastRoom.x + Math.floor(lastRoom.width / 2), + y: lastRoom.y + Math.floor(lastRoom.height / 2) + }; + placeEnemies(floor, rooms, actors, random); decorate(width, height, tiles, random, exit); - - return { world: { width, height, tiles, actors, exit }, playerId }; + + return { + world: { width, height, tiles, actors, exit }, + playerId + }; } + function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] { const rooms: Room[] = []; const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1)); @@ -204,9 +208,9 @@ function generatePatch(width: number, height: number, fillChance: number, iterat return map; } -function placeEnemies(level: number, rooms: Room[], actors: Map, random: () => number): void { +function placeEnemies(floor: number, rooms: Room[], actors: Map, random: () => number): void { let enemyId = 2; - const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus); + const numEnemies = GAME_CONFIG.enemy.baseCount + floor * GAME_CONFIG.enemy.baseCountPerFloor; // Simplified for now for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) { const roomIdx = 1 + Math.floor(random() * (rooms.length - 1)); @@ -215,8 +219,8 @@ function placeEnemies(level: number, rooms: Room[], actors: Map const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2)); const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2)); - const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel; - const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels; + const baseHp = GAME_CONFIG.enemy.baseHp + floor * GAME_CONFIG.enemy.baseHpPerFloor; + const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemy.attackPerTwoFloors; actors.set(enemyId, { id: enemyId, @@ -229,11 +233,15 @@ function placeEnemies(level: number, rooms: Room[], actors: Map maxHp: baseHp + Math.floor(random() * 4), hp: baseHp + Math.floor(random() * 4), attack: baseAttack + Math.floor(random() * 2), - defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1)) + defense: 0, + level: 0, + exp: 0, + expToNextLevel: 0 } }); enemyId++; } } + export const makeTestWorld = generateWorld; diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index b1139c2..3f8150a 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -19,11 +19,12 @@ export function isBlocked(w: World, x: number, y: number): boolean { if (isWall(w, x, y)) return true; for (const a of w.actors.values()) { - if (a.pos.x === x && a.pos.y === y) return true; + if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true; } return false; } + 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 f71d369..2e7a7ea 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -12,7 +12,9 @@ export class DungeonRenderer { private playerSprite?: Phaser.GameObjects.Sprite; private enemySprites: Map = new Map(); + private orbSprites: Map = new Map(); private corpseSprites: Phaser.GameObjects.Sprite[] = []; + // FOV private fov!: any; @@ -52,7 +54,8 @@ export class DungeonRenderer { this.minimapContainer.setVisible(false); } - initializeLevel(world: World) { + initializeFloor(world: World) { + this.world = world; this.seen = new Uint8Array(this.world.width * this.world.height); this.visible = new Uint8Array(this.world.width * this.world.height); @@ -277,6 +280,41 @@ export class DungeonRenderer { } } + // Orbs + const activeOrbIds = new Set(); + for (const a of this.world.actors.values()) { + if (a.type !== "exp_orb") continue; + + const i = idx(this.world, a.pos.x, a.pos.y); + // PD usually shows items only when visible or seen. Let's do visible. + const isVis = this.visible[i] === 1; + + + if (!isVis) continue; + + activeOrbIds.add(a.id); + let orb = this.orbSprites.get(a.id); + if (!orb) { + orb = this.scene.add.circle(0, 0, 4, GAME_CONFIG.rendering.expOrbColor); + orb.setStrokeStyle(1, 0xffffff, 0.5); + orb.setDepth(45); + this.orbSprites.set(a.id, orb); + } + orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); + orb.setVisible(true); + } + + for (const [id, orb] of this.orbSprites.entries()) { + if (!activeOrbIds.has(id)) { + orb.setVisible(false); + if (!this.world.actors.has(id)) { + orb.destroy(); + this.orbSprites.delete(id); + } + } + } + + this.renderMinimap(); } @@ -355,8 +393,10 @@ export class DungeonRenderer { }); } - spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") { + spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat" | "exp_orb") { + if (type === "exp_orb") return; const textureKey = type === "player" ? "warrior" : type; + const corpse = this.scene.add.sprite( x * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2, @@ -389,4 +429,54 @@ export class DungeonRenderer { onComplete: () => text.destroy() }); } + + spawnOrb(_orbId: EntityId, _x: number, _y: number) { + // Just to trigger a render update if needed, but render() handles it + } + + collectOrb(_actorId: EntityId, amount: number, x: number, y: number) { + + const screenX = x * TILE_SIZE + TILE_SIZE / 2; + const screenY = y * TILE_SIZE; + + const text = this.scene.add.text(screenX, screenY, `+${amount} EXP`, { + fontSize: "14px", + color: "#" + GAME_CONFIG.rendering.expTextColor.toString(16), + stroke: "#000", + strokeThickness: 2, + fontStyle: "bold" + }).setOrigin(0.5, 1).setDepth(200); + + this.scene.tweens.add({ + targets: text, + y: screenY - 32, + alpha: 0, + duration: 1000, + ease: "Power1", + onComplete: () => text.destroy() + }); + } + + showLevelUp(x: number, y: number) { + const screenX = x * TILE_SIZE + TILE_SIZE / 2; + const screenY = y * TILE_SIZE; + + const text = this.scene.add.text(screenX, screenY - 16, "+1 LVL", { + fontSize: "20px", + color: "#" + GAME_CONFIG.rendering.levelUpColor.toString(16), + stroke: "#000", + strokeThickness: 3, + fontStyle: "bold" + }).setOrigin(0.5, 1).setDepth(210); + + this.scene.tweens.add({ + targets: text, + y: screenY - 60, + alpha: 0, + duration: 1500, + ease: "Cubic.out", + onComplete: () => text.destroy() + }); + } } + diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index db733cb..a42c95e 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -94,6 +94,17 @@ describe('DungeonRenderer', () => { exists: vi.fn().mockReturnValue(true), generateFrameNumbers: vi.fn(), }, + make: { + tilemap: vi.fn().mockReturnValue({ + addTilesetImage: vi.fn().mockReturnValue({}), + createLayer: vi.fn().mockReturnValue({ + setDepth: vi.fn(), + forEachTile: vi.fn(), + }), + destroy: vi.fn(), + }), + }, + }; mockWorld = { @@ -107,8 +118,9 @@ describe('DungeonRenderer', () => { renderer = new DungeonRenderer(mockScene); }); - it('should track and clear corpse sprites on level initialization', () => { - renderer.initializeLevel(mockWorld); + it('should track and clear corpse sprites on floor initialization', () => { + renderer.initializeFloor(mockWorld); + // Spawn a couple of corpses renderer.spawnCorpse(1, 1, 'rat'); @@ -120,8 +132,9 @@ describe('DungeonRenderer', () => { expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); - // Initialize level again (changing level) - renderer.initializeLevel(mockWorld); + // Initialize floor again (changing level) + renderer.initializeFloor(mockWorld); + // Verify destroy was called on both corpse sprites expect(corpse1.destroy).toHaveBeenCalledTimes(1); diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index fccbf81..5666f62 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -10,7 +10,8 @@ import { TILE_SIZE } from "../core/constants"; import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic"; import { findPathAStar } from "../engine/world/pathfinding"; import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation"; -import { makeTestWorld } from "../engine/world/generator"; +import { generateWorld } from "../engine/world/generator"; + import { DungeonRenderer } from "../rendering/DungeonRenderer"; import { GAME_CONFIG } from "../core/config/GameConfig"; @@ -18,7 +19,8 @@ export class GameScene extends Phaser.Scene { private world!: World; private playerId!: EntityId; - private levelIndex = 1; + private floorIndex = 1; + private gameState: "playing" | "player-turn" | "enemy-turn" = "player-turn"; private runState: RunState = { stats: { ...GAME_CONFIG.player.initialStats }, @@ -62,8 +64,8 @@ export class GameScene extends Phaser.Scene { this.isMenuOpen = isOpen; }); - // Load initial level - this.loadLevel(1); + // Load initial floor + this.loadFloor(1); // Menu Inputs this.input.keyboard?.on("keydown-I", () => { @@ -214,7 +216,7 @@ export class GameScene extends Phaser.Scene { this.events.emit("update-ui", { world: this.world, playerId: this.playerId, - levelIndex: this.levelIndex + floorIndex: this.floorIndex }); } @@ -237,16 +239,23 @@ export class GameScene extends Phaser.Scene { if (player) { this.dungeonRenderer.showWait(player.pos.x, player.pos.y); } + } else if (ev.type === "orb-spawned") { + this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y); + } else if (ev.type === "exp-collected" && ev.actorId === this.playerId) { + this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y); + } else if (ev.type === "leveled-up" && ev.actorId === this.playerId) { + this.dungeonRenderer.showLevelUp(ev.x, ev.y); } } + // Check if player died if (!this.world.actors.has(this.playerId)) { this.syncRunStateFromPlayer(); // Save final stats for death screen const uiScene = this.scene.get("GameUI") as any; if (uiScene) { uiScene.showDeathScreen({ - level: this.levelIndex, + floor: this.floorIndex, gold: this.runState.inventory.gold, stats: this.runState.stats }); @@ -257,7 +266,8 @@ export class GameScene extends Phaser.Scene { // Level transition if (isPlayerOnExit(this.world, this.playerId)) { this.syncRunStateFromPlayer(); - this.loadLevel(this.levelIndex + 1); + this.floorIndex++; + this.loadFloor(this.floorIndex); return; } @@ -267,10 +277,10 @@ export class GameScene extends Phaser.Scene { this.emitUIUpdate(); } - private loadLevel(level: number) { - this.levelIndex = level; + private loadFloor(floor: number) { + this.floorIndex = floor; - const { world, playerId } = makeTestWorld(level, this.runState); + const { world, playerId } = generateWorld(floor, this.runState); this.world = world; this.playerId = playerId; @@ -281,8 +291,8 @@ export class GameScene extends Phaser.Scene { // Camera bounds for this level this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); - // Initialize Renderer for new level - this.dungeonRenderer.initializeLevel(this.world); + // Initialize Renderer for new floor + this.dungeonRenderer.initializeFloor(this.world); // Step until player turn const enemyStep = stepUntilPlayerTurn(this.world, this.playerId); @@ -292,7 +302,6 @@ export class GameScene extends Phaser.Scene { this.centerCameraOnPlayer(); this.dungeonRenderer.render(this.playerPath); this.emitUIUpdate(); - } private syncRunStateFromPlayer() { @@ -310,9 +319,11 @@ export class GameScene extends Phaser.Scene { stats: { ...GAME_CONFIG.player.initialStats }, inventory: { gold: 0, items: [] } }; - this.loadLevel(1); + this.floorIndex = 1; + this.loadFloor(this.floorIndex); } + private centerCameraOnPlayer() { const player = this.world.actors.get(this.playerId)!; this.cameras.main.centerOn( diff --git a/src/scenes/__tests__/GameScene.test.ts b/src/scenes/__tests__/GameScene.test.ts index 8862a09..50cede9 100644 --- a/src/scenes/__tests__/GameScene.test.ts +++ b/src/scenes/__tests__/GameScene.test.ts @@ -61,7 +61,8 @@ vi.mock('phaser', () => { vi.mock('../../rendering/DungeonRenderer', () => ({ DungeonRenderer: vi.fn().mockImplementation(function() { return { - initializeLevel: vi.fn(), + initializeFloor: vi.fn(), + computeFov: vi.fn(), render: vi.fn(), showDamage: vi.fn(), @@ -78,7 +79,8 @@ vi.mock('../../engine/simulation/simulation', () => ({ })); vi.mock('../../engine/world/generator', () => ({ - makeTestWorld: vi.fn(), + generateWorld: vi.fn(), + })); vi.mock('../../engine/world/world-logic', () => ({ @@ -133,7 +135,8 @@ describe('GameScene', () => { }; mockWorld.actors.set(1, mockPlayer); - (generator.makeTestWorld as any).mockReturnValue({ + (generator.generateWorld as any).mockReturnValue({ + world: mockWorld, playerId: 1, }); @@ -170,7 +173,8 @@ describe('GameScene', () => { // Verify it was called with some stats const callArgs = mockUI.showDeathScreen.mock.calls[0][0]; - expect(callArgs).toHaveProperty('level'); + expect(callArgs).toHaveProperty('floor'); + expect(callArgs).toHaveProperty('gold'); expect(callArgs).toHaveProperty('stats'); }); diff --git a/src/ui/GameUI.ts b/src/ui/GameUI.ts index 717fbe7..421161e 100644 --- a/src/ui/GameUI.ts +++ b/src/ui/GameUI.ts @@ -1,11 +1,13 @@ import Phaser from "phaser"; -import { type World, type EntityId } from "../core/types"; +import { type World, type EntityId, type Stats } from "../core/types"; import { GAME_CONFIG } from "../core/config/GameConfig"; export default class GameUI extends Phaser.Scene { // HUD - private levelText!: Phaser.GameObjects.Text; + private floorText!: Phaser.GameObjects.Text; private healthBar!: Phaser.GameObjects.Graphics; + private expBar!: Phaser.GameObjects.Graphics; + // Menu private menuOpen = false; @@ -31,22 +33,23 @@ export default class GameUI extends Phaser.Scene { // Listen for updates from GameScene const gameScene = this.scene.get("GameScene"); - gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; levelIndex: number }) => { - this.updateUI(data.world, data.playerId, data.levelIndex); + 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("close-menu", () => this.setMenuOpen(false)); } private createHud() { - this.levelText = this.add.text(10, 10, "Level 1", { + this.floorText = this.add.text(10, 10, "Floor 1", { fontSize: "20px", color: "#ffffff", fontStyle: "bold" }).setDepth(100); this.healthBar = this.add.graphics().setDepth(100); + this.expBar = this.add.graphics().setDepth(100); } private createMenu() { @@ -163,11 +166,13 @@ export default class GameUI extends Phaser.Scene { this.deathContainer.setVisible(false); } - showDeathScreen(data: { level: number; gold: number; stats: any }) { + showDeathScreen(data: { floor: number; gold: number; stats: Stats }) { const lines = [ - `Dungeon Level: ${data.level}`, + `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}` @@ -211,28 +216,40 @@ export default class GameUI extends Phaser.Scene { gameScene.events.emit("toggle-minimap"); } - private updateUI(world: World, playerId: EntityId, levelIndex: number) { - this.updateHud(world, playerId, levelIndex); + private updateUI(world: World, playerId: EntityId, floorIndex: number) { + this.updateHud(world, playerId, floorIndex); if (this.menuOpen) { - this.updateMenuText(world, playerId, levelIndex); + this.updateMenuText(world, playerId, floorIndex); } } - private updateHud(world: World, playerId: EntityId, levelIndex: number) { - this.levelText.setText(`Level ${levelIndex}`); + private updateHud(world: World, playerId: EntityId, floorIndex: number) { + this.floorText.setText(`Floor ${floorIndex}`); + const p = world.actors.get(playerId); if (!p || !p.stats) return; - const barX = 10; + const barX = 40; const barY = 40; - const barW = 200; + 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); @@ -243,21 +260,54 @@ export default class GameUI extends Phaser.Scene { 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, levelIndex: number) { + + private updateMenuText(world: World, playerId: EntityId, _floorIndex: number) { + + const p = world.actors.get(playerId); const stats = p?.stats; const inv = p?.inventory; const lines: string[] = []; - lines.push(`Level ${levelIndex}`); + lines.push(`Level ${stats?.level ?? 1}`); lines.push(""); lines.push("Stats"); lines.push(` HP: ${stats?.hp ?? 0}/${stats?.maxHp ?? 0}`); + lines.push(` EXP: ${stats?.exp ?? 0}/${stats?.expToNextLevel ?? 0}`); lines.push(` Attack: ${stats?.attack ?? 0}`); lines.push(` Defense: ${stats?.defense ?? 0}`); lines.push(` Speed: ${p?.speed ?? 0}`); + lines.push(""); lines.push("Inventory"); lines.push(` Gold: ${inv?.gold ?? 0}`);