基于 AzzyAI(RO 生命体/佣兵 AI 框架)让人工生命体AI自动寻找并攻击唯一魔物。

一、背景

什么是唯一猎手模式?

最近无聊在玩一款RO SF,工作生活之余对童年进行下回忆吧。这款SF中有一个魔物找茬的小游戏,玩家被传送到特定的地图,地图上有 N 种魔物,其中恰好有一种在全场只有 1 只,其余 N−1 种每种至少 2 只;谁先找到并击杀那只「落单」魔物谁获胜并获得奖励,所有玩家传出地图,开启下一轮找茬。

唯一猎手模式的目标:让生命体自动扫描视野内的怪物,找出数量唯一的类型(即视野中只有 1 只的怪),锁定并只攻击该类型。

思路

最开始考虑过用 YOLO 做屏幕截图目标检测:识别魔物位置后在游戏窗口上框选。后来放弃了——推理有延迟,魔物会移动,等框画好目标早已走开。

我玩的是炼金术士,自带人工生命体;私服默认的生命体 AI 比较呆,调配置时想到——为什么不直接用生命体来实现呢?

优点:

  1. RO 官方开放的 Lua 钩子,合法合规;类似 WoW 留给玩家写插件的接口,无需外挂、无需解包。
  2. 低延迟,可移植,不依赖魔物外观,换私服也能复用这套逻辑。
  3. 生命体 AI 逻辑可扩展到其他玩法,一键切换不同场景。

相关资料

  1. AzzyAI生命体AI框架
  2. 重力社 2006 原版生命体 AI Lua 手册

二、第一版设计

核心函数:FindUniqueMonsterType

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function FindUniqueMonsterType()
    local typeCount = {}
    local actors = GetActors()
    for i, v in ipairs(actors) do
        if IsMonster(v) == 1 then
            if GetV(V_MOTION, v) ~= MOTION_DEAD then
                local monsterType = GetV(V_HOMUNTYPE, v)
                if monsterType ~= 0 then
                    if MyAvoid[monsterType] == nil then
                        typeCount[monsterType] = (typeCount[monsterType] or 0) + 1
                    end
                end
            end
        end
    end
    for typeID, count in pairs(typeCount) do
        if count == 1 then return typeID end
    end
    return 0
end

逻辑很简单:遍历 GetActors(),按 V_HOMUNTYPE 计数,筛掉死亡/无效/回避列表中的怪,返回第一个 count=1 的类型。

新状态

新增 UNIQUE_HUNT_LOCKED_ST = 201,表示"已锁定唯一怪类型"。

两种触发方式

  1. 自动扫描 — 在 OnIDLE_ST() 的默认选敌之前插入
  2. Alt+T 手动 — 在 OnFOLLOW_CMD() 开头拦截

状态流转

1
2
3
IDLE_ST → (扫描到唯一怪) → UNIQUE_HUNT_LOCKED_ST → CHASE_ST → ATTACK_ST
  ↑                                                              |
  └──────────── (目标死亡 → 清除锁定) ────────────────────────────┘

目标丢失(离开视野)→ 回到 UNIQUE_HUNT_LOCKED_ST 等待 目标死亡 → 清除锁定 → 回 IDLE_ST 重新扫描


三、第一轮 Bug:锁定与恢复逻辑

Bug 1:目标死亡后永久卡死

症状:杀死唯一怪后,生命体不再攻击任何怪物,只会跟随玩家。

原因MOTION_DEAD 路径用了 GetUniqueHuntReturnState(),返回的是 UNIQUE_HUNT_LOCKED_ST(因为 UniqueHuntTargetType 没清零)。AI 继续锁定已死的类型,不再重新扫描。

修复

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- 修复前
if (MOTION_DEAD == GetV(V_MOTION,MyEnemy)) then
    MyState = GetUniqueHuntReturnState()  -- 返回 LOCKED_ST!
    ...
end

-- 修复后
if (MOTION_DEAD == GetV(V_MOTION,MyEnemy)) then
    if UniqueHuntTargetType ~= 0 then
        UniqueHuntTargetType = 0      -- 清除锁定
    end
    MyState = IDLE_ST                  -- 回 IDLE 重新扫描
    ...
end

Bug 2:救援机制劫持状态

症状:玩家被攻击时,AI 跳出猎手模式去反击。

原因:AI 主循环的紧急处理器没有排除 UNIQUE_HUNT_LOCKED_ST

1
2
3
4
5
6
7
-- 修复前
if (object~=0 and object ~= MyEnemy and MyState~=FOLLOW_ST) then
    MyState=CHASE_ST  -- 覆盖!
end

-- 修复后
if (object~=0 and object ~= MyEnemy and MyState~=FOLLOW_ST and MyState~=UNIQUE_HUNT_LOCKED_ST) then

同样的修复也应用到了距离检查:

1
if (MyState ~=FOLLOW_ST and MyState~=UNIQUE_HUNT_LOCKED_ST and dist2owner > GetMoveBounds()) then

Bug 3:锁定残留导致永不重新扫描

症状:锁定被意外打断后,状态回到 IDLE_ST,但 UniqueHuntTargetType 仍然非零。OnIDLE_ST 中的自动扫描条件 UniqueHuntTargetType == 0 不成立,扫描被跳过。结果 AI 走默认选敌,攻击普通怪。

原因:自动扫描条件太严格——要求 UniqueHuntTargetType == 0

修复:每次进 IDLE_ST 都重新扫描,不管是否已有锁定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- 修复前
if UniqueHuntAuto == 1 and UniqueHuntTargetType == 0 then  -- 有锁就跳过
    ...
end

-- 修复后
if UniqueHuntAuto == 1 then
    -- 如果已有残留锁定,先清除再重新扫描
    if UniqueHuntTargetType ~= 0 then
        UniqueHuntTargetType = 0
    end
    local autoType = FindUniqueMonsterType()
    ...
end

四、第二轮 Bug:缺函数导致崩溃

症状

1
AI/USER_AI/AI_main.lua:2591: attempt to call global 'GetSOwnerSecondaryBuffSkill' (a nil value)

原因

打唯一猎手补丁后(USER_AI_UNIQ_PATCH)的 AI_main.luaDoAutoBuffs 中调用了 GetSOwnerSecondaryBuffSkill(),但 AzzyAI 原版的 AzzyUtil.lua 里没这个函数。补丁附带了一个 “Secondary Buff Skill” 系统,用于处理额外的主人 Buff 技能,但忘记加对应实现。

修复

Const_.lua 中增加一个安全桩函数:

1
2
3
function GetSOwnerSecondaryBuffSkill(myid)
    return 0,0,0  -- 无技能,不执行任何操作
end

五、第三轮 Bug:“看到打不到"的死循环

症状

日志显示以下循环无限重复:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
UNIQUE_HUNT: AUTO-LOCKED type 1002
OnUNIQUE_HUNT_LOCKED_ST - hunting type 1002
UNIQUE_HUNT: Found target 150540094 dist=19
OnCHASE_ST
AdjustStandPoint() FAILED ...
CHASE_ST -> IDLE_ST : Cannot attack this target, GetStandPoint() reports that all cells around it are occupied.
OnIDLE_ST
UNIQUE_HUNT: Stale lock reset, re-scanning
UNIQUE_HUNT: AUTO-LOCKED type 1002
... (无限循环)

怪明明在视野内(19 格),生命体就是打不到。

如何查看日志

日志通过 RO 客户端内置的 TraceAI 函数输出,写入游戏安装目录下的 AI/TraceAI.txt 文件:

  • 开启日志:游戏内输入 /traceai 命令,再次输入则关闭
  • 日志位置[RO安装目录]/AI/TraceAI.txt
  • 注意事项:日志文件没有自动清理机制,会持续增长,建议调试完后关闭 /traceai
  • 部分官服/台服客户端(如 twRO)可能不支持 /traceai 命令,需改用 AzzyAI 自带的 logappend 系统,查看 USER_AI/data/ 下的日志

根因分析

RO 的移动范围限制。AzzyAI 有三层距离检查,逐级限制了生命体的行动范围:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
GetActors() 视野 ≈ 18-20 格
FindUniqueMonsterType() 能看到目标 ✓
第一层: GetMoveBounds()
  StationaryMoveBounds = 14  ← 主人静止时离主距离不能超过 14 格
  MobileMoveBounds = 9       ← 主人移动时离主距离不能超过 9 格
第二层: Move() 函数硬编码
  if dis2owner > 14 then return  ← 不改成 20 上面改了也没用
第三层: OnMOVE_CMD / OnMOVE_CMD_ST
  if > 15 then → 视为无效指令

生命体能"看到"20 格的怪,但被限制在 14 格内移动。15-20 格的唯一怪就进入了「看到→锁定→追不上→退回→再锁定」的死循环。

修复

统一将所有限制扩展到 20 格:

文件修改
ConfigH_Config.luaStationaryMoveBounds: 14→20, MobileMoveBounds: 9→20
ConfigM_Config.lua同上
DefaultDefaults.lua同上
硬编码AzzyUtil.luaMove()dis2owner > 1420
校验AI_main.luaOnMOVE_CMD() >15→>20, OnMOVE_CMD_ST() >15→>20

六、最终架构

需要哪些文件?

唯一猎手模式基于 AzzyAI 完整框架运行,以下文件缺一不可:

核心文件(必须):

  • AI.lua / AI_M.lua — 入口文件,加载所有模块
  • AI_main.lua — 核心状态机(唯一猎手改动在此)
  • Const_.lua — 常量和全局变量(唯一猎手改动在此)
  • AzzyUtil.lua — 工具函数库(改了距离限制)
  • Defaults.lua — 默认配置值(改了移动范围默认值)
  • H_Config.lua / M_Config.lua — 用户配置(改了移动范围参数)
  • H_SkillList.lua / M_SkillList.lua — 技能映射表
  • H_Tactics.lua / M_Tactics.lua — 战术配置
  • H_Extra.lua / M_Extra.lua — 额外行为
  • Stubs.lua — 兼容桩函数
  • H_Avoid.lua — 回避列表
  • A_Friends.lua — 好友系统
  • H_PVP_Tact.lua / M_PVP_Tact.lua — PVP 战术

非必需(不参与 AI 运行):

  • AzzyAIConfig.exe — 可视化配置工具,改参数可以直接编辑 .lua 文件
  • Documentation.pdf — 用户手册
  • Mob_ID.lua — Mob ID 记录文件(目前为空)
  • twRO.lua — twRO 服务器特殊适配

唯一猎手改动涉及的文件:

文件修改内容
Const_.lua新增状态常量、全局变量、桩函数
AI_main.lua~150 行改动,新增 4 个函数 + 15+ 处状态机路径修改
H_Config.lua / M_Config.lua移动范围参数 14→20
AzzyUtil.lua硬编码距离检查 14→20
Defaults.lua默认值同步
H_Avoid.lua可选:添加要忽略的怪物类型

改动的函数

函数作用
FindUniqueMonsterType()扫描视野,返回唯一怪类型
OnUNIQUE_HUNT_LOCKED_ST()锁定状态处理:搜索目标 → 追击/跟随
GetUniqueHuntReturnState()恢复函数:死亡→IDLE,丢失→LOCKED
GetUniqueHuntReturnStateFromFollow()同上,但非猎手模式回 FOLLOW

状态机路径

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
                ┌─ 自动扫描发现唯一怪 ──┐
                │  或 Alt+T 手动锁定     │
                ▼                       │
         UNIQUE_HUNT_LOCKED_ST ◄───────┘
         ┌──────┴──────┐
         ▼              ▼
    目标存在        目标不存在
         │              │
         ▼              ▼
    CHASE_ST      BetterMoveToOwner
         │          (跟随主人等待)
    ┌───┴───┐
    ▼       ▼
攻击范围  超出范围
    │       │
    ▼       ▼
ATTACK_ST CHASE_ST 继续靠近
    ├── 目标死亡 → UniqueHuntTargetType=0 → IDLE_ST → 重新扫描
    ├── 目标丢失 → UNIQUE_HUNT_LOCKED_ST(继续等待)
    └── 不可达(StandPoint失败) → 日志记录 + UNIQUE_HUNT_LOCKED_ST

配置说明

Const_.lua 中修改:

1
UniqueHuntAuto = 1  -- 1=自动触发(默认),0=仅 Alt+T 手动

H_Avoid.lua 中添加要忽略的怪物类型:

1
MyAvoid[怪物类型ID] = 1  -- 唯一猎手不会锁定这种怪

七、经验总结

  1. 状态机的退出点是最容易出 Bug 的地方。 15+ 个 CHASE_ST/ATTACK_ST 的退出点都需要考虑 UniqueHunter 的情况。漏掉一个就可能导致永久卡死。

  2. RO 的移动系统和视野系统不一致。 GetActors() 能看到 18-20 格,但移动限制只有 14 格。这个"视野-移动鸿沟"是"看到打不到"的根本原因。而且 AzzyUtil.lua 里还有一个硬编码的 14 格限制,配置改得再大也没用——必须同时改这个硬编码。

  3. MyAvoid 的双重作用。 它既控制紧急回避(os.exit()),又控制 Unique Hunter 的排除列表。同一个表在不同上下文中有不同含义,需要小心使用。

  4. 恢复函数的设计至关重要。 “目标死亡"和"目标丢失"是两种完全不同的情况,需要不同的恢复路径:死亡→重新扫描,丢失→继续等待。用一个 GetUniqueHuntReturnState() 来处理所有退出点会混淆这两种情况。


八、附录

日志速查表

日志含义
UNIQUE_HUNT: AUTO-LOCKED type X自动扫描锁定类型 X
UNIQUE_HUNT: LOCKED type XAlt+T 手动锁定类型 X
UNIQUE_HUNT: CANCELLED by Alt+TAlt+T 取消锁定
UNIQUE_HUNT: Stale lock reset残留锁被清除,重新扫描
UNIQUE_HUNT: Found target ID dist=N在视野内发现目标,距离 N
UNIQUE_HUNT: No target in sight目标不在视野,跟随主人
UNIQUE_HUNT: type X unreachable类型 X 的位置不可到达

安装步骤

  1. 先安装 AzzyAI — 将完整 USER_AI/ 目录复制到 [RO安装目录]/AI/USER_AI/
  2. 再打补丁 — 将 USER_AI_UNIQ_PATCH/ 下所有文件复制到 [RO安装目录]/AI/USER_AI/ 覆盖
  3. 重启游戏(或 @refresh
  4. 游戏内输入 /traceai 开启日志
  5. 查看 [RO安装目录]/AI/TraceAI.txt 确认 UNIQUE_HUNT: AUTO-LOCKED 输出
  6. H_Avoid.lua 中可添加要跳过的怪物类型