如何用TapTap 制造实现一套完整的多人匹配/组队系统
04/2493 浏览开发心得
持久化世界 + 队列匹配 + AI填充 + 快照中继 + PvP仲裁


最近在做塔防游戏时实现了一套多人匹配系统,支持合作PvE和竞技PvP两种模式。整体架构不复杂但比较完整,分享出来给有类似需求的朋友参考。
核心思路:持久化世界服务器 + 队列匹配 + 快照中继 + AI兜底。服务器不跑战斗逻辑,只做"撮合 + 转发 + 仲裁",客户端各跑各的战斗,通过快照同步状态。


一、整体架构
```
┌─────────────┐ ┌─────────────┐
│ 客户端 A │ │ 客户端 B │
│ (战斗模拟) │ │ (战斗模拟) │
└──────┬──────┘ └──────┬──────┘
│ RemoteEvent │ RemoteEvent
▼ ▼
┌──────────────────────────────────┐
│ 持久化世界服务器 │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 在线管理 │ │ 匹配系统 │ │
│ └──────────┘ └──────────────┘ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 快照中继 │ │ 结果仲裁 │ │
│ └──────────┘ └──────────────┘ │
└──────────────────────────────────┘
```
关键设计决策:
- 服务器不跑战斗:战斗逻辑完全在客户端,服务器只转发数据,大幅降低服务器压力
- 持久化世界:服务器常驻运行,玩家随进随出,不需要"开房间"
- AI兜底:匹配超时自动填充AI,保证玩家不会空等


二、模块划分(4个文件)
| 文件 | 运行端 | 职责 |
|------|--------|------|
| `shared.lua` | 双端共用 | 事件常量、序列化工具、状态枚举 |
| `server_main.lua` | 服务端 | 连接管理、在线列表、事件路由 |
| `server_matchmaking.lua` | 服务端 | 匹配队列、邀请系统、AI填充 |
| `client_net.lua` | 客户端 | 连接生命周期、事件委托给子模块 |
子模块按玩法拆分(`resonance_net.lua` 合作模式、`arena_net.lua` PvP模式),每个子模块只关心自己的网络逻辑。


