第 26 章 · 轻实时范式
这一章解决:在闭环 3 骨架上把游戏循环改造成「按时间推进」的形态。和第 25 章的回合制对照:那一章把 tick 绑定到事件,本章把 tick 绑定到时间。两章的字段几乎不重叠。
先看一下
合作防守世界没有「轮到谁」的概念。一局开始后,敌人按节奏刷新、玩家持续战斗、波次自动推进。表面上看像是「真实时」的网络游戏,但 VRChat 的同步约束(200 byte 连续 / 280 KB 手动 / 11 KB/s 总预算)做不到 30Hz 的状态同步。把它实现成「轻实时」更稳:玩家位置走 VRC Object Sync、敌人计数走状态字段、命中飘字走事件、波次推进每秒一次。
「轻」是字面意思。同步频率低,精确同步范围窄,但玩家对节奏的感受仍然是连续的。这里的关键判断是:哪些状态需要「精确」、哪些只需要「大致正确」。
这一章会拿到什么
- 轻实时的核心字段集(
waveIndex/waveStartServerTime/waveDuration/enemyAliveCount) - 三档 tick 频率(1Hz / 5Hz / 10Hz)的字段差异和带宽差异
- 「事件做反馈、状态做共识」的混用模式
- 对象池接口边界(不展开实现,第 36 章会做)
- 闭环 3 改造成轻实时的字段对照
- 一份「这种状态需要多精确同步」的判断方法
依赖前面
- 闭环 3 完整
GameState骨架 - 第 19 章五维度判断(频率维度的应用)
- 第 21 章参数化网络事件(敌人受击的反馈通道)
- 创作者视角 5 节奏感差异
时间驱动 tick:与回合制的对照
Section titled “时间驱动 tick:与回合制的对照”回合制(第 25 章)的 tick 由事件驱动:玩家点 EndTurn 推进、超时也是事件。轻实时的 tick 由时间驱动:Update 里检测「这一秒到了吗、这一波的 N 秒到了吗」。
两者的代码区别集中在一个地方:
// 回合制(第 25 章)private void Update(){ if (!Networking.IsOwner(gameObject)) return; if (currentTurnPlayerId < 0) return;
double elapsed = Networking.CalculateServerDeltaTime( Networking.GetServerTimeInSeconds(), turnStartServerTime); if (elapsed >= turnDeadlineSeconds) AdvanceTurn();}
// 轻实时(本章)private void Update(){ if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_INGAME) return;
double elapsed = Networking.CalculateServerDeltaTime( Networking.GetServerTimeInSeconds(), waveStartServerTime); if (elapsed >= waveDuration) AdvanceWave();
TickGameState(elapsed); // 每帧或按 tickInterval 推进}回合制只关心「行动窗口到了吗」,轻实时除了「这一波到了吗」还要每帧推进游戏状态(敌人位置、子弹位置、HP 衰减)。这一段「持续推进」是轻实时的特征。
四个核心同步字段
Section titled “四个核心同步字段”挂在 GameState 上,由 GameState Owner 写。
| 字段 | 类型 | 含义 | 写权 |
|---|---|---|---|
waveIndex | int | 第几波,从 1 起。BeginMatch 时设 1,每次 AdvanceWave +1 | GameState Owner |
waveStartServerTime | double | 这一波从哪一秒开始 | GameState Owner |
waveDuration | float | 这一波持续多少秒,例如 60 秒 | GameState Owner(可设计参数) |
enemyAliveCount | int | 当前波次还活着的敌人数 | GameState Owner |
// GameState(轻实时扩展)[UdonSynced] private int waveIndex = 0;[UdonSynced] private double waveStartServerTime;[UdonSynced] private float waveDuration = 60f;[UdonSynced] private int enemyAliveCount;这四个字段决定了房间的「当前节奏视图」。客户端 UI 显示「第 3 波,剩 28 秒,剩 5 个敌人」就够。
enemyAliveCount 是个聚合值。每个具体敌人的位置、HP、是否瞄准玩家都不在 GameState 上:那些走对象池里的单个对象(第 36 章),各对象自己同步。GameState 只持有「整体进度」。
三档 tick 频率
Section titled “三档 tick 频率”「这一秒到了吗」的检测频率决定了轻实时的精度。三档常见配置,对应不同玩法。
1 Hz:派对小游戏 / 合作 PvE
Section titled “1 Hz:派对小游戏 / 合作 PvE”每秒一次推进。Update 里检查 now - lastTickTime >= 1.0,到点了执行一次 tick 逻辑(敌人 AI 推进一格、波次时间扣 1 秒)。
// GameState.Update(1Hz tick)private double lastTickTime;public float tickInterval = 1.0f;
private void Update(){ if (!Networking.IsOwner(gameObject)) return; if (phase != PHASE_INGAME) return;
double now = Networking.GetServerTimeInSeconds(); if (now - lastTickTime >= tickInterval) { lastTickTime = now; TickGameState(); // 一次完整推进 }}优点:状态变更频率低,11 KB/s 总预算压力小。enemyAliveCount 每秒最多变化几次(玩家击杀),GameState 字段同步压力可控。
缺点:玩家感受是「每秒抖一下」。射击命中的飘字必须走事件(瞬时反馈),不能依赖 1 Hz tick。
5 Hz:合作防守 / 推塔
Section titled “5 Hz:合作防守 / 推塔”每 0.2 秒一次推进。tickInterval = 0.2f。这一档是本卷主项目(4 人合作防守)的默认推荐:节奏感连续,但同步成本仍可控。
每个 tick GameState 决定要不要 RequestSerialization:仅在字段真的变化时调(enemyAliveCount 改了 / 进入下一波 / 切阶段)。空 tick 不发包。
优点:玩家感受连贯。波次推进、敌人计数变化都能在合理延迟内被看到。
缺点:实现要小心。空 tick 不发包是必须的,否则每秒 5 个空包就把预算撑爆。
10 Hz:竞技 PvP / 高强度对战
Section titled “10 Hz:竞技 PvP / 高强度对战”每 0.1 秒一次推进。这一档已经接近 VRChat 同步约束的红线。除非玩法非常吃节奏(节奏游戏 / PvP 反应类),否则建议先尝试 5 Hz 看够不够。
字段差异:通常需要把同步状态拆得更细(每个状态字段独立 manual sync 对象),让 OnPostSerialization 的失败不影响其他字段。也可能需要字节打包(第 23 章)压字段大小。
重要前提:10 Hz 是 GameState Owner 端的内部 tick 频率,不是「同步频率」。同步包是按字段变化触发的,不是每 tick 必发。两者要分开理解。
事件做反馈、状态做共识
Section titled “事件做反馈、状态做共识”轻实时玩法的同步混用模式。这一节把第 19 章五维度判断套到具体场景上。
| 内容 | 谁需要看到 | 频率 | 通道 | 字节策略 |
|---|---|---|---|---|
| 「现在第几波、剩多少秒」 | 所有玩家(含迟入) | 每波切换一次 | GameState 同步字段 | 状态同步 |
| 「敌人剩几个」 | 所有玩家(含迟入) | 每秒几次 | GameState 同步字段 | 状态同步 |
| 「玩家 A 击杀了某敌人」 | 所有玩家(不含迟入) | 偶发 | 参数化网络事件 | 事件,瞬时 |
| 「敌人 X 在 (3, 0, 5) 位置」 | 所有玩家 | 每帧 | VRC Object Sync 或敌人对象自同步 | 走对象池单对象 |
| 「玩家 B 受击 -10 HP」 | 所有玩家 | 偶发 | 参数化事件 | 事件 |
| 「玩家 B 当前 HP」 | 所有玩家 | 每秒几次 | PlayerObject 上的同步字段 | per-player 状态 |
| 「子弹飞行轨迹」 | 所有玩家 | 每帧 | 本地预演(local),不同步 | 不同步 |
最后一行是轻实时的关键设计:子弹的视觉表现本地预演,本地先判断是否命中,然后把「命中谁、伤害多少」作为事件或请求交给 GameState Owner 裁决。事件不是最终真相;最终 HP、分数、敌人剩余数仍要写回同步字段。这一权衡在第 31 章「Lag 补偿的可行边界」会展开。
把它落到字段写权:
// GameState(共识字段)[UdonSynced] private int waveIndex;[UdonSynced] private double waveStartServerTime;[UdonSynced] private int enemyAliveCount;
// 事件(瞬时反馈 / 请求)[NetworkCallable]public void NetOnEnemyKilled(int killerPlayerId, int enemyTypeId){ // 飘字、音效、UI 更新;计分结果由同步字段落地}
// PlayerLobbyState(per-player 状态)[UdonSynced] private int currentHP;GameState 不存玩家 HP,因为 HP 是 per-player 状态,挂 PlayerObject 更稳(第 9 章 PlayerObject 边界规则)。
对象池接口边界
Section titled “对象池接口边界”第 36 章展开对象池实现。本章只规定接口签名,让轻实时玩法能写出来。
// EnemyPool.cs(接口签名,本卷不实现)public class EnemyPool : UdonSharpBehaviour{ public GameObject SpawnEnemy(int enemyTypeId, Vector3 position) { return null; } public void DespawnEnemy(GameObject enemy) { } public int GetActiveEnemyCount() { return 0; }}GameState 用法:
public EnemyPool enemyPool;
private void AdvanceWave(){ waveIndex += 1; waveStartServerTime = Networking.GetServerTimeInSeconds(); SpawnWaveEnemies(waveIndex); RequestSerialization(); ApplyState();}
private void SpawnWaveEnemies(int wave){ int countToSpawn = wave * 5; // 第 N 波 N×5 个敌人 for (int i = 0; i < countToSpawn; i++) { Vector3 pos = ComputeSpawnPosition(i); enemyPool.SpawnEnemy(0, pos); } enemyAliveCount = countToSpawn; // GameState 上的聚合}SpawnEnemy 内部不是 Instantiate:第 36 章会展开「预放置 + 激活」的对象池模式,避免运行时频繁创建对象。本章只用接口。
enemyPool 引用挂在 GameState Inspector 上,enemyPool 自身的 GameObject 在场景里预放置。具体实现的 Owner 选择、迟入恢复、Quest 性能预算放到第 36 章。
闭环 3 的轻实时改造对照
Section titled “闭环 3 的轻实时改造对照”把闭环 3 改造成轻实时(合作防守世界),需要的改动。
| 项目 | 闭环 3 | 轻实时改造 |
|---|---|---|
GameState 字段 | phase / totalScore / phaseStartServerTime / stateVersion 等 | 加 waveIndex / waveStartServerTime / waveDuration / enemyAliveCount / lastTickTime |
BeginMatch | 设 phase = INGAME / 重置 totalScore | 加:设 waveIndex = 1 / 写 waveStartServerTime / enemyPool.SpawnWaveEnemies(1) / 重置 enemyAliveCount |
Update | 倒计时检测「时间到 → EndMatch」 | 改成 tick 推进:每 tickInterval 检查「波次到了吗」、是否进入下一波或结束 |
HandleScore | 加分 + 冷却 + 阶段闸门 | 改名为 HandleEnemyKill,参数 (int enemyTypeId);触发 enemyAliveCount -= 1 和分数处理 |
| 事件 | 闭环 3 没用事件 | 加 NetOnEnemyKilled(killerPlayerId, enemyTypeId) 走 target=All 广播飘字 |
| 对象池引用 | 无 | 加 public EnemyPool enemyPool Inspector 引用 |
代码量比回合制改造大一些,主要因为加了对象池引用和事件入口。stateVersion + gating 那一套不用动,闭环 3 的健壮性继承下来。
HandleEnemyKill 触发链:
// 玩家本地命中判定 → 通过 PlayerObject 发请求private void HandleEnemyKill(VRCPlayerApi who, int enemyTypeId){ if (phase != PHASE_INGAME) return; if (enemyAliveCount <= 0) return; // 防止重复扣到负 enemyAliveCount -= 1; AddScore(who, 1);
// 广播击杀事件(事件层) SendCustomNetworkEvent(NetworkEventTarget.All, nameof(NetOnEnemyKilled), who.playerId, enemyTypeId);
if (enemyAliveCount <= 0) AdvanceWave(); RequestSerialization(); ApplyState();}AddScore 是第 17 章「分数三层架构」的入口,本章不重写。
不同世界不同活法
Section titled “不同世界不同活法”「这种状态需要多精确同步」的判断方法
Section titled “「这种状态需要多精确同步」的判断方法”写新字段前过一遍这三问。
第一问:迟入玩家进来时这个值需不需要立刻正确?
需要的话走状态字段(同步必须)。不需要的话走事件(瞬时即可)。
第二问:这个值每秒变化几次?
每秒 0–1 次:随便选状态字段或事件。
每秒 2–10 次:必须状态字段,且要节流(仅在变化时 RequestSerialization)。
每秒 30 次以上:本地推演,不同步。视觉/听觉反馈走事件。
第三问:玩家直接关注这个值吗?
直接关注(HP / 分数 / 当前波次):状态字段,UI 直读。
不直接关注(敌人精确位置):本地推演 + 事件矫正。
三问下来,新字段的同步策略基本能定。轻实时玩法里 80% 的字段会落到「事件做反馈、状态做共识」的混用模式上。
挑一条试。
- 把
tickInterval从 0.2s 调到 0.1s,跑一局 4 人合作防守,观察 11 KB/s 总预算的变化。哪些字段同步频率涨了?哪些没涨?预算撑得住吗? - 给
enemyAliveCount加一个enemyAliveCountByType[]数组(按敌人类型分计数)。这条改动会影响哪几个Handle*函数?字节代价是什么? - 把波次推进改成「玩家投票决定开始下一波」(不再自动推进)。需要加哪些字段?这一改造把轻实时往回合制方向推了多少?读完第 25 章对照看。
| 场景 | 预期 | 实际 |
|---|---|---|
1. BeginMatch 后 | waveIndex=1,waveStartServerTime 写入,对象池生成第一波敌人 | … |
| 2. 玩家击杀敌人 | enemyAliveCount -= 1,飘字事件广播,所有客户端看到 | … |
| 3. 一波清空 | enemyAliveCount=0 触发 AdvanceWave,waveIndex=2 | … |
| 4. 波次时间到 | Update 里 elapsed >= waveDuration 触发 AdvanceWave 即使敌人没清完 | … |
| 5. 迟入玩家加入第 3 波 | 立刻看到「第 3 波,剩 N 秒,剩 K 个敌人」,过去飘字不显示 | … |
| 6. Owner 转移期间 | lastTickTime 是本地缓存,新 Owner 用 Networking.GetServerTimeInSeconds 重算 tick,节奏不丢 | … |
| 7. 1Hz vs 5Hz tick | 同样玩法切换 tick 频率,11 KB/s 预算变化,体感连贯性变化 | … |
| 8. 大量敌人同时被打死 | enemyAliveCount 单帧多次扣减,最终值正确,飘字事件全部到达 | … |
第 6 行验证 tick 跨 Owner 转移的连续性。第 8 行检测并发扣减不出现负数。
- 能默写轻实时四个核心同步字段,并对每个字段说出写权
- 能解释为什么
enemyAliveCount是聚合值,单个敌人状态不在GameState上 - 能列出三档 tick 频率(1Hz / 5Hz / 10Hz)的代表玩法
- 能说出「事件做反馈、状态做共识」的具体落地方式
- 能用三问判断一个新字段该走状态还是事件
- 能识别
Networking.GetServerTimeInSeconds比Time.time更适合 tick 检测的原因
- VRChat Creator Docs · Networking and Synchronization — 同步字段与事件的官方说明。
- VRChat Creator Docs · Network Specs and Tips — 11 KB/s 总预算、200 byte 连续、280 KB 手动的官方数字。
- 第 16 章 · 开局与重开 —
BeginMatch三入口,轻实时改造的母本。 - 第 19 章 · 状态同步还是命令同步 — 频率维度的应用。
- 第 21 章 · 参数化 Network Event 与决策表 — 飘字与击杀事件的参数化。
- 闭环 3 · 加上命令版本号的工程化一局 — 改造的起点。
- 第 25 章 · 回合制范式 — 事件驱动 tick 与时间驱动 tick 的对照。
- 创作者视角 5 · 节奏感是设计出来的 — 心流型节奏的设计含义。
- 附录 · 术语表 —
Light Real-time Loop/Tick Interval/Wave Index的客观定义。