Compare commits
2 Commits
e130e6d174
...
18d4f0cdd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d4f0cdd4 | ||
|
|
c415becc38 |
@@ -26,6 +26,12 @@ export const CONSUMABLES = {
|
||||
throwable: true,
|
||||
stackable: true,
|
||||
},
|
||||
upgrade_scroll: {
|
||||
name: "Upgrade Scroll",
|
||||
textureKey: "items",
|
||||
spriteIndex: 79,
|
||||
stackable: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const RANGED_WEAPONS = {
|
||||
@@ -224,6 +230,19 @@ export function createArmour(
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpgradeScroll(quantity = 1): ConsumableItem {
|
||||
const t = CONSUMABLES["upgrade_scroll"];
|
||||
return {
|
||||
id: "upgrade_scroll",
|
||||
name: t.name,
|
||||
type: "Consumable",
|
||||
textureKey: t.textureKey,
|
||||
spriteIndex: t.spriteIndex,
|
||||
stackable: true,
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy export for backward compatibility during migration
|
||||
export const ITEMS = ALL_TEMPLATES;
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface BaseItem {
|
||||
quantity?: number;
|
||||
stackable?: boolean;
|
||||
variant?: string; // ItemVariantId - stored as string to avoid circular imports
|
||||
upgradeLevel?: number; // Enhancement level (+1, +2, etc.)
|
||||
}
|
||||
|
||||
export interface MeleeWeaponItem extends BaseItem {
|
||||
|
||||
64
src/engine/systems/UpgradeManager.ts
Normal file
64
src/engine/systems/UpgradeManager.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Item, WeaponItem, ArmourItem } from "../../core/types";
|
||||
|
||||
/**
|
||||
* Manages item upgrade logic for applying upgrade scrolls.
|
||||
*/
|
||||
export class UpgradeManager {
|
||||
|
||||
/**
|
||||
* Checks if an item can be upgraded (weapons and armour only).
|
||||
*/
|
||||
static canUpgrade(item: Item): boolean {
|
||||
return item.type === "Weapon" ||
|
||||
item.type === "BodyArmour" ||
|
||||
item.type === "Helmet" ||
|
||||
item.type === "Gloves" ||
|
||||
item.type === "Boots";
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an upgrade to an item, increasing all stats by +1.
|
||||
* Returns true if successful.
|
||||
*/
|
||||
static applyUpgrade(item: Item): boolean {
|
||||
if (!this.canUpgrade(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment upgrade level
|
||||
const currentLevel = item.upgradeLevel ?? 0;
|
||||
item.upgradeLevel = currentLevel + 1;
|
||||
|
||||
// Update item name with level suffix
|
||||
// Remove any existing upgrade suffix first
|
||||
const baseName = item.name.replace(/\s*\+\d+$/, "");
|
||||
item.name = `${baseName} +${item.upgradeLevel}`;
|
||||
|
||||
// Increase all numeric stats by +1
|
||||
if (item.type === "Weapon") {
|
||||
const weaponItem = item as WeaponItem;
|
||||
if (weaponItem.stats.attack !== undefined) {
|
||||
weaponItem.stats.attack += 1;
|
||||
}
|
||||
} else if (item.type === "BodyArmour" || item.type === "Helmet" ||
|
||||
item.type === "Gloves" || item.type === "Boots") {
|
||||
const armourItem = item as ArmourItem;
|
||||
if (armourItem.stats.defense !== undefined) {
|
||||
armourItem.stats.defense += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display name for an item including upgrade level.
|
||||
*/
|
||||
static getDisplayName(item: Item): string {
|
||||
if (item.upgradeLevel && item.upgradeLevel > 0) {
|
||||
const baseName = item.name.replace(/\s*\+\d+$/, "");
|
||||
return `${baseName} +${item.upgradeLevel}`;
|
||||
}
|
||||
return item.name;
|
||||
}
|
||||
}
|
||||
66
src/engine/systems/__tests__/UpgradeManager.test.ts
Normal file
66
src/engine/systems/__tests__/UpgradeManager.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UpgradeManager } from '../UpgradeManager';
|
||||
import { createMeleeWeapon, createArmour, createConsumable } from '../../../core/config/Items';
|
||||
import type { WeaponItem, ArmourItem } from '../../../core/types';
|
||||
|
||||
describe('UpgradeManager', () => {
|
||||
it('should correctly identify upgradeable items', () => {
|
||||
const sword = createMeleeWeapon("iron_sword");
|
||||
const armor = createArmour("leather_armor");
|
||||
const potion = createConsumable("health_potion");
|
||||
|
||||
expect(UpgradeManager.canUpgrade(sword)).toBe(true);
|
||||
expect(UpgradeManager.canUpgrade(armor)).toBe(true);
|
||||
expect(UpgradeManager.canUpgrade(potion)).toBe(false);
|
||||
});
|
||||
|
||||
it('should upgrade weapon stats and name', () => {
|
||||
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
|
||||
const initialAttack = sword.stats.attack!;
|
||||
const initialName = sword.name;
|
||||
|
||||
const success = UpgradeManager.applyUpgrade(sword);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(sword.stats.attack).toBe(initialAttack + 1);
|
||||
expect(sword.upgradeLevel).toBe(1);
|
||||
expect(sword.name).toBe(`${initialName} +1`);
|
||||
});
|
||||
|
||||
it('should upgrade armour stats and name', () => {
|
||||
const armor = createArmour("leather_armor") as ArmourItem;
|
||||
const initialDefense = armor.stats.defense!;
|
||||
const initialName = armor.name;
|
||||
|
||||
const success = UpgradeManager.applyUpgrade(armor);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(armor.stats.defense).toBe(initialDefense + 1);
|
||||
expect(armor.upgradeLevel).toBe(1);
|
||||
expect(armor.name).toBe(`${initialName} +1`);
|
||||
});
|
||||
|
||||
it('should handle sequential upgrades', () => {
|
||||
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
|
||||
const initialAttack = sword.stats.attack!;
|
||||
const initialName = sword.name;
|
||||
|
||||
UpgradeManager.applyUpgrade(sword); // +1
|
||||
UpgradeManager.applyUpgrade(sword); // +2
|
||||
|
||||
expect(sword.stats.attack).toBe(initialAttack + 2);
|
||||
expect(sword.upgradeLevel).toBe(2);
|
||||
expect(sword.name).toBe(`${initialName} +2`);
|
||||
});
|
||||
|
||||
it('should not upgrade non-upgradeable items', () => {
|
||||
const potion = createConsumable("health_potion");
|
||||
const initialName = potion.name;
|
||||
|
||||
const success = UpgradeManager.applyUpgrade(potion);
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(potion.upgradeLevel).toBeUndefined();
|
||||
expect(potion.name).toBe(initialName);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
createConsumable,
|
||||
createMeleeWeapon,
|
||||
createRangedWeapon,
|
||||
createArmour
|
||||
createArmour,
|
||||
createUpgradeScroll
|
||||
} from "../../core/config/Items";
|
||||
import { seededRandom } from "../../core/math";
|
||||
import * as ROT from "rot-js";
|
||||
@@ -62,7 +63,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
|
||||
createConsumable("throwing_dagger", 3),
|
||||
createRangedWeapon("pistol"),
|
||||
createArmour("leather_armor", "heavy") // Heavy armour variant
|
||||
createArmour("leather_armor", "heavy"), // Heavy armour variant
|
||||
createUpgradeScroll(2) // 2 Upgrade scrolls
|
||||
] : [])
|
||||
]
|
||||
},
|
||||
|
||||
@@ -36,6 +36,12 @@ export class ItemSpriteFactory {
|
||||
sprite.setScale(scale);
|
||||
container.add(sprite);
|
||||
|
||||
// Add upgrade level badge if item has been upgraded
|
||||
if (item.upgradeLevel && item.upgradeLevel > 0) {
|
||||
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
|
||||
container.add(badge);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -109,6 +115,31 @@ export class ItemSpriteFactory {
|
||||
return variant?.glowColor ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a badge displaying the upgrade level (e.g., "+1").
|
||||
*/
|
||||
private static createUpgradeBadge(
|
||||
scene: Phaser.Scene,
|
||||
level: number,
|
||||
scale: number
|
||||
): Phaser.GameObjects.Text {
|
||||
// Position at top-right corner, slightly inset
|
||||
const offset = 5 * scale;
|
||||
|
||||
// Level text with strong outline for readability without background
|
||||
const text = scene.add.text(offset, -offset, `+${level}`, {
|
||||
fontSize: `${9 * scale}px`,
|
||||
color: "#ffd700",
|
||||
fontStyle: "bold",
|
||||
fontFamily: "monospace",
|
||||
stroke: "#000000",
|
||||
strokeThickness: 3
|
||||
});
|
||||
text.setOrigin(0.5);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an item has a variant with a glow.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,8 @@ import GameUI from "../ui/GameUI";
|
||||
import { CameraController } from "./systems/CameraController";
|
||||
import { ItemManager } from "./systems/ItemManager";
|
||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
||||
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private world!: World;
|
||||
@@ -229,6 +231,42 @@ export class GameScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upgrade Scroll Logic
|
||||
if (item.id === "upgrade_scroll") {
|
||||
const uiScene = this.scene.get("GameUI") as GameUI;
|
||||
// Access the public inventory component
|
||||
const inventoryOverlay = uiScene.inventory;
|
||||
|
||||
if (inventoryOverlay && inventoryOverlay instanceof InventoryOverlay) {
|
||||
// Trigger upgrade mode
|
||||
inventoryOverlay.enterUpgradeMode((targetItem: any) => {
|
||||
const success = UpgradeManager.applyUpgrade(targetItem);
|
||||
if (success) {
|
||||
// Consume scroll logic handling stacking
|
||||
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
|
||||
if (scrollItem) {
|
||||
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
|
||||
scrollItem.quantity--;
|
||||
} else {
|
||||
this.itemManager.removeFromInventory(player, "upgrade_scroll");
|
||||
}
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
|
||||
}
|
||||
|
||||
inventoryOverlay.cancelUpgradeMode();
|
||||
this.emitUIUpdate();
|
||||
this.commitPlayerAction({ type: "wait" });
|
||||
} else {
|
||||
// Should technically be prevented by UI highlights, but safety check
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
|
||||
}
|
||||
});
|
||||
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.itemManager.handleUse(data.itemId, player);
|
||||
|
||||
if (result.success && result.consumed) {
|
||||
|
||||
409
src/ui/__tests__/InventoryUtils.test.ts
Normal file
409
src/ui/__tests__/InventoryUtils.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getEquipmentSlotSize,
|
||||
isItemUpgradeable,
|
||||
getCompatibleEquipmentSlots,
|
||||
isItemCompatibleWithSlot,
|
||||
formatTooltipStats,
|
||||
getItemLabelText,
|
||||
isPointInSlot,
|
||||
getBackpackSlotIndexAtPoint,
|
||||
getEquipmentSlotKeyAtPoint,
|
||||
} from "../utils/InventoryUtils";
|
||||
import type { Item, MeleeWeaponItem, RangedWeaponItem, ArmourItem, ConsumableItem } from "../../core/types";
|
||||
|
||||
describe("InventoryUtils", () => {
|
||||
describe("getEquipmentSlotSize", () => {
|
||||
it("returns 58 for bodyArmour", () => {
|
||||
expect(getEquipmentSlotSize("bodyArmour")).toBe(58);
|
||||
});
|
||||
|
||||
it("returns 32 for belt", () => {
|
||||
expect(getEquipmentSlotSize("belt")).toBe(32);
|
||||
});
|
||||
|
||||
it("returns 46 for boots", () => {
|
||||
expect(getEquipmentSlotSize("boots")).toBe(46);
|
||||
});
|
||||
|
||||
it("returns 38 for rings", () => {
|
||||
expect(getEquipmentSlotSize("ringLeft")).toBe(38);
|
||||
expect(getEquipmentSlotSize("ringRight")).toBe(38);
|
||||
});
|
||||
|
||||
it("returns 46 for helmet", () => {
|
||||
expect(getEquipmentSlotSize("helmet")).toBe(46);
|
||||
});
|
||||
|
||||
it("returns 46 for unknown slot keys", () => {
|
||||
expect(getEquipmentSlotSize("unknownSlot")).toBe(46);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isItemUpgradeable", () => {
|
||||
it("returns true for Weapon items", () => {
|
||||
const sword: MeleeWeaponItem = {
|
||||
id: "sword",
|
||||
name: "Sword",
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 5 },
|
||||
};
|
||||
expect(isItemUpgradeable(sword)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for BodyArmour items", () => {
|
||||
const armour: ArmourItem = {
|
||||
id: "armour",
|
||||
name: "Plate Armour",
|
||||
type: "BodyArmour",
|
||||
textureKey: "items",
|
||||
spriteIndex: 1,
|
||||
stats: { defense: 10 },
|
||||
};
|
||||
expect(isItemUpgradeable(armour)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Helmet items", () => {
|
||||
const helmet: ArmourItem = {
|
||||
id: "helmet",
|
||||
name: "Iron Helmet",
|
||||
type: "Helmet",
|
||||
textureKey: "items",
|
||||
spriteIndex: 2,
|
||||
stats: { defense: 3 },
|
||||
};
|
||||
expect(isItemUpgradeable(helmet)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Gloves items", () => {
|
||||
const gloves: ArmourItem = {
|
||||
id: "gloves",
|
||||
name: "Leather Gloves",
|
||||
type: "Gloves",
|
||||
textureKey: "items",
|
||||
spriteIndex: 3,
|
||||
stats: { defense: 2 },
|
||||
};
|
||||
expect(isItemUpgradeable(gloves)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Boots items", () => {
|
||||
const boots: ArmourItem = {
|
||||
id: "boots",
|
||||
name: "Iron Boots",
|
||||
type: "Boots",
|
||||
textureKey: "items",
|
||||
spriteIndex: 4,
|
||||
stats: { defense: 4 },
|
||||
};
|
||||
expect(isItemUpgradeable(boots)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for Consumable items", () => {
|
||||
const potion: ConsumableItem = {
|
||||
id: "potion",
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 5,
|
||||
stats: { hp: 20 },
|
||||
};
|
||||
expect(isItemUpgradeable(potion)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for Ring items", () => {
|
||||
const ring: Item = {
|
||||
id: "ring",
|
||||
name: "Gold Ring",
|
||||
type: "Ring",
|
||||
textureKey: "items",
|
||||
spriteIndex: 6,
|
||||
};
|
||||
expect(isItemUpgradeable(ring)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompatibleEquipmentSlots", () => {
|
||||
it("returns mainHand and offHand for Weapon", () => {
|
||||
const sword: MeleeWeaponItem = {
|
||||
id: "sword",
|
||||
name: "Sword",
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 5 },
|
||||
};
|
||||
const slots = getCompatibleEquipmentSlots(sword);
|
||||
expect(slots).toContain("mainHand");
|
||||
expect(slots).toContain("offHand");
|
||||
});
|
||||
|
||||
it("returns bodyArmour for BodyArmour", () => {
|
||||
const armour: ArmourItem = {
|
||||
id: "armour",
|
||||
name: "Plate Armour",
|
||||
type: "BodyArmour",
|
||||
textureKey: "items",
|
||||
spriteIndex: 1,
|
||||
stats: { defense: 10 },
|
||||
};
|
||||
expect(getCompatibleEquipmentSlots(armour)).toEqual(["bodyArmour"]);
|
||||
});
|
||||
|
||||
it("returns ringLeft and ringRight for Ring", () => {
|
||||
const ring: Item = {
|
||||
id: "ring",
|
||||
name: "Gold Ring",
|
||||
type: "Ring",
|
||||
textureKey: "items",
|
||||
spriteIndex: 6,
|
||||
};
|
||||
const slots = getCompatibleEquipmentSlots(ring);
|
||||
expect(slots).toContain("ringLeft");
|
||||
expect(slots).toContain("ringRight");
|
||||
});
|
||||
|
||||
it("returns empty array for Consumable", () => {
|
||||
const potion: ConsumableItem = {
|
||||
id: "potion",
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 5,
|
||||
stats: { hp: 20 },
|
||||
};
|
||||
expect(getCompatibleEquipmentSlots(potion)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isItemCompatibleWithSlot", () => {
|
||||
const sword: MeleeWeaponItem = {
|
||||
id: "sword",
|
||||
name: "Sword",
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 5 },
|
||||
};
|
||||
|
||||
it("returns true for weapon in mainHand", () => {
|
||||
expect(isItemCompatibleWithSlot(sword, "mainHand")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for weapon in offHand", () => {
|
||||
expect(isItemCompatibleWithSlot(sword, "offHand")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for weapon in helmet slot", () => {
|
||||
expect(isItemCompatibleWithSlot(sword, "helmet")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown slot", () => {
|
||||
expect(isItemCompatibleWithSlot(sword, "unknownSlot")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTooltipStats", () => {
|
||||
it("formats attack stat", () => {
|
||||
const sword: MeleeWeaponItem = {
|
||||
id: "sword",
|
||||
name: "Sword",
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 5 },
|
||||
};
|
||||
expect(formatTooltipStats(sword)).toContain("Attack: +5");
|
||||
});
|
||||
|
||||
it("formats defensive stats for armour", () => {
|
||||
const armour: ArmourItem = {
|
||||
id: "armour",
|
||||
name: "Plate Armour",
|
||||
type: "BodyArmour",
|
||||
textureKey: "items",
|
||||
spriteIndex: 1,
|
||||
stats: { defense: 10 },
|
||||
};
|
||||
expect(formatTooltipStats(armour)).toContain("Defense: +10");
|
||||
});
|
||||
|
||||
it("formats consumable heal amount specially", () => {
|
||||
const potion: ConsumableItem = {
|
||||
id: "potion",
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 5,
|
||||
stats: { hp: 20 },
|
||||
};
|
||||
expect(formatTooltipStats(potion)).toEqual(["Heals 20 HP"]);
|
||||
});
|
||||
|
||||
it("returns empty array for items without stats", () => {
|
||||
const ring: Item = {
|
||||
id: "ring",
|
||||
name: "Gold Ring",
|
||||
type: "Ring",
|
||||
textureKey: "items",
|
||||
spriteIndex: 6,
|
||||
};
|
||||
expect(formatTooltipStats(ring)).toEqual([]);
|
||||
});
|
||||
|
||||
it("formats range stat for ranged weapons", () => {
|
||||
const pistol: RangedWeaponItem = {
|
||||
id: "pistol",
|
||||
name: "Pistol",
|
||||
type: "Weapon",
|
||||
weaponType: "ranged",
|
||||
textureKey: "items",
|
||||
spriteIndex: 7,
|
||||
currentAmmo: 6,
|
||||
stats: {
|
||||
attack: 8,
|
||||
range: 5,
|
||||
magazineSize: 6,
|
||||
ammoType: "9mm",
|
||||
projectileSpeed: 10,
|
||||
},
|
||||
};
|
||||
const lines = formatTooltipStats(pistol);
|
||||
expect(lines).toContain("Attack: +8");
|
||||
expect(lines).toContain("Range: 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getItemLabelText", () => {
|
||||
it("returns quantity for stackable items", () => {
|
||||
const potion: ConsumableItem = {
|
||||
id: "potion",
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 5,
|
||||
stackable: true,
|
||||
quantity: 5,
|
||||
};
|
||||
expect(getItemLabelText(potion)).toBe("x5");
|
||||
});
|
||||
|
||||
it("returns x1 for stackable items with no quantity", () => {
|
||||
const potion: ConsumableItem = {
|
||||
id: "potion",
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 5,
|
||||
stackable: true,
|
||||
};
|
||||
expect(getItemLabelText(potion)).toBe("x1");
|
||||
});
|
||||
|
||||
it("returns ammo count for ranged weapons", () => {
|
||||
const pistol: RangedWeaponItem = {
|
||||
id: "pistol",
|
||||
name: "Pistol",
|
||||
type: "Weapon",
|
||||
weaponType: "ranged",
|
||||
textureKey: "items",
|
||||
spriteIndex: 7,
|
||||
currentAmmo: 4,
|
||||
stats: {
|
||||
attack: 8,
|
||||
range: 5,
|
||||
magazineSize: 6,
|
||||
ammoType: "9mm",
|
||||
projectileSpeed: 10,
|
||||
},
|
||||
};
|
||||
expect(getItemLabelText(pistol)).toBe("4/6");
|
||||
});
|
||||
|
||||
it("returns empty string for non-stackable melee weapon", () => {
|
||||
const sword: MeleeWeaponItem = {
|
||||
id: "sword",
|
||||
name: "Sword",
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 5 },
|
||||
};
|
||||
expect(getItemLabelText(sword)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPointInSlot", () => {
|
||||
it("returns true when point is at center of slot", () => {
|
||||
expect(isPointInSlot(100, 100, 100, 100, 44)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when point is at edge of slot", () => {
|
||||
expect(isPointInSlot(122, 100, 100, 100, 44)).toBe(true);
|
||||
expect(isPointInSlot(78, 100, 100, 100, 44)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when point is outside slot", () => {
|
||||
expect(isPointInSlot(130, 100, 100, 100, 44)).toBe(false);
|
||||
expect(isPointInSlot(70, 100, 100, 100, 44)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles different slot sizes", () => {
|
||||
// bodyArmour is 58px
|
||||
expect(isPointInSlot(129, 100, 100, 100, 58)).toBe(true);
|
||||
expect(isPointInSlot(130, 100, 100, 100, 58)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackpackSlotIndexAtPoint", () => {
|
||||
const slots = [
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 50 },
|
||||
{ x: 50, y: 100 },
|
||||
{ x: 100, y: 100 },
|
||||
];
|
||||
|
||||
it("returns correct index when point is in a slot", () => {
|
||||
expect(getBackpackSlotIndexAtPoint(50, 50, slots)).toBe(0);
|
||||
expect(getBackpackSlotIndexAtPoint(100, 50, slots)).toBe(1);
|
||||
expect(getBackpackSlotIndexAtPoint(50, 100, slots)).toBe(2);
|
||||
expect(getBackpackSlotIndexAtPoint(100, 100, slots)).toBe(3);
|
||||
});
|
||||
|
||||
it("returns null when point is not in any slot", () => {
|
||||
expect(getBackpackSlotIndexAtPoint(200, 200, slots)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEquipmentSlotKeyAtPoint", () => {
|
||||
const slots = new Map<string, { x: number; y: number }>([
|
||||
["helmet", { x: 100, y: 50 }],
|
||||
["bodyArmour", { x: 100, y: 100 }],
|
||||
["mainHand", { x: 50, y: 100 }],
|
||||
]);
|
||||
|
||||
it("returns correct key when point is in a slot", () => {
|
||||
expect(getEquipmentSlotKeyAtPoint(100, 50, slots)).toBe("helmet");
|
||||
expect(getEquipmentSlotKeyAtPoint(100, 100, slots)).toBe("bodyArmour");
|
||||
});
|
||||
|
||||
it("returns null when point is not in any slot", () => {
|
||||
expect(getEquipmentSlotKeyAtPoint(200, 200, slots)).toBe(null);
|
||||
});
|
||||
|
||||
it("respects different slot sizes", () => {
|
||||
// bodyArmour has size 58, helmet has size 46
|
||||
// At x=129, y=100, should hit bodyArmour (center 100, halfSize 29)
|
||||
expect(getEquipmentSlotKeyAtPoint(129, 100, slots)).toBe("bodyArmour");
|
||||
// At x=123, y=50, should NOT hit helmet (center 100, halfSize 23)
|
||||
expect(getEquipmentSlotKeyAtPoint(124, 50, slots)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,14 @@ import Phaser from "phaser";
|
||||
import { OverlayComponent } from "./OverlayComponent";
|
||||
import { type CombatantActor } from "../../core/types";
|
||||
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
|
||||
import {
|
||||
getEquipmentSlotSize,
|
||||
isItemUpgradeable,
|
||||
isItemCompatibleWithSlot,
|
||||
formatTooltipStats,
|
||||
getItemLabelText,
|
||||
} from "../utils/InventoryUtils";
|
||||
import { INVENTORY_CONSTANTS } from "../constants/InventoryConstants";
|
||||
|
||||
export class InventoryOverlay extends OverlayComponent {
|
||||
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
|
||||
@@ -9,6 +17,12 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||
private draggedItemIndex: number | null = null;
|
||||
private draggedEquipmentKey: string | null = null;
|
||||
private cachedPlayer: CombatantActor | null = null; // Cache player for local methods
|
||||
|
||||
// Upgrade Mode
|
||||
public isUpgradeMode = false;
|
||||
private onUpgradeSelect?: (item: any) => void;
|
||||
|
||||
private tooltip: Phaser.GameObjects.Container | null = null;
|
||||
private tooltipName: Phaser.GameObjects.Text | null = null;
|
||||
private tooltipStats: Phaser.GameObjects.Text | null = null;
|
||||
@@ -35,6 +49,23 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
this.createEquipmentPanel();
|
||||
this.createBackpackPanel();
|
||||
this.createTooltip();
|
||||
|
||||
// Global input listener to cancel upgrade mode on click outside
|
||||
this.scene.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
// Only check if visible and in upgrade mode
|
||||
if (this.container.visible && this.isUpgradeMode) {
|
||||
this.handleUpgradeClick(pointer);
|
||||
|
||||
// If clicking outside both panels, cancel
|
||||
const overBackpack = this.getBackpackSlotAt(pointer.x, pointer.y) !== null;
|
||||
const overEquip = this.getEquipmentSlotAt(pointer.x, pointer.y) !== null;
|
||||
|
||||
if (!overBackpack && !overEquip) {
|
||||
console.log("Clicked outside - cancelling (DEBUG: DISABLED to fix interaction)");
|
||||
// this.cancelUpgradeMode();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private createTooltip() {
|
||||
@@ -68,27 +99,7 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
|
||||
this.tooltipName.setText(item.name.toUpperCase());
|
||||
|
||||
let statsText = "";
|
||||
if (item.stats) {
|
||||
const stats = item.stats;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (stats.attack) lines.push(`Attack: +${stats.attack}`);
|
||||
if (stats.defense) lines.push(`Defense: +${stats.defense}`);
|
||||
if (stats.maxHp) lines.push(`HP: +${stats.maxHp}`);
|
||||
if (stats.maxMana) lines.push(`Mana: +${stats.maxMana}`);
|
||||
if (stats.critChance) lines.push(`Crit Chance: +${stats.critChance}%`);
|
||||
if (stats.accuracy) lines.push(`Accuracy: +${stats.accuracy}%`);
|
||||
if (stats.evasion) lines.push(`Evasion: +${stats.evasion}%`);
|
||||
if (stats.blockChance) lines.push(`Block Chance: +${stats.blockChance}%`);
|
||||
if (stats.range) lines.push(`Range: ${stats.range}`);
|
||||
|
||||
statsText = lines.join("\n");
|
||||
}
|
||||
|
||||
if (item.type === "Consumable" && item.stats?.hp) {
|
||||
statsText = `Heals ${item.stats.hp} HP`;
|
||||
}
|
||||
const statsText = formatTooltipStats(item).join("\n");
|
||||
|
||||
this.tooltipStats.setText(statsText);
|
||||
|
||||
@@ -309,6 +320,7 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
}
|
||||
|
||||
update(player: CombatantActor) {
|
||||
this.cachedPlayer = player;
|
||||
if (!player.inventory) return;
|
||||
|
||||
// Clear existing items from backpack slots
|
||||
@@ -336,15 +348,10 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
slot.add(itemContainer);
|
||||
|
||||
// Add Count Label (Bottom-Right)
|
||||
let labelText = "";
|
||||
if (item.stackable) {
|
||||
labelText = `x${item.quantity || 1}`;
|
||||
} else if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
|
||||
labelText = `${item.currentAmmo}/${item.stats.magazineSize}`;
|
||||
}
|
||||
const labelText = getItemLabelText(item);
|
||||
|
||||
if (labelText) {
|
||||
const slotSize = 44;
|
||||
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
|
||||
const display = this.scene.add.text(slotSize / 2 - 3, slotSize / 2 - 3, labelText, {
|
||||
fontSize: "11px",
|
||||
color: "#ffffff",
|
||||
@@ -361,8 +368,25 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
|
||||
this.scene.input.setDraggable(slot);
|
||||
|
||||
slot.on("pointerdown", () => {
|
||||
console.log("Clicked item:", item);
|
||||
// Clear previous listeners to avoid accumulation
|
||||
slot.removeAllListeners("pointerdown");
|
||||
slot.removeAllListeners("pointerover");
|
||||
slot.removeAllListeners("pointerout");
|
||||
|
||||
slot.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||
if (this.isUpgradeMode && this.onUpgradeSelect) {
|
||||
if (item && isItemUpgradeable(item)) {
|
||||
this.onUpgradeSelect(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Right click to use item
|
||||
if (pointer.rightButtonDown()) {
|
||||
const gameScene = this.scene.scene.get("GameScene");
|
||||
gameScene.events.emit("use-item", { itemId: item.id });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||
@@ -386,11 +410,24 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
slot.add(itemContainer);
|
||||
|
||||
// Add interactivity
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
const size = getEquipmentSlotSize(key);
|
||||
slot.setInteractive(new Phaser.Geom.Rectangle(-size/2, -size/2, size, size), Phaser.Geom.Rectangle.Contains);
|
||||
slot.setData("equipmentKey", key);
|
||||
this.scene.input.setDraggable(slot);
|
||||
|
||||
// Clear previous listeners
|
||||
slot.removeAllListeners("pointerdown");
|
||||
slot.removeAllListeners("pointerover");
|
||||
slot.removeAllListeners("pointerout");
|
||||
|
||||
slot.on("pointerdown", () => {
|
||||
if (this.isUpgradeMode && this.onUpgradeSelect) {
|
||||
// All equipped items in valid slots are upgradeable by definition
|
||||
this.onUpgradeSelect(item);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||
this.showTooltip(item, pointer.x, pointer.y);
|
||||
});
|
||||
@@ -408,22 +445,13 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
if (!item || !item.type) return;
|
||||
|
||||
this.equipmentSlots.forEach((container, key) => {
|
||||
let compatible = false;
|
||||
|
||||
// Simple type compatibility check
|
||||
if (item.type === "Weapon" && (key === "mainHand" || key === "offHand")) compatible = true;
|
||||
else if (item.type === "BodyArmour" && key === "bodyArmour") compatible = true;
|
||||
else if (item.type === "Helmet" && key === "helmet") compatible = true;
|
||||
else if (item.type === "Boots" && key === "boots") compatible = true;
|
||||
else if (item.type === "Ring" && (key === "ringLeft" || key === "ringRight")) compatible = true;
|
||||
else if (item.type === "Belt" && key === "belt") compatible = true;
|
||||
else if (item.type === "Offhand" && key === "offHand") compatible = true;
|
||||
const compatible = isItemCompatibleWithSlot(item, key);
|
||||
|
||||
if (compatible) {
|
||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (graphics) {
|
||||
graphics.clear();
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
const size = getEquipmentSlotSize(key);
|
||||
|
||||
// Glowing border
|
||||
graphics.lineStyle(4, 0xffd700, 1);
|
||||
@@ -440,13 +468,14 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
}
|
||||
|
||||
private clearHighlights() {
|
||||
// Reset Equipment Slots
|
||||
this.equipmentSlots.forEach((container, key) => {
|
||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (graphics) {
|
||||
graphics.clear();
|
||||
const slotBorder = 0xd4af37;
|
||||
const slotBg = 0x3a2a2a;
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
const size = getEquipmentSlotSize(key);
|
||||
|
||||
graphics.lineStyle(2, slotBorder, 1);
|
||||
graphics.strokeRect(-size / 2, -size / 2, size, size);
|
||||
@@ -456,12 +485,135 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
|
||||
graphics.fillStyle(slotBg, 1);
|
||||
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
||||
|
||||
// Allow interactions again if they were disabled (though we don't disable them currently)
|
||||
container.setAlpha(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset Backpack Slots
|
||||
this.backpackSlots.forEach(container => {
|
||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (graphics) {
|
||||
graphics.clear();
|
||||
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
|
||||
const { SLOT_BORDER, SLOT_INNER_BORDER, BACKPACK_BG } = INVENTORY_CONSTANTS.COLORS;
|
||||
|
||||
graphics.lineStyle(2, SLOT_BORDER, 1);
|
||||
graphics.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
|
||||
|
||||
graphics.lineStyle(1, SLOT_INNER_BORDER, 1);
|
||||
graphics.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
|
||||
|
||||
graphics.fillStyle(BACKPACK_BG, 1);
|
||||
graphics.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
|
||||
|
||||
container.setAlpha(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters upgrade mode, highlighting upgradeable items.
|
||||
*/
|
||||
enterUpgradeMode(onSelect: (item: any) => void) {
|
||||
this.isUpgradeMode = true;
|
||||
this.onUpgradeSelect = onSelect;
|
||||
|
||||
// Highlight all upgradeable items
|
||||
this.highlightUpgradeableItems();
|
||||
}
|
||||
|
||||
cancelUpgradeMode() {
|
||||
this.isUpgradeMode = false;
|
||||
this.onUpgradeSelect = undefined;
|
||||
this.clearHighlights();
|
||||
}
|
||||
|
||||
private highlightUpgradeableItems() {
|
||||
if (!this.cachedPlayer || !this.cachedPlayer.inventory) return;
|
||||
|
||||
// Green highlight for upgradeable items
|
||||
this.backpackSlots.forEach((slot, index) => {
|
||||
const item = this.cachedPlayer!.inventory!.items[index];
|
||||
|
||||
if (item && isItemUpgradeable(item)) {
|
||||
this.drawSlotHighlight(slot, 44);
|
||||
} else {
|
||||
this.drawSlotDim(slot);
|
||||
}
|
||||
});
|
||||
|
||||
this.equipmentSlots.forEach((slot, key) => {
|
||||
const item = (this.cachedPlayer!.equipment as any)?.[key];
|
||||
const size = getEquipmentSlotSize(key);
|
||||
|
||||
if (item) {
|
||||
this.drawSlotHighlight(slot, size);
|
||||
} else {
|
||||
this.drawSlotDim(slot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private drawSlotHighlight(slot: Phaser.GameObjects.Container, size: number) {
|
||||
const g = slot.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (g) {
|
||||
g.clear();
|
||||
// Highlight border
|
||||
g.lineStyle(2, 0x00ff00, 1);
|
||||
g.strokeRect(-size / 2, -size / 2, size, size);
|
||||
|
||||
g.lineStyle(1, 0x00aa00, 1);
|
||||
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
|
||||
|
||||
g.fillStyle(0x1a2f1a, 1);
|
||||
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
||||
}
|
||||
}
|
||||
|
||||
private drawSlotDim(slot: Phaser.GameObjects.Container) {
|
||||
const g = slot.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (g) {
|
||||
g.setAlpha(0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle clicks for upgrade selection
|
||||
private handleUpgradeClick(pointer: Phaser.Input.Pointer) {
|
||||
if (!this.isUpgradeMode || !this.onUpgradeSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check backpack
|
||||
const backpackIndex = this.getBackpackSlotAt(pointer.x, pointer.y);
|
||||
if (backpackIndex !== null && this.cachedPlayer && this.cachedPlayer.inventory) {
|
||||
const item = this.cachedPlayer.inventory.items[backpackIndex];
|
||||
// Reuse eligibility check
|
||||
if (item && isItemUpgradeable(item)) {
|
||||
this.onUpgradeSelect(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check equipment
|
||||
const equipSlot = this.getEquipmentSlotAt(pointer.x, pointer.y);
|
||||
if (equipSlot !== null && this.cachedPlayer && this.cachedPlayer.equipment) {
|
||||
const item = (this.cachedPlayer.equipment as any)[equipSlot];
|
||||
if (item) { // All equipped items are upgradeable types
|
||||
this.onUpgradeSelect(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private setupDragEvents() {
|
||||
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
|
||||
// Handle Upgrade Mode clicks (prevent drag)
|
||||
if (this.isUpgradeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||
const player = gameScene.world.actors.get(gameScene.playerId);
|
||||
if (!player) return;
|
||||
@@ -601,7 +753,7 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
const localY = y - this.container.y;
|
||||
|
||||
for (const [key, slot] of this.equipmentSlots.entries()) {
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
const size = getEquipmentSlotSize(key);
|
||||
const halfSize = size / 2;
|
||||
const dx = localX - slot.x;
|
||||
const dy = localY - slot.y;
|
||||
@@ -620,7 +772,7 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
|
||||
for (let i = 0; i < this.backpackSlots.length; i++) {
|
||||
const slot = this.backpackSlots[i];
|
||||
const halfSize = 22; // slotSize 44 / 2
|
||||
const halfSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE / 2;
|
||||
const dx = localX - slot.x;
|
||||
const dy = localY - slot.y;
|
||||
|
||||
|
||||
81
src/ui/constants/InventoryConstants.ts
Normal file
81
src/ui/constants/InventoryConstants.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Constants for the Inventory UI.
|
||||
*/
|
||||
|
||||
export const INVENTORY_CONSTANTS = {
|
||||
// Backpack grid
|
||||
BACKPACK_SLOT_SIZE: 44,
|
||||
BACKPACK_ROWS: 6,
|
||||
BACKPACK_COLS: 4,
|
||||
SLOT_SPACING: 6,
|
||||
|
||||
// Equipment slot sizes
|
||||
EQUIPMENT_SLOT_SIZES: {
|
||||
bodyArmour: 58,
|
||||
belt: 32,
|
||||
boots: 46,
|
||||
ringLeft: 38,
|
||||
ringRight: 38,
|
||||
helmet: 46,
|
||||
mainHand: 46,
|
||||
offHand: 46,
|
||||
gloves: 46,
|
||||
amulet: 46,
|
||||
} as Record<string, number>,
|
||||
|
||||
// Panel dimensions
|
||||
PANEL_WIDTH: 680,
|
||||
PANEL_HEIGHT: 480,
|
||||
EQUIPMENT_PANEL_WIDTH: 260,
|
||||
EQUIPMENT_PANEL_HEIGHT: 360,
|
||||
BACKPACK_PANEL_WIDTH: 240,
|
||||
BACKPACK_PANEL_HEIGHT: 360,
|
||||
|
||||
// Colors
|
||||
COLORS: {
|
||||
SLOT_BORDER: 0xd4af37,
|
||||
SLOT_INNER_BORDER: 0x8b7355,
|
||||
SLOT_BG: 0x3a2a2a,
|
||||
BACKPACK_BG: 0x1a0f1a,
|
||||
PANEL_BG: 0x2a1f2a,
|
||||
HIGHLIGHT: 0xffd700,
|
||||
UPGRADE_HIGHLIGHT: 0x00ff00,
|
||||
UPGRADE_INNER: 0x00aa00,
|
||||
UPGRADE_BG: 0x1a2f1a,
|
||||
COMPATIBLE_BG: 0x4a3a3a,
|
||||
TITLE_COLOR: "#d4af37",
|
||||
TOOLTIP_BG: 0x1a0f1a,
|
||||
},
|
||||
|
||||
// Positions (relative to center)
|
||||
EQUIPMENT_PANEL_X: -165,
|
||||
BACKPACK_PANEL_X: 175,
|
||||
PANEL_Y: 40,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Equipment slot keys and their compatible item types.
|
||||
*/
|
||||
export const EQUIPMENT_SLOT_COMPATIBILITY: Record<string, string[]> = {
|
||||
mainHand: ["Weapon"],
|
||||
offHand: ["Weapon", "Offhand"],
|
||||
bodyArmour: ["BodyArmour"],
|
||||
helmet: ["Helmet"],
|
||||
boots: ["Boots"],
|
||||
gloves: ["Gloves"],
|
||||
ringLeft: ["Ring"],
|
||||
ringRight: ["Ring"],
|
||||
belt: ["Belt"],
|
||||
amulet: ["Amulet"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Item types that are considered upgradeable.
|
||||
*/
|
||||
export const UPGRADEABLE_ITEM_TYPES = [
|
||||
"Weapon",
|
||||
"BodyArmour",
|
||||
"Helmet",
|
||||
"Gloves",
|
||||
"Boots",
|
||||
] as const;
|
||||
149
src/ui/utils/InventoryUtils.ts
Normal file
149
src/ui/utils/InventoryUtils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Pure utility functions for inventory operations.
|
||||
* These functions have no Phaser dependencies and are fully testable.
|
||||
*/
|
||||
|
||||
import type { Item } from "../../core/types";
|
||||
import {
|
||||
INVENTORY_CONSTANTS,
|
||||
EQUIPMENT_SLOT_COMPATIBILITY,
|
||||
UPGRADEABLE_ITEM_TYPES,
|
||||
} from "../constants/InventoryConstants";
|
||||
|
||||
/**
|
||||
* Get the pixel size for an equipment slot based on its key.
|
||||
*/
|
||||
export function getEquipmentSlotSize(key: string): number {
|
||||
return INVENTORY_CONSTANTS.EQUIPMENT_SLOT_SIZES[key] ?? 46;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item type is upgradeable.
|
||||
*/
|
||||
export function isItemUpgradeable(item: Item): boolean {
|
||||
return UPGRADEABLE_ITEM_TYPES.includes(item.type as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of compatible equipment slot keys for an item.
|
||||
*/
|
||||
export function getCompatibleEquipmentSlots(item: Item): string[] {
|
||||
const compatibleSlots: string[] = [];
|
||||
|
||||
for (const [slotKey, validTypes] of Object.entries(EQUIPMENT_SLOT_COMPATIBILITY)) {
|
||||
if (validTypes.includes(item.type)) {
|
||||
compatibleSlots.push(slotKey);
|
||||
}
|
||||
}
|
||||
|
||||
return compatibleSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item type is compatible with a specific equipment slot.
|
||||
*/
|
||||
export function isItemCompatibleWithSlot(item: Item, slotKey: string): boolean {
|
||||
const validTypes = EQUIPMENT_SLOT_COMPATIBILITY[slotKey];
|
||||
return validTypes ? validTypes.includes(item.type) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format item stats for tooltip display.
|
||||
* Returns an array of stat lines.
|
||||
*/
|
||||
export function formatTooltipStats(item: Item): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Handle consumables specially
|
||||
if (item.type === "Consumable" && item.stats?.hp) {
|
||||
return [`Heals ${item.stats.hp} HP`];
|
||||
}
|
||||
|
||||
if (!("stats" in item) || !item.stats) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const stats = item.stats as Record<string, number | undefined>;
|
||||
|
||||
if (stats.attack) lines.push(`Attack: +${stats.attack}`);
|
||||
if (stats.defense) lines.push(`Defense: +${stats.defense}`);
|
||||
if (stats.maxHp) lines.push(`HP: +${stats.maxHp}`);
|
||||
if (stats.maxMana) lines.push(`Mana: +${stats.maxMana}`);
|
||||
if (stats.critChance) lines.push(`Crit Chance: +${stats.critChance}%`);
|
||||
if (stats.accuracy) lines.push(`Accuracy: +${stats.accuracy}%`);
|
||||
if (stats.evasion) lines.push(`Evasion: +${stats.evasion}%`);
|
||||
if (stats.blockChance) lines.push(`Block Chance: +${stats.blockChance}%`);
|
||||
if (stats.range) lines.push(`Range: ${stats.range}`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display label for an item (quantity or ammo count).
|
||||
*/
|
||||
export function getItemLabelText(item: Item): string {
|
||||
if (item.stackable) {
|
||||
return `x${item.quantity || 1}`;
|
||||
}
|
||||
|
||||
if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
|
||||
return `${item.currentAmmo}/${item.stats.magazineSize}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point (in local coordinates) is within a slot's bounds.
|
||||
*/
|
||||
export function isPointInSlot(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
slotX: number,
|
||||
slotY: number,
|
||||
slotSize: number
|
||||
): boolean {
|
||||
const halfSize = slotSize / 2;
|
||||
const dx = pointX - slotX;
|
||||
const dy = pointY - slotY;
|
||||
|
||||
return dx >= -halfSize && dx <= halfSize && dy >= -halfSize && dy <= halfSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate which backpack slot index a point falls within.
|
||||
* Returns null if not within any slot.
|
||||
*/
|
||||
export function getBackpackSlotIndexAtPoint(
|
||||
localX: number,
|
||||
localY: number,
|
||||
slots: Array<{ x: number; y: number }>
|
||||
): number | null {
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i];
|
||||
if (isPointInSlot(localX, localY, slot.x, slot.y, INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate which equipment slot key a point falls within.
|
||||
* Returns null if not within any slot.
|
||||
*/
|
||||
export function getEquipmentSlotKeyAtPoint(
|
||||
localX: number,
|
||||
localY: number,
|
||||
slots: Map<string, { x: number; y: number }>
|
||||
): string | null {
|
||||
for (const [key, slot] of slots.entries()) {
|
||||
const size = getEquipmentSlotSize(key);
|
||||
if (isPointInSlot(localX, localY, slot.x, slot.y, size)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user