《妖灵志》多人社交系统开发实录:从同屏到私聊的完整实现
精华修改于04/2382 浏览开发心得
一个人做 MMO 社交系统到底有多难?这篇帖子记录了我在《妖灵志》中实现多人同屏、世界聊天、好友系统、私聊功能的完整过程,包括踩过的坑和最终的架构方案。


先看效果
《妖灵志》是一款回合制 MMO 手游,玩家可以在多张地图自由探索、抓妖、战斗。社交系统是游戏的核心体验之一,目前已经实现了:
多人同屏 — 同一地图的玩家实时显示,走动流畅不卡顿
世界聊天 — 多频道聊天(世界、系统、传闻、帮派、跨服)
好友系统 — 搜索加好友、查看在线状态和角色信息、删除好友
好友私聊 — 一对一聊天、未读消息提醒、聊天记录
下面逐个拆解实现思路。


一、项目架构总览
整个多人系统采用 C/S 架构,服务端为常驻服(Persistent World),代码结构如下:
scripts/
├── main.lua # 客户端入口
├── server_main.lua # 服务端入口
├── network/
│ ├── Client.lua # 客户端网络逻辑
│ ├── Server.lua # 服务端核心逻辑
│ ├── MultiplayerSync.lua # 玩家状态同步
│ ├── Shared.lua # 共享常量和工具
│ └── SaveEvents.lua # 网络事件定义
├── game/
│ ├── ChatSystem.lua # 聊天数据管理
│ ├── FriendSystem.lua # 好友数据管理
│ └── ...
└── ui/
├── ChatOverlay.lua # 聊天界面
├── FriendOverlay.lua # 好友界面
└── ...
网络通信通过 RemoteEvent 实现,一共定义了 17 个事件:
| 类别 | 事件 |
|------|------|
| 玩家同步 | SYNCPLAYERSTATE, SYNCWORLDSTATE, PLAYERJOINEDMAP, PLAYERLEFTMAP |
| 聊天 | CHATSEND, CHATBROADCAST |
| 好友 | FRIENDREQUESTSEND/RECV, FRIENDACCEPTSEND/RECV |
| 私聊 | WHISPERSEND, WHISPERRECV |
| 存档 | SAVECHARACTERS, LOADRESULT, SAVERESULT |
| 管理 | GMACTION, GMACTIONRESULT |
所有事件集中定义在 SaveEvents.lua,好处是客户端和服务端共享同一份事件名,改名不怕漏。


二、多人同屏同步
核心思路
客户端每 100ms(10Hz) 上传一次自己的位置、朝向、所在地图等状态给服务端。服务端收到后按地图分组,只把同一张地图里其他玩家的数据广播回来。
客户端 A 上传状态
↓
服务端收集
↓
按地图分组广播
↓
客户端 B 收到 A 的位置 → 插值渲染
同步的数据
每次同步只传必要字段,控制包体大小:
lua
{
px, py, -- 像素坐标(精确到 0.1)
dir, -- 朝向(8 方向:s/se/e/ne/n/nw/w/sw)
scene, -- 当前地图名
charImgIdx, -- 角色外观索引
charName, -- 角色名
level, -- 等级
displayUID, -- 6 位脱敏 UID
factionId, -- 门派 ID
branchId -- 分支 ID
}
带宽优化:服务端在广播前会把坐标取一位小数 math.floor(x * 10) / 10,减少无意义的精度传输。
客户端插值
收到其他玩家的目标位置后,不是直接跳过去,而是用 Lerp 插值平滑移动:
```lua
local lerpSpeed = 8.0
local dx = rp.targetPX - rp.px
local dy = rp.targetPY - rp.py
local dist = math.sqrt(dx * dx + dy * dy)
if dist > 1 then
-- 还没到目标位置,平滑移动
rp.px = rp.px + dx * math.min(lerpSpeed * dt, 1.0)
rp.py = rp.py + dy * math.min(lerpSpeed * dt, 1.0)
CharacterAnimator.SetState(rp.animator, "walk")
else
-- 到了,停下来
rp.px = rp.targetPX
rp.py = rp.targetPY
CharacterAnimator.SetState(rp.animator, "idle")
end
```
lerpSpeed = 8.0 是反复调出来的值——太小会有明显拖影,太大又会出现瞬移感。
视觉区分
为了让玩家一眼区分自己和别人:
自己:使用自定义名字颜色
其他玩家:绿色名字标签 {140, 230, 180},脚下绿色阴影圆圈
场景分组
服务端按地图名分组广播,同一张地图才能看到对方。这样做的好处:
节省带宽:不在同一张地图的玩家互不干扰
逻辑清晰:玩家切换地图时自动从旧地图消失、在新地图出现
```lua
-- 服务端:按地图分组
local sceneGroups = {}
for connKey, pState in pairs(playerStates_) do
local scene = pState.state.scene
if not sceneGroups[scene] then
sceneGroups[scene] = {}
end
table.insert(sceneGroups[scene], { connKey = connKey, state = pState.state })
end
-- 只广播给同场景的其他玩家
for sceneName, players in pairs(sceneGroups) do
if #players >= 2 then
for i, p in ipairs(players) do
local others = {}
for j, q in ipairs(players) do
if i ~= j then
table.insert(others, q.state)
end
end
-- 发给玩家 i
end
end
end
```


