gdharness 1.3.2 → 1.3.3

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 CHANGED
@@ -12095,7 +12095,7 @@ var init_tool_definitions = __esm(() => {
12095
12095
  {
12096
12096
  name: "project_settings",
12097
12097
  title: "Project settings",
12098
- description: "Reads or writes project.godot: settings, autoloads, the main scene, input actions, plugins and audio buses. A write with an editor open on the project is handed to the editor, which takes the settings the write changed or names from the file (named under editorAdopted), including a setting the file already held, since the editor can hold another value after the file was put back, or reloads the bus layout, so it answers the new values and does not save the old ones back over them. editorNote is an editor that could not take the write, or one on another release's addon that was not asked, which still holds the old values until editor_launch restart. An enabled plugin's code still loads on the editor's next start. set answers old_value and new_value, with was_new true when project.godot did not set the setting before, and was_default true when what it held was the engine's own default, which old_value then is. add_input_action on an action that exists, one of the engine's ui_ actions included, replaces its events and deadzone, and answers was_new false with what it held under old_events and old_deadzone. A set of the value the setting already holds, in the same type, writes nothing and answers unchanged true and saved false. Every write keeps the sections and keys of project.godot in the order the file had them, so a change moves no other line; the save is the engine's, and like the editor's it writes Godot's header and config/features into a file that lacks them. get answers exists, true for any setting the engine knows, and at_default, true when the value is the engine's own default and so has no line in project.godot. Each setting under prefix carries at_default too, so a family read separates what the project sets from the engine's defaults; it is absent for a setting the engine registers no default for, such as an input action the project added, which only the project can have set.",
12098
+ description: "Reads or writes project.godot: settings, autoloads, the main scene, input actions, plugins and audio buses. project_info include lists the autoloads, input actions and audio buses as the project holds them. A write with an editor open on the project is handed to the editor, which takes the settings the write changed or names from the file (named under editorAdopted), including a setting the file already held, since the editor can hold another value after the file was put back, or reloads the bus layout, so it answers the new values and does not save the old ones back over them. editorNote is an editor that could not take the write, or one on another release's addon that was not asked, which still holds the old values until editor_launch restart. An enabled plugin's code still loads on the editor's next start. set answers old_value and new_value, with was_new true when project.godot did not set the setting before, and was_default true when what it held was the engine's own default, which old_value then is. add_input_action on an action that exists, one of the engine's ui_ actions included, replaces its events and deadzone, and answers was_new false with what it held under old_events and old_deadzone. A set of the value the setting already holds, in the same type, writes nothing and answers unchanged true and saved false. Every write keeps the sections and keys of project.godot in the order the file had them, so a change moves no other line; the save is the engine's, and like the editor's it writes Godot's header and config/features into a file that lacks them. get answers exists, true for any setting the engine knows, and at_default, true when the value is the engine's own default and so has no line in project.godot. Each setting under prefix carries at_default too, so a family read separates what the project sets from the engine's defaults; it is absent for a setting the engine registers no default for, such as an input action the project added, which only the project can have set.",
12099
12099
  parameters: {
12100
12100
  projectPath: PROJECT_PATH,
12101
12101
  setting: {
@@ -12317,7 +12317,7 @@ var init_tool_definitions = __esm(() => {
12317
12317
  },
12318
12318
  reimport: {
12319
12319
  effect: "destructive",
12320
- summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. The editor scans first, since its reimport passes over a file it has not taken in, such as one an installer has just written; what that scan imported is named under importedByTheScan and is not imported again unless force asks. Answered from a second status read: reimported lists what is current now, notReimported what is not, one entry per status and reason with the paths sharing them, and via says which of the two did the work. When nothing needs reimporting, nothing runs, and the answer has the two lists empty, a note saying so, and no via. A whole-project reimport also answers missingSource, the .import files whose source is not beside them, empty when there are none, whether or not anything needed reimporting; one resourcePath is not a walk of the project and leaves it out. stillImporting is an editor that had not finished within timeoutMs",
12320
+ summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. The editor scans first, since its reimport passes over a file it has not taken in, such as one an installer has just written; what that scan imported is named under importedByTheScan and is not imported again unless force asks. Answered from a second status read: reimported lists what is current now, notReimported what is not, one entry per status and reason with the paths sharing them, and via says which of the two did the work. Either takes in every file that is behind, so a reimport of one resourcePath names under alsoImported the others it imported. When nothing needs reimporting, nothing runs, and the answer has the two lists empty, a note saying so, and no via. A whole-project reimport also answers missingSource, the .import files whose source is not beside them, empty when there are none, whether or not anything needed reimporting; one resourcePath is not a walk of the project and leaves it out. stillImporting is an editor that had not finished within timeoutMs",
12321
12321
  requires: []
12322
12322
  },
12323
12323
  uid: {
@@ -12419,7 +12419,7 @@ var init_tool_definitions = __esm(() => {
12419
12419
  {
12420
12420
  name: "scene_create",
12421
12421
  title: "Create or save a scene",
12422
- description: `Creates a scene file where none is, saves one, or saves a copy under a new path. A save of a scene open in the editor saves the editor's copy, unsaved changes included. ${WITHOUT_AN_EDITOR}`,
12422
+ description: `Creates a scene file where none is, saves one, or saves a copy under a new path. create makes a folder the path names that is not there, and names it under madeFolder. A save of a scene open in the editor saves the editor's copy, unsaved changes included. ${WITHOUT_AN_EDITOR}`,
12423
12423
  parameters: {
12424
12424
  projectPath: PROJECT_PATH,
12425
12425
  scenePath: SCENE_PATH,
@@ -12606,31 +12606,40 @@ var init_tool_definitions = __esm(() => {
12606
12606
  {
12607
12607
  name: "scene_animation",
12608
12608
  title: "Animations",
12609
- description: "Animations in an AnimationPlayer and states in an AnimationTree state machine. read answers with every track of an animation in the shape add_track takes, so a track read off one animation can be added to another. read and read_states need no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without running the scene's scripts. The ops that write go through the editor when one serves the project; with none, a headless engine runs the editor's own tool and the answer says so under writtenBy. That engine runs every script of the file, where the editor runs only tool scripts, so what a script changes as the file loads is put back before anything is saved, and a file where that cannot be done, such as a scene whose script does not compile, is refused and nothing is written. An editor of the project that is running and has not reached this server is waited for rather than written around.",
12609
+ description: "Animations in an AnimationPlayer and states in an AnimationTree state machine. read answers with every track of an animation in the shape add_track takes, so a track read off one animation can be added to another. add_track on a path the animation already has a track of that type for adds the keys to that track, as the editor keys a property it already animates, answering existingTrack true and keysAdded beside keys, the track's count; a key at a time that track already has a key at is refused. A key past the animation's end, where it never plays, is written and named under pastLength. remove clears the player's autoplay when it named the animation and names under statesPlayingNothing the states that played it. read and read_states need no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without running the scene's scripts. The ops that write go through the editor when one serves the project; with none, a headless engine runs the editor's own tool and the answer says so under writtenBy. That engine runs every script of the file, where the editor runs only tool scripts, so what a script changes as the file loads is put back before anything is saved, and a file where that cannot be done, such as a scene whose script does not compile, is refused and nothing is written. An editor of the project that is running and has not reached this server is waited for rather than written around.",
12610
12610
  parameters: {
12611
12611
  projectPath: PROJECT_PATH,
12612
12612
  scenePath: SCENE_PATH,
12613
12613
  playerNodePath: {
12614
12614
  type: "string",
12615
- ops: ["read", "create", "add_track"],
12615
+ ops: ["read", "create", "add_track", "set", "rename", "remove"],
12616
12616
  description: "The AnimationPlayer node."
12617
12617
  },
12618
12618
  animationName: {
12619
12619
  type: "string",
12620
- ops: ["read", "create", "add_track", "add_state"],
12620
+ ops: ["read", "create", "add_track", "set", "rename", "remove", "add_state"],
12621
12621
  description: "The animation in the player. read: the one to answer with whole, its tracks a list; without it, read lists them, each with the number of its tracks under tracks. add_state: the one that state plays."
12622
12622
  },
12623
- length: { type: "number", ops: ["create"], description: "create: seconds. Default 1." },
12623
+ newName: {
12624
+ type: "string",
12625
+ ops: ["rename"],
12626
+ description: "rename: the animation's new name in the library it is in. The player's autoplay and every state of the scene's AnimationTrees that plays it are renamed with it, named under autoplay and statesFollowed."
12627
+ },
12628
+ length: {
12629
+ type: "number",
12630
+ ops: ["create", "set"],
12631
+ description: "create and set: seconds, more than 0. create: default 1. set: a key past the new end is kept, as the editor keeps it, and named under pastLength, since it never plays."
12632
+ },
12624
12633
  loopMode: {
12625
12634
  type: "string",
12626
- ops: ["create"],
12635
+ ops: ["create", "set"],
12627
12636
  enum: ["none", "linear", "pingpong"],
12628
12637
  description: "create: default none."
12629
12638
  },
12630
12639
  step: {
12631
12640
  type: "number",
12632
- ops: ["create"],
12633
- description: "create: keyframe snap in seconds. Default 0.1."
12641
+ ops: ["create", "set"],
12642
+ description: "create and set: keyframe snap in seconds, 0 or more. create: default 0.1."
12634
12643
  },
12635
12644
  track: { ...ANIMATION_TRACK, ops: ["add_track"] },
12636
12645
  animTreePath: {
@@ -12692,6 +12701,21 @@ var init_tool_definitions = __esm(() => {
12692
12701
  summary: "a property or method track with keyframes",
12693
12702
  requires: ["playerNodePath", "animationName", "track"]
12694
12703
  },
12704
+ set: {
12705
+ effect: "destructive",
12706
+ summary: "an animation's length, loop mode or step",
12707
+ requires: ["playerNodePath", "animationName"]
12708
+ },
12709
+ rename: {
12710
+ effect: "destructive",
12711
+ summary: "an animation, with what plays it",
12712
+ requires: ["playerNodePath", "animationName", "newName"]
12713
+ },
12714
+ remove: {
12715
+ effect: "destructive",
12716
+ summary: "an animation from its player",
12717
+ requires: ["playerNodePath", "animationName"]
12718
+ },
12695
12719
  add_state: {
12696
12720
  effect: "additive",
12697
12721
  summary: "a state playing an animation, in an AnimationTree",
@@ -12868,7 +12892,7 @@ var init_tool_definitions = __esm(() => {
12868
12892
  preview: {
12869
12893
  type: "boolean",
12870
12894
  ops: ["move"],
12871
- description: "move: answer with what would move and change, and write nothing. Default false."
12895
+ description: "move: answer with what would move and change, and write nothing. A move that would be refused is refused here too, with the same reason. Default false."
12872
12896
  },
12873
12897
  controlType: {
12874
12898
  type: "string",
@@ -47291,6 +47315,12 @@ class GodotServer {
47291
47315
  return await editorOrEngine("create_animation", "writes");
47292
47316
  case "add_track":
47293
47317
  return await editorOrEngine("add_animation_track", "writes");
47318
+ case "set":
47319
+ return await editorOrEngine("set_animation", "writes");
47320
+ case "rename":
47321
+ return await editorOrEngine("rename_animation", "writes");
47322
+ case "remove":
47323
+ return await editorOrEngine("remove_animation", "writes");
47294
47324
  case "add_state":
47295
47325
  return await editorOrEngine("add_animation_state", "writes");
47296
47326
  default:
@@ -48088,6 +48118,13 @@ class GodotServer {
48088
48118
  const viaEditor = this.editorHasOpen(projectPath);
48089
48119
  const extra = {};
48090
48120
  const statusAsk = resourcePath === undefined ? { includeUpToDate: true } : { resourcePath };
48121
+ let behindElsewhere = [];
48122
+ if (resourcePath !== undefined) {
48123
+ const whole = await this.operation("get_import_status", {}, projectPath);
48124
+ if (whole.ok) {
48125
+ behindElsewhere = importStatuses(whole.payload).filter((one) => one.status !== "up_to_date" && one.status !== "missing_source" && one.status !== "not_imported" && !targets.includes(one.path)).map((one) => one.path);
48126
+ }
48127
+ }
48091
48128
  if (viaEditor) {
48092
48129
  const scan = await this.handleRescanFilesystem({ projectPath, timeoutMs });
48093
48130
  if (scan.isError === true) {
@@ -48169,6 +48206,16 @@ class GodotServer {
48169
48206
  groups.set(key, group);
48170
48207
  }
48171
48208
  const notReimported = [...groups.values()];
48209
+ if (behindElsewhere.length > 0) {
48210
+ const whole = await this.operation("get_import_status", { includeUpToDate: true }, projectPath);
48211
+ if (whole.ok) {
48212
+ const current = new Set(importStatuses(whole.payload).filter((one) => one.status === "up_to_date").map((one) => one.path));
48213
+ const alsoImported = behindElsewhere.filter((path) => current.has(path));
48214
+ if (alsoImported.length > 0) {
48215
+ extra["alsoImported"] = alsoImported;
48216
+ }
48217
+ }
48218
+ }
48172
48219
  return {
48173
48220
  ok: true,
48174
48221
  payload: {
@@ -81,15 +81,7 @@ static func animations(player: AnimationPlayer, animation_name: String) -> Dicti
81
81
  listed.append(summary)
82
82
  return {"ok": true, "animations": listed}
83
83
  if not player.has_animation(StringName(animation_name)):
84
- var names: PackedStringArray = player.get_animation_list()
85
- return {
86
- "ok": false,
87
- "error":
88
- (
89
- "%s has no animation %s. It has: %s."
90
- % [player.name, animation_name, ", ".join(names) if not names.is_empty() else "none"]
91
- )
92
- }
84
+ return {"ok": false, "error": no_animation(player, str(player.name), animation_name)}
93
85
  var anim: Animation = player.get_animation(StringName(animation_name))
94
86
  var answer: Dictionary = _summary(anim)
95
87
  answer["ok"] = true
@@ -102,6 +94,16 @@ static func animations(player: AnimationPlayer, animation_name: String) -> Dicti
102
94
  return answer
103
95
 
104
96
 
97
+ ## Why [param player], called [param called], cannot be asked for [param animation_name], with the
98
+ ## animations it has.
99
+ static func no_animation(player: AnimationPlayer, called: String, animation_name: String) -> String:
100
+ var names: PackedStringArray = player.get_animation_list()
101
+ return (
102
+ "%s has no animation %s. It has: %s."
103
+ % [called, animation_name, ", ".join(names) if not names.is_empty() else "none"]
104
+ )
105
+
106
+
105
107
  static func _summary(anim: Animation) -> Dictionary:
106
108
  return {
107
109
  "length": anim.length,
@@ -0,0 +1,134 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ const GdhRead = preload("reading.gd")
5
+
6
+ # Two walks rather than one because they disagree about hidden entries on purpose: a suffix
7
+ # walk descends into every visible directory and matches whole names, while the extension walk
8
+ # skips anything beginning with a dot, including .import sidecars that would otherwise answer
9
+ # for the resource they belong to.
10
+ #
11
+ # Both stop at a `.gdignore`, because the engine does: nothing under one is imported, so what is
12
+ # in there is not a resource, not a dependency and not a global class. Walking in anyway hands
13
+ # every caller files the engine will never answer for.
14
+
15
+
16
+ # Why [param path], a res:// or uid:// path a setting names, is not a file the engine can load, or
17
+ # empty when it is. A uid:// path resolves through the project's uid cache, which the import writes,
18
+ # so one that does not resolve is a file that has gone or a project not imported yet, and saying
19
+ # only that the file does not exist sent a caller looking for a scene that was there.
20
+ static func missing_reason(path: String) -> String:
21
+ if path.begins_with("uid://"):
22
+ var id: int = ResourceUID.text_to_id(path)
23
+ if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
24
+ return (
25
+ path
26
+ + " does not resolve: no file in the project's uid cache has it, so the file has gone or the"
27
+ + " project has not been imported yet (project_import refresh_uids imports it)"
28
+ )
29
+ var resolved: String = ResourceUID.get_id_path(id)
30
+ return (
31
+ "" if FileAccess.file_exists(resolved) else path + " names " + resolved + ", which does not exist"
32
+ )
33
+ return "" if FileAccess.file_exists(path) else path + " does not exist"
34
+
35
+
36
+ ## [param said], the refusal for [param path], a res:// path nothing is at, with the files of its
37
+ ## extension whose names are nearest its own, or every one when there are few and none is near, so
38
+ ## the caller has a path to send instead.
39
+ func with_nearest_files(said: String, path: String) -> String:
40
+ var extension: String = path.get_extension()
41
+ if extension.is_empty():
42
+ return said
43
+ var files: Array[String] = find_files_with_extensions("res://", [extension])
44
+ # The walk lists a directory in the order the filesystem keeps it, which differs between
45
+ # platforms, so the same project was answered with its files in another order on Linux.
46
+ files.sort()
47
+ if files.is_empty():
48
+ return "%s. The project holds no .%s file." % [said, extension]
49
+ var names: PackedStringArray = []
50
+ for file: String in files:
51
+ var _named: bool = names.append(file.get_file().get_basename())
52
+ var close: PackedStringArray = GdhRead.nearest(path.get_file().get_basename(), names)
53
+ var offered: PackedStringArray = []
54
+ for file: String in files:
55
+ if file.get_file().get_basename() in close:
56
+ var _offered: bool = offered.append(file)
57
+ if not offered.is_empty():
58
+ var one: bool = offered.size() == 1
59
+ return (
60
+ "%s. The nearest .%s %s %s."
61
+ % [said, extension, "file is" if one else "files are", ", ".join(offered)]
62
+ )
63
+ if files.size() <= GdhRead.NAMED_CHILDREN:
64
+ return "%s. The project's .%s files are %s." % [said, extension, ", ".join(files)]
65
+ return "%s. None of the project's %d .%s files is named like it." % [said, files.size(), extension]
66
+
67
+
68
+ # A directory the engine steps over, marker file and all.
69
+ func is_stepped_over(path: String) -> bool:
70
+ return FileAccess.file_exists(path + ".gdignore")
71
+
72
+
73
+ # An addon gdharness installed, told by the marker setup writes into it. The engine loads it, but
74
+ # nobody on the project wrote it: the health check counted its thirty-odd scripts as the project's,
75
+ # 39 beside the 5 project_info's own count gave, which the server's walks already step over.
76
+ func is_installed_by_gdharness(path: String) -> bool:
77
+ return FileAccess.file_exists(path + ".gdharness-version")
78
+
79
+
80
+ # Files under `path` whose name ends with `extension`, which is passed with its dot.
81
+ func find_files(path: String, extension: String) -> Array[String]:
82
+ var files: Array[String] = []
83
+ if is_stepped_over(path) or is_installed_by_gdharness(path):
84
+ return files
85
+
86
+ var dir: DirAccess = DirAccess.open(path)
87
+
88
+ if dir:
89
+ if dir.list_dir_begin() != OK:
90
+ return files
91
+ var file_name: String = dir.get_next()
92
+
93
+ while file_name != "":
94
+ if dir.current_is_dir() and not file_name.begins_with("."):
95
+ files.append_array(find_files(path + file_name + "/", extension))
96
+ elif file_name.ends_with(extension):
97
+ files.append(path + file_name)
98
+
99
+ file_name = dir.get_next()
100
+
101
+ return files
102
+
103
+
104
+ # Files under `path` whose extension is in `extensions`, which are passed without their dot.
105
+ func find_files_with_extensions(path: String, extensions: Array) -> Array[String]:
106
+ var files: Array[String] = []
107
+ if is_stepped_over(path) or is_installed_by_gdharness(path):
108
+ return files
109
+
110
+ var dir: DirAccess = DirAccess.open(path)
111
+
112
+ if dir:
113
+ if dir.list_dir_begin() != OK:
114
+ return files
115
+ var file_name: String = dir.get_next()
116
+
117
+ while file_name != "":
118
+ if file_name.begins_with("."):
119
+ file_name = dir.get_next()
120
+ continue
121
+
122
+ var full_path: String = path + file_name
123
+ if dir.current_is_dir():
124
+ files.append_array(find_files_with_extensions(full_path + "/", extensions))
125
+ else:
126
+ var ext: String = file_name.get_extension().to_lower()
127
+ if ext in extensions:
128
+ files.append(full_path)
129
+
130
+ file_name = dir.get_next()
131
+
132
+ dir.list_dir_end()
133
+
134
+ return files
@@ -0,0 +1 @@
1
+ uid://c16785cs9xozr
@@ -189,6 +189,7 @@ func write_all(target: Object, properties: Dictionary) -> String:
189
189
  named[str(key).get_slice(":", 0)] = true
190
190
  var before: Dictionary = _stored(target, named)
191
191
  var given: Dictionary = {}
192
+ var declarations: Dictionary = {}
192
193
  # A refusal of one of several leaves the others unwritten too, which a refusal naming only the
193
194
  # one it refused left a caller to guess.
194
195
  var unwritten: String = (
@@ -213,6 +214,7 @@ func write_all(target: Object, properties: Dictionary) -> String:
213
214
  _outside_declared[property] = outside
214
215
  _write(target, property, value[1])
215
216
  given[property] = value[1]
217
+ declarations[property] = declared
216
218
 
217
219
  for property: String in given:
218
220
  if not same(read_at(target, property), given[property]):
@@ -220,13 +222,8 @@ func write_all(target: Object, properties: Dictionary) -> String:
220
222
  for property: String in given:
221
223
  var held: Variant = read_at(target, property)
222
224
  if not same(held, given[property]):
223
- return (
224
- (
225
- "%s.%s was given %s and holds %s after it was set: the engine changed or refused the "
226
- + "value, so nothing was written."
227
- )
228
- % [target.get_class(), property, shown(given[property]), shown(held)]
229
- )
225
+ var declared: Dictionary = declarations[property]
226
+ return _not_held(target, property, given[property], held, declared)
230
227
  for property: String in before:
231
228
  var now: Variant = target.get(property)
232
229
  if not same(now, before[property]):
@@ -234,6 +231,57 @@ func write_all(target: Object, properties: Dictionary) -> String:
234
231
  return ""
235
232
 
236
233
 
234
+ ## Why [param property] of [param target], declared as [param declared], holds [param held] after
235
+ ## it was given [param value]. A text property with a list of names holds only one of them, and its
236
+ ## getter answers its default for any other. A player's bus is one, though the engine fills its list
237
+ ## only in the inspector, so the buses are read from the AudioServer, which holds the project's
238
+ ## layout; "the engine changed or refused the value" left a caller to guess the bus did not exist.
239
+ func _not_held(
240
+ target: Object, property: String, value: Variant, held: Variant, declared: Dictionary
241
+ ) -> String:
242
+ var said: String = (
243
+ "%s.%s was given %s and holds %s after it was set"
244
+ % [target.get_class(), property, shown(value), shown(held)]
245
+ )
246
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
247
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
248
+ var names: PackedStringArray = []
249
+ if hint == PROPERTY_HINT_ENUM and (type == TYPE_STRING or type == TYPE_STRING_NAME):
250
+ names = PackedStringArray(GdhDeclared.enum_values(str(declared.get("hint_string", ""))).keys())
251
+ var bus: bool = property == "bus" and target.get_class().begins_with("AudioStreamPlayer")
252
+ if bus:
253
+ names = []
254
+ for index: int in AudioServer.bus_count:
255
+ var _added: bool = names.append(AudioServer.get_bus_name(index))
256
+ if names.is_empty() or names.has(str(value)):
257
+ return said + ": the engine changed or refused the value, so nothing was written."
258
+ var close: PackedStringArray = GdhRead.nearest(str(value), names)
259
+ var nearest: String = (
260
+ "%s %s" % ["is" if close.size() == 1 else "are", ", ".join(close)] if not close.is_empty() else ""
261
+ )
262
+ if bus:
263
+ return (
264
+ (
265
+ "%s: it takes the name of one of the project's audio buses, and %s is none of them; %s. "
266
+ + "project_info include audio_buses lists them, and project_settings add_audio_bus adds "
267
+ + "one. Nothing was written."
268
+ )
269
+ % [
270
+ said,
271
+ shown(value),
272
+ "the buses are " + ", ".join(names) if nearest.is_empty() else "the nearest " + nearest
273
+ ]
274
+ )
275
+ return (
276
+ "%s: it takes one of the names it lists, and %s is none of them; %s. Nothing was written."
277
+ % [
278
+ said,
279
+ shown(value),
280
+ "it lists " + ", ".join(names) if nearest.is_empty() else "the nearest it lists " + nearest
281
+ ]
282
+ )
283
+
284
+
237
285
  ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
238
286
  ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
239
287
  ## material.
@@ -437,7 +437,9 @@ func unusable_name(parent: Node, child_name: String, moving: Node = null) -> Str
437
437
  return unfit
438
438
  var taken: Node = parent.get_node_or_null(NodePath(child_name))
439
439
  if taken != null and taken != moving and taken.get_parent() == parent:
440
- return "%s already has a child named %s." % [path_of(parent), child_name]
440
+ # The root by its name, since its path is ".", and ". already has a child" read as a typo.
441
+ var holder: String = "The root, %s," % parent.name if parent == root else path_of(parent)
442
+ return "%s already has a child named %s." % [holder, child_name]
441
443
  return ""
442
444
 
443
445
 
@@ -124,6 +124,9 @@ func _init_tools() -> void:
124
124
  "read_animation_states": [_animation_tools, "read_animation_states"],
125
125
  "create_animation": [_animation_tools, "create_animation"],
126
126
  "add_animation_track": [_animation_tools, "add_animation_track"],
127
+ "set_animation": [_animation_tools, "set_animation"],
128
+ "rename_animation": [_animation_tools, "rename_animation"],
129
+ "remove_animation": [_animation_tools, "remove_animation"],
127
130
  "add_animation_state": [_animation_tools, "add_animation_state"],
128
131
  "connect_animation_states": [_animation_tools, "connect_animation_states"],
129
132
  }
@@ -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()
@@ -284,11 +287,12 @@ func create_animation(args: Dictionary) -> Dictionary:
284
287
  return {"ok": false, "error": "Missing scenePath"}
285
288
  if animation_name.strip_edges().is_empty():
286
289
  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
- }
290
+ var loop_modes: Dictionary = LOOP_MODES
290
291
  if not loop_modes.has(loop_mode_name):
291
292
  return {"ok": false, "error": "Unsupported loopMode: " + loop_mode_name}
293
+ var timing: String = _timing_refusal(args)
294
+ if not timing.is_empty():
295
+ return {"ok": false, "error": timing}
292
296
 
293
297
  var opened: Array = _open_at(scene_path, player_node_path, "AnimationPlayer")
294
298
  var scene: GdhSceneFile = opened[0]
@@ -347,15 +351,13 @@ func add_animation_track(args: Dictionary) -> Dictionary:
347
351
  if not keyed[0]:
348
352
  return {"ok": false, "error": keyed[1]}
349
353
 
350
- var opened: Array = _open_at(scene_path, player_node_path, "AnimationPlayer")
354
+ # Asked of the player, which knows every library: looking only in the default one refused
355
+ # "moves/walk" as missing.
356
+ var opened: Array = _animation_at(scene_path, player_node_path, animation_name)
351
357
  var scene: GdhSceneFile = opened[0]
352
358
  if opened[1] == null:
353
359
  return opened[2]
354
360
  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
361
  var anim: Animation = player.get_animation(StringName(animation_name))
360
362
  var homes: Array = _homes(
361
363
  scene, [[_library_of(player, animation_name), "animation library"], [anim, "animation"]]
@@ -394,55 +396,129 @@ func add_animation_track(args: Dictionary) -> Dictionary:
394
396
  return "the track would not be in %s when the scene loads." % animation_name
395
397
  return ""
396
398
  var answer: Dictionary = {
397
- "ok": true, "trackType": str(track.get("type", "")), "trackIndex": track_idx, "keys": keys
399
+ "ok": true,
400
+ "trackType": str(track.get("type", "")),
401
+ "trackIndex": track_idx,
402
+ "keys": keys,
403
+ "keysAdded": keyframes.size(),
398
404
  }
405
+ if built[3]:
406
+ answer["existingTrack"] = true
399
407
  if built[2] != null:
400
408
  answer["methodFound"] = built[2]
409
+ var past: String = _past_length(anim, animation_name)
410
+ if not past.is_empty():
411
+ answer["pastLength"] = past
401
412
  var files: Dictionary = homes[1]
402
413
  return _finish(scene, check, files, answer)
403
414
 
404
415
 
405
- ## Adds [param track] to [param anim] with [param keyframes], as [index, why not, methodFound]:
406
- ## methodFound is null for a property track.
416
+ ## Adds [param track] to [param anim] with [param keyframes], as [index, why not, methodFound,
417
+ ## existing]: methodFound is null for a property track, and existing is whether the keys went into
418
+ ## a track the animation already had for that path.
419
+ ##
420
+ ## Into that track rather than a second one beside it, as the editor keys a property it already
421
+ ## animates: two tracks for one path both play, the later overriding the earlier, and a key meant
422
+ ## to extend the motion was split off where reading the animation showed two halves.
407
423
  func _add_track(
408
424
  anim: Animation, track: Dictionary, target: Node, target_path: String, keyframes: Array
409
425
  ) -> Array:
410
426
  var track_type: String = str(track.get("type", ""))
411
427
  var keys: Array = []
412
428
  var method_found: Variant = null
413
- var track_idx: int = -1
429
+ var kind: Animation.TrackType = Animation.TYPE_VALUE
430
+ var path: NodePath = NodePath(target_path)
414
431
  if track_type == "property":
415
432
  var prop_name: String = str(track.get("property", ""))
416
433
  if prop_name.is_empty():
417
- return [-1, "track.property is required for property track", null]
434
+ return [-1, "track.property is required for property track", null, false]
418
435
  var values: Array = _key_values(target, prop_name, keyframes)
419
436
  if not values[0]:
420
- return [-1, values[1], null]
437
+ return [-1, values[1], null, false]
421
438
  keys = values[1]
422
- track_idx = anim.add_track(Animation.TYPE_VALUE)
423
- anim.track_set_path(track_idx, NodePath(target_path + ":" + prop_name))
439
+ path = NodePath(target_path + ":" + prop_name)
424
440
  elif track_type == "method":
425
441
  var method_name: String = str(track.get("method", ""))
426
442
  if method_name.is_empty():
427
- return [-1, "track.method is required for method track", null]
443
+ return [-1, "track.method is required for method track", null, false]
428
444
  for index: int in keyframes.size():
429
445
  var keyframe: Dictionary = keyframes[index]
430
446
  var given: Variant = keyframe.get("args", [])
431
447
  if not given is Array:
432
- return [-1, "keyframes[%d].args must be a list." % index, null]
448
+ return [-1, "keyframes[%d].args must be a list." % index, null, false]
433
449
  var arguments: Array = given
434
450
  keys.append({"method": method_name, "args": _parse_method_args(arguments)})
435
451
  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))
452
+ kind = Animation.TYPE_METHOD
438
453
  else:
439
- return [-1, "Unsupported track.type: " + track_type, null]
454
+ return [-1, "Unsupported track.type: " + track_type, null, false]
440
455
 
456
+ var track_idx: int = anim.find_track(path, kind)
457
+ var existing: bool = track_idx >= 0
458
+ if existing:
459
+ # Checked before any key goes in, since track_insert_key replaces a key at the same time
460
+ # without a word, and a refusal partway would leave the editor's scene half changed.
461
+ for index: int in keyframes.size():
462
+ var keyframe: Dictionary = keyframes[index]
463
+ var time: float = GdhRead.as_float(keyframe["time"])
464
+ if anim.track_find_key(track_idx, time, Animation.FIND_MODE_APPROX) >= 0:
465
+ return [
466
+ -1,
467
+ (
468
+ (
469
+ "Track %d already animates %s and has a key at %s s, which keyframes[%d] would "
470
+ + "replace. Nothing was written."
471
+ )
472
+ % [track_idx, str(path), _seconds(time), index]
473
+ ),
474
+ null,
475
+ false
476
+ ]
477
+ else:
478
+ track_idx = anim.add_track(kind)
479
+ anim.track_set_path(track_idx, path)
441
480
  for index: int in keyframes.size():
442
481
  var keyframe: Dictionary = keyframes[index]
443
482
  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]
483
+ return [-1, "Keyframe %d could not be inserted." % index, null, false]
484
+ return [track_idx, "", method_found, existing]
485
+
486
+
487
+ ## The keys of [param anim], called [param animation_name], that lie past its end, where they never
488
+ ## play, as a sentence naming each by its track's path and time, or "" when none does. The engine
489
+ ## keeps such a key, as the editor does, so a longer length plays it again.
490
+ static func _past_length(anim: Animation, animation_name: String) -> String:
491
+ var past: PackedStringArray = []
492
+ for track: int in anim.get_track_count():
493
+ for key: int in anim.track_get_key_count(track):
494
+ var time: float = anim.track_get_key_time(track, key)
495
+ if time > anim.length + 0.0001:
496
+ var _added: bool = past.append(
497
+ "%s at %s s" % [str(anim.track_get_path(track)), _seconds(time)]
498
+ )
499
+ if past.is_empty():
500
+ return ""
501
+ return (
502
+ (
503
+ "%s is %s s long, and %s past its end, where %s: %s. %s; scene_animation set "
504
+ + "with a longer length plays %s."
505
+ )
506
+ % [
507
+ animation_name,
508
+ _seconds(anim.length),
509
+ "this key is" if past.size() == 1 else "these keys are",
510
+ "it never plays" if past.size() == 1 else "they never play",
511
+ ", ".join(past),
512
+ "It is kept" if past.size() == 1 else "They are kept",
513
+ "it" if past.size() == 1 else "them",
514
+ ]
515
+ )
516
+
517
+
518
+ ## [param seconds] as a caller wrote it: a key at 2 reads 2, not 2.0.
519
+ static func _seconds(seconds: float) -> String:
520
+ var rounded: float = snappedf(seconds, 0.0001)
521
+ return str(int(rounded)) if rounded == floorf(rounded) else str(rounded)
446
522
 
447
523
 
448
524
  ## The keyframes of [param track], as [true, list] or [false, why]. Each needs its own time: one
@@ -496,6 +572,274 @@ func _key_values(target: Node, prop_name: String, keyframes: Array) -> Array:
496
572
  return [true, values]
497
573
 
498
574
 
575
+ # =============================================================================
576
+ # set_animation, rename_animation, remove_animation
577
+ # =============================================================================
578
+ ## Why the length or step in [param args] cannot be taken, or "". The engine raises a length below
579
+ ## a thousandth of a second to that without a word.
580
+ static func _timing_refusal(args: Dictionary) -> String:
581
+ if args.has("length"):
582
+ var length: Variant = args["length"]
583
+ if not (length is float or length is int) or GdhRead.as_float(length) <= 0.0:
584
+ return "length is the animation's length in seconds, more than 0, and %s is not." % _given(length)
585
+ if args.has("step"):
586
+ var step: Variant = args["step"]
587
+ if not (step is float or step is int) or GdhRead.as_float(step) < 0.0:
588
+ return "step is the keyframe snap in seconds, 0 or more, and %s is not." % _given(step)
589
+ return ""
590
+
591
+
592
+ static func _given(value: Variant) -> String:
593
+ if value is float or value is int:
594
+ return _seconds(GdhRead.as_float(value))
595
+ return JSON.stringify(value)
596
+
597
+
598
+ ## [param scene_path] opened for writing with the player at [param player_node_path], which must
599
+ ## have [param animation_name], as [method _open_at] answers.
600
+ func _animation_at(scene_path: String, player_node_path: String, animation_name: String) -> Array:
601
+ var opened: Array = _open_at(scene_path, player_node_path, "AnimationPlayer")
602
+ if opened[1] == null:
603
+ return opened
604
+ var scene: GdhSceneFile = opened[0]
605
+ var player: AnimationPlayer = opened[1]
606
+ if player.has_animation(StringName(animation_name)):
607
+ return opened
608
+ var said: String = GdhAnimationReads.no_animation(player, player_node_path, animation_name)
609
+ return [scene, null, scene.refuse(said)]
610
+
611
+
612
+ ## Changes the length, loop mode or step of an animation, naming any key a shorter length leaves
613
+ ## past the end.
614
+ func set_animation(args: Dictionary) -> Dictionary:
615
+ var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
616
+ var player_node_path: String = str(args.get("playerNodePath", "."))
617
+ var animation_name: String = str(args.get("animationName", ""))
618
+ var loop_mode_name: String = str(args.get("loopMode", ""))
619
+ if scene_path.strip_edges() == "res://":
620
+ return {"ok": false, "error": "Missing scenePath"}
621
+ if animation_name.strip_edges().is_empty():
622
+ return {"ok": false, "error": "Missing animationName"}
623
+ if not (args.has("length") or args.has("loopMode") or args.has("step")):
624
+ return {"ok": false, "error": "set changes length, loopMode or step, and was given none of them."}
625
+ if args.has("loopMode") and not LOOP_MODES.has(loop_mode_name):
626
+ return {"ok": false, "error": "Unsupported loopMode: " + loop_mode_name}
627
+ var timing: String = _timing_refusal(args)
628
+ if not timing.is_empty():
629
+ return {"ok": false, "error": timing}
630
+
631
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
632
+ if found[1] == null:
633
+ return found[2]
634
+ var scene: GdhSceneFile = found[0]
635
+ var player: AnimationPlayer = found[1]
636
+ var anim: Animation = player.get_animation(StringName(animation_name))
637
+ var homes: Array = _homes(
638
+ scene, [[_library_of(player, animation_name), "animation library"], [anim, "animation"]]
639
+ )
640
+ if not homes[0]:
641
+ return scene.refuse(str(homes[1]))
642
+ if args.has("length"):
643
+ anim.length = GdhRead.as_float(args["length"])
644
+ if args.has("loopMode"):
645
+ anim.loop_mode = GdhRead.as_int(LOOP_MODES[loop_mode_name]) as Animation.LoopMode
646
+ if args.has("step"):
647
+ anim.step = GdhRead.as_float(args["step"])
648
+
649
+ var length: float = anim.length
650
+ var at: String = scene.path_of(player)
651
+ var check: Callable = func(saved: Node) -> String:
652
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
653
+ if copy == null or not copy.has_animation(StringName(animation_name)):
654
+ return "%s would not be in the scene when it loads." % animation_name
655
+ if not is_equal_approx(copy.get_animation(StringName(animation_name)).length, length):
656
+ return "%s would not be %s s long when the scene loads." % [animation_name, _seconds(length)]
657
+ return ""
658
+ var answer: Dictionary = {
659
+ "ok": true,
660
+ "animationName": animation_name,
661
+ "length": anim.length,
662
+ "loopMode": GdhAnimationReads.LOOP_MODE_NAMES.get(anim.loop_mode, str(anim.loop_mode)),
663
+ "step": anim.step,
664
+ }
665
+ var past: String = _past_length(anim, animation_name)
666
+ if not past.is_empty():
667
+ answer["pastLength"] = past
668
+ var files: Dictionary = homes[1]
669
+ return _finish(scene, check, files, answer)
670
+
671
+
672
+ ## Renames an animation within its library, taking along the player's autoplay and every state of
673
+ ## the scene's trees that plays it, since a state naming an animation the player lacks plays nothing
674
+ ## and says so only in the editor's log.
675
+ func rename_animation(args: Dictionary) -> Dictionary:
676
+ var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
677
+ var player_node_path: String = str(args.get("playerNodePath", "."))
678
+ var animation_name: String = str(args.get("animationName", ""))
679
+ var new_name: String = str(args.get("newName", "")).strip_edges()
680
+ if scene_path.strip_edges() == "res://":
681
+ return {"ok": false, "error": "Missing scenePath"}
682
+ if animation_name.strip_edges().is_empty():
683
+ return {"ok": false, "error": "Missing animationName"}
684
+ if new_name.is_empty():
685
+ return {"ok": false, "error": "Missing newName"}
686
+ var library_name: String = animation_name.get_slice("/", 0) if animation_name.contains("/") else ""
687
+ var prefix: String = "" if library_name.is_empty() else library_name + "/"
688
+ if not prefix.is_empty() and new_name.begins_with(prefix):
689
+ new_name = new_name.trim_prefix(prefix)
690
+ if new_name.contains("/"):
691
+ return {
692
+ "ok": false,
693
+ "error":
694
+ (
695
+ (
696
+ "rename keeps an animation in its library (%s), so newName is its name there, with "
697
+ + "no library before a slash."
698
+ )
699
+ % ("the default one" if library_name.is_empty() else library_name)
700
+ ),
701
+ }
702
+ for forbidden: String in [":", ",", "["]:
703
+ if new_name.contains(forbidden):
704
+ return {"ok": false, "error": "An animation's name cannot hold : , or [, and %s does." % new_name}
705
+ var new_full: String = prefix + new_name
706
+
707
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
708
+ if found[1] == null:
709
+ return found[2]
710
+ var scene: GdhSceneFile = found[0]
711
+ var player: AnimationPlayer = found[1]
712
+ if new_full == animation_name:
713
+ return scene.refuse("%s is already called %s." % [animation_name, new_full])
714
+ if player.has_animation(StringName(new_full)):
715
+ return scene.refuse("%s already has an animation %s." % [player_node_path, new_full])
716
+ var library: AnimationLibrary = _library_of(player, animation_name)
717
+ var states: Array = _states_playing(scene, player, animation_name)
718
+ var held: Array = [[library, "animation library"]]
719
+ for state: Array in states:
720
+ held.append([state[0], "state playing %s" % animation_name])
721
+ var homes: Array = _homes(scene, held)
722
+ if not homes[0]:
723
+ return scene.refuse(str(homes[1]))
724
+
725
+ # Read before the rename, since the player moves its autoplay to the new name on its own; it
726
+ # does not clear one when an animation is removed.
727
+ var autoplayed: bool = str(player.autoplay) == animation_name
728
+ library.rename_animation(StringName(animation_name.trim_prefix(prefix)), StringName(new_name))
729
+ if not player.has_animation(StringName(new_full)):
730
+ return scene.refuse("The library did not take the name %s." % new_full)
731
+ var answer: Dictionary = {"ok": true, "animationName": new_full, "was": animation_name}
732
+ if autoplayed:
733
+ answer["autoplay"] = new_full
734
+ var followed: Array = []
735
+ for state: Array in states:
736
+ var plays: AnimationNodeAnimation = state[0]
737
+ plays.animation = StringName(new_full)
738
+ followed.append(state[1])
739
+ if not followed.is_empty():
740
+ answer["statesFollowed"] = followed
741
+
742
+ var at: String = scene.path_of(player)
743
+ var check: Callable = func(saved: Node) -> String:
744
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
745
+ if copy == null or not copy.has_animation(StringName(new_full)):
746
+ return "%s would not be in the scene when it loads." % new_full
747
+ return ""
748
+ var files: Dictionary = homes[1]
749
+ return _finish(scene, check, files, answer)
750
+
751
+
752
+ ## Removes an animation, clearing the player's autoplay when it named it and naming the states of
753
+ ## the scene's trees left playing nothing.
754
+ func remove_animation(args: Dictionary) -> Dictionary:
755
+ var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
756
+ var player_node_path: String = str(args.get("playerNodePath", "."))
757
+ var animation_name: String = str(args.get("animationName", ""))
758
+ if scene_path.strip_edges() == "res://":
759
+ return {"ok": false, "error": "Missing scenePath"}
760
+ if animation_name.strip_edges().is_empty():
761
+ return {"ok": false, "error": "Missing animationName"}
762
+
763
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
764
+ if found[1] == null:
765
+ return found[2]
766
+ var scene: GdhSceneFile = found[0]
767
+ var player: AnimationPlayer = found[1]
768
+ var library: AnimationLibrary = _library_of(player, animation_name)
769
+ var homes: Array = _homes(scene, [[library, "animation library"]])
770
+ if not homes[0]:
771
+ return scene.refuse(str(homes[1]))
772
+ var called: String = animation_name.get_slice("/", 1) if animation_name.contains("/") else animation_name
773
+ var autoplayed: bool = str(player.autoplay) == animation_name
774
+ library.remove_animation(StringName(called))
775
+ if player.has_animation(StringName(animation_name)):
776
+ return scene.refuse("The library did not let go of %s." % animation_name)
777
+ var answer: Dictionary = {"ok": true, "removed": animation_name}
778
+ if autoplayed:
779
+ player.autoplay = ""
780
+ answer["autoplayCleared"] = true
781
+ var orphaned: Array = []
782
+ for state: Array in _states_playing(scene, player, animation_name):
783
+ orphaned.append(state[1])
784
+ if not orphaned.is_empty():
785
+ answer["statesPlayingNothing"] = orphaned
786
+
787
+ var at: String = scene.path_of(player)
788
+ var check: Callable = func(saved: Node) -> String:
789
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
790
+ if copy == null or copy.has_animation(StringName(animation_name)):
791
+ return "%s would still be in the scene when it loads." % animation_name
792
+ return ""
793
+ var files: Dictionary = homes[1]
794
+ return _finish(scene, check, files, answer)
795
+
796
+
797
+ ## The states of [param scene]'s AnimationTrees that play [param animation_name] from [param player],
798
+ ## as [[node, {animTreePath, state}]], a state named by the path of the nodes leading to it.
799
+ func _states_playing(scene: GdhSceneFile, player: AnimationPlayer, animation_name: String) -> Array:
800
+ var found: Array = []
801
+ var trees: Array[Node] = scene.root.find_children("*", "AnimationTree", true, false)
802
+ if scene.root is AnimationTree:
803
+ trees.append(scene.root)
804
+ for node: Node in trees:
805
+ var tree: AnimationTree = node
806
+ if tree.tree_root == null or tree.get_node_or_null(tree.anim_player) != player:
807
+ continue
808
+ var tree_path: String = str(scene.root.get_path_to(tree))
809
+ _collect_playing(tree.tree_root, "", tree_path, animation_name, found)
810
+ return found
811
+
812
+
813
+ static func _collect_playing(
814
+ node: AnimationNode, at: String, tree_path: String, animation_name: String, found: Array
815
+ ) -> void:
816
+ if node is AnimationNodeAnimation:
817
+ var plays: AnimationNodeAnimation = node
818
+ if plays.animation == StringName(animation_name):
819
+ found.append([plays, {"animTreePath": tree_path, "state": "." if at.is_empty() else at}])
820
+ return
821
+ var children: Dictionary = {}
822
+ if node is AnimationNodeStateMachine:
823
+ var machine: AnimationNodeStateMachine = node
824
+ for state: StringName in machine.get_node_list():
825
+ children[str(state)] = machine.get_node(state)
826
+ elif node is AnimationNodeBlendTree:
827
+ var blend: AnimationNodeBlendTree = node
828
+ for input: StringName in blend.get_node_list():
829
+ children[str(input)] = blend.get_node(input)
830
+ elif node is AnimationNodeBlendSpace1D:
831
+ var line: AnimationNodeBlendSpace1D = node
832
+ for point: int in line.get_blend_point_count():
833
+ children[str(point)] = line.get_blend_point_node(point)
834
+ elif node is AnimationNodeBlendSpace2D:
835
+ var plane: AnimationNodeBlendSpace2D = node
836
+ for point: int in plane.get_blend_point_count():
837
+ children[str(point)] = plane.get_blend_point_node(point)
838
+ for step: String in children:
839
+ var child: AnimationNode = children[step]
840
+ _collect_playing(child, step if at.is_empty() else at + "/" + step, tree_path, animation_name, found)
841
+
842
+
499
843
  # =============================================================================
500
844
  # add_animation_state
501
845
  # =============================================================================
@@ -4,6 +4,7 @@ extends RefCounted
4
4
  ## What a new node in a scene is made or copied from, what the scene owns of it, and where among its
5
5
  ## siblings a node may go.
6
6
 
7
+ const GdhFileWalk = preload("../file_walk.gd")
7
8
  const GdhRead = preload("../reading.gd")
8
9
  const GdhPropertyValues = preload("../property_values.gd")
9
10
 
@@ -17,11 +18,15 @@ static func what_to_add(node_type: String, instance_path: String, scene_path: St
17
18
  "add takes nodeType for a node of a class, or instancePath for a scene, and one of them only."
18
19
  ]
19
20
  if not instance_path.is_empty():
21
+ # Asked before the cycle walk, which reads the file's dependencies and so loaded a file that
22
+ # was not there, laying the engine's error and a backtrace beneath the refusal.
23
+ if not ResourceLoader.exists(instance_path, "PackedScene"):
24
+ var said: String = "No scene at %s to instance" % instance_path
25
+ var offered: String = GdhFileWalk.new().with_nearest_files(said, instance_path)
26
+ return [null, offered if offered != said else said + "."]
20
27
  var cycle: String = _instancing_cycle(scene_path, instance_path)
21
28
  if not cycle.is_empty():
22
29
  return [null, cycle]
23
- if not ResourceLoader.exists(instance_path, "PackedScene"):
24
- return [null, "No scene at %s to instance." % instance_path]
25
30
  var packed: PackedScene = ResourceLoader.load(instance_path, "PackedScene") as PackedScene
26
31
  if packed == null or not packed.can_instantiate():
27
32
  return [null, "%s does not load as a scene that can be instanced." % instance_path]
@@ -35,12 +40,24 @@ static func what_to_add(node_type: String, instance_path: String, scene_path: St
35
40
  )
36
41
  ]
37
42
  if not ClassDB.class_exists(node_type) or not ClassDB.is_parent_class(node_type, "Node"):
38
- return [null, "nodeType must be a node class, and %s is not one." % node_type]
43
+ return [null, not_a_node_class("nodeType", node_type)]
39
44
  if not ClassDB.can_instantiate(node_type):
40
45
  return [null, "%s cannot be instantiated." % node_type]
41
46
  return [null, ""]
42
47
 
43
48
 
49
+ ## The refusal for [param given], sent as [param argument], which names no node class, with the
50
+ ## node classes nearest it, so "Node2DD" is offered Node2D.
51
+ static func not_a_node_class(argument: String, given: String) -> String:
52
+ var said: String = "%s must be a node class, and %s is not one" % [argument, given]
53
+ var classes: PackedStringArray = ClassDB.get_inheriters_from_class("Node")
54
+ var _added: bool = classes.append("Node")
55
+ var close: PackedStringArray = GdhRead.nearest(given, classes)
56
+ if close.is_empty():
57
+ return said + "."
58
+ return "%s; the nearest %s %s." % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
59
+
60
+
44
61
  ## A new node of [param node_type], or an instance of [param packed] when there is one. The instance
45
62
  ## is made in the editor's edit state, so the pack writes it as an instance of its file, and owning
46
63
  ## its root leaves its own nodes to the instanced scene.
@@ -202,9 +202,7 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
202
202
  # A class that is not a node went into a Node variable, which is a runtime error the caller
203
203
  # heard about only as "Invalid tool result".
204
204
  if not ClassDB.class_exists(root_node_type) or not ClassDB.is_parent_class(root_node_type, "Node"):
205
- return {
206
- "ok": false, "error": "rootNodeType must be a node class, and %s is not one." % root_node_type
207
- }
205
+ return {"ok": false, "error": GdhPlacing.not_a_node_class("rootNodeType", root_node_type)}
208
206
  if not ClassDB.can_instantiate(root_node_type):
209
207
  return {"ok": false, "error": "%s cannot be instantiated." % root_node_type}
210
208
  var unfit: String = GdhSceneFile.unfit_name(root_node_name)
@@ -243,6 +241,7 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
243
241
  # name is no property the write compares.
244
242
  root.name = root_node_name
245
243
 
244
+ var had_folder: bool = DirAccess.dir_exists_absolute(scene_path.get_base_dir())
246
245
  var folder: String = GdhSceneFile.make_folder_for(scene_path)
247
246
  if not folder.is_empty():
248
247
  root.free()
@@ -251,7 +250,12 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
251
250
  if not written.is_empty():
252
251
  return written
253
252
 
254
- return {"ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name}
253
+ var answer: Dictionary = {
254
+ "ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name
255
+ }
256
+ if not had_folder:
257
+ answer["madeFolder"] = scene_path.get_base_dir()
258
+ return answer
255
259
 
256
260
 
257
261
  func list_scene_nodes(args: Dictionary) -> Dictionary:
@@ -81,15 +81,7 @@ static func animations(player: AnimationPlayer, animation_name: String) -> Dicti
81
81
  listed.append(summary)
82
82
  return {"ok": true, "animations": listed}
83
83
  if not player.has_animation(StringName(animation_name)):
84
- var names: PackedStringArray = player.get_animation_list()
85
- return {
86
- "ok": false,
87
- "error":
88
- (
89
- "%s has no animation %s. It has: %s."
90
- % [player.name, animation_name, ", ".join(names) if not names.is_empty() else "none"]
91
- )
92
- }
84
+ return {"ok": false, "error": no_animation(player, str(player.name), animation_name)}
93
85
  var anim: Animation = player.get_animation(StringName(animation_name))
94
86
  var answer: Dictionary = _summary(anim)
95
87
  answer["ok"] = true
@@ -102,6 +94,16 @@ static func animations(player: AnimationPlayer, animation_name: String) -> Dicti
102
94
  return answer
103
95
 
104
96
 
97
+ ## Why [param player], called [param called], cannot be asked for [param animation_name], with the
98
+ ## animations it has.
99
+ static func no_animation(player: AnimationPlayer, called: String, animation_name: String) -> String:
100
+ var names: PackedStringArray = player.get_animation_list()
101
+ return (
102
+ "%s has no animation %s. It has: %s."
103
+ % [called, animation_name, ", ".join(names) if not names.is_empty() else "none"]
104
+ )
105
+
106
+
105
107
  static func _summary(anim: Animation) -> Dictionary:
106
108
  return {
107
109
  "length": anim.length,
@@ -37,6 +37,9 @@ const RESOURCE_COMMANDS: Array[String] = [
37
37
  const ANIMATION_COMMANDS: Array[String] = [
38
38
  "create_animation",
39
39
  "add_animation_track",
40
+ "set_animation",
41
+ "rename_animation",
42
+ "remove_animation",
40
43
  "add_animation_state",
41
44
  "connect_animation_states",
42
45
  ]
@@ -1,3 +1,4 @@
1
+ @tool
1
2
  extends RefCounted
2
3
 
3
4
  const GdhRead = preload("reading.gd")
@@ -189,6 +189,7 @@ func write_all(target: Object, properties: Dictionary) -> String:
189
189
  named[str(key).get_slice(":", 0)] = true
190
190
  var before: Dictionary = _stored(target, named)
191
191
  var given: Dictionary = {}
192
+ var declarations: Dictionary = {}
192
193
  # A refusal of one of several leaves the others unwritten too, which a refusal naming only the
193
194
  # one it refused left a caller to guess.
194
195
  var unwritten: String = (
@@ -213,6 +214,7 @@ func write_all(target: Object, properties: Dictionary) -> String:
213
214
  _outside_declared[property] = outside
214
215
  _write(target, property, value[1])
215
216
  given[property] = value[1]
217
+ declarations[property] = declared
216
218
 
217
219
  for property: String in given:
218
220
  if not same(read_at(target, property), given[property]):
@@ -220,13 +222,8 @@ func write_all(target: Object, properties: Dictionary) -> String:
220
222
  for property: String in given:
221
223
  var held: Variant = read_at(target, property)
222
224
  if not same(held, given[property]):
223
- return (
224
- (
225
- "%s.%s was given %s and holds %s after it was set: the engine changed or refused the "
226
- + "value, so nothing was written."
227
- )
228
- % [target.get_class(), property, shown(given[property]), shown(held)]
229
- )
225
+ var declared: Dictionary = declarations[property]
226
+ return _not_held(target, property, given[property], held, declared)
230
227
  for property: String in before:
231
228
  var now: Variant = target.get(property)
232
229
  if not same(now, before[property]):
@@ -234,6 +231,57 @@ func write_all(target: Object, properties: Dictionary) -> String:
234
231
  return ""
235
232
 
236
233
 
234
+ ## Why [param property] of [param target], declared as [param declared], holds [param held] after
235
+ ## it was given [param value]. A text property with a list of names holds only one of them, and its
236
+ ## getter answers its default for any other. A player's bus is one, though the engine fills its list
237
+ ## only in the inspector, so the buses are read from the AudioServer, which holds the project's
238
+ ## layout; "the engine changed or refused the value" left a caller to guess the bus did not exist.
239
+ func _not_held(
240
+ target: Object, property: String, value: Variant, held: Variant, declared: Dictionary
241
+ ) -> String:
242
+ var said: String = (
243
+ "%s.%s was given %s and holds %s after it was set"
244
+ % [target.get_class(), property, shown(value), shown(held)]
245
+ )
246
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
247
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
248
+ var names: PackedStringArray = []
249
+ if hint == PROPERTY_HINT_ENUM and (type == TYPE_STRING or type == TYPE_STRING_NAME):
250
+ names = PackedStringArray(GdhDeclared.enum_values(str(declared.get("hint_string", ""))).keys())
251
+ var bus: bool = property == "bus" and target.get_class().begins_with("AudioStreamPlayer")
252
+ if bus:
253
+ names = []
254
+ for index: int in AudioServer.bus_count:
255
+ var _added: bool = names.append(AudioServer.get_bus_name(index))
256
+ if names.is_empty() or names.has(str(value)):
257
+ return said + ": the engine changed or refused the value, so nothing was written."
258
+ var close: PackedStringArray = GdhRead.nearest(str(value), names)
259
+ var nearest: String = (
260
+ "%s %s" % ["is" if close.size() == 1 else "are", ", ".join(close)] if not close.is_empty() else ""
261
+ )
262
+ if bus:
263
+ return (
264
+ (
265
+ "%s: it takes the name of one of the project's audio buses, and %s is none of them; %s. "
266
+ + "project_info include audio_buses lists them, and project_settings add_audio_bus adds "
267
+ + "one. Nothing was written."
268
+ )
269
+ % [
270
+ said,
271
+ shown(value),
272
+ "the buses are " + ", ".join(names) if nearest.is_empty() else "the nearest " + nearest
273
+ ]
274
+ )
275
+ return (
276
+ "%s: it takes one of the names it lists, and %s is none of them; %s. Nothing was written."
277
+ % [
278
+ said,
279
+ shown(value),
280
+ "it lists " + ", ".join(names) if nearest.is_empty() else "the nearest it lists " + nearest
281
+ ]
282
+ )
283
+
284
+
237
285
  ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
238
286
  ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
239
287
  ## material.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",