Add more stats, crit/block/accuracy/dodge/lifesteal

This commit is contained in:
Peter Stockings
2026-01-05 12:39:43 +11:00
parent 171abb681a
commit 86a6afd1df
14 changed files with 815 additions and 406 deletions

View File

@@ -13,6 +13,16 @@ export const GAME_CONFIG = {
strength: 10, strength: 10,
dexterity: 10, dexterity: 10,
intelligence: 10, intelligence: 10,
// Offensive
critChance: 5,
critMultiplier: 150,
accuracy: 90,
lifesteal: 0,
// Defensive
evasion: 5,
blockChance: 0,
// Utility
luck: 0,
passiveNodes: [] as string[] passiveNodes: [] as string[]
}, },
speed: 100, speed: 100,

View File

@@ -12,8 +12,10 @@ export type Action =
export type SimEvent = 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; isCrit?: boolean; isBlock?: boolean }
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" | "exp_orb" } | { type: "dodged"; targetId: EntityId; x: number; y: number }
| { type: "healed"; actorId: EntityId; amount: number; x: number; y: number }
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" }
| { type: "waited"; actorId: EntityId } | { type: "waited"; actorId: EntityId }
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number } | { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
@@ -30,6 +32,19 @@ export type Stats = {
exp: number; exp: number;
expToNextLevel: number; expToNextLevel: number;
// Offensive
critChance: number;
critMultiplier: number;
accuracy: number;
lifesteal: number;
// Defensive
evasion: number;
blockChance: number;
// Utility
luck: number;
// New Progression Fields // New Progression Fields
statPoints: number; statPoints: number;
skillPoints: number; skillPoints: number;
@@ -83,19 +98,35 @@ export type RunState = {
inventory: Inventory; inventory: Inventory;
}; };
export type Actor = { export interface BaseActor {
id: EntityId; id: EntityId;
isPlayer: boolean;
type?: "player" | "rat" | "bat" | "exp_orb";
pos: Vec2; pos: Vec2;
type?: string;
}
export interface CombatantActor extends BaseActor {
category: "combatant";
isPlayer: boolean;
type: "player" | "rat" | "bat";
speed: number; speed: number;
energy: number; energy: number;
stats: Stats;
stats?: Stats;
inventory?: Inventory; inventory?: Inventory;
equipment?: Equipment; equipment?: Equipment;
}; }
export interface CollectibleActor extends BaseActor {
category: "collectible";
type: "exp_orb";
expAmount: number;
}
export interface ItemActor extends BaseActor {
category: "item";
item: Item;
}
export type Actor = CombatantActor | CollectibleActor | ItemActor;
export type World = { export type World = {
width: number; width: number;

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { generateWorld } from '../world/generator'; import { generateWorld } from '../world/generator';
import { isWall, inBounds } from '../world/world-logic'; import { isWall, inBounds } from '../world/world-logic';
import { type CombatantActor } from '../../core/types';
describe('World Generator', () => { describe('World Generator', () => {
describe('generateWorld', () => { describe('generateWorld', () => {
@@ -8,7 +9,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -24,7 +27,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -32,17 +37,20 @@ describe('World Generator', () => {
const { world, playerId } = generateWorld(1, runState); const { world, playerId } = generateWorld(1, runState);
expect(playerId).toBe(1); expect(playerId).toBe(1);
const player = world.actors.get(playerId); const player = world.actors.get(playerId) as CombatantActor;
expect(player).toBeDefined(); expect(player).toBeDefined();
expect(player?.isPlayer).toBe(true); expect(player.category).toBe("combatant");
expect(player?.stats).toEqual(runState.stats); expect(player.isPlayer).toBe(true);
expect(player.stats).toEqual(runState.stats);
}); });
it('should create walkable rooms', () => { it('should create walkable rooms', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -58,7 +66,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -74,7 +84,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -85,14 +97,14 @@ describe('World Generator', () => {
expect(world.actors.size).toBeGreaterThan(1); expect(world.actors.size).toBeGreaterThan(1);
// All non-player actors should be enemies // All non-player actors should be enemies
const enemies = Array.from(world.actors.values()).filter(a => !a.isPlayer); const enemies = Array.from(world.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
expect(enemies.length).toBeGreaterThan(0); expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats // Enemies should have stats
enemies.forEach(enemy => { enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined(); expect(enemy.stats).toBeDefined();
expect(enemy.stats!.hp).toBeGreaterThan(0); expect(enemy.stats.hp).toBeGreaterThan(0);
expect(enemy.stats!.attack).toBeGreaterThan(0); expect(enemy.stats.attack).toBeGreaterThan(0);
}); });
}); });
@@ -100,7 +112,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -121,7 +135,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -137,7 +153,9 @@ describe('World Generator', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [] statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
}, },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -145,15 +163,15 @@ describe('World Generator', () => {
const { world: world1 } = generateWorld(1, runState); const { world: world1 } = generateWorld(1, runState);
const { world: world5 } = generateWorld(5, runState); const { world: world5 } = generateWorld(5, runState);
const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer); const enemies1 = Array.from(world1.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
const enemies5 = Array.from(world5.actors.values()).filter(a => !a.isPlayer); const enemies5 = Array.from(world5.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
// Higher level should have more enemies // Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length); expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats // Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.length; const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies5.length; const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
expect(avgHp5).toBeGreaterThan(avgHp1); expect(avgHp5).toBeGreaterThan(avgHp1);
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { applyAction } from '../simulation/simulation'; import { applyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId } from '../../core/types'; import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
describe('Combat Simulation', () => { describe('Combat Simulation', () => {
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({ const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
@@ -14,6 +14,14 @@ describe('Combat Simulation', () => {
const createTestStats = (overrides: Partial<any> = {}) => ({ const createTestStats = (overrides: Partial<any> = {}) => ({
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [], statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
// New stats (defaults for tests to keep them deterministic)
critChance: 0,
critMultiplier: 100,
accuracy: 100, // Always hit in tests unless specified
lifesteal: 0,
evasion: 0,
blockChance: 0,
luck: 0,
...overrides ...overrides
}); });
@@ -22,7 +30,9 @@ describe('Combat Simulation', () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1, {
id: 1, id: 1,
category: "combatant",
isPlayer: true, isPlayer: true,
type: "player",
pos: { x: 3, y: 3 }, pos: { x: 3, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -30,7 +40,9 @@ describe('Combat Simulation', () => {
}); });
actors.set(2, { actors.set(2, {
id: 2, id: 2,
category: "combatant",
isPlayer: false, isPlayer: false,
type: "rat",
pos: { x: 4, y: 3 }, pos: { x: 4, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -38,20 +50,22 @@ describe('Combat Simulation', () => {
}); });
const world = createTestWorld(actors); const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 }); const events = applyAction(world, 1, { type: "attack", targetId: 2 });
const enemy = world.actors.get(2)!; const enemy = world.actors.get(2) as CombatantActor;
expect(enemy.stats!.hp).toBeLessThan(10); expect(enemy.stats.hp).toBeLessThan(10);
// Should have attack event // Should have attack event
expect(events.some(e => e.type === 'attacked')).toBe(true); expect(events.some(e => e.type === "attacked")).toBe(true);
}); });
it('should kill enemy when damage exceeds hp', () => { it("should kill enemy when damage exceeds hp", () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1, {
id: 1, id: 1,
category: "combatant",
isPlayer: true, isPlayer: true,
type: "player",
pos: { x: 3, y: 3 }, pos: { x: 3, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -59,7 +73,9 @@ describe('Combat Simulation', () => {
}); });
actors.set(2, { actors.set(2, {
id: 2, id: 2,
category: "combatant",
isPlayer: false, isPlayer: false,
type: "rat",
pos: { x: 4, y: 3 }, pos: { x: 4, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -67,20 +83,22 @@ describe('Combat Simulation', () => {
}); });
const world = createTestWorld(actors); const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 }); const events = applyAction(world, 1, { type: "attack", targetId: 2 });
// Enemy should be removed from world // Enemy should be removed from world
expect(world.actors.has(2)).toBe(false); expect(world.actors.has(2)).toBe(false);
// Should have killed event // Should have killed event
expect(events.some(e => e.type === 'killed')).toBe(true); expect(events.some(e => e.type === "killed")).toBe(true);
}); });
it('should apply defense to reduce damage', () => { it("should apply defense to reduce damage", () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1, {
id: 1, id: 1,
category: "combatant",
isPlayer: true, isPlayer: true,
type: "player",
pos: { x: 3, y: 3 }, pos: { x: 3, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -88,7 +106,9 @@ describe('Combat Simulation', () => {
}); });
actors.set(2, { actors.set(2, {
id: 2, id: 2,
category: "combatant",
isPlayer: false, isPlayer: false,
type: "rat",
pos: { x: 4, y: 3 }, pos: { x: 4, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -96,22 +116,98 @@ describe('Combat Simulation', () => {
}); });
const world = createTestWorld(actors); const world = createTestWorld(actors);
applyAction(world, 1, { type: 'attack', targetId: 2 }); applyAction(world, 1, { type: "attack", targetId: 2 });
const enemy = world.actors.get(2)!; const enemy = world.actors.get(2) as CombatantActor;
const damage = 10 - enemy.stats!. hp; const damage = 10 - enemy.stats.hp;
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage) // Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
expect(damage).toBe(2); expect(damage).toBe(2);
}); });
it("should emit dodged event when attack misses", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ accuracy: 0 }) // Force miss
});
actors.set(2, {
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ evasion: 0 })
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
expect(events.some(e => e.type === "dodged")).toBe(true);
expect((world.actors.get(2) as CombatantActor).stats.hp).toBe(20);
});
it("should emit crit flag when critical strike occurs", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ critChance: 100, critMultiplier: 200, attack: 10 })
});
actors.set(2, {
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ defense: 0, hp: 100 })
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
const damagedEvent = events.find(e => e.type === "damaged") as any;
expect(damagedEvent.isCrit).toBe(true);
expect(damagedEvent.amount).toBe(20); // 10 * 2.0
});
it("should emit block flag and reduce damage", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ attack: 10 })
});
actors.set(2, {
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ defense: 0, blockChance: 100, hp: 100 }) // Force block
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
const damagedEvent = events.find(e => e.type === "damaged") as any;
expect(damagedEvent.isBlock).toBe(true);
expect(damagedEvent.amount).toBe(5); // 10 * 0.5
});
it("should heal attacker via lifesteal", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ attack: 10, lifesteal: 50, hp: 5, maxHp: 20 })
});
actors.set(2, {
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
stats: createTestStats({ defense: 0 })
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
expect(events.some(e => e.type === "healed")).toBe(true);
expect((world.actors.get(1) as CombatantActor).stats.hp).toBe(10); // 5 + (10 * 0.5)
});
}); });
describe('applyAction - move', () => { describe("applyAction - move", () => {
it('should move actor to new position', () => { it("should move actor to new position", () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1, {
id: 1, id: 1,
category: "combatant",
isPlayer: true, isPlayer: true,
type: "player",
pos: { x: 3, y: 3 }, pos: { x: 3, y: 3 },
speed: 100, speed: 100,
energy: 0, energy: 0,
@@ -119,13 +215,13 @@ describe('Combat Simulation', () => {
}); });
const world = createTestWorld(actors); const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 }); const events = applyAction(world, 1, { type: "move", dx: 1, dy: 0 });
const player = world.actors.get(1)!; const player = world.actors.get(1) as CombatantActor;
expect(player.pos).toEqual({ x: 4, y: 3 }); expect(player.pos).toEqual({ x: 4, y: 3 });
// Should have moved event // Should have moved event
expect(events.some(e => e.type === 'moved')).toBe(true); expect(events.some(e => e.type === "moved")).toBe(true);
}); });
}); });
}); });

