gdharness 1.1.21 → 1.1.22

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 (47) hide show
  1. package/build/cli.js +1036 -525
  2. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  3. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  9. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  10. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  13. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  14. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
  23. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
  24. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  25. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  26. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  28. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  29. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  31. package/build/godot/operations/audio_buses.gd +47 -11
  32. package/build/godot/operations/class_cache.gd +18 -2
  33. package/build/godot/operations/dependencies.gd +128 -17
  34. package/build/godot/operations/file_walk.gd +20 -0
  35. package/build/godot/operations/gdscript_analysis.gd +59 -20
  36. package/build/godot/operations/gdscript_authoring.gd +127 -60
  37. package/build/godot/operations/godot_operations.gd +24 -5
  38. package/build/godot/operations/import_pipeline.gd +179 -19
  39. package/build/godot/operations/logger.gd +2 -2
  40. package/build/godot/operations/plugins.gd +88 -70
  41. package/build/godot/operations/project_config.gd +41 -6
  42. package/build/godot/operations/project_diagnostics.gd +25 -10
  43. package/build/godot/operations/reading.gd +17 -0
  44. package/build/godot/operations/resource_files.gd +60 -17
  45. package/build/godot/operations/serialisation.gd +116 -7
  46. package/build/index.js +949 -435
  47. package/package.json +1 -1
@@ -5,6 +5,7 @@ extends Node
5
5
 
6
6
  const PropertyValues = preload("../property_values.gd")
7
7
  const Read = preload("../reading.gd")
8
+ const SceneFile = preload("../scene_file.gd")
8
9
 
9
10
  # Shader templates. Written as real multi-line source rather than escaped one-liners so that
10
11
  # what ends up in the .gdshader can be read here. The %s is the shader type.
@@ -67,14 +68,25 @@ func _ensure_res_path(path: String) -> String:
67
68
  return "res://" + path
68
69
 
69
70
 
70
- func _refresh_filesystem() -> void:
71
+ ## Brings the editor up to date with a resource file written under it. The cached copy is
72
+ ## reloaded in place, since it is the one open scenes hold: left alone, the next edit made through
73
+ ## it saved the old values over this one.
74
+ func _written(path: String) -> void:
75
+ if ResourceLoader.has_cached(path):
76
+ var _replaced: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)
71
77
  if _editor_plugin:
72
78
  EditorInterface.get_resource_filesystem().scan()
73
79
 
74
80
 
75
- ## Set each property, answering with what went wrong or "" when nothing did.
76
- func _set_resource_properties(resource: Resource, properties: Variant) -> String:
77
- return _properties.write_all(resource, _parse_properties_dict(properties))
81
+ ## Why a tool that creates [param path] must not write there, or "". Saving over a file that was
82
+ ## there replaced it whole and answered as a new one.
83
+ func _taken(path: String, op: String, remedy: String = "Change it with op=modify") -> String:
84
+ if not FileAccess.file_exists(path):
85
+ return ""
86
+ return (
87
+ "%s already exists, and %s would replace everything in it. %s, or create the new one under another path."
88
+ % [path, op, remedy]
89
+ )
78
90
 
79
91
 
80
92
  func _parse_properties_dict(raw: Variant) -> Dictionary:
@@ -88,6 +100,30 @@ func _parse_properties_dict(raw: Variant) -> Dictionary:
88
100
  return {}
89
101
 
90
102
 
103
+ ## A pair of whole numbers given as {"x": 1, "y": 2} or [1, 2], as [true, Vector2i] or
104
+ ## [false, why]. The server checks the arguments it is given but not what is inside them, and a
105
+ ## pair given the other way went into a typed local and failed as "Invalid tool result".
106
+ func _xy(value: Variant, label: String) -> Array:
107
+ var x: Variant = null
108
+ var y: Variant = null
109
+ if value is Dictionary:
110
+ var fields: Dictionary = value
111
+ x = fields.get("x")
112
+ y = fields.get("y")
113
+ elif value is Array:
114
+ var pair: Array = value
115
+ if pair.size() == 2:
116
+ x = pair[0]
117
+ y = pair[1]
118
+ for number: Variant in [x, y]:
119
+ if not (number is float or number is int) or Read.as_float(number) != floorf(Read.as_float(number)):
120
+ return [
121
+ false,
122
+ '%s must be two whole numbers, {"x": .., "y": ..}, not %s.' % [label, JSON.stringify(value)]
123
+ ]
124
+ return [true, Vector2i(Read.as_int(x), Read.as_int(y))]
125
+
126
+
91
127
  ## The theme on disk at this path, or null when there is not one.
