[Godot] DSL 用のプラグインを作る過程で学んだこと
DSL を書きたかったので、Ruby (mruby) が動くアドオンを作りました。
gdextension を作成するのもプラグインを作成するのも初めてでしたが、その際に得た知見をまとめます。
以下、学んだことです。
- 
- gdextension に関して
 - 1-1. gdextension の概要
- 1-2. ビルドに必要なライブラリ
- 1-3. godotengine/godot-cpp-template から作成する
- 1-4. ドキュメントの生成
- 1-5. macOS の場合は universal バイナリを作成する
- 1-6. macOS の場合はバイナリに署名と公証をする
 
- 
- EditorPlugin に関して
 - 2-1. プラグインの作成方法
- 2-2. メインスクリーンへの表示方法
- 2-3. ファイルシステムドックに表示できる拡張子の追加
 
1. gdextension に関して
1-1. gdextension の概要
gdextension は、自作のノードやリソースを共有ライブラリ(ダイナミックリンクライブラリ)として作成して利用できる仕組みです。C++ で作成します。
パフォーマンスが欲しい箇所で利用したり、C や C++ で書かれた他のライブラリを Godot で利用するために使用します。
公式のチュートリアルが簡単でわかりやすかったので、一度やってみることをおすすめします。
他言語バインディングでも gdextension を使います。
今回 mruby を組み込むにあたって他の言語のバインディングにどんなものがあるか調べていたのですが、Javascript や Scheme があることを初めて知りました。
それぞれ、QuickJS と s7 という C で書かれた組み込み用の軽量の処理系を使用しており、おそらく Ruby に対する mruby のような位置づけだと思います。
1-2. ビルドに必要なライブラリ
ビルドには以下が必要です。
- scons
- C++コンパイラ
Windows でビルドするには、「x64 Native Tools Command Prompt for VS 2022」からターミナルを起動する必要がありました。
各プラットフォーム向けにビルドする必要がありますが、クロスコンパイルはまだやったことがないのでここでは説明しません。
1-3. godotengine/godot-cpp-template から作成する
上述の公式チュートリアルでは、godot-cpp リポジトリの追加と SConstruct ファイルや demo プロジェクトを自前で作成しないといけないですが、それらのひな形が予め用意されているリポジトリがあります。
「Use this template」から利用できます。
今回のプラグイン作成ではここからフォークする形で進めていきました。(その際のメモ)
mruby を使用するため、SConstruct には以下を追記しました。
mruby_include_path = "mruby/build/host/include"
mruby_library_path = "mruby/build/host/lib"
# mruby
env.Append(CPPPATH=[mruby_include_path])
env.Append(LIBPATH=[mruby_library_path])
env.Append(LIBS=["libmruby"])
# for Windows
if (os.name != 'posix'):
    env.Append(LIBS=["Ws2_32"])
1-4. ドキュメントの生成
$ scons
でビルドに成功すると、demo プロジェクトで使用できるようになっていますが、Godot のヘルプ用のドキュメントを生成するには以下コマンドを実行します。
$ cd demo
$ godot --doctool ../ --gdextension-docs
そうすると、doc_classes ディレクトリにヘルプに表示する内容を書くための XML が生成されるのでこれを修正します。
自分はコマンドは覚えたくなかったのと、scons に親しくなかったので rake で実行するようにしました。
desc 'Generate doc_classes/*'
task :doc do
  cd 'demo' do
    sh 'godot --doctool ../ --gdextension-docs'
  end
end
1-5. macOS の場合は universal バイナリを作成する
mruby をビルドすると、Windows なら libmruby.lib ファイルが、macOS なら libmruby.a ファイルが生成されるのですが、Godot で macOS の gdextension を作成する場合は intel チップ用と arm チップ用でそれぞれビルドしてそれを1つ(universal バイナリ)にする必要がありました。
以下のコマンドで universal バイナリにします。
$ lipo -create -output libmruby.a libmruby_arm64.a libmruby_x86_64.a
例によって覚えられないので rake コマンドにしました。
task :mruby_build do
  def build_config(name = nil)
    if name
      ENV['MRUBY_CONFIG'] = "../../build_config/#{name}"
    else
      ENV['MRUBY_CONFIG'] = nil
    end
    sh 'rake'
  end
  cd 'mruby' do
    case RbConfig::CONFIG['host_os']
    when /mswin|mingw|cygwin/
      build_config 'windows'
    when /darwin/
      libmruby_path = 'build/host/lib/libmruby.a'
      next if File.exist? libmruby_path
      arm64_file = Tempfile.new('libmruby_arm64')
      x86_file   = Tempfile.new('libmruby_x86')
      build_config 'macos_arm64'
      mv libmruby_path, arm64_file.path
      sh 'rake clean'
      build_config 'macos_x86'
      mv libmruby_path, x86_file.path
      sh "lipo -create -output #{libmruby_path} #{arm64_file.path} #{x86_file.path}"
    end
  end
end
1-6. macOS の場合はバイナリに署名と公証をする
macOS 向けのバイナリを他人が使えるようにするには、Apple Developer Program に加入して、そのバイナリに署名と公証をする必要があります。
$ codesign で署名、$ xcrun notarytool submit で公証をするのですが、公証に2日かかったり、初めてなこともありよくわかっていないので割愛します。debug ビルドの場合は署名のみでいいらしいですが、よくわかっていないです。
そもそも godot-cpp-template プロジェクトには Github Actions を使う仕組みがあるので、それを使うのがスマートだと思います。
2. EditorPlugin に関して
2-1. プラグインの作成方法
[プロジェクト設定]→[プラグイン]→[新しいプラグインを作成] からプラグインを作成できます。

項目を入力して [作成] ボタンを押下すると、以下が作成されます。
addons/
└── <プラグインの名前>
    ├── <プラグインの名前>.gd
    └── plugin.cfg
@tool
extends EditorPlugin
func _enter_tree() -> void:
    # Initialization of the plugin goes here.
    pass
func _exit_tree() -> void:
    # Clean-up of the plugin goes here.
    pass
[plugin]
name="<プラグインの名前>"
description=""
author=""
version=""
script="<プラグインの名前>.gd"
<プラグインの名前>.gd ファイルを編集してプラグインの処理を書きます。
2-2. メインスクリーンへの表示方法
以下、ReDScribe というのは今回作成したプラグイン名です。
上述の <プラグインの名前>.gd ファイルで以下を行なうとメインスクリーンに表示するプラグインになります。
- 
_has_main_screen()でtrueを返すようにする
- 
_get_plugin_name()で プラグイン名 を返すようにする
- 
_get_plugin_icon()で プラグイン用のアイコン を返すようにする
- 
EditorInterface.get_editor_main_screen().add_childで 表示用のノードを追加する

以下コードの全体です。
@tool
extends EditorPlugin
const Main = preload("res://addons/redscribe/src/main/main.tscn")
var main : Control
func _enter_tree() -> void:
    main = Main.instantiate()
    EditorInterface.get_editor_main_screen().add_child(main)
    _make_visible(false)
func _exit_tree() -> void:
    if main: main.queue_free()
func _has_main_screen() -> bool:
    return true
func _make_visible(visible: bool) -> void:
    if main: main.visible = visible
func _get_plugin_name() -> String:
    return "ReDScribe"
func _get_plugin_icon() -> Texture2D:
    return preload("res://addons/redscribe/assets/icons/editor_icon.svg")
2-3. ファイルシステムドックに表示できる拡張子の追加
Godot では扱えない拡張子のファイルは、ファイルシステムでは通常見れないのですが、それを追加するようにします。(今回は rb ファイル)
以下のような見た目です。

2-3-1. 拡張子用のリソースクラスを定義
まず、その拡張子用のリソースクラスを定義します。
class_name ReDScribeEntry
extends Resource
で、うまくいくはずなんですが、
なんでか自分の環境では初回起動でうまく扱えない問題があり、、
これも gdextension で作成しました。
#ifndef REDSCRIBE_ENTRY_H
#define REDSCRIBE_ENTRY_H
#include <godot_cpp/classes/resource.hpp>
namespace godot {
class ReDScribeEntry : public Resource {
  GDCLASS(ReDScribeEntry, Resource)
protected:
  static void _bind_methods();
public:
  ReDScribeEntry();
  ~ReDScribeEntry();
};
}
#endif
2-3-2. 拡張子用のリソースローダーを定義
次に ResourceFormatLoader クラスを継承したクラスを定義します。
- 
_get_recognized_extensionsで、扱う拡張子の配列を返すようにする
- 
_get_resource_typeで、拡張子に対して、それに対するリソースクラスを返すようにする
- 
_handles_typeで、typenameが扱うリソースクラスならばtrueを返すようにする
- 
_loadで、リソースクラスのインスタンスを返すようにする
@tool
extends ResourceFormatLoader
class_name ReDScribeEntryLoader
var extension = 'rb'
func _get_recognized_extensions() -> PackedStringArray:
    return PackedStringArray([extension])
func _get_resource_type(path: String) -> String:
    var ext = path.get_extension().to_lower()
    if ext == extension:
        return "ReDScribeEntry"
    return ""
func _handles_type(typename: StringName) -> bool:
    return typename == &"ReDScribeEntry"
func _load(path: String, original_path: String, use_sub_threads: bool, cache_mode: int) -> Variant:
    var res = ReDScribeEntry.new()
    return res
以上で、ファイルシステム上で見えるようになります。
2-3-3. ファイルシステムで選択するとメインスクリーンで表示するようにする
最後に、ファイルシステムで選択した際にメインスクリーンに表示するようにします。
- 
_handles(object)で、objectが上記で定義したリソースクラスならtrueを返すようにする
- 
_edit(object)で、objectが上記で定義したリソースクラスならメインスクリーンに表示する処理を行なう
@tool
extends EditorPlugin
const Main = preload("res://addons/redscribe/src/main/main.tscn")
var main : Control
func _enter_tree() -> void:
    main = Main.instantiate()
    EditorInterface.get_editor_main_screen().add_child(main)
    _make_visible(false)
func _exit_tree() -> void:
    if main: main.queue_free()
func _has_main_screen() -> bool:
    return true
# このプラグインで扱うファイルか?
func _handles(object: Object) -> bool:
    return object is ReDScribeEntry
# ファイルシステムで選択された場合の処理
func _edit(object: Object) -> void:
    if object is ReDScribeEntry:
        main.load_file(object.resource_path)
func _make_visible(visible: bool) -> void:
    if main: main.visible = visible
func _get_plugin_name() -> String:
    return "ReDScribe"
func _get_plugin_icon() -> Texture2D:
    return preload("res://addons/redscribe/assets/icons/editor_icon.svg")
2-3-4. ファイルシステムで右クリックで作成できるようにする
以上でだいたいできましたが、
さらに、ファイルシステムで右クリックのメニュー [新規作成] から作成できるようにするには以下を行ないます。
- 
- EditorContextMenuPlugin を継承したクラスを作成
 - 
add_context_menu_itemでメニューに追加する名前と処理、アイコンを設定する
 
- 
- <プラグインの名前>.gd にファイルシステム用の処理を追加
 - 
add_context_menu_pluginで、1 のクラスを登録
 

extends EditorContextMenuPlugin
const rb_icon = preload("res://addons/redscribe/assets/icons/editor_icon_gray.svg")
const gd_icon = preload("res://addons/redscribe/assets/icons/editor_icon_outline.svg")
const Dialog = preload("res://addons/redscribe/ext/context_menu/dialog.tscn")
func _popup_menu(paths: PackedStringArray) -> void:
    if paths.size() == 1:
        add_context_menu_item("DSL/boot *.rb", create_rb_file, rb_icon)
        add_context_menu_item("ReDScribe *.gd", create_gd_file, gd_icon)
func create_gd_file(paths: PackedStringArray) -> void:
    if paths.size() != 1: return
    create_file(paths[0], 'gd')
func create_rb_file(paths: PackedStringArray) -> void:
    if paths.size() != 1: return
    create_file(paths[0], 'rb')
func create_file(path: String, template: String) -> void:
    var dialog = Dialog.instantiate()
    dialog.setup(path, template)
    EditorInterface.get_base_control().add_child(dialog)
    dialog.show()
@tool
extends EditorPlugin
const Main = preload("res://addons/redscribe/src/main/main.tscn")
const ContextMenuFileSystem = preload("res://addons/redscribe/ext/context_menu/file_system.gd")
var main : Control
var context_menu_filesystem : ContextMenuFileSystem
func _enter_tree() -> void:
    main = Main.instantiate()
    EditorInterface.get_editor_main_screen().add_child(main)
    _add_actions() # 追記
    _make_visible(false)
func _exit_tree() -> void:
    if main: main.queue_free()
    _remove_actions() # 追記
func _has_main_screen() -> bool:
    return true
func _handles(object: Object) -> bool:
    return object is ReDScribeEntry
func _edit(object: Object) -> void:
    if object is ReDScribeEntry:
        main.load_file(object.resource_path)
func _make_visible(visible: bool) -> void:
    if main: main.visible = visible
func _get_plugin_name() -> String:
    return "ReDScribe"
func _get_plugin_icon() -> Texture2D:
    return preload("res://addons/redscribe/assets/icons/editor_icon.svg")
# ファイルシステムにアクションを追加
func _add_actions() -> void:
    context_menu_filesystem = ContextMenuFileSystem.new()
    add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_FILESYSTEM_CREATE, context_menu_filesystem)
# ファイルシステムに追加したアクションの削除
func _remove_actions() -> void:
    remove_context_menu_plugin(context_menu_filesystem)
長くなりましたが、以上です。
まだ他にも何か学んだことがあったと思いますが、思い出したら追記していきます。では。

Discussion