返回文章归档
UnityC#MMORPG游戏开发

从 Unity 场景到多人在线世界:我的 C# MMORPG 项目复盘

复盘一个 MMORPG 教学项目,拆解 Unity 客户端、C# 服务端、Protobuf 协议、数据驱动配置与多人在线业务系统的完整协作链路。

做一个能够移动、攻击和切换场景的单机游戏,与做一个多人在线游戏,面对的是两类完全不同的问题。

单机游戏中的角色状态通常只存在于本地内存里;多人游戏却必须回答:谁负责保存角色数据,玩家如何看到彼此,移动消息怎样广播,掉线后如何清理状态,任务、组队、公会和聊天又如何在多个客户端之间保持一致?

我在 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 每帧调用网络更新逻辑,处理收发队列,再将完整消息交给消息分发器。

消息分发:让网络层不认识具体业务

如果网络层在收到数据后写满大量 ifswitch,随着协议增多,代码会迅速失控。

项目使用订阅式消息分发:

MessageDistributer.Instance.Subscribe<UserLoginResponse>(OnUserLogin);
MessageDistributer.Instance.Subscribe<MapEntitySyncResponse>(OnEntitySync);

服务端也是同样的模式:

MessageDistributer<Connection>.Instance
    .Subscribe<UserLoginRequest>(OnLogin);

网络层只负责:

  1. 接收 TCP 数据。
  2. 解析完整 Protobuf 消息。
  3. 放入消息队列。
  4. 根据消息类型找到订阅者。

真正的业务处理由 UserServiceMapServiceGuildService 等服务完成。

这样,连接管理与业务逻辑被分开了。增加一种新消息时,不需要修改底层 Socket 代码,只需定义协议并注册处理函数。

服务端启动时还会创建多个消息处理线程,而地图更新运行在独立循环中。这已经具备了“网络 IO、消息处理、世界更新”分离的基本形态。

从登录到进入游戏:一次完整状态装载

项目中的登录过程不是简单返回“成功”两个字,而是逐步建立一个在线会话。

角色选择与进入游戏流程

登录后由服务端返回角色列表,玩家选择角色并进入在线世界。

客户端连接服务器
  → 发送账号密码
  → 服务端查询用户
  → 返回角色列表
  → 玩家选择角色
  → 服务端创建在线 Character
  → Session 绑定角色
  → 角色进入地图
  → 返回背包、装备、任务、好友和公会数据

服务端的 NetSession 保存当前用户、角色、实体和待发送响应。它相当于一条连接在服务器中的上下文:服务器不仅知道“某个 Socket 发来消息”,还知道消息属于哪个账号、哪个角色以及哪个地图实体。

玩家进入游戏后,客户端会初始化:

  • 道具管理器
  • 背包管理器
  • 装备管理器
  • 任务管理器
  • 好友管理器
  • 公会管理器

这说明角色不是孤立的一张数据表,而是多个业务系统共同组成的聚合状态。

当连接断开时,服务端会移除 Session、在线角色和地图实体,并通知同一地图中的其他玩家。掉线清理看似只是收尾,却是多人在线系统保持状态一致的重要部分。

地图是多人状态同步的边界

地图系统是整个项目最接近 MMORPG 核心的部分。

双客户端多人场景同步演示

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

主城 NPC 与场景点位规划

主城场景中的 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 适合教学。玩家量增加后,需要拆分登录服、网关、世界服、地图服和聊天服,并解决跨服通信、服务发现与状态迁移。

数据可靠性

业务操作需要事务边界、异步持久化、缓存策略和数据库索引。金币、装备、公会职位等关键变更还需要审计日志,便于追查和恢复。

可观测性与测试

除了文本日志,还需要连接数、消息延迟、在线人数、地图负载和数据库耗时等指标。协议处理、任务奖励和公会权限也应该有自动化测试。

这些不足并不否定项目价值。相反,只有先完成一个可运行闭环,才能具体看见下一阶段需要解决的工程问题。

如果重新演进一次

如果基于现有项目继续开发,我会按以下顺序推进:

  1. 完善 README、架构图、启动流程和数据库初始化说明。
  2. 升级账号安全,移除日志中的密码和敏感字段。
  3. 给网络包增加长度上限、协议版本和统一错误码。
  4. 明确每个 Manager 的线程归属,消除并发访问风险。
  5. 将移动和战斗改为服务端权威校验。
  6. 为任务、背包、装备和公会操作增加事务。
  7. 为地图同步增加视野范围,只广播附近实体。
  8. 将 Resources 资源加载迁移为异步方案。
  9. 建立协议、业务逻辑和数据转换的自动化测试。
  10. 最后再考虑拆分网关、地图服和聊天服。

这里的顺序很重要。微服务不是项目一开始就应该追求的目标。先保证协议、安全、状态一致性和可测试性,再拆分进程,才不会把单体中的问题复制到更多服务里。

结语

这个项目让我第一次从完整系统的角度理解 MMORPG。

玩家看到的是角色、地图、背包、任务和公会;程序真正处理的却是连接、协议、会话、实体、广播、配置和持久化。每一个看似简单的游戏功能,都要跨越客户端表现、网络消息、服务端规则和数据库状态。

这个项目最重要的成果,不是做出了多少窗口,而是建立了一条真正能够运行的多人在线链路:

Unity 表现
  ↕
Protobuf 协议
  ↕
C# 游戏服务端
  ↕
内存世界状态与数据库

当这条链路贯通之后,游戏才不再只是本地场景中的角色演示,而开始成为一个由服务器维护、允许多名玩家共同参与的在线世界。

Discussion

评论

加载中
正在检查登录状态…