gdharness 0.4.1

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 (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +125 -0
  3. package/build/cli.js +30353 -0
  4. package/build/godot/addons/auto_reload/auto_reload.gd +89 -0
  5. package/build/godot/addons/auto_reload/plugin.cfg +6 -0
  6. package/build/godot/addons/gdharness_editor/bridge_client.gd +197 -0
  7. package/build/godot/addons/gdharness_editor/plugin.cfg +6 -0
  8. package/build/godot/addons/gdharness_editor/plugin.gd +88 -0
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +104 -0
  10. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +397 -0
  11. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +85 -0
  12. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +427 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +885 -0
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +292 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +77 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_input.gd +254 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +283 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_values.gd +169 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +119 -0
  20. package/build/godot/operations/audio_buses.gd +125 -0
  21. package/build/godot/operations/class_cache.gd +180 -0
  22. package/build/godot/operations/classdb_queries.gd +252 -0
  23. package/build/godot/operations/dependencies.gd +293 -0
  24. package/build/godot/operations/file_walk.gd +55 -0
  25. package/build/godot/operations/gdscript_analysis.gd +288 -0
  26. package/build/godot/operations/gdscript_authoring.gd +419 -0
  27. package/build/godot/operations/godot_operations.gd +186 -0
  28. package/build/godot/operations/import_pipeline.gd +390 -0
  29. package/build/godot/operations/input_actions.gd +237 -0
  30. package/build/godot/operations/logger.gd +33 -0
  31. package/build/godot/operations/plugins.gd +159 -0
  32. package/build/godot/operations/project_config.gd +153 -0
  33. package/build/godot/operations/project_diagnostics.gd +159 -0
  34. package/build/godot/operations/resource_files.gd +132 -0
  35. package/build/godot/operations/serialisation.gd +154 -0
  36. package/build/index.js +29252 -0
  37. package/package.json +57 -0
@@ -0,0 +1,390 @@
1
+ extends RefCounted
2
+
3
+ const FileWalk = preload("file_walk.gd")
4
+ const Log = preload("logger.gd")
5
+
6
+ const IMPORTABLE_EXTENSIONS: Array[String] = [
7
+ "png", "jpg", "jpeg", "webp", "svg", "wav", "mp3", "ogg", "ttf", "otf", "glb", "gltf", "fbx", "obj"
8
+ ]
9
+
10
+ var _log: Log
11
+ var _files: FileWalk = FileWalk.new()
12
+
13
+
14
+ func _init(p_log: Log) -> void:
15
+ _log = p_log
16
+
17
+
18
+ # Get import status for resources
19
+ func get_import_status(params: Dictionary) -> Dictionary:
20
+ var resource_path: String = str(params.get("resource_path", ""))
21
+ var include_up_to_date: bool = bool(params.get("include_up_to_date", false))
22
+
23
+ _log.info(
24
+ (
25
+ "Getting import status"
26
+ + (" for: " + resource_path if not resource_path.is_empty() else " for all resources")
27
+ )
28
+ )
29
+
30
+ var resources: Array[Dictionary] = []
31
+ var summary: Dictionary = {"total": 0, "needs_reimport": 0, "up_to_date": 0, "missing_source": 0}
32
+
33
+ if not resource_path.is_empty():
34
+ var full_path: String = resource_path
35
+ if not full_path.begins_with("res://"):
36
+ full_path = "res://" + full_path
37
+
38
+ var status: Dictionary = _import_status_of(full_path, full_path + ".import")
39
+ resources.append(status)
40
+ _tally(summary, status)
41
+ else:
42
+ for res_path: String in _files.find_files_with_extensions("res://", IMPORTABLE_EXTENSIONS):
43
+ var status: Dictionary = _import_status_of(res_path, res_path + ".import")
44
+
45
+ if include_up_to_date or status["status"] != "up_to_date":
46
+ resources.append(status)
47
+
48
+ _tally(summary, status)
49
+
50
+ return {"resources": resources, "summary": summary}
51
+
52
+
53
+ # Get import options for a resource
54
+ func get_import_options(params: Dictionary) -> Dictionary:
55
+ var resource_path: String = str(params.get("resource_path", ""))
56
+ if not resource_path.begins_with("res://"):
57
+ resource_path = "res://" + resource_path
58
+
59
+ _log.info("Getting import options for: " + resource_path)
60
+
61
+ var import_file_path: String = resource_path + ".import"
62
+
63
+ if not FileAccess.file_exists(import_file_path):
64
+ _log.error("Import file does not exist: " + import_file_path)
65
+ return _log.failure("This resource may not have been imported yet")
66
+
67
+ var config: ConfigFile = ConfigFile.new()
68
+ var err: Error = config.load(import_file_path)
69
+
70
+ if err != OK:
71
+ return _log.failure("Failed to parse import file: " + str(err))
72
+
73
+ var result: Dictionary = {
74
+ "resource_path": resource_path, "import_file": import_file_path, "remap": {}, "deps": {}, "params": {}
75
+ }
76
+
77
+ for section: String in ["remap", "deps", "params"]:
78
+ if config.has_section(section):
79
+ var values: Dictionary = result[section]
80
+ for key: String in config.get_section_keys(section):
81
+ values[key] = config.get_value(section, key)
82
+
83
+ return result
84
+
85
+
86
+ # Set import options for a resource
87
+ func set_import_options(params: Dictionary) -> Dictionary:
88
+ var resource_path: String = str(params.get("resource_path", ""))
89
+ if not resource_path.begins_with("res://"):
90
+ resource_path = "res://" + resource_path
91
+
92
+ var options: Dictionary = params.get("options", {})
93
+ var do_reimport: bool = bool(params.get("reimport", true))
94
+
95
+ _log.info("Setting import options for: " + resource_path)
96
+
97
+ var import_file_path: String = resource_path + ".import"
98
+
99
+ if not FileAccess.file_exists(import_file_path):
100
+ _log.error("Import file does not exist: " + import_file_path)
101
+ return _log.failure("This resource may not have been imported yet")
102
+
103
+ var config: ConfigFile = ConfigFile.new()
104
+ var err: Error = config.load(import_file_path)
105
+
106
+ if err != OK:
107
+ return _log.failure("Failed to parse import file: " + str(err))
108
+
109
+ var updated_keys: Array[String] = []
110
+ for key: Variant in options:
111
+ var name: String = str(key)
112
+ config.set_value("params", name, options[key])
113
+ updated_keys.append(name)
114
+ _log.debug("Set " + name + " = " + str(options[key]))
115
+
116
+ err = config.save(import_file_path)
117
+ if err != OK:
118
+ return _log.failure("Failed to save import file: " + str(err))
119
+
120
+ var result: Dictionary = {
121
+ "resource_path": resource_path, "updated_options": updated_keys, "reimport_triggered": do_reimport
122
+ }
123
+
124
+ # A headless engine has no importer to run, so the .import file is as far as this goes.
125
+ if do_reimport:
126
+ result["note"] = "Import file updated. Run the editor or use 'reimport_resource' to apply changes."
127
+
128
+ return result
129
+
130
+
131
+ # Reimport a resource or all resources
132
+ func reimport_resource(params: Dictionary) -> Dictionary:
133
+ var resource_path: String = str(params.get("resource_path", ""))
134
+ var force: bool = bool(params.get("force", false))
135
+
136
+ _log.info(
137
+ (
138
+ "Reimporting"
139
+ + (" resource: " + resource_path if not resource_path.is_empty() else " all modified resources")
140
+ )
141
+ )
142
+
143
+ # A full reimport is editor work; headless can only report what the state is.
144
+ var result: Dictionary = {
145
+ "status": "requested",
146
+ "resource_path": resource_path if not resource_path.is_empty() else "all",
147
+ "force": force,
148
+ "note": "Reimport in headless mode is limited. For full reimport, open the project in the editor."
149
+ }
150
+
151
+ if not resource_path.is_empty():
152
+ var full_path: String = resource_path
153
+ if not full_path.begins_with("res://"):
154
+ full_path = "res://" + full_path
155
+
156
+ if not FileAccess.file_exists(full_path):
157
+ return _log.failure("Resource file does not exist: " + full_path)
158
+
159
+ result["current_status"] = _import_status_of(full_path, full_path + ".import")["status"]
160
+
161
+ return result
162
+
163
+
164
+ # List export presets
165
+ func list_export_presets(_params: Dictionary) -> Dictionary:
166
+ _log.info("Listing export presets")
167
+
168
+ var presets_file: String = "res://export_presets.cfg"
169
+
170
+ if not FileAccess.file_exists(presets_file):
171
+ return {
172
+ "presets": [],
173
+ "presets_file_exists": false,
174
+ "note": "No export_presets.cfg found. Configure export presets in the Godot editor."
175
+ }
176
+
177
+ var config: ConfigFile = ConfigFile.new()
178
+ var err: Error = config.load(presets_file)
179
+
180
+ if err != OK:
181
+ return _log.failure("Failed to parse export_presets.cfg: " + str(err))
182
+
183
+ # Export presets are stored as [preset.0], [preset.1], etc.
184
+ var presets: Array[Dictionary] = []
185
+ var preset_idx: int = 0
186
+ while config.has_section("preset." + str(preset_idx)):
187
+ var section: String = "preset." + str(preset_idx)
188
+ var preset: Dictionary = {
189
+ "index": preset_idx,
190
+ "name": config.get_value(section, "name", "Unknown"),
191
+ "platform": config.get_value(section, "platform", "Unknown"),
192
+ "runnable": config.get_value(section, "runnable", false),
193
+ "export_path": config.get_value(section, "export_path", ""),
194
+ "export_filter": config.get_value(section, "export_filter", "all_resources"),
195
+ "include_filter": config.get_value(section, "include_filter", ""),
196
+ "exclude_filter": config.get_value(section, "exclude_filter", "")
197
+ }
198
+
199
+ if config.has_section_key(section, "custom_features"):
200
+ preset["custom_features"] = config.get_value(section, "custom_features", "")
201
+
202
+ presets.append(preset)
203
+ preset_idx += 1
204
+
205
+ return {"presets": presets, "presets_file_exists": true, "total_presets": preset_idx}
206
+
207
+
208
+ # Validate project for export
209
+ func validate_project(params: Dictionary) -> Dictionary:
210
+ var preset_name: String = str(params.get("preset", ""))
211
+ var include_suggestions: bool = bool(params.get("include_suggestions", true))
212
+
213
+ _log.info("Validating project" + (" for preset: " + preset_name if not preset_name.is_empty() else ""))
214
+
215
+ var issues: Array[Dictionary] = []
216
+ var warnings: Array[Dictionary] = []
217
+ var checks_performed: Array[String] = []
218
+
219
+ checks_performed.append("project_file")
220
+ if not FileAccess.file_exists("res://project.godot"):
221
+ issues.append(
222
+ _finding(
223
+ "error",
224
+ "project_file",
225
+ "project.godot not found",
226
+ "Ensure you are running this from a valid Godot project directory",
227
+ include_suggestions
228
+ )
229
+ )
230
+
231
+ checks_performed.append("main_scene")
232
+ var main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
233
+ if main_scene.is_empty():
234
+ issues.append(
235
+ _finding(
236
+ "error",
237
+ "main_scene",
238
+ "No main scene set",
239
+ "Set a main scene in Project Settings > Application > Run > Main Scene",
240
+ include_suggestions
241
+ )
242
+ )
243
+ elif not FileAccess.file_exists(main_scene):
244
+ issues.append(
245
+ _finding(
246
+ "error",
247
+ "main_scene",
248
+ "Main scene file does not exist: " + main_scene,
249
+ "Update the main scene setting or create the missing scene file",
250
+ include_suggestions
251
+ )
252
+ )
253
+
254
+ checks_performed.append("export_presets")
255
+ if not FileAccess.file_exists("res://export_presets.cfg"):
256
+ warnings.append(
257
+ _finding(
258
+ "warning",
259
+ "export_presets",
260
+ "No export presets configured",
261
+ "Configure export presets in Godot editor: Project > Export",
262
+ include_suggestions
263
+ )
264
+ )
265
+
266
+ checks_performed.append("icon")
267
+ var icon_path: String = str(ProjectSettings.get_setting("application/config/icon", ""))
268
+ if icon_path.is_empty():
269
+ warnings.append(
270
+ _finding(
271
+ "warning",
272
+ "icon",
273
+ "No application icon set",
274
+ "Set an icon in Project Settings > Application > Config > Icon",
275
+ include_suggestions
276
+ )
277
+ )
278
+ elif not FileAccess.file_exists(icon_path):
279
+ warnings.append(
280
+ _finding(
281
+ "warning",
282
+ "icon",
283
+ "Icon file does not exist: " + icon_path,
284
+ "Update the icon path or add the missing icon file",
285
+ include_suggestions
286
+ )
287
+ )
288
+
289
+ checks_performed.append("project_name")
290
+ var project_name: String = str(ProjectSettings.get_setting("application/config/name", ""))
291
+ if project_name.is_empty():
292
+ warnings.append(
293
+ _finding(
294
+ "warning",
295
+ "project_name",
296
+ "No project name set",
297
+ "Set a project name in Project Settings > Application > Config > Name",
298
+ include_suggestions
299
+ )
300
+ )
301
+
302
+ checks_performed.append("scripts")
303
+ var script_files: Array[String] = _files.find_files_with_extensions("res://", ["gd"])
304
+ var scripts_checked: int = 0
305
+ var script_issues: Array[Dictionary] = []
306
+
307
+ # A hundred scripts is enough to say whether the project is tidy without a large one
308
+ # turning validation into a full read of its source tree.
309
+ for script_path: String in script_files:
310
+ scripts_checked += 1
311
+ if scripts_checked > 100:
312
+ break
313
+
314
+ var file: FileAccess = FileAccess.open(script_path, FileAccess.READ)
315
+ if file:
316
+ var content: String = file.get_as_text()
317
+ file.close()
318
+
319
+ if "# TODO" in content or "# FIXME" in content:
320
+ script_issues.append({"path": script_path, "issue": "Contains TODO/FIXME comments"})
321
+ if "pass # TODO" in content:
322
+ script_issues.append({"path": script_path, "issue": "Contains unimplemented functions"})
323
+
324
+ if script_issues.size() > 0:
325
+ var warning: Dictionary = _finding(
326
+ "warning",
327
+ "scripts",
328
+ str(script_issues.size()) + " script issues found",
329
+ "Review and resolve TODO/FIXME items before release",
330
+ include_suggestions
331
+ )
332
+ warning["details"] = script_issues.slice(0, 5)
333
+ warnings.append(warning)
334
+
335
+ return {
336
+ "valid": issues.is_empty(),
337
+ "issues": issues,
338
+ "warnings": warnings,
339
+ "checks_performed": checks_performed,
340
+ "scripts_checked": scripts_checked,
341
+ "issue_count": issues.size(),
342
+ "warning_count": warnings.size()
343
+ }
344
+
345
+
346
+ func _finding(
347
+ type: String, check: String, message: String, suggestion: String, include_suggestion: bool
348
+ ) -> Dictionary:
349
+ var finding: Dictionary = {"type": type, "check": check, "message": message}
350
+ if include_suggestion:
351
+ finding["suggestion"] = suggestion
352
+ return finding
353
+
354
+
355
+ func _tally(summary: Dictionary, status: Dictionary) -> void:
356
+ summary["total"] += 1
357
+ var state: String = status["status"]
358
+ if summary.has(state):
359
+ summary[state] += 1
360
+
361
+
362
+ # Whether a resource is imported, out of date, or has lost its source file.
363
+ func _import_status_of(resource_path: String, import_file_path: String) -> Dictionary:
364
+ var source_exists: bool = FileAccess.file_exists(resource_path)
365
+ if not source_exists:
366
+ return {
367
+ "path": resource_path,
368
+ "status": "missing_source",
369
+ "import_file_exists": false,
370
+ "source_exists": false
371
+ }
372
+
373
+ var import_file_exists: bool = FileAccess.file_exists(import_file_path)
374
+ if not import_file_exists:
375
+ return {
376
+ "path": resource_path,
377
+ "status": "needs_reimport",
378
+ "import_file_exists": false,
379
+ "source_exists": true
380
+ }
381
+
382
+ var source_modified: int = FileAccess.get_modified_time(resource_path)
383
+ var import_modified: int = FileAccess.get_modified_time(import_file_path)
384
+
385
+ return {
386
+ "path": resource_path,
387
+ "status": "needs_reimport" if source_modified > import_modified else "up_to_date",
388
+ "import_file_exists": true,
389
+ "source_exists": true
390
+ }
@@ -0,0 +1,237 @@
1
+ extends RefCounted
2
+
3
+ const Log = preload("logger.gd")
4
+
5
+ # The key names a caller may write, and what the engine calls them.
6
+ const KEY_NAMES: Dictionary = {
7
+ "A": KEY_A,
8
+ "B": KEY_B,
9
+ "C": KEY_C,
10
+ "D": KEY_D,
11
+ "E": KEY_E,
12
+ "F": KEY_F,
13
+ "G": KEY_G,
14
+ "H": KEY_H,
15
+ "I": KEY_I,
16
+ "J": KEY_J,
17
+ "K": KEY_K,
18
+ "L": KEY_L,
19
+ "M": KEY_M,
20
+ "N": KEY_N,
21
+ "O": KEY_O,
22
+ "P": KEY_P,
23
+ "Q": KEY_Q,
24
+ "R": KEY_R,
25
+ "S": KEY_S,
26
+ "T": KEY_T,
27
+ "U": KEY_U,
28
+ "V": KEY_V,
29
+ "W": KEY_W,
30
+ "X": KEY_X,
31
+ "Y": KEY_Y,
32
+ "Z": KEY_Z,
33
+ "0": KEY_0,
34
+ "1": KEY_1,
35
+ "2": KEY_2,
36
+ "3": KEY_3,
37
+ "4": KEY_4,
38
+ "5": KEY_5,
39
+ "6": KEY_6,
40
+ "7": KEY_7,
41
+ "8": KEY_8,
42
+ "9": KEY_9,
43
+ "F1": KEY_F1,
44
+ "F2": KEY_F2,
45
+ "F3": KEY_F3,
46
+ "F4": KEY_F4,
47
+ "F5": KEY_F5,
48
+ "F6": KEY_F6,
49
+ "F7": KEY_F7,
50
+ "F8": KEY_F8,
51
+ "F9": KEY_F9,
52
+ "F10": KEY_F10,
53
+ "F11": KEY_F11,
54
+ "F12": KEY_F12,
55
+ "Space": KEY_SPACE,
56
+ "Escape": KEY_ESCAPE,
57
+ "Tab": KEY_TAB,
58
+ "Enter": KEY_ENTER,
59
+ "Return": KEY_ENTER,
60
+ "Backspace": KEY_BACKSPACE,
61
+ "Delete": KEY_DELETE,
62
+ "Up": KEY_UP,
63
+ "Down": KEY_DOWN,
64
+ "Left": KEY_LEFT,
65
+ "Right": KEY_RIGHT,
66
+ "Home": KEY_HOME,
67
+ "End": KEY_END,
68
+ "PageUp": KEY_PAGEUP,
69
+ "PageDown": KEY_PAGEDOWN,
70
+ "Insert": KEY_INSERT,
71
+ "Shift": KEY_SHIFT,
72
+ "Ctrl": KEY_CTRL,
73
+ "Alt": KEY_ALT,
74
+ "CapsLock": KEY_CAPSLOCK,
75
+ "NumLock": KEY_NUMLOCK,
76
+ "KP0": KEY_KP_0,
77
+ "KP1": KEY_KP_1,
78
+ "KP2": KEY_KP_2,
79
+ "KP3": KEY_KP_3,
80
+ "KP4": KEY_KP_4,
81
+ "KP5": KEY_KP_5,
82
+ "KP6": KEY_KP_6,
83
+ "KP7": KEY_KP_7,
84
+ "KP8": KEY_KP_8,
85
+ "KP9": KEY_KP_9,
86
+ "Comma": KEY_COMMA,
87
+ "Period": KEY_PERIOD,
88
+ "Slash": KEY_SLASH,
89
+ "Backslash": KEY_BACKSLASH,
90
+ "Semicolon": KEY_SEMICOLON,
91
+ "Apostrophe": KEY_APOSTROPHE,
92
+ "BracketLeft": KEY_BRACKETLEFT,
93
+ "BracketRight": KEY_BRACKETRIGHT,
94
+ "Minus": KEY_MINUS,
95
+ "Equal": KEY_EQUAL,
96
+ }
97
+
98
+ var _log: Log
99
+
100
+
101
+ func _init(p_log: Log) -> void:
102
+ _log = p_log
103
+
104
+
105
+ # Add an input action to the InputMap
106
+ func add_input_action(params: Dictionary) -> Dictionary:
107
+ var action_name: String = str(params.get("action_name", ""))
108
+ var events: Array = params.get("events", [])
109
+ var deadzone: float = float(params.get("deadzone", 0.5))
110
+
111
+ _log.info("Adding input action: " + action_name)
112
+ _log.debug("Events: " + JSON.stringify(events))
113
+ _log.debug("Deadzone: " + str(deadzone))
114
+
115
+ # One bad event fails the whole action: an action written with the events that happened to
116
+ # parse is a binding the caller did not ask for, reported as the one they did.
117
+ var events_config: Array[Dictionary] = []
118
+ for event: Variant in events:
119
+ if not event is Dictionary:
120
+ return _log.failure("Every event must be an object, not " + JSON.stringify(event))
121
+ var fields: Dictionary = event
122
+ var event_type: String = str(fields.get("type", ""))
123
+
124
+ match event_type:
125
+ "key":
126
+ var keycode: String = str(fields.get("keycode", ""))
127
+ if keycode.is_empty():
128
+ return _log.failure("A key event needs keycode")
129
+ var key_value: int = _keycode_value(keycode)
130
+ if key_value == KEY_NONE:
131
+ return _log.failure("Unknown key: " + keycode)
132
+
133
+ var event_config: Dictionary = {"class_name": "InputEventKey", "keycode": key_value}
134
+ if fields.get("ctrl", false):
135
+ event_config["ctrl_pressed"] = true
136
+ if fields.get("alt", false):
137
+ event_config["alt_pressed"] = true
138
+ if fields.get("shift", false):
139
+ event_config["shift_pressed"] = true
140
+ events_config.append(event_config)
141
+
142
+ "mouse_button":
143
+ events_config.append(
144
+ {"class_name": "InputEventMouseButton", "button_index": fields.get("button", 1)}
145
+ )
146
+
147
+ "joypad_button":
148
+ events_config.append(
149
+ {"class_name": "InputEventJoypadButton", "button_index": fields.get("button", 0)}
150
+ )
151
+
152
+ "joypad_axis":
153
+ var motion: Dictionary = {
154
+ "class_name": "InputEventJoypadMotion",
155
+ "axis": fields.get("axis", 0),
156
+ "axis_value": fields.get("axisValue", 1),
157
+ }
158
+ events_config.append(motion)
159
+
160
+ _:
161
+ var known: String = "key, mouse_button, joypad_button, joypad_axis"
162
+ return _log.failure("Unknown event type: " + event_type + ". One of " + known + ".")
163
+
164
+ if events_config.is_empty():
165
+ return _log.failure("events must hold at least one event")
166
+
167
+ var action: Dictionary = build_input_action(deadzone, events_config)
168
+ if action.is_empty():
169
+ return {}
170
+
171
+ # Through ProjectSettings so the file is saved the way the editor saves it, header and
172
+ # every other line kept; a ConfigFile of project.godot drops the comments on the way out.
173
+ ProjectSettings.set_setting("input/" + action_name, action)
174
+ var err: Error = ProjectSettings.save()
175
+ if err != OK:
176
+ return _log.failure("Failed to save project.godot: " + error_string(err))
177
+
178
+ return {
179
+ "action_name": action_name,
180
+ "events_count": events_config.size(),
181
+ "deadzone": deadzone,
182
+ "events": events_config
183
+ }
184
+
185
+
186
+ # The value project.godot stores for one input action.
187
+ #
188
+ # It has to be a Dictionary holding real InputEvent objects. The engine writes those as the
189
+ # unquoted expression it parses back into an action; hand it the same thing as assembled
190
+ # text and it writes a quoted, escaped string, which loads as a String and leaves InputMap
191
+ # with no action at all while add_input_action still reports the events it was given.
192
+ func build_input_action(deadzone: float, events: Array) -> Dictionary:
193
+ # Untyped on purpose: the editor writes this list untyped, and a typed one would be
194
+ # serialised with an Array[InputEvent] prefix the editor's own project.godot never carries.
195
+ var built: Array = []
196
+
197
+ for event: Dictionary in events:
198
+ var evt_class: String = str(event.get("class_name", ""))
199
+
200
+ match evt_class:
201
+ "InputEventKey":
202
+ var key: InputEventKey = InputEventKey.new()
203
+ key.keycode = int(event.get("keycode", 0)) as Key
204
+ key.ctrl_pressed = bool(event.get("ctrl_pressed", false))
205
+ key.alt_pressed = bool(event.get("alt_pressed", false))
206
+ key.shift_pressed = bool(event.get("shift_pressed", false))
207
+ built.append(key)
208
+
209
+ "InputEventMouseButton":
210
+ var mouse: InputEventMouseButton = InputEventMouseButton.new()
211
+ mouse.button_index = (int(event.get("button_index", MOUSE_BUTTON_LEFT)) as MouseButton)
212
+ built.append(mouse)
213
+
214
+ "InputEventJoypadButton":
215
+ var pad: InputEventJoypadButton = InputEventJoypadButton.new()
216
+ pad.button_index = int(event.get("button_index", 0)) as JoyButton
217
+ built.append(pad)
218
+
219
+ "InputEventJoypadMotion":
220
+ var motion: InputEventJoypadMotion = InputEventJoypadMotion.new()
221
+ motion.axis = int(event.get("axis", 0)) as JoyAxis
222
+ motion.axis_value = float(event.get("axis_value", 1.0))
223
+ built.append(motion)
224
+
225
+ _:
226
+ return _log.failure("Unknown input event class: " + evt_class)
227
+
228
+ return {"deadzone": deadzone, "events": built}
229
+
230
+
231
+ # The keycode for a key name as the tool spells it, or KEY_NONE for a name the table lacks.
232
+ func _keycode_value(key_name: String) -> int:
233
+ var wanted: String = key_name.to_lower()
234
+ for name: String in KEY_NAMES:
235
+ if name.to_lower() == wanted:
236
+ return KEY_NAMES[name]
237
+ return KEY_NONE
@@ -0,0 +1,33 @@
1
+ extends RefCounted
2
+
3
+ # The server reads one JSON object off stdout and everything else as diagnostics, so the
4
+ # prefixes are what let a human tell the two apart in a log without parsing it.
5
+ #
6
+ # Every module preloads this as `Log`. `Logger` is a native class from Godot 4.5 on, and a
7
+ # constant of that name is a parse error rather than a shadowing warning.
8
+
9
+ var debug_mode: bool = false
10
+
11
+
12
+ func _init(p_debug_mode: bool = false) -> void:
13
+ debug_mode = p_debug_mode
14
+
15
+
16
+ func debug(message: String) -> void:
17
+ if debug_mode:
18
+ print("[DEBUG] " + message)
19
+
20
+
21
+ func info(message: String) -> void:
22
+ print("[INFO] " + message)
23
+
24
+
25
+ func error(message: String) -> void:
26
+ printerr("[ERROR] " + message)
27
+
28
+
29
+ # The empty result an operation hands back when it cannot answer. Every payload an operation
30
+ # builds has at least one key, so emptiness is unambiguous and the reason is already on stderr.
31
+ func failure(message: String) -> Dictionary:
32
+ error(message)
33
+ return {}