第 25 章 · 回合制范式
这一章解决:在闭环 3 的 PlayerObject + GameState 骨架上,怎么把游戏循环改造成「按回合推进」的形态。回合制和轻实时不是不同的技术栈,是同一套架构在不同 tick 节奏下的两种参数化。
先看一下
闭环 3 的 phase 在 INGAME 期间是平的:进了比赛就一直在打,按倒计时或目标分结束。卡牌世界的玩家不是一直在动,他在等,轮到他时点一张牌、按 EndTurn、再等。这一段「等」对 GameState 来说不是空白,是在维护当前轮到谁、剩多少思考时间、上一回合做了什么。
把这些挂到 GameState 上:currentTurnPlayerId 谁在动、turnNumber 第几个回合、turnStartServerTime 这一回合从哪一秒开始、turnDeadlineSeconds 这一回合最长多少秒。读这四个字段的迟入玩家立刻知道现在该看谁。
这一章会拿到什么
- 回合制的四个核心同步字段,以及它们在
GameState上的写权归属 - 三种推进时机:玩家主动提交、行动超时、特殊事件(投降 / 退出回合制)
- 行动窗口的实现:
Update检查 deadline 而不是WaitForSeconds - 命令日志在回合制里为什么是天然合适的(走子记录就是命令)
- 一份回合制改造对照表:闭环 3 的哪几行要改
依赖前面
- 闭环 3 完整
GameState骨架(stateVersion/OnPreSerialization/OnDeserializationgating) - 第 19 章五维度判断(回合制对每个维度落在哪里)
- 第 22 章命令日志(环形数组 + 五个并行字段)
- 第 16 章
BeginMatch/EndMatch三入口
回合制要解决的不是「怎么轮流」,是「等的时候同步什么」
Section titled “回合制要解决的不是「怎么轮流」,是「等的时候同步什么」”「轮流」本身在单机里两行代码就写完:维护一个 currentPlayerIndex,每次行动后 +=1。挪到多人 VRChat 里复杂的不是这一行,是「玩家 A 在等的时候,房间里所有人看到什么」。
具体几个问题:
- B、C、D 几位旁观者怎么知道现在轮到 A、思考时间还剩几秒?
- A 思考超时了怎么办?有没有人替他强制推进?
- A 在思考途中离开实例了怎么办?这一回合算谁的?
- 一位迟入玩家进来,怎么立刻看到「现在第 3 回合,A 在动,剩 12 秒」?
- 上一回合 B 出了一张「闪电」,A 在做反应判定,这条信息怎么留到 A 看到?
每个问题都对应一段同步状态。回合制的核心同步字段就是为这些问题准备的。
四个核心同步字段
Section titled “四个核心同步字段”挂在 GameState 上,由 GameState Owner 写、所有客户端读。
| 字段 | 类型 | 含义 | 写权 |
|---|---|---|---|
currentTurnPlayerId | int | 当前轮到的玩家 playerId,-1 表示无人在动(开局 / 结算) | GameState Owner |
turnNumber | int | 第几个回合,从 1 起。BeginMatch 时设 1,每次推进 +1 | GameState Owner |
turnStartServerTime | double | 这一回合从哪一秒开始,单位是 Networking.GetServerTimeInSeconds() | GameState Owner |
turnDeadlineSeconds | float | 这一回合的行动窗口长度,例如 30 秒 | GameState Owner(可设计参数) |
// GameState(回合制扩展)[UdonSynced] private int currentTurnPlayerId = -1;[UdonSynced] private int turnNumber = 0;[UdonSynced] private double turnStartServerTime;[UdonSynced] private float turnDeadlineSeconds = 30f;这四个字段加起来是一份完整的「当前回合视图」。任何客户端读它们就能渲染出「谁在动、第几个回合、剩多久」的 UI。迟入玩家拿到当前值即可立刻参与观察。
UI 的剩余时间靠 Update 本地算:
// 客户端本地(不依赖 stateVersion)double elapsed = Networking.CalculateServerDeltaTime( Networking.GetServerTimeInSeconds(), turnStartServerTime);float remaining = Mathf.Max(0f, turnDeadlineSeconds - (float)elapsed);turnTimerLabel.text = remaining.ToString("F0") + "s";这一段和闭环 3 的 matchDuration 倒计时是一个模子,区别只在引用的字段名。
三种推进时机
Section titled “三种推进时机”「下一回合什么时候开始」有三种触发路径。三种都汇聚到同一个 AdvanceTurn 函数,不在多处写推进逻辑。
时机 1:当前玩家主动提交
Section titled “时机 1:当前玩家主动提交”最常见。A 出完牌点 EndTurn 按钮,请求被 PlayerObject 写进 requestType = REQ_END_TURN,GameState Owner 在 OnPlayerRequestUpdated 里识别后调 AdvanceTurn。
// HandleEndTurn 在 GameState 上private void HandleEndTurn(VRCPlayerApi who){ if (phase != PHASE_INGAME) return; if (who.playerId != currentTurnPlayerId) return; // 只有当前玩家能 EndTurn AdvanceTurn();}闸门 who.playerId != currentTurnPlayerId 防止其他玩家强制推进当前回合。改客户端的玩家也能发 REQ_END_TURN,但闸门拦住了。
时机 2:行动超时
Section titled “时机 2:行动超时”A 思考超过 turnDeadlineSeconds 没有提交,由 GameState Owner 在 Update 里检测:
// GameState.Update(GameState Owner 端)if (!Networking.IsOwner(gameObject)) return;if (phase != PHASE_INGAME) return;if (currentTurnPlayerId < 0) return; // 没有当前玩家就不算超时
double elapsed = Networking.CalculateServerDeltaTime( Networking.GetServerTimeInSeconds(), turnStartServerTime);if (elapsed >= turnDeadlineSeconds){ AdvanceTurn(); // 超时推进}只有 GameState Owner 检测超时。所有客户端本地算 remaining 是为了 UI 显示,但只有 Owner 一份代码会真正调 AdvanceTurn。这条规则避免了「四位客户端都看到时间到了,每位都调一次推进」的竞态。
时机 3:特殊事件
Section titled “时机 3:特殊事件”少数玩法允许「跳过当前玩家」「投降」「场外仲裁」。这些走单独的请求字段(REQ_SKIP_TURN / REQ_SURRENDER),GameState Owner 裁决后调 AdvanceTurn。
写成同款是因为推进逻辑统一:不管来源是什么,回合切换时要做的事(重置 deadline、写新 currentTurnPlayerId、turnNumber++、记命令)都一致。
AdvanceTurn 的最小实现
Section titled “AdvanceTurn 的最小实现”private void AdvanceTurn(){ int nextId = ComputeNextTurnPlayerId(); if (nextId < 0) { EndMatch(); // 没有下一位,进入结算 return; }
turnNumber += 1; currentTurnPlayerId = nextId; turnStartServerTime = Networking.GetServerTimeInSeconds(); // turnDeadlineSeconds 可在这里按规则改
AppendCommand(0, nextId, CMD_TURN_START, turnNumber); // 命令日志(第 22 章) RequestSerialization(); ApplyState();}ComputeNextTurnPlayerId 实现取决于回合顺序规则(顺时针 / 跳过失联玩家 / 玩家自己选下一位)。卡牌世界常用「按 playerOrder[] 数组循环」:
[UdonSynced] private int[] playerOrder = new int[8]; // BeginMatch 时填好[UdonSynced] private int turnOrderIndex = 0;
private int ComputeNextTurnPlayerId(){ int playerCount = ComputePlayerOrderLength(); // 排除已离开的 if (playerCount == 0) return -1; turnOrderIndex = (turnOrderIndex + 1) % playerCount; return playerOrder[turnOrderIndex];}playerOrder[] 在 BeginMatch 时按当前在场玩家填好。玩家中途离开(OnPlayerLeft)时不动数组,只是 ComputeNextTurnPlayerId 跳过失效的 playerId。这样回合数和命令日志都不会因为离开玩家而错位。
命令日志在回合制里的天然性
Section titled “命令日志在回合制里的天然性”第 22 章给的命令日志(cmdRequestId[] / cmdPlayerId[] / cmdType[] / cmdPayload[] / cmdServerLikeTime[])在闭环 3 里是「可选能力」,闭环 3 没接。回合制里它几乎是必接的。
理由是回合制的核心信息是「过去几回合发生了什么」:
- 卡牌世界要显示对手上一回合出了什么牌
- 棋类世界要显示走子记录
- 解谜回合制要能「撤回上一步」(command 日志反向应用)
- 战报和复盘是回合制玩法的常规需求
把命令日志按回合分类,每条命令额外记一个 turnNumber:
// 加到第 22 章命令日志的并行数组[UdonSynced] private int[] cmdTurnNumber;
private void AppendCommand(int reqId, int playerId, byte type, int payload){ // ... 第 22 章已写的字段 ... cmdTurnNumber[cmdHead] = turnNumber; // 新增 cmdHead = (cmdHead + 1) % cmdCapacity; if (cmdCount < cmdCapacity) cmdCount++;}读「第 5 回合发生了什么」就遍历 cmdTurnNumber[i] == 5 的条目。容量够大时整局命令都能装下,回合制玩法的单局总命令数通常在百条级(一局 30 回合 × 每回合 3–5 个动作)。
环形容量按一局命令上限配,比闭环 3 的 32 大不少:64 / 128 / 256 视玩法。
迟入恢复在回合制里的具体应用
Section titled “迟入恢复在回合制里的具体应用”迟入玩家 D 在第 5 回合的某一刻进入实例。GameState 上四个核心字段同步给他:
phase = INGAMEcurrentTurnPlayerId = A.playerIdturnNumber = 5turnStartServerTime = T0turnDeadlineSeconds = 30playerOrder[](如果项目同步它)cmdXxx[]命令日志的同步字段(如果项目接了)
D 的客户端 Update 算 elapsed = now - T0,渲染出「轮到 A,剩 17 秒」。同时遍历命令日志拿前 4 回合的关键动作,渲染战报区。
这一过程不需要写专门的「恢复函数」。所有同步字段在 D 进入实例时通过 SDK 反序列化机制送达,OnDeserialization 触发一次 ApplyState,UI 立即正确。
D 自己的角色处理依赖项目设计:
- 如果项目允许 INGAME 中加入新玩家成为旁观(第 18 章默认策略),D 进旁观席,UI 灰按钮。
- 如果项目要求「INGAME 中的迟入玩家等下一局」,D 看着但不进入
playerOrder[],下次BeginMatch才被纳入。
回合制玩法常用第二种:进行中的牌局直接加入会破坏对局公平。
闭环 3 的回合制改造对照
Section titled “闭环 3 的回合制改造对照”把闭环 3 的 GameState 改造成回合制(卡牌世界),需要改的地方集中在三处。
| 项目 | 闭环 3 | 回合制改造 |
|---|---|---|
GameState 字段 | phase / totalScore / phaseStartServerTime / stateVersion / lastProcessedRequestId[] / lastHandleScoreTime[] / 候选队列 | 加 currentTurnPlayerId / turnNumber / turnStartServerTime / turnDeadlineSeconds / playerOrder[] / turnOrderIndex / 命令日志 |
BeginMatch | 设 phase = INGAME / 重置 totalScore / 写 phaseStartServerTime | 加:填充 playerOrder[] / 设 turnNumber = 1 / 设 currentTurnPlayerId = playerOrder[0] / 重置命令日志 |
Update | 倒计时检测「时间到 → EndMatch」 | 改成「行动超时 → AdvanceTurn」,结束条件由玩法决定(牌堆空 / 一方 HP 归零) |
HandleScore | 加分 + 冷却 + 阶段闸门 | 改名为 HandleAction,参数 (commandType, payload);闸门加 who.playerId != currentTurnPlayerId |
OnPlayerLeft | 候选队列移除 + 槽清零 + OnPlayerLeftCleanup | 加:如果离开者是 currentTurnPlayerId,立即 AdvanceTurn |
代码量不大。骨架不变,加的是「当前回合视图」一组字段和「推进函数」一个入口。
不同世界不同活法
Section titled “不同世界不同活法”挑一条试。每条不要直接套上面的代码,先想清楚字段怎么改。
- 把回合时间从固定 30 秒改成「按当前玩家手牌数量动态计算」(手牌多给更多时间)。
turnDeadlineSeconds还是同步字段吗?什么时候写?写错时会发生什么? - 加一类「响应窗口」:A 出了一张攻击牌后,B/C/D 都有 5 秒时间提交防御。
currentTurnPlayerId在响应窗口期间是 -1 还是 A?响应到达顺序由谁裁决? - 把
AdvanceTurn改成「玩家自己选下一位」(卡牌的「点名」机制)。REQ_END_TURN的payload改成int targetPlayerId,HandleEndTurn验证什么?「点名」改变playerOrder[]还是只改turnOrderIndex?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. A、B 两人开局 | playerOrder = [A, B],currentTurnPlayerId = A,turnNumber = 1,B 端 UI 显示「等待 A 行动」 | … |
| 2. A 按 EndTurn | currentTurnPlayerId 切到 B,turnNumber = 2,turnStartServerTime 重置 | … |
| 3. A 在思考时 B 按 EndTurn | HandleEndTurn 拒绝(闸门),A 的回合不被推进 | … |
| 4. A 思考超过 30 秒 | Owner 端 Update 检测,AdvanceTurn 推进到 B | … |
| 5. A 在思考时离开实例 | OnPlayerLeft 检测当前回合归 A,立即 AdvanceTurn 推进到 B | … |
| 6. 迟入玩家 D 在 turnNumber=5 加入 | D 立刻看到「轮到 A,剩 N 秒」,命令日志读出前 4 回合 | … |
| 7. Owner 转移期间超时 | 旧 Owner 离开,候选队列接管;新 Owner Update 接续超时检测,AdvanceTurn 不会重复触发或漏触发 | … |
8. 一回合内多次 RequestSerialization | stateVersion 单调递增,远端 OnDeserialization gating 不应用中间态 | … |
第 4、5 行是回合制的核心健壮性测试:玩家不主动 EndTurn 时系统能不能自己推进。第 7 行验证 Owner 转移时回合制状态不丢失。
- 能默写回合制四个核心同步字段(
currentTurnPlayerId/turnNumber/turnStartServerTime/turnDeadlineSeconds) - 能解释
turnNumber为什么是单调递增的,不是currentTurnPlayerId的派生量 - 能列出三种推进时机,并说出每种走哪条代码路径
- 能识别命令日志在回合制里几乎是必接的,并说出原因
- 能默写「闸门:
who.playerId != currentTurnPlayerId」的位置和功能 - 能解释超时检测为什么只在 Owner 上跑
- VRChat Creator Docs · Networking and Synchronization —
Networking.GetServerTimeInSeconds与CalculateServerDeltaTime的官方说明。 - 第 16 章 · 开局与重开 —
BeginMatch三入口,回合制改造的母本。 - 第 19 章 · 状态同步还是命令同步 — 当前棋盘 vs 走子记录的两条通道。
- 第 22 章 · 命令模式与事件日志 — 回合制走子记录的实现。
- 闭环 3 · 加上命令版本号的工程化一局 — 回合制改造的起点。
- 创作者视角 5 · 节奏感是设计出来的 — 回合制和轻实时的设计含义差异。
- 附录 · 术语表 —
Turn-based Loop/Action Window/Turn Deadline的客观定义。