Add gun to inventory that fires bullets
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
throwable?: boolean;
|
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 = {
|
export type Equipment = {
|
||||||
mainHand?: Item;
|
mainHand?: Item;
|
||||||
|
|||||||
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, Item } 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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user