Compare commits

..

2 Commits

Author SHA1 Message Date
Peter Stockings
ff6b6bfb73 feat: make items in backpack draggable to and from quickslot 2026-01-21 15:18:42 +11:00
Peter Stockings
a11f86d23b feat: handle stacking in inventory and show item count and current/max ammo of ranged weapons 2026-01-21 14:52:08 +11:00
7 changed files with 330 additions and 42 deletions

View File

@@ -52,7 +52,12 @@ export function generateWorld(floor: number, runState: RunState): { world: World
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["pistol"]] : [])
...(runState.inventory.items.length === 0 ? [
{ ...ITEMS["health_potion"], quantity: 2 },
ITEMS["iron_sword"],
{ ...ITEMS["throwing_dagger"], quantity: 3 },
ITEMS["pistol"]
] : [])
]
},
energy: 0

View File

@@ -272,8 +272,21 @@ export class GameScene extends Phaser.Scene {
const item = this.itemManager.getItem(player, data.itemId);
if (!item) return;
// Drop position is simply on the player's current tile
const dropPos = { x: player.pos.x, y: player.pos.y };
// Determine drop position based on pointer or player pos
let dropPos = { x: player.pos.x, y: player.pos.y };
if (data.pointerX !== undefined && data.pointerY !== undefined) {
const tilePos = this.getPointerTilePos({ x: data.pointerX, y: data.pointerY } as Phaser.Input.Pointer);
// Limit drop distance to 1 tile from player for balance/fairness
const dx = Math.sign(tilePos.x - player.pos.x);
const dy = Math.sign(tilePos.y - player.pos.y);
const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy;
if (inBounds(this.world, targetX, targetY) && !isBlocked(this.world, targetX, targetY, this.entityManager)) {
dropPos = { x: targetX, y: targetY };
}
}
// Remove from inventory and spawn in world
if (this.itemManager.removeFromInventory(player, data.itemId)) {

View File

@@ -66,33 +66,47 @@ export class ItemManager {
if (itemActor) {
const item = itemActor.item;
// Stacking Logic
if (item.stackable) {
const existingItem = player.inventory.items.find(it => it.id === item.id);
if (existingItem) {
existingItem.quantity = (existingItem.quantity || 1) + (item.quantity || 1);
console.log(`Stacked ${item.name}. New quantity: ${existingItem.quantity}`);
this.entityManager.removeActor(itemActor.id);
return existingItem;
}
}
// Add to inventory
item.quantity = item.quantity || 1;
player.inventory.items.push(item);
const result = this.addItem(player, item);
// Remove from world
this.entityManager.removeActor(itemActor.id);
console.log("Picked up:", item.name);
return item;
return result;
}
return null;
}
/**
* Add an item to player inventory, handling stacking if applicable
* @returns The added or modified item
*/
addItem(player: CombatantActor, item: Item): Item {
if (!player.inventory) throw new Error("Player has no inventory");
// Deep clone item (crucial for items with mutable stats like ammo or when picking up from ground)
const itemToAdd = { ...item } as Item;
if ('stats' in itemToAdd && itemToAdd.stats) {
(itemToAdd as any).stats = { ...itemToAdd.stats };
}
// Stacking Logic
if (itemToAdd.stackable) {
const existingItem = player.inventory.items.find(it => it.id === itemToAdd.id);
if (existingItem) {
existingItem.quantity = (existingItem.quantity || 1) + (itemToAdd.quantity || 1);
console.log(`Stacked ${itemToAdd.name}. New quantity: ${existingItem.quantity}`);
return existingItem;
}
}
// Add to inventory
itemToAdd.quantity = itemToAdd.quantity || 1;
player.inventory.items.push(itemToAdd);
return itemToAdd;
}
/**
* Handle using an item from inventory
* Returns information about what happened

View File

@@ -9,13 +9,13 @@ import { QuickSlotComponent } from "./components/QuickSlotComponent";
import { ActionButtonComponent } from "./components/ActionButtonComponent";
export default class GameUI extends Phaser.Scene {
private hud: HudComponent;
private menu: MenuComponent;
private inventory: InventoryOverlay;
private character: CharacterOverlay;
private death: DeathOverlay;
private quickSlots: QuickSlotComponent;
private actionButtons: ActionButtonComponent;
public hud: HudComponent;
public menu: MenuComponent;
public inventory: InventoryOverlay;
public character: CharacterOverlay;
public death: DeathOverlay;
public quickSlots: QuickSlotComponent;
public actionButtons: ActionButtonComponent;
constructor() {
super({ key: "GameUI" });

View File

@@ -5,6 +5,9 @@ import { type CombatantActor } from "../../core/types";
export class InventoryOverlay extends OverlayComponent {
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
private backpackSlots: Phaser.GameObjects.Container[] = [];
private dragIcon: Phaser.GameObjects.Sprite | null = null;
private draggedItemIndex: number | null = null;
private draggedEquipmentKey: string | null = null;
protected setupContent() {
// Base overlay is 700x500, so we need to fit within those bounds
@@ -216,13 +219,20 @@ export class InventoryOverlay extends OverlayComponent {
update(player: CombatantActor) {
if (!player.inventory) return;
// Clear existing items from slots
// Clear existing items from backpack slots
this.backpackSlots.forEach(slot => {
if (slot.list.length > 1) {
slot.removeBetween(1, undefined, true);
}
});
// Clear existing items from equipment slots
this.equipmentSlots.forEach(slot => {
if (slot.list.length > 1) {
slot.removeBetween(1, undefined, true);
}
});
// Populate items
player.inventory.items.forEach((item, index) => {
if (index >= this.backpackSlots.length) return;
@@ -236,12 +246,183 @@ export class InventoryOverlay extends OverlayComponent {
sprite.setScale(2.2); // Scale to fit nicely in 44px slots
slot.add(sprite);
// 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.stats.currentAmmo}/${item.stats.magazineSize}`;
}
if (labelText) {
const slotSize = 44;
const display = this.scene.add.text(slotSize / 2 - 3, slotSize / 2 - 3, labelText, {
fontSize: "11px",
color: "#ffffff",
fontStyle: "bold",
stroke: "#000000",
strokeThickness: 2
}).setOrigin(1, 1);
slot.add(display);
}
// Add interactivity
// Add interactivity for backpack items
slot.setInteractive(new Phaser.Geom.Rectangle(-22, -22, 44, 44), Phaser.Geom.Rectangle.Contains);
slot.setData("index", index);
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
this.scene.input.setDraggable(slot);
slot.on("pointerdown", () => {
console.log("Clicked item:", item);
});
});
// Populate equipment slots
if (player.equipment) {
Object.entries(player.equipment).forEach(([key, item]) => {
if (!item) return;
const slot = this.equipmentSlots.get(key);
if (!slot) return;
const sprite = this.scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
sprite.setScale(2.2);
slot.add(sprite);
// Add interactivity
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
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);
});
}
this.setupDragEvents();
}
private setupDragEvents() {
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
const gameScene = this.scene.scene.get("GameScene") as any;
const player = gameScene.world.actors.get(gameScene.playerId);
if (!player) return;
let item: any = null;
// Check if it's a backpack slot or equipment slot
const index = gameObject.getData("index");
const eqKey = gameObject.getData("equipmentKey");
if (index !== undefined && this.backpackSlots.includes(gameObject)) {
item = player.inventory?.items[index];
this.draggedItemIndex = index;
this.draggedEquipmentKey = null;
} else if (eqKey !== undefined && this.equipmentSlots.get(eqKey) === gameObject) {
item = player.equipment?.[eqKey];
this.draggedItemIndex = null;
this.draggedEquipmentKey = eqKey;
}
if (!item) return;
// Setup drag icon
if (!this.dragIcon) {
this.dragIcon = this.scene.add.sprite(0, 0, item.textureKey ?? "items", item.spriteIndex);
this.dragIcon.setDepth(2500).setScale(2.5).setAlpha(0.7);
} else {
this.dragIcon.setTexture(item.textureKey ?? "items", item.spriteIndex);
this.dragIcon.setVisible(true);
}
this.dragIcon.setPosition(pointer.x, pointer.y);
// Ghost original
const sprite = gameObject.list.find((child: any) => child instanceof Phaser.GameObjects.Sprite);
if (sprite) sprite.setAlpha(0.3);
});
this.scene.input.on("drag", (pointer: Phaser.Input.Pointer) => {
if (this.dragIcon && this.dragIcon.visible) {
this.dragIcon.setPosition(pointer.x, pointer.y);
}
});
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: any) => {
if (this.draggedItemIndex === null && this.draggedEquipmentKey === null) return;
const isFromBackpack = this.draggedItemIndex !== null;
if (isFromBackpack && !this.backpackSlots.includes(gameObject)) return;
if (!isFromBackpack && this.equipmentSlots.get(this.draggedEquipmentKey!) !== gameObject) return;
const startIndex = this.draggedItemIndex;
const startEqKey = this.draggedEquipmentKey;
this.draggedItemIndex = null;
this.draggedEquipmentKey = null;
if (this.dragIcon) this.dragIcon.setVisible(false);
// Reset alpha
const sprite = gameObject.list.find((child: any) => child instanceof Phaser.GameObjects.Sprite);
if (sprite) sprite.setAlpha(1.0);
const gameUI = this.scene as any;
const gameScene = this.scene.scene.get("GameScene") as any;
const player = gameScene.world.actors.get(gameScene.playerId);
const item = isFromBackpack ? player.inventory.items[startIndex!] : (player.equipment as any)[startEqKey!];
// Check Quick Slots
if (gameUI.quickSlots && gameUI.quickSlots.isPointerOver(pointer.x, pointer.y)) {
const targetSlot = gameUI.quickSlots.getSlotIndexAt(pointer.x, pointer.y);
if (targetSlot !== null) {
gameUI.quickSlots.assignItem(targetSlot, item.id);
console.log(`Assigned backpack item ${item.name} to quick slot ${targetSlot}`);
}
return;
}
// Check Backpack (for swapping/reordering) - ONLY if dragged from backpack
if (isFromBackpack && this.isPointerOver(pointer.x, pointer.y)) {
const targetIndex = this.getBackpackSlotAt(pointer.x, pointer.y);
if (targetIndex !== null && targetIndex !== startIndex) {
const items = player.inventory.items;
const itemToMove = items[startIndex!];
// Remove from old position
items.splice(startIndex!, 1);
// Insert at new position (clamped to end of list)
const finalTargetIndex = Math.min(targetIndex, items.length);
items.splice(finalTargetIndex, 0, itemToMove);
gameScene.events.emit("request-ui-update");
}
return;
}
// Drop on ground
gameScene.events.emit("drop-item", {
itemId: item.id,
pointerX: pointer.x,
pointerY: pointer.y
});
});
}
private getBackpackSlotAt(x: number, y: number): number | null {
// Relative to container
const localX = x - this.container.x;
const localY = y - this.container.y;
for (let i = 0; i < this.backpackSlots.length; i++) {
const slot = this.backpackSlots[i];
const halfSize = 22; // slotSize 44 / 2
const dx = localX - slot.x;
const dy = localY - slot.y;
if (dx >= -halfSize && dx <= halfSize && dy >= -halfSize && dy <= halfSize) {
return i;
}
}
return null;
}
}

View File

@@ -37,4 +37,22 @@ export abstract class OverlayComponent {
}
protected onOpen() {}
public isPointerOver(x: number, y: number): boolean {
if (!this.isOpen || !this.container.visible) return false;
// Get world bounds of the first child (the background rectangle)
const bg = this.container.list[0] as Phaser.GameObjects.Rectangle;
if (!bg) return false;
// Local coordinates in container are centered at 0,0
const halfW = bg.width / 2;
const halfH = bg.height / 2;
// Container position is fixed on screen (scrollFactor 0)
const localX = x - this.container.x;
const localY = y - this.container.y;
return localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH;
}
}

View File

@@ -71,7 +71,10 @@ export class QuickSlotComponent {
// Drag and Drop Events
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
const index = gameObject.getData("index") as number;
// Only handle if it's one of our slots
const index = gameObject.getData("index");
if (index === undefined || !this.slots.includes(gameObject)) return;
const item = this.itemMap[index];
if (!item) return;
@@ -99,7 +102,7 @@ export class QuickSlotComponent {
});
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
if (this.draggedSlotIndex === null) return;
if (this.draggedSlotIndex === null || !this.slots.includes(gameObject)) return;
const startIndex = this.draggedSlotIndex;
this.draggedSlotIndex = null;
@@ -138,18 +141,26 @@ export class QuickSlotComponent {
this.assignedIds[targetIndex] = temp;
console.log(`Moved/Swapped slot ${startIndex} to ${targetIndex}`);
} else if (targetIndex === null) {
// Dropped outside - drop on ground
const item = this.itemMap[startIndex];
if (item) {
const gameScene = this.scene.scene.get("GameScene") as any;
gameScene.events.emit("drop-item", {
itemId: item.id,
pointerX: pointer.x,
pointerY: pointer.y
});
// Clear the slot
// Check if dropped over inventory backpack
const gameUI = this.scene as any;
if (gameUI.inventory && gameUI.inventory.isPointerOver(pointer.x, pointer.y)) {
// Clear the quick slot (returning to backpack)
this.assignedIds[startIndex] = "";
console.log(`Cleared quick slot ${startIndex} (returned to backpack)`);
} else {
// Dropped outside - drop on ground
const item = this.itemMap[startIndex];
if (item) {
const gameScene = this.scene.scene.get("GameScene") as any;
gameScene.events.emit("drop-item", {
itemId: item.id,
pointerX: pointer.x,
pointerY: pointer.y
});
// Clear the slot
this.assignedIds[startIndex] = "";
}
}
}
@@ -262,4 +273,50 @@ export class QuickSlotComponent {
console.log(`Slot ${index + 1} is empty`);
}
}
public isPointerOver(x: number, y: number): boolean {
const slotSize = 48;
const slotSpacing = 4;
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
const localX = x - this.container.x;
const localY = y - this.container.y;
return localX >= 0 && localX <= totalWidth && localY >= 0 && localY <= slotSize;
}
public getSlotIndexAt(x: number, y: number): number | null {
const slotSize = 48;
const slotSpacing = 4;
const localX = x - this.container.x;
const localY = y - this.container.y;
if (localY >= 0 && localY <= slotSize) {
const index = Math.floor(localX / (slotSize + slotSpacing));
const remainder = localX % (slotSize + slotSpacing);
if (index >= 0 && index < 10 && remainder <= slotSize) {
return index;
}
}
return null;
}
public assignItem(index: number, itemId: string) {
if (index >= 0 && index < 10) {
// Prevent duplicate assignments
const existingIndex = this.assignedIds.indexOf(itemId);
if (existingIndex !== -1 && existingIndex !== index) {
this.assignedIds[existingIndex] = "";
console.log(`Cleared duplicate assignment of ${itemId} from slot ${existingIndex}`);
}
this.assignedIds[index] = itemId;
// Refresh UI
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("request-ui-update");
}
}
}