gdharness 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/build/cli.js +105 -74
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +3 -2
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +157 -4
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +15 -3
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +44 -23
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +225 -0
  7. package/build/godot/addons/gdharness_editor/property_paths.gd.uid +1 -0
  8. package/build/godot/addons/gdharness_editor/property_values.gd +195 -221
  9. package/build/godot/addons/gdharness_editor/reading.gd +15 -0
  10. package/build/godot/addons/gdharness_editor/same_values.gd +177 -0
  11. package/build/godot/addons/gdharness_editor/same_values.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/scene_file.gd +97 -14
  13. package/build/godot/addons/gdharness_editor/serialisation.gd +75 -3
  14. package/build/godot/addons/gdharness_editor/tool_executor.gd +9 -3
  15. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +38 -35
  16. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -2
  17. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +91 -0
  18. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd.uid +1 -0
  19. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +68 -2
  20. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +94 -333
  21. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +363 -0
  22. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/reading.gd +15 -0
  24. package/build/godot/addons/gdharness_runtime/runtime_input.gd +10 -26
  25. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +3 -2
  26. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +2 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_values.gd +30 -0
  28. package/build/godot/addons/gdharness_runtime/serialisation.gd +75 -3
  29. package/build/godot/operations/animation_reads.gd +3 -2
  30. package/build/godot/operations/declared_values.gd +157 -4
  31. package/build/godot/operations/dependencies.gd +14 -39
  32. package/build/godot/operations/editor_tools.gd +4 -3
  33. package/build/godot/operations/file_reads.gd +15 -3
  34. package/build/godot/operations/gdscript_analysis.gd +229 -75
  35. package/build/godot/operations/gdscript_authoring.gd +142 -34
  36. package/build/godot/operations/import_pipeline.gd +17 -6
  37. package/build/godot/operations/logger.gd +7 -0
  38. package/build/godot/operations/property_paths.gd +225 -0
  39. package/build/godot/operations/property_values.gd +195 -221
  40. package/build/godot/operations/reading.gd +15 -0
  41. package/build/godot/operations/same_values.gd +177 -0
  42. package/build/godot/operations/serialisation.gd +75 -3
  43. package/package.json +1 -1
@@ -58,12 +58,46 @@ static func enum_named(choices: Dictionary, word: String) -> Variant:
58
58
  return null
59
59
 
60
60
 
61
- static func enum_refusal(holder: String, property: String, word: String, choices: Dictionary) -> String:
61
+ ## The values of the engine enum [param property] of [param target] holds, by the constants its
62
+ ## class declares them as: blend_mode's Add is BLEND_MODE_ADD in a script, and the hint gives only
63
+ ## the inspector's word. Empty for an enum a script declares.
64
+ ##
65
+ ## The enum is named by the setter's argument, as Node.ProcessMode, since the property list gives
66
+ ## an engine enum property no class of its own.
67
+ static func engine_constants(target: Object, property: String) -> Dictionary:
68
+ var values: Dictionary = {}
69
+ var engine_class: String = target.get_class()
70
+ var setter: String = str(ClassDB.class_get_property_setter(engine_class, property))
71
+ if setter.is_empty():
72
+ return values
73
+ var named: String = ""
74
+ for method: Dictionary in ClassDB.class_get_method_list(engine_class):
75
+ var arguments: Array = method.get("args", [])
76
+ if str(method.get("name", "")) == setter and not arguments.is_empty():
77
+ var value_argument: Dictionary = arguments[-1]
78
+ named = str(value_argument.get("class_name", ""))
79
+ break
80
+ var holder: String = named.get_slice(".", 0)
81
+ if named.count(".") != 1 or not ClassDB.class_exists(holder):
82
+ return values
83
+ for constant: String in ClassDB.class_get_enum_constants(holder, named.get_slice(".", 1)):
84
+ values[constant] = ClassDB.class_get_integer_constant(holder, constant)
85
+ return values
86
+
87
+
88
+ static func enum_refusal(
89
+ holder: String, property: String, word: String, choices: Dictionary, constants: Dictionary = {}
90
+ ) -> String:
91
+ var ways: String = "by name or by number"
92
+ if not constants.is_empty():
93
+ ways = "by name, by number or by the engine's constant, such as %s" % str(constants.keys()[0])
62
94
  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)]
