Compare commits

...

28 Commits

Author SHA1 Message Date
b0dd090a60 Multidirectional map generation 2026-02-07 15:24:11 +11:00
88017add92 Fixed zooming bug black lines again 2026-02-07 14:36:18 +11:00
72c4251fc4 Added more side rooms, 8-10 per level 2026-02-07 14:30:53 +11:00
319ce20b6a fixed vertical black line bug 2026-02-07 14:08:17 +11:00
72d0f5d576 Fixed door saying open bugs 2026-02-07 13:34:12 +11:00
da544438e1 Fixed bug with vision when standing in doorway 2026-02-07 13:04:49 +11:00
02f850da35 Fixed coprse bug 2026-02-07 12:54:25 +11:00
4b50e341a7 Half changes to switch to exit level, Ran out of credits, re added enemies 2026-01-31 14:56:53 +11:00
f6fc057e4f Double level size, fixed skitzo track path 2026-01-31 14:17:08 +11:00
43b33733e9 Added rail tracks, cart and camera movement with arrow keys, removed enemies... 4 now 2026-01-31 13:47:34 +11:00
b18e2d08ba Character sprite switching - directionality - added dragon head 2026-01-31 10:58:12 +11:00
58b3726d21 Merge branch 'master' of https://gitea.peterstockings.com/peterstockings/rogue 2026-01-30 17:49:25 +11:00
41909fd8e6 Added flamethrower with buring effects 2026-01-30 17:49:23 +11:00
Peter Stockings
3a656c46fc Fix broken tests 2026-01-28 20:18:44 +11:00
c06823e08b Provided WASD movement 2026-01-28 19:07:22 +11:00
80e82f68a0 changed visual movement speed to be slower and made diagonal movement with arrow keys work 2026-01-28 18:59:35 +11:00
f01d8de15c Auto reload last reloadble weapon when reload is triggered 2026-01-28 18:32:52 +11:00
90aebc892a Make it so you cant shoot yourself 2026-01-28 18:19:46 +11:00
5d33d0e660 Added pregress bar for reloading 2026-01-28 18:06:42 +11:00
fc18008656 Add Billys assets (WIP) 2026-01-28 17:38:46 +11:00
Peter Stockings
c105719e4a Remove use of any in PlayerInputHandler 2026-01-27 20:55:03 +11:00
Peter Stockings
34554aa051 refactor game scene 2026-01-27 20:38:48 +11:00
Peter Stockings
2493d37c7a fix: when applying or cancelling upgrade clear border effect on items 2026-01-27 18:03:11 +11:00
Peter Stockings
cdedf47e0d Ensure that damage takes into effect stat bonuses from equipment 2026-01-27 17:48:20 +11:00
Peter Stockings
165cde6ca3 Add reload logic for ranged weapons 2026-01-27 17:35:34 +11:00
Peter Stockings
7260781f38 Hide sprites of corpses when in fog of war 2026-01-27 15:56:32 +11:00
Peter Stockings
a15bb3675b Shot trap status/damage of affected entity rather then just player 2026-01-27 14:14:27 +11:00
Peter Stockings
ef7d85750f Begin refactoring GameScene 2026-01-27 13:46:19 +11:00
78 changed files with 6992 additions and 3529 deletions

BIN
assets/ArtStyleTesting.kra Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -38,12 +38,13 @@ export const GAME_CONFIG = {
passiveNodes: [] as string[] passiveNodes: [] as string[]
}, },
speed: 100, speed: 100,
viewRadius: 8 viewRadius: 8,
reloadDuration: 3,
}, },
map: { map: {
width: 60, width: 120,
height: 40, height: 80,
minRooms: 8, minRooms: 8,
maxRooms: 13, maxRooms: 13,
roomMinWidth: 5, roomMinWidth: 5,
@@ -72,10 +73,16 @@ export const GAME_CONFIG = {
}, },
enemyScaling: { enemyScaling: {
baseCount: 3, baseCount: 15,
baseCountPerFloor: 3, baseCountPerFloor: 5,
hpPerFloor: 5, hpPerFloor: 5,
attackPerTwoFloors: 1, attackPerTwoFloors: 1,
expMultiplier: 1.2
},
trapScaling: {
baseCount: 0,
baseCountPerFloor: 0.5
}, },
leveling: { leveling: {
@@ -96,9 +103,9 @@ export const GAME_CONFIG = {
rendering: { rendering: {
tileSize: 16, tileSize: 16,
cameraZoom: 2, cameraZoom: 2,
minZoom: 0.5, minZoom: 1,
maxZoom: 4, maxZoom: 4,
zoomStep: 0.1, zoomStep: 1,
wallColor: 0x2b2b2b, wallColor: 0x2b2b2b,
floorColor: 0x161616, floorColor: 0x161616,
exitColor: 0xffd166, exitColor: 0xffd166,
@@ -129,11 +136,12 @@ export const GAME_CONFIG = {
horizontal: 54, horizontal: 54,
vertical: 55, vertical: 55,
turning: 56 turning: 56
} },
moveDuration: 62 // Visual duration for movement in ms
}, },
ui: { ui: {
// ... rest of content ... // ... rest of content ...
minimapPanelWidth: 340, minimapPanelWidth: 340,
minimapPanelHeight: 220, minimapPanelHeight: 220,
minimapPadding: 20, minimapPadding: 20,
@@ -150,7 +158,15 @@ export const GAME_CONFIG = {
gameplay: { gameplay: {
energyThreshold: 100, energyThreshold: 100,
actionCost: 100 actionCost: 100,
ceramicDragonHead: {
range: 4,
initialDamage: 7,
burnDamage: 3,
burnDuration: 5,
rechargeTurns: 20,
maxCharges: 3
}
}, },
assets: { assets: {
@@ -165,8 +181,20 @@ export const GAME_CONFIG = {
], ],
images: [ images: [
{ key: "splash_bg", path: "assets/ui/splash_bg.png" }, { key: "splash_bg", path: "assets/ui/splash_bg.png" },
{ key: "character_outline", path: "assets/ui/character_outline.png" } { key: "character_outline", path: "assets/ui/character_outline.png" },
{ key: "ceramic_dragon_head", path: "assets/sprites/items/ceramic_dragon_head.png" },
{ key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" },
{ key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" },
{ key: "PriestessEast", path: "assets/sprites/priestess/PriestessEast.png" },
{ key: "PriestessWest", path: "assets/sprites/priestess/PriestessWest.png" },
{ key: "mine_cart", path: "assets/sprites/items/mine_cart.png" },
{ key: "track_straight", path: "assets/sprites/items/track_straight.png" },
{ key: "track_corner", path: "assets/sprites/items/track_corner.png" },
{ key: "track_vertical", path: "assets/sprites/items/track_vertical.png" },
{ key: "track_switch", path: "assets/sprites/items/track_switch.png" }
] ]
}, },
animations: [ animations: [

View File

@@ -3,8 +3,10 @@ import type {
MeleeWeaponItem, MeleeWeaponItem,
RangedWeaponItem, RangedWeaponItem,
ArmourItem, ArmourItem,
AmmoItem AmmoItem,
CeramicDragonHeadItem
} from "../types"; } from "../types";
import { GAME_CONFIG } from "../config/GameConfig";
// ============================================================================= // =============================================================================
// Per-Type Template Lists (Immutable) // Per-Type Template Lists (Immutable)
@@ -157,6 +159,7 @@ export function createRangedWeapon(
textureKey: t.textureKey, textureKey: t.textureKey,
spriteIndex: t.spriteIndex, spriteIndex: t.spriteIndex,
currentAmmo: t.magazineSize, currentAmmo: t.magazineSize,
reloadingTurnsLeft: 0,
variant, variant,
stats: { stats: {
attack: t.attack + attackBonus, attack: t.attack + attackBonus,
@@ -243,6 +246,24 @@ export function createUpgradeScroll(quantity = 1): ConsumableItem {
}; };
} }
export function createCeramicDragonHead(): CeramicDragonHeadItem {
const config = GAME_CONFIG.gameplay.ceramicDragonHead;
return {
id: "ceramic_dragon_head",
name: "Ceramic Dragon Head",
type: "Weapon",
weaponType: "ceramic_dragon_head",
textureKey: "ceramic_dragon_head",
spriteIndex: 0,
charges: config.maxCharges,
maxCharges: config.maxCharges,
lastRechargeTurn: 0,
stats: {
attack: config.initialDamage,
range: config.range,
},
};
}
// Legacy export for backward compatibility during migration // Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES; export const ITEMS = ALL_TEMPLATES;

View File

@@ -8,9 +8,13 @@ export const TileType = {
EXIT: 8, EXIT: 8,
WATER: 63, // Unused but kept for safety/legacy WATER: 63, // Unused but kept for safety/legacy
DOOR_CLOSED: 5, DOOR_CLOSED: 5,
DOOR_OPEN: 6 DOOR_OPEN: 6,
TRACK: 30, // Restored to 30 to fix duplicate key error
SWITCH_OFF: 31,
SWITCH_ON: 32
} as const; } as const;
export type TileType = typeof TileType[keyof typeof TileType]; export type TileType = typeof TileType[keyof typeof TileType];
export interface TileBehavior { export interface TileBehavior {
@@ -32,9 +36,13 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false }, [TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false }, [TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN }, [TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED } [TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED },
[TileType.TRACK]: { id: TileType.TRACK, isBlocking: false, isDestructible: false },
[TileType.SWITCH_OFF]: { id: TileType.SWITCH_OFF, isBlocking: true, isDestructible: false },
[TileType.SWITCH_ON]: { id: TileType.SWITCH_ON, isBlocking: true, isDestructible: false }
}; };
export function isBlocking(tile: number): boolean { export function isBlocking(tile: number): boolean {
const def = TILE_DEFINITIONS[tile]; const def = TILE_DEFINITIONS[tile];
return def ? def.isBlocking : false; return def ? def.isBlocking : false;

View File

@@ -27,7 +27,8 @@ export type SimEvent =
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number } | { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number } | { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number } | { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
| { type: "move-blocked"; actorId: EntityId; x: number; y: number }; | { type: "move-blocked"; actorId: EntityId; x: number; y: number }
| { type: "mission-complete" };
export type Stats = { export type Stats = {
@@ -93,7 +94,7 @@ export interface MeleeWeaponItem extends BaseItem {
type: "Weapon"; type: "Weapon";
weaponType: "melee"; weaponType: "melee";
stats: { stats: {
attack: number; attack: number;
}; };
} }
@@ -101,30 +102,44 @@ export interface RangedWeaponItem extends BaseItem {
type: "Weapon"; type: "Weapon";
weaponType: "ranged"; weaponType: "ranged";
currentAmmo: number; // Runtime state - moved to top level for easier access currentAmmo: number; // Runtime state - moved to top level for easier access
reloadingTurnsLeft: number;
stats: { stats: {
attack: number; attack: number;
range: number; range: number;
magazineSize: number; magazineSize: number;
ammoType: string; ammoType: string;
projectileSpeed: number; projectileSpeed: number;
fireSound?: string; fireSound?: string;
}; };
} }
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem; export interface CeramicDragonHeadItem extends BaseItem {
type: "Weapon";
weaponType: "ceramic_dragon_head";
charges: number;
maxCharges: number;
lastRechargeTurn: number;
stats: {
attack: number;
range: number;
};
}
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem | CeramicDragonHeadItem;
export interface ArmourItem extends BaseItem { export interface ArmourItem extends BaseItem {
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots"; type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
stats: { stats: {
defense: number; defense: number;
}; };
} }
export interface ConsumableItem extends BaseItem { export interface ConsumableItem extends BaseItem {
type: "Consumable"; type: "Consumable";
stats?: { stats?: {
hp?: number; hp?: number;
attack?: number; attack?: number;
}; };
throwable?: boolean; throwable?: boolean;
} }
@@ -162,6 +177,8 @@ export type Inventory = {
export type RunState = { export type RunState = {
stats: Stats; stats: Stats;
inventory: Inventory; inventory: Inventory;
seed: number;
lastReloadableWeaponId?: string | null;
}; };
export interface BaseActor { export interface BaseActor {
@@ -195,9 +212,9 @@ export interface CollectibleActor extends BaseActor {
} }
export interface ItemDropActor extends BaseActor { export interface ItemDropActor extends BaseActor {
category: "item_drop"; category: "item_drop";
// type: string; // "health_potion", etc. or reuse Item // type: string; // "health_potion", etc. or reuse Item
item: Item; item: Item;
} }
export type Actor = CombatantActor | CollectibleActor | ItemDropActor; export type Actor = CombatantActor | CollectibleActor | ItemDropActor;
@@ -206,15 +223,16 @@ export type World = {
width: number; width: number;
height: number; height: number;
tiles: Tile[]; tiles: Tile[];
actors: Map<EntityId, Actor>;
exit: Vec2; exit: Vec2;
trackPath: 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;
}; };
} }

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

View File

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

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { applyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId } from '../../core/types';
import { EntityAccessor } from '../EntityAccessor';
import { ECSWorld } from '../ecs/World';
import { TileType } from '../../core/terrain';
const createTestWorld = (): World => {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 },
trackPath: []
};
};
describe('Multi-step Door Walkthrough Bug', () => {
let ecsWorld: ECSWorld;
let world: World;
beforeEach(() => {
ecsWorld = new ECSWorld();
world = createTestWorld();
});
it('door should close after player walks through and moves away', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,3)
const doorIdx = 3 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door
console.log("Step 1: Moving onto door at (4,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Move off the door to (5,3)
console.log("Step 2: Moving off door to (5,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 5, y: 3 });
// This is where it's reported to stay open sometimes
console.log("Door tile state after Step 2:", world.tiles[doorIdx]);
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
// 3. Move further away to (6,3)
console.log("Step 3: Moving further away to (6,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 6, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
});
it('door should close after player walks through it diagonally', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,4)
const doorIdx = 4 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door diagonally
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 4 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Move off the door diagonally to (5,5)
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
expect(player.pos).toEqual({ x: 5, y: 5 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
});
it('door should stay open while player is standing on it (wait action)', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,3)
const doorIdx = 3 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Wait on the door
applyAction(world, playerId, { type: "wait" }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
});
});

View File

@@ -0,0 +1,276 @@
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 },
trackPath: []
};
}
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 });
});
});
});

View File

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

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

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

View File

@@ -1,19 +1,19 @@
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 } trackPath: []
}; };
}; };
const createTestStats = (overrides: Partial<any> = {}) => ({ const createTestStats = (overrides: Partial<any> = {}) => ({
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
@@ -23,7 +23,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 +63,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 +108,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");
}); });
}); });
}); });
@@ -103,31 +133,32 @@ describe('AI Behavior & Scheduling', () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
describe('AI Aggression State Machine', () => { describe('AI Aggression State Machine', () => {
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 +169,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");
}); });
}); });
}); });

View File

@@ -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,41 @@ 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 } trackPath: []
}; };
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 +71,18 @@ 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 } trackPath: []
}; };
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 +90,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 +98,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 +125,18 @@ 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 } trackPath: []
}; };
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 +144,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 +152,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 });
}); });
}); });

View File

@@ -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', () => {
@@ -20,9 +22,9 @@ describe('World Generator', () => {
const { world } = generateWorld(1, runState); const { world } = generateWorld(1, runState);
expect(world.width).toBe(60); expect(world.width).toBe(120);
expect(world.height).toBe(40); expect(world.height).toBe(80);
expect(world.tiles.length).toBe(60 * 40); expect(world.tiles.length).toBe(120 * 80);
}); });
it('should place player actor', () => { it('should place player actor', () => {
@@ -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;
@@ -242,8 +252,8 @@ describe('World Generator', () => {
const { world } = generateWorld(10, runState); const { world } = generateWorld(10, runState);
// Basic validity checks // Basic validity checks
expect(world.width).toBe(60); expect(world.width).toBe(120);
expect(world.height).toBe(40); expect(world.height).toBe(80);
expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true); expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true);
expect(world.tiles.some(t => t === TileType.WALL)).toBe(true); expect(world.tiles.some(t => t === TileType.WALL)).toBe(true);
}); });
@@ -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) => {
@@ -295,19 +307,20 @@ describe('World Generator', () => {
}); });
it('should verify safe spawn logic on caves', () => { it('should verify safe spawn logic on caves', () => {
const runState = { const runState = {
stats: { stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20, maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: [] passiveNodes: []
}, },
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);
}); });
}); });
}); });

View File

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

View File

@@ -1,15 +1,18 @@
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 } trackPath: []
}); });
it('should find a path between two reachable points', () => { it('should find a path between two reachable points', () => {
@@ -36,7 +39,7 @@ describe('Pathfinding', () => {
it('should return empty array if no path exists', () => { it('should return empty array if no path exists', () => {
const world = createTestWorld(10, 10); const world = createTestWorld(10, 10);
// Create a wall blockage // Create a wall blockage
for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL; for (let x = 0; x < 10; x++) world.tiles[10 + x] = TileType.WALL;
const seen = new Uint8Array(100).fill(1); const seen = new Uint8Array(100).fill(1);
@@ -47,23 +50,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 });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,25 @@
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 } trackPath: []
}; };
}; };
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 +27,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 +44,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 });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked, tryDestructTile, isPlayerOnExit } from '../world/world-logic'; import { idx, inBounds, isWall, isBlocked, tryDestructTile } from '../world/world-logic';
import { type World, type Tile } from '../../core/types'; import { type World, type Tile } from '../../core/types';
import { TileType } from '../../core/terrain'; import { TileType } from '../../core/terrain';
@@ -9,8 +9,8 @@ describe('World Utilities', () => {
width, width,
height, height,
tiles, tiles,
actors: new Map(), exit: { x: 0, y: 0 },
exit: { x: 0, y: 0 } trackPath: []
}); });
describe('idx', () => { describe('idx', () => {
@@ -81,38 +81,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', () => {
@@ -139,41 +138,8 @@ describe('World Utilities', () => {
}); });
it('should return false for out of bounds', () => { it('should return false for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
expect(tryDestructTile(world, -1, 0)).toBe(false);
});
});
describe('isPlayerOnExit', () => {
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 }; expect(tryDestructTile(world, -1, 0)).toBe(false);
world.actors.set(1, {
id: 1,
pos: { x: 5, y: 5 },
isPlayer: true
} as any);
expect(isPlayerOnExit(world, 1)).toBe(true);
});
it('should return false when player is not on exit', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
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);
});
it('should return false when player does not exist', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
world.exit = { x: 5, y: 5 };
expect(isPlayerOnExit(world, 999)).toBe(false);
}); });
}); });
}); });

View File

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

View File

