下载 App

如何用TapTap 制造实现一套完整的多人匹配/组队系统

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

一、整体架构

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

二、模块划分(4个文件)

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

三、核心实现

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
```
horizontal linehorizontal line

四、客户端怎么接

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 路径。
horizontal linehorizontal line

五、踩过的坑

基础篇

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 塔防完全够用。
horizontal linehorizontal line

六、安全性:作弊风险与防御

因为"服务器不跑战斗",必然面临客户端作弊问题。

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 保护经济 + 轻量校验已覆盖绝大多数作弊场景。后续竞技性上升可逐步加强,无需推翻架构。
horizontal linehorizontal line

七、可扩展方向

- 段位匹配:队列中加段位字段,优先匹配相近段位
- 房间制:队列匹配前加一层房间,房主确认后再开始
- 观战:服务器存对局快照,第三方客户端订阅观看
- 重连:记录玩家身份,断线后恢复到原对局
当然有能力的开发者可以直接使用服务端权威来做组队模式,我选择使用快照主要是开发成本低,毕竟积分不是很充裕,欢迎大家留言交流。
猜你想搜
taptap 制造ai填充
【新游上线】末日100天截图
【新游上线】末日100天
大家好,我们是《末日100天》的开发者Paul~ 这是一款围绕「生存-经营-轮回」展开的废土策略小游戏:白天分配 3 点行动力经营据点,夜里迎接十天一循环的尸潮,每张地图第 100 天还有一场首领战等着你。五张地图、五大区域机制、可继承的轮回遗产,以及钻石商城里的抽奖机——我们希望每次重开都有一点点新意。 以下是游戏简单介绍: 1.游戏背景 废土历元年,未知病毒席卷全球,城市化为死地。幸存者们退守
9 赞
6 回复
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
45 赞
9 回复
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
68 赞
21 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
124 赞
44 回复
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
3 回复
游戏自荐《少女终焉契约》截图
游戏自荐《少女终焉契约》
之前玩放置类型的rpg游戏的时候我就在想,现在游戏的操作有的太过于繁琐了,有没有一款休闲一点的,想上线就上线,离线的时候角色能自动行动的游戏呢?感谢TapTap制造,我按照我的想法开发出了现在这款游戏。 一、故事背景和UI 游戏的故事背景是传统的异世界穿越故事,玩家被女神召唤到异世界,随后组建工会,招募美少女,打怪升级探索世界。所以我选择了更加二次元萌系画风的UI风格,主页为各个主要的功能,点开来
8 赞
3 回复
创作资源申请《今天也元气满满》截图
创作资源申请《今天也元气满满》
游戏名称:今天也元气满满 开发工具:TapTap制造 类型:文字 / 克苏鲁 × 职场讽刺 当前版本:v1.2(v1.3 开发中,核心系统已实装) 周一 闹钟响了。理智-10。 到公司。打卡。茶水间海报写着「态度决定一切」。理智-1。 领导说"开个短会"。理智-5。 ——如果上班是一款克苏鲁游戏,大概就是这种体验。 我们把它做出来了。 这游戏玩的是什么? 你是一家制药公司的HR。公司里大部分人的性
9 赞
1 回复
永恒虚空持续开发日记1——关于游戏中的新名词:场频截图
永恒虚空持续开发日记1——关于游戏中的新名词:场频
写在前边:首先感谢TapTap制造版主大大@汪崽 对于作品潜力的肯定,给与了我积分计划二的权限[表情_不好意思]。这边也在加紧开发改进各种功能中,期望尽快给大家带来成熟的版本。[表情_猫咪举手] 大家好,我是永恒虚空的制作人黑猫不详,大家叫我黑猫就好。从今天开始我将以后置开发日记的形式给大家分享我在TapTap制造中制作的游戏永恒虚空的开发过程,新人希望大家多多关照! 今天先给大家介绍我作品中最重
投票
3 赞
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
【UI教程】如何将已有的效果图应用为实际的UI界面截图
【UI教程】如何将已有的效果图应用为实际的UI界面
在经过了一些折腾和比较折磨的尝试后,我总算是摸清楚了嗒啦啦怎么把UI效果图转换为实际游戏中的UI组件,今天就给大家带来这部分的经验分享。 tips:1.本篇教程需要各位掌握最基本的ps使用,具体的操作部分我会大致讲解,但仍需各位自行学习ps的基本操作,部分可以由ai代劳的环节我会注明。 2.本篇教程需要在以我前两篇教程作为前置环节,请在阅读这两篇前置教程后再进行本篇教程内容的学习 在按照前
11 赞
5 回复