diff --git a/src/engine/__tests__/generator.test.ts b/src/engine/__tests__/generator.test.ts index c59d1a7..cdbba0e 100644 --- a/src/engine/__tests__/generator.test.ts +++ b/src/engine/__tests__/generator.test.ts @@ -11,36 +11,36 @@ describe('World Generator', () => { describe('generateWorld', () => { it('should generate a world with correct dimensions', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world } = generateWorld(1, runState); - - expect(world.width).toBe(60); - expect(world.height).toBe(40); - expect(world.tiles.length).toBe(60 * 40); + + expect(world.width).toBe(120); + expect(world.height).toBe(80); + expect(world.tiles.length).toBe(120 * 80); }); it('should place player actor', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world, playerId, ecsWorld } = generateWorld(1, runState); const accessor = new EntityAccessor(world, playerId, ecsWorld); - + expect(playerId).toBeGreaterThan(0); const player = accessor.getPlayer(); expect(player).toBeDefined(); @@ -53,36 +53,36 @@ describe('World Generator', () => { it('should create walkable rooms', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world, playerId, ecsWorld } = generateWorld(1, runState); const accessor = new EntityAccessor(world, playerId, ecsWorld); const player = accessor.getPlayer()!; - + // Player should spawn in a walkable area expect(isWall(world, player.pos.x, player.pos.y)).toBe(false); }); it('should place exit in valid location', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world } = generateWorld(1, runState); - + expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true); // Exit should be on a floor tile expect(isWall(world, world.exit.x, world.exit.y)).toBe(false); @@ -90,21 +90,21 @@ describe('World Generator', () => { it('should create enemies', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world, playerId, ecsWorld } = generateWorld(1, runState); const accessor = new EntityAccessor(world, playerId, ecsWorld); - + const enemies = accessor.getEnemies(); expect(enemies.length).toBeGreaterThan(0); - + // Enemies should have stats enemies.forEach(enemy => { expect(enemy.stats).toBeDefined(); @@ -115,25 +115,25 @@ describe('World Generator', () => { it('should generate deterministic maps for same level', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState); const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState); - + // Same level should generate identical layouts expect(world1.tiles).toEqual(world2.tiles); expect(world1.exit).toEqual(world2.exit); - + const accessor1 = new EntityAccessor(world1, player1, ecs1); const accessor2 = new EntityAccessor(world2, player2, ecs2); - + const player1Pos = accessor1.getPlayer()!.pos; const player2Pos = accessor2.getPlayer()!.pos; expect(player1Pos).toEqual(player2Pos); @@ -141,45 +141,45 @@ describe('World Generator', () => { it('should generate different maps for different levels', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world: world1 } = generateWorld(1, runState); const { world: world2 } = generateWorld(2, runState); - + // Different levels should have different layouts expect(world1.tiles).not.toEqual(world2.tiles); }); it('should scale enemy difficulty with level', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; - + const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState); const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState); - + const accessor1 = new EntityAccessor(world1, p1, ecs1); const accessor5 = new EntityAccessor(world5, p5, ecs5); - + const enemies1 = accessor1.getEnemies(); const enemies5 = accessor5.getEnemies(); - + // Higher level should have more enemies expect(enemies5.length).toBeGreaterThan(enemies1.length); - + // Higher level enemies should have higher stats const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length; const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length; @@ -187,11 +187,11 @@ describe('World Generator', () => { }); it('should generate doors on dungeon floors', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; @@ -205,17 +205,17 @@ describe('World Generator', () => { break; } } - + expect(foundDoor).toBe(true); }); it('should ensure player spawns on safe tile (not grass)', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] + passiveNodes: [] }, inventory: { gold: 0, items: [] } }; @@ -225,7 +225,7 @@ describe('World Generator', () => { const { world, playerId, ecsWorld } = generateWorld(1, runState); const accessor = new EntityAccessor(world, playerId, ecsWorld); const player = accessor.getPlayer()!; - + // Check tile under player const tileIdx = player.pos.y * world.width + player.pos.x; const tile = world.tiles[tileIdx]; @@ -240,7 +240,7 @@ describe('World Generator', () => { describe('Cave Generation (Floors 10+)', () => { it('should generate cellular automata style maps', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, @@ -250,17 +250,17 @@ describe('World Generator', () => { }; const { world } = generateWorld(10, runState); - + // Basic validity checks - expect(world.width).toBe(60); - expect(world.height).toBe(40); + expect(world.width).toBe(120); + expect(world.height).toBe(80); expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true); expect(world.tiles.some(t => t === TileType.WALL)).toBe(true); }); it('should place enemies in caves', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, @@ -277,7 +277,7 @@ describe('World Generator', () => { it('should ensure the map is connected (Player can reach Exit)', () => { const runState = { - stats: { + stats: { 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, critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, @@ -301,26 +301,26 @@ describe('World Generator', () => { pathfinder.compute(player.pos.x, player.pos.y, (x, y) => { path.push([x, y]); }); - + expect(path.length).toBeGreaterThan(0); } }); it('should verify safe spawn logic on caves', () => { - const runState = { - stats: { - 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, - critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, - passiveNodes: [] - }, - inventory: { gold: 0, items: [] } - }; - const { world, playerId, ecsWorld } = generateWorld(12, runState); - 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); + const runState = { + stats: { + 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, + critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0, + passiveNodes: [] + }, + inventory: { gold: 0, items: [] } + }; + const { world, playerId, ecsWorld } = generateWorld(12, runState); + 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); }); }); }); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index b701394..6e62601 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -602,4 +602,28 @@ describe('Combat Simulation', () => { } }); }); + + describe("Death Cleanup", () => { + it("should remove combatants with 0 HP during turn processing", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any; + // Enemy with 0 HP (e.g. killed by status effect prior to turn) + const enemy = { + id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, + stats: createTestStats({ hp: 0 }), energy: 0, type: "rat" + } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + + // This should trigger checkDeaths + const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); + + expect(accessor.hasActor(2 as EntityId)).toBe(false); + expect(result.events.some(e => e.type === "killed" && e.targetId === 2)).toBe(true); + }); + }); }); diff --git a/src/engine/ecs/Prefabs.ts b/src/engine/ecs/Prefabs.ts index aac116a..659892d 100644 --- a/src/engine/ecs/Prefabs.ts +++ b/src/engine/ecs/Prefabs.ts @@ -21,7 +21,6 @@ export const Prefabs = { return EntityBuilder.create(world) .withPosition(x, y) .withName("Rat") - .withSprite("rat", 0) .asEnemy("rat") .withStats({ maxHp: config.baseHp + floorBonus, @@ -41,7 +40,6 @@ export const Prefabs = { return EntityBuilder.create(world) .withPosition(x, y) .withName("Bat") - .withSprite("bat", 0) .asEnemy("bat") .withStats({ maxHp: config.baseHp + floorBonus, @@ -217,7 +215,6 @@ export const Prefabs = { return EntityBuilder.create(world) .withPosition(x, y) .withName("Player") - .withSprite("PriestessSouth", 0) .asPlayer() .withStats(config.initialStats) .withEnergy(config.speed) diff --git a/src/engine/ecs/components.ts b/src/engine/ecs/components.ts index 6eace08..6f81b80 100644 --- a/src/engine/ecs/components.ts +++ b/src/engine/ecs/components.ts @@ -48,7 +48,8 @@ export interface TriggerComponent { onExit?: boolean; // Trigger when entity leaves this tile onInteract?: boolean; // Trigger when entity interacts with this 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) diff --git a/src/engine/ecs/systems/TriggerSystem.ts b/src/engine/ecs/systems/TriggerSystem.ts index edfe438..2e3f212 100644 --- a/src/engine/ecs/systems/TriggerSystem.ts +++ b/src/engine/ecs/systems/TriggerSystem.ts @@ -37,7 +37,7 @@ export class TriggerSystem extends System { const triggerPos = world.getComponent(triggerId, "position"); 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 for (const entityId of allWithPosition) { @@ -50,11 +50,11 @@ export class TriggerSystem extends System { const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos); // Handle enter or manual trigger - if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || (trigger.triggered && !trigger.oneShot)) { + if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) { this.activateTrigger(triggerId, entityId, trigger, world); - // If it was manually triggered, we should probably reset the flag if its not oneShot - if (trigger.triggered && !trigger.oneShot) { + // If it was manually triggered, we should reset the flag + if (trigger.triggered) { trigger.triggered = false; } } @@ -88,7 +88,7 @@ export class TriggerSystem extends System { triggered?: boolean; targetId?: EntityId; onInteract?: boolean; - + spent?: boolean; }, world: ECSWorld ): void { @@ -147,7 +147,8 @@ export class TriggerSystem extends System { // Mark as triggered for one-shot triggers and update sprite if (trigger.oneShot) { - trigger.triggered = true; + trigger.spent = true; + trigger.triggered = false; // Change sprite to triggered appearance if it's a dungeon sprite const sprite = world.getComponent(triggerId, "sprite"); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 39b529d..6495072 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -29,6 +29,8 @@ export function applyAction(w: World, actorId: EntityId, action: Action, accesso break; } + checkDeaths(events, accessor); + return events; } @@ -192,44 +194,55 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a }); if (target.stats.hp <= 0) { - events.push({ - type: "killed", - targetId: target.id, - killerId: actor.id, - 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; - - if (ecsWorld) { - 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 }); - } + killActor(target, events, accessor, actor.id); } return events; } 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); + } + } +} + /** @@ -308,6 +321,7 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: Enti } events.push(...applyAction(w, actor.id, decision.action, accessor)); + checkDeaths(events, accessor); if (!accessor.isPlayerAlive()) { return { awaitingPlayerId: null as any, events }; diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index 285a2ad..b468c45 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -140,10 +140,13 @@ export class DungeonRenderer { console.log(`[DungeonRenderer] Creating ECS sprites...`); const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite"); for (const entId of spriteEntities) { - // Skip player as it's handled separately + // 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) { @@ -282,79 +285,84 @@ export class DungeonRenderer { const pos = this.ecsWorld.getComponent(trapId, "position"); const spriteData = this.ecsWorld.getComponent(trapId, "sprite"); - if (pos && spriteData) { - // Bounds check - if (pos.x < 0 || pos.x >= this.world.width || pos.y < 0 || pos.y >= this.world.height) { - sprite.setVisible(false); - continue; - } - - const i = idx(this.world, pos.x, pos.y); - const isSeen = seen[i] === 1; - 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' - }); - } + // Handle missing components (entity destroyed) + if (!pos || !spriteData) { + sprite.destroy(); + this.trapSprites.delete(trapId); + continue; + } + + // Bounds check + if (pos.x < 0 || pos.x >= this.world.width || pos.y < 0 || pos.y >= this.world.height) { + sprite.setVisible(false); + continue; + } + + const i = idx(this.world, pos.x, pos.y); + const isSeen = seen[i] === 1; + 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' + }); } + } - // Update sprite frame in case trap was triggered - const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head"; - if (!isStandalone && sprite.frame.name !== String(spriteData.index)) { - sprite.setFrame(spriteData.index); - } + // Update sprite frame in case trap was triggered + const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head"; + 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); + // 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]; + // 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 - } + 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.setAlpha(1); - sprite.clearTint(); + sprite.setTint(0xffaa44); // Default orange } + } else { + sprite.setAlpha(1); + sprite.clearTint(); } } } @@ -538,7 +546,19 @@ export class DungeonRenderer { 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); } diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index 2be0493..48662d3 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -9,10 +9,12 @@ vi.mock('phaser', () => { play: vi.fn().mockReturnThis(), setPosition: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(), + setDisplaySize: vi.fn().mockReturnThis(), destroy: vi.fn(), frame: { name: '0' }, setFrame: vi.fn(), setAlpha: vi.fn(), + setAngle: vi.fn(), clearTint: vi.fn(), }; @@ -89,10 +91,12 @@ describe('DungeonRenderer', () => { play: vi.fn().mockReturnThis(), setPosition: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(), + setDisplaySize: vi.fn().mockReturnThis(), destroy: vi.fn(), frame: { name: '0' }, setFrame: vi.fn(), setAlpha: vi.fn(), + setAngle: vi.fn(), clearTint: vi.fn(), })), circle: vi.fn().mockReturnValue({ @@ -134,6 +138,14 @@ describe('DungeonRenderer', () => { setDepth: 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(), }), }, @@ -247,4 +259,43 @@ describe('DungeonRenderer', () => { // Should NOT tween because it's the first spawn 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); + }); }); diff --git a/src/rendering/__tests__/FxRenderer.test.ts b/src/rendering/__tests__/FxRenderer.test.ts index d33b845..626fa8e 100644 --- a/src/rendering/__tests__/FxRenderer.test.ts +++ b/src/rendering/__tests__/FxRenderer.test.ts @@ -11,6 +11,7 @@ vi.mock('phaser', () => { setAlpha: vi.fn().mockReturnThis(), setTint: vi.fn().mockReturnThis(), clearTint: vi.fn().mockReturnThis(), + setDisplaySize: vi.fn().mockReturnThis(), destroy: vi.fn(), }; @@ -42,6 +43,7 @@ describe('FxRenderer', () => { setAlpha: vi.fn().mockReturnThis(), setTint: vi.fn().mockReturnThis(), clearTint: vi.fn().mockReturnThis(), + setDisplaySize: vi.fn().mockReturnThis(), destroy: vi.fn(), })), text: vi.fn(() => ({ diff --git a/src/scenes/rendering/GameRenderer.ts b/src/scenes/rendering/GameRenderer.ts index 638b593..5c91e77 100644 --- a/src/scenes/rendering/GameRenderer.ts +++ b/src/scenes/rendering/GameRenderer.ts @@ -30,8 +30,8 @@ export class GameRenderer implements EventRenderCallbacks { this.dungeonRenderer.showHeal(x, y, amount); } - spawnCorpse(x: number, y: number, type: ActorType): void { - this.dungeonRenderer.spawnCorpse(x, y, type); + spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void { + this.dungeonRenderer.spawnCorpse(x, y, type, targetId); } showWait(x: number, y: number): void { diff --git a/src/scenes/systems/EventRenderer.ts b/src/scenes/systems/EventRenderer.ts index bf31431..9ab1d9a 100644 --- a/src/scenes/systems/EventRenderer.ts +++ b/src/scenes/systems/EventRenderer.ts @@ -8,7 +8,7 @@ export interface EventRenderCallbacks { showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void; showDodge(x: number, y: number): void; showHeal(x: number, y: number, amount: number): void; - spawnCorpse(x: number, y: number, type: ActorType): void; + 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; @@ -40,19 +40,19 @@ export function renderSimEvents( 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"); + callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat", ev.targetId); break; - + case "waited": if (ev.actorId === context.playerId) { const pos = context.getPlayerPos(); @@ -61,23 +61,23 @@ export function renderSimEvents( } } 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; diff --git a/src/scenes/systems/__tests__/TargetingSystem.test.ts b/src/scenes/systems/__tests__/TargetingSystem.test.ts index 9442723..b813433 100644 --- a/src/scenes/systems/__tests__/TargetingSystem.test.ts +++ b/src/scenes/systems/__tests__/TargetingSystem.test.ts @@ -97,11 +97,19 @@ describe('TargetingSystem', () => { const enemyPos = { x: 3, y: 3 }; (getClosestVisibleEnemy as any).mockReturnValue(enemyPos); + const mockAccessor = { + getCombatant: vi.fn().mockReturnValue({ + pos: playerPos, + inventory: { items: [{ id: 'item-1' }] } + }), + context: {} + }; + targetingSystem.startTargeting( 'item-1', playerPos, mockWorld, - {} as any, // accessor + mockAccessor as any, 1 as EntityId, // playerId new Uint8Array(100), 10 @@ -118,11 +126,19 @@ describe('TargetingSystem', () => { const mousePos = { x: 5, y: 5 }; (getClosestVisibleEnemy as any).mockReturnValue(null); + const mockAccessor = { + getCombatant: vi.fn().mockReturnValue({ + pos: playerPos, + inventory: { items: [{ id: 'item-1' }] } + }), + context: {} + }; + targetingSystem.startTargeting( 'item-1', playerPos, mockWorld, - {} as any, // accessor + mockAccessor as any, 1 as EntityId, // playerId new Uint8Array(100), 10, @@ -143,12 +159,20 @@ describe('TargetingSystem', () => { path: [] }); + const mockAccessor = { + getCombatant: vi.fn().mockReturnValue({ + pos: playerPos, + inventory: { items: [{ id: 'item-1' }] } + }), + context: {} + }; + // Start targeting targetingSystem.startTargeting( 'item-1', playerPos, mockWorld, - {} as any, // accessor + mockAccessor as any, 1 as EntityId, new Uint8Array(100), 10,