Перейти до основного вмісту
Модель гри

Цю сторінку ще не перекладено — показано англійську версію.

Entity

Сутність — це оголошення в схемі з живими аспектами: дані 0..*, стани 0..*, RPC 0..*, події 0..*, хуки та історія змін. Мапи прив’язують перешкоди до сутностей, зіткнення — до аспекту трансформу, характеристики — це просто пресет, а об’єкти світу — пресет плюс машина станів.

Модуль Entity монтується в корінь, тому room.Entity<Door>(id) і playserv.Entities<KeyDef>() доступні просто з кореня, а не з простору імен. Він спирається на три примітиви — події, RPC і дані — і більше ні на що. Модулі Collision, Locomotion і Prediction & Lag Comp стоять над ним: кожен прив’язується до одного аспекту, а не до всієї сутності (entity). Тому контакт може запустити перехід, а модулю зіткнень не треба нічого знати про дозволи (permission).

Коли це потрібно

  • Об’єкту світу потрібна поведінка, а не лише поля: машини станів (state machine), RPC з дозволами й події в одному оголошенні.
  • Двері, пастки, предмети, які можна підібрати: переходи мають спрацьовувати від клієнтських подій, контактів зіткнень або порогів характеристик (stat), і код кімнати для цього не потрібен.
  • Ігрові об’єкти мають створюватися одним рядком: застосуйте пресети на кшталт world-objects або виведіть із них власні.
  • Щоб розібрати суперечку, потрібен точний стан світу в момент пострілу: прочитайте екземпляр на минулий sim_time у межах оголошеного вікна.
  • Не потрібно, якщо в об’єкта немає власної ідентичності. Значення, яке живе тільки всередині чогось іншого, як-от напис на табличці дверей, — це поле в аспекті, а не окрема сутність. Усе, до чого звертаються за адресою, — сутність. Data & Subscriptions — це механіка під нею, і жоден шлях через таблиці її не обходить.

Хто що робить

АкторЩо робить на цій сторінці
schema-authorоголошує сутності, аспекти, машини станів, пресети
будь-який акторробить вибірки, підписується, викликає RPC сутностей, читає стан

Коротко

Двері підземелля: два аспекти з власною політикою, машина станів з умовами переходів, оголошена подія та 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();
    }
}

Оголошують аспект, а не поле. У motion своя частота й своя маска, в info — інші, і кожне поле належить рівно одному аспекту. Тому пресет може підключити цілу групу полів одразу, а модуль Collision — прив’язатися до єдиного аспекту з трансформом і не бачити в сутності більше нічого. Саме аспект і є одиницею оголошення (declaration).

Машиною станів у цьому прикладі керують три правила:

  • Ім’я з крапкою вкладає стан на один рівень. open.blocked само пов’язується з open, тому перехід close_requested, оголошений на open, діє і всередині нього — повторювати його не треба. Поки машина перебуває в open.blocked, вона перебуває і в open: перевірка стану open дає true, а OnEntered("open") спрацював ще під час входу й для підстану вдруге не спрацьовує.
  • Таймер, оголошений на стані, іде, лише поки цей стан поточний. Коли машина виходить з opening, його AfterSeconds скидається, а новий вхід запускає таймер наново.
  • Умова переходу (guard) — це оголошений предикат над власними полями сутності, записаний тією самою мовою, що й предикат рядків у правилах доступу. Логіка, якій потрібен код, — це хук, а не умова переходу.

Шляхи до полів записують так, як їх надіслала модель. Предикати й шляхи в запитах називають поля так, як їх надіслало оголошення, — motion.jammed, gate.state, info.name, — хоч як би їх записував у себе кожен біндинг.

Хто може викликати RPC. RPC сутності називає потрібне йому право так само, як будь-яка операція: атомарний дозвіл (entity × execute) плюс предикат рядків, який визначає, на які екземпляри він поширюється (обидва описано на сторінці Access & Roles).

В оголошенніЩо це означає
caller in entity.roomбудь-який актор у кімнаті (room), де стоять двері, хоч би яку збірку він запускав. Близькість сюди не входить: як близько треба стояти, щоб отримувати дельти дверей, визначає правило видимості в політиці синхронізації аспекту. Це питання трафіку, а не прав, і ширший огляд ніколи не розширює права
Actorобліковий запис (identity) того, хто викликає, — той самий об’єкт, який повертає whoami
caller.Inventoryдескриптор (handle) Inventory для цього гравця; доступний усюди, де змонтовано цей модуль

