gdharness 1.3.39 → 1.3.41

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.
@@ -125,15 +125,16 @@ static func no_animation(player: AnimationPlayer, called: String, animation_name
125
125
 
126
126
  static func _summary(anim: Animation) -> Dictionary:
127
127
  return {
128
- "length": anim.length,
128
+ "length": held_seconds(anim.length),
129
129
  "loopMode": LOOP_MODE_NAMES.get(anim.loop_mode, str(anim.loop_mode)),
130
- "step": anim.step,
130
+ "step": held_seconds(anim.step),
131
131
  }
132
132
 
133
133
 
134
- ## [param seconds], a key's time, as the shortest decimal holding the same 32-bit float. A scene keeps
135
- ## key times as 32-bit floats, so a key put at 0.7 read back as 0.699999988079071.
136
- static func key_time(seconds: float) -> float:
134
+ ## [param seconds], a key's time or an animation's length or step, as the shortest decimal holding
135
+ ## the same 32-bit float. An animation keeps all three as 32-bit floats, so a key put at 0.7 read
136
+ ## back as 0.699999988079071 and a step of 0.1 as 0.100000001490116.
137
+ static func held_seconds(seconds: float) -> float:
137
138
  var held: float = PackedFloat32Array([seconds])[0]
138
139
  for places: int in range(0, 10):
139
140
  var shorter: float = String.num(held, places).to_float()
@@ -159,7 +160,7 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
159
160
  var keyframes: Array[Dictionary] = []
160
161
  var methods: Dictionary = {}
161
162
  for key: int in anim.track_get_key_count(track):
162
- var keyframe: Dictionary = {"time": key_time(anim.track_get_key_time(track, key))}
163
+ var keyframe: Dictionary = {"time": held_seconds(anim.track_get_key_time(track, key))}
163
164
  if kind == Animation.TYPE_METHOD:
164
165
  var method: String = str(anim.method_track_get_name(track, key))
165
166
  methods[method] = true
@@ -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}
@@ -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")
@@ -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:
@@ -320,7 +320,10 @@ func create_animation(args: Dictionary) -> Dictionary:
320
320
  return "%s would not be in the scene when it loads." % animation_name
321
321
  return ""
322
322
  var answer: Dictionary = {
323
- "ok": true, "animationName": animation_name, "length": anim.length, "loopMode": loop_mode_name
323
+ "ok": true,
324
+ "animationName": animation_name,
325
+ "length": GdhAnimationReads.held_seconds(anim.length),
326
+ "loopMode": loop_mode_name
324
327
  }
325
328
  if library[2]:
326
329
  answer["libraryMade"] = animation_name.get_slice("/", 0) if animation_name.contains("/") else ""
@@ -477,9 +480,9 @@ func set_animation(args: Dictionary) -> Dictionary:
477
480
  var answer: Dictionary = {
478
481
  "ok": true,
479
482
  "animationName": animation_name,
480
- "length": anim.length,
483
+ "length": GdhAnimationReads.held_seconds(anim.length),
481
484
  "loopMode": GdhAnimationReads.LOOP_MODE_NAMES.get(anim.loop_mode, str(anim.loop_mode)),
482
- "step": anim.step,
485
+ "step": GdhAnimationReads.held_seconds(anim.step),
483
486
  }
484
487
  var past: String = _past_length(anim, animation_name)
485
488
  if not past.is_empty():
@@ -444,7 +444,7 @@ static func _keys_at(anim: Animation, calling: Array, times: Array) -> Array:
444
444
  if pair[1] >= 0 and key != pair[1]:
445
445
  continue
446
446
  var time: float = anim.track_get_key_time(index, key)
447
- var _listed: bool = held.append(str(GdhAnimationReads.key_time(time)))
447
+ var _listed: bool = held.append(str(GdhAnimationReads.held_seconds(time)))
448
448
  for given: float in times:
449
449
  if GdhAnimationReads.same_time(time, given):
450
450
  at.append([index, key])
@@ -142,7 +142,6 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
142
142
 
143
143
  if scene_path.is_empty():
144
144
  return {"ok": false, "error": "Missing scenePath"}
