gdharness 1.3.9 → 1.3.10

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 (73) hide show
  1. package/build/cli.js +4563 -3195
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +102 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +51 -1
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +77 -140
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +11 -22
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +28 -5
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +39 -20
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -362
  18. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  21. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +147 -492
  24. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  25. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -3
  26. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  27. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +79 -5
  28. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  29. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +183 -37
  30. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  31. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  32. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  33. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  34. package/build/godot/addons/gdharness_runtime/reading.gd +51 -1
  35. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  36. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +185 -30
  37. package/build/godot/addons/gdharness_runtime/runtime_input.gd +86 -79
  38. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  39. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  40. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +309 -22
  41. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +163 -20
  42. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  43. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +5 -2
  44. package/build/godot/addons/gdharness_runtime/runtime_values.gd +146 -5
  45. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  46. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  47. package/build/godot/addons/gdharness_runtime/serialisation.gd +11 -22
  48. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  49. package/build/godot/operations/animation_reads.gd +115 -4
  50. package/build/godot/operations/audio_buses.gd +93 -7
  51. package/build/godot/operations/classdb_queries.gd +119 -7
  52. package/build/godot/operations/declared_values.gd +272 -0
  53. package/build/godot/operations/dependencies.gd +154 -41
  54. package/build/godot/operations/editor_tools.gd +22 -14
  55. package/build/godot/operations/export_presets.gd +602 -4
  56. package/build/godot/operations/file_reads.gd +102 -18
  57. package/build/godot/operations/gdscript_authoring.gd +11 -1
  58. package/build/godot/operations/godot_operations.gd +16 -2
  59. package/build/godot/operations/import_pipeline.gd +20 -221
  60. package/build/godot/operations/input_actions.gd +108 -47
  61. package/build/godot/operations/project_config.gd +83 -69
  62. package/build/godot/operations/project_diagnostics.gd +4 -0
  63. package/build/godot/operations/project_validation.gd +266 -0
  64. package/build/godot/operations/property_paths.gd +89 -16
  65. package/build/godot/operations/property_values.gd +180 -121
  66. package/build/godot/operations/reading.gd +51 -1
  67. package/build/godot/operations/scene_connections.gd +222 -0
  68. package/build/godot/operations/serialisation.gd +11 -22
  69. package/build/godot/operations/setting_reads.gd +121 -0
  70. package/build/godot/operations/value_shapes.gd +128 -1
  71. package/build/godot/tests/listed.gd +20 -13
  72. package/build/keeper.js +1371 -706
  73. package/package.json +1 -1
@@ -125,6 +125,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
125
125
  var root: Node = reached["node"]
126
126
 
127
127
  var found: Array[Dictionary] = []
128
+ var found_nodes: Array[Node] = []
128
129
  var pending: Array[Node] = [root]
129
130
  var truncated: bool = false
130
131
  # Counted while the tree is already being walked, for the answer below: how many nodes this
@@ -148,24 +149,28 @@ func find_nodes(params: Dictionary) -> Dictionary:
148
149
  var nearly_hidden: int = 0
149
150
  var nearly_said_hidden: int = 0
150
151
  var hidden: int = 0
152
+ var out_of_view: int = 0
151
153
  while not pending.is_empty():
152
154
  var node: Node = pending.pop_front()
153
155
  var rest: bool = _matches_apart_from_name(node, wanted)
154
156
  if rest and _named(node, names):
155
157
  if not include_hidden and not shown(node):
156
158
  hidden += 1
159
+ elif not include_hidden and not GdhScreen.in_view(node):
160
+ out_of_view += 1
157
161
  elif found.size() >= limit:
158
162
  truncated = true
159
163
  break
160
164
  else:
161
165
  found.append(_found(node, wanted_property))
166
+ found_nodes.append(node)
162
167
  elif rest and _contains_any(str(node.name), plain_names):
163
- if include_hidden or shown(node):
168
+ if include_hidden or readable(node):
164
169
  nearly += 1
165
170
  else:
166
171
  nearly_hidden += 1
167
172
  elif not widened_says.is_empty() and _named(node, names) and _matches_apart_from_name(node, widened):
168
- if include_hidden or shown(node):
173
+ if include_hidden or readable(node):
169
174
  nearly_said += 1
