gdharness 1.3.2 → 1.3.4

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 (46) hide show
  1. package/build/cli.js +163 -85
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +14 -11
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +157 -4
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +15 -3
  5. package/build/godot/addons/gdharness_editor/file_walk.gd +134 -0
  6. package/build/godot/addons/gdharness_editor/file_walk.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/headless_check.gd +44 -23
  8. package/build/godot/addons/gdharness_editor/property_paths.gd +225 -0
  9. package/build/godot/addons/gdharness_editor/property_paths.gd.uid +1 -0
  10. package/build/godot/addons/gdharness_editor/property_values.gd +249 -227
  11. package/build/godot/addons/gdharness_editor/reading.gd +15 -0
  12. package/build/godot/addons/gdharness_editor/same_values.gd +177 -0
  13. package/build/godot/addons/gdharness_editor/same_values.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_editor/scene_file.gd +100 -15
  15. package/build/godot/addons/gdharness_editor/serialisation.gd +75 -3
  16. package/build/godot/addons/gdharness_editor/tool_executor.gd +12 -3
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +402 -55
  18. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -2
  19. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +91 -0
  20. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +88 -5
  22. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +102 -337
  23. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +363 -0
  24. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd.uid +1 -0
  25. package/build/godot/addons/gdharness_runtime/reading.gd +15 -0
  26. package/build/godot/addons/gdharness_runtime/runtime_input.gd +10 -26
  27. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +3 -2
  28. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +2 -1
  29. package/build/godot/addons/gdharness_runtime/runtime_values.gd +30 -0
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +75 -3
  31. package/build/godot/operations/animation_reads.gd +14 -11
  32. package/build/godot/operations/declared_values.gd +157 -4
  33. package/build/godot/operations/dependencies.gd +14 -39
  34. package/build/godot/operations/editor_tools.gd +7 -3
  35. package/build/godot/operations/file_reads.gd +15 -3
  36. package/build/godot/operations/file_walk.gd +1 -0
  37. package/build/godot/operations/gdscript_analysis.gd +229 -75
  38. package/build/godot/operations/gdscript_authoring.gd +142 -34
  39. package/build/godot/operations/import_pipeline.gd +17 -6
  40. package/build/godot/operations/logger.gd +7 -0
  41. package/build/godot/operations/property_paths.gd +225 -0
  42. package/build/godot/operations/property_values.gd +249 -227
  43. package/build/godot/operations/reading.gd +15 -0
  44. package/build/godot/operations/same_values.gd +177 -0
  45. package/build/godot/operations/serialisation.gd +75 -3
  46. package/package.json +1 -1
