refactor: move InventoryItemSpawner and update grid drawing

- Move `InventoryItemSpawner` to `@/gameobjects`
- Wrap grid drawing in `addEffect` in `InventoryTestScene` to ensure
  reactivity
- Fix return type in `onMoveItem` callback
This commit is contained in:
hypercross 2026-04-20 18:44:50 +08:00
parent 7039938a72
commit f03e6c5a7b
4 changed files with 289 additions and 298 deletions

View File

@ -0,0 +1,173 @@
import Phaser from "phaser";
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import {
IDENTITY_TRANSFORM,
ParsedShape,
Transform2D,
transformShape,
type GameItem,
type GameItemMeta,
type InventoryItem,
} from "boardgame-core/samples/slay-the-spire-like";
import { computed, signal, Signal } from "@preact/signals-core";
import { DisposableBag } from "../../../framework/dist";
export interface InventoryItemContainerCallbacks {
onMoveItem: (itemId: string, newX: number, newY: number) => boolean;
}
export class InventoryItemContainer extends Phaser.GameObjects.Container {
private item: Signal<GameItem | undefined>;
constructor(
scene: Phaser.Scene,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemContainerCallbacks,
) {
super(scene, gridOffsetX, gridOffsetY);
scene.add.existing(this);
this.setupInteractive();
const graphics = this.scene.add.graphics();
const label = this.scene.add.text(
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
"",
GRID_CONFIG.ITEM_NAME_STYLE,
);
this.add([graphics, label]);
this.item = signal();
const disposables = new DisposableBag(this);
const itemName = computed(() => {
const item = this.item.value;
return item?.meta?.itemData.name ?? item?.id ?? "";
});
disposables.addEffect(() => {
label.setText(itemName.value);
});
const shape = computed(() => {
return this.item.value?.shape;
});
const color = computed(() => {
return this.getItemColor(this.item.value?.id ?? "");
});
disposables.addEffect(() => {
graphics.clear();
if (!shape.value) return;
this.renderGraphics(graphics, shape.value, color.value);
});
const transform = computed(() => {
return this.item.value?.transform;
});
disposables.addEffect(() => {
if (!transform.value) return;
this.snapBack(transform.value);
});
}
setItem(item: GameItem) {
this.item.value = item;
}
renderGraphics(
graphics: Phaser.GameObjects.Graphics,
shape: ParsedShape,
itemColor: number,
): void {
const cells = transformShape(shape, IDENTITY_TRANSFORM);
for (const cell of cells) {
const localX = (cell.x - cells[0].x) * GRID_CONFIG.VIEWER_CELL_SIZE;
const localY = (cell.y - cells[0].y) * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor);
graphics.fillRect(
localX + 2,
localY + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
}
this.setSize(
GRID_CONFIG.VIEWER_CELL_SIZE * shape.width,
GRID_CONFIG.VIEWER_CELL_SIZE * shape.height,
);
}
private getItemColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
}
private setupInteractive(): void {
this.setScrollFactor(0);
this.setInteractive({ useHandCursor: true });
let startX = 0;
let startY = 0;
dragDropEventEffect(this, (event) => {
if (event.type === DragDropEventType.DOWN) {
startX = this.x;
startY = this.y;
this.setAlpha(0.7);
} else if (event.type === DragDropEventType.MOVE) {
this.x = startX + event.deltaX;
this.y = startY + event.deltaY;
} else if (event.type === DragDropEventType.UP) {
this.setAlpha(1);
if (!this.handleDragEnd()) {
this.x = startX;
this.y = startY;
}
startX = startY = 0;
}
});
}
private handleDragEnd(): boolean {
const item = this.item.value;
if (!item) return false;
const itemId = item.id;
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
const shapeWidth = item.shape?.width ?? 1;
const shapeHeight = item.shape?.height ?? 1;
const x = this.x - this.gridOffsetX;
const y = this.y - this.gridOffsetY;
const targetX = Math.round((x - cellSize / 2) / cellSize);
const targetY = Math.round((y - cellSize / 2) / cellSize);
const clampedX = Math.max(0, Math.min(targetX, 10 - shapeWidth));
const clampedY = Math.max(0, Math.min(targetY, 10 - shapeHeight));
if (
clampedX !== item.transform.offset.x ||
clampedY !== item.transform.offset.y
) {
return this.callbacks.onMoveItem(itemId, clampedX, clampedY);
}
return false;
}
private snapBack(transform: Transform2D): void {
const { x, y } = transform.offset;
const targetX = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const targetY = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
this.scene.tweens.add({
targets: this,
x: targetX,
y: targetY,
duration: 150,
ease: "Power2",
});
}
}

View File

@ -0,0 +1,86 @@
import Phaser from "phaser";
import type { Spawner } from "boardgame-phaser";
import type {
GameItemMeta,
InventoryItem,
} from "boardgame-core/samples/slay-the-spire-like";
import type { InventorySignal } from "@/state/inventory";
import { spawnEffect } from "boardgame-phaser";
import { InventoryItemContainer } from "./InventoryItemContainer";
import type { InventoryItemContainerCallbacks } from "./InventoryItemContainer";
export interface InventoryItemSpawnerCallbacks extends InventoryItemContainerCallbacks {}
export class InventoryItemSpawner implements Spawner<
[string, InventoryItem<GameItemMeta>],
InventoryItemContainer
> {
constructor(
private scene: Phaser.Scene,
private inventorySignal: InventorySignal,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemSpawnerCallbacks,
) {}
*getData(): Iterable<[string, InventoryItem<GameItemMeta>]> {
const inventory = this.inventorySignal.value;
yield* inventory.items.entries();
}
getKey(entry: [string, InventoryItem<GameItemMeta>]): string {
return entry[0];
}
onSpawn(
entry: [string, InventoryItem<GameItemMeta>],
): InventoryItemContainer | null {
const [itemId, item] = entry;
const container = new InventoryItemContainer(
this.scene,
this.gridOffsetX,
this.gridOffsetY,
{
onMoveItem: (id, newX, newY) => {
return this.callbacks.onMoveItem(id, newX, newY);
},
},
);
container.setItem(item);
return container;
}
onUpdate(
entry: [string, InventoryItem<GameItemMeta>],
container: InventoryItemContainer,
): void {
const [itemId, item] = entry;
container.setItem(item);
}
onDespawn(container: InventoryItemContainer): void {
// TODO: add tween
container.destroy();
}
}
export function createInventoryItemSpawner(
scene: Phaser.Scene,
inventorySignal: InventorySignal,
gridOffsetX: number,
gridOffsetY: number,
callbacks: InventoryItemSpawnerCallbacks,
) {
return spawnEffect(
new InventoryItemSpawner(
scene,
inventorySignal,
gridOffsetX,
gridOffsetY,
callbacks,
),
);
}

