@beremaran/godot-agent-loop 1.0.1 → 1.1.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/README.md +59 -34
- package/addons/godot_agent_loop/README.md +26 -16
- package/addons/godot_agent_loop/plugin.cfg +3 -2
- package/addons/godot_agent_loop/plugin.gd +840 -25
- package/agent-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/adapter-manifest.json +3 -3
- package/agent-plugin/pi/extension.ts +1 -1
- package/agent-plugin/skills/build-godot-game/SKILL.md +26 -5
- package/agent-plugin/skills/debug-godot-game/SKILL.md +20 -7
- package/agent-plugin/skills/ship-godot-game/SKILL.md +13 -3
- package/agent-plugin/skills/verify-godot-change/SKILL.md +17 -2
- package/build/authoring-session-manager.js +24 -1
- package/build/domain-tool-registries.js +5 -1
- package/build/editor-authoring-router.js +228 -0
- package/build/editor-bridge-protocol.js +2 -1
- package/build/editor-connection.js +29 -24
- package/build/editor-mutation-guard.js +3 -1
- package/build/editor-plugin-installer.js +2 -1
- package/build/editor-session-registry.js +392 -0
- package/build/editor-sync-queue.js +135 -0
- package/build/index.js +208 -29
- package/build/lifecycle-trace.js +80 -0
- package/build/scripts/mcp_editor_plugin.gd +840 -25
- package/build/scripts/mcp_interaction_server.gd +76 -2
- package/build/scripts/mcp_runtime/core_domain.gd +2 -1
- package/build/scripts/mcp_runtime/system_domain.gd +2 -0
- package/build/server-instructions.js +5 -5
- package/build/session-timing.js +17 -1
- package/build/tool-definitions.js +99 -6
- package/build/tool-handlers/game-tool-handlers.js +21 -4
- package/build/tool-handlers/lifecycle-tool-handlers.js +361 -27
- package/build/tool-handlers/project-handler-services.js +45 -6
- package/build/tool-handlers/project-tool-handlers.js +4 -4
- package/build/tool-manifest.js +29 -1
- package/build/tool-mutation-policy.js +1 -0
- package/build/tool-surface.js +4 -4
- package/build/utils.js +2 -2
- package/package.json +4 -3
- package/product.json +7 -5
|
@@ -8,8 +8,12 @@ extends EditorPlugin
|
|
|
8
8
|
var _server: TCPServer
|
|
9
9
|
var _peer: StreamPeerTCP
|
|
10
10
|
var _buffer: PackedByteArray = PackedByteArray()
|
|
11
|
-
var _port: int =
|
|
11
|
+
var _port: int = 0
|
|
12
12
|
var _secret: String = ""
|
|
13
|
+
var _project_path: String = ""
|
|
14
|
+
var _editor_pid: int = 0
|
|
15
|
+
var _editor_start_identity: String = ""
|
|
16
|
+
var _session_path: String = ""
|
|
13
17
|
var _activity_dock: VBoxContainer
|
|
14
18
|
var _activity_list: ItemList
|
|
15
19
|
var _connection_status: Label
|
|
@@ -21,29 +25,51 @@ var _session_authenticated: bool = false
|
|
|
21
25
|
var _server_version: String = ""
|
|
22
26
|
var _last_history_id: int = EditorUndoRedoManager.GLOBAL_HISTORY
|
|
23
27
|
var _activity_entries: Array[Dictionary] = []
|
|
28
|
+
var _activity_event_ids: Dictionary = {}
|
|
24
29
|
var _last_filesystem_sync: Dictionary = {}
|
|
30
|
+
var _saved_history_versions: Dictionary = {}
|
|
25
31
|
const MAX_ACTIVITY_ENTRIES: int = 200
|
|
26
|
-
const PROTOCOL_VERSION: String = "
|
|
27
|
-
const ADDON_VERSION: String = "1.0
|
|
32
|
+
const PROTOCOL_VERSION: String = "2"
|
|
33
|
+
const ADDON_VERSION: String = "1.1.0"
|
|
34
|
+
const SESSION_DIRECTORY: String = ".godot/godot_agent_loop"
|
|
35
|
+
const SESSION_FILE: String = "editor-session.json"
|
|
28
36
|
|
|
29
37
|
func _enter_tree() -> void:
|
|
30
38
|
set_process(true)
|
|
31
39
|
_port = _read_port()
|
|
32
40
|
_secret = OS.get_environment("GODOT_MCP_EDITOR_SECRET")
|
|
41
|
+
if _secret.is_empty():
|
|
42
|
+
_secret = Marshalls.raw_to_base64(Crypto.new().generate_random_bytes(32))
|
|
43
|
+
_project_path = ProjectSettings.globalize_path("res://").trim_suffix("/")
|
|
44
|
+
_editor_pid = OS.get_process_id()
|
|
45
|
+
_editor_start_identity = "%d-%d" % [_editor_pid, Time.get_ticks_usec()]
|
|
46
|
+
_session_path = _project_path.path_join(SESSION_DIRECTORY).path_join(SESSION_FILE)
|
|
33
47
|
_driver_paused = OS.get_environment("GODOT_MCP_EDITOR_START_PAUSED").strip_edges().to_lower() in ["1", "true", "yes"]
|
|
34
48
|
_server = TCPServer.new()
|
|
35
49
|
_create_activity_dock()
|
|
50
|
+
var scene_changed_error: Error = scene_changed.connect(_on_scene_changed) as Error
|
|
51
|
+
if scene_changed_error != OK:
|
|
52
|
+
push_error("Godot Agent Loop could not observe scene changes: %s" % error_string(scene_changed_error))
|
|
53
|
+
var scene_saved_error: Error = scene_saved.connect(_on_scene_saved) as Error
|
|
54
|
+
if scene_saved_error != OK:
|
|
55
|
+
push_error("Godot Agent Loop could not observe scene saves: %s" % error_string(scene_saved_error))
|
|
36
56
|
var error: int = _server.listen(_port, "127.0.0.1")
|
|
37
57
|
if error != OK:
|
|
38
58
|
push_error("Godot Agent Loop editor bridge could not listen on %d: %s" % [_port, error_string(error)])
|
|
39
59
|
_set_connection_status("Unavailable — port %d: %s" % [_port, error_string(error)])
|
|
40
|
-
elif _secret.is_empty():
|
|
41
|
-
_set_connection_status("Waiting — relaunch the editor through Godot Agent Loop")
|
|
42
60
|
else:
|
|
43
|
-
|
|
61
|
+
_port = _server.get_local_port()
|
|
62
|
+
var discovery_error: Error = _write_discovery_record()
|
|
63
|
+
if discovery_error != OK:
|
|
64
|
+
push_error("Godot Agent Loop could not publish editor discovery: %s" % error_string(discovery_error))
|
|
65
|
+
_set_connection_status("Unavailable — discovery record failed")
|
|
66
|
+
_server.stop()
|
|
67
|
+
else:
|
|
68
|
+
_set_connection_status("Waiting for an authenticated agent")
|
|
44
69
|
|
|
45
70
|
func _exit_tree() -> void:
|
|
46
71
|
set_process(false)
|
|
72
|
+
_remove_owned_discovery_record()
|
|
47
73
|
if _peer != null:
|
|
48
74
|
_peer.disconnect_from_host()
|
|
49
75
|
_peer = null
|
|
@@ -120,7 +146,7 @@ func _handle_request(line: String) -> void:
|
|
|
120
146
|
if not _session_authenticated:
|
|
121
147
|
_send({"id": request.get("id", null), "error": "handshake_required", "protocol_version": PROTOCOL_VERSION})
|
|
122
148
|
return
|
|
123
|
-
var result: Dictionary = _dispatch(command, request.get("params", {}))
|
|
149
|
+
var result: Dictionary = await _dispatch(command, request.get("params", {}))
|
|
124
150
|
result["id"] = request.get("id", null)
|
|
125
151
|
_send(result)
|
|
126
152
|
|
|
@@ -134,7 +160,11 @@ func _dispatch(command: String, raw_params: Variant) -> Dictionary:
|
|
|
134
160
|
"driver_state":
|
|
135
161
|
return _driver_state()
|
|
136
162
|
"filesystem_changed":
|
|
137
|
-
return _sync_filesystem(params)
|
|
163
|
+
return await _sync_filesystem(params)
|
|
164
|
+
"transaction":
|
|
165
|
+
return await _editor_transaction(params)
|
|
166
|
+
"resource_transaction":
|
|
167
|
+
return _editor_resource_transaction(params)
|
|
138
168
|
"select":
|
|
139
169
|
return _select(params)
|
|
140
170
|
"save":
|
|
@@ -167,7 +197,7 @@ func _dispatch(command: String, raw_params: Variant) -> Dictionary:
|
|
|
167
197
|
var redo_history: UndoRedo = redo_manager.get_history_undo_redo(_last_history_id)
|
|
168
198
|
return {"success": redo_history.redo(), "action": "redo"}
|
|
169
199
|
_:
|
|
170
|
-
return {"error": "unknown_command", "allowed": ["inspect", "activity", "driver_state", "filesystem_changed", "select", "save", "reload", "open_scene", "set_property", "rename_node", "undo", "redo"]}
|
|
200
|
+
return {"error": "unknown_command", "allowed": ["inspect", "activity", "driver_state", "filesystem_changed", "transaction", "resource_transaction", "select", "save", "reload", "open_scene", "set_property", "rename_node", "undo", "redo"]}
|
|
171
201
|
|
|
172
202
|
func _handshake(raw_params: Variant) -> Dictionary:
|
|
173
203
|
var params: Dictionary = raw_params if raw_params is Dictionary else {}
|
|
@@ -191,6 +221,10 @@ func _handshake(raw_params: Variant) -> Dictionary:
|
|
|
191
221
|
"addon_version": ADDON_VERSION,
|
|
192
222
|
"server_version": _server_version,
|
|
193
223
|
"godot_version": Engine.get_version_info().get("string", "unknown"),
|
|
224
|
+
"project_path": _project_path,
|
|
225
|
+
"editor_pid": _editor_pid,
|
|
226
|
+
"editor_start_identity": _editor_start_identity,
|
|
227
|
+
"paused": _driver_paused,
|
|
194
228
|
}
|
|
195
229
|
|
|
196
230
|
func _create_activity_dock() -> void:
|
|
@@ -233,7 +267,7 @@ func _create_activity_dock() -> void:
|
|
|
233
267
|
setup_help.name = "SetupHelp"
|
|
234
268
|
setup_help.fit_content = true
|
|
235
269
|
setup_help.custom_minimum_size = Vector2(320, 96)
|
|
236
|
-
setup_help.text = "
|
|
270
|
+
setup_help.text = "Install and enable this addon once for interactive projects.\nOpen Godot normally before or after the MCP; this dock waits for the matching authenticated agent.\nUse launch_editor only when no editor is already open."
|
|
237
271
|
_activity_dock.add_child(setup_help)
|
|
238
272
|
add_control_to_dock(DOCK_SLOT_RIGHT_BL, _activity_dock)
|
|
239
273
|
|
|
@@ -256,11 +290,12 @@ func _driver_state() -> Dictionary:
|
|
|
256
290
|
return {"success": true, "paused": _driver_paused, "agent_driving": not _driver_paused}
|
|
257
291
|
|
|
258
292
|
func _record_activity(params: Dictionary) -> Dictionary:
|
|
259
|
-
var
|
|
260
|
-
if
|
|
261
|
-
return {"
|
|
262
|
-
var
|
|
263
|
-
var
|
|
293
|
+
var event_id: int = _variant_int(params.get("event_id", 0))
|
|
294
|
+
if event_id > 0 and _activity_event_ids.has(event_id):
|
|
295
|
+
return {"success": true, "deduplicated": true, "event_id": event_id, "activity_count": _activity_entries.size()}
|
|
296
|
+
var event: String = str(params.get("event", params.get("phase", "state")))
|
|
297
|
+
var command: String = str(params.get("command", params.get("tool", "unknown")))
|
|
298
|
+
var target: String = str(params.get("target", params.get("target_backend", "unknown")))
|
|
264
299
|
var outcome: String = str(params.get("outcome", "running"))
|
|
265
300
|
var raw_duration_ms: Variant = params.get("duration_ms", 0)
|
|
266
301
|
var duration_ms: int = 0
|
|
@@ -270,28 +305,66 @@ func _record_activity(params: Dictionary) -> Dictionary:
|
|
|
270
305
|
var float_duration_ms: float = raw_duration_ms
|
|
271
306
|
duration_ms = roundi(float_duration_ms)
|
|
272
307
|
var correlation_id: String = str(params.get("correlation_id", ""))
|
|
273
|
-
var
|
|
274
|
-
var
|
|
308
|
+
var running: bool = outcome == "running" or event in ["request_started", "start"]
|
|
309
|
+
var marker: String = "…" if running else "✓" if outcome == "success" else "↪" if outcome == "fallback" else "Ⅱ" if outcome == "paused" else "!" if outcome == "conflict" else "✗"
|
|
310
|
+
var suffix: String = "" if running else " · %d ms" % duration_ms
|
|
275
311
|
var text: String = "%s %s → %s%s" % [marker, command, target, suffix]
|
|
276
312
|
var entry: Dictionary = {
|
|
277
|
-
"event": event, "correlation_id": correlation_id, "command": command,
|
|
313
|
+
"event_id": event_id, "event": event, "correlation_id": correlation_id, "command": command,
|
|
278
314
|
"target": target, "outcome": outcome, "duration_ms": duration_ms, "text": text,
|
|
279
315
|
}
|
|
280
316
|
_activity_entries.append(entry)
|
|
317
|
+
if event_id > 0: _activity_event_ids[event_id] = true
|
|
281
318
|
var item_index: int = _activity_list.add_item(text)
|
|
282
319
|
_activity_list.set_item_tooltip(item_index, correlation_id)
|
|
283
|
-
if
|
|
284
|
-
var color := Color(0.35, 0.85, 0.45) if outcome == "success" else Color(1.0, 0.4, 0.35)
|
|
320
|
+
if not running:
|
|
321
|
+
var color := Color(0.35, 0.85, 0.45) if outcome == "success" else Color(1.0, 0.75, 0.25) if outcome in ["fallback", "paused", "conflict"] else Color(1.0, 0.4, 0.35)
|
|
285
322
|
_activity_list.set_item_custom_fg_color(item_index, color)
|
|
323
|
+
if outcome == "paused": _set_connection_status("Paused — human editing")
|
|
324
|
+
elif outcome == "conflict": _set_connection_status("Conflict — unsaved changes preserved")
|
|
286
325
|
while _activity_entries.size() > MAX_ACTIVITY_ENTRIES:
|
|
287
|
-
_activity_entries.pop_front()
|
|
326
|
+
var removed: Dictionary = _activity_entries.pop_front()
|
|
327
|
+
var removed_id: int = _variant_int(removed.get("event_id", 0))
|
|
328
|
+
if removed_id > 0:
|
|
329
|
+
@warning_ignore("return_value_discarded")
|
|
330
|
+
_activity_event_ids.erase(removed_id)
|
|
288
331
|
_activity_list.remove_item(0)
|
|
289
332
|
return {"success": true, "activity_count": _activity_entries.size()}
|
|
290
333
|
|
|
291
334
|
func _sync_filesystem(params: Dictionary) -> Dictionary:
|
|
292
335
|
var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
|
|
293
|
-
filesystem.scan()
|
|
294
336
|
var scene_path: String = str(params.get("scene_path", ""))
|
|
337
|
+
var resource_path: String = str(params.get("resource_path", ""))
|
|
338
|
+
var root_before: Node = EditorInterface.get_edited_scene_root()
|
|
339
|
+
var open_scene_before: String = "" if root_before == null else root_before.scene_file_path
|
|
340
|
+
var selection_before: Array[String] = []
|
|
341
|
+
for selected_node: Node in EditorInterface.get_selection().get_selected_nodes():
|
|
342
|
+
selection_before.append(str(selected_node.get_path()))
|
|
343
|
+
if root_before != null and not scene_path.is_empty() and root_before.scene_file_path == scene_path and _scene_has_unsaved_changes(root_before):
|
|
344
|
+
_last_filesystem_sync = {
|
|
345
|
+
"success": false, "error": "unsaved_conflict", "state": "unsaved_conflict",
|
|
346
|
+
"scene_path": scene_path, "resource_path": resource_path,
|
|
347
|
+
"observed_target_state": {
|
|
348
|
+
"edited_scene": open_scene_before, "selection": selection_before,
|
|
349
|
+
"unsaved": true, "reloaded": false,
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
_set_connection_status("Conflict — unsaved editor changes preserved")
|
|
353
|
+
return _last_filesystem_sync.duplicate(true)
|
|
354
|
+
_set_connection_status("Synchronizing filesystem")
|
|
355
|
+
filesystem.scan()
|
|
356
|
+
var frames_waited: int = 0
|
|
357
|
+
while filesystem.is_scanning() and frames_waited < 600:
|
|
358
|
+
await get_tree().process_frame
|
|
359
|
+
frames_waited += 1
|
|
360
|
+
if filesystem.is_scanning():
|
|
361
|
+
_last_filesystem_sync = {
|
|
362
|
+
"success": false, "error": "sync_timeout", "state": "syncing",
|
|
363
|
+
"scene_path": scene_path, "resource_path": resource_path,
|
|
364
|
+
"observed_target_state": {"scan_complete": false, "frames_waited": frames_waited},
|
|
365
|
+
}
|
|
366
|
+
_set_connection_status("Connected — filesystem synchronization timed out")
|
|
367
|
+
return _last_filesystem_sync.duplicate(true)
|
|
295
368
|
var root: Node = EditorInterface.get_edited_scene_root()
|
|
296
369
|
var reloaded: bool = false
|
|
297
370
|
if root != null and not scene_path.is_empty() and root.scene_file_path == scene_path:
|
|
@@ -301,14 +374,75 @@ func _sync_filesystem(params: Dictionary) -> Dictionary:
|
|
|
301
374
|
var focused: bool = false
|
|
302
375
|
if reloaded and not focus_path.is_empty():
|
|
303
376
|
focused = _focus_editor_node(focus_path)
|
|
377
|
+
var root_after: Node = EditorInterface.get_edited_scene_root()
|
|
378
|
+
var selection_after: Array[String] = []
|
|
379
|
+
for selected_node: Node in EditorInterface.get_selection().get_selected_nodes():
|
|
380
|
+
selection_after.append(str(selected_node.get_path()))
|
|
381
|
+
var scene_readback: Dictionary = _readback_sync_target(scene_path, true)
|
|
382
|
+
var resource_readback: Dictionary = _readback_sync_target(resource_path, false)
|
|
383
|
+
var scene_visible: bool = scene_readback.get("readable", false) == true
|
|
384
|
+
var resource_visible: bool = resource_readback.get("readable", false) == true
|
|
385
|
+
var target_readable: bool = scene_visible and resource_visible
|
|
386
|
+
var observed_target_state: Dictionary = {
|
|
387
|
+
"scan_complete": true,
|
|
388
|
+
"frames_waited": frames_waited,
|
|
389
|
+
"edited_scene": "" if root_after == null else root_after.scene_file_path,
|
|
390
|
+
"selection": selection_after,
|
|
391
|
+
"scene_visible": scene_visible,
|
|
392
|
+
"resource_visible": resource_visible,
|
|
393
|
+
"scene_readback": scene_readback,
|
|
394
|
+
"resource_readback": resource_readback,
|
|
395
|
+
"independently_reopened": target_readable,
|
|
396
|
+
"reloaded": reloaded,
|
|
397
|
+
"focused": focused,
|
|
398
|
+
"preserved_context": {
|
|
399
|
+
"open_scene": open_scene_before == ("" if root_after == null else root_after.scene_file_path),
|
|
400
|
+
"selection_before": selection_before,
|
|
401
|
+
"viewport": "public_api_unavailable",
|
|
402
|
+
"inspector": "restored_to_focus_target" if focused else "unchanged_or_unavailable",
|
|
403
|
+
},
|
|
404
|
+
}
|
|
304
405
|
_last_filesystem_sync = {
|
|
305
|
-
"success":
|
|
306
|
-
"resource_path":
|
|
406
|
+
"success": target_readable, "rescanned": true, "scene_path": scene_path,
|
|
407
|
+
"resource_path": resource_path, "reloaded": reloaded,
|
|
307
408
|
"command": str(params.get("command", "")), "focus_path": focus_path,
|
|
308
|
-
"focused": focused,
|
|
409
|
+
"focused": focused, "state": "connected",
|
|
410
|
+
"observed_target_state": observed_target_state,
|
|
309
411
|
}
|
|
412
|
+
if not target_readable:
|
|
413
|
+
_last_filesystem_sync["error"] = "target_not_editor_readable"
|
|
414
|
+
_last_filesystem_sync["state"] = "target_unreadable"
|
|
415
|
+
_set_connection_status("Connected — synchronized target is unreadable")
|
|
416
|
+
else:
|
|
417
|
+
_set_connection_status("Authenticated — synchronized")
|
|
310
418
|
return _last_filesystem_sync.duplicate(true)
|
|
311
419
|
|
|
420
|
+
func _readback_sync_target(resource_path: String, require_packed_scene: bool) -> Dictionary:
|
|
421
|
+
if resource_path.is_empty():
|
|
422
|
+
return {"requested": false, "exists": true, "readable": true, "reader": "none"}
|
|
423
|
+
var absolute_path: String = ProjectSettings.globalize_path(resource_path)
|
|
424
|
+
var file_exists: bool = FileAccess.file_exists(absolute_path)
|
|
425
|
+
var extension: String = resource_path.get_extension().to_lower()
|
|
426
|
+
var recognized_extensions: PackedStringArray = ResourceLoader.get_recognized_extensions_for_type("Resource")
|
|
427
|
+
var resource_format: bool = require_packed_scene or ResourceLoader.exists(resource_path) or extension in recognized_extensions
|
|
428
|
+
if resource_format:
|
|
429
|
+
var loaded: Resource = null
|
|
430
|
+
if file_exists:
|
|
431
|
+
loaded = ResourceLoader.load(resource_path, "PackedScene" if require_packed_scene else "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
432
|
+
var resource_readable: bool = loaded is PackedScene if require_packed_scene else loaded != null
|
|
433
|
+
return {
|
|
434
|
+
"requested": true, "exists": file_exists, "readable": resource_readable,
|
|
435
|
+
"reader": "resource_loader", "resource_type": "" if loaded == null else loaded.get_class(),
|
|
436
|
+
}
|
|
437
|
+
var file: FileAccess = FileAccess.open(absolute_path, FileAccess.READ) if file_exists else null
|
|
438
|
+
var file_readable: bool = file != null
|
|
439
|
+
if file != null:
|
|
440
|
+
file.close()
|
|
441
|
+
return {
|
|
442
|
+
"requested": true, "exists": file_exists, "readable": file_readable,
|
|
443
|
+
"reader": "file_access", "resource_type": "",
|
|
444
|
+
}
|
|
445
|
+
|
|
312
446
|
func _inspect() -> Dictionary:
|
|
313
447
|
var root: Node = EditorInterface.get_edited_scene_root()
|
|
314
448
|
var selected: Array[String] = []
|
|
@@ -402,6 +536,573 @@ func _commit_property_action(target: Node, property: String, before: Variant, af
|
|
|
402
536
|
_last_history_id = manager.get_object_history_id(target)
|
|
403
537
|
return {"success": true, "property": property, "before": before, "after": target.get(property), "undo_recorded": true}
|
|
404
538
|
|
|
539
|
+
func _editor_transaction(params: Dictionary) -> Dictionary:
|
|
540
|
+
if _driver_paused:
|
|
541
|
+
return {"error": "paused", "state": "paused"}
|
|
542
|
+
var scene_path: String = _resource_path(str(params.get("scene_path", "")))
|
|
543
|
+
var action_name: String = str(params.get("name", "Godot Agent Loop transaction")).strip_edges()
|
|
544
|
+
var operations_variant: Variant = params.get("operations", [])
|
|
545
|
+
if scene_path.is_empty() or action_name.is_empty() or not operations_variant is Array:
|
|
546
|
+
return {"error": "scene_path, name, and operations are required"}
|
|
547
|
+
var operations: Array = operations_variant
|
|
548
|
+
if operations.is_empty() or operations.size() > 256:
|
|
549
|
+
return {"error": "operations must contain 1 to 256 items"}
|
|
550
|
+
var created_scene: bool = false
|
|
551
|
+
if not ResourceLoader.exists(scene_path):
|
|
552
|
+
var root_type: String = str(params.get("root_type", "Node"))
|
|
553
|
+
if not ClassDB.is_parent_class(root_type, "Node"):
|
|
554
|
+
return {"error": "invalid_root_type", "root_type": root_type}
|
|
555
|
+
var new_root_variant: Variant = ClassDB.instantiate(root_type)
|
|
556
|
+
if not new_root_variant is Node:
|
|
557
|
+
return {"error": "root_type_not_instantiable", "root_type": root_type}
|
|
558
|
+
var new_root: Node = new_root_variant
|
|
559
|
+
new_root.name = scene_path.get_file().get_basename().to_pascal_case()
|
|
560
|
+
var preflight: Dictionary = _validate_transaction(new_root, operations)
|
|
561
|
+
if preflight.has("error"):
|
|
562
|
+
new_root.free()
|
|
563
|
+
return preflight
|
|
564
|
+
_discard_transaction_stages(preflight.get("stages", []))
|
|
565
|
+
var initial_scene := PackedScene.new()
|
|
566
|
+
var pack_error: Error = initial_scene.pack(new_root)
|
|
567
|
+
if pack_error != OK:
|
|
568
|
+
new_root.free()
|
|
569
|
+
return {"error": "create_scene_pack_failed", "error_code": pack_error}
|
|
570
|
+
var directory_error: Error = _ensure_resource_parent_directory(scene_path)
|
|
571
|
+
if directory_error != OK:
|
|
572
|
+
new_root.free()
|
|
573
|
+
return {"error": "create_scene_directory_failed", "error_code": directory_error}
|
|
574
|
+
var create_error: Error = ResourceSaver.save(initial_scene, scene_path)
|
|
575
|
+
new_root.free()
|
|
576
|
+
if create_error != OK:
|
|
577
|
+
return {"error": "create_scene_save_failed", "error_code": create_error}
|
|
578
|
+
created_scene = true
|
|
579
|
+
EditorInterface.open_scene_from_path(scene_path)
|
|
580
|
+
var open_frames: int = 0
|
|
581
|
+
while open_frames < 120:
|
|
582
|
+
var candidate: Node = EditorInterface.get_edited_scene_root()
|
|
583
|
+
if candidate != null and candidate.scene_file_path == scene_path:
|
|
584
|
+
break
|
|
585
|
+
await get_tree().process_frame
|
|
586
|
+
open_frames += 1
|
|
587
|
+
var root: Node = EditorInterface.get_edited_scene_root()
|
|
588
|
+
if root == null or root.scene_file_path != scene_path:
|
|
589
|
+
return _rollback_created_transaction({"error": "scene_open_failed", "scene_path": scene_path}, scene_path, created_scene)
|
|
590
|
+
if _scene_has_unsaved_changes(root):
|
|
591
|
+
return _rollback_created_transaction({"error": "unsaved_conflict", "state": "unsaved_conflict", "scene_path": scene_path}, scene_path, created_scene)
|
|
592
|
+
var validation: Dictionary = _validate_transaction(root, operations)
|
|
593
|
+
if validation.has("error"):
|
|
594
|
+
return _rollback_created_transaction(validation, scene_path, created_scene)
|
|
595
|
+
var stages: Array = validation.get("stages", [])
|
|
596
|
+
var undo_recorded: bool = not stages.is_empty()
|
|
597
|
+
if undo_recorded:
|
|
598
|
+
var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
|
|
599
|
+
if manager == null:
|
|
600
|
+
_discard_transaction_stages(stages)
|
|
601
|
+
return _rollback_created_transaction({"error": "undo_redo_unavailable"}, scene_path, created_scene)
|
|
602
|
+
manager.create_action(action_name)
|
|
603
|
+
for stage_variant: Variant in stages:
|
|
604
|
+
var stage: Dictionary = stage_variant
|
|
605
|
+
_apply_transaction_stage(manager, root, stage)
|
|
606
|
+
manager.commit_action()
|
|
607
|
+
_last_history_id = manager.get_object_history_id(root)
|
|
608
|
+
var focus_path: String = str(params.get("focus_path", validation.get("focus_path", "")))
|
|
609
|
+
var focused: bool = false
|
|
610
|
+
if not focus_path.is_empty():
|
|
611
|
+
focused = _focus_editor_node(focus_path)
|
|
612
|
+
var saved: bool = false
|
|
613
|
+
var save_error: Error = OK
|
|
614
|
+
if _variant_bool(params.get("save", true)):
|
|
615
|
+
save_error = EditorInterface.save_scene()
|
|
616
|
+
saved = save_error == OK
|
|
617
|
+
if save_error != OK:
|
|
618
|
+
return _rollback_created_transaction({"error": "scene_save_failed", "error_code": save_error, "undo_recorded": undo_recorded}, scene_path, created_scene)
|
|
619
|
+
var persisted: Dictionary = _independent_scene_readback(scene_path)
|
|
620
|
+
if persisted.has("error"):
|
|
621
|
+
return _rollback_created_transaction({"error": "independent_readback_failed", "details": persisted, "undo_recorded": undo_recorded}, scene_path, created_scene)
|
|
622
|
+
return {
|
|
623
|
+
"success": true,
|
|
624
|
+
"backend": "editor",
|
|
625
|
+
"transaction_name": action_name,
|
|
626
|
+
"operation_count": operations.size(),
|
|
627
|
+
"undo_recorded": undo_recorded,
|
|
628
|
+
"saved": saved,
|
|
629
|
+
"scene_created": created_scene,
|
|
630
|
+
"creation_fallback": _creation_fallback(created_scene, "initial PackedScene save required before opening"),
|
|
631
|
+
"focused": focused,
|
|
632
|
+
"focus_path": focus_path,
|
|
633
|
+
"observed_target_state": persisted,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
func _rollback_created_transaction(result: Dictionary, scene_path: String, created_scene: bool) -> Dictionary:
|
|
637
|
+
if not created_scene:
|
|
638
|
+
return result
|
|
639
|
+
var remove_error: Error = DirAccess.remove_absolute(ProjectSettings.globalize_path(scene_path))
|
|
640
|
+
result["created_scene_rolled_back"] = remove_error in [OK, ERR_DOES_NOT_EXIST]
|
|
641
|
+
if remove_error not in [OK, ERR_DOES_NOT_EXIST]:
|
|
642
|
+
result["rollback_error_code"] = remove_error
|
|
643
|
+
return result
|
|
644
|
+
|
|
645
|
+
func _discard_transaction_stages(stages_variant: Variant) -> void:
|
|
646
|
+
if not stages_variant is Array:
|
|
647
|
+
return
|
|
648
|
+
var nodes: Array[Node] = []
|
|
649
|
+
for stage_variant: Variant in stages_variant:
|
|
650
|
+
if not stage_variant is Dictionary:
|
|
651
|
+
continue
|
|
652
|
+
var stage: Dictionary = stage_variant
|
|
653
|
+
if str(stage.get("op", "")) not in ["add_node", "instantiate_scene", "duplicate_node"]:
|
|
654
|
+
continue
|
|
655
|
+
var node_variant: Variant = stage.get("node")
|
|
656
|
+
if not node_variant is Node:
|
|
657
|
+
continue
|
|
658
|
+
nodes.append(node_variant)
|
|
659
|
+
_discard_transaction_nodes(nodes)
|
|
660
|
+
|
|
661
|
+
func _discard_transaction_nodes(nodes: Array[Node]) -> void:
|
|
662
|
+
var discarded: Dictionary = {}
|
|
663
|
+
for node: Node in nodes:
|
|
664
|
+
var instance_id: int = node.get_instance_id()
|
|
665
|
+
if discarded.has(instance_id):
|
|
666
|
+
continue
|
|
667
|
+
discarded[instance_id] = true
|
|
668
|
+
if is_instance_valid(node) and node.get_parent() == null:
|
|
669
|
+
node.free()
|
|
670
|
+
|
|
671
|
+
func _validate_transaction(root: Node, operations: Array) -> Dictionary:
|
|
672
|
+
var allocated_nodes: Array[Node] = []
|
|
673
|
+
var validation: Dictionary = _validate_transaction_operations(root, operations, allocated_nodes)
|
|
674
|
+
if validation.has("error"):
|
|
675
|
+
_discard_transaction_nodes(allocated_nodes)
|
|
676
|
+
return validation
|
|
677
|
+
|
|
678
|
+
func _validate_transaction_operations(root: Node, operations: Array, allocated_nodes: Array[Node]) -> Dictionary:
|
|
679
|
+
var stages: Array[Dictionary] = []
|
|
680
|
+
var virtual_nodes: Dictionary = {}
|
|
681
|
+
var virtual_paths: Dictionary = {}
|
|
682
|
+
var staged_node_ids: Dictionary = {}
|
|
683
|
+
_index_transaction_subtree(root, ".", virtual_nodes, virtual_paths)
|
|
684
|
+
var virtual_root_name: String = str(root.name)
|
|
685
|
+
var focus_path: String = ""
|
|
686
|
+
for index: int in operations.size():
|
|
687
|
+
var operation_variant: Variant = operations[index]
|
|
688
|
+
if not operation_variant is Dictionary:
|
|
689
|
+
return {"error": "invalid_operation", "operation_index": index}
|
|
690
|
+
var operation: Dictionary = operation_variant
|
|
691
|
+
var op: String = str(operation.get("op", ""))
|
|
692
|
+
if op == "save":
|
|
693
|
+
continue
|
|
694
|
+
if op == "add_node":
|
|
695
|
+
var parent_path: String = str(operation.get("parent_path", "."))
|
|
696
|
+
var parent: Node = _transaction_node(root, parent_path, virtual_nodes)
|
|
697
|
+
var node_type: String = str(operation.get("node_type", "Node"))
|
|
698
|
+
var node_name: String = str(operation.get("node_name", node_type)).strip_edges()
|
|
699
|
+
if parent == null: return {"error": "parent_not_found", "operation_index": index, "parent_path": parent_path}
|
|
700
|
+
if node_name.is_empty() or node_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
|
|
701
|
+
var normalized_parent_path: String = str(virtual_paths.get(parent.get_instance_id(), ""))
|
|
702
|
+
var node_path: String = _joined_node_path(normalized_parent_path, node_name)
|
|
703
|
+
if virtual_nodes.has(node_path): return {"error": "duplicate_node_name", "operation_index": index, "node_name": node_name}
|
|
704
|
+
if not ClassDB.is_parent_class(node_type, "Node"): return {"error": "invalid_node_type", "operation_index": index, "node_type": node_type}
|
|
705
|
+
var node_variant: Variant = ClassDB.instantiate(node_type)
|
|
706
|
+
if not node_variant is Node: return {"error": "node_type_not_instantiable", "operation_index": index}
|
|
707
|
+
var node: Node = node_variant
|
|
708
|
+
allocated_nodes.append(node)
|
|
709
|
+
node.name = node_name
|
|
710
|
+
var properties_variant: Variant = operation.get("properties", {})
|
|
711
|
+
if not properties_variant is Dictionary: return {"error": "properties_must_be_object", "operation_index": index}
|
|
712
|
+
for property_name_variant: Variant in properties_variant:
|
|
713
|
+
var property_name: String = str(property_name_variant)
|
|
714
|
+
if not property_name in node: return {"error": "property_not_found", "operation_index": index, "property": property_name}
|
|
715
|
+
var decoded: Dictionary = _decode_editor_value(properties_variant[property_name_variant], node.get(property_name))
|
|
716
|
+
if decoded.has("error"): return {"error": decoded.error, "operation_index": index, "property": property_name}
|
|
717
|
+
node.set(property_name, decoded.value)
|
|
718
|
+
_index_transaction_subtree(node, node_path, virtual_nodes, virtual_paths, staged_node_ids, true)
|
|
719
|
+
stages.append({"op": op, "parent": parent, "node": node})
|
|
720
|
+
focus_path = node_path
|
|
721
|
+
elif op == "instantiate_scene":
|
|
722
|
+
var instance_parent_path: String = str(operation.get("parent_path", "."))
|
|
723
|
+
var instance_parent: Node = _transaction_node(root, instance_parent_path, virtual_nodes)
|
|
724
|
+
var packed_path: String = _resource_path(str(operation.get("scene_path", "")))
|
|
725
|
+
var packed: Resource = ResourceLoader.load(packed_path, "PackedScene")
|
|
726
|
+
if instance_parent == null or not packed is PackedScene:
|
|
727
|
+
return {"error": "packed_scene_or_parent_invalid", "operation_index": index}
|
|
728
|
+
var packed_scene: PackedScene = packed
|
|
729
|
+
var instance: Node = packed_scene.instantiate()
|
|
730
|
+
allocated_nodes.append(instance)
|
|
731
|
+
if operation.has("node_name"): instance.name = str(operation.node_name)
|
|
732
|
+
var instance_name: String = str(instance.name).strip_edges()
|
|
733
|
+
if instance_name.is_empty() or instance_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
|
|
734
|
+
var normalized_instance_parent_path: String = str(virtual_paths.get(instance_parent.get_instance_id(), ""))
|
|
735
|
+
var instance_path: String = _joined_node_path(normalized_instance_parent_path, instance_name)
|
|
736
|
+
if virtual_nodes.has(instance_path): return {"error": "duplicate_node_name", "operation_index": index, "node_name": instance_name}
|
|
737
|
+
_index_transaction_subtree(instance, instance_path, virtual_nodes, virtual_paths, staged_node_ids, true)
|
|
738
|
+
stages.append({"op": op, "parent": instance_parent, "node": instance})
|
|
739
|
+
focus_path = instance_path
|
|
740
|
+
else:
|
|
741
|
+
var node_path: String = str(operation.get("node_path", "."))
|
|
742
|
+
var target: Node = _transaction_node(root, node_path, virtual_nodes)
|
|
743
|
+
if target == null: return {"error": "node_not_found", "operation_index": index, "node_path": node_path}
|
|
744
|
+
if target != root and target.owner != root and not staged_node_ids.has(target.get_instance_id()):
|
|
745
|
+
return {"error": "inherited_or_noneditable_node", "operation_index": index, "node_path": node_path}
|
|
746
|
+
var current_path: String = str(virtual_paths.get(target.get_instance_id(), ""))
|
|
747
|
+
if op == "remove_node":
|
|
748
|
+
if target == root: return {"error": "cannot_remove_scene_root", "operation_index": index}
|
|
749
|
+
var current_parent: Node = _transaction_node(root, current_path.get_base_dir(), virtual_nodes)
|
|
750
|
+
stages.append({"op": op, "node": target, "parent": current_parent, "index": target.get_index(), "owner": target.owner})
|
|
751
|
+
_remove_transaction_subtree(target, virtual_nodes, virtual_paths)
|
|
752
|
+
elif op == "rename_node":
|
|
753
|
+
var new_name: String = str(operation.get("name", operation.get("node_name", ""))).strip_edges()
|
|
754
|
+
if new_name.is_empty() or new_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
|
|
755
|
+
var renamed_path: String = current_path if target == root else _joined_node_path(current_path.get_base_dir(), new_name)
|
|
756
|
+
if target != root and virtual_nodes.has(renamed_path) and virtual_nodes[renamed_path] != target:
|
|
757
|
+
return {"error": "duplicate_node_name", "operation_index": index, "node_name": new_name}
|
|
758
|
+
var before_name: String = virtual_root_name if target == root else current_path.get_file()
|
|
759
|
+
stages.append({"op": op, "node": target, "before": before_name, "after": new_name})
|
|
760
|
+
if target == root:
|
|
761
|
+
virtual_root_name = new_name
|
|
762
|
+
else:
|
|
763
|
+
_move_transaction_subtree(target, renamed_path, virtual_nodes, virtual_paths)
|
|
764
|
+
focus_path = renamed_path
|
|
765
|
+
elif op == "duplicate_node":
|
|
766
|
+
if target == root: return {"error": "cannot_duplicate_scene_root", "operation_index": index}
|
|
767
|
+
var duplicate_name: String = str(operation.get("node_name", "%sCopy" % current_path.get_file())).strip_edges()
|
|
768
|
+
if duplicate_name.is_empty() or duplicate_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
|
|
769
|
+
var duplicate_path: String = _joined_node_path(current_path.get_base_dir(), duplicate_name)
|
|
770
|
+
if virtual_nodes.has(duplicate_path): return {"error": "duplicate_node_name", "operation_index": index, "node_name": duplicate_name}
|
|
771
|
+
var duplicated_node: Node = target.duplicate(Node.DUPLICATE_USE_INSTANTIATION)
|
|
772
|
+
allocated_nodes.append(duplicated_node)
|
|
773
|
+
duplicated_node.name = duplicate_name
|
|
774
|
+
var duplicate_parent: Node = _transaction_node(root, current_path.get_base_dir(), virtual_nodes)
|
|
775
|
+
_index_transaction_subtree(duplicated_node, duplicate_path, virtual_nodes, virtual_paths, staged_node_ids, true)
|
|
776
|
+
stages.append({"op": op, "node": duplicated_node, "parent": duplicate_parent})
|
|
777
|
+
focus_path = duplicate_path
|
|
778
|
+
elif op == "reparent_node":
|
|
779
|
+
if target == root: return {"error": "cannot_reparent_scene_root", "operation_index": index}
|
|
780
|
+
var new_parent_path: String = str(operation.get("new_parent_path", ""))
|
|
781
|
+
var new_parent: Node = _transaction_node(root, new_parent_path, virtual_nodes)
|
|
782
|
+
if new_parent == null: return {"error": "invalid_reparent_target", "operation_index": index}
|
|
783
|
+
var normalized_new_parent_path: String = str(virtual_paths.get(new_parent.get_instance_id(), ""))
|
|
784
|
+
if new_parent == target or normalized_new_parent_path.begins_with(current_path + "/"):
|
|
785
|
+
return {"error": "invalid_reparent_target", "operation_index": index}
|
|
786
|
+
var reparented_path: String = _joined_node_path(normalized_new_parent_path, current_path.get_file())
|
|
787
|
+
if virtual_nodes.has(reparented_path) and virtual_nodes[reparented_path] != target:
|
|
788
|
+
return {"error": "duplicate_node_name", "operation_index": index, "node_name": current_path.get_file()}
|
|
789
|
+
var before_parent: Node = _transaction_node(root, current_path.get_base_dir(), virtual_nodes)
|
|
790
|
+
stages.append({"op": op, "node": target, "before_parent": before_parent, "after_parent": new_parent, "keep_global": _variant_bool(operation.get("keep_global_transform", true))})
|
|
791
|
+
_move_transaction_subtree(target, reparented_path, virtual_nodes, virtual_paths)
|
|
792
|
+
focus_path = reparented_path
|
|
793
|
+
elif op == "set_properties":
|
|
794
|
+
var set_properties_variant: Variant = operation.get("properties", {})
|
|
795
|
+
if not set_properties_variant is Dictionary: return {"error": "properties_must_be_object", "operation_index": index}
|
|
796
|
+
for property_variant: Variant in set_properties_variant:
|
|
797
|
+
var property_name: String = str(property_variant)
|
|
798
|
+
if not property_name in target: return {"error": "property_not_found", "operation_index": index, "property": property_name}
|
|
799
|
+
var decoded: Dictionary = _decode_editor_value(set_properties_variant[property_variant], target.get(property_name))
|
|
800
|
+
if decoded.has("error"): return {"error": decoded.error, "operation_index": index, "property": property_name}
|
|
801
|
+
stages.append({"op": "set_property", "node": target, "property": property_name, "before": target.get(property_name), "after": decoded.value})
|
|
802
|
+
focus_path = current_path
|
|
803
|
+
elif op == "attach_script":
|
|
804
|
+
var script: Resource = ResourceLoader.load(_resource_path(str(operation.get("script_path", ""))), "Script")
|
|
805
|
+
if not script is Script: return {"error": "script_not_found", "operation_index": index}
|
|
806
|
+
stages.append({"op": "set_property", "node": target, "property": "script", "before": target.get_script(), "after": script})
|
|
807
|
+
focus_path = current_path
|
|
808
|
+
elif op == "assign_resource":
|
|
809
|
+
var resource_property: String = str(operation.get("property", ""))
|
|
810
|
+
var resource: Resource = ResourceLoader.load(_resource_path(str(operation.get("resource_path", ""))))
|
|
811
|
+
if resource_property.is_empty() or not resource_property in target or resource == null: return {"error": "resource_or_property_invalid", "operation_index": index}
|
|
812
|
+
stages.append({"op": "set_property", "node": target, "property": resource_property, "before": target.get(resource_property), "after": resource})
|
|
813
|
+
focus_path = current_path
|
|
814
|
+
else:
|
|
815
|
+
return {"error": "unsupported_operation", "operation_index": index, "op": op}
|
|
816
|
+
return {"stages": stages, "focus_path": focus_path}
|
|
817
|
+
|
|
818
|
+
func _editor_resource_transaction(params: Dictionary) -> Dictionary:
|
|
819
|
+
if _driver_paused:
|
|
820
|
+
return {"error": "paused", "state": "paused"}
|
|
821
|
+
var resource_path: String = _resource_path(str(params.get("resource_path", "")))
|
|
822
|
+
var resource_type: String = str(params.get("resource_type", ""))
|
|
823
|
+
var properties_variant: Variant = params.get("properties", {})
|
|
824
|
+
if resource_path.is_empty() or not properties_variant is Dictionary:
|
|
825
|
+
return {"error": "resource_path and object properties are required"}
|
|
826
|
+
var properties: Dictionary = properties_variant
|
|
827
|
+
var resource: Resource
|
|
828
|
+
var created: bool = false
|
|
829
|
+
if ResourceLoader.exists(resource_path):
|
|
830
|
+
resource = ResourceLoader.load(resource_path)
|
|
831
|
+
if resource == null:
|
|
832
|
+
return {"error": "resource_load_failed", "resource_path": resource_path}
|
|
833
|
+
else:
|
|
834
|
+
if not _is_supported_editor_resource_type(resource_type):
|
|
835
|
+
return {"error": "unsupported_resource_type", "resource_type": resource_type}
|
|
836
|
+
var instance: Variant = ClassDB.instantiate(resource_type)
|
|
837
|
+
if not instance is Resource:
|
|
838
|
+
return {"error": "resource_type_not_instantiable", "resource_type": resource_type}
|
|
839
|
+
resource = instance
|
|
840
|
+
created = true
|
|
841
|
+
var stages: Array[Dictionary] = []
|
|
842
|
+
for property_variant: Variant in properties:
|
|
843
|
+
var property_name: String = str(property_variant)
|
|
844
|
+
if not property_name in resource:
|
|
845
|
+
return {"error": "property_not_found", "property": property_name}
|
|
846
|
+
var decoded: Dictionary = _decode_editor_value(properties[property_variant], resource.get(property_name))
|
|
847
|
+
if decoded.has("error"):
|
|
848
|
+
return {"error": decoded.error, "property": property_name}
|
|
849
|
+
stages.append({"property": property_name, "before": resource.get(property_name), "after": decoded.value})
|
|
850
|
+
var undo_recorded: bool = not created and not stages.is_empty()
|
|
851
|
+
if undo_recorded:
|
|
852
|
+
var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
|
|
853
|
+
if manager == null:
|
|
854
|
+
return {"error": "undo_redo_unavailable"}
|
|
855
|
+
manager.create_action(str(params.get("name", "Modify resource")))
|
|
856
|
+
for stage: Dictionary in stages:
|
|
857
|
+
var property_name: StringName = StringName(str(stage.get("property", "")))
|
|
858
|
+
manager.add_do_property(resource, property_name, stage.get("after"))
|
|
859
|
+
manager.add_undo_property(resource, property_name, stage.get("before"))
|
|
860
|
+
manager.commit_action()
|
|
861
|
+
_last_history_id = manager.get_object_history_id(resource)
|
|
862
|
+
else:
|
|
863
|
+
for stage: Dictionary in stages:
|
|
864
|
+
var property_name: StringName = StringName(str(stage.get("property", "")))
|
|
865
|
+
resource.set(property_name, stage.get("after"))
|
|
866
|
+
var directory_error: Error = _ensure_resource_parent_directory(resource_path)
|
|
867
|
+
if directory_error != OK:
|
|
868
|
+
return {"error": "resource_directory_failed", "error_code": directory_error, "undo_recorded": undo_recorded}
|
|
869
|
+
var save_error: Error = ResourceSaver.save(resource, resource_path)
|
|
870
|
+
if save_error != OK:
|
|
871
|
+
return {"error": "resource_save_failed", "error_code": save_error, "undo_recorded": undo_recorded}
|
|
872
|
+
EditorInterface.edit_resource(resource)
|
|
873
|
+
var readback: Resource = ResourceLoader.load(resource_path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
874
|
+
if readback == null:
|
|
875
|
+
return {"error": "independent_resource_readback_failed", "undo_recorded": undo_recorded}
|
|
876
|
+
var observed_properties: Dictionary = {}
|
|
877
|
+
for property_variant: Variant in properties:
|
|
878
|
+
var property_name: String = str(property_variant)
|
|
879
|
+
observed_properties[property_name] = str(readback.get(property_name))
|
|
880
|
+
return {
|
|
881
|
+
"success": true,
|
|
882
|
+
"backend": "editor",
|
|
883
|
+
"resource_path": resource_path,
|
|
884
|
+
"resource_type": readback.get_class(),
|
|
885
|
+
"resource_uid": ResourceLoader.get_resource_uid(resource_path),
|
|
886
|
+
"created": created,
|
|
887
|
+
"undo_recorded": undo_recorded,
|
|
888
|
+
"creation_fallback": _creation_fallback(created, "initial ResourceSaver save required before editor inspection"),
|
|
889
|
+
"focused": true,
|
|
890
|
+
"observed_target_state": {
|
|
891
|
+
"resource_path": resource_path,
|
|
892
|
+
"resource_type": readback.get_class(),
|
|
893
|
+
"properties": observed_properties,
|
|
894
|
+
"independently_reloaded": true,
|
|
895
|
+
},
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
func _is_supported_editor_resource_type(resource_type: String) -> bool:
|
|
899
|
+
if resource_type.is_empty() or not ClassDB.class_exists(resource_type) or not ClassDB.can_instantiate(resource_type):
|
|
900
|
+
return false
|
|
901
|
+
for base_type: String in ["BaseMaterial3D", "Mesh", "Shape2D", "Shape3D", "Theme", "AudioStream", "Gradient", "Curve", "Environment"]:
|
|
902
|
+
if ClassDB.is_parent_class(resource_type, base_type):
|
|
903
|
+
return true
|
|
904
|
+
return false
|
|
905
|
+
|
|
906
|
+
func _apply_transaction_stage(manager: EditorUndoRedoManager, root: Node, stage: Dictionary) -> void:
|
|
907
|
+
var op: String = str(stage.get("op", ""))
|
|
908
|
+
var stage_node_variant: Variant = stage.get("node")
|
|
909
|
+
if not stage_node_variant is Node:
|
|
910
|
+
return
|
|
911
|
+
var stage_node: Node = stage_node_variant
|
|
912
|
+
if op in ["add_node", "instantiate_scene", "duplicate_node"]:
|
|
913
|
+
var parent_variant: Variant = stage.get("parent")
|
|
914
|
+
if not parent_variant is Node:
|
|
915
|
+
return
|
|
916
|
+
var parent: Node = parent_variant
|
|
917
|
+
manager.add_do_method(parent, "add_child", stage_node, true)
|
|
918
|
+
manager.add_do_method(self, "_set_scene_owner_recursive", stage_node, root)
|
|
919
|
+
manager.add_do_reference(stage_node)
|
|
920
|
+
manager.add_undo_method(parent, "remove_child", stage_node)
|
|
921
|
+
elif op == "remove_node":
|
|
922
|
+
var parent_variant: Variant = stage.get("parent")
|
|
923
|
+
if not parent_variant is Node:
|
|
924
|
+
return
|
|
925
|
+
var parent: Node = parent_variant
|
|
926
|
+
manager.add_do_method(parent, "remove_child", stage_node)
|
|
927
|
+
manager.add_undo_method(parent, "add_child", stage_node, true)
|
|
928
|
+
manager.add_undo_method(parent, "move_child", stage_node, _variant_int(stage.get("index", 0)))
|
|
929
|
+
manager.add_undo_property(stage_node, "owner", stage.get("owner"))
|
|
930
|
+
elif op == "rename_node":
|
|
931
|
+
manager.add_do_property(stage_node, "name", stage.get("after"))
|
|
932
|
+
manager.add_undo_property(stage_node, "name", stage.get("before"))
|
|
933
|
+
elif op == "reparent_node":
|
|
934
|
+
var after_parent_variant: Variant = stage.get("after_parent")
|
|
935
|
+
var before_parent_variant: Variant = stage.get("before_parent")
|
|
936
|
+
if not after_parent_variant is Node or not before_parent_variant is Node:
|
|
937
|
+
return
|
|
938
|
+
var after_parent: Node = after_parent_variant
|
|
939
|
+
var before_parent: Node = before_parent_variant
|
|
940
|
+
var keep_global: bool = _variant_bool(stage.get("keep_global", true))
|
|
941
|
+
manager.add_do_method(stage_node, "reparent", after_parent, keep_global)
|
|
942
|
+
manager.add_undo_method(stage_node, "reparent", before_parent, keep_global)
|
|
943
|
+
elif op == "set_property":
|
|
944
|
+
var property_name: StringName = StringName(str(stage.get("property", "")))
|
|
945
|
+
manager.add_do_property(stage_node, property_name, stage.get("after"))
|
|
946
|
+
manager.add_undo_property(stage_node, property_name, stage.get("before"))
|
|
947
|
+
|
|
948
|
+
func _set_scene_owner_recursive(node: Node, scene_owner: Node) -> void:
|
|
949
|
+
node.owner = scene_owner
|
|
950
|
+
for child: Node in node.get_children(true):
|
|
951
|
+
_set_scene_owner_recursive(child, scene_owner)
|
|
952
|
+
|
|
953
|
+
func _transaction_node(root: Node, path: String, staged_nodes: Dictionary) -> Node:
|
|
954
|
+
var normalized: String = _normalize_transaction_path(path)
|
|
955
|
+
if normalized == ".": return root
|
|
956
|
+
if staged_nodes.has(normalized): return staged_nodes[normalized]
|
|
957
|
+
return null
|
|
958
|
+
|
|
959
|
+
func _normalize_transaction_path(path: String) -> String:
|
|
960
|
+
var normalized: String = path.strip_edges().trim_prefix("root/").trim_prefix("./").trim_suffix("/")
|
|
961
|
+
return "." if normalized in ["", ".", "root"] else normalized
|
|
962
|
+
|
|
963
|
+
func _index_transaction_subtree(
|
|
964
|
+
node: Node,
|
|
965
|
+
path: String,
|
|
966
|
+
virtual_nodes: Dictionary,
|
|
967
|
+
virtual_paths: Dictionary,
|
|
968
|
+
staged_node_ids: Dictionary = {},
|
|
969
|
+
mark_staged: bool = false,
|
|
970
|
+
) -> void:
|
|
971
|
+
var normalized_path: String = _normalize_transaction_path(path)
|
|
972
|
+
virtual_nodes[normalized_path] = node
|
|
973
|
+
virtual_paths[node.get_instance_id()] = normalized_path
|
|
974
|
+
if mark_staged:
|
|
975
|
+
staged_node_ids[node.get_instance_id()] = true
|
|
976
|
+
for child: Node in node.get_children():
|
|
977
|
+
_index_transaction_subtree(child, _joined_node_path(normalized_path, str(child.name)), virtual_nodes, virtual_paths, staged_node_ids, mark_staged)
|
|
978
|
+
|
|
979
|
+
func _remove_transaction_subtree(node: Node, virtual_nodes: Dictionary, virtual_paths: Dictionary) -> void:
|
|
980
|
+
var root_path: String = str(virtual_paths.get(node.get_instance_id(), ""))
|
|
981
|
+
if root_path.is_empty():
|
|
982
|
+
return
|
|
983
|
+
for path_variant: Variant in virtual_nodes.keys():
|
|
984
|
+
var path: String = str(path_variant)
|
|
985
|
+
if path == root_path or path.begins_with(root_path + "/"):
|
|
986
|
+
var removed_node_variant: Variant = virtual_nodes.get(path)
|
|
987
|
+
if removed_node_variant is Node:
|
|
988
|
+
var removed_node: Node = removed_node_variant
|
|
989
|
+
@warning_ignore("return_value_discarded")
|
|
990
|
+
virtual_paths.erase(removed_node.get_instance_id())
|
|
991
|
+
@warning_ignore("return_value_discarded")
|
|
992
|
+
virtual_nodes.erase(path)
|
|
993
|
+
|
|
994
|
+
func _move_transaction_subtree(node: Node, new_path: String, virtual_nodes: Dictionary, virtual_paths: Dictionary) -> void:
|
|
995
|
+
var old_path: String = str(virtual_paths.get(node.get_instance_id(), ""))
|
|
996
|
+
var normalized_new_path: String = _normalize_transaction_path(new_path)
|
|
997
|
+
if old_path.is_empty() or old_path == normalized_new_path:
|
|
998
|
+
return
|
|
999
|
+
var moved_nodes: Array[Dictionary] = []
|
|
1000
|
+
for path_variant: Variant in virtual_nodes.keys():
|
|
1001
|
+
var path: String = str(path_variant)
|
|
1002
|
+
if path == old_path or path.begins_with(old_path + "/"):
|
|
1003
|
+
var moved_node_variant: Variant = virtual_nodes.get(path)
|
|
1004
|
+
if moved_node_variant is Node:
|
|
1005
|
+
var suffix: String = path.trim_prefix(old_path)
|
|
1006
|
+
moved_nodes.append({"node": moved_node_variant, "path": normalized_new_path + suffix})
|
|
1007
|
+
@warning_ignore("return_value_discarded")
|
|
1008
|
+
virtual_nodes.erase(path)
|
|
1009
|
+
for moved: Dictionary in moved_nodes:
|
|
1010
|
+
var moved_node: Node = moved.node
|
|
1011
|
+
var moved_path: String = str(moved.path)
|
|
1012
|
+
virtual_nodes[moved_path] = moved_node
|
|
1013
|
+
virtual_paths[moved_node.get_instance_id()] = moved_path
|
|
1014
|
+
|
|
1015
|
+
func _joined_node_path(parent_path: String, node_name: String) -> String:
|
|
1016
|
+
var normalized: String = _normalize_transaction_path(parent_path)
|
|
1017
|
+
return node_name if normalized in ["", ".", "root"] else normalized.path_join(node_name)
|
|
1018
|
+
|
|
1019
|
+
func _resource_path(path: String) -> String:
|
|
1020
|
+
return path if path.begins_with("res://") else "res://" + path.trim_prefix("/")
|
|
1021
|
+
|
|
1022
|
+
func _ensure_resource_parent_directory(resource_path: String) -> Error:
|
|
1023
|
+
var parent: String = resource_path.get_base_dir()
|
|
1024
|
+
if parent in ["", "res://"]:
|
|
1025
|
+
return OK
|
|
1026
|
+
return DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(parent))
|
|
1027
|
+
|
|
1028
|
+
func _decode_editor_value(value: Variant, current: Variant) -> Dictionary:
|
|
1029
|
+
if value is Dictionary:
|
|
1030
|
+
var typed: Dictionary = value
|
|
1031
|
+
if not typed.has("type"):
|
|
1032
|
+
return _decode_untyped_editor_value(typed, current)
|
|
1033
|
+
var type_name: String = str(typed.get("type", ""))
|
|
1034
|
+
var data: Variant = typed.get("value")
|
|
1035
|
+
var data_array: Array = data if data is Array else []
|
|
1036
|
+
if type_name == "Vector2" and data_array.size() == 2: return {"value": Vector2(_variant_float(data_array[0]), _variant_float(data_array[1]))}
|
|
1037
|
+
if type_name == "Vector2i" and data_array.size() == 2: return {"value": Vector2i(_variant_int(data_array[0]), _variant_int(data_array[1]))}
|
|
1038
|
+
if type_name == "Vector3" and data_array.size() == 3: return {"value": Vector3(_variant_float(data_array[0]), _variant_float(data_array[1]), _variant_float(data_array[2]))}
|
|
1039
|
+
if type_name == "Vector3i" and data_array.size() == 3: return {"value": Vector3i(_variant_int(data_array[0]), _variant_int(data_array[1]), _variant_int(data_array[2]))}
|
|
1040
|
+
if type_name == "Color": return {"value": Color(str(data))}
|
|
1041
|
+
if type_name == "NodePath": return {"value": NodePath(str(data))}
|
|
1042
|
+
if type_name == "StringName": return {"value": StringName(str(data))}
|
|
1043
|
+
if type_name == "Resource":
|
|
1044
|
+
var resource: Resource = ResourceLoader.load(_resource_path(str(data)))
|
|
1045
|
+
return {"error": "resource_not_found"} if resource == null else {"value": resource}
|
|
1046
|
+
return {"error": "unsupported_typed_value"}
|
|
1047
|
+
match typeof(current):
|
|
1048
|
+
TYPE_INT: return {"value": _variant_int(value)}
|
|
1049
|
+
TYPE_FLOAT: return {"value": _variant_float(value)}
|
|
1050
|
+
TYPE_BOOL: return {"value": _variant_bool(value)}
|
|
1051
|
+
TYPE_STRING: return {"value": str(value)}
|
|
1052
|
+
TYPE_STRING_NAME: return {"value": StringName(str(value))}
|
|
1053
|
+
TYPE_NODE_PATH: return {"value": NodePath(str(value))}
|
|
1054
|
+
TYPE_OBJECT:
|
|
1055
|
+
if current is Resource and value is String:
|
|
1056
|
+
var referenced: Resource = ResourceLoader.load(_resource_path(str(value)))
|
|
1057
|
+
return {"error": "resource_not_found"} if referenced == null else {"value": referenced}
|
|
1058
|
+
return {"value": value}
|
|
1059
|
+
|
|
1060
|
+
func _decode_untyped_editor_value(value: Dictionary, current: Variant) -> Dictionary:
|
|
1061
|
+
match typeof(current):
|
|
1062
|
+
TYPE_VECTOR2:
|
|
1063
|
+
if value.has("x") and value.has("y"): return {"value": Vector2(_variant_float(value.get("x")), _variant_float(value.get("y")))}
|
|
1064
|
+
TYPE_VECTOR2I:
|
|
1065
|
+
if value.has("x") and value.has("y"): return {"value": Vector2i(_variant_int(value.get("x")), _variant_int(value.get("y")))}
|
|
1066
|
+
TYPE_VECTOR3:
|
|
1067
|
+
if value.has("x") and value.has("y") and value.has("z"): return {"value": Vector3(_variant_float(value.get("x")), _variant_float(value.get("y")), _variant_float(value.get("z")))}
|
|
1068
|
+
TYPE_VECTOR3I:
|
|
1069
|
+
if value.has("x") and value.has("y") and value.has("z"): return {"value": Vector3i(_variant_int(value.get("x")), _variant_int(value.get("y")), _variant_int(value.get("z")))}
|
|
1070
|
+
TYPE_COLOR:
|
|
1071
|
+
if value.has("r") and value.has("g") and value.has("b"): return {"value": Color(_variant_float(value.get("r")), _variant_float(value.get("g")), _variant_float(value.get("b")), _variant_float(value.get("a", 1.0)))}
|
|
1072
|
+
return {"value": value}
|
|
1073
|
+
|
|
1074
|
+
func _independent_scene_readback(scene_path: String) -> Dictionary:
|
|
1075
|
+
var packed: Resource = ResourceLoader.load(scene_path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE)
|
|
1076
|
+
if not packed is PackedScene:
|
|
1077
|
+
return {"error": "packed_scene_reload_failed"}
|
|
1078
|
+
var packed_scene: PackedScene = packed
|
|
1079
|
+
var instance: Node = packed_scene.instantiate()
|
|
1080
|
+
if instance == null:
|
|
1081
|
+
return {"error": "packed_scene_instantiate_failed"}
|
|
1082
|
+
var node_count: int = _count_scene_nodes(instance)
|
|
1083
|
+
var hierarchy: Array[String] = []
|
|
1084
|
+
_collect_scene_paths(instance, instance, hierarchy, 256)
|
|
1085
|
+
var result: Dictionary = {
|
|
1086
|
+
"scene_path": scene_path,
|
|
1087
|
+
"root_name": str(instance.name),
|
|
1088
|
+
"root_type": instance.get_class(),
|
|
1089
|
+
"node_count": node_count,
|
|
1090
|
+
"hierarchy": hierarchy,
|
|
1091
|
+
"independently_reopened": true,
|
|
1092
|
+
}
|
|
1093
|
+
instance.free()
|
|
1094
|
+
return result
|
|
1095
|
+
|
|
1096
|
+
func _count_scene_nodes(node: Node) -> int:
|
|
1097
|
+
var count: int = 1
|
|
1098
|
+
for child: Node in node.get_children(): count += _count_scene_nodes(child)
|
|
1099
|
+
return count
|
|
1100
|
+
|
|
1101
|
+
func _collect_scene_paths(root: Node, node: Node, paths: Array[String], limit: int) -> void:
|
|
1102
|
+
if paths.size() >= limit: return
|
|
1103
|
+
paths.append("." if node == root else str(root.get_path_to(node)))
|
|
1104
|
+
for child: Node in node.get_children(): _collect_scene_paths(root, child, paths, limit)
|
|
1105
|
+
|
|
405
1106
|
func _send(response: Dictionary) -> void:
|
|
406
1107
|
if _peer == null:
|
|
407
1108
|
return
|
|
@@ -415,3 +1116,117 @@ func _read_port() -> int:
|
|
|
415
1116
|
var value: int = int(configured)
|
|
416
1117
|
if value > 0 and value < 65536: return value
|
|
417
1118
|
return _port
|
|
1119
|
+
|
|
1120
|
+
func _on_scene_changed(scene_root: Node) -> void:
|
|
1121
|
+
if scene_root != null and not scene_root.scene_file_path.is_empty():
|
|
1122
|
+
_saved_history_versions[scene_root.scene_file_path] = _history_version(scene_root)
|
|
1123
|
+
|
|
1124
|
+
func _on_scene_saved(filepath: String) -> void:
|
|
1125
|
+
var root: Node = EditorInterface.get_edited_scene_root()
|
|
1126
|
+
if root != null and root.scene_file_path == filepath:
|
|
1127
|
+
_saved_history_versions[filepath] = _history_version(root)
|
|
1128
|
+
|
|
1129
|
+
func _history_version(root: Node) -> int:
|
|
1130
|
+
var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
|
|
1131
|
+
if manager == null:
|
|
1132
|
+
return 0
|
|
1133
|
+
var history_id: int = manager.get_object_history_id(root)
|
|
1134
|
+
var history: UndoRedo = manager.get_history_undo_redo(history_id)
|
|
1135
|
+
return 0 if history == null else history.get_version()
|
|
1136
|
+
|
|
1137
|
+
func _scene_has_unsaved_changes(root: Node) -> bool:
|
|
1138
|
+
var path: String = root.scene_file_path
|
|
1139
|
+
if path.is_empty():
|
|
1140
|
+
return true
|
|
1141
|
+
var current_version: int = _history_version(root)
|
|
1142
|
+
if not _saved_history_versions.has(path):
|
|
1143
|
+
_saved_history_versions[path] = current_version
|
|
1144
|
+
return false
|
|
1145
|
+
return _variant_int(_saved_history_versions[path]) != current_version
|
|
1146
|
+
|
|
1147
|
+
func _variant_int(value: Variant) -> int:
|
|
1148
|
+
if value is int:
|
|
1149
|
+
return value
|
|
1150
|
+
if value is float:
|
|
1151
|
+
var float_value: float = value
|
|
1152
|
+
return roundi(float_value)
|
|
1153
|
+
if value is bool:
|
|
1154
|
+
return 1 if value else 0
|
|
1155
|
+
return 0
|
|
1156
|
+
|
|
1157
|
+
func _variant_float(value: Variant) -> float:
|
|
1158
|
+
if value is float:
|
|
1159
|
+
return value
|
|
1160
|
+
if value is int:
|
|
1161
|
+
var int_value: int = value
|
|
1162
|
+
return float(int_value)
|
|
1163
|
+
if value is bool:
|
|
1164
|
+
return 1.0 if value else 0.0
|
|
1165
|
+
return 0.0
|
|
1166
|
+
|
|
1167
|
+
func _variant_bool(value: Variant) -> bool:
|
|
1168
|
+
if value is bool:
|
|
1169
|
+
return value
|
|
1170
|
+
if value is int:
|
|
1171
|
+
return value != 0
|
|
1172
|
+
if value is float:
|
|
1173
|
+
var float_value: float = value
|
|
1174
|
+
return not is_zero_approx(float_value)
|
|
1175
|
+
return false
|
|
1176
|
+
|
|
1177
|
+
func _creation_fallback(created: bool, message: String) -> Variant:
|
|
1178
|
+
if created:
|
|
1179
|
+
return message
|
|
1180
|
+
return null
|
|
1181
|
+
|
|
1182
|
+
func _write_discovery_record() -> Error:
|
|
1183
|
+
var directory: String = _project_path.path_join(SESSION_DIRECTORY)
|
|
1184
|
+
var directory_error: Error = DirAccess.make_dir_recursive_absolute(directory)
|
|
1185
|
+
if directory_error != OK:
|
|
1186
|
+
return directory_error
|
|
1187
|
+
var record: Dictionary = {
|
|
1188
|
+
"project_path": _project_path,
|
|
1189
|
+
"editor_pid": _editor_pid,
|
|
1190
|
+
"editor_start_identity": _editor_start_identity,
|
|
1191
|
+
"port": _port,
|
|
1192
|
+
"token": _secret,
|
|
1193
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
1194
|
+
"addon_version": ADDON_VERSION,
|
|
1195
|
+
"godot_version": str(Engine.get_version_info().get("string", "unknown")),
|
|
1196
|
+
"created_at": str(Time.get_unix_time_from_system()),
|
|
1197
|
+
}
|
|
1198
|
+
var temporary_path: String = _session_path + ".tmp-%d" % _editor_pid
|
|
1199
|
+
var file: FileAccess = FileAccess.open(temporary_path, FileAccess.WRITE)
|
|
1200
|
+
if file == null:
|
|
1201
|
+
return FileAccess.get_open_error()
|
|
1202
|
+
@warning_ignore("return_value_discarded")
|
|
1203
|
+
file.store_string(JSON.stringify(record) + "\n")
|
|
1204
|
+
file.flush()
|
|
1205
|
+
file.close()
|
|
1206
|
+
var permission_error: Error = FileAccess.set_unix_permissions(temporary_path, 384)
|
|
1207
|
+
if permission_error != OK and OS.get_name() not in ["Windows", "Web"]:
|
|
1208
|
+
@warning_ignore("return_value_discarded")
|
|
1209
|
+
DirAccess.remove_absolute(temporary_path)
|
|
1210
|
+
return permission_error
|
|
1211
|
+
if FileAccess.file_exists(_session_path):
|
|
1212
|
+
@warning_ignore("return_value_discarded")
|
|
1213
|
+
DirAccess.remove_absolute(_session_path)
|
|
1214
|
+
var rename_error: Error = DirAccess.rename_absolute(temporary_path, _session_path)
|
|
1215
|
+
if rename_error != OK:
|
|
1216
|
+
@warning_ignore("return_value_discarded")
|
|
1217
|
+
DirAccess.remove_absolute(temporary_path)
|
|
1218
|
+
return rename_error
|
|
1219
|
+
|
|
1220
|
+
func _remove_owned_discovery_record() -> void:
|
|
1221
|
+
if _session_path.is_empty() or not FileAccess.file_exists(_session_path):
|
|
1222
|
+
return
|
|
1223
|
+
var file: FileAccess = FileAccess.open(_session_path, FileAccess.READ)
|
|
1224
|
+
if file == null:
|
|
1225
|
+
return
|
|
1226
|
+
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
|
1227
|
+
file.close()
|
|
1228
|
+
var parsed_record: Dictionary = parsed if parsed is Dictionary else {}
|
|
1229
|
+
if not parsed_record.is_empty() and str(parsed_record.get("editor_start_identity", "")) == _editor_start_identity:
|
|
1230
|
+
var remove_error: Error = DirAccess.remove_absolute(_session_path)
|
|
1231
|
+
if remove_error != OK:
|
|
1232
|
+
push_warning("Godot Agent Loop could not remove editor discovery record: %s" % error_string(remove_error))
|