import Phaser from 'phaser'; import { ReactiveScene } from 'boardgame-phaser'; import { generatePointCrawlMap, type PointCrawlMap, type MapNode, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like'; const NODE_COLORS: Record = { [MapNodeType.Start]: 0x44aa44, [MapNodeType.End]: 0xcc8844, [MapNodeType.Minion]: 0xcc4444, [MapNodeType.Elite]: 0xcc44cc, [MapNodeType.Event]: 0xaaaa44, [MapNodeType.Camp]: 0x44cccc, [MapNodeType.Shop]: 0x4488cc, [MapNodeType.Curio]: 0x8844cc, }; const NODE_LABELS: Record = { [MapNodeType.Start]: '起点', [MapNodeType.End]: '终点', [MapNodeType.Minion]: '战斗', [MapNodeType.Elite]: '精英', [MapNodeType.Event]: '事件', [MapNodeType.Camp]: '篝火', [MapNodeType.Shop]: '商店', [MapNodeType.Curio]: '奇遇', }; 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', async () => { await this.sceneController.launch('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 = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9) const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes) 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 (node.encounter) { this.mapContainer.add( this.add.text(posX, posY + this.NODE_RADIUS + 12, node.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: MapNode): 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: MapNode): number { return -600 + node.layerIndex * this.LAYER_HEIGHT; } }