gdharness 1.3.9 → 1.3.11

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 (73) hide show
  1. package/build/cli.js +4740 -3212
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +102 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +51 -1
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +77 -140
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +11 -22
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +28 -5
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +39 -20
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -362
  18. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  21. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +147 -492
  24. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  25. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -3
  26. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  27. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +79 -5
  28. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  29. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +183 -37
  30. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  31. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  32. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  33. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  34. package/build/godot/addons/gdharness_runtime/reading.gd +51 -1
  35. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  36. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +185 -30
  37. package/build/godot/addons/gdharness_runtime/runtime_input.gd +86 -79
  38. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  39. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  40. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +309 -22
  41. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +163 -20
  42. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  43. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +5 -2
  44. package/build/godot/addons/gdharness_runtime/runtime_values.gd +146 -5
  45. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  46. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  47. package/build/godot/addons/gdharness_runtime/serialisation.gd +11 -22
  48. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  49. package/build/godot/operations/animation_reads.gd +115 -4
  50. package/build/godot/operations/audio_buses.gd +93 -7
  51. package/build/godot/operations/classdb_queries.gd +119 -7
  52. package/build/godot/operations/declared_values.gd +272 -0
  53. package/build/godot/operations/dependencies.gd +154 -41
  54. package/build/godot/operations/editor_tools.gd +22 -14
  55. package/build/godot/operations/export_presets.gd +602 -4
  56. package/build/godot/operations/file_reads.gd +102 -18
  57. package/build/godot/operations/gdscript_authoring.gd +11 -1
  58. package/build/godot/operations/godot_operations.gd +16 -2
  59. package/build/godot/operations/import_pipeline.gd +20 -221
  60. package/build/godot/operations/input_actions.gd +108 -47
  61. package/build/godot/operations/project_config.gd +83 -69
  62. package/build/godot/operations/project_diagnostics.gd +4 -0
  63. package/build/godot/operations/project_validation.gd +266 -0
  64. package/build/godot/operations/property_paths.gd +89 -16
  65. package/build/godot/operations/property_values.gd +180 -121
  66. package/build/godot/operations/reading.gd +51 -1
  67. package/build/godot/operations/scene_connections.gd +222 -0
  68. package/build/godot/operations/serialisation.gd +11 -22
  69. package/build/godot/operations/setting_reads.gd +121 -0
  70. package/build/godot/operations/value_shapes.gd +128 -1
  71. package/build/godot/tests/listed.gd +73 -13
  72. package/build/keeper.js +1371 -706
  73. package/package.json +1 -1
@@ -1,14 +1,16 @@
1
1
  @tool
2
2
  extends Node
3
3
 
4
- ## Resource files, shaders, tilesets and themes, written through the open editor.
4
+ ## Resource files, shaders and themes, written through the open editor.
5
5
 
6
6
  const GdhFileReads = preload("../file_reads.gd")
7
7
  const GdhHeadlessCheck = preload("../headless_check.gd")
8
+ const GdhPropertyPaths = preload("../property_paths.gd")
8
9
  const GdhPropertyValues = preload("../property_values.gd")
9
10
  const GdhRead = preload("../reading.gd")
10
11
  const GdhSame = preload("../same_values.gd")
11
12
  const GdhSceneFile = preload("../scene_file.gd")
13
+ const GdhSceneUids = preload("../scene_uids.gd")
12
14
  const GdhSerialisation = preload("../serialisation.gd")
13
15
 
14
16
  # The minimal shader of a type: its declaration and the one function the engine calls for it.
@@ -21,9 +23,6 @@ const SHADER_ENTRY: Dictionary[String, String] = {
21
23
  "canvas_item": "fragment", "spatial": "fragment", "particles": "process", "sky": "sky", "fog": "fog"
22
24
  }
23
25
 
24
- ## How many cells a read of a tile map lists, enough for a level's worth and not a whole world's.
25
- const CELLS_SHOWN: int = 1000
26
-
27
26
  var _editor_plugin: EditorPlugin = null
28
27
  var _properties: GdhPropertyValues = GdhPropertyValues.new()
29
28
  var _values: GdhSerialisation = GdhSerialisation.new()
@@ -76,33 +75,6 @@ func _parse_properties_dict(raw: Variant) -> Dictionary:
76
75
  return {}
77
76
 
78
77
 
79
- ## A pair of whole numbers given as {"x": 1, "y": 2} or [1, 2], as [true, Vector2i] or
80
- ## [false, why]. The server checks the arguments it is given but not what is inside them, and a
81
- ## pair given the other way went into a typed local and failed as "Invalid tool result".
82
- func _xy(value: Variant, label: String) -> Array:
83
- var x: Variant = null
84
- var y: Variant = null
85
- if value is Dictionary:
86
- var fields: Dictionary = value
87
- x = fields.get("x")
88
- y = fields.get("y")
89
- elif value is Array:
90
- var pair: Array = value
91
- if pair.size() == 2:
92
- x = pair[0]
93
- y = pair[1]
94
- for number: Variant in [x, y]:
95
- if (
96
- not (number is float or number is int)
97
- or GdhRead.as_float(number) != floorf(GdhRead.as_float(number))
98
- ):
99
- return [
100
- false,
101
- '%s must be two whole numbers, {"x": .., "y": ..}, not %s.' % [label, JSON.stringify(value)]
102
- ]
103
- return [true, Vector2i(GdhRead.as_int(x), GdhRead.as_int(y))]
104
-
105
-
106
78
  ## The theme on disk at this path, or null when there is not one.
