☃️

ゲームエンジンGodotでテキストエディタを作る

2023/07/17に公開

Godot について

Godot はMITライセンスのゲームエンジンですが、Godotエディタ自体がGodotエンジンで動作していることもあり、ゲーム以外に使用できそうなGUI関連のクラスが割りと充実している印象があります。

https://www.youtube.com/watch?v=9kKp0oguzr8

紹介動画の一番最初に流れたエディタを見て、アニメーションをつけたりかわいくできるのはおもしろそうだなと思ったのでエディタを作ろうと思います。ただ今回は簡単なエディタを作ります。(※Godot初心者なため凝ったことはできないので)
使用するバージョンは Godot 4.1 です。

幸いGodotには、CodeEdit というそのものズバリのクラスがありました。
https://docs.godotengine.org/en/stable/classes/class_codeedit.html

アドオンなどではなく標準機能として公式がメンテしているものを使っていろいろ作れそうなのはいいですね。

参考

下記の動画は Godot 3 で作成されていますが、エディタを作る参考になりました。
https://www.youtube.com/watch?v=nk0YQGb08IA&list=PLQsiR7DILTczMLsN8qmMym7pYfJXynzK0&index=2

1. CodeEdit を配置

まずはルートシーンに Main(Controlクラス)、その子ノードに CodeArea(CodeEditクラス)を作成します。それぞれアンカーを"Rect全面"に設定して画面全体まで広げます。
そのまま実行します。


CodeEditクラスをControlクラスの子ノードとして作成

エディタが起動しました。簡単ですね。

hiDPIをオフにする

ただ文字が小さいです。Font Sizeを40pxぐらいにしたらいい感じの大きさにはなりますが、普段使っているエディタでそんなサイズを指定したことはないのでなにかおかしいです。
ググってもなかなかわからなかったですが、解像度の問題なのでプロジェクト設定の「hiDPIを許可」のチェックをOFFにします。これでFont Sizeが16pxぐらいで読める大きさになります。

「hiDPIを許可」のチェックをOFFにする

2. "File"メニューの追加

画面上部に"File"メニューを表示するようにします。

Mainの子ノードに TopBar(MenuBarクラス)を追加。その子ノードに FileMenu(MenuButtonクラス)を追加します。
TopBarのアンカーを"上伸長"にして画面上部に表示し、FileMenuの「Text」に"File"を入れてボタン名として"File"が表示されるようにします。

マージンの追加

そのまま実行すると CodeArea の上に TopBar が被さって表示されるため、CodeArea の上部にマージンを追加します。
ここで地味にハマったのが、「インスペクター」で margin やら offset やらのキーワードでプロパティを絞ってもそれらしいのは見つかりません。
CodeAreaのアンカーに"Rect全面"を指定しているのでそれを "カスタム" に変更する必要があります。(anchor で検索すると見つかります)
"カスタム" に設定するとオフセットに関するプロパティが見えるようになるので、「Anchor Offsets」の「Top」に "30px" ぐらいを設定します。これでマージンは追加できました。

アンカーを"カスタム"にする

メニューアイテムの追加

FileMenuの「Items」に5つ、それぞれ「Text」を

  • "New File"
  • "Open File"
  • "Save"
  • "Save As File"
  • "Quit"

として追加します。実行すると "File"メニュー にプルダウンが表示されるようになりました。


"File"メニューのプルダウン

スクリプトの追加

Mainにスクリプトを追加して、"File"メニューのプルダウンが押されたときの処理を書きます。

プルダウンそれぞれが押されたタイミングで、ファイルを開いたり保存したりの処理を行えるよう、シグナルを発行したいですが FileMenu(MenuButtonクラス)の「ノード」パネルからは設定できません。
プルダウンそれぞれが押されたことを知るには、MenuButtonクラスのメソッド get_popup() で取得できる PopupMenuクラスからシグナルを発行しないといけません。めんどくさいですね。

Godot画面からは設定できないので _ready() の処理内で紐づけます。id_pressed がプルダウンそれぞれが押されたときのシグナルなので下記のように書きます。

Main.gd
extends Control

enum FileMenuId { NEW, OPEN, SAVE, SAVE_AS, QUIT }

func _ready() -> void:
    $TopBar/FileMenu.get_popup().id_pressed.connect(_on_file_menu_selected)


func _on_file_menu_selected(id: int) -> void:
    match id:
        FileMenuId.NEW:
            print("File New")
        FileMenuId.OPEN:
            print("File Open")
        FileMenuId.SAVE:
            print("File Save")
        FileMenuId.SAVE_AS:
            print("File Save as")
        FileMenuId.QUIT:
            get_tree().quit()

