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
@@ -7,6 +7,12 @@ const GdhPatterns = preload("patterns.gd")
7
7
  const GdhScriptCheck = preload("script_check.gd")
8
8
 
9
9
  const MODIFICATION_TYPES: Array[String] = ["add_constant", "add_variable", "add_signal", "add_function"]
10
+ ## The class each template's code is written for, and the base a script made from it extends
11
+ ## when given none: the resource template's code is a Resource's, and on the default of Node it
12
+ ## does not parse.
13
+ const TEMPLATE_BASES: Dictionary = {
14
+ "singleton": "Node", "state_machine": "Node", "component": "Node", "resource": "Resource"
15
+ }
10
16
  const BLANKS_AROUND_FUNCTIONS: int = 2
11
17
 
12
18
  var _log: GdhLog
@@ -20,11 +26,11 @@ func _init(p_log: GdhLog) -> void:
20
26
  func create_gdscript(params: Dictionary) -> Dictionary:
21
27
  var script_path: String = str(params.get("script_path", ""))
22
28
  var cls_name_param: String = str(params.get("class_name", ""))
23
- var extends_class: String = str(params.get("extends", "Node"))
24
- if extends_class.is_empty():
25
- extends_class = "Node"
26
29
  var content: String = str(params.get("content", ""))
27
30
  var template: String = str(params.get("template", ""))
31
+ var extends_class: String = str(params.get("extends", ""))
32
+ if extends_class.is_empty():
33
+ extends_class = str(TEMPLATE_BASES.get(template, "Node"))
28
34
 
29
35
  _log.info("Creating GDScript: " + script_path)
30
36
 
@@ -51,7 +57,7 @@ func create_gdscript(params: Dictionary) -> Dictionary:
51
57
  full_script_path = "res://" + full_script_path
52
58
 
53
59
  if not full_script_path.ends_with(".gd"):
54
- return _log.failure("Script path must end with .gd extension")
60
+ return _log.failure(_not_gdscript(full_script_path, "makes"))
55
61
 
56
62
  if FileAccess.file_exists(full_script_path):
57
63
  return _log.failure(
@@ -62,6 +68,8 @@ func create_gdscript(params: Dictionary) -> Dictionary:
62
68
  # and the first does not compile until the second exists.
63
69
  if content.is_empty():
64
70
  var header: String = _header_refusal(cls_name_param, extends_class)
71
+ if header.is_empty() and not template.is_empty():
72
+ header = _template_refusal(template, extends_class, full_script_path)
65
73
  if not header.is_empty():
66
74
  return _log.failure(header + " Nothing was written.")
67
75
 
@@ -80,8 +88,9 @@ func create_gdscript(params: Dictionary) -> Dictionary:
80
88
  script_content += "extends " + extends_class + "\n"
81
89
  # Only what was asked for: with no template the file was given an empty _ready beside a
82
90
  # template_used of "none", which a caller had then to delete or leave lying in the script.
91
+ # Each template opens with the class's doc comment, which belongs straight below the header.
83
92
  if not template.is_empty():
84
- script_content += "\n" + _script_template(template)
93
+ script_content += _script_template(template)
85
94
 
86
95
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.WRITE)
87
96
  if not file:
@@ -93,21 +102,56 @@ func create_gdscript(params: Dictionary) -> Dictionary:
93
102
  return _log.failure("Failed to write to script file: " + full_script_path)
94
103
 
95
104
  var written: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
105
+ var parses: bool = _parses(written)
96
106
  var answer: Dictionary = {
97
107
  "success": true,
98
108
  "script_path": script_path,
99
109
  "full_path": full_script_path,
100
110
  "absolute_path": ProjectSettings.globalize_path(full_script_path),
101
111
  "template_used": template if not template.is_empty() else "none",
102
- "parses": _parses(written),
112
+ "parses": parses,
103
113
  }
