Add more stats, crit/block/accuracy/dodge/lifesteal
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 })
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyAction - move', () => {
|
const world = createTestWorld(actors);
|
||||||
it('should move actor to new position', () => {
|
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", () => {
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,17 +24,25 @@ 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)
|
||||||
|
if (actor.category === "combatant") {
|
||||||
actor.energy -= GAME_CONFIG.gameplay.actionCost;
|
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",
|
||||||
@@ -45,13 +53,11 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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];
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,12 +177,15 @@ 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.category === "combatant") {
|
||||||
if (a.isPlayer) {
|
if (a.isPlayer) {
|
||||||
if (this.playerSprite) {
|
if (this.playerSprite) {
|
||||||
this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||||
@@ -255,12 +196,9 @@ export class DungeonRenderer {
|
|||||||
|
|
||||||
if (!isVis) continue;
|
if (!isVis) continue;
|
||||||
|
|
||||||
const enemyType = a.type as keyof typeof GAME_CONFIG.enemies;
|
|
||||||
if (!GAME_CONFIG.enemies[enemyType]) continue;
|
|
||||||
|
|
||||||
activeEnemyIds.add(a.id);
|
activeEnemyIds.add(a.id);
|
||||||
let sprite = this.enemySprites.get(a.id);
|
let sprite = this.enemySprites.get(a.id);
|
||||||
const textureKey = a.type || "rat";
|
const textureKey = a.type;
|
||||||
|
|
||||||
if (!sprite) {
|
if (!sprite) {
|
||||||
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
||||||
@@ -271,28 +209,8 @@ export class DungeonRenderer {
|
|||||||
|
|
||||||
sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||||
sprite.setVisible(true);
|
sprite.setVisible(true);
|
||||||
}
|
} else if (a.category === "collectible") {
|
||||||
|
if (a.type === "exp_orb") {
|
||||||
for (const [id, sprite] of this.enemySprites.entries()) {
|
|
||||||
if (!activeEnemyIds.has(id)) {
|
|
||||||
sprite.setVisible(false);
|
|
||||||
if (!this.world.actors.has(id)) {
|
|
||||||
sprite.destroy();
|
|
||||||
this.enemySprites.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
if (!isVis) continue;
|
||||||
|
|
||||||
activeOrbIds.add(a.id);
|
activeOrbIds.add(a.id);
|
||||||
@@ -306,6 +224,19 @@ export class DungeonRenderer {
|
|||||||
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||||
orb.setVisible(true);
|
orb.setVisible(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup sprites for removed actors
|
||||||
|
for (const [id, sprite] of this.enemySprites.entries()) {
|
||||||
|
if (!activeEnemyIds.has(id)) {
|
||||||
|
sprite.setVisible(false);
|
||||||
|
if (!this.world.actors.has(id)) {
|
||||||
|
sprite.destroy();
|
||||||
|
this.enemySprites.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [id, orb] of this.orbSprites.entries()) {
|
for (const [id, orb] of this.orbSprites.entries()) {
|
||||||
if (!activeOrbIds.has(id)) {
|
if (!activeOrbIds.has(id)) {
|
||||||
@@ -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;
|
showDodge(x: number, y: number) {
|
||||||
const ey = this.world.exit.y;
|
this.fxRenderer.showDodge(x, 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);
|
showHeal(x: number, y: number, amount: number) {
|
||||||
if (player) {
|
this.fxRenderer.showHeal(x, y, amount);
|
||||||
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()) {
|
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") {
|
||||||
if (a.isPlayer) continue;
|
this.fxRenderer.spawnCorpse(x, y, type);
|
||||||
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) {
|
|
||||||
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()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat" | "exp_orb") {
|
|
||||||
if (type === "exp_orb") return;
|
|
||||||
const textureKey = type === "player" ? "warrior" : type;
|
|
||||||
|
|
||||||
const corpse = this.scene.add.sprite(
|
|
||||||
x * TILE_SIZE + TILE_SIZE / 2,
|
|
||||||
y * TILE_SIZE + TILE_SIZE / 2,
|
|
||||||
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()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
src/rendering/FovManager.ts
Normal file
68
src/rendering/FovManager.ts
Normal 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
191
src/rendering/FxRenderer.ts
Normal 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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/rendering/MinimapRenderer.ts
Normal file
104
src/rendering/MinimapRenderer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user