170
175
  else:
171
176
  nearly_said_hidden += 1
@@ -181,6 +186,39 @@ func find_nodes(params: Dictionary) -> Dictionary:
181
186
 
182
187
  var answer: Dictionary = {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
183
188
  var notes: Array[String] = []
189
+ # Each still listed, since a caller may mean one of them, and marked, so one sign drawn as a label
190
+ # and its outline copies is not read as four signs. The outermost match it copies, since a find
191
+ # under one of the copies has left the label they all copy outside it.
192
+ var copies: int = 0
193
+ for index: int in range(found_nodes.size()):
194
+ var at: int = -1
195
+ for original: Node in GdhWords.copied_through(found_nodes[index]):
196
+ var matched: int = found_nodes.find(original)
197
+ if matched != -1:
198
+ at = matched
199
+ if at != -1:
200
+ found[index]["copyOf"] = found[at]["path"]
201
+ copies += 1
202
+ if copies > 0:
203
+ answer["distinct"] = found.size() - copies
204
+ (
205
+ notes
206
+ . append(
207
+ (
208
+ (
209
+ "%d of the %d matches %s drawn under a match saying the same words, as an outline or a"
210
+ + " glow is drawn, and %s it under copyOf: %d distinct"
211
+ )
212
+ % [
213
+ copies,
214
+ found.size(),
215
+ "is a copy" if copies == 1 else "are copies",
216
+ "names" if copies == 1 else "name",
217
+ found.size() - copies,
218
+ ]
219
+ )
220
+ )
221
+ )
184
222
  # Nothing found is the one answer that cannot be told apart from having asked the wrong
185
223
  # question, and a name written without a wildcard is the way an agent writes "contains".
186
224
  # Said only when it changes the answer, so a genuine nothing stays a plain nothing.
@@ -200,7 +238,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
200
238
  'name is matched as a glob against the whole name; %d %snode %s %s, which "%s"%s would find'
201
239
  % [
202
240
  named_near,
203
- "" if nearly > 0 else "hidden ",
241
+ "" if nearly > 0 else "hidden or out-of-view ",
204
242
  holding,
205
243
  " or ".join(PackedStringArray(near_words)),
206
244
  GdhSays.joined_at_bars(opened),
@@ -220,7 +258,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
220
258
  widened_says,
221
259
  "" if nearly_said > 0 else " with includeHidden true",
222
260
  said_near,
223
- "" if nearly_said > 0 else "hidden ",
261
+ "" if nearly_said > 0 else "hidden or out-of-view ",
224
262
  "" if said_near == 1 else "s",
225
263
  ]
226
264
  )
@@ -236,6 +274,17 @@ func find_nodes(params: Dictionary) -> Dictionary:
236
274
  % [hidden, "" if hidden == 1 else "s"]
237
275
  )
238
276
  )
277
+ if out_of_view > 0:
278
+ answer["out_of_view"] = out_of_view
279
+ notes.append(
280
+ (
281
+ (
282
+ "%d matching node%s drawn outside the viewport or scrolled out of a ScrollContainer"
283
+ + " and left out; includeHidden true answers with them too"
284
+ )
285
+ % [out_of_view, "" if out_of_view == 1 else "s"]
286
+ )
287
+ )
239
288
  if not notes.is_empty():
240
289
  answer["note"] = " ".join(notes)
241
290
  return answer
@@ -268,6 +317,13 @@ static func shown(node: Node) -> bool:
268
317
  return parent == null or shown(parent)
269
318
 
270
319
 
320
+ ## Whether the player can read [param node]: [method shown], and drawn inside its viewport and every
321
+ ## ScrollContainer holding it, see [method GdhScreen.in_view]. A click by words asks [method shown]
322
+ ## alone, since it scrolls what it presses into view.
323
+ static func readable(node: Node) -> bool:
324
+ return shown(node) and GdhScreen.in_view(node)
325
+
326
+
271
327
  ## Whether the canvas [param item] is drawn on is showing: its layer, and the windows around it.
272
328
  static func _on_a_shown_canvas(item: CanvasItem) -> bool:
273
329
  var walk: Node = item.get_parent()
@@ -426,15 +482,46 @@ func read_text(params: Dictionary) -> Dictionary:
426
482
  # and a bare `truncated: true` was looked straight past twice before the limit was suspected.
