gdharness 1.2.33 → 1.2.35

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.
@@ -0,0 +1,232 @@
1
+ extends RefCounted
2
+
3
+ ## export_presets.cfg, read and written in the format the editor's Project > Export saves it in.
4
+
5
+ const Read = preload("reading.gd")
6
+ const Log = preload("logger.gd")
7
+
8
+ const PRESETS_FILE: String = "res://export_presets.cfg"
9
+ ## Where the editor names the one preset of each platform it runs on a device.
10
+ const RUNNABLE_SECTION: String = "runnable_presets"
11
+ ## The platforms the editor's exporter offers, by the name a preset gives its platform, each with
12
+ ## how the names of its files in the export templates directory begin.
13
+ const PLATFORMS: Dictionary[String, String] = {
14
+ "Windows Desktop": "windows_",
15
+ "Linux": "linux_",
16
+ "macOS": "macos",
17
+ "Android": "android_",
18
+ "iOS": "ios",
19
+ "Web": "web_",
20
+ }
21
+
22
+ var _log: Log
23
+
24
+
25
+ func _init(p_log: Log) -> void:
26
+ _log = p_log
27
+
28
+
29
+ ## The names of the project's export presets, in the order export_presets.cfg holds them.
30
+ static func names() -> Array[String]:
31
+ var named: Array[String] = []
32
+ var config: ConfigFile = ConfigFile.new()
33
+ if config.load(PRESETS_FILE) != OK:
34
+ return named
35
+ var index: int = 0
36
+ while config.has_section("preset." + str(index)):
37
+ named.append(str(config.get_value("preset." + str(index), "name", "")))
38
+ index += 1
39
+ return named
40
+
41
+
42
+ func list_export_presets(_params: Dictionary) -> Dictionary:
43
+ if not FileAccess.file_exists(PRESETS_FILE):
44
+ return {
45
+ "presets": [],
46
+ "presets_file_exists": false,
47
+ "note":
48
+ (
49
+ "No export_presets.cfg found. project_export add_preset adds a preset, as the editor's "
50
+ + "Project > Export does."
51
+ )
52
+ }
53
+ var config: ConfigFile = ConfigFile.new()
54
+ var err: Error = config.load(PRESETS_FILE)
55
+ if err != OK:
56
+ return _log.failure("Failed to parse export_presets.cfg: " + error_string(err))
57
+
58
+ var templates: String = _templates_dir()
59
+ var presets: Array[Dictionary] = []
60
+ var index: int = 0
61
+ while config.has_section("preset." + str(index)):
62
+ var section: String = "preset." + str(index)
63
+ var platform: String = str(config.get_value(section, "platform", "Unknown"))
64
+ var preset: Dictionary = {
65
+ "index": index,
66
+ "name": config.get_value(section, "name", "Unknown"),
67
+ "platform": platform,
68
+ "runnable": _runnable(config, section),
69
+ "export_path": config.get_value(section, "export_path", ""),
70
+ "export_filter": config.get_value(section, "export_filter", "all_resources"),
71
+ "include_filter": config.get_value(section, "include_filter", ""),
72
+ "exclude_filter": config.get_value(section, "exclude_filter", "")
73
+ }
74
+ if config.has_section_key(section, "custom_features"):
75
+ preset["custom_features"] = config.get_value(section, "custom_features", "")
76
+ # Left out for a platform this file names and the exporter does not know, which has no
77
+ # templates to look for.
78
+ if platform in PLATFORMS:
79
+ preset["templates_installed"] = _installed(templates, platform)
80
+ presets.append(preset)
81
+ index += 1
82
+ return {"presets": presets, "presets_file_exists": true, "total_presets": index, "templates": templates}
83
+
84
+
85
+ ## Whether the preset in [param section] is the one its platform runs. The editor names that preset
86
+ ## in a section of its own, and kept a key in the preset's section only in files saved before that.
87
+ static func _runnable(config: ConfigFile, section: String) -> bool:
88
+ var platform: String = str(config.get_value(section, "platform", ""))
89
+ if config.has_section_key(RUNNABLE_SECTION, platform):
90
+ return str(config.get_value(RUNNABLE_SECTION, platform)) == str(config.get_value(section, "name", ""))
91
+ return Read.as_bool(config.get_value(section, "runnable", false))
92
+
93
+
94
+ ## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
95
+ ## writes for a new one. The editor's exporter fills every platform option the file leaves out with
96
+ ## its default when it loads the file, which is what a preset fresh from that dialog holds too.
97
+ func add_export_preset(params: Dictionary) -> Dictionary:
98
+ var preset_name: String = str(params.get("preset", "")).strip_edges()
99
+ var platform: String = str(params.get("platform", ""))
100
+ var export_path: String = str(params.get("output_path", "")).trim_prefix("res://")
101
+ if preset_name.is_empty():
102
+ return _log.failure("preset is required: the name the preset is exported by")
103
+ if platform not in PLATFORMS:
104
+ return _log.failure(
105
+ "Unknown platform '%s'. The platforms are %s." % [platform, ", ".join(PLATFORMS.keys())]
106
+ )
107
+ var config: ConfigFile = ConfigFile.new()
108
+ if FileAccess.file_exists(PRESETS_FILE):
109
+ var loading: Error = config.load(PRESETS_FILE)
110
+ if loading != OK:
111
+ return _log.failure("Failed to parse export_presets.cfg: " + error_string(loading))
112
+ var index: int = 0
113
+ while config.has_section("preset." + str(index)):
114
+ var held: String = "preset." + str(index)
115
+ if str(config.get_value(held, "name", "")) == preset_name:
116
+ return _log.failure(
117
+ (
118
+ (
119
+ "There is already an export preset named '%s', for %s, exporting to '%s'. "
120
+ + "project_export run exports with it; give another name to add a second."
121
+ )
122
+ % [
123
+ preset_name,
124
+ config.get_value(held, "platform", ""),
125
+ config.get_value(held, "export_path", "")
126
+ ]
127
+ )
128
+ )
129
+ index += 1
130
+ # A platform with no preset to run takes the new one, as the editor's dialog does.
131
+ var runnable: bool = not config.has_section_key(RUNNABLE_SECTION, platform)
132
+ if runnable:
133
+ config.set_value(RUNNABLE_SECTION, platform, preset_name)
134
+
135
+ var section: String = "preset." + str(index)
136
+ var values: Dictionary = {
137
+ "name": preset_name,
138
+ "platform": platform,
139
+ "dedicated_server": false,
140
+ "custom_features": "",
141
+ "export_filter": "all_resources",
142
+ "include_filter": "",
143
+ "exclude_filter": "",
144
+ "export_path": export_path,
145
+ "patches": PackedStringArray(),
146
+ "encryption_include_filters": "",
147
+ "encryption_exclude_filters": "",
148
+ "seed": 0,
149
+ "encrypt_pck": false,
150
+ "encrypt_directory": false,
151
+ "script_export_mode": 2,
152
+ }
153
+ for key: String in values:
154
+ config.set_value(section, key, values[key])
155
+ # A section with nothing in it is not written, and the editor's loader reports a missing options
156
+ # section as an error; these two are options of every platform, empty in a new preset.
157
+ config.set_value(section + ".options", "custom_template/debug", "")
158
+ config.set_value(section + ".options", "custom_template/release", "")
159
+ var saving: Error = config.save(PRESETS_FILE)
160
+ if saving != OK:
161
+ return _log.failure("Failed to save export_presets.cfg: " + error_string(saving))
162
+
163
+ var templates: String = _templates_dir()
164
+ var installed: bool = _installed(templates, platform)
165
+ var answer: Dictionary = {
166
+ "preset": preset_name,
167
+ "platform": platform,
168
+ "index": index,
169
+ "runnable": runnable,
170
+ "export_path": export_path,
171
+ "file": PRESETS_FILE,
172
+ "templates": templates,
173
+ "templates_installed": installed,
174
+ }
175
+ if not installed:
176
+ answer["templates_note"] = (
177
+ (
178
+ "No %s export templates for this engine are at %s, and an export for %s needs them. The "
179
+ + "editor installs them in Editor > Manage Export Templates, or they can be unpacked "
180
+ + "there from the export templates archive of this engine's release."
181
+ )
182
+ % [platform, templates, platform]
183
+ )
184
+ return answer
185
+
186
+
187
+ ## Where the editor of this engine keeps its export templates: beside the executable for a
188
+ ## self-contained one, otherwise in the user's data directory, as the editor's own paths have it.
189
+ static func _templates_dir() -> String:
190
+ var version: Dictionary = Engine.get_version_info()
191
+ var named: String = "%d.%d" % [Read.as_int(version.get("major", 0)), Read.as_int(version.get("minor", 0))]
192
+ if Read.as_int(version.get("patch", 0)) != 0:
193
+ named += "." + str(Read.as_int(version.get("patch", 0)))
194
+ named += "." + str(version.get("status", ""))
195
+ if ClassDB.class_exists("CSharpScript"):
196
+ named += ".mono"
197
+ var beside: String = OS.get_executable_path().get_base_dir()
198
+ var contained: bool = _marks_self_contained(beside)
199
+ # A macOS bundle is read-only, so its editor looks for the marker beside the bundle too, and keeps
200
+ # its data there whichever of the two places had it.
201
+ if (
202
+ OS.has_feature("macos")
203
+ and beside.ends_with("MacOS")
204
+ and beside.path_join("..").simplify_path().ends_with("Contents")
205
+ ):
206
+ beside = beside.path_join("../../..").simplify_path()
207
+ contained = contained or _marks_self_contained(beside)
208
+ if contained:
209
+ return beside.path_join("editor_data").path_join("export_templates").path_join(named)
210
+ var home: String = (
211
+ "godot" if OS.get_name() in ["Linux", "FreeBSD", "NetBSD", "OpenBSD", "BSD"] else "Godot"
212
+ )
213
+ return OS.get_data_dir().path_join(home).path_join("export_templates").path_join(named)
214
+
215
+
216
+ ## Whether [param templates] holds any of [param platform]'s templates. The directory is one per
217
+ ## engine version and holds whichever platforms were installed into it, so its being there says
218
+ ## nothing about this platform.
219
+ static func _installed(templates: String, platform: String) -> bool:
220
+ if not DirAccess.dir_exists_absolute(templates):
221
+ return false
222
+ for file: String in DirAccess.get_files_at(templates):
223
+ if file.begins_with(PLATFORMS[platform]):
224
+ return true
225
+ return false
226
+
227
+
228
+ static func _marks_self_contained(directory: String) -> bool:
229
+ return (
230
+ FileAccess.file_exists(directory.path_join("._sc_"))
231
+ or FileAccess.file_exists(directory.path_join("_sc_"))
232
+ )
@@ -19,6 +19,30 @@ const Serialisation = preload("serialisation.gd")
19
19
  ## instances another scene and every stored property when [param include_properties] is set, the
