Add levelling up mechanics through experience gained via killing enemies

This commit is contained in:
Peter Stockings
2026-01-04 18:36:31 +11:00
parent 42cd77998d
commit 29e46093f5
11 changed files with 373 additions and 84 deletions

View File

@@ -1,10 +1,20 @@
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,
height: 40,
@@ -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,

View File

@@ -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;

View File

@@ -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);

View File

@@ -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];

View File

@@ -12,16 +12,16 @@ 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(level * 12345);
const random = seededRandom(floor * 12345);
const rooms = generateRooms(width, height, tiles, random);
@@ -30,15 +30,9 @@ export function generateWorld(level: number, runState: RunState): { world: World
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<EntityId, Actor>();
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<EntityId, Actor>, random: () => number): void {
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, 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<EntityId, Actor>
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<EntityId, Actor>
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;

View File

@@ -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;

View File

@@ -12,8 +12,10 @@ export class DungeonRenderer {
private playerSprite?: Phaser.GameObjects.Sprite;
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
// FOV
private fov!: any;
private seen!: Uint8Array;
@@ -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<EntityId>();
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()
});
}
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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');
});

View File

@@ -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,8 +33,8 @@ 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());
@@ -40,13 +42,14 @@ export default class GameUI extends Phaser.Scene {
}
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}`);