对照小练 C · 同一玩法两种通道
这一章解决:第 19、21 章给了状态 vs 命令的判断维度,本练习把同一玩法用两种通道分别实现。完成后能对一段同步选哪条通道有具体的体感。
先看一下
九子棋(井字棋,Tic-tac-toe)是一个标准的两人回合制游戏:3×3 棋盘,X 和 O 轮流落子,三个连成一线获胜。状态规模小,回合频率低,没有迟入恢复以外的复杂需求。
把这一份玩法用两条通道分别实现:
- 骨架 A · 状态同步:用
VRCJson把整张棋盘当作一个字符串字段同步。每次落子重发整张棋盘。 - 骨架 B · 命令同步:用闭环 3 风格的请求式架构同步每一步「下了哪格」,客户端本地维护棋盘状态。
两版功能等价。差别在于通道选择带来的字节代价、迟入恢复路径、调试复杂度。完成本练习后再回到第 21 章决策表,对九子棋这个具体场景做一次自己的判断。
这一章会拿到什么
- 两份能跑通的最小骨架
- 一张「字节 / 迟入 / 调试 / 扩展」对比表(自己填)
- 「九子棋应该选哪条通道」的判断
依赖前面
- 闭环 3 完整骨架
- 第 21 章七通道决策表
- 第 24 章
VRCJson+DataDictionary例子
简化版规则:
- 房主是 X,第二位玩家是 O。多于两人时其余进观战。
- 双方轮流落子。当前轮到谁由 GameState 决定。
- 三连一线判胜。胜负判定后游戏结束,等待 Restart。
- 迟入玩家加入后立刻看到当前棋盘和当前轮到谁。
UI:3×3 网格按钮、当前轮到谁的提示、胜负显示。
骨架 A · 状态同步(VRCJson)
Section titled “骨架 A · 状态同步(VRCJson)”GameState 上一个 [UdonSynced] string boardJson 字段,承载完整棋盘。每次落子由 GameState Owner 写更新这一字段。
GameState 增量
Section titled “GameState 增量”[UdonSynced] private string boardJson; // {"board":[[0,1,0],...],"currentPlayer":1,"winner":-1}
private void HandleSetCell(VRCPlayerApi who, int row, int col){ if (phase != PHASE_INGAME) return; var dict = ParseBoardOrEmpty(); int currentPlayer = ReadInt(dict["currentPlayer"]); // ReadInt 沿用第 24 章 if (PlayerSlot(who) != currentPlayer) return; // 不是这位玩家的回合
var board = dict["board"].DataList; var rowList = board[row].DataList; if (ReadInt(rowList[col]) != 0) return; // 格子已占
rowList.SetValue(col, new DataToken(currentPlayer)); int winner = CheckWinner(board); dict.SetValue("winner", new DataToken(winner)); if (winner == -1) dict.SetValue("currentPlayer", new DataToken(currentPlayer == 1 ? 2 : 1));
if (VRCJson.TrySerializeToJson(dict, JsonExportType.Minify, out var s)) { boardJson = s.String; if (winner != -1) phase = PHASE_RESULT; ApplyState(); }}
public override void OnDeserialization(){ if (stateVersion == lastAppliedStateVersion) return; lastAppliedStateVersion = stateVersion; ApplyState(); // 内部解析 boardJson 并更新 9 个格子 UI}迟入玩家进来读 boardJson 立刻看到当前棋盘。胜负判定也在字段里,远端客户端读 winner 字段直接显示结果。
字节估算:3×3 棋盘最小化 JSON 约 60–80 字符 = 60–80 bytes 单字段。每次落子重发,按一局 9 步算总流量约 720 bytes,单字段就够了。
适合 / 不适合
Section titled “适合 / 不适合”适合:
- 棋盘状态变化少(每两秒一手),重发整张棋盘字节代价不高。
- 迟入恢复天然支持,不需要额外代码。
- 调试时打开 Debug View 看到的 JSON 字符串可读,能直接判断状态对不对。
不适合(在九子棋以外的场景):
- 棋盘扩到 19×19(围棋),JSON 涨到 1.4 KB,每手重发占带宽预算 6%。
- 需要回看「谁下的哪一步」(战报、悔棋功能),JSON 字段没有历史。
- 棋盘上的格子有多余属性(每格的最后落子时间、每格的攻击次数),JSON 字段会膨胀。
骨架 B · 命令同步
Section titled “骨架 B · 命令同步”GameState 上同步「最近一步走子」字段。客户端本地维护 9 格棋盘。
GameState 增量
Section titled “GameState 增量”// 棋盘状态在客户端本地,不同步整张棋盘[UdonSynced] private int lastMoveRequestId; // 单调递增(来自 PlayerLobbyState 的 requestId)[UdonSynced] private byte lastMoveRow;[UdonSynced] private byte lastMoveCol;[UdonSynced] private byte lastMovePlayer; // 1 or 2[UdonSynced] private byte currentPlayer; // 当前轮到谁[UdonSynced] private byte winner; // 0=未定 1=X赢 2=O赢 3=平局
private byte[,] localBoard = new byte[3, 3]; // 客户端本地棋盘private int lastSeenMoveId = 0;
private void HandleSetCell(VRCPlayerApi who, int row, int col){ if (phase != PHASE_INGAME) return; if (PlayerSlot(who) != currentPlayer) return; if (localBoard[row, col] != 0) return;
lastMoveRequestId += 1; lastMoveRow = (byte)row; lastMoveCol = (byte)col; lastMovePlayer = currentPlayer; localBoard[row, col] = currentPlayer; // Owner 端立刻更新 int w = CheckWinner(); winner = (byte)w; if (w == 0) currentPlayer = (byte)(currentPlayer == 1 ? 2 : 1); if (w != 0) phase = PHASE_RESULT; ApplyState();}
public override void OnDeserialization(){ if (stateVersion == lastAppliedStateVersion) return; lastAppliedStateVersion = stateVersion;
// 应用最近一步 if (lastMoveRequestId > lastSeenMoveId) { lastSeenMoveId = lastMoveRequestId; localBoard[lastMoveRow, lastMoveCol] = lastMovePlayer; } ApplyState();}迟入恢复要补一个钩子:迟入玩家本地 lastSeenMoveId = 0,但 lastMoveRequestId 可能已经 5。如果直接按上面逻辑,他会把当前的「最近一步」应用一次,但前面 4 步全部丢失。这是命令同步的天然限制。
补救一:在 GameState 上额外同步一份当前棋盘快照([UdonSynced] byte[] boardSnapshot = new byte[9])。迟入玩家读快照直接初始化 localBoard。这一来命令同步退化成「快照 + 增量」,字节代价从纯命令的 4 byte 涨到 + 9 byte 棋盘 = 13 byte。
补救二:让迟入玩家不参与本局,只观战 RESULT 状态。这一思路适合不想引入快照的世界。
骨架 B 直接用补救一。
适合 / 不适合
Section titled “适合 / 不适合”适合:
- 字节代价比骨架 A 小(每步 4 byte vs 60 byte)。在棋盘扩大时优势更显著。
- 命令本身就是「谁下的哪一步」,做战报、做悔棋、做回放容易。
- 调试时能直接看「上一步谁下的、下在哪」。
不适合:
- 迟入恢复要额外同步快照,复杂度上升。
- 多步合并的场景(连续落两子的「双子棋」变种)要单独处理,要么改命令字段允许多步 payload,要么走
SendCustomNetworkEvent触发批量。 - 客户端本地状态要管理,错了一次后续都错(虽然每次
OnDeserialization重新应用快照能拉回来)。
自己填的对比表
Section titled “自己填的对比表”跑完两版后填这张表。每条选 A / B / 平。
| 维度 | 骨架 A 状态同步 | 骨架 B 命令同步 | 谁更适合九子棋 |
|---|---|---|---|
| 单次同步字节 | 60–80 byte | 4–13 byte | … |
| 迟入恢复 | 自然 | 需要快照辅助 | … |
| 战报 / 悔棋扩展难度 | 高(要外加日志) | 低(命令本身就是日志) | … |
| Bug 调试 | JSON 字符串直接读 | 字段分散,但单次变化清晰 | … |
| 5×5 棋盘扩展 | 字节翻 3 倍 | 字节几乎不变 | … |
| 19×19 围棋扩展 | 1.4 KB / 步 | 4 byte / 步 + 棋盘快照 350 byte | … |
填完后回到第 21 章决策表对照一下。九子棋这个特定场景下两条通道各自的适合性。
完成两份骨架后给两个判断。
判断 1:九子棋这一具体玩法应该选哪条?
写下两到三句理由。注意「字节小」不一定是决定性的,「调试简单」「扩展容易」也是判断维度。
判断 2:如果九子棋升级成 3×3 立体(27 格、要立体连线判定),结论变不变?
棋盘大小 3 倍、连线规则复杂化,这两个变化分别让两条通道的优劣怎么平移?
- 能跑通两份骨架,各自支持完整的一局九子棋
- 能填出对比表的六行
- 能给出「九子棋选哪条」的三句理由
- 能识别命令同步在「迟入恢复」上的天然限制和两种补救路径
- 能解释为什么字节最小不等于代码最简单
- 闭环 3 · 加上命令版本号的工程化一局 — 本练习两版骨架共用的母本。
- 第 19 章 · 状态同步还是命令同步 — 本练习的判断依据。
- 第 21 章 · 参数化 Network Event 与决策表 — 七通道决策表。
- 第 22 章 · 命令模式与事件日志 — 命令同步加日志后的形态。
- 第 24 章 · JSON、DataDictionary 与 DataList — 骨架 A 用的工具。
- 附录 · 术语表 —
State/Command/Snapshot的客观定义。