20
20
  ## file's value or the default where it sets none. [param depth] -1 reads every level.
21
21
  static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
22
+ var laid: Dictionary = _laid(scene_path)
23
+ if not laid.get("ok", false):
24
+ return laid
25
+ var nodes: Dictionary = laid["nodes"]
26
+ return {"ok": true, "tree": _entry(nodes, ".", include_properties, depth, 0)}
27
+
28
+
29
+ ## The node at [param node_path] in [param scene_path] as {ok, name, type, properties}, laid as
30
+ ## [method scene_tree] lays it, with only the properties the files set; {} when the scene has no
31
+ ## node there, and a refusal when the file does not read as a scene.
32
+ static func scene_node(scene_path: String, node_path: String) -> Dictionary:
33
+ var laid: Dictionary = _laid(scene_path)
34
+ if not laid.get("ok", false):
35
+ return laid
36
+ var nodes: Dictionary = laid["nodes"]
37
+ var path: String = _joined(".", node_path)
38
+ if not nodes.has(path):
39
+ return {}
40
+ var found: Dictionary = nodes[path]
41
+ return {"ok": true, "name": found["name"], "type": found["type"], "properties": found["properties"]}
42
+
43
+
44
+ ## Every node of [param scene_path] as {ok, nodes}, keyed by path, or a refusal.
45
+ static func _laid(scene_path: String) -> Dictionary:
22
46
  if not FileAccess.file_exists(scene_path):
