From 3ca2a16e293df10e7326abf47731955551bf48b1 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 14 Apr 2026 14:21:51 +0800 Subject: [PATCH] refactor: global state for scenes --- .../framework/src/scenes/ReactiveScene.ts | 1 + .../src/scenes/GameFlowScene.ts | 92 ++++++++----------- .../src/scenes/MapViewerScene.ts | 28 +++--- .../src/scenes/PlaceholderEncounterScene.ts | 55 +++++++---- .../sts-like-viewer/src/state/gameState.ts | 29 ++++++ packages/sts-like-viewer/src/ui/App.tsx | 8 +- 6 files changed, 122 insertions(+), 91 deletions(-) create mode 100644 packages/sts-like-viewer/src/state/gameState.ts diff --git a/packages/framework/src/scenes/ReactiveScene.ts b/packages/framework/src/scenes/ReactiveScene.ts index 6b8dc5c..1989e10 100644 --- a/packages/framework/src/scenes/ReactiveScene.ts +++ b/packages/framework/src/scenes/ReactiveScene.ts @@ -7,6 +7,7 @@ type CleanupFn = void | (() => void); // 前向声明,避免循环导入 export interface SceneController { launch(sceneKey: string): Promise; + restart(): Promise; currentScene: ReadonlySignal; isTransitioning: ReadonlySignal; } diff --git a/packages/sts-like-viewer/src/scenes/GameFlowScene.ts b/packages/sts-like-viewer/src/scenes/GameFlowScene.ts index dd12759..34307b2 100644 --- a/packages/sts-like-viewer/src/scenes/GameFlowScene.ts +++ b/packages/sts-like-viewer/src/scenes/GameFlowScene.ts @@ -1,18 +1,16 @@ import Phaser from 'phaser'; import { ReactiveScene } from 'boardgame-phaser'; +import { MutableSignal } from 'boardgame-core'; import { - createRunState, canMoveTo, moveToNode, getCurrentNode, getReachableChildren, isAtEndNode, + isAtStartNode, 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, @@ -37,8 +35,8 @@ const NODE_LABELS: Record = { }; export class GameFlowScene extends ReactiveScene { - private runState: RunState; - private seed: number; + /** 全局游戏状态(由 App.tsx 注入) */ + private gameState: MutableSignal; // Layout constants private readonly LAYER_HEIGHT = 110; @@ -63,10 +61,9 @@ export class GameFlowScene extends ReactiveScene { private hoveredNode: string | null = null; private nodeGraphics: Map = new Map(); - constructor() { + constructor(gameState: MutableSignal) { super('GameFlowScene'); - this.seed = Date.now(); - this.runState = createRunState(this.seed); + this.gameState = gameState; } create(): void { @@ -114,7 +111,8 @@ export class GameFlowScene extends ReactiveScene { } private updateHUD(): void { - const { player, currentNodeId, map } = this.runState; + const state = this.gameState.value; + const { player, currentNodeId, map } = state; const currentNode = map.nodes.get(currentNodeId); this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`); @@ -129,12 +127,13 @@ export class GameFlowScene extends ReactiveScene { private drawMap(): void { const { width, height } = this.scale; + const state = this.gameState.value; - // Calculate map bounds + // Calculate map bounds (left-to-right: layers along X, nodes along Y) const maxLayer = 9; const maxNodesInLayer = 5; - const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200; - const mapHeight = maxLayer * this.LAYER_HEIGHT + 200; + 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); @@ -146,8 +145,8 @@ export class GameFlowScene extends ReactiveScene { const graphics = this.add.graphics(); this.mapContainer.add(graphics); - const { map, currentNodeId } = this.runState; - const reachableChildren = getReachableChildren(this.runState); + const { map, currentNodeId } = state; + const reachableChildren = getReachableChildren(state); const reachableIds = new Set(reachableChildren.map(n => n.id)); // Draw edges @@ -212,10 +211,11 @@ export class GameFlowScene extends ReactiveScene { ); } - // Make reachable nodes interactive + // 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; @@ -272,12 +272,13 @@ export class GameFlowScene extends ReactiveScene { } private async onNodeClick(nodeId: string): Promise { - if (!canMoveTo(this.runState, nodeId)) { + const state = this.gameState.value; + if (!canMoveTo(state, nodeId)) { return; } // Move to target node - const result = moveToNode(this.runState, nodeId); + const result = moveToNode(state, nodeId); if (!result.success) { console.warn(`无法移动到节点: ${result.reason}`); return; @@ -288,50 +289,25 @@ export class GameFlowScene extends ReactiveScene { this.redrawMapHighlights(); // Check if at end node - if (isAtEndNode(this.runState)) { + if (isAtEndNode(state)) { this.showEndScreen(); return; } // Launch encounter scene - const currentNode = getCurrentNode(this.runState); + const currentNode = getCurrentNode(state); if (!currentNode || !currentNode.encounter) { + console.warn('当前节点没有遭遇数据'); 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 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) { @@ -360,6 +336,7 @@ export class GameFlowScene extends ReactiveScene { 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); @@ -371,7 +348,7 @@ export class GameFlowScene extends ReactiveScene { fontStyle: 'bold', }).setOrigin(0.5).setDepth(300); - const { player } = this.runState; + const { player } = state; this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, { fontSize: '20px', color: '#ffffff', @@ -384,15 +361,18 @@ export class GameFlowScene extends ReactiveScene { } 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; + // Layers go left-to-right along X axis + return -500 + node.layerIndex * this.LAYER_HEIGHT; } private getNodeY(node: MapNode): number { - return -600 + node.layerIndex * this.LAYER_HEIGHT; + // 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 { diff --git a/packages/sts-like-viewer/src/scenes/MapViewerScene.ts b/packages/sts-like-viewer/src/scenes/MapViewerScene.ts index 5f064cf..e50b37b 100644 --- a/packages/sts-like-viewer/src/scenes/MapViewerScene.ts +++ b/packages/sts-like-viewer/src/scenes/MapViewerScene.ts @@ -116,8 +116,8 @@ export class MapViewerScene extends ReactiveScene { }); // 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 = this.add.container(20, this.scale.height - 180).setDepth(100); + const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8); this.legendContainer.add(legendBg); this.legendContainer.add( @@ -132,11 +132,11 @@ export class MapViewerScene extends ReactiveScene { this.legendContainer.add( this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' }) ); - offsetY += 22; + offsetY += 20; } // Hint text - this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图', { + this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图 (从左到右)', { fontSize: '14px', color: '#888888', }).setOrigin(0.5).setDepth(100); @@ -150,11 +150,11 @@ export class MapViewerScene extends ReactiveScene { // Update title this.titleText.setText(`Map Viewer (Seed: ${this.seed})`); - // Calculate map bounds + // Calculate map bounds (left-to-right: layers along X, nodes along Y) 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; + 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); @@ -240,14 +240,16 @@ export class MapViewerScene extends ReactiveScene { } 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; + // Layers go left-to-right along X axis + return -500 + node.layerIndex * this.LAYER_HEIGHT; } private getNodeY(node: MapNode): number { - return -600 + node.layerIndex * this.LAYER_HEIGHT; + // Nodes within a layer are spread vertically along Y axis + const layer = this.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; } } diff --git a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts index d86fec9..a418e66 100644 --- a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts +++ b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { ReactiveScene } from 'boardgame-phaser'; +import { MutableSignal } from 'boardgame-core'; import { resolveEncounter, type RunState, @@ -7,19 +8,11 @@ import { 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; -} - /** * 占位符遭遇场景 - * - * 当前实现:显示遭遇信息并提供"完成遭遇"按钮 - * + * + * 当前实现:从全局 gameState 读取当前遭遇信息并展示 + * * 后续扩展:根据 encounter.type 路由到不同的专用遭遇场景 * - MapNodeType.Minion / Elite → CombatEncounterScene * - MapNodeType.Shop → ShopEncounterScene @@ -27,9 +20,13 @@ export interface EncounterData { * - MapNodeType.Event → EventEncounterScene * - MapNodeType.Curio → CurioEncounterScene */ -export class PlaceholderEncounterScene extends ReactiveScene { - constructor() { +export class PlaceholderEncounterScene extends ReactiveScene { + /** 全局游戏状态(由 App.tsx 注入) */ + private gameState: MutableSignal; + + constructor(gameState: MutableSignal) { super('PlaceholderEncounterScene'); + this.gameState = gameState; } create(): void { @@ -37,7 +34,24 @@ export class PlaceholderEncounterScene extends ReactiveScene { const { width, height } = this.scale; const centerX = width / 2; const centerY = height / 2; - const { encounter, nodeId } = this.initData; + + // Read encounter data from global state + const state = this.gameState.value; + const node = state.map.nodes.get(state.currentNodeId); + if (!node || !node.encounter) { + this.add.text(centerX, centerY, '没有遭遇数据', { + fontSize: '24px', + color: '#ff4444', + }).setOrigin(0.5); + return; + } + + const encounter = { + type: node.type, + name: node.encounter.name, + description: node.encounter.description, + }; + const nodeId = node.id; // Title this.add.text(centerX, centerY - 150, '遭遇', { @@ -94,16 +108,17 @@ export class PlaceholderEncounterScene extends ReactiveScene { } private async completeEncounter(): Promise { - const { runState, nodeId, encounter, onComplete } = this.initData; + const state = this.gameState.value; + + // Get current encounter info + const node = state.map.nodes.get(state.currentNodeId); + if (!node || !node.encounter) return; // 生成模拟遭遇结果 - const result: EncounterResult = this.generatePlaceholderResult(encounter.type); + const result: EncounterResult = this.generatePlaceholderResult(node.type); // 调用进度管理器结算遭遇 - resolveEncounter(runState, result); - - // 回调通知上层 - onComplete(result); + resolveEncounter(state, result); // 返回游戏流程场景 await this.sceneController.launch('GameFlowScene'); diff --git a/packages/sts-like-viewer/src/state/gameState.ts b/packages/sts-like-viewer/src/state/gameState.ts new file mode 100644 index 0000000..135ee34 --- /dev/null +++ b/packages/sts-like-viewer/src/state/gameState.ts @@ -0,0 +1,29 @@ +import { MutableSignal, mutableSignal } from 'boardgame-core'; +import { createRunState, type RunState } from 'boardgame-core/samples/slay-the-spire-like'; + +/** + * 全局游戏运行状态 Signal + * + * 在 App.tsx 中创建为单例,所有场景共享。 + * 遭遇场景通过读取此 signal 的当前遭遇状态来构建 UI。 + */ +export function createGameState(seed?: number): MutableSignal { + return mutableSignal(createRunState(seed)); +} + +/** 获取当前遭遇数据(computed getter) */ +export function currentEncounter( + gameState: MutableSignal +): { nodeId: string; encounter: { name: string; description: string; type: string } } | null { + const state = gameState.value; + const node = state.map.nodes.get(state.currentNodeId); + if (!node || !node.encounter) return null; + return { + nodeId: node.id, + encounter: { + type: node.type, + name: node.encounter.name, + description: node.encounter.description, + }, + }; +} diff --git a/packages/sts-like-viewer/src/ui/App.tsx b/packages/sts-like-viewer/src/ui/App.tsx index 6f24e8d..959f3c1 100644 --- a/packages/sts-like-viewer/src/ui/App.tsx +++ b/packages/sts-like-viewer/src/ui/App.tsx @@ -7,14 +7,18 @@ import { GridViewerScene } from '@/scenes/GridViewerScene'; import { ShapeViewerScene } from '@/scenes/ShapeViewerScene'; import { GameFlowScene } from '@/scenes/GameFlowScene'; import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene'; +import { createGameState } from '@/state/gameState'; + +// 全局游戏状态单例 +const gameState = createGameState(); 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(), []); + const gameFlowScene = useMemo(() => new GameFlowScene(gameState), []); + const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(gameState), []); return (