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 text style */
TITLE_STYLE: { TITLE_STYLE: {
fontSize: "24px", fontSize: "24px",
color: "#ffffff", color: "#888",
fontStyle: "bold", fontStyle: "bold",
} as const, } as const,
/** Subtitle/hint text style */ /** Subtitle/hint text style */
@ -53,7 +53,7 @@ export const GRID_CONFIG = {
/** Item name text style */ /** Item name text style */
ITEM_NAME_STYLE: { ITEM_NAME_STYLE: {
fontSize: "11px", fontSize: "11px",
color: "#ffffff", color: "#888",
fontStyle: "bold", fontStyle: "bold",
} as const, } as const,
} as const; } as const;

View File

@ -1,5 +1,10 @@
import Phaser from "phaser"; 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 { GRID_CONFIG, ITEM_COLORS } from "@/config";
import type { import type {
GameItemMeta, GameItemMeta,
@ -7,15 +12,27 @@ import type {
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
import type { InventorySignal } from "@/state/inventory"; import type { InventorySignal } from "@/state/inventory";
export interface InventoryItemSpawnerCallbacks {
onMoveItem: (itemId: string, newX: number, newY: number) => void;
}
export class InventoryItemSpawner implements Spawner< export class InventoryItemSpawner implements Spawner<
[string, InventoryItem<GameItemMeta>], [string, InventoryItem<GameItemMeta>],
Phaser.GameObjects.Container Phaser.GameObjects.Container
> { > {
private dragState: {
itemId: string;
startX: number;
startY: number;
container: Phaser.GameObjects.Container;
} | null = null;
constructor( constructor(
private scene: Phaser.Scene, private scene: Phaser.Scene,
private inventorySignal: InventorySignal, private inventorySignal: InventorySignal,
private gridOffsetX: number, private gridOffsetX: number,
private gridOffsetY: number, private gridOffsetY: number,
private callbacks: InventoryItemSpawnerCallbacks,
) {} ) {}
*getData(): Iterable<[string, InventoryItem<GameItemMeta>]> { *getData(): Iterable<[string, InventoryItem<GameItemMeta>]> {
@ -46,6 +63,11 @@ export class InventoryItemSpawner implements Spawner<
return cells; 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( onSpawn(
entry: [string, InventoryItem<GameItemMeta>], entry: [string, InventoryItem<GameItemMeta>],
): Phaser.GameObjects.Container | null { ): Phaser.GameObjects.Container | null {
@ -73,7 +95,7 @@ export class InventoryItemSpawner implements Spawner<
const firstCell = cells[0]; const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE; const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * 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( const text = this.scene.add.text(
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2, px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
@ -84,13 +106,104 @@ export class InventoryItemSpawner implements Spawner<
text.setOrigin(0.5); text.setOrigin(0.5);
container.add([graphics, text]); container.add([graphics, text]);
container.setPosition(px, py);
} else { } else {
container.add(graphics); 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; 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( onUpdate(
entry: [string, InventoryItem<GameItemMeta>], entry: [string, InventoryItem<GameItemMeta>],
obj: Phaser.GameObjects.Container, 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 px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
this.scene.tweens.add({ // Don't animate if currently dragging this item
targets: obj, if (this.dragState?.itemId !== entry[0]) {
x: px, this.scene.tweens.add({
y: py, targets: obj,
duration: 200, x: px,
ease: "Power2", y: py,
}); duration: 200,
ease: "Power2",
});
}
} }
} }
@ -123,11 +239,6 @@ export class InventoryItemSpawner implements Spawner<
onComplete: () => obj.destroy(), 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( export function createInventoryItemSpawner(
@ -135,8 +246,15 @@ export function createInventoryItemSpawner(
inventorySignal: InventorySignal, inventorySignal: InventorySignal,
gridOffsetX: number, gridOffsetX: number,
gridOffsetY: number, gridOffsetY: number,
callbacks: InventoryItemSpawnerCallbacks,
) { ) {
return spawnEffect( 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 { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton"; import { createButton } from "@/utils/createButton";
import { GRID_CONFIG } from "@/config"; 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 { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
import { createInventoryItemSpawner } from "./InventoryItemSpawner"; import { createInventoryItemSpawner } from "./InventoryItemSpawner";
import { SceneKey } from "./types"; import { SceneKey } from "./types";
@ -45,7 +45,7 @@ export class InventoryTestScene extends ReactiveScene {
.text( .text(
width / 2, width / 2,
height - 40, height - 40,
"Items update reactively via signals", "Drag items to move them",
GRID_CONFIG.SUBTITLE_STYLE, GRID_CONFIG.SUBTITLE_STYLE,
) )
.setOrigin(0.5); .setOrigin(0.5);
@ -90,6 +90,11 @@ export class InventoryTestScene extends ReactiveScene {
this.inventorySignal, this.inventorySignal,
this.gridOffsetX, this.gridOffsetX,
this.gridOffsetY, this.gridOffsetY,
{
onMoveItem: (itemId: string, newX: number, newY: number) => {
moveItem(this.inventorySignal, itemId, newX, newY);
},
},
); );
this.disposables.add(spawner); this.disposables.add(spawner);
} }

View File

@ -4,6 +4,9 @@ import {
createItemIn, createItemIn,
data, data,
GameItemMeta, GameItemMeta,
placeItem,
removeItemFromGrid,
validatePlacement,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
function genId() { function genId() {
@ -16,9 +19,43 @@ export function createInventorySignal() {
const inventory = createGridInventory<GameItemMeta>(4, 6); const inventory = createGridInventory<GameItemMeta>(4, 6);
const startingItems = data.desert.getStartingItems(); const startingItems = data.desert.getStartingItems();
for (const data of startingItems) { for (const d of startingItems) {
createItemIn(inventory, `${data.id}-${genId()}`, data); createItemIn(inventory, `${d.id}-${genId()}`, d);
} }
return mutableSignal(inventory); 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;
}