breakpoint-mcp 1.0.0 → 1.2.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/breakpoint_mcp/README.md +28 -0
- package/addon/breakpoint_mcp/bridge_server.gd +121 -0
- package/addon/breakpoint_mcp/icon.png +0 -0
- package/addon/breakpoint_mcp/operations.gd +4578 -0
- package/addon/breakpoint_mcp/plugin.cfg +7 -0
- package/addon/breakpoint_mcp/plugin.gd +64 -0
- package/addon/breakpoint_mcp/runtime_bridge.gd +440 -0
- package/addon/breakpoint_mcp/variant_json.gd +108 -0
- package/dist/cli/args.js +40 -0
- package/dist/cli/clients.js +74 -0
- package/dist/cli/doctor.js +269 -0
- package/dist/cli/init.js +199 -0
- package/dist/config.js +6 -30
- package/dist/index.js +49 -4
- package/dist/tools/editor/animation.js +162 -0
- package/dist/tools/editor/audio.js +109 -0
- package/dist/tools/editor/common.js +31 -0
- package/dist/tools/editor/core.js +49 -0
- package/dist/tools/editor/filesystem.js +35 -0
- package/dist/tools/editor/introspection.js +73 -0
- package/dist/tools/editor/node.js +171 -0
- package/dist/tools/editor/particles.js +96 -0
- package/dist/tools/editor/physics.js +236 -0
- package/dist/tools/editor/project_input_test.js +187 -0
- package/dist/tools/editor/resource.js +104 -0
- package/dist/tools/editor/scene.js +95 -0
- package/dist/tools/editor/shader.js +59 -0
- package/dist/tools/editor/signal.js +67 -0
- package/dist/tools/editor/spatial.js +185 -0
- package/dist/tools/editor/tiles.js +160 -0
- package/dist/tools/editor/ui.js +175 -0
- package/dist/tools/editor.js +39 -1914
- package/package.json +4 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[plugin]
|
|
2
|
+
|
|
3
|
+
name="Breakpoint MCP"
|
|
4
|
+
description="Exposes the live Godot editor and the running game to an MCP host so an AI assistant can drive them: scene/node/resource CRUD with full undo/redo, project settings, ClassDB, editor + in-game screenshots, and an in-game runtime bridge (live SceneTree, property get/set, method calls, signals, input injection, Performance monitors). Pairs with the breakpoint-mcp MCP host."
|
|
5
|
+
author="James Livingston"
|
|
6
|
+
version="1.2.0"
|
|
7
|
+
script="plugin.gd"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends EditorPlugin
|
|
3
|
+
## Breakpoint MCP — EditorPlugin entry point.
|
|
4
|
+
##
|
|
5
|
+
## Spins up a loopback TCP server (bridge_server.gd) when the plugin is enabled
|
|
6
|
+
## and tears it down when disabled. All engine interaction happens on the main
|
|
7
|
+
## thread: the server polls its socket from _process, so every request handler
|
|
8
|
+
## already runs in a main-thread context (no marshaling needed for this scaffold).
|
|
9
|
+
##
|
|
10
|
+
## D3: also watches the editor selection and the edited scene, and asks the
|
|
11
|
+
## bridge server to push a "resource.changed" event when either moves, so a
|
|
12
|
+
## subscribed MCP host can emit notifications/resources/updated.
|
|
13
|
+
|
|
14
|
+
const BridgeServer := preload("res://addons/breakpoint_mcp/bridge_server.gd")
|
|
15
|
+
const RUNTIME_AUTOLOAD := "BreakpointRuntimeBridge"
|
|
16
|
+
const RUNTIME_SCRIPT := "res://addons/breakpoint_mcp/runtime_bridge.gd"
|
|
17
|
+
|
|
18
|
+
var _server: Node = null
|
|
19
|
+
var _selection: EditorSelection = null
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
func _enter_tree() -> void:
|
|
23
|
+
_server = BridgeServer.new()
|
|
24
|
+
_server.name = "BreakpointBridgeServer"
|
|
25
|
+
_server.setup(self)
|
|
26
|
+
add_child(_server)
|
|
27
|
+
# Inject the runtime bridge into every run of the project so the runtime_*
|
|
28
|
+
# tools work as soon as the game starts. Removed cleanly on disable.
|
|
29
|
+
add_autoload_singleton(RUNTIME_AUTOLOAD, RUNTIME_SCRIPT)
|
|
30
|
+
# D3: emit resources/updated triggers. Selection + edited scene are reflected
|
|
31
|
+
# in godot://editor-state; the edited tree in godot://scene-tree.
|
|
32
|
+
_selection = EditorInterface.get_selection()
|
|
33
|
+
if _selection and not _selection.selection_changed.is_connected(_on_selection_changed):
|
|
34
|
+
_selection.selection_changed.connect(_on_selection_changed)
|
|
35
|
+
if not scene_changed.is_connected(_on_scene_changed):
|
|
36
|
+
scene_changed.connect(_on_scene_changed)
|
|
37
|
+
print("[breakpoint_mcp] plugin enabled")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
func _exit_tree() -> void:
|
|
41
|
+
if _selection and _selection.selection_changed.is_connected(_on_selection_changed):
|
|
42
|
+
_selection.selection_changed.disconnect(_on_selection_changed)
|
|
43
|
+
_selection = null
|
|
44
|
+
if scene_changed.is_connected(_on_scene_changed):
|
|
45
|
+
scene_changed.disconnect(_on_scene_changed)
|
|
46
|
+
remove_autoload_singleton(RUNTIME_AUTOLOAD)
|
|
47
|
+
if _server:
|
|
48
|
+
_server.shutdown()
|
|
49
|
+
_server.queue_free()
|
|
50
|
+
_server = null
|
|
51
|
+
print("[breakpoint_mcp] plugin disabled")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## D3: the editor selection changed — godot://editor-state reflects the selection.
|
|
55
|
+
func _on_selection_changed() -> void:
|
|
56
|
+
if _server:
|
|
57
|
+
_server.broadcast_event("godot://editor-state")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## D3: a different scene is being edited — both the tree and editor state change.
|
|
61
|
+
func _on_scene_changed(_scene_root: Node) -> void:
|
|
62
|
+
if _server:
|
|
63
|
+
_server.broadcast_event("godot://scene-tree")
|
|
64
|
+
_server.broadcast_event("godot://editor-state")
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
## Breakpoint Runtime Bridge — runs INSIDE the running game as an autoload.
|
|
3
|
+
##
|
|
4
|
+
## The editor plugin auto-registers this as an autoload singleton
|
|
5
|
+
## ("BreakpointRuntimeBridge"), so it is present whenever the project runs. It opens
|
|
6
|
+
## a loopback TCP server on 127.0.0.1:9081 (override BREAKPOINT_RUNTIME_PORT) speaking
|
|
7
|
+
## the SAME newline-delimited JSON protocol as the editor bridge, and exposes the
|
|
8
|
+
## live SceneTree: read/write properties, call methods, emit signals, inject
|
|
9
|
+
## input, read Performance monitors, capture frames, and read a log ring buffer.
|
|
10
|
+
##
|
|
11
|
+
## NOTE: this script is intentionally NOT @tool — it must run in the game, not
|
|
12
|
+
## the editor. All handlers run on the main thread (socket polled from _process).
|
|
13
|
+
|
|
14
|
+
const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
|
|
15
|
+
const DEFAULT_PORT := 9081
|
|
16
|
+
const LOG_CAP := 1000
|
|
17
|
+
# D6: source for the runtime-compiled Logger subclass (Godot 4.5+). Kept as a
|
|
18
|
+
# string so `extends Logger` is only ever compiled where the class exists — the
|
|
19
|
+
# addon stays parse-clean on Godot 4.3/4.4 (no Logger class).
|
|
20
|
+
const _LOG_CAPTURE_SRC := """extends Logger
|
|
21
|
+
var sink: Callable
|
|
22
|
+
func _log_message(message: String, error: bool) -> void:
|
|
23
|
+
if sink.is_valid():
|
|
24
|
+
sink.call("error" if error else "info", message)
|
|
25
|
+
func _log_error(function: String, file: String, line: int, code: String, rationale: String, editor_notify: bool, error_type: int, script_backtraces: Array) -> void:
|
|
26
|
+
if sink.is_valid():
|
|
27
|
+
var lvl := "warning" if error_type == 1 else "error"
|
|
28
|
+
var detail := rationale if rationale != "" else code
|
|
29
|
+
sink.call(lvl, "%s (%s:%d)" % [detail, file, line])
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Curated Performance monitors exposed by runtime.get_monitors.
|
|
33
|
+
const MONITORS := {
|
|
34
|
+
"time/fps": Performance.TIME_FPS,
|
|
35
|
+
"time/process": Performance.TIME_PROCESS,
|
|
36
|
+
"time/physics_process": Performance.TIME_PHYSICS_PROCESS,
|
|
37
|
+
"memory/static": Performance.MEMORY_STATIC,
|
|
38
|
+
"object/node_count": Performance.OBJECT_NODE_COUNT,
|
|
39
|
+
"object/resource_count": Performance.OBJECT_RESOURCE_COUNT,
|
|
40
|
+
"render/total_objects_drawn": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME,
|
|
41
|
+
"render/total_draw_calls": Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME,
|
|
42
|
+
"render/video_mem_used": Performance.RENDER_VIDEO_MEM_USED,
|
|
43
|
+
"physics_3d/active_objects": Performance.PHYSICS_3D_ACTIVE_OBJECTS,
|
|
44
|
+
"physics_2d/active_objects": Performance.PHYSICS_2D_ACTIVE_OBJECTS,
|
|
45
|
+
"audio/output_latency": Performance.AUDIO_OUTPUT_LATENCY,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var _server: TCPServer
|
|
49
|
+
var _clients: Array = [] # Array of {peer, buf}
|
|
50
|
+
var _port: int = DEFAULT_PORT
|
|
51
|
+
var _log: Array = [] # ring buffer of {seq, level, message}
|
|
52
|
+
var _log_seq: int = 0
|
|
53
|
+
var _tree_dirty: bool = false
|
|
54
|
+
var _log_dirty: bool = false
|
|
55
|
+
var _log_capture = null # registered Logger (Godot 4.5+) or null
|
|
56
|
+
var _in_capture: bool = false
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
func _ready() -> void:
|
|
60
|
+
# Keep servicing requests even while the game is paused (e.g. at a breakpoint).
|
|
61
|
+
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
62
|
+
var env_port := OS.get_environment("BREAKPOINT_RUNTIME_PORT")
|
|
63
|
+
if env_port != "" and env_port.is_valid_int():
|
|
64
|
+
_port = int(env_port)
|
|
65
|
+
_server = TCPServer.new()
|
|
66
|
+
var err := _server.listen(_port, "127.0.0.1")
|
|
67
|
+
if err != OK:
|
|
68
|
+
push_error("[breakpoint_runtime] could not listen on 127.0.0.1:%d (error %d)" % [_port, err])
|
|
69
|
+
else:
|
|
70
|
+
push_log("info", "BreakpointRuntimeBridge listening on 127.0.0.1:%d" % _port)
|
|
71
|
+
# D3 follow-up: re-emit godot://runtime/tree when the live SceneTree structure
|
|
72
|
+
# changes so subscribers re-read it. Collapsed to one push per frame via
|
|
73
|
+
# _tree_dirty (see _process) so a burst of node adds/removes is a single event.
|
|
74
|
+
var tree := get_tree()
|
|
75
|
+
if tree:
|
|
76
|
+
tree.node_added.connect(_on_tree_structure_changed)
|
|
77
|
+
tree.node_removed.connect(_on_tree_structure_changed)
|
|
78
|
+
tree.node_renamed.connect(_on_tree_structure_changed)
|
|
79
|
+
_install_log_capture()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Public API: game code can route its own logs here for runtime.get_log to read.
|
|
83
|
+
func push_log(level: String, message: String) -> void:
|
|
84
|
+
_log_seq += 1
|
|
85
|
+
_log.append({"seq": _log_seq, "level": level, "message": message})
|
|
86
|
+
while _log.size() > LOG_CAP:
|
|
87
|
+
_log.pop_front()
|
|
88
|
+
_log_dirty = true
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
func _exit_tree() -> void:
|
|
92
|
+
var tree := get_tree()
|
|
93
|
+
if tree:
|
|
94
|
+
if tree.node_added.is_connected(_on_tree_structure_changed):
|
|
95
|
+
tree.node_added.disconnect(_on_tree_structure_changed)
|
|
96
|
+
if tree.node_removed.is_connected(_on_tree_structure_changed):
|
|
97
|
+
tree.node_removed.disconnect(_on_tree_structure_changed)
|
|
98
|
+
if tree.node_renamed.is_connected(_on_tree_structure_changed):
|
|
99
|
+
tree.node_renamed.disconnect(_on_tree_structure_changed)
|
|
100
|
+
if _log_capture != null and ClassDB.class_has_method("OS", "remove_logger"):
|
|
101
|
+
# Call dynamically: OS.remove_logger() is 4.5+, and a literal OS.remove_logger(...) is
|
|
102
|
+
# resolved at PARSE time, so it fails to compile the whole script on Godot 4.3/4.4 —
|
|
103
|
+
# taking the entire runtime bridge down, not just capture. OS.call() defers the lookup
|
|
104
|
+
# to runtime, past the class_has_method guard above.
|
|
105
|
+
OS.call("remove_logger", _log_capture)
|
|
106
|
+
_log_capture = null
|
|
107
|
+
for c in _clients:
|
|
108
|
+
var peer: StreamPeerTCP = c["peer"]
|
|
109
|
+
if peer:
|
|
110
|
+
peer.disconnect_from_host()
|
|
111
|
+
_clients.clear()
|
|
112
|
+
if _server:
|
|
113
|
+
_server.stop()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
func _process(_delta: float) -> void:
|
|
117
|
+
if _server == null:
|
|
118
|
+
return
|
|
119
|
+
while _server.is_connection_available():
|
|
120
|
+
var peer := _server.take_connection()
|
|
121
|
+
if peer:
|
|
122
|
+
_clients.append({"peer": peer, "buf": ""})
|
|
123
|
+
var alive: Array = []
|
|
124
|
+
for c in _clients:
|
|
125
|
+
var peer: StreamPeerTCP = c["peer"]
|
|
126
|
+
peer.poll()
|
|
127
|
+
var status := peer.get_status()
|
|
128
|
+
if status == StreamPeerTCP.STATUS_ERROR or status == StreamPeerTCP.STATUS_NONE:
|
|
129
|
+
continue
|
|
130
|
+
var available := peer.get_available_bytes()
|
|
131
|
+
if available > 0:
|
|
132
|
+
var chunk := peer.get_data(available)
|
|
133
|
+
if chunk[0] == OK:
|
|
134
|
+
var bytes: PackedByteArray = chunk[1]
|
|
135
|
+
c["buf"] += bytes.get_string_from_utf8()
|
|
136
|
+
_drain_lines(c)
|
|
137
|
+
alive.append(c)
|
|
138
|
+
_clients = alive
|
|
139
|
+
# D3 follow-up: one runtime-tree push per frame if the SceneTree changed.
|
|
140
|
+
if _tree_dirty:
|
|
141
|
+
_tree_dirty = false
|
|
142
|
+
broadcast_event("godot://runtime/tree")
|
|
143
|
+
if _log_dirty:
|
|
144
|
+
_log_dirty = false
|
|
145
|
+
broadcast_event("godot://runtime/log")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
func _drain_lines(c: Dictionary) -> void:
|
|
149
|
+
var buf: String = c["buf"]
|
|
150
|
+
while true:
|
|
151
|
+
var nl := buf.find("\n")
|
|
152
|
+
if nl == -1:
|
|
153
|
+
break
|
|
154
|
+
var line := buf.substr(0, nl).strip_edges()
|
|
155
|
+
buf = buf.substr(nl + 1)
|
|
156
|
+
if line != "":
|
|
157
|
+
_handle_line(c["peer"], line)
|
|
158
|
+
c["buf"] = buf
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
func _handle_line(peer: StreamPeerTCP, line: String) -> void:
|
|
162
|
+
var parsed: Variant = JSON.parse_string(line)
|
|
163
|
+
if typeof(parsed) != TYPE_DICTIONARY:
|
|
164
|
+
_send(peer, {"id": null, "ok": false, "error": {"code": "bad_json", "message": "Bad request"}})
|
|
165
|
+
return
|
|
166
|
+
var req: Dictionary = parsed
|
|
167
|
+
var id: Variant = req.get("id", null)
|
|
168
|
+
var method := String(req.get("method", ""))
|
|
169
|
+
var params: Dictionary = req.get("params", {}) if typeof(req.get("params")) == TYPE_DICTIONARY else {}
|
|
170
|
+
var result := _dispatch(method, params)
|
|
171
|
+
var response := {"id": id}
|
|
172
|
+
response.merge(result)
|
|
173
|
+
_send(peer, response)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
|
|
177
|
+
peer.put_data((JSON.stringify(obj) + "\n").to_utf8_buffer())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
## D3: push an unsolicited "resource changed" event to every connected client so
|
|
181
|
+
## a subscribed MCP host can emit notifications/resources/updated. Mirrors the
|
|
182
|
+
## editor bridge_server; events carry no "id" (they are not responses), so the
|
|
183
|
+
## host routes them by the "event" field without colliding with request/response.
|
|
184
|
+
func broadcast_event(uri: String) -> void:
|
|
185
|
+
for c in _clients:
|
|
186
|
+
var peer: StreamPeerTCP = c["peer"]
|
|
187
|
+
if peer and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
188
|
+
_send(peer, {"event": "resource.changed", "uri": uri})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
## D3 follow-up: the live SceneTree gained/lost/renamed a node. Mark it dirty;
|
|
192
|
+
## _process coalesces to a single godot://runtime/tree push per frame regardless
|
|
193
|
+
## of how many nodes changed this frame.
|
|
194
|
+
func _on_tree_structure_changed(_node: Node) -> void:
|
|
195
|
+
_tree_dirty = true
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
## D6: zero-config console capture. Godot 4.5+ exposes a scriptable Logger
|
|
199
|
+
## (OS.add_logger); register one that funnels every print()/push_warning/
|
|
200
|
+
## push_error and engine message into the same ring buffer runtime.get_log reads,
|
|
201
|
+
## so the host gets the game's console with NO managed parent process. Compiled at
|
|
202
|
+
## runtime, so `extends Logger` is only ever parsed where the class exists.
|
|
203
|
+
func _install_log_capture() -> void:
|
|
204
|
+
if _log_capture != null:
|
|
205
|
+
return
|
|
206
|
+
if not ClassDB.class_exists("Logger") or not ClassDB.class_has_method("OS", "add_logger"):
|
|
207
|
+
return # < 4.5: no scriptable logger; runtime.get_log still serves push_log entries.
|
|
208
|
+
var src := GDScript.new()
|
|
209
|
+
src.source_code = _LOG_CAPTURE_SRC
|
|
210
|
+
if src.reload() != OK:
|
|
211
|
+
return # runtime script compilation unavailable — degrade quietly.
|
|
212
|
+
var inst = src.new()
|
|
213
|
+
inst.set("sink", Callable(self, "_on_captured_log"))
|
|
214
|
+
# Call dynamically (see _exit_tree): OS.add_logger() is 4.5+, and a literal call is resolved
|
|
215
|
+
# at PARSE time — a bare OS.add_logger(...) fails to compile the script on Godot 4.3/4.4,
|
|
216
|
+
# killing the whole runtime bridge. OS.call() defers to runtime, past the class_exists /
|
|
217
|
+
# class_has_method guard above.
|
|
218
|
+
OS.call("add_logger", inst)
|
|
219
|
+
_log_capture = inst
|
|
220
|
+
push_log("info", "log capture active (Godot %s)" % Engine.get_version_info().get("string", ""))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
## Sink for the runtime-compiled Logger. Writes to the ring buffer only (never
|
|
224
|
+
## prints/errors — that would recurse through the logger we registered); the
|
|
225
|
+
## _in_capture guard is belt-and-braces in case a downstream call ever emits.
|
|
226
|
+
func _on_captured_log(level: String, message: String) -> void:
|
|
227
|
+
if _in_capture:
|
|
228
|
+
return
|
|
229
|
+
_in_capture = true
|
|
230
|
+
push_log(level, message.strip_edges())
|
|
231
|
+
_in_capture = false
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ----------------------------------------------------------- dispatch --------
|
|
235
|
+
|
|
236
|
+
func _dispatch(method: String, params: Dictionary) -> Dictionary:
|
|
237
|
+
match method:
|
|
238
|
+
"ping":
|
|
239
|
+
return _ok({"pong": true, "runtime": true, "godot": Engine.get_version_info().get("string", ""), "log_capture": _log_capture != null})
|
|
240
|
+
"runtime.get_tree":
|
|
241
|
+
return _get_tree(params)
|
|
242
|
+
"runtime.get_property":
|
|
243
|
+
return _get_property(params)
|
|
244
|
+
"runtime.set_property":
|
|
245
|
+
return _set_property(params)
|
|
246
|
+
"runtime.call_method":
|
|
247
|
+
return _call_method(params)
|
|
248
|
+
"runtime.emit_signal":
|
|
249
|
+
return _emit_signal(params)
|
|
250
|
+
"runtime.inject_input":
|
|
251
|
+
return _inject_input(params)
|
|
252
|
+
"runtime.get_monitors":
|
|
253
|
+
return _get_monitors(params)
|
|
254
|
+
"runtime.screenshot":
|
|
255
|
+
return _screenshot()
|
|
256
|
+
"runtime.get_log":
|
|
257
|
+
return _get_log(params)
|
|
258
|
+
_:
|
|
259
|
+
return _err("unknown_method", "No such method: %s" % method)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
func _ok(result: Variant) -> Dictionary:
|
|
263
|
+
return {"ok": true, "result": result}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
func _err(code: String, message: String) -> Dictionary:
|
|
267
|
+
return {"ok": false, "error": {"code": code, "message": message}}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
func _base() -> Node:
|
|
271
|
+
return get_tree().current_scene
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
func _resolve(path: String) -> Node:
|
|
275
|
+
if path == "" or path == ".":
|
|
276
|
+
return _base()
|
|
277
|
+
if path.begins_with("/"):
|
|
278
|
+
return get_node_or_null(NodePath(path))
|
|
279
|
+
var b := _base()
|
|
280
|
+
if b == null:
|
|
281
|
+
return null
|
|
282
|
+
return b.get_node_or_null(NodePath(path))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
func _path_of(node: Node) -> String:
|
|
286
|
+
var b := _base()
|
|
287
|
+
if b and (node == b or b.is_ancestor_of(node)):
|
|
288
|
+
return "." if node == b else String(b.get_path_to(node))
|
|
289
|
+
return String(node.get_path())
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
func _serialize(node: Node, depth: int, max_depth: int) -> Dictionary:
|
|
293
|
+
var d := {
|
|
294
|
+
"name": String(node.name),
|
|
295
|
+
"type": node.get_class(),
|
|
296
|
+
"path": _path_of(node),
|
|
297
|
+
"child_count": node.get_child_count(),
|
|
298
|
+
}
|
|
299
|
+
if node is CanvasItem:
|
|
300
|
+
d["visible"] = (node as CanvasItem).visible
|
|
301
|
+
elif node is Node3D:
|
|
302
|
+
d["visible"] = (node as Node3D).visible
|
|
303
|
+
if depth < max_depth and node.get_child_count() > 0:
|
|
304
|
+
var kids: Array = []
|
|
305
|
+
for c in node.get_children():
|
|
306
|
+
kids.append(_serialize(c, depth + 1, max_depth))
|
|
307
|
+
d["children"] = kids
|
|
308
|
+
return d
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
func _get_tree(params: Dictionary) -> Dictionary:
|
|
312
|
+
var base := _base()
|
|
313
|
+
if base == null:
|
|
314
|
+
return _err("no_scene", "No current scene is running")
|
|
315
|
+
return _ok(_serialize(base, 0, int(params.get("max_depth", 64))))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
func _get_property(params: Dictionary) -> Dictionary:
|
|
319
|
+
var node := _resolve(String(params.get("path", "")))
|
|
320
|
+
if node == null:
|
|
321
|
+
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
322
|
+
var prop := String(params.get("property", ""))
|
|
323
|
+
return _ok({"path": _path_of(node), "property": prop, "value": Codec.encode(node.get(prop))})
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
func _set_property(params: Dictionary) -> Dictionary:
|
|
327
|
+
var node := _resolve(String(params.get("path", "")))
|
|
328
|
+
if node == null:
|
|
329
|
+
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
330
|
+
var prop := String(params.get("property", ""))
|
|
331
|
+
node.set(prop, Codec.decode(params.get("value")))
|
|
332
|
+
return _ok({"path": _path_of(node), "property": prop, "value": Codec.encode(node.get(prop))})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
func _call_method(params: Dictionary) -> Dictionary:
|
|
336
|
+
var node := _resolve(String(params.get("path", "")))
|
|
337
|
+
if node == null:
|
|
338
|
+
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
339
|
+
var method := String(params.get("method", ""))
|
|
340
|
+
if not node.has_method(method):
|
|
341
|
+
return _err("no_method", "%s has no method %s" % [node.get_class(), method])
|
|
342
|
+
var args: Array = []
|
|
343
|
+
for a in params.get("args", []):
|
|
344
|
+
args.append(Codec.decode(a))
|
|
345
|
+
var result: Variant = node.callv(method, args)
|
|
346
|
+
return _ok({"return": Codec.encode(result)})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
func _emit_signal(params: Dictionary) -> Dictionary:
|
|
350
|
+
var node := _resolve(String(params.get("path", "")))
|
|
351
|
+
if node == null:
|
|
352
|
+
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
353
|
+
var sig := String(params.get("signal", ""))
|
|
354
|
+
if not node.has_signal(sig):
|
|
355
|
+
return _err("no_signal", "%s has no signal %s" % [node.get_class(), sig])
|
|
356
|
+
var call_args: Array = [sig]
|
|
357
|
+
for a in params.get("args", []):
|
|
358
|
+
call_args.append(Codec.decode(a))
|
|
359
|
+
node.callv("emit_signal", call_args)
|
|
360
|
+
return _ok({"emitted": true})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
func _inject_input(params: Dictionary) -> Dictionary:
|
|
364
|
+
var ev: Dictionary = params.get("event", {})
|
|
365
|
+
var kind := String(ev.get("kind", ""))
|
|
366
|
+
match kind:
|
|
367
|
+
"action":
|
|
368
|
+
var action := String(ev.get("action", ""))
|
|
369
|
+
if bool(ev.get("pressed", true)):
|
|
370
|
+
Input.action_press(action, float(ev.get("strength", 1.0)))
|
|
371
|
+
else:
|
|
372
|
+
Input.action_release(action)
|
|
373
|
+
return _ok({"injected": true, "kind": kind})
|
|
374
|
+
"key":
|
|
375
|
+
var k := InputEventKey.new()
|
|
376
|
+
k.keycode = int(ev.get("keycode", 0))
|
|
377
|
+
k.pressed = bool(ev.get("pressed", true))
|
|
378
|
+
Input.parse_input_event(k)
|
|
379
|
+
return _ok({"injected": true, "kind": kind})
|
|
380
|
+
"mouse_button":
|
|
381
|
+
var mb := InputEventMouseButton.new()
|
|
382
|
+
mb.button_index = int(ev.get("button", 1))
|
|
383
|
+
mb.pressed = bool(ev.get("pressed", true))
|
|
384
|
+
var pos: Variant = Codec.decode(ev.get("position"))
|
|
385
|
+
if pos is Vector2:
|
|
386
|
+
mb.position = pos
|
|
387
|
+
Input.parse_input_event(mb)
|
|
388
|
+
return _ok({"injected": true, "kind": kind})
|
|
389
|
+
"mouse_motion":
|
|
390
|
+
var mm := InputEventMouseMotion.new()
|
|
391
|
+
var mpos: Variant = Codec.decode(ev.get("position"))
|
|
392
|
+
if mpos is Vector2:
|
|
393
|
+
mm.position = mpos
|
|
394
|
+
var rel: Variant = Codec.decode(ev.get("relative"))
|
|
395
|
+
if rel is Vector2:
|
|
396
|
+
mm.relative = rel
|
|
397
|
+
Input.parse_input_event(mm)
|
|
398
|
+
return _ok({"injected": true, "kind": kind})
|
|
399
|
+
_:
|
|
400
|
+
return _err("bad_kind", "Unknown input kind: %s" % kind)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
func _get_monitors(params: Dictionary) -> Dictionary:
|
|
404
|
+
var out := {}
|
|
405
|
+
var keys: Array = params.get("keys", [])
|
|
406
|
+
var wanted: Array = keys if keys.size() > 0 else MONITORS.keys()
|
|
407
|
+
for key in wanted:
|
|
408
|
+
var k := String(key)
|
|
409
|
+
if MONITORS.has(k):
|
|
410
|
+
out[k] = Performance.get_monitor(MONITORS[k])
|
|
411
|
+
return _ok({"monitors": out})
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
func _screenshot() -> Dictionary:
|
|
415
|
+
var vp := get_viewport()
|
|
416
|
+
if vp == null:
|
|
417
|
+
return _err("no_viewport", "No viewport")
|
|
418
|
+
var tex := vp.get_texture()
|
|
419
|
+
if tex == null:
|
|
420
|
+
return _err("no_texture", "No viewport texture")
|
|
421
|
+
var img := tex.get_image()
|
|
422
|
+
if img == null:
|
|
423
|
+
return _err("no_image", "Could not read frame")
|
|
424
|
+
var buf := img.save_png_to_buffer()
|
|
425
|
+
return _ok({
|
|
426
|
+
"mime": "image/png",
|
|
427
|
+
"base64": Marshalls.raw_to_base64(buf),
|
|
428
|
+
"width": img.get_width(),
|
|
429
|
+
"height": img.get_height(),
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
func _get_log(params: Dictionary) -> Dictionary:
|
|
434
|
+
var since := int(params.get("since_seq", 0))
|
|
435
|
+
var levels: Array = params.get("levels", [])
|
|
436
|
+
var entries: Array = []
|
|
437
|
+
for e in _log:
|
|
438
|
+
if int(e["seq"]) > since and (levels.is_empty() or levels.has(e["level"])):
|
|
439
|
+
entries.append(e)
|
|
440
|
+
return _ok({"entries": entries, "latest_seq": _log_seq, "capture": _log_capture != null})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends RefCounted
|
|
3
|
+
## Variant <-> JSON codec.
|
|
4
|
+
##
|
|
5
|
+
## JSON can only carry null/bool/number/string/array/object. Godot's rich value
|
|
6
|
+
## types (Vector*, Color, Rect2, NodePath, Quaternion, ...) are encoded as tagged
|
|
7
|
+
## objects: {"__type__": "Vector3", "x": .., "y": .., "z": ..}. `decode()` turns
|
|
8
|
+
## those tags back into real Variants so property set/get round-trips correctly.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
static func encode(v: Variant) -> Variant:
|
|
12
|
+
var t := typeof(v)
|
|
13
|
+
match t:
|
|
14
|
+
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT:
|
|
15
|
+
return v
|
|
16
|
+
TYPE_STRING:
|
|
17
|
+
return v
|
|
18
|
+
TYPE_STRING_NAME:
|
|
19
|
+
return String(v)
|
|
20
|
+
TYPE_NODE_PATH:
|
|
21
|
+
return {"__type__": "NodePath", "path": String(v)}
|
|
22
|
+
TYPE_VECTOR2:
|
|
23
|
+
return {"__type__": "Vector2", "x": v.x, "y": v.y}
|
|
24
|
+
TYPE_VECTOR2I:
|
|
25
|
+
return {"__type__": "Vector2i", "x": v.x, "y": v.y}
|
|
26
|
+
TYPE_VECTOR3:
|
|
27
|
+
return {"__type__": "Vector3", "x": v.x, "y": v.y, "z": v.z}
|
|
28
|
+
TYPE_VECTOR3I:
|
|
29
|
+
return {"__type__": "Vector3i", "x": v.x, "y": v.y, "z": v.z}
|
|
30
|
+
TYPE_VECTOR4:
|
|
31
|
+
return {"__type__": "Vector4", "x": v.x, "y": v.y, "z": v.z, "w": v.w}
|
|
32
|
+
TYPE_COLOR:
|
|
33
|
+
return {"__type__": "Color", "r": v.r, "g": v.g, "b": v.b, "a": v.a}
|
|
34
|
+
TYPE_RECT2:
|
|
35
|
+
return {
|
|
36
|
+
"__type__": "Rect2",
|
|
37
|
+
"x": v.position.x, "y": v.position.y,
|
|
38
|
+
"w": v.size.x, "h": v.size.y,
|
|
39
|
+
}
|
|
40
|
+
TYPE_QUATERNION:
|
|
41
|
+
return {"__type__": "Quaternion", "x": v.x, "y": v.y, "z": v.z, "w": v.w}
|
|
42
|
+
TYPE_DICTIONARY:
|
|
43
|
+
var out := {}
|
|
44
|
+
for k in v:
|
|
45
|
+
out[String(k)] = encode(v[k])
|
|
46
|
+
return out
|
|
47
|
+
TYPE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \
|
|
48
|
+
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, \
|
|
49
|
+
TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, \
|
|
50
|
+
TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY:
|
|
51
|
+
var arr := []
|
|
52
|
+
for item in v:
|
|
53
|
+
arr.append(encode(item))
|
|
54
|
+
return arr
|
|
55
|
+
TYPE_OBJECT:
|
|
56
|
+
if v == null:
|
|
57
|
+
return null
|
|
58
|
+
if v is Resource:
|
|
59
|
+
return {
|
|
60
|
+
"__type__": "Resource",
|
|
61
|
+
"class": v.get_class(),
|
|
62
|
+
"path": v.resource_path,
|
|
63
|
+
}
|
|
64
|
+
return {"__type__": "Object", "class": v.get_class()}
|
|
65
|
+
_:
|
|
66
|
+
# Fallback: best-effort string representation for unhandled types.
|
|
67
|
+
return {"__type__": "Unsupported", "repr": str(v), "type_id": t}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
static func decode(j: Variant) -> Variant:
|
|
71
|
+
if typeof(j) == TYPE_DICTIONARY:
|
|
72
|
+
if j.has("__type__"):
|
|
73
|
+
match String(j["__type__"]):
|
|
74
|
+
"NodePath":
|
|
75
|
+
return NodePath(String(j.get("path", "")))
|
|
76
|
+
"Vector2":
|
|
77
|
+
return Vector2(j.get("x", 0.0), j.get("y", 0.0))
|
|
78
|
+
"Vector2i":
|
|
79
|
+
return Vector2i(int(j.get("x", 0)), int(j.get("y", 0)))
|
|
80
|
+
"Vector3":
|
|
81
|
+
return Vector3(j.get("x", 0.0), j.get("y", 0.0), j.get("z", 0.0))
|
|
82
|
+
"Vector3i":
|
|
83
|
+
return Vector3i(int(j.get("x", 0)), int(j.get("y", 0)), int(j.get("z", 0)))
|
|
84
|
+
"Vector4":
|
|
85
|
+
return Vector4(j.get("x", 0.0), j.get("y", 0.0), j.get("z", 0.0), j.get("w", 0.0))
|
|
86
|
+
"Color":
|
|
87
|
+
return Color(j.get("r", 0.0), j.get("g", 0.0), j.get("b", 0.0), j.get("a", 1.0))
|
|
88
|
+
"Rect2":
|
|
89
|
+
return Rect2(j.get("x", 0.0), j.get("y", 0.0), j.get("w", 0.0), j.get("h", 0.0))
|
|
90
|
+
"Quaternion":
|
|
91
|
+
return Quaternion(j.get("x", 0.0), j.get("y", 0.0), j.get("z", 0.0), j.get("w", 1.0))
|
|
92
|
+
"Resource":
|
|
93
|
+
var path := String(j.get("path", ""))
|
|
94
|
+
if path != "" and ResourceLoader.exists(path):
|
|
95
|
+
return ResourceLoader.load(path)
|
|
96
|
+
return null
|
|
97
|
+
_:
|
|
98
|
+
return null
|
|
99
|
+
var out := {}
|
|
100
|
+
for k in j:
|
|
101
|
+
out[k] = decode(j[k])
|
|
102
|
+
return out
|
|
103
|
+
elif typeof(j) == TYPE_ARRAY:
|
|
104
|
+
var arr := []
|
|
105
|
+
for item in j:
|
|
106
|
+
arr.append(decode(item))
|
|
107
|
+
return arr
|
|
108
|
+
return j
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function parseArgs(argv, booleanFlags = []) {
|
|
2
|
+
const bool = new Set(booleanFlags);
|
|
3
|
+
const positionals = [];
|
|
4
|
+
const flags = {};
|
|
5
|
+
for (let i = 0; i < argv.length; i++) {
|
|
6
|
+
const a = argv[i];
|
|
7
|
+
if (a === "--") {
|
|
8
|
+
positionals.push(...argv.slice(i + 1));
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
if (a.startsWith("--")) {
|
|
12
|
+
const eq = a.indexOf("=");
|
|
13
|
+
if (eq !== -1) {
|
|
14
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const key = a.slice(2);
|
|
18
|
+
if (bool.has(key)) {
|
|
19
|
+
flags[key] = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const next = argv[i + 1];
|
|
23
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
24
|
+
flags[key] = next;
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
flags[key] = true;
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (a.startsWith("-") && a.length > 1) {
|
|
33
|
+
flags[a.slice(1)] = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
positionals.push(a);
|
|
37
|
+
}
|
|
38
|
+
return { positionals, flags };
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=args.js.map
|