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
This commit is contained in:
hypercross 2026-04-20 17:06:46 +08:00
parent 5884e74d8d
commit 29b516c371
4 changed files with 181 additions and 21 deletions

View File

@ -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;

View File

@ -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<GameItemMeta>],
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<GameItemMeta>]> {
@ -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<GameItemMeta>],
): 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<GameItemMeta>],
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,
),
);
}

View File

@ -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);
}

View File

@ -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<GameItemMeta>(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;
}