gdharness 1.3.8 → 1.3.9

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 (35) hide show
  1. package/build/cli.js +137 -66
  2. package/build/godot/addons/gdharness_editor/file_reads.gd +13 -0
  3. package/build/godot/addons/gdharness_editor/reading.gd +61 -5
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +41 -35
  5. package/build/godot/addons/gdharness_editor/serialisation.gd +8 -7
  6. package/build/godot/addons/gdharness_editor/tool_executor.gd +12 -2
  7. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +244 -0
  8. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +105 -157
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +25 -1
  11. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -1
  12. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +72 -3
  14. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +7 -1
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  16. package/build/godot/addons/gdharness_runtime/reading.gd +61 -5
  17. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +3 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_input.gd +27 -31
  20. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +11 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +2 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +26 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_values.gd +105 -6
  24. package/build/godot/addons/gdharness_runtime/serialisation.gd +8 -7
  25. package/build/godot/operations/editor_tools.gd +8 -0
  26. package/build/godot/operations/file_reads.gd +13 -0
  27. package/build/godot/operations/gdscript_analysis.gd +14 -0
  28. package/build/godot/operations/gdscript_authoring.gd +1 -1
  29. package/build/godot/operations/godot_operations.gd +2 -0
  30. package/build/godot/operations/input_actions.gd +22 -7
  31. package/build/godot/operations/project_config.gd +31 -0
  32. package/build/godot/operations/reading.gd +61 -5
  33. package/build/godot/operations/serialisation.gd +8 -7
  34. package/build/keeper.js +43 -13
  35. package/package.json +1 -1
@@ -172,6 +172,11 @@ func _cut_asked(params: Dictionary, in_window: bool) -> Dictionary:
172
172
  return _refused(shape % JSON.stringify(region_given))
173
173
  asked["region"] = region
174
174
  if not node_path.is_empty():
175
+ # Before any refusal of the window: a headless game refused a path that named nothing for
176
+ # having no window, and the wrong path was found only after a restart with one.
177
+ var standing: Dictionary = GdhValues.node_at(_host.get_tree().root, node_path)
178
+ if standing.has("message"):
179
+ return standing
175
180
  asked["node_path"] = node_path
176
181
  return asked
177
182
 
@@ -493,6 +493,9 @@ func call_method(params: Dictionary) -> Dictionary:
493
493
  answer["awaited"] = true
494
494
  _keep_reported(answer, "errors", caught.raised)
495
495
  _keep_reported(answer, "warnings", caught.warned)
496
+ # Main.explode, ended at a null it called into, answered result 0 as if it had counted something.
497
+ if caught.ended_at.has(named):
498
+ answer["ended_by_error_at"] = caught.ended_at[named]
496
499
  var asked: Variant = params.get("properties")
497
500
  if _reads_the_result(asked):
498
501
  answer.merge(_read_off_result(result, asked))
@@ -21,15 +21,6 @@ const GdhMouse = preload("runtime_mouse.gd")
21
21
  ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
22
22
  const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
23
23
 
24
- ## Node.ProcessMode as its names, for a refusal to say which one is in force.
25
- const PROCESS_MODES: Dictionary[int, String] = {
26
- Node.PROCESS_MODE_INHERIT: "INHERIT",
27
- Node.PROCESS_MODE_PAUSABLE: "PAUSABLE",
28
- Node.PROCESS_MODE_WHEN_PAUSED: "WHEN_PAUSED",
29
- Node.PROCESS_MODE_ALWAYS: "ALWAYS",
30
- Node.PROCESS_MODE_DISABLED: "DISABLED",
31
- }
32
-
33
24
  ## Whether this run has had the pointer note in full; the game process is the run.
34
25
  static var _pointer_explained: bool = false
35
26
 
@@ -151,7 +142,7 @@ func inject_action(params: Dictionary) -> Dictionary:
151
142
  return {"type": "error", "message": "Action name required"}
152
143
 
153
144
  if not InputMap.has_action(action):
154
- return {"type": "error", "message": "Action not found: " + action}
145
+ return {"type": "error", "message": GdhValues.no_action(action)}
155
146
  var holding: Variant = GdhValues.held_for(params)
156
147
  if holding is String:
157
148
  return {"type": "error", "message": holding}
@@ -159,6 +150,9 @@ func inject_action(params: Dictionary) -> Dictionary:
159
150
 
160
151
  var focused: String = _focus_owner()
161
152
  _say_action(action, whole or held, strength)
153
+ var shortcuts: Dictionary = {"shortcuts": [], "shortcut_pressed": null}
154
+ if whole or held:
155
+ shortcuts = await GdhValues.press_shortcuts(_host.get_tree().root, action)
162
156
  var lasted: Variant = null
