跳转到内容

对照小练 C · 同一玩法两种通道

约 6 分钟 难度:3

这一章解决:第 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 网格按钮、当前轮到谁的提示、胜负显示。


GameState 上一个 [UdonSynced] string boardJson 字段,承载完整棋盘。每次落子由 GameState Owner 写更新这一字段。

[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,单字段就够了。

适合:

  • 棋盘状态变化少(每两秒一手),重发整张棋盘字节代价不高。
  • 迟入恢复天然支持,不需要额外代码。
  • 调试时打开 Debug View 看到的 JSON 字符串可读,能直接判断状态对不对。

不适合(在九子棋以外的场景):

  • 棋盘扩到 19×19(围棋),JSON 涨到 1.4 KB,每手重发占带宽预算 6%。
  • 需要回看「谁下的哪一步」(战报、悔棋功能),JSON 字段没有历史。
  • 棋盘上的格子有多余属性(每格的最后落子时间、每格的攻击次数),JSON 字段会膨胀。

GameState 上同步「最近一步走子」字段。客户端本地维护 9 格棋盘。

// 棋盘状态在客户端本地,不同步整张棋盘
[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 直接用补救一。

适合:

  • 字节代价比骨架 A 小(每步 4 byte vs 60 byte)。在棋盘扩大时优势更显著。
  • 命令本身就是「谁下的哪一步」,做战报、做悔棋、做回放容易。
  • 调试时能直接看「上一步谁下的、下在哪」。

不适合:

  • 迟入恢复要额外同步快照,复杂度上升。
  • 多步合并的场景(连续落两子的「双子棋」变种)要单独处理,要么改命令字段允许多步 payload,要么走 SendCustomNetworkEvent 触发批量。
  • 客户端本地状态要管理,错了一次后续都错(虽然每次 OnDeserialization 重新应用快照能拉回来)。

跑完两版后填这张表。每条选 A / B / 平。

维度骨架 A 状态同步骨架 B 命令同步谁更适合九子棋
单次同步字节60–80 byte4–13 byte
迟入恢复自然需要快照辅助
战报 / 悔棋扩展难度高(要外加日志)低(命令本身就是日志)
Bug 调试JSON 字符串直接读字段分散,但单次变化清晰
5×5 棋盘扩展字节翻 3 倍字节几乎不变
19×19 围棋扩展1.4 KB / 步4 byte / 步 + 棋盘快照 350 byte

填完后回到第 21 章决策表对照一下。九子棋这个特定场景下两条通道各自的适合性。


完成两份骨架后给两个判断。

判断 1:九子棋这一具体玩法应该选哪条?

写下两到三句理由。注意「字节小」不一定是决定性的,「调试简单」「扩展容易」也是判断维度。

判断 2:如果九子棋升级成 3×3 立体(27 格、要立体连线判定),结论变不变?

棋盘大小 3 倍、连线规则复杂化,这两个变化分别让两条通道的优劣怎么平移?



  • 能跑通两份骨架,各自支持完整的一局九子棋
  • 能填出对比表的六行
  • 能给出「九子棋选哪条」的三句理由
  • 能识别命令同步在「迟入恢复」上的天然限制和两种补救路径
  • 能解释为什么字节最小不等于代码最简单