三、核心实现
3.1 事件常量统一管理(shared.lua)
所有网络事件名集中定义,双端 require 同一个文件,改名只改一处:
```lua
Shared.EVENTS = {
CLIENT_READY = "ClientReady", WELCOME = "Welcome",
MATCH_QUEUE = "MatchQueue", MATCH_CANCEL = "MatchCancel",
MATCH_RESULT = "MatchResult", GAME_SNAPSHOT = "GameSnapshot",
GAME_FINISH = "GameFinish", GAME_RESULT = "GameResult",
INVITE_SEND = "InviteSend", INVITE_NOTIFY = "InviteNotify",
INVITE_ACCEPT = "InviteAccept", INVITE_REJECT = "InviteReject",
}
-- 按方向分类,一键注册
function Shared.RegisterServerEvents()
for _, name in ipairs(Shared.SERVER_EVENTS) do
network:RegisterRemoteEvent(name)
end
end
Shared.STATUS = { IDLE = "idle", MATCHING = "matching", IN_GAME = "in_game" }
```
3.2 队列匹配 + AI 超时填充
核心就是一个数组队列,加入时防重复,每帧 Tick 凑对+超时清理:
```lua
local queue_ = {}
local MATCH_TIMEOUT = 30.0
function Matchmaking.HandleQueue(eventData)
local connKey = Shared.GetConnKey(eventData["Connection"]:GetPtr("Connection"))
-- 防重复
for _, entry in ipairs(queue_) do
if entry.connKey == connKey then return end
end
table.insert(queue_, { connKey = connKey, joinTime = os.clock(), ... })
end
```
PvE 按难度优先匹配,PvP 先到先得,两个队列独立 Tick:
```lua
function Matchmaking.Tick(dt)
local now = os.clock()
-- 1. PvE: 双重循环找同难度的对
for i = 1, #queue_ - 1 do
for j = i + 1, #queue_ do
if queue_[i].difficulty == queue_[j].difficulty then
local b = table.remove(queue_, j)
local a = table.remove(queue_, i)
CreateMatch(a.connKey, b.connKey, a.difficulty)
break
end
end
end
-- 2. PvP: 凑够两人就匹配
while #arenaQueue_ >= 2 do
local a = table.remove(arenaQueue_, 1)
local b = table.remove(arenaQueue_, 1)
CreateArenaMatch(a.connKey, b.connKey)
end
-- 3. 超时 → AI填充
for i = #queue_, 1, -1 do
if now - queue_[i].joinTime >= MATCH_TIMEOUT then
SendAIMatch(table.remove(queue_, i))
end
end
end
```
创建对局就是分配 matchId + 通知双方;AI 填充只是发 `IsAI=true`,客户端自动跑本地AI:
```lua
function CreateMatch(connKey1, connKey2)
local matchId = nextMatchId_; nextMatchId_ = nextMatchId_ + 1
activeMatches_[matchId] = { player1ConnKey=connKey1, player2ConnKey=connKey2, ... }
-- 通知双方: MatchId, PartnerName, IsAI=false
end
function SendAIMatch(entry)
-- 通知客户端: MatchId=0, PartnerName="AI队友", IsAI=true
-- 服务器完全不参与AI战斗
end
```
3.3 快照中继(合作模式核心)
服务器只做转发,收到 A 的快照直接发给 B:
```lua
function Matchmaking.HandleSnapshot(eventData)
local connKey = Shared.GetConnKey(eventData["Connection"]:GetPtr("Connection"))
local match = activeMatches_[eventData["MatchId"]:GetInt()]
if not match then return end
-- 找到对方并转发
local targetKey = (match.player1ConnKey == connKey)
and match.player2ConnKey or match.player1ConnKey
local targetInfo = getPlayerInfo_(targetKey)
if targetInfo and targetInfo.connection then
targetInfo.connection:SendRemoteEvent(Shared.EVENTS.GAME_SNAPSHOT, true, eventData)
end
end
```
客户端用紧凑字符串编码,10Hz 节流发送:
```lua
--- 编码: "H:1,战士,3,1,2;2,法师,2,1,3|M:1,500,1000,120,2|C:350|L:80"
function EncodeSnapshot(lane)
local parts = {}
local heroes = {}
for _, h in ipairs(lane.heroes) do
table.insert(heroes, string.format("%d,%s,%d,%d,%d", h.id, h.name, h.star, h.row, h.col))
end
table.insert(parts, "H:" .. table.concat(heroes, ";"))
-- 怪物、资源同理...
return table.concat(parts, "|")
end
--- 10Hz 节流
local SNAPSHOT_INTERVAL = 0.1
function SendSnapshot(dt, lane)
timer_ = timer_ + dt
if timer_ < SNAPSHOT_INTERVAL then return end
timer_ = 0
-- 发送 EncodeSnapshot(lane) 到服务器
end
```
3.4 PvP 结果仲裁
双方各报胜负,服务器等两份结果到齐后裁决:
```lua
function Matchmaking.HandleFinish(eventData)
local connKey = ...
match.results[connKey] = result -- "win" / "lose"
local r1, r2 = match.results[p1Key], match.results[p2Key]
if not r1 or not r2 then return end -- 等双方
-- 仲裁: 一方win一方lose→正常; 双方都win→平局; 双方都lose→平局
if r1 == "win" and r2 ~= "win" then final1, final2 = "win", "lose"
elseif r2 == "win" and r1 ~= "win" then final1, final2 = "lose", "win"
else final1, final2 = "draw", "draw" end
sendResult(p1Key, final1); sendResult(p2Key, final2)
activeMatches_[matchId] = nil
end
```
3.5 邀请系统
```
A 发起邀请 → 服务器检查B在线且空闲 → 通知B → B接受→创建对局 / B拒绝→通知A / 超时→通知A过期
```
服务器用 table 存待处理邀请,30秒超时自动清理。
3.6 断线清理
玩家断线必须清理三处:队列、邀请、对局。对局中断线还要通知对手:
```lua
function Matchmaking.CleanupPlayer(connKey)
-- 1. 从两个匹配队列移除
-- 2. 清理相关邀请
-- 3. 通知对局中的对手断线(复用已有事件,加 Disconnected=true 字段)
for matchId, match in pairs(activeMatches_) do
local targetKey = ... -- 找到对方
if targetKey then
notifyDisconnect(targetKey, matchId)
activeMatches_[matchId] = nil
end
end
end
```
3.7 PvP 状态同步 vs PvE 快照同步
两种模式同步的内容和频率完全不同:
| | PvE 合作(快照中继) | PvP 竞技(状态中继) |
|---|---|---|
| 同步内容 | 完整车道状态(英灵、怪物、资源) | 4个数字(波次、HP、击杀、英灵数) |
| 同步频率 | 10Hz(100ms) | 3Hz(333ms) |
| 数据量 | ~200-500 字节/帧 | ~20 字节/帧 |
| 用途 | 渲染对方完整战场 | 显示对手进度条 |
PvP 不需要看对方战场,只需要知道"他打到第几波、血量多少":
```lua
-- PvP 状态编码: "W:3|H:80|K:15|N:5"
function ArenaNet.EncodeStatus(wave, hp, kills, heroCount)
return string.format("W:%d|H:%d|K:%d|N:%d", wave, hp, kills, heroCount)
end
```
3.8 诅咒/技能中继(优先级事件)
PvP 中的"诅咒"机制(减速、混乱等)必须立即送达,不能被节流吞掉。设计要点:
1. 搭车发送:诅咒附加在状态消息里(`NM:slow` 后缀),不新增事件类型
2. 绕过节流*:有诅咒时立即发送,不等 0.33s 计时器
3. 消费式队列:接收方用队列缓存诅咒,保证每个都被处理一次
```lua
-- 发送:诅咒绕过节流
function ArenaNet.SendStatus(dt, wave, hp, kills, heroCount, nightmareId)
if nightmareId then
SendToServer(EncodeStatus(wave, hp, kills, heroCount, nightmareId))
return -- 不重置计时器
end
statusTimer_ = statusTimer_ + dt
if statusTimer_ < 0.33 then return end
statusTimer_ = 0
SendToServer(EncodeStatus(wave, hp, kills, heroCount))
end
-- 接收:分离诅咒存入队列
function ArenaNet.HandlePeerStatus(eventData)
local decoded = DecodeStatus(eventData["Status"]:GetString())
if decoded.nightmare then table.insert(pendingNightmares_, decoded.nightmare) end
latestPeerStatus_ = decoded
end
-- 消费:战斗逻辑每帧取一个
function ArenaNet.ConsumeNightmare()
return #pendingNightmares_ > 0 and table.remove(pendingNightmares_, 1) or nil
end
```


