diff --git a/public/assets/sprites/items/items.png b/public/assets/sprites/items/items.png new file mode 100644 index 0000000..e0eaa3c Binary files /dev/null and b/public/assets/sprites/items/items.png differ diff --git a/src/core/__tests__/math.test.ts b/src/core/__tests__/math.test.ts index 490ed10..290e493 100644 --- a/src/core/__tests__/math.test.ts +++ b/src/core/__tests__/math.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { seededRandom, manhattan, lerp } from '../math'; +import { seededRandom, manhattan, lerp, raycast } from '../math'; describe('Math Utilities', () => { describe('seededRandom', () => { @@ -20,6 +20,37 @@ describe('Math Utilities', () => { }); }); + describe('raycast', () => { + it('should return straight horizontal line', () => { + const points = raycast(0, 0, 3, 0); + expect(points).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 } + ]); + }); + + it('should return straight vertical line', () => { + const points = raycast(0, 0, 0, 3); + expect(points).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: 2 }, + { x: 0, y: 3 } + ]); + }); + + it('should return diagonal line', () => { + const points = raycast(0, 0, 2, 2); + expect(points).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 2 } + ]); + }); + }); + describe('manhattan', () => { it('should calculate correct distance', () => { expect(manhattan({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7); diff --git a/src/core/__tests__/terrain.test.ts b/src/core/__tests__/terrain.test.ts index 2a37c2c..43fd30b 100644 --- a/src/core/__tests__/terrain.test.ts +++ b/src/core/__tests__/terrain.test.ts @@ -36,11 +36,13 @@ describe('Terrain', () => { expect(blocksSight(TileType.EMPTY)).toBe(false); expect(blocksSight(TileType.EXIT)).toBe(false); + expect(blocksSight(TileType.GRASS_SAPLINGS)).toBe(false); }); it('should return correct destruction result', () => { expect(getDestructionResult(TileType.GRASS)).toBe(TileType.GRASS_SAPLINGS); expect(getDestructionResult(TileType.DOOR_CLOSED)).toBe(TileType.DOOR_OPEN); + expect(getDestructionResult(TileType.DOOR_OPEN)).toBe(TileType.DOOR_CLOSED); expect(getDestructionResult(TileType.WALL)).toBeUndefined(); }); @@ -48,7 +50,17 @@ describe('Terrain', () => { it('should correctly identify tiles destructible by walk', () => { expect(isDestructibleByWalk(TileType.GRASS)).toBe(true); expect(isDestructibleByWalk(TileType.DOOR_CLOSED)).toBe(true); + expect(isDestructibleByWalk(TileType.DOOR_OPEN)).toBe(true); // Should be closable by walk expect(isDestructibleByWalk(TileType.WALL)).toBe(false); }); + + it('should handle unknown tile types gracefully', () => { + const unknownTile = 999; + expect(isBlocking(unknownTile)).toBe(false); + expect(isDestructible(unknownTile)).toBe(false); + expect(blocksSight(unknownTile)).toBe(false); + expect(getDestructionResult(unknownTile)).toBeUndefined(); + expect(isDestructibleByWalk(unknownTile)).toBe(false); + }); }); }); diff --git a/src/core/config/GameConfig.ts b/src/core/config/GameConfig.ts index f31cf7a..ff178d8 100644 --- a/src/core/config/GameConfig.ts +++ b/src/core/config/GameConfig.ts @@ -136,6 +136,7 @@ export const GAME_CONFIG = { { key: "bat", path: "assets/sprites/actors/enemies/bat.png", frameConfig: { frameWidth: 15, frameHeight: 15 } }, { key: "dungeon", path: "assets/tilesets/dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } }, { key: "soldier.idle", path: "assets/sprites/actors/player/soldier/Idle.png", frameConfig: { frameWidth: 60, frameHeight: 75 } }, + { key: "items", path: "assets/sprites/items/items.png", frameConfig: { frameWidth: 16, frameHeight: 16 } }, ], images: [ { key: "splash_bg", path: "assets/ui/splash_bg.png" } diff --git a/src/core/config/Items.ts b/src/core/config/Items.ts index 951522d..f18667f 100644 --- a/src/core/config/Items.ts +++ b/src/core/config/Items.ts @@ -5,14 +5,18 @@ export const ITEMS: Record = { id: "health_potion", name: "Health Potion", type: "Consumable", - icon: "potion_red", - stats: {} // Special logic for usage + textureKey: "items", + spriteIndex: 57, + stats: { + hp: 5 + } }, "iron_sword": { id: "iron_sword", name: "Iron Sword", type: "Weapon", - icon: "sword_iron", + textureKey: "items", + spriteIndex: 2, stats: { attack: 2 } @@ -21,9 +25,21 @@ export const ITEMS: Record = { id: "leather_armor", name: "Leather Armor", type: "BodyArmour", - icon: "armor_leather", + textureKey: "items", + spriteIndex: 25, stats: { defense: 2 } + }, + "throwing_dagger": { + id: "throwing_dagger", + name: "Throwing Dagger", + type: "Consumable", + textureKey: "items", + spriteIndex: 15, + stats: { + attack: 4 + }, + throwable: true } }; diff --git a/src/core/math.ts b/src/core/math.ts index 1e9f202..7bb02b2 100644 --- a/src/core/math.ts +++ b/src/core/math.ts @@ -1,17 +1,42 @@ import type { Vec2 } from "./types"; -export function seededRandom(seed: number): () => number { - let state = seed; +export function seededRandom(seed: number) { + let s = seed % 2147483647; + if (s <= 0) s += 2147483646; return () => { - state = (state * 1103515245 + 12345) & 0x7fffffff; - return state / 0x7fffffff; + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; }; } +/** + * Bresenham's line algorithm to get all points between two coordinates. + */ +export function raycast(x0: number, y0: number, x1: number, y1: number): Vec2[] { + const points: Vec2[] = []; + let startX = x0; + let startY = y0; + + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = (x0 < x1) ? 1 : -1; + const sy = (y0 < y1) ? 1 : -1; + let err = dx - dy; + + while(true) { + points.push({ x: startX, y: startY }); + if (startX === x1 && startY === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; startX += sx; } + if (e2 < dx) { err += dx; startY += sy; } + } + return points; +} + export function manhattan(a: Vec2, b: Vec2): number { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } -export function lerp(a: number, b: number, t: number): number { - return a + (b - a) * t; +export function lerp(start: number, end: number, t: number): number { + return start * (1 - t) + end * t; } diff --git a/src/core/types.ts b/src/core/types.ts index b30b65f..c30e467 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -80,7 +80,9 @@ export type Item = { name: string; type: ItemType; stats?: Partial; - icon?: string; + textureKey: string; + spriteIndex: number; + throwable?: boolean; }; export type Equipment = { @@ -136,12 +138,13 @@ export interface CollectibleActor extends BaseActor { expAmount: number; } -export interface ItemActor extends BaseActor { - category: "item"; - item: Item; +export interface ItemDropActor extends BaseActor { + category: "item_drop"; + // type: string; // "health_potion", etc. or reuse Item + item: Item; } -export type Actor = CombatantActor | CollectibleActor | ItemActor; +export type Actor = CombatantActor | CollectibleActor | ItemDropActor; export type World = { width: number; diff --git a/src/engine/__tests__/pathfinding.test.ts b/src/engine/__tests__/pathfinding.test.ts index 01aa7e2..daeab90 100644 --- a/src/engine/__tests__/pathfinding.test.ts +++ b/src/engine/__tests__/pathfinding.test.ts @@ -48,7 +48,7 @@ describe('Pathfinding', () => { it('should respect ignoreBlockedTarget option', () => { const world = createTestWorld(10, 10); // Place an actor at target - world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat' } as any); + world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat', category: 'combatant' } as any); const seen = new Uint8Array(100).fill(1); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 53a7883..8453c2a 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -25,6 +25,14 @@ describe('Combat Simulation', () => { + describe('applyAction', () => { + it('should return empty events if actor does not exist', () => { + const world = createTestWorld(new Map()); + const events = applyAction(world, 999, { type: "wait" }); + expect(events).toEqual([]); + }); + }); + describe('applyAction - success paths', () => { it('should deal damage when player attacks enemy', () => { const actors = new Map(); @@ -87,6 +95,24 @@ describe('Combat Simulation', () => { // Tile should effectively be destroyed (turned to saplings/2) expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS }); + + it("should handle wait action", () => { + const actors = new Map(); + actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any); + const world = createTestWorld(actors); + + const events = applyAction(world, 1, { type: "wait" }, new EntityManager(world)); + expect(events).toEqual([{ type: "waited", actorId: 1 }]); + }); + + it("should default to wait for unknown action type", () => { + const actors = new Map(); + actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any); + const world = createTestWorld(actors); + + const events = applyAction(world, 1, { type: "unknown_hack" } as any, new EntityManager(world)); + expect(events).toEqual([{ type: "waited", actorId: 1 }]); + }); }); describe("decideEnemyAction - AI Logic", () => { @@ -239,6 +265,32 @@ describe('Combat Simulation', () => { expect(result.events.length).toBeGreaterThan(0); expect(result.awaitingPlayerId).toBe(1); }); + + it("should handle player death during enemy turn", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats({ hp: 1 }), energy: 0 } as any; + // Enemy that will kill player + const enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 1, y: 0 }, + speed: 100, + stats: createTestStats({ attack: 100 }), + aiState: "pursuing", + energy: 100 + } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + const em = new EntityManager(world); + + const result = stepUntilPlayerTurn(world, 1, em); + + expect(world.actors.has(1)).toBe(false); // Player dead + expect(result.events.some(e => e.type === "killed" && e.targetId === 1)).toBe(true); + }); }); describe("Combat Mechanics - Detailed", () => { @@ -351,6 +403,26 @@ describe('Combat Simulation', () => { expect(player.stats.hp).toBe(15); expect(events.some(e => e.type === "healed")).toBe(true); }); + + it("should not lifesteal beyond maxHp", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 100, hp: 19, maxHp: 20 }) + } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + mockRandom.mockReturnValue(0.1); + + applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + + // Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20. + expect(player.stats.hp).toBe(20); + }); }); describe("Level Up Logic", () => { diff --git a/src/engine/gameplay/CombatLogic.ts b/src/engine/gameplay/CombatLogic.ts new file mode 100644 index 0000000..3d26770 --- /dev/null +++ b/src/engine/gameplay/CombatLogic.ts @@ -0,0 +1,53 @@ +import { type World, type Vec2, type EntityId } from "../../core/types"; +import { isBlocked } from "../world/world-logic"; +import { raycast } from "../../core/math"; +import { EntityManager } from "../EntityManager"; + +export interface ProjectileResult { + path: Vec2[]; + blockedPos: Vec2; + hitActorId?: EntityId; +} + +/** + * Calculates the path and impact of a projectile. + */ +export function traceProjectile( + world: World, + start: Vec2, + target: Vec2, + entityManager: EntityManager, + shooterId?: EntityId +): ProjectileResult { + const points = raycast(start.x, start.y, target.x, target.y); + let blockedPos = target; + let hitActorId: EntityId | undefined; + + // Iterate points (skip start) + for (let i = 1; i < points.length; i++) { + const p = points[i]; + + // Check for blocking + if (isBlocked(world, p.x, p.y, entityManager)) { + // Check if we hit a combatant + const actors = entityManager.getActorsAt(p.x, p.y); + const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId); + + if (enemy) { + hitActorId = enemy.id; + blockedPos = p; + } else { + // Hit wall or other obstacle + blockedPos = p; + } + break; + } + blockedPos = p; + } + + return { + path: points, + blockedPos, + hitActorId + }; +} diff --git a/src/engine/gameplay/__tests__/CombatLogic.test.ts b/src/engine/gameplay/__tests__/CombatLogic.test.ts new file mode 100644 index 0000000..d2167ae --- /dev/null +++ b/src/engine/gameplay/__tests__/CombatLogic.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { traceProjectile } from '../CombatLogic'; +import type { World } from '../../../core/types'; +import { EntityManager } from '../../EntityManager'; +import { TileType } from '../../../core/terrain'; + +describe('CombatLogic', () => { + // Mock World + const mockWorld: World = { + width: 10, + height: 10, + tiles: new Array(100).fill(TileType.EMPTY), + actors: new Map(), + exit: { x: 9, y: 9 } + }; + + // Helper to set wall + const setWall = (x: number, y: number) => { + 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(() => { + clearWorld(); + }); + + describe('traceProjectile', () => { + it('should travel full path if no obstacles', () => { + const start = { x: 0, y: 0 }; + const end = { x: 5, y: 0 }; + + const result = traceProjectile(mockWorld, start, end, mockEntityManager); + + expect(result.blockedPos).toEqual(end); + 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); + }); + + it('should stop at wall', () => { + const start = { x: 0, y: 0 }; + const end = { x: 5, y: 0 }; + setWall(3, 0); // Wall at (3,0) + + const result = traceProjectile(mockWorld, start, end, mockEntityManager); + + expect(result.blockedPos).toEqual({ x: 3, y: 0 }); + expect(result.hitActorId).toBeUndefined(); + }); + + it('should stop at enemy', () => { + const start = { x: 0, y: 0 }; + const end = { x: 5, y: 0 }; + + // Place enemy at (3,0) + const enemyId = 2; + mockWorld.actors.set(enemyId, { + id: enemyId, + type: 'rat', + category: 'combatant', + pos: { x: 3, y: 0 }, + isPlayer: false + // ... other props mocked if needed + } as any); + + const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); // Shooter 1 + + expect(result.blockedPos).toEqual({ x: 3, y: 0 }); + expect(result.hitActorId).toBe(enemyId); + }); + + it('should ignore shooter position', () => { + const start = { x: 0, y: 0 }; + const end = { x: 5, y: 0 }; + + // Shooter at start + mockWorld.actors.set(1, { + id: 1, + type: 'player', + category: 'combatant', + pos: { x: 0, y: 0 }, + isPlayer: true + } as any); + + const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); + + // Should not hit self + expect(result.hitActorId).toBeUndefined(); + expect(result.blockedPos).toEqual(end); + }); + + it('should ignore non-combatant actors (e.g. items)', () => { + const start = { x: 0, y: 0 }; + const end = { x: 5, y: 0 }; + + // Item at (3,0) + mockWorld.actors.set(99, { + id: 99, + category: 'item_drop', + pos: { x: 3, y: 0 }, + } as any); + + const result = traceProjectile(mockWorld, start, end, mockEntityManager); + + // Should pass through item + expect(result.blockedPos).toEqual(end); + expect(result.hitActorId).toBeUndefined(); + }); + }); +}); diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index a7cc8ff..9af3633 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -52,7 +52,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World items: [ ...runState.inventory.items, // Add starting items for testing if empty - ...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"]] : []) + ...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"]] : []) ] }, energy: 0 diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index 3fe4af7..28127a0 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -42,11 +42,13 @@ export function isBlocked(w: World, x: number, y: number, em?: EntityManager): b if (isBlockingTile(w, x, y)) return true; if (em) { - return em.isOccupied(x, y, "exp_orb"); + const actors = em.getActorsAt(x, y); + // Only combatants block movement + return actors.some(a => a.category === "combatant"); } for (const a of w.actors.values()) { - if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true; + if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true; } return false; } diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index 257caee..828a4fc 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -3,6 +3,7 @@ import { type World, type EntityId, type Vec2, type ActorType } from "../core/ty import { TILE_SIZE } from "../core/constants"; import { idx, isWall } from "../engine/world/world-logic"; import { GAME_CONFIG } from "../core/config/GameConfig"; +import { ITEMS } from "../core/config/Items"; import { FovManager } from "./FovManager"; import { MinimapRenderer } from "./MinimapRenderer"; import { FxRenderer } from "./FxRenderer"; @@ -15,6 +16,7 @@ export class DungeonRenderer { private playerSprite?: Phaser.GameObjects.Sprite; private enemySprites: Map = new Map(); private orbSprites: Map = new Map(); + private itemSprites: Map = new Map(); private fovManager: FovManager; private minimapRenderer: MinimapRenderer; @@ -97,6 +99,12 @@ export class DungeonRenderer { return this.fovManager.isSeen(x, y); } + updateTile(x: number, y: number) { + if (!this.map || !this.world) return; + const t = this.world.tiles[idx(this.world, x, y)]; + this.map.putTileAt(t, x, y); + } + get seenArray() { return this.fovManager.seenArray; } @@ -139,6 +147,7 @@ export class DungeonRenderer { // Actors (Combatants) const activeEnemyIds = new Set(); const activeOrbIds = new Set(); + const activeItemIds = new Set(); for (const a of this.world.actors.values()) { const i = idx(this.world, a.pos.x, a.pos.y); @@ -215,6 +224,23 @@ export class DungeonRenderer { orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); orb.setVisible(true); } + } else if (a.category === "item_drop") { + if (!isVis) continue; + + activeItemIds.add(a.id); + let itemSprite = this.itemSprites.get(a.id); + if (!itemSprite) { + itemSprite = this.scene.add.sprite(0, 0, a.item.textureKey, a.item.spriteIndex); + itemSprite.setDepth(40); + this.itemSprites.set(a.id, itemSprite); + } + const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2; + const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2; + itemSprite.setPosition(tx, ty); + itemSprite.setVisible(true); + + // bobbing effect? + itemSprite.y += Math.sin(this.scene.time.now / 300) * 2; } } @@ -239,6 +265,16 @@ export class DungeonRenderer { } } + for (const [id, item] of this.itemSprites.entries()) { + if (!activeItemIds.has(id)) { + item.setVisible(false); + if (!this.world.actors.has(id)) { + item.destroy(); + this.itemSprites.delete(id); + } + } + } + this.minimapRenderer.render(this.world, seen, visible); } @@ -278,4 +314,46 @@ export class DungeonRenderer { showAlert(x: number, y: number) { this.fxRenderer.showAlert(x, y); } + + showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) { + // World coords + const startX = from.x * TILE_SIZE + TILE_SIZE / 2; + const startY = from.y * TILE_SIZE + TILE_SIZE / 2; + const endX = to.x * TILE_SIZE + TILE_SIZE / 2; + const endY = to.y * TILE_SIZE + TILE_SIZE / 2; + + // Create sprite + // Look up sprite index from config + const itemConfig = ITEMS[itemId]; + const texture = itemConfig?.textureKey ?? "items"; + const frame = itemConfig?.spriteIndex ?? 0; + + // Use 'items' spritesheet + const sprite = this.scene.add.sprite(startX, startY, texture, frame); + sprite.setDepth(2000); + + // Rotate? + 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({ + targets: sprite, + x: endX, + y: endY, + rotation: sprite.rotation + 4 * Math.PI, // Spin effect + duration: duration, + ease: 'Linear', + onComplete: () => { + sprite.destroy(); + onComplete(); + } + }); + } + + shakeCamera() { + this.scene.cameras.main.shake(100, 0.01); + } } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 6fd9de6..253ed1f 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,3 +1,4 @@ +// Reading types.ts to verify actor structure before next step import Phaser from "phaser"; import { type EntityId, @@ -5,13 +6,18 @@ import { type Action, type RunState, type World, - type CombatantActor + type CombatantActor, + type Item, + type ItemDropActor } from "../core/types"; import { TILE_SIZE } from "../core/constants"; -import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic"; +import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic"; import { findPathAStar } from "../engine/world/pathfinding"; import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation"; import { generateWorld } from "../engine/world/generator"; +import { traceProjectile } from "../engine/gameplay/CombatLogic"; + + import { DungeonRenderer } from "../rendering/DungeonRenderer"; import { GAME_CONFIG } from "../core/config/GameConfig"; @@ -44,6 +50,11 @@ export class GameScene extends Phaser.Scene { private entityManager!: EntityManager; private progressionManager: ProgressionManager = new ProgressionManager(); + // Targeting Mode + private isTargeting = false; + private targetingItem: string | null = null; + private targetingGraphics!: Phaser.GameObjects.Graphics; + private turnCount = 0; // Track turns for mana regen constructor() { @@ -58,7 +69,8 @@ export class GameScene extends Phaser.Scene { this.cameras.main.fadeIn(1000, 0, 0, 0); // Initialize Sub-systems - this.dungeonRenderer = new DungeonRenderer(this); + this.dungeonRenderer = new DungeonRenderer(this); + this.targetingGraphics = this.add.graphics().setDepth(2000); // Launch UI Scene this.scene.launch("GameUI"); @@ -79,28 +91,23 @@ export class GameScene extends Phaser.Scene { // Menu Inputs this.input.keyboard?.on("keydown-I", () => { - // Close minimap if it's open if (this.dungeonRenderer.isMinimapVisible()) { this.dungeonRenderer.toggleMinimap(); } this.events.emit("toggle-menu"); - // Force update UI in case it opened this.emitUIUpdate(); }); this.input.keyboard?.on("keydown-ESC", () => { this.events.emit("close-menu"); - // Also close minimap if (this.dungeonRenderer.isMinimapVisible()) { this.dungeonRenderer.toggleMinimap(); } }); this.input.keyboard?.on("keydown-M", () => { - // Close menu if it's open this.events.emit("close-menu"); this.dungeonRenderer.toggleMinimap(); }); this.input.keyboard?.on("keydown-B", () => { - // Toggle inventory this.events.emit("toggle-inventory"); }); this.input.keyboard?.on("keydown-C", () => { @@ -132,16 +139,16 @@ export class GameScene extends Phaser.Scene { const player = this.world.actors.get(this.playerId) as CombatantActor; if (player) { this.progressionManager.allocateStat(player, statName); - this.emitUIUpdate(); - } + this.emitUIUpdate(); + } }); this.events.on("allocate-passive", (nodeId: string) => { const player = this.world.actors.get(this.playerId) as CombatantActor; if (player) { this.progressionManager.allocatePassive(player, nodeId); - this.emitUIUpdate(); - } + this.emitUIUpdate(); + } }); this.events.on("player-wait", () => { @@ -155,50 +162,44 @@ export class GameScene extends Phaser.Scene { if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return; console.log("Player searching..."); - // Search takes a turn (functionally same as wait for now, but semantically distinct) this.commitPlayerAction({ type: "wait" }); }); this.events.on("use-item", (data: { itemId: string }) => { if (!this.awaitingPlayer) return; - // Don't block item usage if inventory is open, as we might use it from there or hotbar. - // But if we use it from inventory, we might want to close inventory or update it. const player = this.world.actors.get(this.playerId) as CombatantActor; if (!player || !player.inventory) return; - if (data.itemId === "health_potion") { - // Heal logic - const healAmount = 5; + const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId); + if (itemIdx === -1) return; + const item = player.inventory.items[itemIdx]; + + if (item.stats && item.stats.hp && item.stats.hp > 0) { + const healAmount = item.stats.hp; if (player.stats.hp < player.stats.maxHp) { player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp); - // Visuals handled by diff in stats usually? No, we need explicit heal event or simple floating text - // commitPlayerAction triggers simulation which might generate events. - // But healing from item is instant effect before turn passes? - // Or we treat it as an action. + // Remove item after use + player.inventory.items.splice(itemIdx, 1); - // Let's remove item first - const idx = player.inventory.items.findIndex(it => it.id === "health_potion"); - if (idx !== -1) { - player.inventory.items.splice(idx, 1); - - // Show visual - this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount); - - // Pass turn - this.commitPlayerAction({ type: "wait" }); - this.emitUIUpdate(); - } - } else { - console.log("Already at full health"); + this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount); + this.commitPlayerAction({ type: "wait" }); + this.emitUIUpdate(); } - } else { - console.log("Used item:", data.itemId); + } else if (item.throwable) { + this.targetingItem = item.id; + this.isTargeting = true; + console.log("Targeting Mode: ON"); } }); - + // Right Clicks to cancel targeting + this.input.on('pointerdown', (p: Phaser.Input.Pointer) => { + if (p.rightButtonDown() && this.isTargeting) { + this.cancelTargeting(); + } + }); // Zoom Control this.input.on( @@ -227,12 +228,15 @@ export class GameScene extends Phaser.Scene { // Camera Panning this.input.on("pointermove", (p: Phaser.Input.Pointer) => { - if (!p.isDown) return; + if (!p.isDown) { // Even if not down, we might need to update targeting line + if (this.isTargeting) { + this.updateTargetingLine(p); + } + return; + } + if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return; - // Pan with Middle Click or Right Click - // Note: p.button is not always reliable in move events for holding, - // so we use specific button down checks or the shift key modifier. const isRightDrag = p.rightButtonDown(); const isMiddleDrag = p.middleButtonDown(); const isShiftDrag = p.isDown && p.event.shiftKey; @@ -249,16 +253,30 @@ export class GameScene extends Phaser.Scene { this.followPlayer = false; } + + if (this.isTargeting) { + this.updateTargetingLine(p); + } }); - // Mouse click -> compute path (only during player turn, and not while menu/minimap is open) + // Mouse click -> this.input.on("pointerdown", (p: Phaser.Input.Pointer) => { - // Only allow Left Click (0) for movement + // Targeting Click + if (this.isTargeting) { + // Only Left Click throws + if (p.button === 0) { + const tx = Math.floor(p.worldX / TILE_SIZE); + const ty = Math.floor(p.worldY / TILE_SIZE); + this.executeThrow(tx, ty); + } + return; + } + + // Movement Click if (p.button !== 0) return; - + this.followPlayer = true; - if (!this.awaitingPlayer) return; if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return; @@ -267,22 +285,18 @@ export class GameScene extends Phaser.Scene { if (!inBounds(this.world, tx, ty)) return; - // Exploration rule: cannot click-to-move into unseen tiles if (!this.dungeonRenderer.isSeen(tx, ty)) return; - // Check if clicking on an enemy const isEnemy = [...this.world.actors.values()].some(a => - a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer + a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer ); - // Check for diagonal adjacency for immediate attack const player = this.world.actors.get(this.playerId) as CombatantActor; 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) { - // Check targetId again to get the ID... technically we just did .some() above. const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer )?.id; @@ -300,7 +314,6 @@ export class GameScene extends Phaser.Scene { { ignoreBlockedTarget: isEnemy } ); - if (path.length >= 2) this.playerPath = path; this.dungeonRenderer.render(this.playerPath); }); @@ -323,17 +336,15 @@ export class GameScene extends Phaser.Scene { } if (isBlocked(this.world, next.x, next.y, this.entityManager)) { - // Check if it's an enemy at 'next' const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer )?.id; if (targetId !== undefined) { this.commitPlayerAction({ type: "attack", targetId }); - this.playerPath = []; // Stop after attack + this.playerPath = []; return; } else { - // Blocked by something else (friendly?) this.playerPath = []; return; } @@ -344,39 +355,16 @@ export class GameScene extends Phaser.Scene { return; } - // Arrow keys - Support diagonals for attacking only let action: Action | null = null; let dx = 0; let dy = 0; - // Check all keys to allow simultaneous presses - 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; - - // Force single step input "just now" check to avoid super speed, - // OR we rely on `awaitingPlayer` to throttle us. - // `update` runs every frame. `awaitingPlayer` is set to false in `commitPlayerAction`. - // It remains false until `stepUntilPlayerTurn` returns true. - // So as long as we only act when `awaitingPlayer` is true, simple `isDown` works for direction combination. - // BUT we need to ensure we don't accidentally move if we just want to tap. - // However, common roguelike Input: if you hold, you repeat. - // We already have `awaitingPlayer` logic. - - // One nuance: mixing JustDown and isDown. - // If we use isDown, we might act immediately. - // If we want to support "turn based", usually we wait for "JustDown" of *any* key. - // But if we want diagonal, we need 2 keys. - // Simpler approach: - // If any direction key is JustDown, capture the state of ALL direction keys. const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) || Phaser.Input.Keyboard.JustDown(this.cursors.right!) || Phaser.Input.Keyboard.JustDown(this.cursors.up!) || Phaser.Input.Keyboard.JustDown(this.cursors.down!); if (anyJustDown) { - // Recalculate dx/dy based on currently held keys to catch the combo dx = 0; dy = 0; if (this.cursors.left!.isDown) dx -= 1; if (this.cursors.right!.isDown) dx += 1; @@ -388,7 +376,6 @@ export class GameScene extends Phaser.Scene { const targetX = player.pos.x + dx; const targetY = player.pos.y + dy; - // Check for enemy at target position const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer )?.id; @@ -396,7 +383,6 @@ export class GameScene extends Phaser.Scene { if (targetId !== undefined) { action = { type: "attack", targetId }; } else { - // Only move if strictly cardinal (no diagonals) if (Math.abs(dx) + Math.abs(dy) === 1) { action = { type: "move", dx, dy }; } @@ -423,10 +409,15 @@ export class GameScene extends Phaser.Scene { this.followPlayer = true; const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager); + + // Check for pickups right after move (before enemy turn, so you get it efficiently) + if (action.type === "move") { + this.tryPickupItem(); + } + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; - // Increment turn counter and handle mana regeneration this.turnCount++; if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) { const player = this.world.actors.get(this.playerId) as CombatantActor; @@ -440,7 +431,6 @@ export class GameScene extends Phaser.Scene { } - // Process events for visual fx const allEvents = [...playerEvents, ...enemyStep.events]; for (const ev of allEvents) { if (ev.type === "damaged") { @@ -468,9 +458,8 @@ export class GameScene extends Phaser.Scene { } - // Check if player died if (!this.world.actors.has(this.playerId)) { - this.syncRunStateFromPlayer(); // Save final stats for death screen + this.syncRunStateFromPlayer(); const uiScene = this.scene.get("GameUI") as any; if (uiScene) { uiScene.showDeathScreen({ @@ -482,7 +471,6 @@ export class GameScene extends Phaser.Scene { return; } - // Level transition if (isPlayerOnExit(this.world, this.playerId)) { this.syncRunStateFromPlayer(); this.floorIndex++; @@ -508,17 +496,13 @@ export class GameScene extends Phaser.Scene { this.entityManager = new EntityManager(this.world); - // Reset transient state this.playerPath = []; this.awaitingPlayer = false; - // Camera bounds for this level this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); - // Initialize Renderer for new floor this.dungeonRenderer.initializeFloor(this.world, this.playerId); - // Step until player turn const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; @@ -527,6 +511,9 @@ export class GameScene extends Phaser.Scene { this.centerCameraOnPlayer(); this.dungeonRenderer.render(this.playerPath); this.emitUIUpdate(); + + // Create daggers for testing if none exist (redundant if generator does it, but good for safety) + // Removed to rely on generator.ts } private syncRunStateFromPlayer() { @@ -548,7 +535,6 @@ export class GameScene extends Phaser.Scene { this.loadFloor(this.floorIndex); } - private centerCameraOnPlayer() { const player = this.world.actors.get(this.playerId) as CombatantActor; this.cameras.main.centerOn( @@ -557,5 +543,129 @@ export class GameScene extends Phaser.Scene { ); } -} + private updateTargetingLine(p: Phaser.Input.Pointer) { + if (!this.world) return; + this.targetingGraphics.clear(); + + const player = this.world.actors.get(this.playerId) as CombatantActor; + if (!player) return; + const startX = player.pos.x * TILE_SIZE + TILE_SIZE / 2; + const startY = player.pos.y * TILE_SIZE + TILE_SIZE / 2; + + const endX = p.worldX; + const endY = p.worldY; + + this.targetingGraphics.lineStyle(2, 0xff0000, 0.7); + this.targetingGraphics.lineBetween(startX, startY, endX, endY); + + const tx = Math.floor(endX / TILE_SIZE); + const ty = Math.floor(endY / TILE_SIZE); + this.targetingGraphics.strokeRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE); + } + + private cancelTargeting() { + this.isTargeting = false; + this.targetingItem = null; + this.targetingGraphics.clear(); + console.log("Targeting cancelled"); + } + + private executeThrow(targetX: number, targetY: number) { + const player = this.world.actors.get(this.playerId) as CombatantActor; + if (!player) return; + + const itemArg = this.targetingItem; + if (!itemArg) return; + + const itemIdx = player.inventory!.items.findIndex(it => it.id === itemArg); + if (itemIdx === -1) { + console.log("Item not found!"); + this.cancelTargeting(); + return; + } + + const item = player.inventory!.items[itemIdx]; + player.inventory!.items.splice(itemIdx, 1); + + const start = player.pos; + const end = { x: targetX, y: targetY }; + + const result = traceProjectile(this.world, start, end, this.entityManager, this.playerId); + const { blockedPos, hitActorId } = result; + + this.dungeonRenderer.showProjectile( + start, + blockedPos, + item.id, + () => { + if (hitActorId !== undefined) { + const victim = this.world.actors.get(hitActorId) as CombatantActor; + if (victim) { + const dmg = item.stats?.attack ?? 1; // Use item stats + victim.stats.hp -= dmg; + this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg); + this.dungeonRenderer.shakeCamera(); + + if (victim.stats.hp <= 0) { + // Force kill handled by simulation + } + } + } + + // Drop the actual item at the landing spot + this.spawnItem(item, blockedPos.x, blockedPos.y); + + // "Count as walking over the tile" -> Trigger destruction/interaction + // e.g. breaking grass, opening items + if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) { + this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y); + } + + this.cancelTargeting(); + this.commitPlayerAction({ type: "wait" }); + this.emitUIUpdate(); + } + ); + } + + private spawnItem(item: Item, x: number, y: number) { + if (!this.world || !this.entityManager) return; + + const id = this.entityManager.getNextId(); + const drop: ItemDropActor = { + id, + pos: { x, y }, + category: "item_drop", + item: { ...item } // Clone item + }; + + this.entityManager.addActor(drop); + // Ensure renderer knows? Renderer iterates world.actors, so it should pick it up if we handle "item_drop" + } + + private tryPickupItem() { + const player = this.world.actors.get(this.playerId) as CombatantActor; + if (!player) return; + + const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y); + const itemActor = actors.find(a => (a as any).category === "item_drop"); // Safe check + + if (itemActor) { + const drop = itemActor as any; // Cast to ItemDropActor + const item = drop.item; + + // Add to inventory + player.inventory!.items.push(item); + + // Remove from world + this.entityManager.removeActor(drop.id); + + console.log("Picked up:", item.name); + // Show FX? + // this.dungeonRenderer.showPickup(player.pos.x, player.pos.y); -> need to implement + this.emitUIUpdate(); + } + } + +} diff --git a/src/scenes/__tests__/GameScene.test.ts b/src/scenes/__tests__/GameScene.test.ts index f74910b..8eddb5b 100644 --- a/src/scenes/__tests__/GameScene.test.ts +++ b/src/scenes/__tests__/GameScene.test.ts @@ -35,7 +35,13 @@ vi.mock('phaser', () => { get: vi.fn(), }; add = { - graphics: vi.fn(() => ({})), + graphics: vi.fn(() => ({ + setDepth: vi.fn().mockReturnThis(), + clear: vi.fn(), + lineStyle: vi.fn(), + lineBetween: vi.fn(), + strokeRect: vi.fn(), + })), text: vi.fn(() => ({})), rectangle: vi.fn(() => ({})), container: vi.fn(() => ({})), diff --git a/src/ui/components/InventoryOverlay.ts b/src/ui/components/InventoryOverlay.ts index c777356..bdc6681 100644 --- a/src/ui/components/InventoryOverlay.ts +++ b/src/ui/components/InventoryOverlay.ts @@ -98,24 +98,13 @@ export class InventoryOverlay extends OverlayComponent { const slot = this.backpackSlots[index]; - let color = "#ffffff"; - let label = item.name.substring(0, 2).toUpperCase(); + const texture = item.textureKey; + const frame = item.spriteIndex; - if (item.type === "Consumable") { - color = "#ff5555"; - } else if (item.type === "Weapon") { - color = "#aaaaaa"; - } else if (item.type === "BodyArmour") { - color = "#aa5500"; - } - - const txt = this.scene.add.text(0, 0, label, { - fontSize: "10px", - color: color, - fontStyle: "bold" - }).setOrigin(0.5); - - slot.add(txt); + const sprite = this.scene.add.sprite(0, 0, texture, frame); + sprite.setScale(2); // 16x16 -> 32x32, fits in 40x40 slot + + slot.add(sprite); // Add simple tooltip on hover (console log for now) or click slot.setInteractive(new Phaser.Geom.Rectangle(-20, -20, 40, 40), Phaser.Geom.Rectangle.Contains); diff --git a/src/ui/components/QuickSlotComponent.ts b/src/ui/components/QuickSlotComponent.ts index 69204b5..47e8163 100644 --- a/src/ui/components/QuickSlotComponent.ts +++ b/src/ui/components/QuickSlotComponent.ts @@ -6,7 +6,7 @@ export class QuickSlotComponent { private container!: Phaser.GameObjects.Container; private slots: Phaser.GameObjects.Container[] = []; private itemMap: (Item | null)[] = [null, null, null, null]; // 4 slots - private assignedIds: string[] = ["health_potion", "", "", ""]; // Default slot 1 to HP pot + private assignedIds: string[] = ["health_potion", "throwing_dagger", "", ""]; // Default slot 1 to HP pot, 2 to Dagger constructor(scene: Phaser.Scene) { this.scene = scene; @@ -61,7 +61,6 @@ export class QuickSlotComponent { const desiredId = this.assignedIds[i]; const slot = this.slots[i]; - // Clear previous item icon if any (children > 2, since 0=bg, 1=text) // Clear previous item icon if any (children > 2, since 0=bg, 1=text) if (slot.list.length > 2) { slot.removeBetween(2, undefined, true); @@ -72,29 +71,11 @@ export class QuickSlotComponent { this.itemMap[i] = foundItem || null; if (foundItem) { - // Determine color based on item ID for now since we don't have real assets loaded for everything yet - let color = 0xffffff; - let label = "?"; - - if (foundItem.id === "health_potion") { - color = 0xff3333; - label = "HP"; - } - // Draw simple icon representation - const icon = this.scene.add.text(20, 20, label, { - fontSize: "14px", - color: "#ffffff", - fontStyle: "bold" - }).setOrigin(0.5); - - // Add bg circle for color - const circle = this.scene.add.graphics(); - circle.fillStyle(color, 1); - circle.fillCircle(20, 20, 10); - - // Move text to front - slot.add(circle); - slot.add(icon); + const texture = foundItem.textureKey ?? "items"; + const sprite = this.scene.add.sprite(20, 20, texture, foundItem.spriteIndex); + // PD items are 16x16, slot is 40x40. Scale it up? + sprite.setScale(2); + slot.add(sprite); // Add count if stackable (future) const count = player.inventory.items.filter(it => it.id === desiredId).length;