23
47
  return {"ok": false, "error": "Scene not found: " + scene_path}
24
48
  # Loaded without naming the type, so a resource handed in by mistake loads as what it is and the
@@ -42,7 +66,7 @@ static func scene_tree(scene_path: String, depth: int, include_properties: bool)
42
66
  return {"ok": false, "error": refused}
43
67
  if not nodes.has("."):
44
68
  return {"ok": false, "error": "%s holds no nodes" % scene_path}
45
- return {"ok": true, "tree": _entry(nodes, ".", include_properties, depth, 0)}
69
+ return {"ok": true, "nodes": nodes}
46
70
 
47
71
 
48
72
  ## Lays the nodes of [param state] into [param nodes] under [param prefix], keyed by their path
@@ -192,12 +216,22 @@ static func _stored(type: String, given: Dictionary) -> Dictionary:
192
216
  ## What a resource file holds, in the form modify takes, so a value read here can be written back.
193
217
  ## Read from the file rather than a cached copy, since the file is what the writes change and what
194
218
  ## the project loads.
219
+ ##
220
+ ## [param res_path] may name a resource kept inside a file, as "res://hero.tscn::Animation_jump",
221
+ ## the path a property holding one is read back with.
195
222
  static func resource(res_path: String, include_defaults: bool) -> Dictionary:
