gdharness 0.4.1

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.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +125 -0
  3. package/build/cli.js +30353 -0
  4. package/build/godot/addons/auto_reload/auto_reload.gd +89 -0
  5. package/build/godot/addons/auto_reload/plugin.cfg +6 -0
  6. package/build/godot/addons/gdharness_editor/bridge_client.gd +197 -0
  7. package/build/godot/addons/gdharness_editor/plugin.cfg +6 -0
  8. package/build/godot/addons/gdharness_editor/plugin.gd +88 -0
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +104 -0
  10. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +397 -0
  11. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +85 -0
  12. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +427 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +885 -0
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +292 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +77 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_input.gd +254 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +283 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_values.gd +169 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +119 -0
  20. package/build/godot/operations/audio_buses.gd +125 -0
  21. package/build/godot/operations/class_cache.gd +180 -0
  22. package/build/godot/operations/classdb_queries.gd +252 -0
  23. package/build/godot/operations/dependencies.gd +293 -0
  24. package/build/godot/operations/file_walk.gd +55 -0
  25. package/build/godot/operations/gdscript_analysis.gd +288 -0
  26. package/build/godot/operations/gdscript_authoring.gd +419 -0
  27. package/build/godot/operations/godot_operations.gd +186 -0
  28. package/build/godot/operations/import_pipeline.gd +390 -0
  29. package/build/godot/operations/input_actions.gd +237 -0
  30. package/build/godot/operations/logger.gd +33 -0
  31. package/build/godot/operations/plugins.gd +159 -0
  32. package/build/godot/operations/project_config.gd +153 -0
  33. package/build/godot/operations/project_diagnostics.gd +159 -0
  34. package/build/godot/operations/resource_files.gd +132 -0
  35. package/build/godot/operations/serialisation.gd +154 -0
  36. package/build/index.js +29252 -0
  37. package/package.json +57 -0
@@ -0,0 +1,292 @@
1
+ extends Node
2
+
3
+ ## The runtime autoload: a TCP server inside the running game that the gdharness server asks
4
+ ## about the scene tree, drives with input, and captures. This file is the server and the
5
+ ## command table; what each command does lives in a sibling module.
6
+ ##
7
+ ## The port is whatever the operating system hands out, and the game announces it by writing
8
+ ## one file named after its process id into a directory the server derives the same way. Two
9
+ ## games can therefore run at once, and a headless operation never takes the port a game
10
+ ## wanted. Both directions of the socket carry one JSON object per line. Every request names an
11
+ ## id and the reply carries it back.
12
+
13
+ const Capture = preload("runtime_capture.gd")
14
+ const InputCommands = preload("runtime_input.gd")
15
+ const Queries = preload("runtime_queries.gd")
16
+ const Values = preload("runtime_values.gd")
17
+ const Waits = preload("runtime_waits.gd")
18
+
19
+ const PROTOCOL: int = 2
20
+ const DEFAULT_BIND_ADDRESS: String = "127.0.0.1"
21
+ const BIND_ADDRESS_SETTING: String = "gdharness/runtime/bind_address"
22
+ ## 0 asks the operating system for a free port, which is the default and what the server
23
+ ## expects. A fixed port is for a client that cannot read the announcement.
24
+ const PORT_SETTING: String = "gdharness/runtime/port"
25
+
26
+ var values: Values = Values.new()
27
+
28
+ # The modules are members and not locals of _init, because a Callable holds its object by id
29
+ # rather than by reference, and a module referenced only by the Callables in the command table
30
+ # would be freed on the way out of _init, leaving every command "unknown".
31
+ var _queries: Queries = Queries.new(self, values)
32
+ var _input: InputCommands = InputCommands.new(self, values)
33
+ var _capture: Capture = Capture.new(self)
34
+ var _waits: Waits = Waits.new(self, values)
35
+
36
+ var _server: TCPServer
37
+ var _clients: Array[StreamPeerTCP] = []
38
+ ## Bytes received from each client that do not yet end in a newline, keyed by the peer.
39
+ var _pending: Dictionary = {}
40
+ var _port: int = 0
41
+ var _enabled: bool = true
42
+ var _announcement: String = ""
43
+ ## Every command, by the name a request uses, as the module method that answers it.
44
+ var _commands: Dictionary = {}
45
+
46
+
47
+ func _init() -> void:
48
+ _commands = {
49
+ "ping": _ping,
50
+ "get_tree": _queries.get_tree,
51
+ "find_nodes": _queries.find_nodes,
52
+ "get_rect": _queries.get_rect,
53
+ "get_property": _queries.get_property,
54
+ "set_property": _queries.set_property,
55
+ "call_method": _queries.call_method,
56
+ "get_metrics": _queries.get_metrics,
57
+ "capture_screenshot": _capture.capture_screenshot,
58
+ "capture_viewport": _capture.capture_viewport,
59
+ "inject_action": _input.inject_action,
60
+ "inject_key": _input.inject_key,
61
+ "inject_mouse_click": _input.inject_mouse_click,
62
+ "inject_mouse_motion": _input.inject_mouse_motion,
63
+ "click": _input.click,
64
+ "wait_frames": _waits.wait_frames,
65
+ "wait_signal": _waits.wait_signal,
66
+ "wait_until": _waits.wait_until,
67
+ }
68
+
69
+
70
+ func _ready() -> void:
71
+ name = "GdharnessRuntime"
72
+ # The TCP control loop runs in _process. With the default PROCESS_MODE_INHERIT it stops
73
+ # while the tree is paused, so the runtime silently goes unreachable and the game cannot
74
+ # even be un-paused over the socket. A debug server has to stay responsive while the game
75
+ # is frozen, to inspect, capture, inject or resume it.
76
+ process_mode = Node.PROCESS_MODE_ALWAYS
77
+ _start_server()
78
+
79
+
80
+ func _exit_tree() -> void:
81
+ _cleanup()
82
+
83
+
84
+ func _process(_delta: float) -> void:
85
+ if not _enabled or _server == null:
86
+ return
87
+
88
+ if _server.is_connection_available():
89
+ var client: StreamPeerTCP = _server.take_connection()
90
+ if client:
91
+ _clients.append(client)
92
+ _send_welcome(client)
93
+
94
+ var gone: Array[StreamPeerTCP] = []
95
+ for client: StreamPeerTCP in _clients:
96
+ client.poll()
97
+ if client.get_status() != StreamPeerTCP.STATUS_CONNECTED:
98
+ gone.append(client)
99
+ continue
100
+ var available: int = client.get_available_bytes()
101
+ if available > 0:
102
+ var received: Array = client.get_data(available)
103
+ var bytes: PackedByteArray = received[1]
104
+ _receive(client, bytes)
105
+
106
+ for client: StreamPeerTCP in gone:
107
+ _clients.erase(client)
108
+ _pending.erase(client)
109
+
110
+
111
+ ## Bytes arrive in whatever pieces the socket makes of them, so a request is only handled once
112
+ ## its newline has arrived, and two that arrive together are handled one after the other.
113
+ func _receive(client: StreamPeerTCP, bytes: PackedByteArray) -> void:
114
+ var buffered: PackedByteArray = _pending.get(client, PackedByteArray())
115
+ buffered.append_array(bytes)
116
+ var start: int = 0
117
+ var newline: int = buffered.find(10, start)
118
+ while newline != -1:
119
+ var line: String = buffered.slice(start, newline).get_string_from_utf8().strip_edges()
120
+ start = newline + 1
121
+ newline = buffered.find(10, start)
122
+ if not line.is_empty():
123
+ # Through a Callable and not awaited: a request that waits on the game must not stop
124
+ # the others being read, and the analyser would otherwise insist on the await.
125
+ _handle_message.call(client, line)
126
+ _pending[client] = buffered.slice(start)
127
+
128
+
129
+ func _start_server() -> void:
130
+ # The command set includes call_method, set_property and input injection, none of it
131
+ # authenticated, so a release export must not serve it.
132
+ if not OS.is_debug_build():
133
+ _enabled = false
134
+ return
135
+
136
+ _server = TCPServer.new()
137
+ # listen() defaults bind_address to "*", which exposes the game to the whole network.
138
+ var bind_address: String = str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
139
+ var wanted_port: int = int(ProjectSettings.get_setting(PORT_SETTING, 0))
140
+ var error: Error = _server.listen(wanted_port, bind_address)
141
+ if error != OK:
142
+ # A warning, not an error: callers treat any ERROR line on stderr as a failed
143
+ # operation, and a game without a runtime server is a handled condition.
144
+ push_warning(
145
+ "[gdharness] runtime port %d is unavailable (%s), running without a server" % [wanted_port, error]
146
+ )
147
+ _enabled = false
148
+ return
149
+
150
+ _port = _server.get_local_port()
151
+ _announce(bind_address)
152
+ print("[gdharness] runtime listening on %s:%d, announced at %s" % [bind_address, _port, _announcement])
153
+
154
+
155
+ ## Where the announcement goes. The server derives the same path with the same precedence, so
156
+ ## the two only meet if this stays in step with `runtimeDirectory` in src/runtime-client.ts.
157
+ func _announcement_directory() -> String:
158
+ var explicit: String = OS.get_environment("GDHARNESS_RUNTIME_DIR")
159
+ if not explicit.is_empty():
160
+ return explicit
161
+ var per_user: String = OS.get_environment("XDG_RUNTIME_DIR")
162
+ var base: String = per_user if not per_user.is_empty() else OS.get_temp_dir()
163
+ return base.path_join("gdharness")
164
+
165
+
166
+ func _announce(bind_address: String) -> void:
167
+ var directory: String = _announcement_directory()
168
+ var made: Error = DirAccess.make_dir_recursive_absolute(directory)
169
+ if made != OK and made != ERR_ALREADY_EXISTS:
170
+ push_warning(
171
+ "[gdharness] cannot create %s (%s); the server will not find this game" % [directory, made]
172
+ )
173
+ return
174
+ var path: String = directory.path_join("runtime-%d.json" % OS.get_process_id())
175
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
176
+ if file == null:
177
+ push_warning(
178
+ (
179
+ "[gdharness] cannot write %s (%s); the server will not find this game"
180
+ % [path, FileAccess.get_open_error()]
181
+ )
182
+ )
183
+ return
184
+ file.store_string(JSON.stringify(_identity(bind_address)))
185
+ file.close()
186
+ _announcement = path
187
+
188
+
189
+ ## What the announcement file and the welcome both carry: enough to pick this game out of
190
+ ## several and to know whether the server speaks its protocol.
191
+ func _identity(bind_address: String) -> Dictionary:
192
+ return {
193
+ "protocol": PROTOCOL,
194
+ "pid": OS.get_process_id(),
195
+ "port": _port,
196
+ "address": bind_address,
197
+ "project":
198
+ {
199
+ "name": str(ProjectSettings.get_setting("application/config/name", "")),
200
+ "path": ProjectSettings.globalize_path("res://").rstrip("/")
201
+ },
202
+ "godot": Engine.get_version_info().get("string", ""),
203
+ }
204
+
205
+
206
+ func _send_welcome(client: StreamPeerTCP) -> void:
207
+ var welcome: Dictionary = _identity(
208
+ str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
209
+ )
210
+ welcome["type"] = "welcome"
211
+ welcome["commands"] = _commands.keys()
212
+ _send_response(client, welcome)
213
+
214
+
215
+ func _handle_message(client: StreamPeerTCP, line: String) -> void:
216
+ var json: JSON = JSON.new()
217
+ if json.parse(line) != OK:
218
+ _send_error(client, null, "Invalid JSON: " + json.get_error_message())
219
+ return
220
+
221
+ var message: Variant = json.get_data()
222
+ if not message is Dictionary:
223
+ _send_error(client, null, "A request must be an object")
224
+ return
225
+
226
+ var fields: Dictionary = message
227
+ var request_id: Variant = fields.get("id", null)
228
+ if request_id == null:
229
+ _send_error(client, null, "A request must carry an id")
230
+ return
231
+
232
+ var command: String = str(fields.get("command", ""))
233
+ var params: Variant = fields.get("params", {})
234
+ if not params is Dictionary:
235
+ _send_error(client, request_id, "params must be an object")
236
+ return
237
+
238
+ # A command that waits on the game (a frame, a signal, a condition) suspends here and answers
239
+ # when it is done, while _process keeps serving the other clients in the meantime. A client
240
+ # that hung up while its command waited is simply not written to.
241
+ var result: Dictionary = await _execute_command(command, params)
242
+ result["id"] = request_id
243
+ if client.get_status() == StreamPeerTCP.STATUS_CONNECTED:
244
+ _send_response(client, result)
245
+
246
+
247
+ func _execute_command(command: String, params: Dictionary) -> Dictionary:
248
+ var handler: Callable = _commands.get(command, Callable())
249
+ if not handler.is_valid():
250
+ return {
251
+ "type": "error",
252
+ "message": "Unknown command: %s. Commands: %s" % [command, ", ".join(_commands.keys())]
253
+ }
254
+ var answer: Variant = await handler.call(params)
255
+ return answer
256
+
257
+
258
+ func _ping(_params: Dictionary) -> Dictionary:
259
+ return {"type": "pong", "timestamp": Time.get_unix_time_from_system()}
260
+
261
+
262
+ ## One JSON object and a newline. put_data rather than put_utf8_string, which would prefix the
263
+ ## bytes with a length the other side is not expecting.
264
+ func _send_response(client: StreamPeerTCP, data: Dictionary) -> void:
265
+ client.put_data((JSON.stringify(data) + "\n").to_utf8_buffer())
266
+
267
+
268
+ ## A request that could not be read far enough to find its id is answered with a null one.
269
+ func _send_error(client: StreamPeerTCP, request_id: Variant, message: String) -> void:
270
+ _send_response(client, {"type": "error", "message": message, "id": request_id})
271
+
272
+
273
+ func _notification(what: int) -> void:
274
+ if what == NOTIFICATION_WM_CLOSE_REQUEST:
275
+ _cleanup()
276
+
277
+
278
+ func _cleanup() -> void:
279
+ for client: StreamPeerTCP in _clients:
280
+ client.disconnect_from_host()
281
+ _clients.clear()
282
+ _pending.clear()
283
+
284
+ if _server:
285
+ _server.stop()
286
+ _server = null
287
+
288
+ # The announcement is what tells the server this game exists, so it goes before the
289
+ # process does. A crash leaves it behind, and the server drops one whose process is gone.
290
+ if not _announcement.is_empty():
291
+ DirAccess.remove_absolute(_announcement)
292
+ _announcement = ""
@@ -0,0 +1,77 @@
1
+ extends RefCounted
2
+
3
+ ## A picture of the running game, as a PNG written where the server asked for it.
4
+
5
+ var _host: Node
6
+
7
+
8
+ func _init(host: Node) -> void:
9
+ _host = host
10
+
11
+
12
+ func capture_screenshot(params: Dictionary) -> Dictionary:
13
+ return _capture(_host.get_tree().root, params)
14
+
15
+
16
+ func capture_viewport(params: Dictionary) -> Dictionary:
17
+ var viewport_path: String = String(params.get("viewportPath", ""))
18
+ if viewport_path.is_empty():
19
+ return capture_screenshot(params)
20
+
21
+ var node: Node = _host.get_tree().root.get_node_or_null(viewport_path)
22
+ if node == null:
23
+ return {"type": "error", "message": "Viewport not found: " + viewport_path}
24
+ if not node is Viewport:
25
+ return {"type": "error", "message": "Node is not a Viewport: " + viewport_path}
26
+ return _capture(node, params)
27
+
28
+
29
+ ## The server names the file, so a game cannot point it at a path of its own choosing; a call
30
+ ## with no path is a call the server did not make.
31
+ func _capture(viewport: Viewport, params: Dictionary) -> Dictionary:
32
+ var requested_path: String = String(params.get("output_path", ""))
33
+ if requested_path.is_empty():
34
+ return {"type": "error", "message": "output_path required"}
35
+
36
+ # Godot draws nothing to a minimised window and nothing at all without one, and the
37
+ # texture keeps whatever was drawn last. A capture then comes back byte for byte the same
38
+ # every time, with a success payload, and a game running perfectly well reads as a game
39
+ # that has frozen. A frame nobody drew is not evidence of anything, so it is refused.
40
+ if not _host.get_tree().root.can_draw():
41
+ return {
42
+ "type": "error",
43
+ "message":
44
+ (
45
+ "Nothing is being drawn to the game's window: it is minimised, or this engine "
46
+ + "has no window. The texture still holds the last frame that was drawn, so this "
47
+ + "and every capture after it would be that frame. Restore the window and ask again."
48
+ ),
49
+ }
50
+
51
+ var viewport_texture: ViewportTexture = viewport.get_texture()
52
+ if viewport_texture == null:
53
+ return {"type": "error", "message": "No viewport texture available"}
54
+
55
+ var image: Image = viewport_texture.get_image()
56
+ if image == null:
57
+ return {"type": "error", "message": "Failed to capture viewport image"}
58
+
59
+ var width: int = int(params.get("width", 0))
60
+ var height: int = int(params.get("height", 0))
61
+ if width > 0 and height > 0:
62
+ image.resize(width, height)
63
+
64
+ var screenshot_path: String = requested_path
65
+ if screenshot_path.begins_with("user://") or screenshot_path.begins_with("res://"):
66
+ screenshot_path = ProjectSettings.globalize_path(screenshot_path)
67
+ var save_error: Error = image.save_png(screenshot_path)
68
+ if save_error != OK:
69
+ return {"type": "error", "message": "Failed to save screenshot as PNG: " + str(save_error)}
70
+
71
+ return {
72
+ "type": "screenshot_file",
73
+ "format": "png",
74
+ "width": image.get_width(),
75
+ "height": image.get_height(),
76
+ "path": screenshot_path
77
+ }
@@ -0,0 +1,254 @@
1
+ extends RefCounted
2
+
3
+ ## Input handed to the running game as if a player had given it: actions, keys, the mouse, and
4
+ ## a whole click on a Control found by path.
5
+
6
+ const Values = preload("runtime_values.gd")
7
+
8
+ var _host: Node
9
+ var _values: Values
10
+
11
+
12
+ func _init(host: Node, values: Values) -> void:
13
+ _host = host
14
+ _values = values
15
+
16
+
17
+ func inject_action(params: Dictionary) -> Dictionary:
18
+ var action: String = String(params.get("action", ""))
19
+ var pressed: bool = bool(params.get("pressed", true))
20
+ var strength: float = float(params.get("strength", 1.0))
21
+
22
+ if action.is_empty():
23
+ return {"type": "error", "message": "Action name required"}
24
+
25
+ if not InputMap.has_action(action):
26
+ return {"type": "error", "message": "Action not found: " + action}
27
+
28
+ var event: InputEventAction = InputEventAction.new()
29
+ event.action = action
30
+ event.pressed = pressed
31
+ event.strength = strength
32
+ Input.parse_input_event(event)
33
+
34
+ return {"type": "input_injected", "input_type": "action", "action": action, "pressed": pressed}
35
+
36
+
37
+ func inject_key(params: Dictionary) -> Dictionary:
38
+ var keycode_raw: Variant = params.get("keycode", 0)
39
+ var pressed: bool = bool(params.get("pressed", true))
40
+ var key_label: String = String(params.get("key_label", ""))
41
+
42
+ if keycode_raw is String:
43
+ var named: String = keycode_raw
44
+ if not named.is_empty() and key_label.is_empty():
45
+ key_label = named
46
+ var keycode: int = 0 if keycode_raw is String else int(keycode_raw)
47
+
48
+ var event: InputEventKey = InputEventKey.new()
49
+ event.pressed = pressed
50
+
51
+ if not key_label.is_empty():
52
+ event.keycode = OS.find_keycode_from_string(key_label)
53
+ if event.keycode == KEY_NONE:
54
+ return {"type": "error", "message": "Invalid key_label: " + key_label}
55
+ elif keycode > 0:
56
+ event.keycode = keycode as Key
57
+ else:
58
+ return {"type": "error", "message": "keycode or key_label required"}
59
+
60
+ # A key event from a real keyboard carries all three, and InputMap consults whichever one
61
+ # the bound event declares: keycode first, then physical_keycode, then key_label. An
62
+ # injected event with only keycode set can therefore never match an action bound by
63
+ # physical key, which is how a rebinding UI normally stores one, so inject_key silently
64
+ # did nothing for those actions.
65
+ event.physical_keycode = event.keycode
66
+ event.key_label = event.keycode
67
+
68
+ event.shift_pressed = bool(params.get("shift", false))
69
+ event.ctrl_pressed = bool(params.get("ctrl", false))
70
+ event.alt_pressed = bool(params.get("alt", false))
71
+
72
+ Input.parse_input_event(event)
73
+
74
+ return {
75
+ "type": "input_injected",
76
+ "input_type": "key",
77
+ "keycode": event.keycode,
78
+ "physical_keycode": event.physical_keycode,
79
+ "shift": event.shift_pressed,
80
+ "ctrl": event.ctrl_pressed,
81
+ "alt": event.alt_pressed,
82
+ "pressed": pressed
83
+ }
84
+
85
+
86
+ ## A point the tool schema sends as two flat numbers, or the older form of one [x, y] value.
87
+ ## Answers a Vector2, or the String that says what was wrong with it.
88
+ func _read_point(params: Dictionary, x_key: String, y_key: String, pair_key: String) -> Variant:
89
+ if params.has(x_key) and params.has(y_key):
90
+ return Vector2(float(params[x_key]), float(params[y_key]))
91
+ var raw: Variant = params.get(pair_key, Vector2.ZERO)
92
+ if raw is Vector2:
93
+ return raw
94
+ if raw is Array:
95
+ var pair: Array = raw
96
+ if pair.size() < 2:
97
+ return "%s array must contain [x, y]" % pair_key
98
+ return Vector2(float(pair[0]), float(pair[1]))
99
+ return "%s must be Vector2 or [x, y]" % pair_key
100
+
101
+
102
+ func inject_mouse_click(params: Dictionary) -> Dictionary:
103
+ var point: Variant = _read_point(params, "x", "y", "position")
104
+ if point is String:
105
+ return {"type": "error", "message": point}
106
+ var position: Vector2 = point
107
+ var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
108
+ var pressed: bool = bool(params.get("pressed", true))
109
+ var double: bool = bool(params.get("doubleClick", false))
110
+
111
+ Input.parse_input_event(_button(position, button, pressed, double))
112
+
113
+ return {
114
+ "type": "input_injected",
115
+ "input_type": "mouse_click",
116
+ "position": [position.x, position.y],
117
+ "button": button,
118
+ "pressed": pressed,
119
+ "double": double
120
+ }
121
+
122
+
123
+ func inject_mouse_motion(params: Dictionary) -> Dictionary:
124
+ var point: Variant = _read_point(params, "x", "y", "position")
125
+ if point is String:
126
+ return {"type": "error", "message": point}
127
+ var position: Vector2 = point
128
+ var movement: Variant = _read_point(params, "relativeX", "relativeY", "relative")
129
+ if movement is String:
130
+ return {"type": "error", "message": movement}
131
+ var relative: Vector2 = movement
132
+
133
+ Input.parse_input_event(_motion(position, relative))
134
+
135
+ return {
136
+ "type": "input_injected",
137
+ "input_type": "mouse_motion",
138
+ "position": [position.x, position.y],
139
+ "relative": [relative.x, relative.y]
140
+ }
141
+
142
+
143
+ ## A whole click on a Control: the pointer moves onto it, the button goes down, a frame passes,
144
+ ## the button comes up. BaseButton fires on the release, which is why a single injected press
145
+ ## never pressed anything. The position is the control's centre carried into window pixels, so
146
+ ## the caller never has to do that arithmetic.
147
+ func click(params: Dictionary) -> Dictionary:
148
+ var node_path: String = str(params.get("path", ""))
149
+ if node_path.is_empty():
150
+ return {"type": "error", "message": "Node path required"}
151
+
152
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
153
+ if node == null:
154
+ return {"type": "error", "message": "Node not found: " + node_path}
155
+ if not node is Control:
156
+ return {"type": "error", "message": "%s is a %s, not a Control" % [node_path, node.get_class()]}
157
+ var control: Control = node
158
+ if not control.is_visible_in_tree():
159
+ return {"type": "error", "message": "%s is not visible, so nothing can click it" % node_path}
160
+
161
+ var viewport: Viewport = control.get_viewport()
162
+ var centre: Vector2 = control.get_global_transform_with_canvas() * (control.size * 0.5)
163
+ var position: Vector2 = viewport.get_final_transform() * centre
164
+ # The GUI only delivers to what is inside the viewport, so a centre outside it would be a
165
+ # click that silently reached nothing.
166
+ if not viewport.get_visible_rect().has_point(centre):
167
+ return {
168
+ "type": "error",
169
+ "message":
170
+ (
171
+ "%s has its centre at %s, outside the viewport %s, so nothing can click it"
172
+ % [node_path, centre, viewport.get_visible_rect()]
173
+ )
174
+ }
175
+ var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
176
+ var double: bool = bool(params.get("double", false))
177
+
178
+ # Pushed into the viewport rather than through Input: Input accumulates events and flushes
179
+ # them at the next frame, so the hovered control read below would be the one from before
180
+ # the pointer moved. The viewport delivers it to the GUI the same way a real one arrives.
181
+ viewport.push_input(_motion(position, Vector2.ZERO))
182
+ # What the engine itself thinks is under the pointer, which is the answer to "did it land",
183
+ # read before the press so the caller learns about a control on top rather than a click
184
+ # that went to it. Read in full here, path included, because nothing about that control
185
+ # is guaranteed to survive the release: a button that opens the next screen takes the
186
+ # whole menu out of the tree, and a node that has left the tree has no path to give.
187
+ var hovered: Control = viewport.gui_get_hovered_control()
188
+ var hovered_path: Variant = null
189
+ if hovered != null:
190
+ hovered_path = str(hovered.get_path())
191
+ var landed: bool = hovered == control or (hovered != null and control.is_ancestor_of(hovered))
192
+
193
+ viewport.push_input(_button(position, button, true, double))
194
+ await _host.get_tree().process_frame
195
+ viewport.push_input(_button(position, button, false, false))
196
+ # The release is what a button acts on, and a queue_free it causes lands at the end of
197
+ # this frame; the frame passes so the answer describes the control as the click left it.
198
+ await _host.get_tree().process_frame
199
+
200
+ # What became of the control: still in the tree, taken out of it, or freed. A button that
201
+ # opened another screen is the second or the third, and the caller wants to hear that
202
+ # rather than guess it from a tree that has changed shape. A freed reference cannot be
203
+ # handed to anything typed, so the question is asked here.
204
+ var afterwards: String = "freed"
205
+ if is_instance_valid(control):
206
+ afterwards = "in_tree" if control.is_inside_tree() else "removed"
207
+
208
+ return {
209
+ "type": "clicked",
210
+ "path": node_path,
211
+ "position": _values.serialize(position),
212
+ "button": button,
213
+ "double": double,
214
+ "hovered": hovered_path,
215
+ "landed": landed,
216
+ "control_afterwards": afterwards,
217
+ }
218
+
219
+
220
+ func _motion(position: Vector2, relative: Vector2) -> InputEventMouseMotion:
221
+ var event: InputEventMouseMotion = InputEventMouseMotion.new()
222
+ event.position = position
223
+ event.global_position = position
224
+ event.relative = relative
225
+ return event
226
+
227
+
228
+ func _button(position: Vector2, button: int, pressed: bool, double: bool) -> InputEventMouseButton:
229
+ var event: InputEventMouseButton = InputEventMouseButton.new()
230
+ event.position = position
231
+ event.global_position = position
232
+ event.button_index = button as MouseButton
233
+ event.pressed = pressed
234
+ event.double_click = double
235
+ return event
236
+
237
+
238
+ func _resolve_mouse_button(raw: Variant) -> int:
239
+ if raw is String:
240
+ var named: String = raw
241
+ match named.to_lower():
242
+ "left":
243
+ return MOUSE_BUTTON_LEFT
244
+ "right":
245
+ return MOUSE_BUTTON_RIGHT
246
+ "middle":
247
+ return MOUSE_BUTTON_MIDDLE
248
+ "wheel_up", "wheelup":
249
+ return MOUSE_BUTTON_WHEEL_UP
250
+ "wheel_down", "wheeldown":
251
+ return MOUSE_BUTTON_WHEEL_DOWN
252
+ _:
253
+ return MOUSE_BUTTON_LEFT
254
+ return int(raw)