diff --git a/packages/sts-like-viewer/index.html b/packages/sts-like-viewer/index.html
new file mode 100644
index 0000000..9276bf8
--- /dev/null
+++ b/packages/sts-like-viewer/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ STS-Like Viewer
+
+
+
+
+
+
diff --git a/packages/sts-like-viewer/package.json b/packages/sts-like-viewer/package.json
new file mode 100644
index 0000000..abe68a4
--- /dev/null
+++ b/packages/sts-like-viewer/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "sts-like-viewer",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@preact/signals-core": "^1.5.1",
+ "boardgame-core": "link:../../../boardgame-core",
+ "boardgame-phaser": "workspace:*",
+ "mutative": "^1.3.0",
+ "phaser": "^3.80.1",
+ "preact": "^10.19.3"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.8.1",
+ "@preact/signals": "^2.9.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.1.0"
+ }
+}
diff --git a/packages/sts-like-viewer/src/main.tsx b/packages/sts-like-viewer/src/main.tsx
new file mode 100644
index 0000000..013e785
--- /dev/null
+++ b/packages/sts-like-viewer/src/main.tsx
@@ -0,0 +1,11 @@
+import { h } from 'preact';
+import { GameUI } from 'boardgame-phaser';
+import './style.css';
+import App from "@/ui/App";
+
+const ui = new GameUI({
+ container: document.getElementById('ui-root')!,
+ root: ,
+});
+
+ui.mount();
diff --git a/packages/sts-like-viewer/src/scenes/GridViewerScene.ts b/packages/sts-like-viewer/src/scenes/GridViewerScene.ts
new file mode 100644
index 0000000..e332ae4
--- /dev/null
+++ b/packages/sts-like-viewer/src/scenes/GridViewerScene.ts
@@ -0,0 +1,256 @@
+import Phaser from 'phaser';
+import {
+ createGridInventory,
+ placeItem,
+ getAdjacentItems,
+ parseShapeString,
+ heroItemFighter1Data,
+ type GridInventory,
+ type InventoryItem,
+} from 'boardgame-core/samples/slay-the-spire-like';
+import { ReactiveScene } from 'boardgame-phaser';
+
+export class GridViewerScene extends ReactiveScene {
+ private inventory: GridInventory;
+ private readonly CELL_SIZE = 60;
+ private readonly GRID_WIDTH = 6;
+ private readonly GRID_HEIGHT = 4;
+ private gridOffsetX = 0;
+ private gridOffsetY = 0;
+
+ constructor() {
+ super('GridViewerScene');
+ this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT);
+ }
+
+ create(): void {
+ super.create();
+ const { width, height } = this.scale;
+ this.gridOffsetX = (width - this.GRID_WIDTH * this.CELL_SIZE) / 2;
+ this.gridOffsetY = (height - this.GRID_HEIGHT * this.CELL_SIZE) / 2 + 20;
+
+ // Place some sample items
+ this.placeSampleItems();
+
+ // Draw grid
+ this.drawGrid();
+
+ // Draw items
+ this.drawItems();
+
+ // Title
+ this.add.text(width / 2, 30, 'Grid Inventory Viewer (4x6)', {
+ fontSize: '24px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+
+ // Controls
+ this.createControls();
+
+ // Info text
+ this.add.text(width / 2, height - 40, 'Hover over cells to see item details', {
+ fontSize: '14px',
+ color: '#aaaaaa',
+ }).setOrigin(0.5);
+ }
+
+ private placeSampleItems(): void {
+ // Place a few items from heroItemFighter1Data
+ const sampleItems = [
+ { index: 0, x: 0, y: 0 }, // 剑
+ { index: 3, x: 3, y: 0 }, // 短刀
+ { index: 6, x: 0, y: 2 }, // 盾
+ { index: 12, x: 3, y: 2 }, // 披风
+ ];
+
+ for (const { index, x, y } of sampleItems) {
+ const data = heroItemFighter1Data[index];
+ const shape = parseShapeString(data.shape);
+ const item: InventoryItem = {
+ id: `item-${index}`,
+ shape,
+ transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
+ meta: { name: data.name, data },
+ };
+ placeItem(this.inventory, item);
+ }
+ }
+
+ private drawGrid(): void {
+ const graphics = this.add.graphics();
+
+ // Draw cells
+ for (let y = 0; y < this.GRID_HEIGHT; y++) {
+ for (let x = 0; x < this.GRID_WIDTH; x++) {
+ const px = this.gridOffsetX + x * this.CELL_SIZE;
+ const py = this.gridOffsetY + y * this.CELL_SIZE;
+
+ const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`);
+ const color = isOccupied ? 0x334455 : 0x222233;
+
+ graphics.fillStyle(color);
+ graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
+ graphics.lineStyle(1, 0x555577);
+ graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
+ }
+ }
+ }
+
+ private drawItems(): void {
+ for (const [itemId, item] of this.inventory.items) {
+ const shape = item.shape;
+ const transform = item.transform;
+
+ // Get occupied cells
+ 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 });
+ }
+ }
+ }
+
+ // Draw item cells with a unique color per item
+ const itemColor = this.getItemColor(itemId);
+ const graphics = this.add.graphics();
+
+ for (const cell of cells) {
+ const px = this.gridOffsetX + cell.x * this.CELL_SIZE;
+ const py = this.gridOffsetY + cell.y * this.CELL_SIZE;
+
+ graphics.fillStyle(itemColor);
+ graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4);
+ }
+
+ // Item name label (at first cell)
+ if (cells.length > 0) {
+ const firstCell = cells[0];
+ const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
+ const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE;
+ const itemName = (item.meta?.name as string) ?? item.id;
+
+ this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, itemName, {
+ fontSize: '11px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+ }
+
+ // Adjacent items info
+ const adjacent = getAdjacentItems(this.inventory, itemId);
+ if (adjacent.size > 0) {
+ const adjacentNames = Array.from(adjacent.values()).map(i => (i.meta?.name as string) ?? i.id).join(', ');
+ const firstCell = cells[0];
+ const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
+ const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE - 20;
+
+ this.add.text(px + this.CELL_SIZE / 2, py, `邻接: ${adjacentNames}`, {
+ fontSize: '10px',
+ color: '#ffff88',
+ }).setOrigin(0.5);
+ }
+ }
+ }
+
+ private getItemColor(itemId: string): number {
+ // Generate a consistent color based on item ID
+ const hash = itemId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
+ const colors = [0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc];
+ return colors[hash % colors.length];
+ }
+
+ private createControls(): void {
+ const { width, height } = this.scale;
+
+ // Back button
+ this.createButton('返回菜单', 100, 40, () => {
+ this.scene.start('IndexScene');
+ });
+
+ // Clear button
+ this.createButton('清空', width - 260, 40, () => {
+ this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT);
+ this.scene.restart();
+ });
+
+ // Random fill button
+ this.createButton('随机填充', width - 130, 40, () => {
+ this.randomFill();
+ this.scene.restart();
+ });
+ }
+
+ private randomFill(): void {
+ this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT);
+
+ // Try to place random items
+ let itemIndex = 0;
+ for (let y = 0; y < this.GRID_HEIGHT && itemIndex < heroItemFighter1Data.length; y++) {
+ for (let x = 0; x < this.GRID_WIDTH && itemIndex < heroItemFighter1Data.length; x++) {
+ const data = heroItemFighter1Data[itemIndex];
+ const shape = parseShapeString(data.shape);
+
+ // Check if placement is valid
+ const occupiedCells = new Set();
+ for (let sy = 0; sy < shape.height; sy++) {
+ for (let sx = 0; sx < shape.width; sx++) {
+ if (shape.grid[sy]?.[sx]) {
+ occupiedCells.add(`${sx + x},${sy + y}`);
+ }
+ }
+ }
+
+ // Simple check if any cell is out of bounds or occupied
+ let valid = true;
+ for (const cell of occupiedCells) {
+ const [cx, cy] = cell.split(',').map(Number);
+ if (cx >= this.GRID_WIDTH || cy >= this.GRID_HEIGHT || this.inventory.occupiedCells.has(cell)) {
+ valid = false;
+ break;
+ }
+ }
+
+ if (valid) {
+ const item: InventoryItem = {
+ id: `item-${itemIndex}`,
+ shape,
+ transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
+ meta: { name: data.name, data },
+ };
+ placeItem(this.inventory, item);
+ itemIndex++;
+ }
+ }
+ }
+ }
+
+ private createButton(label: string, x: number, y: number, onClick: () => void): void {
+ const buttonWidth = 120;
+ const buttonHeight = 36;
+
+ const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
+ .setStrokeStyle(2, 0x7777aa)
+ .setInteractive({ useHandCursor: true });
+
+ const text = this.add.text(x, y, label, {
+ fontSize: '16px',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+
+ bg.on('pointerover', () => {
+ bg.setFillStyle(0x555588);
+ text.setScale(1.05);
+ });
+
+ bg.on('pointerout', () => {
+ bg.setFillStyle(0x444466);
+ text.setScale(1);
+ });
+
+ bg.on('pointerdown', onClick);
+ }
+}
diff --git a/packages/sts-like-viewer/src/scenes/IndexScene.ts b/packages/sts-like-viewer/src/scenes/IndexScene.ts
new file mode 100644
index 0000000..0f04c57
--- /dev/null
+++ b/packages/sts-like-viewer/src/scenes/IndexScene.ts
@@ -0,0 +1,70 @@
+import Phaser from 'phaser';
+import { ReactiveScene } from 'boardgame-phaser';
+
+export class IndexScene extends ReactiveScene {
+ constructor() {
+ super('IndexScene');
+ }
+
+ create(): void {
+ super.create();
+ const { width, height } = this.scale;
+ const centerX = width / 2;
+ const centerY = height / 2;
+
+ // Title
+ this.add.text(centerX, centerY - 150, 'Slay-the-Spire-Like Viewer', {
+ fontSize: '36px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+
+ // Subtitle
+ this.add.text(centerX, centerY - 100, 'Choose a viewer to explore:', {
+ fontSize: '18px',
+ color: '#aaaaaa',
+ }).setOrigin(0.5);
+
+ // Buttons
+ const buttons = [
+ { label: 'Map Viewer', scene: 'MapViewerScene', y: centerY - 20 },
+ { label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 50 },
+ { label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 120 },
+ ];
+
+ for (const btn of buttons) {
+ this.createButton(btn.label, btn.scene, centerX, btn.y);
+ }
+ }
+
+ private createButton(label: string, targetScene: string, x: number, y: number): void {
+ const buttonWidth = 300;
+ const buttonHeight = 50;
+
+ // Background
+ const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x333355)
+ .setStrokeStyle(2, 0x6666aa)
+ .setInteractive({ useHandCursor: true });
+
+ // Text
+ const text = this.add.text(x, y, label, {
+ fontSize: '20px',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+
+ // Hover effects
+ bg.on('pointerover', () => {
+ bg.setFillStyle(0x444477);
+ text.setScale(1.05);
+ });
+
+ bg.on('pointerout', () => {
+ bg.setFillStyle(0x333355);
+ text.setScale(1);
+ });
+
+ bg.on('pointerdown', () => {
+ this.scene.start(targetScene);
+ });
+ }
+}
diff --git a/packages/sts-like-viewer/src/scenes/MapViewerScene.ts b/packages/sts-like-viewer/src/scenes/MapViewerScene.ts
new file mode 100644
index 0000000..6fdaa9e
--- /dev/null
+++ b/packages/sts-like-viewer/src/scenes/MapViewerScene.ts
@@ -0,0 +1,184 @@
+import Phaser from 'phaser';
+import { ReactiveScene } from 'boardgame-phaser';
+import { generatePointCrawlMap, type PointCrawlMap, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like';
+
+const NODE_COLORS: Record = {
+ [MapNodeType.Start]: 0x44aa44,
+ [MapNodeType.Combat]: 0xcc4444,
+ [MapNodeType.Event]: 0xaaaa44,
+ [MapNodeType.Elite]: 0xcc44cc,
+ [MapNodeType.Shelter]: 0x44cccc,
+ [MapNodeType.NPC]: 0x4488cc,
+ [MapNodeType.Boss]: 0xcc8844,
+};
+
+const NODE_LABELS: Record = {
+ [MapNodeType.Start]: '起点',
+ [MapNodeType.Combat]: '战斗',
+ [MapNodeType.Event]: '事件',
+ [MapNodeType.Elite]: '精英',
+ [MapNodeType.Shelter]: '篝火',
+ [MapNodeType.NPC]: 'NPC',
+ [MapNodeType.Boss]: 'Boss',
+};
+
+export class MapViewerScene extends ReactiveScene {
+ private map: PointCrawlMap | null = null;
+ private seed: number = Date.now();
+
+ // Layout constants
+ private readonly LAYER_HEIGHT = 100;
+ private readonly NODE_SPACING = 120;
+ private readonly NODE_RADIUS = 25;
+
+ constructor() {
+ super('MapViewerScene');
+ }
+
+ create(): void {
+ super.create();
+ this.drawMap();
+ this.createControls();
+ }
+
+ private drawMap(): void {
+ this.children.removeAll();
+ this.map = generatePointCrawlMap(this.seed);
+
+ const { width, height } = this.scale;
+ const graphics = this.add.graphics();
+
+ // Draw edges first
+ graphics.lineStyle(2, 0x666666);
+ for (const [nodeId, node] of this.map.nodes) {
+ const posX = this.getNodeX(node, width);
+ const posY = this.getNodeY(node, height);
+
+ for (const childId of node.childIds) {
+ const child = this.map.nodes.get(childId);
+ if (child) {
+ const childX = this.getNodeX(child, width);
+ const childY = this.getNodeY(child, height);
+ graphics.lineBetween(posX, posY, childX, childY);
+ }
+ }
+ }
+
+ // Draw nodes
+ for (const [nodeId, node] of this.map.nodes) {
+ const posX = this.getNodeX(node, width);
+ const posY = this.getNodeY(node, height);
+ const color = NODE_COLORS[node.type as MapNodeType] ?? 0x888888;
+
+ // Node circle
+ graphics.fillStyle(color);
+ graphics.fillCircle(posX, posY, this.NODE_RADIUS);
+ graphics.lineStyle(2, 0xffffff);
+ graphics.strokeCircle(posX, posY, this.NODE_RADIUS);
+
+ // Node label
+ const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
+ this.add.text(posX, posY, label, {
+ fontSize: '12px',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+
+ // Encounter name (if available)
+ if ('encounter' in node && node.encounter) {
+ this.add.text(posX, posY + this.NODE_RADIUS + 10, (node as any).encounter.name ?? '', {
+ fontSize: '10px',
+ color: '#cccccc',
+ }).setOrigin(0.5);
+ }
+ }
+
+ // Title
+ this.add.text(width / 2, 30, `Map Viewer (Seed: ${this.seed})`, {
+ fontSize: '24px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+
+ // Legend
+ this.drawLegend(20, height - 200);
+ }
+
+ private getNodeX(node: any, sceneWidth: number): number {
+ const layer = this.map!.layers[node.layerIndex];
+ const nodeIndex = layer.nodeIds.indexOf(node.id);
+ const totalNodes = layer.nodeIds.length;
+ const layerWidth = (totalNodes - 1) * this.NODE_SPACING;
+ const startX = sceneWidth / 2 - layerWidth / 2;
+ return startX + nodeIndex * this.NODE_SPACING;
+ }
+
+ private getNodeY(node: any, sceneHeight: number): number {
+ return 80 + node.layerIndex * this.LAYER_HEIGHT;
+ }
+
+ private drawLegend(x: number, y: number): void {
+ const graphics = this.add.graphics();
+ graphics.fillStyle(0x222222, 0.8);
+ graphics.fillRect(x, y, 150, 180);
+
+ let offsetY = y + 15;
+ this.add.text(x + 10, offsetY, '图例:', {
+ fontSize: '14px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ });
+ offsetY += 25;
+
+ for (const [type, color] of Object.entries(NODE_COLORS)) {
+ graphics.fillStyle(color);
+ graphics.fillCircle(x + 20, offsetY, 8);
+ this.add.text(x + 40, offsetY - 5, NODE_LABELS[type as MapNodeType], {
+ fontSize: '12px',
+ color: '#ffffff',
+ });
+ offsetY += 22;
+ }
+ }
+
+ private createControls(): void {
+ const { width, height } = this.scale;
+
+ // Back button
+ this.createButton('返回菜单', 100, 40, () => {
+ this.scene.start('IndexScene');
+ });
+
+ // Regenerate button
+ this.createButton('重新生成', width - 120, 40, () => {
+ this.seed = Date.now();
+ this.drawMap();
+ this.createControls();
+ });
+ }
+
+ private createButton(label: string, x: number, y: number, onClick: () => void): void {
+ const buttonWidth = 140;
+ const buttonHeight = 36;
+
+ const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
+ .setStrokeStyle(2, 0x7777aa)
+ .setInteractive({ useHandCursor: true });
+
+ const text = this.add.text(x, y, label, {
+ fontSize: '16px',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+
+ bg.on('pointerover', () => {
+ bg.setFillStyle(0x555588);
+ text.setScale(1.05);
+ });
+
+ bg.on('pointerout', () => {
+ bg.setFillStyle(0x444466);
+ text.setScale(1);
+ });
+
+ bg.on('pointerdown', onClick);
+ }
+}
diff --git a/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts b/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts
new file mode 100644
index 0000000..2fb51d9
--- /dev/null
+++ b/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts
@@ -0,0 +1,166 @@
+import Phaser from 'phaser';
+import { ReactiveScene } from 'boardgame-phaser';
+import { parseShapeString, heroItemFighter1Data, type ParsedShape } from 'boardgame-core/samples/slay-the-spire-like';
+
+export class ShapeViewerScene extends ReactiveScene {
+ private readonly CELL_SIZE = 40;
+ private readonly ITEMS_PER_ROW = 4;
+ private currentIndex = 0;
+ private currentRotation = 0;
+ private currentFlipX = false;
+ private currentFlipY = false;
+
+ constructor() {
+ super('ShapeViewerScene');
+ }
+
+ create(): void {
+ super.create();
+ this.drawShapeViewer();
+ this.createControls();
+ }
+
+ private drawShapeViewer(): void {
+ this.children.removeAll();
+
+ const { width, height } = this.scale;
+
+ // Title
+ this.add.text(width / 2, 30, 'Shape Viewer - Item Shapes', {
+ fontSize: '24px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+
+ // Draw all shapes in a grid
+ this.drawAllShapes();
+ }
+
+ private drawAllShapes(): void {
+ const { width } = this.scale;
+ const startY = 80;
+ const spacingX = 220;
+ const spacingY = 140;
+
+ // Show first 12 items for clarity
+ const itemsToShow = heroItemFighter1Data.slice(0, 12);
+
+ for (let i = 0; i < itemsToShow.length; i++) {
+ const data = itemsToShow[i];
+ const shape = parseShapeString(data.shape);
+
+ const col = i % this.ITEMS_PER_ROW;
+ const row = Math.floor(i / this.ITEMS_PER_ROW);
+
+ const x = 60 + col * spacingX;
+ const y = startY + row * spacingY;
+
+ this.drawSingleShape(x, y, data, shape);
+ }
+ }
+
+ private drawSingleShape(startX: number, startY: number, data: any, shape: ParsedShape): void {
+ const graphics = this.add.graphics();
+
+ // Draw shape background
+ const shapeWidth = shape.width * this.CELL_SIZE;
+ const shapeHeight = shape.height * this.CELL_SIZE;
+
+ // Title - item name
+ this.add.text(startX + shapeWidth / 2, startY - 20, data.name, {
+ fontSize: '14px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+
+ // Draw shape cells
+ for (let y = 0; y < shape.height; y++) {
+ for (let x = 0; x < shape.width; x++) {
+ if (shape.grid[y]?.[x]) {
+ const px = startX + x * this.CELL_SIZE;
+ const py = startY + y * this.CELL_SIZE;
+
+ // Determine if this is the origin cell
+ const isOrigin = x === shape.originX && y === shape.originY;
+ const color = isOrigin ? 0x88cc44 : 0x4488cc;
+
+ graphics.fillStyle(color);
+ graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
+ graphics.lineStyle(2, 0xffffff);
+ graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
+
+ // Mark origin with 'O'
+ if (isOrigin) {
+ this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, 'O', {
+ fontSize: '16px',
+ color: '#ffffff',
+ fontStyle: 'bold',
+ }).setOrigin(0.5);
+ }
+ }
+ }
+ }
+
+ // Shape string
+ this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 10, `形状: ${data.shape}`, {
+ fontSize: '11px',
+ color: '#aaaaaa',
+ }).setOrigin(0.5);
+
+ // Type and cost
+ this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 28,
+ `类型: ${data.type} | 费用: ${data.costCount} ${data.costType}`, {
+ fontSize: '11px',
+ color: '#cccccc',
+ }).setOrigin(0.5);
+
+ // Description
+ this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 46, data.desc, {
+ fontSize: '10px',
+ color: '#888888',
+ wordWrap: { width: shapeWidth },
+ }).setOrigin(0.5);
+ }
+
+ private createControls(): void {
+ const { width, height } = this.scale;
+
+ // Back button
+ this.createButton('返回菜单', 100, height - 40, () => {
+ this.scene.start('IndexScene');
+ });
+
+ // Info text
+ this.add.text(width / 2, height - 40,
+ `Showing first 12 items | Green = Origin | Blue = Normal`, {
+ fontSize: '14px',
+ color: '#aaaaaa',
+ }).setOrigin(0.5);
+ }
+
+ private createButton(label: string, x: number, y: number, onClick: () => void): void {
+ const buttonWidth = 120;
+ const buttonHeight = 36;
+
+ const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
+ .setStrokeStyle(2, 0x7777aa)
+ .setInteractive({ useHandCursor: true });
+
+ const text = this.add.text(x, y, label, {
+ fontSize: '16px',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+
+ bg.on('pointerover', () => {
+ bg.setFillStyle(0x555588);
+ text.setScale(1.05);
+ });
+
+ bg.on('pointerout', () => {
+ bg.setFillStyle(0x444466);
+ text.setScale(1);
+ });
+
+ bg.on('pointerdown', onClick);
+ }
+}
diff --git a/packages/sts-like-viewer/src/style.css b/packages/sts-like-viewer/src/style.css
new file mode 100644
index 0000000..4da823c
--- /dev/null
+++ b/packages/sts-like-viewer/src/style.css
@@ -0,0 +1,7 @@
+@import "tailwindcss";
+
+body {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
diff --git a/packages/sts-like-viewer/src/ui/App.tsx b/packages/sts-like-viewer/src/ui/App.tsx
new file mode 100644
index 0000000..994888a
--- /dev/null
+++ b/packages/sts-like-viewer/src/ui/App.tsx
@@ -0,0 +1,27 @@
+import { h } from 'preact';
+import { PhaserGame, PhaserScene } from 'boardgame-phaser';
+import { useMemo } from 'preact/hooks';
+import { IndexScene } from '@/scenes/IndexScene';
+import { MapViewerScene } from '@/scenes/MapViewerScene';
+import { GridViewerScene } from '@/scenes/GridViewerScene';
+import { ShapeViewerScene } from '@/scenes/ShapeViewerScene';
+
+export default function App() {
+ const indexScene = useMemo(() => new IndexScene(), []);
+ const mapViewerScene = useMemo(() => new MapViewerScene(), []);
+ const gridViewerScene = useMemo(() => new GridViewerScene(), []);
+ const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []);
+
+ return (
+
+ );
+}
diff --git a/packages/sts-like-viewer/tsconfig.json b/packages/sts-like-viewer/tsconfig.json
new file mode 100644
index 0000000..d7f3323
--- /dev/null
+++ b/packages/sts-like-viewer/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "noEmit": true,
+ "declaration": false,
+ "declarationMap": false,
+ "sourceMap": false
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/sts-like-viewer/vite.config.ts b/packages/sts-like-viewer/vite.config.ts
new file mode 100644
index 0000000..c75bb07
--- /dev/null
+++ b/packages/sts-like-viewer/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+import tailwindcss from '@tailwindcss/vite';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [preact(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ 'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b7dd04..f80ea92 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -211,6 +211,46 @@ importers:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
+ packages/sts-like-viewer:
+ dependencies:
+ '@preact/signals-core':
+ specifier: ^1.5.1
+ version: 1.14.1
+ boardgame-core:
+ specifier: link:../../../boardgame-core
+ version: link:../../../boardgame-core
+ boardgame-phaser:
+ specifier: workspace:*
+ version: link:../framework
+ mutative:
+ specifier: ^1.3.0
+ version: 1.3.0
+ phaser:
+ specifier: ^3.80.1
+ version: 3.90.0
+ preact:
+ specifier: ^10.19.3
+ version: 10.29.0
+ devDependencies:
+ '@preact/preset-vite':
+ specifier: ^2.8.1
+ version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
+ '@preact/signals':
+ specifier: ^2.9.0
+ version: 2.9.0(preact@10.29.0)
+ '@tailwindcss/vite':
+ specifier: ^4.0.0
+ version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
+ tailwindcss:
+ specifier: ^4.0.0
+ version: 4.2.2
+ typescript:
+ specifier: ^5.3.3
+ version: 5.9.3
+ vite:
+ specifier: ^5.1.0
+ version: 5.4.21(lightningcss@1.32.0)
+
packages:
'@babel/code-frame@7.29.0':