163
157
  if whole:
164
158
  # A frame between the halves at least, as a click has: a listener that acts on the press and
@@ -172,6 +166,8 @@ func inject_action(params: Dictionary) -> Dictionary:
172
166
  "type": "input_injected",
173
167
  "input_type": "action",
174
168
  "action": action,
169
+ "shortcuts": shortcuts["shortcuts"],
170
+ "shortcut_pressed": shortcuts["shortcut_pressed"],
175
171
  "pressed": held and not whole,
176
172
  "whole": whole,
177
173
  "held_frames": GdhValues.held_answer(whole, frames),
@@ -272,6 +268,7 @@ func inject_key(params: Dictionary) -> Dictionary:
272
268
  "ctrl": event.ctrl_pressed,
273
269
  "alt": event.alt_pressed,
274
270
  "unicode": event.unicode,
271
+ "actions": GdhValues.actions_of(event),
275
272
  "pressed": held and not whole,
276
273
  "whole": whole,
277
274
  },
@@ -317,7 +314,8 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
317
314
  # its drag from the press, which is where a real pointer would be.
318
315
  var _moved: Vector2 = _arrive(position)
319
316
  var supplied: bool = _follow_in_the_window(position)
320
- Input.parse_input_event(_button(position, button, whole or held, double))
317
+ var press: InputEventMouseButton = _button(position, button, whole or held, double)
318
+ Input.parse_input_event(press)
321
319
  var lasted: Variant = null
322
320
  if notch:
323
321
  Input.parse_input_event(_button(position, button, false, false))
@@ -326,7 +324,12 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
326
324
  # press and one that acts on the release each get a frame to do it in.
327
325
  lasted = await GdhValues.hold(_host.get_tree(), frames)
328
326
  Input.parse_input_event(_button(position, button, false, false))
329
- await _host.get_tree().process_frame
327
+ # Input hands the events on at the next frame, so the control under the pointer is read after it.
328
+ await _host.get_tree().process_frame
329
+ var hovered: Control = _hover.hovered_in(_host.get_viewport())
330
+ var hovered_path: Variant = null
331
+ if hovered != null:
332
+ hovered_path = str(hovered.get_path())
330
333
 
331
334
  var at: Array[Vector2] = [position]
332
335
  var said: Dictionary = _pointer_noted(
@@ -338,6 +341,8 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
338
341
  "held_for": lasted,
339
342
  "position": [position.x, position.y],
340
343
  "button": button,
344
+ "hovered": hovered_path,
345
+ "actions": GdhValues.actions_of(press),
341
346
  "pressed": held and not whole,
342
347
  "whole": whole,
343
348
  "double": double
@@ -653,7 +658,8 @@ func click(params: Dictionary) -> Dictionary:
653
658
  var was_pressed: Variant = null
654
659
  if toggle != null and toggle.toggle_mode:
655
660
  was_pressed = toggle.button_pressed
656
- _press_button(viewport, _button(position, button, true, double))
661
+ var press: InputEventMouseButton = _button(position, button, true, double)
662
+ _press_button(viewport, press)
657
663
  await _host.get_tree().process_frame
658
664
  _press_button(viewport, _button(position, button, false, false))
659
665
  # The release is what a button acts on, and a queue_free it causes lands at the end of
@@ -675,9 +681,16 @@ func click(params: Dictionary) -> Dictionary:
675
681
  "double": double,
676
682
  "hovered": hovered_path,
677
683
  "landed": landed,
684
+ "actions": GdhValues.actions_of(press),
678
685
  "control_afterwards": afterwards,
679
686
  "scrolled_into_view": scrolled,
680
687
  }
688
+ # A Label and a TextureRect ignore the pointer unless told otherwise, so the click goes to what is
689
+ # under them, and landed false with that control as hovered said nothing of why.
690
+ if not landed and control.mouse_filter == Control.MOUSE_FILTER_IGNORE:
691
+ answer["why_not_landed"] = (
692
+ "%s's mouse_filter is ignore, so the pointer passes through it to what is under it" % node_path
693
+ )
681
694
  if found != null:
682
695
  answer["found"] = found
683
696
  if was_pressed != null:
@@ -718,7 +731,7 @@ func _takes_no_click(control: Control, node_path: String) -> String:
718
731
  % [node_path, on]
719
732
  )
720
733
  if not control.can_process():
721
- var why: String = _why_it_does_not_process(control, node_path)
734
+ var why: String = GdhTargets.why_it_does_not_process(control, node_path)
722
735
  var over: Node = GdhTargets.drawn_over(control)
723
736
  var cover: String = "" if over == null else "; %s is drawn over it" % over.get_path()
