refactor: remove inventory widget and drag controller

Removes the inventory management system and drag-and-drop
functionality from the STS-like viewer. This includes deleting the
`InventoryWidget`, `InventoryItemSpawner`, and `DragController`
classes, as well as cleaning up references in
`PlaceholderEncounterScene`.
This commit is contained in:
hypercross 2026-04-19 14:59:39 +08:00
parent 82df3f2a2f
commit 34a7cff964
4 changed files with 6 additions and 1038 deletions

View File

@ -1,26 +1,22 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { MutableSignal } from "boardgame-core";
import {
resolveEncounter,
removeItem,
type RunState,
type EncounterResult,
type MapNodeType,
type MapNode,
} from "boardgame-core/samples/slay-the-spire-like";
import { InventoryWidget } from "@/widgets/InventoryWidget";
import { ReactiveScene } from "boardgame-phaser";
import type { MutableSignal } from "boardgame-core";
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { createButton } from "@/utils/createButton";
/**
*
*
* 使 InventoryWidget
*/
export class PlaceholderEncounterScene extends ReactiveScene {
private gameState: MutableSignal<RunState>;
private inventoryWidget: InventoryWidget | null = null;
constructor(gameState: MutableSignal<RunState>) {
super("PlaceholderEncounterScene");
@ -39,30 +35,9 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
const leftPanelW = gridW + 40;
this.inventoryWidget = new InventoryWidget({
scene: this,
gameState: this.gameState,
x: 60,
y: (height - gridH) / 2 + 20,
cellSize,
gridGap: GRID_CONFIG.GRID_GAP,
});
this.cameras.main.setBounds(0, 0, width, height);
this.cameras.main.setScroll(0, 0);
// Panel background
this.add
.rectangle(
60 + leftPanelW / 2,
this.inventoryWidgetY(gridH),
leftPanelW + 10,
gridH + 50,
0x111122,
0.9,
)
.setStrokeStyle(2, 0x5555aa);
// "背包" title
this.add
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
@ -92,11 +67,6 @@ export class PlaceholderEncounterScene extends ReactiveScene {
);
}
private inventoryWidgetY(gridH: number): number {
const { height } = this.scale;
return (height - gridH) / 2 + 20 + gridH / 2;
}
private drawRightPanel(
node: MapNode & { encounter: { name: string; description: string } },
leftPanelW: number,
@ -201,15 +171,6 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) return;
// Clear lost items from inventory
if (this.inventoryWidget) {
const lostIds = this.inventoryWidget.getLostItems();
for (const lostId of lostIds) {
removeItem(state, lostId);
}
this.inventoryWidget.clearLostItems();
}
const result: EncounterResult = this.generatePlaceholderResult(node.type);
resolveEncounter(state, result);
await this.sceneController.launch("GameFlowScene");

View File

@ -1,477 +0,0 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
type GridInventory,
validatePlacement,
transformShape,
} from "boardgame-core/samples/slay-the-spire-like";
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
import { DisposableBag } from "boardgame-phaser";
export interface DragSession {
itemId: string;
itemShape: InventoryItem<GameItemMeta>["shape"];
itemTransform: InventoryItem<GameItemMeta>["transform"];
itemMeta: InventoryItem<GameItemMeta>["meta"];
ghostContainer: Phaser.GameObjects.Container;
previewGraphics: Phaser.GameObjects.Graphics;
disposables: DisposableBag;
}
export interface DragControllerOptions {
scene: Phaser.Scene;
container: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
getInventory: () => GridInventory<GameItemMeta>;
getItemColor: (itemId: string) => number;
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
) => void;
}
/**
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
* Manages ghost visuals, placement preview, rotation, and validation.
*/
export class DragController {
private scene: Phaser.Scene;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private getInventory: () => GridInventory<GameItemMeta>;
private getItemColor: (itemId: string) => number;
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
private onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
) => void;
private activeSession: DragSession | null = null;
constructor(options: DragControllerOptions) {
this.scene = options.scene;
this.container = options.container;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.getInventory = options.getInventory;
this.getItemColor = options.getItemColor;
this.onPlaceItem = options.onPlaceItem;
this.onCreateLostItem = options.onCreateLostItem;
}
/**
* Start a drag session for an inventory item.
* Uses dragDropEventEffect for pointer tracking and event emission.
*/
startDrag(
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): () => void {
const cells = this.getItemCells(item);
const firstCell = cells[0];
const worldX =
this.container.x +
this.gridX +
firstCell.x * (this.cellSize + this.gridGap);
const worldY =
this.container.y +
this.gridY +
firstCell.y * (this.cellSize + this.gridGap);
const ghostContainer = this.createGhostContainer(
worldX,
worldY,
item.shape,
item.transform,
this.getItemColor(itemId),
);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
const disposables = new DisposableBag();
const session: DragSession = {
itemId,
itemShape: item.shape,
itemTransform: {
...item.transform,
offset: { ...item.transform.offset },
},
itemMeta: item.meta,
ghostContainer,
previewGraphics,
disposables,
};
this.activeSession = session;
// Set up drag-drop event handling via framework utility
const disposeDrag = dragDropEventEffect(
itemContainer as Phaser.GameObjects.GameObject,
disposables,
);
itemContainer.on("dragstart", () => {
ghostContainer.setVisible(true);
});
itemContainer.on("dragmove", () => {
this.handleDragMove(session);
});
itemContainer.on("dragend", () => {
this.handleDragEnd(session);
disposeDrag();
this.activeSession = null;
});
return () => {
disposeDrag();
this.destroySession(session);
this.activeSession = null;
};
}
/**
* Start a drag session for a lost item.
*/
startLostItemDrag(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
lostContainer: Phaser.GameObjects.Container,
): () => void {
const pointer = this.scene.input.activePointer;
const ghostContainer = this.createGhostContainer(
pointer.x,
pointer.y,
shape,
transform,
this.getItemColor(itemId),
);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
const disposables = new DisposableBag();
const session: DragSession = {
itemId,
itemShape: shape,
itemTransform: { ...transform, offset: { ...transform.offset } },
itemMeta: meta,
ghostContainer,
previewGraphics,
disposables,
};
this.activeSession = session;
const disposeDrag = dragDropEventEffect(
lostContainer as Phaser.GameObjects.GameObject,
disposables,
);
lostContainer.on("dragstart", () => {
ghostContainer.setVisible(true);
});
lostContainer.on("dragmove", () => {
this.handleDragMove(session);
});
lostContainer.on("dragend", () => {
this.handleDragEnd(session);
disposeDrag();
this.activeSession = null;
});
return () => {
disposeDrag();
this.destroySession(session);
this.activeSession = null;
};
}
/**
* Rotate the currently dragged item by 90 degrees.
*/
rotateDraggedItem(): void {
if (!this.activeSession) return;
const currentRotation =
(this.activeSession.itemTransform.rotation + 90) % 360;
this.activeSession.itemTransform = {
...this.activeSession.itemTransform,
rotation: currentRotation,
};
this.updateGhostVisuals(this.activeSession);
}
/**
* Check if currently dragging.
*/
isDragging(): boolean {
return this.activeSession !== null;
}
/**
* Get the ID of the item being dragged, or null.
*/
getDraggedItemId(): string | null {
return this.activeSession?.itemId ?? null;
}
/**
* Get the current position of the dragged ghost container.
*/
getDraggedItemPosition(): { x: number; y: number } {
if (!this.activeSession) return { x: 0, y: 0 };
return {
x: this.activeSession.ghostContainer.x,
y: this.activeSession.ghostContainer.y,
};
}
/**
* Clean up active session and destroy all visuals.
*/
destroy(): void {
if (this.activeSession) {
this.destroySession(this.activeSession);
this.activeSession = null;
}
}
private createGhostContainer(
x: number,
y: number,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
color: number,
): Phaser.GameObjects.Container {
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const cells = transformShape(shape, transform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
ghostContainer.add(ghostGraphics);
return ghostContainer;
}
private updateGhostVisuals(session: DragSession): void {
session.ghostContainer.removeAll(true);
const ghostGraphics = this.scene.add.graphics();
const color = this.getItemColor(session.itemId);
const cells = transformShape(session.itemShape, session.itemTransform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
session.ghostContainer.add(ghostGraphics);
}
private handleDragMove(session: DragSession): void {
const pointer = this.scene.input.activePointer;
session.ghostContainer.setPosition(pointer.x, pointer.y);
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
session.previewGraphics.clear();
if (gridCell) {
const inventory = this.getInventory();
const testTransform = {
...session.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
session.itemShape,
testTransform,
);
const cells = transformShape(session.itemShape, testTransform);
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);
if (validation.valid) {
session.previewGraphics.fillStyle(0x33ff33, 0.3);
session.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
session.previewGraphics.lineStyle(2, 0x33ff33);
session.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
} else {
session.previewGraphics.fillStyle(0xff3333, 0.3);
session.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
session.previewGraphics.lineStyle(2, 0xff3333);
session.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
}
}
}
}
private handleDragEnd(session: DragSession): void {
const pointer = this.scene.input.activePointer;
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
const inventory = this.getInventory();
session.ghostContainer.destroy();
session.previewGraphics.destroy();
session.disposables.dispose();
if (gridCell) {
const testTransform = {
...session.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
session.itemShape,
testTransform,
);
if (validation.valid) {
const item: InventoryItem<GameItemMeta> = {
id: session.itemId,
shape: session.itemShape,
transform: testTransform,
meta: session.itemMeta,
};
this.onPlaceItem(item);
} else {
this.onCreateLostItem(
session.itemId,
session.itemShape,
session.itemTransform,
session.itemMeta,
session.ghostContainer.x,
session.ghostContainer.y,
);
}
} else {
this.onCreateLostItem(
session.itemId,
session.itemShape,
session.itemTransform,
session.itemMeta,
session.ghostContainer.x,
session.ghostContainer.y,
);
}
}
private getWorldGridCell(
worldX: number,
worldY: number,
): { x: number; y: number } | null {
const localX = worldX - this.container.x - this.gridX;
const localY = worldY - this.container.y - this.gridY;
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
const inventory = this.getInventory();
if (
cellX < 0 ||
cellY < 0 ||
cellX >= inventory.width ||
cellY >= inventory.height
) {
return null;
}
return { x: cellX, y: cellY };
}
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;
}
private destroySession(session: DragSession): void {
session.disposables.dispose();
session.ghostContainer.destroy();
session.previewGraphics.destroy();
}
}

View File

@ -1,277 +0,0 @@
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;
}
}

View File

@ -1,239 +0,0 @@
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();
}
}