import Phaser from 'phaser'; import { ReactiveScene } from 'boardgame-phaser'; import { MutableSignal } from 'boardgame-core'; import { canMoveTo, moveToNode, getCurrentNode, getReachableChildren, isAtEndNode, isAtStartNode, type RunState, type MapNode, } from 'boardgame-core/samples/slay-the-spire-like'; const NODE_COLORS: Record = { start: 0x44aa44, end: 0xcc8844, minion: 0xcc4444, elite: 0xcc44cc, event: 0xaaaa44, camp: 0x44cccc, shop: 0x4488cc, curio: 0x8844cc, }; const NODE_LABELS: Record = { start: '起点', end: '终点', minion: '战斗', elite: '精英', event: '事件', camp: '营地', shop: '商店', curio: '奇遇', }; export class GameFlowScene extends ReactiveScene { /** 全局游戏状态(由 App.tsx 注入) */ private gameState: MutableSignal; // Layout constants private readonly LAYER_HEIGHT = 110; private readonly NODE_SPACING = 140; private readonly NODE_RADIUS = 28; // UI elements private hudContainer!: Phaser.GameObjects.Container; private hpText!: Phaser.GameObjects.Text; private goldText!: Phaser.GameObjects.Text; private nodeText!: Phaser.GameObjects.Text; // Map elements private mapContainer!: Phaser.GameObjects.Container; private isDragging = false; private dragStartX = 0; private dragStartY = 0; private dragStartContainerX = 0; private dragStartContainerY = 0; // Interaction private hoveredNode: string | null = null; private nodeGraphics: Map = new Map(); constructor(gameState: MutableSignal) { super('GameFlowScene'); this.gameState = gameState; } create(): void { super.create(); this.drawHUD(); this.drawMap(); this.updateHUD(); } private drawHUD(): void { const { width } = this.scale; // HUD background const hudBg = this.add.rectangle(width / 2, 25, 400, 40, 0x111122, 0.8); this.hudContainer = this.add.container(width / 2, 25).setDepth(200); this.hudContainer.add(hudBg); // HP this.hpText = this.add.text(-150, 0, '', { fontSize: '16px', color: '#ff6666', fontStyle: 'bold', }).setOrigin(0, 0.5); this.hudContainer.add(this.hpText); // Gold this.goldText = this.add.text(-50, 0, '', { fontSize: '16px', color: '#ffcc44', fontStyle: 'bold', }).setOrigin(0, 0.5); this.hudContainer.add(this.goldText); // Current node this.nodeText = this.add.text(50, 0, '', { fontSize: '16px', color: '#ffffff', }).setOrigin(0, 0.5); this.hudContainer.add(this.nodeText); // Back to menu button this.createButton('返回菜单', width - 100, 25, 140, 36, async () => { await this.sceneController.launch('IndexScene'); }); } private updateHUD(): void { const state = this.gameState.value; const { player, currentNodeId, map } = state; const currentNode = map.nodes.get(currentNodeId); this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`); this.goldText.setText(`💰 ${player.gold}`); if (currentNode) { const typeLabel = NODE_LABELS[currentNode.type] ?? currentNode.type; const encounterName = currentNode.encounter?.name ?? typeLabel; this.nodeText.setText(`当前: ${encounterName}`); } } private drawMap(): void { const { width, height } = this.scale; const state = this.gameState.value; // Calculate map bounds (left-to-right: layers along X, nodes along Y) const maxLayer = 9; const maxNodesInLayer = 5; const mapWidth = maxLayer * this.LAYER_HEIGHT + 200; const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200; // Create scrollable container this.mapContainer = this.add.container(width / 2, height / 2 + 50); // Background panel 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); const { map, currentNodeId } = state; const reachableChildren = getReachableChildren(state); const reachableIds = new Set(reachableChildren.map(n => n.id)); // Draw edges graphics.lineStyle(2, 0x666666); for (const [nodeId, node] of map.nodes) { const posX = this.getNodeX(node); const posY = this.getNodeY(node); for (const childId of node.childIds) { const child = 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 map.nodes) { const posX = this.getNodeX(node); const posY = this.getNodeY(node); const isCurrent = nodeId === currentNodeId; const isReachable = reachableIds.has(nodeId); const baseColor = NODE_COLORS[node.type] ?? 0x888888; // Node circle const nodeGraphics = this.add.graphics(); this.mapContainer.add(nodeGraphics); this.nodeGraphics.set(nodeId, nodeGraphics); const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor); nodeGraphics.fillStyle(color); nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS); if (isCurrent) { nodeGraphics.lineStyle(3, 0xffff44); } else if (isReachable) { nodeGraphics.lineStyle(2, 0xaaddaa); } else { nodeGraphics.lineStyle(2, 0x888888); } nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS); // Node label const label = NODE_LABELS[node.type] ?? node.type; this.mapContainer.add( this.add.text(posX, posY, label, { fontSize: '11px', color: '#ffffff', fontStyle: isCurrent ? 'bold' : 'normal', }).setOrigin(0.5) ); // Encounter name 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) ); } // Make reachable nodes interactive (add hitZone to mapContainer so positions match) if (isReachable) { const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0) .setInteractive({ useHandCursor: true }); this.mapContainer.add(hitZone); hitZone.on('pointerover', () => { this.hoveredNode = nodeId; nodeGraphics.clear(); nodeGraphics.fillStyle(this.brightenColor(baseColor)); nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS); nodeGraphics.lineStyle(3, 0xaaddaa); nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS); }); hitZone.on('pointerout', () => { this.hoveredNode = null; nodeGraphics.clear(); nodeGraphics.fillStyle(baseColor); nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS); nodeGraphics.lineStyle(2, 0xaaddaa); nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS); }); hitZone.on('pointerdown', () => { this.onNodeClick(nodeId); }); } } // 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; }); // Hint text this.add.text(width / 2, this.scale.height - 20, '点击可到达的节点进入遭遇 | 拖拽滚动查看地图', { fontSize: '14px', color: '#888888', }).setOrigin(0.5).setDepth(200); } private async onNodeClick(nodeId: string): Promise { const state = this.gameState.value; if (!canMoveTo(state, nodeId)) { return; } // Move to target node const result = moveToNode(state, nodeId); if (!result.success) { console.warn(`无法移动到节点: ${result.reason}`); return; } // Update visuals this.updateHUD(); this.redrawMapHighlights(); // Check if at end node if (isAtEndNode(state)) { this.showEndScreen(); return; } // Launch encounter scene const currentNode = getCurrentNode(state); if (!currentNode || !currentNode.encounter) { console.warn('当前节点没有遭遇数据'); return; } await this.sceneController.launch('PlaceholderEncounterScene'); } private redrawMapHighlights(): void { const state = this.gameState.value; const { map, currentNodeId } = state; const reachableChildren = getReachableChildren(state); const reachableIds = new Set(reachableChildren.map(n => n.id)); for (const [nodeId, nodeGraphics] of this.nodeGraphics) { const node = map.nodes.get(nodeId); if (!node) continue; const isCurrent = nodeId === currentNodeId; const isReachable = reachableIds.has(nodeId); const baseColor = NODE_COLORS[node.type] ?? 0x888888; nodeGraphics.clear(); const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor); nodeGraphics.fillStyle(color); nodeGraphics.fillCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS); if (isCurrent) { nodeGraphics.lineStyle(3, 0xffff44); } else if (isReachable) { nodeGraphics.lineStyle(2, 0xaaddaa); } else { nodeGraphics.lineStyle(2, 0x888888); } nodeGraphics.strokeCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS); } } private showEndScreen(): void { const { width, height } = this.scale; const state = this.gameState.value; // Overlay const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300); // End message this.add.text(width / 2, height / 2 - 40, '恭喜通关!', { fontSize: '36px', color: '#ffcc44', fontStyle: 'bold', }).setOrigin(0.5).setDepth(300); const { player } = state; this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, { fontSize: '20px', color: '#ffffff', align: 'center', }).setOrigin(0.5).setDepth(300); this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => { await this.sceneController.launch('IndexScene'); }, 300); } private getNodeX(node: MapNode): number { // Layers go left-to-right along X axis return -500 + node.layerIndex * this.LAYER_HEIGHT; } private getNodeY(node: MapNode): number { // Nodes within a layer are spread vertically along Y axis const state = this.gameState.value; const layer = state.map.layers[node.layerIndex]; const nodeIndex = layer.nodeIds.indexOf(node.id); const totalNodes = layer.nodeIds.length; const layerHeight = (totalNodes - 1) * this.NODE_SPACING; return -layerHeight / 2 + nodeIndex * this.NODE_SPACING; } private brightenColor(color: number): number { // Simple color brightening const r = Math.min(255, ((color >> 16) & 0xff) + 40); const g = Math.min(255, ((color >> 8) & 0xff) + 40); const b = Math.min(255, (color & 0xff) + 40); return (r << 16) | (g << 8) | b; } private createButton( label: string, x: number, y: number, width: number, height: number, onClick: () => void, depth: number = 200 ): void { const bg = this.add.rectangle(x, y, width, height, 0x444466) .setStrokeStyle(2, 0x7777aa) .setInteractive({ useHandCursor: true }) .setDepth(depth); const text = this.add.text(x, y, label, { fontSize: '16px', color: '#ffffff', }).setOrigin(0.5).setDepth(depth); bg.on('pointerover', () => { bg.setFillStyle(0x555588); text.setScale(1.05); }); bg.on('pointerout', () => { bg.setFillStyle(0x444466); text.setScale(1); }); bg.on('pointerdown', onClick); } }