724
737
  if _host.get_tree().paused:
@@ -730,23 +743,6 @@ func _takes_no_click(control: Control, node_path: String) -> String:
730
743
  return ""
731
744
 
732
745
 
733
- ## Which node's process_mode keeps [param control] from processing, and what it is. A node set to
734
- ## inherit takes its parent's, so the one deciding can be any node above it: the game turning a
735
- ## whole screen off while a menu shows over it reads, on the button, as a button turned off.
736
- static func _why_it_does_not_process(control: Control, node_path: String) -> String:
737
- var deciding: Node = control
738
- while deciding.process_mode == Node.PROCESS_MODE_INHERIT and deciding.get_parent() != null:
739
- deciding = deciding.get_parent()
740
- # The root inherits from nothing, which processes as pausable.
741
- var value: int = deciding.process_mode
742
- if value == Node.PROCESS_MODE_INHERIT:
743
- value = Node.PROCESS_MODE_PAUSABLE
744
- var mode: String = PROCESS_MODES.get(value, str(value))
745
- if deciding == control:
746
- return "its process_mode is %s" % mode
747
- return "%s inherits process_mode %s from %s" % [node_path, mode, deciding.get_path()]
748
-
749
-
750
746
  ## Why a click at [param control]'s centre would reach nothing, or "" when it would reach it: the
751
747
  ## centre outside the viewport it is drawn in, at [param local]; outside the viewport showing that
752
748
  ## one on the screen, which [param reached] holds with the centre there; or clipped away by a
@@ -405,10 +405,13 @@ static func _arity(object: Object, method: String) -> Dictionary:
405
405
  ##
406
406
  ## A path is read, and a wait reads it every frame, so a call in one has to be a read. The engine
407
407
  ## marks its own reading methods const and `queue_free()`, `free()` and `set_name()` not, measured on
408
- ## 4.7.2. A game's methods carry no such mark, so one declared `-> void` is taken at its word, as
409
- ## doing something and answering nothing, and one left untyped is trusted. Without this a find
410
- ## with property "queue_free()" freed every node it matched, and a wait on "advance_day()" called
411
- ## it once a frame and then reported the state it had driven the game to.
408
+ ## 4.7.2, though it leaves some reads unmarked as well, `Engine.get_frames_drawn()` and
409
+ ## `get_time_scale()` among them; so the refusal says the mark is missing, not that the method
410
+ ## changes anything, and points at runtime_invoke call, which calls it. A game's methods carry no
411
+ ## such mark, so one declared `-> void` is taken at its word, as doing something and answering
412
+ ## nothing, and one left untyped is trusted. Without this a find with property "queue_free()" freed
413
+ ## every node it matched, and a wait on "advance_day()" called it once a frame and then reported the
414
+ ## state it had driven the game to.
412
415
  ##
413
416
  ## Given [param given] arguments, a call is the engine's and marked as reading, or it is refused. A
414
417
  ## game's method taking arguments is trusted on nothing at all: one answering a value can as well
@@ -450,7 +453,10 @@ static func call_refused(object: Object, method: String, called_as: String, give
450
453
  )
451
454
  return ""
452
455
  if _native_flags(object, method) & METHOD_FLAG_CONST == 0:
453
- return "%s.%s changes what it is called on rather than reading it%s" % [called_as, method, READS_ONLY]
456
+ return (
457
+ "%s.%s is not one the engine marks as only reading%s; runtime_invoke call calls it"
458
+ % [called_as, method, READS_ONLY]
459
+ )
454
460
  return ""
455
461
 
456
462
 
@@ -723,6 +723,8 @@ func get_metrics(params: Dictionary) -> Dictionary:
723
723
  "frame_time": frame_time,
724
724
  "frame_time_max": frame_time_max,
725
725
  "frames_timed": timed,
726
+ "process_frames": Engine.get_process_frames(),
727
+ "physics_frames": Engine.get_physics_frames(),
726
728
  "physics_steps_per_second": physics_steps_per_second,
727
729
  "physics_ticks_per_second": Engine.physics_ticks_per_second,
728
730
  "max_physics_steps_per_frame": Engine.max_physics_steps_per_frame,
@@ -13,6 +13,15 @@ const GdhWords = preload("runtime_words.gd")
13
13
  ## How many of the controls saying the same words a refusal names, with the index for each.
14
14
  const LISTED_CANDIDATES: int = 10
15
15
 