View File

@@ -89,10 +89,13 @@ describe('World Utilities', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
world.actors.set(1, { world.actors.set(1, {
id: 1, id: 1,
category: "combatant",
isPlayer: true, isPlayer: true,
type: "player",
pos: { x: 3, y: 3 }, pos: { x: 3, y: 3 },
speed: 100, speed: 100,
energy: 0 energy: 0,
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
}); });
expect(isBlocked(world, 3, 3)).toBe(true); expect(isBlocked(world, 3, 3)).toBe(true);

View File

@@ -1,4 +1,4 @@
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types"; import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor } from "../../core/types";
import { isBlocked } from "../world/world-logic"; import { isBlocked } from "../world/world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig"; import { GAME_CONFIG } from "../../core/config/GameConfig";
@@ -24,34 +24,40 @@ 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 -= GAME_CONFIG.gameplay.actionCost; if (actor.category === "combatant") {
actor.energy -= GAME_CONFIG.gameplay.actionCost;
}
return events; return events;
} }
function handleExpCollection(w: World, player: Actor, events: SimEvent[]) { 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); if (player.category !== "combatant") return;
const orbs = [...w.actors.values()].filter(a =>
a.category === "collectible" &&
a.type === "exp_orb" &&
a.pos.x === player.pos.x &&
a.pos.y === player.pos.y
) as CollectibleActor[];
for (const orb of orbs) { for (const orb of orbs) {
const amount = (orb as any).expAmount || 0; const amount = orb.expAmount || 0;
if (player.stats) { player.stats.exp += amount;
player.stats.exp += amount; events.push({
events.push({ type: "exp-collected",
type: "exp-collected", actorId: player.id,
actorId: player.id, amount,
amount, x: player.pos.x,
x: player.pos.x, y: player.pos.y
y: player.pos.y });
});
checkLevelUp(player, events); checkLevelUp(player, events);
}
w.actors.delete(orb.id); w.actors.delete(orb.id);
} }
} }
function checkLevelUp(player: Actor, events: SimEvent[]) { function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
if (!player.stats) return;
const s = player.stats; const s = player.stats;
while (s.exp >= s.expToNextLevel) { while (s.exp >= s.expToNextLevel) {
@@ -91,7 +97,7 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }):
const to = { ...actor.pos }; const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
if (actor.isPlayer) { if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events); handleExpCollection(w, actor, events);
} }
@@ -104,19 +110,68 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }):
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.category === "combatant" && actor.category === "combatant") {
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
const dmg = Math.max(1, actor.stats.attack - target.stats.defense); // 1. Accuracy vs Evasion Check
const hitChance = actor.stats.accuracy - target.stats.evasion;
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) {
// Miss!
events.push({
type: "dodged",
targetId: action.targetId,
x: target.pos.x,
y: target.pos.y
});
return events;
}
// 2. Base Damage Calculation
let dmg = Math.max(1, actor.stats.attack - target.stats.defense);
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < actor.stats.critChance;
if (isCrit) {
dmg = Math.floor(dmg * (actor.stats.critMultiplier / 100));
}
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
let isBlock = false;
if (blockRoll < target.stats.blockChance) {
dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50%
isBlock = true;
}
target.stats.hp -= dmg; target.stats.hp -= dmg;
// 5. Lifesteal Logic
if (actor.stats.lifesteal > 0 && dmg > 0) {
const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100));
if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
});
}
}
events.push({ events.push({
type: "damaged", type: "damaged",
targetId: action.targetId, targetId: action.targetId,
amount: dmg, amount: dmg,
hp: target.stats.hp, hp: target.stats.hp,
x: target.pos.x, x: target.pos.x,
y: target.pos.y y: target.pos.y,
isCrit,
isBlock
}); });
if (target.stats.hp <= 0) { if (target.stats.hp <= 0) {
@@ -126,7 +181,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
killerId: actor.id, killerId: actor.id,
x: target.pos.x, x: target.pos.x,
y: target.pos.y, y: target.pos.y,
victimType: target.type victimType: target.type as "player" | "rat" | "bat"
}); });
w.actors.delete(target.id); w.actors.delete(target.id);
@@ -135,15 +190,12 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
const expAmount = enemyDef?.expValue || 0; const expAmount = enemyDef?.expValue || 0;
const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1; const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1;
w.actors.set(orbId, { w.actors.set(orbId, {
id: orbId, id: orbId,
isPlayer: false, category: "collectible",
type: "exp_orb", type: "exp_orb",
pos: { ...target.pos }, pos: { ...target.pos },
speed: 0, expAmount // Explicit member in CollectibleActor
energy: 0, });
expAmount // Hidden property for simulation
} as any);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y }); events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
} }
@@ -158,7 +210,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
* - if adjacent to player, attack * - if adjacent to player, attack
* - else step toward player using greedy Manhattan * - else step toward player using greedy Manhattan
*/ */
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action { export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor): Action {
const dx = player.pos.x - enemy.pos.x; const dx = player.pos.x - enemy.pos.x;
const dy = player.pos.y - enemy.pos.y; const dy = player.pos.y - enemy.pos.y;
const dist = Math.abs(dx) + Math.abs(dy); const dist = Math.abs(dx) + Math.abs(dy);
@@ -192,17 +244,23 @@ export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action
* Returns enemy events accumulated along the way. * Returns enemy events accumulated along the way.
*/ */
export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } { export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } {
const player = w.actors.get(playerId); const player = w.actors.get(playerId) as CombatantActor;
if (!player) throw new Error("Player missing"); if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
const events: SimEvent[] = []; const events: SimEvent[] = [];
while (true) { while (true) {
while (![...w.actors.values()].some(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold)) { while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) {
for (const a of w.actors.values()) a.energy += a.speed; for (const a of w.actors.values()) {
if (a.category === "combatant") {
a.energy += a.speed;
}
}
} }
const ready = [...w.actors.values()].filter(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold); const ready = [...w.actors.values()].filter(a =>
a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold
) as CombatantActor[];
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

@@ -35,6 +35,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
actors.set(playerId, { actors.set(playerId, {
id: playerId, id: playerId,
category: "combatant",
isPlayer: true, isPlayer: true,
type: "player", type: "player",
pos: { x: playerX, y: playerY }, pos: { x: playerX, y: playerY },
@@ -229,6 +230,7 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
actors.set(enemyId, { actors.set(enemyId, {
id: enemyId, id: enemyId,
category: "combatant",
isPlayer: false, isPlayer: false,
type, type,
pos: { x: enemyX, y: enemyY }, pos: { x: enemyX, y: enemyY },
@@ -247,6 +249,13 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
strength: 0, strength: 0,
dexterity: 0, dexterity: 0,
intelligence: 0, intelligence: 0,
critChance: 0,
critMultiplier: 100,
accuracy: 80,
lifesteal: 0,
evasion: 0,
blockChance: 0,
luck: 0,
passiveNodes: [] passiveNodes: []
} }
}); });

View File

@@ -1,9 +1,11 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { FOV } from "rot-js";
import { type World, type EntityId, type Vec2 } from "../core/types"; import { type World, type EntityId, type Vec2 } from "../core/types";
import { TILE_SIZE } from "../core/constants"; import { TILE_SIZE } from "../core/constants";
import { idx, inBounds, isWall } from "../engine/world/world-logic"; import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig"; import { GAME_CONFIG } from "../core/config/GameConfig";
import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer";
import { FxRenderer } from "./FxRenderer";
export class DungeonRenderer { export class DungeonRenderer {
private scene: Phaser.Scene; private scene: Phaser.Scene;
@@ -13,53 +15,23 @@ 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 orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
private fovManager: FovManager;
private minimapRenderer: MinimapRenderer;
private fxRenderer: FxRenderer;
// FOV
private fov!: any;
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
// State refs
private world!: World; private world!: World;
// Minimap
private minimapGfx!: Phaser.GameObjects.Graphics;
private minimapContainer!: Phaser.GameObjects.Container;
private minimapBg!: Phaser.GameObjects.Rectangle;
private minimapVisible = false;
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
this.scene = scene; this.scene = scene;
this.initMinimap(); this.fovManager = new FovManager();
} this.minimapRenderer = new MinimapRenderer(scene);
this.fxRenderer = new FxRenderer(scene);
private initMinimap() {
this.minimapContainer = this.scene.add.container(0, 0);
this.minimapContainer.setScrollFactor(0);
this.minimapContainer.setDepth(1001);
this.minimapBg = this.scene.add
.rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive();
this.minimapGfx = this.scene.add.graphics();
this.minimapContainer.add(this.minimapBg);
this.minimapContainer.add(this.minimapGfx);
this.positionMinimap();
this.minimapContainer.setVisible(false);
} }
initializeFloor(world: World) { initializeFloor(world: World) {
this.world = world; this.world = world;
this.seen = new Uint8Array(this.world.width * this.world.height); this.fovManager.initialize(world);
this.visible = new Uint8Array(this.world.width * this.world.height);
this.visibleStrength = new Float32Array(this.world.width * this.world.height);
// Setup Tilemap // Setup Tilemap
if (this.map) this.map.destroy(); if (this.map) this.map.destroy();
@@ -80,21 +52,17 @@ export class DungeonRenderer {
tile.setVisible(false); tile.setVisible(false);
}); });
// Clear old corpses this.fxRenderer.clearCorpses();
for (const sprite of this.corpseSprites) { this.setupAnimations();
sprite.destroy(); this.minimapRenderer.positionMinimap();
} }
this.corpseSprites = [];
// Setup player sprite private setupAnimations() {
// Player
if (!this.playerSprite) { if (!this.playerSprite) {
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0); this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
this.playerSprite.setDepth(100); this.playerSprite.setDepth(100);
// Calculate scale to fit 15px high sprite into 16px tile
const scale = 1.0;
this.playerSprite.setScale(scale);
this.scene.anims.create({ this.scene.anims.create({
key: 'warrior-idle', key: 'warrior-idle',
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }), frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }),
@@ -161,69 +129,39 @@ export class DungeonRenderer {
repeat: 0 repeat: 0
}); });
} }
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
if (!inBounds(this.world, x, y)) return false;
return !isWall(this.world, x, y);
});
this.positionMinimap();
}
private positionMinimap() {
const cam = this.scene.cameras.main;
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
} }
toggleMinimap() { toggleMinimap() {
this.minimapVisible = !this.minimapVisible; this.minimapRenderer.toggle();
this.minimapContainer.setVisible(this.minimapVisible);
} }
isMinimapVisible(): boolean { isMinimapVisible(): boolean {
return this.minimapVisible; return this.minimapRenderer.isVisible();
} }
computeFov(playerId: EntityId) { computeFov(playerId: EntityId) {
this.visible.fill(0); this.fovManager.compute(this.world, playerId);
this.visibleStrength.fill(0);
const player = this.world.actors.get(playerId)!;
const ox = player.pos.x;
const oy = player.pos.y;
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(this.world, x, y)) return;
const i = idx(this.world, x, y);
this.visible[i] = 1;
this.seen[i] = 1;
const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength;
});
} }
isSeen(x: number, y: number): boolean { isSeen(x: number, y: number): boolean {
if (!this.world || !inBounds(this.world, x, y)) return false; return this.fovManager.isSeen(x, y);
return this.seen[idx(this.world, x, y)] === 1;
} }
get seenArray() { get seenArray() {
return this.seen; return this.fovManager.seenArray;
} }
render(_playerPath: Vec2[]) { render(_playerPath: Vec2[]) {
if (!this.world || !this.layer) return; if (!this.world || !this.layer) return;
const seen = this.fovManager.seenArray;
const visible = this.fovManager.visibleArray;
// Update Tiles // Update Tiles
this.layer.forEachTile(tile => { this.layer.forEachTile(tile => {
const i = idx(this.world, tile.x, tile.y); const i = idx(this.world, tile.x, tile.y);
const isSeen = this.seen[i] === 1; const isSeen = seen[i] === 1;
const isVis = this.visible[i] === 1; const isVis = visible[i] === 1;
if (!isSeen) { if (!isSeen) {
tile.setVisible(false); tile.setVisible(false);
@@ -239,40 +177,57 @@ export class DungeonRenderer {
} }
}); });
// Actors // Actors (Combatants)
const activeEnemyIds = new Set<EntityId>(); const activeEnemyIds = new Set<EntityId>();
const activeOrbIds = new Set<EntityId>();
for (const a of this.world.actors.values()) { for (const a of this.world.actors.values()) {
const i = idx(this.world, a.pos.x, a.pos.y); const i = idx(this.world, a.pos.x, a.pos.y);
const isVis = this.visible[i] === 1; const isVis = visible[i] === 1;
if (a.isPlayer) { if (a.category === "combatant") {
if (this.playerSprite) { if (a.isPlayer) {
this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); if (this.playerSprite) {
this.playerSprite.setVisible(true); this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
this.playerSprite.setVisible(true);
}
continue;
}
if (!isVis) continue;
activeEnemyIds.add(a.id);
let sprite = this.enemySprites.get(a.id);
const textureKey = a.type;
if (!sprite) {
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
sprite.setDepth(99);
sprite.play(`${textureKey}-idle`);
this.enemySprites.set(a.id, sprite);
}
sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
sprite.setVisible(true);
} else if (a.category === "collectible") {
if (a.type === "exp_orb") {
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);
} }
continue;
} }
if (!isVis) continue;
const enemyType = a.type as keyof typeof GAME_CONFIG.enemies;
if (!GAME_CONFIG.enemies[enemyType]) continue;
activeEnemyIds.add(a.id);
let sprite = this.enemySprites.get(a.id);
const textureKey = a.type || "rat";
if (!sprite) {
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
sprite.setDepth(99);
sprite.play(`${textureKey}-idle`);
this.enemySprites.set(a.id, sprite);
}
sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
sprite.setVisible(true);
} }
// Cleanup sprites for removed actors
for (const [id, sprite] of this.enemySprites.entries()) { for (const [id, sprite] of this.enemySprites.entries()) {
if (!activeEnemyIds.has(id)) { if (!activeEnemyIds.has(id)) {
sprite.setVisible(false); sprite.setVisible(false);
@@ -283,30 +238,6 @@ 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()) { for (const [id, orb] of this.orbSprites.entries()) {
if (!activeOrbIds.has(id)) { if (!activeOrbIds.has(id)) {
orb.setVisible(false); orb.setVisible(false);
@@ -317,169 +248,39 @@ export class DungeonRenderer {
} }
} }
this.minimapRenderer.render(this.world, seen, visible);
this.renderMinimap();
} }
private renderMinimap() { // FX Delegations
this.minimapGfx.clear(); showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
if (!this.world) return; this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock);
const padding = GAME_CONFIG.ui.minimapPadding;
const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2;
const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2;
const scaleX = availableWidth / this.world.width;
const scaleY = availableHeight / this.world.height;
const tileSize = Math.floor(Math.min(scaleX, scaleY));
const mapPixelWidth = this.world.width * tileSize;
const mapPixelHeight = this.world.height * tileSize;
const offsetX = -mapPixelWidth / 2;
const offsetY = -mapPixelHeight / 2;
for (let y = 0; y < this.world.height; y++) {
for (let x = 0; x < this.world.width; x++) {
const i = idx(this.world, x, y);
if (this.seen[i] !== 1) continue;
const wall = isWall(this.world, x, y);
const color = wall ? 0x666666 : 0x333333;
this.minimapGfx.fillStyle(color, 1);
this.minimapGfx.fillRect(offsetX + x * tileSize, offsetY + y * tileSize, tileSize, tileSize);
}
}
const ex = this.world.exit.x;
const ey = this.world.exit.y;
if (this.seen[idx(this.world, ex, ey)] === 1) {
this.minimapGfx.fillStyle(0xffd166, 1);
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
}
const player = [...this.world.actors.values()].find(a => a.isPlayer);
if (player) {
this.minimapGfx.fillStyle(0x66ff66, 1);
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
}
for (const a of this.world.actors.values()) {
if (a.isPlayer) continue;
const i = idx(this.world, a.pos.x, a.pos.y);
if (this.visible[i] === 1) {
this.minimapGfx.fillStyle(0xff6666, 1);
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
}
}
} }
showDamage(x: number, y: number, amount: number) { showDodge(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2; this.fxRenderer.showDodge(x, y);
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, amount.toString(), {
fontSize: "16px",
color: "#ff3333",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 24,
alpha: 0,
duration: 800,
ease: "Power1",
onComplete: () => text.destroy()
});
} }
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat" | "exp_orb") { showHeal(x: number, y: number, amount: number) {
if (type === "exp_orb") return; this.fxRenderer.showHeal(x, y, amount);
const textureKey = type === "player" ? "warrior" : type; }
const corpse = this.scene.add.sprite( spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") {
x * TILE_SIZE + TILE_SIZE / 2, this.fxRenderer.spawnCorpse(x, y, type);
y * TILE_SIZE + TILE_SIZE / 2,
textureKey,
0
);
corpse.setDepth(50);
corpse.play(`${textureKey}-die`);
this.corpseSprites.push(corpse);
} }
showWait(x: number, y: number) { showWait(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2; this.fxRenderer.showWait(x, y);
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, "zZz", {
fontSize: "14px",
color: "#aaaaff",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 20,
alpha: 0,
duration: 600,
ease: "Power1",
onComplete: () => text.destroy()
});
} }
spawnOrb(_orbId: EntityId, _x: number, _y: number) { spawnOrb(_orbId: EntityId, _x: number, _y: number) {
// Just to trigger a render update if needed, but render() handles it // Handled in render()
} }
collectOrb(_actorId: EntityId, amount: number, x: number, y: number) { collectOrb(actorId: EntityId, amount: number, x: number, y: number) {
this.fxRenderer.collectOrb(actorId, amount, x, y);
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) { showLevelUp(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2; this.fxRenderer.showLevelUp(x, y);
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

@@ -0,0 +1,68 @@
import { FOV } from "rot-js";
import { type World, type EntityId } from "../core/types";
import { idx, inBounds, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
import Phaser from "phaser";
export class FovManager {
private fov!: any;
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
private worldWidth: number = 0;
private worldHeight: number = 0;
initialize(world: World) {
this.worldWidth = world.width;
this.worldHeight = world.height;
this.seen = new Uint8Array(world.width * world.height);
this.visible = new Uint8Array(world.width * world.height);
this.visibleStrength = new Float32Array(world.width * world.height);
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
if (!inBounds(world, x, y)) return false;
return !isWall(world, x, y);
});
}
compute(world: World, playerId: EntityId) {
this.visible.fill(0);
this.visibleStrength.fill(0);
const player = world.actors.get(playerId)!;
const ox = player.pos.x;
const oy = player.pos.y;
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(world, x, y)) return;
const i = idx(world, x, y);
this.visible[i] = 1;
this.seen[i] = 1;
const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength;
});
}
isSeen(x: number, y: number): boolean {
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
return this.seen[y * this.worldWidth + x] === 1;
}
isVisible(x: number, y: number): boolean {
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
return this.visible[y * this.worldWidth + x] === 1;
}
get seenArray() {
return this.seen;
}
get visibleArray() {
return this.visible;
}
}

