gdharness 1.3.46 → 1.3.48

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.
@@ -25,29 +25,41 @@ static func save_keeping_uid(resource: Resource, file_path: String) -> Error:
25
25
  var marked: Error = ResourceSaver.set_uid(file_path, uid)
26
26
  if marked != OK:
27
27
  return marked
28
- return _with_external_uids(file_path)
28
+ return _as_the_editor_writes(file_path)
29
29
 
30
30
 
31
- ## Puts back the uid on each external resource the text file at [param file_path] names. Only the
32
- ## editor's file system tells the saver a path's uid, so a headless save writes every reference by
33
- ## path alone, and each breaks when the file it names is moved.
34
- static func _with_external_uids(file_path: String) -> Error:
31
+ ## Writes the text file at [param file_path] as the editor's save would have.
32
+ ##
33
+ ## Only the editor's file system tells the saver a path's uid, so a headless save writes every
34
+ ## reference by path alone, and each breaks when the file it names is moved: the uid is put back on
35
+ ## each external resource.
36
+ ##
37
+ ## The saver leaves out a shader parameter holding the default the renderer gives it, and a headless
38
+ ## engine's renderer gives none, so every parameter a ShaderMaterial never set was written as null.
39
+ ## Loading null clears the parameter, so the line changes nothing, and the editor drops it again.
40
+ static func _as_the_editor_writes(file_path: String) -> Error:
35
41
  if not (file_path.ends_with(".tscn") or file_path.ends_with(".tres")):
36
42
  return OK
37
43
  var lines: PackedStringArray = FileAccess.get_file_as_string(file_path).split("\n")
38
44
  var pattern: RegEx = RegEx.create_from_string(' path="([^"]+)"')
45
+ var unset: RegEx = RegEx.create_from_string("^shader_parameter/\\S+ = null$")
46
+ var kept: PackedStringArray = []
39
47
  var changed: bool = false
40
- for at: int in lines.size():
41
- var line: String = lines[at]
42
- if not line.begins_with("[ext_resource ") or line.contains(' uid="'):
43
- continue
44
- var named: RegExMatch = pattern.search(line)
45
- if named == null:
48
+ for line: String in lines:
49
+ if unset.search(line) != null:
50
+ changed = true
46
51
  continue
47
- var uid: String = reference_uid(named.get_string(1))
52
+ var named: RegExMatch = (
53
+ pattern.search(line)
54
+ if line.begins_with("[ext_resource ") and not line.contains(' uid="')
55
+ else null
56
+ )
57
+ var uid: String = "" if named == null else reference_uid(named.get_string(1))
48
58
  if not uid.is_empty():
49
- lines[at] = line.replace(' path="', uid + ' path="')
59
+ line = line.replace(' path="', uid + ' path="')
50
60
  changed = true
61
+ var _added: bool = kept.append(line)
62
+ lines = kept
51
63
  if not changed:
52
64
  return OK
53
65
  var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE)
@@ -137,13 +137,26 @@ func connect_animation_states(args: Dictionary) -> Dictionary:
137
137
  sm.add_transition(StringName(from_state), StringName(to_state), transition)
138
138
  if not sm.has_transition(StringName(from_state), StringName(to_state)):
139
139
  return scene.refuse("The state machine did not take the transition.")
140
+ _tell_the_tree(sm)
140
141
  var files: Dictionary = found[2]
141
- return _finish(
142
- scene,
143
- Callable(),
144
- files,
145
- { "ok": true, "from": from_state, "to": to_state, "advanceMode": advance_mode }
146
- )
142
+ var made: Dictionary = {
143
+ "ok": true,
144
+ "from": from_state,
145
+ "to": to_state,
146
+ "transitionType": transition_type,
147
+ "advanceMode": advance_mode,
148
+ }
149
+ if not advance_condition.is_empty():
150
+ made["advanceCondition"] = advance_condition
151
+ return _finish(scene, Callable(), files, made)
152
+
153
+
154
+ ## Has the AnimationTree playing [param sm] list its parameters again. Adding or removing a
155
+ ## transition does not say the tree changed, so the save kept the parameters from before it: a
156
+ ## condition's parameter was written by the next call to change the machine, and a removed one's
157
+ ## stayed until then.
158
+ static func _tell_the_tree(sm: AnimationNodeStateMachine) -> void:
159
+ var _told: Error = sm.emit_signal(&"tree_changed")
147
160
 
