Godot AI Bridge

This commit is contained in:
torsten 2026-10-28 07:59:55 +01:00
parent 6086bcf59e
commit b3339db681
6 changed files with 1938 additions and 0 deletions

6
weltenauge/AGENTS.md Normal file
View file

@ -0,0 +1,6 @@
# Godot 4.7 Entwicklungs-Regeln
- Nutze striktes Static Typing in GDScript (`var speed: float = 100.0`).
- Bevorzuge `@onready var` für Knoten-Referenzen.
- Nutze das `godot`-MCP-Tool, um Szenenhierarchien zu prüfen, bevor du Knotenpfade im Code anpasst.
- Spiele-Logik immer in dedizierten Komponenten/Nodes kapseln.

View file

@ -0,0 +1,341 @@
@tool
extends EditorPlugin
## Main plugin for Godot AI Bridge
const WSServer = preload("res://addons/godot_ai_bridge/ws_server.gd")
const MessageHandler = preload("res://addons/godot_ai_bridge/message_handler.gd")
const RUNTIME_AUTOLOAD_NAME := "GodotAIBridgeRuntime"
const RUNTIME_AUTOLOAD_PATH := "res://addons/godot_ai_bridge/runtime_bridge.gd"
var _ws_server: Node
var _message_handler: RefCounted
var _debugger_plugin: AIBridgeDebuggerPlugin
var _port: int = 6550
const PAIRING_PATH := "res://.godot/godot-ai-bridge.json"
func _enter_tree() -> void:
_ensure_runtime_autoload()
_message_handler = MessageHandler.new()
_message_handler.editor_interface = get_editor_interface()
_message_handler.undo_redo = get_undo_redo()
# Capture runtime output, errors, and automation responses.
_debugger_plugin = AIBridgeDebuggerPlugin.new()
_debugger_plugin.message_handler = _message_handler
_message_handler.debugger_plugin = _debugger_plugin
add_debugger_plugin(_debugger_plugin)
_ws_server = WSServer.new()
_ws_server.port = _port
_ws_server.pairing_token = _create_pairing_token()
if _ws_server.pairing_token.is_empty():
push_error("[AI Bridge] Unable to create private pairing credentials; server disabled.")
return
_ws_server.message_received.connect(_on_message_received)
_ws_server.client_connected.connect(_on_client_connected)
_ws_server.client_disconnected.connect(_on_client_disconnected)
add_child(_ws_server)
var err = _ws_server.start()
if err == OK:
print("[AI Bridge] Server started on port ", _port)
else:
push_error("[AI Bridge] Failed to start server: " + str(err))
func _exit_tree() -> void:
if _debugger_plugin:
remove_debugger_plugin(_debugger_plugin)
_debugger_plugin = null
if _ws_server:
_ws_server.stop()
_ws_server.queue_free()
_ws_server = null
_message_handler = null
func _create_pairing_token() -> String:
var bytes := Crypto.new().generate_random_bytes(32)
if bytes.size() != 32:
return ""
var token := bytes.hex_encode()
var absolute_path := ProjectSettings.globalize_path(PAIRING_PATH)
var mkdir_error := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir())
if mkdir_error != OK and mkdir_error != ERR_ALREADY_EXISTS:
return ""
var directory := DirAccess.open(absolute_path.get_base_dir())
if directory == null or directory.is_link(absolute_path.get_file()):
return ""
# Create an empty file, restrict access, then write the secret. Never log it.
var file := FileAccess.open(absolute_path, FileAccess.WRITE)
if file == null:
return ""
# Editor safe-save writes through a temporary file until close(). Restrict
# the resulting empty file before reopening it without replacement.
file.close()
if OS.get_name() != "Windows":
if FileAccess.set_unix_permissions(absolute_path, 384) != OK:
return ""
file = FileAccess.open(absolute_path, FileAccess.READ_WRITE)
if file == null:
return ""
file.store_string(JSON.stringify({"token": token, "port": _port, "version": 1}))
file.close()
return token
func _ensure_runtime_autoload() -> void:
var autoload_key := "autoload/%s" % RUNTIME_AUTOLOAD_NAME
var existing_value := str(ProjectSettings.get_setting(autoload_key, ""))
var normalized_existing := existing_value.trim_prefix("*")
if normalized_existing.is_empty():
add_autoload_singleton(RUNTIME_AUTOLOAD_NAME, RUNTIME_AUTOLOAD_PATH)
# A separately launched runtime reads project.godot immediately.
if ProjectSettings.save() != OK:
push_error("[AI Bridge] Failed to persist runtime autoload configuration.")
return
if normalized_existing != RUNTIME_AUTOLOAD_PATH:
push_warning(
"[AI Bridge] Autoload name conflict for %s: %s"
% [RUNTIME_AUTOLOAD_NAME, existing_value]
)
func _on_message_received(peer_id: int, message: String) -> void:
call_deferred("_send_response_async", peer_id, message)
func _send_response_async(peer_id: int, message: String) -> void:
var response: String = await _message_handler.handle_message(message)
if _ws_server:
_ws_server.send_message(peer_id, response)
func _on_client_connected(peer_id: int) -> void:
print("[AI Bridge] Client connected: ", peer_id)
func _on_client_disconnected(peer_id: int) -> void:
print("[AI Bridge] Client disconnected: ", peer_id)
## Debugger plugin to capture output, errors, and runtime automation responses.
class AIBridgeDebuggerPlugin extends EditorDebuggerPlugin:
var message_handler: RefCounted
const FILTERED_MESSAGES := [
"game_view:cursor_set_shape",
"game_view:mouse_over",
]
const RUNTIME_PREFIX := "godot_runtime"
const DEFAULT_RUNTIME_TIMEOUT_SEC := 5.0
var _tracked_session_ids: Array[int] = []
var _session_runtime_ready: Dictionary = {}
var _pending_runtime_requests: Dictionary = {}
var _next_runtime_request_id: int = 1
func _has_capture(_prefix: String) -> bool:
return true
func _capture(message: String, data: Array, session_id: int) -> bool:
if not message_handler:
return false
match message:
"%s:ready" % RUNTIME_PREFIX:
_handle_runtime_ready(data, session_id)
return true
"%s:response" % RUNTIME_PREFIX:
_handle_runtime_response(data, session_id)
return true
for filtered in FILTERED_MESSAGES:
if message == filtered:
return false
# Ordinary engine output/error/stack packets are consumed by Godot before
# plugin capture. MessageHandler reads the configured runtime log instead.
return false
func send_runtime_request(
method: String,
params: Dictionary,
timeout_seconds: float = DEFAULT_RUNTIME_TIMEOUT_SEC
) -> Dictionary:
var session_id := _get_active_session_id()
if session_id == -1:
return {
"error": {
"code": -32603,
"message": "No active runtime debug session. Run a scene before using runtime automation tools."
}
}
var session = get_session(session_id)
if not session:
return {
"error": {
"code": -32603,
"message": "Failed to access the active runtime debug session."
}
}
var request_id := _next_runtime_request_id
_next_runtime_request_id += 1
_pending_runtime_requests[request_id] = {
"ready": false,
"payload": {},
"session_id": session_id
}
session.send_message("%s:request" % RUNTIME_PREFIX, [request_id, method, params])
var deadline := Time.get_ticks_msec() + int(max(timeout_seconds, 0.1) * 1000.0)
while Time.get_ticks_msec() < deadline:
var pending = _pending_runtime_requests.get(request_id)
if pending is Dictionary and bool(pending.get("ready", false)):
_pending_runtime_requests.erase(request_id)
return _coerce_runtime_reply(pending.get("payload", {}), method)
await Engine.get_main_loop().process_frame
_pending_runtime_requests.erase(request_id)
return {
"error": {
"code": -32603,
"message": (
"Timed out waiting for runtime automation response for %s. "
+ "Make sure the scene is running and the runtime harness is loaded."
) % method
}
}
func _setup_session(session_id: int) -> void:
if not _tracked_session_ids.has(session_id):
_tracked_session_ids.append(session_id)
_session_runtime_ready[session_id] = false
var session = get_session(session_id)
if not session:
return
var started_callable := Callable(self, "_on_session_started").bind(session_id)
if not session.started.is_connected(started_callable):
session.started.connect(started_callable)
var stopped_callable := Callable(self, "_on_session_stopped").bind(session_id)
if not session.stopped.is_connected(stopped_callable):
session.stopped.connect(stopped_callable)
if session.is_active():
_on_session_started(session_id)
func _on_session_started(session_id: int) -> void:
_session_runtime_ready[session_id] = false
if message_handler:
message_handler.begin_runtime_log()
message_handler.log_output(
"[DEBUG] Game started (session %d)" % session_id,
"info",
"debugger"
)
func _on_session_stopped(session_id: int) -> void:
_session_runtime_ready.erase(session_id)
_fail_pending_requests_for_session(
session_id,
"Runtime debug session stopped before the automation request completed."
)
if message_handler:
message_handler.end_runtime_log()
message_handler.log_output(
"[DEBUG] Game stopped (session %d)" % session_id,
"info",
"debugger"
)
func _get_active_session_id() -> int:
for i in range(_tracked_session_ids.size() - 1, -1, -1):
var session_id = _tracked_session_ids[i]
var session = get_session(session_id)
if session and session.is_active():
return session_id
return -1
func _handle_runtime_ready(data: Array, session_id: int) -> void:
_session_runtime_ready[session_id] = true
var details := ""
if data.size() > 0 and data[0] is Dictionary:
var payload: Dictionary = data[0]
var scene_root := str(payload.get("scene_root", ""))
if not scene_root.is_empty():
details = " for %s" % scene_root
if message_handler:
message_handler.log_output(
"[RUNTIME] Automation harness ready%s (session %d)" % [details, session_id],
"info",
"debugger"
)
func _handle_runtime_response(data: Array, session_id: int) -> void:
if data.size() < 2:
if message_handler:
message_handler.log_output(
"[RUNTIME] Ignoring malformed runtime response for session %d" % session_id,
"warning",
"debugger"
)
return
var request_id := int(data[0])
if not _pending_runtime_requests.has(request_id):
return
var pending: Dictionary = _pending_runtime_requests[request_id]
pending["ready"] = true
pending["payload"] = data[1]
pending["session_id"] = session_id
_pending_runtime_requests[request_id] = pending
func _coerce_runtime_reply(payload, method: String) -> Dictionary:
if payload is Dictionary:
var payload_dict: Dictionary = payload
if bool(payload_dict.get("success", false)):
return {"result": payload_dict.get("result", {})}
return {
"error": {
"code": -32603,
"message": str(
payload_dict.get(
"error",
"Runtime automation request failed for " + method
)
)
}
}
return {"result": payload}
func _fail_pending_requests_for_session(session_id: int, error_message: String) -> void:
var pending_ids := _pending_runtime_requests.keys()
for request_id in pending_ids:
var pending: Dictionary = _pending_runtime_requests.get(request_id, {})
if int(pending.get("session_id", -1)) != session_id:
continue
pending["ready"] = true
pending["payload"] = {
"success": false,
"error": error_message
}
_pending_runtime_requests[request_id] = pending

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
[plugin]
name="Godot AI Bridge"
description="WebSocket server enabling AI assistants (like Claude) to control the Godot editor"
author="genai-gametools"
version="0.1.0"
script="godot_ai_bridge.gd"

