import Phaser from "phaser"; import { MutableSignal } from "boardgame-core"; import { spawnEffect } from "boardgame-phaser"; import { type GridInventory, type InventoryItem, type GameItemMeta, type RunState, removeItemFromGrid, placeItem, } from "boardgame-core/samples/slay-the-spire-like"; import { InventoryItemSpawner } from "./InventoryItemSpawner"; import { GridBackgroundRenderer } from "./GridBackgroundRenderer"; import { DragController } from "./DragController"; import { LostItemManager } from "./LostItemManager"; export interface InventoryWidgetOptions { scene: Phaser.Scene; gameState: MutableSignal; x: number; y: number; cellSize: number; gridGap?: number; isLocked?: boolean; } /** * Inventory widget using the Spawner pattern for reactive item rendering. * * Architecture: * - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals * - GridBackgroundRenderer: static grid background drawn once * - DragController: event-driven drag logic via dragDropEventEffect * - LostItemManager: tracks items dropped outside valid placement */ export class InventoryWidget { private scene: Phaser.Scene; private gameState: MutableSignal; private container: Phaser.GameObjects.Container; private cellSize: number; private gridGap: number; private gridX = 0; private gridY = 0; private isLocked: boolean; private itemSpawner: InventoryItemSpawner; private backgroundRenderer: GridBackgroundRenderer; private dragController: DragController; private lostItemManager: LostItemManager; private spawnDispose: (() => void) | null = null; private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void; constructor(options: InventoryWidgetOptions) { this.scene = options.scene; this.gameState = options.gameState; this.cellSize = options.cellSize; this.gridGap = options.gridGap ?? 2; this.isLocked = options.isLocked ?? false; const inventory = this.getInventory(); this.container = this.scene.add.container(options.x, options.y); // 1. Static grid background (drawn once) this.backgroundRenderer = new GridBackgroundRenderer({ scene: this.scene, parentContainer: this.container, cellSize: this.cellSize, gridGap: this.gridGap, gridX: this.gridX, gridY: this.gridY, }); this.backgroundRenderer.draw(inventory.width, inventory.height); // 2. Reactive item spawner this.itemSpawner = new InventoryItemSpawner({ scene: this.scene, gameState: this.gameState, parentContainer: this.container, cellSize: this.cellSize, gridGap: this.gridGap, gridX: this.gridX, gridY: this.gridY, isLocked: () => this.isLocked, isDragging: () => this.dragController.isDragging(), onItemDragStart: (itemId, item, itemContainer) => { this.handleItemDragStart(itemId, item, itemContainer); }, }); // 3. Drag controller this.dragController = new DragController({ scene: this.scene, container: this.container, cellSize: this.cellSize, gridGap: this.gridGap, gridX: this.gridX, gridY: this.gridY, getInventory: () => this.getInventory(), getItemColor: (id) => this.itemSpawner.getItemColor(id), onPlaceItem: (item) => this.handlePlaceItem(item), onCreateLostItem: (id, shape, transform, meta, x, y) => this.handleCreateLostItem(id, shape, transform, meta, x, y), }); // 4. Lost item manager this.lostItemManager = new LostItemManager({ scene: this.scene, cellSize: this.cellSize, gridGap: this.gridGap, getItemColor: (id) => this.itemSpawner.getItemColor(id), onLostItemDragStart: (id, lostContainer) => this.dragController.startLostItemDrag( id, this.getLostItemShape(id), this.getLostItemTransform(id), this.getLostItemMeta(id), lostContainer, ), isDragging: () => this.dragController.isDragging(), }); // Activate the spawner effect (auto-cleans up on dispose) this.spawnDispose = spawnEffect(this.itemSpawner); // Right-click rotation handler this.setupInput(); this.scene.events.once("shutdown", () => this.destroy()); } private getInventory(): GridInventory { return this.gameState.value .inventory as unknown as GridInventory; } private handleItemDragStart( itemId: string, item: InventoryItem, itemContainer: Phaser.GameObjects.Container, ): void { // Mark as dragging FIRST so spawner excludes it from getData(). // This prevents the spawner effect from destroying the container // when we later update the inventory state. this.itemSpawner.markDragging(itemId); // Start drag session this.dragController.startDrag(itemId, item, itemContainer); } private handlePlaceItem(item: InventoryItem): void { this.gameState.produce((state) => { placeItem(state.inventory, item); }); // Unmark dragging so spawner picks it up on next effect run this.itemSpawner.unmarkDragging(item.id); } private handleCreateLostItem( itemId: string, shape: InventoryItem["shape"], transform: InventoryItem["transform"], meta: InventoryItem["meta"], x: number, y: number, ): void { // Remove from inventory since it's dropped outside valid placement this.gameState.produce((state) => { removeItemFromGrid(state.inventory, itemId); }); this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y); // Unmark dragging — item is now "lost" and managed by LostItemManager this.itemSpawner.unmarkDragging(itemId); } private getLostItemShape(itemId: string) { return this.lostItemManager.getLostItem(itemId)?.shape!; } private getLostItemTransform(itemId: string) { return this.lostItemManager.getLostItem(itemId)?.transform!; } private getLostItemMeta(itemId: string) { return this.lostItemManager.getLostItem(itemId)?.meta!; } private setupInput(): void { this.rightClickHandler = (pointer: Phaser.Input.Pointer) => { if (!this.dragController.isDragging()) return; if (pointer.button === 1) { this.dragController.rotateDraggedItem(); } }; this.scene.input.on("pointerdown", this.rightClickHandler); } public setLocked(locked: boolean): void { this.isLocked = locked; } public getLostItems(): string[] { return this.lostItemManager.getLostItemIds(); } public clearLostItems(): void { this.lostItemManager.clear(); } /** * Force re-sync of item visuals with current inventory state. * With spawnEffect this is usually automatic, but useful after * external state changes that don't trigger the effect. */ public refresh(): void { // The spawner effect automatically re-syncs when gameState.value changes. // If immediate refresh is needed, reading the signal triggers the effect. void this.gameState.value; } public destroy(): void { this.scene.input.off("pointerdown", this.rightClickHandler); if (this.spawnDispose) { this.spawnDispose(); this.spawnDispose = null; } this.dragController.destroy(); this.lostItemManager.destroy(); this.backgroundRenderer.destroy(); this.container.destroy(); } }