跳到主要内容
实时游戏

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

Rooms

房间就是一局游戏会话,平台不关心由谁托管。 同一个抽象覆盖每局一台专用服务器、切分成多个逻辑层的大型共享地图、由主客户端托管的房间,以及由 backend 托管的小游戏。房间内部由我们实现,你从外部驱动房间。

何时使用

  • 游戏里有会话(对局、大厅、副本、比赛),需要有人管理它们的生命周期、成员资格(membership)和重连。
  • 你把游戏托管在专用服务器、玩家的主客户端(master-client)或 backend 本身上,需要把玩家路由过去。
  • 一张共享地图(map)要同时运行多个逻辑会话:即分层,由 Visibility 划定范围。
  • 玩家在会话中途加入,必须看到当前状态:先拿到房间(room)进入时的状态,再接收实时流量。
  • 断线不能让玩家丢掉席位(seat):在模板规定的宽限期(grace window)内(battle 中为 45s),玩家恢复原来的成员资格。
  • 无需使用:如果功能只是对记录做请求/响应。普通的 Data & Subscriptions 已经够用。

分工

主体在本页中的职责
room-owner在整个进程范围内注册房间;在单个房间实例上使用该实例的管理接口:修改实时配置、踢人、锁定、广播、销毁
entry-validator接受或拒绝加入请求,并给出代码和原因
room-visitor浏览房间、携带数据加入、在宽限期内重连、离开
spectator加入但不参与对抗;接收广播和实时流量
match-organizer预留计入容量的席位;预留在模板规定的期限后过期(battle 中为 90s)

概览

battle 模板:容量、tick、主机类型、命名地图,以及两个席位窗口
[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 专用服务器和主客户端都拥有完整的这套接口:注册房间、每个进程托管多个房间、修改实时配置、踢人、发布、销毁。

entry-validator hook:在门口拒绝已封禁的玩家,并给出代码和原因
[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),确定写法后会补上示例。

在引擎之外运行

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

在引擎之外运行

hook 是云函数:它在平台上执行,不在引擎里执行。用 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));

模型

房间是带规则的群组,而不是实体。 成员资格来自群组;房间的系统状态由平台维护,游戏状态则存放在限定于该房间的实体中。而且,无论哪种权威模式,房间内部都不运行任何使用方代码。

房间类型声明什么。

声明项含义
容量以席位计。席位是容量的单位,与成员资格分离:可以在加入前预留,也可以在玩家不活跃时保留。预留有时限,声明的截止时间一到,没有人加入的席位就会释放
可见性可枚举 · 凭名称或代码 · 隐藏
创建方式三选一;首次加入时创建 方式必须声明一个初始化 hook
两个独立的超时空闲超时:参与者可以沉默多久;空房间 TTL:没人的房间能存活多久
重连窗口在窗口内回来,恢复的是同一份成员资格和同一个席位,而不是新建一个参与者
权威模式平台模拟 或 外部权威,没有默认值
对上报结果的信任针对外部权威(external authority):接受 · 用 hook 检查 · 不接受。同样没有默认值
主机掉线时的行为等宽限期结束 · 关闭房间 · 接纳替代主机
地图实例与世界层可选:房间占用哪个实例,以及实例中的哪些层
房间作用域实体工作室的哪些实体归属该房间的作用域,用一个谓词表达,而不是新机制

两个状态机。

对象状态
房间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
由谁运行 tick:同一种房间类型,其主机按权威模式分组

两种权威模式。

模式由谁运行 tick
平台模拟我们的房间实现及其模块
外部权威运行我们 SDK、以权威角色身处房间内的进程:工作室的游戏服务器,或者作为主客户端的玩家客户端

模式决定什么,不决定什么。

含义
分界线按角色划分,而不是按进程归谁。专用服务器就是不做渲染的同一个客户端;它和玩家机器的区别在于信任,而不在于构造。这也是点对点不需要第三种模式的原因:它就是外部权威模式下的房间,权威恰好是一台客户端主机
相同的部分三种情况下的加入规则、在线状态、重连以及所有声明都一样。唯一不同的是哪个进程持有权威,以及授予它多少权威
房间不会迁走两种模式下房间内部都不运行使用方代码,会话状态和持久状态都留在我们这里。主客户端是持有权威角色的成员:tick 在那里计算,房间并不驻留在那里

托管房间。

基于推送的模板注册房间:每个房间一个幂等键,每个进程多个房间
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)
注册需要幂等键(idempotency key),否则注册超时后无法恢复:用同一个键重复调用,拿回的是同一个房间,而不是第二个谁也不知道地址的房间

加入、在线状态与重连。

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 s 和 45 s 是 battle 模板的取值)
含义
加入是一次经过校验的请求:加入者在加入时提供数据,加入 hook 接受或拒绝,并给出代码和原因。成员用这份数据完成初始化:在 battle 中,成员的 Tank 带着加入时提交的装备生成
席位与预留Matchmaking 按模板的预留期限占住一个席位(battle 中为 90 秒),随后客户端直接加入。预留计入容量,过期时释放席位会发出事件,而不是悄无声息
掉线不等于离开断开的成员在宽限期内(battle 中为 45s)保留席位,并重连回同一份成员资格;过期后才算离开,事件会说明是哪一种。晚一秒回来的人会得知房间还在、成员资格已不在,这和“没有这个房间”是不同的答复
中途加入拿到的是状态,不是日志加入者先拿到房间的当前状态,再接收实时流量

重连后不会重放事件

玩家离开期间发送的事件不会重放,无论是中途加入者还是返回的成员。必须跨越这段空白保留下来的内容都得是状态:玩家布下的地雷是一个房间作用域实体,而不是一条要有人接住的 MinePlaced 消息。

错误

  • 不存在或被谓词隐藏的房间,以及已销毁的房间,都返回 not found:拒绝结果绝不会暴露你无权看到的房间。
  • 容量已满属于冲突,且预留的席位按已占用计算;有席位释放时值得重试。停止接受加入同样属于冲突,房间重新开放时值得重试。
  • 规则拒绝的加入属于冲突;hook 拒绝的加入则带有 hook 自己的代码和原因,因此“游戏规则不允许”绝不会看起来像传输故障。
  • 重连窗口已过期属于冲突:以新参与者身份、占用新席位加入。
  • 预留已失效属于冲突:重新预留。
  • 地图实例不可用或不存在属于校验拒绝。
  • 目标房间拒绝了转移属于冲突,如何处理取决于拒绝原因。
  • 超出房间上限的创建返回速率限制或冲突,取决于触发的是哪个上限。

限制

具体数值将随平台限制一章给出;到达每个上限时的行为现在已经确定。

  • 房间容量:加入按冲突拒绝,预留的席位按已占用计算。
  • 每个项目的房间数:创建按冲突拒绝。
  • 每个主体的房间数:拒绝创建,已创建的房间绝不会为了腾出空间而销毁。
  • 房间创建速率:速率限制,并附带可重试的截止时间。
  • 参与者的空闲超时:强制离开,发出事件并附带声明的原因。
  • 空房间 TTL:销毁并发出事件;持久区域类型可以关闭它。
  • 席位预留的截止时间:释放并发出事件。
  • 单个地图实例上的房间数:在已占用的实例上创建会被拒绝,除非类型声明了共享占用。
  • 房间事件 payload 的大小:发送之前就拒绝发布,绝不截断。