gdharness 1.3.8 → 1.3.10

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 (78) hide show
  1. package/build/cli.js +4668 -3229
  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 +115 -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 +112 -6
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
  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 +19 -29
  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 +39 -6
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
  17. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  20. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  23. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
  25. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  26. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
  27. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  28. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  29. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
  30. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  31. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
  32. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  33. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  34. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  35. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  36. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  37. package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
  38. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  40. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
  41. package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
  42. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  43. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  44. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
  45. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
  46. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  47. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
  48. package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
  49. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  50. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  51. package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
  52. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  53. package/build/godot/operations/animation_reads.gd +115 -4
  54. package/build/godot/operations/audio_buses.gd +93 -7
  55. package/build/godot/operations/classdb_queries.gd +119 -7
  56. package/build/godot/operations/declared_values.gd +272 -0
  57. package/build/godot/operations/dependencies.gd +154 -41
  58. package/build/godot/operations/editor_tools.gd +29 -13
  59. package/build/godot/operations/export_presets.gd +602 -4
  60. package/build/godot/operations/file_reads.gd +115 -18
  61. package/build/godot/operations/gdscript_analysis.gd +14 -0
  62. package/build/godot/operations/gdscript_authoring.gd +12 -2
  63. package/build/godot/operations/godot_operations.gd +18 -2
  64. package/build/godot/operations/import_pipeline.gd +20 -221
  65. package/build/godot/operations/input_actions.gd +127 -51
  66. package/build/godot/operations/project_config.gd +113 -68
  67. package/build/godot/operations/project_diagnostics.gd +4 -0
  68. package/build/godot/operations/project_validation.gd +266 -0
  69. package/build/godot/operations/property_paths.gd +89 -16
  70. package/build/godot/operations/property_values.gd +180 -121
  71. package/build/godot/operations/reading.gd +112 -6
  72. package/build/godot/operations/scene_connections.gd +222 -0
  73. package/build/godot/operations/serialisation.gd +19 -29
  74. package/build/godot/operations/setting_reads.gd +121 -0
  75. package/build/godot/operations/value_shapes.gd +128 -1
  76. package/build/godot/tests/listed.gd +20 -13
  77. package/build/keeper.js +1402 -707
  78. package/package.json +1 -1