16
+ ## Node.ProcessMode as its names, for a refusal to say which one is in force.
17
+ const PROCESS_MODES: Dictionary[int, String] = {
18
+ Node.PROCESS_MODE_INHERIT: "INHERIT",
19
+ Node.PROCESS_MODE_PAUSABLE: "PAUSABLE",
20
+ Node.PROCESS_MODE_WHEN_PAUSED: "WHEN_PAUSED",
21
+ Node.PROCESS_MODE_ALWAYS: "ALWAYS",
22
+ Node.PROCESS_MODE_DISABLED: "DISABLED",
23
+ }
24
+
16
25
  ## What a game started without a window gets whatever the project settings say, and the usual
17
26
  ## reason a control is out of reach. Only worth telling somebody when it is what they have.
18
27
  const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
@@ -954,3 +963,20 @@ static func _out_of_reach_exactly(nodes: Array[Node], why: Array[String]) -> Str
954
963
  if nodes.size() == 1:
955
964
  return "1 control says exactly that and cannot be clicked: %s" % first
956
965
  return "%d controls say exactly that and cannot be clicked, such as %s" % [nodes.size(), first]
966
+
967
+
968
+ ## Which node's process_mode keeps [param control] from processing, and what it is. A node set to
969
+ ## inherit takes its parent's, so the one deciding can be any node above it: the game turning a
970
+ ## whole screen off while a menu shows over it reads, on the button, as a button turned off.
971
+ static func why_it_does_not_process(control: Control, node_path: String) -> String:
972
+ var deciding: Node = control
973
+ while deciding.process_mode == Node.PROCESS_MODE_INHERIT and deciding.get_parent() != null:
974
+ deciding = deciding.get_parent()
975
+ # The root inherits from nothing, which processes as pausable.
976
+ var value: int = deciding.process_mode
977
+ if value == Node.PROCESS_MODE_INHERIT:
978
+ value = Node.PROCESS_MODE_PAUSABLE
979
+ var mode: String = PROCESS_MODES.get(value, str(value))
980
+ if deciding == control:
981
+ return "its process_mode is %s" % mode
982
+ return "%s inherits process_mode %s from %s" % [node_path, mode, deciding.get_path()]
@@ -31,6 +31,9 @@ const SINGLETONS: Array[String] = [
31
31
  ## The most frames a press is held in one call, as many as a frames wait takes.
32
32
  const MOST_HELD: int = 600
33
33
 
34
+ ## Read by [method _key_names], once a game.
35
+ static var _named_keys: PackedStringArray = []
36
+
34
37
  var _shared: GdhSerialisation = GdhSerialisation.new()
35
38
 
36
39
 
@@ -160,12 +163,7 @@ static func glyph_of(keycode: Key, shifted: bool) -> int:
160
163
  ## engine's own names, printable keys and the special ones above [constant KEY_SPECIAL], since the
161
164
  ## game takes any name OS.find_keycode_from_string does.
162
165
  static func unknown_key(name: String) -> String:
163
- var names: PackedStringArray = []
164
- for code: int in range(32, 127) + range(KEY_SPECIAL + 1, KEY_SPECIAL + 0x100):
165
- var spelled: String = OS.get_keycode_string(code as Key)
166
- if not spelled.is_empty() and OS.find_keycode_from_string(spelled) == code:
167
- var _named: bool = names.append(spelled)
168
- var close: PackedStringArray = GdhRead.nearest(name, names)
166
+ var close: PackedStringArray = GdhRead.nearest(name, _key_names())
169
167
  var said: String = "No key is called %s." % name
170
168
  if close.size() == 1:
171
169
  said += " The nearest key name is %s." % close[0]
@@ -178,6 +176,107 @@ static func unknown_key(name: String) -> String:
178
176
  )
179
177
 
180
178
 
