Begin refactoring GameScene
This commit is contained in:
@@ -206,13 +206,13 @@ export type World = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
tiles: Tile[];
|
tiles: Tile[];
|
||||||
actors: Map<EntityId, Actor>;
|
|
||||||
exit: Vec2;
|
exit: Vec2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UIUpdatePayload {
|
export interface UIUpdatePayload {
|
||||||
world: World;
|
world: World;
|
||||||
playerId: EntityId;
|
playerId: EntityId;
|
||||||
|
player: CombatantActor | null; // Added for ECS Access
|
||||||
floorIndex: number;
|
floorIndex: number;
|
||||||
uiState: {
|
uiState: {
|
||||||
targetingItemId: string | null;
|
targetingItemId: string | null;
|
||||||
|
|||||||
348
src/engine/EntityAccessor.ts
Normal file
348
src/engine/EntityAccessor.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import type {
|
||||||
|
World,
|
||||||
|
EntityId,
|
||||||
|
Actor,
|
||||||
|
CombatantActor,
|
||||||
|
CollectibleActor,
|
||||||
|
ItemDropActor,
|
||||||
|
Vec2,
|
||||||
|
EnemyAIState
|
||||||
|
} from "../core/types";
|
||||||
|
import type { ECSWorld } from "./ecs/World";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized accessor for game entities.
|
||||||
|
* Provides a unified interface for querying actors from the World.
|
||||||
|
*
|
||||||
|
* This facade:
|
||||||
|
* - Centralizes entity access patterns
|
||||||
|
* - Makes it easy to migrate to ECS later
|
||||||
|
* - Reduces scattered world.actors calls
|
||||||
|
*/
|
||||||
|
export class EntityAccessor {
|
||||||
|
private _playerId: EntityId;
|
||||||
|
private ecsWorld: ECSWorld;
|
||||||
|
private actorCache: Map<EntityId, Actor> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
_world: World,
|
||||||
|
playerId: EntityId,
|
||||||
|
ecsWorld: ECSWorld
|
||||||
|
) {
|
||||||
|
this._playerId = playerId;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the world reference (called when loading new floors).
|
||||||
|
*/
|
||||||
|
updateWorld(_world: World, playerId: EntityId, ecsWorld: ECSWorld): void {
|
||||||
|
this._playerId = playerId;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
this.actorCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private entityToActor(id: EntityId): Actor | null {
|
||||||
|
if (!this.ecsWorld) return null;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.actorCache.get(id);
|
||||||
|
if (cached) {
|
||||||
|
// Double check it still exists in ECS
|
||||||
|
if (!this.ecsWorld.hasEntity(id)) {
|
||||||
|
this.actorCache.delete(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = this.ecsWorld.getComponent(id, "position");
|
||||||
|
if (!pos) return null;
|
||||||
|
|
||||||
|
// Check for combatant
|
||||||
|
const stats = this.ecsWorld.getComponent(id, "stats");
|
||||||
|
const actorType = this.ecsWorld.getComponent(id, "actorType");
|
||||||
|
|
||||||
|
if (stats && actorType) {
|
||||||
|
const energyComp = this.ecsWorld.getComponent(id, "energy");
|
||||||
|
const playerComp = this.ecsWorld.getComponent(id, "player");
|
||||||
|
const ai = this.ecsWorld.getComponent(id, "ai");
|
||||||
|
const inventory = this.ecsWorld.getComponent(id, "inventory");
|
||||||
|
const equipment = this.ecsWorld.getComponent(id, "equipment");
|
||||||
|
|
||||||
|
// Create a proxy-like object to ensure writes persist to ECS components
|
||||||
|
let localEnergy = 0;
|
||||||
|
const actor = {
|
||||||
|
id,
|
||||||
|
// Pass Reference to PositionComponent so moves persist
|
||||||
|
pos: pos,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: !!playerComp,
|
||||||
|
type: actorType.type,
|
||||||
|
// Pass Reference to StatsComponent
|
||||||
|
stats: stats,
|
||||||
|
|
||||||
|
// Speed defaults
|
||||||
|
speed: energyComp?.speed ?? 100,
|
||||||
|
|
||||||
|
// Pass Reference (or fallback)
|
||||||
|
inventory: inventory ?? { gold: 0, items: [] },
|
||||||
|
equipment: equipment
|
||||||
|
} as CombatantActor;
|
||||||
|
|
||||||
|
// Manually define 'energy' property to proxy to component
|
||||||
|
Object.defineProperty(actor, 'energy', {
|
||||||
|
get: () => energyComp ? energyComp.current : localEnergy,
|
||||||
|
set: (v: number) => {
|
||||||
|
if (energyComp) {
|
||||||
|
energyComp.current = v;
|
||||||
|
} else {
|
||||||
|
localEnergy = v;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy AI state properties
|
||||||
|
Object.defineProperty(actor, 'aiState', {
|
||||||
|
get: () => ai?.state,
|
||||||
|
set: (v: EnemyAIState) => { if (ai) ai.state = v; },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(actor, 'alertedAt', {
|
||||||
|
get: () => ai?.alertedAt,
|
||||||
|
set: (v: number) => { if (ai) ai.alertedAt = v; },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(actor, 'lastKnownPlayerPos', {
|
||||||
|
get: () => ai?.lastKnownPlayerPos,
|
||||||
|
set: (v: Vec2) => { if (ai) ai.lastKnownPlayerPos = v; },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.actorCache.set(id, actor);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for collectible
|
||||||
|
const collectible = this.ecsWorld.getComponent(id, "collectible");
|
||||||
|
if (collectible) {
|
||||||
|
const actor = {
|
||||||
|
id,
|
||||||
|
pos: pos, // Reference
|
||||||
|
category: "collectible",
|
||||||
|
type: "exp_orb",
|
||||||
|
expAmount: collectible.amount
|
||||||
|
} as CollectibleActor;
|
||||||
|
this.actorCache.set(id, actor);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Item Drop
|
||||||
|
const groundItem = this.ecsWorld.getComponent(id, "groundItem");
|
||||||
|
if (groundItem) {
|
||||||
|
const actor = {
|
||||||
|
id,
|
||||||
|
pos: pos,
|
||||||
|
category: "item_drop",
|
||||||
|
item: groundItem.item
|
||||||
|
} as ItemDropActor;
|
||||||
|
this.actorCache.set(id, actor);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Player Access
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player's entity ID.
|
||||||
|
*/
|
||||||
|
get playerId(): EntityId {
|
||||||
|
return this._playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player entity.
|
||||||
|
*/
|
||||||
|
getPlayer(): CombatantActor | null {
|
||||||
|
const actor = this.entityToActor(this._playerId);
|
||||||
|
if (actor?.category === "combatant") return actor as CombatantActor;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player's current position.
|
||||||
|
*/
|
||||||
|
getPlayerPos(): Vec2 | null {
|
||||||
|
const player = this.getPlayer();
|
||||||
|
return player ? { ...player.pos } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the player exists (is alive).
|
||||||
|
*/
|
||||||
|
isPlayerAlive(): boolean {
|
||||||
|
return this.ecsWorld.hasEntity(this._playerId) && (this.ecsWorld.getComponent(this._playerId, "position") !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Generic Actor Access
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets any actor by ID.
|
||||||
|
*/
|
||||||
|
getActor(id: EntityId): Actor | null {
|
||||||
|
return this.entityToActor(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a combatant actor by ID.
|
||||||
|
*/
|
||||||
|
getCombatant(id: EntityId): CombatantActor | null {
|
||||||
|
const actor = this.entityToActor(id);
|
||||||
|
if (actor?.category === "combatant") return actor as CombatantActor;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an actor exists.
|
||||||
|
*/
|
||||||
|
hasActor(id: EntityId): boolean {
|
||||||
|
return this.ecsWorld.hasEntity(id) && (this.ecsWorld.getComponent(id, "position") !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Spatial Queries
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all actors at a specific position.
|
||||||
|
*/
|
||||||
|
getActorsAt(x: number, y: number): Actor[] {
|
||||||
|
// Query ECS
|
||||||
|
return [...this.getAllActors()].filter(a => a.pos.x === x && a.pos.y === y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an enemy combatant at a specific position.
|
||||||
|
*/
|
||||||
|
findEnemyAt(x: number, y: number): CombatantActor | null {
|
||||||
|
const actors = this.getActorsAt(x, y);
|
||||||
|
for (const actor of actors) {
|
||||||
|
if (actor.category === "combatant" && !actor.isPlayer) {
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there's any enemy at the given position.
|
||||||
|
*/
|
||||||
|
hasEnemyAt(x: number, y: number): boolean {
|
||||||
|
return this.findEnemyAt(x, y) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a collectible at a specific position.
|
||||||
|
*/
|
||||||
|
findCollectibleAt(x: number, y: number): CollectibleActor | null {
|
||||||
|
const actors = this.getActorsAt(x, y);
|
||||||
|
for (const actor of actors) {
|
||||||
|
if (actor.category === "collectible") {
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an item drop at a specific position.
|
||||||
|
*/
|
||||||
|
findItemDropAt(x: number, y: number): ItemDropActor | null {
|
||||||
|
const actors = this.getActorsAt(x, y);
|
||||||
|
for (const actor of actors) {
|
||||||
|
if (actor.category === "item_drop") {
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Collection Queries
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all enemy combatants in the world.
|
||||||
|
*/
|
||||||
|
getEnemies(): CombatantActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is CombatantActor => a.category === "combatant" && !a.isPlayer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all combatants (player + enemies).
|
||||||
|
*/
|
||||||
|
getCombatants(): CombatantActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is CombatantActor => a.category === "combatant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all collectibles (exp orbs, etc.).
|
||||||
|
*/
|
||||||
|
getCollectibles(): CollectibleActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is CollectibleActor => a.category === "collectible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all item drops.
|
||||||
|
*/
|
||||||
|
getItemDrops(): ItemDropActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is ItemDropActor => a.category === "item_drop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates over all actors (for rendering, etc.).
|
||||||
|
*/
|
||||||
|
getAllActors(): IterableIterator<Actor> {
|
||||||
|
const actors: Actor[] = [];
|
||||||
|
// Get all entities with position (candidates)
|
||||||
|
const entities = this.ecsWorld.getEntitiesWith("position");
|
||||||
|
for (const id of entities) {
|
||||||
|
const actor = this.entityToActor(id);
|
||||||
|
if (actor) actors.push(actor);
|
||||||
|
}
|
||||||
|
return actors.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an actor from the world.
|
||||||
|
*/
|
||||||
|
removeActor(id: EntityId): void {
|
||||||
|
this.ecsWorld.destroyEntity(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access to the raw ECS world if needed for specialized systems.
|
||||||
|
*/
|
||||||
|
get context(): ECSWorld | undefined {
|
||||||
|
return this.ecsWorld;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { type World, type EntityId, type Actor, type Vec2, type CombatantActor } from "../core/types";
|
|
||||||
import { idx } from "./world/world-logic";
|
|
||||||
import { ECSWorld } from "./ecs/World";
|
|
||||||
import { MovementSystem } from "./ecs/MovementSystem";
|
|
||||||
import { AISystem } from "./ecs/AISystem";
|
|
||||||
|
|
||||||
export class EntityManager {
|
|
||||||
private grid: Map<number, EntityId[]> = new Map();
|
|
||||||
private actors: Map<EntityId, Actor>;
|
|
||||||
private world: World;
|
|
||||||
private lastId: number = 0;
|
|
||||||
private ecs: ECSWorld;
|
|
||||||
private movementSystem: MovementSystem;
|
|
||||||
private aiSystem: AISystem;
|
|
||||||
|
|
||||||
constructor(world: World) {
|
|
||||||
this.world = world;
|
|
||||||
this.actors = world.actors;
|
|
||||||
this.ecs = new ECSWorld();
|
|
||||||
this.movementSystem = new MovementSystem(this.ecs, this.world, this);
|
|
||||||
this.aiSystem = new AISystem(this.ecs, this.world, this);
|
|
||||||
this.lastId = Math.max(0, ...this.actors.keys());
|
|
||||||
this.ecs.setNextId(this.lastId + 1);
|
|
||||||
|
|
||||||
this.rebuildGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
get ecsWorld(): ECSWorld {
|
|
||||||
return this.ecs;
|
|
||||||
}
|
|
||||||
|
|
||||||
get movement(): MovementSystem {
|
|
||||||
return this.movementSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
get ai(): AISystem {
|
|
||||||
return this.aiSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
rebuildGrid() {
|
|
||||||
this.grid.clear();
|
|
||||||
// Also re-sync ECS if needed, though typically we do this once at start
|
|
||||||
for (const actor of this.actors.values()) {
|
|
||||||
this.syncActorToECS(actor);
|
|
||||||
this.addToGrid(actor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncActorToECS(actor: Actor) {
|
|
||||||
const id = actor.id;
|
|
||||||
this.ecs.addComponent(id, "position", actor.pos);
|
|
||||||
this.ecs.addComponent(id, "name", { name: actor.id.toString() });
|
|
||||||
|
|
||||||
if (actor.category === "combatant") {
|
|
||||||
const c = actor as CombatantActor;
|
|
||||||
this.ecs.addComponent(id, "stats", c.stats);
|
|
||||||
this.ecs.addComponent(id, "energy", { current: c.energy, speed: c.speed });
|
|
||||||
this.ecs.addComponent(id, "actorType", { type: c.type });
|
|
||||||
if (c.isPlayer) {
|
|
||||||
this.ecs.addComponent(id, "player", {});
|
|
||||||
} else {
|
|
||||||
this.ecs.addComponent(id, "ai", {
|
|
||||||
state: c.aiState || "wandering",
|
|
||||||
alertedAt: c.alertedAt,
|
|
||||||
lastKnownPlayerPos: c.lastKnownPlayerPos
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (actor.category === "collectible") {
|
|
||||||
this.ecs.addComponent(id, "collectible", { type: "exp_orb", amount: actor.expAmount });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private addToGrid(actor: Actor) {
|
|
||||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
|
||||||
if (!this.grid.has(i)) {
|
|
||||||
this.grid.set(i, []);
|
|
||||||
}
|
|
||||||
this.grid.get(i)!.push(actor.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeFromGrid(actor: Actor) {
|
|
||||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
|
||||||
const ids = this.grid.get(i);
|
|
||||||
if (ids) {
|
|
||||||
const index = ids.indexOf(actor.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
ids.splice(index, 1);
|
|
||||||
}
|
|
||||||
if (ids.length === 0) {
|
|
||||||
this.grid.delete(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveActor(actorId: EntityId, from: Vec2, to: Vec2) {
|
|
||||||
const actor = this.actors.get(actorId);
|
|
||||||
if (!actor) return;
|
|
||||||
|
|
||||||
// Remove from old position
|
|
||||||
const oldIdx = idx(this.world, from.x, from.y);
|
|
||||||
const ids = this.grid.get(oldIdx);
|
|
||||||
if (ids) {
|
|
||||||
const index = ids.indexOf(actorId);
|
|
||||||
if (index !== -1) ids.splice(index, 1);
|
|
||||||
if (ids.length === 0) this.grid.delete(oldIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update position
|
|
||||||
actor.pos.x = to.x;
|
|
||||||
actor.pos.y = to.y;
|
|
||||||
|
|
||||||
// Update ECS
|
|
||||||
const posComp = this.ecs.getComponent(actorId, "position");
|
|
||||||
if (posComp) {
|
|
||||||
posComp.x = to.x;
|
|
||||||
posComp.y = to.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to new position
|
|
||||||
const newIdx = idx(this.world, to.x, to.y);
|
|
||||||
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
|
|
||||||
this.grid.get(newIdx)!.push(actorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
addActor(actor: Actor) {
|
|
||||||
this.actors.set(actor.id, actor);
|
|
||||||
this.syncActorToECS(actor);
|
|
||||||
this.addToGrid(actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeActor(actorId: EntityId) {
|
|
||||||
const actor = this.actors.get(actorId);
|
|
||||||
if (actor) {
|
|
||||||
this.removeFromGrid(actor);
|
|
||||||
this.ecs.destroyEntity(actorId);
|
|
||||||
this.actors.delete(actorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getActorsAt(x: number, y: number): Actor[] {
|
|
||||||
const i = idx(this.world, x, y);
|
|
||||||
const ids = this.grid.get(i);
|
|
||||||
if (!ids) return [];
|
|
||||||
return ids.map(id => this.actors.get(id)!).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
isOccupied(x: number, y: number, ignoreType?: string): boolean {
|
|
||||||
const actors = this.getActorsAt(x, y);
|
|
||||||
if (ignoreType) {
|
|
||||||
return actors.some(a => a.type !== ignoreType);
|
|
||||||
}
|
|
||||||
return actors.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getNextId(): EntityId {
|
|
||||||
this.lastId++;
|
|
||||||
return this.lastId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
275
src/engine/__tests__/EntityAccessor.test.ts
Normal file
275
src/engine/__tests__/EntityAccessor.test.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { EntityAccessor } from "../EntityAccessor";
|
||||||
|
import { ECSWorld } from "../ecs/World";
|
||||||
|
import type { World, CombatantActor, CollectibleActor, ItemDropActor, Actor, EntityId } from "../../core/types";
|
||||||
|
|
||||||
|
function createMockWorld(): World {
|
||||||
|
return {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayer(id: number, x: number, y: number): CombatantActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
|
speed: 100,
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: 20, hp: 20, maxMana: 10, mana: 10,
|
||||||
|
attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
||||||
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0,
|
||||||
|
evasion: 5, blockChance: 0, luck: 0,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
|
passiveNodes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemy(id: number, x: number, y: number, type: "rat" | "bat" = "rat"): CombatantActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
type,
|
||||||
|
speed: 80,
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: 10, hp: 10, maxMana: 0, mana: 0,
|
||||||
|
attack: 3, defense: 1, level: 1, exp: 0, expToNextLevel: 10,
|
||||||
|
critChance: 0, critMultiplier: 100, accuracy: 80, lifesteal: 0,
|
||||||
|
evasion: 0, blockChance: 0, luck: 0,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 5, dexterity: 5, intelligence: 5,
|
||||||
|
passiveNodes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpOrb(id: number, x: number, y: number): CollectibleActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "collectible",
|
||||||
|
type: "exp_orb",
|
||||||
|
expAmount: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItemDrop(id: number, x: number, y: number): ItemDropActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "item_drop",
|
||||||
|
item: {
|
||||||
|
id: "health_potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EntityAccessor", () => {
|
||||||
|
let world: World;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let accessor: EntityAccessor;
|
||||||
|
const PLAYER_ID = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createMockWorld();
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
accessor = new EntityAccessor(world, PLAYER_ID as EntityId, ecsWorld);
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncActor(actor: Actor) {
|
||||||
|
ecsWorld.addComponent(actor.id, "position", actor.pos);
|
||||||
|
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
|
||||||
|
|
||||||
|
if (actor.category === "combatant") {
|
||||||
|
const c = actor as CombatantActor;
|
||||||
|
ecsWorld.addComponent(actor.id, "stats", c.stats);
|
||||||
|
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed });
|
||||||
|
ecsWorld.addComponent(actor.id, "actorType", { type: c.type });
|
||||||
|
if (c.isPlayer) {
|
||||||
|
ecsWorld.addComponent(actor.id, "player", {});
|
||||||
|
} else {
|
||||||
|
ecsWorld.addComponent(actor.id, "ai", { state: "wandering" });
|
||||||
|
}
|
||||||
|
} else if (actor.category === "collectible") {
|
||||||
|
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount });
|
||||||
|
} else if (actor.category === "item_drop") {
|
||||||
|
ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Player Access", () => {
|
||||||
|
it("getPlayer returns player when exists", () => {
|
||||||
|
const player = createPlayer(PLAYER_ID, 5, 5);
|
||||||
|
syncActor(player);
|
||||||
|
|
||||||
|
expect(accessor.getPlayer()?.id).toBe(player.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPlayer returns null when player doesn't exist", () => {
|
||||||
|
expect(accessor.getPlayer()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPlayerPos returns position copy", () => {
|
||||||
|
const player = createPlayer(PLAYER_ID, 3, 4);
|
||||||
|
syncActor(player);
|
||||||
|
|
||||||
|
const pos = accessor.getPlayerPos();
|
||||||
|
expect(pos).toEqual({ x: 3, y: 4 });
|
||||||
|
|
||||||
|
// Verify it's a copy
|
||||||
|
if (pos) {
|
||||||
|
pos.x = 99;
|
||||||
|
const freshPlayer = accessor.getPlayer();
|
||||||
|
expect(freshPlayer?.pos.x).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isPlayerAlive returns true when player exists", () => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 5, 5));
|
||||||
|
expect(accessor.isPlayerAlive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isPlayerAlive returns false when player is dead", () => {
|
||||||
|
expect(accessor.isPlayerAlive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Generic Actor Access", () => {
|
||||||
|
it("getActor returns actor by ID", () => {
|
||||||
|
const enemy = createEnemy(2, 3, 3);
|
||||||
|
syncActor(enemy);
|
||||||
|
|
||||||
|
expect(accessor.getActor(2 as EntityId)?.id).toBe(enemy.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getActor returns null for non-existent ID", () => {
|
||||||
|
expect(accessor.getActor(999 as EntityId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCombatant returns combatant by ID", () => {
|
||||||
|
const enemy = createEnemy(2, 3, 3);
|
||||||
|
syncActor(enemy);
|
||||||
|
|
||||||
|
expect(accessor.getCombatant(2 as EntityId)?.id).toBe(enemy.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCombatant returns null for non-combatant", () => {
|
||||||
|
const orb = createExpOrb(3, 5, 5);
|
||||||
|
syncActor(orb);
|
||||||
|
|
||||||
|
expect(accessor.getCombatant(3 as EntityId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasActor returns true for existing actor", () => {
|
||||||
|
syncActor(createEnemy(2, 3, 3));
|
||||||
|
expect(accessor.hasActor(2 as EntityId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasActor returns false for non-existent ID", () => {
|
||||||
|
expect(accessor.hasActor(999 as EntityId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Spatial Queries", () => {
|
||||||
|
it("findEnemyAt returns enemy at position", () => {
|
||||||
|
const enemy = createEnemy(2, 4, 4);
|
||||||
|
syncActor(enemy);
|
||||||
|
|
||||||
|
expect(accessor.findEnemyAt(4, 4)?.id).toBe(enemy.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findEnemyAt returns null when no enemy at position", () => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 4, 4));
|
||||||
|
expect(accessor.findEnemyAt(4, 4)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasEnemyAt returns true when enemy exists at position", () => {
|
||||||
|
syncActor(createEnemy(2, 4, 4));
|
||||||
|
expect(accessor.hasEnemyAt(4, 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findCollectibleAt returns collectible at position", () => {
|
||||||
|
const orb = createExpOrb(3, 6, 6);
|
||||||
|
syncActor(orb);
|
||||||
|
|
||||||
|
expect(accessor.findCollectibleAt(6, 6)?.id).toBe(orb.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findItemDropAt returns item drop at position", () => {
|
||||||
|
const drop = createItemDrop(4, 7, 7);
|
||||||
|
syncActor(drop);
|
||||||
|
|
||||||
|
expect(accessor.findItemDropAt(7, 7)?.id).toBe(drop.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Collection Queries", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 5, 5));
|
||||||
|
syncActor(createEnemy(2, 3, 3));
|
||||||
|
syncActor(createEnemy(3, 4, 4, "bat"));
|
||||||
|
syncActor(createExpOrb(4, 6, 6));
|
||||||
|
syncActor(createItemDrop(5, 7, 7));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getEnemies returns only non-player combatants", () => {
|
||||||
|
const enemies = accessor.getEnemies();
|
||||||
|
expect(enemies.length).toBe(2);
|
||||||
|
expect(enemies.every(e => !e.isPlayer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCombatants returns player and enemies", () => {
|
||||||
|
const combatants = accessor.getCombatants();
|
||||||
|
expect(combatants.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCollectibles returns only collectibles", () => {
|
||||||
|
const collectibles = accessor.getCollectibles();
|
||||||
|
expect(collectibles.length).toBe(1);
|
||||||
|
expect(collectibles[0].id).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getItemDrops returns only item drops", () => {
|
||||||
|
const drops = accessor.getItemDrops();
|
||||||
|
expect(drops.length).toBe(1);
|
||||||
|
expect(drops[0].id).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWorld", () => {
|
||||||
|
it("updates references correctly", () => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 1, 1));
|
||||||
|
|
||||||
|
const newWorld = createMockWorld();
|
||||||
|
const newEcsWorld = new ECSWorld();
|
||||||
|
const newPlayerId = 10;
|
||||||
|
|
||||||
|
const newPlayer = createPlayer(newPlayerId, 8, 8);
|
||||||
|
// Manually add to newEcsWorld
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "position", newPlayer.pos);
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "actorType", { type: "player" });
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "stats", newPlayer.stats);
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "player", {});
|
||||||
|
|
||||||
|
accessor.updateWorld(newWorld, newPlayerId as EntityId, newEcsWorld);
|
||||||
|
|
||||||
|
const player = accessor.getPlayer();
|
||||||
|
expect(player?.id).toBe(newPlayerId);
|
||||||
|
expect(player?.pos).toEqual({ x: 8, y: 8 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { EntityManager } from '../EntityManager';
|
|
||||||
import { type World, type Actor } from '../../core/types';
|
|
||||||
|
|
||||||
describe('EntityManager', () => {
|
|
||||||
let mockWorld: World;
|
|
||||||
let entityManager: EntityManager;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockWorld = {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
tiles: new Array(100).fill(0),
|
|
||||||
actors: new Map<number, Actor>(),
|
|
||||||
exit: { x: 9, y: 9 }
|
|
||||||
};
|
|
||||||
|
|
||||||
entityManager = new EntityManager(mockWorld);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add an actor and update the grid', () => {
|
|
||||||
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
|
|
||||||
entityManager.addActor(actor);
|
|
||||||
|
|
||||||
expect(mockWorld.actors.has(1)).toBe(true);
|
|
||||||
expect(entityManager.getActorsAt(2, 3).map(a => a.id)).toContain(1);
|
|
||||||
expect(entityManager.isOccupied(2, 3)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove an actor and update the grid', () => {
|
|
||||||
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
|
|
||||||
entityManager.addActor(actor);
|
|
||||||
entityManager.removeActor(1);
|
|
||||||
|
|
||||||
expect(mockWorld.actors.has(1)).toBe(false);
|
|
||||||
expect(entityManager.getActorsAt(2, 3).map(a => a.id)).not.toContain(1);
|
|
||||||
expect(entityManager.isOccupied(2, 3)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update the grid when an actor moves', () => {
|
|
||||||
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
|
|
||||||
entityManager.addActor(actor);
|
|
||||||
|
|
||||||
entityManager.moveActor(1, { x: 2, y: 3 }, { x: 4, y: 5 });
|
|
||||||
|
|
||||||
expect(actor.pos.x).toBe(4);
|
|
||||||
expect(actor.pos.y).toBe(5);
|
|
||||||
expect(entityManager.isOccupied(2, 3)).toBe(false);
|
|
||||||
expect(entityManager.isOccupied(4, 5)).toBe(true);
|
|
||||||
expect(entityManager.getActorsAt(4, 5).map(a => a.id)).toContain(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly identify occupied tiles while ignoring specific types', () => {
|
|
||||||
const orb: Actor = { id: 1, category: 'collectible', type: 'exp_orb', pos: { x: 2, y: 2 } } as any;
|
|
||||||
const enemy: Actor = { id: 2, category: 'combatant', type: 'rat', pos: { x: 5, y: 5 } } as any;
|
|
||||||
|
|
||||||
entityManager.addActor(orb);
|
|
||||||
entityManager.addActor(enemy);
|
|
||||||
|
|
||||||
expect(entityManager.isOccupied(2, 2)).toBe(true);
|
|
||||||
expect(entityManager.isOccupied(2, 2, 'exp_orb')).toBe(false);
|
|
||||||
expect(entityManager.isOccupied(5, 5)).toBe(true);
|
|
||||||
expect(entityManager.isOccupied(5, 5, 'exp_orb')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate the next available ID by scanning current actors', () => {
|
|
||||||
mockWorld.actors.set(10, { id: 10, pos: { x: 0, y: 0 } } as any);
|
|
||||||
mockWorld.actors.set(15, { id: 15, pos: { x: 1, y: 1 } } as any);
|
|
||||||
|
|
||||||
// Create new manager to trigger scan since current one has stale lastId
|
|
||||||
const manager = new EntityManager(mockWorld);
|
|
||||||
expect(manager.getNextId()).toBe(16);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it('should handle multiple actors at the same position', () => {
|
|
||||||
const actor1: Actor = { id: 1, pos: { x: 1, y: 1 } } as any;
|
|
||||||
const actor2: Actor = { id: 2, pos: { x: 1, y: 1 } } as any;
|
|
||||||
|
|
||||||
entityManager.addActor(actor1);
|
|
||||||
entityManager.addActor(actor2);
|
|
||||||
|
|
||||||
const atPos = entityManager.getActorsAt(1, 1);
|
|
||||||
expect(atPos.length).toBe(2);
|
|
||||||
expect(atPos.map(a => a.id)).toContain(1);
|
|
||||||
expect(atPos.map(a => a.id)).toContain(2);
|
|
||||||
|
|
||||||
entityManager.removeActor(1);
|
|
||||||
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toEqual([2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should handle removing non-existent actor gracefully', () => {
|
|
||||||
// Should not throw
|
|
||||||
entityManager.removeActor(999);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle moving non-existent actor gracefully', () => {
|
|
||||||
// Should not throw
|
|
||||||
entityManager.moveActor(999, { x: 0, y: 0 }, { x: 1, y: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle moving an actor that is not in the grid at expected position (inconsistent state)', () => {
|
|
||||||
const actor: Actor = { id: 1, pos: { x: 0, y: 0 } } as any;
|
|
||||||
// Add to actors map but NOT to grid (simulating desync)
|
|
||||||
mockWorld.actors.set(1, actor);
|
|
||||||
|
|
||||||
// Attempt move
|
|
||||||
entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 });
|
|
||||||
|
|
||||||
expect(actor.pos.x).toBe(1);
|
|
||||||
expect(actor.pos.y).toBe(1);
|
|
||||||
// Should be added to new position in grid
|
|
||||||
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toContain(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle moving an actor that is in grid but ID not found in list (very rare edge case)', () => {
|
|
||||||
// Manually pollute grid with empty array for old pos
|
|
||||||
// This forces `ids` to exist but `indexOf` to return -1
|
|
||||||
const idx = 0; // 0,0
|
|
||||||
// @ts-ignore
|
|
||||||
entityManager.grid.set(idx, [999]); // occupied by someone else
|
|
||||||
|
|
||||||
const actor: Actor = { id: 1, pos: { x: 0, y:0 } } as any;
|
|
||||||
mockWorld.actors.set(1, actor);
|
|
||||||
|
|
||||||
entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 });
|
|
||||||
expect(actor.pos).toEqual({ x: 1, y: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
64
src/engine/__tests__/PrefabTrap.test.ts
Normal file
64
src/engine/__tests__/PrefabTrap.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { TriggerSystem } from '../ecs/systems/TriggerSystem';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { EventBus } from '../ecs/EventBus';
|
||||||
|
import { Prefabs } from '../ecs/Prefabs';
|
||||||
|
import type { EntityId } from '../../core/types';
|
||||||
|
|
||||||
|
describe('Prefab Trap Integration', () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
let eventBus: EventBus;
|
||||||
|
let system: TriggerSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
eventBus = new EventBus();
|
||||||
|
system = new TriggerSystem();
|
||||||
|
system.setEventBus(eventBus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger poison trap when player moves onto it', () => {
|
||||||
|
// Setup Player (ID 1)
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
world.addComponent(playerId, 'position', { x: 1, y: 1 });
|
||||||
|
world.addComponent(playerId, 'stats', { hp: 10, maxHp: 10 } as any);
|
||||||
|
world.addComponent(playerId, 'player', {});
|
||||||
|
|
||||||
|
// Setup Prefab Trap (ID 100) at (2, 1)
|
||||||
|
// Use a high ID to avoid collision (simulating generator fix)
|
||||||
|
world.setNextId(100);
|
||||||
|
const trapId = Prefabs.poisonTrap(world, 2, 1, 5, 2);
|
||||||
|
|
||||||
|
// Register system (initializes entity positions)
|
||||||
|
system.onRegister(world);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(eventBus, 'emit');
|
||||||
|
|
||||||
|
// === MOVE PLAYER ===
|
||||||
|
// Update Player Position to (2, 1)
|
||||||
|
const pos = world.getComponent(playerId, 'position');
|
||||||
|
if (pos) pos.x = 2; // Move reference
|
||||||
|
|
||||||
|
// Update System
|
||||||
|
system.update([trapId], world);
|
||||||
|
|
||||||
|
// Expect trigger activated
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'trigger_activated',
|
||||||
|
triggerId: trapId,
|
||||||
|
activatorId: playerId
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Expect damage (magnitude 2)
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'damage',
|
||||||
|
amount: 2
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Expect status applied
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'status_applied',
|
||||||
|
status: 'poison'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/engine/__tests__/TriggerRepro.test.ts
Normal file
55
src/engine/__tests__/TriggerRepro.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { TriggerSystem } from '../ecs/systems/TriggerSystem';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { EventBus } from '../ecs/EventBus';
|
||||||
|
import type { EntityId } from '../../core/types';
|
||||||
|
|
||||||
|
describe('TriggerSystem Integration', () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
let eventBus: EventBus;
|
||||||
|
let system: TriggerSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
eventBus = new EventBus();
|
||||||
|
system = new TriggerSystem();
|
||||||
|
system.setEventBus(eventBus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onEnter when player moves onto trap', () => {
|
||||||
|
// Setup Player (ID 1)
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
const playerPos = { x: 1, y: 1 };
|
||||||
|
world.addComponent(playerId, 'position', playerPos);
|
||||||
|
world.addComponent(playerId, 'player', {});
|
||||||
|
|
||||||
|
// Setup Trap (ID 100) at (2, 1)
|
||||||
|
const trapId = 100 as EntityId;
|
||||||
|
world.addComponent(trapId, 'position', { x: 2, y: 1 });
|
||||||
|
world.addComponent(trapId, 'trigger', {
|
||||||
|
onEnter: true,
|
||||||
|
damage: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register system (initializes entity positions)
|
||||||
|
system.onRegister(world);
|
||||||
|
|
||||||
|
// Verify initial state: Player at (1,1), Trap at (2,1)
|
||||||
|
// System tracking: Player at (1,1)
|
||||||
|
const spy = vi.spyOn(eventBus, 'emit');
|
||||||
|
|
||||||
|
// === MOVE PLAYER ===
|
||||||
|
// Simulate MovementSystem update
|
||||||
|
playerPos.x = 2; // Move to (2,1) directly (reference update)
|
||||||
|
|
||||||
|
// System Update
|
||||||
|
system.update([trapId], world);
|
||||||
|
|
||||||
|
// Expect trigger activation
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'trigger_activated',
|
||||||
|
triggerId: trapId,
|
||||||
|
activatorId: playerId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||||
import { EntityManager } from '../EntityManager';
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
const createTestWorld = (): World => {
|
||||||
return {
|
return {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(TileType.EMPTY),
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
actors,
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -23,7 +22,37 @@ const createTestStats = (overrides: Partial<any> = {}) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('AI Behavior & Scheduling', () => {
|
describe('AI Behavior & Scheduling', () => {
|
||||||
let entityManager: EntityManager;
|
let accessor: EntityAccessor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncToECS = (actors: Map<EntityId, Actor>) => {
|
||||||
|
let maxId = 0;
|
||||||
|
for (const actor of actors.values()) {
|
||||||
|
if (actor.id > maxId) maxId = actor.id;
|
||||||
|
ecsWorld.addComponent(actor.id, "position", actor.pos);
|
||||||
|
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
|
||||||
|
if (actor.category === "combatant") {
|
||||||
|
const c = actor as CombatantActor;
|
||||||
|
ecsWorld.addComponent(actor.id, "stats", c.stats || createTestStats());
|
||||||
|
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 });
|
||||||
|
ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" });
|
||||||
|
if (c.isPlayer) {
|
||||||
|
ecsWorld.addComponent(actor.id, "player", {});
|
||||||
|
} else {
|
||||||
|
ecsWorld.addComponent(actor.id, "ai", {
|
||||||
|
state: c.aiState || "wandering",
|
||||||
|
alertedAt: c.alertedAt,
|
||||||
|
lastKnownPlayerPos: c.lastKnownPlayerPos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ecsWorld.setNextId(maxId + 1);
|
||||||
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Scheduling Fairness
|
// Scheduling Fairness
|
||||||
@@ -33,37 +62,34 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
// Player Speed 100
|
// Player Speed 100
|
||||||
const player = {
|
const player = {
|
||||||
id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
|
id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
|
||||||
speed: 100, stats: createTestStats(), energy: 0
|
speed: 100, stats: createTestStats(), energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
// Rat Speed 80 (Slow)
|
// Rat Speed 80 (Slow)
|
||||||
const rat = {
|
const rat = {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
|
||||||
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
|
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, rat);
|
actors.set(2 as EntityId, rat);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
entityManager = new EntityManager(world);
|
syncToECS(actors);
|
||||||
|
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
let ratMoves = 0;
|
let ratMoves = 0;
|
||||||
|
|
||||||
// Simulate 20 player turns
|
// Simulate 20 player turns
|
||||||
// With fair scheduling, Rat (80 speed) should move approx 80% as often as Player (100 speed).
|
|
||||||
// So in 20 turns, approx 16 moves. Definitley > 0.
|
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
const result = stepUntilPlayerTurn(world, 1, entityManager);
|
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
|
||||||
const enemyActs = result.events.filter(e =>
|
const enemyActs = result.events.filter(e =>
|
||||||
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
|
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
|
||||||
((e as any).actorId === 2 || (e as any).enemyId === 2)
|
((e as any).actorId === 2 || (e as any).enemyId === 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log(`Turn ${i}: Events`, result.events);
|
|
||||||
if (enemyActs.length > 0) ratMoves++;
|
if (enemyActs.length > 0) ratMoves++;
|
||||||
}
|
}
|
||||||
// console.log(`Total Rat Moves: ${ratMoves}`);
|
|
||||||
expect(ratMoves).toBeGreaterThan(0);
|
expect(ratMoves).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -81,19 +107,22 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
terrainTypes.forEach(({ type, name }) => {
|
terrainTypes.forEach(({ type, name }) => {
|
||||||
it(`should see player when standing on ${name}`, () => {
|
it(`should see player when standing on ${name}`, () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
|
actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
|
||||||
actors.set(2, {
|
actors.set(2 as EntityId, {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
|
||||||
stats: createTestStats(), aiState: "wandering", energy: 0
|
stats: createTestStats(), aiState: "wandering", energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
world.tiles[0] = type;
|
world.tiles[0] = type;
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
// Rat at 0,0. Player at 5,0.
|
// Rat at 0,0. Player at 5,0.
|
||||||
decideEnemyAction(world, actors.get(2) as any, actors.get(1) as any, new EntityManager(world));
|
decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor);
|
||||||
|
|
||||||
expect((actors.get(2) as CombatantActor).aiState).toBe("alerted");
|
const updatedRat = testAccessor.getCombatant(2 as EntityId);
|
||||||
|
expect(updatedRat?.aiState).toBe("alerted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -105,29 +134,30 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
it('should become pursuing when damaged by player, even if not sighting player', () => {
|
it('should become pursuing when damaged by player, even if not sighting player', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
// Player far away/invisible (simulated logic)
|
// Player far away/invisible (simulated logic)
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
|
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
|
||||||
const enemy = {
|
const enemy = {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
|
||||||
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
|
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const em = new EntityManager(world);
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
applyAction(world, 1, { type: "attack", targetId: 2 }, em);
|
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor);
|
||||||
|
|
||||||
const updatedEnemy = actors.get(2) as CombatantActor;
|
const updatedEnemy = testAccessor.getCombatant(2 as EntityId);
|
||||||
expect(updatedEnemy.aiState).toBe("pursuing");
|
expect(updatedEnemy?.aiState).toBe("pursuing");
|
||||||
expect(updatedEnemy.lastKnownPlayerPos).toEqual(player.pos);
|
expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
|
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
|
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
const enemy = {
|
const enemy = {
|
||||||
id: 2,
|
id: 2 as EntityId,
|
||||||
category: "combatant",
|
category: "combatant",
|
||||||
isPlayer: false,
|
isPlayer: false,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
@@ -138,17 +168,20 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
energy: 0
|
energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
|
||||||
// Player is far away and potentially blocked
|
// Player is far away and potentially blocked
|
||||||
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
|
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
const rat = testAccessor.getCombatant(2 as EntityId)!;
|
||||||
|
decideEnemyAction(world, rat, testAccessor.getPlayer()!, testAccessor);
|
||||||
|
|
||||||
// alerted -> pursuing (due to time) -> searching (due to no sight)
|
// alerted -> pursuing (due to time) -> searching (due to no sight)
|
||||||
expect(enemy.aiState).toBe("searching");
|
expect(rat.aiState).toBe("searching");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { getClosestVisibleEnemy } from "../gameplay/CombatLogic";
|
import { getClosestVisibleEnemy } from "../gameplay/CombatLogic";
|
||||||
import type { World, CombatantActor } from "../../core/types";
|
import type { World, CombatantActor, Actor, EntityId } from "../../core/types";
|
||||||
|
import { EntityAccessor } from "../EntityAccessor";
|
||||||
|
import { ECSWorld } from "../ecs/World";
|
||||||
|
|
||||||
describe("CombatLogic - getClosestVisibleEnemy", () => {
|
describe("CombatLogic - getClosestVisibleEnemy", () => {
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to create valid default stats for testing
|
// Helper to create valid default stats for testing
|
||||||
const createMockStats = () => ({
|
const createMockStats = () => ({
|
||||||
@@ -21,29 +28,40 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player: CombatantActor = {
|
const player: CombatantActor = {
|
||||||
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
inventory: { gold: 0, items: [] }, equipment: {},
|
inventory: { gold: 0, items: [] }, equipment: {},
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
actors.set(0 as EntityId, player);
|
||||||
|
|
||||||
const enemy: CombatantActor = {
|
const enemy: CombatantActor = {
|
||||||
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
|
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(1, enemy);
|
actors.set(1 as EntityId, enemy);
|
||||||
|
|
||||||
|
for (const a of actors.values()) {
|
||||||
|
ecsWorld.addComponent(a.id, "position", a.pos);
|
||||||
|
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
|
||||||
|
if (a.category === "combatant") {
|
||||||
|
ecsWorld.addComponent(a.id, "stats", a.stats);
|
||||||
|
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Mock seenArray where nothing is seen
|
// Mock seenArray where nothing is seen
|
||||||
const seenArray = new Uint8Array(100).fill(0);
|
const seenArray = new Uint8Array(100).fill(0);
|
||||||
|
|
||||||
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
|
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,17 +70,17 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player: CombatantActor = {
|
const player: CombatantActor = {
|
||||||
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
inventory: { gold: 0, items: [] }, equipment: {},
|
inventory: { gold: 0, items: [] }, equipment: {},
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
actors.set(0 as EntityId, player);
|
||||||
|
|
||||||
// Enemy 1: Close (distance sqrt(2) ~= 1.41)
|
// Enemy 1: Close (distance sqrt(2) ~= 1.41)
|
||||||
const enemy1: CombatantActor = {
|
const enemy1: CombatantActor = {
|
||||||
@@ -70,7 +88,7 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(1, enemy1);
|
actors.set(1 as EntityId, enemy1);
|
||||||
|
|
||||||
// Enemy 2: Farther (distance sqrt(8) ~= 2.82)
|
// Enemy 2: Farther (distance sqrt(8) ~= 2.82)
|
||||||
const enemy2: CombatantActor = {
|
const enemy2: CombatantActor = {
|
||||||
@@ -78,14 +96,25 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(2, enemy2);
|
actors.set(2 as EntityId, enemy2);
|
||||||
|
|
||||||
|
for (const a of actors.values()) {
|
||||||
|
ecsWorld.addComponent(a.id, "position", a.pos);
|
||||||
|
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
|
||||||
|
if (a.category === "combatant") {
|
||||||
|
ecsWorld.addComponent(a.id, "stats", a.stats);
|
||||||
|
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Mock seenArray where both are seen
|
// Mock seenArray where both are seen
|
||||||
const seenArray = new Uint8Array(100).fill(0);
|
const seenArray = new Uint8Array(100).fill(0);
|
||||||
seenArray[6 * 10 + 6] = 1; // Enemy 1 visible
|
seenArray[6 * 10 + 6] = 1; // Enemy 1 visible
|
||||||
seenArray[7 * 10 + 7] = 1; // Enemy 2 visible
|
seenArray[7 * 10 + 7] = 1; // Enemy 2 visible
|
||||||
|
|
||||||
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
|
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
|
||||||
expect(result).toEqual({ x: 6, y: 6 });
|
expect(result).toEqual({ x: 6, y: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,17 +123,17 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player: CombatantActor = {
|
const player: CombatantActor = {
|
||||||
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
inventory: { gold: 0, items: [] }, equipment: {},
|
inventory: { gold: 0, items: [] }, equipment: {},
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
actors.set(0 as EntityId, player);
|
||||||
|
|
||||||
// Enemy 1: Close but invisible
|
// Enemy 1: Close but invisible
|
||||||
const enemy1: CombatantActor = {
|
const enemy1: CombatantActor = {
|
||||||
@@ -112,7 +141,7 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(1, enemy1);
|
actors.set(1 as EntityId, enemy1);
|
||||||
|
|
||||||
// Enemy 2: Farther but visible
|
// Enemy 2: Farther but visible
|
||||||
const enemy2: CombatantActor = {
|
const enemy2: CombatantActor = {
|
||||||
@@ -120,13 +149,24 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(2, enemy2);
|
actors.set(2 as EntityId, enemy2);
|
||||||
|
|
||||||
|
for (const a of actors.values()) {
|
||||||
|
ecsWorld.addComponent(a.id, "position", a.pos);
|
||||||
|
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
|
||||||
|
if (a.category === "combatant") {
|
||||||
|
ecsWorld.addComponent(a.id, "stats", a.stats);
|
||||||
|
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Mock seenArray where only Enemy 2 is seen
|
// Mock seenArray where only Enemy 2 is seen
|
||||||
const seenArray = new Uint8Array(100).fill(0);
|
const seenArray = new Uint8Array(100).fill(0);
|
||||||
seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5)
|
seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5)
|
||||||
|
|
||||||
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
|
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
|
||||||
expect(result).toEqual({ x: 8, y: 5 });
|
expect(result).toEqual({ x: 8, y: 5 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { generateWorld } from '../world/generator';
|
import { generateWorld } from '../world/generator';
|
||||||
import { isWall, inBounds } from '../world/world-logic';
|
import { isWall, inBounds } from '../world/world-logic';
|
||||||
import { type CombatantActor } from '../../core/types';
|
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
import * as ROT from 'rot-js';
|
import * as ROT from 'rot-js';
|
||||||
|
|
||||||
describe('World Generator', () => {
|
describe('World Generator', () => {
|
||||||
@@ -36,14 +38,17 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
|
||||||
expect(playerId).toBe(1);
|
expect(playerId).toBeGreaterThan(0);
|
||||||
const player = world.actors.get(playerId) as CombatantActor;
|
const player = accessor.getPlayer();
|
||||||
expect(player).toBeDefined();
|
expect(player).toBeDefined();
|
||||||
expect(player.category).toBe("combatant");
|
expect(player?.category).toBe("combatant");
|
||||||
expect(player.isPlayer).toBe(true);
|
expect(player?.isPlayer).toBe(true);
|
||||||
expect(player.stats).toEqual(runState.stats);
|
// We expect the stats to be the same, but they are proxies now
|
||||||
|
expect(player?.stats.hp).toEqual(runState.stats.hp);
|
||||||
|
expect(player?.stats.attack).toEqual(runState.stats.attack);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create walkable rooms', () => {
|
it('should create walkable rooms', () => {
|
||||||
@@ -57,8 +62,9 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
|
|
||||||
// Player should spawn in a walkable area
|
// Player should spawn in a walkable area
|
||||||
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
|
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
|
||||||
@@ -93,13 +99,10 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
|
||||||
// Should have player + enemies
|
const enemies = accessor.getEnemies();
|
||||||
expect(world.actors.size).toBeGreaterThan(1);
|
|
||||||
|
|
||||||
// All non-player actors should be enemies
|
|
||||||
const enemies = Array.from(world.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
|
||||||
expect(enemies.length).toBeGreaterThan(0);
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Enemies should have stats
|
// Enemies should have stats
|
||||||
@@ -121,15 +124,18 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world: world1, playerId: player1 } = generateWorld(1, runState);
|
const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState);
|
||||||
const { world: world2, playerId: player2 } = generateWorld(1, runState);
|
const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState);
|
||||||
|
|
||||||
// Same level should generate identical layouts
|
// Same level should generate identical layouts
|
||||||
expect(world1.tiles).toEqual(world2.tiles);
|
expect(world1.tiles).toEqual(world2.tiles);
|
||||||
expect(world1.exit).toEqual(world2.exit);
|
expect(world1.exit).toEqual(world2.exit);
|
||||||
|
|
||||||
const player1Pos = world1.actors.get(player1)!.pos;
|
const accessor1 = new EntityAccessor(world1, player1, ecs1);
|
||||||
const player2Pos = world2.actors.get(player2)!.pos;
|
const accessor2 = new EntityAccessor(world2, player2, ecs2);
|
||||||
|
|
||||||
|
const player1Pos = accessor1.getPlayer()!.pos;
|
||||||
|
const player2Pos = accessor2.getPlayer()!.pos;
|
||||||
expect(player1Pos).toEqual(player2Pos);
|
expect(player1Pos).toEqual(player2Pos);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,11 +168,14 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world: world1 } = generateWorld(1, runState);
|
const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState);
|
||||||
const { world: world5 } = generateWorld(5, runState);
|
const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState);
|
||||||
|
|
||||||
const enemies1 = Array.from(world1.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
const accessor1 = new EntityAccessor(world1, p1, ecs1);
|
||||||
const enemies5 = Array.from(world5.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
const accessor5 = new EntityAccessor(world5, p5, ecs5);
|
||||||
|
|
||||||
|
const enemies1 = accessor1.getEnemies();
|
||||||
|
const enemies5 = accessor5.getEnemies();
|
||||||
|
|
||||||
// Higher level should have more enemies
|
// Higher level should have more enemies
|
||||||
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
||||||
@@ -213,8 +222,9 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
// Generate multiple worlds to stress test spawn placement
|
// Generate multiple worlds to stress test spawn placement
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
|
|
||||||
// Check tile under player
|
// Check tile under player
|
||||||
const tileIdx = player.pos.y * world.width + player.pos.x;
|
const tileIdx = player.pos.y * world.width + player.pos.x;
|
||||||
@@ -259,8 +269,9 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(11, runState);
|
const { world, playerId, ecsWorld } = generateWorld(11, runState);
|
||||||
const enemies = Array.from(world.actors.values()).filter(a => a.category === 'combatant' && !a.isPlayer);
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const enemies = accessor.getEnemies();
|
||||||
expect(enemies.length).toBeGreaterThan(0);
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,8 +287,9 @@ describe('World Generator', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const { world, playerId } = generateWorld(10 + i, runState);
|
const { world, playerId, ecsWorld } = generateWorld(10 + i, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
const exit = world.exit;
|
const exit = world.exit;
|
||||||
|
|
||||||
const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => {
|
const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => {
|
||||||
@@ -304,8 +316,9 @@ describe('World Generator', () => {
|
|||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
const { world, playerId } = generateWorld(12, runState);
|
const { world, playerId, ecsWorld } = generateWorld(12, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
|
|
||||||
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
|
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { ItemManager } from "../../scenes/systems/ItemManager";
|
import { ItemManager } from "../../scenes/systems/ItemManager";
|
||||||
import type { World, CombatantActor, Item } from "../../core/types";
|
import type { World, CombatantActor, Item, EntityId } from "../../core/types";
|
||||||
import { EntityManager } from "../../engine/EntityManager";
|
import { EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
import { ECSWorld } from "../../engine/ecs/World";
|
||||||
|
|
||||||
describe("ItemManager - Stacking Logic", () => {
|
describe("ItemManager - Stacking Logic", () => {
|
||||||
let itemManager: ItemManager;
|
let itemManager: ItemManager;
|
||||||
let entityManager: EntityManager;
|
let accessor: EntityAccessor;
|
||||||
let world: World;
|
let world: World;
|
||||||
let player: CombatantActor;
|
let player: CombatantActor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
world = {
|
world = {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: [],
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
entityManager = new EntityManager(world);
|
ecsWorld = new ECSWorld();
|
||||||
itemManager = new ItemManager(world, entityManager);
|
accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
itemManager = new ItemManager(world, accessor, ecsWorld);
|
||||||
|
|
||||||
player = {
|
player = {
|
||||||
id: 0,
|
id: 0 as EntityId,
|
||||||
pos: { x: 1, y: 1 },
|
pos: { x: 1, y: 1 },
|
||||||
category: "combatant",
|
category: "combatant",
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
type: "player",
|
type: "player",
|
||||||
inventory: { gold: 0, items: [] },
|
inventory: { gold: 0, items: [] },
|
||||||
stats: {} as any,
|
stats: { hp: 10, maxHp: 10 } as any,
|
||||||
equipment: {} as any,
|
equipment: {} as any,
|
||||||
speed: 1,
|
speed: 100,
|
||||||
energy: 0
|
energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
|
||||||
|
// Sync player to ECS
|
||||||
|
ecsWorld.addComponent(player.id, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(player.id, "player", {});
|
||||||
|
ecsWorld.addComponent(player.id, "stats", player.stats);
|
||||||
|
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
|
||||||
|
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should stack stackable items when picked up", () => {
|
it("should stack stackable items when picked up", () => {
|
||||||
@@ -47,25 +57,27 @@ describe("ItemManager - Stacking Logic", () => {
|
|||||||
quantity: 1
|
quantity: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
// First potion
|
// First potion
|
||||||
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
expect(player.inventory!.items[0].quantity).toBe(1);
|
expect(playerActor.inventory!.items[0].quantity).toBe(1);
|
||||||
|
|
||||||
// Second potion
|
// Second potion
|
||||||
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
expect(player.inventory!.items[0].quantity).toBe(2);
|
expect(playerActor.inventory!.items[0].quantity).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT stack non-stackable items", () => {
|
it("should NOT stack non-stackable items", () => {
|
||||||
const sword: Item = {
|
const sword: Item = {
|
||||||
id: "sword",
|
id: "iron_sword",
|
||||||
name: "Sword",
|
name: "Iron Sword",
|
||||||
type: "Weapon",
|
type: "Weapon",
|
||||||
weaponType: "melee",
|
weaponType: "melee",
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
@@ -74,40 +86,44 @@ describe("ItemManager - Stacking Logic", () => {
|
|||||||
stats: { attack: 1 }
|
stats: { attack: 1 }
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
// First sword
|
// First sword
|
||||||
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
|
|
||||||
// Second sword
|
// Second sword
|
||||||
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(2);
|
expect(playerActor.inventory!.items.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sum quantities of stackable items correctly", () => {
|
it("should sum quantities of stackable items correctly", () => {
|
||||||
const ammo: Item = {
|
const ammo: Item = {
|
||||||
id: "ammo",
|
id: "9mm_ammo",
|
||||||
name: "Ammo",
|
name: "9mm Ammo",
|
||||||
type: "Ammo",
|
type: "Ammo",
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
spriteIndex: 2,
|
spriteIndex: 2,
|
||||||
stackable: true,
|
stackable: true,
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
ammoType: "9mm"
|
ammoType: "9mm"
|
||||||
};
|
} as any;
|
||||||
|
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
itemManager.spawnItem(ammo, { x: 1, y: 1 });
|
itemManager.spawnItem(ammo, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items[0].quantity).toBe(10);
|
expect(playerActor.inventory!.items[0].quantity).toBe(10);
|
||||||
|
|
||||||
const moreAmmo = { ...ammo, quantity: 5 };
|
const moreAmmo = { ...ammo, quantity: 5 };
|
||||||
itemManager.spawnItem(moreAmmo, { x: 1, y: 1 });
|
itemManager.spawnItem(moreAmmo, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items[0].quantity).toBe(15);
|
expect(playerActor.inventory!.items[0].quantity).toBe(15);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { findPathAStar } from '../world/pathfinding';
|
import { findPathAStar } from '../world/pathfinding';
|
||||||
import { type World } from '../../core/types';
|
import type { World, EntityId } from '../../core/types';
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
|
|
||||||
describe('Pathfinding', () => {
|
describe('Pathfinding', () => {
|
||||||
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
|
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles: new Array(width * height).fill(tileType),
|
tiles: new Array(width * height).fill(tileType),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 0, y: 0 }
|
exit: { x: 0, y: 0 }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,23 +49,22 @@ describe('Pathfinding', () => {
|
|||||||
|
|
||||||
it('should respect ignoreBlockedTarget option', () => {
|
it('should respect ignoreBlockedTarget option', () => {
|
||||||
const world = createTestWorld(10, 10);
|
const world = createTestWorld(10, 10);
|
||||||
|
const ecsWorld = new ECSWorld();
|
||||||
|
|
||||||
// Place an actor at target
|
// Place an actor at target
|
||||||
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat', category: 'combatant' } as any);
|
ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 });
|
||||||
|
ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" });
|
||||||
|
ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any);
|
||||||
|
|
||||||
const seen = new Uint8Array(100).fill(1);
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Without option, it should be blocked (because actor is there)
|
// With accessor, it should be blocked
|
||||||
// Wait, default pathfinding might treat actors as blocking unless specified.
|
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor });
|
||||||
// Let's check `isBlocked` usage in `pathfinding.ts`.
|
|
||||||
// It calls `isBlocked` which checks actors.
|
|
||||||
|
|
||||||
// However, findPathAStar has logic:
|
|
||||||
// if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
|
||||||
|
|
||||||
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
|
||||||
expect(pathBlocked).toEqual([]);
|
expect(pathBlocked).toEqual([]);
|
||||||
|
|
||||||
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true });
|
// With ignoreBlockedTarget, it should succeed
|
||||||
|
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true, accessor });
|
||||||
expect(pathIgnored.length).toBeGreaterThan(0);
|
expect(pathIgnored.length).toBeGreaterThan(0);
|
||||||
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||||
import { EntityManager } from '../EntityManager';
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
const createTestWorld = (): World => {
|
||||||
return {
|
return {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors,
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -21,14 +22,45 @@ const createTestStats = (overrides: Partial<any> = {}) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Combat Simulation', () => {
|
describe('Combat Simulation', () => {
|
||||||
let entityManager: EntityManager;
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncToECS = (actors: Map<EntityId, Actor>) => {
|
||||||
|
let maxId = 0;
|
||||||
|
for (const actor of actors.values()) {
|
||||||
|
if (actor.id > maxId) maxId = actor.id;
|
||||||
|
ecsWorld.addComponent(actor.id, "position", actor.pos);
|
||||||
|
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
|
||||||
|
if (actor.category === "combatant") {
|
||||||
|
const c = actor as CombatantActor;
|
||||||
|
ecsWorld.addComponent(actor.id, "stats", c.stats || createTestStats());
|
||||||
|
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 });
|
||||||
|
ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" });
|
||||||
|
if (c.isPlayer) {
|
||||||
|
ecsWorld.addComponent(actor.id, "player", {});
|
||||||
|
} else {
|
||||||
|
ecsWorld.addComponent(actor.id, "ai", {
|
||||||
|
state: c.aiState || "wandering",
|
||||||
|
alertedAt: c.alertedAt,
|
||||||
|
lastKnownPlayerPos: c.lastKnownPlayerPos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (actor.category === "collectible") {
|
||||||
|
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: actor.expAmount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ecsWorld.setNextId(maxId + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
describe('applyAction', () => {
|
describe('applyAction', () => {
|
||||||
it('should return empty events if actor does not exist', () => {
|
it('should return empty events if actor does not exist', () => {
|
||||||
const world = createTestWorld(new Map());
|
const world = createTestWorld();
|
||||||
const events = applyAction(world, 999, { type: "wait" });
|
const events = applyAction(world, 999 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
expect(events).toEqual([]);
|
expect(events).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -36,60 +68,63 @@ describe('Combat Simulation', () => {
|
|||||||
describe('applyAction - success paths', () => {
|
describe('applyAction - success paths', () => {
|
||||||
it('should deal damage when player attacks enemy', () => {
|
it('should deal damage when player attacks enemy', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
actors.set(1 as EntityId, {
|
||||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
actors.set(2, {
|
actors.set(2 as EntityId, {
|
||||||
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0
|
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
entityManager = new EntityManager(world);
|
syncToECS(actors);
|
||||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor);
|
||||||
|
|
||||||
const enemy = world.actors.get(2) as CombatantActor;
|
const enemy = accessor.getCombatant(2 as EntityId);
|
||||||
expect(enemy.stats.hp).toBeLessThan(10);
|
expect(enemy?.stats.hp).toBeLessThan(10);
|
||||||
expect(events.some(e => e.type === "attacked")).toBe(true);
|
expect(events.some(e => e.type === "attacked")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
actors.set(1 as EntityId, {
|
||||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }), energy: 0
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }), energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
actors.set(2, {
|
actors.set(2 as EntityId, {
|
||||||
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0
|
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
entityManager = new EntityManager(world);
|
syncToECS(actors);
|
||||||
applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor);
|
||||||
|
|
||||||
// Enemy (id 2) should be gone
|
// Enemy (id 2) should be gone
|
||||||
expect(world.actors.has(2)).toBe(false);
|
expect(accessor.hasActor(2 as EntityId)).toBe(false);
|
||||||
|
|
||||||
// A new ID should be generated for the orb (should be 3)
|
// A new ID should be generated for the orb (should be 3)
|
||||||
const orb = [...world.actors.values()].find(a => a.type === "exp_orb");
|
const orb = accessor.getCollectibles().find(a => a.type === "exp_orb");
|
||||||
expect(orb).toBeDefined();
|
expect(orb).toBeDefined();
|
||||||
expect(orb!.id).toBe(3);
|
expect(orb!.id).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should destruction tile when walking on destructible-by-walk tile", () => {
|
it("should destruction tile when walking on destructible-by-walk tile", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
actors.set(1 as EntityId, {
|
||||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
// tile at 4,3 is grass (15) which is destructible by walk
|
// tile at 4,3 is grass (15) which is destructible by walk
|
||||||
const grassIdx = 3 * 10 + 4;
|
const grassIdx = 3 * 10 + 4;
|
||||||
world.tiles[grassIdx] = 15; // TileType.GRASS
|
world.tiles[grassIdx] = 15; // TileType.GRASS
|
||||||
|
|
||||||
entityManager = new EntityManager(world);
|
syncToECS(actors);
|
||||||
applyAction(world, 1, { type: "move", dx: 1, dy: 0 }, entityManager);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, accessor);
|
||||||
|
|
||||||
// Player moved to 4,3
|
// Player moved to 4,3
|
||||||
const player = world.actors.get(1);
|
const player = accessor.getActor(1 as EntityId);
|
||||||
expect(player!.pos).toEqual({ x: 4, y: 3 });
|
expect(player!.pos).toEqual({ x: 4, y: 3 });
|
||||||
|
|
||||||
// Tile should effectively be destroyed (turned to saplings/2)
|
// Tile should effectively be destroyed (turned to saplings/2)
|
||||||
@@ -98,28 +133,30 @@ describe('Combat Simulation', () => {
|
|||||||
|
|
||||||
it("should handle wait action", () => {
|
it("should handle wait action", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
|
actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
const events = applyAction(world, 1, { type: "wait" }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
|
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should default to wait for unknown action type", () => {
|
it("should default to wait for unknown action type", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
|
actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const events = applyAction(world, 1, { type: "unknown_hack" } as any, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "unknown_hack" } as any, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
|
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT emit wait event for throw action", () => {
|
it("should NOT emit wait event for throw action", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
|
actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const events = applyAction(world, 1, { type: "throw" }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "throw" }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
expect(events).toEqual([]);
|
expect(events).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -129,14 +166,15 @@ describe('Combat Simulation', () => {
|
|||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats(), energy: 0 } as any;
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats(), energy: 0 } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
world.tiles[3 * 10 + 4] = 4; // Wall
|
world.tiles[3 * 10 + 4] = 4; // Wall
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
entityManager = new EntityManager(world);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, accessor);
|
||||||
|
|
||||||
expect(decision.action.type).toBe("move");
|
expect(decision.action.type).toBe("move");
|
||||||
});
|
});
|
||||||
@@ -154,13 +192,14 @@ describe('Combat Simulation', () => {
|
|||||||
aiState: "pursuing",
|
aiState: "pursuing",
|
||||||
lastKnownPlayerPos: { x: 4, y: 3 }
|
lastKnownPlayerPos: { x: 4, y: 3 }
|
||||||
} as any;
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
entityManager = new EntityManager(world);
|
syncToECS(actors);
|
||||||
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, accessor);
|
||||||
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,13 +215,15 @@ describe('Combat Simulation', () => {
|
|||||||
aiState: "wandering",
|
aiState: "wandering",
|
||||||
energy: 0
|
energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(enemy.aiState).toBe("alerted");
|
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
|
||||||
|
expect(updatedEnemy?.state).toBe("alerted");
|
||||||
expect(decision.justAlerted).toBe(true);
|
expect(decision.justAlerted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,14 +240,16 @@ describe('Combat Simulation', () => {
|
|||||||
aiState: "pursuing", // Currently pursuing
|
aiState: "pursuing", // Currently pursuing
|
||||||
lastKnownPlayerPos: { x: 5, y: 5 }
|
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||||
} as any;
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Should switch to searching because can't see player
|
// Should switch to searching because can't see player
|
||||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(enemy.aiState).toBe("searching");
|
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
|
||||||
|
expect(updatedEnemy?.state).toBe("searching");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should transition from searching to alerted when sight regained", () => {
|
it("should transition from searching to alerted when sight regained", () => {
|
||||||
@@ -222,13 +265,15 @@ describe('Combat Simulation', () => {
|
|||||||
aiState: "searching",
|
aiState: "searching",
|
||||||
lastKnownPlayerPos: { x: 5, y: 5 }
|
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||||
} as any;
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(enemy.aiState).toBe("alerted");
|
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
|
||||||
|
expect(updatedEnemy?.state).toBe("alerted");
|
||||||
expect(decision.justAlerted).toBe(true);
|
expect(decision.justAlerted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,13 +291,15 @@ describe('Combat Simulation', () => {
|
|||||||
aiState: "searching",
|
aiState: "searching",
|
||||||
lastKnownPlayerPos: { x: 9, y: 9 }
|
lastKnownPlayerPos: { x: 9, y: 9 }
|
||||||
} as any;
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(enemy.aiState).toBe("wandering");
|
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
|
||||||
|
expect(updatedEnemy?.state).toBe("wandering");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,12 +310,13 @@ describe('Combat Simulation', () => {
|
|||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any;
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats(), energy: 0 } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats(), energy: 0 } as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
const em = new EntityManager(world);
|
syncToECS(actors);
|
||||||
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
const result = stepUntilPlayerTurn(world, 1, em);
|
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
|
||||||
|
|
||||||
// Enemy should have taken at least one action
|
// Enemy should have taken at least one action
|
||||||
expect(result.events.length).toBeGreaterThan(0);
|
expect(result.events.length).toBeGreaterThan(0);
|
||||||
@@ -290,15 +338,16 @@ describe('Combat Simulation', () => {
|
|||||||
energy: 100
|
energy: 100
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
const em = new EntityManager(world);
|
syncToECS(actors);
|
||||||
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
const result = stepUntilPlayerTurn(world, 1, em);
|
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
|
||||||
|
|
||||||
expect(world.actors.has(1)).toBe(false); // Player dead
|
expect(accessor.hasActor(1 as EntityId)).toBe(false); // Player dead
|
||||||
expect(result.events.some(e => e.type === "killed" && e.targetId === 1)).toBe(true);
|
expect(result.events.some((e: any) => e.type === "killed" && e.targetId === 1)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,17 +368,22 @@ describe('Combat Simulation', () => {
|
|||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, stats: createTestStats({ accuracy: 100 }), energy: 0 } as any;
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, stats: createTestStats({ accuracy: 100 }), energy: 0 } as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }), energy: 0 } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }), energy: 0 } as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51
|
// Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51
|
||||||
|
mockRandom.mockReturnValue(0.1); // Hit roll
|
||||||
|
// Wait, hitChance is Acc (100) - Eva (50) = 50.
|
||||||
|
// Roll 0.51 * 100 = 51. 51 > 50 -> Dodge.
|
||||||
mockRandom.mockReturnValue(0.51);
|
mockRandom.mockReturnValue(0.51);
|
||||||
|
|
||||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(events.some(e => e.type === "dodged")).toBe(true);
|
expect(events.some(e => e.type === "dodged")).toBe(true);
|
||||||
expect(enemy.stats.hp).toBe(10); // No damage
|
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "stats");
|
||||||
|
expect(updatedEnemy?.hp).toBe(10); // No damage
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should crit when roll < crit chance", () => {
|
it("should crit when roll < crit chance", () => {
|
||||||
@@ -343,9 +397,10 @@ describe('Combat Simulation', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 0, defense: 0, hp: 50 }), energy: 0 } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 0, defense: 0, hp: 50 }), energy: 0 } as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Mock random:
|
// Mock random:
|
||||||
// 1. Hit roll: 0.1 (Hit)
|
// 1. Hit roll: 0.1 (Hit)
|
||||||
@@ -353,7 +408,7 @@ describe('Combat Simulation', () => {
|
|||||||
// 3. Block roll: 0.9 (No block)
|
// 3. Block roll: 0.9 (No block)
|
||||||
mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9);
|
mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9);
|
||||||
|
|
||||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
// Damage = 10 * 2 = 20
|
// Damage = 10 * 2 = 20
|
||||||
const dmgEvent = events.find(e => e.type === "damaged") as any;
|
const dmgEvent = events.find(e => e.type === "damaged") as any;
|
||||||
@@ -373,9 +428,10 @@ describe('Combat Simulation', () => {
|
|||||||
stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0
|
stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Mock random:
|
// Mock random:
|
||||||
// 1. Hit roll: 0.1
|
// 1. Hit roll: 0.1
|
||||||
@@ -383,7 +439,7 @@ describe('Combat Simulation', () => {
|
|||||||
// 3. Block roll: 0.4 (Block, since < 0.5)
|
// 3. Block roll: 0.4 (Block, since < 0.5)
|
||||||
mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4);
|
mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4);
|
||||||
|
|
||||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
// Damage = 10 * 0.5 = 5
|
// Damage = 10 * 0.5 = 5
|
||||||
const dmgEvent = events.find(e => e.type === "damaged") as any;
|
const dmgEvent = events.find(e => e.type === "damaged") as any;
|
||||||
@@ -399,17 +455,19 @@ describe('Combat Simulation', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Standard hit
|
// Standard hit
|
||||||
mockRandom.mockReturnValue(0.1);
|
mockRandom.mockReturnValue(0.1);
|
||||||
|
|
||||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
// Damage 10. Heal 50% = 5. HP -> 15.
|
// Damage 10. Heal 50% = 5. HP -> 15.
|
||||||
expect(player.stats.hp).toBe(15);
|
const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats");
|
||||||
|
expect(updatedPlayer?.hp).toBe(15);
|
||||||
expect(events.some(e => e.type === "healed")).toBe(true);
|
expect(events.some(e => e.type === "healed")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -421,16 +479,18 @@ describe('Combat Simulation', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
mockRandom.mockReturnValue(0.1);
|
mockRandom.mockReturnValue(0.1);
|
||||||
|
|
||||||
applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
// Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20.
|
// Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20.
|
||||||
expect(player.stats.hp).toBe(20);
|
const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats");
|
||||||
|
expect(updatedPlayer?.hp).toBe(20);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,15 +506,17 @@ describe('Combat Simulation', () => {
|
|||||||
id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150
|
id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, orb);
|
actors.set(2 as EntityId, orb);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Move player onto orb
|
// Move player onto orb
|
||||||
const events = applyAction(world, 1, { type: "move", dx: 1, dy: 0 }, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(player.stats.level).toBe(2);
|
const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats");
|
||||||
expect(player.stats.exp).toBe(50); // 150 - 100 = 50
|
expect(updatedPlayer?.level).toBe(2);
|
||||||
|
expect(updatedPlayer?.exp).toBe(50); // 150 - 100 = 50
|
||||||
expect(events.some(e => e.type === "leveled-up")).toBe(true);
|
expect(events.some(e => e.type === "leveled-up")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -480,12 +542,13 @@ describe('Combat Simulation', () => {
|
|||||||
energy: 0
|
energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, enemy);
|
actors.set(1 as EntityId, enemy);
|
||||||
actors.set(2, player);
|
actors.set(2 as EntityId, player);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
// Enemy should decide to attack
|
// Enemy should decide to attack
|
||||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
expect(decision.action.type).toBe("attack");
|
expect(decision.action.type).toBe("attack");
|
||||||
if (decision.action.type === "attack") {
|
if (decision.action.type === "attack") {
|
||||||
@@ -498,12 +561,13 @@ describe('Combat Simulation', () => {
|
|||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any;
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any;
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const action: any = { type: "attack", targetId: 2 };
|
const action: any = { type: "attack", targetId: 2 };
|
||||||
const events = applyAction(world, 1, action, new EntityManager(world));
|
const events = applyAction(world, 1 as EntityId, action, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
|
|
||||||
const attackEvent = events.find(e => e.type === "attacked");
|
const attackEvent = events.find(e => e.type === "attacked");
|
||||||
expect(attackEvent).toBeDefined();
|
expect(attackEvent).toBeDefined();
|
||||||
@@ -524,11 +588,12 @@ describe('Combat Simulation', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any;
|
const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
|
|
||||||
actors.set(1, enemy);
|
actors.set(1 as EntityId, enemy);
|
||||||
actors.set(2, player);
|
actors.set(2 as EntityId, player);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
|
||||||
if (decision.action.type === "move") {
|
if (decision.action.type === "move") {
|
||||||
const { dx, dy } = decision.action;
|
const { dx, dy } = decision.action;
|
||||||
// Should be (0, 1) or cardinal, sum of abs should be 1
|
// Should be (0, 1) or cardinal, sum of abs should be 1
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { traceProjectile } from '../gameplay/CombatLogic';
|
import { traceProjectile } from '../gameplay/CombatLogic';
|
||||||
import { EntityManager } from '../EntityManager';
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
import { type World, type Actor, type EntityId } from '../../core/types';
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import type { World, EntityId } from '../../core/types';
|
||||||
|
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
const createTestWorld = (): World => {
|
||||||
return {
|
return {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0), // 0 = Floor
|
tiles: new Array(100).fill(0), // 0 = Floor
|
||||||
actors,
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Throwing Mechanics', () => {
|
describe('Throwing Mechanics', () => {
|
||||||
it('should land ON the wall currently (demonstrating the bug)', () => {
|
it('should land ON the wall currently (demonstrating the bug)', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const world = createTestWorld();
|
||||||
const world = createTestWorld(actors);
|
const ecsWorld = new ECSWorld();
|
||||||
const entityManager = new EntityManager(world);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Wall at (5, 0)
|
// Wall at (5, 0)
|
||||||
world.tiles[5] = 4; // Wall
|
world.tiles[5] = 4; // Wall
|
||||||
@@ -25,16 +26,16 @@ describe('Throwing Mechanics', () => {
|
|||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const target = { x: 5, y: 0 }; // Target the wall directly
|
const target = { x: 5, y: 0 }; // Target the wall directly
|
||||||
|
|
||||||
const result = traceProjectile(world, start, target, entityManager);
|
const result = traceProjectile(world, start, target, accessor);
|
||||||
|
|
||||||
// NEW BEHAVIOR: blockedPos is the tile BEFORE the wall (4, 0)
|
// NEW BEHAVIOR: blockedPos is the tile BEFORE the wall (4, 0)
|
||||||
expect(result.blockedPos).toEqual({ x: 4, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 4, y: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should land ON the wall when throwing PAST a wall (demonstrating the bug)', () => {
|
it('should land ON the wall when throwing PAST a wall (demonstrating the bug)', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const world = createTestWorld();
|
||||||
const world = createTestWorld(actors);
|
const ecsWorld = new ECSWorld();
|
||||||
const entityManager = new EntityManager(world);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Wall at (3, 0)
|
// Wall at (3, 0)
|
||||||
world.tiles[3] = 4; // Wall
|
world.tiles[3] = 4; // Wall
|
||||||
@@ -42,7 +43,7 @@ describe('Throwing Mechanics', () => {
|
|||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const target = { x: 5, y: 0 }; // Target past the wall
|
const target = { x: 5, y: 0 }; // Target past the wall
|
||||||
|
|
||||||
const result = traceProjectile(world, start, target, entityManager);
|
const result = traceProjectile(world, start, target, accessor);
|
||||||
|
|
||||||
// NEW BEHAVIOR: Hits the wall at 3,0, stops at 2,0
|
// NEW BEHAVIOR: Hits the wall at 3,0, stops at 2,0
|
||||||
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ describe('World Utilities', () => {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles,
|
tiles,
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 0, y: 0 }
|
exit: { x: 0, y: 0 }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,38 +80,37 @@ describe('World Utilities', () => {
|
|||||||
|
|
||||||
|
|
||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
const mockAccessor = { getActorsAt: () => [] } as any;
|
||||||
|
|
||||||
expect(isBlocked(world, 5, 5)).toBe(true);
|
expect(isBlocked(world, 5, 5, mockAccessor)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for actor positions', () => {
|
it('should return true for actor positions', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
world.actors.set(1, {
|
const mockAccessor = {
|
||||||
id: 1,
|
getActorsAt: (x: number, y: number) => {
|
||||||
category: "combatant",
|
if (x === 3 && y === 3) return [{ category: "combatant" }];
|
||||||
isPlayer: true,
|
return [];
|
||||||
type: "player",
|
}
|
||||||
pos: { x: 3, y: 3 },
|
} as any;
|
||||||
speed: 100,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any,
|
|
||||||
energy: 100
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isBlocked(world, 3, 3)).toBe(true);
|
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for empty floor tiles', () => {
|
it('should return false for empty floor tiles', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
|
const mockAccessor = { getActorsAt: () => [] } as any;
|
||||||
|
|
||||||
expect(isBlocked(world, 3, 3)).toBe(false);
|
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false);
|
||||||
expect(isBlocked(world, 7, 7)).toBe(false);
|
expect(isBlocked(world, 7, 7, mockAccessor)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for out of bounds', () => {
|
it('should return true for out of bounds', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
|
const mockAccessor = { getActorsAt: () => [] } as any;
|
||||||
|
|
||||||
expect(isBlocked(world, -1, 0)).toBe(true);
|
expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true);
|
||||||
expect(isBlocked(world, 10, 10)).toBe(true);
|
expect(isBlocked(world, 10, 10, mockAccessor)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('tryDestructTile', () => {
|
describe('tryDestructTile', () => {
|
||||||
@@ -148,32 +146,34 @@ describe('World Utilities', () => {
|
|||||||
it('should return true when player is on exit', () => {
|
it('should return true when player is on exit', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
world.exit = { x: 5, y: 5 };
|
world.exit = { x: 5, y: 5 };
|
||||||
world.actors.set(1, {
|
|
||||||
id: 1,
|
|
||||||
pos: { x: 5, y: 5 },
|
|
||||||
isPlayer: true
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, 1)).toBe(true);
|
const mockAccessor = {
|
||||||
|
getPlayer: () => ({ pos: { x: 5, y: 5 } })
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(isPlayerOnExit(world, mockAccessor)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when player is not on exit', () => {
|
it('should return false when player is not on exit', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
world.exit = { x: 5, y: 5 };
|
world.exit = { x: 5, y: 5 };
|
||||||
world.actors.set(1, {
|
|
||||||
id: 1,
|
|
||||||
pos: { x: 4, y: 4 },
|
|
||||||
isPlayer: true
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, 1)).toBe(false);
|
const mockAccessor = {
|
||||||
|
getPlayer: () => ({ pos: { x: 4, y: 4 } })
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(isPlayerOnExit(world, mockAccessor)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when player does not exist', () => {
|
it('should return false when player does not exist', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
world.exit = { x: 5, y: 5 };
|
world.exit = { x: 5, y: 5 };
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, 999)).toBe(false);
|
const mockAccessor = {
|
||||||
|
getPlayer: () => null
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(isPlayerOnExit(world, mockAccessor)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
|
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
import { findPathAStar } from "../world/pathfinding";
|
import { findPathAStar } from "../world/pathfinding";
|
||||||
import { isBlocked, inBounds } from "../world/world-logic";
|
import { isBlocked, inBounds } from "../world/world-logic";
|
||||||
import { blocksSight } from "../../core/terrain";
|
import { blocksSight } from "../../core/terrain";
|
||||||
@@ -9,12 +9,12 @@ import { FOV } from "rot-js";
|
|||||||
export class AISystem {
|
export class AISystem {
|
||||||
private ecsWorld: ECSWorld;
|
private ecsWorld: ECSWorld;
|
||||||
private gameWorld: GameWorld;
|
private gameWorld: GameWorld;
|
||||||
private em?: EntityManager;
|
private accessor: EntityAccessor;
|
||||||
|
|
||||||
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
|
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) {
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
this.gameWorld = gameWorld;
|
this.gameWorld = gameWorld;
|
||||||
this.em = em;
|
this.accessor = accessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
|
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
|
||||||
@@ -82,7 +82,11 @@ export class AISystem {
|
|||||||
|
|
||||||
// A* Pathfinding
|
// A* Pathfinding
|
||||||
const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1);
|
const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1);
|
||||||
const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em: this.em });
|
const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, {
|
||||||
|
ignoreBlockedTarget: true,
|
||||||
|
ignoreSeen: true,
|
||||||
|
accessor: this.accessor
|
||||||
|
});
|
||||||
|
|
||||||
if (path.length >= 2) {
|
if (path.length >= 2) {
|
||||||
const next = path[1];
|
const next = path[1];
|
||||||
@@ -111,7 +115,7 @@ export class AISystem {
|
|||||||
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
|
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
|
||||||
// Simple shuffle and try
|
// Simple shuffle and try
|
||||||
for (const dir of directions.sort(() => Math.random() - 0.5)) {
|
for (const dir of directions.sort(() => Math.random() - 0.5)) {
|
||||||
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.em)) {
|
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.accessor)) {
|
||||||
return { type: "move", ...dir };
|
return { type: "move", ...dir };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { type ComponentMap } from "./components";
|
import { type ComponentMap } from "./components";
|
||||||
import { type EntityId, type Stats, type EnemyAIState, type ActorType } from "../../core/types";
|
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item } from "../../core/types";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +89,22 @@ export class EntityBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add inventory component.
|
||||||
|
*/
|
||||||
|
withInventory(inventory: Inventory): this {
|
||||||
|
this.components.inventory = inventory;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add equipment component.
|
||||||
|
*/
|
||||||
|
withEquipment(equipment: Equipment): this {
|
||||||
|
this.components.equipment = equipment;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add AI component for enemy behavior.
|
* Add AI component for enemy behavior.
|
||||||
*/
|
*/
|
||||||
@@ -192,8 +208,8 @@ export class EntityBuilder {
|
|||||||
/**
|
/**
|
||||||
* Configure as an item on the ground.
|
* Configure as an item on the ground.
|
||||||
*/
|
*/
|
||||||
asGroundItem(itemId: string, quantity: number = 1): this {
|
asGroundItem(item: Item): this {
|
||||||
this.components.groundItem = { itemId, quantity };
|
this.components.groundItem = { item };
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { type World as GameWorld, type EntityId } from "../../core/types";
|
import { type World as GameWorld, type EntityId } from "../../core/types";
|
||||||
import { isBlocked } from "../world/world-logic";
|
import { isBlocked } from "../world/world-logic";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
|
||||||
export class MovementSystem {
|
export class MovementSystem {
|
||||||
private ecsWorld: ECSWorld;
|
private ecsWorld: ECSWorld;
|
||||||
private gameWorld: GameWorld;
|
private gameWorld: GameWorld;
|
||||||
private em?: EntityManager;
|
private accessor: EntityAccessor;
|
||||||
|
|
||||||
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
|
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) {
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
this.gameWorld = gameWorld;
|
this.gameWorld = gameWorld;
|
||||||
this.em = em;
|
this.accessor = accessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
move(entityId: EntityId, dx: number, dy: number): boolean {
|
move(entityId: EntityId, dx: number, dy: number): boolean {
|
||||||
@@ -21,18 +21,11 @@ export class MovementSystem {
|
|||||||
const nx = pos.x + dx;
|
const nx = pos.x + dx;
|
||||||
const ny = pos.y + dy;
|
const ny = pos.y + dy;
|
||||||
|
|
||||||
if (!isBlocked(this.gameWorld, nx, ny, this.em)) {
|
if (!isBlocked(this.gameWorld, nx, ny, this.accessor)) {
|
||||||
const oldPos = { ...pos };
|
|
||||||
|
|
||||||
// Update ECS Position
|
// Update ECS Position
|
||||||
pos.x = nx;
|
pos.x = nx;
|
||||||
pos.y = ny;
|
pos.y = ny;
|
||||||
|
|
||||||
// Update grid-based EntityManager if present
|
|
||||||
if (this.em) {
|
|
||||||
this.em.moveActor(entityId, oldPos, { x: nx, y: ny });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { EntityBuilder } from "./EntityBuilder";
|
import { EntityBuilder } from "./EntityBuilder";
|
||||||
import { type EntityId } from "../../core/types";
|
import { type EntityId, type Item } from "../../core/types";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,12 +176,12 @@ export const Prefabs = {
|
|||||||
/**
|
/**
|
||||||
* Create an item drop on the ground.
|
* Create an item drop on the ground.
|
||||||
*/
|
*/
|
||||||
itemDrop(world: ECSWorld, x: number, y: number, itemId: string, quantity: number = 1, spriteIndex: number = 0): EntityId {
|
itemDrop(world: ECSWorld, x: number, y: number, item: Item, spriteIndex: number = 0): EntityId {
|
||||||
return EntityBuilder.create(world)
|
return EntityBuilder.create(world)
|
||||||
.withPosition(x, y)
|
.withPosition(x, y)
|
||||||
.withName("Item")
|
.withName(item.name)
|
||||||
.withSprite("items", spriteIndex)
|
.withSprite("items", spriteIndex)
|
||||||
.asGroundItem(itemId, quantity)
|
.asGroundItem(item)
|
||||||
.build();
|
.build();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export class ECSWorld {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasEntity(id: EntityId): boolean {
|
||||||
|
return this.entities.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
destroyEntity(id: EntityId) {
|
destroyEntity(id: EntityId) {
|
||||||
this.entities.delete(id);
|
this.entities.delete(id);
|
||||||
for (const type in this.components) {
|
for (const type in this.components) {
|
||||||
@@ -20,6 +24,7 @@ export class ECSWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
|
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
|
||||||
|
this.entities.add(id); // Ensure entity is registered
|
||||||
if (!this.components[type]) {
|
if (!this.components[type]) {
|
||||||
this.components[type] = new Map();
|
this.components[type] = new Map();
|
||||||
}
|
}
|
||||||
@@ -71,4 +76,8 @@ export class ECSWorld {
|
|||||||
setNextId(id: number) {
|
setNextId(id: number) {
|
||||||
this.nextId = id;
|
this.nextId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentNextId(): number {
|
||||||
|
return this.nextId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/engine/ecs/__tests__/ECSRemoval.test.ts
Normal file
38
src/engine/ecs/__tests__/ECSRemoval.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ECSWorld } from '../World';
|
||||||
|
import { EntityAccessor } from '../../EntityAccessor';
|
||||||
|
import { EntityBuilder } from '../EntityBuilder';
|
||||||
|
import type { World as GameWorld, EntityId } from '../../../core/types';
|
||||||
|
|
||||||
|
describe('ECS Removal and Accessor', () => {
|
||||||
|
it('should not report destroyed entities in getAllActors', () => {
|
||||||
|
const ecsWorld = new ECSWorld();
|
||||||
|
const gameWorld: GameWorld = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
exit: { x: 0, y: 0 }
|
||||||
|
};
|
||||||
|
const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);
|
||||||
|
|
||||||
|
// Create Entity
|
||||||
|
const id = EntityBuilder.create(ecsWorld)
|
||||||
|
.asEnemy("rat")
|
||||||
|
.withPosition(5, 5)
|
||||||
|
.withStats({ hp: 10, maxHp: 10 } as any)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
let actors = [...accessor.getAllActors()];
|
||||||
|
expect(actors.length).toBe(1);
|
||||||
|
expect(actors[0].id).toBe(id);
|
||||||
|
|
||||||
|
// Destroy it
|
||||||
|
ecsWorld.destroyEntity(id);
|
||||||
|
|
||||||
|
// Verify it is gone
|
||||||
|
actors = [...accessor.getAllActors()];
|
||||||
|
expect(actors.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId } from "../../core/types";
|
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId, type Inventory, type Equipment, type Item } from "../../core/types";
|
||||||
|
|
||||||
export interface PositionComponent extends Vec2 {}
|
export interface PositionComponent extends Vec2 {}
|
||||||
|
|
||||||
@@ -98,10 +98,13 @@ export interface DestructibleComponent {
|
|||||||
* For items laying on the ground that can be picked up.
|
* For items laying on the ground that can be picked up.
|
||||||
*/
|
*/
|
||||||
export interface GroundItemComponent {
|
export interface GroundItemComponent {
|
||||||
itemId: string; // Reference to item definition
|
item: Item;
|
||||||
quantity: number; // Stack size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InventoryComponent extends Inventory {}
|
||||||
|
|
||||||
|
export interface EquipmentComponent extends Equipment {}
|
||||||
|
|
||||||
export type ComponentMap = {
|
export type ComponentMap = {
|
||||||
// Core components
|
// Core components
|
||||||
position: PositionComponent;
|
position: PositionComponent;
|
||||||
@@ -120,6 +123,8 @@ export type ComponentMap = {
|
|||||||
combat: CombatComponent;
|
combat: CombatComponent;
|
||||||
destructible: DestructibleComponent;
|
destructible: DestructibleComponent;
|
||||||
groundItem: GroundItemComponent;
|
groundItem: GroundItemComponent;
|
||||||
|
inventory: InventoryComponent;
|
||||||
|
equipment: EquipmentComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ComponentType = keyof ComponentMap;
|
export type ComponentType = keyof ComponentMap;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type World, type Vec2, type EntityId } from "../../core/types";
|
import { type World, type Vec2, type EntityId } from "../../core/types";
|
||||||
import { isBlocked } from "../world/world-logic";
|
import { isBlocked } from "../world/world-logic";
|
||||||
import { raycast } from "../../core/math";
|
import { raycast } from "../../core/math";
|
||||||
import { EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
|
||||||
export interface ProjectileResult {
|
export interface ProjectileResult {
|
||||||
path: Vec2[];
|
path: Vec2[];
|
||||||
@@ -16,7 +16,7 @@ export function traceProjectile(
|
|||||||
world: World,
|
world: World,
|
||||||
start: Vec2,
|
start: Vec2,
|
||||||
target: Vec2,
|
target: Vec2,
|
||||||
entityManager: EntityManager,
|
accessor: EntityAccessor | undefined,
|
||||||
shooterId?: EntityId
|
shooterId?: EntityId
|
||||||
): ProjectileResult {
|
): ProjectileResult {
|
||||||
const points = raycast(start.x, start.y, target.x, target.y);
|
const points = raycast(start.x, start.y, target.x, target.y);
|
||||||
@@ -28,9 +28,13 @@ export function traceProjectile(
|
|||||||
const p = points[i];
|
const p = points[i];
|
||||||
|
|
||||||
// Check for blocking
|
// Check for blocking
|
||||||
if (isBlocked(world, p.x, p.y, entityManager)) {
|
if (accessor && isBlocked(world, p.x, p.y, accessor)) {
|
||||||
// Check if we hit a combatant
|
// Check if we hit a combatant
|
||||||
const actors = entityManager.getActorsAt(p.x, p.y);
|
let actors: any[] = [];
|
||||||
|
if (accessor) {
|
||||||
|
actors = accessor.getActorsAt(p.x, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
|
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
|
||||||
|
|
||||||
if (enemy) {
|
if (enemy) {
|
||||||
@@ -56,10 +60,10 @@ export function traceProjectile(
|
|||||||
* Finds the closest visible enemy to a given position.
|
* Finds the closest visible enemy to a given position.
|
||||||
*/
|
*/
|
||||||
export function getClosestVisibleEnemy(
|
export function getClosestVisibleEnemy(
|
||||||
world: World,
|
|
||||||
origin: Vec2,
|
origin: Vec2,
|
||||||
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
||||||
width?: number // Required if seenTiles is a flat array
|
width?: number, // Required if seenTiles is a flat array
|
||||||
|
accessor?: EntityAccessor
|
||||||
): Vec2 | null {
|
): Vec2 | null {
|
||||||
let closestDistSq = Infinity;
|
let closestDistSq = Infinity;
|
||||||
let closestPos: Vec2 | null = null;
|
let closestPos: Vec2 | null = null;
|
||||||
@@ -76,7 +80,9 @@ export function getClosestVisibleEnemy(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const actor of world.actors.values()) {
|
const enemies = accessor ? accessor.getEnemies() : [];
|
||||||
|
|
||||||
|
for (const actor of enemies) {
|
||||||
if (actor.category !== "combatant" || actor.isPlayer) continue;
|
if (actor.category !== "combatant" || actor.isPlayer) continue;
|
||||||
|
|
||||||
// Check visibility
|
// Check visibility
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { traceProjectile } from '../CombatLogic';
|
import { traceProjectile } from '../CombatLogic';
|
||||||
import type { World } from '../../../core/types';
|
import type { World, EntityId } from '../../../core/types';
|
||||||
import { EntityManager } from '../../EntityManager';
|
import { EntityAccessor } from '../../EntityAccessor';
|
||||||
import { TileType } from '../../../core/terrain';
|
import { TileType } from '../../../core/terrain';
|
||||||
|
import { ECSWorld } from '../../ecs/World';
|
||||||
|
|
||||||
describe('CombatLogic', () => {
|
describe('CombatLogic', () => {
|
||||||
// Mock World
|
// Mock World
|
||||||
const mockWorld: World = {
|
let mockWorld: World;
|
||||||
width: 10,
|
let ecsWorld: ECSWorld;
|
||||||
height: 10,
|
let accessor: EntityAccessor;
|
||||||
tiles: new Array(100).fill(TileType.EMPTY),
|
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to set wall
|
// Helper to set wall
|
||||||
const setWall = (x: number, y: number) => {
|
const setWall = (x: number, y: number) => {
|
||||||
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
|
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to clear world
|
|
||||||
const clearWorld = () => {
|
|
||||||
mockWorld.tiles.fill(TileType.EMPTY);
|
|
||||||
mockWorld.actors.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock EntityManager
|
|
||||||
const mockEntityManager = {
|
|
||||||
getActorsAt: (x: number, y: number) => {
|
|
||||||
return [...mockWorld.actors.values()].filter(a => a.pos.x === x && a.pos.y === y);
|
|
||||||
}
|
|
||||||
} as unknown as EntityManager;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearWorld();
|
mockWorld = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
|
exit: { x: 9, y: 9 }
|
||||||
|
};
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
// Shooter ID 1
|
||||||
|
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function syncActor(actor: any) {
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "position", actor.pos);
|
||||||
|
if (actor.category === 'combatant') {
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "actorType", { type: actor.type });
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "stats", { hp: 10 } as any);
|
||||||
|
if (actor.isPlayer) ecsWorld.addComponent(actor.id as EntityId, "player", {});
|
||||||
|
} else if (actor.category === 'item_drop') {
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "groundItem", { item: actor.item || {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('traceProjectile', () => {
|
describe('traceProjectile', () => {
|
||||||
it('should travel full path if no obstacles', () => {
|
it('should travel full path if no obstacles', () => {
|
||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
const result = traceProjectile(mockWorld, start, end, accessor);
|
||||||
|
|
||||||
expect(result.blockedPos).toEqual(end);
|
expect(result.blockedPos).toEqual(end);
|
||||||
expect(result.hitActorId).toBeUndefined();
|
expect(result.hitActorId).toBeUndefined();
|
||||||
// Path should be (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (5,0)
|
|
||||||
// But raycast implementation includes start?
|
|
||||||
// CombatLogic logic: "skip start" -> loop i=1
|
|
||||||
// So result.path is full array from raycast.
|
|
||||||
expect(result.path).toHaveLength(6);
|
expect(result.path).toHaveLength(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ describe('CombatLogic', () => {
|
|||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
setWall(3, 0); // Wall at (3,0)
|
setWall(3, 0); // Wall at (3,0)
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
const result = traceProjectile(mockWorld, start, end, accessor);
|
||||||
|
|
||||||
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
||||||
expect(result.hitActorId).toBeUndefined();
|
expect(result.hitActorId).toBeUndefined();
|
||||||
@@ -68,17 +68,17 @@ describe('CombatLogic', () => {
|
|||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
// Place enemy at (3,0)
|
// Place enemy at (3,0)
|
||||||
const enemyId = 2;
|
const enemyId = 2 as EntityId;
|
||||||
mockWorld.actors.set(enemyId, {
|
const enemy = {
|
||||||
id: enemyId,
|
id: enemyId,
|
||||||
type: 'rat',
|
type: 'rat',
|
||||||
category: 'combatant',
|
category: 'combatant',
|
||||||
pos: { x: 3, y: 0 },
|
pos: { x: 3, y: 0 },
|
||||||
isPlayer: false
|
isPlayer: false
|
||||||
// ... other props mocked if needed
|
};
|
||||||
} as any);
|
syncActor(enemy);
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); // Shooter 1
|
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); // Shooter 1
|
||||||
|
|
||||||
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
|
||||||
expect(result.hitActorId).toBe(enemyId);
|
expect(result.hitActorId).toBe(enemyId);
|
||||||
@@ -89,15 +89,16 @@ describe('CombatLogic', () => {
|
|||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
// Shooter at start
|
// Shooter at start
|
||||||
mockWorld.actors.set(1, {
|
const shooter = {
|
||||||
id: 1,
|
id: 1 as EntityId,
|
||||||
type: 'player',
|
type: 'player',
|
||||||
category: 'combatant',
|
category: 'combatant',
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
isPlayer: true
|
isPlayer: true
|
||||||
} as any);
|
};
|
||||||
|
syncActor(shooter);
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1);
|
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId);
|
||||||
|
|
||||||
// Should not hit self
|
// Should not hit self
|
||||||
expect(result.hitActorId).toBeUndefined();
|
expect(result.hitActorId).toBeUndefined();
|
||||||
@@ -109,13 +110,15 @@ describe('CombatLogic', () => {
|
|||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
// Item at (3,0)
|
// Item at (3,0)
|
||||||
mockWorld.actors.set(99, {
|
const item = {
|
||||||
id: 99,
|
id: 99 as EntityId,
|
||||||
category: 'item_drop',
|
category: 'item_drop',
|
||||||
pos: { x: 3, y: 0 },
|
pos: { x: 3, y: 0 },
|
||||||
} as any);
|
item: { name: 'Test Item' }
|
||||||
|
};
|
||||||
|
syncActor(item);
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
const result = traceProjectile(mockWorld, start, end, accessor);
|
||||||
|
|
||||||
// Should pass through item
|
// Should pass through item
|
||||||
expect(result.blockedPos).toEqual(end);
|
expect(result.blockedPos).toEqual(end);
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { ItemManager } from "../../../scenes/systems/ItemManager";
|
import { ItemManager } from "../../../scenes/systems/ItemManager";
|
||||||
import { EntityManager } from "../../EntityManager";
|
import type { World, CombatantActor, RangedWeaponItem, EntityId } from "../../../core/types";
|
||||||
import type { World, CombatantActor, RangedWeaponItem } from "../../../core/types";
|
import { EntityAccessor } from "../../EntityAccessor";
|
||||||
|
import { ECSWorld } from "../../ecs/World";
|
||||||
import { createRangedWeapon, createAmmo } from "../../../core/config/Items";
|
import { createRangedWeapon, createAmmo } from "../../../core/config/Items";
|
||||||
|
|
||||||
// Mock World and EntityManager
|
|
||||||
const mockWorld: World = {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
tiles: new Array(100).fill(0),
|
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Fireable Weapons & Ammo System", () => {
|
describe("Fireable Weapons & Ammo System", () => {
|
||||||
let entityManager: EntityManager;
|
let accessor: EntityAccessor;
|
||||||
let itemManager: ItemManager;
|
let itemManager: ItemManager;
|
||||||
let player: CombatantActor;
|
let player: CombatantActor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let world: World;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
entityManager = new EntityManager(mockWorld);
|
world = {
|
||||||
itemManager = new ItemManager(mockWorld, entityManager);
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
exit: { x: 9, y: 9 }
|
||||||
|
};
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
itemManager = new ItemManager(world, accessor, ecsWorld);
|
||||||
|
|
||||||
player = {
|
player = {
|
||||||
id: 1,
|
id: 1 as EntityId,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
category: "combatant",
|
category: "combatant",
|
||||||
type: "player",
|
type: "player",
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
speed: 1,
|
speed: 100,
|
||||||
energy: 0,
|
energy: 0,
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 100, hp: 100,
|
maxHp: 100, hp: 100,
|
||||||
@@ -43,55 +44,68 @@ describe("Fireable Weapons & Ammo System", () => {
|
|||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] },
|
inventory: { gold: 0, items: [] },
|
||||||
equipment: {}
|
equipment: {}
|
||||||
};
|
} as any;
|
||||||
mockWorld.actors.clear();
|
|
||||||
mockWorld.actors.set(player.id, player);
|
// Sync player to ECS
|
||||||
|
ecsWorld.addComponent(player.id, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(player.id, "player", {});
|
||||||
|
ecsWorld.addComponent(player.id, "stats", player.stats);
|
||||||
|
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
|
||||||
|
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
|
||||||
|
|
||||||
|
// Avoid ID collisions between manually added player (ID 1) and spawned entities
|
||||||
|
ecsWorld.setNextId(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should stack ammo correctly", () => {
|
it("should stack ammo correctly", () => {
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
// Spawn Ammo pack 1
|
// Spawn Ammo pack 1
|
||||||
const ammo1 = createAmmo("ammo_9mm", 10);
|
const ammo1 = createAmmo("ammo_9mm", 10);
|
||||||
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
|
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
|
||||||
|
|
||||||
// Pickup
|
// Pickup
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
expect(player.inventory!.items[0].quantity).toBe(10);
|
expect(playerActor.inventory!.items[0].quantity).toBe(10);
|
||||||
|
|
||||||
// Spawn Ammo pack 2
|
// Spawn Ammo pack 2
|
||||||
const ammo2 = createAmmo("ammo_9mm", 5);
|
const ammo2 = createAmmo("ammo_9mm", 5);
|
||||||
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
|
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
|
||||||
|
|
||||||
// Pickup (should merge)
|
// Pickup (should merge)
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
expect(player.inventory!.items.length).toBe(1); // Still 1 stack
|
expect(playerActor.inventory!.items.length).toBe(1); // Still 1 stack
|
||||||
expect(player.inventory!.items[0].quantity).toBe(15);
|
expect(playerActor.inventory!.items[0].quantity).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should consume ammo from weapon when fired", () => {
|
it("should consume ammo from weapon when fired", () => {
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
// Create pistol using factory (already has currentAmmo initialized)
|
// Create pistol using factory (already has currentAmmo initialized)
|
||||||
const pistol = createRangedWeapon("pistol");
|
const pistol = createRangedWeapon("pistol");
|
||||||
player.inventory!.items.push(pistol);
|
playerActor.inventory!.items.push(pistol);
|
||||||
|
|
||||||
// Sanity Check - currentAmmo is now top-level
|
// Sanity Check - currentAmmo is now top-level
|
||||||
expect(pistol.currentAmmo).toBe(6);
|
expect(pistol.currentAmmo).toBe(6);
|
||||||
expect(pistol.stats.magazineSize).toBe(6);
|
expect(pistol.stats.magazineSize).toBe(6);
|
||||||
|
|
||||||
// Simulate Firing (logic mimic from GameScene)
|
// Simulate Firing (logic mimic from GameScene)
|
||||||
if (pistol.currentAmmo > 0) {
|
if (pistol.currentAmmo! > 0) {
|
||||||
pistol.currentAmmo--;
|
pistol.currentAmmo!--;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(pistol.currentAmmo).toBe(5);
|
expect(pistol.currentAmmo).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reload weapon using inventory ammo", () => {
|
it("should reload weapon using inventory ammo", () => {
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
const pistol = createRangedWeapon("pistol");
|
const pistol = createRangedWeapon("pistol");
|
||||||
pistol.currentAmmo = 0; // Empty
|
pistol.currentAmmo = 0; // Empty
|
||||||
player.inventory!.items.push(pistol);
|
playerActor.inventory!.items.push(pistol);
|
||||||
|
|
||||||
const ammo = createAmmo("ammo_9mm", 10);
|
const ammo = createAmmo("ammo_9mm", 10);
|
||||||
player.inventory!.items.push(ammo);
|
playerActor.inventory!.items.push(ammo);
|
||||||
|
|
||||||
// Logic mimic from GameScene
|
// Logic mimic from GameScene
|
||||||
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
||||||
@@ -105,12 +119,13 @@ describe("Fireable Weapons & Ammo System", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle partial reload if not enough ammo", () => {
|
it("should handle partial reload if not enough ammo", () => {
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
const pistol = createRangedWeapon("pistol");
|
const pistol = createRangedWeapon("pistol");
|
||||||
pistol.currentAmmo = 0;
|
pistol.currentAmmo = 0;
|
||||||
player.inventory!.items.push(pistol);
|
playerActor.inventory!.items.push(pistol);
|
||||||
|
|
||||||
const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
|
const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
|
||||||
player.inventory!.items.push(ammo);
|
playerActor.inventory!.items.push(ammo);
|
||||||
|
|
||||||
// Logic mimic
|
// Logic mimic
|
||||||
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
||||||
@@ -124,16 +139,17 @@ describe("Fireable Weapons & Ammo System", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should deep clone on spawn so pistols remain independent", () => {
|
it("should deep clone on spawn so pistols remain independent", () => {
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
const pistol1 = createRangedWeapon("pistol");
|
const pistol1 = createRangedWeapon("pistol");
|
||||||
|
|
||||||
// Spawn 1
|
// Spawn 1
|
||||||
itemManager.spawnItem(pistol1, {x:0, y:0});
|
itemManager.spawnItem(pistol1, {x:0, y:0});
|
||||||
const picked1 = itemManager.tryPickup(player)! as RangedWeaponItem;
|
const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
|
||||||
|
|
||||||
// Spawn 2
|
// Spawn 2
|
||||||
const pistol2 = createRangedWeapon("pistol");
|
const pistol2 = createRangedWeapon("pistol");
|
||||||
itemManager.spawnItem(pistol2, {x:0, y:0});
|
itemManager.spawnItem(pistol2, {x:0, y:0});
|
||||||
const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem;
|
const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
|
||||||
|
|
||||||
expect(picked1).not.toBe(picked2);
|
expect(picked1).not.toBe(picked2);
|
||||||
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
|
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { applyAction } from '../simulation';
|
import { applyAction } from '../simulation';
|
||||||
import type { World, CombatantActor, Action } from '../../../core/types';
|
import type { World, CombatantActor, Action, EntityId } from '../../../core/types';
|
||||||
import { TileType } from '../../../core/terrain';
|
import { TileType } from '../../../core/terrain';
|
||||||
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
||||||
|
import { EntityAccessor } from '../../EntityAccessor';
|
||||||
|
import { ECSWorld } from '../../ecs/World';
|
||||||
|
|
||||||
describe('Movement Blocking Behavior', () => {
|
describe('Movement Blocking Behavior', () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
let player: CombatantActor;
|
let player: CombatantActor;
|
||||||
|
let accessor: EntityAccessor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// minimalist world setup
|
// minimalist world setup
|
||||||
@@ -14,7 +19,6 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
width: 3,
|
width: 3,
|
||||||
height: 3,
|
height: 3,
|
||||||
tiles: new Array(9).fill(TileType.GRASS),
|
tiles: new Array(9).fill(TileType.GRASS),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 2, y: 2 }
|
exit: { x: 2, y: 2 }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
world.tiles[1] = TileType.WALL;
|
world.tiles[1] = TileType.WALL;
|
||||||
|
|
||||||
player = {
|
player = {
|
||||||
id: 1,
|
id: 1 as EntityId,
|
||||||
type: 'player',
|
type: 'player',
|
||||||
category: 'combatant',
|
category: 'combatant',
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
@@ -32,12 +36,19 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
stats: { ...GAME_CONFIG.player.initialStats }
|
stats: { ...GAME_CONFIG.player.initialStats }
|
||||||
};
|
};
|
||||||
|
|
||||||
world.actors.set(player.id, player);
|
ecsWorld = new ECSWorld();
|
||||||
|
ecsWorld.addComponent(player.id, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(player.id, "stats", player.stats);
|
||||||
|
ecsWorld.addComponent(player.id, "actorType", { type: player.type });
|
||||||
|
ecsWorld.addComponent(player.id, "player", {});
|
||||||
|
ecsWorld.addComponent(player.id, "energy", { current: player.energy, speed: player.speed });
|
||||||
|
|
||||||
|
accessor = new EntityAccessor(world, player.id, ecsWorld);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return move-blocked event when moving into a wall', () => {
|
it('should return move-blocked event when moving into a wall', () => {
|
||||||
const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0)
|
const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0)
|
||||||
const events = applyAction(world, player.id, action);
|
const events = applyAction(world, player.id, action, accessor);
|
||||||
|
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0]).toMatchObject({
|
expect(events[0]).toMatchObject({
|
||||||
@@ -50,7 +61,7 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
|
|
||||||
it('should return moved event when moving into empty space', () => {
|
it('should return moved event when moving into empty space', () => {
|
||||||
const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid
|
const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid
|
||||||
const events = applyAction(world, player.id, action);
|
const events = applyAction(world, player.id, action, accessor);
|
||||||
|
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0]).toMatchObject({
|
expect(events[0]).toMatchObject({
|
||||||
|
|||||||
@@ -3,24 +3,24 @@ import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, Collecti
|
|||||||
import { isBlocked, tryDestructTile } from "../world/world-logic";
|
import { isBlocked, tryDestructTile } from "../world/world-logic";
|
||||||
import { isDestructibleByWalk } from "../../core/terrain";
|
import { isDestructibleByWalk } from "../../core/terrain";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
import { AISystem } from "../ecs/AISystem";
|
||||||
|
import { Prefabs } from "../ecs/Prefabs";
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
export function applyAction(w: World, actorId: EntityId, action: Action, accessor: EntityAccessor): SimEvent[] {
|
||||||
const actor = w.actors.get(actorId);
|
const actor = accessor.getActor(actorId);
|
||||||
if (!actor) return [];
|
if (!actor) return [];
|
||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move":
|
case "move":
|
||||||
events.push(...handleMove(w, actor, action, em));
|
events.push(...handleMove(w, actor, action, accessor));
|
||||||
break;
|
break;
|
||||||
case "attack":
|
case "attack":
|
||||||
events.push(...handleAttack(w, actor, action, em));
|
events.push(...handleAttack(w, actor, action, accessor));
|
||||||
break;
|
break;
|
||||||
case "throw":
|
case "throw":
|
||||||
// Throwing consumes a turn but visuals are handled by the renderer/scene directly
|
|
||||||
// so we do NOT emit a "waited" event.
|
|
||||||
break;
|
break;
|
||||||
case "wait":
|
case "wait":
|
||||||
default:
|
default:
|
||||||
@@ -28,19 +28,16 @@ export function applyAction(w: World, actorId: EntityId, action: Action, em?: En
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Energy is now managed by ROT.Scheduler, no need to deduct manually
|
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) {
|
function handleExpCollection(player: Actor, events: SimEvent[], accessor: EntityAccessor) {
|
||||||
if (player.category !== "combatant") return;
|
if (player.category !== "combatant") return;
|
||||||
|
|
||||||
const orbs = [...w.actors.values()].filter(a =>
|
const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y);
|
||||||
|
const orbs = actorsAtPos.filter(a =>
|
||||||
a.category === "collectible" &&
|
a.category === "collectible" &&
|
||||||
a.type === "exp_orb" &&
|
a.type === "exp_orb"
|
||||||
a.pos.x === player.pos.x &&
|
|
||||||
a.pos.y === player.pos.y
|
|
||||||
) as CollectibleActor[];
|
) as CollectibleActor[];
|
||||||
|
|
||||||
for (const orb of orbs) {
|
for (const orb of orbs) {
|
||||||
@@ -55,8 +52,7 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: E
|
|||||||
});
|
});
|
||||||
|
|
||||||
checkLevelUp(player, events);
|
checkLevelUp(player, events);
|
||||||
if (em) em.removeActor(orb.id);
|
accessor.removeActor(orb.id);
|
||||||
else w.actors.delete(orb.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,47 +87,26 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, em?: EntityManager): SimEvent[] {
|
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, accessor: EntityAccessor): SimEvent[] {
|
||||||
const from = { ...actor.pos };
|
const from = { ...actor.pos };
|
||||||
const nx = actor.pos.x + action.dx;
|
const nx = actor.pos.x + action.dx;
|
||||||
const ny = actor.pos.y + action.dy;
|
const ny = actor.pos.y + action.dy;
|
||||||
|
|
||||||
if (em) {
|
if (!isBlocked(w, nx, ny, accessor)) {
|
||||||
const moved = em.movement.move(actor.id, action.dx, action.dy);
|
actor.pos.x = nx;
|
||||||
if (moved) {
|
actor.pos.y = ny;
|
||||||
const to = { ...actor.pos };
|
const to = { ...actor.pos };
|
||||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
||||||
|
|
||||||
const tileIdx = ny * w.width + nx;
|
const tileIdx = ny * w.width + nx;
|
||||||
const tile = w.tiles[tileIdx];
|
if (isDestructibleByWalk(w.tiles[tileIdx])) {
|
||||||
if (isDestructibleByWalk(tile)) {
|
tryDestructTile(w, nx, ny);
|
||||||
tryDestructTile(w, nx, ny);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.category === "combatant" && actor.isPlayer) {
|
|
||||||
handleExpCollection(w, actor, events, em);
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Fallback for cases without EntityManager (e.g. tests)
|
|
||||||
if (!isBlocked(w, nx, ny)) {
|
|
||||||
actor.pos.x = nx;
|
|
||||||
actor.pos.y = ny;
|
|
||||||
const to = { ...actor.pos };
|
|
||||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
|
||||||
|
|
||||||
const tileIdx = ny * w.width + nx;
|
if (actor.category === "combatant" && actor.isPlayer) {
|
||||||
if (isDestructibleByWalk(w.tiles[tileIdx])) {
|
handleExpCollection(actor, events, accessor);
|
||||||
tryDestructTile(w, nx, ny);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.category === "combatant" && actor.isPlayer) {
|
|
||||||
handleExpCollection(w, actor, events);
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
}
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
|
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
|
||||||
@@ -139,8 +114,8 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] {
|
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
|
||||||
const target = w.actors.get(action.targetId);
|
const target = accessor.getActor(action.targetId);
|
||||||
if (target && target.category === "combatant" && actor.category === "combatant") {
|
if (target && target.category === "combatant" && actor.category === "combatant") {
|
||||||
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
||||||
|
|
||||||
@@ -149,7 +124,6 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
const hitRoll = Math.random() * 100;
|
const hitRoll = Math.random() * 100;
|
||||||
|
|
||||||
if (hitRoll > hitChance) {
|
if (hitRoll > hitChance) {
|
||||||
// Miss!
|
|
||||||
events.push({
|
events.push({
|
||||||
type: "dodged",
|
type: "dodged",
|
||||||
targetId: action.targetId,
|
targetId: action.targetId,
|
||||||
@@ -173,17 +147,15 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
const blockRoll = Math.random() * 100;
|
const blockRoll = Math.random() * 100;
|
||||||
let isBlock = false;
|
let isBlock = false;
|
||||||
if (blockRoll < target.stats.blockChance) {
|
if (blockRoll < target.stats.blockChance) {
|
||||||
dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50%
|
dmg = Math.floor(dmg * 0.5);
|
||||||
isBlock = true;
|
isBlock = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
target.stats.hp -= dmg;
|
target.stats.hp -= dmg;
|
||||||
|
|
||||||
// Aggression on damage: if target is enemy and attacker is player (or vice versa), alert them
|
|
||||||
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
|
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
|
||||||
// Switch to pursuing immediately
|
|
||||||
target.aiState = "pursuing";
|
target.aiState = "pursuing";
|
||||||
target.alertedAt = Date.now(); // Reset alert timer if any
|
target.alertedAt = Date.now();
|
||||||
if (actor.pos) {
|
if (actor.pos) {
|
||||||
target.lastKnownPlayerPos = { ...actor.pos };
|
target.lastKnownPlayerPos = { ...actor.pos };
|
||||||
}
|
}
|
||||||
@@ -224,28 +196,18 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
y: target.pos.y,
|
y: target.pos.y,
|
||||||
victimType: target.type as ActorType
|
victimType: target.type as ActorType
|
||||||
});
|
});
|
||||||
if (em) em.removeActor(target.id);
|
|
||||||
else w.actors.delete(target.id);
|
|
||||||
|
|
||||||
|
|
||||||
|
accessor.removeActor(target.id);
|
||||||
|
|
||||||
// Spawn EXP Orb
|
// Spawn EXP Orb
|
||||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||||
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
|
const expAmount = enemyDef?.expValue || 0;
|
||||||
|
|
||||||
const orb: CollectibleActor = {
|
const ecsWorld = accessor.context;
|
||||||
id: orbId,
|
if (ecsWorld) {
|
||||||
category: "collectible",
|
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
|
||||||
type: "exp_orb",
|
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
||||||
pos: { ...target.pos },
|
}
|
||||||
expAmount: enemyDef?.expValue || 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (em) em.addActor(orb);
|
|
||||||
else w.actors.set(orbId, orb);
|
|
||||||
|
|
||||||
|
|
||||||
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
@@ -260,12 +222,13 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
* - Alerted: Brief period after spotting player (shows "!")
|
* - Alerted: Brief period after spotting player (shows "!")
|
||||||
* - Pursuing: Chase player while in FOV or toward last known position
|
* - Pursuing: Chase player while in FOV or toward last known position
|
||||||
*/
|
*/
|
||||||
export function decideEnemyAction(_w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } {
|
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } {
|
||||||
if (em) {
|
const ecsWorld = accessor.context;
|
||||||
const result = em.ai.update(enemy.id, player.id);
|
if (ecsWorld) {
|
||||||
|
const aiSystem = new AISystem(ecsWorld, w, accessor);
|
||||||
|
const result = aiSystem.update(enemy.id, player.id);
|
||||||
|
|
||||||
// Sync ECS component state back to Actor object for compatibility with tests and old logic
|
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
|
||||||
const aiComp = em.ecsWorld.getComponent(enemy.id, "ai");
|
|
||||||
if (aiComp) {
|
if (aiComp) {
|
||||||
enemy.aiState = aiComp.state;
|
enemy.aiState = aiComp.state;
|
||||||
enemy.alertedAt = aiComp.alertedAt;
|
enemy.alertedAt = aiComp.alertedAt;
|
||||||
@@ -275,8 +238,6 @@ export function decideEnemyAction(_w: World, enemy: CombatantActor, player: Comb
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for tests or cases without EntityManager
|
|
||||||
// [Existing decideEnemyAction logic could be kept here as fallback, or just return wait]
|
|
||||||
return { action: { type: "wait" }, justAlerted: false };
|
return { action: { type: "wait" }, justAlerted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,81 +245,42 @@ export function decideEnemyAction(_w: World, enemy: CombatantActor, player: Comb
|
|||||||
* Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input.
|
* Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input.
|
||||||
* Returns enemy events accumulated along the way.
|
* Returns enemy events accumulated along the way.
|
||||||
*/
|
*/
|
||||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: EntityAccessor): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||||
// Energy Threshold
|
|
||||||
const THRESHOLD = 100;
|
const THRESHOLD = 100;
|
||||||
|
|
||||||
// Ensure player exists
|
const player = accessor.getCombatant(playerId);
|
||||||
const player = w.actors.get(playerId) as CombatantActor;
|
if (!player) throw new Error("Player missing or invalid");
|
||||||
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
|
||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
// If player already has enough energy (from previous accumulation), return immediately to let them act
|
|
||||||
// NOTE: We do NOT deduct player energy here. The player's action will cost energy in the next turn processing or we expect the caller to have deducted it?
|
|
||||||
// Actually, standard roguelike loop:
|
|
||||||
// 1. Player acts. Deduct cost.
|
|
||||||
// 2. Loop game until Player has energy >= Threshold.
|
|
||||||
|
|
||||||
// Since this function is called AFTER user input (Player just acted), we assume Player needs to recover energy.
|
|
||||||
// BUT, we should check if we need to deduct energy first?
|
|
||||||
// The caller just applied an action. We should probably deduct energy for that action BEFORE entering the loop?
|
|
||||||
// For now, let's assume the player is at < 100 energy and needs to wait.
|
|
||||||
// Wait, if we don't deduct energy, the player stays at high energy?
|
|
||||||
// The caller doesn't manage energy. WE manage energy.
|
|
||||||
|
|
||||||
// Implicitly, the player just spent 100 energy to trigger this call.
|
|
||||||
// So we should deduct it from the player NOW.
|
|
||||||
if (player.energy >= THRESHOLD) {
|
if (player.energy >= THRESHOLD) {
|
||||||
player.energy -= THRESHOLD;
|
player.energy -= THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// If player has enough energy to act, return control to user
|
|
||||||
if (player.energy >= THRESHOLD) {
|
if (player.energy >= THRESHOLD) {
|
||||||
return { awaitingPlayerId: playerId, events };
|
return { awaitingPlayerId: playerId, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give energy to everyone
|
const actors = [...accessor.getAllActors()];
|
||||||
for (const actor of w.actors.values()) {
|
for (const actor of actors) {
|
||||||
if (actor.category === "combatant") {
|
if (actor.category === "combatant") {
|
||||||
actor.energy += actor.speed;
|
actor.energy += actor.speed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process turns for everyone who has enough energy (except player, who breaks the loop)
|
|
||||||
// We sort by energy to give priority to those who have waited longest/are fastest?
|
|
||||||
// ROT.Scheduler uses a priority queue. Here we can iterate.
|
|
||||||
// Iterating map values is insertion order.
|
|
||||||
// Ideally we'd duplicate the list to sort it, but for performance let's simple iterate.
|
|
||||||
|
|
||||||
// We need to loop multiple times if someone has A LOT of energy (e.g. speed 200 vs speed 50)
|
|
||||||
// But typically we step 1 tick.
|
|
||||||
|
|
||||||
// Simpler approach:
|
|
||||||
// Process all actors with energy >= THRESHOLD.
|
|
||||||
// If multiple have >= THRESHOLD, who goes first?
|
|
||||||
// Usually the one with highest energy.
|
|
||||||
|
|
||||||
// Let's protect against infinite loops if someone has infinite speed.
|
|
||||||
let actionsTaken = 0;
|
let actionsTaken = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const eligibleActors = [...w.actors.values()].filter(
|
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
|
||||||
a => a.category === "combatant" && a.energy >= THRESHOLD && !a.isPlayer
|
|
||||||
) as CombatantActor[];
|
|
||||||
|
|
||||||
if (eligibleActors.length === 0) break;
|
if (eligibleActors.length === 0) break;
|
||||||
|
|
||||||
// Sort by energy descending
|
|
||||||
eligibleActors.sort((a, b) => b.energy - a.energy);
|
eligibleActors.sort((a, b) => b.energy - a.energy);
|
||||||
|
|
||||||
const actor = eligibleActors[0];
|
const actor = eligibleActors[0];
|
||||||
|
|
||||||
// Actor takes a turn
|
|
||||||
actor.energy -= THRESHOLD;
|
actor.energy -= THRESHOLD;
|
||||||
|
|
||||||
// Decide logic
|
const decision = decideEnemyAction(w, actor, player, accessor);
|
||||||
const decision = decideEnemyAction(w, actor, player, em);
|
|
||||||
|
|
||||||
if (decision.justAlerted) {
|
if (decision.justAlerted) {
|
||||||
events.push({
|
events.push({
|
||||||
@@ -369,15 +291,14 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
events.push(...applyAction(w, actor.id, decision.action, em));
|
events.push(...applyAction(w, actor.id, decision.action, accessor));
|
||||||
|
|
||||||
// Check if player died
|
if (!accessor.isPlayerAlive()) {
|
||||||
if (!w.actors.has(playerId)) {
|
|
||||||
return { awaitingPlayerId: null as any, events };
|
return { awaitingPlayerId: null as any, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsTaken++;
|
actionsTaken++;
|
||||||
if (actionsTaken > 1000) break; // Emergency break
|
if (actionsTaken > 1000) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/engine/systems/EquipmentService.ts
Normal file
134
src/engine/systems/EquipmentService.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { type CombatantActor, type Item, type Equipment } from "../../core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equipment slot keys matching the Equipment interface.
|
||||||
|
*/
|
||||||
|
export type EquipmentSlotKey = keyof Equipment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of item types to valid equipment slot keys.
|
||||||
|
*/
|
||||||
|
const ITEM_TYPE_TO_SLOTS: Record<string, EquipmentSlotKey[]> = {
|
||||||
|
Weapon: ["mainHand", "offHand"],
|
||||||
|
BodyArmour: ["bodyArmour"],
|
||||||
|
Helmet: ["helmet"],
|
||||||
|
Gloves: ["gloves"],
|
||||||
|
Boots: ["boots"],
|
||||||
|
Ring: ["ringLeft", "ringRight"],
|
||||||
|
Belt: ["belt"],
|
||||||
|
Amulet: ["amulet"],
|
||||||
|
Offhand: ["offHand"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an item can be equipped in the specified slot.
|
||||||
|
*/
|
||||||
|
export function isItemValidForSlot(item: Item | undefined, slotKey: string): boolean {
|
||||||
|
if (!item || !item.type) return false;
|
||||||
|
const validSlots = ITEM_TYPE_TO_SLOTS[item.type];
|
||||||
|
return validSlots?.includes(slotKey as EquipmentSlotKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies or removes item stats to/from a player.
|
||||||
|
* @param player - The player actor to modify
|
||||||
|
* @param item - The item with stats to apply
|
||||||
|
* @param isAdding - True to add stats, false to remove
|
||||||
|
*/
|
||||||
|
export function applyItemStats(player: CombatantActor, item: Item, isAdding: boolean): void {
|
||||||
|
if (!("stats" in item) || !item.stats) return;
|
||||||
|
|
||||||
|
const modifier = isAdding ? 1 : -1;
|
||||||
|
const stats = item.stats as Record<string, number | undefined>;
|
||||||
|
|
||||||
|
// Primary stats
|
||||||
|
if (stats.defense) player.stats.defense += stats.defense * modifier;
|
||||||
|
if (stats.attack) player.stats.attack += stats.attack * modifier;
|
||||||
|
|
||||||
|
// Max HP with current HP adjustment
|
||||||
|
if (stats.maxHp) {
|
||||||
|
const diff = stats.maxHp * modifier;
|
||||||
|
player.stats.maxHp += diff;
|
||||||
|
player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max Mana with current mana adjustment
|
||||||
|
if (stats.maxMana) {
|
||||||
|
const diff = stats.maxMana * modifier;
|
||||||
|
player.stats.maxMana += diff;
|
||||||
|
player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary stats
|
||||||
|
if (stats.critChance) player.stats.critChance += stats.critChance * modifier;
|
||||||
|
if (stats.accuracy) player.stats.accuracy += stats.accuracy * modifier;
|
||||||
|
if (stats.evasion) player.stats.evasion += stats.evasion * modifier;
|
||||||
|
if (stats.blockChance) player.stats.blockChance += stats.blockChance * modifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-equips an item from the specified slot, removing stats and returning to inventory.
|
||||||
|
* @returns The de-equipped item, or null if slot was empty
|
||||||
|
*/
|
||||||
|
export function deEquipItem(
|
||||||
|
player: CombatantActor,
|
||||||
|
slotKey: EquipmentSlotKey
|
||||||
|
): Item | null {
|
||||||
|
if (!player.equipment) return null;
|
||||||
|
|
||||||
|
const item = (player.equipment as Record<string, Item | undefined>)[slotKey];
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
// Remove from equipment
|
||||||
|
delete (player.equipment as Record<string, Item | undefined>)[slotKey];
|
||||||
|
|
||||||
|
// Remove stats
|
||||||
|
applyItemStats(player, item, false);
|
||||||
|
|
||||||
|
// Add back to inventory
|
||||||
|
if (!player.inventory) player.inventory = { gold: 0, items: [] };
|
||||||
|
player.inventory.items.push(item);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equips an item to the specified slot, handling swaps if needed.
|
||||||
|
* @returns Object with success status and optional message
|
||||||
|
*/
|
||||||
|
export function equipItem(
|
||||||
|
player: CombatantActor,
|
||||||
|
item: Item,
|
||||||
|
slotKey: EquipmentSlotKey
|
||||||
|
): { success: boolean; swappedItem?: Item; message?: string } {
|
||||||
|
// Validate slot
|
||||||
|
if (!isItemValidForSlot(item, slotKey)) {
|
||||||
|
return { success: false, message: "Cannot equip there!" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from inventory
|
||||||
|
if (!player.inventory) return { success: false, message: "No inventory" };
|
||||||
|
const itemIdx = player.inventory.items.findIndex(it => it.id === item.id);
|
||||||
|
if (itemIdx === -1) return { success: false, message: "Item not in inventory" };
|
||||||
|
|
||||||
|
// Handle swap if slot is occupied
|
||||||
|
if (!player.equipment) player.equipment = {};
|
||||||
|
const oldItem = (player.equipment as Record<string, Item | undefined>)[slotKey];
|
||||||
|
let swappedItem: Item | undefined;
|
||||||
|
|
||||||
|
if (oldItem) {
|
||||||
|
swappedItem = deEquipItem(player, slotKey) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to equipment (re-find index after potential swap)
|
||||||
|
const newIdx = player.inventory.items.findIndex(it => it.id === item.id);
|
||||||
|
if (newIdx !== -1) {
|
||||||
|
player.inventory.items.splice(newIdx, 1);
|
||||||
|
}
|
||||||
|
(player.equipment as Record<string, Item | undefined>)[slotKey] = item;
|
||||||
|
|
||||||
|
// Apply stats
|
||||||
|
applyItemStats(player, item, true);
|
||||||
|
|
||||||
|
return { success: true, swappedItem };
|
||||||
|
}
|
||||||
231
src/engine/systems/__tests__/EquipmentService.test.ts
Normal file
231
src/engine/systems/__tests__/EquipmentService.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
isItemValidForSlot,
|
||||||
|
applyItemStats,
|
||||||
|
deEquipItem,
|
||||||
|
equipItem,
|
||||||
|
} from "../EquipmentService";
|
||||||
|
import type { CombatantActor, Item, WeaponItem, ArmourItem } from "../../../core/types";
|
||||||
|
|
||||||
|
// Helper to create a mock player
|
||||||
|
function createMockPlayer(overrides: Partial<CombatantActor> = {}): CombatantActor {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
|
speed: 100,
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: 20,
|
||||||
|
hp: 20,
|
||||||
|
maxMana: 10,
|
||||||
|
mana: 10,
|
||||||
|
attack: 5,
|
||||||
|
defense: 2,
|
||||||
|
level: 1,
|
||||||
|
exp: 0,
|
||||||
|
expToNextLevel: 10,
|
||||||
|
critChance: 5,
|
||||||
|
critMultiplier: 150,
|
||||||
|
accuracy: 90,
|
||||||
|
lifesteal: 0,
|
||||||
|
evasion: 5,
|
||||||
|
blockChance: 0,
|
||||||
|
luck: 0,
|
||||||
|
statPoints: 0,
|
||||||
|
skillPoints: 0,
|
||||||
|
strength: 10,
|
||||||
|
dexterity: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
passiveNodes: [],
|
||||||
|
},
|
||||||
|
inventory: { gold: 0, items: [] },
|
||||||
|
equipment: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSword(): WeaponItem {
|
||||||
|
return {
|
||||||
|
id: "sword_1",
|
||||||
|
name: "Iron Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 3 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArmour(): ArmourItem {
|
||||||
|
return {
|
||||||
|
id: "armour_1",
|
||||||
|
name: "Leather Armor",
|
||||||
|
type: "BodyArmour",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 1,
|
||||||
|
stats: { defense: 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EquipmentService", () => {
|
||||||
|
describe("isItemValidForSlot", () => {
|
||||||
|
it("returns true for weapon in mainHand", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "mainHand")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for weapon in offHand", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "offHand")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for weapon in bodyArmour slot", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "bodyArmour")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for BodyArmour in bodyArmour slot", () => {
|
||||||
|
expect(isItemValidForSlot(createArmour(), "bodyArmour")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for undefined item", () => {
|
||||||
|
expect(isItemValidForSlot(undefined, "mainHand")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unknown slot", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "unknownSlot")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyItemStats", () => {
|
||||||
|
let player: CombatantActor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
player = createMockPlayer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds attack stat when isAdding is true", () => {
|
||||||
|
const sword = createSword();
|
||||||
|
applyItemStats(player, sword, true);
|
||||||
|
expect(player.stats.attack).toBe(8); // 5 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes attack stat when isAdding is false", () => {
|
||||||
|
const sword = createSword();
|
||||||
|
player.stats.attack = 8;
|
||||||
|
applyItemStats(player, sword, false);
|
||||||
|
expect(player.stats.attack).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds defense stat when isAdding is true", () => {
|
||||||
|
const armour = createArmour();
|
||||||
|
applyItemStats(player, armour, true);
|
||||||
|
expect(player.stats.defense).toBe(4); // 2 + 2
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles items without stats", () => {
|
||||||
|
const itemWithoutStats = { id: "coin", name: "Coin", type: "Currency" } as Item;
|
||||||
|
applyItemStats(player, itemWithoutStats, true);
|
||||||
|
expect(player.stats.attack).toBe(5); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deEquipItem", () => {
|
||||||
|
let player: CombatantActor;
|
||||||
|
let sword: WeaponItem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sword = createSword();
|
||||||
|
player = createMockPlayer({
|
||||||
|
equipment: { mainHand: sword },
|
||||||
|
inventory: { gold: 0, items: [] },
|
||||||
|
});
|
||||||
|
player.stats.attack = 8; // Sword already equipped
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes item from equipment slot", () => {
|
||||||
|
deEquipItem(player, "mainHand");
|
||||||
|
expect(player.equipment?.mainHand).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the de-equipped item", () => {
|
||||||
|
const result = deEquipItem(player, "mainHand");
|
||||||
|
expect(result?.id).toBe("sword_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds item back to inventory", () => {
|
||||||
|
deEquipItem(player, "mainHand");
|
||||||
|
expect(player.inventory?.items.length).toBe(1);
|
||||||
|
expect(player.inventory?.items[0].id).toBe("sword_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes item stats from player", () => {
|
||||||
|
deEquipItem(player, "mainHand");
|
||||||
|
expect(player.stats.attack).toBe(5); // Back to base
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty slot", () => {
|
||||||
|
const result = deEquipItem(player, "offHand");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipItem", () => {
|
||||||
|
let player: CombatantActor;
|
||||||
|
let sword: WeaponItem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sword = createSword();
|
||||||
|
player = createMockPlayer({
|
||||||
|
inventory: { gold: 0, items: [sword] },
|
||||||
|
equipment: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equips item to valid slot", () => {
|
||||||
|
const result = equipItem(player, sword, "mainHand");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(player.equipment?.mainHand?.id).toBe("sword_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes item from inventory", () => {
|
||||||
|
equipItem(player, sword, "mainHand");
|
||||||
|
expect(player.inventory?.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies item stats", () => {
|
||||||
|
equipItem(player, sword, "mainHand");
|
||||||
|
expect(player.stats.attack).toBe(8); // 5 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails for invalid slot", () => {
|
||||||
|
const result = equipItem(player, sword, "bodyArmour");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe("Cannot equip there!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps existing item", () => {
|
||||||
|
const sword2: WeaponItem = {
|
||||||
|
id: "sword_2",
|
||||||
|
name: "Steel Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
player.inventory!.items.push(sword2);
|
||||||
|
|
||||||
|
// Equip first sword
|
||||||
|
equipItem(player, sword, "mainHand");
|
||||||
|
expect(player.stats.attack).toBe(8);
|
||||||
|
|
||||||
|
// Equip second sword (should swap)
|
||||||
|
const result = equipItem(player, sword2, "mainHand");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.swappedItem?.id).toBe("sword_1");
|
||||||
|
expect(player.equipment?.mainHand?.id).toBe("sword_2");
|
||||||
|
expect(player.stats.attack).toBe(10); // 5 base + 5 new sword
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/engine/world/__tests__/DebuggingStack.test.ts
Normal file
41
src/engine/world/__tests__/DebuggingStack.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateWorld } from '../generator';
|
||||||
|
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
||||||
|
|
||||||
|
describe('World Generator Stacking Debug', () => {
|
||||||
|
it('should not spawn multiple enemies on the same tile', () => {
|
||||||
|
const runState = {
|
||||||
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
|
inventory: { gold: 0, items: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run multiple times to catch sporadic rng issues
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const floor = 1 + (i % 10);
|
||||||
|
const { ecsWorld } = generateWorld(floor, runState);
|
||||||
|
|
||||||
|
// Get all enemies
|
||||||
|
const aiEntities = ecsWorld.getEntitiesWith("ai");
|
||||||
|
|
||||||
|
const positions = new Set<string>();
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
|
||||||
|
for (const entityId of aiEntities) {
|
||||||
|
const pos = ecsWorld.getComponent(entityId, "position");
|
||||||
|
if (pos) {
|
||||||
|
const key = `${pos.x},${pos.y}`;
|
||||||
|
if (positions.has(key)) {
|
||||||
|
duplicates.push(key);
|
||||||
|
}
|
||||||
|
positions.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
console.error(`Found duplicates on iteration ${i} (floor ${floor}):`, duplicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(duplicates.length).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types";
|
import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "../../core/types";
|
||||||
import { TileType } from "../../core/terrain";
|
import { TileType } from "../../core/terrain";
|
||||||
import { idx } from "./world-logic";
|
import { idx } from "./world-logic";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
@@ -13,6 +13,7 @@ import { seededRandom } from "../../core/math";
|
|||||||
import * as ROT from "rot-js";
|
import * as ROT from "rot-js";
|
||||||
import { ECSWorld } from "../ecs/World";
|
import { ECSWorld } from "../ecs/World";
|
||||||
import { Prefabs } from "../ecs/Prefabs";
|
import { Prefabs } from "../ecs/Prefabs";
|
||||||
|
import { EntityBuilder } from "../ecs/EntityBuilder";
|
||||||
|
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
@@ -35,6 +36,9 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
|
|
||||||
const random = seededRandom(floor * 12345);
|
const random = seededRandom(floor * 12345);
|
||||||
|
|
||||||
|
// Create ECS World first
|
||||||
|
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
|
||||||
|
|
||||||
// Set ROT's RNG seed for consistent dungeon generation
|
// Set ROT's RNG seed for consistent dungeon generation
|
||||||
ROT.RNG.setSeed(floor * 12345);
|
ROT.RNG.setSeed(floor * 12345);
|
||||||
|
|
||||||
@@ -45,34 +49,33 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
||||||
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
|
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
|
||||||
|
|
||||||
const actors = new Map<EntityId, Actor>();
|
// Create Player Entity in ECS
|
||||||
const playerId = 1;
|
const runInventory = {
|
||||||
|
|
||||||
actors.set(playerId, {
|
|
||||||
id: playerId,
|
|
||||||
category: "combatant",
|
|
||||||
isPlayer: true,
|
|
||||||
type: "player",
|
|
||||||
pos: { x: playerX, y: playerY },
|
|
||||||
speed: GAME_CONFIG.player.speed,
|
|
||||||
stats: { ...runState.stats },
|
|
||||||
inventory: {
|
|
||||||
gold: runState.inventory.gold,
|
gold: runState.inventory.gold,
|
||||||
items: [
|
items: [
|
||||||
...runState.inventory.items,
|
...runState.inventory.items,
|
||||||
// Add starting items for testing if empty
|
// Add starting items for testing if empty
|
||||||
...(runState.inventory.items.length === 0 ? [
|
...(runState.inventory.items.length === 0 ? [
|
||||||
createConsumable("health_potion", 2),
|
createConsumable("health_potion", 2),
|
||||||
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
|
createMeleeWeapon("iron_sword", "sharp"),
|
||||||
createConsumable("throwing_dagger", 3),
|
createConsumable("throwing_dagger", 3),
|
||||||
createRangedWeapon("pistol"),
|
createRangedWeapon("pistol"),
|
||||||
createArmour("leather_armor", "heavy"), // Heavy armour variant
|
createArmour("leather_armor", "heavy"),
|
||||||
createUpgradeScroll(2) // 2 Upgrade scrolls
|
createUpgradeScroll(2)
|
||||||
] : [])
|
] : [])
|
||||||
]
|
]
|
||||||
},
|
};
|
||||||
energy: 0
|
|
||||||
});
|
const playerId = EntityBuilder.create(ecsWorld)
|
||||||
|
.asPlayer()
|
||||||
|
.withPosition(playerX, playerY)
|
||||||
|
// RunState stats override default player stats
|
||||||
|
.withStats(runState.stats)
|
||||||
|
.withInventory(runInventory)
|
||||||
|
.withEnergy(GAME_CONFIG.player.speed)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// No more legacy Actors Map
|
||||||
|
|
||||||
// Place exit in last room
|
// Place exit in last room
|
||||||
const lastRoom = rooms[rooms.length - 1];
|
const lastRoom = rooms[rooms.length - 1];
|
||||||
@@ -81,10 +84,10 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
y: lastRoom.y + Math.floor(lastRoom.height / 2)
|
y: lastRoom.y + Math.floor(lastRoom.height / 2)
|
||||||
};
|
};
|
||||||
|
|
||||||
placeEnemies(floor, rooms, actors, random);
|
placeEnemies(floor, rooms, ecsWorld, random);
|
||||||
|
|
||||||
|
// Place traps (using same ecsWorld)
|
||||||
|
|
||||||
// Create ECS world and place traps
|
|
||||||
const ecsWorld = new ECSWorld();
|
|
||||||
const occupiedPositions = new Set<string>();
|
const occupiedPositions = new Set<string>();
|
||||||
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
|
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
|
||||||
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
|
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
|
||||||
@@ -103,7 +106,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
tiles[playerY * width + playerX] = TileType.EMPTY;
|
tiles[playerY * width + playerX] = TileType.EMPTY;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
world: { width, height, tiles, actors, exit },
|
world: { width, height, tiles, exit },
|
||||||
playerId,
|
playerId,
|
||||||
ecsWorld
|
ecsWorld
|
||||||
};
|
};
|
||||||
@@ -368,8 +371,7 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): void {
|
||||||
let enemyId = 2;
|
|
||||||
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
||||||
|
|
||||||
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
||||||
@@ -394,43 +396,23 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
|||||||
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||||
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||||
|
|
||||||
actors.set(enemyId, {
|
const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed));
|
||||||
id: enemyId,
|
|
||||||
category: "combatant",
|
// Create Enemy in ECS
|
||||||
isPlayer: false,
|
EntityBuilder.create(ecsWorld)
|
||||||
type,
|
.asEnemy(type)
|
||||||
pos: { x: ex, y: ey },
|
.withPosition(ex, ey)
|
||||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
.withStats({
|
||||||
stats: {
|
maxHp: scaledHp + Math.floor(random() * 4),
|
||||||
maxHp: scaledHp + Math.floor(random() * 4),
|
hp: scaledHp + Math.floor(random() * 4),
|
||||||
hp: scaledHp + Math.floor(random() * 4),
|
attack: scaledAttack + Math.floor(random() * 2),
|
||||||
maxMana: 0,
|
defense: enemyDef.baseDefense,
|
||||||
mana: 0,
|
})
|
||||||
attack: scaledAttack + Math.floor(random() * 2),
|
.withEnergy(speed) // Configured speed
|
||||||
defense: enemyDef.baseDefense,
|
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
|
||||||
level: 0,
|
.build();
|
||||||
exp: 0,
|
|
||||||
expToNextLevel: 0,
|
|
||||||
statPoints: 0,
|
|
||||||
skillPoints: 0,
|
|
||||||
strength: 0,
|
|
||||||
dexterity: 0,
|
|
||||||
intelligence: 0,
|
|
||||||
critChance: 0,
|
|
||||||
critMultiplier: 100,
|
|
||||||
accuracy: 80,
|
|
||||||
lifesteal: 0,
|
|
||||||
evasion: 0,
|
|
||||||
blockChance: 0,
|
|
||||||
luck: 0,
|
|
||||||
passiveNodes: []
|
|
||||||
},
|
|
||||||
energy: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
occupiedPositions.add(k);
|
occupiedPositions.add(k);
|
||||||
enemyId++;
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { World, Vec2 } from "../../core/types";
|
import type { World, Vec2 } from "../../core/types";
|
||||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
import * as ROT from "rot-js";
|
import * as ROT from "rot-js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,14 +16,14 @@ export function findPathAStar(
|
|||||||
seen: Uint8Array,
|
seen: Uint8Array,
|
||||||
start: Vec2,
|
start: Vec2,
|
||||||
end: Vec2,
|
end: Vec2,
|
||||||
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}
|
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; accessor?: EntityAccessor } = {}
|
||||||
): Vec2[] {
|
): Vec2[] {
|
||||||
// Validate target
|
// Validate target
|
||||||
if (!inBounds(w, end.x, end.y)) return [];
|
if (!inBounds(w, end.x, end.y)) return [];
|
||||||
if (isWall(w, end.x, end.y)) return [];
|
if (isWall(w, end.x, end.y)) return [];
|
||||||
|
|
||||||
// Check if target is blocked (unless ignoring)
|
// Check if target is blocked (unless ignoring)
|
||||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.accessor)) return [];
|
||||||
|
|
||||||
// Check if target is unseen (unless ignoring)
|
// Check if target is unseen (unless ignoring)
|
||||||
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||||
@@ -44,7 +44,7 @@ export function findPathAStar(
|
|||||||
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
||||||
|
|
||||||
// Check actor blocking
|
// Check actor blocking
|
||||||
if (isBlocked(w, x, y, options.em)) return false;
|
if (options.accessor && isBlocked(w, x, y, options.accessor)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { World, EntityId } from "../../core/types";
|
import type { World } from "../../core/types";
|
||||||
import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain";
|
import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
|
||||||
|
|
||||||
export function inBounds(w: World, x: number, y: number): boolean {
|
export function inBounds(w: World, x: number, y: number): boolean {
|
||||||
@@ -37,26 +37,19 @@ export function tryDestructTile(w: World, x: number, y: number): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
|
export function isBlocked(w: World, x: number, y: number, accessor: EntityAccessor | undefined): boolean {
|
||||||
if (!inBounds(w, x, y)) return true;
|
if (!inBounds(w, x, y)) return true;
|
||||||
if (isBlockingTile(w, x, y)) return true;
|
if (isBlockingTile(w, x, y)) return true;
|
||||||
|
|
||||||
if (em) {
|
if (!accessor) return false;
|
||||||
const actors = em.getActorsAt(x, y);
|
const actors = accessor.getActorsAt(x, y);
|
||||||
// Only combatants block movement
|
return actors.some(a => a.category === "combatant");
|
||||||
return actors.some(a => a.category === "combatant");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const a of w.actors.values()) {
|
|
||||||
if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
export function isPlayerOnExit(w: World, accessor: EntityAccessor): boolean {
|
||||||
const p = w.actors.get(playerId);
|
const p = accessor.getPlayer();
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MinimapRenderer } from "./MinimapRenderer";
|
|||||||
import { FxRenderer } from "./FxRenderer";
|
import { FxRenderer } from "./FxRenderer";
|
||||||
import { ItemSpriteFactory } from "./ItemSpriteFactory";
|
import { ItemSpriteFactory } from "./ItemSpriteFactory";
|
||||||
import { type ECSWorld } from "../engine/ecs/World";
|
import { type ECSWorld } from "../engine/ecs/World";
|
||||||
|
import { type EntityAccessor } from "../engine/EntityAccessor";
|
||||||
|
|
||||||
export class DungeonRenderer {
|
export class DungeonRenderer {
|
||||||
private scene: Phaser.Scene;
|
private scene: Phaser.Scene;
|
||||||
@@ -25,7 +26,8 @@ export class DungeonRenderer {
|
|||||||
private fxRenderer: FxRenderer;
|
private fxRenderer: FxRenderer;
|
||||||
|
|
||||||
private world!: World;
|
private world!: World;
|
||||||
private ecsWorld?: ECSWorld;
|
private entityAccessor!: EntityAccessor;
|
||||||
|
private ecsWorld!: ECSWorld;
|
||||||
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
|
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
@@ -35,17 +37,33 @@ export class DungeonRenderer {
|
|||||||
this.fxRenderer = new FxRenderer(scene);
|
this.fxRenderer = new FxRenderer(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeFloor(world: World, playerId: EntityId, ecsWorld?: ECSWorld) {
|
initializeFloor(world: World, ecsWorld: ECSWorld, entityAccessor: EntityAccessor) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
|
this.entityAccessor = entityAccessor;
|
||||||
this.fovManager.initialize(world);
|
this.fovManager.initialize(world);
|
||||||
|
|
||||||
// Clear old trap sprites
|
// Clear old sprites from maps
|
||||||
for (const [, sprite] of this.trapSprites) {
|
for (const [, sprite] of this.trapSprites) {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
}
|
}
|
||||||
this.trapSprites.clear();
|
this.trapSprites.clear();
|
||||||
|
|
||||||
|
for (const [, sprite] of this.enemySprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.enemySprites.clear();
|
||||||
|
|
||||||
|
for (const [, sprite] of this.orbSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.orbSprites.clear();
|
||||||
|
|
||||||
|
for (const [, sprite] of this.itemSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.itemSprites.clear();
|
||||||
|
|
||||||
// Setup Tilemap
|
// Setup Tilemap
|
||||||
if (this.map) this.map.destroy();
|
if (this.map) this.map.destroy();
|
||||||
this.map = this.scene.make.tilemap({
|
this.map = this.scene.make.tilemap({
|
||||||
@@ -81,8 +99,8 @@ export class DungeonRenderer {
|
|||||||
// Kill any active tweens on the player sprite
|
// Kill any active tweens on the player sprite
|
||||||
this.scene.tweens.killTweensOf(this.playerSprite);
|
this.scene.tweens.killTweensOf(this.playerSprite);
|
||||||
|
|
||||||
// Get player position in new world using provided playerId
|
|
||||||
const player = world.actors.get(playerId);
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player && player.category === "combatant") {
|
if (player && player.category === "combatant") {
|
||||||
this.playerSprite.setPosition(
|
this.playerSprite.setPosition(
|
||||||
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
@@ -122,8 +140,11 @@ export class DungeonRenderer {
|
|||||||
return this.minimapRenderer.isVisible();
|
return this.minimapRenderer.isVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
computeFov(playerId: EntityId) {
|
computeFov() {
|
||||||
this.fovManager.compute(this.world, playerId);
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (player && player.category === "combatant") {
|
||||||
|
this.fovManager.compute(this.world, player.pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSeen(x: number, y: number): boolean {
|
isSeen(x: number, y: number): boolean {
|
||||||
@@ -210,7 +231,8 @@ export class DungeonRenderer {
|
|||||||
const activeOrbIds = new Set<EntityId>();
|
const activeOrbIds = new Set<EntityId>();
|
||||||
const activeItemIds = new Set<EntityId>();
|
const activeItemIds = new Set<EntityId>();
|
||||||
|
|
||||||
for (const a of this.world.actors.values()) {
|
const actors = this.entityAccessor.getAllActors();
|
||||||
|
for (const a of actors) {
|
||||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||||
const isVis = visible[i] === 1;
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
@@ -310,7 +332,7 @@ export class DungeonRenderer {
|
|||||||
for (const [id, sprite] of this.enemySprites.entries()) {
|
for (const [id, sprite] of this.enemySprites.entries()) {
|
||||||
if (!activeEnemyIds.has(id)) {
|
if (!activeEnemyIds.has(id)) {
|
||||||
sprite.setVisible(false);
|
sprite.setVisible(false);
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.entityAccessor.hasActor(id)) {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
this.enemySprites.delete(id);
|
this.enemySprites.delete(id);
|
||||||
}
|
}
|
||||||
@@ -320,7 +342,7 @@ export class DungeonRenderer {
|
|||||||
for (const [id, orb] of this.orbSprites.entries()) {
|
for (const [id, orb] of this.orbSprites.entries()) {
|
||||||
if (!activeOrbIds.has(id)) {
|
if (!activeOrbIds.has(id)) {
|
||||||
orb.setVisible(false);
|
orb.setVisible(false);
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.entityAccessor.hasActor(id)) {
|
||||||
orb.destroy();
|
orb.destroy();
|
||||||
this.orbSprites.delete(id);
|
this.orbSprites.delete(id);
|
||||||
}
|
}
|
||||||
@@ -330,14 +352,14 @@ export class DungeonRenderer {
|
|||||||
for (const [id, item] of this.itemSprites.entries()) {
|
for (const [id, item] of this.itemSprites.entries()) {
|
||||||
if (!activeItemIds.has(id)) {
|
if (!activeItemIds.has(id)) {
|
||||||
item.setVisible(false);
|
item.setVisible(false);
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.entityAccessor.hasActor(id)) {
|
||||||
item.destroy();
|
item.destroy();
|
||||||
this.itemSprites.delete(id);
|
this.itemSprites.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.minimapRenderer.render(this.world, seen, visible);
|
this.minimapRenderer.render(this.world, seen, visible, this.entityAccessor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FX Delegations
|
// FX Delegations
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FOV } from "rot-js";
|
import { FOV } from "rot-js";
|
||||||
import type ROT from "rot-js";
|
import type ROT from "rot-js";
|
||||||
import { type World, type EntityId } from "../core/types";
|
import { type World } from "../core/types";
|
||||||
import { idx, inBounds } from "../engine/world/world-logic";
|
import { idx, inBounds } from "../engine/world/world-logic";
|
||||||
import { blocksSight } from "../core/terrain";
|
import { blocksSight } from "../core/terrain";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
@@ -28,13 +28,12 @@ export class FovManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
compute(world: World, playerId: EntityId) {
|
compute(world: World, origin: { x: number; y: number }) {
|
||||||
this.visible.fill(0);
|
this.visible.fill(0);
|
||||||
this.visibleStrength.fill(0);
|
this.visibleStrength.fill(0);
|
||||||
|
|
||||||
const player = world.actors.get(playerId)!;
|
const ox = origin.x;
|
||||||
const ox = player.pos.x;
|
const oy = origin.y;
|
||||||
const oy = player.pos.y;
|
|
||||||
|
|
||||||
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
|
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
|
||||||
if (!inBounds(world, x, y)) return;
|
if (!inBounds(world, x, y)) return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type CombatantActor } from "../core/types";
|
import { type World } from "../core/types";
|
||||||
|
import { type EntityAccessor } from "../engine/EntityAccessor";
|
||||||
import { idx, isWall } from "../engine/world/world-logic";
|
import { idx, isWall } from "../engine/world/world-logic";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export class MinimapRenderer {
|
|||||||
return this.minimapVisible;
|
return this.minimapVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(world: World, seen: Uint8Array, visible: Uint8Array) {
|
render(world: World, seen: Uint8Array, visible: Uint8Array, accessor: EntityAccessor) {
|
||||||
this.minimapGfx.clear();
|
this.minimapGfx.clear();
|
||||||
if (!world) return;
|
if (!world) return;
|
||||||
|
|
||||||
@@ -84,20 +85,17 @@ export class MinimapRenderer {
|
|||||||
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
|
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;
|
const player = accessor.getPlayer();
|
||||||
if (player) {
|
if (player) {
|
||||||
this.minimapGfx.fillStyle(0x66ff66, 1);
|
this.minimapGfx.fillStyle(0x66ff66, 1);
|
||||||
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
|
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const a of world.actors.values()) {
|
for (const a of accessor.getEnemies()) {
|
||||||
if (a.category === "combatant") {
|
const i = idx(world, a.pos.x, a.pos.y);
|
||||||
if (a.isPlayer) continue;
|
if (visible[i] === 1) {
|
||||||
const i = idx(world, a.pos.x, a.pos.y);
|
this.minimapGfx.fillStyle(0xff6666, 1);
|
||||||
if (visible[i] === 1) {
|
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
|
||||||
this.minimapGfx.fillStyle(0xff6666, 1);
|
|
||||||
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { DungeonRenderer } from '../DungeonRenderer';
|
import { DungeonRenderer } from '../DungeonRenderer';
|
||||||
import { type World } from '../../core/types';
|
import type { World, EntityId } from '../../core/types';
|
||||||
|
import { ECSWorld } from '../../engine/ecs/World';
|
||||||
|
import { EntityAccessor } from '../../engine/EntityAccessor';
|
||||||
|
|
||||||
// Mock Phaser
|
// Mock Phaser
|
||||||
vi.mock('phaser', () => {
|
vi.mock('phaser', () => {
|
||||||
@@ -11,6 +14,10 @@ vi.mock('phaser', () => {
|
|||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
|
frame: { name: '0' },
|
||||||
|
setFrame: vi.fn(),
|
||||||
|
setAlpha: vi.fn(),
|
||||||
|
clearTint: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockGraphics = {
|
const mockGraphics = {
|
||||||
@@ -27,6 +34,7 @@ vi.mock('phaser', () => {
|
|||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
setScrollFactor: vi.fn().mockReturnThis(),
|
setScrollFactor: vi.fn().mockReturnThis(),
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
y: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRectangle = {
|
const mockRectangle = {
|
||||||
@@ -41,6 +49,13 @@ vi.mock('phaser', () => {
|
|||||||
Graphics: vi.fn(() => mockGraphics),
|
Graphics: vi.fn(() => mockGraphics),
|
||||||
Container: vi.fn(() => mockContainer),
|
Container: vi.fn(() => mockContainer),
|
||||||
Rectangle: vi.fn(() => mockRectangle),
|
Rectangle: vi.fn(() => mockRectangle),
|
||||||
|
Arc: vi.fn(() => ({
|
||||||
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
Scene: vi.fn(),
|
Scene: vi.fn(),
|
||||||
Math: {
|
Math: {
|
||||||
@@ -54,6 +69,8 @@ describe('DungeonRenderer', () => {
|
|||||||
let mockScene: any;
|
let mockScene: any;
|
||||||
let renderer: DungeonRenderer;
|
let renderer: DungeonRenderer;
|
||||||
let mockWorld: World;
|
let mockWorld: World;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let accessor: EntityAccessor;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -72,13 +89,25 @@ describe('DungeonRenderer', () => {
|
|||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
|
frame: { name: '0' },
|
||||||
|
setFrame: vi.fn(),
|
||||||
|
setAlpha: vi.fn(),
|
||||||
|
clearTint: vi.fn(),
|
||||||
})),
|
})),
|
||||||
|
circle: vi.fn().mockReturnValue({
|
||||||
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
}),
|
||||||
container: vi.fn().mockReturnValue({
|
container: vi.fn().mockReturnValue({
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
setPosition: vi.fn(),
|
setPosition: vi.fn(),
|
||||||
setVisible: vi.fn(),
|
setVisible: vi.fn(),
|
||||||
setScrollFactor: vi.fn(),
|
setScrollFactor: vi.fn(),
|
||||||
setDepth: vi.fn(),
|
setDepth: vi.fn(),
|
||||||
|
y: 0
|
||||||
}),
|
}),
|
||||||
rectangle: vi.fn().mockReturnValue({
|
rectangle: vi.fn().mockReturnValue({
|
||||||
setStrokeStyle: vi.fn().mockReturnThis(),
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
@@ -89,6 +118,7 @@ describe('DungeonRenderer', () => {
|
|||||||
main: {
|
main: {
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
shake: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
anims: {
|
anims: {
|
||||||
@@ -110,6 +140,9 @@ describe('DungeonRenderer', () => {
|
|||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
killTweensOf: vi.fn(),
|
killTweensOf: vi.fn(),
|
||||||
},
|
},
|
||||||
|
time: {
|
||||||
|
now: 0
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -117,15 +150,16 @@ describe('DungeonRenderer', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 },
|
exit: { x: 9, y: 9 },
|
||||||
};
|
};
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
renderer = new DungeonRenderer(mockScene);
|
renderer = new DungeonRenderer(mockScene);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should track and clear corpse sprites on floor initialization', () => {
|
it('should track and clear corpse sprites on floor initialization', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
|
|
||||||
// Spawn a couple of corpses
|
// Spawn a couple of corpses
|
||||||
@@ -133,31 +167,29 @@ describe('DungeonRenderer', () => {
|
|||||||
renderer.spawnCorpse(2, 2, 'bat');
|
renderer.spawnCorpse(2, 2, 'bat');
|
||||||
|
|
||||||
// Get the mock sprites that were returned by scene.add.sprite
|
// Get the mock sprites that were returned by scene.add.sprite
|
||||||
|
// The player sprite is created first in initializeFloor if it doesn't exist
|
||||||
|
// Then the two corpses
|
||||||
const corpse1 = mockScene.add.sprite.mock.results[1].value;
|
const corpse1 = mockScene.add.sprite.mock.results[1].value;
|
||||||
const corpse2 = mockScene.add.sprite.mock.results[2].value;
|
const corpse2 = mockScene.add.sprite.mock.results[2].value;
|
||||||
|
|
||||||
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); // Player + 2 corpses
|
||||||
|
|
||||||
// Initialize floor again (changing level)
|
// Initialize floor again (changing level)
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
|
|
||||||
// Verify destroy was called on both corpse sprites
|
// Verify destroy was called on both corpse sprites (via fxRenderer.clearCorpses)
|
||||||
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(corpse2.destroy).toHaveBeenCalledTimes(1);
|
expect(corpse2.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render exp_orb as a circle and not as an enemy sprite', () => {
|
it('should render exp_orb correctly', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
// Add an exp_orb to the world
|
// Add an exp_orb to the ECS world
|
||||||
mockWorld.actors.set(2, {
|
ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 });
|
||||||
id: 2,
|
ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 });
|
||||||
category: "collectible",
|
ecsWorld.addComponent(2 as EntityId, "actorType", { type: "exp_orb" as any });
|
||||||
type: "exp_orb",
|
|
||||||
pos: { x: 2, y: 1 },
|
|
||||||
expAmount: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make the tile visible for it to render
|
// Make the tile visible for it to render
|
||||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
|
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
|
||||||
@@ -165,40 +197,19 @@ describe('DungeonRenderer', () => {
|
|||||||
// Reset mocks
|
// Reset mocks
|
||||||
mockScene.add.sprite.mockClear();
|
mockScene.add.sprite.mockClear();
|
||||||
|
|
||||||
// Mock scene.add.circle
|
|
||||||
mockScene.add.circle = vi.fn().mockReturnValue({
|
|
||||||
setStrokeStyle: vi.fn().mockReturnThis(),
|
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
|
||||||
});
|
|
||||||
|
|
||||||
renderer.render([]);
|
renderer.render([]);
|
||||||
|
|
||||||
// Should NOT have added an enemy sprite for the orb
|
|
||||||
const spriteCalls = mockScene.add.sprite.mock.calls;
|
|
||||||
// Any sprite added that isn't the player (which isn't in mockWorld.actors here except if we added it)
|
|
||||||
// The current loop skips a.isPlayer and then checks if type is in GAME_CONFIG.enemies
|
|
||||||
expect(spriteCalls.length).toBe(0);
|
|
||||||
|
|
||||||
// Should HAVE added a circle for the orb
|
// Should HAVE added a circle for the orb
|
||||||
expect(mockScene.add.circle).toHaveBeenCalled();
|
expect(mockScene.add.circle).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render any enemy type defined in config as a sprite', () => {
|
it('should render any enemy type as a sprite', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
// Add a rat (defined in config)
|
// Add a rat
|
||||||
mockWorld.actors.set(3, {
|
ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 });
|
||||||
id: 3,
|
ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" });
|
||||||
category: "combatant",
|
ecsWorld.addComponent(3 as EntityId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
isPlayer: false,
|
|
||||||
type: "rat",
|
|
||||||
pos: { x: 3, y: 1 },
|
|
||||||
speed: 10,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any,
|
|
||||||
energy: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
||||||
mockScene.add.sprite.mockClear();
|
mockScene.add.sprite.mockClear();
|
||||||
@@ -211,21 +222,16 @@ describe('DungeonRenderer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize new enemy sprites at target position and not tween them', () => {
|
it('should initialize new enemy sprites at target position and not tween them', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
// Position 5,5 -> 5*16 + 8 = 88
|
// Position 5,5 -> 5*16 + 8 = 88
|
||||||
const TILE_SIZE = 16;
|
const TILE_SIZE = 16;
|
||||||
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
|
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const targetY = 5 * TILE_SIZE + TILE_SIZE / 2;
|
const targetY = 5 * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
mockWorld.actors.set(999, {
|
ecsWorld.addComponent(999 as EntityId, "position", { x: 5, y: 5 });
|
||||||
id: 999,
|
ecsWorld.addComponent(999 as EntityId, "actorType", { type: "rat" });
|
||||||
category: "combatant",
|
ecsWorld.addComponent(999 as EntityId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
isPlayer: false,
|
|
||||||
type: "rat",
|
|
||||||
pos: { x: 5, y: 5 },
|
|
||||||
stats: { hp: 10, maxHp: 10 } as any,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
(renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1;
|
(renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1;
|
||||||
mockScene.add.sprite.mockClear();
|
mockScene.add.sprite.mockClear();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type Action,
|
type Action,
|
||||||
type RunState,
|
type RunState,
|
||||||
type World,
|
type World,
|
||||||
type CombatantActor,
|
|
||||||
type UIUpdatePayload
|
type UIUpdatePayload
|
||||||
} from "../core/types";
|
} from "../core/types";
|
||||||
import { TILE_SIZE } from "../core/constants";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
@@ -16,13 +15,14 @@ import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulatio
|
|||||||
import { generateWorld } from "../engine/world/generator";
|
import { generateWorld } from "../engine/world/generator";
|
||||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
import { EntityManager } from "../engine/EntityManager";
|
import { EntityAccessor } from "../engine/EntityAccessor";
|
||||||
import { ProgressionManager } from "../engine/ProgressionManager";
|
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||||
import GameUI from "../ui/GameUI";
|
import GameUI from "../ui/GameUI";
|
||||||
import { CameraController } from "./systems/CameraController";
|
import { CameraController } from "./systems/CameraController";
|
||||||
import { ItemManager } from "./systems/ItemManager";
|
import { ItemManager } from "./systems/ItemManager";
|
||||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||||
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
||||||
|
import { deEquipItem, equipItem } from "../engine/systems/EquipmentService";
|
||||||
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
||||||
import { ECSWorld } from "../engine/ecs/World";
|
import { ECSWorld } from "../engine/ecs/World";
|
||||||
import { SystemRegistry } from "../engine/ecs/System";
|
import { SystemRegistry } from "../engine/ecs/System";
|
||||||
@@ -30,6 +30,7 @@ import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
|||||||
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
||||||
import { EventBus } from "../engine/ecs/EventBus";
|
import { EventBus } from "../engine/ecs/EventBus";
|
||||||
import { generateLoot } from "../engine/systems/LootSystem";
|
import { generateLoot } from "../engine/systems/LootSystem";
|
||||||
|
import { renderSimEvents, getEffectColor, getEffectName, type EventRenderCallbacks } from "./systems/EventRenderer";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
private world!: World;
|
||||||
@@ -55,7 +56,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private isInventoryOpen = false;
|
private isInventoryOpen = false;
|
||||||
private isCharacterOpen = false;
|
private isCharacterOpen = false;
|
||||||
|
|
||||||
private entityManager!: EntityManager;
|
private entityAccessor!: EntityAccessor;
|
||||||
private progressionManager: ProgressionManager = new ProgressionManager();
|
private progressionManager: ProgressionManager = new ProgressionManager();
|
||||||
private targetingSystem!: TargetingSystem;
|
private targetingSystem!: TargetingSystem;
|
||||||
|
|
||||||
@@ -80,7 +81,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Initialize Sub-systems
|
// Initialize Sub-systems
|
||||||
this.dungeonRenderer = new DungeonRenderer(this);
|
this.dungeonRenderer = new DungeonRenderer(this);
|
||||||
this.cameraController = new CameraController(this.cameras.main);
|
this.cameraController = new CameraController(this.cameras.main);
|
||||||
this.itemManager = new ItemManager(this.world, this.entityManager);
|
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
|
||||||
|
this.itemManager = new ItemManager(this.world, this.entityAccessor);
|
||||||
this.targetingSystem = new TargetingSystem(this);
|
this.targetingSystem = new TargetingSystem(this);
|
||||||
|
|
||||||
// Launch UI Scene
|
// Launch UI Scene
|
||||||
@@ -147,7 +149,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.events.on("allocate-stat", (statName: string) => {
|
this.events.on("allocate-stat", (statName: string) => {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player) {
|
if (player) {
|
||||||
this.progressionManager.allocateStat(player, statName);
|
this.progressionManager.allocateStat(player, statName);
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
@@ -155,7 +157,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.events.on("allocate-passive", (nodeId: string) => {
|
this.events.on("allocate-passive", (nodeId: string) => {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player) {
|
if (player) {
|
||||||
this.progressionManager.allocatePassive(player, nodeId);
|
this.progressionManager.allocatePassive(player, nodeId);
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
@@ -179,7 +181,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.on("use-item", (data: { itemId: string }) => {
|
this.events.on("use-item", (data: { itemId: string }) => {
|
||||||
if (!this.awaitingPlayer) return;
|
if (!this.awaitingPlayer) return;
|
||||||
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (!player || !player.inventory) return;
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
|
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
|
||||||
@@ -232,7 +234,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
item.id,
|
item.id,
|
||||||
player.pos,
|
player.pos,
|
||||||
this.world,
|
this.world,
|
||||||
this.entityManager,
|
this.entityAccessor,
|
||||||
this.playerId,
|
this.playerId,
|
||||||
this.dungeonRenderer.seenArray,
|
this.dungeonRenderer.seenArray,
|
||||||
this.world.width,
|
this.world.width,
|
||||||
@@ -302,7 +304,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
item.id,
|
item.id,
|
||||||
player.pos,
|
player.pos,
|
||||||
this.world,
|
this.world,
|
||||||
this.entityManager,
|
this.entityAccessor,
|
||||||
this.playerId,
|
this.playerId,
|
||||||
this.dungeonRenderer.seenArray,
|
this.dungeonRenderer.seenArray,
|
||||||
this.world.width,
|
this.world.width,
|
||||||
@@ -315,7 +317,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => {
|
this.events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => {
|
||||||
if (!this.awaitingPlayer) return;
|
if (!this.awaitingPlayer) return;
|
||||||
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (!player || !player.inventory) return;
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
const item = this.itemManager.getItem(player, data.itemId);
|
const item = this.itemManager.getItem(player, data.itemId);
|
||||||
@@ -332,7 +334,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const targetX = player.pos.x + dx;
|
const targetX = player.pos.x + dx;
|
||||||
const targetY = player.pos.y + dy;
|
const targetY = player.pos.y + dy;
|
||||||
|
|
||||||
if (inBounds(this.world, targetX, targetY) && !isBlocked(this.world, targetX, targetY, this.entityManager)) {
|
if (inBounds(this.world, targetX, targetY) && !isBlocked(this.world, targetX, targetY, this.entityAccessor)) {
|
||||||
dropPos = { x: targetX, y: targetY };
|
dropPos = { x: targetX, y: targetY };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,43 +351,31 @@ export class GameScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.events.on("equip-item", (data: { itemId: string, slotKey: string }) => {
|
this.events.on("equip-item", (data: { itemId: string, slotKey: string }) => {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (!player || !player.inventory) return;
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
|
const item = player.inventory.items.find(it => it.id === data.itemId);
|
||||||
if (itemIdx === -1) return;
|
if (!item) return;
|
||||||
const item = player.inventory.items[itemIdx];
|
|
||||||
|
|
||||||
// Type check
|
const result = equipItem(player, item, data.slotKey as any);
|
||||||
const isValid = this.isItemValidForSlot(item, data.slotKey);
|
if (!result.success) {
|
||||||
if (!isValid) {
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, result.message ?? "Cannot equip!", "#ff0000");
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot equip there!", "#ff0000");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle swapping
|
|
||||||
if (!player.equipment) player.equipment = {};
|
|
||||||
const oldItem = (player.equipment as any)[data.slotKey];
|
|
||||||
if (oldItem) {
|
|
||||||
this.handleDeEquipItem(data.slotKey, player, false); // De-equip without emitting UI update yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to equipment
|
|
||||||
player.inventory.items.splice(itemIdx, 1);
|
|
||||||
(player.equipment as any)[data.slotKey] = item;
|
|
||||||
|
|
||||||
// Apply stats
|
|
||||||
this.applyItemStats(player, item, true);
|
|
||||||
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37");
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37");
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.events.on("de-equip-item", (data: { slotKey: string }) => {
|
this.events.on("de-equip-item", (data: { slotKey: string }) => {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (!player || !player.equipment) return;
|
if (!player || !player.equipment) return;
|
||||||
|
|
||||||
this.handleDeEquipItem(data.slotKey, player, true);
|
const removedItem = deEquipItem(player, data.slotKey as any);
|
||||||
|
if (removedItem) {
|
||||||
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${removedItem.name}`, "#aaaaaa");
|
||||||
|
this.emitUIUpdate();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Right Clicks to cancel targeting
|
// Right Clicks to cancel targeting
|
||||||
@@ -411,7 +401,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (this.targetingSystem.isActive) {
|
if (this.targetingSystem.isActive) {
|
||||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
const tx = Math.floor(p.worldX / TILE_SIZE);
|
||||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player) {
|
if (player) {
|
||||||
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
||||||
}
|
}
|
||||||
@@ -438,7 +428,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (this.targetingSystem.isActive) {
|
if (this.targetingSystem.isActive) {
|
||||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
const tx = Math.floor(p.worldX / TILE_SIZE);
|
||||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player) {
|
if (player) {
|
||||||
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
||||||
}
|
}
|
||||||
@@ -473,21 +463,19 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
|
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
|
||||||
|
|
||||||
const isEnemy = [...this.world.actors.values()].some(a =>
|
const isEnemy = this.entityAccessor.hasEnemyAt(tx, ty);
|
||||||
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
|
|
||||||
);
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
|
||||||
const dx = tx - player.pos.x;
|
const dx = tx - player.pos.x;
|
||||||
const dy = ty - player.pos.y;
|
const dy = ty - player.pos.y;
|
||||||
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
|
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
|
||||||
|
|
||||||
if (isEnemy && isDiagonalNeighbor) {
|
if (isEnemy && isDiagonalNeighbor) {
|
||||||
const targetId = [...this.world.actors.values()].find(
|
const enemy = this.entityAccessor.findEnemyAt(tx, ty);
|
||||||
a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
|
if (enemy) {
|
||||||
)?.id;
|
this.commitPlayerAction({ type: "attack", targetId: enemy.id });
|
||||||
if (targetId !== undefined) {
|
|
||||||
this.commitPlayerAction({ type: "attack", targetId });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,7 +485,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.dungeonRenderer.seenArray,
|
this.dungeonRenderer.seenArray,
|
||||||
{ ...player.pos },
|
{ ...player.pos },
|
||||||
{ x: tx, y: ty },
|
{ x: tx, y: ty },
|
||||||
{ ignoreBlockedTarget: isEnemy }
|
{ ignoreBlockedTarget: isEnemy, accessor: this.entityAccessor }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (path.length >= 2) this.playerPath = path;
|
if (path.length >= 2) this.playerPath = path;
|
||||||
@@ -511,7 +499,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Auto-walk one step per turn
|
// Auto-walk one step per turn
|
||||||
if (this.playerPath.length >= 2) {
|
if (this.playerPath.length >= 2) {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
const next = this.playerPath[1];
|
const next = this.playerPath[1];
|
||||||
const dx = next.x - player.pos.x;
|
const dx = next.x - player.pos.x;
|
||||||
const dy = next.y - player.pos.y;
|
const dy = next.y - player.pos.y;
|
||||||
@@ -521,13 +511,11 @@ export class GameScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlocked(this.world, next.x, next.y, this.entityManager)) {
|
if (isBlocked(this.world, next.x, next.y, this.entityAccessor)) {
|
||||||
const targetId = [...this.world.actors.values()].find(
|
const enemy = this.entityAccessor.findEnemyAt(next.x, next.y);
|
||||||
a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
|
|
||||||
)?.id;
|
|
||||||
|
|
||||||
if (targetId !== undefined) {
|
if (enemy) {
|
||||||
this.commitPlayerAction({ type: "attack", targetId });
|
this.commitPlayerAction({ type: "attack", targetId: enemy.id });
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@@ -562,16 +550,16 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.targetingSystem.cancel();
|
this.targetingSystem.cancel();
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
const targetX = player.pos.x + dx;
|
const targetX = player.pos.x + dx;
|
||||||
const targetY = player.pos.y + dy;
|
const targetY = player.pos.y + dy;
|
||||||
|
|
||||||
const targetId = [...this.world.actors.values()].find(
|
const enemy = this.entityAccessor.findEnemyAt(targetX, targetY);
|
||||||
a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
|
|
||||||
)?.id;
|
|
||||||
|
|
||||||
if (targetId !== undefined) {
|
if (enemy) {
|
||||||
action = { type: "attack", targetId };
|
action = { type: "attack", targetId: enemy.id };
|
||||||
} else {
|
} else {
|
||||||
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
||||||
action = { type: "move", dx, dy };
|
action = { type: "move", dx, dy };
|
||||||
@@ -590,6 +578,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const payload: UIUpdatePayload = {
|
const payload: UIUpdatePayload = {
|
||||||
world: this.world,
|
world: this.world,
|
||||||
playerId: this.playerId,
|
playerId: this.playerId,
|
||||||
|
player: this.entityAccessor.getPlayer(),
|
||||||
floorIndex: this.floorIndex,
|
floorIndex: this.floorIndex,
|
||||||
uiState: {
|
uiState: {
|
||||||
targetingItemId: this.targetingSystem.itemId
|
targetingItemId: this.targetingSystem.itemId
|
||||||
@@ -599,7 +588,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private commitPlayerAction(action: Action) {
|
private commitPlayerAction(action: Action) {
|
||||||
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
|
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
|
||||||
|
|
||||||
if (playerEvents.some(ev => ev.type === "move-blocked")) {
|
if (playerEvents.some(ev => ev.type === "move-blocked")) {
|
||||||
return;
|
return;
|
||||||
@@ -610,47 +599,31 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Check for pickups right after move (before enemy turn, so you get it efficiently)
|
// Check for pickups right after move (before enemy turn, so you get it efficiently)
|
||||||
if (action.type === "move") {
|
if (action.type === "move") {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
const pickedItem = this.itemManager.tryPickup(player);
|
const pickedItem = this.itemManager.tryPickup(player);
|
||||||
if (pickedItem) {
|
if (pickedItem) {
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync player position to ECS for trap detection
|
|
||||||
const playerEcs = this.ecsWorld.getEntitiesWith("player");
|
|
||||||
if (playerEcs.length > 0) {
|
|
||||||
const playerEcsId = playerEcs[0];
|
|
||||||
const ecsPos = this.ecsWorld.getComponent(playerEcsId, "position");
|
|
||||||
if (ecsPos) {
|
|
||||||
ecsPos.x = player.pos.x;
|
|
||||||
ecsPos.y = player.pos.y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process traps and status effects
|
// Process traps and status effects
|
||||||
|
console.log(`[GameScene] Processing traps. Player Pos: ${player.pos.x},${player.pos.y}`);
|
||||||
this.ecsRegistry.updateAll();
|
this.ecsRegistry.updateAll();
|
||||||
|
|
||||||
// Handle trap events from ECS
|
// Handle trap events from ECS
|
||||||
const trapEvents = this.ecsEventBus.drain();
|
const trapEvents = this.ecsEventBus.drain();
|
||||||
|
if (trapEvents.length > 0) {
|
||||||
|
console.log(`[GameScene] Traps triggered: ${trapEvents.length} events`, trapEvents);
|
||||||
|
}
|
||||||
for (const ev of trapEvents) {
|
for (const ev of trapEvents) {
|
||||||
if (ev.type === "trigger_activated") {
|
if (ev.type === "trigger_activated") {
|
||||||
// Get trap trigger data for status effect display
|
// Get trap trigger data for status effect display
|
||||||
const trapTrigger = this.ecsWorld.getComponent(ev.triggerId, "trigger");
|
const trapTrigger = this.ecsWorld.getComponent(ev.triggerId, "trigger");
|
||||||
|
|
||||||
if (trapTrigger?.effect) {
|
if (trapTrigger?.effect) {
|
||||||
// Show status effect text
|
const color = getEffectColor(trapTrigger.effect);
|
||||||
const effectColors: Record<string, string> = {
|
const text = getEffectName(trapTrigger.effect);
|
||||||
poison: "#00ff00",
|
|
||||||
burning: "#ff6600",
|
|
||||||
frozen: "#00ffff"
|
|
||||||
};
|
|
||||||
const effectNames: Record<string, string> = {
|
|
||||||
poison: "Poisoned!",
|
|
||||||
burning: "Burning!",
|
|
||||||
frozen: "Paralyzed!"
|
|
||||||
};
|
|
||||||
const color = effectColors[trapTrigger.effect] ?? "#ffffff";
|
|
||||||
const text = effectNames[trapTrigger.effect] ?? trapTrigger.effect;
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, text, color);
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, text, color);
|
||||||
}
|
}
|
||||||
} else if (ev.type === "damage") {
|
} else if (ev.type === "damage") {
|
||||||
@@ -665,12 +638,12 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
|
||||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
this.turnCount++;
|
this.turnCount++;
|
||||||
if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) {
|
if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player && player.stats.mana < player.stats.maxMana) {
|
if (player && player.stats.mana < player.stats.maxMana) {
|
||||||
const regenAmount = Math.min(
|
const regenAmount = Math.min(
|
||||||
GAME_CONFIG.mana.regenPerTurn,
|
GAME_CONFIG.mana.regenPerTurn,
|
||||||
@@ -680,44 +653,38 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
|
const renderCallbacks: EventRenderCallbacks = {
|
||||||
|
showDamage: (x, y, amount, isCrit, isBlock) => this.dungeonRenderer.showDamage(x, y, amount, isCrit, isBlock),
|
||||||
|
showDodge: (x, y) => this.dungeonRenderer.showDodge(x, y),
|
||||||
|
showHeal: (x, y, amount) => this.dungeonRenderer.showHeal(x, y, amount),
|
||||||
|
spawnCorpse: (x, y, type) => this.dungeonRenderer.spawnCorpse(x, y, type),
|
||||||
|
showWait: (x, y) => this.dungeonRenderer.showWait(x, y),
|
||||||
|
spawnOrb: (orbId, x, y) => this.dungeonRenderer.spawnOrb(orbId, x, y),
|
||||||
|
collectOrb: (actorId, amount, x, y) => this.dungeonRenderer.collectOrb(actorId, amount, x, y),
|
||||||
|
showLevelUp: (x, y) => this.dungeonRenderer.showLevelUp(x, y),
|
||||||
|
showAlert: (x, y) => this.dungeonRenderer.showAlert(x, y),
|
||||||
|
showFloatingText: (x, y, message, color) => this.dungeonRenderer.showFloatingText(x, y, message, color),
|
||||||
|
};
|
||||||
|
|
||||||
|
renderSimEvents(allEvents, renderCallbacks, {
|
||||||
|
playerId: this.playerId,
|
||||||
|
getPlayerPos: () => this.entityAccessor.getPlayerPos()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
||||||
for (const ev of allEvents) {
|
for (const ev of allEvents) {
|
||||||
if (ev.type === "damaged") {
|
if (ev.type === "killed" && ev.victimType && ev.victimType !== "player") {
|
||||||
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock);
|
const loot = generateLoot(Math.random, ev.victimType, this.floorIndex);
|
||||||
} else if (ev.type === "dodged") {
|
if (loot) {
|
||||||
this.dungeonRenderer.showDodge(ev.x, ev.y);
|
this.itemManager.spawnItem(loot, { x: ev.x, y: ev.y });
|
||||||
} else if (ev.type === "healed") {
|
this.dungeonRenderer.showFloatingText(ev.x, ev.y, `${loot.name}!`, "#ffd700");
|
||||||
this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount);
|
}
|
||||||
} else if (ev.type === "killed") {
|
}
|
||||||
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
}
|
||||||
|
|
||||||
// Try to drop loot from killed enemy
|
|
||||||
if (ev.victimType && ev.victimType !== "player") {
|
|
||||||
const loot = generateLoot(Math.random, ev.victimType, this.floorIndex);
|
|
||||||
if (loot) {
|
|
||||||
this.itemManager.spawnItem(loot, { x: ev.x, y: ev.y });
|
|
||||||
this.dungeonRenderer.showFloatingText(ev.x, ev.y, `${loot.name}!`, "#ffd700");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (ev.type === "waited" && ev.actorId === this.playerId) {
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
|
||||||
if (player) {
|
|
||||||
this.dungeonRenderer.showWait(player.pos.x, player.pos.y);
|
|
||||||
}
|
|
||||||
} else if (ev.type === "orb-spawned") {
|
|
||||||
this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y);
|
|
||||||
} else if (ev.type === "exp-collected" && ev.actorId === this.playerId) {
|
|
||||||
this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
|
||||||
} else if (ev.type === "leveled-up" && ev.actorId === this.playerId) {
|
|
||||||
this.dungeonRenderer.showLevelUp(ev.x, ev.y);
|
|
||||||
} else if (ev.type === "enemy-alerted") {
|
|
||||||
this.dungeonRenderer.showAlert(ev.x, ev.y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (!this.world.actors.has(this.playerId)) {
|
if (!this.entityAccessor.isPlayerAlive()) {
|
||||||
this.syncRunStateFromPlayer();
|
this.syncRunStateFromPlayer();
|
||||||
const uiScene = this.scene.get("GameUI") as GameUI;
|
const uiScene = this.scene.get("GameUI") as GameUI;
|
||||||
if (uiScene && 'showDeathScreen' in uiScene) {
|
if (uiScene && 'showDeathScreen' in uiScene) {
|
||||||
@@ -730,17 +697,19 @@ export class GameScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlayerOnExit(this.world, this.playerId)) {
|
if (isPlayerOnExit(this.world, this.entityAccessor)) {
|
||||||
this.syncRunStateFromPlayer();
|
this.syncRunStateFromPlayer();
|
||||||
this.floorIndex++;
|
this.floorIndex++;
|
||||||
this.loadFloor(this.floorIndex);
|
this.loadFloor(this.floorIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dungeonRenderer.computeFov(this.playerId);
|
this.dungeonRenderer.computeFov();
|
||||||
if (this.cameraController.isFollowing) {
|
if (this.cameraController.isFollowing) {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
this.cameraController.centerOnTile(player.pos.x, player.pos.y);
|
if (player) {
|
||||||
|
this.cameraController.centerOnTile(player.pos.x, player.pos.y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
this.dungeonRenderer.render(this.playerPath);
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
@@ -753,45 +722,50 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
|
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
this.entityManager = new EntityManager(this.world);
|
|
||||||
this.itemManager.updateWorld(this.world, this.entityManager);
|
// Initialize or update entity accessor
|
||||||
|
if (!this.entityAccessor) {
|
||||||
|
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
|
||||||
|
} else {
|
||||||
|
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
|
||||||
|
|
||||||
// Initialize ECS for traps and status effects
|
// Initialize ECS for traps and status effects
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
this.ecsEventBus = new EventBus();
|
this.ecsEventBus = new EventBus();
|
||||||
|
// Register systems
|
||||||
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
||||||
this.ecsRegistry.register(new TriggerSystem());
|
this.ecsRegistry.register(new TriggerSystem());
|
||||||
this.ecsRegistry.register(new StatusEffectSystem());
|
this.ecsRegistry.register(new StatusEffectSystem());
|
||||||
|
|
||||||
// Add player to ECS for trap detection
|
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
// No need to manually add player here anymore.
|
||||||
if (player) {
|
|
||||||
const playerEcsId = this.ecsWorld.createEntity();
|
|
||||||
this.ecsWorld.addComponent(playerEcsId, "position", { x: player.pos.x, y: player.pos.y });
|
|
||||||
this.ecsWorld.addComponent(playerEcsId, "stats", player.stats);
|
|
||||||
this.ecsWorld.addComponent(playerEcsId, "player", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
this.awaitingPlayer = false;
|
this.awaitingPlayer = false;
|
||||||
|
|
||||||
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
||||||
|
|
||||||
this.dungeonRenderer.initializeFloor(this.world, this.playerId, this.ecsWorld);
|
this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor);
|
||||||
|
|
||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
|
||||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
|
||||||
this.dungeonRenderer.computeFov(this.playerId);
|
this.dungeonRenderer.computeFov();
|
||||||
this.cameraController.centerOnTile(player.pos.x, player.pos.y);
|
const p = this.entityAccessor.getPlayer();
|
||||||
|
if (p) {
|
||||||
|
this.cameraController.centerOnTile(p.pos.x, p.pos.y);
|
||||||
|
}
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
this.dungeonRenderer.render(this.playerPath);
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncRunStateFromPlayer() {
|
private syncRunStateFromPlayer() {
|
||||||
const p = this.world.actors.get(this.playerId) as CombatantActor;
|
const p = this.entityAccessor.getPlayer();
|
||||||
if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return;
|
if (!p || !p.stats || !p.inventory) return;
|
||||||
|
|
||||||
this.runState = {
|
this.runState = {
|
||||||
stats: { ...p.stats },
|
stats: { ...p.stats },
|
||||||
@@ -808,19 +782,15 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.loadFloor(this.floorIndex);
|
this.loadFloor(this.floorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private executeThrow() {
|
private executeThrow() {
|
||||||
const success = this.targetingSystem.executeThrow(
|
const success = this.targetingSystem.executeThrow(
|
||||||
this.world,
|
this.world,
|
||||||
this.playerId,
|
this.playerId,
|
||||||
this.entityManager,
|
this.entityAccessor,
|
||||||
(blockedPos, hitActorId, item) => {
|
(blockedPos, hitActorId, item) => {
|
||||||
// Damage Logic
|
// Damage Logic
|
||||||
if (hitActorId !== undefined) {
|
if (hitActorId !== undefined) {
|
||||||
const victim = this.world.actors.get(hitActorId) as CombatantActor;
|
const victim = this.entityAccessor.getCombatant(hitActorId);
|
||||||
if (victim) {
|
if (victim) {
|
||||||
const stats = 'stats' in item ? item.stats : undefined;
|
const stats = 'stats' in item ? item.stats : undefined;
|
||||||
const dmg = (stats && 'attack' in stats) ? (stats.attack ?? 1) : 1;
|
const dmg = (stats && 'attack' in stats) ? (stats.attack ?? 1) : 1;
|
||||||
@@ -830,7 +800,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
// Projectile Visuals
|
// Projectile Visuals
|
||||||
let projectileId = item.id;
|
let projectileId = item.id;
|
||||||
@@ -883,63 +854,4 @@ export class GameScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isItemValidForSlot(item: any, slotKey: string): boolean {
|
|
||||||
if (!item || !item.type) return false;
|
|
||||||
if (item.type === "Weapon") return slotKey === "mainHand" || slotKey === "offHand";
|
|
||||||
if (item.type === "BodyArmour") return slotKey === "bodyArmour";
|
|
||||||
if (item.type === "Helmet") return slotKey === "helmet";
|
|
||||||
if (item.type === "Boots") return slotKey === "boots";
|
|
||||||
if (item.type === "Ring") return slotKey === "ringLeft" || slotKey === "ringRight";
|
|
||||||
if (item.type === "Belt") return slotKey === "belt";
|
|
||||||
if (item.type === "Offhand") return slotKey === "offHand";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyItemStats(player: CombatantActor, item: any, isAdding: boolean) {
|
|
||||||
if (!item.stats) return;
|
|
||||||
|
|
||||||
const modifier = isAdding ? 1 : -1;
|
|
||||||
|
|
||||||
// Apply stats from ArmourItem or MiscItem
|
|
||||||
if (item.stats.defense) player.stats.defense += item.stats.defense * modifier;
|
|
||||||
if (item.stats.attack) player.stats.attack += item.stats.attack * modifier;
|
|
||||||
if (item.stats.maxHp) {
|
|
||||||
const diff = item.stats.maxHp * modifier;
|
|
||||||
player.stats.maxHp += diff;
|
|
||||||
player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0));
|
|
||||||
}
|
|
||||||
if (item.stats.maxMana) {
|
|
||||||
const diff = item.stats.maxMana * modifier;
|
|
||||||
player.stats.maxMana += diff;
|
|
||||||
player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other secondary stats
|
|
||||||
if (item.stats.critChance) player.stats.critChance += item.stats.critChance * modifier;
|
|
||||||
if (item.stats.accuracy) player.stats.accuracy += item.stats.accuracy * modifier;
|
|
||||||
if (item.stats.evasion) player.stats.evasion += item.stats.evasion * modifier;
|
|
||||||
if (item.stats.blockChance) player.stats.blockChance += item.stats.blockChance * modifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDeEquipItem(slotKey: string, player: CombatantActor, emitUpdate: boolean) {
|
|
||||||
if (!player.equipment) return;
|
|
||||||
const item = (player.equipment as any)[slotKey];
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
// Remove from equipment
|
|
||||||
delete (player.equipment as any)[slotKey];
|
|
||||||
|
|
||||||
// Remove stats
|
|
||||||
this.applyItemStats(player, item, false);
|
|
||||||
|
|
||||||
// Add back to inventory
|
|
||||||
if (!player.inventory) player.inventory = { gold: 0, items: [] };
|
|
||||||
player.inventory.items.push(item);
|
|
||||||
|
|
||||||
if (emitUpdate) {
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${item.name}`, "#aaaaaa");
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,29 +40,29 @@ vi.mock('phaser', () => {
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
};
|
};
|
||||||
add = {
|
add = {
|
||||||
graphics: vi.fn(() => ({
|
graphics: vi.fn(function() { return {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
lineStyle: vi.fn(),
|
lineStyle: vi.fn(),
|
||||||
lineBetween: vi.fn(),
|
lineBetween: vi.fn(),
|
||||||
strokeRect: vi.fn(),
|
strokeRect: vi.fn(),
|
||||||
})),
|
}; }),
|
||||||
sprite: vi.fn(() => ({
|
sprite: vi.fn(function() { return {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
setAlpha: vi.fn().mockReturnThis(),
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
})),
|
}; }),
|
||||||
text: vi.fn(() => ({})),
|
text: vi.fn(function() { return {}; }),
|
||||||
rectangle: vi.fn(() => ({})),
|
rectangle: vi.fn(function() { return {}; }),
|
||||||
container: vi.fn(() => ({})),
|
container: vi.fn(function() { return {}; }),
|
||||||
};
|
};
|
||||||
load = {
|
load = {
|
||||||
spritesheet: vi.fn(),
|
spritesheet: vi.fn(),
|
||||||
};
|
};
|
||||||
anims = {
|
anims = {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
exists: vi.fn(() => true),
|
exists: vi.fn(function() { return true; }),
|
||||||
generateFrameNumbers: vi.fn(),
|
generateFrameNumbers: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -83,24 +83,37 @@ import { GameScene } from '../GameScene';
|
|||||||
import * as simulation from '../../engine/simulation/simulation';
|
import * as simulation from '../../engine/simulation/simulation';
|
||||||
import * as generator from '../../engine/world/generator';
|
import * as generator from '../../engine/world/generator';
|
||||||
|
|
||||||
|
vi.mock('../../engine/EntityAccessor', () => ({
|
||||||
|
EntityAccessor: class {
|
||||||
|
getPlayer = vi.fn(() => ({
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 1, y: 1 },
|
||||||
|
category: 'combatant',
|
||||||
|
stats: { hp: 10, maxHp: 10 }
|
||||||
|
}));
|
||||||
|
updateWorld = vi.fn();
|
||||||
|
isPlayerAlive = vi.fn(() => true);
|
||||||
|
getActor = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock other modules
|
// Mock other modules
|
||||||
vi.mock('../../rendering/DungeonRenderer', () => ({
|
vi.mock('../../rendering/DungeonRenderer', () => ({
|
||||||
DungeonRenderer: vi.fn().mockImplementation(function() {
|
DungeonRenderer: class {
|
||||||
return {
|
initializeFloor = vi.fn();
|
||||||
initializeFloor: vi.fn(),
|
computeFov = vi.fn();
|
||||||
computeFov: vi.fn(),
|
render = vi.fn();
|
||||||
render: vi.fn(),
|
showDamage = vi.fn();
|
||||||
showDamage: vi.fn(),
|
spawnCorpse = vi.fn();
|
||||||
spawnCorpse: vi.fn(),
|
showWait = vi.fn();
|
||||||
showWait: vi.fn(),
|
isMinimapVisible = vi.fn(() => false);
|
||||||
isMinimapVisible: vi.fn(() => false),
|
toggleMinimap = vi.fn();
|
||||||
toggleMinimap: vi.fn(),
|
updateTile = vi.fn();
|
||||||
updateTile: vi.fn(),
|
showProjectile = vi.fn();
|
||||||
showProjectile: vi.fn(),
|
showHeal = vi.fn();
|
||||||
showHeal: vi.fn(),
|
shakeCamera = vi.fn();
|
||||||
shakeCamera: vi.fn(),
|
showFloatingText = vi.fn();
|
||||||
};
|
},
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/simulation/simulation', () => ({
|
vi.mock('../../engine/simulation/simulation', () => ({
|
||||||
@@ -112,12 +125,33 @@ vi.mock('../../engine/world/generator', () => ({
|
|||||||
generateWorld: vi.fn(),
|
generateWorld: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/System', () => ({
|
||||||
|
SystemRegistry: class {
|
||||||
|
register = vi.fn();
|
||||||
|
updateAll = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/EventBus', () => ({
|
||||||
|
EventBus: class {
|
||||||
|
drain = vi.fn(() => []);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/systems/TriggerSystem', () => ({
|
||||||
|
TriggerSystem: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/systems/StatusEffectSystem', () => ({
|
||||||
|
StatusEffectSystem: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/world/world-logic', () => ({
|
vi.mock('../../engine/world/world-logic', () => ({
|
||||||
inBounds: vi.fn(() => true),
|
inBounds: vi.fn(function() { return true; }),
|
||||||
isBlocked: vi.fn(() => false),
|
isBlocked: vi.fn(function() { return false; }),
|
||||||
isPlayerOnExit: vi.fn(() => false),
|
isPlayerOnExit: vi.fn(function() { return false; }),
|
||||||
idx: vi.fn((w, x, y) => y * w.width + x),
|
idx: vi.fn(function(w: any, x: number, y: number) { return y * w.width + x; }),
|
||||||
tryDestructTile: vi.fn(() => false),
|
tryDestructTile: vi.fn(function() { return false; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('GameScene', () => {
|
describe('GameScene', () => {
|
||||||
@@ -150,27 +184,18 @@ describe('GameScene', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 },
|
exit: { x: 9, y: 9 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPlayer = {
|
|
||||||
id: 1,
|
|
||||||
isPlayer: true,
|
|
||||||
pos: { x: 1, y: 1 },
|
|
||||||
speed: 100,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
|
||||||
inventory: { gold: 0, items: [] },
|
|
||||||
};
|
|
||||||
mockWorld.actors.set(1, mockPlayer);
|
|
||||||
|
|
||||||
// Mock ecsWorld with required methods
|
// Mock ecsWorld with required methods
|
||||||
const mockEcsWorld = {
|
const mockEcsWorld = {
|
||||||
createEntity: vi.fn(() => 99),
|
createEntity: vi.fn(function() { return 99; }),
|
||||||
addComponent: vi.fn(),
|
addComponent: vi.fn(),
|
||||||
getComponent: vi.fn(),
|
getComponent: vi.fn(),
|
||||||
hasComponent: vi.fn(() => false),
|
hasComponent: vi.fn(function() { return false; }),
|
||||||
getEntitiesWith: vi.fn(() => []),
|
getEntitiesWith: vi.fn(function() { return []; }),
|
||||||
removeEntity: vi.fn(),
|
removeEntity: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,8 +215,8 @@ describe('GameScene', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger death screen when player is killed', () => {
|
it('should trigger death screen when player is killed', () => {
|
||||||
(simulation.applyAction as any).mockImplementation((world: any) => {
|
(simulation.applyAction as any).mockImplementation(() => {
|
||||||
world.actors.delete(1);
|
// world.actors.delete(1);
|
||||||
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +225,8 @@ describe('GameScene', () => {
|
|||||||
events: [],
|
events: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(scene as any).entityAccessor.isPlayerAlive = vi.fn(() => false);
|
||||||
|
|
||||||
(scene as any).commitPlayerAction({ type: 'wait' });
|
(scene as any).commitPlayerAction({ type: 'wait' });
|
||||||
|
|
||||||
expect(mockUI.showDeathScreen).toHaveBeenCalled();
|
expect(mockUI.showDeathScreen).toHaveBeenCalled();
|
||||||
|
|||||||
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 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
|
* Result of attempting to use an item
|
||||||
@@ -16,26 +18,29 @@ export interface ItemUseResult {
|
|||||||
*/
|
*/
|
||||||
export class ItemManager {
|
export class ItemManager {
|
||||||
private world: World;
|
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.world = world;
|
||||||
this.entityManager = entityManager;
|
this.entityAccessor = entityAccessor;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update references when world changes (e.g., new floor)
|
* 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.world = world;
|
||||||
this.entityManager = entityManager;
|
this.entityAccessor = entityAccessor;
|
||||||
|
if (ecsWorld) this.ecsWorld = ecsWorld;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn an item drop at the specified position
|
* Spawn an item drop at the specified position
|
||||||
*/
|
*/
|
||||||
spawnItem(item: Item, pos: Vec2): void {
|
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)
|
// Deep clone item (crucial for items with mutable stats like ammo)
|
||||||
const clonedItem = { ...item } as Item;
|
const clonedItem = { ...item } as Item;
|
||||||
@@ -43,15 +48,11 @@ export class ItemManager {
|
|||||||
(clonedItem as any).stats = { ...clonedItem.stats };
|
(clonedItem as any).stats = { ...clonedItem.stats };
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = this.entityManager.getNextId();
|
// ECS Path: Spawn using EntityBuilder
|
||||||
const drop: ItemDropActor = {
|
EntityBuilder.create(this.ecsWorld)
|
||||||
id,
|
.withPosition(pos.x, pos.y)
|
||||||
pos: { x: pos.x, y: pos.y },
|
.asGroundItem(clonedItem)
|
||||||
category: "item_drop",
|
.build();
|
||||||
item: clonedItem
|
|
||||||
};
|
|
||||||
|
|
||||||
this.entityManager.addActor(drop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,15 +62,19 @@ export class ItemManager {
|
|||||||
tryPickup(player: CombatantActor): Item | null {
|
tryPickup(player: CombatantActor): Item | null {
|
||||||
if (!player || !player.inventory) return null;
|
if (!player || !player.inventory) return null;
|
||||||
|
|
||||||
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
|
let itemActor: ItemDropActor | null = null;
|
||||||
const itemActor = actors.find((a): a is ItemDropActor => a.category === "item_drop");
|
|
||||||
|
// Use EntityAccessor to find item on the ground
|
||||||
|
if (this.entityAccessor) {
|
||||||
|
itemActor = this.entityAccessor.findItemDropAt(player.pos.x, player.pos.y);
|
||||||
|
}
|
||||||
|
|
||||||
if (itemActor) {
|
if (itemActor) {
|
||||||
const item = itemActor.item;
|
const item = itemActor.item;
|
||||||
const result = this.addItem(player, item);
|
const result = this.addItem(player, item);
|
||||||
|
|
||||||
// Remove from world
|
// Remove from world
|
||||||
this.entityManager.removeActor(itemActor.id);
|
this.entityAccessor.removeActor(itemActor.id);
|
||||||
|
|
||||||
console.log("Picked up:", item.name);
|
console.log("Picked up:", item.name);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Phaser from "phaser";
|
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 { TILE_SIZE } from "../../core/constants";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { UI_CONFIG } from "../../core/config/ui";
|
import { UI_CONFIG } from "../../core/config/ui";
|
||||||
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
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.
|
* Manages targeting mode for thrown items.
|
||||||
@@ -19,7 +19,7 @@ export class TargetingSystem {
|
|||||||
|
|
||||||
// Context for predictive visual
|
// Context for predictive visual
|
||||||
private world: World | null = null;
|
private world: World | null = null;
|
||||||
private entityManager: EntityManager | null = null;
|
private accessor: EntityAccessor | null = null;
|
||||||
private playerId: EntityId | null = null;
|
private playerId: EntityId | null = null;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
@@ -40,7 +40,7 @@ export class TargetingSystem {
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
playerPos: Vec2,
|
playerPos: Vec2,
|
||||||
world: World,
|
world: World,
|
||||||
entityManager: EntityManager,
|
accessor: EntityAccessor,
|
||||||
playerId: EntityId,
|
playerId: EntityId,
|
||||||
seenArray: Uint8Array,
|
seenArray: Uint8Array,
|
||||||
worldWidth: number,
|
worldWidth: number,
|
||||||
@@ -48,12 +48,12 @@ export class TargetingSystem {
|
|||||||
): void {
|
): void {
|
||||||
this.targetingItemId = itemId;
|
this.targetingItemId = itemId;
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.entityManager = entityManager;
|
this.accessor = accessor;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
this.active = true;
|
this.active = true;
|
||||||
|
|
||||||
// Auto-target closest visible enemy
|
// Auto-target closest visible enemy
|
||||||
const closest = getClosestVisibleEnemy(world, playerPos, seenArray, worldWidth);
|
const closest = getClosestVisibleEnemy(playerPos, seenArray, worldWidth, accessor);
|
||||||
|
|
||||||
if (closest) {
|
if (closest) {
|
||||||
this.cursor = closest;
|
this.cursor = closest;
|
||||||
@@ -84,14 +84,14 @@ export class TargetingSystem {
|
|||||||
executeThrow(
|
executeThrow(
|
||||||
world: World,
|
world: World,
|
||||||
playerId: EntityId,
|
playerId: EntityId,
|
||||||
entityManager: EntityManager,
|
accessor: EntityAccessor,
|
||||||
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
|
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!this.active || !this.targetingItemId || !this.cursor) {
|
if (!this.active || !this.targetingItemId || !this.cursor) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const player = world.actors.get(playerId) as CombatantActor;
|
const player = accessor.getCombatant(playerId);
|
||||||
if (!player || !player.inventory) return false;
|
if (!player || !player.inventory) return false;
|
||||||
|
|
||||||
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
|
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
|
||||||
@@ -116,7 +116,7 @@ export class TargetingSystem {
|
|||||||
const start = player.pos;
|
const start = player.pos;
|
||||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
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;
|
const { blockedPos, hitActorId } = result;
|
||||||
|
|
||||||
// Call the callback with throw results
|
// Call the callback with throw results
|
||||||
@@ -133,7 +133,7 @@ export class TargetingSystem {
|
|||||||
this.targetingItemId = null;
|
this.targetingItemId = null;
|
||||||
this.cursor = null;
|
this.cursor = null;
|
||||||
this.world = null;
|
this.world = null;
|
||||||
this.entityManager = null;
|
this.accessor = null;
|
||||||
this.playerId = null;
|
this.playerId = null;
|
||||||
this.graphics.clear();
|
this.graphics.clear();
|
||||||
this.crosshairSprite.setVisible(false);
|
this.crosshairSprite.setVisible(false);
|
||||||
@@ -184,8 +184,8 @@ export class TargetingSystem {
|
|||||||
let finalEndX = aimEndX;
|
let finalEndX = aimEndX;
|
||||||
let finalEndY = aimEndY;
|
let finalEndY = aimEndY;
|
||||||
|
|
||||||
if (this.world && this.entityManager && this.playerId !== null) {
|
if (this.world && this.accessor && this.playerId !== null) {
|
||||||
const result = traceProjectile(this.world, playerPos, this.cursor, this.entityManager, this.playerId);
|
const result = traceProjectile(this.world, playerPos, this.cursor, this.accessor, this.playerId);
|
||||||
const bPos = result.blockedPos;
|
const bPos = result.blockedPos;
|
||||||
|
|
||||||
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
|
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';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
// Mock Phaser
|
// Mock Phaser
|
||||||
@@ -18,6 +19,10 @@ vi.mock('phaser', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
|
GameObjects: {
|
||||||
|
Sprite: vi.fn(() => mockSprite),
|
||||||
|
Graphics: vi.fn(() => mockGraphics),
|
||||||
|
},
|
||||||
Scene: class {
|
Scene: class {
|
||||||
add = {
|
add = {
|
||||||
graphics: vi.fn(() => mockGraphics),
|
graphics: vi.fn(() => mockGraphics),
|
||||||
@@ -37,11 +42,11 @@ vi.mock('../../../engine/gameplay/CombatLogic', () => ({
|
|||||||
import { TargetingSystem } from '../TargetingSystem';
|
import { TargetingSystem } from '../TargetingSystem';
|
||||||
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
|
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
|
||||||
import { TILE_SIZE } from '../../../core/constants';
|
import { TILE_SIZE } from '../../../core/constants';
|
||||||
|
import type { EntityId } from '../../../core/types';
|
||||||
|
|
||||||
describe('TargetingSystem', () => {
|
describe('TargetingSystem', () => {
|
||||||
let targetingSystem: TargetingSystem;
|
let targetingSystem: TargetingSystem;
|
||||||
let mockWorld: any;
|
let mockWorld: any;
|
||||||
let mockEntityManager: any;
|
|
||||||
let mockScene: any;
|
let mockScene: any;
|
||||||
let mockGraphics: any;
|
let mockGraphics: any;
|
||||||
let mockSprite: any;
|
let mockSprite: any;
|
||||||
@@ -72,7 +77,6 @@ describe('TargetingSystem', () => {
|
|||||||
|
|
||||||
targetingSystem = new TargetingSystem(mockScene);
|
targetingSystem = new TargetingSystem(mockScene);
|
||||||
mockWorld = { width: 10, height: 10 };
|
mockWorld = { width: 10, height: 10 };
|
||||||
mockEntityManager = {};
|
|
||||||
|
|
||||||
// Default return for traceProjectile
|
// Default return for traceProjectile
|
||||||
(traceProjectile as any).mockReturnValue({
|
(traceProjectile as any).mockReturnValue({
|
||||||
@@ -97,8 +101,8 @@ describe('TargetingSystem', () => {
|
|||||||
'item-1',
|
'item-1',
|
||||||
playerPos,
|
playerPos,
|
||||||
mockWorld,
|
mockWorld,
|
||||||
mockEntityManager!,
|
{} as any, // accessor
|
||||||
1 as any,
|
1 as EntityId, // playerId
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
@@ -118,8 +122,8 @@ describe('TargetingSystem', () => {
|
|||||||
'item-1',
|
'item-1',
|
||||||
playerPos,
|
playerPos,
|
||||||
mockWorld,
|
mockWorld,
|
||||||
mockEntityManager!,
|
{} as any, // accessor
|
||||||
1 as any,
|
1 as EntityId, // playerId
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10,
|
10,
|
||||||
mousePos
|
mousePos
|
||||||
@@ -144,8 +148,8 @@ describe('TargetingSystem', () => {
|
|||||||
'item-1',
|
'item-1',
|
||||||
playerPos,
|
playerPos,
|
||||||
mockWorld,
|
mockWorld,
|
||||||
mockEntityManager!,
|
{} as any, // accessor
|
||||||
1 as any,
|
1 as EntityId,
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10,
|
10,
|
||||||
targetPos
|
targetPos
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type CombatantActor, type Stats, type UIUpdatePayload } from "../core/types";
|
import { type Stats, type UIUpdatePayload } from "../core/types";
|
||||||
import { HudComponent } from "./components/HudComponent";
|
import { HudComponent } from "./components/HudComponent";
|
||||||
import { MenuComponent } from "./components/MenuComponent";
|
import { MenuComponent } from "./components/MenuComponent";
|
||||||
import { InventoryOverlay } from "./components/InventoryOverlay";
|
import { InventoryOverlay } from "./components/InventoryOverlay";
|
||||||
@@ -28,7 +28,6 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
this.actionButtons = new ActionButtonComponent(this);
|
this.actionButtons = new ActionButtonComponent(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.hud.create();
|
this.hud.create();
|
||||||
this.menu.create();
|
this.menu.create();
|
||||||
@@ -40,7 +39,6 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
|
|
||||||
const gameScene = this.scene.get("GameScene");
|
const gameScene = this.scene.get("GameScene");
|
||||||
|
|
||||||
|
|
||||||
// Listen for updates from GameScene
|
// Listen for updates from GameScene
|
||||||
gameScene.events.on("update-ui", (payload: UIUpdatePayload) => {
|
gameScene.events.on("update-ui", (payload: UIUpdatePayload) => {
|
||||||
this.updateUI(payload);
|
this.updateUI(payload);
|
||||||
@@ -91,14 +89,12 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
gameScene.events.emit("character-toggled", this.character.isOpen);
|
gameScene.events.emit("character-toggled", this.character.isOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
||||||
this.death.show(data);
|
this.death.show(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUI(payload: UIUpdatePayload) {
|
private updateUI(payload: UIUpdatePayload) {
|
||||||
const { world, playerId, floorIndex, uiState } = payload;
|
const { player, floorIndex, uiState } = payload;
|
||||||
const player = world.actors.get(playerId) as CombatantActor;
|
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
this.hud.update(player.stats, floorIndex);
|
this.hud.update(player.stats, floorIndex);
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gameScene = this.scene.scene.get("GameScene") as any;
|
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||||
const player = gameScene.world.actors.get(gameScene.playerId);
|
const player = gameScene.entityAccessor.getPlayer();
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
let item: any = null;
|
let item: any = null;
|
||||||
@@ -684,7 +684,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
|
|
||||||
const gameUI = this.scene as any;
|
const gameUI = this.scene as any;
|
||||||
const gameScene = this.scene.scene.get("GameScene") as any;
|
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||||
const player = gameScene.world.actors.get(gameScene.playerId);
|
const player = gameScene.entityAccessor.getPlayer();
|
||||||
|
|
||||||
const item = isFromBackpack ? player.inventory.items[startIndex!] : (player.equipment as any)[startEqKey!];
|
const item = isFromBackpack ? player.inventory.items[startIndex!] : (player.equipment as any)[startEqKey!];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user