import Phaser from 'phaser'; import { MutableSignal } from 'boardgame-core'; import { type GridInventory, type InventoryItem, type GameItemMeta, type RunState, type CellKey, validatePlacement, removeItemFromGrid, placeItem, moveItem, rotateItem, transformShape, } from 'boardgame-core/samples/slay-the-spire-like'; const ITEM_COLORS = [0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633]; export interface InventoryWidgetOptions { scene: Phaser.Scene; gameState: MutableSignal; x: number; y: number; cellSize: number; gridGap?: number; isLocked?: boolean; } interface DragState { itemId: string; itemShape: InventoryItem['shape']; itemTransform: InventoryItem['transform']; itemMeta: InventoryItem['meta']; ghostContainer: Phaser.GameObjects.Container; previewGraphics: Phaser.GameObjects.Graphics; } interface LostItem { id: string; container: Phaser.GameObjects.Container; } 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 itemContainers = new Map(); private itemGraphics = new Map(); private itemTexts = new Map(); private colorMap = new Map(); private colorIdx = 0; private gridGraphics!: Phaser.GameObjects.Graphics; private dragState: DragState | null = null; private lostItems = new Map(); private pointerMoveHandler: (pointer: Phaser.Input.Pointer) => void; private pointerUpHandler: (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.gameState.value.inventory; const gridW = inventory.width * this.cellSize + (inventory.width - 1) * this.gridGap; const gridH = inventory.height * this.cellSize + (inventory.height - 1) * this.gridGap; this.container = this.scene.add.container(options.x, options.y); this.drawGridBackground(inventory.width, inventory.height, gridW, gridH); this.drawItems(); this.setupInput(); this.pointerMoveHandler = this.onPointerMove.bind(this); this.pointerUpHandler = this.onPointerUp.bind(this); this.scene.events.once('shutdown', () => this.destroy()); } private getInventory(): GridInventory { return this.gameState.value.inventory as unknown as GridInventory; } private drawGridBackground(width: number, height: number, gridW: number, gridH: number): void { this.gridGraphics = this.scene.add.graphics(); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const px = this.gridX + x * (this.cellSize + this.gridGap); const py = this.gridY + y * (this.cellSize + this.gridGap); this.gridGraphics.fillStyle(0x1a1a2e); this.gridGraphics.fillRect(px, py, this.cellSize, this.cellSize); this.gridGraphics.lineStyle(2, 0x444477); this.gridGraphics.strokeRect(px, py, this.cellSize, this.cellSize); } } this.container.add(this.gridGraphics); } private drawItems(): void { const inventory = this.getInventory(); for (const [itemId, item] of inventory.items) { if (this.itemContainers.has(itemId)) continue; this.createItemVisuals(itemId, item); } } private createItemVisuals(itemId: string, item: InventoryItem): void { const color = this.colorMap.get(itemId) ?? ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length]; this.colorMap.set(itemId, color); const graphics = this.scene.add.graphics(); this.itemGraphics.set(itemId, 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); this.itemTexts.set(itemId, 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 ); const container = this.scene.add.container(0, 0); container.add(graphics); container.add(text); container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains); container.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (this.isLocked) return; if (this.dragState) return; if (pointer.button === 0) { this.startDrag(itemId, pointer); } }); this.itemContainers.set(itemId, container); this.container.add(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; } private setupInput(): void { this.scene.input.on('pointermove', this.pointerMoveHandler); this.scene.input.on('pointerup', this.pointerUpHandler); this.scene.input.on('pointerdown', this.onPointerDown.bind(this)); } private onPointerDown(pointer: Phaser.Input.Pointer): void { if (!this.dragState) return; if (pointer.button === 1) { this.rotateDraggedItem(); } } private startDrag(itemId: string, pointer: Phaser.Input.Pointer): void { const inventory = this.getInventory(); const item = inventory.items.get(itemId); if (!item) return; this.gameState.produce(state => { removeItemFromGrid(state.inventory, itemId); }); this.removeItemVisuals(itemId); const ghostContainer = this.scene.add.container(pointer.x, pointer.y).setDepth(1000); const ghostGraphics = this.scene.add.graphics(); const color = this.colorMap.get(itemId) ?? 0x888888; for (let y = 0; y < item.shape.height; y++) { for (let x = 0; x < item.shape.width; x++) { if (item.shape.grid[y]?.[x]) { ghostGraphics.fillStyle(color, 0.7); ghostGraphics.fillRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2); ghostGraphics.lineStyle(2, 0xffffff); ghostGraphics.strokeRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize); } } } ghostContainer.add(ghostGraphics); const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5); this.dragState = { itemId, itemShape: item.shape, itemTransform: { ...item.transform, offset: { ...item.transform.offset } }, itemMeta: item.meta, ghostContainer, previewGraphics, }; } private rotateDraggedItem(): void { if (!this.dragState) return; const currentRotation = (this.dragState.itemTransform.rotation + 90) % 360; this.dragState.itemTransform = { ...this.dragState.itemTransform, rotation: currentRotation, }; this.updateGhostVisuals(); } private updateGhostVisuals(): void { if (!this.dragState) return; this.dragState.ghostContainer.removeAll(true); const ghostGraphics = this.scene.add.graphics(); const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888; const cells = transformShape(this.dragState.itemShape, this.dragState.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); } this.dragState.ghostContainer.add(ghostGraphics); } private onPointerMove(pointer: Phaser.Input.Pointer): void { if (!this.dragState) return; this.dragState.ghostContainer.setPosition(pointer.x, pointer.y); const gridCell = this.getWorldGridCell(pointer.x, pointer.y); this.dragState.previewGraphics.clear(); if (gridCell) { const inventory = this.getInventory(); const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } }; const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform); const cells = transformShape(this.dragState.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) { this.dragState.previewGraphics.fillStyle(0x33ff33, 0.3); this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize); this.dragState.previewGraphics.lineStyle(2, 0x33ff33); this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize); } else { this.dragState.previewGraphics.fillStyle(0xff3333, 0.3); this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize); this.dragState.previewGraphics.lineStyle(2, 0xff3333); this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize); } } } } private onPointerUp(pointer: Phaser.Input.Pointer): void { if (!this.dragState) return; const gridCell = this.getWorldGridCell(pointer.x, pointer.y); const inventory = this.getInventory(); this.dragState.ghostContainer.destroy(); this.dragState.previewGraphics.destroy(); if (gridCell) { const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } }; const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform); if (validation.valid) { this.gameState.produce(state => { const item: InventoryItem = { id: this.dragState!.itemId, shape: this.dragState!.itemShape, transform: testTransform, meta: this.dragState!.itemMeta, }; placeItem(state.inventory, item); }); this.createItemVisualsFromDrag(); } else { this.createLostItem(); } } else { this.createLostItem(); } this.dragState = null; } private createItemVisualsFromDrag(): void { if (!this.dragState) return; const inventory = this.getInventory(); const item = inventory.items.get(this.dragState.itemId); if (item) { this.createItemVisuals(this.dragState.itemId, item); } } 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)); return { x: cellX, y: cellY }; } private createLostItem(): void { if (!this.dragState) return; const container = this.scene.add.container( this.dragState.ghostContainer.x, this.dragState.ghostContainer.y ).setDepth(500); const graphics = this.scene.add.graphics(); const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888; const cells = transformShape(this.dragState.itemShape, this.dragState.itemTransform); for (const cell of cells) { graphics.fillStyle(color, 0.5); graphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2); graphics.lineStyle(2, 0xff4444); graphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize); } container.add(graphics); const name = this.dragState.itemMeta?.itemData.name ?? this.dragState.itemId; const text = this.scene.add.text(0, -20, `${name} (lost)`, { fontSize: '12px', color: '#ff4444', fontStyle: 'italic', }).setOrigin(0.5); container.add(text); this.lostItems.set(this.dragState.itemId, { id: this.dragState.itemId, container }); } private removeItemVisuals(itemId: string): void { this.itemContainers.get(itemId)?.destroy(); this.itemGraphics.get(itemId)?.destroy(); this.itemTexts.get(itemId)?.destroy(); this.itemContainers.delete(itemId); this.itemGraphics.delete(itemId); this.itemTexts.delete(itemId); } public setLocked(locked: boolean): void { this.isLocked = locked; } public getLostItems(): string[] { return Array.from(this.lostItems.keys()); } public clearLostItems(): void { for (const lost of this.lostItems.values()) { lost.container.destroy(); } this.lostItems.clear(); } public refresh(): void { const inventory = this.getInventory(); for (const itemId of this.itemContainers.keys()) { if (!inventory.items.has(itemId)) { this.removeItemVisuals(itemId); } } for (const [itemId, item] of inventory.items) { if (!this.itemContainers.has(itemId)) { this.createItemVisuals(itemId, item); } } } public destroy(): void { this.scene.input.off('pointermove', this.pointerMoveHandler); this.scene.input.off('pointerup', this.pointerUpHandler); if (this.dragState) { this.dragState.ghostContainer.destroy(); this.dragState.previewGraphics.destroy(); this.dragState = null; } this.clearLostItems(); for (const container of this.itemContainers.values()) { container.destroy(); } this.itemContainers.clear(); this.itemGraphics.clear(); this.itemTexts.clear(); this.gridGraphics.destroy(); this.container.destroy(); } }