gdharness 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/build/cli.js +163 -85
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +14 -11
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +157 -4
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +15 -3
  5. package/build/godot/addons/gdharness_editor/file_walk.gd +134 -0
  6. package/build/godot/addons/gdharness_editor/file_walk.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/headless_check.gd +44 -23
  8. package/build/godot/addons/gdharness_editor/property_paths.gd +225 -0
  9. package/build/godot/addons/gdharness_editor/property_paths.gd.uid +1 -0
  10. package/build/godot/addons/gdharness_editor/property_values.gd +249 -227
  11. package/build/godot/addons/gdharness_editor/reading.gd +15 -0
  12. package/build/godot/addons/gdharness_editor/same_values.gd +177 -0
  13. package/build/godot/addons/gdharness_editor/same_values.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_editor/scene_file.gd +100 -15
  15. package/build/godot/addons/gdharness_editor/serialisation.gd +75 -3
  16. package/build/godot/addons/gdharness_editor/tool_executor.gd +12 -3
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +402 -55
  18. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -2
  19. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +91 -0
  20. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +88 -5
  22. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +102 -337
  23. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +363 -0
  24. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd.uid +1 -0
  25. package/build/godot/addons/gdharness_runtime/reading.gd +15 -0
  26. package/build/godot/addons/gdharness_runtime/runtime_input.gd +10 -26
  27. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +3 -2
  28. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +2 -1
  29. package/build/godot/addons/gdharness_runtime/runtime_values.gd +30 -0
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +75 -3
  31. package/build/godot/operations/animation_reads.gd +14 -11
  32. package/build/godot/operations/declared_values.gd +157 -4
  33. package/build/godot/operations/dependencies.gd +14 -39
  34. package/build/godot/operations/editor_tools.gd +7 -3
  35. package/build/godot/operations/file_reads.gd +15 -3
  36. package/build/godot/operations/file_walk.gd +1 -0
  37. package/build/godot/operations/gdscript_analysis.gd +229 -75
  38. package/build/godot/operations/gdscript_authoring.gd +142 -34
  39. package/build/godot/operations/import_pipeline.gd +17 -6
  40. package/build/godot/operations/logger.gd +7 -0
  41. package/build/godot/operations/property_paths.gd +225 -0
  42. package/build/godot/operations/property_values.gd +249 -227
  43. package/build/godot/operations/reading.gd +15 -0
  44. package/build/godot/operations/same_values.gd +177 -0
  45. package/build/godot/operations/serialisation.gd +75 -3
  46. package/package.json +1 -1
@@ -14,6 +14,9 @@ const GdhSerialisation = preload("../serialisation.gd")
14
14
  ## How far right of the rightmost state a new one goes in the editor's graph, wider than a state's
15
15
  ## box there.
16
16
  const STATE_SPACING: float = 200.0
17
+ const LOOP_MODES: Dictionary = {
18
+ "none": Animation.LOOP_NONE, "linear": Animation.LOOP_LINEAR, "pingpong": Animation.LOOP_PINGPONG
19
+ }
17
20
 
18
21
  var _editor_plugin: EditorPlugin = null
19
22
  var _properties: GdhPropertyValues = GdhPropertyValues.new()
@@ -26,18 +29,6 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
26
29
  # =============================================================================
27
30
  # Shared helpers
28
31
  # =============================================================================
29
- func _ensure_res_path(path: String) -> String:
30
- if not path.begins_with("res://"):
31
- return "res://" + path
32
- return path
33
-
34
-
35
- func _find_node(root: Node, path: String) -> Node:
36
- if path == "." or path.is_empty():
37
- return root
38
- return root.get_node_or_null(path)
39
-
40
-
41
32
  ## [param scene_path] opened for writing, with the node at [param node_path] in it, which must be a
42
33
  ## [param kind] the scene owns. Answers [scene, node, refusal]; the refusal is {} when both were
43
34
  ## found.
@@ -46,7 +37,7 @@ func _open_at(scene_path: String, node_path: String, kind: String) -> Array:
46
37
  var refused: Dictionary = scene.open(scene_path, true)
47
38
  if not refused.is_empty():
48
39
  return [scene, null, refused]
49
- var node: Node = _find_node(scene.root, node_path)
40
+ var node: Node = GdhSceneFile.node_at(scene.root, node_path)
50
41
  if node == null or not node.is_class(kind):
51
42
  return [scene, null, _not_of_kind(scene, node, node_path, kind)]
52
43
  var foreign: String = scene.foreign(node)