@@ -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, type Vec2 } 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.
*/ */
@@ -151,17 +167,22 @@ export class EntityBuilder {
asTrigger(options: { asTrigger(options: {
onEnter?: boolean; onEnter?: boolean;
onExit?: boolean; onExit?: boolean;
onInteract?: boolean;
oneShot?: boolean; oneShot?: boolean;
targetId?: EntityId;
effect?: string; effect?: string;
effectDuration?: number; effectDuration?: number;
}): this { }): this {
this.components.trigger = { this.components.trigger = {
onEnter: options.onEnter ?? true, onEnter: options.onEnter ?? false,
onExit: options.onExit, onExit: options.onExit,
onInteract: options.onInteract,
oneShot: options.oneShot, oneShot: options.oneShot,
targetId: options.targetId,
effect: options.effect, effect: options.effect,
effectDuration: options.effectDuration effectDuration: options.effectDuration
}; };
return this; return this;
} }
@@ -192,8 +213,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;
} }
@@ -221,11 +242,26 @@ export class EntityBuilder {
return this; return this;
} }
/**
* Configure as a mine cart.
*/
asMineCart(path: Vec2[]): this {
this.components.mineCart = {
isMoving: false,
path,
pathIndex: 0
};
this.withSprite("mine_cart", 0);
this.withName("Mine Cart");
return this;
}
/** /**
* Finalize and register all components with the ECS world. * Finalize and register all components with the ECS world.
* @returns The created entity ID * @returns The created entity ID
*/ */
build(): EntityId { build(): EntityId {
for (const [type, data] of Object.entries(this.components)) { for (const [type, data] of Object.entries(this.components)) {
if (data !== undefined) { if (data !== undefined) {
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any); this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);

View File

@@ -19,12 +19,18 @@ export type GameEvent =
// Movement & trigger events // Movement & trigger events
| { type: "stepped_on"; entityId: EntityId; x: number; y: number } | { type: "stepped_on"; entityId: EntityId; x: number; y: number }
| { type: "entity_moved"; entityId: EntityId; from: { x: number; y: number }; to: { x: number; y: number } }
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId } | { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
// Status effect events // Status effect events
| { type: "status_applied"; entityId: EntityId; status: string; duration: number } | { type: "status_applied"; entityId: EntityId; status: string; duration: number }
| { type: "status_expired"; entityId: EntityId; status: string } | { type: "status_expired"; entityId: EntityId; status: string }
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }; | { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
// World events
| { type: "tile_changed"; x: number; y: number }
| { type: "mission_complete" };
export type GameEventType = GameEvent["type"]; export type GameEventType = GameEvent["type"];

View File

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

View File

@@ -1,6 +1,7 @@
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, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig"; import { GAME_CONFIG } from "../../core/config/GameConfig";
/** /**
@@ -20,7 +21,6 @@ export const Prefabs = {
return EntityBuilder.create(world) return EntityBuilder.create(world)
.withPosition(x, y) .withPosition(x, y)
.withName("Rat") .withName("Rat")
.withSprite("rat", 0)
.asEnemy("rat") .asEnemy("rat")
.withStats({ .withStats({
maxHp: config.baseHp + floorBonus, maxHp: config.baseHp + floorBonus,
@@ -40,7 +40,6 @@ export const Prefabs = {
return EntityBuilder.create(world) return EntityBuilder.create(world)
.withPosition(x, y) .withPosition(x, y)
.withName("Bat") .withName("Bat")
.withSprite("bat", 0)
.asEnemy("bat") .asEnemy("bat")
.withStats({ .withStats({
maxHp: config.baseHp + floorBonus, maxHp: config.baseHp + floorBonus,
@@ -176,12 +175,35 @@ 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();
},
/**
* Create a fire entity on a tile.
*/
fire(world: ECSWorld, x: number, y: number, duration: number = 4): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Fire")
.withSprite("dungeon", 19) // Reuse fire trap sprite index for fire
.with("lifeSpan", { remainingTurns: duration })
.asTrigger({
onEnter: true,
effect: "burning",
effectDuration: 5
})
.with("trigger", {
onEnter: true,
effect: "burning",
effectDuration: 5,
damage: 3
})
.build(); .build();
}, },
@@ -193,15 +215,43 @@ export const Prefabs = {
return EntityBuilder.create(world) return EntityBuilder.create(world)
.withPosition(x, y) .withPosition(x, y)
.withName("Player") .withName("Player")
.withSprite("warrior", 0)
.asPlayer() .asPlayer()
.withStats(config.initialStats) .withStats(config.initialStats)
.withEnergy(config.speed) .withEnergy(config.speed)
.withCombat() .withCombat()
.build(); .build();
},
/**
* Create a mine cart at the start of a path.
*/
mineCart(world: ECSWorld, path: Vec2[]): EntityId {
const start = path[0];
return EntityBuilder.create(world)
.withPosition(start.x, start.y)
.asMineCart(path)
.build();
},
/**
* Create a switch that triggers the mine cart.
*/
trackSwitch(world: ECSWorld, x: number, y: number, cartId: EntityId): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Track Switch")
.withSprite("track_switch", 0)
.asTrigger({
onEnter: false,
onInteract: true,
oneShot: true,
targetId: cartId
})
.build();
} }
}; };
/** /**
* Type for prefab factory functions. * Type for prefab factory functions.
* Useful for creating maps of spawnable entities. * Useful for creating maps of spawnable entities.

View File

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

View File

@@ -0,0 +1,39 @@
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 },
trackPath: []
};
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);
});
});

View File

@@ -1,8 +1,8 @@
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 { }
export interface StatsComponent extends Stats {} export interface StatsComponent extends Stats { }
export interface EnergyComponent { export interface EnergyComponent {
current: number; current: number;
@@ -15,7 +15,7 @@ export interface AIComponent {
lastKnownPlayerPos?: Vec2; lastKnownPlayerPos?: Vec2;
} }
export interface PlayerTagComponent {} export interface PlayerTagComponent { }
export interface CollectibleComponent { export interface CollectibleComponent {
type: "exp_orb"; type: "exp_orb";
@@ -46,13 +46,26 @@ export interface ActorTypeComponent {
export interface TriggerComponent { export interface TriggerComponent {
onEnter?: boolean; // Trigger when entity steps on this tile onEnter?: boolean; // Trigger when entity steps on this tile
onExit?: boolean; // Trigger when entity leaves this tile onExit?: boolean; // Trigger when entity leaves this tile
onInteract?: boolean; // Trigger when entity interacts with this
oneShot?: boolean; // Destroy/disable after triggering once oneShot?: boolean; // Destroy/disable after triggering once
triggered?: boolean; // Has already triggered (for oneShot triggers) triggered?: boolean; // Is currently triggered/active
spent?: boolean; // Has already triggered (for oneShot triggers)
targetId?: EntityId; // Target entity for this trigger (e.g., mine cart for a switch)
damage?: number; // Damage to deal on trigger (for traps) damage?: number; // Damage to deal on trigger (for traps)
effect?: string; // Status effect to apply (e.g., "poison", "slow") effect?: string; // Status effect to apply (e.g., "poison", "slow")
effectDuration?: number; // Duration of applied effect effectDuration?: number; // Duration of applied effect
} }
/**
* For the Mine Cart.
*/
export interface MineCartComponent {
isMoving: boolean;
path: Vec2[];
pathIndex: number;
}
/** /**
* Status effect instance applied to an entity. * Status effect instance applied to an entity.
*/ */
@@ -98,8 +111,18 @@ 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 { }
/**
* For entities that should be destroyed after a certain amount of time/turns.
*/
export interface LifeSpanComponent {
remainingTurns: number;
} }
export type ComponentMap = { export type ComponentMap = {
@@ -120,6 +143,10 @@ export type ComponentMap = {
combat: CombatComponent; combat: CombatComponent;
destructible: DestructibleComponent; destructible: DestructibleComponent;
groundItem: GroundItemComponent; groundItem: GroundItemComponent;
inventory: InventoryComponent;
equipment: EquipmentComponent;
lifeSpan: LifeSpanComponent;
mineCart: MineCartComponent;
}; };
export type ComponentType = keyof ComponentMap; export type ComponentType = keyof ComponentMap;

View File

@@ -0,0 +1,103 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type ComponentType } from "../components";
import { type EntityId, type World } from "../../../core/types";
import { TileType, getDestructionResult } from "../../../core/terrain";
import { idx, inBounds } from "../../world/world-logic";
import { Prefabs } from "../Prefabs";
export class FireSystem extends System {
readonly name = "Fire";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = 15; // Run after status effects
private world: World;
constructor(world: World) {
super();
this.world = world;
}
update(entities: EntityId[], ecsWorld: ECSWorld, _dt?: number): void {
const fireEntities = entities.filter(id => ecsWorld.getComponent(id, "name")?.name === "Fire");
const spreadTargets: { x: number; y: number; duration: number }[] = [];
const entitiesToRemove: EntityId[] = [];
// Get all combatant positions to avoid spreading onto them
const combatantEntities = ecsWorld.getEntitiesWith("position").filter(id =>
ecsWorld.hasComponent(id, "player") || ecsWorld.hasComponent(id, "stats")
);
const combatantPosSet = new Set(combatantEntities.map(id => {
const p = ecsWorld.getComponent(id, "position")!;
return `${p.x},${p.y}`;
}));
// 1. Process existing fire entities
for (const fireId of fireEntities) {
const pos = ecsWorld.getComponent(fireId, "position");
const lifeSpan = ecsWorld.getComponent(fireId, "lifeSpan");
if (!pos) continue;
// Decrement lifespan
if (lifeSpan) {
lifeSpan.remainingTurns--;
// If fire expires, destroy it and the tile below it
if (lifeSpan.remainingTurns <= 0) {
entitiesToRemove.push(fireId);
const tileIdx = idx(this.world, pos.x, pos.y);
const tile = this.world.tiles[tileIdx];
const nextTile = getDestructionResult(tile);
if (nextTile !== undefined) {
this.world.tiles[tileIdx] = nextTile;
this.eventBus?.emit({ type: "tile_changed", x: pos.x, y: pos.y });
}
continue; // Fire is gone, don't spread from it anymore
}
}
// 2. Spreading logic (only if fire is still active)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = pos.x + dx;
const ny = pos.y + dy;
if (!inBounds(this.world, nx, ny)) continue;
// Skip tiles occupied by any combatant
if (combatantPosSet.has(`${nx},${ny}`)) continue;
const tileIdx = idx(this.world, nx, ny);
const tile = this.world.tiles[tileIdx];
// Fire ONLY spreads to GRASS
if (tile === TileType.GRASS) {
spreadTargets.push({ x: nx, y: ny, duration: 2 });
}
}
}
}
// Cleanup expired fires
for (const id of entitiesToRemove) {
ecsWorld.destroyEntity(id);
}
// 3. Apply spreading
for (const target of spreadTargets) {
// Check if fire already there
const existing = ecsWorld.getEntitiesWith("position").find(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.x && p?.y === target.y && n?.name === "Fire";
});
if (!existing) {
Prefabs.fire(ecsWorld, target.x, target.y, target.duration);
}
}
}
}

View File

@@ -0,0 +1,47 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type EntityId } from "../../../core/types";
/**
* System that moves the mine cart along its fixed path.
* Moves 1 tile per update (tick).
*/
export class MineCartSystem extends System {
readonly name = "MineCart";
readonly requiredComponents = ["mineCart", "position", "sprite"] as const;
update(entities: EntityId[], world: ECSWorld) {
for (const id of entities) {
const mineCart = world.getComponent(id, "mineCart");
const pos = world.getComponent(id, "position");
if (!mineCart || !pos || !mineCart.isMoving) continue;
// Move to next path node if available
if (mineCart.pathIndex < mineCart.path.length - 1) {
mineCart.pathIndex++;
const nextPos = mineCart.path[mineCart.pathIndex];
// Update position component
pos.x = nextPos.x;
pos.y = nextPos.y;
// Emit event for visual feedback
this.eventBus?.emit({
type: "entity_moved",
entityId: id,
from: { x: pos.x, y: pos.y },
to: nextPos
});
} else {
// Reached the end
if (mineCart.isMoving) {
mineCart.isMoving = false;
this.eventBus?.emit({ type: "mission_complete" });
}
}
}
}
}

View File

@@ -37,7 +37,7 @@ export class TriggerSystem extends System {
const triggerPos = world.getComponent(triggerId, "position"); const triggerPos = world.getComponent(triggerId, "position");
if (!trigger || !triggerPos) continue; if (!trigger || !triggerPos) continue;
if (trigger.triggered && trigger.oneShot) continue; // Already triggered one-shot if (trigger.spent && trigger.oneShot) continue; // Already spent one-shot
// Check for entities at this trigger's position // Check for entities at this trigger's position
for (const entityId of allWithPosition) { for (const entityId of allWithPosition) {
@@ -49,9 +49,14 @@ export class TriggerSystem extends System {
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y; const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos); const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
// Handle enter // Handle enter or manual trigger
if (trigger.onEnter && isOnTrigger && !wasOnTrigger) { if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) {
this.activateTrigger(triggerId, entityId, trigger, world); this.activateTrigger(triggerId, entityId, trigger, world);
// If it was manually triggered, we should reset the flag
if (trigger.triggered) {
trigger.triggered = false;
}
} }
// Handle exit // Handle exit
@@ -81,6 +86,9 @@ export class TriggerSystem extends System {
effectDuration?: number; effectDuration?: number;
oneShot?: boolean; oneShot?: boolean;
triggered?: boolean; triggered?: boolean;
targetId?: EntityId;
onInteract?: boolean;
spent?: boolean;
}, },
world: ECSWorld world: ECSWorld
): void { ): void {
@@ -91,7 +99,22 @@ export class TriggerSystem extends System {
activatorId activatorId
}); });
// Handle Mine Cart activation
if (trigger.targetId) {
const mineCart = world.getComponent(trigger.targetId, "mineCart");
if (mineCart) {
mineCart.isMoving = true;
// Change switch sprite if applicable (optional for now as we only have one frame)
const sprite = world.getComponent(triggerId, "sprite");
if (sprite && sprite.texture === "dungeon") {
sprite.index = 32;
}
}
}
// Apply damage if trap // Apply damage if trap
if (trigger.damage && trigger.damage > 0) { if (trigger.damage && trigger.damage > 0) {
const stats = world.getComponent(activatorId, "stats"); const stats = world.getComponent(activatorId, "stats");
if (stats) { if (stats) {
@@ -124,11 +147,12 @@ export class TriggerSystem extends System {
// Mark as triggered for one-shot triggers and update sprite // Mark as triggered for one-shot triggers and update sprite
if (trigger.oneShot) { if (trigger.oneShot) {
trigger.triggered = true; trigger.spent = true;
trigger.triggered = false;
// Change sprite to triggered appearance (dungeon sprite 23) // Change sprite to triggered appearance if it's a dungeon sprite
const sprite = world.getComponent(triggerId, "sprite"); const sprite = world.getComponent(triggerId, "sprite");
if (sprite) { if (sprite && sprite.texture === "dungeon") {
sprite.index = 23; // Triggered/spent trap appearance sprite.index = 23; // Triggered/spent trap appearance
} }
} }

View File

@@ -1,7 +1,7 @@
import { type World, type Vec2, type EntityId } from "../../core/types"; import { type World, type Vec2, type EntityId, type Stats, type Item } 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[];
@@ -9,6 +9,73 @@ export interface ProjectileResult {
hitActorId?: EntityId; hitActorId?: EntityId;
} }
export interface DamageResult {
dmg: number;
hit: boolean;
isCrit: boolean;
isBlock: boolean;
}
/**
* Centralized damage calculation for both melee and ranged attacks.
*/
export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?: Item): DamageResult {
const result: DamageResult = {
dmg: 0,
hit: false,
isCrit: false,
isBlock: false
};
// 1. Accuracy vs Evasion Check
const hitChance = attackerStats.accuracy - targetStats.evasion;
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) {
return result; // Miss
}
result.hit = true;
// 2. Base Damage Calculation
// Use player attack as base, add item attack if it's a weapon
let baseAttack = attackerStats.attack;
if (item && "stats" in item && item.stats && "attack" in item.stats) {
// For weapons, the item stats are already added to player stats in EquipmentService
// However, if we want to support 'thrown' items having their own base damage, we can add it here.
// For ranged weapons, executeThrow was using item.stats.attack.
// If it's a weapon, we assume the item.stats.attack is what should be used (or added).
// Actually, equipmentService adds item.stats.attack to player.stats.attack.
// So baseAttack is already "player + weapon".
// BUT for projectiles/thrown, we might want to ensure we're using the right value.
// If it's a weapon item, it's likely already factored in.
// If it's a CONSUMABLE (thrown), it might NOT be.
if (item.type === "Consumable") {
baseAttack += (item.stats as any).attack || 0;
}
}
let dmg = Math.max(1, baseAttack - targetStats.defense);
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < attackerStats.critChance;
if (isCrit) {
dmg = Math.floor(dmg * (attackerStats.critMultiplier / 100));
result.isCrit = true;
}
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
if (blockRoll < targetStats.blockChance) {
dmg = Math.floor(dmg * 0.5);
result.isBlock = true;
}
result.dmg = dmg;
return result;
}
/** /**
* Calculates the path and impact of a projectile. * Calculates the path and impact of a projectile.
*/ */
@@ -16,7 +83,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 +95,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) {
@@ -52,14 +123,43 @@ export function traceProjectile(
}; };
} }
/** /**
* Finds the closest visible enemy to a given position. * Calculates tiles within a cone for area of effect attacks.
*/ */
export function getConeTiles(origin: Vec2, target: Vec2, range: number): Vec2[] {
const tiles: Vec2[] = [];
const angle = Math.atan2(target.y - origin.y, target.x - origin.x);
const halfSpread = Math.PI / 4; // 90 degree cone
for (let dy = -range; dy <= range; dy++) {
for (let dx = -range; dx <= range; dx++) {
if (dx === 0 && dy === 0) continue;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > range + 0.5) continue;
const tilePos = { x: origin.x + dx, y: origin.y + dy };
const tileAngle = Math.atan2(dy, dx);
// Normalize angle difference to [-PI, PI]
let angleDiff = tileAngle - angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
if (Math.abs(angleDiff) <= halfSpread) {
tiles.push(tilePos);
}
}
}
return tiles;
}
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 +176,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

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from "vitest";
import { calculateDamage } from "../CombatLogic";
import { type Stats, type Item } from "../../../core/types";
describe("CombatLogic - calculateDamage", () => {
const createStats = (overrides: Partial<Stats> = {}): Stats => ({
hp: 100, maxHp: 100, attack: 10, defense: 5,
accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 200,
blockChance: 0, lifesteal: 0, mana: 50, maxMana: 50,
level: 1, exp: 0, expToNextLevel: 100, luck: 0,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
passiveNodes: [],
...overrides
});
it("should calculate base damage correctly (attack - defense)", () => {
const attacker = createStats({ attack: 15 });
const target = createStats({ defense: 5 });
// Mock Math.random to ensure hit and no crit/block
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const result = calculateDamage(attacker, target);
expect(result.hit).toBe(true);
expect(result.dmg).toBe(10); // 15 - 5
expect(result.isCrit).toBe(false);
expect(result.isBlock).toBe(false);
vi.restoreAllMocks();
});
it("should ensure minimum damage of 1", () => {
const attacker = createStats({ attack: 5 });
const target = createStats({ defense: 10 });
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const result = calculateDamage(attacker, target);
expect(result.dmg).toBe(1);
vi.restoreAllMocks();
});
it("should handle misses (accuracy vs evasion)", () => {
const attacker = createStats({ accuracy: 50 });
const target = createStats({ evasion: 0 });
// Mock random to be > 50 (miss)
vi.spyOn(Math, 'random').mockReturnValue(0.6);
const result = calculateDamage(attacker, target);
expect(result.hit).toBe(false);
expect(result.dmg).toBe(0);
vi.restoreAllMocks();
});
it("should handle critical hits", () => {
const attacker = createStats({ attack: 10, critChance: 100, critMultiplier: 200 });
const target = createStats({ defense: 0 });
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const result = calculateDamage(attacker, target);
expect(result.isCrit).toBe(true);
expect(result.dmg).toBe(20); // 10 * 2.0
vi.restoreAllMocks();
});
it("should handle blocking", () => {
const attacker = createStats({ attack: 20 });
const target = createStats({ defense: 0, blockChance: 100 });
// We need multiple random calls or a smarter mock if calculateDamage calls random multiple times.
// 1. Hit check
// 2. Crit check
// 3. Block check
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1) // Hit (chance 100)
.mockReturnValueOnce(0.9) // No Crit (chance 0)
.mockReturnValueOnce(0.1); // Block (chance 100)
vi.spyOn(Math, 'random').mockImplementation(mockRandom);
const result = calculateDamage(attacker, target);
expect(result.isBlock).toBe(true);
expect(result.dmg).toBe(10); // (20-0) * 0.5
vi.restoreAllMocks();
});
it("should consider item attack for consumables (thrown items)", () => {
const attacker = createStats({ attack: 10 });
const target = createStats({ defense: 0 });
const item: Item = {
id: "bomb",
name: "Bomb",
type: "Consumable",
textureKey: "items",
spriteIndex: 0,
stats: { attack: 20 }
} as any;
vi.spyOn(Math, 'random').mockReturnValue(0.1);
const result = calculateDamage(attacker, target, item);
expect(result.dmg).toBe(30); // 10 (player) + 20 (item)
vi.restoreAllMocks();
});
it("should NOT add weapon attack twice (assumes it's already in player stats)", () => {
const attacker = createStats({ attack: 30 }); // Player 10 + Weapon 20
const target = createStats({ defense: 0 });
const item: Item = {
id: "pistol",
name: "Pistol",
type: "Weapon",
weaponType: "ranged",
textureKey: "items",
spriteIndex: 0,
stats: { attack: 20 }
} as any;
vi.spyOn(Math, 'random').mockReturnValue(0.1);
const result = calculateDamage(attacker, target, item);
expect(result.dmg).toBe(30); // Should remain 30, not 50
vi.restoreAllMocks();
});
});

View File

@@ -1,54 +1,55 @@
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 },
trackPath: []
};
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 +58,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,40 +69,41 @@ 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);
}); });
it('should ignore shooter position', () => { it('should ignore shooter position', () => {
const start = { x: 0, y: 0 }; const start = { x: 0, y: 0 };
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();
expect(result.blockedPos).toEqual(end); expect(result.blockedPos).toEqual(end);
}); });
it('should ignore non-combatant actors (e.g. items)', () => { it('should ignore non-combatant actors (e.g. items)', () => {
@@ -109,17 +111,19 @@ 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);
expect(result.hitActorId).toBeUndefined(); expect(result.hitActorId).toBeUndefined();
}); });
}); });
}); });

View File

@@ -1,35 +1,37 @@
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 },
trackPath: []
};
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 +45,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 +120,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,22 +140,23 @@ 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 pistol1 = createRangedWeapon("pistol"); const playerActor = accessor.getPlayer()!;
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!
// Modifying one should not affect other // Modifying one should not affect other
picked1.currentAmmo = 0; picked1.currentAmmo = 0;
expect(picked2.currentAmmo).toBe(6); expect(picked2.currentAmmo).toBe(6);
}); });
}); });

View File

@@ -0,0 +1,147 @@
import Phaser from "phaser";
import { TILE_SIZE } from "../../core/constants";
export interface GameInputEvents {
"toggle-menu": () => void;
"close-menu": () => void;
"toggle-inventory": () => void;
"toggle-character": () => void;
"toggle-minimap": () => void;
"reload": () => void;
"wait": () => void;
"zoom": (deltaY: number) => void;
"pan": (dx: number, dy: number) => void;
"cancel-target": () => void;
"confirm-target": () => void; // Left click while targeting
"tile-click": (tileX: number, tileY: number, button: number) => void;
"cursor-move": (worldX: number, worldY: number) => void;
}
export class GameInput extends Phaser.Events.EventEmitter {
private scene: Phaser.Scene;
private cursors: Phaser.Types.Input.Keyboard.CursorKeys;
private wasd: {
W: Phaser.Input.Keyboard.Key;
A: Phaser.Input.Keyboard.Key;
S: Phaser.Input.Keyboard.Key;
D: Phaser.Input.Keyboard.Key;
};
constructor(scene: Phaser.Scene) {
super();
this.scene = scene;
this.cursors = this.scene.input.keyboard!.createCursorKeys();
this.wasd = this.scene.input.keyboard!.addKeys("W,A,S,D") as any;
this.setupKeyboard();
this.setupMouse();
}
private setupKeyboard() {
if (!this.scene.input.keyboard) return;
this.scene.input.keyboard.on("keydown-I", () => this.emit("toggle-menu"));
this.scene.input.keyboard.on("keydown-ESC", () => this.emit("close-menu"));
this.scene.input.keyboard.on("keydown-M", () => this.emit("toggle-minimap"));
this.scene.input.keyboard.on("keydown-B", () => this.emit("toggle-inventory"));
this.scene.input.keyboard.on("keydown-C", () => this.emit("toggle-character"));
this.scene.input.keyboard.on("keydown-R", () => this.emit("reload"));
this.scene.input.keyboard.on("keydown-SPACE", () => this.emit("wait"));
}
private setupMouse() {
this.scene.input.on("wheel", (_p: any, _g: any, _x: any, deltaY: number) => {
this.emit("zoom", deltaY);
});
this.scene.input.mouse?.disableContextMenu();
this.scene.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
if (p.rightButtonDown()) {
this.emit("cancel-target");
}
// For general clicks, we emit tile-click
// Logic for "confirm-target" vs "move" happens in Scene for now,
// or we can distinguish based on internal state if we moved targeting here.
// For now, let's just emit generic events or specific if clear.
// Actually, GameScene has specific logic:
// "If targeting active -> Left Click = throw"
// "Else -> Left Click = move/attack"
// To keep GameInput "dumb", we just emit the click details.
// EXCEPT: Panning logic is computed from pointer movement.
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
this.emit("tile-click", tx, ty, p.button);
});
this.scene.input.on("pointermove", (p: Phaser.Input.Pointer) => {
this.emit("cursor-move", p.worldX, p.worldY);
// Panning logic
if (p.isDown) {
const isRightDrag = p.rightButtonDown();
const isMiddleDrag = p.middleButtonDown();
const isShiftDrag = p.isDown && p.event.shiftKey;
if (isRightDrag || isMiddleDrag || isShiftDrag) {
const { x, y } = p.position;
const { x: prevX, y: prevY } = p.prevPosition;
const dx = (x - prevX); // Zoom factor needs to be handled by receiver or passed here
const dy = (y - prevY);
this.emit("pan", dx, dy);
}
}
});
}
public getCursorState() {
// Return simplified WASD state for movement
let dx = 0;
let dy = 0;
const left = this.wasd.A.isDown;
const right = this.wasd.D.isDown;
const up = this.wasd.W.isDown;
const down = this.wasd.S.isDown;
if (left) dx -= 1;
if (right) dx += 1;
if (up) dy -= 1;
if (down) dy += 1;
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
Phaser.Input.Keyboard.JustDown(this.wasd.A) ||
Phaser.Input.Keyboard.JustDown(this.wasd.S) ||
Phaser.Input.Keyboard.JustDown(this.wasd.D);
return {
dx, dy, anyJustDown,
isLeft: !!left,
isRight: !!right,
isUp: !!up,
isDown: !!down
};
}
public getCameraPanState() {
// Return Arrow key state for camera panning
let dx = 0;
let dy = 0;
if (this.cursors.left?.isDown) dx -= 1;
if (this.cursors.right?.isDown) dx += 1;
if (this.cursors.up?.isDown) dy -= 1;
if (this.cursors.down?.isDown) dy += 1;
return { dx, dy };
}
public cleanup() {
this.removeAllListeners();
// Determine is scene specific cleanup is needed for inputs
}
}

