From 86a6afd1df6bfe0570c6f8a7530b27bbaae0ae00 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 12:39:43 +1100 Subject: [PATCH] Add more stats, crit/block/accuracy/dodge/lifesteal --- src/core/config/GameConfig.ts | 10 + src/core/types.ts | 47 ++- src/engine/__tests__/generator.test.ts | 54 ++- src/engine/__tests__/simulation.test.ts | 130 +++++- src/engine/__tests__/world.test.ts | 5 +- src/engine/simulation/simulation.ts | 128 ++++-- src/engine/world/generator.ts | 9 + src/rendering/DungeonRenderer.ts | 373 ++++-------------- src/rendering/FovManager.ts | 68 ++++ src/rendering/FxRenderer.ts | 191 +++++++++ src/rendering/MinimapRenderer.ts | 104 +++++ .../__tests__/DungeonRenderer.test.ts | 30 +- src/scenes/GameScene.ts | 39 +- src/ui/GameUI.ts | 33 +- 14 files changed, 815 insertions(+), 406 deletions(-) create mode 100644 src/rendering/FovManager.ts create mode 100644 src/rendering/FxRenderer.ts create mode 100644 src/rendering/MinimapRenderer.ts diff --git a/src/core/config/GameConfig.ts b/src/core/config/GameConfig.ts index 524fdc8..de2de22 100644 --- a/src/core/config/GameConfig.ts +++ b/src/core/config/GameConfig.ts @@ -13,6 +13,16 @@ export const GAME_CONFIG = { strength: 10, dexterity: 10, intelligence: 10, + // Offensive + critChance: 5, + critMultiplier: 150, + accuracy: 90, + lifesteal: 0, + // Defensive + evasion: 5, + blockChance: 0, + // Utility + luck: 0, passiveNodes: [] as string[] }, speed: 100, diff --git a/src/core/types.ts b/src/core/types.ts index f1f64cf..3f8e60a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -12,8 +12,10 @@ export type Action = export type SimEvent = | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } | { type: "attacked"; attackerId: EntityId; targetId: EntityId } - | { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number } - | { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" | "exp_orb" } + | { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number; isCrit?: boolean; isBlock?: boolean } + | { 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: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number } @@ -30,6 +32,19 @@ export type Stats = { exp: number; expToNextLevel: number; + // Offensive + critChance: number; + critMultiplier: number; + accuracy: number; + lifesteal: number; + + // Defensive + evasion: number; + blockChance: number; + + // Utility + luck: number; + // New Progression Fields statPoints: number; skillPoints: number; @@ -83,19 +98,35 @@ export type RunState = { inventory: Inventory; }; -export type Actor = { +export interface BaseActor { id: EntityId; - isPlayer: boolean; - type?: "player" | "rat" | "bat" | "exp_orb"; pos: Vec2; + type?: string; +} +export interface CombatantActor extends BaseActor { + category: "combatant"; + isPlayer: boolean; + type: "player" | "rat" | "bat"; speed: number; energy: number; - - stats?: Stats; + stats: Stats; inventory?: Inventory; 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 = { width: number; diff --git a/src/engine/__tests__/generator.test.ts b/src/engine/__tests__/generator.test.ts index 52e2c88..05a4a51 100644 --- a/src/engine/__tests__/generator.test.ts +++ b/src/engine/__tests__/generator.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { generateWorld } from '../world/generator'; import { isWall, inBounds } from '../world/world-logic'; +import { type CombatantActor } from '../../core/types'; describe('World Generator', () => { describe('generateWorld', () => { @@ -8,7 +9,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -24,7 +27,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -32,17 +37,20 @@ describe('World Generator', () => { const { world, playerId } = generateWorld(1, runState); expect(playerId).toBe(1); - const player = world.actors.get(playerId); + const player = world.actors.get(playerId) as CombatantActor; expect(player).toBeDefined(); - expect(player?.isPlayer).toBe(true); - expect(player?.stats).toEqual(runState.stats); + expect(player.category).toBe("combatant"); + expect(player.isPlayer).toBe(true); + expect(player.stats).toEqual(runState.stats); }); it('should create walkable rooms', () => { const runState = { stats: { 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: [] } }; @@ -58,7 +66,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -74,7 +84,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -85,14 +97,14 @@ describe('World Generator', () => { expect(world.actors.size).toBeGreaterThan(1); // 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); // Enemies should have stats enemies.forEach(enemy => { expect(enemy.stats).toBeDefined(); - expect(enemy.stats!.hp).toBeGreaterThan(0); - expect(enemy.stats!.attack).toBeGreaterThan(0); + expect(enemy.stats.hp).toBeGreaterThan(0); + expect(enemy.stats.attack).toBeGreaterThan(0); }); }); @@ -100,7 +112,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -121,7 +135,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -137,7 +153,9 @@ describe('World Generator', () => { const runState = { stats: { 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: [] } }; @@ -145,15 +163,15 @@ describe('World Generator', () => { const { world: world1 } = generateWorld(1, runState); const { world: world5 } = generateWorld(5, runState); - const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer); - const enemies5 = Array.from(world5.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.category === "combatant" && !a.isPlayer) as CombatantActor[]; // Higher level should have more enemies expect(enemies5.length).toBeGreaterThan(enemies1.length); // Higher level enemies should have higher stats - 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 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; expect(avgHp5).toBeGreaterThan(avgHp1); }); }); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 0d2944c..2db5faa 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; 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', () => { const createTestWorld = (actors: Map): World => ({ @@ -14,6 +14,14 @@ describe('Combat Simulation', () => { const createTestStats = (overrides: Partial = {}) => ({ 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: [], + // 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 }); @@ -22,7 +30,9 @@ describe('Combat Simulation', () => { const actors = new Map(); actors.set(1, { id: 1, + category: "combatant", isPlayer: true, + type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, @@ -30,7 +40,9 @@ describe('Combat Simulation', () => { }); actors.set(2, { id: 2, + category: "combatant", isPlayer: false, + type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, @@ -38,20 +50,22 @@ describe('Combat Simulation', () => { }); 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)!; - expect(enemy.stats!.hp).toBeLessThan(10); + const enemy = world.actors.get(2) as CombatantActor; + expect(enemy.stats.hp).toBeLessThan(10); // 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(); actors.set(1, { id: 1, + category: "combatant", isPlayer: true, + type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, @@ -59,7 +73,9 @@ describe('Combat Simulation', () => { }); actors.set(2, { id: 2, + category: "combatant", isPlayer: false, + type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, @@ -67,20 +83,22 @@ describe('Combat Simulation', () => { }); 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 expect(world.actors.has(2)).toBe(false); // 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(); actors.set(1, { id: 1, + category: "combatant", isPlayer: true, + type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, @@ -88,7 +106,9 @@ describe('Combat Simulation', () => { }); actors.set(2, { id: 2, + category: "combatant", isPlayer: false, + type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, @@ -96,22 +116,98 @@ describe('Combat Simulation', () => { }); 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 damage = 10 - enemy.stats!. hp; + const enemy = world.actors.get(2) as CombatantActor; + const damage = 10 - enemy.stats.hp; // Damage should be reduced by defense (5 attack - 3 defense = 2 damage) expect(damage).toBe(2); }); + + it("should emit dodged event when attack misses", () => { + const actors = new Map(); + 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(); + 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(); + 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(); + 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', () => { - it('should move actor to new position', () => { + describe("applyAction - move", () => { + it("should move actor to new position", () => { const actors = new Map(); actors.set(1, { id: 1, + category: "combatant", isPlayer: true, + type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, @@ -119,13 +215,13 @@ describe('Combat Simulation', () => { }); 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 }); // Should have moved event - expect(events.some(e => e.type === 'moved')).toBe(true); + expect(events.some(e => e.type === "moved")).toBe(true); }); }); }); diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index db9bc73..0c92a9d 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -89,10 +89,13 @@ describe('World Utilities', () => { const world = createTestWorld(10, 10, new Array(100).fill(0)); world.actors.set(1, { id: 1, + category: "combatant", isPlayer: true, + type: "player", pos: { x: 3, y: 3 }, speed: 100, - energy: 0 + energy: 0, + stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any }); expect(isBlocked(world, 3, 3)).toBe(true); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index d6d0bf7..0e427c5 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -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 { 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) - actor.energy -= GAME_CONFIG.gameplay.actionCost; + if (actor.category === "combatant") { + actor.energy -= GAME_CONFIG.gameplay.actionCost; + } return events; } function handleExpCollection(w: World, player: Actor, events: SimEvent[]) { - const orbs = [...w.actors.values()].filter(a => a.type === "exp_orb" && a.pos.x === player.pos.x && a.pos.y === player.pos.y); + 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) { - 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); - } + const amount = orb.expAmount || 0; + 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; +function checkLevelUp(player: CombatantActor, events: SimEvent[]) { const s = player.stats; 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 events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; - if (actor.isPlayer) { + if (actor.category === "combatant" && actor.isPlayer) { 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[] { 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 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; + // 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({ type: "damaged", targetId: action.targetId, amount: dmg, hp: target.stats.hp, x: target.pos.x, - y: target.pos.y + y: target.pos.y, + isCrit, + isBlock }); if (target.stats.hp <= 0) { @@ -126,7 +181,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S killerId: actor.id, x: target.pos.x, y: target.pos.y, - victimType: target.type + victimType: target.type as "player" | "rat" | "bat" }); 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 orbId = Math.max(0, ...w.actors.keys(), target.id) + 1; w.actors.set(orbId, { - id: orbId, - isPlayer: false, + category: "collectible", type: "exp_orb", pos: { ...target.pos }, - speed: 0, - energy: 0, - expAmount // Hidden property for simulation - } as any); + expAmount // Explicit member in CollectibleActor + }); 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 * - 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 dy = player.pos.y - enemy.pos.y; 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. */ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } { - const player = w.actors.get(playerId); - if (!player) throw new Error("Player missing"); + const player = w.actors.get(playerId) as CombatantActor; + if (!player || player.category !== "combatant") throw new Error("Player missing or invalid"); const events: SimEvent[] = []; while (true) { - while (![...w.actors.values()].some(a => a.energy >= GAME_CONFIG.gameplay.energyThreshold)) { - for (const a of w.actors.values()) a.energy += a.speed; + while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) { + 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)); const actor = ready[0]; diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 3d4c2d7..fd512a3 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -35,6 +35,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World actors.set(playerId, { id: playerId, + category: "combatant", isPlayer: true, type: "player", pos: { x: playerX, y: playerY }, @@ -229,6 +230,7 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map actors.set(enemyId, { id: enemyId, + category: "combatant", isPlayer: false, type, pos: { x: enemyX, y: enemyY }, @@ -247,6 +249,13 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map strength: 0, dexterity: 0, intelligence: 0, + critChance: 0, + critMultiplier: 100, + accuracy: 80, + lifesteal: 0, + evasion: 0, + blockChance: 0, + luck: 0, passiveNodes: [] } }); diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index dafa43c..5bde52b 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -1,9 +1,11 @@ import Phaser from "phaser"; -import { FOV } from "rot-js"; import { type World, type EntityId, type Vec2 } from "../core/types"; 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 { FovManager } from "./FovManager"; +import { MinimapRenderer } from "./MinimapRenderer"; +import { FxRenderer } from "./FxRenderer"; export class DungeonRenderer { private scene: Phaser.Scene; @@ -13,53 +15,23 @@ export class DungeonRenderer { private playerSprite?: Phaser.GameObjects.Sprite; private enemySprites: Map = new Map(); private orbSprites: Map = 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; - // Minimap - 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); + this.fovManager = new FovManager(); + this.minimapRenderer = new MinimapRenderer(scene); + this.fxRenderer = new FxRenderer(scene); } initializeFloor(world: World) { - this.world = world; - this.seen = new Uint8Array(this.world.width * this.world.height); - this.visible = new Uint8Array(this.world.width * this.world.height); - this.visibleStrength = new Float32Array(this.world.width * this.world.height); + this.fovManager.initialize(world); // Setup Tilemap if (this.map) this.map.destroy(); @@ -80,21 +52,17 @@ export class DungeonRenderer { tile.setVisible(false); }); - // Clear old corpses - for (const sprite of this.corpseSprites) { - sprite.destroy(); - } - this.corpseSprites = []; + this.fxRenderer.clearCorpses(); + this.setupAnimations(); + this.minimapRenderer.positionMinimap(); + } - // Setup player sprite + private setupAnimations() { + // Player if (!this.playerSprite) { this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0); 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({ key: 'warrior-idle', frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }), @@ -161,69 +129,39 @@ export class DungeonRenderer { 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() { - this.minimapVisible = !this.minimapVisible; - this.minimapContainer.setVisible(this.minimapVisible); + this.minimapRenderer.toggle(); } isMinimapVisible(): boolean { - return this.minimapVisible; + return this.minimapRenderer.isVisible(); } computeFov(playerId: EntityId) { - this.visible.fill(0); - 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; - }); + this.fovManager.compute(this.world, playerId); } isSeen(x: number, y: number): boolean { - if (!this.world || !inBounds(this.world, x, y)) return false; - return this.seen[idx(this.world, x, y)] === 1; + return this.fovManager.isSeen(x, y); } get seenArray() { - return this.seen; + return this.fovManager.seenArray; } render(_playerPath: Vec2[]) { if (!this.world || !this.layer) return; + const seen = this.fovManager.seenArray; + const visible = this.fovManager.visibleArray; + // Update Tiles this.layer.forEachTile(tile => { const i = idx(this.world, tile.x, tile.y); - const isSeen = this.seen[i] === 1; - const isVis = this.visible[i] === 1; + const isSeen = seen[i] === 1; + const isVis = visible[i] === 1; if (!isSeen) { tile.setVisible(false); @@ -239,40 +177,57 @@ export class DungeonRenderer { } }); - // Actors + // Actors (Combatants) const activeEnemyIds = new Set(); + const activeOrbIds = new Set(); + for (const a of this.world.actors.values()) { const i = idx(this.world, a.pos.x, a.pos.y); - const isVis = this.visible[i] === 1; - - if (a.isPlayer) { - if (this.playerSprite) { - this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); - this.playerSprite.setVisible(true); + const isVis = visible[i] === 1; + + if (a.category === "combatant") { + if (a.isPlayer) { + if (this.playerSprite) { + 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()) { if (!activeEnemyIds.has(id)) { sprite.setVisible(false); @@ -283,30 +238,6 @@ export class DungeonRenderer { } } - // Orbs - const activeOrbIds = new Set(); - 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); @@ -317,169 +248,39 @@ export class DungeonRenderer { } } - - this.renderMinimap(); + this.minimapRenderer.render(this.world, seen, visible); } - private renderMinimap() { - this.minimapGfx.clear(); - if (!this.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 / 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); - } - } + // FX Delegations + showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) { + this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock); } - showDamage(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.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() - }); + showDodge(x: number, y: number) { + this.fxRenderer.showDodge(x, y); } - spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat" | "exp_orb") { - if (type === "exp_orb") return; - const textureKey = type === "player" ? "warrior" : type; + showHeal(x: number, y: number, amount: number) { + this.fxRenderer.showHeal(x, y, amount); + } - 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); + spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") { + this.fxRenderer.spawnCorpse(x, y, type); } 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() - }); + this.fxRenderer.showWait(x, y); } 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) { - - 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() - }); + collectOrb(actorId: EntityId, amount: number, x: number, y: number) { + this.fxRenderer.collectOrb(actorId, amount, x, y); } 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() - }); + this.fxRenderer.showLevelUp(x, y); } } - diff --git a/src/rendering/FovManager.ts b/src/rendering/FovManager.ts new file mode 100644 index 0000000..4dfb161 --- /dev/null +++ b/src/rendering/FovManager.ts @@ -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; + } +} diff --git a/src/rendering/FxRenderer.ts b/src/rendering/FxRenderer.ts new file mode 100644 index 0000000..6320965 --- /dev/null +++ b/src/rendering/FxRenderer.ts @@ -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() + }); + } +} diff --git a/src/rendering/MinimapRenderer.ts b/src/rendering/MinimapRenderer.ts new file mode 100644 index 0000000..21bf475 --- /dev/null +++ b/src/rendering/MinimapRenderer.ts @@ -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); + } + } + } + } +} diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index 5bacf61..b1db59d 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -147,17 +147,16 @@ describe('DungeonRenderer', () => { renderer.initializeFloor(mockWorld); // Add an exp_orb to the world - mockWorld.actors.set(99, { - id: 99, - isPlayer: false, - type: 'exp_orb', - pos: { x: 5, y: 5 }, - speed: 0, - energy: 0 + mockWorld.actors.set(2, { + id: 2, + category: "collectible", + type: "exp_orb", + pos: { x: 2, y: 1 }, + expAmount: 10 }); // 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 mockScene.add.sprite.mockClear(); @@ -186,17 +185,18 @@ describe('DungeonRenderer', () => { renderer.initializeFloor(mockWorld); // Add a rat (defined in config) - mockWorld.actors.set(100, { - id: 100, + mockWorld.actors.set(3, { + id: 3, + category: "combatant", isPlayer: false, - type: 'rat', - pos: { x: 2, y: 2 }, - speed: 100, + type: "rat", + pos: { x: 3, y: 1 }, + speed: 10, 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(); renderer.render([]); diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index a450fd1..bdee1a6 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -4,7 +4,8 @@ import { type Vec2, type Action, type RunState, - type World + type World, + type CombatantActor } from "../core/types"; import { TILE_SIZE } from "../core/constants"; 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; // 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( this.world, this.dungeonRenderer.seenArray, @@ -170,7 +173,7 @@ export class GameScene extends Phaser.Scene { // Auto-walk one step per turn 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 dx = next.x - player.pos.x; 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)) { // Check if it's an enemy at 'next' 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; if (targetId !== undefined) { @@ -213,13 +216,13 @@ export class GameScene extends Phaser.Scene { else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1; 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 targetY = player.pos.y + dy; // Check for enemy at target position 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; if (targetId !== undefined) { @@ -254,11 +257,15 @@ export class GameScene extends Phaser.Scene { const allEvents = [...playerEvents, ...enemyStep.events]; for (const ev of allEvents) { 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") { this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat"); } 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) { this.dungeonRenderer.showWait(player.pos.x, player.pos.y); } @@ -328,8 +335,8 @@ export class GameScene extends Phaser.Scene { } private syncRunStateFromPlayer() { - const p = this.world.actors.get(this.playerId); - if (!p?.stats || !p.inventory) return; + const p = this.world.actors.get(this.playerId) as CombatantActor; + if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return; this.runState = { stats: { ...p.stats }, @@ -348,7 +355,7 @@ export class GameScene extends Phaser.Scene { private centerCameraOnPlayer() { - const player = this.world.actors.get(this.playerId)!; + const player = this.world.actors.get(this.playerId) as CombatantActor; this.cameras.main.centerOn( player.pos.x * 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) { - const p = this.world.actors.get(this.playerId); - if (!p || !p.stats || p.stats.statPoints <= 0) return; + const p = this.world.actors.get(this.playerId) as CombatantActor; + if (!p || p.category !== "combatant" || !p.stats || p.stats.statPoints <= 0) return; p.stats.statPoints--; if (statName === "strength") { @@ -380,8 +387,8 @@ export class GameScene extends Phaser.Scene { } private allocatePassive(nodeId: string) { - const p = this.world.actors.get(this.playerId); - if (!p || !p.stats || p.stats.skillPoints <= 0) return; + const p = this.world.actors.get(this.playerId) as CombatantActor; + if (!p || p.category !== "combatant" || !p.stats || p.stats.skillPoints <= 0) return; if (p.stats.passiveNodes.includes(nodeId)) return; diff --git a/src/ui/GameUI.ts b/src/ui/GameUI.ts index 1a15338..ee65417 100644 --- a/src/ui/GameUI.ts +++ b/src/ui/GameUI.ts @@ -1,5 +1,5 @@ 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"; export default class GameUI extends Phaser.Scene { @@ -555,8 +555,8 @@ export default class GameUI extends Phaser.Scene { } private updateCharacterUI(world: World, playerId: EntityId) { - const p = world.actors.get(playerId); - if (!p || !p.stats) return; + const p = world.actors.get(playerId) as CombatantActor; + if (!p || p.category !== "combatant" || !p.stats) return; const s = p.stats; 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}`, `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)"}` ]; this.charStatsText.setText(statsLines.join("\n")); } private updateInventoryUI(world: World, playerId: EntityId) { - const p = world.actors.get(playerId); - if (!p) return; + const p = world.actors.get(playerId) as CombatantActor; + if (!p || p.category !== "combatant") return; // Clear existing item icons/text from slots if needed (future refinement) // For now we just show names or placeholders @@ -588,8 +596,8 @@ export default class GameUI extends Phaser.Scene { this.floorText.setText(`Floor ${floorIndex}`); - const p = world.actors.get(playerId); - if (!p || !p.stats) return; + const p = world.actors.get(playerId) as CombatantActor; + if (!p || p.category !== "combatant" || !p.stats) return; const barX = 40; const barY = 40; @@ -655,9 +663,10 @@ export default class GameUI extends Phaser.Scene { private updateMenuText(world: World, playerId: EntityId, _floorIndex: number) { - const p = world.actors.get(playerId); - const stats = p?.stats; - const inv = p?.inventory; + const p = world.actors.get(playerId) as CombatantActor; + if (!p || p.category !== "combatant") return; + const stats = p.stats; + const inv = p.inventory; const lines: string[] = []; 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(` Defense: ${stats?.defense ?? 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("Inventory");