196
- if not FileAccess.file_exists(res_path):
197
- return {"ok": false, "error": "No resource at " + res_path}
198
- var loaded: Resource = ResourceLoader.load(res_path, "", ResourceLoader.CACHE_MODE_IGNORE)
223
+ var file: String = res_path.get_slice("::", 0)
224
+ if not FileAccess.file_exists(file):
225
+ return {"ok": false, "error": "No resource at " + file}
226
+ var loaded: Resource = ResourceLoader.load(file, "", ResourceLoader.CACHE_MODE_IGNORE)
199
227
  if loaded == null:
200
- return {"ok": false, "error": "%s did not load as a resource." % res_path}
228
+ return {"ok": false, "error": "%s did not load as a resource." % file}
229
+ if file != res_path:
230
+ var id: String = res_path.trim_prefix(file + "::")
231
+ var inner: Resource = _kept_in(loaded, file, id, {})
232
+ if inner == null:
233
+ return {"ok": false, "error": "%s keeps no resource with the id %s." % [file, id]}
234
+ loaded = inner
201
235
  # A scene's own properties are its packed bundle, which says nothing a caller can use.
202
236
  if loaded is PackedScene:
203
237
  return {
@@ -235,3 +269,40 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
235
269
  if script != null:
236
270
  answer["script"] = script.resource_path
237
271
  return answer
272
+
273
+
274
+ ## The resource [param file] keeps under [param id], found in [param value] or anything it holds.
275
+ ## The engine loads such a path only while the file is cached, and a read loads the file uncached,
276
+ ## so it is looked for among what the file holds. A resource of another file is neither taken nor
277
+ ## walked into: ids are unique only within one file, and a scene instancing another holds that
278
+ ## file's resources under ids of the other file's.
279
+ static func _kept_in(value: Variant, file: String, id: String, seen: Dictionary) -> Resource:
280
+ if value is Resource:
281
+ var held: Resource = value
282
+ if seen.has(held.get_instance_id()):
283
+ return null
284
+ seen[held.get_instance_id()] = true
285
+ var at: String = held.resource_path
286
+ if not at.is_empty() and at.get_slice("::", 0) != file:
287
+ return null
288
+ if held.resource_scene_unique_id == id:
289
+ return held
290
+ for p: Dictionary in held.get_property_list():
291
+ if Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
292
+ var found: Resource = _kept_in(held.get(str(p.get("name", ""))), file, id, seen)
293
+ if found != null:
294
+ return found
295
+ elif value is Array:
296
+ var items: Array = value
297
+ for item: Variant in items:
298
+ var found: Resource = _kept_in(item, file, id, seen)
299
+ if found != null:
300
+ return found
301
+ elif value is Dictionary:
302
+ var entries: Dictionary = value
303
+ for key: Variant in entries:
304
+ for side: Variant in [key, entries[key]]:
305
+ var found: Resource = _kept_in(side, file, id, seen)
306
+ if found != null:
307
+ return found
308
+ return null
@@ -14,10 +14,12 @@ extends SceneTree
14
14
  # the operations directory ships inside the server package and is handed to the engine as an
15
15
  # absolute path outside the project. A res:// path would only resolve in the test fixture.
16
16
 
17
+ const AnimationReads = preload("animation_reads.gd")
17
18
  const AudioBuses = preload("audio_buses.gd")
18
19
  const ClassCache = preload("class_cache.gd")
19
20
  const ClassDbQueries = preload("classdb_queries.gd")
20
21
  const Dependencies = preload("dependencies.gd")
22
+ const ExportPresets = preload("export_presets.gd")
21
23
  const FileReads = preload("file_reads.gd")
22
24
  const GdscriptAnalysis = preload("gdscript_analysis.gd")
23
25
  const GdscriptAuthoring = preload("gdscript_authoring.gd")
@@ -153,6 +155,22 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
153
155
  str(params.get("resource_path", "")), Read.as_bool(params.get("include_defaults", false))
154
156
  )
