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.
This commit is contained in:
hypercross 2026-04-19 00:24:20 +08:00
parent a7095c37fc
commit 5af7140958
4 changed files with 426 additions and 260 deletions

View File

@ -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();
}
}

View File

@ -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<RunState>;
parentContainer: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
isLocked: () => boolean;
isDragging: () => boolean;
onItemDragStart: (
itemId: string,
item: InventoryItem<GameItemMeta>,
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<GameItemMeta>, Phaser.GameObjects.Container>
{
private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>;
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<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
) => void;
private colorMap = new Map<string, number>();
private colorIdx = 0;
private draggingIds = new Set<string>();
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<InventoryItem<GameItemMeta>> {
const inventory = this.getInventory();
for (const [, item] of inventory.items) {
if (!this.draggingIds.has(item.id)) {
yield item;
}
}
}
getKey(item: InventoryItem<GameItemMeta>): string {
return item.id;
}
onSpawn(item: InventoryItem<GameItemMeta>): 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<GameItemMeta>,
): void {
obj.destroy();
}
onUpdate(
item: InventoryItem<GameItemMeta>,
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<GameItemMeta> {
return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
}
private createItemVisuals(
item: InventoryItem<GameItemMeta>,
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<GameItemMeta>,
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<GameItemMeta>,
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<GameItemMeta>,
): { 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;
}
}

View File