191
src/rendering/FxRenderer.ts Normal file
View File

@@ -0,0 +1,191 @@
import Phaser from "phaser";
import { type EntityId } from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class FxRenderer {
private scene: Phaser.Scene;
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
clearCorpses() {
for (const sprite of this.corpseSprites) {
sprite.destroy();
}
this.corpseSprites = [];
}
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
let textStr = amount.toString();
let color = "#ff3333";
let fontSize = "16px";
if (isCrit) {
textStr += "!";
color = "#ffff00";
fontSize = "22px";
}
const text = this.scene.add.text(screenX, screenY, textStr, {
fontSize,
color,
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
if (isBlock) {
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
fontSize: "10px",
color: "#888888",
fontStyle: "bold"
}).setOrigin(0, 1).setDepth(200);
this.scene.tweens.add({
targets: blockText,
y: screenY - 34,
alpha: 0,
duration: 800,
onComplete: () => blockText.destroy()
});
}
this.scene.tweens.add({
targets: text,
y: screenY - 24,
alpha: 0,
duration: isCrit ? 1200 : 800,
ease: isCrit ? "Bounce.out" : "Power1",
onComplete: () => text.destroy()
});
}
showDodge(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, "Dodge", {
fontSize: "14px",
color: "#ffffff",
stroke: "#000",
strokeThickness: 2,
fontStyle: "italic"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
x: screenX + (Math.random() > 0.5 ? 20 : -20),
y: screenY - 20,
alpha: 0,
duration: 600,
onComplete: () => text.destroy()
});
}
showHeal(x: number, y: number, amount: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, `+${amount}`, {
fontSize: "16px",
color: "#33ff33",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 30,
alpha: 0,
duration: 1000,
onComplete: () => text.destroy()
});
}
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") {
const textureKey = type === "player" ? "warrior" : type;
const corpse = this.scene.add.sprite(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
textureKey,
0
);
corpse.setDepth(50);
corpse.play(`${textureKey}-die`);
this.corpseSprites.push(corpse);
}
showWait(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, "zZz", {
fontSize: "14px",
color: "#aaaaff",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 20,
alpha: 0,
duration: 600,
ease: "Power1",
onComplete: () => text.destroy()
});
}
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

