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