92
128
  ##
93
129
  ## Read past the cache, because the caller saves what it gets back and the cached copy is the
@@ -103,12 +139,44 @@ func _load_theme(theme_path: String) -> Theme:
103
139
  return null
104
140
 
105
141
 
106
- func _save_scene_root(root: Node, scene_path: String) -> Error:
107
- var packed: PackedScene = PackedScene.new()
108
- var pack_result: Error = packed.pack(root)
109
- if pack_result != OK:
110
- return pack_result
111
- return ResourceSaver.save(packed, scene_path)
142
+ ## Saves [param resource] to [param path] and reads the file back, answering "" when each of
143
+ ## [param names] loads as [param resource] holds it, or why not. On a mismatch the file is put back
144
+ ## as it was, [param previous], or removed when there was none.
145
+ ##
146
+ ## Read back because a property can be set and still not be saved: one without storage, a
147
+ ## script variable that is not exported. Both were answered as changed.
148
+ func _save_checked(resource: Resource, path: String, names: Array, previous: Variant) -> String:
149
+ var saving: Error = ResourceSaver.save(resource, path)
150
+ if saving != OK:
151
+ return "Failed to save %s: %s" % [path, error_string(saving)]
152
+ var loaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
153
+ var lost: String = ""
154
+ if loaded == null:
155
+ lost = "%s did not load back after it was saved." % path
156
+ else:
157
+ for key: Variant in names:
158
+ var property: String = str(key)
159
+ var read: Variant = loaded.get(property)
160
+ if not PropertyValues.same(read, resource.get(property)):
161
+ lost = (
162
+ (
163
+ "%s.%s would load from the file as %s: it is not a property the file keeps, so "
164
+ + "nothing was changed."
165
+ )
166
+ % [path, property, _properties.shown(read)]
167
+ )
168
+ break
169
+ if lost.is_empty():
170
+ _written(path)
171
+ return ""
172
+ if previous is PackedByteArray:
173
+ var bytes: PackedByteArray = previous
174
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
175
+ if file == null or not file.store_buffer(bytes):
176
+ return lost + " Putting the file back as it was failed too."
177
+ elif DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) != OK:
178
+ return lost + " Removing the file it wrote failed too."
179
+ return lost
112
180
 
113
181
 
114
182
  func create_resource(args: Dictionary) -> Dictionary:
@@ -116,48 +184,79 @@ func create_resource(args: Dictionary) -> Dictionary:
116
184
  var resource_type: String = str(args.get("resourceType", "Resource"))
117
185
  if res_path == "res://":
118
186
  return {"ok": false, "error": "resourcePath is required"}
119
-
120
- var instance: Variant = ClassDB.instantiate(resource_type)
121
- if instance == null or not (instance is Resource):
122
- return {"ok": false, "error": "Failed to instantiate resource type", "resourceType": resource_type}
123
- var resource: Resource = instance
124
-
187
+ var taken: String = _taken(res_path, "resource_edit op=create")
188
+ if not taken.is_empty():
189
+ return {"ok": false, "error": taken}
190
+
191
+ if (
192
+ not ClassDB.class_exists(resource_type)
193
+ or not ClassDB.is_parent_class(resource_type, "Resource")
194
+ or not ClassDB.can_instantiate(resource_type)
195
+ ):
196
+ return {"ok": false, "error": "%s is not a resource class that can be created." % resource_type}
197
+ var resource: Resource = ClassDB.instantiate(resource_type)
198
+
199
+ # A script that did not load was skipped, and a plain resource was saved in place of the custom
200
+ # one asked for.
125
201
  var script_path: String = str(args.get("script", ""))