427
483
  # A number is what sends somebody back.
428
484
  var lines: Array[String] = []
429
- var left_out: int = _read_into(root, include_hidden, limit, lines)
430
- return {
485
+ var tally: Dictionary[String, int] = {"omitted": 0, "out_of_view": 0, "copies": 0}
486
+ _read_into(root, include_hidden, limit, lines, tally)
487
+ var answer: Dictionary = {
431
488
  "type": "text",
432
489
  "root": root_path,
433
490
  "lines": lines,
434
491
  "count": lines.size(),
435
- "truncated": left_out > 0,
436
- "omitted": left_out,
492
+ "truncated": tally["omitted"] > 0,
493
+ "omitted": tally["omitted"],
437
494
  }
495
+ var notes: Array[String] = []
496
+ if tally["out_of_view"] > 0:
497
+ answer["out_of_view"] = tally["out_of_view"]
498
+ notes.append(
499
+ (
500
+ (
501
+ "%d line%s drawn outside the viewport or scrolled out of a ScrollContainer, where the"
502
+ + " player cannot read them, left out; includeHidden true answers with them too"
503
+ )
504
+ % [tally["out_of_view"], "" if tally["out_of_view"] == 1 else "s"]
505
+ )
506
+ )
507
+ if tally["copies"] > 0:
508
+ answer["copies"] = tally["copies"]
509
+ notes.append(
510
+ (
511
+ (
512
+ "%d line%s drawn as a copy under a node saying the same words, as an outline or a glow"
513
+ + " is drawn, read once with the words %s"
514
+ )
515
+ % [
516
+ tally["copies"],
517
+ "" if tally["copies"] == 1 else "s",
518
+ "it copies" if tally["copies"] == 1 else "they copy"
519
+ ]
520
+ )
521
+ )
522
+ if not notes.is_empty():
523
+ answer["note"] = " ".join(notes)
524
+ return answer
438
525
 
439
526
 
440
527
  ## Walks [param node] depth first, which is the order the screen is laid out in and the order a
@@ -445,22 +532,33 @@ func read_text(params: Dictionary) -> Dictionary:
445
532
  ## screen full of forms answered with every label on it and none of the values in it. The hidden
446
533
  ## check covers a [Window] for the same walk: a dropdown's popup is a child that is not drawn
447
534
  ## until it is opened, and reading a closed menu would put every item on the screen.
448
- ## Fills [param into] up to [param most] lines and answers with how many further lines the rest of
449
- ## the subtree says, which is what the caller is told rather than left to infer.
535
+ ## Fills [param into] up to [param most] lines and counts in [param tally] how many further lines the
536
+ ## rest of the subtree says, under omitted, and how many it says out of view, under out_of_view, which
537
+ ## is what the caller is told rather than left to infer.
450
538
  ##
451
539
  ## Asked of each node rather than cut off at the first hidden one, since a hidden panel does not
452
540
  ## hide a label reached through a plain Node: see [method shown].
453
- func _read_into(node: Node, include_hidden: bool, most: int, into: Array[String]) -> int:
454
- var left_out: int = 0
541
+ func _read_into(
542
+ node: Node, include_hidden: bool, most: int, into: Array[String], tally: Dictionary[String, int]
543
+ ) -> void:
455
544
  if include_hidden or shown(node):
456
- for said: String in GdhWords.lines_said_by(node):
457
- if into.size() < most:
458
- into.append(said)
459
- else:
460
- left_out += 1
545
+ var said: Array[String] = GdhWords.lines_said_by(node)
546
+ # A copy drawn under the words it copies, as an outline or a glow is, reads once. Only while
547
+ # what it copies is itself read, so an original hidden or drawn out of view does not take the
548
+ # words with it.
549
+ var original: Node = GdhWords.copied_from(node) if not said.is_empty() else null
550
+ if original != null and (include_hidden or readable(original)):
551
+ tally["copies"] += said.size()
552
+ elif not include_hidden and not GdhScreen.in_view(node):
553
+ tally["out_of_view"] += said.size()
554
+ else:
555
+ for line: String in said:
556
+ if into.size() < most:
557
+ into.append(line)
558
+ else:
559
+ tally["omitted"] += 1
461
560
  for child: Node in node.get_children(true):
462
- left_out += _read_into(child, include_hidden, most, into)
463
- return left_out
561
+ _read_into(child, include_hidden, most, into, tally)
464
562
 
465
563
 
466
564
  ## Where a node is on screen: a Control's rectangle, a Node2D's position, or the place a 3D node
@@ -488,6 +586,7 @@ func get_rect(params: Dictionary) -> Dictionary:
488
586
  "type": "rect",
489
587
  "path": node_path,
490
588
  "visible": control.is_visible_in_tree(),
589
+ "in_view": GdhScreen.in_view(control),
491
590
  "canvas": _values.serialize(canvas_rect),
492
591
  "window":
493
592
  _values.serialize(
@@ -501,13 +600,20 @@ func get_rect(params: Dictionary) -> Dictionary:
501
600
  var item: Node2D = node
502
601
  var canvas_position: Vector2 = item.get_global_transform_with_canvas().origin
503
602
  var window_position: Vector2 = GdhScreen.in_window(item.get_viewport(), canvas_position)
504
- return {
603
+ var at: Dictionary = {
505
604
  "type": "point",
506
605
  "path": node_path,
507
606
  "visible": item.is_visible_in_tree(),
508
607
  "canvas": _values.serialize(canvas_position),
509
608
  "window": _values.serialize(window_position),
510
609
  }
610
+ for local: Rect2 in _drawn_rect(item):
611
+ var covered: Rect2 = item.get_global_transform_with_canvas() * local
612
+ at["covers"] = {
613
+ "canvas": _values.serialize(covered),
614
+ "window": _values.serialize(GdhScreen.rect_in_window(item.get_viewport(), covered)),
615
+ }
616
+ return at
511
617
  if node is Node3D:
512
618
  var spatial: Node3D = node
513
619
  return _in_the_frame(node_path, spatial)
@@ -516,6 +622,43 @@ func get_rect(params: Dictionary) -> Dictionary:
516
622
  }
517
623
 
518
624
 
625
+ ## The rectangle a 2D node's own geometry takes up in its own coordinates, as the one entry of the
626
+ ## list, which is empty for a node that has none of its own: a sprite's texture, a collision shape's
627
+ ## shape, the points of a polygon or a line. A Node2D answered only where it stands, so the extent of
628
+ ## a floor or a sprite to aim inside was a read of its shape and some arithmetic.
629
+ static func _drawn_rect(item: Node2D) -> Array[Rect2]:
630
+ var drawn: Array[Rect2] = []
631
+ var sprite: Sprite2D = item as Sprite2D
632
+ if sprite != null:
633
+ if sprite.texture != null:
634
+ drawn.append(sprite.get_rect())
635
+ return drawn
636
+ var shaped: CollisionShape2D = item as CollisionShape2D
637
+ if shaped != null:
638
+ if shaped.shape != null:
639
+ drawn.append(shaped.shape.get_rect())
640
+ return drawn
641
+ var points: PackedVector2Array = []
642
+ var offset: Vector2 = Vector2.ZERO
643
+ if item is Polygon2D:
644
+ var polygon: Polygon2D = item
645
+ points = polygon.polygon
646
+ offset = polygon.offset
647
+ elif item is CollisionPolygon2D:
648
+ var outline: CollisionPolygon2D = item
649
+ points = outline.polygon
650
+ elif item is Line2D:
651
+ var line: Line2D = item
652
+ points = line.points
653
+ if points.is_empty():
654
+ return drawn
655
+ var bounds: Rect2 = Rect2(points[0] + offset, Vector2.ZERO)
656
+ for point: Vector2 in points:
657
+ bounds = bounds.expand(point + offset)
658
+ drawn.append(bounds)
659
+ return drawn
660
+
661
+
519
662
  ## Where a 3D node is in the frame drawing it: the point to aim at, and the rectangle its own
520
663
  ## geometry covers, each in canvas coordinates and in window pixels.
521
664
  ##
@@ -29,9 +29,13 @@ static func steps_out(viewport: Viewport, point: Vector2) -> Array[Dictionary]:
29
29
  var out: Array[Dictionary] = [{"viewport": viewport, "point": point, "through": null}]
30
30
  while true:
31
31
  var window: Window = viewport as Window
32
- if window != null and window.is_embedded() and window.get_parent() != null:
33
- point += Vector2(window.position)
34
- viewport = window.get_parent().get_viewport()
32
+ var host: Viewport = embedder_of(window) if window != null and window.is_embedded() else null
33
+ if host != null:
34
+ # A window's canvas is drawn into its embedder through its own final transform, which is
35
+ # where a content scale goes: one at 4 drew a button at four times the point it was clicked
36
+ # at, and the click missed it.
37
+ point = Vector2(window.position) + window.get_final_transform() * point
38
+ viewport = host
35
39
  out.append({"viewport": viewport, "point": point, "through": window})
36
40
  continue
37
41
  var shown_by: SubViewportContainer = viewport.get_parent() as SubViewportContainer
@@ -45,6 +49,19 @@ static func steps_out(viewport: Viewport, point: Vector2) -> Array[Dictionary]:
45
49
  return out
46
50
 
47
51
 
52
+ ## The viewport [param window] is embedded in and placed by, found as the engine finds it, which
53
+ ## does not offer it to scripts: the nearest viewport above that embeds subwindows. That is not
54
+ ## always its parent's: a window under a window that does not embed is embedded in the one above.
55
+ static func embedder_of(window: Window) -> Viewport:
56
+ var above: Node = window.get_parent()
57
+ # A viewport's get_viewport is itself, so a window's parent that is one is asked first.
58
+ var viewport: Viewport = above.get_viewport() if above != null else null
59
+ while viewport != null and not viewport.is_embedding_subwindows():
60
+ above = viewport.get_parent()
61
+ viewport = above.get_viewport() if above != null else null
62
+ return viewport
63
+
64
+
48
65
  ## Where [param point], in [param viewport]'s own coordinates, is in the pixels of the window the
49
66
  ## game is shown in, which is what a mouse event names.
50
67
  static func in_window(viewport: Viewport, point: Vector2) -> Vector2:
@@ -60,3 +77,32 @@ static func rect_in_window(viewport: Viewport, rect: Rect2) -> Rect2:
60
77
  var start: Vector2 = in_window(viewport, rect.position)
61
78
  var end: Vector2 = in_window(viewport, rect.end)
62
79
  return Rect2(start.min(end), (end - start).abs())
80
+
81
+
82
+ ## Whether any of [param node] is drawn where the player can see it: inside its viewport, and inside
83
+ ## every ScrollContainer between it and the viewport, each of which clips what it holds. A label
84
+ ## placed past the window's edge was read out as written on the screen. Only a Control has a box to
85
+ ## ask about, so anything else is in view.
86
+ static func in_view(node: Node) -> bool:
87
+ var control: Control = node as Control
88
+ return control == null or shown_part(control) != null
89
+
90
+
91
+ ## The part of [param control] the player can see, in its viewport's canvas, or null when there is
92
+ ## none: its drawn box cut to the viewport and to every ScrollContainer between, each cut taken from
93
+ ## what the last one left. A control in view of an inner list and of the outer one can still sit
94
+ ## where the two do not overlap.
95
+ static func shown_part(control: Control) -> Variant:
96
+ var part: Rect2 = control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size)
97
+ var clips: Array[Rect2] = [control.get_viewport().get_visible_rect()]
98
+ var walking: Node = control.get_parent()
99
+ while walking != null and not (walking is Viewport):
100
+ var holder: ScrollContainer = walking as ScrollContainer
101
+ if holder != null:
102
+ clips.append(holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size))
103
+ walking = walking.get_parent()
104
+ for clip: Rect2 in clips:
105
+ if not part.intersects(clip, true):
106
+ return null
107
+ part = part.intersection(clip)
108
+ return part
@@ -762,9 +762,12 @@ static func _moved_by(node: Node, moves: Dictionary[ScrollContainer, Vector2]) -
762
762
  ## target's own viewport, a confirmation over a settings dialog did not cover the dialog's OK, and
