简介:面向大话西游2.0.78版本的LUA脚本引擎源码包,适合熟悉C/C++、需为老版本游戏接入或调试Lua4脚本的开发者参考。压缩包共123个文件、约193KB,以34个c源文件与26个h头文件构成解释器与编译器的核心实现,另有25个lua脚本用于测试与示例,并附makefile、vcproj/sln工程文件及readme、history等说明文档,可在Visual Studio 2005环境下组织编译。需注意编译2.0.78版时应将src\lopcodes-78.h改名为lopcodes.h。内容源自Lua官方4.x分支(Copyright 1994-2000 TeCGraf, PUC-Rio),保留原始版权与官网信息。已有3403人学习下载,读者可据此了解虚拟机指令、词法语法分析与标准库的组织方式,对照lcode、lvm、lparser等模块梳理引擎结构,为脚本接口改造与版本适配提供可参考的完整工程骨架。
1. 从一次停机两小时的发版说起:这套 LUA 脚本引擎到底替谁干活
早年间在某回合制项目里改一个活动奖励的数值,流程是这样的:改 C++ 常量、重新编译整个服务端、等发版窗口、全服停机、重启、验数据,前后两个小时。而这类改动一个月要来十几次。后来玩法层整体挪到了 LUA 脚本引擎里,同一个改动变成保存文件、敲一条 reload 命令、盯一眼日志,几秒钟结束。这套 2.0.78 版的 LUA 脚本引擎,干的就是这件事——把战斗计算、任务链、活动配置、NPC 对话这些高频变更的逻辑从 C++ 核心里剥出来,交给脚本层跑,引擎负责虚拟机生命周期、C 与脚本的双向调用、事件派发、定时器和热更新。适合两类人:一类是接手了老服务端、想搞清脚本层怎么跟 C++ 打交道的维护者;另一类是想给自己的项目套一层脚本化玩法框架的开发者。
2. 引擎骨架:一个 lua_State 从创建到跑起来的完整链路
先别急着看脚本,先看引擎本身。这套东西的价值不在于「用了 Lua」,而在于它把宿主和脚本的职责切得很干脆:C++ 管网络、存储、地图寻路、对象生命周期这些动一下就出事的部分;Lua 管玩法规则、数值组合、AI 状态机、配置表达。两边靠一层薄薄的绑定代码通信,中间不互相渗透。
2.1 三层结构与目录落点
拿到资源后第一件事是把目录扫一遍,结构基本决定了后面所有问题的答案。这套引擎的骨架大致是这样组织的:
scripts-engine/ ├── src/ │ ├── core/ # 引擎主体:State 管理、Tick 循环、错误处理 │ │ ├── engine.cpp │ │ ├── engine.h │ │ └── error.cpp # traceback 与错误日志落盘 │ ├── bind/ # C++ 到 Lua 的注册代码,一个业务域一个文件 │ │ ├── player_api.cpp │ │ ├── bag_api.cpp │ │ ├── map_api.cpp │ │ └── battle_api.cpp │ ├── loader/ # 脚本搜索、字节码加载、热重载 │ │ ├── script_loader.cpp │ │ └── hotfix.cpp │ ├── timer/ # 定时器最小堆与协程调度 │ │ └── timer_wheel.cpp │ └── event/ # 事件总线,C++ 与 Lua 双向注册 │ └── event_bus.cpp ├── script/ │ ├── boot.lua # 引导脚本,所有模块从这里 require 进来 │ ├── conf/ # 数值表、掉落表、活动配置 │ ├── module/ # 玩法模块:任务、战斗、商店、邮件 │ ├── ai/ # 怪物与 NPC 行为脚本 │ └── proto/ # 协议号与字段名的脚本侧常量 └── tools/ ├── luac_pack.sh # 批量编译成字节码并做版本戳 └── reload.sh # 向运行中的进程发重载指令这套划分有个很实际的好处:bind/出问题只影响对应的业务域,loader/出问题整个脚本层起不来,边界非常清楚。我一般接手陌生引擎时,先看bind/有多少个文件、每个文件注册了多少函数,就能估出脚本层的能力半径有多大——注册函数少说明脚本只能做表现,注册函数多说明玩法逻辑真的在脚本里。
2.2 启动链路:从 newstate 到第一个 Tick
引擎初始化的顺序是有讲究的,顺序错了会出现「脚本能跑但拿不到玩家对象」这种诡异问题。核心初始化代码大致如下:
// src/core/engine.cpp bool LuaEngine::Init(const EngineConfig& cfg) { // 1. 建一个独立虚拟机实例。默认 5.1 语义,初始栈给 1MB 足够跑常规玩法 L_ = luaL_newstate(); if (!L_) return false; luaL_openlibs(L_); // 打开 string/table/math 等标准库 // 2. 先注册 C++ 侧能力,再加载脚本,否则 require 时全局表还不存在 RegisterPlayerApi(L_); RegisterBagApi(L_); RegisterMapApi(L_); RegisterBattleApi(L_); EventBus::Instance().BindToLua(L_); // 事件总线挂进脚本侧 // 3. 压入 traceback 函数并记住栈位置,后面所有 pcall 都以它为错误处理器 lua_pushcfunction(L_, LuaTraceback); traceback_idx_ = lua_gettop(L_); // 4. 指定脚本根目录并执行引导脚本 ScriptLoader::SetRoot(cfg.script_root); // 常见值 "./script" ScriptLoader::SetBytecodeMode(cfg.use_luac); // 线上一般开,开发期关掉方便改 if (!ScriptLoader::DoFile(L_, "boot.lua")) { fprintf(stderr, "boot.lua failed: %s\n", lua_tostring(L_, -1)); return false; } // 5. 记录起始时间,Tick 里用它算逻辑帧 start_ms_ = NowMs(); return true; }逐段说明一下。第一步luaL_newstate建出来的是一个完全空的虚拟机,什么库都没有,luaL_openlibs才把string、table、math这些装进去;有些老项目为了省内存只开部分库,结果脚本里string.format直接报 nil,排查半天。第二步的顺序不能反,因为boot.lua里第一行大概率就是local player = Player,全局表还没注入就拿到 nil。第三步的traceback_idx_是个整数栈索引,后续每次lua_pcall都传它,作用是脚本出错时打印完整调用栈而不是孤零零一行attempt to index a nil value。第四步的SetBytecodeMode是个开关,线上跑.luac省解析时间,开发期跑源码方便改。
Tick 循环是引擎的心跳,每帧要做的事固定三件:推进定时器、派发事件、调用脚本侧的OnTick:
// src/core/engine.cpp void LuaEngine::Tick(uint64_t now_ms) { timer_.Advance(now_ms); // 到期的定时器回调在这里被放进事件队列 EventBus::Instance().DispatchAll(L_); // 统一在帧边界处理,避免回调里改容器 // 脚本层可选实现 OnTick,没有实现就跳过,不强制 lua_getglobal(L_, "OnTick"); if (lua_isfunction(L_, -1)) { lua_pushinteger(L_, static_cast<lua_Integer>(now_ms)); if (lua_pcall(L_, 1, 0, traceback_idx_) != 0) { LogLuaError(lua_tostring(L_, -1)); // 落盘并限流,防止刷爆日志 lua_pop(L_, 1); // 必须把错误对象弹掉,否则栈会涨 } } else { lua_pop(L_, 1); // 不是函数也要弹,getglobal 一定压了一个值 } }DispatchAll放在定时器之后、OnTick之前是有考虑的:定时器到期的回调如果直接执行,可能在遍历某个容器中途修改它;先进队列、帧边界统一派发,就能规避大部分迭代器失效的坑。LogLuaError里通常还会做一个频率限制,同一个错误一秒内只记一次,不然一个死循环报错能把磁盘写满。
2.3 配置项怎么调
引擎启动参数不多,但每一个都影响线上表现,值得单独过一遍:
| 参数 | 常见取值 | 说明 |
|---|---|---|
script_root | ./script | 脚本根目录,loader 的所有路径拼接都基于它 |
use_luac | 线上 true / 开发 false | 是否加载预编译字节码 |
tick_interval_ms | 50 | 逻辑帧间隔,定时器精度受它约束 |
max_script_mem | 64(MB) | 虚拟机内存上限,超了触发强制 GC 或告警 |
gc_step | 2 | 每帧 GC 步进量,调大更平滑但更耗 CPU |
max_script_mem这项很多项目根本不设,结果脚本侧某个 table 无限增长,跑到内存爆掉才发现。常见做法是在Tick里每 60 帧查一次lua_gc(L_, LUA_GCCOUNT, 0),超过阈值就打点告警,先定位是哪个模块涨的,再决定是限流还是改数据结构。
3. 脚本加载与热更新:require 是怎么被接管,改完文件不用重启
脚本层的加载机制决定了「改一行能不能立刻生效」这件事。原生require走的是package.path加文件系统搜索,在游戏服务端里这套不够用:路径要可控、字节码要校验、热重载要能做、模块名要能带命名空间。所以要接管 searcher。
3.1 自定义 searcher:把 require 映射到自己的加载策略
Lua 5.1 的模块查找靠package.loaders这个函数数组,每一项是一个 searcher,依次尝试直到有一个返回加载器函数。往里面插一个自己的 searcher,就能完全掌握模块的解析规则:
// src/loader/script_loader.cpp // 自定义 searcher:把 require "module.player" 解析成 <root>/module/player.luac static int LuaSearcher(lua_State* L) { const char* name = luaL_checkstring(L, 1); // 模块名里的点换成路径分隔符,命名空间就是这么映射出来的 std::string rel = ReplaceAll(name, ".", "/"); std::string full = ScriptLoader::Root() + "/" + rel + (ScriptLoader::UseBytecode() ? ".luac" : ".lua"); // 文件不存在就把模块名原样压回栈,交给下一个 searcher 继续找 if (!FileExists(full)) { lua_pushfstring(L, "\n\tno script file '%s'", full.c_str()); return 1; } // 命中后压入一个加载器闭包,真正的读取在 require 调用它时才发生 lua_pushcfunction(L, LuaLoader); lua_pushstring(L, full.c_str()); return 2; // 两个返回值:加载器函数 + 传给加载器的参数 }这里有两个细节容易被忽略。第一,找不到文件时必须压一个字符串描述而不是直接返回 0,否则require最终报的错里没有任何路径信息,线上排查只能靠猜。第二,LuaLoader里读取文件内容后要先做一次版本戳校验(文件名或文件头里的哈希),再交给lua_load;校验失败就当作模块不存在处理,能让进程退回到上一份好脚本,而不是加载一半崩掉。
LuaLoader本身很薄,核心是这几行:
static int LuaLoader(lua_State* L) { const char* path = luaL_checkstring(L, 1); std::string buf; if (!ReadWholeFile(path, buf)) { // 整文件读入,脚本一般不大 lua_pushfstring(L, "read failed: %s", path); return lua_error(L); } // 第四个参数是 chunk name,报错时会带上它,比 [string "..."] 好认得多 if (luaL_loadbuffer(L, buf.data(), buf.size(), path) != 0) { return lua_error(L); // 语法错误原样抛给 require } return 1; // 返回编译好的 chunk,require 会执行它 }提示:
luaL_loadbuffer的 chunk name 建议用相对路径而不是绝对路径,线上日志里绝对路径会暴露部署结构,而且换机器后对不上。
3.2 热重载:脏标记加安全点切换
热重载最容易踩的坑是「改完直接重新 require」,老的闭包还挂在事件回调里,新逻辑根本没生效,表现得像玄学。正确做法是脏标记加安全点切换,不是随时想换就换:
// src/loader/hotfix.cpp void Hotfix::MarkDirty(const std::string& mod) { dirty_.insert(mod); // 只是记录,不动运行时 } // 由引擎在帧边界调用,保证此刻没有脚本正在执行 void Hotfix::ApplyIfAny(lua_State* L, int traceback_idx) { if (dirty_.empty()) return; std::vector<std::string> mods(dirty_.begin(), dirty_.end()); dirty_.clear(); for (const auto& m : mods) { // 1. 把 package.loaded[m] 置 nil,让下次 require 重新走加载器 lua_getglobal(L, "package"); lua_getfield(L, -1, "loaded"); lua_pushnil(L); lua_setfield(L, -2, m.c_str()); lua_pop(L, 2); // 2. 立刻重新 require 一次,失败则回滚旧版本并告警 std::string code = "require('" + m + "')"; if (luaL_dostring(L, code.c_str()) != 0) { LogHotfixError(L, m, lua_tostring(L, -1)); lua_pop(L, 1); Rollback(m); // 从备份目录恢复上一次的好版本 } } }关键点在调用时机:ApplyIfAny只能放在帧边界,不能在某个脚本函数执行到一半时切。因为旧闭包可能正持有upvalue,此时把package.loaded清掉并不会让已存在的闭包失效,它引用的还是旧环境。想彻底干净,模块就得遵守一个约定:所有对外暴露的回调都通过Event.On("xxx", handler)注册,重载时先Event.Off再重新On,把旧闭包从注册表里摘掉。这套约定在module/里的每个文件头部都有注释说明,接手时值得先读一遍。
脚本侧配合起来很简单:
-- script/module/shop.lua local M = {} local function onBuy(player, goods_id) local conf = Conf.Shop[goods_id] if not conf then return end Bag.AddItem(player, conf.item, conf.count) end -- 注册时带上模块名,热重载按模块名批量摘除,避免残留旧闭包 Event.On("shop_buy", onBuy, "module.shop") return MEvent.On的第三个参数是可选的归属标记,热重载时按标记清理。没有这个参数的老代码也能跑,但重载后会出现「一次购买触发两次发货」这类问题,属于血泪经验里最贵的一课。
3.3 加载顺序与循环依赖
模块之间有依赖是常态,shop依赖bag,bag依赖proto,一旦成环require就会返回nil而不是报错——因为 Lua 在执行模块体之前就把package.loaded[name]占位设成true了,环上第二次 require 拿到的是这个占位值。
处理办法有两种。一种是延迟 require,把模块顶部的local bag = require("module.bag")挪到函数内部第一次用时;另一种是显式声明依赖层级,在boot.lua里按层次顺序 require,保证下层先完成。我一般倾向后者,因为依赖关系在引导脚本里一眼能看全,出问题也好定位。顺序大致是:proto→conf→module.util→module.bag→module.player→module.shop→module.battle,越往上依赖越多。
4. C 与 LUA 的边界:绑定层怎么写、栈怎么用、错了怎么兜
绑定层是这套引擎里代码密度最高的部分,也是最容易写错的地方。核心就一件事:把 C++ 的能力暴露成 Lua 能调的全局函数,把 Lua 传上来的参数转成 C++ 类型,再把结果压回栈。
4.1 一个函数注册的完整写法
拿背包查询举例,这是脚本层调用频率最高的接口之一:
// src/bind/bag_api.cpp // 对应脚本调用:Bag.GetItemCount(player, 10023) static int L_GetItemCount(lua_State* L) { // 参数 1:玩家对象,用 userdata 承载,类型不匹配直接抛错给脚本 PlayerObj* p = (PlayerObj*)luaL_checkudata(L, 1, "Player"); // 参数 2:物品模板 ID,必须是整数;传字符串会被 checkinteger 拦下来 int item_id = (int)luaL_checkinteger(L, 2); int cnt = BagSystem::Count(p->id, item_id); lua_pushinteger(L, cnt); return 1; // 返回值个数,写错就等着玄学崩 } // 统一注册入口,由 engine.cpp 在 Init 阶段调用 void RegisterBagApi(lua_State* L) { static const luaL_Reg bag_funcs[] = { {"GetItemCount", L_GetItemCount}, {"AddItem", L_AddItem}, {"RemoveItem", L_RemoveItem}, {NULL, NULL} }; luaL_register(L, "Bag", bag_funcs); // 生成全局表 Bag,脚本侧直接 Bag.Xxx lua_pop(L, 1); // register 压了一张表,用完弹掉 }return 1这个返回值是最容易写错的地方,它告诉 Lua 栈顶有几个值是要返回给调用方的。写成return 0但实际压了一个值,那个值会残留在栈上,调几百次之后栈溢出;写成return 2但只压了一个,第二个返回值就是栈上的垃圾数据,脚本拿到一个不对劲的值还查不出来。血泪经验:每次写完绑定函数,先写一个脚本单测把返回值打印出来,别指望肉眼检查。
luaL_checkudata的第三个参数是元表名,必须和创建 userdata 时用的名字完全一致,大小写敏感。这个名字对不上时报的错是bad argument #1 (Player expected, got userdata),看着像类型对了但元表不对,其实就是一个字符串写岔了。
4.2 userdata 的生命周期与元表
玩家对象不能由 Lua 侧管理内存,否则脚本里一个player = nil就把 C++ 还在用的对象释放了。常规做法是 userdata 只存一个 ID,真实对象由 C++ 的对象池托管:
// 创建:只把 uint64 的玩家 ID 塞进 userdata,不持有真实对象 static void PushPlayer(lua_State* L, uint64_t player_id) { PlayerObj** ud = (PlayerObj**)lua_newuserdata(L, sizeof(PlayerObj*)); *ud = ObjectPool::Find(player_id); // 可能为 nullptr,脚本侧要判空 luaL_getmetatable(L, "Player"); lua_setmetatable(L, -2); } // 元表里的 __index 负责把字段访问转发到 C++,脚本写 p.level 就能取值 static int L_PlayerIndex(lua_State* L) { PlayerObj** ud = (PlayerObj**)luaL_checkudata(L, 1, "Player"); const char* key = luaL_checkstring(L, 2); if (!*ud) { lua_pushnil(L); return 1; } // 对象已销毁,返回 nil 而不是崩 if (strcmp(key, "level") == 0) { lua_pushinteger(L, (*ud)->level); return 1; } if (strcmp(key, "name") == 0) { lua_pushstring(L, (*ud)->name); return 1; } lua_pushnil(L); return 1; }这里*ud判空这一步经常被省掉,省掉之后的故障现象是:玩家下线后脚本里还挂着引用,下一次访问直接段错误。加个判空返回 nil,脚本侧写if not p.level then return end就能兜住,成本极低。
元表注册一般在引擎初始化时做一次:
void RegisterPlayerMetatable(lua_State* L) { luaL_newmetatable(L, "Player"); lua_pushcfunction(L, L_PlayerIndex); lua_setfield(L, -2, "__index"); // 字段读取走 C++ 转发 lua_pushcfunction(L, L_PlayerGC); // __gc 里只清引用,不 delete 真实对象 lua_setfield(L, -2, "__gc"); lua_pop(L, 1); }4.3 事件总线:C++ 主动推给脚本
绑定解决的是「脚本调 C++」,事件总线解决反方向。战斗结算、任务进度、玩家登录这些由 C++ 侧触发的时机,通过事件名推给脚本:
// src/event/event_bus.cpp void EventBus::Emit(lua_State* L, const std::string& ev, const std::vector<LuaValue>& args) { auto it = handlers_.find(ev); if (it == handlers_.end()) return; // 没人监听直接返回,不要报错 for (auto ref : it->second) { lua_rawgeti(L, LUA_REGISTRYINDEX, ref); // 取出注册时存的函数引用 for (const auto& a : args) a.Push(L); // 逐个压参 if (lua_pcall(L, (int)args.size(), 0, traceback_idx_) != 0) { LogLuaError(lua_tostring(L, -1)); lua_pop(L, 1); // 单个回调出错不影响其他回调,这点很重要 } } }两个设计选择值得说。用luaL_ref存函数引用而不是存函数名字符串,避免脚本里_G被改写后回调找不到;单个回调pcall失败只记日志不中断循环,一个任务模块写错不该连累战斗结算。事件名建议统一加业务前缀,比如battle.、quest.、bag.,脚本里Event.On("bag.item_add", ...)一看就知道归属。
4.4 定时器与协程:把异步流程写成同步样子
定时器底层是最小堆加帧驱动,每帧检查堆顶有没有到期。真正好用的一层是给脚本提供Sleep语义,靠协程实现:
-- script/module/quest.lua local function runGuide(player) Npc.Say(player, "跟我来") Timer.Sleep(1500) -- 挂起当前协程,1500ms 后从这行继续 Map.MoveTo(player, 101, 220, 180) Timer.Sleep(800) Npc.Say(player, "到了") end Event.On("quest.guide_start", function(player) Coroutine.Start(runGuide, player) -- 每个玩家一条协程,互不阻塞 end)Timer.Sleep内部做的事是coroutine.yield,定时器到期时再coroutine.resume把协程唤醒。好处是任务流程可以按人类阅读顺序写下来,不用拆成一堆回调。要注意的是协程不能跨帧持有 C++ 侧的裸指针,唤醒时机可能在对象销毁之后,所以runGuide里的每一步操作前都该判一下玩家还在不在,这是写这类流程脚本的固定动作。
5. 避坑清单:五条在真机上翻过车的 LUA 引擎问题
下面这几条都是从日志和 core dump 里一句句扒出来的,现象、原因、解决三件套,遇到同类问题可以直接对号入座。
5.1 多线程共用一个 lua_State,栈错乱到无法复现
现象:压测时偶发stack overflow或拿到的返回值是另一个玩家的数据,单线程下完全复现不了。
原因:lua_State本身不是线程安全的,两个线程同时对同一个 State 压栈弹栈,栈指针就乱了。有些老代码为了省内存,所有工作线程共用一个虚拟机,靠一把大锁保护,锁粒度没覆盖到所有路径就出事。
解决:每个逻辑线程一个独立 State,线程之间只传序列化数据不传对象引用;如果确实要共享,必须保证同一时刻只有一个线程在操作,锁要包住整个「压参 + 调用 + 取返回值」的序列,不能只锁调用那一行。
5.2 pcall 漏包,一个脚本错误带走整个 Tick
现象:某个活动脚本里nil索引报错后,整个服务端的定时器和事件全部停止,玩家在线但什么都不响应。
原因:C++ 直接调lua_call而不是lua_pcall,错误会以 longjmp 的方式穿出去,穿过 C++ 栈帧时析构函数不执行,轻则资源泄漏重则直接崩。
解决:所有从 C++ 进入 Lua 的入口一律用lua_pcall并带上 traceback 索引,包括事件派发、定时器回调、OnTick、GM 命令。代码评审时专门搜一遍lua_call,出现就是问题。
5.3 热重载后旧闭包还在跑,发一次奖扣两次钱
现象:修完一个商店逻辑重载脚本,测试购买一次扣了两份货币,日志里onBuy打印了两遍。
原因:老代码用匿名函数注册事件,Event.On("shop_buy", function(p, id) ... end),重载时package.loaded清了、新模块也 require 了,但注册表里那个旧闭包没人摘,新旧两个都在监听。
解决:Event.On强制要求传归属标记,重载前按标记批量Off;同时把这条写进脚本规范,新增事件注册必须带第三参数,CI 里加一条静态检查扫匿名函数注册。
5.4 GC 抖动,每 30 秒卡一次
现象:帧耗时曲线呈周期性尖峰,间隔固定,和玩家数量正相关。
原因:脚本里大量临时 table,加上 GC 是步进式的但步进量默认很小,攒够阈值后一次性回收,就形成了周期性卡顿。
解决:先定位谁在造垃圾,常见的是循环里string.format拼接、每次调用都新建配置表副本。改法一是把配置表在boot.lua阶段一次性构建好放全局;二是把gc_step调大让回收更均匀;三是关键帧手动lua_gc(L_, LUA_GCSTEP, n)分批推进。调完之后用同一份压测脚本对比帧耗时曲线,尖峰应该被抹平。
5.5 64 位整数精度丢失,ID 对不上
现象:玩家 ID 传进脚本再传回来,末几位变了,查背包查不到人。
原因:Lua 5.1 的数字只有 double 一种,53 位有效位,超过这个范围的 64 位 ID 会被截断。
解决:ID 一律用字符串在脚本层传递,或者封装成 userdata 只做实比较不做运算;lua_Integer相关的接口在绑定层显式检查范围,超范围直接报错而不是静默截断。这条在开服初期不明显,玩家 ID 涨上去之后才暴露,属于典型的后悔药没处买。
6. 二次开发:给引擎加一个自定义脚本接口的完整路径
假设要加一个「按条件筛选在线玩家」的接口,给运营活动脚本用。整个改动会穿过绑定层、脚本层和验证环节,正好把前面几章串起来。
第一步在bind/player_api.cpp里加实现,注意参数校验和返回 table 的写法:
// 脚本调用:Player.QueryOnline({ min_level = 30, map_id = 101 }) static int L_QueryOnline(lua_State* L) { luaL_checktype(L, 1, LUA_TTABLE); // 参数必须是表,写错立刻报错 int min_level = 1, map_id = 0; lua_getfield(L, 1, "min_level"); if (lua_isnumber(L, -1)) min_level = (int)lua_tointeger(L, -1); lua_pop(L, 1); // 每次 getfield 后必须弹栈 lua_getfield(L, 1, "map_id"); if (lua_isnumber(L, -1)) map_id = (int)lua_tointeger(L, -1); lua_pop(L, 1); std::vector<uint64_t> ids = PlayerMgr::Query(min_level, map_id); lua_newtable(L); // 结果表 int i = 1; for (uint64_t id : ids) { lua_pushinteger(L, i++); // key 从 1 开始,脚本用 ipairs 遍历 lua_pushstring(L, std::to_string(id).c_str()); // ID 走字符串,避开精度问题 lua_settable(L, -3); } return 1; // 整个结果表作为一个返回值 }lua_getfield和lua_settable这类操作会改栈,成对出现的 pop 少一个就会让栈缓慢增长,跑一天下来溢出。保守写法是每段操作前记一下int top = lua_gettop(L),结束时lua_settop(L, top - n)强制对齐。
第二步在RegisterPlayerApi的luaL_Reg数组里补一行{"QueryOnline", L_QueryOnline},注册就完成了,不用改引擎其他部分。
第三步写脚本侧封装,把参数校验和空结果处理收在一处:
-- script/module/player_query.lua local M = {} function M.OnlineByLevel(min_level) local list = Player.QueryOnline({ min_level = min_level }) if not list or #list == 0 then return {} end return list end return M第四步验证。先写一个最小回归脚本,在开发环境跑:
-- tools/test_player_query.lua local q = require("module.player_query") local list = q.OnlineByLevel(30) print("matched:", #list) assert(type(list) == "table", "返回值必须是表") for _, id in ipairs(list) do assert(type(id) == "string", "ID 必须是字符串,防止精度丢失") end再压一轮:起 200 个模拟客户端在线,脚本里每帧调一次查询,观察帧耗时和虚拟机内存。返回大表时临时对象会明显增多,如果帧耗时出现尖峰,说明该在 C++ 侧做结果缓存或者在脚本侧限制调用频率,而不是怪 GC。
最后说一个改造习惯。给引擎加接口这件事,我一般会强制走三步:先在绑定层写最保守的实现(所有参数 check、所有返回值显式压栈)、再写脚本侧薄封装、最后补一个能在开发环境一键跑的单测脚本。那套 2.0.78 的引擎在我手上加过二十来个接口,凡是想省掉第三步的,最后都在线上用日志补了回来。从那以后我每次动绑定层,哪怕只加一行注册,都强制把tools/test_*.lua跑一遍再提。希望帮到你。
本文还有配套的精品资源,点击获取