新幂尔框架 · 结构图(32 模块)
- 事件系统 — Event:add / push 订阅发布
- 常量系统 — Const 收口魔法数字
- 常用函数 — class / p / dump 等 50+ 工具
- 引导系统 — 任务引导
- 文件系统 — readcsv 读表
- 假人系统 — 假人上线
- 沙巴克相关 — 沙巴克攻城
- 玩家构造器 — 核心,挂载 11 子系统
- 货币系统 — add/dec/check 防透支
- 道具系统 — give/take/addatt
- 变量系统 — player.var 15 接口
- buff系统 — 临时状态效果
- 地图系统 — gohome/move
- 技能系统 — getlv/addlv/qh
- 邮件系统 — send 带附件
- 队伍系统 — 组队查询
- 称号系统 — 时效机制
- 怪物宝宝 — 刷怪/召唤
- 行会系统 — 成员/沙巴克
- 登录触发 — 玩家登录
- 充值触发 — 充值分成/VIP
- 入包触发 — 拾取/回收/极品
- 升级相关 — 里程碑奖励
- 协议触发 — 前端协议路由
- 定时触发 — 定时器/排行榜
- 杀怪触发 — 杀怪事件
- 点击触发 — NPC 点击
- 物品触发 — 道具使用
- 任务触发 — 任务回调
- 穿戴触发 — 穿戴装备
- 行会触发 — 行会操作
- 启动初始化 — 加载全部模块
加载顺序
启动初始化先加载「常用函数」「事件系统」,再按系统内核 → 玩家核心 → 触发器 顺序挂载,最后打印启动报告。
五大核心思维
- 1. 面向对象封装 — class(actor) 包装成 player
- 2. 链式调用 — 方法返回 self
- 3. 配置驱动 — 数据表替代 if/else
- 4. 事件解耦 — Event:add / push
- 5. 约定优于配置 — 统一命名 / 挂载
玩家对象挂载(player.xxx 从哪来)
class(actor) → player ├─ player.money 货币系统(add/dec/check) ├─ player.item 道具系统(give/take/addatt) ├─ player.var 变量系统(getint/settbl) ├─ player.buff buff系统(add/del/has) ├─ player.map 地图系统(gohome/move) ├─ player.skill 技能系统(getlv/addlv/qh) ├─ player.mail 邮件系统(send) ├─ player.team 队伍系统(mylist/hasplayer) ├─ player.title 称号系统(add/del/has) ├─ player.guild 行会系统(mylistobj/issbk) └─ player.mon 怪物宝宝(gen/bb)
第一课:认识框架
课时:60 分钟|学员:有 Lua 基础、会写引擎脚本|前置:无|教具:投影 / 代码编辑器 / 测试服
课前准备
- 准备好投影,把一段「魔法数字」引擎代码提前放到编辑器里;
- 确认测试服能跑
class(actor),方便现场演示; - 打印或准备好 32 模块四层划分的目录树图。
一、教学目标
本课结束后,学员能够:
- 用自己的话说出「引擎」和「框架」的区别,并一句话解释框架的价值;
- 记住 32 个模块的四层划分(系统内核 / 玩家核心 / 触发器 / 顶层),说出每层作用;
- 面对「发物品、加钱、传地图、发邮件」等需求,快速定位到对应模块;
- 独立写出「用
class(actor)包装玩家 + 链式调用」的最简业务代码。
二、教学重点与难点
重点
- 引擎原生写法的痛点:
getbaseinfo(actor, 6)这类魔法数字难记、易错、不可读; - 框架 = 封装层:
class(actor)把全局函数 + 数字编号包装成player.属性/player.方法; - 32 模块四层划分与加载顺序。
难点
- 「封装」这个抽象概念——为什么包一层就好用了,封装到底产生了什么价值;
- 链式调用的原理(方法返回 self);
- 三层分工的记忆与迁移。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 投影一段看不懂的引擎代码,制造痛点 |
| 知识点 1:引擎是什么 | 8 分钟 | 引擎的定位 + 三个痛点 |
| 知识点 2:框架 = 封装层 | 12 分钟 | class(actor) 的语义化改造 |
| 知识点 3:32 模块分层 | 10 分钟 | 四层划分 + 加载顺序 + 口诀 |
| 案例演示 | 15 分钟 | 发武器三写法对比 + 链式 + 满级判断 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书小结 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「同学们好。在正式上课之前,我先请大家看一段代码。大家都会写引擎脚本了,这段代码对你们来说应该不陌生。请看投影——」
投影代码:
local lv = getbaseinfo(actor, 6) local job = getbaseinfo(actor, 7) local account = getbaseinfo(actor, 2) local money = getbaseinfo(actor, 53)
【讲师继续说】
「现在请大家告诉我:这里的 6、7、2、53,分别代表什么?知道的举手。」
【停顿 5 秒,观察学员反应,通常没人能答全】
「好,我看大家都有点犹豫。那我揭晓答案——6 是等级,7 是职业,2 是账号,53 是元宝。大家发现没有?这串数字,如果不查文档、不靠背,你根本不知道它是什么。」
「我再问一个问题:如果明天让你写一个『判断玩家元宝够不够 100』的脚本,你能不查文档、一次写对吗?大多数人不能。为什么?因为这些数字编号太反人类了。」
【板书】写下四个字:魔法数字。
「今天这一课,我们就来解决这个问题。学完之后,你会知道框架是怎么把这些『天书数字』变成『人话』的。这就是本课的主题——认识框架。」
环节二:知识点 1 —— 引擎是什么(8 分钟)
【讲师讲解话术】
「在讲框架之前,我们得先搞清楚引擎是什么。我们用的 996M2,是传奇服务端的引擎。引擎给我们提供了什么?提供的是『原子能力』——查属性、发物品、传地图、发消息、读配置。」
「注意我说的这个词——『原子能力』。什么叫原子能力?就是它只负责『能做这件事』,不负责『让你用得舒服』。引擎的能力通过一堆全局函数暴露出来,参数几乎全是数字编号。」
「我们来看引擎原生写法到底有几个痛点。我总结成三个,大家记一下——」
【板书】
引擎三个痛点: 1. 魔法数字难记 getbaseinfo(actor, 6) 的 6 是啥? 2. 参数顺序难记 sendmail(userId, 1, title, content, itemsstr) 3. 代码不可读 三个月后回来,自己都看不懂
【讲师逐个展开】
「第一个痛点,魔法数字难记。刚才我们见识过了,6 是等级、53 是元宝,全靠背。一个两个还行,引擎有几十上百个编号,谁能全记住?」
「第二个痛点,参数顺序难记。比如发邮件,引擎是 sendmail(userId, 1, title, content, itemsstr),五个参数,顺序错了就发错。你写的时候要一直想着『第一个是用户 ID,第二个是类型,第三个是标题……』」
「第三个痛点,代码不可读。你现在写 changemoney(actor, 1, 100, "="),三个月后回来再看,你还记得 1 是什么货币、"=" 是什么意思吗?大概率不记得了。」
「这三个痛点,本质上是同一个问题——引擎只给能力,不给语义。引擎是好引擎,但直接写引擎接口,代码就像天书。这就是我们要引入框架的原因。」
【小提问】「谁能总结一下,引擎的三个痛点分别是什么?」(抽一位学员回答,确认理解)
环节三:知识点 2 —— 框架 = 封装层(12 分钟)
【讲师讲解话术】
「好,既然引擎这么难用,那框架是怎么解决的?框架的思路很简单——在引擎上面包一层,把难记的接口变成好读的写法。」
「大家看这段代码——」
-- 引擎原生写法:难读 local lv = getbaseinfo(actor, 6) changemoney(actor, 1, 100, "=") -- 框架写法:语义化,一眼看懂 local player = class(actor) local lv = player.lv player.money:add("元宝", 100)
「看出区别了吗?框架把 getbaseinfo(actor, 6) 封装成了 player.lv,把 changemoney(...) 一堆参数封装成了 player.money:add("元宝", 100)。」
「我们来逐行对比。第一行 class(actor),这一步叫『包装』——把引擎的 actor 对象,包装成框架的 player 对象。包装完之后,player.lv 就是等级,player.money:add("元宝", 100) 就是加 100 元宝。读出来是不是像在说话?」
【板书】
封装:魔法数字 → 语义化 getbaseinfo(actor, 6) → player.lv changemoney(一堆参数) → player.money:add("元宝", 100) sendmail(5参数) → player.mail:send("标题","内容",{{"物品",1}})
【讲师继续】
「这里我要强调一个概念,也是本课第一个难点——封装。封装到底做了什么?它没有创造任何新能力。player.lv 底层还是调的 getbaseinfo(actor, 6),只是把那个数字 6 藏起来了。」
「那封装产生了什么价值?产生的是可读性。把『查表记忆』变成了『读代码即懂』。你看到 player.money:add("元宝", 100),不用查文档,直接就知道是加元宝。」
【类比】
「我打个比方。引擎给你的是一堆『零件编号』——6 号零件、53 号零件,你要自己查表才知道 6 号是等级、53 号是元宝。框架帮你把这些零件组装成了一台『有名字的机器』——等级表、元宝表。你以后操作的是『机器』,不是『零件编号』。」
【常见误区】
「有个误区大家要注意:封装不是把功能变强,而是把用法变清楚。有些同学以为框架给引擎加了什么新功能,其实没有。框架的所有能力,底层都是引擎的能力,框架只是把它们『翻译成人话』。」
环节四:知识点 3 —— 32 模块分层(10 分钟)
【讲师讲解话术】
「搞懂了封装,我们来看看框架的整体结构。框架一共 32 个模块,分成四层。大家看投影——」
系统内核(7) 事件系统 / 常量系统 / 常用函数 / 引导系统 / 文件系统 / 假人系统 / 沙巴克相关 —— 地基,最先加载,被所有模块依赖 玩家核心(12) 玩家构造器 + 11 个子系统(货币/道具/变量/buff/地图/技能/邮件/队伍/称号/怪物宝宝/行会) —— 对象封装,把玩家包装成 player 对象 触发器(12) 登录/充值/入包/升级/协议/定时/杀怪/点击/物品/任务/穿戴/行会 —— 入口,引擎每个时机从这里进来触发业务 顶层(1) 启动初始化 —— 入口,负责加载上面所有模块
「这四层,我用一个口诀帮大家记住——系统内核是地基,玩家核心是对象,触发器是入口。」
「我们一层一层看。第一层系统内核,7 个模块,是『地基』。事件系统、常量系统、常用函数,这些是所有模块都依赖的基础设施,所以它们最先加载。大家记住:常用函数和事件系统最先加载,因为其他模块都要用它们。」
「第二层玩家核心,12 个模块。核心是『玩家构造器』,它负责把玩家包装成 player 对象,并且把另外 11 个子系统——货币、道具、变量、buff、地图……——全部挂到 player 上。这就是为什么你能写 player.money、player.item。」
「第三层触发器,12 个模块。这是『入口』。引擎的每个时机——玩家登录了、杀怪了、充值了、点按钮了——都会从这些入口进来,触发业务逻辑。」
「第四层顶层,就一个『启动初始化』,是整个框架的入口,负责按顺序加载上面所有模块。」
【加载顺序板书】
加载顺序:启动初始化 → 系统内核(常用函数/事件先行)→ 玩家核心 → 触发器
【小提问】「如果我要做一个『给玩家发邮件』的功能,去哪个模块找?」(答:玩家核心 → 邮件系统)
环节五:案例演示(15 分钟)
【讲师演示话术】
「好,理论讲完了,我们来动手写代码。第一个案例,也是本课重点——发武器的三种写法对比。」
-- 写法一:纯引擎,难读 giveitem(actor, "炼狱", 1) -- 写法二:框架物品系统,推荐 local player = class(actor) player.item:give("炼狱", 1) -- 写法三:链式调用,一口气发多个 player.item:give("炼狱", 1):give("金条", 5):give("随机传送石", 1)
【讲师逐行讲】
「写法一,纯引擎 giveitem(actor, "炼狱", 1),能用,但不够语义化。」
「写法二,先 class(actor) 包装,然后 player.item:give("炼狱", 1)。item 是物品子模块,give 是发放方法。读出来就是『给玩家的物品系统发一把炼狱』。」
「写法三最精彩,这是链式调用。大家看,give 之后还能继续 .give,一口气发三件。为什么能连起来?因为 give 方法返回 self——它发完物品后,把自己(item 对象)又返回来了,所以能继续点下一个方法。」
【板书】链式调用 = 方法返回 self
【讲师继续】
「我们再看第二个案例,取等级判断满级——」
function checkMax(actor) local player = class(actor) if player.lv >= 50 then player:send("你已经满级了!") return true end player:send("还差 " .. (50 - player.lv) .. " 级满级") return false end
「这个函数里,player.lv 替代了 getbaseinfo(actor, 6),player:send 替代了引擎发消息函数。整个函数读起来就是自然语言——如果等级大于等于 50 就发『满级了』,否则发『还差几级』。」
「注意 player:send 用的是冒号,player.lv 用的是点号。这个区别后面第七课会细讲,现在先记住:冒号调方法,点号取属性。」
环节六:提问互动(5 分钟)
【问 1】「getbaseinfo(actor, 6) 里的 6 是什么?为什么不直接用数字?」
【学员答后,讲师点评】「6 是等级字段的编号。引擎用数字编号代表属性,难记易错,所以框架要封装。」
【追问】「player.lv 和 getbaseinfo(actor, 6) 最终查的是不是同一个东西?」
【点评】「是同一个东西。player.lv 内部就是调 getbaseinfo(actor, 6),只是把数字藏起来了。」
【问 2】「框架做了什么,让代码变好读?」
【点评】「把引擎的全局函数 + 数字编号,封装成『对象.属性』『对象.方法』,比如 player.lv、player.money:add(...)。而且封装没有产生新能力,它产生的是可读性。」
【问 3】「32 个模块分几层?每层干什么?加载顺序是什么?」
【点评】「系统内核(地基,先加载)、玩家核心(对象)、触发器(入口)、顶层(启动)。加载顺序:启动初始化 → 系统内核 → 玩家核心 → 触发器。」
环节七:课堂练习与小结(5 分钟)
【课堂练习】
- 说出引擎和框架的区别,各举一个例子。
- 用
class(actor)包装玩家,读出lv、job、name三个属性。 - 用链式调用给玩家发 3 件物品,再加 100 元宝。
- 说出 32 模块的四层划分。
【参考答案】
- 引擎提供原子能力(
getbaseinfo、giveitem),参数是数字编号;框架是封装层(player.lv、player.item:give),把编号变成语义化写法。 local p = class(actor); print(p.lv, p.job, p.name)。player.item:give("炼狱",1):give("金条",5):give("随机传送石",1); player.money:add("元宝", 100)。- 系统内核(7)/ 玩家核心(12)/ 触发器(12)/ 顶层(1)。
【讲师小结话术】
「今天这一课,核心就一句话——框架就是把引擎的魔法数字,封装成会说人话的 player 对象。引擎给能力,框架给语义。32 个模块按『地基 / 对象 / 入口』三层组织,外加一个启动入口。」
「下节课我们讲『五大核心思维』,搞清楚框架背后的设计哲学——为什么这么封装、怎么封装才对。下课。」
五、难点深度解析
难点 1:为什么「封装一层」就好用了?
核心是语义化。getbaseinfo(actor, 6) 里的 6 是死的编号,不查文档不知道是等级;player.lv 是活的语义,一眼就知道。封装把「查表记忆」变成了「读代码即懂」。
讲课时可以用「零件编号 vs 组装好的机器」这个类比:引擎给你一堆带编号的零件(6 号、53 号),你要自己查表才知道是什么;框架帮你组装成有名字的机器(等级、元宝),操作机器不需要记编号。
关键结论:封装不产生新能力,产生的是可读性;而可读性决定了团队协作和后期维护的成本。
难点 2:三层的分工怎么记?
用盖楼比喻:
- 系统内核 = 地基:事件、常量、工具函数,所有模块都依赖它,所以最先加载;
- 玩家核心 = 房子主体:把玩家封装成对象,提供 item/money/var 等「房间」;
- 触发器 = 门窗:引擎的每个时机(登录、杀怪、充值)都从这扇门进来触发业务。
看到新需求时,先判断它是「能力」(找玩家核心)还是「时机」(找触发器)。
难点 3:链式调用为什么能链起来?
因为每个方法最后都 return self。self 就是调用它的那个对象(如 player.item),返回了自己,自然可以继续 .方法() 点下去。
学员可以现场验证:打开任意一个子系统源码,看 add/give 等方法末尾是不是都有 return self。
六、板书设计(整课板书汇总)
引擎:原子能力 + 数字编号(魔法数字) 痛点:难记 / 参数顺序乱 / 不可读 ↓ 封装 框架:class(actor) → player.属性 / player.方法 ↓ 分层 系统内核(地基7) → 玩家核心(对象12) → 触发器(入口12) + 顶层启动(1) ↓ 特性 链式调用(方法 return self)
七、课后作业
- 打开框架目录,找到「玩家核心」和「触发器」两个文件夹,数一数各有多少个文件,和今天讲的 12 个对上;
- 写一个函数,用
class(actor)包装玩家后,读出等级、职业、名字,并用player:send打印出来; - 用链式调用一口气给玩家发 3 件物品,体验「方法返回 self」。
第二课:五大核心思维
课时:60 分钟|学员:有 Lua 基础|前置:第一课|教具:投影 / 代码编辑器
课前准备
- 准备一段「写死的 if/else」代码和一段「事件互相调用」的代码,用于对比演示;
- 准备「充值 VIP 奖励」综合案例的完整代码;
- 在白板或投影上预留板书位置。
一、教学目标
本课结束后,学员能够:
- 说出框架设计的五大核心思维,并说出每个思维「解决什么问题」;
- 理解「面向对象封装」和「链式调用」的设计动机;
- 用「配置驱动」思维,把写死的逻辑改造成数据表驱动;
- 用「事件解耦」思维,把模块间的直接调用改成订阅发布;
- 独立判断一段代码是否符合框架风格,并给出修改建议。
二、教学重点与难点
重点
- 五大思维:面向对象封装、链式调用、配置驱动、事件解耦、约定优于配置;
- 每个思维「解决什么问题」+「怎么落地」。
难点
- 「配置驱动」的抽象——怎么把 if/else 拆成数据表;
- 「事件解耦」——Event:add / Event:push 的订阅发布思想;
- 从「能写」到「写得好」的思维转变。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 回顾第一课,引出「框架背后有设计思想」 |
| 知识点 1:封装 + 链式 | 15 分钟 | 思维 1 面向对象封装、思维 2 链式调用 |
| 知识点 2:配置 + 事件 + 约定 | 15 分钟 | 思维 3/4/5 |
| 案例演示 | 15 分钟 | 充值 VIP 案例串起五大思维 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「上节课我们讲了认识框架——框架把魔法数字封装成语义化写法。那这节课我们往深挖一层:框架为什么这么设计?背后有一套什么样的思想?」
「大家先想一个问题:同样是用框架写功能,为什么有的人写出来的代码和框架『长得很像』,一看就是老手写的;有的人写出来的代码却像『披着框架外衣的引擎代码』?区别就在于——有没有掌握框架的设计思维。」
「今天讲五大核心思维。学完之后,你写出来的代码才能和框架『同频』,而不是各写各的。」
【板书】写下:五大核心思维。
环节二:知识点 1 —— 思维 1 封装 + 思维 2 链式(15 分钟)
思维 1:面向对象封装
【讲师讲解话术】
「第一个思维,面向对象封装。它解决什么问题?解决的是『引擎是全局函数 + 数字编号,散乱难用』。」
「怎么落地?用 class(actor) 把玩家包装成 player 对象,把相关操作挂到对象上——」
local player = class(actor) player.lv -- 属性:等级 player.money:add("元宝", 100) -- 方法:加元宝 player.item:give("炼狱", 1) -- 方法:发物品
「注意看,这里有个关键思想——把数据 + 操作绑在一起。货币相关的所有操作都在 player.money 里,物品相关的都在 player.item 里。你看到 player.xxx,就知道在操作玩家的什么。」
「这就是面向对象的核心:不是把一堆函数堆在一起,而是按『对象』组织。玩家是一个对象,它有属性(等级、名字),有子系统(货币、物品),每个子系统又有自己的方法。」
【板书】面向对象封装 = 把数据+操作绑到 player 对象上
思维 2:链式调用
【讲师讲解话术】
「第二个思维,链式调用。它解决什么问题?解决『连续多个操作时,代码冗长』的问题。」
「我们对比一下——」
-- 不链式:啰嗦 player.item:give("炼狱", 1) player.item:give("金条", 5) -- 链式:清爽 player.item:give("炼狱", 1):give("金条", 5)
「链式调用的关键是什么?就是上节课我们埋下的伏笔——方法返回 self。give 发完物品后,把自己(item 对象)又返回来了,所以能继续 .give 点下去。」
【板书】链式调用 = 方法 return self
【常见误区】
「有个误区要提醒:不是所有方法都能链式。只有返回 self 的操作类方法才能链式。查询类方法(比如 getname、check)返回的是值或布尔,就不能链。这个后面讲道具系统时还会强调。」
环节三:知识点 2 —— 思维 3 配置驱动、思维 4 事件解耦、思维 5 约定(15 分钟)
思维 3:配置驱动
【讲师讲解话术】
「第三个思维,配置驱动。这是今天的一个难点,大家认真听。它解决什么问题?解决『写死的一堆 if/else 难维护』。」
「先看反例——写死的版本:」
if level == 35 then give("中型盔甲", 1) end if level == 46 then give("金条", 1) end if level == 50 then give("炼狱", 1) end
「这段代码能跑,但有两个问题:第一,加一个等级奖励要加一行 if;第二,改奖励要动代码逻辑。现在看正例——配置驱动的版本:」
local REWARDS = { [35] = { {"中型盔甲", 1} }, [46] = { {"金条", 1} }, [50] = { {"炼狱", 1} }, } local items = REWARDS[level] if items then for _, v in pairs(items) do player.item:give(v[1], v[2]) end end
「看出区别了吗?核心是把『逻辑』和『数据』分离。奖励是『数据』,发奖励的动作是『逻辑』。改奖励只改 REWARDS 表,不动逻辑。」
「框架里的常量系统(Const)就是这个思维的极致体现——把所有配置数据集中起来,业务代码只引用,不写死。」
【板书】配置驱动 = 数据与逻辑分离,改数据不动逻辑
思维 4:事件解耦
【讲师讲解话术】
「第四个思维,事件解耦。解决什么问题?解决『模块之间互相调用,耦合严重』。」
「先看耦合的写法——登录模块直接调用背包模块、称号模块:」
function login(actor) check_bag(actor) -- 登录时检查背包 check_title(actor) -- 检查称号 end
「问题在哪?登录模块要知道背包模块、称号模块的存在,还要知道它们的函数名。以后加个『检查 VIP』,又得改 login 函数。这叫紧耦合。」
「现在看解耦的写法——用事件订阅发布:」
-- 登录模块只推送事件,不知道谁在听 function login(actor) Event:push("登录完成", actor) end -- 背包模块自己订阅 Event:add("登录完成", function(actor) check_bag(actor) end, 1) -- 称号模块自己订阅 Event:add("登录完成", function(actor) check_title(actor) end, 1)
「好处很明显:登录模块只 push 一次,各模块各自 add。以后加『检查 VIP』,只需新增一个订阅,不用改 login 函数。这就是解耦。」
【板书】事件解耦 = Event:add 订阅 / Event:push 推送,模块互不依赖
思维 5:约定优于配置
【讲师讲解话术】
「第五个思维,约定优于配置。解决什么问题?减少不必要的决策和配置。」
「框架里处处是约定:所有子系统都挂到 player 下,命名固定(player.money、player.item);方法命名用动词(add/dec/check/get/set);中文 key 统一用方括号 cfg["中文"]。」
「约定一旦确立,你看到 player.xxx:add(...) 就知道是『加』,不用每个模块都去查文档。这就是约定的力量——减少认知负担。」
【板书】约定优于配置 = 统一命名/挂载/中文key,减少决策
环节四:案例演示(15 分钟)
【讲师演示话术】
「好,五大思维讲完了。现在用一个综合案例把它们串起来——充值 VIP 奖励。大家看代码,注意每个思维体现在哪——」
-- 思维3:配置驱动 —— 奖励配置表 local VIP_REWARDS = { [1] = { money = {"元宝", 1000}, items = {{"赤血魔剑", 1}, {"金条", 1}} }, [2] = { money = {"元宝", 3000}, items = {{"炼狱", 1}} }, [3] = { money = {"元宝", 10000}, items = {{"屠龙", 1}} }, } -- 思维4:事件解耦 —— 订阅充值事件 Event:add("充值相关", function(actor, gold, ...) -- 思维1:面向对象 —— 包装 player local player = class(actor) local lv = player:getviplv() local reward = VIP_REWARDS[lv] if not reward then return end -- 思维2:链式调用 —— 一口气发完(配合思维5的命名约定) player.money:add(reward.money[1], reward.money[2]) for _, v in pairs(reward.items) do player.item:give(v[1], v[2]) end end, 1)
【讲师逐段讲】
「第一段,VIP_REWARDS 是配置表,这是思维 3 配置驱动——奖励数据集中,改奖励只改表。」
「第二段,Event:add("充值相关", ...) 是思维 4 事件解耦——订阅充值事件,不和充值模块直接耦合。」
「第三段,class(actor) 是思维 1 面向对象——包装成 player。」
「第四段,player.money:add(...) 和 player.item:give(...),方法命名是动词,这是思维 5 约定;而 player.item:give 连续调用,是思维 2 链式(虽然这里用 for 循环,但 give 本身返回 self 支持链式)。」
「五大思维,全在这个例子里了。大家以后写功能,脑子里就过这五条。」
环节五:提问互动(5 分钟)
【问 1】「链式调用为什么能一直点下去?」
【点评】「因为方法返回 self。如果某个方法不返回 self,链就会断,后面再 .方法() 会报错(对 nil 调用)。」
【问 2】「配置驱动和写死 if/else 的本质区别是什么?」
【点评】「配置驱动把『数据』和『逻辑』分离,改数据不动逻辑;写死则两者耦合,改一处要动代码。判断标准:如果一个 if/else 分支超过 3 个,且内容是同构的(都是发东西/加钱),就该抽成配置表。」
【问 3】「事件解耦的好处是什么?」
【点评】「模块间不直接调用,松耦合。加功能只需再订阅一次,不用改触发方。代价是调试时要搜事件名找订阅者,没那么直观。」
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 写出五大核心思维。
- 把写死的逻辑改成配置驱动:
if map=="sldg" then send("欢迎来到沙巴克") elseif map=="mz" then send("欢迎来到盟重") end。 - 判断链式是否成立:
player.item:give("炼狱",1):getname()能继续.give()吗?
【参考答案】
- 面向对象封装、链式调用、配置驱动、事件解耦、约定优于配置。
local WELCOME = { sldg = "欢迎来到沙巴克", mz = "欢迎来到盟重" }; local msg = WELCOME[map]; if msg then send(msg) end。- 不能。
give返回 self 可以链,但getname返回的是名字(字符串),后面不能再.give()。
【讲师小结话术】
「今天五大思维,总结成一句话——先封装,再链式,配置驱动管数据,事件解耦管耦合,约定贯穿全框架。」
「这五条里,前两条(封装、链式)是基础,后两条(配置驱动、事件解耦)是高阶,约定是贯穿始终的规范。下节课我们讲目录结构与启动流程,把这 32 个模块在物理上是怎么组织的搞清楚。下课。」
五、难点深度解析
难点 1:配置驱动到底「高级」在哪?
新手常问:「写死 if/else 不也能跑吗,为什么要折腾成表?」配置驱动的价值在维护阶段:
- 需求变了(35 级奖励从「中型盔甲」改成「金条」),配置驱动改一行数据,写死要改逻辑、还可能改漏;
- 加新等级奖励,配置驱动加一行表项,写死加一个 if 分支。
判断标准:如果一个 if/else 分支超过 3 个,且内容是同构的(都是发东西、加钱),就该抽成配置表。
难点 2:事件解耦和直接调用的区别
直接调用:A 模块 require B 模块,调用 B 的函数——A 依赖 B 的存在。事件解耦:A 只 Event:push,B 只 Event:add——A 和 B 互相不知道对方。
好处:B 可以随时增删改,不影响 A。代价:调试时跳转不直观(要搜事件名)。
判断标准:跨模块的功能触发,用事件;同一模块内部,直接调用。
难点 3:五大思维的关系(不是并列的)
五大思维有主次:
- 面向对象封装是基础(把 actor 变成 player);
- 链式调用是封装后的顺手优化;
- 配置驱动和事件解耦是「怎么写好」的高阶思维;
- 约定优于配置是贯穿始终的规范。
记住一句话:先封装,再链式,配置驱动管数据,事件解耦管耦合,约定贯穿全框架。
六、板书设计(整课板书汇总)
五大核心思维 1. 面向对象封装 → class(actor) → player.属性/方法(数据+操作绑定) 2. 链式调用 → 方法 return self 3. 配置驱动 → 数据表替代 if/else(数据与逻辑分离) 4. 事件解耦 → Event:add / Event:push(模块互不依赖) 5. 约定优于配置 → 统一命名/挂载/中文key 关系:先封装,再链式,配置管数据,事件管耦合,约定贯穿
七、课后作业
- 找一段自己之前写的「写死 if/else」代码,用配置驱动改造一遍;
- 把两个模块之间的直接调用,改成 Event:add / Event:push 的订阅发布;
- 打开框架的常量系统源码,看看 Const 表里有哪些分组,体会「配置集中管理」。
第三课:目录结构与启动流程
课时:60 分钟|学员:有 Lua 基础|前置:第一课|教具:投影 / 目录树图 / 启动日志
课前准备
- 准备一份框架完整目录树图(投影用);
- 准备一段真实的启动日志(含「通过/错误」计数);
- 准备
启动初始化.lua源码,方便现场对照讲解。
一、教学目标
本课结束后,学员能够:
- 默画出框架的完整目录树,说清每个目录放什么;
- 讲清
启动初始化.lua的完整加载流程(四步); - 理解「防重复加载」「pcall 容错」「分类挂载」三个关键机制;
- 遇到「新增一个模块文件」时,知道该放哪、怎么被加载。
二、教学重点与难点
重点
- 目录树:系统内核 / 玩家核心 / 触发器 / 顶层 四层的物理位置;
- 启动初始化的四步:加载前置核心 → 遍历分类 → 批量挂载 → 打印报告;
- loadScript 的防重复 + pcall 容错。
难点
- 环境文件遍历的分类逻辑(「幂尔框架」与版本名分别归类);
- cachecsv 数据核心 xls 的预加载;
- require 路径与文件系统的对应关系。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 回顾四层划分,落到「物理目录」 |
| 知识点 1:目录树 | 15 分钟 | 逐层讲解目录结构 |
| 知识点 2:启动流程四步 | 15 分钟 | 启动初始化的完整加载流程 |
| 知识点 3:两个关键机制 | 10 分钟 | 防重复 + pcall 容错 |
| 案例演示 | 8 分钟 | 追踪一个新模块的加载全过程 |
| 提问互动 | 4 分钟 | 3 个问题 |
| 练习与小结 | 3 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「第一课我们说了 32 个模块分四层,但那是『逻辑上』的分层。这节课我们落到『物理上』——这些模块的文件到底放在哪?引擎启动的时候,怎么把它们一个个加载起来的?」
「搞懂这两个问题,你就知道以后『新增一个功能该改哪个文件』了。这是把框架从『看懂』到『会用』的关键一步。」
【板书】写下:目录 + 启动流程。
环节二:知识点 1 —— 目录树(15 分钟)
【讲师讲解话术】
「框架源码放在 Mirserver/Mir200/Envir/mirs/幂尔框架/ 这个目录下,重写后的新框架在 新幂尔/后端/。目录结构是这样的——」
投影目录树:
幂尔框架/ ├── 系统内核/ # 7 个文件,地基 │ ├── 常用函数.lua # 最先加载,50+ 工具函数 │ ├── 事件系统.lua # 最先加载,Event 订阅/推送 │ ├── 常量系统.lua # Const 常量表 │ ├── 引导系统.lua # 任务引导 │ ├── 文件系统.lua # 读 csv │ ├── 假人系统.lua # 假人上线 │ └── 沙巴克相关.lua # 沙巴克攻城 ├── 玩家核心/ # 12 个文件,对象封装 │ ├── 玩家构造器.lua # 核心,挂载 11 个子系统 │ ├── 货币系统.lua / 道具系统.lua / 变量系统.lua │ ├── buff系统.lua / 地图系统.lua / 技能系统.lua │ ├── 邮件系统.lua / 队伍系统.lua / 称号系统.lua │ └── 怪物宝宝.lua / 行会系统.lua ├── 触发器/ # 12 个文件,入口 │ ├── 登录触发.lua / 充值触发.lua / 入包触发.lua │ ├── 升级相关.lua / 协议触发.lua / 定时触发.lua │ ├── 杀怪触发.lua / 点击触发.lua / 物品触发.lua │ └── 任务触发.lua / 穿戴触发.lua / 行会触发.lua └── 启动初始化.lua # 顶层入口
「大家记住一句话:三层目录 + 一个入口文件。系统内核、玩家核心、触发器三个目录,加上根目录下一个 启动初始化.lua。」
「这里有个易错点,我要特别强调——玩家核心里『玩家构造器』是核心。它负责 require 并挂载另外 11 个子系统到 player 对象上。所以以后新增一个子系统,要在玩家构造器里加挂载,不是光建个文件就完事。」
【小提问】「如果我要新增一个『签到系统』,应该放哪个目录?」(答:玩家核心)
环节三:知识点 2 —— 启动流程四步(15 分钟)
【讲师讲解话术】
「好,目录搞清楚了。那引擎启动时,启动初始化.lua 是怎么把这些模块加载起来的?流程分四步。我们打开源码对照看——」
第 1 步:加载前置核心
loadScript("Envir/mirs/幂尔框架/系统内核/常用函数", "常用函数", "常用函数") local ok, ev = loadScript(".../事件系统", "事件系统", "事件系统") if ok then Event = ev.new() end
「第一步,先加载『常用函数』和『事件系统』。为什么是这两个?因为其他所有模块都可能用到 class、getbaseinfo、p(在常用函数里),以及 Event:add(在事件系统里)。如果加载顺序反了,触发器里写 Event:add(...) 时 Event 还是 nil,就会报错。所以这两个强制最先加载。」
「注意事件系统加载后立即 ev.new() 初始化成全局 Event,后面所有模块才能用。」
第 2 步:遍历环境文件并分类
for _, v in pairs(envir) do if v:find("%.lua$") then if v:find("幂尔框架") and not v:find("启动初始化") then table.insert(frameList, v) -- 框架自己的脚本 elseif v:find(versionName) then table.insert(versionList, v) -- 版本玩法脚本 end end if v:find("数据核心") and v:find("%.xls$") then cachecsv[getFileName(v)] = readexcel("../" .. v) -- 缓存数据核心 xls end end
「第二步,引擎提供 getenvirfilelist() 返回所有环境文件的路径列表。这里按字符串匹配分类——路径含『幂尔框架』的归入框架脚本,含版本名的归入玩法脚本。同时把『数据核心』的 xls 表预读进 cachecsv 缓存。」
第 3 步:批量挂载
local function batchLoad(fileList) for _, cleanPath in ipairs(fileList) do loadScript("Envir/" .. cleanPath, name, name) end end
「第三步,把分类好的脚本列表,逐个 loadScript 挂载。」
第 4 步:打印报告
release_print(string.format("[框架报告] 挂载代码数量 : %d (通过: %d | 错误: %d)", ...))
「第四步,打印启动报告,告诉你一共挂载了多少、通过多少、错误多少。启动日志里就能看到。」
【板书】四步:加载前置 → 遍历分类 → 批量挂载 → 打印报告。
环节四:知识点 3 —— 两个关键机制(10 分钟)
【讲师讲解话术】
「loadScript 里有两个关键机制,是框架启动稳定的保障。第一个,防重复加载——」
local loadedFiles = {} local function loadScript(path, printName, storeKey) local uniqueKey = string.gsub(path, "%.lua$", "") if loadedFiles[uniqueKey] then return true, _G.store[storeKey] end loadedFiles[uniqueKey] = true -- ... end
「用 loadedFiles 表记录已加载的路径,避免同一个文件被 require 两次(二次 require 可能导致模块被覆盖或重复初始化)。」
「第二个,pcall 容错——」
local ok, result = pcall(require, uniqueKey) if ok then _G.store[storeKey] = result successCount = successCount + 1 else release_print(string.format("[错误] %s 详情: %s", printName, tostring(result))) errorCount = errorCount + 1 end
「用 pcall 包住 require,单个模块报错不会拖垮整个框架启动,而是记录错误继续加载。这就是为什么启动日志里能看到『通过 N 个、错误 N 个』。」
【互动提问】「如果不用 pcall 会怎样?」(答:一个模块报错,整个 require 抛异常,后续模块都不加载,框架启动失败。)
环节五:案例演示(8 分钟)
【讲师演示话术】
「假设我们要新增一个 玩家核心/签到系统.lua,我们走一遍完整流程,看它怎么被加载——」
- 文件放在
玩家核心/目录; - 在
玩家构造器.lua里加 require 并挂载到 player(后面课详讲); - 引擎启动 →
启动初始化.lua遍历环境文件,把签到系统.lua归入frameList; batchLoad调loadScript,pcall require 加载;- 加载成功,
_G.store["签到系统"]缓存,计数器 +1。
「大家记住:放对目录 + 挂载 + 启动加载是一条链,缺一不可。少了任何一环,你的模块都不会生效。」
环节六:提问互动(4 分钟)
【问 1】「框架源码在哪个物理目录?」(答:Mirserver/Mir200/Envir/mirs/幂尔框架/)
【问 2】「启动初始化的四步是什么?」(答:加载前置核心 → 遍历分类 → 批量挂载 → 打印报告)
【问 3】「为什么用 pcall 包住 require?」(答:单个模块报错不拖垮整体启动,记录错误继续加载)
环节七:课堂练习与小结(3 分钟)
【课堂练习】
- 默写目录树(三层目录 + 入口文件)。
- 说出启动初始化的四步流程。
- 新增一个模块文件,要经过哪几个环节才能被加载?
【参考答案】
- 系统内核(7)/ 玩家核心(12)/ 触发器(12)/ 启动初始化.lua。
- 加载前置核心 → 遍历分类 → 批量挂载 → 打印报告。
- ①放到对应目录 ②在玩家构造器挂载(若为子系统)③启动初始化遍历归类 ④loadScript pcall require。
【讲师小结话术】
「今天这一课,核心就两句话:目录决定放哪,启动初始化决定怎么加载,防重复 + pcall 保证加载稳。搞懂了目录和启动流程,你就从『会用框架』进阶到了『能改框架』。」
「下节课讲『编码规范与中文 key』,这是写框架代码的第一道坎——GBK 编码和中文 key 的方括号写法,踩坑的人特别多。下课。」
五、难点深度解析
难点 1:环境文件遍历的分类逻辑
getenvirfilelist() 返回的是一堆路径字符串,没有目录结构。分类靠 v:find("幂尔框架") 和 v:find(versionName) 做字符串匹配。
学员可能困惑「为什么用字符串匹配而不是目录」。因为引擎只给扁平的路径列表,只能靠路径里是否含关键字分类。关键点:框架自己的脚本路径里都含「幂尔框架」,版本玩法脚本路径里含版本名(GameName)。
难点 2:为什么「常用函数」和「事件系统」要最先加载
因为其他所有模块都可能用到 class、getbaseinfo、p(常用函数)和 Event:add(事件系统)。加载顺序反了,触发器里 Event 还是 nil 就报错。所以启动初始化第 1 步强制先加载这两个。
难点 3:防重复加载的意义
require 本身有缓存,但框架额外用 loadedFiles 表做一层保护,配合 _G.store 缓存结果。作用是:如果两个文件都 require 了同一个模块,第二次直接返回缓存结果,不重新执行模块代码——避免副作用重复执行(如重复 Event:add 导致事件重复触发)。
六、板书设计(整课板书汇总)
目录树:系统内核(7) / 玩家核心(12) / 触发器(12) / 启动初始化(1) 启动流程四步: 1. 加载前置核心(常用函数、事件系统) 2. 遍历环境文件 → 分类(幂尔框架 / 版本名 / 数据核心xls) 3. 批量挂载 batchLoad → loadScript 4. 打印报告(通过/错误计数) 两个关键机制: - 防重复加载(loadedFiles + _G.store) - pcall 容错(单模块报错不拖垮全局)
七、课后作业
- 打开框架目录,对照今天的目录树,把每个文件都点开看一眼名字;
- 找到
启动初始化.lua,通读一遍,标出四步分别对应哪段代码; - 看一次真实启动日志,确认「通过/错误」计数,若有错误,尝试定位是哪个模块。
第四课:编码规范与中文 key
课时:60 分钟|学员:有 Lua 基础|前置:第一课|教具:投影 / 一个「乱码」示例文件
课前准备
- 准备一个被存成 UTF-8 的 lua 文件,现场演示中文乱码;
- 准备一个含中文 key 的配置表,演示点号写法报错;
- 准备好转码工具(或编辑器切换编码的步骤截图)。
一、教学目标
本课结束后,学员能够:
- 说清「后端 lua 用 GBK、文档/html/js 用 UTF-8」这条铁律及其原因;
- 正确使用方括号
cfg["中文"]访问中文 key,避开点号写法的坑; - 遵守框架的注释规范(模块头一句话 + 每函数一行注释);
- 独立排查「中文乱码」和「中文 key 报错」两类常见问题。
二、教学重点与难点
重点
- GBK vs UTF-8 的分工及原因;
- 中文 key 必须用方括号
cfg["中文"]; - 注释规范:模块头 + 每函数一行注释。
难点
- 为什么点号
cfg.中文会出错(Lua 标识符规则); - 编码混用导致的「乱码」和「读取失败」如何定位;
- 转码工具的使用(保存 GBK 的流程)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 展示乱码文件,引出编码问题 |
| 知识点 1:GBK vs UTF-8 | 15 分钟 | 编码铁律 + 为什么后端必须 GBK |
| 知识点 2:中文 key | 15 分钟 | 方括号写法、点号的坑 |
| 知识点 3:注释规范 | 10 分钟 | 模块头 + 函数注释 |
| 案例演示 | 8 分钟 | 中文 key 读配置、乱码排查 |
| 提问互动 | 4 分钟 | 3 个问题 |
| 练习与小结 | 3 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「写框架代码,最容易翻车的地方不是逻辑,而是两个『小坑』——编码和中文 key。大家看投影,这是同一个文件,左边是正常的,右边是读出来全是问号和乱码——」
【演示:展示乱码】
「为什么会这样?因为存错编码了。再看第二个坑——有个同学写配置表,想读 cfg.武器,结果直接报语法错误。这两个坑,今天一次填平。」
【板书】写下:编码 + 中文 key。
环节二:知识点 1 —— GBK vs UTF-8(15 分钟)
【讲师讲解话术】
「先说编码。框架有一条铁律,大家必须刻在脑子里——」
【板书,加重语气】
后端 .lua → GBK
文档/html/js → UTF-8
「为什么后端 lua 必须用 GBK?因为 996M2 引擎是十几年前的中文传奇引擎,它的脚本解析器认的是 GBK(中文 Windows 的默认 ANSI 编码)。你如果用编辑器默认的 UTF-8 存 lua 文件,中文注释和字符串读进引擎就会变成乱码,甚至导致语法错误。」
「所以记住一句话:后端跟着引擎走(GBK),前端文档跟着现代工具走(UTF-8)。」
「那怎么保证存成 GBK?三个办法——」
- 编辑器保存时选「GBK / GB2312 / ANSI」编码;
- 写完 UTF-8 后用脚本转码(项目里常用 python 转码);
- 用十六进制查看器确认文件头。
【易错点】「VS Code 等现代编辑器默认 UTF-8,直接存 lua 就会踩坑。务必确认 lua 文件编码是 GBK。」
环节三:知识点 2 —— 中文 key 的方括号写法(15 分钟)
【讲师讲解话术】
「第二个坑,中文 key。框架里大量配置表用中文做 key,比如——」
local cfg = { ["攻击"] = 100, ["生命"] = 500, }
「访问的时候,只能这样写——」
-- 正确:方括号 local atk = cfg["攻击"] -- 错误:点号会报语法错误 -- local atk = cfg.攻击 ← 这行直接报错
「为什么 cfg.攻击 报错?因为 Lua 里 .key 是 ["key"] 的语法糖,但 . 后面必须是合法的标识符——就是字母或下划线开头,后面跟字母数字下划线。中文不是合法标识符,所以 cfg.攻击 语法错误,必须用 cfg["攻击"]。」
【板书】中文 key 用方括号:cfg["中文"](点号只适用英文合法标识符)
「延伸一下——带空格的 key、数字 key、含特殊字符的 key,也都必须用方括号:」
local t = { ["玩家名字"] = "张三", [1] = "第一个", ["a-b"] = "横线", } print(t["玩家名字"], t[1], t["a-b"])
【互动提问】「cfg.attack 能不能写?」(答:能,因为 attack 是英文合法标识符)
环节四:知识点 3 —— 注释规范(10 分钟)
【讲师讲解话术】
「第三个内容,注释规范。框架的注释有统一风格,大家照着写,代码才整齐。就两条——」
第一条:模块头一句话 + 一行用法
-- 邮件系统:给玩家发送邮件,可附带物品(挂载为 player.mail) -- 用法:player.mail:send("标题", "内容", {{"物品名", 数量}})
「模块开头,一句话说明这个文件是干什么的,再一行用法示例。不要写 40 行的长模块头。」
第二条:每个函数一行注释
-- 参数:title=标题 content=正文 items=附件表(可选) → 返回 self → 发送邮件 function Mail:send(title, content, items) -- ... end
「每个函数一行注释,格式是『参数 → 返回 → 干什么』。三要素写清楚,别人看注释就知道怎么用。」
【板书】注释规范:模块头一句话+一行用法;每函数一行注释(参数→返回→干什么)
环节五:案例演示(8 分钟)
【讲师演示话术】
「我们看两个实战案例。第一个,读中文 key 的配置表——」
local ITEM_ATTR = { ["炼狱"] = { atk = 30, price = 10000 }, ["嗜魂法杖"] = { atk = 25, price = 12000 }, } function getWeaponAtk(name) local info = ITEM_ATTR[name] -- name 是变量,天然用方括号 if info then return info.atk -- atk 是英文 key,可以用点号 end return 0 end
「注意对比:ITEM_ATTR[name] 用变量做 key,天然是方括号;info.atk 用英文 key,可以用点号。中文 key 和英文 key 的访问方式一目了然。」
「第二个案例,中文乱码排查——」
p("恭喜你获得奖励") -- 若文件是 UTF-8,引擎读出来就是乱码 -- 排查步骤: -- 1. 用十六进制查看文件头,判断编码; -- 2. 确认是 UTF-8,则重新以 GBK 保存; -- 3. 重启引擎验证。
「教大家一个定位链路:乱码 → 编码不对 → 转 GBK → 重启验证。」
环节六:提问互动(4 分钟)
【问 1】「后端 lua 用什么编码?为什么?」(答:GBK,因为引擎按 GBK 解析,UTF-8 会乱码)
【问 2】「cfg.攻击 为什么报错?」(答:Lua 点号后面必须是合法标识符,中文不是,要用 cfg["攻击"])
【问 3】「发现 lua 文件中文乱码,第一反应是什么?」(答:检查文件是不是存成了 UTF-8,应该转 GBK)
环节七:课堂练习与小结(3 分钟)
【课堂练习】
- 写出编码铁律。
- 判断:
cfg.攻击还是cfg["攻击"]正确? - 取表
t = { ["a b"]=1, [1]=2, ["中文"]=3 }里的三个值。
【参考答案】
- 后端 lua = GBK;文档/html/js = UTF-8。
cfg["攻击"]正确,cfg.攻击语法错误。t["a b"]、t[1]、t["中文"]。
【讲师小结话术】
「今天三个点:编码跟着引擎走(GBK)、中文 key 用方括号、注释短而准。这些都是小细节,但踩坑的人最多,大家写代码前先过一遍这三条。」
「下节课讲『前后端一体』,搞懂后端 lua 怎么和前端界面配合,打通交互闭环。下课。」
五、难点深度解析
难点 1:为什么后端必须 GBK 而不是 UTF-8
引擎是十几年前的技术栈,脚本解析器用 GBK(中文 Windows 的默认 ANSI 编码)。UTF-8 是后来的多字节编码,引擎解析器不认,导致中文变乱码。这不是「选错编码」,而是「引擎的硬约束」。记住:后端跟着引擎走(GBK),前端文档跟着现代工具走(UTF-8)。
难点 2:点号和方括号的本质区别
.key 是 ["key"] 的语法糖,但 key 必须是合法标识符(字母、下划线开头,后接字母数字下划线)。中文、数字开头、带特殊字符的 key,都不能用点号,只能用方括号。判断标准:key 是纯英文合法标识符 → 可以用点号;其余 → 用方括号。
难点 3:怎么确认一个文件到底是 GBK 还是 UTF-8
三个办法:
- 用编辑器的「编码」栏看;
- 用十六进制编辑器看文件头(UTF-8 中文开头有
EF BB BFBOM 或明显的 3 字节序列); - 用脚本读:
open(path, encoding='utf-8')读报错,基本就是 GBK。
六、板书设计(整课板书汇总)
编码铁律:后端 lua = GBK;文档/html/js = UTF-8 中文 key: cfg["中文"] ✅ 方括号 cfg.中文 ❌ 点号(中文不是合法标识符) 注释规范: 模块头一句话 + 一行用法 每函数一行注释:参数 → 返回 → 干什么
七、课后作业
- 检查自己之前写的 lua 文件,确认是不是 GBK 编码,不是的转成 GBK;
- 写一个含中文 key 的配置表,用方括号正确读出所有值;
- 按注释规范,给一段自己写的函数补上「参数 → 返回 → 干什么」注释。
第五课:前后端一体
课时:60 分钟|学员:有 Lua 基础|前置:第四课|教具:投影 / 一个可点按钮的测试 NPC
课前准备
- 准备一个完整的「签到」功能示例(前端 Lmain + 后端 Smain/call1);
- 准备
点击触发.lua的源码(讲 toSend 内部实现); - 准备一个可现场点击的测试 NPC,演示完整闭环。
一、教学目标
本课结束后,学员能够:
- 说清框架里「前端脚本」和「后端逻辑」的分工与边界;
- 理解
toSend加载前端脚本、gg.server:send发协议的机制; - 独立写出「玩家点按钮 → 后端处理 → 前端刷新」的完整闭环;
- 看懂前端脚本的
--前端开始]]/--前端结束]]标记约定。
二、教学重点与难点
重点
- 前端脚本 = 界面 + 交互(gg.ui / gg.button),后端 = 逻辑 + 数据;
toSend(actor, "脚本名")发送前端脚本;gg.server:send(MSG_ID, p1, p2)前端发协议、Smain后端收协议。
难点
- 前后端的「协议」通信机制(前端 gg.server:send → 后端 Smain 路由分发);
- 前端脚本的
--前端开始]]/--前端结束]]标记与加载缓存; - 完整闭环的时序理解。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:功能的前后端边界在哪 |
| 知识点 1:前后端分工 | 15 分钟 | 前端 Lmain vs 后端 Smain |
| 知识点 2:toSend + 协议 | 15 分钟 | 两大发送机制 |
| 知识点 3:完整闭环 | 10 分钟 | 点按钮 → Smain 路由 → 业务 |
| 案例演示 | 10 分钟 | 签到功能完整代码 |
| 提问互动 | 3 分钟 | 3 个问题 |
| 练习与小结 | 2 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「在传奇里做一个功能,一半是界面——玩家看到的按钮、文字、进度条;一半是逻辑——点按钮后加钱、发装备。框架怎么把这两半组织起来的?这就是今天要讲的『前后端一体』。」
「先问大家一个问题:一个『签到』功能,界面上的『签到按钮』,和点击后『加 100 元宝』的逻辑,它们分别在框架的哪部分?带着这个问题,我们往下看。」
环节二:知识点 1 —— 前端脚本 vs 后端逻辑(15 分钟)
【讲师讲解话术】
「框架把一个功能拆成两半,写在一个文件里,用两个标记分隔——--前端开始]] 和 --前端结束]]——」
| 部分 | 内容 | 位置 |
|---|---|---|
| 前端脚本 | 界面 UI(gg.ui)+ 交互(gg.button/gg.server) | 两个标记之间 |
| 后端逻辑 | 数据处理(Smain 路由 + call 方法) | --前端结束]] 之后 |
「看一个标准 NPC 的骨架——」
--前端开始]] local npc = {} -- NPC 类 local MSG_ID = 141 -- 协议号(每份脚本唯一) npc.CONFIG = { -- 配置表 SIGN_REWARD = 100, -- 签到奖励 100 元宝 } -- 前端入口:加载界面 + 绑定按钮 function npc:Lmain() local ui, root = gg.ui:npc(tostring(MSG_ID)) -- 加载 UI if not ui then return end self.ui = ui gg.button:click(ui.sign_btn, function() -- 绑定签到按钮 gg.server:send(MSG_ID, 1) -- 点按钮 → 发协议给后端 end) end --前端结束]] -- 后端入口:接收协议,按 p1 路由分发 function npc:Smain(actor, p1, p2, p3, str) local player = class(actor) if not player then return end local dis = { [1] = self.call1, -- 签到 } local handler = dis[p1] if handler then handler(self, actor, player, p2, p3, str) else player:send("未知操作请求! 错误码:" .. tostring(p1)) end end -- 签到业务 function npc:call1(actor, player, p2, p3, str) if player.var:getdayvar("今日签到") == 1 then player:send("今天已经签过啦!") return end player.money:add("元宝", self.CONFIG.SIGN_REWARD) player.var:setdayvar("今日签到", 1) player:send("签到成功!获得 100 元宝") end return npc
「大家注意看结构:--前端开始]] 到 --前端结束]] 之间是前端,用 gg.ui:npc 加载界面、gg.button:click 绑定按钮、gg.server:send 发协议。标记之后是后端,Smain 接收协议,按 p1 用 dis 表路由到具体的 call1 方法。」
「为什么这么设计?好处是一个功能一个文件,界面和逻辑写在一起,维护集中。不像某些框架要分开两个文件,改起来两头跑。」
环节三:知识点 2 —— toSend 与协议(15 分钟)
【讲师讲解话术】
「搞清楚分工后,看两个关键机制。」
toSend:发送前端脚本
toSend(actor, "签到") -- 读文件,截取前端部分,发给客户端
「toSend 读 mirs/版本名/签到.lua,截取 --前端结束]] 前的界面代码,发给客户端渲染。我们看它内部的实现——」
local function performSend(actor, scriptName, ...) local content = ScriptLoader:load(scriptName) -- 读文件,截取前端部分 if not content then return false end local player = class(actor) local data = { script = content, time = os.time(), name = player.name, npcname = scriptName, args = { ... } } player:sendClient(1000, tbl2json(data)) -- 协议推送 return true end
「注意最后一行 sendClient(1000, ...)——toSend 内部也是用 sendClient 推送的,协议号 1000 是『加载前端脚本』的协议。客户端收到后,执行前端代码(Lmain 里的 gg.ui / gg.button)。」
gg.server:send:前端发协议
gg.server:send(MSG_ID, 1) -- 前端发协议,p1=1 表示「签到」操作
「玩家点按钮,前端 gg.server:send(MSG_ID, p1, ...) 把协议发回后端,后端 Smain 接收。」
环节四:知识点 3 —— 完整闭环(10 分钟)
【讲师讲解话术】
「我们把完整链路串起来——」
① 玩家点 NPC → 后端 clicknpc → toSend("签到") → 截取前端代码 → sendClient(1000) 发给客户端 ② 客户端执行前端代码 → Lmain → gg.ui:npc 加载界面 → gg.button:click 绑定按钮 ③ 玩家点「签到」按钮 → gg.server:send(MSG_ID, 1) → 发协议 ④ 后端 handlerequest(actor, MSG_ID, 1, ...) → 找到对应 NPC → 调 Smain(actor, 1, ...) ⑤ Smain 按 p1=1 路由 → dis[1] = call1 → 加元宝、存变量 → send 反馈
「大家记住这个闭环:界面是前端脚本(gg API),处理是后端方法(Smain/call1),两者靠协议打通。」
环节五:案例演示(10 分钟)
【讲师演示话术】
「我们看一个完整的签到功能,前端后端都在一个文件里——」
--前端开始]] local npc = {} local MSG_ID = 141 function npc:Lmain() local ui, root = gg.ui:npc(tostring(MSG_ID)) if not ui then return end self.ui = ui gg.button:click(ui.sign_btn, function() gg.server:send(MSG_ID, 1) -- ② 点按钮发协议 end) end --前端结束]] function npc:Smain(actor, p1, p2, p3, str) -- ④ 后端收协议 local player = class(actor) if not player then return end local dis = { [1] = self.call1 } local handler = dis[p1] if handler then handler(self, actor, player, p2, p3, str) end end function npc:call1(actor, player, p2, p3, str) -- ⑤ 签到业务 if player.var:getdayvar("今日签到") == 1 then player:send("今天已经签过啦!") return end player.money:add("元宝", 100) player.var:setdayvar("今日签到", 1) player:send("签到成功!获得 100 元宝") end return npc
【讲师逐段讲】
「前端 Lmain 管界面和交互,后端 Smain + call1 管逻辑。点按钮 → gg.server:send 发协议 → Smain 收协议 → dis 路由到 call1 → 加钱存变量。这就是一个完整的前后端闭环。」
环节六:提问互动(3 分钟)
【问 1】「前端脚本和后端逻辑怎么区分?」(答:以 --前端开始]] / --前端结束]] 标记为界,标记之间是前端,之后是后端)
【问 2】「toSend 的作用是什么?」(答:加载指定脚本,截取前端部分发给客户端,内部用 sendClient(1000) 推送)
【问 3】「玩家点按钮后,后端怎么收到?」(答:前端 gg.server:send 发协议,后端 Smain 接收并路由)
环节七:课堂练习与小结(2 分钟)
【课堂练习】
- 说出前后端的分工。
--前端开始]]/--前端结束]]标记的作用是什么?- 写一个「点按钮加 50 元宝」的完整闭环(前端 + 后端)。
【参考答案】
- 前端 = 界面 UI + 交互(gg.ui/gg.button);后端 = 逻辑 + 数据(Smain/call)。
- 分隔界面代码和逻辑代码,toSend 只取标记之间的前端部分发客户端。
- 前端
gg.button:click(ui.btn, function() gg.server:send(MSG_ID, 1) end)+ 后端Smain路由到call1,call1里class(actor).money:add("元宝", 50)。
【讲师小结话术】
「今天核心一句话:前端管界面(gg API),后端管逻辑(Smain 路由),靠 toSend + 协议双向打通,一个功能一个文件。下节课讲 class(actor) 玩家对象——这是框架最核心的封装,之后 90% 的业务都靠它写。下课。」
五、难点深度解析
难点 1:协议通信的完整链路
很多学员搞不清「前端点了按钮,后端怎么知道」。链路是:
- 前端按钮绑定
gg.button:click(ui.xxx, function() gg.server:send(MSG_ID, p1) end); - 玩家点击 → 客户端把「MSG_ID + p1」打包成协议发给引擎;
- 引擎回调
handlerequest(actor, msgID, p1, p2, p3, str),按 msgID 找到对应 NPC; - 后端调
npc:Smain(actor, p1, p2, p3, str),用 dis 表按 p1 路由到 call1/call2。
关键点:gg.server:send(MSG_ID, p1) 里的 MSG_ID 是协议号、p1 是操作码,Smain 里的 dis[p1] 就是按操作码分发到具体方法。
难点 2:前端标记的作用
toSend 加载脚本时用 content:match("^(.-)前端结束") 截取 --前端结束]] 之前的内容。这样界面代码和逻辑代码写在同一个文件里,但加载时只发界面部分给客户端,逻辑部分留在后端执行。好处是一个功能一个文件,维护集中。
难点 3:为什么后端也能主动推界面
不是只有「玩家点了才响应」。后端在定时器、事件里也能 sendClient / toSend 主动推界面。比如挂机满 4 小时,后端主动弹提示。理解「双向通信」:前端 gg.server:send 发协议给后端,后端 sendClient 推协议给前端。
六、板书设计(整课板书汇总)
前后端一体:
前端 = 界面 + 交互(gg.ui / gg.button / gg.server)
位置:--前端开始]] 到 --前端结束]] 之间
后端 = 逻辑 + 数据(Smain 路由 + call 方法)
位置:--前端结束]] 之后
通信:
toSend("脚本") → 发前端界面(内部 sendClient(1000))
gg.server:send(ID,p1) → 前端发协议
Smain(actor,p1,...) → 后端收协议,dis[p1] 路由
闭环:点NPC → 发界面 → 点按钮 → gg.server:send → Smain → call → 反馈
七、课后作业
- 找一个现成的 NPC 脚本(如祝福系统.lua),找到
--前端开始]]/--前端结束]]标记,区分前后端两部分; - 写一个「点按钮弹提示」的最小闭环,跑通一次;
- 看 Smain 里的 dis 路由表,理解 p1 操作码是怎么分发到 call1/call2 的。
第六课:class(actor) 玩家对象
课时:60 分钟|学员:有 Lua 基础|前置:第五课|教具:投影 / 玩家构造器源码
课前准备
- 准备
玩家构造器.lua源码,投影 attr_map 和 new 挂载部分; - 准备一个可现场跑的测试脚本,演示
class(actor)包装; - 准备 attr_map 表(语义名 → 数字编号映射)打印图。
一、教学目标
本课结束后,学员能够:
- 理解
class(actor)的本质——把引擎的 actor 包装成 player 对象; - 掌握玩家构造器挂载 12 个子系统的方式(
player.xxx从哪来); - 熟练用
player的属性(lv/name/job/sex)和方法写业务; - 理解
player:xxx与player.xxx的区别(方法 vs 属性/子模块)。
二、教学重点与难点
重点
class(actor)的作用与返回值;- 玩家构造器如何挂载 12 个子系统(new + require + setmetatable);
- player 的动态属性(attr_map)与缓存机制。
难点
__index元表代理——为什么player.lv能直接读等级;player:send()冒号语法 vsplayer.money:add()冒号语法;- 子模块的
new(player)构造模式。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 回顾前后端,引出「所有业务都围绕 player」 |
| 知识点 1:class 本质 | 15 分钟 | class(actor) + 玩家构造器挂载 12 子系统 |
| 知识点 2:动态属性 + __index | 15 分钟 | attr_map + __index 代理 + 冒号语法 |
| 案例演示 | 15 分钟 | 用 player 写完整业务 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「前几课我们反复出现 local player = class(actor)。这个 player 到底是个啥?为什么 player.lv 能读等级、player.money:add 能加钱?这些『属性』和『方法』是从哪来的?」
「今天把玩家对象彻底讲透。学完之后,你写 90% 的业务,脑子里就一个念头——先 class(actor) 包装,再用 player 干活。」
环节二:知识点 1 —— class(actor) 的本质(15 分钟)
【讲师讲解话术】
「class(actor) 是常用函数里最核心的工具。它的作用就一件事——把引擎的 actor(玩家对象句柄)包装成框架的 player 对象。」
local player = class(actor) -- 包装 player.lv -- 读等级 player.name -- 读名字 player.job -- 读职业 player:send("你好") -- 发消息
「包装完之后,player 上挂满了属性和子系统。这些子系统是哪来的?看玩家构造器的 new 方法——」
function Player.new(actor) local self = setmetatable({ actor = actor }, Player) -- 挂载 12 个子系统 self.money = Money.new(self) -- 货币 self.item = Item.new(self) -- 道具 self.var = Var.new(self) -- 变量 self.buff = Buff.new(self) -- buff self.map = Map.new(self) -- 地图 self.skill = Skill.new(self) -- 技能 self.mail = Mail.new(self) -- 邮件 self.team = Team.new(self) -- 队伍 self.title = Title.new(self) -- 称号 self.guild = Guild.new(self) -- 行会 self.mon = Mon.new(self) -- 怪物宝宝 return self end
「注意看,每个子系统的 new(player) 都缓存了 player 引用,然后挂到 player 对象上。这就是 player.money、player.item 这些属性的来源——它们是 new 时一个个挂上去的。」
【板书】class(actor) → player;new 时挂 12 个子系统(money/item/var/...)
环节三:知识点 2 —— 动态属性与 __index(15 分钟)
【讲师讲解话术】
「现在讲今天第一个难点——player.lv 为什么能直接读等级。答案藏在两个东西里:attr_map 和 __index 元表。」
「先看 attr_map,这是『语义名 → 数字编号』的映射表——」
local attr_map = { lv = 6, -- 等级 → getbaseinfo(actor, 6) name = 1, -- 名字 → getbaseinfo(actor, 1) job = 7, -- 职业 → getbaseinfo(actor, 7) sex = 8, -- 性别 → getbaseinfo(actor, 8) mymap = 3, -- 当前地图 → getbaseinfo(actor, 3) -- ... 41 个动态属性 }
「再看 __index 代理——」
Player.__index = function(self, key) local idx = attr_map[key] if idx then return getbaseinfo(self.actor, idx) -- 动态查引擎属性 end return rawget(self, key) -- 普通字段 end
「原理是这样的:player.lv 先找 player 表里有没有 lv 字段,没有就触发 __index 元方法。元方法发现 lv 在 attr_map 里,就自动调 getbaseinfo(actor, 6)。这就是把魔法数字藏起来的地方——attr_map 是『语义名 → 数字编号』的映射表。」
【板书】player.lv → __index → attr_map[lv]=6 → getbaseinfo(actor,6)
「最后讲冒号语法。大家注意区分——」
player:send("你好") -- 冒号:隐式传 self,调用方法 player.lv -- 点号:读属性 player.money:add("元宝",1) -- money 是子模块,add 是子模块方法
「player:send(...) 等价于 player.send(player, ...)。冒号自动把调用者作为第一个参数 self 传入。记住口诀:冒号调方法,点号取属性/子模块。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写一个完整的玩家业务,把本课知识点串起来——」
function doSomething(actor) local player = class(actor) -- 读属性(点号) local lv = player.lv local name = player.name -- 发消息(冒号方法) player:send("你好," .. name .. ",当前等级 " .. lv) -- 子模块操作(点号取子模块 + 冒号调方法) player.money:add("元宝", 100) player.item:give("炼狱", 1) player.var:setint("已完成任务", 1) -- 链式(give 返回 self) player.item:give("金条", 5):give("随机传送石", 1) end
【讲师逐段讲】
「第一段,读属性用点号——player.lv、player.name。第二段,发消息用冒号——player:send。第三段,子模块操作——先点号拿子模块 player.money,再冒号调方法 :add。第四段,链式——give 返回 self,连续发。」
「大家跟着我的口诀念一遍:点号取属性,冒号调方法,子模块点号取、冒号调。」
环节五:提问互动(5 分钟)
【问 1】「class(actor) 返回什么?」(答:包装好的 player 对象)
【问 2】「player.lv 为什么能读等级?」(答:__index 元表发现 lv 在 attr_map 里,自动调 getbaseinfo(actor, 6)。追问:attr_map 存什么?答:语义名 → 数字编号映射)
【问 3】「player:send(...) 和 player.send(...) 有什么区别?」(答:冒号自动把 player 作为第一个参数 self 传入;点号要手动传)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 写出 class(actor) 的作用。
- player.lv 底层调的是什么?
- 区分
player.lv/player:send()/player.money:add()。 - 写一个「读等级 + 加钱 + 发消息」的完整代码。
【参考答案】
- 把引擎 actor 包装成框架 player 对象,提供属性和子系统方法。
getbaseinfo(actor, 6)(6 是等级编号)。- 属性 / 方法 / 子模块方法。
local p = class(actor); p.money:add("元宝",100); p:send("等级"..p.lv)。
【讲师小结话术】
「今天核心:class(actor) 把 actor 包装成 player,靠 attr_map + __index 读属性,靠 new 挂载 12 个子系统,冒号调方法、点号取属性。下节课讲『玩家属性与消息』,深入 player 的 41 个动态属性和 send/send0/send9/sendClient 各种消息接口。下课。」
五、难点深度解析
难点 1:__index 元表代理的原理
Lua 里 player.lv 先找 player 表里有没有 lv 字段,没有就触发 __index 元方法。框架把 __index 设成一个函数:查 attr_map,命中就调 getbaseinfo,否则 rawget。这是「动态属性」的实现——属性不是存死的,而是每次读取实时查引擎。
难点 2:方法 vs 属性 vs 子模块
player.lv:属性(点号,__index 动态查);player:send():方法(冒号,定义在 Player 上);player.money:add():先点号取子模块 money,再冒号调 add。
判断标准:冒号 = 方法调用;点号 = 取字段/属性/子模块。
难点 3:为什么子模块要 new(player) 而不是直接用 player.actor
子模块需要「既能访问玩家、又能访问引擎 actor」,所以 new 时把两者都缓存。方法内部用 self.actor 调引擎(比如 getbaseinfo(self.actor, 3)),用 self.player 访问玩家其他能力(比如 self.player.mymap)。这保持了「一个模块管一件事」的内聚。
六、板书设计(整课板书汇总)
class(actor) → player 对象 挂载:new 时挂 12 个子系统(money/item/var/buff/map/skill/mail/team/title/guild/mon) 动态属性:attr_map(lv=6,name=1,...)+ __index 代理 语法: 点号 = 属性/子模块 player.lv, player.money 冒号 = 方法 player:send(), player.money:add()
七、课后作业
- 打开玩家构造器源码,找到 attr_map 表,读出 5 个属性的编号对应关系;
- 写一个函数,用 class(actor) 包装后,读取玩家等级、职业、当前地图并打印;
- 找到任意一个子系统源码,确认它的 new(player) 是否缓存了 player 和 actor。
第七课:玩家属性与消息
课时:60 分钟|学员:有 Lua 基础|前置:第六课|教具:投影 / 属性表 / 消息示例
课前准备
- 准备 41 个动态属性中的高频属性表(打印或投影);
- 准备 send / send0 / send9 / sendClient 四种消息的对比示例;
- 准备一个「属性只读」的反例(player.lv = 50 不生效)。
一、教学目标
本课结束后,学员能够:
- 熟练读写 player 的常用动态属性(等级/名字/职业/地图/坐标等);
- 分清 send / send0 / send9 / sendClient 四种消息接口的用途;
- 理解动态属性与「可写属性」的区别(哪些能直接赋值、哪些要用方法);
- 用正确的接口实现「提示、广播、系统消息、前端刷新」四种场景。
二、教学重点与难点
重点
- 41 个动态属性里的高频项(lv/name/job/sex/mymap/x/y/hp/mp);
- 四种消息接口:send(聊天)、send0(聊天框)、send9(系统提示)、sendClient(协议);
- 属性读写 vs 方法操作。
难点
- send0 / send9 的细微差别,容易用混;
- 动态属性是「只读」的——想改等级要用
setlv等方法,不能player.lv = 50; - sendClient 的协议号含义。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:player 属性能直接赋值吗 |
| 知识点 1:高频属性 | 15 分钟 | 41 个动态属性高频项逐一讲 |
| 知识点 2:四种消息 | 15 分钟 | send/send0/send9/sendClient |
| 知识点 3:属性只读 | 10 分钟 | 为什么不能 player.lv = 50 |
| 案例演示 | 8 分钟 | 属性 + 消息综合案例 |
| 提问互动 | 4 分钟 | 3 个问题 |
| 练习与小结 | 3 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「上节课我们知道 player.lv 能读等级。那我现在想一个问题——我想把玩家等级改成 50,能不能直接 player.lv = 50?」
【停顿,看学员反应】
「答案是不能。为什么?因为动态属性是只读的。今天就把这个问题讲透:哪些属性能读、哪些要用方法改,以及怎么给玩家发各种消息。」
环节二:知识点 1 —— 高频动态属性(15 分钟)
【讲师讲解话术】
「attr_map 里有 41 个动态属性,不用全背,记住高频的就行。我们过一遍最常用的——」
| 属性 | 含义 | 底层编号 |
|---|---|---|
player.name | 角色名 | getbaseinfo(actor,1) |
player.account | 账号 | 2 |
player.mymap | 当前地图 | 3 |
player.x / player.y | 坐标 | 4 / 5 |
player.lv | 等级 | 6 |
player.job | 职业 | 7 |
player.sex | 性别 | 8 |
player.hp / player.mp | 血量 / 蓝量 | 相关编号 |
player.guildname | 行会名 | 相关编号 |
player.isgm | 是否 GM | 相关编号 |
「读属性就这么写——」
local player = class(actor) print(player.name, player.lv, player.job) print(player.mymap, player.x, player.y)
「大家记住:这些属性都是只读的。想改它们,要用玩家构造器提供的方法(如 setlv、setname),这个一会儿讲。」
环节三:知识点 2 —— 四种消息接口(15 分钟)
【讲师讲解话术】
「给玩家发消息,框架有四种接口,用途不一样,别用混。」
send:聊天消息(玩家能看到的最常规消息)
player:send("欢迎来到玛法大陆!")
send0:聊天框消息(弹到聊天框特定位置)
player:send0("这是聊天框消息")
send9:系统提示(屏幕中央或顶部的系统提示,最醒目)
player:send9("操作成功!")
sendClient:协议推送(推给前端刷新界面,不是纯文字消息)
player:sendClient(1000, tbl2json(data)) -- 加载前端脚本 player:sendClient(1567) -- 刷新回收界面
【板书对比表】
| 接口 | 用途 | 场景 |
|---|---|---|
| send | 聊天消息 | 普通提示、公告 |
| send0 | 聊天框消息 | 指定位置文字 |
| send9 | 系统提示 | 操作反馈 |
| sendClient | 协议 | 刷新前端界面 |
「一句话记忆:普通信息用 send,需要玩家一定看到的反馈用 send9,聊天框特定位置用 send0,刷新界面用 sendClient。」
环节四:知识点 3 —— 属性只读(10 分钟)
【讲师讲解话术】
「回到开场的问题——为什么 player.lv = 50 不生效?」
player.lv = 50 -- ❌ 错误:动态属性只读,赋值无效 player:setlv(50) -- ✅ 正确:用方法改等级
「原因:动态属性通过 __index 实时查引擎,但没有对应的 __newindex 处理赋值。所以 player.lv = 50 只是往 player 表里塞了个临时字段 lv=50,下次读 player.lv 依然走 __index 查引擎,读到的还是引擎真实等级。」
「改数据必须走方法(setlv、addmoney 等),这些方法内部调引擎的『写』接口。这是框架的一个重要约定:读用属性,写用方法。」
环节五:案例演示(8 分钟)
【讲师演示话术】
「我们看一个综合案例——击杀 boss 后的属性读取 + 消息反馈——」
Event:add("杀怪触发", function(actor, mon, itype, kill, monName, mapID) local player = class(actor) -- 读属性判断 if monName ~= "祖玛教皇" then return end -- 发奖励 player.money:add("元宝", 1000) -- 不同消息接口反馈 player:send("恭喜击杀祖玛教皇!") player:send9("击杀祖玛教皇成功,奖励已发放") end, 1)
「注意 send 和 send9 一起用——send 是聊天记录(可回看),send9 是醒目提示(屏幕弹出)。让玩家既有记录、又不会漏看。」
环节六:提问互动(4 分钟)
【问 1】「player.lv = 50 能改等级吗?为什么?」(答:不能,动态属性只读,__index 实时查引擎,赋值无 __newindex 处理。改等级用 setlv)
【问 2】「send 和 send9 的区别?」(答:send 是聊天消息,send9 是系统提示更醒目。想让玩家明显看到操作成功用 send9)
【问 3】「读玩家坐标用什么属性?」(答:player.x、player.y)
环节七:课堂练习与小结(3 分钟)
【课堂练习】
- 写出 5 个常用动态属性及其含义。
- 改玩家等级的正确写法?
- 给玩家发「操作成功」的系统提示用什么接口?
- 四种消息接口分别是什么?
【参考答案】
- name 角色名、lv 等级、job 职业、mymap 当前地图、x/y 坐标。
player:setlv(50)(不是player.lv = 50)。player:send9("操作成功")。- send(聊天)、send0(聊天框)、send9(系统提示)、sendClient(协议)。
【讲师小结话术】
「今天三个要点:属性只读、改用方法;消息分四种,send 聊天、send9 提示、sendClient 协议。下节课讲事件系统(上),掌握 Event:add / Event:push。下课。」
五、难点深度解析
难点 1:send0 和 send9 到底差在哪
两者都是「提示」,但渲染位置和样式不同。send0 偏向聊天框内消息,send9 偏向屏幕提示。经验法则:普通信息用 send,需要玩家一定看到的操作反馈用 send9,聊天框特定位置用 send0。不确定时,优先 send + send9 组合。
难点 2:为什么动态属性是只读的
__index 只处理「读」,没配 __newindex 处理「写」。所以 player.lv = 50 只是往 player 表里塞了个临时字段,下次 player.lv 依然走 __index 查引擎。改数据必须走方法,方法内部调引擎的写接口。
难点 3:41 个属性记不住怎么办
不需要全背。记住高频的(name/lv/job/sex/mymap/x/y/hp/mp),其余用到时查 attr_map 表或玩家构造器源码。记忆策略:属性名都是英文缩写,见名知义,配合底层编号注释。
六、板书设计(整课板书汇总)
动态属性(只读): name/lv/job/sex/mymap/x/y/hp/mp ... 改数据用方法:player:setlv(50) ✅ player.lv=50 ❌ 四种消息: send 聊天消息 send0 聊天框消息 send9 系统提示 sendClient 协议(刷新前端)
七、课后作业
- 写一个函数,读玩家的等级、职业、坐标,用 send 打印出来;
- 试一下
player.lv = 50再读player.lv,验证属性只读; - 分别用 send、send9 发一条消息,感受两种提示的区别。
第八课:事件系统(上)
课时:60 分钟|学员:有 Lua 基础|前置:第七课|教具:投影 / 广播站比喻图
课前准备
- 准备「广播站」比喻图(主播喊话,听众接收);
- 准备一个可跑的 Event:add / Event:push 最小示例;
- 准备 12 个触发器里用到事件订阅的源码片段。
一、教学目标
本课结束后,学员能够:
- 说清事件系统的三个核心概念:事件名、订阅(add)、推送(push);
- 独立用
Event:add订阅事件、Event:push推送事件; - 理解「一个事件多个订阅者」的广播机制;
- 看懂框架里 12 个触发器如何用事件解耦。
二、教学重点与难点
重点
- Event:add(name, func, priority) 订阅;
- Event:push(name, ...) 推送;
- 事件回调的参数传递(... 变长参数)。
难点
- 订阅发布的「解耦」思想,初学者难理解「为什么绕一圈」;
- 优先级 priority 的作用与执行顺序;
- 事件回调里 actor 是第一参数的约定。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 用「广播站」比喻引入事件 |
| 知识点 1:三大概念 | 15 分钟 | 事件名/订阅/推送 + add/push 基本用法 |
| 知识点 2:广播机制 | 15 分钟 | 多订阅者 + 优先级 + 参数传递 |
| 案例演示 | 15 分钟 | 登录事件广播案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「想象一个广播站:主播喊一句『升级了』,所有调到这台频率的人(听众)都听到。主播不知道有几个听众,听众也不知道谁在喊——他们只通过『频率』连接。」
「框架的事件系统,就是这个广播站。今天讲上半部分——怎么订阅(当听众)、怎么推送(当主播)。」
环节二:知识点 1 —— 三大概念(15 分钟)
【讲师讲解话术】
「事件系统有三个核心概念,先记下来——」
| 概念 | 说明 |
|---|---|
| 事件名 | 一个字符串,如 "升级相关"、"充值相关" |
| 订阅 add | 注册一个回调函数,表示「这个事件发生时执行我」 |
| 推送 push | 触发事件,通知所有订阅者 |
「基本用法——」
-- 订阅:事件名 + 回调 + 优先级 Event:add("升级相关", function(actor, level) local player = class(actor) player:send("你升级到 " .. level .. " 级了!") end, 1) -- 推送:事件名 + 参数 Event:push("升级相关", actor, 46)
「Event:add 第一个参数是事件名,第二个是回调函数,第三个是优先级(数字,越小越先执行)。Event:push 触发事件,后面的参数会传给所有订阅者。」
环节三:知识点 2 —— 广播机制(15 分钟)
【讲师讲解话术】
「事件系统最核心的特性是广播——一个事件可以有多个订阅者,push 一次,全部执行。看这个例子——」
-- 订阅者 1:升级发奖励 Event:add("升级相关", function(actor, level) class(actor).money:add("元宝", 100) end, 1) -- 订阅者 2:升级发提示 Event:add("升级相关", function(actor, level) class(actor):send("升级啦!") end, 2) -- 推送一次,两个订阅者都执行 Event:push("升级相关", actor, 50)
「push 一次,两个订阅者按优先级顺序(1 先 2 后)都执行。触发方不知道有几个订阅者,订阅者也不知道谁触发的——这就是解耦。」
「再讲参数传递——」
Event:add("某个事件", function(a, b, c, d) -- a, b, c, d 对应 push 时的参数 end, 1) Event:push("某个事件", 1, 2, 3, 4)
「push 时传几个参数,回调就收几个。框架约定第一个参数通常是 actor,因为几乎所有业务都要拿玩家对象。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们看一个实战——登录事件广播。登录触发里只 push 一次,各模块各自订阅——」
-- 登录触发里推送一次 Event:push("登录完成", actor) -- 各模块各自订阅,互不干扰 Event:add("登录完成", function(actor) class(actor):send("欢迎回来!") end, 1) Event:add("登录完成", function(actor) -- 检查今日签到 if class(actor).var:getdayvar("今日签到") ~= 1 then class(actor):send9("今日还未签到,记得去签到!") end end, 2) Event:add("登录完成", function(actor) -- 检查是否有未读邮件 end, 3)
【讲师逐段讲】
「登录只 push 一次,三个功能模块各自订阅、各自处理,互不耦合。以后加『登录检查 VIP』,只需再加一个订阅,不用改登录函数。这就是事件解耦的价值——加功能不动老代码。」
环节五:提问互动(5 分钟)
【问 1】「事件系统的三个核心概念是什么?」(答:事件名、订阅 add、推送 push)
【问 2】「Event:add 的三个参数是什么?」(答:事件名、回调函数、优先级。追问:优先级数字越小执行越早还是越晚?答:越小越先执行)
【问 3】「push 一次,多个订阅者都会执行吗?」(答:会,按优先级顺序依次执行,这就是广播机制)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 写出订阅和推送的语法。
- 一个事件能有多少个订阅者?
- 写一个「充值后发消息」的事件订阅。
- 优先级数字 1 和 10,谁先执行?
【参考答案】
Event:add(name, func, priority);Event:push(name, ...)。- 不限,push 一次全部按优先级执行。
Event:add("充值相关", function(actor, gold) class(actor):send("充值成功") end, 1)。- 1 先执行(数字越小越先)。
【讲师小结话术】
「今天核心:事件系统是广播站,add 订阅、push 推送,实现模块间松耦合。下节课讲事件系统(下),深入内部实现——优先级队列、depth 锁防越界这些进阶内容。下课。」
五、难点深度解析
难点 1:为什么「绕一圈」用事件,不直接调用函数
直接调用:A 模块 require B 模块,调 B 的函数——A 依赖 B。事件:A 只 push,B 只 add——A 和 B 互相不知道对方。好处是松耦合:B 可以随时增删改,不影响 A;加新功能只需新增订阅。代价是调试时要搜事件名找订阅者。判断标准:跨模块的功能触发,用事件;同一模块内部,直接调用。
难点 2:优先级的实际意义
有些业务有先后依赖,比如「先清旧称号,再发新称号」,就要控制执行顺序。priority 数字越小越先执行。框架里定时触发的 allsort 就靠优先级保证「先清后发」。
难点 3:回调里为什么第一参数是 actor
这是框架约定,不是强制。约定第一参数传 actor,是因为几乎所有业务都要拿到玩家对象。遵守约定,代码才一致好读。推送时 Event:push("xxx", actor, ...),回调 function(actor, ...) 接住。
六、板书设计(整课板书汇总)
事件系统三要素: 事件名(字符串) 订阅 Event:add(name, func, priority) 推送 Event:push(name, ...) 广播:push 一次 → 所有订阅者按优先级执行(越小越先) 约定:回调第一参数 = actor
七、课后作业
- 写一个自定义事件,add 订阅 + push 触发,验证参数传递;
- 给同一个事件 add 两个订阅者(不同优先级),观察执行顺序;
- 找框架里一个触发器的 Event:add 订阅,看它订阅了什么事件、回调参数有哪些。
第九课:事件系统(下)
课时:60 分钟|学员:有 Lua 基础|前置:第八课|教具:投影 / 事件系统源码
课前准备
- 准备事件系统的数据结构示意图(事件名 → 优先级 → 回调列表);
- 准备一个手写的简化版事件系统代码;
- 准备「事件重复触发」和「事件没触发」两个真实 bug 案例。
一、教学目标
本课结束后,学员能够:
- 理解事件系统的内部实现(事件表 + 订阅列表);
- 掌握「优先级队列」的排序机制;
- 理解「depth 深度锁 + dead 墓碑」防止回调重入/越界的原理;
- 会排查事件相关的常见问题(事件未触发、重复触发、重入死循环)。
二、教学重点与难点
重点
- 事件系统的数据结构:
{ [事件名] = { [优先级] = {回调列表} } }; - 优先级排序 + 依次执行;
- depth 锁防止重入、dead 墓碑标记删除。
难点
- depth 锁 + dead 墓碑的防越界原理(进阶);
- 事件重复订阅导致的重复执行;
- 回调里再次 push 同一事件(重入)的处理。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 回顾上节,引出「内部怎么实现」 |
| 知识点 1:数据结构 + add | 15 分钟 | 事件表 + add 内部逻辑 |
| 知识点 2:push + depth 锁 | 15 分钟 | push 排序 + depth 锁防重入 |
| 案例演示 | 15 分钟 | 手写简化版事件系统 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「上节课我们会用 add/push 了。今天掀开盖子,看事件系统内部怎么实现的。为什么要看内部?因为理解了内部,你遇到『事件没触发』『事件触发两次』这类 bug 才查得出来——这些 bug 十有八九是内部机制没搞懂导致的。」
环节二:知识点 1 —— 数据结构与 add(15 分钟)
【讲师讲解话术】
「事件系统的核心数据结构是这样的——」
local events = { -- 事件名 → 按优先级组织的订阅列表 ["升级相关"] = { [1] = { func1 }, -- 优先级 1 的回调列表 [2] = { func2, func3 }, -- 优先级 2 的回调列表(可多个) }, }
「看懂了吗?两层嵌套:第一层按事件名,第二层按优先级,最后是回调列表。add 的内部逻辑就是把回调按这个结构塞进去——」
function Event:add(name, func, priority) priority = priority or 1 if not events[name] then events[name] = {} end if not events[name][priority] then events[name][priority] = {} end table.insert(events[name][priority], func) end
「三步:确保事件名存在 → 确保优先级存在 → 把回调塞进列表。就这么简单。」
环节三:知识点 2 —— push 与 depth 锁(15 分钟)
【讲师讲解话术】
「再看 push 的内部逻辑——」
function Event:push(name, ...) local handlers = events[name] if not handlers then return end -- 按优先级从小到大排序 local sorted = {} for k, _ in pairs(handlers) do table.insert(sorted, k) end table.sort(sorted) -- 依次执行 for _, prio in ipairs(sorted) do for _, func in ipairs(handlers[prio]) do func(...) end end end
「push 三步:收集所有优先级 → 排序 → 依次执行每个回调。参数 ... 透传给回调。」
「但这里有个隐患——如果某个回调里又 push 了自己这个事件,会无限递归,导致死循环。所以框架加了 depth 锁——」
local depth = 0 function Event:push(name, ...) depth = depth + 1 if depth > 10 then err("事件重入过深: " .. name) -- 判定死循环,中断 depth = depth - 1 return end -- ... 执行回调 depth = depth - 1 end
「depth 记录嵌套深度,每次 push +1、执行完 -1。如果回调里又 push 自己,深度会不断 +1,超过阈值(如 10)就判定死循环并中断。」
「还有 dead 墓碑:如果回调执行过程中删除了某个订阅,直接遍历列表会越界。解决方法是标记 dead = true,遍历时跳过,最后统一清理。这叫『延迟删除』。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们手写一个简化版事件系统,覆盖『数据结构 + 优先级排序 + depth 锁』三大核心——」
local Event = { _events = {}, _depth = 0 } function Event:add(name, func, priority) priority = priority or 1 self._events[name] = self._events[name] or {} self._events[name][priority] = self._events[name][priority] or {} table.insert(self._events[name][priority], func) end function Event:push(name, ...) self._depth = self._depth + 1 if self._depth > 10 then self._depth = self._depth - 1 err("事件重入过深: " .. name) return end local handlers = self._events[name] if handlers then local sorted = {} for k in pairs(handlers) do table.insert(sorted, k) end table.sort(sorted) for _, prio in ipairs(sorted) do for _, func in ipairs(handlers[prio]) do func(...) end end end self._depth = self._depth - 1 end
【讲师逐段讲】
「大家跟着抄一遍,就能理解事件系统全貌。add 塞回调,push 排序执行,depth 防重入。真实框架的事件系统就是这个的加强版,多了 dead 墓碑和更多边界处理。」
环节五:提问互动(5 分钟)
【问 1】「事件系统的数据结构是怎样的?」(答:{ [事件名] = { [优先级] = {回调列表} } })
【问 2】「push 时回调按什么顺序执行?」(答:优先级从小到大排序后依次执行)
【问 3】「depth 锁解决什么问题?」(答:防止回调里再次 push 同一事件导致死循环/重入过深。追问:阈值一般多少?答:常见 10 层,超过判定死循环)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 画出事件系统的数据结构。
- depth 锁的阈值一般是多少?超过会怎样?
- 事件重复触发两次,最可能的原因?
- 手写一个 add 函数(简化版)。
【参考答案】
{ [事件名] = { [优先级] = {回调列表} } }。- 常见 10,超过判定死循环并中断报错。
- 事件被 add 了两次(模块重复加载)。
- 见案例里的
Event:add实现。
【讲师小结话术】
「今天核心:事件系统内部就是『按优先级排序执行回调』,depth 锁防重入、dead 墓碑防越界。下节课讲常量系统,用 Const 收口所有魔法数字。下课。」
五、难点深度解析
难点 1:depth 锁 + dead 墓碑的防越界原理
depth 锁:记录 push 的嵌套深度,每次 push +1、执行完 -1。如果回调里又 push 自己,深度会不断 +1,超过阈值(如 10)就判定死循环,中断并报错。这是「软限制」,防的是无意的重入。
dead 墓碑:回调执行中,如果某个订阅者把自己删了(del),正在遍历的列表就少了一个元素,ipairs 会越界。解决办法是不立即删除,而是标记 dead = true,遍历时跳过,遍历完再清理。这是「延迟删除」策略。
难点 2:事件重复订阅的根因
根因通常是「模块被 require 两次」。框架虽然做了防重复加载(loadedFiles),但如果有代码绕过了防重机制,或者同一段 Event:add 在多个地方写了,就会重复。排查方法:在回调里打印,看执行几次。
难点 3:优先级数字的约定
框架里数字越小越先执行(table.sort 默认升序)。和某些系统「越大越先」相反,学员要记清:本框架 priority 越小越先。需要「先清后发」时,清理逻辑用较小 priority。
六、板书设计(整课板书汇总)
数据结构:{ [事件名] = { [优先级] = {回调列表} } }
push 流程:
1. 收集优先级 → 排序(升序)
2. 依次执行回调
3. depth 锁防重入(>10 报错)
4. dead 墓碑延迟删除
常见 bug:
重复触发 = add 两次
没触发 = 名字/顺序/报错
七、课后作业
- 手写完整的事件系统(含 add/push/depth 锁),跑通一个广播例子;
- 故意写一个「回调里 push 自己」的死循环,观察 depth 锁如何拦截;
- 搜索框架里所有
Event:add("某个事件",检查有没有重复订阅。
第十课:常量系统
课时:60 分钟|学员:有 Lua 基础|前置:第九课|教具:投影 / 常量系统源码
课前准备
- 准备常量系统源码,投影 Const 表的 8 组常量;
- 准备一段「写死数字」的代码,用于改造对比;
- 准备「引擎元宝 ID 从 2 改成 99」的假设场景。
一、教学目标
本课结束后,学员能够:
- 理解常量系统的作用——把散落的魔法数字收口成有名字的
Const; - 熟练使用框架里已有的常量(货币 ID、装备位、buff ID、协议 msgID 等);
- 自己定义业务常量,并把写死的数字替换成
Const.xxx; - 理解「常量集中管理」带来的维护收益。
二、教学重点与难点
重点
- 常量系统的定位与价值(配置驱动思维的落地);
- 框架现有常量的分类(货币/装备位/buff/协议/职业等 8 组);
- 如何新增常量、如何引用。
难点
- 常量表和普通变量的区别(为什么用大写 Const);
- 哪些数字该提成常量、哪些不用(过度抽象的问题);
- 常量命名规范。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 回顾魔法数字痛点,引出常量 |
| 知识点 1:常量定位 | 15 分钟 | 常量系统作用 + Const 表结构 |
| 知识点 2:8 组常量 | 15 分钟 | 各组详解 + 新增与引用 |
| 案例演示 | 15 分钟 | 用常量改造写死数字 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「第一课我们痛恨魔法数字 getbaseinfo(actor, 6)。框架怎么彻底解决它?答案是——把所有魔法数字集中到一个叫『常量系统』的地方,给每个数字起个名字。今天就把这个系统讲透。」
环节二:知识点 1 —— 常量系统定位(15 分钟)
【讲师讲解话术】
「常量系统是『配置驱动思维』的极致体现——把散落在各处的数字/字符串集中到 Const 表,统一命名、统一引用。看它的结构——」
Const = {
货币ID = { 金币 = 1, 元宝 = 2, ... },
装备位 = { 武器 = 5, 衣服 = 6, ... },
buffID = { ... },
msgID = { 自动回收协议 = 1567, ... },
bag_addatt = { ... },
uplv_30 = { ... },
-- 8 组常量
}
「常量在启动时加载,全局可访问。业务代码引用 Const.货币ID.元宝,而不是写死 2。我们对比一下常量和普通变量——」
| 区别 | 常量 | 普通变量 |
|---|---|---|
| 命名 | 大写开头(Const) | 小写 |
| 修改 | 集中一处改 | 分散多处 |
| 语义 | 有名字 | 裸数字 |
【板书】常量系统 = 配置驱动思维的极致:魔法数字 → 有名字的 Const
环节三:知识点 2 —— 8 组常量详解(15 分钟)
【讲师讲解话术】
「框架现有 8 组常量,逐一过一遍——」
| 组 | 内容 | 示例 |
|---|---|---|
| 货币 ID | 金币/元宝/绑定金币等 | Const.货币ID.元宝 |
| 装备位 | 武器/衣服/项链等穿戴位 | Const.装备位.武器 |
| buff ID | 各种 buff 编号 | Const.buffID.xxx |
| 协议 msgID | 前后端协议号 | Const.msgID["自动回收协议"] |
| bag_addatt | 可随机属性的 stdmode | Const.bag_addatt[stdmode] |
| uplv_30 | 35 级职业装备 | Const.uplv_30[player.job] |
| 其他 | 各类业务配置 | ... |
「怎么新增一组常量——」
-- 在常量系统里加一组 Const.我的业务 = { ["每日签到奖励"] = 100, ["邀请奖励"] = 500, }
「怎么引用——」
local reward = Const.我的业务["每日签到奖励"] player.money:add("元宝", reward)
「注意:中文 key 用方括号,这就是第四课学的规范在这里的应用。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们做一次改造——把写死数字改成常量——」
-- 改造前:写死数字 player.money:add("元宝", 2) -- 2 是啥?不知道 -- 改造后:用常量 player.money:add(Const.货币ID.元宝, 100) -- 或者更彻底:货币名也统一 local 货币 = Const.货币ID player.money:add(货币.元宝, 100)
「改造后,Const.货币ID.元宝 一眼就知道是元宝。我再问大家一个灵魂问题——」
【互动提问】「如果哪天引擎的元宝 ID 从 2 变成了 99,你要改几处?」
【点评】「如果用常量,只改常量系统一处(Const.货币ID.元宝 = 99)。如果当初写死数字,全项目搜 2,改无数处,还容易改漏。这就是常量集中管理的价值。」
环节五:提问互动(5 分钟)
【问 1】「常量系统解决什么问题?」(答:把散落的魔法数字集中命名,解决难记、易错、难维护)
【问 2】「框架的常量分哪几组?」(答:货币 ID、装备位、buff ID、协议 msgID、bag_addatt、uplv_30 等 8 组)
【问 3】「哪些数字该提成常量?」(答:跨模块复用、有明确业务含义、可能变化的数字)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 说出常量系统分哪 8 组(至少 4 组)。
- 把
player.money:add("元宝", 100)改成用常量。 - 判断:循环变量
for i=1,5要提成常量吗? - 新增一组业务常量的步骤?
【参考答案】
- 货币 ID、装备位、buff ID、协议 msgID、bag_addatt、uplv_30 等。
player.money:add(Const.货币ID.元宝, 100)。- 不用,局部一次性数字不提。
- 在常量系统文件里加
Const.业务名 = {...},业务里引用。
【讲师小结话术】
「今天核心:常量系统把魔法数字收口成有名字的 Const,一处定义、全局引用、改了只改一处。下节课讲常用函数(调试),掌握 p/dump/err 调试工具。下课。」
五、难点深度解析
难点 1:哪些数字该提成常量
判断标准:
- 跨模块复用:多个地方用到同一个数字 → 提常量;
- 有业务含义:
2代表元宝、5代表武器位 → 提常量; - 可能变化:引擎 ID 可能调整 → 提常量。
反例:循环里的一次性 for i = 1, 3 这种局部数字,不用提。
难点 2:常量命名的规范
框架约定:分组用大写驼峰或中文分组名,如 Const.货币ID、Const.装备位。业务常量建议用中文 key,如 Const.我的业务["每日签到奖励"],见名知义。
难点 3:常量表是「可读不可改」的约定
常量理论上不该被运行时修改。虽然 Lua 里 Const 表能被改,但约定上「只读」。改了会破坏全局一致性。纪律:常量只在常量系统文件里定义,业务代码只读不改。
六、板书设计(整课板书汇总)
常量系统 = 配置驱动思维的极致
Const 表:8 组(货币ID/装备位/buffID/msgID/...)
改造:写死数字 → Const.分组.名字
提常量的标准:跨模块 / 有含义 / 会变化
七、课后作业
- 打开常量系统源码,通读 8 组常量,各找一个代表值;
- 找一段自己写的「写死数字」代码,用常量改造;
- 新增一组业务常量,并在业务里引用它。
第十一课:常用函数(调试)
课时:60 分钟|学员:有 Lua 基础|前置:第十课|教具:投影 / 调试示例
课前准备
- 准备一个含 bug 的代码,现场演示用 p/dump 定位;
- 准备 dump_table 源码(讲循环引用保护);
- 准备一个可跑的调试脚本。
一、教学目标
本课结束后,学员能够:
- 熟练使用
p/dump/err/release_print四个调试函数; - 掌握
dump_table递归打印表的技巧,看懂循环引用保护; - 会利用调试函数定位「变量值不对」「表结构不清」两类问题;
- 养成「改完代码先加 p 验证」的调试习惯。
二、教学重点与难点
重点
- p(打印)、dump(打印表)、err(报错)、release_print(发布日志)的用途;
- dump_table 的递归打印与 key 排序;
- 调试函数的参数与返回值。
难点
- dump_table 的循环引用保护(seen 表);
- p 和 release_print 的区别(调试 vs 正式日志);
- 在事件回调里调试的正确姿势。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:代码出 bug 你第一步做什么 |
| 知识点 1:三个基础调试函数 | 15 分钟 | p / err / release_print |
| 知识点 2:dump / dump_table | 15 分钟 | 打印表 + 循环引用保护 |
| 案例演示 | 15 分钟 | 用调试函数定位真实 bug |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「代码出了 bug,你们的第一反应是什么?很多新手第一反应是『瞎改』——这里改改、那里改改,越改越乱。」
「正确的第一反应是:先看清楚变量到底是多少。框架的常用函数里有一批调试工具,今天学会用它们,出问题自己就能查,不用瞎猜。」
环节二:知识点 1 —— 三个基础调试函数(15 分钟)
【讲师讲解话术】
p:打印(调试用)
p("进入函数") p("玩家等级 =", player.lv) p("元宝数量 =", player.money:get("元宝"))
「p 是最常用的调试打印,接受多个参数,打印到控制台/日志。」
err:报错
if not data then err("数据为空!") return end
「err 抛出错误信息,用于『不该发生的分支』。它能让错误显式暴露,而不是静默继续。」
release_print:发布日志
release_print("[框架报告] 挂载代码数量:", count)
「release_print 是正式日志,用于框架启动报告、重要节点记录。三者的区别:p 是临时调试(用完就删),release_print 是正式输出(留下来)。」
【板书】p 临时调试 / err 显式报错 / release_print 正式日志
环节三:知识点 2 —— dump / dump_table(15 分钟)
【讲师讲解话术】
「p 只能打印单个值,遇到表就无能为力了。这时候用 dump——」
local t = { name = "张三", lv = 50, job = "战士" } dump(t) -- 输出: -- { -- job = "战士", -- lv = 50, -- name = "张三", -- }
「dump 把表递归打印出来,key 会排序,方便看结构。它的核心是 dump_table,里面有个关键技巧——循环引用保护——」
local seen = {} local function dump_table(t, indent) if seen[t] then p("(循环引用)") return end seen[t] = true -- 递归打印... end
「为什么需要保护?如果表里有循环引用(A 引用 B,B 引用 A),不加保护会无限递归,直到栈溢出。seen 表记录『已打印过的表』,再次遇到就跳过。这是任何递归遍历表的地方都能用的技巧。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们现场定位一个 bug。假设有个函数,业务数据不对,我们加调试代码——」
function buggy(actor) local player = class(actor) local data = player.var:gettbl("任务数据") -- 调试:先看 data 到底长啥样 p("=== 任务数据 ===") dump(data) p("===============") if not data then err("任务数据为空!") return end -- 继续业务逻辑... end
【讲师逐段讲】
「遇到『变量值不对』,先 dump 看结构;遇到『不该为空却空了』,先 err 暴露;遇到『走了哪个分支』,先 p 打标记。三个工具配合,90% 的 bug 都能定位。」
环节五:提问互动(5 分钟)
【问 1】「p 和 release_print 的区别?」(答:p 是临时调试打印,release_print 是正式日志)
【问 2】「dump 打印表时做了什么特殊处理?」(答:key 排序 + 循环引用保护(seen 表)。追问:为什么需要循环引用保护?答:表互相引用会无限递归)
【问 3】「遇到『变量值不对』第一步做什么?」(答:用 p/dump 打印看实际值)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 四个调试函数分别是什么?
- dump 的循环引用保护用什么实现?
- 写出「打印玩家等级和元宝」的调试代码。
- 遇到「变量可能为空」应该加什么?
【参考答案】
- p(打印)、dump(打印表)、err(报错)、release_print(发布日志)。
- seen 表记录已打印的表。
p(player.lv, player.money:get("元宝"))。if not x then err("x 为空") return end。
【讲师小结话术】
「今天核心:p 打印、dump 看表、err 报错、release_print 记日志,四个工具配合定位问题。养成习惯:改完代码先 p 验证 → 定位 → 修 → 删 p。下节课讲常用函数(数据),掌握 readxls/readcsv 数据读取。下课。」
五、难点深度解析
难点 1:dump_table 的循环引用保护原理
用 seen 表记录「已打印过的表对象」。进入 dump_table 时,先查 seen:如果已经打印过,说明是循环引用,打印 (循环引用) 并返回,避免无限递归。这是「访问标记」技巧,任何递归遍历表的地方都能用。
难点 2:p 和 release_print 什么时候用哪个
- 开发调试阶段:用 p,随时打印,上线前删掉;
- 正式运行:用 release_print,记录框架启动、重要业务节点,方便查线上问题。
经验:p 是「用完就删」的临时手段,release_print 是「留下来的」正式日志。
难点 3:调试函数可能被「吞掉」
如果在 pcall 里调用调试函数,报错会被吞,导致「明明 p 了却没输出」。排查时注意:调试代码要放在 pcall 之外,或者用 release_print 保证输出。
六、板书设计(整课板书汇总)
调试函数: p 打印(临时调试) dump 打印表(排序 + 循环引用保护) err 报错(异常分支) release_print 发布日志(正式) 习惯:改完先 p 验证 → 定位 → 修 → 删 p
七、课后作业
- 写一个含循环引用的表,用 dump 打印,观察循环引用保护;
- 给自己之前写的一个函数加 p 打标记,验证执行路径;
- 区分 p 和 release_print,各用一次,看输出位置。
第十二课:常用函数(数据)
课时:60 分钟|学员:有 Lua 基础|前置:第十一课|教具:投影 / xls 表结构示例
课前准备
- 准备一个 xls 配置表示例(展示二维数组和字典两种结构);
- 准备 cachecsv 缓存的启动加载代码片段;
- 准备 j2t/t2j、tbl2json/json2tbl 的转换示例。
一、教学目标
本课结束后,学员能够:
- 掌握 readxls / readcsv 读取数据表的方法及两种模式(数组/字典);
- 理解
cachecsv数据核心缓存的机制与用途; - 熟练用 j2t / t2j、tbl2json / json2tbl 做数据格式互转;
- 独立读写 xls/csv 配置,支撑业务数据需求。
二、教学重点与难点
重点
- readxls(pathname, xlstype) 的两种模式;
- readcsv 读 csv 文件;
- j2t/t2j(字符串↔表)、tbl2json/json2tbl(表↔JSON)。
难点
- xlstype 1(二维数组)vs 2(字典)的区别与适用场景;
- 数据缓存的解析缓存 _parsedCache;
- 大数据量时的性能考虑。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:游戏里海量配置数据存哪 |
| 知识点 1:readxls 两种模式 | 15 分钟 | 数组 vs 字典 + readcsv |
| 知识点 2:cachecsv + 格式互转 | 15 分钟 | 缓存 + j2t/t2j |
| 案例演示 | 15 分钟 | 读配置 + 转格式完整案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「游戏里有海量配置——装备属性表、怪物表、掉落表。这些数据存在哪?怎么读出来用?今天讲常用函数里的『数据』工具,搞懂读表和格式转换。」
环节二:知识点 1 —— readxls 两种模式(15 分钟)
【讲师讲解话术】
「readxls 是读数据表的核心函数,它有两种模式,由第二个参数 xlstype 决定——」
-- 模式 1:二维数组(每行是一个数组) local data = readxls("装备表", 1) -- data = { { "炼狱", 30, 10000 }, { "屠龙", 35, 20000 } } -- 模式 2:字典(按 key 索引) local data = readxls("装备表", 2) -- data = { 炼狱 = { 攻击 = 30, 价格 = 10000 }, ... }
「两种模式的区别——模式 1 适合『按行遍历』,模式 2 适合『按名字查』。选择依据很简单:要按名字精确查,用模式 2;要遍历全部,用模式 1。」
「readcsv 是读 csv 文件的封装——」
local data = readcsv("某表") -- 直通引擎 newreadcsv 的封装
环节三:知识点 2 —— cachecsv 缓存 + 格式互转(15 分钟)
【讲师讲解话术】
「先说 cachecsv 缓存。启动初始化时,把『数据核心』目录的 xls 表全部预读进缓存——」
-- 启动初始化里预读数据核心 xls cachecsv[getFileName(v)] = readexcel("../" .. v)
「这样业务脚本直接查缓存,不用每次读磁盘,性能更好。」
「再说格式互转,四对函数——」
-- j2t:JSON 字符串 → 表 local t = j2t('{"name":"张三","lv":50}') -- t2j:表 → JSON 字符串 local s = t2j({ name = "张三", lv = 50 }) -- tbl2json / json2tbl:同上,更通用的别名 local json = tbl2json(t) local t2 = json2tbl(json)
「这四对函数用于数据序列化——存变量、发协议、上报后台时,把表转成字符串;读回来时再转回表。记住:凡是跨边界传递数据,都要序列化成字符串。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们串一个完整数据链路——读配置 + 转 JSON 上报——」
-- 1. 读装备配置 local config = readxls("装备表", 2) local weapon = config["炼狱"] -- 2. 转 JSON 发给前端 local data = { name = "炼狱", atk = weapon.攻击, price = weapon.价格 } player:sendClient(1000, tbl2json(data)) -- 3. 从字符串转回表 local recv = json2tbl('{"result":"ok"}') if recv.result == "ok" then player:send("上报成功") end
【讲师逐段讲】
「读配置 → 组装数据 → 转 JSON 发送 → 收到响应转回表判断。四步走完,数据在『表』和『字符串』之间来回转换,这就是数据工具的核心用法。」
环节五:提问互动(5 分钟)
【问 1】「readxls 的两种模式有什么区别?」(答:模式 1 返回二维数组(按行遍历),模式 2 返回字典(按名字查)。追问:按装备名查攻击力用哪个?答:模式 2)
【问 2】「cachecsv 缓存的是什么?」(答:启动时预读的「数据核心」xls 表)
【问 3】「发协议给前端时,表要转成什么?」(答:JSON 字符串(tbl2json))
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- readxls 的两种模式分别返回什么结构?
- 把一个表
{a=1,b=2}转成 JSON 字符串。 - 存玩家变量前,表要做什么处理?
- cachecsv 缓存的作用?
【参考答案】
- 模式 1 二维数组,模式 2 字典。
tbl2json({a=1,b=2})。- t2j 转成字符串。
- 预读数据核心 xls,业务查缓存不用读磁盘。
【讲师小结话术】
「今天核心:readxls 两种模式读表,cachecsv 缓存提速,j2t/t2j 序列化跨边界传递。下节课讲常用函数(变量),掌握 GetInt/SetInt 引擎变量函数。下课。」
五、难点深度解析
难点 1:两种模式的选择
- 模式 1(二维数组):数据按行组织,
data[1][1]是第一行第一列。适合「遍历所有行」的场景(如批量发奖励)。 - 模式 2(字典):数据按首列做 key,
data["炼狱"]直接查。适合「按名精确查」的场景(如查单个装备属性)。
判断标准:看你是「遍历」还是「查单个」。
难点 2:解析缓存的原理
_parsedCache[1] 和 _parsedCache[2] 分别缓存两种模式的解析结果。第一次读表时解析并缓存,之后直接返回缓存,避免重复解析大表。代价是内存换时间——大表常驻内存。
难点 3:数据格式互转的适用场景
- 存玩家变量:把表 t2j 存进字符串变量,读时 j2t 转回;
- 发前端协议:tbl2json 转 JSON 字符串;
- 上报后台:tbl2json 转 JSON 后 httppost。
记住:凡是「跨边界传递数据」(存变量/发协议/上报),都要序列化成字符串。
六、板书设计(整课板书汇总)
数据函数: readxls(path, type) type 1=二维数组 2=字典 readcsv(path) cachecsv 缓存(数据核心 xls) 格式互转: j2t / t2j JSON ↔ 表 tbl2json/json2tbl 表 ↔ JSON
七、课后作业
- 用 readxls 的两种模式各读一次同一个表,对比返回结构;
- 把一个表用 tbl2json 转成 JSON,再用 json2tbl 转回来,验证一致;
- 找启动初始化里 cachecsv 的代码,理解数据核心 xls 是怎么预读的。
第十三课:常用函数(变量)
课时:60 分钟|学员:有 Lua 基础|前置:第十二课|教具:投影 / 变量分类表
课前准备
- 准备变量五分类表(数字/字符串/表格/天/周);
- 准备 player.var 的接口清单;
- 准备一个签到系统案例(天变量 + 表格变量综合)。
一、教学目标
本课结束后,学员能够:
- 掌握 GetInt / SetInt / GetStr / SetStr 等引擎变量读写函数;
- 理解「数字变量 / 字符串变量 / 表格变量 / 天变量 / 周变量」的分类;
- 熟练用 j2t / t2j 配合表格变量存储复杂数据;
- 理解变量与玩家构造器
player.var的关系。
二、教学重点与难点
重点
- 变量读写函数族(Get/Set Int/Str/Tbl);
- 变量的分类与生命周期(普通/天/周);
- player.var 是对引擎变量的封装。
难点
- 天变量/周变量的「到期清零」机制;
- 表格变量的序列化存储;
- 变量名与「前缀」约定(字符串变量自动加前缀)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:玩家的数据存在哪 |
| 知识点 1:变量分类 + 基础读写 | 15 分钟 | 五分类 + GetInt/SetInt 等 |
| 知识点 2:表格变量 + player.var | 15 分钟 | 序列化 + player.var 封装 |
| 案例演示 | 15 分钟 | 签到系统变量综合案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「玩家的等级、元宝、任务进度、签到状态,这些数据都存在引擎的『变量』里。变量是游戏数据的基石。今天讲变量的分类、读写,以及玩家核心的变量系统 player.var。」
环节二:知识点 1 —— 变量分类与基础读写(15 分钟)
【讲师讲解话术】
「变量分五类,先记下来——」
| 类型 | 用途 | 示例 |
|---|---|---|
| 数字变量 | 存数字 | 等级、元宝、任务计数 |
| 字符串变量 | 存字符串 | 名字、备注 |
| 表格变量 | 存复杂数据 | 任务列表、装备集合 |
| 天变量 | 每日清零 | 今日签到、今日充值 |
| 周变量 | 每周清零 | 本周击杀数 |
「基础读写——」
-- 数字变量 GetInt(0, actor, "变量名") SetInt(0, actor, "变量名", 值) -- 字符串变量 GetStr(actor, "变量名") SetStr(actor, "变量名", 值) -- 天变量 GetTLInt(0, actor, "变量名") SetTLInt(0, actor, "变量名", 值, 到期时间)
「GetInt/SetInt 第一个参数 0 是作用域(玩家个人),actor 是玩家,第三个是变量名。天变量多一个『到期时间』参数。」
环节三:知识点 2 —— 表格变量与 player.var(15 分钟)
【讲师讲解话术】
「引擎没有原生的『表格变量』,所以存表时先序列化成字符串——」
-- 存:表 → JSON 字符串 → 存字符串变量 SetStr(actor, "任务列表", t2j(taskList)) -- 读:字符串 → JSON → 表 local taskList = j2t(GetStr(actor, "任务列表"))
「不过这套手动序列化很麻烦,所以玩家核心的变量系统 player.var 把它们封装成了好用的接口——」
player.var:getint("签到次数") -- 读数字 player.var:setint("签到次数", 1) -- 写数字 player.var:getstr("备注") -- 读字符串 player.var:setstr("备注", "xxx") -- 写字符串 player.var:gettbl("任务列表") -- 读表(自动 j2t) player.var:settbl("任务列表", t) -- 写表(自动 t2j) player.var:getdayvar("今日签到") -- 读天变量 player.var:setdayvar("今日签到", 1)-- 写天变量
「注意:settbl 自动 t2j、gettbl 自动 j2t,你不用手动序列化。这就是封装的价值。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写一个签到系统,综合用天变量和表格变量——」
function 签到(actor) local player = class(actor) -- 用天变量记录今日是否签到 if player.var:getdayvar("今日签到") == 1 then player:send9("今天已经签过啦!") return end -- 发放奖励 player.money:add("元宝", 100) -- 记录签到 player.var:setdayvar("今日签到", 1) -- 用表格变量累加签到记录 local record = player.var:gettbl("签到记录") or {} table.insert(record, os.date("%Y-%m-%d")) player.var:settbl("签到记录", record) player:send("签到成功!") end
【讲师逐段讲】
「天变量管『今日是否签到』——每日自动清零,不用自己写清空逻辑。表格变量存历史签到记录——多天的数据,用表存。两种变量各司其职,覆盖一个签到的完整数据。」
环节五:提问互动(5 分钟)
【问 1】「玩家的数据存在哪?」(答:引擎的变量里,分数字/字符串/表格/天/周)
【问 2】「天变量和普通变量的区别?」(答:天变量到指定时间自动清零,普通变量不自动清。追问:天变量适合存什么?答:每日重置的数据,如今日签到、今日充值)
【问 3】「存一个表到变量,要怎么做?」(答:t2j 序列化成字符串存,读时 j2t 转回。或用 player.var:settbl 自动处理)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 变量分哪几类?
- 每日重置的数据用哪类变量?
- 存一个任务表到玩家变量。
- 读数字变量的正确接口?
【参考答案】
- 数字、字符串、表格、天、周。
- 天变量。
player.var:settbl("任务列表", taskTable)。player.var:getint("变量名")。
【讲师小结话术】
「今天核心:变量按生命周期分五类,player.var 封装读写,表格变量自动序列化。下节课讲货币系统,深入 add/dec/set/check/get 货币操作。下课。」
五、难点深度解析
难点 1:天/周变量的到期清零机制
天变量通过「到期时间」参数控制,到期后引擎自动清零。所以「今日签到」用天变量,过了午夜自动重置,不用自己写清空逻辑。判断标准:需要每日/每周重置的数据,用天/周变量;永久数据用普通变量。
难点 2:表格变量的序列化
引擎只支持数字和字符串变量,存表必须序列化。player.var:settbl 内部做了 t2j,gettbl 做了 j2t,学员不用手动处理。但要理解:底层存的是 JSON 字符串,不是真的表,所以大表存储会有序列化开销。
难点 3:字符串变量的前缀约定
部分引擎的字符串变量有「自动加前缀」机制(如读时自动加 S 前缀)。这就是为什么「数字变量列表」和「字符串变量列表」要分开处理。学员遇到字符串变量读不到时,检查是否前缀问题。
六、板书设计(整课板书汇总)
变量分类:数字 / 字符串 / 表格 / 天 / 周 读写: player.var:getint/setint 数字 player.var:getstr/setstr 字符串 player.var:gettbl/settbl 表格(自动 t2j/j2t) player.var:getdayvar/setdayvar 天 player.var:getweekvar/setweekvar 周
七、课后作业
- 写出五类变量的各一个使用场景;
- 用表格变量存一个「任务列表」,再读出来遍历;
- 用天变量做一个「每日首次登录」的提示。
第十四课:货币系统
课时:60 分钟|学员:有 Lua 基础|前置:第六课|教具:投影 / 货币类型表
课前准备
- 准备 15 种货币类型清单;
- 准备 check 双返回值的演示代码;
- 准备一个「转职扣费」的完整案例。
一、教学目标
本课结束后,学员能够:
- 说出框架支持的货币类型(金币/元宝/绑定金币等 15 种);
- 熟练用 add / dec / set / get / check 操作货币;
- 掌握「先 check 再扣款」的防透支写法;
- 理解 check 返回两个值(是否够 + 当前数量)的用法。
二、教学重点与难点
重点
- 货币增删查(add/dec/set/get);
- check 防透支检查;
- 货币名 → ID 映射表。
难点
- check 返回两个值的正确使用;
- del 与 dec 的区别(del 是 dec 的别名);
- 货币的别名(如「荣誉」)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:扣钱前要做什么 |
| 知识点 1:货币类型 + 增删查 | 15 分钟 | 15 种货币 + add/dec/set/get |
| 知识点 2:check 双返回值 | 15 分钟 | check + del 别名 + 货币映射 |
| 案例演示 | 15 分钟 | 带检查的扣款完整案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「玩家充钱、买装备、学技能,都涉及货币。扣钱最怕什么?扣成负数!所以扣钱前必须先检查余额。今天讲货币系统,重点是『先 check 再扣』。」
环节二:知识点 1 —— 货币类型与增删查(15 分钟)
【讲师讲解话术】
「框架支持 15 种货币——」
金币、元宝、绑定金币、绑定元宝、金刚石、经验、灵符、 泡点、荣誉值、游戏点、充值点、鲜花、PK点、积分、声望
「增删查四个操作——」
player.money:add("元宝", 100) -- 加 100 元宝 player.money:dec("金币", 500) -- 扣 500 金币 player.money:get("元宝") -- 查余额(返回数字) player.money:set("金币", 0) -- 直接设置余额为 0
「add 加、dec 扣、get 查、set 直接设。货币名用字符串,如 "元宝"。为什么用名字不用编号?因为框架内部有『货币名 → ID』映射表,帮你把名字转成引擎编号,你只管写名字。」
环节三:知识点 2 —— check 双返回值(15 分钟)
【讲师讲解话术】
「扣款前要检查余额,用 check。它有个特殊之处——返回两个值——」
local ok, num = player.money:check("元宝", 50) -- ok:是否够(布尔) -- num:当前数量(数字)
「第一个值判断够不够,第二个值是当前数量。很多人只用第一个值,忽略了第二个值。但第二个值在提示差额时很有用——」
if not player.money:check("元宝", 1000) then player:send("元宝不足,需要 1000!") return end player.money:dec("元宝", 1000)
「更好的写法是用第二个值提示差额:『你只有 300,还差 700』。另外 del 是 dec 的别名,两个等价。」
【板书】check(类型,数量) → (是否够, 当前数量);del = dec 别名
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写一个转职扣费的完整案例,覆盖防透支——」
function 转职(actor, targetJob) local player = class(actor) local cost = 1000 -- 1. 检查余额 local ok, current = player.money:check("元宝", cost) if not ok then player:send9("转职需要 " .. cost .. " 元宝,你只有 " .. current .. "!") return end -- 2. 扣款 player.money:dec("元宝", cost) -- 3. 转职 player:setjob(targetJob) player:send("转职成功!") end
【讲师逐段讲】
「注意 check 的第二个返回值 current 用于提示『你只有多少』,用户体验更好。顺序是:先 check → 扣款 → 执行业务,三步缺一不可。这就是防透支的标准写法。」
环节五:提问互动(5 分钟)
【问 1】「加钱用哪个方法?」(答:add)
【问 2】「check 返回什么?」(答:两个值——是否足够(布尔)+ 当前数量。追问:第二个值有什么用?答:提示玩家差额)
【问 3】「del 和 dec 什么关系?」(答:del 是 dec 的别名,等价)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 写出 5 个货币名。
- 写一个「检查再扣款」的兑换逻辑。
- 用 get 查玩家元宝余额。
- check 的两个返回值分别是什么?
【参考答案】
- 金币、元宝、绑定金币、金刚石、经验。
if not p.money:check("元宝",100) then return end; p.money:dec("元宝",100)。player.money:get("元宝")。- 是否足够(布尔)、当前数量(数字)。
【讲师小结话术】
「今天核心:货币操作 add/dec/set/get,check 返回双值防透支,先检查再扣款。下节课讲道具系统,掌握 give/getname/getstd/addatt 等 36 个接口。下课。」
五、难点深度解析
难点 1:check 两个返回值怎么用
很多人只用第一个值判断,忽略了第二个值。第二个值(当前数量)在提示差额时很有用,比如「你只有 300,还差 700」。最佳实践:local ok, current = check(...),if not ok 时用 current 拼提示语。
难点 2:为什么要防透支
引擎底层允许扣成负数(余额变负),但这会破坏游戏经济平衡。所以框架约定「扣款前先 check」。这是业务规范,不是语法强制。写代码时养成「先检查、再扣款」的习惯。
难点 3:货币名 → ID 映射的价值
业务代码用 "元宝" 这样的名字,框架内部查映射表转成编号。好处是:如果引擎的元宝编号变了,只改映射表一处。这也是「配置驱动」思维的体现。
六、板书设计(整课板书汇总)
货币类型(15种):金币/元宝/绑定金币/金刚石/经验... 操作: add 加 / dec 扣 / set 设 / get 查 check(类型,数量) → (是否够, 当前数量) del = dec 别名 防透支:先 check 再 dec
七、课后作业
- 写一个「购买物品」函数,含 check 防透支;
- 用 check 的第二个返回值,写一个提示差额的提示语;
- 查一下货币系统源码,确认「货币名 → ID」映射表里有哪些货币。
第十五课:道具系统
课时:60 分钟|学员:有 Lua 基础|前置:第十四课|教具:投影 / 道具接口表
课前准备
- 准备道具系统的常用接口清单(36 个里的高频项);
- 准备 stdmode 编号说明表;
- 准备一个「打造幸运武器」的完整案例。
一、教学目标
本课结束后,学员能够:
- 熟练用 give / take / check 发放、回收、检查道具;
- 掌握 getname / getstd / getcount 查询道具信息;
- 理解 addatt / addaddatt 给装备附加属性的机制;
- 掌握道具系统的链式调用(give 返回 self)。
二、教学重点与难点
重点
- give(发)、take(收)、check(检查);
- 道具信息查询(getname/getstd/getcount);
- 附加属性(addatt/addaddatt)。
难点
- stdmode(物品类别编号)的判断;
- addatt 的属性表格式
{ [属性编号] = 值 }; - 链式调用在道具系统的应用。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:发装备用什么 |
| 知识点 1:基础操作 | 15 分钟 | give/take/check |
| 知识点 2:查询 + 附加属性 | 15 分钟 | getstd/addatt |
| 案例演示 | 15 分钟 | 打造幸运武器案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「发装备、收道具、查背包,是传奇业务最高频的操作。框架的道具系统有 36 个接口,但常用的就十几个。今天把常用的讲透,够你写 80% 的装备相关业务。」
环节二:知识点 1 —— 基础操作(15 分钟)
【讲师讲解话术】
「三个基础操作:give 发、take 收、check 检查——」
player.item:give("炼狱", 1) -- 发 1 把炼狱 player.item:give("金条", 5) -- 发 5 根金条 player.item:give("炼狱", 1):give("金条", 5) -- 链式发多个 player.item:take("金条", 1) -- 收回 1 根金条 if not player.item:check("千里传音") then player:send("缺少千里传音!") return end
「注意 give 返回 self,支持链式——这是第二课讲的链式调用在道具系统的应用。take 是回收,check 是检查是否拥有。」
环节三:知识点 2 —— 查询与附加属性(15 分钟)
【讲师讲解话术】
「查询道具信息——」
local name = player.item:getname(itemid) -- 拿道具名 local std = player.item:getstd(itemid) -- 拿物品类别编号 local count = player.item:getcount("金条") -- 拿背包数量
「其中 getstd 返回的是 stdmode——物品类别编号。5/6 是武器、10/11 是衣服、25 是特殊物品……业务里常用来判断『这是不是武器』。」
「附加属性 addatt——」
-- 给道具加属性:属性编号 39 = 幸运,1 = 攻击 等 player.item:addatt(itemid, { [39] = 1 }) -- 加 1 点幸运 player.item:addatt(itemid, { [1] = 10 }) -- 加 10 点攻击
「addatt 的参数是一个『属性编号 → 值』的表。属性编号是什么含义,查装备属性表。这也是魔法数字,所以属性编号要用常量。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写一个『打造幸运武器』的完整案例——」
function 打造幸运武器(actor, itemid) local player = class(actor) -- 检查是不是武器 local std = player.item:getstd(itemid) if std ~= 5 and std ~= 6 then player:send("这不是武器!") return end -- 检查材料 if not player.item:check("祝福油") then player:send("缺少祝福油!") return end -- 消耗材料 + 加幸运 player.item:take("祝福油", 1) player.item:addatt(itemid, { [39] = 1 }) player:send("幸运 +1!") end
【讲师逐段讲】
「stdmode 判断类型 → check 材料 → take 消耗 → addatt 加属性。道具系统的核心操作串成完整业务。这个模式——先判断类型、再检查材料、再消耗、最后加属性——是装备打造类功能的标准套路。」
环节五:提问互动(5 分钟)
【问 1】「发装备用什么?」(答:player.item:give("装备名", 数量))
【问 2】「getstd 返回什么?」(答:物品类别编号(stdmode),如 5/6 是武器。追问:怎么判断道具是不是武器?答:local std = player.item:getstd(id); if std == 5 or std == 6 then ...)
【问 3】「give 能链式调用吗?为什么?」(答:能,因为 give 返回 self)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 写出道具系统最常用的 3 个操作。
- 怎么判断一件道具是不是武器?
- 给道具加 10 点攻击。
- give 为什么能链式?
【参考答案】
- give(发)、take(收)、check(检查)。
player.item:getstd(id)判断 stdmode 是否为武器编号。player.item:addatt(itemid, { [攻击编号] = 10 })。- 因为返回 self。
【讲师小结话术】
「今天核心:give/take/check 操作道具,getstd 判断类型,addatt 加属性,操作类返回 self 可链式。下节课讲变量系统,深入 player.var 的 15 个接口。下课。」
五、难点深度解析
难点 1:stdmode 是什么
stdmode 是引擎里「物品类别」的编号:武器、衣服、项链、戒指、消耗品等各有编号。业务里常用它判断物品类型(如「这是武器」「这是消耗品」)。框架里 stdmode 编号有常量,不用死记,查常量系统。
难点 2:addatt 的属性编号
addatt(itemid, { [39] = 1 }) 里的 39 是「幸运」属性编号。不同属性有不同编号(攻击、魔法、道术、幸运……)。这些编号也是魔法数字,应该用常量或查属性表。记忆策略:编号含义查装备属性表,业务里优先用常量。
难点 3:道具系统的链式调用
give 返回 self,所以能链式 :give(a):give(b):give(c)。但不是所有道具接口都返回 self——getname、getstd、check 返回的是「值」或「布尔」,不能链式。判断标准:看接口是「操作」还是「查询」——操作类返回 self,查询类返回值。
六、板书设计(整课板书汇总)
道具系统: give 发(返回 self,可链式) take 收 check 检查 getname/getstd/getcount 查询 附加属性:addatt(itemid, { [编号]=值 }) stdmode:物品类别编号
七、课后作业
- 写一个「发新手装备」的链式调用,一次发 3 件;
- 写一个「交任务」函数,check 任务道具 + take 回收 + 发奖励;
- 查一下 stdmode 编号表,记下武器、衣服、消耗品的编号。
第十六课:变量系统
课时:60 分钟|学员:有 Lua 基础|前置:第十三课|教具:投影 / 变量接口表
课前准备
- 准备变量系统 15 个接口清单;
- 准备表格变量自动序列化的演示代码;
- 准备一个任务系统的综合案例。
一、教学目标
本课结束后,学员能够:
- 熟练用 player.var 的 15 个接口读写各类变量;
- 掌握数字/字符串/表格/天/周变量的读写与适用场景;
- 理解表格变量的自动序列化(settbl/gettbl);
- 掌握 pushall 推送变量到客户端等进阶用法。
二、教学重点与难点
重点
- 15 个接口的分类(数字/字符串/表格/天/周);
- settbl/gettbl 自动序列化;
- pushall 推送机制。
难点
- 天变量与充值日变量的区别;
- 表格变量的性能(大表序列化开销);
- setstr 推送时误传 Lua 表的问题(曾修过的 bug)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 回顾第十三课变量分类 |
| 知识点 1:15 个接口 | 15 分钟 | 逐一讲解 |
| 知识点 2:序列化 + pushall | 15 分钟 | 表格变量 + 推送 |
| 案例演示 | 15 分钟 | 任务系统变量综合案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「第十三课讲了引擎的变量函数。今天讲变量系统(player.var)——它把这些引擎函数封装成了更好用的接口。这 15 个接口,是业务里读写数据的核心,必须烂熟于心。」
环节二:知识点 1 —— 15 个接口逐一讲解(15 分钟)
【讲师讲解话术】
「15 个接口分五类,逐一过——」
-- 数字变量 player.var:getint("变量名") -- 读 player.var:setint("变量名", 值) -- 写(可带过期时间参数) -- 字符串变量 player.var:getstr("变量名") player.var:setstr("变量名", 值) -- 表格变量 player.var:gettbl("变量名") -- 读表(自动 j2t) player.var:settbl("变量名", 表) -- 写表(自动 t2j) -- 天变量 player.var:getdayvar("变量名") player.var:setdayvar("变量名", 值) -- 周变量 player.var:getweekvar("变量名") player.var:setweekvar("变量名", 值) -- 其他 player.var:pushall() -- 推送所有变量到客户端 player.var:getvar(作用域, "变量名") -- 通用读
「核心是数字、字符串、表格三类,加上天/周两个『自动清零』变体。掌握这五类,业务数据读写就全通了。」
环节三:知识点 2 —— 序列化与 pushall(15 分钟)
【讲师讲解话术】
「表格变量的自动序列化是重点——」
-- 存:settbl 自动 t2j local tasks = { ["杀怪"] = 10, ["采集"] = 5 } player.var:settbl("任务进度", tasks) -- 读:gettbl 自动 j2t local tasks = player.var:gettbl("任务进度") p(tasks["杀怪"]) -- 10
「你不用手动 t2j/j2t,settbl/gettbl 帮你做了。但记住:底层存的是 JSON 字符串,不是真表。」
「pushall 推送——」
player.var:setint("金币", 100) player.var:pushall() -- 把变量变化同步到客户端
「变量改了之后,客户端不一定知道,需要 pushall 推送。GM 重载、变量批量变更后常用。」
【易错点】「这里有个真实的坑——setstr 推送时如果误传 Lua 表,会导致序列化错误。字符串变量传字符串,表格用 settbl,别混。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写一个任务系统,综合用三类变量——」
function 接任务(actor) local player = class(actor) player.var:setint("任务状态", 1) -- 数字:状态 player.var:settbl("任务进度", { ["杀怪"] = 0, ["采集"] = 0 }) -- 表格:进度 local times = player.var:getdayvar("今日接任务") -- 天:每日次数 player.var:setdayvar("今日接任务", times + 1) player:send("已接取任务!") end function 更新进度(actor, taskName) local player = class(actor) local progress = player.var:gettbl("任务进度") or {} progress[taskName] = (progress[taskName] or 0) + 1 player.var:settbl("任务进度", progress) end
【讲师逐段讲】
「数字变量管状态、表格变量管进度、天变量管每日次数。三类变量各司其职,覆盖一个任务的完整数据。注意 gettbl("任务进度") or {} 这个判空——防止变量没初始化时返回 nil 导致报错。」
环节五:提问互动(5 分钟)
【问 1】「变量系统有多少个接口?分几类?」(答:15 个,分数字/字符串/表格/天/周五类)
【问 2】「settbl 和 gettbl 内部做了什么?」(答:settbl 自动 t2j 序列化,gettbl 自动 j2t 反序列化。追问:存的是真表还是字符串?答:底层是 JSON 字符串)
【问 3】「pushall 的作用?」(答:把变量变化同步到客户端)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 写出 15 个接口里的 5 个。
- 存一个表到变量,用哪个接口?
- 每日签到次数用哪个接口?
- 变量改了之后怎么同步客户端?
【参考答案】
- getint/setint/getstr/setstr/gettbl/settbl/getdayvar/setdayvar 等。
player.var:settbl("变量名", 表)。player.var:getdayvar/setdayvar。player.var:pushall()。
【讲师小结话术】
「今天核心:变量系统封装引擎变量为五类接口,表格变量自动序列化,天/周变量自动清零。下节课讲 buff 系统,掌握 add/del/has 等 7 个接口。下课。」
五、难点深度解析
难点 1:表格变量的性能
表格变量底层是 JSON 字符串,每次 settbl 要序列化、gettbl 要反序列化。大表(几千条)频繁读写会有性能开销。所以:小数据(任务进度)用表格变量没问题;大数据(全服排行)用系统变量(getsysvar_tbl)更合适。
难点 2:天变量 vs 充值日变量
天变量按「自然日」清零(午夜)。充值日变量是「充值相关的日统计」,有专门的 Z89 处理。学员要区分:普通每日数据用天变量,充值统计用充值日变量。
难点 3:setstr 传表的坑
setstr 期望字符串,如果误传 Lua 表,序列化会出错(这是框架早期修过的真实 bug)。规则:字符串变量传字符串,表格变量用 settbl(它会自动序列化)。
六、板书设计(整课板书汇总)
变量系统(15 接口):
数字 getint/setint
字符串 getstr/setstr
表格 gettbl/settbl(自动 t2j/j2t)
天 getdayvar/setdayvar
周 getweekvar/setweekvar
pushall 推送客户端
注意:字符串变量传字符串,表格用 settbl
七、课后作业
- 用 settbl/gettbl 存读一个任务表,验证自动序列化;
- 用天变量做一个「每日首登」提示;
- 查变量系统源码,找出 15 个接口的完整清单。
第十七课:buff 系统
课时:60 分钟|学员:有 Lua 基础|前置:第十六课|教具:投影 / buff 示例
课前准备
- 准备 buff 概念图(增益/减益、ID、时长);
- 准备「加速 + 中毒」两个方向的 buff 示例;
- 准备 getall 的调试演示。
一、教学目标
本课结束后,学员能够:
- 理解 buff(增益/减益效果)在游戏里的作用;
- 熟练用 add / del / has / check 操作 buff;
- 理解 buff 的时长参数与叠加规则;
- 会用 buff 实现「加速、加攻、中毒」等效果。
二、教学重点与难点
重点
- add(加 buff,带时长)、del(移除)、has(检查);
- buff ID 的含义(buff 编号);
- getall 获取全部 buff。
难点
- buff 时长参数的设置;
- has 和 check 的关系(check 复用 has);
- buff 的覆盖/叠加规则。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:游戏里中毒、加速怎么实现 |
| 知识点 1:buff 概念 + add/del | 15 分钟 | buff 概念 + 基本用法 |
| 知识点 2:has/check/getall | 15 分钟 | 检查 + 时长 + 获取全部 |
| 案例演示 | 15 分钟 | 加速 buff + 中毒效果案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「游戏里角色加速、攻击加成、中毒掉血,这些『状态效果』在框架里统一叫 buff。今天讲 buff 系统,学会给玩家加各种状态。」
环节二:知识点 1 —— buff 概念与基本用法(15 分钟)
【讲师讲解话术】
「buff 是什么?就是附着在玩家身上的『临时状态』,有 ID、有持续时间。比如『加速 buff』让玩家移动更快,『中毒 buff』让玩家持续掉血。」
「add 加 buff——」
player.buff:add(buffID, 时长) -- 例:加一个持续 60 秒的加速 buff player.buff:add(1001, 60)
「del 移除 buff——」
player.buff:del(buffID)
「add 第一个参数是 buff 编号(ID),第二个是持续时间(秒)。del 按编号移除。到时间后 buff 自动消失。」
环节三:知识点 2 —— has / check / getall(15 分钟)
【讲师讲解话术】
「has 检查是否有某 buff——」
if player.buff:has(buffID) then player:send("已经有这个 buff 了") end
「check 同 has——框架里 check 内部复用 has,两个逻辑相同——」
if player.buff:check(buffID) then ... end
「getall 获取全部 buff——」
local allBuffs = player.buff:getall() for _, b in pairs(allBuffs) do p(b) end
「getall 返回玩家当前所有 buff,用于调试或批量处理。buff ID 是数字编号,含义查 buff 配置表,框架常量系统里也有 buffID 常量组。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写两个案例——喝药加速 + 施毒——」
function 喝加速药(actor) local player = class(actor) -- 检查是否已有加速 if player.buff:has(加速buffID) then player:send("加速效果还在!") return end -- 加 60 秒加速 player.buff:add(加速buffID, 60) player:send("移动速度提升 60 秒!") end function 施毒(actor, target) local targetPlayer = class(target) -- 给目标加中毒 buff targetPlayer.buff:add(中毒buffID, 30) targetPlayer:send("你中毒了!") end
【讲师逐段讲】
「has 检查避免重复加 buff,add 加 buff。注意 buff 的『增益』(加速)和『减益』(中毒)都走同一套接口——add 加、del 删。给别的玩家加 buff(施毒),就用 target 包装成 player 再操作。」
环节五:提问互动(5 分钟)
【问 1】「buff 是什么?」(答:附着在玩家身上的临时状态(增益/减益),有 ID 和持续时间)
【问 2】「add 的两个参数是什么?」(答:buff ID 和持续时间(秒))
【问 3】「has 和 check 什么关系?」(答:逻辑相同,check 复用 has。追问:所以用哪个都行?答:对,行为一致)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 给玩家加一个持续 30 秒的 buff。
- 检查玩家是否有某 buff。
- 移除 buff。
- buff 系统有哪几个接口?
【参考答案】
player.buff:add(buffID, 30)。player.buff:has(buffID)。player.buff:del(buffID)。- add/del/has/hasbuff/check/getall(7 个)。
【讲师小结话术】
「今天核心:buff 是临时状态,add 加、del 删、has 查,ID 用常量。下节课讲地图系统,掌握 gohome/move/getmoncount 地图操作。下课。」
五、难点深度解析
难点 1:buff 时长的意义
add 的第二个参数是「持续时间(秒)」。到时间后 buff 自动消失。时长为 0 或负数,表示永久 buff(视引擎实现)。约定:临时效果给正时长,永久效果给 0 或特殊值。
难点 2:buff ID 是魔法数字
buff ID 是数字编号,含义要查 buff 配置表。框架常量系统里通常有 buffID 常量组,业务里优先用常量。记忆策略:buff ID 用常量,见名知义。
难点 3:has 和 check 为什么重复
框架早期 has 和 check 是两份逻辑相同的代码,重构时把 check 改成复用 has,消除重复。学员记住:这俩一个意思,用哪个都行,推荐 has。
六、板书设计(整课板书汇总)
buff 系统(7 接口): add(buffID, 时长) 加 buff del(buffID) 移除 has/check(buffID) 检查(check 复用 has) getall() 获取全部 buff = 临时状态,有 ID + 时长
七、课后作业
- 写一个「VIP 登录自动加 buff」的函数;
- 用 getall 打印玩家的所有 buff,观察结构;
- 查 buffID 常量,找 2 个常用 buff 的编号。
第十八课:地图系统
课时:60 分钟|学员:有 Lua 基础|前置:第十七课|教具:投影 / 地图操作示例
课前准备
- 准备 move 四参数(地图/坐标/范围)的示意图;
- 准备一个「副本入口 + 刷怪检查」的案例;
- 准备阵营回城的分支逻辑示例。
一、教学目标
本课结束后,学员能够:
- 熟练用 gohome / move 实现回城、传送;
- 掌握 getmoncount 统计地图怪物数量;
- 理解地图操作的坐标参数与范围参数;
- 会用地图系统实现「副本、传送点、刷怪」等业务。
二、教学重点与难点
重点
- gohome(回城)、move(传送到指定地图坐标);
- getmoncount(统计怪物数);
- 地图 ID 的表示(地图名/地图编号)。
难点
- move 的坐标 + 范围参数;
- 默认地图的获取(player.mymap);
- 阵营相关的回城分支逻辑。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:传送、回城怎么实现 |
| 知识点 1:gohome/move | 15 分钟 | 回城 + 传送 |
| 知识点 2:getmoncount + 坐标 | 15 分钟 | 统计怪物 + 坐标范围 |
| 案例演示 | 15 分钟 | 副本 + 传送点案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「玩家回城、进副本、传送到盟重,这些『地图切换』操作都在地图系统里。今天学地图系统,重点是 move 的坐标参数,以及统计怪物数。」
环节二:知识点 1 —— gohome / move(15 分钟)
【讲师讲解话术】
「gohome 回城——」
player.map:gohome() -- 传送回出生地/安全区
「move 传送到指定地图坐标——」
player.map:move("sldg", 337, 405, 3) -- 参数:地图ID, X坐标, Y坐标, 范围
「move 四个参数——地图 ID(字符串,如 "sldg" 是沙巴克)、X 坐标、Y 坐标、范围(在坐标周围随机落点)。」
「读当前地图和坐标——」
local mapID = player.mymap -- 当前地图 local x = player.x -- 当前 X local y = player.y -- 当前 Y
环节三:知识点 2 —— getmoncount 与坐标范围(15 分钟)
【讲师讲解话术】
「getmoncount 统计怪物数量——」
local count = player.map:getmoncount("sldg") -- 沙巴克怪物数 local count = player.map:getmoncount() -- 当前地图怪物数
「传地图 ID 统计指定地图,不传则统计当前地图(内部用 player.mymap)。」
「坐标 + 范围——」
player.map:move("sldg", 337, 405, 3) -- 在 (337,405) 周围 3 格范围内随机落点,避免多人堆叠
「范围参数让玩家传送后不重叠。范围越大,落点越分散。范围 0 表示精确落点。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写副本入口 + 回城的案例——」
function 进副本(actor) local player = class(actor) -- 检查副本怪物是否清完 local count = player.map:getmoncount("副本地图ID") if count > 0 then player:send("副本怪物未清完,无法进入!") return end -- 传送进副本 player.map:move("副本地图ID", 100, 100, 2) player:send("进入副本!") end function 回城(actor) class(actor).map:gohome() end
「再看阵营回城——」
function 回城(actor) local player = class(actor) local camp = player.var:getint("阵营") if camp == 0 then player.map:move("sldg", 337, 405, 3) -- 无阵营回沙巴克 else player.map:move(camp .. "_3", 325, 326, 1) -- 阵营回各自主城 end end
【讲师逐段讲】
「getmoncount 检查怪物 → move 传送,这是副本系统核心逻辑。阵营回城用变量拼接地图 ID,是地图系统与变量系统的结合。」
环节五:提问互动(5 分钟)
【问 1】「回城用什么?」(答:player.map:gohome())
【问 2】「move 的四个参数是什么?」(答:地图 ID、X 坐标、Y 坐标、范围。追问:范围参数有什么用?答:在坐标周围随机落点,避免多人堆叠)
【问 3】「统计当前地图怪物数怎么写?」(答:player.map:getmoncount()(不传参数默认当前地图))
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 回城的写法。
- 传送到沙巴克 (337,405)。
- 统计盟重怪物数。
- 读玩家当前地图和坐标。
【参考答案】
player.map:gohome()。player.map:move("sldg", 337, 405, 3)。player.map:getmoncount("mz")。player.mymap、player.x、player.y。
【讲师小结话术】
「今天核心:gohome 回城、move 传送、getmoncount 统计怪物,地图 ID 是字符串。注意一个易错点——在子模块里拿玩家属性用 self.player.xxx,不是 self.xxx。下节课讲技能系统。下课。」
五、难点深度解析
难点 1:地图 ID 的表示
地图 ID 是字符串(如 "sldg"、"mz"、"副本地图ID")。有些地图 ID 带阵营后缀(如 "1_3" 表示阵营 1 的三大陆)。记忆策略:地图 ID 查地图配置,业务里用变量拼接时要小心拼接结果正确性。
难点 2:move 的范围参数
范围参数控制落点的随机半径。范围 0 表示精确落点,范围 3 表示周围 3 格内随机。经验:传送用范围 2~3 避免堆叠,精确场景(如出生点)用 0 或 1。
难点 3:默认地图的坑
getmoncount 默认地图曾有个 bug——内部用了 self.mymap(模块实例没有这个字段,恒 nil),应改为 self.player.mymap(玩家构造器的动态属性)。学员记住:在子模块里要拿玩家属性,用 self.player.xxx,不是 self.xxx。
六、板书设计(整课板书汇总)
地图系统: gohome() 回城 move(地图,x,y,范围) 传送 getmoncount(地图) 统计怪物(默认当前地图) 读位置:player.mymap / player.x / player.y 注意:子模块拿玩家属性用 self.player.xxx
七、课后作业
- 写一个「使用传送石」的函数,check 道具 + take + move 传送;
- 写一个副本入口,进副本前先 getmoncount 检查怪物是否清完;
- 读一下地图系统源码,确认 getmoncount 默认地图用的是 self.player.mymap。
第十九课:技能系统
课时:60 分钟|学员:有 Lua 基础|前置:第十八课|教具:投影 / 技能接口表
课前准备
- 准备技能系统 11 个接口清单;
- 准备 id2name/name2id 映射的演示;
- 准备「学习 + 升级 + 强化」的完整案例。
一、教学目标
本课结束后,学员能够:
- 熟练用 getlv / getname / get 查询玩家技能;
- 掌握 addlv / add / del 操作技能(学习、升级、遗忘);
- 理解技能 ID 与技能名的映射(id2name / name2id);
- 掌握强化技能 qh_get / qh_add 的用法。
二、教学重点与难点
重点
- 技能查询(getlv/getname/get);
- 技能操作(addlv/add/del);
- 技能 ID ↔ 名称映射。
难点
- add 与 addlv 的关系(add 复用 addlv);
- 技能强化(qh)的概念;
- 技能 ID 的魔法数字问题。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:学技能、升级技能怎么实现 |
| 知识点 1:技能查询 | 15 分钟 | getlv/getname/get |
| 知识点 2:技能操作 | 15 分钟 | addlv/add/del/强化 |
| 案例演示 | 15 分钟 | 学习 + 升级技能案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「战士学烈火剑法、法师学冰咆哮,技能是传奇的核心玩法。框架的技能系统管技能的查询、学习、升级、遗忘、强化。今天讲透。」
环节二:知识点 1 —— 技能查询(15 分钟)
【讲师讲解话术】
「查技能等级 getlv——」
local lv = player.skill:getlv("烈火剑法") -- 返回技能等级(0 表示没学)
「查技能名 getname——」
local name = player.skill:getname(skillID) -- 按 ID 查名字
「查技能 get——」
local info = player.skill:get("烈火剑法")
「ID ↔ 名称映射——」
local id = player.skill:name2id("烈火剑法") -- 名字 → ID local name = player.skill:id2name(id) -- ID → 名字
「技能既有名字又有 ID,业务里常用名字(可读),引擎底层用 ID。映射函数负责转换。规则:对外用名字,对内用 ID。」
环节三:知识点 2 —— 技能操作(15 分钟)
【讲师讲解话术】
「学习/升级技能 addlv——」
player.skill:addlv("烈火剑法", 3) -- 学习并升到 3 级
「学技能 add(复用 addlv)——」
player.skill:add("烈火剑法", 1) -- 学习技能(1 级)
「遗忘技能 del——」
player.skill:del("烈火剑法")
「强化技能 qh——」
local qhLv = player.skill:qh_get("烈火剑法") -- 查强化等级 player.skill:qh_add("烈火剑法", 1) -- 强化 +1
「注意:add 与 addlv 逻辑相同(add 复用 addlv)。强化(qh)是独立体系,与技能等级分开——等级管『会不会』,强化管『强不强』。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写学习 + 升级的完整案例——」
function 学习烈火剑法(actor) local player = class(actor) -- 检查是否已学 local lv = player.skill:getlv("烈火剑法") if lv > 0 then player:send("已经学会了!") return end player.skill:addlv("烈火剑法", 1) player:send("学会烈火剑法!") end function 升级烈火剑法(actor) local player = class(actor) local lv = player.skill:getlv("烈火剑法") if lv >= 3 then player:send("已满级!") return end player.skill:addlv("烈火剑法", lv + 1) player:send("烈火剑法升到 " .. (lv + 1) .. " 级!") end
【讲师逐段讲】
「getlv 查等级 → 判断 → addlv 升级。技能系统的核心是『查等级 → 判断 → 操作』。getlv 返回 0 表示没学,这个约定要记住。」
环节五:提问互动(5 分钟)
【问 1】「查技能等级用什么?」(答:player.skill:getlv("技能名"),返回等级(0 表示没学))
【问 2】「add 和 addlv 什么关系?」(答:逻辑相同,add 复用 addlv。追问:所以学技能用哪个都行?答:对,行为一致)
【问 3】「技能强化用什么接口?」(答:qh_get(查强化等级)、qh_add(强化 +1))
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 查玩家烈火剑法等级。
- 学会烈火剑法 1 级。
- 技能名转 ID。
- 技能强化 +1。
【参考答案】
player.skill:getlv("烈火剑法")。player.skill:addlv("烈火剑法", 1)。player.skill:name2id("烈火剑法")。player.skill:qh_add("烈火剑法", 1)。
【讲师小结话术】
「今天核心:getlv 查等级、addlv 学升、del 遗忘、qh 强化,名字与 ID 靠映射转换。下节课讲邮件系统,掌握 send 发邮件带附件。下课。」
五、难点深度解析
难点 1:add 与 addlv 为什么重复
原框架里 add 和 addlv 是两份重复代码,重构时让 add 复用 addlv,消除重复。学员记住:这俩等价,学技能用 addlv 或 add 都行。
难点 2:技能 ID 的魔法数字
技能 ID 是数字,业务里应该用名字(可读)+ name2id 转换,或直接用常量。规则:对外用名字,对内用 ID,转换靠 id2name/name2id。
难点 3:技能强化(qh)与技能等级的区别
技能等级是「学没学、几级」(1~3 级),强化(qh)是「额外的强化层数」(强化 +1、+2……)。两者独立,互不影响。记忆:等级管「会不会」,强化管「强不强」。
六、板书设计(整课板书汇总)
技能系统: 查询 getlv / getname / get 映射 id2name / name2id 操作 addlv(学/升)/ add(复用addlv)/ del(遗忘) 强化 qh_get / qh_add 等级管「会不会」,强化管「强不强」
七、课后作业
- 写一个「学习技能」函数,先 getlv 检查是否已学;
- 用 name2id 把一个技能名转成 ID,再 id2name 转回来;
- 查技能系统源码,确认 add 是否复用了 addlv。
第二十课:邮件系统
课时:60 分钟|学员:有 Lua 基础|前置:第十九课|教具:投影 / 邮件示例
课前准备
- 准备 send 三参数(标题/正文/附件)的演示;
- 准备附件 items 的「名#数量&」序列化示意;
- 准备「离线发奖」的场景说明。
一、教学目标
本课结束后,学员能够:
- 熟练用
player.mail:send给玩家发邮件; - 掌握带附件(物品)邮件的写法;
- 理解附件物品的「名#数量&」序列化格式;
- 掌握「离线发奖」「系统发奖」等邮件应用场景。
二、教学重点与难点
重点
- send(title, content, items) 三个参数;
- 附件 items 的表格式
{{物品名,数量},...}; - 邮件系统挂载为 player.mail。
难点
- items 内部拼成「名#数量&」串的序列化逻辑;
- 可带附件与不带附件的区别(items 传 nil);
- 邮件 vs 直接发物品的选择。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:玩家离线怎么发奖励 |
| 知识点 1:send 基本用法 | 15 分钟 | 三个参数 |
| 知识点 2:附件 items | 15 分钟 | 序列化格式 |
| 案例演示 | 15 分钟 | 离线发奖、VIP 礼包案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「玩家在线,直接发物品就行。但玩家离线了怎么办?答案是用邮件——把奖励塞进邮件,玩家下次上线领取。今天讲邮件系统,这是『离线发奖』最稳的方式。」
环节二:知识点 1 —— send 基本用法(15 分钟)
【讲师讲解话术】
「send 三个参数——」
player.mail:send(title, content, items) -- title 标题 -- content 正文 -- items 附件(可选,nil 表示无附件)
「基础示例——」
-- 无附件 player.mail:send("系统公告", "欢迎来到玛法大陆!") -- 带附件 player.mail:send("奖励", "恭喜获得奖励", { {"元宝", 100} })
「前两个参数是标题和正文,第三个是附件(可省略)。send 返回 self,支持链式。」
环节三:知识点 2 —— 附件 items 的格式(15 分钟)
【讲师讲解话术】
「附件 items 是表,格式是 { {物品名, 数量}, ... }——」
-- 发 3 件物品 player.mail:send("新手礼包", "这是你的新手礼包", { {"赤血魔剑", 1}, {"金条", 5}, {"随机传送石", 1}, })
「内部序列化——items 拼成「名#数量&」格式的字符串,再调引擎 sendmail——」
local itemsstr = "" for _, v in pairs(items) do itemsstr = itemsstr .. v[1] .. "#" .. v[2] .. "&" end sendmail(userId, 1, title, content, itemsstr)
「学员要理解这个格式——# 分隔名和数量,& 分隔不同物品。排查附件问题时,这个格式有用。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「案例一:VIP 首充礼包(离线也能收)——」
function 发首充礼包(actor) local player = class(actor) player.mail:send("首充礼包", "感谢您的首充!", { {"赤血魔剑", 1}, {"金条", 1}, {"元宝", 100}, }) end
「案例二:推广奖励(发给可能离线的推广爸爸)——」
sendmail(playeraccount, 1, "奖励", "您的下线充值了", "元宝#" .. givemoney)
【讲师逐段讲】
「邮件最大的价值是『离线也能收』。给可能离线的玩家发奖励,必须用邮件。推广奖励这种发给『别人』的场景,尤其要用邮件。」
环节五:提问互动(5 分钟)
【问 1】「send 的三个参数是什么?」(答:标题、正文、附件(items))
【问 2】「附件的表格式是什么?」(答:{ {物品名, 数量}, ... })
【问 3】「附件内部拼成什么格式?」(答:「名#数量&名#数量&」字符串。追问:为什么要拼成字符串?答:因为引擎 sendmail 的参数是字符串)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 发一封无附件的邮件。
- 发一封带 2 件物品的邮件。
- 附件的表格式?
- 给离线玩家发奖用什么?
【参考答案】
player.mail:send("标题", "正文")。player.mail:send("标题", "正文", {{"金条",1},{"元宝",100}})。{ {物品名, 数量}, ... }。- 邮件。
【讲师小结话术】
「今天核心:send 发邮件,items 带附件,离线也能收,是发奖最稳的方式。下节课讲队伍系统,掌握组队查询与队伍传送。下课。」
五、难点深度解析
难点 1:附件序列化格式
附件在内部拼成 名#数量& 串,# 分隔名和数量,& 分隔不同物品。排查「附件没发出去」时,检查拼串逻辑是否正确。
难点 2:邮件 vs 直接发物品
- 直接发物品:玩家在线,立即到背包;
- 邮件:玩家可能离线,上线后领取。
判断标准:发奖对象可能离线 → 用邮件;确定在线 → 直接发(或两者都用)。
难点 3:send 返回 self 的意义
send 返回 self,支持链式。但邮件系统就一个 send 方法,链式意义不大。学员知道它返回 self 即可,重点是 send 的三个参数。
六、板书设计(整课板书汇总)
邮件系统: player.mail:send(标题, 正文, items) items = { {物品名, 数量}, ... } 内部:拼成 "名#数量&" 串 → sendmail 场景:离线发奖、系统通知
七、课后作业
- 写一封带 3 件物品的邮件;
- 写一个「活动奖励」函数,给玩家发邮件(含元宝和装备);
- 查邮件系统源码,确认 items 拼串逻辑。
第二十一课:队伍系统
课时:60 分钟|学员:有 Lua 基础|前置:第二十课|教具:投影 / 队伍示例
课前准备
- 准备 getlist/mylist 的区别对比;
- 准备组队击杀奖励的完整案例;
- 准备「对象 vs 名字」的易错点说明。
一、教学目标
本课结束后,学员能够:
- 熟练用 getlist / mylist 获取队伍成员列表;
- 掌握 hasplayer 检测某玩家是否在队伍;
- 会用 send 给队友发传送令;
- 理解「队伍」在传奇组队玩法中的作用。
二、教学重点与难点
重点
- getlist(全部队员,含自己)、mylist(队友,不含自己);
- hasplayer(检测队友);
- send(队伍传送令)。
难点
- getlist 和 mylist 的区别(是否含自己);
- 队员对象 vs 队员名字的区分;
- 组队奖励的分发逻辑。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:组队玩法怎么拿队友列表 |
| 知识点 1:获取成员列表 | 15 分钟 | getlist / mylist |
| 知识点 2:hasplayer + send | 15 分钟 | 检测 + 传送 |
| 案例演示 | 15 分钟 | 组队击杀奖励案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「组队打 boss,奖励要发给全队。怎么拿到队友列表?怎么判断某人是不是队友?今天讲队伍系统,重点是 getlist 和 mylist 的区别。」
环节二:知识点 1 —— 获取成员列表(15 分钟)
【讲师讲解话术】
「getlist 全部队员(含自己)——」
local all = player.team:getlist() for _, member in pairs(all) do -- member 是队员对象(actor) end
「mylist 队友(不含自己)——」
local teammates = player.team:mylist() for _, member in pairs(teammates) do -- member 是队友对象(不含自己) end
「两者都返回队员对象列表,区别是 getlist 含自己,mylist 不含自己。发奖励给全队用 getlist,发奖励给队友(不含自己)用 mylist。」
环节三:知识点 2 —— hasplayer 检测 + send 传送(15 分钟)
【讲师讲解话术】
「hasplayer 检测某玩家是否在队伍——」
if player.team:hasplayer("张三") then player:send("张三在你的队伍里") end
「参数是玩家名字(字符串),返回布尔。内部遍历队员,对比 getbaseinfo(v, 1)(名字)。」
「send 队伍传送令——」
local teammates = player.team:mylist() player.team:send(teammates, "传送令脚本名")
「给队友发传送令(前端脚本),让队友能快速传送到队长身边。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写组队击杀祖玛教皇的全队奖励——」
Event:add("杀怪触发", function(actor, mon, itype, kill, monName, mapID) if monName ~= "祖玛教皇" then return end local player = class(actor) -- 遍历全队发奖励 for _, v in pairs(getgroupmember(actor) or {}) do local member = class(v) if member.var:getint("是否领取奖励") == 1 then if member.var:getint("祖玛教皇奖励") == 0 then member.var:setint("祖玛教皇奖励", 1) member.money:add("元宝", 2000) member.money:add("荣誉", 1000) member:send9("击杀祖玛教皇,奖励已发放!") else member:send9("你已击杀过,无需重复击杀") end end end end, 1)
【讲师逐段讲】
「遍历全队成员,逐个检查领取标记、发奖励。这是队伍系统最典型的应用——组队奖励。注意两个点:一是遍历拿到的 v 是对象,要用 class(v) 包装;二是用变量做防重复标记,避免多次触发重复发奖。」
环节五:提问互动(5 分钟)
【问 1】「getlist 和 mylist 的区别?」(答:getlist 含自己,mylist 不含自己。追问:给队友发奖励(不含自己)用哪个?答:mylist)
【问 2】「hasplayer 的参数是什么?」(答:玩家名字(字符串),返回布尔)
【问 3】「队员列表里存的是对象还是名字?」(答:对象(actor))
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 拿全部队员(含自己)。
- 拿队友(不含自己)。
- 检测张三是否在队伍。
- 组队奖励为什么要防重复?
【参考答案】
player.team:getlist()。player.team:mylist()。player.team:hasplayer("张三")。- 避免多次触发重复发放,用变量标记。
【讲师小结话术】
「今天核心:getlist 含自己、mylist 不含,hasplayer 检测,send 传送,组队奖励要防重复。下节课讲称号系统,掌握称号的授予/剥夺/检查。下课。」
五、难点深度解析
难点 1:对象 vs 名字
队员列表存的是「对象」(actor),hasplayer 的参数是「名字」(字符串)。不要混用——遍历列表拿到的 v 是对象,要比较名字得用 getbaseinfo(v, 1)。规则:列表里是对象,判断名字用 hasplayer 或 getbaseinfo。
难点 2:组队奖励的防重复
组队奖励容易「重复发」(多次击杀、多个队友触发)。案例里用 祖玛教皇奖励 变量做标记,避免重复。经验:组队/全服奖励一定要加防重复标记。
难点 3:getlist 的底层
getlist 内部调 getgroupmember(actor)(引擎函数),返回队员对象列表。框架封装成 player.team:getlist()。学员知道底层是 getgroupmember 即可。
六、板书设计(整课板书汇总)
队伍系统: getlist() 全部队员(含自己) mylist() 队友(不含自己) hasplayer(name) 检测是否在队 send(list, 脚本名) 队伍传送令 组队奖励:遍历 + 防重复标记
七、课后作业
- 写一个「检测队友」的函数,用 hasplayer 判断;
- 写一个「队长拉人」的函数,用 mylist + send 通知队友;
- 查队伍系统源码,确认 getlist 底层调用了 getgroupmember。
第二十二课:称号系统
课时:60 分钟|学员:有 Lua 基础|前置:第二十一课|教具:投影 / 称号示例
课前准备
- 准备称号时效机制(2 小时)的说明;
- 准备 BLOCK_TITLES 屏蔽称号表;
- 准备排行榜称号「先清后发」的案例。
一、教学目标
本课结束后,学员能够:
- 熟练用 add / del / has 操作玩家称号;
- 理解称号的「时效」机制(部分称号设 2 小时时效);
- 理解「屏蔽称号」的特殊处理(排行榜长期称号不设时效);
- 会用称号系统实现「排行榜称号」「VIP 称号」等业务。
二、教学重点与难点
重点
- add(授予)、del(剥夺)、has(检查);
- 称号时效(changetitletime);
- 屏蔽称号(BLOCK_TITLES)。
难点
- 屏蔽称号表的作用(只授予不设时效);
- 称号时效 2 小时的设置逻辑;
- 排行榜称号的「先清旧再发新」。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:排行榜第一名怎么显示称号 |
| 知识点 1:基本用法 | 15 分钟 | add/del/has |
| 知识点 2:时效 + 屏蔽称号 | 15 分钟 | 时效机制 + BLOCK_TITLES |
| 案例演示 | 15 分钟 | 排行榜称号刷新案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「排行榜第一名头顶『第一战士』,VIP 玩家头顶『vip3』,这些称号怎么授予和撤销?今天讲称号系统,重点是时效机制——哪些称号有时效、哪些永久。」
环节二:知识点 1 —— 基本用法(15 分钟)
【讲师讲解话术】
「三个接口:add 授予、del 剥夺、has 检查——」
player.title:add("第一战士") -- 授予称号 player.title:del("第一战士") -- 剥夺称号 if player.title:has("第一战士") then player:send("你拥有第一战士称号") end
「链式调用——」
player.title:add("第一战士"):has("第一战士"):del("第一战士")
「add 和 del 返回 self,可链式。has 返回布尔。」
环节三:知识点 2 —— 时效机制 + 屏蔽称号(15 分钟)
【讲师讲解话术】
「add 内部有时效处理——」
function Title:add(titlename) confertitle(self.actor, titlename, 1) -- 授予 if BLOCK_TITLES[titlename] then return self -- 屏蔽称号不设时效 end changetitletime(self.actor, titlename, "=", os.time() + 2*60*60) -- 2 小时时效 return self end
「屏蔽称号表 BLOCK_TITLES——」
local BLOCK_TITLES = { ["大道无庸"] = true, ["vip1"] = true, ["vip2"] = true, ... }
「逻辑:普通称号授予后设 2 小时时效(到期自动消失);屏蔽称号(排行榜长期称号、VIP 称号)不设时效,永久有效。为什么这么设计?因为排行榜称号、VIP 称号是长期有效的,如果也设 2 小时时效,到期就消失,不符合预期。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写排行榜称号刷新——」
function 刷新战士榜(actor, rank) local player = class(actor) -- 先清旧称号 for _, t in ipairs({"第一战士", "第二战士", "第三战士"}) do player.title:del(t) end -- 按名次发新称号 local titles = { "第一战士", "第二战士", "第三战士" } if titles[rank] then player.title:add(titles[rank]) player:send("获得称号:" .. titles[rank]) end end
【讲师逐段讲】
「排行榜称号的特点是『名次变了,旧称号要清掉,发新称号』。所以先 del 旧、再 add 新。如果不先清,玩家会同时拥有『第一战士』和『第二战士』两个矛盾称号。」
环节五:提问互动(5 分钟)
【问 1】「称号系统的三个接口?」(答:add(授予)、del(剥夺)、has(检查))
【问 2】「普通称号授予后有几分钟时效?」(答:2 小时(7200 秒)。追问:哪些称号不设时效?答:屏蔽称号(BLOCK_TITLES),如排行榜称号、VIP 称号)
【问 3】「排行榜名次变化时,称号怎么处理?」(答:先清旧称号,再按新名次发新称号)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 授予「第一战士」称号。
- 检查是否拥有「第一战士」。
- 剥夺称号。
- 普通称号的时效是多久?
【参考答案】
player.title:add("第一战士")。player.title:has("第一战士")。player.title:del("第一战士")。- 2 小时。
【讲师小结话术】
「今天核心:add 授、del 夺、has 查,普通称号 2 小时时效,排行榜称号先清后发。下节课讲行会系统,掌握行会成员查询与沙巴克检测。下课。」
五、难点深度解析
难点 1:屏蔽称号的设计动机
排行榜称号、VIP 称号是「长期有效的」,如果也设 2 小时时效,到期就会消失,不符合预期。所以用 BLOCK_TITLES 表把「长期称号」排除在时效设置之外。理解:时效适合「限时活动称号」,长期称号不设时效。
难点 2:排行榜称号的刷新逻辑
排行榜定时刷新(allsort),名次可能变化。所以刷新时要「先清旧、再发新」,否则玩家会同时拥有「第一战士」和「第二战士」两个矛盾称号。规则:同类型称号先清后发。
难点 3:称号时效的实现
changetitletime(actor, title, "=", os.time() + 7200) 设置称号到期时间。到期后引擎自动移除称号。os.time() 是当前时间戳,加 7200 秒(2 小时)就是到期时间。
六、板书设计(整课板书汇总)
称号系统: add 授予(返回 self) del 剥夺 has 检查 时效:普通称号 2 小时;屏蔽称号(BLOCK_TITLES)不设时效 排行榜:先清旧 → 再发新
七、课后作业
- 写一个「发 VIP 称号」的函数,vip 等级拼称号名;
- 写一个排行榜称号刷新,先清旧再发新;
- 查称号系统源码,确认 BLOCK_TITLES 表里有哪些称号。
第二十三课:行会系统
课时:60 分钟|学员:有 Lua 基础|前置:第二十二课|教具:投影 / 行会示例
课前准备
- 准备「名字列表 vs 对象列表」的对比;
- 准备 mylistname 曾修的 bug(对象名字混用)说明;
- 准备行会活动奖励的案例。
一、教学目标
本课结束后,学员能够:
- 熟练用 getlist / mylistname / mylistobj 获取行会成员;
- 掌握 hasguild 检测某玩家是否在本行会;
- 会用 issbk 检测是否沙巴克攻城方;
- 理解行会成员「名字列表」与「对象列表」的区别。
二、教学重点与难点
重点
- getlist(成员名列表)、mylistname(不含自己)、mylistobj(对象列表);
- hasguild(检测成员)、issbk(沙巴克检测);
- 行会传送令 send。
难点
- 名字列表 vs 对象列表的区别;
- mylistname 曾有个「把自己加进列表」的 bug(v ~= self.actor 应改为名字比较);
- issbk 的 castleidentity 返回值判断。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:行会成员怎么查 |
| 知识点 1:成员列表 | 15 分钟 | getlist/mylistname/mylistobj |
| 知识点 2:hasguild/issbk/send | 15 分钟 | 检测 + 沙巴克 + 传送 |
| 案例演示 | 15 分钟 | 行会活动奖励案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「行会攻城、行会活动,都要拿行会成员列表。今天讲行会系统,重点区分『名字列表』和『对象列表』——搞混了会出 bug,框架里还真修过这样的 bug。」
环节二:知识点 1 —— 成员列表(15 分钟)
【讲师讲解话术】
「三个列表接口——」
local names = player.guild:getlist() -- 全部成员名 local names = player.guild:mylistname() -- 除自己以外的成员名 local objs = player.guild:mylistobj() -- 除自己以外的成员对象
「三个接口分别返回『名字列表』『除自己名字』『除自己对象』。要比较名字用名字列表,要操作玩家(发消息/发奖励)用对象列表。」
「这里有个真实的 bug 要讲——mylistname 原来用 v ~= self.actor(名字和 actor 比较恒不等,会把自己也加进列表),应改为 v ~= getbaseinfo(self.actor, 1)(名字和名字比较)。这是『对象和名字混用』的典型错误。」
环节三:知识点 2 —— hasguild / issbk / send(15 分钟)
【讲师讲解话术】
「hasguild 检测是否在本行会——」
if player.guild:hasguild("李四") then player:send("李四在你的行会里") end
「issbk 检测是否沙巴克攻城方——」
if player.guild:issbk() then player:send("你是沙巴克攻城方") end
「send 行会传送令——」
local members = player.guild:mylistobj() player.guild:send(members, "行会传送令")
「hasguild 参数是名字,issbk 判断沙巴克攻城方(castleidentity > 0)。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写行会活动奖励——」
function 行会活动奖励(actor) local player = class(actor) -- 拿行会成员对象 local members = player.guild:mylistobj() for _, v in pairs(members) do if v then local member = class(v) member.money:add("元宝", 100) member:send("行会活动奖励已发放!") end end end
【讲师逐段讲】
「mylistobj 拿对象 → 遍历 → class 包装 → 发奖励。注意判断 if v then——离线成员 getplayerbyname 可能返回 nil,拿玩家对象列表后一定要判空。」
环节五:提问互动(5 分钟)
【问 1】「mylistname 和 mylistobj 的区别?」(答:mylistname 返回名字列表,mylistobj 返回对象列表。追问:要操作成员(发奖励)用哪个?答:mylistobj)
【问 2】「hasguild 的参数是什么?」(答:玩家名字,返回布尔)
【问 3】「mylistname 曾有个什么 bug?」(答:用 v ~= self.actor(名字 vs actor)恒真,会把自己加进列表,应改为名字比较)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 拿行会全部成员名。
- 检测李四是否在本行会。
- 检测是否沙巴克攻城方。
- 拿行会成员对象(不含自己)。
【参考答案】
player.guild:getlist()。player.guild:hasguild("李四")。player.guild:issbk()。player.guild:mylistobj()。
【讲师小结话术】
「今天核心:三个列表接口(名字/对象)、hasguild 检测、issbk 沙巴克判断,对象列表要判空。下节课讲怪物宝宝,掌握刷怪 gen 与召唤宝宝 bb。下课。」
五、难点深度解析
难点 1:名字列表 vs 对象列表
mylistname 返回名字(字符串),mylistobj 返回对象(getplayerbyname 结果)。规则:要比较、要展示 → 名字列表;要操作玩家 → 对象列表。
难点 2:离线成员的处理
mylistobj 用 getplayerbyname(v) 拿对象,离线成员可能返回 nil。所以遍历时要 if v then 判空。经验:拿玩家对象列表后,一定要判空。
难点 3:mylistname 的 bug 启示
那个 bug 的根源是「对象和名字比较」。self.actor 是对象,v 是名字,v ~= self.actor 恒成立,导致把自己也加进列表。教训:比较名字用 getbaseinfo(actor, 1),比较对象用 actor 本身,两者别混。
六、板书设计(整课板书汇总)
行会系统: getlist() 全部成员名 mylistname() 除自己成员名 mylistobj() 除自己成员对象(需判空) hasguild(name) 检测成员 issbk() 沙巴克检测 send(list,脚本) 行会传送令 注意:名字 vs 对象别混,对象列表要判空
七、课后作业
- 写一个「检测行会成员」的函数;
- 写一个「行会活动发奖」的函数,用 mylistobj 遍历 + 判空;
- 查行会系统源码,确认 mylistname 用的是名字比较。
第二十四课:怪物宝宝
课时:60 分钟|学员:有 Lua 基础|前置:第二十三课|教具:投影 / genmonex 参数示意
课前准备
- 准备 gen 刷怪的 data 表结构示意;
- 准备 genmonex 参数顺序口诀图;
- 准备召唤宝宝 + 绑定主人的案例。
一、教学目标
本课结束后,学员能够:
- 熟练用 gen 按配置批量刷怪;
- 掌握 killall 杀死地图怪物;
- 会用 bb / callbb 召唤宝宝并绑定主人;
- 理解 genmonex 的参数顺序(召唤宝宝的关键)。
二、教学重点与难点
重点
- gen(刷怪,data 表配置)、killall(清怪);
- bb(召唤宝宝 + 绑定主人 + 加属性)、callbb(简单召唤);
- gen 的 mons 表格式。
难点
- genmonex 的参数顺序(容易传错);
- bb 的 attlist 属性表(加属性);
- 宝宝绑定主人(setmonmaster)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:地图刷怪、召唤宝宝怎么实现 |
| 知识点 1:gen + killall | 15 分钟 | 刷怪 + 清怪 |
| 知识点 2:bb + callbb | 15 分钟 | 召唤宝宝 + 参数顺序 |
| 案例演示 | 15 分钟 | 刷怪 + 召唤宝宝案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「地图上要刷一批怪、道士要召唤宝宝,这些都靠怪物宝宝系统。今天讲刷怪和召唤宝宝,重点是 genmonex 的参数顺序——这是最容易传错的地方。」
环节二:知识点 1 —— gen 刷怪 + killall 清怪(15 分钟)
【讲师讲解话术】
「gen 按配置批量刷怪——」
player.mon:gen({ mapID = "3", mons = { {"稻草人", 10, 20, 5, 5, 255}, -- 名字,x,y,数量,范围,颜色 {"多钩猫", 30, 40, 3, 5, 255}, }, })
「gen 的参数是一个表,含 mapID(地图)和 mons(怪物配置列表)。每个 mons 项是 {名字, x, y, 数量, 范围, 颜色}。」
「killall 杀死地图全部怪物——」
player.mon:killall("3") -- 杀死地图 3 的所有怪物
环节三:知识点 2 —— bb / callbb 召唤宝宝(15 分钟)
【讲师讲解话术】
「bb 召唤宝宝 + 绑定主人 + 加属性——」
player.mon:bb("骷髅", 2, { [属性编号] = 值 }) -- 召唤 2 只骷髅,绑定主人,加属性
「callbb 简单召唤——」
local mon = player.mon:callbb("骷髅", 2) -- 召唤 2 只骷髅,绑定主人,返回怪物对象
「genmonex 参数顺序口诀——」
genmonex(mapID, x, y, monname, 类型, 数量, 0, 255, 名字, 1, 宝宝名, 1, 1, 1, 1)
「记住口诀:地图坐标名,类型数量色,名字绑宝宝。bb 内部就是调 genmonex 再 setmonmaster 绑定主人——」
setmonmaster(monobj, self.actor) -- 把怪物绑定为玩家的宝宝
「绑定主人后,宝宝会跟随主人、听主人指挥、主人下线宝宝消失。这是『召唤兽』和『野怪』的本质区别。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「案例一:道士召唤骷髅——」
function 召唤骷髅(actor) local player = class(actor) if player.skill:getlv("召唤骷髅") == 0 then player:send("还没学召唤骷髅!") return end player.mon:bb("骷髅", 2, {}) player:send("召唤成功!") end
「案例二:刷活动怪——」
function 刷活动怪(actor) local player = class(actor) player.mon:gen({ mapID = "活动地图", mons = { {"祖玛教皇", 100, 100, 1, 5, 255}, }, }) player:send("活动怪已刷新!") end
【讲师逐段讲】
「召唤前先检查技能等级(getlv == 0 表示没学),刷活动怪用 gen 配置。bb 的第三个参数 attlist 是加属性的表,不需要加属性就传空表 {}。」
环节五:提问互动(5 分钟)
【问 1】「gen 刷怪的配置表格式?」(答:{ mapID = "地图", mons = {{名字,x,y,数量,范围,颜色}} })
【问 2】「bb 和 callbb 的区别?」(答:bb 额外支持加属性(attlist),callbb 简单召唤返回对象。追问:都要绑定主人吗?答:都绑定,内部都调 setmonmaster)
【问 3】「genmonex 参数顺序口诀?」(答:地图坐标名,类型数量色,名字绑宝宝)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 刷一批怪到地图 3。
- 杀死地图 3 全部怪物。
- 召唤 1 只神兽。
- 绑定主人用什么函数?
【参考答案】
player.mon:gen({mapID="3", mons={{...}}})。player.mon:killall("3")。player.mon:bb("神兽", 1, {})。setmonmaster(monobj, actor)。
【讲师小结话术】
「今天核心:gen 刷怪、killall 清怪、bb 召唤宝宝,genmonex 参数顺序要记牢。下节课讲触发器总览与登录,进入触发器模块,掌握 12 个回调时机。下课。」
五、难点深度解析
难点 1:genmonex 参数顺序
genmonex 是引擎函数,参数多且顺序严格。记忆口诀:「地图坐标名,类型数量色,名字绑宝宝」——地图、x、y、怪物名、类型、数量、颜色、显示名、宝宝名。传错一个,宝宝就召唤不出来。
难点 2:宝宝的「绑定主人」意义
setmonmaster 把怪物绑定为某玩家的宝宝,这样宝宝会跟随主人、听主人指挥、主人下线宝宝消失。这是「召唤兽」和「野怪」的本质区别。
难点 3:bb 的 attlist 属性表
bb 的第三个参数 attlist 是 { [属性编号] = 值 },用于给宝宝加额外属性(如攻击、防御)。如果不需要加属性,传空表 {}。注意:属性编号是魔法数字,用常量或查属性表。
六、板书设计(整课板书汇总)
怪物宝宝系统: gen(data) 刷怪 {mapID, mons={{名,x,y,量,范围,色}}} killall(地图) 清怪 bb(名,量,属性) 召唤宝宝+绑定+加属性 callbb(名,量) 简单召唤 genmonex 口诀:地图坐标名,类型数量色,名字绑宝宝
七、课后作业
- 写一个「刷活动怪」的函数,用 gen 配置;
- 写一个「召唤神兽」的函数,先检查技能等级;
- 查怪物宝宝源码,确认 bb 内部调用了 genmonex 和 setmonmaster。
第二十五课:触发器总览与登录
课时:60 分钟|学员:有 Lua 基础|前置:第二十四课|教具:投影 / 触发器清单
课前准备
- 准备 12 个触发器清单(名称 + 触发时机);
- 准备登录触发的业务清单;
- 准备「新人检测」的建号记录示例。
一、教学目标
本课结束后,学员能够:
- 说清触发器模块的作用——引擎的 12 个回调时机;
- 记住 12 个触发器的名称与触发时机;
- 掌握登录触发(onLogin)的完整流程;
- 理解「触发器 = 入口,业务 = 事件」的架构模式。
二、教学重点与难点
重点
- 12 个触发器一览(登录/充值/入包/升级/协议/定时/杀怪/点击/物品/任务/穿戴/行会);
- 登录触发的业务内容(会员/按钮/挂机/任务/新人初始化/阵营);
- 触发器通过 Event:add 订阅事件的模式。
难点
- 「引擎回调」与「Event 订阅」的关系;
- 登录触发的初始化顺序(哪些先做哪些后做);
- 新人检测(首次登录)与老玩家登录的区分。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:引擎怎么知道玩家登录了 |
| 知识点 1:12 触发器一览 | 15 分钟 | 回调时机 |
| 知识点 2:登录触发 | 15 分钟 | 完整业务 |
| 案例演示 | 15 分钟 | 登录初始化案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「之前讲的是『能力』——玩家核心的子系统。但业务要跑起来,还得有『时机』——玩家登录了、杀怪了、充值了,谁来触发?这就是触发器模块,共 12 个。今天先总览,再深讲登录触发。」
环节二:知识点 1 —— 12 触发器一览(15 分钟)
【讲师讲解话术】
「触发器 = 引擎回调的入口。引擎在 12 个时机回调框架,框架再分发业务——」
| 触发器 | 触发时机 |
|---|---|
| 登录触发 | 玩家登录 |
| 充值触发 | 玩家充值 |
| 入包触发 | 物品入背包 |
| 升级相关 | 玩家升级 |
| 协议触发 | 前端发协议 |
| 定时触发 | 定时器到点 |
| 杀怪触发 | 杀死怪物 |
| 点击触发 | 点击 NPC |
| 物品触发 | 使用物品 |
| 任务触发 | 任务相关 |
| 穿戴触发 | 穿戴装备 |
| 行会触发 | 行会操作 |
「触发器内部模式——通过 Event:add 订阅引擎事件——」
Event:add("入包触发", function(actor, itemid, itemidx) -- 业务处理 end, 1)
「引擎在对应时机回调某个全局函数(如 handlerequest、completetask),框架在这些函数里 Event:push 事件,各触发器 Event:add 订阅。触发器是入口,业务逻辑在订阅回调里。」
环节三:知识点 2 —— 登录触发的完整业务(15 分钟)
【讲师讲解话术】
「登录触发是玩家进入游戏的第一站,依次处理——」
1. 会员功能检测(VIP 状态) 2. 顶部按钮渲染(背包、商城、任务等按钮) 3. 挂机功能恢复 4. 任务状态推送 5. 新人初始化(首次登录发礼包、设阵营) 6. 阵营分配 7. 每日奖励
「新人检测——用『建号记录』变量判断是否首次登录——」
local createTime = player.var:getint("建号记录") if createTime == 0 then -- 首次登录,发新人礼包、初始化 player.item:give("中型盔甲(男)", 1) player.money:add("金币", 1000000) player.var:setint("建号记录", os.time()) end
「建号记录首次登录时写入时间戳,之后非 0,不会重复发新人礼包。这是『只执行一次』逻辑的标准写法:用变量标记。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写登录初始化(简化版)——」
Event:add("登录完成", function(actor) local player = class(actor) -- 1. 新人检测 if player.var:getint("建号记录") == 0 then player.item:give("中型盔甲(男)", 1) :give("盟重传送石", 1) player.money:add("金币", 1000000) player.var:setint("建号记录", os.time()) player:send("欢迎新玩家!") end -- 2. VIP 检查 if player:getviplv() >= 2 then player.buff:add(vipBuffID, 3600) end -- 3. 阵营分配 if player.var:getint("阵营") == 0 then player.var:setint("阵营", math.random(1, 2)) end end, 1)
【讲师逐段讲】
「登录触发按『新人检测 → VIP → 阵营』顺序处理。顺序的意义:先初始化基础数据,再处理衍生逻辑。阵营没分配就用阵营,会出 bug。」
环节五:提问互动(5 分钟)
【问 1】「触发器模块有几个?作用是什么?」(答:12 个,是引擎 12 个回调时机的入口)
【问 2】「触发器内部怎么订阅事件?」(答:用 Event:add(事件名, 回调, 优先级))
【问 3】「怎么判断玩家是否首次登录?」(答:检查「建号记录」变量是否为 0)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 说出 5 个触发器及其时机。
- 触发器内部用什么订阅事件?
- 新人检测怎么做?
- 登录触发处理哪些业务?
【参考答案】
- 登录(登录)、充值(充值)、杀怪(杀怪)、升级(升级)、入包(物品入包)。
- Event:add。
- 检查「建号记录」变量是否为 0。
- 会员、按钮、挂机、任务、新人初始化、阵营、奖励。
【讲师小结话术】
「今天核心:触发器是引擎回调的入口,内部 Event 订阅,登录触发处理所有上线业务。下节课讲升级与杀怪触发,掌握里程碑奖励与杀怪事件。下课。」
五、难点深度解析
难点 1:引擎回调与 Event 订阅的关系
引擎在玩家登录时调用框架的某个函数(引擎硬编码约定),框架在这个函数里 Event:push("登录完成", actor),然后各业务模块 Event:add("登录完成", ...) 订阅。三层结构:引擎回调 → 框架 push → 业务 add。 好处是登录逻辑可以拆成多个独立订阅,互不耦合。
难点 2:登录初始化的顺序
登录初始化有依赖顺序:先判断新人(发基础装备),再处理 VIP(基于等级/充值),再分配阵营。顺序错了会出 bug(比如阵营没分配就用了阵营)。写登录逻辑要理清依赖。
难点 3:新人检测的可靠性
「建号记录」变量是玩家首次登录时写入时间戳。之后登录该变量非 0,不会重复发新人礼包。这是「只执行一次」逻辑的标准写法:用变量标记。
六、板书设计(整课板书汇总)
触发器(12)= 引擎回调入口: 登录/充值/入包/升级/协议/定时/杀怪/点击/物品/任务/穿戴/行会 三层结构:引擎回调 → 框架 push → 业务 add 登录触发:新人检测(建号记录==0) → VIP → 阵营 → 奖励
七、课后作业
- 默写 12 个触发器及其触发时机;
- 写一个登录欢迎消息的订阅;
- 找一个触发器源码,看它 Event:add 订阅了什么事件。
第二十六课:升级与杀怪触发
课时:60 分钟|学员:有 Lua 基础|前置:第二十五课|教具:投影 / 升级杀怪示例
课前准备
- 准备 35/46/50 级里程碑奖励说明;
- 准备职业装备发放的配置表示例;
- 准备杀怪首杀的 SetSysStr 防重复示例。
一、教学目标
本课结束后,学员能够:
- 掌握升级触发(Event "升级相关")的里程碑奖励发放;
- 理解 35/46/50 级奖励的分级逻辑与职业装备发放;
- 掌握杀怪触发(Event "任务二奖励"等)的首杀队伍奖励;
- 会用「配置表 + 事件」实现升级/杀怪奖励。
二、教学重点与难点
重点
- 升级触发的里程碑判断(lv == 35/46/50);
- 职业装备发放(按 job 发不同装备);
- 杀怪触发的怪物名判断 + 全队奖励。
难点
- 升级触发的「提前 return」优化(非 35 级提前退出);
- 性别占位符替换(装备名含 %s);
- 杀怪首杀的防重复(SetSysStr 标记)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:升级奖励怎么发 |
| 知识点 1:升级触发 | 15 分钟 | 35/46/50 级奖励 |
| 知识点 2:杀怪触发 | 15 分钟 | 首杀队伍奖励 |
| 案例演示 | 15 分钟 | 升级奖励 + 杀怪首杀案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「玩家升到 35 级要发装备、升到 46 级要发元宝、击杀祖玛教皇要发全队奖励。这些『时机触发的奖励』怎么写?今天讲升级触发和杀怪触发。」
环节二:知识点 1 —— 升级触发(15 分钟)
【讲师讲解话术】
「升级触发的里程碑——」
Event:add("升级相关", function(actor, level) local player = class(actor) -- 46 级奖励 if player.lv == 46 then player.money:add("元宝", 1000) player:send("恭喜达到 46 级,送 1000 元宝!") end -- 50 级奖励 if player.lv == 50 then player.money:add("元宝", 2000) player:send("恭喜达到 50 级,送 2000 元宝!") end -- 35 级奖励(提前 return 优化) if player.lv ~= 35 then return end if player.var:getint("领取35级装备") == 1 then return end -- 按职业发装备 local items = Const.uplv_30[player.job] if items then for _, v in pairs(items) do if v.name:find("%s") then v.name = string.format(v.name, player.sex) -- 性别占位符替换 end player:give(v.name, 1) end end player.var:setint("领取35级装备", 1) end, 1)
【讲师逐段讲】
「46/50 级是独立判断(玩家等级是单一值,互斥)。35 级奖励用『提前 return』优化——非 35 级直接退出,避免无意义判断。职业装备按 Const.uplv_30[player.job] 配置表发,装备名含 %s 用 string.format 替换性别。」
环节三:知识点 2 —— 杀怪触发(15 分钟)
【讲师讲解话术】
「杀怪触发——」
Event:add("任务二奖励", function(actor, mon, itype, kill, monName, mapID) if monName ~= "祖玛教皇" then return end local money1, money2 = 2000, 1000 if GetSysStr("祖玛教皇队伍奖励") == 1 then money1, money2 = 800, 400 end -- 全队发奖励 for _, v in pairs(getgroupmember(actor) or {}) do local player = class(v) if player.var:getint("是否领取推广奖励") == 1 then if player.var:getint("祖玛教皇队伍奖励") == 0 then player.var:setint("祖玛教皇队伍奖励", 1) player.money:add("元宝", money1) player.money:add("荣誉", money2) player:send9("击杀祖玛教皇,奖励已发放!") end end end SetSysStr("祖玛教皇队伍奖励", 1) end, 1)
「杀怪触发先判断怪物名,再遍历全队发奖励。SetSysStr("祖玛教皇队伍奖励", 1) 是『全服首杀』标记——首次击杀后,后续击杀奖励降级。系统变量(全服)和玩家变量(个人)要分清。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「我们写升级职业装备发放——」
local JOB_ITEMS = { ["战士"] = { {"中型盔甲(%s)", 1}, {"炼狱", 1} }, ["法师"] = { {"中型盔甲(%s)", 1}, {"嗜魂法杖", 1} }, } function 发35级装备(actor) local player = class(actor) local items = JOB_ITEMS[player.job] for _, v in pairs(items) do local name = v[1] if name:find("%s") then name = string.format(name, player.sex) -- 替换性别 end player.item:give(name, v[2]) end end
「职业不同发不同武器,性别用 %s 占位替换。这是升级奖励的核心模式。」
环节五:提问互动(5 分钟)
【问 1】「升级触发的里程碑是哪几级?」(答:35/46/50 级)
【问 2】「35 级奖励为什么用『提前 return』?」(答:非 35 级直接退出,避免无意义的后续判断。追问:46 级和 50 级能同时触发吗?答:不能,玩家等级是单一值)
【问 3】「全服首杀怎么标记?」(答:SetSysStr("祖玛教皇队伍奖励", 1) 系统变量标记)
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 升级触发的里程碑是哪几级?
- 按职业发装备的关键是什么?
- 性别占位符怎么替换?
- 全服首杀用什么变量标记?
【参考答案】
- 35/46/50。
- 按 player.job 查配置表。
string.format(name, player.sex)。- 系统变量(SetSysStr)。
【讲师小结话术】
「今天核心:升级触发按里程碑发奖励(提前 return + 职业装备),杀怪触发按怪名发全队奖励(系统变量防重复)。下节课讲充值、入包、穿戴触发。下课。」
五、难点深度解析
难点 1:升级触发的「提前 return」优化
升级触发里,35/46/50 级奖励是「稀疏」的——绝大多数升级(36、37、38...)都不需要处理。所以先判断 if player.lv ~= 35 then return end 提前退出,避免后面无意义判断。这是性能优化,也是代码清晰的做法。
难点 2:性别占位符替换
装备名如「中型盔甲(%s)」的 %s 是占位符,用 string.format(name, player.sex) 替换成「男」或「女」。所以 name:find("%s") 先判断是否含占位符,含了才替换。注意:find 的 %s 在 Lua 模式里是空格,要精确匹配百分号 s 需转义(或用 plain 参数)。
难点 3:首杀防重复
SetSysStr("祖玛教皇队伍奖励", 1) 是系统变量(全服共享),首次击杀后设为 1,之后所有玩家的奖励降级。系统变量(全服)和玩家变量(个人)要分清:首杀用系统变量,个人领取用玩家变量。
六、板书设计(整课板书汇总)
升级触发:35/46/50 级里程碑 提前 return 优化 按 job 发职业装备 性别占位符 string.format(name, sex) 杀怪触发:怪物名判断 → 全队奖励 首杀用系统变量 SetSysStr 标记
七、课后作业
- 写一个升级里程碑奖励(配置驱动);
- 写一个杀怪计数任务(杀 100 只稻草人);
- 查升级触发源码,确认 35 级奖励的性别占位符处理。
第二十七课:充值、入包、穿戴触发
课时:60 分钟|学员:有 Lua 基础|前置:第二十六课|教具:投影 / 充值入包示例
课前准备
- 准备充值触发的业务清单(分成/累计/上报/VIP);
- 准备入包触发的随机属性算法说明;
- 准备 post623 上报的 data 表结构。
一、教学目标
本课结束后,学员能够:
- 掌握充值触发(Event "充值相关")的推广分成、VIP 奖励、上报逻辑;
- 掌握入包触发(Event "入包触发")的自动拾取、自动回收、随机属性;
- 理解穿戴触发的预留结构;
- 会用「事件回调参数」处理充值金额、物品 ID 等数据。
二、教学重点与难点
重点
- 充值触发:推广分成(promotion)、VIP 奖励、post623 上报;
- 入包触发:自动拾取停止、自动回收通知、8% 随机极品;
- 充值金额的累计(今日充值、累计充值)。
难点
- 推广分成的计算(按充值金额分给推广爸爸);
- 入包触发的随机属性算法(权重随机);
- 充值上报 post623 的 data 表结构。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:充钱后要做什么 |
| 知识点 1:充值触发 | 15 分钟 | 分成/VIP/上报 |
| 知识点 2:入包触发 | 15 分钟 | 拾取/回收/随机属性 |
| 案例演示 | 15 分钟 | 充值奖励 + 自动回收案例 |
| 提问互动 | 5 分钟 | 3 个问题 + 追问 |
| 练习与小结 | 5 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「玩家充值后,要给推广人分成、要升级 VIP、要上报后台;物品掉进背包,要自动回收、随机加属性。今天讲充值、入包、穿戴三个触发。」
环节二:知识点 1 —— 充值触发(15 分钟)
【讲师讲解话术】
「充值触发流程——」
Event:add("充值相关", function(actor, gold, productId, moneyId, ...) local player = class(actor) if moneyId ~= 2 then return end -- 只处理元宝充值 -- 1. 推广分成 promotion(actor, gold) -- 2. 累计充值 player.var:setint("我的充值积分", player.var:getint("我的充值积分") + gold * 10) player.var:setint("我的累计充值", player.var:getint("我的累计充值") + gold * 10) -- 3. 上报后台 post623(actor, { action = 1, money = gold * 10, ... }) -- 4. VIP 奖励 local lv = player:getviplv() if lv >= 1 and player.var:getint("赤血魔剑领取") == 0 then player.var:setint("赤血魔剑领取", 1) player.mail:send("新人", "恭喜达到 VIP1,送赤血魔剑+金条", {{"赤血魔剑",1},{"金条",1}}) end end, 1)
「推广分成——」
local function promotion(actor, gold) local player = class(actor) local playeraccount = player.var:getstr("推广爸爸") local givemoney if gold >= 2000 then givemoney = 6000 else givemoney = math.floor(gold * 3) -- 3 倍分成 end sendmail(playeraccount, 1, "奖励", "下线充值", "元宝#" .. givemoney) end
「充值触发按顺序:分成 → 累计 → 上报 → VIP 奖励。moneyId ~= 2 过滤非元宝充值。」
环节三:知识点 2 —— 入包触发(15 分钟)
【讲师讲解话术】
「入包触发——」
Event:add("入包触发", function(actor, itemid, itemidx) local player = class(actor) -- 1. 背包快满或开自动拾取则停止拾取 if getbagblank(actor) < 10 or getbaseinfo(actor, 35) > 0 then stoppickupitems(actor) end -- 2. 自动回收 if player.var:getint("自动回收") == 1 then if getbagblank(actor) < 30 then player:sendClient(Const.msgID["自动回收协议"], 3) end end -- 3. 8% 概率随机极品 if math.random(100) <= 8 then player.item:addatt(itemid, { [随机属性编号] = 值 }) end end, 1)
「入包触发是『物品掉进背包』的时机,处理三件事:停止拾取、自动回收、随机极品。随机极品的 8% 概率是『掉落惊喜感』的来源。」
环节四:案例演示(15 分钟)
【讲师演示话术】
「案例:充值 VIP 奖励(配置驱动)——」
local VIP_REWARDS = { [1] = { items = {{"赤血魔剑",1},{"金条",1}} }, [2] = { money = {"元宝", 3000} }, [3] = { items = {{"炼狱",1}}, money = {"元宝", 10000} }, } Event:add("充值相关", function(actor, gold, productId, moneyId) local player = class(actor) if moneyId ~= 2 then return end local lv = player:getviplv() local reward = VIP_REWARDS[lv] if not reward then return end if reward.items then for _, v in pairs(reward.items) do player.item:give(v[1], v[2]) end end if reward.money then player.money:add(reward.money[1], reward.money[2]) end player:send("VIP" .. lv .. " 奖励已发放!") end, 1)
【讲师逐段讲】
「用配置表 VIP_REWARDS 驱动 VIP 奖励,比写死 if/else 清晰——这是第二课配置驱动思维的实战应用。」
环节五:提问互动(5 分钟)
【问 1】「充值触发处理哪些业务?」(答:推广分成、累计充值、上报后台、VIP 奖励)
【问 2】「怎么过滤非元宝充值?」(答:if moneyId ~= 2 then return end)
【问 3】「随机极品概率多少?」(答:8%(math.random(100) <= 8))
环节六:课堂练习与小结(5 分钟)
【课堂练习】
- 充值触发处理哪些业务?
- 随机极品的概率?
- 推广分成怎么发给推广爸爸?
- 自动回收通知用什么协议?
【参考答案】
- 分成、累计、上报、VIP 奖励。
- 8%。
- sendmail 发邮件(离线也能收)。
- Const.msgID["自动回收协议"]。
【讲师小结话术】
「今天核心:充值触发管分成/VIP/上报,入包触发管拾取/回收/随机极品,穿戴触发预留。下节课讲物品、协议、定时触发。下课。」
五、难点深度解析
难点 1:推广分成的计算逻辑
推广分成是「下线充值,上线(推广爸爸)拿分成」。分成比例:充值 ≥2000 给固定 6000,否则 3 倍(gold * 3)。这是「拉人充值」玩法的核心。关键:用 sendmail 发给推广爸爸(可能离线)。
难点 2:入包的随机属性算法
8% 概率触发随机极品,触发后从属性池(攻击/魔法/道术等)随机抽属性、随机加值。这是「掉落惊喜感」的来源。注意:随机属性要控制上限,避免刷出变态属性。
难点 3:充值上报 post623
post623 把充值信息 POST 到后台(api_key + action + money + 玩家信息),用于数据统计、防作弊。action 区分业务:1=充值、3=兑换 CDK、4=鲜花。 学员了解上报结构即可,不用深究后台实现。
六、板书设计(整课板书汇总)
充值触发:分成 → 累计 → 上报(post623) → VIP奖励
入包触发:停止拾取 / 自动回收 / 8%随机极品
穿戴触发:预留(空回调)
七、课后作业
- 写一个「今日充值统计」的天变量累加;
- 写一个自动回收通知(用 Const.msgID 常量);
- 查充值触发源码,确认 moneyId 过滤逻辑。
第二十八课:物品、协议、定时触发
课时:60 分钟|学员:有 Lua 基础|前置:第二十七课|教具:投影 / 三类触发示例
课前准备
- 准备物品触发的 stdmode 分发示意;
- 准备协议触发的 msgID → npcname 映射表;
- 准备定时触发的 ontimer + allsort 示例。
一、教学目标
本课结束后,学员能够:
- 掌握物品触发(stdmodefunc)的道具使用分发;
- 掌握协议触发(handlerequest)的协议号路由;
- 掌握定时触发(ontimer1~5 + allsort)的定时器回调;
- 会用「协议号映射」和「定时器」实现业务。
二、教学重点与难点
重点
- 物品触发:双击类道具使用(stdmode 判断);
- 协议触发:msgID 路由到处理器(npcname 映射);
- 定时触发:5 个 ontimer 回调 + allsort 排行榜刷新。
难点
- 协议触发的 npcname 映射逻辑(msgID → 处理器名);
- 定时触发的挂机时长判断(>7h 强制掉线);
- 物品触发的 stdmode 分发(配置表驱动)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:双击道具、点按钮、定时任务怎么触发 |
| 知识点 1:物品触发 | 15 分钟 | stdmodefunc 分发 |
| 知识点 2:协议触发 | 15 分钟 | msgID 路由 |
| 知识点 3:定时触发 | 10 分钟 | ontimer + allsort |
| 案例演示 | 10 分钟 | 双击道具 + 协议路由 |
| 提问互动 | 3 分钟 | 3 个问题 |
| 练习与小结 | 2 分钟 | 3 道练习 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「双击回城石回城、点商城按钮买东西、每隔几秒回血——这些『使用、点击、定时』的时机,分别由物品、协议、定时触发处理。今天把这三个讲完,触发器模块就齐了。」
环节二:知识点 1 —— 物品触发(15 分钟)
【讲师讲解话术】
「物品触发 stdmodefunc——」
function stdmodefunc(actor, Anicount, makeIndex, itemIdx, nOverLap) -- Anicount:物品动画计数(stdmode) -- 根据 stdmode 分发到对应使用逻辑 return true end
「玩家双击道具,引擎回调 stdmodefunc,按道具的 stdmode(物品类别)分发到对应处理。分发用配置驱动——」
local HANDLERS = { [25] = function(actor) ... end, -- 传送石 [26] = function(actor) ... end, -- 回城石 } local handler = HANDLERS[stdmode] if handler then handler(actor) end
环节三:知识点 2 —— 协议触发(15 分钟)
【讲师讲解话术】
「协议触发 handlerequest——」
function handlerequest(actor, msgID, p1, p2, p3, str) local player = class(actor) -- msgID 10000:技能 CD 协议 if msgID == 10000 then return end -- 协议号 → NPC 处理器名映射 local npcname = string.gsub(getbaseinfo(getnpcbyindex(msgID), 1, 1) or "", "\\\\", "") if msgID == 2001 then npcname = "合成召唤卡" end if msgID == 2002 then npcname = "沙巴克红包" end local handler = store[npcname] if not handler then p("协议:[" .. npcname .. "]不存在!") return end handler:Smain(actor, p1, p2, p3, str) end
「协议触发是前端交互的入口。msgID 映射到处理器名(npcname),再调对应处理器的 Smain。曾修的 bug:npcname 加 or "" 防 getbaseinfo 返回 nil 时 gsub 报错。」
环节四:知识点 3 —— 定时触发(10 分钟)
【讲师讲解话术】
「ontimer 定时器回调——」
function ontimer1(actor) -- 挂机时长检测 local player = class(actor) local guaTime = GetTLInt(0, actor, "挂机时间") if guaTime > 7 * 3600 then player:send("挂机超 7 小时,强制掉线!") kick(actor) return end if guaTime > 4 * 3600 then player:send("挂机超 4 小时!") end end
「allsort 排行榜刷新——」
function allsort() -- 刷新 2~6 类排行榜称号(先清旧再发新) for i = 2, 6 do local data = mysort(i) end end
「曾修的 bug:ontimer1 的 player 未定义 + 判断顺序颠倒(>4h 在 >7h 前导致 >7h 成死代码)。写多分支时先写更极端的条件。」
环节五:案例演示(10 分钟)
-- 双击回城石 function stdmodefunc(actor, Anicount, makeIndex, itemIdx, nOverLap) if Anicount == 回城石stdmode then class(actor).map:gohome() return true end return false end -- 协议路由到商店 function handlerequest(actor, msgID, p1, p2, p3, str) if msgID == 商店协议号 then toSend(actor, "商店") end end
环节六:提问互动(3 分钟)
【问 1】「双击道具由哪个触发处理?」(答:物品触发(stdmodefunc),按 stdmode 分发)
【问 2】「协议触发的 msgID 怎么路由?」(答:msgID 映射到处理器名(npcname),再调 Smain)
【问 3】「挂机超几小时强制掉线?」(答:7 小时)
环节七:课堂练习与小结(2 分钟)
【课堂练习】
- 双击道具由哪个函数处理?
- 协议号怎么路由到业务?
- 挂机超 7 小时怎么处理?
- 排行榜刷新用什么函数?
【参考答案】
- stdmodefunc。
- msgID 映射 npcname → 调 Smain。
- kick(actor) 强制掉线。
- allsort。
【讲师小结话术】
「今天核心:物品触发管道具使用、协议触发管前端交互、定时触发管定时任务,多分支先写极端条件。下节课讲实战:完整 NPC + 活动系统。下课。」
五、难点深度解析
难点 1:协议触发的 npcname 映射
协议号 → 处理器名的映射有两种来源:一是 getnpcbyindex(msgID) 查 NPC,二是写死的 msgID 映射表(if msgID == 2001 then npcname = "合成召唤卡")。理解:协议号是「入口编号」,处理器名是「业务模块」,映射表把它们连起来。
难点 2:定时触发曾修的三个 bug
- player 未定义——补
class(actor); - 判断顺序颠倒——
>4h在>7h前,导致>7h强制掉线永远执行不到,交换顺序; setsend方法不存在——框架只有send,改send。
启示:写多分支判断时,先写「更极端」的条件,再写「次极端」。
难点 3:物品触发的 stdmode 分发
stdmodefunc 拿到的 Anicount 是物品的 stdmode,用配置表把 stdmode 映射到处理函数,实现「不同道具不同逻辑」。这是「配置驱动」在物品使用上的应用。
六、板书设计(整课板书汇总)
物品触发 stdmodefunc:按 stdmode 分发道具使用
协议触发 handlerequest:msgID → npcname → Smain
定时触发:
ontimer1~5 定时器回调
allsort 排行榜刷新
七、课后作业
- 写一个双击回城石的使用逻辑;
- 在 handlerequest 里加一个自定义协议号分支;
- 查定时触发源码,确认 ontimer1 的判断顺序已修正。
第二十九课:实战:完整 NPC + 活动系统
课时:60 分钟|学员:有 Lua 基础|前置:第二十八课|教具:投影 / 完整 NPC 代码
课前准备
- 准备「签到 + 抽奖 + 商城」三合一 NPC 的完整代码(Lmain + Smain + call1/2/3);
- 准备权重随机算法的示意图;
- 准备实战开发流程(需求分析 → 数据设计)的板书。
一、教学目标
本课结束后,学员能够:
- 独立完成一个「签到 + 抽奖 + 商城」的完整 NPC 功能;
- 综合运用玩家对象、货币/道具/变量系统、前端 gg API、后端 Smain 路由;
- 理解「前端界面 + 后端逻辑 + 数据持久化」的完整闭环;
- 掌握「先设计数据、再写逻辑」的实战开发流程。
二、教学重点与难点
重点
- 完整 NPC 的结构(
npc.CONFIG配置 +Lmain前端 +Smain路由 +call方法); - 五大思维的实战运用(配置驱动、路由分发);
- 数据持久化(变量系统存进度)。
难点
- 多个功能模块的组织(签到/抽奖/商城用 call1/call2/call3 拆开);
- 抽奖的权重随机算法;
- 前后端协议的衔接(gg.server:send 操作码 → Smain dis 路由)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 明确目标:独立写完整 NPC |
| 知识点 1:需求分析 + 数据设计 | 15 分钟 | 先设计后编码 |
| 知识点 2:完整 NPC 结构 | 15 分钟 | 前端 Lmain + 后端 Smain/call |
| 案例演示 | 20 分钟 | 完整代码逐行讲解 |
| 提问互动 | 3 分钟 | 3 个问题 |
| 练习与小结 | 2 分钟 | 布置作业 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「今天不学新东西,把前面 28 课全部串起来,独立完成一个完整的 NPC 功能——签到、抽奖、商城三合一。这是对你掌握程度的检验,也是从『学框架』到『用框架』的转折点。」
环节二:知识点 1 —— 需求分析 + 数据设计(15 分钟)
【讲师讲解话术】
「写功能前,先别急着敲代码。第一步是需求分析——明确要做什么。我们的需求是:①每日签到,每天签一次发元宝;②抽奖,消耗元宝抽随机奖励;③商城,用元宝买指定道具。」
「第二步是数据设计——想清楚要存什么变量——」
| 数据 | 变量 | 类型 |
|---|---|---|
| 今日是否签到 | 今日签到 | 天变量 |
| 累计签到天数 | 累计签到 | 数字变量 |
| 抽奖次数 | 抽奖次数 | 数字变量 |
「第三步是奖品配置(配置驱动)——」
npc.CONFIG = {
SIGN_REWARD = 100, -- 签到奖励 100 元宝
LOTTERY_COST = 100, -- 抽奖消耗 100 元宝
LOTTERY_POOL = { -- 抽奖池(权重随机)
{ name = "炼狱", weight = 30 },
{ name = "金条", weight = 40 },
{ name = "元宝", weight = 30, count = 500 },
},
SHOP = { -- 商城
{ name = "炼狱", price = 5000 },
{ name = "金条", price = 2000 },
},
}
「记住:先设计数据、再写逻辑。新手常犯的错是上来就写代码,结果数据没想清楚,反复返工。」
环节三:知识点 2 —— 完整 NPC 结构(15 分钟)
【讲师讲解话术】
「一个完整 NPC,前端后端都在一个文件里。前端用 Lmain 加载界面、绑定按钮;后端用 Smain 收协议、dis 表路由到 call1/call2/call3——」
--前端开始]] local npc = {} local MSG_ID = 141 -- 协议号 -- 前端入口:加载界面 + 绑定按钮 function npc:Lmain() local ui, root = gg.ui:npc(tostring(MSG_ID)) if not ui then return end self.ui = ui gg.button:click(ui.sign_btn, function() -- 签到按钮 → 操作码 1 gg.server:send(MSG_ID, 1) end) gg.button:click(ui.lottery_btn, function() -- 抽奖按钮 → 操作码 2 gg.server:send(MSG_ID, 2) end) gg.button:click(ui.shop_btn, function() -- 商城按钮 → 操作码 3 gg.server:send(MSG_ID, 3) end) end --前端结束]] -- 后端入口:按 p1(操作码)路由分发 function npc:Smain(actor, p1, p2, p3, str) local player = class(actor) if not player then return end local dis = { [1] = self.call1, -- 签到 [2] = self.call2, -- 抽奖 [3] = self.call3, -- 商城 } local handler = dis[p1] if handler then handler(self, actor, player, p2, p3, str) else player:send("未知操作请求! 错误码:" .. tostring(p1)) end end
「前端管界面,后端管逻辑,靠 gg.server:send(MSG_ID, 操作码) 发协议、Smain 的 dis 表路由。这是第五课的内容,现在实战应用。」
环节四:案例演示 —— 完整代码逐行讲解(20 分钟)
【讲师演示话术】
「签到 call1——」
function npc:call1(actor, player, p2, p3, str) -- 签到 if player.var:getdayvar("今日签到") == 1 then player:send9("今天已签到!") return end player.var:setdayvar("今日签到", 1) player.money:add("元宝", self.CONFIG.SIGN_REWARD) player:send("签到成功,获得 100 元宝!") end
「抽奖 call2(权重随机,今天的重点难点)——」
function npc:call2(actor, player, p2, p3, str) -- 抽奖 -- 1. 检查元宝 if not player.money:check("元宝", self.CONFIG.LOTTERY_COST) then player:send9("元宝不足!") return end player.money:dec("元宝", self.CONFIG.LOTTERY_COST) -- 2. 权重随机 local totalWeight = 0 for _, v in pairs(self.CONFIG.LOTTERY_POOL) do totalWeight = totalWeight + v.weight end local roll = math.random(totalWeight) local prize, acc = nil, 0 for _, v in pairs(self.CONFIG.LOTTERY_POOL) do acc = acc + v.weight if roll <= acc then prize = v; break end end -- 3. 发奖 if prize.count then player.money:add(prize.name, prize.count) else player.item:give(prize.name, 1) end player:send("抽到 " .. prize.name .. "!") end
「商城 call3——」
function npc:call3(actor, player, p2, p3, str) -- 商城,p2 是商品序号 local item = self.CONFIG.SHOP[p2] if not item then player:send("商品不存在!") return end if not player.money:check("元宝", item.price) then player:send9("元宝不足!") return end player.money:dec("元宝", item.price) player.item:give(item.name, 1) player:send("购买成功!") end return npc
【讲师逐段讲】
「权重随机三步:先算总权重(30+40+30=100),随机 roll 一个 1~100 的数,再从头累加权重找落点。权重越大,区间越宽,中奖概率越高。」
「注意商城用 p2 传商品序号,前端点不同商品按钮时 gg.server:send(MSG_ID, 3, 商品序号),后端 SHOP[p2] 取商品。」
「三个功能各自独立成一个 call 方法,互不干扰,每个都能单独测试。这就是模块化组织——配置表放顶部,方法按功能分开。」
环节五:提问互动(3 分钟)
【问 1】「写一个功能,第一步做什么?」(答:需求分析 + 数据设计(想清楚存什么))
【问 2】「签到用哪类变量?」(答:天变量(每日清零))
【问 3】「权重随机抽奖的步骤?」(答:算总权重 → 随机 roll → 累加找落点)
环节六:课堂练习与小结(2 分钟)
【课堂练习 + 作业】
- 独立完成「每日签到」功能;
- 独立完成「权重抽奖」功能;
- 课后作业:给签到加上「连续签到 7 天额外奖励」。
【讲师小结话术】
「今天核心:先设计数据、再写逻辑,配置驱动管奖品、Smain 路由管分发,五大思维贯穿始终。你已经能独立写完整功能了。下节课讲调试与踩坑,这是最后一课。下课。」
五、难点深度解析
难点 1:权重随机算法
权重随机是「按概率抽奖」的标准算法:
- 算总权重(如 30+40+30=100);
- 随机 roll 一个 1~总权重 的数;
- 从头累加权重,roll 落在哪个区间就中哪个。
关键:权重越大,区间越宽,中奖概率越高。
难点 2:实战开发的流程
先设计后编码:需求分析 → 数据设计(存什么变量)→ 配置设计(奖品表)→ 逻辑编码 → 测试。新手常犯的错是「上来就写代码」,结果数据没想清楚,反复返工。
难点 3:功能的模块化组织
签到、抽奖、商城是三个功能,但都在一个 NPC 脚本里。组织方式:配置表放 CONFIG 顶部,处理逻辑按 call1/call2/call3 分开,Smain 的 dis 表负责路由。每个 call 独立可测。不要写成一个几百行的大函数。
六、板书设计(整课板书汇总)
完整 NPC = 需求分析 → 数据设计 → 配置 → 逻辑 → 测试 结构: npc.CONFIG 配置表(奖品/价格) Lmain 前端(gg.ui / gg.button / gg.server:send) Smain 后端路由(dis[p1] → call1/call2/call3) call1/2/3 业务方法(签到/抽奖/商城) 权重随机:总权重 → roll → 累加找落点
七、课后作业
- 独立完成签到 + 抽奖 + 商城的完整 NPC(Lmain + Smain + call1/2/3);
- 给签到加「连续签到 7 天额外奖励」(用累计签到数字变量);
- 把抽奖池改成自己的奖品配置,测试权重随机。
第三十课:调试与踩坑
课时:60 分钟|学员:有 Lua 基础|前置:第二十九课|教具:投影 / 错误案例集
课前准备
- 准备 10 类常见错误的清单;
- 准备两个真实 bug 的排查过程演示;
- 准备调试函数(p/dump/err)的速查表。
一、教学目标
本课结束后,学员能够:
- 掌握框架代码的通用排查流程(复现 → 定位 → 修 → 验证);
- 认识框架里 10+ 类常见错误及其解决方案;
- 熟练用 p / dump / 日志 定位问题;
- 建立「先看报错、再查数据、后改逻辑」的调试心智模型。
二、教学重点与难点
重点
- 排查流程四步:复现、定位、修复、验证;
- 常见错误分类:编码、nil、参数顺序、命名、逻辑顺序;
- 调试函数的使用(p/dump/err/release_print)。
难点
- nil 报错(attempt to index a nil value)的定位;
- 事件重复触发、链式断链这类「隐性」bug;
- 逻辑顺序颠倒类 bug(不易发现)。
三、时间分配总表(60 分钟)
| 环节 | 时长 | 做什么 |
|---|---|---|
| 开场导入 | 5 分钟 | 提问:遇到 bug 你的第一反应 |
| 知识点 1:排查流程 + 调试函数 | 15 分钟 | 四步 + p/dump |
| 知识点 2:10 类常见错误 | 20 分钟 | 逐一剖析 |
| 案例演示 | 15 分钟 | 两个真实 bug 排查全过程 |
| 提问互动 | 3 分钟 | 3 个问题 |
| 练习与小结 | 2 分钟 | 布置作业 + 板书 |
四、逐字教学流程
环节一:开场导入(5 分钟)
【讲师开场白】
「写代码不可能一次就对。高手和新手的区别不是『不犯错』,而是『犯了错能快速定位』。今天把框架里最常见的坑和排查方法全部梳理一遍,这是最后一课,也是最实用的一课。」
环节二:知识点 1 —— 排查流程四步 + 调试函数(15 分钟)
【讲师讲解话术】
「排查流程四步——」
1. 复现:确定 bug 在什么条件下出现; 2. 定位:用 p/dump 缩小范围,找到出错行; 3. 修复:改代码; 4. 验证:重新测试,确认修好且没引入新问题。
「调试函数——」
p("走到这里了") -- 标记执行到哪 dump(data) -- 看表结构 err("不该到这") -- 暴露异常 release_print("[业务] xxx") -- 正式日志
「定位 bug 的关键是『二分法』——先打印函数入口,再打印中间,逐步缩小到出错行。」
环节三:知识点 2 —— 10 类常见错误(20 分钟)
【讲师讲解话术】(每类配一个例子,快速过)
错误 1:编码错误(GBK vs UTF-8)——lua 文件存成 UTF-8,引擎按 GBK 读,中文乱码。解决:转 GBK。
错误 2:nil 报错——local data = player.var:gettbl("不存在"); data.count = 1 会报 attempt to index a nil value。解决:or {} 判空。
错误 3:参数顺序传错——player.map:move(x, y, "sldg", 3) 顺序错,应为 move("sldg", x, y, 3)。
错误 4:中文 key 用点号——cfg.攻击 语法错误,应为 cfg["攻击"]。
错误 5:链式断链——player.item:give("炼狱",1):getname():give(...) 中 getname 返回字符串,不能继续 give。
错误 6:逻辑顺序颠倒——先判断 >4h 再判断 >7h,导致 >7h 永远执行不到。应先写更极端的条件。
错误 7:对象和名字混用——v ~= self.actor(名字 vs actor)恒真。应为 v ~= getbaseinfo(self.actor, 1)。
错误 8:事件重复触发——奖励发两次,原因是 Event:add 执行了两次。
错误 9:防透支缺失——player.money:dec("元宝", 1000) 没 check,可能扣成负数。
错误 10:离线成员判空缺失——遍历 mylistobj 结果时 class(v):send(...),v 可能 nil,应先 if v then。
环节四:案例演示(15 分钟)
【讲师演示话术】
「案例一:nil 报错排查——」
-- 报错:attempt to index a nil value (local 'data') -- 步骤 1:找到报错行 -- 步骤 2:在报错行前加 dump 看 data local data = player.var:gettbl("任务数据") dump(data) -- 发现是 nil -- 步骤 3:定位到 gettbl 返回 nil(变量没初始化) -- 步骤 4:修复,加 or {} local data = player.var:gettbl("任务数据") or {}
「案例二:奖励发两次排查——」
-- 症状:升级奖励发了两次 -- 步骤 1:在奖励逻辑加 p 打标记 p("升级奖励触发") -- 步骤 2:发现打印了两次 → 事件 add 了两次 -- 步骤 3:搜索 Event:add("升级相关",找到重复订阅 -- 步骤 4:删除重复,或加防重标记
环节五:提问互动(3 分钟)
【问 1】「排查 bug 的四步是什么?」(答:复现、定位、修复、验证)
【问 2】「attempt to index a nil value 怎么解决?」(答:找到 nil 来源,判空或加 or {})
【问 3】「奖励发两次最可能原因?」(答:事件重复订阅(add 两次))
环节六:课堂练习与小结(2 分钟)
【课堂练习 + 作业】
- 说出 5 类常见错误;
- 防 nil 的标准写法;
- 课后作业:把框架里自己写的代码排查一遍,用 p/dump 验证。
【讲师小结话术】
「今天核心:bug 不可怕,掌握『复现→定位→修复→验证』流程和『打印查数据』的手段,任何问题都能自己排查。心智模型记牢——先看报错、再查数据、后改逻辑。」
「至此 30 课全部讲完。恭喜大家从框架入门走到独立开发。接下来就是多写、多练、多看源码。结课。」
五、难点深度解析
难点 1:nil 报错的心智模型
attempt to index a nil value 意思是「在 nil 上取字段/调方法」。根源是某个函数返回了 nil,你却没判空。解决思路:找到哪个变量是 nil → 看它从哪来 → 在源头判空或给默认值。
难点 2:隐性 bug(顺序颠倒、对象名字混用)
这类 bug 不报错,但结果不对。特点是「代码看着没错,逻辑有问题」。排查靠「打印中间值」+「推演逻辑」。预防:写多分支时先写极端条件;比较时统一类型(名字对名字、对象对对象)。
难点 3:调试的心智模型
先看报错 → 再查数据 → 后改逻辑。很多人一上来就改逻辑,结果越改越乱。正确顺序:先看报错信息(定位行),再打印数据(看值对不对),最后才改逻辑。记住:数据不会撒谎,打印是排查第一手段。
六、板书设计(整课板书汇总)
排查四步:复现 → 定位(p/dump) → 修复 → 验证 10 类错误: 编码 / nil / 参数顺序 / 中文key点号 / 链式断链 / 逻辑顺序颠倒 / 对象名字混用 / 事件重复 / 防透支 / 判空 心智模型:先看报错 → 再查数据 → 后改逻辑
七、课后作业
- 用 p/dump 把框架里自己写的一个功能完整调试一遍;
- 故意制造一个 nil 报错和一个事件重复触发,练习定位;
- 整理一份自己的「踩坑清单」,以后写代码前过一遍。
课程完结:恭喜学完 30 课。接下来多写、多练、多看框架源码,把学到的思维用到实际项目里。