179
+ ## Every key name the engine takes, read once a game. The engine spells a code it has no name for as
180
+ ## the character it would be, and past the last code point that is an error it prints: 136 of them
181
+ ## for the special range, two seconds of console on a fast machine, more than a call's whole wait on
182
+ ## a loaded one, and each in the game's log as an error the game never made. So they are asked with
183
+ ## the engine's error printing off, which no other line of the main thread runs beside.
184
+ static func _key_names() -> PackedStringArray:
185
+ if not _named_keys.is_empty():
186
+ return _named_keys
187
+ var printing: bool = Engine.print_error_messages
188
+ Engine.print_error_messages = false
189
+ for code: int in range(32, 127) + range(KEY_SPECIAL + 1, KEY_SPECIAL + 0x100):
190
+ var spelled: String = OS.get_keycode_string(code as Key)
191
+ if not spelled.is_empty() and OS.find_keycode_from_string(spelled) == code:
192
+ var _named: bool = _named_keys.append(spelled)
193
+ Engine.print_error_messages = printing
194
+ return _named_keys
195
+
196
+
197
+ ## The refusal for [param action], which the input map lacks, with the nearest of the project's own
198
+ ## actions, or else all of them. The engine's ui_ ones are in every project, and offered for any name
199
+ ## they only buried the project's ("dance" is near ui_cancel), so they are offered for a ui_ name only.
200
+ static func no_action(action: String) -> String:
201
+ var engine: PackedStringArray = []
202
+ var own: PackedStringArray = []
203
+ for named: StringName in InputMap.get_actions():
204
+ if str(named).begins_with("ui_"):
205
+ var _listed: bool = engine.append(str(named))
206
+ else:
207
+ var _owned: bool = own.append(str(named))
208
+ own.sort()
209
+ var said: String = "Action not found: %s" % action
210
+ var close: PackedStringArray = GdhRead.nearest(action, engine if action.begins_with("ui_") else own)
211
+ if not close.is_empty():
212
+ return "%s; the nearest %s %s." % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
213
+ if own.is_empty():
214
+ return "%s; the project defines no actions of its own, and the engine's are the ui_ ones." % said
215
+ return "%s; the project's own actions are %s, beside the engine's ui_ ones." % [said, ", ".join(own)]
216
+
217
+
218
+ ## The actions of the input map that [param event] stands for, as Input reads it for
219
+ ## is_action_pressed: a key or a button bound to an action presses that action too, so a game
220
+ ## counting the action counted the key, and the answer had said only that a key went in.
221
+ static func actions_of(event: InputEvent) -> Array[String]:
222
+ var named: Array[String] = []
223
+ for action: StringName in InputMap.get_actions():
224
+ if InputMap.event_is_action(event, action):
225
+ named.append(str(action))
226
+ named.sort()
227
+ return named
228
+
229
+
230
+ ## Presses the buttons under [param root] whose Shortcut [param action] matches, as the player's key
231
+ ## bound to it would, and answers their paths under "shortcuts" and the one that took the press
232
+ ## under "shortcut_pressed". A Viewport hands a shortcut only keys, joypad buttons and
233
+ ## InputEventShortcut, so an action pressed on its own never reached a Button whose Shortcut holds
234
+ ## that action, which is how a game gives a button a binding the player can change.
235
+ ##
236
+ ## A key goes to the shortcuts last in the tree first and stops at the first button that takes it,
237
+ ## while an InputEventShortcut reaches only the buttons holding that one Shortcut. So each Shortcut
238
+ ## is sent in that order, a frame apart, until a button is pressed; a button still decides by its
239
+ ## disabled, its visibility and its shortcut context, as it does for the key.
240
+ static func press_shortcuts(root: Node, action: String) -> Dictionary:
241
+ var pressing: InputEventAction = InputEventAction.new()
242
+ pressing.action = action
243
+ pressing.pressed = true
244
+ var buttons: Array[BaseButton] = []
245
+ for node: Node in root.find_children("*", "BaseButton", true, false):
246
+ var button: BaseButton = node
247
+ if button.shortcut != null and button.shortcut.matches_event(pressing):
248
+ buttons.append(button)
249
+ var paths: Array[String] = []
250
+ for button: BaseButton in buttons:
251
+ paths.append(str(button.get_path()))
252
+ buttons.reverse()
253
+ var taken: Array[String] = []
254
+ var heard: Array[Callable] = []
255
+ for button: BaseButton in buttons:
256
+ var path: String = str(button.get_path())
257
+ var hearing: Callable = func() -> void: taken.append(path)
258
+ heard.append(hearing)
259
+ var _listening: int = button.pressed.connect(hearing)
260
+ var sent: Dictionary = {}
261
+ for button: BaseButton in buttons:
262
+ if not taken.is_empty():
263
+ break
264
+ if sent.has(button.shortcut):
265
+ continue
266
+ sent[button.shortcut] = true
267
+ var through: InputEventShortcut = InputEventShortcut.new()
268
+ through.shortcut = button.shortcut
269
+ Input.parse_input_event(through)
270
+ await root.get_tree().process_frame
271
+ for at: int in buttons.size():
272
+ if is_instance_valid(buttons[at]):
273
+ buttons[at].pressed.disconnect(heard[at])
274
+ var pressed: Variant = null
275
+ if not taken.is_empty():
276
+ pressed = taken[0]
277
+ return {"shortcuts": paths, "shortcut_pressed": pressed}
278
+
279
+
181
280
  ## What [param path] names: a node under [param root], as [method node_at] reads it, or an engine
182
281
  ## singleton by its name. Answers the object under "object", and under "node" the node that objects
183
282
  ## named in a value are found from, which for a singleton is [param root].
@@ -142,28 +142,29 @@ func serialize_value(value: Variant) -> Variant:
142
142
  return value
143
143
  var carried: Variant = call(serialiser, value)