@@ -0,0 +1,363 @@
1
+ @tool
2
+ extends Node
3
+
4
+ ## Signal connections in a scene file, made, removed and listed on a copy of the file as the other
5
+ ## scene tools edit one.
6
+
7
+ const GdhRead = preload("../reading.gd")
8
+ const GdhSceneFile = preload("../scene_file.gd")
9
+
10
+ ## What every emission meets when a connection cannot call its method.
11
+ const FAILS: String = ", so every emission fails to call it."
12
+
13
+
14
+ func connect_signal(args: Dictionary) -> Dictionary:
15
+ var project_path: String = str(args.get("projectPath", ""))
16
+ var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
17
+ var source_node_path: String = str(args.get("sourceNodePath", ""))
18
+ var signal_name: String = str(args.get("signalName", ""))
19
+ var target_node_path: String = str(args.get("targetNodePath", ""))
20
+ var method_name: String = str(args.get("methodName", ""))
21
+ # A connection without CONNECT_PERSIST is a runtime one, and PackedScene.pack drops those on
22
+ # the way out: without this the scene saves unchanged and this answers success over nothing.
23
+ var flags: int = GdhRead.as_int(args.get("flags", 0)) | Object.CONNECT_PERSIST
24
+
25
+ if (
26
+ source_node_path.is_empty()
27
+ or signal_name.is_empty()
28
+ or target_node_path.is_empty()
29
+ or method_name.is_empty()
30
+ ):
31
+ return {"ok": false, "error": "Missing required signal connection arguments"}
32
+
33
+ var scene: GdhSceneFile = GdhSceneFile.new()
34
+ var refused: Dictionary = scene.open(scene_path, true)
35
+ if not refused.is_empty():
36
+ return refused
37
+
38
+ var ends: Array = _connection_ends(scene, source_node_path, signal_name, target_node_path)
39
+ if ends[0] == null:
40
+ return scene.refuse(str(ends[1]))
41
+ var source: Node = ends[0]
42
+ var target: Node = ends[1]
43
+
44
+ # Reconnected rather than left alone when the flags differ, so the connection saved is the one
45
+ # asked for. Left alone, the answer gave the flags asked for and the file kept the old ones.
46
+ var callable: Callable = Callable(target, method_name)
47
+ var method_found: bool = target.has_method(method_name)
48
+ var misfit: String = _misfit(source, signal_name, callable, flags)
49
+ var flags_before: int = -1
50
+ for connection: Dictionary in source.get_signal_connection_list(signal_name):
51
+ if connection.get("callable") == callable:
52
+ flags_before = GdhRead.as_int(connection.get("flags", 0))
53
+ if source.is_connected(signal_name, callable):
54
+ source.disconnect(signal_name, callable)
55
+ var connect_result: Error = source.connect(signal_name, callable, flags)
56
+ if connect_result != OK:
57
+ return scene.refuse("Failed to connect signal: " + error_string(connect_result))
58
+
59
+ var from: String = scene.path_of(source)
60
+ var to: String = scene.path_of(target)
61
+ var saved_flags: Array = [-1]
62
+ var written: Dictionary = scene.write(
63
+ func(saved: Node) -> String:
64
+ saved_flags[0] = _connection_flags(saved, from, signal_name, to, method_name)
65
+ if saved_flags[0] < 0:
66
+ return "the connection would not be in the scene when it loads."
67
+ return ""
68
+ )
69
+ if not written.is_empty():
70
+ return written
71
+
72
+ var answer: Dictionary = {
73
+ "ok": true,
74
+ "sourceNodePath": from,
75
+ "signalName": signal_name,
76
+ "targetNodePath": to,
77
+ "methodName": method_name,
78
+ "flags": saved_flags[0],
79
+ "flagNames": GdhSceneFile.flag_names(GdhRead.as_int(saved_flags[0])),
80
+ # Connected either way, as the editor's own dialog allows, since the method may be written
81
+ # next; a caller told nothing learnt of a misspelt one only when the signal fired.
82
+ "methodFound": method_found
83
+ }
84
+ # Written all the same, for the reason a missing method is: the method may be changed next.
85
+ if method_found:
86
+ answer["argumentsFit"] = misfit.is_empty()
87
+ if not misfit.is_empty():
88
+ answer["misfit"] = misfit
89
+ # A second connect of the same pair read exactly like the first, so a caller could not tell a
90
+ # connection made from one that was already there, and a list showing one where two were made
91
+ # looked like a lost write.
92
+ if flags_before >= 0:
93
+ answer["alreadyConnected"] = true
94
+ if flags_before != GdhRead.as_int(saved_flags[0]):
95
+ answer["flagsBefore"] = GdhSceneFile.flag_names(flags_before)
96
+ return answer
97
+
98
+
99
+ ## The two ends of a connection in [param scene], as [source, target], or [null, why not].
100
+ func _connection_ends(
101
+ scene: GdhSceneFile, source_node_path: String, signal_name: String, target_node_path: String
102
+ ) -> Array:
103
+ var source: Node = GdhSceneFile.node_at(scene.root, source_node_path)
104
+ var target: Node = GdhSceneFile.node_at(scene.root, target_node_path)
105
+ if not source:
106
+ return [null, scene.not_found("Source node not found: " + source_node_path, source_node_path)]
107
+ if not target:
108
+ return [null, scene.not_found("Target node not found: " + target_node_path, target_node_path)]
109
+ # pack drops a connection with either end inside an instanced scene, and the answer said it was
110
+ # made or removed.
111
+ for end: Node in [source, target]:
112
+ var foreign: String = scene.foreign(end)
113
+ if not foreign.is_empty():
114
+ return [null, foreign]
115
+ if not source.has_signal(signal_name):
116
+ var said: String = "%s has no signal %s" % [source_node_path, signal_name]
117
+ var offered: String = GdhRead.offered(said, signal_name, GdhRead.signals_of(source))
118
+ return [null, GdhSceneFile.missing_on(source, offered)]
119
+ return [source, target]
120
+
121
+
122
+ ## The flags of the persistent connection from [param from]'s [param signal_name] to
123
+ ## [param method] on [param to] in the tree under [param root], or -1 when there is none.
124
+ func _connection_flags(root: Node, from: String, signal_name: String, to: String, method: String) -> int:
125
+ var source: Node = root.get_node_or_null(from)
126
+ var target: Node = root.get_node_or_null(to)
127
+ if source == null or target == null or not source.has_signal(signal_name):
128
+ return -1
129
+ for conn: Dictionary in source.get_signal_connection_list(signal_name):
130
+ var flags: int = GdhRead.as_int(conn.get("flags", 0))
131
+ var callable: Callable = conn.get("callable", Callable())
132
+ if (
133
+ flags & Object.CONNECT_PERSIST
134
+ and callable.get_object() == target
135
+ and str(callable.get_method()) == method
136
+ ):
137
+ return flags
138
+ return -1
139
+
140
+
141
+ ## Why what [param signal_name] of [param source] passes cannot reach [param callable], or "" when it
142
+ ## can or the method is not there to judge. Only what fails on every emission is named: too few or
143
+ ## too many arguments, an object passed where the method takes a value or the reverse, and an engine
144
+ ## class unrelated to the one the method takes. Anything that may convert or narrow when it runs is
145
+ ## left alone, since a warning about a connection that works is one nobody can act on.
146
+ static func _misfit(source: Node, signal_name: String, callable: Callable, flags: int) -> String:
147
+ var target: Object = callable.get_object()
148
+ var method: String = str(callable.get_method())
149
+ var taken: Dictionary = {}
150
+ for info: Dictionary in target.get_method_list() if target != null else []:
151
+ if str(info.get("name", "")) == method:
152
+ taken = info
153
+ break
154
+ if taken.is_empty() or GdhRead.as_int(taken.get("flags", 0)) & METHOD_FLAG_VARARG:
155
+ return ""
156
+ var passed: Array[Dictionary] = []
157
+ for info: Dictionary in source.get_signal_list():
158
+ if str(info.get("name", "")) == signal_name:
159
+ for argument: Dictionary in info.get("args", []):
160
+ passed.append(argument)
161
+ break
162
+ if flags & Object.CONNECT_APPEND_SOURCE_OBJECT:
163
+ passed.append({"name": "source", "type": TYPE_OBJECT, "class_name": source.get_class()})
164
+ passed = passed.slice(0, maxi(passed.size() - callable.get_unbound_arguments_count(), 0))
165
+ for bound: Variant in callable.get_bound_arguments():
166
+ var bound_class: String = ""
167
+ if bound is Object:
168
+ var object: Object = bound
169
+ bound_class = object.get_class()
170
+ passed.append({"name": "bound", "type": typeof(bound), "class_name": bound_class})
171
+
172
+ var parameters: Array = taken.get("args", [])
173
+ var defaults: Array = taken.get("default_args", [])
174
+ var needed: int = parameters.size() - defaults.size()
175
+ if passed.size() < needed:
176
+ return "%s passes %s, and %s needs %d%s" % [signal_name, _count(passed.size()), method, needed, FAILS]
177
+ if passed.size() > parameters.size():
178
+ var most: String = "none" if parameters.is_empty() else "at most %d" % parameters.size()
179
+ return "%s passes %s, and %s takes %s%s" % [signal_name, _count(passed.size()), method, most, FAILS]
180
+ for at: int in passed.size():
181
+ var given: Dictionary = passed[at]
182
+ var parameter: Dictionary = parameters[at]
183
+ if not _unrelated(given, parameter):
184
+ continue
185
+ return (
186
+ "%s passes argument %d as %s, and %s takes it as %s: %s%s"
187
+ % [
188
+ signal_name,
189
+ at + 1,
190
+ _type_named(given),
191
+ method,
192
+ str(parameter.get("name", "")),
193
+ _type_named(parameter),
194
+ FAILS
195
+ ]
196
+ )
197
+ return ""
198
+
199
+
200
+ ## Whether a value declared as [param given] can never be passed as [param parameter].
201
+ static func _unrelated(given: Dictionary, parameter: Dictionary) -> bool:
202
+ var given_type: int = GdhRead.as_int(given.get("type", TYPE_NIL))
203
+ var taken_type: int = GdhRead.as_int(parameter.get("type", TYPE_NIL))
204
+ if given_type == TYPE_NIL or taken_type == TYPE_NIL:
205
+ return false
206
+ if (given_type == TYPE_OBJECT) != (taken_type == TYPE_OBJECT):
207
+ return true
208
+ if given_type != TYPE_OBJECT:
209
+ return false
210
+ var given_class: String = str(given.get("class_name", ""))
211
+ var taken_class: String = str(parameter.get("class_name", ""))
212
+ # A class a script declares is not in ClassDB, and an engine class can still hold an instance of
213
+ # one, so only two engine classes are judged.
214
+ if not ClassDB.class_exists(given_class) or not ClassDB.class_exists(taken_class):
215
+ return false
216
+ return (
217
+ not ClassDB.is_parent_class(given_class, taken_class)
218
+ and not ClassDB.is_parent_class(taken_class, given_class)
219
+ )
220
+
221
+
222
+ static func _type_named(declared: Dictionary) -> String:
223
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
224
+ var named: String = str(declared.get("class_name", ""))
225
+ if type == TYPE_OBJECT and not named.is_empty():
226
+ return named
227
+ return type_string(type)
228
+
229
+
230
+ static func _count(arguments: int) -> String:
231
+ if arguments == 0:
232
+ return "no arguments"
233
+ return "1 argument" if arguments == 1 else "%d arguments" % arguments
234
+
235
+
236
+ func disconnect_signal(args: Dictionary) -> Dictionary:
237
+ var project_path: String = str(args.get("projectPath", ""))
238
+ var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
239
+ var source_node_path: String = str(args.get("sourceNodePath", ""))
240
+ var signal_name: String = str(args.get("signalName", ""))
241
+ var target_node_path: String = str(args.get("targetNodePath", ""))
242
+ var method_name: String = str(args.get("methodName", ""))
243
+
244
+ if (
245
+ source_node_path.is_empty()
246
+ or signal_name.is_empty()
247
+ or target_node_path.is_empty()
248
+ or method_name.is_empty()
249
+ ):
250
+ return {"ok": false, "error": "Missing required signal disconnection arguments"}
251
+
252
+ var scene: GdhSceneFile = GdhSceneFile.new()
253
+ var refused: Dictionary = scene.open(scene_path, true)
254
+ if not refused.is_empty():
255
+ return refused
256
+
257
+ var ends: Array = _connection_ends(scene, source_node_path, signal_name, target_node_path)
258
+ if ends[0] == null:
259
+ return scene.refuse(str(ends[1]))
260
+ var source: Node = ends[0]
261
+ var target: Node = ends[1]
262
+
263
+ var from: String = scene.path_of(source)
264
+ var to: String = scene.path_of(target)
265
+ # A connection that was never there saved the scene unchanged and answered as removed, so a
266
+ # typo in either name read as success.
267
+ if _connection_flags(scene.root, from, signal_name, to, method_name) < 0:
268
+ return scene.refuse(
269
+ "%s has no connection from %s.%s to %s.%s." % [scene_path, from, signal_name, to, method_name]
270
+ )
271
+ source.disconnect(signal_name, Callable(target, method_name))
272
+
273
+ var written: Dictionary = scene.write(
274
+ func(saved: Node) -> String:
275
+ if _connection_flags(saved, from, signal_name, to, method_name) >= 0:
276
+ return "the connection would still be in the scene when it loads."
277
+ return ""
278
+ )
279
+ if not written.is_empty():
280
+ return written
281
+
282
+ return {
283
+ "ok": true,
284
+ "removed": true,
285
+ "sourceNodePath": from,
286
+ "signalName": signal_name,
287
+ "targetNodePath": to,
288
+ "methodName": method_name
289
+ }
290
+
291
+
292
+ func list_connections(args: Dictionary) -> Dictionary:
293
+ var project_path: String = str(args.get("projectPath", ""))
294
+ var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
295
+ var filter_path: String = str(args.get("nodePath", ""))
296
+
297
+ var scene: GdhSceneFile = GdhSceneFile.new()
298
+ var refused: Dictionary = scene.open(scene_path, false)
299
+ if not refused.is_empty():
300
+ return refused
301
+
302
+ var root: Node = scene.root
303
+ var filtered: Node = null
304
+ if not filter_path.is_empty():
305
+ filtered = GdhSceneFile.node_at(root, filter_path)
306
+ if filtered == null:
307
+ return scene.refuse(scene.not_found("Node not found: " + filter_path, filter_path))
308
+ var nodes: Array = []
309
+ _collect_nodes_recursive(root, ".", nodes)
310
+
311
+ # Only persistent connections, which are the ones a scene file holds: a Container connects
312
+ # signals of each child to itself as the child is added, and those were answered as if the file
313
+ # held them. Matched on either end, since the filter is for connections involving the node.
314
+ var connections: Array = []
315
+ for entry: Dictionary in nodes:
316
+ var path: String = str(entry["path"])
317
+ var node: Node = entry["node"]
318
+ for signal_info: Dictionary in node.get_signal_list():
319
+ var signal_name: String = str(signal_info.get("name", ""))
320
+ if signal_name.is_empty():
321
+ continue
322
+ for conn: Dictionary in node.get_signal_connection_list(signal_name):
323
+ var flags: int = GdhRead.as_int(conn.get("flags", 0))
324
+ if not flags & Object.CONNECT_PERSIST:
325
+ continue
326
+ var callable: Callable = conn.get("callable", Callable())
327
+ var target_obj: Object = callable.get_object()
328
+ if filtered != null and node != filtered and target_obj != filtered:
329
+ continue
330
+ var target_path: String = ""
331
+ var editable: bool = scene.foreign(node).is_empty()
332
+ if target_obj is Node:
333
+ target_path = str(root.get_path_to(target_obj as Node))
334
+ editable = editable and scene.foreign(target_obj as Node).is_empty()
335
+ var listed: Dictionary = {
336
+ "sourceNodePath": path,
337
+ "signalName": signal_name,
338
+ "targetNodePath": target_path,
339
+ "methodName": str(callable.get_method()),
340
+ "flags": flags,
341
+ "flagNames": GdhSceneFile.flag_names(flags),
342
+ # False for a connection an instanced scene makes inside itself, which only that
343
+ # scene can change.
344
+ "editable": editable,
345
+ "methodFound": target_obj != null and target_obj.has_method(callable.get_method()),
346
+ }
347
+ var misfit: String = _misfit(node, signal_name, callable, flags)
348
+ if not misfit.is_empty():
349
+ listed["misfit"] = misfit
350
+ connections.append(listed)
351
+
352
+ scene.close()
353
+ var answer: Dictionary = {"ok": true, "connections": connections}
354
+ if Engine.is_editor_hint():
355
+ answer["unsavedInEditor"] = GdhSceneFile.unsaved(scene_path)
356
+ return answer
357
+
358
+
359
+ func _collect_nodes_recursive(node: Node, path: String, out_nodes: Array) -> void:
360
+ out_nodes.append({"path": path, "node": node})
361
+ for child: Node in node.get_children():
362
+ var child_path: String = str(child.name) if path == "." else path + "/" + str(child.name)
363
+ _collect_nodes_recursive(child, child_path, out_nodes)
@@ -0,0 +1 @@
1
+ uid://8ktrjlekhgex
@@ -189,6 +189,21 @@ static func offered(said: String, word: String, names: Array) -> String:
189
189
  return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
