Skip to content

混合同步 - Node 位置 + PropertyHostComponent 数据 ​

PropertyHostComponent 是一种为连续运动的联机实体设计的同步形态:位置 / 朝向交给 replicated Node 的 transform(客户端原生平滑),游戏数据交给挂在该 node 上的同步属性组件。两者按 node 关联,但属性可独立做 per-property 可见性分级。

它建立在 服务端 GameGraph SceneGraph 发布与同步 之上:node 的复制、发布、transform 平滑由那套系统负责;本组件补上「数据挂在 node 上、按需分级可见」这一层,并用代码生成消除样板。

> 真实样例:code_sample 的 HybridFlappyBird(混合同步联机 FlappyBird)。本文所有片段都对应该样例。


它解决什么问题 ​

连续运动的联机游戏(跑酷、弹球、2D 射击、竞速、服务端权威物理对象……)有两个需求经常打架:

  1. 位置要平滑。服务端按 tick(约 30Hz)推进位置,客户端帧率更高。若把位置当普通同步属性发,客户端只能拿到一跳一跳的离散值,要么看着抖,要么得手写线性外插去补帧。
  2. 数据要能分级可见。得分、存活等可以公开;背包、隐藏装备等只该 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
动态创建都可运行时随时创建 / 销毁

不同点 ​

维度PropertyObjectNode + 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);                       // 发布给目标玩家(看得到这个实体)
#endif

AddPropertyHost<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,不过滤会建出无人控制的实体。
  • 客户端不能写同步属性;要改状态发请求给服务端,由服务端校验后写入。

相关文档 ​