Skip to content

开放局加入系统 ​

概述 ​

GameCore.LobbySystem.LobbyService 目前对外公开了两个客户端 API:

  • TryGetCurrentOpenGameRef(out OpenGameRef? openGame)
  • LeaveAndJoinOpenGame(OpenGameRef openGame)

这两个 API 适合处理这样一类需求:

  1. 当前玩家已经在一个允许中途加入的游戏局内。
  2. 当前玩家通过你自己的项目逻辑,把该游戏局的引用提供给另一位玩家。
  3. 其他玩家收到该引用后,调用 LeaveAndJoinOpenGame 切换加入同一个游戏局。

这套 API 只解决两件事:

  • 从当前本地游戏局提取 OpenGameRef
  • 在已处于另一个游戏局内时,提交“退出当前局并加入目标开放局”的切换请求

它不负责帮你把 OpenGameRef 传递给其他游戏局里的玩家,也不包含好友发现、组队邀请或跨服分发能力。另一位玩家如何拿到这个 OpenGameRef,取决于你自己的项目逻辑。

这里的“开放局”通常来自创作者中心配置的“随进随出”大厅模式。模式 key、房间最大人数、地图玩家槽位和“大厅进入时创建全新的游戏局”的完整关系,见 随进随出模式与开放局。

如果目标玩家当前在另一个游戏局里,一个常见做法是:

  1. 发起方客户端把 OpenGameRef 先交给自己当前游戏局的服务器。
  2. 当前服务器通过 跨服务器频道系统 把消息发到目标用户当前所在的服务器。
  3. 目标服务器再把 OpenGameRef 转交给目标客户端。

> 后台配置注意:中途加入游戏系统依赖大厅支持“随进随出”能力。接入前,应在创作者中心的「运营配置 → 游戏基础配置 → 游戏大厅配置」中创建随进随出模式,并明确配置模式 key、房间最大人数和大厅进入行为。完整配置说明见 随进随出模式与开放局。

与大厅自动匹配的关系 ​

后台“房间最大人数”控制的是大厅自动匹配容量:玩家从大厅点击该模式时,平台只会自动加入尚未达到该人数的开放局。

LeaveAndJoinOpenGame 是按 OpenGameRef 定向加入一个具体游戏局,不会重新执行大厅的模式分池和自动容量筛选。因此,定向加入可以让开放局实际人数超过后台房间最大人数,但不能超过地图配置的真人玩家槽位数。

模式 key 只隔离大厅自动匹配,不是 OpenGameRef 的版本校验。平台不会替项目判断目标开放局中的旧客户端/服务器逻辑是否能与当前版本共存;如果发布了不兼容的新版本,项目应为新版本使用新的模式 key,并在自己的邀请、队伍或引用分发逻辑中停止分发旧局引用,或自行完成版本兼容性检查。完整的版本隔离策略见随进随出模式与开放局。

例如,地图有 100 个真人玩家槽位,后台随进随出模式的房间最大人数设为 1:从大厅进入的玩家可以各自创建单人开放局;组队时,队员仍可通过队长分享的 OpenGameRef 定向进入队长局。这个结构适合“平时单人世界,组队时进入同一个副本服务器”的玩法。

后台自动匹配人数和地图硬上限的完整设计示例,见 随进随出模式与开放局。

核心类型 ​

OpenGameRef ​

OpenGameRef 描述一个可以被主动加入的游戏局:

csharp
public sealed record OpenGameRef(
    string MapName,
    string JoinKey,
    long? GameSessionId = null,
    JoinPolicy JoinPolicy = JoinPolicy.Open);

字段说明:

字段说明
MapName目标地图名
JoinKey允许加入该局的 key
GameSessionId可选的会话 ID,仅用于附带上下文
JoinPolicy当前仅支持 Closed 和 Open 两态

API 参考 ​

TryGetCurrentOpenGameRef ​

csharp
public static bool TryGetCurrentOpenGameRef(out OpenGameRef? openGame)