95
+ "%s.%s is an enum, and %s is none of its values: send %s %s"
96
+ % [holder, property, word, _listed(choices), ways]
65
97
  )
66
- var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
98
+ var named: PackedStringArray = PackedStringArray(choices.keys())
99
+ named.append_array(PackedStringArray(constants.keys()))
100
+ var close: PackedStringArray = GdhRead.nearest(word, named)
67
101
  if close.is_empty():
68
102
  return said + "."
69
103
  return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
@@ -76,6 +110,17 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
76
110
  var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
77
111
  var hint_string: String = str(declared.get("hint_string", ""))
78
112
  var property: String = str(declared.get("name", ""))
113
+ if hint == PROPERTY_HINT_ENUM and (typeof(value) == TYPE_STRING or typeof(value) == TYPE_STRING_NAME):
114
+ var names: PackedStringArray = PackedStringArray(enum_values(hint_string).keys())
115
+ if names.is_empty() or names.has(str(value)):
116
+ return ""
117
+ return (
118
+ (
119
+ "%s is none of the names %s lists: %s, which the inspector holds it to; the engine "
120
+ + "stores it as given."
121
+ )
122
+ % [shown, property, ", ".join(names)]
123
+ )
79
124
  if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
80
125
  return ""
81
126
  var number: float = GdhRead.as_float(value)
@@ -114,3 +159,111 @@ static func _listed(choices: Dictionary) -> String:
114
159
  ## 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
160
  static func _bound(end: float) -> String:
116
161
  return str(int(end)) if end == floorf(end) and absf(end) < 1e15 else str(end)
