From 5af7140958e204c810fd886e57fa81623f5251ac Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 19 Apr 2026 00:24:20 +0800 Subject: [PATCH] Refactor inventory to use Spawner pattern Replace manual ItemRenderer with InventoryItemSpawner and GridBackgroundRenderer. Uses spawnEffect to automatically sync item visuals with game state changes. Separates static grid rendering and tracks dragged items to prevent spawner conflicts. --- .../src/widgets/GridBackgroundRenderer.ts | 71 +++++ .../src/widgets/InventoryItemSpawner.ts | 273 ++++++++++++++++++ .../src/widgets/InventoryWidget.ts | 144 +++++---- .../src/widgets/ItemRenderer.ts | 198 ------------- 4 files changed, 426 insertions(+), 260 deletions(-) create mode 100644 packages/sts-like-viewer/src/widgets/GridBackgroundRenderer.ts create mode 100644 packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts delete mode 100644 packages/sts-like-viewer/src/widgets/ItemRenderer.ts diff --git a/packages/sts-like-viewer/src/widgets/GridBackgroundRenderer.ts b/packages/sts-like-viewer/src/widgets/GridBackgroundRenderer.ts new file mode 100644 index 0000000..bbbf872 --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/GridBackgroundRenderer.ts @@ -0,0 +1,71 @@ +import Phaser from "phaser"; + +export interface GridBackgroundRendererOptions { + scene: Phaser.Scene; + parentContainer: Phaser.GameObjects.Container; + cellSize: number; + gridGap: number; + gridX: number; + gridY: number; + /** Background fill color for each cell */ + cellBgColor?: number; + /** Border/stroke color for each cell */ + cellBorderColor?: number; +} + +/** + * Renders the static grid background (empty cells with borders). + * Separated from item rendering so it can be drawn once and left alone. + */ +export class GridBackgroundRenderer { + private scene: Phaser.Scene; + private parentContainer: Phaser.GameObjects.Container; + private cellSize: number; + private gridGap: number; + private gridX: number; + private gridY: number; + private cellBgColor: number; + private cellBorderColor: number; + + private graphics!: Phaser.GameObjects.Graphics; + + constructor(options: GridBackgroundRendererOptions) { + this.scene = options.scene; + this.parentContainer = options.parentContainer; + this.cellSize = options.cellSize; + this.gridGap = options.gridGap; + this.gridX = options.gridX; + this.gridY = options.gridY; + this.cellBgColor = options.cellBgColor ?? 0x1a1a2e; + this.cellBorderColor = options.cellBorderColor ?? 0x444477; + } + + /** + * Draw the grid background for the given dimensions. + * Should be called once during initialization. + */ + draw(width: number, height: number): void { + this.graphics = this.scene.add.graphics(); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const px = this.gridX + x * (this.cellSize + this.gridGap); + const py = this.gridY + y * (this.cellSize + this.gridGap); + + this.graphics.fillStyle(this.cellBgColor); + this.graphics.fillRect(px, py, this.cellSize, this.cellSize); + this.graphics.lineStyle(2, this.cellBorderColor); + this.graphics.strokeRect(px, py, this.cellSize, this.cellSize); + } + } + + this.parentContainer.add(this.graphics); + } + + /** + * Destroy the graphics object. + */ + destroy(): void { + this.graphics?.destroy(); + } +} diff --git a/packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts b/packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts new file mode 100644 index 0000000..4f07071 --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts @@ -0,0 +1,273 @@ +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, 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.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) => { + 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; + } +} diff --git a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts index b0428a1..04f33d0 100644 --- a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts +++ b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts @@ -1,5 +1,6 @@ import Phaser from "phaser"; import { MutableSignal } from "boardgame-core"; +import { spawnEffect } from "boardgame-phaser"; import { type GridInventory, type InventoryItem, @@ -8,7 +9,8 @@ import { removeItemFromGrid, placeItem, } from "boardgame-core/samples/slay-the-spire-like"; -import { ItemRenderer } from "./ItemRenderer"; +import { InventoryItemSpawner } from "./InventoryItemSpawner"; +import { GridBackgroundRenderer } from "./GridBackgroundRenderer"; import { DragController } from "./DragController"; import { LostItemManager } from "./LostItemManager"; @@ -23,9 +25,13 @@ export interface InventoryWidgetOptions { } /** - * Thin orchestrator for the inventory grid widget. - * Delegates rendering, drag logic, and lost-item management to focused modules. - * Uses event-driven drag via dragDropEventEffect from boardgame-phaser. + * 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; @@ -37,10 +43,12 @@ export class InventoryWidget { private gridY = 0; private isLocked: boolean; - private renderer: ItemRenderer; + 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) { @@ -50,19 +58,38 @@ export class InventoryWidget { this.gridGap = options.gridGap ?? 2; this.isLocked = options.isLocked ?? false; - const inventory = this.gameState.value.inventory; + const inventory = this.getInventory(); this.container = this.scene.add.container(options.x, options.y); - this.renderer = new ItemRenderer({ + // 1. Static grid background (drawn once) + this.backgroundRenderer = new GridBackgroundRenderer({ scene: this.scene, - container: this.container, + 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, @@ -71,17 +98,18 @@ export class InventoryWidget { gridX: this.gridX, gridY: this.gridY, getInventory: () => this.getInventory(), - getItemColor: (id) => this.renderer.getItemColor(id), + 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.renderer.getItemColor(id), + getItemColor: (id) => this.itemSpawner.getItemColor(id), onLostItemDragStart: (id, lostContainer) => this.dragController.startLostItemDrag( id, @@ -93,8 +121,10 @@ export class InventoryWidget { isDragging: () => this.dragController.isDragging(), }); - this.renderer.drawGridBackground(inventory.width, inventory.height); - this.drawItems(); + // 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()); @@ -105,54 +135,30 @@ export class InventoryWidget { .inventory as unknown as GridInventory; } - private drawItems(): void { - const inventory = this.getInventory(); - for (const [itemId, item] of inventory.items) { - if (this.renderer.hasItem(itemId)) continue; - const visuals = this.renderer.createItemVisuals(itemId, item); - this.setupItemInteraction(itemId, visuals, item); - } - } - - private setupItemInteraction( + private handleItemDragStart( itemId: string, - visuals: ReturnType, item: InventoryItem, + itemContainer: Phaser.GameObjects.Container, ): void { - visuals.container.on("pointerdown", (pointer: Phaser.Input.Pointer) => { - if (this.isLocked) return; - if (this.dragController.isDragging()) return; - if (pointer.button === 0) { - this.gameState.produce((state) => { - removeItemFromGrid(state.inventory, itemId); - }); - this.renderer.removeItemVisuals(itemId); - this.dragController.startDrag(itemId, item, visuals.container); - } + // Remove from inventory state + this.gameState.produce((state) => { + removeItemFromGrid(state.inventory, itemId); }); - } - private setupInput(): void { - this.rightClickHandler = (pointer: Phaser.Input.Pointer) => { - if (!this.dragController.isDragging()) return; - if (pointer.button === 1) { - this.dragController.rotateDraggedItem(); - } - }; + // Mark as dragging so spawner excludes it from getData() + this.itemSpawner.markDragging(itemId); - this.scene.input.on("pointerdown", this.rightClickHandler); + // Start drag session + this.dragController.startDrag(itemId, item, itemContainer); } private handlePlaceItem(item: InventoryItem): void { this.gameState.produce((state) => { placeItem(state.inventory, item); }); - const inventory = this.getInventory(); - const placedItem = inventory.items.get(item.id); - if (placedItem) { - const visuals = this.renderer.createItemVisuals(item.id, placedItem); - this.setupItemInteraction(item.id, visuals, placedItem); - } + + // Unmark dragging so spawner picks it up on next effect run + this.itemSpawner.unmarkDragging(item.id); } private handleCreateLostItem( @@ -164,6 +170,9 @@ export class InventoryWidget { y: number, ): void { this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y); + + // Unmark dragging — item is now "lost" and no longer in inventory + this.itemSpawner.unmarkDragging(itemId); } private getLostItemShape(itemId: string) { @@ -178,6 +187,17 @@ export class InventoryWidget { 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; } @@ -190,28 +210,28 @@ export class InventoryWidget { 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 { - const inventory = this.getInventory(); - - this.renderer.destroy(); - this.renderer = new ItemRenderer({ - scene: this.scene, - container: this.container, - cellSize: this.cellSize, - gridGap: this.gridGap, - gridX: this.gridX, - gridY: this.gridY, - }); - this.renderer.drawGridBackground(inventory.width, inventory.height); - this.drawItems(); + // 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.renderer.destroy(); + this.backgroundRenderer.destroy(); this.container.destroy(); } } diff --git a/packages/sts-like-viewer/src/widgets/ItemRenderer.ts b/packages/sts-like-viewer/src/widgets/ItemRenderer.ts deleted file mode 100644 index 157118b..0000000 --- a/packages/sts-like-viewer/src/widgets/ItemRenderer.ts +++ /dev/null @@ -1,198 +0,0 @@ -import Phaser from "phaser"; -import { - type InventoryItem, - type GameItemMeta, -} from "boardgame-core/samples/slay-the-spire-like"; - -const ITEM_COLORS = [ - 0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, - 0xff6633, -]; - -export interface ItemVisuals { - container: Phaser.GameObjects.Container; - graphics: Phaser.GameObjects.Graphics; - text: Phaser.GameObjects.Text; -} - -export interface ItemRendererOptions { - scene: Phaser.Scene; - container: Phaser.GameObjects.Container; - cellSize: number; - gridGap: number; - gridX: number; - gridY: number; -} - -/** - * Handles all Phaser visual rendering for inventory items and the grid background. - * Manages color assignment, graphics creation, and cleanup. - */ -export class ItemRenderer { - private scene: Phaser.Scene; - private container: Phaser.GameObjects.Container; - private cellSize: number; - private gridGap: number; - private gridX: number; - private gridY: number; - - private itemVisuals = new Map(); - private colorMap = new Map(); - private colorIdx = 0; - - private gridGraphics!: Phaser.GameObjects.Graphics; - - constructor(options: ItemRendererOptions) { - this.scene = options.scene; - this.container = options.container; - this.cellSize = options.cellSize; - this.gridGap = options.gridGap; - this.gridX = options.gridX; - this.gridY = options.gridY; - } - - /** - * Draw the grid background (empty cells with borders). - */ - drawGridBackground(width: number, height: number): void { - this.gridGraphics = this.scene.add.graphics(); - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const px = this.gridX + x * (this.cellSize + this.gridGap); - const py = this.gridY + y * (this.cellSize + this.gridGap); - - this.gridGraphics.fillStyle(0x1a1a2e); - this.gridGraphics.fillRect(px, py, this.cellSize, this.cellSize); - this.gridGraphics.lineStyle(2, 0x444477); - this.gridGraphics.strokeRect(px, py, this.cellSize, this.cellSize); - } - } - - this.container.add(this.gridGraphics); - } - - /** - * Create Phaser visuals for a single inventory item. - * Returns the visuals object for further interaction setup. - */ - createItemVisuals( - itemId: string, - item: InventoryItem, - ): ItemVisuals { - const color = - this.colorMap.get(itemId) ?? - ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length]; - this.colorMap.set(itemId, color); - - 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); - - 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, - ); - - const container = this.scene.add.container(0, 0); - container.add(graphics); - container.add(text); - container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains); - - const visuals: ItemVisuals = { container, graphics, text }; - this.itemVisuals.set(itemId, visuals); - this.container.add(container); - - return visuals; - } - - /** - * Remove and destroy all Phaser objects for a given item. - */ - removeItemVisuals(itemId: string): void { - const visuals = this.itemVisuals.get(itemId); - if (!visuals) return; - - visuals.container.destroy(); - visuals.graphics.destroy(); - visuals.text.destroy(); - this.itemVisuals.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)!; - } - - /** - * Compute the grid cells occupied by an item based on its shape and transform. - */ - 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; - } - - /** - * Check if visuals exist for a given item ID. - */ - hasItem(itemId: string): boolean { - return this.itemVisuals.has(itemId); - } - - /** - * Destroy all managed visuals and grid graphics. - */ - destroy(): void { - for (const visuals of this.itemVisuals.values()) { - visuals.container.destroy(); - visuals.graphics.destroy(); - visuals.text.destroy(); - } - this.itemVisuals.clear(); - this.colorMap.clear(); - this.gridGraphics.destroy(); - } -}