boardgame-phaser/packages/sts-like-viewer/src/widgets/InventoryWidget.ts

240 lines
7.3 KiB
TypeScript

import Phaser from "phaser";
import { MutableSignal } from "boardgame-core";
import { spawnEffect } from "boardgame-phaser";
import {
type GridInventory,
type InventoryItem,
type GameItemMeta,
type RunState,
removeItemFromGrid,
placeItem,
} from "boardgame-core/samples/slay-the-spire-like";
import { InventoryItemSpawner } from "./InventoryItemSpawner";
import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
import { DragController } from "./DragController";
import { LostItemManager } from "./LostItemManager";
export interface InventoryWidgetOptions {
scene: Phaser.Scene;
gameState: MutableSignal<RunState>;
x: number;
y: number;
cellSize: number;
gridGap?: number;
isLocked?: boolean;
}
/**
* 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;
private gameState: MutableSignal<RunState>;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX = 0;
private gridY = 0;
private isLocked: boolean;
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) {
this.scene = options.scene;
this.gameState = options.gameState;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap ?? 2;
this.isLocked = options.isLocked ?? false;
const inventory = this.getInventory();
this.container = this.scene.add.container(options.x, options.y);
// 1. Static grid background (drawn once)
this.backgroundRenderer = new GridBackgroundRenderer({
scene: this.scene,
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,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
getInventory: () => this.getInventory(),
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.itemSpawner.getItemColor(id),
onLostItemDragStart: (id, lostContainer) =>
this.dragController.startLostItemDrag(
id,
this.getLostItemShape(id),
this.getLostItemTransform(id),
this.getLostItemMeta(id),
lostContainer,
),
isDragging: () => this.dragController.isDragging(),
});
// 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());
}
private getInventory(): GridInventory<GameItemMeta> {
return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
}
private handleItemDragStart(
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): void {
// Mark as dragging FIRST so spawner excludes it from getData().
// This prevents the spawner effect from destroying the container
// when we later update the inventory state.
this.itemSpawner.markDragging(itemId);
// Start drag session
this.dragController.startDrag(itemId, item, itemContainer);
}
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
this.gameState.produce((state) => {
placeItem(state.inventory, item);
});
// Unmark dragging so spawner picks it up on next effect run
this.itemSpawner.unmarkDragging(item.id);
}
private handleCreateLostItem(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
): void {
// Remove from inventory since it's dropped outside valid placement
this.gameState.produce((state) => {
removeItemFromGrid(state.inventory, itemId);
});
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y);
// Unmark dragging — item is now "lost" and managed by LostItemManager
this.itemSpawner.unmarkDragging(itemId);
}
private getLostItemShape(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.shape!;
}
private getLostItemTransform(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.transform!;
}
private getLostItemMeta(itemId: string) {
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;
}
public getLostItems(): string[] {
return this.lostItemManager.getLostItemIds();
}
public clearLostItems(): void {
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 {
// 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.backgroundRenderer.destroy();
this.container.destroy();
}
}