@@ -62,7 +53,7 @@ func _read_at(scene_path: String, node_path: String, kind: String) -> Array:
62
53
  var refused: Dictionary = scene.open(scene_path, false)
63
54
  if not refused.is_empty():
64
55
  return [scene, null, refused]
65
- var node: Node = _find_node(scene.root, node_path)
56
+ var node: Node = GdhSceneFile.node_at(scene.root, node_path)
66
57
  if node == null or not node.is_class(kind):
67
58
  return [scene, null, _not_of_kind(scene, node, node_path, kind)]
68
59
  return [scene, node, {}]
@@ -230,7 +221,7 @@ func _animated(player: AnimationPlayer, track_path: String) -> Node:
230
221
  ## The animations of an AnimationPlayer, or one of them whole. A track comes back in the shape
231
222
  ## add_track takes, so one read here can be written to another animation as it stands.
232
223
  func read_animations(args: Dictionary) -> Dictionary:
233
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
224
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
234
225
  var player_node_path: String = str(args.get("playerNodePath", "."))
235
226
  var animation_name: String = str(args.get("animationName", ""))
236
227
  if scene_path.strip_edges() == "res://":
@@ -255,7 +246,7 @@ func _read_in(scene: GdhSceneFile, answer: Dictionary) -> Dictionary:
255
246
 
256
247
  ## The states and transitions of a state machine in an AnimationTree.
257
248
  func read_animation_states(args: Dictionary) -> Dictionary:
258
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
249
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
259
250
  var anim_tree_path: String = str(args.get("animTreePath", ""))
260
251
  var state_machine_path: String = str(args.get("stateMachinePath", ""))
261
252
  if scene_path.strip_edges() == "res://":
@@ -275,7 +266,7 @@ func read_animation_states(args: Dictionary) -> Dictionary:
275
266
  # create_animation
276
267
  # =============================================================================
277
268
  func create_animation(args: Dictionary) -> Dictionary:
278
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
269
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
279
270
  var player_node_path: String = str(args.get("playerNodePath", "."))
280
271
  var animation_name: String = str(args.get("animationName", ""))
281
272
  var loop_mode_name: String = str(args.get("loopMode", "none"))
@@ -284,11 +275,12 @@ func create_animation(args: Dictionary) -> Dictionary:
284
275
  return {"ok": false, "error": "Missing scenePath"}
285
276
  if animation_name.strip_edges().is_empty():
286
277
  return {"ok": false, "error": "Missing animationName"}
287
- var loop_modes: Dictionary = {
288
- "none": Animation.LOOP_NONE, "linear": Animation.LOOP_LINEAR, "pingpong": Animation.LOOP_PINGPONG
289
- }
278
+ var loop_modes: Dictionary = LOOP_MODES
290
279
  if not loop_modes.has(loop_mode_name):
291
280
  return {"ok": false, "error": "Unsupported loopMode: " + loop_mode_name}
281
+ var timing: String = _timing_refusal(args)
282
+ if not timing.is_empty():
283
+ return {"ok": false, "error": timing}
292
284
 
293
285
  var opened: Array = _open_at(scene_path, player_node_path, "AnimationPlayer")
294
286
  var scene: GdhSceneFile = opened[0]
@@ -331,7 +323,7 @@ func create_animation(args: Dictionary) -> Dictionary:
331
323
  # add_animation_track
332
324
  # =============================================================================
333
325
  func add_animation_track(args: Dictionary) -> Dictionary:
334
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
326
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
335
327
  var player_node_path: String = str(args.get("playerNodePath", "."))
336
328
  var animation_name: String = str(args.get("animationName", ""))
337
329
  var given_track: Variant = args.get("track", {})
@@ -347,15 +339,13 @@ func add_animation_track(args: Dictionary) -> Dictionary:
347
339
  if not keyed[0]:
348
340
  return {"ok": false, "error": keyed[1]}
349
341
 
350
- var opened: Array = _open_at(scene_path, player_node_path, "AnimationPlayer")
342
+ # Asked of the player, which knows every library: looking only in the default one refused
343
+ # "moves/walk" as missing.
344
+ var opened: Array = _animation_at(scene_path, player_node_path, animation_name)
351
345
  var scene: GdhSceneFile = opened[0]
352
346
  if opened[1] == null:
353
347
  return opened[2]
354
348
  var player: AnimationPlayer = opened[1]
355
- # Asked of the player, which knows every library: looking only in the default one refused
356
- # "moves/walk" as missing.
357
- if not player.has_animation(StringName(animation_name)):
358
- return scene.refuse("%s has no animation %s" % [player_node_path, animation_name])
359
349
  var anim: Animation = player.get_animation(StringName(animation_name))
360
350
  var homes: Array = _homes(
361
351
  scene, [[_library_of(player, animation_name), "animation library"], [anim, "animation"]]
@@ -394,55 +384,129 @@ func add_animation_track(args: Dictionary) -> Dictionary:
394
384
  return "the track would not be in %s when the scene loads." % animation_name
395
385
  return ""
396
386
  var answer: Dictionary = {
397
- "ok": true, "trackType": str(track.get("type", "")), "trackIndex": track_idx, "keys": keys
387
+ "ok": true,
388
+ "trackType": str(track.get("type", "")),
389
+ "trackIndex": track_idx,
390
+ "keys": keys,
391
+ "keysAdded": keyframes.size(),
398
392
  }
393
+ if built[3]:
394
+ answer["existingTrack"] = true
399
395
  if built[2] != null:
400
396
  answer["methodFound"] = built[2]
397
+ var past: String = _past_length(anim, animation_name)
398
+ if not past.is_empty():
399
+ answer["pastLength"] = past
401
400
  var files: Dictionary = homes[1]
402
401
  return _finish(scene, check, files, answer)
403
402
 
404
403
 
405
- ## Adds [param track] to [param anim] with [param keyframes], as [index, why not, methodFound]:
406
- ## methodFound is null for a property track.
404
+ ## Adds [param track] to [param anim] with [param keyframes], as [index, why not, methodFound,
405
+ ## existing]: methodFound is null for a property track, and existing is whether the keys went into
406
+ ## a track the animation already had for that path.
407
+ ##
408
+ ## Into that track rather than a second one beside it, as the editor keys a property it already
409
+ ## animates: two tracks for one path both play, the later overriding the earlier, and a key meant
410
+ ## to extend the motion was split off where reading the animation showed two halves.
407
411
  func _add_track(
408
412
  anim: Animation, track: Dictionary, target: Node, target_path: String, keyframes: Array
409
413
  ) -> Array:
410
414
  var track_type: String = str(track.get("type", ""))
411
415
  var keys: Array = []
412
416
  var method_found: Variant = null
413
- var track_idx: int = -1
417
+ var kind: Animation.TrackType = Animation.TYPE_VALUE
418
+ var path: NodePath = NodePath(target_path)
414
419
  if track_type == "property":
415
420
  var prop_name: String = str(track.get("property", ""))
416
421
  if prop_name.is_empty():
417
- return [-1, "track.property is required for property track", null]
422
+ return [-1, "track.property is required for property track", null, false]
418
423
  var values: Array = _key_values(target, prop_name, keyframes)
419
424
  if not values[0]:
420
- return [-1, values[1], null]
425
+ return [-1, values[1], null, false]
421
426
  keys = values[1]
422
- track_idx = anim.add_track(Animation.TYPE_VALUE)
423
- anim.track_set_path(track_idx, NodePath(target_path + ":" + prop_name))
427
+ path = NodePath(target_path + ":" + prop_name)
424
428
  elif track_type == "method":
425
429
  var method_name: String = str(track.get("method", ""))
426
430
  if method_name.is_empty():
427
- return [-1, "track.method is required for method track", null]
431
+ return [-1, "track.method is required for method track", null, false]
428
432
  for index: int in keyframes.size():
429
433
  var keyframe: Dictionary = keyframes[index]
430
434
  var given: Variant = keyframe.get("args", [])
431
435
  if not given is Array:
432
- return [-1, "keyframes[%d].args must be a list." % index, null]
436
+ return [-1, "keyframes[%d].args must be a list." % index, null, false]
433
437
  var arguments: Array = given
434
438
  keys.append({"method": method_name, "args": _parse_method_args(arguments)})
435
439
  method_found = target.has_method(method_name)
436
- track_idx = anim.add_track(Animation.TYPE_METHOD)
437
- anim.track_set_path(track_idx, NodePath(target_path))
440
+ kind = Animation.TYPE_METHOD
438
441
  else:
439
- return [-1, "Unsupported track.type: " + track_type, null]
442
+ return [-1, "Unsupported track.type: " + track_type, null, false]
440
443
 
444
+ var track_idx: int = anim.find_track(path, kind)
445
+ var existing: bool = track_idx >= 0
446
+ if existing:
447
+ # Checked before any key goes in, since track_insert_key replaces a key at the same time
448
+ # without a word, and a refusal partway would leave the editor's scene half changed.
449
+ for index: int in keyframes.size():
450
+ var keyframe: Dictionary = keyframes[index]
451
+ var time: float = GdhRead.as_float(keyframe["time"])
452
+ if anim.track_find_key(track_idx, time, Animation.FIND_MODE_APPROX) >= 0:
453
+ return [
454
+ -1,
455
+ (
456
+ (
457
+ "Track %d already animates %s and has a key at %s s, which keyframes[%d] would "
458
+ + "replace. Nothing was written."
459
+ )
460
+ % [track_idx, str(path), _seconds(time), index]
461
+ ),
462
+ null,
463
+ false
464
+ ]
465
+ else:
466
+ track_idx = anim.add_track(kind)
467
+ anim.track_set_path(track_idx, path)
441
468
  for index: int in keyframes.size():
442
469
  var keyframe: Dictionary = keyframes[index]
443
470
  if anim.track_insert_key(track_idx, GdhRead.as_float(keyframe["time"]), keys[index]) < 0:
444
- return [-1, "Keyframe %d could not be inserted." % index, null]
445
- return [track_idx, "", method_found]
471
+ return [-1, "Keyframe %d could not be inserted." % index, null, false]
472
+ return [track_idx, "", method_found, existing]
473
+
474
+
475
+ ## The keys of [param anim], called [param animation_name], that lie past its end, where they never
476
+ ## play, as a sentence naming each by its track's path and time, or "" when none does. The engine
477
+ ## keeps such a key, as the editor does, so a longer length plays it again.
478
+ static func _past_length(anim: Animation, animation_name: String) -> String:
479
+ var past: PackedStringArray = []
480
+ for track: int in anim.get_track_count():
481
+ for key: int in anim.track_get_key_count(track):
482
+ var time: float = anim.track_get_key_time(track, key)
483
+ if time > anim.length + 0.0001:
484
+ var _added: bool = past.append(
485
+ "%s at %s s" % [str(anim.track_get_path(track)), _seconds(time)]
486
+ )
487
+ if past.is_empty():
488
+ return ""
489
+ return (
490
+ (
491
+ "%s is %s s long, and %s past its end, where %s: %s. %s; scene_animation set "
492
+ + "with a longer length plays %s."
493
+ )
494
+ % [
495
+ animation_name,
496
+ _seconds(anim.length),
497
+ "this key is" if past.size() == 1 else "these keys are",
498
+ "it never plays" if past.size() == 1 else "they never play",
499
+ ", ".join(past),
500
+ "It is kept" if past.size() == 1 else "They are kept",
501
+ "it" if past.size() == 1 else "them",
502
+ ]
503
+ )
504
+
505
+
506
+ ## [param seconds] as a caller wrote it: a key at 2 reads 2, not 2.0.
507
+ static func _seconds(seconds: float) -> String:
508
+ var rounded: float = snappedf(seconds, 0.0001)
509
+ return str(int(rounded)) if rounded == floorf(rounded) else str(rounded)
446
510
 
447
511
 
448
512
  ## The keyframes of [param track], as [true, list] or [false, why]. Each needs its own time: one
@@ -477,7 +541,7 @@ func _key_values(target: Node, prop_name: String, keyframes: Array) -> Array:
477
541
  var base: String = prop_name.get_slice(":", 0)
478
542
  var declared: Dictionary = _properties.declaration(target, base)
479
543
  if declared.is_empty():
480
- return [false, "%s has no property %s" % [target.get_class(), base]]
544
+ return [false, GdhPropertyValues.no_property(target, base)]
481
545
  var values: Array = []
482
546
  for index: int in keyframes.size():
483
547
  var keyframe: Dictionary = keyframes[index]
@@ -496,11 +560,279 @@ func _key_values(target: Node, prop_name: String, keyframes: Array) -> Array:
496
560
  return [true, values]
497
561
 
498
562
 
563
+ # =============================================================================
564
+ # set_animation, rename_animation, remove_animation
565
+ # =============================================================================
566
+ ## Why the length or step in [param args] cannot be taken, or "". The engine raises a length below
567
+ ## a thousandth of a second to that without a word.
568
+ static func _timing_refusal(args: Dictionary) -> String:
569
+ if args.has("length"):
570
+ var length: Variant = args["length"]
571
+ if not (length is float or length is int) or GdhRead.as_float(length) <= 0.0:
572
+ return "length is the animation's length in seconds, more than 0, and %s is not." % _given(length)
573
+ if args.has("step"):
574
+ var step: Variant = args["step"]
575
+ if not (step is float or step is int) or GdhRead.as_float(step) < 0.0:
576
+ return "step is the keyframe snap in seconds, 0 or more, and %s is not." % _given(step)
577
+ return ""
578
+
579
+
580
+ static func _given(value: Variant) -> String:
581
+ if value is float or value is int:
582
+ return _seconds(GdhRead.as_float(value))
583
+ return JSON.stringify(value)
584
+
585
+
586
+ ## [param scene_path] opened for writing with the player at [param player_node_path], which must
587
+ ## have [param animation_name], as [method _open_at] answers.
588
+ func _animation_at(scene_path: String, player_node_path: String, animation_name: String) -> Array:
589
+ var opened: Array = _open_at(scene_path, player_node_path, "AnimationPlayer")
590
+ if opened[1] == null:
591
+ return opened
592
+ var scene: GdhSceneFile = opened[0]
593
+ var player: AnimationPlayer = opened[1]
594
+ if player.has_animation(StringName(animation_name)):
595
+ return opened
596
+ var said: String = GdhAnimationReads.no_animation(player, player_node_path, animation_name)
597
+ return [scene, null, scene.refuse(said)]
598
+
599
+
600
+ ## Changes the length, loop mode or step of an animation, naming any key a shorter length leaves
601
+ ## past the end.
602
+ func set_animation(args: Dictionary) -> Dictionary:
603
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
604
+ var player_node_path: String = str(args.get("playerNodePath", "."))
605
+ var animation_name: String = str(args.get("animationName", ""))
606
+ var loop_mode_name: String = str(args.get("loopMode", ""))
607
+ if scene_path.strip_edges() == "res://":
608
+ return {"ok": false, "error": "Missing scenePath"}
609
+ if animation_name.strip_edges().is_empty():
610
+ return {"ok": false, "error": "Missing animationName"}
611
+ if not (args.has("length") or args.has("loopMode") or args.has("step")):
612
+ return {"ok": false, "error": "set changes length, loopMode or step, and was given none of them."}
613
+ if args.has("loopMode") and not LOOP_MODES.has(loop_mode_name):
614
+ return {"ok": false, "error": "Unsupported loopMode: " + loop_mode_name}
615
+ var timing: String = _timing_refusal(args)
616
+ if not timing.is_empty():
617
+ return {"ok": false, "error": timing}
618
+
619
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
620
+ if found[1] == null:
621
+ return found[2]
622
+ var scene: GdhSceneFile = found[0]
623
+ var player: AnimationPlayer = found[1]
624
+ var anim: Animation = player.get_animation(StringName(animation_name))
625
+ var homes: Array = _homes(
626
+ scene, [[_library_of(player, animation_name), "animation library"], [anim, "animation"]]
627
+ )
628
+ if not homes[0]:
629
+ return scene.refuse(str(homes[1]))
630
+ if args.has("length"):
631
+ anim.length = GdhRead.as_float(args["length"])
632
+ if args.has("loopMode"):
633
+ anim.loop_mode = GdhRead.as_int(LOOP_MODES[loop_mode_name]) as Animation.LoopMode
634
+ if args.has("step"):
635
+ anim.step = GdhRead.as_float(args["step"])
636
+
637
+ var length: float = anim.length
638
+ var at: String = scene.path_of(player)
639
+ var check: Callable = func(saved: Node) -> String:
640
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
641
+ if copy == null or not copy.has_animation(StringName(animation_name)):
642
+ return "%s would not be in the scene when it loads." % animation_name
643
+ if not is_equal_approx(copy.get_animation(StringName(animation_name)).length, length):
644
+ return "%s would not be %s s long when the scene loads." % [animation_name, _seconds(length)]
645
+ return ""
646
+ var answer: Dictionary = {
647
+ "ok": true,
648
+ "animationName": animation_name,
649
+ "length": anim.length,
650
+ "loopMode": GdhAnimationReads.LOOP_MODE_NAMES.get(anim.loop_mode, str(anim.loop_mode)),
651
+ "step": anim.step,
652
+ }
653
+ var past: String = _past_length(anim, animation_name)
654
+ if not past.is_empty():
655
+ answer["pastLength"] = past
656
+ var files: Dictionary = homes[1]
657
+ return _finish(scene, check, files, answer)
658
+
659
+
660
+ ## Renames an animation within its library, taking along the player's autoplay and every state of
661
+ ## the scene's trees that plays it, since a state naming an animation the player lacks plays nothing
662
+ ## and says so only in the editor's log.
663
+ func rename_animation(args: Dictionary) -> Dictionary:
664
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
665
+ var player_node_path: String = str(args.get("playerNodePath", "."))
666
+ var animation_name: String = str(args.get("animationName", ""))
667
+ var new_name: String = str(args.get("newName", "")).strip_edges()
668
+ if scene_path.strip_edges() == "res://":
669
+ return {"ok": false, "error": "Missing scenePath"}
670
+ if animation_name.strip_edges().is_empty():
671
+ return {"ok": false, "error": "Missing animationName"}
672
+ if new_name.is_empty():
673
+ return {"ok": false, "error": "Missing newName"}
674
+ var library_name: String = animation_name.get_slice("/", 0) if animation_name.contains("/") else ""
675
+ var prefix: String = "" if library_name.is_empty() else library_name + "/"
676
+ if not prefix.is_empty() and new_name.begins_with(prefix):
677
+ new_name = new_name.trim_prefix(prefix)
678
+ if new_name.contains("/"):
679
+ return {
680
+ "ok": false,
681
+ "error":
682
+ (
683
+ (
684
+ "rename keeps an animation in its library (%s), so newName is its name there, with "
685
+ + "no library before a slash."
686
+ )
687
+ % ("the default one" if library_name.is_empty() else library_name)
688
+ ),
689
+ }
690
+ for forbidden: String in [":", ",", "["]:
691
+ if new_name.contains(forbidden):
692
+ return {"ok": false, "error": "An animation's name cannot hold : , or [, and %s does." % new_name}
693
+ var new_full: String = prefix + new_name
694
+
695
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
696
+ if found[1] == null:
697
+ return found[2]
698
+ var scene: GdhSceneFile = found[0]
699
+ var player: AnimationPlayer = found[1]
700
+ if new_full == animation_name:
701
+ return scene.refuse("%s is already called %s." % [animation_name, new_full])
702
+ if player.has_animation(StringName(new_full)):
703
+ return scene.refuse("%s already has an animation %s." % [player_node_path, new_full])
704
+ var library: AnimationLibrary = _library_of(player, animation_name)
705
+ var states: Array = _states_playing(scene, player, animation_name)
706
+ var held: Array = [[library, "animation library"]]
707
+ for state: Array in states:
708
+ held.append([state[0], "state playing %s" % animation_name])
709
+ var homes: Array = _homes(scene, held)
710
+ if not homes[0]:
711
+ return scene.refuse(str(homes[1]))
712
+
713
+ # Read before the rename, since the player moves its autoplay to the new name on its own; it
714
+ # does not clear one when an animation is removed.
715
+ var autoplayed: bool = str(player.autoplay) == animation_name
716
+ library.rename_animation(StringName(animation_name.trim_prefix(prefix)), StringName(new_name))
717
+ if not player.has_animation(StringName(new_full)):
718
+ return scene.refuse("The library did not take the name %s." % new_full)
719
+ var answer: Dictionary = {"ok": true, "animationName": new_full, "was": animation_name}
720
+ if autoplayed:
721
+ answer["autoplay"] = new_full
722
+ var followed: Array = []
723
+ for state: Array in states:
724
+ var plays: AnimationNodeAnimation = state[0]
725
+ plays.animation = StringName(new_full)
726
+ followed.append(state[1])
727
+ if not followed.is_empty():
728
+ answer["statesFollowed"] = followed
729
+
730
+ var at: String = scene.path_of(player)
731
+ var check: Callable = func(saved: Node) -> String:
732
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
733
+ if copy == null or not copy.has_animation(StringName(new_full)):
734
+ return "%s would not be in the scene when it loads." % new_full
735
+ return ""
736
+ var files: Dictionary = homes[1]
737
+ return _finish(scene, check, files, answer)
738
+
739
+
740
+ ## Removes an animation, clearing the player's autoplay when it named it and naming the states of
741
+ ## the scene's trees left playing nothing.
742
+ func remove_animation(args: Dictionary) -> Dictionary:
743
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
744
+ var player_node_path: String = str(args.get("playerNodePath", "."))
745
+ var animation_name: String = str(args.get("animationName", ""))
746
+ if scene_path.strip_edges() == "res://":
747
+ return {"ok": false, "error": "Missing scenePath"}
748
+ if animation_name.strip_edges().is_empty():
749
+ return {"ok": false, "error": "Missing animationName"}
750
+
751
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
752
+ if found[1] == null:
753
+ return found[2]
754
+ var scene: GdhSceneFile = found[0]
755
+ var player: AnimationPlayer = found[1]
756
+ var library: AnimationLibrary = _library_of(player, animation_name)
757
+ var homes: Array = _homes(scene, [[library, "animation library"]])
758
+ if not homes[0]:
759
+ return scene.refuse(str(homes[1]))
760
+ var called: String = animation_name.get_slice("/", 1) if animation_name.contains("/") else animation_name
761
+ var autoplayed: bool = str(player.autoplay) == animation_name
762
+ library.remove_animation(StringName(called))
763
+ if player.has_animation(StringName(animation_name)):
764
+ return scene.refuse("The library did not let go of %s." % animation_name)
765
+ var answer: Dictionary = {"ok": true, "removed": animation_name}
766
+ if autoplayed:
767
+ player.autoplay = ""
768
+ answer["autoplayCleared"] = true
769
+ var orphaned: Array = []
770
+ for state: Array in _states_playing(scene, player, animation_name):
771
+ orphaned.append(state[1])
772
+ if not orphaned.is_empty():
773
+ answer["statesPlayingNothing"] = orphaned
774
+
775
+ var at: String = scene.path_of(player)
776
+ var check: Callable = func(saved: Node) -> String:
777
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
778
+ if copy == null or copy.has_animation(StringName(animation_name)):
779
+ return "%s would still be in the scene when it loads." % animation_name
780
+ return ""
781
+ var files: Dictionary = homes[1]
782
+ return _finish(scene, check, files, answer)
783
+
784
+
785
+ ## The states of [param scene]'s AnimationTrees that play [param animation_name] from [param player],
786
+ ## as [[node, {animTreePath, state}]], a state named by the path of the nodes leading to it.
787
+ func _states_playing(scene: GdhSceneFile, player: AnimationPlayer, animation_name: String) -> Array:
788
+ var found: Array = []
789
+ var trees: Array[Node] = scene.root.find_children("*", "AnimationTree", true, false)
790
+ if scene.root is AnimationTree:
791
+ trees.append(scene.root)
792
+ for node: Node in trees:
793
+ var tree: AnimationTree = node
794
+ if tree.tree_root == null or tree.get_node_or_null(tree.anim_player) != player:
795
+ continue
796
+ var tree_path: String = str(scene.root.get_path_to(tree))
797
+ _collect_playing(tree.tree_root, "", tree_path, animation_name, found)
798
+ return found
799
+
800
+
801
+ static func _collect_playing(
802
+ node: AnimationNode, at: String, tree_path: String, animation_name: String, found: Array
803
+ ) -> void:
804
+ if node is AnimationNodeAnimation:
805
+ var plays: AnimationNodeAnimation = node
806
+ if plays.animation == StringName(animation_name):
807
+ found.append([plays, {"animTreePath": tree_path, "state": "." if at.is_empty() else at}])
808
+ return
809
+ var children: Dictionary = {}
810
+ if node is AnimationNodeStateMachine:
811
+ var machine: AnimationNodeStateMachine = node
812
+ for state: StringName in machine.get_node_list():
813
+ children[str(state)] = machine.get_node(state)
814
+ elif node is AnimationNodeBlendTree:
815
+ var blend: AnimationNodeBlendTree = node
816
+ for input: StringName in blend.get_node_list():
817
+ children[str(input)] = blend.get_node(input)
818
+ elif node is AnimationNodeBlendSpace1D:
819
+ var line: AnimationNodeBlendSpace1D = node
820
+ for point: int in line.get_blend_point_count():
821
+ children[str(point)] = line.get_blend_point_node(point)
822
+ elif node is AnimationNodeBlendSpace2D:
823
+ var plane: AnimationNodeBlendSpace2D = node
824
+ for point: int in plane.get_blend_point_count():
825
+ children[str(point)] = plane.get_blend_point_node(point)
826
+ for step: String in children:
827
+ var child: AnimationNode = children[step]
828
+ _collect_playing(child, step if at.is_empty() else at + "/" + step, tree_path, animation_name, found)
829
+
830
+
499
831
  # =============================================================================
500
832
  # add_animation_state
501
833
  # =============================================================================
502
834
  func add_animation_state(args: Dictionary) -> Dictionary:
503
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
835
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
504
836
  var anim_tree_path: String = str(args.get("animTreePath", ""))
505
837
  var state_name: String = str(args.get("stateName", ""))
506
838
  var animation_name: String = str(args.get("animationName", ""))
@@ -528,15 +860,7 @@ func add_animation_state(args: Dictionary) -> Dictionary:
528
860
  if sm.has_node(StringName(state_name)):
529
861
  return scene.refuse("The state machine already has a state named %s." % state_name)
530
862
  var player: AnimationPlayer = anim_tree.get_node_or_null(anim_tree.anim_player) as AnimationPlayer
531
- if player != null and not player.has_animation(StringName(animation_name)):
532
- # By its path from the scene root, as playerNodePath takes it; anim_player is relative to the
533
- # tree ("../Anim"), which no argument takes.
534
- return scene.refuse(
535
- (
536
- "The AnimationPlayer at %s has no animation %s for the state to play."
537
- % [str(scene.root.get_path_to(player)), animation_name]
538
- )
539
- )
863
+ var found: bool = player != null and player.has_animation(StringName(animation_name))
540
864
 
541
865
  var anim_node: AnimationNodeAnimation = AnimationNodeAnimation.new()
542
866
  anim_node.animation = StringName(animation_name)
@@ -556,19 +880,42 @@ func add_animation_state(args: Dictionary) -> Dictionary:
556
880
  "ok": true,
557
881
  "stateName": state_name,
558
882
  "animationName": animation_name,
559
- # False when the tree has no player to look the animation up in.
560
- "animationFound": player != null,
883
+ # False when the tree has no player to look the animation up in, or its player has none of
884
+ # that name.
885
+ "animationFound": found,
561
886
  "position": GdhSerialisation.new().serialize_value(position),
562
887
  }
888
+ # Written all the same, as the editor writes one: a state machine is often laid out before its
889
+ # animations are made, and refusing it stopped a tree being built in that order.
890
+ if player != null and not found:
891
+ answer["animationMissing"] = _missing_animation(scene, player, animation_name)
563
892
  var files: Dictionary = machine[1]
564
893
  return _finish(scene, Callable(), files, answer)
565
894
 
566
895
 
896
+ ## Why the state playing [param animation_name] plays nothing yet. The player is named by its path
897
+ ## from the scene root, as playerNodePath takes it; anim_player is relative to the tree ("../Anim"),
898
+ ## which no argument takes.
899
+ static func _missing_animation(
900
+ scene: GdhSceneFile, player: AnimationPlayer, animation_name: String
901
+ ) -> String:
902
+ var said: String = (
903
+ "The AnimationPlayer at %s has no animation %s yet, so the state plays nothing until it has one."
904
+ % [str(scene.root.get_path_to(player)), animation_name]
905
+ )
906
+ var close: PackedStringArray = GdhRead.nearest(animation_name, player.get_animation_list())
907
+ if close.is_empty():
908
+ return said
909
+ return (
910
+ said + " The nearest %s %s." % ["it has is" if close.size() == 1 else "it has are", ", ".join(close)]
911
+ )
912
+
913
+
567
914
  # =============================================================================