763
763
  ## a click by words tied it with the confirmation's own.
764
764
  static func _window_over(target: Control, point: Vector2, window: Window) -> bool:
765
- if window.get_parent() == null or window.is_ancestor_of(target):
765
+ # Not passed over for holding the target in the tree: a window under an embedded window is
766
+ # embedded beside it, not in it, so the one holding it can be stacked over it. The target's own
767
+ # window is the step out below, and is never over it.
768
+ if window.get_parent() == null:
766
769
  return false
767
- var host: Viewport = window.get_parent().get_viewport()
770
+ var host: Viewport = GdhScreen.embedder_of(window)
768
771
  for step: Dictionary in GdhScreen.steps_out(target.get_viewport(), point):
769
772
  if step["viewport"] != host:
770
773
  continue
@@ -8,6 +8,7 @@ extends RefCounted
8
8
  ## only true of a running game: reaching a node by path, and fitting an argument to the type the
9
9
  ## method it is going to declares.
10
10
 
11
+ const GdhDeclared = preload("declared_values.gd")
11
12
  const GdhRead = preload("reading.gd")
12
13
  const GdhSerialisation = preload("serialisation.gd")
13
14
  const GdhShapes = preload("value_shapes.gd")
@@ -80,9 +81,18 @@ static func node_at(root: Node, path: String) -> Dictionary:
80
81
 
