主题
混合同步 - Node 位置 + PropertyHostComponent 数据
PropertyHostComponent 是一种为连续运动的联机实体设计的同步形态:位置 / 朝向交给 replicated Node 的 transform(客户端原生平滑),游戏数据交给挂在该 node 上的同步属性组件。两者按 node 关联,但属性可独立做 per-property 可见性分级。
它建立在 服务端 GameGraph SceneGraph 发布与同步 之上:node 的复制、发布、transform 平滑由那套系统负责;本组件补上「数据挂在 node 上、按需分级可见」这一层,并用代码生成消除样板。
> 真实样例:code_sample 的 HybridFlappyBird(混合同步联机 FlappyBird)。本文所有片段都对应该样例。
它解决什么问题
连续运动的联机游戏(跑酷、弹球、2D 射击、竞速、服务端权威物理对象……)有两个需求经常打架:
- 位置要平滑。服务端按 tick(约 30Hz)推进位置,客户端帧率更高。若把位置当普通同步属性发,客户端只能拿到一跳一跳的离散值,要么看着抖,要么得手写线性外插去补帧。
- 数据要能分级可见。得分、存活等可以公开;背包、隐藏装备等只该 owner 看到(反作弊)。
混合形态对这两点各用最合适的通道:
- 位置 → replicated
Node.Position/Node.Rotation。客户端SmoothedTransform自动从当前显示值向最新网络目标收敛,渲染端零外插代码。 - 数据 →
PropertyHostComponent的同步属性。逐属性可设SyncType,且属性只发给「能看到该 node 的玩家」(即 node 所在 SceneGraph 的当前订阅集)。
> 推荐定位:Canvas 绘制的联机游戏,只要有连续运动 / 持续变化的视觉元素(如 FlappyBird、跑酷、弹球),就优先用这套混合形态。 这正是它存在的理由——PropertyObject 没有内置插值,用它同步连续位置只能在客户端手搓 smoothing / 线性外插(易错、难调、难维护);把位置交给 node 后,平滑由引擎原生负责,玩法与渲染代码不再碰插值。纯离散状态(无连续运动)才继续用 PropertyObject。
选型:PropertyObject vs Node + PropertyHostComponent
这是最常见的纠结点。两者底层都是服务端权威的属性同步(客户端只读、都用代码生成出强类型访问器、都支持 per-property SyncType),差别在「位置」和「可见性门控」。
相同点
| 维度 | 说明 |
|---|---|
| 服务端权威 | 只在服务端创建 / 写入,客户端只读;防作弊 |
| 强类型访问 | [PropertyObjectWrapper] / [PropertyHostFacet] 代码生成访问器,零样板 |
| per-property 可见性 | 都支持把单条属性设为 SyncType.Self / Ally / All |
| 动态创建 | 都可运行时随时创建 / 销毁 |
不同点
| 维度 | PropertyObject | Node + PropertyHostComponent |
|---|---|---|
| 空间 / 位置 | 无场景概念。位置只能当普通属性同步(离散、无插值),连续运动需自行外插 | 位置走 replicated Node 的 transform,客户端原生平滑,零外插 |
| 可见性门控 | 收件人 = per-property SyncType(全局口径) | 收件人 = 该 node 所在 SceneGraph 的订阅集 ∩ per-property SyncType——先「谁能看到这个实体」、再叠加属性级范围 |
| 关联与生命周期 | 独立对象,自行管理 | 挂在 Node 上,随 node 销毁原子释放 |
| 客户端发现 | PropertyObject.All / EventPropertyObjectReplicated | 按 SceneGraph 观察 node(SceneGraph.ObserveReplicatedNodes),再按 node 取组件 |
何时选 Node + PropertyHostComponent(后者)
- Canvas 绘制的联机游戏,有连续运动 / 持续变化的视觉元素(FlappyBird、跑酷、弹球、竞速……)——这是推荐路线。实体有连续运动的位置 / 朝向时,位置走 node 拿引擎原生平滑;不要用 PropertyObject 同步连续坐标,那会逼你手写 smoothing / 外插。
- 需要按「谁能看到这个实体」门控数据可见性:把图发布给一部分玩家(看得到实体),再对单条属性叠加
Self/Ally(其中谁能看到该私有字段)。
何时仍用纯 PropertyObject
- 无空间 / 离散状态:卡牌、棋盘格、回合制、放置 / 挂机、背包。位置不连续,
OrderIndex/GroupId或自定义属性足够,不需要 node 与场景图的开销。详见 PropertyObject 系统。
何时只用裸 Node(不要 facet)
- 实体有位置、且只需要「看得到 node 就看得到它的全部小状态」、不需要 per-property 范围 → 直接用
Node.SetSyncVar(...)(轻量节点级元数据),无需属性组件。详见 SceneGraph 同步 - Node SyncVar。
> 一句话:位置连续 → node;要属性级隐私 → facet;都不要 → 纯 PropertyObject 或裸 SyncVar。
配方
1. 声明数据 facet
在一个 EPropertyXxx 枚举上标 [PropertyHostFacet] + [EnumExtension(Extendable = true)],成员用 [PropertyType(typeof(T))] 指定类型、[PropertySyncType(...)] 声明 per-property 可见性。代码生成会产出 partial class Xxx : PropertyHostComponent(剥掉 EProperty 前缀),含每条属性的访问器(服务端可写、客户端只读)。
csharp
using EngineInterface.BaseType;
using GameCore.Extension;
[PropertyHostFacet]
[EnumExtension(Extendable = true)]
public enum EPropertyBird
{
[PropertyType(typeof(int))]
Score, // 默认 All(公开)
[PropertyType(typeof(bool))]
IsAlive,
}
// → 自动生成 partial class Bird : PropertyHostComponent
// { public int Score { get; /* #if SERVER */ set; } public bool IsAlive { ... } }> 命名规则同其它扩展枚举:枚举名必须以 E 开头、直属命名空间;生成的类名去掉 EProperty 前缀(EPropertyBird → Bird)。
2. 服务端:位置写 node,数据挂 facet
csharp
#if SERVER
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component;
var graph = new SceneGraph(SceneGraphReplication.ReplicatedUnpublished);
var node = graph.RootNode?.CreateChild("Bird", NodeReplication.Replicated);
// 一行挂上强类型同步属性组件,返回组件直接读写
var bird = node.AddPropertyHost<Bird>(player, SyncType.All);
node.Position = new Vector3(laneX, y, 0); // 位置 → transform(客户端平滑)
bird.Score = 0; // 数据 → facet
graph.Publish(player); // 发布给目标玩家(看得到这个实体)
#endifAddPropertyHost<T> 内部已处理好「先以空收件人创建、再绑定到 node 所在图、再放开目标 SyncType」的时序,避免本体闪现给非订阅者。挂载与发布顺序无关,收件人逐帧自动对账。
> AddPropertyHost 要求 node 已在某个 SceneGraph 中(CreateChild 自图创建即满足)。node 不在图里时会记一条 warning。
3. 客户端:发现 node、读平滑位置 + facet 数据
csharp
#if CLIENT
using GameGraph.NodeSystem;
using GameGraph.NodeSystem.Component;
// 跟踪「所有额外发布图里出现/消失的 replicated node」(present + future),返回 IDisposable
var observer = SceneGraph.ObserveReplicatedNodes(OnNodeAdded, OnNodeRemoved);
void OnNodeAdded(Node node)
{
// 按 node.Name 分类:Name 是 AM_NET 复制属性,随 node 创建快照一同到达,
// 不必等独立到达的属性组件,分类无错判窗口。
if ((string)node.Name == "Bird")
{
// 属性组件可能晚于 node 到达,须容忍未就绪
node.OnPropertyHostReady<Bird>(bird =>
Render(node.Position, bird.Score, bird.IsAlive));
}
}
// 渲染循环(如 CanvasAnimated.OnAnimatedRender)里:
if (node.IsValid && node.TryGetPropertyHost<Bird>(out var bird))
{
var pos = node.Position; // 已是平滑后的值,零外插
Draw(pos.X, pos.Y, bird.Score, bird.IsAlive);
}
#endif取用 API:
| API | 端 | 说明 |
|---|---|---|
node.AddPropertyHost<T>(player, syncType) | 服务端 | 挂载并返回组件 |
node.TryGetPropertyHost<T>(out var host) | 双端 | 取回已就绪的组件;未就绪返回 false |
node.OnPropertyHostReady<T>(host => ...) | 客户端 | 组件就绪后触发一次(容忍晚到) |
node.RemovePropertyHost<T>() | 服务端 | 仅移除该组件(销毁 node 会自动带走) |
SceneGraph.ObserveReplicatedNodes(onAdded, onRemoved) | 客户端 | 跨所有额外发布图观察 node 出现/消失,返回 IDisposable;不包含当前 SCE 场景 |
> 客户端读不在本端可见范围内、或尚未就绪的属性时返回默认值——这正是反作弊在 API 层的体现(见下)。
> 当前 SCE 场景不在 GetRegisteredSceneGraphs() 列表中。观察 SCE 场景内的 replicated node 时,从 Player.LocalPlayer.Scene.GetSceneGraph() 获取图,直接订阅该实例的 OnNodeReplicated / OnNodeUnreplicated,并遍历 GetReplicatedNodes() 回灌已有节点。
per-property 隐私 / 反作弊
给 facet 字段标 [PropertySyncType(SyncType.Self)],该字段就只发给 owner,敌方收不到字节、读到默认值——即便他们看得到实体本体与公开字段。
csharp
[PropertyHostFacet]
[EnumExtension(Extendable = true)]
public enum EPropertyHybridSoldier
{
[PropertyType(typeof(int))]
Score, // All:双方可见
[PropertyType(typeof(int))]
[PropertySyncType(SyncType.Self)]
SecretLoadout, // Self:仅 owner 可见,敌方读到默认值 0
}可见性是两层求交:
某字段对某玩家可见 ⟺ 该玩家订阅了 node 所在的 SceneGraph(看得到实体)
且 该玩家落在该字段的 SyncType 范围内(Self/Ally/All)于是能表达「看得到对面玩家在动、却看不到对面的背包」:把图发布给双方(都看得到士兵移动),但 SecretLoadout 设 Self(只有 owner 收到字节)。owner 必须是具体玩家,Self 才有「仅该玩家」的语义。
声明式 [PropertySyncType] 在组件挂载(首次写入前)即生效,初始快照与后续增量都按目标范围分发,是推荐路径。如需运行时 per-instance 覆盖,组件基类提供 protected SetPropertySyncType(property, syncType) 供 facet 内部(自定义 partial 方法里)调用——属于进阶用法,建议在该属性首次写入后调用。
连续运动与平滑
位置走 node 后,平滑由 SceneGraph 同步 - Transform 平滑 负责,本组件不额外处理。两点要注意:
- 平滑参数是客户端本地设置(不随网络同步),要在客户端取得对应
SceneGraph后配置,如graph.ConfigureTransformSmoothing(smoothingConstant, snapThreshold)。 - 快速运动必须抬高 snap 阈值。默认
TransformSnapThreshold = 5是为单位级慢速运动调的:当某次网络更新让「当前显示位置 → 最新目标」的距离超过该阈值,会直接贴合而不插值。速度一快(如每秒数百单位),几乎每次更新都触发贴合 → 看着一格一格地跳。把阈值抬到「远大于正常每帧位移、小于真正的瞬移(如重生 / 传送)」即可恢复平滑。HybridFlappyBird 用ConfigureTransformSmoothing(50, 120)(场地 1200×800、移动体约 220 单位/秒)。
朝向同理可走 Node.Rotation;也可像 HybridFlappyBird 那样在客户端从平滑后位置的逐帧位移导出视觉朝向(如小鸟倾斜角),完全不占同步通道。
生命周期
- 挂载:服务端
AddPropertyHost<T>;node 与组件走两条独立同步流,组件可能晚于 node 到达,客户端取用必须用OnPropertyHostReady/TryGetPropertyHost容忍未就绪,不可假设「node 在 ⇒ 数据在」。 - 销毁:销毁 node 自动带走组件并释放其同步属性(原子)。客户端 node 被反注册(实体销毁 / 整图注销 / 重连清场)时,框架自动拆除其组件。
- 可见性免维护:属性收件人每 tick 实时 = 图订阅集 ∩ per-property
SyncType。Publish/Unpublish/ 掉线改变订阅集,下一 tick 自动改变收件人,无需镜像两套集合。
完整示例
code_sample 的 HybridFlappyBird 是端到端范例:
- 小鸟、管道用 replicated node 承载位置(客户端读平滑
node.Position,零外插); - 小鸟数据用
[PropertyHostFacet]生成的组件承载; - 客户端用
SceneGraph.ObserveReplicatedNodes发现 node、按node.Name分类,CanvasAnimated.OnAnimatedRender每帧渲染; - 服务端用
IThinker跑权威模拟,ProtoCustomMessage/EventClientMessage收输入。
per-property 反作弊(Self 私有字段,敌方读默认值)的独立演示见 code_sample 的 PropertyHostPrivacy。
反模式与注意事项
- 别用 PropertyObject 同步连续坐标再手写外插——这正是混合形态要取代的旧做法。位置走 node。
- 别假设组件随 node 同帧到达。永远用
OnPropertyHostReady/TryGetPropertyHost,读不到就给默认值。 - 快速运动忘了调 snap 阈值会看着抖。先查
ConfigureTransformSmoothing,不是 fps 问题。 - 服务端按玩家建实体要过滤:只给真人玩家(
Player.Controller == ControllerType.User)且在线(Player.IsOnline)建。电脑(AI)槽IsOnline恒为 true,不过滤会建出无人控制的实体。 - 客户端不能写同步属性;要改状态发请求给服务端,由服务端校验后写入。
相关文档
- 服务端 GameGraph SceneGraph 发布与同步 - node 复制、发布、transform 平滑、SyncVar(本系统的底座)
- PropertyObject 系统 - 无空间 / 离散状态的数据同步(选型对照项)
- GameGraph 概览 - SceneGraph / Node / 组件模型
- Thinker 系统 - 服务端权威模拟的每 tick 回调
- Canvas 绘制系统 -
CanvasAnimated.OnAnimatedRender每帧渲染