148
161
 
149
162
  ## Why [param sm] cannot take a transition from [param from_state] to [param to_state], or "".
@@ -270,6 +283,7 @@ func disconnect_animation_states(args: Dictionary) -> Dictionary:
270
283
  sm.remove_transition(StringName(from_state), StringName(to_state))
271
284
  if sm.has_transition(StringName(from_state), StringName(to_state)):
272
285
  return scene.refuse("The state machine did not let go of the transition.")
286
+ _tell_the_tree(sm)
273
287
  var files: Dictionary = found[2]
274
288
  return _finish(
275
289
  scene, Callable(), files, { "ok": true, "removed": { "from": from_state, "to": to_state } }
@@ -109,6 +109,9 @@ func add_animation_track(args: Dictionary) -> Dictionary:
109
109
  if anim.track_get_type(track_idx) == Animation.TYPE_VALUE:
110
110
  var mode: int = anim.value_track_get_update_mode(track_idx)
111
111
  answer["update"] = GdhAnimationReads.UPDATE_MODE_NAMES.get(mode, str(mode))
112
+ var blended: int = anim.track_get_interpolation_type(track_idx)
113
+ answer["interpolation"] = GdhAnimationReads.INTERPOLATION_NAMES.get(blended, str(blended))
114
+ answer["loopWrap"] = anim.track_get_interpolation_loop_wrap(track_idx)
112
115
  if built[3]:
113
116
  answer["existingTrack"] = true
114
117
  var replaced: Array = built[5]
@@ -165,6 +168,7 @@ func _add_track(
165
168
  var kind: Animation.TrackType = Animation.TYPE_VALUE
166
169
  var path: NodePath = NodePath(target_path)
167
170
  var update: int = -1
171
+ var interpolation: int = -1
168
172
  if track_type == "property":
169
173
  var prop_name: String = str(track.get("property", ""))
170
174
  if prop_name.is_empty():
@@ -175,6 +179,11 @@ func _add_track(
175
179
  )
176
180
  if named != null:
177
181
  update = GdhRead.as_int(named)
182
+ var blended: Variant = GdhAnimationReads.INTERPOLATION_NAMES.find_key(
183
+ str(track.get("interpolation", ""))
184
+ )
185
+ if blended != null:
186
+ interpolation = GdhRead.as_int(blended)
178
187
  var values: Array = _key_values(target, prop_name, keyframes)
179
188
  if not values[0]:
180
189
  return _not_added(str(values[1]))
@@ -196,6 +205,12 @@ func _add_track(
196
205
  misfit = _key_misfit(target, method_name, arguments, index)
197
206
  method_found = target.has_method(method_name)
198
207
  kind = Animation.TYPE_METHOD
208
+ # A method key is called at its time and never blended, so neither has anything to set.
209
+ for blending: String in ["interpolation", "loopWrap"]:
210
+ if track.has(blending):
211
+ return _not_added(
212
+ "track.%s is for property tracks; a method key is not blended." % blending
213
+ )
199
214
  else:
200
215
  return _not_added(
201
216
  (
@@ -250,6 +265,10 @@ func _add_track(
250
265
  _as_the_editor_makes(anim, track_idx, player, target)
251
266
  if update >= 0:
252
267
  anim.value_track_set_update_mode(track_idx, update as Animation.UpdateMode)
268
+ if interpolation >= 0:
269
+ anim.track_set_interpolation_type(track_idx, interpolation as Animation.InterpolationType)
270
+ if track.has("loopWrap"):
271
+ anim.track_set_interpolation_loop_wrap(track_idx, GdhRead.as_bool(track["loopWrap"]))
253
272
  for index: int in keyframes.size():
254
273
  var keyframe: Dictionary = keyframes[index]
255
274
  if anim.track_insert_key(track_idx, GdhRead.as_float(keyframe["time"]), keys[index]) < 0:
@@ -718,6 +718,15 @@ func set_node_properties(args: Dictionary) -> Dictionary:
718
718
  if property != "name" and property in node and not named.has(property):
719
719
  named.append(property)
720
720
  var source: String = scene.values_from(node, old_path)
721
+ # Read before the write, which frees the node.
722
+ var unsaved: Array[String] = []
723
+ for declared: Dictionary in node.get_property_list():
724
+ var declared_name: String = str(declared.get("name", ""))
725
+ if (
726
+ named.has(declared_name)
727
+ and not GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE
728
+ ):
729
+ unsaved.append(declared_name)
721
730
  var carried: GdhPaths = GdhPaths.new()
722
731
  var _rewritten: int = carried.carry(
723
732
  scene,
@@ -751,7 +760,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
751
760
  answer["droppedNodes"] = dropped["nodes"]
752
761
  _note_put_back(answer, scene, id, new_path)
753
762
  _note_loads_as(answer, scene, new_path)
754
- _note_left_out(answer, scene.written_pack, new_path, named, source)
763
+ _note_left_out(answer, scene.written_pack, new_path, named, unsaved, source)
755
764
  if new_path != old_path:
756
765
  answer.merge(GdhKin.follow(scene_path, old_path, new_path, carried))
757
766
  return answer
@@ -793,9 +802,15 @@ static func _note_put_back(answer: Dictionary, scene: GdhSceneFile, id: int, at:
793
802
 
794
803
  ## Notes under notStored each of [param named] the file saved from [param pack] does not set on the
795
804
  ## node at [param at]: a set gave it the value the node has without it, so the packer leaves it out,
796
- ## as the editor does, and it follows [param source], or its default, from then on.
805
+ ## as the editor does, and it follows [param source], or its default, from then on. [param unsaved]
806
+ ## are those of them the node never stores by name.
797
807
  static func _note_left_out(
798
- answer: Dictionary, pack: PackedScene, at: String, named: Array[String], source: String
808
+ answer: Dictionary,
809
+ pack: PackedScene,
810
+ at: String,
811
+ named: Array[String],
812
+ unsaved: Array[String],
813
+ source: String
799
814
  ) -> void:
800
815
  var stored: Dictionary = {}
801
816
  var state: SceneState = pack.get_state()
@@ -803,9 +818,13 @@ static func _note_left_out(
803
818
  if str(state.get_node_path(index)).trim_prefix("./") == at:
804
819
  for property: int in state.get_node_property_count(index):
805
820
  stored[str(state.get_node_property_name(index, property))] = true
821
+ # A property the file never holds by name, as a Control's position, which its offsets hold, is
822
+ # stored when what changed with it is.
823
+ var also: Dictionary = answer.get("alsoChanged", {})
824
+ var held_with_it: bool = also.keys().any(func(other: Variant) -> bool: return stored.has(other))
806
825
  var left: Array[String] = []
807
826
  for asked: String in named:
808
- if not stored.has(asked):
827
+ if not stored.has(asked) and not (held_with_it and unsaved.has(asked)):
809
828
  left.append(asked)
810
829
  if left.is_empty():
811
830
  return
@@ -160,7 +160,7 @@ func _hover(viewport: Viewport, point: Vector2) -> void:
160
160
  var inside: Vector2 = container.get_global_transform_with_canvas().affine_inverse() * point
161
161
  if container.stretch:
162
162
  inside /= container.stretch_shrink
163
- for child: Node in container.get_children():
163
+ for child: Node in container.get_children(true):
164
164
  var shown: SubViewport = child as SubViewport
165
165
  if shown != null and not shown.gui_disable_input:
166
166
  _hover(shown, shown.get_final_transform().affine_inverse() * inside)
@@ -179,7 +179,7 @@ func _drop(viewport: Viewport, until: int) -> void:
179
179
  var over: Control = instance_from_id(top) as Control
180
180
  var container: SubViewportContainer = over as SubViewportContainer
181
181
  if container != null:
182
- for child: Node in container.get_children():
182
+ for child: Node in container.get_children(true):
183
183
  var shown: SubViewport = child as SubViewport
184
184
  if shown != null:
185
185
  _drop(shown, 0)
@@ -264,7 +264,7 @@ func _pressed_at(viewport: Viewport, point: Vector2) -> Node:
264
264
  var inside: Vector2 = container.get_global_transform_with_canvas().affine_inverse() * point
265
265
  if container.stretch:
266
266
  inside /= container.stretch_shrink
267
- for child: Node in container.get_children():
267
+ for child: Node in container.get_children(true):
268
268
  var shown: SubViewport = child as SubViewport
269
269
  if shown != null and not shown.gui_disable_input:
270
270
  var within: Node = _pressed_at(
@@ -277,9 +277,11 @@ func _pressed_at(viewport: Viewport, point: Vector2) -> Node:
277
277
 
278
278
  ## The controls [param node] holds that the engine keeps as roots of [param node]'s viewport: drawn
279
279
  ## on their own, or with no control above them. Not into another viewport, a window or a
280
- ## SubViewport, whose controls are that one's.
280
+ ## SubViewport, whose controls are that one's. Internal children count, as they do for the engine,
281
+ ## though get_children leaves them out unless asked: a layer added with INTERNAL_MODE_BACK was
282
+ ## passed over, and the press predicted for the control under it.
281
283
  static func _gather_roots(node: Node, roots: Array[Control]) -> void:
282
- for child: Node in node.get_children():
284
+ for child: Node in node.get_children(true):
283
285
  if child is Viewport:
284
286
  continue
285
287
  var control: Control = child as Control
@@ -331,8 +333,8 @@ func _at(item: CanvasItem, point: Vector2, holder: Transform2D) -> Control:
331
333
  var control: Control = item as Control
332
334
  var local: Vector2 = matrix.affine_inverse() * point
333
335
  if control == null or not control.is_clipping_contents() or _has_point(control, local):
334
- for index: int in range(item.get_child_count() - 1, -1, -1):
335
- var child: CanvasItem = item.get_child(index) as CanvasItem
336
+ for index: int in range(item.get_child_count(true) - 1, -1, -1):
337
+ var child: CanvasItem = item.get_child(index, true) as CanvasItem
336
338
  if child == null or child.top_level:
337
339
  continue
338
340
  var found: Control = _at(child, point, matrix)
@@ -61,9 +61,16 @@ func get_tree(params: Dictionary) -> Dictionary:
61
61
  return reached
62
62
  var root: Node = reached["node"]
63
63
 
64
+ # A game whose first scene hands over to another shows a tree that names neither, so which
65
+ # scene is showing was guessed from the project's documentation.
66
+ var scene: Node = _host.get_tree().current_scene
67
+ var showing: Variant = null
68
+ if scene != null:
69
+ showing = { "path": str(scene.get_path()), "file": scene.scene_file_path }
64
70
  return {
65
71
  "type": "tree",
66
72
  "root": _serialize_node_tree(root, 0, max_depth, include_properties, named),
73
+ "current_scene": showing,
67
74
  }
68
75
 
69
76
 
@@ -17,6 +17,11 @@ const MOST_FRAMES: int = 600
17
17
  ## How long a wait for words leaves between looks, as a multiple of what the last look took: nine
18
18
  ## gives looking about a tenth of the time.
19
19
  const LOOKS_APART: int = 9
20
+ ## The longest a wait for words holds one of the game's frames while it looks, in microseconds: a
21
+ ## look over a screen too large to read in that time carries on in the next frame from where it
22
+ ## stopped. Read whole, a look over an interface of about 1,000 labels held a frame for 28ms every
23
+ ## few dozen, which a game logging its own frame times took for a hitch of its own.
24
+ const LOOK_SLICE_USEC: int = 2000
20
25
  ## How a property wait compares what it reads with the value: equality by default, and order for a
21
26
  ## number that steps rather than lands, as a day counter on a fast clock goes from 31 to 34 between
22
27
  ## two looks and a wait for 32 never sees it.
@@ -785,9 +790,12 @@ static func _declared_slot(holder: Variant, named: String) -> Dictionary:
785
790
  ## looking takes about a tenth of the time. Every frame on a hall of 3,500 nodes held the game at a
786
791
  ## sixth of its frame rate, and its clock, which advances by the frame's delta, lost three quarters
787
792
  ## of its time, so a wait for a date timed out on a clock that turned a day every thirty seconds.
788
- ## The answer says how many frames the wait spanned, how many looks it took and how long they took
789
- ## between them, which is how a caller sees what it cost and whether the game lost its speed to the
790
- ## looks or to something else.
793
+ ## Each look is read a slice at a time, [constant LOOK_SLICE_USEC] to a frame, so the frames it runs
794
+ ## in keep their length; the last is read whole, since the wait answers on it.
795
+ ##
796
+ ## The answer says how many frames the wait spanned, how many looks it took, how long they took
797
+ ## between them and the longest they held one frame, which is how a caller sees what it cost and
798
+ ## whether the game lost its speed, or a frame its length, to the looks or to something else.
791
799
  func _wait_until_said(
792
800
  node_path: String, said: String, timeout_ms: int, include_hidden: bool
793
801
  ) -> Dictionary:
@@ -795,18 +803,29 @@ func _wait_until_said(
795
803
  var frames: int = 0
796
804
  var looks: int = 0
797
805
  var looked_usec: int = 0
806
+ var held_usec: int = 0
798
807
  var next_look: int = 0
799
808
  var looked_at_frame: int = 0
800
809
  var found: bool = false
810
+ var look: GdhLook = null
811
+ var look_began: int = 0
801
812
  while true:
802
- if Time.get_ticks_usec() >= next_look:
803
- var looking: int = Time.get_ticks_usec()
804
- found = _anything_says(node_path, said, include_hidden)
805
- looks += 1
806
- var took: int = Time.get_ticks_usec() - looking
807
- looked_usec += took
808
- next_look = Time.get_ticks_usec() + took * LOOKS_APART
813
+ if look == null and Time.get_ticks_usec() >= next_look:
814
+ look = GdhLook.new(_under(node_path), said, include_hidden)
815
+ look_began = Time.get_ticks_usec()
816
+ if look != null:
817
+ var held: int = look.go_on(LOOK_SLICE_USEC)
818
+ looked_usec += held
819
+ held_usec = maxi(held_usec, held)
809
820
  looked_at_frame = frames
821
+ if look.done:
822
+ looks += 1
823
+ found = look.found
824
+ # From where the look began, since the frames it was read across are the game's:
825
+ # counted from its end, a look spread over a third of a second waited out its nine
826
+ # lengths after that, and a wait on 3,500 nodes looked four times in two seconds.
827
+ next_look = look_began + look.took_usec * (LOOKS_APART + 1)
828
+ look = null
810
829
  if found or Time.get_ticks_msec() - started >= timeout_ms:
811
830
  break
812
831
  await _host.get_tree().process_frame
@@ -815,14 +834,18 @@ func _wait_until_said(
815
834
  # never having come. Taken once a whole frame has run since the last look, which is two frame
816
835
  # starts: a first look that outlasted the timeout otherwise left none, so the wait answered with
817
836
  # the game not having run at all, and a command sent during that look not yet read.
837
+ var heard: bool = found
818
838
  if not found:
819
839
  while frames - looked_at_frame < 2:
820
840
  await _host.get_tree().process_frame
821
841
  frames += 1
822
- var last: int = Time.get_ticks_usec()
823
- found = _anything_says(node_path, said, include_hidden)
842
+ var last: GdhLook = GdhLook.new(_under(node_path), said, include_hidden)
843
+ var held: int = last.go_on(-1)
824
844
  looks += 1
825
- looked_usec += Time.get_ticks_usec() - last
845
+ looked_usec += held
846
+ held_usec = maxi(held_usec, held)
847
+ found = last.found
848
+ heard = last.heard
826
849
 
827
850
  var answer: Dictionary = {
828
851
  "type": "condition",
@@ -834,10 +857,11 @@ func _wait_until_said(
834
857
  "frames": frames,
835
858
  "looks": looks,
836
859
  "looking_ms": looked_usec / 1000.0,
860
+ "longest_hold_ms": held_usec / 1000.0,
837
861
  }
838
862
  # Words that are there and cannot be read look, from a wait that ran out, like words that never
839
863
  # came.
840
- if not found and not include_hidden and _anything_says(node_path, said, true):
864
+ if not found and heard:
841
865
  answer["unseen_note"] = (
842
866
  (
843
867
  "Something under %s says it, but is hidden or drawn outside the viewport or a "
@@ -849,22 +873,8 @@ func _wait_until_said(
849
873
  return answer
850
874
 
851
875
 
852
- ## Whether anything under [param node_path] says [param said], the node itself included, and where
853
- ## the player can read it unless hidden ones are wanted too. Matched as a find and a click match
854
- ## `says`, a glob over the whole text when it has `*` or `?`: read as literal words, a glob a click
855
- ## had just pressed by was waited on for the whole timeout with its label on screen, and answered as
856
- ## words never arriving.
857
- func _anything_says(node_path: String, said: String, include_hidden: bool) -> bool:
858
- var root: Node = _host.get_tree().root.get_node_or_null(node_path)
859
- if root == null:
860
- return false
861
- var pending: Array[Node] = [root]
862
- while not pending.is_empty():
863
- var node: Node = pending.pop_back()
864
- if GdhSays.says(node, said) and (include_hidden or GdhQueries.readable(node)):
865
- return true
866
- pending.append_array(node.get_children(true))
867
- return false
876
+ func _under(node_path: String) -> Node:
877
+ return _host.get_tree().root.get_node_or_null(node_path)
868
878
 
869
879
 
870
880
  ## Remembers that a signal fired and what it carried, for a wait that polls rather than
@@ -942,6 +952,56 @@ class GdhTrace:
942
952
  dropped += 1
943
953
 
944
954
 
955
+ ## One look for whether anything under a node says some words, the node itself included, and where
956
+ ## the player can read it unless hidden ones are wanted too. Matched as a find and a click match
957
+ ## `says`, a glob over the whole text when it has `*` or `?`: read as literal words, a glob a click
958
+ ## had just pressed by was waited on for the whole timeout with its label on screen, and answered as
959
+ ## words never arriving.
960
+ class GdhLook:
961
+ extends RefCounted
962
+ ## Whether a node that counts says the words.
963
+ var found: bool = false
964
+ ## Whether any node says them, hidden or not: words that are there and cannot be read look,
965
+ ## from a wait that ran out, like words that never came.
966
+ var heard: bool = false
967
+ var done: bool = false
968
+ ## How long the look has taken over every frame it has run in.
969
+ var took_usec: int = 0
970
+ var _said: String
971
+ var _include_hidden: bool
972
+ ## Untyped, since a node waiting here can be freed between two frames of the look.
973
+ var _pending: Array = []
974
+
975
+ func _init(root: Node, said: String, include_hidden: bool) -> void:
976
+ _said = said
977
+ _include_hidden = include_hidden
978
+ if root != null:
979
+ _pending.append(root)
980
+
981
+ ## Reads on until the look is done or [param budget_usec] has passed, -1 for no limit, and
982
+ ## answers how long it read.
983
+ func go_on(budget_usec: int) -> int:
984
+ var from: int = Time.get_ticks_usec()
985
+ while not _pending.is_empty():
986
+ if budget_usec >= 0 and Time.get_ticks_usec() - from >= budget_usec:
987
+ break
988
+ var next: Variant = _pending.pop_back()
989
+ if not is_instance_valid(next):
990
+ continue
991
+ var node: Node = next
992
+ if GdhSays.says(node, _said):
993
+ heard = true
994
+ if _include_hidden or GdhQueries.readable(node):
995
+ found = true
996
+ _pending.clear()
997
+ break
998
+ _pending.append_array(node.get_children(true))
999
+ done = _pending.is_empty()
1000
+ var read: int = Time.get_ticks_usec() - from
1001
+ took_usec += read
1002
+ return read
1003
+
1004
+
945
1005
  ## A signal recorded from when it was armed until it is collected.
946
1006
  class GdhWatch:
947
1007
  extends RefCounted
@@ -163,7 +163,7 @@ static func pressable_words(from: Node, open: Callable, most: int) -> String:
163
163
  if offered.size() == most:
164
164
  return ", ".join(offered) + ", and more"
165
165
  offered.append(quoted)
166
- var children: Array[Node] = node.get_children()
166
+ var children: Array[Node] = node.get_children(true)
167
167
  for index: int in range(children.size() - 1, -1, -1):
168
168
  pending.push_front(children[index])
169
169
  return ", ".join(offered)
@@ -16,6 +16,22 @@ const UPDATE_MODE_NAMES: Dictionary[int, String] = {
16
16
  Animation.UPDATE_DISCRETE: "discrete",
17
17
  Animation.UPDATE_CAPTURE: "capture",
18
18
  }
19
+ const INTERPOLATION_NAMES: Dictionary[int, String] = {
20
+ Animation.INTERPOLATION_NEAREST: "nearest",
21
+ Animation.INTERPOLATION_LINEAR: "linear",
22
+ Animation.INTERPOLATION_CUBIC: "cubic",
23
+ Animation.INTERPOLATION_LINEAR_ANGLE: "linear_angle",
24
+ Animation.INTERPOLATION_CUBIC_ANGLE: "cubic_angle",
25
+ }
26
+ ## The track types whose keys are blended between, so how they are and whether a looping animation
27
+ ## blends its last key into its first say how the track moves.
28
+ const INTERPOLATED_TYPES: Array[int] = [
29
+ Animation.TYPE_VALUE,
30
+ Animation.TYPE_POSITION_3D,
31
+ Animation.TYPE_ROTATION_3D,
32
+ Animation.TYPE_SCALE_3D,
33
+ Animation.TYPE_BLEND_SHAPE,
34
+ ]
19
35
  const TRACK_TYPE_NAMES: Dictionary[int, String] = {
20
36
  Animation.TYPE_VALUE: "property",
21
37
  Animation.TYPE_POSITION_3D: "position_3d",
@@ -174,6 +190,10 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
174
190
  if kind == Animation.TYPE_VALUE:
175
191
  var mode: int = anim.value_track_get_update_mode(track)
176
192
  shaped["update"] = UPDATE_MODE_NAMES.get(mode, str(mode))
193
+ if kind in INTERPOLATED_TYPES:
194
+ var interpolation: int = anim.track_get_interpolation_type(track)
195
+ shaped["interpolation"] = INTERPOLATION_NAMES.get(interpolation, str(interpolation))
196
+ shaped["loopWrap"] = anim.track_get_interpolation_loop_wrap(track)
177
197
  var keyframes: Array[Dictionary] = []
178
198
  var methods: Dictionary = {}
179
199
  for key: int in anim.track_get_key_count(track):
@@ -530,6 +530,20 @@ static func _comment_of(line: String) -> String:
530
530
 
531
531
  # How a line that names the resource by path uses it.
532
532
  func _path_reference_kind(line: String, file_path: String) -> String:
533
+ var placed: String = _placed_kind(line, file_path)
534
+ if not placed.is_empty():
535
+ return placed
536
+ var trimmed: String = GdhPatterns.without_annotations(line)
537
+ if "preload(" in trimmed:
538
+ return "preload"
539
+ if "load(" in trimmed:
540
+ return "load"
541
+ return "path"
542
+
543
+
544
+ # The kind a reference on [param line] has by the file it is in or by how the line starts, or ""
545
+ # when neither says.
546
+ static func _placed_kind(line: String, file_path: String) -> String:
533
547
  match file_path.get_file():
534
548
  "project.godot":
535
549
  return "setting"
@@ -546,11 +560,31 @@ func _path_reference_kind(line: String, file_path: String) -> String:
546
560
  return "ext_resource"
547
561
  if trimmed.begins_with("#include"):
548
562
  return "include"
549
- if "preload(" in trimmed:
563
+ return ""
564
+
565
+
566
+ # The kind of the reference [param found] is in [param content], read from [param path], by the
567
+ # call its path is handed to rather than by the line, which can name a second file another way: a
568
+ # path only written in a string, which nothing in the file may load, read as a load.
569
+ static func _forward_kind(content: String, found: RegExMatch, path: String) -> String:
570
+ var matched: String = found.get_string()
571
+ if matched.begins_with("preload("):
550
572
  return "preload"
551
- if "load(" in trimmed:
573
+ if matched.begins_with("load("):
552
574
  return "load"
553
- return "path"
575
+ var start: int = found.get_start()
576
+ var line_start: int = content.rfind("\n", start) + 1
577
+ var line_end: int = content.find("\n", start)
578
+ var line: String = content.substr(
579
+ line_start, (content.length() if line_end == -1 else line_end) - line_start
580
+ )
581
+ var before: String = content.substr(line_start, start - line_start).rstrip("\"'")
582
+ if before.ends_with("preload("):
583
+ return "preload"
584
+ if before.ends_with("load("):
585
+ return "load"
586
+ var placed: String = _placed_kind(line, path)
587
+ return "path" if placed.is_empty() else placed
554
588
 
555
589
 
556
590
  func _regex_escaped(text: String) -> String:
@@ -633,8 +667,14 @@ func _depend(dep_info: Dictionary, current_depth: int, walk: GdhDependencyWalk)
633
667
  # tree answered that file as a dependency of itself.
634
668
  if dep_path in walk.path_stack:
635
669
  var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(dep_path))
636
- cycle.append(dep_path)
637
- if not cycle in walk.circular_references:
670
+ # Kept by a key that is the same wherever the walk entered the cycle: compared as written,
671
+ # one cycle met from two of its files was listed twice, and comparing each against every
672
+ # cycle found so far grew with the square of their number, sixteen thousand on three
673
+ # hundred scripts typed by each other's class names.
674
+ var key: String = _cycle_key(cycle)
675
+ if not walk.cycles_seen.has(key):
676
+ walk.cycles_seen[key] = true
677
+ cycle.append(dep_path)
638
678
  walk.circular_references.append(cycle)
639
679
  dep_info["circular"] = true
640
680
  return false
@@ -660,6 +700,16 @@ func _depend(dep_info: Dictionary, current_depth: int, walk: GdhDependencyWalk)
660
700
  return walked["cut"]
661
701
 
662
702
 
703
+ ## [param cycle], the files of one cycle in the order the walk met them, turned to start at the
704
+ ## first of them in sorting order, so the cycle met from any of its files keys the same.
705
+ static func _cycle_key(cycle: Array[String]) -> String:
706
+ var first: int = 0
707
+ for at: int in cycle.size():
708
+ if cycle[at] < cycle[first]:
709
+ first = at
710
+ return "\n".join(PackedStringArray(cycle.slice(first) + cycle.slice(0, first)))
711
+
712
+
663
713
  ## What [param path] names, each file once, as the entries the tree lists it by; read once a walk.
664
714
  func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
665
715
  if walk.references.has(path):
@@ -688,12 +738,13 @@ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
688
738
  for m: RegExMatch in regex.search_all(content):
689
739
  var written: String = _resolved(_referenced_path(m.get_string()), path)
690
740
  var dep_path: String = str(moved.get(written, written))
741
+ var kind: String = _forward_kind(content, m, path)
691
742
 
692
743
  # A uid the project does not know names no file, and loading it fails: passed over
693
744
  # as no res:// path, a script's load of one went unsaid while the game printed
694
745
  # "Unrecognized UID" at that line.
695
746
  if dep_path.begins_with("uid://"):
696
- _name(deps, path, dep_path, {})
747
+ _name(deps, path, dep_path, { "kind": kind })
697
748
  continue
698
749
  if not dep_path.begins_with("res://"):
699
750
  continue
@@ -709,7 +760,9 @@ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
709
760
  if not walk.include_built_in and dep_path.begins_with("res://."):
710
761
  continue
711
762
 
712
- var named_by: Dictionary = { "stale_path": written } if dep_path != written else {}
763
+ var named_by: Dictionary = { "kind": kind }
764
+ if dep_path != written:
765
+ named_by["stale_path"] = written
713
766
  if unknown.has(dep_path):
714
767
  named_by["unknown_uid"] = unknown[dep_path]
715
768
  _name(deps, path, dep_path, named_by)
@@ -730,8 +783,8 @@ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
730
783
 
731
784
  # Adds [param dep_path] to [param deps], what [param path] depends on, once however many times it
732
785
  # is named. [param named_by] is the class or autoload name it was named by, as by_class or
733
- # by_autoload, and the path written when the engine loads another, as stale_path; empty for a path
734
- # that is the file.
786
+ # by_autoload, or for a path its kind, with the path written when the engine loads another as
787
+ # stale_path.
735
788
  func _name(deps: Array[Dictionary], path: String, dep_path: String, named_by: Dictionary) -> void:
736
789
  if dep_path == path:
737
790
  return
@@ -962,6 +1015,9 @@ class GdhDependencyWalk:
962
1015
 
963
1016
  var circular_references: Array
964
1017
 
1018
+ ## Each cycle in circular_references, by [method _cycle_key].
1019
+ var cycles_seen: Dictionary[String, bool] = {}
1020
+
965
1021
  func _init(p_max_depth: int, p_include_built_in: bool, p_circular_references: Array) -> void:
966
1022
  max_depth = p_max_depth
967
1023
  include_built_in = p_include_built_in