gdharness 1.3.35 → 1.3.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cli.js +288 -78
- package/build/godot/addons/gdharness_editor/animation_reads.gd +17 -1
- package/build/godot/addons/gdharness_editor/reading.gd +10 -2
- package/build/godot/addons/gdharness_editor/scene_file.gd +12 -6
- package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +57 -0
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +16 -0
- package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +81 -4
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -3
- package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -2
- package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +59 -0
- package/build/godot/addons/gdharness_editor/tools/script_paths.gd +148 -0
- package/build/godot/addons/gdharness_editor/tools/script_paths.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +10 -2
- package/build/godot/addons/gdharness_runtime/runtime_hover.gd +70 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +223 -248
- package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +128 -5
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +107 -0
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +15 -1
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +8 -0
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +22 -0
- package/build/godot/operations/animation_reads.gd +17 -1
- package/build/godot/operations/classdb_queries.gd +16 -0
- package/build/godot/operations/dependencies.gd +46 -4
- package/build/godot/operations/editor_tools.gd +5 -5
- package/build/godot/operations/gdscript_authoring.gd +7 -3
- package/build/godot/operations/input_actions.gd +105 -0
- package/build/godot/operations/project_diagnostics.gd +4 -0
- package/build/godot/operations/project_validation.gd +15 -0
- package/build/godot/operations/reading.gd +10 -2
- package/build/keeper.js +5 -5
- package/package.json +1 -1
|
@@ -4,6 +4,9 @@ extends RefCounted
|
|
|
4
4
|
## here and sent from runtime_input.gd, which keeps where the pointer is.
|
|
5
5
|
|
|
6
6
|
const GdhRead = preload("reading.gd")
|
|
7
|
+
const GdhScreen = preload("runtime_screen.gd")
|
|
8
|
+
const GdhTargets = preload("runtime_targets.gd")
|
|
9
|
+
const GdhValues = preload("runtime_values.gd")
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
## A point the tool schema sends as two flat numbers. Answers a Vector2, or the String that says
|
|
@@ -14,6 +17,84 @@ static func read_point(params: Dictionary, x_key: String, y_key: String) -> Vari
|
|
|
14
17
|
return Vector2(GdhRead.as_float(params[x_key]), GdhRead.as_float(params[y_key]))
|
|
15
18
|
|
|
16
19
|
|
|
20
|
+
## The point a click names in [param node]'s own space, null when it names none, or the String
|
|
21
|
+
## refusing it: a 3D node is clicked where it is drawn, and an item found by its words, a tab or a
|
|
22
|
+
## row, where the control draws it.
|
|
23
|
+
##
|
|
24
|
+
## For a control that draws its parts itself and hit-tests them in _gui_input, whose centre was all
|
|
25
|
+
## a click could press; in a native window, where the game's window pixels do not reach, nothing
|
|
26
|
+
## else could press the rest.
|
|
27
|
+
static func point_in(params: Dictionary, node: Node, node_path: String, has_item: bool) -> Variant:
|
|
28
|
+
if not params.has("x") and not params.has("y"):
|
|
29
|
+
return null
|
|
30
|
+
if node is Node3D:
|
|
31
|
+
return (
|
|
32
|
+
"%s is a 3D node, clicked where it is drawn; x and y name a point in a Control's own space"
|
|
33
|
+
% node_path
|
|
34
|
+
)
|
|
35
|
+
if has_item:
|
|
36
|
+
return (
|
|
37
|
+
(
|
|
38
|
+
"the words name an item %s draws, which is pressed where it is drawn, and x and y a point in it:"
|
|
39
|
+
+ " give one of them"
|
|
40
|
+
)
|
|
41
|
+
% node_path
|
|
42
|
+
)
|
|
43
|
+
return read_point(params, "x", "y")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## The node a mouse_click, mouse_motion or mouse_drag names its points in, as {"node", "path"}, an
|
|
47
|
+
## empty Dictionary for the game's window, or the String refusing it.
|
|
48
|
+
##
|
|
49
|
+
## A point in a node's own space is the only way to press part of a control in a native window,
|
|
50
|
+
## where the game's window pixels do not reach: a game that draws its buttons itself and hit-tests
|
|
51
|
+
## them in _gui_input had nothing a click could aim at but the control's centre.
|
|
52
|
+
static func aim_of(params: Dictionary, root: Window) -> Variant:
|
|
53
|
+
var path: String = str(params.get("nodePath", ""))
|
|
54
|
+
if path.is_empty():
|
|
55
|
+
return {}
|
|
56
|
+
var standing: Dictionary = GdhValues.node_at(root, path)
|
|
57
|
+
if standing.has("message"):
|
|
58
|
+
return standing["message"]
|
|
59
|
+
var node: Node = standing["node"]
|
|
60
|
+
if node is CanvasItem or node is Viewport:
|
|
61
|
+
return {"node": node, "path": path}
|
|
62
|
+
return (
|
|
63
|
+
"%s is a %s, and the points are in a Control's, a Node2D's or a Window's own space"
|
|
64
|
+
% [path, node.get_class()]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
## Where [param local], in the space of the node [param aim] names, arrives: {"viewport", "position"},
|
|
69
|
+
## the viewport the pointer reaches it through and the point in that viewport's pixels, which is what
|
|
70
|
+
## an event sent there names. A Window's space is its own pixels, as the game's window pixels are the
|
|
71
|
+
## root's; a canvas item's is its own, the one it draws and reads _gui_input in.
|
|
72
|
+
static func carried(aim: Dictionary, local: Vector2, root: Window) -> Dictionary:
|
|
73
|
+
var node: Node = aim.get("node", root)
|
|
74
|
+
var reached: Dictionary = {}
|
|
75
|
+
if node is Viewport:
|
|
76
|
+
var viewport: Viewport = node
|
|
77
|
+
reached = GdhScreen.reach(viewport, viewport.get_final_transform().affine_inverse() * local)
|
|
78
|
+
else:
|
|
79
|
+
var item: CanvasItem = node
|
|
80
|
+
reached = GdhScreen.reach(item.get_viewport(), item.get_global_transform_with_canvas() * local)
|
|
81
|
+
var arriving: Viewport = reached["viewport"]
|
|
82
|
+
var point: Vector2 = reached["point"]
|
|
83
|
+
return {"viewport": arriving, "position": arriving.get_final_transform() * point}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
## What a pointer answer says of where [param points] went in [param viewport]: the node they were
|
|
87
|
+
## named in, the window when it is not the game's own, and any of them outside its viewport. Read
|
|
88
|
+
## before the first event, since a release can close the window it went to.
|
|
89
|
+
static func where(aim: Dictionary, viewport: Viewport, points: Array[Vector2]) -> Dictionary:
|
|
90
|
+
var said: Dictionary = outside_noted({}, viewport, points)
|
|
91
|
+
if aim.has("path"):
|
|
92
|
+
said["path"] = aim["path"]
|
|
93
|
+
if viewport != viewport.get_tree().root:
|
|
94
|
+
said["window"] = str(viewport.get_path())
|
|
95
|
+
return said
|
|
96
|
+
|
|
97
|
+
|
|
17
98
|
## The points of a drag: x, y, then each of via, then toX, toY. Answers the String saying what is
|
|
18
99
|
## wrong instead, since a point left out would otherwise read as the window's corner.
|
|
19
100
|
static func stroke_of(params: Dictionary) -> Variant:
|
|
@@ -66,9 +147,10 @@ static func mask_of(button: int) -> int:
|
|
|
66
147
|
|
|
67
148
|
|
|
68
149
|
## A button event carries the buttons held once it has happened: the pressed one among them, the
|
|
69
|
-
## released one out. A wheel step is a button with no mask, as it is from a real mouse.
|
|
150
|
+
## released one out. A wheel step is a button with no mask, as it is from a real mouse. [param held]
|
|
151
|
+
## is what was held before it, which Input knows for the game's window.
|
|
70
152
|
static func button_event(
|
|
71
|
-
position: Vector2, button: int, pressed: bool, double: bool
|
|
153
|
+
position: Vector2, button: int, pressed: bool, double: bool, held: int = -1
|
|
72
154
|
) -> InputEventMouseButton:
|
|
73
155
|
var event: InputEventMouseButton = InputEventMouseButton.new()
|
|
74
156
|
event.position = position
|
|
@@ -76,7 +158,8 @@ static func button_event(
|
|
|
76
158
|
event.button_index = button as MouseButton
|
|
77
159
|
event.pressed = pressed
|
|
78
160
|
event.double_click = double
|
|
79
|
-
|
|
161
|
+
if held < 0:
|
|
162
|
+
held = Input.get_mouse_button_mask()
|
|
80
163
|
var own: int = mask_of(button)
|
|
81
164
|
event.button_mask = (held | own) if pressed else (held & ~own)
|
|
82
165
|
return event
|
|
@@ -90,12 +173,15 @@ static func button_event(
|
|
|
90
173
|
## content scale and any letterbox offset, so the rect is taken into window pixels first.
|
|
91
174
|
static func outside_noted(answer: Dictionary, viewport: Viewport, points: Array[Vector2]) -> Dictionary:
|
|
92
175
|
var shown: Rect2 = viewport.get_final_transform() * viewport.get_visible_rect()
|
|
176
|
+
var which: String = (
|
|
177
|
+
"the window's" if viewport == viewport.get_tree().root else "%s's" % viewport.get_path()
|
|
178
|
+
)
|
|
93
179
|
for point: Vector2 in points:
|
|
94
180
|
if not shown.has_point(point):
|
|
95
181
|
answer["outside_viewport"] = (
|
|
96
182
|
(
|
|
97
|
-
"%s is outside
|
|
98
|
-
% [point, shown]
|
|
183
|
+
"%s is outside %s viewport, %s in window pixels, so no control there receives"
|
|
184
|
+
% [point, which, shown]
|
|
99
185
|
)
|
|
100
186
|
+ " it; the game's _input and _unhandled_input still do."
|
|
101
187
|
)
|
|
@@ -140,3 +226,40 @@ static func button_of(raw: Variant) -> int:
|
|
|
140
226
|
# number past the two extra buttons names nothing a mask bit can be made for.
|
|
141
227
|
var number: int = GdhRead.as_int(raw, -1)
|
|
142
228
|
return number if number >= MOUSE_BUTTON_LEFT and number <= MOUSE_BUTTON_XBUTTON2 else -1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
## Why [param control] would take no click a player gave it, or "" when it would.
|
|
232
|
+
##
|
|
233
|
+
## The last two answered landed, since the pointer reaches the control, while the press did nothing,
|
|
234
|
+
## measured on 4.7.2: a disabled button keeps its mouse filter and ignores the press, and a control
|
|
235
|
+
## that does not process while the game is paused is hovered and never handed the event.
|
|
236
|
+
## A control whose pointer a `mouse_behavior_recursive` above it has turned off is passed through to
|
|
237
|
+
## what is underneath, which answered `landed: false` with nothing saying why. [param paused] is
|
|
238
|
+
## whether the game is.
|
|
239
|
+
static func takes_no_click(control: Control, node_path: String, paused: bool) -> String:
|
|
240
|
+
if not control.is_visible_in_tree():
|
|
241
|
+
return "%s is not visible%s, so nothing can click it" % [node_path, GdhTargets.why_hidden(control)]
|
|
242
|
+
var button: BaseButton = control as BaseButton
|
|
243
|
+
if button != null and button.disabled:
|
|
244
|
+
return "%s is disabled, so clicking it presses nothing" % node_path
|
|
245
|
+
var turned_off: Control = GdhTargets.pointer_turned_off_by(control)
|
|
246
|
+
if turned_off != null:
|
|
247
|
+
var on: String = "itself" if turned_off == control else str(turned_off.get_path())
|
|
248
|
+
return (
|
|
249
|
+
(
|
|
250
|
+
"%s takes no pointer input, since mouse_behavior_recursive is disabled on %s,"
|
|
251
|
+
+ " so clicking it presses nothing"
|
|
252
|
+
)
|
|
253
|
+
% [node_path, on]
|
|
254
|
+
)
|
|
255
|
+
if not control.can_process():
|
|
256
|
+
var why: String = GdhTargets.why_it_does_not_process(control, node_path)
|
|
257
|
+
var over: Node = GdhTargets.drawn_over(control)
|
|
258
|
+
var cover: String = "" if over == null else "; %s is drawn over it" % over.get_path()
|
|
259
|
+
if paused:
|
|
260
|
+
return (
|
|
261
|
+
"the game is paused and %s does not process while it is (%s), so clicking it does nothing%s"
|
|
262
|
+
% [node_path, why, cover]
|
|
263
|
+
)
|
|
264
|
+
return "%s does not process (%s), so clicking it does nothing%s" % [node_path, why, cover]
|
|
265
|
+
return ""
|
|
@@ -3,6 +3,8 @@ extends RefCounted
|
|
|
3
3
|
## Where a point in some viewport is on the game's own screen, for a click to be sent there and for
|
|
4
4
|
## an answer to say where something is drawn.
|
|
5
5
|
|
|
6
|
+
const GdhHover = preload("runtime_hover.gd")
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
## The viewport a pointer at [param point], in [param viewport]'s own coordinates, arrives through,
|
|
8
10
|
## and where it is there: {"viewport", "point"}.
|
|
@@ -106,3 +108,108 @@ static func shown_part(control: Control) -> Variant:
|
|
|
106
108
|
return null
|
|
107
109
|
part = part.intersection(clip)
|
|
108
110
|
return part
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## Whether [param centre] is somewhere a click can reach it: inside the viewport, and inside every
|
|
114
|
+
## ScrollContainer between the control and the root, each of which clips what it holds.
|
|
115
|
+
static func in_sight(control: Control, viewport: Viewport, centre: Vector2) -> bool:
|
|
116
|
+
return viewport.get_visible_rect().has_point(centre) and clipped_by(control, centre) == null
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
## The ScrollContainer between [param control] and its viewport that clips [param centre] away from
|
|
120
|
+
## it, or null when none does. Not past the viewport: one holding a dialog is in another space.
|
|
121
|
+
static func clipped_by(control: Control, centre: Vector2) -> ScrollContainer:
|
|
122
|
+
var walking: Node = control.get_parent()
|
|
123
|
+
while walking != null and not (walking is Viewport):
|
|
124
|
+
var holder: ScrollContainer = walking as ScrollContainer
|
|
125
|
+
# Carried into the same space the centre is in, which is the canvas rather than the
|
|
126
|
+
# container's own: the two are only the same while nothing above it is transformed.
|
|
127
|
+
if holder != null:
|
|
128
|
+
var seen: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
|
|
129
|
+
if not seen.has_point(centre):
|
|
130
|
+
return holder
|
|
131
|
+
walking = walking.get_parent()
|
|
132
|
+
return null
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
## Scrolls whatever is holding [param control] until it is on screen, and answers whether the view
|
|
136
|
+
## moved.
|
|
137
|
+
##
|
|
138
|
+
## Innermost container first and outwards, a frame apart. A container moves what it holds on the
|
|
139
|
+
## next layout pass rather than inside the call, and the next one out works out where the control is
|
|
140
|
+
## from where it is drawn, so asked in the same frame it scrolled to where the control had been.
|
|
141
|
+
## Moved means a scroll value changed: a container asked to show what it already shows moves
|
|
142
|
+
## nothing, and answering true for it said the view had moved when it had not. Only the containers in
|
|
143
|
+
## the control's own viewport: one holding a dialog scrolls the page behind it, not the dialog.
|
|
144
|
+
static func scroll_into_view(control: Control, tree: SceneTree) -> bool:
|
|
145
|
+
var moved: bool = false
|
|
146
|
+
var walking: Variant = control.get_parent()
|
|
147
|
+
while walking != null and not (walking is Viewport):
|
|
148
|
+
var step: Node = walking
|
|
149
|
+
var holder: ScrollContainer = step as ScrollContainer
|
|
150
|
+
if holder != null:
|
|
151
|
+
var before: Vector2i = Vector2i(holder.scroll_horizontal, holder.scroll_vertical)
|
|
152
|
+
holder.ensure_control_visible(control)
|
|
153
|
+
if Vector2i(holder.scroll_horizontal, holder.scroll_vertical) != before:
|
|
154
|
+
moved = true
|
|
155
|
+
await tree.process_frame
|
|
156
|
+
# A panel that rebuilds itself can go in the frame this waited.
|
|
157
|
+
if not is_instance_valid(control) or not is_instance_valid(holder):
|
|
158
|
+
return moved
|
|
159
|
+
walking = step.get_parent()
|
|
160
|
+
return moved
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
## Whether a pointer on its way out through [param out], the steps [method steps_out] answers, gets
|
|
164
|
+
## past the interface in every viewport, as {"landed", "hovered"}: the path of the control that took
|
|
165
|
+
## it, or of the one under it that let it by, or null. [param hover] answers what is hovered.
|
|
166
|
+
##
|
|
167
|
+
## In the viewport the node is drawn in, a control under the pointer takes it unless it and every
|
|
168
|
+
## control holding it let the pointer pass. In each viewport outside that one, the pointer has to be
|
|
169
|
+
## over what shows the one inside: the SubViewportContainer, with nothing drawn over it, or inside
|
|
170
|
+
## the embedded window. Read in the first viewport alone, a click beside a container that is smaller
|
|
171
|
+
## than what it shows, or under a panel drawn over the container, answered landed.
|
|
172
|
+
static func through_the_interface(out: Array[Dictionary], hover: GdhHover) -> Dictionary:
|
|
173
|
+
var first_under: Variant = null
|
|
174
|
+
for step: Dictionary in out:
|
|
175
|
+
var viewport: Viewport = step["viewport"]
|
|
176
|
+
var through: Variant = step["through"]
|
|
177
|
+
var hovered: Control = hover.hovered_in(viewport)
|
|
178
|
+
if through is Window:
|
|
179
|
+
var window: Window = through
|
|
180
|
+
var point: Vector2 = step["point"]
|
|
181
|
+
if not Rect2(window.position, window.size).has_point(point):
|
|
182
|
+
return {"landed": false, "hovered": _path_or_null(hovered)}
|
|
183
|
+
continue
|
|
184
|
+
if through is SubViewportContainer:
|
|
185
|
+
if hovered != through:
|
|
186
|
+
return {"landed": false, "hovered": _path_or_null(hovered)}
|
|
187
|
+
continue
|
|
188
|
+
if hovered != null and not _passes_on(hovered):
|
|
189
|
+
return {"landed": false, "hovered": str(hovered.get_path())}
|
|
190
|
+
if hovered != null and first_under == null:
|
|
191
|
+
first_under = str(hovered.get_path())
|
|
192
|
+
return {"landed": true, "hovered": first_under}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
## Whether a pointer over [param control] goes on past the interface: a control that lets it pass
|
|
196
|
+
## hands it to the one holding it, and one that stops it takes it, up to a control drawn on its own
|
|
197
|
+
## or something that is not a control. Measured on 4.7.2: a HUD over the room letting the pointer
|
|
198
|
+
## pass answered not landed while the game's own input took the press. A script on a control that
|
|
199
|
+
## lets it pass can still take the event for itself, which nothing outside it can see.
|
|
200
|
+
static func _passes_on(control: Control) -> bool:
|
|
201
|
+
var walk: Node = control
|
|
202
|
+
while walk is Control:
|
|
203
|
+
var at: Control = walk
|
|
204
|
+
if at.get_mouse_filter_with_override() == Control.MOUSE_FILTER_STOP:
|
|
205
|
+
return false
|
|
206
|
+
if at.top_level:
|
|
207
|
+
break
|
|
208
|
+
walk = at.get_parent()
|
|
209
|
+
return true
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
static func _path_or_null(node: Node) -> Variant:
|
|
213
|
+
if node == null:
|
|
214
|
+
return null
|
|
215
|
+
return str(node.get_path())
|
|
@@ -173,11 +173,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
173
173
|
|
|
174
174
|
var under: String = "" if root_path == "/root" else " under %s" % root_path
|
|
175
175
|
if targets.is_empty():
|
|
176
|
+
var from: Node = reached["node"]
|
|
176
177
|
return {
|
|
177
178
|
"type": "error",
|
|
178
179
|
"message":
|
|
179
180
|
(
|
|
180
|
-
'no control on screen%s says "%s", so nothing was clicked%s%s%s%s'
|
|
181
|
+
'no control on screen%s says "%s", so nothing was clicked%s%s%s%s%s'
|
|
181
182
|
% [
|
|
182
183
|
under,
|
|
183
184
|
wanted,
|
|
@@ -185,6 +186,7 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
185
186
|
_outside_note(outside, root),
|
|
186
187
|
_covered_note(covered, cover),
|
|
187
188
|
_open_menu_note(root, wanted),
|
|
189
|
+
_offered_note(from, drawn, areas),
|
|
188
190
|
]
|
|
189
191
|
)
|
|
190
192
|
}
|
|
@@ -437,6 +439,18 @@ static func _passed_over(best: int, others: Array[int], one: bool) -> String:
|
|
|
437
439
|
|
|
438
440
|
|
|
439
441
|
## What to add to a refusal that found nothing on screen, when words matched controls out of sight.
|
|
442
|
+
static func _offered_note(from: Node, drawn: Array[Node], areas: Array[Rect2]) -> String:
|
|
443
|
+
var open: Callable = func(button: Control) -> bool:
|
|
444
|
+
var aim: Vector2 = button.size * 0.5
|
|
445
|
+
return (
|
|
446
|
+
GdhQueries.shown(button)
|
|
447
|
+
and not _off_screen(button, aim)
|
|
448
|
+
and _cover_of(button, drawn, areas, aim) == null
|
|
449
|
+
)
|
|
450
|
+
var words: String = GdhWords.pressable_words(from, open, LISTED_CANDIDATES)
|
|
451
|
+
return "" if words.is_empty() else "; the buttons on screen say %s" % words
|
|
452
|
+
|
|
453
|
+
|
|
440
454
|
static func _hidden_note(hidden: int) -> String:
|
|
441
455
|
if hidden == 0:
|
|
442
456
|
return ""
|
|
@@ -227,6 +227,8 @@ func _collect(id: int, timeout_ms: int) -> Dictionary:
|
|
|
227
227
|
"args": _values.serialize(watch.catcher.args),
|
|
228
228
|
"elapsed_ms": Time.get_ticks_msec() - started,
|
|
229
229
|
}
|
|
230
|
+
if watch.catcher.fired:
|
|
231
|
+
answer["fired_after_ms"] = watch.catcher.fired_after_ms
|
|
230
232
|
if before:
|
|
231
233
|
answer["fired_before_wait"] = true
|
|
232
234
|
return answer
|
|
@@ -848,10 +850,16 @@ class GdhSignalCatcher:
|
|
|
848
850
|
## How many times it fired; [member args] holds what the first carried.
|
|
849
851
|
var times: int = 0
|
|
850
852
|
var args: Array = []
|
|
853
|
+
## Milliseconds from when this began listening to the first emission, -1 before one. A wait
|
|
854
|
+
## collecting a watch measured only itself, so a call made between the arming and the wait left
|
|
855
|
+
## nothing saying how long after it the signal came.
|
|
856
|
+
var fired_after_ms: int = -1
|
|
857
|
+
var _armed_at: int = Time.get_ticks_msec()
|
|
851
858
|
|
|
852
859
|
func _on_fired(...carried: Array) -> void:
|
|
853
860
|
if not fired:
|
|
854
861
|
args = carried
|
|
862
|
+
fired_after_ms = Time.get_ticks_msec() - _armed_at
|
|
855
863
|
fired = true
|
|
856
864
|
times += 1
|
|
857
865
|
|
|
@@ -144,6 +144,28 @@ static func draws_a_list(node: Node) -> bool:
|
|
|
144
144
|
return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
## The words of the enabled buttons under [param from], quoted, each once and in the order they are
|
|
148
|
+
## drawn, that [param open] says a player could press: at most [param most], then "and more". What a
|
|
149
|
+
## refusal of words nothing on screen says offers instead: a click for "Settings" named nothing,
|
|
150
|
+
## though Play, Options and Quit were all there.
|
|
151
|
+
static func pressable_words(from: Node, open: Callable, most: int) -> String:
|
|
152
|
+
var offered: Array[String] = []
|
|
153
|
+
var pending: Array[Node] = [from]
|
|
154
|
+
while not pending.is_empty():
|
|
155
|
+
var node: Node = pending.pop_front()
|
|
156
|
+
var button: BaseButton = node as BaseButton
|
|
157
|
+
var words: String = "" if button == null or button.disabled else said_by(button).strip_edges()
|
|
158
|
+
var quoted: String = '"%s"' % words
|
|
159
|
+
if not words.is_empty() and not offered.has(quoted) and open.call(button):
|
|
160
|
+
if offered.size() == most:
|
|
161
|
+
return ", ".join(offered) + ", and more"
|
|
162
|
+
offered.append(quoted)
|
|
163
|
+
var children: Array[Node] = node.get_children()
|
|
164
|
+
for index: int in range(children.size() - 1, -1, -1):
|
|
165
|
+
pending.push_front(children[index])
|
|
166
|
+
return ", ".join(offered)
|
|
167
|
+
|
|
168
|
+
|
|
147
169
|
static func _keep(line: String, into: Array[String]) -> void:
|
|
148
170
|
var trimmed: String = line.strip_edges()
|
|
149
171
|
if not trimmed.is_empty():
|
|
@@ -131,6 +131,22 @@ static func _summary(anim: Animation) -> Dictionary:
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
## [param seconds], a key's time, as the shortest decimal holding the same 32-bit float. A scene keeps
|
|
135
|
+
## key times as 32-bit floats, so a key put at 0.7 read back as 0.699999988079071.
|
|
136
|
+
static func key_time(seconds: float) -> float:
|
|
137
|
+
var held: float = PackedFloat32Array([seconds])[0]
|
|
138
|
+
for places: int in range(0, 10):
|
|
139
|
+
var shorter: float = String.num(held, places).to_float()
|
|
140
|
+
if PackedFloat32Array([shorter])[0] == held:
|
|
141
|
+
return shorter
|
|
142
|
+
return held
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Whether two times are one key's: the same 32-bit float, as a scene stores them.
|
|
146
|
+
static func same_time(first: float, second: float) -> bool:
|
|
147
|
+
return PackedFloat32Array([first])[0] == PackedFloat32Array([second])[0]
|
|
148
|
+
|
|
149
|
+
|
|
134
150
|
static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dictionary:
|
|
135
151
|
var kind: int = anim.track_get_type(track)
|
|
136
152
|
var path: NodePath = anim.track_get_path(track)
|
|
@@ -143,7 +159,7 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
|
|
|
143
159
|
var keyframes: Array[Dictionary] = []
|
|
144
160
|
var methods: Dictionary = {}
|
|
145
161
|
for key: int in anim.track_get_key_count(track):
|
|
146
|
-
var keyframe: Dictionary = {"time": anim.track_get_key_time(track, key)}
|
|
162
|
+
var keyframe: Dictionary = {"time": key_time(anim.track_get_key_time(track, key))}
|
|
147
163
|
if kind == Animation.TYPE_METHOD:
|
|
148
164
|
var method: String = str(anim.method_track_get_name(track, key))
|
|
149
165
|
methods[method] = true
|
|
@@ -310,6 +310,10 @@ func _not_found(wanted: String, declared: Dictionary, answers_own: bool = false)
|
|
|
310
310
|
for name: String in close:
|
|
311
311
|
if name.to_lower().similarity(wanted.to_lower()) < best - CLOSE_TO_THE_BEST:
|
|
312
312
|
continue
|
|
313
|
+
# Similarity counts shared letter pairs, so a word the two names share passes it: NoSuchClass
|
|
314
|
+
# was offered ClassDB. A spelling is a few letters off, a swap of two among them.
|
|
315
|
+
if _edits(name.to_lower(), wanted.to_lower()) > maxi(2, floori(wanted.length() / 3.0)):
|
|
316
|
+
continue
|
|
313
317
|
if declared.has(name):
|
|
314
318
|
own.append(name)
|
|
315
319
|
else:
|
|
@@ -347,6 +351,18 @@ func _not_found(wanted: String, declared: Dictionary, answers_own: bool = false)
|
|
|
347
351
|
)
|
|
348
352
|
|
|
349
353
|
|
|
354
|
+
## How many letters must be put in, taken out or changed to make [param from] into [param to].
|
|
355
|
+
static func _edits(from: String, to: String) -> int:
|
|
356
|
+
var above: PackedInt32Array = PackedInt32Array(range(to.length() + 1))
|
|
357
|
+
for row: int in range(1, from.length() + 1):
|
|
358
|
+
var here: PackedInt32Array = PackedInt32Array([row])
|
|
359
|
+
for column: int in range(1, to.length() + 1):
|
|
360
|
+
var changed: int = above[column - 1] + (0 if from[row - 1] == to[column - 1] else 1)
|
|
361
|
+
var _added: bool = here.append(mini(changed, mini(above[column] + 1, here[column - 1] + 1)))
|
|
362
|
+
above = here
|
|
363
|
+
return above[to.length()]
|
|
364
|
+
|
|
365
|
+
|
|
350
366
|
# One member of a class by name, wherever in its ancestry it is declared.
|
|
351
367
|
#
|
|
352
368
|
# The question a member is asked by name for is "does this engine have it, and what does it take":
|
|
@@ -123,12 +123,18 @@ func get_dependencies(params: Dictionary) -> Dictionary:
|
|
|
123
123
|
|
|
124
124
|
|
|
125
125
|
## Every file a scene or resource of the project loads through an ext_resource that is not there,
|
|
126
|
-
## by its path or its uid,
|
|
127
|
-
##
|
|
128
|
-
## write, so every project was told its addons referred to
|
|
129
|
-
##
|
|
126
|
+
## by its path or its uid, and every one a script hands load() as written, each with the files
|
|
127
|
+
## loading it. Not any path a script names: one may be asking whether the file exists, and the
|
|
128
|
+
## shipped addons check for files they write, so every project was told its addons referred to
|
|
129
|
+
## files that are missing. A load() of a file the script also asks exists() about is left out for
|
|
130
|
+
## the same reason, and a script's preloads are the compile's to judge.
|
|
130
131
|
func missing_references() -> Array[Dictionary]:
|
|
131
132
|
var missing: Dictionary = {}
|
|
133
|
+
for file: String in _files.find_files("res://", ".gd"):
|
|
134
|
+
for path: String in _loaded_and_missing(file):
|
|
135
|
+
var naming: Array = missing.get_or_add(path, [])
|
|
136
|
+
if not file in naming:
|
|
137
|
+
naming.append(file)
|
|
132
138
|
for file: String in _files.find_files_with_extensions("res://", GdhResourceFiles.TEXT_RESOURCE_KINDS):
|
|
133
139
|
var content: String = FileAccess.get_file_as_string(file)
|
|
134
140
|
var moved: Dictionary = _moved_by_uid(content)
|
|
@@ -158,6 +164,42 @@ func missing_references() -> Array[Dictionary]:
|
|
|
158
164
|
return found
|
|
159
165
|
|
|
160
166
|
|
|
167
|
+
## The files [param script] loads with load() or ResourceLoader.load() by a path written in the call
|
|
168
|
+
## that are not on disk. A load called on another object, such as a ConfigFile's, reads a file that
|
|
169
|
+
## object's owner may write, so only the bare call and the ResourceLoader's count. A path the script
|
|
170
|
+
## names anywhere else, asking whether it exists, writing it or removing it, is one it looks after
|
|
171
|
+
## itself, and a `load(` inside a string is not a call: read across lines, one in a string literal
|
|
172
|
+
## took the rest of the file up to the next quote as a path.
|
|
173
|
+
func _loaded_and_missing(script: String) -> Array[String]:
|
|
174
|
+
var loaded: Array[String] = []
|
|
175
|
+
var named: Dictionary = {}
|
|
176
|
+
for line: String in FileAccess.get_file_as_string(script).split("\n"):
|
|
177
|
+
var code: String = GdhPatterns.without_comment(line)
|
|
178
|
+
if not code.contains("load("):
|
|
179
|
+
for literal: String in _string_literals(code):
|
|
180
|
+
named[_resolved(literal, script)] = true
|
|
181
|
+
continue
|
|
182
|
+
var calls: Dictionary = {}
|
|
183
|
+
for loading: RegExMatch in (
|
|
184
|
+
GdhPatterns.compiled("(?:(?<![\\w.])|ResourceLoader\\.)load\\(\\s*(?=[\"'])").search_all(code)
|
|
185
|
+
):
|
|
186
|
+
calls[loading.get_end()] = true
|
|
187
|
+
for literal: RegExMatch in GdhPatterns.compiled(STRING_LITERAL).search_all(code):
|
|
188
|
+
var single: String = literal.get_string(2)
|
|
189
|
+
var path: String = _resolved(literal.get_string(1) if single.is_empty() else single, script)
|
|
190
|
+
if calls.has(literal.get_start()):
|
|
191
|
+
loaded.append(path)
|
|
192
|
+
else:
|
|
193
|
+
named[path] = true
|
|
194
|
+
var missing: Array[String] = []
|
|
195
|
+
for path: String in loaded:
|
|
196
|
+
if not path.begins_with("res://") or path.begins_with("res://.") or path.get_extension().is_empty():
|
|
197
|
+
continue
|
|
198
|
+
if not named.has(path) and not FileAccess.file_exists(path) and not missing.has(path):
|
|
199
|
+
missing.append(path)
|
|
200
|
+
return missing
|
|
201
|
+
|
|
202
|
+
|
|
161
203
|
## The files project.godot's settings name that are not on disk: an autoload, a registered
|
|
162
204
|
## translation, an icon, an enabled plugin. Not the main scene, which has a check of its own, and
|
|
163
205
|
## the engine reports each of the rest on every boot.
|
|
@@ -86,11 +86,11 @@ static func run(params: Dictionary) -> Dictionary:
|
|
|
86
86
|
if not GdhSceneUids.by_path.is_empty():
|
|
87
87
|
answered["namedByPath"] = (
|
|
88
88
|
(
|
|
89
|
-
"What this saved names %s by path rather than by uid. A uid in a
|
|
90
|
-
+ "resolved through the project's uid cache, which holds none for
|
|
91
|
-
+ "file made since the project was last scanned,
|
|
92
|
-
+ "warn on every load. project_import refresh_uids scans the project, and
|
|
93
|
-
+ "after it name %s by uid."
|
|
89
|
+
"What this saved names %s by path rather than by uid, which loads the same. A uid in a "
|
|
90
|
+
+ "file's own header is resolved through the project's uid cache, which holds none for "
|
|
91
|
+
+ "%s yet, as with a file made since the project was last scanned, so a uid written "
|
|
92
|
+
+ "now would warn on every load. project_import refresh_uids scans the project, and "
|
|
93
|
+
+ "writes after it name %s by uid."
|
|
94
94
|
)
|
|
95
95
|
% [
|
|
96
96
|
", ".join(GdhSceneUids.by_path),
|
|
@@ -116,7 +116,10 @@ func create_gdscript(params: Dictionary) -> Dictionary:
|
|
|
116
116
|
# answered for a file whose header says otherwise, so those are read off the text instead.
|
|
117
117
|
if parses:
|
|
118
118
|
answer["extends"] = _base_named(written)
|
|
119
|
-
|
|
119
|
+
var declared: String = str(written.get_global_name())
|
|
120
|
+
answer["class_name"] = null
|
|
121
|
+
if not declared.is_empty():
|
|
122
|
+
answer["class_name"] = declared
|
|
120
123
|
else:
|
|
121
124
|
answer.merge(_header_written(script_content))
|
|
122
125
|
return answer
|
|
@@ -134,9 +137,10 @@ static func _not_gdscript(path: String, doing: String) -> String:
|
|
|
134
137
|
|
|
135
138
|
|
|
136
139
|
## The class_name and extends [param text] declares at its margin, as written. A script with no
|
|
137
|
-
## extends extends RefCounted
|
|
140
|
+
## extends extends RefCounted, and one with no class_name answers null for it, as an absent name
|
|
141
|
+
## does everywhere else rather than as a name that is empty.
|
|
138
142
|
static func _header_written(text: String) -> Dictionary:
|
|
139
|
-
var header: Dictionary = {"extends": "RefCounted", "class_name":
|
|
143
|
+
var header: Dictionary = {"extends": "RefCounted", "class_name": null}
|
|
140
144
|
for line: String in text.split("\n"):
|
|
141
145
|
# An inner class's extends is indented, and is not the script's.
|
|
142
146
|
if line.begins_with(" ") or line.begins_with("\t"):
|