144
144
  # The fixed shapes carry their components as bare floats, so an infinite one in a Vector2 is
145
- # tagged here; containers reach their members through this function already.
146
- return carried if typeof(value) in [TYPE_ARRAY, TYPE_DICTIONARY] else _finite(carried)
145
+ # tagged here, and a 32-bit one shortened; containers reach their members through this already.
146
+ var type: int = typeof(value)
147
+ return carried if type in [TYPE_ARRAY, TYPE_DICTIONARY] else _finite(carried, GdhRead.holds_singles(type))
147
148
 
148
149
 
149
150
  # [param carried] with every non-finite float in it tagged. JSON has no spelling for infinity or
150
151
  # NaN: the engine writes 1e99999 and null, and the server, reading 1e99999 as Infinity, writes it
151
152
  # back out as null, so a property holding INF was answered as holding nothing.
152
- func _finite(carried: Variant) -> Variant:
153
+ func _finite(carried: Variant, single: bool = false) -> Variant:
153
154
  if carried is float:
154
155
  var number: float = carried
155
- return _serialize_float(number)
156
+ return _serialize_float(GdhRead.single(number) if single else number)
156
157
  if carried is Dictionary:
157
158
  var fields: Dictionary = carried
158
159
  var tagged: Dictionary = {}
159
160
  for key: Variant in fields:
160
- tagged[key] = _finite(fields[key])
161
+ tagged[key] = _finite(fields[key], single)
161
162
  return tagged
162
163
  if carried is Array:
163
164
  var items: Array = carried
164
165
  var tagged_items: Array = []
165
166
  for item: Variant in items:
166
- tagged_items.append(_finite(item))
167
+ tagged_items.append(_finite(item, single))
167
168
  return tagged_items
168
169
  return carried
169
170
 
@@ -189,7 +190,7 @@ func _serialize_floats(value: Variant) -> Variant:
189
190
  for each: float in numbers:
190
191
  items.append(_serialize_float(each))
191
192
  return items
192
- return value
193
+ return numbers if value is PackedFloat32Array else value
193
194
 
194
195
 
195
196
  # Rebuilds a Godot value from the shape serialize_value gave it.
@@ -7,6 +7,7 @@ extends RefCounted
7
7
  ## made here passes the same checks and answers in the same shape. What the engine does that an
8
8
  ## editor does not, running every script, is put back in scene_file.gd and headless_check.gd.
9
9
 
10
+ const GdhAnimationStates = preload("../addons/gdharness_editor/tools/animation_states.gd")
10
11
  const GdhAnimationTools = preload("../addons/gdharness_editor/tools/animation_tools.gd")
11
12
  const GdhResourceTools = preload("../addons/gdharness_editor/tools/resource_tools.gd")
12
13
  const GdhSceneTools = preload("../addons/gdharness_editor/tools/scene_tools.gd")
