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.
- package/build/cli.js +1036 -525
- package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
- package/build/godot/addons/gdharness_editor/reading.gd +17 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
- package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
- package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
- package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
- package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
- package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
- package/build/godot/operations/audio_buses.gd +47 -11
- package/build/godot/operations/class_cache.gd +18 -2
- package/build/godot/operations/dependencies.gd +128 -17
- package/build/godot/operations/file_walk.gd +20 -0
- package/build/godot/operations/gdscript_analysis.gd +59 -20
- package/build/godot/operations/gdscript_authoring.gd +127 -60
- package/build/godot/operations/godot_operations.gd +24 -5
- package/build/godot/operations/import_pipeline.gd +179 -19
- package/build/godot/operations/logger.gd +2 -2
- package/build/godot/operations/plugins.gd +88 -70
- package/build/godot/operations/project_config.gd +41 -6
- package/build/godot/operations/project_diagnostics.gd +25 -10
- package/build/godot/operations/reading.gd +17 -0
- package/build/godot/operations/resource_files.gd +60 -17
- package/build/godot/operations/serialisation.gd +116 -7
- package/build/index.js +949 -435
- 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
|
-
|
|
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
|
-
##
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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": "
|
|
250
|
+
return {"ok": false, "error": "%s did not load as a resource." % res_path}
|
|
152
251
|
|
|
153
|
-
var
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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 {
|
|
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
|
|
296
|
+
return {"ok": false, "error": "Failed to write the shader file " + shader_path}
|
|
191
297
|
|
|
192
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
591
|
+
return {"ok": false, "error": "Failed to save %s: %s" % [theme_path, error_string(save_result)]}
|
|
359
592
|
|
|
360
|
-
|
|
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
|
|
627
|
+
return {"ok": false, "error": "Failed to save %s: %s" % [theme_path, error_string(save_result)]}
|
|
395
628
|
|
|
396
|
-
|
|
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):
|