gdharness 1.3.1 → 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 +369 -97
- package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -9
- package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
- package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/file_reads.gd +5 -0
- 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 +161 -17
- package/build/godot/addons/gdharness_editor/reading.gd +37 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +3 -1
- package/build/godot/addons/gdharness_editor/serialisation.gd +4 -0
- 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/resource_tools.gd +133 -39
- package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +20 -3
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +17 -13
- package/build/godot/addons/gdharness_editor/value_shapes.gd +7 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +37 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +27 -1
- package/build/godot/addons/gdharness_runtime/serialisation.gd +4 -0
- package/build/godot/addons/gdharness_runtime/value_shapes.gd +7 -0
- package/build/godot/operations/animation_reads.gd +11 -9
- package/build/godot/operations/classdb_queries.gd +13 -3
- package/build/godot/operations/declared_values.gd +116 -0
- package/build/godot/operations/dependencies.gd +109 -5
- package/build/godot/operations/editor_tools.gd +3 -0
- package/build/godot/operations/export_presets.gd +4 -6
- package/build/godot/operations/file_reads.gd +5 -0
- package/build/godot/operations/file_walk.gd +35 -0
- package/build/godot/operations/gdscript_analysis.gd +7 -2
- package/build/godot/operations/gdscript_authoring.gd +105 -2
- package/build/godot/operations/godot_operations.gd +2 -0
- package/build/godot/operations/import_pipeline.gd +39 -0
- package/build/godot/operations/input_actions.gd +73 -1
- package/build/godot/operations/project_config.gd +59 -3
- package/build/godot/operations/project_diagnostics.gd +27 -2
- package/build/godot/operations/property_values.gd +161 -17
- package/build/godot/operations/reading.gd +37 -0
- package/build/godot/operations/resource_files.gd +4 -1
- package/build/godot/operations/script_check.gd +16 -0
- package/build/godot/operations/serialisation.gd +4 -0
- package/build/godot/operations/value_shapes.gd +7 -0
- package/package.json +1 -1
|
@@ -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,116 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends RefCounted
|
|
3
|
+
|
|
4
|
+
## What a property declares beyond its type: the values of its enum and the range it is exported
|
|
5
|
+
## with, for taking a value by name and for saying when a value written lies outside them.
|
|
6
|
+
|
|
7
|
+
const GdhRead = preload("reading.gd")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## The values an enum hint names, by name, from its hint string: "SWORD:0,SHIELD:1" as a script
|
|
11
|
+
## declares one, or "Add,Subtract" as an engine property does, each name then taking the number
|
|
12
|
+
## after the one before it.
|
|
13
|
+
static func enum_values(hint_string: String) -> Dictionary:
|
|
14
|
+
var values: Dictionary = {}
|
|
15
|
+
var next: int = 0
|
|
16
|
+
for entry: String in hint_string.split(",", false):
|
|
17
|
+
var name: String = entry.get_slice(":", 0).strip_edges()
|
|
18
|
+
var number: String = entry.get_slice(":", 1).strip_edges() if ":" in entry else ""
|
|
19
|
+
var value: int = number.to_int() if number.is_valid_int() else next
|
|
20
|
+
values[name] = value
|
|
21
|
+
next = value + 1
|
|
22
|
+
return values
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## The values of the enum [param declared] declares, by name, spelt as the script of [param target]
|
|
26
|
+
## spells them when it declares that enum. The engine's hint capitalises each name, "SWORD" as
|
|
27
|
+
## "Sword", and a refusal listing those named values the script never spells.
|
|
28
|
+
static func enum_of(target: Object, declared: Dictionary) -> Dictionary:
|
|
29
|
+
var hinted: Dictionary = enum_values(str(declared.get("hint_string", "")))
|
|
30
|
+
var attached: Variant = target.get_script()
|
|
31
|
+
if not attached is Script:
|
|
32
|
+
return hinted
|
|
33
|
+
var script: Script = attached
|
|
34
|
+
for constant: Variant in script.get_script_constant_map().values():
|
|
35
|
+
if not constant is Dictionary:
|
|
36
|
+
continue
|
|
37
|
+
var values: Dictionary = constant
|
|
38
|
+
if values.size() != hinted.size():
|
|
39
|
+
continue
|
|
40
|
+
var spelt: Dictionary = {}
|
|
41
|
+
for name: Variant in values:
|
|
42
|
+
if hinted.get(str(name).capitalize()) == values[name]:
|
|
43
|
+
spelt[str(name)] = values[name]
|
|
44
|
+
if spelt.size() == hinted.size():
|
|
45
|
+
return spelt
|
|
46
|
+
return hinted
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## The number [param word] names among [param choices], matched as written and then ignoring case,
|
|
50
|
+
## or null when it names none.
|
|
51
|
+
static func enum_named(choices: Dictionary, word: String) -> Variant:
|
|
52
|
+
var wanted: String = word.strip_edges()
|
|
53
|
+
if choices.has(wanted):
|
|
54
|
+
return choices[wanted]
|
|
55
|
+
for name: String in choices:
|
|
56
|
+
if name.to_lower() == wanted.to_lower():
|
|
57
|
+
return choices[name]
|
|
58
|
+
return null
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
static func enum_refusal(holder: String, property: String, word: String, choices: Dictionary) -> String:
|
|
62
|
+
var said: String = (
|
|
63
|
+
"%s.%s is an enum, and %s is none of its values: send %s by name or by number"
|
|
64
|
+
% [holder, property, word, _listed(choices)]
|
|
65
|
+
)
|
|
66
|
+
var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
|
|
67
|
+
if close.is_empty():
|
|
68
|
+
return said + "."
|
|
69
|
+
return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## Why [param value], shown as [param shown], lies outside what [param declared] allows, an export
|
|
73
|
+
## range or an enum's values, or "" when it does not. A range marked or_greater or or_less is open
|
|
74
|
+
## on that side.
|
|
75
|
+
static func outside(target: Object, declared: Dictionary, value: Variant, shown: String) -> String:
|
|
76
|
+
var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
|
|
77
|
+
var hint_string: String = str(declared.get("hint_string", ""))
|
|
78
|
+
var property: String = str(declared.get("name", ""))
|
|
79
|
+
if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
|
|
80
|
+
return ""
|
|
81
|
+
var number: float = GdhRead.as_float(value)
|
|
82
|
+
if hint == PROPERTY_HINT_ENUM and typeof(value) == TYPE_INT:
|
|
83
|
+
var choices: Dictionary = enum_of(target, declared)
|
|
84
|
+
if choices.is_empty() or choices.values().has(value):
|
|
85
|
+
return ""
|
|
86
|
+
return "%d is none of the values %s declares: %s." % [value, property, _listed(choices)]
|
|
87
|
+
if hint != PROPERTY_HINT_RANGE:
|
|
88
|
+
return ""
|
|
89
|
+
var parts: PackedStringArray = []
|
|
90
|
+
for part: String in hint_string.split(","):
|
|
91
|
+
var _added: bool = parts.append(part.strip_edges())
|
|
92
|
+
if parts.size() < 2 or not parts[0].is_valid_float() or not parts[1].is_valid_float():
|
|
93
|
+
return ""
|
|
94
|
+
var low: float = parts[0].to_float()
|
|
95
|
+
var high: float = parts[1].to_float()
|
|
96
|
+
if (number <= high or parts.has("or_greater")) and (number >= low or parts.has("or_less")):
|
|
97
|
+
return ""
|
|
98
|
+
return (
|
|
99
|
+
(
|
|
100
|
+
"%s is outside the range %s declares, %s to %s, which the inspector holds it to; the "
|
|
101
|
+
+ "engine stores it as given."
|
|
102
|
+
)
|
|
103
|
+
% [shown, property, _bound(low), _bound(high)]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
static func _listed(choices: Dictionary) -> String:
|
|
108
|
+
var listed: PackedStringArray = []
|
|
109
|
+
for name: String in choices:
|
|
110
|
+
var _added: bool = listed.append("%s (%d)" % [name, choices[name]])
|
|
111
|
+
return ", ".join(listed)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## A range's end as the script wrote it: the engine's hint gives @export_range(0, 10) as 0.0 and 10.0.
|
|
115
|
+
static func _bound(end: float) -> String:
|
|
116
|
+
return str(int(end)) if end == floorf(end) and absf(end) < 1e15 else str(end)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://c2bcafdf9rzgo
|
|
@@ -427,6 +427,11 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
|
|
|
427
427
|
}
|
|
428
428
|
if script != null:
|
|
429
429
|
answer["script"] = script.resource_path
|
|
430
|
+
# resourceType is the engine class under the script, which for a class the project declares
|
|
431
|
+
# is Resource whatever create was given.
|
|
432
|
+
var declared_class: String = str(script.get_global_name())
|
|
433
|
+
if not declared_class.is_empty():
|
|
434
|
+
answer["scriptClass"] = declared_class
|
|
430
435
|
return answer
|
|
431
436
|
|
|
432
437
|
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@ extends RefCounted
|
|
|
14
14
|
## write accepts. What is here on top of it is the part only a property knows: the type wanted, so a
|
|
15
15
|
## dictionary shaped like a vector or a bare pair of numbers can be read as one.
|
|
16
16
|
|
|
17
|
+
const GdhDeclared = preload("declared_values.gd")
|
|
17
18
|
const GdhRead = preload("reading.gd")
|
|
18
19
|
const GdhSerialisation = preload("serialisation.gd")
|
|
19
20
|
const GdhShapes = preload("value_shapes.gd")
|
|
@@ -78,6 +79,11 @@ var _refused: String = ""
|
|
|
78
79
|
# offsets. An answer naming only what was given leaves the caller to guess the rest.
|
|
79
80
|
var _also_changed: Dictionary = {}
|
|
80
81
|
|
|
82
|
+
# The values the last write_all wrote outside what their property declares, by name, as a sentence
|
|
83
|
+
# saying so: a number past an export range, or one no enum value has. The engine stores either, and
|
|
84
|
+
# the inspector would not have let it be entered, so a write answered as made said nothing of it.
|
|
85
|
+
var _outside_declared: Dictionary = {}
|
|
86
|
+
|
|
81
87
|
|
|
82
88
|
## Turns what arrived over the wire into the Godot value a property wants.
|
|
83
89
|
##
|
|
@@ -177,25 +183,38 @@ func cannot_hold(
|
|
|
177
183
|
## caller returns the refusal instead of saving.
|
|
178
184
|
func write_all(target: Object, properties: Dictionary) -> String:
|
|
179
185
|
_also_changed = {}
|
|
186
|
+
_outside_declared = {}
|
|
180
187
|
var named: Dictionary = {}
|
|
181
188
|
for key: Variant in properties:
|
|
182
189
|
named[str(key).get_slice(":", 0)] = true
|
|
183
190
|
var before: Dictionary = _stored(target, named)
|
|
184
191
|
var given: Dictionary = {}
|
|
192
|
+
var declarations: Dictionary = {}
|
|
193
|
+
# A refusal of one of several leaves the others unwritten too, which a refusal naming only the
|
|
194
|
+
# one it refused left a caller to guess.
|
|
195
|
+
var unwritten: String = (
|
|
196
|
+
"" if properties.size() < 2 else " None of the %d properties given was written." % properties.size()
|
|
197
|
+
)
|
|
185
198
|
for key: Variant in properties:
|
|
186
199
|
var property: String = str(key)
|
|
187
200
|
var declared: Dictionary = declaration(target, property)
|
|
188
201
|
if declared.is_empty() and ":" in property:
|
|
189
202
|
declared = _component_slot(target, property)
|
|
190
203
|
if declared.has("refused"):
|
|
191
|
-
return str(declared["refused"])
|
|
204
|
+
return str(declared["refused"]) + unwritten
|
|
205
|
+
if declared.is_empty() and target is ShaderMaterial and property.begins_with("shader_parameter/"):
|
|
206
|
+
return _no_uniform(target as ShaderMaterial, property) + unwritten
|
|
192
207
|
if declared.is_empty():
|
|
193
|
-
return "%s has no property %s" % [target
|
|
208
|
+
return "%s has no property %s." % [class_called(target), property] + unwritten
|
|
194
209
|
var value: Array = value_for(target, declared, properties[key])
|
|
195
210
|
if not value[0]:
|
|
196
|
-
return str(value[1])
|
|
211
|
+
return str(value[1]) + unwritten
|
|
212
|
+
var outside: String = GdhDeclared.outside(target, declared, value[1], shown(value[1]))
|
|
213
|
+
if not outside.is_empty():
|
|
214
|
+
_outside_declared[property] = outside
|
|
197
215
|
_write(target, property, value[1])
|
|
198
216
|
given[property] = value[1]
|
|
217
|
+
declarations[property] = declared
|
|
199
218
|
|
|
200
219
|
for property: String in given:
|
|
201
220
|
if not same(read_at(target, property), given[property]):
|
|
@@ -203,13 +222,8 @@ func write_all(target: Object, properties: Dictionary) -> String:
|
|
|
203
222
|
for property: String in given:
|
|
204
223
|
var held: Variant = read_at(target, property)
|
|
205
224
|
if not same(held, given[property]):
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"%s.%s was given %s and holds %s after it was set: the engine changed or refused the "
|
|
209
|
-
+ "value, so nothing was written."
|
|
210
|
-
)
|
|
211
|
-
% [target.get_class(), property, shown(given[property]), shown(held)]
|
|
212
|
-
)
|
|
225
|
+
var declared: Dictionary = declarations[property]
|
|
226
|
+
return _not_held(target, property, given[property], held, declared)
|
|
213
227
|
for property: String in before:
|
|
214
228
|
var now: Variant = target.get(property)
|
|
215
229
|
if not same(now, before[property]):
|
|
@@ -217,11 +231,83 @@ func write_all(target: Object, properties: Dictionary) -> String:
|
|
|
217
231
|
return ""
|
|
218
232
|
|
|
219
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
|
+
|
|
285
|
+
## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
|
|
286
|
+
## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
|
|
287
|
+
## material.
|
|
288
|
+
static func _no_uniform(material: ShaderMaterial, property: String) -> String:
|
|
289
|
+
var uniform: String = property.trim_prefix("shader_parameter/")
|
|
290
|
+
if material.shader == null:
|
|
291
|
+
return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
|
|
292
|
+
var shader: String = material.shader.resource_path
|
|
293
|
+
var named: String = shader if not shader.is_empty() else "Its shader"
|
|
294
|
+
var uniforms: Array = []
|
|
295
|
+
for entry: Dictionary in material.shader.get_shader_uniform_list():
|
|
296
|
+
uniforms.append(str(entry["name"]))
|
|
297
|
+
var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
|
|
298
|
+
if uniforms.is_empty():
|
|
299
|
+
return said + "; it declares no uniforms."
|
|
300
|
+
return GdhRead.offered(said, uniform, uniforms) + "."
|
|
301
|
+
|
|
302
|
+
|
|
220
303
|
## Puts under alsoChanged in [param answer] each property the last [method write_all] changed
|
|
221
|
-
## without being given it, as {was, now},
|
|
222
|
-
##
|
|
223
|
-
##
|
|
224
|
-
|
|
304
|
+
## without being given it, as {was, now}, and under outsideDeclared each value it wrote outside what
|
|
305
|
+
## its property declares, when there are any. A node is named by its path from [param relative_to],
|
|
306
|
+
## as a scene answer names one. Called before the scene is written, since the write frees the nodes
|
|
307
|
+
## these values can hold.
|
|
308
|
+
func note_write(answer: Dictionary, relative_to: Node = null) -> void:
|
|
309
|
+
if not _outside_declared.is_empty():
|
|
310
|
+
answer["outsideDeclared"] = _outside_declared.duplicate()
|
|
225
311
|
if _also_changed.is_empty():
|
|
226
312
|
return
|
|
227
313
|
var noted: Dictionary = {}
|
|
@@ -324,6 +410,18 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
|
|
|
324
410
|
var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
|
|
325
411
|
if expected_type == TYPE_OBJECT and hint == PROPERTY_HINT_NODE_TYPE:
|
|
326
412
|
return _node_reference(target, property, raw)
|
|
413
|
+
# An enum is written by the name of a value as well as its number, since the name is what the
|
|
414
|
+
# script and the inspector show and the number is only where the value happens to sit.
|
|
415
|
+
if expected_type == TYPE_INT and hint == PROPERTY_HINT_ENUM and typeof(raw) == TYPE_STRING:
|
|
416
|
+
var choices: Dictionary = GdhDeclared.enum_of(target, declared)
|
|
417
|
+
var chosen: Variant = GdhDeclared.enum_named(choices, str(raw))
|
|
418
|
+
if chosen == null:
|
|
419
|
+
var hinted: Dictionary = GdhDeclared.enum_values(str(declared.get("hint_string", "")))
|
|
420
|
+
chosen = GdhDeclared.enum_named(hinted, str(raw))
|
|
421
|
+
if chosen != null:
|
|
422
|
+
return [true, chosen]
|
|
423
|
+
if not str(raw).strip_edges().is_valid_int():
|
|
424
|
+
return [false, GdhDeclared.enum_refusal(class_called(target), property, str(raw), choices)]
|
|
327
425
|
|
|
328
426
|
# A resource-valued property takes the path of one, which is how a caller names a TileSet,
|
|
329
427
|
# a material or a theme: there is no other way to hand a tool a Resource.
|
|
@@ -339,12 +437,12 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
|
|
|
339
437
|
return [false, _fraction_refusal(target, property, raw)]
|
|
340
438
|
var rounded: String = GdhSerialisation.inexact(raw, expected_type)
|
|
341
439
|
if not rounded.is_empty():
|
|
342
|
-
return [false, "%s.%s holds int: %s." % [target
|
|
440
|
+
return [false, "%s.%s holds int: %s." % [class_called(target), property, rounded]]
|
|
343
441
|
_refused = ""
|
|
344
442
|
value = parse(raw, expected_type, elements)
|
|
345
443
|
if not _refused.is_empty():
|
|
346
444
|
return [false, _refused]
|
|
347
|
-
var refusal: String = cannot_hold(target
|
|
445
|
+
var refusal: String = cannot_hold(class_called(target), property, value, expected_type, elements)
|
|
348
446
|
if not refusal.is_empty():
|
|
349
447
|
return [false, refusal]
|
|
350
448
|
if value is Object:
|
|
@@ -489,10 +587,43 @@ func _fraction_refusal(target: Object, property: String, raw: Variant) -> String
|
|
|
489
587
|
"%s.%s holds whole numbers, and %s would be cut to fit. Setting it would save a different "
|
|
490
588
|
+ "value than the one asked for."
|
|
491
589
|
)
|
|
492
|
-
% [target
|
|
590
|
+
% [class_called(target), property, shown(raw)]
|
|
493
591
|
)
|
|
494
592
|
|
|
495
593
|
|
|
594
|
+
## [param raw] as a colour, read as a property write reads one, as [true, Color] or [false, why]:
|
|
595
|
+
## fields from 0 to 1 with a optional, or an html code. [param named] is what the refusal calls it.
|
|
596
|
+
func colour_from(raw: Variant, named: String) -> Array:
|
|
597
|
+
_refused = ""
|
|
598
|
+
var value: Variant = parse(raw, TYPE_COLOR)
|
|
599
|
+
if not _refused.is_empty():
|
|
600
|
+
return [false, _refused]
|
|
601
|
+
if value is Color:
|
|
602
|
+
return [true, value]
|
|
603
|
+
return [
|
|
604
|
+
false,
|
|
605
|
+
(
|
|
606
|
+
(
|
|
607
|
+
'%s is {"r": .., "g": .., "b": .., "a": ..} from 0 to 1, a optional, or an html code such as '
|
|
608
|
+
+ '"#ff8000" or "#ff800080"; %s is neither.'
|
|
609
|
+
)
|
|
610
|
+
% [named, JSON.stringify(raw)]
|
|
611
|
+
)
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
## What [param target] is called in a refusal: the class_name its script declares, or else its
|
|
616
|
+
## engine class. A resource made as a project class is a Resource to the engine, and "Resource.kind"
|
|
617
|
+
## named a property no Resource has.
|
|
618
|
+
static func class_called(target: Object) -> String:
|
|
619
|
+
var attached: Variant = target.get_script()
|
|
620
|
+
if attached is Script:
|
|
621
|
+
var script: Script = attached
|
|
622
|
+
if not str(script.get_global_name()).is_empty():
|
|
623
|
+
return str(script.get_global_name())
|
|
624
|
+
return target.get_class()
|
|
625
|
+
|
|
626
|
+
|
|
496
627
|
## The resource [param path] names, as [found, resource or why not].
|
|
497
628
|
##
|
|
498
629
|
## The project boundary is enforced here as well as on the server, because only the engine knows
|
|
@@ -516,6 +647,19 @@ func _resource_at(path: String, property: String) -> Array:
|
|
|
516
647
|
)
|
|
517
648
|
]
|
|
518
649
|
if not ResourceLoader.exists(path):
|
|
650
|
+
# An image or a sound nobody has imported is a file the engine has no loader for, and "No
|
|
651
|
+
# resource at" sent a caller looking for a file that was there.
|
|
652
|
+
if path.begins_with("res://") and FileAccess.file_exists(path):
|
|
653
|
+
return [
|
|
654
|
+
false,
|
|
655
|
+
(
|
|
656
|
+
(
|
|
657
|
+
"%s is there, but it has never been imported, so the engine has no resource to load"
|
|
658
|
+
+ " from it for %s. project_import reimport with resourcePath %s imports it."
|
|
659
|
+
)
|
|
660
|
+
% [path, property, path]
|
|
661
|
+
)
|
|
662
|
+
]
|
|
519
663
|
return [false, "No resource at %s for %s" % [path, property]]
|
|
520
664
|
return [true, load(path)]
|
|
521
665
|
|
|
@@ -164,6 +164,43 @@ static func not_of_kind(
|
|
|
164
164
|
return told + " The scene's %ss are at %s." % [kind, shown]
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
## Up to three of [param names] closest to [param word], closest first, ignoring case: the names a
|
|
168
|
+
## refusal offers for one that is not there, as a typo usually is one of them.
|
|
169
|
+
static func nearest(word: String, names: PackedStringArray) -> PackedStringArray:
|
|
170
|
+
var wanted: String = word.to_lower()
|
|
171
|
+
var close: Array[String] = []
|
|
172
|
+
for name: String in names:
|
|
173
|
+
if name.to_lower().similarity(wanted) >= 0.5:
|
|
174
|
+
close.append(name)
|
|
175
|
+
close.sort_custom(
|
|
176
|
+
func(a: String, b: String) -> bool:
|
|
177
|
+
return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
|
|
178
|
+
)
|
|
179
|
+
return PackedStringArray(close.slice(0, 3))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## [param said], the refusal for [param word], which is not among [param names], with those nearest
|
|
183
|
+
## it when any are, or else every one of them, so the caller has a name to send instead.
|
|
184
|
+
static func offered(said: String, word: String, names: Array) -> String:
|
|
185
|
+
var every: PackedStringArray = PackedStringArray(names)
|
|
186
|
+
var close: PackedStringArray = nearest(word, every)
|
|
187
|
+
if close.is_empty():
|
|
188
|
+
return "%s; it has %s" % [said, ", ".join(every)]
|
|
189
|
+
return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
## The signals [param object] has that a caller can connect, its own class's first: the engine
|
|
193
|
+
## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
|
|
194
|
+
## starting with an underscore are the engine's own.
|
|
195
|
+
static func signals_of(object: Object) -> Array:
|
|
196
|
+
var names: Array = []
|
|
197
|
+
for entry: Dictionary in object.get_signal_list():
|
|
198
|
+
var name: String = str(entry["name"])
|
|
199
|
+
if not name.begins_with("_"):
|
|
200
|
+
names.push_front(name)
|
|
201
|
+
return names
|
|
202
|
+
|
|
203
|
+
|
|
167
204
|
## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
|
|
168
205
|
static func with_article(word: String) -> String:
|
|
169
206
|
return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
|
|
@@ -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
|
|
|
@@ -410,6 +410,10 @@ func fitted(value: Variant, type: int) -> Variant:
|
|
|
410
410
|
var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
|
|
411
411
|
if not simple.has(type) or not simple.has(typeof(rebuilt)):
|
|
412
412
|
return rebuilt
|
|
413
|
+
# A number or a truth value stays as sent where text is wanted, for the refusal to name. Converted,
|
|
414
|
+
# 1 arrived as the double it is in JSON and was saved as "1.0", which is not the text anyone sends.
|
|
415
|
+
if type == TYPE_STRING and not rebuilt is String:
|
|
416
|
+
return rebuilt
|
|
413
417
|
|
|
414
418
|
# Digits are read as digits, since read as JSON they become a double and one past 2^53 a
|
|
415
419
|
# neighbour of the number written.
|
|
@@ -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
|
}
|