View File

@ -1,270 +0,0 @@
import Phaser from "phaser";
import {
spawnEffect,
dragDropEventEffect,
DragDropEventType,
type Spawner,
} from "boardgame-phaser";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import type {
GameItemMeta,
InventoryItem,
} 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>]> {
const inventory = this.inventorySignal.value;
yield* inventory.items.entries();
}
getKey(entry: [string, InventoryItem<GameItemMeta>]): string {
return entry[0];
}
private getCells(
item: InventoryItem<GameItemMeta>,
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
const shape = item.shape;
const transform = item.transform;
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
const finalX = x + transform.offset.x;
const finalY = y + transform.offset.y;
cells.push({ x: finalX, y: finalY });
}
}
}
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 {
const [itemId, item] = entry;
const container = this.scene.add.container(0, 0);
const cells = this.getCells(item);
const itemColor = this.getItemColor(itemId);
const graphics = this.scene.add.graphics();
// Draw graphics in container-local coordinates (container is at firstCell position)
for (const cell of cells) {
// Local coordinates relative to container position
const localX = (cell.x - cells[0].x) * GRID_CONFIG.VIEWER_CELL_SIZE;
const localY = (cell.y - cells[0].y) * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor);
graphics.fillRect(
localX + 2,
localY + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
}
if (cells.length > 0) {
const itemName = item.meta?.itemData.name ?? itemId;
// Text is centered in the first cell, relative to container
const textX = GRID_CONFIG.VIEWER_CELL_SIZE / 2;
const textY = GRID_CONFIG.VIEWER_CELL_SIZE / 2;
const text = this.scene.add.text(
textX,
textY,
itemName,
GRID_CONFIG.ITEM_NAME_STYLE,
);
text.setOrigin(0.5);
container.add([graphics, text]);
// Position container at the first cell's world position
const worldX =
this.gridOffsetX + cells[0].x * GRID_CONFIG.VIEWER_CELL_SIZE;
const worldY =
this.gridOffsetY + cells[0].y * GRID_CONFIG.VIEWER_CELL_SIZE;
container.setPosition(worldX, worldY);
} 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 = this.dragState.startX + event.deltaX;
container.y = this.dragState.startY + 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,
): void {
const [, item] = entry;
const cells = this.getCells(item);
if (cells.length > 0) {
// Don't animate if currently dragging this item
if (this.dragState?.itemId !== entry[0]) {
const worldX =
this.gridOffsetX + cells[0].x * GRID_CONFIG.VIEWER_CELL_SIZE;
const worldY =
this.gridOffsetY + cells[0].y * GRID_CONFIG.VIEWER_CELL_SIZE;
this.scene.tweens.add({
targets: obj,
x: worldX,
y: worldY,
duration: 200,
ease: "Power2",
});
}
}
}
onDespawn(obj: Phaser.GameObjects.Container): void {
this.scene.tweens.add({
targets: obj,
alpha: 0,
scale: 0.5,
duration: 200,
ease: "Back.easeIn",
onComplete: () => obj.destroy(),
});
}
}
export function createInventoryItemSpawner(
scene: Phaser.Scene,
inventorySignal: InventorySignal,
gridOffsetX: number,
gridOffsetY: number,
callbacks: InventoryItemSpawnerCallbacks,
) {
return spawnEffect(
new InventoryItemSpawner(
scene,
inventorySignal,
gridOffsetX,
gridOffsetY,
callbacks,
),
);
}

View File

@ -3,7 +3,7 @@ import { createButton } from "@/utils/createButton";
import { GRID_CONFIG } from "@/config";
import { createInventorySignal, moveItem } from "@/state/inventory";
import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
import { createInventoryItemSpawner } from "./InventoryItemSpawner";
import { createInventoryItemSpawner } from "@/gameobjects/InventoryItemSpawner";
import { SceneKey } from "./types";
export class InventoryTestScene extends ReactiveScene {
@ -54,6 +54,7 @@ export class InventoryTestScene extends ReactiveScene {
private drawGrid(invWidth: number, invHeight: number): void {
const graphics = this.add.graphics();
this.addEffect(() => {
for (let y = 0; y < invHeight; y++) {
for (let x = 0; x < invWidth; x++) {
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
@ -82,6 +83,7 @@ export class InventoryTestScene extends ReactiveScene {
);
}
}
});
}
private setupItemSpawner(): void {
@ -92,7 +94,7 @@ export class InventoryTestScene extends ReactiveScene {
this.gridOffsetY,
{
onMoveItem: (itemId: string, newX: number, newY: number) => {
moveItem(this.inventorySignal, itemId, newX, newY);
return moveItem(this.inventorySignal, itemId, newX, newY);
},
},
);