162
+
163
+
164
+ ## Puts back on [param built] what its script set as it was attached and was not among
165
+ ## [param wanted], to the defaults it declares, outside the editor. Attaching runs the script's _init
166
+ ## there, which the editor never runs for a script that is not a tool, so a resource_name _init chose
167
+ ## was saved as if asked for. A property whose default the engine cannot report is left as made.
168
+ static func unmade(built: Resource, wanted: Dictionary) -> void:
169
+ var attached: Variant = built.get_script()
170
+ if Engine.is_editor_hint() or not attached is Script:
171
+ return
172
+ var script: Script = attached
173
+ var asked: Dictionary = {}
174
+ for key: Variant in wanted:
175
+ asked[str(key).get_slice(":", 0)] = true
176
+ for entry: Dictionary in built.get_property_list():
177
+ var name: String = str(entry["name"])
178
+ var usage: int = GdhRead.as_int(entry["usage"])
179
+ if usage & PROPERTY_USAGE_STORAGE == 0 or name == "script" or asked.has(name):
180
+ continue
181
+ var declared: Variant = ClassDB.class_get_property_default_value(built.get_class(), name)
182
+ if usage & PROPERTY_USAGE_SCRIPT_VARIABLE:
183
+ declared = script.get_property_default_value(name)
184
+ var held: Variant = built.get(name)
185
+ if declared != null and not (typeof(held) == typeof(declared) and held == declared):
186
+ built.set(name, declared)
187
+
188
+
189
+ ## The element types a typed Array or Dictionary property declares: [element] for an array,
190
+ ## [key, value] for a dictionary, [] when nothing is declared.
191
+ ##
192
+ ## Read from the declaration because the value cannot say: a script's exported `Array[int]` sits
193
+ ## behind a placeholder in the editor, and measured on 4.7.2 that placeholder stores [1.5, 2.0]
194
+ ## as given, which the game then fails to load into the typed array.
195
+ static func elements_of(declared: Dictionary) -> Array:
196
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
197
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
198
+ var wanted: String = str(declared.get("hint_string", ""))
199
+ if wanted.is_empty():
200
+ return []
201
+ if hint == PROPERTY_HINT_TYPE_STRING:
202
+ var parts: PackedStringArray = wanted.split(";")
203
+ if type == TYPE_ARRAY and parts.size() == 1:
204
+ return [_type_in_hint(parts[0])]
205
+ if type == TYPE_DICTIONARY and parts.size() == 2:
206
+ return [_type_in_hint(parts[0]), _type_in_hint(parts[1])]
207
+ return []
208
+ if hint == PROPERTY_HINT_ARRAY_TYPE and type == TYPE_ARRAY:
209
+ return [_type_named(wanted)]
210
+ if hint == PROPERTY_HINT_DICTIONARY_TYPE and type == TYPE_DICTIONARY:
211
+ var parts: PackedStringArray = wanted.split(";")
212
+ if parts.size() == 2:
213
+ return [_type_named(parts[0]), _type_named(parts[1])]
214
+ return []
215
+
216
+
217
+ ## The type at the front of one entry of a type-string hint, "2:" or "24/17:Texture2D".
218
+ static func _type_in_hint(entry: String) -> int:
219
+ var number: String = entry.get_slice(":", 0).get_slice("/", 0)
220
+ return number.to_int() if number.is_valid_int() else TYPE_NIL
221
+
222
+
223
+ ## The Variant type [param name] names, or TYPE_OBJECT for a class.
224
+ static func _type_named(name: String) -> int:
225
+ var named: String = name.get_slice(":", 0).strip_edges()
226
+ for type: int in TYPE_MAX:
227
+ if type_string(type) == named:
228
+ return type
229
+ return TYPE_OBJECT if not named.is_empty() else TYPE_NIL
230
+
231
+
232
+ ## The class the objects of a typed list or map hold, and whether they are nodes, as
233
+ ## [class, nodes], read from entry [param at] of [param declared]'s hint: "24/17:Item" in a type
234
+ ## string, "Item" in an array or dictionary type.
235
+ static func object_kind(declared: Dictionary, at: int) -> Array:
236
+ var entry: String = str(declared.get("hint_string", "")).get_slice(";", at)
237
+ if GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE)) != PROPERTY_HINT_TYPE_STRING:
238
+ return [entry.strip_edges(), false]
239
+ var nodes: bool = entry.get_slice(":", 0).get_slice("/", 1) == str(PROPERTY_HINT_NODE_TYPE)
240
+ return [entry.get_slice(":", 1), nodes]
241
+
242
+
243
+ ## The base class and script that type a list or map holding [param wanted], as [class, script],
244
+ ## or [] when nothing in the project or the engine is called that.
245
+ static func object_typing(wanted: String) -> Array:
246
+ if ClassDB.class_exists(wanted):
247
+ return [StringName(wanted), null]
248
+ var script: Script = script_class(wanted)
249
+ return [] if script == null else [script.get_instance_base_type(), script]
250
+
251
+
252
+ ## The script declaring the class_name [param name], or null.
253
+ static func script_class(name: String) -> Script:
254
+ for entry: Dictionary in ProjectSettings.get_global_class_list():
255
+ if str(entry.get("class", "")) == name:
256
+ return load(str(entry.get("path", ""))) as Script
257
+ return null
258
+
259
+
260
+ ## Whether [param value] is the class [param name] names, an engine class or a script's class_name.
261
+ static func is_a(value: Object, name: String) -> bool:
262
+ if value.is_class(name):
263
+ return true
264
+ var script: Script = value.get_script()
265
+ while script != null:
266
+ if str(script.get_global_name()) == name:
267
+ return true
268
+ script = script.get_base_script()
269
+ return false
@@ -10,8 +10,8 @@ extends RefCounted
10
10
  ## without making a node. Loading the file still does what loading does in the game: static
