From 9b1fc78409eaa7d4c8d6aae3cd7752927a271503 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Tue, 6 Jan 2026 20:58:53 +1100 Subject: [PATCH] Add in throwable items (dagger) from pixel dungeon --- public/assets/sprites/items/items.png | Bin 0 -> 16942 bytes src/core/__tests__/math.test.ts | 33 +- src/core/__tests__/terrain.test.ts | 12 + src/core/config/GameConfig.ts | 1 + src/core/config/Items.ts | 24 +- src/core/math.ts | 37 ++- src/core/types.ts | 13 +- src/engine/__tests__/pathfinding.test.ts | 2 +- src/engine/__tests__/simulation.test.ts | 72 +++++ src/engine/gameplay/CombatLogic.ts | 53 ++++ .../gameplay/__tests__/CombatLogic.test.ts | 125 ++++++++ src/engine/world/generator.ts | 2 +- src/engine/world/world-logic.ts | 6 +- src/rendering/DungeonRenderer.ts | 78 +++++ src/scenes/GameScene.ts | 294 ++++++++++++------ src/scenes/__tests__/GameScene.test.ts | 8 +- src/ui/components/InventoryOverlay.ts | 23 +- src/ui/components/QuickSlotComponent.ts | 31 +- 18 files changed, 659 insertions(+), 155 deletions(-) create mode 100644 public/assets/sprites/items/items.png create mode 100644 src/engine/gameplay/CombatLogic.ts create mode 100644 src/engine/gameplay/__tests__/CombatLogic.test.ts diff --git a/public/assets/sprites/items/items.png b/public/assets/sprites/items/items.png new file mode 100644 index 0000000000000000000000000000000000000000..e0eaa3c53ebe1ca7dc2ae340b642ded5c8791f8c GIT binary patch literal 16942 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 { 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;