入门指南
写新脚本时读这一篇,从项目结构到常见陷阱。API 参数和返回值见侧栏 API 章节。
项目结构
Lua 脚本生存于应用同级的 scripts/ 目录,启动时自动发现:
<应用同级目录>/
├── <主程序>
└── scripts/
├── _test_*.lua # 下划线前缀 = 示例,不自动加载
├── keybind_hint_pack.lua
├── low_health_alert.lua
└── ...下划线开头的脚本不在启动时自动加载,只能手动用 script.load("_test_xx.lua") 或菜单点击加载。普通脚本由配置中的脚本列表决定是否启动加载。
加载顺序
1. 配置系统加载 读默认 plan + 当前 plan
2. Lua 运行时初始化 VM + 绑定
3. 静态菜单建立 aimbot / visuals / misc 等已挂好
4. 启动脚本加载 按配置中的脚本列表逐个 load
5. 菜单 / 后台线程上来脚本执行时静态菜单已经在 GUI 树里,可以直接 gui.set("aimbot.master", true) 写任何已存在路径。脚本自己 gui.tab("X") 创建的子树挂在尾部,多脚本之间没有顺序保证——不要互相引用其他脚本的路径。
用户通过菜单"加载脚本"按钮加载与启动加载执行相同流程,但 script_loaded 事件的 is_reload 字段会反映是否是热重载。
_SCRIPT_PATH 全局变量
每个脚本执行前注入:
print(_SCRIPT_PATH) -- "scripts/damage_logger.lua"用途:在 script_unloaded 事件里识别是否是自己被卸载——
event.on("script_unloaded", function(ue)
if ue.script_path ~= _SCRIPT_PATH then return end
-- 只对自己被卸载做处理
end)print 重定向
print(...) 已重定向到 log.info(...)。io.write / os.write 等 stdlib 输出函数未重定向,不要用。
log.* vs gui.notify
| 用途 | 走哪个 | 谁能看到 |
|---|---|---|
| 开发者诊断、调试信息、错误追踪 | log.info/success/warn/error | 菜单内 console |
| 用户友好提示(操作反馈、错误告警) | gui.notify:info/success/warn/error | 浮动 toast,约 3-5s 自动消失 |
经验法则:
- 脚本加载完毕 →
log.success("...已注册"),不弹用户提示 - 用户点 button 触发某动作 →
gui.notify:success("已应用预设 ...", 2.5) - 程序错误(云端连不上、文件写失败)两个都发:
log.warn留诊断痕迹,gui.notify:error让用户看到
资源生命周期
下列六类资源在脚本被卸载时(菜单点叉、script.unload、程序关闭)自动释放,不必手写 event.on("script_unloaded", ...) 监听清理:
| 资源 | 写法 |
|---|---|
| 事件订阅 | event.on("frame_update", fn) |
| ESP 元素 | esp.add("tag", fn, opts) |
| 菜单容器和控件 | gui.tab(...) / tab:group(...) 等 |
| 按键 formatter | input.format(path, fn) |
| HTTP / WebSocket | net.http:get / net.ws:connect |
| 图像纹理 | file.image:load/svg/create |
仅当脚本有外部副作用需要收尾(写盘、HTTP 上报)时才需要手动监听卸载事件:
event.on("script_unloaded", function(ue)
if ue.script_path ~= _SCRIPT_PATH then return end
flush_buffer_to_disk()
end)跨脚本通信
shared 全局表
shared 是 Lua state 上的普通 table,所有脚本共享:
-- 脚本 A
shared.last_kill_time = os.clock()
-- 脚本 B
event.on("frame_update", function(e)
if shared.last_kill_time and (os.clock() - shared.last_kill_time) < 5.0 then
-- 最近 5 秒内有击杀
end
end)特性:
- 写入立即对其他脚本可见,无锁、无快照
- 生命周期跟 Lua state 一样,脚本卸载不清空
- 默认空表,命名由脚本自己负责
推荐写法:挂自己的命名空间
shared.damage_logger = shared.damage_logger or {}
shared.damage_logger.last_event_t = os.clock()
shared.damage_logger.total = (shared.damage_logger.total or 0) + 1读方:
local d = shared.damage_logger
if d and d.last_event_t then ... end自定义事件总线
需要更结构化的通信用 event.emit / event.on:
-- 脚本 A
event.on("hp_changed", function(name, hp, old) print(name, old, "→", hp) end)
-- 脚本 B
event.emit("hp_changed", "alice", 30, 100)脚本卸载时自动撤销订阅。
不要走 gui.set/get 当通信通道
gui.set("MyTab.g.flag", true) 也能实现脚本间互相读写,但慎用:
- 走配置写路径,每次写都会触发帧末快照发布给后台读线程
- 控件类型限制(bool/int/float/string)
- 路径解析有开销
shared.* 或自定义事件才是脚本间通信的正路。
时间与节奏
frame_update 节拍
event.on("frame_update", fn) 的回调每渲染帧触发——绑定屏幕渲染节奏。144Hz 显示器上是 144 次/秒,240Hz 上是 240 次/秒。
含义:
draw.*必须挂在frame_update里调,前景 draw list 每帧重置,漏一帧就是空白闪烁- 重计算任务(数据扫描、网络 IO)不能每帧跑,要节流(见下)
时间函数
| 函数 | 含义 | 适合 |
|---|---|---|
time.now() | 墙钟秒(os.clock() 别名) | 计时器、节流、任何"现实秒数" |
time.delta() | 上一帧耗时(秒) | 速度积分(移动、动画 lerp) |
time.game() | 游戏内时间 | 跟游戏 in-game time 同步(暂停时停) |
time.frame_count() | 累计渲染帧数 | 模数调度 % N == 0 |
也可以从 event.on("frame_update", fn) 回调里拿 e.delta_time,跟 time.delta() 等价。
累加器节流
每帧跑重计算(扫所有 player + DMA 慢查询)会卡。标准模板:
local accum = 0.0
event.on("frame_update", function(e)
accum = accum + e.delta_time
if accum < 0.5 then return end -- 每 0.5s 一次
accum = 0.0
do_heavy_work()
end)帧数模数调度
更简单的"每 N 帧一次":
event.on("frame_update", function(e)
if time.frame_count() % 60 == 0 then
do_periodic_thing()
end
end)反抖:上次触发后 N 秒
local last_alert = {}
local function should_fire(key)
local t = os.clock()
local prev = last_alert[key] or 0.0
if t - prev < 4.0 then return false end
last_alert[key] = t
return true
end适合同一玩家持续触发某状态时压制通知刷屏。
示例脚本
详细注释完整源码见 示例脚本。八个脚本涵盖:
| 脚本 | 演示要点 |
|---|---|
keybind_hint_pack.lua | input.format 三种返回形式(字符串 / table / 阈值变色) |
low_health_alert.lua | ESP 文本元素 + 反抖通知 + 实体字段访问 |
tactical_theme.lua | ESP scenario 配色批量切换 + Combobox 回调 |
perf_hud.lua | 浮动窗口 + 滑窗均值 + live_table |
loadout_presets.lua | 多套配置一键切换 + 子页签显隐控制 |
loot_inspector.lua | 实体遍历 + 过滤 + live_table |
remote_config_push.lua | WebSocket 四回调 + 远端推送热改菜单 |
damage_logger.lua | ESP polling + 帧节拍 flush + 文件 + HTTP 综合 |
常见陷阱
没有 input.is_down(vk)
API 不提供 input.is_down(VK_F1) / GetAsyncKeyState 这类查"玩家是否按下 F1"的函数。HApex3.0 是双机架构——程序跑在一台机器,玩家在另一台。OS 按键 API 读的是程序所在机器的键盘,不是玩家。所以查玩家按键必须走:
game.localplayer.*字段(如is_zooming、is_grenade),从游戏内存读input.is_active(path)查菜单 hotkey 是否处于 active 状态
要看用户菜单按了什么 hotkey,绑 KeybindControl 控件,用 input.format 显示状态。
draw.* 必须每帧调
前景 draw list 每帧重置:
-- 错:脚本顶部直接调,只画了 1 帧就消失
draw.text(10, 10, draw.rgba(1,1,1,1), "Hello")
-- 对:挂在 frame_update 每帧重画
event.on("frame_update", function(e)
draw.text(10, 10, draw.rgba(1,1,1,1), "Hello")
end)漏一帧 = 那一帧画面上空白闪一下。
冒号 : 与点 . 调用规则
| 写法 | 用途 |
|---|---|
obj:method(args) | stateful 对象方法:gui.find(p):set(v) / tab:group(...) / net.http:get(...) / net.ws:connect(...) / file.image:load(...) |
obj.field | read-only 数据字段:player.health / event.delta_time / game.localplayer.weapon |
module.func(args) | 模块函数:gui.set(p, v) / esp.add(...) / log.info(...) / time.now() |
混了会报 bad self argument。模块函数永远用点;stateful 对象方法永远用冒号。
net.http:get / net.ws:connect 用冒号,因为 net.http / net.ws 是对象而非普通 table。
武器配置路径
武器配置统一走 gui.set/get 的路径,格式 aimbot.weapon.<id>.<sub>。当前手持武器路径从 gui.get_active_override_path() 取。详见 gui 武器配置读写。
gui.set("aimbot.weapon.r301.aim.fov_hip", 12.5)
local p = gui.get_active_override_path()
if p ~= "" then gui.set(p .. ".aim.bone_target", 2) end字段名跟着 C++ SCHEMA 走,写错路径返 nil / 静默 no-op。
同帧 set 后 get 可见
gui.set("aimbot.master", true)
local v = gui.get("aimbot.master") -- 立即拿到 true,不用等下一帧gui.get / Window:get_open 等菜单线程读路径都走"当前帧最新值",同帧可见。后台读线程则永远走帧末发布后的快照。
没有 net.http:get_sync
API 不提供同步 HTTP,避免阻塞菜单线程导致脚本加载期间 GUI 冻结。正确写法:
net.http:get("https://example.com", function(resp)
if resp.ok then
-- 处理 resp.body
else
log.warn("HTTP fail: " .. (resp.error or ""))
end
end, 3) -- 3s timeoutcallback 在菜单线程派发,可以直接调 gui.set / gui.notify:*。
Combobox 索引是 1-based
group:combobox("playstyle", "...", { "Default", "Holder", "Rusher", "Safe" }, 2)
-- ^ 默认选 "Holder"on_change(fn) 回调签名也是 fn(idx_1based, name)。Combobox 的默认值和索引在脚本里都用 1-based。
gui.get/set 拿到的是底层存储值,仍是 0-based int。要在 Combobox 之间转换记得 +1 / -1。
遍历实体表用 pairs,不能用 #
-- 错
local n = #game.entities.loots
-- 对
local n = 0
for _ in pairs(game.entities.loots) do n = n + 1 endgame.entities.{players, loots, projectiles} 是 userdata-backed 表,# 返 0 或未定义。pairs 正常工作。