Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Stockings
18d4f0cdd4 refactor inventory overlay 2026-01-23 23:45:41 +11:00
Peter Stockings
c415becc38 feat: add upgrade scrolls 2026-01-23 23:26:55 +11:00
11 changed files with 1059 additions and 47 deletions

View File

@@ -26,6 +26,12 @@ export const CONSUMABLES = {
throwable: true,
stackable: true,
},
upgrade_scroll: {
name: "Upgrade Scroll",
textureKey: "items",
spriteIndex: 79,
stackable: true,
},
} as const;
export const RANGED_WEAPONS = {
@@ -224,6 +230,19 @@ export function createArmour(
};
}
export function createUpgradeScroll(quantity = 1): ConsumableItem {
const t = CONSUMABLES["upgrade_scroll"];
return {
id: "upgrade_scroll",
name: t.name,
type: "Consumable",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stackable: true,
quantity,
};
}
// Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES;

View File

@@ -86,6 +86,7 @@ export interface BaseItem {
quantity?: number;
stackable?: boolean;
variant?: string; // ItemVariantId - stored as string to avoid circular imports
upgradeLevel?: number; // Enhancement level (+1, +2, etc.)
}
export interface MeleeWeaponItem extends BaseItem {

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

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

View File

@@ -6,7 +6,8 @@ import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
createArmour
createArmour,
createUpgradeScroll
} from "../../core/config/Items";
import { seededRandom } from "../../core/math";
import * as ROT from "rot-js";
@@ -62,7 +63,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createArmour("leather_armor", "heavy") // Heavy armour variant
createArmour("leather_armor", "heavy"), // Heavy armour variant
createUpgradeScroll(2) // 2 Upgrade scrolls
] : [])
]
},

View File

@@ -36,6 +36,12 @@ export class ItemSpriteFactory {
sprite.setScale(scale);
container.add(sprite);
// Add upgrade level badge if item has been upgraded
if (item.upgradeLevel && item.upgradeLevel > 0) {
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
container.add(badge);
}
return container;
}
@@ -109,6 +115,31 @@ export class ItemSpriteFactory {
return variant?.glowColor ?? null;
}
/**
* Creates a badge displaying the upgrade level (e.g., "+1").
*/
private static createUpgradeBadge(
scene: Phaser.Scene,
level: number,
scale: number
): Phaser.GameObjects.Text {
// Position at top-right corner, slightly inset
const offset = 5 * scale;
// Level text with strong outline for readability without background
const text = scene.add.text(offset, -offset, `+${level}`, {
fontSize: `${9 * scale}px`,
color: "#ffd700",
fontStyle: "bold",
fontFamily: "monospace",
stroke: "#000000",
strokeThickness: 3
});
text.setOrigin(0.5);
return text;
}
/**
* Checks if an item has a variant with a glow.
*/

View File