126
202
  if script_path != "":
127
- var script_obj: Resource = load(_ensure_res_path(script_path))
128
- if script_obj:
129
- resource.set_script(script_obj)
130
-
131
- if args.has("properties"):
132
- var refused: String = _set_resource_properties(resource, args.get("properties"))
133
- if not refused.is_empty():
134
- return {"ok": false, "error": refused}
203
+ var script_file: String = _ensure_res_path(script_path)
204
+ var script: Script = load(script_file) as Script
205
+ if script == null:
206
+ return {"ok": false, "error": "No script loads from " + script_file}
207
+ var base: String = str(script.get_instance_base_type())
208
+ if not ClassDB.is_parent_class(resource_type, base):
209
+ return {
210
+ "ok": false,
211
+ "error": "%s extends %s, so it cannot be on a %s." % [script_file, base, resource_type]
212
+ }
213
+ resource.set_script(script)
135
214
 
136
- var save_result: Error = ResourceSaver.save(resource, res_path)
137
- if save_result != OK:
138
- return {"ok": false, "error": "Failed to save resource", "code": save_result}
215
+ var properties: Dictionary = _parse_properties_dict(args.get("properties", {}))
216
+ var refused: String = _properties.write_all(resource, properties)
217
+ if not refused.is_empty():
218
+ return {"ok": false, "error": refused}
139
219
 
140
- _refresh_filesystem()
141
- return {"ok": true, "resourcePath": res_path, "resourceType": args.get("resourceType")}
220
+ var failed: String = _save_checked(resource, res_path, properties.keys(), null)
221
+ if not failed.is_empty():
222
+ return {"ok": false, "error": failed}
223
+ return {"ok": true, "resourcePath": res_path, "resourceType": resource.get_class()}
142
224
 
143
225
 
144
226
  func modify_resource(args: Dictionary) -> Dictionary:
145
227
  var res_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
146
228
  if res_path == "res://":
147
229
  return {"ok": false, "error": "resourcePath is required"}
148
-
149
- var resource: Resource = load(res_path)
230
+ if not FileAccess.file_exists(res_path):
231
+ return {"ok": false, "error": "No resource at " + res_path}
232
+ # The editor's copy of an imported file is rebuilt from the source on every import, and a save
233
+ # to it fails; edited through the cache, the editor kept the changed copy after the refusal.
234
+ if FileAccess.file_exists(res_path + ".import"):
235
+ return {
236
+ "ok": false,
237
+ "error":
238
+ (
239
+ (
240
+ "%s is imported, so what it holds comes from its source file and its import options. "
241
+ + "Change those with project_import op=set_options."
242
+ )
243
+ % res_path
244
+ )
245
+ }
246
+
247
+ # Past the cache, so a refusal leaves the copy open scenes hold as it was.
248
+ var resource: Resource = ResourceLoader.load(res_path, "", ResourceLoader.CACHE_MODE_IGNORE)
150
249
  if resource == null:
151
- return {"ok": false, "error": "Resource not found", "resourcePath": res_path}
250
+ return {"ok": false, "error": "%s did not load as a resource." % res_path}
152
251
 
153
- var refused: String = _set_resource_properties(resource, args.get("properties", ""))
252
+ var properties: Dictionary = _parse_properties_dict(args.get("properties", ""))
253
+ var refused: String = _properties.write_all(resource, properties)
154
254
  if not refused.is_empty():
155
255
  return {"ok": false, "error": refused}
156
- var save_result: Error = ResourceSaver.save(resource, res_path)
157
- if save_result != OK:
158
- return {"ok": false, "error": "Failed to save resource", "code": save_result}
159
-
160
- _refresh_filesystem()
256
+ var previous: PackedByteArray = FileAccess.get_file_as_bytes(res_path)
257
+ var failed: String = _save_checked(resource, res_path, properties.keys(), previous)
258
+ if not failed.is_empty():
259
+ return {"ok": false, "error": failed}
161
260
  return {"ok": true, "resourcePath": res_path}
162
261
 
