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(50 毫秒)执行一次 | 否 |
on_boss_damaged | Boss 受到任何来源的伤害时 | 否 |
on_boss_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 | Boss 切换目标时 | 是 |
on_death | Boss 死亡时 | 否 |
on_phase_switch | 阶段 Boss 切换到新阶段时 | 否 |
on_mind_action | 原生 Mind 向附加能力请求动作时;参阅动作上下文 | 否 |
on_zone_enter | 实体进入被监视的区域时 | 是(如果该实体是玩家) |
on_zone_leave | 实体离开被监视的区域时 | 是(如果该实体是玩家) |
当 context.player 列为"否"时,访问它会返回 nil。使用前请始终进行 nil 检查。
on_zone_enter / on_zone_leave are not fired by Lua zones这两个钩子会被加载器接受并通过校验,但 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 区域脚本配对使用,否则请使用监视器。
(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 a firework 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_boss_damaged_by_elite 和 on_player_damaged_by_boss。
| 字段 / 方法 | 类型 | 说明 |
|---|---|---|
event.damage_amount | double | 原始伤害值 |
event.damage_cause | string | Spigot DamageCause 名称(如 "ENTITY_ATTACK"、"PROJECTILE") |
event.damager | 实体表 | 造成伤害的实体。仅在由实体造成伤害的钩子中存在。 |
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
生成钩子
适用于 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()。如果某个钩子的 context.event 为 nil(如 on_game_tick、on_heal),则没有底层事件可供交互。
有关完整的实体表字段,请参阅 Boss 与实体。有关伤害原因和生成原因的值,请参阅枚举与值。
钩子执行顺序
当一个 Boss 附加了多个 Lua 能力时,每个能力的钩子都会为同一事件被调用。执行顺序由 priority 字段决定:
- 值越小越先执行(默认为
0)。 - 相同优先级的能力按加载顺序执行(实际上是未指定的)。
return {
api_version = 1,
priority = -10, -- runs before most other powers
on_boss_damaged_by_player = function(context)
-- This runs early, so other powers see any state changes we make
context.state.last_attacker = context.player.uuid
end
}
优先级仅影响同一 Boss 上 Lua 能力之间的排序,不会与 EliteScript 的执行顺序产生交互。
运行时模型
每个 Boss 一个运行时
每个 Boss 实体都会获得自己独立的 Lua 运行时实例。当 Boss 生成时,EliteMobs 加载 Lua 源代码,在全新的沙盒环境中对其求值,并存储返回的表。当 Boss 消失或被移除时,运行时会关闭。
这意味着:
- 文件求值期间设置的全局 Lua 变量(如使用
local function定义的辅助函数)对该 Boss 是私有的。 - 返回表的钩子函数永远不会在 Boss 之间共享。
状态隔离
每个运行时都有自己的 context.state 表。一个 Boss 的状态对其他所有 Boss 完全不可见,即使它们共享同一个 Lua 能力文件。使用 context.state 来存储计数器、标志、计时器或跨钩子所需的任何按 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)——都会被自动取消。 - 所有区域监视都会被清除。
你永远不需要在 Boss 移除时手动清理计划任务。但是,你仍然应该在正常游戏过程中不再需要重复任务时取消它们,以避免不必要的开销:
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 工作和区域监视器任务都会被自动取消。
错误和性能行为
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,因此脚本无法访问它。
可用的标准库
Lua 标准库的其他所有内容均正常工作:
| 类别 | 函数 |
|---|---|
| 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 |
print 会写入服务器控制台,但建议使用 context.log:info(msg) 或 context.log:warn(msg) 进行输出。这些会带有能力名称前缀,更容易追踪是哪个能力产生了该消息。
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 还带有一个 em.location 子表,提供在所有 MagmaGuy 插件之间共享的只读世界查询:is_in_dungeon、is_protected、owned_by、owners、kinds_at 和 has_kind。它们的第一个参数是位置表,且对 nil 安全(缺失或格式错误的位置会返回 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
}
有关区域形状、过滤器、监视器和目标模式的完整说明,请参阅区域与目标。
