[gd_scene load_steps=5 format=3 uid="uid://bf3b0i8scthbm"] [ext_resource type="Texture2D" uid="uid://bnkl8rujlgv0h" path="res://addons/SpritesheetGenerator/Checker.png" id="1_hs1uu"] [sub_resource type="GDScript" id="1"] resource_name = "Generator" script/source = "extends Control const SUPPORTED_FORMATS: PackedStringArray = [\"bmp\", \"dds\", \"exr\", \"hdr\", \"jpg\", \"jpeg\", \"png\", \"tga\", \"svg\", \"svgz\", \"webp\"] @onready var grid := %GridContainer var file_list: Array var image_list: Array var texture_list: Array var images_to_process: Array var images_to_texturize: Array var first_time := true var image_count: int var output_path: String var auto := true var margin := Vector2.ONE var pan_origin: Vector2 var pan_start: Vector2 signal images_processed func _enter_tree() -> void: $SplitDialog.hide() $StashDialog.hide() func _ready(): $Status.text = $Status.text % \", \".join(SUPPORTED_FORMATS) get_viewport().files_dropped.connect(load_files) grid.minimum_size_changed.connect(refresh_background) set_process(false) func refresh_background(): %Background.custom_minimum_size = grid.get_minimum_size() func load_files(files: PackedStringArray): file_list.clear() image_list.clear() %CustomName.text = \"\" %Reload.disabled = false %SavePNG.disabled = false if files.size() == 1 and not FileAccess.file_exists(files[0]): var dir := DirAccess.open(files[0]) if not dir: show_error(\"Can't open directory.\") return for file in dir.get_files(): if file.get_extension() in SUPPORTED_FORMATS: file_list.append(str(dir.get_current_dir().path_join(file))) else: var wrong_count: int for file in files: if file.get_extension() in SUPPORTED_FORMATS: file_list.append(file) else: wrong_count += 1 if wrong_count > 0: show_error(\"Skipped %s file(s) with unsupported extension.\" % wrong_count) if file_list.is_empty(): show_error(\"No valid files or directories to process.\") return load_images() func load_images(): texture_list.clear() for image in grid.get_children(): image.free() for image in %StashImages.get_children(): image.free() update_stash() var size_map: Dictionary if not file_list.is_empty(): image_list = file_list.map(func(file: String): var image := Image.load_from_file(file) if image: image.set_meta(&\"path\", file) return image) for image in image_list: if not image: continue if not image.get_size() in size_map: size_map[image.get_size()] = [] size_map[image.get_size()].append(image) var output_name: String var most_common_size: Vector2i var most_common_count: int for size in size_map: if size_map[size].size() > most_common_count: most_common_size = size most_common_count = size_map[size].size() for image in size_map[most_common_size]: if output_path.is_empty(): var path: String = image.get_meta(&\"path\", \"\") output_path = path.get_base_dir() output_name = path.get_base_dir().get_file() images_to_process.append(image) size_map.clear() if not output_name.is_empty() and %CustomName.text.is_empty(): %CustomName.text = output_name update_save_button() if images_to_process.size() < file_list.size(): show_error(\"Rejected %s image(s) due to size mismatch.\" % (file_list.size() - images_to_process.size())) if images_to_process.size() == 1: if file_list.size() > 1: images_to_process.clear() show_error(\"Only one dropped image was valid.\") else: %SplitPreview.texture = ImageTexture.create_from_image(images_to_process[0]) $SplitDialog.reset_size() $SplitDialog.popup_centered() return $Status.show() %CenterContainer.hide() image_count = images_to_process.size() %Columns.max_value = image_count threshold = %Threshold.value min_x = 9999999 min_y = 9999999 max_x = -9999999 max_y = -9999999 set_process(true) await images_processed for texture in texture_list: add_frame(texture) toggle_auto(auto) refresh_margin() $Status.hide() %CenterContainer.show() var threshold: float var min_x: int var min_y: int var max_x: int var max_y: int func _process(delta: float) -> void: if not images_to_process.is_empty(): var image: Image = images_to_process.pop_front() $Status.text = str(\"Preprocessing image \", image_count - images_to_process.size(), \"/\", image_count) for x in image.get_width(): for y in image.get_height(): if image.get_pixel(x, y).a >= threshold: min_x = mini(min_x, x) min_y = mini(min_y, y) max_x = maxi(max_x, x) max_y = maxi(max_y, y) images_to_texturize.append(image) elif not images_to_texturize.is_empty(): var rect := Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1) var image: Image = images_to_texturize.pop_front() $Status.text = str(\"Creating texture \", image_count - images_to_texturize.size(), \"/\", image_count) var true_image := Image.create(rect.size.x, rect.size.y, false, image.get_format()) true_image.blit_rect(image, rect, Vector2()) var texture := ImageTexture.create_from_image(true_image) texture_list.append(texture) if images_to_texturize.is_empty(): set_process(false) images_processed.emit() if first_time: recenter() first_time = false func toggle_grid(show: bool) -> void: get_tree().call_group(&\"frame\", &\"set_display_background\", show) func toggle_auto(button_pressed: bool) -> void: %Columns.editable = not button_pressed auto = button_pressed if button_pressed: var best: int var best_score = -9999999 for i in range(1, image_count + 1): var cols = i var rows = ceili(image_count / float(i)) var score = image_count - cols * rows - maxi(cols, rows) - rows if score > best_score: best = i best_score = score grid.columns = best else: grid.columns = %Columns.value refresh_grid() func hmargin_changed(value: float) -> void: margin.x = value refresh_margin() func vmargin_changed(value: float) -> void: margin.y = value refresh_margin() func refresh_margin(): get_tree().call_group(&\"frame\", &\"set_frame_margin\", margin) func columns_changed(value: float) -> void: grid.columns = value refresh_grid() func refresh_grid(): var coord: Vector2 var dark = false for rect in grid.get_children(): rect.set_background_color(Color(0, 0, 0, 0.2 if dark else 0.1)) dark = not dark coord.x += 1 if coord.x == grid.columns: coord.x = 0 coord.y += 1 dark = int(coord.y) % 2 == 1 func save_png() -> void: var image_size: Vector2 = grid.get_child(0).get_minimum_size() var image := Image.create(image_size.x * grid.columns, image_size.y * (ceil(grid.get_child_count() / float(grid.columns))), false, Image.FORMAT_RGBA8) for rect in grid.get_children(): image.blit_rect(rect.get_texture_data(), Rect2(Vector2(), image_size), rect.get_position2()) image.save_png(output_path.path_join(%CustomName.text) + \".png\") func show_error(text: String): if not %Error.visible: %Error.show() else: %Error.text += \"\\n\" %Error.text += text %Timer.start() func error_hidden() -> void: %Error.text = \"\" func _input(event: InputEvent) -> void: if event is InputEventMouseButton: var cc: Control = %CenterContainer if event.button_index == MOUSE_BUTTON_MIDDLE: if event.pressed: pan_origin = get_local_mouse_position() pan_start = cc.position else: pan_origin = Vector2() if event.button_index == MOUSE_BUTTON_WHEEL_DOWN: var lm = cc.get_local_mouse_position() cc.scale -= Vector2.ONE * 0.05 if cc.scale.x <= 0: cc.scale = Vector2.ONE * 0.05 cc.position -= (lm - cc.get_local_mouse_position()) * cc.scale elif event.button_index == MOUSE_BUTTON_WHEEL_UP: var lm = cc.get_local_mouse_position() cc.scale += Vector2.ONE * 0.05 cc.position -= (lm - cc.get_local_mouse_position()) * cc.scale if event is InputEventMouseMotion: if pan_origin != Vector2(): %CenterContainer.position = pan_start + (get_local_mouse_position() - pan_origin) func recenter() -> void: %CenterContainer.position = get_viewport().size / 2 - Vector2i(%CenterContainer.size) / 2 %CenterContainer.scale = Vector2.ONE func update_split_preview(): %SplitPreview.queue_redraw() func draw_split_preview() -> void: var preview: TextureRect = %SplitPreview var frame_count := Vector2(%SplitX.value, %SplitY.value) var frame_size := preview.size / frame_count for x in range(1, frame_count.x): for y in int(frame_count.y): preview.draw_line(frame_size * Vector2(x, y), frame_size * Vector2(x, y + 1), Color.WHITE) preview.draw_line(frame_size * Vector2(x, y) + Vector2.RIGHT, frame_size * Vector2(x, y + 1) + Vector2.RIGHT, Color.BLACK) for y in range(1, frame_count.y): for x in int(frame_count.x): preview.draw_line(frame_size * Vector2(x, y), frame_size * Vector2(x + 1, y), Color.WHITE) preview.draw_line(frame_size * Vector2(x, y) + Vector2.DOWN, frame_size * Vector2(x + 1, y) + Vector2.DOWN, Color.BLACK) func split_spritesheet() -> void: file_list.clear() image_list.clear() var image: Image = images_to_process[0] var sub_image_size := image.get_size() / Vector2i(%SplitX.value, %SplitY.value) for y in %SplitY.value: for x in %SplitX.value: image_list.append(image.get_region(Rect2i(Vector2i(x, y) * sub_image_size, sub_image_size))) images_to_process.clear() load_images() func remove_frame(frame): var image: Image = frame.get_texture_data() var texture := ImageTexture.create_from_image(image) var button := TextureButton.new() button.texture_normal = texture button.custom_minimum_size = Vector2(128, 128) button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED button.ignore_texture_size = true button.pressed.connect(re_add_image.bind(button), CONNECT_DEFERRED) %StashImages.add_child(button) var ref := ReferenceRect.new() button.add_child(ref) ref.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ref.mouse_filter = Control.MOUSE_FILTER_IGNORE ref.editor_only = false frame.free() refresh_grid() update_stash() func update_stash(): %Stash.disabled = %StashImages.get_child_count() == 0 func re_add_image(tb: TextureButton): add_frame(tb.texture_normal) tb.free() refresh_grid() update_stash() if %Stash.disabled: $StashDialog.hide() func add_frame(texture: Texture2D): var rect := preload(\"res://addons/SpritesheetGenerator/SpritesheetFrame.tscn\").instantiate() rect.set_texture(texture) rect.set_display_background(%DisplayGrid.button_pressed) rect.set_frame_margin(margin) grid.add_child(rect) func update_save_button() -> void: %SavePNG.disabled = %CustomName.text.is_empty() " [sub_resource type="StyleBoxFlat" id="5"] content_margin_left = 20.0 content_margin_top = 20.0 content_margin_right = 20.0 content_margin_bottom = 20.0 bg_color = Color(0, 0, 0, 0.25098) [sub_resource type="StyleBoxTexture" id="StyleBoxTexture_kjgn5"] texture = ExtResource("1_hs1uu") axis_stretch_horizontal = 1 axis_stretch_vertical = 1 [node name="Main" type="HBoxContainer"] anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 script = SubResource("1") [node name="MarginContainer" type="PanelContainer" parent="."] layout_mode = 2 theme_override_styles/panel = SubResource("5") [node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] layout_mode = 2 theme_override_constants/separation = 10 [node name="Label5" type="Label" parent="MarginContainer/VBoxContainer"] layout_mode = 2 text = "Alpha Threshold" horizontal_alignment = 1 [node name="Threshold" type="SpinBox" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 max_value = 1.0 step = 0.005 value = 0.9 [node name="Reload" type="Button" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 disabled = true text = "Reload" [node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer"] layout_mode = 2 [node name="Label" type="Label" parent="MarginContainer/VBoxContainer"] layout_mode = 2 text = "Columns" horizontal_alignment = 1 [node name="Columns" type="SpinBox" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 min_value = 1.0 value = 1.0 editable = false [node name="Grid" type="CheckButton" parent="MarginContainer/VBoxContainer"] layout_mode = 2 button_pressed = true text = "Auto" [node name="Label3" type="Label" parent="MarginContainer/VBoxContainer"] layout_mode = 2 text = "Horizontal Margin" horizontal_alignment = 1 [node name="MarginH" type="SpinBox" parent="MarginContainer/VBoxContainer"] layout_mode = 2 value = 1.0 suffix = "px" [node name="Label4" type="Label" parent="MarginContainer/VBoxContainer"] layout_mode = 2 text = "Vertical Margin" horizontal_alignment = 1 [node name="MarginV" type="SpinBox" parent="MarginContainer/VBoxContainer"] layout_mode = 2 max_value = 128.0 value = 1.0 suffix = "px" [node name="Stash" type="Button" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 disabled = true text = "Image Stash" [node name="HSeparator2" type="HSeparator" parent="MarginContainer/VBoxContainer"] layout_mode = 2 [node name="Button" type="Button" parent="MarginContainer/VBoxContainer"] layout_mode = 2 text = "Recenter View " [node name="DisplayGrid" type="CheckBox" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 button_pressed = true text = "Show Grid" [node name="HSeparator3" type="HSeparator" parent="MarginContainer/VBoxContainer"] layout_mode = 2 [node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] layout_mode = 2 [node name="CustomName" type="LineEdit" parent="MarginContainer/VBoxContainer/HBoxContainer"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Image Name" [node name="SavePNG" type="Button" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 disabled = true text = "Save PNG" [node name="Status" type="Label" parent="."] layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 text = "Drop folder or image files here to start. Images should be of the same size. If their sizes don't match, the generator will try to use the dominating size. The images will be automatically cropped based on the Alpha Threshold value. Greater value means more exact crop. Supported formats: %s If you drop a single image, the generator will instead edit it as spritesheet." horizontal_alignment = 1 vertical_alignment = 1 [node name="View" type="CanvasLayer" parent="."] layer = -1 [node name="CenterContainer" type="CenterContainer" parent="View"] unique_name_in_owner = true visible = false anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 size_flags_horizontal = 3 [node name="Background" type="ColorRect" parent="View/CenterContainer"] unique_name_in_owner = true layout_mode = 2 mouse_filter = 1 color = Color(0, 0.501961, 0.501961, 1) [node name="GridContainer" type="GridContainer" parent="View/CenterContainer"] unique_name_in_owner = true layout_mode = 2 theme_override_constants/h_separation = 0 theme_override_constants/v_separation = 0 columns = 3 [node name="VBoxContainer" type="GridContainer" parent="View"] anchors_preset = 3 anchor_left = 1.0 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 offset_left = -40.0 offset_top = -40.0 grow_horizontal = 0 grow_vertical = 0 mouse_filter = 2 columns = 3 [node name="Label" type="Label" parent="View/VBoxContainer"] layout_mode = 2 text = "LMB" horizontal_alignment = 1 [node name="VSeparator" type="VSeparator" parent="View/VBoxContainer"] layout_mode = 2 [node name="Label2" type="Label" parent="View/VBoxContainer"] layout_mode = 2 text = "rearrange images" [node name="Label3" type="Label" parent="View/VBoxContainer"] layout_mode = 2 text = "RMB" horizontal_alignment = 1 [node name="VSeparator2" type="VSeparator" parent="View/VBoxContainer"] layout_mode = 2 [node name="Label4" type="Label" parent="View/VBoxContainer"] layout_mode = 2 text = "delete images" [node name="Label5" type="Label" parent="View/VBoxContainer"] layout_mode = 2 text = "MMB" horizontal_alignment = 1 [node name="VSeparator3" type="VSeparator" parent="View/VBoxContainer"] layout_mode = 2 [node name="Label6" type="Label" parent="View/VBoxContainer"] layout_mode = 2 text = "pan view" [node name="CanvasLayer" type="CanvasLayer" parent="."] [node name="Error" type="Label" parent="CanvasLayer"] unique_name_in_owner = true modulate = Color(1, 0, 0, 1) anchors_preset = 12 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 offset_top = -14.0 grow_horizontal = 2 grow_vertical = 0 size_flags_vertical = 0 horizontal_alignment = 1 [node name="Timer" type="Timer" parent="CanvasLayer"] unique_name_in_owner = true wait_time = 5.0 one_shot = true [node name="SplitDialog" type="ConfirmationDialog" parent="."] title = "Edit Spritesheet" position = Vector2i(-500, 0) size = Vector2i(272, 343) visible = true [node name="VBoxContainer" type="VBoxContainer" parent="SplitDialog"] offset_left = 8.0 offset_top = 8.0 offset_right = 264.0 offset_bottom = 294.0 [node name="Label" type="Label" parent="SplitDialog/VBoxContainer"] layout_mode = 2 text = "Split Frames" horizontal_alignment = 1 [node name="HBoxContainer" type="HBoxContainer" parent="SplitDialog/VBoxContainer"] layout_mode = 2 alignment = 1 [node name="SplitX" type="SpinBox" parent="SplitDialog/VBoxContainer/HBoxContainer"] unique_name_in_owner = true layout_mode = 2 min_value = 1.0 max_value = 1000.0 value = 1.0 select_all_on_focus = true [node name="Label" type="Label" parent="SplitDialog/VBoxContainer/HBoxContainer"] layout_mode = 2 text = "x" [node name="SplitY" type="SpinBox" parent="SplitDialog/VBoxContainer/HBoxContainer"] unique_name_in_owner = true layout_mode = 2 min_value = 1.0 max_value = 1000.0 value = 1.0 select_all_on_focus = true [node name="CenterContainer" type="CenterContainer" parent="SplitDialog/VBoxContainer"] layout_mode = 2 size_flags_vertical = 3 [node name="PanelContainer" type="PanelContainer" parent="SplitDialog/VBoxContainer/CenterContainer"] layout_mode = 2 theme_override_styles/panel = SubResource("StyleBoxTexture_kjgn5") [node name="SplitPreview" type="TextureRect" parent="SplitDialog/VBoxContainer/CenterContainer/PanelContainer"] unique_name_in_owner = true layout_mode = 2 [node name="StashDialog" type="AcceptDialog" parent="."] title = "Image Stash" position = Vector2i(-500, 500) size = Vector2i(309, 100) visible = true [node name="VBoxContainer" type="VBoxContainer" parent="StashDialog"] offset_left = 8.0 offset_top = 8.0 offset_right = 301.0 offset_bottom = 51.0 [node name="Label" type="Label" parent="StashDialog/VBoxContainer"] layout_mode = 2 text = "Click frame to re-add it to spritesheet." horizontal_alignment = 1 [node name="StashImages" type="HFlowContainer" parent="StashDialog/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 [connection signal="pressed" from="MarginContainer/VBoxContainer/Reload" to="." method="load_images"] [connection signal="value_changed" from="MarginContainer/VBoxContainer/Columns" to="." method="columns_changed"] [connection signal="toggled" from="MarginContainer/VBoxContainer/Grid" to="." method="toggle_auto"] [connection signal="value_changed" from="MarginContainer/VBoxContainer/MarginH" to="." method="hmargin_changed"] [connection signal="value_changed" from="MarginContainer/VBoxContainer/MarginV" to="." method="vmargin_changed"] [connection signal="pressed" from="MarginContainer/VBoxContainer/Stash" to="StashDialog" method="popup_centered_ratio" binds= [0.5]] [connection signal="pressed" from="MarginContainer/VBoxContainer/Button" to="." method="recenter"] [connection signal="toggled" from="MarginContainer/VBoxContainer/DisplayGrid" to="." method="toggle_grid"] [connection signal="text_changed" from="MarginContainer/VBoxContainer/HBoxContainer/CustomName" to="." method="update_save_button" unbinds=1] [connection signal="pressed" from="MarginContainer/VBoxContainer/SavePNG" to="." method="save_png"] [connection signal="hidden" from="CanvasLayer/Error" to="." method="error_hidden"] [connection signal="timeout" from="CanvasLayer/Timer" to="CanvasLayer/Error" method="hide"] [connection signal="confirmed" from="SplitDialog" to="." method="split_spritesheet"] [connection signal="value_changed" from="SplitDialog/VBoxContainer/HBoxContainer/SplitX" to="." method="update_split_preview" unbinds=1] [connection signal="value_changed" from="SplitDialog/VBoxContainer/HBoxContainer/SplitY" to="." method="update_split_preview" unbinds=1] [connection signal="draw" from="SplitDialog/VBoxContainer/CenterContainer/PanelContainer/SplitPreview" to="." method="draw_split_preview"]