Lua Modding · 文档

Lua 模组开发

游戏内那一层是 Lua 写的。这一节讲清楚两个会让人静默失败的上限,以及常用接口和调试方法。

约 9 分钟 · 最后更新 2026-10-25

01加载与生命周期

游戏内的那一层是一组 Lua 文件,由 BeamNG 的扩展机制加载。它做三件事:

  • 通信:把本机车辆状态发出去,把别人的状态接进来。
  • 规则:按玩法决定玩家现在能做什么、不能做什么。
  • 显示:画一层很薄的游戏内 HUD。

加载过程有明确的先后顺序,理解它比记住函数名重要得多:

  1. 按顺序加载文件每个文件被执行一遍,顶层代码只在此时运行一次。
  2. 注册到主模块各文件把自己的能力挂到主模块导出的表上。
  3. 桥接绑定主模块连上本地桥,开始收发消息。
  4. 进入帧循环每帧、每秒的回调开始被周期调用。
第三步是分水岭

绑定之后才注册的东西,会错过绑定那一刻的握手与初始状态。所有注册都要在绑定之前完成 —— 顺序错了,功能不是报错,而是安静地不生效。

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

三条纪律

  1. 新增限制只往这个函数里加。散落在别处的判断迟早会互相打架。
  2. 「车报废」必须同时管住输入和伤害归属:只限制输入、不管伤害,会出现「撞残骸的人自己掉血」这种诡异规则。
  3. 会话结束时阶段必须收回大厅,否则上面的第一行永远返回 false,玩家的传送、复位、换车就再也恢复不了了。
玩家说的「按键没反应」有九成在这里

先看当前阶段,再看会话是否还活着。这两个状态对不上,就会产生一堆看起来毫无关联的怪现象。

09游戏内 HUD

StartRide 在游戏里只加一层很薄的 HUD,显示房间与网络状态。

位置显示什么
左上角当前房间号、房间内玩家数、实时延迟
启动器「在线」页同样的数据,但能点进去看每个玩家的明细
  • HUD 不接收任何鼠标或键盘输入,不会挡到你开车。
  • 绘制成本随每帧重建的控件数量上升。值没变就别重建,复用已有的绘制对象。
  • 把「渲染」和「数据」分开:数据更新在逻辑里做,绘制只读现成的值。
别在绘制里算东西

绘制回调里做的任何计算,都会直接叠加到帧时间上。要算就提前算好。

10计分与幂等

计分是一本本地账本:每个人都自己算自己的分数,故意不通过网络同步。

设计点做法为什么
账本位置本地同步一份分数会引入一致性问题,收益却几乎为零
幂等键mode:round@phaseAt同一条事件重复到达不会重复加分
换房重置由 HUD 帧触发一次重置换房间等于换了一场比赛
桩测独立的计分桩测试脚本计分逻辑可以脱离游戏单独验证
为什么必须幂等

网络消息可能重复、可能乱序、可能在重连后补发。加分的代码如果不幂等,玩家会发现自己的分数莫名其妙地跳 —— 而且这种 bug 只在网络不好的时候出现,极难复现。

阶段状态清零清单

进入新阶段(尤其是换房、重开一局)时,下面这些必须逐项清零。漏一项,它就会跨局存活:

  • 本局累计的分数与击倒 / 撞击计数
  • 本局的就位确认记录
  • 本局的事件幂等键缓存
  • 本局临时缓存的车辆对象引用
「跨局存活」是最阴的一类 bug

它只在「连续玩两局」时才出现,重启游戏就消失 —— 于是你会在一个错误的方向上反复重启。清除清单要写在注释里,加字段时照着清单补。

11与启动器 / 中继通信

游戏内 Lua 不自己开 socket。所有网络细节都在启动器那一侧,Lua 只做两件事:发一条 JSON、收一条 JSON。

要做的事正确姿势
发消息构造一条符合封套的消息,交给本地桥
收消息在消息处理入口里分流,并且必须有兜底分支
读配置从约定的配置文件 / 配置读取器里取,别硬编码
拿时间用游戏提供的时钟,不要自己拼日期字符串

运行环境差异(照搬桌面脚本必踩)

  • math.atan2 这个运行时里已经被移除,要自己用 atan 拼一个出来,别直接调。
  • 不要依赖 utf8 库,它不一定存在。
  • loadstring 不一定可用,别把动态代码生成当成常规手段。
  • 这个 table 相关的辅助函数可能有参数顺序上的差异,别假设和标准 Lua 一致 —— 传进去的顺序不对,结果会悄悄反过来。
先查可用的 API 再写

这个运行时和标准 Lua 有实打实的差别。照抄桌面脚本的写法,有很大概率在运行到那一行之前都是「一切正常」。

12静态自检三件套

这三项都在进游戏之前跑,能省掉大量「启动 → 加载 → 发现没用 → 退出」的时间。

检查查什么不过的后果
语法检查文件能否被解析整个文件加载失败
上值计数每个函数捕获的上值是否 ≤ 60该函数编译期静默失败
顶层作用域检查顶层 local 是否 ≤ 200整个文件编译期静默失败
  • 三项都是纯静态的,不需要启动游戏。
  • 把它们接进改动流程里:改完 Lua 就跑,跑不过不提交。
  • 计数不是「差不多就行」。59 和 60 之间没有任何缓冲 —— 撞线是断崖,不是斜坡。
三处副本一致性也要查

源码、构建产物、安装目录三份 Lua 必须逐字节一致。这一条没有静态检查能替你做,靠流程保证。

13常见错误对照表

现象最可能的原因先查什么
改完 Lua 完全没反应三处副本不一致比对三处文件的哈希
整个模组没加载顶层 local 超 200,或语法错误跑语法检查 + 顶层作用域检查
某个函数不生效,别的正常该函数上值超 60跑上值计数
回调报 index nil依赖的表还没建好就注册了回调检查注册顺序
子模块拿到 nil导出晚于桥接绑定把导出提前
车穿墙 / 抖动直接写了整体位姿改成逐节点施加力
按键没反应阶段没复位,或会话已结束查阶段与会话状态
分数莫名跳动加分不幂等检查幂等键
进了游戏看不见人本地桥没连上(常被代理劫持)查本地回环端口是否被占用
跨局还带着上一局数据阶段切换时漏清某个状态照着清零清单逐项对