Up to date

This page is up to date for Godot 4.2. If you still find outdated information, please open an issue.

以 C++ 語言自定模組

模組

在 Godot 中可以通過模組化的方法來擴充引擎。可以建立新的模組,並啟用或禁用該模組。這樣一來便能在不修改核心的情況下,在任何一個層級上為引擎加上新功能,而這些功能也能拆分為不同的模組來使用於復用。

模組放在建置系統的 modules/ 子資料夾中。Godot 預設啟用了數個模組,如 GDScript (沒錯,GDScript 並非基礎引擎的一部分)、Mono 執行環境、正規表示式模組…以及其他更多模組。可以建立任意數量的新模組並任意組合使用。SCons 會自動處理。

可以做什麼?

雖然對於大多數的遊戲來說我們都建議通過腳本來撰寫 (因為能大幅節省時間),但使用 C++ 也完全沒問題。下列情況適合撰寫新的 C++ 模組:

  • 將外部函式庫繫結到 Godot 中 (如 PhysX, FMOD…等)。

  • 最佳化遊戲中重要的部分。

  • 為引擎與/或編輯器加上新功能。

  • 移植現有遊戲。

  • 如果你沒有 C++ 活不下去的話,可以用 C++ 來撰寫一個完整的新遊戲。

建立新模組

在建立模組之前,請先下載 Godot 的原始碼並進行編譯。在說明文件中有相關的教學。

要建立新的模組,第一步就是在 modules/ 中建立一個新資料夾。若想分開維護模組的話,可以將不同的 VCS 簽出 (Checkout) 到 modules 內來使用。

實例模組的名字就叫“summator(加法器)”( godot/modules/summator )。我們在裡面建立一個簡單的加法器類:

/* summator.h */

#ifndef SUMMATOR_H
#define SUMMATOR_H

#include "core/object/ref_counted.h"

class Summator : public RefCounted {
    GDCLASS(Summator, RefCounted);

    int count;

protected:
    static void _bind_methods();

public:
    void add(int p_value);
    void reset();
    int get_total() const;

    Summator();
};

#endif // SUMMATOR_H

以及 cpp 檔。

/* summator.cpp */

#include "summator.h"

void Summator::add(int p_value) {
    count += p_value;
}

void Summator::reset() {
    count = 0;
}

int Summator::get_total() const {
    return count;
}

void Summator::_bind_methods() {
    ClassDB::bind_method(D_METHOD("add", "value"), &Summator::add);
    ClassDB::bind_method(D_METHOD("reset"), &Summator::reset);
    ClassDB::bind_method(D_METHOD("get_total"), &Summator::get_total);
}

Summator::Summator() {
    count = 0;
}

接著,我們需要用某種方式註冊這個新類別,所以需要建立另外兩個檔案:

register_types.h
register_types.cpp

重要

這幾個檔案必須要放在模組的最上層目錄 (也就是 SCsubconfig.py 檔的旁邊),這樣一來模組才能被正確註冊。

這幾個檔案的內容如下:

/* register_types.h */

#include "modules/register_module_types.h"

void initialize_summator_module(ModuleInitializationLevel p_level);
void uninitialize_summator_module(ModuleInitializationLevel p_level);
/* yes, the word in the middle must be the same as the module folder name */
/* register_types.cpp */

#include "register_types.h"

#include "core/object/class_db.h"
#include "summator.h"

void initialize_summator_module(ModuleInitializationLevel p_level) {
    if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
            return;
    }
    ClassDB::register_class<Summator>();
}

void uninitialize_summator_module(ModuleInitializationLevel p_level) {
    if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
            return;
    }
   // Nothing to do here in this example.
}

接下來,我們需要建立 SCsub 檔案,這樣建置系統才能編譯該模組:

# SCsub

Import('env')

env.add_source_files(env.modules_sources, "*.cpp") # Add all cpp files to the build

當有多個原始碼時,可以分別將各個檔案加到 Python 字串列表中:

src_list = ["summator.cpp", "other.cpp", "etc.cpp"]
env.add_source_files(env.modules_sources, src_list)

