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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user