@@ -36,11 +37,16 @@ const RESOURCE_COMMANDS: Array[String] = [
36
37
  const ANIMATION_COMMANDS: Array[String] = [
37
38
  "create_animation",
38
39
  "add_animation_track",
40
+ "remove_animation_track",
39
41
  "set_animation",
40
42
  "rename_animation",
41
43
  "remove_animation",
44
+ ]
45
+ const ANIMATION_STATE_COMMANDS: Array[String] = [
42
46
  "add_animation_state",
43
47
  "connect_animation_states",
48
+ "remove_animation_state",
49
+ "disconnect_animation_states",
44
50
  ]
45
51
 
46
52
 
@@ -60,6 +66,8 @@ static func run(params: Dictionary) -> Dictionary:
60
66
  tools = GdhResourceTools.new()
61
67
  elif command in ANIMATION_COMMANDS:
62
68
  tools = GdhAnimationTools.new()
69
+ elif command in ANIMATION_STATE_COMMANDS:
70
+ tools = GdhAnimationStates.new()
63
71
  else:
64
72
  return {"ok": false, "error": "No editor tool runs headless as " + command}
65
73
  var answer: Variant = tools.call(command, arguments)
@@ -119,6 +119,9 @@ static func _laid(scene_path: String) -> Dictionary:
119
119
  return {"ok": false, "error": refused}
120
120
  if not nodes.has("."):
121
121
  return {"ok": false, "error": "%s holds no nodes" % scene_path}
122
+ var base: SceneState = packed.get_state().get_base_scene_state()
123
+ if base != null:
124
+ nodes["."]["inherits"] = base.get_path()
122
125
  return {"ok": true, "nodes": nodes}
123
126
 
124
127
 
@@ -175,6 +178,10 @@ static func _lay(
175
178
  nodes[path] = _made(str(state.get_node_type(index)))
176
179
  if ids[index] >= 0:
177
180
  nodes[path]["uniqueId"] = ids[index]
181
+ # Laid at the root with no scene handed in is a scene the one being read inherits, and the
182
+ # deepest is laid first, so this is the file that makes the node.
183
+ if prefix == "." and loaded == null:
184
+ nodes[path]["inheritedFrom"] = state.get_path()
178
185
  # The name is the state's even for an entry laid already, since an inherited scene names its
179
186
  # root itself; a root instanced inside another scene is renamed by that scene once laid.
180
187
  if not recovered:
@@ -324,6 +331,12 @@ static func _entry(
324
331
  data["instanceOf"] = node["instanceOf"]
325
332
  if node.has("editableChildren"):
326
333
  data["editableChildren"] = node["editableChildren"]
334
+ # An inherited scene's file holds only what it changes, so the read says what it inherits and
335
+ # which nodes come from there, as the editor greys them.
336
+ if node.has("inherits"):
337
+ data["inherits"] = node["inherits"]
338
+ elif node.has("inheritedFrom"):
339
+ data["inheritedFrom"] = node["inheritedFrom"]
327
340
  var groups: Array[String] = node["groups"]
328
341
  if not groups.is_empty():
329
342
  var sorted: Array[String] = groups.duplicate()
@@ -76,6 +76,9 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
76
76
  if not full_script_path.begins_with("res://"):
77
77
  full_script_path = "res://" + full_script_path
78
78
 
79
+ # Read as GDScript, a scene came back as a script extending RefCounted with nothing in it.
80
+ if full_script_path.get_extension().to_lower() != "gd":
81
+ return _log.failure(_not_a_script(full_script_path))
79
82
  if not FileAccess.file_exists(full_script_path):
80
83
  return _log.failure(
81
84
  GdhFileWalk.new().with_nearest_files(
@@ -334,6 +337,17 @@ func _inner_class(
334
337
  return body
335
338
 
336
339
 
340
+ ## The refusal for [param path], which is not a .gd file, with the tool that reads what it is.
341
+ static func _not_a_script(path: String) -> String:
342
+ var said: String = "%s is not a GDScript file, and script_info reads .gd files only" % path
343
+ match path.get_extension().to_lower():
344
+ "tscn", "scn":
345
+ return said + ": scene_tree reads a scene's nodes, and with properties the script each carries."
346
+ "tres", "res":
347
+ return said + ": resource_edit read reads a resource."
348
+ return said + "."
349
+
350
+
337
351
  static func _indentation(line: String) -> String:
338
352
  return line.substr(0, line.length() - line.strip_edges(true, false).length())
339
353
 
@@ -259,7 +259,7 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
259
259
  )
260
260
  return _log.failure(
261
261
  (
262
- "Nothing was changed: %s parses as it is and would not with these additions: %s"
262
+ "Nothing was changed: %s parses now, and would not with these additions: %s"
263
263
  % [script_path, _reasons(caught.raised, full_script_path)]
264
264
  )
265
265
  )
@@ -247,6 +247,8 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
247
247
  payload = GdhProjectConfig.new(_log).get_project_setting(params)
248
248
  "set_project_setting":
249
249
  payload = GdhProjectConfig.new(_log).set_project_setting(params)
250
+ "unset_project_setting":
251
+ payload = GdhProjectConfig.new(_log).unset_project_setting(params)
250
252
  "add_autoload":
251
253
  payload = GdhProjectConfig.new(_log).add_autoload(params)
252
254
  "remove_autoload":
@@ -3,6 +3,8 @@ extends RefCounted
3
3
  const GdhRead = preload("reading.gd")
4
4
  const GdhLog = preload("logger.gd")
5
5
  const GdhProjectFile = preload("project_file.gd")
6
+ ## The device an event matches every device with, InputMap's ALL_DEVICES, which GDScript is not given.
7
+ const ALL_DEVICES: int = -1
6
8
 
7
9
  # The key names a caller may write, and what the engine calls them.
8
10
  const KEY_NAMES: Dictionary = {
@@ -108,7 +110,13 @@ func _init(p_log: GdhLog) -> void:
108
110
  func add_input_action(params: Dictionary) -> Dictionary:
109
111
  var action_name: String = str(params.get("action_name", ""))
110
112
  var events: Array = params.get("events", [])
111
- var deadzone: float = GdhRead.as_float(params.get("deadzone", 0.5), 0.5)
113
+ var setting: String = "input/" + action_name
114
+ var held: Variant = ProjectSettings.get_setting(setting) if ProjectSettings.has_setting(setting) else null
115
+ var old: Dictionary = held if held is Dictionary else {}
116
+ var old_deadzone: float = GdhRead.as_float(old.get("deadzone", 0.5), 0.5)
117
+ # A rebind that names no deadzone keeps the one the action has, which a caller adding a key did
118
+ # not mean to change.
119
+ var deadzone: float = GdhRead.as_float(params.get("deadzone", old_deadzone), old_deadzone)
112
120
 
113
121
  _log.info("Adding input action: " + action_name)
114
122
  # The editor's Input Map refuses these, since project.godot keys an action by its name and
@@ -150,7 +158,9 @@ func add_input_action(params: Dictionary) -> Dictionary:
150
158
  if key_value == KEY_NONE:
151
159
  return _log.failure(_unknown_key(keycode))
152
160
 
153
- var event_config: Dictionary = {"class_name": "InputEventKey", "keycode": key_value}
161
+ var event_config: Dictionary = {"class_name": "InputEventKey"}
162
+ var physical: bool = GdhRead.as_bool(fields.get("physical", false))
163
+ event_config["physical_keycode" if physical else "keycode"] = key_value
154
164
  if fields.get("ctrl", false):
155
165
  event_config["ctrl_pressed"] = true
156
166
  if fields.get("alt", false):
@@ -190,7 +200,6 @@ func add_input_action(params: Dictionary) -> Dictionary:
190
200
 
191
201
  # An action that exists is rebound, which is how one of the engine's ui_ actions is changed, so
192
202
  # what it held is answered rather than lost without a word.
193
- var setting: String = "input/" + action_name
194
203
  var answer: Dictionary = {
195
204
  "action_name": action_name,
196
205
  "events_count": events_config.size(),
@@ -199,8 +208,6 @@ func add_input_action(params: Dictionary) -> Dictionary:
199
208
  "was_new": not ProjectSettings.has_setting(setting),
200
209
  }
201
210
  if not answer["was_new"]:
202
- var held: Variant = ProjectSettings.get_setting(setting)
203
- var old: Dictionary = held if held is Dictionary else {}
204
211
  var old_events: Array[Dictionary] = []
205
212
  var listed: Variant = old.get("events", [])
206
213
  if listed is Array:
@@ -210,7 +217,7 @@ func add_input_action(params: Dictionary) -> Dictionary:
210
217
  var bound: InputEvent = event
211
218
  old_events.append(_shaped(bound))
212
219
  answer["old_events"] = old_events
213
- answer["old_deadzone"] = GdhRead.as_float(old.get("deadzone", 0.5), 0.5)
220
+ answer["old_deadzone"] = old_deadzone
214
221
 
215
222
  # Through ProjectSettings so the file is saved the way the editor saves it, header and
216
223
  # every other line kept; a ConfigFile of project.godot drops the comments on the way out.
@@ -226,9 +233,12 @@ func add_input_action(params: Dictionary) -> Dictionary:
226
233
  ## under text, which also covers a kind of event that form has no fields for.
227
234
  static func _shaped(event: InputEvent) -> Dictionary:
228
235
  var shaped: Dictionary = {"class_name": event.get_class(), "text": event.as_text()}
236
+ if event.device != ALL_DEVICES:
237
+ shaped["device"] = event.device
229
238
  if event is InputEventKey:
230
239
  var key: InputEventKey = event
231
- shaped["keycode"] = key.keycode
240
+ if key.keycode != KEY_NONE or key.physical_keycode == KEY_NONE:
241
+ shaped["keycode"] = key.keycode
232
242
  if key.physical_keycode != KEY_NONE:
233
243
  shaped["physical_keycode"] = key.physical_keycode
234
244
  for modifier: String in ["ctrl_pressed", "alt_pressed", "shift_pressed"]:
@@ -328,6 +338,7 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
328
338
  "InputEventKey":
329
339
  var key: InputEventKey = InputEventKey.new()
330
340
  key.keycode = GdhRead.as_int(event.get("keycode", 0)) as Key
341
+ key.physical_keycode = GdhRead.as_int(event.get("physical_keycode", 0)) as Key
331
342
  key.ctrl_pressed = GdhRead.as_bool(event.get("ctrl_pressed", false))
332
343
  key.alt_pressed = GdhRead.as_bool(event.get("alt_pressed", false))
333
344
  key.shift_pressed = GdhRead.as_bool(event.get("shift_pressed", false))
@@ -354,6 +365,10 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
354
365
 
355
366
  _:
356
367
  return _log.failure("Unknown input event class: " + evt_class)
368
+ # Every device, as the editor's Input Map binds an event: a new event names one device, and
369
+ # an action bound to it ignored a second joypad.
370
+ var bound: InputEvent = built.back()
371
+ bound.device = ALL_DEVICES
357
372
 
358
373
  return {"deadzone": deadzone, "events": built}
359
374