Begin refactoring GameScene

This commit is contained in:
Peter Stockings
2026-01-26 15:30:14 +11:00
parent 1d7be54fd9
commit ef7d85750f
46 changed files with 2459 additions and 1291 deletions

View 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;
}

View File

@@ -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;

View File

@@ -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;

View 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);
});
});

View File

@@ -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