11
11
  ## initialisers run, and a resource with a script is made with it.
12
12
 
13
- const GdhPropertyValues = preload("property_values.gd")
14
13
  const GdhRead = preload("reading.gd")
14
+ const GdhSame = preload("same_values.gd")
15
15
  const GdhSerialisation = preload("serialisation.gd")
16
16
 
17
17
 
@@ -188,6 +188,12 @@ static func _lay(
188
188
  path,
189
189
  state.get_node_index(index)
190
190
  )
191
+ # Every file on the way puts the node in its own groups: the scene making it, and each scene
192
+ # instancing or inheriting that one.
193
+ var grouped: Array[String] = nodes[path]["groups"]
194
+ for group: StringName in state.get_node_groups(index):
195
+ if not grouped.has(str(group)):
196
+ grouped.append(str(group))
191
197
  var held: Dictionary = nodes[path]["properties"]
192
198
  for property: int in state.get_node_property_count(index):
193
199
  held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
@@ -281,7 +287,8 @@ static func _by_id(nodes: Dictionary, owner: String, id: int) -> String:
281
287
 
282
288
  static func _made(type: String) -> Dictionary:
283
289
  var children: Array[String] = []
284
- return {"name": "", "type": type, "properties": {}, "children": children}
290
+ var groups: Array[String] = []
291
+ return {"name": "", "type": type, "properties": {}, "children": children, "groups": groups}
285
292
 
286
293
 
287
294
  ## Puts [param path] among [param parent]'s children, at [param at] when the file says where (a node
@@ -317,6 +324,11 @@ static func _entry(
317
324
  data["instanceOf"] = node["instanceOf"]
318
325
  if node.has("editableChildren"):
319
326
  data["editableChildren"] = node["editableChildren"]
327
+ var groups: Array[String] = node["groups"]
328
+ if not groups.is_empty():
329
+ var sorted: Array[String] = groups.duplicate()
330
+ sorted.sort()
331
+ data["groups"] = sorted
320
332
  if include_properties:
321
333
  var given: Dictionary = node["properties"]
322
334
  data["properties"] = _stored(str(node["type"]), given)
@@ -419,7 +431,7 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
419
431
  if scripted.has(property)
420
432
  else ClassDB.class_get_property_default_value(loaded.get_class(), property)
421
433
  )
422
- if GdhPropertyValues.same(value, default):
434
+ if GdhSame.same(value, default):
423
435
  continue
424
436
  properties[property] = serialiser.serialize_value(value)