145
- var asked_type: String = root_node_type
146
145
  var made: Dictionary = GdhSceneFile.made_as(
147
146
  root_node_type,
148
147
  "" if script_path.is_empty() else GdhSceneFile.res_path(project_path, script_path),
@@ -200,13 +199,20 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
200
199
  if not folder.is_empty():
201
200
  root.free()
202
201
  return {"ok": false, "error": folder}
202
+ # Read before the write, which frees the root.
203
+ var root_class: Dictionary = GdhSceneFile.class_of(root)
203
204
  var written: Dictionary = scene.write()
204
205
  if not written.is_empty():
205
206
  return written
206
207
 
207
208
  var answer: Dictionary = {
208
- "ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name
209
+ "ok": true,
210
+ "scenePath": scene_path,
211
+ "rootNodeType": root_class["nodeType"],
212
+ "rootNodeName": root_node_name
209
213
  }
214
+ if root_class.has("scriptClass"):
215
+ answer["scriptClass"] = root_class["scriptClass"]
210
216
  if not script_path.is_empty():
211
217
  answer["script"] = script_path
212
218
  if not had_folder:
@@ -241,7 +247,6 @@ func add_node(args: Dictionary) -> Dictionary:
241
247
 
242
248
  if node_name.is_empty():
243
249
  return {"ok": false, "error": "Missing nodeName"}
244
- var asked_type: String = node_type
245
250
  var class_script: Script = null
246
251
  if not node_type.is_empty() and not ClassDB.class_exists(node_type):
247
252
  var declared: Dictionary = GdhSceneFile.script_class(node_type, "Node")
@@ -301,13 +306,9 @@ func add_node(args: Dictionary) -> Dictionary:
301
306
 
302
307
  var new_path: String = scene.path_of(new_node)
303
308
  # 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
- }
309
+ var answer: Dictionary = {"ok": true, "nodeName": node_name}
310
+ answer.merge(GdhSceneFile.class_of(new_node))
311
+ answer.merge({"nodePath": new_path, "index": new_node.get_index()})
311
312
  if not properties.is_empty():
312
313
  var held: Dictionary = {}
313
314
  for key: Variant in properties:
@@ -10,6 +10,9 @@ const GdhSerialisation = preload("../serialisation.gd")
10
10
 
11
11
  ## What every emission meets when a connection cannot call its method.
12
12
  const FAILS: String = ", so every emission fails to call it."
13
+ ## The engine's CONNECT_INHERITED, which GDScript is not given: on a connection an instanced or
14
+ ## inherited scene makes, as a scene holding one loads it.
15
+ const CONNECT_INHERITED: int = 32
13
16
  ## The names Object.ConnectFlags gives each bit. The number alone left a reader guessing that 2 is
14
17
  ## the persistence every connection in a saved scene carries.
