主题
联机同步
本文档帮助老用户理清星火编辑器2.0中服务端→客户端的数据同步有哪几条路、各自适合什么,并重点介绍连续运动联机游戏的推荐做法:位置走 Node、数据走 PropertyHostComponent 的混合形态。
目录
先理清:服务端权威与三条同步路
2.0 的联机是服务端权威的:游戏状态在服务端计算,同步给客户端,客户端只读、只负责表现。这点和 1.0 的理念一致。
「把状态同步给客户端」在 2.0 有三条路,按实体有没有连续运动的位置、要不要属性级隐私来选:
- PropertyObject——无场景、无位置的数据对象。适合卡牌、棋盘、回合制、放置、背包这类离散状态。位置(如果有)靠
OrderIndex/GroupId或自定义属性表达。 - Node + PropertyHostComponent(本文重点)——位置走 replicated
Node(客户端原生平滑),数据走挂在 node 上的同步属性组件。适合连续运动的实体(小鸟、子弹、载具、物理对象),且能按「谁看得到这个实体」叠加属性级隐私。 - 裸 Node + SyncVar——实体有位置、但只需要少量「看得到就全看得到」的小状态,不要属性级范围。直接
Node.SetSyncVar(...)。
> 单机游戏不需要这些——直接在客户端管理数据即可,无同步开销。
选型一张表
| 你的实体 | 选 | 为什么 |
|---|---|---|
| 离散状态、无连续位置(卡牌/棋子/背包/放置) | PropertyObject | 不需要场景与平滑,最轻 |
| 连续运动 + 需要属性级隐私(背包只给自己) | Node + PropertyHostComponent | node 拿原生平滑;数据按「看得到实体 ∩ 属性范围」分级 |
| 连续运动 + 只要「看得到就全看得到」 | 裸 Node + SyncVar | 不需要属性级范围,最省样板 |
✅ 推荐路线:Canvas 绘制的联机游戏,只要有移动 / 持续变化的视觉元素(FlappyBird、跑酷、弹球这类),优先用 Node + PropertyHostComponent。 这套架构正是为此而生——PropertyObject 没有内置插值,用它同步连续位置就得在客户端手搓 smoothing / 外插(易错难调);位置走 Node 后,平滑交给引擎,玩法代码一行外插都不用写。
⚠️ 反模式:用 PropertyObject 同步连续运动的坐标。客户端只能拿到一跳一跳的位置,逼你手写线性外插补帧——连续位置请走 Node。纯离散状态(无连续运动)才继续用 PropertyObject。
混合同步:位置走 Node、数据走属性组件
核心思想:让最合适的通道各管一摊。
- 位置 / 朝向 → replicated
Node的 transform。客户端SmoothedTransform自动从当前显示值向最新网络目标收敛,渲染端一行外插都不用写。 - 游戏数据 →
PropertyHostComponent同步属性。用[PropertyHostFacet]代码生成,逐属性可设可见性范围。
三步配方
① 声明数据 facet(枚举驱动,类似 [PropertyObjectWrapper]):
csharp
using EngineInterface.BaseType;
using GameCore.Extension;
[PropertyHostFacet]
[EnumExtension(Extendable = true)]
public enum EPropertyBird
{
[PropertyType(typeof(int))] Score,
[PropertyType(typeof(bool))] IsAlive,
}
// → 自动生成 partial class Bird : PropertyHostComponent② 服务端:位置写 node,数据挂 facet:
csharp
#if SERVER
var node = graph.RootNode?.CreateChild("Bird", NodeReplication.Replicated);
var bird = node.AddPropertyHost<Bird>(player, SyncType.All); // 一行挂上
node.Position = new Vector3(x, y, 0); // 位置 → transform(客户端平滑)
bird.Score = 0; // 数据 → facet
graph.Publish(player); // 发布给能看到这个实体的玩家
#endif③ 客户端:发现 node,读平滑位置 + facet 数据:
csharp
#if CLIENT
var observer = SceneGraph.ObserveReplicatedNodes(node =>
{
if ((string)node.Name == "Bird")
node.OnPropertyHostReady<Bird>(b => Track(node, b)); // 组件可能晚到,须容忍
});
// 渲染循环里(如 CanvasAnimated.OnAnimatedRender):
if (node.TryGetPropertyHost<Bird>(out var bird))
Draw(node.Position, bird.Score, bird.IsAlive); // node.Position 已是平滑值
#endif这里的 ObserveReplicatedNodes 只观察服务端通过 Publish(player) 显式发布的额外脚本图,不包含当前 SCE 场景。访问当前 SCE 场景内的 replicated node 时,从 Player.LocalPlayer.Scene.GetSceneGraph() 获取图,直接订阅该实例的 node 事件,并遍历 GetReplicatedNodes() 回灌已有节点。
按 node.Name 分类是因为节点名随 node 创建快照一同到达(属于复制属性),不必等独立到达的属性组件,分类不会错判。
反作弊:per-property 可见性
给 facet 字段标 [PropertySyncType(SyncType.Self)],它就只发给 owner,敌方读到默认值——即便敌方看得到实体本体与公开字段。
csharp
[PropertyType(typeof(int))]
[PropertySyncType(SyncType.Self)] // 私有:仅 owner 收到字节
SecretLoadout,可见性是两层求交:「该玩家订阅了实体所在的图(看得到实体)」且「该玩家在该字段的 SyncType 范围内」。于是能表达「看得到对面在动、却看不到对面背包」——图发布给双方,私有字段设 Self。
常见坑
- 快速运动看着抖:不是 fps,是 transform 平滑的 snap 阈值默认(5)对快速运动太低。客户端调
graph.ConfigureTransformSmoothing(50, 120)(阈值抬到正常每帧位移之上、瞬移之下)。 - 组件读到默认值:要么不在你的可见范围(正常,反作弊),要么组件还没到达——用
OnPropertyHostReady/TryGetPropertyHost容忍,别假设「node 在 ⇒ 数据在」。 - 服务端给玩家建实体:只给真人(
Player.Controller == ControllerType.User)且在线(Player.IsOnline)建;电脑(AI)槽IsOnline恒为 true,不过滤会建出无人控制的实体。
深入与示例
- 完整参考:混合同步 - Node 位置 + PropertyHostComponent 数据(选型详表、API、生命周期、反作弊)
- 底座:服务端 GameGraph SceneGraph 发布与同步(node 复制、发布、transform 平滑)
- 离散状态对照:PropertyObject 系统
- 真实样例:
code_sample的 HybridFlappyBird(混合同步联机 FlappyBird);per-property 反作弊独立演示见 PropertyHostPrivacy。