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 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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue