下载 App

《妖灵志》多人社交系统开发实录:从同屏到私聊的完整实现

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

先看效果

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

一、项目架构总览

整个多人系统采用 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,好处是客户端和服务端共享同一份事件名,改名不怕漏。
horizontal linehorizontal line

二、多人同屏同步

核心思路

客户端每 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
```
horizontal linehorizontal line

三、世界聊天系统

频道设计

聊天系统支持 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 条消息,超过自动淘汰最旧的。聊天记录不做持久化存储——重新登录就清空,这对于世界聊天来说完全够用。
horizontal linehorizontal line

四、好友系统

功能列表

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 个标签页:
| 标签 | 功能 |
|------|------|
| 最近联系 | 最近私聊过的好友,按时间排序 |
| 好友列表 | 全部好友,按状态排序 |
| 好友请求 | 待处理的请求 |
| 添加好友 | 搜索框 + 搜索结果 |
horizontal linehorizontal line

五、好友私聊

消息存储

每个好友独立维护一份聊天记录,最多保存 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

采用经典的气泡聊天布局:
自己的消息:靠右,蓝色背景
对方的消息:靠左,深色背景
底部输入框 + 发送按钮
horizontal linehorizontal line

六、屏蔽与管理

玩家屏蔽

可以屏蔽指定玩家,屏蔽后:
1、世界聊天里看不到他的消息
2、屏蔽列表持久化到云存档

GM 管理面板

服务端支持 GM 操作,通过白名单控制权限:
| 操作 | 说明 |
|------|------|
| 禁言 | 指定时长,到期自动解除 |
| 封禁 | 踢出并禁止登录 |
| 踢出 | 强制断开连接 |
禁言和封禁都是基于时间戳的,到期后自动清除,不需要手动解封:
```lua
mutedUsers_[userId] = os.time() + duration * 60
-- 检查时自动清理
if muteEnd and os.time() >= muteEnd then
    mutedUsers_[userId] = nil
end
```
horizontal linehorizontal line

七、数据持久化

玩家数据(包括好友列表)通过 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
    })
horizontal linehorizontal line

八、踩过的坑

1. 网络节点必须用 Dispose()

多人场景中创建的网络节点,销毁时不能用 Remove(),必须用 Dispose(),否则会内存泄漏。这个问题调了很久才发现。

2. 同步频率的平衡

最初用 20Hz 同步,带宽撑不住。后来降到 10Hz,配合客户端插值,视觉效果几乎没有差异,但带宽直接砍半。

3. 坐标精度

浮点数直接传输会有很多无意义的小数位,math.floor(x * 10) / 10 取一位小数就够了,包体更小。

4. UID 脱敏

不能把真实用户 ID 暴露给其他玩家,所以做了一层 6 位哈希映射。用 XOR 混淆 + 取模生成,简单但够用。
horizontal linehorizontal line

九、总结

整个社交系统的代码量分布:
| 模块 | 文件 | 职责 |
|------|------|------|
| 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. 时间戳自动过期 — 禁言、封禁到期自动清除,不需要定时任务
如果你也在做多人游戏,希望这篇帖子能给你一些参考。有问题欢迎评论区讨论。
猜你想搜
taptap 制造多人同屏实现
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
82 赞
23 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
126 赞
44 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
浅记《阿方的旅程》开发历程截图
浅记《阿方的旅程》开发历程
预计阅读时长10分钟,欢迎您先品鉴游戏,再回来看这篇“流水账” 这是我第一次实际接触“用自然语言制作游戏”,也是我第一次接触游戏开发工作,所以一切都是从零开始,跌跌撞撞,懵懵懂懂。[表情_豹哭] 我是通过获得的本次开发资格,6月22日晚,在微信公众号收到了测试资格的消息,最开始公布主题“跳跃”的时候,脑子里最先想到的就是几款非常经典的游戏「马里奥」「Flappy Bird」「Doodle Jump
10 赞
1 回复
01:29
官方阅,申请积分计划2,希望官方继续支持我完成这款游戏,截图
官方阅,申请积分计划2,希望官方继续支持我完成这款游戏,
视频内容只是一部分, 本游戏耗时3个月,目前完成进度百分之80, 是生存游戏,进入修仙界,走剧情找到宗门加入, 这时游戏才是真正开始, 提升实力境界后就可以变强,获取功法法术法宝, @视频内是游戏里挑战妖族入侵一种主流玩法,游戏就是围绕着这个来进行, 还有很多地方可以战斗,获得资源修炼变强,比如去藏书阁学习功法, 去法宝楼抽法宝,每一个宗门数十个法宝抽取 去修仙界探索,获得各自的资源,可能遇见妖兽
1 赞
3 回复
安史之乱爆发选择效忠大唐,还是助燕称帝?截图
安史之乱爆发选择效忠大唐,还是助燕称帝?
没有固定的任务线!你可以选择协助大唐勤王,收复河山;也可以加入安史叛军,踏平长安;甚至可以招兵买马,割据一方,成就霸业! 招募兵种、整备战马、收集天下奇珍。步、骑、弓兵种克制,地形、士气、补给缺一不可,真实还原古代战争谋略。 从长安到幽州,大地图自由探索,真实模拟唐朝地理与势力版图。 如果你厌倦了无脑推图,如果你向往真实的乱世求生,欢迎来体验这款硬核策略RPG!求大佬轻喷,给个关注!#安史之乱 #
1 赞
【Tap make 塔拉拉之立项干货】萌新看过来!截图
【Tap make 塔拉拉之立项干货】萌新看过来!
题外话:本人也是tap make 使用者之一。塔啦啦忠实信徒、天哥忠实拥护者、八年游戏策划经验的低级策划。但是!我已经做出来一款5000+热度、且稳定可更新的框架、并且还在持续产出更多游戏内容的橘猫! 这几天正好在开新坑。我是3月初加入的tapmake大家庭。两周时间于3月25日将这个作品上架。当时的全凭感觉摸索。如今,自己也是一个有自己项目的小菜鸡了。今天,就来分享一下,如何高效的制作一款高大上
投票精华
69 赞
24 回复
00:37
【积分申请】《最后的黎明》全球进化,游历全球,活下去!截图
【积分申请】《最后的黎明》全球进化,游历全球,活下去!
《最后的黎明》 是一款 2D 像素风、横版、末世开放探索 + 轻战斗的冒险游戏。参考《僵尸毁灭工程》的建筑搜刮与风险玩法,《死亡细胞》的战斗手感,《守墓人》《去月球》的叙事氛围。 世界观 灾难过后,文明陨落。 城市化为废墟,天空常年灰蒙,只有地平线尽头透出一线极淡的暖金色黎明。玩家扮演一名饱经沧桑的幸存者,破旧军绿外套、脏污绷带、单边护目镜、缠布的铁管武器,独自深入这座沉睡的城市,追寻那道光。 核
1 赞
1 回复
01:07
我给制造开发了一个半自动的开发工作流截图
我给制造开发了一个半自动的开发工作流
随着多模态模型识图能力增强,尝试一下AI 是否能自己修复bug,自己检查。 1,基于画面修复代码 2,点击TapTap制造 菜单,切换屏幕显示 3,截图验证 刚跑通一个小闭环,给大家分享一下 随着模型能力增强,Token费用走低,这种半自动开发是未来常态了
1 赞
2 回复
游戏自荐《少女终焉契约》截图
游戏自荐《少女终焉契约》
之前玩放置类型的rpg游戏的时候我就在想,现在游戏的操作有的太过于繁琐了,有没有一款休闲一点的,想上线就上线,离线的时候角色能自动行动的游戏呢?感谢TapTap制造,我按照我的想法开发出了现在这款游戏。 一、故事背景和UI 游戏的故事背景是传统的异世界穿越故事,玩家被女神召唤到异世界,随后组建工会,招募美少女,打怪升级探索世界。所以我选择了更加二次元萌系画风的UI风格,主页为各个主要的功能,点开来
8 赞
3 回复