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

Эта страница ещё не переведена — показана английская версия.

Entity

Сущность — это объявление в схеме с живыми аспектами: данные 0..*, состояния 0..*, RPC 0..*, события 0..*, хуки и история изменений. Карты привязывают препятствия к сущностям, столкновения — аспект трансформа, характеристики — это просто пресет, а объекты мира — пресет плюс машина состояний.

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

Когда это нужно

  • Объекту мира нужно поведение, а не только поля: машины состояний, RPC с разрешениями и события (event) в одном объявлении.
  • Двери, ловушки, подбираемые предметы: переходы должны срабатывать от клиентских событий, контактов столкновений или порогов характеристик (stat) без кода в комнате.
  • Игровые объекты должны создаваться одной строкой: примените пресеты вроде world-objects или выведите из них свои.
  • Чтобы разобрать спор, нужно точное состояние мира в момент выстрела: прочитайте экземпляр на прошедший sim_time в пределах объявленного окна.
  • Не нужно, если к объекту не обращаются самостоятельно. Значение, которое живёт только внутри чего-то другого, например надпись на табличке двери, — это поле (field) в аспекте, а не отдельная сущность. Всё, к чему обращаются по адресу, — сущность. 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 — привязаться к единственному аспекту с трансформом и не видеть в сущности больше ничего.

Машиной состояний в этом примере управляют три правила:

  • Имя с точкой вкладывает состояние на один уровень. 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), где стоит дверь, какую бы сборку он ни запускал. Близость сюда не входит: насколько близко нужно стоять, чтобы получать дельты двери, решает правило модуля Visibility в политике синхронизации аспекта. Это вопрос трафика, а не прав, и более широкий обзор никогда не расширяет права
Actorучётная запись вызывающего — тот же объект, что возвращает whoami
caller.Inventoryдескриптор 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 — собственное событие двери. Его адресат задан объявлением — это сам экземпляр, — поэтому его слышат все, кто подписан на дверь, а список получателей вместе с отправкой не передаётся.

Модель

Танк, дверь, полоса характеристики и квест — всё это сущности. Различаются они только набором аспектов, и именно поэтому любой другой модуль может строиться на этом.

Что объявляет сущность.

ОбъявляетЧто это
aspectименованная группа полей, объявляемая целиком, со своей политикой синхронизации и маской доступа. У сущности их несколько, и ни одно поле не входит в два аспекта сразу
машина состоянийсостояния с вложенностью на один уровень, переходы и условия перехода; у сущности их может быть несколько
triggerто, что запускает переход; четыре источника описаны ниже
RPC сущностидействие над сущностью, объявленное внутри представления (view) вместе с нужным ему атомарным разрешением
событие сущностисигнал, который испускает сущность; его получают все, кто подписан на этот экземпляр
hookдо и после, на операциях с данными и на переходах; разворачиваются как облачные функции. Порядок, форму вердикта и последствия сбоя объявляет модуль Extensibility
отслеживание историихранит ли представление окно мгновенной истории вообще
refссылка на другую сущность, которая хранит её id и никогда не хранит key, поэтому переименование ключа не ломает ссылок. Include подтягивает связанную сущность вместе со страницей

Что запускает переход. Ни один из четырёх источников — не код, работающий в комнате.

ИсточникКак срабатывает
клиентское событие или RPCлюбое объявленное событие или RPC: RequestOpen выше запускает open_requested
столкновениеконтакт или вход в триггерный объём — ловушки, нажимные плиты — через аспект, к которому привязывается Collision
порог данныхобъявляется на характеристике, например 0 HP → death, и обеспечивается порядком хуков, а не кодом в комнате
времяAfterSeconds на состоянии — объявленный триггер, а не корутина. Он идёт по часам симуляции комнаты, продвигается вместе с sim_time и останавливается, пока комната не симулируется. Если экземпляр удалить, вместе с ним завершатся его машины состояний и ожидающие таймеры

Что может выборка.

ОсьЧто допустимо
filter и sortтолько объявленные поля: дескриптора таблицы нет, а выборка адресуется по сущностям в пределах комнаты или проекта (project)
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();

Что верно для любой сущности.

ВсегдаЧто это
выборкаэто набор сущностей, а не группа. Участники группы (group) — акторы, и группа нужна, чтобы один сигнал дошёл до всех, а выборка — это чтение, которое просто остаётся живым
историятолько окно мгновенной истории: недавние состояния, проиндексированные по sim_time, в пределах объявленной глубины. Ветвящаяся история — альтернативные линии времени, отмена, повтор целого матча — не поддерживается: она требовала бы воспроизводимых значений с плавающей точкой, а правила типов их не гарантируют
объявленный метод без реализацииэто законченное состояние, а не недонастроенное. Вызов такого метода возвращает вердикт с машиночитаемой причиной «no implementation»: это не отказ и не успех с пустым результатом
необъявленное имядаёт другой исход, чем имя, объявленное без реализации: в первом случае будет отказ валидации, во втором — вердикт, и код может их различить
вызов без реализациине пропадает бесследно: студия может увидеть, что его кто-то вызвал. В какой форме — намеренно не входит в контракт, поэтому опирайтесь на сам факт, что вызов заметен, а не на строку в логе
создание экземпляратребует атомарного разрешения (permission atom) entity × write. По умолчанию оно есть у облачной функции, выделенного сервера и мастер-клиента, а у обычного клиента — только если его выдаёт роль (role). Так во всех биндингах, не только в 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, которого по умолчанию нет ни у одного клиентского ключа.
  • Несовпадение версии — нарушение предусловия: перечитайте данные и решите заново.
  • Занятый ключ — конфликт.
  • Запись от имени игрока (player), в которой этот игрок не указан, отклоняется валидацией, а не записывается без автора.
  • Нет разрешения — ответ forbidden, отдельно для чтения и для записи.

Ограничения

Конкретные числа задаются для каждого проекта, а поведение на каждой границе зафиксировано уже сейчас.

ОграничениеНа границе
аспектов на представление · машин состояний на представление · глубина вложенности внутри аспектаобъявление отклоняется при playserv push и никогда молча не обрезается
размер хранимого экземпляразапись отклоняется как конфликт с указанием поля и измеренного размера; приближение к пределу видно ещё до отказа
размер страницы выборкистраница обрезается до предела, а признак «есть ещё» остаётся истинным: короткой страницы, которая выглядит последней, вы не получите
окно мгновенной историичтение за пределами окна отклоняется, а не получает ближайшее значение
частота изменений одного экземпляраотказ по частоте с указанием, сколько нужно подождать