四、客户端怎么接
4.1 回调注册模式
网络模块暴露 `OnXxx(callback)` 方法,业务屏幕初始化时注册:
```lua
local ResonanceNet = require("network.resonance_net")
ResonanceNet.OnMatched(function(info) -- { matchId, partnerName, isAI, difficulty }
if info.isAI then StartLocalAI() else StartNetworkSync() end
end)
ResonanceNet.OnInvite(function(info) ShowInviteDialog(info) end)
ResonanceNet.OnDisconnect(function() SwitchToAI() end)
-- PvP 额外回调
local ArenaNet = require("network.arena_net")
ArenaNet.OnResult(function(r) ShowResultScreen(r) end) -- { result, opponentName }
ArenaNet.OnNightmare(function(id) PlayNightmareEffect(id) end)
```
4.2 Consume vs Peek 双接口
```lua
-- Consume: 读取并清空(战斗逻辑用,保证每份数据只处理一次)
local snap = ResonanceNet.ConsumeSnapshot()
-- Peek: 只读不清空(渲染用,每帧都能拿到最新数据)
local snap = ResonanceNet.PeekSnapshot()
```
战斗逻辑用 `Consume` 判断"有没有新快照"来决定是否重建网格;渲染层用 `Peek` 保证每帧都有数据可画。
4.3 AI/真人无感切换
战斗屏幕不关心对方是谁,只关心"数据从哪来":
```lua
function Update(dt)
if ResonanceNet.IsAI() then
UpdateLocalAI(dt, partnerLane) -- AI驱动
else
ResonanceNet.SendSnapshot(dt, myLane) -- 发送
local snap = ResonanceNet.ConsumeSnapshot()
if snap then ApplySnapshot(partnerLane, snap) end -- 接收
end
end
```
断线时自动切换:`OnDisconnect` → `isAI_=true` → 下一帧走 AI 路径。


