简介:大话2.0.78版LUA脚本引擎是一套面向《大话西游2》2.0.78版本脚本开发与研究的Lua4引擎源码工程,适合游戏服务端/客户端脚本开发者、老版本引擎移植学习者及相关技术研究者,重点解决该版本脚本引擎的编译适配与二次开发问题。压缩包共123个文件,约193KB,主要由34个C源文件、26个头文件、25个Lua脚本以及makefile、readme、vcproj等工程配置文件组成,覆盖词法解析、代码生成、虚拟机执行、标准库与测试用例等模块,目录组织便于按功能查阅。编译2.0.78版时需将src\lopcodes-78.h改名为lopcodes.h,这是还原该版本运行环境的关键适配细节;读者可据此研究Lua4在特定游戏中的定制改造、构建流程与常见编译问题。已有3403人学习,可作为理解老版本脚本引擎实现与排错思路的参考。
1. 改一行逻辑不用重编客户端:这套 2.0.78 的 LUA 脚本引擎到底给谁用
最难受的体验,是在客户端里把一个伤害系数从 1.2 改成 1.05,然后重编、重新打包资源、重启、再走一遍流程。手上这份资源,就是某 2.0.78 版回合制客户端内置的 LUA 脚本引擎包,它把变动最频繁的那层逻辑——战斗结算、任务流程、UI 面板事件——从宿主代码里拆了出来,交给一个独立的 Lua 状态机执行,宿主本身只保留渲染、网络和资源加载。
它的价值在于「改完就能看到」:脚本目录里改完.lua,重载模块甚至重启一次客户端就能验证,不需要动编译器。适合三类人:手上有一份 2.0.78 的包、想搞清脚本层怎么被加载起来的;要往里面加自定义模块、但被 require 和回调顺序绊住的;以及想给这类老引擎补一套热重载和自检流程的。下面按拆包、跑通、调参、排错、进阶的顺序来。
2. 拆开引擎看结构:Lua 状态机、require 解析和宿主 API 的三种形态
在动手写脚本之前,先把引擎的启动链路搞清楚。绝大多数「脚本不生效」的问题,根子都在这一层:你以为宿主执行的是你改的那个文件,其实它读的是打包进资源包里的另一份副本。这个 2.0.78 的引擎包结构不算复杂,但几个约定是硬性的,绕不过去。
2.1 启动链路:宿主是怎么把 Lua 状态机拉起来的
一次完整的启动,大致会经过这么几个阶段,顺序是固定的:
| 阶段 | 宿主动作 | 脚本侧可见的结果 |
|---|---|---|
| 1 | 创建 Lua 状态机,打开裁剪后的标准库 | _G里只有 string / table / math / coroutine / os 的一部分 |
| 2 | 注册 C 侧函数到全局表 | 出现game、ui、net、log、event这类全局表 |
| 3 | 设置package.path指向脚本根目录 | require能按相对路径找到模块 |
| 4 | 执行入口文件main.lua | 拿到入口模块返回的 table |
| 5 | 进入帧循环,按事件派发 | frame、scene_enter等回调开始被调用 |
第一件该做的事,不是写脚本,而是确认第 2 步到底注册了什么。不同版本、不同渠道的包里,暴露的全局表名字和函数签名都可能有差异,凭记忆写代码是最容易翻车的方式。我一般会在拿到包之后先跑一段导出脚本,把_G完整扫一遍:
-- tools/dump_api.lua -- 作用:把宿主注入到 Lua 的全局表全部打印出来,确认这份引擎到底暴露了哪些接口 -- 放置位置:脚本根目录下的 tools/,用宿主提供的「执行脚本文件」入口手动跑一次 local function dump(t, prefix, depth, seen) if depth > 2 or seen[t] then return end seen[t] = true -- 先拷一份 key 快照再遍历:log 内部有可能往全局表里写东西, -- 边 pairs 边新增键在 5.1 里是未定义行为,轻则漏打,重则直接报错 local keys = {} for k in pairs(t) do keys[#keys + 1] = k end table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) for _, k in ipairs(keys) do local v = t[k] local name = prefix and (prefix .. "." .. tostring(k)) or tostring(k) if type(v) == "table" then log.info("api", "%s -> table (%d)", name, #v) dump(v, name, depth + 1, seen) else log.info("api", "%s -> %s", name, type(v)) end end end dump(_G, nil, 1, {})这段脚本有两个地方值得说明。seen表是为了防止循环引用——宿主注入的很多表会互相指来指去(比如ui.root.parent = ui.root),不加防护会直接栈溢出,而且溢出发生在启动阶段,看起来像是引擎崩了。第二个是「先拷 key 快照」,这是 Lua 5.1 下的血泪经验:next在遍历过程中如果表被插入新键,行为是未定义的,而日志模块内部往全局表塞缓存的情况并不罕见。
参数上,depth > 2这个深度限制是刻意的。全局表往下两层基本能覆盖ui.panel.show这种调用路径,再深就是数据而不是接口了,打出来只是噪音。
2.2 脚本目录与 require 的解析规则
入口文件名是宿主写死的,通常是main.lua,但模块之间怎么找,走的是package.path。这个路径由宿主在启动时设置,一般长这样:
# 解包后可参考的脚本目录骨架 script/ ├── main.lua # 入口,宿主唯一硬编码的文件名 ├── conf/ │ └── init.lua # 全局配置表,最先被 main 加载 ├── core/ │ ├── event.lua # 事件注册与派发封装 │ ├── timer.lua # 定时器 │ └── class.lua # 面向对象封装 ├── logic/ │ ├── battle/ # 战斗结算相关 │ └── task/ # 任务流程相关 └── ui/ └── panel/ # 面板脚本关键是package.path的写法。Lua 默认用?做占位符,require("logic.battle.damage")会把点换成路径分隔符,依次尝试package.path里的每一项。宿主要么用script/?.lua,要么用script/?/init.lua,两者混用会导致require("logic.battle")这种「目录即模块」的写法失效。判断方法很简单:在入口里打一行日志看实际值。
-- script/main.lua 顶部,加载任何业务模块之前 log.info("boot", "package.path = %s", package.path) log.info("boot", "package.cpath = %s", package.cpath)除了路径,还有个更隐蔽的约定:require的返回值会被缓存进package.loaded。这个缓存是后面热重载那一章的核心,也是「改了脚本没生效」的头号嫌疑人。缓存键是require时传的字符串,所以require("core.event")和require("core/event")会各存一份,同一个文件被加载两次,模块里的局部状态就有两份,表现出来就是「事件被派发了两次」。统一用点号分段,是最省事的规矩。
另外要留意编码与换行。老引擎里 Lua 解析器对 UTF-8 BOM 的处理不一致,带 BOM 的文件可能在第 1 行就报unexpected symbol near '<\239>'。编辑器统一存成 UTF-8 无 BOM,这个坑能省掉一大半。
2.3 宿主 API 的三种暴露形态
C 侧把能力暴露给 Lua,常见做法无非三种,各有各的脾气:
| 形态 | 典型样子 | 调用开销 | 主要问题 |
|---|---|---|---|
| 全局函数 | game.get_role(id) | 低,直接 C 调用 | 全局命名空间容易撞名 |
| 全局表 + 字符串分派 | ui.call("panel.show", arg) | 中,多一次字符串查表 | 参数类型全靠约定,错了才在运行时报 |
| userdata + 元表代理 | role:get_name() | 中,走元方法 | 对象生命周期难管,宿主销毁后是悬空引用 |
第一种最直白,也是这份 2.0.78 包里最常见的形式:game、net、log这几个表下面挂的都是直接注册的 C 函数。写起来最舒服,缺点是全局名字一旦多了就乱,我在core/下会再包一层,把game.xxx收进自己的模块里,只在那一处引用全局,将来换引擎版本只改一个文件。
第二种是字符串分派,好处是宿主可以动态注册、脚本侧不需要知道具体函数名,坏处是参数类型完全靠约定。给panel.show传nil还是传空表,宿主那边可能一个直接返回、一个崩掉。用这种接口时我习惯在外面套一层参数校验,尤其是数字参数,tonumber一下再传,能挡掉格式转换带来的玄学问题。
第三种最容易出事。userdata 指向的 C++ 对象在切场景、关面板的时候会被销毁,而 Lua 这边只要还有人持有引用,GC 就不会回收那个 userdata 壳子,你调用它的方法就是在访问已经释放的内存。表现是「切场景之后再点一下就闪退」,日志里什么都看不到,典型的黑匣子。对付它的办法是给每个代理对象加一个显式的失效标记,宿主销毁对象时把标记置上,Lua 侧调用前先查。
-- core/proxy.lua -- 给 userdata 代理加一层存活性检查,避免访问已释放的宿主对象 local Proxy = {} local alive = setmetatable({}, { __mode = "k" }) -- 弱键表,不阻止 GC function Proxy.wrap(obj) alive[obj] = true return obj end function Proxy.invalidate(obj) alive[obj] = nil end function Proxy.is_alive(obj) return obj ~= nil and alive[obj] == true end return Proxy这里的__mode = "k"是重点:弱键表意味着 Lua 不会因为这张表还引用着对象就不回收它,否则监测表本身就成了内存泄漏源。这个模式在引擎里到处能用,事件监听表、定时器表都该这么写。
3. 写出第一个能跑的脚本:入口约定、事件注册和跨场景状态
结构弄清楚之后,就可以落地了。这一章的目标很明确:写出一个能被宿主正确加载、能收到回调、能在切场景后不丢状态的脚本。三件事各自有约定,坑也各不相同。
3.1 入口约定:main.lua 返回什么
入口文件必须返回一个 table,宿主拿这个 table 当模块句柄,之后往上面找on_load、on_frame这类约定名字。返回nil或者忘了写return,宿主拿到的是true,调用时就是attempt to index a boolean value。
-- script/main.lua -- 入口文件:宿主执行后拿返回值当模块句柄 local M = {} -- 宿主在脚本加载完成后调用一次,ctx 是宿主构造的上下文表 function M.on_load(ctx) M.ctx = ctx log.info("boot", "build=%s path=%s", tostring(ctx.build), tostring(ctx.script_path)) -- 注册事件,返回值是订阅 id,用于后面反注册 M.tick_id = event.on("frame", M.on_frame, { priority = 100 }) M.scene_id = event.on("scene_enter", M.on_scene_enter) end -- 每帧调用一次,dt_ms 是上一帧到这一帧的间隔,单位毫秒 function M.on_frame(dt_ms) -- 断线重连、切后台回来时首帧间隔会异常大,直接丢弃避免逻辑跳帧 if dt_ms > 200 then return end M.accum = (M.accum or 0) + dt_ms end function M.on_scene_enter(scene_id) log.info("scene", "enter %d", scene_id) -- 切场景时把累积的时间清零,否则新场景第一帧会凭空多出几百毫秒 M.accum = 0 end return Mctx里通常带三类东西:构建号或版本号、脚本根路径、以及宿主预置的一些配置表。构建号是排错时的第一手信息,务必在on_load里打出来,否则线上拿到一份日志都不知道跑的是哪个版本的脚本。
event.on的第三个参数是选项表,priority决定同一事件下多个订阅者的调用顺序,数值小的先跑。默认值一般是 0 或 100,具体看宿主实现。这个参数在需要「先算 buff 再算伤害」的场景里很关键,两个模块都监听同一个事件时,顺序错了结果就错了,而且错得不明显——数值差一点点,不跑对比根本发现不了。我一般把关键链路的优先级显式写死,并且留出间隔(100、200、300),方便以后插新模块。
on_frame里那个 200 毫秒的阈值也值得说。宿主传入的dt_ms在正常情况下一帧是 16 毫秒左右,但断线重连、切到后台再切回来、以及首次加载资源时,会突然来一个 800 甚至 3000 毫秒的值。直接拿去累加,所有基于时间的逻辑会瞬间跳一大步,比如冷却直接走完、动画瞬间播完。丢弃异常大的帧是通用做法,判断标准就是看它是否显著超过正常帧间隔的若干倍。
3.2 事件回调与派发顺序
事件系统的骨架一般是「注册 → 派发 → 反注册」,但派发顺序这件事经常被忽略。一份常见的实现是:同优先级按注册先后顺序调用,优先级数值小的先调用。这意味着它是广度优先的——所有priority=100的订阅者先全部跑完,才轮到priority=200的。
| 事件名 | 触发时机 | 是否每帧 | 参数 |
|---|---|---|---|
frame | 主循环每帧 | 是 | dt_ms |
scene_enter | 场景切换完成 | 否 | scene_id |
ui_event | UI 控件交互 | 否 | widget, action, data |
net_msg | 收到服务器消息 | 否 | msg_id, body |
exit | 客户端退出前 | 否 | 无 |
exit事件是最容易被漏掉的一个。它是在宿主销毁 Lua 状态机之前最后派发的,用来写存档、刷缓存、关文件。少了这一步,玩家退出时最后一段时间的数据就丢了,而且丢得悄无声息。
反注册同样重要。注册时拿到的 id 一定要存下来,在模块卸载或者面板关闭时调event.off(id)。不反注册的后果不是立刻报错,而是回调越积越多:面板开了十次,同一个点击事件被处理十一次(第一次注册的那次还在)。表现出来是「点一下加了十一次属性」,排查时往注册方向想,一般十分钟能定位。
ui_event的参数里,widget往往就是前面说的 userdata 代理。派发时宿主保证对象有效,但如果你把widget存进全局表、在下一帧再去访问它,对象可能已经没了。规矩是事件回调里用完就丢,不要把宿主对象存到跨帧的地方。
3.3 跨场景状态:运行时数据放哪儿
脚本重启、切场景、热重载之后,Lua 里的局部变量全没了,能留下来的只有宿主帮你在外面存的那一份。所以状态要分两类:可丢弃的(缓存、临时计算)随便放局部变量;需要跨场景的必须显式存。
-- core/save.lua -- 跨场景状态:只允许存放可序列化的基础类型 local Save = {} local MAX_FLAGS = 256 function Save.snapshot(state) -- 逐字段手写,不要用 pairs 全量拷贝:宿主对象混在里面会导致序列化直接失败 local data = { ver = 1, stage = tonumber(state.stage) or 1, count = tonumber(state.count) or 0, flags = {}, } local n = 0 for k, v in pairs(state.flags or {}) do if type(k) == "string" and type(v) == "boolean" then data.flags[k] = v n = n + 1 if n >= MAX_FLAGS then break end end end return data end function Save.restore(data) if type(data) ~= "table" then return false end if data.ver ~= 1 then return false end -- 版本不匹配就放弃,别硬兼容 return { stage = data.stage or 1, count = data.count or 0, flags = data.flags or {}, } end return Save参数说明:ver是存档版本号,必须写,而且校验失败时直接放弃而不是「尽力兼容」。老存档结构对不上新逻辑,硬兼容出来的结果往往是数值错乱,比清空还难查。MAX_FLAGS是个保险丝,防止某个 bug 无限往表里塞键把存档撑爆。
snapshot里逐字段手写而不是深拷贝整个 state,也是刻意的。深拷贝一旦碰到 function、userdata 或者循环引用就会炸,而这类对象在业务表里到处都是。手写字段虽然啰嗦,但边界是明确的——写进去什么,序列化出来就是什么。
4. 参数调优与调度:Lua 协程、帧预算和资源上限怎么设
脚本能跑起来只是及格线,真正决定体验的是参数。帧预算给多少、协程怎么调度、内存上限卡在哪,这三个数字定错了,表现是「偶尔卡一下」「跑久了越来越慢」「某些设备直接闪退」,全都是难复现的类型。
4.1 帧预算:单帧耗时上限与降级策略
60 帧意味着每帧 16.6 毫秒,逻辑层不该吃掉超过四分之一。我一般把单帧预算压在 4 毫秒,超过 2.5 毫秒就开始打警告日志。
-- core/budget.lua -- 给一批任务做时间片切分,超预算就留到下一帧 local Budget = { limit_ms = 4.0, warn_ms = 2.5 } -- 用宿主暴露的高精度计时;没有的话退回到自己累加 dt_ms local now_ms = time and time.ms or nil function Budget.run(tasks, cursor, dt_ms) cursor = cursor or 1 local spent = 0 while cursor <= #tasks do if now_ms then local t0 = now_ms() local ok, err = pcall(tasks[cursor]) spent = spent + (now_ms() - t0) if not ok then log.error("budget", "task %d failed: %s", cursor, tostring(err)) end else -- 没有高精度计时就按累加值估算,精度差但不会卡死 local ok, err = pcall(tasks[cursor]) if not ok then log.error("budget", "task %d failed: %s", cursor, tostring(err)) end spent = spent + 0.1 end cursor = cursor + 1 if spent >= Budget.limit_ms then break end end if spent >= Budget.warn_ms then log.warn("budget", "spent %.2fms, cursor=%d/%d", spent, cursor - 1, #tasks) end return cursor -- 调用方把 cursor 存下来,下一帧从这里继续 end return Budget这里有个必须点出来的坑:不要用os.clock()测耗时。Lua 5.1 里它的语义是「本进程消耗的 CPU 时间」,测不出等待、sleep 和 I/O 的时间,而且在部分平台上分辨率只有十几毫秒,4 毫秒的预算根本量不出来。要么用宿主暴露的高精度计时(常见名字是time.ms、time.now),要么退回到用dt_ms累加估算——精度差,但至少不会死循环。
pcall包住每个任务是必须的。单个任务报错不应该让整批任务停摆,否则一个面板的 bug 会把整个调度器拖死。但pcall的代价是错误信息会被吞掉堆栈,所以错误日志里一定要带上是第几个任务。
limit_ms设成 4.0 不是拍脑袋:16.6 的一帧里,渲染和网络各占一部分,逻辑层留四分之一是比较稳的余量。设备性能差的场景可以降到 2.5,宁可让任务慢一帧完成,也不要让帧率掉下去——逻辑延迟一帧用户感知不到,掉帧是立刻能看见的。
4.2 协程调度:把长流程写成同步代码
任务流程这类逻辑,天然是一段一段推进的:等 NPC 对话结束、等动画播完、等服务器回包。用状态机写会变成一堆case,用协程写就是顺着的代码,可读性差一个量级。
-- core/co.lua -- 极简协程调度器:yield 出去的返回值当作「等待毫秒数」 local Co = { jobs = {} } function Co.start(fn, ...) local co = coroutine.create(fn) Co.jobs[#Co.jobs + 1] = { co = co, args = { ... }, wait = 0 } end function Co.update(dt_ms) for i = #Co.jobs, 1, -1 do local job = Co.jobs[i] if job.wait > 0 then job.wait = job.wait - dt_ms elseif coroutine.status(job.co) == "suspended" then local args = job.args or {} job.args = nil local ok, ret = coroutine.resume(job.co, unpack(args)) if not ok then log.error("co", "%s", tostring(ret)) table.remove(Co.jobs, i) elseif coroutine.status(job.co) == "dead" then table.remove(Co.jobs, i) else -- 协程 yield 出来的值就是等待时间,nil 视为不等待 job.wait = tonumber(ret) or 0 end else table.remove(Co.jobs, i) end end end return Co倒序遍历是为了能在循环里安全table.remove——正序删除会跳过元素,这个坑在 Lua 里踩过的人不少。job.args用完立刻置nil,是因为 resume 的参数只在第一次有效,留着容易被误用。
最大的限制来自 Lua 5.1 本身:coroutine.yield不能跨越 C 调用边界。也就是说,如果宿主的事件派发是用lua_pcall从 C 侧调进 Lua 的,你在那个回调里直接yield就会得到attempt to yield across C-call boundary错误。绕法是不要在宿主回调里直接 yield,而是在回调里Co.start一个新协程,让协程在下一帧由Co.update推进。这个思路在所有嵌入式 Lua 项目里都通用。
4.3 沙箱与资源上限:指令数、内存水位、禁用库
脚本层是开放的,就意味着可能有人在里面写死循环。指令数钩子是最直接的保险:
-- core/sandbox.lua -- 白名单建环境 + 指令数/内存双闸门 local ALLOW = { "assert", "error", "ipairs", "next", "pairs", "pcall", "select", "tonumber", "tostring", "type", "unpack", "xpcall", "rawget", "rawset", "string", "table", "math", "coroutine", } local raw_global = _G local env = {} for _, k in ipairs(ALLOW) do env[k] = raw_global[k] end -- Lua 5.1 用 setfenv;5.2 以后要换成给 chunk 设置 _ENV setfenv(1, env) local MEM_LIMIT_KB = 24576 -- 24MB local HOOK_COUNT = 10000 -- 每 10000 条指令检查一次 debug.sethook(function() if collectgarbage("count") > MEM_LIMIT_KB then error("script memory limit exceeded", 2) end end, "", HOOK_COUNT)参数上有几个点必须说清楚。collectgarbage("count")返回的单位是KB,不是字节,写成24 * 1024 * 1024就等于给自己设了 24GB 的上限,等于没设。HOOK_COUNT取 10000 是在开销和灵敏度之间折中:太小(比如 100)会让所有脚本慢一倍以上,太大(比如 100000)会让死循环跑很久才被抓住。
| 参数 | 推荐值 | 调大后的后果 | 调小后的后果 |
|---|---|---|---|
| 单帧预算 | 4 ms | 掉帧 | 任务完成延迟增加 |
| 协程等待粒度 | 10 ms | 动画衔接变钝 | 调度开销上升 |
| 内存上限 | 24 MB | 低端机闪退 | 正常业务被误杀 |
| 指令数检查间隔 | 10000 | 死循环卡死更久 | 全局性能下降 |
沙箱这层要注意setfenv的版本差异:5.1 是setfenv(1, env),5.2 之后改成了_ENV上值,同一个写法在 5.3 上会直接报attempt to call a nil value (global 'setfenv')。工程里我一般会写个兼容判断,用_VERSION分支,别指望一份代码横跨两个大版本。
5. 避坑与排查:脚本不生效、内存上涨、yield 报错的五种真实原因
下面这五条,是我在拆这类引擎包时反复遇到的。每条按「现象 → 原因 → 解决」写,照着对照基本能覆盖大部分卡点。
第一条:改完脚本重启,行为完全没变。现象是改了一个数值,重启客户端,游戏里的伤害还是老样子,日志也没报错。原因通常是两个:一是宿主读的不是你改的那个文件,客户端优先加载打包进资源包里的副本,磁盘上的script/目录只是开发时的映射;二是package.loaded里有缓存,模块已经被加载过了。解决分两步:先确认加载路径,在on_load里打出ctx.script_path和package.path,看它指向哪儿;再确认是不是走了资源包,如果是,就得用宿主提供的开发模式参数或者重新打包。
第二条:事件被触发了两次,数值翻倍。现象是点一次按钮,属性加了两次,重启后又正常,开一会儿再点又翻倍。原因是面板开关多次后,旧的事件订阅没有反注册,回调列表里堆了多份。这个问题在热重载场景下尤其明显。解决是强制成对:每次event.on必须把返回的 id 存起来,on_unload或面板关闭时event.off(id);更稳的做法是把订阅集中在一个表里,卸载时整体遍历注销。
第三条:切场景之后再操作就闪退,日志空白。现象是进出场景几次后,点某个面板直接闪退,日志里最后一行还是上一个场景的。原因是面板脚本把宿主给的对象代理存进了模块级变量,切场景时宿主销毁了 C++ 对象,Lua 侧还留着 userdata 壳子,下一次调用就是访问已释放内存。解决是前面说的存活性标记:所有宿主对象通过Proxy.wrap存一遍,销毁时Proxy.invalidate,访问前统一Proxy.is_alive判断。代价是多一次表查找,换来的是不闪退。
第四条:跑十几分钟后内存缓慢上涨,最后被引擎杀掉。现象是刚启动正常,越玩越卡,collectgarbage("count")的读数一直在涨、GC 也压不下去。原因通常是全局表当缓存用了,键还在持续增加;或者定时器创建了但没取消,timer.every每帧都在堆新任务。解决是三条:所有缓存表加容量上限并做 LRU 淘汰;定时器必须保存句柄并在销毁时timer.cancel;在自检脚本里定期打一次collectgarbage("count"),把它当监控指标而不是排错手段。
第五条:协程里一 yield 就报attempt to yield across C-call boundary。现象是在事件回调里直接写了一段带coroutine.yield的逻辑,一跑就报这个错。原因是调用栈是从 C 侧的lua_pcall进来的,跨不过去。解决是把这段逻辑包进Co.start(function() ... end),在回调里只负责启动协程,真正的等待交给调度器在下一帧推进。顺带一提,pcall包着的地方也不能 yield,这是同一个限制的另一副面孔。
6. 进阶技巧:热重载的正确卸载顺序与一个开机自检脚本
热重载是这类引擎最实用的能力,但顺序错了会留下「重载后事件触发三次」这种更难查的问题。核心在于:先反注册,再清缓存,最后重新加载。反注册必须在清缓存之前,因为清掉package.loaded之后就再也拿不到旧模块的句柄了。
-- tools/reload.lua -- 热重载单个模块:顺序不能变 local function reload(name) local old = package.loaded[name] -- 1. 让旧模块自己清理:反注册事件、取消定时器 if type(old) == "table" and type(old.on_unload) == "function" then local ok, err = pcall(old.on_unload) if not ok then log.error("reload", "unload failed: %s", tostring(err)) end end -- 2. 清缓存,注意键名必须和 require 时完全一致 package.loaded[name] = nil -- 3. 重新加载。沙箱里 loadfile 可能被禁用,那就交给宿主的加载入口 local path = string.gsub(name, "%.", "/") .. ".lua" local chunk, err = loadfile(path) if not chunk then log.error("reload", "load failed: %s", tostring(err)) package.loaded[name] = old -- 失败就回滚,别留个空壳 return false end local ok, mod = pcall(chunk) if not ok then log.error("reload", "exec failed: %s", tostring(mod)) package.loaded[name] = old return false end package.loaded[name] = mod log.info("reload", "%s ok", name) return true end return reload两个细节值得单独说。一是失败回滚:加载失败时把old塞回package.loaded,否则下一次require会重新执行一遍旧文件,而那些已经反注册过的事件不会自动恢复,模块就变成「活着但不响应」的状态。二是键名一致,reload("core.event")传的字符串必须和业务代码里require的完全一样,差一个斜杠就是两套模块。
热重载只适合开发期。线上环境里我一般把入口封掉,只留一个命令开关,而且重载前强制跑一次状态快照,重载后恢复——脚本可以重来,玩家进度不能。
配套的自检脚本,我习惯放在on_load的最后一行调用,检查项不多,但每项都能防住一类事故:
| 检查项 | 判断方式 | 失败后果 |
|---|---|---|
| 关键 API 是否存在 | type(game.get_role) == "function" | 后面全是 nil 报错 |
| 脚本构建号 | ctx.build与预期比对 | 版本串了,行为对不上 |
| 内存基线 | collectgarbage("count")记录初值 | 后续涨了没有参照 |
| 必需配置项 | 遍历conf/init.lua的必填字段 | 运行到一半才报错 |
| 事件订阅数 | 记录event.count()初值 | 重载后翻倍能被发现 |
最后一行那个订阅数基线特别好用。重载十次之后,如果订阅数从 12 变成 32,一眼就能看出泄漏,比等到数值翻倍再回头查要省太多时间。
从那以后我每次接手这类引擎包,第一件事都不是写业务,而是先把_G导出来、把package.path打出来、把构建号记下来,再写一个能跑通的最小入口。这三个信息在手,后面九成的「不生效」问题都能在五分钟内定位。希望帮到你。
本文还有配套的精品资源,点击获取