refactor: 拆分 claude-dev-stack 为 windows-dev-stack 和 wsl-dev-stack
将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
## Editor-side commands for runtime game inspection.
|
||||
## Communicates with MCPGameInspector autoload via file-based IPC.
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_game_scene_tree": _get_game_scene_tree,
|
||||
"get_game_node_properties": _get_game_node_properties,
|
||||
"set_game_node_property": _set_game_node_property,
|
||||
"capture_frames": _capture_frames,
|
||||
"monitor_properties": _monitor_properties,
|
||||
"execute_game_script": _execute_game_script,
|
||||
"start_recording": _start_recording,
|
||||
"stop_recording": _stop_recording,
|
||||
"replay_recording": _replay_recording,
|
||||
"find_nodes_by_script": _find_nodes_by_script,
|
||||
"get_autoload": _get_autoload,
|
||||
"batch_get_properties": _batch_get_properties,
|
||||
"find_ui_elements": _find_ui_elements,
|
||||
"click_button_by_text": _click_button_by_text,
|
||||
"wait_for_node": _wait_for_node,
|
||||
"find_nearby_nodes": _find_nearby_nodes,
|
||||
"navigate_to": _navigate_to,
|
||||
"move_to": _move_to,
|
||||
"watch_signals": _watch_signals,
|
||||
}
|
||||
|
||||
|
||||
func _get_game_scene_tree(params: Dictionary) -> Dictionary:
|
||||
var max_depth: int = optional_int(params, "max_depth", -1)
|
||||
var cmd_params := {"max_depth": max_depth}
|
||||
|
||||
var script_filter: String = optional_string(params, "script_filter")
|
||||
if not script_filter.is_empty():
|
||||
cmd_params["script_filter"] = script_filter
|
||||
|
||||
var type_filter: String = optional_string(params, "type_filter")
|
||||
if not type_filter.is_empty():
|
||||
cmd_params["type_filter"] = type_filter
|
||||
|
||||
var named_only: bool = optional_bool(params, "named_only", false)
|
||||
if named_only:
|
||||
cmd_params["named_only"] = true
|
||||
|
||||
return await _send_game_command("get_scene_tree", cmd_params)
|
||||
|
||||
|
||||
func _get_game_node_properties(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"node_path": result[0]}
|
||||
# Optional property filter
|
||||
if params.has("properties") and params["properties"] is Array:
|
||||
cmd_params["properties"] = params["properties"]
|
||||
|
||||
return await _send_game_command("get_node_properties", cmd_params)
|
||||
|
||||
|
||||
func _set_game_node_property(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var prop_result := require_string(params, "property")
|
||||
if prop_result[1] != null:
|
||||
return prop_result[1]
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
|
||||
return await _send_game_command("set_node_property", {
|
||||
"node_path": result[0],
|
||||
"property": prop_result[0],
|
||||
"value": params["value"],
|
||||
})
|
||||
|
||||
|
||||
func _execute_game_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "code")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
return await _send_game_command("execute_script", {
|
||||
"code": result[0],
|
||||
}, 10.0)
|
||||
|
||||
|
||||
func _capture_frames(params: Dictionary) -> Dictionary:
|
||||
var count: int = optional_int(params, "count", 5)
|
||||
var frame_interval: int = optional_int(params, "frame_interval", 10)
|
||||
var half_resolution: bool = optional_bool(params, "half_resolution", true)
|
||||
|
||||
# Dynamic timeout: allow enough time for frame capture
|
||||
# At 60fps, 30 frames * 10 interval = 300 frames = 5 seconds + overhead
|
||||
var estimated_seconds: float = (count * frame_interval) / 60.0 + 2.0
|
||||
var timeout := minf(estimated_seconds, 25.0)
|
||||
|
||||
return await _send_game_command("capture_frames", {
|
||||
"count": count,
|
||||
"frame_interval": frame_interval,
|
||||
"half_resolution": half_resolution,
|
||||
}, timeout)
|
||||
|
||||
|
||||
func _monitor_properties(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
if not params.has("properties") or not params["properties"] is Array:
|
||||
return error_invalid_params("'properties' array is required")
|
||||
|
||||
var frame_count: int = optional_int(params, "frame_count", 60)
|
||||
var frame_interval: int = optional_int(params, "frame_interval", 1)
|
||||
|
||||
# Dynamic timeout
|
||||
var estimated_seconds: float = (frame_count * frame_interval) / 60.0 + 2.0
|
||||
var timeout := minf(estimated_seconds, 25.0)
|
||||
|
||||
return await _send_game_command("monitor_properties", {
|
||||
"node_path": result[0],
|
||||
"properties": params["properties"],
|
||||
"frame_count": frame_count,
|
||||
"frame_interval": frame_interval,
|
||||
}, timeout)
|
||||
|
||||
|
||||
func _start_recording(params: Dictionary) -> Dictionary:
|
||||
return await _send_game_command("start_recording", {})
|
||||
|
||||
|
||||
func _stop_recording(params: Dictionary) -> Dictionary:
|
||||
return await _send_game_command("stop_recording", {}, 5.0)
|
||||
|
||||
|
||||
func _replay_recording(params: Dictionary) -> Dictionary:
|
||||
if not params.has("events") or not params["events"] is Array:
|
||||
return error_invalid_params("'events' array is required")
|
||||
var speed: float = float(params.get("speed", 1.0))
|
||||
|
||||
# Calculate timeout based on event duration
|
||||
var max_time_ms: int = 0
|
||||
for event_data: Dictionary in params["events"]:
|
||||
var t: int = int(event_data.get("time_ms", 0))
|
||||
if t > max_time_ms:
|
||||
max_time_ms = t
|
||||
var timeout := (max_time_ms / 1000.0 / speed) + 5.0
|
||||
|
||||
return await _send_game_command("replay_recording", {
|
||||
"events": params["events"],
|
||||
"speed": speed,
|
||||
}, minf(timeout, 120.0))
|
||||
|
||||
|
||||
func _find_nodes_by_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "script")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"script": result[0]}
|
||||
if params.has("properties") and params["properties"] is Array:
|
||||
cmd_params["properties"] = params["properties"]
|
||||
|
||||
return await _send_game_command("find_nodes_by_script", cmd_params)
|
||||
|
||||
|
||||
func _get_autoload(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "name")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"name": result[0]}
|
||||
if params.has("properties") and params["properties"] is Array:
|
||||
cmd_params["properties"] = params["properties"]
|
||||
|
||||
return await _send_game_command("get_autoload", cmd_params)
|
||||
|
||||
|
||||
func _batch_get_properties(params: Dictionary) -> Dictionary:
|
||||
if not params.has("nodes") or not params["nodes"] is Array:
|
||||
return error_invalid_params("'nodes' array is required")
|
||||
|
||||
return await _send_game_command("batch_get_properties", {
|
||||
"nodes": params["nodes"],
|
||||
})
|
||||
|
||||
|
||||
func _find_ui_elements(params: Dictionary) -> Dictionary:
|
||||
var cmd_params := {}
|
||||
var type_filter: String = optional_string(params, "type_filter")
|
||||
if not type_filter.is_empty():
|
||||
cmd_params["type_filter"] = type_filter
|
||||
return await _send_game_command("find_ui_elements", cmd_params)
|
||||
|
||||
|
||||
func _click_button_by_text(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "text")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"text": result[0]}
|
||||
var partial: bool = optional_bool(params, "partial", true)
|
||||
cmd_params["partial"] = partial
|
||||
|
||||
return await _send_game_command("click_button_by_text", cmd_params)
|
||||
|
||||
|
||||
func _wait_for_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var timeout: float = float(params.get("timeout", 5.0))
|
||||
var poll_frames: int = optional_int(params, "poll_frames", 5)
|
||||
|
||||
return await _send_game_command("wait_for_node", {
|
||||
"node_path": result[0],
|
||||
"timeout": timeout,
|
||||
"poll_frames": poll_frames,
|
||||
}, timeout + 2.0)
|
||||
|
||||
|
||||
func _find_nearby_nodes(params: Dictionary) -> Dictionary:
|
||||
if not params.has("position"):
|
||||
return error_invalid_params("Missing required parameter: position")
|
||||
|
||||
var cmd_params: Dictionary = {"position": params["position"]}
|
||||
if params.has("radius"):
|
||||
cmd_params["radius"] = float(params["radius"])
|
||||
var type_filter: String = optional_string(params, "type_filter")
|
||||
if not type_filter.is_empty():
|
||||
cmd_params["type_filter"] = type_filter
|
||||
var group_filter: String = optional_string(params, "group_filter")
|
||||
if not group_filter.is_empty():
|
||||
cmd_params["group_filter"] = group_filter
|
||||
if params.has("max_results"):
|
||||
cmd_params["max_results"] = int(params["max_results"])
|
||||
|
||||
return await _send_game_command("find_nearby_nodes", cmd_params)
|
||||
|
||||
|
||||
func _navigate_to(params: Dictionary) -> Dictionary:
|
||||
if not params.has("target"):
|
||||
return error_invalid_params("Missing required parameter: target")
|
||||
|
||||
var cmd_params: Dictionary = {"target": params["target"]}
|
||||
var player_path: String = optional_string(params, "player_path")
|
||||
if not player_path.is_empty():
|
||||
cmd_params["player_path"] = player_path
|
||||
var camera_path: String = optional_string(params, "camera_path")
|
||||
if not camera_path.is_empty():
|
||||
cmd_params["camera_path"] = camera_path
|
||||
if params.has("move_speed"):
|
||||
cmd_params["move_speed"] = float(params["move_speed"])
|
||||
|
||||
return await _send_game_command("navigate_to", cmd_params)
|
||||
|
||||
|
||||
func _move_to(params: Dictionary) -> Dictionary:
|
||||
if not params.has("target"):
|
||||
return error_invalid_params("Missing required parameter: target")
|
||||
|
||||
var cmd_params: Dictionary = {"target": params["target"]}
|
||||
var player_path: String = optional_string(params, "player_path")
|
||||
if not player_path.is_empty():
|
||||
cmd_params["player_path"] = player_path
|
||||
var camera_path: String = optional_string(params, "camera_path")
|
||||
if not camera_path.is_empty():
|
||||
cmd_params["camera_path"] = camera_path
|
||||
if params.has("arrival_radius"):
|
||||
cmd_params["arrival_radius"] = float(params["arrival_radius"])
|
||||
if params.has("timeout"):
|
||||
cmd_params["timeout"] = float(params["timeout"])
|
||||
if params.has("run"):
|
||||
cmd_params["run"] = bool(params["run"])
|
||||
if params.has("look_at_target"):
|
||||
cmd_params["look_at_target"] = bool(params["look_at_target"])
|
||||
|
||||
# Dynamic timeout: game-side timeout + overhead for IPC polling
|
||||
var game_timeout: float = float(params.get("timeout", 15.0))
|
||||
var ipc_timeout: float = game_timeout + 5.0
|
||||
|
||||
return await _send_game_command("move_to", cmd_params, ipc_timeout)
|
||||
|
||||
|
||||
func _watch_signals(params: Dictionary) -> Dictionary:
|
||||
if not params.has("node_paths") or not params["node_paths"] is Array:
|
||||
return error_invalid_params("Missing required parameter: node_paths (Array)")
|
||||
|
||||
var cmd_params: Dictionary = {"node_paths": params["node_paths"]}
|
||||
if params.has("signal_filter") and params["signal_filter"] is Array:
|
||||
cmd_params["signal_filter"] = params["signal_filter"]
|
||||
var duration_ms: int = optional_int(params, "duration_ms", 5000)
|
||||
cmd_params["duration_ms"] = duration_ms
|
||||
|
||||
# Dynamic timeout: duration + overhead
|
||||
var timeout_sec: float = (duration_ms / 1000.0) + 5.0
|
||||
|
||||
return await _send_game_command("watch_signals", cmd_params, timeout_sec)
|
||||
|
||||
|
||||
# ── IPC Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _send_game_command(command: String, params: Dictionary, timeout_sec: float = 5.0) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
||||
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_game_request"
|
||||
var response_path := user_dir + "/mcp_game_response"
|
||||
|
||||
# Clean stale response
|
||||
if FileAccess.file_exists(response_path):
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
# Write request
|
||||
var request_data := JSON.stringify({"command": command, "params": params})
|
||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if req == null:
|
||||
return error_internal("Could not create game request file")
|
||||
req.store_string(request_data)
|
||||
req.close()
|
||||
|
||||
# Poll for response
|
||||
var attempts := int(timeout_sec / 0.1)
|
||||
while attempts > 0:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
# Check if game is still running
|
||||
if not ei.is_playing_scene():
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game stopped during command execution")
|
||||
attempts -= 1
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
# Try to auto-resume the debugger (runtime error may have paused the game)
|
||||
if ei.is_playing_scene():
|
||||
_try_debugger_continue()
|
||||
# Give the game a chance to recover and write a response
|
||||
for _retry in 20:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
|
||||
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
|
||||
})
|
||||
|
||||
# Read response
|
||||
var file := FileAccess.open(response_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Could not read game response file")
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
var parsed = JSON.parse_string(text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
return error_internal("Invalid response JSON from game")
|
||||
|
||||
if parsed.has("error"):
|
||||
return error(-32000, str(parsed["error"]))
|
||||
|
||||
return success(parsed)
|
||||
|
||||
|
||||
## Press the debugger "Continue" button to resume a paused game process.
|
||||
func _try_debugger_continue() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var queue: Array[Node] = [base]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
var inner: Array[Node] = [node]
|
||||
while not inner.is_empty():
|
||||
var n := inner.pop_front()
|
||||
if n is Button and n.tooltip_text == "Continue":
|
||||
n.emit_signal("pressed")
|
||||
push_warning("[MCP] Auto-resumed debugger after runtime error")
|
||||
return
|
||||
for c in n.get_children():
|
||||
inner.append(c)
|
||||
return
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
Reference in New Issue
Block a user