跳转到内容

第 25 章 · 回合制范式

约 11 分钟 难度:3 动手章

这一章解决:在闭环 3 的 PlayerObject + GameState 骨架上,怎么把游戏循环改造成「按回合推进」的形态。回合制和轻实时不是不同的技术栈,是同一套架构在不同 tick 节奏下的两种参数化。

先看一下

闭环 3 的 phase 在 INGAME 期间是平的:进了比赛就一直在打,按倒计时或目标分结束。卡牌世界的玩家不是一直在动,他在等,轮到他时点一张牌、按 EndTurn、再等。这一段「等」对 GameState 来说不是空白,是在维护当前轮到谁、剩多少思考时间、上一回合做了什么。

把这些挂到 GameState 上:currentTurnPlayerId 谁在动、turnNumber 第几个回合、turnStartServerTime 这一回合从哪一秒开始、turnDeadlineSeconds 这一回合最长多少秒。读这四个字段的迟入玩家立刻知道现在该看谁。

这一章会拿到什么

  • 回合制的四个核心同步字段,以及它们在 GameState 上的写权归属
  • 三种推进时机:玩家主动提交、行动超时、特殊事件(投降 / 退出回合制)
  • 行动窗口的实现:Update 检查 deadline 而不是 WaitForSeconds
  • 命令日志在回合制里为什么是天然合适的(走子记录就是命令)
  • 一份回合制改造对照表:闭环 3 的哪几行要改

依赖前面

  • 闭环 3 完整 GameState 骨架(stateVersion / OnPreSerialization / OnDeserialization gating)
  • 第 19 章五维度判断(回合制对每个维度落在哪里)
  • 第 22 章命令日志(环形数组 + 五个并行字段)
  • 第 16 章 BeginMatch / EndMatch 三入口

回合制要解决的不是「怎么轮流」,是「等的时候同步什么」

Section titled “回合制要解决的不是「怎么轮流」,是「等的时候同步什么」”

「轮流」本身在单机里两行代码就写完:维护一个 currentPlayerIndex,每次行动后 +=1。挪到多人 VRChat 里复杂的不是这一行,是「玩家 A 在等的时候,房间里所有人看到什么」。

具体几个问题:

  • B、C、D 几位旁观者怎么知道现在轮到 A、思考时间还剩几秒?
  • A 思考超时了怎么办?有没有人替他强制推进?
  • A 在思考途中离开实例了怎么办?这一回合算谁的?
  • 一位迟入玩家进来,怎么立刻看到「现在第 3 回合,A 在动,剩 12 秒」?
  • 上一回合 B 出了一张「闪电」,A 在做反应判定,这条信息怎么留到 A 看到?

每个问题都对应一段同步状态。回合制的核心同步字段就是为这些问题准备的。


挂在 GameState 上,由 GameState Owner 写、所有客户端读。

字段类型含义写权
currentTurnPlayerIdint当前轮到的玩家 playerId,-1 表示无人在动(开局 / 结算)GameState Owner
turnNumberint第几个回合,从 1 起。BeginMatch 时设 1,每次推进 +1GameState Owner
turnStartServerTimedouble这一回合从哪一秒开始,单位是 Networking.GetServerTimeInSeconds()GameState Owner
turnDeadlineSecondsfloat这一回合的行动窗口长度,例如 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 倒计时是一个模子,区别只在引用的字段名。


「下一回合什么时候开始」有三种触发路径。三种都汇聚到同一个 AdvanceTurn 函数,不在多处写推进逻辑。

最常见。A 出完牌点 EndTurn 按钮,请求被 PlayerObject 写进 requestType = REQ_END_TURNGameState OwnerOnPlayerRequestUpdated 里识别后调 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,但闸门拦住了。

A 思考超过 turnDeadlineSeconds 没有提交,由 GameState OwnerUpdate 里检测:

// 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。这条规则避免了「四位客户端都看到时间到了,每位都调一次推进」的竞态。

少数玩法允许「跳过当前玩家」「投降」「场外仲裁」。这些走单独的请求字段(REQ_SKIP_TURN / REQ_SURRENDER),GameState Owner 裁决后调 AdvanceTurn

写成同款是因为推进逻辑统一:不管来源是什么,回合切换时要做的事(重置 deadline、写新 currentTurnPlayerIdturnNumber++、记命令)都一致。


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。这样回合数和命令日志都不会因为离开玩家而错位。