163
262
 
@@ -166,6 +265,9 @@ func create_shader(args: Dictionary) -> Dictionary:
166
265
  var shader_type: String = str(args.get("shaderType", "canvas_item"))
167
266
  if shader_path == "res://":
168
267
  return {"ok": false, "error": "shaderPath is required"}
268
+ var taken: String = _taken(shader_path, "resource_edit op=create_shader", "Edit its text")
269
+ if not taken.is_empty():
270
+ return {"ok": false, "error": taken}
169
271
 
170
272
  var code: String = str(args.get("code", ""))
171
273
  var template: String = str(args.get("template", ""))
@@ -183,13 +285,17 @@ func create_shader(args: Dictionary) -> Dictionary:
183
285
 
184
286
  var file: FileAccess = FileAccess.open(shader_path, FileAccess.WRITE)
185
287
  if file == null:
186
- return {"ok": false, "error": "Failed to open shader file for writing", "shaderPath": shader_path}
288
+ return {
289
+ "ok": false,
290
+ "error":
291
+ "Could not open %s for writing: %s" % [shader_path, error_string(FileAccess.get_open_error())]
292
+ }
187
293
  var written: bool = file.store_string(code)
188
294
  file.close()
189
295
  if not written:
190
- return {"ok": false, "error": "Failed to write the shader file", "shaderPath": shader_path}
296
+ return {"ok": false, "error": "Failed to write the shader file " + shader_path}
191
297
 
192
- _refresh_filesystem()
298
+ _written(shader_path)
193
299
  return {"ok": true, "shaderPath": shader_path, "shaderType": shader_type}
194
300
 
195
301
 
@@ -197,46 +303,117 @@ func create_tileset(args: Dictionary) -> Dictionary:
197
303
  var tileset_path: String = _ensure_res_path(str(args.get("tilesetPath", "")))
198
304
  if tileset_path == "res://":
199
305
  return {"ok": false, "error": "tilesetPath is required"}
306
+ var taken: String = _taken(tileset_path, "resource_edit op=create_tileset")
307
+ if not taken.is_empty():
308
+ return {"ok": false, "error": taken}
200
309
 
201
- var tileset: TileSet = TileSet.new()
202
- var sources: Variant = args.get("sources", [])
203
- if typeof(sources) != TYPE_ARRAY:
204
- sources = []
205
-
206
- for entry: Variant in sources:
207
- if typeof(entry) != TYPE_DICTIONARY:
208
- continue
209
- var source: Dictionary = entry
210
- var atlas: TileSetAtlasSource = TileSetAtlasSource.new()
211
- var tex_path: String = _ensure_res_path(str(source.get("texture", "")))
212
- var tex: Texture2D = load(tex_path) as Texture2D
213
- if tex == null:
214
- continue
215
- atlas.texture = tex
310
+ var given: Variant = args.get("sources", [])
311
+ if not given is Array:
312
+ return {"ok": false, "error": "sources must list at least one atlas"}
313
+ var sources: Array = given
314
+ if sources.is_empty():
315
+ return {"ok": false, "error": "sources must list at least one atlas"}
216
316
 