@@ -0,0 +1,485 @@
1
+ @tool
2
+ extends "resource_tools.gd"
3
+
4
+ ## TileSets and the cells of tile maps, beside the resource files the script it extends writes,
5
+ ## whose helpers it shares.
6
+
7
+ ## How many cells a read of a tile map lists, enough for a level's worth and not a whole world's.
8
+ const CELLS_SHOWN: int = 1000
9
+
10
+
11
+ ## A pair of whole numbers given as {"x": 1, "y": 2} or [1, 2], as [true, Vector2i] or
12
+ ## [false, why]. The server checks the arguments it is given but not what is inside them, and a
13
+ ## pair given the other way went into a typed local and failed as "Invalid tool result".
14
+ func _xy(value: Variant, label: String) -> Array:
15
+ var x: Variant = null
16
+ var y: Variant = null
17
+ if value is Dictionary:
18
+ var fields: Dictionary = value
19
+ x = fields.get("x")
20
+ y = fields.get("y")
21
+ elif value is Array:
22
+ var pair: Array = value
23
+ if pair.size() == 2:
24
+ x = pair[0]
25
+ y = pair[1]
26
+ for number: Variant in [x, y]:
27
+ if (
28
+ not (number is float or number is int)
29
+ or GdhRead.as_float(number) != floorf(GdhRead.as_float(number))
30
+ ):
31
+ return [
32
+ false,
33
+ '%s must be two whole numbers, {"x": .., "y": ..}, not %s.' % [label, JSON.stringify(value)]
34
+ ]
35
+ return [true, Vector2i(GdhRead.as_int(x), GdhRead.as_int(y))]
36
+
37
+
38
+ func create_tileset(args: Dictionary) -> Dictionary:
39
+ var tileset_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
40
+ if tileset_path == "res://":
41
+ return {"ok": false, "error": "resourcePath is required"}
42
+ # modify sets properties and builds no atlas, so the other path is the only way on offered.
43
+ var taken: String = (
44
+ ""
45
+ if not FileAccess.file_exists(tileset_path)
46
+ else (
47
+ (
48
+ "%s already exists, and create_tileset builds a whole new TileSet, which would replace "
49
+ + "everything in it, the tiles a scene paints with among them. Give resourcePath a path "
50
+ + "nothing is at."
51
+ )
52
+ % tileset_path
53
+ )
54
+ )
55
+ if not taken.is_empty():
56
+ return {"ok": false, "error": taken}
57
+
58
+ var given: Variant = args.get("sources", [])
59
+ if not given is Array:
60
+ return {"ok": false, "error": "sources must list at least one atlas"}
61
+ var sources: Array = given
62
+ if sources.is_empty():
63
+ return {"ok": false, "error": "sources must list at least one atlas"}
64
+
65
+ var tileset: TileSet = TileSet.new()
66
+ var made: Array = []
67
+ for index: int in sources.size():
68
+ var built: Array = _atlas(sources[index], index)
69
+ if not built[0]:
70
+ return {"ok": false, "error": built[1]}
71
+ var atlas: TileSetAtlasSource = built[1]
72
+ # The first source sets the tile set's own tile size, which is what a TileMapLayer draws
73
+ # cells at; left at its default of 16 it did not match the atlas.
74
+ if index == 0:
75
+ tileset.tile_size = atlas.texture_region_size
76
+ var added: int = tileset.add_source(atlas)
77
+ if added < 0:
78
+ return {
79
+ "ok": false,
80
+ "error": "The TileSet did not take the atlas for %s" % atlas.texture.resource_path
81
+ }
82
+ var entry: Dictionary = {
83
+ "sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()
84
+ }
85
+ var blank: Array = built[2]
86
+ if not blank.is_empty():
87
+ entry["leftBlank"] = blank
88
+ var spare: String = built[3]
89
+ if not spare.is_empty():
90
+ entry["notInAnyTile"] = spare
91
+ made.append(entry)
92
+
93
+ var folder: String = GdhSceneFile.make_folder_for(tileset_path)
94
+ if not folder.is_empty():
95
+ return {"ok": false, "error": folder}
96
+ var saving: Error = GdhSceneUids.save_keeping_uid(tileset, tileset_path)
97
+ if saving != OK:
98
+ return {"ok": false, "error": "Failed to save %s: %s" % [tileset_path, error_string(saving)]}
99
+
100
+ _written(tileset_path)
101
+ # The size cells are drawn at, which the file leaves out when it is the default 16 by 16.
102
+ return {
103
+ "ok": true,
104
+ "tilesetPath": tileset_path,
105
+ "tileSize": {"x": tileset.tile_size.x, "y": tileset.tile_size.y},
106
+ "sources": made
107
+ }
108
+
109
+
110
+ ## One atlas source from its description, with its tiles made, as [true, source, the grid cells left
111
+ ## without a tile for being wholly transparent, a sentence on the pixels no tile covers or ""] or
112
+ ## [false, why].
113
+ ##
114
+ ## Tiles are made in every cell of the grid that has a pixel that is not transparent, which is
115
+ ## what the editor offers to do when an atlas gets a texture. Without them the atlas held no tiles,
116
+ ## and every cell painted from it drew nothing. The cells passed over and the strip a tile size that
117
+ ## does not divide the picture leaves are said, since a count alone left a caller to work out which.
118
+ func _atlas(entry: Variant, index: int) -> Array:
119
+ if not entry is Dictionary:
120
+ return [false, "sources[%d] must be an object with texture and tileSize" % index]
121
+ var source: Dictionary = entry
122
+ var texture_path: String = str(source.get("texture", ""))
123
+ if not texture_path.begins_with("res://") and not texture_path.begins_with("uid://"):
124
+ texture_path = _ensure_res_path(texture_path)
125
+ # Contained here as well as on the server: a relative path climbing out of the project became
126
+ # res://../../ and was loaded.
127
+ if texture_path.split("/").has(".."):
128
+ return [false, "sources[%d].texture leaves the project: %s" % [index, texture_path]]
129
+ if not ResourceLoader.exists(texture_path):
130
+ # An image the engine has not imported loads as nothing, where "no texture" would send a caller
131
+ # looking for a file that is there.
132
+ if FileAccess.file_exists(texture_path) and not FileAccess.file_exists(texture_path + ".import"):
133
+ return [
134
+ false,
135
+ (
136
+ (
137
+ "sources[%d].texture: %s has not been imported, so the engine has no texture for it "
138
+ + "yet. project_import reimport with resourcePath %s imports it."
139
+ )
140
+ % [index, texture_path, texture_path]
141
+ )
142
+ ]
143
+ return [false, "sources[%d].texture: no texture at %s" % [index, texture_path]]
144
+ var texture: Texture2D = load(texture_path) as Texture2D
145
+ if texture == null:
146
+ return [false, "sources[%d].texture: %s is not a texture" % [index, texture_path]]
147
+
148
+ var sized: Array = _xy(source.get("tileSize"), "sources[%d].tileSize" % index)
149
+ if not sized[0]:
150
+ return sized
151
+ var tile_size: Vector2i = sized[1]
152
+ if tile_size.x <= 0 or tile_size.y <= 0:
153
+ return [false, "sources[%d].tileSize must be above zero on both sides" % index]
154
+
155
+ var atlas: TileSetAtlasSource = TileSetAtlasSource.new()
156
+ atlas.texture = texture
157
+ atlas.texture_region_size = tile_size
158
+ for field: String in ["separation", "offset"]:
159
+ if not source.has(field):
160
+ continue
161
+ var pair: Array = _xy(source[field], "sources[%d].%s" % [index, field])
162
+ if not pair[0]:
163
+ return pair
164
+ if field == "separation":
165
+ atlas.separation = pair[1]
166
+ else:
167
+ atlas.margins = pair[1]
168
+
169
+ var grid: Vector2i = atlas.get_atlas_grid_size()
170
+ if grid.x <= 0 or grid.y <= 0:
171
+ return [
172
+ false,
173
+ (
174
+ "sources[%d]: a %s tile does not fit in %s, which is %s"
175
+ % [index, tile_size, texture_path, texture.get_size()]
176
+ )
177
+ ]
178
+ var image: Image = texture.get_image()
179
+ var blank: Array = []
180
+ for y: int in grid.y:
181
+ for x: int in grid.x:
182
+ var cell: Vector2i = Vector2i(x, y)
183
+ # Worked out here: the atlas answers a region only for a tile it already has.
184
+ var region: Rect2i = Rect2i(
185
+ atlas.margins + cell * (atlas.texture_region_size + atlas.separation),
186
+ atlas.texture_region_size
187
+ )
188
+ if image != null and _blank(image, region):
189
+ blank.append({"x": x, "y": y})
190
+ continue
191
+ atlas.create_tile(cell)
192
+ var covered: Vector2i = atlas.margins + grid * tile_size + (grid - Vector2i.ONE) * atlas.separation
193
+ var left: Vector2i = Vector2i(texture.get_size()) - covered
194
+ var sides: PackedStringArray = []
195
+ if left.x > 0:
196
+ var _right: bool = sides.append("%d px on the right" % left.x)
197
+ if left.y > 0:
198
+ var _bottom: bool = sides.append("%d px at the bottom" % left.y)
199
+ var spare: String = (
200
+ ""
201
+ if sides.is_empty()
202
+ else (
203
+ "%s is %d by %d, so %s %s in no %d by %d tile."
204
+ % [
205
+ texture_path,
206
+ texture.get_width(),
207
+ texture.get_height(),
208
+ " and ".join(sides),
209
+ "is" if sides.size() == 1 else "are",
210
+ tile_size.x,
211
+ tile_size.y
212
+ ]
213
+ )
214
+ )
215
+ return [true, atlas, blank, spare]
216
+
217
+
218
+ func _blank(image: Image, region: Rect2i) -> bool:
219
+ if image.is_compressed():
220
+ return false
221
+ return image.get_region(region).is_invisible()
222
+
223
+
224
+ ## The sources a tile set holds, as "source 0", "sources 0, 2" or "no sources", for saying what a
225
+ ## cell could have named instead.
226
+ func _sources_of(tile_set: TileSet) -> String:
227
+ var ids: PackedStringArray = []
228
+ for index: int in tile_set.get_source_count():
229
+ var _listed: bool = ids.append(str(tile_set.get_source_id(index)))
230
+ if ids.is_empty():
231
+ return "no sources"
232
+ return ("source " if ids.size() == 1 else "sources ") + ", ".join(ids)
233
+
234
+
235
+ func set_tilemap_cells(args: Dictionary) -> Dictionary:
236
+ var scene_path: String = _ensure_res_path(str(args.get("scenePath", "")))
237
+ var node_path: String = str(args.get("nodePath", ""))
238
+ if scene_path == "res://":
239
+ return {"ok": false, "error": "scenePath is required"}
240
+
241
+ var cells: Variant = args.get("cells", [])
242
+ # Refused rather than skipped: placing nothing and answering success is indistinguishable
243
+ # from placing everything, and the caller only finds out by opening the scene.
244
+ if typeof(cells) != TYPE_ARRAY:
245
+ return {"ok": false, "error": "cells must be an array of cells to place"}
246
+ var placed: Array = cells
247
+ if placed.is_empty():
248
+ return {"ok": false, "error": "cells is empty, so there is nothing to place"}
249
+
250
+ var scene: GdhSceneFile = GdhSceneFile.new()
251
+ var refused: Dictionary = scene.open(scene_path, true)
252
+ if not refused.is_empty():
253
+ return refused
254
+
255
+ var layer: int = GdhRead.as_int(args.get("layer", 0))
256
+ var found: Array = _tile_node(scene, node_path, layer)
257
+ if found[0] == null:
258
+ return scene.refuse(str(found[1]))
259
+ var node: Node = found[0]
260
+ var layered: TileMap = node as TileMap
261
+ var single: TileMapLayer = node as TileMapLayer
262
+
263
+ # set_cell stores a cell whatever it is given, and a cell naming no tile simply draws nothing, so
264
+ # the source, the tile and its alternative are checked here: the alternative is a call that
265
+ # reports placing tiles and a scene that shows none.
266
+ var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
267
+
268
+ scene.asked(node, ["layer_%d/tile_data" % layer if layered != null else "tile_map_data"])
269
+ # Every cell is checked before any is placed, and every one refused is named: stopping at the first
270
+ # left a caller fixing one cell a call.
271
+ var checked: Array = []
272
+ var refusals: PackedStringArray = []
273
+ for index: int in placed.size():
274
+ var cell: Array = _cell(placed[index], index, tile_set)
275
+ if not cell[0]:
276
+ var _named: bool = refusals.append(str(cell[1]))
277
+ checked.append(cell)
278
+ if not refusals.is_empty():
279
+ return scene.refuse("%s. Nothing was placed." % "; ".join(refusals))
280
+ var wanted: Array = []
281
+ var erased: int = 0
282
+ for cell: Array in checked:
283
+ var at: Vector2i = cell[1]
284
+ var source_id: int = cell[2]
285
+ var atlas_coords: Vector2i = cell[3]
286
+ var alternative: int = cell[4]
287
+ if layered != null:
288
+ layered.set_cell(layer, at, source_id, atlas_coords, alternative)
289
+ else:
290
+ single.set_cell(at, source_id, atlas_coords, alternative)
291
+ wanted.append([at, source_id, atlas_coords, alternative])
292
+ if source_id == -1:
293
+ erased += 1
294
+
295
+ var at_path: String = scene.path_of(node)
296
+ var written: Dictionary = scene.write(
297
+ func(saved: Node) -> String:
298
+ var copy: Node = saved.get_node_or_null(at_path)
299
+ for entry: Array in wanted:
300
+ var at: Vector2i = entry[0]
301
+ var held: Array = _cell_at(copy, layer, at)
302
+ if held != entry.slice(1):
303
+ return "the cell at %s would load as %s, not %s." % [at, held, entry.slice(1)]
304
+ return ""
305
+ )
306
+ if not written.is_empty():
307
+ return written
308
+
309
+ return {
310
+ "ok": true,
311
+ "layer": layer,
312
+ "placed": wanted.size() - erased,
313
+ "erased": erased,
314
+ "requested": placed.size()
315
+ }
316
+
317
+
318
+ ## The node at [param node_path] cells are painted on, with a TileSet, as [node, why not].
319
+ ## TileMapLayer is the node since 4.3, and TileMap the one it replaced, which keeps its layers.
320
+ func _tile_node(scene: GdhSceneFile, node_path: String, layer: int) -> Array:
321
+ var node: Node = (
322
+ scene.root if node_path == "." or node_path == "" else scene.root.get_node_or_null(node_path)
323
+ )
324
+ if node == null:
325
+ return [null, "No node at %s in %s" % [node_path, scene.path]]
326
+ var layered: TileMap = node as TileMap
327
+ var single: TileMapLayer = node as TileMapLayer
328
+ if layered == null and single == null:
329
+ return [null, "%s is a %s, not a TileMapLayer or a TileMap" % [node_path, node.get_class()]]
330
+ var foreign: String = scene.foreign(node)
331
+ if not foreign.is_empty():
332
+ return [null, foreign]
333
+ if single != null and layer != 0:
334
+ return [null, "%s is a TileMapLayer, which is one layer; layer applies to a TileMap" % node_path]
335
+ if layered != null and (layer < 0 or layer >= layered.get_layers_count()):
336
+ return [null, "%s has %d layers, and no layer %d" % [node_path, layered.get_layers_count(), layer]]
337
+ var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
338
+ if tile_set == null:
339
+ return [
340
+ null,
341
+ (
342
+ (
343
+ '%s has no TileSet, so no cell can name a source. scene_node set with properties {"tile_set": '
344
+ + '"res://..."} on %s gives it one, a TileSet file such as resource_edit create_tileset makes.'
345
+ )
346
+ % [node_path, node_path]
347
+ )
348
+ ]
349
+ return [node, ""]
350
+
351
+
352
+ ## One cell from its description, checked against [param tile_set], as
353
+ ## [true, coords, source id, atlas coords, alternative] or [false, why]. A source of -1 erases the
354
+ ## cell, as set_cell takes it, and needs no atlasCoords.
355
+ func _cell(entry: Variant, index: int, tile_set: TileSet) -> Array:
356
+ if not entry is Dictionary:
357
+ return [false, "cells[%d] must be an object with coords, sourceId and atlasCoords" % index]
358
+ var cell: Dictionary = entry
359
+ for field: String in ["coords", "sourceId"]:
360
+ if not cell.has(field):
361
+ return [false, "cells[%d] has no %s" % [index, field]]
362
+ var at: Array = _xy(cell["coords"], "cells[%d].coords" % index)
363
+ if not at[0]:
364
+ return at
365
+ var coords: Vector2i = at[1]
366
+ var named: String = "cells[%d] at (%d, %d)" % [index, coords.x, coords.y]
367
+ var source_id: int = GdhRead.as_int(cell["sourceId"], -2)
368
+ if source_id == -1:
369
+ return [true, coords, -1, Vector2i(-1, -1), -1]
370
+ if not cell.has("atlasCoords"):
371
+ return [
372
+ false, "%s has no atlasCoords, which every cell but an erased one (sourceId -1) needs" % named
373
+ ]
374
+ var atlas_at: Array = _xy(cell["atlasCoords"], "cells[%d].atlasCoords" % index)
375
+ if not atlas_at[0]:
376
+ return atlas_at
377
+ if not tile_set.has_source(source_id):
378
+ return [
379
+ false,
380
+ (
381
+ "%s names source %d, and the TileSet has %s; sourceId -1 erases a cell"
382
+ % [named, source_id, _sources_of(tile_set)]
383
+ )
384
+ ]
385
+ var source: TileSetSource = tile_set.get_source(source_id)
386
+ var atlas_coords: Vector2i = atlas_at[1]
387
+ var alternative: int = GdhRead.as_int(cell.get("alternativeTile", 0))
388
+ if not source.has_tile(atlas_coords):
389
+ return [
390
+ false,
391
+ "%s: source %d has no tile at %s; it has %s" % [named, source_id, atlas_coords, _tiles_of(source)]
392
+ ]
393
+ if not source.has_alternative_tile(atlas_coords, alternative):
394
+ return [
395
+ false,
396
+ (
397
+ "%s: the tile at %s in source %d has no alternative %d"
398
+ % [named, atlas_coords, source_id, alternative]
399
+ )
400
+ ]
401
+ return [true, coords, source_id, atlas_coords, alternative]
402
+
403
+
404
+ ## The atlas coordinates [param source] has tiles at, the first twelve and how many more, or that it
405
+ ## has none: a refusal naming the one missing sent a caller guessing which of the others exist.
406
+ static func _tiles_of(source: TileSetSource) -> String:
407
+ var count: int = source.get_tiles_count()
408
+ if count == 0:
409
+ return "no tiles"
410
+ var named: Array[String] = []
411
+ for index: int in mini(count, 12):
412
+ named.append(str(source.get_tile_id(index)))
413
+ var more: String = "" if count <= 12 else " and %d more" % (count - 12)
414
+ return "tiles at " + ", ".join(PackedStringArray(named)) + more
415
+
416
+
417
+ ## The cells a TileMapLayer or a TileMap holds, as {"cellCount", "cells"} in the form
418
+ ## set_tilemap_cells takes, or {} for any other node. The scene keeps them as packed bytes, which a
419
+ ## read answered with and nobody could read: a probe counted three cells by decoding the base64 by
420
+ ## hand. At most CELLS_SHOWN are listed, the count saying how many there are.
421
+ static func cells_of(node: Node) -> Dictionary:
422
+ var listed: Array[Dictionary] = []
423
+ var count: int = 0
424
+ var single: TileMapLayer = node as TileMapLayer
425
+ var layered: TileMap = node as TileMap
426
+ if single != null:
427
+ var used: Array[Vector2i] = single.get_used_cells()
428
+ count = used.size()
429
+ for at: Vector2i in used.slice(0, CELLS_SHOWN):
430
+ listed.append(
431
+ _cell_entry(
432
+ at,
433
+ single.get_cell_source_id(at),
434
+ single.get_cell_atlas_coords(at),
435
+ single.get_cell_alternative_tile(at)
436
+ )
437
+ )
438
+ elif layered != null:
439
+ for layer: int in layered.get_layers_count():
440
+ var used: Array[Vector2i] = layered.get_used_cells(layer)
441
+ count += used.size()
442
+ for at: Vector2i in used:
443
+ if listed.size() >= CELLS_SHOWN:
444
+ break
445
+ var entry: Dictionary = _cell_entry(
446
+ at,
447
+ layered.get_cell_source_id(layer, at),
448
+ layered.get_cell_atlas_coords(layer, at),
449
+ layered.get_cell_alternative_tile(layer, at)
450
+ )
451
+ entry["layer"] = layer
452
+ listed.append(entry)
453
+ else:
454
+ return {}
455
+ var answer: Dictionary = {"cellCount": count, "cells": listed}
456
+ if count > listed.size():
457
+ answer["cellsNote"] = "The first %d of %d cells are listed." % [listed.size(), count]
458
+ return answer
459
+
460
+
461
+ static func _cell_entry(at: Vector2i, source_id: int, atlas: Vector2i, alternative: int) -> Dictionary:
462
+ return {
463
+ "coords": {"x": at.x, "y": at.y},
464
+ "sourceId": source_id,
465
+ "atlasCoords": {"x": atlas.x, "y": atlas.y},
466
+ "alternativeTile": alternative,
467
+ }
468
+
469
+
470
+ func _cell_at(node: Node, layer: int, at: Vector2i) -> Array:
471
+ var layered: TileMap = node as TileMap
472
+ if layered != null:
473
+ return [
474
+ layered.get_cell_source_id(layer, at),
475
+ layered.get_cell_atlas_coords(layer, at),
476
+ layered.get_cell_alternative_tile(layer, at)
477
+ ]
478
+ var single: TileMapLayer = node as TileMapLayer
479
+ if single != null:
480
+ return [
481
+ single.get_cell_source_id(at),
482
+ single.get_cell_atlas_coords(at),
483
+ single.get_cell_alternative_tile(at)
484
+ ]
485
+ return []
@@ -0,0 +1 @@
1
+ uid://chrp5707sthas
@@ -5,6 +5,22 @@ extends RefCounted
5
5
  ##