190
190
 
191
191
 
192
+ ## [param said], the refusal for [param property], which [param target] does not declare, with the
193
+ ## names it declares nearest it when any are, as a misspelt one is usually one of them. Unlike
194
+ ## [method offered], nothing is listed when none is near, since an object declares a hundred.
195
+ static func near_property(said: String, target: Object, property: String) -> String:
196
+ var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
197
+ var names: Array[String] = []
198
+ for declared: Dictionary in target.get_property_list():
199
+ if not as_int(declared.get("usage", 0)) & headings:
200
+ names.append(str(declared.get("name", "")))
201
+ var close: PackedStringArray = nearest(property, PackedStringArray(names))
202
+ if close.is_empty():
203
+ return said
204
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
205
+
206
+
192
207
  ## The signals [param object] has that a caller can connect, its own class's first: the engine
193
208
  ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
194
209
  ## starting with an underscore are the engine's own.
@@ -17,9 +17,6 @@ const GdhScreen = preload("runtime_screen.gd")
17
17
  const GdhTargets = preload("runtime_targets.gd")
18
18
  const GdhMouse = preload("runtime_mouse.gd")
19
19
 
20
- ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
21
- const TO_SMALL: int = 32
22
-
23
20
  ## Set, to the desktop's name, for a process gdharness started on a desktop of its own and for