第 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 = INGAME
  • currentTurnPlayerId = A.playerId
  • turnNumber = 5
  • turnStartServerTime = T0
  • turnDeadlineSeconds = 30
  • playerOrder[](如果项目同步它)
  • cmdXxx[] 命令日志的同步字段(如果项目接了)

D 的客户端 Updateelapsed = now - T0,渲染出「轮到 A,剩 17 秒」。同时遍历命令日志拿前 4 回合的关键动作,渲染战报区。

这一过程不需要写专门的「恢复函数」。所有同步字段在 D 进入实例时通过 SDK 反序列化机制送达,OnDeserialization 触发一次 ApplyState,UI 立即正确。

D 自己的角色处理依赖项目设计:

  • 如果项目允许 INGAME 中加入新玩家成为旁观(第 18 章默认策略),D 进旁观席,UI 灰按钮。
  • 如果项目要求「INGAME 中的迟入玩家等下一局」,D 看着但不进入 playerOrder[],下次 BeginMatch 才被纳入。

回合制玩法常用第二种:进行中的牌局直接加入会破坏对局公平。


把闭环 3 的 GameState 改造成回合制(卡牌世界),需要改的地方集中在三处。

项目闭环 3回合制改造
GameState 字段phase / totalScore / phaseStartServerTime / stateVersion / lastProcessedRequestId[] / lastHandleScoreTime[] / 候选队列currentTurnPlayerId / turnNumber / turnStartServerTime / turnDeadlineSeconds / playerOrder[] / turnOrderIndex / 命令日志
BeginMatchphase = INGAME / 重置 totalScore / 写 phaseStartServerTime加:填充 playerOrder[] / 设 turnNumber = 1 / 设 currentTurnPlayerId = playerOrder[0] / 重置命令日志
Update倒计时检测「时间到 → EndMatch」改成「行动超时 → AdvanceTurn」,结束条件由玩法决定(牌堆空 / 一方 HP 归零)
HandleScore加分 + 冷却 + 阶段闸门改名为 HandleAction,参数 (commandType, payload);闸门加 who.playerId != currentTurnPlayerId
OnPlayerLeft候选队列移除 + 槽清零 + OnPlayerLeftCleanup加:如果离开者是 currentTurnPlayerId,立即 AdvanceTurn

代码量不大。骨架不变,加的是「当前回合视图」一组字段和「推进函数」一个入口。



挑一条试。每条不要直接套上面的代码,先想清楚字段怎么改。

  • 把回合时间从固定 30 秒改成「按当前玩家手牌数量动态计算」(手牌多给更多时间)。turnDeadlineSeconds 还是同步字段吗?什么时候写?写错时会发生什么?
  • 加一类「响应窗口」:A 出了一张攻击牌后,B/C/D 都有 5 秒时间提交防御。currentTurnPlayerId 在响应窗口期间是 -1 还是 A?响应到达顺序由谁裁决?
  • AdvanceTurn 改成「玩家自己选下一位」(卡牌的「点名」机制)。REQ_END_TURNpayload 改成 int targetPlayerIdHandleEndTurn 验证什么?「点名」改变 playerOrder[] 还是只改 turnOrderIndex

场景预期实际
1. A、B 两人开局playerOrder = [A, B]currentTurnPlayerId = AturnNumber = 1,B 端 UI 显示「等待 A 行动」
2. A 按 EndTurncurrentTurnPlayerId 切到 B,turnNumber = 2turnStartServerTime 重置
3. A 在思考时 B 按 EndTurnHandleEndTurn 拒绝(闸门),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. 一回合内多次 RequestSerializationstateVersion 单调递增,远端 OnDeserialization gating 不应用中间态

第 4、5 行是回合制的核心健壮性测试:玩家不主动 EndTurn 时系统能不能自己推进。第 7 行验证 Owner 转移时回合制状态不丢失。


  • 能默写回合制四个核心同步字段(currentTurnPlayerId / turnNumber / turnStartServerTime / turnDeadlineSeconds
  • 能解释 turnNumber 为什么是单调递增的,不是 currentTurnPlayerId 的派生量
  • 能列出三种推进时机,并说出每种走哪条代码路径
  • 能识别命令日志在回合制里几乎是必接的,并说出原因
  • 能默写「闸门:who.playerId != currentTurnPlayerId」的位置和功能
  • 能解释超时检测为什么只在 Owner 上跑