import Phaser from "phaser"; import { MutableSignal } from "boardgame-core"; import { type InventoryItem, type GameItemMeta, type RunState, type GridInventory, } from "boardgame-core/samples/slay-the-spire-like"; import type { Spawner } from "boardgame-phaser"; const ITEM_COLORS = [ 0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633, ]; export interface InventoryItemSpawnerOptions { scene: Phaser.Scene; gameState: MutableSignal; parentContainer: Phaser.GameObjects.Container; cellSize: number; gridGap: number; gridX: number; gridY: number; isLocked: () => boolean; isDragging: () => boolean; onItemDragStart: ( itemId: string, item: InventoryItem, itemContainer: Phaser.GameObjects.Container, ) => void; } /** * Spawner for inventory items using the boardgame-phaser Spawner pattern. * Reactively spawns/despawns/updates item visuals when gameState.inventory changes. * * Items currently being dragged are excluded from getData() to prevent * the spawner from respawning them while they're in flight. */ export class InventoryItemSpawner implements Spawner< InventoryItem, Phaser.GameObjects.Container > { private scene: Phaser.Scene; private gameState: MutableSignal; private parentContainer: Phaser.GameObjects.Container; private cellSize: number; private gridGap: number; private gridX: number; private gridY: number; private isLocked: () => boolean; private isDragging: () => boolean; private onItemDragStart: ( itemId: string, item: InventoryItem, itemContainer: Phaser.GameObjects.Container, ) => void; private colorMap = new Map(); private colorIdx = 0; private draggingIds = new Set(); constructor(options: InventoryItemSpawnerOptions) { this.scene = options.scene; this.gameState = options.gameState; this.parentContainer = options.parentContainer; this.cellSize = options.cellSize; this.gridGap = options.gridGap; this.gridX = options.gridX; this.gridY = options.gridY; this.isLocked = options.isLocked; this.isDragging = options.isDragging; this.onItemDragStart = options.onItemDragStart; } *getData(): Iterable> { const inventory = this.getInventory(); for (const [, item] of inventory.items) { if (!this.draggingIds.has(item.id)) { yield item; } } } getKey(item: InventoryItem): string { return item.id; } onSpawn(item: InventoryItem): Phaser.GameObjects.Container { const color = this.colorMap.get(item.id) ?? ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length]; this.colorMap.set(item.id, color); const container = this.createItemVisuals(item, color); this.setupInteraction(item, container); this.parentContainer.add(container); return container; } onDespawn( obj: Phaser.GameObjects.Container, _item: InventoryItem, ): void { obj.removeAllListeners(); obj.destroy(); } onUpdate( item: InventoryItem, obj: Phaser.GameObjects.Container, ): void { const color = this.colorMap.get(item.id) ?? 0x888888; this.rebuildItemVisuals(obj, item, color); } /** * Mark an item as being dragged so the spawner excludes it from getData(). * Call this before removing the item from the inventory. */ markDragging(itemId: string): void { this.draggingIds.add(itemId); } /** * Unmark an item after drag ends (placed or lost). */ unmarkDragging(itemId: string): void { this.draggingIds.delete(itemId); } /** * Get the color assigned to an item (creates one if not yet assigned). */ getItemColor(itemId: string): number { if (!this.colorMap.has(itemId)) { this.colorMap.set( itemId, ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length], ); } return this.colorMap.get(itemId)!; } private getInventory(): GridInventory { return this.gameState.value .inventory as unknown as GridInventory; } private createItemVisuals( item: InventoryItem, color: number, ): Phaser.GameObjects.Container { const container = this.scene.add.container(0, 0); const graphics = this.scene.add.graphics(); const cells = this.getItemCells(item); for (const cell of cells) { const px = this.gridX + cell.x * (this.cellSize + this.gridGap); const py = this.gridY + cell.y * (this.cellSize + this.gridGap); graphics.fillStyle(color); graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2); graphics.lineStyle(2, 0xffffff); graphics.strokeRect(px, py, this.cellSize, this.cellSize); } const firstCell = cells[0]; const name = item.meta?.itemData.name ?? item.id; const fontSize = Math.max(10, Math.floor(this.cellSize / 5)); const text = this.scene.add .text( this.gridX + firstCell.x * (this.cellSize + this.gridGap) + this.cellSize / 2, this.gridY + firstCell.y * (this.cellSize + this.gridGap) + this.cellSize / 2, name, { fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" }, ) .setOrigin(0.5); container.add(graphics); container.add(text); const hitRect = new Phaser.Geom.Rectangle( this.gridX + firstCell.x * (this.cellSize + this.gridGap), this.gridY + firstCell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize, ); container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains); return container; } private rebuildItemVisuals( container: Phaser.GameObjects.Container, item: InventoryItem, color: number, ): void { container.removeAll(true); const graphics = this.scene.add.graphics(); const cells = this.getItemCells(item); for (const cell of cells) { const px = this.gridX + cell.x * (this.cellSize + this.gridGap); const py = this.gridY + cell.y * (this.cellSize + this.gridGap); graphics.fillStyle(color); graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2); graphics.lineStyle(2, 0xffffff); graphics.strokeRect(px, py, this.cellSize, this.cellSize); } const firstCell = cells[0]; const name = item.meta?.itemData.name ?? item.id; const fontSize = Math.max(10, Math.floor(this.cellSize / 5)); const text = this.scene.add .text( this.gridX + firstCell.x * (this.cellSize + this.gridGap) + this.cellSize / 2, this.gridY + firstCell.y * (this.cellSize + this.gridGap) + this.cellSize / 2, name, { fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" }, ) .setOrigin(0.5); container.add(graphics); container.add(text); const hitRect = new Phaser.Geom.Rectangle( this.gridX + firstCell.x * (this.cellSize + this.gridGap), this.gridY + firstCell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize, ); container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains); } private setupInteraction( item: InventoryItem, container: Phaser.GameObjects.Container, ): void { container.on("pointerdown", (pointer: Phaser.Input.Pointer) => { // Guard against stale events firing on destroyed containers if (!container.scene || !container.active) return; if (this.isLocked()) return; if (this.isDragging()) return; if (pointer.button === 0) { this.onItemDragStart(item.id, item, container); } }); } private getItemCells( item: InventoryItem, ): { x: number; y: number }[] { const cells: { x: number; y: number }[] = []; const { offset } = item.transform; for (let y = 0; y < item.shape.height; y++) { for (let x = 0; x < item.shape.width; x++) { if (item.shape.grid[y]?.[x]) { cells.push({ x: x + offset.x, y: y + offset.y }); } } } return cells; } }