155
157
  )
158
+ "read_animations":
159
+ payload = _read(
160
+ AnimationReads.scene_animations(
161
+ str(params.get("scene_path", "")),
162
+ str(params.get("player_node_path", ".")),
163
+ str(params.get("animation_name", ""))
164
+ )
165
+ )
166
+ "read_animation_states":
167
+ payload = _read(
168
+ AnimationReads.scene_states(
169
+ str(params.get("scene_path", "")),
170
+ str(params.get("anim_tree_path", "")),
171
+ str(params.get("state_machine_path", ""))
172
+ )
173
+ )
156
174
 
157
175
  # Import and export pipeline
158
176
  "get_import_status":
@@ -162,7 +180,9 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
162
180
  "set_import_options":
163
181
  payload = ImportPipeline.new(_log).set_import_options(params)
164
182
  "list_export_presets":
165
- payload = ImportPipeline.new(_log).list_export_presets(params)
183
+ payload = ExportPresets.new(_log).list_export_presets(params)
184
+ "add_export_preset":
185
+ payload = ExportPresets.new(_log).add_export_preset(params)
166
186
  "validate_project":
167
187
  payload = ImportPipeline.new(_log).validate_project(params)
168
188
 
@@ -1,5 +1,6 @@
1
1
  extends RefCounted
2
2
 
3
+ const ExportPresets = preload("export_presets.gd")
3
4
  const Read = preload("reading.gd")
4
5
  const Serialisation = preload("serialisation.gd")
5
6
  const FileWalk = preload("file_walk.gd")
@@ -319,63 +320,6 @@ static func _all_ints(held: Variant, keys: bool) -> bool:
319
320
  return true
320
321
 
321
322
 