15
18
  const CONNECT_FLAG_NAMES: Dictionary[int, String] = {
@@ -18,9 +21,7 @@ const CONNECT_FLAG_NAMES: Dictionary[int, String] = {
18
21
  Object.CONNECT_ONE_SHOT: "one_shot",
19
22
  Object.CONNECT_REFERENCE_COUNTED: "reference_counted",
20
23
  Object.CONNECT_APPEND_SOURCE_OBJECT: "append_source_object",
21
- # The engine's CONNECT_INHERITED, which GDScript is not given: on a connection an instanced scene
22
- # makes inside itself, as a scene holding one loads it.
23
- 32: "inherited",
24
+ CONNECT_INHERITED: "inherited",
24
25
  }
25
26
  ## The flags a caller may give: the engine's own CONNECT_INHERITED marks a connection an instanced
26
27
  ## scene makes, and is never one a scene makes for itself.
@@ -295,7 +296,9 @@ static func _misfit(source: Node, signal_name: String, callable: Callable, flags
295
296
  break
296
297
  if flags & Object.CONNECT_APPEND_SOURCE_OBJECT:
297
298
  passed.append({"name": "source", "type": TYPE_OBJECT, "class_name": source.get_class()})
298
- passed = passed.slice(0, maxi(passed.size() - callable.get_unbound_arguments_count(), 0))
299
+ var emitted: int = passed.size()
300
+ var unbound: int = callable.get_unbound_arguments_count()
301
+ passed = passed.slice(0, maxi(emitted - unbound, 0))
299
302
  var from_signal: int = passed.size()
300
303
  for bound: Variant in callable.get_bound_arguments():
301
304
  var bound_class: String = ""
@@ -303,43 +306,58 @@ static func _misfit(source: Node, signal_name: String, callable: Callable, flags
303
306
  var object: Object = bound
304
307
  bound_class = object.get_class()
305
308
  passed.append({"name": "bound", "type": typeof(bound), "class_name": bound_class})
309
+ var binds: int = passed.size() - from_signal
310
+
311
+ # What the method is called with, said in the parts that make it: an answer counting the bound
312
+ # arguments among the signal's said the signal passed more than it declares.
313
+ var called: String = "%s passes %s" % [signal_name, _count(emitted)]
314
+ if unbound > 0 or binds > 0:
315
+ var changed: Array[String] = []
316
+ if unbound > 0:
317
+ changed.append("unbinds %d" % unbound)
318
+ if binds > 0:
319
+ changed.append("binds %d" % binds)
320
+ called += (
321
+ " and the connection %s, so %s is called with %s, and"
322
+ % [" and ".join(PackedStringArray(changed)), method, _count(passed.size())]
323
+ )
324
+ else:
325
+ called += ", and " + method
306
326
 
307
327
  var parameters: Array = taken.get("args", [])
308
328
  var defaults: Array = taken.get("default_args", [])
309
329
  var needed: int = parameters.size() - defaults.size()
310
330
  if passed.size() < needed:
311
- return "%s passes %s, and %s needs %d%s" % [signal_name, _count(passed.size()), method, needed, FAILS]
331
+ return "%s needs %d%s" % [called, needed, FAILS]
312
332
  if passed.size() > parameters.size():
313
333
  var most: String = "none" if parameters.is_empty() else "at most %d" % parameters.size()
314
334
  # Said with the misfit, since unbinds is what a connection to a method wanting less is for, and
315
- # only when the extra arguments are the signal's: a bound one is not dropped by unbinding.
335
+ # only when the extra arguments are the signal's: a scene's connection unbinds the arguments
336
+ # the signal passes before it adds the bound ones, so a bound one is never dropped.
316
337
  var extra: int = passed.size() - parameters.size()
317
338
  var remedy: String = (
318
339
  (
319
- " Connected with unbinds %d, the last %s the signal passes %s left out."
320
- % [
321
- extra + callable.get_unbound_arguments_count(),
322
- "argument" if extra == 1 else "%d arguments" % extra,
323
- "is" if extra == 1 else "are",
324
- ]
340
+ " Connect it with unbinds %d to leave out the last %s the signal passes."
341
+ % [extra + unbound, "argument" if extra == 1 else "%d arguments" % extra]
325
342
  )
326
343
  if extra <= from_signal
327
344
  else ""
328
345
  )
329
- return (
330
- "%s passes %s, and %s takes %s%s%s"
331
- % [signal_name, _count(passed.size()), method, most, FAILS, remedy]
332
- )
346
+ return "%s takes %s%s%s" % [called, most, FAILS, remedy]
333
347
  for at: int in passed.size():
334
348
  var given: Dictionary = passed[at]
335
349
  var parameter: Dictionary = parameters[at]
336
350
  if not _unrelated(given, parameter):
337
351
  continue
352
+ var whose: String = (
353
+ "%s passes argument %d" % [signal_name, at + 1]
354
+ if at < from_signal
355
+ else "the connection binds argument %d" % (at + 1)
356
+ )
338
357
  return (
339
- "%s passes argument %d as %s, and %s takes it as %s: %s%s"
358
+ "%s as %s, and %s takes it as %s: %s%s"
340
359
  % [
341
- signal_name,
342
- at + 1,
360
+ whose,
343
361
  _type_named(given),
344
362
  method,
345
363
  str(parameter.get("name", "")),
@@ -380,6 +398,25 @@ static func _type_named(declared: Dictionary) -> String:
380
398
  return type_string(type)
381
399
 
382
400
 
401
+ ## The scene that makes the connection [param source] holds with [param flags], as a path, or "" for
402
+ ## one this scene makes. The engine marks such a connection inherited as the scene holding it loads:
403
+ ## from an instance, the scene instanced, and from the root of an inherited scene, the one it
404
+ ## inherits.
405
+ static func _made_in(scene: GdhSceneFile, source: Node, flags: int) -> String:
406
+ if not flags & CONNECT_INHERITED:
407
+ return ""
408
+ if source != scene.root and not source.scene_file_path.is_empty():
409
+ return source.scene_file_path
410
+ var holder: Node = source if source == scene.root else source.owner
411
+ while holder != null and holder != scene.root and holder.scene_file_path.is_empty():
412
+ holder = holder.owner
413
+ if holder != null and holder != scene.root:
414
+ return holder.scene_file_path
415
+ var packed: PackedScene = load(scene.path) as PackedScene
416
+ var base: SceneState = packed.get_state().get_base_scene_state() if packed != null else null
417
+ return base.get_path() if base != null else "a scene this one inherits"
418
+
419
+
383
420
  static func _count(arguments: int) -> String:
384
421
  if arguments == 0:
385
422
  return "no arguments"
@@ -428,6 +465,21 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
428
465
  % [scene_path, said_from, signal_name, said_to, method_name]
429
466
  )
430
467
  )
468
+ var made_in: String = _made_in(scene, source, GdhRead.as_int(held["flags"]))
469
+ if not made_in.is_empty():
470
+ var named_from: String = str(source.name) if source == scene.root else from
471
+ var named_to: String = str(target.name) if target == scene.root else to
472
+ var made_by: String = (
473
+ "The connection from %s.%s to %s.%s is made by %s"
474
+ % [named_from, signal_name, named_to, method_name, made_in]
475
+ )
476
+ return scene.refuse(
477
+ (
478
+ made_by
479
+ + ", and this scene saves nothing about it: disconnect it there, which changes every"
480
+ + " scene using it."
481
+ )
482
+ )
431
483
  var callable: Callable = held["callable"]
432
484
  source.disconnect(signal_name, callable)
433
485
 
@@ -498,6 +550,9 @@ func list_connections(args: Dictionary) -> Dictionary:
498
550
  if target_obj is Node:
499
551
  target_path = str(root.get_path_to(target_obj as Node))
500
552
  editable = editable and scene.foreign(target_obj as Node).is_empty()
553
+ # A connection an instanced scene makes on its own root sits on a node this scene owns,
554
+ # so the ends alone read it as one this scene could change.
555
+ var made_in: String = _made_in(scene, node, flags)
501
556
  var listed: Dictionary = {
502
557
  "sourceNodePath": path,
503
558
  "signalName": signal_name,
@@ -505,11 +560,11 @@ func list_connections(args: Dictionary) -> Dictionary:
505
560
  "methodName": str(callable.get_method()),
506
561
  "flags": flags,
507
562
  "flagNames": flag_names(flags),
508
- # False for a connection an instanced scene makes inside itself, which only that
509
- # scene can change.
510
- "editable": editable,
563
+ "editable": editable and made_in.is_empty(),
511
564
  "methodFound": target_obj != null and target_obj.has_method(callable.get_method()),
512
565
  }
566
+ if not made_in.is_empty():
567
+ listed["madeIn"] = made_in
513
568
  var bound: Dictionary = {
514
569
  "binds": callable.get_bound_arguments(),
515
570
  "unbinds": callable.get_unbound_arguments_count(),
@@ -61,9 +61,11 @@ func _init(host: Node, values: GdhValues) -> void:
61
61
  ## pointer when the game is on one.
62
62
  ##
63
63
  ## For the root window the engine asks Windows where the pointer is, and GetCursorPos refuses a
64
- ## process on a desktop that is not in use: measured on one of gdharness's own, it answered false
65
- ## with access denied and left the point unwritten. The engine does not check, so a game driven
66
- ## there read get_mouse_position() as x 836852416 after a motion to 1310, 314. Nothing here can
64
+ ## process on a desktop that is not in use: on one of gdharness's own it answers false and leaves the
65
+ ## point unwritten, with access denied in the user's window station and with
66
+ ## ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION in the one gdharness's desktop is in. The engine does
67
+ ## not check, so a game driven there read get_mouse_position() as x 836852416 after a motion to
68
+ ## 1310, 314. Nothing here can
67
69
  ## put a pointer on that desktop: Windows moves one only on the desktop in use, and making this one
68
70
  ## that would take the screen. What reads the injected event is right, tooltips included, since the
69
71
  ## engine places a tooltip from the motion that reached the control.
@@ -112,11 +112,15 @@ static func field_before(focused: Control) -> Dictionary:
112
112
  ## Whether [param focused], read as [param before], stopped being edited, as a [LineEdit] does when
113
113
  ## it is submitted: it keeps the focus and drops every key after, so a caller reading only the focus
114
114
  ## typed next into nothing.
115
- static func ended_editing(focused: Control, before: Dictionary) -> bool:
115
+ ##
116
+ ## [param focused] is a Variant because the key can free it, as Escape closing the panel a field is
117
+ ## on does, and a parameter typed Control refuses a freed object before the check here can run.
118
+ static func ended_editing(focused: Variant, before: Dictionary) -> bool:
116
119
  var was: bool = before.get("editing", false)
117
120
  if not was or not is_instance_valid(focused):
118
121
  return false
119
- var field: LineEdit = focused as LineEdit
122
+ var held: Object = focused
123
+ var field: LineEdit = held as LineEdit
120
124
  return field != null and not field.is_editing()
121
125
 
122
126
 
@@ -124,15 +128,18 @@ static func ended_editing(focused: Control, before: Dictionary) -> bool:
124
128
  ## typed_into and holds where its text changed, and editing_ended where it stopped being edited. A
125
129
  ## field being edited takes a printable key as typing and keeps it from the game's _unhandled_input,
126
130
  ## so Space into a name field pressed no jump, and an answer naming only the focus did not say why.
127
- static func field_noted(said: Dictionary, focused: Control, before: Dictionary) -> Dictionary:
131
+ ## A Variant for the reason [method ended_editing] gives.
132
+ static func field_noted(said: Dictionary, focused: Variant, before: Dictionary) -> Dictionary:
128
133
  if before.is_empty() or not is_instance_valid(focused):
129
134
  return said
130
- var field: LineEdit = focused as LineEdit
131
- var now: String = field.text if field != null else (focused as TextEdit).text
135
+ var held: Object = focused
136
+ var control: Control = held as Control
137
+ var field: LineEdit = control as LineEdit
138
+ var now: String = field.text if field != null else (control as TextEdit).text
132
139
  if now != before["text"]:
133
- said["typed_into"] = str(focused.get_path())
134
- said["holds"] = _what_it_holds(focused)
135
- if ended_editing(focused, before):
140
+ said["typed_into"] = str(control.get_path())
141
+ said["holds"] = _what_it_holds(control)
142
+ if ended_editing(control, before):
136
143
  said["editing_ended"] = true
137
144
  return said
138
145
 
@@ -125,15 +125,16 @@ static func no_animation(player: AnimationPlayer, called: String, animation_name
125
125
 
126
126
  static func _summary(anim: Animation) -> Dictionary:
127
127
  return {
128
- "length": anim.length,
128
+ "length": held_seconds(anim.length),
129
129
  "loopMode": LOOP_MODE_NAMES.get(anim.loop_mode, str(anim.loop_mode)),
130
- "step": anim.step,
130
+ "step": held_seconds(anim.step),
131
131
  }
132
132
 
133
133
 
134
- ## [param seconds], a key's time, as the shortest decimal holding the same 32-bit float. A scene keeps
135
- ## key times as 32-bit floats, so a key put at 0.7 read back as 0.699999988079071.
136
- static func key_time(seconds: float) -> float:
134
+ ## [param seconds], a key's time or an animation's length or step, as the shortest decimal holding
135
+ ## the same 32-bit float. An animation keeps all three as 32-bit floats, so a key put at 0.7 read
136
+ ## back as 0.699999988079071 and a step of 0.1 as 0.100000001490116.
137
+ static func held_seconds(seconds: float) -> float:
137
138
  var held: float = PackedFloat32Array([seconds])[0]
138
139
  for places: int in range(0, 10):
139
140
  var shorter: float = String.num(held, places).to_float()
@@ -159,7 +160,7 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
159
160
  var keyframes: Array[Dictionary] = []
160
161
  var methods: Dictionary = {}
161
162
  for key: int in anim.track_get_key_count(track):
162
- var keyframe: Dictionary = {"time": key_time(anim.track_get_key_time(track, key))}
163
+ var keyframe: Dictionary = {"time": held_seconds(anim.track_get_key_time(track, key))}
163
164
  if kind == Animation.TYPE_METHOD:
164
165
  var method: String = str(anim.method_track_get_name(track, key))
165
166
  methods[method] = true
@@ -428,7 +428,10 @@ func _declared_class_name(path: String) -> String:
428
428
  func _usage_files(kinds: Array) -> Array[String]:
429
429
  var found: Array[String] = []
430
430
  for kind: Variant in kinds:
431
- match str(kind):
431
+ # As project_search reads an extension: ".tscn" searched no file, and the answer said
432
+ # nothing used a script its scene attaches.
433
+ var named: String = str(kind).trim_prefix(".").to_lower()
434
+ match named:
432
435
  "godot":
433
436
  found.append("res://project.godot")
434
437
  "cfg":
@@ -436,7 +439,7 @@ func _usage_files(kinds: Array) -> Array[String]:
436
439
  if FileAccess.file_exists("res://export_presets.cfg"):
437
440
  found.append("res://export_presets.cfg")
438
441
  _:
439
- found.append_array(_files.find_files("res://", "." + str(kind)))
442
+ found.append_array(_files.find_files("res://", "." + named))
440
443
  return found
441
444
 
442
445
 
@@ -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}
@@ -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")
@@ -404,6 +404,14 @@ static func _modification_problems(modifications: Array, declared: Dictionary) -
404
404
  )
405
405
  if mod_type == "add_constant" and str(fields.get("defaultValue", "")).is_empty():
406
406
  problems.append(which + " is a constant with no defaultValue, and a constant needs a value")
407
+ if (
408
+ not str(fields.get("exportHint", "")).is_empty()
409
+ and fields.has("isExport")
410
+ and not GdhRead.as_bool(fields["isExport"])
411
+ ):
412
+ problems.append(
413
+ which + " has an exportHint and isExport false, and a hint is written only on an export"
414
+ )
407
415
  if mod_name.is_empty():
408
416
  problems.append(which + " has no name")
409
417
  continue
@@ -527,8 +535,9 @@ func _add_variable(lines: Array[String], mod: Dictionary, placed: Array[Dictiona
527
535
  var var_name: String = str(mod.get("name", ""))
528
536
  var var_type: String = str(mod.get("varType", ""))
529
537
  var default_value: String = str(mod.get("defaultValue", ""))
530
- var is_export: bool = GdhRead.as_bool(mod.get("isExport", false))
531
538
  var export_hint: String = str(mod.get("exportHint", ""))
539
+ # A hint can only mean an export, so a hint given alone writes one.
540
+ var is_export: bool = GdhRead.as_bool(mod.get("isExport", not export_hint.is_empty()))
532
541
  var is_onready: bool = GdhRead.as_bool(mod.get("isOnready", false))
533
542
 
534
543
  var var_line: String = ""
@@ -408,7 +408,7 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
408
408
  # Why [param resource_path] has no import file, with what to do about it where anything can be done.
409
409
  func _no_sidecar(resource_path: String) -> String:
410
410
  if not FileAccess.file_exists(resource_path):
411
- return resource_path + " is not on disk"
411
+ return GdhFileWalk.new().with_nearest_files(resource_path + " is not on disk", resource_path)
412
412
  var never: String = _never_imported(resource_path)
413
413
  if not never.is_empty():
414
414
  return resource_path + " has no import options: " + never