@ -1,5 +1,6 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { MutableSignal } from "boardgame-core"; import { MutableSignal } from "boardgame-core";
import { spawnEffect } from "boardgame-phaser";
import { import {
type GridInventory, type GridInventory,
type InventoryItem, type InventoryItem,
@ -8,7 +9,8 @@ import {
removeItemFromGrid, removeItemFromGrid,
placeItem, placeItem,
} from "boardgame-core/samples/slay-the-spire-like"; } 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 { DragController } from "./DragController";
import { LostItemManager } from "./LostItemManager"; import { LostItemManager } from "./LostItemManager";
@ -23,9 +25,13 @@ export interface InventoryWidgetOptions {
} }
/** /**
* Thin orchestrator for the inventory grid widget. * Inventory widget using the Spawner pattern for reactive item rendering.
* Delegates rendering, drag logic, and lost-item management to focused modules. *
* Uses event-driven drag via dragDropEventEffect from boardgame-phaser. * 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 { export class InventoryWidget {
private scene: Phaser.Scene; private scene: Phaser.Scene;
@ -37,10 +43,12 @@ export class InventoryWidget {
private gridY = 0; private gridY = 0;
private isLocked: boolean; private isLocked: boolean;
private renderer: ItemRenderer; private itemSpawner: InventoryItemSpawner;
private backgroundRenderer: GridBackgroundRenderer;
private dragController: DragController; private dragController: DragController;
private lostItemManager: LostItemManager; private lostItemManager: LostItemManager;
private spawnDispose: (() => void) | null = null;
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void; private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
constructor(options: InventoryWidgetOptions) { constructor(options: InventoryWidgetOptions) {
@ -50,19 +58,38 @@ export class InventoryWidget {
this.gridGap = options.gridGap ?? 2; this.gridGap = options.gridGap ?? 2;
this.isLocked = options.isLocked ?? false; 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.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, scene: this.scene,
container: this.container, parentContainer: this.container,
cellSize: this.cellSize, cellSize: this.cellSize,
gridGap: this.gridGap, gridGap: this.gridGap,
gridX: this.gridX, gridX: this.gridX,
gridY: this.gridY, 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({ this.dragController = new DragController({
scene: this.scene, scene: this.scene,
container: this.container, container: this.container,
@ -71,17 +98,18 @@ export class InventoryWidget {
gridX: this.gridX, gridX: this.gridX,
gridY: this.gridY, gridY: this.gridY,
getInventory: () => this.getInventory(), getInventory: () => this.getInventory(),
getItemColor: (id) => this.renderer.getItemColor(id), getItemColor: (id) => this.itemSpawner.getItemColor(id),
onPlaceItem: (item) => this.handlePlaceItem(item), onPlaceItem: (item) => this.handlePlaceItem(item),
onCreateLostItem: (id, shape, transform, meta, x, y) => onCreateLostItem: (id, shape, transform, meta, x, y) =>
this.handleCreateLostItem(id, shape, transform, meta, x, y), this.handleCreateLostItem(id, shape, transform, meta, x, y),
}); });
// 4. Lost item manager
this.lostItemManager = new LostItemManager({ this.lostItemManager = new LostItemManager({
scene: this.scene, scene: this.scene,
cellSize: this.cellSize, cellSize: this.cellSize,
gridGap: this.gridGap, gridGap: this.gridGap,
getItemColor: (id) => this.renderer.getItemColor(id), getItemColor: (id) => this.itemSpawner.getItemColor(id),
onLostItemDragStart: (id, lostContainer) => onLostItemDragStart: (id, lostContainer) =>
this.dragController.startLostItemDrag( this.dragController.startLostItemDrag(
id, id,
@ -93,8 +121,10 @@ export class InventoryWidget {
isDragging: () => this.dragController.isDragging(), isDragging: () => this.dragController.isDragging(),
}); });
this.renderer.drawGridBackground(inventory.width, inventory.height); // Activate the spawner effect (auto-cleans up on dispose)
this.drawItems(); this.spawnDispose = spawnEffect(this.itemSpawner);
// Right-click rotation handler
this.setupInput(); this.setupInput();
this.scene.events.once("shutdown", () => this.destroy()); this.scene.events.once("shutdown", () => this.destroy());
@ -105,54 +135,30 @@ export class InventoryWidget {
.inventory as unknown as GridInventory<GameItemMeta>; .inventory as unknown as GridInventory<GameItemMeta>;
} }
private drawItems(): void { private handleItemDragStart(
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(
itemId: string, itemId: string,
visuals: ReturnType<typeof ItemRenderer.prototype.createItemVisuals>,
item: InventoryItem<GameItemMeta>, item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): void { ): void {
visuals.container.on("pointerdown", (pointer: Phaser.Input.Pointer) => { // Remove from inventory state
if (this.isLocked) return; this.gameState.produce((state) => {
if (this.dragController.isDragging()) return; removeItemFromGrid(state.inventory, itemId);
if (pointer.button === 0) {
this.gameState.produce((state) => {
removeItemFromGrid(state.inventory, itemId);
});
this.renderer.removeItemVisuals(itemId);
this.dragController.startDrag(itemId, item, visuals.container);
}
}); });
}
private setupInput(): void { // Mark as dragging so spawner excludes it from getData()
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => { this.itemSpawner.markDragging(itemId);
if (!this.dragController.isDragging()) return;
if (pointer.button === 1) {
this.dragController.rotateDraggedItem();
}
};
this.scene.input.on("pointerdown", this.rightClickHandler); // Start drag session
this.dragController.startDrag(itemId, item, itemContainer);
} }
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void { private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
this.gameState.produce((state) => { this.gameState.produce((state) => {
placeItem(state.inventory, item); placeItem(state.inventory, item);
}); });
const inventory = this.getInventory();
const placedItem = inventory.items.get(item.id); // Unmark dragging so spawner picks it up on next effect run
if (placedItem) { this.itemSpawner.unmarkDragging(item.id);
const visuals = this.renderer.createItemVisuals(item.id, placedItem);
this.setupItemInteraction(item.id, visuals, placedItem);
}
} }
private handleCreateLostItem( private handleCreateLostItem(
@ -164,6 +170,9 @@ export class InventoryWidget {
y: number, y: number,
): void { ): void {
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y); 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) { private getLostItemShape(itemId: string) {
@ -178,6 +187,17 @@ export class InventoryWidget {
return this.lostItemManager.getLostItem(itemId)?.meta!; 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 { public setLocked(locked: boolean): void {
this.isLocked = locked; this.isLocked = locked;
} }
@ -190,28 +210,28 @@ export class InventoryWidget {
this.lostItemManager.clear(); 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 { public refresh(): void {
const inventory = this.getInventory(); // The spawner effect automatically re-syncs when gameState.value changes.
// If immediate refresh is needed, reading the signal triggers the effect.
this.renderer.destroy(); void this.gameState.value;
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();
} }
public destroy(): void { public destroy(): void {
this.scene.input.off("pointerdown", this.rightClickHandler); this.scene.input.off("pointerdown", this.rightClickHandler);
if (this.spawnDispose) {
this.spawnDispose();
this.spawnDispose = null;
}
this.dragController.destroy(); this.dragController.destroy();
this.lostItemManager.destroy(); this.lostItemManager.destroy();
this.renderer.destroy(); this.backgroundRenderer.destroy();
this.container.destroy(); this.container.destroy();
} }
} }

View File

@ -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<string, ItemVisuals>();
private colorMap = new Map<string, number>();
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<GameItemMeta>,
): 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<GameItemMeta>): { 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();
}
}