24
21
  ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
25
22
  const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
@@ -161,10 +158,11 @@ func inject_action(params: Dictionary) -> Dictionary:
161
158
  var frames: int = holding
162
159
 
163
160
  _say_action(action, whole or held, strength)
161
+ var lasted: Variant = null
164
162
  if whole:
165
163
  # A frame between the halves at least, as a click has: a listener that acts on the press and
166
164
  # a listener that acts on the release both get their own frame to do it in.
167
- await _held(frames)
165
+ lasted = await GdhValues.hold(_host.get_tree(), frames)
168
166
  _say_action(action, false, strength)
169
167
  await _host.get_tree().process_frame
170
168
 
@@ -175,15 +173,10 @@ func inject_action(params: Dictionary) -> Dictionary:
175
173
  "pressed": held and not whole,
176
174
  "whole": whole,
177
175
  "held_frames": GdhValues.held_answer(whole, frames),
176
+ "held_for": lasted,
178
177
  }
179
178
 
180
179
 
181
- ## Waits out [param frames] process frames, which is how long a whole press is held down.
182
- func _held(frames: int) -> void:
183
- for _frame: int in frames:
184
- await _host.get_tree().process_frame
185
-
186
-
187
180
  func _say_action(action: String, down: bool, strength: float) -> void:
188
181
  var event: InputEventAction = InputEventAction.new()
189
182
  event.action = action
@@ -237,11 +230,12 @@ func inject_key(params: Dictionary) -> Dictionary:
237
230
  # TextEdit insert `unicode` and never consult the keycode, so an injected key could press any
238
231
  # action in the map and still type nothing into a search box. Godot's keycodes for printable
239
232
  # keys are the code points themselves, which is what makes this a mapping and not a table.
240
- event.unicode = _glyph_of(event.keycode, event.shift_pressed)
233
+ event.unicode = GdhValues.glyph_of(event.keycode, event.shift_pressed)
241
234
 
242
235
  Input.parse_input_event(event)
236
+ var lasted: Variant = null
243
237
  if whole:
244
- await _held(frames)
238
+ lasted = await GdhValues.hold(_host.get_tree(), frames)
245
239
  var up: InputEventKey = event.duplicate()
246
240
  up.pressed = false
247
241
  Input.parse_input_event(up)
@@ -251,6 +245,7 @@ func inject_key(params: Dictionary) -> Dictionary:
251
245
  "type": "input_injected",
252
246
  "input_type": "key",
253
247
  "held_frames": GdhValues.held_answer(whole, frames),
248
+ "held_for": lasted,
254
249
  "keycode": event.keycode,