Оголошення стає справжнім лише після playserv push. Цей крок описано на сторінці Схема в коді. Команда порівнює ваші оголошення з розгорнутою моделлю, передає ревізію, відносно якої рахували 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 — власна подія (event) дверей. Її адресата задає оголошення — це сам екземпляр. Тому її чують усі, хто підписаний на двері, а список отримувачів разом із надсиланням не передається.

Модель

Танк, двері, смужка характеристики й квест — усе це сутності. Відрізняються вони лише набором аспектів, і саме тому будь-який інший модуль може спиратися на цей.

Що оголошує сутність.

ОголошуєЩо це
aspectіменована група полів, яку оголошують цілком, із власною політикою синхронізації (sync) і маскою доступу. Сутність має кілька аспектів, і жодне поле не входить у два
машина станівстани з вкладенням на один рівень, переходи й умови переходів; на сутність їх може бути кілька
triggerте, що запускає перехід; чотири джерела описано нижче
RPC сутностідія над сутністю, яку оголошують усередині подання разом із потрібним їй атомарним дозволом (permission atom)
подія сутностісигнал, який надсилає сутність; його отримують усі, хто підписався на цей екземпляр
hookдо і після, на операціях з даними й на переходах, розгортається як хмарні функції. Порядок, форму вердикту і те, що робить збій, оголошує Extensibility
відстеження історіїчи зберігає подання (view) вікно миттєвої історії взагалі
refпосилання на іншу сутність, яке зберігає її id і ніколи її key, тож перейменування ключа ніколи не ламає посилання. Include підтягує її разом зі сторінкою

Що запускає перехід. Жодне з чотирьох джерел не є кодом, що виконується в кімнаті.

ДжерелоЯк спрацьовує
клієнтська подія або RPCбудь-які оголошені — RequestOpen вище запускає open_requested
зіткненняконтакт або вхід у тригер-об’єм — пастки, натискні плити — через аспект, до якого прив’язується Collision
поріг данихоголошується на характеристиці, 0 HP → death; його забезпечує порядок хуків, а не код у кімнаті
час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. Хмарна функція, виділений сервер (dedicated server) і майстер-клієнт мають його за замовчуванням, а звичайний клієнт — лише тоді, коли його видає роль. Так у кожному біндингу, а не лише в Unreal

Зміна атомарна лише в межах однієї сутності

Якщо той, хто викликає, змінює дві сутності — наприклад, списує гроші з гаманця й додає предмет, — інші можуть побачити зміну застосованою наполовину. Пару, яка має з’являтися разом, тримайте в одному екземплярі. Хук тут не допоможе: він працює навколо однієї зміни, а не двох.

Пресети. Пресет — це іменований набір аспектів, машин станів, хуків і обмежень, який застосовують до подання. Нових понять він не додає: усе, що приносить пресет, можна оголосити й вручну. У комплекті їх п’ять — 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, якого за замовчуванням немає в жодного клієнтського ключа.
  • Розбіжність версій — це порушення передумови: перечитайте дані й ухваліть рішення знову.
  • Зайнятий ключ — це конфлікт.
  • Запис від імені гравця, у якому гравця не названо, дає відмову валідації, а не запис, що не належить нікому.
  • Якщо дозволу немає, відповідь — forbidden, причому читання й запис розрізняються.

Обмеження

Числа задаються для кожного проєкту окремо, а те, що відбувається на кожній межі, зафіксовано вже зараз.

ОбмеженняНа межі
аспектів на подання · машин станів на подання · глибина вкладення в аспектіplayserv push відхиляє оголошення, а не обрізає його мовчки
розмір збереженого екземпляраплатформа відхиляє запис як конфлікт і називає поле та виміряний розмір; наближення до ліміту видно ще до відмови
розмір сторінки вибіркиплатформа обрізає сторінку до ліміту, а «є ще» лишається true — ви ніколи не отримаєте коротку сторінку, яка виглядає останньою
вікно миттєвої історіїплатформа відхиляє читання поза вікном, а не повертає найближче значення
частота змін одного екземпляравідмова через ліміт частоти, у якій сказано, скільки чекати