104
- # Read off the file as the engine parsed it rather than off the arguments, which content leaves out.
105
- if written != null:
114
+ # Read off the file as the engine parsed it rather than off the arguments, which content leaves
115
+ # out. A script that does not parse tells the engine nothing, and its empty base and name were
116
+ # answered for a file whose header says otherwise, so those are read off the text instead.
117
+ if parses:
106
118
  answer["extends"] = _base_named(written)
107
119
  answer["class_name"] = str(written.get_global_name())
120
+ else:
121
+ answer.merge(_header_written(script_content))
108
122
  return answer
109
123
 
110
124
 
125
+ ## The refusal for [param path], which is no GDScript file, naming what [param doing] it instead:
126
+ ## a shader is made with resource_edit, and once it exists its text is edited as a file's.
127
+ static func _not_gdscript(path: String, doing: String) -> String:
128
+ var refusal: String = "%s is not a .gd file, and script_edit %s GDScript only." % [path, doing]
129
+ if path.get_extension() != "gdshader":
130
+ return refusal
131
+ if doing == "makes":
132
+ return refusal + " resource_edit create_shader makes a shader, with its source as code."
133
+ return refusal + " A shader's text is edited as a file."
134
+
135
+
136
+ ## The class_name and extends [param text] declares at its margin, as written. A script with no
137
+ ## extends extends RefCounted.
138
+ static func _header_written(text: String) -> Dictionary:
139
+ var header: Dictionary = {"extends": "RefCounted", "class_name": ""}
140
+ for line: String in text.split("\n"):
141
+ # An inner class's extends is indented, and is not the script's.
142
+ if line.begins_with(" ") or line.begins_with("\t"):
143
+ continue
144
+ var bare: String = GdhPatterns.without_annotations(GdhPatterns.without_comment(line))
145
+ var found: RegExMatch = GdhPatterns.declared_class(bare)
146
+ if found != null:
147
+ header["class_name"] = found.get_string(1)
148
+ if not found.get_string(2).is_empty():
149
+ header["extends"] = found.get_string(2)
150
+ elif bare.begins_with("extends "):
151
+ header["extends"] = bare.substr(8).strip_edges()
152
+ return header
153
+
154
+
111
155
  # Modify an existing GDScript file by adding functions, variables, or signals
112
156
  func modify_gdscript(params: Dictionary) -> Dictionary:
113
157
  var script_path: String = str(params.get("script_path", ""))
@@ -119,6 +163,9 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
119
163
  if not full_script_path.begins_with("res://"):
120
164
  full_script_path = "res://" + full_script_path
121
165
 
166
+ if not full_script_path.ends_with(".gd"):
167
+ return _log.failure(_not_gdscript(full_script_path, "changes"))
168
+
122
169
  if not FileAccess.file_exists(full_script_path):