通過這種做法,我們便可以通過 Python 的迴圈與邏輯陳述式來達成各種可能性。請參考 Godot 預設附帶的模組為例。

要新增讓編譯器搜尋的 include 資料夾,可以將資料夾附加到編譯環境的路徑內:

env.Append(CPPPATH=["mylib/include"]) # this is a relative path
env.Append(CPPPATH=["#myotherlib/include"]) # this is an 'absolute' path

若要為模組新增自定編譯器旗標,則需要先複製 env 變數,以免修改到整個 Godot 建置用的旗標 (進而導致發生錯誤)。下列為使用自定旗標的 SCsub 範例:

# SCsub

Import('env')

module_env = env.Clone()
module_env.add_source_files(env.modules_sources, "*.cpp")
# Append CCFLAGS flags for both C and C++ code.
module_env.Append(CCFLAGS=['-O2'])
# If you need to, you can:
# - Append CFLAGS for C code only.
# - Append CXXFLAGS for C++ code only.

最後是模組的組態設定檔,這個設定檔是一個名為 config.py 的簡易 Python 腳本:

# config.py

def can_build(env, platform):
    return True

def configure(env):
    pass

建置時會詢問是否能在各個特定的平台上進行建置 (在這個例子中, True 則代表所有平台上都會進行建置)。

就這樣。希望不會太複雜!最終模組應該會長這樣:

godot/modules/summator/config.py
godot/modules/summator/summator.h
godot/modules/summator/summator.cpp
godot/modules/summator/register_types.h
godot/modules/summator/register_types.cpp
godot/modules/summator/SCsub

接著可以將該模組打包為壓縮檔,然後將模組分享給其他人。當為所有平台編譯時 (有關編譯的說明在前一段中),該模組都會被包含在內。

備註

對於如子類別別 (Subclass) 的東西,C++ 模組內限制最多只能有 5 個參數。包含了標頭檔 core/method_bind_ext.gen.inc 便可提升至 13 個。

使用模組

現在,可以在任何腳本內使用剛才建立的模組了:

var s = Summator.new()
s.add(10)
s.add(20)
s.add(30)
print(s.get_total())
s.reset()

輸出為 60

也參考

剛才的 Summator 例子適合小型的自定模組,但如果想使用大型的外部函式庫呢?有關繫結外部函式庫的詳細訊息,請參考 繫結外部函式庫

警告

若模組時要用來在執行中專案內存取的 (即不只是從編輯器中),則必須要重新編譯每個要使用到的匯出樣板,然後在各個匯出預設設定中指定自定樣板的路徑。否則在執行專案的時候會產生錯誤,因為模組沒有被編譯到匯出樣板中。更多資訊請參考 編譯 一頁。

從外部編譯模組

編譯模組會需要將模組的原始碼直接移至 modules/ 資料夾內。雖然這種方法是編譯模組最直觀的方式,但有些情況下我們可能不想用這種方式:

  1. 不管要不要編譯模組,每次編譯引擎的時候都需要手動複製模組的原始碼,甚至還需要額外的步驟來手動在編譯時期通過如 module_summator_enabled=no 這樣的建置選項來停用模組。建立符號連結可能是個解法,但如果要通過腳本實作的話,可能會需要解決一些如符號連結權限這樣的作業系統限制。

  2. 依據是否有要修改引擎的程式碼,當使用 VCS (如 git) 時如果將模組檔案直接放到 modules/ 會更改到工作樹,進而造成需要篩選更改來只 Commit 引擎相關程式碼的麻煩。

因此,如果想要在自定模組上使用獨立的架構,我們可以將「summator」模組移到引擎上層的資料夾:

mkdir ../modules
mv modules/summator ../modules

然後通過提供一個 custom_moduels 建置選項來將自定模組與引擎一起編譯。這個建置選項允許傳入一組以逗號分隔的列表,其中為包含自定 C++ 模組的路徑,用法如下:

scons custom_modules=../modules

建置系統應該會自動偵測 ../modules 資料夾下的所有模組然後進行編譯。其中,包含了我們的「summator」模組。

警告