6
6
  ## This file is the one beside the operations, and `bun run sync:gd` copies it into each addon.
7
7
 
8
+ const GdhRead = preload("reading.gd")
9
+
10
+ ## What each packed array holds, one value of which every element is fitted to.
11
+ const PACKED_ELEMENTS: Dictionary = {
12
+ TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
13
+ TYPE_PACKED_INT32_ARRAY: TYPE_INT,
14
+ TYPE_PACKED_INT64_ARRAY: TYPE_INT,
15
+ TYPE_PACKED_FLOAT32_ARRAY: TYPE_FLOAT,
16
+ TYPE_PACKED_FLOAT64_ARRAY: TYPE_FLOAT,
17
+ TYPE_PACKED_STRING_ARRAY: TYPE_STRING,
18
+ TYPE_PACKED_VECTOR2_ARRAY: TYPE_VECTOR2,
19
+ TYPE_PACKED_VECTOR3_ARRAY: TYPE_VECTOR3,
20
+ TYPE_PACKED_VECTOR4_ARRAY: TYPE_VECTOR4,
21
+ TYPE_PACKED_COLOR_ARRAY: TYPE_COLOR,
22
+ }
23
+
8
24
  # What each tag has to carry before it is built. A tag naming a type whose keys are not there is a
9
25
  # tag somebody wrote by mistake or wrote for a different shape, and building one out of defaults