123
170
  return _log.failure(
124
171
  GdhFileWalk.new().with_nearest_files(
@@ -135,7 +182,11 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
135
182
 
136
183
  var lines: Array[String] = []
137
184
  lines.assign(original_content.split("\n"))
185
+ # Loaded only to learn whether it parsed as it was. What the engine says of that file carries its
186
+ # old line numbers, and beside the errors of the file written it named lines that had moved.
187
+ printerr(GdhLog.ASIDE)
138
188
  var before: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
189
+ printerr(GdhLog.RESUMED)
139
190
  var parsed_before: bool = _parses(before)
140
191
 
141
192
  # All of them checked before any is made, so a call is made whole or not at all: one skipped for a
@@ -259,6 +310,44 @@ static func _header_refusal(named: String, base: String) -> String:
259
310
  return said + "; the nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
260
311
 
261
312
 
313
+ ## Why [param template] cannot be written on [param base], or "". Each template's code calls
314
+ ## methods of the class it is written for, so a base outside it makes a script that does not parse.
315
+ ## A base whose engine class cannot be read here is left to the engine's parse.
316
+ static func _template_refusal(template: String, base: String, script_path: String) -> String:
317
+ var wanted: String = str(TEMPLATE_BASES.get(template, ""))
318
+ var native: String = _engine_class_of(base, script_path)
319
+ if wanted.is_empty() or native.is_empty():
320
+ return ""
321
+ if native == wanted or ClassDB.is_parent_class(native, wanted):
322
+ return ""
323
+ var named: String = base if native == base else "%s, a %s," % [base, native]
324
+ return (
325
+ (
326
+ "The %s template is written for a %s, and extends %s is not one. Give extends a %s "
327
+ + "class, or leave it out for %s."
328
+ )
329
+ % [template, wanted, named, wanted, wanted]
330
+ )
331
+
332
+
333
+ ## The engine class [param base] comes down to: itself, the one beneath the script a class_name or
334
+ ## a quoted path names, or "" when that script cannot be read.
335
+ static func _engine_class_of(base: String, script_path: String) -> String:
336
+ if ClassDB.class_exists(base):
337
+ return base
338
+ var path: String = ""
339
+ if base.begins_with('"') or base.begins_with("'"):
340
+ path = base.substr(1, base.length() - 2)
341
+ if not path.begins_with("res://") and not path.begins_with("uid://"):
342
+ path = script_path.get_base_dir().path_join(path)
343
+ else:
344
+ path = str(_declared_classes().get(base, ""))
345
+ if path.is_empty() or not ResourceLoader.exists(path):
346
+ return ""
347
+ var script: Script = ResourceLoader.load(path, "Script") as Script
348
+ return "" if script == null else str(script.get_instance_base_type())
349
+
350
+
262
351
  # The class_name each script of the project declares, by name, with its path.
263
352
  static func _declared_classes() -> Dictionary:
264
353
  var declared: Dictionary = {}
@@ -472,7 +561,18 @@ func _add_signal(lines: Array[String], mod: Dictionary, placed: Array[Dictionary
472
561
  signal_line += "(" + signal_params + ")"
473
562
 
474
563
  var insert_line: int = _signal_insertion_point(lines)
475
- return insert_line if _insert(lines, insert_line, [signal_line], placed) else -1
564
+ # Set apart by a blank line from the header above and from whatever follows that is not a
565
+ # signal, as the style guide and gdformat lay the signals out.
566
+ var block: Array[String] = [signal_line]
567
+ var above: String = lines[insert_line - 1].strip_edges() if insert_line > 0 else ""
568
+ var at: int = insert_line
569
+ if not above.is_empty() and not GdhPatterns.without_annotations(above).begins_with("signal "):
570
+ block.push_front("")
571
+ at += 1
572
+ var below: String = lines[insert_line].strip_edges() if insert_line < lines.size() else ""
573
+ if not below.is_empty() and not GdhPatterns.without_annotations(below).begins_with("signal "):
574
+ block.append("")
575
+ return at if _insert(lines, insert_line, block, placed) else -1
476
576
 
477
577
 
478
578
  # Answers with the zero-based index of the `func` line, below the blank lines that separate it.
@@ -545,6 +645,7 @@ func _variable_insertion_point(lines: Array[String]) -> int:
545
645
  before_func = i
546
646
  break
547
647
 
648
+ after_header = _past_class_doc(lines, after_header)
548
649
  for i: int in range(after_header, before_func):
549
650
  if GdhPatterns.without_annotations(lines[i]).begins_with("var "):
550
651
  after_header = i + 1
@@ -552,6 +653,20 @@ func _variable_insertion_point(lines: Array[String]) -> int:
552
653
  return after_header
553
654
 
554
655
 
656
+ ## [param at], or the line after the class's doc comment when [param at] follows the header and
657
+ ## one opens there. Godot reads the `##` lines straight below `extends` as the class's own
658
+ ## documentation, and a line put between them handed that documentation to the line put there.
659
+ static func _past_class_doc(lines: Array[String], at: int) -> int:
660
+ if at == 0 or at > lines.size():
661
+ return at
662
+ var above: String = GdhPatterns.without_annotations(lines[at - 1])
663
+ if not (above.begins_with("extends ") or above.begins_with("class_name ")):
664
+ return at
665
+ while at < lines.size() and lines[at].strip_edges().begins_with("##"):
666
+ at += 1
667
+ return at
668
+
669
+
555
670
  func _signal_insertion_point(lines: Array[String]) -> int:
556
671
  var after_header: int = 0
557
672
 
@@ -566,7 +681,7 @@ func _signal_insertion_point(lines: Array[String]) -> int:
566
681
  elif line.begins_with("var ") or line.begins_with("func "):
567
682
  break
568
683
 
569
- return after_header
684
+ return _past_class_doc(lines, after_header)
570
685
 
571
686
 
572
687
  func _function_insertion_point(lines: Array[String], position: String) -> int:
@@ -621,24 +736,15 @@ static func _indentation(line: String) -> String:
621
736
  func _script_template(template_name: String) -> String:
622
737
  match template_name:
623
738
  "singleton":
624
- return """## Singleton (Autoload) script
625
- ## Add to Project Settings -> Autoload to use as a global singleton
626
-
627
- var _instance: Object = null
739
+ return """## One instance for the whole game, which the project makes at start once
740
+ ## project_settings add_autoload names it, and every script reaches by that name.
628
741
 
629
- func _init() -> void:
630
- \tif _instance != null:
631
- \t\tpush_error("Singleton instance already exists!")
632
- \t\treturn
633
- \t_instance = self
634
742
 
635
743
  func _ready() -> void:
636
744
  \tpass
637
-
638
- # Add your singleton methods here
639
745
  """
640
746
  "state_machine":
641
- return """## Finite State Machine implementation
747
+ return """## A finite state machine: one state at a time, entered and left through change_state.
642
748
 
643
749
  signal state_changed(old_state: String, new_state: String)
644
750
 
@@ -647,22 +753,27 @@ var current_state: String = ""
647
753
  ## physics_process(delta); whichever it has are called.
648
754
  var states: Dictionary = {}
649
755
 
756
+
650
757
  func _ready() -> void:
651
758
  \t_setup_states()
652
759
  \tif states.size() > 0:
653
760
  \t\tchange_state(str(states.keys()[0]))
654
761
 
762
+
655
763
  func _setup_states() -> void:
656
764
  \t# Override this to add states
657
765
  \t# Example: states["idle"] = IdleState.new()
658
766
  \tpass
659
767
 
768
+
660
769
  func _process(delta: float) -> void:
661
770
  \t_call_state(current_state, "process", [delta])
662
771
 
772
+
663
773
  func _physics_process(delta: float) -> void:
664
774
  \t_call_state(current_state, "physics_process", [delta])
665
775
 
776
+
666
777
  func change_state(new_state: String) -> void:
667
778
  \tif not states.has(new_state):
668
779
  \t\tpush_error("State not found: " + new_state)
@@ -674,6 +785,7 @@ func change_state(new_state: String) -> void:
674
785
  \t_call_state(current_state, "enter", [])
675
786
  \tstate_changed.emit(old_state, new_state)
676
787
 
788
+
677
789
  ## Calls a method on the named state when it has one; a state without it is left alone.
678
790
  func _call_state(state_name: String, method: String, args: Array) -> void:
679
791
  \tif state_name.is_empty() or not states.has(state_name):
@@ -683,51 +795,47 @@ func _call_state(state_name: String, method: String, args: Array) -> void:
683
795
  \t\tstate.callv(method, args)
684
796
  """
685
797
  "component":
686
- return """## Component pattern - attach to nodes to add behavior
798
+ return """## A component: a child node that adds one behaviour to its parent, switched off with disable().
687
799
 
688
800
  @export var enabled: bool = true
689
801
 
802
+
690
803
  func _ready() -> void:
691
804
  \tif not enabled:
692
805
  \t\tset_process(false)
693
806
  \t\tset_physics_process(false)
694
807
 
808
+
695
809
  func _process(_delta: float) -> void:
696
810
  \tif not enabled:
697
811
  \t\treturn
698
812
  \t# Component logic here
699
813
  \tpass
700
814
 
815
+
701
816
  func enable() -> void:
702
817
  \tenabled = true
703
818
  \tset_process(true)
704
819
  \tset_physics_process(true)
705
820
 
821
+
706
822
  func disable() -> void:
707
823
  \tenabled = false
708
824
  \tset_process(false)
709
825
  \tset_physics_process(false)
710
826
  """
711
827
  "resource":
712
- return """## Custom Resource - save and load data
828
+ return """## Data kept in a .tres file, which resource_edit create makes and the game loads.
713
829
 
714
830
  @export var id: String = ""
715
831
  @export var display_name: String = ""
716
832
  @export var description: String = ""
717
833
 
834
+
718
835
  func _init(p_id: String = "", p_name: String = "", p_desc: String = "") -> void:
719
836
  \tid = p_id
720
837
  \tdisplay_name = p_name
721
838
  \tdescription = p_desc
722
-
723
- func duplicate_resource() -> Resource:
724
- \tvar new_resource: Resource = duplicate()
725
- \treturn new_resource
726
839
  """
727
840
  _:
728
- return """func _ready() -> void:
729
- \tpass
730
-
731
- func _process(_delta: float) -> void:
732
- \tpass
733
- """
841
+ return ""
@@ -224,18 +224,29 @@ func set_import_options(params: Dictionary) -> Dictionary:
224
224
  if not refused.is_empty():
225
225
  return _log.failure("Nothing was written: " + "; ".join(refused))
226
226
 
227
+ # One already holding the value given changes nothing, and listing it as updated said it had.
227
228
  var updated_keys: Array[String] = []
229
+ var unchanged_keys: Array[String] = []
228
230
  for name: String in fitted_values:
229
- config.set_value("params", name, fitted_values[name])
231
+ var held: Variant = config.get_value("params", name, null)
232
+ var value: Variant = fitted_values[name]
233
+ if held != null and typeof(held) == typeof(value) and held == value:
234
+ unchanged_keys.append(name)
235
+ continue
236
+ config.set_value("params", name, value)
230
237
  updated_keys.append(name)
231
- _log.debug("Set " + name + " = " + str(fitted_values[name]))
238
+ _log.debug("Set " + name + " = " + str(value))
232
239
 
233
- err = config.save(import_file_path)
234
- if err != OK:
235
- return _log.failure("Failed to save import file: " + str(err))
240
+ if not updated_keys.is_empty():
241
+ err = config.save(import_file_path)
242
+ if err != OK:
243
+ return _log.failure("Failed to save import file: " + str(err))
236
244
 
237
245
  # The reimport that applies them is the server's, through the editor or the engine's import.
238
- return {"resource_path": resource_path, "updated_options": updated_keys}
246
+ var answer: Dictionary = {"resource_path": resource_path, "updated_options": updated_keys}
247
+ if not unchanged_keys.is_empty():
248
+ answer["unchanged_options"] = unchanged_keys
249
+ return answer
239
250
 
240
251
 
241
252
  ## [param given] as the option holds it. Every JSON number arrives as a float, and an enum option
@@ -6,6 +6,13 @@ extends RefCounted
6
6
  # Every module preloads this as `GdhLog`. `Logger` is a native class from Godot 4.5 on, and a
7
7
  # constant of that name is a parse error rather than a shadowing warning.
8
8
 
9
+ ## Printed around engine output an operation provokes to answer a question of its own, such as
10
+ ## whether a script parsed before a change, which src/headless.ts leaves out of the answer: its
11
+ ## errors are about a file the answer does not describe. Kept in step with `ASIDE` and `RESUMED`
12
+ ## there.
13
+ const ASIDE: String = "[gdharness] aside"
14
+ const RESUMED: String = "[gdharness] resumed"
15
+
9
16
  var debug_mode: bool = false
10
17
 
11
18
 
@@ -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