gdharness 1.3.34 → 1.3.36
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 -59
- 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/package.json +1 -1
|
@@ -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
|
|
@@ -215,7 +215,8 @@ static func offered(said: String, word: String, names: Array) -> String:
|
|
|
215
215
|
## [param said], the refusal for [param property], which [param target] does not declare, with the
|
|
216
216
|
## names it declares nearest it when any are, as a misspelt one is usually one of them, or else the
|
|
217
217
|
## variables its script declares. The engine's own are not listed when none is near, since an object
|
|
218
|
-
## has a hundred, while a script's are the few a caller most likely meant
|
|
218
|
+
## has a hundred, while a script's are the few a caller most likely meant; an object with no script
|
|
219
|
+
## is told where they are listed instead, since the refusal alone left nothing to try.
|
|
219
220
|
static func near_property(said: String, target: Object, property: String) -> String:
|
|
220
221
|
var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
|
|
221
222
|
var names: Array[String] = []
|
|
@@ -233,8 +234,15 @@ static func near_property(said: String, target: Object, property: String) -> Str
|
|
|
233
234
|
var close: PackedStringArray = nearest(property, PackedStringArray(names))
|
|
234
235
|
if not close.is_empty():
|
|
235
236
|
return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
|
|
236
|
-
|
|
237
|
+
# With a script that declares nothing readable, as one that does not compile, the name is likelier
|
|
238
|
+
# the script's than the class's, so the class's list would point the wrong way.
|
|
239
|
+
if scripted.is_empty() and target.get_script() != null:
|
|
237
240
|
return said
|
|
241
|
+
if scripted.is_empty():
|
|
242
|
+
return (
|
|
243
|
+
"%s; editor_classes info with className %s and includeInherited true lists the ones it has"
|
|
244
|
+
% [said, target.get_class()]
|
|
245
|
+
)
|
|
238
246
|
scripted.sort()
|
|
239
247
|
return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
|
|
240
248
|
|
|
@@ -630,11 +630,12 @@ func write(check: Callable = Callable()) -> Dictionary:
|
|
|
630
630
|
}
|
|
631
631
|
|
|
632
632
|
|
|
633
|
-
## Each property [param state] stores differently from the file, on a node the file held
|
|
634
|
-
##
|
|
635
|
-
## saved where the write does not. The engine writes a node as it holds it once the file
|
|
636
|
-
## and one property can set others as it loads, as anchors_preset gives a centred Control
|
|
637
|
-
## directions, so a file not written by the editor gains lines on
|
|
633
|
+
## Each property [param state] stores differently from the file, on a node the file held, that the
|
|
634
|
+
## call neither asked for nor changed, as {node, property, was, saved}: was left out where the file did
|
|
635
|
+
## not set it, saved where the write does not. The engine writes a node as it holds it once the file
|
|
636
|
+
## has loaded, and one property can set others as it loads, as anchors_preset gives a centred Control
|
|
637
|
+
## both grow directions, so a file not written by the editor gains lines on any node, the one a set
|
|
638
|
+
## names among them. What the call's own change moved on that node is its answer's alsoChanged.
|
|
638
639
|
func _also_saved(state: SceneState) -> Array[Dictionary]:
|
|
639
640
|
var noted: Array[Dictionary] = []
|
|
640
641
|
var values: GdhSerialisation = GdhSerialisation.new()
|
|
@@ -643,14 +644,19 @@ func _also_saved(state: SceneState) -> Array[Dictionary]:
|
|
|
643
644
|
if node == null:
|
|
644
645
|
continue
|
|
645
646
|
var id: int = node.get_instance_id()
|
|
646
|
-
if
|
|
647
|
+
if not _filed.has(id):
|
|
647
648
|
continue
|
|
649
|
+
# What the call set on it, and what the engine moved as it did, which the answer gives as
|
|
650
|
+
# alsoChanged.
|
|
651
|
+
var on: Dictionary = _asked.get(id, {})
|
|
648
652
|
var filed: Dictionary = _filed[id]
|
|
649
653
|
var stored: Dictionary = _stored_at(state, index)
|
|
650
654
|
var names: Dictionary = filed.duplicate()
|
|
651
655
|
names.merge(stored)
|
|
652
656
|
for key: Variant in names:
|
|
653
657
|
var name: String = str(key)
|
|
658
|
+
if on.has(name):
|
|
659
|
+
continue
|
|
654
660
|
var held: bool = filed.has(name)
|
|
655
661
|
if held == stored.has(name) and (not held or GdhSame.same(stored[name], filed[name])):
|
|
656
662
|
continue
|
|
@@ -306,6 +306,10 @@ func remove_animation_track(args: Dictionary) -> Dictionary:
|
|
|
306
306
|
var property: String = str(track.get("property", ""))
|
|
307
307
|
var shape: Array = [track_type, node_path, property]
|
|
308
308
|
var method: String = str(track.get("method", "")) if track_type == "method" else ""
|
|
309
|
+
var read_times: Variant = _times_of(args.get("times"))
|
|
310
|
+
if read_times is String:
|
|
311
|
+
return {"ok": false, "error": read_times}
|
|
312
|
+
var times: Array = read_times
|
|
309
313
|
|
|
310
314
|
var found: Array = _animation_at(scene_path, player_node_path, animation_name)
|
|
311
315
|
if found[1] == null:
|
|
@@ -345,6 +349,12 @@ func remove_animation_track(args: Dictionary) -> Dictionary:
|
|
|
345
349
|
% [animation_name, node_path, method, ", ".join(_methods_called(anim, matching))]
|
|
346
350
|
)
|
|
347
351
|
)
|
|
352
|
+
if not times.is_empty():
|
|
353
|
+
var timed: Array = _keys_at(anim, calling, times)
|
|
354
|
+
if timed[1] != "":
|
|
355
|
+
var on: String = node_path if property.is_empty() else "%s:%s" % [node_path, property]
|
|
356
|
+
return scene.refuse("%s's %s track on %s %s" % [animation_name, track_type, on, timed[1]])
|
|
357
|
+
calling = timed[0]
|
|
348
358
|
var keys: int = 0
|
|
349
359
|
var tracks_removed: int = 0
|
|
350
360
|
calling.reverse()
|
|
@@ -404,6 +414,53 @@ static func _keys_calling(anim: Animation, matching: Array[int], method: String)
|
|
|
404
414
|
return calling
|
|
405
415
|
|
|
406
416
|
|
|
417
|
+
## The key times [param given] names, as floats, empty when it is null, or the String refusing it.
|
|
418
|
+
static func _times_of(given: Variant) -> Variant:
|
|
419
|
+
var times: Array = []
|
|
420
|
+
if given == null:
|
|
421
|
+
return times
|
|
422
|
+
if not given is Array:
|
|
423
|
+
return "times is a list of the key times to remove, in seconds"
|
|
424
|
+
var listed: Array = given
|
|
425
|
+
if listed.is_empty():
|
|
426
|
+
return "times is a list of the key times to remove, in seconds"
|
|
427
|
+
for time: Variant in listed:
|
|
428
|
+
if not (time is float or time is int):
|
|
429
|
+
return "times holds %s, which is not a number of seconds" % str(time)
|
|
430
|
+
times.append(GdhRead.as_float(time))
|
|
431
|
+
return times
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
## Of the keys [param calling] names, as [method _keys_calling] answers them, the ones at
|
|
435
|
+
## [param times], as [[[track, key]...], ""], or with the second the words refusing a time none is at.
|
|
436
|
+
## Removing a key took removing its whole track, so moving one key in time rewrote every other.
|
|
437
|
+
static func _keys_at(anim: Animation, calling: Array, times: Array) -> Array:
|
|
438
|
+
var at: Array = []
|
|
439
|
+
var held: PackedStringArray = []
|
|
440
|
+
var missed: Array = times.duplicate()
|
|
441
|
+
for pair: Array in calling:
|
|
442
|
+
var index: int = pair[0]
|
|
443
|
+
for key: int in anim.track_get_key_count(index):
|
|
444
|
+
if pair[1] >= 0 and key != pair[1]:
|
|
445
|
+
continue
|
|
446
|
+
var time: float = anim.track_get_key_time(index, key)
|
|
447
|
+
var _listed: bool = held.append(str(GdhAnimationReads.key_time(time)))
|
|
448
|
+
for given: float in times:
|
|
449
|
+
if GdhAnimationReads.same_time(time, given):
|
|
450
|
+
at.append([index, key])
|
|
451
|
+
missed.erase(given)
|
|
452
|
+
break
|
|
453
|
+
if missed.is_empty():
|
|
454
|
+
return [at, ""]
|
|
455
|
+
var named: PackedStringArray = []
|
|
456
|
+
for given: float in missed:
|
|
457
|
+
var _named: bool = named.append(str(given))
|
|
458
|
+
return [
|
|
459
|
+
at,
|
|
460
|
+
"has no key at %s s; its keys are at %s s. Nothing was removed." % [", ".join(named), ", ".join(held)]
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
|
|
407
464
|
## The methods the keys of the tracks [param matching] call, each once.
|
|
408
465
|
static func _methods_called(anim: Animation, matching: Array[int]) -> PackedStringArray:
|
|
409
466
|
var called: PackedStringArray = []
|
|
@@ -168,8 +168,20 @@ func scan_status(_args: Dictionary) -> Dictionary:
|
|
|
168
168
|
if not _editor_plugin:
|
|
169
169
|
return {"ok": false, "error": "Editor plugin unavailable"}
|
|
170
170
|
var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
|
|
171
|
+
# The scenes open in tabs, the one shown and the ones holding unsaved edits: a scene edit on disk
|
|
172
|
+
# meets what the editor holds of it, and nothing said which scenes those were. A tab of a scene
|
|
173
|
+
# never saved has no path, and a new project's editor opens with an empty one, so it is left out
|
|
174
|
+
# of the open; one with edits is kept among the unsaved, as the edits a close would lose.
|
|
175
|
+
var shown: Node = EditorInterface.get_edited_scene_root()
|
|
176
|
+
var open: Array[String] = []
|
|
177
|
+
open.assign(Array(EditorInterface.get_open_scenes()).filter(_has_path))
|
|
178
|
+
var unsaved: Array[String] = []
|
|
179
|
+
unsaved.assign(EditorInterface.get_unsaved_scenes())
|
|
171
180
|
return {
|
|
172
181
|
"ok": true,
|
|
182
|
+
"openScenes": open,
|
|
183
|
+
"editedScene": "" if shown == null else shown.scene_file_path,
|
|
184
|
+
"unsavedScenes": unsaved,
|
|
173
185
|
"scanning": filesystem.is_scanning(),
|
|
174
186
|
"importing": filesystem.is_importing(),
|
|
175
187
|
"pending": GdhSceneFile.scan_due,
|
|
@@ -179,6 +191,10 @@ func scan_status(_args: Dictionary) -> Dictionary:
|
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
|
|
194
|
+
func _has_path(path: String) -> bool:
|
|
195
|
+
return path != ""
|
|
196
|
+
|
|
197
|
+
|
|
182
198
|
## Reimport the files named under `paths`, whatever their state, the way the editor's Reimport
|
|
183
199
|
## button does, and report whether the reimport was started.
|
|
184
200
|
##
|
|
@@ -24,8 +24,26 @@ static func bake(scene: GdhSceneFile, region: Node) -> Dictionary:
|
|
|
24
24
|
var at: String = scene.path_of(region)
|
|
25
25
|
var flat: bool = region is NavigationRegion2D
|
|
26
26
|
if not flat and not region is NavigationRegion3D:
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
var regions: PackedStringArray = []
|
|
28
|
+
for node: Node in _all_under(scene.root):
|
|
29
|
+
if node is NavigationRegion2D or node is NavigationRegion3D:
|
|
30
|
+
var _named: bool = regions.append(scene.path_of(node))
|
|
31
|
+
return (
|
|
32
|
+
scene
|
|
33
|
+
. refuse(
|
|
34
|
+
(
|
|
35
|
+
"%s is a %s, not a NavigationRegion2D or a NavigationRegion3D; %s."
|
|
36
|
+
% [
|
|
37
|
+
at,
|
|
38
|
+
region.get_class(),
|
|
39
|
+
(
|
|
40
|
+
"the scene has none, and scene_node add puts one in"
|
|
41
|
+
if regions.is_empty()
|
|
42
|
+
else "the scene's are at %s" % ", ".join(regions)
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
)
|
|
29
47
|
)
|
|
30
48
|
var property: String = "navigation_polygon" if flat else "navigation_mesh"
|
|
31
49
|
var held: Resource = region.get(property)
|
|
@@ -64,6 +82,8 @@ static func bake(scene: GdhSceneFile, region: Node) -> Dictionary:
|
|
|
64
82
|
)
|
|
65
83
|
if polygons == 0:
|
|
66
84
|
answer["note"] = _why_empty(held, flat, parsed)
|
|
85
|
+
elif flat and not parsed:
|
|
86
|
+
answer["note"] = _nothing_read(scene, held)
|
|
67
87
|
if not flat and not Engine.is_editor_hint() and _reads_meshes(region, held):
|
|
68
88
|
answer["meshesNote"] = (
|
|
69
89
|
"The engine's warning that it had to parse RenderingServer meshes at runtime is about a "
|
|
@@ -97,7 +117,7 @@ static func bake(scene: GdhSceneFile, region: Node) -> Dictionary:
|
|
|
97
117
|
|
|
98
118
|
|
|
99
119
|
## Reads the source geometry for [param held] off a copy of the region at [param at] and bakes it
|
|
100
|
-
## into [param held], answering whether the copy gave any geometry.
|
|
120
|
+
## into [param held], answering whether the copy gave any geometry: in 2D, any obstacle.
|
|
101
121
|
static func _parse_and_bake(scene: GdhSceneFile, at: String, held: Resource, flat: bool) -> bool:
|
|
102
122
|
var copy: Node = scene.root.duplicate(Node.DUPLICATE_GROUPS)
|
|
103
123
|
var reading: Resource = held.duplicate()
|
|
@@ -116,7 +136,12 @@ static func _parse_and_bake(scene: GdhSceneFile, at: String, held: Resource, fla
|
|
|
116
136
|
var outline: NavigationPolygon = reading
|
|
117
137
|
var geometry: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
|
|
118
138
|
NavigationServer2D.parse_source_geometry_data(outline, geometry, region)
|
|
119
|
-
|
|
139
|
+
# has_data() counts only traversable outlines, so a bake carving every obstacle it read would
|
|
140
|
+
# still say it read nothing.
|
|
141
|
+
found = (
|
|
142
|
+
not geometry.get_obstruction_outlines().is_empty()
|
|
143
|
+
or not geometry.get_projected_obstructions().is_empty()
|
|
144
|
+
)
|
|
120
145
|
var polygon: NavigationPolygon = held
|
|
121
146
|
NavigationServer2D.bake_from_source_geometry_data(polygon, geometry)
|
|
122
147
|
else:
|
|
@@ -215,6 +240,58 @@ static func _why_empty(held: Resource, flat: bool, parsed: bool) -> String:
|
|
|
215
240
|
)
|
|
216
241
|
|
|
217
242
|
|
|
243
|
+
## Why a 2D bake of [param held] left the whole outline walkable: no obstacle was read, and which
|
|
244
|
+
## nodes of [param scene] that give obstacles are under no region. A tile layer beside the region
|
|
245
|
+
## rather than under it is the usual cause, and the bake alone does not show it.
|
|
246
|
+
static func _nothing_read(scene: GdhSceneFile, held: Resource) -> String:
|
|
247
|
+
var polygon: NavigationPolygon = held
|
|
248
|
+
var said: String = (
|
|
249
|
+
(
|
|
250
|
+
"No obstacle was read from %s, so all the outlines enclose is walkable, less the "
|
|
251
|
+
+ "agent_radius %s kept from their edges."
|
|
252
|
+
)
|
|
253
|
+
% [
|
|
254
|
+
_source_said(polygon.source_geometry_mode, polygon.source_geometry_group_name),
|
|
255
|
+
polygon.agent_radius
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
var outside: PackedStringArray = []
|
|
259
|
+
for node: Node in _all_under(scene.root):
|
|
260
|
+
var gives: bool = (
|
|
261
|
+
node is TileMapLayer
|
|
262
|
+
or node is StaticBody2D
|
|
263
|
+
or node is MeshInstance2D
|
|
264
|
+
or node is Polygon2D
|
|
265
|
+
or node is NavigationObstacle2D
|
|
266
|
+
)
|
|
267
|
+
if gives and not _in_a_region(node):
|
|
268
|
+
var _named: bool = outside.append("%s (a %s)" % [scene.path_of(node), node.get_class()])
|
|
269
|
+
if outside.is_empty():
|
|
270
|
+
return said
|
|
271
|
+
return (
|
|
272
|
+
(
|
|
273
|
+
"%s %s %s outside what was read; the bake reads a node elsewhere once it is in the group %s and "
|
|
274
|
+
+ "source_geometry_mode is groups_with_children, or once it is moved under the region."
|
|
275
|
+
)
|
|
276
|
+
% [
|
|
277
|
+
said,
|
|
278
|
+
", ".join(outside),
|
|
279
|
+
"is" if outside.size() == 1 else "are",
|
|
280
|
+
polygon.source_geometry_group_name,
|
|
281
|
+
]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
## Whether [param node] is under a 2D region, this one or another, whose obstacle it then is.
|
|
286
|
+
static func _in_a_region(node: Node) -> bool:
|
|
287
|
+
var above: Node = node.get_parent()
|
|
288
|
+
while above != null:
|
|
289
|
+
if above is NavigationRegion2D:
|
|
290
|
+
return true
|
|
291
|
+
above = above.get_parent()
|
|
292
|
+
return false
|
|
293
|
+
|
|
294
|
+
|
|
218
295
|
static func _source_said(mode: int, group: String) -> String:
|
|
219
296
|
if mode == 0:
|
|
220
297
|
return "the region's children (source mode root_node_children)"
|
|
@@ -470,7 +470,7 @@ func set_theme_color(args: Dictionary) -> Dictionary:
|
|
|
470
470
|
return _noting_variation(
|
|
471
471
|
{
|
|
472
472
|
"ok": true,
|
|
473
|
-
"
|
|
473
|
+
"resourcePath": theme_path,
|
|
474
474
|
"controlType": control_type,
|
|
475
475
|
"colorName": color_name,
|
|
476
476
|
"color": {"r": stored.r, "g": stored.g, "b": stored.b, "a": stored.a}
|
|
@@ -524,7 +524,7 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
|
|
|
524
524
|
return _noting_variation(
|
|
525
525
|
{
|
|
526
526
|
"ok": true,
|
|
527
|
-
"
|
|
527
|
+
"resourcePath": theme_path,
|
|
528
528
|
"controlType": control_type,
|
|
529
529
|
"fontSizeName": font_size_name,
|
|
530
530
|
"size": saved.get_font_size(font_size_name, control_type)
|
|
@@ -572,7 +572,7 @@ func remove_theme_item(args: Dictionary) -> Dictionary:
|
|
|
572
572
|
var unsaved: String = _save_theme(theme, theme_path, removed)
|
|
573
573
|
if not unsaved.is_empty():
|
|
574
574
|
return {"ok": false, "error": unsaved}
|
|
575
|
-
return {"ok": true, "
|
|
575
|
+
return {"ok": true, "resourcePath": theme_path, "controlType": control_type, "removed": removed}
|
|
576
576
|
|
|
577
577
|
|
|
578
578
|
## [param answer] with a note when its controlType names no class a theme styles. Any name is a
|
|
@@ -204,9 +204,11 @@ static func follow(changed: String, old_path: String, new_path: String, carried:
|
|
|
204
204
|
file.close()
|
|
205
205
|
GdhSceneFile.refresh(scene_path)
|
|
206
206
|
followed.append(scene_path)
|
|
207
|
-
|
|
207
|
+
var holds_paths: bool = text.contains("NodePath(")
|
|
208
|
+
# A script is only read, for the lines naming the node, so it does not need the file written.
|
|
209
|
+
if moves.is_empty() or not (holds_paths or text.contains('Script"')):
|
|
208
210
|
continue
|
|
209
|
-
if not unsaved.is_empty() and not new_path.is_empty():
|
|
211
|
+
if holds_paths and not unsaved.is_empty() and not new_path.is_empty():
|
|
210
212
|
not_followed.append({"scenePath": scene_path, "reason": unsaved})
|
|
211
213
|
continue
|
|
212
214
|
var before: int = carried.retargeted.size()
|
|
@@ -15,6 +15,7 @@ const GdhHeadlessCheck = preload("../headless_check.gd")
|
|
|
15
15
|
const GdhRead = preload("../reading.gd")
|
|
16
16
|
const GdhSceneFile = preload("../scene_file.gd")
|
|
17
17
|
const GdhSceneUids = preload("../scene_uids.gd")
|
|
18
|
+
const GdhScriptPaths = preload("script_paths.gd")
|
|
18
19
|
## PROPERTY_HINT_NO_NODEPATH, which marks a property the editor's rename skips. It is not bound for
|
|
19
20
|
## scripts, and follows PROPERTY_HINT_ONESHOT in the engine's list.
|
|
20
21
|
const HINT_NO_NODEPATH: int = PROPERTY_HINT_ONESHOT + 1
|
|
@@ -25,6 +26,14 @@ var retargeted: Array[Dictionary] = []
|
|
|
25
26
|
var not_retargeted: Array[Dictionary] = []
|
|
26
27
|
## Each path naming a deleted node, or a node below one, which now names nothing.
|
|
27
28
|
var still_naming: Array[Dictionary] = []
|
|
29
|
+
## Each line of a script on a node naming a node that moved or went by the path it had, as
|
|
30
|
+
## {scenePath, nodePath, script, line, from, to}, to absent for a deleted node. Code is left as it
|
|
31
|
+
## was, as the editor's own rename leaves it.
|
|
32
|
+
var scripts_naming: Array[Dictionary] = []
|
|
33
|
+
## The script lines already named, so one shared by an instance and its scene is named once.
|
|
34
|
+
var _lines: Dictionary = {}
|
|
35
|
+
## The paths each script names, by the script, read once however many nodes it is on.
|
|
36
|
+
var _named_in: Dictionary = {}
|
|
28
37
|
## The files beside a scene holding a rewritten track, each to the resource saved there.
|
|
29
38
|
var _homes: Dictionary = {}
|
|
30
39
|
## Each track rewritten in an animation one of [member _homes] keeps, as [animation, track, the path
|
|
@@ -87,6 +96,7 @@ func carry(scene: GdhSceneFile, moves: Dictionary) -> int:
|
|
|
87
96
|
var mixer: AnimationMixer = node as AnimationMixer
|
|
88
97
|
if mixer != null:
|
|
89
98
|
_tracks(mixer, now)
|
|
99
|
+
_scripts(node, was, now)
|
|
90
100
|
return retargeted.size() - before
|
|
91
101
|
|
|
92
102
|
|
|
@@ -146,9 +156,45 @@ func answer() -> Dictionary:
|
|
|
146
156
|
fields["notRetargeted"] = not_retargeted
|
|
147
157
|
if not still_naming.is_empty():
|
|
148
158
|
fields["stillNaming"] = still_naming
|
|
159
|
+
if not scripts_naming.is_empty():
|
|
160
|
+
fields["scriptsNaming"] = scripts_naming
|
|
149
161
|
return fields
|
|
150
162
|
|
|
151
163
|
|
|
164
|
+
## Names each line of the scripts on [param node], its own and those it extends, that names from
|
|
165
|
+
## where the node was a node the move took, by the path that no longer reaches it.
|
|
166
|
+
func _scripts(node: Node, was: String, now: String) -> void:
|
|
167
|
+
var script: Script = node.get_script()
|
|
168
|
+
while script is GDScript:
|
|
169
|
+
var file: String = script.resource_path
|
|
170
|
+
if not _named_in.has(script):
|
|
171
|
+
var source: String = script.source_code
|
|
172
|
+
if source.is_empty() and FileAccess.file_exists(file):
|
|
173
|
+
source = FileAccess.get_file_as_string(file)
|
|
174
|
+
_named_in[script] = GdhScriptPaths.named(source)
|
|
175
|
+
var listed: Array[Dictionary] = _named_in[script]
|
|
176
|
+
for named: Dictionary in listed:
|
|
177
|
+
var path: String = named["path"]
|
|
178
|
+
var key: String = "%s:%d:%s" % [file, named["line"], path]
|
|
179
|
+
if _lines.has(key):
|
|
180
|
+
continue
|
|
181
|
+
var after: Array = _after(NodePath(path), was, now)
|
|
182
|
+
if after[0] == UNCHANGED:
|
|
183
|
+
continue
|
|
184
|
+
_lines[key] = true
|
|
185
|
+
var record: Dictionary = {
|
|
186
|
+
"scenePath": _scene.path,
|
|
187
|
+
"nodePath": now,
|
|
188
|
+
"script": file,
|
|
189
|
+
"line": named["line"],
|
|
190
|
+
"from": path,
|
|
191
|
+
}
|
|
192
|
+
if after[0] == REWRITTEN:
|
|
193
|
+
record["to"] = after[1]
|
|
194
|
+
scripts_naming.append(record)
|
|
195
|
+
script = script.get_base_script()
|
|
196
|
+
|
|
197
|
+
|
|
152
198
|
## Puts back each track rewritten outside the scene whose record [param matching] takes, or that is
|
|
153
199
|
## kept in [param file], and answers their records. A file left with none is not saved.
|
|
154
200
|
func _put_back(matching: Callable, file: String = "") -> Array[Dictionary]:
|
|
@@ -294,6 +340,8 @@ func _after(path: NodePath, was: String, now: String) -> Array:
|
|
|
294
340
|
if holder.get_node_or_null(NodePath(path.get_concatenated_names())) == target:
|
|
295
341
|
return [UNCHANGED, ""]
|
|
296
342
|
var to: String = str(holder.get_path_to(target, unique))
|
|
343
|
+
if unique:
|
|
344
|
+
to = _unique_to(start, target, to)
|
|
297
345
|
if path.get_subname_count() > 0:
|
|
298
346
|
to += ":" + path.get_concatenated_subnames()
|
|
299
347
|
return [REWRITTEN, to]
|
|
@@ -305,6 +353,17 @@ func _names_nothing(path: NodePath, now: String) -> bool:
|
|
|
305
353
|
return holder != null and holder.get_node_or_null(NodePath(path.get_concatenated_names())) == null
|
|
306
354
|
|
|
307
355
|
|
|
356
|
+
## The path to [param target] through the unique node that was at [param start], written through its
|
|
357
|
+
## unique name as the path being carried was, or [param otherwise] when it is no longer unique. The
|
|
358
|
+
## engine's own unique path is given only from a node with the same owner, so never from the root.
|
|
359
|
+
func _unique_to(start: String, target: Node, otherwise: String) -> String:
|
|
360
|
+
var through: Node = _scene.root.get_node_or_null(moved(start, _moves))
|
|
361
|
+
if through == null or not through.unique_name_in_owner:
|
|
362
|
+
return otherwise
|
|
363
|
+
var rest: String = str(through.get_path_to(target))
|
|
364
|
+
return "%" + through.name + ("" if rest == "." else "/" + rest)
|
|
365
|
+
|
|
366
|
+
|
|
308
367
|
## Where the node a path held by the node at [param now] names as %[param name] was: one marked
|
|
309
368
|
## unique in the same owner that had that name before the move, or a deleted one that had it.
|
|
310
369
|
func _unique_was(name: String, now: String) -> String:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends RefCounted
|
|
3
|
+
|
|
4
|
+
## The node paths a GDScript names in its own source, from the node it is on: `$Path`, `%Unique`,
|
|
5
|
+
## and a string handed to get_node, get_node_or_null or has_node called on itself. Read from the
|
|
6
|
+
## tokens rather than the lines, so a `$` or a `%` inside a string or a comment names nothing, and
|
|
7
|
+
## a `%` after an operand is the remainder of a division rather than a unique name.
|
|
8
|
+
|
|
9
|
+
enum { NAME, STRING, MARK }
|
|
10
|
+
|
|
11
|
+
## The calls that take a path from the node they are called on.
|
|
12
|
+
const CALLS: Array[String] = ["get_node", "get_node_or_null", "has_node"]
|
|
13
|
+
## The words after which a `%` starts an operand.
|
|
14
|
+
const OPERAND_AFTER: Array[String] = [
|
|
15
|
+
"return", "and", "or", "not", "in", "if", "elif", "while", "await", "match", "when", "else"
|
|
16
|
+
]
|
|
17
|
+
## The marks after which a `%` starts an operand: an opening bracket, a separator or an operator.
|
|
18
|
+
const OPERAND_AFTER_MARKS: String = "([{,=:;+-*/<>!&|^~%"
|
|
19
|
+
const MARKS: String = "+-*/%<>=!&|^~()[]{},.:;$@?\\"
|
|
20
|
+
const QUOTES: String = "\"'"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Each path [param source] names, as {line, path}: the line it is on, counted from 1, and the path
|
|
24
|
+
## as a NodePath writes it, a unique name with its `%`.
|
|
25
|
+
static func named(source: String) -> Array[Dictionary]:
|
|
26
|
+
var tokens: Array[Array] = _tokens(source)
|
|
27
|
+
var found: Array[Dictionary] = []
|
|
28
|
+
for at: int in tokens.size():
|
|
29
|
+
var token: Array = tokens[at]
|
|
30
|
+
var path: String = ""
|
|
31
|
+
if _is_mark(token, "$"):
|
|
32
|
+
path = _path_from(tokens, at + 1)
|
|
33
|
+
elif _is_mark(token, "%") and _starts_operand(tokens, at):
|
|
34
|
+
path = _path_from(tokens, at)
|
|
35
|
+
elif token[0] == NAME and CALLS.has(str(token[1])) and _on_itself(tokens, at):
|
|
36
|
+
if at + 2 < tokens.size() and _is_mark(tokens[at + 1], "(") and tokens[at + 2][0] == STRING:
|
|
37
|
+
path = str(tokens[at + 2][1])
|
|
38
|
+
if not path.is_empty():
|
|
39
|
+
found.append({"line": token[2], "path": path})
|
|
40
|
+
return found
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## The path written from [param at]: a string, or names joined by `/`, each a word, `.` or `..`,
|
|
44
|
+
## and any of them a unique name after a `%`. "" when nothing there is a path.
|
|
45
|
+
static func _path_from(tokens: Array[Array], at: int) -> String:
|
|
46
|
+
if at < tokens.size() and tokens[at][0] == STRING:
|
|
47
|
+
return str(tokens[at][1])
|
|
48
|
+
var parts: PackedStringArray = []
|
|
49
|
+
var more: bool = true
|
|
50
|
+
while more:
|
|
51
|
+
var part: Array = _part_at(tokens, at)
|
|
52
|
+
if part.is_empty():
|
|
53
|
+
break
|
|
54
|
+
var _added: bool = parts.append(str(part[0]))
|
|
55
|
+
at = part[1]
|
|
56
|
+
more = at + 1 < tokens.size() and _is_mark(tokens[at], "/")
|
|
57
|
+
at += 1
|
|
58
|
+
return "/".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
## The name of a path at [param at], as [its text, where the next token is], or [] when there is none.
|
|
62
|
+
static func _part_at(tokens: Array[Array], at: int) -> Array:
|
|
63
|
+
var unique: String = ""
|
|
64
|
+
if at < tokens.size() and _is_mark(tokens[at], "%"):
|
|
65
|
+
unique = "%"
|
|
66
|
+
at += 1
|
|
67
|
+
if at >= tokens.size():
|
|
68
|
+
return []
|
|
69
|
+
var token: Array = tokens[at]
|
|
70
|
+
if token[0] == NAME or (unique.is_empty() and _is_mark(token, ".", "..")):
|
|
71
|
+
return [unique + str(token[1]), at + 1]
|
|
72
|
+
if token[0] == STRING and not unique.is_empty():
|
|
73
|
+
return [unique + str(token[1]), at + 1]
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## Whether the `%` at [param at] begins an operand, where after one it is a division's remainder.
|
|
78
|
+
static func _starts_operand(tokens: Array[Array], at: int) -> bool:
|
|
79
|
+
if at == 0:
|
|
80
|
+
return true
|
|
81
|
+
var before: Array = tokens[at - 1]
|
|
82
|
+
if before[0] == MARK:
|
|
83
|
+
return OPERAND_AFTER_MARKS.contains(str(before[1]))
|
|
84
|
+
return before[0] == NAME and OPERAND_AFTER.has(str(before[1]))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
## Whether the call named at [param at] is made on the script's own node: bare, or on `self`.
|
|
88
|
+
static func _on_itself(tokens: Array[Array], at: int) -> bool:
|
|
89
|
+
if at == 0 or not _is_mark(tokens[at - 1], "."):
|
|
90
|
+
return true
|
|
91
|
+
return at >= 2 and tokens[at - 2][0] == NAME and tokens[at - 2][1] == "self"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
static func _is_mark(token: Array, mark: String, other: String = "") -> bool:
|
|
95
|
+
return token[0] == MARK and (token[1] == mark or (not other.is_empty() and token[1] == other))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## [param source] as [kind, text, line] tokens, without comments or blank space. A string's text is
|
|
99
|
+
## what is between its quotes, as written.
|
|
100
|
+
static func _tokens(source: String) -> Array[Array]:
|
|
101
|
+
var tokens: Array[Array] = []
|
|
102
|
+
var line: int = 1
|
|
103
|
+
var at: int = 0
|
|
104
|
+
while at < source.length():
|
|
105
|
+
var c: String = source[at]
|
|
106
|
+
var next: String = source[at + 1] if at + 1 < source.length() else ""
|
|
107
|
+
if c == "\n":
|
|
108
|
+
line += 1
|
|
109
|
+
at += 1
|
|
110
|
+
elif c == "#":
|
|
111
|
+
while at < source.length() and source[at] != "\n":
|
|
112
|
+
at += 1
|
|
113
|
+
elif c in " \t\r":
|
|
114
|
+
at += 1
|
|
115
|
+
elif QUOTES.contains(c) or ("r^&".contains(c) and QUOTES.contains(next) and not next.is_empty()):
|
|
116
|
+
var quoted: Array = _string(source, at if QUOTES.contains(c) else at + 1)
|
|
117
|
+
tokens.append([STRING, quoted[0], line])
|
|
118
|
+
line += str(quoted[0]).count("\n")
|
|
119
|
+
at = quoted[1]
|
|
120
|
+
elif c == "." and next == ".":
|
|
121
|
+
tokens.append([MARK, "..", line])
|
|
122
|
+
at += 2
|
|
123
|
+
elif MARKS.contains(c):
|
|
124
|
+
tokens.append([MARK, c, line])
|
|
125
|
+
at += 1
|
|
126
|
+
else:
|
|
127
|
+
var start: int = at
|
|
128
|
+
at += 1
|
|
129
|
+
while at < source.length() and not _ends_a_word(source[at]):
|
|
130
|
+
at += 1
|
|
131
|
+
tokens.append([NAME, source.substr(start, at - start), line])
|
|
132
|
+
return tokens
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
## The string whose opening quote is at [param at], as [what is between its quotes, where it ends].
|
|
136
|
+
## A backslash keeps the character after it inside the string, raw or not, as the tokenizer reads it.
|
|
137
|
+
static func _string(source: String, at: int) -> Array:
|
|
138
|
+
var quote: String = source[at]
|
|
139
|
+
var closing: String = quote.repeat(3) if source.substr(at, 3) == quote.repeat(3) else quote
|
|
140
|
+
var start: int = at + closing.length()
|
|
141
|
+
var end: int = start
|
|
142
|
+
while end < source.length() and source.substr(end, closing.length()) != closing:
|
|
143
|
+
end += 2 if source[end] == "\\" else 1
|
|
144
|
+
return [source.substr(start, end - start), mini(end + closing.length(), source.length())]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
static func _ends_a_word(c: String) -> bool:
|
|
148
|
+
return c in " \t\r\n#" or MARKS.contains(c) or QUOTES.contains(c)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://s4xm1m4xqtxf
|
|
@@ -215,7 +215,8 @@ static func offered(said: String, word: String, names: Array) -> String:
|
|
|
215
215
|
## [param said], the refusal for [param property], which [param target] does not declare, with the
|
|
216
216
|
## names it declares nearest it when any are, as a misspelt one is usually one of them, or else the
|
|
217
217
|
## variables its script declares. The engine's own are not listed when none is near, since an object
|
|
218
|
-
## has a hundred, while a script's are the few a caller most likely meant
|
|
218
|
+
## has a hundred, while a script's are the few a caller most likely meant; an object with no script
|
|
219
|
+
## is told where they are listed instead, since the refusal alone left nothing to try.
|
|
219
220
|
static func near_property(said: String, target: Object, property: String) -> String:
|
|
220
221
|
var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
|
|
221
222
|
var names: Array[String] = []
|
|
@@ -233,8 +234,15 @@ static func near_property(said: String, target: Object, property: String) -> Str
|
|
|
233
234
|
var close: PackedStringArray = nearest(property, PackedStringArray(names))
|
|
234
235
|
if not close.is_empty():
|
|
235
236
|
return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
|
|
236
|
-
|
|
237
|
+
# With a script that declares nothing readable, as one that does not compile, the name is likelier
|
|
238
|
+
# the script's than the class's, so the class's list would point the wrong way.
|
|
239
|
+
if scripted.is_empty() and target.get_script() != null:
|
|
237
240
|
return said
|
|
241
|
+
if scripted.is_empty():
|
|
242
|
+
return (
|
|
243
|
+
"%s; editor_classes info with className %s and includeInherited true lists the ones it has"
|
|
244
|
+
% [said, target.get_class()]
|
|
245
|
+
)
|
|
238
246
|
scripted.sort()
|
|
239
247
|
return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
|
|
240
248
|
|