refactor items logic

This commit is contained in:
Peter Stockings
2026-01-22 22:04:23 +11:00
parent 4129f5390f
commit d2039df8c8
8 changed files with 220 additions and 118 deletions

View File

@@ -1,77 +1,180 @@
import type { Item } from "../types"; import type {
ConsumableItem,
MeleeWeaponItem,
RangedWeaponItem,
ArmourItem,
AmmoItem
} from "../types";
export const ITEMS: Record<string, Item> = { // =============================================================================
"health_potion": { // Per-Type Template Lists (Immutable)
id: "health_potion", // =============================================================================
export const CONSUMABLES = {
health_potion: {
name: "Health Potion", name: "Health Potion",
type: "Consumable",
textureKey: "items", textureKey: "items",
spriteIndex: 57, spriteIndex: 57,
stats: { healAmount: 5,
hp: 5
},
stackable: true, stackable: true,
quantity: 1
}, },
"iron_sword": { throwing_dagger: {
id: "iron_sword",
name: "Iron Sword",
type: "Weapon",
weaponType: "melee",
textureKey: "items",
spriteIndex: 2,
stats: {
attack: 2
}
},
"leather_armor": {
id: "leather_armor",
name: "Leather Armor",
type: "BodyArmour",
textureKey: "items",
spriteIndex: 25,
stats: {
defense: 2
}
},
"throwing_dagger": {
id: "throwing_dagger",
name: "Throwing Dagger", name: "Throwing Dagger",
type: "Consumable",
textureKey: "items", textureKey: "items",
spriteIndex: 15, spriteIndex: 15,
stats: { attack: 4,
attack: 4
},
throwable: true, throwable: true,
stackable: true, stackable: true,
quantity: 1
}, },
"pistol": { } as const;
id: "pistol",
export const RANGED_WEAPONS = {
pistol: {
name: "Pistol", name: "Pistol",
type: "Weapon",
weaponType: "ranged",
textureKey: "weapons", textureKey: "weapons",
spriteIndex: 1, spriteIndex: 1,
stats: {
attack: 10, attack: 10,
range: 8, range: 8,
magazineSize: 6, magazineSize: 6,
currentAmmo: 6,
ammoType: "9mm", ammoType: "9mm",
projectileSpeed: 15, projectileSpeed: 15,
fireSound: "shoot" fireSound: "shoot",
}
}, },
"ammo_9mm": { } as const;
id: "ammo_9mm",
export const MELEE_WEAPONS = {
iron_sword: {
name: "Iron Sword",
textureKey: "items",
spriteIndex: 2,
attack: 2,
},
} as const;
export const AMMO = {
ammo_9mm: {
name: "9mm Ammo", name: "9mm Ammo",
type: "Ammo",
ammoType: "9mm",
textureKey: "weapons", textureKey: "weapons",
spriteIndex: 23, spriteIndex: 23,
ammoType: "9mm",
stackable: true, stackable: true,
quantity: 10 // Finds a pack of 10 },
} } as const;
};
export const ARMOUR = {
leather_armor: {
name: "Leather Armor",
textureKey: "items",
spriteIndex: 25,
defense: 2,
},
} as const;
// Combined lookup for rendering (e.g., projectile sprites)
export const ALL_TEMPLATES = {
...CONSUMABLES,
...RANGED_WEAPONS,
...MELEE_WEAPONS,
...AMMO,
...ARMOUR,
} as const;
// =============================================================================
// Type-Safe IDs (derived from templates)
// =============================================================================
export type ConsumableId = keyof typeof CONSUMABLES;
export type RangedWeaponId = keyof typeof RANGED_WEAPONS;
export type MeleeWeaponId = keyof typeof MELEE_WEAPONS;
export type AmmoId = keyof typeof AMMO;
export type ArmourId = keyof typeof ARMOUR;
export type ItemTemplateId = keyof typeof ALL_TEMPLATES;
// =============================================================================
// Factory Functions
// =============================================================================
export function createConsumable(id: ConsumableId, quantity = 1): ConsumableItem {
const t = CONSUMABLES[id];
return {
id,
name: t.name,
type: "Consumable",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stackable: t.stackable ?? false,
quantity,
stats: {
hp: "healAmount" in t ? t.healAmount : undefined,
attack: "attack" in t ? t.attack : undefined,
},
throwable: "throwable" in t ? t.throwable : undefined,
};
}
export function createRangedWeapon(id: RangedWeaponId): RangedWeaponItem {
const t = RANGED_WEAPONS[id];
return {
id,
name: t.name,
type: "Weapon",
weaponType: "ranged",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
currentAmmo: t.magazineSize,
stats: {
attack: t.attack,
range: t.range,
magazineSize: t.magazineSize,
ammoType: t.ammoType,
projectileSpeed: t.projectileSpeed,
fireSound: t.fireSound,
},
};
}
export function createMeleeWeapon(id: MeleeWeaponId): MeleeWeaponItem {
const t = MELEE_WEAPONS[id];
return {
id,
name: t.name,
type: "Weapon",
weaponType: "melee",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stats: {
attack: t.attack,
},
};
}
export function createAmmo(id: AmmoId, quantity = 10): AmmoItem {
const t = AMMO[id];
return {
id,
name: t.name,
type: "Ammo",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
ammoType: t.ammoType,
stackable: true,
quantity,
};
}
export function createArmour(id: ArmourId): ArmourItem {
const t = ARMOUR[id];
return {
id,
name: t.name,
type: "BodyArmour",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stats: {
defense: t.defense,
},
};
}
// Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES;

View File

@@ -98,11 +98,11 @@ export interface MeleeWeaponItem extends BaseItem {
export interface RangedWeaponItem extends BaseItem { export interface RangedWeaponItem extends BaseItem {
type: "Weapon"; type: "Weapon";
weaponType: "ranged"; weaponType: "ranged";
currentAmmo: number; // Runtime state - moved to top level for easier access
stats: { stats: {
attack: number; attack: number;
range: number; range: number;
magazineSize: number; magazineSize: number;
currentAmmo: number;
ammoType: string; ammoType: string;
projectileSpeed: number; projectileSpeed: number;
fireSound?: string; fireSound?: string;

View File

@@ -2,8 +2,8 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { ItemManager } from "../../../scenes/systems/ItemManager"; import { ItemManager } from "../../../scenes/systems/ItemManager";
import { EntityManager } from "../../EntityManager"; import { EntityManager } from "../../EntityManager";
import type { World, CombatantActor, RangedWeaponItem, AmmoItem } from "../../../core/types"; import type { World, CombatantActor, RangedWeaponItem } from "../../../core/types";
import { ITEMS } from "../../../core/config/Items"; import { createRangedWeapon, createAmmo } from "../../../core/config/Items";
// Mock World and EntityManager // Mock World and EntityManager
const mockWorld: World = { const mockWorld: World = {
@@ -50,8 +50,7 @@ describe("Fireable Weapons & Ammo System", () => {
it("should stack ammo correctly", () => { it("should stack ammo correctly", () => {
// Spawn Ammo pack 1 // Spawn Ammo pack 1
const ammo1 = { ...ITEMS["ammo_9mm"] } as AmmoItem; const ammo1 = createAmmo("ammo_9mm", 10);
ammo1.quantity = 10;
itemManager.spawnItem(ammo1, { x: 0, y: 0 }); itemManager.spawnItem(ammo1, { x: 0, y: 0 });
// Pickup // Pickup
@@ -60,8 +59,7 @@ describe("Fireable Weapons & Ammo System", () => {
expect(player.inventory!.items[0].quantity).toBe(10); expect(player.inventory!.items[0].quantity).toBe(10);
// Spawn Ammo pack 2 // Spawn Ammo pack 2
const ammo2 = { ...ITEMS["ammo_9mm"] } as AmmoItem; const ammo2 = createAmmo("ammo_9mm", 5);
ammo2.quantity = 5;
itemManager.spawnItem(ammo2, { x: 0, y: 0 }); itemManager.spawnItem(ammo2, { x: 0, y: 0 });
// Pickup (should merge) // Pickup (should merge)
@@ -71,82 +69,77 @@ describe("Fireable Weapons & Ammo System", () => {
}); });
it("should consume ammo from weapon when fired", () => { it("should consume ammo from weapon when fired", () => {
// Manually Equip Pistol // Create pistol using factory (already has currentAmmo initialized)
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem; const pistol = createRangedWeapon("pistol");
// Deep clone stats for test isolation
pistol.stats = { ...pistol.stats };
player.inventory!.items.push(pistol); player.inventory!.items.push(pistol);
// Sanity Check // Sanity Check - currentAmmo is now top-level
expect(pistol.stats.currentAmmo).toBe(6); expect(pistol.currentAmmo).toBe(6);
expect(pistol.stats.magazineSize).toBe(6); expect(pistol.stats.magazineSize).toBe(6);
// Simulate Firing (logic mimic from GameScene) // Simulate Firing (logic mimic from GameScene)
if (pistol.stats.currentAmmo > 0) { if (pistol.currentAmmo > 0) {
pistol.stats.currentAmmo--; pistol.currentAmmo--;
} }
expect(pistol.stats.currentAmmo).toBe(5); expect(pistol.currentAmmo).toBe(5);
}); });
it("should reload weapon using inventory ammo", () => { it("should reload weapon using inventory ammo", () => {
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem; const pistol = createRangedWeapon("pistol");
pistol.stats = { ...pistol.stats }; pistol.currentAmmo = 0; // Empty
pistol.stats.currentAmmo = 0; // Empty
player.inventory!.items.push(pistol); player.inventory!.items.push(pistol);
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem; const ammo = createAmmo("ammo_9mm", 10);
ammo.quantity = 10;
player.inventory!.items.push(ammo); player.inventory!.items.push(ammo);
// Logic mimic from GameScene // Logic mimic from GameScene
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6 const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity); // 6 const toTake = Math.min(needed, ammo.quantity!); // 6
pistol.stats.currentAmmo += toTake; pistol.currentAmmo += toTake;
ammo.quantity -= toTake; ammo.quantity! -= toTake;
expect(pistol.stats.currentAmmo).toBe(6); expect(pistol.currentAmmo).toBe(6);
expect(ammo.quantity).toBe(4); expect(ammo.quantity).toBe(4);
}); });
it("should handle partial reload if not enough ammo", () => { it("should handle partial reload if not enough ammo", () => {
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem; const pistol = createRangedWeapon("pistol");
pistol.stats = { ...pistol.stats }; pistol.currentAmmo = 0;
pistol.stats.currentAmmo = 0;
player.inventory!.items.push(pistol); player.inventory!.items.push(pistol);
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem; const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
ammo.quantity = 3; // Only 3 bullets
player.inventory!.items.push(ammo); player.inventory!.items.push(ammo);
// Logic mimic // Logic mimic
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6 const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity); // 3 const toTake = Math.min(needed, ammo.quantity!); // 3
pistol.stats.currentAmmo += toTake; pistol.currentAmmo += toTake;
ammo.quantity -= toTake; ammo.quantity! -= toTake;
expect(pistol.stats.currentAmmo).toBe(3); expect(pistol.currentAmmo).toBe(3);
expect(ammo.quantity).toBe(0); expect(ammo.quantity).toBe(0);
}); });
it("should deep clone stats on spawn so pistols remain independent", () => { it("should deep clone on spawn so pistols remain independent", () => {
const pistolDef = ITEMS["pistol"] as RangedWeaponItem; const pistol1 = createRangedWeapon("pistol");
// Spawn 1 // Spawn 1
itemManager.spawnItem(pistolDef, {x:0, y:0}); itemManager.spawnItem(pistol1, {x:0, y:0});
const picked1 = itemManager.tryPickup(player)! as RangedWeaponItem; const picked1 = itemManager.tryPickup(player)! as RangedWeaponItem;
// Spawn 2 // Spawn 2
itemManager.spawnItem(pistolDef, {x:0, y:0}); const pistol2 = createRangedWeapon("pistol");
itemManager.spawnItem(pistol2, {x:0, y:0});
const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem; const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem;
expect(picked1).not.toBe(picked2); expect(picked1).not.toBe(picked2);
expect(picked1.stats).not.toBe(picked2.stats); // Critical! expect(picked1.stats).not.toBe(picked2.stats); // Critical!
// Modifying one should not affect other // Modifying one should not affect other
picked1.stats.currentAmmo = 0; picked1.currentAmmo = 0;
expect(picked2.stats.currentAmmo).toBe(6); expect(picked2.currentAmmo).toBe(6);
}); });
}); });

View File

@@ -2,7 +2,12 @@ import { type World, type EntityId, type RunState, type Tile, type Actor, type V
import { TileType } from "../../core/terrain"; import { TileType } from "../../core/terrain";
import { idx } from "./world-logic"; import { idx } from "./world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig"; import { GAME_CONFIG } from "../../core/config/GameConfig";
import { ITEMS } from "../../core/config/Items"; import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
createArmour
} from "../../core/config/Items";
import { seededRandom } from "../../core/math"; import { seededRandom } from "../../core/math";
import * as ROT from "rot-js"; import * as ROT from "rot-js";
@@ -53,11 +58,11 @@ export function generateWorld(floor: number, runState: RunState): { world: World
...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 ? [ ...(runState.inventory.items.length === 0 ? [
{ ...ITEMS["health_potion"], quantity: 2 }, createConsumable("health_potion", 2),
ITEMS["iron_sword"], createMeleeWeapon("iron_sword"),
{ ...ITEMS["throwing_dagger"], quantity: 3 }, createConsumable("throwing_dagger", 3),
ITEMS["pistol"], createRangedWeapon("pistol"),
ITEMS["leather_armor"] createArmour("leather_armor")
] : []) ] : [])
] ]
}, },

View File

@@ -3,7 +3,7 @@ import { type World, type EntityId, type Vec2, type ActorType } from "../core/ty
import { TILE_SIZE } from "../core/constants"; import { TILE_SIZE } from "../core/constants";
import { idx, isWall } from "../engine/world/world-logic"; import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig"; import { GAME_CONFIG } from "../core/config/GameConfig";
import { ITEMS } from "../core/config/Items"; import { ALL_TEMPLATES } from "../core/config/Items";
import { FovManager } from "./FovManager"; import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer"; import { MinimapRenderer } from "./MinimapRenderer";
import { FxRenderer } from "./FxRenderer"; import { FxRenderer } from "./FxRenderer";
@@ -328,7 +328,7 @@ export class DungeonRenderer {
// Create sprite // Create sprite
// Look up sprite index from config // Look up sprite index from config
const itemConfig = ITEMS[itemId]; const itemConfig = ALL_TEMPLATES[itemId as keyof typeof ALL_TEMPLATES];
const texture = itemConfig?.textureKey ?? "items"; const texture = itemConfig?.textureKey ?? "items";
const frame = itemConfig?.spriteIndex ?? 0; const frame = itemConfig?.spriteIndex ?? 0;

View File

@@ -176,16 +176,16 @@ export class GameScene extends Phaser.Scene {
// Ranged Weapon Logic // Ranged Weapon Logic
if (item.type === "Weapon" && item.weaponType === "ranged") { if (item.type === "Weapon" && item.weaponType === "ranged") {
// Check Ammo // Check Ammo
if (item.stats.currentAmmo <= 0) { if (item.currentAmmo <= 0) {
// Try Reload // Try Reload
const ammoId = `ammo_${item.stats.ammoType}`; const ammoId = `ammo_${item.stats.ammoType}`;
const ammoItem = player.inventory.items.find(it => it.id === ammoId); // Simple check const ammoItem = player.inventory.items.find(it => it.id === ammoId); // Simple check
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) { if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
const needed = item.stats.magazineSize - item.stats.currentAmmo; const needed = item.stats.magazineSize - item.currentAmmo;
const toTake = Math.min(needed, ammoItem.quantity); const toTake = Math.min(needed, ammoItem.quantity);
item.stats.currentAmmo += toTake; item.currentAmmo += toTake;
ammoItem.quantity -= toTake; ammoItem.quantity -= toTake;
if (ammoItem.quantity <= 0) { if (ammoItem.quantity <= 0) {
@@ -193,7 +193,7 @@ export class GameScene extends Phaser.Scene {
} }
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloaded!", "#00ff00"); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloaded!", "#00ff00");
console.log("Reloaded. Ammo:", item.stats.currentAmmo); console.log("Reloaded. Ammo:", item.currentAmmo);
this.commitPlayerAction({ type: "wait" }); this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate(); this.emitUIUpdate();
} else { } else {
@@ -721,8 +721,8 @@ export class GameScene extends Phaser.Scene {
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
// Consume Ammo // Consume Ammo
if (item.stats.currentAmmo > 0) { if (item.currentAmmo > 0) {
item.stats.currentAmmo--; item.currentAmmo--;
} }
} }
@@ -735,8 +735,9 @@ export class GameScene extends Phaser.Scene {
const shouldDrop = item.type !== "Weapon"; const shouldDrop = item.type !== "Weapon";
if (shouldDrop) { if (shouldDrop) {
// Drop the actual item at the landing spot // Drop a SINGLE item at the landing spot (not the whole stack)
this.itemManager.spawnItem(item, blockedPos); const singleItem = { ...item, quantity: 1 };
this.itemManager.spawnItem(singleItem, blockedPos);
} }
// Trigger destruction/interaction // Trigger destruction/interaction

View File

@@ -343,7 +343,7 @@ export class InventoryOverlay extends OverlayComponent {
if (item.stackable) { if (item.stackable) {
labelText = `x${item.quantity || 1}`; labelText = `x${item.quantity || 1}`;
} else if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) { } else if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
labelText = `${item.stats.currentAmmo}/${item.stats.magazineSize}`; labelText = `${item.currentAmmo}/${item.stats.magazineSize}`;
} }
if (labelText) { if (labelText) {

View File

@@ -236,7 +236,7 @@ export class QuickSlotComponent {
labelText = `x${totalQuantity}`; labelText = `x${totalQuantity}`;
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) { } else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
// Show ammo for non-stackable ranged weapons // Show ammo for non-stackable ranged weapons
labelText = `${foundItem.stats.currentAmmo}/${foundItem.stats.magazineSize}`; labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
} }
if (labelText) { if (labelText) {