@@ -22,6 +22,8 @@ import GameUI from "../ui/GameUI";
import { CameraController } from "./systems/CameraController";
import { ItemManager } from "./systems/ItemManager";
import { TargetingSystem } from "./systems/TargetingSystem";
import { UpgradeManager } from "../engine/systems/UpgradeManager";
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
export class GameScene extends Phaser.Scene {
private world!: World;
@@ -229,6 +231,42 @@ export class GameScene extends Phaser.Scene {
return;
}
// Upgrade Scroll Logic
if (item.id === "upgrade_scroll") {
const uiScene = this.scene.get("GameUI") as GameUI;
// Access the public inventory component
const inventoryOverlay = uiScene.inventory;
if (inventoryOverlay && inventoryOverlay instanceof InventoryOverlay) {
// Trigger upgrade mode
inventoryOverlay.enterUpgradeMode((targetItem: any) => {
const success = UpgradeManager.applyUpgrade(targetItem);
if (success) {
// Consume scroll logic handling stacking
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
if (scrollItem) {
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
scrollItem.quantity--;
} else {
this.itemManager.removeFromInventory(player, "upgrade_scroll");
}
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
}
inventoryOverlay.cancelUpgradeMode();
this.emitUIUpdate();
this.commitPlayerAction({ type: "wait" });
} else {
// Should technically be prevented by UI highlights, but safety check
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
}
});
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
}
return;
}
const result = this.itemManager.handleUse(data.itemId, player);
if (result.success && result.consumed) {

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,6 +17,12 @@ export class InventoryOverlay extends OverlayComponent {
private dragIcon: Phaser.GameObjects.Sprite | null = null;
private draggedItemIndex: number | null = null;
private draggedEquipmentKey: string | null = null;
private cachedPlayer: CombatantActor | null = null; // Cache player for local methods
// Upgrade Mode
public isUpgradeMode = false;
private onUpgradeSelect?: (item: any) => void;
private tooltip: Phaser.GameObjects.Container | null = null;
private tooltipName: Phaser.GameObjects.Text | null = null;
private tooltipStats: Phaser.GameObjects.Text | null = null;
@@ -35,6 +49,23 @@ export class InventoryOverlay extends OverlayComponent {
this.createEquipmentPanel();
this.createBackpackPanel();
this.createTooltip();
// Global input listener to cancel upgrade mode on click outside
this.scene.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
// Only check if visible and in upgrade mode
if (this.container.visible && this.isUpgradeMode) {
this.handleUpgradeClick(pointer);
// If clicking outside both panels, cancel
const overBackpack = this.getBackpackSlotAt(pointer.x, pointer.y) !== null;
const overEquip = this.getEquipmentSlotAt(pointer.x, pointer.y) !== null;
if (!overBackpack && !overEquip) {
console.log("Clicked outside - cancelling (DEBUG: DISABLED to fix interaction)");
// this.cancelUpgradeMode();
}
}
});
}
private createTooltip() {
@@ -68,27 +99,7 @@ export class InventoryOverlay extends OverlayComponent {
this.tooltipName.setText(item.name.toUpperCase());
let statsText = "";
if (item.stats) {
const stats = item.stats;
const lines: string[] = [];
if (stats.attack) lines.push(`Attack: +${stats.attack}`);
if (stats.defense) lines.push(`Defense: +${stats.defense}`);
if (stats.maxHp) lines.push(`HP: +${stats.maxHp}`);
if (stats.maxMana) lines.push(`Mana: +${stats.maxMana}`);
if (stats.critChance) lines.push(`Crit Chance: +${stats.critChance}%`);
if (stats.accuracy) lines.push(`Accuracy: +${stats.accuracy}%`);
if (stats.evasion) lines.push(`Evasion: +${stats.evasion}%`);
if (stats.blockChance) lines.push(`Block Chance: +${stats.blockChance}%`);
if (stats.range) lines.push(`Range: ${stats.range}`);
statsText = lines.join("\n");
}
if (item.type === "Consumable" && item.stats?.hp) {
statsText = `Heals ${item.stats.hp} HP`;
}
const statsText = formatTooltipStats(item).join("\n");
this.tooltipStats.setText(statsText);
@@ -309,6 +320,7 @@ export class InventoryOverlay extends OverlayComponent {
}
update(player: CombatantActor) {
this.cachedPlayer = player;
if (!player.inventory) return;
// Clear existing items from backpack slots
@@ -336,15 +348,10 @@ export class InventoryOverlay extends OverlayComponent {
slot.add(itemContainer);
// Add Count Label (Bottom-Right)
let labelText = "";
if (item.stackable) {
labelText = `x${item.quantity || 1}`;
} else if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
labelText = `${item.currentAmmo}/${item.stats.magazineSize}`;
}
const labelText = getItemLabelText(item);
if (labelText) {
const slotSize = 44;
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
const display = this.scene.add.text(slotSize / 2 - 3, slotSize / 2 - 3, labelText, {
fontSize: "11px",
color: "#ffffff",
@@ -361,8 +368,25 @@ export class InventoryOverlay extends OverlayComponent {
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
this.scene.input.setDraggable(slot);
slot.on("pointerdown", () => {
console.log("Clicked item:", item);
// Clear previous listeners to avoid accumulation
slot.removeAllListeners("pointerdown");
slot.removeAllListeners("pointerover");
slot.removeAllListeners("pointerout");
slot.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
if (this.isUpgradeMode && this.onUpgradeSelect) {
if (item && isItemUpgradeable(item)) {
this.onUpgradeSelect(item);
}
return;
}
// Right click to use item
if (pointer.rightButtonDown()) {
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("use-item", { itemId: item.id });
return;
}
});
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
@@ -386,11 +410,24 @@ export class InventoryOverlay extends OverlayComponent {
slot.add(itemContainer);
// Add interactivity
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
const size = getEquipmentSlotSize(key);
slot.setInteractive(new Phaser.Geom.Rectangle(-size/2, -size/2, size, size), Phaser.Geom.Rectangle.Contains);
slot.setData("equipmentKey", key);
this.scene.input.setDraggable(slot);
// Clear previous listeners
slot.removeAllListeners("pointerdown");
slot.removeAllListeners("pointerover");
slot.removeAllListeners("pointerout");
slot.on("pointerdown", () => {
if (this.isUpgradeMode && this.onUpgradeSelect) {
// All equipped items in valid slots are upgradeable by definition
this.onUpgradeSelect(item);
return;
}
});
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
this.showTooltip(item, pointer.x, pointer.y);
});
@@ -408,22 +445,13 @@ export class InventoryOverlay extends OverlayComponent {
if (!item || !item.type) return;
this.equipmentSlots.forEach((container, key) => {
let compatible = false;
// Simple type compatibility check
if (item.type === "Weapon" && (key === "mainHand" || key === "offHand")) compatible = true;
else if (item.type === "BodyArmour" && key === "bodyArmour") compatible = true;
else if (item.type === "Helmet" && key === "helmet") compatible = true;
else if (item.type === "Boots" && key === "boots") compatible = true;
else if (item.type === "Ring" && (key === "ringLeft" || key === "ringRight")) compatible = true;
else if (item.type === "Belt" && key === "belt") compatible = true;
else if (item.type === "Offhand" && key === "offHand") compatible = true;
const compatible = isItemCompatibleWithSlot(item, key);
if (compatible) {
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) {
graphics.clear();
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
const size = getEquipmentSlotSize(key);
// Glowing border
graphics.lineStyle(4, 0xffd700, 1);
@@ -440,13 +468,14 @@ export class InventoryOverlay extends OverlayComponent {
}
private clearHighlights() {
// Reset Equipment Slots
this.equipmentSlots.forEach((container, key) => {
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) {
graphics.clear();
const slotBorder = 0xd4af37;
const slotBg = 0x3a2a2a;
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
const size = getEquipmentSlotSize(key);
graphics.lineStyle(2, slotBorder, 1);
graphics.strokeRect(-size / 2, -size / 2, size, size);
@@ -456,12 +485,135 @@ export class InventoryOverlay extends OverlayComponent {
graphics.fillStyle(slotBg, 1);
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
// Allow interactions again if they were disabled (though we don't disable them currently)
container.setAlpha(1);
}
});
// Reset Backpack Slots
this.backpackSlots.forEach(container => {
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) {
graphics.clear();
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
const { SLOT_BORDER, SLOT_INNER_BORDER, BACKPACK_BG } = INVENTORY_CONSTANTS.COLORS;
graphics.lineStyle(2, SLOT_BORDER, 1);
graphics.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
graphics.lineStyle(1, SLOT_INNER_BORDER, 1);
graphics.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
graphics.fillStyle(BACKPACK_BG, 1);
graphics.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
container.setAlpha(1);
}
});
}
/**
* Enters upgrade mode, highlighting upgradeable items.
*/
enterUpgradeMode(onSelect: (item: any) => void) {
this.isUpgradeMode = true;
this.onUpgradeSelect = onSelect;
// Highlight all upgradeable items
this.highlightUpgradeableItems();
}
cancelUpgradeMode() {
this.isUpgradeMode = false;
this.onUpgradeSelect = undefined;
this.clearHighlights();
}
private highlightUpgradeableItems() {
if (!this.cachedPlayer || !this.cachedPlayer.inventory) return;
// Green highlight for upgradeable items
this.backpackSlots.forEach((slot, index) => {
const item = this.cachedPlayer!.inventory!.items[index];
if (item && isItemUpgradeable(item)) {
this.drawSlotHighlight(slot, 44);
} else {
this.drawSlotDim(slot);
}
});
this.equipmentSlots.forEach((slot, key) => {
const item = (this.cachedPlayer!.equipment as any)?.[key];
const size = getEquipmentSlotSize(key);
if (item) {
this.drawSlotHighlight(slot, size);
} else {
this.drawSlotDim(slot);
}
});
}
private drawSlotHighlight(slot: Phaser.GameObjects.Container, size: number) {
const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) {
g.clear();
// Highlight border
g.lineStyle(2, 0x00ff00, 1);
g.strokeRect(-size / 2, -size / 2, size, size);
g.lineStyle(1, 0x00aa00, 1);
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
g.fillStyle(0x1a2f1a, 1);
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
}
}
private drawSlotDim(slot: Phaser.GameObjects.Container) {
const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) {
g.setAlpha(0.3);
}
}
// Handle clicks for upgrade selection
private handleUpgradeClick(pointer: Phaser.Input.Pointer) {
if (!this.isUpgradeMode || !this.onUpgradeSelect) {
return;
}
// Check backpack
const backpackIndex = this.getBackpackSlotAt(pointer.x, pointer.y);
if (backpackIndex !== null && this.cachedPlayer && this.cachedPlayer.inventory) {
const item = this.cachedPlayer.inventory.items[backpackIndex];
// Reuse eligibility check
if (item && isItemUpgradeable(item)) {
this.onUpgradeSelect(item);
}
return;
}
// Check equipment
const equipSlot = this.getEquipmentSlotAt(pointer.x, pointer.y);
if (equipSlot !== null && this.cachedPlayer && this.cachedPlayer.equipment) {
const item = (this.cachedPlayer.equipment as any)[equipSlot];
if (item) { // All equipped items are upgradeable types
this.onUpgradeSelect(item);
}
return;
}
}
private setupDragEvents() {
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
// Handle Upgrade Mode clicks (prevent drag)
if (this.isUpgradeMode) {
return;
}
const gameScene = this.scene.scene.get("GameScene") as any;
const player = gameScene.world.actors.get(gameScene.playerId);
if (!player) return;
@@ -601,7 +753,7 @@ export class InventoryOverlay extends OverlayComponent {
const localY = y - this.container.y;
for (const [key, slot] of this.equipmentSlots.entries()) {
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
const size = getEquipmentSlotSize(key);
const halfSize = size / 2;
const dx = localX - slot.x;
const dy = localY - slot.y;
@@ -620,7 +772,7 @@ export class InventoryOverlay extends OverlayComponent {
for (let i = 0; i < this.backpackSlots.length; i++) {
const slot = this.backpackSlots[i];
const halfSize = 22; // slotSize 44 / 2
const halfSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE / 2;
const dx = localX - slot.x;
const dy = localY - slot.y;

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