@satelliteoflove/godot-mcp 3.4.1 → 3.6.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/addon/command_router.gd +1 -0
- package/addon/commands/runtime_state_commands.gd +82 -0
- package/addon/commands/screenshot_commands.gd +9 -6
- package/addon/core/mcp_debugger_plugin.gd +2 -2
- package/addon/game_bridge/mcp_game_bridge.gd +306 -5
- package/addon/game_bridge/mcp_runtime_state_sampler.gd +215 -0
- package/addon/plugin.cfg +1 -1
- package/dist/__tests__/core/discriminated-union-schema.test.js +17 -13
- package/dist/__tests__/core/discriminated-union-schema.test.js.map +1 -1
- package/dist/__tests__/tools/editor.test.js +1 -1
- package/dist/__tests__/tools/editor.test.js.map +1 -1
- package/dist/__tests__/tools/runtime-state.test.d.ts +2 -0
- package/dist/__tests__/tools/runtime-state.test.d.ts.map +1 -0
- package/dist/__tests__/tools/runtime-state.test.js +302 -0
- package/dist/__tests__/tools/runtime-state.test.js.map +1 -0
- package/dist/tools/editor.d.ts +2 -0
- package/dist/tools/editor.d.ts.map +1 -1
- package/dist/tools/editor.js +9 -7
- package/dist/tools/editor.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/runtime-state.d.ts +66 -0
- package/dist/tools/runtime-state.d.ts.map +1 -0
- package/dist/tools/runtime-state.js +204 -0
- package/dist/tools/runtime-state.js.map +1 -0
- package/package.json +3 -3
package/addon/command_router.gd
CHANGED
|
@@ -21,6 +21,7 @@ func setup(plugin: EditorPlugin) -> void:
|
|
|
21
21
|
_register_handler(MCPScene3DCommands.new(), plugin)
|
|
22
22
|
_register_handler(MCPInputCommands.new(), plugin)
|
|
23
23
|
_register_handler(MCPProfilerCommands.new(), plugin)
|
|
24
|
+
_register_handler(MCPRuntimeStateCommands.new(), plugin)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends MCPBaseCommand
|
|
3
|
+
class_name MCPRuntimeStateCommands
|
|
4
|
+
|
|
5
|
+
const GENERIC_TIMEOUT := 5.0
|
|
6
|
+
var _last_error: Dictionary = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
func get_commands() -> Dictionary:
|
|
10
|
+
return {
|
|
11
|
+
"get_runtime_state": get_runtime_state,
|
|
12
|
+
"watch_start": watch_start,
|
|
13
|
+
"watch_collect": watch_collect,
|
|
14
|
+
"watch_stop": watch_stop,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
func get_runtime_state(params: Dictionary) -> Dictionary:
|
|
19
|
+
var result = await _send_and_wait("get_runtime_state", [params])
|
|
20
|
+
if result == null:
|
|
21
|
+
return _last_error
|
|
22
|
+
if result is Dictionary:
|
|
23
|
+
return _success(result)
|
|
24
|
+
return _success({"data": result})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
func watch_start(params: Dictionary) -> Dictionary:
|
|
28
|
+
var specs: Array = params.get("specs", [])
|
|
29
|
+
var hz: int = params.get("hz", 20)
|
|
30
|
+
var duration_ms: int = params.get("duration_ms", 1000)
|
|
31
|
+
var result = await _send_and_wait("watch_start", [specs, hz, duration_ms])
|
|
32
|
+
if result == null:
|
|
33
|
+
return _last_error
|
|
34
|
+
if result is Dictionary:
|
|
35
|
+
return _success(result)
|
|
36
|
+
return _success({"started": true})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
func watch_collect(_params: Dictionary) -> Dictionary:
|
|
40
|
+
var result = await _send_and_wait("watch_collect", [])
|
|
41
|
+
if result == null:
|
|
42
|
+
return _last_error
|
|
43
|
+
if result is Dictionary:
|
|
44
|
+
return _success(result)
|
|
45
|
+
return _success({"data": result})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
func watch_stop(_params: Dictionary) -> Dictionary:
|
|
49
|
+
var result = await _send_and_wait("watch_stop", [])
|
|
50
|
+
if result == null:
|
|
51
|
+
return _last_error
|
|
52
|
+
if result is Dictionary:
|
|
53
|
+
return _success(result)
|
|
54
|
+
return _success({"stopped": true})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
func _send_and_wait(msg_type: String, args: Array = []):
|
|
58
|
+
if not EditorInterface.is_playing_scene():
|
|
59
|
+
_last_error = _error("NOT_RUNNING", "No game is currently running")
|
|
60
|
+
return null
|
|
61
|
+
|
|
62
|
+
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
63
|
+
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
|
64
|
+
_last_error = _error("NO_SESSION", "No active debug session")
|
|
65
|
+
return null
|
|
66
|
+
|
|
67
|
+
var sent: bool = debugger_plugin.send_game_message(msg_type, args)
|
|
68
|
+
if not sent:
|
|
69
|
+
_last_error = _error("SEND_FAILED", "Failed to send message to game")
|
|
70
|
+
return null
|
|
71
|
+
|
|
72
|
+
var start_time := Time.get_ticks_msec()
|
|
73
|
+
while not debugger_plugin.has_response(msg_type):
|
|
74
|
+
await Engine.get_main_loop().process_frame
|
|
75
|
+
if (Time.get_ticks_msec() - start_time) / 1000.0 > GENERIC_TIMEOUT:
|
|
76
|
+
debugger_plugin.clear_response(msg_type)
|
|
77
|
+
_last_error = _error("TIMEOUT", "Timed out waiting for %s response" % msg_type)
|
|
78
|
+
return null
|
|
79
|
+
|
|
80
|
+
var response = debugger_plugin.get_response(msg_type)
|
|
81
|
+
debugger_plugin.clear_response(msg_type)
|
|
82
|
+
return response
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
extends MCPBaseCommand
|
|
3
3
|
class_name MCPScreenshotCommands
|
|
4
4
|
|
|
5
|
-
const DEFAULT_MAX_WIDTH :=
|
|
5
|
+
const DEFAULT_MAX_WIDTH := 1024
|
|
6
|
+
const DEFAULT_JPEG_QUALITY := 75
|
|
6
7
|
const SCREENSHOT_TIMEOUT := 5.0
|
|
7
8
|
|
|
8
9
|
var _screenshot_result: Dictionary = {}
|
|
@@ -21,6 +22,7 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
|
|
|
21
22
|
return _error("NOT_RUNNING", "No game is currently running. Use run_project first.")
|
|
22
23
|
|
|
23
24
|
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
|
|
25
|
+
var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
|
|
24
26
|
|
|
25
27
|
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
26
28
|
if debugger_plugin == null:
|
|
@@ -33,7 +35,7 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
|
|
|
33
35
|
_screenshot_result = {}
|
|
34
36
|
|
|
35
37
|
debugger_plugin.screenshot_received.connect(_on_screenshot_received, CONNECT_ONE_SHOT)
|
|
36
|
-
debugger_plugin.request_screenshot(max_width)
|
|
38
|
+
debugger_plugin.request_screenshot(max_width, quality)
|
|
37
39
|
|
|
38
40
|
var start_time := Time.get_ticks_msec()
|
|
39
41
|
while _screenshot_pending:
|
|
@@ -62,6 +64,7 @@ func _on_screenshot_received(success: bool, image_base64: String, width: int, he
|
|
|
62
64
|
func capture_editor_screenshot(params: Dictionary) -> Dictionary:
|
|
63
65
|
var viewport_type: String = params.get("viewport", "")
|
|
64
66
|
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
|
|
67
|
+
var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
|
|
65
68
|
|
|
66
69
|
var viewport: SubViewport = null
|
|
67
70
|
|
|
@@ -76,10 +79,10 @@ func capture_editor_screenshot(params: Dictionary) -> Dictionary:
|
|
|
76
79
|
return _error("NO_VIEWPORT", "Could not find editor viewport")
|
|
77
80
|
|
|
78
81
|
var image := viewport.get_texture().get_image()
|
|
79
|
-
return _process_and_encode_image(image, max_width)
|
|
82
|
+
return _process_and_encode_image(image, max_width, quality)
|
|
80
83
|
|
|
81
84
|
|
|
82
|
-
func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
|
|
85
|
+
func _process_and_encode_image(image: Image, max_width: int, quality: float = 0.75) -> Dictionary:
|
|
83
86
|
if image == null:
|
|
84
87
|
return _error("CAPTURE_FAILED", "Failed to capture image from viewport")
|
|
85
88
|
|
|
@@ -88,8 +91,8 @@ func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
|
|
|
88
91
|
var new_height := int(image.get_height() * scale_factor)
|
|
89
92
|
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
|
|
90
93
|
|
|
91
|
-
var
|
|
92
|
-
var base64 := Marshalls.raw_to_base64(
|
|
94
|
+
var jpg_buffer := image.save_jpg_to_buffer(quality)
|
|
95
|
+
var base64 := Marshalls.raw_to_base64(jpg_buffer)
|
|
93
96
|
|
|
94
97
|
return _success({
|
|
95
98
|
"image_base64": base64,
|
|
@@ -97,14 +97,14 @@ func has_active_session() -> bool:
|
|
|
97
97
|
return true
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
func request_screenshot(max_width: int =
|
|
100
|
+
func request_screenshot(max_width: int = 1024, quality: float = 0.75) -> void:
|
|
101
101
|
if _active_session_id < 0:
|
|
102
102
|
screenshot_received.emit(false, "", 0, 0, "No active game session")
|
|
103
103
|
return
|
|
104
104
|
_pending_screenshot = true
|
|
105
105
|
var session := get_session(_active_session_id)
|
|
106
106
|
if session:
|
|
107
|
-
session.send_message("godot_mcp:take_screenshot", [max_width])
|
|
107
|
+
session.send_message("godot_mcp:take_screenshot", [max_width, quality])
|
|
108
108
|
else:
|
|
109
109
|
_pending_screenshot = false
|
|
110
110
|
screenshot_received.emit(false, "", 0, 0, "Could not get debugger session")
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
extends Node
|
|
2
2
|
class_name MCPGameBridge
|
|
3
3
|
|
|
4
|
-
const DEFAULT_MAX_WIDTH :=
|
|
4
|
+
const DEFAULT_MAX_WIDTH := 1024
|
|
5
|
+
const DEFAULT_JPEG_QUALITY := 0.75
|
|
5
6
|
|
|
6
7
|
var _logger: _MCPGameLogger
|
|
7
8
|
var _profiler: MCPFrameProfiler
|
|
9
|
+
var _sampler: MCPRuntimeStateSampler
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
func _ready() -> void:
|
|
@@ -14,6 +16,8 @@ func _ready() -> void:
|
|
|
14
16
|
OS.add_logger(_logger)
|
|
15
17
|
_profiler = MCPFrameProfiler.new()
|
|
16
18
|
EngineDebugger.register_profiler("mcp_frame_profiler", _profiler)
|
|
19
|
+
_sampler = MCPRuntimeStateSampler.new()
|
|
20
|
+
add_child(_sampler)
|
|
17
21
|
EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
|
|
18
22
|
MCPLog.info("Game bridge initialized")
|
|
19
23
|
|
|
@@ -89,16 +93,29 @@ func _on_debugger_message(message: String, data: Array) -> bool:
|
|
|
89
93
|
"get_signal_connections":
|
|
90
94
|
_handle_get_signal_connections(data)
|
|
91
95
|
return true
|
|
96
|
+
"get_runtime_state":
|
|
97
|
+
_handle_get_runtime_state(data)
|
|
98
|
+
return true
|
|
99
|
+
"watch_start":
|
|
100
|
+
_handle_watch_start(data)
|
|
101
|
+
return true
|
|
102
|
+
"watch_collect":
|
|
103
|
+
_handle_watch_collect()
|
|
104
|
+
return true
|
|
105
|
+
"watch_stop":
|
|
106
|
+
_handle_watch_stop()
|
|
107
|
+
return true
|
|
92
108
|
return false
|
|
93
109
|
|
|
94
110
|
|
|
95
111
|
func _take_screenshot_deferred(data: Array) -> void:
|
|
96
112
|
var max_width: int = data[0] if data.size() > 0 else DEFAULT_MAX_WIDTH
|
|
113
|
+
var quality: float = data[1] if data.size() > 1 else DEFAULT_JPEG_QUALITY
|
|
97
114
|
await RenderingServer.frame_post_draw
|
|
98
|
-
_capture_and_send_screenshot(max_width)
|
|
115
|
+
_capture_and_send_screenshot(max_width, quality)
|
|
99
116
|
|
|
100
117
|
|
|
101
|
-
func _capture_and_send_screenshot(max_width: int) -> void:
|
|
118
|
+
func _capture_and_send_screenshot(max_width: int, quality: float = DEFAULT_JPEG_QUALITY) -> void:
|
|
102
119
|
var viewport := get_viewport()
|
|
103
120
|
if viewport == null:
|
|
104
121
|
_send_screenshot_error("NO_VIEWPORT", "Could not get game viewport")
|
|
@@ -111,8 +128,8 @@ func _capture_and_send_screenshot(max_width: int) -> void:
|
|
|
111
128
|
var scale_factor := float(max_width) / float(image.get_width())
|
|
112
129
|
var new_height := int(image.get_height() * scale_factor)
|
|
113
130
|
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
|
|
114
|
-
var
|
|
115
|
-
var base64 := Marshalls.raw_to_base64(
|
|
131
|
+
var jpg_buffer := image.save_jpg_to_buffer(quality)
|
|
132
|
+
var base64 := Marshalls.raw_to_base64(jpg_buffer)
|
|
116
133
|
EngineDebugger.send_message("godot_mcp:screenshot_result", [
|
|
117
134
|
true,
|
|
118
135
|
base64,
|
|
@@ -374,6 +391,290 @@ func _node_path_string(node: Node, scene_root: Node) -> String:
|
|
|
374
391
|
return path
|
|
375
392
|
|
|
376
393
|
|
|
394
|
+
func _handle_get_runtime_state(data: Array) -> void:
|
|
395
|
+
var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
|
|
396
|
+
|
|
397
|
+
var tree := get_tree()
|
|
398
|
+
var scene_root := tree.current_scene if tree else null
|
|
399
|
+
if not scene_root:
|
|
400
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["get_runtime_state", {
|
|
401
|
+
"scene": "",
|
|
402
|
+
"selection": "fallback",
|
|
403
|
+
"entity_count": 0,
|
|
404
|
+
"entities": [],
|
|
405
|
+
"hint": "No scene is currently running.",
|
|
406
|
+
}])
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
var select_mode: String = params.get("select", "auto")
|
|
410
|
+
var group_name: String = params.get("group", "mcp_watch")
|
|
411
|
+
var name_filter: String = params.get("name", "")
|
|
412
|
+
var type_filter: String = params.get("type", "")
|
|
413
|
+
var max_nodes: int = params.get("max_nodes", 40)
|
|
414
|
+
var include_fields: Array = params.get("include", [])
|
|
415
|
+
max_nodes = clampi(max_nodes, 1, 200)
|
|
416
|
+
|
|
417
|
+
# Resolve camera for on-screen checks
|
|
418
|
+
var camera_2d: Camera2D = _find_camera_2d()
|
|
419
|
+
var camera_viewport_rect := Rect2()
|
|
420
|
+
if camera_2d:
|
|
421
|
+
camera_viewport_rect = camera_2d.get_viewport_rect()
|
|
422
|
+
|
|
423
|
+
# Determine which selection tier to use
|
|
424
|
+
var actual_selection: String = select_mode
|
|
425
|
+
if select_mode == "auto":
|
|
426
|
+
if _has_group_members(scene_root, group_name):
|
|
427
|
+
actual_selection = "group"
|
|
428
|
+
elif _has_mcp_state_nodes(scene_root):
|
|
429
|
+
actual_selection = "method"
|
|
430
|
+
else:
|
|
431
|
+
actual_selection = "fallback"
|
|
432
|
+
|
|
433
|
+
# Collect entities
|
|
434
|
+
var entities: Array = []
|
|
435
|
+
_collect_runtime_state(scene_root, scene_root, actual_selection, group_name,
|
|
436
|
+
name_filter, type_filter, include_fields, camera_2d, camera_viewport_rect,
|
|
437
|
+
max_nodes, entities)
|
|
438
|
+
|
|
439
|
+
# Extract camera entity separately if present
|
|
440
|
+
var camera_entity = null
|
|
441
|
+
var cam_node := _find_camera_2d() if actual_selection != "fallback" else camera_2d
|
|
442
|
+
if cam_node:
|
|
443
|
+
camera_entity = {
|
|
444
|
+
"type": "Camera2D",
|
|
445
|
+
"pos": {"x": snapped(cam_node.global_position.x, 0.01), "y": snapped(cam_node.global_position.y, 0.01)},
|
|
446
|
+
"zoom": {"x": snapped(cam_node.zoom.x, 0.01), "y": snapped(cam_node.zoom.y, 0.01)},
|
|
447
|
+
"camera": true,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
var hint := ""
|
|
451
|
+
if actual_selection == "fallback":
|
|
452
|
+
hint = ("No nodes found in group '%s' and no _mcp_state() methods detected. " +
|
|
453
|
+
"For richer data: add key nodes to the '%s' group, then implement " +
|
|
454
|
+
"`func _mcp_state() -> Dictionary` on them. " +
|
|
455
|
+
"In _mcp_state(), include both live runtime values (position, health, score) " +
|
|
456
|
+
"AND static definition context (puzzle clues, level config, item data) — " +
|
|
457
|
+
"an agent needs both to understand and verify game state.") % [group_name, group_name]
|
|
458
|
+
|
|
459
|
+
var result: Dictionary = {
|
|
460
|
+
"scene": scene_root.scene_file_path,
|
|
461
|
+
"selection": actual_selection,
|
|
462
|
+
"entity_count": entities.size(),
|
|
463
|
+
"entities": entities,
|
|
464
|
+
}
|
|
465
|
+
if camera_entity:
|
|
466
|
+
result["camera"] = camera_entity
|
|
467
|
+
if not hint.is_empty():
|
|
468
|
+
result["hint"] = hint
|
|
469
|
+
|
|
470
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["get_runtime_state", result])
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
func _has_group_members(scene_root: Node, group_name: String) -> bool:
|
|
474
|
+
var tree := get_tree()
|
|
475
|
+
if tree == null:
|
|
476
|
+
return false
|
|
477
|
+
return tree.get_nodes_in_group(group_name).size() > 0
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
func _has_mcp_state_nodes(node: Node) -> bool:
|
|
481
|
+
if node.has_method("_mcp_state"):
|
|
482
|
+
return true
|
|
483
|
+
for child in node.get_children():
|
|
484
|
+
if _has_mcp_state_nodes(child):
|
|
485
|
+
return true
|
|
486
|
+
return false
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
func _collect_runtime_state(node: Node, scene_root: Node, selection: String, group_name: String,
|
|
490
|
+
name_filter: String, type_filter: String, include_fields: Array,
|
|
491
|
+
camera_2d: Camera2D, viewport_rect: Rect2,
|
|
492
|
+
max_nodes: int, results: Array) -> void:
|
|
493
|
+
if results.size() >= max_nodes:
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
var include_node := false
|
|
497
|
+
match selection:
|
|
498
|
+
"group":
|
|
499
|
+
include_node = node.is_in_group(group_name)
|
|
500
|
+
"method":
|
|
501
|
+
include_node = node.has_method("_mcp_state")
|
|
502
|
+
"fallback":
|
|
503
|
+
include_node = (node is CanvasItem and (node as CanvasItem).is_visible_in_tree())
|
|
504
|
+
|
|
505
|
+
if include_node:
|
|
506
|
+
if not name_filter.is_empty() and not node.name.matchn(name_filter):
|
|
507
|
+
include_node = false
|
|
508
|
+
if not type_filter.is_empty() and not node.is_class(type_filter):
|
|
509
|
+
include_node = false
|
|
510
|
+
|
|
511
|
+
if include_node:
|
|
512
|
+
var entity := _extract_node_state(node, scene_root, include_fields, camera_2d, viewport_rect)
|
|
513
|
+
if entity != null:
|
|
514
|
+
results.append(entity)
|
|
515
|
+
|
|
516
|
+
for child in node.get_children():
|
|
517
|
+
if results.size() >= max_nodes:
|
|
518
|
+
return
|
|
519
|
+
_collect_runtime_state(child, scene_root, selection, group_name,
|
|
520
|
+
name_filter, type_filter, include_fields, camera_2d, viewport_rect,
|
|
521
|
+
max_nodes, results)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# _mcp_state() contract: return a Dictionary with two categories —
|
|
525
|
+
# (1) live runtime values that change during play (cursor pos, health, score, fill counts)
|
|
526
|
+
# (2) static definition context needed to interpret them (puzzle clues, level layout, config)
|
|
527
|
+
# An agent can observe (1) without (2) but cannot verify correctness without both.
|
|
528
|
+
# Optionally include layout geometry (bounds, sizes) to enable programmatic layout checks.
|
|
529
|
+
# Error handling: _mcp_state() runtime errors are non-fatal in GDScript (Godot prints them
|
|
530
|
+
# and the call returns null); the `is Dictionary` check below handles that silently.
|
|
531
|
+
func _extract_node_state(node: Node, scene_root: Node, include_fields: Array,
|
|
532
|
+
camera_2d: Camera2D, viewport_rect: Rect2) -> Dictionary:
|
|
533
|
+
var want := include_fields.is_empty()
|
|
534
|
+
var want_transform := want or include_fields.has("transform")
|
|
535
|
+
var want_velocity := want or include_fields.has("velocity")
|
|
536
|
+
var want_anim := want or include_fields.has("anim")
|
|
537
|
+
var want_groups := want or include_fields.has("groups")
|
|
538
|
+
var want_onscreen := want or include_fields.has("onscreen")
|
|
539
|
+
var want_state := want or include_fields.has("state")
|
|
540
|
+
|
|
541
|
+
var entity: Dictionary = {
|
|
542
|
+
"path": _node_path_string(node, scene_root),
|
|
543
|
+
"type": node.get_class(),
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if want_groups:
|
|
547
|
+
var groups := node.get_groups().filter(func(g): return not g.begins_with("_"))
|
|
548
|
+
if not groups.is_empty():
|
|
549
|
+
entity["groups"] = groups
|
|
550
|
+
|
|
551
|
+
if want_transform and node is Node2D:
|
|
552
|
+
var n2d := node as Node2D
|
|
553
|
+
entity["pos"] = {"x": snapped(n2d.global_position.x, 0.01), "y": snapped(n2d.global_position.y, 0.01)}
|
|
554
|
+
entity["rot"] = snapped(rad_to_deg(n2d.global_rotation), 0.01)
|
|
555
|
+
if n2d.scale != Vector2.ONE:
|
|
556
|
+
entity["scale"] = {"x": snapped(n2d.scale.x, 0.01), "y": snapped(n2d.scale.y, 0.01)}
|
|
557
|
+
|
|
558
|
+
if want_transform and node is Node3D:
|
|
559
|
+
var n3d := node as Node3D
|
|
560
|
+
entity["pos"] = {
|
|
561
|
+
"x": snapped(n3d.global_position.x, 0.01),
|
|
562
|
+
"y": snapped(n3d.global_position.y, 0.01),
|
|
563
|
+
"z": snapped(n3d.global_position.z, 0.01),
|
|
564
|
+
}
|
|
565
|
+
entity["rot"] = {
|
|
566
|
+
"x": snapped(rad_to_deg(n3d.global_rotation.x), 0.01),
|
|
567
|
+
"y": snapped(rad_to_deg(n3d.global_rotation.y), 0.01),
|
|
568
|
+
"z": snapped(rad_to_deg(n3d.global_rotation.z), 0.01),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if want_velocity:
|
|
572
|
+
if node is CharacterBody2D:
|
|
573
|
+
var v := (node as CharacterBody2D).velocity
|
|
574
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01)}
|
|
575
|
+
elif node is RigidBody2D:
|
|
576
|
+
var v := (node as RigidBody2D).linear_velocity
|
|
577
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01)}
|
|
578
|
+
entity["angvel"] = snapped((node as RigidBody2D).angular_velocity, 0.01)
|
|
579
|
+
elif node is CharacterBody3D:
|
|
580
|
+
var v := (node as CharacterBody3D).velocity
|
|
581
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01), "z": snapped(v.z, 0.01)}
|
|
582
|
+
elif node is RigidBody3D:
|
|
583
|
+
var v := (node as RigidBody3D).linear_velocity
|
|
584
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01), "z": snapped(v.z, 0.01)}
|
|
585
|
+
var av := (node as RigidBody3D).angular_velocity
|
|
586
|
+
entity["angvel"] = {"x": snapped(av.x, 0.01), "y": snapped(av.y, 0.01), "z": snapped(av.z, 0.01)}
|
|
587
|
+
|
|
588
|
+
if want_anim:
|
|
589
|
+
if node is AnimationPlayer:
|
|
590
|
+
var ap := node as AnimationPlayer
|
|
591
|
+
entity["anim"] = ap.current_animation
|
|
592
|
+
entity["anim_pos"] = snapped(ap.current_animation_position, 0.01)
|
|
593
|
+
entity["playing"] = ap.is_playing()
|
|
594
|
+
elif node is AnimatedSprite2D:
|
|
595
|
+
var asp := node as AnimatedSprite2D
|
|
596
|
+
entity["anim"] = asp.animation
|
|
597
|
+
entity["anim_frame"] = asp.frame
|
|
598
|
+
|
|
599
|
+
if want_onscreen and camera_2d and node is Node2D:
|
|
600
|
+
var pos := (node as Node2D).global_position
|
|
601
|
+
entity["onscreen"] = viewport_rect.has_point(pos)
|
|
602
|
+
|
|
603
|
+
if want_state and node.has_method("_mcp_state"):
|
|
604
|
+
var raw_state = node._mcp_state()
|
|
605
|
+
if raw_state is Dictionary:
|
|
606
|
+
var serialized := _serialize_mcp_state(raw_state)
|
|
607
|
+
if not serialized.is_empty():
|
|
608
|
+
entity["state"] = serialized
|
|
609
|
+
|
|
610
|
+
return entity
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
const _MCP_STATE_MAX_BYTES := 1024
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
func _serialize_mcp_state(state: Dictionary) -> Dictionary:
|
|
617
|
+
var result: Dictionary = {}
|
|
618
|
+
for key in state:
|
|
619
|
+
var val = state[key]
|
|
620
|
+
var serializable = null
|
|
621
|
+
match typeof(val):
|
|
622
|
+
TYPE_BOOL, TYPE_STRING:
|
|
623
|
+
serializable = val
|
|
624
|
+
TYPE_INT:
|
|
625
|
+
serializable = int(val)
|
|
626
|
+
TYPE_FLOAT:
|
|
627
|
+
serializable = snapped(float(val), 0.01)
|
|
628
|
+
TYPE_ARRAY:
|
|
629
|
+
serializable = val
|
|
630
|
+
TYPE_DICTIONARY:
|
|
631
|
+
serializable = val
|
|
632
|
+
# skip non-serializable types (Objects, NodePaths, RIDs, etc.)
|
|
633
|
+
if serializable == null:
|
|
634
|
+
continue
|
|
635
|
+
result[str(key)] = serializable
|
|
636
|
+
if JSON.stringify(result).length() > _MCP_STATE_MAX_BYTES:
|
|
637
|
+
result.erase(str(key))
|
|
638
|
+
result["_truncated"] = true
|
|
639
|
+
break
|
|
640
|
+
return result
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
func _find_camera_2d() -> Camera2D:
|
|
644
|
+
var viewport := get_viewport()
|
|
645
|
+
if viewport == null:
|
|
646
|
+
return null
|
|
647
|
+
return viewport.get_camera_2d()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
func _handle_watch_start(data: Array) -> void:
|
|
651
|
+
if _sampler == null:
|
|
652
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {"started": false, "error": "Sampler not initialized"}])
|
|
653
|
+
return
|
|
654
|
+
var specs: Array = data[0] if data.size() > 0 else []
|
|
655
|
+
var hz: int = data[1] if data.size() > 1 else 20
|
|
656
|
+
var duration_ms: int = data[2] if data.size() > 2 else 1000
|
|
657
|
+
var start_result := _sampler.start(specs, hz, duration_ms)
|
|
658
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {
|
|
659
|
+
"started": true,
|
|
660
|
+
"resolved_fields": start_result.get("resolved_fields", 0),
|
|
661
|
+
}])
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
func _handle_watch_collect() -> void:
|
|
665
|
+
if _sampler == null:
|
|
666
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", {"window_ms": 0, "sample_count": 0, "fields": {}}])
|
|
667
|
+
return
|
|
668
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", _sampler.collect()])
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
func _handle_watch_stop() -> void:
|
|
672
|
+
if _sampler == null:
|
|
673
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", {"window_ms": 0, "sample_count": 0, "fields": {}}])
|
|
674
|
+
return
|
|
675
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", _sampler.stop()])
|
|
676
|
+
|
|
677
|
+
|
|
377
678
|
class _MCPGameLogger extends Logger:
|
|
378
679
|
var _output: PackedStringArray = []
|
|
379
680
|
var _max_lines := 1000
|