diff --git a/README.md b/README.md index 58341f7..19aae41 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ Imports `.pxo` files from [Pixelorama](https://orama-interactive.itch.io/pixelor ## Status -The plugin provides two import types: +The plugin provides three import types: **Single Image**: All Cels are overlaid into one image per frame as a `1xN` StreamTexture PNG with the 2D Pixel Texture preset. **SpriteFrames**: Animation tags are used to create a SpriteFrames resource from the pxo file, which can be used with AnimatedSprite. However, due to a resource reload bug in Godot, any changes to the pxo file are not visible in the editor but by either launching the game or restarting the editor, the edited textures are visible. +**Sprite & AnimationPlayer**: Animation tags are used to create a Sprite and AnimationPlayer scene from the pxo file. + There is also an Inspector plugin which lets you open Pixelorama from within the Godot editor. When you have a .pxo file open in the Inspector, click on "Open in Pixelorama" to launch Pixelorama. It requires a one-time configuration of the location of the Pixelorama binary. Linux/BSD and Windows are supported as of now ## Usage diff --git a/addons/godot_pixelorama_importer/animation_player_import.gd b/addons/godot_pixelorama_importer/animation_player_import.gd new file mode 100644 index 0000000..845e98a --- /dev/null +++ b/addons/godot_pixelorama_importer/animation_player_import.gd @@ -0,0 +1,212 @@ +@tool +extends EditorImportPlugin + +const VISIBLE_NAME := "Sprite2D & AnimationPlayer" + +var editor: EditorInterface + + +func _init(editor_interface): + editor = editor_interface + + +func _get_importer_name() -> String: + return "com.technohacker.pixelorama.animationplayer" + + +func _get_visible_name() -> String: + return VISIBLE_NAME + + +func _get_recognized_extensions() -> PackedStringArray: + return PackedStringArray(["pxo"]) + + +func _get_save_extension() -> String: + return "tscn" + + +func _get_resource_type() -> String: + return "PackedScene" + + +func _get_import_order() -> int: + return 1 + + +func _get_import_options(path: String, _preset_index: int) -> Array: + var default_scale: Vector2 = ProjectSettings.get_setting("pixelorama/default_scale") + var default_external_save: bool = ProjectSettings.get_setting( + "pixelorama/default_animation_external_save" + ) + var default_external_save_path: String = ProjectSettings.get_setting( + "pixelorama/default_animation_external_save_path" + ) + + if default_external_save_path == "": + default_external_save_path = path.get_base_dir() + + return [ + {"name": "Sprite2D", "default_value": false, "usage": PROPERTY_USAGE_GROUP}, + {"name": "scale", "default_value": default_scale}, + {"name": "Animation", "default_value": false, "usage": PROPERTY_USAGE_GROUP}, + { + "name": "external_save", + "default_value": default_external_save, + "usage": PROPERTY_USAGE_DEFAULT | PROPERTY_USAGE_UPDATE_ALL_IF_MODIFIED + }, + { + "name": "external_save_path", + "default_value": default_external_save_path, + "property_hint": PROPERTY_HINT_DIR + }, + ] + + +func _get_option_visibility(_path: String, option_name: StringName, options: Dictionary) -> bool: + if option_name == "external_save_path" and options.has("external_save"): + return options["external_save"] + return true + + +func _get_preset_count() -> int: + return 0 + + +func _get_priority() -> float: + var default_import_type: String = ProjectSettings.get_setting("pixelorama/default_import_type") + if default_import_type == _get_visible_name(): + return 2.0 + return 1.0 + + +func _import( + source_file: String, + save_path: String, + options: Dictionary, + _platform_variants: Array[String], + gen_files: Array[String] +) -> Error: + """ + Main import function. Reads the Pixelorama project and creates the animation player resource + """ + + var spritesheet_path = "%s.spritesheet" % [save_path] + + # Open the project + var load_res = preload("./util/read_pxo_file.gd").read_pxo_file(source_file, spritesheet_path) + + if load_res.error != OK: + printerr("Project Load Error") + return load_res.error + + var project = load_res.value + + # Path to the spritesheet + spritesheet_path = "%s.ctex" % spritesheet_path + gen_files.push_back(spritesheet_path) + + # Load the spritesheet as a .stex + var spritesheet_tex = CompressedTexture2D.new() + spritesheet_tex.load(spritesheet_path) + + # create the Sprite + var sprite := Sprite2D.new() + sprite.texture = spritesheet_tex + sprite.name = source_file.get_file().get_basename() + sprite.apply_scale(options.scale) + sprite.hframes = project.frames.size() + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + + # create the AnimationPlayer + var animation_player := AnimationPlayer.new() + sprite.add_child(animation_player) + animation_player.name = "AnimationPlayer" + animation_player.owner = sprite # for PackedScene + + # add some default animations + if project.tags.size() == 0: + # No tags, put all in default + project.tags.append({"name": "default", "from": 1, "to": project.frames.size()}) + # puts a RESET track + project.tags.append({"name": "RESET", "from": 1, "to": 1}) + + var animation_library: AnimationLibrary + var animation_library_path: String + if options.external_save: + var base_dir = options.external_save_path + if base_dir == "": + base_dir = source_file.get_file().get_base_dir() + animation_library_path = ( + "%s%s-animations.tres" + % [options.external_save_path, source_file.get_file().get_basename()] + ) + if FileAccess.file_exists(animation_library_path): + # in case the AnimationLibrary is already save, try to load it + animation_library = load(animation_library_path) + else: + animation_library = AnimationLibrary.new() + animation_library.resource_path = animation_library_path + else: + animation_library = AnimationLibrary.new() + animation_player.add_animation_library("", animation_library) + + # import all animations + for tag in project.tags: + var animation: Animation + if animation_library.has_animation(tag.name): + animation = animation_library.get_animation(tag.name) + else: + animation = Animation.new() + animation_library.add_animation(tag.name, animation) + + var track_index := animation.find_track(".:frame", Animation.TYPE_VALUE) + if track_index != -1: + # track exist, remove it to add a fresh one + animation.remove_track(track_index) + + # add the track for the frame + track_index = animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(track_index, ".:frame") + animation.track_set_interpolation_loop_wrap(track_index, false) + + # insert the new animation keys + var time := 0.0 + for frame in range(tag.from - 1, tag.to): + animation.track_insert_key(track_index, time, frame) + time += 1.0 / (project.fps / project.frames[frame].duration) + + # loop handling + if ( + tag.name.begins_with("loop") + or tag.name.begins_with("cycle") + or tag.name.ends_with("loop") + or tag.name.ends_with("cycle") + ): + animation.loop_mode = Animation.LOOP_LINEAR + + # update/set the length + animation.length = time + + var err: int # Error enum + if options.external_save: + err = ResourceSaver.save(animation_library, animation_library_path) + if err != OK: + printerr("Error saving AnimationLibrary: error %s" % [err]) + return err + gen_files.push_back(animation_library_path) + + var scene = PackedScene.new() + err = scene.pack(sprite) + if err != OK: + printerr("Error creating PackedScene") + return err + + var packed_scene_path = "%s.%s" % [save_path, _get_save_extension()] + err = ResourceSaver.save(scene, packed_scene_path) + if err != OK: + printerr("Error saving PackedScene") + return err + gen_files.push_back(packed_scene_path) + + return OK diff --git a/addons/godot_pixelorama_importer/plugin.gd b/addons/godot_pixelorama_importer/plugin.gd index ed48df5..02f2335 100644 --- a/addons/godot_pixelorama_importer/plugin.gd +++ b/addons/godot_pixelorama_importer/plugin.gd @@ -1,4 +1,4 @@ -tool +@tool extends EditorPlugin var editor_settings := get_editor_interface().get_editor_settings() @@ -6,7 +6,7 @@ var editor_settings := get_editor_interface().get_editor_settings() var import_plugins -func handles(object: Object) -> bool: +func _handles(object) -> bool: # object is Variant # Find .pxo files if object is Resource && (object as Resource).resource_path.ends_with(".pxo"): return true @@ -14,34 +14,35 @@ func handles(object: Object) -> bool: return false -func edit(object: Object) -> void: +func _edit(object) -> void: # object is Variant # Safeguard if object is Resource && (object as Resource).resource_path.ends_with(".pxo"): if editor_settings.get_setting("pixelorama/path") == "": var popup = AcceptDialog.new() - popup.window_title = "No Pixelorama Binary found!" + popup.title = "No Pixelorama Binary found!" # gdlint: ignore=max-line-length popup.dialog_text = "Specify the path to the binary in the Editor Settings (Editor > Editor Settings...) under Pixelorama > Path" - popup.popup_exclusive = true - popup.set_as_minsize() + popup.exclusive = true + popup.wrap_controls = true get_editor_interface().get_base_control().add_child(popup) - popup.popup_centered_minsize() + popup.popup_centered_clamped() - yield(popup, "confirmed") + var confirmed = await popup.confirmed popup.queue_free() return - var file = File.new() var path = editor_settings.get_setting("pixelorama/path") if OS.get_name() == "OSX": path += "/Contents/MacOS/Pixelorama" - if file.open(path, File.READ): + FileAccess.open(path, FileAccess.READ) + if FileAccess.get_open_error(): push_error("Pixelorama binary could not be found") return - OS.execute(path, [ProjectSettings.globalize_path(object.resource_path)], false) + var output = [] + OS.execute(path, [ProjectSettings.globalize_path(object.resource_path)], output, false) func _enter_tree() -> void: @@ -70,8 +71,57 @@ func _enter_tree() -> void: import_plugins = [ preload("single_image_import.gd").new(), - preload("spriteframes_import.gd").new(get_editor_interface()) + preload("spriteframes_import.gd").new(get_editor_interface()), + preload("animation_player_import.gd").new(get_editor_interface()) ] + + var hint_string := [] + for plugin in import_plugins: + hint_string.append(plugin.VISIBLE_NAME) + + var property_infos = [ + { + "default": "Single Image", + "property_info": + { + "name": "pixelorama/default_import_type", + "type": TYPE_STRING, + "hint": PROPERTY_HINT_ENUM, + "hint_string": ",".join(hint_string) # "Single Image,SpriteFrames,Sprite & AnimationPlayer" + } + }, + { + "default": Vector2.ONE, + "property_info": + { + "name": "pixelorama/default_scale", + "type": TYPE_VECTOR2, + } + }, + { + "default": false, + "property_info": + { + "name": "pixelorama/default_animation_external_save", + "type": TYPE_BOOL, + } + }, + { + "default": "", + "property_info": + { + "name": "pixelorama/default_animation_external_save_path", + "type": TYPE_STRING, + "hint": PROPERTY_HINT_DIR + } + } + ] + + for pi in property_infos: + if !ProjectSettings.has_setting(pi.property_info.name): + ProjectSettings.set_setting(pi.property_info.name, pi.default) + ProjectSettings.add_property_info(pi.property_info) + for plugin in import_plugins: add_import_plugin(plugin) diff --git a/addons/godot_pixelorama_importer/single_image_import.gd b/addons/godot_pixelorama_importer/single_image_import.gd index cb44341..f24b6e6 100644 --- a/addons/godot_pixelorama_importer/single_image_import.gd +++ b/addons/godot_pixelorama_importer/single_image_import.gd @@ -1,45 +1,65 @@ -tool +@tool extends EditorImportPlugin +const VISIBLE_NAME := "Single Image" -func get_importer_name(): + +func _get_importer_name() -> String: return "com.technohacker.pixelorama" -func get_visible_name(): - return "Single Image" +func _get_visible_name() -> String: + return VISIBLE_NAME -func get_recognized_extensions(): - return ["pxo"] +func _get_recognized_extensions() -> PackedStringArray: + return PackedStringArray(["pxo"]) # We save directly to stex because ImageTexture doesn't work for some reason -func get_save_extension(): - return "stex" +func _get_save_extension() -> String: + return "ctex" + + +func _get_resource_type() -> String: + return "CompressedTexture2D" -func get_resource_type(): - return "StreamTexture" +func _get_import_order() -> int: + return 1 -func get_import_options(_preset): +func _get_import_options(_path: String, _preset_index: int) -> Array[Dictionary]: return [] -func get_option_visibility(_option, _options): +func _get_option_visibility(_path: String, _option_name: StringName, _options: Dictionary) -> bool: return true -func get_preset_count(): +func _get_preset_count() -> int: return 0 -func import(source_file, save_path, _options, _r_platform_variants, _r_gen_files): +func _get_priority() -> float: + var default_import_type: String = ProjectSettings.get_setting("pixelorama/default_import_type") + if default_import_type == _get_visible_name(): + return 2.0 + return 1.0 + + +func _import( + source_file: String, + save_path: String, + _options: Dictionary, + _platform_variants: Array[String], + _gen_files: Array[String] +) -> Error: """ Main import function. Reads the Pixelorama project and extracts the PNG image from it """ # Open the project var load_res = preload("./util/read_pxo_file.gd").read_pxo_file(source_file, save_path) + return load_res.error diff --git a/addons/godot_pixelorama_importer/spriteframes_import.gd b/addons/godot_pixelorama_importer/spriteframes_import.gd index 50b2cbe..ba2a6b0 100644 --- a/addons/godot_pixelorama_importer/spriteframes_import.gd +++ b/addons/godot_pixelorama_importer/spriteframes_import.gd @@ -1,47 +1,66 @@ -tool +@tool extends EditorImportPlugin +const VISIBLE_NAME := "SpriteFrames" + var editor: EditorInterface -func _init(editor_interface): +func _init(editor_interface: EditorInterface): editor = editor_interface -func get_importer_name(): +func _get_importer_name() -> String: return "com.technohacker.pixelorama.spriteframe" -func get_visible_name(): - return "SpriteFrames" +func _get_visible_name() -> String: + return VISIBLE_NAME -func get_recognized_extensions(): - return ["pxo"] +func _get_recognized_extensions() -> PackedStringArray: + return PackedStringArray(["pxo"]) -# We save directly to stex because ImageTexture doesn't work for some reason -func get_save_extension(): +# We save directly to ctex because ImageTexture doesn't work for some reason +func _get_save_extension() -> String: return "tres" -func get_resource_type(): +func _get_resource_type() -> String: return "SpriteFrames" -func get_import_options(_preset): - return [{"name": "animation_fps", "default_value": 6}] +func _get_import_order() -> int: + return 1 + +func _get_import_options(_path: String, _preset_index: int) -> Array[Dictionary]: + return [] -func get_option_visibility(_option, _options): + +func _get_option_visibility(_path: String, _option_name: StringName, _options: Dictionary) -> bool: return true -func get_preset_count(): +func _get_preset_count() -> int: return 0 -func import(source_file, save_path, options, _r_platform_variants, r_gen_files): +func _get_priority() -> float: + var default_import_type: String = ProjectSettings.get_setting("pixelorama/default_import_type") + if default_import_type == _get_visible_name(): + return 2.0 + return 1.0 + + +func _import( + source_file: String, + save_path: String, + _options: Dictionary, + _platform_variants: Array[String], + gen_files: Array[String] +) -> Error: """ Main import function. Reads the Pixelorama project and creates the SpriteFrames resource """ @@ -58,11 +77,11 @@ func import(source_file, save_path, options, _r_platform_variants, r_gen_files): var project = load_res.value # Note the spritesheet - spritesheet_path = "%s.stex" % spritesheet_path - r_gen_files.push_back(spritesheet_path) + spritesheet_path = "%s.ctex" % spritesheet_path + gen_files.push_back(spritesheet_path) - # Load the spritesheet as a .stex - var spritesheet_tex = StreamTexture.new() + # Load the spritesheet as a .ctex + var spritesheet_tex = CompressedTexture2D.new() spritesheet_tex.load(spritesheet_path) # Create the frames @@ -78,21 +97,19 @@ func import(source_file, save_path, options, _r_platform_variants, r_gen_files): for tag in project.tags: frames.add_animation(tag.name) - frames.set_animation_speed(tag.name, options.animation_fps) + frames.set_animation_speed(tag.name, project.fps) for frame in range(tag.from, tag.to + 1): - var image_rect := Rect2(Vector2((frame - 1) * frame_size.x, 0), frame_size) + var image_rect := Rect2i(Vector2((frame - 1) * frame_size.x, 0), frame_size) var image := Image.new() - image = spritesheet_tex.get_data().get_rect(image_rect) - var image_texture := ImageTexture.new() - image_texture.create_from_image(image, 0) + image = spritesheet_tex.get_image().get_region(image_rect) + var image_texture := ImageTexture.create_from_image(image) #,0 frames.add_frame(tag.name, image_texture) - var err = ResourceSaver.save("%s.%s" % [save_path, get_save_extension()], frames) + var err = ResourceSaver.save(frames, "%s.%s" % [save_path, _get_save_extension()]) if err != OK: printerr("Error saving SpriteFrames") return err - editor.get_inspector().refresh() return OK diff --git a/addons/godot_pixelorama_importer/util/read_pxo_file.gd b/addons/godot_pixelorama_importer/util/read_pxo_file.gd index bab17dc..9525074 100644 --- a/addons/godot_pixelorama_importer/util/read_pxo_file.gd +++ b/addons/godot_pixelorama_importer/util/read_pxo_file.gd @@ -7,21 +7,21 @@ static func read_pxo_file(source_file: String, image_save_path: String): var result = Result.new() # Open the Pixelorama project file - var file = File.new() - var err = file.open_compressed(source_file, File.READ, File.COMPRESSION_ZSTD) - if err != OK: - file.open(source_file, File.READ) + var file = FileAccess.open_compressed(source_file, FileAccess.READ, FileAccess.COMPRESSION_ZSTD) + if FileAccess.get_open_error() != OK: + file = FileAccess.open(source_file, FileAccess.READ) # Parse it as JSON var text = file.get_line() - var json = JSON.parse(text) + var test_json_conv = JSON.new() + var json_error = test_json_conv.parse(text) - if json.error != OK: + if json_error != OK: printerr("JSON Parse Error") - result.error = json.error + result.error = json_error return result - var project = json.result + var project = test_json_conv.get_data() # Make sure it's a JSON Object if typeof(project) != TYPE_DICTIONARY: @@ -34,8 +34,7 @@ static func read_pxo_file(source_file: String, image_save_path: String): var frame_count = project.frames.size() # Prepare the spritesheet image - var spritesheet = Image.new() - spritesheet.create(size.x * frame_count, size.y, false, Image.FORMAT_RGBA8) + var spritesheet = Image.create(size.x * frame_count, size.y, false, Image.FORMAT_RGBA8) var cel_data_size: int = size.x * size.y * 4 @@ -50,19 +49,16 @@ static func read_pxo_file(source_file: String, image_save_path: String): if project.layers[layer].visible and opacity > 0.0: # Load the cel image - var cel_img := Image.new() - cel_img.create_from_data( + var cel_img = Image.create_from_data( size.x, size.y, false, Image.FORMAT_RGBA8, file.get_buffer(cel_data_size) ) if opacity < 1.0: - cel_img.lock() for x in range(size.x): for y in range(size.y): var color := cel_img.get_pixel(x, y) color.a *= opacity cel_img.set_pixel(x, y, color) - cel_img.unlock() if frame_img == null: frame_img = cel_img @@ -79,43 +75,49 @@ static func read_pxo_file(source_file: String, image_save_path: String): # Add to the spritesheet spritesheet.blit_rect(frame_img, Rect2(Vector2.ZERO, size), Vector2(size.x * i, 0)) - save_stex(spritesheet, image_save_path) + save_ctex(spritesheet, image_save_path) result.value = project result.error = OK return result -# Taken from https://github.com/lifelike/godot-animator-import -static func save_stex(image, save_path): - var tmppng = "%s-tmp.png" % [save_path] - image.save_png(tmppng) - var pngf = File.new() - pngf.open(tmppng, File.READ) - var pnglen = pngf.get_len() - var pngdata = pngf.get_buffer(pnglen) - pngf.close() - Directory.new().remove(tmppng) - - var stexf = File.new() - stexf.open("%s.stex" % [save_path], File.WRITE) - stexf.store_8(0x47) # G - stexf.store_8(0x44) # D - stexf.store_8(0x53) # S - stexf.store_8(0x54) # T - stexf.store_32(image.get_width()) - stexf.store_32(image.get_height()) - stexf.store_32(0) # flags: Disable all of it as we're dealing with pixel-perfect images - stexf.store_32(0x07100000) # data format - stexf.store_32(1) # nr mipmaps - stexf.store_32(pnglen + 6) - stexf.store_8(0x50) # P - stexf.store_8(0x4e) # N - stexf.store_8(0x47) # G - stexf.store_8(0x20) # space - stexf.store_buffer(pngdata) - stexf.close() - - print("stex saved") +# Based on CompressedTexture2D::_load_data from +# https://github.com/godotengine/godot/blob/master/scene/resources/texture.cpp +static func save_ctex(image, save_path: String): + var tmpwebp = "%s-tmp.webp" % [save_path] + image.save_webp(tmpwebp) # not quite sure, but the png import that I tested was in webp + + var webpf = FileAccess.open(tmpwebp, FileAccess.READ) + var webplen = webpf.get_length() + var webpdata = webpf.get_buffer(webplen) + webpf = null # setting null will close the file + + var dir := DirAccess.open(tmpwebp.get_base_dir()) + dir.remove(tmpwebp.get_file()) + + var ctexf = FileAccess.open("%s.ctex" % [save_path], FileAccess.WRITE) + ctexf.store_8(0x47) # G + ctexf.store_8(0x53) # S + ctexf.store_8(0x54) # T + ctexf.store_8(0x32) # 2 + ctexf.store_32(0x01) # FORMAT_VERSION + ctexf.store_32(image.get_width()) + ctexf.store_32(image.get_height()) + ctexf.store_32(0xD000000) # data format (?) + ctexf.store_32(0xFFFFFFFF) # mipmap_limit + ctexf.store_32(0x0) # reserved + ctexf.store_32(0x0) # reserved + ctexf.store_32(0x0) # reserved + ctexf.store_32(0x02) # data format (WEBP, it's DataFormat enum but not available in gdscript) + ctexf.store_16(image.get_width()) # w + ctexf.store_16(image.get_height()) # h + ctexf.store_32(0x00) # mipmaps + ctexf.store_32(Image.FORMAT_RGBA8) # format + ctexf.store_32(webplen) # webp length + ctexf.store_buffer(webpdata) + ctexf = null # setting null will close the file + + print("ctex saved") return OK