Begin refactoring GameScene
This commit is contained in:
115
src/scenes/systems/EventRenderer.ts
Normal file
115
src/scenes/systems/EventRenderer.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { SimEvent, ActorType, EntityId, Vec2 } from "../../core/types";
|
||||
|
||||
/**
|
||||
* Callbacks for rendering game simulation events.
|
||||
* These delegate to the actual rendering implementation.
|
||||
*/
|
||||
export interface EventRenderCallbacks {
|
||||
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void;
|
||||
showDodge(x: number, y: number): void;
|
||||
showHeal(x: number, y: number, amount: number): void;
|
||||
spawnCorpse(x: number, y: number, type: ActorType): void;
|
||||
showWait(x: number, y: number): void;
|
||||
spawnOrb(orbId: EntityId, x: number, y: number): void;
|
||||
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void;
|
||||
showLevelUp(x: number, y: number): void;
|
||||
showAlert(x: number, y: number): void;
|
||||
showFloatingText(x: number, y: number, message: string, color: string): void;
|
||||
spawnLoot?(x: number, y: number, itemName: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context needed for event rendering decisions.
|
||||
*/
|
||||
export interface EventRenderContext {
|
||||
playerId: EntityId;
|
||||
getPlayerPos: () => Vec2 | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all simulation events using the provided callbacks.
|
||||
* This is a pure function that maps events to render calls.
|
||||
*/
|
||||
export function renderSimEvents(
|
||||
events: SimEvent[],
|
||||
callbacks: EventRenderCallbacks,
|
||||
context: EventRenderContext
|
||||
): void {
|
||||
for (const ev of events) {
|
||||
switch (ev.type) {
|
||||
case "damaged":
|
||||
callbacks.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock);
|
||||
break;
|
||||
|
||||
case "dodged":
|
||||
callbacks.showDodge(ev.x, ev.y);
|
||||
break;
|
||||
|
||||
case "healed":
|
||||
callbacks.showHeal(ev.x, ev.y, ev.amount);
|
||||
break;
|
||||
|
||||
case "killed":
|
||||
callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
||||
break;
|
||||
|
||||
case "waited":
|
||||
if (ev.actorId === context.playerId) {
|
||||
const pos = context.getPlayerPos();
|
||||
if (pos) {
|
||||
callbacks.showWait(pos.x, pos.y);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "orb-spawned":
|
||||
callbacks.spawnOrb(ev.orbId, ev.x, ev.y);
|
||||
break;
|
||||
|
||||
case "exp-collected":
|
||||
if (ev.actorId === context.playerId) {
|
||||
callbacks.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
||||
}
|
||||
break;
|
||||
|
||||
case "leveled-up":
|
||||
if (ev.actorId === context.playerId) {
|
||||
callbacks.showLevelUp(ev.x, ev.y);
|
||||
}
|
||||
break;
|
||||
|
||||
case "enemy-alerted":
|
||||
callbacks.showAlert(ev.x, ev.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status effect display colors and names.
|
||||
*/
|
||||
const EFFECT_COLORS: Record<string, string> = {
|
||||
poison: "#00ff00",
|
||||
burning: "#ff6600",
|
||||
frozen: "#00ffff"
|
||||
};
|
||||
|
||||
const EFFECT_NAMES: Record<string, string> = {
|
||||
poison: "Poisoned!",
|
||||
burning: "Burning!",
|
||||
frozen: "Paralyzed!"
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the display color for a status effect.
|
||||
*/
|
||||
export function getEffectColor(effect: string): string {
|
||||
return EFFECT_COLORS[effect] ?? "#ffffff";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display name for a status effect.
|
||||
*/
|
||||
export function getEffectName(effect: string): string {
|
||||
return EFFECT_NAMES[effect] ?? effect;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { World, CombatantActor, Item, ItemDropActor, Vec2 } from "../../core/types";
|
||||
import { EntityManager } from "../../engine/EntityManager";
|
||||
import { EntityAccessor } from "../../engine/EntityAccessor";
|
||||
import { type ECSWorld } from "../../engine/ecs/World";
|
||||
import { EntityBuilder } from "../../engine/ecs/EntityBuilder";
|
||||
|
||||
/**
|
||||
* Result of attempting to use an item
|
||||
@@ -16,26 +18,29 @@ export interface ItemUseResult {
|
||||
*/
|
||||
export class ItemManager {
|
||||
private world: World;
|
||||
private entityManager: EntityManager;
|
||||
private entityAccessor: EntityAccessor;
|
||||
private ecsWorld?: ECSWorld;
|
||||
|
||||
constructor(world: World, entityManager: EntityManager) {
|
||||
constructor(world: World, entityAccessor: EntityAccessor, ecsWorld?: ECSWorld) {
|
||||
this.world = world;
|
||||
this.entityManager = entityManager;
|
||||
this.entityAccessor = entityAccessor;
|
||||
this.ecsWorld = ecsWorld;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update references when world changes (e.g., new floor)
|
||||
*/
|
||||
updateWorld(world: World, entityManager: EntityManager): void {
|
||||
updateWorld(world: World, entityAccessor: EntityAccessor, ecsWorld?: ECSWorld): void {
|
||||
this.world = world;
|
||||
this.entityManager = entityManager;
|
||||
this.entityAccessor = entityAccessor;
|
||||
if (ecsWorld) this.ecsWorld = ecsWorld;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an item drop at the specified position
|
||||
*/
|
||||
spawnItem(item: Item, pos: Vec2): void {
|
||||
if (!this.world || !this.entityManager) return;
|
||||
if (!this.world || !this.ecsWorld) return;
|
||||
|
||||
// Deep clone item (crucial for items with mutable stats like ammo)
|
||||
const clonedItem = { ...item } as Item;
|
||||
@@ -43,15 +48,11 @@ export class ItemManager {
|
||||
(clonedItem as any).stats = { ...clonedItem.stats };
|
||||
}
|
||||
|
||||
const id = this.entityManager.getNextId();
|
||||
const drop: ItemDropActor = {
|
||||
id,
|
||||
pos: { x: pos.x, y: pos.y },
|
||||
category: "item_drop",
|
||||
item: clonedItem
|
||||
};
|
||||
|
||||
this.entityManager.addActor(drop);
|
||||
// ECS Path: Spawn using EntityBuilder
|
||||
EntityBuilder.create(this.ecsWorld)
|
||||
.withPosition(pos.x, pos.y)
|
||||
.asGroundItem(clonedItem)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,15 +62,19 @@ export class ItemManager {
|
||||
tryPickup(player: CombatantActor): Item | null {
|
||||
if (!player || !player.inventory) return null;
|
||||
|
||||
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
|
||||
const itemActor = actors.find((a): a is ItemDropActor => a.category === "item_drop");
|
||||
let itemActor: ItemDropActor | null = null;
|
||||
|
||||
// Use EntityAccessor to find item on the ground
|
||||
if (this.entityAccessor) {
|
||||
itemActor = this.entityAccessor.findItemDropAt(player.pos.x, player.pos.y);
|
||||
}
|
||||
|
||||
if (itemActor) {
|
||||
const item = itemActor.item;
|
||||
const result = this.addItem(player, item);
|
||||
|
||||
// Remove from world
|
||||
this.entityManager.removeActor(itemActor.id);
|
||||
this.entityAccessor.removeActor(itemActor.id);
|
||||
|
||||
console.log("Picked up:", item.name);
|
||||
return result;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Phaser from "phaser";
|
||||
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
|
||||
import type { World, Item, Vec2, EntityId } from "../../core/types";
|
||||
import { TILE_SIZE } from "../../core/constants";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
import { UI_CONFIG } from "../../core/config/ui";
|
||||
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
||||
import type { EntityManager } from "../../engine/EntityManager";
|
||||
import { type EntityAccessor } from "../../engine/EntityAccessor";
|
||||
|
||||
/**
|
||||
* Manages targeting mode for thrown items.
|
||||
@@ -19,7 +19,7 @@ export class TargetingSystem {
|
||||
|
||||
// Context for predictive visual
|
||||
private world: World | null = null;
|
||||
private entityManager: EntityManager | null = null;
|
||||
private accessor: EntityAccessor | null = null;
|
||||
private playerId: EntityId | null = null;
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
@@ -40,7 +40,7 @@ export class TargetingSystem {
|
||||
itemId: string,
|
||||
playerPos: Vec2,
|
||||
world: World,
|
||||
entityManager: EntityManager,
|
||||
accessor: EntityAccessor,
|
||||
playerId: EntityId,
|
||||
seenArray: Uint8Array,
|
||||
worldWidth: number,
|
||||
@@ -48,12 +48,12 @@ export class TargetingSystem {
|
||||
): void {
|
||||
this.targetingItemId = itemId;
|
||||
this.world = world;
|
||||
this.entityManager = entityManager;
|
||||
this.accessor = accessor;
|
||||
this.playerId = playerId;
|
||||
this.active = true;
|
||||
|
||||
// Auto-target closest visible enemy
|
||||
const closest = getClosestVisibleEnemy(world, playerPos, seenArray, worldWidth);
|
||||
const closest = getClosestVisibleEnemy(playerPos, seenArray, worldWidth, accessor);
|
||||
|
||||
if (closest) {
|
||||
this.cursor = closest;
|
||||
@@ -84,14 +84,14 @@ export class TargetingSystem {
|
||||
executeThrow(
|
||||
world: World,
|
||||
playerId: EntityId,
|
||||
entityManager: EntityManager,
|
||||
accessor: EntityAccessor,
|
||||
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
|
||||
): boolean {
|
||||
if (!this.active || !this.targetingItemId || !this.cursor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const player = world.actors.get(playerId) as CombatantActor;
|
||||
const player = accessor.getCombatant(playerId);
|
||||
if (!player || !player.inventory) return false;
|
||||
|
||||
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
|
||||
@@ -116,7 +116,7 @@ export class TargetingSystem {
|
||||
const start = player.pos;
|
||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
||||
|
||||
const result = traceProjectile(world, start, end, entityManager, playerId);
|
||||
const result = traceProjectile(world, start, end, accessor, playerId);
|
||||
const { blockedPos, hitActorId } = result;
|
||||
|
||||
// Call the callback with throw results
|
||||
@@ -133,7 +133,7 @@ export class TargetingSystem {
|
||||
this.targetingItemId = null;
|
||||
this.cursor = null;
|
||||
this.world = null;
|
||||
this.entityManager = null;
|
||||
this.accessor = null;
|
||||
this.playerId = null;
|
||||
this.graphics.clear();
|
||||
this.crosshairSprite.setVisible(false);
|
||||
@@ -184,8 +184,8 @@ export class TargetingSystem {
|
||||
let finalEndX = aimEndX;
|
||||
let finalEndY = aimEndY;
|
||||
|
||||
if (this.world && this.entityManager && this.playerId !== null) {
|
||||
const result = traceProjectile(this.world, playerPos, this.cursor, this.entityManager, this.playerId);
|
||||
if (this.world && this.accessor && this.playerId !== null) {
|
||||
const result = traceProjectile(this.world, playerPos, this.cursor, this.accessor, this.playerId);
|
||||
const bPos = result.blockedPos;
|
||||
|
||||
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||
|
||||
62
src/scenes/systems/__tests__/ItemManager.test.ts
Normal file
62
src/scenes/systems/__tests__/ItemManager.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ItemManager } from '../ItemManager';
|
||||
import type { World, Item, ItemDropActor, EntityId } from "../../../core/types";
|
||||
|
||||
describe('ItemManager', () => {
|
||||
let world: World;
|
||||
let entityAccessor: any;
|
||||
let itemManager: ItemManager;
|
||||
|
||||
beforeEach(() => {
|
||||
world = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
tiles: new Array(100).fill(1), // Floor
|
||||
exit: { x: 9, y: 9 }
|
||||
};
|
||||
|
||||
entityAccessor = {
|
||||
findItemDropAt: vi.fn(() => null),
|
||||
removeActor: vi.fn(),
|
||||
context: undefined,
|
||||
getEnemies: vi.fn(() => [])
|
||||
};
|
||||
|
||||
itemManager = new ItemManager(world, entityAccessor);
|
||||
});
|
||||
|
||||
it('should pickup an item at the player position', () => {
|
||||
const player = {
|
||||
id: 1 as EntityId,
|
||||
pos: { x: 2, y: 2 },
|
||||
inventory: { items: [], gold: 0 }
|
||||
} as any;
|
||||
|
||||
const item: Item = {
|
||||
id: 'health_potion',
|
||||
name: 'Health Potion',
|
||||
type: 'Consumable',
|
||||
textureKey: 'items',
|
||||
spriteIndex: 0
|
||||
};
|
||||
|
||||
const itemActor: ItemDropActor = {
|
||||
id: 2 as EntityId,
|
||||
category: 'item_drop',
|
||||
pos: { x: 2, y: 2 },
|
||||
item
|
||||
};
|
||||
|
||||
// Setup Accessor to find the item
|
||||
entityAccessor.findItemDropAt.mockReturnValue(itemActor);
|
||||
|
||||
const result = itemManager.tryPickup(player);
|
||||
|
||||
expect(entityAccessor.findItemDropAt).toHaveBeenCalledWith(2, 2);
|
||||
expect(result).not.toBeNull();
|
||||
expect(player.inventory.items.length).toBe(1);
|
||||
expect(player.inventory.items[0]).toEqual({ ...item, quantity: 1 });
|
||||
expect(entityAccessor.removeActor).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock Phaser
|
||||
@@ -18,6 +19,10 @@ vi.mock('phaser', () => {
|
||||
|
||||
return {
|
||||
default: {
|
||||
GameObjects: {
|
||||
Sprite: vi.fn(() => mockSprite),
|
||||
Graphics: vi.fn(() => mockGraphics),
|
||||
},
|
||||
Scene: class {
|
||||
add = {
|
||||
graphics: vi.fn(() => mockGraphics),
|
||||
@@ -37,11 +42,11 @@ vi.mock('../../../engine/gameplay/CombatLogic', () => ({
|
||||
import { TargetingSystem } from '../TargetingSystem';
|
||||
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
|
||||
import { TILE_SIZE } from '../../../core/constants';
|
||||
import type { EntityId } from '../../../core/types';
|
||||
|
||||
describe('TargetingSystem', () => {
|
||||
let targetingSystem: TargetingSystem;
|
||||
let mockWorld: any;
|
||||
let mockEntityManager: any;
|
||||
let mockScene: any;
|
||||
let mockGraphics: any;
|
||||
let mockSprite: any;
|
||||
@@ -72,7 +77,6 @@ describe('TargetingSystem', () => {
|
||||
|
||||
targetingSystem = new TargetingSystem(mockScene);
|
||||
mockWorld = { width: 10, height: 10 };
|
||||
mockEntityManager = {};
|
||||
|
||||
// Default return for traceProjectile
|
||||
(traceProjectile as any).mockReturnValue({
|
||||
@@ -97,8 +101,8 @@ describe('TargetingSystem', () => {
|
||||
'item-1',
|
||||
playerPos,
|
||||
mockWorld,
|
||||
mockEntityManager!,
|
||||
1 as any,
|
||||
{} as any, // accessor
|
||||
1 as EntityId, // playerId
|
||||
new Uint8Array(100),
|
||||
10
|
||||
);
|
||||
@@ -118,8 +122,8 @@ describe('TargetingSystem', () => {
|
||||
'item-1',
|
||||
playerPos,
|
||||
mockWorld,
|
||||
mockEntityManager!,
|
||||
1 as any,
|
||||
{} as any, // accessor
|
||||
1 as EntityId, // playerId
|
||||
new Uint8Array(100),
|
||||
10,
|
||||
mousePos
|
||||
@@ -144,8 +148,8 @@ describe('TargetingSystem', () => {
|
||||
'item-1',
|
||||
playerPos,
|
||||
mockWorld,
|
||||
mockEntityManager!,
|
||||
1 as any,
|
||||
{} as any, // accessor
|
||||
1 as EntityId,
|
||||
new Uint8Array(100),
|
||||
10,
|
||||
targetPos
|
||||
|
||||
Reference in New Issue
Block a user