568
915
  # connect_animation_states
569
916
  # =============================================================================
570
917
  func connect_animation_states(args: Dictionary) -> Dictionary:
571
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
918
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
572
919
  var anim_tree_path: String = str(args.get("animTreePath", ""))
573
920
  var from_state: String = str(args.get("fromState", ""))
574
921
  var to_state: String = str(args.get("toState", ""))
@@ -7,6 +7,7 @@ const GdhFileReads = preload("../file_reads.gd")
7
7
  const GdhHeadlessCheck = preload("../headless_check.gd")
8
8
  const GdhPropertyValues = preload("../property_values.gd")
9
9
  const GdhRead = preload("../reading.gd")
10
+ const GdhSame = preload("../same_values.gd")
10
11
  const GdhSceneFile = preload("../scene_file.gd")
11
12
 
12
13
  # The minimal shader of a type: its declaration and the one function the engine calls for it.
@@ -136,7 +137,7 @@ func _save_checked(resource: Resource, path: String, names: Array, previous: Var
136
137
  for key: Variant in names:
137
138
  var property: String = str(key)
138
139
  var read: Variant = _properties.read_at(loaded, property)
139
- if not GdhPropertyValues.same(read, _properties.read_at(resource, property)):
140
+ if not GdhSame.same(read, _properties.read_at(resource, property)):
140
141
  lost = (
141
142
  (
142
143
  "%s.%s would load from the file as %s: it is not a property the file keeps, so "
@@ -182,7 +183,7 @@ func _unkept_headless(resource: Resource, path: String, names: Array, previous:
182
183
  var property: String = str(key)
183
184
  asked[property] = true
184
185
  asked[property.get_slice(":", 0)] = true
185
- reached.merge(GdhHeadlessCheck.reached(resource, property))
186
+ GdhHeadlessCheck.spare(reached, GdhHeadlessCheck.reached(resource, property))
186
187
  return GdhHeadlessCheck.settle_saved(
187
188
  path,
188
189
  before,