diff --git a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts index af5aeb5..3037658 100644 --- a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts +++ b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts @@ -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; - private inventoryWidget: InventoryWidget | null = null; constructor(gameState: MutableSignal) { 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"); diff --git a/packages/sts-like-viewer/src/widgets/DragController.ts b/packages/sts-like-viewer/src/widgets/DragController.ts deleted file mode 100644 index f91ae31..0000000 --- a/packages/sts-like-viewer/src/widgets/DragController.ts +++ /dev/null @@ -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["shape"]; - itemTransform: InventoryItem["transform"]; - itemMeta: InventoryItem["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; - getItemColor: (itemId: string) => number; - onPlaceItem: (item: InventoryItem) => void; - onCreateLostItem: ( - itemId: string, - shape: InventoryItem["shape"], - transform: InventoryItem["transform"], - meta: InventoryItem["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; - private getItemColor: (itemId: string) => number; - private onPlaceItem: (item: InventoryItem) => void; - private onCreateLostItem: ( - itemId: string, - shape: InventoryItem["shape"], - transform: InventoryItem["transform"], - meta: InventoryItem["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, - 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["shape"], - transform: InventoryItem["transform"], - meta: InventoryItem["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["shape"], - transform: InventoryItem["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 = { - 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, - ): { 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(); - } -} diff --git a/packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts b/packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts deleted file mode 100644 index 30f5320..0000000 --- a/packages/sts-like-viewer/src/widgets/InventoryItemSpawner.ts +++ /dev/null @@ -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; - parentContainer: Phaser.GameObjects.Container; - cellSize: number; - gridGap: number; - gridX: number; - gridY: number; - isLocked: () => boolean; - isDragging: () => boolean; - onItemDragStart: ( - itemId: string, - item: InventoryItem, - 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, - Phaser.GameObjects.Container -> { - private scene: Phaser.Scene; - private gameState: MutableSignal; - 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, - itemContainer: Phaser.GameObjects.Container, - ) => void; - - private colorMap = new Map(); - private colorIdx = 0; - private draggingIds = new Set(); - - 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> { - const inventory = this.getInventory(); - for (const [, item] of inventory.items) { - if (!this.draggingIds.has(item.id)) { - yield item; - } - } - } - - getKey(item: InventoryItem): string { - return item.id; - } - - onSpawn(item: InventoryItem): 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, - ): void { - obj.removeAllListeners(); - obj.destroy(); - } - - onUpdate( - item: InventoryItem, - 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 { - return this.gameState.value - .inventory as unknown as GridInventory; - } - - private createItemVisuals( - item: InventoryItem, - 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, - 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, - 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, - ): { 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; - } -} diff --git a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts deleted file mode 100644 index 3eb58f1..0000000 --- a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts +++ /dev/null @@ -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; - 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; - 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 { - return this.gameState.value - .inventory as unknown as GridInventory; - } - - private handleItemDragStart( - itemId: string, - item: InventoryItem, - 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): 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["shape"], - transform: InventoryItem["transform"], - meta: InventoryItem["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(); - } -}