HOOZi文档
Skip to content

入门指南

写新脚本时读这一篇,从项目结构到常见陷阱。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 全局变量

每个脚本执行前注入:

lua
print(_SCRIPT_PATH)  -- "scripts/damage_logger.lua"

用途:在 script_unloaded 事件里识别是否是自己被卸载——

lua
event.on("script_unloaded", function(ue)
    if ue.script_path ~= _SCRIPT_PATH then return end
    -- 只对自己被卸载做处理
end)

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(...)
按键 formatterinput.format(path, fn)
HTTP / WebSocketnet.http:get / net.ws:connect
图像纹理file.image:load/svg/create

仅当脚本有外部副作用需要收尾(写盘、HTTP 上报)时才需要手动监听卸载事件:

lua
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,所有脚本共享:

lua
-- 脚本 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 一样,脚本卸载不清空
  • 默认空表,命名由脚本自己负责

推荐写法:挂自己的命名空间

lua
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

读方:

lua
local d = shared.damage_logger
if d and d.last_event_t then ... end

自定义事件总线

需要更结构化的通信用 event.emit / event.on

lua
-- 脚本 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 慢查询)会卡。标准模板:

lua
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 帧一次":

lua
event.on("frame_update", function(e)
    if time.frame_count() % 60 == 0 then
        do_periodic_thing()
    end
end)

反抖:上次触发后 N 秒

lua
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.luainput.format 三种返回形式(字符串 / table / 阈值变色)
low_health_alert.luaESP 文本元素 + 反抖通知 + 实体字段访问
tactical_theme.luaESP scenario 配色批量切换 + Combobox 回调
perf_hud.lua浮动窗口 + 滑窗均值 + live_table
loadout_presets.lua多套配置一键切换 + 子页签显隐控制
loot_inspector.lua实体遍历 + 过滤 + live_table
remote_config_push.luaWebSocket 四回调 + 远端推送热改菜单
damage_logger.luaESP polling + 帧节拍 flush + 文件 + HTTP 综合

常见陷阱

没有 input.is_down(vk)

API 不提供 input.is_down(VK_F1) / GetAsyncKeyState 这类查"玩家是否按下 F1"的函数。HApex3.0 是双机架构——程序跑在一台机器,玩家在另一台。OS 按键 API 读的是程序所在机器的键盘,不是玩家。所以查玩家按键必须走:

  • game.localplayer.* 字段(如 is_zoomingis_grenade),从游戏内存读
  • input.is_active(path) 查菜单 hotkey 是否处于 active 状态

要看用户菜单按了什么 hotkey,绑 KeybindControl 控件,用 input.format 显示状态。

draw.* 必须每帧调

前景 draw list 每帧重置:

lua
-- 错:脚本顶部直接调,只画了 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.fieldread-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 武器配置读写

lua
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。

同帧 setget 可见

lua
gui.set("aimbot.master", true)
local v = gui.get("aimbot.master")  -- 立即拿到 true,不用等下一帧

gui.get / Window:get_open 等菜单线程读路径都走"当前帧最新值",同帧可见。后台读线程则永远走帧末发布后的快照。

没有 net.http:get_sync

API 不提供同步 HTTP,避免阻塞菜单线程导致脚本加载期间 GUI 冻结。正确写法:

lua
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 timeout

callback 在菜单线程派发,可以直接调 gui.set / gui.notify:*

Combobox 索引是 1-based

lua
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,不能用 #

lua
-- 错
local n = #game.entities.loots

-- 对
local n = 0
for _ in pairs(game.entities.loots) do n = n + 1 end

game.entities.{players, loots, projectiles} 是 userdata-backed 表,# 返 0 或未定义。pairs 正常工作。