【Godot】キーコンフィグを作る

はじめに

PC向けにゲームを作ると様々な入力デバイスがあることに気づきます。

Godotではプロジェクト設定からインプットマップを設定できますね。

f:id:ueshita:20200909140848p:plain

複数のデバイスを1つのactionに割り当てることができます。

しかし、キーアサインを好みに設定したいプレイヤーもいて、キーコンフィグ機能があるゲームも多いです。

本記事ではインプットマップを実行時に設定する方法を解説します。

InputMapを使う

プロジェクト設定から設定したインプットマップは、InputMapというAPIでアクセスできます。今回はこのAPIを主に使います。

docs.godotengine.org

キーコンフィグを作る

今回は下記のようにキーボードとゲームパッド両方から操作できるような設定です。

f:id:ueshita:20200909140848p:plain

InputMapの設定

ここでは入力イベントを受け取り、InputMapに上書きする方法を解説します。

InputMap設定メソッド

InputMapに設定します。設定を上書きするAPIは存在しないので、一旦InputMap.action_erase_eventsで削除してからInputMap.action_add_eventでInputEventを設定します。

func set_inputmap(action: String, event_index: int, event: InputEvent):
    # InputMapからactionを取得
    var list = InputMap.get_action_list(action)
    # actionを一旦削除
    InputMap.action_erase_events(action)
    # リストを上書き
    list[event_index] = event
    # actionを改めて追加
    InputMap.action_add_event(action, list[0])  # キー設定
    InputMap.action_add_event(action, list[1])  # パッド設定

入力を受けるメソッド

_gui_input で入力を受け付けます (Controlノードにスクリプトを付けます)

受け取ったInputEventをset_inputmapに設定します。

func _gui_input(event: InputEvent):
    var action = "act_jump"  # 暫定的にact_jump固定
    if event is InputEventKey:
        # キー入力された
        var key := event as InputEventKey
        if key.is_pressed():
            set_inputmap(action, 0, event)
    elif event is InputEventJoypadButton:
        # パッド(ボタン)入力された
        var joypad_button := event as InputEventJoypadButton
        if joypad_button.is_pressed():
            set_inputmap(action, 1, event)
    elif event is InputEventJoypadMotion:
        # パッド(スティック)入力された
        var joypad_motion := event as InputEventJoypadMotion
        if joypad_motion.axis_value != 0:
            set_inputmap(action, 1, event)

InputMapのセーブとロード

InputMapは再起動するとプロジェクト設定のデータにリセットされます。

キーコンフィグ機能として成り立たせるためには、設定時にファイルにコンフィグ情報を保存して、ゲーム起動時にロードする必要があります。

保存対象のaction

const input_actions := [
    "act_attack",
    "act_jump",
    "act_up",
    "act_down",
    "act_left",
    "act_right",
    "ui_accept",
    "ui_cancel",
    "ui_up",
    "ui_down",
    "ui_left",
    "ui_right",
]

InputMapのシリアライズ

設定したInputMapを保存するときに使います。

func _serialize_input() -> Dictionary:
    var data = {}
    for action in input_actions:
        var list = InputMap.get_action_list(action)
        var item = {}
        item["key"] = list[0].scancode
        
        if list[1] is InputEventJoypadButton:
            item["button"] = list[1].button_index
        elif list[1] is InputEventJoypadMotion:
            item["axis"] = list[1].axis
            item["axis_dir"] = 1 if list[1].axis_value > 0 else -1

        data[action] = item

    return data

InputMapのデシリアライズ

保存されたコンフィグ情報をInputMapに再設定するときに使います。

func _deserialize_input(data):
    if not data is Dictionary:
        return

    for action_name in data:
        var item = data[action_name] as Dictionary
        if not item:
            continue
        
        var list = InputMap.get_action_list(action_name) as Array
        if not list:
            continue
        
        if item.has("key"):
            var keycode = item["key"]
            if typeof(keycode) == TYPE_REAL:
                list[0].scancode = keycode
        
        if item.has("button"):
            var button_index = item["button"]
            if typeof(button_index) == TYPE_REAL:
                var event = InputEventJoypadButton.new()
                event.button_index = button_index
                list[1] = event
        elif item.has("axis") and item.has("axis_dir"):
            var axis_id = item["axis"]
            var axis_dir = item["axis_dir"]
            if typeof(axis_id) == TYPE_REAL and typeof(axis_dir) == TYPE_REAL:
                var event = InputEventJoypadMotion.new()
                event.axis = axis_id
                event.axis_value = 1 if axis_dir > 0 else -1
                list[1] = event
        
        InputMap.action_erase_events(action_name)
        InputMap.action_add_event(action_name, list[0])
        InputMap.action_add_event(action_name, list[1])

