Compare commits
2 Commits
e130e6d174
...
18d4f0cdd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d4f0cdd4 | ||
|
|
c415becc38 |
@@ -26,6 +26,12 @@ export const CONSUMABLES = {
|
|||||||
throwable: true,
|
throwable: true,
|
||||||
stackable: true,
|
stackable: true,
|
||||||
},
|
},
|
||||||
|
upgrade_scroll: {
|
||||||
|
name: "Upgrade Scroll",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 79,
|
||||||
|
stackable: true,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const RANGED_WEAPONS = {
|
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
|
// Legacy export for backward compatibility during migration
|
||||||
export const ITEMS = ALL_TEMPLATES;
|
export const ITEMS = ALL_TEMPLATES;
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface BaseItem {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
stackable?: boolean;
|
stackable?: boolean;
|
||||||
variant?: string; // ItemVariantId - stored as string to avoid circular imports
|
variant?: string; // ItemVariantId - stored as string to avoid circular imports
|
||||||
|
upgradeLevel?: number; // Enhancement level (+1, +2, etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeleeWeaponItem extends BaseItem {
|
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,
|
createConsumable,
|
||||||
createMeleeWeapon,
|
createMeleeWeapon,
|
||||||
createRangedWeapon,
|
createRangedWeapon,
|
||||||
createArmour
|
createArmour,
|
||||||
|
createUpgradeScroll
|
||||||
} from "../../core/config/Items";
|
} from "../../core/config/Items";
|
||||||
import { seededRandom } from "../../core/math";
|
import { seededRandom } from "../../core/math";
|
||||||
import * as ROT from "rot-js";
|
import * as ROT from "rot-js";
|
||||||
@@ -62,7 +63,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
|
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
|
||||||
createConsumable("throwing_dagger", 3),
|
createConsumable("throwing_dagger", 3),
|
||||||
createRangedWeapon("pistol"),
|
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);
|
sprite.setScale(scale);
|
||||||
container.add(sprite);
|
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;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +115,31 @@ export class ItemSpriteFactory {
|
|||||||
return variant?.glowColor ?? null;
|
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.
|
* 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 { CameraController } from "./systems/CameraController";
|
||||||
import { ItemManager } from "./systems/ItemManager";
|
import { ItemManager } from "./systems/ItemManager";
|
||||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||||
|
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
||||||
|
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
private world!: World;
|
||||||
@@ -229,6 +231,42 @@ export class GameScene extends Phaser.Scene {
|
|||||||
return;
|
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);
|
const result = this.itemManager.handleUse(data.itemId, player);
|
||||||
|
|
||||||
if (result.success && result.consumed) {
|
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 { OverlayComponent } from "./OverlayComponent";
|
||||||
import { type CombatantActor } from "../../core/types";
|
import { type CombatantActor } from "../../core/types";
|
||||||
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
|
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 {
|
export class InventoryOverlay extends OverlayComponent {
|
||||||
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
|
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 dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||||
private draggedItemIndex: number | null = null;
|
private draggedItemIndex: number | null = null;
|
||||||
private draggedEquipmentKey: string | 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 tooltip: Phaser.GameObjects.Container | null = null;
|
||||||
private tooltipName: Phaser.GameObjects.Text | null = null;
|
private tooltipName: Phaser.GameObjects.Text | null = null;
|
||||||
private tooltipStats: Phaser.GameObjects.Text | null = null;
|
private tooltipStats: Phaser.GameObjects.Text | null = null;
|
||||||
@@ -35,6 +49,23 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
this.createEquipmentPanel();
|
this.createEquipmentPanel();
|
||||||
this.createBackpackPanel();
|
this.createBackpackPanel();
|
||||||
this.createTooltip();
|
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() {
|
private createTooltip() {
|
||||||
@@ -68,27 +99,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
|
|
||||||
this.tooltipName.setText(item.name.toUpperCase());
|
this.tooltipName.setText(item.name.toUpperCase());
|
||||||
|
|
||||||
let statsText = "";
|
const statsText = formatTooltipStats(item).join("\n");
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tooltipStats.setText(statsText);
|
this.tooltipStats.setText(statsText);
|
||||||
|
|
||||||
@@ -309,6 +320,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(player: CombatantActor) {
|
update(player: CombatantActor) {
|
||||||
|
this.cachedPlayer = player;
|
||||||
if (!player.inventory) return;
|
if (!player.inventory) return;
|
||||||
|
|
||||||
// Clear existing items from backpack slots
|
// Clear existing items from backpack slots
|
||||||
@@ -336,15 +348,10 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
slot.add(itemContainer);
|
slot.add(itemContainer);
|
||||||
|
|
||||||
// Add Count Label (Bottom-Right)
|
// Add Count Label (Bottom-Right)
|
||||||
let labelText = "";
|
const labelText = getItemLabelText(item);
|
||||||
if (item.stackable) {
|
|
||||||
labelText = `x${item.quantity || 1}`;
|
|
||||||
} else if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
|
|
||||||
labelText = `${item.currentAmmo}/${item.stats.magazineSize}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labelText) {
|
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, {
|
const display = this.scene.add.text(slotSize / 2 - 3, slotSize / 2 - 3, labelText, {
|
||||||
fontSize: "11px",
|
fontSize: "11px",
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
@@ -361,8 +368,25 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
|
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
|
||||||
this.scene.input.setDraggable(slot);
|
this.scene.input.setDraggable(slot);
|
||||||
|
|
||||||
slot.on("pointerdown", () => {
|
// Clear previous listeners to avoid accumulation
|
||||||
console.log("Clicked item:", item);
|
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) => {
|
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||||
@@ -386,11 +410,24 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
slot.add(itemContainer);
|
slot.add(itemContainer);
|
||||||
|
|
||||||
// Add interactivity
|
// 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.setInteractive(new Phaser.Geom.Rectangle(-size/2, -size/2, size, size), Phaser.Geom.Rectangle.Contains);
|
||||||
slot.setData("equipmentKey", key);
|
slot.setData("equipmentKey", key);
|
||||||
this.scene.input.setDraggable(slot);
|
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) => {
|
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||||
this.showTooltip(item, pointer.x, pointer.y);
|
this.showTooltip(item, pointer.x, pointer.y);
|
||||||
});
|
});
|
||||||
@@ -408,22 +445,13 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
if (!item || !item.type) return;
|
if (!item || !item.type) return;
|
||||||
|
|
||||||
this.equipmentSlots.forEach((container, key) => {
|
this.equipmentSlots.forEach((container, key) => {
|
||||||
let compatible = false;
|
const compatible = isItemCompatibleWithSlot(item, key);
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (compatible) {
|
if (compatible) {
|
||||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||||
if (graphics) {
|
if (graphics) {
|
||||||
graphics.clear();
|
graphics.clear();
|
||||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
const size = getEquipmentSlotSize(key);
|
||||||
|
|
||||||
// Glowing border
|
// Glowing border
|
||||||
graphics.lineStyle(4, 0xffd700, 1);
|
graphics.lineStyle(4, 0xffd700, 1);
|
||||||
@@ -440,13 +468,14 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private clearHighlights() {
|
private clearHighlights() {
|
||||||
|
// Reset Equipment Slots
|
||||||
this.equipmentSlots.forEach((container, key) => {
|
this.equipmentSlots.forEach((container, key) => {
|
||||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||||
if (graphics) {
|
if (graphics) {
|
||||||
graphics.clear();
|
graphics.clear();
|
||||||
const slotBorder = 0xd4af37;
|
const slotBorder = 0xd4af37;
|
||||||
const slotBg = 0x3a2a2a;
|
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.lineStyle(2, slotBorder, 1);
|
||||||
graphics.strokeRect(-size / 2, -size / 2, size, size);
|
graphics.strokeRect(-size / 2, -size / 2, size, size);
|
||||||
@@ -456,12 +485,135 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
|
|
||||||
graphics.fillStyle(slotBg, 1);
|
graphics.fillStyle(slotBg, 1);
|
||||||
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
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() {
|
private setupDragEvents() {
|
||||||
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
|
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 gameScene = this.scene.scene.get("GameScene") as any;
|
||||||
const player = gameScene.world.actors.get(gameScene.playerId);
|
const player = gameScene.world.actors.get(gameScene.playerId);
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
@@ -601,7 +753,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
const localY = y - this.container.y;
|
const localY = y - this.container.y;
|
||||||
|
|
||||||
for (const [key, slot] of this.equipmentSlots.entries()) {
|
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 halfSize = size / 2;
|
||||||
const dx = localX - slot.x;
|
const dx = localX - slot.x;
|
||||||
const dy = localY - slot.y;
|
const dy = localY - slot.y;
|
||||||
@@ -620,7 +772,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
|
|
||||||
for (let i = 0; i < this.backpackSlots.length; i++) {
|
for (let i = 0; i < this.backpackSlots.length; i++) {
|
||||||
const slot = this.backpackSlots[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 dx = localX - slot.x;
|
||||||
const dy = localY - slot.y;
|
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