做一个能够移动、攻击和切换场景的单机游戏,与做一个多人在线游戏,面对的是两类完全不同的问题。
单机游戏中的角色状态通常只存在于本地内存里;多人游戏却必须回答:谁负责保存角色数据,玩家如何看到彼此,移动消息怎样广播,掉线后如何清理状态,任务、组队、公会和聊天又如何在多个客户端之间保持一致?
我在
MMORPG_Work_Last
项目中实践的,正是从 Unity 单机表现层走向客户端与服务端协作的过程。项目采用 Unity 构建客户端,以 C# 编写游戏服务端,通过 TCP 和 Protobuf 传输消息,并使用 Entity Framework 持久化账号、角色和业务数据。
它并不是可以直接商业运营的大型 MMORPG 服务端,但已经完成了一个教学项目最关键的闭环:
注册登录
→ 创建角色
→ 进入地图
→ 多人状态同步
→ 物品、装备与任务
→ 好友、组队、公会与聊天
→ 数据库存储
这篇文章不从某个 UI 或某段战斗代码讲起,而是复盘整套系统如何被拆成客户端、协议层、服务端和数据层,以及这些分层为什么重要。
先看整体架构
项目主要由四部分组成:
| 模块 | 主要职责 |
|---|---|
| Unity 客户端 | 场景、角色表现、输入、UI、音效与网络请求 |
| 公共协议与模型 | Protobuf 消息、枚举、配置数据结构和网络封包 |
| C# 游戏服务端 | 连接管理、消息分发、地图状态和业务逻辑 |
| 数据与策划配置 | Entity Framework 数据库、Excel/JSON 静态配置 |
从目录结构也可以看出这种分层:
Src/
├─ Client/ # Unity 客户端
├─ Server/GameServer/ # C# 游戏服务端
├─ Lib/Common/ # 公共数据结构与网络组件
├─ Lib/Protocol/ # Protobuf 协议
└─ Data/ # 地图、NPC、道具、任务等配置
客户端负责“看起来是什么样”,服务端负责“世界实际上是什么状态”,协议层负责双方如何表达一件事,数据库和配置则负责让状态能够保存和被配置。
这种职责划分是整个项目最重要的基础。如果所有逻辑都写在 Unity 场景脚本中,游戏可以演示,却很难真正支持多名玩家共享同一个世界。
协议层:先定义双方共同的语言
客户端和服务端之间不能直接传递 C# 对象引用,它们需要一套稳定的数据格式。项目使用 .proto 文件集中定义消息,再生成双方都能使用的 C# 类型。
协议中包含账号、角色、地图、物品、任务、好友、队伍、公会和聊天等消息。例如,一次登录请求可以抽象为:
message UserLoginRequest {
string user = 1;
string password = 2;
}
message UserLoginResponse {
RESULT result = 1;
string errormsg = 2;
NUserInfo userinfo = 3;
}
这种做法有三个价值。
第一,网络边界变得明确。客户端只需要知道自己发送什么请求、会收到什么响应,不必了解服务端内部如何查询数据库。
第二,功能扩展有统一入口。增加公会功能时,需要先定义创建、加入、审批、退出和管理消息,再分别实现客户端与服务端逻辑。
第三,消息比直接传输任意对象更容易控制。字段编号使协议可以在一定范围内兼容演进,也便于跨语言实现其他客户端或服务。
项目将所有请求放进 NetMessageRequest,所有响应放进 NetMessageResponse,再由统一的 NetMessage 作为网络传输外壳。这种“消息信封”设计让封包、解析和分发流程可以复用。
TCP 不是消息:处理粘包与拆包
TCP 提供的是连续字节流,而不是“一次发送对应一次接收”的消息边界。一个 Protobuf 消息可能被拆成多次接收,也可能与后一个消息一起到达。
项目在每个消息前增加 4 字节长度:
┌──────────────┬──────────────────────┐
│ 4 字节长度 │ Protobuf 消息内容 │
└──────────────┴──────────────────────┘
打包流程可以概括为:
Serialize(message);
Write(messageLength);
Write(messageBody);
接收端先读取长度,再判断缓冲区中是否已经包含完整消息。如果数据不足就继续等待;如果足够,则反序列化消息,并继续解析缓冲区中可能存在的下一包。
这段逻辑解决的是多人在线项目非常基础、也非常容易被忽略的问题:网络收到的是字节,业务处理的是消息,中间必须有可靠的边界协议。
客户端 NetClient 还维护了发送队列、接收缓冲区、连接重试和断开事件。Unity 每帧调用网络更新逻辑,处理收发队列,再将完整消息交给消息分发器。
消息分发:让网络层不认识具体业务
如果网络层在收到数据后写满大量 if 或 switch,随着协议增多,代码会迅速失控。
项目使用订阅式消息分发:
MessageDistributer.Instance.Subscribe<UserLoginResponse>(OnUserLogin);
MessageDistributer.Instance.Subscribe<MapEntitySyncResponse>(OnEntitySync);
服务端也是同样的模式:
MessageDistributer<Connection>.Instance
.Subscribe<UserLoginRequest>(OnLogin);
网络层只负责:
- 接收 TCP 数据。
- 解析完整 Protobuf 消息。
- 放入消息队列。
- 根据消息类型找到订阅者。
真正的业务处理由 UserService、MapService、GuildService 等服务完成。
这样,连接管理与业务逻辑被分开了。增加一种新消息时,不需要修改底层 Socket 代码,只需定义协议并注册处理函数。
服务端启动时还会创建多个消息处理线程,而地图更新运行在独立循环中。这已经具备了“网络 IO、消息处理、世界更新”分离的基本形态。
从登录到进入游戏:一次完整状态装载
项目中的登录过程不是简单返回“成功”两个字,而是逐步建立一个在线会话。

登录后由服务端返回角色列表,玩家选择角色并进入在线世界。
客户端连接服务器
→ 发送账号密码
→ 服务端查询用户
→ 返回角色列表
→ 玩家选择角色
→ 服务端创建在线 Character
→ Session 绑定角色
→ 角色进入地图
→ 返回背包、装备、任务、好友和公会数据
服务端的 NetSession 保存当前用户、角色、实体和待发送响应。它相当于一条连接在服务器中的上下文:服务器不仅知道“某个 Socket 发来消息”,还知道消息属于哪个账号、哪个角色以及哪个地图实体。
玩家进入游戏后,客户端会初始化:
- 道具管理器
- 背包管理器
- 装备管理器
- 任务管理器
- 好友管理器
- 公会管理器
这说明角色不是孤立的一张数据表,而是多个业务系统共同组成的聚合状态。
当连接断开时,服务端会移除 Session、在线角色和地图实体,并通知同一地图中的其他玩家。掉线清理看似只是收尾,却是多人在线系统保持状态一致的重要部分。
地图是多人状态同步的边界
地图系统是整个项目最接近 MMORPG 核心的部分。

两个客户端同时进入主城,用于验证角色出现、移动与聊天消息的同步链路。

主城场景中的 NPC 点位规划,用于连接地图表现、任务入口与配置数据。
服务端为每张地图维护:
- 当前在线角色
- 地图中的怪物
- 刷怪管理器
- 实体状态
- 进入与离开事件
当一个角色进入地图时,服务端会把地图中已有角色和怪物发给新玩家,同时将新玩家的信息通知给其他在线玩家。
玩家 A 进入地图
├─ A 收到:B、C、怪物列表
├─ B 收到:A 进入
└─ C 收到:A 进入
角色移动时,客户端发送实体 ID、位置、方向、速度和动作事件。服务端更新角色状态,再广播给同地图的其他连接。其他客户端收到同步消息后,通过 EntityManager 更新逻辑实体,并通知 Unity 中对应的角色对象播放移动、跳跃或骑乘表现。
这里形成了一条清晰链路:
玩家输入
→ 本地角色控制
→ EntitySync 请求
→ 服务端地图状态更新
→ 广播给其他玩家
→ 远端实体表现更新
项目当前更接近“客户端上报、服务端转发”的同步模型,适合教学和局域网演示。若要走向竞技或商业环境,服务端还需要成为更严格的权威状态来源,对移动速度、距离、技能冷却和碰撞结果进行校验,避免客户端任意修改数据。
业务系统为什么采用 Service 与 Manager
项目中的多数功能都被拆成两类对象:
Service:接收协议请求、组织响应、连接网络会话。Manager:管理内存状态和领域操作。
以任务系统为例,QuestService 负责接收接受任务和提交任务的消息;角色自己的 QuestManager 负责检查任务配置、改变任务状态、发放金币和道具奖励,并保存数据库。
公会系统中,GuildService 负责创建、申请、审批、退出和管理命令;GuildManager 负责维护服务器内存中的公会集合和名称索引;具体的 Guild 模型则处理成员与职位。
这种分法让一条业务请求具有清晰层次:
协议请求
→ Service 做入口校验和响应组织
→ Manager / Model 修改业务状态
→ DBService 持久化
→ Service 返回协议响应
好友、组队、公会和聊天还有一个共同特点:它们依赖在线 Session。

好友系统界面设计稿:角色状态、好友列表、私聊入口与主场景被组织在同一个交互层中。
例如组队邀请需要先通过角色 ID 找到目标玩家的会话;目标在线时,服务器将邀请实时转发给对方;对方接受后,再更新双方的队伍状态。私聊和公会申请也采用类似机制。
这让我真正理解了多人游戏中的一个关键概念:很多功能不是简单的数据库增删改查,而是持久状态与在线状态的协作。
数据驱动:程序写规则,配置决定内容
地图、角色、传送点、刷怪点、NPC、道具、商店、装备和任务没有全部硬编码在业务类中,而是由 DataManager 从 JSON 配置中加载。
Excel 策划表
→ 数据导出工具
→ JSON/TXT 配置
→ 客户端与服务端共同加载
代码负责解释“任务如何接受和提交”,配置负责描述“任务 ID、目标和奖励是什么”;代码负责商店购买流程,配置决定商店出售哪些物品。
这种方式带来的好处是:
- 调整数值不必修改核心代码。
- 客户端和服务端可以共享同一套静态定义。
- 策划内容与程序逻辑有了明确边界。
- 新增地图、NPC 或道具时,更接近数据生产而不是功能开发。
对于 MMORPG 这类内容量远大于代码量的项目,数据驱动不是锦上添花,而是长期维护的前提。
UI 系统:从功能窗口中抽离创建逻辑
Unity 客户端包含背包、商店、装备、任务、好友、队伍、公会、搜索和设置等大量窗口。

商店窗口读取物品配置,并通过统一 UI 管理入口完成打开、购买与关闭流程。
项目使用统一的 UIManager 保存 UI 类型与 Resources 路径的映射:
UIManager.Instance.Show<UIBag>();
UIManager.Instance.Close<UIBag>();
部分窗口关闭时只隐藏并缓存,部分窗口则直接销毁。业务代码因此不需要反复处理 Prefab 路径、实例化和生命周期细节。
这种统一入口降低了 UI 之间的耦合,也让打开窗口时播放音效、处理缓存和进行资源检查有了固定位置。
不过,当项目规模继续扩大时,Resources 同步加载会逐渐成为性能和包体管理的限制。后续可以升级为 Addressables 或自建异步资源系统,并进一步引入窗口栈、遮罩层级和资源引用计数。
这套项目最值得保留的设计
回看整个项目,我认为最有价值的不是功能数量,而是几个已经建立起来的工程边界。
1. 客户端与服务端共享协议,但不共享业务实现
双方都认识 UserLoginRequest,但登录查询和密码判断只发生在服务端。协议是契约,不是把服务端逻辑搬到客户端。
2. 在线状态与数据库状态被区分
数据库保存长期角色数据,Session 和 Manager 保存当前在线世界状态。角色离线后,地图实体应被移除,但角色等级、物品和任务仍然存在。
3. 地图承担广播边界
移动和角色进出只通知同一地图玩家,而不是向所有连接广播。这是后续做兴趣管理和分区扩展的起点。
4. 内容配置与程序规则分离
静态配置由数据表生成,程序消费配置。任务和物品系统因此能够继续增加内容,而不必为每个新对象重新写业务类。
5. 社交系统围绕实时会话工作
好友、队伍、公会和聊天不是独立页面,它们通过在线玩家、服务器转发和持久化数据真正连接起来。
从教学项目走向生产,还缺什么
这套架构已经足够展示完整链路,但如果目标变成公网运行和真实玩家运营,还需要一次系统性升级。
账号与安全
当前版本中的账号密码处理更适合本地学习环境。生产环境需要密码哈希与盐、登录限流、会话令牌、防重放、敏感日志脱敏和完整的权限校验。
网络包还应限制最大长度、验证字段范围,并对异常客户端实施断开和封禁策略。
服务端权威
客户端上报的位置、速度、物品操作和目标 ID 都不能被直接信任。服务端需要校验移动、战斗、交易、任务条件和公会权限,重要数值只能由服务端计算。
并发模型
消息队列和全局 Manager 在多线程环境下需要清晰的线程归属与同步策略。更稳妥的方案可以是地图单线程、Actor 模型或按逻辑分区串行处理,减少共享可变状态。
世界扩展
当前单进程 GameServer 适合教学。玩家量增加后,需要拆分登录服、网关、世界服、地图服和聊天服,并解决跨服通信、服务发现与状态迁移。
数据可靠性
业务操作需要事务边界、异步持久化、缓存策略和数据库索引。金币、装备、公会职位等关键变更还需要审计日志,便于追查和恢复。
可观测性与测试
除了文本日志,还需要连接数、消息延迟、在线人数、地图负载和数据库耗时等指标。协议处理、任务奖励和公会权限也应该有自动化测试。
这些不足并不否定项目价值。相反,只有先完成一个可运行闭环,才能具体看见下一阶段需要解决的工程问题。
如果重新演进一次
如果基于现有项目继续开发,我会按以下顺序推进:
- 完善 README、架构图、启动流程和数据库初始化说明。
- 升级账号安全,移除日志中的密码和敏感字段。
- 给网络包增加长度上限、协议版本和统一错误码。
- 明确每个 Manager 的线程归属,消除并发访问风险。
- 将移动和战斗改为服务端权威校验。
- 为任务、背包、装备和公会操作增加事务。
- 为地图同步增加视野范围,只广播附近实体。
- 将 Resources 资源加载迁移为异步方案。
- 建立协议、业务逻辑和数据转换的自动化测试。
- 最后再考虑拆分网关、地图服和聊天服。
这里的顺序很重要。微服务不是项目一开始就应该追求的目标。先保证协议、安全、状态一致性和可测试性,再拆分进程,才不会把单体中的问题复制到更多服务里。
结语
这个项目让我第一次从完整系统的角度理解 MMORPG。
玩家看到的是角色、地图、背包、任务和公会;程序真正处理的却是连接、协议、会话、实体、广播、配置和持久化。每一个看似简单的游戏功能,都要跨越客户端表现、网络消息、服务端规则和数据库状态。
这个项目最重要的成果,不是做出了多少窗口,而是建立了一条真正能够运行的多人在线链路:
Unity 表现
↕
Protobuf 协议
↕
C# 游戏服务端
↕
内存世界状态与数据库
当这条链路贯通之后,游戏才不再只是本地场景中的角色演示,而开始成为一个由服务器维护、允许多名玩家共同参与的在线世界。
Discussion
评论