81
82
  ## "Node not found" for [param path], with the deepest node of it the game has and the children that
82
83
  ## node has instead, as a scene's refusal names them: a mistyped step was otherwise found by reading
83
- ## the whole tree.
84
+ ## the whole tree. A path written from the current scene rather than from the root, as a scene's
85
+ ## tools take one, is given the path that reaches it.
84
86
  static func _not_found(root: Node, path: String) -> String:
85
87
  var said: String = "Node not found: " + path
88
+ var scene: Node = root.get_tree().current_scene if root.is_inside_tree() else null
89
+ if not path.begins_with("/") and scene != null:
90
+ var meant: Node = scene.get_node_or_null(path)
91
+ if meant != null:
92
+ return (
93
+ "%s. Paths here go from the root, /root, not from the current scene, so this one is %s."
94
+ % [said, str(meant.get_path())]
95
+ )
86
96
  var parts: PackedStringArray = path.split("/", false)
87
97
  var at: Node = root
88
98
  var step: int = 0
@@ -220,13 +230,43 @@ static func no_action(action: String) -> String:
220
230
  ## counting the action counted the key, and the answer had said only that a key went in.
221
231
  static func actions_of(event: InputEvent) -> Array[String]:
222
232
  var named: Array[String] = []
233
+ # A motion matches every action on its axis, either way and at any distance, which would name
234
+ # ui_left for a stick held right; Input presses only those its direction reaches past their
235
+ # deadzone.
236
+ var moved: bool = event is InputEventJoypadMotion
223
237
  for action: StringName in InputMap.get_actions():
