Skip to content

小游戏邀请好友对战(Party 邀约)

适用于微信/抖音等小游戏:玩家通过分享卡片或邀请链接拉好友进房,再由房主开局进入对战房。

场景目标

  • 让“好友邀请”与 matchmaker_room 的 Party 流程对齐,避免自建第二套开房协议。
  • 控制加入权限(例如 inviteToken、有效期、来源校验),防止房间码被外部滥用。
  • 全员最终仍走统一链路:match:foundjoinById(roomId) 进对局房(默认 game_room,可不传 gameRoomName)。

推荐消息流

  1. 房主与队员都先 joinOrCreate("matchmaker_room", { token })
  2. 房主 party:create,拿到 partyCode(可写入分享参数)。
  3. 受邀玩家用 invite:join(自定义入口)或直接 party:join(见下文 好友加入示例「好友 · TypeScript」 等标签页)。
  4. 服务端校验邀请参数后转发到 Party 标准流程。
  5. 人数 playersPerMatch 后房主 party:start不会满员自动开局),全员收到 match:foundjoinById 进对局房(合并 joinOptions、顶层 reconnectKeytoken)。

服务端扩展点(继承 MatchmakerRoom

  • 在子类 onCreate 里先 super.onCreate(options)
  • 新增 invite:join 消息,校验 inviteToken / 时间戳 / 签名。
  • 校验通过后,按标准流程转发 party:join,减少重复逻辑。

invite:join 等自定义入口的服务端转发与 mm:hintparty:join 的写法,可参考:匹配与开房 中「示例 B:被动开房邀约匹配(party:join)」。小游戏侧房主建 Party、好友凭房间码加入、开局进 game_room客户端骨架见本文 客户端示例代码(Party)

客户端要点

  • 邀请链接建议包含 partyCode 与一次性 inviteToken
  • 先注册 match:found,再发送邀请入口消息,避免竞态丢消息。
  • 收到 mm:hint 时按服务端提示转发(如 party:join),最终统一 joinById(roomId, { ...joinOptions, token })

常见坑

  • 只校验房间码,不校验邀请参数来源:容易被非邀请用户加入。
  • 房主提前 party:start:人数不足会触发 party:error
  • 忘记把 token 合并到 joinById options:可能在 game_room 鉴权失败。

客户端示例代码(Party)

以下均为 客户端 示例:连接框架提供的 matchmaker_room,按 Party 协议发 party:create / party:join / party:start,再按 match:found 携带的 roomIdjoinOptions 调用 joinById 进入 game_room。不涉及在客户端实现匹配队列或成局算法。

匹配与 Party 成局 由框架内置的 MatchmakerRoommatchmaker_room)处理:固定人数 Party、自动匹配等逻辑已带好,业务侧通常 不必再写一套匹配/组局服务。若要做邀请签名校验、自定义入口消息、与业务后台联动等,再在服务端 继承 MatchmakerRoom 覆写或增消息即可(见上文「服务端扩展点」与 匹配与开房)。

目标:房主创建 Party、好友加入、房主开局,最终统一 match:found -> joinById

游戏场景:微信/抖音小游戏“分享拉人开黑”,常见 1v1 / 2v2 固定人数对战。