五、踩过的坑
基础篇
1. 匹配队列防重复:快速点两次"匹配"会加入两次队列,必须检查 connKey
2. 断线清理要彻底:队列、邀请、对局三处都要清,漏一处就有幽灵数据
3. 快照节流:10Hz 足够流畅,每帧发浪费带宽
4. AI填充要无感:`IsAI=true` 后体验和真人一样,只是对方行为由本地AI驱动
5. PvP仲裁要兜底:双方都报"赢"是可能的(网络延迟),仲裁规则要提前定
6. 超时对局要清理:10分钟自动清理,防内存泄漏
进阶篇
7. 断线通知复用已有事件:不新增 `DISCONNECT` 事件,而是在 `MATCHED`/`RESULT` 事件里加 `Disconnected=true` 字段。客户端用 `pcall` 探测:
```lua
local ok, disc = pcall(function() return eventData["Disconnected"]:GetBool() end)
if ok and disc then onDisconnectCallback_(); return end
-- 正常逻辑...
```
好处是少注册事件;坑是必须在 handler 开头就检查,否则后续字段不存在会崩。
8. Install() 依赖注入:匹配模块通过 `Install(getPlayerInfo, getConnByUserId, ...)` 注入外部依赖,避免循环依赖,方便独立测试。
9. VariantMap 访问不存在的 key 会崩**:不是返回 nil,而是直接报错。访问可选字段必须用 `pcall` 或约定全部必传。
10. 双队列独立 Tick:PvE 和 PvP 分开写,不要过度抽象成通用匹配器。代码有些重复但逻辑清晰。
11. 覆盖式同步(Overwrite Sync):合作模式全量覆盖对方车道数据,不做增量。看起来浪费,但天然幂等、无序问题,10Hz 塔防完全够用。


六、安全性:作弊风险与防御
因为"服务器不跑战斗",必然面临客户端作弊问题。
6.1 作弊风险分析
| 作弊方式 | 原理 | 影响 |
|---------|------|------|
| 加速挂 | 修改 `dt` 加速战斗 | PvP 抢先报赢 |
| 数据篡改 | 改本地英灵属性 | 合作影响小,PvP影响体验 |
| 伪造快照 | 发假数据给对方 | 对方看到假状态,不影响实际战斗 |
| 永远报赢 | 每局发 `result="win"` | PvP排行榜被刷 |
| 卡断线 | 快输时拔线 | 逃避失败记录 |
6.2 现有防线:SA 系统保护经济
经济系统走服务端权威(SA),改不了钱、改不了卡、刷不了奖励。作弊者最多刷 PvP 排名,拿不到经济利益。
```lua
Shared.SA_ACTION = {
ADD_CURRENCY = "sa_add_currency", GACHA_PULL = "sa_gacha_pull",
ARENA_REWARD = "sa_arena_reward", CDK_REDEEM = "sa_cdk_redeem",
}
```
6.3 可选的服务端验证加固
轻量级校验即可覆盖大部分场景:
```lua
-- 校验1: 战斗时长 vs 服务器计时
local serverElapsed = os.clock() - match.startTime
if duration < serverElapsed * 0.5 then result = "lose" end -- 疑似加速
-- 校验2: 波次跳跃检测
if wave - lastWave > 5 then Log("WARN: wave jump") end
```
6.4 断线逃跑惩罚
对局中断线 → 断线方判负,留下的人判胜。断线者的失败记录存起来,下次上线结算。
6.5 设计取舍总结
| | 服务端跑战斗 | 服务端不跑战斗(本文) |
|---|---|---|
| 服务器成本 | 高 | 低 |
| 开发复杂度 | 高 | 低 |
| 反作弊 | 强 | 弱(靠校验规则) |
| 延迟敏感度 | 高 | 低 |
| 适用场景 | MOBA、FPS | 休闲PvP、合作PvE |
结论:塔防这类非强竞技游戏,"服务端不跑战斗"是合理选择。SA 保护经济 + 轻量校验已覆盖绝大多数作弊场景。后续竞技性上升可逐步加强,无需推翻架构。


七、可扩展方向
- 段位匹配:队列中加段位字段,优先匹配相近段位
- 房间制:队列匹配前加一层房间,房主确认后再开始
- 观战:服务器存对局快照,第三方客户端订阅观看
- 重连:记录玩家身份,断线后恢复到原对局
当然有能力的开发者可以直接使用服务端权威来做组队模式,我选择使用快照主要是开发成本低,毕竟积分不是很充裕,欢迎大家留言交流。


