跳转到内容

第 26 章 · 轻实时范式

约 11 分钟 难度:3 动手章

这一章解决:在闭环 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 节奏感差异

回合制(第 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 衰减)。这一段「持续推进」是轻实时的特征。


挂在 GameState 上,由 GameState Owner 写。

字段类型含义写权
waveIndexint第几波,从 1 起。BeginMatch 时设 1,每次 AdvanceWave +1GameState Owner
waveStartServerTimedouble这一波从哪一秒开始GameState Owner
waveDurationfloat这一波持续多少秒,例如 60 秒GameState Owner(可设计参数)
enemyAliveCountint当前波次还活着的敌人数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 只持有「整体进度」。


「这一秒到了吗」的检测频率决定了轻实时的精度。三档常见配置,对应不同玩法。

每秒一次推进。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。

每 0.2 秒一次推进。tickInterval = 0.2f。这一档是本卷主项目(4 人合作防守)的默认推荐:节奏感连续,但同步成本仍可控。

每个 tick GameState 决定要不要 RequestSerialization:仅在字段真的变化时调(enemyAliveCount 改了 / 进入下一波 / 切阶段)。空 tick 不发包。

优点:玩家感受连贯。波次推进、敌人计数变化都能在合理延迟内被看到。

缺点:实现要小心。空 tick 不发包是必须的,否则每秒 5 个空包就把预算撑爆。

每 0.1 秒一次推进。这一档已经接近 VRChat 同步约束的红线。除非玩法非常吃节奏(节奏游戏 / PvP 反应类),否则建议先尝试 5 Hz 看够不够。

字段差异:通常需要把同步状态拆得更细(每个状态字段独立 manual sync 对象),让 OnPostSerialization 的失败不影响其他字段。也可能需要字节打包(第 23 章)压字段大小。

重要前提:10 Hz 是 GameState Owner 端的内部 tick 频率,不是「同步频率」。同步包是按字段变化触发的,不是每 tick 必发。两者要分开理解。


轻实时玩法的同步混用模式。这一节把第 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 边界规则)。


第 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 改造成轻实时(合作防守世界),需要的改动。

项目闭环 3轻实时改造
GameState 字段phase / totalScore / phaseStartServerTime / stateVersionwaveIndex / waveStartServerTime / waveDuration / enemyAliveCount / lastTickTime
BeginMatchphase = 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 “「这种状态需要多精确同步」的判断方法”

写新字段前过一遍这三问。

第一问:迟入玩家进来时这个值需不需要立刻正确?

需要的话走状态字段(同步必须)。不需要的话走事件(瞬时即可)。

第二问:这个值每秒变化几次?

每秒 0–1 次:随便选状态字段或事件。
每秒 2–10 次:必须状态字段,且要节流(仅在变化时 RequestSerialization)。
每秒 30 次以上:本地推演,不同步。视觉/听觉反馈走事件。

第三问:玩家直接关注这个值吗?

直接关注(HP / 分数 / 当前波次):状态字段,UI 直读。
不直接关注(敌人精确位置):本地推演 + 事件矫正。

三问下来,新字段的同步策略基本能定。轻实时玩法里 80% 的字段会落到「事件做反馈、状态做共识」的混用模式上。


挑一条试。

  • tickInterval 从 0.2s 调到 0.1s,跑一局 4 人合作防守,观察 11 KB/s 总预算的变化。哪些字段同步频率涨了?哪些没涨?预算撑得住吗?
  • enemyAliveCount 加一个 enemyAliveCountByType[] 数组(按敌人类型分计数)。这条改动会影响哪几个 Handle* 函数?字节代价是什么?
  • 把波次推进改成「玩家投票决定开始下一波」(不再自动推进)。需要加哪些字段?这一改造把轻实时往回合制方向推了多少?读完第 25 章对照看。

场景预期实际
1. BeginMatchwaveIndex=1waveStartServerTime 写入,对象池生成第一波敌人
2. 玩家击杀敌人enemyAliveCount -= 1,飘字事件广播,所有客户端看到
3. 一波清空enemyAliveCount=0 触发 AdvanceWavewaveIndex=2
4. 波次时间到Updateelapsed >= 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.GetServerTimeInSecondsTime.time 更适合 tick 检测的原因