gdharness 1.3.40 → 1.3.42

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 (32) hide show
  1. package/build/cli.js +343 -142
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +31 -4
  3. package/build/godot/addons/gdharness_editor/file_reads.gd +36 -1
  4. package/build/godot/addons/gdharness_editor/file_walk.gd +33 -1
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +29 -0
  6. package/build/godot/addons/gdharness_editor/property_values.gd +6 -9
  7. package/build/godot/addons/gdharness_editor/scene_file.gd +27 -0
  8. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +5 -2
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +6 -2
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +15 -0
  11. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +7 -2
  12. package/build/godot/addons/gdharness_editor/tools/scene_sharing.gd +59 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_sharing.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +75 -11
  15. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +77 -22
  16. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +4 -1
  17. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +4 -3
  18. package/build/godot/addons/gdharness_runtime/runtime_touch.gd +211 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_touch.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +15 -8
  21. package/build/godot/operations/animation_reads.gd +31 -4
  22. package/build/godot/operations/dependencies.gd +5 -2
  23. package/build/godot/operations/file_reads.gd +36 -1
  24. package/build/godot/operations/file_walk.gd +33 -1
  25. package/build/godot/operations/gdscript_analysis.gd +19 -1
  26. package/build/godot/operations/gdscript_authoring.gd +65 -4
  27. package/build/godot/operations/import_pipeline.gd +1 -1
  28. package/build/godot/operations/input_actions.gd +1 -0
  29. package/build/godot/operations/plugins.gd +39 -1
  30. package/build/godot/operations/project_config.gd +104 -1
  31. package/build/godot/operations/property_values.gd +6 -9
  32. package/package.json +1 -1
