简介:这份资源是2020版彩虹引擎3的完整源码包,面向希望深入理解游戏引擎架构、进行二次开发或定制化改造的开发者,涵盖从初学者到有经验程序员的多个技能层级。压缩包共5299个文件,约289.43MB,以pas、dcu、dfm等Delphi工程文件为主体,辅以cpp、h、cs等C++与C#源码,以及dpr、dproj、sln等工程配置、html与txt说明文档、dll与exe可执行组件和res、ico等资源文件,完整保留了渲染、物理、音频、脚本、资源管理与输入处理等模块的工程结构。已有5043人学习关注。读者可据此研读OpenGL与DirectX双渲染后端、光照阴影与粒子系统实现、Box2D或Bullet物理封装、Lua与JavaScript脚本接口,以及场景编辑器与性能分析器等工具源码,为引擎学习、性能优化和项目定制提供可直接参考的工程范本。
1. 彩虹引擎3完整源码公布:一套可直接落地的游戏服务端工程
第一次拿到这套源码时,我下意识先翻的是目录而不是文档——因为一个服务端项目能不能跑起来,看目录结构基本就有数了。彩虹引擎3完整源码公布这件事,对做游戏服务端、尤其是做多人在线玩法后端的开发者来说,价值不在于“又多了一份源码”,而在于它把网关、逻辑、数据、脚本这几层完整摊开给你看。它适合两类人:一类是想照着跑通一套完整服务端链路的新手,另一类是准备在成熟骨架上做二次开发、加玩法模块的熟手。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份源码拆开讲。
2. 工程结构与技术选型:从目录看这套服务端怎么分层
2.1 顶层目录与各层职责
这套引擎的目录组织是典型的“分层 + 分服”结构,核心思路是把网络接入、业务逻辑、数据持久化和脚本配置拆成独立目录,避免所有代码堆在一个工程里。常见做法是下面这种布局(不同版本目录名可能略有差异,但职责划分基本一致):
rainbow-engine3/ ├── src/ │ ├── gateway/ # 网关层:连接管理、协议编解码、消息转发 │ ├── logic/ # 逻辑层:玩家、场景、战斗、任务等业务模块 │ ├── common/ # 公共库:日志、定时器、序列化、工具函数 │ └── db/ # 数据层:数据库访问封装、缓存读写 ├── script/ # 脚本目录:配置表、AI 脚本、活动脚本 ├── config/ # 运行配置:端口、数据库连接、日志级别 ├── bin/ # 编译产物与启动脚本 └── tools/ # 辅助工具:导表、协议生成、压测客户端这个结构里最关键的是gateway和logic的分离。网关只负责“收消息、解包、转发给逻辑服”,逻辑服只负责“处理业务、回包”。好处是网关可以横向扩多个实例扛连接数,逻辑服按场景或分线拆开,互不干扰。我第一次看这种结构时觉得多此一举,后来做过一个连接数上万的服才明白,网关和逻辑混在一起,一个业务卡顿就能把整个接入层拖死,血泪经验。
common目录通常放的是跨层复用的东西,比如日志宏、时间轮定时器、二进制序列化。db层一般会做一层缓存封装,避免逻辑层直接写 SQL。script目录是这套引擎比较有特色的地方——很多玩法逻辑不写在 C++ 里,而是放在脚本里热更新,改活动、调数值不用重新编译。
2.2 技术栈与构建方式
从工程形态看,这套源码是 C++ 为主、脚本为辅的组合。服务端核心用 C++ 写,保证性能和内存可控;玩法脚本用 Lua 或类似轻量脚本语言,方便策划和开发快速迭代。构建一般走 CMake 或 Makefile,依赖第三方库(网络库、数据库客户端、脚本引擎)通常放在third_party或系统路径下。
# 典型构建流程(以 CMake 为例) mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc)-DCMAKE_BUILD_TYPE=Release这个参数别省,Debug 版本跑服务端性能差一大截,压测数据完全不可信。-j$(nproc)是按 CPU 核数并行编译,源码量大时能省不少时间。编译产物一般输出到bin/目录,启动脚本也在那里。
提示:如果编译报找不到某个库,先看
CMakeLists.txt里的find_package或link_directories,多数是依赖路径没配对,而不是代码问题。
2.3 启动链路:一个进程从起来到能收包
服务端启动一般分几步:读配置 → 初始化日志 → 连数据库 → 加载脚本 → 启动监听。这套引擎的入口通常在src/gateway/main.cpp或类似位置,核心逻辑是初始化各子系统后进入事件循环。
int main(int argc, char** argv) { // 1. 解析启动参数,指定配置文件路径 std::string cfgPath = argc > 1 ? argv[1] : "config/server.conf"; // 2. 加载配置,失败直接退出,避免带病运行 if (!Config::Instance().Load(cfgPath)) { fprintf(stderr, "load config failed: %s\n", cfgPath.c_str()); return 1; } // 3. 初始化日志系统,按配置决定输出到文件还是控制台 Logger::Init(Config::Instance().Get("log.path"), Config::Instance().GetInt("log.level")); // 4. 初始化数据库连接池 if (!DbPool::Instance().Init(Config::Instance().Get("db.dsn"))) { LOG_ERROR("db init failed"); return 1; } // 5. 加载脚本与配置表 ScriptMgr::Instance().LoadAll(Config::Instance().Get("script.dir")); // 6. 启动网络监听,进入事件循环 GatewayServer server; server.Listen(Config::Instance().GetInt("gateway.port")); server.Run(); // 阻塞,直到收到退出信号 return 0; }这段代码的价值在于它把启动顺序讲清楚了:配置必须最先加载,因为后面每一步都依赖配置;日志第二,保证后续出错有记录;数据库和脚本在监听之前,避免服务起来了但依赖没准备好。server.Run()是阻塞的事件循环,内部一般是 epoll 或 IOCP 驱动,收到消息后解包、分发到对应 handler。参数上,gateway.port是监听端口,db.dsn是数据库连接串,script.dir是脚本根目录,这几个在config/server.conf里都能改。
3. 核心模块拆解:网关、逻辑与数据三层怎么协作
3.1 网关层的连接管理与协议编解码
网关层是整个服务端的入口,它要处理三件事:管理大量客户端连接、把二进制流解成完整消息、把消息路由到逻辑层。连接管理常见做法是用一个Connection对象池,每个连接带读写缓冲区,避免频繁分配内存。
// 简化的连接读取与解包逻辑 void Connection::OnReadable() { char buf[4096]; ssize_t n = recv(fd_, buf, sizeof(buf), 0); if (n <= 0) { Close(); // 对端关闭或出错,直接清理 return; } readBuf_.Append(buf, n); // 循环解包:只要缓冲区里还有完整消息就继续处理 while (true) { uint32_t msgLen = 0; if (readBuf_.Size() < sizeof(msgLen)) break; // 前 4 字节是长度字段,网络字节序 memcpy(&msgLen, readBuf_.Data(), sizeof(msgLen)); msgLen = ntohl(msgLen); // 长度异常保护,防止恶意包撑爆内存 if (msgLen == 0 || msgLen > MAX_MSG_LEN) { Close(); return; } if (readBuf_.Size() < sizeof(msgLen) + msgLen) break; // 取出完整消息体,交给上层分发 std::string body(readBuf_.Data() + sizeof(msgLen), msgLen); readBuf_.Consume(sizeof(msgLen) + msgLen); Dispatch(body); } }这里有两个容易翻车的点。一是长度字段的字节序,发送方和接收方必须约定一致,否则解出来的长度是天文数字,直接触发保护断开。二是MAX_MSG_LEN一定要设,不设的话一个构造的超大长度包就能让服务端分配巨量内存。Dispatch里一般按消息 ID 查 handler 表,找到对应处理函数后投递到逻辑线程,网关线程本身不做业务计算,保证接入层不被业务拖慢。
3.2 逻辑层的模块注册与消息分发
逻辑层是业务核心,玩家登录、移动、战斗、任务都在这里。这套引擎的模块组织方式通常是“每个玩法一个 Module 类,启动时注册到消息分发表”。这样加新玩法只需要新增一个模块文件,不用改分发核心。
// 模块基类与注册宏 class ModuleBase { public: virtual ~ModuleBase() = default; virtual void OnMessage(uint32_t msgId, const std::string& body) = 0; }; // 全局模块注册表 class ModuleMgr { public: static ModuleMgr& Instance() { static ModuleMgr inst; return inst; } void Register(uint32_t msgId, ModuleBase* mod) { handlers_[msgId] = mod; } void Dispatch(uint32_t msgId, const std::string& body) { auto it = handlers_.find(msgId); if (it != handlers_.end()) { it->second->OnMessage(msgId, body); } } private: std::unordered_map<uint32_t, ModuleBase*> handlers_; }; // 具体玩法模块:玩家模块 class PlayerModule : public ModuleBase { public: void OnMessage(uint32_t msgId, const std::string& body) override { switch (msgId) { case MSG_LOGIN: HandleLogin(body); break; case MSG_MOVE: HandleMove(body); break; default: break; } } private: void HandleLogin(const std::string& body); void HandleMove(const std::string& body); };Register把消息 ID 和模块实例绑定,Dispatch按 ID 查表调用。这种设计的扩展性很好,加一个背包模块就写一个BagModule,在初始化时Register(MSG_BAG_XXX, &bagModule)即可。参数上,msgId是协议号,body是消息体,具体解析在各Handle函数里做。注意模块实例的生命周期要管好,一般用静态对象或由管理器统一持有,别在栈上创建后注册指针,否则函数返回就悬空了。
3.3 数据层的缓存与落库策略
数据层要解决的核心问题是“读写快”和“不丢数据”之间的平衡。常见做法是玩家数据登录时从数据库加载到内存,操作改内存,定时或关键节点落库。这套引擎的db目录一般会封装一个PlayerData结构和对应的Save接口。
// 玩家数据缓存与异步落库 class PlayerDataMgr { public: // 登录时加载,找不到则新建 std::shared_ptr<PlayerData> Load(uint64_t uid) { auto it = cache_.find(uid); if (it != cache_.end()) return it->second; auto data = std::make_shared<PlayerData>(); if (!DbPool::Instance().QueryPlayer(uid, *data)) { ># 安装基础构建工具 sudo apt-get update sudo apt-get install -y build-essential cmake git # 安装数据库客户端开发库(以 MySQL 为例) sudo apt-get install -y libmysqlclient-dev # 安装脚本引擎开发库(以 Lua 为例) sudo apt-get install -y liblua5.3-dev装完用cmake --version和g++ --version确认版本。这里有个坑:不同发行版的库名不一样,liblua5.3-dev在有些系统上叫lua5.3-devel,找不到就先用apt-cache search lua查一下实际包名。数据库同理,MariaDB 和 MySQL 的开发库名不同,但接口基本兼容。
4.2 配置文件逐项说明
配置是启动成败的关键,config/server.conf里每一项都要对得上实际环境。常见配置项如下:
| 配置项 | 含义 | 常见取值 | 注意事项 |
|---|---|---|---|
| gateway.port | 网关监听端口 | 7001 | 被占用会启动失败 |
| logic.thread_num | 逻辑线程数 | 4 | 按 CPU 核数调,别超过物理核 |
| db.dsn | 数据库连接串 | host=127.0.0.1;port=3306;db=game | 库要先建好 |
| db.pool_size | 连接池大小 | 8 | 太小高并发会排队 |
| script.dir | 脚本根目录 | ./script | 相对路径以启动目录为准 |
| log.level | 日志级别 | info | 排查时临时调 debug |
db.dsn里的库必须先手动建好,服务端一般不会自动建库。script.dir用相对路径时,启动脚本里要先cd到正确目录,否则加载不到脚本,报一堆“file not found”。我第一次跑就栽在这,日志里全是脚本加载失败,查了半天才发现是工作目录不对。
4.3 首次启动与连通性验证
配置改好后,按“先起数据库 → 再起逻辑服 → 最后起网关”的顺序启动。很多引擎支持单进程同时起网关和逻辑,但分步起更容易定位问题。
# 1. 确认数据库可连 mysql -h 127.0.0.1 -P 3306 -u root -p -e "show databases;" # 2. 启动服务端(前台运行,方便看日志) cd bin ./rainbow_server ../config/server.conf # 3. 另开终端,用工具或客户端连网关端口 telnet 127.0.0.1 7001前台启动能看到实时日志,确认出现“gateway listening on 7001”“logic thread started”这类输出,说明各子系统起来了。telnet能连上端口说明网络层通了,但业务是否正常还要用配套客户端或压测工具发一条登录消息验证。如果连不上,先查防火墙和端口占用(ss -lntp | grep 7001),再看日志里有没有绑定失败的错误。
5. 避坑与排查:跑这套源码最容易踩的五个坑
5.1 编译通过但启动即崩
现象是编译没报错,一运行就段错误或直接退出。原因多半是配置没加载成功却继续往下走,或者某个全局对象初始化顺序问题。解决方法是看 core dump 或用gdb ./rainbow_server跑一遍,定位崩在哪一行;同时确认main里每一步初始化失败都有返回值检查,别让程序带病运行。
5.2 客户端连上就断
现象是 telnet 能连,但游戏客户端一登录就断开。原因通常是协议长度字段或字节序对不上,网关解包失败触发保护性关闭。解决方法是抓包对比客户端和服务端的消息头,确认长度字段是 4 字节还是 2 字节、大端还是小端,以及MAX_MSG_LEN是否设得太小把正常包也拦了。
5.3 脚本加载一堆 file not found
现象是启动日志刷屏“script load failed”。原因是工作目录不对或script.dir配错。解决方法是启动前pwd确认当前目录,把script.dir改成绝对路径先跑通,再换回相对路径并确保启动脚本里cd到位。
5.4 数据库连接池耗尽
现象是运行一段时间后所有请求卡住,日志报“no available connection”。原因是连接池太小或某处查询没释放连接。解决方法是先调大db.pool_size应急,再排查代码里是否有Query后忘记归还连接的路径,连接池封装一般要保证 RAII 式自动归还。
5.5 压测时 CPU 飙满但吞吐上不去
现象是并发一高 CPU 就 100%,但消息处理量没涨。原因常见于网关线程和逻辑线程锁竞争,或者日志在热路径上同步写文件。解决方法是把日志改成异步队列,检查分发链路上有没有不必要的全局锁,必要时把网关和逻辑拆成独立进程部署。
6. 二次开发:加一个玩法模块并验证的完整套路
二次开发最典型的动作是加一个新玩法模块,比如“每日签到”。这套引擎的模块化设计让这件事有固定套路,我一般按四步走:定义协议 → 写模块 → 注册 → 验证。
先定义消息 ID 和消息体结构,放在协议头文件里:
// protocol/sign.h enum { MSG_SIGN_QUERY = 0x2001, // 查询签到状态 MSG_SIGN_DO = 0x2002, // 执行签到 }; // 签到请求体:无额外参数 // 签到回包体:结果码 + 连续签到天数 struct SignResp { int32_t code; // 0 成功,非 0 失败 int32_t continueDays; };然后写模块,继承ModuleBase,在OnMessage里按消息 ID 分支处理。签到逻辑要读玩家数据、判断今天是否已签、更新连续天数、发奖励,最后MarkDirty标记落库。这里的关键是把“判断”和“发奖”放在同一个事务性流程里,避免判断通过但发奖失败导致状态不一致。
class SignModule : public ModuleBase { public: void OnMessage(uint32_t msgId, const std::string& body) override { switch (msgId) { case MSG_SIGN_QUERY: HandleQuery(body); break; case MSG_SIGN_DO: HandleDo(body); break; default: break; } } private: void HandleDo(const std::string& body) { uint64_t uid = GetCurrentUid(); // 从会话上下文取 auto data = PlayerDataMgr::Instance().Load(uid); int32_t today = GetToday(); // 形如 20240101 if (data->lastSignDay == today) { SendSignResp(uid, 1, style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />