240 lines
7.3 KiB
TypeScript
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();
|
|
}
|
|
}
|