party:create 参数(PartyCreateRequest

匹配与开房「被动开房」一致。进匹配房时的 token / accessTokenjoinOrCreate("matchmaker_room", …) 的 options 里传(必填,见 JWT 与房间鉴权),不在 party:create body 里。

字段必填类型说明
modeIdstring玩法 / 模式池标识,业务自定义。例:mini_duel(小游戏单挑)。与路人 match:findmodeId 语义一致。
playersPerMatchnumber成局人数,整数 2~100。Party 内人数 该值后房主可 party:start;1v1=2,2v2=4
gameRoomNamestring对局 Colyseus 房间类型名(须在服务端 app.configdefine)。不传默认 game_room
regionstring分区键第三段,与 modeIdplayersPerMatch 组成逻辑队列;未传时服务端多按 global。见 队列维度与 region
userobject展示用资料(昵称、头像、平台等),结构由业务自定。userId 以 JWT 为准,客户端若在 user 里传 userId 会被忽略。缺省时服务端可能用 JWT 里的 username 补进成员资料。

party:join 参数(PartyJoinRequest

字段必填类型说明
partyCodestring6 位房间码(不区分大小写,服务端会 trim 并转大写)。
userobjectparty:createuser,仅展示;userId 以好友 JWT 为准

user 示例字段(均可选,按业务扩展)

字段说明
nickname昵称,用于 party:updatemembers 展示。
avatarUrl头像 URL。
platform"wechat" / "douyin"
level等级等数值展示。

不要传 userId

user 里请勿依赖 userId 鉴权或区分身份;身份以 joinOrCreate 时的 JWT 为准。传了 user.userId 不会报错,但会被服务端 忽略,最终以好友自己的 JWT 为准。

好友加入流程(party:join

  1. 好友账号的 JWT joinOrCreate("matchmaker_room", { token }),等 mm:ready
  2. 先监听 party:joined / party:update / party:error / match:found(避免丢消息)。
  3. 发送 party:joinpartyCode 必填(来自分享或房主 party:created);user 可选(展示资料)。
  4. 收到 party:joined:确认 countplayersPerMatchmembers;房主侧会收到 party:update
  5. 房主 party:start 后,好友与房主一样收到 match:foundjoinById(roomId, …)

好友侧下行(节选)

消息说明
party:joined加入成功:partyIdpartyCodecountmembers 等。
party:update人数或成员变化(含房主端广播)。
party:error如房间码无效、已满、已在其他队伍等。
match:found房主开局后下发,再进对局房。
ts
import Colyseus from "colyseus.js";

const ENDPOINT = "ws://127.0.0.1:2567";
const HOST_TOKEN = "HOST_JWT_TOKEN";

async function hostCreateAndStart() {
  const hostClient = new Colyseus.Client(ENDPOINT);
  const mm = await hostClient.joinOrCreate("matchmaker_room", { token: HOST_TOKEN });

  // 1)先监听关键消息
  const foundP = new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
  const createdP = new Promise<any>((resolve) => mm.onMessage("party:created", resolve));

  // 2)房主创建 Party(必填 modeId、playersPerMatch;其余可选)
  mm.send("party:create", {
    // —— 必填 ——
    modeId: "mini_duel",
    playersPerMatch: 2,
    // —— 可选 ——
    gameRoomName: "game_room", // 不传则默认 game_room,或由 MATCH_MODE_GAME_ROOM_MAP 按 modeId 映射
    region: "global",
    user: {
      nickname: "房主小明",
      avatarUrl: "https://cdn.example.com/avatar/host.png",
      platform: "wechat",
      level: 12,
      // 勿传 userId:以 HOST_TOKEN 对应 JWT 为准
    },
  });
  const created = await createdP;
  console.log("party code:", created.partyCode);

  // 3)等待好友加入后,房主开局(示例用 setTimeout)
  setTimeout(() => mm.send("party:start", {}), 2000);

  // 4)收到 match:found 后进入 game_room
  const found = await foundP;
  const game = await hostClient.joinById(found.roomId, {
    ...found.joinOptions,
    reconnectKey: found.joinOptions?.reconnectKey ?? found.reconnectKey,
    token: HOST_TOKEN,
  });
  console.log("host joined game:", game.roomId);
}
ts
import Colyseus from "colyseus.js";

const ENDPOINT = "ws://127.0.0.1:2567";
const FRIEND_TOKEN = "FRIEND_JWT_TOKEN"; // 必须与房主不同的登录态

/** 分享参数里带上 partyCode(6 位房间码) */
async function friendJoinByPartyCode(partyCode: string) {
  const friendClient = new Colyseus.Client(ENDPOINT);
  const mm = await friendClient.joinOrCreate("matchmaker_room", { token: FRIEND_TOKEN });

  // 1)先挂下行(再发 join,避免竞态)
  const joinedP = new Promise<any>((resolve, reject) => {
    mm.onMessage("party:joined", resolve);
    mm.onMessage("party:error", (e) => reject(new Error(e?.message || "party:error")));
  });
  const foundP = new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
  mm.onMessage("party:update", (u) => {
    console.log("party members:", u.count, "/", u.playersPerMatch, u.members);
  });

  // 2)加入 Party —— 必填 partyCode;可选 user(勿传 userId)
  mm.send("party:join", {
    partyCode, // 必填,来自分享 / 房主 party:created.partyCode
    user: {
      nickname: "好友小红",
      avatarUrl: "https://cdn.example.com/avatar/friend.png",
      platform: "wechat",
      level: 8,
    },
  });

  const joined = await joinedP;
  console.log("joined party:", joined.partyId, "seat count", joined.count);

  // 3)等房主 party:start 后收 match:found,再进对局房
  const found = await foundP;
  const game = await friendClient.joinById(found.roomId, {
    ...found.joinOptions,
    reconnectKey: found.joinOptions?.reconnectKey ?? found.reconnectKey,
    token: FRIEND_TOKEN,
  });
  console.log("friend in game:", game.roomId, "seat", found.seatIndex);
}
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;

public static class MiniInviteBattleExample
{
    public static async Task HostRun(Colyseus.Client hostClient, string hostToken)
    {
        var mm = await hostClient.JoinOrCreate<DynamicSchema>(
            "matchmaker_room",
            new Dictionary<string, object> { { "token", hostToken } });

        // 1) 先挂关键下行
        var createdTcs = new TaskCompletionSource<Dictionary<string, object>>();
        var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
        mm.OnMessage<Dictionary<string, object>>("party:created", p => createdTcs.TrySetResult(p));
        mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));

        // 2) 房主建房(必填 modeId、playersPerMatch;见上文参数表)
        mm.Send("party:create", new Dictionary<string, object> {
            // 必填
            { "modeId", "mini_duel" },
            { "playersPerMatch", 2 },
            // 可选
            { "gameRoomName", "game_room" },
            { "region", "global" },
            { "user", new Dictionary<string, object> {
                { "nickname", "房主小明" },
                { "avatarUrl", "https://cdn.example.com/avatar/host.png" },
                { "platform", "wechat" },
                { "level", 12 },
            }},
        });

        var created = await createdTcs.Task;
        Console.WriteLine($"party code: {created["partyCode"]}");

        // 3) 示例里直接开局;实际应在人数满足后再 start
        mm.Send("party:start", new Dictionary<string, object>());

        // 4) 统一收口:match:found -> JoinById
        var found = await foundTcs.Task;
        var roomId = found["roomId"]?.ToString();
        var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
        var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", hostToken } };
        if (found.TryGetValue("reconnectKey", out var rk) && rk != null)
            gameOptions["reconnectKey"] = rk;
        await hostClient.JoinById<DynamicSchema>(roomId, gameOptions);
    }
}
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;

public static class MiniInviteFriendJoinExample
{
    /// <summary>好友凭房间码加入,等房主开局后 JoinById。</summary>
    public static async Task FriendJoinByPartyCode(
        Colyseus.Client friendClient,
        string friendToken,
        string partyCode)
    {
        var mm = await friendClient.JoinOrCreate<DynamicSchema>(
            "matchmaker_room",
            new Dictionary<string, object> { { "token", friendToken } });

        var joinedTcs = new TaskCompletionSource<Dictionary<string, object>>();
        var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();

        mm.OnMessage<Dictionary<string, object>>("party:joined", p => joinedTcs.TrySetResult(p));
        mm.OnMessage<Dictionary<string, object>>("party:error", p =>
            joinedTcs.TrySetException(new Exception(p["message"]?.ToString() ?? "party:error")));
        mm.OnMessage<Dictionary<string, object>>("party:update", p =>
            Console.WriteLine($"party update {p["count"]}/{p["playersPerMatch"]}"));
        mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));

        // party:join —— 必填 partyCode;可选 user(勿传 userId)
        mm.Send("party:join", new Dictionary<string, object> {
            { "partyCode", partyCode.Trim().ToUpperInvariant() },
            { "user", new Dictionary<string, object> {
                { "nickname", "好友小红" },
                { "avatarUrl", "https://cdn.example.com/avatar/friend.png" },
                { "platform", "wechat" },
                { "level", 8 },
            }},
        });

        var joined = await joinedTcs.Task;
        Console.WriteLine($"joined party {joined["partyId"]}, count={joined["count"]}");

        var found = await foundTcs.Task;
        var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
        var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", friendToken } };
        if (found.TryGetValue("reconnectKey", out var rk) && rk != null)
            gameOptions["reconnectKey"] = rk;
        await friendClient.JoinById<DynamicSchema>(found["roomId"]?.ToString(), gameOptions);
    }
}
gdscript
var _host_client: Colyseus.Client
var _host_mm: Colyseus.Room
var _host_token := "HOST_JWT_TOKEN"

func host_run() -> void:
	_host_client = Colyseus.Client.new("ws://127.0.0.1:2567")
	_host_mm = _host_client.join_or_create("matchmaker_room", {"token": _host_token})
	if _host_mm:
		_host_mm.message_received.connect(_on_host_mm_message)
		# 1)房主创建 Party(必填 modeId、playersPerMatch;见上文参数表)
		_host_mm.send_message("party:create", {
			# 必填
			"modeId": "mini_duel",
			"playersPerMatch": 2,
			# 可选
			"gameRoomName": "game_room",
			"region": "global",
			"user": {
				"nickname": "房主小明",
				"avatarUrl": "https://cdn.example.com/avatar/host.png",
				"platform": "wechat",
				"level": 12,
			},
		})

func _on_host_mm_message(type: Variant, data: Variant) -> void:
	var t := str(type)
	if t == "party:created" and typeof(data) == TYPE_DICTIONARY:
		var d: Dictionary = data
		print("party code: ", d.get("partyCode", ""))
		# 2)示例里直接开局;实际应在人数满足后再 start
		_host_mm.send_message("party:start", {})
	elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
		# 3)统一收口:match:found -> join_by_id
		_join_game_from_found(_host_client, _host_token, data as Dictionary)

func _join_game_from_found(client: Colyseus.Client, token: String, found: Dictionary) -> void:
	var opts := {"token": token}
	var join_options: Variant = found.get("joinOptions", {})
	if typeof(join_options) == TYPE_DICTIONARY:
		for k in (join_options as Dictionary):
			opts[k] = (join_options as Dictionary)[k]
	if found.has("reconnectKey"):
		opts["reconnectKey"] = found["reconnectKey"]
	client.join_by_id(str(found.get("roomId", "")), opts)
