From 29b516c3714723b2e32a611f77a4a1081c80535e Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 20 Apr 2026 17:06:46 +0800 Subject: [PATCH] feat(sts-viewer): implement drag-and-drop for inventory items Adds interactive drag-and-drop functionality to the inventory spawner, allowing users to move items within the grid. - Implements `InventoryItemSpawnerCallbacks` to handle item movement - Adds `moveItem` utility to update inventory state via signals - Integrates `dragDropEventEffect` for item interaction - Updates `InventoryTestScene` to demonstrate movement - Adjusts text colors in `GRID_CONFIG` for better visibility --- packages/sts-like-viewer/src/config/index.ts | 4 +- .../src/scenes/InventoryItemSpawner.ts | 148 ++++++++++++++++-- .../src/scenes/InventoryTestScene.ts | 9 +- .../sts-like-viewer/src/state/inventory.ts | 41 ++++- 4 files changed, 181 insertions(+), 21 deletions(-) diff --git a/packages/sts-like-viewer/src/config/index.ts b/packages/sts-like-viewer/src/config/index.ts index 5cb099f..038c17f 100644 --- a/packages/sts-like-viewer/src/config/index.ts +++ b/packages/sts-like-viewer/src/config/index.ts @@ -42,7 +42,7 @@ export const GRID_CONFIG = { /** Title text style */ TITLE_STYLE: { fontSize: "24px", - color: "#ffffff", + color: "#888", fontStyle: "bold", } as const, /** Subtitle/hint text style */ @@ -53,7 +53,7 @@ export const GRID_CONFIG = { /** Item name text style */ ITEM_NAME_STYLE: { fontSize: "11px", - color: "#ffffff", + color: "#888", fontStyle: "bold", } as const, } as const; diff --git a/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts b/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts index f0e0d1d..493b39d 100644 --- a/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts +++ b/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts @@ -1,5 +1,10 @@ import Phaser from "phaser"; -import { spawnEffect, type Spawner } from "boardgame-phaser"; +import { + spawnEffect, + dragDropEventEffect, + DragDropEventType, + type Spawner, +} from "boardgame-phaser"; import { GRID_CONFIG, ITEM_COLORS } from "@/config"; import type { GameItemMeta, @@ -7,15 +12,27 @@ import type { } from "boardgame-core/samples/slay-the-spire-like"; import type { InventorySignal } from "@/state/inventory"; +export interface InventoryItemSpawnerCallbacks { + onMoveItem: (itemId: string, newX: number, newY: number) => void; +} + export class InventoryItemSpawner implements Spawner< [string, InventoryItem], Phaser.GameObjects.Container > { + private dragState: { + itemId: string; + startX: number; + startY: number; + container: Phaser.GameObjects.Container; + } | null = null; + constructor( private scene: Phaser.Scene, private inventorySignal: InventorySignal, private gridOffsetX: number, private gridOffsetY: number, + private callbacks: InventoryItemSpawnerCallbacks, ) {} *getData(): Iterable<[string, InventoryItem]> { @@ -46,6 +63,11 @@ export class InventoryItemSpawner implements Spawner< return cells; } + private getItemColor(itemId: string): number { + const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); + return ITEM_COLORS[hash % ITEM_COLORS.length]; + } + onSpawn( entry: [string, InventoryItem], ): Phaser.GameObjects.Container | null { @@ -73,7 +95,7 @@ export class InventoryItemSpawner implements Spawner< const firstCell = cells[0]; const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE; - const itemName = item.meta?.itemData.name ?? item.id; + const itemName = item.meta?.itemData.name ?? itemId; const text = this.scene.add.text( px + GRID_CONFIG.VIEWER_CELL_SIZE / 2, @@ -84,13 +106,104 @@ export class InventoryItemSpawner implements Spawner< text.setOrigin(0.5); container.add([graphics, text]); + container.setPosition(px, py); } else { container.add(graphics); } + // Make container interactive for drag-and-drop + container.setInteractive( + new Phaser.Geom.Rectangle( + 0, + 0, + GRID_CONFIG.VIEWER_CELL_SIZE, + GRID_CONFIG.VIEWER_CELL_SIZE, + ), + Phaser.Geom.Rectangle.Contains, + ); + container.setScrollFactor(0); + container.setSize( + GRID_CONFIG.VIEWER_CELL_SIZE * (item.shape?.width ?? 1), + GRID_CONFIG.VIEWER_CELL_SIZE * (item.shape?.height ?? 1), + ); + + // Setup drag handling + dragDropEventEffect(container, (event) => { + if (event.type === DragDropEventType.DOWN) { + // Start drag + this.dragState = { + itemId, + startX: container.x, + startY: container.y, + container, + }; + container.setAlpha(0.7); + } else if (event.type === DragDropEventType.MOVE) { + // Update drag position + if (this.dragState?.itemId === itemId) { + container.x += event.deltaX; + container.y += event.deltaY; + } + } else if (event.type === DragDropEventType.UP) { + // End drag + if (this.dragState?.itemId === itemId) { + container.setAlpha(1); + this.handleDragEnd(itemId, container); + this.dragState = null; + } + } + }); + return container; } + private handleDragEnd( + itemId: string, + container: Phaser.GameObjects.Container, + ): void { + const inventory = this.inventorySignal.value; + const item = inventory.items.get(itemId); + if (!item) return; + + const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE; + const shapeWidth = item.shape?.width ?? 1; + const shapeHeight = item.shape?.height ?? 1; + + // Calculate target grid position based on container center + const targetX = Math.round((container.x - cellSize / 2) / cellSize); + const targetY = Math.round((container.y - cellSize / 2) / cellSize); + + // Clamp to inventory bounds + const clampedX = Math.max( + 0, + Math.min(targetX, inventory.width - shapeWidth), + ); + const clampedY = Math.max( + 0, + Math.min(targetY, inventory.height - shapeHeight), + ); + + // If position changed, notify callback + if ( + clampedX !== item.transform.offset.x || + clampedY !== item.transform.offset.y + ) { + this.callbacks.onMoveItem(itemId, clampedX, clampedY); + } else { + // Snap back to original position + const originalX = this.gridOffsetX + item.transform.offset.x * cellSize; + const originalY = this.gridOffsetY + item.transform.offset.y * cellSize; + + this.scene.tweens.add({ + targets: container, + x: originalX, + y: originalY, + duration: 150, + ease: "Power2", + }); + } + } + onUpdate( entry: [string, InventoryItem], obj: Phaser.GameObjects.Container, @@ -103,13 +216,16 @@ export class InventoryItemSpawner implements Spawner< const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE; - this.scene.tweens.add({ - targets: obj, - x: px, - y: py, - duration: 200, - ease: "Power2", - }); + // Don't animate if currently dragging this item + if (this.dragState?.itemId !== entry[0]) { + this.scene.tweens.add({ + targets: obj, + x: px, + y: py, + duration: 200, + ease: "Power2", + }); + } } } @@ -123,11 +239,6 @@ export class InventoryItemSpawner implements Spawner< onComplete: () => obj.destroy(), }); } - - private getItemColor(itemId: string): number { - const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); - return ITEM_COLORS[hash % ITEM_COLORS.length]; - } } export function createInventoryItemSpawner( @@ -135,8 +246,15 @@ export function createInventoryItemSpawner( inventorySignal: InventorySignal, gridOffsetX: number, gridOffsetY: number, + callbacks: InventoryItemSpawnerCallbacks, ) { return spawnEffect( - new InventoryItemSpawner(scene, inventorySignal, gridOffsetX, gridOffsetY), + new InventoryItemSpawner( + scene, + inventorySignal, + gridOffsetX, + gridOffsetY, + callbacks, + ), ); } diff --git a/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts b/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts index 1eb8435..408d045 100644 --- a/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts +++ b/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts @@ -1,7 +1,7 @@ import { ReactiveScene } from "boardgame-phaser"; import { createButton } from "@/utils/createButton"; import { GRID_CONFIG } from "@/config"; -import { createInventorySignal } from "@/state/inventory"; +import { createInventorySignal, moveItem } from "@/state/inventory"; import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like"; import { createInventoryItemSpawner } from "./InventoryItemSpawner"; import { SceneKey } from "./types"; @@ -45,7 +45,7 @@ export class InventoryTestScene extends ReactiveScene { .text( width / 2, height - 40, - "Items update reactively via signals", + "Drag items to move them", GRID_CONFIG.SUBTITLE_STYLE, ) .setOrigin(0.5); @@ -90,6 +90,11 @@ export class InventoryTestScene extends ReactiveScene { this.inventorySignal, this.gridOffsetX, this.gridOffsetY, + { + onMoveItem: (itemId: string, newX: number, newY: number) => { + moveItem(this.inventorySignal, itemId, newX, newY); + }, + }, ); this.disposables.add(spawner); } diff --git a/packages/sts-like-viewer/src/state/inventory.ts b/packages/sts-like-viewer/src/state/inventory.ts index 2010d5c..395b0b3 100644 --- a/packages/sts-like-viewer/src/state/inventory.ts +++ b/packages/sts-like-viewer/src/state/inventory.ts @@ -4,6 +4,9 @@ import { createItemIn, data, GameItemMeta, + placeItem, + removeItemFromGrid, + validatePlacement, } from "boardgame-core/samples/slay-the-spire-like"; function genId() { @@ -16,9 +19,43 @@ export function createInventorySignal() { const inventory = createGridInventory(4, 6); const startingItems = data.desert.getStartingItems(); - for (const data of startingItems) { - createItemIn(inventory, `${data.id}-${genId()}`, data); + for (const d of startingItems) { + createItemIn(inventory, `${d.id}-${genId()}`, d); } return mutableSignal(inventory); } + +/** + * Move an item to a new position in the inventory. + * Returns true if the move was successful, false if the new position is invalid. + */ +export function moveItem( + inventorySignal: InventorySignal, + itemId: string, + newX: number, + newY: number, +): boolean { + const inventory = inventorySignal.value; + const item = inventory.items.get(itemId); + + if (!item) { + return false; + } + + const newTransform = { + ...item.transform, + offset: { x: newX, y: newY }, + }; + const validation = validatePlacement(inventory, item.shape, newTransform); + if (!validation.valid) return false; + + inventorySignal.produce((inv) => { + const item = inv.items.get(itemId)!; + removeItemFromGrid(inv, itemId); + item.transform = newTransform; + placeItem(inv, item); + }); + + return true; +}