創建你的第一個模組
簡介
這個教程旨在幫助你開始在LeviLamina中進行模組開發。它絕不是LeviLamina中所有可能性的完整教程,而是基礎知識的總體概述。首先確保您瞭解C++,在 IDE中設置工作區,然後介紹大多數LeviLamina模組的基本知識。
在這個教程中,我們將會創建一個簡單的模組,用於實現以下功能:
- 玩家可以輸入
/suicide命令自殺 - 玩家首次進入世界時給予一個鐘
- 玩家使用鍾時,彈出確認窗口詢問是否自殺,如果確認則自殺
這個教程包含以下知識點:
- 日誌輸出
- 訂閱事件
- 註冊命令
- 讀取配置文件
- 數據庫存取
- 使用表單
- 構造Minecraft對象
- 調用Minecraft函數
Info
本教程的所有源碼可以在ShrBox/ExampleMod找到。我們建議你一邊看源碼一邊看教程。
學習C++
這些教程需要C++編程語言的基礎知識。如果您剛剛開始使用C++或需要複習一下,以下是一個非詳盡的列表。
- C++ Developer Roadmap
- cppreference.com
- C++ Tutorial
- C++ Language Tutorial
- hacking C++
- C++ Core Guidelines
設置工作區
在開發模組(或學習C++)之前,您需要設置一個開發環境。這包括但不限於以下內容:
- xmake
- Visual Studio Code
- Git
- Visual Studio(安裝Visual Studio時,請確保勾選了“使用C++的桌面開發”這一項)
- LLVM:打開Visual Studio Installer,在“使用 C++ 的桌面開發”可選組件下選擇“適用於 Windows 的 C++ Clang 工具”。或者從GitHub下載。
為Visual Studio Code安裝擴展程序
在安裝完VSCode後,你還需要在VSCode中安裝以下擴展:
創建模組倉庫
訪問levilamina-mod-template,點擊Use this template以使用這個模板初始化你的模組倉庫。

