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 +58 -11
- package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -9
- package/build/godot/addons/gdharness_editor/file_walk.gd +134 -0
- package/build/godot/addons/gdharness_editor/file_walk.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/property_values.gd +55 -7
- package/build/godot/addons/gdharness_editor/scene_file.gd +3 -1
- package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +367 -23
- package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +20 -3
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +8 -4
- package/build/godot/operations/animation_reads.gd +11 -9
- package/build/godot/operations/editor_tools.gd +3 -0
- package/build/godot/operations/file_walk.gd +1 -0
- package/build/godot/operations/property_values.gd +55 -7
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
]
|
|
@@ -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
|
-
|
|
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.
|
|
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",
|