Compare commits
2 Commits
02d2f45da2
...
c639218b53
| Author | SHA1 | Date |
|---|---|---|
|
|
c639218b53 | |
|
|
e91e8d2ab7 |
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>STS-Like Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ui-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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: <App/>,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
|
@ -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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
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, number> = {
|
||||
[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, string> = {
|
||||
[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 = 110;
|
||||
private readonly NODE_SPACING = 140;
|
||||
private readonly NODE_RADIUS = 28;
|
||||
|
||||
// Scroll state
|
||||
private mapContainer!: Phaser.GameObjects.Container;
|
||||
private isDragging = false;
|
||||
private dragStartX = 0;
|
||||
private dragStartContainerX = 0;
|
||||
private dragStartY = 0;
|
||||
private dragStartContainerY = 0;
|
||||
|
||||
// Fixed UI (always visible, not scrolled)
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private backButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private backButtonText!: Phaser.GameObjects.Text;
|
||||
private regenButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private regenButtonText!: Phaser.GameObjects.Text;
|
||||
private legendContainer!: Phaser.GameObjects.Container;
|
||||
|
||||
constructor() {
|
||||
super('MapViewerScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
this.drawFixedUI();
|
||||
this.drawMap();
|
||||
}
|
||||
|
||||
private drawFixedUI(): void {
|
||||
const { width } = this.scale;
|
||||
|
||||
// Title
|
||||
this.titleText = this.add.text(width / 2, 30, '', {
|
||||
fontSize: '24px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
|
||||
// Back button
|
||||
this.backButtonBg = this.add.rectangle(100, 40, 140, 36, 0x444466)
|
||||
.setStrokeStyle(2, 0x7777aa)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(100);
|
||||
this.backButtonText = this.add.text(100, 40, '返回菜单', {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
|
||||
this.backButtonBg.on('pointerover', () => {
|
||||
this.backButtonBg.setFillStyle(0x555588);
|
||||
this.backButtonText.setScale(1.05);
|
||||
});
|
||||
this.backButtonBg.on('pointerout', () => {
|
||||
this.backButtonBg.setFillStyle(0x444466);
|
||||
this.backButtonText.setScale(1);
|
||||
});
|
||||
this.backButtonBg.on('pointerdown', () => {
|
||||
this.scene.start('IndexScene');
|
||||
});
|
||||
|
||||
// Regenerate button
|
||||
this.regenButtonBg = this.add.rectangle(width - 120, 40, 140, 36, 0x444466)
|
||||
.setStrokeStyle(2, 0x7777aa)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(100);
|
||||
this.regenButtonText = this.add.text(width - 120, 40, '重新生成', {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
|
||||
this.regenButtonBg.on('pointerover', () => {
|
||||
this.regenButtonBg.setFillStyle(0x555588);
|
||||
this.regenButtonText.setScale(1.05);
|
||||
});
|
||||
this.regenButtonBg.on('pointerout', () => {
|
||||
this.regenButtonBg.setFillStyle(0x444466);
|
||||
this.regenButtonText.setScale(1);
|
||||
});
|
||||
this.regenButtonBg.on('pointerdown', () => {
|
||||
this.seed = Date.now();
|
||||
this.mapContainer.destroy();
|
||||
this.drawMap();
|
||||
});
|
||||
|
||||
// Legend (bottom-left, fixed)
|
||||
this.legendContainer = this.add.container(20, this.scale.height - 200).setDepth(100);
|
||||
const legendBg = this.add.rectangle(75, 90, 150, 180, 0x222222, 0.8);
|
||||
this.legendContainer.add(legendBg);
|
||||
|
||||
this.legendContainer.add(
|
||||
this.add.text(10, 5, '图例:', { fontSize: '14px', color: '#ffffff', fontStyle: 'bold' })
|
||||
);
|
||||
|
||||
let offsetY = 30;
|
||||
for (const [type, color] of Object.entries(NODE_COLORS)) {
|
||||
this.legendContainer.add(
|
||||
this.add.circle(20, offsetY, 8, color)
|
||||
);
|
||||
this.legendContainer.add(
|
||||
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
|
||||
);
|
||||
offsetY += 22;
|
||||
}
|
||||
|
||||
// Hint text
|
||||
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图', {
|
||||
fontSize: '14px',
|
||||
color: '#888888',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
}
|
||||
|
||||
private drawMap(): void {
|
||||
this.map = generatePointCrawlMap(this.seed);
|
||||
|
||||
const { width, height } = this.scale;
|
||||
|
||||
// Update title
|
||||
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
|
||||
|
||||
// Calculate map bounds
|
||||
const maxLayer = 12; // TOTAL_LAYERS - 1
|
||||
const maxNodesInLayer = 6; // widest layer
|
||||
const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
||||
const mapHeight = maxLayer * this.LAYER_HEIGHT + 200;
|
||||
|
||||
// Create scrollable container
|
||||
this.mapContainer = this.add.container(width / 2, height / 2);
|
||||
|
||||
// Background panel for the map area
|
||||
const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
|
||||
.setOrigin(0.5);
|
||||
this.mapContainer.add(bg);
|
||||
|
||||
const graphics = this.add.graphics();
|
||||
this.mapContainer.add(graphics);
|
||||
|
||||
// Draw edges
|
||||
graphics.lineStyle(2, 0x666666);
|
||||
for (const [nodeId, node] of this.map.nodes) {
|
||||
const posX = this.getNodeX(node);
|
||||
const posY = this.getNodeY(node);
|
||||
|
||||
for (const childId of node.childIds) {
|
||||
const child = this.map.nodes.get(childId);
|
||||
if (child) {
|
||||
const childX = this.getNodeX(child);
|
||||
const childY = this.getNodeY(child);
|
||||
graphics.lineBetween(posX, posY, childX, childY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (const [nodeId, node] of this.map.nodes) {
|
||||
const posX = this.getNodeX(node);
|
||||
const posY = this.getNodeY(node);
|
||||
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.mapContainer.add(
|
||||
this.add.text(posX, posY, label, {
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5)
|
||||
);
|
||||
|
||||
// Encounter name (if available)
|
||||
if ('encounter' in node && (node as any).encounter) {
|
||||
this.mapContainer.add(
|
||||
this.add.text(posX, posY + this.NODE_RADIUS + 12, (node as any).encounter.name ?? '', {
|
||||
fontSize: '10px',
|
||||
color: '#cccccc',
|
||||
}).setOrigin(0.5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drag-to-scroll
|
||||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
this.isDragging = true;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.dragStartContainerX = this.mapContainer.x;
|
||||
this.dragStartContainerY = this.mapContainer.y;
|
||||
});
|
||||
|
||||
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.isDragging) return;
|
||||
this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
});
|
||||
|
||||
this.input.on('pointerup', () => {
|
||||
this.isDragging = false;
|
||||
});
|
||||
|
||||
this.input.on('pointerout', () => {
|
||||
this.isDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
private getNodeX(node: any): 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;
|
||||
return -layerWidth / 2 + nodeIndex * this.NODE_SPACING;
|
||||
}
|
||||
|
||||
private getNodeY(node: any): number {
|
||||
return -600 + node.layerIndex * this.LAYER_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame initialScene="IndexScene" config={{ width: 1920, height: 1080 }}>
|
||||
<PhaserScene sceneKey="IndexScene" scene={indexScene} />
|
||||
<PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} />
|
||||
<PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} />
|
||||
<PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} />
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue