ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

棋牌服务端6.6源码拆解:IOCP网络内核、存储过程与二次开发

棋牌服务端6.6源码拆解:IOCP网络内核、存储过程与二次开发

简介:网狐6.6完整源码是一套经典网络游戏平台源代码,面向具备C++基础、希望研读游戏服务端与平台架构的开发者,可用于学习二次开发、模块拆解与性能排查。源码已适配Visual Studio 2008编译,标签中的“包含内核”指向网络通信、多线程、内存管理等底层处理,也涉及数据库交互、错误处理与日志记录、常见设计模式等知识点。压缩包约52.48MB,文件总数与主要文件类型明细上游暂未提供,获取后可结合目录结构自行梳理模块。目前已有1240人学习下载,说明该版本在同类资源中具有一定关注度。读者可从中观察平台功能划分、服务端并发处理与客户端交互的实现思路,并对照调试、排错和优化过程,积累较完整的工程阅读经验。

1. 从一套只能当"黑匣子"看的老工程说起

把一个 6.6 版本的棋牌服务端完整源码包解压出来,第一眼往往不是兴奋,而是满屏红色波浪线:解决方案里十几个 vcxproj,数据库连接串指向一个根本不存在的实例名,客户端启动后卡在登录界面转圈。这套东西真正有价值的地方,恰恰是这种"老工程"里最难自己搭出来的部分——一套手写的网络内核、服务注册与命令分发、房间与桌子状态机,再配上一整套 SQL Server 存储过程。它只是个完整骨架,还没到能直接上线的实现程度;想搞懂服务端内核到底怎么写的中高级后端,和手里有个房间对战类玩法、需要一套现成骨架快速起步的开发者,是它最对口的读者。下面按"由什么组成 → 内核怎么跑 → 玩法与数据怎么落 → 怎么编译启动 → 哪里会翻车 → 怎么改"的顺序往下拆。

2. 技术底座与目录结构:这套 6.6 源码到底由哪几块拼起来

2.1 三件套选型:Win32 C++、IOCP 与 SQL Server

整套服务端的底座非常"年代感":纯 Win32 C++(无 STL 重度依赖、无 boost),网络层直接压在 Windows 完成端口(IOCP)上,业务数据全部走 SQL Server 的存储过程,客户端是 MFC/DirectUI 写的大厅加一套游戏端。这套选型放在今天看不算时髦,但它有个好处——没有框架替你兜底,每一层你都能看到原始实现。一个包从网卡到业务函数之间到底经过了几次拷贝、几个线程、几次加锁,全在源码里摆着。

内核(Kernel 目录)承担的是"操作系统之上、业务之下"的那一层:套接字引擎、会话管理、线程池、定时器、日志、内存池、服务管理器。业务服务(登录、大厅、游戏、比赛)全部以内核为基类派生,各自只关心自己的命令字。这种分层在当年是标准做法,现在看依然清晰:内核不认识"麻将"也不认识"金币",它只认识连接、包和定时器。

数据库侧用的是 SQL Server 2005 以后的版本,业务逻辑大量下沉到存储过程,C++ 侧只负责拼参数、取结果集。我一般把这种设计叫"重数据库":好处是金币、战绩这类涉及一致性的操作能靠事务兜住;坏处是所有业务规则散落在 .sql 文件里,改动时要同时盯 C++ 和存储过程两边。

提示:如果你本机只有 MySQL,别想着"顺手迁一下"。这套代码里对存储过程返回值、输出参数、@@ROWCOUNT的依赖非常密,迁移成本比换一门语言还高。

2.2 目录拆解:五层结构一眼看清职责边界

解压后不要急着打开 sln,先按目录把职责划出来,后面编译报错时才知道该去哪个目录找原因。典型结构如下(不同打包版本顺序可能有差异,层级基本一致):

Source/ Kernel/ 内核:网络引擎、会话、线程池、定时器、日志、内存池 Service/ Share/ 公共层:协议头、CMD 常量、结构体、公共工具 LogonServer/ 登录服:注册、登录、Token、账号状态 HallServer/ 大厅服:房间列表、公告、跳转、好友 GameServer/ 游戏服:桌子容器、玩法调度、结算入口 MatchServer/ 比赛服:赛程、报名、晋级 DBService/ 数据服:唯一持有数据库连接的进程 Game/ 各玩法模块(按 KindID 一个 DLL) Tools/ 配置工具、GM 工具、协议导出 DB/ Create/ 建库建表脚本 Procedure/ 存储过程 Client/ Hall/ 大厅客户端 Game/ 游戏客户端 Doc/ 协议文档、部署说明

Share这一层最容易出事。它同时被服务端和客户端引用,协议结构体、#define MDM_xxx、#define SUB_xxx这些常量只要有一边没同步,表现就是"客户端发包了、服务端没反应",而且日志里什么都不打——典型的黑匣子现场。血泪经验是:任何协议改动,先把 Share 编译成静态库,两端都重新链接一遍,再谈调试。

Game目录按玩法拆分,一个玩法一个工程、输出一个 DLL,游戏服启动时按配置去加载。这个设计决定了二次开发的入口:加新玩法不用碰内核,只要实现约定的导出函数、在大厅配置里登记 KindID,就能挂上去。

2.3 进程与端口全景:一次登录到进桌要经过谁

这套架构是多进程的,每个服务一个 exe(或注册成 Windows 服务),互相之间用内部 TCP 或共享内存通信,数据库只允许 DBService 碰。链路大致是:

进程默认端口职责上游依赖
DBService无(直连 1433)唯一持有 SQL Server 连接池,对外提供数据命令SQL Server
LogonServer8300账号注册、登录校验、发 Token、封号状态DBService
HallServer8200房间/玩法列表、公告、玩家跳转LogonServer
GameServer8100 起桌子容器、玩法调度、结算上报DBService、HallServer
MatchServer8400赛事排期、报名、淘汰晋级HallServer

具体端口以各目录下的ServiceConfig.ini或注册表配置为准,源码里默认值常常被改成别的。客户端启动后的顺序是:连登录服拿 Token → 拿服务器列表 → 连大厅服拿房间列表 → 点进某个房间时被"重定向"到对应 GameServer 的 IP 和端口。这一步的重定向是排查问题的关键分界点:卡在登录界面说明登录服有问题,卡在房间列表说明大厅服有问题,点了桌子进不去说明游戏服或 KindID 配置有问题。

3. 内核拆解:一个网络包从 IOCP 回调到业务分发的完整链路

3.1 服务对象模型:所有服务都从同一个基类派生

内核定义了一个服务基类,登录服、大厅服、游戏服全部从它继承,生命周期由内核统一驱动:读配置、创建监听、注册命令处理函数、进入消息循环、退出时释放资源。简化后的接口形态大概是这样:

// Kernel/ServiceBase.h 节选(按源码风格整理) class CServiceBase { public: // 内核在启动阶段调用:读配置、建监听、初始化数据库连接 virtual bool OnStartUp() = 0; // 内核在退出阶段调用:断开所有会话、落盘、释放资源 virtual bool OnStop() = 0; // 主命令 + 子命令两级分发,返回 false 表示不处理 virtual bool OnEvent(WORD wMainCmdID, WORD wSubCmdID) = 0; // 定时器回调,nTimerID 由内核 SetTimer 时分配 virtual bool OnTimer(UINT nTimerID) = 0; protected: IServiceManager* m_pServiceManager; // 服务管理器:查别的服务地址、发内部消息 ISocketEngine* m_pSocketEngine; // 网络引擎:监听、连接、发包 IDataBase* m_pDataBase; // 数据库代理,实际执行在 DBService };

这里最关键的设计是OnEvent的两级分发:主命令(MDM_)标识业务大类,比如登录、大厅、游戏;子命令(SUB_)标识具体动作。内核的网络线程收到完整包后,只做一件事——把wMainCmdID和wSubCmdID交给对应服务的OnEvent,其余全由业务自己判断。这样内核完全不需要知道任何业务语义,新增玩法也不用改内核一行代码。

参数上要留意OnTimer的nTimerID:它不是系统定时器句柄,而是内核内部定时器队列的索引。业务里常见的写法是SetTimer(TIMER_TABLE_CHECK, 1000),然后在OnTimer里switch(nTimerID)分发。踩过的坑是定时器 ID 到处硬编码,三个模块用了同一个值,最后只有一个回调被触发,查了半天。

3.2 网络层:完成端口、会话对象与粘包处理

IOCP 的骨架很经典:创建完成端口 → 创建工作线程(数量一般是 CPU 核数 × 2)→ 每个连接绑定一个会话对象,投递第一个WSARecv→ 工作线程里GetQueuedCompletionStatus拿结果。核心循环简化后如下:

// Kernel/SocketEngine.cpp 工作线程节选(按源码风格整理) DWORD WINAPI CSocketEngine::WorkerThread(LPVOID lpParam) { CSocketEngine* pEngine = (CSocketEngine*)lpParam; DWORD dwTrans = 0; ULONG_PTR pKey = 0; LPOVERLAPPED pOverlapped = NULL; while (true) { // 第三个参数 pKey 就是投递时绑定的会话对象指针 BOOL bOk = GetQueuedCompletionStatus(pEngine->m_hIocp, &dwTrans, &pKey, &pOverlapped, INFINITE); if (pKey == NULL) break; // 退出信号包 CSession* pSession = (CSession*)pKey; if (!bOk || dwTrans == 0) // 对端关闭或出错 { pEngine->OnSessionClose(pSession); // 注意:这里只标记,不直接 delete continue; } // 同一个 OVERLAPPED 结构,靠内部标记区分是收完成还是发完成 pSession->OnIocpCompleted(dwTrans, pOverlapped); } return 0; }

这段代码有两处必须理解到位。第一,pKey是会话指针,所以会话对象的生命周期不能由业务随便 delete,否则工作线程下一次回调就是野指针;标准做法是标记为"待关闭",由主循环统一回收。第二,dwTrans == 0不代表一定出错,但在这套实现里等价于对端关闭,直接走关闭流程即可。

收上来的是字节流,必须自己切包。协议头里带长度字段,所以处理逻辑是"够一个头就判长度,够一个包就分发,不够就等下次":

// 会话收包缓冲中的切包逻辑(简化) while (m_nRecvSize >= sizeof(TCP_Head)) { TCP_Head* pHead = (TCP_Head*)m_pRecvBuf; // 长度合法性校验:防止恶意包把缓冲区算爆 if (pHead->wPacketSize < sizeof(TCP_Head) || pHead->wPacketSize > MAX_PACKET_SIZE) { CloseSession(); // 直接踢掉,不尝试恢复 return; } if (m_nRecvSize < pHead->wPacketSize) break; // 半包,等下一次收 DispatchPacket(pHead, pHead->wPacketSize); // 交给 OnEvent 分发 m_nRecvSize -= pHead->wPacketSize; memmove(m_pRecvBuf, m_pRecvBuf + pHead->wPacketSize, m_nRecvSize); // 剩余数据前移 }

MAX_PACKET_SIZE是这套代码里少见的"安全阀",一般设 8KB 到 64KB。这里用memmove前移剩余数据是最直观的写法,连接数一多就是性能热点,二次开发时我一般会换成环形缓冲区,读写指针各走各的,省掉整块搬移。翻车的场景很典型:压测到两千连接,CPU 有大半耗在memmove上。

3.3 定时器与线程池:桌子里的"心跳"怎么跑

游戏服里每个桌子都需要周期性检查:超时踢人、托管出牌、房间空闲回收。这套代码没有给每个桌子开线程,而是一个全局定时器队列 + 一个扫描线程,到点把回调扔给业务。简化实现:

// Kernel/TimerEngine.cpp 定时器扫描(简化) void CTimerEngine::Run() { while (!m_bExit) { DWORD dwNow = GetTickCount(); // 只扫到期的,队列按触发时间有序,所以扫到第一个未到期就能跳出 while (!m_queue.empty() && m_queue.top().dwTriggerTime <= dwNow) { TimerItem item = m_queue.top(); m_queue.pop(); if (item.bRepeat) // 周期定时器:算下一次触发时间重新入队 { item.dwTriggerTime = dwNow + item.dwInterval; m_queue.push(item); } m_pService->OnTimer(item.nTimerID); // 回到业务层的统一入口 } Sleep(10); // 扫描粒度,决定定时精度 } }

Sleep(10)这个扫描粒度决定了整套定时器的精度上限,也就是说 100ms 以内的定时需求在这套实现里做不到。业务上没人会在棋牌桌子里要求毫秒级,但如果你二次开发要加"抢红包倒计时"这类玩法,就得注意别把精度压到 10ms 以内,否则会出现偶发的"明明超时了却没触发"。另一个细节是OnTimer跑在定时器线程上,回调里绝对不能做阻塞操作(比如同步等数据库返回),否则整个进程的所有定时器全卡住。

3.4 协议约定:CMD 头加结构体,改协议要动哪几个文件

协议是自定义二进制,包头是一个固定结构,后面跟着结构体或变长数据。写法上必须#pragma pack(1),否则编译器按 4 字节对齐塞填充字节,两端结构体一错位,收到的全是垃圾数据:

// Service/Share/Protocol.h 节选 #pragma pack(push, 1) // 强制 1 字节对齐,客户端必须保持一致 struct TCP_Head { WORD wPacketSize; // 整包长度(含头) WORD wMainCmdID; // 主命令,如 MDM_GP_LOGON WORD wSubCmdID; // 子命令,如 SUB_GP_LOGON_BY_ACCOUNTS DWORD dwReserved; // 保留字段,部分版本用来放校验值 }; struct CMD_GP_LogonByAccounts { TCP_Head Head; BYTE cbDeviceType; // 设备类型:PC / 安卓 / iOS WORD wDataSize; // 后面变长区总长度 // 紧跟变长数据:账号\0密码\0 }; #pragma pack(pop)

改协议时的动作顺序建议固定成三步:先在Share/Protocol.h加结构体或字段,再在Share/Command.h里加MDM_/SUB_常量,最后服务端和客户端各实现一遍处理函数。少做一步的表现就是"包发出去了,服务端静默丢弃"——因为OnEvent的 switch 里没有对应分支,默认直接用return false忽略掉了。

注意:结构体里出现std::string、CString这类带指针的类型,二进制传输必崩。协议结构体里只能出现定长整型、定长字符数组和定长结构体。

4. 玩法到数据再到启动:房间逻辑、存储过程与编译链路

4.1 玩法模块的插件式挂载:新增一个玩法动哪几个位置

游戏服本身不含任何具体玩法,它只负责桌子容器和玩家进出,具体规则由Game目录下的 DLL 提供。加载方式是标准的导出函数约定:

// Game/Ddz/GameModule.cpp 每个玩法 DLL 的入口 #include "IGameModule.h" // 游戏服 LoadLibrary 之后按名字取这个函数 extern "C" __declspec(dllexport) IGameModule* CreateGameModule() { return new CDdzGameModule(); // 一个玩法一个实现类 } // 可选:返回玩法标识,游戏服据此和配置表比对 extern "C" __declspec(dllexport) WORD GetGameKindID() { return KIND_ID_DDZ; // 必须与数据库中配置的 KindID 一致 }

挂载过程是三处对齐:DLL 文件名、GetGameKindID()返回值、数据库玩法表里的KindID和DLLName。任何一处不一致,游戏服启动时日志里只会打一行"加载模块失败",不会告诉你具体是哪一处对不上。我一般的做法是先在配置文件里把KindID和DLLName打出来,再对照 DLL 目录实际文件名核对一遍,比看日志快。

IGameModule接口通常只有几个方法:初始化、创建桌子、销毁桌子、处理玩家坐下、处理玩家动作。玩法自己的所有状态都放在IGameModule的实现类里,游戏服不感知。这个边界要守住——一旦你为了图省事把玩法状态塞进游戏服的全局结构里,后面想加第二个玩法就得复制粘贴一堆代码。

4.2 桌子状态机:等待、开始、出牌、结算

每个桌子是一个独立对象,内部有一个明确的状态流转。简化后的骨架:

// Game/Ddz/DdzTable.cpp 桌子状态机(简化) bool CDdzTable::OnUserAction(WORD wChairID, WORD wSubCmdID, void* pData) { switch (m_enStatus) { case TABLE_STATUS_FREE: // 空闲:只接受"坐下" if (wSubCmdID == SUB_GF_USER_SIT) return OnUserSit(wChairID); return false; // 其他动作一律拒绝 case TABLE_STATUS_PLAYING: // 进行中:只接受本局玩家的出牌/过牌 if (!IsPlayingChair(wChairID)) return false; return OnPlayCard(wChairID, pData); case TABLE_STATUS_END: // 结算中:拒绝一切动作,防重复结算 return false; } return false; }

这个switch就是防作弊和防重复结算的第一道闸门。常见的翻车是结算阶段没锁死状态,玩家在 200ms 的结算窗口里又发了一次出牌包,服务端当成新一局的第一手处理,直接导致牌局错乱。加上TABLE_STATUS_END分支之后,这类包会被静默丢弃,代价是玩家偶尔感觉"点了没反应",所以结算要在 200ms 内完成并且立刻把状态推回FREE。

桌子内部的定时器也挂在状态上:PLAYING状态启动出牌超时定时器,END状态启动下一局开始定时器。定时器 ID 建议直接用状态枚举偏移,避免和全局定时器冲突。

4.3 数据落地:金币变更必须走事务

所有涉及金币的操作都由 DBService 转发给 SQL Server 存储过程执行,C++ 侧只负责拼参数。核心存储过程大概长这样:

-- DB/Procedure/GSP_GS_ChangeUserScore.sql CREATE PROCEDURE GSP_GS_ChangeUserScore @UserID INT, @ChangeScore BIGINT, -- 正数加币,负数扣币 @Reason INT, -- 变更原因:游戏输赢/任务奖励/后台补发 @NewScore BIGINT OUTPUT AS BEGIN SET NOCOUNT ON BEGIN TRANSACTION UPDATE Accounts SET Score = Score + @ChangeScore WHERE UserID = @UserID AND Score + @ChangeScore >= 0 -- 扣成负数直接不更新 IF @@ROWCOUNT = 0 -- 用户不存在或余额不足 BEGIN ROLLBACK TRANSACTION RETURN -1 END INSERT INTO ScoreLog(UserID, ChangeScore, Reason, CreateTime) VALUES(@UserID, @ChangeScore, @Reason, GETDATE()) SELECT @NewScore = Score FROM Accounts WHERE UserID = @UserID COMMIT TRANSACTION RETURN 0 END

这里的WHERE Score + @ChangeScore >= 0是整套账务逻辑的核心:把"余额够不够"的判断交给数据库,一条 UPDATE 完成校验和扣减,天然避免了"先查后改"的并发漏洞。@@ROWCOUNT = 0同时覆盖了两种情况——用户不存在和余额不足,返回值统一为 -1,由业务层决定提示文案。

ScoreLog是后悔药,一定要写。线上对不上账的时候,唯一能救你的就是这张流水表:谁、什么时候、因为什么原因、变了多少。我见过为了省磁盘把日志表关掉的版本,出问题时只能靠玩家截图对账。

4.4 编译与启动:平台、字符集、建库、顺序四件事

环境上,这套代码原生是 VS2008/2010 时代的工程,用新版本 VS 打开会提示"平台工具集未安装"。有两条路:装回旧的工具集,或者在项目属性里把工具集改成 v141/v142 并重定向 SDK。后者更省事,但要逐个工程改,十几工程改完手会酸,建议写个脚本批量替换.vcxproj里的PlatformToolset。

平台一定要选Win32,这套代码里到处是int与指针互转的写法,x64 下会爆一堆截断警告甚至运行时崩溃。字符集选多字节字符集(MBCS),选 Unicode 会有一批CString与char*转换报错。批量编译命令:

# 用 MSBuild 批量编译整个服务端解决方案(Release / Win32) "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe" \ Source/Server.sln /p:Configuration=Release /p:Platform=Win32 /m /t:Rebuild

/m是并行编译,/t:Rebuild强制全量。第一次编译建议不要并行,报错顺序更清晰。编译顺序上,Share是基础库,必须先生成;如果它没生成成功,后面所有服务都会报一堆莫名其妙的 LNK2019。

数据库这一步别偷懒:先执行DB/Create下的建库脚本,再按文件名顺序执行DB/Procedure下的存储过程(脚本之间有依赖,顺序错了会报"对象不存在"),最后改各服务目录下配置文件里的连接串——服务器地址、实例名、账号密码、库名,四个字段都要对。很多人只改了 IP 没改实例名,SQL Server Express 默认是.\SQLEXPRESS,连接串里写成localhost就连不上。

启动顺序是 DBService → LogonServer → HallServer → GameServer → MatchServer → 客户端。每启动一个,看日志目录下对应文件有没有"监听成功"的字样,再启动下一个。全启动完用netstat -ano | findstr 8200这类命令确认端口真的在监听,比盯着客户端转圈猜要快得多。

5. 排查与避坑:这套老源码在本机跑不起来的五个典型现场

5.1 服务双击启动后窗口一闪而过,日志目录空的

现象是 exe 双击后瞬间退出,Log目录里连文件都没生成。原因通常不是崩溃,而是工作目录不对:注册成 Windows 服务或者用快捷方式启动时,当前目录可能是System32,而配置文件路径写的是相对路径.\ServiceConfig.ini,读不到就直接return false退出了。

解决方式是两条:一是在调试时把项目属性的"工作目录"设成$(TargetDir);二是把配置文件路径改成基于 exe 自身路径拼接的绝对路径(GetModuleFileName取目录再拼)。改完之后再加一句DebugBreak()或弹个MessageBox把读配置失败的原因打出来,以后同类问题一眼就能定位。

5.2 客户端登录一直转圈,服务端一行日志都没有

现象是客户端卡在登录界面,服务端日志毫无动静。原因有三类:端口没监听(服务没起来或防火墙拦了)、连接串错导致登录服连不上数据库、以及客户端与服务端的协议版本号不一致。第三类最隐蔽,因为包确实发到了,只是服务端在OnEvent里没匹配到分支,静默丢掉了。

排查顺序建议固定:先netstat -ano确认端口在LISTENING;再用 GM 工具或 telnet 直连端口确认能建立 TCP;最后对比Share目录下协议常量在两端的编译时间。这一步基本能定位到 90% 的"转圈"问题。

5.3 编译报一堆 LNK2019,函数明明写了

现象是链接阶段报"无法解析的外部符号",但对应的函数实现明明存在。原因通常是三处:工程平台不一致(一半 Win32 一半 x64)、字符集不一致(一个工程 MBCS 一个 Unicode 导致函数名修饰不同)、或者依赖的第三方.lib没加进链接器输入。

解决上,先在解决方案级别统一平台和字符集,再检查项目依赖顺序里Share是否排在最前。剩下的个别符号,用dumpbin /exports xxx.lib看导出表里到底有没有这个名字,比猜快。

5.4 金币扣了、战绩没写,对不上账

现象是玩家反馈"输了钱但战绩里没这局"。原因是结算被拆成了多次数据库调用:先扣金币,再写战绩,中间任何一步超时或失败,后面的就不执行了,而 C++ 侧没有补偿逻辑。

解决方式是把一局结算的所有写操作合并进同一个存储过程、同一个事务,要么全成功,要么全回滚。实在需要跨库或跨服务,就加一张"待确认结算"表,先落记录再异步补偿。这条踩过一次之后,我对任何涉及数值变更的多步操作都强制要求单事务。

5.5 跑几个小时内存涨到几个 G

现象是服务跑几小时后内存持续上涨,重启就好。原因一般是会话对象或桌子对象没释放:玩家异常断线时走的是"标记关闭"分支,而统一回收的逻辑漏掉了这类"半关闭"会话,对象一直挂在链表上。

排查方式是先看会话数量和实际在线人数是否一致,不一致就是泄漏。解决上,给会话和桌子对象加引用计数或定时扫描(每 30 秒扫一遍标记为关闭的对象强制回收),并且在压测环境连续跑 24 小时观察内存曲线,比上线后救火便宜得多。

6. 二次开发:从"能跑"到"能改"的一条实操路径

真正开始改这套代码,我建议第一个改造目标不要选"加一个新玩法",而是选加一个只读的查询命令。比如在登录服加一个SUB_GP_QUERY_SERVER_TIME,客户端发一个空包,服务端返回服务器时间戳。这个改动会强迫你把整条链路走一遍:Share/Protocol.h加结构体、Share/Command.h加常量、OnEvent加分支、客户端加发送与解析。全程不碰数据库、不碰状态机,出问题也炸不到账务,是成本最低的一次全链路演练。

走通之后再动玩法,顺序建议固定在下面这条线上:

步骤改动位置验证方式
1. 定义协议Share/Protocol.h、Share/Command.h两端重新编译,无警告
2. 实现 DLLGame/<新玩法>/,导出CreateGameModule游戏服日志出现"加载成功"
3. 登记配置玩法表、房间表里的 KindID 与 DLLName大厅能刷出该玩法入口
4. 落数据DB/Procedure新增结算存储过程手工执行一次,看流水表
5. 压测单桌模拟 3 人 100 局金币总量守恒、无内存增长

第三步是最容易被跳过的一步。很多人 DLL 编译好了、导出函数也写了,结果客户端看不到入口,就以为是客户端的问题,其实是数据库里的玩法表没登记,大厅服查出来是空列表。改配置之后记得重启大厅服,这套代码的配置基本都在启动时读一次,热加载支持得很有限。

另外两个实操技巧值得记住。第一,别在原目录上直接改,先复制一份干净源码做基线,所有改动用 git 管理,这样出问题时git diff就能看出你动了哪些文件——这套老代码没有单元测试,diff 是唯一的回归手段。第二,调试游戏服时不要每次都启动全部五个进程,把 DBService 和 GameServer 单独拉起来、客户端直连游戏服端口,能省掉一大半等待时间。

最后说个教训。我最早接手这类工程时,图快,直接在 Release 配置上改代码、编译、替换线上 exe,日志级别还开着 Error。结果一次结算逻辑改动上线后,某个边界条件下金币扣了两次,日志里什么都没有,只能靠玩家截图一条条对账。从那以后我每次改结算相关的代码,都强制走一遍"本地事务回滚测试 → 压测环境跑 1000 局对账 → 流水表抽样核对"这三步,再急也不跳。希望这些能帮你少走点弯路。

本文还有配套的精品资源,点击获取

返回列表