三、世界聊天系统
频道设计
聊天系统支持 5 个频道,其中世界和帮派可发言,系统和传闻只读:
| 频道 | 说明 | 可发言 |
|------|------|--------|
| 世界 | 全服广播 | 是 |
| 系统 | 系统公告、战斗日志 | 否 |
| 传闻 | 服务器事件通知 | 否 |
| 帮派 | 帮派内部聊天 | 是(预留) |
| 跨服 | 跨服聊天 | 是(预留) |
消息类型与颜色编码
不同来源的消息用不同颜色显示,一眼就能分清:
lua
MSG_COLORS = {
system = { 255, 220, 100 }, -- 金色:系统消息
whisper = { 100, 220, 255 }, -- 天蓝:私聊
world = { 200, 255, 200 }, -- 浅绿:世界聊天
battle = { 255, 140, 100 }, -- 橙红:战斗信息
}
频道过滤
每个频道标签页会聚合相关的消息类型:
lua
CHANNEL_FILTER = {
world = { "world", "player", "npc" },
system = { "system", "battle" },
}
切到"世界"标签只看世界频道的聊天,切到"系统"只看系统公告和战斗日志。
消息流转
玩家 A 发消息 → CHAT_SEND → 服务端
↓
遍历所有在线玩家(排除发送者)
↓
CHAT_BROADCAST → 玩家 B, C, D...
服务端代码很直白:
lua
function HandleChatSend(eventType, eventData)
-- 广播给除发送者外的所有人
for ck, conn in pairs(serverConnections_) do
if ck ~= connKey then
conn:SendRemoteEvent(EVENTS.CHAT_BROADCAST, true, vm)
end
end
end
消息上限
客户端最多保存 100 条消息,超过自动淘汰最旧的。聊天记录不做持久化存储——重新登录就清空,这对于世界聊天来说完全够用。


四、好友系统
功能列表
1、搜索好友:支持按角色名或 6 位 UID 搜索
2、发送/接受/拒绝好友请求
3、好友列表:按在线状态排序(在线 > 忙碌 > 离线),同状态按等级排
4、查看好友信息:等级、门派、最后在线时间
5、删除好友:二次确认弹窗
上限
lua
MAX_FRIENDS = 50 -- 最多 50 个好友
好友数据结构
lua
{
name, -- 角色名
level, -- 等级
status, -- 状态:online / busy / offline
lastOnline, -- 最后在线时间
charImgIdx, -- 角色外观
uid, -- 用户 ID
displayUID, -- 6 位脱敏 UID
factionId, -- 门派
branchId, -- 分支
faction -- 门派名称
}
好友请求流程
玩家 A 发送好友请求
↓ FRIEND_REQUEST_SEND
服务端查找目标玩家
↓ FRIEND_REQUEST_RECV
玩家 B 收到请求弹窗
↓ 接受 → FRIEND_ACCEPT_SEND
服务端通知玩家 A
↓ FRIEND_ACCEPT_RECV
双方互相添加
好友关系是双向的,A 加了 B,B 的列表里也会有 A。
在线状态排序
好友列表不是乱序的,按状态 + 等级双重排序:
lua
local order = { online = 1, busy = 2, offline = 3 }
table.sort(friends_, function(a, b)
local oa = order[a.status] or 3
local ob = order[b.status] or 3
if oa ~= ob then return oa < ob end
return a.level > b.level
end)
在线的好友永远排在最前面,方便快速找到能一起玩的人。
界面设计
好友界面分 4 个标签页:
| 标签 | 功能 |
|------|------|
| 最近联系 | 最近私聊过的好友,按时间排序 |
| 好友列表 | 全部好友,按状态排序 |
| 好友请求 | 待处理的请求 |
| 添加好友 | 搜索框 + 搜索结果 |


五、好友私聊
消息存储
每个好友独立维护一份聊天记录,最多保存 50 条:
```lua
MAXPRIVATEMSGS = 50
-- 数据结构
privateMessages_ = {
["好友名A"] = {
{ sender = "我", content = "在干嘛", time = 1700000000 },
{ sender = "好友名A", content = "打副本呢", time = 1700000005 },
},
}
```
消息路由
私聊走的是点对点转发,不是广播:
玩家 A → WHISPER_SEND → 服务端
↓
在 playerStates_ 中查找目标玩家
↓
WHISPER_RECV → 玩家 B(仅目标玩家)
服务端只做转发,不存储私聊内容。
未读消息追踪
这是私聊体验的关键细节:
1、收到私聊时,如果聊天窗口没打开,未读计数 +1
2、好友列表里显示红色未读角标
3、好友按钮上显示总未读数
4、打开和某个好友的聊天窗口后,该好友的未读清零
```lua
-- 收到私聊
function FriendSystem.AddPrivateMessage(friendName, sender, content)
-- 存储消息
-- 更新最近联系人时间戳
end
-- 未读管理
FriendSystem.AddUnread(senderName) -- +1
FriendSystem.ClearUnread(friendName) -- 清零
FriendSystem_.GetTotalUnread() -- 总数(用于角标)
```
聊天 UI
采用经典的气泡聊天布局:
自己的消息:靠右,蓝色背景
对方的消息:靠左,深色背景
底部输入框 + 发送按钮


六、屏蔽与管理
玩家屏蔽
可以屏蔽指定玩家,屏蔽后:
1、世界聊天里看不到他的消息
2、屏蔽列表持久化到云存档
GM 管理面板
服务端支持 GM 操作,通过白名单控制权限:
| 操作 | 说明 |
|------|------|
| 禁言 | 指定时长,到期自动解除 |
| 封禁 | 踢出并禁止登录 |
| 踢出 | 强制断开连接 |
禁言和封禁都是基于时间戳的,到期后自动清除,不需要手动解封:
```lua
mutedUsers_[userId] = os.time() + duration * 60
-- 检查时自动清理
if muteEnd and os.time() >= muteEnd then
mutedUsers_[userId] = nil
end
```


七、数据持久化
玩家数据(包括好友列表)通过 serverCloud
API 持久化到云端:
lua
CLOUD_KEYS = {
CHARACTERS = "characters_v1",
LAST_PLAYED = "last_played_v1",
AUDIO = "audio_settings_v1",
}
Key 带 _v1 版本后缀,方便后续数据结构升级时做向后兼容。
登录时服务端批量拉取所有存档:
lua
serverCloud:BatchGet(userId)
:Key(KEYS.CHARACTERS)
:Key(KEYS.LAST_PLAYED)
:Key(KEYS.AUDIO)
:Fetch({
ok = function(values)
-- 一次性发给客户端
end
})


八、踩过的坑
1. 网络节点必须用 Dispose()
多人场景中创建的网络节点,销毁时不能用 Remove(),必须用 Dispose(),否则会内存泄漏。这个问题调了很久才发现。
2. 同步频率的平衡
最初用 20Hz 同步,带宽撑不住。后来降到 10Hz,配合客户端插值,视觉效果几乎没有差异,但带宽直接砍半。
3. 坐标精度
浮点数直接传输会有很多无意义的小数位,math.floor(x * 10) / 10 取一位小数就够了,包体更小。
4. UID 脱敏
不能把真实用户 ID 暴露给其他玩家,所以做了一层 6 位哈希映射。用 XOR 混淆 + 取模生成,简单但够用。


九、总结
整个社交系统的代码量分布:
| 模块 | 文件 | 职责 |
|------|------|------|
| MultiplayerSync.lua | 同步逻辑 | 上传/接收/插值/渲染 |
| ChatSystem.lua | 聊天数据 | 消息存储、频道过滤 |
| ChatOverlay.lua | 聊天 UI | 频道标签、消息列表、输入框 |
| FriendSystem.lua | 好友数据 | 好友 CRUD、私聊记录、未读追踪 |
| FriendOverlay.lua | 好友 UI | 列表、搜索、聊天气泡 |
| server_main.lua | 服务端 | 广播、转发、GM、云存档 |
| SaveEvents.lua | 事件定义 | 17 个网络事件 |
几个关键的设计决策:
1. 10Hz 同步 + 客户端插值 — 带宽和体验的最佳平衡点
2. 场景分组广播 — 不在同一张地图就不同步,大幅节省带宽
3. 事件集中定义 — 客户端和服务端共享事件名,减少出错
4. 依赖注入 — 每个模块通过 `Init(deps)` 注入依赖,方便测试和替换
5. 时间戳自动过期 — 禁言、封禁到期自动清除,不需要定时任务
如果你也在做多人游戏,希望这篇帖子能给你一些参考。有问题欢迎评论区讨论。


