📎

[Godot] DSL 用のプラグインを作る過程で学んだこと

に公開

DSL を書きたかったので、Ruby (mruby) が動くアドオンを作りました。
https://zenn.dev/tkmfujise/articles/2e8685d958d371

gdextension を作成するのもプラグインを作成するのも初めてでしたが、その際に得た知見をまとめます。
以下、学んだことです。

    1. gdextension に関して
    • 1-1. gdextension の概要
    • 1-2. ビルドに必要なライブラリ
    • 1-3. godotengine/godot-cpp-template から作成する
    • 1-4. ドキュメントの生成
    • 1-5. macOS の場合は universal バイナリを作成する
    • 1-6. macOS の場合はバイナリに署名と公証をする
    1. EditorPlugin に関して
    • 2-1. プラグインの作成方法
    • 2-2. メインスクリーンへの表示方法
    • 2-3. ファイルシステムドックに表示できる拡張子の追加

1. gdextension に関して

1-1. gdextension の概要

gdextension は、自作のノードやリソースを共有ライブラリ(ダイナミックリンクライブラリ)として作成して利用できる仕組みです。C++ で作成します。
パフォーマンスが欲しい箇所で利用したり、C や C++ で書かれた他のライブラリを Godot で利用するために使用します。

公式のチュートリアルが簡単でわかりやすかったので、一度やってみることをおすすめします。
https://docs.godotengine.org/ja/4.x/tutorials/scripting/gdextension/gdextension_cpp_example.html

他言語バインディングでも gdextension を使います。
今回 mruby を組み込むにあたって他の言語のバインディングにどんなものがあるか調べていたのですが、JavascriptScheme があることを初めて知りました。
それぞれ、QuickJSs7 という 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 プロジェクトを自前で作成しないといけないですが、それらのひな形が予め用意されているリポジトリがあります。

https://github.com/godotengine/godot-cpp-template
「Use this template」から利用できます。

今回のプラグイン作成ではここからフォークする形で進めていきました。(その際のメモ)

mruby を使用するため、SConstruct には以下を追記しました。

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 が生成されるのでこれを修正します。
https://github.com/tkmfujise/ReDScribe/blob/main/doc_classes/ReDScribe.xml

自分はコマンドは覚えたくなかったのと、scons に親しくなかったので rake で実行するようにしました。

Rakefile
desc 'Generate doc_classes/*'
task :doc do
  cd 'demo' do
    sh 'godot --doctool ../ --gdextension-docs'
  end
end

https://github.com/tkmfujise/ReDScribe/blob/main/Rakefile

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 コマンドにしました。

Rakefile
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
<プラグインの名前>.gd
@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.cfg
[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 で 表示用のノードを追加する

ReDScribe

以下コードの全体です。

addons/redscribe.gd
@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 で作成しました。

redscribe_entry.h
#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 で、リソースクラスのインスタンスを返すようにする
addons/redscribe/ext/redscribe_entry_loader.gd
@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 が上記で定義したリソースクラスならメインスクリーンに表示する処理を行なう
addons/redscribe.gd
@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. ファイルシステムで右クリックで作成できるようにする

以上でだいたいできましたが、
さらに、ファイルシステムで右クリックのメニュー [新規作成] から作成できるようにするには以下を行ないます。

    1. EditorContextMenuPlugin を継承したクラスを作成
    • add_context_menu_item でメニューに追加する名前と処理、アイコンを設定する
    1. <プラグインの名前>.gd にファイルシステム用の処理を追加
    • add_context_menu_plugin で、1 のクラスを登録

addons/redscribe/ext/context_menu/file_system.gd
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()
addons/redscribe.gd
@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