それぞれ押して「出力」パネルに文字が表示されることを確認できました。

3. ファイルダイアログの追加

"File"メニューのプルダウンそれぞれが押された時に表示するファイルダイアログを追加します。

Mainの子ノードに OpenFileDiaglog(FileDiaglogクラス)と SaveFileDiaglog(FileDiaglogクラス)を追加します。
OpenFileDiaglog の「File Mode」を "Open File"、「Access」を "File System" に設定、SaveFileDiaglog の「Access」を "File System" に設定します。


FileDiaglogクラスを追加し、「File Mode」と「Access」を変更する

デフォルトは非表示ですが、とりあえずシーンドックで目のアイコンをクリックすれば見た目は確認できます。
そのままでは小さいので、それぞれ「Size」を 600x480px ぐらいに設定して、「Initial Position」も "Center of Main Window Screen" にして画面中央に表示されるようにします。

シグナルの設定

「ノード」パネルから、OpenFileDialog と SaveFileDialog の file_selected をそれぞれ、_on_open_file_dialog_file_selected_on_save_file_dialog_file_selected として紐づけます。

Main.gd
func _on_file_menu_selected(id: int) -> void:
    match id:
        FileMenuId.NEW:
            print("File New")
        FileMenuId.OPEN:
            $OpenFileDialog.popup()
        FileMenuId.SAVE:
            print("File Save")
        FileMenuId.SAVE_AS:
            $SaveFileDialog.popup()
        FileMenuId.QUIT:
            get_tree().quit()


func _on_open_file_dialog_file_selected(path: String) -> void:
    var f = FileAccess.open(path, FileAccess.READ)
    $CodeArea.text = f.get_as_text()
    f.close()


func _on_save_file_dialog_file_selected(path: String) -> void:
    var f = FileAccess.open(path, FileAccess.WRITE)
    f.store_string($CodeArea.text)
    f.close()

ファイルを開いて保存できるようになりました。これで、エディタとしての最低限の機能はできました。

4. "File"メニューの完成

残りの "New File" と "Save" にも対応します。current_file 変数に現在開いているファイルのパスを記憶してファイルダイアログを表示せずに保存できるようにします。

また、ウィンドウのタイトルにファイル名を表示するため update_window_title() メソッドを用意し、さらに、ファイルの変更をしたときに米印"*"を表示するため CodeArea(CodeEditクラス)の text_changed シグナルを Main に紐づけて、file_dirty 変数でファイルの変更有無を管理します。

それらを踏まえると下記のようになりました。

Main.gd
extends Control

enum FileMenuId { NEW, OPEN, SAVE, SAVE_AS, QUIT }

const NEW_FILE_PLACEHOLDER = "Untitled"

var current_file : String
var file_dirty = false

func _ready() -> void:
    $TopBar/FileMenu.get_popup().id_pressed.connect(_on_file_menu_selected)
    $CodeArea.grab_focus() # ← 起動したときにエディタに自動フォーカス
    update_window_title()


func update_window_title():
    var title = \
        ProjectSettings.get_setting("application/config/name") \
        + ' - ' + current_file_name()
    if file_dirty: title += " *"
    get_window().title = title


func current_file_name() -> String:
    if current_file:
        return current_file.get_file()
    else:
        return NEW_FILE_PLACEHOLDER


func new_file():
    current_file   = ''
    $CodeArea.text = ''
    file_dirty = false
    update_window_title()


func save_file():
    if current_file:
        var path = current_file
        var f = FileAccess.open(path, FileAccess.WRITE)
        f.store_string($CodeArea.text)
        f.close()
        current_file = path
        file_dirty = false
        update_window_title()
    else:
        $SaveFileDialog.popup()


func _on_file_menu_selected(id: int) -> void:
    match id:
        FileMenuId.NEW:
            new_file()
        FileMenuId.OPEN:
            $OpenFileDialog.popup()
        FileMenuId.SAVE:
            save_file()
        FileMenuId.SAVE_AS:
            $SaveFileDialog.popup()
        FileMenuId.QUIT:
            get_tree().quit()


func _on_open_file_dialog_file_selected(path: String) -> void:
    var f = FileAccess.open(path, FileAccess.READ)
    $CodeArea.text = f.get_as_text()
    f.close()
    current_file = path
    file_dirty = false
    update_window_title()


func _on_save_file_dialog_file_selected(path: String) -> void:
    var f = FileAccess.open(path, FileAccess.WRITE)
    f.store_string($CodeArea.text)
    f.close()
    current_file = path
    file_dirty = false
    update_window_title()


func _on_code_area_text_changed() -> void:
    if file_dirty: return
    file_dirty = true
    update_window_title()

ショートカットキーの追加

上記で一通りできましたが、最後にそれぞれにショートカットキーを割り当てます。
プロジェクト設定のインプットマップを下記のように設定します。


インプットマップ

Main に _input メソッドを追加して、イベントをフックするようにします。

Main.gd
func _input(event):
    if event.is_action_pressed("file_new"):
        new_file()
    elif event.is_action_pressed("file_open"):
        $OpenFileDialog.popup()
    elif event.is_action_pressed("file_save_as"):
        $SaveFileDialog.popup()
    elif event.is_action_pressed("file_save"):
        save_file()

これでショートカットキーで動作するようになりました。

5. シンタックスハイライトの設定

これで一応エディタはできあがりですが、欲張ってシンタックスハイライトにも対応します。

CodeArea(CodeEditクラス)にスクリプトを追加します。

CodeEditクラスが持っている syntax_highlighter に CodeHighlighterクラスのインスタンスを突っ込んでいろいろ設定すれば色が変わるようなので試してみます。

CodeArea.gd
extends CodeEdit

func _ready() -> void:
    syntax_highlighter = CodeHighlighter.new()
    syntax_highlighter.add_keyword_color("TEST", "#FF0000")
    syntax_highlighter.add_color_region("\"", "\"", "#00FF00")
    syntax_highlighter.add_color_region("//", "", "#8888FF")
    syntax_highlighter.set_symbol_color("#FFFF88")
    syntax_highlighter.set_number_color("0088FF")
    syntax_highlighter.set_function_color("F9B7DC")
    syntax_highlighter.set_member_variable_color("88FF88")


syntax_highlighter のテスト

add_keyword_color が単語をキーに、add_color_region が開始文字と終了文字をキーにして色が変わるようです。add_color_region は終了文字に空文字を指定すると行末までが範囲になります。

数字(set_number_color)、記号(set_symbol_color)、括弧の前の文字列(set_function_color)、ドット以降の文字列(set_member_variable_color)は色は変えれますが、色を変える範囲は変更できません。

各言語のシンタックスを設定するには、add_keyword_coloradd_color_region をうまく組み合わせるしかありません。正規表現とかは無いです。

カスタムリソースの定義

言語のシンタックスとエディタのテーマをそれぞれカスタムリソースとして作成しようと思います。
下記URLが参考になりました。
https://zenn.dev/slm/articles/17889b6b3d8088

まずはリソースの定義をします。
SyntaxResource.gd ファイルと ThemeResource.gd ファイルを作成し以下のようにします。

resources/SyntaxResource.gd
extends Resource
class_name SyntaxResource

@export var regions = {
    "key": [["begin", "end"]]
}
@export var keywords = {
    "key": ["keyword"]
}
resources/ThemeResource.gd
extends Resource
class_name ThemeResource

@export var background_color = "#FFFFFF"
@export var foreground_color = "#000000"
@export var symbol_color     = "#008888"
@export var number_color     = "#0000FF"
@export var function_color   = "#FF0000"
@export var member_variable_color = "#000000"

@export var syntax_colors = {
    "boolean": "#FF8800",
    "keyword": "#FF00FF",
    "string":  "#008800",
    "comment": "#888888",
    "constant": "#FF4444",
}

仕組みとしては、SyntaxResource の regions もしくは keywords で定義した key に該当する箇所を、ThemeResource の syntax_colors のキーで指定することで色を変えようと思います。

カスタムリソースの作成

次に上記で定義したリソースの型に対して実際のリソースを作成していきます。
「ファイルシステム」ドックから右クリックで「新規作成」→「リソース」でひな形が作成できるので、それを編集します。
画面から編集してもいいですが、エディタで編集した方が楽です。幸いエディタは手元にありますね。


Ruby-syntax.tres

↑で試しに入れた色がいい感じに出ています。
編集するのは [resource] の配下です。

code-syntaxes/Ruby-syntax.tres
regions = {
    "comment": [["#", ""]],
    "constant": [[":", " "]],
    "string": [["'", "'"], ["\"", "\""]]
}
keywords = {
    "boolean": ["true", "false", "nil"],
    "keyword": [
        "class", "def", "end", "module", "require", "return", "initialize", "attr_accessor"
    ]
}
code-themes/Default-theme.tres
background_color = "#FFFFFF"
foreground_color = "#000000"
symbol_color = "#008888"
number_color = "#0000FF"
function_color = "#FF0000"
member_variable_color = "#880088"
syntax_colors = {
    "boolean": "#FF8800",
    "comment": "#888888",
    "constant": "#FF4444",
    "keyword": "#FF00FF",
    "string": "#008800"
}

テーマとシンタックスの適用

set_code_theme メソッドと set_code_syntax メソッドを作成して、先ほど作成したリソースを読み込んでみます。

CodeArea.gd
extends CodeEdit

var current_theme : ThemeResource
var current_syntax : SyntaxResource


func _ready() -> void:
    syntax_highlighter = CodeHighlighter.new()
    set_code_theme(load("res://code-themes/Default-theme.tres"))
    set_code_syntax(load("res://code-syntaxes/Ruby-syntax.tres"))


func set_code_theme(_theme: ThemeResource) -> void:
    current_theme = _theme
    add_theme_color_override("background_color", _theme.background_color)
    add_theme_color_override("font_color", _theme.foreground_color)
    syntax_highlighter.set_symbol_color(_theme.symbol_color)
    syntax_highlighter.set_number_color(_theme.number_color)
    syntax_highlighter.set_function_color(_theme.function_color)
    syntax_highlighter.set_member_variable_color(_theme.member_variable_color)


func set_code_syntax(syntax: SyntaxResource) -> void:
    current_syntax = syntax
    for key in syntax.regions:
        for arr in syntax.regions[key]:
            if current_theme.syntax_colors.has(key):
                syntax_highlighter.add_color_region(
                    arr[0], arr[1], current_theme.syntax_colors[key]
                )
    for key in syntax.keywords:
        for value in syntax.keywords[key]:
            if current_theme.syntax_colors.has(key):
                syntax_highlighter.add_keyword_color(
		    value, current_theme.syntax_colors[key]
		)


Ruby-syntax.tres と Default-theme.tres を適用

ばっちり変わりました。

6. コード補完の追加

シンタックスハイライトに対応したので今度はコード補完も欲しくなりました。

下記のように SyntaxResource に completions を追加します。

resources/SyntaxResource.gd
extends Resource
class_name SyntaxResource

@export var regions = {
    "key": [["begin", "end"]]
}
@export var keywords = {
    "key": ["keyword"]
}
@export var completions = [
    ["display_text", "insert_text"]
]

Ruby-syntax.tres にも completions を追加します。

code-syntaxes/Ruby-syntax.tres
completions = [
    ["class", "class"], ["def", "def"], ["end", "end"], ["return", "return"], ["module", "module"], 
    ["require", "require"], ["attr_accessor", "attr_accessor"]
]

で、CodeArea.gd を修正しますが、コード補完に関してはググってもなかなか情報が出てこず、公式のドキュメントにも使い方は書いていないので以下のやり方がスマートなのかはわからないです。。

とりあえず見つけたやり方としては、CodeEditクラスが持っている add_code_completion_optionupdate_code_completion_options を使えばコード補完できるようです。
https://www.reddit.com/r/godot/comments/zv3gln/codeedit_node_code_completion/

code_completion_enabled というプロパティもありますが、ONにしてもそれでコード補完は動かなかったです。
https://docs.godotengine.org/en/stable/classes/class_codeedit.html

CodeArea の「ノード」パネルから text_changedcode_completion_requested シグナルを CodeArea 自身で受け取るようにします。
_on_text_changed の中で request_code_completion を実行して、テキスト入力されたタイミングで都度コード補完をします。

CodeArea.gd
func _on_code_completion_requested() -> void:
    if !current_syntax: return
    for arr in current_syntax.completions:
        add_code_completion_option(CodeEdit.KIND_PLAIN_TEXT, arr[0], arr[1])
    update_code_completion_options(true)


func _on_text_changed() -> void:
    if get_word_at_pos(get_caret_draw_pos()).length() > 0:
        request_code_completion(true)

これで code-syntaxes/Ruby-syntax.tres の completion で定義した内容に従ってコード補完されるようになりました。

できたもの

最終的にはシンタックスを自動判定したり、プルダウンで変更できるようにしたり、テーマを変えるようにしたりなどの修正を行ない下記のようなものができました。

完成物

できたものは以下に置いています。
https://github.com/tkmfujise/Godot-general_purpose-examples/tree/main/Editor

Godotはいいぞ

Discussion