所有傳到 custom_modules 的路徑都會被自動轉換為絕對路徑,以區分自定與內建模組。這表示,如產生模組說明文件等行為可能需要以來建置用機器上特定的路徑結構。

自訂展現形式

模組可以在運作時與其他內建引擎類別互動,甚至影響核心型別的初始化方式。到目前為止,我們一直在使用“register_summator_types”作為引入模組類別以在引擎中可用的方法。

引擎設定的粗略順序可以概括為以下型別註冊方法的列表:

preregister_module_types();
preregister_server_types();
register_core_singletons();
register_server_types();
register_scene_types();
EditorNode::register_editor_types();
register_platform_apis();
register_module_types();
initialize_physics();
initialize_navigation_server();
register_server_singletons();
register_driver_types();
ScriptServer::init_languages();

我們的“Summator”類別在“register_module_types()”呼叫期間初始化。想像一下,我們需要滿足一些常見的模組運作時依賴性(例如單例),或者允許我們覆蓋現有的引擎方法回調,然後才能由引擎本身分配它們。在這種情況下,我們希望確保我們的模組類別在任何其他內建型別*之前*註冊。

這是我們可以定義一個可選的“preregister_summator_types()”方法的地方,該方法將在“preregister_module_types()”引擎設定階段期間在其他任何事情之前被呼叫。

我們現在需要將此方法新增到「register_types」頭檔和來源檔案:

/* register_types.h */

#define MODULE_SUMMATOR_HAS_PREREGISTER
void preregister_summator_types();

void register_summator_types();
void unregister_summator_types();

備註

與其他註冊方法不同,我們必須明確定義“MODULE_SUMMATOR_HAS_PREREGISTER”,以使建置系統知道在編譯時要包含哪些相關方法呼叫。模組的名稱也必須轉換為大寫。

/* register_types.cpp */

#include "register_types.h"

#include "core/object/class_db.h"
#include "summator.h"

void preregister_summator_types() {
    // Called before any other core types are registered.
    // Nothing to do here in this example.
}

void register_summator_types() {
    ClassDB::register_class<Summator>();
}

void unregister_summator_types() {
   // Nothing to do here in this example.
}

為開發環境改進建置系統

警告

如果想要在不對引擎進行重新編譯的情況下,將模組分發給其他使用者,請使用 GDNative。此處的共用庫支援並不是為此設計的。

到目前為止,我們建立了一個用來將新模組的原始碼加到 Godot 二進位檔中的一個簡易的 SCsub 檔案。

當我們只是要建置遊戲的釋出版本時,由於我們想將所有模組都放在單一二進位檔中,這種靜態的方法沒什麼問題。

但是,這種做法的代價就是每次改動時都需要重新編譯整個遊戲。就算 SCons 有辦法偵測並只重新編譯有改動的部分,要找出這些檔案並將其連結到最終的二進位檔是一段耗時且消耗資源的過程。

要避免消耗這些資源的方法就是將我們的模組建置為會在開啟遊戲二進位檔時動態載入的共用函式庫。

# SCsub

Import('env')

sources = [
    "register_types.cpp",
    "summator.cpp"
]

# First, create a custom env for the shared library.
module_env = env.Clone()

# Position-independent code is required for a shared library.
module_env.Append(CCFLAGS=['-fPIC'])

# Don't inject Godot's dependencies into our shared library.
module_env['LIBS'] = []

# Define the shared library. By default, it would be built in the module's
# folder, however it's better to output it into `bin` next to the
# Godot binary.
shared_lib = module_env.SharedLibrary(target='#bin/summator', source=sources)

# Finally, notify the main build environment it now has our shared library
# as a new dependency.

# LIBPATH and LIBS need to be set on the real "env" (not the clone)
# to link the specified libraries to the Godot executable.

env.Append(LIBPATH=['#bin'])

# SCons wants the name of the library with it custom suffixes
# (e.g. ".linuxbsd.tools.64") but without the final ".so".
shared_lib_shim = shared_lib[0].name.rsplit('.', 1)[0]
env.Append(LIBS=[shared_lib_shim])

編譯後,就會出現同時包含了 godot*libsummator*.sobin 。但,由於 .so 並不在標準資料夾內 (如 /usr/lib) 中,因此我們必須要通過 LD_LIBRARY_PATH 環境變數來讓我們的二進位檔能在執行時期找到這些函式庫:

export LD_LIBRARY_PATH="$PWD/bin/"
./bin/godot*

備註

必須要 export 環境變數,否則將無法從編輯器來執行專案。

此外,也可以能夠選擇是要將我們的模組作為共用函式庫 (用於開發) 還是作為 Godot 二進位檔的一部分 (用於釋出) 也不錯。要這麼做,我們必須要通過 ARGUMENT 指令定義一個會傳給 SCons 的自定旗標:

# SCsub

Import('env')

sources = [
    "register_types.cpp",
    "summator.cpp"
]

module_env = env.Clone()
module_env.Append(CCFLAGS=['-O2'])

if ARGUMENTS.get('summator_shared', 'no') == 'yes':
    # Shared lib compilation
    module_env.Append(CCFLAGS=['-fPIC'])
    module_env['LIBS'] = []
    shared_lib = module_env.SharedLibrary(target='#bin/summator', source=sources)
    shared_lib_shim = shared_lib[0].name.rsplit('.', 1)[0]
    env.Append(LIBS=[shared_lib_shim])
    env.Append(LIBPATH=['#bin'])
else:
    # Static compilation
    module_env.add_source_files(env.modules_sources, sources)

現在,預設的 scons 指令會將我們的模組作為 Godot 二進位檔的一部分來編譯,而傳入 summator_shared=yes 時則會作為共用函式庫編譯。

最後,我們還可以明確將共享函式庫指定為 SCons 指令的建構目標來進一步加速建構過程:

scons summator_shared=yes platform=linuxbsd bin/libsummator.linuxbsd.tools.64.so

撰寫自定說明文件

撰寫說明文件看起來可能是一件無聊的工作,但我們非常建議為新建立的模組撰寫說明文件,這樣一來可以讓其他使用者更輕鬆地從模組中獲益。更不用提你自己一年前寫的程式碼看起來可能跟別人寫的程式差不多,因此也請善待未來的自己吧!

要為模組設定自定說明文件有幾個步驟:

  1. 在模組的根目錄建立新資料夾。該資料夾可任意明明,但我們會在本段落中使用 doc_classes 這個名稱。

  2. 接著,我們需要編輯 config.py ,加入下列程式碼片段:

    def get_doc_path():
        return "doc_classes"
    
    def get_doc_classes():
        return [
            "Summator",
        ]
    

get_doc_path() 函式是建置系統所使用的,用於判斷說明文件的位置。在這個例子中,說明文件會放在 modules/summator/doc_classes 資料夾中。若沒有定義這個函式,則模組說明文件的路徑會被遞補為主要的 doc/classes 資料夾。

必須要有 get_doc_classes() 方法,這樣一來建置系統才知道哪些已註冊的類別屬於這個模組。在此處必須要列出所有的類別。沒有在此處定義的類別會遞補到主要的 doc/classes 資料夾中。

小訣竅

可以使用 git status 來通過 Git 檢查未簽出與未追蹤的檔案,來確認是否有遺漏的類別。舉例來說:

user@host:~/godot$ git status

範例輸出:

Untracked files:
    (use "git add <file>..." to include in what will be committed)

    doc/classes/MyClass2D.xml
    doc/classes/MyClass4D.xml
    doc/classes/MyClass5D.xml
    doc/classes/MyClass6D.xml
    ...
  1. 接著,我們可以產生說明文件:

可以通過執行 Godot 的 doctool 來產生,即 godot --doctool <路徑> ,該指令會以 XML 格式傾印引擎的 API 參照文件到指定的 <路徑> 內。

在這個例子中,我們會將文件輸出至 Clone 的儲存庫根目錄內。在實際使用時可以指定其他資料夾,然後依據需求複製這些檔案到所需的地方。

執行指令:

user@host:~/godot$ ./bin/<godot_binary> --doctool .

接著若開啟 godot/modules/summator/doc_classes 資料夾,就可以看到裡面有 Summator.xml 檔案,或在 get_doc_classes 函式中參照的其他類別。

依據 參與貢獻類別參照文件 中的說明編輯這些檔案,然後重新編譯引擎。

完成編譯後,就可以從引擎內建的說明文件系統中存取這些文件。

之後若要維持這些文件為最新版本,只需要修改其中的 XML 檔,然後重新編譯引擎。

當修改了模組 API 時,則也需要重新截取說明文件。截取出來的說明文件會包含之前新增過的內容。當然,若將說明文件指向到 Godot 資料夾時,請確保不要從舊的引擎建置截取到新的引擎建置上,以免遺失改動。

如果沒有所提供 <路徑> 的寫入權限,則可能會遇到類似下列錯誤:

ERROR: Can't write doc file: docs/doc/classes/@GDScript.xml
   At: editor/doc/doc_data.cpp:956

撰寫自定說明文件

可以將獨立的單元測試編寫為 C++ 模組的一部分。如果您還不熟悉 Godot 中的單元測試流程,請參閱 doc_unit_testing。

有下列事項需注意:

  1. 在模組的根目錄下建立一個名為「tests/」的新目錄:

cd modules/summator
mkdir tests
cd tests
  1. 建立一個新的測試套件:「test_summator.h」。標頭必須以“test_”為前綴,以便建置系統可以收集它並將其包含在執行測試的“tests/test_main.cpp”中。

  2. 編寫一些測試案例。這是一個例子:

// test_summator.h
#ifndef TEST_SUMMATOR_H
#define TEST_SUMMATOR_H

#include "tests/test_macros.h"

#include "modules/summator/summator.h"

namespace TestSummator {

TEST_CASE("[Modules][Summator] Adding numbers") {
    Ref<Summator> s = memnew(Summator);
    CHECK(s->get_total() == 0);

    s->add(10);
    CHECK(s->get_total() == 10);

    s->add(20);
    CHECK(s->get_total() == 30);

    s->add(30);
    CHECK(s->get_total() == 60);

    s->reset();
    CHECK(s->get_total() == 0);
}

} // namespace TestSummator

#endif // TEST_SUMMATOR_H
  1. 使用“sconstests=yes”編譯引擎,並使用以下命令執行測試:

./bin/<godot_binary> --test --source-file="*test_summator*" --success

現在你就應該會看到出現了群組。

新增自定編輯器圖示

與在模組中撰寫自封式說明文件類似,也可以為類別建立會出現在編輯器中的自定圖示。

有關建立整合進引擎之編輯器圖示的實際過程,請先參考 編輯器圖示

建立好圖示後,請執行下列步驟:

  1. 在模組的根目錄建立一個名為 icons 的資料夾。該資料夾是引擎尋找模組編輯器圖示的預設路徑。

  2. 將新建立的 svg 圖示 (無論是否經過最佳化) 移至該資料夾內。

  3. 重新編譯引擎並執行編輯器。接著,該圖示會顯示在編輯器界面中適當的位置。

如果想將圖示儲存在模組中其他的位置,請將下列程式碼片段新增至 config.py 以複寫預設路徑:

def get_icons_path():
    return "path/to/icons"

總結

請記住:

  • 使用 GDCLASS 來處理繼承,這樣 Godot 才能對其進行封裝

  • 使用 _bind_methods 來將函式繫結至腳本,這樣才能讓這些函式擁有回呼與訊號的功能。

  • 避免暴露給 Godot 的類別的多重繼承,因為「GDCLASS」不支援這一點。您仍然可以在自己的類別中使用多重繼承,只要它們不暴露於 Godot 的腳本 API。

但依據使用情況,還有其他要注意的點。在實際製作模組的過程還會遇到許多驚喜 (希望是正面的驚喜)。

  • 若從 Node (以及其他衍生 Node 型別,如 Sprite) 繼承,則新類別會出現在編輯器的「新增節點」對話框中的繼承樹中。

  • 若從 Resource 繼承,則類別會出現在資源列表中,且所有暴露的屬性都能在保存與載入時被序列化。

  • 通過相同的方法,也可以擴充編輯器以及引擎幾乎所有的部分。