Godot AI Bridge
This commit is contained in:
parent
6086bcf59e
commit
b3339db681
6 changed files with 1938 additions and 0 deletions
6
weltenauge/AGENTS.md
Normal file
6
weltenauge/AGENTS.md
Normal 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.
|
||||||
341
weltenauge/godot_ai_bridge/godot_ai_bridge.gd
Normal file
341
weltenauge/godot_ai_bridge/godot_ai_bridge.gd
Normal 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
|
||||||
1128
weltenauge/godot_ai_bridge/message_handler.gd
Normal file
1128
weltenauge/godot_ai_bridge/message_handler.gd
Normal file
File diff suppressed because it is too large
Load diff
7
weltenauge/godot_ai_bridge/plugin.cfg
Normal file
7
weltenauge/godot_ai_bridge/plugin.cfg
Normal 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"
|
||||||
344
weltenauge/godot_ai_bridge/runtime_bridge.gd
Normal file
344
weltenauge/godot_ai_bridge/runtime_bridge.gd
Normal 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])
|
||||||
112
weltenauge/godot_ai_bridge/ws_server.gd
Normal file
112
weltenauge/godot_ai_bridge/ws_server.gd
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue