Compare commits

..

14 Commits

Author SHA1 Message Date
hypercross fe0621cedf feat(sts-like-viewer): animate inventory item rotation
Introduce a tween for the item's rotation and update the hit area
calculation to account for the preview rotation. The graphics are now
centered within the container to support these transformations.
2026-04-20 22:15:05 +08:00
hypercross 30e2d9ac36 refactor: improve inventory item interaction and state
- Decouple hit area calculation from the rendering effect in
  `InventoryItemContainer`.
- Implement a dedicated `hitArea` property to track cell rectangles.
- Update `setupInteractive` to use the container itself as the hit area
  with a custom callback.
- Move drag-and-drop logic into a dedicated `setupDnDEffect` method.
- Add `addPreviewRotation` to `InventoryItemState` for cleaner rotation
  updates.
- Normalize `previewRotation` to stay within 0-360 degrees.
2026-04-20 21:59:08 +08:00
hypercross c9f573ef86 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`
2026-04-20 21:40:23 +08:00
hypercross cc796d1e11 feat(framework): support multi-button drag and drop
Adds support for tracking the specific mouse button used during
drag-and-drop operations. This includes a new `ALTBUTTON` event type
to handle cases where a different button is pressed while a drag is
already in progress, and disables the browser context menu by default
in the Phaser configuration to prevent interference.
2026-04-20 20:33:56 +08:00
hypercross fd347e7a62 refactor: use InventoryItemState in InventoryItemContainer
Replace local signal-based state management with the dedicated
`InventoryItemState` class to encapsulate item properties and
logic.
2026-04-20 19:50:25 +08:00
hypercross 19dfafeb30 refactor: implement cell-based hit area for inventory items
Update `InventoryItemContainer` to use a custom hit area callback
based on individual cell rectangles instead of a single bounding
box. This allows for more precise interaction with non-rectangular
item shapes.
2026-04-20 19:45:01 +08:00
hypercross b2862e34d9 fix: improve inventory item drag-and-drop behavior
- Use `snapBack` instead of immediate coordinate reset when a drag fails
- Adjust grid snapping calculation to use center-relative coordinates
- Validate item placement by simulating removal from the grid first
2026-04-20 19:22:55 +08:00
hypercross fd41db2a89 fix(sts-like-viewer): defer interactive setup until shape is available
Delay calling `setupInteractive` in `InventoryItemContainer` until
the item shape is actually rendered to ensure the hit area matches
the visual representation.
2026-04-20 18:49:56 +08:00
hypercross f03e6c5a7b 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
2026-04-20 18:44:50 +08:00
hypercross 7039938a72 refactor: delegate signal effects to DisposableBag
Introduce `addEffect` to `DisposableBag` to encapsulate Preact
signal effect creation and disposal. Update `ReactiveScene` to use
this method, simplifying effect management and ensuring proper
cleanup.
2026-04-20 18:18:26 +08:00
hypercross 45844ea800 fix(sts-viewer): use local coordinates for inventory item container
Update `InventoryItemSpawner` to draw graphics and text relative to the
container's local coordinate system instead of world coordinates.
The container is then positioned at the first cell's world position.
This also fixes drag positioning by calculating movement relative to
the initial drag start point.
2026-04-20 17:20:30 +08:00
hypercross 29b516c371 feat(sts-viewer): implement drag-and-drop for inventory items
Adds interactive drag-and-drop functionality to the inventory spawner,
allowing users to move items within the grid.

- Implements `InventoryItemSpawnerCallbacks` to handle item movement
- Adds `moveItem` utility to update inventory state via signals
- Integrates `dragDropEventEffect` for item interaction
- Updates `InventoryTestScene` to demonstrate movement
- Adjusts text colors in `GRID_CONFIG` for better visibility
2026-04-20 17:06:46 +08:00
hypercross 5884e74d8d refactor: implement inventory item spawner and config
- Add cell and text styling constants to `GRID_CONFIG`
- Replace manual item drawing in `InventoryTestScene` with
  `InventoryItemSpawner`
- Update `InventoryTestScene` to use dynamic inventory dimensions for
  grid rendering
- Register `InventoryTestScene` in the main `App` component
2026-04-20 16:45:14 +08:00
hypercross b06876ccc0 feat(sts-like-viewer): add inventory test scene
Implement `InventoryTestScene` to demonstrate reactive inventory
management using signals. Includes a grid-based visualization of items
and controls to add or remove items dynamically.
2026-04-20 16:34:03 +08:00
13 changed files with 706 additions and 7 deletions

View File

@ -1,4 +1,4 @@
import { effect, type ReadonlySignal } from "@preact/signals-core"; import { type ReadonlySignal } from "@preact/signals-core";
import { Scene } from "phaser"; import { Scene } from "phaser";
import { DisposableBag, type IDisposable } from "../utils"; import { DisposableBag, type IDisposable } from "../utils";
@ -85,6 +85,6 @@ export abstract class ReactiveScene<TData = object>
/** 注册响应式监听(场景关闭时自动清理) */ /** 注册响应式监听(场景关闭时自动清理) */
public addEffect(fn: () => CleanupFn): void { public addEffect(fn: () => CleanupFn): void {
this.disposables.add(effect(fn)); this.disposables.addEffect(fn);
} }
} }

View File

@ -37,6 +37,7 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
parent: "phaser-container", parent: "phaser-container",
backgroundColor: "#f9fafb", backgroundColor: "#f9fafb",
scene: [], scene: [],
disableContextMenu: true,
}; };
export interface PhaserGameProps { export interface PhaserGameProps {

View File

@ -1,3 +1,5 @@
import { effect } from "@preact/signals-core";
export interface IDisposable { export interface IDisposable {
dispose(): void; dispose(): void;
} }
@ -8,6 +10,10 @@ export class DisposableBag implements IDisposable {
private _disposables = new Set<DisposableItem>(); private _disposables = new Set<DisposableItem>();
private _isDisposed = false; private _isDisposed = false;
constructor(go?: Phaser.GameObjects.GameObject) {
if (go) go.on("shutdown", () => this.dispose());
}
get isDisposed(): boolean { get isDisposed(): boolean {
return this._isDisposed; return this._isDisposed;
} }
@ -20,6 +26,10 @@ export class DisposableBag implements IDisposable {
this._disposables.add(item); this._disposables.add(item);
} }
addEffect(fn: () => void) {
this.add(effect(fn));
}
dispose(): void { dispose(): void {
if (this._isDisposed) return; if (this._isDisposed) return;

View File

@ -2,6 +2,7 @@
type PointerRecord = { type PointerRecord = {
id: number; id: number;
button: number;
x: number; x: number;
y: number; y: number;
}; };
@ -10,6 +11,7 @@ export enum DragDropEventType {
DOWN, DOWN,
UP, UP,
MOVE, MOVE,
ALTBUTTON,
} }
export type DragDropEvent = { export type DragDropEvent = {
@ -29,9 +31,22 @@ export function dragDropEventEffect(
let down: PointerRecord | null = null; let down: PointerRecord | null = null;
function onPointerDown(pointer: Phaser.Input.Pointer) { function onPointerDown(pointer: Phaser.Input.Pointer) {
if (isDragging) return; if (down !== null) {
if (pointer.button === down.button) return;
callback({
type: DragDropEventType.ALTBUTTON,
deltaX: pointer.x - down.x,
deltaY: pointer.y - down.y,
});
return;
}
isDragging = true; isDragging = true;
down = { id: pointer.id, x: pointer.x, y: pointer.y }; down = {
id: pointer.id,
button: pointer.button,
x: pointer.x,
y: pointer.y,
};
const event: DragDropEvent = { const event: DragDropEvent = {
type: DragDropEventType.DOWN, type: DragDropEventType.DOWN,
@ -42,12 +57,23 @@ export function dragDropEventEffect(
} }
function onPointerUp(pointer: Phaser.Input.Pointer) { function onPointerUp(pointer: Phaser.Input.Pointer) {
if (!isDragging || !down || pointer.id !== down.id) return; if (
!isDragging ||
!down ||
pointer.id !== down.id ||
pointer.button !== down.button
)
return;
isDragging = false;
const deltaX = pointer.x - down.x; const deltaX = pointer.x - down.x;
const deltaY = pointer.y - down.y; const deltaY = pointer.y - down.y;
const event: DragDropEvent = { type: DragDropEventType.UP, deltaX, deltaY };
isDragging = false;
const event: DragDropEvent = {
type: DragDropEventType.UP,
deltaX,
deltaY,
};
callback(event); callback(event);
down = null; down = null;
} }

View File

@ -33,6 +33,29 @@ export const GRID_CONFIG = {
WIDGET_CELL_SIZE: 80, WIDGET_CELL_SIZE: 80,
/** Gap between grid cells (pixels) */ /** Gap between grid cells (pixels) */
GRID_GAP: 2, GRID_GAP: 2,
/** Empty cell background color */
CELL_EMPTY_COLOR: 0x222233,
/** Occupied cell background color */
CELL_OCCUPIED_COLOR: 0x334455,
/** Grid line color */
GRID_LINE_COLOR: 0x555577,
/** Title text style */
TITLE_STYLE: {
fontSize: "24px",
color: "#888",
fontStyle: "bold",
} as const,
/** Subtitle/hint text style */
SUBTITLE_STYLE: {
fontSize: "14px",
color: "#aaaaaa",
} as const,
/** Item name text style */
ITEM_NAME_STYLE: {
fontSize: "11px",
color: "#888",
fontStyle: "bold",
} as const,
} as const; } as const;
// ── Shape Viewer ──────────────────────────────────────────────────────────── // ── Shape Viewer ────────────────────────────────────────────────────────────

View File

@ -0,0 +1,225 @@
import Phaser from "phaser";
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
import { GRID_CONFIG } from "@/config";
import {
IDENTITY_TRANSFORM,
ParsedShape,
Point2D,
Transform2D,
transformShape,
type GameItem,
} 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;
}
export class InventoryItemContainer extends Phaser.GameObjects.Container {
private itemState: InventoryItemState;
private hitArea: Point2D[] = [];
constructor(
scene: Phaser.Scene,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemContainerCallbacks,
) {
super(scene, gridOffsetX, gridOffsetY);
scene.add.existing(this);
const graphics = this.scene.add.graphics();
graphics.setPosition(
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
);
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.setupInteractive();
this.itemState = new InventoryItemState();
const disposables = new DisposableBag(this);
disposables.addEffect(() => {
label.setText(this.itemState.name.value);
});
disposables.add(this.setupDnDEffect());
disposables.addEffect(() => {
graphics.clear();
if (!this.itemState.shape.value) return;
this.renderGraphics(
graphics,
this.itemState.shape.value,
this.itemState.color.value,
);
});
disposables.addEffect(() => {
this.scene.tweens.add({
targets: graphics,
rotation: -(this.itemState.previewRotation.value * Math.PI) / 180,
duration: 150,
ease: "Power2",
});
this.hitArea = this.updateHitArea(this.itemState.previewRotation.value);
});
disposables.addEffect(() => {
if (!this.itemState.transform.value) return;
this.snapBack(this.itemState.transform.value);
this.itemState.setPreviewRotation(0);
});
}
updateHitArea(value: number): Point2D[] {
const shape = this.itemState.shape.value;
if (!shape) return [];
const rotation = value;
const cells = transformShape(shape, {
rotation,
offset: { x: 0, y: 0 },
flipX: false,
flipY: false,
});
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
return cells.map((cell) => ({
x: (cell.x - cells[0].x) * cellSize,
y: (cell.y - cells[0].y) * cellSize,
}));
}
setItem(item: GameItem): void {
this.itemState.setItem(item);
}
renderGraphics(
graphics: Phaser.GameObjects.Graphics,
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;
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,
);
}
}
private setupInteractive() {
this.setScrollFactor(0);
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
this.setInteractive({
hitArea: this,
hitAreaCallback: (
hitArea: InventoryItemContainer,
x: number,
y: number,
) =>
hitArea.hitArea.some(
(cell) =>
x >= cell.x &&
x < cell.x + cellSize &&
y >= cell.y &&
y < cell.y + cellSize,
),
useHandCursor: true,
} as Phaser.Types.Input.InputConfiguration);
}
private setupDnDEffect() {
let startX = 0;
let startY = 0;
return 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.ALTBUTTON) {
this.itemState.addPreviewRotation(90);
} else if (event.type === DragDropEventType.UP) {
this.setAlpha(1);
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(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));
if (
clampedX !== item.transform.offset.x ||
clampedY !== item.transform.offset.y ||
finalRotation !== 0
) {
return this.callbacks.onMoveItem(
item.id,
clampedX,
clampedY,
finalRotation,
);
}
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, newRotation) => {
return this.callbacks.onMoveItem(id, newX, newY, newRotation);
},
},
);
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

@ -49,6 +49,11 @@ export class IndexScene extends ReactiveScene {
scene: SceneKey.ShapeViewerScene, scene: SceneKey.ShapeViewerScene,
y: centerY + 140, y: centerY + 140,
}, },
{
label: "Inventory Test",
scene: SceneKey.InventoryTestScene,
y: centerY + 210,
},
]; ];
for (const btn of buttons) { for (const btn of buttons) {

View File

@ -0,0 +1,189 @@
import { ReactiveScene } from "boardgame-phaser";
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 "@/gameobjects/InventoryItemSpawner";
import { SceneKey } from "./types";
export class InventoryTestScene extends ReactiveScene {
private inventorySignal = createInventorySignal();
private gridOffsetX = 0;
private gridOffsetY = 0;
constructor() {
super("InventoryTestScene");
}
create(): void {
super.create();
const { width, height } = this.scale;
const inventory = this.inventorySignal.value;
const invWidth = inventory.width;
const invHeight = inventory.height;
this.gridOffsetX = (width - invWidth * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
this.gridOffsetY =
(height - invHeight * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 40;
this.drawGrid(invWidth, invHeight);
this.setupItemSpawner();
this.add
.text(
width / 2,
30,
"Inventory Signal Test (4x6)",
GRID_CONFIG.TITLE_STYLE,
)
.setOrigin(0.5);
this.createControls();
this.add
.text(
width / 2,
height - 40,
"Drag items to move them",
GRID_CONFIG.SUBTITLE_STYLE,
)
.setOrigin(0.5);
}
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;
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const isOccupied = this.inventorySignal.value.occupiedCells.has(
`${x},${y}`,
);
graphics.fillStyle(
isOccupied
? GRID_CONFIG.CELL_OCCUPIED_COLOR
: GRID_CONFIG.CELL_EMPTY_COLOR,
);
graphics.fillRect(
px + 1,
py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
);
graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR);
graphics.strokeRect(
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
}
}
});
}
private setupItemSpawner(): void {
const spawner = createInventoryItemSpawner(
this,
this.inventorySignal,
this.gridOffsetX,
this.gridOffsetY,
{
onMoveItem: (
itemId: string,
newX: number,
newY: number,
newRotation: number,
) => {
return moveItem(
this.inventorySignal,
itemId,
newX,
newY,
newRotation,
);
},
},
);
this.disposables.add(spawner);
}
private createControls(): void {
const { width } = this.scale;
createButton({
scene: this,
label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
},
});
createButton({
scene: this,
label: "添加道具",
x: width - 300,
y: 40,
onClick: () => {
this.addRandomItem();
},
});
createButton({
scene: this,
label: "移除最后一个",
x: width - 150,
y: 40,
onClick: () => {
this.removeLastItem();
},
});
}
private addRandomItem(): void {
const items = data.desert.getItems();
this.inventorySignal.produce((inventory) => {
const usedIndices = new Set<number>();
for (const item of inventory.items.values()) {
const match = item.id.match(/^item-(\d+)-/);
if (match) {
usedIndices.add(parseInt(match[1], 10));
}
}
let availableIndex = 0;
while (usedIndices.has(availableIndex) && availableIndex < items.length) {
availableIndex++;
}
if (availableIndex >= items.length) {
return;
}
const itemData = items[availableIndex];
const id = `item-${availableIndex}-${Date.now().toString(16).slice(-4)}`;
createItemIn(inventory, id, itemData);
});
}
private removeLastItem(): void {
this.inventorySignal.produce((inventory) => {
const items = Array.from(inventory.items.entries());
if (items.length === 0) {
return;
}
const [lastId] = items[items.length - 1];
inventory.items.delete(lastId);
});
}
}

View File

@ -1,6 +1,7 @@
export enum SceneKey { export enum SceneKey {
GridViewerScene = "GridViewerScene", GridViewerScene = "GridViewerScene",
IndexScene = "IndexScene", IndexScene = "IndexScene",
InventoryTestScene = "InventoryTestScene",
MapViewerScene = "MapViewerScene", MapViewerScene = "MapViewerScene",
ShapeViewerScene = "ShapeViewerScene", ShapeViewerScene = "ShapeViewerScene",
} }

View File

@ -0,0 +1,61 @@
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";
export class InventoryItemState {
private readonly _item: Signal<GameItem | undefined>;
private readonly _previewRotation: Signal<number>;
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._previewRotation = signal(0);
this.name = computed(() => {
const item = this._item.value;
return item?.meta?.itemData.name ?? item?.id ?? "";
});
this.shape = computed(() => this._item.value?.shape);
this.color = computed(() => this.computeColor(this._item.value?.id ?? ""));
this.transform = computed(() => this._item.value?.transform);
this.previewRotation = computed(() => {
const base = this._item.value?.transform?.rotation ?? 0;
return (base + this._previewRotation.value) % 360;
});
}
get item(): GameItem | undefined {
return this._item.value;
}
setItem(item: GameItem): void {
this._item.value = item;
this._previewRotation.value = 0;
}
setPreviewRotation(rotation: number): void {
this._previewRotation.value = rotation;
}
addPreviewRotation(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];
}
}

View File

@ -0,0 +1,70 @@
import { create } from "mutative";
import { mutableSignal } from "boardgame-core";
import {
createGridInventory,
createItemIn,
data,
GameItemMeta,
placeItem,
removeItemFromGrid,
Transform2D,
validatePlacement,
} from "boardgame-core/samples/slay-the-spire-like";
function genId() {
return Math.random().toString(16).slice(-8);
}
export type InventorySignal = ReturnType<typeof createInventorySignal>;
export function createInventorySignal() {
const inventory = createGridInventory<GameItemMeta>(4, 6);
const startingItems = data.desert.getStartingItems();
for (const d of startingItems) {
createItemIn(inventory, `${d.id}-${genId()}`, d);
}
return mutableSignal(inventory);
}
/**
* Move an item to a new position in the inventory.
* Returns true if the move was successful, false if the new position is invalid.
*/
export function moveItem(
inventorySignal: InventorySignal,
itemId: string,
newX: number,
newY: number,
newRotation?: number,
): boolean {
const inventory = inventorySignal.value;
const item = inventory.items.get(itemId);
if (!item) {
return false;
}
const newTransform: Transform2D = {
offset: { x: newX, y: newY },
rotation: newRotation === undefined ? item.transform.rotation : newRotation,
flipX: false,
flipY: false,
};
const removed = create(inventory, (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)!;
removeItemFromGrid(inv, itemId);
item.transform = newTransform;
placeItem(inv, item);
});
return true;
}

View File

@ -4,6 +4,7 @@ import { MapViewerScene } from "@/scenes/MapViewerScene";
import { GridViewerScene } from "@/scenes/GridViewerScene"; import { GridViewerScene } from "@/scenes/GridViewerScene";
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene"; import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
import { GAME_CONFIG } from "@/config"; import { GAME_CONFIG } from "@/config";
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
export default function App() { export default function App() {
return ( return (
@ -11,6 +12,7 @@ export default function App() {
<div className="flex-1 flex relative justify-center items-center"> <div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}> <PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
<PhaserScene scene={IndexScene} /> <PhaserScene scene={IndexScene} />
<PhaserScene scene={InventoryTestScene} />
<PhaserScene scene={MapViewerScene} /> <PhaserScene scene={MapViewerScene} />
<PhaserScene scene={GridViewerScene} /> <PhaserScene scene={GridViewerScene} />
<PhaserScene scene={ShapeViewerScene} /> <PhaserScene scene={ShapeViewerScene} />