255
250
  "physical_keycode": event.physical_keycode,
256
251
  "shift": event.shift_pressed,
@@ -262,19 +257,6 @@ func inject_key(params: Dictionary) -> Dictionary:
262
257
  }
263
258
 
264
259
 
265
- ## The character a key produces, or nothing for a key that produces none.
266
- ##
267
- ## Godot's keycodes below [constant KEY_SPECIAL] are the Unicode code points of the keys that
268
- ## print something, so the mapping is the value itself. Letters are held as their capitals, which
269
- ## is the one place the shift a caller asked for changes the answer rather than the key.
270
- func _glyph_of(keycode: Key, shifted: bool) -> int:
271
- if keycode >= KEY_SPECIAL:
272
- return 0
273
- if keycode >= KEY_A and keycode <= KEY_Z and not shifted:
274
- return keycode + TO_SMALL
275
- return keycode
276
-
277
-
278
260
  ## Clicks a mouse button at a position, and lets go of it again unless asked to hold it.
279
261
  ##
280
262
  ## The whole click by default, for the reason [method inject_action] is the whole press, and
@@ -314,12 +296,13 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
314
296
  var _moved: Vector2 = _arrive(position)
315
297
  var supplied: bool = _follow_in_the_window(position)
316
298
  Input.parse_input_event(_button(position, button, whole or held, double))
299
+ var lasted: Variant = null
317
300
  if notch:
318
301
  Input.parse_input_event(_button(position, button, false, false))
319
302
  elif whole:
320
303
  # A frame between the halves at least, as inject_action has, so a control that acts on the
321
304
  # press and one that acts on the release each get a frame to do it in.
322
- await _held(frames)
305
+ lasted = await GdhValues.hold(_host.get_tree(), frames)
323
306
  Input.parse_input_event(_button(position, button, false, false))
324
307
  await _host.get_tree().process_frame
325
308
 
@@ -329,6 +312,7 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
329
312
  "type": "input_injected",
330
313
  "input_type": "mouse_click",
331
314
  "held_frames": GdhValues.held_answer(whole and not notch, frames),
315
+ "held_for": lasted,
332
316
  "position": [position.x, position.y],
333
317
  "button": button,
334
318
  "pressed": held and not whole,
@@ -69,11 +69,11 @@ func inject_joypad_button(params: Dictionary) -> Dictionary:
69
69
  var frames: int = holding
70
70
 
71
71
  Input.parse_input_event(_button(button, device, whole or held))
72
+ var lasted: Variant = null
72
73
  if whole:
73
74
  # A frame between the halves at least, as a key has, so a game that acts on the press and one
74
75
  # that acts on the release each get a frame to do it in.
75
- for _frame: int in frames:
76
- await _host.get_tree().process_frame
76
+ lasted = await GdhValues.hold(_host.get_tree(), frames)
77
77
  Input.parse_input_event(_button(button, device, false))
78
78
  await _host.get_tree().process_frame
79
79
 
@@ -81,6 +81,7 @@ func inject_joypad_button(params: Dictionary) -> Dictionary:
81
81
  "type": "input_injected",
82
82
  "input_type": "joypad_button",
83
83
  "held_frames": GdhValues.held_answer(whole, frames),
84
+ "held_for": lasted,
84
85
  "button": button,
85
86
  "button_name": _name_of(button, JOY_BUTTONS),
86
87
  "device": device,
@@ -4,6 +4,7 @@ extends RefCounted
4
4
  ## run off the game the node keeps. Every op that reads, writes, calls or waits on a game's state
5
5
  ## walks the same way, so the walk and what it says when a step is not there live in one place.
6
6
 
7
+ const GdhRead = preload("reading.gd")
7
8
  const GdhSerialisation = preload("serialisation.gd")
8
9
  const GdhValues = preload("runtime_values.gd")
9
10
 
@@ -640,7 +641,7 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
640
641
  "%s has no property %s; it has a method of that name, which a path calls as %s()"
641
642
  % [called_as, named, named]
642
643
  )
643
- return "%s has no property %s" % [called_as, named]
644
+ return GdhRead.near_property("%s has no property %s" % [called_as, named], object, named)
644
645
 
645
646
 
646
647
  ## Why [param method] is not a call a path makes on [param sort] ("a list", "a packed list" or "a
@@ -126,6 +126,36 @@ static func held_answer(whole: bool, frames: int) -> Variant:
126
126
  return null
127
127
 
128
128
 
129
+ ## Waits out [param frames] process frames of [param tree], how long a whole press is held down,
130
+ ## and answers how long that was by the clock and in physics ticks. A process frame is no set
131
+ ## time: a headless game draws nothing and runs them as fast as it can, so thirty held a movement
132
+ ## read in _physics_process for twelve ticks, a fifth of a second, where a caller counting sixty
133
+ ## frames a second expected half of one.
134
+ static func hold(tree: SceneTree, frames: int) -> Dictionary:
135
+ var began_usec: int = Time.get_ticks_usec()
136
+ var began_tick: int = Engine.get_physics_frames()
137
+ for _frame: int in frames:
138
+ await tree.process_frame
139
+ return {
140
+ "seconds": snappedf((Time.get_ticks_usec() - began_usec) / 1000000.0, 0.001),
141
+ "physics_frames": Engine.get_physics_frames() - began_tick,
142
+ }
143
+
144
+
145
+ ## The character a key produces, or nothing for a key that produces none.
146
+ ##
147
+ ## Godot's keycodes below [constant KEY_SPECIAL] are the Unicode code points of the keys that
148
+ ## print something, so the mapping is the value itself. Letters are held as their capitals, which
149
+ ## is the one place the shift a caller asked for changes the answer rather than the key: the small
150
+ ## letter is 32 above its capital.
151
+ static func glyph_of(keycode: Key, shifted: bool) -> int:
152
+ if keycode >= KEY_SPECIAL:
153
+ return 0
154
+ if keycode >= KEY_A and keycode <= KEY_Z and not shifted:
155
+ return keycode + 32
156
+ return keycode
157
+
158
+
129
159
  ## What [param path] names: a node under [param root], as [method node_at] reads it, or an engine
130
160
  ## singleton by its name. Answers the object under "object", and under "node" the node that objects
131
161
  ## named in a value are found from, which for a singleton is [param root].
@@ -121,6 +121,10 @@ const BUILDERS: Dictionary = {
121
121
  "Dictionary": "_build_dictionary",
122
122
  }
123
123
 
124
+ ## The built-in resources being described, outermost first, so one holding itself somewhere inside
125
+ ## is answered by its class rather than without end.
126
+ var _describing: Array[Resource] = []
127
+
124
128
 
125
129
  # Converts a Godot value into something JSON can carry. A type with no entry in the table
126
130
  # passes through as itself, which is what the JSON-native ones want.
