Lua 腳本:鉤子與生命週期
本頁涵蓋 Lua 能力可以定義的每個鉤子、鉤子執行順序、每個 Boss 如何取得自己的隔離執行階段,以及沙盒中可用的標準函式庫函式。
如果你還沒有編寫過 Lua 能力,請先從入門指南開始。
本頁記載的是位於 plugins/EliteMobs/powers/ 的 Boss Lua 能力的鉤子。NPC Lua 腳本使用它們自己的 plugins/EliteMobs/npc_scripts/ 資料夾,以及 NPC 專屬的鉤子,例如 on_npc_interact 與 on_npc_proximity_enter;請見 NPC 腳本。
鉤子參考
每個 Lua 能力檔案都會回傳一個表。該表中的每個鍵(除了 api_version 與 priority 之外)都必須是下方所列鉤子之一。每當對應的遊戲事件觸發時,執行階段就會呼叫相符的函式。
| 鉤子 | 觸發時機 | context.player 是否可用? |
|---|---|---|
on_spawn | 精英怪物生成時 | 否 |
on_game_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 | entity 表 | 投射物實體,前提是傷害來源為投射物。 |
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 | entity 表 | 正在死亡的實體 |
區域鉤子
適用於 on_zone_enter 與 on_zone_leave。
| 欄位 / 方法 | 類型 | 說明 |
|---|---|---|
event.entity | 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。
關於完整的 entity 表欄位,請見 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 之間共用。
載入器也會執行一次檔案以驗證回傳表。檔案頂層初始化應獨立於鉤子的上下文。僅使用逐刻鉤子的能力不需要 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
}
逐刻時鐘的行為
只有當能力定義了 on_game_tick 鉤子時,某個 Lua 能力實例的內部刻時鐘才會運作。透過 context.zones:watch_zone(...) 或 context.script:zone(...):watch(...) 建立的區域監看,會建立它們自己歸其所有的重複性任務,而不會啟用該能力頂層的 on_game_tick 鉤子。
沒有 on_game_tick 或區域監看器時,執行階段不會註冊這個逐刻時鐘。明確建立的排程任務及遊戲輔助功能啟動的任務,仍按各自排程執行。Boss 消失或執行階段關閉時,逐刻工作及其擁有的區域監看任務會被取消。
暫停執行階段會保留 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 ms |
| 無法測量 CPU 時間時的備用經過時間 | 250 ms |
| 每個進入點的位元組碼指令數 | 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(...)將工作分散到各刻。 - 讓
on_game_tick處理器保持輕量——它們每一刻都會執行。 - 將繁重的初始化移到
on_spawn或on_enter_combat中,而不要每刻都重複執行。 - 請記得,在大範圍區域上進行粒子迴圈是燒掉 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 |
字串共用一個鎖定的 metatable。getmetatable("") 會回傳 false,而且即使腳本在自己的 string 表中新增或取代函式,("text"):upper() 這類字串方法呼叫仍一律使用標準字串函式。
print 會寫入主控台,但建議使用 context.log:info(msg) 或 context.log:warn(msg)。Boss 日誌不會自動包含能力檔名。多個能力可能同時記錄時,請在訊息中加上檔名。
em 輔助命名空間
em 表在檔案載入時(任何鉤子執行之前)即可使用。它提供用於建構整個 API 所使用的 location 表、vector 表與區域定義的輔助建構子。
| 函式 | 用途 |
|---|---|
em.create_location(x, y, z [, world, yaw, pitch]) | 建立一個 location 表,可選擇加上世界名稱、yaw 與 pitch |
em.create_vector(x, y, z) | 建立一個 vector 表 |
em.zone.create_sphere_zone(radius) | 建立一個球體區域定義 |
em.zone.create_dome_zone(radius) | 建立一個半球(dome)區域定義 |
em.zone.create_cylinder_zone(radius, height) | 建立一個圓柱區域定義 |
em.zone.create_cuboid_zone(x, y, z) | 建立一個長方體(cuboid)區域定義 |
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
}
關於區域形狀、篩選條件、監看器與目標選取模式的完整說明,請見區域與目標選取。
