👆

[Godot 3.x] ドラッグ&ドロップでインベントリシステムを作ってみる

6 min read

インベントリって何? -> ゲームでよくあるアイテム画面のことだよ

この記事はGodotでよくあるControlノードのDrag&Dropの実装をやってみた類ですが、ちょっとしたアレンジもあります。

完成コード

こちら

https://github.com/harumaxy/InventrySystem

Godot3.x のドラッグ&ドロップ

実装方法は2つありました。

  • マウスイベント検出系のsignal
  • Controlノードの virtual method (今回はこちらを採用)

Signal を使う場合(今回は使わない)

基本的に、まずマウスイベントの検出から始まります。
衝突検出系のノード、つまりCollisionObject系の(RigidBodyとかAreaとか、2D/3D両方)もしくはControl(UI系)を継承しているノードにそれっぽいsignalがあります

  • input_event
  • gui_input
  • mouse_entered/exited

これらを使う場合は、ドラッグ&ドロップの処理はほぼ自前の実装になるでしょう。
出来ないことは無さそうですが、面倒そうなんで今回は採用しませんでした。

UIだけじゃなく、ゲームのロジックにドラッグ&ドロップ操作を組み込みたい場合(キャラクターとかを動かす)場合は必然的にこっちでしょう。

Control系の virtual method

Control系のノードの場合はドラッグ&ドロップでの利用が想定されているようで、予め用意されている virtual method をオーバーライドすることで比較的かんたんに実装できます。
(いわゆる、継承して拡張すること前提で定義されたメソッドということですね)

↓インターフェース

# Control

# Drag する Control Node に実装する
virtual get_drag_data(position: Vector2) -> Variant

# Drop される側の Control Node に実装する
virtual can_drop_data(position: Vector2, data: Variant) -> bool
virtual drop_data(position: Vector2, data: Variant) -> void

(↓Zennを探したらこっちの方法に関する記事を書いてる人がいたので、参考リンク貼らせていただきます🙏)

https://zenn.dev/slm/articles/060a4cdb7a920a
  • ドラッグを開始する側にget_drag_data()関数
  • ドロップされる側にcan_drop_data(), drop_data()関数

を実装するということですね。

Signalを使う方法に比べるとドラッグ&ドロップの検知やデータの受け渡しを実装する必要がないのでだいぶ楽です。

ドラッグの見た目をアレンジ (set_drag_previewを使わない)

Control系の virtual method を使う方法をググると、大体はset_drag_preview()関数をつかっている記事や動画がでてきます。

# Control

set_drag_preview(control: Control) -> void

Control ノードを渡すとドラッグしているマウスカーソルに追従して、それでドラッグしてる視覚効果を得られるわけですね。

個人的には、もう少し見た目をアレンジしたいんですよねぇ。
マウスを離したときにドロップ先がなかったら元の場所に戻るTweenアニメーションみたいなのを追加したい(シャ○バのカードみたいな)

そもそも Drag Preview にセットしたノード って SceneTree のどこにあるんじゃいという。
ドキュメントによれば

void set_drag_preview(control: Control)
Shows the given control at the mouse pointer. A good time to call this method is in get_drag_data(). The control must not be in the scene tree. You should not free the control, and you should not keep a reference to the control beyond the duration of the drag. It will be deleted automatically after the drag has ended.

与えられたControlをマウスポインターに表示します。このメソッドを呼び出すタイミングとしては、get_drag_data()の中が良いでしょう。Controlはシーンツリーにあってはいけません。Controlを解放してはいけません。また、ドラッグしている間を超えてControlへの参照を保持してはいけません。ドラッグが終了すると自動的に削除されます。

つまり、set_drag_preview()したあとのControlはプログラマーがいじっちゃダメってことです。ドラッグを終了すると自動で削除されるので、元の位置に戻るアニメーションなんかをついかするなら他の方法を使う必要があります。
(手っ取り早くドラッグ&ドロップの見た目を実現するだけなら使ったほうが断然楽ですが...)

なので、今回はドラッグ&ドロップ処理にはControlノードのvirtual methodを使い、視覚的な効果は自前でやるということにしました。

アイコンを作る

まずドラッグできるアイテムアイコンを作ります。

まずはドラッグする側のControlノードを作っていきます。
TextureRectノードでアイコンを作りました。
元の場所に戻るアニメーションのための Tween ノードも追加してます。

↓スクリプト

# ItemIcon.gd

extends Control

var picked := false
var slot: Control = null

func return_to_slot():
  picked = false
  # ドラッグ終了したらまたマウスイベントを拾うようにする
  self.mouse_filter = MOUSE_FILTER_PASS
  if slot:
    # slot の Rect の中央に戻るようにオフセットを設定
    var offset = (slot.rect_size - self.rect_size) / 2
    $Tween.interpolate_property(self, "rect_position", rect_position, slot.rect_global_position + offset, 0.5, Tween.TRANS_EXPO, Tween.EASE_OUT)
    $Tween.start()

# ドラッグ中 かつ マウスが離されたら元の slot に戻る
func _input(event):
  if event is InputEventMouseButton:
    if not event.is_pressed() and picked:
      return_to_slot()

# マウス位置への移動を自前で実装
func _process(dt):
  if picked:
    self.rect_position = get_global_mouse_position() - (self.texture.get_size() / 2)

# ドラッグを検出
func get_drag_data(_pos):
  var data = {node = self}
  print(data)
  picked = true
  # ドラッグ開始したら、マウスイベントを拾わないようにする (そのままだと、ドロップ先のControlノードがイベントを拾えない)
  self.mouse_filter = MOUSE_FILTER_IGNORE
  return data

特に、ドラッグ開始/終了時のmouse_filterプロパティを変更する処理が超重要です!
ドロップするときにMOUSE_FILTER_IGNORE じゃないとドロップされる側のControlノードがイベントを拾ってくれませんでした。

(set_drag_preview()関数を使った場合のドラッグプレビューだとマウスイベントを拾わないように自動でしてくれるので問題ないですが、自前でControlノードを操作する場合はドロップ時にドラッグプレビューがマウスイベントを吸わないようにする必要があります。)

アイテムスロットを作る

次は、アイテムをドロップできるスロットを作ります

# ItemSlot.gd

extends Control

var item: Control = null

# slot が item をセットされていなかったらドロップできる
func can_drop_data(position, data):
  return item == null

func drop_data(position, data):
  print(data)
  print("dropped")
  var _item: Control = data.node
  # update item slot
  if _item.slot != null:
    _item.slot.item = null # item が以前にセットされていた slot の item property を null にする
  self.item = _item
  self.item.slot = self
  self.item.return_to_slot()

主に can_drop_data()drop_data()のオーバーライドですね。
data には Dictionaly型を渡していて、data.nodeで ItemIcon のノードを取得できるので slot プロパティを更新した後にreturn_to_slot()を呼び出して新しいslotの位置に移動させてあげます。

いわゆる、SceneTreeシステムの親子関係的なものはセットしてません。
ItemSlotの子要素にItemIconがあるのが自然な気もしますが、親子関係をセットするとローカル座標の位置が変わったりよくわかんなかったので。
もっといい方法がある気もするけど[1]、とりあえず動くのでヨシ!👉

インベントリ画面

最終的に出来た UI がこちら
Panel とか GridContainer とか VBox/HBoxContainer を駆使してできるいつものやつですね。
RPGとかの装備画面に使えそう。

まとめ

  • UIのドラッグ&ドロップであれば、Control ノードの virtual method をオーバーライドするのが楽
  • Control.set_drag_preview() を使えば見た目も簡単に作れる
    • 今回は見た目やアニメーションを凝りたかったので使わなかったけど

感想

get_drag_data(), can_drop_data(), drop_data() だけ使って、set_drag_preview() を使わないのはありなんでしょうか...
探した限り、あんまりそういうやり方の人がいないのでちょっと異端感を感じる

あと、欲を言えばドラッグ&ドロップ周りはもうちょっと Godot の signal システムに統合されててもいい気もする
(自前で実装するより簡単だけど)

https://github.com/harumaxy/InventrySystem
脚注
  1. ※余談。実は上のコード、ちょっと出来が良くない気がする。
    オブジェクト指向でお互いに参照をプロパティで持つような感じとか(Slot <=> Item)、nullを多用するコードとか... ↩︎

Discussion

ログインするとコメントできます