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.
- package/LICENSE +22 -0
- package/README.md +125 -0
- package/build/cli.js +30353 -0
- package/build/godot/addons/auto_reload/auto_reload.gd +89 -0
- package/build/godot/addons/auto_reload/plugin.cfg +6 -0
- package/build/godot/addons/gdharness_editor/bridge_client.gd +197 -0
- package/build/godot/addons/gdharness_editor/plugin.cfg +6 -0
- package/build/godot/addons/gdharness_editor/plugin.gd +88 -0
- package/build/godot/addons/gdharness_editor/tool_executor.gd +104 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +397 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +85 -0
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +427 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +885 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +292 -0
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +77 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +254 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +283 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +169 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +119 -0
- package/build/godot/operations/audio_buses.gd +125 -0
- package/build/godot/operations/class_cache.gd +180 -0
- package/build/godot/operations/classdb_queries.gd +252 -0
- package/build/godot/operations/dependencies.gd +293 -0
- package/build/godot/operations/file_walk.gd +55 -0
- package/build/godot/operations/gdscript_analysis.gd +288 -0
- package/build/godot/operations/gdscript_authoring.gd +419 -0
- package/build/godot/operations/godot_operations.gd +186 -0
- package/build/godot/operations/import_pipeline.gd +390 -0
- package/build/godot/operations/input_actions.gd +237 -0
- package/build/godot/operations/logger.gd +33 -0
- package/build/godot/operations/plugins.gd +159 -0
- package/build/godot/operations/project_config.gd +153 -0
- package/build/godot/operations/project_diagnostics.gd +159 -0
- package/build/godot/operations/resource_files.gd +132 -0
- package/build/godot/operations/serialisation.gd +154 -0
- package/build/index.js +29252 -0
- 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)
|