224
- if InputMap.event_is_action(event, action):
238
+ if event.is_action_pressed(action) if moved else InputMap.event_is_action(event, action):
225
239
  named.append(str(action))
226
240
  named.sort()
227
241
  return named
228
242
 
229
243
 
244
+ ## The path of the Control holding the keyboard focus in [param host]'s viewport, or "" when none
245
+ ## does. Read before an event goes in, since pressing a focused button can move the focus.
246
+ static func focus_path(host: Node) -> String:
247
+ var holding: Control = host.get_viewport().gui_get_focus_owner()
248
+ return "" if holding == null else str(holding.get_path())
249
+
250
+
251
+ ## [param said] with [param focused], the Control that had the focus, under focused. A key, an action
252
+ ## or a pad's event reaches that Control before the game's _unhandled_input: Space on a focused Button
253
+ ## pressed it and the game's own handler counted the key as well, and a stick pushed down moved a
254
+ ## focused list's selection and never reached the game, with nothing in the answer saying why.
255
+ ##
256
+ ## Where the focus is once the event has been handled goes under focused_after when it moved, null
257
+ ## when nothing holds it then: ui_down on a focused button hands the focus to its neighbour, and an
258
+ ## answer naming only the button that took the press read as the focus never having moved.
259
+ static func focus_noted(said: Dictionary, focused: String, host: Node) -> Dictionary:
260
+ if not focused.is_empty():
261
+ said["focused"] = focused
262
+ var now: String = focus_path(host)
263
+ if now.is_empty() and not focused.is_empty():
264
+ said["focused_after"] = null
265
+ elif now != focused:
266
+ said["focused_after"] = now
267
+ return said
268
+
269
+
230
270
  ## Presses the buttons under [param root] whose Shortcut [param action] matches, as the player's key