用途:

  • 从当前本地活跃游戏局中提取一个可分享的开放局引用。
  • 这是一个同步 getter,不会额外向服务器发请求。

典型失败原因:

  • 当前没有活跃游戏局。
  • 当前局不是允许中途加入的局。
  • 当前局是 quick start 局。
  • 当前局缺少可用的 join key。

LeaveAndJoinOpenGame ​

csharp
public static bool LeaveAndJoinOpenGame(OpenGameRef openGame)

用途:

  • 当当前调用方已经在另一个普通游戏局内时,提交“退出当前局并加入目标开放局”的切换请求。

行为说明:

  • 这是 fire-and-forget API,只表示宿主是否接受了这次切换请求。
  • 宿主会在内部处理退局、清理当前游戏状态,再发起加入目标开放局。
  • 当前调用环境如果位于 StateGame WASM 中,退局过程可能直接结束当前 WASM 运行时,因此这个 API 不提供可等待的异步结果。
  • 返回 true 不表示已经进入目标局;目标局结束、JoinKey 失效或地图真人玩家槽位耗尽时,最终加入仍可能失败。
  • 切局流程由框架负责释放源局的开放局占位和真人 Player 槽位;目标局只有在实际加入成功后才占用目标槽位。项目不要因为返回 true 就自行修改两边的人数。

不包含的能力 ​

当前公开的 API 不包含下面这些能力:

  • 查询其他玩家当前所在的开放局
  • 邀请指定玩家加入你的游戏局
  • 好友发现、组队关系或大厅房间管理
  • 在不同游戏局、不同服务器或不同进程之间分发 OpenGameRef

如果你的玩法需要“让另一个当前不在本局里的玩家加入”,那么除了本篇介绍的两个 API 之外,你还需要自己提供一套能够把 OpenGameRef 交给对方的项目逻辑。

典型用法 ​

场景:平时单人,组队时切入队长开放局 ​

可以让后台大厅只负责创建单人开放局,再由项目自己的组队系统定向集结:

  1. 地图配置足够的真人玩家槽位。
  2. 随进随出模式的后台房间最大人数设为 1。
  3. 队长读取并分享自己的 OpenGameRef。
  4. 队员调用 LeaveAndJoinOpenGame 切入队长局。

后台人数只限制大厅自动匹配,定向加入的人数最终由地图真人玩家槽位限制。完整配置见 随进随出模式与开放局。

场景:一方提供开放局引用,另一方主动加入 ​

最小使用闭环是:

  1. 发起方调用 TryGetCurrentOpenGameRef。
  2. 你的项目逻辑把 OpenGameRef 交给目标玩家。
  3. 目标玩家收到后,调用 LeaveAndJoinOpenGame。

发起方:读取当前开放局引用 ​

csharp
#if CLIENT
using GameCore.LobbySystem;

public static class OpenGameHostExample
{
    public static OpenGameRef? GetCurrentOpenGame()
    {
        if (!LobbyService.TryGetCurrentOpenGameRef(out var openGame) || openGame is null)
        {
            return null;
        }

        return openGame;
    }
}
#endif

接收方:切换加入目标开放局 ​

csharp
#if CLIENT
using GameCore.LobbySystem;

public static class OpenGameSwitchExample
{
    public static bool JoinBySwitching(OpenGameRef openGame)
    {
        return LobbyService.LeaveAndJoinOpenGame(openGame);
    }
}
#endif

场景:通过 ServerChannel 把 OpenGameRef 转发给另一个游戏局里的玩家 ​

这一类需求的链路通常是:

  1. 发起方客户端调用 TryGetCurrentOpenGameRef。
  2. 发起方客户端把 TargetUserId + OpenGameRef 发给自己当前游戏局的服务器。
  3. 当前服务器调用 ServerChannelMessage.PublishToUser(...),把消息发到目标用户当前所在的服务器。
  4. 目标服务器收到 ServerChannel 消息后,再通过本局的客户端/服务端同步方式把 OpenGameRef 发给目标客户端。
  5. 目标客户端调用 LeaveAndJoinOpenGame。