gdscript
## 好友凭房间码加入(party:join)→ 等房主 party:start → match:found → join_by_id

var _friend_client: Colyseus.Client
var _friend_mm: Colyseus.Room
var _friend_token := "FRIEND_JWT_TOKEN"

func friend_join_by_party_code(party_code: String) -> void:
	_friend_client = Colyseus.Client.new("ws://127.0.0.1:2567")
	_friend_mm = _friend_client.join_or_create("matchmaker_room", {"token": _friend_token})
	if not _friend_mm:
		return
	_friend_mm.message_received.connect(_on_friend_mm_message)
	# 先监听再发送,避免丢 party:joined / match:found
	_friend_mm.send_message("party:join", {
		"partyCode": party_code.strip_edges().to_upper(), # 必填
		"user": { # 可选,勿传 userId
			"nickname": "好友小红",
			"avatarUrl": "https://cdn.example.com/avatar/friend.png",
			"platform": "wechat",
			"level": 8,
		},
	})

func _on_friend_mm_message(type: Variant, data: Variant) -> void:
	var t := str(type)
	if t == "party:joined" and typeof(data) == TYPE_DICTIONARY:
		var d: Dictionary = data
		print("friend joined party, count=", d.get("count"), " members=", d.get("members"))
	elif t == "party:update" and typeof(data) == TYPE_DICTIONARY:
		var u: Dictionary = data
		print("party update ", u.get("count"), "/", u.get("playersPerMatch"))
	elif t == "party:error" and typeof(data) == TYPE_DICTIONARY:
		push_error("party:error " + str((data as Dictionary).get("message", "")))
	elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
		_join_game_from_found(_friend_client, _friend_token, data as Dictionary)

func _join_game_from_found(client: Colyseus.Client, token: String, found: Dictionary) -> void:
	var opts := {"token": token}
	var join_options: Variant = found.get("joinOptions", {})
	if typeof(join_options) == TYPE_DICTIONARY:
		for k in (join_options as Dictionary):
			opts[k] = (join_options as Dictionary)[k]
	if found.has("reconnectKey"):
		opts["reconnectKey"] = found["reconnectKey"]
	client.join_by_id(str(found.get("roomId", "")), opts)

相关文档