231
271
  ## bound to it would, and answers their paths under "shortcuts" and the one that took the press
232
272
  ## under "shortcut_pressed". A Viewport hands a shortcut only keys, joypad buttons and
@@ -277,6 +317,20 @@ static func press_shortcuts(root: Node, action: String) -> Dictionary:
277
317
  return {"shortcuts": paths, "shortcut_pressed": pressed}
278
318
 
279
319
 
320
+ ## The methods [param object] answers to, as the engine lists them: for a script itself, the static
321
+ ## functions it declares before the methods every script has. The engine's list for a script holds
322
+ ## only those, so a static function called on its class was read as taking nothing.
323
+ static func methods_of(object: Object) -> Array[Dictionary]:
324
+ var listed: Array[Dictionary] = []
325
+ if object is Script:
326
+ var script: Script = object
327
+ for entry: Dictionary in script.get_script_method_list():
328
+ if GdhRead.as_int(entry.get("flags", 0)) & METHOD_FLAG_STATIC:
329
+ listed.append(entry)
330
+ listed.append_array(object.get_method_list())
331
+ return listed
332
+
333
+
280
334
  ## What [param path] names: a node under [param root], as [method node_at] reads it, or an engine
281
335
  ## singleton by its name. Answers the object under "object", and under "node" the node that objects
282
336
  ## named in a value are found from, which for a singleton is [param root].
@@ -302,6 +356,32 @@ static func target_at(root: Node, path: String) -> Dictionary:
302
356
  return {"object": standing["node"], "node": standing["node"]}
303
357
 
304
358
 
359
+ ## The script [param path] names for a call of one of its static functions, as [method target_at]
360
+ ## answers a node, with [param root] as the node objects named in its arguments are found from: a
361
+ ## script by its res:// path, or the global class of that name where no node is at [param path].
362
+ ## Answers {} when it names a node or no class, and a message for a res:// path holding no script.
363
+ ## A class no node carries could not be called at all, and each way of naming it read as a node or
364
+ ## a method misspelt.
365
+ static func class_at(root: Node, path: String) -> Dictionary:
366
+ var script_path: String = path
367
+ if not path.begins_with("res://"):
368
+ if not path.is_valid_ascii_identifier() or not node_at(root, path).has("message"):
369
+ return {}
370
+ script_path = ""
371
+ for declared: Dictionary in ProjectSettings.get_global_class_list():
372
+ if str(declared.get("class", "")) == path:
373
+ script_path = str(declared.get("path", ""))
374
+ if script_path.is_empty():
375
+ return {}
376
+ var loaded: Resource = load(script_path) if ResourceLoader.exists(script_path) else null
377
+ if not loaded is Script:
378
+ return {
379
+ "type": "error",
380
+ "message": "No script is at %s to call a static function of." % script_path,
381
+ }
382
+ return {"object": loaded, "node": root}
383
+
384
+
305
385
  ## Whether two values can be compared without the comparison itself failing.
306
386
  static func comparable(one: Variant, other: Variant) -> bool:
307
387
  return GdhSerialisation.comparable(one, other)
@@ -352,7 +432,7 @@ static func remedy(value: Variant, type: int) -> String:
352
432
  ## The declared type of one parameter of a method, or TYPE_NIL when the method or the parameter
353
433
  ## is not there, which reads as "no opinion".
354
434
  func parameter_type(object: Object, method: String, index: int) -> int:
355
- for entry: Dictionary in object.get_method_list():
435
+ for entry: Dictionary in methods_of(object):
356
436
  if entry.get("name", "") != method:
357
437
  continue
358
438
  var params: Array = entry.get("args", [])
@@ -363,10 +443,59 @@ func parameter_type(object: Object, method: String, index: int) -> int:
363
443
  return TYPE_NIL
364
444
 
365
445
 
