diff --git a/src/core/types.ts b/src/core/types.ts index f782659..b53258f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -155,3 +155,12 @@ export type World = { actors: Map; exit: Vec2; }; + +export interface UIUpdatePayload { + world: World; + playerId: EntityId; + floorIndex: number; + uiState: { + targetingItemId: string | null; + }; +} diff --git a/src/engine/__tests__/combat_logic.test.ts b/src/engine/__tests__/combat_logic.test.ts new file mode 100644 index 0000000..534110d --- /dev/null +++ b/src/engine/__tests__/combat_logic.test.ts @@ -0,0 +1,132 @@ + +import { describe, it, expect } from "vitest"; +import { getClosestVisibleEnemy } from "../gameplay/CombatLogic"; +import type { World, CombatantActor } from "../../core/types"; + +describe("CombatLogic - getClosestVisibleEnemy", () => { + + // Helper to create valid default stats for testing + const createMockStats = () => ({ + hp: 10, maxHp: 10, attack: 1, defense: 0, + accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 0, + blockChance: 0, lifesteal: 0, mana: 0, maxMana: 0, + level: 1, exp: 0, expToNextLevel: 100, luck: 0, + statPoints: 0, skillPoints: 0, + strength: 10, dexterity: 10, intelligence: 10, + passiveNodes: [] + }); + + it("should return null if no enemies are visible", () => { + const world: World = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors: new Map(), + exit: { x: 9, y: 9 } + }; + + const player: CombatantActor = { + id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true, + stats: createMockStats(), + inventory: { gold: 0, items: [] }, equipment: {}, + speed: 1, energy: 0 + }; + world.actors.set(0, player); + + const enemy: CombatantActor = { + id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false, + stats: createMockStats(), + speed: 1, energy: 0 + }; + world.actors.set(1, enemy); + + // Mock seenArray where nothing is seen + const seenArray = new Uint8Array(100).fill(0); + + const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10); + expect(result).toBeNull(); + }); + + it("should return the closest visible enemy", () => { + const world: World = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors: new Map(), + exit: { x: 9, y: 9 } + }; + + const player: CombatantActor = { + id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true, + stats: createMockStats(), + inventory: { gold: 0, items: [] }, equipment: {}, + speed: 1, energy: 0 + }; + world.actors.set(0, player); + + // Enemy 1: Close (distance sqrt(2) ~= 1.41) + const enemy1: CombatantActor = { + id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false, + stats: createMockStats(), + speed: 1, energy: 0 + }; + world.actors.set(1, enemy1); + + // Enemy 2: Farther (distance sqrt(8) ~= 2.82) + const enemy2: CombatantActor = { + id: 2, category: "combatant", type: "rat", pos: { x: 7, y: 7 }, isPlayer: false, + stats: createMockStats(), + speed: 1, energy: 0 + }; + world.actors.set(2, enemy2); + + // Mock seenArray where both are seen + const seenArray = new Uint8Array(100).fill(0); + seenArray[6 * 10 + 6] = 1; // Enemy 1 visible + seenArray[7 * 10 + 7] = 1; // Enemy 2 visible + + const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10); + expect(result).toEqual({ x: 6, y: 6 }); + }); + + it("should ignore invisible closer enemies and select visible farther ones", () => { + const world: World = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors: new Map(), + exit: { x: 9, y: 9 } + }; + + const player: CombatantActor = { + id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true, + stats: createMockStats(), + inventory: { gold: 0, items: [] }, equipment: {}, + speed: 1, energy: 0 + }; + world.actors.set(0, player); + + // Enemy 1: Close but invisible + const enemy1: CombatantActor = { + id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false, + stats: createMockStats(), + speed: 1, energy: 0 + }; + world.actors.set(1, enemy1); + + // Enemy 2: Farther but visible + const enemy2: CombatantActor = { + id: 2, category: "combatant", type: "rat", pos: { x: 8, y: 5 }, isPlayer: false, + stats: createMockStats(), + speed: 1, energy: 0 + }; + world.actors.set(2, enemy2); + + // Mock seenArray where only Enemy 2 is seen + const seenArray = new Uint8Array(100).fill(0); + seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5) + + const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10); + expect(result).toEqual({ x: 8, y: 5 }); + }); +}); diff --git a/src/engine/gameplay/CombatLogic.ts b/src/engine/gameplay/CombatLogic.ts index 3d26770..e5edb51 100644 --- a/src/engine/gameplay/CombatLogic.ts +++ b/src/engine/gameplay/CombatLogic.ts @@ -51,3 +51,46 @@ export function traceProjectile( hitActorId }; } + +/** + * Finds the closest visible enemy to a given position. + */ +export function getClosestVisibleEnemy( + world: World, + origin: Vec2, + seenTiles: Set | boolean[] | Uint8Array, // Support various visibility structures + width?: number // Required if seenTiles is a flat array +): Vec2 | null { + let closestDistSq = Infinity; + let closestPos: Vec2 | null = null; + + // Helper to check visibility + const isVisible = (x: number, y: number) => { + if (Array.isArray(seenTiles) || seenTiles instanceof Uint8Array || seenTiles instanceof Int8Array) { + // Flat array + if (!width) return false; + return (seenTiles as any)[y * width + x]; + } else { + // Set + return (seenTiles as Set).has(`${x},${y}`); + } + }; + + for (const actor of world.actors.values()) { + if (actor.category !== "combatant" || actor.isPlayer) continue; + + // Check visibility + if (!isVisible(actor.pos.x, actor.pos.y)) continue; + + const dx = actor.pos.x - origin.x; + const dy = actor.pos.y - origin.y; + const distSq = dx*dx + dy*dy; + + if (distSq < closestDistSq) { + closestDistSq = distSq; + closestPos = { x: actor.pos.x, y: actor.pos.y }; + } + } + + return closestPos; +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 37e3618..28c31b0 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -8,14 +8,15 @@ import { type World, type CombatantActor, type Item, - type ItemDropActor + type ItemDropActor, + type UIUpdatePayload } from "../core/types"; import { TILE_SIZE } from "../core/constants"; 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 { traceProjectile, getClosestVisibleEnemy } from "../engine/gameplay/CombatLogic"; @@ -53,6 +54,7 @@ export class GameScene extends Phaser.Scene { // Targeting Mode private isTargeting = false; private targetingItem: string | null = null; + private targetCursor: { x: number, y: number } | null = null; private targetingGraphics!: Phaser.GameObjects.Graphics; private turnCount = 0; // Track turns for mana regen @@ -188,9 +190,38 @@ export class GameScene extends Phaser.Scene { this.emitUIUpdate(); } } else if (item.throwable) { + // Check if already targeting this item -> verify intent to throw + if (this.isTargeting && this.targetingItem === item.id) { + if (this.targetCursor) { + this.executeThrow(this.targetCursor.x, this.targetCursor.y); + } + return; + } + this.targetingItem = item.id; this.isTargeting = true; + + // Auto-target closest visible enemy + const closest = getClosestVisibleEnemy( + this.world, + player.pos, + this.dungeonRenderer.seenArray, + this.world.width + ); + + if (closest) { + this.targetCursor = closest; + } else { + // Default to player pos or null? + // If we default to mouse pos, we need current mouse pos. + // Let's default to null and wait for mouse move, OR default to player pos forward? + // Let's just default to null until mouse moves. + this.targetCursor = null; + } + + this.drawTargetingLine(); console.log("Targeting Mode: ON"); + this.emitUIUpdate(); } }); @@ -230,7 +261,11 @@ export class GameScene extends Phaser.Scene { this.input.on("pointermove", (p: Phaser.Input.Pointer) => { if (!p.isDown) { // Even if not down, we might need to update targeting line if (this.isTargeting) { - this.updateTargetingLine(p); + const tx = Math.floor(p.worldX / TILE_SIZE); + const ty = Math.floor(p.worldY / TILE_SIZE); + // Only update if changed to avoid jitter if needed, but simple assignment is fine + this.targetCursor = { x: tx, y: ty }; + this.drawTargetingLine(); } return; } @@ -255,7 +290,10 @@ export class GameScene extends Phaser.Scene { } if (this.isTargeting) { - this.updateTargetingLine(p); + const tx = Math.floor(p.worldX / TILE_SIZE); + const ty = Math.floor(p.worldY / TILE_SIZE); + this.targetCursor = { x: tx, y: ty }; + this.drawTargetingLine(); } }); @@ -265,9 +303,9 @@ export class GameScene extends Phaser.Scene { 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); + if (this.targetCursor) { + this.executeThrow(this.targetCursor.x, this.targetCursor.y); + } } return; } @@ -397,11 +435,15 @@ export class GameScene extends Phaser.Scene { } private emitUIUpdate() { - this.events.emit("update-ui", { + const payload: UIUpdatePayload = { world: this.world, playerId: this.playerId, - floorIndex: this.floorIndex - }); + floorIndex: this.floorIndex, + uiState: { + targetingItemId: this.targetingItem + } + }; + this.events.emit("update-ui", payload); } private commitPlayerAction(action: Action) { @@ -547,8 +589,12 @@ export class GameScene extends Phaser.Scene { ); } - private updateTargetingLine(p: Phaser.Input.Pointer) { - if (!this.world) return; + private drawTargetingLine() { + if (!this.world || !this.targetCursor) { + this.targetingGraphics.clear(); + return; + } + this.targetingGraphics.clear(); const player = this.world.actors.get(this.playerId) as CombatantActor; @@ -557,22 +603,22 @@ export class GameScene extends Phaser.Scene { 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; + const endX = this.targetCursor.x * TILE_SIZE + TILE_SIZE / 2; + const endY = this.targetCursor.y * TILE_SIZE + TILE_SIZE / 2; 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); + this.targetingGraphics.strokeRect(this.targetCursor.x * TILE_SIZE, this.targetCursor.y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } private cancelTargeting() { this.isTargeting = false; this.targetingItem = null; + this.targetCursor = null; this.targetingGraphics.clear(); console.log("Targeting cancelled"); + this.emitUIUpdate(); } private executeThrow(targetX: number, targetY: number) { diff --git a/src/ui/GameUI.ts b/src/ui/GameUI.ts index 031da59..c2a13be 100644 --- a/src/ui/GameUI.ts +++ b/src/ui/GameUI.ts @@ -1,5 +1,5 @@ import Phaser from "phaser"; -import { type World, type EntityId, type CombatantActor, type Stats } from "../core/types"; +import { type CombatantActor, type Stats, type UIUpdatePayload } from "../core/types"; import { HudComponent } from "./components/HudComponent"; import { MenuComponent } from "./components/MenuComponent"; import { InventoryOverlay } from "./components/InventoryOverlay"; @@ -42,8 +42,8 @@ export default class GameUI extends Phaser.Scene { // Listen for updates from GameScene - gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => { - this.updateUI(data.world, data.playerId, data.floorIndex); + gameScene.events.on("update-ui", (payload: UIUpdatePayload) => { + this.updateUI(payload); }); gameScene.events.on("toggle-menu", () => { @@ -96,13 +96,14 @@ export default class GameUI extends Phaser.Scene { this.death.show(data); } - private updateUI(world: World, playerId: EntityId, floorIndex: number) { + private updateUI(payload: UIUpdatePayload) { + const { world, playerId, floorIndex, uiState } = payload; const player = world.actors.get(playerId) as CombatantActor; if (!player) return; this.hud.update(player.stats, floorIndex); this.inventory.update(player); this.character.update(player); - this.quickSlots.update(player); + this.quickSlots.update(player, uiState.targetingItemId); } } diff --git a/src/ui/components/QuickSlotComponent.ts b/src/ui/components/QuickSlotComponent.ts index 47e8163..fc8bb81 100644 --- a/src/ui/components/QuickSlotComponent.ts +++ b/src/ui/components/QuickSlotComponent.ts @@ -53,13 +53,14 @@ export class QuickSlotComponent { this.scene.input.keyboard?.on("keydown-FOUR", () => this.activateSlot(3)); } - update(player: CombatantActor) { + update(player: CombatantActor, activeItemId?: string | null) { if (!player.inventory) return; // Update slots based on inventory availability for (let i = 0; i < 4; i++) { const desiredId = this.assignedIds[i]; const slot = this.slots[i]; + const bgGraphics = slot.list[0] as Phaser.GameObjects.Graphics; // Clear previous item icon if any (children > 2, since 0=bg, 1=text) if (slot.list.length > 2) { @@ -70,6 +71,20 @@ export class QuickSlotComponent { const foundItem = player.inventory.items.find(it => it.id === desiredId); this.itemMap[i] = foundItem || null; + const isActive = foundItem && foundItem.id === activeItemId; + + // Redraw background based on active state + bgGraphics.clear(); + bgGraphics.fillStyle(0x1a1a1a, 0.8); + bgGraphics.fillRect(0, 0, 40, 40); + + if (isActive) { + bgGraphics.lineStyle(2, 0xffff00); // Gold highlight + } else { + bgGraphics.lineStyle(1, 0x555555); // Default gray + } + bgGraphics.strokeRect(0, 0, 40, 40); + if (foundItem) { const texture = foundItem.textureKey ?? "items"; const sprite = this.scene.add.sprite(20, 20, texture, foundItem.spriteIndex); @@ -87,6 +102,12 @@ export class QuickSlotComponent { } } else { this.itemMap[i] = null; + // Reset bg + bgGraphics.clear(); + bgGraphics.fillStyle(0x1a1a1a, 0.8); + bgGraphics.fillRect(0, 0, 40, 40); + bgGraphics.lineStyle(1, 0x555555); + bgGraphics.strokeRect(0, 0, 40, 40); } } }