107
79
  ##
108
80
  ## Read past the cache, because the caller saves what it gets back and the cached copy is the
@@ -125,10 +97,13 @@ func _load_theme(theme_path: String) -> Theme:
125
97
  ## Read back because a property can be set and still not be saved: one without storage, a
126
98
  ## script variable that is not exported. Both were answered as changed.
127
99
  func _save_checked(resource: Resource, path: String, names: Array, previous: Variant) -> String:
128
- var saving: Error = GdhSceneFile.save_keeping_uid(resource, path)
100
+ var carried: Dictionary = GdhHeadlessCheck.embedded_in(GdhHeadlessCheck.stored(resource).values(), {})
101
+ var saving: Error = GdhSceneUids.save_keeping_uid(resource, path)
129
102
  if saving != OK:
130
103
  return "Failed to save %s: %s" % [path, error_string(saving)]
131
- var unkept: String = _unkept_headless(resource, path, names, previous)
104
+ var unkept: String = _unkept_headless(
105
+ resource, path, names + _properties.moved_by_engine.keys(), previous, carried
106
+ )
132
107
  if not unkept.is_empty():
133
108
  return "Nothing was written: " + unkept + GdhHeadlessCheck.put_back(path, previous)
134
109
  var loaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
@@ -156,8 +131,11 @@ func _save_checked(resource: Resource, path: String, names: Array, previous: Var
156
131
 
157
132
  ## Outside the editor, puts back in the file just saved at [param path] what a script that is not a
158
133
  ## tool changed and the call did not ask for, [param names] being what it asked for, and answers ""
159
- ## or why that did not hold. See [GdhHeadlessCheck].
160
- func _unkept_headless(resource: Resource, path: String, names: Array, previous: Variant) -> String:
134
+ ## or why that did not hold. [param carried] is what each embedded id named before the save. See
135
+ ## [GdhHeadlessCheck].
136
+ func _unkept_headless(
137
+ resource: Resource, path: String, names: Array, previous: Variant, carried: Dictionary
138
+ ) -> String:
161
139
  if Engine.is_editor_hint():
162
140
  return ""
163
141
  var script: Script = resource.get_script()
@@ -193,14 +171,14 @@ func _unkept_headless(resource: Resource, path: String, names: Array, previous:
193
171
  resource if scripted else null,
194
172
  asked,
195
173
  reached,
196
- func() -> Error: return GdhSceneFile.save_keeping_uid(resource, path)
174
+ carried,
175
+ func() -> Error: return GdhSceneUids.save_keeping_uid(resource, path)
197
176
  )
198
177
 
199
178
 
200
179
  func create_resource(args: Dictionary) -> Dictionary:
201
180
  var res_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
202
181
  var resource_type: String = str(args.get("resourceType", "Resource"))
203
- var asked_type: String = resource_type
204
182
  if res_path == "res://":
205
183
  return {"ok": false, "error": "resourcePath is required"}
206
184
  var taken: String = _taken(res_path, "resource_edit op=create")
@@ -249,12 +227,21 @@ func create_resource(args: Dictionary) -> Dictionary:
249
227
  var properties: Dictionary = _parse_properties_dict(args.get("properties", {}))
250
228
  var refused: String = _properties.write_all(resource, properties)
251
229
  if not refused.is_empty():
252
- return {"ok": false, "error": refused}
230
+ return {"ok": false, "error": GdhHeadlessCheck.setter_ran(resource, _properties.unheld, refused)}
253
231
 
254
232
  var folder: String = GdhSceneFile.make_folder_for(res_path)
255
233
  if not folder.is_empty():
256
234
  return {"ok": false, "error": folder}
257
- var answer: Dictionary = {"ok": true, "resourcePath": res_path, "resourceType": asked_type}
235
+ # As a read names it, the engine class under resourceType and the script's class beside it, since
236
+ # a create given resourceType Resource and a script answered nothing of the class it had made, and
237
+ # one given a class_name answered it where a read of the same file answers Resource.
238
+ var answer: Dictionary = {"ok": true, "resourcePath": res_path, "resourceType": resource_type}
239
+ var scripted: Variant = resource.get_script()
240
+ if scripted is Script:
241
+ var attached: Script = scripted
242
+ answer["script"] = attached.resource_path
243
+ if not attached.get_global_name().is_empty():
244
+ answer["scriptClass"] = str(attached.get_global_name())
258
245
  _properties.note_write(answer)
259
246
  var failed: String = _save_checked(resource, res_path, properties.keys(), null)
260
247
  if not failed.is_empty():
@@ -269,7 +256,7 @@ func modify_resource(args: Dictionary) -> Dictionary:
269
256
  if res_path == "res://":
270
257
  return {"ok": false, "error": "resourcePath is required"}
271
258
  if not FileAccess.file_exists(res_path):
272
- return {"ok": false, "error": "No resource at " + res_path}
259
+ return {"ok": false, "error": "No resource at %s; op=create makes one there." % res_path}
273
260
  # The editor's copy of an imported file is rebuilt from the source on every import, and a save
274
261
  # to it fails; edited through the cache, the editor kept the changed copy after the refusal.
275
262
  if FileAccess.file_exists(res_path + ".import"):
@@ -303,6 +290,10 @@ func modify_resource(args: Dictionary) -> Dictionary:
303
290
 
304
291
  var properties: Dictionary = _parse_properties_dict(args.get("properties", ""))
305
292
  var refused: String = _properties.write_all(resource, properties)
293
+ if refused.is_empty():
294
+ refused = _holding_itself(resource, res_path, properties.keys())
295
+ else:
296
+ refused = GdhHeadlessCheck.setter_ran(resource, _properties.unheld, refused)
306
297
  if not refused.is_empty():
307
298
  return {"ok": false, "error": refused}
308
299
  var answer: Dictionary = {"ok": true, "resourcePath": res_path}
@@ -315,6 +306,25 @@ func modify_resource(args: Dictionary) -> Dictionary:
315
306
  return answer
316
307
 
317
308
 
309
+ ## Why [param resource], saved at [param path], cannot hold what [param names] were given, or "": a
310
+ ## resource cannot hold the file it is saved as. Godot saves the reference as null with only a
311
+ ## warning in its log, and the read back after the save reported the property as one the file does
312
+ ## not keep.
313
+ func _holding_itself(resource: Resource, path: String, names: Array) -> String:
314
+ for key: Variant in names:
315
+ var property: String = str(key)
316
+ for held: Resource in GdhHeadlessCheck.resources_in(_properties.read_at(resource, property)):
317
+ if held == resource or held.resource_path == path:
318
+ return (
319
+ (
320
+ "%s.%s would hold %s itself, and a resource cannot hold the file it is saved as: "
321
+ + "Godot saves the reference as null. Nothing was written."
322
+ )
323
+ % [GdhPropertyPaths.class_called(resource), property, path]
324
+ )
325
+ return ""
326
+
327
+
318
328
  ## What [param resource] holds at each property or path in [param asked], once the save has been
319
329
  ## checked against it: a write saying only that it was written left the caller a second call to
320
330
  ## learn what the engine kept.
@@ -366,460 +376,60 @@ func create_shader(args: Dictionary) -> Dictionary:
366
376
  return {"ok": false, "error": "Failed to write the shader file " + shader_path}
367
377
 
368
378
  _written(shader_path)
369
- return {"ok": true, "shaderPath": shader_path, "shaderType": shader_type}
370
-
371
-
372
- func create_tileset(args: Dictionary) -> Dictionary:
373
- var tileset_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
374
- if tileset_path == "res://":
375
- return {"ok": false, "error": "resourcePath is required"}
376
- # modify sets properties and builds no atlas, so the other path is the only way on offered.
377
- var taken: String = (
378
- ""
379
- if not FileAccess.file_exists(tileset_path)
380
- else (
381
- (
382
- "%s already exists, and create_tileset builds a whole new TileSet, which would replace "
383
- + "everything in it, the tiles a scene paints with among them. Give resourcePath a path "
384
- + "nothing is at."
385
- )
386
- % tileset_path
387
- )
388
- )
389
- if not taken.is_empty():
390
- return {"ok": false, "error": taken}
391
-
392
- var given: Variant = args.get("sources", [])
393
- if not given is Array:
394
- return {"ok": false, "error": "sources must list at least one atlas"}
395
- var sources: Array = given
396
- if sources.is_empty():
397
- return {"ok": false, "error": "sources must list at least one atlas"}
398
-
399
- var tileset: TileSet = TileSet.new()
400
- var made: Array = []
401
- for index: int in sources.size():
402
- var built: Array = _atlas(sources[index], index)
403
- if not built[0]:
404
- return {"ok": false, "error": built[1]}
405
- var atlas: TileSetAtlasSource = built[1]
406
- # The first source sets the tile set's own tile size, which is what a TileMapLayer draws
407
- # cells at; left at its default of 16 it did not match the atlas.
408
- if index == 0:
409
- tileset.tile_size = atlas.texture_region_size
410
- var added: int = tileset.add_source(atlas)
411
- if added < 0:
412
- return {
413
- "ok": false,
414
- "error": "The TileSet did not take the atlas for %s" % atlas.texture.resource_path
415
- }
416
- var entry: Dictionary = {
417
- "sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()
418
- }
419
- var blank: Array = built[2]
420
- if not blank.is_empty():
421
- entry["leftBlank"] = blank
422
- var spare: String = built[3]
423
- if not spare.is_empty():
424
- entry["notInAnyTile"] = spare
425
- made.append(entry)
426
-
427
- var folder: String = GdhSceneFile.make_folder_for(tileset_path)
428
- if not folder.is_empty():
429
- return {"ok": false, "error": folder}
430
- var saving: Error = GdhSceneFile.save_keeping_uid(tileset, tileset_path)
431
- if saving != OK:
432
- return {"ok": false, "error": "Failed to save %s: %s" % [tileset_path, error_string(saving)]}
433
-
434
- _written(tileset_path)
435
- return {"ok": true, "tilesetPath": tileset_path, "sources": made}
436
-
437
-
438
- ## One atlas source from its description, with its tiles made, as [true, source, the grid cells left
439
- ## without a tile for being wholly transparent, a sentence on the pixels no tile covers or ""] or
440
- ## [false, why].
441
- ##
442
- ## Tiles are made in every cell of the grid that has a pixel that is not transparent, which is
443
- ## what the editor offers to do when an atlas gets a texture. Without them the atlas held no tiles,
444
- ## and every cell painted from it drew nothing. The cells passed over and the strip a tile size that
445
- ## does not divide the picture leaves are said, since a count alone left a caller to work out which.
446
- func _atlas(entry: Variant, index: int) -> Array:
447
- if not entry is Dictionary:
448
- return [false, "sources[%d] must be an object with texture and tileSize" % index]
449
- var source: Dictionary = entry
450
- var texture_path: String = str(source.get("texture", ""))
451
- if not texture_path.begins_with("res://") and not texture_path.begins_with("uid://"):
452
- texture_path = _ensure_res_path(texture_path)
453
- # Contained here as well as on the server: a relative path climbing out of the project became
454
- # res://../../ and was loaded.
455
- if texture_path.split("/").has(".."):
456
- return [false, "sources[%d].texture leaves the project: %s" % [index, texture_path]]
457
- if not ResourceLoader.exists(texture_path):
458
- # An image the engine has not imported loads as nothing, where "no texture" would send a caller
459
- # looking for a file that is there.
460
- if FileAccess.file_exists(texture_path) and not FileAccess.file_exists(texture_path + ".import"):
461
- return [
462
- false,
463
- (
464
- (
465
- "sources[%d].texture: %s has not been imported, so the engine has no texture for it "
466
- + "yet. project_import reimport with resourcePath %s imports it."
467
- )
468
- % [index, texture_path, texture_path]
469
- )
470
- ]
471
- return [false, "sources[%d].texture: no texture at %s" % [index, texture_path]]
472
- var texture: Texture2D = load(texture_path) as Texture2D
473
- if texture == null:
474
- return [false, "sources[%d].texture: %s is not a texture" % [index, texture_path]]
475
-
476
- var sized: Array = _xy(source.get("tileSize"), "sources[%d].tileSize" % index)
477
- if not sized[0]:
478
- return sized
479
- var tile_size: Vector2i = sized[1]
480
- if tile_size.x <= 0 or tile_size.y <= 0:
481
- return [false, "sources[%d].tileSize must be above zero on both sides" % index]
482
-
483
- var atlas: TileSetAtlasSource = TileSetAtlasSource.new()
484
- atlas.texture = texture
485
- atlas.texture_region_size = tile_size
486
- for field: String in ["separation", "offset"]:
487
- if not source.has(field):
488
- continue
489
- var pair: Array = _xy(source[field], "sources[%d].%s" % [index, field])
490
- if not pair[0]:
491
- return pair
492
- if field == "separation":
493
- atlas.separation = pair[1]
494
- else:
495
- atlas.margins = pair[1]
496
-
497
- var grid: Vector2i = atlas.get_atlas_grid_size()
498
- if grid.x <= 0 or grid.y <= 0:
499
- return [
500
- false,
501
- (
502
- "sources[%d]: a %s tile does not fit in %s, which is %s"
503
- % [index, tile_size, texture_path, texture.get_size()]
504
- )
505
- ]
506
- var image: Image = texture.get_image()
507
- var blank: Array = []
508
- for y: int in grid.y:
509
- for x: int in grid.x:
510
- var cell: Vector2i = Vector2i(x, y)
511
- # Worked out here: the atlas answers a region only for a tile it already has.
512
- var region: Rect2i = Rect2i(
513
- atlas.margins + cell * (atlas.texture_region_size + atlas.separation),
514
- atlas.texture_region_size
515
- )
516
- if image != null and _blank(image, region):
517
- blank.append({"x": x, "y": y})
518
- continue
519
- atlas.create_tile(cell)
520
- var covered: Vector2i = atlas.margins + grid * tile_size + (grid - Vector2i.ONE) * atlas.separation
521
- var left: Vector2i = Vector2i(texture.get_size()) - covered
522
- var sides: PackedStringArray = []
523
- if left.x > 0:
524
- var _right: bool = sides.append("%d px on the right" % left.x)
525
- if left.y > 0:
526
- var _bottom: bool = sides.append("%d px at the bottom" % left.y)
527
- var spare: String = (
528
- ""
529
- if sides.is_empty()
530
- else (
531
- "%s is %d by %d, so %s %s in no %d by %d tile."
532
- % [
533
- texture_path,
534
- texture.get_width(),
535
- texture.get_height(),
536
- " and ".join(sides),
537
- "is" if sides.size() == 1 else "are",
538
- tile_size.x,
539
- tile_size.y
540
- ]
541
- )
542
- )
543
- return [true, atlas, blank, spare]
544
-
545
-
546
- func _blank(image: Image, region: Rect2i) -> bool:
547
- if image.is_compressed():
548
- return false
549
- return image.get_region(region).is_invisible()
550
-
551
-
552
- ## The sources a tile set holds, as "source 0", "sources 0, 2" or "no sources", for saying what a
553
- ## cell could have named instead.
554
- func _sources_of(tile_set: TileSet) -> String:
555
- var ids: PackedStringArray = []
556
- for index: int in tile_set.get_source_count():
557
- var _listed: bool = ids.append(str(tile_set.get_source_id(index)))
558
- if ids.is_empty():
559
- return "no sources"
560
- return ("source " if ids.size() == 1 else "sources ") + ", ".join(ids)
561
-
562
-
563
- func set_tilemap_cells(args: Dictionary) -> Dictionary:
564
- var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
565
- var node_path: String = str(args.get("nodePath", ""))
566
- if scene_path == "res://":
567
- return {"ok": false, "error": "scenePath is required"}
568
-
569
- var cells: Variant = args.get("cells", [])
570
- # Refused rather than skipped: placing nothing and answering success is indistinguishable
571
- # from placing everything, and the caller only finds out by opening the scene.
572
- if typeof(cells) != TYPE_ARRAY:
573
- return {"ok": false, "error": "cells must be an array of cells to place"}
574
- var placed: Array = cells
575
- if placed.is_empty():
576
- return {"ok": false, "error": "cells is empty, so there is nothing to place"}
577
-
578
- var scene: GdhSceneFile = GdhSceneFile.new()
579
- var refused: Dictionary = scene.open(scene_path, true)
580
- if not refused.is_empty():
581
- return refused
582
-
583
- var layer: int = GdhRead.as_int(args.get("layer", 0))
584
- var found: Array = _tile_node(scene, node_path, layer)
585
- if found[0] == null:
586
- return scene.refuse(str(found[1]))
587
- var node: Node = found[0]
588
- var layered: TileMap = node as TileMap
589
- var single: TileMapLayer = node as TileMapLayer
590
-
591
- # set_cell stores a cell whatever it is given, and a cell naming no tile simply draws nothing, so
592
- # the source, the tile and its alternative are checked here: the alternative is a call that
593
- # reports placing tiles and a scene that shows none.
594
- var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
595
-
596
- scene.asked(node, ["layer_%d/tile_data" % layer if layered != null else "tile_map_data"])
597
- # Every cell is checked before any is placed, and every one refused is named: stopping at the first
598
- # left a caller fixing one cell a call.
599
- var checked: Array = []
600
- var refusals: PackedStringArray = []
601
- for index: int in placed.size():
602
- var cell: Array = _cell(placed[index], index, tile_set)
603
- if not cell[0]:
604
- var _named: bool = refusals.append(str(cell[1]))
605
- checked.append(cell)
606
- if not refusals.is_empty():
607
- return scene.refuse("%s. Nothing was placed." % "; ".join(refusals))
608
- var wanted: Array = []
609
- var erased: int = 0
610
- for cell: Array in checked:
611
- var at: Vector2i = cell[1]
612
- var source_id: int = cell[2]
613
- var atlas_coords: Vector2i = cell[3]
614
- var alternative: int = cell[4]
615
- if layered != null:
616
- layered.set_cell(layer, at, source_id, atlas_coords, alternative)
617
- else:
618
- single.set_cell(at, source_id, atlas_coords, alternative)
619
- wanted.append([at, source_id, atlas_coords, alternative])
620
- if source_id == -1:
621
- erased += 1
622
-
623
- var at_path: String = scene.path_of(node)
624
- var written: Dictionary = scene.write(
625
- func(saved: Node) -> String:
626
- var copy: Node = saved.get_node_or_null(at_path)
627
- for entry: Array in wanted:
628
- var at: Vector2i = entry[0]
629
- var held: Array = _cell_at(copy, layer, at)
630
- if held != entry.slice(1):
631
- return "the cell at %s would load as %s, not %s." % [at, held, entry.slice(1)]
632
- return ""
633
- )
634
- if not written.is_empty():
635
- return written
636
-
637
- return {
638
- "ok": true,
639
- "layer": layer,
640
- "placed": wanted.size() - erased,
641
- "erased": erased,
642
- "requested": placed.size()
643
- }
644
-
645
-
646
- ## The node at [param node_path] cells are painted on, with a TileSet, as [node, why not].
647
- ## TileMapLayer is the node since 4.3, and TileMap the one it replaced, which keeps its layers.
648
- func _tile_node(scene: GdhSceneFile, node_path: String, layer: int) -> Array:
649
- var node: Node = (
650
- scene.root if node_path == "." or node_path == "" else scene.root.get_node_or_null(node_path)
651
- )
652
- if node == null:
653
- return [null, "No node at %s in %s" % [node_path, scene.path]]
654
- var layered: TileMap = node as TileMap
655
- var single: TileMapLayer = node as TileMapLayer
656
- if layered == null and single == null:
657
- return [null, "%s is a %s, not a TileMapLayer or a TileMap" % [node_path, node.get_class()]]
658
- var foreign: String = scene.foreign(node)
659
- if not foreign.is_empty():
660
- return [null, foreign]
661
- if single != null and layer != 0:
662
- return [null, "%s is a TileMapLayer, which is one layer; layer applies to a TileMap" % node_path]
663
- if layered != null and (layer < 0 or layer >= layered.get_layers_count()):
664
- return [null, "%s has %d layers, and no layer %d" % [node_path, layered.get_layers_count(), layer]]
665
- var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
666
- if tile_set == null:
667
- return [
668
- null,
669
- (
670
- (
671
- '%s has no TileSet, so no cell can name a source. scene_node set with properties {"tile_set": '
672
- + '"res://..."} on %s gives it one, a TileSet file such as resource_edit create_tileset makes.'
673
- )
674
- % [node_path, node_path]
675
- )
676
- ]
677
- return [node, ""]
678
-
679
-
680
- ## One cell from its description, checked against [param tile_set], as
681
- ## [true, coords, source id, atlas coords, alternative] or [false, why]. A source of -1 erases the
682
- ## cell, as set_cell takes it, and needs no atlasCoords.
683
- func _cell(entry: Variant, index: int, tile_set: TileSet) -> Array:
684
- if not entry is Dictionary:
685
- return [false, "cells[%d] must be an object with coords, sourceId and atlasCoords" % index]
686
- var cell: Dictionary = entry
687
- for field: String in ["coords", "sourceId"]:
688
- if not cell.has(field):
689
- return [false, "cells[%d] has no %s" % [index, field]]
690
- var at: Array = _xy(cell["coords"], "cells[%d].coords" % index)
691
- if not at[0]:
692
- return at
693
- var coords: Vector2i = at[1]
694
- var named: String = "cells[%d] at (%d, %d)" % [index, coords.x, coords.y]
695
- var source_id: int = GdhRead.as_int(cell["sourceId"], -2)
696
- if source_id == -1:
697
- return [true, coords, -1, Vector2i(-1, -1), -1]
698
- if not cell.has("atlasCoords"):
699
- return [
700
- false, "%s has no atlasCoords, which every cell but an erased one (sourceId -1) needs" % named
701
- ]
702
- var atlas_at: Array = _xy(cell["atlasCoords"], "cells[%d].atlasCoords" % index)
703
- if not atlas_at[0]:
704
- return atlas_at
705
- if not tile_set.has_source(source_id):
706
- return [
707
- false,
708
- (
709
- "%s names source %d, and the TileSet has %s; sourceId -1 erases a cell"
710
- % [named, source_id, _sources_of(tile_set)]
711
- )
712
- ]
713
- var source: TileSetSource = tile_set.get_source(source_id)
714
- var atlas_coords: Vector2i = atlas_at[1]
715
- var alternative: int = GdhRead.as_int(cell.get("alternativeTile", 0))
716
- if not source.has_tile(atlas_coords):
717
- return [
718
- false,
719
- "%s: source %d has no tile at %s; it has %s" % [named, source_id, atlas_coords, _tiles_of(source)]
720
- ]
721
- if not source.has_alternative_tile(atlas_coords, alternative):
722
- return [
723
- false,
724
- (
725
- "%s: the tile at %s in source %d has no alternative %d"
726
- % [named, atlas_coords, source_id, alternative]
727
- )
728
- ]
729
- return [true, coords, source_id, atlas_coords, alternative]
730
-
731
-
732
- ## The atlas coordinates [param source] has tiles at, the first twelve and how many more, or that it
733
- ## has none: a refusal naming the one missing sent a caller guessing which of the others exist.
734
- static func _tiles_of(source: TileSetSource) -> String:
735
- var count: int = source.get_tiles_count()
736
- if count == 0:
737
- return "no tiles"
738
- var named: Array[String] = []
739
- for index: int in mini(count, 12):
740
- named.append(str(source.get_tile_id(index)))
741
- var more: String = "" if count <= 12 else " and %d more" % (count - 12)
742
- return "tiles at " + ", ".join(PackedStringArray(named)) + more
743
-
744
-
745
- ## The cells a TileMapLayer or a TileMap holds, as {"cellCount", "cells"} in the form
746
- ## set_tilemap_cells takes, or {} for any other node. The scene keeps them as packed bytes, which a
747
- ## read answered with and nobody could read: a probe counted three cells by decoding the base64 by
748
- ## hand. At most CELLS_SHOWN are listed, the count saying how many there are.
749
- static func cells_of(node: Node) -> Dictionary:
750
- var listed: Array[Dictionary] = []
751
- var count: int = 0
752
- var single: TileMapLayer = node as TileMapLayer
753
- var layered: TileMap = node as TileMap
754
- if single != null:
755
- var used: Array[Vector2i] = single.get_used_cells()
756
- count = used.size()
757
- for at: Vector2i in used.slice(0, CELLS_SHOWN):
758
- listed.append(
759
- _cell_entry(
760
- at,
761
- single.get_cell_source_id(at),
762
- single.get_cell_atlas_coords(at),
763
- single.get_cell_alternative_tile(at)
764
- )
765
- )
766
- elif layered != null:
767
- for layer: int in layered.get_layers_count():
768
- var used: Array[Vector2i] = layered.get_used_cells(layer)
769
- count += used.size()
770
- for at: Vector2i in used:
771
- if listed.size() >= CELLS_SHOWN:
772
- break
773
- var entry: Dictionary = _cell_entry(
774
- at,
775
- layered.get_cell_source_id(layer, at),
776
- layered.get_cell_atlas_coords(layer, at),
777
- layered.get_cell_alternative_tile(layer, at)
778
- )
779
- entry["layer"] = layer
780
- listed.append(entry)
781
- else:
782
- return {}
783
- var answer: Dictionary = {"cellCount": count, "cells": listed}
784
- if count > listed.size():
785
- answer["cellsNote"] = "The first %d of %d cells are listed." % [listed.size(), count]
379
+ var answer: Dictionary = {"ok": true, "shaderPath": shader_path, "shaderType": shader_type}
380
+ var errors: Array[Dictionary] = _shader_errors(shader_path)
381
+ answer["compiles"] = errors.is_empty()
382
+ if not errors.is_empty():
383
+ answer["compileErrors"] = errors
786
384
  return answer
787
385
 
788
386
 
789
- static func _cell_entry(at: Vector2i, source_id: int, atlas: Vector2i, alternative: int) -> Dictionary:
790
- return {
791
- "coords": {"x": at.x, "y": at.y},
792
- "sourceId": source_id,
793
- "atlasCoords": {"x": atlas.x, "y": atlas.y},
794
- "alternativeTile": alternative,
795
- }
796
-
797
-
798
- func _cell_at(node: Node, layer: int, at: Vector2i) -> Array:
799
- var layered: TileMap = node as TileMap
800
- if layered != null:
801
- return [
802
- layered.get_cell_source_id(layer, at),
803
- layered.get_cell_atlas_coords(layer, at),
804
- layered.get_cell_alternative_tile(layer, at)
805
- ]
806
- var single: TileMapLayer = node as TileMapLayer
807
- if single != null:
808
- return [
809
- single.get_cell_source_id(at),
810
- single.get_cell_atlas_coords(at),
811
- single.get_cell_alternative_tile(at)
812
- ]
813
- return []
814
-
815
-
816
- ## Saves [param theme] over [param theme_path] with [param item] changed, answering "" or why not.
817
- func _save_theme(theme: Theme, theme_path: String, item: String) -> String:
387
+ ## The errors the engine raises compiling the shader at [param path], as {line, message} in order.
388
+ ## A shader is compiled once something reads what it declares, and a create answering only the path
389
+ ## left a missing semicolon to show up wherever the shader was first drawn with.
390
+ func _shader_errors(path: String) -> Array[Dictionary]:
391
+ var caught: GdhShaderErrors = GdhShaderErrors.new()
392
+ OS.add_logger(caught)
393
+ var shader: Shader = ResourceLoader.load(path, "Shader", ResourceLoader.CACHE_MODE_IGNORE) as Shader
394
+ if shader != null:
395
+ var _uniforms: Array = shader.get_shader_uniform_list()
396
+ OS.remove_logger(caught)
397
+ return caught.raised
398
+
399
+
400
+ ## Every shader error the engine raises while it is attached, in order.
401
+ class GdhShaderErrors:
402
+ extends Logger
403
+
404
+ var raised: Array[Dictionary] = []
405
+
406
+ func _log_message(_message: String, _error: bool) -> void:
407
+ pass
408
+
409
+ # The engine's own signature for a Logger, eight parameters and not this project's to shape.
410
+ # gdlint:ignore = function-arguments-number
411
+ func _log_error(
412
+ _function: String,
413
+ _file: String,
414
+ line: int,
415
+ code: String,
416
+ rationale: String,
417
+ _editor_notify: bool,
418
+ error_type: int,
419
+ _script_backtraces: Array[ScriptBacktrace],
420
+ ) -> void:
421
+ if error_type == Logger.ERROR_TYPE_SHADER:
422
+ raised.append({"line": line, "message": code if rationale.is_empty() else rationale})
423
+
424
+
425
+ ## Saves [param theme] over [param theme_path] with [param items] changed, answering "" or why not.
426
+ func _save_theme(theme: Theme, theme_path: String, items: Array) -> String:
818
427
  var previous: PackedByteArray = FileAccess.get_file_as_bytes(theme_path)
819
- var saving: Error = GdhSceneFile.save_keeping_uid(theme, theme_path)
428
+ var carried: Dictionary = GdhHeadlessCheck.embedded_in(GdhHeadlessCheck.stored(theme).values(), {})
429
+ var saving: Error = GdhSceneUids.save_keeping_uid(theme, theme_path)
820
430
  if saving != OK:
821
431
  return "Failed to save %s: %s" % [theme_path, error_string(saving)]
822
- var unkept: String = _unkept_headless(theme, theme_path, [item], previous)
432
+ var unkept: String = _unkept_headless(theme, theme_path, items, previous, carried)
823
433
  if not unkept.is_empty():
824
434
  return "Nothing was written: " + unkept + GdhHeadlessCheck.put_back(theme_path, previous)
825
435
  _written(theme_path)
@@ -848,7 +458,7 @@ func set_theme_color(args: Dictionary) -> Dictionary:
848
458
  var color: Color = parsed[1]
849
459
  theme.set_color(color_name, control_type, color)
850
460
 
851
- var unsaved: String = _save_theme(theme, theme_path, "%s/colors/%s" % [control_type, color_name])
461
+ var unsaved: String = _save_theme(theme, theme_path, ["%s/colors/%s" % [control_type, color_name]])
852
462
  if not unsaved.is_empty():
853
463
  return {"ok": false, "error": unsaved}
854
464
 
@@ -901,7 +511,9 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
901
511
  }
