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 = { export const GAME_CONFIG = {
player: { 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, speed: 100,
viewRadius: 8 viewRadius: 8
}, },
map: { map: {
width: 60, width: 60,
height: 40, height: 40,
@@ -17,18 +27,26 @@ export const GAME_CONFIG = {
}, },
enemy: { enemy: {
baseHpPerLevel: 2,
baseHp: 8, baseHp: 8,
baseAttack: 3, baseAttack: 3,
attackPerTwoLevels: 1,
minSpeed: 80, minSpeed: 80,
maxSpeed: 130, maxSpeed: 130,
maxDefense: 2, baseHpPerFloor: 5,
baseCountPerLevel: 1, attackPerTwoFloors: 1,
baseCount: 3, baseCount: 3,
randomBonus: 4 baseCountPerFloor: 3,
ratExp: 5,
batExp: 8
}, },
leveling: {
baseExpToNextLevel: 10,
expMultiplier: 1.5,
hpGainPerLevel: 5,
attackGainPerLevel: 1
},
rendering: { rendering: {
tileSize: 16, tileSize: 16,
cameraZoom: 2, cameraZoom: 2,
@@ -38,6 +56,9 @@ export const GAME_CONFIG = {
playerColor: 0x66ff66, playerColor: 0x66ff66,
enemyColor: 0xff6666, enemyColor: 0xff6666,
pathPreviewColor: 0x3355ff, pathPreviewColor: 0x3355ff,
expOrbColor: 0x33ccff,
expTextColor: 0x33ccff,
levelUpColor: 0xffff00,
fogAlphaFloor: 0.15, fogAlphaFloor: 0.15,
fogAlphaWall: 0.35, fogAlphaWall: 0.35,
visibleMinAlpha: 0.35, visibleMinAlpha: 0.35,
@@ -45,6 +66,7 @@ export const GAME_CONFIG = {
visibleStrengthFactor: 0.65 visibleStrengthFactor: 0.65
}, },
terrain: { terrain: {
empty: 1, empty: 1,
wall: 4, wall: 4,

View File

@@ -13,16 +13,25 @@ export type SimEvent =
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
| { type: "attacked"; attackerId: EntityId; targetId: EntityId } | { type: "attacked"; attackerId: EntityId; targetId: EntityId }
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number } | { 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: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" | "exp_orb" }
| { type: "waited"; actorId: EntityId };
| { 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 = { export type Stats = {
maxHp: number; maxHp: number;
hp: number; hp: number;
attack: number; attack: number;
defense: number; defense: number;
level: number;
exp: number;
expToNextLevel: number;
}; };
export type Inventory = { export type Inventory = {
gold: number; gold: number;
items: string[]; items: string[];
@@ -36,8 +45,9 @@ export type RunState = {
export type Actor = { export type Actor = {
id: EntityId; id: EntityId;
isPlayer: boolean; isPlayer: boolean;
type?: "player" | "rat" | "bat"; type?: "player" | "rat" | "bat" | "exp_orb";
pos: Vec2; pos: Vec2;
speed: number; speed: number;
energy: number; energy: number;

View File

@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic'; import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
import { type World, type Tile } from '../../core/types'; import { type World, type Tile } from '../../core/types';
import { GAME_CONFIG } from '../../core/config/GameConfig';
describe('World Utilities', () => { describe('World Utilities', () => {
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({ const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
@@ -44,9 +46,10 @@ describe('World Utilities', () => {
describe('isWall', () => { describe('isWall', () => {
it('should return true for wall tiles', () => { it('should return true for wall tiles', () => {
const tiles: Tile[] = new Array(100).fill(0); const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
tiles[0] = 1; // wall at 0,0 tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0
tiles[55] = 1; // wall at 5,5 tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
@@ -55,11 +58,12 @@ describe('World Utilities', () => {
}); });
it('should return false for floor tiles', () => { 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); const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false); expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false); expect(isWall(world, 7, 7)).toBe(false);
}); });
it('should return false for out of bounds coordinates', () => { it('should return false for out of bounds coordinates', () => {
@@ -72,8 +76,9 @@ describe('World Utilities', () => {
describe('isBlocked', () => { describe('isBlocked', () => {
it('should return true for walls', () => { it('should return true for walls', () => {
const tiles: Tile[] = new Array(100).fill(0); const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
tiles[55] = 1; // wall at 5,5 tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
const world = createTestWorld(10, 10, tiles); 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 type { World, EntityId, Action, SimEvent, Actor } from "../../core/types";
import { isBlocked } from "../world/world-logic"; import { isBlocked } from "../world/world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig";
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] { export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
const actor = w.actors.get(actorId); 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) // Spend energy for any action (move/wait/attack)
actor.energy -= ACTION_COST; actor.energy -= GAME_CONFIG.gameplay.actionCost;
return events; 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[] { function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] {
const from = { ...actor.pos }; const from = { ...actor.pos };
const nx = actor.pos.x + action.dx; 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.x = nx;
actor.pos.y = ny; actor.pos.y = ny;
const to = { ...actor.pos }; 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 { } else {
return [{ type: "waited", actorId: actor.id }]; return [{ type: "waited", actorId: actor.id }];
} }
} }
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] { function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
const target = w.actors.get(action.targetId); const target = w.actors.get(action.targetId);
if (target && target.stats && actor.stats) { if (target && target.stats && actor.stats) {
@@ -69,12 +126,29 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
victimType: target.type victimType: target.type
}); });
w.actors.delete(target.id); 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 events;
} }
return [{ type: "waited", actorId: actor.id }]; return [{ type: "waited", actorId: actor.id }];
} }
/** /**
* Very basic enemy AI: * Very basic enemy AI:
* - if adjacent to player, attack * - if adjacent to player, attack
@@ -120,11 +194,12 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
const events: SimEvent[] = []; const events: SimEvent[] = [];
while (true) { 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; 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)); ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
const actor = ready[0]; const actor = ready[0];

View File

@@ -12,16 +12,16 @@ interface Room {
/** /**
* Generates a procedural dungeon world with rooms and corridors * Generates a procedural dungeon world with rooms and corridors
* @param level The level number (affects difficulty and randomness seed) * @param floor The floor number (affects difficulty)
* @param runState Player's persistent state across levels * @param runState Player's persistent state across floors
* @returns Generated world and player ID * @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 width = GAME_CONFIG.map.width;
const height = GAME_CONFIG.map.height; 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); 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 playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 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 actors = new Map<EntityId, Actor>();
const playerId = 1; const playerId = 1;
actors.set(playerId, { actors.set(playerId, {
id: playerId, id: playerId,
isPlayer: true, isPlayer: true,
@@ -50,13 +44,23 @@ export function generateWorld(level: number, runState: RunState): { world: World
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] } 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); 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[] { function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] {
const rooms: Room[] = []; const rooms: Room[] = [];
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1)); 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; 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; 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++) { for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1)); 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 enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2)); const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel; const baseHp = GAME_CONFIG.enemy.baseHp + floor * GAME_CONFIG.enemy.baseHpPerFloor;
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels; const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemy.attackPerTwoFloors;
actors.set(enemyId, { actors.set(enemyId, {
id: enemyId, id: enemyId,
@@ -229,11 +233,15 @@ function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>
maxHp: baseHp + Math.floor(random() * 4), maxHp: baseHp + Math.floor(random() * 4),
hp: baseHp + Math.floor(random() * 4), hp: baseHp + Math.floor(random() * 4),
attack: baseAttack + Math.floor(random() * 2), attack: baseAttack + Math.floor(random() * 2),
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1)) defense: 0,
level: 0,
exp: 0,
expToNextLevel: 0
} }
}); });
enemyId++; enemyId++;
} }
} }
export const makeTestWorld = generateWorld; 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; if (isWall(w, x, y)) return true;
for (const a of w.actors.values()) { 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; return false;
} }
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;

View File

@@ -12,8 +12,10 @@ export class DungeonRenderer {
private playerSprite?: Phaser.GameObjects.Sprite; private playerSprite?: Phaser.GameObjects.Sprite;
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map(); private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
private corpseSprites: Phaser.GameObjects.Sprite[] = []; private corpseSprites: Phaser.GameObjects.Sprite[] = [];
// FOV // FOV
private fov!: any; private fov!: any;
private seen!: Uint8Array; private seen!: Uint8Array;
@@ -52,7 +54,8 @@ export class DungeonRenderer {
this.minimapContainer.setVisible(false); this.minimapContainer.setVisible(false);
} }
initializeLevel(world: World) { initializeFloor(world: World) {
this.world = world; this.world = world;
this.seen = new Uint8Array(this.world.width * this.world.height); this.seen = new Uint8Array(this.world.width * this.world.height);
this.visible = 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(); 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 textureKey = type === "player" ? "warrior" : type;
const corpse = this.scene.add.sprite( const corpse = this.scene.add.sprite(
x * TILE_SIZE + TILE_SIZE / 2, x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2,
@@ -389,4 +429,54 @@ export class DungeonRenderer {
onComplete: () => text.destroy() 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), exists: vi.fn().mockReturnValue(true),
generateFrameNumbers: vi.fn(), 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 = { mockWorld = {
@@ -107,8 +118,9 @@ describe('DungeonRenderer', () => {
renderer = new DungeonRenderer(mockScene); renderer = new DungeonRenderer(mockScene);
}); });
it('should track and clear corpse sprites on level initialization', () => { it('should track and clear corpse sprites on floor initialization', () => {
renderer.initializeLevel(mockWorld); renderer.initializeFloor(mockWorld);
// Spawn a couple of corpses // Spawn a couple of corpses
renderer.spawnCorpse(1, 1, 'rat'); renderer.spawnCorpse(1, 1, 'rat');
@@ -120,8 +132,9 @@ describe('DungeonRenderer', () => {
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
// Initialize level again (changing level) // Initialize floor again (changing level)
renderer.initializeLevel(mockWorld); renderer.initializeFloor(mockWorld);
// Verify destroy was called on both corpse sprites // Verify destroy was called on both corpse sprites
expect(corpse1.destroy).toHaveBeenCalledTimes(1); 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 { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
import { findPathAStar } from "../engine/world/pathfinding"; import { findPathAStar } from "../engine/world/pathfinding";
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation"; 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 { DungeonRenderer } from "../rendering/DungeonRenderer";
import { GAME_CONFIG } from "../core/config/GameConfig"; import { GAME_CONFIG } from "../core/config/GameConfig";
@@ -18,7 +19,8 @@ export class GameScene extends Phaser.Scene {
private world!: World; private world!: World;
private playerId!: EntityId; private playerId!: EntityId;
private levelIndex = 1; private floorIndex = 1;
private gameState: "playing" | "player-turn" | "enemy-turn" = "player-turn";
private runState: RunState = { private runState: RunState = {
stats: { ...GAME_CONFIG.player.initialStats }, stats: { ...GAME_CONFIG.player.initialStats },
@@ -62,8 +64,8 @@ export class GameScene extends Phaser.Scene {
this.isMenuOpen = isOpen; this.isMenuOpen = isOpen;
}); });
// Load initial level // Load initial floor
this.loadLevel(1); this.loadFloor(1);
// Menu Inputs // Menu Inputs
this.input.keyboard?.on("keydown-I", () => { this.input.keyboard?.on("keydown-I", () => {
@@ -214,7 +216,7 @@ export class GameScene extends Phaser.Scene {
this.events.emit("update-ui", { this.events.emit("update-ui", {
world: this.world, world: this.world,
playerId: this.playerId, playerId: this.playerId,
levelIndex: this.levelIndex floorIndex: this.floorIndex
}); });
} }
@@ -237,16 +239,23 @@ export class GameScene extends Phaser.Scene {
if (player) { if (player) {
this.dungeonRenderer.showWait(player.pos.x, player.pos.y); 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 // Check if player died
if (!this.world.actors.has(this.playerId)) { if (!this.world.actors.has(this.playerId)) {
this.syncRunStateFromPlayer(); // Save final stats for death screen this.syncRunStateFromPlayer(); // Save final stats for death screen
const uiScene = this.scene.get("GameUI") as any; const uiScene = this.scene.get("GameUI") as any;
if (uiScene) { if (uiScene) {
uiScene.showDeathScreen({ uiScene.showDeathScreen({
level: this.levelIndex, floor: this.floorIndex,
gold: this.runState.inventory.gold, gold: this.runState.inventory.gold,
stats: this.runState.stats stats: this.runState.stats
}); });
@@ -257,7 +266,8 @@ export class GameScene extends Phaser.Scene {
// Level transition // Level transition
if (isPlayerOnExit(this.world, this.playerId)) { if (isPlayerOnExit(this.world, this.playerId)) {
this.syncRunStateFromPlayer(); this.syncRunStateFromPlayer();
this.loadLevel(this.levelIndex + 1); this.floorIndex++;
this.loadFloor(this.floorIndex);
return; return;
} }
@@ -267,10 +277,10 @@ export class GameScene extends Phaser.Scene {
this.emitUIUpdate(); this.emitUIUpdate();
} }
private loadLevel(level: number) { private loadFloor(floor: number) {
this.levelIndex = level; this.floorIndex = floor;
const { world, playerId } = makeTestWorld(level, this.runState); const { world, playerId } = generateWorld(floor, this.runState);
this.world = world; this.world = world;
this.playerId = playerId; this.playerId = playerId;
@@ -281,8 +291,8 @@ export class GameScene extends Phaser.Scene {
// Camera bounds for this level // Camera bounds for this level
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
// Initialize Renderer for new level // Initialize Renderer for new floor
this.dungeonRenderer.initializeLevel(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);
@@ -292,7 +302,6 @@ export class GameScene extends Phaser.Scene {
this.centerCameraOnPlayer(); this.centerCameraOnPlayer();
this.dungeonRenderer.render(this.playerPath); this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate(); this.emitUIUpdate();
} }
private syncRunStateFromPlayer() { private syncRunStateFromPlayer() {
@@ -310,9 +319,11 @@ export class GameScene extends Phaser.Scene {
stats: { ...GAME_CONFIG.player.initialStats }, stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
this.loadLevel(1); this.floorIndex = 1;
this.loadFloor(this.floorIndex);
} }
private centerCameraOnPlayer() { private centerCameraOnPlayer() {
const player = this.world.actors.get(this.playerId)!; const player = this.world.actors.get(this.playerId)!;
this.cameras.main.centerOn( this.cameras.main.centerOn(

View File

@@ -61,7 +61,8 @@ vi.mock('phaser', () => {
vi.mock('../../rendering/DungeonRenderer', () => ({ vi.mock('../../rendering/DungeonRenderer', () => ({
DungeonRenderer: vi.fn().mockImplementation(function() { DungeonRenderer: vi.fn().mockImplementation(function() {
return { return {
initializeLevel: vi.fn(), initializeFloor: vi.fn(),
computeFov: vi.fn(), computeFov: vi.fn(),
render: vi.fn(), render: vi.fn(),
showDamage: vi.fn(), showDamage: vi.fn(),
@@ -78,7 +79,8 @@ vi.mock('../../engine/simulation/simulation', () => ({
})); }));
vi.mock('../../engine/world/generator', () => ({ vi.mock('../../engine/world/generator', () => ({
makeTestWorld: vi.fn(), generateWorld: vi.fn(),
})); }));
vi.mock('../../engine/world/world-logic', () => ({ vi.mock('../../engine/world/world-logic', () => ({
@@ -133,7 +135,8 @@ describe('GameScene', () => {
}; };
mockWorld.actors.set(1, mockPlayer); mockWorld.actors.set(1, mockPlayer);
(generator.makeTestWorld as any).mockReturnValue({ (generator.generateWorld as any).mockReturnValue({
world: mockWorld, world: mockWorld,
playerId: 1, playerId: 1,
}); });
@@ -170,7 +173,8 @@ describe('GameScene', () => {
// Verify it was called with some stats // Verify it was called with some stats
const callArgs = mockUI.showDeathScreen.mock.calls[0][0]; const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
expect(callArgs).toHaveProperty('level'); expect(callArgs).toHaveProperty('floor');
expect(callArgs).toHaveProperty('gold'); expect(callArgs).toHaveProperty('gold');
expect(callArgs).toHaveProperty('stats'); expect(callArgs).toHaveProperty('stats');
}); });

View File

@@ -1,11 +1,13 @@
import Phaser from "phaser"; 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"; import { GAME_CONFIG } from "../core/config/GameConfig";
export default class GameUI extends Phaser.Scene { export default class GameUI extends Phaser.Scene {
// HUD // HUD
private levelText!: Phaser.GameObjects.Text; private floorText!: Phaser.GameObjects.Text;
private healthBar!: Phaser.GameObjects.Graphics; private healthBar!: Phaser.GameObjects.Graphics;
private expBar!: Phaser.GameObjects.Graphics;
// Menu // Menu
private menuOpen = false; private menuOpen = false;
@@ -31,8 +33,8 @@ export default class GameUI extends Phaser.Scene {
// Listen for updates from GameScene // Listen for updates from GameScene
const gameScene = this.scene.get("GameScene"); const gameScene = this.scene.get("GameScene");
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; levelIndex: number }) => { gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
this.updateUI(data.world, data.playerId, data.levelIndex); this.updateUI(data.world, data.playerId, data.floorIndex);
}); });
gameScene.events.on("toggle-menu", () => this.toggleMenu()); gameScene.events.on("toggle-menu", () => this.toggleMenu());
@@ -40,13 +42,14 @@ export default class GameUI extends Phaser.Scene {
} }
private createHud() { private createHud() {
this.levelText = this.add.text(10, 10, "Level 1", { this.floorText = this.add.text(10, 10, "Floor 1", {
fontSize: "20px", fontSize: "20px",
color: "#ffffff", color: "#ffffff",
fontStyle: "bold" fontStyle: "bold"
}).setDepth(100); }).setDepth(100);
this.healthBar = this.add.graphics().setDepth(100); this.healthBar = this.add.graphics().setDepth(100);
this.expBar = this.add.graphics().setDepth(100);
} }
private createMenu() { private createMenu() {
@@ -163,11 +166,13 @@ export default class GameUI extends Phaser.Scene {
this.deathContainer.setVisible(false); this.deathContainer.setVisible(false);
} }
showDeathScreen(data: { level: number; gold: number; stats: any }) { showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
const lines = [ const lines = [
`Dungeon Level: ${data.level}`, `Dungeon Floor: ${data.floor}`,
`Gold Collected: ${data.gold}`, `Gold Collected: ${data.gold}`,
"", "",
`Experience gained: ${data.stats.exp}`,
`Final HP: 0 / ${data.stats.maxHp}`, `Final HP: 0 / ${data.stats.maxHp}`,
`Attack: ${data.stats.attack}`, `Attack: ${data.stats.attack}`,
`Defense: ${data.stats.defense}` `Defense: ${data.stats.defense}`
@@ -211,28 +216,40 @@ export default class GameUI extends Phaser.Scene {
gameScene.events.emit("toggle-minimap"); gameScene.events.emit("toggle-minimap");
} }
private updateUI(world: World, playerId: EntityId, levelIndex: number) { private updateUI(world: World, playerId: EntityId, floorIndex: number) {
this.updateHud(world, playerId, levelIndex); this.updateHud(world, playerId, floorIndex);
if (this.menuOpen) { if (this.menuOpen) {
this.updateMenuText(world, playerId, levelIndex); this.updateMenuText(world, playerId, floorIndex);
} }
} }
private updateHud(world: World, playerId: EntityId, levelIndex: number) { private updateHud(world: World, playerId: EntityId, floorIndex: number) {
this.levelText.setText(`Level ${levelIndex}`); this.floorText.setText(`Floor ${floorIndex}`);
const p = world.actors.get(playerId); const p = world.actors.get(playerId);
if (!p || !p.stats) return; if (!p || !p.stats) return;
const barX = 10; const barX = 40;
const barY = 40; const barY = 40;
const barW = 200; const barW = 180;
const barH = 16; const barH = 16;
this.healthBar.clear(); 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.fillStyle(0x444444, 1);
this.healthBar.fillRect(barX, barY, barW, barH); this.healthBar.fillRect(barX, barY, barW, barH);
const hp = Math.max(0, p.stats.hp); const hp = Math.max(0, p.stats.hp);
const maxHp = Math.max(1, p.stats.maxHp); const maxHp = Math.max(1, p.stats.maxHp);
const pct = Phaser.Math.Clamp(hp / maxHp, 0, 1); 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.lineStyle(2, 0xffffff, 1);
this.healthBar.strokeRect(barX, barY, barW, barH); 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 p = world.actors.get(playerId);
const stats = p?.stats; const stats = p?.stats;
const inv = p?.inventory; const inv = p?.inventory;
const lines: string[] = []; const lines: string[] = [];
lines.push(`Level ${levelIndex}`); lines.push(`Level ${stats?.level ?? 1}`);
lines.push(""); lines.push("");
lines.push("Stats"); lines.push("Stats");
lines.push(` HP: ${stats?.hp ?? 0}/${stats?.maxHp ?? 0}`); 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(` Attack: ${stats?.attack ?? 0}`);
lines.push(` Defense: ${stats?.defense ?? 0}`); lines.push(` Defense: ${stats?.defense ?? 0}`);
lines.push(` Speed: ${p?.speed ?? 0}`); lines.push(` Speed: ${p?.speed ?? 0}`);
lines.push(""); lines.push("");
lines.push("Inventory"); lines.push("Inventory");
lines.push(` Gold: ${inv?.gold ?? 0}`); lines.push(` Gold: ${inv?.gold ?? 0}`);