小游戏邀请好友对战(Party 邀约)
适用于微信/抖音等小游戏:玩家通过分享卡片或邀请链接拉好友进房,再由房主开局进入对战房。
场景目标
- 让“好友邀请”与
matchmaker_room的 Party 流程对齐,避免自建第二套开房协议。 - 控制加入权限(例如
inviteToken、有效期、来源校验),防止房间码被外部滥用。 - 全员最终仍走统一链路:
match:found -> joinById(game_room)。
推荐消息流
- 房主与队员都先
joinOrCreate("matchmaker_room", { token })。 - 房主
party:create,拿到partyCode(可写入分享参数)。 - 受邀玩家用
invite:join(自定义入口)或直接party:join。 - 服务端校验邀请参数后转发到 Party 标准流程。
- 人满后房主
party:start,全员收到match:found并joinById进game_room。
服务端扩展点(继承 MatchmakerRoom)
- 在子类
onCreate里先super.onCreate(options)。 - 新增
invite:join消息,校验inviteToken/ 时间戳 / 签名。 - 校验通过后,按标准流程转发
party:join,减少重复逻辑。
invite:join 等自定义入口的服务端转发与 mm:hint → party: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合并到joinByIdoptions:可能在game_room鉴权失败。
客户端示例代码(Party)
以下均为 客户端 示例:连接框架提供的 matchmaker_room,按 Party 协议发 party:create / party:join / party:start,再按 match:found 携带的 roomId、joinOptions 调用 joinById 进入 game_room。不涉及在客户端实现匹配队列或成局算法。
匹配与 Party 成局 由框架内置的 MatchmakerRoom(matchmaker_room)处理:固定人数 Party、自动匹配等逻辑已带好,业务侧通常 不必再写一套匹配/组局服务。若要做邀请签名校验、自定义入口消息、与业务后台联动等,再在服务端 继承 MatchmakerRoom 覆写或增消息即可(见上文「服务端扩展点」与 匹配与开房)。
目标:房主创建 Party、好友加入、房主开局,最终统一 match:found -> joinById。
游戏场景:微信/抖音小游戏“分享拉人开黑”,常见 1v1 / 2v2 固定人数对战。
party:create 核心参数(与 匹配与开房「被动开房」一致,三端示例里同名同义):
| 字段 | 含义 |
|---|---|
modeId | 玩法 / 模式池标识,字符串由业务约定;决定 Party 与后续 game_room 落在哪条模式线上,并与路人 match:find 的队列维度语义一致。示例 mini_duel 表示「小游戏单挑池」,可改成你方枚举。 |
playersPerMatch | 成局所需人数(整数 2~100)。Party 内人数达到该值后,房主才能 party:start;同时也是开局时创建的 game_room 规模。1v1 填 2,2v2 填 4。 |
region | 分区字符串(排队键的第三段),与 modeId、playersPerMatch 一起决定落在哪条逻辑队列;不是自动地理选服,含义由业务约定。未传时服务端多按 global。与 match:find 的 region 必须同字符串才会进同一路人池;Party 好友间也应约定同一值。完整说明见 匹配与开房 · 队列维度与 region。 |
ts
import Colyseus from "colyseus.js";
const ENDPOINT = "ws://127.0.0.1:2567";
const HOST_TOKEN = "HOST_JWT_TOKEN";
const FRIEND_TOKEN = "FRIEND_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 并拿到房间码(参数含义见上文表格)
mm.send("party:create", {
modeId: "mini_duel", // 玩法池标识,与 game_room 模式对齐;可自定义
playersPerMatch: 2, // 满几人后房主可 party:start;1v1=2,2v2=4;服务端限制 2~100
region: "global", // 与 modeId、playersPerMatch 组成 queue:…:{region};全服常用 global
});
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, token: HOST_TOKEN });
console.log("host joined game:", game.roomId);
}
async function friendJoinByCode(partyCode: string) {
const friendClient = new Colyseus.Client(ENDPOINT);
const mm = await friendClient.joinOrCreate("matchmaker_room", { token: FRIEND_TOKEN });
const foundP = new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
mm.send("party:join", { partyCode });
const found = await foundP;
const game = await friendClient.joinById(found.roomId, { ...found.joinOptions, token: FRIEND_TOKEN });
console.log("friend joined game:", game.roomId);
}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 / region 含义见上文表格)
mm.Send("party:create", new Dictionary<string, object> {
{ "modeId", "mini_duel" }, // 玩法池标识
{ "playersPerMatch", 2 }, // 满员人数,1v1=2,2v2=4;范围 2~100
{ "region", "global" }, // 分区键第三段,与 match:find 同义
});
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 } };
await hostClient.JoinById<DynamicSchema>(roomId, 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(字段含义见上文表格)
_host_mm.send_message("party:create", {
"modeId": "mini_duel", # 玩法池标识,与 game_room 模式对齐
"playersPerMatch": 2, # 满员人数;1v1=2,2v2=4;服务端 2~100
"region": "global", # 分区键;与 modeId、playersPerMatch 共同决定排队池
})
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
var d2: Dictionary = data
var opts := {"token": _host_token}
var join_options: Variant = d2.get("joinOptions", {})
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
_host_client.join_by_id(str(d2.get("roomId", "")), opts)