902
512
  theme.set_font_size(font_size_name, control_type, int(pixels))
903
513
 
904
- var unsaved: String = _save_theme(theme, theme_path, "%s/font_sizes/%s" % [control_type, font_size_name])
514
+ var unsaved: String = _save_theme(
515
+ theme, theme_path, ["%s/font_sizes/%s" % [control_type, font_size_name]]
516
+ )
905
517
  if not unsaved.is_empty():
906
518
  return {"ok": false, "error": unsaved}
907
519
 
@@ -920,6 +532,49 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
920
532
  )
921
533
 
922
534
 
535
+ ## Takes one item, such as "colors/font_color", or with none every item of a theme type, out of a
536
+ ## Theme: a type is any name, so a misspelt one is written as given, and this is the way back.
537
+ func remove_theme_item(args: Dictionary) -> Dictionary:
538
+ var theme_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
539
+ var control_type: String = str(args.get("controlType", ""))
540
+ var theme: Theme = _load_theme(theme_path)
541
+ if theme == null:
542
+ return {"ok": false, "error": "No Theme at %s." % theme_path}
543
+ if not theme.get_type_list().has(control_type):
544
+ var types: PackedStringArray = theme.get_type_list()
545
+ var said: String = "%s holds no type %s" % [theme_path, control_type]
546
+ return {"ok": false, "error": GdhRead.offered(said, control_type, Array(types))}
547
+ var kinds: Array[String] = ["colors", "constants", "fonts", "font_sizes", "icons", "styles"]
548
+ var item: String = str(args.get("item", ""))
549
+ var removed: Array[String] = []
550
+ var held: Array[String] = []
551
+ for kind: int in kinds.size():
552
+ for called: String in theme.get_theme_item_list(kind as Theme.DataType, control_type):
553
+ var entry: String = "%s/%s" % [kinds[kind], called]
554
+ held.append(entry)
555
+ if item.is_empty() or item == entry:
556
+ removed.append("%s/%s" % [control_type, entry])
557
+ theme.clear_theme_item(kind as Theme.DataType, called, control_type)
558
+ var base: String = theme.get_type_variation_base(control_type)
559
+ if item.is_empty():
560
+ # A variation's base is stored apart from its items and would outlive them in the file.
561
+ if not base.is_empty():
562
+ removed.append("%s/base_type" % control_type)
563
+ theme.remove_type(control_type)
564
+ elif removed.is_empty() and held.is_empty():
565
+ var only: String = (
566
+ "%s holds no items, only the type it varies, %s; "
567
+ + "without item, remove_theme_item takes it out."
568
+ )
569
+ return {"ok": false, "error": only % [control_type, base]}
570
+ elif removed.is_empty():
571
+ return {"ok": false, "error": GdhRead.offered("%s has no item %s" % [control_type, item], item, held)}
572
+ var unsaved: String = _save_theme(theme, theme_path, removed)
573
+ if not unsaved.is_empty():
574
+ return {"ok": false, "error": unsaved}
575
+ return {"ok": true, "themePath": theme_path, "controlType": control_type, "removed": removed}
576
+
577
+
923
578
  ## [param answer] with a note when its controlType names no class a theme styles. Any name is a
924
579
  ## theme type, so one that is no class is a type variation, which styles only a control whose
925
580
  ## theme_type_variation names it, and a misspelt "Buton" would otherwise read as Button set.
@@ -946,7 +601,7 @@ func _noting_variation(answer: Dictionary) -> Dictionary:
946
601
  var close: PackedStringArray = GdhRead.nearest(type, themed)
947
602
  if not close.is_empty():
948
603
  answer["note"] += (
949
- " If a class was meant, the nearest %s %s."
950
- % ["is" if close.size() == 1 else "are", ", ".join(close)]
604
+ (" If a class was meant, the nearest %s %s, and remove_theme_item with controlType %s takes this out.")
605
+ % ["is" if close.size() == 1 else "are", ", ".join(close), type]
951
606
  )
952
607
  return answer