@beremaran/godot-agent-loop 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- package/scripts/prepare-package.js +42 -0
|
@@ -0,0 +1,1534 @@
|
|
|
1
|
+
#!/usr/bin/env -S godot --headless --script
|
|
2
|
+
extends SceneTree
|
|
3
|
+
|
|
4
|
+
# The outcome of one operation. Operations never report errors and never quit:
|
|
5
|
+
# they return this to the entry point, which is the only place that logs
|
|
6
|
+
# failures and chooses the exit code.
|
|
7
|
+
class OperationResult extends RefCounted:
|
|
8
|
+
var ok: bool
|
|
9
|
+
var errors: PackedStringArray
|
|
10
|
+
|
|
11
|
+
func _init(p_ok: bool, p_errors: PackedStringArray) -> void:
|
|
12
|
+
ok = p_ok
|
|
13
|
+
errors = p_errors
|
|
14
|
+
|
|
15
|
+
# A parsed command line: the operation to run and its JSON object parameters,
|
|
16
|
+
# or the reasons the command line could not be used.
|
|
17
|
+
class CliInvocation extends RefCounted:
|
|
18
|
+
var operation: String
|
|
19
|
+
var params: Dictionary
|
|
20
|
+
var errors: PackedStringArray
|
|
21
|
+
|
|
22
|
+
func _init(p_operation: String, p_params: Dictionary, p_errors: PackedStringArray) -> void:
|
|
23
|
+
operation = p_operation
|
|
24
|
+
params = p_params
|
|
25
|
+
errors = p_errors
|
|
26
|
+
|
|
27
|
+
func is_valid() -> bool:
|
|
28
|
+
return errors.is_empty()
|
|
29
|
+
|
|
30
|
+
# Opt-in debug diagnostics. This is constructed only under --debug-godot, so a
|
|
31
|
+
# normal operation never logs diagnostics and never writes a probe file into the
|
|
32
|
+
# user's project. Values that can carry file paths, tokens, or script source are
|
|
33
|
+
# summarized by type and size rather than printed.
|
|
34
|
+
class DebugDiagnostics extends RefCounted:
|
|
35
|
+
const PROBE_PREFIX := "godot_mcp_write_probe_"
|
|
36
|
+
|
|
37
|
+
func log_message(message: String) -> void:
|
|
38
|
+
print("[DEBUG] " + message)
|
|
39
|
+
|
|
40
|
+
# Describe a JSON value without disclosing its contents. JSON values arrive
|
|
41
|
+
# as Variant, so each branch narrows after its own type check.
|
|
42
|
+
func redact(value: Variant) -> String:
|
|
43
|
+
if value is String:
|
|
44
|
+
var text: String = value
|
|
45
|
+
return "<string, " + str(text.length()) + " chars>"
|
|
46
|
+
if value is Dictionary:
|
|
47
|
+
var object: Dictionary = value
|
|
48
|
+
return "<object, " + str(object.size()) + " keys>"
|
|
49
|
+
if value is Array:
|
|
50
|
+
var items: Array = value
|
|
51
|
+
return "<array, " + str(items.size()) + " items>"
|
|
52
|
+
if value == null:
|
|
53
|
+
return "null"
|
|
54
|
+
if value is bool or value is int or value is float:
|
|
55
|
+
return str(value)
|
|
56
|
+
return "<" + type_string(typeof(value)) + ">"
|
|
57
|
+
|
|
58
|
+
func redact_params(params: Dictionary) -> String:
|
|
59
|
+
var parts := PackedStringArray()
|
|
60
|
+
var keys := PackedStringArray(params.keys())
|
|
61
|
+
keys.sort()
|
|
62
|
+
for key in keys:
|
|
63
|
+
@warning_ignore("return_value_discarded")
|
|
64
|
+
parts.append(key + ": " + redact(params[key]))
|
|
65
|
+
return "{" + ", ".join(parts) + "}"
|
|
66
|
+
|
|
67
|
+
func log_project_environment() -> void:
|
|
68
|
+
log_message("Globalized res:// path: " + ProjectSettings.globalize_path("res://"))
|
|
69
|
+
log_message("Globalized user:// path: " + ProjectSettings.globalize_path("user://"))
|
|
70
|
+
|
|
71
|
+
# Only report which variables are set; their values are paths and may
|
|
72
|
+
# carry credentials.
|
|
73
|
+
var set_vars := PackedStringArray()
|
|
74
|
+
for env_var: String in ["PATH", "HOME", "USER", "TEMP", "GODOT_PATH"]:
|
|
75
|
+
if OS.has_environment(env_var):
|
|
76
|
+
@warning_ignore("return_value_discarded")
|
|
77
|
+
set_vars.append(env_var)
|
|
78
|
+
log_message("Environment variables set: " + ", ".join(set_vars))
|
|
79
|
+
|
|
80
|
+
# Check that a directory is writable by creating and deleting one probe file.
|
|
81
|
+
# The probe name is unique per call and the file is removed on every branch,
|
|
82
|
+
# so the directory is left exactly as it was found.
|
|
83
|
+
func probe_write_access(res_dir: String) -> void:
|
|
84
|
+
var probe_path: String = res_dir.path_join(PROBE_PREFIX + str(Time.get_ticks_usec()) + ".tmp")
|
|
85
|
+
if FileAccess.file_exists(probe_path):
|
|
86
|
+
log_message("Skipping write probe, path is already taken: " + probe_path)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
var probe := FileAccess.open(probe_path, FileAccess.WRITE)
|
|
90
|
+
if probe == null:
|
|
91
|
+
log_message("Write probe failed for " + res_dir + " (open error " + str(FileAccess.get_open_error()) + "); this indicates a permission or path issue")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
@warning_ignore("return_value_discarded")
|
|
95
|
+
probe.store_string("write probe")
|
|
96
|
+
probe.close()
|
|
97
|
+
log_message("Write probe succeeded for: " + res_dir)
|
|
98
|
+
_remove_probe(probe_path)
|
|
99
|
+
|
|
100
|
+
func _remove_probe(probe_path: String) -> void:
|
|
101
|
+
if not FileAccess.file_exists(probe_path):
|
|
102
|
+
return
|
|
103
|
+
var remove_error: Error = DirAccess.remove_absolute(ProjectSettings.globalize_path(probe_path))
|
|
104
|
+
if remove_error != OK:
|
|
105
|
+
log_message("Failed to remove write probe " + probe_path + " (error " + str(remove_error) + ")")
|
|
106
|
+
|
|
107
|
+
# Debug mode flag
|
|
108
|
+
var debug_mode: bool = false
|
|
109
|
+
|
|
110
|
+
# The diagnostics service, or null when diagnostics are off. Nothing outside
|
|
111
|
+
# log_debug() and the branches guarded by debug_mode may use it.
|
|
112
|
+
var _diagnostics: DebugDiagnostics = null
|
|
113
|
+
|
|
114
|
+
# Operation name -> handler. The single source of truth for which operations
|
|
115
|
+
# exist; the CLI rejects any name that is not registered here.
|
|
116
|
+
var _operations: Dictionary[String, Callable] = {}
|
|
117
|
+
const SERVE_ARGUMENT: String = "--serve-authoring"
|
|
118
|
+
var _capture_output: bool = false
|
|
119
|
+
var _operation_output: PackedStringArray = PackedStringArray()
|
|
120
|
+
|
|
121
|
+
func _init() -> void:
|
|
122
|
+
_register_operations()
|
|
123
|
+
|
|
124
|
+
var args: PackedStringArray = OS.get_cmdline_args()
|
|
125
|
+
debug_mode = "--debug-godot" in args
|
|
126
|
+
if debug_mode:
|
|
127
|
+
_diagnostics = DebugDiagnostics.new()
|
|
128
|
+
|
|
129
|
+
if SERVE_ARGUMENT in args:
|
|
130
|
+
# Autoloads enter the tree after the script main loop is constructed.
|
|
131
|
+
# Keep the deferred startup and its exit decision lexically inside this
|
|
132
|
+
# CLI entry point; helpers report failures but never terminate Godot.
|
|
133
|
+
var start_authoring_session: Callable = func() -> void:
|
|
134
|
+
var startup_errors: PackedStringArray = _start_authoring_session()
|
|
135
|
+
if not startup_errors.is_empty():
|
|
136
|
+
_report_errors(startup_errors)
|
|
137
|
+
quit(1)
|
|
138
|
+
start_authoring_session.call_deferred()
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
var invocation := _parse_cli(args)
|
|
142
|
+
if not invocation.is_valid():
|
|
143
|
+
_report_errors(invocation.errors)
|
|
144
|
+
quit(1)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
log_info("Executing operation: " + invocation.operation)
|
|
148
|
+
|
|
149
|
+
var handler: Callable = _operations[invocation.operation]
|
|
150
|
+
var result: OperationResult = handler.call(invocation.params)
|
|
151
|
+
if not result.ok:
|
|
152
|
+
_report_errors(result.errors)
|
|
153
|
+
quit(1)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
quit(0)
|
|
157
|
+
|
|
158
|
+
func _start_authoring_session() -> PackedStringArray:
|
|
159
|
+
var server: Node = get_root().get_node_or_null("McpInteractionServer")
|
|
160
|
+
if server == null:
|
|
161
|
+
return PackedStringArray([
|
|
162
|
+
"Authoring session requires the McpInteractionServer autoload.",
|
|
163
|
+
])
|
|
164
|
+
if not server.has_method("register_authoring_dispatcher"):
|
|
165
|
+
return PackedStringArray([
|
|
166
|
+
"McpInteractionServer does not support authoring commands.",
|
|
167
|
+
])
|
|
168
|
+
server.call("register_authoring_dispatcher", execute_operation)
|
|
169
|
+
log_info("Authoring session ready")
|
|
170
|
+
return PackedStringArray()
|
|
171
|
+
|
|
172
|
+
func execute_operation(operation: String, params: Dictionary) -> Dictionary:
|
|
173
|
+
if not _operations.has(operation):
|
|
174
|
+
return {
|
|
175
|
+
"error": "Unknown authoring operation: " + operation,
|
|
176
|
+
"error_data": {
|
|
177
|
+
"reason": "unknown_authoring_operation",
|
|
178
|
+
"operation": operation,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
_operation_output.clear()
|
|
182
|
+
_capture_output = true
|
|
183
|
+
log_info("Executing operation: " + operation)
|
|
184
|
+
var handler: Callable = _operations[operation]
|
|
185
|
+
var result: OperationResult = handler.call(params)
|
|
186
|
+
_capture_output = false
|
|
187
|
+
if not result.ok:
|
|
188
|
+
return {
|
|
189
|
+
"error": "\n".join(result.errors),
|
|
190
|
+
"error_data": {
|
|
191
|
+
"reason": "authoring_operation_failed",
|
|
192
|
+
"operation": operation,
|
|
193
|
+
"errors": Array(result.errors),
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
"success": true,
|
|
198
|
+
"operation": operation,
|
|
199
|
+
"stdout": "\n".join(_operation_output),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func _register_operations() -> void:
|
|
203
|
+
_operations = {
|
|
204
|
+
"create_scene": create_scene,
|
|
205
|
+
"add_node": add_node,
|
|
206
|
+
"load_sprite": load_sprite,
|
|
207
|
+
"export_mesh_library": export_mesh_library,
|
|
208
|
+
"save_scene": save_scene,
|
|
209
|
+
"get_uid": get_uid,
|
|
210
|
+
"resave_resources": resave_resources,
|
|
211
|
+
"read_scene": read_scene,
|
|
212
|
+
"modify_node": modify_node,
|
|
213
|
+
"remove_node": remove_node,
|
|
214
|
+
"attach_script": attach_script,
|
|
215
|
+
"create_resource": create_resource,
|
|
216
|
+
"manage_resource": manage_resource,
|
|
217
|
+
"manage_scene_signals": manage_scene_signals,
|
|
218
|
+
"manage_theme_resource": manage_theme_resource,
|
|
219
|
+
"manage_scene_structure": manage_scene_structure,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Parse the Godot command line into an operation and its parameters. The
|
|
223
|
+
# operation name and the JSON object are both validated before any dispatch.
|
|
224
|
+
func _parse_cli(args: PackedStringArray) -> CliInvocation:
|
|
225
|
+
var script_index: int = args.find("--script")
|
|
226
|
+
if script_index == -1:
|
|
227
|
+
return _invalid_cli("Could not find --script argument")
|
|
228
|
+
|
|
229
|
+
# The operation follows the script path, and the params follow the operation.
|
|
230
|
+
var operation_index: int = script_index + 2
|
|
231
|
+
var params_index: int = script_index + 3
|
|
232
|
+
|
|
233
|
+
if args.size() <= params_index:
|
|
234
|
+
return _invalid_cli(
|
|
235
|
+
"Usage: godot --headless --script godot_operations.gd <operation> <json_params>",
|
|
236
|
+
PackedStringArray(["Not enough command-line arguments provided."])
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# The arguments carry the params JSON, which can hold paths or script
|
|
240
|
+
# source, so only their positions are logged.
|
|
241
|
+
log_debug("Argument count: " + str(args.size()))
|
|
242
|
+
log_debug("Script index: " + str(script_index))
|
|
243
|
+
log_debug("Operation index: " + str(operation_index))
|
|
244
|
+
log_debug("Params index: " + str(params_index))
|
|
245
|
+
|
|
246
|
+
var operation: String = args[operation_index]
|
|
247
|
+
var params_json: String = args[params_index]
|
|
248
|
+
|
|
249
|
+
log_info("Operation: " + operation)
|
|
250
|
+
log_debug("Params JSON: " + str(params_json.length()) + " chars")
|
|
251
|
+
|
|
252
|
+
if not _operations.has(operation):
|
|
253
|
+
return _invalid_cli(
|
|
254
|
+
"Unknown operation: " + operation,
|
|
255
|
+
PackedStringArray(["Known operations: " + ", ".join(_operation_names())])
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
var json := JSON.new()
|
|
259
|
+
var parse_error: Error = json.parse(params_json)
|
|
260
|
+
if parse_error != OK:
|
|
261
|
+
return _invalid_cli(
|
|
262
|
+
"Failed to parse JSON parameters: " + params_json,
|
|
263
|
+
PackedStringArray(["JSON Error: " + json.get_error_message() + " at line " + str(json.get_error_line())])
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
var data: Variant = json.get_data()
|
|
267
|
+
if not (data is Dictionary):
|
|
268
|
+
return _invalid_cli("Parameters must be a JSON object: " + params_json)
|
|
269
|
+
|
|
270
|
+
var operation_params: Dictionary = data
|
|
271
|
+
log_debug("Params: " + _redact_params(operation_params))
|
|
272
|
+
return CliInvocation.new(operation, operation_params, PackedStringArray())
|
|
273
|
+
|
|
274
|
+
func _operation_names() -> PackedStringArray:
|
|
275
|
+
var names := PackedStringArray(_operations.keys())
|
|
276
|
+
names.sort()
|
|
277
|
+
return names
|
|
278
|
+
|
|
279
|
+
func _invalid_cli(message: String, details: PackedStringArray = PackedStringArray()) -> CliInvocation:
|
|
280
|
+
return CliInvocation.new("", {}, _error_lines(message, details))
|
|
281
|
+
|
|
282
|
+
# Result constructors used by every operation instead of printerr()/quit(1).
|
|
283
|
+
func _ok() -> OperationResult:
|
|
284
|
+
return OperationResult.new(true, PackedStringArray())
|
|
285
|
+
|
|
286
|
+
func _fail(message: String, details: PackedStringArray = PackedStringArray()) -> OperationResult:
|
|
287
|
+
return OperationResult.new(false, _error_lines(message, details))
|
|
288
|
+
|
|
289
|
+
func _failed(errors: PackedStringArray) -> OperationResult:
|
|
290
|
+
return OperationResult.new(false, errors)
|
|
291
|
+
|
|
292
|
+
func _error_lines(message: String, details: PackedStringArray) -> PackedStringArray:
|
|
293
|
+
var lines := PackedStringArray([message])
|
|
294
|
+
lines.append_array(details)
|
|
295
|
+
return lines
|
|
296
|
+
|
|
297
|
+
func _report_errors(errors: PackedStringArray) -> void:
|
|
298
|
+
for line in errors:
|
|
299
|
+
log_error(line)
|
|
300
|
+
|
|
301
|
+
# Logging functions
|
|
302
|
+
func log_debug(message: String) -> void:
|
|
303
|
+
if _diagnostics != null:
|
|
304
|
+
_diagnostics.log_message(message)
|
|
305
|
+
|
|
306
|
+
# Summaries used when a debug line would otherwise print a value that can carry
|
|
307
|
+
# a file path, a token, or script source.
|
|
308
|
+
func _redact(value: Variant) -> String:
|
|
309
|
+
if _diagnostics == null:
|
|
310
|
+
return ""
|
|
311
|
+
return _diagnostics.redact(value)
|
|
312
|
+
|
|
313
|
+
func _redact_params(params: Dictionary) -> String:
|
|
314
|
+
if _diagnostics == null:
|
|
315
|
+
return ""
|
|
316
|
+
return _diagnostics.redact_params(params)
|
|
317
|
+
|
|
318
|
+
func log_info(message: String) -> void:
|
|
319
|
+
_emit_output("[INFO] " + message)
|
|
320
|
+
|
|
321
|
+
func _emit_output(message: String) -> void:
|
|
322
|
+
print(message)
|
|
323
|
+
if _capture_output:
|
|
324
|
+
@warning_ignore("return_value_discarded")
|
|
325
|
+
_operation_output.append(message)
|
|
326
|
+
|
|
327
|
+
func log_error(message: String) -> void:
|
|
328
|
+
printerr("[ERROR] " + message)
|
|
329
|
+
|
|
330
|
+
# Parameter readers: JSON values arrive as Variant, so every read narrows to the
|
|
331
|
+
# type the operation works with instead of relying on dynamic access.
|
|
332
|
+
func _param_string(params: Dictionary, key: String, default_value: String = "") -> String:
|
|
333
|
+
if not params.has(key):
|
|
334
|
+
return default_value
|
|
335
|
+
var value: Variant = params[key]
|
|
336
|
+
if value is String:
|
|
337
|
+
return value
|
|
338
|
+
return str(value)
|
|
339
|
+
|
|
340
|
+
func _param_dictionary(params: Dictionary, key: String) -> Dictionary:
|
|
341
|
+
var value: Variant = params.get(key, {})
|
|
342
|
+
if value is Dictionary:
|
|
343
|
+
return value
|
|
344
|
+
return {}
|
|
345
|
+
|
|
346
|
+
func _param_array(params: Dictionary, key: String) -> Array:
|
|
347
|
+
var value: Variant = params.get(key, [])
|
|
348
|
+
if value is Array:
|
|
349
|
+
return value
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
# --- Project paths ---------------------------------------------------------
|
|
353
|
+
#
|
|
354
|
+
# Tool callers may pass either a project-relative path or a full res:// path,
|
|
355
|
+
# so every operation normalizes through _res_path() instead of open-coding the
|
|
356
|
+
# prefix check.
|
|
357
|
+
|
|
358
|
+
const RES_PREFIX: String = "res://"
|
|
359
|
+
|
|
360
|
+
func _res_path(path: String) -> String:
|
|
361
|
+
if path.begins_with(RES_PREFIX):
|
|
362
|
+
return path
|
|
363
|
+
return RES_PREFIX + path
|
|
364
|
+
|
|
365
|
+
# The path relative to the project root, which is what DirAccess calls rooted
|
|
366
|
+
# at res:// expect.
|
|
367
|
+
func _res_relative(res_path: String) -> String:
|
|
368
|
+
return res_path.substr(RES_PREFIX.length())
|
|
369
|
+
|
|
370
|
+
# --- Directories -----------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
# Create a res:// directory and its parents if they are missing.
|
|
373
|
+
func _ensure_directory(res_dir: String) -> OperationResult:
|
|
374
|
+
var relative: String = _res_relative(res_dir)
|
|
375
|
+
if relative.is_empty():
|
|
376
|
+
return _ok()
|
|
377
|
+
|
|
378
|
+
var absolute: String = ProjectSettings.globalize_path(res_dir)
|
|
379
|
+
if DirAccess.dir_exists_absolute(absolute):
|
|
380
|
+
log_debug("Directory already exists: " + res_dir)
|
|
381
|
+
return _ok()
|
|
382
|
+
|
|
383
|
+
var dir := DirAccess.open(RES_PREFIX)
|
|
384
|
+
if dir == null:
|
|
385
|
+
# res:// itself could not be opened, so fall back to the absolute path.
|
|
386
|
+
var open_error: Error = DirAccess.get_open_error()
|
|
387
|
+
log_debug("Failed to open res:// (error " + str(open_error) + "), creating by absolute path")
|
|
388
|
+
var absolute_error: Error = DirAccess.make_dir_recursive_absolute(absolute)
|
|
389
|
+
if absolute_error != OK:
|
|
390
|
+
return _fail("Failed to create directory: " + res_dir, PackedStringArray([
|
|
391
|
+
"DirAccess error: " + str(open_error),
|
|
392
|
+
"Error code: " + str(absolute_error),
|
|
393
|
+
]))
|
|
394
|
+
else:
|
|
395
|
+
var make_error: Error = dir.make_dir_recursive(relative)
|
|
396
|
+
if make_error != OK:
|
|
397
|
+
return _fail("Failed to create directory: " + res_dir, PackedStringArray([
|
|
398
|
+
"Error code: " + str(make_error),
|
|
399
|
+
]))
|
|
400
|
+
|
|
401
|
+
if not DirAccess.dir_exists_absolute(absolute):
|
|
402
|
+
return _fail("Directory reported as created but does not exist: " + absolute, PackedStringArray([
|
|
403
|
+
"This may indicate a problem with path resolution or permissions",
|
|
404
|
+
]))
|
|
405
|
+
|
|
406
|
+
log_debug("Created directory: " + res_dir)
|
|
407
|
+
return _ok()
|
|
408
|
+
|
|
409
|
+
# --- Scene loading, packing and saving -------------------------------------
|
|
410
|
+
|
|
411
|
+
# A scene file opened for editing: its instantiated root, or the reasons it
|
|
412
|
+
# could not be opened. The instantiated tree is never added to the running
|
|
413
|
+
# SceneTree and queue_free() would never be processed before quit(), so the
|
|
414
|
+
# guard frees the root when the OpenScene itself is released; without it every
|
|
415
|
+
# early return leaks the whole tree as ObjectDB instances at exit.
|
|
416
|
+
class OpenScene extends RefCounted:
|
|
417
|
+
var root: Node
|
|
418
|
+
var errors: PackedStringArray
|
|
419
|
+
|
|
420
|
+
func _init(p_root: Node, p_errors: PackedStringArray) -> void:
|
|
421
|
+
root = p_root
|
|
422
|
+
errors = p_errors
|
|
423
|
+
|
|
424
|
+
func is_valid() -> bool:
|
|
425
|
+
return errors.is_empty()
|
|
426
|
+
|
|
427
|
+
func _notification(what: int) -> void:
|
|
428
|
+
if what == NOTIFICATION_PREDELETE and root != null and is_instance_valid(root):
|
|
429
|
+
root.free()
|
|
430
|
+
|
|
431
|
+
# Load a scene file and instantiate it so an operation can edit the tree.
|
|
432
|
+
func _open_scene(res_scene_path: String) -> OpenScene:
|
|
433
|
+
if not FileAccess.file_exists(res_scene_path):
|
|
434
|
+
return _scene_not_opened("Scene file does not exist at: " + res_scene_path, PackedStringArray([
|
|
435
|
+
"Absolute file path that doesn't exist: " + ProjectSettings.globalize_path(res_scene_path),
|
|
436
|
+
]))
|
|
437
|
+
|
|
438
|
+
var scene := load(res_scene_path) as PackedScene
|
|
439
|
+
if scene == null:
|
|
440
|
+
return _scene_not_opened("Failed to load scene: " + res_scene_path)
|
|
441
|
+
|
|
442
|
+
var scene_root := scene.instantiate()
|
|
443
|
+
if scene_root == null:
|
|
444
|
+
return _scene_not_opened("Failed to instantiate scene: " + res_scene_path)
|
|
445
|
+
|
|
446
|
+
log_debug("Scene instantiated: " + res_scene_path)
|
|
447
|
+
return OpenScene.new(scene_root, PackedStringArray())
|
|
448
|
+
|
|
449
|
+
func _scene_not_opened(message: String, details: PackedStringArray = PackedStringArray()) -> OpenScene:
|
|
450
|
+
return OpenScene.new(null, _error_lines(message, details))
|
|
451
|
+
|
|
452
|
+
# Resolve a tool-supplied node path against a scene root. Callers address the
|
|
453
|
+
# root as "", "root" or ".", and its descendants with or without a "root/"
|
|
454
|
+
# prefix.
|
|
455
|
+
func _resolve_scene_node(scene_root: Node, tool_path: String) -> Node:
|
|
456
|
+
if tool_path == "" or tool_path == "root" or tool_path == ".":
|
|
457
|
+
return scene_root
|
|
458
|
+
var p: String = tool_path
|
|
459
|
+
if p.begins_with("root/"):
|
|
460
|
+
p = p.substr(5)
|
|
461
|
+
return scene_root.get_node_or_null(p)
|
|
462
|
+
|
|
463
|
+
# Pack an edited scene root and write it back to a res:// path.
|
|
464
|
+
func _save_scene_root(scene_root: Node, res_scene_path: String) -> OperationResult:
|
|
465
|
+
var packed := PackedScene.new()
|
|
466
|
+
var pack_error: Error = packed.pack(scene_root)
|
|
467
|
+
if pack_error != OK:
|
|
468
|
+
return _fail("Failed to pack scene: " + res_scene_path, PackedStringArray([
|
|
469
|
+
"Error code: " + str(pack_error),
|
|
470
|
+
]))
|
|
471
|
+
|
|
472
|
+
return _save_resource(packed, res_scene_path, "scene")
|
|
473
|
+
|
|
474
|
+
# Save a resource to a res:// path, creating its directory first and verifying
|
|
475
|
+
# the file exists afterwards.
|
|
476
|
+
func _save_resource(resource: Resource, res_path: String, what: String) -> OperationResult:
|
|
477
|
+
var dir_result := _ensure_directory(res_path.get_base_dir())
|
|
478
|
+
if not dir_result.ok:
|
|
479
|
+
return dir_result
|
|
480
|
+
|
|
481
|
+
var save_error: Error = ResourceSaver.save(resource, res_path)
|
|
482
|
+
if save_error != OK:
|
|
483
|
+
return _fail("Failed to save " + what + ": " + str(save_error), PackedStringArray([
|
|
484
|
+
"Path: " + res_path + _save_error_hint(save_error),
|
|
485
|
+
]))
|
|
486
|
+
|
|
487
|
+
if not FileAccess.file_exists(res_path):
|
|
488
|
+
return _fail("File reported as saved but does not exist at: " + res_path)
|
|
489
|
+
|
|
490
|
+
log_debug("Saved " + what + " to: " + res_path)
|
|
491
|
+
return _ok()
|
|
492
|
+
|
|
493
|
+
func _save_error_hint(save_error: Error) -> String:
|
|
494
|
+
match save_error:
|
|
495
|
+
ERR_CANT_CREATE:
|
|
496
|
+
return " (ERR_CANT_CREATE - Cannot create the file)"
|
|
497
|
+
ERR_CANT_OPEN:
|
|
498
|
+
return " (ERR_CANT_OPEN - Cannot open the file for writing)"
|
|
499
|
+
ERR_FILE_CANT_WRITE:
|
|
500
|
+
return " (ERR_FILE_CANT_WRITE - Cannot write to the file)"
|
|
501
|
+
ERR_FILE_NO_PERMISSION:
|
|
502
|
+
return " (ERR_FILE_NO_PERMISSION - No permission to write the file)"
|
|
503
|
+
return ""
|
|
504
|
+
|
|
505
|
+
# --- Resource loading ------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
# An existing resource opened for reading or modification.
|
|
508
|
+
class OpenResource extends RefCounted:
|
|
509
|
+
var resource: Resource
|
|
510
|
+
var errors: PackedStringArray
|
|
511
|
+
|
|
512
|
+
func _init(p_resource: Resource, p_errors: PackedStringArray) -> void:
|
|
513
|
+
resource = p_resource
|
|
514
|
+
errors = p_errors
|
|
515
|
+
|
|
516
|
+
func is_valid() -> bool:
|
|
517
|
+
return errors.is_empty()
|
|
518
|
+
|
|
519
|
+
# `what` names the resource in failures ("Resource", "Theme"), so callers keep
|
|
520
|
+
# their domain wording without repeating the exists/load dance.
|
|
521
|
+
func _open_resource(res_path: String, what: String) -> OpenResource:
|
|
522
|
+
if not ResourceLoader.exists(res_path):
|
|
523
|
+
return OpenResource.new(null, PackedStringArray([what + " not found: " + res_path]))
|
|
524
|
+
|
|
525
|
+
var resource := ResourceLoader.load(res_path)
|
|
526
|
+
if resource == null:
|
|
527
|
+
return OpenResource.new(null, PackedStringArray(["Failed to load " + what.to_lower() + ": " + res_path]))
|
|
528
|
+
|
|
529
|
+
return OpenResource.new(resource, PackedStringArray())
|
|
530
|
+
|
|
531
|
+
# Apply JSON properties to a node or resource, converting each value to the
|
|
532
|
+
# type the target property declares.
|
|
533
|
+
func _apply_properties(target: Object, properties: Dictionary) -> void:
|
|
534
|
+
for prop_name: String in properties:
|
|
535
|
+
var raw_value: Variant = properties[prop_name]
|
|
536
|
+
var converted_value: Variant = _convert_property_value(target, prop_name, raw_value)
|
|
537
|
+
log_debug("Setting " + prop_name + " = " + _redact(converted_value) + " (from " + _redact(raw_value) + ")")
|
|
538
|
+
target.set(prop_name, converted_value)
|
|
539
|
+
|
|
540
|
+
# --- Class and script resolution -------------------------------------------
|
|
541
|
+
|
|
542
|
+
# Get a script by name or path
|
|
543
|
+
func get_script_by_name(name_of_class: String) -> Script:
|
|
544
|
+
log_debug("Attempting to get script for class: " + name_of_class)
|
|
545
|
+
|
|
546
|
+
# Try to load it directly if it's a resource path
|
|
547
|
+
if ResourceLoader.exists(name_of_class, "Script"):
|
|
548
|
+
log_debug("Resource exists, loading directly: " + name_of_class)
|
|
549
|
+
var script := load(name_of_class) as Script
|
|
550
|
+
if script:
|
|
551
|
+
log_debug("Successfully loaded script from path")
|
|
552
|
+
return script
|
|
553
|
+
else:
|
|
554
|
+
printerr("Failed to load script from path: " + name_of_class)
|
|
555
|
+
else:
|
|
556
|
+
log_debug("Resource not found, checking global class registry")
|
|
557
|
+
|
|
558
|
+
# Search for it in the global class registry if it's a class name
|
|
559
|
+
var global_classes: Array[Dictionary] = ProjectSettings.get_global_class_list()
|
|
560
|
+
log_debug("Searching through " + str(global_classes.size()) + " global classes")
|
|
561
|
+
|
|
562
|
+
for global_class in global_classes:
|
|
563
|
+
var found_name_of_class: String = global_class["class"]
|
|
564
|
+
var found_path: String = global_class["path"]
|
|
565
|
+
|
|
566
|
+
if found_name_of_class == name_of_class:
|
|
567
|
+
log_debug("Found matching class in registry: " + found_name_of_class + " at path: " + found_path)
|
|
568
|
+
var script := load(found_path) as Script
|
|
569
|
+
if script:
|
|
570
|
+
log_debug("Successfully loaded script from registry")
|
|
571
|
+
return script
|
|
572
|
+
else:
|
|
573
|
+
printerr("Failed to load script from registry path: " + found_path)
|
|
574
|
+
break
|
|
575
|
+
|
|
576
|
+
printerr("Could not find script for class: " + name_of_class)
|
|
577
|
+
return null
|
|
578
|
+
|
|
579
|
+
# Instantiate a class by name
|
|
580
|
+
func instantiate_class(name_of_class: String) -> Object:
|
|
581
|
+
if name_of_class.is_empty():
|
|
582
|
+
printerr("Cannot instantiate class: name is empty")
|
|
583
|
+
return null
|
|
584
|
+
|
|
585
|
+
var result: Object = null
|
|
586
|
+
log_debug("Attempting to instantiate class: " + name_of_class)
|
|
587
|
+
|
|
588
|
+
# Check if it's a built-in class
|
|
589
|
+
if ClassDB.class_exists(name_of_class):
|
|
590
|
+
log_debug("Class exists in ClassDB, using ClassDB.instantiate()")
|
|
591
|
+
if ClassDB.can_instantiate(name_of_class):
|
|
592
|
+
result = ClassDB.instantiate(name_of_class)
|
|
593
|
+
if result == null:
|
|
594
|
+
printerr("ClassDB.instantiate() returned null for class: " + name_of_class)
|
|
595
|
+
else:
|
|
596
|
+
printerr("Class exists but cannot be instantiated: " + name_of_class)
|
|
597
|
+
printerr("This may be an abstract class or interface that cannot be directly instantiated")
|
|
598
|
+
else:
|
|
599
|
+
# Try to get the script
|
|
600
|
+
log_debug("Class not found in ClassDB, trying to get script")
|
|
601
|
+
var script := get_script_by_name(name_of_class)
|
|
602
|
+
if script is GDScript:
|
|
603
|
+
log_debug("Found GDScript, creating instance")
|
|
604
|
+
result = (script as GDScript).new()
|
|
605
|
+
else:
|
|
606
|
+
printerr("Failed to get script for class: " + name_of_class)
|
|
607
|
+
return null
|
|
608
|
+
|
|
609
|
+
if result == null:
|
|
610
|
+
printerr("Failed to instantiate class: " + name_of_class)
|
|
611
|
+
else:
|
|
612
|
+
log_debug("Successfully instantiated class: " + name_of_class + " of type: " + result.get_class())
|
|
613
|
+
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
# Instantiate a class that must be a Node (scene operations)
|
|
617
|
+
func instantiate_node(name_of_class: String) -> Node:
|
|
618
|
+
return instantiate_class(name_of_class) as Node
|
|
619
|
+
|
|
620
|
+
# Create a new scene with a specified root node type
|
|
621
|
+
func create_scene(params: Dictionary) -> OperationResult:
|
|
622
|
+
var scene_path: String = _param_string(params, "scene_path")
|
|
623
|
+
_emit_output("Creating scene: " + scene_path)
|
|
624
|
+
|
|
625
|
+
if _diagnostics != null:
|
|
626
|
+
_diagnostics.log_project_environment()
|
|
627
|
+
_diagnostics.probe_write_access(RES_PREFIX)
|
|
628
|
+
|
|
629
|
+
var full_scene_path: String = _res_path(scene_path)
|
|
630
|
+
var root_node_type: String = _param_string(params, "root_node_type", "Node2D")
|
|
631
|
+
log_debug("Scene path: " + full_scene_path + ", root node type: " + root_node_type)
|
|
632
|
+
|
|
633
|
+
var scene_root := instantiate_node(root_node_type)
|
|
634
|
+
if scene_root == null:
|
|
635
|
+
return _fail("Failed to instantiate node of type: " + root_node_type, PackedStringArray([
|
|
636
|
+
"Make sure the class exists and can be instantiated",
|
|
637
|
+
"Check if the class is registered in ClassDB or available as a script",
|
|
638
|
+
]))
|
|
639
|
+
|
|
640
|
+
# PackedScene.pack() stores the root plus the nodes it owns. The root itself
|
|
641
|
+
# needs no owner: Node.set_owner() rejects a node owning itself, so assigning
|
|
642
|
+
# one only printed an engine error.
|
|
643
|
+
scene_root.name = "root"
|
|
644
|
+
|
|
645
|
+
var save_result := _save_scene_root(scene_root, full_scene_path)
|
|
646
|
+
# The root never joins the SceneTree, so it must be freed explicitly or it
|
|
647
|
+
# leaks an ObjectDB instance at exit.
|
|
648
|
+
scene_root.free()
|
|
649
|
+
if not save_result.ok:
|
|
650
|
+
if _diagnostics != null:
|
|
651
|
+
_diagnostics.probe_write_access(full_scene_path.get_base_dir())
|
|
652
|
+
return save_result
|
|
653
|
+
|
|
654
|
+
_emit_output("Scene created successfully at: " + scene_path)
|
|
655
|
+
return _ok()
|
|
656
|
+
|
|
657
|
+
# Add a node to an existing scene
|
|
658
|
+
func add_node(params: Dictionary) -> OperationResult:
|
|
659
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
660
|
+
_emit_output("Adding node to scene: " + full_scene_path)
|
|
661
|
+
|
|
662
|
+
var opened := _open_scene(full_scene_path)
|
|
663
|
+
if not opened.is_valid():
|
|
664
|
+
return _failed(opened.errors)
|
|
665
|
+
var scene_root: Node = opened.root
|
|
666
|
+
|
|
667
|
+
var parent_path: String = _param_string(params, "parent_node_path", "root")
|
|
668
|
+
var parent := _resolve_scene_node(scene_root, parent_path)
|
|
669
|
+
if parent == null:
|
|
670
|
+
return _fail("Parent node not found: " + parent_path)
|
|
671
|
+
|
|
672
|
+
var node_type: String = _param_string(params, "node_type")
|
|
673
|
+
var new_node := instantiate_node(node_type)
|
|
674
|
+
if new_node == null:
|
|
675
|
+
return _fail("Failed to instantiate node of type: " + node_type, PackedStringArray([
|
|
676
|
+
"Make sure the class exists and can be instantiated",
|
|
677
|
+
"Check if the class is registered in ClassDB or available as a script",
|
|
678
|
+
]))
|
|
679
|
+
|
|
680
|
+
var node_name: String = _param_string(params, "node_name")
|
|
681
|
+
new_node.name = node_name
|
|
682
|
+
_apply_properties(new_node, _param_dictionary(params, "properties"))
|
|
683
|
+
|
|
684
|
+
# force_readable_name matches editor semantics: a duplicate name becomes
|
|
685
|
+
# Twin2 rather than the transient @Twin@N, which PackedScene will not
|
|
686
|
+
# persist — without this the second node silently vanished from the file.
|
|
687
|
+
parent.add_child(new_node, true)
|
|
688
|
+
new_node.owner = scene_root
|
|
689
|
+
|
|
690
|
+
var save_result := _save_scene_root(scene_root, full_scene_path)
|
|
691
|
+
if not save_result.ok:
|
|
692
|
+
return save_result
|
|
693
|
+
|
|
694
|
+
_emit_output("Node '" + node_name + "' of type '" + node_type + "' added successfully")
|
|
695
|
+
return _ok()
|
|
696
|
+
|
|
697
|
+
# Load a sprite into a Sprite2D node
|
|
698
|
+
func load_sprite(params: Dictionary) -> OperationResult:
|
|
699
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
700
|
+
var full_texture_path: String = _res_path(_param_string(params, "texture_path"))
|
|
701
|
+
_emit_output("Loading sprite into scene: " + full_scene_path)
|
|
702
|
+
|
|
703
|
+
var opened := _open_scene(full_scene_path)
|
|
704
|
+
if not opened.is_valid():
|
|
705
|
+
return _failed(opened.errors)
|
|
706
|
+
var scene_root: Node = opened.root
|
|
707
|
+
|
|
708
|
+
# An empty node path means the root node is the sprite.
|
|
709
|
+
var node_path: String = _param_string(params, "node_path")
|
|
710
|
+
var sprite_node := _resolve_scene_node(scene_root, node_path)
|
|
711
|
+
if sprite_node == null:
|
|
712
|
+
return _fail("Node not found: " + node_path)
|
|
713
|
+
|
|
714
|
+
if not (sprite_node is Sprite2D or sprite_node is Sprite3D or sprite_node is TextureRect):
|
|
715
|
+
return _fail("Node is not a sprite-compatible type: " + sprite_node.get_class())
|
|
716
|
+
|
|
717
|
+
var texture := load(full_texture_path) as Texture2D
|
|
718
|
+
if texture == null:
|
|
719
|
+
return _fail("Failed to load texture: " + full_texture_path)
|
|
720
|
+
|
|
721
|
+
if sprite_node is Sprite2D:
|
|
722
|
+
(sprite_node as Sprite2D).texture = texture
|
|
723
|
+
elif sprite_node is Sprite3D:
|
|
724
|
+
(sprite_node as Sprite3D).texture = texture
|
|
725
|
+
else:
|
|
726
|
+
(sprite_node as TextureRect).texture = texture
|
|
727
|
+
|
|
728
|
+
var save_result := _save_scene_root(scene_root, full_scene_path)
|
|
729
|
+
if not save_result.ok:
|
|
730
|
+
return save_result
|
|
731
|
+
|
|
732
|
+
_emit_output("Sprite loaded successfully with texture: " + full_texture_path)
|
|
733
|
+
return _ok()
|
|
734
|
+
|
|
735
|
+
# Export a scene as a MeshLibrary resource
|
|
736
|
+
func export_mesh_library(params: Dictionary) -> OperationResult:
|
|
737
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
738
|
+
var full_output_path: String = _res_path(_param_string(params, "output_path"))
|
|
739
|
+
_emit_output("Exporting MeshLibrary from scene: " + full_scene_path)
|
|
740
|
+
|
|
741
|
+
var opened := _open_scene(full_scene_path)
|
|
742
|
+
if not opened.is_valid():
|
|
743
|
+
return _failed(opened.errors)
|
|
744
|
+
var scene_root: Node = opened.root
|
|
745
|
+
|
|
746
|
+
# An empty list means every mesh in the scene.
|
|
747
|
+
var mesh_item_names: Array = _param_array(params, "mesh_item_names")
|
|
748
|
+
var use_specific_items: bool = mesh_item_names.size() > 0
|
|
749
|
+
|
|
750
|
+
var mesh_library := MeshLibrary.new()
|
|
751
|
+
var item_id: int = 0
|
|
752
|
+
|
|
753
|
+
for child in scene_root.get_children():
|
|
754
|
+
var child_name: String = String(child.name)
|
|
755
|
+
if use_specific_items and not (child_name in mesh_item_names):
|
|
756
|
+
log_debug("Skipping node " + child_name + " (not in specified items list)")
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
var mesh_instance := _find_mesh_instance(child)
|
|
760
|
+
if mesh_instance == null or mesh_instance.mesh == null:
|
|
761
|
+
log_debug("Node " + child_name + " has no valid mesh")
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
mesh_library.create_item(item_id)
|
|
765
|
+
mesh_library.set_item_name(item_id, child_name)
|
|
766
|
+
mesh_library.set_item_mesh(item_id, mesh_instance.mesh)
|
|
767
|
+
|
|
768
|
+
var shape := _find_collision_shape(child)
|
|
769
|
+
if shape != null:
|
|
770
|
+
mesh_library.set_item_shapes(item_id, [shape])
|
|
771
|
+
|
|
772
|
+
log_debug("Added mesh '" + child_name + "' to library with ID: " + str(item_id))
|
|
773
|
+
item_id += 1
|
|
774
|
+
|
|
775
|
+
if item_id == 0:
|
|
776
|
+
return _fail("No valid meshes found in the scene")
|
|
777
|
+
|
|
778
|
+
var save_result := _save_resource(mesh_library, full_output_path, "MeshLibrary")
|
|
779
|
+
if not save_result.ok:
|
|
780
|
+
return save_result
|
|
781
|
+
|
|
782
|
+
_emit_output("MeshLibrary exported successfully with " + str(item_id) + " items to: " + full_output_path)
|
|
783
|
+
return _ok()
|
|
784
|
+
|
|
785
|
+
# A library item's mesh is either the node itself or one of its direct children.
|
|
786
|
+
func _find_mesh_instance(node: Node) -> MeshInstance3D:
|
|
787
|
+
if node is MeshInstance3D:
|
|
788
|
+
return node
|
|
789
|
+
for child in node.get_children():
|
|
790
|
+
if child is MeshInstance3D:
|
|
791
|
+
return child
|
|
792
|
+
return null
|
|
793
|
+
|
|
794
|
+
func _find_collision_shape(node: Node) -> Shape3D:
|
|
795
|
+
for child in node.get_children():
|
|
796
|
+
if child is CollisionShape3D and (child as CollisionShape3D).shape != null:
|
|
797
|
+
return (child as CollisionShape3D).shape
|
|
798
|
+
return null
|
|
799
|
+
|
|
800
|
+
# Find files with a specific extension recursively
|
|
801
|
+
func find_files(path: String, extension: String) -> PackedStringArray:
|
|
802
|
+
var files := PackedStringArray()
|
|
803
|
+
var dir := DirAccess.open(path)
|
|
804
|
+
|
|
805
|
+
if dir:
|
|
806
|
+
@warning_ignore("return_value_discarded")
|
|
807
|
+
dir.list_dir_begin()
|
|
808
|
+
var file_name: String = dir.get_next()
|
|
809
|
+
|
|
810
|
+
while file_name != "":
|
|
811
|
+
if dir.current_is_dir() and not file_name.begins_with("."):
|
|
812
|
+
files.append_array(find_files(path + file_name + "/", extension))
|
|
813
|
+
elif file_name.ends_with(extension):
|
|
814
|
+
@warning_ignore("return_value_discarded")
|
|
815
|
+
files.append(path + file_name)
|
|
816
|
+
|
|
817
|
+
file_name = dir.get_next()
|
|
818
|
+
|
|
819
|
+
return files
|
|
820
|
+
|
|
821
|
+
# Get UID for a specific file
|
|
822
|
+
func get_uid(params: Dictionary) -> OperationResult:
|
|
823
|
+
if not params.has("file_path"):
|
|
824
|
+
return _fail("File path is required")
|
|
825
|
+
|
|
826
|
+
var file_path: String = _res_path(_param_string(params, "file_path"))
|
|
827
|
+
var absolute_path: String = ProjectSettings.globalize_path(file_path)
|
|
828
|
+
_emit_output("Getting UID for file: " + file_path)
|
|
829
|
+
|
|
830
|
+
if not FileAccess.file_exists(file_path):
|
|
831
|
+
return _fail("File does not exist at: " + file_path, PackedStringArray([
|
|
832
|
+
"Absolute file path that doesn't exist: " + absolute_path,
|
|
833
|
+
]))
|
|
834
|
+
|
|
835
|
+
var result: Dictionary = {
|
|
836
|
+
"file": file_path,
|
|
837
|
+
"absolutePath": absolute_path,
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
var uid_file := FileAccess.open(file_path + ".uid", FileAccess.READ)
|
|
841
|
+
if uid_file != null:
|
|
842
|
+
var uid_content: String = uid_file.get_as_text()
|
|
843
|
+
uid_file.close()
|
|
844
|
+
result["uid"] = uid_content.strip_edges()
|
|
845
|
+
result["exists"] = true
|
|
846
|
+
else:
|
|
847
|
+
log_debug("UID file does not exist or could not be opened for: " + file_path)
|
|
848
|
+
result["exists"] = false
|
|
849
|
+
result["message"] = "UID file does not exist for this file. Use resave_resources to generate UIDs."
|
|
850
|
+
|
|
851
|
+
_emit_output(JSON.stringify(result))
|
|
852
|
+
return _ok()
|
|
853
|
+
|
|
854
|
+
# Resave all resources to update UID references
|
|
855
|
+
func resave_resources(params: Dictionary) -> OperationResult:
|
|
856
|
+
_emit_output("Resaving all resources to update UID references...")
|
|
857
|
+
|
|
858
|
+
var project_path: String = RES_PREFIX
|
|
859
|
+
if params.has("project_path"):
|
|
860
|
+
project_path = _res_path(_param_string(params, "project_path"))
|
|
861
|
+
if not project_path.ends_with("/"):
|
|
862
|
+
project_path += "/"
|
|
863
|
+
log_debug("Using project path: " + project_path)
|
|
864
|
+
|
|
865
|
+
# Per-file problems accumulate here instead of being printed and quit on,
|
|
866
|
+
# so the entry point reports them together.
|
|
867
|
+
var errors: Array[String] = []
|
|
868
|
+
|
|
869
|
+
var scenes := find_files(project_path, ".tscn")
|
|
870
|
+
var saved_scenes: int = 0
|
|
871
|
+
for scene_path in scenes:
|
|
872
|
+
var scene := load(scene_path) as Resource
|
|
873
|
+
if scene == null:
|
|
874
|
+
errors.append("Failed to load: " + scene_path)
|
|
875
|
+
continue
|
|
876
|
+
|
|
877
|
+
var save_result := _save_resource(scene, scene_path, "scene")
|
|
878
|
+
if save_result.ok:
|
|
879
|
+
saved_scenes += 1
|
|
880
|
+
else:
|
|
881
|
+
errors.append_array(save_result.errors)
|
|
882
|
+
|
|
883
|
+
# A missing .uid sidecar is regenerated by resaving the script or shader.
|
|
884
|
+
var scripts := find_files(project_path, ".gd") + find_files(project_path, ".shader") + find_files(project_path, ".gdshader")
|
|
885
|
+
var missing_uids: int = 0
|
|
886
|
+
var generated_uids: int = 0
|
|
887
|
+
|
|
888
|
+
for script_path in scripts:
|
|
889
|
+
var uid_path: String = script_path + ".uid"
|
|
890
|
+
if FileAccess.file_exists(uid_path):
|
|
891
|
+
continue
|
|
892
|
+
|
|
893
|
+
missing_uids += 1
|
|
894
|
+
# ResourceSaver does not write .uid sidecars for scripts outside the
|
|
895
|
+
# editor's import pipeline (verified on 4.7: resaving a .gd leaves no
|
|
896
|
+
# sidecar), so create and persist a fresh UID explicitly, matching the
|
|
897
|
+
# sidecar format the importer writes.
|
|
898
|
+
var new_id: int = ResourceUID.create_id()
|
|
899
|
+
var uid_file := FileAccess.open(uid_path, FileAccess.WRITE)
|
|
900
|
+
if uid_file == null:
|
|
901
|
+
errors.append("Failed to write UID file: " + uid_path)
|
|
902
|
+
continue
|
|
903
|
+
@warning_ignore("return_value_discarded")
|
|
904
|
+
uid_file.store_line(ResourceUID.id_to_text(new_id))
|
|
905
|
+
uid_file.close()
|
|
906
|
+
if not ResourceUID.has_id(new_id):
|
|
907
|
+
ResourceUID.add_id(new_id, script_path)
|
|
908
|
+
generated_uids += 1
|
|
909
|
+
|
|
910
|
+
log_debug("Summary:")
|
|
911
|
+
log_debug("- Scenes processed: " + str(scenes.size()))
|
|
912
|
+
log_debug("- Scenes successfully saved: " + str(saved_scenes))
|
|
913
|
+
log_debug("- Scripts/shaders missing UIDs: " + str(missing_uids))
|
|
914
|
+
log_debug("- UIDs successfully generated: " + str(generated_uids))
|
|
915
|
+
log_debug("- Errors: " + str(errors.size()))
|
|
916
|
+
_emit_output("Resave operation complete")
|
|
917
|
+
|
|
918
|
+
if not errors.is_empty():
|
|
919
|
+
return _failed(PackedStringArray(errors))
|
|
920
|
+
return _ok()
|
|
921
|
+
|
|
922
|
+
# Save changes to a scene file
|
|
923
|
+
func save_scene(params: Dictionary) -> OperationResult:
|
|
924
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
925
|
+
_emit_output("Saving scene: " + full_scene_path)
|
|
926
|
+
|
|
927
|
+
var opened := _open_scene(full_scene_path)
|
|
928
|
+
if not opened.is_valid():
|
|
929
|
+
return _failed(opened.errors)
|
|
930
|
+
|
|
931
|
+
# A new path saves a copy; otherwise the scene is rewritten in place.
|
|
932
|
+
var save_path: String = full_scene_path
|
|
933
|
+
if params.has("new_path"):
|
|
934
|
+
save_path = _res_path(_param_string(params, "new_path"))
|
|
935
|
+
|
|
936
|
+
var save_result := _save_scene_root(opened.root, save_path)
|
|
937
|
+
if not save_result.ok:
|
|
938
|
+
return save_result
|
|
939
|
+
|
|
940
|
+
_emit_output("Scene saved successfully to: " + save_path)
|
|
941
|
+
return _ok()
|
|
942
|
+
|
|
943
|
+
# JSON decodes into Variant, so the conversions below are the one place where
|
|
944
|
+
# untyped values are narrowed. Every suppression in this file lives here.
|
|
945
|
+
|
|
946
|
+
# Helper: Narrow a JSON scalar to float
|
|
947
|
+
func _variant_to_float(value: Variant, default_value: float = 0.0) -> float:
|
|
948
|
+
if value is float or value is int or value is bool:
|
|
949
|
+
@warning_ignore("unsafe_call_argument")
|
|
950
|
+
return float(value)
|
|
951
|
+
if value is String:
|
|
952
|
+
var text: String = value
|
|
953
|
+
return text.to_float()
|
|
954
|
+
return default_value
|
|
955
|
+
|
|
956
|
+
# Helper: Narrow a JSON scalar to int (JSON numbers decode as float)
|
|
957
|
+
func _variant_to_int(value: Variant, default_value: int = 0) -> int:
|
|
958
|
+
if value is float or value is int or value is bool:
|
|
959
|
+
@warning_ignore("unsafe_call_argument", "narrowing_conversion")
|
|
960
|
+
return int(value)
|
|
961
|
+
if value is String:
|
|
962
|
+
var text: String = value
|
|
963
|
+
return text.to_int()
|
|
964
|
+
return default_value
|
|
965
|
+
|
|
966
|
+
# Helper: Narrow a JSON scalar to bool
|
|
967
|
+
func _variant_to_bool(value: Variant) -> bool:
|
|
968
|
+
if value is bool:
|
|
969
|
+
return value
|
|
970
|
+
if value is float or value is int:
|
|
971
|
+
return not is_zero_approx(_variant_to_float(value))
|
|
972
|
+
if value is String:
|
|
973
|
+
var text: String = value
|
|
974
|
+
return text.to_lower() == "true"
|
|
975
|
+
return false
|
|
976
|
+
|
|
977
|
+
# Helper: Read a float member out of a JSON object, tolerating ints
|
|
978
|
+
func _json_float(source: Dictionary, key: String, default_value: float = 0.0) -> float:
|
|
979
|
+
return _variant_to_float(source.get(key, default_value), default_value)
|
|
980
|
+
|
|
981
|
+
# Helper: Read an int member out of a JSON object
|
|
982
|
+
func _json_int(source: Dictionary, key: String, default_value: int = 0) -> int:
|
|
983
|
+
return _variant_to_int(source.get(key, default_value), default_value)
|
|
984
|
+
|
|
985
|
+
# Helper: Read a nested JSON object member
|
|
986
|
+
func _json_object(source: Dictionary, key: String) -> Dictionary:
|
|
987
|
+
var value: Variant = source.get(key, {})
|
|
988
|
+
if value is Dictionary:
|
|
989
|
+
return value
|
|
990
|
+
return {}
|
|
991
|
+
|
|
992
|
+
# Helper: Convert a JSON value to the correct Godot type based on a node's property type
|
|
993
|
+
func _convert_property_value(node: Object, prop_name: String, value: Variant) -> Variant:
|
|
994
|
+
for prop in node.get_property_list():
|
|
995
|
+
if prop["name"] == prop_name:
|
|
996
|
+
var type_id: int = _variant_to_int(prop.get("type", 0))
|
|
997
|
+
return _convert_to_property_type(type_id, value)
|
|
998
|
+
return value
|
|
999
|
+
|
|
1000
|
+
# Helper: Convert a JSON value to a specific Variant type, or return it unchanged
|
|
1001
|
+
# when the value does not carry the shape that type needs
|
|
1002
|
+
func _convert_to_property_type(type_id: int, value: Variant) -> Variant:
|
|
1003
|
+
var obj: Dictionary = value if value is Dictionary else {}
|
|
1004
|
+
var text: String = value if value is String else ""
|
|
1005
|
+
|
|
1006
|
+
match type_id:
|
|
1007
|
+
TYPE_VECTOR2:
|
|
1008
|
+
if obj.has("x") and obj.has("y"):
|
|
1009
|
+
return Vector2(_json_float(obj, "x"), _json_float(obj, "y"))
|
|
1010
|
+
TYPE_VECTOR2I:
|
|
1011
|
+
if obj.has("x") and obj.has("y"):
|
|
1012
|
+
return Vector2i(_json_int(obj, "x"), _json_int(obj, "y"))
|
|
1013
|
+
TYPE_VECTOR3:
|
|
1014
|
+
if obj.has("x") and obj.has("y"):
|
|
1015
|
+
return Vector3(_json_float(obj, "x"), _json_float(obj, "y"), _json_float(obj, "z"))
|
|
1016
|
+
TYPE_VECTOR3I:
|
|
1017
|
+
if obj.has("x") and obj.has("y"):
|
|
1018
|
+
return Vector3i(_json_int(obj, "x"), _json_int(obj, "y"), _json_int(obj, "z"))
|
|
1019
|
+
TYPE_COLOR:
|
|
1020
|
+
if obj.has("r") and obj.has("g") and obj.has("b"):
|
|
1021
|
+
return Color(_json_float(obj, "r"), _json_float(obj, "g"), _json_float(obj, "b"), _json_float(obj, "a", 1.0))
|
|
1022
|
+
if text.begins_with("#"):
|
|
1023
|
+
return Color.html(text)
|
|
1024
|
+
TYPE_QUATERNION:
|
|
1025
|
+
if value is Dictionary:
|
|
1026
|
+
return Quaternion(_json_float(obj, "x"), _json_float(obj, "y"), _json_float(obj, "z"), _json_float(obj, "w", 1.0))
|
|
1027
|
+
TYPE_RECT2:
|
|
1028
|
+
if obj.has("position") and obj.has("size"):
|
|
1029
|
+
var pos: Dictionary = _json_object(obj, "position")
|
|
1030
|
+
var sz: Dictionary = _json_object(obj, "size")
|
|
1031
|
+
return Rect2(_json_float(pos, "x"), _json_float(pos, "y"), _json_float(sz, "x"), _json_float(sz, "y"))
|
|
1032
|
+
TYPE_AABB:
|
|
1033
|
+
if obj.has("position") and obj.has("size"):
|
|
1034
|
+
var pos: Dictionary = _json_object(obj, "position")
|
|
1035
|
+
var sz: Dictionary = _json_object(obj, "size")
|
|
1036
|
+
return AABB(
|
|
1037
|
+
Vector3(_json_float(pos, "x"), _json_float(pos, "y"), _json_float(pos, "z")),
|
|
1038
|
+
Vector3(_json_float(sz, "x"), _json_float(sz, "y"), _json_float(sz, "z"))
|
|
1039
|
+
)
|
|
1040
|
+
TYPE_BASIS:
|
|
1041
|
+
if obj.has("x") and obj.has("y") and obj.has("z"):
|
|
1042
|
+
return _json_basis(obj)
|
|
1043
|
+
TYPE_TRANSFORM3D:
|
|
1044
|
+
if obj.has("basis") and obj.has("origin"):
|
|
1045
|
+
var basis_d: Dictionary = _json_object(obj, "basis")
|
|
1046
|
+
var origin_d: Dictionary = _json_object(obj, "origin")
|
|
1047
|
+
var basis: Basis = Basis.IDENTITY
|
|
1048
|
+
if basis_d.has("x"):
|
|
1049
|
+
basis = _json_basis(basis_d)
|
|
1050
|
+
var origin := Vector3(_json_float(origin_d, "x"), _json_float(origin_d, "y"), _json_float(origin_d, "z"))
|
|
1051
|
+
return Transform3D(basis, origin)
|
|
1052
|
+
TYPE_TRANSFORM2D:
|
|
1053
|
+
if obj.has("x") and obj.has("y") and obj.has("origin"):
|
|
1054
|
+
var tx: Dictionary = _json_object(obj, "x")
|
|
1055
|
+
var ty: Dictionary = _json_object(obj, "y")
|
|
1056
|
+
var t_origin: Dictionary = _json_object(obj, "origin")
|
|
1057
|
+
return Transform2D(
|
|
1058
|
+
Vector2(_json_float(tx, "x"), _json_float(tx, "y")),
|
|
1059
|
+
Vector2(_json_float(ty, "x"), _json_float(ty, "y")),
|
|
1060
|
+
Vector2(_json_float(t_origin, "x"), _json_float(t_origin, "y"))
|
|
1061
|
+
)
|
|
1062
|
+
TYPE_BOOL:
|
|
1063
|
+
return _variant_to_bool(value)
|
|
1064
|
+
TYPE_INT:
|
|
1065
|
+
return _variant_to_int(value)
|
|
1066
|
+
TYPE_FLOAT:
|
|
1067
|
+
return _variant_to_float(value)
|
|
1068
|
+
TYPE_STRING:
|
|
1069
|
+
return str(value)
|
|
1070
|
+
TYPE_NODE_PATH:
|
|
1071
|
+
return NodePath(str(value))
|
|
1072
|
+
TYPE_OBJECT:
|
|
1073
|
+
if text.begins_with("res://"):
|
|
1074
|
+
if ResourceLoader.exists(text):
|
|
1075
|
+
var res := load(text)
|
|
1076
|
+
if res != null:
|
|
1077
|
+
return res
|
|
1078
|
+
printerr("Failed to load resource from path: " + text)
|
|
1079
|
+
return value
|
|
1080
|
+
return value
|
|
1081
|
+
|
|
1082
|
+
# Helper: Build a Basis from a JSON object holding x/y/z column objects
|
|
1083
|
+
func _json_basis(source: Dictionary) -> Basis:
|
|
1084
|
+
var bx: Dictionary = _json_object(source, "x")
|
|
1085
|
+
var by: Dictionary = _json_object(source, "y")
|
|
1086
|
+
var bz: Dictionary = _json_object(source, "z")
|
|
1087
|
+
return Basis(
|
|
1088
|
+
Vector3(_json_float(bx, "x"), _json_float(bx, "y"), _json_float(bx, "z")),
|
|
1089
|
+
Vector3(_json_float(by, "x"), _json_float(by, "y"), _json_float(by, "z")),
|
|
1090
|
+
Vector3(_json_float(bz, "x"), _json_float(bz, "y"), _json_float(bz, "z"))
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
# Helper: Safe variant-to-string for scene reading
|
|
1094
|
+
func _variant_to_string(value: Variant) -> String:
|
|
1095
|
+
if value == null:
|
|
1096
|
+
return "null"
|
|
1097
|
+
if value is String:
|
|
1098
|
+
return value
|
|
1099
|
+
if value is bool:
|
|
1100
|
+
return "true" if value else "false"
|
|
1101
|
+
if value is NodePath:
|
|
1102
|
+
return str(value)
|
|
1103
|
+
return str(value)
|
|
1104
|
+
|
|
1105
|
+
# Read a scene file and return its full node tree as JSON
|
|
1106
|
+
func read_scene(params: Dictionary) -> OperationResult:
|
|
1107
|
+
if not params.has("scene_path"):
|
|
1108
|
+
return _fail("scene_path is required")
|
|
1109
|
+
|
|
1110
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
1111
|
+
log_info("Reading scene: " + full_scene_path)
|
|
1112
|
+
|
|
1113
|
+
if not FileAccess.file_exists(full_scene_path):
|
|
1114
|
+
return _fail("Scene file does not exist at: " + full_scene_path)
|
|
1115
|
+
|
|
1116
|
+
var scene := load(full_scene_path) as PackedScene
|
|
1117
|
+
if not scene:
|
|
1118
|
+
# A scene that references missing external resources still has readable
|
|
1119
|
+
# text, so fall back to the raw file rather than failing the operation.
|
|
1120
|
+
var f := FileAccess.open(full_scene_path, FileAccess.READ)
|
|
1121
|
+
if f:
|
|
1122
|
+
var raw_content: String = f.get_as_text()
|
|
1123
|
+
f.close()
|
|
1124
|
+
_emit_output("SCENE_JSON_START")
|
|
1125
|
+
_emit_output(JSON.stringify({"error": "Failed to instantiate scene, returning raw text", "raw": raw_content.substr(0, 4096)}))
|
|
1126
|
+
_emit_output("SCENE_JSON_END")
|
|
1127
|
+
return _ok()
|
|
1128
|
+
return _fail("Failed to load scene: " + full_scene_path, PackedStringArray([
|
|
1129
|
+
"The scene may reference missing external resources.",
|
|
1130
|
+
]))
|
|
1131
|
+
|
|
1132
|
+
var scene_root := scene.instantiate()
|
|
1133
|
+
if scene_root == null:
|
|
1134
|
+
return _fail("Failed to instantiate scene: " + full_scene_path)
|
|
1135
|
+
|
|
1136
|
+
var tree_data: Dictionary = _walk_scene_tree(scene_root)
|
|
1137
|
+
|
|
1138
|
+
# Output as JSON for the TypeScript side to parse
|
|
1139
|
+
_emit_output("SCENE_JSON_START")
|
|
1140
|
+
_emit_output(JSON.stringify(tree_data))
|
|
1141
|
+
_emit_output("SCENE_JSON_END")
|
|
1142
|
+
|
|
1143
|
+
# queue_free() is never processed under --script before quit(), so free
|
|
1144
|
+
# the tree immediately.
|
|
1145
|
+
scene_root.free()
|
|
1146
|
+
return _ok()
|
|
1147
|
+
|
|
1148
|
+
func _walk_scene_tree(node: Node) -> Dictionary:
|
|
1149
|
+
var info: Dictionary = {
|
|
1150
|
+
"name": node.name,
|
|
1151
|
+
"type": node.get_class(),
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
# Include script path if attached
|
|
1155
|
+
var node_script: Script = node.get_script()
|
|
1156
|
+
if node_script != null:
|
|
1157
|
+
info["script"] = node_script.resource_path
|
|
1158
|
+
|
|
1159
|
+
# Collect non-default properties
|
|
1160
|
+
var props: Dictionary = {}
|
|
1161
|
+
for prop in node.get_property_list():
|
|
1162
|
+
var prop_name: String = prop["name"]
|
|
1163
|
+
var usage: int = prop.get("usage", 0)
|
|
1164
|
+
# Only include editor-visible, storage properties
|
|
1165
|
+
if usage & PROPERTY_USAGE_EDITOR and usage & PROPERTY_USAGE_STORAGE:
|
|
1166
|
+
var value: Variant = node.get(prop_name)
|
|
1167
|
+
if value != null:
|
|
1168
|
+
props[prop_name] = _variant_to_string(value)
|
|
1169
|
+
|
|
1170
|
+
if props.size() > 0:
|
|
1171
|
+
info["properties"] = props
|
|
1172
|
+
|
|
1173
|
+
# Include groups
|
|
1174
|
+
var groups: Array[StringName] = node.get_groups()
|
|
1175
|
+
if groups.size() > 0:
|
|
1176
|
+
var group_names: Array[String] = []
|
|
1177
|
+
for g in groups:
|
|
1178
|
+
group_names.append(String(g))
|
|
1179
|
+
info["groups"] = group_names
|
|
1180
|
+
|
|
1181
|
+
# Recurse into children
|
|
1182
|
+
var children_arr: Array[Dictionary] = []
|
|
1183
|
+
for child in node.get_children():
|
|
1184
|
+
children_arr.append(_walk_scene_tree(child))
|
|
1185
|
+
|
|
1186
|
+
if children_arr.size() > 0:
|
|
1187
|
+
info["children"] = children_arr
|
|
1188
|
+
|
|
1189
|
+
return info
|
|
1190
|
+
|
|
1191
|
+
# Modify a node's properties in a scene file
|
|
1192
|
+
func modify_node(params: Dictionary) -> OperationResult:
|
|
1193
|
+
if not params.has("scene_path") or not params.has("node_path") or not params.has("properties"):
|
|
1194
|
+
return _fail("scene_path, node_path, and properties are required")
|
|
1195
|
+
|
|
1196
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
1197
|
+
log_info("Modifying node in scene: " + full_scene_path)
|
|
1198
|
+
|
|
1199
|
+
var opened := _open_scene(full_scene_path)
|
|
1200
|
+
if not opened.is_valid():
|
|
1201
|
+
return _failed(opened.errors)
|
|
1202
|
+
|
|
1203
|
+
var node_path: String = _param_string(params, "node_path")
|
|
1204
|
+
var target := _resolve_scene_node(opened.root, node_path)
|
|
1205
|
+
if target == null:
|
|
1206
|
+
return _fail("Node not found: " + node_path)
|
|
1207
|
+
|
|
1208
|
+
_apply_properties(target, _param_dictionary(params, "properties"))
|
|
1209
|
+
|
|
1210
|
+
var save_result := _save_scene_root(opened.root, full_scene_path)
|
|
1211
|
+
if not save_result.ok:
|
|
1212
|
+
return save_result
|
|
1213
|
+
|
|
1214
|
+
_emit_output("Node modified successfully in: " + full_scene_path)
|
|
1215
|
+
return _ok()
|
|
1216
|
+
|
|
1217
|
+
# Remove a node from a scene file
|
|
1218
|
+
func remove_node(params: Dictionary) -> OperationResult:
|
|
1219
|
+
if not params.has("scene_path") or not params.has("node_path"):
|
|
1220
|
+
return _fail("scene_path and node_path are required")
|
|
1221
|
+
|
|
1222
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
1223
|
+
log_info("Removing node from scene: " + full_scene_path)
|
|
1224
|
+
|
|
1225
|
+
var opened := _open_scene(full_scene_path)
|
|
1226
|
+
if not opened.is_valid():
|
|
1227
|
+
return _failed(opened.errors)
|
|
1228
|
+
var scene_root: Node = opened.root
|
|
1229
|
+
|
|
1230
|
+
var node_path: String = _param_string(params, "node_path")
|
|
1231
|
+
var target := _resolve_scene_node(scene_root, node_path)
|
|
1232
|
+
if target == null:
|
|
1233
|
+
return _fail("Node not found: " + node_path)
|
|
1234
|
+
|
|
1235
|
+
if target == scene_root:
|
|
1236
|
+
return _fail("Cannot remove the root node of a scene")
|
|
1237
|
+
|
|
1238
|
+
var removed_name: String = String(target.name)
|
|
1239
|
+
target.get_parent().remove_child(target)
|
|
1240
|
+
# queue_free() is never processed under --script before quit(); free the
|
|
1241
|
+
# detached subtree immediately so it cannot leak at exit.
|
|
1242
|
+
target.free()
|
|
1243
|
+
|
|
1244
|
+
var save_result := _save_scene_root(scene_root, full_scene_path)
|
|
1245
|
+
if not save_result.ok:
|
|
1246
|
+
return save_result
|
|
1247
|
+
|
|
1248
|
+
_emit_output("Node '" + removed_name + "' removed successfully from: " + full_scene_path)
|
|
1249
|
+
return _ok()
|
|
1250
|
+
|
|
1251
|
+
# Attach a script to a node in a scene file
|
|
1252
|
+
func attach_script(params: Dictionary) -> OperationResult:
|
|
1253
|
+
if not params.has("scene_path") or not params.has("node_path") or not params.has("script_path"):
|
|
1254
|
+
return _fail("scene_path, node_path, and script_path are required")
|
|
1255
|
+
|
|
1256
|
+
var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
|
|
1257
|
+
var full_script_path: String = _res_path(_param_string(params, "script_path"))
|
|
1258
|
+
log_info("Attaching script " + full_script_path + " to node in scene: " + full_scene_path)
|
|
1259
|
+
|
|
1260
|
+
if not FileAccess.file_exists(full_script_path):
|
|
1261
|
+
return _fail("Script file does not exist at: " + full_script_path)
|
|
1262
|
+
|
|
1263
|
+
var opened := _open_scene(full_scene_path)
|
|
1264
|
+
if not opened.is_valid():
|
|
1265
|
+
return _failed(opened.errors)
|
|
1266
|
+
|
|
1267
|
+
var node_path: String = _param_string(params, "node_path")
|
|
1268
|
+
var target := _resolve_scene_node(opened.root, node_path)
|
|
1269
|
+
if target == null:
|
|
1270
|
+
return _fail("Node not found: " + node_path)
|
|
1271
|
+
|
|
1272
|
+
var script := load(full_script_path) as Script
|
|
1273
|
+
if script == null:
|
|
1274
|
+
return _fail("Failed to load script: " + full_script_path)
|
|
1275
|
+
|
|
1276
|
+
target.set_script(script)
|
|
1277
|
+
|
|
1278
|
+
var save_result := _save_scene_root(opened.root, full_scene_path)
|
|
1279
|
+
if not save_result.ok:
|
|
1280
|
+
return save_result
|
|
1281
|
+
|
|
1282
|
+
_emit_output("Script '" + full_script_path + "' attached successfully to node in: " + full_scene_path)
|
|
1283
|
+
return _ok()
|
|
1284
|
+
|
|
1285
|
+
# Create a resource file (.tres)
|
|
1286
|
+
func create_resource(params: Dictionary) -> OperationResult:
|
|
1287
|
+
if not params.has("resource_type") or not params.has("resource_path"):
|
|
1288
|
+
return _fail("resource_type and resource_path are required")
|
|
1289
|
+
|
|
1290
|
+
var resource_type: String = _param_string(params, "resource_type")
|
|
1291
|
+
var full_resource_path: String = _res_path(_param_string(params, "resource_path"))
|
|
1292
|
+
log_info("Creating resource of type " + resource_type + " at: " + full_resource_path)
|
|
1293
|
+
|
|
1294
|
+
if not ClassDB.class_exists(resource_type):
|
|
1295
|
+
return _fail("Unknown resource type: " + resource_type, PackedStringArray([
|
|
1296
|
+
"Must be a valid Godot class name (e.g., StandardMaterial3D, AudioStreamPlayer, Theme)",
|
|
1297
|
+
]))
|
|
1298
|
+
|
|
1299
|
+
if not ClassDB.can_instantiate(resource_type):
|
|
1300
|
+
return _fail("Cannot instantiate resource type: " + resource_type)
|
|
1301
|
+
|
|
1302
|
+
var instance: Object = ClassDB.instantiate(resource_type)
|
|
1303
|
+
if instance == null:
|
|
1304
|
+
return _fail("Failed to instantiate resource of type: " + resource_type)
|
|
1305
|
+
|
|
1306
|
+
var resource := instance as Resource
|
|
1307
|
+
if resource == null:
|
|
1308
|
+
return _fail("Type " + resource_type + " is not a Resource subclass")
|
|
1309
|
+
|
|
1310
|
+
_apply_properties(resource, _param_dictionary(params, "properties"))
|
|
1311
|
+
|
|
1312
|
+
var save_result := _save_resource(resource, full_resource_path, "resource")
|
|
1313
|
+
if not save_result.ok:
|
|
1314
|
+
return save_result
|
|
1315
|
+
|
|
1316
|
+
_emit_output("Resource created successfully at: " + full_resource_path)
|
|
1317
|
+
return _ok()
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
func manage_resource(params: Dictionary) -> OperationResult:
|
|
1321
|
+
var full_path: String = _res_path(_param_string(params, "resource_path"))
|
|
1322
|
+
var action: String = _param_string(params, "action", "read")
|
|
1323
|
+
|
|
1324
|
+
if action != "read" and action != "modify":
|
|
1325
|
+
return _fail("Unknown manage_resource action: " + action, PackedStringArray([
|
|
1326
|
+
"Allowed actions: read, modify",
|
|
1327
|
+
]))
|
|
1328
|
+
|
|
1329
|
+
var opened := _open_resource(full_path, "Resource")
|
|
1330
|
+
if not opened.is_valid():
|
|
1331
|
+
return _failed(opened.errors)
|
|
1332
|
+
var resource: Resource = opened.resource
|
|
1333
|
+
|
|
1334
|
+
if action == "read":
|
|
1335
|
+
var props: Dictionary = {}
|
|
1336
|
+
for prop in resource.get_property_list():
|
|
1337
|
+
var usage: int = prop.get("usage", 0)
|
|
1338
|
+
if usage & PROPERTY_USAGE_STORAGE:
|
|
1339
|
+
var prop_name: String = prop["name"]
|
|
1340
|
+
props[prop_name] = str(resource.get(prop_name))
|
|
1341
|
+
_emit_output("RESOURCE_JSON_START")
|
|
1342
|
+
_emit_output(JSON.stringify({"type": resource.get_class(), "path": full_path, "properties": props}))
|
|
1343
|
+
_emit_output("RESOURCE_JSON_END")
|
|
1344
|
+
return _ok()
|
|
1345
|
+
|
|
1346
|
+
_apply_properties(resource, _param_dictionary(params, "properties"))
|
|
1347
|
+
|
|
1348
|
+
var save_result := _save_resource(resource, full_path, "resource")
|
|
1349
|
+
if not save_result.ok:
|
|
1350
|
+
return save_result
|
|
1351
|
+
|
|
1352
|
+
_emit_output("Resource modified: " + full_path)
|
|
1353
|
+
return _ok()
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
func manage_scene_signals(params: Dictionary) -> OperationResult:
|
|
1357
|
+
var full_path: String = _res_path(_param_string(params, "scene_path"))
|
|
1358
|
+
var action: String = _param_string(params, "action", "list")
|
|
1359
|
+
|
|
1360
|
+
if not FileAccess.file_exists(full_path):
|
|
1361
|
+
return _fail("Scene not found: " + full_path)
|
|
1362
|
+
|
|
1363
|
+
var content: String = FileAccess.get_file_as_string(full_path)
|
|
1364
|
+
|
|
1365
|
+
if action == "list":
|
|
1366
|
+
var connections: Array[String] = []
|
|
1367
|
+
var lines: PackedStringArray = content.split("\n")
|
|
1368
|
+
for line in lines:
|
|
1369
|
+
if line.begins_with("[connection"):
|
|
1370
|
+
connections.append(line.strip_edges())
|
|
1371
|
+
_emit_output("SIGNALS_JSON_START")
|
|
1372
|
+
_emit_output(JSON.stringify({"connections": connections}))
|
|
1373
|
+
_emit_output("SIGNALS_JSON_END")
|
|
1374
|
+
elif action == "add":
|
|
1375
|
+
var signal_name: String = _param_string(params, "signal_name")
|
|
1376
|
+
var source_path: String = _param_string(params, "source_path", ".")
|
|
1377
|
+
var target_path: String = _param_string(params, "target_path", ".")
|
|
1378
|
+
var method: String = _param_string(params, "method")
|
|
1379
|
+
var conn_line: String = '[connection signal="%s" from="%s" to="%s" method="%s"]' % [signal_name, source_path, target_path, method]
|
|
1380
|
+
content += "\n" + conn_line + "\n"
|
|
1381
|
+
var file := FileAccess.open(full_path, FileAccess.WRITE)
|
|
1382
|
+
if file == null:
|
|
1383
|
+
return _fail("Failed to open scene for writing: " + full_path + ", error: " + str(FileAccess.get_open_error()))
|
|
1384
|
+
@warning_ignore("return_value_discarded")
|
|
1385
|
+
file.store_string(content)
|
|
1386
|
+
file.close()
|
|
1387
|
+
_emit_output("Signal connection added: " + conn_line)
|
|
1388
|
+
elif action == "remove":
|
|
1389
|
+
var signal_name: String = _param_string(params, "signal_name")
|
|
1390
|
+
var lines: PackedStringArray = content.split("\n")
|
|
1391
|
+
var new_lines := PackedStringArray()
|
|
1392
|
+
for line in lines:
|
|
1393
|
+
if not (line.begins_with("[connection") and signal_name in line):
|
|
1394
|
+
@warning_ignore("return_value_discarded")
|
|
1395
|
+
new_lines.append(line)
|
|
1396
|
+
var file := FileAccess.open(full_path, FileAccess.WRITE)
|
|
1397
|
+
if file == null:
|
|
1398
|
+
return _fail("Failed to open scene for writing: " + full_path + ", error: " + str(FileAccess.get_open_error()))
|
|
1399
|
+
@warning_ignore("return_value_discarded")
|
|
1400
|
+
file.store_string("\n".join(new_lines))
|
|
1401
|
+
file.close()
|
|
1402
|
+
_emit_output("Signal connections for '%s' removed" % signal_name)
|
|
1403
|
+
else:
|
|
1404
|
+
return _fail("Unknown manage_scene_signals action: " + action, PackedStringArray([
|
|
1405
|
+
"Allowed actions: list, add, remove",
|
|
1406
|
+
]))
|
|
1407
|
+
|
|
1408
|
+
return _ok()
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
func manage_theme_resource(params: Dictionary) -> OperationResult:
|
|
1412
|
+
var full_path: String = _res_path(_param_string(params, "resource_path"))
|
|
1413
|
+
var action: String = _param_string(params, "action", "read")
|
|
1414
|
+
var properties: Dictionary = _param_dictionary(params, "properties")
|
|
1415
|
+
|
|
1416
|
+
if action == "create":
|
|
1417
|
+
var new_theme := Theme.new()
|
|
1418
|
+
for key: String in properties:
|
|
1419
|
+
new_theme.set(key, properties[key])
|
|
1420
|
+
|
|
1421
|
+
var create_result := _save_resource(new_theme, full_path, "theme")
|
|
1422
|
+
if not create_result.ok:
|
|
1423
|
+
return create_result
|
|
1424
|
+
|
|
1425
|
+
_emit_output("Theme created at: " + full_path)
|
|
1426
|
+
return _ok()
|
|
1427
|
+
|
|
1428
|
+
if action != "read" and action != "modify":
|
|
1429
|
+
return _fail("Unknown manage_theme_resource action: " + action, PackedStringArray([
|
|
1430
|
+
"Allowed actions: create, read, modify",
|
|
1431
|
+
]))
|
|
1432
|
+
|
|
1433
|
+
var opened := _open_resource(full_path, "Theme")
|
|
1434
|
+
if not opened.is_valid():
|
|
1435
|
+
return _failed(opened.errors)
|
|
1436
|
+
var theme: Resource = opened.resource
|
|
1437
|
+
|
|
1438
|
+
if action == "read":
|
|
1439
|
+
_emit_output("THEME_JSON_START")
|
|
1440
|
+
_emit_output(JSON.stringify({"type": theme.get_class(), "path": full_path}))
|
|
1441
|
+
_emit_output("THEME_JSON_END")
|
|
1442
|
+
return _ok()
|
|
1443
|
+
|
|
1444
|
+
for key: String in properties:
|
|
1445
|
+
theme.set(key, properties[key])
|
|
1446
|
+
|
|
1447
|
+
var save_result := _save_resource(theme, full_path, "theme")
|
|
1448
|
+
if not save_result.ok:
|
|
1449
|
+
return save_result
|
|
1450
|
+
|
|
1451
|
+
_emit_output("Theme modified: " + full_path)
|
|
1452
|
+
return _ok()
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
func manage_scene_structure(params: Dictionary) -> OperationResult:
|
|
1456
|
+
var full_path: String = _res_path(_param_string(params, "scene_path"))
|
|
1457
|
+
var action: String = _param_string(params, "action", "rename")
|
|
1458
|
+
var node_path_str: String = _param_string(params, "node_path")
|
|
1459
|
+
|
|
1460
|
+
var opened := _open_scene(full_path)
|
|
1461
|
+
if not opened.is_valid():
|
|
1462
|
+
return _failed(opened.errors)
|
|
1463
|
+
var scene_root: Node = opened.root
|
|
1464
|
+
|
|
1465
|
+
var target := _resolve_scene_node(scene_root, node_path_str)
|
|
1466
|
+
if target == null:
|
|
1467
|
+
return _fail("Node not found: " + node_path_str)
|
|
1468
|
+
|
|
1469
|
+
if action == "rename":
|
|
1470
|
+
var new_name: String = _param_string(params, "new_name")
|
|
1471
|
+
if new_name.is_empty():
|
|
1472
|
+
return _fail("new_name is required for rename")
|
|
1473
|
+
target.name = new_name
|
|
1474
|
+
_emit_output("Node renamed to '%s'" % target.name)
|
|
1475
|
+
elif action == "duplicate":
|
|
1476
|
+
if target == scene_root:
|
|
1477
|
+
return _fail("Cannot duplicate the root node")
|
|
1478
|
+
var dup := target.duplicate()
|
|
1479
|
+
target.get_parent().add_child(dup, true)
|
|
1480
|
+
_set_owner_recursive(dup, scene_root)
|
|
1481
|
+
_emit_output("Node duplicated: %s (as '%s')" % [node_path_str, dup.name])
|
|
1482
|
+
elif action == "move":
|
|
1483
|
+
var new_parent_path: String = _param_string(params, "new_parent_path")
|
|
1484
|
+
if new_parent_path.is_empty():
|
|
1485
|
+
return _fail("new_parent_path is required for move")
|
|
1486
|
+
if target == scene_root:
|
|
1487
|
+
return _fail("Cannot move the root node")
|
|
1488
|
+
var new_parent := _resolve_scene_node(scene_root, new_parent_path)
|
|
1489
|
+
if new_parent == null:
|
|
1490
|
+
return _fail("New parent not found: " + new_parent_path)
|
|
1491
|
+
if new_parent == target or _is_ancestor(target, new_parent):
|
|
1492
|
+
return _fail("Cannot move a node into itself or one of its descendants")
|
|
1493
|
+
target.get_parent().remove_child(target)
|
|
1494
|
+
# Descendants keep their owner across remove_child, and add_child warns
|
|
1495
|
+
# that the pending owner would be inconsistent; clear owners first and
|
|
1496
|
+
# reassign them once the subtree is in place.
|
|
1497
|
+
_clear_owner_recursive(target)
|
|
1498
|
+
new_parent.add_child(target, true)
|
|
1499
|
+
_set_owner_recursive(target, scene_root)
|
|
1500
|
+
_emit_output("Node moved: %s -> parent %s (as '%s')" % [node_path_str, new_parent_path, target.name])
|
|
1501
|
+
else:
|
|
1502
|
+
return _fail("Unknown manage_scene_structure action: " + action, PackedStringArray([
|
|
1503
|
+
"Allowed actions: rename, duplicate, move",
|
|
1504
|
+
]))
|
|
1505
|
+
|
|
1506
|
+
var save_result := _save_scene_root(scene_root, full_path)
|
|
1507
|
+
if not save_result.ok:
|
|
1508
|
+
return save_result
|
|
1509
|
+
|
|
1510
|
+
_emit_output("Scene structure saved: " + full_path)
|
|
1511
|
+
return _ok()
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
func _clear_owner_recursive(node: Node) -> void:
|
|
1515
|
+
node.owner = null
|
|
1516
|
+
for c in node.get_children():
|
|
1517
|
+
_clear_owner_recursive(c)
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
func _set_owner_recursive(node: Node, owner_root: Node) -> void:
|
|
1521
|
+
node.owner = owner_root
|
|
1522
|
+
if node.scene_file_path != "":
|
|
1523
|
+
return
|
|
1524
|
+
for c in node.get_children():
|
|
1525
|
+
_set_owner_recursive(c, owner_root)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
func _is_ancestor(node: Node, maybe_descendant: Node) -> bool:
|
|
1529
|
+
var n := maybe_descendant.get_parent()
|
|
1530
|
+
while n != null:
|
|
1531
|
+
if n == node:
|
|
1532
|
+
return true
|
|
1533
|
+
n = n.get_parent()
|
|
1534
|
+
return false
|