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.
- package/LICENSE +22 -0
- package/README.md +125 -0
- package/build/cli.js +30353 -0
- package/build/godot/addons/auto_reload/auto_reload.gd +89 -0
- package/build/godot/addons/auto_reload/plugin.cfg +6 -0
- package/build/godot/addons/gdharness_editor/bridge_client.gd +197 -0
- package/build/godot/addons/gdharness_editor/plugin.cfg +6 -0
- package/build/godot/addons/gdharness_editor/plugin.gd +88 -0
- package/build/godot/addons/gdharness_editor/tool_executor.gd +104 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +397 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +85 -0
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +427 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +885 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +292 -0
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +77 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +254 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +283 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +169 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +119 -0
- package/build/godot/operations/audio_buses.gd +125 -0
- package/build/godot/operations/class_cache.gd +180 -0
- package/build/godot/operations/classdb_queries.gd +252 -0
- package/build/godot/operations/dependencies.gd +293 -0
- package/build/godot/operations/file_walk.gd +55 -0
- package/build/godot/operations/gdscript_analysis.gd +288 -0
- package/build/godot/operations/gdscript_authoring.gd +419 -0
- package/build/godot/operations/godot_operations.gd +186 -0
- package/build/godot/operations/import_pipeline.gd +390 -0
- package/build/godot/operations/input_actions.gd +237 -0
- package/build/godot/operations/logger.gd +33 -0
- package/build/godot/operations/plugins.gd +159 -0
- package/build/godot/operations/project_config.gd +153 -0
- package/build/godot/operations/project_diagnostics.gd +159 -0
- package/build/godot/operations/resource_files.gd +132 -0
- package/build/godot/operations/serialisation.gd +154 -0
- package/build/index.js +29252 -0
- 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 {}
|