流程控制教程:客户端与服务器权威实现
本文面向《恶魔轮盘》开发者,以教程式说明介绍对局流程控制的设计原理、代码结构与双端差异。
建议阅读顺序:先理解客户端(单机)流程 → 再理解服务器如何「复用同一套逻辑」并增加网络层。
目录
- 为什么需要流程控制模块
- 核心概念速览
- 客户端(单机)流程控制
- 服务器权威流程控制
- 双端对比与适用架构
- 扩展新功能时的接入清单
- 相关文件索引
1. 为什么需要流程控制模块
卡牌/回合制游戏中,规则逻辑与玩家交互往往纠缠在一起:开枪要轮询响应、濒死要插队结算、道具使用可能触发嵌套濒死……如果把这些分支散落在 UI 或各技能脚本里,很快会变成不可维护的「意大利面代码」。
《恶魔轮盘》的做法是:抽出一层「流程控制(Flow)」作为全局调度中枢,所有模块只负责自己的职责:
| 模块 | 职责 | 不做什么 |
| FlowController | 回合流转、五阶段状态机、响应链、压栈/出栈 | 不直接画 UI |
| GameSession | 聚合玩家、枪膛、事件总线、技能执行器 | 不决定「何时轮到谁」 |
| IDebugHost | 决策输入、表现播放、快照推送(宿主差异点) | 不修改游戏规则 |
| Skill / Item | 响应事件、执行效果 | 不推进回合阶段 |
这种分层特别适合:
- 回合制 + 响应窗口(类似三国杀锦囊响应)
- 需要权威服务器的联机对战(客户端只展示,服务端算结果)
- 同一套规则跑在 Unity 与服务端(共享 GameCore,减少双端不一致)
2. 核心概念速览
2.1 五阶段回合状态机
每个存活玩家的回合严格按顺序推进,不可跳步:
TurnStart → MainAction → Judgment → Discard → TurnEnd
回合开始 主行动 裁决 弃牌 回合结束
对应枚举 TurnPhase(Assets/Scripts/Core/GameEnums.cs)。
2.2 分层状态栈
常规五阶段是「基础流程」。当开枪、濒死、转职等临时交互插入时,当前流程压栈挂起,处理完毕再出栈恢复:
栈底 ── … ── [MainAction 挂起] ── [ShootResponse 活跃] ← 栈顶
GameStateStack 实现先入后出;FlowStackEntry.StateType 区分 TurnPhase / ShootResponse / DyingResponse / TraitorChoice。
2.3 协程驱动的「可中断流程」
FlowController 全部用 C# IEnumerator 协程编写。遇到「等玩家点按钮」时 yield return 挂起,决策到达后继续——这与 Unity 单机天然契合;服务端则用 CoroutineDriver 把同一套协程跑在 async/await 线程上。
2.4 IDebugHost:双端的「插槽」
流程层从不直接读键盘或 WebSocket,而是调用 IDebugHost 接口:
- Unity 客户端:
DebugService — 本地 IMGUI / NormalHud 收决策、本地播特效
- 服务端:
ServerDebugHost — 等 WS 提交决策、经 FlowPublisher 广播 FlowStep
同一套 FlowController 代码,只换宿主实现,即可单机/联机共用。
3. 客户端(单机)流程控制
3.1 启动链路
单机对局从 GameEntry 启动,创建 GameSession(DebugService) 后跑协程:
// Assets/Scripts/Game/GameEntry.cs(节选)
Session = new GameSession(new DebugService());
// ...
yield return Session.Flow.RunGameCoroutine();
GameSession 构造函数里自动创建 FlowController:
// Assets/Scripts/Flow/GameSession.cs(节选)
public GameSession(IDebugHost debug)
{
Debug = debug;
ItemService = new ItemService(SkillExecutor, Random);
AI = new AIDecisionMaker(this);
Flow = new FlowController(this);
}
3.2 对局主循环
RunGameCoroutine 是对局顶层入口:发开局牌 → 循环各玩家回合 → 直到 IsGameOver:
// Assets/Scripts/Flow/FlowController.cs(节选)
public IEnumerator RunGameCoroutine()
{
yield return PresentStartingDealsIfAny();
while (!_session.IsGameOver)
{
var player = _session.GetCurrentTurnPlayer();
if (player == null) { _session.SetGameOver(); break; }
_session.SetTurnOwner(player);
player.ShootLockedThisTurn = false;
yield return RunTurnCoroutine(player);
if (_session.IsGameOver) break;
_session.AdvanceTurn();
PushAuthoritativeHudSnapshot(FlowBoundaryKind.TurnChange);
}
}
3.3 单回合五阶段
public IEnumerator RunTurnCoroutine(PlayerEntity player)
{
yield return RunTurnStartPhase(player); // 发道具、装弹、TurnStart 被动
yield return RunMainActionPhase(player); // 开枪/道具/结束 循环
yield return RunJudgmentPhase(player); // 濒死、转职
yield return RunDiscardPhase(player); // 超限弃牌
RunTurnEndPhase(player); // 清理、TurnEnd 被动
}
流程图:单回合五阶段
flowchart TD
A[回合开始 TurnStart] --> B[发回合道具 + 装弹]
B --> C[主行动 MainAction]
C --> D{玩家操作}
D -->|开枪| E[压栈 ShootResponse]
E --> F[响应轮询 + 结算]
F --> C
D -->|用道具| G[即时结算 + 表现]
G --> C
D -->|结束行动| H[裁决 Judgment]
H --> I{濒死/转职?}
I -->|是| J[压栈 Dying/Traitor]
J --> H
I -->|否| K[弃牌 Discard]
K --> L[回合结束 TurnEnd]
L --> M[AdvanceTurn 下家]
3.4 主行动:决策循环
主行动阶段是典型的 「等决策 → 执行 → 等表现 → 再决策」 循环:
// Assets/Scripts/Flow/FlowController.cs(节选)
private IEnumerator RunMainActionPhase(PlayerEntity player)
{
_session.Debug.NotifyPhase(_session, TurnPhase.MainAction, player);
var ended = false;
while (!ended && player.IsAlive)
{
PlayerDecision decision = null;
// 关键:协程挂起,等 IDebugHost 回调
yield return _session.Debug.RequestMainActionDecision(player, _session, d => decision = d);
if (decision?.MainAction == null) continue;
switch (decision.MainAction)
{
case MainActionType.Shoot:
yield return ExecuteShoot(player, decision.TargetPlayerId, ...);
yield return WaitActionPresentation();
break;
case MainActionType.UseItem:
yield return ExecuteUseItem(player, decision, ...);
yield return WaitActionPresentation();
break;
case MainActionType.EndAction:
ended = true;
break;
}
}
}
客户端 DebugService.RequestDecision 分支:
- AI / 托管 → 立即调用
AIDecisionMaker 返回
- 人类 → 打开 IMGUI/NormalHud 决策 UI,
SubmitHumanDecision 写入后协程继续
3.5 开枪:状态栈 + 响应链
开枪是流程控制最复杂的场景之一,展示了压栈、轮询、结算、嵌套濒死的完整模式:
// Assets/Scripts/Flow/FlowController.cs(节选)
private IEnumerator ExecuteShoot(PlayerEntity shooter, string targetId, ...)
{
// ... 校验、Peek 初始子弹 ...
_session.StateStack.Push(new FlowStackEntry
{
StateType = FlowStateType.ShootResponse,
Description = "开枪响应",
Payload = ctx
});
yield return RunShootResponseWindow(shooter, ctx); // 顺时针轮询魔术师等
_session.StateStack.Pop();
yield return ResolveShoot(shooter, target, ctx); // 出弹、伤害、濒死
}
流程图:开枪完整流程
sequenceDiagram
participant FC as FlowController
participant Stack as GameStateStack
participant Host as DebugService
participant Skill as SkillExecutor
FC->>Stack: Push(ShootResponse)
FC->>FC: Publish(ShootDeclared)
loop 每个可响应玩家
FC->>Host: RequestShootResponseDecision
Host-->>FC: PlayerDecision
opt 魔术师发动弹道
FC->>Skill: ExecuteActive
FC->>Host: QueuePresentationCue + Wait
end
end
FC->>Stack: Pop()
FC->>FC: PopNext 子弹 + 伤害结算
FC->>Host: EmitShootResolve (PresentationCueRelay)
opt 目标濒死
FC->>FC: HandlePlayerDying (再压栈)
end
3.6 表现层:PresentationCueRelay
流程结算点不直接操作 UI,而是发出结构化的 PresentationCueSpec:
// Assets/Scripts/Flow/PresentationCueRelay.cs(节选)
public static void EmitShootResolve(
GameSession session, string shooterId, string targetId,
BulletType bullet, bool rageApplied, bool amuletBlocked,
bool applyDamage, int damage)
{
session.Debug.QueuePresentationCue(new PresentationCueSpec
{
Kind = PresentationCueKind.Shoot,
ActorPlayerId = shooterId,
TargetPlayerId = targetId,
BulletType = bullet,
// ...
});
// 实弹伤害时再 EmitHealthChange ...
}
DebugService.QueuePresentationCue 把 cue 转成 Unity 协程,在 WaitActionPresentation 里顺序播放——流程层因此可以 yield 等待动画结束,再打开下一个决策窗。
流程图:客户端单机数据流
flowchart LR
subgraph GameCore["GameCore(共享逻辑)"]
FC[FlowController]
GS[GameSession]
PCR[PresentationCueRelay]
end
subgraph UnityClient["Unity 客户端"]
DS[DebugService]
HUD[NormalHud / IMGUI]
FX[PresentationCuePlayback]
end
FC --> GS
FC -->|Request*Decision| DS
FC --> PCR
PCR -->|QueuePresentationCue| DS
DS --> HUD
DS --> FX
DS -->|SubmitHumanDecision| FC
4. 服务器权威流程控制
4.1 设计原则:逻辑共享,宿主替换
服务端不重写 FlowController。启动对局时:
- 创建
ServerDebugHost 代替 DebugService
- 创建同一个
GameSession(debugHost)
- 用
CoroutineDriver.RunAsync 跑 RunGameCoroutine()
- 用
FlowPublisher 把 cue / 战报 / 快照合并为 FlowStep 广播
// server/src/NewRingGame.Server/Services/Game/GameLoopService.cs(节选)
var debugHost = new ServerDebugHost();
var session = new GameSession(debugHost)
{
IsNormalPlayMode = true,
HideOpponentHandItems = room.PlayMode == PlayMode.Master
};
session.SetupGame(seatConfigs, started.Seed);
var publisher = new FlowPublisher(match, buildSnapshot, BroadcastFlowStepAsync);
debugHost.FlowPublisher = publisher;
await CoroutineDriver.RunAsync(
match.Session.Flow.RunGameCoroutine(),
onIdle: null,
match.Cancellation.Token);
客户端联机时不再跑本地 FlowController(见 GameEntry.BeginOnlineMatch:IsOnlineMatch = true,只收 FlowStep)。
4.2 ServerDebugHost:决策从哪来?
服务端 RequestDecision 与客户端结构相同,但人类分支等 WebSocket:
// server/src/NewRingGame.Server/Services/Game/ServerDebugHost.cs(节选)
private IEnumerator RequestDecision(PlayerEntity player, GameSession session,
DecisionKind kind, Action<PlayerDecision> onComplete, ShootContext? shoot = null)
{
if (player.Control == ControlType.AI || AiDelegation.ShouldAutoDecide(player))
{
var ai = GameDebugAiResolver.Resolve(session, player, kind, shoot);
onComplete(ai);
yield break;
}
BeginWait(player, session, kind, shoot);
_ = FlowPublisher?.PublishDecisionOpenAsync(); // 通知客户端开决策 UI
while (_waiting)
{
if (超时) { /* AI 代打 */ break; }
yield return null; // CoroutineDriver 每 100ms 让出时间片
}
onComplete(_resolvedDecision);
}
玩家通过 Hub 提交决策 → GameLoopService.TrySubmitDecision → ServerDebugHost.TrySubmitDecision 写入 _resolvedDecision 并 PublishDecisionCloseAsync。
| 场景 | 决策来源 |
| 原生 AI 座位 | 立即 GameDebugAiResolver |
| 人类玩家 | 等 WS,30s 超时 AI 代打 |
| 断线人类 | ControlType 切 AI,整局托管(见 HandleDisconnect) |
| 重连 | ResyncPlayerAsync 恢复人类控制 + 单播 MatchResynced |
4.3 FlowPublisher:一步一 revision
服务端不会单独推 snapshot / cue / event 三条通道(旧版兼容保留),而是合并为一个 FlowStep:
// server/src/NewRingGame.Server/Services/Game/FlowPublisher.cs(核心逻辑节选)
private Task PublishAsync(ProtoFlowBoundary boundary, bool includeHold)
{
_revision++;
var holdMs = includeHold ? PresentationHoldCalculator.ComputeMs(_bufferedCueSpecs) : 0;
LastPresentationHoldMs = holdMs;
var step = new FlowStep { Revision = _revision, Boundary = boundary, PresentationHoldMs = holdMs };
// 合并 buffered cues、events、snapshot ...
_bufferedCueSpecs.Clear();
_bufferedEvents.Clear();
return _broadcast(_match, step);
}
FlowController 在 #if HEADLESS 下调用 PushAuthoritativeHudSnapshot,最终走到 ServerDebugHost.PushAuthoritativeSnapshot → FlowPublisher.Publish*Async。
关键约束:必须先 QueuePresentationCue / LogEvent(缓冲),再 PushAuthoritativeSnapshot(提交),保证 cue 与 snapshot 同一 revision。
流程图:服务端一步广播
flowchart TD
A[FlowController 结算点] --> B[PresentationCueRelay.Emit*]
B --> C[ServerDebugHost.QueuePresentationCue]
A --> D[LogEvent]
D --> E[FlowPublisher.BufferEvent]
A --> F[PushAuthoritativeHudSnapshot]
F --> G{boundary 类型}
G -->|ActionSettle| H[PublishActionSettleAsync]
G -->|DecisionOpen| I[PublishDecisionOpenAsync]
G -->|TurnChange| J[PublishTurnChangeAsync]
H --> K[合并 cue + event + snapshot]
K --> L[WebSocket FlowStep]
L --> M[所有客户端 OnlineFlowOrchestrator]
4.4 CoroutineDriver:无 Unity 的协程运行时
服务端没有 MonoBehaviour.StartCoroutine,用栈模拟协程调度:
// server/src/NewRingGame.Server/Services/Game/CoroutineDriver.cs(节选)
while (stack.Count > 0)
{
if (current.Current is IEnumerator nested) { stack.Push(nested); continue; }
if (current.Current is CoroutineDelay delay)
{
await Task.Delay(delay.Milliseconds, cancellationToken);
continue;
}
// 普通 yield return null → 等 100ms,给 WS 决策留时间片
await Task.Delay(100, cancellationToken);
}
ServerDebugHost.WaitActionPresentation 读取 FlowPublisher.LastPresentationHoldMs,在下一决策前 yield 等待表现时长——与客户端「播完动画再继续」语义对齐。
4.5 客户端联机:只消费 FlowStep
联机客户端是纯消费者:不跑 FlowController,按 revision 顺序应用权威态并播放 cue。
flowchart TD
subgraph Server["ASP.NET 服务端"]
GL[GameLoopService]
FC2[FlowController 同一份代码]
SDH[ServerDebugHost]
FP[FlowPublisher]
GL --> FC2
FC2 --> SDH
SDH --> FP
FP -->|FlowStep| WS[SignalR Hub]
end
subgraph Client["Unity 联机客户端"]
OS[OnlineSession]
OFO[OnlineFlowOrchestrator]
OHC[OnlineHudCoordinator]
OMP[OnlineMatchPresentationPlayer]
WS --> OS
OS --> OFO
OFO --> OHC
OHC --> OMP
OHC -->|SubmitDecision| WS
end
OnlineFlowOrchestrator 职责:
- 按
revision 排序,乱序缓冲
- 表现播放中门禁,防止多 Step 同帧抢跑 HUD
- 硬边界(
TurnChange / MatchResync 等)Abort 积压特效
更细的 FlowStep 协议与客户端状态机见 Assets/Scripts/联机流程同步.md。
5. 双端对比与适用架构
5.1 对照表
| 维度 | 客户端(单机) | 服务器(联机权威) |
| 流程引擎 | FlowController(GameCore) | 同一份 FlowController |
| 会话 | GameSession(DebugService) | GameSession(ServerDebugHost) |
| 决策输入 | IMGUI / NormalHud 本地 UI | WebSocket SubmitDecision |
| AI | AIDecisionMaker + 可选托管 | GameDebugAiResolver + 超时/断线代打 |
| 表现 | DebugService 本地播 cue 协程 | cue 进 FlowStep,客户端播放 |
| 权威快照 | 仅本地 HUD 刷新 | FlowPublisher → GameStateSnapshot |
| 随机数 | 本地 Random | 开局 seed 注入,可复现 |
| 协程运行时 | Unity StartCoroutine | CoroutineDriver.RunAsync |
5.2 架构模式名称
本项目的流程控制组合了三种常见模式:
分层状态机 + 状态栈(Hierarchical State Machine)
适合有「插入式响应窗口」的回合制游戏。
协程/Continuation 流程脚本(Scripted Coroutine Workflow)
用 yield 表达「等待玩家」,比回调地狱更易读;服务端用 Driver 移植。
权威服务器 + Command/Event 同步(Server-Authoritative)
逻辑只在服务端跑;客户端收 FlowStep(snapshot + cue + events 捆绑)。
5.3 适合哪些项目架构
| 你的项目特征 | 是否适合借鉴 |
| 回合制 / 阶段制,有响应链 | ✅ 非常适合 |
| 需要联机且防作弊 | ✅ 服务端跑同一 FlowController |
| 纯即时动作(FPS/MOBA) | ❌ 不适用;应用帧同步或 ECS 系统 |
| 纯单机、无复杂插入流程 | ⚠️ 可简化:仅五阶段状态机,不必上栈 |
| 多端(Unity + Web + 服务端) | ✅ IDebugHost 插槽 + 共享 GameCore 是良好范例 |
5.4 为什么联机客户端不跑 FlowController?
- 防作弊:随机数、伤害、道具数量必须以服务端为准。
- 单点真相:避免「客户端算完再上报」的同步冲突。
- 表现可慢不可错:客户端可以等动画;逻辑步进由
revision 严格排序。
单机模式则相反:零网络延迟,本地跑全流程最简单。
6. 扩展新功能时的接入清单
添加新动作(例如新道具、新响应技能)时,按层接入:
6.1 流程层(GameCore,双端自动生效)
- 在
FlowController 合适结算点调用 PresentationCueRelay.Emit*(或新增 Emit 方法)
- 若涉及玩家抉择,增加/复用
IDebugHost.Request*Decision 路径
- 若涉及插入式流程,考虑
StateStack.Push/Pop 模式
6.2 表现时长(双端共用)
在 PresentationHoldCalculator 为新 cue 类型估算毫秒数。
6.3 协议(仅联机)
PresentationCueKind → proto → PresentationCueMapper.ToProto
6.4 客户端表现(Unity)
PresentationCuePlayback.TryBuildFromSpec 增加分支;联机路径无需改 Orchestrator(自动跟 FlowStep)。
6.5 不需要做的事
- ❌ 不要在客户端联机模式里 duplicate 一份结算逻辑
- ❌ 不要单独广播 snapshot 而不带 cue(会破坏 revision 对齐)
- ❌ 不要在
FlowController 里写 UnityEngine 或 SignalR 依赖(保持 GameCore 纯净)
7. 相关文件索引
GameCore(客户端 Assets,服务端链接同目录)
Assets/Scripts/Flow/FlowController.cs # 流程主控
Assets/Scripts/Flow/GameSession.cs # 会话聚合
Assets/Scripts/Flow/PresentationCueRelay.cs
Assets/Scripts/Flow/PresentationHoldCalculator.cs
Assets/Scripts/Core/GameStateStack.cs
Assets/Scripts/Core/GameEnums.cs # TurnPhase, FlowStateType
Assets/Scripts/Debug/IDebugHost.cs
Assets/Scripts/Debug/DebugService.cs # Unity 宿主
Assets/Scripts/Game/GameEntry.cs # 单机启动
服务端
server/src/NewRingGame.Server/Services/Game/GameLoopService.cs
server/src/NewRingGame.Server/Services/Game/ServerDebugHost.cs
server/src/NewRingGame.Server/Services/Game/FlowPublisher.cs
server/src/NewRingGame.Server/Services/Game/CoroutineDriver.cs
server/proto/v1/game.proto # FlowStep 定义
联机客户端消费层
Assets/Scripts/Network/OnlineFlowOrchestrator.cs
Assets/Scripts/Network/OnlineHudCoordinator.cs
Assets/Scripts/UI/OnlineMatchPresentationPlayer.cs
Assets/Scripts/UI/NormalGameUIInputView.Online.cs
Assets/Scripts/联机流程同步.md # FlowStep 协议细节
调试与测试
# 客户端 FlowSync 单元测试(无需启动服务端)
cd server/tools/ProtoSmokeTest
dotnet run -- --flow-sync-unit
# 联机对局时序冒烟(需 API 运行)
dotnet run -- --match-flow --minutes 8 http://127.0.0.1:8080 用户名 密码
附录:从 0 理解一次「人类开枪」的跨端时序
sequenceDiagram
participant FC as FlowController
participant SDH as ServerDebugHost
participant FP as FlowPublisher
participant C as Unity Client
participant P as 玩家
FC->>SDH: RequestMainActionDecision
SDH->>FP: PublishDecisionOpen (FlowStep)
FP->>C: boundary=DECISION_OPEN
C->>P: 显示开枪/道具/结束 UI
P->>C: 点击开枪
C->>SDH: SubmitDecision (WS)
SDH->>FP: PublishDecisionClose
FC->>FC: ExecuteShoot + 响应链
FC->>SDH: QueuePresentationCue (Shoot)
FC->>SDH: PushAuthoritativeSnapshot(ActionSettle)
SDH->>FP: PublishActionSettle
FP->>C: FlowStep(cues+snapshot, hold=2800ms)
C->>C: 播放开枪动画 + 应用血量
FC->>SDH: WaitActionPresentation (2.8s)
FC->>SDH: RequestMainActionDecision (下一轮)
文档版本:与 GameCore 共用 FlowController + FlowStep 联机方案同步。如有协议变更,请同时更新 联机流程同步.md。