Mark items as dragging before updating inventory state to prevent the spawner from destroying containers mid-drag. Move inventory removal to drop time for lost items, and add guards to ignore stale pointer events on inactive containers.
278 lines
8.1 KiB
TypeScript
278 lines
8.1 KiB
TypeScript
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.removeAllListeners();
|
|
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) => {
|
|
// 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<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;
|
|
}
|
|
}
|