Compare commits
8 Commits
064952f254
...
75df62db66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75df62db66 | ||
|
|
59a84b97e0 | ||
|
|
327b6aa0eb | ||
|
|
1a91aa5274 | ||
|
|
d4f763d1d0 | ||
|
|
bac2c130aa | ||
|
|
1713ba76de | ||
|
|
0d00e76d6b |
BIN
public/assets/sprites/items/weapons.png
Normal file
BIN
public/assets/sprites/items/weapons.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
26
src/__tests__/test-setup.ts
Normal file
26
src/__tests__/test-setup.ts
Normal 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' };
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
8
src/core/config/ui.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const UI_CONFIG = {
|
||||
targeting: {
|
||||
crosshair: {
|
||||
textureKey: "weapons",
|
||||
frame: 35
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
152
src/engine/gameplay/__tests__/FireableWeapons.test.ts
Normal file
152
src/engine/gameplay/__tests__/FireableWeapons.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
src/scenes/systems/__tests__/TargetingSystem.test.ts
Normal file
170
src/scenes/systems/__tests__/TargetingSystem.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user