本記事で話すこと
普段Unityを使っている視点で、GodotのUI配置をUnityっぽくする方法について話します。
- Unity(RectTransform)とGodot(Control)の違い
- GodotでのEditorPluginの作り方
はじめに
本記事は、「QualiArts Advent Callender 2023」の9日目の記事となります。
株式会社QualiArtsでUnityエンジニアをしているRinia(りにあ)です。今回は、業務で使用しているUnityからちょっと離れて、最近流行りのゲームエンジンであるGodot Engineの話をしようと思います。
Godot Engineとは?
Godot Engineはゲームエンジンの1つで、ゲーム開発に必要な基盤が既にあったり、シンプルさと特殊化が設計思想の基本だったりと、クリエイティブに集中できる開発体験が魅力です。特徴の例を挙げると、こんな感じになります。
- ビルトインにMultiplayer、Gridmap(Tilemapの3D版)、UI Themeなどの機能がある
- コンポーネントは継承がベースで、他のオブジェクトを相対パスで指定して操作するのが基本
- 独自の実装言語 GDScript は、Pythonっぽいスクリプト言語だけど必要に応じて静的な型チェックを行える
これらの点がかなり魅力的なのですが、エディタでのUI配置だけがしづらかったので、EditorPlugin(カスタムエディタ)を作ってUnityっぽく扱えるようにしました。
エディタの比較(Unity, Godot)
UnityでUIを配置するときは、AnchorとPivotを使って基準を決めて、そこから位置やサイズを指定することが多いと思います。この操作は直感的で、よく馴染みがあるものでしょう。
一方、GodotのエディタでUI配置をしようとすると...
①を開いて、②を「Anchors」にして、③からPresetを選んで、ってやらないといけません。さらに、Godotでの各設定値はシンプルさを重視しているためか、レイアウトのために設定値を自前で算出しなければいけないことが多いです。
そこで今回は、GodotのUI配置の仕組みを理解し、カスタムエディタを使ってGodotでもUnity同様直感的に操作できるようにすることを目指しました。
仕組みの比較(Unity, Godot)
※実装レベルの詳細な解説はしません。概念的な話だと思ってください。
UnityのRectTransformでは内部的に、以下の設定値をベースにレイアウトを行っています。
- Anchor
- 配置の基準となる四隅の座標(親をベースに割合で決まる)
- AnchoredPosition
- Anchorに囲まれた領域を基準に、自身の位置がどのくらいズレているか
- SizeDelta
- Anchorに囲まれた領域を基準に、自身のサイズがどのくらい異なるか
- Pivot
- 自身の中で基準となる一点の座標(サイズをベースに割合で決まる)
エディタ上に表示される「Pos X」とか「Width」とかは実は設定値ではなく、すべてここから算出された値です。そして特に大事なのが、Anchorに対する「位置の差分」と「サイズの差分」で自身の領域を決めているということです。
次に、Godotの場合です。
- Anchor
- 配置の基準となる四隅の座標(親をベースに割合で決まる)
- AnchorOffset
- Anchorに囲まれた領域を基準に、自身の上下左右がどのくらいズレているか
- PivotOffset
- 自身の中で基準となる一点の座標(左上座標をベースに位置で決まる)
こちらは、Anchorに対する「上下左右の差分」のみで自身の位置・サイズの両方を決めています。 先ほど言った通り、Godotの方は非常にシンプルな仕組みになっています。
つまり、UnityっぽいエディタでGodotのレイアウトを操作するには、この「Anchorから自身の領域を決める部分」を相互に変換できれば良いわけです。次はこれを踏まえて、EditorPluginを作っていきましょう。
EditorPluginを作る
下準備
まずは、GodotのインスペクタにカスタムUIを表示する下地を作ります。ほとんどのやり方は公式ドキュメントに載っています。
Project > ProjectSettings > Plugins > Create New Plugin
からプラグインを作成できます。
こんな感じに入力すると、フォルダとスクリプトが作られます。
そしたら、プラグインのスクリプトを書きます。これで、読み込み時にインスペクタプラグインが作成され、インスペクタでControl
型のオブジェクトを開いたときに rect_transform_editor.tscn
というシーンがインスペクタにそのまま表示されるようになります。
Godotの「シーン」は、UnityでいうPrefabに当たります。
@tool
extends EditorPlugin
var plugin
func _enter_tree():
plugin = preload("res://addons/custom_rect/custom_inspector.gd").new()
add_inspector_plugin(plugin)
func _exit_tree():
remove_inspector_plugin(plugin)
@tool
extends EditorInspectorPlugin
var scene = preload("res://addons/custom_rect/rect_transform_editor.tscn")
func _can_handle(object):
# Control型の時のみ動作させる
return object is Control
func _parse_begin(object):
# シーンを生成して、インスペクタに追加する
var instance: RectTransformEditor = scene.instantiate()
instance.target = object
add_custom_control(instance)
次に、インスペクタに表示するUIを作っていきましょう。
見た目を作る
これがインスペクタに表示するUIです。なんとシーンを作ればそれがそのまま動くので、とても便利です。
文字の表示は Label
、テキストボックスは LineEdit
を使ってます。左上の図形は ColorRect
と NinePatchRect
を組み合わせて作ってます。
シーンのルートオブジェクトには、以下のスクリプトをアタッチしておきましょう。
@tool
extends Control
class_name RectTransformEditor
# 操作対象
var target: Control = null
次に、作成したUIのテキストボックスを設定値と同期させる仕組みを作ります。
エディタと設定値を同期させる
実装量が多いので端折りますが、要するに以下2つをすればOKです。
- テキストボックスに変更があったとき、設定値に適用する
- 毎フレーム、テキストボックスに設定値を表示する(テキストボックスの編集中以外)
ここでは例として、$RotationLineEdit
というテキストボックスと、オブジェクトの回転角target.rotation_degrees
を同期する実装を示します。
# 前略
# ここで示しているのは説明用に簡略化したコードのため、本来の実装とは異なります
# 初期フレーム実行
func _ready():
$RotationLineEdit.connect(
"text_changed", # テキストの編集時
func (value):
apply(float(value))
)
$RotationLineEdit.connect(
"text_submitted", # テキストの決定時
func (value):
apply(float(value))
set_view()
)
# 毎フレーム実行
func _process(delta):
set_view()
# テキストボックスに変更があったとき、設定値に適用する
func apply(value: float):
target.rotation_degrees = value
# テキストボックスに設定値を表示する(テキストボックスの編集中以外)
func set_view():
if not $RotationLineEdit.has_focus():
$RotationLineEdit.text = str(target.rotation_degrees)
あとは、これを他の全部のテキストボックスと設定値に対して増やしていきます。しかし、一部は apply()
と set_view()
に変換ロジックを挟む必要があります。
レイアウトを相互変換する
今回の一番難しい所です。全部説明すると長くなってしまうので、ここでは「Pos X」と「Width」に絞って解説します。
まず、Unityでは以下のように表示の出し分けが行われます。
-
AnchorMin
とAnchorMax
が同じとき、「Pos X」「Width」表示 - そうでないとき、「Left」「Right」表示
これを実装していきます。
# 前略
# ここで示しているのは説明用に簡略化したコードのため、本来の実装とは異なります
# テキストボックスに変更があったとき、設定値に適用する
func _on_x1_edited(value: float):
if target.anchor_left == target.anchor_right:
set_position_x(value) # Pos X
else:
set_left(value) # Left
# テキストボックスに変更があったとき、設定値に適用する
func _on_x2_edited(value: float):
if target.anchor_left == target.anchor_right:
set_width(value) # Width
else:
set_right(value) # Right
# テキストボックスに設定値を表示する(テキストボックスの編集中以外)
func set_view():
if target.anchor_left == target.anchor_right:
$X1Label.text = "Pos X"
if not $X1LineEdit.has_focus():
$X1LineEdit.text = str(get_position_x())
$X2Label.text = "Width"
if not $X2LineEdit.has_focus():
$X2LineEdit.text = str(get_width())
else:
$X1Label.text = "Left"
if not $X1LineEdit.has_focus():
$X1LineEdit.text = str(get_left())
$X2Label.text = "Right"
if not $X2LineEdit.has_focus():
$X2LineEdit.text = str(get_right())
あとは、「Pos X」と「Width」のそれぞれを設定値と相互変換する関数を実装します。
まずは「Pos X」から。
func get_position_x():
# 「Pos X」の表示値はPivotの座標なので、Pivotの座標を計算する
return target.offset_left + target.pivot_offset.x
func set_position_x(value: float):
# getの逆。左端と右端のX座標を指定して、その差がwidthになっていればOK。
var width = target.size.x
var prev_pivot_offset = target.pivot_offset.x
target.set_offset(SIDE_LEFT, value - prev_pivot_offset)
target.set_offset(SIDE_RIGHT, value - prev_pivot_offset + width)
次に「Width」ですが、こちらは少し工夫をする必要があります。
「Width」を増減させたときには
- 「左端からPivotまでの距離:右端からPivotまでの距離」の比
を基準に左右それぞれへの伸縮量が決まるのですが、「Width」が0になってしまうとこの比率が保持できなくなってしまうため、オブジェクトのメタデータとして比率をメモしておきます。
func get_width():
# 「Width」はそのまま横幅を返せばOK
return target.size.x
func set_width(value: float):
var pivot_position_x = target.offset_left + target.pivot_offset.x
var pivot_x = 0
if target.size.x > 0:
# 「左端からPivotまでの距離:右端からPivotまでの距離」の比を算出
pivot_x = target.pivot_offset.x / target.size.x
elif target.has_meta("pivot_x"):
# 「Width」が0ならメタデータから取得
pivot_x = target.get_meta("pivot_x")
# 算出した比率はメタデータに保存しておく
target.set_meta("pivot_x", pivot_x)
# Pivotの位置を基準に、widthから左端と右端を決める
target.set_offset(SIDE_LEFT, pivot_position_x - value * pivot_x)
target.set_offset(SIDE_RIGHT, pivot_position_x + value * (1 - pivot_x))
# 左端が変わるので、左端からPivotの距離を再計算する
target.pivot_offset.x = value * pivot_x
解説は以上になります。そのほか、詳しく知りたい方はソースコードをご覧ください。
まとめ
このようにして、以下のようなエディタをGodotに出現させることが出来ました。ちゃんとUnityと同じように動き、非常に快適です。
今回実装したEditorPluginはここです。まだまだ改善点は多いですが、参考にどうぞ。
これによりGodotのUI配置が楽になり、満足して制作が進められそうです。みなさんもぜひ、Godot Engineを触ってみてください。Unityとはまた違った開発体験があって楽しいです。
ちなみに、今回は無理やりUnityに近付けましたが、Godotは元々エディタ上でUI配置をすることを想定していないんじゃないかと個人的には思っています。
XDやFigmaなどでデザインしたものを持ってきて、オプション的な感じでAnchorやPivotを付けるような使い方をすれば、十分使いこなせそうな気がしてます。UIも似てますし。
それでは、最後までお読みいただきありがとうございました。