跳到主要内容

Lua 脚本:钩子与生命周期

webapp_banner.jpg

本页涵盖 Lua 能力可以定义的每个钩子、钩子的执行顺序、每个 Boss 如何获得自己的隔离运行时,以及沙盒中可用的标准库函数。

如果你还没有编写过 Lua 能力,请先从入门指南开始。

Boss 能力钩子

本页记录的是 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_damagedBoss 受到任何来源的伤害时否
on_boss_damaged_by_playerBoss 受到玩家的伤害时是
on_reinforcement_damaged_by_player此 Boss 召唤的增援受到玩家伤害时;在召唤者的能力上执行是
on_boss_damaged_by_eliteBoss 受到另一个精英怪物的伤害时否
on_player_damaged_by_boss玩家受到此 Boss 的伤害时是
on_enter_combatBoss 进入战斗时是
on_exit_combatBoss 脱离战斗时否
on_healBoss 治疗时否
on_boss_target_changedEliteMobs 报告玩家目标事件时,包括首次获取目标;不涵盖每次目标变更或丢失是
on_deathBoss 死亡时否
on_phase_switch阶段 Boss 切换到新阶段时否
on_mind_action原生 Mind 向附加能力请求动作时;参阅动作上下文否
on_zone_enter实体进入被监视的区域时是(如果该实体是玩家)
on_zone_leave实体离开被监视的区域时是(如果该实体是玩家)

当 context.player 列为"否"时,访问它会返回 nil。使用前请始终进行 nil 检查。

on_boss_damaged 和更具体的伤害钩子走不同的事件路径。一次攻击可以触发两者,不应把它们视为互斥。目标钩子不能通过此 Lua 事件表取消,也不报告之前的目标。

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_amountdouble创建此事件表时的伤害快照
event.get_damage_amount()double读取经过先前修改后的当前伤害
event.damage_causestringSpigot 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_reasonstringSpigot 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 消失时:

  1. 运行时调用 shutdown()。
  2. 所有已拥有的任务——无论是一次性(run_after)还是重复(run_every)——都会被自动取消。
  3. 所有区域监视都会被清除。
  4. 该能力仍持有的 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 的标准库,包括以下常用函数:

类别函数
Mathmath.abs、math.ceil、math.floor、math.max、math.min、math.random、math.sin、math.cos、math.sqrt、math.pi,以及所有其他 math.* 函数
Stringstring.byte、string.char、string.find、string.format、string.gsub、string.len、string.lower、string.match、string.rep、string.sub、string.upper,以及所有其他 string.* 函数
Tabletable.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
}

有关区域形状、过滤器、监视器和目标模式的完整说明,请参阅区域与目标。


后续步骤​