feat(sts-viewer): add item rotation support in inventory

Introduces the ability to rotate inventory items using the Alt button
during drag-and-drop.

- Updates `InventoryItemContainer` to handle rotation via
  `previewRotation`
- Adds `newRotation` parameter to `onMoveItem` callback and `moveItem`
  command
- Updates `InventoryItemState` to manage rotation state and compute
  transformed shapes
- Implements rotation logic in `renderGraphics` using `transformShape`
This commit is contained in:
hypercross 2026-04-20 21:40:23 +08:00
parent cc796d1e11
commit c9f573ef86
5 changed files with 102 additions and 43 deletions

View File

@ -10,9 +10,15 @@ import {
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
import { DisposableBag } from "../../../framework/dist"; import { DisposableBag } from "../../../framework/dist";
import { InventoryItemState } from "@/state/InventoryItemState"; import { InventoryItemState } from "@/state/InventoryItemState";
import { effect } from "@preact/signals-core";
export interface InventoryItemContainerCallbacks { export interface InventoryItemContainerCallbacks {
onMoveItem: (itemId: string, newX: number, newY: number) => boolean; onMoveItem: (
itemId: string,
newX: number,
newY: number,
newRotation: number,
) => boolean;
} }
export class InventoryItemContainer extends Phaser.GameObjects.Container { export class InventoryItemContainer extends Phaser.GameObjects.Container {
@ -44,21 +50,25 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
label.setText(this.itemState.name.value); label.setText(this.itemState.name.value);
}); });
disposables.addEffect(() => { disposables.add(
graphics.clear(); effect(() => {
if (!this.itemState.shape.value) return; graphics.clear();
const cellRects = this.renderGraphics( if (!this.itemState.shape.value) return;
graphics, const cellRects = this.renderGraphics(
this.itemState.shape.value, graphics,
this.itemState.color.value, this.itemState.shape.value,
); this.itemState.color.value,
this.itemState.previewRotation.value,
);
this.setupInteractive(cellRects); return this.setupInteractive(cellRects);
}); }),
);
disposables.addEffect(() => { disposables.addEffect(() => {
if (!this.itemState.transform.value) return; if (!this.itemState.transform.value) return;
this.snapBack(this.itemState.transform.value); this.snapBack(this.itemState.transform.value);
this.itemState.setPreviewRotation(0);
}); });
} }
@ -70,8 +80,15 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
graphics: Phaser.GameObjects.Graphics, graphics: Phaser.GameObjects.Graphics,
shape: ParsedShape, shape: ParsedShape,
itemColor: number, itemColor: number,
rotation: number,
): { x: number; y: number }[] { ): { x: number; y: number }[] {
const cells = transformShape(shape, IDENTITY_TRANSFORM); const transform: Transform2D = {
offset: { x: 0, y: 0 },
rotation,
flipX: false,
flipY: false,
};
const cells = transformShape(shape, transform);
const cellRects: { x: number; y: number }[] = []; const cellRects: { x: number; y: number }[] = [];
for (const cell of cells) { for (const cell of cells) {
@ -92,7 +109,7 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
return cellRects; return cellRects;
} }
private setupInteractive(cellRects: { x: number; y: number }[]): void { private setupInteractive(cellRects: { x: number; y: number }[]) {
this.setScrollFactor(0); this.setScrollFactor(0);
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE; const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
this.setInteractive({ this.setInteractive({
@ -114,7 +131,8 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
dragDropEventEffect(this, (event) => { return dragDropEventEffect(this, (event) => {
console.log(event.type);
if (event.type === DragDropEventType.DOWN) { if (event.type === DragDropEventType.DOWN) {
startX = this.x; startX = this.x;
startY = this.y; startY = this.y;
@ -122,18 +140,25 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
} else if (event.type === DragDropEventType.MOVE) { } else if (event.type === DragDropEventType.MOVE) {
this.x = startX + event.deltaX; this.x = startX + event.deltaX;
this.y = startY + event.deltaY; this.y = startY + event.deltaY;
} else if (event.type === DragDropEventType.ALTBUTTON) {
const current = this.itemState.previewRotation.peek();
this.itemState.setPreviewRotation(current + 90);
} else if (event.type === DragDropEventType.UP) { } else if (event.type === DragDropEventType.UP) {
this.setAlpha(1); this.setAlpha(1);
if (!this.handleDragEnd()) { const finalRotation = this.itemState.previewRotation.peek();
if (!this.handleDragEnd(finalRotation)) {
this.itemState.setPreviewRotation(0);
const t = this.itemState.transform.peek(); const t = this.itemState.transform.peek();
t && this.snapBack(t); t && this.snapBack(t);
} else {
this.itemState.setPreviewRotation(0);
} }
startX = startY = 0; startX = startY = 0;
} }
}); });
} }
private handleDragEnd(): boolean { private handleDragEnd(finalRotation: number): boolean {
const item = this.itemState.item; const item = this.itemState.item;
if (!item) return false; if (!item) return false;
@ -151,9 +176,15 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
if ( if (
clampedX !== item.transform.offset.x || clampedX !== item.transform.offset.x ||
clampedY !== item.transform.offset.y clampedY !== item.transform.offset.y ||
finalRotation !== 0
) { ) {
return this.callbacks.onMoveItem(item.id, clampedX, clampedY); return this.callbacks.onMoveItem(
item.id,
clampedX,
clampedY,
finalRotation,
);
} }
return false; return false;
} }

View File

@ -42,8 +42,8 @@ export class InventoryItemSpawner implements Spawner<
this.gridOffsetX, this.gridOffsetX,
this.gridOffsetY, this.gridOffsetY,
{ {
onMoveItem: (id, newX, newY) => { onMoveItem: (id, newX, newY, newRotation) => {
return this.callbacks.onMoveItem(id, newX, newY); return this.callbacks.onMoveItem(id, newX, newY, newRotation);
}, },
}, },
); );

View File

@ -93,8 +93,19 @@ export class InventoryTestScene extends ReactiveScene {
this.gridOffsetX, this.gridOffsetX,
this.gridOffsetY, this.gridOffsetY,
{ {
onMoveItem: (itemId: string, newX: number, newY: number) => { onMoveItem: (
return moveItem(this.inventorySignal, itemId, newX, newY); itemId: string,
newX: number,
newY: number,
newRotation: number,
) => {
return moveItem(
this.inventorySignal,
itemId,
newX,
newY,
newRotation,
);
}, },
}, },
); );

View File

@ -1,44 +1,57 @@
import { computed, signal, Signal } from "@preact/signals-core" import { computed, ReadonlySignal, signal, Signal } from "@preact/signals-core";
import { ITEM_COLORS } from "@/config" import { ITEM_COLORS } from "@/config";
import { import {
type GameItem, type GameItem,
type ParsedShape, type ParsedShape,
type Transform2D, type Transform2D,
} from "boardgame-core/samples/slay-the-spire-like" } from "boardgame-core/samples/slay-the-spire-like";
export class InventoryItemState { export class InventoryItemState {
private readonly _item: Signal<GameItem | undefined> private readonly _item: Signal<GameItem | undefined>;
private readonly _previewRotation: Signal<number>;
readonly name: Signal<string> readonly name: ReadonlySignal<string>;
readonly shape: Signal<ParsedShape | undefined> readonly shape: ReadonlySignal<ParsedShape | undefined>;
readonly color: Signal<number> readonly color: ReadonlySignal<number>;
readonly transform: Signal<Transform2D | undefined> readonly transform: ReadonlySignal<Transform2D | undefined>;
readonly previewRotation: ReadonlySignal<number>;
constructor(initialItem?: GameItem) { constructor(initialItem?: GameItem) {
this._item = signal(initialItem) this._item = signal(initialItem);
this._previewRotation = signal(0);
this.name = computed(() => { this.name = computed(() => {
const item = this._item.value 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;
return base + this._previewRotation.value;
});
} }
get item(): GameItem | undefined { get item(): GameItem | undefined {
return this._item.value return this._item.value;
} }
setItem(item: GameItem): void { setItem(item: GameItem): void {
this._item.value = item this._item.value = item;
this._previewRotation.value = 0;
}
setPreviewRotation(rotation: number): void {
this._previewRotation.value = rotation;
} }
private computeColor(itemId: string): number { private computeColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0) const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length] return ITEM_COLORS[hash % ITEM_COLORS.length];
} }
} }

View File

@ -7,6 +7,7 @@ import {
GameItemMeta, GameItemMeta,
placeItem, placeItem,
removeItemFromGrid, removeItemFromGrid,
Transform2D,
validatePlacement, validatePlacement,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
@ -36,6 +37,7 @@ export function moveItem(
itemId: string, itemId: string,
newX: number, newX: number,
newY: number, newY: number,
newRotation?: number,
): boolean { ): boolean {
const inventory = inventorySignal.value; const inventory = inventorySignal.value;
const item = inventory.items.get(itemId); const item = inventory.items.get(itemId);
@ -44,9 +46,11 @@ export function moveItem(
return false; return false;
} }
const newTransform = { const newTransform: Transform2D = {
...item.transform,
offset: { x: newX, y: newY }, offset: { x: newX, y: newY },
rotation: newRotation === undefined ? item.transform.rotation : newRotation,
flipX: false,
flipY: false,
}; };
const removed = create(inventory, (inv) => { const removed = create(inventory, (inv) => {