@@ -665,9 +669,21 @@ func _serialize_object(value: Object) -> Variant:
665
669
  return _describe_input_event(event)
666
670
  if value is Resource:
667
671
  var resource: Resource = value
668
- if resource.resource_path.is_empty():
669
- return {"class": resource.get_class(), "_type": "Resource"}
670
- return {"path": resource.resource_path, "class": resource.get_class(), "_type": "Resource"}
672
+ # A file's built-in resources load with a path of the file's own and "::", which no other
673
+ # file can refer to.
674
+ if resource.resource_path.is_empty() or resource.resource_path.contains("::"):
675
+ return _describe_built_in(resource)
676
+ var filed: Dictionary = {
677
+ "path": resource.resource_path, "class": resource.get_class(), "_type": "Resource"
678
+ }
679
+ # The engine class of a resource with a script is the one its script extends, so a Stats
680
+ # file read as a Resource and nothing said it was a Stats.
681
+ var attached: Variant = resource.get_script()
682
+ if attached is Script:
683
+ var script: Script = attached
684
+ if not script.get_global_name().is_empty():
685
+ filed["scriptClass"] = str(script.get_global_name())
686
+ return filed
671
687
  if value is Node:
672
688
  var node: Node = value
673
689
  if node.is_inside_tree():
@@ -675,6 +691,62 @@ func _serialize_object(value: Object) -> Variant:
675
691
  return _describe_object(value)
676
692
 
677
693
 
694
+ ## A resource built into the file it was read from, in the form a write builds one from: its class
695
+ ## under _type beside every property it stores away from its default. As its class alone it said
696
+ ## nothing of what it held, so a CollisionShape2D's radius could not be read, and it could not be
697
+ ## written back.
698
+ func _describe_built_in(resource: Resource) -> Dictionary:
699
+ if _describing.has(resource):
700
+ return {"class": resource.get_class(), "_type": "Resource"}
701
+ _describing.append(resource)
702
+ var described: Dictionary = {"_type": resource.get_class()}
703
+ var attached: Variant = resource.get_script()
704
+ var script: Script = null
705
+ if attached is Script:
706
+ script = attached
707
+ described["script"] = serialize_value(script)
708
+ var fresh: Object = _fresh(script)
709
+ for property: Dictionary in resource.get_property_list():
710
+ var usage: int = property.get("usage", 0)
711
+ var name: String = str(property.get("name", ""))
712
+ if (
713
+ not (usage & PROPERTY_USAGE_STORAGE)
714
+ or name == "script"
715
+ or (name.begins_with("resource_") and name != "resource_name")
716
+ ):
717
+ continue
718
+ var held: Variant = resource.get(name)
719
+ var default: Variant = ClassDB.class_get_property_default_value(resource.get_class(), name)
720
+ if fresh != null:
721
+ default = fresh.get(name)
722
+ elif script != null and usage & PROPERTY_USAGE_SCRIPT_VARIABLE:
723
+ default = script.get_property_default_value(name)
724
+ if typeof(held) == typeof(default) and held == default:
725
+ continue
726
+ described[name] = serialize_value(held)
727
+ _describing.pop_back()
728
+ return described
729
+
730
+
731
+ ## A new instance of [param script] to read defaults off, or null where none should be made. What
732
+ ## the script's own code sets as it is made (a resource_name in _init, a list built by a
733
+ ## constructor, which the engine reports no default for) is then a default rather than a value
734
+ ## the file holds. None is made for a script whose _init needs arguments, nor in the editor for one
735
+ ## that is not a tool: the editor never runs those, and can_instantiate says so.
736
+ static func _fresh(script: Script) -> Object:
737
+ if script == null or not script.can_instantiate():
738
+ return null
739
+ for method: Dictionary in script.get_script_method_list():
740
+ if str(method.get("name", "")) == "_init":
741
+ var arguments: Array = method.get("args", [])
742
+ var defaults: Array = method.get("default_args", [])
743
+ if arguments.size() > defaults.size():
744
+ return null
745
+ # Called by name: new() is declared on each kind of script rather than on Script.
746
+ var made: Variant = script.call("new")
747
+ return made if made is Object else null
748
+
749
+
678
750
  ## An input event, by what it is bound to: the engine's own words for it and every field it stores
679
751
  ## away from its default. As a bare resource it was its class and nothing else, so an input action
680
752
  ## read back from project.godot could not say whether it was bound to Space or to a joypad button.