diff --git a/packages/sts-like-viewer/src/scenes/GameFlowScene.ts b/packages/sts-like-viewer/src/scenes/GameFlowScene.ts new file mode 100644 index 0000000..dd12759 --- /dev/null +++ b/packages/sts-like-viewer/src/scenes/GameFlowScene.ts @@ -0,0 +1,437 @@ +import Phaser from 'phaser'; +import { ReactiveScene } from 'boardgame-phaser'; +import { + createRunState, + canMoveTo, + moveToNode, + getCurrentNode, + getReachableChildren, + isAtEndNode, + type RunState, + type MapNode, + type EncounterResult, + type EncounterState, +} from 'boardgame-core/samples/slay-the-spire-like'; +import { PlaceholderEncounterScene, type EncounterData } from './PlaceholderEncounterScene'; + +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 { + private runState: RunState; + private seed: number; + + // 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() { + super('GameFlowScene'); + this.seed = Date.now(); + this.runState = createRunState(this.seed); + } + + 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 { player, currentNodeId, map } = this.runState; + 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; + + // Calculate map bounds + const maxLayer = 9; + const maxNodesInLayer = 5; + 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 + 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 } = this.runState; + const reachableChildren = getReachableChildren(this.runState); + 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 + if (isReachable) { + const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0) + .setInteractive({ useHandCursor: true }); + + 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 { + if (!canMoveTo(this.runState, nodeId)) { + return; + } + + // Move to target node + const result = moveToNode(this.runState, nodeId); + if (!result.success) { + console.warn(`无法移动到节点: ${result.reason}`); + return; + } + + // Update visuals + this.updateHUD(); + this.redrawMapHighlights(); + + // Check if at end node + if (isAtEndNode(this.runState)) { + this.showEndScreen(); + return; + } + + // Launch encounter scene + const currentNode = getCurrentNode(this.runState); + if (!currentNode || !currentNode.encounter) { + return; + } + + // Create encounter data + const encounterData: EncounterData = { + runState: this.runState, + nodeId: currentNode.id, + encounter: { + type: currentNode.type, + name: currentNode.encounter.name, + description: currentNode.encounter.description, + }, + onComplete: (result: EncounterResult) => { + // Encounter completed, update HUD + this.updateHUD(); + this.redrawMapHighlights(); + }, + }; + + // Re-add encounter scene with new data + const phaserGame = this.phaserGame.value.game; + const encounterScene = new PlaceholderEncounterScene(); + if (!phaserGame.scene.getScene('PlaceholderEncounterScene')) { + phaserGame.scene.add('PlaceholderEncounterScene', encounterScene, false, { + ...encounterData, + phaserGame: this.phaserGame, + sceneController: this.sceneController, + }); + } + + await this.sceneController.launch('PlaceholderEncounterScene'); + } + + private redrawMapHighlights(): void { + const { map, currentNodeId } = this.runState; + const reachableChildren = getReachableChildren(this.runState); + 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; + + // 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 } = this.runState; + 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 { + const layer = this.runState.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; + } + + 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); + } +} diff --git a/packages/sts-like-viewer/src/scenes/IndexScene.ts b/packages/sts-like-viewer/src/scenes/IndexScene.ts index 845b9c6..fb6adfe 100644 --- a/packages/sts-like-viewer/src/scenes/IndexScene.ts +++ b/packages/sts-like-viewer/src/scenes/IndexScene.ts @@ -27,9 +27,10 @@ export class IndexScene extends ReactiveScene { // 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 }, + { label: '开始游戏', scene: 'GameFlowScene', y: centerY - 70 }, + { label: 'Map Viewer', scene: 'MapViewerScene', y: centerY }, + { label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 70 }, + { label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 }, ]; for (const btn of buttons) { diff --git a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts new file mode 100644 index 0000000..d86fec9 --- /dev/null +++ b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts @@ -0,0 +1,187 @@ +import Phaser from 'phaser'; +import { ReactiveScene } from 'boardgame-phaser'; +import { + resolveEncounter, + type RunState, + type EncounterResult, + type MapNodeType, +} from 'boardgame-core/samples/slay-the-spire-like'; + +/** 遭遇场景接收的数据 */ +export interface EncounterData { + runState: RunState; + nodeId: string; + encounter: { type: MapNodeType; name: string; description: string }; + onComplete: (result: EncounterResult) => void; +} + +/** + * 占位符遭遇场景 + * + * 当前实现:显示遭遇信息并提供"完成遭遇"按钮 + * + * 后续扩展:根据 encounter.type 路由到不同的专用遭遇场景 + * - MapNodeType.Minion / Elite → CombatEncounterScene + * - MapNodeType.Shop → ShopEncounterScene + * - MapNodeType.Camp → CampEncounterScene + * - MapNodeType.Event → EventEncounterScene + * - MapNodeType.Curio → CurioEncounterScene + */ +export class PlaceholderEncounterScene extends ReactiveScene { + constructor() { + super('PlaceholderEncounterScene'); + } + + create(): void { + super.create(); + const { width, height } = this.scale; + const centerX = width / 2; + const centerY = height / 2; + const { encounter, nodeId } = this.initData; + + // Title + this.add.text(centerX, centerY - 150, '遭遇', { + fontSize: '32px', + color: '#ffffff', + fontStyle: 'bold', + }).setOrigin(0.5); + + // Encounter type badge + const typeLabel = this.getEncounterTypeLabel(encounter.type); + const typeBg = this.add.rectangle(centerX, centerY - 80, 120, 36, this.getEncounterTypeColor(encounter.type)); + this.add.text(centerX, centerY - 80, typeLabel, { + fontSize: '16px', + color: '#ffffff', + fontStyle: 'bold', + }).setOrigin(0.5); + + // Encounter name + this.add.text(centerX, centerY - 30, encounter.name, { + fontSize: '24px', + color: '#ffffff', + }).setOrigin(0.5); + + // Encounter description + this.add.text(centerX, centerY + 20, encounter.description || '(暂无描述)', { + fontSize: '16px', + color: '#aaaaaa', + wordWrap: { width: 600 }, + align: 'center', + }).setOrigin(0.5); + + // Node ID info + this.add.text(centerX, centerY + 80, `节点: ${nodeId}`, { + fontSize: '12px', + color: '#666666', + }).setOrigin(0.5); + + // Placeholder notice + this.add.text(centerX, centerY + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', { + fontSize: '14px', + color: '#ff8844', + fontStyle: 'italic', + }).setOrigin(0.5); + + // Complete button + this.createButton('完成遭遇', centerX, centerY + 200, 200, 50, async () => { + await this.completeEncounter(); + }); + + // Back button (without resolving) + this.createButton('暂不处理', centerX, centerY + 270, 200, 40, async () => { + await this.sceneController.launch('GameFlowScene'); + }); + } + + private async completeEncounter(): Promise { + const { runState, nodeId, encounter, onComplete } = this.initData; + + // 生成模拟遭遇结果 + const result: EncounterResult = this.generatePlaceholderResult(encounter.type); + + // 调用进度管理器结算遭遇 + resolveEncounter(runState, result); + + // 回调通知上层 + onComplete(result); + + // 返回游戏流程场景 + await this.sceneController.launch('GameFlowScene'); + } + + private generatePlaceholderResult(type: MapNodeType): EncounterResult { + // 根据遭遇类型生成不同的模拟结果 + switch (type) { + case 'minion': + case 'elite': + return { hpLost: type === 'elite' ? 15 : 8, goldEarned: type === 'elite' ? 30 : 15 }; + case 'camp': + return { hpGained: 15 }; + case 'shop': + return { goldEarned: 0 }; + case 'curio': + case 'event': + return { goldEarned: 20 }; + default: + return {}; + } + } + + private getEncounterTypeLabel(type: MapNodeType): string { + const labels: Record = { + start: '起点', + end: '终点', + minion: '战斗', + elite: '精英战斗', + event: '事件', + camp: '营地', + shop: '商店', + curio: '奇遇', + }; + return labels[type] ?? type; + } + + private getEncounterTypeColor(type: MapNodeType): number { + const colors: Record = { + start: 0x44aa44, + end: 0xcc8844, + minion: 0xcc4444, + elite: 0xcc44cc, + event: 0xaaaa44, + camp: 0x44cccc, + shop: 0x4488cc, + curio: 0x8844cc, + }; + return colors[type] ?? 0x888888; + } + + private createButton( + label: string, + x: number, + y: number, + width: number, + height: number, + onClick: () => void + ): void { + const bg = this.add.rectangle(x, y, width, height, 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/ui/App.tsx b/packages/sts-like-viewer/src/ui/App.tsx index 457d347..6f24e8d 100644 --- a/packages/sts-like-viewer/src/ui/App.tsx +++ b/packages/sts-like-viewer/src/ui/App.tsx @@ -5,12 +5,16 @@ import { IndexScene } from '@/scenes/IndexScene'; import { MapViewerScene } from '@/scenes/MapViewerScene'; import { GridViewerScene } from '@/scenes/GridViewerScene'; import { ShapeViewerScene } from '@/scenes/ShapeViewerScene'; +import { GameFlowScene } from '@/scenes/GameFlowScene'; +import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene'; export default function App() { const indexScene = useMemo(() => new IndexScene(), []); const mapViewerScene = useMemo(() => new MapViewerScene(), []); const gridViewerScene = useMemo(() => new GridViewerScene(), []); const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []); + const gameFlowScene = useMemo(() => new GameFlowScene(), []); + const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(), []); return (
@@ -20,6 +24,8 @@ export default function App() { + +