@@ -64,7 +64,13 @@ static func scene_states(scene_path: String, tree_path: String, state_machine_pa
64
64
  player_path = "."
65
65
  var found: Array = _bare(scene_path, player_path, "AnimationPlayer")
66
66
  player = found[0]
67
- var answer: Dictionary = states(tree, state_machine_path, player, player_path)
67
+ var reached: Node = null
68
+ if player == null and not player_path.is_empty():
69
+ reached = _bare(scene_path, player_path, "Node")[0]
70
+ var unplayed: String = no_player(str(tree.anim_player), reached)
71
+ if reached != null:
72
+ reached.free()
73
+ var answer: Dictionary = states(tree, state_machine_path, player, player_path, unplayed)
68
74
  tree.free()
69
75
  if player != null:
70
76
  player.free()
@@ -236,6 +242,20 @@ static func no_machine(tree: AnimationTree, state_machine_path: String) -> Strin
236
242
  return "No state machine at " + state_machine_path
237
243
 
238
244
 
245
+ ## Why no state finds its animation when [param anim_player], an AnimationTree's path to its
246
+ ## player, reaches no AnimationPlayer: [param reached] is the node it reaches instead, null for none.
247
+ static func no_player(anim_player: String, reached: Node) -> String:
248
+ var until: String = "so no state plays anything until scene_node set points anim_player at one."
249
+ if anim_player.is_empty():
250
+ return "The AnimationTree has no anim_player, " + until
251
+ if reached == null:
252
+ return "The AnimationTree's anim_player %s reaches no node, %s" % [anim_player, until]
253
+ return (
254
+ "The AnimationTree's anim_player %s reaches %s, a %s rather than an AnimationPlayer, %s"
255
+ % [anim_player, reached.name, reached.get_class(), until]
256
+ )
257
+
258
+
239
259
  ## Why a state playing [param animation_name] from [param player], at [param player_path] from the
240
260
  ## scene root as playerNodePath takes it, plays nothing yet. anim_player is relative to the tree
241
261
  ## ("../Anim"), which no argument takes.
@@ -254,9 +274,14 @@ static func missing_animation(player: AnimationPlayer, player_path: String, anim
254
274
 
255
275
  ## The states and transitions of the state machine at [param state_machine_path] in [param tree].
256
276
  ## A state playing an animation says whether [param player], at [param player_path], has it, as
257
- ## add_state answers: a state naming one it lacks plays nothing, and says so only in a log.
277
+ ## add_state answers: a state naming one it lacks plays nothing, and says so only in a log. With no
278
+ ## player, each says [param unplayed], what [method no_player] made of the tree's path.
258
279
  static func states(
259
- tree: AnimationTree, state_machine_path: String, player: AnimationPlayer, player_path: String
280
+ tree: AnimationTree,
281
+ state_machine_path: String,
282
+ player: AnimationPlayer,
283
+ player_path: String,
284
+ unplayed: String
260
285
  ) -> Dictionary:
261
286
  var sm: AnimationNodeStateMachine = state_machine(tree, state_machine_path)
262
287
  if sm == null:
@@ -270,7 +295,9 @@ static func states(
270
295
  entry["animationName"] = str(plays.animation)
271
296
  var found: bool = player != null and player.has_animation(plays.animation)
272
297
  entry["animationFound"] = found
273
- if player != null and not found:
298
+ if player == null:
299
+ entry["animationMissing"] = unplayed
300
+ elif not found:
274
301
  entry["animationMissing"] = missing_animation(player, player_path, str(plays.animation))
275
302
  listed.append(entry)
276
303
  var transitions: Array[Dictionary] = []
@@ -11,6 +11,7 @@ extends RefCounted
11
11
  ## initialisers run, and a resource with a script is made with it.
12
12
 
13
13
  const GdhRead = preload("reading.gd")
14
+ const GdhFileWalk = preload("file_walk.gd")
14
15
  const GdhSame = preload("same_values.gd")
15
16
  const GdhSerialisation = preload("serialisation.gd")
16
17
  const GdhShapes = preload("value_shapes.gd")
@@ -487,7 +488,7 @@ static func _stored(type: String, given: Dictionary, include_defaults: bool) ->
487
488
  static func resource(res_path: String, include_defaults: bool) -> Dictionary:
488
489
  var file: String = res_path.get_slice("::", 0)
489
490
  if not FileAccess.file_exists(file):
490
- return {"ok": false, "error": "No resource at " + file}
491
+ return {"ok": false, "error": GdhFileWalk.new().with_nearest_files("No resource at " + file, file)}
491
492
  var whole: Resource = ResourceLoader.load(file, "", ResourceLoader.CACHE_MODE_IGNORE)
492
493
  if whole == null:
493
494
  return {"ok": false, "error": "%s did not load as a resource." % file}
@@ -516,6 +517,18 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
516
517
  scripted[str(declared.get("name", ""))] = true
517
518
  var serialiser: GdhSerialisation = GdhSerialisation.new()
518
519
  var properties: Dictionary = {}
520
+ # A headless engine runs a script that is not a tool as the file loads, so what its _init and
521
+ # setters gave other properties read as values the file sets; the file's own lines say which it
522
+ # does. The editor runs no such script.
523
+ var untooled: bool = script != null and not script.is_tool() and not Engine.is_editor_hint()
524
+ var from_file: bool = (
525
+ untooled and file.ends_with(".tres") and not GdhShapes.STORED_APART.has(loaded.get_class())
526
+ )
527
+ var set_in_file: Dictionary = (
528
+ _names_set_in(FileAccess.get_file_as_string(file), res_path.trim_prefix(file).trim_prefix("::"))
529
+ if from_file
530
+ else {}
531
+ )
519
532
  # A Curve's points are read off one with none, since the engine has no default for point_0.
520
533
  var fresh: Object = (
521
534
  ClassDB.instantiate(loaded.get_class()) if GdhShapes.STORED_APART.has(loaded.get_class()) else null
@@ -537,6 +550,8 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
537
550
  )
538
551
  if GdhSame.same(value, default):
539
552
  continue
553
+ if from_file and not set_in_file.has(property):
554
+ continue
540
555
  properties[property] = serialiser.serialize_value(value)
541
556
  var answer: Dictionary = {
542
557
  "ok": true, "resourcePath": res_path, "resourceType": loaded.get_class(), "properties": properties
@@ -551,6 +566,26 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
551
566
  return answer
552
567
 
553
568
 
569
+ ## The names the [code][resource][/code] section of [param text], a .tres file, sets, or those of the
570
+ ## embedded resource with the id [param id] when one is given, as a set.
571
+ static func _names_set_in(text: String, id: String) -> Dictionary:
572
+ var names: Dictionary = {}
573
+ var inside: bool = false
574
+ var key: RegEx = RegEx.create_from_string("^([^\\s=\\[][^=]*?) = ")
575
+ for line: String in text.split("\n"):
576
+ if line.begins_with("["):
577
+ inside = (
578
+ line.begins_with("[resource]")
579
+ if id.is_empty()
580
+ else line.begins_with("[sub_resource") and line.contains('id="%s"' % id)
581
+ )
582
+ continue
583
+ var found: RegExMatch = key.search(line) if inside else null
584
+ if found != null:
585
+ names[found.get_string(1)] = true
586
+ return names
587
+
588
+
554
589
  ## The resource [param file] keeps under [param id], found in [param value] or anything it holds.
555
590
  ## The engine loads such a path only while the file is cached, and a read loads the file uncached,
556
591
  ## so it is looked for among what the file holds. A resource of another file is neither taken nor
@@ -35,8 +35,24 @@ static func missing_reason(path: String) -> String:
35
35
 
36
36
  ## [param said], the refusal for [param path], a res:// path nothing is at, with the files of its
37
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.
38
+ ## the caller has a path to send instead. Files of the same name beside it come first, since a
39
+ ## misspelt extension has no file of its own extension to be near.
39
40
  func with_nearest_files(said: String, path: String) -> String:
41
+ # One the refusal names is the file it refuses, as a script given for a scene is. Word by word,
42
+ # since res://brain.gd is inside res://brain.gdd.
43
+ var named: Array[String] = []
44
+ for word: String in said.replace("\n", " ").replace(",", " ").replace(";", " ").split(" ", false):
45
+ named.append(word.rstrip(".:"))
46
+ var namesakes: Array[String] = []
47
+ for file: String in _namesakes(path):
48
+ if not file in named:
49
+ namesakes.append(file)
50
+ if not namesakes.is_empty():
51
+ var alone: bool = namesakes.size() == 1
52
+ return (
53
+ "%s. The %s of that name beside it %s %s."
54
+ % [said, "file" if alone else "files", "is" if alone else "are", ", ".join(namesakes)]
55
+ )
40
56
  var extension: String = path.get_extension()
41
57
  if extension.is_empty():
42
58
  return said
@@ -65,6 +81,22 @@ func with_nearest_files(said: String, path: String) -> String:
65
81
  return "%s. None of the project's %d .%s files is named like it." % [said, files.size(), extension]
66
82
 
67
83
 
84
+ # The files in [param path]'s folder named as it is with another extension, leaving out the import
85
+ # and uid files the engine keeps beside a file, which are named after the whole file name.
86
+ static func _namesakes(path: String) -> Array[String]:
87
+ var found: Array[String] = []
88
+ var folder: String = path.get_base_dir()
89
+ if not DirAccess.dir_exists_absolute(folder):
90
+ return found
91
+ var stem: String = path.get_file().get_basename()
92
+ var files: PackedStringArray = DirAccess.get_files_at(folder)
93
+ files.sort()
94
+ for file: String in files:
95
+ if file.get_basename() == stem and file != path.get_file():
96
+ found.append(folder.path_join(file))
97
+ return found
98
+
99
+
68
100
  # A directory the engine steps over, marker file and all.
69
101
  func is_stepped_over(path: String) -> bool:
70
102
  return FileAccess.file_exists(path + ".gdignore")
@@ -505,6 +505,35 @@ static func setter_ran(object: Object, property: String, refused: String) -> Str
505
505
  )
506
506
 
507
507
 
508
+ ## Moves out of alsoChanged in [param answer] each of [param names], what a headless write put back
509
+ ## as the doing of the script of [param owner], which is not a tool, to setByScript, with a note
510
+ ## saying why the file does not hold it.
511
+ static func move_script_set(answer: Dictionary, names: Array, owner: String) -> void:
512
+ if not answer.has("alsoChanged"):
513
+ return
514
+ var changed: Dictionary = answer["alsoChanged"]
515
+ var by_script: Dictionary = {}
516
+ for key: Variant in names:
517
+ if changed.has(key):
518
+ by_script[key] = changed[key]
519
+ var _erased: bool = changed.erase(key)
520
+ if by_script.is_empty():
521
+ return
522
+ if changed.is_empty():
523
+ var _dropped: bool = answer.erase("alsoChanged")
524
+ answer["setByScript"] = by_script
525
+ var listed: Array = by_script.keys()
526
+ var pronoun: String = "it" if listed.size() == 1 else "them"
527
+ answer["setByScriptNote"] = (
528
+ (
529
+ "%s's script is not a tool, so it ran in the headless engine that wrote the file, and set %s "
530
+ + "as it did. The file does not hold %s, as a save from the editor, where the script does not "
531
+ + "run, would not; the script sets %s again when it runs in the game."
532
+ )
533
+ % [owner, ", ".join(listed), pronoun, pronoun]
534
+ )
535
+
536
+
508
537
  ## Why [param name] on [param what] cannot be saved as the file held it.
509
538
  static func unasked(what: String, name: String, why: String = "") -> String:
510
539
  return (
@@ -383,19 +383,16 @@ func _readable(value: Variant, relative_to: Node) -> Variant:
383
383
  return _values.serialize_value(value)
384
384
 
385
385
 
386
- ## What [param target] stores, by name, but its script and the properties in [param leave]. A list
387
- ## or a map is copied, since a setter can change the one it holds in place.
386
+ ## What [param target] stores, by name, but its script and the properties in [param leave], in the
387
+ ## names a read answers with: a Curve's points as point_0/position and the rest rather than the _data
388
+ ## list every point write changes. A list or a map is copied, since a setter can change the one it
389
+ ## holds in place.
388
390
  func _stored(target: Object, leave: Dictionary) -> Dictionary:
389
391
  var held: Dictionary = {}
390
- for entry: Dictionary in target.get_property_list():
392
+ for entry: Dictionary in GdhShapes.read_properties(target):
391
393
  var name: String = str(entry["name"])
392
394
  var usage: int = GdhRead.as_int(entry["usage"])
393
- if (
394
- usage & PROPERTY_USAGE_STORAGE == 0
395
- or usage & HEADINGS != 0
396
- or name == "script"
397
- or leave.has(name)
398
- ):
395
+ if usage & HEADINGS != 0 or name == "script" or leave.has(name):
399
396
  continue
400
397
  var value: Variant = target.get(name)
401
398
  if value is Array:
@@ -45,6 +45,17 @@ var root: Node = null
45
45
  var path: String = ""
46
46
  ## What the last write saved, for its answer to read what the file now stores.
47
47
  var written_pack: PackedScene = null
48
+ ## The properties a headless write put back on each node the tool changed, by instance id, as a
49
+ ## set: what the node's script, which is not a tool, changed as the tool wrote, and the file was
50
+ ## not given.
51
+ var restored_by_write: Dictionary = {}
52
+ ## What the scene loads with where it stores what the tool gave, by node path and then property:
53
+ ## the node's script changed the value as the copy the write checks loaded.
54
+ var loads_as: Dictionary = {}
55
+ ## The node paths in [member loads_as] whose script is not a tool, so an editor does not run it.
56
+ var loads_untooled: Dictionary = {}
57
+ ## The pack a write's check reads, for [method stores] to answer from.
58
+ var _checked_pack: PackedScene = null
48
59
  ## The path of each node a scene this one inherits gives, with the file that makes it.
49
60
  var _base_paths: Dictionary = {}
50
61
  var _state: SceneState = null
@@ -564,6 +575,19 @@ func kept(saved: Node, loaded: Variant, given: Variant) -> bool:
564
575
  return GdhSame.same(loaded, given)
565
576
 
566
577
 
578
+ ## Whether the pack a write is checking stores [param given] for [param property] on the node at
579
+ ## [param at], a path from the root.
580
+ func stores(at: String, property: String, given: Variant) -> bool:
581
+ if _checked_pack == null:
582
+ return false
583
+ var state: SceneState = _checked_pack.get_state()
584
+ for index: int in state.get_node_count():
585
+ if _relative(str(state.get_node_path(index))) == at:
586
+ var stored: Dictionary = _stored_at(state, index)
587
+ return stored.has(property) and GdhSame.same(stored[property], given)
588
+ return false
589
+
590
+
567
591
  ## Packs [member root], has [param check] read the result as the file will hold it, and writes the
568
592
  ## file only if the check answers "". Frees [member root] either way.
569
593
  ##
@@ -592,6 +616,7 @@ func write(check: Callable = Callable()) -> Dictionary:
592
616
  var saved: Node = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
593
617
  if saved == null:
594
618
  return refuse("The packed copy of %s did not instantiate, so nothing was written." % path)
619
+ _checked_pack = packed
595
620
  var missing: String = str(check.call(saved))
596
621
  saved.free()
597
622
  if not missing.is_empty():
@@ -777,8 +802,10 @@ func _settled(packed: PackedScene) -> Array:
777
802
  for found: Array in moved:
778
803
  var node: Node = found[0]
779
804
  var before: Dictionary = _before[node.get_instance_id()]
805
+ var restored: Dictionary = restored_by_write.get_or_add(node.get_instance_id(), {})
780
806
  for name: String in found[1]:
781
807
  node.set(name, before.get(name, GdhHeadlessCheck.default_of(node, name)))
808
+ restored[name] = true
782
809
  current = PackedScene.new()
783
810
  var packing: Error = current.pack(root)
784
811
  if packing != OK:
@@ -29,7 +29,8 @@ func add_animation_state(args: Dictionary) -> Dictionary:
29
29
  # are in use in every state machine.
30
30
  if sm.has_node(StringName(state_name)):
31
31
  return scene.refuse("The state machine already has a state named %s." % state_name)
32
- var player: AnimationPlayer = anim_tree.get_node_or_null(anim_tree.anim_player) as AnimationPlayer
32
+ var reached: Node = anim_tree.get_node_or_null(anim_tree.anim_player)
33
+ var player: AnimationPlayer = reached as AnimationPlayer
33
34
  var found: bool = player != null and player.has_animation(StringName(animation_name))
34
35
 
35
36
  var anim_node: AnimationNodeAnimation = AnimationNodeAnimation.new()
@@ -57,7 +58,9 @@ func add_animation_state(args: Dictionary) -> Dictionary:
57
58
  }
58
59
  # Written all the same, as the editor writes one: a state machine is often laid out before its
59
60
  # animations are made, and refusing it stopped a tree being built in that order.
60
- if player != null and not found:
61
+ if player == null:
62
+ answer["animationMissing"] = GdhAnimationReads.no_player(str(anim_tree.anim_player), reached)
63
+ elif not found:
61
64
  answer["animationMissing"] = GdhAnimationReads.missing_animation(
62
65
  player, scene.path_of(player), animation_name
63
66
  )
@@ -259,9 +259,13 @@ func read_animation_states(args: Dictionary) -> Dictionary:
259
259
  if opened[1] == null:
260
260
  return opened[2]
261
261
  var anim_tree: AnimationTree = opened[1]
262
- var player: AnimationPlayer = anim_tree.get_node_or_null(anim_tree.anim_player) as AnimationPlayer
262
+ var reached: Node = anim_tree.get_node_or_null(anim_tree.anim_player)
263
+ var player: AnimationPlayer = reached as AnimationPlayer
263
264
  var player_path: String = "" if player == null else scene.path_of(player)
264
- return _read_in(scene, GdhAnimationReads.states(anim_tree, state_machine_path, player, player_path))
265
+ var unplayed: String = GdhAnimationReads.no_player(str(anim_tree.anim_player), reached)
266
+ return _read_in(
267
+ scene, GdhAnimationReads.states(anim_tree, state_machine_path, player, player_path, unplayed)
268
+ )
265
269
 
266
270
 
267
271
  # =============================================================================
@@ -246,6 +246,7 @@ func create_resource(args: Dictionary) -> Dictionary:
246
246
  var failed: String = _save_checked(resource, res_path, properties.keys(), null)
247
247
  if not failed.is_empty():
248
248
  return {"ok": false, "error": failed}
249
+ _note_put_back(answer, resource, res_path)
249
250
  if not properties.is_empty():
250
251
  answer["properties"] = _held(resource, properties.keys())
251
252
  return answer
@@ -302,10 +303,24 @@ func modify_resource(args: Dictionary) -> Dictionary:
302
303
  var failed: String = _save_checked(resource, res_path, properties.keys(), previous)
303
304
  if not failed.is_empty():
304
305
  return {"ok": false, "error": failed}
306
+ _note_put_back(answer, resource, res_path)
305
307
  answer["properties"] = _held(resource, properties.keys())
306
308
  return answer
307
309
 
308
310
 
311
+ ## Moves to setByScript each property alsoChanged in [param answer] gave a value [param resource] no
312
+ ## longer holds once saved at [param path]: the save put it back as its script's doing.
313
+ func _note_put_back(answer: Dictionary, resource: Resource, path: String) -> void:
314
+ var noted: Dictionary = answer.get("alsoChanged", {})
315
+ var restored: Array = []
316
+ for key: Variant in noted:
317
+ var now: Variant = _values.serialize_value(_properties.read_at(resource, str(key)))
318
+ var entry: Dictionary = noted[key]
319
+ if JSON.stringify(now) != JSON.stringify(entry.get("now")):
320
+ restored.append(key)
321
+ GdhHeadlessCheck.move_script_set(answer, restored, path)
322
+
323
+
309
324
  ## Why [param resource], saved at [param path], cannot hold what [param names] were given, or "": a
310
325
  ## resource cannot hold the file it is saved as. Godot saves the reference as null with only a
311
326
  ## warning in its log, and the read back after the save reported the property as one the file does
@@ -126,8 +126,13 @@ static func inherit(scene_path: String, base_path: String, root_name: String, sc
126
126
  "rootNodeType": root_type,
127
127
  "rootNodeName": name
128
128
  }
129
- if script != null:
130
- answer["script"] = script.resource_path
129
+ var properties: Dictionary = base["properties"]
130
+ var carried: Variant = script if script != null else properties.get("script")
131
+ if carried is Script:
132
+ var carried_script: Script = carried
133
+ answer["script"] = carried_script.resource_path
134
+ if not str(carried_script.get_global_name()).is_empty():
135
+ answer["scriptClass"] = str(carried_script.get_global_name())
131
136
  return answer
132
137
 
133
138
 
@@ -0,0 +1,59 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## Where else in a scene a resource built into it is held, since such a resource is one object
5
+ ## wherever the scene uses it.
6
+
7
+ const GdhPropertyPaths = preload("../property_paths.gd")
8
+ const GdhRead = preload("../reading.gd")
9
+ const GdhSceneFile = preload("../scene_file.gd")
10
+
11
+
12
+ ## Every other place in [param scene] holding a resource that [param key] was written through on
13
+ ## [param node], as {node, property}. A copy made by duplicate shares its original's resources, so
14
+ ## the write changed each of them too.
15
+ static func holders(scene: GdhSceneFile, node: Node, key: String) -> Array[Dictionary]:
16
+ var found_at: Array[Dictionary] = []
17
+ var through: Array = GdhPropertyPaths.holder_of(node, key, GdhPropertyPaths.home_of(node))[2]
18
+ if through.is_empty():
19
+ return found_at
20
+ var wanted: Dictionary = {}
21
+ for reached: Resource in through:
22
+ wanted[reached.get_instance_id()] = true
23
+ var nodes: Array[Node] = [scene.root]
24
+ nodes.append_array(scene.root.find_children("*", "", true, false))
25
+ for at: Node in nodes:
26
+ for declared: Dictionary in at.get_property_list():
27
+ var property: String = str(declared.get("name", ""))
28
+ if property.is_empty() or not (GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
29
+ continue
30
+ for found: String in _paths_to(at.get(property), property, wanted, {}):
31
+ if at != node or not key.begins_with(found + "/"):
32
+ found_at.append({"node": scene.path_of(at), "property": found})
33
+ return found_at
34
+
35
+
36
+ ## The paths below [param path] at which [param value] holds a resource of [param wanted], by
37
+ ## instance id, stepping into lists and into resources built into a scene.
38
+ static func _paths_to(value: Variant, path: String, wanted: Dictionary, seen: Dictionary) -> Array[String]:
39
+ var paths: Array[String] = []
40
+ if value is Array:
41
+ var items: Array = value
42
+ for index: int in items.size():
43
+ paths.append_array(_paths_to(items[index], "%s/%d" % [path, index], wanted, seen))
44
+ return paths
45
+ if not value is Resource:
46
+ return paths
47
+ var held: Resource = value
48
+ var id: int = held.get_instance_id()
49
+ if wanted.has(id):
50
+ paths.append(path)
51
+ var built_in: bool = held.resource_path.is_empty() or held.resource_path.contains("::")
52
+ if seen.has(id) or not built_in:
53
+ return paths
54
+ seen[id] = true
55
+ for declared: Dictionary in held.get_property_list():
56
+ var property: String = str(declared.get("name", ""))
57
+ if not property.is_empty() and GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
58
+ paths.append_array(_paths_to(held.get(property), path + "/" + property, wanted, seen))
59
+ return paths
@@ -0,0 +1 @@
1
+ uid://blndo1urj5l2w
@@ -15,6 +15,7 @@ const GdhPlacing = preload("scene_placing.gd")
15
15
  const GdhPropertyValues = preload("../property_values.gd")
16
16
  const GdhSame = preload("../same_values.gd")
17
17
  const GdhSceneFile = preload("../scene_file.gd")
18
+ const GdhSceneSharing = preload("scene_sharing.gd")
18
19
  const GdhSerialisation = preload("../serialisation.gd")
19
20
  const GdhTilesetTools = preload("tileset_tools.gd")
20
21
 
@@ -43,7 +44,16 @@ func _kept(scene: GdhSceneFile, saved: Node, node: Node, names: Array) -> String
43
44
  for key: Variant in names:
44
45
  var property: String = str(key)
45
46
  var loaded: Variant = _properties.read_at(copy, property)
46
- if not scene.kept(saved, loaded, _properties.read_at(node, property)):
47
+ var given: Variant = _properties.read_at(node, property)
48
+ if not scene.kept(saved, loaded, given):
49
+ # The file holds it, and the copy's own script set it again as the copy loaded, as it will
50
+ # wherever the scene loads; an editor saves the same.
51
+ if scene.stores(at, property, given):
52
+ var on: Dictionary = scene.loads_as.get_or_add(at, {})
53
+ if GdhHeadlessCheck.untooled(copy.get_script()):
54
+ scene.loads_untooled[at] = true
55
+ on[property] = _values.serialize_value(loaded)
56
+ continue
47
57
  return (
48
58
  (
49
59
  "%s.%s would load from the file as %s: the scene does not keep it, because the property "
@@ -142,7 +152,6 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
142
152
 
143
153
  if scene_path.is_empty():
144
154
  return {"ok": false, "error": "Missing scenePath"}
145
- var asked_type: String = root_node_type
146
155
  var made: Dictionary = GdhSceneFile.made_as(
147
156
  root_node_type,
148
157
  "" if script_path.is_empty() else GdhSceneFile.res_path(project_path, script_path),
@@ -200,13 +209,20 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
200
209
  if not folder.is_empty():
201
210
  root.free()
202
211
  return {"ok": false, "error": folder}
212
+ # Read before the write, which frees the root.
213
+ var root_class: Dictionary = GdhSceneFile.class_of(root)
203
214
  var written: Dictionary = scene.write()
204
215
  if not written.is_empty():
205
216
  return written
206
217
 
207
218
  var answer: Dictionary = {
208
- "ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name
219
+ "ok": true,
220
+ "scenePath": scene_path,
221
+ "rootNodeType": root_class["nodeType"],
222
+ "rootNodeName": root_node_name
209
223
  }
224
+ if root_class.has("scriptClass"):
225
+ answer["scriptClass"] = root_class["scriptClass"]
210
226
  if not script_path.is_empty():
211
227
  answer["script"] = script_path
212
228
  if not had_folder:
@@ -241,7 +257,6 @@ func add_node(args: Dictionary) -> Dictionary:
241
257
 
242
258
  if node_name.is_empty():
243
259
  return {"ok": false, "error": "Missing nodeName"}
244
- var asked_type: String = node_type
245
260
  var class_script: Script = null
246
261
  if not node_type.is_empty() and not ClassDB.class_exists(node_type):
247
262
  var declared: Dictionary = GdhSceneFile.script_class(node_type, "Node")
@@ -301,13 +316,9 @@ func add_node(args: Dictionary) -> Dictionary:
301
316
 
302
317
  var new_path: String = scene.path_of(new_node)
303
318
  # Read before the write, which frees the scene it was added to.
304
- var answer: Dictionary = {
305
- "ok": true,
306
- "nodeName": node_name,
307
- "nodeType": asked_type if class_script != null else new_node.get_class(),
308
- "nodePath": new_path,
309
- "index": new_node.get_index(),
310
- }
319
+ var answer: Dictionary = {"ok": true, "nodeName": node_name}
320
+ answer.merge(GdhSceneFile.class_of(new_node))
321
+ answer.merge({"nodePath": new_path, "index": new_node.get_index()})
311
322
  if not properties.is_empty():
312
323
  var held: Dictionary = {}
313
324
  for key: Variant in properties:
@@ -316,6 +327,7 @@ func add_node(args: Dictionary) -> Dictionary:
316
327
  _properties.note_write(answer, new_node)
317
328
  if not adding.is_empty():
318
329
  answer.merge(GdhGroups.read_added(new_node, adding))
330
+ var id: int = new_node.get_instance_id()
319
331
  var written: Dictionary = scene.write(
320
332
  func(saved: Node) -> String:
321
333
  var unkept: String = _kept(
@@ -328,6 +340,8 @@ func add_node(args: Dictionary) -> Dictionary:
328
340
  if not written.is_empty():
329
341
  return written
330
342
 
343
+ _note_put_back(answer, scene, id, new_path)
344
+ _note_loads_as(answer, scene, new_path)
331
345
  if packed != null:
332
346
  answer["instanceOf"] = instance_path
333
347
  return answer
@@ -518,6 +532,7 @@ func reparent_node(args: Dictionary) -> Dictionary:
518
532
  for property: String in GdhPlacing.placing(node):
519
533
  drawn_at[property] = _properties.read_at(node, property)
520
534
  var held_at: Transform3D = (node as Node3D).transform if node is Node3D else Transform3D.IDENTITY
535
+ var stored_before: Dictionary = GdhHeadlessCheck.stored(node)
521
536
  # Unowned while it moves: a node taken out of the tree keeps its owner, and adding it under the new
522
537
  # parent warned that the owner was not above it. Node.reparent warns the same way.
523
538
  node.owner = null
@@ -527,6 +542,11 @@ func reparent_node(args: Dictionary) -> Dictionary:
527
542
  GdhPlacing.own(node, root)
528
543
  if GdhRead.as_bool(args.get("keepGlobalTransform", true)):
529
544
  GdhPlacing.keep_in_place(node, old_parent)
545
+ # Unasked, a headless write takes the move on a node whose script is not a tool for the
546
+ # script's doing and puts the node back as the file held it.
547
+ var placed: Array[String] = GdhHeadlessCheck.differing(node, stored_before, {})
548
+ if not placed.is_empty():
549
+ scene.asked(node, placed)
530
550
  var also_changed: Dictionary = {}
531
551
  for property: String in drawn_at:
532
552
  var now: Variant = _properties.read_at(node, property)
@@ -643,6 +663,13 @@ func set_node_properties(args: Dictionary) -> Dictionary:
643
663
  var new_path: String = scene.path_of(node)
644
664
  var answer: Dictionary = {"ok": true, "nodePath": new_path, "properties": held}
645
665
  _properties.note_write(answer, node)
666
+ var shared: Dictionary = {}
667
+ for key: Variant in properties:
668
+ var holders: Array[Dictionary] = GdhSceneSharing.holders(scene, node, str(key))
669
+ if not holders.is_empty():
670
+ shared[str(key)] = holders
671
+ if not shared.is_empty():
672
+ answer["sharedWith"] = shared
646
673
  if not adding.is_empty() or not removing.is_empty():
647
674
  answer["groups"] = GdhGroups.read(scene, node).get("groups", [])
648
675
  var named: Array[String] = []
@@ -653,6 +680,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
653
680
  var source: String = scene.values_from(node, old_path)
654
681
  var carried: GdhPaths = GdhPaths.new()
655
682
  var _rewritten: int = carried.carry(scene, {old_path: new_path} if new_path != old_path else {})
683
+ var id: int = node.get_instance_id()
656
684
  var written: Dictionary = scene.write(
657
685
  func(saved: Node) -> String:
658
686
  var unkept: String = _kept(scene, saved, node, properties.keys())
@@ -674,12 +702,48 @@ func set_node_properties(args: Dictionary) -> Dictionary:
674
702
  if not dropped.is_empty():
675
703
  answer["droppedValues"] = dropped["values"]
676
704
  answer["droppedNodes"] = dropped["nodes"]
705
+ _note_put_back(answer, scene, id, new_path)
706
+ _note_loads_as(answer, scene, new_path)
677
707
  _note_left_out(answer, scene.written_pack, new_path, named, source)
678
708
  if new_path != old_path:
679
709
  answer.merge(GdhKin.follow(scene_path, old_path, new_path, carried))
680
710
  return answer
681
711
 
682
712
 
713
+ ## Puts under loadsAs in [param answer] each value given that the file holds and the script of the
714
+ ## node at [param at] changes as the scene loads, as it loads.
715
+ static func _note_loads_as(answer: Dictionary, scene: GdhSceneFile, at: String) -> void:
716
+ var on: Dictionary = scene.loads_as.get(at, {})
717
+ if on.is_empty():
718
+ return
719
+ answer["loadsAs"] = on
720
+ var names: Array = on.keys()
721
+ var pronoun: String = "it" if names.size() == 1 else "them"
722
+ if scene.loads_untooled.has(at):
723
+ answer["loadsAsNote"] = (
724
+ (
725
+ "The file holds %s as given, which the editor shows. %s's script is not a tool, so in the "
726
+ + "game it runs as the scene loads and changes %s to what loadsAs gives."
727
+ )
728
+ % [", ".join(names), at, pronoun]
729
+ )
730
+ else:
731
+ answer["loadsAsNote"] = (
732
+ (
733
+ "The file holds %s as given. %s's script runs as the scene loads, in the editor and in "
734
+ + "the game, and changes %s to what loadsAs gives."
735
+ )
736
+ % [", ".join(names), at, pronoun]
737
+ )
738
+
739
+
740
+ ## Moves out of alsoChanged in [param answer] what the last write of [param scene] put back on the
741
+ ## node with instance id [param id], at [param at], to setByScript.
742
+ static func _note_put_back(answer: Dictionary, scene: GdhSceneFile, id: int, at: String) -> void:
743
+ var restored: Dictionary = scene.restored_by_write.get(id, {})
744
+ GdhHeadlessCheck.move_script_set(answer, restored.keys(), at)
745
+
746
+
683
747
  ## Notes under notStored each of [param named] the file saved from [param pack] does not set on the
684
748
  ## node at [param at]: a set gave it the value the node has without it, so the packer leaves it out,
685
749
  ## as the editor does, and it follows [param source], or its default, from then on.