View File

@@ -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,15 +19,15 @@ 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 } trackPath: []
}; };
// Blocking wall at (1, 0) // Blocking wall at (1, 0)
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 +37,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 +62,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({

View File

@@ -1,26 +1,27 @@
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types"; import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
import { calculateDamage } from "../gameplay/CombatLogic";
import { isBlocked, tryDestructTile } from "../world/world-logic"; import { isBlocked, tryDestructTile } from "../world/world-logic";
import { isDestructibleByWalk } from "../../core/terrain"; import { isDestructibleByWalk, TileType } 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 +29,18 @@ 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 checkDeaths(events, accessor);
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 +55,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,46 +90,62 @@ 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]; const tile = w.tiles[tileIdx];
if (isDestructibleByWalk(tile)) { if (isDestructibleByWalk(tile)) {
// Only open if it's currently closed.
// tryDestructTile toggles, so we must be specific for doors.
if (tile === TileType.DOOR_CLOSED) {
tryDestructTile(w, nx, ny);
} else if (tile !== TileType.DOOR_OPEN) {
// For other destructibles like grass
tryDestructTile(w, nx, ny); tryDestructTile(w, nx, ny);
} }
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events, em);
}
return events;
} }
// Handle "from" tile - Close door if we just left it and no one else is there
const fromIdx = from.y * w.width + from.x;
if (w.tiles[fromIdx] === TileType.DOOR_OPEN) {
const actorsLeft = accessor.getActorsAt(from.x, from.y);
if (actorsLeft.length === 0) {
console.log(`[simulation] Closing door at ${from.x},${from.y} - Actor ${actor.id} left`);
w.tiles[fromIdx] = TileType.DOOR_CLOSED;
} else {
console.log(`[simulation] Door at ${from.x},${from.y} stays open - ${actorsLeft.length} actors remain`);
}
}
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(actor, events, accessor);
}
return events;
} else { } else {
// Fallback for cases without EntityManager (e.g. tests) // If blocked, check if we can interact with an entity at the target position
if (!isBlocked(w, nx, ny)) { if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
actor.pos.x = nx; const ecsWorld = accessor.context;
actor.pos.y = ny; const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
const to = { ...actor.pos }; const p = ecsWorld.getComponent(id, "position");
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; const t = ecsWorld.getComponent(id, "trigger");
return p?.x === nx && p?.y === ny && t?.onInteract;
});
const tileIdx = ny * w.width + nx; if (interactables.length > 0) {
if (isDestructibleByWalk(w.tiles[tileIdx])) { // Trigger interaction by marking it as triggered
tryDestructTile(w, nx, ny); // The TriggerSystem will pick this up on the next update
ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true;
} }
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events);
}
return events;
} }
} }
@@ -139,17 +154,16 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] {
const target = w.actors.get(action.targetId); function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
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 }];
// 1. Accuracy vs Evasion Check // 1. Calculate Damage
const hitChance = actor.stats.accuracy - target.stats.evasion; const result = calculateDamage(actor.stats, target.stats);
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) { if (!result.hit) {
// Miss!
events.push({ events.push({
type: "dodged", type: "dodged",
targetId: action.targetId, targetId: action.targetId,
@@ -159,34 +173,18 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
return events; return events;
} }
// 2. Base Damage Calculation const dmg = result.dmg;
let dmg = Math.max(1, actor.stats.attack - target.stats.defense); const isCrit = result.isCrit;
const isBlock = result.isBlock;
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < actor.stats.critChance;
if (isCrit) {
dmg = Math.floor(dmg * (actor.stats.critMultiplier / 100));
}
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
let isBlock = false;
if (blockRoll < target.stats.blockChance) {
dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50%
isBlock = true;
}
target.stats.hp -= dmg; target.stats.hp -= dmg;
// 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();
target.alertedAt = Date.now(); // Reset alert timer if any if (actor.pos) {
if (actor.pos) { target.lastKnownPlayerPos = { ...actor.pos };
target.lastKnownPlayerPos = { ...actor.pos }; }
}
} }
// 5. Lifesteal Logic // 5. Lifesteal Logic
@@ -195,11 +193,11 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
if (healAmount > 0) { if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount); actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({ events.push({
type: "healed", type: "healed",
actorId: actor.id, actorId: actor.id,
amount: healAmount, amount: healAmount,
x: actor.pos.x, x: actor.pos.x,
y: actor.pos.y y: actor.pos.y
}); });
} }
} }
@@ -216,42 +214,55 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
}); });
if (target.stats.hp <= 0) { if (target.stats.hp <= 0) {
events.push({ killActor(target, events, accessor, actor.id);
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
if (em) em.removeActor(target.id);
else w.actors.delete(target.id);
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
const orb: CollectibleActor = {
id: orbId,
category: "collectible",
type: "exp_orb",
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;
} }
return [{ type: "waited", actorId: actor.id }]; return [{ type: "waited", actorId: actor.id }];
} }
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
events.push({
type: "killed",
targetId: target.id,
killerId: killerId ?? (0 as EntityId),
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
}
export function checkDeaths(events: SimEvent[], accessor: EntityAccessor): void {
const combatants = accessor.getCombatants();
for (const c of combatants) {
if (c.stats.hp <= 0) {
killActor(c, events, accessor);
}
}
}
/** /**
@@ -260,23 +271,22 @@ 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; enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos; }
}
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,100 +294,61 @@ 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.energy -= THRESHOLD;
// Actor takes a turn const decision = decideEnemyAction(w, actor, player, accessor);
actor.energy -= THRESHOLD;
// Decide logic if (decision.justAlerted) {
const decision = decideEnemyAction(w, actor, player, em); events.push({
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
if (decision.justAlerted) { events.push(...applyAction(w, actor.id, decision.action, accessor));
events.push({ checkDeaths(events, accessor);
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
events.push(...applyAction(w, actor.id, decision.action, em)); if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };
}
// Check if player died actionsTaken++;
if (!w.actors.has(playerId)) { if (actionsTaken > 1000) break;
return { awaitingPlayerId: null as any, events };
}
actionsTaken++;
if (actionsTaken > 1000) break; // Emergency break
} }
} }
} }

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

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

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

View File

@@ -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";
@@ -7,12 +7,17 @@ import {
createMeleeWeapon, createMeleeWeapon,
createRangedWeapon, createRangedWeapon,
createArmour, createArmour,
createUpgradeScroll createUpgradeScroll,
createAmmo,
createCeramicDragonHead
} from "../../core/config/Items"; } from "../../core/config/Items";
import { seededRandom } from "../../core/math"; 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 {
@@ -33,287 +38,352 @@ export function generateWorld(floor: number, runState: RunState): { world: World
const height = GAME_CONFIG.map.height; const height = GAME_CONFIG.map.height;
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL); const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
const random = seededRandom(floor * 12345); const random = seededRandom(runState.seed + 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(runState.seed + floor * 12345);
const rooms = generateRooms(width, height, tiles, floor, random); // Replace generateRooms call with track-first logic for mine cart mechanic
const { rooms, trackPath } = generateTrackLevel(width, height, tiles, floor, random);
// Place player in first room console.log(`[generator] Track generated with ${trackPath.length} nodes.`);
const firstRoom = rooms[0]; console.log(`[generator] Rooms generated: ${rooms.length}`);
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
const actors = new Map<EntityId, Actor>(); if (!trackPath || trackPath.length === 0) {
const playerId = 1; throw new Error("Failed to generate track path");
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,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createArmour("leather_armor", "heavy"), // Heavy armour variant
createUpgradeScroll(2) // 2 Upgrade scrolls
] : [])
]
},
energy: 0
});
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exit: Vec2 = {
x: lastRoom.x + Math.floor(lastRoom.width / 2),
y: lastRoom.y + Math.floor(lastRoom.height / 2)
};
placeEnemies(floor, rooms, actors, random);
// Create ECS world and place traps
const ecsWorld = new ECSWorld();
const occupiedPositions = new Set<string>();
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place doors for dungeon levels (Uniform/Digger)
// Caves (Floors 10+) shouldn't have manufactured doors
if (floor <= 9) {
placeDoors(width, height, tiles, rooms, random);
} }
// Place player at start of track
const playerX = trackPath[0].x;
const playerY = trackPath[0].y;
// Clear track path
for (const pos of trackPath) {
tiles[pos.y * width + pos.x] = TileType.TRACK;
}
// Create Player Entity in ECS
const runInventory = {
gold: runState.inventory.gold,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"),
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createAmmo("ammo_9mm", 10),
createCeramicDragonHead(),
createArmour("leather_armor", "heavy"),
createUpgradeScroll(2)
] : [])
]
};
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();
// Create Mine Cart at start of track
const cartId = Prefabs.mineCart(ecsWorld, trackPath);
const exit = { ...trackPath[trackPath.length - 1] };
// Place Switch adjacent to the end of the track
let switchPos = { x: exit.x, y: exit.y };
const neighbors = [
{ x: exit.x + 1, y: exit.y },
{ x: exit.x - 1, y: exit.y },
{ x: exit.x, y: exit.y + 1 },
{ x: exit.x, y: exit.y - 1 },
];
for (const n of neighbors) {
if (n.x >= 1 && n.x < width - 1 && n.y >= 1 && n.y < height - 1) {
const t = tiles[n.y * width + n.x];
if (t === TileType.EMPTY || t === TileType.EMPTY_DECO || t === TileType.GRASS || t === TileType.TRACK) {
switchPos = n;
// Don't break if it's track, try to find a real empty spot first
if (t !== TileType.TRACK) break;
}
}
}
Prefabs.trackSwitch(ecsWorld, switchPos.x, switchPos.y, cartId);
// Mark all track and room tiles as occupied for objects
const occupiedPositions = new Set<string>();
occupiedPositions.add(`${playerX},${playerY}`);
occupiedPositions.add(`${exit.x},${exit.y}`);
for (const pos of trackPath) {
occupiedPositions.add(`${pos.x},${pos.y}`);
}
// Place enemies
placeEnemies(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place traps
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Decorate and finalize tiles
decorate(width, height, tiles, random, exit); decorate(width, height, tiles, random, exit);
// CRITICAL FIX: Ensure player start position is always clear! // Ensure start and end are walkable and marked
// Otherwise spawning in Grass (which blocks vision) makes the player blind.
tiles[playerY * width + playerX] = TileType.EMPTY; tiles[playerY * width + playerX] = TileType.EMPTY;
tiles[exit.y * width + exit.x] = TileType.EXIT;
return { return {
world: { width, height, tiles, actors, exit }, world: { width, height, tiles, exit, trackPath },
playerId, playerId,
ecsWorld ecsWorld
}; };
} }
// Update generateRooms signature to accept random
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
const rooms: Room[] = [];
// Choose dungeon algorithm based on floor depth
let dungeon: any;
if (floor <= 4) {
// Floors 1-4: Uniform (organic, irregular rooms)
dungeon = new ROT.Map.Uniform(width, height, {
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
roomDugPercentage: 0.3,
});
} else if (floor <= 9) {
// Floors 5-9: Digger (traditional rectangular rooms + corridors)
dungeon = new ROT.Map.Digger(width, height, {
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
corridorLength: [2, 6],
});
} else {
// Floors 10+: Cellular (natural cave systems)
dungeon = new ROT.Map.Cellular(width, height, {
born: [4, 5, 6, 7, 8],
survive: [2, 3, 4, 5],
});
// Cellular needs randomization and smoothing
dungeon.randomize(0.5);
for (let i = 0; i < 4; i++) {
dungeon.create();
}
}
// Generate the dungeon
dungeon.create((x: number, y: number, value: number) => {
if (value === 0) {
// 0 = floor, 1 = wall
tiles[y * width + x] = TileType.EMPTY;
}
});
// Extract room information from the generated dungeon
const roomData = (dungeon as any).getRooms?.();
if (roomData && roomData.length > 0) {
// Traditional dungeons (Uniform/Digger) have explicit rooms
for (const room of roomData) {
rooms.push({
x: room.getLeft(),
y: room.getTop(),
width: room.getRight() - room.getLeft() + 1,
height: room.getBottom() - room.getTop() + 1
});
}
} else {
// Cellular caves don't have explicit rooms, so find connected floor areas
rooms.push(...extractRoomsFromCave(width, height, tiles));
// Connect the isolated cave rooms
connectRooms(width, tiles, rooms, random);
}
// Ensure we have at least 2 rooms for player/exit placement
if (rooms.length < 2) {
// Fallback: create two basic rooms
rooms.push(
{ x: 5, y: 5, width: 5, height: 5 },
{ x: width - 10, y: height - 10, width: 5, height: 5 }
);
// Connect the fallback rooms
connectRooms(width, tiles, rooms, random);
}
return rooms;
}
function connectRooms(width: number, tiles: Tile[], rooms: Room[], random: () => number) {
for (let i = 0; i < rooms.length - 1; i++) {
const r1 = rooms[i];
const r2 = rooms[i+1];
const c1x = r1.x + Math.floor(r1.width / 2);
const c1y = r1.y + Math.floor(r1.height / 2);
const c2x = r2.x + Math.floor(r2.width / 2);
const c2y = r2.y + Math.floor(r2.height / 2);
if (random() < 0.5) {
digH(width, tiles, c1x, c2x, c1y);
digV(width, tiles, c1y, c2y, c2x);
} else {
digV(width, tiles, c1y, c2y, c1x);
digH(width, tiles, c1x, c2x, c2y);
}
}
}
function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) {
const start = Math.min(x1, x2);
const end = Math.max(x1, x2);
for (let x = start; x <= end; x++) {
const idx = y * width + x;
if (tiles[idx] === TileType.WALL) {
tiles[idx] = TileType.EMPTY;
}
}
}
function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) {
const start = Math.min(y1, y2);
const end = Math.max(y1, y2);
for (let y = start; y <= end; y++) {
const idx = y * width + x;
if (tiles[idx] === TileType.WALL) {
tiles[idx] = TileType.EMPTY;
}
}
}
/** /**
* For cellular/cave maps, find clusters of floor tiles to use as "rooms" * Generates a level with a central rail track from start to end.
*/ */
function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] { function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
const rooms: Room[] = []; const rooms: Room[] = [];
const visited = new Set<number>(); const trackPath: Vec2[] = [];
// Find large connected floor areas // 1. Generate a winding path of "Anchor Points" for rooms
for (let y = 1; y < height - 1; y++) { const anchors: Vec2[] = [];
for (let x = 1; x < width - 1; x++) { const startDir = Math.floor(random() * 4); // 0: East, 1: West, 2: South, 3: North
const idx = y * width + x;
if (tiles[idx] === TileType.EMPTY && !visited.has(idx)) {
const cluster = floodFill(width, height, tiles, x, y, visited);
// Only consider clusters larger than 20 tiles let currA: Vec2;
if (cluster.length > 20) { const margin = 10;
// Create bounding box for this cluster const stepSize = 12;
let minX = width, maxX = 0, minY = height, maxY = 0;
for (const pos of cluster) { if (startDir === 0) { // East (Left to Right)
const cx = pos % width; currA = { x: margin, y: margin + Math.floor(random() * (height - margin * 2)) };
const cy = Math.floor(pos / width); } else if (startDir === 1) { // West (Right to Left)
minX = Math.min(minX, cx); currA = { x: width - margin, y: margin + Math.floor(random() * (height - margin * 2)) };
maxX = Math.max(maxX, cx); } else if (startDir === 2) { // South (Top to Bottom)
minY = Math.min(minY, cy); currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: margin };
maxY = Math.max(maxY, cy); } else { // North (Bottom to Top)
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: height - margin };
}
anchors.push({ ...currA });
const isFinished = () => {
if (startDir === 0) return currA.x >= width - margin;
if (startDir === 1) return currA.x <= margin;
if (startDir === 2) return currA.y >= height - margin;
return currA.y <= margin;
};
while (!isFinished()) {
let nextX = currA.x;
let nextY = currA.y;
if (startDir === 0) { // East
nextX += Math.floor(stepSize * (0.8 + random() * 0.4));
nextY += Math.floor((random() - 0.5) * height * 0.4);
} else if (startDir === 1) { // West
nextX -= Math.floor(stepSize * (0.8 + random() * 0.4));
nextY += Math.floor((random() - 0.5) * height * 0.4);
} else if (startDir === 2) { // South
nextY += Math.floor(stepSize * (0.8 + random() * 0.4));
nextX += Math.floor((random() - 0.5) * width * 0.4);
} else { // North
nextY -= Math.floor(stepSize * (0.8 + random() * 0.4));
nextX += Math.floor((random() - 0.5) * width * 0.4);
}
currA = {
x: Math.max(margin / 2, Math.min(width - margin / 2, nextX)),
y: Math.max(margin / 2, Math.min(height - margin / 2, nextY))
};
anchors.push({ ...currA });
}
// 2. Place Primary Rooms at anchors and connect them
let prevCenter: Vec2 | null = null;
for (const anchor of anchors) {
const rw = 7 + Math.floor(random() * 6);
const rh = 6 + Math.floor(random() * 6);
const rx = Math.floor(anchor.x - rw / 2);
const ry = Math.floor(anchor.y - rh / 2);
const room: Room = { x: rx, y: ry, width: rw, height: rh };
// Dig room interior
for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
tiles[y * width + x] = TileType.EMPTY;
}
}
}
rooms.push(room);
const currCenter = { x: rx + Math.floor(rw / 2), y: ry + Math.floor(rh / 2) };
// 3. Connect to previous room and lay track
if (prevCenter) {
// Connect path
const segment: Vec2[] = [];
let tx = prevCenter.x;
let ty = prevCenter.y;
const dig = (x: number, y: number) => {
for (let dy = 0; dy <= 1; dy++) {
for (let dx = 0; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
tiles[ny * width + nx] = TileType.EMPTY;
}
} }
rooms.push({
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1
});
} }
if (!segment.find(p => p.x === x && p.y === y)) {
segment.push({ x, y });
}
};
// Simple L-shape for tracks within/between rooms
while (tx !== currCenter.x) {
tx += currCenter.x > tx ? 1 : -1;
dig(tx, ty);
} }
while (ty !== currCenter.y) {
ty += currCenter.y > ty ? 1 : -1;
dig(tx, ty);
}
trackPath.push(...segment);
} else {
trackPath.push(currCenter);
}
prevCenter = currCenter;
}
// 4. Branch Side Rooms off the main path
const targetSideRooms = 10;
let attempts = 0;
const maxAttempts = 300;
while (rooms.length < targetSideRooms + anchors.length && attempts < maxAttempts) {
attempts++;
const sourcePathIdx = Math.floor(random() * trackPath.length);
const source = trackPath[sourcePathIdx];
const rw = 5 + Math.floor(random() * 5); // Slightly smaller rooms to fit better
const rh = 4 + Math.floor(random() * 5);
// Try multiple offsets to find a gap
const distances = [5, 6, 7, 8];
const sides = [-1, 1];
let placed = false;
for (const dist of distances) {
for (const side of sides) {
let rx, ry;
if (random() < 0.5) { // Try horizontal offset
rx = source.x + (side * dist);
ry = source.y - Math.floor(rh / 2);
} else { // Try vertical offset
rx = source.x - Math.floor(rw / 2);
ry = source.y + (side * dist);
}
rx = Math.max(1, Math.min(width - rw - 1, rx));
ry = Math.max(1, Math.min(height - rh - 1, ry));
const room = { x: rx, y: ry, width: rw, height: rh };
// 1. Check overlap with existing rooms (strict padding)
const overlapRooms = rooms.some(r => !(room.x + room.width < r.x - 1 || room.x > r.x + r.width + 1 || room.y + room.height < r.y - 1 || room.y > r.y + r.height + 1));
if (overlapRooms) continue;
// 2. Check overlap with existing core structures (EMPTY tiles)
let overlapEmpty = false;
for (let y = ry - 1; y < ry + rh + 1; y++) {
for (let x = rx - 1; x < rx + rw + 1; x++) {
if (tiles[y * width + x] === TileType.EMPTY) {
overlapEmpty = true;
break;
}
}
if (overlapEmpty) break;
}
if (overlapEmpty) continue;
// Valid spot found!
for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
tiles[y * width + x] = TileType.EMPTY;
}
}
digCorridor(width, tiles, source.x, source.y, rx + Math.floor(rw / 2), ry + Math.floor(rh / 2));
// Place door at room boundary
let ex = rx + Math.floor(rw / 2);
let ey = ry + (source.y <= ry ? 0 : rh - 1);
if (source.x < rx) {
ex = rx; ey = ry + Math.floor(rh / 2);
} else if (source.x >= rx + rw) {
ex = rx + rw - 1; ey = ry + Math.floor(rh / 2);
} else if (source.y < ry) {
ex = rx + Math.floor(rw / 2); ey = ry;
} else if (source.y >= ry + rh) {
ex = rx + Math.floor(rw / 2); ey = ry + rh - 1;
}
tiles[ey * width + ex] = TileType.DOOR_CLOSED;
rooms.push(room);
placed = true;
break;
}
if (placed) break;
} }
} }
return rooms; console.log(`[generator] Final side rooms placed: ${rooms.length - anchors.length} after ${attempts} attempts.`);
// Place visual exit at track end
const lastNode = trackPath[trackPath.length - 1];
tiles[lastNode.y * width + lastNode.x] = TileType.EXIT;
return { rooms, trackPath };
} }
/**
* Flood fill to find connected floor tiles
*/
function floodFill(width: number, height: number, tiles: Tile[], startX: number, startY: number, visited: Set<number>): number[] {
const cluster: number[] = [];
const queue: number[] = [startY * width + startX];
while (queue.length > 0) { function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) {
const idx = queue.shift()!; let currX = x1;
if (visited.has(idx)) continue; let currY = y1;
visited.add(idx); while (currX !== x2 || currY !== y2) {
cluster.push(idx); if (currX !== x2) {
currX += x2 > currX ? 1 : -1;
const x = idx % width; } else if (currY !== y2) {
const y = Math.floor(idx / width); currY += y2 > currY ? 1 : -1;
}
// Check 4 directions // Only dig if it's currently a wall
const neighbors = [ if (tiles[currY * width + currX] === TileType.WALL) {
{ nx: x + 1, ny: y }, tiles[currY * width + currX] = TileType.EMPTY;
{ nx: x - 1, ny: y },
{ nx: x, ny: y + 1 },
{ nx: x, ny: y - 1 },
];
for (const { nx, ny } of neighbors) {
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx;
if (tiles[nIdx] === TileType.EMPTY && !visited.has(nIdx)) {
queue.push(nIdx);
}
}
} }
} }
return cluster;
} }
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
function decorate(width: number, height: number, tiles: Tile[], random: () => number, _exit: Vec2): void {
const world = { width, height }; const world = { width, height };
// Stairs removed as per user request
// Set exit tile
tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT;
// Use Simplex noise for natural-looking grass distribution // Use Simplex noise for natural-looking grass distribution
const grassNoise = new ROT.Noise.Simplex(); const grassNoise = new ROT.Noise.Simplex();
@@ -345,7 +415,6 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
if (decoValue > 0.5) { if (decoValue > 0.5) {
tiles[i] = TileType.EMPTY_DECO; tiles[i] = TileType.EMPTY_DECO;
} else if (decoValue > 0.3 && random() < 0.3) { } else if (decoValue > 0.3 && random() < 0.3) {
// Sparse decorations at medium noise levels
tiles[i] = TileType.EMPTY_DECO; tiles[i] = TileType.EMPTY_DECO;
} }
} }
@@ -360,20 +429,28 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
const nextY = idx(world as any, x, y + 1); const nextY = idx(world as any, x, y + 1);
if (tiles[i] === TileType.WALL && if (tiles[i] === TileType.WALL &&
tiles[nextY] === TileType.GRASS && tiles[nextY] === TileType.GRASS &&
random() < 0.25) { random() < 0.25) {
tiles[i] = TileType.WALL_DECO; tiles[i] = TileType.WALL_DECO;
} }
} }
} }
} }
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void { function placeEnemies(
let enemyId = 2; floor: number,
rooms: Room[],
ecsWorld: ECSWorld,
tiles: Tile[],
width: number,
random: () => number,
occupiedPositions: Set<string>
): void {
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);
const occupiedPositions = new Set<string>();
if (rooms.length < 2) return;
for (let i = 0; i < numEnemies; i++) { for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0) // Pick a random room (not the starting room 0)
@@ -381,56 +458,42 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
const room = rooms[roomIdx]; const room = rooms[roomIdx];
// Try to find an empty spot in the room // Try to find an empty spot in the room
for (let attempts = 0; attempts < 5; attempts++) { for (let attempts = 0; attempts < 20; attempts++) {
const ex = room.x + 1 + Math.floor(random() * (room.width - 2)); const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2)); const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`; const k = `${ex},${ey}`;
const tileIdx = ey * width + ex;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (!occupiedPositions.has(k)) { if (isFloor && !occupiedPositions.has(k)) {
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies; const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type]; const enemyDef = GAME_CONFIG.enemies[type];
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)), .withSprite(type, 0)
stats: { .withName(type.charAt(0).toUpperCase() + type.slice(1))
.withCombat()
.withStats({
maxHp: scaledHp + Math.floor(random() * 4), maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4), hp: scaledHp + Math.floor(random() * 4),
maxMana: 0,
mana: 0,
attack: scaledAttack + Math.floor(random() * 2), attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense, defense: enemyDef.baseDefense,
level: 0, })
exp: 0, .withEnergy(speed) // Configured speed
expToNextLevel: 0, .build();
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;
} }
} }
@@ -458,6 +521,8 @@ function placeTraps(
const maxTraps = minTraps + 2; const maxTraps = minTraps + 2;
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1)); const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
if (rooms.length < 2) return;
for (let i = 0; i < numTraps; i++) { for (let i = 0; i < numTraps; i++) {
// Pick a random room (not the starting room) // Pick a random room (not the starting room)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1)); const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
@@ -472,8 +537,8 @@ function placeTraps(
// Check if position is valid (floor tile, not occupied) // Check if position is valid (floor tile, not occupied)
const tileIdx = ty * width + tx; const tileIdx = ty * width + tx;
const isFloor = tiles[tileIdx] === TileType.EMPTY || const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO || tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS; tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (isFloor && !occupiedPositions.has(key)) { if (isFloor && !occupiedPositions.has(key)) {
// Pick a random trap type // Pick a random trap type
@@ -505,39 +570,3 @@ function placeTraps(
export const makeTestWorld = generateWorld; export const makeTestWorld = generateWorld;
function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[], random: () => number): void {
const checkAndPlaceDoor = (x: number, y: number) => {
const i = idx({ width, height } as any, x, y);
if (tiles[i] === TileType.EMPTY) {
// Found a connection (floor tile on perimeter)
// 50% chance to place a door
if (random() < 0.5) {
// 90% chance for closed door, 10% for open
tiles[i] = random() < 0.9 ? TileType.DOOR_CLOSED : TileType.DOOR_OPEN;
}
}
};
for (const room of rooms) {
// Scan top and bottom walls
const topY = room.y - 1;
const bottomY = room.y + room.height;
// Scan horizontal perimeters (iterate x from left-1 to right+1 to cover corners too if needed,
// but usually doors are in the middle segments. Let's cover the full range adjacent to room.)
for (let x = room.x; x < room.x + room.width; x++) {
if (topY >= 0) checkAndPlaceDoor(x, topY);
if (bottomY < height) checkAndPlaceDoor(x, bottomY);
}
// Scan left and right walls
const leftX = room.x - 1;
const rightX = room.x + room.width;
for (let y = room.y; y < room.y + room.height; y++) {
if (leftX >= 0) checkAndPlaceDoor(leftX, y);
if (rightX < width) checkAndPlaceDoor(rightX, y);
}
}
}

View File

@@ -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,13 +44,13 @@ 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;
}; };
// Use rot-js A* pathfinding with 4-directional topology // Use rot-js A* pathfinding with 8-directional topology
const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 4 }); const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 8 });
const path: Vec2[] = []; const path: Vec2[] = [];

View File

@@ -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,27 @@ 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 if (actors.some(a => a.category === "combatant")) return true;
return actors.some(a => a.category === "combatant");
// Check for interactable entities (switches, etc.) that should block movement
if (accessor.context) {
const ecs = accessor.context;
const isInteractable = ecs.getEntitiesWith("position", "trigger").some(id => {
const p = ecs.getComponent(id, "position");
const t = ecs.getComponent(id, "trigger");
return p?.x === x && p?.y === y && t?.onInteract;
});
if (isInteractable) return true;
} }
for (const a of w.actors.values()) {
if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true;
}
return false; return false;
} }
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
const p = w.actors.get(playerId);
if (!p) return false;
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
}

View File

@@ -1,14 +1,16 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types"; import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
import { TileType } from "../core/terrain";
import { TILE_SIZE } from "../core/constants"; import { TILE_SIZE } from "../core/constants";
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";
import { ALL_TEMPLATES } from "../core/config/Items";
import { FovManager } from "./FovManager"; import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer"; 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,8 +27,10 @@ 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();
private trackSprites: Phaser.GameObjects.Sprite[] = [];
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
this.scene = scene; this.scene = scene;
@@ -35,43 +39,83 @@ 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.trackSprites) {
sprite.destroy();
}
this.trackSprites = [];
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({
data: Array.from({ length: world.height }, (_, y) => tileWidth: TILE_SIZE,
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)]) tileHeight: TILE_SIZE,
), width: world.width,
tileWidth: 16, height: world.height
tileHeight: 16
}); });
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!; const tileset = this.map.addTilesetImage("dungeon", "dungeon");
this.layer = this.map.createLayer(0, tileset, 0, 0)!; if (!tileset) {
this.layer.setDepth(0); console.error("[DungeonRenderer] FAILED to load tileset 'dungeon'!");
// Fallback or throw?
}
// Initial tile states (hidden) this.layer = this.map.createBlankLayer("floor", tileset || "dungeon")!;
this.layer.forEachTile(tile => { if (this.layer) {
tile.setVisible(false); this.layer.setDepth(0);
}); this.layer.setVisible(true);
console.log(`[DungeonRenderer] Layer created. Size: ${world.width}x${world.height}`);
} else {
console.error("[DungeonRenderer] FAILED to create tilemap layer!");
}
let tilesPlaced = 0;
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = y * world.width + x;
const tile = world.tiles[i];
if (tile !== undefined && this.layer) {
this.layer.putTileAt(tile, x, y);
tilesPlaced++;
}
}
}
console.log(`[DungeonRenderer] Placed ${tilesPlaced} tiles.`);
this.fxRenderer.clearCorpses(); this.fxRenderer.clearCorpses();
// Ensure player sprite exists // Ensure player sprite exists
if (!this.playerSprite) { if (!this.playerSprite) {
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0); this.playerSprite = this.scene.add.sprite(0, 0, "PriestessSouth");
this.playerSprite.setDepth(100); this.playerSprite.setDepth(100);
this.playerSprite.play('warrior-idle'); this.playerSprite.setDisplaySize(TILE_SIZE, TILE_SIZE); // Ensure it fits in 1 tile
// No animation for simple sprites for now
} }
this.minimapRenderer.positionMinimap(); this.minimapRenderer.positionMinimap();
@@ -81,8 +125,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,
@@ -91,25 +135,44 @@ export class DungeonRenderer {
} }
} }
// Create sprites for ECS trap entities // Create sprites for ECS entities with sprites (traps, mine carts, etc.)
if (this.ecsWorld) { if (this.ecsWorld) {
const traps = this.ecsWorld.getEntitiesWith("trigger", "position", "sprite"); console.log(`[DungeonRenderer] Creating ECS sprites...`);
for (const trapId of traps) { const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite");
const pos = this.ecsWorld.getComponent(trapId, "position"); for (const entId of spriteEntities) {
const spriteData = this.ecsWorld.getComponent(trapId, "sprite"); // Skip combatants as they are handled separately (player and enemies)
const player = this.ecsWorld.getComponent(entId, "player");
if (player) continue;
const actorType = this.ecsWorld.getComponent(entId, "actorType");
if (actorType) continue;
const pos = this.ecsWorld.getComponent(entId, "position");
const spriteData = this.ecsWorld.getComponent(entId, "sprite");
if (pos && spriteData) { if (pos && spriteData) {
const sprite = this.scene.add.sprite( try {
pos.x * TILE_SIZE + TILE_SIZE / 2, const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head" || spriteData.texture === "track_switch";
pos.y * TILE_SIZE + TILE_SIZE / 2, const sprite = this.scene.add.sprite(
spriteData.texture, pos.x * TILE_SIZE + TILE_SIZE / 2,
spriteData.index pos.y * TILE_SIZE + TILE_SIZE / 2,
); spriteData.texture,
sprite.setDepth(5); // Below actors, above floor isStandalone ? undefined : (spriteData.index ?? 0)
sprite.setVisible(false); // Hidden until FOV reveals );
this.trapSprites.set(trapId, sprite); sprite.setDepth(5);
sprite.setVisible(true); // Force visible for diagnostics
sprite.setAlpha(1.0); // Force opaque for diagnostics
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
console.log(`[DungeonRenderer] Created sprite for ${spriteData.texture} at ${pos.x},${pos.y}`);
this.trapSprites.set(entId, sprite);
} catch (e) {
console.error(`[DungeonRenderer] Failed to create sprite for entity ${entId}:`, e);
}
} }
} }
} }
// Render static tracks
this.renderTracks();
} }
@@ -122,8 +185,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 {
@@ -131,21 +197,40 @@ export class DungeonRenderer {
} }
updateTile(x: number, y: number) { updateTile(x: number, y: number) {
if (!this.map || !this.world) return; if (!this.map || !this.world) return;
const t = this.world.tiles[idx(this.world, x, y)]; const t = this.world.tiles[idx(this.world, x, y)];
this.map.putTileAt(t, x, y); this.map.putTileAt(t, x, y);
} }
get seenArray() { get seenArray() {
return this.fovManager.seenArray; return this.fovManager.seenArray;
} }
private firstRender = true;
render(_playerPath: Vec2[]) { render(_playerPath: Vec2[]) {
if (!this.world || !this.layer) return; if (!this.world || !this.layer) return;
if (this.firstRender) {
console.log(`[DungeonRenderer] First render call... World: ${this.world.width}x${this.world.height}`);
this.firstRender = false;
}
const seen = this.fovManager.seenArray; const seen = this.fovManager.seenArray;
const visible = this.fovManager.visibleArray; const visible = this.fovManager.visibleArray;
// Pre-collect fire positions for efficient tile tinting
const firePositions = new Set<string>();
if (this.ecsWorld) {
const fires = this.ecsWorld.getEntitiesWith("position", "name");
for (const fid of fires) {
if (this.ecsWorld.getComponent(fid, "name")?.name === "Fire") {
const pos = this.ecsWorld.getComponent(fid, "position")!;
firePositions.add(`${pos.x},${pos.y}`);
}
}
}
// Update Tiles // Update Tiles
this.layer.forEachTile(tile => { this.layer.forEachTile(tile => {
const i = idx(this.world, tile.x, tile.y); const i = idx(this.world, tile.x, tile.y);
@@ -153,8 +238,6 @@ export class DungeonRenderer {
// Sync visual tile with logical tile (e.g. if grass was destroyed) // Sync visual tile with logical tile (e.g. if grass was destroyed)
if (tile.index !== worldTile) { if (tile.index !== worldTile) {
// We can safely update the index property for basic tile switching
// If we needed to change collision properties, we'd use putTileAt
tile.index = worldTile; tile.index = worldTile;
} }
@@ -168,6 +251,13 @@ export class DungeonRenderer {
if (isVis) { if (isVis) {
tile.alpha = 1.0; tile.alpha = 1.0;
tile.tint = 0xffffff; tile.tint = 0xffffff;
// Special effect for burning grass
if (firePositions.has(`${tile.x},${tile.y}`) && worldTile === TileType.GRASS) {
const flicker = 0.8 + Math.sin(this.scene.time.now / 120) * 0.2;
tile.tint = 0xff3333; // Bright red
tile.alpha = flicker;
}
} else { } else {
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2; tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
tile.tint = 0x888888; tile.tint = 0x888888;
@@ -175,28 +265,99 @@ export class DungeonRenderer {
} }
}); });
// Update track sprites visibility
for (const sprite of this.trackSprites) {
const tx = Math.floor(sprite.x / TILE_SIZE);
const ty = Math.floor(sprite.y / TILE_SIZE);
const i = idx(this.world, tx, ty);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
sprite.setVisible(isSeen);
sprite.alpha = isVis ? 1.0 : 0.3;
}
// Update trap sprites visibility and appearance // Update trap sprites visibility and appearance
if (this.ecsWorld) { if (this.ecsWorld) {
for (const [trapId, sprite] of this.trapSprites) { for (const [trapId, sprite] of this.trapSprites) {
const pos = this.ecsWorld.getComponent(trapId, "position"); const pos = this.ecsWorld.getComponent(trapId, "position");
const spriteData = this.ecsWorld.getComponent(trapId, "sprite"); const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
if (pos && spriteData) { // Handle missing components (entity destroyed)
const i = idx(this.world, pos.x, pos.y); if (!pos || !spriteData) {
const isSeen = seen[i] === 1; sprite.destroy();
const isVis = visible[i] === 1; this.trapSprites.delete(trapId);
continue;
}
sprite.setVisible(isSeen); // Bounds check
if (pos.x < 0 || pos.x >= this.world.width || pos.y < 0 || pos.y >= this.world.height) {
sprite.setVisible(false);
continue;
}
// Update sprite frame in case trap was triggered const i = idx(this.world, pos.x, pos.y);
if (sprite.frame.name !== String(spriteData.index)) { const isSeen = seen[i] === 1;
sprite.setFrame(spriteData.index); const isVis = visible[i] === 1;
sprite.setVisible(isSeen);
// Update position (with simple smoothing)
const targetX = pos.x * TILE_SIZE + TILE_SIZE / 2;
const targetY = pos.y * TILE_SIZE + TILE_SIZE / 2;
if (sprite.x !== targetX || sprite.y !== targetY) {
// Check if it's far away (teleport) or nearby (tween)
const dist = Phaser.Math.Distance.Between(sprite.x, sprite.y, targetX, targetY);
if (dist > TILE_SIZE * 2) {
this.scene.tweens.killTweensOf(sprite);
sprite.setPosition(targetX, targetY);
} else if (!this.scene.tweens.isTweening(sprite)) {
this.scene.tweens.add({
targets: sprite,
x: targetX,
y: targetY,
duration: GAME_CONFIG.rendering.moveDuration,
ease: 'Power1'
});
} }
}
// Dim if not currently visible
if (isSeen && !isVis) { // Update sprite frame in case trap was triggered
sprite.setAlpha(0.4); const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
sprite.setTint(0x888888); if (!isStandalone && sprite.frame.name !== String(spriteData.index)) {
sprite.setFrame(spriteData.index);
}
// Dim if not currently visible
if (isSeen && !isVis) {
sprite.setAlpha(0.4);
sprite.setTint(0x888888);
} else {
// Flickering effect for Fire
const name = this.ecsWorld.getComponent(trapId, "name");
if (name?.name === "Fire") {
const flicker = 0.8 + Math.sin(this.scene.time.now / 100) * 0.2;
sprite.setAlpha(flicker);
sprite.setScale(0.9 + Math.sin(this.scene.time.now / 150) * 0.1);
// Tint based on underlying tile
const tileIdx = idx(this.world, pos.x, pos.y);
const worldTile = this.world.tiles[tileIdx];
if (worldTile === TileType.GRASS) {
sprite.setTint(0xff3300); // Bright red-orange for burning grass
} else if (worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
// Pulse between yellow and red for doors
const pulse = (Math.sin(this.scene.time.now / 150) + 1) / 2;
const r = 255;
const g = Math.floor(200 * (1 - pulse));
const b = 0;
sprite.setTint((r << 16) | (g << 8) | b);
} else {
sprite.setTint(0xffaa44); // Default orange
}
} else { } else {
sprite.setAlpha(1); sprite.setAlpha(1);
sprite.clearTint(); sprite.clearTint();
@@ -210,7 +371,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;
@@ -221,16 +383,36 @@ export class DungeonRenderer {
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2; const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) { if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) {
this.scene.tweens.add({ // Determine direction
targets: this.playerSprite, const dx = tx - this.playerSprite.x;
x: tx, const dy = ty - this.playerSprite.y;
y: ty,
duration: 120, if (Math.abs(dy) > Math.abs(dx)) {
ease: 'Quad.easeOut', if (dy < 0) this.playerSprite.setTexture("PriestessNorth");
overwrite: true else this.playerSprite.setTexture("PriestessSouth");
}); } else if (Math.abs(dx) > 0) {
if (dx > 0) this.playerSprite.setTexture("PriestessEast");
else this.playerSprite.setTexture("PriestessWest");
}
this.scene.tweens.add({
targets: this.playerSprite,
x: tx,
y: ty,
duration: GAME_CONFIG.rendering.moveDuration,
ease: 'Quad.easeOut',
overwrite: true
});
} }
this.playerSprite.setVisible(true); this.playerSprite.setVisible(true);
// Burning status effect
const statusEffects = this.ecsWorld.getComponent(this.entityAccessor.playerId, "statusEffects");
if (statusEffects?.effects.some(e => e.type === "burning")) {
this.playerSprite.setTint(0xff6600);
} else {
this.playerSprite.clearTint();
}
} }
continue; continue;
} }
@@ -252,22 +434,30 @@ export class DungeonRenderer {
this.enemySprites.set(a.id, sprite); this.enemySprites.set(a.id, sprite);
sprite.setVisible(true); sprite.setVisible(true);
} else { } else {
if (!sprite.visible) { if (!sprite.visible) {
// If it was hidden, snap to new position immediately // If it was hidden, snap to new position immediately
this.scene.tweens.killTweensOf(sprite); this.scene.tweens.killTweensOf(sprite);
sprite.setPosition(tx, ty); sprite.setPosition(tx, ty);
sprite.setVisible(true); sprite.setVisible(true);
} else if (sprite.x !== tx || sprite.y !== ty) { } else if (sprite.x !== tx || sprite.y !== ty) {
// Only tween if it was already visible and moved // Only tween if it was already visible and moved
this.scene.tweens.add({ this.scene.tweens.add({
targets: sprite, targets: sprite,
x: tx, x: tx,
y: ty, y: ty,
duration: 120, duration: GAME_CONFIG.rendering.moveDuration,
ease: 'Quad.easeOut', ease: 'Quad.easeOut',
overwrite: true overwrite: true
}); });
} }
}
// Burning status effect
const statusEffects = this.ecsWorld.getComponent(a.id, "statusEffects");
if (statusEffects?.effects.some(e => e.type === "burning")) {
sprite.setTint(0xff6600);
} else if (sprite) {
sprite.clearTint();
} }
} else if (a.category === "collectible") { } else if (a.category === "collectible") {
@@ -286,23 +476,23 @@ export class DungeonRenderer {
orb.setVisible(true); orb.setVisible(true);
} }
} else if (a.category === "item_drop") { } else if (a.category === "item_drop") {
if (!isVis) continue; if (!isVis) continue;
activeItemIds.add(a.id); activeItemIds.add(a.id);
let itemContainer = this.itemSprites.get(a.id); let itemContainer = this.itemSprites.get(a.id);
if (!itemContainer) { if (!itemContainer) {
// Use ItemSpriteFactory to create sprite with optional glow // Use ItemSpriteFactory to create sprite with optional glow
itemContainer = ItemSpriteFactory.createItemSprite(this.scene, a.item, 0, 0, 1); itemContainer = ItemSpriteFactory.createItemSprite(this.scene, a.item, 0, 0, 1);
itemContainer.setDepth(40); itemContainer.setDepth(40);
this.itemSprites.set(a.id, itemContainer); this.itemSprites.set(a.id, itemContainer);
} }
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2; const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2; const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
itemContainer.setPosition(tx, ty); itemContainer.setPosition(tx, ty);
itemContainer.setVisible(true); itemContainer.setVisible(true);
// bobbing effect on the container // bobbing effect on the container
itemContainer.y += Math.sin(this.scene.time.now / 300) * 2; itemContainer.y += Math.sin(this.scene.time.now / 300) * 2;
} }
} }
@@ -310,7 +500,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 +510,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);
} }
@@ -328,16 +518,17 @@ 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);
this.fxRenderer.updateVisibility(seen, visible, this.world.width);
} }
// FX Delegations // FX Delegations
@@ -353,7 +544,19 @@ export class DungeonRenderer {
this.fxRenderer.showHeal(x, y, amount); this.fxRenderer.showHeal(x, y, amount);
} }
spawnCorpse(x: number, y: number, type: ActorType) { spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId) {
if (targetId !== undefined) {
if (targetId === this.entityAccessor.playerId) {
if (this.playerSprite) {
this.playerSprite.setVisible(false);
}
} else {
const sprite = this.enemySprites.get(targetId);
if (sprite) {
sprite.setVisible(false);
}
}
}
this.fxRenderer.spawnCorpse(x, y, type); this.fxRenderer.spawnCorpse(x, y, type);
} }
@@ -381,45 +584,102 @@ export class DungeonRenderer {
this.fxRenderer.showFloatingText(x, y, message, color); this.fxRenderer.showFloatingText(x, y, message, color);
} }
showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) { showProjectile(from: Vec2, to: Vec2, texture: string, frame: number, onComplete: () => void) {
// World coords // World coords
const startX = from.x * TILE_SIZE + TILE_SIZE / 2; const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
const startY = from.y * TILE_SIZE + TILE_SIZE / 2; const startY = from.y * TILE_SIZE + TILE_SIZE / 2;
const endX = to.x * TILE_SIZE + TILE_SIZE / 2; const endX = to.x * TILE_SIZE + TILE_SIZE / 2;
const endY = to.y * TILE_SIZE + TILE_SIZE / 2; const endY = to.y * TILE_SIZE + TILE_SIZE / 2;
// Create sprite // Create sprite
// Look up sprite index from config const isStandalone = frame === undefined || frame === 0;
const itemConfig = ALL_TEMPLATES[itemId as keyof typeof ALL_TEMPLATES]; const sprite = isStandalone
const texture = itemConfig?.textureKey ?? "items"; ? this.scene.add.sprite(startX, startY, texture)
const frame = itemConfig?.spriteIndex ?? 0; : this.scene.add.sprite(startX, startY, texture, frame);
// Use 'items' spritesheet // Ensure all sprites fit in a single 16x16 tile.
const sprite = this.scene.add.sprite(startX, startY, texture, frame); sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
sprite.setDepth(2000);
// Rotate? sprite.setDepth(2000);
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
const dist = Phaser.Math.Distance.Between(startX, startY, endX, endY);
const duration = dist * 2; // speed
this.scene.tweens.add({ // Rotate?
targets: sprite, const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
x: endX, sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
y: endY,
rotation: sprite.rotation + 4 * Math.PI, // Spin effect const dist = Phaser.Math.Distance.Between(startX, startY, endX, endY);
duration: duration, const duration = dist * 2; // speed
ease: 'Linear',
onComplete: () => { this.scene.tweens.add({
sprite.destroy(); targets: sprite,
onComplete(); x: endX,
} y: endY,
}); rotation: sprite.rotation + 4 * Math.PI, // Spin effect
duration: duration,
ease: 'Linear',
onComplete: () => {
sprite.destroy();
onComplete();
}
});
} }
shakeCamera() { shakeCamera() {
this.scene.cameras.main.shake(100, 0.01); this.scene.cameras.main.shake(100, 0.01);
}
private renderTracks() {
if (!this.world.trackPath || this.world.trackPath.length === 0) return;
const path = this.world.trackPath;
for (let i = 0; i < path.length; i++) {
const curr = path[i];
const prev = i > 0 ? path[i - 1] : null;
const next = i < path.length - 1 ? path[i + 1] : null;
let spriteKey = "track_straight";
let angle = 0;
if (prev && next) {
const dx1 = curr.x - prev.x;
const dy1 = curr.y - prev.y;
const dx2 = next.x - curr.x;
const dy2 = next.y - curr.y;
if (dx1 === dx2 && dy1 === dy2) {
// Straight
spriteKey = "track_straight";
angle = dx1 === 0 ? 0 : 90; // Asset is vertical (0 deg), rotate to 90 for horizontal
} else {
// Corner
spriteKey = "track_corner";
const p = { dx: prev.x - curr.x, dy: prev.y - curr.y };
const n = { dx: next.x - curr.x, dy: next.y - curr.y };
// Top-Right: 180, Right-Bottom: 270, Bottom-Left: 0, Left-Top: 90
if ((p.dy === -1 && n.dx === 1) || (n.dy === -1 && p.dx === 1)) angle = 180;
else if ((p.dx === 1 && n.dy === 1) || (n.dx === 1 && p.dy === 1)) angle = 270;
else if ((p.dy === 1 && n.dx === -1) || (n.dy === 1 && p.dx === -1)) angle = 0;
else if ((p.dx === -1 && n.dy === -1) || (n.dx === -1 && p.dy === -1)) angle = 90;
}
} else if (next) {
spriteKey = "track_straight";
angle = (next.x === curr.x) ? 0 : 90;
} else if (prev) {
spriteKey = "track_straight";
angle = (prev.x === curr.x) ? 0 : 90;
}
const sprite = this.scene.add.sprite(
curr.x * TILE_SIZE + TILE_SIZE / 2,
curr.y * TILE_SIZE + TILE_SIZE / 2,
spriteKey
);
sprite.setAngle(angle);
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
sprite.setDepth(2);
sprite.setVisible(false);
this.trackSprites.push(sprite);
}
} }
} }

View File

@@ -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";
@@ -13,6 +13,7 @@ export class FovManager {
private visibleStrength!: Float32Array; private visibleStrength!: Float32Array;
private worldWidth: number = 0; private worldWidth: number = 0;
private worldHeight: number = 0; private worldHeight: number = 0;
private currentOrigin: { x: number; y: number } = { x: 0, y: 0 };
initialize(world: World) { initialize(world: World) {
this.worldWidth = world.width; this.worldWidth = world.width;
@@ -22,19 +23,23 @@ export class FovManager {
this.visibleStrength = new Float32Array(world.width * world.height); this.visibleStrength = new Float32Array(world.width * world.height);
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => { this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
// Best practice: Origin is always transparent to itself,
// otherwise vision is blocked if standing on an opaque tile (like a doorway).
if (x === this.currentOrigin.x && y === this.currentOrigin.y) return true;
if (!inBounds(world, x, y)) return false; if (!inBounds(world, x, y)) return false;
const idx = y * world.width + x; const idx = y * world.width + x;
return !blocksSight(world.tiles[idx]); return !blocksSight(world.tiles[idx]);
}); });
} }
compute(world: World, playerId: EntityId) { compute(world: World, origin: { x: number; y: number }) {
this.currentOrigin = origin;
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;

View File

@@ -5,7 +5,7 @@ import { GAME_CONFIG } from "../core/config/GameConfig";
export class FxRenderer { export class FxRenderer {
private scene: Phaser.Scene; private scene: Phaser.Scene;
private corpseSprites: Phaser.GameObjects.Sprite[] = []; private corpseSprites: { sprite: Phaser.GameObjects.Sprite; x: number; y: number }[] = [];
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
this.scene = scene; this.scene = scene;
@@ -34,8 +34,8 @@ export class FxRenderer {
} }
clearCorpses() { clearCorpses() {
for (const sprite of this.corpseSprites) { for (const entry of this.corpseSprites) {
sprite.destroy(); entry.sprite.destroy();
} }
this.corpseSprites = []; this.corpseSprites = [];
} }
@@ -49,9 +49,9 @@ export class FxRenderer {
let fontSize = "16px"; let fontSize = "16px";
if (isCrit) { if (isCrit) {
textStr += "!"; textStr += "!";
color = "#ffff00"; color = "#ffff00";
fontSize = "22px"; fontSize = "22px";
} }
const text = this.scene.add.text(screenX, screenY, textStr, { const text = this.scene.add.text(screenX, screenY, textStr, {
@@ -63,19 +63,19 @@ export class FxRenderer {
}).setOrigin(0.5, 1).setDepth(200); }).setOrigin(0.5, 1).setDepth(200);
if (isBlock) { if (isBlock) {
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", { const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
fontSize: "10px", fontSize: "10px",
color: "#888888", color: "#888888",
fontStyle: "bold" fontStyle: "bold"
}).setOrigin(0, 1).setDepth(200); }).setOrigin(0, 1).setDepth(200);
this.scene.tweens.add({ this.scene.tweens.add({
targets: blockText, targets: blockText,
y: screenY - 34, y: screenY - 34,
alpha: 0, alpha: 0,
duration: 800, duration: 800,
onComplete: () => blockText.destroy() onComplete: () => blockText.destroy()
}); });
} }
this.scene.tweens.add({ this.scene.tweens.add({
@@ -132,7 +132,7 @@ export class FxRenderer {
} }
spawnCorpse(x: number, y: number, type: ActorType) { spawnCorpse(x: number, y: number, type: ActorType) {
const textureKey = type === "player" ? "warrior" : type; const textureKey = type === "player" ? "PriestessSouth" : type;
const corpse = this.scene.add.sprite( const corpse = this.scene.add.sprite(
x * TILE_SIZE + TILE_SIZE / 2, x * TILE_SIZE + TILE_SIZE / 2,
@@ -141,8 +141,38 @@ export class FxRenderer {
0 0
); );
corpse.setDepth(50); corpse.setDepth(50);
corpse.play(`${textureKey}-die`); corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized
this.corpseSprites.push(corpse);
// Only play animation if it's not a priestess sprite
if (!textureKey.startsWith("Priestess")) {
corpse.play(`${textureKey}-die`);
} else {
// Maybe rotate or fade for visual interest since there's no animation
corpse.setAngle(90);
}
this.corpseSprites.push({ sprite: corpse, x, y });
}
updateVisibility(seen: Uint8Array, visible: Uint8Array, worldWidth: number) {
for (const entry of this.corpseSprites) {
const idx = entry.y * worldWidth + entry.x;
const isSeen = seen[idx] === 1;
const isVisible = visible[idx] === 1;
entry.sprite.setVisible(isSeen);
if (isSeen) {
if (isVisible) {
entry.sprite.setAlpha(1);
entry.sprite.clearTint();
} else {
entry.sprite.setAlpha(0.4);
entry.sprite.setTint(0x888888);
}
}
}
} }
showWait(x: number, y: number) { showWait(x: number, y: number) {

View File

@@ -32,10 +32,21 @@ export class ItemSpriteFactory {
} }
// Create main item sprite // Create main item sprite
const sprite = scene.add.sprite(0, 0, item.textureKey, item.spriteIndex); // Standalone images don't use frame indices
sprite.setScale(scale); const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
const sprite = isStandalone
? scene.add.sprite(0, 0, item.textureKey)
: scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
if (isStandalone) {
sprite.setDisplaySize(16 * scale, 16 * scale);
} else {
sprite.setScale(scale);
}
container.add(sprite); container.add(sprite);
// Add upgrade level badge if item has been upgraded // Add upgrade level badge if item has been upgraded
if (item.upgradeLevel && item.upgradeLevel > 0) { if (item.upgradeLevel && item.upgradeLevel > 0) {
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale); const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
@@ -56,9 +67,19 @@ export class ItemSpriteFactory {
y: number, y: number,
scale: number = 1 scale: number = 1
): Phaser.GameObjects.Sprite { ): Phaser.GameObjects.Sprite {
const sprite = scene.add.sprite(x, y, item.textureKey, item.spriteIndex); const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
sprite.setScale(scale); const sprite = isStandalone
? scene.add.sprite(x, y, item.textureKey)
: scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
if (isStandalone) {
sprite.setDisplaySize(16 * scale, 16 * scale);
} else {
sprite.setScale(scale);
}
return sprite; return sprite;
} }
/** /**

View File

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

View File

@@ -1,8 +1,7 @@
import '../../__tests__/test-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DungeonRenderer } from '../DungeonRenderer';
import { type World } from '../../core/types';
// Mock Phaser // Mock Phaser - must be before imports that use it
vi.mock('phaser', () => { vi.mock('phaser', () => {
const mockSprite = { const mockSprite = {
setDepth: vi.fn().mockReturnThis(), setDepth: vi.fn().mockReturnThis(),
@@ -10,7 +9,13 @@ vi.mock('phaser', () => {
play: vi.fn().mockReturnThis(), play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(), setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(), destroy: vi.fn(),
frame: { name: '0' },
setFrame: vi.fn(),
setAlpha: vi.fn(),
setAngle: vi.fn(),
clearTint: vi.fn(),
}; };
const mockGraphics = { const mockGraphics = {
@@ -27,6 +32,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 +47,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: {
@@ -50,10 +63,17 @@ vi.mock('phaser', () => {
}; };
}); });
import { DungeonRenderer } from '../DungeonRenderer';
import type { World, EntityId } from '../../core/types';
import { ECSWorld } from '../../engine/ecs/World';
import { EntityAccessor } from '../../engine/EntityAccessor';
describe('DungeonRenderer', () => { 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();
@@ -71,14 +91,28 @@ describe('DungeonRenderer', () => {
play: vi.fn().mockReturnThis(), play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(), setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(), destroy: vi.fn(),
frame: { name: '0' },
setFrame: vi.fn(),
setAlpha: vi.fn(),
setAngle: 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 +123,7 @@ describe('DungeonRenderer', () => {
main: { main: {
width: 800, width: 800,
height: 600, height: 600,
shake: vi.fn(),
}, },
}, },
anims: { anims: {
@@ -103,6 +138,14 @@ describe('DungeonRenderer', () => {
setDepth: vi.fn(), setDepth: vi.fn(),
forEachTile: vi.fn(), forEachTile: vi.fn(),
}), }),
createBlankLayer: vi.fn().mockReturnValue({
setDepth: vi.fn().mockReturnThis(),
forEachTile: vi.fn().mockReturnThis(),
putTileAt: vi.fn(),
setScale: vi.fn().mockReturnThis(),
setScrollFactor: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
}),
destroy: vi.fn(), destroy: vi.fn(),
}), }),
}, },
@@ -110,6 +153,9 @@ describe('DungeonRenderer', () => {
add: vi.fn(), add: vi.fn(),
killTweensOf: vi.fn(), killTweensOf: vi.fn(),
}, },
time: {
now: 0
}
}; };
@@ -117,15 +163,17 @@ 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 },
trackPath: []
}; };
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 +181,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 +211,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 +236,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();
@@ -239,4 +259,43 @@ describe('DungeonRenderer', () => {
// Should NOT tween because it's the first spawn // Should NOT tween because it's the first spawn
expect(mockScene.tweens.add).not.toHaveBeenCalled(); expect(mockScene.tweens.add).not.toHaveBeenCalled();
}); });
it('should hide the original sprite when spawnCorpse is called with targetId', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add a rat
const enemyId = 100 as EntityId;
ecsWorld.addComponent(enemyId, "position", { x: 3, y: 1 });
ecsWorld.addComponent(enemyId, "actorType", { type: "rat" });
ecsWorld.addComponent(enemyId, "stats", { hp: 10, maxHp: 10 } as any);
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
renderer.render([]);
// Verify sprite was created and is visible
const sprite = (renderer as any).enemySprites.get(enemyId);
expect(sprite).toBeDefined();
expect(sprite.setVisible).toHaveBeenCalledWith(true);
// Call spawnCorpse with targetId
renderer.spawnCorpse(3, 1, 'rat', enemyId);
// Verify original sprite was hidden
expect(sprite.setVisible).toHaveBeenCalledWith(false);
});
it('should hide the player sprite when spawnCorpse is called with playerId', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Verify player sprite was created and is visible
const playerSprite = (renderer as any).playerSprite;
expect(playerSprite).toBeDefined();
playerSprite.setVisible(true); // Force visible for test
// Call spawnCorpse with playerId
renderer.spawnCorpse(1, 1, 'player', accessor.playerId);
// Verify player sprite was hidden
expect(playerSprite.setVisible).toHaveBeenCalledWith(false);
});
}); });

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock Phaser
vi.mock('phaser', () => ({
default: {
Math: {
Clamp: (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
}
}
}));
import { FovManager } from '../FovManager';
import { TileType } from '../../core/terrain';
import { type World } from '../../core/types';
describe('FovManager Repro', () => {
let fovManager: FovManager;
let world: World;
beforeEach(() => {
world = {
width: 11,
height: 11,
tiles: new Array(11 * 11).fill(TileType.EMPTY),
exit: { x: 10, y: 10 },
trackPath: []
};
fovManager = new FovManager();
});
it('should see through a doorway when standing in it (open door)', () => {
// Create a vertical wall at x=5 with a door at (5,5)
for (let y = 0; y < 11; y++) {
if (y === 5) {
world.tiles[y * 11 + 5] = TileType.DOOR_OPEN;
} else {
world.tiles[y * 11 + 5] = TileType.WALL;
}
}
fovManager.initialize(world);
fovManager.compute(world, { x: 5, y: 5 });
expect(fovManager.isVisible(4, 5)).toBe(true);
expect(fovManager.isVisible(6, 5)).toBe(true);
});
it('should NOT be blind when standing on an opaque tile (like a closed door) AFTER FIX', () => {
// Create a horizontal wall with a closed door at (5,5)
for (let x = 0; x < 11; x++) {
if (x === 5) {
world.tiles[5 * 11 + x] = TileType.DOOR_CLOSED;
} else {
world.tiles[5 * 11 + x] = TileType.WALL;
}
}
fovManager.initialize(world);
fovManager.compute(world, { x: 5, y: 5 });
// AFTER FIX: should see tiles on both sides of the door
expect(fovManager.isVisible(5, 4)).toBe(true);
expect(fovManager.isVisible(5, 6)).toBe(true);
});
});

View File

@@ -0,0 +1,91 @@
import '../../__tests__/test-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Phaser - must be before imports that use it
vi.mock('phaser', () => {
const mockSprite = {
setDepth: vi.fn().mockReturnThis(),
play: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setTint: vi.fn().mockReturnThis(),
clearTint: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(),
};
return {
default: {
GameObjects: {
Sprite: vi.fn(() => mockSprite),
},
Scene: vi.fn(),
},
};
});
import { FxRenderer } from '../FxRenderer';
describe('FxRenderer', () => {
let mockScene: any;
let fxRenderer: FxRenderer;
beforeEach(() => {
vi.clearAllMocks();
mockScene = {
add: {
sprite: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
play: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setTint: vi.fn().mockReturnThis(),
clearTint: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(),
})),
text: vi.fn(() => ({
setOrigin: vi.fn().mockReturnThis(),
setDepth: vi.fn().mockReturnThis(),
destroy: vi.fn(),
})),
},
tweens: {
add: vi.fn(),
},
};
fxRenderer = new FxRenderer(mockScene);
});
it('should update corpse visibility and appearance based on FOV', () => {
// Spawn a corpse at (5, 5)
fxRenderer.spawnCorpse(5, 5, 'rat');
const corpseSprite = mockScene.add.sprite.mock.results[0].value;
const seen = new Uint8Array(100).fill(0);
const visible = new Uint8Array(100).fill(0);
const worldWidth = 10;
const idx = 5 * worldWidth + 5;
// Case 1: Unseen tile
fxRenderer.updateVisibility(seen, visible, worldWidth);
expect(corpseSprite.setVisible).toHaveBeenCalledWith(false);
// Case 2: Seen but not currently visible (dimmed)
seen[idx] = 1;
fxRenderer.updateVisibility(seen, visible, worldWidth);
expect(corpseSprite.setVisible).toHaveBeenCalledWith(true);
expect(corpseSprite.setAlpha).toHaveBeenCalledWith(0.4);
expect(corpseSprite.setTint).toHaveBeenCalledWith(0x888888);
// Case 3: Currently visible (full brightness)
visible[idx] = 1;
fxRenderer.updateVisibility(seen, visible, worldWidth);
expect(corpseSprite.setVisible).toHaveBeenCalledWith(true);
expect(corpseSprite.setAlpha).toHaveBeenCalledWith(1);
expect(corpseSprite.clearTint).toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,12 @@ vi.mock('phaser', () => {
input = { input = {
keyboard: { keyboard: {
createCursorKeys: vi.fn(() => ({})), createCursorKeys: vi.fn(() => ({})),
addKeys: vi.fn(() => ({
W: { isDown: false },
A: { isDown: false },
S: { isDown: false },
D: { isDown: false }
})),
on: vi.fn(), on: vi.fn(),
}, },
on: vi.fn(), on: vi.fn(),
@@ -40,29 +46,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(),
}; };
} }
@@ -75,6 +81,17 @@ vi.mock('phaser', () => {
JustDown: vi.fn(), JustDown: vi.fn(),
}, },
}, },
Events: {
EventEmitter: class {
on = vi.fn();
off = vi.fn();
emit = vi.fn();
addListener = vi.fn();
removeListener = vi.fn();
removeAllListeners = vi.fn();
once = vi.fn();
}
}
} }
}; };
}); });
@@ -83,24 +100,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 +142,97 @@ 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('../systems/TargetingSystem', () => ({
TargetingSystem: class {
isActive = false;
itemId = null;
cursorPos = null;
startTargeting = vi.fn();
cancel = vi.fn();
updateCursor = vi.fn();
executeThrow = vi.fn();
},
}));
vi.mock('../systems/CameraController', () => ({
CameraController: class {
constructor() {}
handleWheel = vi.fn();
handlePan = vi.fn();
enableFollowMode = vi.fn();
setBounds = vi.fn();
centerOnTile = vi.fn();
},
}));
vi.mock('../systems/ItemManager', () => ({
ItemManager: class {
constructor() {}
updateWorld = vi.fn();
setEntityAccessor = vi.fn();
handleUse = vi.fn(() => ({ success: true, consumed: false }));
removeFromInventory = vi.fn(() => true);
spawnItem = vi.fn();
tryPickup = vi.fn();
getItem = vi.fn(() => ({ id: 'test', name: 'Test' }));
},
}));
vi.mock('../../engine/ProgressionManager', () => ({
ProgressionManager: class {
allocateStat = vi.fn();
allocatePassive = vi.fn();
},
}));
vi.mock('../../engine/systems/UpgradeManager', () => ({
UpgradeManager: {
applyUpgrade: vi.fn(),
},
}));
vi.mock('../../engine/systems/EquipmentService', () => ({
equipItem: vi.fn(),
deEquipItem: vi.fn(),
}));
vi.mock('../../engine/systems/LootSystem', () => ({
generateLoot: vi.fn(),
}));
vi.mock('../systems/EventRenderer', () => ({
renderSimEvents: vi.fn(),
getEffectColor: vi.fn((e) => e === 'poison' ? '#00ff00' : "#ffffff"),
getEffectName: vi.fn((e) => e === 'poison' ? 'Poisoned!' : e),
}));
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 +265,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 +296,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,8 +306,56 @@ 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();
}); });
it('should show damage text at the entity position when a trap damages a non-player entity', () => {
const enemyActor = {
id: 5,
pos: { x: 5, y: 5 },
category: 'combatant',
};
// Setup mocks
(scene as any).entityAccessor.getActor = vi.fn((id) => (id === 5 ? enemyActor : null));
(scene as any).ecsEventBus.drain = vi.fn(() => [
{ type: 'damage', entityId: 5, amount: 10 }
]);
// Trigger action that processes traps (like move)
(simulation.applyAction as any).mockReturnValue([{ type: 'move', targetId: 1, dx: 1, dy: 0 }]);
(scene as any).commitPlayerAction({ type: 'move', dx: 1, dy: 0 });
// Verify
expect((scene as any).dungeonRenderer.showDamage).toHaveBeenCalledWith(5, 5, 10);
});
it('should show effect text at the entity position when a trap triggers an effect on a non-player entity', () => {
const enemyActor = {
id: 7,
pos: { x: 7, y: 7 },
category: 'combatant',
};
// Setup mocks
(scene as any).entityAccessor.getActor = vi.fn((id) => (id === 7 ? enemyActor : null));
(scene as any).ecsEventBus.drain = vi.fn(() => [
{ type: 'trigger_activated', triggerId: 10, activatorId: 7 }
]);
(scene as any).ecsWorld.getComponent = vi.fn((id, type) => {
if (id === 10 && type === 'trigger') return { effect: 'poison' };
return null;
});
// Trigger action
(simulation.applyAction as any).mockReturnValue([{ type: 'move', targetId: 1, dx: 1, dy: 0 }]);
(scene as any).commitPlayerAction({ type: 'move', dx: 1, dy: 0 });
// Verify
expect((scene as any).dungeonRenderer.showFloatingText).toHaveBeenCalledWith(7, 7, 'Poisoned!', '#00ff00');
});
}); });

View File

@@ -0,0 +1,65 @@
import { DungeonRenderer } from "../../rendering/DungeonRenderer";
import { renderSimEvents, type EventRenderCallbacks } from "../systems/EventRenderer";
import { type SimEvent, type EntityId, type ActorType } from "../../core/types";
import { type EntityAccessor } from "../../engine/EntityAccessor";
export class GameRenderer implements EventRenderCallbacks {
private dungeonRenderer: DungeonRenderer;
constructor(dungeonRenderer: DungeonRenderer) {
this.dungeonRenderer = dungeonRenderer;
}
public renderEvents(events: SimEvent[], playerId: EntityId, accessor: EntityAccessor) {
renderSimEvents(events, this, {
playerId: playerId,
getPlayerPos: () => accessor.getPlayerPos()
});
}
// Delegation Methods
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void {
this.dungeonRenderer.showDamage(x, y, amount, isCrit, isBlock);
}
showDodge(x: number, y: number): void {
this.dungeonRenderer.showDodge(x, y);
}
showHeal(x: number, y: number, amount: number): void {
this.dungeonRenderer.showHeal(x, y, amount);
}
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void {
this.dungeonRenderer.spawnCorpse(x, y, type, targetId);
}
showWait(x: number, y: number): void {
this.dungeonRenderer.showWait(x, y);
}
spawnOrb(orbId: EntityId, x: number, y: number): void {
this.dungeonRenderer.spawnOrb(orbId, x, y);
}
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void {
this.dungeonRenderer.collectOrb(actorId, amount, x, y);
}
showLevelUp(x: number, y: number): void {
this.dungeonRenderer.showLevelUp(x, y);
}
showAlert(x: number, y: number): void {
this.dungeonRenderer.showAlert(x, y);
}
showFloatingText(x: number, y: number, message: string, color: string): void {
this.dungeonRenderer.showFloatingText(x, y, message, color);
}
spawnLoot(x: number, y: number, itemName: string): void {
// Optional hook if we wanted to visualize loot spawn specifically here
this.dungeonRenderer.showFloatingText(x, y, `${itemName}!`, "#ffd700");
}
}

View File

@@ -8,7 +8,7 @@ import { GAME_CONFIG } from "../../core/config/GameConfig";
*/ */
export class CameraController { export class CameraController {
private camera: Phaser.Cameras.Scene2D.Camera; private camera: Phaser.Cameras.Scene2D.Camera;
private followMode: boolean = true; private followMode: boolean = false;
constructor(camera: Phaser.Cameras.Scene2D.Camera) { constructor(camera: Phaser.Cameras.Scene2D.Camera) {
this.camera = camera; this.camera = camera;
@@ -58,7 +58,7 @@ export class CameraController {
handleWheel(deltaY: number): void { handleWheel(deltaY: number): void {
const zoomDir = deltaY > 0 ? -1 : 1; const zoomDir = deltaY > 0 ? -1 : 1;
const newZoom = Phaser.Math.Clamp( const newZoom = Phaser.Math.Clamp(
this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep, Math.round(this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep),
GAME_CONFIG.rendering.minZoom, GAME_CONFIG.rendering.minZoom,
GAME_CONFIG.rendering.maxZoom GAME_CONFIG.rendering.maxZoom
); );

View File

@@ -0,0 +1,115 @@
import type { SimEvent, ActorType, EntityId, Vec2 } from "../../core/types";
/**
* Callbacks for rendering game simulation events.
* These delegate to the actual rendering implementation.
*/
export interface EventRenderCallbacks {
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void;
showDodge(x: number, y: number): void;
showHeal(x: number, y: number, amount: number): void;
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): 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", ev.targetId);
break;
case "waited":
if (ev.actorId === context.playerId) {
const pos = context.getPlayerPos();
if (pos) {
callbacks.showWait(pos.x, pos.y);
}
}
break;
case "orb-spawned":
callbacks.spawnOrb(ev.orbId, ev.x, ev.y);
break;
case "exp-collected":
if (ev.actorId === context.playerId) {
callbacks.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
}
break;
case "leveled-up":
if (ev.actorId === context.playerId) {
callbacks.showLevelUp(ev.x, ev.y);
}
break;
case "enemy-alerted":
callbacks.showAlert(ev.x, ev.y);
break;
}
}
}
/**
* Status effect display colors and names.
*/
const EFFECT_COLORS: Record<string, string> = {
poison: "#00ff00",
burning: "#ff6600",
frozen: "#00ffff"
};
const EFFECT_NAMES: Record<string, string> = {
poison: "Poisoned!",
burning: "Burning!",
frozen: "Paralyzed!"
};
/**
* Gets the display color for a status effect.
*/
export function getEffectColor(effect: string): string {
return EFFECT_COLORS[effect] ?? "#ffffff";
}
/**
* Gets the display name for a status effect.
*/
export function getEffectName(effect: string): string {
return EFFECT_NAMES[effect] ?? effect;
}

View File

@@ -0,0 +1,286 @@
import type { GameScene } from "../GameScene";
import { UpgradeManager } from "../../engine/systems/UpgradeManager";
import { InventoryOverlay } from "../../ui/components/InventoryOverlay";
import { equipItem, deEquipItem } from "../../engine/systems/EquipmentService";
import { inBounds, isBlocked } from "../../engine/world/world-logic";
import GameUI from "../../ui/GameUI";
export class GameEventHandler {
private scene: GameScene;
constructor(scene: GameScene) {
this.scene = scene;
}
public registerListeners() {
const events = this.scene.events;
events.on("menu-toggled", (isOpen: boolean) => {
this.scene.isMenuOpen = isOpen;
});
events.on("inventory-toggled", (isOpen: boolean) => {
this.scene.isInventoryOpen = isOpen;
});
events.on("character-toggled", (isOpen: boolean) => {
this.scene.isCharacterOpen = isOpen;
});
events.on("toggle-minimap", () => {
this.scene.dungeonRenderer.toggleMinimap();
});
events.on("request-ui-update", () => {
this.scene.emitUIUpdate();
});
events.on("restart-game", () => {
this.scene.restartGame();
});
events.on("allocate-stat", (statName: string) => {
const player = this.scene.entityAccessor.getPlayer();
if (player) {
this.scene.progressionManager.allocateStat(player, statName);
this.scene.emitUIUpdate();
}
});
events.on("allocate-passive", (nodeId: string) => {
const player = this.scene.entityAccessor.getPlayer();
if (player) {
this.scene.progressionManager.allocatePassive(player, nodeId);
this.scene.emitUIUpdate();
}
});
events.on("player-wait", () => {
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
this.scene.commitPlayerAction({ type: "wait" });
});
events.on("player-search", () => {
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
console.log("Player searching...");
this.scene.commitPlayerAction({ type: "wait" });
});
events.on("use-item", (data: { itemId: string }) => {
this.handleUseItem(data.itemId);
});
events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => {
this.handleDropItem(data);
});
events.on("equip-item", (data: { itemId: string, slotKey: string }) => {
this.handleEquipItem(data);
});
events.on("de-equip-item", (data: { slotKey: string }) => {
this.handleDeEquipItem(data);
});
}
private handleUseItem(itemId: string) {
if (!this.scene.awaitingPlayer) return;
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.inventory) return;
const itemIdx = player.inventory.items.findIndex(it => it.id === itemId);
if (itemIdx === -1) return;
const item = player.inventory.items[itemIdx];
// Ranged Weapon Logic
if (item.type === "Weapon" && (item.weaponType === "ranged" || item.weaponType === "ceramic_dragon_head")) {
if (item.weaponType === "ranged") {
// Check Ammo
if (item.currentAmmo <= 0) {
if (item.reloadingTurnsLeft > 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
return;
}
// Try Reload
this.scene.startReload(player, item as any);
return;
}
// Is it already reloading?
if (item.reloadingTurnsLeft > 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
return;
}
} else if (item.weaponType === "ceramic_dragon_head") {
// Check Charges
if (item.charges <= 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No charges!", "#ff6600");
return;
}
}
// Has ammo/charges, start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute action
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
return;
}
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
this.scene.targetingSystem.startTargeting(
item.id,
player.pos,
this.scene.world,
this.scene.entityAccessor,
this.scene.playerId,
this.scene.dungeonRenderer.seenArray,
this.scene.world.width,
{ x: tx, y: ty }
);
this.scene.emitUIUpdate();
return;
}
// Upgrade Scroll Logic
if (item.id === "upgrade_scroll") {
const uiScene = this.scene.scene.get("GameUI") as GameUI;
// Access the public inventory component
const inventoryOverlay = uiScene.inventory;
if (inventoryOverlay && inventoryOverlay instanceof InventoryOverlay) {
// Trigger upgrade mode
inventoryOverlay.enterUpgradeMode((targetItem: any) => {
const success = UpgradeManager.applyUpgrade(targetItem);
if (success) {
// Consume scroll logic handling stacking
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
if (scrollItem) {
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
scrollItem.quantity--;
} else {
this.scene.itemManager.removeFromInventory(player, "upgrade_scroll");
}
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
}
inventoryOverlay.cancelUpgradeMode();
this.scene.emitUIUpdate();
this.scene.commitPlayerAction({ type: "wait" });
} else {
// Should technically be prevented by UI highlights, but safety check
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
inventoryOverlay.cancelUpgradeMode();
this.scene.emitUIUpdate();
}
});
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
}
return;
}
const result = this.scene.itemManager.handleUse(itemId, player);
if (result.success && result.consumed) {
const healAmount = player.stats.maxHp - player.stats.hp; // Already healed by manager
const actualHeal = Math.min(healAmount, player.stats.hp);
this.scene.dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
this.scene.commitPlayerAction({ type: "wait" });
this.scene.emitUIUpdate();
} else if (result.success && !result.consumed) {
// Throwable item - start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute throw
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
return;
}
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
this.scene.targetingSystem.startTargeting(
item.id,
player.pos,
this.scene.world,
this.scene.entityAccessor,
this.scene.playerId,
this.scene.dungeonRenderer.seenArray,
this.scene.world.width,
{ x: tx, y: ty }
);
this.scene.emitUIUpdate();
}
}
private handleDropItem(data: { itemId: string, pointerX: number, pointerY: number }) {
if (!this.scene.awaitingPlayer) return;
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.inventory) return;
const item = this.scene.itemManager.getItem(player, data.itemId);
if (!item) return;
// Determine drop position based on pointer or player pos
let dropPos = { x: player.pos.x, y: player.pos.y };
if (data.pointerX !== undefined && data.pointerY !== undefined) {
const tilePos = this.scene.getPointerTilePos({ x: data.pointerX, y: data.pointerY } as Phaser.Input.Pointer);
// Limit drop distance to 1 tile from player for balance/fairness
const dx = Math.sign(tilePos.x - player.pos.x);
const dy = Math.sign(tilePos.y - player.pos.y);
const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy;
if (inBounds(this.scene.world, targetX, targetY) && !isBlocked(this.scene.world, targetX, targetY, this.scene.entityAccessor)) {
dropPos = { x: targetX, y: targetY };
}
}
// Remove from inventory and spawn in world
if (this.scene.itemManager.removeFromInventory(player, data.itemId)) {
this.scene.itemManager.spawnItem(item, dropPos);
const quantityText = (item.quantity && item.quantity > 1) ? ` x${item.quantity}` : "";
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Dropped ${item.name}${quantityText}`, "#aaaaaa");
this.scene.emitUIUpdate();
}
}
private handleEquipItem(data: { itemId: string, slotKey: string }) {
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.inventory) return;
const item = player.inventory.items.find(it => it.id === data.itemId);
if (!item) return;
const result = equipItem(player, item, data.slotKey as any);
if (!result.success) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, result.message ?? "Cannot equip!", "#ff0000");
return;
}
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37");
this.scene.emitUIUpdate();
}
private handleDeEquipItem(data: { slotKey: string }) {
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.equipment) return;
const removedItem = deEquipItem(player, data.slotKey as any);
if (removedItem) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${removedItem.name}`, "#aaaaaa");
this.scene.emitUIUpdate();
}
}
}

View File

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

View File

@@ -0,0 +1,220 @@
import type { GameScene } from "../GameScene";
import { TILE_SIZE } from "../../core/constants";
import { inBounds } from "../../engine/world/world-logic";
import { findPathAStar } from "../../engine/world/pathfinding";
import type { Action, RangedWeaponItem } from "../../core/types";
export class PlayerInputHandler {
private scene: GameScene;
// Input Chording state
private pendingDx: number = 0;
private pendingDy: number = 0;
private moveChordTime: number = 0;
private readonly CHORD_WINDOW = 40; // ms to wait for diagonal chord
constructor(scene: GameScene) {
this.scene = scene;
}
public registerListeners() {
const input = this.scene.gameInput;
input.on("toggle-menu", () => {
if (this.scene.dungeonRenderer.isMinimapVisible()) {
this.scene.dungeonRenderer.toggleMinimap();
}
this.scene.events.emit("toggle-menu");
this.scene.emitUIUpdate();
});
input.on("close-menu", () => {
this.scene.events.emit("close-menu");
if (this.scene.dungeonRenderer.isMinimapVisible()) {
this.scene.dungeonRenderer.toggleMinimap();
}
});
input.on("toggle-minimap", () => {
this.scene.events.emit("close-menu");
this.scene.dungeonRenderer.toggleMinimap();
});
input.on("toggle-inventory", () => {
this.scene.events.emit("toggle-inventory");
});
input.on("toggle-character", () => {
this.scene.events.emit("toggle-character");
});
input.on("reload", () => {
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.inventory) return;
// Check active target or main hand
const activeId = this.scene.targetingSystem.itemId;
let weaponToReload: RangedWeaponItem | null = null;
if (activeId) {
const item = player.inventory.items.find(it => it.id === activeId);
if (item && item.type === "Weapon" && item.weaponType === "ranged") {
weaponToReload = item;
}
}
if (!weaponToReload && player.equipment?.mainHand) {
const item = player.equipment.mainHand;
if (item.type === "Weapon" && item.weaponType === "ranged") {
weaponToReload = item;
}
}
if (!weaponToReload && this.scene.runState.lastReloadableWeaponId) {
const item = player.inventory.items.find(it => it.id === this.scene.runState.lastReloadableWeaponId);
if (item && item.type === "Weapon" && item.weaponType === "ranged") {
weaponToReload = item;
}
}
if (weaponToReload) {
this.scene.startReload(player, weaponToReload);
}
});
input.on("wait", () => {
if (!this.scene.awaitingPlayer) return;
// Check blocking UI
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
this.scene.commitPlayerAction({ type: "wait" });
});
input.on("cancel-target", () => {
if (this.scene.targetingSystem.isActive) {
this.scene.targetingSystem.cancel();
this.scene.emitUIUpdate();
}
});
input.on("zoom", (deltaY: number) => {
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
this.scene.cameraController.handleWheel(deltaY);
});
input.on("pan", (dx: number, dy: number) => {
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
this.scene.cameraController.handlePan(dx / this.scene.cameras.main.zoom, dy / this.scene.cameras.main.zoom);
});
input.on("cursor-move", (worldX: number, worldY: number) => {
if (this.scene.targetingSystem.isActive) {
const tx = Math.floor(worldX / TILE_SIZE);
const ty = Math.floor(worldY / TILE_SIZE);
const player = this.scene.entityAccessor.getPlayer();
if (player) {
this.scene.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
}
}
});
input.on("tile-click", (tx: number, ty: number, button: number) => {
this.handleTileClick(tx, ty, button);
});
}
private handleTileClick(tx: number, ty: number, button: number) {
// Targeting Click
if (this.scene.targetingSystem.isActive) {
// Only Left Click throws
if (button === 0) {
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
}
return;
}
// Movement Click
if (button !== 0) return;
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
if (!inBounds(this.scene.world, tx, ty)) return;
if (!this.scene.dungeonRenderer.isSeen(tx, ty)) return;
const isEnemy = this.scene.entityAccessor.hasEnemyAt(tx, ty);
const player = this.scene.entityAccessor.getPlayer();
if (!player) return;
const dx = tx - player.pos.x;
const dy = ty - player.pos.y;
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
if (isEnemy && isDiagonalNeighbor) {
const enemy = this.scene.entityAccessor.findEnemyAt(tx, ty);
if (enemy) {
this.scene.commitPlayerAction({ type: "attack", targetId: enemy.id });
return;
}
}
const path = findPathAStar(
this.scene.world,
this.scene.dungeonRenderer.seenArray,
{ ...player.pos },
{ x: tx, y: ty },
{ ignoreBlockedTarget: isEnemy, accessor: this.scene.entityAccessor }
);
if (path.length >= 2) this.scene.playerPath = path;
this.scene.dungeonRenderer.render(this.scene.playerPath);
}
public handleCursorMovement(): Action | null {
const { dx, dy, anyJustDown } = this.scene.gameInput.getCursorState() as any;
const now = this.scene.time.now;
if (anyJustDown) {
// Start or update chord
if (this.moveChordTime === 0) {
this.moveChordTime = now + this.CHORD_WINDOW;
}
if (dx !== 0) this.pendingDx = dx;
if (dy !== 0) this.pendingDy = dy;
}
if (this.moveChordTime > 0 && now >= this.moveChordTime) {
const finalDx = this.pendingDx;
const finalDy = this.pendingDy;
// Reset state
this.moveChordTime = 0;
this.pendingDx = 0;
this.pendingDy = 0;
if (finalDx !== 0 || finalDy !== 0) {
if (this.scene.targetingSystem.isActive) {
this.scene.targetingSystem.cancel();
this.scene.emitUIUpdate();
}
const player = this.scene.entityAccessor.getPlayer();
if (!player) return null;
const targetX = player.pos.x + finalDx;
const targetY = player.pos.y + finalDy;
const enemy = this.scene.entityAccessor.findEnemyAt(targetX, targetY);
if (enemy) {
return { type: "attack", targetId: enemy.id };
} else {
return { type: "move", dx: finalDx, dy: finalDy };
}
}
}
return null;
}
}

View File

@@ -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, getConeTiles } 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,16 +84,22 @@ 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;
// Prevent targeting self
if (this.cursor.x === player.pos.x && this.cursor.y === player.pos.y) {
console.log("Cannot target self!");
return false;
}
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId); const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
if (itemIdx === -1) { if (itemIdx === -1) {
console.log("Item not found!"); console.log("Item not found!");
@@ -103,20 +109,29 @@ export class TargetingSystem {
const item = player.inventory.items[itemIdx]; const item = player.inventory.items[itemIdx];
// Only remove if it's a consumable throwable
if (item.type === "Consumable" && item.throwable) {
// Handle stack decrement if applicable, or remove
if (item.quantity && item.quantity > 1) {
item.quantity--;
} else {
player.inventory.items.splice(itemIdx, 1);
}
}
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); if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
if (item.charges <= 0) {
console.log("No charges left!");
return false;
}
onProjectileComplete(end, undefined, item);
return true;
}
// Only remove if it's a consumable throwable
if (item.type === "Consumable" && item.throwable) {
// Handle stack decrement if applicable, or remove
if (item.quantity && item.quantity > 1) {
item.quantity--;
} else {
player.inventory.items.splice(itemIdx, 1);
}
}
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 +148,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,12 +199,30 @@ 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;
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2; finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
// Draw Cone if it's a ceramic dragon head
const player = this.accessor.getCombatant(this.playerId);
const item = player?.inventory?.items.find(it => it.id === this.targetingItemId);
if (item?.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
const range = item.stats.range;
const tiles = getConeTiles(playerPos, this.cursor, range);
this.graphics.fillStyle(GAME_CONFIG.ui.targetingLineColor, 0.2);
for (const tile of tiles) {
this.graphics.fillRect(
tile.x * TILE_SIZE,
tile.y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE
);
}
}
} }
// Update crosshair position to ACTUAL impact point // Update crosshair position to ACTUAL impact point
@@ -235,14 +268,14 @@ export class TargetingSystem {
let currentDist = 0; let currentDist = 0;
while (currentDist < distance) { while (currentDist < distance) {
const len = Math.min(dashLen, distance - currentDist); const len = Math.min(dashLen, distance - currentDist);
const sx = x1 + currentDist * cos; const sx = x1 + currentDist * cos;
const sy = y1 + currentDist * sin; const sy = y1 + currentDist * sin;
const ex = sx + len * cos; const ex = sx + len * cos;
const ey = sy + len * sin; const ey = sy + len * sin;
this.graphics.lineBetween(sx, sy, ex, ey); this.graphics.lineBetween(sx, sy, ex, ey);
currentDist += dashLen + gapLen; currentDist += dashLen + gapLen;
} }
} }
} }

View File

@@ -0,0 +1,63 @@
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 },
trackPath: []
};
entityAccessor = {
findItemDropAt: vi.fn(() => null),
removeActor: vi.fn(),
context: undefined,
getEnemies: vi.fn(() => [])
};
itemManager = new ItemManager(world, entityAccessor);
});
it('should pickup an item at the player position', () => {
const player = {
id: 1 as EntityId,
pos: { x: 2, y: 2 },
inventory: { items: [], gold: 0 }
} as any;
const item: Item = {
id: 'health_potion',
name: 'Health Potion',
type: 'Consumable',
textureKey: 'items',
spriteIndex: 0
};
const itemActor: ItemDropActor = {
id: 2 as EntityId,
category: 'item_drop',
pos: { x: 2, y: 2 },
item
};
// Setup Accessor to find the item
entityAccessor.findItemDropAt.mockReturnValue(itemActor);
const result = itemManager.tryPickup(player);
expect(entityAccessor.findItemDropAt).toHaveBeenCalledWith(2, 2);
expect(result).not.toBeNull();
expect(player.inventory.items.length).toBe(1);
expect(player.inventory.items[0]).toEqual({ ...item, quantity: 1 });
expect(entityAccessor.removeActor).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Phaser
vi.mock('phaser', () => {
class MockEventEmitter {
on = vi.fn().mockReturnThis();
once = vi.fn().mockReturnThis();
emit = vi.fn().mockReturnThis();
off = vi.fn().mockReturnThis();
removeAllListeners = vi.fn().mockReturnThis();
}
return {
default: {
Events: {
EventEmitter: MockEventEmitter
},
Scene: class {
events = new MockEventEmitter();
add = {
graphics: vi.fn(),
sprite: vi.fn(),
};
}
}
};
});
import { PlayerInputHandler } from '../PlayerInputHandler';
import type { CombatantActor, RangedWeaponItem } from '../../../core/types';
// Minimal mock for GameScene
const createMockScene = () => {
const scene = {
gameInput: {
on: vi.fn(),
},
targetingSystem: {
itemId: null,
isActive: false,
},
entityAccessor: {
getPlayer: vi.fn(),
},
runState: {
lastReloadableWeaponId: null,
},
startReload: vi.fn(),
} as any;
return scene;
};
describe('Reload Last Used Weapon Logic', () => {
let scene: any;
let inputHandler: PlayerInputHandler;
let reloadCallback: Function;
beforeEach(() => {
scene = createMockScene();
inputHandler = new PlayerInputHandler(scene);
inputHandler.registerListeners();
// Find the reload listener
const reloadCall = scene.gameInput.on.mock.calls.find((call: any[]) => call[0] === 'reload');
reloadCallback = reloadCall[1];
});
it('should reload the last reloadable weapon if nothing else is targeted or equipped', () => {
const pistol: RangedWeaponItem = {
id: 'pistol-1',
name: 'Pistol',
type: 'Weapon',
weaponType: 'ranged',
currentAmmo: 0,
reloadingTurnsLeft: 0,
stats: { attack: 1, range: 5, magazineSize: 6, ammoType: '9mm', projectileSpeed: 10 },
textureKey: 'weapons',
spriteIndex: 0
};
const player: CombatantActor = {
id: 1,
pos: { x: 0, y: 0 },
category: 'combatant',
isPlayer: true,
type: 'player',
inventory: { items: [pistol], gold: 0 },
equipment: { mainHand: { type: 'Weapon', weaponType: 'melee', id: 'sword-1' } as any },
stats: {} as any,
energy: 100,
speed: 100
};
scene.entityAccessor.getPlayer.mockReturnValue(player);
scene.runState.lastReloadableWeaponId = 'pistol-1';
// Trigger reload (simulating 'R' press)
reloadCallback();
expect(scene.startReload).toHaveBeenCalledWith(player, pistol);
});
it('should prioritize targeted item over last used', () => {
const pistol1: RangedWeaponItem = { id: 'p1', name: 'P1', type: 'Weapon', weaponType: 'ranged' } as any;
const pistol2: RangedWeaponItem = { id: 'p2', name: 'P2', type: 'Weapon', weaponType: 'ranged' } as any;
const player: CombatantActor = {
id: 1, inventory: { items: [pistol1, pistol2] }, equipment: {}
} as any;
scene.entityAccessor.getPlayer.mockReturnValue(player);
scene.targetingSystem.itemId = 'p2';
scene.runState.lastReloadableWeaponId = 'p1';
reloadCallback();
expect(scene.startReload).toHaveBeenCalledWith(player, pistol2);
});
it('should prioritize equipped ranged weapon over last used', () => {
const pistol1: RangedWeaponItem = { id: 'p1', name: 'P1', type: 'Weapon', weaponType: 'ranged' } as any;
const pistol2: RangedWeaponItem = { id: 'p2', name: 'P2', type: 'Weapon', weaponType: 'ranged' } as any;
const player: CombatantActor = {
id: 1, inventory: { items: [pistol1, pistol2] }, equipment: { mainHand: pistol2 }
} as any;
scene.entityAccessor.getPlayer.mockReturnValue(player);
scene.runState.lastReloadableWeaponId = 'p1';
reloadCallback();
expect(scene.startReload).toHaveBeenCalledWith(player, pistol2);
});
it('should do nothing if no weapon is found', () => {
const player: CombatantActor = { id: 1, inventory: { items: [] }, equipment: {} } as any;
scene.entityAccessor.getPlayer.mockReturnValue(player);
reloadCallback();
expect(scene.startReload).not.toHaveBeenCalled();
});
});

View File

@@ -1,31 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Phaser // Mock Phaser
vi.mock('phaser', () => { vi.mock('phaser', () => {
const mockGraphics = { const mockGraphics = {
setDepth: vi.fn().mockReturnThis(), setDepth: vi.fn().mockReturnThis(),
clear: vi.fn().mockReturnThis(), clear: vi.fn().mockReturnThis(),
lineStyle: vi.fn().mockReturnThis(), lineStyle: vi.fn().mockReturnThis(),
lineBetween: vi.fn().mockReturnThis(), lineBetween: vi.fn().mockReturnThis(),
}; };
const mockSprite = { const mockSprite = {
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(),
}; };
return { return {
default: { default: {
Scene: class { GameObjects: {
add = { Sprite: vi.fn(() => mockSprite),
graphics: vi.fn(() => mockGraphics), Graphics: vi.fn(() => mockGraphics),
sprite: vi.fn(() => mockSprite), },
}; Scene: class {
} add = {
} graphics: vi.fn(() => mockGraphics),
}; sprite: vi.fn(() => mockSprite),
};
}
}
};
}); });
// Mock CombatLogic // Mock CombatLogic
@@ -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({
@@ -93,12 +97,20 @@ describe('TargetingSystem', () => {
const enemyPos = { x: 3, y: 3 }; const enemyPos = { x: 3, y: 3 };
(getClosestVisibleEnemy as any).mockReturnValue(enemyPos); (getClosestVisibleEnemy as any).mockReturnValue(enemyPos);
const mockAccessor = {
getCombatant: vi.fn().mockReturnValue({
pos: playerPos,
inventory: { items: [{ id: 'item-1' }] }
}),
context: {}
};
targetingSystem.startTargeting( targetingSystem.startTargeting(
'item-1', 'item-1',
playerPos, playerPos,
mockWorld, mockWorld,
mockEntityManager!, mockAccessor as any,
1 as any, 1 as EntityId, // playerId
new Uint8Array(100), new Uint8Array(100),
10 10
); );
@@ -114,12 +126,20 @@ describe('TargetingSystem', () => {
const mousePos = { x: 5, y: 5 }; const mousePos = { x: 5, y: 5 };
(getClosestVisibleEnemy as any).mockReturnValue(null); (getClosestVisibleEnemy as any).mockReturnValue(null);
const mockAccessor = {
getCombatant: vi.fn().mockReturnValue({
pos: playerPos,
inventory: { items: [{ id: 'item-1' }] }
}),
context: {}
};
targetingSystem.startTargeting( targetingSystem.startTargeting(
'item-1', 'item-1',
playerPos, playerPos,
mockWorld, mockWorld,
mockEntityManager!, mockAccessor as any,
1 as any, 1 as EntityId, // playerId
new Uint8Array(100), new Uint8Array(100),
10, 10,
mousePos mousePos
@@ -139,13 +159,21 @@ describe('TargetingSystem', () => {
path: [] path: []
}); });
const mockAccessor = {
getCombatant: vi.fn().mockReturnValue({
pos: playerPos,
inventory: { items: [{ id: 'item-1' }] }
}),
context: {}
};
// Start targeting // Start targeting
targetingSystem.startTargeting( targetingSystem.startTargeting(
'item-1', 'item-1',
playerPos, playerPos,
mockWorld, mockWorld,
mockEntityManager!, mockAccessor as any,
1 as any, 1 as EntityId,
new Uint8Array(100), new Uint8Array(100),
10, 10,
targetPos targetPos
@@ -167,4 +195,33 @@ describe('TargetingSystem', () => {
expect(mockGraphics.clear).toHaveBeenCalled(); expect(mockGraphics.clear).toHaveBeenCalled();
expect(mockSprite.setVisible).toHaveBeenCalledWith(false); expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
}); });
it('should prevent targeting self', () => {
const playerPos = { x: 1, y: 1 };
// Setup targeting
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
{ getCombatant: vi.fn().mockReturnValue({ pos: playerPos, inventory: { items: [{ id: 'item-1' }] } }) } as any,
1 as EntityId,
new Uint8Array(100),
10
);
// Manually set cursor to player pos (startTargeting might do it, but we ensure it)
targetingSystem.updateCursor(playerPos, playerPos);
const callback = vi.fn();
const result = targetingSystem.executeThrow(
mockWorld,
1 as EntityId,
{ getCombatant: vi.fn().mockReturnValue({ pos: playerPos, inventory: { items: [{ id: 'item-1' }] } }) } as any,
callback
);
expect(result).toBe(false);
expect(callback).not.toHaveBeenCalled();
});
}); });

View File

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

View File

@@ -266,6 +266,7 @@ describe("InventoryUtils", () => {
textureKey: "items", textureKey: "items",
spriteIndex: 7, spriteIndex: 7,
currentAmmo: 6, currentAmmo: 6,
reloadingTurnsLeft: 0,
stats: { stats: {
attack: 8, attack: 8,
range: 5, range: 5,
@@ -315,6 +316,7 @@ describe("InventoryUtils", () => {
textureKey: "items", textureKey: "items",
spriteIndex: 7, spriteIndex: 7,
currentAmmo: 4, currentAmmo: 4,
reloadingTurnsLeft: 0,
stats: { stats: {
attack: 8, attack: 8,
range: 5, range: 5,

View File

@@ -23,6 +23,13 @@ export class InventoryOverlay extends OverlayComponent {
public isUpgradeMode = false; public isUpgradeMode = false;
private onUpgradeSelect?: (item: any) => void; private onUpgradeSelect?: (item: any) => void;
public setVisible(visible: boolean) {
if (!visible && this.isUpgradeMode) {
this.cancelUpgradeMode();
}
super.setVisible(visible);
}
private tooltip: Phaser.GameObjects.Container | null = null; private tooltip: Phaser.GameObjects.Container | null = null;
private tooltipName: Phaser.GameObjects.Text | null = null; private tooltipName: Phaser.GameObjects.Text | null = null;
private tooltipStats: Phaser.GameObjects.Text | null = null; private tooltipStats: Phaser.GameObjects.Text | null = null;
@@ -61,8 +68,8 @@ export class InventoryOverlay extends OverlayComponent {
const overEquip = this.getEquipmentSlotAt(pointer.x, pointer.y) !== null; const overEquip = this.getEquipmentSlotAt(pointer.x, pointer.y) !== null;
if (!overBackpack && !overEquip) { if (!overBackpack && !overEquip) {
console.log("Clicked outside - cancelling (DEBUG: DISABLED to fix interaction)"); console.log("Clicked outside - cancelling");
// this.cancelUpgradeMode(); this.cancelUpgradeMode();
} }
} }
}); });
@@ -213,23 +220,10 @@ export class InventoryOverlay extends OverlayComponent {
} }
private createEquipmentSlots(centerX: number, centerY: number) { private createEquipmentSlots(centerX: number, centerY: number) {
const slotBorder = 0xd4af37;
const slotBg = 0x3a2a2a;
const createSlot = (x: number, y: number, size: number, key: string) => { const createSlot = (x: number, y: number, size: number, key: string) => {
const g = this.scene.add.graphics(); const g = this.scene.add.graphics();
this.drawBaseSlot(g, size, false);
// Outer golden border
g.lineStyle(2, slotBorder, 1);
g.strokeRect(-size / 2, -size / 2, size, size);
// Inner darker border
g.lineStyle(1, 0x8b7355, 1);
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
// Background
g.fillStyle(slotBg, 1);
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
const container = this.scene.add.container(x, y, [g]); const container = this.scene.add.container(x, y, [g]);
this.equipmentSlots.set(key, container); this.equipmentSlots.set(key, container);
@@ -299,18 +293,7 @@ export class InventoryOverlay extends OverlayComponent {
const y = startY + r * (slotSize + spacing) + slotSize / 2; const y = startY + r * (slotSize + spacing) + slotSize / 2;
const g = this.scene.add.graphics(); const g = this.scene.add.graphics();
this.drawBaseSlot(g, slotSize, true);
// Golden border
g.lineStyle(2, 0xd4af37, 1);
g.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
// Inner border
g.lineStyle(1, 0x8b7355, 1);
g.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
// Background
g.fillStyle(0x1a0f1a, 1);
g.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
const container = this.scene.add.container(x, y, [g]); const container = this.scene.add.container(x, y, [g]);
this.container.add(container); this.container.add(container);
@@ -448,21 +431,8 @@ export class InventoryOverlay extends OverlayComponent {
const compatible = isItemCompatibleWithSlot(item, key); const compatible = isItemCompatibleWithSlot(item, key);
if (compatible) { if (compatible) {
const graphics = container.list[0] as Phaser.GameObjects.Graphics; const size = getEquipmentSlotSize(key);
if (graphics) { this.drawSlotHighlight(container, size);
graphics.clear();
const size = getEquipmentSlotSize(key);
// Glowing border
graphics.lineStyle(4, 0xffd700, 1);
graphics.strokeRect(-size / 2, -size / 2, size, size);
graphics.lineStyle(1, 0x8b7355, 1);
graphics.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
graphics.fillStyle(0x4a3a3a, 1);
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
}
} }
}); });
} }
@@ -472,21 +442,11 @@ export class InventoryOverlay extends OverlayComponent {
this.equipmentSlots.forEach((container, key) => { this.equipmentSlots.forEach((container, key) => {
const graphics = container.list[0] as Phaser.GameObjects.Graphics; const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) { if (graphics) {
graphics.clear();
const slotBorder = 0xd4af37;
const slotBg = 0x3a2a2a;
const size = getEquipmentSlotSize(key); const size = getEquipmentSlotSize(key);
this.drawBaseSlot(graphics, size, false);
graphics.lineStyle(2, slotBorder, 1); // Allow interactions again and reset alpha
graphics.strokeRect(-size / 2, -size / 2, size, size); graphics.setAlpha(1);
graphics.lineStyle(1, 0x8b7355, 1);
graphics.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
graphics.fillStyle(slotBg, 1);
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
// Allow interactions again if they were disabled (though we don't disable them currently)
container.setAlpha(1); container.setAlpha(1);
} }
}); });
@@ -495,19 +455,10 @@ export class InventoryOverlay extends OverlayComponent {
this.backpackSlots.forEach(container => { this.backpackSlots.forEach(container => {
const graphics = container.list[0] as Phaser.GameObjects.Graphics; const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) { if (graphics) {
graphics.clear();
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE; const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
const { SLOT_BORDER, SLOT_INNER_BORDER, BACKPACK_BG } = INVENTORY_CONSTANTS.COLORS; this.drawBaseSlot(graphics, slotSize, true);
graphics.lineStyle(2, SLOT_BORDER, 1);
graphics.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
graphics.lineStyle(1, SLOT_INNER_BORDER, 1);
graphics.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
graphics.fillStyle(BACKPACK_BG, 1);
graphics.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
graphics.setAlpha(1);
container.setAlpha(1); container.setAlpha(1);
} }
}); });
@@ -559,19 +510,42 @@ export class InventoryOverlay extends OverlayComponent {
private drawSlotHighlight(slot: Phaser.GameObjects.Container, size: number) { private drawSlotHighlight(slot: Phaser.GameObjects.Container, size: number) {
const g = slot.list[0] as Phaser.GameObjects.Graphics; const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) { if (g) {
g.clear(); // Draw base first (preserves gold border logic)
// Highlight border const isBackpack = this.backpackSlots.includes(slot);
g.lineStyle(2, 0x00ff00, 1); this.drawBaseSlot(g, size, isBackpack);
// Add green highlight on top
const { UPGRADE_HIGHLIGHT, UPGRADE_INNER, UPGRADE_BG } = INVENTORY_CONSTANTS.COLORS;
g.lineStyle(2, UPGRADE_HIGHLIGHT, 1);
g.strokeRect(-size / 2, -size / 2, size, size); g.strokeRect(-size / 2, -size / 2, size, size);
g.lineStyle(1, 0x00aa00, 1); g.lineStyle(1, UPGRADE_INNER, 1);
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4); g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
g.fillStyle(0x1a2f1a, 1); g.fillStyle(UPGRADE_BG, 1);
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6); g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
} }
} }
private drawBaseSlot(g: Phaser.GameObjects.Graphics, size: number, isBackpack: boolean) {
g.clear();
const { SLOT_BORDER, SLOT_INNER_BORDER, SLOT_BG, BACKPACK_BG } = INVENTORY_CONSTANTS.COLORS;
const border = SLOT_BORDER;
const inner = SLOT_INNER_BORDER;
const bg = isBackpack ? BACKPACK_BG : SLOT_BG;
g.lineStyle(2, border, 1);
g.strokeRect(-size / 2, -size / 2, size, size);
g.lineStyle(1, inner, 1);
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
g.fillStyle(bg, 1);
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
}
private drawSlotDim(slot: Phaser.GameObjects.Container) { private drawSlotDim(slot: Phaser.GameObjects.Container) {
const g = slot.list[0] as Phaser.GameObjects.Graphics; const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) { if (g) {
@@ -615,7 +589,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 +658,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!];

View File

@@ -1,232 +1,246 @@
import Phaser from "phaser"; import Phaser from "phaser";
import type { CombatantActor, Item } from "../../core/types"; import type { CombatantActor, Item } from "../../core/types";
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory"; import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
import { GAME_CONFIG } from "../../core/config/GameConfig";
export class QuickSlotComponent { export class QuickSlotComponent {
private scene: Phaser.Scene; private scene: Phaser.Scene;
private container!: Phaser.GameObjects.Container; private container!: Phaser.GameObjects.Container;
private slots: Phaser.GameObjects.Container[] = []; private slots: Phaser.GameObjects.Container[] = [];
private itemMap: (Item | null)[] = new Array(10).fill(null); private itemMap: (Item | null)[] = new Array(10).fill(null);
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", ...new Array(7).fill("")]; private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", "ceramic_dragon_head", ...new Array(6).fill("")];
private draggedSlotIndex: number | null = null; private draggedSlotIndex: number | null = null;
private dragIcon: Phaser.GameObjects.Sprite | null = null; private dragIcon: Phaser.GameObjects.Sprite | null = null;
private reloadSliderContainer!: Phaser.GameObjects.Container;
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
this.scene = scene; this.scene = scene;
}
create() {
const { width, height } = this.scene.scale;
const slotSize = 48;
const slotSpacing = 4;
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
const actionButtonHeight = 40 + 10; // Button height + spacing
// Position above action buttons
this.container = this.scene.add.container(
width / 2 - totalWidth / 2,
height - slotSize - actionButtonHeight - 20
);
this.container.setScrollFactor(0).setDepth(1500);
for (let i = 0; i < 10; i++) {
const x = i * (slotSize + slotSpacing);
const g = this.scene.add.graphics();
// Draw slot background (dark purple/brown)
g.fillStyle(0x2a1f3d, 0.95);
g.fillRect(0, 0, slotSize, slotSize);
// Draw gold border (default state)
g.lineStyle(2, 0xD4AF37, 1);
g.strokeRect(0, 0, slotSize, slotSize);
// Hotkey label (bottom-left, gold color)
const label = i === 9 ? "0" : `${i + 1}`;
const key = this.scene.add.text(3, slotSize - 3, label, {
fontSize: "12px",
color: "#D4AF37",
fontStyle: "bold"
}).setOrigin(0, 1);
const slotContainer = this.scene.add.container(x, 0, [g, key]);
slotContainer.setData("index", i);
this.slots.push(slotContainer);
this.container.add(slotContainer);
// Input
const hitArea = new Phaser.Geom.Rectangle(0, 0, slotSize, slotSize);
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
this.scene.input.setDraggable(slotContainer);
slotContainer.on("pointerdown", () => {
});
slotContainer.on("pointerup", (pointer: Phaser.Input.Pointer) => {
// If we didn't drag, then activate
if (this.draggedSlotIndex === null && pointer.getDistance() < 10) {
this.activateSlot(i);
}
});
} }
// Drag and Drop Events create() {
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => { const { width, height } = this.scene.scale;
// Only handle if it's one of our slots
const index = gameObject.getData("index");
if (index === undefined || !this.slots.includes(gameObject)) return;
const item = this.itemMap[index];
if (!item) return;
this.draggedSlotIndex = index;
// Setup drag icon
if (!this.dragIcon) {
this.dragIcon = this.scene.add.sprite(0, 0, item.textureKey ?? "items", item.spriteIndex);
this.dragIcon.setDepth(2000).setScale(2.5).setAlpha(0.7);
} else {
this.dragIcon.setTexture(item.textureKey ?? "items", item.spriteIndex);
this.dragIcon.setVisible(true);
}
this.dragIcon.setPosition(pointer.x, pointer.y);
// Ghost the original slot's item
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
if (sprite) sprite.setAlpha(0.3);
});
this.scene.input.on("drag", (pointer: Phaser.Input.Pointer) => {
if (this.dragIcon) {
this.dragIcon.setPosition(pointer.x, pointer.y);
}
});
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
if (this.draggedSlotIndex === null || !this.slots.includes(gameObject)) return;
const startIndex = this.draggedSlotIndex;
this.draggedSlotIndex = null;
if (this.dragIcon) this.dragIcon.setVisible(false);
// Reset alpha of original sprite
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
if (sprite) sprite.setAlpha(1.0);
// Determine if we dropped on another slot
let targetIndex: number | null = null;
const slotSize = 48; const slotSize = 48;
const slotSpacing = 4; const slotSpacing = 4;
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
const actionButtonHeight = 40 + 10; // Button height + spacing
// Calculate pointer position relative to the quick-slot container // Position above action buttons
// Since container has scrollFactor(0), its screen position is fixed this.container = this.scene.add.container(
const localX = pointer.x - this.container.x; width / 2 - totalWidth / 2,
const localY = pointer.y - this.container.y; height - slotSize - actionButtonHeight - 20
);
this.container.setScrollFactor(0).setDepth(1500);
// Check if pointer is within the vertical bounds of the slots for (let i = 0; i < 10; i++) {
if (localY >= 0 && localY <= slotSize) { const x = i * (slotSize + slotSpacing);
// Calculate which slot index the pointer is over const g = this.scene.add.graphics();
const index = Math.floor(localX / (slotSize + slotSpacing));
const remainder = localX % (slotSize + slotSpacing);
// Ensure index is valid and pointer is within the slot's actual area (not spacing) // Draw slot background (dark purple/brown)
if (index >= 0 && index < 10 && remainder <= slotSize) { g.fillStyle(0x2a1f3d, 0.95);
targetIndex = index; g.fillRect(0, 0, slotSize, slotSize);
}
// Draw gold border (default state)
g.lineStyle(2, 0xD4AF37, 1);
g.strokeRect(0, 0, slotSize, slotSize);
// Hotkey label (bottom-left, gold color)
const label = i === 9 ? "0" : `${i + 1}`;
const key = this.scene.add.text(3, slotSize - 3, label, {
fontSize: "12px",
color: "#D4AF37",
fontStyle: "bold"
}).setOrigin(0, 1);
const slotContainer = this.scene.add.container(x, 0, [g, key]);
slotContainer.setData("index", i);
this.slots.push(slotContainer);
this.container.add(slotContainer);
// Input
const hitArea = new Phaser.Geom.Rectangle(0, 0, slotSize, slotSize);
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
this.scene.input.setDraggable(slotContainer);
slotContainer.on("pointerdown", () => {
});
slotContainer.on("pointerup", (pointer: Phaser.Input.Pointer) => {
// If we didn't drag, then activate
if (this.draggedSlotIndex === null && pointer.getDistance() < 10) {
this.activateSlot(i);
}
});
} }
if (targetIndex !== null && targetIndex !== startIndex) { // Drag and Drop Events
// Swap or Move this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
const temp = this.assignedIds[startIndex]; // Only handle if it's one of our slots
this.assignedIds[startIndex] = this.assignedIds[targetIndex]; const index = gameObject.getData("index");
this.assignedIds[targetIndex] = temp; if (index === undefined || !this.slots.includes(gameObject)) return;
console.log(`Moved/Swapped slot ${startIndex} to ${targetIndex}`);
} else if (targetIndex === null) {
// Check if dropped over inventory backpack
const gameUI = this.scene as any;
if (gameUI.inventory && gameUI.inventory.isPointerOver(pointer.x, pointer.y)) {
// Clear the quick slot (returning to backpack)
this.assignedIds[startIndex] = "";
console.log(`Cleared quick slot ${startIndex} (returned to backpack)`);
} else {
// Dropped outside - drop on ground
const item = this.itemMap[startIndex];
if (item) {
const gameScene = this.scene.scene.get("GameScene") as any;
gameScene.events.emit("drop-item", {
itemId: item.id,
pointerX: pointer.x,
pointerY: pointer.y
});
// Clear the slot const item = this.itemMap[index];
this.assignedIds[startIndex] = ""; if (!item) return;
this.draggedSlotIndex = index;
// Setup drag icon
if (!this.dragIcon) {
this.dragIcon = this.scene.add.sprite(0, 0, item.textureKey ?? "items", item.spriteIndex);
this.dragIcon.setDepth(2000).setScale(2.5).setAlpha(0.7);
} else {
this.dragIcon.setTexture(item.textureKey ?? "items", item.spriteIndex);
this.dragIcon.setVisible(true);
}
this.dragIcon.setPosition(pointer.x, pointer.y);
// Ghost the original slot's item
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
if (sprite) sprite.setAlpha(0.3);
});
this.scene.input.on("drag", (pointer: Phaser.Input.Pointer) => {
if (this.dragIcon) {
this.dragIcon.setPosition(pointer.x, pointer.y);
}
});
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
if (this.draggedSlotIndex === null || !this.slots.includes(gameObject)) return;
const startIndex = this.draggedSlotIndex;
this.draggedSlotIndex = null;
if (this.dragIcon) this.dragIcon.setVisible(false);
// Reset alpha of original sprite
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
if (sprite) sprite.setAlpha(1.0);
// Determine if we dropped on another slot
let targetIndex: number | null = null;
const slotSize = 48;
const slotSpacing = 4;
// Calculate pointer position relative to the quick-slot container
// Since container has scrollFactor(0), its screen position is fixed
const localX = pointer.x - this.container.x;
const localY = pointer.y - this.container.y;
// Check if pointer is within the vertical bounds of the slots
if (localY >= 0 && localY <= slotSize) {
// Calculate which slot index the pointer is over
const index = Math.floor(localX / (slotSize + slotSpacing));
const remainder = localX % (slotSize + slotSpacing);
// Ensure index is valid and pointer is within the slot's actual area (not spacing)
if (index >= 0 && index < 10 && remainder <= slotSize) {
targetIndex = index;
} }
} }
}
// Trigger UI refresh to reflect changes on the correct event bus if (targetIndex !== null && targetIndex !== startIndex) {
const gameScene = this.scene.scene.get("GameScene"); // Swap or Move
gameScene.events.emit("request-ui-update"); const temp = this.assignedIds[startIndex];
}); this.assignedIds[startIndex] = this.assignedIds[targetIndex];
this.assignedIds[targetIndex] = temp;
console.log(`Moved/Swapped slot ${startIndex} to ${targetIndex}`);
} else if (targetIndex === null) {
// Check if dropped over inventory backpack
const gameUI = this.scene as any;
if (gameUI.inventory && gameUI.inventory.isPointerOver(pointer.x, pointer.y)) {
// Clear the quick slot (returning to backpack)
this.assignedIds[startIndex] = "";
console.log(`Cleared quick slot ${startIndex} (returned to backpack)`);
} else {
// Dropped outside - drop on ground
const item = this.itemMap[startIndex];
if (item) {
const gameScene = this.scene.scene.get("GameScene") as any;
gameScene.events.emit("drop-item", {
itemId: item.id,
pointerX: pointer.x,
pointerY: pointer.y
});
// Keyboard inputs // Clear the slot
this.scene.input.keyboard?.on("keydown-ONE", () => this.activateSlot(0)); this.assignedIds[startIndex] = "";
this.scene.input.keyboard?.on("keydown-TWO", () => this.activateSlot(1)); }
this.scene.input.keyboard?.on("keydown-THREE", () => this.activateSlot(2)); }
this.scene.input.keyboard?.on("keydown-FOUR", () => this.activateSlot(3)); }
this.scene.input.keyboard?.on("keydown-FIVE", () => this.activateSlot(4));
this.scene.input.keyboard?.on("keydown-SIX", () => this.activateSlot(5));
this.scene.input.keyboard?.on("keydown-SEVEN", () => this.activateSlot(6));
this.scene.input.keyboard?.on("keydown-EIGHT", () => this.activateSlot(7));
this.scene.input.keyboard?.on("keydown-NINE", () => this.activateSlot(8));
this.scene.input.keyboard?.on("keydown-ZERO", () => this.activateSlot(9));
}
update(player: CombatantActor, activeItemId?: string | null) { // Trigger UI refresh to reflect changes on the correct event bus
if (!player.inventory) return; const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("request-ui-update");
});
const slotSize = 48; // Keyboard inputs
this.scene.input.keyboard?.on("keydown-ONE", () => this.activateSlot(0));
this.scene.input.keyboard?.on("keydown-TWO", () => this.activateSlot(1));
this.scene.input.keyboard?.on("keydown-THREE", () => this.activateSlot(2));
this.scene.input.keyboard?.on("keydown-FOUR", () => this.activateSlot(3));
this.scene.input.keyboard?.on("keydown-FIVE", () => this.activateSlot(4));
this.scene.input.keyboard?.on("keydown-SIX", () => this.activateSlot(5));
this.scene.input.keyboard?.on("keydown-SEVEN", () => this.activateSlot(6));
this.scene.input.keyboard?.on("keydown-EIGHT", () => this.activateSlot(7));
this.scene.input.keyboard?.on("keydown-NINE", () => this.activateSlot(8));
this.scene.input.keyboard?.on("keydown-ZERO", () => this.activateSlot(9));
// Update slots based on inventory availability // Global Slider Container
for (let i = 0; i < 10; i++) { this.reloadSliderContainer = this.scene.add.container(
const desiredId = this.assignedIds[i]; totalWidth / 2,
const slot = this.slots[i]; -40
const bgGraphics = slot.list[0] as Phaser.GameObjects.Graphics; );
this.container.add(this.reloadSliderContainer);
}
// Clear previous item icon if any (children > 2, since 0=bg, 1=text) update(player: CombatantActor, activeItemId?: string | null) {
if (slot.list.length > 2) { if (!player.inventory) return;
slot.removeBetween(2, undefined, true);
}
if (desiredId) { const slotSize = 48;
const foundItem = player.inventory.items.find(it => it.id === desiredId);
this.itemMap[i] = foundItem || null;
const isActive = foundItem && foundItem.id === activeItemId; // Update slots based on inventory availability
for (let i = 0; i < 10; i++) {
const desiredId = this.assignedIds[i];
const slot = this.slots[i];
const bgGraphics = slot.list[0] as Phaser.GameObjects.Graphics;
// Redraw background based on active state // Clear previous item icon if any (children > 2, since 0=bg, 1=text)
bgGraphics.clear(); if (slot.list.length > 2) {
slot.removeBetween(2, undefined, true);
}
// Dark background if (desiredId) {
bgGraphics.fillStyle(0x2a1f3d, 0.95); const foundItem = player.inventory.items.find(it => it.id === desiredId);
bgGraphics.fillRect(0, 0, slotSize, slotSize); this.itemMap[i] = foundItem || null;
// Border - subtle cyan for active, gold for normal const isActive = foundItem && foundItem.id === activeItemId;
if (isActive) {
bgGraphics.lineStyle(2, 0x00E5FF, 1); // Cyan highlight // Redraw background based on active state
} else { bgGraphics.clear();
bgGraphics.lineStyle(2, 0xD4AF37, 1); // Gold border
} // Dark background
bgGraphics.strokeRect(0, 0, slotSize, slotSize); bgGraphics.fillStyle(0x2a1f3d, 0.95);
bgGraphics.fillRect(0, 0, slotSize, slotSize);
// Border - subtle cyan for active, gold for normal
if (isActive) {
bgGraphics.lineStyle(2, 0x00E5FF, 1); // Cyan highlight
} else {
bgGraphics.lineStyle(2, 0xD4AF37, 1); // Gold border
}
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
if (foundItem) { if (foundItem) {
// Use ItemSpriteFactory for glow effect on variants // Use ItemSpriteFactory for glow effect on variants
// Standalone images (24x24) need less scaling than 16x16 sprites
const isStandalone = foundItem.spriteIndex === undefined || foundItem.spriteIndex === 0;
const itemScale = isStandalone ? 1.5 : 2.5;
const itemContainer = ItemSpriteFactory.createItemSprite( const itemContainer = ItemSpriteFactory.createItemSprite(
this.scene, foundItem, slotSize / 2, slotSize / 2, 2.5 this.scene, foundItem, slotSize / 2, slotSize / 2, itemScale
); );
slot.add(itemContainer); slot.add(itemContainer);
// Unified Label (Bottom-Right) // Unified Label (Bottom-Right)
let labelText = ""; let labelText = "";
if (foundItem.stackable) { if (foundItem.stackable) {
@@ -238,6 +252,9 @@ export class QuickSlotComponent {
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) { } else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
// Show ammo for non-stackable ranged weapons // Show ammo for non-stackable ranged weapons
labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`; labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ceramic_dragon_head") {
// Show charges for ceramic dragon head
labelText = `${foundItem.charges}/${foundItem.maxCharges}`;
} }
if (labelText) { if (labelText) {
@@ -250,74 +267,197 @@ export class QuickSlotComponent {
}).setOrigin(1, 1); }).setOrigin(1, 1);
slot.add(display); slot.add(display);
} }
// Reloading overlay logic removed from individual slots -> Replacing with active lock symbol
if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.reloadingTurnsLeft > 0) {
// Transparent grey overlay
const overlay = this.scene.add.graphics();
overlay.fillStyle(0x000000, 0.5);
overlay.fillRect(0, 0, slotSize, slotSize);
slot.add(overlay);
}
} }
} else { } else {
this.itemMap[i] = null; this.itemMap[i] = null;
// Reset bg // Reset bg
bgGraphics.clear(); bgGraphics.clear();
bgGraphics.fillStyle(0x2a1f3d, 0.95); bgGraphics.fillStyle(0x2a1f3d, 0.95);
bgGraphics.fillRect(0, 0, slotSize, slotSize); bgGraphics.fillRect(0, 0, slotSize, slotSize);
bgGraphics.lineStyle(2, 0xD4AF37, 1); bgGraphics.lineStyle(2, 0xD4AF37, 1);
bgGraphics.strokeRect(0, 0, slotSize, slotSize); bgGraphics.strokeRect(0, 0, slotSize, slotSize);
} }
}
}
private activateSlot(index: number) {
const item = this.itemMap[index];
if (item) {
console.log(`Activating slot ${index + 1}: ${item.name}`);
// Emit event to GameScene to handle item usage
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("use-item", { itemId: item.id });
} else {
console.log(`Slot ${index + 1} is empty`);
}
}
public isPointerOver(x: number, y: number): boolean {
const slotSize = 48;
const slotSpacing = 4;
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
const localX = x - this.container.x;
const localY = y - this.container.y;
return localX >= 0 && localX <= totalWidth && localY >= 0 && localY <= slotSize;
}
public getSlotIndexAt(x: number, y: number): number | null {
const slotSize = 48;
const slotSpacing = 4;
const localX = x - this.container.x;
const localY = y - this.container.y;
if (localY >= 0 && localY <= slotSize) {
const index = Math.floor(localX / (slotSize + slotSpacing));
const remainder = localX % (slotSize + slotSpacing);
if (index >= 0 && index < 10 && remainder <= slotSize) {
return index;
}
}
return null;
}
public assignItem(index: number, itemId: string) {
if (index >= 0 && index < 10) {
// Prevent duplicate assignments
const existingIndex = this.assignedIds.indexOf(itemId);
if (existingIndex !== -1 && existingIndex !== index) {
this.assignedIds[existingIndex] = "";
console.log(`Cleared duplicate assignment of ${itemId} from slot ${existingIndex}`);
} }
this.assignedIds[index] = itemId; // -----------------------------------------------------------------------
// Global Reload Slider Logic
// -----------------------------------------------------------------------
this.reloadSliderContainer.removeAll(true);
// Refresh UI // Find ANY reloading item in the inventory (that needs the UI)
const gameScene = this.scene.scene.get("GameScene"); // Usually the active one, or just the first one found since turn-based RL doesn't do parallel reloading much
gameScene.events.emit("request-ui-update"); const reloadingItem = player.inventory.items.find(
it => it.type === "Weapon" && it.weaponType === "ranged" && it.reloadingTurnsLeft > 0
) as any; // Cast for easier access to RangedWeaponItem props
if (reloadingItem) {
const maxTurns = GAME_CONFIG.player.reloadDuration;
const progress = 1 - (reloadingItem.reloadingTurnsLeft / maxTurns);
const sliderWidth = 260; // Half of ~520px
const sliderHeight = 14;
const grooveColor = 0x1a1a1a;
const trackColor = 0x4a4a4a; // Stone Grey
const handleColor = 0x888888; // Lighter Stone
// 1. Draw Track Base (Stone Slab)
const g = this.scene.add.graphics();
// Stone Border / Bevel
g.lineStyle(4, 0x666666); // Light edge (Top/Left)
g.beginPath();
g.moveTo(-sliderWidth / 2, sliderHeight / 2);
g.lineTo(-sliderWidth / 2, -sliderHeight / 2);
g.lineTo(sliderWidth / 2, -sliderHeight / 2);
g.strokePath();
g.lineStyle(4, 0x222222); // Dark edge (Bottom/Right)
g.beginPath();
g.moveTo(sliderWidth / 2, -sliderHeight / 2);
g.lineTo(sliderWidth / 2, sliderHeight / 2);
g.lineTo(-sliderWidth / 2, sliderHeight / 2);
g.strokePath();
// Main Track Body
g.fillStyle(trackColor);
g.fillRect(-sliderWidth / 2, -sliderHeight / 2, sliderWidth, sliderHeight);
// Groove (Chiseled out)
g.fillStyle(grooveColor);
g.fillRect(-sliderWidth / 2 + 4, -2, sliderWidth - 8, 4);
// Tick marks (Etched into stone)
g.lineStyle(2, 0x222222, 0.5);
// Draw ticks based on actual turns
for (let k = 0; k <= maxTurns; k++) {
const tx = (-sliderWidth / 2 + 4) + (k * ((sliderWidth - 8) / maxTurns));
g.moveTo(tx, -sliderHeight / 2);
g.lineTo(tx, -4); // Stop at groove
g.moveTo(tx, 4); // Start after groove
g.lineTo(tx, sliderHeight / 2);
}
g.strokePath();
this.reloadSliderContainer.add(g);
// 2. Draw Handle / Knob (Stone Block)
const safeProgress = Math.max(0, Math.min(1, progress));
const knobWidth = 20;
const knobHeight = 28;
const travelLength = (sliderWidth - 8) - knobWidth; // Subtract padding/groove end
const startX = (-sliderWidth / 2 + 4) + knobWidth / 2;
const knobX = startX + (safeProgress * travelLength);
const knob = this.scene.add.graphics();
// Knob Body
knob.fillStyle(handleColor);
knob.fillRect(knobX - knobWidth / 2, -knobHeight / 2, knobWidth, knobHeight);
// Stone texture (noise/dots) - simplified as darker specks
knob.fillStyle(0x555555, 0.4);
knob.fillRect(knobX - knobWidth / 2 + 2, -knobHeight / 2 + 2, 4, 4);
knob.fillRect(knobX + knobWidth / 2 - 6, knobHeight / 2 - 6, 4, 4);
// 3D Bevel for Block
knob.lineStyle(2, 0xaaaaaa); // Highlight
knob.beginPath();
knob.moveTo(knobX - knobWidth / 2, knobHeight / 2);
knob.lineTo(knobX - knobWidth / 2, -knobHeight / 2);
knob.lineTo(knobX + knobWidth / 2, -knobHeight / 2);
knob.strokePath();
knob.lineStyle(2, 0x333333); // Shadow
knob.beginPath();
knob.moveTo(knobX + knobWidth / 2, -knobHeight / 2);
knob.lineTo(knobX + knobWidth / 2, knobHeight / 2);
knob.lineTo(knobX - knobWidth / 2, knobHeight / 2);
knob.strokePath();
// Center indent line
knob.lineStyle(2, 0x444444);
knob.moveTo(knobX, -knobHeight / 2 + 6);
knob.lineTo(knobX, knobHeight / 2 - 6);
knob.strokePath();
this.reloadSliderContainer.add(knob);
const label = this.scene.add.text(0, sliderHeight + 4, "RELOADING", {
fontSize: "12px",
color: "#888888",
fontFamily: "monospace",
fontStyle: "bold",
shadow: { offsetX: 1, offsetY: 1, color: "#000000", blur: 0, stroke: true, fill: true }
}).setOrigin(0.5, 0);
this.reloadSliderContainer.add(label);
}
}
private activateSlot(index: number) {
const item = this.itemMap[index];
if (item) {
console.log(`Activating slot ${index + 1}: ${item.name}`);
// Emit event to GameScene to handle item usage
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("use-item", { itemId: item.id });
} else {
console.log(`Slot ${index + 1} is empty`);
}
}
public isPointerOver(x: number, y: number): boolean {
const slotSize = 48;
const slotSpacing = 4;
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
const localX = x - this.container.x;
const localY = y - this.container.y;
return localX >= 0 && localX <= totalWidth && localY >= 0 && localY <= slotSize;
}
public getSlotIndexAt(x: number, y: number): number | null {
const slotSize = 48;
const slotSpacing = 4;
const localX = x - this.container.x;
const localY = y - this.container.y;
if (localY >= 0 && localY <= slotSize) {
const index = Math.floor(localX / (slotSize + slotSpacing));
const remainder = localX % (slotSize + slotSpacing);
if (index >= 0 && index < 10 && remainder <= slotSize) {
return index;
}
}
return null;
}
public assignItem(index: number, itemId: string) {
if (index >= 0 && index < 10) {
// Prevent duplicate assignments
const existingIndex = this.assignedIds.indexOf(itemId);
if (existingIndex !== -1 && existingIndex !== index) {
this.assignedIds[existingIndex] = "";
console.log(`Cleared duplicate assignment of ${itemId} from slot ${existingIndex}`);
}
this.assignedIds[index] = itemId;
// Refresh UI
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("request-ui-update");
}
} }
}
} }