refactor: global state for scenes

This commit is contained in:
hypercross 2026-04-14 14:21:51 +08:00
parent 7fb9edcbf0
commit 3ca2a16e29
6 changed files with 122 additions and 91 deletions

View File

@ -7,6 +7,7 @@ type CleanupFn = void | (() => void);
// 前向声明,避免循环导入
export interface SceneController {
launch(sceneKey: string): Promise<void>;
restart(): Promise<void>;
currentScene: ReadonlySignal<string | null>;
isTransitioning: ReadonlySignal<boolean>;
}

View File

@ -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<string, number> = {
start: 0x44aa44,
@ -37,8 +35,8 @@ const NODE_LABELS: Record<string, string> = {
};
export class GameFlowScene extends ReactiveScene {
private runState: RunState;
private seed: number;
/** 全局游戏状态(由 App.tsx 注入) */
private gameState: MutableSignal<RunState>;
// Layout constants
private readonly LAYER_HEIGHT = 110;
@ -63,10 +61,9 @@ export class GameFlowScene extends ReactiveScene {
private hoveredNode: string | null = null;
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
constructor() {
constructor(gameState: MutableSignal<RunState>) {
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<void> {
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 {

View File

@ -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;
}
}

View File

@ -1,5 +1,6 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import { MutableSignal } from 'boardgame-core';
import {
resolveEncounter,
type RunState,
@ -7,18 +8,10 @@ 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
@ -27,9 +20,13 @@ export interface EncounterData {
* - MapNodeType.Event EventEncounterScene
* - MapNodeType.Curio CurioEncounterScene
*/
export class PlaceholderEncounterScene extends ReactiveScene<EncounterData> {
constructor() {
export class PlaceholderEncounterScene extends ReactiveScene {
/** 全局游戏状态(由 App.tsx 注入) */
private gameState: MutableSignal<RunState>;
constructor(gameState: MutableSignal<RunState>) {
super('PlaceholderEncounterScene');
this.gameState = gameState;
}
create(): void {
@ -37,7 +34,24 @@ export class PlaceholderEncounterScene extends ReactiveScene<EncounterData> {
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<EncounterData> {
}
private async completeEncounter(): Promise<void> {
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');

View File

@ -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<RunState> {
return mutableSignal<RunState>(createRunState(seed));
}
/** 获取当前遭遇数据computed getter */
export function currentEncounter(
gameState: MutableSignal<RunState>
): { 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,
},
};
}

View File

@ -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 (
<div className="flex flex-col h-screen">