如何用 TapTap Maker 开发多人联机游戏-可自行扩展的多房间管理
精华修改于03/30292 浏览开发心得
支持MOBA、MMO、SLG、RTS、FPS、休闲、卡牌、回合制、放置、沙盒、解谜、竞速、格斗、角色扮演等类型。其实就是什么游戏都支持。
本文以一个100人在线,每局 4 人,玩家可自选不同房间的拍卖游戏的实战项目为例,讲解多人联机游戏的核心概念和开发流程。即使你没有网络编程经验,也能跟着理解。
一、先搞清楚一个根本问题:为什么需要服务端?
想象你和朋友玩扑克牌。如果每个人都能自己翻牌、自己记分,那作弊就太容易了。所以你们需要一个"庄家"——负责发牌、计分、判定胜负。
多人游戏里的服务端就是这个庄家:
- 所有重要数据(金币、物品、排名)都存在服务端
- 所有关键判定(出价是否合法、谁赢了)都由服务端做
- 客户端只负责两件事:把玩家的操作告诉服务端,以及把服务端的结果展示给玩家
这就是"服务端权威"架构。记住这个原则,后面所有设计都围绕它展开。
二、两种联机模式,先选一种
开始写代码之前,你需要决定游戏的联机模式。这个决定影响服务器怎么启动、怎么关闭、玩家怎么进入游戏。
2.1 对局制(Match)
适合有明确"一局"概念的游戏。
工作方式:系统先把玩家凑到一起(匹配),凑齐后开一台服务器,这局打完服务器就关掉。下次再打,重新匹配、重新开服务器。
生命周期:匹配 → 开服 → 游戏 → 结算 → 关服。
典型游戏:棋牌(4 人一桌,打完散场)。
2.2 常驻服(Persistent World)
适合"世界一直在"的游戏。
工作方式:服务器先启动并初始化好游戏世界,然后玩家可以随时加入、随时离开,不影响世界运转。只有当所有人都离开且超时后,服务器才会关闭。
生命周期:开服 → 世界运行(玩家随时进出)→ 无人在线超时 → 关服。
典型游戏:Minecraft(世界一直在,玩家随便进出)、Roblox 体验(进入即玩)、MMO 开放世界。
2.3 现实中大多数游戏是两者结合
英雄联盟、吃鸡这类游戏看起来是"一局一局打"的,但它们其实也有一个常驻的大厅服务器——玩家登录后可以选区域、选模式、组队、匹配。只是当一局比赛开始时,会单独启动一个对局服务器进程来跑这局游戏,打完就销毁。
换句话说,它们的架构是常驻服(大厅)+ 对局制(比赛),只不过把两部分拆成了不同的服务端进程分别运行。
拍卖游戏选了常驻服
我们的拍卖游戏选择常驻服,因为它需要一个"拍卖大厅"始终运行。玩家进来选区域、匹配、拍卖、结束后回大厅,随时可以再来一轮。和 LoL 不同的是,我们的大厅和房间都跑在同一个服务端进程里,不拆分进程,只是逻辑上分成了 QueueManager(匹配)和 RoomManager(对局)两个模块。
三、代码怎么组织?一个入口,两条路
多人游戏的代码天然分成三部分:服务端逻辑、客户端逻辑、两边都用的公共代码。
3.1 入口文件:main.lua
入口文件只做一件事——判断"我现在是谁",然后把控制权交给对应模块:
function Start()
if IsServerMode() then
Module = require("network.Server")
elseif IsNetworkMode() then
Module = require("network.Client")
end
Module.Start()
end
引擎提供了 IsServerMode() 和 IsNetworkMode() 两个全局函数。同一份代码,部署到服务器时走 Server 分支,玩家打开游戏时走 Client 分支。
3.2 目录结构
scripts/
├── main.lua -- 入口,分发到 Server 或 Client
├── config/ -- 双端共享的配置
│ ├── Settings.lua -- 事件名、游戏参数
│ └── Items.lua -- 物品数据表
└── network/
├── Shared.lua -- 公共工具(事件注册、JSON 收发)
├── Server.lua -- 服务端入口
├── Client.lua -- 客户端入口
├── server/ -- 服务端子模块
│ ├── QueueManager -- 匹配队列
│ └── RoomManager -- 房间和拍卖逻辑
└── client/ -- 客户端子模块
├── LobbyUI -- 大厅界面
├── AuctionUI -- 拍卖界面
└── ResultUI -- 结算界面
3.3 怎么决定代码放哪边?
问自己一个问题:这段逻辑如果被篡改,会影响游戏公平性吗?
- 放服务端 server/:判断出价是否合法、扣除/增加金币、决定谁赢了、存档玩家数据
- 放客户端 client/:展示拍卖界面、播放动画和音效、采集玩家的按钮点击、显示倒计时
- 放公共 config/:事件名常量、物品数据表、共享的配置参数、工具函数
四、服务端和客户端怎么"说话"?
两端通信的方式叫做远程事件(Remote Event)。你可以把它理解为"发电报"——一端发出一条带名字的消息,另一端收到后执行对应的处理。
三步走:注册、发送、接收
第一步:双端注册事件名
在公共模块 Shared.lua 里统一定义所有事件名,然后服务端和客户端各自注册自己需要接收的事件。没注册的事件会被引擎直接丢弃,这是一个安全机制。
第二步:一端发送
客户端想加入匹配队列,就给服务端"发电报"。发送时指定事件名,可以附带数据。
第三步:另一端接收
服务端订阅这个事件名。当有客户端发来这个事件时,引擎会自动调用对应的处理函数。eventData 里除了发送方的数据,引擎还会自动加一个 Connection 字段——告诉你这条消息是谁发的。
发送方向
- 客户端 → 服务端:玩家操作,如出价、加入队列、返回大厅
- 服务端 → 单个客户端:只给某个玩家发,如出价被拒绝、个人数据
- 服务端 → 所有客户端:全体通知,如拍卖开始、最高出价更新、游戏结束
传复杂数据怎么办?
VariantMap 只支持基本类型。如果你要传一个物品列表或嵌套结构,用 JSON:
-- 发送方:把 table 编码成 JSON 字符串
local json = cjson.encode({ regionId = "black_market", gold = 500 })
data["Data"] = Variant(json)
-- 接收方:把 JSON 字符串解码回 table
local payload = cjson.decode(eventData["Data"]:GetString())
cjson 是引擎内置的全局变量,不需要 require。
五、连接管理:玩家进来和离开时发生什么?
正确的连接流程
客户端 服务端
│ │
│ ────── 网络连接建立 ──────────────→ │ 触发 ClientConnected
│ │ (此时只记录连接,不做其他事)
│ │
│ ────── 身份认证消息 ──────────────→ │ 触发 ClientIdentity
│ │ (此时才能拿到 user_id)
│ │
│ 创建空 Scene │
│ 设置 serverConnection.scene │
│ │
│ ────── 发送 ClientReady 事件 ─────→ │ 收到后设置 connection.scene
│ │ (触发场景数据的全量同步)
三个关键事件的区别:
- ClientConnected:TCP 连接刚建立时触发。此时只能记录连接对象,不能获取 user_id,不能设置 scene
- ClientIdentity:客户端发送了身份认证后触发。此时可以从 connection.identity 获取 user_id
- ClientReady(自定义事件):客户端主动发送。此时客户端已准备好,可以安全设置 connection.scene
玩家断开连接
当玩家断线时,服务端会收到 ClientDisconnected 事件。你需要在这里做清理:从玩家列表中移除、处理掉线逻辑、存档玩家数据。
六、实战:拍卖游戏的完整通信流程
阶段一:玩家连接
1. 玩家打开游戏,引擎自动连接服务器
2. 服务端收到 ClientIdentity 事件,获取玩家的 user_id
3. 客户端创建空 Scene,设置 serverConnection.scene,发送 ClientReady
4. 服务端收到 ClientReady,设置 connection.scene,在内存中创建玩家记录
5. 服务端从云端加载该玩家的金币存档,然后推送给客户端
阶段二:匹配
1. 玩家在大厅界面选了"黑市"区域,点击匹配
2. 客户端发送 JoinQueue 事件,附带 { regionId = "black_market" }
3. 服务端的 QueueManager 把玩家加入黑市队列
4. 每帧检查:队列满 4 人 → 立即开房;超过 15 秒还没满 → AI 补位
5. 期间服务端不断推送 QueueStatus("当前 2/4 人"),客户端更新界面
阶段三:拍卖
1. QueueManager 凑齐 4 人后,通知 RoomManager 创建房间
2. RoomManager 随机挑选 3 件藏品,进入第一轮竞价
3. 服务端广播 AuctionStart,所有房间内玩家的界面切换到拍卖画面
4. 玩家点击"+100"按钮,客户端发送 PlaceBid 事件
5. 服务端验证:金额是否超过当前最高价?玩家余额够不够?
- 合法 → 更新最高出价,广播 BidUpdate 给所有人
- 不合法 → 只给出价者发 BidRejected,附带拒绝原因
6. 30 秒倒计时结束 → 服务端结算,广播 AuctionResult
阶段四:结算
1. 3 轮都打完后,服务端广播 GameEnd,附带完整历史记录和最终排名
2. 客户端展示总结画面
3. 玩家点击"返回大厅",发送 ReturnLobby 事件,回到阶段二
整个流程中,客户端从不直接修改金币数值。扣金币是服务端做的,然后告诉客户端"你现在有多少钱"。
七、房间系统:从匹配到对局的完整机制
前面的"实战流程"从玩家视角走了一遍。这一节换到服务端视角,拆解房间系统的内部运转。
玩家状态机
服务端给每个在线玩家维护一个"状态"字段,决定了这个玩家当前能做什么:
lobby(大厅)──→ queuing(排队中)──→ in_room(房间内)
↑ │ │
│ │ 取消匹配 │ 返回大厅 / 游戏结束
│ ↓ │
└────────────────────┘←────────────────────┘
三种状态:
- lobby(大厅):玩家在大厅浏览区域,可以选区域、点击匹配
- queuing(排队中):玩家在匹配队列中等待凑人,可以取消匹配
- in_room(房间内):玩家在房间里竞价,可以出价、等待结算、返回大厅
为什么需要状态?防止非法操作。服务端每收到一个远程事件,第一件事就是检查玩家状态是否匹配。
Server.lua:事件路由器
Server.lua 是服务端的"总调度"。它不直接处理游戏逻辑,而是根据事件类型把请求转发给对应模块:
- ClientIdentity
→ Server 自己处理:创建玩家记录,关联 Scene
- ClientReady → Server 自己处理:从云端加载存档,推送金币
- JoinQueue → 转发给 QueueManager:加入匹配队列
- LeaveQueue → 转发给 QueueManager:离开匹配队列
- PlaceBid → 转发给 RoomManager:处理出价
- ReturnLobby → RoomManager → Server:离开房间,状态改回 lobby
- ClientDisconnected → 两者都通知:从队列/房间中移除,存档
Server.lua 还负责每帧驱动两个子模块——在 Update 事件里依次调用 QueueManager.Tick(dt) 和 RoomManager.Tick(dt)。
QueueManager:匹配队列
QueueManager 负责"凑人"——把点了匹配按钮的玩家凑到一起,凑够了就开房间。
每个区域一条独立队列
拍卖游戏有三个区域(古玩街、拍卖行、黑市),每个区域各自维护一条队列。选了"黑市"的玩家只会和其他选"黑市"的玩家匹配,不会跨区域。
匹配的两种触发条件
- 满员:队列人数 >= 4 时,立即取出 4 人,创建房间
- 超时:等待超过 15 秒时,取出所有真人 + AI 补位到 4 人,创建房间
从队列到房间的交接
凑齐 4 人后,QueueManager 把这 4 个玩家从队列中移除,然后调用 RoomManager.CreateRoom(regionId, players) 创建房间。从这一刻起,这 4 个玩家的管理权从 QueueManager 移交给 RoomManager。
RoomManager:房间与拍卖状态机
RoomManager 是服务端最复杂的模块。每个房间是一个独立的拍卖"对局",有自己的状态、倒计时、出价记录和历史。
房间的数据结构
一个房间对象包含以下核心数据:
- id:房间唯一标识,如 "room_1"、"room_2"
- regionId:所属区域
- players:房间内的玩家表,以 connKey 为键
- items:本场拍卖的藏品列表(3 件,创建房间时随机选取)
- round:当前轮次(1~3,0 表示未开始)
- state:房间状态(见下方状态机)
- highestBid:当前最高出价金额
- highestBidder:当前最高出价者的 connKey
- timer:竞价倒计时(秒)
- history:已完成轮次的结算记录
创建房间的完整步骤
房间不是凭空出现的,它由 QueueManager 触发、RoomManager 执行,经过 5 个步骤才最终"活"起来:
第 1 步:QueueManager 凑齐人 → 调用 RoomManager.CreateRoom(regionId, 4个玩家)
第 2 步:生成房间 ID → 自增计数器拼出 "room_1"、"room_2"...(只增不减,不复用)
第 3 步:随机选取藏品 → 从该区域的 5 件藏品池中随机抽 3 件(Fisher-Yates 洗牌)
第 4 步:组装房间数据结构
谁决定了房间长什么样?完全由 RoomManager 决定。CreateRoom() 函数里有一个写死的表构造器,所有字段名、嵌套方式、初始值都在这里定义。其他模块只负责提供原材料:
- QueueManager:提供 regionId 和 4 个 playerInfo,只是传参,不决定结构
- Items:提供 3 件随机藏品的列表,只是返回数据,不决定结构
- Settings:提供 ROUNDS_PER_GAME 等常量,只是配置值,不决定结构
- RoomManager:定义全部字段、嵌套关系、初始值,是唯一的决定者
room = {
┌─────────────── 来自第 2 步 ──────────────┐
│ id = "room_1" │ ← roomIdCounter_ 自增
├─────────────── 来自第 1 步 ──────────────┤
│ regionId = "antique_street" │ ← QueueManager 传入
├─────────────── 来自第 3 步 ──────────────┤
│ items = { 翡翠扳指, 宣德炉, 铜钱串 } │ ← Items.GetRandomItems()
├─────────────── 初始值(写死)──────────────┤
│ round, state, currentItem, │
│ highestBid, highestBidder, │
│ timer, extendCount, settleTimer, │
│ history = {} │
├─────────────── 来自第 1 步(逐个注册)─────┤
│ players = { │
│ ["conn_3"] = { info=玩家A, roundGold=1000 },
│ ["conn_5"] = { info=玩家B, roundGold=1000 },
│ ... │
│ } │
└──────────────────────────────────────────┘
}
注册玩家时还做了两件事:把 state 改成 "in_room",记录 roundGold(金币快照,用于算盈亏)。
第 5 步:启动第一轮拍卖 → 房间从 idle 跳到 bidding,广播 AUCTION_START。
两个房间并行运行时的内存快照
RoomManager 内部只有一个 rooms_ 表,所有房间平铺在里面,以 roomId 为键:
rooms_ = {
┌─────────────────────────────────────────────────────────────┐
│ "room_1" │
│ regionId = "antique_street" ← 古玩街 │
│ state = "bidding" ← 正在竞价 │
│ round = 2 ← 第 2 轮(共 3 轮) │
│ items = { 青花瓷瓶, 玉扳指, 铜香炉 } │
│ highestBid = 280, highestBidder = "conn_5" │
│ timer = 12.3 ← 还剩 12.3 秒 │
│ players = { conn_3, conn_5, conn_7, ai_-1 } │
│ history = { {item="青花瓷瓶", winner="conn_3", price=350} }│
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ "room_2" │
│ regionId = "black_market" ← 黑市 │
│ state = "bidding" ← 正在竞价 │
│ round = 1 ← 第 1 轮 │
│ items = { 夜光杯, 鎏金佛像, 翡翠手镯 } │
│ highestBid = 0 ← 还没人出价 │
│ timer = 28.7 ← 刚开始 │
│ players = { conn_10, conn_11, conn_12, conn_13 } │
│ history = {} │
└─────────────────────────────────────────────────────────────┘
}
- 两个房间互不干扰。Tick(dt) 遍历所有房间,各自推进倒计时和 AI 逻辑。
- 玩家只属于一个房间。通过 playerInfo.roomId 找到对应房间,不会串房。
- 房间销毁:所有真人离开后,rooms_[roomId] = nil,Lua GC 自动回收。
房间状态机
idle ──→ bidding(30秒)──→ settling(4秒)──→ 下一轮 bidding 或 ended
四种状态:
- idle(空闲):房间刚创建,立即进入 bidding
- bidding(竞价中):玩家竞价,AI 思考出价,倒计时归零后结束
- settling(结算展示):展示本轮结果,4 秒后进入下一轮或 ended
- ended(已结束):展示总结,等待玩家离开,所有人离开后销毁
竞价验证与延时
出价验证:金额必须 > 当前最高价 + 10,且不超过玩家余额。
倒计时延时:最后 5 秒内有人出价 → +5 秒,最多延长 2 次(最长 40 秒)。
玩家中途退出
1. 从 players 表移除
2. 如果是最高出价者,清除出价记录
3. 如果没有真人了,销毁房间
八、数据怎么存?serverCloud
玩家下线后再上线,金币不能丢。引擎提供了 serverCloud API——一个只能在服务端调用的云端数据库接口。
8.1 基本操作
- Get(userId, key, callbacks):读取数据
- Set(userId, key, value):写入任意类型
- SetInt(userId, key, value):写入整数(可被排行榜排序)
- Add(userId, key, delta):整数增量(原子性)
- Delete(userId, key):删除
8.2 批量操作
serverCloud:BatchGet(uid)
:Key("gold"):Key("kills"):Key("level")
:Fetch({ ok = function(scores, iscores) ... end })
8.3 子对象
- serverCloud.money:货币系统,Cost 自动检查余额
- serverCloud.list:列表存储,适合背包道具
- serverCloud.item:道具系统,支持"使用"操作
- serverCloud.message:消息系统,适合好友赠礼
- serverCloud.quota:配额计数器,适合每日签到
8.4 事务
用 BatchCommit 保证多个操作的原子性(要么全成功,要么全回滚):
local c = serverCloud:BatchCommit("购买道具")
c:MoneyCost(uid, "gold", 100) -- 扣 100 金币
c:ListAdd(uid, "inventory", { ... }) -- 加一件道具
c:Commit() -- 原子提交
8.5 什么时候存档?
- 玩家断开连接时 → 必存
- 每 5 分钟自动存一次 → 防止服务器意外重启丢数据
- 关键操作后 → 如每轮拍卖结算后
九、容易踩的坑
1. 客户端没创建 Scene:即使纯 2D 游戏也要创建 Scene 并关联 serverConnection
2. connection.scene 赋值太早:等客户端发来 ClientReady 后再设置
3. 客户端直接改权威数据:客户端只发请求,服务端验证后再改
4. 客户端创建节点没加 LOCAL:CreateChild() 默认 REPLICATED,客户端必须加 LOCAL
5. 删除节点用了 Remove():用 Dispose() 立即生效,Remove() 依赖 GC 有延迟
6. NodeAdded 中读 Vars:此时变量还没同步,用 ScriptObject 的 DelayedStart() 代替
7. 远程事件收不到:检查是否调用了 network:RegisterRemoteEvent(eventName)
8. 远程事件数据没用 Variant:eventData["Value"] = Variant(123) 而非直接赋值
十、总结:开发多人游戏的四步流程
第一步:选模式。对局制还是常驻服?这决定了服务器的生命周期和玩家的进入方式。
第二步:定义通信协议。把所有客户端和服务端之间需要传递的消息列清楚——事件名叫什么、数据格式是什么。统一写在 Shared.lua 里,双端共享。
第三步:按功能逐个实现,每个功能双端一起写。比如做"出价"功能:先写服务端的验证和广播逻辑,再写客户端的按钮和响应,然后联调跑通。跑通一个再做下一个,不要把服务端全写完才开始写客户端。
第四步:构建测试。每次改完代码都要调用构建工具。先确认单人流程没问题,再测试多人交互。
把握住一个核心思想:服务端是裁判,客户端是选手。裁判负责规则和记分,选手负责操作和看比赛。分清职责,多人游戏就不会乱。



