本页尚未翻译,当前显示英文版本。
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 longGo 中基于特性的声明方式尚未确定(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[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 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
hook 是云函数:它在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unreal 订阅它产生的事件。引擎内编写的函数已在规划中:Blueprint 图表,以及 C++(转译为平台校验过的脚本目标)。两者都像其他声明一样经过分析并推送,执行仍在平台上。
hook 是云函数:它在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,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));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 中基于特性的声明方式尚未确定(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.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(加入、离开);以及单实例管理接口:对这个房间踢人、锁定、修改配置、关闭、销毁。它不看成员资格,只对持有该实例管理员或主机角色的人开放 |
两种权威模式。
| 模式 | 由谁运行 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 中基于特性的声明方式尚未确定(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.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),否则注册超时后无法恢复:用同一个键重复调用,拿回的是同一个房间,而不是第二个谁也不知道地址的房间 |
加入、在线状态与重连。
| 含义 | |
|---|---|
加入 | 是一次经过校验的请求:加入者在加入时提供数据,加入 hook 接受或拒绝,并给出代码和原因。成员用这份数据完成初始化:在 battle 中,成员的 Tank 带着加入时提交的装备生成 |
席位与预留 | Matchmaking 按模板的预留期限占住一个席位(battle 中为 90 秒),随后客户端直接加入。预留计入容量,过期时释放席位会发出事件,而不是悄无声息 |
掉线不等于离开 | 断开的成员在宽限期内(battle 中为 45s)保留席位,并重连回同一份成员资格;过期后才算离开,事件会说明是哪一种。晚一秒回来的人会得知房间还在、成员资格已不在,这和“没有这个房间”是不同的答复 |
中途加入拿到的是状态,不是日志 | 加入者先拿到房间的当前状态,再接收实时流量 |
重连后不会重放事件
玩家离开期间发送的事件不会重放,无论是中途加入者还是返回的成员。必须跨越这段空白保留下来的内容都得是状态:玩家布下的地雷是一个房间作用域实体,而不是一条要有人接住的 MinePlaced 消息。
错误
- 不存在或被谓词隐藏的房间,以及已销毁的房间,都返回 not found:拒绝结果绝不会暴露你无权看到的房间。
- 容量已满属于冲突,且预留的席位按已占用计算;有席位释放时值得重试。停止接受加入同样属于冲突,房间重新开放时值得重试。
- 规则拒绝的加入属于冲突;hook 拒绝的加入则带有 hook 自己的代码和原因,因此“游戏规则不允许”绝不会看起来像传输故障。
- 重连窗口已过期属于冲突:以新参与者身份、占用新席位加入。
- 预留已失效属于冲突:重新预留。
- 地图实例不可用或不存在属于校验拒绝。
- 目标房间拒绝了转移属于冲突,如何处理取决于拒绝原因。
- 超出房间上限的创建返回速率限制或冲突,取决于触发的是哪个上限。
限制
具体数值将随平台限制一章给出;到达每个上限时的行为现在已经确定。
- 房间容量:加入按冲突拒绝,预留的席位按已占用计算。
- 每个项目的房间数:创建按冲突拒绝。
- 每个主体的房间数:拒绝创建,已创建的房间绝不会为了腾出空间而销毁。
- 房间创建速率:速率限制,并附带可重试的截止时间。
- 参与者的空闲超时:强制离开,发出事件并附带声明的原因。
- 空房间 TTL:销毁并发出事件;持久区域类型可以关闭它。
- 席位预留的截止时间:释放并发出事件。
- 单个地图实例上的房间数:在已占用的实例上创建会被拒绝,除非类型声明了共享占用。
- 房间事件 payload 的大小:发送之前就拒绝发布,绝不截断。