@@ -0,0 +1,104 @@
import Phaser from "phaser";
import { type World, type CombatantActor } from "../core/types";
import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class MinimapRenderer {
private scene: Phaser.Scene;
private minimapGfx!: Phaser.GameObjects.Graphics;
private minimapContainer!: Phaser.GameObjects.Container;
private minimapBg!: Phaser.GameObjects.Rectangle;
private minimapVisible = false;
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.initMinimap();
}
private initMinimap() {
this.minimapContainer = this.scene.add.container(0, 0);
this.minimapContainer.setScrollFactor(0);
this.minimapContainer.setDepth(1001);
this.minimapBg = this.scene.add
.rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive();
this.minimapGfx = this.scene.add.graphics();
this.minimapContainer.add(this.minimapBg);
this.minimapContainer.add(this.minimapGfx);
this.positionMinimap();
this.minimapContainer.setVisible(false);
}
positionMinimap() {
const cam = this.scene.cameras.main;
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
}
toggle() {
this.minimapVisible = !this.minimapVisible;
this.minimapContainer.setVisible(this.minimapVisible);
}
isVisible(): boolean {
return this.minimapVisible;
}
render(world: World, seen: Uint8Array, visible: Uint8Array) {
this.minimapGfx.clear();
if (!world) return;
const padding = GAME_CONFIG.ui.minimapPadding;
const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2;
const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2;
const scaleX = availableWidth / world.width;
const scaleY = availableHeight / world.height;
const tileSize = Math.floor(Math.min(scaleX, scaleY));
const mapPixelWidth = world.width * tileSize;
const mapPixelHeight = world.height * tileSize;
const offsetX = -mapPixelWidth / 2;
const offsetY = -mapPixelHeight / 2;
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = idx(world, x, y);
if (seen[i] !== 1) continue;
const wall = isWall(world, x, y);
const color = wall ? 0x666666 : 0x333333;
this.minimapGfx.fillStyle(color, 1);
this.minimapGfx.fillRect(offsetX + x * tileSize, offsetY + y * tileSize, tileSize, tileSize);
}
}
const ex = world.exit.x;
const ey = world.exit.y;
if (seen[idx(world, ex, ey)] === 1) {
this.minimapGfx.fillStyle(0xffd166, 1);
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
}
const player = [...world.actors.values()].find(a => a.category === "combatant" && a.isPlayer) as CombatantActor;
if (player) {
this.minimapGfx.fillStyle(0x66ff66, 1);
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
}
for (const a of world.actors.values()) {
if (a.category === "combatant") {
if (a.isPlayer) continue;
const i = idx(world, a.pos.x, a.pos.y);
if (visible[i] === 1) {
this.minimapGfx.fillStyle(0xff6666, 1);
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
}
}
}
}
}

