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.
This commit is contained in:
hypercross 2026-04-20 16:34:03 +08:00
parent 368d9942d2
commit b06876ccc0
4 changed files with 257 additions and 0 deletions

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,227 @@
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import { createInventorySignal } from "@/state/inventory";
import {
createItemIn,
data,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
import type { InventorySignal } from "@/state/inventory";
import type { InventoryItem } from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class InventoryTestScene extends ReactiveScene {
private inventorySignal!: InventorySignal;
private gridOffsetX = 0;
private gridOffsetY = 0;
constructor() {
super("InventoryTestScene");
}
create(): void {
super.create();
this.inventorySignal = createInventorySignal();
const { width, height } = this.scale;
this.gridOffsetX =
(width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
this.gridOffsetY =
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 40;
this.drawGrid();
this.drawItems();
this.add
.text(width / 2, 30, "Inventory Signal Test (4x6)", {
fontSize: "24px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.createControls();
this.add
.text(width / 2, height - 40, "Items update reactively via signals", {
fontSize: "14px",
color: "#aaaaaa",
})
.setOrigin(0.5);
// React to inventory changes by re-rendering
this.addEffect(() => {
const inventory = this.inventorySignal.value;
// Track item count for reactivity
const itemCount = inventory.items.size;
return () => {};
});
}
private drawGrid(): void {
const graphics = this.add.graphics();
for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) {
for (let x = 0; x < GRID_CONFIG.WIDTH; x++) {
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(0x222233);
graphics.fillRect(
px + 1,
py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
);
graphics.lineStyle(1, 0x555577);
graphics.strokeRect(
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
}
}
}
private drawItems(): void {
const inventory = this.inventorySignal.value;
for (const [itemId, item] of inventory.items) {
this.drawItem(itemId, item);
}
}
private drawItem(itemId: string, item: InventoryItem<GameItemMeta>): void {
const shape = item.shape;
const transform = item.transform;
const cells: { x: number; y: number }[] = [];
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
const finalX = x + transform.offset.x;
const finalY = y + transform.offset.y;
cells.push({ x: finalX, y: finalY });
}
}
}
const itemColor = this.getItemColor(itemId);
const graphics = this.add.graphics();
for (const cell of cells) {
const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor);
graphics.fillRect(
px + 2,
py + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
}
if (cells.length > 0) {
const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
const itemName = item.meta?.itemData.name ?? item.id;
this.add
.text(
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
itemName,
{
fontSize: "11px",
color: "#ffffff",
fontStyle: "bold",
},
)
.setOrigin(0.5);
}
}
private getItemColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
}
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,24 @@
import { mutableSignal } from "boardgame-core";
import {
createGridInventory,
createItemIn,
data,
GameItemMeta,
} 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 data of startingItems) {
createItemIn(inventory, `${data.id}-${genId()}`, data);
}
return mutableSignal(inventory);
}