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,682 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_editor_errors": _get_editor_errors,
|
||||
"get_output_log": _get_output_log,
|
||||
"get_editor_screenshot": _get_editor_screenshot,
|
||||
"get_game_screenshot": _get_game_screenshot,
|
||||
"execute_editor_script": _execute_editor_script,
|
||||
"clear_output": _clear_output,
|
||||
"reload_plugin": _reload_plugin,
|
||||
"reload_project": _reload_project,
|
||||
"get_signals": _get_signals,
|
||||
"compare_screenshots": _compare_screenshots,
|
||||
"set_auto_dismiss": _set_auto_dismiss,
|
||||
"get_editor_camera": _get_editor_camera,
|
||||
"set_editor_camera": _set_editor_camera,
|
||||
}
|
||||
|
||||
|
||||
func _get_editor_errors(params: Dictionary) -> Dictionary:
|
||||
var errors: Array = []
|
||||
var max_lines: int = optional_int(params, "max_lines", 50)
|
||||
var base: Control = get_editor().get_base_control()
|
||||
|
||||
# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
|
||||
# This captures runtime errors, warnings, and print output
|
||||
var editor_log: Node = base.find_child("Output", true, false)
|
||||
if editor_log:
|
||||
var rtl: RichTextLabel = _find_rtl(editor_log)
|
||||
if rtl:
|
||||
var content: String = rtl.get_parsed_text()
|
||||
var lines: PackedStringArray = content.split("\n")
|
||||
var start: int = maxi(0, lines.size() - max_lines)
|
||||
for i in range(start, lines.size()):
|
||||
var line: String = lines[i]
|
||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
|
||||
errors.append(line.strip_edges())
|
||||
|
||||
# 2. Check the script editor for compile errors (red background lines)
|
||||
# These don't appear in the Output panel
|
||||
var script_errors: Array = []
|
||||
var script_editor: ScriptEditor = EditorInterface.get_script_editor()
|
||||
if script_editor:
|
||||
var current_script: Script = script_editor.get_current_script()
|
||||
var ce: CodeEdit = _find_code_edit(script_editor)
|
||||
if ce and current_script:
|
||||
var script_path: String = current_script.resource_path
|
||||
for i in range(ce.get_line_count()):
|
||||
var bg: Color = ce.get_line_background_color(i)
|
||||
if bg.r > 0.8 and bg.a > 0: # Red-ish background = error
|
||||
var line_text: String = ce.get_line(i).strip_edges()
|
||||
script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])
|
||||
|
||||
# 3. Read from script editor error/warning panels (GDScript analyzer messages)
|
||||
# Each open script editor has a VSplitContainer with two RichTextLabels:
|
||||
# child[1] = warnings panel, child[2] = errors panel
|
||||
var analyzer_errors: Array = []
|
||||
if script_editor:
|
||||
var open_editors: Array = script_editor.get_open_script_editors()
|
||||
var open_scripts: Array = script_editor.get_open_scripts()
|
||||
for ei in range(open_editors.size()):
|
||||
var editor_node: Node = open_editors[ei]
|
||||
var script_path: String = ""
|
||||
if ei < open_scripts.size() and open_scripts[ei] != null:
|
||||
script_path = (open_scripts[ei] as Resource).resource_path
|
||||
var vsplit: VSplitContainer = null
|
||||
for c in editor_node.get_children():
|
||||
if c is VSplitContainer:
|
||||
vsplit = c as VSplitContainer
|
||||
break
|
||||
if vsplit == null:
|
||||
continue
|
||||
var children: Array = vsplit.get_children()
|
||||
# child[1] = warnings panel (RichTextLabel)
|
||||
if children.size() > 1 and children[1] is RichTextLabel:
|
||||
var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
|
||||
if not text.is_empty():
|
||||
for line in text.split("\n"):
|
||||
var stripped: String = line.strip_edges()
|
||||
if stripped.is_empty() or stripped == "[Ignore]":
|
||||
continue
|
||||
# Remove leading "[Ignore]" prefix from warning lines
|
||||
stripped = stripped.trim_prefix("[Ignore]")
|
||||
var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
|
||||
analyzer_errors.append(prefix + stripped)
|
||||
# child[2] = errors panel (RichTextLabel)
|
||||
if children.size() > 2 and children[2] is RichTextLabel:
|
||||
var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
|
||||
if not text.is_empty():
|
||||
for line in text.split("\n"):
|
||||
var stripped: String = line.strip_edges()
|
||||
if stripped.is_empty():
|
||||
continue
|
||||
var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
|
||||
analyzer_errors.append(prefix + stripped)
|
||||
|
||||
# 4. Read from the debugger Errors tab (runtime errors/warnings)
|
||||
# Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
|
||||
var debugger_errors: Array = []
|
||||
var base2: Control = get_editor().get_base_control()
|
||||
if base2:
|
||||
var queue: Array[Node] = [base2]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
# Find TabContainer inside the debugger
|
||||
for child in node.get_children():
|
||||
if child is TabContainer:
|
||||
var tab_container := child as TabContainer
|
||||
for tab_idx in range(tab_container.get_tab_count()):
|
||||
var tab_control: Control = tab_container.get_tab_control(tab_idx)
|
||||
if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
|
||||
# Find Tree inside the Errors tab
|
||||
for vchild in tab_control.get_children():
|
||||
if vchild is Tree:
|
||||
var tree := vchild as Tree
|
||||
var root_item: TreeItem = tree.get_root()
|
||||
if root_item:
|
||||
var item: TreeItem = root_item.get_first_child()
|
||||
while item:
|
||||
var col0: String = item.get_text(0).strip_edges()
|
||||
var col1: String = item.get_text(1).strip_edges()
|
||||
if not col0.is_empty() or not col1.is_empty():
|
||||
var msg: String = col0
|
||||
if not col1.is_empty():
|
||||
msg += " " + col1 if not msg.is_empty() else col1
|
||||
debugger_errors.append("DEBUGGER: " + msg)
|
||||
# Also check child items (expanded error details)
|
||||
var sub: TreeItem = item.get_first_child()
|
||||
while sub:
|
||||
var sub0: String = sub.get_text(0).strip_edges()
|
||||
var sub1: String = sub.get_text(1).strip_edges()
|
||||
if not sub0.is_empty() or not sub1.is_empty():
|
||||
var sub_msg: String = sub0
|
||||
if not sub1.is_empty():
|
||||
sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
|
||||
debugger_errors.append("DEBUGGER: " + sub_msg)
|
||||
sub = sub.get_next()
|
||||
item = item.get_next()
|
||||
break # Found Errors tab, stop searching tabs
|
||||
break # Found TabContainer, stop searching debugger children
|
||||
break # Found ScriptEditorDebugger, stop BFS
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
|
||||
# Fallback: read from log file if Output panel not accessible
|
||||
if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
|
||||
var log_path := "user://logs/godot.log"
|
||||
if FileAccess.file_exists(log_path):
|
||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
||||
if file:
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
var start: int = maxi(0, lines.size() - max_lines)
|
||||
for i in range(start, lines.size()):
|
||||
var line: String = lines[i]
|
||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
|
||||
errors.append(line.strip_edges())
|
||||
|
||||
errors.append_array(script_errors)
|
||||
errors.append_array(analyzer_errors)
|
||||
errors.append_array(debugger_errors)
|
||||
return success({"errors": errors, "count": errors.size()})
|
||||
|
||||
|
||||
func _get_output_log(params: Dictionary) -> Dictionary:
|
||||
var max_lines: int = optional_int(params, "max_lines", 100)
|
||||
var filter: String = optional_string(params, "filter", "")
|
||||
var base: Control = get_editor().get_base_control()
|
||||
|
||||
var editor_log: Node = base.find_child("Output", true, false)
|
||||
if editor_log == null:
|
||||
# Fallback: read from log file
|
||||
var log_path := "user://logs/godot.log"
|
||||
if not FileAccess.file_exists(log_path):
|
||||
return error_internal("Output panel not found and no log file available")
|
||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read log file")
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
var start: int = maxi(0, lines.size() - max_lines)
|
||||
var output_lines: Array = []
|
||||
for i in range(start, lines.size()):
|
||||
var line: String = lines[i]
|
||||
if filter.is_empty() or line.contains(filter):
|
||||
output_lines.append(line)
|
||||
return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})
|
||||
|
||||
var rtl: RichTextLabel = _find_rtl(editor_log)
|
||||
if rtl == null:
|
||||
return error_internal("Could not find RichTextLabel in Output panel")
|
||||
|
||||
var content: String = rtl.get_parsed_text()
|
||||
var all_lines: PackedStringArray = content.split("\n")
|
||||
var start: int = maxi(0, all_lines.size() - max_lines)
|
||||
var output_lines: Array = []
|
||||
for i in range(start, all_lines.size()):
|
||||
var line: String = all_lines[i]
|
||||
if filter.is_empty() or line.contains(filter):
|
||||
output_lines.append(line)
|
||||
|
||||
return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})
|
||||
|
||||
|
||||
func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
|
||||
if depth > 8:
|
||||
return null
|
||||
if node is CodeEdit:
|
||||
return node as CodeEdit
|
||||
for child in node.get_children():
|
||||
var found: CodeEdit = _find_code_edit(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
|
||||
if depth > 6:
|
||||
return null
|
||||
if node is RichTextLabel:
|
||||
return node as RichTextLabel
|
||||
for child in node.get_children():
|
||||
var found: RichTextLabel = _find_rtl(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _get_editor_screenshot(params: Dictionary) -> Dictionary:
|
||||
# Capture the editor's main viewport - no await to avoid timeout
|
||||
var base_control: Control = get_editor().get_base_control()
|
||||
if base_control == null:
|
||||
return error_internal("Could not access editor base control")
|
||||
|
||||
var viewport: Viewport = base_control.get_viewport()
|
||||
if viewport == null:
|
||||
return error_internal("Could not access editor viewport")
|
||||
|
||||
var texture: ViewportTexture = viewport.get_texture()
|
||||
if texture == null:
|
||||
return error_internal("Could not get viewport texture")
|
||||
|
||||
var image: Image = texture.get_image()
|
||||
if image == null:
|
||||
return error_internal("Could not get image from viewport")
|
||||
|
||||
var save_path: String = params.get("save_path", "")
|
||||
if save_path != "":
|
||||
var abs_path := _resolve_save_path(save_path)
|
||||
var err := image.save_png(abs_path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save screenshot: %s" % error_string(err))
|
||||
return success({
|
||||
"saved_path": save_path,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
|
||||
return success({
|
||||
"image_base64": base64,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
|
||||
func _get_game_screenshot(params: Dictionary) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
||||
|
||||
# Communicate with the game process via file system
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_screenshot_request"
|
||||
var screenshot_path := user_dir + "/mcp_screenshot.png"
|
||||
|
||||
# Clean up any stale screenshot file
|
||||
if FileAccess.file_exists(screenshot_path):
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
|
||||
# Create the request file to signal the game process
|
||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if req == null:
|
||||
return error_internal("Could not create screenshot request file")
|
||||
req.close()
|
||||
|
||||
# Poll for the screenshot file (max 3 seconds, 0.1s interval)
|
||||
var attempts := 30
|
||||
while attempts > 0:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(screenshot_path):
|
||||
break
|
||||
attempts -= 1
|
||||
|
||||
if not FileAccess.file_exists(screenshot_path):
|
||||
# Clean up request file if it still exists
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Screenshot timed out", {
|
||||
"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
|
||||
})
|
||||
|
||||
# Load the PNG file
|
||||
var image := Image.new()
|
||||
var err := image.load(screenshot_path)
|
||||
if err != OK:
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
return error_internal("Failed to load screenshot: %s" % error_string(err))
|
||||
|
||||
# Clean up temp file
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
|
||||
var save_path_param: String = params.get("save_path", "")
|
||||
if save_path_param != "":
|
||||
var abs_path := _resolve_save_path(save_path_param)
|
||||
var save_err := image.save_png(abs_path)
|
||||
if save_err != OK:
|
||||
return error_internal("Failed to save screenshot: %s" % error_string(save_err))
|
||||
return success({
|
||||
"saved_path": save_path_param,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
|
||||
return success({
|
||||
"image_base64": base64,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
|
||||
func _resolve_save_path(path: String) -> String:
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return ProjectSettings.globalize_path(path)
|
||||
return path
|
||||
|
||||
|
||||
func _execute_editor_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "code")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var code: String = result[0]
|
||||
var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
|
||||
var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
|
||||
if not unsafe_guard.is_empty():
|
||||
return unsafe_guard
|
||||
|
||||
# Wrap user code in a @tool script
|
||||
var wrapped_code := """@tool
|
||||
extends Node
|
||||
|
||||
var _mcp_output: Array = []
|
||||
|
||||
func _mcp_print(value: Variant) -> void:
|
||||
_mcp_output.append(str(value))
|
||||
|
||||
func run() -> Variant:
|
||||
# User code begins
|
||||
%s
|
||||
# User code ends
|
||||
return _mcp_output
|
||||
""" % _indent_code(code)
|
||||
|
||||
# Create a temporary script
|
||||
var script := GDScript.new()
|
||||
script.source_code = wrapped_code
|
||||
var err := script.reload()
|
||||
|
||||
if err != OK:
|
||||
return error(-32002, "Script compilation failed", {
|
||||
"error": error_string(err),
|
||||
"code": wrapped_code,
|
||||
})
|
||||
|
||||
# Create temp node and execute
|
||||
var temp_node := Node.new()
|
||||
temp_node.set_script(script)
|
||||
add_child(temp_node)
|
||||
|
||||
var output: Variant = null
|
||||
|
||||
# Execute with error handling
|
||||
if temp_node.has_method("run"):
|
||||
output = temp_node.run()
|
||||
|
||||
var mcp_output: Array = []
|
||||
var raw_output: Variant = temp_node.get("_mcp_output")
|
||||
if raw_output is Array:
|
||||
mcp_output = raw_output
|
||||
|
||||
# Cleanup
|
||||
temp_node.queue_free()
|
||||
|
||||
return success({
|
||||
"output": mcp_output,
|
||||
"return_value": str(output) if output != null else null,
|
||||
})
|
||||
|
||||
|
||||
func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
|
||||
if allow_unsafe_editor_io:
|
||||
return {}
|
||||
var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
|
||||
var unsafe_patterns: Array[String] = []
|
||||
if compact.contains("ResourceSaver.save("):
|
||||
unsafe_patterns.append("ResourceSaver.save")
|
||||
if compact.contains("ProjectSettings.save("):
|
||||
unsafe_patterns.append("ProjectSettings.save")
|
||||
if compact.contains("ConfigFile.save("):
|
||||
unsafe_patterns.append("ConfigFile.save")
|
||||
if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
|
||||
unsafe_patterns.append("FileAccess.open WRITE")
|
||||
if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
|
||||
unsafe_patterns.append("DirAccess filesystem mutation")
|
||||
if unsafe_patterns.is_empty():
|
||||
return {}
|
||||
return error_conflict(
|
||||
"Refusing to execute editor script with direct file/resource write APIs",
|
||||
{
|
||||
"unsafe_patterns": unsafe_patterns,
|
||||
"open_scenes": get_open_scene_paths(),
|
||||
"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
func _contains_any(value: String, needles: Array[String]) -> bool:
|
||||
for needle: String in needles:
|
||||
if value.contains(needle):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _indent_code(code: String) -> String:
|
||||
var lines := code.split("\n")
|
||||
var indented: PackedStringArray = []
|
||||
for line in lines:
|
||||
indented.append("\t" + line)
|
||||
return "\n".join(indented)
|
||||
|
||||
|
||||
func _clear_output(params: Dictionary) -> Dictionary:
|
||||
print("\n".repeat(50))
|
||||
return success({"cleared": true})
|
||||
|
||||
|
||||
func _reload_plugin(params: Dictionary) -> Dictionary:
|
||||
# Disable and re-enable this plugin to reload all scripts
|
||||
var plugin_name := "godot_mcp"
|
||||
var ei := get_editor()
|
||||
|
||||
# Send success BEFORE reloading (connection will briefly drop)
|
||||
# Use call_deferred so the response is sent first
|
||||
_deferred_reload_plugin.call_deferred(ei, plugin_name)
|
||||
return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})
|
||||
|
||||
|
||||
func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
|
||||
ei.set_plugin_enabled(plugin_name, false)
|
||||
ei.set_plugin_enabled(plugin_name, true)
|
||||
print("[MCP] Plugin reloaded")
|
||||
|
||||
|
||||
func _reload_project(params: Dictionary) -> Dictionary:
|
||||
# Rescan filesystem and reload changed scripts
|
||||
var ei := get_editor()
|
||||
ei.get_resource_filesystem().scan()
|
||||
|
||||
return success({"reloaded": true, "message": "Filesystem rescanned."})
|
||||
|
||||
|
||||
func _get_signals(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path)
|
||||
|
||||
var signals: Array = []
|
||||
for sig in node.get_signal_list():
|
||||
var sig_info: Dictionary = {
|
||||
"name": sig["name"],
|
||||
"args": [],
|
||||
}
|
||||
for arg in sig["args"]:
|
||||
sig_info["args"].append({"name": arg["name"], "type": arg["type"]})
|
||||
|
||||
# Get connections for this signal
|
||||
var connections: Array = []
|
||||
for conn in node.get_signal_connection_list(sig["name"]):
|
||||
connections.append({
|
||||
"target": str(root.get_path_to(conn["callable"].get_object())),
|
||||
"method": conn["callable"].get_method(),
|
||||
})
|
||||
sig_info["connections"] = connections
|
||||
signals.append(sig_info)
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
"signals": signals,
|
||||
"count": signals.size(),
|
||||
})
|
||||
|
||||
|
||||
func _load_image_from_param(value: String, label: String) -> Array:
|
||||
## Returns [Image, null] on success or [null, error_dict] on failure.
|
||||
## Accepts a file path (res://, user://) or raw base64 PNG data.
|
||||
var img := Image.new()
|
||||
if value.begins_with("res://") or value.begins_with("user://"):
|
||||
var err := img.load(value)
|
||||
if err != OK:
|
||||
return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
|
||||
return [img, null]
|
||||
# Treat as base64 PNG
|
||||
var buf := Marshalls.base64_to_raw(value)
|
||||
var err := img.load_png_from_buffer(buf)
|
||||
if err != OK:
|
||||
return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
|
||||
return [img, null]
|
||||
|
||||
|
||||
func _compare_screenshots(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "image_a")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var image_a_value: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "image_b")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var image_b_value: String = result2[0]
|
||||
|
||||
var threshold: int = optional_int(params, "threshold", 10)
|
||||
|
||||
# Load images (from path or base64)
|
||||
var load_a := _load_image_from_param(image_a_value, "image_a")
|
||||
if load_a[1] != null:
|
||||
return load_a[1]
|
||||
var img_a: Image = load_a[0]
|
||||
|
||||
var load_b := _load_image_from_param(image_b_value, "image_b")
|
||||
if load_b[1] != null:
|
||||
return load_b[1]
|
||||
var img_b: Image = load_b[0]
|
||||
|
||||
if img_a.get_size() != img_b.get_size():
|
||||
return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])
|
||||
|
||||
var width := img_a.get_width()
|
||||
var height := img_a.get_height()
|
||||
var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)
|
||||
|
||||
var changed_pixels: int = 0
|
||||
var total_pixels: int = width * height
|
||||
|
||||
for y in height:
|
||||
for x in width:
|
||||
var ca: Color = img_a.get_pixel(x, y)
|
||||
var cb: Color = img_b.get_pixel(x, y)
|
||||
var dr := absi(int(ca.r8) - int(cb.r8))
|
||||
var dg := absi(int(ca.g8) - int(cb.g8))
|
||||
var db := absi(int(ca.b8) - int(cb.b8))
|
||||
var max_diff := maxi(dr, maxi(dg, db))
|
||||
if max_diff > threshold:
|
||||
changed_pixels += 1
|
||||
# Red highlight for changed pixels
|
||||
diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
|
||||
else:
|
||||
# Dim version of original
|
||||
diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))
|
||||
|
||||
var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
|
||||
var identical: bool = changed_pixels == 0
|
||||
|
||||
# Encode diff image
|
||||
var diff_png := diff_image.save_png_to_buffer()
|
||||
var diff_base64 := Marshalls.raw_to_base64(diff_png)
|
||||
|
||||
return success({
|
||||
"identical": identical,
|
||||
"changed_pixels": changed_pixels,
|
||||
"total_pixels": total_pixels,
|
||||
"diff_percentage": snappedf(diff_percentage, 0.01),
|
||||
"threshold": threshold,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"diff_image_base64": diff_base64,
|
||||
})
|
||||
|
||||
|
||||
func _get_editor_camera(_params: Dictionary) -> Dictionary:
|
||||
var vp3d := EditorInterface.get_editor_viewport_3d()
|
||||
var cam := vp3d.get_camera_3d() if vp3d else null
|
||||
if not cam:
|
||||
return error(-32000, "No 3D editor camera found", {
|
||||
"suggestion": "Make sure a 3D scene is open in the editor",
|
||||
})
|
||||
var pos := cam.global_position
|
||||
var rot := cam.rotation_degrees
|
||||
return success({
|
||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
||||
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
|
||||
"fov": cam.fov,
|
||||
"near": cam.near,
|
||||
"far": cam.far,
|
||||
})
|
||||
|
||||
|
||||
func _set_editor_camera(params: Dictionary) -> Dictionary:
|
||||
var vp3d := EditorInterface.get_editor_viewport_3d()
|
||||
var cam := vp3d.get_camera_3d() if vp3d else null
|
||||
if not cam:
|
||||
return error(-32000, "No 3D editor camera found", {
|
||||
"suggestion": "Make sure a 3D scene is open in the editor",
|
||||
})
|
||||
|
||||
# Set position
|
||||
if params.has("position"):
|
||||
var p: Dictionary = params["position"]
|
||||
cam.global_position = Vector3(
|
||||
float(p.get("x", cam.global_position.x)),
|
||||
float(p.get("y", cam.global_position.y)),
|
||||
float(p.get("z", cam.global_position.z)),
|
||||
)
|
||||
|
||||
# Set rotation
|
||||
if params.has("rotation_degrees"):
|
||||
var r: Dictionary = params["rotation_degrees"]
|
||||
cam.rotation_degrees = Vector3(
|
||||
float(r.get("x", cam.rotation_degrees.x)),
|
||||
float(r.get("y", cam.rotation_degrees.y)),
|
||||
float(r.get("z", cam.rotation_degrees.z)),
|
||||
)
|
||||
|
||||
# Look at target (overrides rotation if set)
|
||||
if params.has("look_at"):
|
||||
var t: Dictionary = params["look_at"]
|
||||
cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))
|
||||
|
||||
# Set FOV
|
||||
if params.has("fov"):
|
||||
cam.fov = float(params["fov"])
|
||||
|
||||
var pos := cam.global_position
|
||||
var rot := cam.rotation_degrees
|
||||
return success({
|
||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
||||
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
|
||||
"fov": cam.fov,
|
||||
})
|
||||
|
||||
|
||||
func _set_auto_dismiss(params: Dictionary) -> Dictionary:
|
||||
var enabled: bool = params.get("enabled", true)
|
||||
editor_plugin.auto_dismiss_dialogs = enabled
|
||||
return success({
|
||||
"auto_dismiss": enabled,
|
||||
"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
|
||||
})
|
||||
Reference in New Issue
Block a user