Lua 脚本:钩子与生命周期
本页涵盖 Lua 能力可以定义的每个钩子、钩子的执行顺序、每个 Boss 如何获得自己的隔离运行时,以及沙盒中可用的标准库函数。
如果你还没有编写过 Lua 能力,请先从入门指南开始。
本页记录的是 plugins/EliteMobs/powers/ 中 Boss Lua 能力的钩子。NPC Lua 脚本使用它们自己的 plugins/EliteMobs/npc_scripts/ 文件夹,以及诸如 on_npc_interact 和 on_npc_proximity_enter 之类的 NPC 专属钩子;请参阅 NPC 脚本。
钩子参考
每个 Lua 能力文件都会返回一个表。该表中的每个键(除了 api_version 和 priority)都必须是下面列出的钩子之一。运行时在相应的游戏事件触发时会调用匹配的函数。
| 钩子 | 触发时机 | context.player 是否可用? |
|---|---|---|
on_spawn | 精英怪物生成时 | 否 |
on_game_tick | 运行时时钟启用且未暂停时,每个服务器 tick 执行一次;20 TPS 下通常间隔 50 毫秒 | 否 |
on_boss_damaged | Boss 受到任何来源的伤害时 | 否 |
on_boss_damaged_by_player | Boss 受到玩家的伤害时 | 是 |
on_reinforcement_damaged_by_player | 此 Boss 召唤的增援受到玩家伤害时;在召唤者的能力上执行 | 是 |
on_boss_damaged_by_elite | Boss 受到另一个精英怪物的伤害时 | 否 |
on_player_damaged_by_boss | 玩家受到此 Boss 的伤害时 | 是 |
on_enter_combat | Boss 进入战斗时 | 是 |
on_exit_combat | Boss 脱离战斗时 | 否 |
on_heal | Boss 治疗时 | 否 |
on_boss_target_changed | EliteMobs 报告玩家目标事件时,包括首次获取目标;不涵盖每次目标变更或丢失 | 是 |
on_death | Boss 死亡时 | 否 |
on_phase_switch | 阶段 Boss 切换到新阶段时 | 否 |
on_mind_action | 原生 Mind 向附加能力请求动作时;参阅动作上下文 | 否 |
on_zone_enter | 实体进入被监视的区域时 | 是(如果该实体是玩家) |
on_zone_leave | 实体离开被监视的区域时 | 是(如果该实体是玩家) |
当 context.player 列为"否"时,访问它会返回 nil。使用前请始终进行 nil 检查。
on_boss_damaged 和更具体的伤害钩子走不同的事件路径。一次攻击可以触发两者,不应把它们视为互斥。目标钩子不能通过此 Lua 事件表取消,也不报告之前的目标。
on_zone_enter / on_zone_leave这两个钩子会被加载器接受并通过校验,但 Lua 能力里没有任何东西能让它们触发。它们只由 EliteScript 的区域监听器派发,而该监听器是在同一 Boss 上某个 YAML eliteScript: 的 Events: 中列出 ZoneEnterEvent 或 ZoneLeaveEvent 时才会启动的。一个只有 Lua 能力的 Boss 会毫无怨言地加载包含这些钩子的文件,然后永远不会调用它们。
Lua 监视器是另一套独立机制:context.zones:watch_zone(...) 和 context.script:zone(...):watch(...) 会直接调用你传给它们的 on_enter / on_leave 回调,绝不会经由这些顶层钩子。除非你是刻意把 Lua 能力与 YAML 区域脚本配对使用,否则请使用监视器。
YAML 区域事件会送达该 Boss 的所有匹配能力。事件包含进入或离开的实体,但不包含来源区域或脚本的标识。多个 YAML 监听器因此可能为同一实体送出多个事件。
NPC Lua 脚本则不同。它们的 context.zones 来自 MagmaCore,其 watch 会派发 on_zone_enter / on_zone_leave。参见 NPC 脚本。
典型的多钩子能力
单个 Lua 能力可以根据需要定义任意数量的钩子。下面是一个同时使用三个钩子的骨架示例:
return {
api_version = 1,
on_enter_combat = function(context)
-- Initialize per-fight state when combat begins
context.state.hit_count = 0
context.log:info("Combat started!")
end,
on_boss_damaged_by_player = function(context)
-- Track hits and trigger an ability every 5th hit
context.state.hit_count = (context.state.hit_count or 0) + 1
if context.state.hit_count % 5 ~= 0 then
return
end
if not context.cooldowns:check_local("counter_attack", 100) then
return
end
-- Fire a projectile back at the player
local origin = context.boss:get_location()
origin:add(0, 1, 0)
context.boss:summon_projectile(
"SMALL_FIREBALL", origin, context.player:get_location(), 1.5
)
end,
on_death = function(context)
-- Spawn an explosion particle on death
context.world:spawn_particle_at_location(
context.boss:get_location(), "EXPLOSION_EMITTER", 1
)
end
}
事件数据(context.event)
某些钩子会接收一个 context.event 表,其中暴露了触发该钩子的游戏事件的相关数据。可用的字段取决于正在运行的钩子。
伤害钩子
适用于 on_boss_damaged、on_boss_damaged_by_player、on_reinforcement_damaged_by_player、on_boss_damaged_by_elite 和 on_player_damaged_by_boss。
| 字段 / 方法 | 类型 | 说明 |
|---|---|---|
event.damage_amount | double | 创建此事件表时的伤害快照 |
event.get_damage_amount() | double | 读取经过先前修改后的当前伤害 |
event.damage_cause | string | Spigot DamageCause 名称(如 "ENTITY_ATTACK"、"PROJECTILE") |
event.damager | 实体表 | 底层事件为实体伤害时的原始 Bukkit 伤害来源,on_boss_damaged 也包括这种情况。投射物命中时是投射物本身,不是射手。 |
event.projectile | 实体表 | 如果造成伤害的是投射物,则为该投射物实体。 |
event.set_damage_amount(n) | — | 将伤害覆盖为固定值 |
event.multiply_damage_amount(n) | — | 将当前伤害乘以 n |
event.cancel_event() | — | 完全取消伤害事件 |
on_boss_damaged_by_player = function(context)
-- Halve all projectile damage
if context.event.damage_cause == "PROJECTILE" then
context.event.multiply_damage_amount(0.5)
end
end
使用点号调用 event.set_damage_amount(n) 和 event.multiply_damage_amount(n)。这两个 setter 直接读取第一个参数,不会移除冒号调用传入的 self。event:transfer_damage(target, amount) 支持冒号调用。
在玩家伤害精英的钩子中,event.entity 标识受伤的精英。在 on_reinforcement_damaged_by_player 中,context.boss 仍是召唤者,context.player 是攻击玩家。钩子接收增援原本可修改的伤害事件。
这些钩子也提供 event.is_damage_transfer 和 event:transfer_damage(target, amount)。目标必须解析为另一只存活的精英,并且能力所属 Boss 有权影响它。伤害量必须有限且非负。事件已取消、伤害为零或嵌套转移会返回 false。有效调用提交一次独立、归属于玩家的攻击并返回 true,不会扣除原攻击的伤害,也不保证其他监听器允许新攻击。要分摊而非复制伤害,请自行调用 event.set_damage_amount(...) 修改原伤害。转移的伤害不能递归再次转移。
生成钩子
适用于 on_spawn。
| 字段 / 方法 | 类型 | 说明 |
|---|---|---|
event.spawn_reason | string | Spigot SpawnReason 名称 |
event.cancel_event() | — | 取消生成 |
死亡钩子
适用于 on_death。
| 字段 / 方法 | 类型 | 说明 |
|---|---|---|
event.entity | 实体表 | 正在死亡的实体 |
区域钩子
适用于 on_zone_enter 和 on_zone_leave。
| 字段 / 方法 | 类型 | 说明 |
|---|---|---|
event.entity | 实体表 | 进入或离开区域的实体 |
由 Lua 创建的区域监视器不会填充 context.event;它们会将进入/离开的实体直接传递给其回调。
可取消事件(通用)
底层游戏事件可取消的钩子提供 event.cancel_event()。on_game_tick、on_mind_action 和普通调度回调的 context.event 为 nil。on_heal 等钩子有底层事件,但此表没有治疗专用字段。调用前请确认文档列出的成员。
Boss 专用事件表不提供共享包装器的 cancel()、uncancel() 或 is_cancelled。取消事件会停止向同一 Boss 的后续能力派发,但不会退出当前 Lua 函数。如果函数后续代码不应执行,请添加 return。
有关完整的实体表字段,请参阅 Boss 与实体。有关伤害原因和生成原因的值,请参阅枚举与值。
钩子执行顺序
Boss 的事件派发器按执行顺序访问附加的脚本能力。Lua 能力的 priority 字段决定其位置:
- 值越小越先执行(默认为
0)。 - 相同优先级保留 Boss 能力的插入顺序。不要依赖不同加载路径中始终具有某个固定顺序。
return {
api_version = 1,
priority = -10, -- runs before most other powers
on_boss_damaged_by_player = function(context)
-- Record this power's last attacker before its later hooks run
context.state.last_attacker = context.player.uuid
end
}
YAML EliteScript 能力以优先级 0 参与同一派发器。负数 Lua 优先级在它们之前执行,正数在之后。这只为一个 Boss 上的一次事件排序,不控制独立 Bukkit 事件或调度任务的顺序。已取消的事件会停止派发,因此并非所有能力都会收到事件。增援自身的能力执行后,仅当事件仍未取消时,其直接召唤者才会收到增援伤害钩子。
运行时模型
每个能力与 Boss 组合一个运行时
每个附加的 Lua 能力都会为每只 Boss 获得独立运行时。启动时,EliteMobs 在全新的沙盒中执行已加载的源代码并保存返回表。Boss 消失或被移除时,运行时关闭。因此,拥有两个 Lua 能力的 Boss 有两套独立的状态表和环境。
这意味着:
- 文件局部变量、函数及自行定义的 Lua 全局变量,都只属于该 Boss 上该能力的运行时。
- 返回表的钩子函数永远不会在 Boss 之间共享。
加载器还会执行一次文件以验证返回表。文件顶层初始化应独立于钩子上下文。仅使用 tick 的能力不需要 on_spawn 就能启动时钟。运行时初始化与生成回调是两个独立步骤。
状态隔离
每个运行时都有自己的 context.state 表,与同一 Boss 的其他能力以及其他 Boss 的同名能力隔离。用它在钩子之间保存计数器、标志、计时器等能力数据。状态只在运行时活动期间保留,不会写入磁盘跨越消失或重启。
return {
api_version = 1,
on_boss_damaged_by_player = function(context)
-- Each boss tracks its own enrage counter independently
context.state.enrage_hits = (context.state.enrage_hits or 0) + 1
if context.state.enrage_hits >= 20 then
context.boss:apply_potion_effect("SPEED", 200, 2)
end
end
}
计划任务所有权
通过 context.scheduler 创建的所有任务都归创建它们的运行时所有。当 Boss 消失时:
- 运行时调用
shutdown()。 - 所有已拥有的任务——无论是一次性(
run_after)还是重复(run_every)——都会被自动取消。 - 所有区域监视都会被清除。
- 该能力仍持有的 AI、感知、缩放和无敌修改会被恢复,它设置的治疗状态会被清除,其追踪火球控制器也会停止。
运行时会在移除 Boss 时清理自己拥有的调度任务。部分 Java 游戏辅助功能会启动具有独立生命周期的任务,请查阅对应说明。正常游戏中不再需要的重复任务仍应取消:
return {
api_version = 1,
on_enter_combat = function(context)
local pulse_count = 0
local task_id
task_id = context.scheduler:run_every(20, function(tick_context)
pulse_count = pulse_count + 1
if pulse_count > 10 or not tick_context.boss.exists then
tick_context.scheduler:cancel_task(task_id)
return
end
tick_context.world:spawn_particle_at_location(
tick_context.boss:get_location(),
{ particle = "FLAME", amount = 20, speed = 0.1 }
)
end)
end
}
每 Tick 时钟行为
Lua 能力实例的内部 tick 时钟仅在该能力定义了 on_game_tick 钩子时才会运行。通过 context.zones:watch_zone(...) 或 context.script:zone(...):watch(...) 创建的区域监视会创建它们自己拥有的重复任务,而不是启用该能力的顶层 on_game_tick 钩子。
没有 on_game_tick 或区域监视器时,运行时不注册这个 tick 时钟。显式调度任务和游戏辅助功能启动的任务仍按各自计划执行。Boss 消失或运行时关闭时,tick 工作及其拥有的区域监视任务会被取消。
暂停运行时会保留 Lua 状态和所拥有的任务,但跳过它们的工作。Boss 事件路径还会检查能力是否被抑制。移除 Boss 或关闭运行时会终止所拥有的工作,而非仅仅暂停。
错误和性能行为
EliteMobs 对 Lua 能力实施严格的错误和性能限制:
异常
如果钩子函数或计划回调抛出 Lua 错误(或 API 调用中浮现出 Java 异常),该能力会针对那个 Boss 实例被立即禁用。运行时会关闭,所有已拥有的任务会被取消。
失败的实例保持关闭,后续伤害钩子不会静默重建它。修复并重新加载定义,然后创建新运行时。所有者主动拆除实例时可以清除旧引用,供之后重建实体本体。
错误会连同能力文件名和正在执行的钩子记录到服务器控制台;能够获取时也会附上行号:
[Lua] Error in 'frost_cone.lua' at line 35 during 'on_boss_damaged_by_player':
[Lua] -> ...explanation of what went wrong...
[Lua] -> Script has been disabled for this entity to prevent further errors.
执行预算
Lua 能力在一个硬性执行预算下运行,该预算在 Lua VM 内部强制执行,而不仅仅是事后测量。每一个钩子、每一个调度回调,乃至文件的初次求值,都在该预算之内运行:
| 限制 | 取值 |
|---|---|
| 每个入口点的线程 CPU 时间 | 50 毫秒 |
| 无法测量 CPU 时间时的备用经过时间 | 250 毫秒 |
| 每个入口点的字节码指令数 | 250,000 |
VM 会统计指令数,并每 1024 条指令检查一次时间。错误会指出超出的限制:
Lua instruction budget exceeded (250000 instruction limit)
Lua CPU-time budget exceeded (50ms current-thread CPU limit)
Lua elapsed-time fallback budget exceeded (250ms fallback; current-thread CPU time unavailable)
无限 Lua 循环会在执行过程中被中断。嵌套调用共享入口点的预算。VM 无法中断正在执行的 Java API 调用,调用返回后也没有额外的 50 毫秒实际时间限制。因此,预算并不能保证高开销的世界操作安全。
为了保持在预算范围内:
- 避免在钩子内使用无界循环。使用
context.scheduler:run_every(...)将工作分散到多个 tick。 - 保持
on_game_tick处理程序轻量——它们每个 tick 都会运行。 - 将繁重的初始化移到
on_spawn或on_enter_combat中,而不是每个 tick 都重复执行。 - 请记住,在大范围区域上做粒子循环是烧掉 250,000 条指令的常见方式。使用
coverage采样更少的位置。
Lua 沙盒
Lua 能力在沙盒化的 LuaJ 环境中运行。多个可能访问文件系统或 Java 运行时的全局变量已被移除。
已移除的全局变量
以下标准 Lua 全局变量被设置为 nil,无法使用:
| 已移除 | 原因 |
|---|---|
debug | 暴露内部 VM 状态 |
dofile | 文件系统访问 |
io | 文件系统访问 |
load | 任意代码加载 |
loadfile | 文件系统访问 |
luajava | 直接 Java 类访问 |
module | 模块系统(不需要) |
os | 操作系统访问 |
package | 模块系统(不需要) |
require | 模块系统 / 文件系统访问 |
VM 的调试钩子在内部仍然是安装着的 —— 执行预算正是靠它来统计指令数 —— 但 debug 表本身在紧随其后就被设为 nil,因此脚本无法访问它。
可用的标准库
沙盒保留 LuaJ 的标准库,包括以下常用函数:
| 类别 | 函数 |
|---|---|
| Math | math.abs、math.ceil、math.floor、math.max、math.min、math.random、math.sin、math.cos、math.sqrt、math.pi,以及所有其他 math.* 函数 |
| String | string.byte、string.char、string.find、string.format、string.gsub、string.len、string.lower、string.match、string.rep、string.sub、string.upper,以及所有其他 string.* 函数 |
| Table | table.insert、table.remove、table.sort、table.concat,以及所有其他 table.* 函数 |
| 迭代器 | pairs、ipairs、next |
| 类型 | type、tostring、tonumber、select、unpack |
| 错误处理 | pcall、xpcall、error、assert |
| 其他 | print、rawget、rawset、rawequal、rawlen、setmetatable、getmetatable |
字符串共享一个被锁定的元表。getmetatable("") 返回 false,而且即使脚本在自己的 string 表中添加或替换了函数,对字符串的方法调用(例如 ("text"):upper())也始终使用标准字符串函数。
print 会写入控制台,但建议使用 context.log:info(msg) 或 context.log:warn(msg)。Boss 日志不会自动包含能力文件名。多个能力可能同时记录时,请在消息中添加文件名。
em 辅助命名空间
em 表在文件加载时(任何钩子运行之前)就可用。它提供辅助构造函数,用于构建整个 API 中使用的位置表、向量表和区域定义。
| 函数 | 用途 |
|---|---|
em.create_location(x, y, z [, world, yaw, pitch]) | 创建带有可选世界名称、偏航和俯仰的位置表 |
em.create_vector(x, y, z) | 创建向量表 |
em.zone.create_sphere_zone(radius) | 创建球形区域定义 |
em.zone.create_dome_zone(radius) | 创建穹顶区域定义 |
em.zone.create_cylinder_zone(radius, height) | 创建圆柱体区域定义 |
em.zone.create_cuboid_zone(x, y, z) | 创建长方体区域定义 |
em.zone.create_cone_zone(length, radius) | 创建锥形区域定义 |
em.zone.create_static_ray_zone(length, thickness) | 创建静态射线区域定义 |
em.zone.create_rotating_ray_zone(length, point_radius, animation_duration) | 创建旋转射线区域定义 |
em.zone.create_translating_ray_zone(length, point_radius, animation_duration) | 创建平移射线区域定义 |
em.location 提供共享的只读世界查询:is_in_dungeon、is_protected、owned_by、owners、kinds_at 和 has_kind。第一个参数是位置表。位置缺失、不是表、缺少世界或世界未加载时,返回 false 或空表。缺失坐标默认为零;坐标类型无效仍可能报错。为这些查询构造位置时应明确指定世界。签名和返回结构见 Lua API 参考。
-- Skip a destructive mechanic inside claims or protected regions
if em.location.is_protected(context.player:get_location()) then return end
不存在用于建造权限检查的 Lua 绑定。em.location.is_protected 只告诉你是否有任何保护插件声明了该位置;它不解析针对具体玩家的建造权限。
区域构造器返回可链式调用的表,支持 :set_center(loc)(或根据区域类型使用 :set_origin(loc) / :set_destination(loc))。它们设计为可在文件顶部或钩子内使用:
-- At file scope: create a reusable zone shape
local blast_zone = em.zone.create_sphere_zone(5)
return {
api_version = 1,
on_boss_damaged_by_player = function(context)
-- Anchor the zone to the boss's current location at call time
blast_zone:set_center(context.boss:get_location())
local entities = context.zones:get_entities_in_zone(blast_zone)
for i = 1, #entities do
if entities[i].is_player then
entities[i]:apply_potion_effect("SLOWNESS", 60, 1)
end
end
end
}
有关区域形状、过滤器、监视器和目标模式的完整说明,请参阅区域与目标。