这条链路里有两类通信:

下面给一个完整示例。

共享消息类型 ​

csharp
using EngineInterface.BaseType;

public sealed class RequestCrossGameOpenGameInviteMessage
{
    public long TargetUserId { get; set; }
    public OpenGameRef? OpenGame { get; set; }
}

public sealed class OpenGameServerChannelEnvelope
{
    public long FromUserId { get; set; }
    public OpenGameRef? OpenGame { get; set; }
}

public sealed class NotifyOpenGameInviteMessage
{
    public long FromUserId { get; set; }
    public OpenGameRef? OpenGame { get; set; }
}

发起方客户端:把目标用户和 OpenGameRef 交给本地服务器 ​

> 这一步只是当前游戏局内部的客户端 → 服务端同步,不是跨服通信。

csharp
#if CLIENT
using GameCore.LobbySystem;
using TriggerEncapsulation.Messaging;

public static class OpenGameInviteClient
{
    public static async Task<bool> InviteUserAsync(long targetUserId)
    {
        if (!LobbyService.TryGetCurrentOpenGameRef(out var openGame) || openGame is null)
        {
            return false;
        }

        return await MessageBuilder.Create(new RequestCrossGameOpenGameInviteMessage
            {
                TargetUserId = targetUserId,
                OpenGame = openGame
            })
            .WithPriority(MessagePriority.High)
            .SendToServerAsync();
    }
}
#endif

发起方所在服务器:通过 ServerChannel 把消息IncomingContext 发给目标用户 ​

csharp
#if SERVER
using GameCore.ServerChannelSystem;
using GameCore.PlayerAndUsers;
using TriggerEncapsulation.Messaging;

public static class OpenGameInviteRelayServer
{
    [MessageHandler]
    public static async Task<bool> OnRequestCrossGameOpenGameInvite(
        Player? sender,
        RequestCrossGameOpenGameInviteMessage message)
    {
        if (sender?.User is null || message.OpenGame is null)
        {
            return false;
        }

        var envelope = new OpenGameServerChannelEnvelope
        {
            FromUserId = sender.User.UserId,
            OpenGame = message.OpenGame
        };

        return await Task.FromResult(
            ServerChannelMessage.PublishToUser(message.TargetUserId, "open-game-join", envelope));
    }
}
#endif

目标用户所在服务器:订阅频道并把消息转交给目标客户端 ​

> 这一段里,ServerChannel 负责服务器之间的转发;真正投递到目标客户端时,仍然是目标游戏局内部自己的客户端/服务端同步。

csharp
#if SERVER
using Events;
using GameCore.Event;
using GameCore.PlayerAndUsers;
using GameCore.ServerChannelSystem;
using GameCore.ServerChannelSystem.Event;
using TriggerEncapsulation.Event;
using TriggerEncapsulation.Messaging;

public static class OpenGameInviteReceiverServer
{
    public static void OnRegisterGameClass()
    {
        Game.OnGameInstanceInitialization += Initialize;
    }

    private static void Initialize()
    {
        var trigger = new Trigger<EventChannelMessage>(OnChannelMessageReceived, keepReference: true);
        trigger.Register(Game.Instance);

        var onConnected = new Trigger<EventPlayerUserConnected>(OnPlayerConnected, keepReference: true);
        onConnected.Register(Game.Instance);

        var onDisconnected = new Trigger<EventPlayerUserDisconnected>(OnPlayerDisconnected, keepReference: true);
        onDisconnected.Register(Game.Instance);
    }

    private static async Task<bool> OnPlayerConnected(object sender, EventPlayerUserConnected eventArgs)
    {
        ServerChannelMessage.SubscribeUser(eventArgs.User.UserId, "open-game-join");
        return await Task.FromResult(true);
    }

    private static async Task<bool> OnPlayerDisconnected(object sender, EventPlayerUserDisconnected eventArgs)
    {
        ServerChannelMessage.UnsubscribeUser(eventArgs.User.UserId, "open-game-join");
        return await Task.FromResult(true);
    }

