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,283 @@
1
+ extends RefCounted
2
+
3
+ ## What the server asks about the running tree: its shape, one node, the nodes matching a
4
+ ## question, where one is on screen, a property set, a method called, the metrics.
5
+
6
+ const Values = preload("runtime_values.gd")
7
+
8
+ ## The most nodes one find answers with, unless asked for fewer: enough for any real query and
9
+ ## far short of the tree dump a query exists to avoid.
10
+ const FIND_LIMIT: int = 100
11
+ const FIND_LIMIT_CEILING: int = 1000
12
+
13
+ var _host: Node
14
+ var _values: Values
15
+
16
+
17
+ ## The host is the autoload, which is how the tree is reached: it is not in one when the
18
+ ## modules are built, and a fixture may put it in one later.
19
+ func _init(host: Node, values: Values) -> void:
20
+ _host = host
21
+ _values = values
22
+
23
+
24
+ func get_tree(params: Dictionary) -> Dictionary:
25
+ var root_path: String = str(params.get("root", "/root"))
26
+ var max_depth: int = int(params.get("depth", 3))
27
+ var include_properties: bool = bool(params.get("include_properties", false))
28
+
29
+ var root: Node = _host.get_tree().root.get_node_or_null(root_path)
30
+ if root == null:
31
+ return {"type": "error", "message": "Node not found: " + root_path}
32
+
33
+ return {"type": "tree", "root": _serialize_node_tree(root, 0, max_depth, include_properties)}
34
+
35
+
36
+ ## Nodes matching every filter given, as paths, so a caller can name what it wants without
37
+ ## reading the whole tree to find it. `class` matches native classes and their subclasses, and
38
+ ## the global name of a script class; `name` is a case-insensitive glob; `script` is a path.
39
+ func find_nodes(params: Dictionary) -> Dictionary:
40
+ var root_path: String = str(params.get("root", "/root"))
41
+ var wanted_class: String = str(params.get("class", ""))
42
+ var wanted_script: String = str(params.get("script", ""))
43
+ var wanted_name: String = str(params.get("name", ""))
44
+ var wanted_group: String = str(params.get("group", ""))
45
+ var limit: int = clampi(int(params.get("limit", FIND_LIMIT)), 1, FIND_LIMIT_CEILING)
46
+
47
+ if (
48
+ wanted_class.is_empty()
49
+ and wanted_script.is_empty()
50
+ and wanted_name.is_empty()
51
+ and wanted_group.is_empty()
52
+ ):
53
+ return {"type": "error", "message": "find_nodes needs at least one of class, script, name, group"}
54
+ if not wanted_script.is_empty() and not wanted_script.begins_with("res://"):
55
+ wanted_script = "res://" + wanted_script
56
+
57
+ var root: Node = _host.get_tree().root.get_node_or_null(root_path)
58
+ if root == null:
59
+ return {"type": "error", "message": "Node not found: " + root_path}
60
+
61
+ var found: Array[Dictionary] = []
62
+ var pending: Array[Node] = [root]
63
+ var truncated: bool = false
64
+ while not pending.is_empty():
65
+ var node: Node = pending.pop_front()
66
+ if _matches(node, wanted_class, wanted_script, wanted_name, wanted_group):
67
+ if found.size() >= limit:
68
+ truncated = true
69
+ break
70
+ found.append(_serialize_node(node, false))
71
+ var children: Array[Node] = node.get_children()
72
+ for index: int in range(children.size() - 1, -1, -1):
73
+ pending.push_front(children[index])
74
+
75
+ return {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
76
+
77
+
78
+ func _matches(
79
+ node: Node, wanted_class: String, wanted_script: String, wanted_name: String, wanted_group: String
80
+ ) -> bool:
81
+ if not wanted_group.is_empty() and not node.is_in_group(wanted_group):
82
+ return false
83
+ if not wanted_name.is_empty() and not str(node.name).matchn(wanted_name):
84
+ return false
85
+ var script: Variant = node.get_script()
86
+ if not wanted_script.is_empty():
87
+ if not script is Script:
88
+ return false
89
+ var attached: Script = script
90
+ if attached.resource_path != wanted_script:
91
+ return false
92
+ if not wanted_class.is_empty() and not node.is_class(wanted_class):
93
+ if not script is Script:
94
+ return false
95
+ var attached: Script = script
96
+ if attached.get_global_name() != wanted_class:
97
+ return false
98
+ return true
99
+
100
+
101
+ ## Where a node is on screen: a Control's rectangle, or a Node2D's position, in both the
102
+ ## canvas coordinates the node reports and the window pixels input arrives in. The two differ
103
+ ## whenever the project stretches its viewport, which is what made a rect unusable for a click.
104
+ func get_rect(params: Dictionary) -> Dictionary:
105
+ var node_path: String = str(params.get("path", ""))
106
+ if node_path.is_empty():
107
+ return {"type": "error", "message": "Node path required"}
108
+
109
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
110
+ if node == null:
111
+ return {"type": "error", "message": "Node not found: " + node_path}
112
+
113
+ if node is Control:
114
+ var control: Control = node
115
+ var to_window: Transform2D = control.get_viewport().get_final_transform()
116
+ var canvas_rect: Rect2 = control.get_global_rect()
117
+ return {
118
+ "type": "rect",
119
+ "path": node_path,
120
+ "visible": control.is_visible_in_tree(),
121
+ "canvas": _values.serialize(canvas_rect),
122
+ "window": _values.serialize(to_window * canvas_rect),
123
+ }
124
+ if node is Node2D:
125
+ var item: Node2D = node
126
+ var canvas_position: Vector2 = item.get_global_transform_with_canvas().origin
127
+ var window_position: Vector2 = item.get_viewport().get_final_transform() * canvas_position
128
+ return {
129
+ "type": "point",
130
+ "path": node_path,
131
+ "visible": item.is_visible_in_tree(),
132
+ "canvas": _values.serialize(canvas_position),
133
+ "window": _values.serialize(window_position),
134
+ }
135
+ return {
136
+ "type": "error", "message": "%s is a %s, which has no place on screen" % [node_path, node.get_class()]
137
+ }
138
+
139
+
140
+ func get_property(params: Dictionary) -> Dictionary:
141
+ var node_path: String = str(params.get("path", ""))
142
+ var property: String = str(params.get("property", ""))
143
+
144
+ if node_path.is_empty() or property.is_empty():
145
+ return {"type": "error", "message": "Node path and property required"}
146
+
147
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
148
+ if node == null:
149
+ return {"type": "error", "message": "Node not found: " + node_path}
150
+
151
+ # Asked of the property list rather than read and compared to null, because a property the
152
+ # node does not have and a property that is null both read as null.
153
+ var known: bool = false
154
+ for entry: Dictionary in node.get_property_list():
155
+ if str(entry["name"]) == property:
156
+ known = true
157
+ break
158
+ if not known:
159
+ return {"type": "error", "message": "%s has no property %s" % [node_path, property]}
160
+
161
+ return {
162
+ "type": "property",
163
+ "path": node_path,
164
+ "property": property,
165
+ "value": _values.serialize(node.get(property)),
166
+ }
167
+
168
+
169
+ func set_property(params: Dictionary) -> Dictionary:
170
+ var node_path: String = str(params.get("path", ""))
171
+ var property: String = str(params.get("property", ""))
172
+ var value: Variant = params.get("value")
173
+
174
+ if node_path.is_empty() or property.is_empty():
175
+ return {"type": "error", "message": "Node path and property required"}
176
+
177
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
178
+ if node == null:
179
+ return {"type": "error", "message": "Node not found: " + node_path}
180
+
181
+ var old_value: Variant = node.get(property)
182
+ node.set(property, _values.fitted(value, typeof(old_value)))
183
+
184
+ return {
185
+ "type": "property_set",
186
+ "path": node_path,
187
+ "property": property,
188
+ "old_value": _values.serialize(old_value),
189
+ "new_value": _values.serialize(node.get(property))
190
+ }
191
+
192
+
193
+ func call_method(params: Dictionary) -> Dictionary:
194
+ var node_path: String = str(params.get("path", ""))
195
+ var method: String = str(params.get("method", ""))
196
+ var args: Array = params.get("args", [])
197
+
198
+ if node_path.is_empty() or method.is_empty():
199
+ return {"type": "error", "message": "Node path and method required"}
200
+
201
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
202
+ if node == null:
203
+ return {"type": "error", "message": "Node not found: " + node_path}
204
+
205
+ if not node.has_method(method):
206
+ return {"type": "error", "message": "Method not found: " + method}
207
+
208
+ var deserialized_args: Array = []
209
+ for index: int in args.size():
210
+ deserialized_args.append(_values.fitted(args[index], _values.parameter_type(node, method, index)))
211
+
212
+ var result: Variant = node.callv(method, deserialized_args)
213
+
214
+ return {"type": "method_result", "path": node_path, "method": method, "result": _values.serialize(result)}
215
+
216
+
217
+ func get_metrics(params: Dictionary) -> Dictionary:
218
+ var metrics: Array = params.get("metrics", [])
219
+ var all: Dictionary = {
220
+ "fps": Engine.get_frames_per_second(),
221
+ "frame_time": Performance.get_monitor(Performance.TIME_PROCESS),
222
+ "physics_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS),
223
+ "memory_static": Performance.get_monitor(Performance.MEMORY_STATIC),
224
+ "memory_static_max": Performance.get_monitor(Performance.MEMORY_STATIC_MAX),
225
+ "object_count": Performance.get_monitor(Performance.OBJECT_COUNT),
226
+ "object_resource_count": Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT),
227
+ "object_node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
228
+ "object_orphan_node_count": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
229
+ "render_total_objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
230
+ "render_total_primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
231
+ "render_total_draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
232
+ }
233
+
234
+ if metrics.is_empty():
235
+ return {"type": "metrics", "data": all}
236
+
237
+ # A caller that names metrics gets those and no others, and hears about a name that is
238
+ # not one rather than getting everything back as if the list had not been sent.
239
+ var unknown: Array[String] = []
240
+ var selected: Dictionary = {}
241
+ for metric: Variant in metrics:
242
+ if all.has(metric):
243
+ selected[metric] = all[metric]
244
+ else:
245
+ unknown.append(str(metric))
246
+ if not unknown.is_empty():
247
+ return {
248
+ "type": "error",
249
+ "message": "Unknown metrics: %s. Available: %s" % [", ".join(unknown), ", ".join(all.keys())]
250
+ }
251
+ return {"type": "metrics", "data": selected}
252
+
253
+
254
+ func _serialize_node_tree(node: Node, depth: int, max_depth: int, include_properties: bool) -> Dictionary:
255
+ var result: Dictionary = _serialize_node(node, include_properties)
256
+
257
+ if depth < max_depth:
258
+ var children: Array = []
259
+ for child: Node in node.get_children():
260
+ children.append(_serialize_node_tree(child, depth + 1, max_depth, include_properties))
261
+ result["children"] = children
262
+
263
+ return result
264
+
265
+
266
+ func _serialize_node(node: Node, include_properties: bool) -> Dictionary:
267
+ var result: Dictionary = {"name": node.name, "type": node.get_class(), "path": str(node.get_path())}
268
+
269
+ var script: Variant = node.get_script()
270
+ if script is Script:
271
+ var attached: Script = script
272
+ result["script"] = attached.resource_path
273
+
274
+ if include_properties:
275
+ var properties: Dictionary = {}
276
+ for prop: Dictionary in node.get_property_list():
277
+ if prop["usage"] & PROPERTY_USAGE_STORAGE:
278
+ var property_name: String = prop["name"]
279
+ if not property_name.begins_with("_"):
280
+ properties[property_name] = _values.serialize(node.get(property_name))
281
+ result["properties"] = properties
282
+
283
+ return result
@@ -0,0 +1,169 @@
1
+ extends RefCounted
2
+
3
+ ## What crosses the wire between the game and the server, in both directions: Godot values
4
+ ## as JSON-safe dictionaries, and JSON back into the values a property or parameter wants.
5
+
6
+ # What each Godot type becomes on the wire, keyed on typeof() rather than written as a chain of
7
+ # `is` tests whose order has to be trusted: Resource had to be tested before Object, or every
8
+ # resource came back as a bare class name with its path dropped.
9
+ #
10
+ # Method names rather than Callables or lambdas: a lambda spanning more than one line inside a
11
+ # dictionary literal is where gdformat loses track of every comment in the file and writes them
12
+ # all again into the lambda body, on every run.
13
+ const SERIALISERS: Dictionary = {
14
+ TYPE_NIL: "_serialize_nil",
15
+ TYPE_VECTOR2: "_serialize_vector2",
16
+ TYPE_VECTOR3: "_serialize_vector3",
17
+ TYPE_VECTOR2I: "_serialize_vector2i",
18
+ TYPE_VECTOR3I: "_serialize_vector3i",
19
+ TYPE_COLOR: "_serialize_color",
20
+ TYPE_NODE_PATH: "_serialize_node_path",
21
+ TYPE_ARRAY: "_serialize_array",
22
+ TYPE_RECT2: "_serialize_rect2",
23
+ TYPE_TRANSFORM2D: "_serialize_transform2d",
24
+ TYPE_DICTIONARY: "_serialize_dictionary",
25
+ TYPE_OBJECT: "_serialize_object",
26
+ }
27
+
28
+
29
+ ## Converts a Godot value into something JSON can carry. A type with no entry in the table
30
+ ## passes through as itself, which is what the JSON-native ones want.
31
+ func serialize(value: Variant) -> Variant:
32
+ var serialiser: String = SERIALISERS.get(typeof(value), "")
33
+ return call(serialiser, value) if not serialiser.is_empty() else value
34
+
35
+
36
+ func _serialize_nil(_value: Variant) -> Variant:
37
+ return null
38
+
39
+
40
+ func _serialize_vector2(value: Vector2) -> Dictionary:
41
+ return {"_type": "Vector2", "x": value.x, "y": value.y}
42
+
43
+
44
+ func _serialize_vector3(value: Vector3) -> Dictionary:
45
+ return {"_type": "Vector3", "x": value.x, "y": value.y, "z": value.z}
46
+
47
+
48
+ func _serialize_vector2i(value: Vector2i) -> Dictionary:
49
+ return {"_type": "Vector2i", "x": value.x, "y": value.y}
50
+
51
+
52
+ func _serialize_vector3i(value: Vector3i) -> Dictionary:
53
+ return {"_type": "Vector3i", "x": value.x, "y": value.y, "z": value.z}
54
+
55
+
56
+ func _serialize_color(value: Color) -> Dictionary:
57
+ return {"_type": "Color", "r": value.r, "g": value.g, "b": value.b, "a": value.a}
58
+
59
+
60
+ func _serialize_node_path(value: NodePath) -> Dictionary:
61
+ return {"_type": "NodePath", "path": str(value)}
62
+
63
+
64
+ func _serialize_array(value: Array) -> Array:
65
+ return value.map(serialize)
66
+
67
+
68
+ func _serialize_rect2(value: Rect2) -> Dictionary:
69
+ return {"_type": "Rect2", "position": serialize(value.position), "size": serialize(value.size)}
70
+
71
+
72
+ func _serialize_transform2d(value: Transform2D) -> Dictionary:
73
+ return {
74
+ "_type": "Transform2D",
75
+ "origin": serialize(value.origin),
76
+ "x": serialize(value.x),
77
+ "y": serialize(value.y)
78
+ }
79
+
80
+
81
+ func _serialize_dictionary(value: Dictionary) -> Dictionary:
82
+ var serialised: Dictionary = {}
83
+ for key: Variant in value:
84
+ serialised[str(key)] = serialize(value[key])
85
+ return serialised
86
+
87
+
88
+ ## The one case that genuinely needs the class hierarchy, since a Resource is also an Object and
89
+ ## its path is the half worth having. A node in the tree is answered with its path, so a
90
+ ## property that points at one can be fed straight back to any tool that takes a node.
91
+ func _serialize_object(value: Object) -> Dictionary:
92
+ if value is Resource:
93
+ var resource: Resource = value
94
+ return {"_type": "Resource", "path": resource.resource_path, "class": resource.get_class()}
95
+ if value is Node:
96
+ var node: Node = value
97
+ if node.is_inside_tree():
98
+ return {"_type": "Node", "class": node.get_class(), "path": str(node.get_path())}
99
+ return {"_type": "Object", "class": value.get_class()}
100
+
101
+
102
+ ## Rebuilds a Godot value from the shape serialize gave it.
103
+ func deserialize(value: Variant) -> Variant:
104
+ if value == null:
105
+ return null
106
+ if value is Array:
107
+ var items: Array = value
108
+ var rebuilt: Array = []
109
+ for item: Variant in items:
110
+ rebuilt.append(deserialize(item))
111
+ return rebuilt
112
+ if not value is Dictionary:
113
+ return value
114
+
115
+ var fields: Dictionary = value
116
+ if not fields.has("_type"):
117
+ var rebuilt: Dictionary = {}
118
+ for key: Variant in fields:
119
+ rebuilt[key] = deserialize(fields[key])
120
+ return rebuilt
121
+
122
+ match fields["_type"]:
123
+ "Vector2":
124
+ return Vector2(fields.get("x", 0), fields.get("y", 0))
125
+ "Vector3":
126
+ return Vector3(fields.get("x", 0), fields.get("y", 0), fields.get("z", 0))
127
+ "Vector2i":
128
+ return Vector2i(fields.get("x", 0), fields.get("y", 0))
129
+ "Vector3i":
130
+ return Vector3i(fields.get("x", 0), fields.get("y", 0), fields.get("z", 0))
131
+ "Color":
132
+ return Color(fields.get("r", 0), fields.get("g", 0), fields.get("b", 0), fields.get("a", 1))
133
+ "NodePath":
134
+ return NodePath(fields.get("path", ""))
135
+ return value
136
+
137
+
138
+ ## The declared type of one parameter of a method, or TYPE_NIL when the method or the parameter
139
+ ## is not there, which reads as "no opinion".
140
+ func parameter_type(object: Object, method: String, index: int) -> int:
141
+ for entry: Dictionary in object.get_method_list():
142
+ if entry.get("name", "") != method:
143
+ continue
144
+ var params: Array = entry.get("args", [])
145
+ if index < 0 or index >= params.size():
146
+ return TYPE_NIL
147
+ var parameter: Dictionary = params[index]
148
+ return int(parameter.get("type", TYPE_NIL))
149
+ return TYPE_NIL
150
+
151
+
152
+ ## A value from the wire fitted to the type a property or parameter declares. Arguments arrive
153
+ ## as strings often enough, and callv refuses a "2.0" where a float is wanted, so a string that
154
+ ## reads as the wanted scalar is read as one.
155
+ func fitted(value: Variant, type: int) -> Variant:
156
+ var rebuilt: Variant = deserialize(value)
157
+ if type == TYPE_NIL or typeof(rebuilt) == type:
158
+ return rebuilt
159
+
160
+ var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
161
+ if not simple.has(type) or not simple.has(typeof(rebuilt)):
162
+ return rebuilt
163
+
164
+ if rebuilt is String and type != TYPE_STRING:
165
+ var parsed: Variant = JSON.parse_string(rebuilt)
166
+ if typeof(parsed) != TYPE_NIL and typeof(parsed) != TYPE_STRING:
167
+ rebuilt = parsed
168
+
169
+ return type_convert(rebuilt, type)
@@ -0,0 +1,119 @@
1
+ extends RefCounted
2
+
3
+ ## The commands that take time: they let the game run and answer when what was waited for has
4
+ ## happened, or when the time ran out, so the caller never sleeps for a guessed length.
5
+
6
+ const Values = preload("runtime_values.gd")
7
+
8
+ ## The longest one wait may last, whatever the request says: past this the server has long
9
+ ## since given up on the reply.
10
+ const CEILING_MSEC: int = 120000
11
+
12
+ var _host: Node
13
+ var _values: Values
14
+
15
+
16
+ func _init(host: Node, values: Values) -> void:
17
+ _host = host
18
+ _values = values
19
+
20
+
21
+ func wait_frames(params: Dictionary) -> Dictionary:
22
+ var frames: int = clampi(int(params.get("frames", 1)), 1, 600)
23
+ var started: int = Time.get_ticks_msec()
24
+ for _frame: int in frames:
25
+ await _host.get_tree().process_frame
26
+ return {"type": "waited", "frames": frames, "elapsed_ms": Time.get_ticks_msec() - started}
27
+
28
+
29
+ ## Waits for a signal to fire, and answers with what it carried, or that the time ran out.
30
+ func wait_signal(params: Dictionary) -> Dictionary:
31
+ var node_path: String = str(params.get("path", ""))
32
+ var signal_name: String = str(params.get("signal", ""))
33
+ var timeout_ms: int = clampi(int(params.get("timeout_ms", 5000)), 1, CEILING_MSEC)
34
+ if node_path.is_empty() or signal_name.is_empty():
35
+ return {"type": "error", "message": "Node path and signal name required"}
36
+
37
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
38
+ if node == null:
39
+ return {"type": "error", "message": "Node not found: " + node_path}
40
+ if not node.has_signal(signal_name):
41
+ return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
42
+
43
+ var catcher: SignalCatcher = SignalCatcher.new()
44
+ catcher.arity = _signal_arity(node, signal_name)
45
+ var callable: Callable = catcher._on_fired
46
+ node.connect(signal_name, callable, CONNECT_ONE_SHOT)
47
+ var started: int = Time.get_ticks_msec()
48
+ while not catcher.fired and Time.get_ticks_msec() - started < timeout_ms:
49
+ await _host.get_tree().process_frame
50
+ if not catcher.fired and is_instance_valid(node) and node.is_connected(signal_name, callable):
51
+ node.disconnect(signal_name, callable)
52
+
53
+ return {
54
+ "type": "signal",
55
+ "path": node_path,
56
+ "signal": signal_name,
57
+ "fired": catcher.fired,
58
+ "args": _values.serialize(catcher.args),
59
+ "elapsed_ms": Time.get_ticks_msec() - started,
60
+ }
61
+
62
+
63
+ ## Waits until a property reads as the value given, or the time runs out, and answers with
64
+ ## what it read last either way.
65
+ func wait_until(params: Dictionary) -> Dictionary:
66
+ var node_path: String = str(params.get("path", ""))
67
+ var property: String = str(params.get("property", ""))
68
+ var timeout_ms: int = clampi(int(params.get("timeout_ms", 5000)), 1, CEILING_MSEC)
69
+ if node_path.is_empty() or property.is_empty():
70
+ return {"type": "error", "message": "Node path and property required"}
71
+ if not params.has("value"):
72
+ return {"type": "error", "message": "A value to wait for is required"}
73
+
74
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
75
+ if node == null:
76
+ return {"type": "error", "message": "Node not found: " + node_path}
77
+
78
+ var current: Variant = node.get(property)
79
+ var wanted: Variant = _values.fitted(params["value"], typeof(current))
80
+ var started: int = Time.get_ticks_msec()
81
+ while current != wanted and Time.get_ticks_msec() - started < timeout_ms:
82
+ await _host.get_tree().process_frame
83
+ if not is_instance_valid(node):
84
+ return {"type": "error", "message": "%s was freed while waiting" % node_path}
85
+ current = node.get(property)
86
+
87
+ return {
88
+ "type": "condition",
89
+ "path": node_path,
90
+ "property": property,
91
+ "met": current == wanted,
92
+ "value": _values.serialize(current),
93
+ "elapsed_ms": Time.get_ticks_msec() - started,
94
+ }
95
+
96
+
97
+ func _signal_arity(node: Node, signal_name: String) -> int:
98
+ for entry: Dictionary in node.get_signal_list():
99
+ if entry.get("name", "") == signal_name:
100
+ var declared: Array = entry.get("args", [])
101
+ return declared.size()
102
+ return 0
103
+
104
+
105
+ ## Remembers that a signal fired and what it carried, for a wait that polls rather than
106
+ ## awaits the signal directly, so the wait can also give up. The handler accepts up to five
107
+ ## arguments, which covers every signal the engine declares, and records as many as the signal
108
+ ## has.
109
+ class SignalCatcher:
110
+ extends RefCounted
111
+ var fired: bool = false
112
+ var arity: int = 0
113
+ var args: Array = []
114
+
115
+ func _on_fired(
116
+ a: Variant = null, b: Variant = null, c: Variant = null, d: Variant = null, e: Variant = null
117
+ ) -> void:
118
+ fired = true
119
+ args = [a, b, c, d, e].slice(0, mini(arity, 5))
@@ -0,0 +1,125 @@
1
+ extends RefCounted
2
+
3
+ # The bus layout is a project resource the engine loads at startup and this process holds in
4
+ # memory, so every change is written back to that file or it is gone with the process.
5
+
6
+ const Log = preload("logger.gd")
7
+
8
+ var _log: Log
9
+
10
+
11
+ func _init(p_log: Log) -> void:
12
+ _log = p_log
13
+
14
+
15
+ func create_audio_bus(params: Dictionary) -> Dictionary:
16
+ var bus_name: String = str(params.get("bus_name", ""))
17
+ if bus_name.is_empty():
18
+ return _log.failure("bus_name is required")
19
+ var parent_idx: int = int(params.get("parent_bus_index", 0))
20
+ if parent_idx < 0 or parent_idx >= AudioServer.bus_count:
21
+ return _log.failure("No bus at index " + str(parent_idx))
22
+
23
+ AudioServer.add_bus(parent_idx + 1)
24
+ var new_idx: int = AudioServer.bus_count - 1
25
+ AudioServer.set_bus_name(new_idx, bus_name)
26
+ if parent_idx > 0:
27
+ AudioServer.set_bus_send(new_idx, AudioServer.get_bus_name(parent_idx))
28
+
29
+ var layout: String = _save_layout()
30
+ if layout.is_empty():
31
+ return {}
32
+ return {"bus": _bus(new_idx), "layout": layout}
33
+
34
+
35
+ func get_audio_buses(_params: Dictionary) -> Dictionary:
36
+ var buses: Array[Dictionary] = []
37
+ for i: int in range(AudioServer.bus_count):
38
+ buses.append(_bus(i))
39
+ return {"bus_count": AudioServer.bus_count, "buses": buses, "layout": _layout_path()}
40
+
41
+
42
+ func set_audio_bus_effect(params: Dictionary) -> Dictionary:
43
+ var bus_idx: int = int(params.get("bus_index", 0))
44
+ var effect_idx: int = int(params.get("effect_index", 0))
45
+ var effect_type: String = str(params.get("effect_type", ""))
46
+ var enabled: bool = bool(params.get("enabled", true))
47
+ if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
48
+ return _log.failure("No bus at index " + str(bus_idx))
49
+
50
+ var effect: AudioEffect = _effect_named(effect_type)
51
+ if effect == null:
52
+ return _log.failure("Unknown effect type: " + effect_type)
53
+
54
+ # The slot has to exist before an effect can be placed at that index.
55
+ while AudioServer.get_bus_effect_count(bus_idx) <= effect_idx:
56
+ AudioServer.add_bus_effect(bus_idx, AudioEffectAmplify.new())
57
+
58
+ AudioServer.add_bus_effect(bus_idx, effect, effect_idx)
59
+ AudioServer.set_bus_effect_enabled(bus_idx, effect_idx, enabled)
60
+
61
+ var layout: String = _save_layout()
62
+ if layout.is_empty():
63
+ return {}
64
+ return {"bus": _bus(bus_idx), "effect_index": effect_idx, "effect_type": effect_type, "layout": layout}
65
+
66
+
67
+ func set_audio_bus_volume(params: Dictionary) -> Dictionary:
68
+ var bus_idx: int = int(params.get("bus_index", 0))
69
+ var volume_db: float = float(params.get("volume_db", 0.0))
70
+ if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
71
+ return _log.failure("No bus at index " + str(bus_idx))
72
+
73
+ AudioServer.set_bus_volume_db(bus_idx, volume_db)
74
+
75
+ var layout: String = _save_layout()
76
+ if layout.is_empty():
77
+ return {}
78
+ return {"bus": _bus(bus_idx), "layout": layout}
79
+
80
+
81
+ func _bus(index: int) -> Dictionary:
82
+ var effects: Array[Dictionary] = []
83
+ for e: int in range(AudioServer.get_bus_effect_count(index)):
84
+ var effect: Dictionary = {
85
+ "index": e,
86
+ "type": AudioServer.get_bus_effect(index, e).get_class(),
87
+ "enabled": AudioServer.is_bus_effect_enabled(index, e),
88
+ }
89
+ effects.append(effect)
90
+ return {
91
+ "index": index,
92
+ "name": AudioServer.get_bus_name(index),
93
+ "volume_db": AudioServer.get_bus_volume_db(index),
94
+ "mute": AudioServer.is_bus_mute(index),
95
+ "solo": AudioServer.is_bus_solo(index),
96
+ "send": AudioServer.get_bus_send(index),
97
+ "effects": effects,
98
+ }
99
+
100
+
101
+ func _layout_path() -> String:
102
+ return str(ProjectSettings.get_setting("audio/buses/default_bus_layout", "res://default_bus_layout.tres"))
103
+
104
+
105
+ # The path the layout was written to, or empty with the reason on stderr.
106
+ func _save_layout() -> String:
107
+ var path: String = _layout_path()
108
+ var err: Error = ResourceSaver.save(AudioServer.generate_bus_layout(), path)
109
+ if err != OK:
110
+ _log.error("Failed to save the bus layout to " + path + ": " + error_string(err))
111
+ return ""
112
+ return path
113
+
114
+
115
+ # Any effect class the engine has, by its class name or the part after "AudioEffect".
116
+ func _effect_named(effect_type: String) -> AudioEffect:
117
+ var cls: String = effect_type if effect_type.begins_with("AudioEffect") else "AudioEffect" + effect_type
118
+ if not ClassDB.class_exists(cls) or not ClassDB.is_parent_class(cls, "AudioEffect"):
119
+ return null
120
+ if not ClassDB.can_instantiate(cls):
121
+ return null
122
+ var instance: Variant = ClassDB.instantiate(cls)
123
+ if instance is AudioEffect:
124
+ return instance
125
+ return null