在某個用於存放模組工程的文件夾中使用git clone將模組倉庫克隆到本地,然後使用VSCode打開。你需要修改其中的一些文件,填寫你的模組信息。
首先,你需要修改xmake.lua中模組名字信息。修改模組名字是為了指定你的模組的名字,這個名字將會在LeviLamina中顯示。名字允許英文大小寫、數字、中劃線,不允許包括空格和其他特殊字符,建議採用example-mod或ExampleMod這兩種形式。在這裡,我們的模組命名為ExampleMod。
target("ExampleMod") -- Change this to your mod name.然後,最好手動固定一下模組使用的LeviLamina版本,比如,我們需要使用LeviLamina 26.51的最新版本,我們在levilamina後面添加26.51.*作為版本號。也支持使用某個commit作為版本號,這在LeviLamina未能發佈新版本時提前為模組適配新版本時很有用。
-- add_requires("levilamina x.x.x") for a specific version
-- add_requires("levilamina develop") to use develop version
add_requires("levilamina 26.51.*", {configs = {target_type = get_config("target_type")}})接著,修改tooth.json的內容。tooth.json為lip安裝模組包提供了相關信息,正確配置後,你的模組將會被LeviMC 模組和LeviLauncher收錄,並能被全世界的用戶下載安裝。
Tip
其中label為空的variant代表服務端,label為client的variant代表客戶端,如果您的模組只在服務端/客戶端可用,可以將無用的variant刪掉。服主用 lip 安裝服務端版本:
lip install github.com/ShrBox/ExampleModlip 只管理服務端工作區;玩家通過 LeviLauncher 安裝客戶端版本,它會為客戶端實例選擇客戶端版本。
Note
- 將
tooth字段的值改為這個模組的GitHub倉庫路徑(不帶https://,例如github.com/ShrBox/ExampleMod),填寫info中各個信息字段
info.avatar_url會顯示在LeviMC 模組以及LeviLauncher的模組頁面中,為模組挑選一個適合的圖標也是展示您的模組的重要一環,這裡為了方便直接使用教程編寫者的GitHub頭像 - 修改各個
variant中的dependencies裡的LeviLamina目標版本為模組實際使用的LeviLamina版本 - 然後根據倉庫release地址填寫
assets中各項的urls,修改依賴的LeviLamina版本,並根據在xmake.lua中填寫的模組名修改placements的src和dest。對於本文的模組,以下是一個可行的參考:
{
"format_version": 3,
"format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d",
"tooth": "github.com/ShrBox/ExampleMod",
"version": "0.1.0",
"info": {
"name": "ExampleMod",
"description": "Mod example for LeviLamina",
"tags": [
"platform:levilamina",
"type:mod"
],
"avatar_url": "https://avatars.githubusercontent.com/u/53301243"
},
"variants": [
{
"label": "",
"platform": "win-x64",
"dependencies": {
"github.com/LiteLDev/LeviLamina": "26.51.*"
},
"assets": [
{
"type": "zip",
"urls": [
"https://{{tooth}}/releases/download/v{{version}}/ExampleMod-server-windows-x64.zip"
],
"placements": [
{
"type": "dir",
"src": "ExampleMod/",
"dest": "plugins/ExampleMod/"
}
]
}
],
"preserve_files": [],
"remove_files": [],
"scripts": {
"pre_install": [],
"install": [],
"post_install": [],
"pre_uninstall": [],
"uninstall": [],
"post_uninstall": []
}
},
{
"label": "client",
"platform": "win-x64",
"dependencies": {
"github.com/LiteLDev/LeviLamina#client": "26.51.*"
},
"assets": [
{
"type": "zip",
"urls": [
"https://{{tooth}}/releases/download/v{{version}}/ExampleMod-client-windows-x64.zip"
],
"placements": [
{
"type": "dir",
"src": "ExampleMod/",
"dest": "mods/ExampleMod/"
}
]
}
],
"preserve_files": [],
"remove_files": [],
"scripts": {
"pre_install": [],
"install": [],
"post_install": [],
"pre_uninstall": [],
"uninstall": [],
"post_uninstall": []
}
}
]
}然後,你需要修改LICENSE文件中的版權信息。你可以在這裡選擇一個適合你的模組的開源協議。請放心,你的模組不需要開源,因為模組模板使用了CC0協議,你可以隨意修改或刪除LICENSE文件。但是,我們建議你使用一個開源協議,因為這樣可以讓其他人更容易地使用你的模組和幫助你改進你的模組。
接下來,你需要修改README.md文件中的內容。這個文件將會在你的模組倉庫主頁顯示,你可以在這裡介紹你的模組的功能、使用方法、配置文件、命令等等。
最後,你需要修改命名空間名。將MyMod.cpp和MyMod.h中命名空間my_mod以及類MyMod改成你想要的名字。按照C++常見慣例,命名空間名應當使用小寫字母和下劃線,且應當保持一致。這裡,我們將命名空間統一改成example_mod,類名改為ExampleMod。同樣,你可以將MyMod.cpp和MyMod.h改為你想要的名字,但同時要記得把源文件中的#include MyMod.h改為新的頭文件名。
構建你的模組
在一切開始之前,先讓我們嘗試構建一下空的模組。
在VSCode界面左側的側邊欄找到XMake圖標,然後選擇構建的模式,這裡選擇Debug

然後我們還需要手動指定一下模組將被構建成服務端模組還是客戶端模組
- 點擊VSCode左側側邊欄的擴展按鈕,在擴展標籤頁中找到XMake,然後右鍵點擊設置

- 在彈出的設置頁面中,找到
Additional Config Arguments,點擊下面的Add Item,在輸入框中輸入--target_type=client或--target_type=server,它們分別表示構建為客戶端模組或服務端模組

Failure
如果你在更新倉庫或配置構建過程中,出現了下載失敗的情況,那麼可能需要配置GitHub鏡像代理 或者配置HTTP代理:
然後點擊VSCode底欄的Build the given target圖標來構建模組
註冊命令/suicide
在Minecraft中,命令並不是一開始就能夠註冊的,而是需要在特定的程序執行之後才能註冊。因此,你不能在模組加載時註冊命令,在服務端中,你可以在模組啟用時註冊命令,因為此時服務端已完全啟動,但在客戶端中,你不能這麼做,因為自26.10版本開始,客戶端會在遊戲啟動完成時啟用模組,而非在本地世界加載完成時。
所以,在本教程中,我們將通過監聽ServerCommandRegisterEvent來註冊命令。
Warning
模組在加載時,會調用其加載方法。但請不要將事件訂閱、命令註冊等任何與遊戲相關的操作放在加載方法中,因為這些操作需要在遊戲加載完成後才能進行。如果你在加載方法中進行了這些操作,那麼你的模組將很有可能會在加載時崩潰。
Tip
一般來說,模組的構造函數中只需要進行一些與遊戲無關初始化操作即可,例如初始化日誌系統、初始化配置文件、初始化數據庫等等。
-
include一些我們需要的頭文件
如前文所說,我們需要在ServerCommandRegisterEvent中註冊命令,所以我們需要include下面的頭文件C++#include "ll/api/command/CommandHandle.h" #include "ll/api/command/CommandRegistrar.h" #include "ll/api/event/EventBus.h" #include "ll/api/event/command/ServerCommandRegisterEvent.h" -
在
ExampleMod::enable()中實現我們的事件監聽以及命令註冊C++bool ExampleMod::enable() { // 会被LeviLamina启用所有模组时调用 auto& logger = getSelf().getLogger(); logger.debug("Enabling..."); // 向控制台以DEBUG等级输出日志 using namespace ll:: event; // 提前使用命名空间以精简代码,比如ll::event::EventBus可以直接写成EventBus,ll::event::command::ServerCommandRegisterEvent可以直接写成 command::ServerCommandRegisterEvent auto& bus = EventBus::getInstance(); // EventBus是单例,通过getInstance()方法获取实例 bus.emplaceListener< command::ServerCommandRegisterEvent>([&logger]( command::ServerCommandRegisterEvent& ) { // 以lambda表达式注册事件监听器,参数为ServerCommandRegisterEvent的引用 auto& command = ll::command::CommandRegistrar::getInstance( false ) // CommandRegistrar是单例,通过getInstance()方法获取实例,参数表示是否为客户端侧命令,传入false表示为服务端 .getOrCreateCommand( "suicide", "Commits suicide.", CommandPermissionLevel::Any ); // 获取或创建命令,第一个参数为命令名称,第二个参数为命令描述,会出现在客户端的命令提示以及服务端的help中,第三个参数为最低命令权限等级 command.overload().execute([&logger]( CommandOrigin const& origin, CommandOutput& output ) { // 重载命令并且注册命令回调 auto* entity = origin.getEntity(); // 通过CommandOrigin获取执行命令的实体对象 if (entity == nullptr || !entity->isPlayer()) { // 如果实体对象为空指针或实体对象并非玩家 output.error( "Only players can commit suicide" ); // 通过CommandOutput向控制台/命令方块等非玩家对象输出错误 return; } auto* player = static_cast<Player*>( entity ); // 将实体对象转为玩家对象,因为在Minecraft中玩家对象继承自实体对象,且在上文我们已经判断过实体为玩家了 player->kill(); // 调用kill()方法杀死玩家 logger.info( "{} killed themselves", player->getRealName() ); // 向控制台输出玩家自杀了 }); }); return true; // 返回true表明模组启用成功 }
我們結合以上代碼中的註釋將以上的代碼拆開來理解
LeviLamina的命令系統支持使用CommandRegistrar::getOrCreateCommand()函數直接註冊或獲取命令。
auto& command = ll::command::CommandRegistrar::getInstance(false)
.getOrCreateCommand("suicide", "Commits suicide.", CommandPermissionLevel::Any);第三個參數為執行命令至少需要的權限等級,如果我們希望普通玩家也能執行,應當選擇Any。而GameDirectors對應權限至少為Operator(通過控制台OP命令賦予權限或通過暫停菜單提升為操作員)的玩家,Host對應控制台的權限。
然後,我們需要為命令增加一個重載並設置對應的回調。
command.overload().execute([this](CommandOrigin const& origin, CommandOutput& output) {
// ...
});Note
命令的重載意味著命令的一個模式,例如ll <unload|reload|reactivate> <mod:string> 是一個重載,而ll list是另一個重載。
下面是一個例子,來自LeviLamina的模組管理命令:
enum LeviCommandOperation : int {
unload,
reload,
reactivate,
};
struct LeviCommand {
LeviCommandOperation operation;
SoftEnum<mod::ModNames> mod;
};
void registerModManageCommand() {
// ...
cmd.alias("ll");
cmd.overload<LeviCommand3>().text("load").required("mod").execute(
[](CommandOrigin const&, CommandOutput& output, LeviCommand3 const& param) {
// ...
}
); // ll load <mod:string>
cmd.overload<LeviCommand>()
.required("operation")
.required("mod")
.execute([](CommandOrigin const&, CommandOutput& output, LeviCommand const& param) {
// ...
}); // ll <unload|reload|reactivate> <mod:string>
cmd.overload().text("list").execute([](CommandOrigin const&, CommandOutput& output) {
// ...
}); // ll list
}Warning
由於MCBE缺乏RTTI信息,因此不能夠使用dynamic_cast<T>()。
Tip
你可能注意到另一個函數player->getName(),但我們並沒有使用它。這是因為玩家的名字是可以通過模組或其它方式進行修改的,而player->getRealName()的結果則是固定的。
到這一步,命令對象已經配置完畢,當服務器啟動後或客戶端進入本地世界後,命令對象將被加載到遊戲中。
如果在enable()函數中返回了false,則LeviLamina會認為模組啟用失敗,並在控制台上提示錯誤信息。
讀取配置文件
我們的模組的第二個功能是玩家首次進入服務器時,給予一個鐘;第三個功能是使用鐘的時候,彈出確認自殺的提示,玩家確認後可以自殺。但這兩個功能有個小問題:服務器管理員可能已經安裝了其它的模組,實現了類似的功能,而不希望使用這個自殺模組中這幾個功能。我們希望能提供某種方式,允許管理員開關這兩個功能。
我們在此非常高興地宣佈,LeviLamina在C++中,實現了配置文件與配置信息結構體的反射。這意味著,我們可以在C++中定義一個結構體,然後在配置文件中定義這個結構體的實例,LeviLamina會自動將配置文件中的內容讀取到結構體實例中。這樣,我們就可以在C++中直接使用這個結構體實例,而不需要自己去解析配置文件。
首先,我們另外創建一個Config.h文件,定義一個結構體Config,用於保存配置信息。
namespace example_mod {
struct Config {
int version = 1;
bool doGiveClockOnFirstJoin = true;
bool enableClockMenu = true;
};
} // namespace example_mod
我們在匿名命名空間中增加一個成員變量,用於保存配置文件中的配置信息。
namespace {
Config config;
}然後,我們讀取配置文件並將配置信息保存到成員變量中。
bool ExampleMod::load() { // 会被LeviLamina加载所有模组
auto& logger = getSelf().getLogger();
logger.debug("Loading...");
// 加载或初始化配置文件
const auto& configFilePath = getSelf().getConfigDir() / "config.json";
if (!ll::config::loadConfig(config, configFilePath)) {
logger.warn("Cannot load configurations from {}", configFilePath);
logger.info("Saving default configurations");
if (!ll::config::saveConfig(config, configFilePath)) {
logger.error("Cannot save default configurations to {}", configFilePath);
}
}
return true;
}在這段代碼中,我們首先獲取模組的配置文件路徑,然後調用ll::config::loadConfig()函數,將配置文件中的配置信息讀取到結構體實例中。如果讀取失敗,我們將會在控制台上輸出警告信息,並將默認配置信息保存到配置文件中。
Note
由於配置文件讀取是在加載方法內進行的,所以在後續操作中可以保證配置文件已經讀取成功了。
將玩家進服信息持久化保存在數據庫中
我們的模組的第二個功能是玩家首次進入服務器時,給予一個鐘。但是,如果我們將進服信息保存在內存中,那麼當服務器重啟後,玩家的進服信息就會丟失。因此,我們需要將玩家的進服信息持久化保存在數據庫中。LeviLamina提供了KV數據庫的封裝,可以讓我們在C++中直接使用數據庫。
首先,我們在匿名命名空間中增加一個成員變量,用於保存數據庫實例。
namespace {
Config config;
std::unique_ptr<ll::data::KeyValueDB> playerDb;
} // namespaceNote
為什麼是std::unique_ptr<ll::data::KeyValueDB>而不是ll::data::KeyValueDB?這是因為ll::data::KeyValueDB禁止拷貝,只能移動。因此,我們需要使用std::unique_ptr來保存ll::data::KeyValueDB的實例。
Warning
請不要使用普通的指針來保存ll::data::KeyValueDB的實例,因為這樣很容易使得生命週期管理變得複雜,從而導致內存洩漏和其他問題。請記住:你在寫C++,而不是C。
然後,我們在load方法中,初始化數據庫實例。
bool ExampleMod::load() { // 会被LeviLamina加载所有模组
// ...
// 初始化数据库
const auto& playerDbPath = getSelf().getDataDir() / "players";
playerDb = std::make_unique<ll::data::KeyValueDB>(playerDbPath);
return true;
}在這段代碼中,我們首先獲取模組的數據庫路徑,然後調用std::make_unique<ll::data::KeyValueDB>()函數,創建一個數據庫實例。如果數據庫路徑不存在,那麼std::make_unique<ll::data::KeyValueDB>()函數會自動創建數據庫路徑。
Note
由於數據庫初始化是在加載方法內進行的,所以在後續操作中可以保證數據庫已經初始化成功了。
玩家首次進入遊戲時,給予一個鐘
我們的模組的第二個功能是玩家首次進入服務器時,給予一個鐘。我們需要在玩家進服時,判斷玩家是否首次進服,如果是,則給予一個鐘。
在Minecraft中,玩家進入遊戲時,會觸發事件PlayerJoinEvent。在LeviLamina中,我們可以訂閱這個事件,當這個事件被觸發時,模組可以在這裡實現玩家進服時的邏輯。
LeviLamina足夠智能,能夠在模組被禁用時自動取消事件的監聽,所以我們不需要為事件監聽的生命週期擔心。
bool ExampleMod::enable() { // 会被LeviLamina启用所有模组时调用
// ...
bus.emplaceListener<ll::event::player::PlayerJoinEvent>([&doGiveClockOnFirstJoin = config.doGiveClockOnFirstJoin,
&logger,
&playerDb =
playerDb](ll::event::player::PlayerJoinEvent& event) {
if (doGiveClockOnFirstJoin) { // 判断是否需要在玩家首次加入时给予钟
auto& player = event.self(); // 获取玩家对象
const auto& uuid = player.getUuid(); // 获取玩家的UUID
// 检查玩家之前是否加入过
if (!playerDb->get(uuid.asString())) {
// 构造ItemStack对象
ItemStack itemStack;
itemStack.reinit("minecraft:clock", 1, 0);
// 给玩家的背包添加ItemStack对象
player.add(itemStack);
// 需要刷新玩家的背包来让玩家看得见钟
player.refreshInventory();
// 标记玩家已加入过
if (!playerDb->set(uuid.asString(), "true")) {
logger.error("Cannot mark {} as joined in database", player.getRealName());
}
// 以INFO等级向控制台输出日志,表示玩家首次加入并给予了钟
logger.info("First join of {}! Giving them a clock", player.getRealName());
}
}
});
return true; // 返回true表明模组启用成功
}讓我們將這些代碼拆開來看。在回調lambda函數中,我們捕獲了配置中的doGiveClockOnFirstJoin,以及logger變量和數據庫實例。然後,我們判斷配置中的doGiveClockOnFirstJoin是否為true,如果是,則繼續執行邏輯。
接下來,我們獲取事件實例中的玩家實例和玩家的UUID。
Note
這裡獲取的UUID的類型是mce::UUID而不是std::string。我們建議只有在需要時才將UUID轉換為std::string,因為mce::UUID的實現更加高效。
Danger
請不要使用XUID作為玩家的唯一標識符。雖然在LiteLoaderBDS時代,不少模組使用XUID作為玩家的唯一標識符,但這是不正確的。XUID是Xbox Live的標識符,而不是玩家的標識符。如果服務器沒有開啟在線模式,或者存在假人,那麼XUID的行為將是不可預測的。因此,我們強烈建議使用UUID作為玩家的唯一標識符。
然後,我們使用玩家的UUID作為鍵,從數據庫中獲取玩家是否已經進服過。如果玩家已經進服過,那麼我們就不需要再給予玩家一個鐘了。
接下來,我們構造了一個鐘的物品棧,並將這個物品棧添加到玩家的背包中。
Note
這裡使用了ItemStack類,而不是Item類。ItemStack類是Item類的一個包裝,它包含了物品的數量、附魔、耐久等信息,而Item類僅僅代表這個物品類別。因此應當使用ItemStack類而不是Item類。
然後,我們需要刷新玩家的背包,以便玩家能夠看到鍾。
最後,我們將玩家的UUID作為鍵,將玩家標記為已經進服過。
使用鐘的時候,彈出確認自殺的提示
我們的模組的第三個功能是使用鐘的時候,彈出確認自殺的提示,玩家確認後可以自殺。我們需要訂閱玩家使用物品的事件,當玩家使用鍾時,彈出確認自殺的提示。
在enable()函數中註冊這個事件監聽器。
bool ExampleMod::enable() { // 会被LeviLamina启用所有模组时调用
auto& logger = getSelf().getLogger();
// ...
bus.emplaceListener<ll::event::PlayerUseItemEvent>([enableClockMenu = config.enableClockMenu,
&logger](ll::event::PlayerUseItemEvent& event) {
if (enableClockMenu) {
auto& player = event.self(); // 获取玩家对象
auto& itemStack = event.item(); // 获取玩家使用的物品对象
if (itemStack.getRawNameId() == "clock") { // 如果物品是钟
using namespace ll::form; // 使用ll::form命名空间以简化代码
// 构造ModalForm对象,传入标题、内容、上方按钮文本、下方按钮文本
ModalForm form("Warning", "Are you sure you want to kill yourself?", "Yes", "No");
// 发送ModalForm给玩家,并注册回调函数
form.sendTo(player, [&logger](Player& player, ModalFormResult res, FormCancelReason) {
// 如果玩家选择了上方按钮(Yes),则杀死玩家
if (res.has_value() && res.value() == ModalFormSelectedButton::Upper) {
player.kill();
logger.info("{} killed themselves", player.getRealName());
}
});
}
}
});
return true; // 返回true表明模组启用成功
}讓我們將代碼拆開來看。在回調lambda函數中,我們捕獲了配置項enableClockMenu和logger,然後進行判斷,只有配置項啟用時,才執行邏輯。
在邏輯中,我們首先獲取該事件的兩個屬性,即使用物品的玩家和被使用的物品。然後判斷物品id是否為clock,並執行彈出表單的邏輯。
Warning
不要使用itemStack.getName(),因為這個函數返回的是物品顯示的名字,比如Clock或Iron Sword。
在這裡我們使用了最簡單的模態表單ModalForm,其構造函數的參數分別是:
1. 表單的標題
2. 表單提示內容
3. 上方按鈕內容
4. 下方按鈕內容。
回調函數接收三個參數,分別是: 1. 表單發送向的玩家 2. 玩家的選擇結果 3. 表單被取消的原因,此處暫未使用。
運行你的模組
如果你的模組正常構建完畢,你應該能看到bin/目錄內有一個以你的模組名為名的目錄。將這個目錄拷貝到LeviLamina服務端或客戶端目錄中的mods/目錄中(如果沒有,請創建)。在由 lip 配置的服務端中,plugins/是指向mods/的鏈接,兩個路徑都可以。
然後運行LeviLamina服務端(bedrock_server_mod.exe)或LeviLamina客戶端即可。
發佈你的模組
- 將
tooth.json中的version字段改為你即將發佈的版本,例如0.1.0 - 為
CHANGELOG.md添加即將發佈的新版本的CHANGELOG,具體的CHANGELOG格式可以參照keepachangelog.com,例如:Markdown## 0.1.0 - 2026-08-04 ### Added - First release. - (可選)安裝Node.js,然後運行
Bash
npm install keep-a-changelog -g - (可選)運行
來格式化Bash
changelog --format markdownlintCHANGELOG.md - 在GitHub上創建新的release,例如
v0.1.0
GitHub Actions會自動將CHANGELOG.md的內容寫進release,稍等幾分鐘,你的模組將會自動被編譯並上傳到release
Warning
一定要使用以v開頭並且符合語義化版本的版本號,否則無法正常被LeviMC 模組和LeviLauncher收錄!