refactor: support multiple inventory surfaces

Introduce `InventorySurface` to allow items to be moved between
different inventory grids.

- Update `InventoryItemContainer` to use surface-specific cell sizes
  and coordinate transformations.
- Update `InventoryItemSpawner` to handle multiple surfaces.
- Refactor `moveItem` to support transferring items from one
  `InventorySignal` to another.
- Add `InventorySurfaceState` to manage surface-specific metadata.
- Remove reliance on global `GRID_CONFIG` in favor of surface data.
This commit is contained in:
hypercross 2026-04-21 16:39:10 +08:00
parent 093eadbefd
commit 422ddde200
5 changed files with 191 additions and 129 deletions

View File

@ -11,44 +11,44 @@ import {
} from "boardgame-core/samples/slay-the-spire-like";
import { DisposableBag } from "../../../framework/dist";
import { InventoryItemState } from "@/state/InventoryItemState";
export interface InventoryItemContainerCallbacks {
onMoveItem: (
itemId: string,
newX: number,
newY: number,
newRotation: number,
) => boolean;
}
import { moveItem } from "@/state/inventory";
import {
InventorySurface,
InventorySurfaceState,
inventoryToScene,
sceneToInventory,
} from "@/state/inventorySurfaceState";
export class InventoryItemContainer extends Phaser.GameObjects.Container {
private itemState: InventoryItemState;
private surfaceState: InventorySurfaceState;
private surfaces: Iterable<InventorySurface>;
private hitArea: Point2D[] = [];
constructor(
scene: Phaser.Scene,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemContainerCallbacks,
item: GameItem,
surface: InventorySurface,
surfaces: Iterable<InventorySurface>,
) {
super(scene, gridOffsetX, gridOffsetY);
super(scene, surface.gridOffsetX, surface.gridOffsetY);
scene.add.existing(this);
const cellSize = surface.cellSize;
const graphics = this.scene.add.graphics();
graphics.setPosition(
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
);
graphics.setPosition(cellSize / 2, cellSize / 2);
const label = this.scene.add.text(
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
cellSize / 2,
cellSize / 2,
"",
GRID_CONFIG.ITEM_NAME_STYLE,
);
this.add([graphics, label]);
this.setupInteractive();
this.itemState = new InventoryItemState();
this.itemState = new InventoryItemState(item);
this.surfaceState = new InventorySurfaceState(surface);
this.surfaces = surfaces;
const disposables = new DisposableBag(this);
@ -60,9 +60,9 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
disposables.addEffect(() => {
graphics.clear();
if (!this.itemState.shape.value) return;
this.renderGraphics(
graphics,
this.surfaceState.cellSize,
this.itemState.shape.value,
this.itemState.color.value,
);
@ -79,13 +79,13 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
});
disposables.addEffect(() => {
if (!this.itemState.transform.value) return;
this.snapBack(this.itemState.transform.value);
this.itemState.setPreviewRotation(0);
});
}
setItem(item: GameItem): void {
setSurfaceItem(surface: InventorySurface, item: GameItem): void {
this.surfaceState.setSurface(surface);
this.itemState.setItem(item);
}
@ -100,7 +100,7 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
flipX: false,
flipY: false,
});
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
const cellSize = this.surfaceState.cellSize;
return cells.map((cell) => ({
x: (cell.x - cells[0].x) * cellSize,
@ -110,28 +110,28 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
private renderGraphics(
graphics: Phaser.GameObjects.Graphics,
cellSize: number,
shape: ParsedShape,
itemColor: number,
) {
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;
const localX = (cell.x - cells[0].x) * cellSize;
const localY = (cell.y - cells[0].y) * cellSize;
graphics.fillStyle(itemColor);
graphics.fillRect(
localX + 2 - GRID_CONFIG.VIEWER_CELL_SIZE / 2,
localY + 2 - GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
localX + 2 - cellSize / 2,
localY + 2 - cellSize / 2,
cellSize - 4,
cellSize - 4,
);
}
}
private setupInteractive() {
this.setScrollFactor(0);
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
this.setInteractive({
hitArea: this,
hitAreaCallback: (
@ -142,9 +142,9 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
hitArea.hitArea.some(
(cell) =>
x >= cell.x &&
x < cell.x + cellSize &&
x < cell.x + this.surfaceState.cellSize &&
y >= cell.y &&
y < cell.y + cellSize,
y < cell.y + this.surfaceState.cellSize,
),
useHandCursor: true,
} as Phaser.Types.Input.InputConfiguration);
@ -164,58 +164,68 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
} else if (event.type === DragDropEventType.ALTBUTTON) {
this.itemState.addPreviewRotation(90);
} else if (event.type === DragDropEventType.UP) {
this.setAlpha(1);
const finalRotation = this.itemState.previewRotation.peek();
if (!this.handleDragEnd(finalRotation)) {
const finalX = this.x;
const finalY = this.y;
if (!this.handleDragEnd(finalX, finalY, finalRotation)) {
const t = this.itemState.transform.peek();
t && this.snapBack(t);
}
this.setAlpha(1);
this.itemState.setPreviewRotation(0);
startX = startY = 0;
}
});
}
private handleDragEnd(finalRotation: number): boolean {
private handleDragEnd(
finalX: number,
finalY: number,
finalRotation: number,
): boolean {
const item = this.itemState.item;
if (!item) return false;
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);
const targetY = Math.round(y / cellSize);
const clampedX = Math.max(0, Math.min(targetX, 10 - shapeWidth));
const clampedY = Math.max(0, Math.min(targetY, 10 - shapeHeight));
const target = this.findDropSurface(finalX, finalY);
if (!target) return false;
if (
clampedX !== item.transform.offset.x ||
clampedY !== item.transform.offset.y ||
finalRotation !== item.transform.rotation
) {
return this.callbacks.onMoveItem(
this.surfaceState.surface === target.surface &&
item.transform.offset.x === target.x &&
item.transform.offset.y === target.x &&
item.transform.rotation === finalRotation
)
return false;
return moveItem(
this.surfaceState.invSignal,
target.surface.invSignal,
item.id,
clampedX,
clampedY,
target.x,
target.y,
finalRotation,
);
}
return false;
private findDropSurface(x: number, y: number) {
for (const surface of this.surfaces) {
const target = sceneToInventory(surface, x, y);
if (target)
return {
surface,
...target,
};
}
return null;
}
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;
const target = inventoryToScene(this.surfaceState, x, y);
this.scene.tweens.add({
targets: this,
x: targetX,
y: targetY,
...target,
duration: 150,
ease: "Power2",
});

View File

@ -1,64 +1,53 @@
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 type { GameItem } from "boardgame-core/samples/slay-the-spire-like";
import { spawnEffect } from "boardgame-phaser";
import { InventoryItemContainer } from "./InventoryItemContainer";
import type { InventoryItemContainerCallbacks } from "./InventoryItemContainer";
export interface InventoryItemSpawnerCallbacks extends InventoryItemContainerCallbacks {}
import { InventorySurface } from "@/state/inventorySurfaceState";
export class InventoryItemSpawner implements Spawner<
[string, InventoryItem<GameItemMeta>],
[InventorySurface, GameItem],
InventoryItemContainer
> {
constructor(
private scene: Phaser.Scene,
private inventorySignal: InventorySignal,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemSpawnerCallbacks,
private surfaces: Iterable<InventorySurface>,
) {}
*getData(): Iterable<[string, InventoryItem<GameItemMeta>]> {
const inventory = this.inventorySignal.value;
yield* inventory.items.entries();
*getData(): Iterable<[InventorySurface, GameItem]> {
for (const surface of this.surfaces) {
const inv = surface.invSignal.value;
for (const item of inv.items.values()) {
yield [surface, item];
}
}
}
getKey(entry: [string, InventoryItem<GameItemMeta>]): string {
return entry[0];
getKey(entry: [InventorySurface, GameItem]): string {
return entry[1].id;
}
onSpawn(
entry: [string, InventoryItem<GameItemMeta>],
): InventoryItemContainer | null {
const [itemId, item] = entry;
onSpawn(entry: [InventorySurface, GameItem]): InventoryItemContainer | null {
const [surface, item] = entry;
const container = new InventoryItemContainer(
this.scene,
this.gridOffsetX,
this.gridOffsetY,
{
onMoveItem: (id, newX, newY, newRotation) => {
return this.callbacks.onMoveItem(id, newX, newY, newRotation);
},
},
item,
surface,
this.surfaces,
);
container.setItem(item);
container.setSurfaceItem(surface, item);
return container;
}
onUpdate(
entry: [string, InventoryItem<GameItemMeta>],
entry: [InventorySurface, GameItem],
container: InventoryItemContainer,
): void {
const [itemId, item] = entry;
const [surface, item] = entry;
container.setItem(item);
container.setSurfaceItem(surface, item);
}
onDespawn(container: InventoryItemContainer): void {
@ -69,18 +58,7 @@ export class InventoryItemSpawner implements Spawner<
export function createInventoryItemSpawner(
scene: Phaser.Scene,
inventorySignal: InventorySignal,
gridOffsetX: number,
gridOffsetY: number,
callbacks: InventoryItemSpawnerCallbacks,
surfaces: Iterable<InventorySurface>,
) {
return spawnEffect(
new InventoryItemSpawner(
scene,
inventorySignal,
gridOffsetX,
gridOffsetY,
callbacks,
),
);
return spawnEffect(new InventoryItemSpawner(scene, surfaces));
}

View File

@ -7,37 +7,37 @@ import {
} from "boardgame-core/samples/slay-the-spire-like";
export class InventoryItemState {
private readonly _item: Signal<GameItem | undefined>;
private readonly _item: Signal<GameItem>;
private readonly _previewRotation: Signal<number>;
readonly name: ReadonlySignal<string>;
readonly shape: ReadonlySignal<ParsedShape | undefined>;
readonly shape: ReadonlySignal<ParsedShape>;
readonly color: ReadonlySignal<number>;
readonly transform: ReadonlySignal<Transform2D | undefined>;
readonly transform: ReadonlySignal<Transform2D>;
readonly previewRotation: ReadonlySignal<number>;
constructor(initialItem?: GameItem) {
constructor(initialItem: GameItem) {
this._item = signal(initialItem);
this._previewRotation = signal(0);
this.name = computed(() => {
const item = this._item.value;
return item?.meta?.itemData.name ?? item?.id ?? "";
return item.meta?.itemData.name ?? item?.id ?? "";
});
this.shape = computed(() => this._item.value?.shape);
this.shape = computed(() => this._item.value.shape);
this.color = computed(() => this.computeColor(this._item.value?.id ?? ""));
this.color = computed(() => this.computeColor(this._item.value.id ?? ""));
this.transform = computed(() => this._item.value?.transform);
this.transform = computed(() => this._item.value.transform);
this.previewRotation = computed(() => {
const base = this._item.value?.transform?.rotation ?? 0;
const base = this._item.value.transform.rotation ?? 0;
return (base + this._previewRotation.value) % 360;
});
}
get item(): GameItem | undefined {
get item(): GameItem {
return this._item.value;
}

View File

@ -17,13 +17,15 @@ function genId() {
export type InventorySignal = ReturnType<typeof createInventorySignal>;
export function createInventorySignal() {
export function createInventorySignal(giveStart = false) {
const inventory = createGridInventory<GameItemMeta>(4, 6);
if (giveStart) {
const startingItems = data.desert.getStartingItems();
for (const d of startingItems) {
createItemIn(inventory, `${d.id}-${genId()}`, d);
}
}
return mutableSignal(inventory);
}
@ -33,13 +35,14 @@ export function createInventorySignal() {
* Returns true if the move was successful, false if the new position is invalid.
*/
export function moveItem(
inventorySignal: InventorySignal,
from: InventorySignal,
to: InventorySignal,
itemId: string,
newX: number,
newY: number,
newRotation?: number,
): boolean {
const inventory = inventorySignal.value;
const inventory = from.value;
const item = inventory.items.get(itemId);
if (!item) {
@ -53,17 +56,20 @@ export function moveItem(
flipY: false,
};
const removed = create(inventory, (inv) => {
const removed = create(to.value, (inv) => {
removeItemFromGrid(inv, itemId);
});
const validation = validatePlacement(removed, item.shape, newTransform);
if (!validation.valid) return false;
inventorySignal.produce((inv) => {
const item = inv.items.get(itemId)!;
from.produce((inv) => {
removeItemFromGrid(inv, itemId);
item.transform = newTransform;
placeItem(inv, item);
});
to.produce((inv) => {
placeItem(inv, {
...item,
transform: newTransform,
});
});
return true;

View File

@ -0,0 +1,68 @@
import { InventorySignal } from "./inventory";
import { MutableSignal, mutableSignal } from "boardgame-core";
export type InventorySurface = {
invSignal: InventorySignal;
gridOffsetX: number;
gridOffsetY: number;
cellSize: number;
};
export class InventorySurfaceState {
private readonly _signal: MutableSignal<InventorySurface>;
constructor(init: InventorySurface) {
this._signal = mutableSignal(init);
}
public get surface() {
return this._signal.value;
}
public get invSignal() {
return this._signal.value.invSignal;
}
public get gridOffsetX() {
return this._signal.value.gridOffsetX;
}
public get gridOffsetY() {
return this._signal.value.gridOffsetY;
}
public get cellSize() {
return this._signal.value.cellSize;
}
public setSurface(surface: InventorySurface) {
this._signal.value = surface;
}
}
export function sceneToInventory(
surface: InventorySurface | InventorySurfaceState,
x: number,
y: number,
): { x: number; y: number } | null {
const invX = Math.round(x / surface.cellSize);
const invY = Math.round(y / surface.cellSize);
const { width, height } = surface.invSignal.peek();
if (invX < 0 || invY < 0 || invX >= width || invY >= height) {
return null;
}
return { x: invX, y: invY };
}
export function inventoryToScene(
surface: InventorySurface | InventorySurfaceState,
x: number,
y: number,
): { x: number; y: number } {
return {
x: x * surface.cellSize + surface.gridOffsetX,
y: y * surface.cellSize + surface.gridOffsetY,
};
}