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";
import { DisposableBag } from "../../../framework/dist";
import { InventoryItemState } from "@/state/InventoryItemState";
import { effect } from "@preact/signals-core";
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 {
@ -44,21 +50,25 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
label.setText(this.itemState.name.value);
});
disposables.addEffect(() => {
disposables.add(
effect(() => {
graphics.clear();
if (!this.itemState.shape.value) return;
const cellRects = this.renderGraphics(
graphics,
this.itemState.shape.value,
this.itemState.color.value,
this.itemState.previewRotation.value,
);
this.setupInteractive(cellRects);
});
return this.setupInteractive(cellRects);
}),
);
disposables.addEffect(() => {
if (!this.itemState.transform.value) return;
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,
shape: ParsedShape,
itemColor: number,
rotation: 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 }[] = [];
for (const cell of cells) {
@ -92,7 +109,7 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
return cellRects;
}
private setupInteractive(cellRects: { x: number; y: number }[]): void {
private setupInteractive(cellRects: { x: number; y: number }[]) {
this.setScrollFactor(0);
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
this.setInteractive({
@ -114,7 +131,8 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
let startX = 0;
let startY = 0;
dragDropEventEffect(this, (event) => {
return dragDropEventEffect(this, (event) => {
console.log(event.type);
if (event.type === DragDropEventType.DOWN) {
startX = this.x;
startY = this.y;
@ -122,18 +140,25 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
} else if (event.type === DragDropEventType.MOVE) {
this.x = startX + event.deltaX;
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) {
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();
t && this.snapBack(t);
} else {
this.itemState.setPreviewRotation(0);
}
startX = startY = 0;
}
});
}
private handleDragEnd(): boolean {
private handleDragEnd(finalRotation: number): boolean {
const item = this.itemState.item;
if (!item) return false;
@ -151,9 +176,15 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
if (
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;
}

View File

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

View File

@ -93,8 +93,19 @@ export class InventoryTestScene extends ReactiveScene {
this.gridOffsetX,
this.gridOffsetY,
{
onMoveItem: (itemId: string, newX: number, newY: number) => {
return moveItem(this.inventorySignal, itemId, newX, newY);
onMoveItem: (
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 { ITEM_COLORS } from "@/config"
import { computed, ReadonlySignal, signal, Signal } from "@preact/signals-core";
import { ITEM_COLORS } from "@/config";
import {
type GameItem,
type ParsedShape,
type Transform2D,
} from "boardgame-core/samples/slay-the-spire-like"
} from "boardgame-core/samples/slay-the-spire-like";
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 shape: Signal<ParsedShape | undefined>
readonly color: Signal<number>
readonly transform: Signal<Transform2D | undefined>
readonly name: ReadonlySignal<string>;
readonly shape: ReadonlySignal<ParsedShape | undefined>;
readonly color: ReadonlySignal<number>;
readonly transform: ReadonlySignal<Transform2D | undefined>;
readonly previewRotation: ReadonlySignal<number>;
constructor(initialItem?: GameItem) {
this._item = signal(initialItem)
this._item = signal(initialItem);
this._previewRotation = signal(0);
this.name = computed(() => {
const item = this._item.value
return item?.meta?.itemData.name ?? item?.id ?? ""
})
const item = this._item.value;
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 {
return this._item.value
return this._item.value;
}
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 {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0)
return ITEM_COLORS[hash % ITEM_COLORS.length]
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
}
}

View File

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