322
- # The names of the project's export presets, in the order export_presets.cfg holds them.
323
- static func _preset_names() -> Array[String]:
324
- var names: Array[String] = []
325
- var config: ConfigFile = ConfigFile.new()
326
- if config.load("res://export_presets.cfg") != OK:
327
- return names
328
- var index: int = 0
329
- while config.has_section("preset." + str(index)):
330
- names.append(str(config.get_value("preset." + str(index), "name", "")))
331
- index += 1
332
- return names
333
-
334
-
335
- # List export presets
336
- func list_export_presets(_params: Dictionary) -> Dictionary:
337
- _log.info("Listing export presets")
338
-
339
- var presets_file: String = "res://export_presets.cfg"
340
-
341
- if not FileAccess.file_exists(presets_file):
342
- return {
343
- "presets": [],
344
- "presets_file_exists": false,
345
- "note": "No export_presets.cfg found. Configure export presets in the Godot editor."
346
- }
347
-
348
- var config: ConfigFile = ConfigFile.new()
349
- var err: Error = config.load(presets_file)
350
-
351
- if err != OK:
352
- return _log.failure("Failed to parse export_presets.cfg: " + str(err))
353
-
354
- # Export presets are stored as [preset.0], [preset.1], etc.
355
- var presets: Array[Dictionary] = []
356
- var preset_idx: int = 0
357
- while config.has_section("preset." + str(preset_idx)):
358
- var section: String = "preset." + str(preset_idx)
359
- var preset: Dictionary = {
360
- "index": preset_idx,
361
- "name": config.get_value(section, "name", "Unknown"),
362
- "platform": config.get_value(section, "platform", "Unknown"),
363
- "runnable": config.get_value(section, "runnable", false),
364
- "export_path": config.get_value(section, "export_path", ""),
365
- "export_filter": config.get_value(section, "export_filter", "all_resources"),
366
- "include_filter": config.get_value(section, "include_filter", ""),
367
- "exclude_filter": config.get_value(section, "exclude_filter", "")
368
- }
369
-
370
- if config.has_section_key(section, "custom_features"):
371
- preset["custom_features"] = config.get_value(section, "custom_features", "")
372
-
373
- presets.append(preset)
374
- preset_idx += 1
375
-
376
- return {"presets": presets, "presets_file_exists": true, "total_presets": preset_idx}
377
-
378
-
379
323
  # Validate project for export
380
324
  func validate_project(params: Dictionary) -> Dictionary:
381
325
  var preset_name: String = str(params.get("preset", ""))
@@ -429,7 +373,7 @@ func validate_project(params: Dictionary) -> Dictionary:
429
373
  "warning",
430
374
  "export_presets",
431
375
  "No export presets configured",
432
- "Configure export presets in Godot editor: Project > Export",
376
+ "Add a preset with project_export add_preset, or in the editor: Project > Export",
433
377
  include_suggestions
434
378
  )
435
379
  )
@@ -438,7 +382,7 @@ func validate_project(params: Dictionary) -> Dictionary:
438
382
  # does not exist answered valid, as though it had been checked.
439
383
  if not preset_name.is_empty():
440
384
  checks_performed.append("export_preset")
441
- var named: Array[String] = _preset_names()
385
+ var named: Array[String] = ExportPresets.names()
442
386
  if preset_name not in named:
443
387
  issues.append(
444
388
  _finding(
@@ -453,7 +397,7 @@ func validate_project(params: Dictionary) -> Dictionary:
453
397
  else ": the presets are " + ", ".join(named)
454
398
  )
455
399
  ),
456
- "Pass one of the preset names, or add the preset in the editor: Project > Export",
400
+ "Pass one of the preset names, or add the preset with project_export add_preset",
457
401
  include_suggestions
458
402
  )
459
403
  )
@@ -170,19 +170,63 @@ func add_input_action(params: Dictionary) -> Dictionary:
170
170
  if action.is_empty():
171
171
  return {}
