Compare commits

...

8 Commits

Author SHA1 Message Date
Peter Stockings
75df62db66 Add test coverage for TargetingSystem 2026-01-20 23:22:33 +11:00
Peter Stockings
59a84b97e0 Fix broken GameScene test 2026-01-20 23:07:31 +11:00
Peter Stockings
327b6aa0eb Change targetting line to dashed 2026-01-20 23:05:18 +11:00
Peter Stockings
1a91aa5274 Change crosshair targeting sprite 2026-01-20 22:56:16 +11:00
Peter Stockings
d4f763d1d0 Add ammo counter for ranged items in quickslot 2026-01-20 21:35:34 +11:00
Peter Stockings
bac2c130aa Add gun to inventory that fires bullets 2026-01-20 21:31:21 +11:00
Peter Stockings
1713ba76de Add in weapons (guns + cross hair) sprites 2026-01-20 18:20:03 +11:00
Peter Stockings
0d00e76d6b Fix broken test 2026-01-20 18:19:14 +11:00
19 changed files with 802 additions and 111 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,26 @@
import { vi } from 'vitest';
// Stub global window for Phaser device detection
if (typeof window === 'undefined') {
(globalThis as any).window = {
location: { href: '', origin: '' },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
cordova: undefined,
navigator: { userAgent: 'node' }
};
}
if (typeof document === 'undefined') {
(globalThis as any).document = {
createElement: vi.fn(() => ({
getContext: vi.fn(),
style: {}
})),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}
if (typeof navigator === 'undefined') {
(globalThis as any).navigator = { userAgent: 'node' };
}

View File

@@ -125,7 +125,10 @@ export const GAME_CONFIG = {
// Targeting
targetingLineColor: 0xff0000,
targetingLineWidth: 2,
targetingLineAlpha: 0.7
targetingLineAlpha: 0.7,
targetingLineDash: 6,
targetingLineGap: 4,
targetingLineShorten: 8
},
gameplay: {
@@ -140,6 +143,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: "items", path: "assets/sprites/items/items.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
{ key: "weapons", path: "assets/sprites/items/weapons.png", frameConfig: { frameWidth: 24, frameHeight: 24 } }
],
images: [
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },

View File

@@ -15,6 +15,7 @@ export const ITEMS: Record<string, Item> = {
id: "iron_sword",
name: "Iron Sword",
type: "Weapon",
weaponType: "melee",
textureKey: "items",
spriteIndex: 2,
stats: {
@@ -40,6 +41,35 @@ export const ITEMS: Record<string, Item> = {
stats: {
attack: 4
},
throwable: true
throwable: true,
stackable: true,
quantity: 1
},
"pistol": {
id: "pistol",
name: "Pistol",
type: "Weapon",
weaponType: "ranged",
textureKey: "weapons",
spriteIndex: 1,
stats: {
attack: 10,
range: 8,
magazineSize: 6,
currentAmmo: 6,
ammoType: "9mm",
projectileSpeed: 15,
fireSound: "shoot"
}
},
"ammo_9mm": {
id: "ammo_9mm",
name: "9mm Ammo",
type: "Ammo",
ammoType: "9mm",
textureKey: "weapons",
spriteIndex: 23,
stackable: true,
quantity: 10 // Finds a pack of 10
}
};

8
src/core/config/ui.ts Normal file
View File

@@ -0,0 +1,8 @@
export const UI_CONFIG = {
targeting: {
crosshair: {
textureKey: "weapons",
frame: 35
}
}
};

View File

@@ -75,17 +75,69 @@ export type ItemType =
| "Ring"
| "Belt"
| "Currency"
| "Consumable";
| "Consumable"
| "Ammo";
export type Item = {
export interface BaseItem {
id: string;
name: string;
type: ItemType;
stats?: Partial<Stats>;
textureKey: string;
spriteIndex: number;
quantity?: number;
stackable?: boolean;
}
export interface MeleeWeaponItem extends BaseItem {
type: "Weapon";
weaponType: "melee";
stats: {
attack: number;
};
}
export interface RangedWeaponItem extends BaseItem {
type: "Weapon";
weaponType: "ranged";
stats: {
attack: number;
range: number;
magazineSize: number;
currentAmmo: number;
ammoType: string;
projectileSpeed: number;
fireSound?: string;
};
}
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem;
export interface ArmourItem extends BaseItem {
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
stats: {
defense: number;
};
}
export interface ConsumableItem extends BaseItem {
type: "Consumable";
stats?: {
hp?: number;
attack?: number;
};
throwable?: boolean;
};
}
export interface AmmoItem extends BaseItem {
type: "Ammo";
ammoType: string;
}
export interface MiscItem extends BaseItem {
type: "Currency" | "Ring" | "Amulet" | "Belt" | "Offhand";
stats?: Partial<Stats>;
}
export type Item = WeaponItem | ArmourItem | ConsumableItem | AmmoItem | MiscItem;
export type Equipment = {
mainHand?: Item;

View File

@@ -59,7 +59,7 @@ describe('CombatLogic', () => {
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
expect(result.hitActorId).toBeUndefined();
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ItemManager } from "../../../scenes/systems/ItemManager";
import { EntityManager } from "../../EntityManager";
import type { World, CombatantActor, RangedWeaponItem, AmmoItem } from "../../../core/types";
import { ITEMS } from "../../../core/config/Items";
// Mock World and EntityManager
const mockWorld: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors: new Map(),
exit: { x: 9, y: 9 }
};
describe("Fireable Weapons & Ammo System", () => {
let entityManager: EntityManager;
let itemManager: ItemManager;
let player: CombatantActor;
beforeEach(() => {
entityManager = new EntityManager(mockWorld);
itemManager = new ItemManager(mockWorld, entityManager);
player = {
id: 1,
pos: { x: 0, y: 0 },
category: "combatant",
type: "player",
isPlayer: true,
speed: 1,
energy: 0,
stats: {
maxHp: 100, hp: 100,
maxMana: 50, mana: 50,
attack: 1, defense: 0,
level: 1, exp: 0, expToNextLevel: 100,
critChance: 0, critMultiplier: 0, accuracy: 0, lifesteal: 0,
evasion: 0, blockChance: 0, luck: 0,
statPoints: 0, skillPoints: 0, strength: 0, dexterity: 0, intelligence: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] },
equipment: {}
};
mockWorld.actors.clear();
mockWorld.actors.set(player.id, player);
});
it("should stack ammo correctly", () => {
// Spawn Ammo pack 1
const ammo1 = { ...ITEMS["ammo_9mm"] } as AmmoItem;
ammo1.quantity = 10;
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
// Pickup
itemManager.tryPickup(player);
expect(player.inventory!.items.length).toBe(1);
expect(player.inventory!.items[0].quantity).toBe(10);
// Spawn Ammo pack 2
const ammo2 = { ...ITEMS["ammo_9mm"] } as AmmoItem;
ammo2.quantity = 5;
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
// Pickup (should merge)
itemManager.tryPickup(player);
expect(player.inventory!.items.length).toBe(1); // Still 1 stack
expect(player.inventory!.items[0].quantity).toBe(15);
});
it("should consume ammo from weapon when fired", () => {
// Manually Equip Pistol
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
// Deep clone stats for test isolation
pistol.stats = { ...pistol.stats };
player.inventory!.items.push(pistol);
// Sanity Check
expect(pistol.stats.currentAmmo).toBe(6);
expect(pistol.stats.magazineSize).toBe(6);
// Simulate Firing (logic mimic from GameScene)
if (pistol.stats.currentAmmo > 0) {
pistol.stats.currentAmmo--;
}
expect(pistol.stats.currentAmmo).toBe(5);
});
it("should reload weapon using inventory ammo", () => {
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
pistol.stats = { ...pistol.stats };
pistol.stats.currentAmmo = 0; // Empty
player.inventory!.items.push(pistol);
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem;
ammo.quantity = 10;
player.inventory!.items.push(ammo);
// Logic mimic from GameScene
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity); // 6
pistol.stats.currentAmmo += toTake;
ammo.quantity -= toTake;
expect(pistol.stats.currentAmmo).toBe(6);
expect(ammo.quantity).toBe(4);
});
it("should handle partial reload if not enough ammo", () => {
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
pistol.stats = { ...pistol.stats };
pistol.stats.currentAmmo = 0;
player.inventory!.items.push(pistol);
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem;
ammo.quantity = 3; // Only 3 bullets
player.inventory!.items.push(ammo);
// Logic mimic
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity); // 3
pistol.stats.currentAmmo += toTake;
ammo.quantity -= toTake;
expect(pistol.stats.currentAmmo).toBe(3);
expect(ammo.quantity).toBe(0);
});
it("should deep clone stats on spawn so pistols remain independent", () => {
const pistolDef = ITEMS["pistol"] as RangedWeaponItem;
// Spawn 1
itemManager.spawnItem(pistolDef, {x:0, y:0});
const picked1 = itemManager.tryPickup(player)! as RangedWeaponItem;
// Spawn 2
itemManager.spawnItem(pistolDef, {x:0, y:0});
const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem;
expect(picked1).not.toBe(picked2);
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
// Modifying one should not affect other
picked1.stats.currentAmmo = 0;
expect(picked2.stats.currentAmmo).toBe(6);
});
});