View file

@ -0,0 +1,344 @@
extends Node
## Runtime automation harness for the Godot AI Bridge.
const MESSAGE_PREFIX := "godot_runtime"
var _capture_callable: Callable
var _mouse_position: Vector2 = Vector2.ZERO
var _mouse_button_mask: int = 0
func _enter_tree() -> void:
if not EngineDebugger.is_active():
return
_capture_callable = Callable(self, "_capture_debugger_message")
EngineDebugger.register_message_capture(MESSAGE_PREFIX, _capture_callable)
call_deferred("_announce_ready")
func _exit_tree() -> void:
if _capture_callable.is_valid():
EngineDebugger.unregister_message_capture(MESSAGE_PREFIX)
_capture_callable = Callable()
func _announce_ready() -> void:
if EngineDebugger.is_active():
EngineDebugger.send_message("%s:ready" % MESSAGE_PREFIX, [_build_status_payload()])
func _capture_debugger_message(message: String, data: Array) -> bool:
if message != "request":
return false
if data.size() < 3:
_send_response(-1, {"success": false, "error": "Invalid runtime request payload."})
return true
var request_id := int(data[0])
var method := str(data[1])
var params: Dictionary = data[2] if data[2] is Dictionary else {}
call_deferred("_process_request_async", request_id, method, params)
return true
func _process_request_async(request_id: int, method: String, params: Dictionary) -> void:
var response: Dictionary
match method:
"status":
response = {"success": true, "result": _build_status_payload()}
"wait":
response = {"success": true, "result": await _handle_wait(params)}
"press_action":
response = {"success": true, "result": await _handle_press_action(params)}
"release_action":
response = {"success": true, "result": await _handle_release_action(params)}
"tap_action":
response = {"success": true, "result": await _handle_tap_action(params)}
"mouse_move":
response = {"success": true, "result": await _handle_mouse_move(params)}
"click":
response = {"success": true, "result": await _handle_click(params)}
"type_text":
response = {"success": true, "result": await _handle_type_text(params)}
"capture_screenshot":
response = await _handle_capture_screenshot(params)
_:
response = {
"success": false,
"error": "Unknown runtime automation method: " + method
}
_send_response(request_id, response)
func _handle_wait(params: Dictionary) -> Dictionary:
var frames := max(0, int(params.get("frames", 0)))
var seconds := max(0.0, float(params.get("seconds", 0.0)))
if frames == 0 and seconds <= 0.0:
frames = 1
for _i in range(frames):
await get_tree().process_frame
if seconds > 0.0:
await get_tree().create_timer(seconds).timeout
return {
"frames": frames,
"seconds": seconds
}
func _handle_press_action(params: Dictionary) -> Dictionary:
var action := _require_non_empty_string(params, "action")
var strength := float(params.get("strength", 1.0))
_push_action_event(action, true, strength)
await get_tree().process_frame
return {
"action": action,
"pressed": true,
"strength": strength
}
func _handle_release_action(params: Dictionary) -> Dictionary:
var action := _require_non_empty_string(params, "action")
_push_action_event(action, false, 0.0)
await get_tree().process_frame
return {
"action": action,
"pressed": false
}
func _handle_tap_action(params: Dictionary) -> Dictionary:
var action := _require_non_empty_string(params, "action")
var frames := max(1, int(params.get("frames", 1)))
var strength := float(params.get("strength", 1.0))
_push_action_event(action, true, strength)
for _i in range(frames):
await get_tree().process_frame
_push_action_event(action, false, 0.0)
await get_tree().process_frame
return {
"action": action,
"frames": frames,
"strength": strength
}
func _handle_mouse_move(params: Dictionary) -> Dictionary:
var target := _require_position(params)
await _push_mouse_motion(target)
return {
"position": _vector2_dict(_mouse_position)
}
func _handle_click(params: Dictionary) -> Dictionary:
var button := int(params.get("button", MOUSE_BUTTON_LEFT))
var hold_frames := max(1, int(params.get("holdFrames", 1)))
if params.has("x") or params.has("y"):
var target := _require_position(params)
await _push_mouse_motion(target)
var click_position := _mouse_position
await _push_mouse_button(click_position, button, true)
for _i in range(hold_frames):
await get_tree().process_frame
await _push_mouse_button(click_position, button, false)
return {
"button": button,
"hold_frames": hold_frames,
"position": _vector2_dict(click_position)
}
func _handle_type_text(params: Dictionary) -> Dictionary:
var text := _require_non_empty_string(params, "text")
var root := _get_root_viewport()
root.push_text_input(text)
await get_tree().process_frame
return {
"text": text,
"focused_control": _focused_control_path()
}
func _handle_capture_screenshot(params: Dictionary) -> Dictionary:
var raw_path := _require_non_empty_string(params, "path")
var output_path := _resolve_output_path(raw_path)
var output_dir := output_path.get_base_dir()
if not output_dir.is_empty():
var dir_err := DirAccess.make_dir_recursive_absolute(output_dir)
if dir_err != OK and dir_err != ERR_ALREADY_EXISTS:
return {
"success": false,
"error": "Failed to create screenshot directory: " + output_dir
}
await RenderingServer.frame_post_draw
var root := _get_root_viewport()
var image := root.get_texture().get_image()
if image == null:
return {
"success": false,
"error": "Failed to capture screenshot image from the root viewport."
}
var save_err := image.save_png(output_path)
if save_err != OK:
return {
"success": false,
"error": "Failed to save screenshot: " + error_string(save_err)
}
return {
"success": true,
"result": {
"path": output_path,
"width": image.get_width(),
"height": image.get_height()
}
}
func _push_action_event(action: String, pressed: bool, strength: float) -> void:
var event := InputEventAction.new()
event.action = action
event.pressed = pressed
event.strength = strength if pressed else 0.0
Input.parse_input_event(event)
func _push_mouse_motion(target: Vector2) -> void:
var root := _get_root_viewport()
var event := InputEventMouseMotion.new()
event.position = target
event.global_position = target
event.relative = target - _mouse_position
event.velocity = Vector2.ZERO
event.button_mask = _mouse_button_mask
root.push_input(event, true)
_mouse_position = target
await get_tree().process_frame
func _push_mouse_button(target: Vector2, button: int, pressed: bool) -> void:
var root := _get_root_viewport()
var mask := _button_mask_for(button)
var next_mask := _mouse_button_mask
if pressed:
next_mask |= mask
else:
next_mask &= ~mask
var event := InputEventMouseButton.new()
event.position = target
event.global_position = target
event.button_index = button
event.pressed = pressed
event.button_mask = next_mask
root.push_input(event, true)
_mouse_button_mask = next_mask
_mouse_position = target
await get_tree().process_frame
func _button_mask_for(button: int) -> int:
match button:
MOUSE_BUTTON_LEFT:
return MOUSE_BUTTON_MASK_LEFT
MOUSE_BUTTON_RIGHT:
return MOUSE_BUTTON_MASK_RIGHT
MOUSE_BUTTON_MIDDLE:
return MOUSE_BUTTON_MASK_MIDDLE
MOUSE_BUTTON_XBUTTON1:
return MOUSE_BUTTON_MASK_MB_XBUTTON1
MOUSE_BUTTON_XBUTTON2:
return MOUSE_BUTTON_MASK_MB_XBUTTON2
_:
return 0
func _get_root_viewport() -> Viewport:
var tree := get_tree()
if tree == null or tree.root == null:
push_error("[AI Bridge] Runtime automation requires an active scene tree root viewport.")
return null
return tree.root
func _build_status_payload() -> Dictionary:
var root := _get_root_viewport()
var current_scene := get_tree().current_scene
var focused_control := _focused_control_path()
return {
"available": true,
"scene_root": current_scene.name if current_scene else "",
"scene_path": current_scene.scene_file_path if current_scene else "",
"viewport_size": _vector2_dict(root.get_visible_rect().size) if root else _vector2_dict(Vector2.ZERO),
"mouse_position": _vector2_dict(_mouse_position),
"focused_control": focused_control
}
func _focused_control_path() -> String:
var root := _get_root_viewport()
if root == null:
return ""
var focused = root.gui_get_focus_owner()
if focused:
return str(focused.get_path())
return ""
func _require_non_empty_string(params: Dictionary, key: String) -> String:
var value := str(params.get(key, "")).strip_edges()
if value.is_empty():
push_error("[AI Bridge] Missing required runtime parameter: " + key)
return value
func _require_position(params: Dictionary) -> Vector2:
var has_x := params.has("x")
var has_y := params.has("y")
if not has_x or not has_y:
push_error("[AI Bridge] Runtime position requires both x and y values.")
return _mouse_position
return Vector2(float(params.get("x", _mouse_position.x)), float(params.get("y", _mouse_position.y)))
func _resolve_output_path(raw_path: String) -> String:
var path := raw_path.strip_edges()
if path.begins_with("res://") or path.begins_with("user://"):
return ProjectSettings.globalize_path(path)
if path.begins_with("/") or path.contains(":/"):
return path
return ProjectSettings.globalize_path("res://" + path)
func _vector2_dict(value: Vector2) -> Dictionary:
return {
"x": value.x,
"y": value.y
}
func _send_response(request_id: int, payload: Dictionary) -> void:
if EngineDebugger.is_active():
EngineDebugger.send_message("%s:response" % MESSAGE_PREFIX, [request_id, payload])

View file

@ -0,0 +1,112 @@
@tool
extends Node
## WebSocket server for AI Bridge
signal message_received(peer_id: int, message: String)
signal client_connected(peer_id: int)
signal client_disconnected(peer_id: int)
var port: int = 6550
var pairing_token: String = ""
const PROTOCOL_PREFIX := "godot-ai-bridge."
var _tcp_server: TCPServer
var _ws_peers: Dictionary = {} # peer_id -> {peer: WebSocketPeer, stream: StreamPeerTCP}
var _timer: Timer
func _ready() -> void:
pass
func start() -> Error:
if pairing_token.length() != 64:
return ERR_UNAUTHORIZED
_tcp_server = TCPServer.new()
var err = _tcp_server.listen(port, "127.0.0.1")
if err != OK:
return err
_timer = Timer.new()
_timer.wait_time = 0.016
_timer.timeout.connect(_poll)
add_child(_timer)
_timer.start()
print("[AI Bridge] Timer started for polling")
return OK
func stop() -> void:
if _timer:
_timer.stop()
_timer.queue_free()
_timer = null
for peer_id in _ws_peers.keys():
_ws_peers[peer_id]["peer"].close()
_ws_peers.clear()
if _tcp_server:
_tcp_server.stop()
_tcp_server = null
func _poll() -> void:
# Accept new TCP connections and immediately create WebSocketPeer
while _tcp_server and _tcp_server.is_connection_available():
var stream = _tcp_server.take_connection()
if stream:
print("[AI Bridge] New TCP connection!")
var ws_peer = WebSocketPeer.new()
ws_peer.supported_protocols = PackedStringArray([PROTOCOL_PREFIX + pairing_token])
# Let WebSocketPeer handle the handshake itself
var err = ws_peer.accept_stream(stream)
if err == OK:
var peer_id = stream.get_instance_id()
_ws_peers[peer_id] = {"peer": ws_peer, "stream": stream, "connected": false}
print("[AI Bridge] WebSocket peer created, waiting for handshake...")
else:
print("[AI Bridge] Failed to accept stream: ", err)
# Process WebSocket peers
var to_remove: Array = []
for peer_id in _ws_peers.keys():
var data = _ws_peers[peer_id]
var peer: WebSocketPeer = data["peer"]
peer.poll()
var state = peer.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
if peer.get_selected_protocol() != PROTOCOL_PREFIX + pairing_token:
peer.close(1008, "Pairing required")
continue
if not data["connected"]:
data["connected"] = true
print("[AI Bridge] Client connected: ", peer_id)
client_connected.emit(peer_id)
while peer.get_available_packet_count() > 0:
var packet = peer.get_packet()
var msg = packet.get_string_from_utf8()
message_received.emit(peer_id, msg)
elif state == WebSocketPeer.STATE_CLOSED:
print("[AI Bridge] Client disconnected: ", peer_id)
to_remove.append(peer_id)
client_disconnected.emit(peer_id)
for peer_id in to_remove:
_ws_peers.erase(peer_id)
func send_message(peer_id: int, message: String) -> Error:
if not _ws_peers.has(peer_id):
return ERR_DOES_NOT_EXIST
var peer: WebSocketPeer = _ws_peers[peer_id]["peer"]
if peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
return ERR_CONNECTION_ERROR
return peer.send_text(message)