refactor: remove inventory widget and drag controller
Removes the inventory management system and drag-and-drop functionality from the STS-like viewer. This includes deleting the `InventoryWidget`, `InventoryItemSpawner`, and `DragController` classes, as well as cleaning up references in `PlaceholderEncounterScene`.
This commit is contained in:
parent
82df3f2a2f
commit
34a7cff964
|
|
@ -1,26 +1,22 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import {
|
||||
resolveEncounter,
|
||||
removeItem,
|
||||
type RunState,
|
||||
type EncounterResult,
|
||||
type MapNodeType,
|
||||
type MapNode,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { InventoryWidget } from "@/widgets/InventoryWidget";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
|
||||
import type { MutableSignal } from "boardgame-core";
|
||||
|
||||
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
|
||||
/**
|
||||
* 占位符遭遇场景
|
||||
*
|
||||
* 左侧显示背包网格(使用 InventoryWidget),右侧显示遭遇信息。
|
||||
*/
|
||||
export class PlaceholderEncounterScene extends ReactiveScene {
|
||||
private gameState: MutableSignal<RunState>;
|
||||
private inventoryWidget: InventoryWidget | null = null;
|
||||
|
||||
constructor(gameState: MutableSignal<RunState>) {
|
||||
super("PlaceholderEncounterScene");
|
||||
|
|
@ -39,30 +35,9 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
|
||||
const leftPanelW = gridW + 40;
|
||||
|
||||
this.inventoryWidget = new InventoryWidget({
|
||||
scene: this,
|
||||
gameState: this.gameState,
|
||||
x: 60,
|
||||
y: (height - gridH) / 2 + 20,
|
||||
cellSize,
|
||||
gridGap: GRID_CONFIG.GRID_GAP,
|
||||
});
|
||||
|
||||
this.cameras.main.setBounds(0, 0, width, height);
|
||||
this.cameras.main.setScroll(0, 0);
|
||||
|
||||
// Panel background
|
||||
this.add
|
||||
.rectangle(
|
||||
60 + leftPanelW / 2,
|
||||
this.inventoryWidgetY(gridH),
|
||||
leftPanelW + 10,
|
||||
gridH + 50,
|
||||
0x111122,
|
||||
0.9,
|
||||
)
|
||||
.setStrokeStyle(2, 0x5555aa);
|
||||
|
||||
// "背包" title
|
||||
this.add
|
||||
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
|
||||
|
|
@ -92,11 +67,6 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
);
|
||||
}
|
||||
|
||||
private inventoryWidgetY(gridH: number): number {
|
||||
const { height } = this.scale;
|
||||
return (height - gridH) / 2 + 20 + gridH / 2;
|
||||
}
|
||||
|
||||
private drawRightPanel(
|
||||
node: MapNode & { encounter: { name: string; description: string } },
|
||||
leftPanelW: number,
|
||||
|
|
@ -201,15 +171,6 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
const node = state.map.nodes.get(state.currentNodeId);
|
||||
if (!node || !node.encounter) return;
|
||||
|
||||
// Clear lost items from inventory
|
||||
if (this.inventoryWidget) {
|
||||
const lostIds = this.inventoryWidget.getLostItems();
|
||||
for (const lostId of lostIds) {
|
||||
removeItem(state, lostId);
|
||||
}
|
||||
this.inventoryWidget.clearLostItems();
|
||||
}
|
||||
|
||||
const result: EncounterResult = this.generatePlaceholderResult(node.type);
|
||||
resolveEncounter(state, result);
|
||||
await this.sceneController.launch("GameFlowScene");
|
||||
|
|
|
|||
|
|
@ -1,477 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
import {
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type GridInventory,
|
||||
validatePlacement,
|
||||
transformShape,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
||||
import { DisposableBag } from "boardgame-phaser";
|
||||
|
||||
export interface DragSession {
|
||||
itemId: string;
|
||||
itemShape: InventoryItem<GameItemMeta>["shape"];
|
||||
itemTransform: InventoryItem<GameItemMeta>["transform"];
|
||||
itemMeta: InventoryItem<GameItemMeta>["meta"];
|
||||
ghostContainer: Phaser.GameObjects.Container;
|
||||
previewGraphics: Phaser.GameObjects.Graphics;
|
||||
disposables: DisposableBag;
|
||||
}
|
||||
|
||||
export interface DragControllerOptions {
|
||||
scene: Phaser.Scene;
|
||||
container: Phaser.GameObjects.Container;
|
||||
cellSize: number;
|
||||
gridGap: number;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
getInventory: () => GridInventory<GameItemMeta>;
|
||||
getItemColor: (itemId: string) => number;
|
||||
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
||||
onCreateLostItem: (
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
|
||||
* Manages ghost visuals, placement preview, rotation, and validation.
|
||||
*/
|
||||
export class DragController {
|
||||
private scene: Phaser.Scene;
|
||||
private container: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX: number;
|
||||
private gridY: number;
|
||||
private getInventory: () => GridInventory<GameItemMeta>;
|
||||
private getItemColor: (itemId: string) => number;
|
||||
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
||||
private onCreateLostItem: (
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
) => void;
|
||||
|
||||
private activeSession: DragSession | null = null;
|
||||
|
||||
constructor(options: DragControllerOptions) {
|
||||
this.scene = options.scene;
|
||||
this.container = options.container;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap;
|
||||
this.gridX = options.gridX;
|
||||
this.gridY = options.gridY;
|
||||
this.getInventory = options.getInventory;
|
||||
this.getItemColor = options.getItemColor;
|
||||
this.onPlaceItem = options.onPlaceItem;
|
||||
this.onCreateLostItem = options.onCreateLostItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a drag session for an inventory item.
|
||||
* Uses dragDropEventEffect for pointer tracking and event emission.
|
||||
*/
|
||||
startDrag(
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
): () => void {
|
||||
const cells = this.getItemCells(item);
|
||||
const firstCell = cells[0];
|
||||
const worldX =
|
||||
this.container.x +
|
||||
this.gridX +
|
||||
firstCell.x * (this.cellSize + this.gridGap);
|
||||
const worldY =
|
||||
this.container.y +
|
||||
this.gridY +
|
||||
firstCell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
const ghostContainer = this.createGhostContainer(
|
||||
worldX,
|
||||
worldY,
|
||||
item.shape,
|
||||
item.transform,
|
||||
this.getItemColor(itemId),
|
||||
);
|
||||
const previewGraphics = this.scene.add
|
||||
.graphics()
|
||||
.setDepth(999)
|
||||
.setAlpha(0.5);
|
||||
|
||||
const disposables = new DisposableBag();
|
||||
const session: DragSession = {
|
||||
itemId,
|
||||
itemShape: item.shape,
|
||||
itemTransform: {
|
||||
...item.transform,
|
||||
offset: { ...item.transform.offset },
|
||||
},
|
||||
itemMeta: item.meta,
|
||||
ghostContainer,
|
||||
previewGraphics,
|
||||
disposables,
|
||||
};
|
||||
|
||||
this.activeSession = session;
|
||||
|
||||
// Set up drag-drop event handling via framework utility
|
||||
const disposeDrag = dragDropEventEffect(
|
||||
itemContainer as Phaser.GameObjects.GameObject,
|
||||
disposables,
|
||||
);
|
||||
|
||||
itemContainer.on("dragstart", () => {
|
||||
ghostContainer.setVisible(true);
|
||||
});
|
||||
|
||||
itemContainer.on("dragmove", () => {
|
||||
this.handleDragMove(session);
|
||||
});
|
||||
|
||||
itemContainer.on("dragend", () => {
|
||||
this.handleDragEnd(session);
|
||||
disposeDrag();
|
||||
this.activeSession = null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeDrag();
|
||||
this.destroySession(session);
|
||||
this.activeSession = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a drag session for a lost item.
|
||||
*/
|
||||
startLostItemDrag(
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
lostContainer: Phaser.GameObjects.Container,
|
||||
): () => void {
|
||||
const pointer = this.scene.input.activePointer;
|
||||
const ghostContainer = this.createGhostContainer(
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
shape,
|
||||
transform,
|
||||
this.getItemColor(itemId),
|
||||
);
|
||||
const previewGraphics = this.scene.add
|
||||
.graphics()
|
||||
.setDepth(999)
|
||||
.setAlpha(0.5);
|
||||
|
||||
const disposables = new DisposableBag();
|
||||
const session: DragSession = {
|
||||
itemId,
|
||||
itemShape: shape,
|
||||
itemTransform: { ...transform, offset: { ...transform.offset } },
|
||||
itemMeta: meta,
|
||||
ghostContainer,
|
||||
previewGraphics,
|
||||
disposables,
|
||||
};
|
||||
|
||||
this.activeSession = session;
|
||||
|
||||
const disposeDrag = dragDropEventEffect(
|
||||
lostContainer as Phaser.GameObjects.GameObject,
|
||||
disposables,
|
||||
);
|
||||
|
||||
lostContainer.on("dragstart", () => {
|
||||
ghostContainer.setVisible(true);
|
||||
});
|
||||
|
||||
lostContainer.on("dragmove", () => {
|
||||
this.handleDragMove(session);
|
||||
});
|
||||
|
||||
lostContainer.on("dragend", () => {
|
||||
this.handleDragEnd(session);
|
||||
disposeDrag();
|
||||
this.activeSession = null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeDrag();
|
||||
this.destroySession(session);
|
||||
this.activeSession = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the currently dragged item by 90 degrees.
|
||||
*/
|
||||
rotateDraggedItem(): void {
|
||||
if (!this.activeSession) return;
|
||||
|
||||
const currentRotation =
|
||||
(this.activeSession.itemTransform.rotation + 90) % 360;
|
||||
this.activeSession.itemTransform = {
|
||||
...this.activeSession.itemTransform,
|
||||
rotation: currentRotation,
|
||||
};
|
||||
|
||||
this.updateGhostVisuals(this.activeSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently dragging.
|
||||
*/
|
||||
isDragging(): boolean {
|
||||
return this.activeSession !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the item being dragged, or null.
|
||||
*/
|
||||
getDraggedItemId(): string | null {
|
||||
return this.activeSession?.itemId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current position of the dragged ghost container.
|
||||
*/
|
||||
getDraggedItemPosition(): { x: number; y: number } {
|
||||
if (!this.activeSession) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: this.activeSession.ghostContainer.x,
|
||||
y: this.activeSession.ghostContainer.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up active session and destroy all visuals.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.activeSession) {
|
||||
this.destroySession(this.activeSession);
|
||||
this.activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createGhostContainer(
|
||||
x: number,
|
||||
y: number,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
color: number,
|
||||
): Phaser.GameObjects.Container {
|
||||
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
|
||||
const cells = transformShape(shape, transform);
|
||||
for (const cell of cells) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize - 2,
|
||||
this.cellSize - 2,
|
||||
);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
ghostContainer.add(ghostGraphics);
|
||||
|
||||
return ghostContainer;
|
||||
}
|
||||
|
||||
private updateGhostVisuals(session: DragSession): void {
|
||||
session.ghostContainer.removeAll(true);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
const color = this.getItemColor(session.itemId);
|
||||
|
||||
const cells = transformShape(session.itemShape, session.itemTransform);
|
||||
for (const cell of cells) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize - 2,
|
||||
this.cellSize - 2,
|
||||
);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
session.ghostContainer.add(ghostGraphics);
|
||||
}
|
||||
|
||||
private handleDragMove(session: DragSession): void {
|
||||
const pointer = this.scene.input.activePointer;
|
||||
session.ghostContainer.setPosition(pointer.x, pointer.y);
|
||||
|
||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
||||
session.previewGraphics.clear();
|
||||
|
||||
if (gridCell) {
|
||||
const inventory = this.getInventory();
|
||||
const testTransform = {
|
||||
...session.itemTransform,
|
||||
offset: { x: gridCell.x, y: gridCell.y },
|
||||
};
|
||||
const validation = validatePlacement(
|
||||
inventory,
|
||||
session.itemShape,
|
||||
testTransform,
|
||||
);
|
||||
|
||||
const cells = transformShape(session.itemShape, testTransform);
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
if (validation.valid) {
|
||||
session.previewGraphics.fillStyle(0x33ff33, 0.3);
|
||||
session.previewGraphics.fillRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
session.previewGraphics.lineStyle(2, 0x33ff33);
|
||||
session.previewGraphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
} else {
|
||||
session.previewGraphics.fillStyle(0xff3333, 0.3);
|
||||
session.previewGraphics.fillRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
session.previewGraphics.lineStyle(2, 0xff3333);
|
||||
session.previewGraphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDragEnd(session: DragSession): void {
|
||||
const pointer = this.scene.input.activePointer;
|
||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
||||
const inventory = this.getInventory();
|
||||
|
||||
session.ghostContainer.destroy();
|
||||
session.previewGraphics.destroy();
|
||||
session.disposables.dispose();
|
||||
|
||||
if (gridCell) {
|
||||
const testTransform = {
|
||||
...session.itemTransform,
|
||||
offset: { x: gridCell.x, y: gridCell.y },
|
||||
};
|
||||
const validation = validatePlacement(
|
||||
inventory,
|
||||
session.itemShape,
|
||||
testTransform,
|
||||
);
|
||||
|
||||
if (validation.valid) {
|
||||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: session.itemId,
|
||||
shape: session.itemShape,
|
||||
transform: testTransform,
|
||||
meta: session.itemMeta,
|
||||
};
|
||||
this.onPlaceItem(item);
|
||||
} else {
|
||||
this.onCreateLostItem(
|
||||
session.itemId,
|
||||
session.itemShape,
|
||||
session.itemTransform,
|
||||
session.itemMeta,
|
||||
session.ghostContainer.x,
|
||||
session.ghostContainer.y,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.onCreateLostItem(
|
||||
session.itemId,
|
||||
session.itemShape,
|
||||
session.itemTransform,
|
||||
session.itemMeta,
|
||||
session.ghostContainer.x,
|
||||
session.ghostContainer.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getWorldGridCell(
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
): { x: number; y: number } | null {
|
||||
const localX = worldX - this.container.x - this.gridX;
|
||||
const localY = worldY - this.container.y - this.gridY;
|
||||
|
||||
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
|
||||
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
|
||||
|
||||
const inventory = this.getInventory();
|
||||
if (
|
||||
cellX < 0 ||
|
||||
cellY < 0 ||
|
||||
cellX >= inventory.width ||
|
||||
cellY >= inventory.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: cellX, y: cellY };
|
||||
}
|
||||
|
||||
private getItemCells(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
): { x: number; y: number }[] {
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
const { offset } = item.transform;
|
||||
for (let y = 0; y < item.shape.height; y++) {
|
||||
for (let x = 0; x < item.shape.width; x++) {
|
||||
if (item.shape.grid[y]?.[x]) {
|
||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private destroySession(session: DragSession): void {
|
||||
session.disposables.dispose();
|
||||
session.ghostContainer.destroy();
|
||||
session.previewGraphics.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import {
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type RunState,
|
||||
type GridInventory,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
const ITEM_COLORS = [
|
||||
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
|
||||
0xff6633,
|
||||
];
|
||||
|
||||
export interface InventoryItemSpawnerOptions {
|
||||
scene: Phaser.Scene;
|
||||
gameState: MutableSignal<RunState>;
|
||||
parentContainer: Phaser.GameObjects.Container;
|
||||
cellSize: number;
|
||||
gridGap: number;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
isLocked: () => boolean;
|
||||
isDragging: () => boolean;
|
||||
onItemDragStart: (
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawner for inventory items using the boardgame-phaser Spawner pattern.
|
||||
* Reactively spawns/despawns/updates item visuals when gameState.inventory changes.
|
||||
*
|
||||
* Items currently being dragged are excluded from getData() to prevent
|
||||
* the spawner from respawning them while they're in flight.
|
||||
*/
|
||||
export class InventoryItemSpawner implements Spawner<
|
||||
InventoryItem<GameItemMeta>,
|
||||
Phaser.GameObjects.Container
|
||||
> {
|
||||
private scene: Phaser.Scene;
|
||||
private gameState: MutableSignal<RunState>;
|
||||
private parentContainer: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX: number;
|
||||
private gridY: number;
|
||||
private isLocked: () => boolean;
|
||||
private isDragging: () => boolean;
|
||||
private onItemDragStart: (
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
) => void;
|
||||
|
||||
private colorMap = new Map<string, number>();
|
||||
private colorIdx = 0;
|
||||
private draggingIds = new Set<string>();
|
||||
|
||||
constructor(options: InventoryItemSpawnerOptions) {
|
||||
this.scene = options.scene;
|
||||
this.gameState = options.gameState;
|
||||
this.parentContainer = options.parentContainer;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap;
|
||||
this.gridX = options.gridX;
|
||||
this.gridY = options.gridY;
|
||||
this.isLocked = options.isLocked;
|
||||
this.isDragging = options.isDragging;
|
||||
this.onItemDragStart = options.onItemDragStart;
|
||||
}
|
||||
|
||||
*getData(): Iterable<InventoryItem<GameItemMeta>> {
|
||||
const inventory = this.getInventory();
|
||||
for (const [, item] of inventory.items) {
|
||||
if (!this.draggingIds.has(item.id)) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getKey(item: InventoryItem<GameItemMeta>): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
onSpawn(item: InventoryItem<GameItemMeta>): Phaser.GameObjects.Container {
|
||||
const color =
|
||||
this.colorMap.get(item.id) ??
|
||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
|
||||
this.colorMap.set(item.id, color);
|
||||
|
||||
const container = this.createItemVisuals(item, color);
|
||||
this.setupInteraction(item, container);
|
||||
this.parentContainer.add(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(
|
||||
obj: Phaser.GameObjects.Container,
|
||||
_item: InventoryItem<GameItemMeta>,
|
||||
): void {
|
||||
obj.removeAllListeners();
|
||||
obj.destroy();
|
||||
}
|
||||
|
||||
onUpdate(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
obj: Phaser.GameObjects.Container,
|
||||
): void {
|
||||
const color = this.colorMap.get(item.id) ?? 0x888888;
|
||||
this.rebuildItemVisuals(obj, item, color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as being dragged so the spawner excludes it from getData().
|
||||
* Call this before removing the item from the inventory.
|
||||
*/
|
||||
markDragging(itemId: string): void {
|
||||
this.draggingIds.add(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark an item after drag ends (placed or lost).
|
||||
*/
|
||||
unmarkDragging(itemId: string): void {
|
||||
this.draggingIds.delete(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color assigned to an item (creates one if not yet assigned).
|
||||
*/
|
||||
getItemColor(itemId: string): number {
|
||||
if (!this.colorMap.has(itemId)) {
|
||||
this.colorMap.set(
|
||||
itemId,
|
||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length],
|
||||
);
|
||||
}
|
||||
return this.colorMap.get(itemId)!;
|
||||
}
|
||||
|
||||
private getInventory(): GridInventory<GameItemMeta> {
|
||||
return this.gameState.value
|
||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
||||
}
|
||||
|
||||
private createItemVisuals(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
color: number,
|
||||
): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(0, 0);
|
||||
|
||||
const graphics = this.scene.add.graphics();
|
||||
const cells = this.getItemCells(item);
|
||||
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
}
|
||||
|
||||
const firstCell = cells[0];
|
||||
const name = item.meta?.itemData.name ?? item.id;
|
||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
||||
const text = this.scene.add
|
||||
.text(
|
||||
this.gridX +
|
||||
firstCell.x * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
this.gridY +
|
||||
firstCell.y * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
name,
|
||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
container.add(graphics);
|
||||
container.add(text);
|
||||
|
||||
const hitRect = new Phaser.Geom.Rectangle(
|
||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private rebuildItemVisuals(
|
||||
container: Phaser.GameObjects.Container,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
color: number,
|
||||
): void {
|
||||
container.removeAll(true);
|
||||
|
||||
const graphics = this.scene.add.graphics();
|
||||
const cells = this.getItemCells(item);
|
||||
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
}
|
||||
|
||||
const firstCell = cells[0];
|
||||
const name = item.meta?.itemData.name ?? item.id;
|
||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
||||
const text = this.scene.add
|
||||
.text(
|
||||
this.gridX +
|
||||
firstCell.x * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
this.gridY +
|
||||
firstCell.y * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
name,
|
||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
container.add(graphics);
|
||||
container.add(text);
|
||||
|
||||
const hitRect = new Phaser.Geom.Rectangle(
|
||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
||||
}
|
||||
|
||||
private setupInteraction(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
container: Phaser.GameObjects.Container,
|
||||
): void {
|
||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||
// Guard against stale events firing on destroyed containers
|
||||
if (!container.scene || !container.active) return;
|
||||
if (this.isLocked()) return;
|
||||
if (this.isDragging()) return;
|
||||
if (pointer.button === 0) {
|
||||
this.onItemDragStart(item.id, item, container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getItemCells(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
): { x: number; y: number }[] {
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
const { offset } = item.transform;
|
||||
for (let y = 0; y < item.shape.height; y++) {
|
||||
for (let x = 0; x < item.shape.width; x++) {
|
||||
if (item.shape.grid[y]?.[x]) {
|
||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import { spawnEffect } from "boardgame-phaser";
|
||||
import {
|
||||
type GridInventory,
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type RunState,
|
||||
removeItemFromGrid,
|
||||
placeItem,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { InventoryItemSpawner } from "./InventoryItemSpawner";
|
||||
import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
|
||||
import { DragController } from "./DragController";
|
||||
import { LostItemManager } from "./LostItemManager";
|
||||
|
||||
export interface InventoryWidgetOptions {
|
||||
scene: Phaser.Scene;
|
||||
gameState: MutableSignal<RunState>;
|
||||
x: number;
|
||||
y: number;
|
||||
cellSize: number;
|
||||
gridGap?: number;
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory widget using the Spawner pattern for reactive item rendering.
|
||||
*
|
||||
* Architecture:
|
||||
* - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals
|
||||
* - GridBackgroundRenderer: static grid background drawn once
|
||||
* - DragController: event-driven drag logic via dragDropEventEffect
|
||||
* - LostItemManager: tracks items dropped outside valid placement
|
||||
*/
|
||||
export class InventoryWidget {
|
||||
private scene: Phaser.Scene;
|
||||
private gameState: MutableSignal<RunState>;
|
||||
private container: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX = 0;
|
||||
private gridY = 0;
|
||||
private isLocked: boolean;
|
||||
|
||||
private itemSpawner: InventoryItemSpawner;
|
||||
private backgroundRenderer: GridBackgroundRenderer;
|
||||
private dragController: DragController;
|
||||
private lostItemManager: LostItemManager;
|
||||
|
||||
private spawnDispose: (() => void) | null = null;
|
||||
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
|
||||
|
||||
constructor(options: InventoryWidgetOptions) {
|
||||
this.scene = options.scene;
|
||||
this.gameState = options.gameState;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap ?? 2;
|
||||
this.isLocked = options.isLocked ?? false;
|
||||
|
||||
const inventory = this.getInventory();
|
||||
|
||||
this.container = this.scene.add.container(options.x, options.y);
|
||||
|
||||
// 1. Static grid background (drawn once)
|
||||
this.backgroundRenderer = new GridBackgroundRenderer({
|
||||
scene: this.scene,
|
||||
parentContainer: this.container,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
gridX: this.gridX,
|
||||
gridY: this.gridY,
|
||||
});
|
||||
this.backgroundRenderer.draw(inventory.width, inventory.height);
|
||||
|
||||
// 2. Reactive item spawner
|
||||
this.itemSpawner = new InventoryItemSpawner({
|
||||
scene: this.scene,
|
||||
gameState: this.gameState,
|
||||
parentContainer: this.container,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
gridX: this.gridX,
|
||||
gridY: this.gridY,
|
||||
isLocked: () => this.isLocked,
|
||||
isDragging: () => this.dragController.isDragging(),
|
||||
onItemDragStart: (itemId, item, itemContainer) => {
|
||||
this.handleItemDragStart(itemId, item, itemContainer);
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Drag controller
|
||||
this.dragController = new DragController({
|
||||
scene: this.scene,
|
||||
container: this.container,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
gridX: this.gridX,
|
||||
gridY: this.gridY,
|
||||
getInventory: () => this.getInventory(),
|
||||
getItemColor: (id) => this.itemSpawner.getItemColor(id),
|
||||
onPlaceItem: (item) => this.handlePlaceItem(item),
|
||||
onCreateLostItem: (id, shape, transform, meta, x, y) =>
|
||||
this.handleCreateLostItem(id, shape, transform, meta, x, y),
|
||||
});
|
||||
|
||||
// 4. Lost item manager
|
||||
this.lostItemManager = new LostItemManager({
|
||||
scene: this.scene,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
getItemColor: (id) => this.itemSpawner.getItemColor(id),
|
||||
onLostItemDragStart: (id, lostContainer) =>
|
||||
this.dragController.startLostItemDrag(
|
||||
id,
|
||||
this.getLostItemShape(id),
|
||||
this.getLostItemTransform(id),
|
||||
this.getLostItemMeta(id),
|
||||
lostContainer,
|
||||
),
|
||||
isDragging: () => this.dragController.isDragging(),
|
||||
});
|
||||
|
||||
// Activate the spawner effect (auto-cleans up on dispose)
|
||||
this.spawnDispose = spawnEffect(this.itemSpawner);
|
||||
|
||||
// Right-click rotation handler
|
||||
this.setupInput();
|
||||
|
||||
this.scene.events.once("shutdown", () => this.destroy());
|
||||
}
|
||||
|
||||
private getInventory(): GridInventory<GameItemMeta> {
|
||||
return this.gameState.value
|
||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
||||
}
|
||||
|
||||
private handleItemDragStart(
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
): void {
|
||||
// Mark as dragging FIRST so spawner excludes it from getData().
|
||||
// This prevents the spawner effect from destroying the container
|
||||
// when we later update the inventory state.
|
||||
this.itemSpawner.markDragging(itemId);
|
||||
|
||||
// Start drag session
|
||||
this.dragController.startDrag(itemId, item, itemContainer);
|
||||
}
|
||||
|
||||
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
|
||||
this.gameState.produce((state) => {
|
||||
placeItem(state.inventory, item);
|
||||
});
|
||||
|
||||
// Unmark dragging so spawner picks it up on next effect run
|
||||
this.itemSpawner.unmarkDragging(item.id);
|
||||
}
|
||||
|
||||
private handleCreateLostItem(
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
// Remove from inventory since it's dropped outside valid placement
|
||||
this.gameState.produce((state) => {
|
||||
removeItemFromGrid(state.inventory, itemId);
|
||||
});
|
||||
|
||||
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y);
|
||||
|
||||
// Unmark dragging — item is now "lost" and managed by LostItemManager
|
||||
this.itemSpawner.unmarkDragging(itemId);
|
||||
}
|
||||
|
||||
private getLostItemShape(itemId: string) {
|
||||
return this.lostItemManager.getLostItem(itemId)?.shape!;
|
||||
}
|
||||
|
||||
private getLostItemTransform(itemId: string) {
|
||||
return this.lostItemManager.getLostItem(itemId)?.transform!;
|
||||
}
|
||||
|
||||
private getLostItemMeta(itemId: string) {
|
||||
return this.lostItemManager.getLostItem(itemId)?.meta!;
|
||||
}
|
||||
|
||||
private setupInput(): void {
|
||||
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.dragController.isDragging()) return;
|
||||
if (pointer.button === 1) {
|
||||
this.dragController.rotateDraggedItem();
|
||||
}
|
||||
};
|
||||
|
||||
this.scene.input.on("pointerdown", this.rightClickHandler);
|
||||
}
|
||||
|
||||
public setLocked(locked: boolean): void {
|
||||
this.isLocked = locked;
|
||||
}
|
||||
|
||||
public getLostItems(): string[] {
|
||||
return this.lostItemManager.getLostItemIds();
|
||||
}
|
||||
|
||||
public clearLostItems(): void {
|
||||
this.lostItemManager.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-sync of item visuals with current inventory state.
|
||||
* With spawnEffect this is usually automatic, but useful after
|
||||
* external state changes that don't trigger the effect.
|
||||
*/
|
||||
public refresh(): void {
|
||||
// The spawner effect automatically re-syncs when gameState.value changes.
|
||||
// If immediate refresh is needed, reading the signal triggers the effect.
|
||||
void this.gameState.value;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.scene.input.off("pointerdown", this.rightClickHandler);
|
||||
|
||||
if (this.spawnDispose) {
|
||||
this.spawnDispose();
|
||||
this.spawnDispose = null;
|
||||
}
|
||||
|
||||
this.dragController.destroy();
|
||||
this.lostItemManager.destroy();
|
||||
this.backgroundRenderer.destroy();
|
||||
this.container.destroy();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue