Перейти к основному содержимому
Живая игра

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

Rooms

Комната — это игровая сессия, и платформе всё равно, где она хостится. Одна абстракция покрывает и выделенный сервер на каждый матч, и одну большую общую карту, разбитую на логические слои, и комнату на мастер-клиенте, и мини-игру, которую хостит бэкенд. Внутренности комнаты — наша забота, а вы управляете комнатой снаружи.

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

  • В игре есть сессии — матчи, лобби, подземелья, гонки, — и кто-то должен отвечать за их жизненный цикл, участие игроков и переподключения.
  • Вы хостите игру на выделенных серверах, на мастер-клиенте игрока или прямо на бэкенде, и игроков (player) нужно направлять туда.
  • На одной общей карте (map) должно идти много логических сессий — слоёв, которые разграничивает модуль Visibility.
  • Игроки входят посреди сессии и должны увидеть текущее состояние: сначала состояние комнаты (room) на момент входа, затем живой трафик.
  • Обрыв связи не должен стоить игроку места (seat): в течение льготного периода (grace window) из шаблона — в battle это 45 с — восстанавливается то же самое участие (membership).
  • Не нужно, если фича сводится к запросам и ответам над записями. С этим справится обычный модуль Data & Subscriptions.

Кто что делает

АкторЧто делает на этой странице
room-ownerрегистрирует комнаты для всего процесса; на отдельном экземпляре комнаты получает её админский интерфейс: правит живой конфиг, выгоняет игроков, закрывает вход, рассылает сообщения, уничтожает комнату
entry-validatorпринимает или отклоняет запросы на вход, указывая код и причину
room-visitorпросматривает список комнат, входит с данными, переподключается в пределах льготного периода, выходит
spectatorвходит, не участвуя в борьбе; получает рассылки и живой трафик
match-organizerрезервирует места, которые учитываются при подсчёте вместимости; резерв истекает через срок из шаблона (в battle это 90 с)

Коротко

Шаблон battle: вместимость, тик, тип хоста, именованная карта и два окна для места
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — тот же класс шаблона компилируется в Unity на базовой версии 2021.3 — синтаксис C# 12 здесь не используется
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Где бы ни был написан шаблон комнаты (room template), после загрузки он версионируется и редактируется в панели, поэтому live-ops перенастраивает тип комнаты без передеплоя движка. Хостинг комнат по этому шаблону — тот же интерфейс, только доступный другой роли (role). И выделенный сервер Unreal, и мастер-клиент получают его целиком: регистрируют комнаты, держат несколько комнат в одном процессе, правят живой конфиг, выгоняют игроков, публикуют события (event) и уничтожают комнаты.

Хук entry-validator: забаненных игроков не пускают на входе, указывая код и причину
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unreal подпишется на события, которые он порождает. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unity подпишется на события, которые он порождает.

Клиент:

Найти комнаты CTF по фильтру, войти с данными снаряжения и реагировать на появление новых игроков
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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 rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

Модель

Комната — это группа с правилами, а не сущность. Участие в комнате берётся из групп. Системное состояние комнаты живёт на уровне платформы, а состояние игры — в сущностях с областью видимости этой комнаты. И внутри комнаты не выполняется никакой код потребителя — ни в одном из режимов авторитетности.

Что объявляет тип комнаты.

Что объявляетсяЧто это
вместимостьизмеряется в местах. Место — единица вместимости, отделённая от участия: его можно зарезервировать до входа и удерживать, пока участник неактивен. Резерв ограничен по времени: у него объявлен крайний срок, после которого место освобождается, если никто так и не вошёл
видимостьв общем списке · по имени или коду · скрытая
режим созданияодин из трёх; в режиме при первом входе обязательно объявить хук инициализации
два независимых тайм-аутатайм-аут бездействия — сколько участник может молчать; и TTL пустой комнаты — сколько живёт комната, в которой никого нет
окно возвратаесли игрок вернулся в пределах этого окна, ему восстанавливается то же участие и то же место, а не создаётся новый участник
режим авторитетностисимуляция платформы или внешний авторитетный источник (external authority), и значения по умолчанию нет
доверие к присланному итогудля внешнего авторитетного источника: принять · проверить хуком · не принимать. Значения по умолчанию здесь тоже нет
поведение при падении хостапереждать льготный период · закрыть комнату · пустить замену
экземпляр карты и ярусы мирапри желании — какой экземпляр карты занимает комната и какие ярусы внутри него
сущности комнатыкакие сущности (entity) студии получают область видимости комнаты. Это задаётся предикатом, а не отдельным механизмом

Две машины состояний.

ОбъектСостояния
комнатаcreated → open → closed → torn down. torn down — конечное состояние, а closed значит, что новых входов нет, а не что комнаты больше не существует
участиеactive ⇄ inactive → departed; для этого участия departed — конечное состояние

Что верно для любой комнаты.

ВсегдаЧто это
потеря связи и выход— разные события, и чем закончилось окно, видно: «вернулся» и «окно истекло» различимы, так что клиенту не приходится гадать, что произошло
зритель— не урезанный участник: он присутствует, но не занимает места и не входит в состав, к которому обращаются как к «игрокам»
ролей внутри комнаты нетвладелец комнаты (room owner) — это актор, у которого есть право (Access & Roles), а не звание в списке участников
присутствиеу присутствия (presence) есть история, а у состава (roster) — нет: сохраняется, кто вошёл, отвалился, вернулся и ушёл, а изменения состава второй историей не становятся
интерфейспринадлежит конкретной комнате: вы обращаетесь к этой комнате, а не только к её типу
три оси, а не двеAPI для всех комнат сразу (просмотр, регистрация, список); API отдельной комнаты, который вызывает любой участник (вход, выход); и админский интерфейс экземпляра — выгнать, закрыть вход, поправить конфиг, закрыть, уничтожить эту комнату. Доступ к нему даёт не участие, а роль администратора или хоста именно этого экземпляра
one room type — one set of declarationsexternal authorityour simulationdedicated server · host keymaster client · room-ownerbackend · backend-service a match on a fleeta player's machinea mini-game
Кто выполняет тик: один тип комнаты, хосты сгруппированы по режиму авторитетности

Два режима авторитетности.

РежимКто выполняет тик
симуляция платформынаша реализация комнаты и её модули
внешний авторитетный источникпроцесс с нашим SDK, который находится в комнате с авторитетной ролью: игровой сервер студии или клиент игрока в роли мастер-клиента

Что режим решает, а что нет.

Что это
границапроходит по роли, а не по тому, чей это процесс. Выделенный сервер — тот же клиент, только без рендеринга. От машины игрока его отличает доверие, а не устройство. Поэтому для peer-to-peer третий режим не нужен: это комната в режиме внешнего авторитетного источника, где этим источником служит хост на клиенте
что одинаковоправила входа, присутствие, переподключение и все объявления — во всех трёх случаях. Различается только то, какой процесс авторитетен и сколько прав ему выдано
комната никуда не переезжаетни в одном режиме внутри комнаты не выполняется код потребителя, а состояние сессии и постоянное состояние в обоих режимах остаются у нас. Мастер-клиент — это участник с авторитетной ролью: на нём считается тик, но комната на нём не живёт

Хостинг комнаты.

Регистрация комнат по загруженному шаблону: у каждой свой ключ идемпотентности, несколько комнат на процесс
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — как сборка мастер-клиента: клиент, который зарегистрировал комнату, во время работы получает тот же интерфейс хоста
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
ВсегдаЧто это
дескриптор— тот же объект, что и во вкладке клиента: отдельной инициализации хоста и серверного дескриптора нет. Он отвечает на эти вызовы, потому что во время работы они входят в роль актора — выделенного сервера или мастер-клиента, запущенного с ключом хоста (host key) (Access & Roles)
регистрацияпринимает ключ идемпотентности, иначе после тайм-аута её уже не восстановить. Повторите вызов с тем же ключом — и получите ту же комнату, а не вторую, адреса которой никто не знает