10
26
  # answers with a Transform2D of zeros where the caller meant something else entirely. Unbuilt, it
@@ -33,6 +49,21 @@ const REQUIRED: Dictionary = {
33
49
  "RID": [["id"]],
34
50
  "Dictionary": [["entries"]],
35
51
  }
52
+ ## The fields each type reads as numbers. A builder reads a word there as 0, so {"x": 0, "y": "far"}
53
+ ## was written as (0, 0) and answered as written.
54
+ const NUMBER_FIELDS: Dictionary = {
55
+ "Vector2": ["x", "y"],
56
+ "Vector2i": ["x", "y"],
57
+ "Vector3": ["x", "y", "z"],
58
+ "Vector3i": ["x", "y", "z"],
59
+ "Vector4": ["x", "y", "z", "w"],
60
+ "Vector4i": ["x", "y", "z", "w"],
61
+ "Rect2": ["x", "y", "width", "height"],
62
+ "Rect2i": ["x", "y", "width", "height"],
63
+ "Plane": ["d"],
64
+ "Quaternion": ["x", "y", "z", "w"],
65
+ "Color": ["r", "g", "b", "a"],
66
+ }
36
67
  ## The fields a builder reads when they are there and does without when not.
37
68
  const OPTIONAL: Dictionary = {
38
69
  "Color": ["a"],
@@ -77,6 +108,86 @@ static func spare(fields: Dictionary, tag: String) -> Array:
77
108
  return lists
78
109
 
79
110
 
111
+ ## Whether a [param tag] built from [param fields] reads every one of them, and each it reads as a
112
+ ## number holds one.
113
+ static func buildable(fields: Dictionary, tag: String) -> bool:
114
+ return (
115
+ spare(fields, tag).any(func(left: Array) -> bool: return left.is_empty())
116
+ and not_a_number(fields, tag).is_empty()
117
+ )
118
+
119
+
120
+ ## The first field of [param fields] that a [param tag] reads as a number and that holds no number,
121
+ ## or "" when there is none. Digits in quotes are a number, as a write takes them for a float.
122
+ static func not_a_number(fields: Dictionary, tag: String) -> String:
123
+ for key: String in NUMBER_FIELDS.get(tag, []):
124
+ if fields.has(key) and not _is_number(fields[key]):
125
+ return key
126
+ return ""
127
+
128
+
129
+ ## Whether every item of [param items] is a number, for a list read by its places as a vector.
130
+ static func all_numbers(items: Array) -> bool:
131
+ return items.all(_is_number)
132
+
133
+
134
+ # A float JSON has no number for, infinity or NaN, crosses tagged as {"_type": "float", "value":
135
+ # "inf"}, and is a number.
136
+ static func _is_number(held: Variant) -> bool:
137
+ if held is int or held is float:
138
+ return true
139
+ if held is String:
140
+ return str(held).is_valid_float()
141
+ if held is Dictionary:
142
+ var tagged: Dictionary = held
143
+ return str(tagged.get("_type", "")) in ["float", "int"] and tagged.has("value")
144
+ return false
145
+
146
+
147
+ ## Whether [param value] holds a number with a fractional part where an integer is wanted, in a
148
+ ## list or a dictionary as much as bare. The conversion would cut 2.7 to 2 without a word, and a
149
+ ## dictionary key arrives as the text "2.7", which reads as the same number.
150
+ static func fraction(value: Variant, type: int, elements: Array = []) -> bool:
151
+ if type == TYPE_INT:
152
+ var number: Variant = value
153
+ if typeof(value) == TYPE_STRING:
154
+ number = GdhRead.json_or_null(str(value))
155
+ if typeof(number) != TYPE_FLOAT:
156
+ return false
157
+ var whole: float = number
158
+ return is_finite(whole) and whole != floorf(whole)
159
+ var element: int = GdhRead.as_int(PACKED_ELEMENTS.get(type, TYPE_NIL), TYPE_NIL)
160
+ if type == TYPE_ARRAY and not elements.is_empty():
161
+ element = GdhRead.as_int(elements[0], TYPE_NIL)
162
+ if value is Array and element != TYPE_NIL:
163
+ var items: Array = value
164
+ return items.any(func(item: Variant) -> bool: return fraction(item, element))
165
+ if type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
166
+ var entries: Dictionary = value
167
+ var key_type: int = GdhRead.as_int(elements[0], TYPE_NIL)
168
+ var value_type: int = GdhRead.as_int(elements[1], TYPE_NIL)
169
+ for key: Variant in entries:
170
+ if fraction(key, key_type) or fraction(entries[key], value_type):
171
+ return true
172
+ return false
173
+
174
+
175
+ ## The packed array [param type] built from [param items], each fitted by [param fit] to what the
176
+ ## array holds, or [param items] as they are when one cannot become that, for the caller to refuse.
177
+ ## Element by element, so a point given as its fields is read as one: converted whole, a list of
178
+ ## {"x": 3, "y": 4} became zero vectors, and the call ran on them.
179
+ static func packed_from(items: Array, type: int, fit: Callable) -> Variant:
180
+ var element: int = PACKED_ELEMENTS[type]
181
+ var numbers: Array[int] = [TYPE_INT, TYPE_FLOAT]
182
+ var built: Array = []
183
+ for item: Variant in items:
184
+ var one: Variant = fit.call(item, element)
185
+ if typeof(one) != element and not (numbers.has(typeof(one)) and numbers.has(element)):
186
+ return items
187
+ built.append(one)
188
+ return type_convert(built, type)
189
+
190
+
80
191
  ## The sentences a refusal of [param value] where a [param type] goes ends with: which of its keys
81
192
  ## kept it from being read as one, and how one is written. Empty for a type not built from fields.
82
193
  ##
@@ -92,7 +203,23 @@ static func remedy(value: Variant, type: int) -> String:
92
203
  var shape: String = written_as(type)
93
204
  if shape.is_empty():
94
205
  return ""
95
- return _unread(value, type) + " A %s is sent as %s." % [type_string(type), shape]
206
+ return (
207
+ _unread(value, type) + _no_number(value, type) + " A %s is sent as %s." % [type_string(type), shape]
208
+ )
209
+
210
+
211
+ static func _no_number(value: Variant, type: int) -> String:
212
+ var tag: String = type_string(type)
213
+ if value is Dictionary:
214
+ var fields: Dictionary = value
215
+ var key: String = not_a_number(fields, tag)
216
+ if not key.is_empty():
217
+ return " Its %s is %s, which is no number." % [key, JSON.stringify(fields[key])]
218
+ if value is Array and NUMBER_FIELDS.has(tag):
219
+ var items: Array = value
220
+ if not all_numbers(items):
221
+ return " Read by its places, each item is a number."
222
+ return ""
96
223
 
97
224
 
98
225
  static func _unread(value: Variant, type: int) -> String:
@@ -16,6 +16,10 @@ var refused: String = ""
16
16
  var raised: Array[String] = []
17
17
  ## The warnings reported while it ran, a line each.
18
18
  var warned: Array[String] = []
19
+ ## Where a script error first ended each function it ended, as "res://path:line" by function name.
20
+ ## A function the VM ends at a script error gives back its return type's default, 0, "" or null,
21
+ ## and its caller goes on with that as if it had been returned.
22
+ var ended_at: Dictionary = {}
19
23
 
20
24
 
21
25
  func _log_message(_message: String, _error: bool) -> void:
@@ -26,8 +30,8 @@ func _log_message(_message: String, _error: bool) -> void:
26
30
  # gdlint:ignore = function-arguments-number
27
31
  func _log_error(
28
32
  function: String,
29
- _file: String,
30
- _line: int,
33
+ file: String,
34
+ line: int,
31
35
  code: String,
32
36
  rationale: String,
33
37
  _editor_notify: bool,
@@ -45,3 +49,5 @@ func _log_error(
45
49
  warned.append(text)
46
50
  else:
47
51
  raised.append(text)
52
+ if error_type == ERROR_TYPE_SCRIPT and not ended_at.has(function):
53
+ ended_at[function] = "%s:%d" % [file, line]