425
437
  var answer: Dictionary = {
@@ -11,8 +11,9 @@ extends RefCounted
11
11
  ## both, so a property a built-in setter derives (a Control's offsets from its size) is left alone
12
12
  ## unless a script that is not a tool is involved.
13
13
 
14
- const GdhPropertyValues = preload("property_values.gd")
14
+ const GdhPropertyPaths = preload("property_paths.gd")
15
15
  const GdhRead = preload("reading.gd")
16
+ const GdhSame = preload("same_values.gd")
16
17
 
17
18
  const REMEDY: String = (
18
19
  "Open the project's editor (editor_launch with hidden: true) and ask again: the write then goes "
@@ -78,7 +79,7 @@ static func differing(object: Object, before: Dictionary, names: Dictionary) ->
78
79
  if names.has(name):
79
80
  continue
80
81
  var was: Variant = before[name] if before.has(name) else default_of(object, name)
81
- if not GdhPropertyValues.same(now[name], was):
82
+ if not GdhSame.same(now[name], was):
82
83
  changed.append(name)
83
84
  return changed
84
85
 
@@ -122,28 +123,46 @@ static func _initialised(script: Script, name: String, type: int) -> Variant:
122
123
  ## nothing a script did: the saver writes a property whose default the engine cannot report whatever
123
124
  ## it holds, the editor's save as well.
124
125
  static func at_default(object: Object, name: String) -> bool:
125
- return GdhPropertyValues.same(object.get(name), default_of(object, name))
126
+ return GdhSame.same(object.get(name), default_of(object, name))
126
127
 
127
128
 
128
- ## The ids, in the file, of the embedded resources [param property] reaches into on [param object],
129
- ## such as the material of [code]material:albedo_color[/code], as a set.
129
+ ## What [param property] writes in the embedded resource it reaches into on [param object], by its
130
+ ## id in the file, as the set of names written in it: the hp of the resource built in as inner for
131
+ ## [code]inner/hp[/code], or position for [code]inner/position:x[/code]. Only those names are spared
132
+ ## from the check for what a script changed: the resource's _init still ran when it was loaded, and
133
+ ## sparing the whole resource saved what that set.
130
134
  static func reached(object: Object, property: String) -> Dictionary:
131
135
  var ids: Dictionary = {}
132
- var parts: PackedStringArray = property.split(":")
133
- var current: Variant = object
134
- for index: int in parts.size() - 1:
135
- if not current is Object:
136
- break
137
- var holder: Object = current
138
- current = holder.get(parts[index])
139
- if current is Resource:
140
- var resource: Resource = current
141
- var id: String = resource.get_scene_unique_id()
142
- if not id.is_empty():
143
- ids[id] = true
136
+ var place: Array = GdhPropertyPaths.holder_of(object, property)
137
+ var through: Array[Resource] = place[2]
138
+ if through.is_empty():
139
+ return ids
140
+ var holder: Resource = through.back()
141
+ var name: String = place[1]
142
+ if GdhPropertyPaths.declared_on(holder, name).is_empty():
143
+ name = name.get_slice(":", 0)
144
+ var id: String = holder.get_scene_unique_id()
145
+ if not id.is_empty():
146
+ ids[id] = {name: true}
144
147
  return ids
145
148
 
146
149
 
150
+ ## Adds what [param more] spares to [param into], both as [method reached] answers them; true for an
151
+ ## id spares the whole resource, as one a tool rebuilt.
152
+ static func spare(into: Dictionary, more: Dictionary) -> void:
153
+ for id: Variant in more:
154
+ var held: Variant = into.get(id)
155
+ var given: Variant = more[id]
156
+ if held is bool or given is bool:
157
+ into[id] = true
158
+ elif held is Dictionary:
159
+ var names: Dictionary = held
160
+ var added: Dictionary = given
161
+ names.merge(added)
162
+ else:
163
+ into[id] = given
164
+
165
+
147
166
  ## Whether [param object] holds, at any depth, an embedded resource with a script that is not a tool.
148
167
  static func holds_scripted_embedded(object: Object, seen: Dictionary) -> bool:
149
168
  for value: Variant in stored(object).values():
@@ -276,7 +295,7 @@ static func reads_the_same(was: Variant, now: Variant, default: Variant) -> bool
276
295
  return false
277
296
  else:
278
297
  values.append(str_to_var(str(text)))
279
- return GdhPropertyValues.same(values[0], values[1])
298
+ return GdhSame.same(values[0], values[1])
280
299
 
281
300
 
282
301
  static func _main_entries(text: String) -> Dictionary:
@@ -315,9 +334,9 @@ static func scripted_sections(text: String) -> Dictionary:
315
334
 
316
335
  ## Each property an embedded resource of [param after] sets differently from [param before], as
317
336
  ## [its id, name, whether before sets it, the text before gives it, the text after gives it or
318
- ## null]. Only those with a script that
319
- ## is not a tool are compared, since only a script runs differently here, and those in
320
- ## [param reached_ids], which the tool changed, are left out.
337
+ ## null]. Only those with a script that is not a tool are compared, since only a script runs
338
+ ## differently here, and what [param reached_ids] says the tool wrote is left out, as
339
+ ## [method reached] answers it.
321
340
  static func embedded_changes(before: String, after: String, reached_ids: Dictionary) -> Array[Array]:
322
341
  var changed: Array[Array] = []
323
342
  if before.is_empty():
@@ -327,8 +346,10 @@ static func embedded_changes(before: String, after: String, reached_ids: Diction
327
346
  for key: Variant in scripted_sections(before):
328
347
  var section: String = str(key)
329
348
  var id: String = section.trim_prefix("sub:")
330
- if not section.begins_with("sub:") or reached_ids.has(id) or not new.has(section):
349
+ var spared: Variant = reached_ids.get(id, {})
350
+ if not section.begins_with("sub:") or spared is bool or not new.has(section):
331
351
  continue
352
+ var written: Dictionary = spared
332
353
  var was_section: Dictionary = old[section]
333
354
  var now_section: Dictionary = new[section]
334
355
  var was: Dictionary = entries_of(was_section)
@@ -337,7 +358,7 @@ static func embedded_changes(before: String, after: String, reached_ids: Diction
337
358
  all.merge(now)
338
359
  for entry: Variant in all:
339
360
  var name: String = str(entry)
340
- if was.get(name) != now.get(name):
361
+ if was.get(name) != now.get(name) and not written.has(name):
341
362
  changed.append([id, name, was.has(name), str(was.get(name, "")), now.get(name)])
342
363
  return changed
343
364
 
@@ -0,0 +1,225 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## Where a property a tool writes lives: on the object given, on a resource built into it, or on an
5
+ ## element of one of its lists that the write has to make first.
6
+
7
+ const GdhRead = preload("reading.gd")
8
+
9
+ const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
10
+
11
+
12
+ ## The entry [param target] declares for [param property], or {} when it declares none, headings
13
+ ## left out.
14
+ static func declared_on(target: Object, property: String) -> Dictionary:
15
+ for declared: Dictionary in target.get_property_list():
16
+ if str(declared.get("name", "")) != property:
17
+ continue
18
+ if GdhRead.as_int(declared.get("usage", 0)) & HEADINGS:
19
+ continue
20
+ return declared
21
+ return {}
22
+
23
+
24
+ ## Where [param key] is written: as [holder, name, through], the object holding the property, its
25
+ ## name there, and the resources passed on the way. A key that is none of [param target]'s own may
26
+ ## start with the name of one holding a resource built into it, then a slash and a key of that
27
+ ## resource: "sources/0/0:0/0/probability" is the probability of tile 0:0 in the atlas source a
28
+ ## TileSet keeps as sources/0. A colon path cannot reach it, since the tile's own name holds colons.
29
+ ## A resource kept in a file of its own is not reached, nor, given the file [param home] that
30
+ ## [param target] is saved in, one built into another file, since writing it here would leave that
31
+ ## file as it was.
32
+ static func holder_of(target: Object, key: String, home: String = "") -> Array:
33
+ var through: Array[Resource] = []
34
+ var holder: Object = target
35
+ var name: String = key
36
+ while declared_on(holder, name).is_empty():
37
+ var reached: Resource = null
38
+ var at: int = name.find("/")
39
+ while at > 0:
40
+ var held: Resource = built_in_at(holder, name.left(at), home)
41
+ if held != null:
42
+ reached = held
43
+ break
44
+ at = name.find("/", at + 1)
45
+ if reached == null:
46
+ break
47
+ through.append(reached)
48
+ holder = reached
49
+ name = name.substr(at + 1)
50
+ return [holder, name, through]
51
+
52
+
53
+ ## The resource [param target] holds under [param property] when it is built into a file, or null;
54
+ ## given [param home], only one built into that file or into none yet.
55
+ static func built_in_at(target: Object, property: String, home: String = "") -> Resource:
56
+ var held: Resource = held_at(target, property)
57
+ if held == null or not held.is_built_in():
58
+ return null
59
+ var file: String = held.resource_path.get_slice("::", 0)
60
+ return held if home.is_empty() or file.is_empty() or file == home else null
61
+
62
+
63
+ ## The file [param target] is saved in: its scene's for a node, as the nodes of an instance are
64
+ ## saved in the scene holding it, and its own for a resource; "" when it has none yet.
65
+ static func home_of(target: Object) -> String:
66
+ if target is Node:
67
+ var top: Node = target
68
+ while top.owner != null:
69
+ top = top.owner
70
+ return top.scene_file_path
71
+ if target is Resource:
72
+ var resource: Resource = target
73
+ return resource.resource_path.get_slice("::", 0)
74
+ return ""
75
+
76
+
77
+ ## The resource [param target] holds under [param property], built in or kept in a file, or null.
78
+ static func held_at(target: Object, property: String) -> Resource:
79
+ if declared_on(target, property).is_empty():
80
+ return null
81
+ var held: Variant = target.get(property)
82
+ return held if held is Resource else null
83
+
84
+
85
+ ## Makes the element of a list [param name] names on [param target] when it is the next one, as the
86
+ ## inspector's Add Element does, and answers "" (also when [param name] is in no list), or why not,
87
+ ## [param called] being what [param target] is called. The engine lists an element's properties
88
+ ## only once the element exists, so a TileSet with no physics layer had no
89
+ ## physics_layer_0/collision_layer to write. A list the engine declares as an array grows through
90
+ ## the method named after its elements, add_physics_layer for physics_layer_, given the indices of
91
+ ## the lists around it; a list counted by a property, as a tile's polygons are by polygons_count,
92
+ ## grows by raising the count.
93
+ static func grow(target: Object, name: String, called: String) -> String:
94
+ var steps: PackedStringArray = name.split("/")
95
+ for depth: int in steps.size() - 1:
96
+ var step: String = steps[depth]
97
+ var cut: int = step.rfind("_")
98
+ if cut < 1 or not step.substr(cut + 1).is_valid_int():
99
+ continue
100
+ var index: int = step.substr(cut + 1).to_int()
101
+ var above: String = "/".join(steps.slice(0, depth))
102
+ var under: String = above + "/" if not above.is_empty() else ""
103
+ var prefix: String = under + step.left(cut + 1)
104
+ var element: String = step.left(cut)
105
+ var counted: String = under + element + "s_count"
106
+ var count: int = -1
107
+ var adding: Callable
108
+ if not declared_on(target, counted).is_empty():
109
+ count = GdhRead.as_int(target.get(counted))
110
+ adding = func() -> void: target.set(counted, count + 1)
111
+ elif _listed_array(target, prefix):
112
+ count = _elements(target, prefix)
113
+ var method: String = "add_" + element
114
+ var outer: Array = []
115
+ for enclosing: String in steps.slice(0, depth):
116
+ var at: int = enclosing.rfind("_")
117
+ if at > 0 and enclosing.substr(at + 1).is_valid_int():
118
+ outer.append(enclosing.substr(at + 1).to_int())
119
+ if target.has_method(method):
120
+ adding = func() -> void: target.callv(method, outer)
121
+ if count < 0 or index < count:
122
+ continue
123
+ var held: String = (
124
+ "%s has %d %s%s"
125
+ % [
126
+ above if not above.is_empty() else called,
127
+ count,
128
+ element.replace("_", " "),
129
+ "" if count == 1 else "s"
130
+ ]
131
+ )
132
+ if index > count:
133
+ return (
134
+ "%s, and a new one goes after the last, so %s%d cannot be written yet: write %s%d first."
135
+ % [held, prefix, index, prefix, count]
136
+ )
137
+ if not adding.is_valid():
138
+ return "%s, and nothing makes another, so %s cannot be written." % [held, name]
139
+ adding.call()
140
+ return ""
141
+ return ""
142
+
143
+
144
+ ## Whether [param target] declares a list whose elements' properties start with [param prefix],
145
+ ## as TileSet declares physics_layers for physics_layer_0/collision_layer.
146
+ static func _listed_array(target: Object, prefix: String) -> bool:
147
+ for declared: Dictionary in target.get_property_list():
148
+ var usage: int = GdhRead.as_int(declared.get("usage", 0))
149
+ if usage & PROPERTY_USAGE_ARRAY and str(declared.get("class_name", "")) == prefix:
150
+ return true
151
+ return false
152
+
153
+
154
+ ## How many elements of the list whose properties start with [param prefix] [param target] holds.
155
+ static func _elements(target: Object, prefix: String) -> int:
156
+ var count: int = 0
157
+ for declared: Dictionary in target.get_property_list():
158
+ var name: String = str(declared.get("name", ""))
159
+ if not name.begins_with(prefix):
160
+ continue
161
+ var index: String = name.substr(prefix.length()).get_slice("/", 0)
162
+ if index.is_valid_int():
163
+ count = maxi(count, index.to_int() + 1)
164
+ return count
165
+
166
+
167
+ ## What [param target] is called in a refusal: the class_name its script declares, or else its
168
+ ## engine class. A resource made as a project class is a Resource to the engine, and "Resource.kind"
169
+ ## named a property no Resource has.
170
+ static func class_called(target: Object) -> String:
171
+ var attached: Variant = target.get_script()
172
+ if attached is Script:
173
+ var script: Script = attached
174
+ if not str(script.get_global_name()).is_empty():
175
+ return str(script.get_global_name())
176
+ return target.get_class()
177
+
178
+
179
+ ## The refusal for [param name], which [param target] does not declare, when it runs through a
180
+ ## resource saved in a file other than [param home], the one [param target] is saved in; or "". The
181
+ ## write belongs in that file, since this one would be saved and that one left as it was: through a
182
+ ## TileMapLayer's tile_set kept in a file of its own, or through the mesh of an instance's editable
183
+ ## child, which is built into the scene the instance comes from.
184
+ static func held_elsewhere(target: Object, name: String, home: String) -> String:
185
+ var at: int = name.find("/")
186
+ while at > 0:
187
+ var head: String = name.left(at)
188
+ var rest: String = name.substr(at + 1)
189
+ var held: Resource = held_at(target, head)
190
+ var file: String = "" if held == null else held.resource_path.get_slice("::", 0)
191
+ if held != null and not held.is_built_in():
192
+ return (
193
+ "%s.%s is %s, a file of its own, so %s is written there, with resource_edit modify on %s."
194
+ % [class_called(target), head, file, rest, file]
195
+ )
196
+ if held != null and not file.is_empty() and not home.is_empty() and file != home:
197
+ return _built_in_elsewhere(target, head, rest, held, file)
198
+ at = name.find("/", at + 1)
199
+ return ""
200
+
201
+
202
+ ## The refusal for writing [param rest] into [param held], which [param target] holds under
203
+ ## [param head] and which is built into [param file], the scene an instance in this one comes from.
204
+ static func _built_in_elsewhere(
205
+ target: Object, head: String, rest: String, held: Resource, file: String
206
+ ) -> String:
207
+ var node: String = ""
208
+ if target is Node:
209
+ var placed: Node = target
210
+ var from: Node = placed
211
+ while from != null and from.scene_file_path != file:
212
+ from = from.owner
213
+ if from != null:
214
+ node = " on %s" % from.get_path_to(placed)
215
+ var text: String = (
216
+ (
217
+ "%s.%s is built into %s, so %s is written there, with scene_node set%s in %s, which changes "
218
+ + "it wherever that scene is used. To change it here alone, give %s a %s of its own: a "
219
+ + 'value with "_type" set to its class and its properties beside it.'
220
+ )
221
+ % [class_called(target), head, file, rest, node, file, head, held.get_class()]
222
+ )
223
+ if target is GeometryInstance3D and (held is Material or rest.contains("material")):
224
+ text += " A material for this instance alone goes in material_override."
225
+ return text
@@ -0,0 +1 @@
1
+ uid://mi7vnabydtlw