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
|
// Targeting
|
||||||
targetingLineColor: 0xff0000,
|
targetingLineColor: 0xff0000,
|
||||||
targetingLineWidth: 2,
|
targetingLineWidth: 2,
|
||||||
targetingLineAlpha: 0.7
|
targetingLineAlpha: 0.7,
|
||||||
|
targetingLineDash: 6,
|
||||||
|
targetingLineGap: 4,
|
||||||
|
targetingLineShorten: 8
|
||||||
},
|
},
|
||||||
|
|
||||||
gameplay: {
|
gameplay: {
|
||||||
@@ -140,6 +143,7 @@ export const GAME_CONFIG = {
|
|||||||
{ key: "bat", path: "assets/sprites/actors/enemies/bat.png", frameConfig: { frameWidth: 15, frameHeight: 15 } },
|
{ 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: "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: "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: [
|
images: [
|
||||||
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },
|
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const ITEMS: Record<string, Item> = {
|
|||||||
id: "iron_sword",
|
id: "iron_sword",
|
||||||
name: "Iron Sword",
|
name: "Iron Sword",
|
||||||
type: "Weapon",
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
spriteIndex: 2,
|
spriteIndex: 2,
|
||||||
stats: {
|
stats: {
|
||||||
@@ -40,6 +41,35 @@ export const ITEMS: Record<string, Item> = {
|
|||||||
stats: {
|
stats: {
|
||||||
attack: 4
|
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"
|
| "Ring"
|
||||||
| "Belt"
|
| "Belt"
|
||||||
| "Currency"
|
| "Currency"
|
||||||
| "Consumable";
|
| "Consumable"
|
||||||
|
| "Ammo";
|
||||||
|
|
||||||
export type Item = {
|
export interface BaseItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: ItemType;
|
|
||||||
stats?: Partial<Stats>;
|
|
||||||
textureKey: string;
|
textureKey: string;
|
||||||
spriteIndex: number;
|
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;
|
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 = {
|
export type Equipment = {
|
||||||
mainHand?: Item;
|
mainHand?: Item;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('CombatLogic', () => {
|
|||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
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();
|
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: [
|
items: [
|
||||||
...runState.inventory.items,
|
...runState.inventory.items,
|
||||||
// Add starting items for testing if empty
|
// 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
|
energy: 0
|
||||||
|
|||||||
@@ -315,6 +315,10 @@ export class DungeonRenderer {
|
|||||||
this.fxRenderer.showAlert(x, y);
|
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) {
|
showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) {
|
||||||
// World coords
|
// World coords
|
||||||
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
|
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|||||||
@@ -11,6 +11,28 @@ export class FxRenderer {
|
|||||||
this.scene = scene;
|
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() {
|
clearCorpses() {
|
||||||
for (const sprite of this.corpseSprites) {
|
for (const sprite of this.corpseSprites) {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.dungeonRenderer = new DungeonRenderer(this);
|
this.dungeonRenderer = new DungeonRenderer(this);
|
||||||
this.cameraController = new CameraController(this.cameras.main);
|
this.cameraController = new CameraController(this.cameras.main);
|
||||||
this.itemManager = new ItemManager(this.world, this.entityManager);
|
this.itemManager = new ItemManager(this.world, this.entityManager);
|
||||||
const targetingGraphics = this.add.graphics().setDepth(2000);
|
this.targetingSystem = new TargetingSystem(this);
|
||||||
this.targetingSystem = new TargetingSystem(targetingGraphics);
|
|
||||||
|
|
||||||
// Launch UI Scene
|
// Launch UI Scene
|
||||||
this.scene.launch("GameUI");
|
this.scene.launch("GameUI");
|
||||||
@@ -174,6 +173,62 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (itemIdx === -1) return;
|
if (itemIdx === -1) return;
|
||||||
const item = player.inventory.items[itemIdx];
|
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);
|
const result = this.itemManager.handleUse(data.itemId, player);
|
||||||
|
|
||||||
if (result.success && result.consumed) {
|
if (result.success && result.consumed) {
|
||||||
@@ -187,17 +242,22 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
|
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
|
||||||
// Already targeting - execute throw
|
// Already targeting - execute throw
|
||||||
if (this.targetingSystem.cursorPos) {
|
if (this.targetingSystem.cursorPos) {
|
||||||
this.executeThrow(this.targetingSystem.cursorPos.x, this.targetingSystem.cursorPos.y);
|
this.executeThrow();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { x: tx, y: ty } = this.getPointerTilePos(this.input.activePointer);
|
||||||
|
|
||||||
this.targetingSystem.startTargeting(
|
this.targetingSystem.startTargeting(
|
||||||
item.id,
|
item.id,
|
||||||
player.pos,
|
player.pos,
|
||||||
this.world,
|
this.world,
|
||||||
|
this.entityManager,
|
||||||
|
this.playerId,
|
||||||
this.dungeonRenderer.seenArray,
|
this.dungeonRenderer.seenArray,
|
||||||
this.world.width
|
this.world.width,
|
||||||
|
{ x: tx, y: ty }
|
||||||
);
|
);
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
@@ -267,7 +327,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Only Left Click throws
|
// Only Left Click throws
|
||||||
if (p.button === 0) {
|
if (p.button === 0) {
|
||||||
if (this.targetingSystem.cursorPos) {
|
if (this.targetingSystem.cursorPos) {
|
||||||
this.executeThrow(this.targetingSystem.cursorPos.x, this.targetingSystem.cursorPos.y);
|
this.executeThrow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -559,16 +619,18 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
private executeThrow(_targetX: number, _targetY: number) {
|
private executeThrow() {
|
||||||
const success = this.targetingSystem.executeThrow(
|
const success = this.targetingSystem.executeThrow(
|
||||||
this.world,
|
this.world,
|
||||||
this.playerId,
|
this.playerId,
|
||||||
this.entityManager,
|
this.entityManager,
|
||||||
(blockedPos, hitActorId, item) => {
|
(blockedPos, hitActorId, item) => {
|
||||||
|
// Damage Logic
|
||||||
if (hitActorId !== undefined) {
|
if (hitActorId !== undefined) {
|
||||||
const victim = this.world.actors.get(hitActorId) as CombatantActor;
|
const victim = this.world.actors.get(hitActorId) as CombatantActor;
|
||||||
if (victim) {
|
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;
|
victim.stats.hp -= dmg;
|
||||||
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
|
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
|
||||||
this.dungeonRenderer.shakeCamera();
|
this.dungeonRenderer.shakeCamera();
|
||||||
@@ -576,13 +638,30 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
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(
|
this.dungeonRenderer.showProjectile(
|
||||||
player.pos,
|
player.pos,
|
||||||
blockedPos,
|
blockedPos,
|
||||||
item.id,
|
projectileId,
|
||||||
() => {
|
() => {
|
||||||
// Drop the actual item at the landing spot
|
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
|
||||||
this.itemManager.spawnItem(item, blockedPos);
|
const shouldDrop = item.type !== "Weapon";
|
||||||
|
|
||||||
|
if (shouldDrop) {
|
||||||
|
// Drop the actual item at the landing spot
|
||||||
|
this.itemManager.spawnItem(item, blockedPos);
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger destruction/interaction
|
// Trigger destruction/interaction
|
||||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||||
@@ -590,7 +669,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.targetingSystem.cancel();
|
this.targetingSystem.cancel();
|
||||||
this.commitPlayerAction({ type: "throw" });
|
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
|
||||||
this.emitUIUpdate();
|
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 { 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', () => {
|
vi.mock('phaser', () => {
|
||||||
const mockEventEmitter = {
|
const mockEventEmitter = {
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
@@ -11,71 +8,97 @@ vi.mock('phaser', () => {
|
|||||||
off: vi.fn(),
|
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 {
|
return {
|
||||||
default: {
|
default: {
|
||||||
Scene: class {
|
Scene: MockScene,
|
||||||
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(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
Input: {
|
Input: {
|
||||||
Keyboard: {
|
Keyboard: {
|
||||||
JustDown: vi.fn(),
|
JustDown: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { GameScene } from '../GameScene';
|
||||||
|
import * as simulation from '../../engine/simulation/simulation';
|
||||||
|
import * as generator from '../../engine/world/generator';
|
||||||
|
|
||||||
// Mock other modules
|
// Mock other modules
|
||||||
vi.mock('../../rendering/DungeonRenderer', () => ({
|
vi.mock('../../rendering/DungeonRenderer', () => ({
|
||||||
DungeonRenderer: vi.fn().mockImplementation(function() {
|
DungeonRenderer: vi.fn().mockImplementation(function() {
|
||||||
return {
|
return {
|
||||||
initializeFloor: vi.fn(),
|
initializeFloor: vi.fn(),
|
||||||
|
|
||||||
computeFov: vi.fn(),
|
computeFov: vi.fn(),
|
||||||
render: vi.fn(),
|
render: vi.fn(),
|
||||||
showDamage: vi.fn(),
|
showDamage: vi.fn(),
|
||||||
spawnCorpse: vi.fn(),
|
spawnCorpse: vi.fn(),
|
||||||
showWait: vi.fn(),
|
showWait: vi.fn(),
|
||||||
isMinimapVisible: vi.fn(() => false),
|
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', () => ({
|
vi.mock('../../engine/world/generator', () => ({
|
||||||
generateWorld: vi.fn(),
|
generateWorld: vi.fn(),
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/world/world-logic', () => ({
|
vi.mock('../../engine/world/world-logic', () => ({
|
||||||
@@ -95,6 +117,7 @@ vi.mock('../../engine/world/world-logic', () => ({
|
|||||||
isBlocked: vi.fn(() => false),
|
isBlocked: vi.fn(() => false),
|
||||||
isPlayerOnExit: vi.fn(() => false),
|
isPlayerOnExit: vi.fn(() => false),
|
||||||
idx: vi.fn((w, x, y) => y * w.width + x),
|
idx: vi.fn((w, x, y) => y * w.width + x),
|
||||||
|
tryDestructTile: vi.fn(() => false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('GameScene', () => {
|
describe('GameScene', () => {
|
||||||
@@ -142,7 +165,6 @@ describe('GameScene', () => {
|
|||||||
mockWorld.actors.set(1, mockPlayer);
|
mockWorld.actors.set(1, mockPlayer);
|
||||||
|
|
||||||
(generator.generateWorld as any).mockReturnValue({
|
(generator.generateWorld as any).mockReturnValue({
|
||||||
|
|
||||||
world: mockWorld,
|
world: mockWorld,
|
||||||
playerId: 1,
|
playerId: 1,
|
||||||
});
|
});
|
||||||
@@ -157,9 +179,7 @@ describe('GameScene', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger death screen when player is killed', () => {
|
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) => {
|
(simulation.applyAction as any).mockImplementation((world: any) => {
|
||||||
// simulate player being killed
|
|
||||||
world.actors.delete(1);
|
world.actors.delete(1);
|
||||||
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
||||||
});
|
});
|
||||||
@@ -169,19 +189,8 @@ describe('GameScene', () => {
|
|||||||
events: [],
|
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' });
|
(scene as any).commitPlayerAction({ type: 'wait' });
|
||||||
|
|
||||||
// 3. Verify showDeathScreen was called on the mock UI
|
|
||||||
expect(mockUI.showDeathScreen).toHaveBeenCalled();
|
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,
|
commitActionFn: (action: any) => void,
|
||||||
emitUIUpdateFn: () => void,
|
emitUIUpdateFn: () => void,
|
||||||
restartGameFn: () => void,
|
restartGameFn: () => void,
|
||||||
executeThrowFn: (x: number, y: number) => void
|
executeThrowFn: () => void
|
||||||
): void {
|
): void {
|
||||||
// Menu state listeners (from UI)
|
// Menu state listeners (from UI)
|
||||||
this.scene.events.on("menu-toggled", (isOpen: boolean) => {
|
this.scene.events.on("menu-toggled", (isOpen: boolean) => {
|
||||||
@@ -115,7 +115,7 @@ export class EventBridge {
|
|||||||
if (targetingSystem.isActive && targetingSystem.itemId === item.id) {
|
if (targetingSystem.isActive && targetingSystem.itemId === item.id) {
|
||||||
// Already targeting - execute throw
|
// Already targeting - execute throw
|
||||||
if (targetingSystem.cursorPos) {
|
if (targetingSystem.cursorPos) {
|
||||||
executeThrowFn(targetingSystem.cursorPos.x, targetingSystem.cursorPos.y);
|
executeThrowFn();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,8 @@ export class EventBridge {
|
|||||||
item.id,
|
item.id,
|
||||||
player.pos,
|
player.pos,
|
||||||
(this.scene as any).world,
|
(this.scene as any).world,
|
||||||
|
(this.scene as any).entityManager,
|
||||||
|
(this.scene as any).playerId,
|
||||||
dungeonRenderer.seenArray,
|
dungeonRenderer.seenArray,
|
||||||
(this.scene as any).world.width
|
(this.scene as any).world.width
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,12 +37,18 @@ export class ItemManager {
|
|||||||
spawnItem(item: Item, pos: Vec2): void {
|
spawnItem(item: Item, pos: Vec2): void {
|
||||||
if (!this.world || !this.entityManager) return;
|
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 id = this.entityManager.getNextId();
|
||||||
const drop: ItemDropActor = {
|
const drop: ItemDropActor = {
|
||||||
id,
|
id,
|
||||||
pos: { x: pos.x, y: pos.y },
|
pos: { x: pos.x, y: pos.y },
|
||||||
category: "item_drop",
|
category: "item_drop",
|
||||||
item: { ...item } // Clone item
|
item: clonedItem
|
||||||
};
|
};
|
||||||
|
|
||||||
this.entityManager.addActor(drop);
|
this.entityManager.addActor(drop);
|
||||||
@@ -61,7 +67,20 @@ export class ItemManager {
|
|||||||
if (itemActor) {
|
if (itemActor) {
|
||||||
const item = itemActor.item;
|
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
|
// Add to inventory
|
||||||
|
item.quantity = item.quantity || 1;
|
||||||
player.inventory.items.push(item);
|
player.inventory.items.push(item);
|
||||||
|
|
||||||
// Remove from world
|
// Remove from world
|
||||||
@@ -91,7 +110,7 @@ export class ItemManager {
|
|||||||
const item = player.inventory.items[itemIdx];
|
const item = player.inventory.items[itemIdx];
|
||||||
|
|
||||||
// Check if item is a healing consumable
|
// 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;
|
const healAmount = item.stats.hp;
|
||||||
|
|
||||||
if (player.stats.hp >= player.stats.maxHp) {
|
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);
|
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
|
||||||
|
|
||||||
// Remove item after use
|
// Consume item (check stack)
|
||||||
player.inventory.items.splice(itemIdx, 1);
|
if (item.quantity && item.quantity > 1) {
|
||||||
|
item.quantity--;
|
||||||
|
} else {
|
||||||
|
player.inventory.items.splice(itemIdx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -110,8 +133,8 @@ export class ItemManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throwable items are handled by TargetingSystem, not here
|
// Throwable items
|
||||||
if (item.throwable) {
|
if (item.type === "Consumable" && item.throwable) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
consumed: false,
|
consumed: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Phaser from "phaser";
|
|||||||
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
|
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
|
||||||
import { TILE_SIZE } from "../../core/constants";
|
import { TILE_SIZE } from "../../core/constants";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
import { UI_CONFIG } from "../../core/config/ui";
|
||||||
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
||||||
import type { EntityManager } from "../../engine/EntityManager";
|
import type { EntityManager } from "../../engine/EntityManager";
|
||||||
|
|
||||||
@@ -11,12 +12,25 @@ import type { EntityManager } from "../../engine/EntityManager";
|
|||||||
*/
|
*/
|
||||||
export class TargetingSystem {
|
export class TargetingSystem {
|
||||||
private graphics: Phaser.GameObjects.Graphics;
|
private graphics: Phaser.GameObjects.Graphics;
|
||||||
|
private crosshairSprite: Phaser.GameObjects.Sprite;
|
||||||
private active: boolean = false;
|
private active: boolean = false;
|
||||||
private targetingItemId: string | null = null;
|
private targetingItemId: string | null = null;
|
||||||
private cursor: Vec2 | 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) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.graphics = graphics;
|
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,
|
itemId: string,
|
||||||
playerPos: Vec2,
|
playerPos: Vec2,
|
||||||
world: World,
|
world: World,
|
||||||
|
entityManager: EntityManager,
|
||||||
|
playerId: EntityId,
|
||||||
seenArray: Uint8Array,
|
seenArray: Uint8Array,
|
||||||
worldWidth: number
|
worldWidth: number,
|
||||||
|
initialTargetPos?: Vec2
|
||||||
): void {
|
): void {
|
||||||
this.targetingItemId = itemId;
|
this.targetingItemId = itemId;
|
||||||
|
this.world = world;
|
||||||
|
this.entityManager = entityManager;
|
||||||
|
this.playerId = playerId;
|
||||||
this.active = true;
|
this.active = true;
|
||||||
|
|
||||||
// Auto-target closest visible enemy
|
// Auto-target closest visible enemy
|
||||||
@@ -37,11 +57,14 @@ export class TargetingSystem {
|
|||||||
|
|
||||||
if (closest) {
|
if (closest) {
|
||||||
this.cursor = closest;
|
this.cursor = closest;
|
||||||
|
} else if (initialTargetPos) {
|
||||||
|
this.cursor = { ...initialTargetPos };
|
||||||
} else {
|
} 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");
|
console.log("Targeting Mode: ON");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +75,7 @@ export class TargetingSystem {
|
|||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|
||||||
this.cursor = { x: worldPos.x, y: worldPos.y };
|
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];
|
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 start = player.pos;
|
||||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
const end = { x: this.cursor.x, y: this.cursor.y };
|
||||||
@@ -101,7 +132,11 @@ export class TargetingSystem {
|
|||||||
this.active = false;
|
this.active = false;
|
||||||
this.targetingItemId = null;
|
this.targetingItemId = null;
|
||||||
this.cursor = null;
|
this.cursor = null;
|
||||||
|
this.world = null;
|
||||||
|
this.entityManager = null;
|
||||||
|
this.playerId = null;
|
||||||
this.graphics.clear();
|
this.graphics.clear();
|
||||||
|
this.crosshairSprite.setVisible(false);
|
||||||
console.log("Targeting cancelled");
|
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) {
|
if (!this.cursor) {
|
||||||
this.graphics.clear();
|
this.graphics.clear();
|
||||||
|
this.crosshairSprite.setVisible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.graphics.clear();
|
this.graphics.clear();
|
||||||
|
this.crosshairSprite.setVisible(true);
|
||||||
|
|
||||||
const startX = playerPos.x * TILE_SIZE + TILE_SIZE / 2;
|
const startX = playerPos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const startY = playerPos.y * 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 aimEndX = this.cursor.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const endY = this.cursor.y * 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(
|
this.graphics.lineStyle(
|
||||||
GAME_CONFIG.ui.targetingLineWidth,
|
GAME_CONFIG.ui.targetingLineWidth,
|
||||||
GAME_CONFIG.ui.targetingLineColor,
|
GAME_CONFIG.ui.targetingLineColor,
|
||||||
GAME_CONFIG.ui.targetingLineAlpha
|
GAME_CONFIG.ui.targetingLineAlpha
|
||||||
);
|
);
|
||||||
this.graphics.lineBetween(startX, startY, endX, endY);
|
|
||||||
|
|
||||||
this.graphics.strokeRect(
|
const dx = finalEndX - startX;
|
||||||
this.cursor.x * TILE_SIZE,
|
const dy = finalEndY - startY;
|
||||||
this.cursor.y * TILE_SIZE,
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
TILE_SIZE,
|
const shortenAmt = GAME_CONFIG.ui.targetingLineShorten;
|
||||||
TILE_SIZE
|
|
||||||
);
|
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 container!: Phaser.GameObjects.Container;
|
||||||
private slots: Phaser.GameObjects.Container[] = [];
|
private slots: Phaser.GameObjects.Container[] = [];
|
||||||
private itemMap: (Item | null)[] = new Array(10).fill(null);
|
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) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@@ -125,6 +125,19 @@ export class QuickSlotComponent {
|
|||||||
}).setOrigin(1, 1);
|
}).setOrigin(1, 1);
|
||||||
slot.add(countText);
|
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 {
|
} else {
|
||||||
this.itemMap[i] = null;
|
this.itemMap[i] = null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
setupFiles: ["src/__tests__/test-setup.ts"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ['text', 'json', 'html'],
|
||||||
|
|||||||
Reference in New Issue
Block a user