Add more stats, crit/block/accuracy/dodge/lifesteal
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EntityId, Actor>): World => ({
|
||||
@@ -14,6 +14,14 @@ describe('Combat Simulation', () => {
|
||||
const createTestStats = (overrides: Partial<any> = {}) => ({
|
||||
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<EntityId, Actor>();
|
||||
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<EntityId, Actor>();
|
||||
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<EntityId, Actor>();
|
||||
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<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', () => {
|
||||
it('should move actor to new position', () => {
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
|
||||
|
||||
expect(events.some(e => e.type === "dodged")).toBe(true);
|
||||
expect((world.actors.get(2) as CombatantActor).stats.hp).toBe(20);
|
||||
});
|
||||
|
||||
it("should emit crit flag when critical strike occurs", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
|
||||
stats: createTestStats({ critChance: 100, critMultiplier: 200, attack: 10 })
|
||||
});
|
||||
actors.set(2, {
|
||||
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
|
||||
stats: createTestStats({ defense: 0, hp: 100 })
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
|
||||
|
||||
const damagedEvent = events.find(e => e.type === "damaged") as any;
|
||||
expect(damagedEvent.isCrit).toBe(true);
|
||||
expect(damagedEvent.amount).toBe(20); // 10 * 2.0
|
||||
});
|
||||
|
||||
it("should emit block flag and reduce damage", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
|
||||
stats: createTestStats({ attack: 10 })
|
||||
});
|
||||
actors.set(2, {
|
||||
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
|
||||
stats: createTestStats({ defense: 0, blockChance: 100, hp: 100 }) // Force block
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
|
||||
|
||||
const damagedEvent = events.find(e => e.type === "damaged") as any;
|
||||
expect(damagedEvent.isBlock).toBe(true);
|
||||
expect(damagedEvent.amount).toBe(5); // 10 * 0.5
|
||||
});
|
||||
|
||||
it("should heal attacker via lifesteal", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0,
|
||||
stats: createTestStats({ attack: 10, lifesteal: 50, hp: 5, maxHp: 20 })
|
||||
});
|
||||
actors.set(2, {
|
||||
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0,
|
||||
stats: createTestStats({ defense: 0 })
|
||||
});
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 });
|
||||
|
||||
expect(events.some(e => e.type === "healed")).toBe(true);
|
||||
expect((world.actors.get(1) as CombatantActor).stats.hp).toBe(10); // 5 + (10 * 0.5)
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAction - move", () => {
|
||||
it("should move actor to new position", () => {
|
||||
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,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,17 +24,25 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
||||
}
|
||||
|
||||
// Spend energy for any action (move/wait/attack)
|
||||
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) {
|
||||
const amount = orb.expAmount || 0;
|
||||
player.stats.exp += amount;
|
||||
events.push({
|
||||
type: "exp-collected",
|
||||
@@ -45,13 +53,11 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
@@ -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<EntityId, Actor>
|
||||
|
||||
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<EntityId, Actor>
|
||||
strength: 0,
|
||||
dexterity: 0,
|
||||
intelligence: 0,
|
||||
critChance: 0,
|
||||
critMultiplier: 100,
|
||||
accuracy: 80,
|
||||
lifesteal: 0,
|
||||
evasion: 0,
|
||||
blockChance: 0,
|
||||
luck: 0,
|
||||
passiveNodes: []
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<EntityId, Phaser.GameObjects.Sprite> = 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;
|
||||
|
||||
// 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.fxRenderer.clearCorpses();
|
||||
this.setupAnimations();
|
||||
this.minimapRenderer.positionMinimap();
|
||||
}
|
||||
this.corpseSprites = [];
|
||||
|
||||
// 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,12 +177,15 @@ export class DungeonRenderer {
|
||||
}
|
||||
});
|
||||
|
||||
// Actors
|
||||
// Actors (Combatants)
|
||||
const activeEnemyIds = new Set<EntityId>();
|
||||
const activeOrbIds = new Set<EntityId>();
|
||||
|
||||
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;
|
||||
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);
|
||||
@@ -255,12 +196,9 @@ export class DungeonRenderer {
|
||||
|
||||
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";
|
||||
const textureKey = a.type;
|
||||
|
||||
if (!sprite) {
|
||||
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.setVisible(true);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
} else if (a.category === "collectible") {
|
||||
if (a.type === "exp_orb") {
|
||||
if (!isVis) continue;
|
||||
|
||||
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.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()) {
|
||||
if (!activeOrbIds.has(id)) {
|
||||
@@ -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);
|
||||
}
|
||||
// FX Delegations
|
||||
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
|
||||
this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock);
|
||||
}
|
||||
|
||||
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);
|
||||
showDodge(x: number, y: number) {
|
||||
this.fxRenderer.showDodge(x, y);
|
||||
}
|
||||
|
||||
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);
|
||||
showHeal(x: number, y: number, amount: number) {
|
||||
this.fxRenderer.showHeal(x, y, amount);
|
||||
}
|
||||
|
||||
for (const a of this.world.actors.values()) {
|
||||
if (a.isPlayer) continue;
|
||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||
if (this.visible[i] === 1) {
|
||||
this.minimapGfx.fillStyle(0xff6666, 1);
|
||||
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showDamage(x: number, y: number, amount: number) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
// 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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user