    private static async Task<bool> OnChannelMessageReceived(object sender, EventChannelMessage args)
    {
        if (!ServerChannelMessage.TryGetUserIdFromChannel(args.Channel, out var userId, out var subchannel) ||
            subchannel != "open-game-join")
        {
            return false;
        }

        if (!ServerChannelMessage.TryParseJson<OpenGameServerChannelEnvelope>(args.Message, out var envelope) ||
            envelope?.OpenGame is null)
        {
            return false;
        }

        var player = Player.AllPlayers.FirstOrDefault(p => p.User?.UserId == userId);
        if (player is null)
        {
            return false;
        }

        return await MessageBuilder.Create(new NotifyOpenGameInviteMessage
            {
                FromUserId = envelope.FromUserId,
                OpenGame = envelope.OpenGame
            })
            .WithPriority(MessagePriority.High)
            .ToPlayer(player)
            .SendAsync();
    }
}
#endif

目标客户端:收到 OpenGameRef 后调用 LeaveAndJoinOpenGame ​

csharp
#if CLIENT
using GameCore.LobbySystem;
using TriggerEncapsulation.Messaging;

public static class OpenGameInviteReceiverClient
{
    [MessageHandler]
    public static bool OnNotifyOpenGameInvite(
        Player? sender,
        NotifyOpenGameInviteMessage message)
    {
        if (message.OpenGame is null)
        {
            return false;
        }

        return LobbyService.LeaveAndJoinOpenGame(message.OpenGame);
    }
}
#endif

最佳实践 ​

1. 把 OpenGameRef 当作短期有效的快照 ​

OpenGameRef 适合即时分享和即时使用,不应长期缓存。目标局可能随时结束、满员或关闭加入。

2. 发起加入前给用户一个确认机会 ​

如果你的项目有好友、队伍或邀请 UI,建议在真正调用 LeaveAndJoinOpenGame 前先让用户确认,而不是在拿到 OpenGameRef 后立即跳转。

3. 把 OpenGameRef 的分发视为你自己的项目逻辑 ​

OpenGameRef 本身不负责做好友、队伍或跨服权限控制。谁可以拿到它、通过什么方式拿到它,都应该由你的项目逻辑决定。

4. 跨服分发和局内同步要分开设计 ​

如果目标玩家在另一个游戏局里,通常至少会经过两段链路:

  • 服务器 A → 服务器 B:跨服分发,适合用 跨服务器频道系统
  • 服务器 B → 客户端 B:目标游戏局内部同步,适合用你自己的本局同步方案

不要把这两段链路混成同一种通信机制来设计。

5. 为失败结果提供 UI 反馈 ​

加入请求失败时,通常要给用户明确提示,例如“目标游戏已结束”“目标游戏已满”或“当前不可加入”。

6. 让框架处理离开和槽位释放 ​

客户端正常退出当前局时使用 LobbyService.QuitCurrentGame();服务端正常让玩家离开时使用 Player.Disconnect(),需要强制移出时使用 Player.Kick(...)。这些路径会按当前开放局规则清理房间占位和地图真人槽位。

普通网络断线仍可能保留重连名额,不能仅因为 Player.IsOnline 变为 false 就认为槽位已经空出。玩家放弃重连后,框架才会完成最终释放;未完成连接的进入预约也会在保护期结束后自动释放。完整的时间和状态表见 随进随出模式与开放局。

当前限制 ​

  • 当前公开的大厅 API 只覆盖“开放局加入”相关能力,不包含完整的好友、组队和邀请协议。
  • TryGetCurrentOpenGameRef 目前只针对标准的开放游戏局生效。
  • quick start 局不会返回 OpenGameRef。
  • 当前 LeaveAndJoinOpenGame 仅覆盖普通 GamePlay_Game 类型,不包含 Lobby/Equipment 等其他局类型。

相关文档 ​