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:
parent
5884e74d8d
commit
29b516c371
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,6 +216,8 @@ 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;
|
||||
|
||||
// Don't animate if currently dragging this item
|
||||
if (this.dragState?.itemId !== entry[0]) {
|
||||
this.scene.tweens.add({
|
||||
targets: obj,
|
||||
x: px,
|
||||
|
|
@ -112,6 +227,7 @@ export class InventoryItemSpawner implements Spawner<
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
||||
this.scene.tweens.add({
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue