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