172
172
 
173
+ # An action that exists is rebound, which is how one of the engine's ui_ actions is changed, so
174
+ # what it held is answered rather than lost without a word.
175
+ var setting: String = "input/" + action_name
176
+ var answer: Dictionary = {
177
+ "action_name": action_name,
178
+ "events_count": events_config.size(),
179
+ "deadzone": deadzone,
180
+ "events": events_config,
181
+ "was_new": not ProjectSettings.has_setting(setting),
182
+ }
183
+ if not answer["was_new"]:
184
+ var held: Variant = ProjectSettings.get_setting(setting)
185
+ var old: Dictionary = held if held is Dictionary else {}
186
+ var old_events: Array[Dictionary] = []
187
+ var listed: Variant = old.get("events", [])
188
+ if listed is Array:
189
+ var events_held: Array = listed
190
+ for event: Variant in events_held:
191
+ if event is InputEvent:
192
+ var bound: InputEvent = event
193
+ old_events.append(_shaped(bound))
194
+ answer["old_events"] = old_events
195
+ answer["old_deadzone"] = Read.as_float(old.get("deadzone", 0.5), 0.5)
196
+
173
197
  # Through ProjectSettings so the file is saved the way the editor saves it, header and
174
198
  # every other line kept; a ConfigFile of project.godot drops the comments on the way out.
175
- ProjectSettings.set_setting("input/" + action_name, action)
199
+ ProjectSettings.set_setting(setting, action)
176
200
  var err: Error = ProjectFile.save()
177
201
  if err != OK:
178
202
  return _log.failure("Failed to save project.godot: " + error_string(err))
179
203
 
180
- return {
181
- "action_name": action_name,
182
- "events_count": events_config.size(),
183
- "deadzone": deadzone,
184
- "events": events_config
185
- }
204
+ return answer
205
+
206
+
207
+ ## [param event] in the form add_input_action answers its events with, and as the engine names it
208
+ ## under text, which also covers a kind of event that form has no fields for.
209
+ static func _shaped(event: InputEvent) -> Dictionary:
210
+ var shaped: Dictionary = {"class_name": event.get_class(), "text": event.as_text()}
211
+ if event is InputEventKey:
212
+ var key: InputEventKey = event
213
+ shaped["keycode"] = key.keycode
214
+ if key.physical_keycode != KEY_NONE:
215
+ shaped["physical_keycode"] = key.physical_keycode
216
+ for modifier: String in ["ctrl_pressed", "alt_pressed", "shift_pressed"]:
217
+ if key.get(modifier):
218
+ shaped[modifier] = true
219
+ elif event is InputEventMouseButton:
220
+ var mouse: InputEventMouseButton = event
221
+ shaped["button_index"] = mouse.button_index
222
+ elif event is InputEventJoypadButton:
223
+ var pad: InputEventJoypadButton = event
224
+ shaped["button_index"] = pad.button_index
225
+ elif event is InputEventJoypadMotion:
226
+ var motion: InputEventJoypadMotion = event
227
+ shaped["axis"] = motion.axis
228
+ shaped["axis_value"] = motion.axis_value
229
+ return shaped
186
230
 
187
231
 
188
232
  # Remove an input action the project defines, which add_input_action had no way back from.
@@ -148,7 +148,9 @@ func get_project_health(params: Dictionary) -> Dictionary:
148
148
  recommendations.append("Address critical issues to improve project stability")
149
149
  # A project still being made has no presets yet, which is no fault in it.
150
150
  if "config" in check_categories and not FileAccess.file_exists("res://export_presets.cfg"):
151
- recommendations.append("No export presets are configured: add them before exporting")
151
+ recommendations.append(
152
+ "No export presets are configured: project_export add_preset adds one before exporting"
153
+ )
152
154
 
153
155
  # Every detail costs points, so each is an issue; `issues` was always empty beside them, and a
154
156
  # category said passed next to the failure it had just found.