跳到主要内容
游戏模型

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

Entity

实体就是一份带有活动 aspect 的 schema 声明: 数据 0..*、状态 0..*、RPC 0..*、事件 0..*、hook,以及变更历史。 地图把障碍物绑定到实体上,碰撞绑定一个变换 aspect,属性本身就是一个预设,世界物件则是预设加一个状态机。

Entity 模块挂载在根上,因此 room.Entity<Door>(id) 和 playserv.Entities<KeyDef>() 直接位于根上,而不是某个命名空间之下。它只依赖三个原语(primitive):事件、RPC 和数据,别无其他。Collision、Locomotion 和 Prediction & Lag Comp 位于它之上:每个模块只绑定一个 aspect,而不是整个实体(entity)。正因如此,一次接触可以触发状态转换,而碰撞模块对权限一无所知。

何时使用

  • 世界物件(world object)需要行为,而不只是字段:状态机、带权限的 RPC 和事件都放在同一份声明里。
  • 门、陷阱、拾取物:状态转换必须能由客户端事件、碰撞接触或属性(stat)阈值触发,无需在房间(room)中写代码。
  • 你希望一行代码就创建一个游戏对象:应用或派生 world-objects 这样的预设(preset)。
  • 处理争议时需要开枪那一刻的准确世界状态:在声明的窗口内,读取某个实例在过去某个 sim_time 的状态。
  • 无需使用:如果这个东西没有身份。一个只存在于其他东西内部的值,比如门牌上的文字,是某个 aspect 中的字段,而不是独立的实体。凡是需要寻址的东西都是实体:Data & Subscriptions 是底层机制,任何表路径都绕不过它。

分工

主体在本页中的职责
schema-author声明实体、aspect、状态机和预设
每个主体查询、订阅、调用实体 RPC、读取状态

概览

地牢之门:两个各有策略的 aspect、一个带守卫条件的状态机、一个声明的事件,以及一个写明所需权限的 RPC
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
即将推出 —— Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — 同样的 C# 特性在 Unity 中也能编译(基线 2021.3,不需要更新的 C#)——声明推送到同一个模型
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

声明的单位是 aspect,而不是字段:motion 有自己的更新频率和掩码,info 有另一套,每个字段只属于其中一个。正因如此,预设才能一次挂上一整组字段,碰撞也才能只绑定携带变换的那个 aspect,而看不到实体上的其他内容。

上面代码块中的状态机遵循三条规则:

  • 带点的名称嵌套一层。 open.blocked 会自动归属到 open,因此在 open 上声明的 close_requested 转换在它内部同样生效,无需重复声明。状态机处于 open.blocked 时,它也处于 open:检查 open 状态的结果为真,OnEntered("open") 在进入时已经触发,不会因为子状态再触发一次。
  • 在某个状态上声明的计时器,只在该状态为当前状态时运行。 离开 opening 会丢弃它的 AfterSeconds,再次进入则重新开始计时。
  • 守卫条件(guard)是一个声明的谓词,作用于实体自身的字段,所用语言与访问控制的行谓词相同。需要代码的逻辑应该写成 hook,而不是守卫条件。

字段路径采用推送后模型中的写法。 谓词和查询路径按声明推送时的名称引用字段,如 motion.jammed、gate.state、info.name,不管各个绑定在本地怎么拼写。

谁可以调用这个 RPC。 实体 RPC 与所有操作一样,写明自己需要的权限:一个原子权限(entity × execute),加上一个说明覆盖哪些实例的行谓词(两者都由 Access & Roles 管理)。

声明中的写法含义
caller in entity.room门所在房间中的任意主体(actor),无论运行的是哪个 build。距离不在其中:要站得多近才能收到门的增量,由 aspect 同步策略上的可见性规则决定——这关乎带宽,而非权限,扩大视野永远不会扩大权限
Actor调用方的身份,与 whoami 返回的是同一个对象
caller.InventoryInventory 为该玩家提供的句柄,只要挂载了该模块就能使用

声明要靠 playserv push 才会真正生效。 这一步归 Schema 即代码负责:它把你的声明与已部署的模型做 diff,带上 diff 所依据的修订版本;如果已部署的 schema 已经变化,它会拒绝推送,而不是覆盖。如果重新推送会破坏已经在线的实例,就要走提议 → 计划 → 应用的流程,这样在任何内容变化之前,计划都是可读的。

在客户端,实体本身就是 API:

门就是 API:连接、加入、调用 RPC,处理两种结果——提示音和锁定信号
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
即将推出 —— Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型原样运行
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

locked 信号是门自己的事件:它的目标由声明决定,即当前实例,因此所有订阅这扇门的人都能收到,发送时不需要附带接收者列表。

模型

坦克、门、属性条和任务都是实体。它们唯一的区别在于携带哪些 aspect,正因如此,其他所有模块都能构建在这个模块之上。

实体声明什么。