Вход, присутствие и переподключение.

seat reserved by Matchmakingseat freed, with an eventyour entry hookrefused — a code and a reasonactive member — room state, then live trafficinactive — the seat is helddeparted — a leave, with an event 90 s, no joinjoin with datarejectedacceptedconnection lostback in 45 s45 s passed
Жизнь места: резерв, проверка на входе, удержание при обрыве связи (90 с и 45 с — значения из шаблона battle)
Что это
вход— это проверяемый запрос. Входящий передаёт данные, а хук входа принимает или отклоняет запрос с кодом и причиной. Из этих данных участник себя и инициализирует: в battle танк участника появляется с тем снаряжением, которое тот передал при входе
места и резервымодуль Matchmaking занимает место на срок резерва из шаблона — в battle это 90 секунд, — после чего клиент входит в комнату напрямую. Резервы учитываются при подсчёте вместимости, а когда резерв истекает, место освобождается с событием, а не молча
обрыв — не выходотключившийся участник сохраняет место на льготный период (в battle это 45 с) и переподключается в то же участие. Когда период истекает, обрыв превращается в выход, и событие сообщает, чем именно всё закончилось. Тот, кто вернулся на секунду позже, узнает, что комната жива, а его участия уже нет, — и это другой ответ, чем «такой комнаты нет»
поздний вход — это состояние, а не журналвошедший получает текущее состояние комнаты, а затем живой трафик

После переподключения события не воспроизводятся

События, отправленные, пока игрока не было, заново не приходят — ни опоздавшему, ни вернувшемуся участнику. Всё, что должно пережить разрыв, должно быть состоянием: мина, которую поставил игрок, — это сущность комнаты, а не сообщение MinePlaced, которое кто-то должен успеть поймать.

Ошибки

  • Несуществующая комната, комната, скрытая предикатом, и уничтоженная комната отвечают not found: по отказу нельзя узнать о комнате, которую вам не положено видеть.
  • Нет свободной вместимости — это конфликт, и зарезервированные места считаются занятыми. Повторить стоит, когда место освободится. Вход закрыт — тоже конфликт; повторить стоит, если вход откроют.
  • Вход, отклонённый правилом, — это конфликт. Вход, отклонённый хуком, приходит с кодом и причиной из самого хука, поэтому «правила игры не пустили» никогда не выглядит как сбой транспорта.
  • Окно возврата истекло — это конфликт: войдите как новый участник, на новое место.
  • Резерв истёк — это конфликт: возьмите новый.
  • Недоступный или несуществующий экземпляр карты — отказ при валидации.
  • Переход, который отклонила целевая комната, — это конфликт, и что делать дальше, зависит от причины.
  • Создание комнаты сверх ограничения отвечает ограничением частоты или конфликтом — в зависимости от того, какое ограничение сработало.

Ограничения

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

  • Вместимость комнаты: вход отклоняется как конфликт, зарезервированные места считаются занятыми.
  • Комнат на проект: создание отклоняется как конфликт.
  • Комнат на актора: создание отклоняется, а уже созданные комнаты ради свободного места никогда не уничтожаются.
  • Частота создания комнат: ограничение частоты с указанием, когда можно повторить.
  • Тайм-аут бездействия участника: принудительный выход с событием и объявленной причиной.
  • TTL пустой комнаты: уничтожение с событием; для типа постоянной зоны его можно отключить.
  • Крайний срок резерва места: освобождение с событием.
  • Комнат на одном экземпляре карты: создание на занятом экземпляре отклоняется, если тип не объявил совместное использование.
  • Размер полезной нагрузки события комнаты: публикация отклоняется до отправки, а не обрезается.