View File

@@ -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"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"]] : [])
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["pistol"]] : [])
]
},
energy: 0

View File

@@ -315,6 +315,10 @@ export class DungeonRenderer {
this.fxRenderer.showAlert(x, y);
}
showFloatingText(x: number, y: number, message: string, color: string) {
this.fxRenderer.showFloatingText(x, y, message, color);
}
showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) {
// World coords
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;

View File

@@ -11,6 +11,28 @@ export class FxRenderer {
this.scene = scene;
}
showFloatingText(x: number, y: number, message: string, color: string) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, message, {
fontSize: "14px",
color: color,
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 30,
alpha: 0,
duration: 1000,
ease: "Power1",
onComplete: () => text.destroy()
});
}
clearCorpses() {
for (const sprite of this.corpseSprites) {
sprite.destroy();

View File

@@ -68,8 +68,7 @@ export class GameScene extends Phaser.Scene {
this.dungeonRenderer = new DungeonRenderer(this);
this.cameraController = new CameraController(this.cameras.main);
this.itemManager = new ItemManager(this.world, this.entityManager);
const targetingGraphics = this.add.graphics().setDepth(2000);
this.targetingSystem = new TargetingSystem(targetingGraphics);
this.targetingSystem = new TargetingSystem(this);
// Launch UI Scene
this.scene.launch("GameUI");
@@ -174,6 +173,62 @@ export class GameScene extends Phaser.Scene {
if (itemIdx === -1) return;
const item = player.inventory.items[itemIdx];
// Ranged Weapon Logic
if (item.type === "Weapon" && item.weaponType === "ranged") {
// Check Ammo
if (item.stats.currentAmmo <= 0) {
// Try Reload
const ammoId = `ammo_${item.stats.ammoType}`;
const ammoItem = player.inventory.items.find(it => it.id === ammoId); // Simple check
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
const needed = item.stats.magazineSize - item.stats.currentAmmo;
const toTake = Math.min(needed, ammoItem.quantity);
item.stats.currentAmmo += toTake;
ammoItem.quantity -= toTake;
if (ammoItem.quantity <= 0) {
player.inventory.items = player.inventory.items.filter(it => it !== ammoItem);
}
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloaded!", "#00ff00");
console.log("Reloaded. Ammo:", item.stats.currentAmmo);
this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
} else {
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No Ammo!", "#ff0000");
console.log("No ammo found for", item.name);
}
return;
}
// Has ammo, start targeting
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
// Already targeting - execute shoot
if (this.targetingSystem.cursorPos) {
this.executeThrow();
}
return;
}
const { x: tx, y: ty } = this.getPointerTilePos(this.input.activePointer);
this.targetingSystem.startTargeting(
item.id,
player.pos,
this.world,
this.entityManager,
this.playerId,
this.dungeonRenderer.seenArray,
this.world.width,
{ x: tx, y: ty }
);
this.emitUIUpdate();
return;
}
const result = this.itemManager.handleUse(data.itemId, player);
if (result.success && result.consumed) {
@@ -187,17 +242,22 @@ export class GameScene extends Phaser.Scene {
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
// Already targeting - execute throw
if (this.targetingSystem.cursorPos) {
this.executeThrow(this.targetingSystem.cursorPos.x, this.targetingSystem.cursorPos.y);
this.executeThrow();
}
return;
}
const { x: tx, y: ty } = this.getPointerTilePos(this.input.activePointer);
this.targetingSystem.startTargeting(
item.id,
player.pos,
this.world,
this.entityManager,
this.playerId,
this.dungeonRenderer.seenArray,
this.world.width
this.world.width,
{ x: tx, y: ty }
);
this.emitUIUpdate();
}
@@ -267,7 +327,7 @@ export class GameScene extends Phaser.Scene {
// Only Left Click throws
if (p.button === 0) {
if (this.targetingSystem.cursorPos) {
this.executeThrow(this.targetingSystem.cursorPos.x, this.targetingSystem.cursorPos.y);
this.executeThrow();
}
}
return;
@@ -559,16 +619,18 @@ export class GameScene extends Phaser.Scene {
private executeThrow(_targetX: number, _targetY: number) {
private executeThrow() {
const success = this.targetingSystem.executeThrow(
this.world,
this.playerId,
this.entityManager,
(blockedPos, hitActorId, item) => {
// Damage Logic
if (hitActorId !== undefined) {
const victim = this.world.actors.get(hitActorId) as CombatantActor;
if (victim) {
const dmg = item.stats?.attack ?? 1;
const stats = 'stats' in item ? item.stats : undefined;
const dmg = (stats && 'attack' in stats) ? (stats.attack ?? 1) : 1;
victim.stats.hp -= dmg;
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
this.dungeonRenderer.shakeCamera();
@@ -576,13 +638,30 @@ export class GameScene extends Phaser.Scene {
}
const player = this.world.actors.get(this.playerId) as CombatantActor;
// Projectile Visuals
let projectileId = item.id;
if (item.type === "Weapon" && item.weaponType === "ranged") {
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
// Consume Ammo
if (item.stats.currentAmmo > 0) {
item.stats.currentAmmo--;
}
}
this.dungeonRenderer.showProjectile(
player.pos,
blockedPos,
item.id,
projectileId,
() => {
// Drop the actual item at the landing spot
this.itemManager.spawnItem(item, blockedPos);
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
const shouldDrop = item.type !== "Weapon";
if (shouldDrop) {
// Drop the actual item at the landing spot
this.itemManager.spawnItem(item, blockedPos);
}
// Trigger destruction/interaction
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
@@ -590,7 +669,7 @@ export class GameScene extends Phaser.Scene {
}
this.targetingSystem.cancel();
this.commitPlayerAction({ type: "throw" });
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
this.emitUIUpdate();
}
);
@@ -602,4 +681,12 @@ export class GameScene extends Phaser.Scene {
}
}
private getPointerTilePos(pointer: Phaser.Input.Pointer): { x: number, y: number } {
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
return {
x: Math.floor(worldPoint.x / TILE_SIZE),
y: Math.floor(worldPoint.y / TILE_SIZE)
};
}
}

View File

@@ -1,9 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GameScene } from '../GameScene';
import * as simulation from '../../engine/simulation/simulation';
import * as generator from '../../engine/world/generator';
// Mock Phaser
// Mock Phaser BEFORE any project imports
vi.mock('phaser', () => {
const mockEventEmitter = {
on: vi.fn(),
@@ -11,71 +8,97 @@ vi.mock('phaser', () => {
off: vi.fn(),
};
class MockScene {
events = mockEventEmitter;
input = {
keyboard: {
createCursorKeys: vi.fn(() => ({})),
on: vi.fn(),
},
on: vi.fn(),
activePointer: {
worldX: 0,
worldY: 0,
x: 0,
y: 0
},
mouse: {
disableContextMenu: vi.fn()
}
};
cameras = {
main: {
setZoom: vi.fn(),
setBounds: vi.fn(),
centerOn: vi.fn(),
fadeIn: vi.fn(),
getWorldPoint: vi.fn((x, y) => ({ x, y }))
},
};
scene = {
launch: vi.fn(),
get: vi.fn(),
};
add = {
graphics: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn(),
lineStyle: vi.fn(),
lineBetween: vi.fn(),
strokeRect: vi.fn(),
})),
sprite: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
})),
text: vi.fn(() => ({})),
rectangle: vi.fn(() => ({})),
container: vi.fn(() => ({})),
};
load = {
spritesheet: vi.fn(),
};
anims = {
create: vi.fn(),
exists: vi.fn(() => true),
generateFrameNumbers: vi.fn(),
};
}
return {
default: {
Scene: class {
events = mockEventEmitter;
input = {
keyboard: {
createCursorKeys: vi.fn(() => ({})),
on: vi.fn(),
},
on: vi.fn(),
};
cameras = {
main: {
setZoom: vi.fn(),
setBounds: vi.fn(),
centerOn: vi.fn(),
fadeIn: vi.fn(),
},
};
scene = {
launch: vi.fn(),
get: vi.fn(),
};
add = {
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(() => ({})),
};
load = {
spritesheet: vi.fn(),
};
anims = {
create: vi.fn(),
exists: vi.fn(() => true),
generateFrameNumbers: vi.fn(),
};
},
Scene: MockScene,
Input: {
Keyboard: {
JustDown: vi.fn(),
},
},
},
}
};
});
import { GameScene } from '../GameScene';
import * as simulation from '../../engine/simulation/simulation';
import * as generator from '../../engine/world/generator';
// Mock other modules
vi.mock('../../rendering/DungeonRenderer', () => ({
DungeonRenderer: vi.fn().mockImplementation(function() {
return {
initializeFloor: vi.fn(),
computeFov: vi.fn(),
render: vi.fn(),
showDamage: vi.fn(),
spawnCorpse: vi.fn(),
showWait: vi.fn(),
isMinimapVisible: vi.fn(() => false),
toggleMinimap: vi.fn(),
updateTile: vi.fn(),
showProjectile: vi.fn(),
showHeal: vi.fn(),
shakeCamera: vi.fn(),
};
}),
}));
@@ -87,7 +110,6 @@ vi.mock('../../engine/simulation/simulation', () => ({
vi.mock('../../engine/world/generator', () => ({
generateWorld: vi.fn(),
}));
vi.mock('../../engine/world/world-logic', () => ({
@@ -95,6 +117,7 @@ vi.mock('../../engine/world/world-logic', () => ({
isBlocked: vi.fn(() => false),
isPlayerOnExit: vi.fn(() => false),
idx: vi.fn((w, x, y) => y * w.width + x),
tryDestructTile: vi.fn(() => false),
}));
describe('GameScene', () => {
@@ -142,7 +165,6 @@ describe('GameScene', () => {
mockWorld.actors.set(1, mockPlayer);
(generator.generateWorld as any).mockReturnValue({
world: mockWorld,
playerId: 1,
});
@@ -157,9 +179,7 @@ describe('GameScene', () => {
});
it('should trigger death screen when player is killed', () => {
// 1. Mock simulation so that after action, player is gone from world
(simulation.applyAction as any).mockImplementation((world: any) => {
// simulate player being killed
world.actors.delete(1);
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
});
@@ -169,19 +189,8 @@ describe('GameScene', () => {
events: [],
});
// 2. Commit an action
// We need to access private method or trigger it via public interface
// commitPlayerAction is private, let's cast to any to call it
(scene as any).commitPlayerAction({ type: 'wait' });
// 3. Verify showDeathScreen was called on the mock UI
expect(mockUI.showDeathScreen).toHaveBeenCalled();
// Verify it was called with some stats
const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
expect(callArgs).toHaveProperty('floor');
expect(callArgs).toHaveProperty('gold');
expect(callArgs).toHaveProperty('stats');
});
});

View File

@@ -28,7 +28,7 @@ export class EventBridge {
commitActionFn: (action: any) => void,
emitUIUpdateFn: () => void,
restartGameFn: () => void,
executeThrowFn: (x: number, y: number) => void
executeThrowFn: () => void
): void {
// Menu state listeners (from UI)
this.scene.events.on("menu-toggled", (isOpen: boolean) => {
@@ -115,7 +115,7 @@ export class EventBridge {
if (targetingSystem.isActive && targetingSystem.itemId === item.id) {
// Already targeting - execute throw
if (targetingSystem.cursorPos) {
executeThrowFn(targetingSystem.cursorPos.x, targetingSystem.cursorPos.y);
executeThrowFn();
}
return;
}
@@ -124,6 +124,8 @@ export class EventBridge {
item.id,
player.pos,
(this.scene as any).world,
(this.scene as any).entityManager,
(this.scene as any).playerId,
dungeonRenderer.seenArray,
(this.scene as any).world.width
);

View File

@@ -37,12 +37,18 @@ export class ItemManager {
spawnItem(item: Item, pos: Vec2): void {
if (!this.world || !this.entityManager) return;
// Deep clone item (crucial for items with mutable stats like ammo)
const clonedItem = { ...item } as Item;
if ('stats' in clonedItem && clonedItem.stats) {
(clonedItem as any).stats = { ...clonedItem.stats };
}
const id = this.entityManager.getNextId();
const drop: ItemDropActor = {
id,
pos: { x: pos.x, y: pos.y },
category: "item_drop",
item: { ...item } // Clone item
item: clonedItem
};
this.entityManager.addActor(drop);
@@ -61,7 +67,20 @@ export class ItemManager {
if (itemActor) {
const item = itemActor.item;
// Stacking Logic
if (item.stackable) {
const existingItem = player.inventory.items.find(it => it.id === item.id);
if (existingItem) {
existingItem.quantity = (existingItem.quantity || 1) + (item.quantity || 1);
console.log(`Stacked ${item.name}. New quantity: ${existingItem.quantity}`);
this.entityManager.removeActor(itemActor.id);
return existingItem;
}
}
// Add to inventory
item.quantity = item.quantity || 1;
player.inventory.items.push(item);
// Remove from world
@@ -91,7 +110,7 @@ export class ItemManager {
const item = player.inventory.items[itemIdx];
// Check if item is a healing consumable
if (item.stats && item.stats.hp && item.stats.hp > 0) {
if (item.type === "Consumable" && item.stats?.hp) {
const healAmount = item.stats.hp;
if (player.stats.hp >= player.stats.maxHp) {
@@ -100,8 +119,12 @@ export class ItemManager {
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
// Remove item after use
player.inventory.items.splice(itemIdx, 1);
// Consume item (check stack)
if (item.quantity && item.quantity > 1) {
item.quantity--;
} else {
player.inventory.items.splice(itemIdx, 1);
}
return {
success: true,
@@ -110,8 +133,8 @@ export class ItemManager {
};
}
// Throwable items are handled by TargetingSystem, not here
if (item.throwable) {
// Throwable items
if (item.type === "Consumable" && item.throwable) {
return {
success: true,
consumed: false,

View File

@@ -2,6 +2,7 @@ import Phaser from "phaser";
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
import { TILE_SIZE } from "../../core/constants";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { UI_CONFIG } from "../../core/config/ui";
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
import type { EntityManager } from "../../engine/EntityManager";
@@ -11,12 +12,25 @@ import type { EntityManager } from "../../engine/EntityManager";
*/
export class TargetingSystem {
private graphics: Phaser.GameObjects.Graphics;
private crosshairSprite: Phaser.GameObjects.Sprite;
private active: boolean = false;
private targetingItemId: string | null = null;
private cursor: Vec2 | null = null;
// Context for predictive visual
private world: World | null = null;
private entityManager: EntityManager | null = null;
private playerId: EntityId | null = null;
constructor(graphics: Phaser.GameObjects.Graphics) {
this.graphics = graphics;
constructor(scene: Phaser.Scene) {
this.graphics = scene.add.graphics();
this.graphics.setDepth(2000); // High depth to draw over world
// Create crosshair sprite but hide it initially
this.crosshairSprite = scene.add.sprite(0, 0, UI_CONFIG.targeting.crosshair.textureKey, UI_CONFIG.targeting.crosshair.frame);
this.crosshairSprite.setDepth(2001); // On top of line
this.crosshairSprite.setVisible(false);
this.crosshairSprite.setAlpha(0.8);
}
/**
@@ -26,10 +40,16 @@ export class TargetingSystem {
itemId: string,
playerPos: Vec2,
world: World,
entityManager: EntityManager,
playerId: EntityId,
seenArray: Uint8Array,
worldWidth: number
worldWidth: number,
initialTargetPos?: Vec2
): void {
this.targetingItemId = itemId;
this.world = world;
this.entityManager = entityManager;
this.playerId = playerId;
this.active = true;
// Auto-target closest visible enemy
@@ -37,11 +57,14 @@ export class TargetingSystem {
if (closest) {
this.cursor = closest;
} else if (initialTargetPos) {
this.cursor = { ...initialTargetPos };
} else {
this.cursor = null;
// Default to existing cursor or player pos
this.cursor = this.cursor ? { ...this.cursor } : { ...playerPos };
}
this.drawLine(playerPos);
this.updateVisuals(playerPos);
console.log("Targeting Mode: ON");
}
@@ -52,7 +75,7 @@ export class TargetingSystem {
if (!this.active) return;
this.cursor = { x: worldPos.x, y: worldPos.y };
this.drawLine(playerPos);
this.updateVisuals(playerPos);
}
/**
@@ -79,8 +102,16 @@ export class TargetingSystem {
}
const item = player.inventory.items[itemIdx];
// Remove item from inventory before throw
player.inventory.items.splice(itemIdx, 1);
// Only remove if it's a consumable throwable
if (item.type === "Consumable" && item.throwable) {
// Handle stack decrement if applicable, or remove
if (item.quantity && item.quantity > 1) {
item.quantity--;
} else {
player.inventory.items.splice(itemIdx, 1);
}
}
const start = player.pos;
const end = { x: this.cursor.x, y: this.cursor.y };
@@ -101,7 +132,11 @@ export class TargetingSystem {
this.active = false;
this.targetingItemId = null;
this.cursor = null;
this.world = null;
this.entityManager = null;
this.playerId = null;
this.graphics.clear();
this.crosshairSprite.setVisible(false);
console.log("Targeting cancelled");
}
@@ -127,34 +162,87 @@ export class TargetingSystem {
}
/**
* Draw targeting line from player to cursor
* Draw targeting line and update crosshair
*/
private drawLine(playerPos: Vec2): void {
private updateVisuals(playerPos: Vec2): void {
if (!this.cursor) {
this.graphics.clear();
this.crosshairSprite.setVisible(false);
return;
}
this.graphics.clear();
this.crosshairSprite.setVisible(true);
const startX = playerPos.x * TILE_SIZE + TILE_SIZE / 2;
const startY = playerPos.y * TILE_SIZE + TILE_SIZE / 2;
const endX = this.cursor.x * TILE_SIZE + TILE_SIZE / 2;
const endY = this.cursor.y * TILE_SIZE + TILE_SIZE / 2;
const aimEndX = this.cursor.x * TILE_SIZE + TILE_SIZE / 2;
const aimEndY = this.cursor.y * TILE_SIZE + TILE_SIZE / 2;
// Predictive impact point
let finalEndX = aimEndX;
let finalEndY = aimEndY;
if (this.world && this.entityManager && this.playerId !== null) {
const result = traceProjectile(this.world, playerPos, this.cursor, this.entityManager, this.playerId);
const bPos = result.blockedPos;
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
}
// Update crosshair position to ACTUAL impact point
this.crosshairSprite.setPosition(finalEndX, finalEndY);
// Draw Main Line (dashed to impact)
this.graphics.lineStyle(
GAME_CONFIG.ui.targetingLineWidth,
GAME_CONFIG.ui.targetingLineColor,
GAME_CONFIG.ui.targetingLineAlpha
);
this.graphics.lineBetween(startX, startY, endX, endY);
this.graphics.strokeRect(
this.cursor.x * TILE_SIZE,
this.cursor.y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE
);
const dx = finalEndX - startX;
const dy = finalEndY - startY;
const dist = Math.sqrt(dx * dx + dy * dy);
const shortenAmt = GAME_CONFIG.ui.targetingLineShorten;
if (dist > shortenAmt) {
const ratio = (dist - shortenAmt) / dist;
const shortenedEndX = startX + dx * ratio;
const shortenedEndY = startY + dy * ratio;
this.drawDashedLine(startX, startY, shortenedEndX, shortenedEndY);
}
}
/**
* Manual dashed line implementation for Phaser Graphics
*/
private drawDashedLine(x1: number, y1: number, x2: number, y2: number): void {
const dashLen = GAME_CONFIG.ui.targetingLineDash;
const gapLen = GAME_CONFIG.ui.targetingLineGap;
const dx = x2 - x1;
const dy = y2 - y1;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const angle = Math.atan2(dy, dx);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
let currentDist = 0;
while (currentDist < distance) {
const len = Math.min(dashLen, distance - currentDist);
const sx = x1 + currentDist * cos;
const sy = y1 + currentDist * sin;
const ex = sx + len * cos;
const ey = sy + len * sin;
this.graphics.lineBetween(sx, sy, ex, ey);
currentDist += dashLen + gapLen;
}
}
}

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Phaser
vi.mock('phaser', () => {
const mockGraphics = {
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn().mockReturnThis(),
lineStyle: vi.fn().mockReturnThis(),
lineBetween: vi.fn().mockReturnThis(),
};
const mockSprite = {
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
};
return {
default: {
Scene: class {
add = {
graphics: vi.fn(() => mockGraphics),
sprite: vi.fn(() => mockSprite),
};
}
}
};
});
// Mock CombatLogic
vi.mock('../../../engine/gameplay/CombatLogic', () => ({
traceProjectile: vi.fn(),
getClosestVisibleEnemy: vi.fn(),
}));
import { TargetingSystem } from '../TargetingSystem';
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
import { TILE_SIZE } from '../../../core/constants';
describe('TargetingSystem', () => {
let targetingSystem: TargetingSystem;
let mockWorld: any;
let mockEntityManager: any;
let mockScene: any;
let mockGraphics: any;
let mockSprite: any;
beforeEach(() => {
vi.clearAllMocks();
mockGraphics = {
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn().mockReturnThis(),
lineStyle: vi.fn().mockReturnThis(),
lineBetween: vi.fn().mockReturnThis(),
};
mockSprite = {
setDepth: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setAlpha: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
};
mockScene = {
add: {
graphics: vi.fn(() => mockGraphics),
sprite: vi.fn(() => mockSprite),
},
} as any;
targetingSystem = new TargetingSystem(mockScene);
mockWorld = { width: 10, height: 10 };
mockEntityManager = {};
// Default return for traceProjectile
(traceProjectile as any).mockReturnValue({
blockedPos: { x: 0, y: 0 },
hitActorId: undefined,
path: []
});
});
it('should initialize with graphics and crosshair sprite hidden', () => {
expect(mockScene.add.graphics).toHaveBeenCalled();
expect(mockScene.add.sprite).toHaveBeenCalled();
expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
});
it('should start targeting and auto-select closest enemy', () => {
const playerPos = { x: 1, y: 1 };
const enemyPos = { x: 3, y: 3 };
(getClosestVisibleEnemy as any).mockReturnValue(enemyPos);
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
mockEntityManager!,
1 as any,
new Uint8Array(100),
10
);
expect(targetingSystem.isActive).toBe(true);
expect(targetingSystem.itemId).toBe('item-1');
expect(targetingSystem.cursorPos).toEqual(enemyPos);
expect(mockSprite.setVisible).toHaveBeenCalledWith(true);
});
it('should fallback to mouse position if no enemy found', () => {
const playerPos = { x: 1, y: 1 };
const mousePos = { x: 5, y: 5 };
(getClosestVisibleEnemy as any).mockReturnValue(null);
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
mockEntityManager!,
1 as any,
new Uint8Array(100),
10,
mousePos
);
expect(targetingSystem.cursorPos).toEqual(mousePos);
});
it('should update visuals with predictive impact point', () => {
const playerPos = { x: 1, y: 1 };
const targetPos = { x: 5, y: 1 };
const blockedPos = { x: 3, y: 1 }; // Wall at 3,1
(traceProjectile as any).mockReturnValue({
blockedPos: blockedPos,
hitActorId: undefined,
path: []
});
// Start targeting
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
mockEntityManager!,
1 as any,
new Uint8Array(100),
10,
targetPos
);
// The crosshair should be at blockedPos, not targetPos
const expectedX = blockedPos.x * TILE_SIZE + TILE_SIZE / 2;
const expectedY = blockedPos.y * TILE_SIZE + TILE_SIZE / 2;
expect(mockSprite.setPosition).toHaveBeenCalledWith(expectedX, expectedY);
// Verify dashed line was drawn (multiple lineBetween calls)
expect(mockGraphics.lineBetween).toHaveBeenCalled();
expect(mockGraphics.lineBetween.mock.calls.length).toBeGreaterThan(1);
});
it('should clear visuals on cancel', () => {
targetingSystem.cancel();
expect(targetingSystem.isActive).toBe(false);
expect(mockGraphics.clear).toHaveBeenCalled();
expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
});
});

View File

@@ -6,7 +6,7 @@ export class QuickSlotComponent {
private container!: Phaser.GameObjects.Container;
private slots: Phaser.GameObjects.Container[] = [];
private itemMap: (Item | null)[] = new Array(10).fill(null);
private assignedIds: string[] = ["health_potion", "throwing_dagger", ...new Array(8).fill("")];
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", ...new Array(7).fill("")];
constructor(scene: Phaser.Scene) {
this.scene = scene;
@@ -125,6 +125,19 @@ export class QuickSlotComponent {
}).setOrigin(1, 1);
slot.add(countText);
}
// Add Ammo Counter for Ranged Weapons (Top-Right)
if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
const ammoText = `${foundItem.stats.currentAmmo}/${foundItem.stats.magazineSize}`;
const ammoDisplay = this.scene.add.text(slotSize - 2, 2, ammoText, {
fontSize: "10px",
color: "#00FF00", // Green text
fontStyle: "bold",
stroke: "#000000",
strokeThickness: 2
}).setOrigin(1, 0); // Top-right anchor
slot.add(ammoDisplay);
}
}
} else {
this.itemMap[i] = null;

View File

@@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ["src/__tests__/test-setup.ts"],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],