声明含义
aspect整体声明的一组具名字段,有自己的同步策略和访问掩码。一个实体可以有多个 aspect,而任何字段都不会同时属于两个
状态机状态(可嵌套一层)、转换和守卫条件;每个实体可以有多个
触发器触发状态转换的来源——四种来源见下文
实体 RPC实体上的一个动作,在视图(view)中声明,并写明它需要的原子权限
实体事件实体发出的信号,投递给订阅了该实例的所有人
hook分为前置和后置,作用于数据操作和状态转换,部署为云函数。Extensibility 规定执行顺序、裁决的结构,以及失败时的行为
历史记录视图是否保留即时窗口
ref指向另一个实体的链接,保存它的 id,绝不保存它的 key,因此重命名 key 永远不会破坏链接。Include 会随当前页一起拉取它

什么会触发状态转换。 四种来源都不是在房间中运行的代码。

来源如何触发
客户端事件或 RPC任何已声明的事件或 RPC——上文的 RequestOpen 会触发 open_requested
碰撞一次接触或进入触发体积(陷阱、压力板),通过 Collision 所绑定的 aspect 触发
数据阈值在属性上声明,如 0 HP → death,由 hook 的执行顺序保证,而不是由房间中的代码保证
时间状态上的 AfterSeconds 是声明的触发器,而不是协程:它按房间的模拟时钟运行,随 sim_time 推进,房间不在模拟时停止;删除实例时,它的状态机和尚未触发的计时器随之结束

选择集能做什么。

维度允许的内容
filter 和 sort只能用声明过的字段——没有表句柄,选择集(selection)按实体寻址,范围限定在一个房间或整个项目
include一个声明过的 ref,随当前页一起拉取
paging使用不透明游标:不是偏移量,也不是行 id,版本变更后其含义不再有效。原样传回,绝不解析
access谓词在分页之前生效,因此页面中不会出现本应是隐藏行的空洞
live订阅一个选择集会让它保持实时,成员随数据变化进入或离开
查询、过滤、排序、按游标分页——以及订阅选择集本身
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
即将推出 —— Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型原样运行
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

所有实体都成立的规则。

始终成立含义
选择集是一组实体,而不是群组:群组的成员是主体,存在的目的是让一条信号送达所有成员;选择集则只是一次恰好保持实时的读取
历史只有即时窗口:按 sim_time 索引的近期状态,深度由声明限定。分支历史(另一条时间线、撤销、整场比赛的回放)不在范围之内,因为那需要保证浮点值可复现,而类型规则并不保证这一点
没有实现的已声明方法是一个完整的状态,而不是配置了一半。调用它会得到一个裁决,其中带有机器可读的原因“no implementation”:既不是拒绝,也不是一个结果为空的成功
从未声明的名称与声明了但没有实现的名称是不同的结果:前者是校验拒绝,后者是裁决,代码可以区分二者
未实现的调用不会凭空消失——工作室能够观察到有人调用了它。观察以什么形式呈现,刻意不写进契约,所以请依赖“可以观察到”这一事实,而不是某一行日志
创建实例需要 entity × write 原子权限:云函数、专用服务器和主客户端默认持有它,普通客户端只有在某个角色授予时才持有——所有绑定都是如此,不仅限于 Unreal

变更只在单个实体内具有原子性

同一个调用方修改两个实体时,比如从钱包扣款并添加物品,可能会被观察到只应用了一半。必须同时出现的一对变更应当放在同一个实例中。hook 无法替代这一点:它只包裹一次变更,而不会跨越两次变更。

预设。 预设是应用到视图上的一组具名的 aspect、状态机、hook 和限制。它不引入任何新概念:预设带来的一切,你都可以手动声明。内置五个预设:stats、abilities、projectiles、drops、world-objects,Entity Presets 完整声明了每一个。工作室可以基于它们在代码或面板中派生自己的预设,例如 Crate 就是 world-objects 加 stats:

从两个内置预设派生 Crate,然后每行创建一个,并调到 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
即将推出 —— Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 同样的 C# 声明;创建操作在房间主机上执行,Unity 客户端会看到箱子出现
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

错误

  • 不存在的实例和被谓词隐藏的实例,都返回 not found——因此拒绝永远不会告诉调用方“某个东西存在,但不属于你”。
  • 未声明的字段(包括嵌套字段)和没有值的必填字段都属于校验拒绝,并指明对应字段。
  • 状态机未声明的转换会以 invalid_state_transition 拒绝,绝不会被悄悄忽略。越过守卫条件强行推动状态机是另一种操作,需要另一个原子权限:administer,默认没有任何客户端密钥持有它。
  • 版本不匹配属于前置条件失败:重新读取,再做决定。
  • key 已被占用属于冲突。
  • 代表玩家写入却没有指明玩家属于校验拒绝,而不会变成一次不归属于任何人的写入。
  • 没有权限返回 forbidden,并区分读和写。

限制

具体数值按项目设置;每个边界上会发生什么,现在已经确定。

限制到达边界时
每个视图的 aspect 数 · 每个视图的状态机数 · aspect 内的嵌套深度在 playserv push 时拒绝该声明,绝不悄悄截断
存储的实例大小以冲突拒绝写入,并指明字段和测得的大小;接近上限时,在被拒绝之前就可以观察到
选择集的页大小页面按上限截断,“还有更多”保持为真——你永远不会拿到一个看起来已经结束的短页
即时历史窗口读取窗口之外的内容会被拒绝,而不是返回最接近的值
单个实例的变更频率限流拒绝,并说明需要等待多久