refactor inventory overlay
This commit is contained in:
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,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;
|
||||
|
||||
|
||||
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