うどんてっくメモ

技術的なメモをまったりと

【Godot】GDScriptをAIエージェントに動的生成・実行させる仕組み

はじめに

本記事で紹介および検証を行なっているツール、ライブラリのバージョンは次の通りです。

バージョンによっては挙動に差異がある場合もありますので、ご了承ください。

AIエージェント用の動的実行がしたい

コーディングエージェントに開発を任せる際、ゲームエンジン上での動作確認まで自律的に行わせたいことがあります。たとえば「現在のシーンを調べて」「シーン上にオブジェクトを配置して実行確認して」「スタートボタンを押してゲーム画面に遷移するか確認して」といった操作です。

そのためには、外部から実行中のゲームに任意のコードを注入できる仕組みが必要になります。本記事では、GDScriptの動的生成機能を使ってこれを実現する方法を紹介します。

GDScriptの動的生成

set_source_code()メソッドがあり、文字列からスクリプトを動的に生成できます。

var script = GDScript.new()
script.set_source_code(source_text)
script.reload()
var instance = script.new()

set_source_code()でソースコードを設定し、reload()でコンパイルします。コンパイルが成功すればnew()でインスタンス化でき、通常のスクリプトと同様にメソッドを呼び出せます。 この仕組みを使えば、外部から受け取った文字列を実行可能なスクリプトに変換できます。

実行用の関数

受け取ったコードを実行して結果を返す関数の例です。

func execute_code(code: String) -> Variant:
    var source = """
extends RefCounted

var _root: Node

func _init(p_root: Node) -> void:
    _root = p_root

func run() -> Variant:
%s
""" % _indent(code)

    var script = GDScript.new()
    script.set_source_code(source)

    var err = script.reload()
    if err != OK:
        push_error("Compile error")
        return null

    var instance = script.new(get_tree().root)
    return instance.run()


func _indent(text: String) -> String:
    var lines = text.split("\n")
    var result: PackedStringArray = []
    for line in lines:
        result.append("\t" + line)
    return "\n".join(result)

受け取ったコードをrun()メソッドの本体として埋め込み、スクリプト全体を生成しています。シーンツリーのルートノードを渡すことで、生成したスクリプト内から任意のノードにアクセスできます。 GDScriptはインデントで構文を判断するため、_indent()で適切なインデントをつけます。

外部からの接続

CLIからゲームを操作するには、ゲームプロセスとCLIプロセスの間で通信する仕組みが必要です。 ゲームは独立したプロセスとして動作しているため、外部から直接関数を呼び出すことはできません。そこで、ゲーム内で外部からのリクエストを待ち受け、受け取ったコードを実行して結果を返す仕組みを用意します。 プロセス間通信の方法としてはTCP、WebSocket、UDP、ファイル監視など色々ありますが、本記事ではGodotに標準で用意されているTCPServerを使ったシンプルなサンプル実装を紹介します。

流れは以下の通りです。

  1. ゲーム起動時にTCPサーバーを開始し、指定ポートで待ち受け
  2. CLIクライアントがTCP接続を確立
  3. CLIJSON形式でコードを送信
  4. TCPサーバーがコードを受け取り、動的に実行
  5. 実行結果をJSONで返す
extends Node

var _server: TCPServer
var _clients: Array[StreamPeerTCP] = []

# 適当なポート
const PORT = 9876

func _ready() -> void:
    _server = TCPServer.new()
    _server.listen(PORT)
    print("[BridgeServer] Listening on port %d" % PORT)


func _process(_delta: float) -> void:
    # 新しい接続を受け付け
    if _server.is_connection_available():
        var client = _server.take_connection()
        _clients.append(client)

    # 接続中のクライアントからのメッセージを処理
    for client in _clients.duplicate():
        if client.get_status() !=
StreamPeerTCP.STATUS_CONNECTED:
            _clients.erase(client)
            continue

        client.poll()
        if client.get_available_bytes() > 0:
            var data =
client.get_data(client.get_available_bytes())
            if data[0] == OK:
                var message =
data[1].get_string_from_utf8()
                _handle_message(client, message)


func _handle_message(client: StreamPeerTCP, message:
String) -> void:
    var request = JSON.parse_string(message)
    if request == null or request.get("command") !=
"execute":
        return

    var result = execute_code(request.get("code", ""))
    var response = JSON.stringify({"result": result})
    client.put_data(response.to_utf8_buffer())

_process()で毎フレーム接続とメッセージを確認しています。TCPServer.listen()でポートを開き、take_connection()で接続を受け付け、StreamPeerTCP経由でデータをやり取りします。

動的ロード

main.gdというエントリーポイントを想定し、readyでロードするサンプルです。

# main.gd
extends Node


func _ready() -> void:
    _setup_debug_tools()


func _setup_debug_tools() -> void:
    if not
ResourceLoader.exists("res://debug/bridge_server.gd"):
        return

    var script = load("res://debug/bridge_server.gd")
    var bridge = Node.new()
    bridge.set_script(script)
    bridge.name = "BridgeServer"
    add_child(bridge)

CLIクライアント

外部からコードを送信するクライアントです。

# debug/client.gd
extends SceneTree

const PORT = 9876
const HOST = "127.0.0.1"


func _init() -> void:
    var args = OS.get_cmdline_user_args()
    if args.is_empty():
        print("Usage: godot --headless --script
debug/client.gd -- <code>")
        quit(1)
        return

    var code = args[0]
    var result = _send_command(code)
    print(result)
    quit(0)


func _send_command(code: String) -> String:
    var client = StreamPeerTCP.new()
    client.connect_to_host(HOST, PORT)

    while client.get_status() ==
StreamPeerTCP.STATUS_CONNECTING:
        client.poll()
        OS.delay_msec(10)

    if client.get_status() !=
StreamPeerTCP.STATUS_CONNECTED:
        return '{"error": "Connection failed"}'

    var request = JSON.stringify({"command": "execute",
"code": code})
    client.put_data(request.to_utf8_buffer())

    var timeout = 5000
    var start = Time.get_ticks_msec()
    while Time.get_ticks_msec() - start < timeout:
        client.poll()
        if client.get_available_bytes() > 0:
            var data =
client.get_data(client.get_available_bytes())
            if data[0] == OK:
                return data[1].get_string_from_utf8()
        OS.delay_msec(10)

    return '{"error": "Timeout"}'

CLIの実行とAIエージェントとの連携

実行方法は次の通りです。

# ゲームを起動した状態で
godot --headless --script debug/client.gd -- "return
_root.find_child('Label', true, false).text"

使用例はこんな感じです。

# ノードを検索して名前を返す
godot --headless --script debug/client.gd -- "return
_root.find_child('StartButton', true, false).name"

# ボタンのpressedシグナルを発火
godot --headless --script debug/client.gd -- "var btn =
_root.find_child('StartButton', true, false)
btn.pressed.emit()
return 'clicked'"

AIエージェントなどにこれらを任せる際に、よしなにCLIとして認識させるなり、Skillとして確立させておくと便利です。

---
name: executing-godot-code
description: 実行中のGodotゲームに任意コードを実行して検証
を行う。UI操作、状態確認、ノード探索が可能。「ボタンを押し
て」「Labelのテキストを確認して」などのリクエストで使用。
---

# Godotコード実行

実行中のGodotゲームに対してコード実行・状態確認を行う。

## 前提条件

- ゲームが起動済みで、TCPサーバーが待ち受け中
- `hogehoge/debug/client.gd` が配置済み

## コマンド形式

godot --headless --script hogehoge/debug/client.gd -- "<code>"

コード内で使用可能な変数:
- _root: シーンツリーのルートノード

コード実行パターン

ノード検索

var node = _root.find_child("NodeName", true, false)
return node != null

ボタン押下

var btn = _root.find_child("StartButton", true, false)
btn.pressed.emit()
return "ok"

プロパティ確認

var label = _root.find_child("ScoreLabel", true, false)
return label.text

子ノード一覧

var parent = _root.find_child("Container", true, false)
return parent.get_children().map(func(n): return n.name)

実際の内容はプロジェクトの用途や構造に合わせて調整してください。 自動でシーンの調査や処理を、動的生成するコードをAI側で考えて構築し、実行してくれます。

リリースビルド用の管理

当然ですが、この外部からコードを実行できる仕組みはリリースしちゃまずいです。フィルタ機能などを使ってセキュリティ管理として除外してください。エクスポートのプリセットで除外するディレクトリを決めて、そこに入れておくとかが手軽です。

まとめ

GDScript.set_source_code()とreload()を組み合わせることで、実行時に任意のGDScriptコードを動的に生成・実行できます。外部からゲーム内操作が可能になると、AIエージェントがゲームエンジンを自由に操作することが可能になり、検証、調査、開発の行動が迅速になります。筆者はWebSocketサーバーをたててこの仕組みを運用し、シーンの操作やログの検証、実行検証をしたりしているので、ぜひ試してみてください。