跳到主要内容
基础构件

本页尚未翻译,当前显示英文版本。

Data

**改一个字段,同步随之完成。**Data 是每个同步字段背后的机制:相对上次确认状态的 delta、作为策略单位的 aspect、优先级与发送频率、可续接的订阅、保留窗口,以及变更前/变更后的 hook。

你面对的是实体(entity),不是数据表。读取和变更的接口(查找、过滤、排序、分页、订阅选择集)见 Entity,本页讲的是它下面的机制。使用方没有通往数据表的路径,也没有第二种写入方式:变更就是一次 Entity 操作,delta 是它的结果。

何时使用

  • 你需要把状态复制到客户端,又不想写快照代码:改字段就是同步的全部。
  • 字段的紧急程度或受众各不相同:每个 aspect 有自己的优先级和发送频率上限,战争迷雾则用可见性(visibility)谓词实现。
  • 重连的客户端不能悄悄偏离:缺口会被检测并明确报告,超出保留窗口(retained window)的缺口直接用完整状态回应。
  • 你需要最近的历史:按 sim_time 索引的 delta 保留窗口,正是预测和延迟补偿读取的数据。
  • 校验规则应当只写在一个地方:变更前 hook 在变更生效前把值钳制在范围内,或者否决它。
  • 无需调整这些参数:如果你只需要读取或查询。Entity 的接口建立在这些机制之上,无需你去碰它们。

分工

主体在本页中的职责
schema-author声明 aspect、它们的同步策略和可见性谓词
任意主体订阅目标;从某个位置续接;请求完整状态
backend-service编写变更前/变更后 hook
operator查看按主体数据包的开销;得知投递何时降级、数据包何时被拆分

概览

tank 上的两个 aspect:运动数据每秒发送 30 次,配装只发给其拥有者
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — 同样的 C# 特性可在 Unity(基线版本 2021.3)中编译——声明推送到同一个模型,改字段同样就是同步的全部
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

模型

delta 携带什么。

字段含义
变更的字段只有这些字段,从不包含整个对象
pair它所属的实例 × aspect
number该组合内的序号,正是它让缺口可以被检测到

aspect 声明哪些内容。

全部通过特性声明,写在 aspect 或单个字段上,从不在运行时通过调用设置。aspect 给出默认值,字段可以覆盖;aspect 始终是策略的单位,否则就没有东西可以用来组装预设(preset)。

声明项取值,以及它不是什么
priority信道不够用时决定先发什么。不是延迟承诺:它是相对的,只决定字段之间的发送顺序,不保证送达时限
最大更新频率发送频率的上限。不承诺按这个频率接收——接收取决于信道
仅 delta没变的内容不发送
投递模式shared packet:所有人收到相同内容,CPU 开销低。per-actor packet:每个接收方按自己的可见区域收到专属数据包;CPU 开销高,但玩家数量大时必不可少
可见性规则决定谁能收到的谓词;完整说明见 Visibility
a field changes — revision 15a receiver that acknowledged 12a receiver that acknowledged 14a receiver past the retained window delta 12 → 15delta 14 → 15full state, marked as such
每个接收方收到的是相对它已确认状态的差异;超出保留窗口的接收方收到完整状态(修订号仅作示意)

订阅包含什么。

包含项含义
target一个实例、一个选择集或一个 aspect,订阅接收该目标的 delta。目标不是流:一个目标可能覆盖多个组合,顺序只在组合内部保证,而不是在整个目标上
position从哪里续接,由使用方提供。如果缺口大于保留窗口,送达的是完整状态而不是一串 delta,所以长时间断线也不会让客户端悄悄出错
stateactive → gap detected → resynchronised | closed,其中 closed 是终态

所有流都满足的几点。

始终成立含义
合并delta 允许合并(见下文)。这正是 delta 与事件的区别:事件丢一个,信息就永远丢了
缺口检测不允许悄悄丢失 delta;使用方数的就是组合内的序号
顺序在同一个实例 × aspect 组合内成立;组合之间不作任何形式的保证
遍历只针对已声明的内容:能用于过滤、排序或包含的,只有已声明的字段和已声明的引用。实体的选择集接口是这个模型的投影,本原语不向使用方提供自己的遍历方式——不存在第二种查询语言
历史由 delta 构成:实体的瞬时窗口就是按 sim_time 索引的 delta 保留窗口。它的深度是本原语的限制之一,而且对浮点字段不保证可复现
数据包预算按声明降级:按主体的预算用完后,平台按声明回退到共享数据包,而不是开始随意丢掉接收方

中间值可能被跳过

delta 会在两次发送之间合并:100 → 90 → 80 可能以 100 → 80 的形式到达,因为最终状态依然正确。收到的值序列并不是事情经过的历史。

hook 能做什么,在什么时候做。

motion aspect 上的变更前 hook:负数燃料在变更生效前就被拒绝
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

在引擎之外运行

hook 是云函数(cloud function):它在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写——你的 Unreal 代码订阅它产生的事件。在引擎中编写函数的能力已在计划中:Blueprint 图表,以及 C++(翻译为经平台校验的脚本目标)。两者都会像其他声明一样经过分析并推送,执行仍在平台上。

在引擎之外运行

hook 是云函数:它在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写——你的 Unity 代码订阅它产生的事件。

Hook能做什么
变更前修改变更,或否决它。被否决的变更完全不产生 delta——订阅方什么也看不到,而不是先看到一个值、再看到更正
变更后添加副作用,永远无法让变更失败

删除操作的 hook 挂在 Entity 上,删除本身就在那里;本原语只挂接变更。

错误

  • 未声明的订阅目标会得到校验拒绝。
  • 没有订阅权限时返回 forbidden 或 not found,取决于目标是否存在本身是不是机密——拒绝不能变成可供试探的信息来源。
  • 无法解析的续接位置属于错误请求,绝不会悄悄从“现在”重新开始。
  • 平台一侧关闭的订阅属于冲突,而且可以观察到:状态机的 closed 是终态,进入这个状态无需客户端去推断。
  • 订阅数量用尽属于冲突:权限还在,但已经没有容量了。

限制

具体数值会随平台限制一章给出;达到每个上限时会发生什么,现在已经确定。

  • delta 保留窗口:从早于窗口的位置续接,得到的是完整状态,而不是拒绝。
  • 每个主体(actor)的订阅数:拒绝新订阅,已有订阅继续工作。
  • delta 大小:delta 会被拆分而不是截断,拆分可以观察到。
  • 发送频率:是上限,不是保证。
  • 按主体数据包的开销:用尽时按声明降级为共享数据包。