JSONにセーブ

シリアライズしたInputMapの情報をJSON化してファイルに保存します。

func savefile():   
    var file := File.new()
    if file.open("user://settings.json", File.WRITE) != OK:
        return

    var jsonstr := JSON.print({
        "input": _serialize_input(),
    }, "  ")

    file.store_string(jsonstr)
    file.close()

JSONからロード

JSON形式で読み込んだ情報をInputMapに再設定します。

func loadfile():
    var file := File.new()
    if file.open("user://settings.json", File.READ) != OK:
        return

    var jsonstr := file.get_as_text()
    file.close()
    
    if not jsonstr:
        return
    
    var json_res := JSON.parse(jsonstr)
    if json_res.error:
        printerr("failed to load settings: " + json_res.error_string)
    
    var json := json_res.result as Dictionary

    _deserialize_input(json.get("input"))

注意

JSONはユーザーでもテキストエディタを使っていじれるため、 エラー処理をしっかりしないとセキュリティホールになるかもしれないので注意です。

【Godot】GDScriptのエラー: The class "ClassName" couldn't be fully loaded (script error or cyclic dependency). について

概要

GDScriptを書いているとよく見る「The class "ClassName" couldn't be fully loaded (script error or cyclic dependency).」について解説します。

エラーを調べる

GDScriptでスクリプトから別のスクリプト(のクラス)を参照するときに、発生することがあるエラーです。

エラー文の括弧内にもある通り、以下の2つが原因の可能性が高いです。

  1. Script error (スクリプトエラー)
  2. Cyclic dependency (循環依存エラー)

スクリプトエラー?

例えばScriptAからScriptBを参照しているとき、ScriptBで何かエラーが起きています。 そっちを直すと解決することがあります。

循環参照エラー?

こちらが本題です。

以下の2つのスクリプトを書くとどちらかで cyclic dependencyエラーが発生します。

  • ScriptA.gd
class_name ScriptA

func _ready():
    var b = get_node("/Root/ScriptB") as ScriptB
    b.method_b()

func method_a():
    print("method_a")
  • ScriptB.gd
class_name ScriptB

func _ready():
    var a = get_node("/Root/ScriptA") as ScriptA
    a.method_a()

func method_b():
    print("method_b")

GodotはGCではなく参照カウンタ方式のメモリ管理なので、循環参照するのはよろしくないです。でもクラスメンバに持つだけでなく、メソッド内で一時的に循環参照するのもダメなのはどうしてかなと。

原因

エラーの原因はどうも循環参照ではなく、スクリプトのロード順の問題とのことらしいです。

C/C++でヘッダ同士がお互いをインクルードしてはいけないやつに似ていますが…。

解決方法

片方のスクリプトから型情報を消します。

class_name ScriptB

func _ready():
    var a = get_node("/Root/ScriptA")
    a.method_a()

func method_b():
    print("method_b")

えって思うかもしれませんが、GDScriptは基本的に動的型付け言語なので問題は無いです。

このようにすると型の解決がパース時ではなく実行時になるため、スクリプトのロード順の問題は起きません。

型情報を失うと型チェックやインテリセンスが使えなくなるのでちょっと…ってなるのですが、現状ではこうするしかないようです。

C/C++では前方宣言で解決できるのですが、GDScriptにそのような仕組みは無いようですね…

Godot4では解決?

Godot4ではその辺考えなくてもよくなるとの噂を聞いたのですが、果たしてどうなるのでしょうか 今後にGodot Engineに期待しましょう。

【Godot】TileMapのコリジョンからタイル情報を検出する

やりたいこと

GodotのTileMapで横スクロールな2Dステージを作ったとします。

そこでキャラが乗っているタイルの情報が欲しくなりました。

ダメージ床だったらダメージ処理したり、衝突したタイルを破壊したりできます。

タイル情報を検出する方法

TileSetの設定

まずTileSetを設定します。

画像から領域を切って、コリジョンを設定します。

f:id:ueshita:20200902004854p:plain

タイルでステージを作る

TileSetからタイルを選んでTileMapに配置します。

今回は横長タイルにしました。

f:id:ueshita:20200902010521p:plain

キャラの作成

キャラにはKinematicBody2Dを設定します。

今回は2Dユニティちゃんをお借りしました。🙏🙏🙏🙏🙏🙏🙏

f:id:ueshita:20200902120813p:plain

移動処理を書きます。詳細な処理の記載は省いています。

func _physics_process(delta: float):
    # 落下処理
    velocity.y += GRAVITY * delta
    velocity.y = min(velocity.y, MAX_FALLING_SPEED)
    
    # 移動処理
    velocity = move_and_slide_with_snap(velocity, 
        Vector2(0, 4), Vector2.UP, true, 4, deg2rad(48))

コリジョンからタイル情報を検出する

ここからが本題です。

move_and_slide_with_snap で移動している場合、get_slide_collisionKinematicCollision2D が得られます。

KinematicCollision2Dの各プロパティはタイルマップの場合、以下の通りになります

  • collider : CollisionObject2D
  • collider_shape : TileMap
  • collider_shape_index : シェイプ番号 (タイルごとのシェイプ)

collider_shape_index が重要です。乗っているタイルによってここの値が変化することが確認できます。

CollisionObject2Dに登録されているコリジョンシェイプの番号になるので、TIleIDにどうにかして変換する必要があります。

いろいろ調べて、以下の方法で上手くいきました。

func _process(delta: float):
    var collision := get_slide_collision(0)
    if collision:
        # TileMapに衝突しているかどうか
        var tilemap := collision.collider_shape as TileMap
        if tilemap:
            var tileset := tilemap.tile_set
            # collider_shape_indexを使ってメタデータ(タイル位置)を取得
            var tile_pos = Physics2DServer.body_get_shape_metadata(
                collision.collider.get_rid(), 
                collision.collider_shape_index)
            # タイル位置を使ってTIleMapからタイルIDを取得
            var tile_id := tilemap.get_cellv(tile_pos)
            # タイルIDを表示
            print(tileset.tile_get_name(tile_id));

実行画面

f:id:ueshita:20200902005039p:plain

出力ログ

--- Debugging process started ---
Godot Engine v3.2.2.stable.official - https://godotengine.org
OpenGL ES 3.0 Renderer: Intel(R) Iris(R) Plus Graphics
 
基本チップ
ハーフチップ
右上がり斜面
左上がり斜面
右上がり斜面

まとめ

CollisionObject2Dにメタデータという形で記録されたタイル位置情報を collider_shape_index を使ってタイル情報を得る方法の紹介でした。

おまけ(調べた方法)

TileMapのソースコードを読みました。

Godotはエンジンのお気持ちが知りたくなったら、ソースコードを見に行けばいいのでお手軽ですね。

github.com

タイルのコリジョンシェイプをbody(CollisionObject2D)へ登録している箇所があります。

_add_shape(shape_idx, q, shape, shapes[j], xform, Vector2(E->key().x, E->key().y));

シェイプ情報以外にもタイルの位置情報をメタデータとして登録しています。

void TileMap::_add_shape(int &shape_idx, const Quadrant &p_q, const Ref<Shape2D> &p_shape, const TileSet::ShapeData &p_shape_data, const Transform2D &p_xform, const Vector2 &p_metadata) {
    PhysicsServer2D *ps = PhysicsServer2D::get_singleton();

    if (!use_parent) {
        ps->body_add_shape(p_q.body, p_shape->get_rid(), p_xform);
        ps->body_set_shape_metadata(p_q.body, shape_idx, p_metadata);

どうにかしてこのメタデータを取り出せばタイル位置が判明します。

Physics2DServerはGDScriptからも叩けるので、Physics2DServer.body_get_shape_metadataでメタデータを取ることができることが分かりました。

ライセンス表記

f:id:ueshita:20200902122900p:plain

この記事はユニティちゃんライセンス条項の元に提供されています

【Godot】Visual StudioでC++のプラグインを作る

Godot EngineはGDScript以外にもC++で開発することができます。

仕組みをGDNativeといい、Godotのエンジン機能にアクセスすることができます。

一応公式ではGDNativeのビルドツールにSConsを推奨していますが、Visual StudioC++プラグイン開発したいと思う方は結構いるんじゃないでしょうか。

今回はVisual StudioC++でGodotプラグインを開発する方法をまとめました。

ビルド環境

準備

C++開発するために必要なgodot-cppを準備します。

godot-cppをcloneする

まずリポジトリGitHubから取得します。

"3.2"は現在使用しているGodotバージョンと同じものを指定します。

git clone --recursive -b 3.2 https://github.com/GodotNativeTools/godot-cpp.git

godot-cppをビルドする

godot-cppのビルドにはSConsを使用します。

持っていない場合はPythonをインストールしてからpip install sconsでSConsをインストールします。

32/64bit版、Debug/Release版をそれぞれビルドします。

scons platform=windows bits=32 generate_bindings=yes target=debug -j4
scons platform=windows bits=32 generate_bindings=yes target=release -j4
scons platform=windows bits=64 generate_bindings=yes target=debug -j4
scons platform=windows bits=64 generate_bindings=yes target=release -j4

Visual Studioプラグインを作成する

プロジェクト作成

Win32 C++ DLLプロジェクトを作成します。

プロジェクト設定

以下のgodot-cppのヘッダーパスをインクルードディレクトリに追加します。

  • $(ProjectDir)godot-cpp\godot_headers
  • $(ProjectDir)godot-cpp\include
  • $(ProjectDir)godot-cpp\include\core
  • $(ProjectDir)godot-cpp\include\gen

※今回はプロジェクトディレクトリ内にgodot-cppを配置しています。別の場所に配置した場合は適宜変更してください。

さらに以下のgodot-cppのライブラリをビルド構成それぞれに指定します。

  • [Win32/Debug] $(ProjectDir)godot-cpp\bin\libgodot-cpp.windows.debug.32.lib
  • [Win32/Release] $(ProjectDir)godot-cpp\bin\libgodot-cpp.windows.release.32.lib
  • [x64/Debug] $(ProjectDir)godot-cpp\bin\libgodot-cpp.windows.debug.64.lib
  • [x64/Release] $(ProjectDir)godot-cpp\bin\libgodot-cpp.windows.release.64.lib

プラグインソースの追加

公式のチュートリアルにならってSpriteを拡張したクラスを作成します。

https://docs.godotengine.org/ja/latest/tutorials/plugins/gdnative/gdnative-cpp-example.html

#pragma once

#include <Godot.hpp>
#include <Sprite.hpp>

class MySprite : public godot::Sprite
{
    GODOT_CLASS(MySprite, godot::Sprite)

private:
    float time_passed;

public:
    static void _register_methods();

    MySprite();
    ~MySprite();

    void _init();

    void _process(float delta);
};
#include "MySprite.h"

using namespace godot;

void MySprite::_register_methods()
{
    register_method("_process", &MySprite::_process);
}

MySprite::MySprite()
{
}

MySprite::~MySprite()
{
}

void MySprite::_init()
{
    time_passed = 0.0;
}

void MySprite::_process(float delta)
{
    time_passed += delta;

    Vector2 new_position = Vector2(10.0 + (10.0 * sin(time_passed * 2.0)), 10.0 + (10.0 * cos(time_passed * 1.5)));

    set_position(new_position);
}

次にDLLからエクスポートされるC関数を追加します。

#include "MySprite.h"

extern "C" void GDN_EXPORT godot_gdnative_init(godot_gdnative_init_options *o)
{
    godot::Godot::gdnative_init(o);
}

extern "C" void GDN_EXPORT godot_gdnative_terminate(godot_gdnative_terminate_options *o)
{
    godot::Godot::gdnative_terminate(o);
}

extern "C" void GDN_EXPORT godot_nativescript_init(void *handle)
{
    godot::Godot::nativescript_init(handle);

    godot::register_class<MySprite>();
}

プラグインのビルド

エラーが出なければ成功です。

Godotプロジェクトにプラグインを登録する

DLLをGodotプロジェクトの以下に配置します。

今回は開発中なのでデバッグ版DLLを配置します。(最終的にはRelease版DLLを配置します)

  • myplugin/win32/libmyplugin.dll
  • myplugin/win64/libmyplugin.dll

次にプラグインの定義ファイルを追加します。(新規リソース追加)

  • myplugin/myplugin.gdnlib

myplugin.gdnlibにDLLを設定します。(今回はWindowsのみ)

f:id:ueshita:20200901224336p:plain

プラグイン内のクラスのNativeScriptを追加します(新規スクリプト追加)

  • MySprite.gdns

Libraryにmyplugin.gdnlibを設定します。

f:id:ueshita:20200901224441p:plain

スプライトにMySpriteを設定して実行。

f:id:ueshita:20200901224457g:plain

うまく動いているようです。

Visual Studioプラグインデバッグする

デバッグ版DLLをGodotプロジェクト内に配置します。

Godotで実行中のプロセスにアタッチする方法を行います。

Visual Studioメニューのデバッグ→プロセスにアタッチを選択します。

f:id:ueshita:20200901224522p:plain

プロセスにアタッチが成功すると、プラグインソース内でブレークできました。

f:id:ueshita:20200901224536p:plain

これでプラグイン開発が捗りますね!

サンプル置き場

https://github.com/ueshita/gdnative-vsproj-example

おわりに

本記事はWindowsで効率よくGodot向けのプラグインを開発したい方向けです。

しかし実際のところ、Macやモバイルといったマルチプラットフォーム向けには、結局SConsでビルドできるようにする必要があります。(MacMacXCodeでビルドできるようにすると多少捗るかもしれませんが…)

【Godot】Node2DのGizmoを描画する

はじめに

Node2DにGizmoを描画したくて色々ハマったので情報をまとめます。

動作環境

Godot Engine 3.2.1

メモ

SpatialGizmoは2Dでは動かない

SpatialGizmo https://docs.godotengine.org/ja/stable/tutorials/plugins/editor/spatial_gizmos.html

最初これかなと思って試したら、動かなくてウーンウーン悩んでました。 Spatialは3DのNodeなのでNode2Dで動かないのは当然でした。ちなみにGodot4からはSpatialはNode3Dに改名するみたいです。

2D版のGizmoは無かった

EditorPlugin https://docs.godotengine.org/en/stable/classes/class_editorplugin.html

EditorPluginにadd_spatial_gizmo_pluginは存在するが、add_node2d_gizmo_pluginは無さそうなので、2D版のGizmoは無いみたいです。

解決: _drawで描画する

他のCamera2Dといった2DノードはどうやってEditor画面に描画しているのか気になったのでGitHubのソースを眺めてみました。

https://github.com/godotengine/godot/blob/master/scene/2d/camera_2d.cpp

どうも_notificationのcase NOTIFICATION_DRAW で描画しているみたいです。 エンジンの真のお気持ちはソースコードに書いてあります。

GDScriptでは先頭にtoolを追加して_drawで描画すればよさそうです。

Node2Dの周りに四角形を描きたいときは以下のコードになります。

tool
extends Node2D

export var left   := -100
export var top    := -100
export var right  := 100
export var bottom := 100

func _process(delta):
    if Engine.editor_hint:
        update()

func _draw():
    if Engine.editor_hint:
        var color := Color(1.0, 0.0, 0.0)
        draw_line(Vector2(left, top), Vector2(right, top), color)
        draw_line(Vector2(right, top), Vector2(right, bottom), color)
        draw_line(Vector2(right, bottom), Vector2(left, bottom), color)
        draw_line(Vector2(left, bottom), Vector2(left, top), color)

EditorでGizmo描画できました。

f:id:ueshita:20200901110733p:plain

注意点

  • _processは実行時にも走るので、Engine.editor_hintでEditor上でのみ実行されるように制御する。
  • _processupdate()を呼び出す方式は、毎フレーム描画処理が行われてしまうので、Gizmo描画に影響あるパラメータをプロパティにしてその中でupdate()したほうがお行儀がよさそう。