446
+ ## The values of the enum argument [param index] of [param method] declares, by name, as
447
+ ## `{"enum": "Mood", "values": {...}}`, or {} when it declares none. The engine gives such an argument
448
+ ## the type int and the enum's name, as res://main.gd.Mood or Node.ProcessMode.
449
+ func parameter_enum(object: Object, method: String, index: int) -> Dictionary:
450
+ for entry: Dictionary in methods_of(object):
451
+ if entry.get("name", "") != method:
452
+ continue
453
+ var params: Array = entry.get("args", [])
454
+ if index < 0 or index >= params.size():
455
+ return {}
456
+ var parameter: Dictionary = params[index]
457
+ if GdhRead.as_int(parameter.get("usage", 0)) & PROPERTY_USAGE_CLASS_IS_ENUM == 0:
458
+ return {}
459
+ var qualified: String = str(parameter.get("class_name", ""))
460
+ var enum_name: String = qualified.get_slice(".", qualified.get_slice_count(".") - 1)
461
+ var values: Dictionary = GdhDeclared.enum_declared_as(qualified)
462
+ if values.is_empty():
463
+ values = _enum_along(object if object is Script else object.get_script(), enum_name)
464
+ if values.is_empty():
465
+ return {}
466
+ return {"enum": enum_name, "values": values}
467
+ return {}
468
+
469
+
470
+ ## The enum [param named] declared by [param script] or a script it extends, or {}. A script built in
471
+ ## a scene or made at run time names its enums by an id nothing loads, gdscript://-92233….gd.Mood
472
+ ## measured on 4.7.2, and a method of the object called declares its enums along this chain.
473
+ static func _enum_along(script: Variant, named: String) -> Dictionary:
474
+ if not script is Script:
475
+ return {}
476
+ var current: Script = script
477
+ while current != null:
478
+ var found: Variant = current.get_script_constant_map().get(named)
479
+ if found is Dictionary:
480
+ var values: Dictionary = found
481
+ return values
482
+ current = current.get_base_script()
483
+ return {}
484
+
485
+
486
+ ## An empty list typed as what the packed array [param type] holds, for its elements to be fitted
487
+ ## against one by one, or null for any other type.
488
+ static func packed_template(type: int) -> Variant:
489
+ if not GdhSerialisation.LIST_TYPES.has(type):
490
+ return null
491
+ var element: int = GdhShapes.PACKED_ELEMENTS[type]
492
+ return Array([], element, &"", null)
493
+
494
+
366
495
  ## The class a method declares for argument [param index], or "" when it names none: an untyped
367
496
  ## parameter, a Variant, or a type that is not an object.
368
497
  func parameter_class(object: Object, method: String, index: int) -> String:
369
- for entry: Dictionary in object.get_method_list():
498
+ for entry: Dictionary in methods_of(object):
370
499
  if entry.get("name", "") != method:
371
500
  continue
372
501
  var params: Array = entry.get("args", [])
@@ -512,7 +641,7 @@ func fitted(value: Variant, type: int) -> Variant:
512
641
  ## An empty list or map typed the way argument [param index] of [param method] declares, or null
513
642
  ## when that argument is not a typed container. What [method typed_like] builds against.
514
643
  func parameter_container(object: Object, method: String, index: int) -> Variant:
515
- for entry: Dictionary in object.get_method_list():
644
+ for entry: Dictionary in methods_of(object):
516
645
  if entry.get("name", "") != method:
517
646
  continue
518
647
  var params: Array = entry.get("args", [])
@@ -654,6 +783,18 @@ func _element(item: Variant, builtin: int, declared: String, script: Script, res
654
783
  if named.has("message"):
655
784
  return {"message": str(named["message"]) + "."}
656
785
  return {"value": named["object"]}
786
+ # Fitted alone, 4.5 into an Array[int] went in as 4, as a single value is refused for.
787
+ if cuts(item, builtin):
788
+ return {
789
+ "message":
790
+ (
791
+ "is %s, which %s cannot hold without changing it."
792
+ % [JSON.stringify(serialize(item), "", false), cut_by(builtin)]
793
+ )
794
+ }
795
+ var rounded: String = inexact(item, builtin)
796
+ if not rounded.is_empty():
797
+ return {"message": "is too large to be sure of: %s." % rounded}
657
798
  var fitted_item: Variant = fitted(item, builtin)
658
799
  if not acceptable(fitted_item, builtin):
659
800
  # The value as it was sent rather than its engine type: JSON numbers arrive as floats, so a 7