217
- var tile_size: Dictionary = source.get("tileSize", {})
218
- atlas.texture_region_size = Vector2i(
219
- Read.as_int(tile_size.get("x", 0)), Read.as_int(tile_size.get("y", 0))
317
+ var tileset: TileSet = TileSet.new()
318
+ var made: Array = []
319
+ for index: int in sources.size():
320
+ var built: Array = _atlas(sources[index], index)
321
+ if not built[0]:
322
+ return {"ok": false, "error": built[1]}
323
+ var atlas: TileSetAtlasSource = built[1]
324
+ # The first source sets the tile set's own tile size, which is what a TileMapLayer draws
325
+ # cells at; left at its default of 16 it did not match the atlas.
326
+ if index == 0:
327
+ tileset.tile_size = atlas.texture_region_size
328
+ var added: int = tileset.add_source(atlas)
329
+ if added < 0:
330
+ return {
331
+ "ok": false,
332
+ "error": "The TileSet did not take the atlas for %s" % atlas.texture.resource_path
333
+ }
334
+ made.append(
335
+ {"sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()}
220
336
  )
221
337
 
222
- if source.has("separation"):
223
- var sep: Dictionary = source.get("separation", {})
224
- atlas.separation = Vector2i(Read.as_int(sep.get("x", 0)), Read.as_int(sep.get("y", 0)))
338
+ var saving: Error = ResourceSaver.save(tileset, tileset_path)
339
+ if saving != OK:
340
+ return {"ok": false, "error": "Failed to save %s: %s" % [tileset_path, error_string(saving)]}
225
341
 
226
- if source.has("offset"):
227
- var off: Dictionary = source.get("offset", {})
228
- atlas.margins = Vector2i(Read.as_int(off.get("x", 0)), Read.as_int(off.get("y", 0)))
342
+ _written(tileset_path)
343
+ return {"ok": true, "tilesetPath": tileset_path, "sources": made}
229
344
 
230
- var added: int = tileset.add_source(atlas)
231
- if added < 0:
232
- return {"ok": false, "error": "Failed to add a tile source", "texture": tex_path}
233
-
234
- var save_result: Error = ResourceSaver.save(tileset, tileset_path)
235
- if save_result != OK:
236
- return {"ok": false, "error": "Failed to save TileSet", "code": save_result}
237
345
 
238
- _refresh_filesystem()
239
- return {"ok": true, "tilesetPath": tileset_path}
346
+ ## One atlas source from its description, with its tiles made, as [true, source] or [false, why].
347
+ ##
348
+ ## Tiles are made in every cell of the grid that has a pixel that is not transparent, which is
349
+ ## what the editor offers to do when an atlas gets a texture. Without them the atlas held no tiles,
350
+ ## and every cell painted from it drew nothing.
351
+ func _atlas(entry: Variant, index: int) -> Array:
352
+ if not entry is Dictionary:
353
+ return [false, "sources[%d] must be an object with texture and tileSize" % index]
354
+ var source: Dictionary = entry
355
+ var texture_path: String = str(source.get("texture", ""))
356
+ if not texture_path.begins_with("res://") and not texture_path.begins_with("uid://"):
357
+ texture_path = _ensure_res_path(texture_path)
358
+ # Contained here as well as on the server: a relative path climbing out of the project became
359
+ # res://../../ and was loaded.
360
+ if texture_path.split("/").has(".."):
361
+ return [false, "sources[%d].texture leaves the project: %s" % [index, texture_path]]
362
+ if not ResourceLoader.exists(texture_path):
363
+ return [false, "sources[%d].texture: no texture at %s" % [index, texture_path]]
364
+ var texture: Texture2D = load(texture_path) as Texture2D
365
+ if texture == null:
366
+ return [false, "sources[%d].texture: %s is not a texture" % [index, texture_path]]
367
+
368
+ var sized: Array = _xy(source.get("tileSize"), "sources[%d].tileSize" % index)
369
+ if not sized[0]:
370
+ return sized
371
+ var tile_size: Vector2i = sized[1]
372
+ if tile_size.x <= 0 or tile_size.y <= 0:
373
+ return [false, "sources[%d].tileSize must be above zero on both sides" % index]
374
+
375
+ var atlas: TileSetAtlasSource = TileSetAtlasSource.new()
376
+ atlas.texture = texture
377
+ atlas.texture_region_size = tile_size
378
+ for field: String in ["separation", "offset"]:
379
+ if not source.has(field):
380
+ continue
381
+ var pair: Array = _xy(source[field], "sources[%d].%s" % [index, field])
382
+ if not pair[0]:
383
+ return pair
384
+ if field == "separation":
385
+ atlas.separation = pair[1]
386
+ else:
387
+ atlas.margins = pair[1]
388
+
389
+ var grid: Vector2i = atlas.get_atlas_grid_size()
390
+ if grid.x <= 0 or grid.y <= 0:
391
+ return [
392
+ false,
393
+ (
394
+ "sources[%d]: a %s tile does not fit in %s, which is %s"
395
+ % [index, tile_size, texture_path, texture.get_size()]
396
+ )
397
+ ]
398
+ var image: Image = texture.get_image()
399
+ for y: int in grid.y:
400
+ for x: int in grid.x:
401
+ var cell: Vector2i = Vector2i(x, y)
402
+ # Worked out here: the atlas answers a region only for a tile it already has.
403
+ var region: Rect2i = Rect2i(
404
+ atlas.margins + cell * (atlas.texture_region_size + atlas.separation),
405
+ atlas.texture_region_size
406
+ )
407
+ if image != null and _blank(image, region):
408
+ continue
409
+ atlas.create_tile(cell)
410
+ return [true, atlas]
411
+
412
+
413
+ func _blank(image: Image, region: Rect2i) -> bool:
414
+ if image.is_compressed():
415
+ return false
416
+ return image.get_region(region).is_invisible()
240
417
 
241
418
 
242
419
  ## The source ids a tile set holds, for saying what a cell could have named instead.
@@ -253,81 +430,137 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
253
430
  if scene_path == "res://":
254
431
  return {"ok": false, "error": "scenePath is required"}
255
432
 
256
- var scene_res: PackedScene = load(scene_path) as PackedScene
257
- if scene_res == null:
258
- return {"ok": false, "error": "Scene not found", "scenePath": scene_path}
259
-
260
- var root: Node = scene_res.instantiate()
261
- if root == null:
262
- return {"ok": false, "error": "Failed to instantiate scene"}
263
-
264
- var tilemap: TileMap = null
265
- if node_path == "." or node_path == "":
266
- tilemap = root as TileMap
267
- else:
268
- tilemap = root.get_node_or_null(node_path) as TileMap
269
-
270
- if tilemap == null:
271
- root.queue_free()
272
- return {"ok": false, "error": "TileMap node not found", "tilemapNodePath": node_path}
273
-
274
- var layer: int = Read.as_int(args.get("layer", 0))
275
433
  var cells: Variant = args.get("cells", [])
276
434
  # Refused rather than skipped: placing nothing and answering success is indistinguishable
277
435
  # from placing everything, and the caller only finds out by opening the scene.
278
436
  if typeof(cells) != TYPE_ARRAY:
279
- root.queue_free()
280
437
  return {"ok": false, "error": "cells must be an array of cells to place"}
281
-
282
438
  var placed: Array = cells
283
439
  if placed.is_empty():
284
- root.queue_free()
285
440
  return {"ok": false, "error": "cells is empty, so there is nothing to place"}
286
441
 
287
- # set_cell stores a cell whatever source id it is given, and a tile set without that source
288
- # simply draws nothing, so the source is checked here: the alternative is a call that reports
289
- # placing tiles and a scene that shows none.
290
- var tile_set: TileSet = tilemap.tile_set
291
- if tile_set == null:
292
- root.queue_free()
293
- return {"ok": false, "error": "The TileMap has no TileSet, so no cell can name a source"}
442
+ var scene: SceneFile = SceneFile.new()
443
+ var refused: Dictionary = scene.open(scene_path, true)
444
+ if not refused.is_empty():
445
+ return refused
294
446
 
295
- var written: int = 0
296
- for entry: Variant in placed:
297
- if typeof(entry) != TYPE_DICTIONARY:
298
- root.queue_free()
299
- return {
300
- "ok": false, "error": "every cell must be an object with coords, sourceId and atlasCoords"
301
- }
302
- var cell: Dictionary = entry
303
- var source_id: int = Read.as_int(cell.get("sourceId", -1), -1)
304
- if not tile_set.has_source(source_id):
305
- root.queue_free()
306
- return {
307
- "ok": false,
308
- "error": "The TileSet has no source %d; it has %s" % [source_id, _source_ids(tile_set)]
309
- }
447
+ var layer: int = Read.as_int(args.get("layer", 0))
448
+ var found: Array = _tile_node(scene, node_path, layer)
449
+ if found[0] == null:
450
+ return scene.refuse(str(found[1]))
451
+ var node: Node = found[0]
452
+ var layered: TileMap = node as TileMap
453
+ var single: TileMapLayer = node as TileMapLayer
454
+
455
+ # set_cell stores a cell whatever it is given, and a cell naming no tile simply draws nothing, so
456
+ # the source, the tile and its alternative are checked here: the alternative is a call that
457
+ # reports placing tiles and a scene that shows none.
458
+ var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
459
+
460
+ var wanted: Array = []
461
+ for index: int in placed.size():
462
+ var cell: Array = _cell(placed[index], index, tile_set)
463
+ if not cell[0]:
464
+ return scene.refuse(str(cell[1]))
465
+ var at: Vector2i = cell[1]
466
+ var source_id: int = cell[2]
467
+ var atlas_coords: Vector2i = cell[3]
468
+ var alternative: int = cell[4]
469
+ if layered != null:
470
+ layered.set_cell(layer, at, source_id, atlas_coords, alternative)
471
+ else:
472
+ single.set_cell(at, source_id, atlas_coords, alternative)
473
+ wanted.append([at, source_id, atlas_coords, alternative])
474
+
475
+ var at_path: String = scene.path_of(node)
476
+ var written: Dictionary = scene.write(
477
+ func(saved: Node) -> String:
478
+ var copy: Node = saved.get_node_or_null(at_path)
479
+ for entry: Array in wanted:
480
+ var at: Vector2i = entry[0]
481
+ var held: Array = _cell_at(copy, layer, at)
482
+ if held != entry.slice(1):
483
+ return "the cell at %s would load as %s, not %s." % [at, held, entry.slice(1)]
484
+ return ""
485
+ )
486
+ if not written.is_empty():
487
+ return written
310
488
 
311
- var coords: Dictionary = cell.get("coords", {})
312
- var atlas_coords: Dictionary = cell.get("atlasCoords", {})
313
- var at: Vector2i = Vector2i(Read.as_int(coords.get("x", 0)), Read.as_int(coords.get("y", 0)))
314
- tilemap.set_cell(
315
- layer,
316
- at,
317
- source_id,
318
- Vector2i(Read.as_int(atlas_coords.get("x", 0)), Read.as_int(atlas_coords.get("y", 0))),
319
- Read.as_int(cell.get("alternativeTile", 0))
320
- )
321
- if tilemap.get_cell_source_id(layer, at) != -1:
322
- written += 1
489
+ return {"ok": true, "layer": layer, "placed": wanted.size(), "requested": placed.size()}
323
490
 
324
- var save_result: Error = _save_scene_root(root, scene_path)
325
- root.queue_free()
326
- if save_result != OK:
327
- return {"ok": false, "error": "Failed to save scene", "code": save_result}
328
491
 
329
- _refresh_filesystem()
330
- return {"ok": true, "layer": layer, "placed": written, "requested": placed.size()}
492
+ ## The node at [param node_path] cells are painted on, with a TileSet, as [node, why not].
493
+ ## TileMapLayer is the node since 4.3, and TileMap the one it replaced, which keeps its layers.
494
+ func _tile_node(scene: SceneFile, node_path: String, layer: int) -> Array:
495
+ var node: Node = (
496
+ scene.root if node_path == "." or node_path == "" else scene.root.get_node_or_null(node_path)
497
+ )
498
+ if node == null:
499
+ return [null, "No node at %s in %s" % [node_path, scene.path]]
500
+ var layered: TileMap = node as TileMap
501
+ var single: TileMapLayer = node as TileMapLayer
502
+ if layered == null and single == null:
503
+ return [null, "%s is a %s, not a TileMapLayer or a TileMap" % [node_path, node.get_class()]]
504
+ var foreign: String = scene.foreign(node)
505
+ if not foreign.is_empty():
506
+ return [null, foreign]
507
+ if single != null and layer != 0:
508
+ return [null, "%s is a TileMapLayer, which is one layer; layer applies to a TileMap" % node_path]
509
+ if layered != null and (layer < 0 or layer >= layered.get_layers_count()):
510
+ return [null, "%s has %d layers, and no layer %d" % [node_path, layered.get_layers_count(), layer]]
511
+ var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
512
+ if tile_set == null:
513
+ return [null, "%s has no TileSet, so no cell can name a source" % node_path]
514
+ return [node, ""]
515
+
516
+
517
+ ## One cell from its description, checked against [param tile_set], as
518
+ ## [true, coords, source id, atlas coords, alternative] or [false, why].
519
+ func _cell(entry: Variant, index: int, tile_set: TileSet) -> Array:
520
+ if not entry is Dictionary:
521
+ return [false, "cells[%d] must be an object with coords, sourceId and atlasCoords" % index]
522
+ var cell: Dictionary = entry
523
+ for field: String in ["coords", "sourceId", "atlasCoords"]:
524
+ if not cell.has(field):
525
+ return [false, "cells[%d] has no %s" % [index, field]]
526
+ var at: Array = _xy(cell["coords"], "cells[%d].coords" % index)
527
+ if not at[0]:
528
+ return at
529
+ var atlas_at: Array = _xy(cell["atlasCoords"], "cells[%d].atlasCoords" % index)
530
+ if not atlas_at[0]:
531
+ return atlas_at
532
+ var source_id: int = Read.as_int(cell["sourceId"], -1)
533
+ if not tile_set.has_source(source_id):
534
+ return [false, "The TileSet has no source %d; it has %s" % [source_id, _source_ids(tile_set)]]
535
+ var source: TileSetSource = tile_set.get_source(source_id)
536
+ var atlas_coords: Vector2i = atlas_at[1]
537
+ var alternative: int = Read.as_int(cell.get("alternativeTile", 0))
538
+ if not source.has_tile(atlas_coords):
539
+ return [false, "Source %d has no tile at %s" % [source_id, atlas_coords]]
540
+ if not source.has_alternative_tile(atlas_coords, alternative):
541
+ return [
542
+ false,
543
+ "The tile at %s in source %d has no alternative %d" % [atlas_coords, source_id, alternative]
544
+ ]
545
+ return [true, at[1], source_id, atlas_coords, alternative]
546
+
547
+
548
+ func _cell_at(node: Node, layer: int, at: Vector2i) -> Array:
549
+ var layered: TileMap = node as TileMap
550
+ if layered != null:
551
+ return [
552
+ layered.get_cell_source_id(layer, at),
553
+ layered.get_cell_atlas_coords(layer, at),
554
+ layered.get_cell_alternative_tile(layer, at)
555
+ ]
556
+ var single: TileMapLayer = node as TileMapLayer
557
+ if single != null:
558
+ return [
559
+ single.get_cell_source_id(at),
560
+ single.get_cell_atlas_coords(at),
561
+ single.get_cell_alternative_tile(at)
562
+ ]
563
+ return []
331
564
 
332
565
 
333
566
  func set_theme_color(args: Dictionary) -> Dictionary:
@@ -355,9 +588,9 @@ func set_theme_color(args: Dictionary) -> Dictionary:
355
588
 
356
589
  var save_result: Error = ResourceSaver.save(theme, theme_path)
357
590
  if save_result != OK:
358
- return {"ok": false, "error": "Failed to save theme", "code": save_result}
591
+ return {"ok": false, "error": "Failed to save %s: %s" % [theme_path, error_string(save_result)]}
359
592
 
360
- _refresh_filesystem()
593
+ _written(theme_path)
361
594
 
362
595
  var saved: Theme = _load_theme(theme_path)
363
596
  if saved == null or not saved.has_color(color_name, control_type):
@@ -391,9 +624,9 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
391
624
 
392
625
  var save_result: Error = ResourceSaver.save(theme, theme_path)
393
626
  if save_result != OK:
394
- return {"ok": false, "error": "Failed to save theme", "code": save_result}
627
+ return {"ok": false, "error": "Failed to save %s: %s" % [theme_path, error_string(save_result)]}
395
628
 
396
- _refresh_filesystem()
629
+ _written(theme_path)
397
630
 
398
631
  var saved: Theme = _load_theme(theme_path)
399
632
  if saved == null or not saved.has_font_size(font_size_name, control_type):