View File

@@ -147,17 +147,16 @@ describe('DungeonRenderer', () => {
renderer.initializeFloor(mockWorld); renderer.initializeFloor(mockWorld);
// Add an exp_orb to the world // Add an exp_orb to the world
mockWorld.actors.set(99, { mockWorld.actors.set(2, {
id: 99, id: 2,
isPlayer: false, category: "collectible",
type: 'exp_orb', type: "exp_orb",
pos: { x: 5, y: 5 }, pos: { x: 2, y: 1 },
speed: 0, expAmount: 10
energy: 0
}); });
// Make the tile visible for it to render // Make the tile visible for it to render
(renderer as any).visible[5 * mockWorld.width + 5] = 1; (renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
// Reset mocks // Reset mocks
mockScene.add.sprite.mockClear(); mockScene.add.sprite.mockClear();
@@ -186,17 +185,18 @@ describe('DungeonRenderer', () => {
renderer.initializeFloor(mockWorld); renderer.initializeFloor(mockWorld);
// Add a rat (defined in config) // Add a rat (defined in config)
mockWorld.actors.set(100, { mockWorld.actors.set(3, {
id: 100, id: 3,
category: "combatant",
isPlayer: false, isPlayer: false,
type: 'rat', type: "rat",
pos: { x: 2, y: 2 }, pos: { x: 3, y: 1 },
speed: 100, speed: 10,
energy: 0, energy: 0,
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0, level: 1, exp: 0, expToNextLevel: 0, statPoints: 0, skillPoints: 0, strength: 0, dexterity: 0, intelligence: 0, passiveNodes: [] } stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
}); });
(renderer as any).visible[2 * mockWorld.width + 2] = 1; (renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
mockScene.add.sprite.mockClear(); mockScene.add.sprite.mockClear();
renderer.render([]); renderer.render([]);

View File

@@ -4,7 +4,8 @@ import {
type Vec2, type Vec2,
type Action, type Action,
type RunState, type RunState,
type World type World,
type CombatantActor
} from "../core/types"; } from "../core/types";
import { TILE_SIZE } from "../core/constants"; import { TILE_SIZE } from "../core/constants";
import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic"; import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
@@ -148,9 +149,11 @@ export class GameScene extends Phaser.Scene {
if (!this.dungeonRenderer.isSeen(tx, ty)) return; if (!this.dungeonRenderer.isSeen(tx, ty)) return;
// Check if clicking on an enemy // Check if clicking on an enemy
const isEnemy = [...this.world.actors.values()].some(a => a.pos.x === tx && a.pos.y === ty && !a.isPlayer); const isEnemy = [...this.world.actors.values()].some(a =>
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
);
const player = this.world.actors.get(this.playerId)!; const player = this.world.actors.get(this.playerId) as CombatantActor;
const path = findPathAStar( const path = findPathAStar(
this.world, this.world,
this.dungeonRenderer.seenArray, this.dungeonRenderer.seenArray,
@@ -170,7 +173,7 @@ export class GameScene extends Phaser.Scene {
// Auto-walk one step per turn // Auto-walk one step per turn
if (this.playerPath.length >= 2) { if (this.playerPath.length >= 2) {
const player = this.world.actors.get(this.playerId)!; const player = this.world.actors.get(this.playerId) as CombatantActor;
const next = this.playerPath[1]; const next = this.playerPath[1];
const dx = next.x - player.pos.x; const dx = next.x - player.pos.x;
const dy = next.y - player.pos.y; const dy = next.y - player.pos.y;
@@ -183,7 +186,7 @@ export class GameScene extends Phaser.Scene {
if (isBlocked(this.world, next.x, next.y)) { if (isBlocked(this.world, next.x, next.y)) {
// Check if it's an enemy at 'next' // Check if it's an enemy at 'next'
const targetId = [...this.world.actors.values()].find( const targetId = [...this.world.actors.values()].find(
a => a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
)?.id; )?.id;
if (targetId !== undefined) { if (targetId !== undefined) {
@@ -213,13 +216,13 @@ export class GameScene extends Phaser.Scene {
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1; else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
if (dx !== 0 || dy !== 0) { if (dx !== 0 || dy !== 0) {
const player = this.world.actors.get(this.playerId)!; const player = this.world.actors.get(this.playerId) as CombatantActor;
const targetX = player.pos.x + dx; const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy; const targetY = player.pos.y + dy;
// Check for enemy at target position // Check for enemy at target position
const targetId = [...this.world.actors.values()].find( const targetId = [...this.world.actors.values()].find(
a => a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
)?.id; )?.id;
if (targetId !== undefined) { if (targetId !== undefined) {
@@ -254,11 +257,15 @@ export class GameScene extends Phaser.Scene {
const allEvents = [...playerEvents, ...enemyStep.events]; const allEvents = [...playerEvents, ...enemyStep.events];
for (const ev of allEvents) { for (const ev of allEvents) {
if (ev.type === "damaged") { if (ev.type === "damaged") {
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount); this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock);
} else if (ev.type === "dodged") {
this.dungeonRenderer.showDodge(ev.x, ev.y);
} else if (ev.type === "healed") {
this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount);
} else if (ev.type === "killed") { } else if (ev.type === "killed") {
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat"); this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
} else if (ev.type === "waited" && ev.actorId === this.playerId) { } else if (ev.type === "waited" && ev.actorId === this.playerId) {
const player = this.world.actors.get(this.playerId); const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) { if (player) {
this.dungeonRenderer.showWait(player.pos.x, player.pos.y); this.dungeonRenderer.showWait(player.pos.x, player.pos.y);
} }
@@ -328,8 +335,8 @@ export class GameScene extends Phaser.Scene {
} }
private syncRunStateFromPlayer() { private syncRunStateFromPlayer() {
const p = this.world.actors.get(this.playerId); const p = this.world.actors.get(this.playerId) as CombatantActor;
if (!p?.stats || !p.inventory) return; if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return;
this.runState = { this.runState = {
stats: { ...p.stats }, stats: { ...p.stats },
@@ -348,7 +355,7 @@ export class GameScene extends Phaser.Scene {
private centerCameraOnPlayer() { private centerCameraOnPlayer() {
const player = this.world.actors.get(this.playerId)!; const player = this.world.actors.get(this.playerId) as CombatantActor;
this.cameras.main.centerOn( this.cameras.main.centerOn(
player.pos.x * TILE_SIZE + TILE_SIZE / 2, player.pos.x * TILE_SIZE + TILE_SIZE / 2,
player.pos.y * TILE_SIZE + TILE_SIZE / 2 player.pos.y * TILE_SIZE + TILE_SIZE / 2
@@ -356,8 +363,8 @@ export class GameScene extends Phaser.Scene {
} }
private allocateStat(statName: string) { private allocateStat(statName: string) {
const p = this.world.actors.get(this.playerId); const p = this.world.actors.get(this.playerId) as CombatantActor;
if (!p || !p.stats || p.stats.statPoints <= 0) return; if (!p || p.category !== "combatant" || !p.stats || p.stats.statPoints <= 0) return;
p.stats.statPoints--; p.stats.statPoints--;
if (statName === "strength") { if (statName === "strength") {
@@ -380,8 +387,8 @@ export class GameScene extends Phaser.Scene {
} }
private allocatePassive(nodeId: string) { private allocatePassive(nodeId: string) {
const p = this.world.actors.get(this.playerId); const p = this.world.actors.get(this.playerId) as CombatantActor;
if (!p || !p.stats || p.stats.skillPoints <= 0) return; if (!p || p.category !== "combatant" || !p.stats || p.stats.skillPoints <= 0) return;
if (p.stats.passiveNodes.includes(nodeId)) return; if (p.stats.passiveNodes.includes(nodeId)) return;

View File

@@ -1,5 +1,5 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { type World, type EntityId, type Stats } from "../core/types"; import { type World, type EntityId, type Stats, type CombatantActor } 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 {
@@ -555,8 +555,8 @@ export default class GameUI extends Phaser.Scene {
} }
private updateCharacterUI(world: World, playerId: EntityId) { private updateCharacterUI(world: World, playerId: EntityId) {
const p = world.actors.get(playerId); const p = world.actors.get(playerId) as CombatantActor;
if (!p || !p.stats) return; if (!p || p.category !== "combatant" || !p.stats) return;
const s = p.stats; const s = p.stats;
this.attrText.setText(`STR: ${s.strength}\nDEX: ${s.dexterity}\nINT: ${s.intelligence}`); this.attrText.setText(`STR: ${s.strength}\nDEX: ${s.dexterity}\nINT: ${s.intelligence}`);
@@ -571,14 +571,22 @@ export default class GameUI extends Phaser.Scene {
`Defense: ${s.defense}`, `Defense: ${s.defense}`,
`Speed: ${p.speed}`, `Speed: ${p.speed}`,
"", "",
`Accuracy: ${s.accuracy}%`,
`Crit Chance: ${s.critChance}%`,
`Crit Mult: ${s.critMultiplier}%`,
`Evasion: ${s.evasion}%`,
`Block: ${s.blockChance}%`,
`Lifesteal: ${s.lifesteal}%`,
`Luck: ${s.luck}`,
"",
`Passive Nodes: ${s.passiveNodes.length > 0 ? s.passiveNodes.join(", ") : "(none)"}` `Passive Nodes: ${s.passiveNodes.length > 0 ? s.passiveNodes.join(", ") : "(none)"}`
]; ];
this.charStatsText.setText(statsLines.join("\n")); this.charStatsText.setText(statsLines.join("\n"));
} }
private updateInventoryUI(world: World, playerId: EntityId) { private updateInventoryUI(world: World, playerId: EntityId) {
const p = world.actors.get(playerId); const p = world.actors.get(playerId) as CombatantActor;
if (!p) return; if (!p || p.category !== "combatant") return;
// Clear existing item icons/text from slots if needed (future refinement) // Clear existing item icons/text from slots if needed (future refinement)
// For now we just show names or placeholders // For now we just show names or placeholders
@@ -588,8 +596,8 @@ export default class GameUI extends Phaser.Scene {
this.floorText.setText(`Floor ${floorIndex}`); this.floorText.setText(`Floor ${floorIndex}`);
const p = world.actors.get(playerId); const p = world.actors.get(playerId) as CombatantActor;
if (!p || !p.stats) return; if (!p || p.category !== "combatant" || !p.stats) return;
const barX = 40; const barX = 40;
const barY = 40; const barY = 40;
@@ -655,9 +663,10 @@ export default class GameUI extends Phaser.Scene {
private updateMenuText(world: World, playerId: EntityId, _floorIndex: number) { private updateMenuText(world: World, playerId: EntityId, _floorIndex: number) {
const p = world.actors.get(playerId); const p = world.actors.get(playerId) as CombatantActor;
const stats = p?.stats; if (!p || p.category !== "combatant") return;
const inv = p?.inventory; const stats = p.stats;
const inv = p.inventory;
const lines: string[] = []; const lines: string[] = [];
lines.push(`Level ${stats?.level ?? 1}`); lines.push(`Level ${stats?.level ?? 1}`);
@@ -668,6 +677,10 @@ export default class GameUI extends Phaser.Scene {
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(` Crit: ${stats?.critChance ?? 0}%`);
lines.push(` Crit x: ${stats?.critMultiplier ?? 0}%`);
lines.push(` Accuracy: ${stats?.accuracy ?? 0}%`);
lines.push(` Evasion: ${stats?.evasion ?? 0}%`);
lines.push(""); lines.push("");
lines.push("Inventory"); lines.push("Inventory");