refactor inventory overlay

This commit is contained in:
Peter Stockings
2026-01-23 23:41:29 +11:00
parent c415becc38
commit 18d4f0cdd4
4 changed files with 672 additions and 68 deletions

View 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);
});
});
});

View File

@@ -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,8 +17,6 @@ export class InventoryOverlay extends OverlayComponent {
private dragIcon: Phaser.GameObjects.Sprite | null = null;
private draggedItemIndex: number | null = null;
private draggedEquipmentKey: string | null = null;
private isDragging = false;
private dragPayload: any = null;
private cachedPlayer: CombatantActor | null = null; // Cache player for local methods
// Upgrade Mode
@@ -93,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);
@@ -362,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",
@@ -394,7 +375,7 @@ export class InventoryOverlay extends OverlayComponent {
slot.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
if (this.isUpgradeMode && this.onUpgradeSelect) {
if (item && (item.type === "Weapon" || item.type === "BodyArmour" || item.type === "Helmet" || item.type === "Gloves" || item.type === "Boots")) {
if (item && isItemUpgradeable(item)) {
this.onUpgradeSelect(item);
}
return;
@@ -429,7 +410,7 @@ 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);
@@ -464,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);
@@ -503,7 +475,7 @@ export class InventoryOverlay extends OverlayComponent {
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);
@@ -524,17 +496,16 @@ export class InventoryOverlay extends OverlayComponent {
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) {
graphics.clear();
const slotSize = 44;
const slotBorder = 0xd4af37;
const slotBg = 0x1a0f1a; // Darker bg for backpack
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
const { SLOT_BORDER, SLOT_INNER_BORDER, BACKPACK_BG } = INVENTORY_CONSTANTS.COLORS;
graphics.lineStyle(2, slotBorder, 1);
graphics.lineStyle(2, SLOT_BORDER, 1);
graphics.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
graphics.lineStyle(1, 0x8b7355, 1);
graphics.lineStyle(1, SLOT_INNER_BORDER, 1);
graphics.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
graphics.fillStyle(slotBg, 1);
graphics.fillStyle(BACKPACK_BG, 1);
graphics.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
container.setAlpha(1);
@@ -565,33 +536,27 @@ export class InventoryOverlay extends OverlayComponent {
// Green highlight for upgradeable items
this.backpackSlots.forEach((slot, index) => {
const item = this.cachedPlayer!.inventory!.items[index];
let bg = slot.list.find(c => c instanceof Phaser.GameObjects.Graphics) as Phaser.GameObjects.Graphics;
// In this complex container setup, the bg is the first child of the slot container's first child
// Actually looking at createBackpackPanel, the slot IS a container with [Graphics] in it.
// The Graphics draws border and bg.
// We should clear and redraw highlighted
if (item && (item.type === "Weapon" || item.type === "BodyArmour" || item.type === "Helmet" || item.type === "Gloves" || item.type === "Boots")) {
this.drawSlotHighlight(slot, true, 44);
if (item && isItemUpgradeable(item)) {
this.drawSlotHighlight(slot, 44);
} else {
this.drawSlotDim(slot, 44);
this.drawSlotDim(slot);
}
});
this.equipmentSlots.forEach((slot, key) => {
const item = (this.cachedPlayer!.equipment as any)?.[key];
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
const size = getEquipmentSlotSize(key);
if (item) {
this.drawSlotHighlight(slot, true, size);
this.drawSlotHighlight(slot, size);
} else {
this.drawSlotDim(slot, size);
this.drawSlotDim(slot);
}
});
}
private drawSlotHighlight(slot: Phaser.GameObjects.Container, active: boolean, size: number) {
private drawSlotHighlight(slot: Phaser.GameObjects.Container, size: number) {
const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) {
g.clear();
@@ -607,7 +572,7 @@ export class InventoryOverlay extends OverlayComponent {
}
}
private drawSlotDim(slot: Phaser.GameObjects.Container, size: number) {
private drawSlotDim(slot: Phaser.GameObjects.Container) {
const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) {
g.setAlpha(0.3);
@@ -625,7 +590,7 @@ export class InventoryOverlay extends OverlayComponent {
if (backpackIndex !== null && this.cachedPlayer && this.cachedPlayer.inventory) {
const item = this.cachedPlayer.inventory.items[backpackIndex];
// Reuse eligibility check
if (item && (item.type === "Weapon" || item.type === "BodyArmour" || item.type === "Helmet" || item.type === "Gloves" || item.type === "Boots")) {
if (item && isItemUpgradeable(item)) {
this.onUpgradeSelect(item);
}
return;
@@ -788,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;
@@ -807,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;

View 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;

View 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;
}