Add levelling up mechanics through experience gained via killing enemies
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user