Lua 模组开发
游戏内那一层是 Lua 写的。这一节讲清楚两个会让人静默失败的上限,以及常用接口和调试方法。
01加载与生命周期
游戏内的那一层是一组 Lua 文件,由 BeamNG 的扩展机制加载。它做三件事:
- 通信:把本机车辆状态发出去,把别人的状态接进来。
- 规则:按玩法决定玩家现在能做什么、不能做什么。
- 显示:画一层很薄的游戏内 HUD。
加载过程有明确的先后顺序,理解它比记住函数名重要得多:
- 按顺序加载文件每个文件被执行一遍,顶层代码只在此时运行一次。
- 注册到主模块各文件把自己的能力挂到主模块导出的表上。
- 桥接绑定主模块连上本地桥,开始收发消息。
- 进入帧循环每帧、每秒的回调开始被周期调用。
绑定之后才注册的东西,会错过绑定那一刻的握手与初始状态。所有注册都要在绑定之前完成 —— 顺序错了,功能不是报错,而是安静地不生效。
02文件划分与依赖
模组按功能拆成多个文件,每个文件管一块,互不越界:
Mods/
startride_mod.lua 桥接层:与启动器 / 中继通信、游戏内 HUD
startrideMode.lua 玩法状态机:阶段切换、回合管理
startrideSpawn.lua 出生点与就位
startrideInput.lua 输入策略:按阶段限制玩家能做什么
startrideScore.lua 计分账本
startrideHideSeek.lua 捉迷藏玩法
startrideModeHud.lua 玩法专用 HUD 渲染
文件之间怎么交互
- 每个文件是一个模块,末尾
return M,把自己那张表交出去。 - 子模块要借用主模块提供的东西,必须挂到模块表上导出,不能去抓主模块的局部变量。
- 导出动作要早于桥接绑定;晚一步,子模块拿到的就是
nil。 - 不要在两个文件之间共享一份可变状态。谁拥有状态,谁提供读写函数。
每多一个文件就多一次加载顺序、多一组导出时机、多一份需要同步的副本。新增文件前先问一句:能不能并进已有模块?
03硬上限一:每函数 60 个上值
BeamNG 用的是 Lua 5.1,每个函数最多只能捕获 60 个上值(upvalue)。超过 60 个,函数在编译期静默失败 —— 不报错、不进日志,功能就是不生效。
「上值」指的是函数体里用到、但不是自己参数也不是自己局部变量的那些外部变量。下面这段就把 4 个外部变量都抓了:
local cfg, state, send, now = ... -- 4 个外部变量
local function tick() -- 这个函数捕获了 4 个上值
if state.on then send(cfg.id, now()) end
end
几条能救命的经验
- 不要在一个闭包里抓一堆零散的外部变量。把相关状态打包成一张表,只抓那一个表 —— 60 个零散变量和 1 个表,省下来的是 59 个名额。
- 桥接层通常常年贴着上限跑。加字段之前,先确认当前用了多少个。
- 实在不够用就拆函数:把一部分逻辑挪到一个显式的、只吃参数的纯函数里,纯函数不产生额外上值。
- 不确定有没有超,就写个脚本数一遍:解析函数体,统计它引用的、在函数外定义的局部变量个数。
这类问题不会渐进地变差:59 个的时候一切正常,第 60 个再往上加一,整块功能瞬间消失,而且没有任何提示。把计数做成常跑的检查,是唯一可靠的防线。
04硬上限二:顶层 200 个局部变量
单个 Lua 文件顶层最多 200 个 local。撞线同样是编译期静默挂,表现和上值超限一模一样:什么都没发生。
local a, b, c = 1, 2, 3 -- 3 个顶层局部变量
local S = {} -- 1 个(表内部的字段不算)
-- 顶层总共 4 个 ✓
做法
- 顶层不要堆状态。状态放进表里,一张表只算 1 个
local。 - 临时变量一律
local进函数,不要为了「少打字」提到顶层。 - 文件末尾保持
return M的模块写法,别搞全局变量。 - 和上值一样,把这个计数做成静态检查,而不是靠自觉。
05导出与注册顺序
加载顺序决定了很多东西能不能用。这张表是实践中最容易踩的几种顺序错误:
| 错误顺序 | 现象 | 正确做法 |
|---|---|---|
| 先绑定桥接,再挂子模块 | 子模块拿到 nil,功能静默失效 | 所有导出与注册都放在绑定之前 |
| 先起帧循环,再读配置 | 前几帧用的是默认值 | 配置读在注册阶段完成 |
| 先注册回调,再定义它依赖的表 | 回调运行时报 attempt to index nil | 依赖的表先建好再注册 |
| 子模块直接引用主模块局部变量 | 加载顺序一变就变成全局查找(拿到 nil) | 一律走模块表导出 |
它们大多表现为「静默失效」而不是报错。所以改加载顺序时,要同时验证所有依赖方,不能只看改动的那个文件。
06帧循环与性能
每帧回调会被调用很多次,它是掉帧的头号嫌疑人。
| 能做 | 不能做 |
|---|---|
| 读缓存好的状态、算几个数 | 做文件 IO |
| 把要发的数据塞进待发队列 | 在回调里等网络返回 |
| 按固定间隔(不是每帧)做重活 | 每帧遍历所有玩家并构造新表 |
| 复用已有的表,清空后重填 | 每帧新建大量临时表(给 GC 添堵) |
在 60 帧下,一次回调里的 1 毫秒开销,就是一秒钟里 60 毫秒的浪费。拿不准就先量一下,别凭感觉优化。
07车辆状态读写
读
- 取当前玩家车辆的对象,读它的位置、朝向、速度、是否报废。
- 别每帧重新查找车辆对象,查一次缓存起来,车辆切换时再刷新。
写
直接设置整台车的位置和朝向,会和物理引擎打架:车穿墙、抖动、和别人互相弹开。远程车要用逐节点施加力的方式逼近目标状态,交给物理求解器自己算。
- 本机车:正常通过游戏的车辆 API 操作,不要硬写物理状态。
- 远程车:把目标位姿转成每个节点上的力,逐节点施加。
- 误差是必然的,靠持续的修正节奏收敛,不要指望一次到位。
08输入策略
「这个人现在能不能动」这个问题,必须只有一个答案来源。所有阶段判断都收敛到一个函数里:
-- 输入策略:所有限制的单一入口
local function inputPolicy(player)
if not S.sessionAlive then return false end -- 会话没了
if S.phase == "staging" then return false end
if S.phase == "ended" then return false end
if player.wrecked then return false end -- 已报废,不能再开
return true
end
三条纪律
- 新增限制只往这个函数里加。散落在别处的判断迟早会互相打架。
- 「车报废」必须同时管住输入和伤害归属:只限制输入、不管伤害,会出现「撞残骸的人自己掉血」这种诡异规则。
- 会话结束时阶段必须收回大厅,否则上面的第一行永远返回 false,玩家的传送、复位、换车就再也恢复不了了。
先看当前阶段,再看会话是否还活着。这两个状态对不上,就会产生一堆看起来毫无关联的怪现象。
09游戏内 HUD
StartRide 在游戏里只加一层很薄的 HUD,显示房间与网络状态。
| 位置 | 显示什么 |
|---|---|
| 左上角 | 当前房间号、房间内玩家数、实时延迟 |
| 启动器「在线」页 | 同样的数据,但能点进去看每个玩家的明细 |
- HUD 不接收任何鼠标或键盘输入,不会挡到你开车。
- 绘制成本随每帧重建的控件数量上升。值没变就别重建,复用已有的绘制对象。
- 把「渲染」和「数据」分开:数据更新在逻辑里做,绘制只读现成的值。
绘制回调里做的任何计算,都会直接叠加到帧时间上。要算就提前算好。
10计分与幂等
计分是一本本地账本:每个人都自己算自己的分数,故意不通过网络同步。
| 设计点 | 做法 | 为什么 |
|---|---|---|
| 账本位置 | 本地 | 同步一份分数会引入一致性问题,收益却几乎为零 |
| 幂等键 | mode:round@phaseAt | 同一条事件重复到达不会重复加分 |
| 换房重置 | 由 HUD 帧触发一次重置 | 换房间等于换了一场比赛 |
| 桩测 | 独立的计分桩测试脚本 | 计分逻辑可以脱离游戏单独验证 |
网络消息可能重复、可能乱序、可能在重连后补发。加分的代码如果不幂等,玩家会发现自己的分数莫名其妙地跳 —— 而且这种 bug 只在网络不好的时候出现,极难复现。
阶段状态清零清单
进入新阶段(尤其是换房、重开一局)时,下面这些必须逐项清零。漏一项,它就会跨局存活:
- 本局累计的分数与击倒 / 撞击计数
- 本局的就位确认记录
- 本局的事件幂等键缓存
- 本局临时缓存的车辆对象引用
它只在「连续玩两局」时才出现,重启游戏就消失 —— 于是你会在一个错误的方向上反复重启。清除清单要写在注释里,加字段时照着清单补。
11与启动器 / 中继通信
游戏内 Lua 不自己开 socket。所有网络细节都在启动器那一侧,Lua 只做两件事:发一条 JSON、收一条 JSON。
| 要做的事 | 正确姿势 |
|---|---|
| 发消息 | 构造一条符合封套的消息,交给本地桥 |
| 收消息 | 在消息处理入口里分流,并且必须有兜底分支 |
| 读配置 | 从约定的配置文件 / 配置读取器里取,别硬编码 |
| 拿时间 | 用游戏提供的时钟,不要自己拼日期字符串 |
运行环境差异(照搬桌面脚本必踩)
math.atan2这个运行时里已经被移除,要自己用atan拼一个出来,别直接调。- 不要依赖
utf8库,它不一定存在。 loadstring不一定可用,别把动态代码生成当成常规手段。- 这个
table相关的辅助函数可能有参数顺序上的差异,别假设和标准 Lua 一致 —— 传进去的顺序不对,结果会悄悄反过来。
这个运行时和标准 Lua 有实打实的差别。照抄桌面脚本的写法,有很大概率在运行到那一行之前都是「一切正常」。
12静态自检三件套
这三项都在进游戏之前跑,能省掉大量「启动 → 加载 → 发现没用 → 退出」的时间。
| 检查 | 查什么 | 不过的后果 |
|---|---|---|
| 语法检查 | 文件能否被解析 | 整个文件加载失败 |
| 上值计数 | 每个函数捕获的上值是否 ≤ 60 | 该函数编译期静默失败 |
| 顶层作用域检查 | 顶层 local 是否 ≤ 200 | 整个文件编译期静默失败 |
- 三项都是纯静态的,不需要启动游戏。
- 把它们接进改动流程里:改完 Lua 就跑,跑不过不提交。
- 计数不是「差不多就行」。59 和 60 之间没有任何缓冲 —— 撞线是断崖,不是斜坡。
源码、构建产物、安装目录三份 Lua 必须逐字节一致。这一条没有静态检查能替你做,靠流程保证。
13常见错误对照表
| 现象 | 最可能的原因 | 先查什么 |
|---|---|---|
| 改完 Lua 完全没反应 | 三处副本不一致 | 比对三处文件的哈希 |
| 整个模组没加载 | 顶层 local 超 200,或语法错误 | 跑语法检查 + 顶层作用域检查 |
| 某个函数不生效,别的正常 | 该函数上值超 60 | 跑上值计数 |
| 回调报 index nil | 依赖的表还没建好就注册了回调 | 检查注册顺序 |
| 子模块拿到 nil | 导出晚于桥接绑定 | 把导出提前 |
| 车穿墙 / 抖动 | 直接写了整体位姿 | 改成逐节点施加力 |
| 按键没反应 | 阶段没复位,或会话已结束 | 查阶段与会话状态 |
| 分数莫名跳动 | 加分不幂等 | 检查幂等键 |
| 进了游戏看不见人 | 本地桥没连上(常被代理劫持) | 查本地回环端口是否被占用 |
| 跨局还带着上一局数据 | 阶段切换时漏清某个状态 | 照着清零清单逐项对 |