diff --git a/Translations/Translations.pot b/Translations/Translations.pot index e88a43a1f..948144197 100644 --- a/Translations/Translations.pot +++ b/Translations/Translations.pot @@ -2239,6 +2239,10 @@ msgstr "" msgid "Group" msgstr "" +#. A tilemap is a type of layer, which is divided by grid cells, the size of which is determined by the tileset it uses. Each grid cell is mapped to a tile in the tileset. Tilemaps can be used to create game levels and layouts. +msgid "Tilemap" +msgstr "" + msgid "Layers" msgstr "" @@ -2247,33 +2251,47 @@ msgid "Clipping mask" msgstr "" #. Hint tooltip of the create new layer button, found on the left side of the timeline. +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Create a new layer" msgstr "" #. One of the options of the create new layer button. +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Add Pixel Layer" msgstr "" #. One of the options of the create new layer button. +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Add Group Layer" msgstr "" #. One of the options of the create new layer button. +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Add 3D Layer" msgstr "" +#. One of the options of the create new layer button. +#: src/UI/Timeline/AnimationTimeline.tscn +msgid "Add Tilemap Layer" +msgstr "" + +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Remove current layer" msgstr "" +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Move up the current layer" msgstr "" +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Move down the current layer" msgstr "" +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Clone current layer" msgstr "" +#: src/UI/Timeline/AnimationTimeline.tscn msgid "Merge current layer with the one below" msgstr "" @@ -2960,6 +2978,10 @@ msgstr "" msgid "Recorder" msgstr "" +#. Tiles are images of a specific shape, usually rectangular, that are laid out in a grid. They are used in tile-based video games. https://en.wikipedia.org/wiki/Tile-based_video_game +msgid "Tiles" +msgstr "" + msgid "Crop" msgstr "" @@ -3361,3 +3383,51 @@ msgstr "" #. Text from a confirmation dialog that appears when the user is attempting to drag and drop an image directly from the browser into Pixelorama. msgid "Do you want to download the image from %s?" msgstr "" + +#. A tileset is a collection of tiles. +#: src/Classes/TileSetCustom.gd +#: src/UI/Dialogs/ImportPreviewDialog.gd +msgid "Tileset" +msgstr "" + +#. A tileset is a collection of tiles. +#: src/UI/Timeline/NewTileMapLayerDialog.tscn +msgid "Tileset:" +msgstr "" + +#. A tileset is a collection of tiles. +#: src/UI/Dialogs/ProjectProperties.tscn +msgid "Tilesets" +msgstr "" + +#: src/UI/Timeline/NewTileMapLayerDialog.tscn +msgid "New tileset" +msgstr "" + +#: src/UI/Timeline/NewTileMapLayerDialog.tscn +msgid "Tileset name:" +msgstr "" + +#: src/UI/Timeline/NewTileMapLayerDialog.tscn +msgid "Tile size:" +msgstr "" + +#: src/UI/TilesPanel.tscn +msgid "Draw tiles" +msgstr "" + +#: src/UI/TilesPanel.tscn +msgid "Rotate tile left (counterclockwise)" +msgstr "" + +#: src/UI/TilesPanel.tscn +msgid "Rotate tile right (clockwise)" +msgstr "" + +#: src/UI/TilesPanel.tscn +msgid "Flip tile horizontally" +msgstr "" + +#: src/UI/TilesPanel.tscn +msgid "Flip tile vertically" +msgstr "" diff --git a/assets/graphics/misc/mirror_x.svg b/assets/graphics/misc/mirror_x.svg new file mode 100644 index 000000000..c73996a39 --- /dev/null +++ b/assets/graphics/misc/mirror_x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/graphics/misc/mirror_x.svg.import b/assets/graphics/misc/mirror_x.svg.import new file mode 100644 index 000000000..96239f95f --- /dev/null +++ b/assets/graphics/misc/mirror_x.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpsfilx47bw3r" +path="res://.godot/imported/mirror_x.svg-16a0646fb607af92a2ccf231dd0f1d98.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/graphics/misc/mirror_x.svg" +dest_files=["res://.godot/imported/mirror_x.svg-16a0646fb607af92a2ccf231dd0f1d98.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/graphics/misc/mirror_y.svg b/assets/graphics/misc/mirror_y.svg new file mode 100644 index 000000000..7f8787231 --- /dev/null +++ b/assets/graphics/misc/mirror_y.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/graphics/misc/mirror_y.svg.import b/assets/graphics/misc/mirror_y.svg.import new file mode 100644 index 000000000..eef8847d9 --- /dev/null +++ b/assets/graphics/misc/mirror_y.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bk6iaxiyl74ih" +path="res://.godot/imported/mirror_y.svg-47cb90f0f94e4ed7c37f151a9ddbaab0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/graphics/misc/mirror_y.svg" +dest_files=["res://.godot/imported/mirror_y.svg-47cb90f0f94e4ed7c37f151a9ddbaab0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/layouts/Default.tres b/assets/layouts/Default.tres index 6f753dda1..119610248 100644 --- a/assets/layouts/Default.tres +++ b/assets/layouts/Default.tres @@ -1,30 +1,44 @@ -[gd_resource type="Resource" script_class="DockableLayout" load_steps=27 format=3 uid="uid://4xtpiowddm7p"] +[gd_resource type="Resource" script_class="DockableLayout" load_steps=29 format=3 uid="uid://4xtpiowddm7p"] -[ext_resource type="Script" path="res://addons/dockable_container/layout_panel.gd" id="1"] -[ext_resource type="Script" path="res://addons/dockable_container/layout_split.gd" id="2"] -[ext_resource type="Script" path="res://addons/dockable_container/layout.gd" id="3"] +[ext_resource type="Script" path="res://addons/dockable_container/layout_panel.gd" id="1_jxh43"] +[ext_resource type="Script" path="res://addons/dockable_container/layout_split.gd" id="2_lw52w"] +[ext_resource type="Script" path="res://addons/dockable_container/layout.gd" id="3_4h5wj"] [sub_resource type="Resource" id="Resource_atmme"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Tools") current_tab = 0 +[sub_resource type="Resource" id="Resource_4b0py"] +resource_name = "Tabs" +script = ExtResource("1_jxh43") +names = PackedStringArray("Tiles") +current_tab = 0 + +[sub_resource type="Resource" id="Resource_epagr"] +resource_name = "Split" +script = ExtResource("2_lw52w") +direction = 1 +percent = 0.5 +first = SubResource("Resource_atmme") +second = SubResource("Resource_4b0py") + [sub_resource type="Resource" id="Resource_ouvfk"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Main Canvas") current_tab = 0 [sub_resource type="Resource" id="Resource_an0ef"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Perspective Editor") current_tab = 0 [sub_resource type="Resource" id="Resource_xgnjk"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 0 percent = 0.5 first = SubResource("Resource_ouvfk") @@ -32,13 +46,13 @@ second = SubResource("Resource_an0ef") [sub_resource type="Resource" id="Resource_o7cqb"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Second Canvas") current_tab = 0 [sub_resource type="Resource" id="Resource_ataha"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 0 percent = 0.980952 first = SubResource("Resource_xgnjk") @@ -46,13 +60,13 @@ second = SubResource("Resource_o7cqb") [sub_resource type="Resource" id="Resource_8y4au"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Animation Timeline") current_tab = 0 [sub_resource type="Resource" id="Resource_q2jwk"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 1 percent = 0.75578 first = SubResource("Resource_ataha") @@ -60,19 +74,19 @@ second = SubResource("Resource_8y4au") [sub_resource type="Resource" id="Resource_5r0ap"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Canvas Preview") current_tab = 0 [sub_resource type="Resource" id="Resource_6pqxe"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Recorder") current_tab = 0 [sub_resource type="Resource" id="Resource_ln20x"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 1 percent = 0.911765 first = SubResource("Resource_5r0ap") @@ -80,39 +94,39 @@ second = SubResource("Resource_6pqxe") [sub_resource type="Resource" id="Resource_dksrd"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Global Tool Options") current_tab = 0 [sub_resource type="Resource" id="Resource_kmey0"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Color Picker", "Reference Images") current_tab = 0 [sub_resource type="Resource" id="Resource_1tm61"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 1 -percent = 0.134307 +percent = 0.0499712 first = SubResource("Resource_dksrd") second = SubResource("Resource_kmey0") [sub_resource type="Resource" id="Resource_btl4b"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Left Tool Options") current_tab = 0 [sub_resource type="Resource" id="Resource_eu0mc"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Right Tool Options") current_tab = 0 [sub_resource type="Resource" id="Resource_8ff4m"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 0 percent = 0.5 first = SubResource("Resource_btl4b") @@ -120,21 +134,21 @@ second = SubResource("Resource_eu0mc") [sub_resource type="Resource" id="Resource_e72nu"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 1 -percent = 0.660142 +percent = 0.643859 first = SubResource("Resource_1tm61") second = SubResource("Resource_8ff4m") [sub_resource type="Resource" id="Resource_sg54a"] resource_name = "Tabs" -script = ExtResource("1") +script = ExtResource("1_jxh43") names = PackedStringArray("Palettes") current_tab = 0 [sub_resource type="Resource" id="Resource_gdwmg"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 1 percent = 0.82948 first = SubResource("Resource_e72nu") @@ -142,7 +156,7 @@ second = SubResource("Resource_sg54a") [sub_resource type="Resource" id="Resource_acda3"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 1 percent = 0.0549133 first = SubResource("Resource_ln20x") @@ -150,30 +164,31 @@ second = SubResource("Resource_gdwmg") [sub_resource type="Resource" id="Resource_2qk0j"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 0 -percent = 0.731967 +percent = 0.704098 first = SubResource("Resource_q2jwk") second = SubResource("Resource_acda3") [sub_resource type="Resource" id="Resource_msuil"] resource_name = "Split" -script = ExtResource("2") +script = ExtResource("2_lw52w") direction = 0 percent = 0.0 -first = SubResource("Resource_atmme") +first = SubResource("Resource_epagr") second = SubResource("Resource_2qk0j") [resource] resource_name = "Default" -script = ExtResource("3") +script = ExtResource("3_4h5wj") root = SubResource("Resource_msuil") hidden_tabs = { "Canvas Preview": true, "Color Picker Sliders": true, "Perspective Editor": true, "Recorder": true, -"Second Canvas": true +"Second Canvas": true, +"Tiles": true } windows = {} save_on_change = false diff --git a/assets/layouts/Tallscreen.tres b/assets/layouts/Tallscreen.tres index cf1252892..b1d96489b 100644 --- a/assets/layouts/Tallscreen.tres +++ b/assets/layouts/Tallscreen.tres @@ -1,24 +1,24 @@ -[gd_resource type="Resource" script_class="DockableLayout" load_steps=23 format=3 uid="uid://brcnmadkdaqok"] +[gd_resource type="Resource" script_class="DockableLayout" load_steps=25 format=3 uid="uid://brcnmadkdaqok"] -[ext_resource type="Script" path="res://addons/dockable_container/layout_panel.gd" id="1_nokpu"] -[ext_resource type="Script" path="res://addons/dockable_container/layout_split.gd" id="2_q5vl6"] -[ext_resource type="Script" path="res://addons/dockable_container/layout.gd" id="3_ox7l5"] +[ext_resource type="Script" path="res://addons/dockable_container/layout_panel.gd" id="1_t44r1"] +[ext_resource type="Script" path="res://addons/dockable_container/layout_split.gd" id="2_rngtv"] +[ext_resource type="Script" path="res://addons/dockable_container/layout.gd" id="3_v86xb"] [sub_resource type="Resource" id="Resource_kn4x4"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Main Canvas") current_tab = 0 [sub_resource type="Resource" id="Resource_btw27"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Second Canvas") current_tab = 0 [sub_resource type="Resource" id="Resource_bp28t"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 0 percent = 0.829091 first = SubResource("Resource_kn4x4") @@ -26,13 +26,13 @@ second = SubResource("Resource_btw27") [sub_resource type="Resource" id="Resource_10g0s"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Perspective Editor") current_tab = 0 [sub_resource type="Resource" id="Resource_otntk"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 0 percent = 0.8625 first = SubResource("Resource_bp28t") @@ -40,25 +40,25 @@ second = SubResource("Resource_10g0s") [sub_resource type="Resource" id="Resource_12axs"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Tools") current_tab = 0 [sub_resource type="Resource" id="Resource_1omiw"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Left Tool Options", "Right Tool Options") current_tab = 0 [sub_resource type="Resource" id="Resource_p32ds"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Color Picker") current_tab = 0 [sub_resource type="Resource" id="Resource_n6xyc"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 0 percent = 0.5 first = SubResource("Resource_1omiw") @@ -66,19 +66,19 @@ second = SubResource("Resource_p32ds") [sub_resource type="Resource" id="Resource_1dcep"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Canvas Preview", "Reference Images", "Recorder") current_tab = 0 [sub_resource type="Resource" id="Resource_hc3ve"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Global Tool Options") current_tab = 0 [sub_resource type="Resource" id="Resource_nppps"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 1 percent = 0.729839 first = SubResource("Resource_1dcep") @@ -86,13 +86,13 @@ second = SubResource("Resource_hc3ve") [sub_resource type="Resource" id="Resource_d54jb"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Palettes") current_tab = 0 [sub_resource type="Resource" id="Resource_f6rik"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 0 percent = 0.5 first = SubResource("Resource_nppps") @@ -100,7 +100,7 @@ second = SubResource("Resource_d54jb") [sub_resource type="Resource" id="Resource_26vov"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 0 percent = 0.501251 first = SubResource("Resource_n6xyc") @@ -108,21 +108,35 @@ second = SubResource("Resource_f6rik") [sub_resource type="Resource" id="Resource_m3axb"] resource_name = "Tabs" -script = ExtResource("1_nokpu") +script = ExtResource("1_t44r1") names = PackedStringArray("Animation Timeline") current_tab = 0 +[sub_resource type="Resource" id="Resource_8dhxy"] +resource_name = "Tabs" +script = ExtResource("1_t44r1") +names = PackedStringArray("Tiles") +current_tab = 0 + +[sub_resource type="Resource" id="Resource_j3q3h"] +resource_name = "Split" +script = ExtResource("2_rngtv") +direction = 0 +percent = 0.5 +first = SubResource("Resource_m3axb") +second = SubResource("Resource_8dhxy") + [sub_resource type="Resource" id="Resource_af0bk"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 1 percent = 0.5 first = SubResource("Resource_26vov") -second = SubResource("Resource_m3axb") +second = SubResource("Resource_j3q3h") [sub_resource type="Resource" id="Resource_1xpva"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 0 percent = 0.03125 first = SubResource("Resource_12axs") @@ -130,7 +144,7 @@ second = SubResource("Resource_af0bk") [sub_resource type="Resource" id="Resource_6dytr"] resource_name = "Split" -script = ExtResource("2_q5vl6") +script = ExtResource("2_rngtv") direction = 1 percent = 0.459538 first = SubResource("Resource_otntk") @@ -138,12 +152,13 @@ second = SubResource("Resource_1xpva") [resource] resource_name = "Tallscreen" -script = ExtResource("3_ox7l5") +script = ExtResource("3_v86xb") root = SubResource("Resource_6dytr") hidden_tabs = { "Perspective Editor": true, "Recorder": true, -"Second Canvas": true +"Second Canvas": true, +"Tiles": true } windows = {} save_on_change = false diff --git a/project.godot b/project.godot index 6c4397d44..aa3a195ef 100644 --- a/project.godot +++ b/project.godot @@ -908,7 +908,7 @@ previous_project={ } center_canvas={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":67,"physical_keycode":0,"key_label":0,"unicode":67,"location":0,"echo":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":66,"physical_keycode":0,"key_label":0,"unicode":66,"location":0,"echo":false,"script":null) ] } left_text_tool={ @@ -925,6 +925,46 @@ show_pixel_indices={ "deadzone": 0.5, "events": [] } +toggle_draw_tiles_mode={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +tile_edit_mode_manual={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":33,"location":0,"echo":false,"script":null) +] +} +tile_edit_mode_auto={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":64,"location":0,"echo":false,"script":null) +] +} +tile_edit_mode_stack={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":35,"location":0,"echo":false,"script":null) +] +} +tile_rotate_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":90,"key_label":0,"unicode":90,"location":0,"echo":false,"script":null) +] +} +tile_rotate_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":88,"location":0,"echo":false,"script":null) +] +} +tile_flip_horizontal={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":67,"location":0,"echo":false,"script":null) +] +} +tile_flip_vertical={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":86,"key_label":0,"unicode":86,"location":0,"echo":false,"script":null) +] +} [input_devices] diff --git a/src/Autoload/DrawingAlgos.gd b/src/Autoload/DrawingAlgos.gd index 6a668fea8..97f2c0f83 100644 --- a/src/Autoload/DrawingAlgos.gd +++ b/src/Autoload/DrawingAlgos.gd @@ -535,9 +535,11 @@ func center(indices: Array) -> void: tmp_centered.blend_rect(cel.image, used_rect, offset) var centered := ImageExtended.new() centered.copy_from_custom(tmp_centered, cel_image.is_indexed) + if cel is CelTileMap: + (cel as CelTileMap).serialize_undo_data_source_image(centered, redo_data, undo_data) centered.add_data_to_dictionary(redo_data, cel_image) cel_image.add_data_to_dictionary(undo_data) - Global.undo_redo_compress_images(redo_data, undo_data) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true)) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) project.undo_redo.commit_action() @@ -546,15 +548,15 @@ func center(indices: Array) -> void: func scale_project(width: int, height: int, interpolation: int) -> void: var redo_data := {} var undo_data := {} - for f in Global.current_project.frames: - for i in range(f.cels.size() - 1, -1, -1): - var cel := f.cels[i] - if not cel is PixelCel: - continue - var cel_image := (cel as PixelCel).get_image() - var sprite := _resize_image(cel_image, width, height, interpolation) as ImageExtended - sprite.add_data_to_dictionary(redo_data, cel_image) - cel_image.add_data_to_dictionary(undo_data) + for cel in Global.current_project.get_all_pixel_cels(): + if not cel is PixelCel: + continue + var cel_image := (cel as PixelCel).get_image() + var sprite := _resize_image(cel_image, width, height, interpolation) as ImageExtended + if cel is CelTileMap: + (cel as CelTileMap).serialize_undo_data_source_image(sprite, redo_data, undo_data) + sprite.add_data_to_dictionary(redo_data, cel_image) + cel_image.add_data_to_dictionary(undo_data) general_do_and_undo_scale(width, height, redo_data, undo_data) @@ -596,9 +598,9 @@ func _resize_image( func crop_to_selection() -> void: if not Global.current_project.has_selection: return + Global.canvas.selection.transform_content_confirm() var redo_data := {} var undo_data := {} - Global.canvas.selection.transform_content_confirm() var rect: Rect2i = Global.canvas.selection.big_bounding_rectangle # Loop through all the cels to crop them for cel in Global.current_project.get_all_pixel_cels(): @@ -606,6 +608,8 @@ func crop_to_selection() -> void: var tmp_cropped := cel_image.get_region(rect) var cropped := ImageExtended.new() cropped.copy_from_custom(tmp_cropped, cel_image.is_indexed) + if cel is CelTileMap: + (cel as CelTileMap).serialize_undo_data_source_image(cropped, redo_data, undo_data) cropped.add_data_to_dictionary(redo_data, cel_image) cel_image.add_data_to_dictionary(undo_data) @@ -617,18 +621,17 @@ func crop_to_selection() -> void: func crop_to_content() -> void: Global.canvas.selection.transform_content_confirm() var used_rect := Rect2i() - for f in Global.current_project.frames: - for cel in f.cels: - if not cel is PixelCel: - continue - var cel_used_rect := cel.get_image().get_used_rect() - if cel_used_rect == Rect2i(0, 0, 0, 0): # If the cel has no content - continue + for cel in Global.current_project.get_all_pixel_cels(): + if not cel is PixelCel: + continue + var cel_used_rect := cel.get_image().get_used_rect() + if cel_used_rect == Rect2i(0, 0, 0, 0): # If the cel has no content + continue - if used_rect == Rect2i(0, 0, 0, 0): # If we still haven't found the first cel with content - used_rect = cel_used_rect - else: - used_rect = used_rect.merge(cel_used_rect) + if used_rect == Rect2i(0, 0, 0, 0): # If we still haven't found the first cel with content + used_rect = cel_used_rect + else: + used_rect = used_rect.merge(cel_used_rect) # If no layer has any content, just return if used_rect == Rect2i(0, 0, 0, 0): @@ -644,6 +647,8 @@ func crop_to_content() -> void: var tmp_cropped := cel_image.get_region(used_rect) var cropped := ImageExtended.new() cropped.copy_from_custom(tmp_cropped, cel_image.is_indexed) + if cel is CelTileMap: + (cel as CelTileMap).serialize_undo_data_source_image(cropped, redo_data, undo_data) cropped.add_data_to_dictionary(redo_data, cel_image) cel_image.add_data_to_dictionary(undo_data) @@ -662,6 +667,8 @@ func resize_canvas(width: int, height: int, offset_x: int, offset_y: int) -> voi cel_image, Rect2i(Vector2i.ZERO, cel_image.get_size()), Vector2i(offset_x, offset_y) ) resized.convert_rgb_to_indexed() + if cel is CelTileMap: + (cel as CelTileMap).serialize_undo_data_source_image(resized, redo_data, undo_data) resized.add_data_to_dictionary(redo_data, cel_image) cel_image.add_data_to_dictionary(undo_data) @@ -698,7 +705,7 @@ func general_do_and_undo_scale( project.undo_redo.add_do_property(project, "y_symmetry_point", new_y_symmetry_point) project.undo_redo.add_do_property(project.x_symmetry_axis, "points", new_x_symmetry_axis_points) project.undo_redo.add_do_property(project.y_symmetry_axis, "points", new_y_symmetry_axis_points) - Global.undo_redo_compress_images(redo_data, undo_data) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_undo_property(project, "size", project.size) project.undo_redo.add_undo_property(project, "x_symmetry_point", project.x_symmetry_point) project.undo_redo.add_undo_property(project, "y_symmetry_point", project.y_symmetry_point) diff --git a/src/Autoload/Global.gd b/src/Autoload/Global.gd index 8a14d3b56..295ec1457 100644 --- a/src/Autoload/Global.gd +++ b/src/Autoload/Global.gd @@ -14,7 +14,7 @@ signal cel_switched ## Emitted whenever you select a different cel. signal project_data_changed(project: Project) ## Emitted when project data is modified. signal font_loaded ## Emitted when a new font has been loaded, or an old one gets unloaded. -enum LayerTypes { PIXEL, GROUP, THREE_D } +enum LayerTypes { PIXEL, GROUP, THREE_D, TILEMAP } enum GridTypes { CARTESIAN, ISOMETRIC, ALL } ## ## Used to tell whether a color is being taken from the current theme, ## or if it is a custom color. @@ -897,12 +897,17 @@ func _initialize_keychain() -> void: &"reference_rotate": Keychain.InputAction.new("", "Reference images", false), &"reference_scale": Keychain.InputAction.new("", "Reference images", false), &"reference_quick_menu": Keychain.InputAction.new("", "Reference images", false), - &"cancel_reference_transform": Keychain.InputAction.new("", "Reference images", false) + &"cancel_reference_transform": Keychain.InputAction.new("", "Reference images", false), + &"tile_rotate_left": Keychain.InputAction.new("", "Tileset panel", false), + &"tile_rotate_right": Keychain.InputAction.new("", "Tileset panel", false), + &"tile_flip_horizontal": Keychain.InputAction.new("", "Tileset panel", false), + &"tile_flip_vertical": Keychain.InputAction.new("", "Tileset panel", false) } Keychain.groups = { "Canvas": Keychain.InputGroup.new("", false), "Cursor movement": Keychain.InputGroup.new("Canvas"), + "Reference images": Keychain.InputGroup.new("Canvas"), "Buttons": Keychain.InputGroup.new(), "Tools": Keychain.InputGroup.new(), "Left": Keychain.InputGroup.new("Tools"), @@ -921,7 +926,7 @@ func _initialize_keychain() -> void: "Shape tools": Keychain.InputGroup.new("Tool modifiers"), "Selection tools": Keychain.InputGroup.new("Tool modifiers"), "Transformation tools": Keychain.InputGroup.new("Tool modifiers"), - "Reference images": Keychain.InputGroup.new("Canvas") + "Tileset panel": Keychain.InputGroup.new() } Keychain.ignore_actions = ["left_mouse", "right_mouse", "middle_mouse", "shift", "ctrl"] @@ -954,7 +959,7 @@ func general_redo(project := current_project) -> void: ## Performs actions done after an undo or redo is done. this takes [member general_undo] and ## [member general_redo] a step further. Does further work if the current action requires it ## like refreshing textures, redraw UI elements etc...[br] -## [param frame_index] and [param layer_index] are there for optimizzation. if the undo or redo +## [param frame_index] and [param layer_index] are there for optimization. if the undo or redo ## happens only in one cel then the cel's frame and layer should be passed to [param frame_index] ## and [param layer_index] respectively, otherwise the entire timeline will be refreshed. func undo_or_redo( @@ -980,20 +985,24 @@ func undo_or_redo( ] ): if layer_index > -1 and frame_index > -1: - canvas.update_texture(layer_index, frame_index, project) + var cel := project.frames[frame_index].cels[layer_index] + if action_name == "Scale": + cel.size_changed(project.size) + canvas.update_texture(layer_index, frame_index, project, undo) else: for i in project.frames.size(): for j in project.layers.size(): - canvas.update_texture(j, i, project) + var cel := project.frames[i].cels[j] + if action_name == "Scale": + cel.size_changed(project.size) + canvas.update_texture(j, i, project, undo) canvas.selection.queue_redraw() if action_name == "Scale": for i in project.frames.size(): for j in project.layers.size(): var current_cel := project.frames[i].cels[j] - if current_cel is Cel3D: - current_cel.size_changed(project.size) - else: + if current_cel is not Cel3D: current_cel.image_texture.set_image(current_cel.get_image()) canvas.camera_zoom() canvas.grid.queue_redraw() diff --git a/src/Autoload/OpenSave.gd b/src/Autoload/OpenSave.gd index dc9f8f3c2..0a8eb3237 100644 --- a/src/Autoload/OpenSave.gd +++ b/src/Autoload/OpenSave.gd @@ -258,6 +258,18 @@ func open_pxo_file(path: String, is_backup := false, replace_empty := true) -> v new_project.tiles.tile_mask = image else: new_project.tiles.reset_mask() + if result.has("tilesets"): + for i in result.tilesets.size(): + var tileset_dict: Dictionary = result.tilesets[i] + var tileset := new_project.tilesets[i] + var tile_size := tileset.tile_size + var tile_amount: int = tileset_dict.tile_amount + for j in tile_amount: + var image_data := zip_reader.read_file("tilesets/%s/%s" % [i, j]) + var image := Image.create_from_data( + tile_size.x, tile_size.y, false, new_project.get_image_format(), image_data + ) + tileset.add_tile(image, null) zip_reader.close() new_project.export_directory_path = path.get_base_dir() @@ -418,6 +430,14 @@ func save_pxo_file( zip_packer.start_file("image_data/tile_map") zip_packer.write_file(project.tiles.tile_mask.get_data()) zip_packer.close_file() + for i in project.tilesets.size(): + var tileset := project.tilesets[i] + var tileset_path := "tilesets/%s" % i + for j in tileset.tiles.size(): + var tile := tileset.tiles[j] + zip_packer.start_file(tileset_path.path_join(str(j))) + zip_packer.write_file(tile.image.get_data()) + zip_packer.close_file() zip_packer.close() if temp_path != path: @@ -699,17 +719,18 @@ func open_image_at_cel(image: Image, layer_index := 0, frame_index := 0) -> void return image.convert(project.get_image_format()) var cel_image := (cel as PixelCel).get_image() - var new_cel_image := ImageExtended.create_custom( - project_width, project_height, false, project.get_image_format(), cel_image.is_indexed - ) - new_cel_image.blit_rect(image, Rect2i(Vector2i.ZERO, image.get_size()), Vector2i.ZERO) - new_cel_image.convert_rgb_to_indexed() - var redo_data := {} - new_cel_image.add_data_to_dictionary(redo_data, cel_image) var undo_data := {} + if cel is CelTileMap: + undo_data[cel] = (cel as CelTileMap).serialize_undo_data() cel_image.add_data_to_dictionary(undo_data) - Global.undo_redo_compress_images(redo_data, undo_data, project) - + cel_image.blit_rect(image, Rect2i(Vector2i.ZERO, image.get_size()), Vector2i.ZERO) + cel_image.convert_rgb_to_indexed() + var redo_data := {} + if cel is CelTileMap: + (cel as CelTileMap).update_tilemap() + redo_data[cel] = (cel as CelTileMap).serialize_undo_data() + cel_image.add_data_to_dictionary(redo_data) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_do_property(project, "selected_cels", []) project.undo_redo.add_do_method(project.change_cel.bind(frame_index, layer_index)) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) @@ -815,6 +836,49 @@ func import_reference_image_from_image(image: Image) -> void: reference_image_imported.emit() +func open_image_as_tileset( + path: String, image: Image, horiz: int, vert: int, project := Global.current_project +) -> void: + image.convert(project.get_image_format()) + horiz = mini(horiz, image.get_size().x) + vert = mini(vert, image.get_size().y) + var frame_width := image.get_size().x / horiz + var frame_height := image.get_size().y / vert + var tile_size := Vector2i(frame_width, frame_height) + var tileset := TileSetCustom.new(tile_size, path.get_basename().get_file()) + for yy in range(vert): + for xx in range(horiz): + var cropped_image := image.get_region( + Rect2i(frame_width * xx, frame_height * yy, frame_width, frame_height) + ) + @warning_ignore("int_as_enum_without_cast") + tileset.add_tile(cropped_image, null) + project.tilesets.append(tileset) + + +func open_image_as_tileset_smart( + path: String, + image: Image, + sliced_rects: Array[Rect2i], + tile_size: Vector2i, + project := Global.current_project +) -> void: + image.convert(project.get_image_format()) + if sliced_rects.size() == 0: # Image is empty sprite (manually set data to be consistent) + tile_size = image.get_size() + sliced_rects.append(Rect2i(Vector2i.ZERO, tile_size)) + var tileset := TileSetCustom.new(tile_size, path.get_basename().get_file()) + for rect in sliced_rects: + var offset: Vector2 = (0.5 * (tile_size - rect.size)).floor() + var cropped_image := Image.create( + tile_size.x, tile_size.y, false, project.get_image_format() + ) + cropped_image.blit_rect(image, rect, offset) + @warning_ignore("int_as_enum_without_cast") + tileset.add_tile(cropped_image, null) + project.tilesets.append(tileset) + + func set_new_imported_tab(project: Project, path: String) -> void: var prev_project_empty := Global.current_project.is_empty() var prev_project_pos := Global.current_project_index diff --git a/src/Autoload/Tools.gd b/src/Autoload/Tools.gd index 3a79882e1..2b7b13455 100644 --- a/src/Autoload/Tools.gd +++ b/src/Autoload/Tools.gd @@ -2,6 +2,8 @@ extends Node signal color_changed(color_info: Dictionary, button: int) +@warning_ignore("unused_signal") +signal selected_tile_index_changed(tile_index: int) signal config_changed(slot_idx: int, config: Dictionary) @warning_ignore("unused_signal") signal flip_rotated(flip_x, flip_y, rotate_90, rotate_180, rotate_270) @@ -88,7 +90,11 @@ var tools := { ), "Move": Tool.new( - "Move", "Move", "move", "res://src/Tools/UtilityTools/Move.tscn", [Global.LayerTypes.PIXEL] + "Move", + "Move", + "move", + "res://src/Tools/UtilityTools/Move.tscn", + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP] ), "Zoom": Tool.new("Zoom", "Zoom", "zoom", "res://src/Tools/UtilityTools/Zoom.tscn"), "Pan": Tool.new("Pan", "Pan", "pan", "res://src/Tools/UtilityTools/Pan.tscn"), @@ -116,7 +122,7 @@ var tools := { "Pencil", "pencil", "res://src/Tools/DesignTools/Pencil.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], "Hold %s to make a line", ["draw_create_line"] ), @@ -126,7 +132,7 @@ var tools := { "Eraser", "eraser", "res://src/Tools/DesignTools/Eraser.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], "Hold %s to make a line", ["draw_create_line"] ), @@ -136,7 +142,7 @@ var tools := { "Bucket", "fill", "res://src/Tools/DesignTools/Bucket.tscn", - [Global.LayerTypes.PIXEL] + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP] ), "Shading": Tool.new( @@ -144,7 +150,7 @@ var tools := { "Shading Tool", "shading", "res://src/Tools/DesignTools/Shading.tscn", - [Global.LayerTypes.PIXEL] + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP] ), "LineTool": ( @@ -154,7 +160,7 @@ var tools := { "Line Tool", "linetool", "res://src/Tools/DesignTools/LineTool.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], """Hold %s to snap the angle of the line Hold %s to center the shape on the click origin Hold %s to displace the shape's origin""", @@ -169,7 +175,7 @@ Hold %s to displace the shape's origin""", "Curve Tool", "curvetool", "res://src/Tools/DesignTools/CurveTool.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], """Draws bezier curves Press %s/%s to add new points Press and drag to control the curvature @@ -185,7 +191,7 @@ Press %s to remove the last added point""", "Rectangle Tool", "rectangletool", "res://src/Tools/DesignTools/RectangleTool.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], """Hold %s to create a 1:1 shape Hold %s to center the shape on the click origin Hold %s to displace the shape's origin""", @@ -200,7 +206,7 @@ Hold %s to displace the shape's origin""", "Ellipse Tool", "ellipsetool", "res://src/Tools/DesignTools/EllipseTool.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], """Hold %s to create a 1:1 shape Hold %s to center the shape on the click origin Hold %s to displace the shape's origin""", @@ -213,7 +219,7 @@ Hold %s to displace the shape's origin""", "Text", "text", "res://src/Tools/UtilityTools/Text.tscn", - [Global.LayerTypes.PIXEL], + [Global.LayerTypes.PIXEL, Global.LayerTypes.TILEMAP], "" ), "3DShapeEdit": @@ -232,10 +238,12 @@ var _panels := {} var _curr_layer_type := Global.LayerTypes.PIXEL var _left_tools_per_layer_type := { Global.LayerTypes.PIXEL: "Pencil", + Global.LayerTypes.TILEMAP: "Pencil", Global.LayerTypes.THREE_D: "3DShapeEdit", } var _right_tools_per_layer_type := { Global.LayerTypes.PIXEL: "Eraser", + Global.LayerTypes.TILEMAP: "Eraser", Global.LayerTypes.THREE_D: "Pan", } var _tool_buttons: Node @@ -575,6 +583,87 @@ func calculate_mirror_x_minus_y(pos: Vector2i, project: Project) -> Vector2i: ) +func is_placing_tiles() -> bool: + if Global.current_project.frames.size() == 0 or Global.current_project.layers.size() == 0: + return false + return Global.current_project.get_current_cel() is CelTileMap and TileSetPanel.placing_tiles + + +func _get_closest_point_to_grid(pos: Vector2, distance: float, grid_pos: Vector2) -> Vector2: + # If the cursor is close to the start/origin of a grid cell, snap to that + var snap_distance := distance * Vector2.ONE + var closest_point := Vector2.INF + var rect := Rect2() + rect.position = pos - (snap_distance / 4.0) + rect.end = pos + (snap_distance / 4.0) + if rect.has_point(grid_pos): + closest_point = grid_pos + return closest_point + # If the cursor is far from the grid cell origin but still close to a grid line + # Look for a point close to a horizontal grid line + var grid_start_hor := Vector2(0, grid_pos.y) + var grid_end_hor := Vector2(Global.current_project.size.x, grid_pos.y) + var closest_point_hor := get_closest_point_to_segment( + pos, distance, grid_start_hor, grid_end_hor + ) + # Look for a point close to a vertical grid line + var grid_start_ver := Vector2(grid_pos.x, 0) + var grid_end_ver := Vector2(grid_pos.x, Global.current_project.size.y) + var closest_point_ver := get_closest_point_to_segment( + pos, distance, grid_start_ver, grid_end_ver + ) + # Snap to the closest point to the closest grid line + var horizontal_distance := (closest_point_hor - pos).length() + var vertical_distance := (closest_point_ver - pos).length() + if horizontal_distance < vertical_distance: + closest_point = closest_point_hor + elif horizontal_distance > vertical_distance: + closest_point = closest_point_ver + elif horizontal_distance == vertical_distance and closest_point_hor != Vector2.INF: + closest_point = grid_pos + return closest_point + + +func get_closest_point_to_segment( + pos: Vector2, distance: float, s1: Vector2, s2: Vector2 +) -> Vector2: + var test_line := (s2 - s1).rotated(deg_to_rad(90)).normalized() + var from_a := pos - test_line * distance + var from_b := pos + test_line * distance + var closest_point := Vector2.INF + if Geometry2D.segment_intersects_segment(from_a, from_b, s1, s2): + closest_point = Geometry2D.get_closest_point_to_segment(pos, s1, s2) + return closest_point + + +func snap_to_rectangular_grid_boundary( + pos: Vector2, grid_size: Vector2i, grid_offset := Vector2i.ZERO, snapping_distance := 9999.0 +) -> Vector2: + var grid_pos := pos.snapped(grid_size) + grid_pos += Vector2(grid_offset) + # keeping grid_pos as is would have been fine but this adds extra accuracy as to + # which snap point (from the list below) is closest to mouse and occupy THAT point + # t_l is for "top left" and so on + var t_l := grid_pos + Vector2(-grid_size.x, -grid_size.y) + var t_c := grid_pos + Vector2(0, -grid_size.y) + var t_r := grid_pos + Vector2(grid_size.x, -grid_size.y) + var m_l := grid_pos + Vector2(-grid_size.x, 0) + var m_c := grid_pos + var m_r := grid_pos + Vector2(grid_size.x, 0) + var b_l := grid_pos + Vector2(-grid_size.x, grid_size.y) + var b_c := grid_pos + Vector2(0, grid_size.y) + var b_r := grid_pos + Vector2(grid_size) + var vec_arr: PackedVector2Array = [t_l, t_c, t_r, m_l, m_c, m_r, b_l, b_c, b_r] + for vec in vec_arr: + if vec.distance_to(pos) < grid_pos.distance_to(pos): + grid_pos = vec + + var grid_point := _get_closest_point_to_grid(pos, snapping_distance, grid_pos) + if grid_point != Vector2.INF: + pos = grid_point.floor() + return pos + + func set_button_size(button_size: int) -> void: var size := Vector2(24, 24) if button_size == Global.ButtonSize.SMALL else Vector2(32, 32) if not is_instance_valid(_tool_buttons): diff --git a/src/Classes/Cels/BaseCel.gd b/src/Classes/Cels/BaseCel.gd index 8dedefba4..4b01ec7d8 100644 --- a/src/Classes/Cels/BaseCel.gd +++ b/src/Classes/Cels/BaseCel.gd @@ -67,7 +67,7 @@ func get_image() -> Image: ## Used to update the texture of the cel. -func update_texture() -> void: +func update_texture(_undo := false) -> void: texture_changed.emit() if link_set != null: var frame := Global.current_project.current_frame @@ -92,6 +92,10 @@ func deserialize(dict: Dictionary) -> void: user_data = dict.get("user_data", user_data) +func size_changed(_new_size: Vector2i) -> void: + pass + + ## Used to perform cleanup after a cel is removed. func on_remove() -> void: pass diff --git a/src/Classes/Cels/CelTileMap.gd b/src/Classes/Cels/CelTileMap.gd new file mode 100644 index 000000000..4cf9e75f8 --- /dev/null +++ b/src/Classes/Cels/CelTileMap.gd @@ -0,0 +1,700 @@ +# gdlint: ignore=max-public-methods +class_name CelTileMap +extends PixelCel + +## A cel type for 2D tile-based maps. +## A Tilemap cel uses a [TileSetCustom], which it inherits from its [LayerTileMap]. +## Extending from [PixelCel], it contains an internal [Image], which is divided in +## grid cells, the size of which comes from [member TileSetCustom.tile_size]. +## Each cell contains an index, which is an integer used to map that portion of the +## internal [member PixelCel.image] to a tile in [member tileset], as well as +## information that specifies if that cell has a transformation applied to it, +## such as horizontal flipping, vertical flipping, or if it's transposed. + +## The [TileSetCustom] that this cel uses, passed down from the cel's [LayerTileMap]. +var tileset: TileSetCustom + +## The [Array] of type [CelTileMap.Cell] that contains data for each cell of the tilemap. +## The array's size is equal to [member horizontal_cells] * [member vertical_cells]. +var cells: Array[Cell] +## The amount of horizontal cells. +var horizontal_cells: int +## The amount of vertical cells. +var vertical_cells: int +## Dictionary of [int] and [Array]. +## The key is the index of the tile in the tileset, +## and the value is the index of the tilemap tile that changed first, along with +## its image that is being changed when manual mode is enabled. +## Gets reset on [method update_tilemap]. +var editing_images := {} + + +## An internal class of [CelTIleMap], which contains data used by individual cells of the tilemap. +class Cell: + ## The index of the [TileSetCustom] tile that the cell is mapped to. + var index := 0 + ## If [code]true[/code], the tile is flipped horizontally in this cell. + var flip_h := false + ## If [code]true[/code], the tile is flipped vertically in this cell. + var flip_v := false + ## If [code]true[/code], the tile is rotated 90 degrees counter-clockwise, + ## and then flipped vertically in this cell. + var transpose := false + + func _to_string() -> String: + var text := str(index) + if flip_h: + text += "H" + if flip_v: + text += "V" + if transpose: + text += "T" + return text + + func remove_transformations() -> void: + flip_h = false + flip_v = false + transpose = false + + func serialize() -> Dictionary: + return {"index": index, "flip_h": flip_h, "flip_v": flip_v, "transpose": transpose} + + func deserialize(dict: Dictionary) -> void: + index = dict.get("index", index) + flip_h = dict.get("flip_h", flip_h) + flip_v = dict.get("flip_v", flip_v) + transpose = dict.get("transpose", transpose) + + +func _init(_tileset: TileSetCustom, _image := ImageExtended.new(), _opacity := 1.0) -> void: + super._init(_image, _opacity) + set_tileset(_tileset) + + +func set_tileset(new_tileset: TileSetCustom, reset_indices := true) -> void: + if tileset == new_tileset: + return + if is_instance_valid(tileset): + if tileset.updated.is_connected(_on_tileset_updated): + tileset.updated.disconnect(_on_tileset_updated) + tileset = new_tileset + if is_instance_valid(tileset): + _resize_cells(get_image().get_size(), reset_indices) + if not tileset.updated.is_connected(_on_tileset_updated): + tileset.updated.connect(_on_tileset_updated) + + +## Maps the cell at position [param cell_position] to +## the [member tileset]'s tile of index [param index]. +func set_index(cell_position: int, index: int) -> void: + index = clampi(index, 0, tileset.tiles.size() - 1) + var previous_index := cells[cell_position].index + + if previous_index != index: + if previous_index > 0 and previous_index < tileset.tiles.size(): + tileset.tiles[previous_index].times_used -= 1 + tileset.tiles[index].times_used += 1 + cells[cell_position].index = index + cells[cell_position].flip_h = TileSetPanel.is_flipped_h + cells[cell_position].flip_v = TileSetPanel.is_flipped_v + cells[cell_position].transpose = TileSetPanel.is_transposed + _update_cell(cell_position) + Global.canvas.queue_redraw() + + +## Returns the pixel coordinates of the tilemap's cell +## at position [cell_position] in the cel's image. +## The reverse of [method get_cell_position]. +func get_cell_coords_in_image(cell_position: int) -> Vector2i: + var x_coord := float(tileset.tile_size.x) * (cell_position % horizontal_cells) + @warning_ignore("integer_division") + var y_coord := float(tileset.tile_size.y) * (cell_position / horizontal_cells) + return Vector2i(x_coord, y_coord) + + +## Returns the position of a cell in the tilemap +## at pixel coordinates [param coords] in the cel's image. +## The reverse of [method get_cell_coords_in_image]. +func get_cell_position(coords: Vector2i) -> int: + @warning_ignore("integer_division") + var x := coords.x / tileset.tile_size.x + x = clampi(x, 0, horizontal_cells - 1) + @warning_ignore("integer_division") + var y := coords.y / tileset.tile_size.y + y = clampi(y, 0, vertical_cells - 1) + y *= horizontal_cells + return x + y + + +## Returns the position of a cell in the tilemap +## at tilemap coordinates [param coords] in the cel's image. +func get_cell_position_in_tilemap_space(coords: Vector2i) -> int: + var x := coords.x + x = clampi(x, 0, horizontal_cells - 1) + var y := coords.y + y = clampi(y, 0, vertical_cells - 1) + y *= horizontal_cells + return x + y + + +## Returns the index of a cell in the tilemap +## at pixel coordinates [param coords] in the cel's image. +func get_cell_index_at_coords(coords: Vector2i) -> int: + return cells[get_cell_position(coords)].index + + +## Returns the index of a cell in the tilemap +## at tilemap coordinates [param coords] in the cel's image. +func get_cell_index_at_coords_in_tilemap_space(coords: Vector2i) -> int: + return cells[get_cell_position_in_tilemap_space(coords)].index + + +## Returns [code]true[/code] if the tile at cell position [param cell_position] +## with image [param image_portion] is equal to [param tile_image]. +func _tiles_equal(cell_position: int, image_portion: Image, tile_image: Image) -> bool: + var cell_data := cells[cell_position] + var final_image_portion := transform_tile( + tile_image, cell_data.flip_h, cell_data.flip_v, cell_data.transpose + ) + return image_portion.get_data() == final_image_portion.get_data() + + +## Applies transformations to [param tile_image] based on [param flip_h], +## [param flip_v] and [param transpose], and returns the transformed image. +## If [param reverse] is [code]true[/code], the transposition is applied the reverse way. +func transform_tile( + tile_image: Image, flip_h: bool, flip_v: bool, transpose: bool, reverse := false +) -> Image: + var transformed_tile := Image.new() + transformed_tile.copy_from(tile_image) + if transpose: + var tmp_image := Image.new() + tmp_image.copy_from(transformed_tile) + if reverse: + tmp_image.rotate_90(CLOCKWISE) + else: + tmp_image.rotate_90(COUNTERCLOCKWISE) + transformed_tile.blit_rect( + tmp_image, Rect2i(Vector2i.ZERO, transformed_tile.get_size()), Vector2i.ZERO + ) + if reverse and not (flip_h != flip_v): + transformed_tile.flip_x() + else: + transformed_tile.flip_y() + if flip_h: + transformed_tile.flip_x() + if flip_v: + transformed_tile.flip_y() + return transformed_tile + + +## Given a [param selection_map] and a [param selection_rect], +## the method finds the cells that are currently selected and returns them +## in the form of a 2D array that contains the serialiazed data +##of the selected cells in the form of [Dictionary]. +func get_selected_cells(selection_map: SelectionMap, selection_rect: Rect2i) -> Array[Array]: + var selected_cells: Array[Array] = [] + for x in range(0, selection_rect.size.x, tileset.tile_size.x): + selected_cells.append([]) + for y in range(0, selection_rect.size.y, tileset.tile_size.y): + var pos := Vector2i(x, y) + selection_rect.position + var x_index := x / tileset.tile_size.x + if selection_map.is_pixel_selected(pos): + var cell_pos := get_cell_position(pos) + selected_cells[x_index].append(cells[cell_pos].serialize()) + else: + # If it's not selected, append the transparent tile 0. + selected_cells[x_index].append( + {"index": 0, "flip_h": false, "flip_v": false, "transpose": false} + ) + return selected_cells + + +## Resizes [param selected_indices], which is an array of arrays of [Dictionary], +## to [param horizontal_size] and [param vertical_size]. +## This method is used when resizing a selection and draw tiles mode is enabled. +func resize_selection( + selected_cells: Array[Array], horizontal_size: int, vertical_size: int +) -> Array[Array]: + var resized_cells: Array[Array] = [] + var current_columns := selected_cells.size() + if current_columns == 0: + return resized_cells + var current_rows := selected_cells[0].size() + if current_rows == 0: + return resized_cells + resized_cells.resize(horizontal_size) + for x in horizontal_size: + resized_cells[x] = [] + resized_cells[x].resize(vertical_size) + var column_middles := current_columns - 2 + if current_columns == 1: + for x in horizontal_size: + _resize_rows(selected_cells[0], resized_cells[x], current_rows, vertical_size) + else: + for x in horizontal_size: + if x == 0: + _resize_rows(selected_cells[0], resized_cells[x], current_rows, vertical_size) + elif x == horizontal_size - 1: + _resize_rows(selected_cells[-1], resized_cells[x], current_rows, vertical_size) + else: + if x < current_columns - 1: + _resize_rows(selected_cells[x], resized_cells[x], current_rows, vertical_size) + else: + if column_middles == 0: + _resize_rows( + selected_cells[-1], resized_cells[x], current_rows, vertical_size + ) + else: + var x_index := x - (column_middles * ((x - 1) / column_middles)) + _resize_rows( + selected_cells[x_index], resized_cells[x], current_rows, vertical_size + ) + return resized_cells + + +## Helper method of [method resize_selection]. +func _resize_rows( + selected_cells: Array, resized_cells: Array, current_rows: int, vertical_size: int +) -> void: + var row_middles := current_rows - 2 + if current_rows == 1: + for y in vertical_size: + resized_cells[y] = selected_cells[0] + else: + for y in vertical_size: + if y == 0: + resized_cells[y] = selected_cells[0] + elif y == vertical_size - 1: + resized_cells[y] = selected_cells[-1] + else: + if y < current_rows - 1: + resized_cells[y] = selected_cells[y] + else: + if row_middles == 0: + resized_cells[y] = selected_cells[-1] + else: + var y_index := y - (row_middles * ((y - 1) / row_middles)) + resized_cells[y] = selected_cells[y_index] + + +## Applies the [param selected_cells] data to [param target_image] data, +## offset by [param selection_rect]. The target image needs to be resized first. +## This method is used when resizing a selection and draw tiles mode is enabled. +func apply_resizing_to_image( + target_image: Image, selected_cells: Array[Array], selection_rect: Rect2i +) -> void: + for x in selected_cells.size(): + for y in selected_cells[x].size(): + var pos := Vector2i(x, y) * tileset.tile_size + selection_rect.position + var cell_pos := get_cell_position(pos) + var coords := get_cell_coords_in_image(cell_pos) - selection_rect.position + var rect := Rect2i(coords, tileset.tile_size) + var image_portion := target_image.get_region(rect) + var cell_data := Cell.new() + cell_data.deserialize(selected_cells[x][y]) + var index := cell_data.index + if index >= tileset.tiles.size(): + index = 0 + var current_tile := tileset.tiles[index].image + var transformed_tile := transform_tile( + current_tile, cell_data.flip_h, cell_data.flip_v, cell_data.transpose + ) + if image_portion.get_data() != transformed_tile.get_data(): + var tile_size := transformed_tile.get_size() + target_image.blit_rect(transformed_tile, Rect2i(Vector2i.ZERO, tile_size), coords) + if target_image is ImageExtended: + target_image.convert_rgb_to_indexed() + + +## Appends data to a [Dictionary] to be used for undo/redo. +func serialize_undo_data() -> Dictionary: + var dict := {} + var cell_indices := [] + cell_indices.resize(cells.size()) + for i in cell_indices.size(): + cell_indices[i] = cells[i].serialize() + dict["cell_indices"] = cell_indices + dict["tileset"] = tileset.serialize_undo_data() + dict["resize"] = false + return dict + + +## Same purpose as [method serialize_undo_data], but for when the image resource +## ([param source_image]) we want to store to the undo/redo stack +## is not the same as [member image]. This method also handles the resizing logic for undo/redo. +func serialize_undo_data_source_image( + source_image: ImageExtended, redo_data: Dictionary, undo_data: Dictionary +) -> void: + undo_data[self] = serialize_undo_data() + if source_image.get_size() != image.get_size(): + undo_data[self]["resize"] = true + _resize_cells(source_image.get_size()) + tileset.clear_tileset(self) + var tile_editing_mode := TileSetPanel.tile_editing_mode + if tile_editing_mode == TileSetPanel.TileEditingMode.MANUAL: + tile_editing_mode = TileSetPanel.TileEditingMode.AUTO + update_tilemap(tile_editing_mode, source_image) + redo_data[self] = serialize_undo_data() + redo_data[self]["resize"] = undo_data[self]["resize"] + + +## Reads data from a [param dict] [Dictionary], and uses them to add methods to [param undo_redo]. +func deserialize_undo_data(dict: Dictionary, undo_redo: UndoRedo, undo: bool) -> void: + var cell_indices = dict.cell_indices + if undo: + undo_redo.add_undo_method(_deserialize_cell_data.bind(cell_indices, dict.resize)) + if dict.has("tileset"): + undo_redo.add_undo_method(tileset.deserialize_undo_data.bind(dict.tileset, self)) + else: + undo_redo.add_do_method(_deserialize_cell_data.bind(cell_indices, dict.resize)) + if dict.has("tileset"): + undo_redo.add_do_method(tileset.deserialize_undo_data.bind(dict.tileset, self)) + + +## Gets called every time a change is being applied to the [param image], +## such as when finishing drawing with a draw tool, or when applying an image effect. +## This method responsible for updating the indices of the [member cells], as well as +## updating the [member tileset] with the incoming changes. +## The updating behavior depends on the current tile editing mode +## by [member TileSetPanel.tile_editing_mode]. +## If a [param source_image] is provided, that image is being used instead of [member image]. +func update_tilemap( + tile_editing_mode := TileSetPanel.tile_editing_mode, source_image := image +) -> void: + editing_images.clear() + var tileset_size_before_update := tileset.tiles.size() + for i in cells.size(): + var coords := get_cell_coords_in_image(i) + var rect := Rect2i(coords, tileset.tile_size) + var image_portion := source_image.get_region(rect) + var index := cells[i].index + if index >= tileset.tiles.size(): + index = 0 + var current_tile := tileset.tiles[index] + if tile_editing_mode == TileSetPanel.TileEditingMode.MANUAL: + if image_portion.is_invisible(): + continue + if index == 0: + # If the tileset is empty, only then add a new tile. + if tileset.tiles.size() <= 1: + tileset.add_tile(image_portion, self) + cells[i].index = tileset.tiles.size() - 1 + continue + if not _tiles_equal(i, image_portion, current_tile.image): + tileset.replace_tile_at(image_portion, index, self) + elif tile_editing_mode == TileSetPanel.TileEditingMode.AUTO: + _handle_auto_editing_mode(i, image_portion, tileset_size_before_update) + else: # Stack + if image_portion.is_invisible(): + continue + var found_tile := false + for j in range(1, tileset.tiles.size()): + var tile := tileset.tiles[j] + if _tiles_equal(i, image_portion, tile.image): + if cells[i].index != j: + cells[i].index = j + cells[i].remove_transformations() + found_tile = true + break + if not found_tile: + tileset.add_tile(image_portion, self) + cells[i].index = tileset.tiles.size() - 1 + cells[i].remove_transformations() + # Updates transparent cells that have indices higher than 0. + # This can happen when switching to another tileset which has less tiles + # than the previous one. + for i in cells.size(): + var coords := get_cell_coords_in_image(i) + var rect := Rect2i(coords, tileset.tile_size) + var image_portion := source_image.get_region(rect) + if not image_portion.is_invisible(): + continue + var index := cells[i].index + if index == 0: + continue + if index >= tileset.tiles.size(): + index = 0 + var current_tile := tileset.tiles[index] + if not _tiles_equal(i, image_portion, current_tile.image): + set_index(i, cells[i].index) + + +## Gets called by [method update_tilemap]. This method is responsible for handling +## the tilemap updating behavior for the auto tile editing mode.[br] +## Cases:[br] +## 0) Cell is transparent. Set its index to 0. +## [br] +## 0.5) Cell is transparent and mapped. +## Set its index to 0 and unuse the mapped tile. +## If the mapped tile is removed, reduce the index of all cells that have +## indices greater or equal than the existing tile's index. +## [br] +## 1) Cell not mapped, exists in the tileset. +## Map the cell to the existing tile and increase its times_used by one. +## [br] +## 2) Cell not mapped, does not exist in the tileset. +## Add the cell as a tile in the tileset, set its index to be the tileset's tile size - 1. +## [br] +## 3) Cell mapped, tile did not change. Do nothing. +## [br] +## 4) Cell mapped, exists in the tileset. +## The mapped tile still exists in the tileset. +## Map the cell to the existing tile, increase its times_used by one, +## and reduce the previously mapped tile's times_used by 1. +## [br] +## 5) Cell mapped, exists in the tileset. +## The mapped tile does not exist in the tileset anymore. +## Map the cell to the existing tile and increase its times_used by one. +## Remove the previously mapped tile, +## and reduce the index of all cells that have indices greater or equal +## than the existing tile's index. +## [br] +## 6) Cell mapped, does not exist in the tileset. +## The mapped tile still exists in the tileset. +## Add the cell as a tile in the tileset, set its index to be the tileset's tile size - 1. +## Reduce the previously mapped tile's times_used by 1. +## [br] +## 7) Cell mapped, does not exist in the tileset. +## The mapped tile does not exist in the tileset anymore. +## Simply replace the old tile with the new one, do not change its index. +func _handle_auto_editing_mode( + i: int, image_portion: Image, tileset_size_before_update: int +) -> void: + var index := cells[i].index + if index >= tileset.tiles.size(): + index = 0 + var current_tile := tileset.tiles[index] + if image_portion.is_invisible(): + # Case 0: The cell is transparent. + if cells[i].index >= tileset_size_before_update: + return + cells[i].index = 0 + cells[i].remove_transformations() + if index > 0: + # Case 0.5: The cell is transparent and mapped to a tile. + var is_removed := tileset.unuse_tile_at_index(index, self) + if is_removed: + # Re-index all indices that are after the deleted one. + _re_index_cells_after_index(index) + return + var index_in_tileset := tileset.find_tile(image_portion) + if index == 0: # If the cell is not mapped to a tile. + if index_in_tileset > -1: + # Case 1: The cell is not mapped already, + # and it exists in the tileset as a tile. + tileset.tiles[index_in_tileset].times_used += 1 + cells[i].index = index_in_tileset + else: + # Case 2: The cell is not mapped already, + # and it does not exist in the tileset. + tileset.add_tile(image_portion, self) + cells[i].index = tileset.tiles.size() - 1 + else: # If the cell is already mapped. + if _tiles_equal(i, image_portion, current_tile.image): + # Case 3: The cell is mapped and it did not change. + # Do nothing and move on to the next cell. + return + if index_in_tileset > -1: # If the cell exists in the tileset as a tile. + if current_tile.times_used > 1: + # Case 4: The cell is mapped and it exists in the tileset as a tile, + # and the currently mapped tile still exists in the tileset. + tileset.tiles[index_in_tileset].times_used += 1 + cells[i].index = index_in_tileset + tileset.unuse_tile_at_index(index, self) + else: + # Case 5: The cell is mapped and it exists in the tileset as a tile, + # and the currently mapped tile no longer exists in the tileset. + tileset.tiles[index_in_tileset].times_used += 1 + cells[i].index = index_in_tileset + tileset.remove_tile_at_index(index, self) + # Re-index all indices that are after the deleted one. + _re_index_cells_after_index(index) + else: # If the cell does not exist in the tileset as a tile. + if current_tile.times_used > 1: + # Case 6: The cell is mapped and it does not + # exist in the tileset as a tile, + # and the currently mapped tile still exists in the tileset. + tileset.unuse_tile_at_index(index, self) + tileset.add_tile(image_portion, self) + cells[i].index = tileset.tiles.size() - 1 + else: + # Case 7: The cell is mapped and it does not + # exist in the tileset as a tile, + # and the currently mapped tile no longer exists in the tileset. + tileset.replace_tile_at(image_portion, index, self) + cells[i].remove_transformations() + + +## Re-indexes all [member cells] that are larger or equal to [param index], +## by reducing their value by one. +func _re_index_cells_after_index(index: int) -> void: + for i in cells.size(): + var tmp_index := cells[i].index + if tmp_index >= index: + cells[i].index -= 1 + + +## Updates the [member image] data of the cell of the tilemap in [param cell_position], +## to ensure that it is the same as its mapped tile in the [member tileset]. +func _update_cell(cell_position: int) -> void: + var coords := get_cell_coords_in_image(cell_position) + var rect := Rect2i(coords, tileset.tile_size) + var image_portion := image.get_region(rect) + var cell_data := cells[cell_position] + var index := cell_data.index + if index >= tileset.tiles.size(): + index = 0 + var current_tile := tileset.tiles[index].image + var transformed_tile := transform_tile( + current_tile, cell_data.flip_h, cell_data.flip_v, cell_data.transpose + ) + if image_portion.get_data() != transformed_tile.get_data(): + var tile_size := transformed_tile.get_size() + image.blit_rect(transformed_tile, Rect2i(Vector2i.ZERO, tile_size), coords) + image.convert_rgb_to_indexed() + + +## Calls [method _update_cell] for all [member cells]. +func update_cel_portions() -> void: + for i in cells.size(): + _update_cell(i) + + +## Loops through all [member cells] of the tilemap and updates their indices, +## so they can remain mapped to the [member tileset]'s tiles. +func _re_index_all_cells() -> void: + for i in cells.size(): + var coords := get_cell_coords_in_image(i) + var rect := Rect2i(coords, tileset.tile_size) + var image_portion := image.get_region(rect) + if image_portion.is_invisible(): + var index := cells[i].index + if index > 0 and index < tileset.tiles.size(): + var current_tile := tileset.tiles[index] + if not _tiles_equal(i, image_portion, current_tile.image): + set_index(i, cells[i].index) + continue + for j in range(1, tileset.tiles.size()): + var tile := tileset.tiles[j] + if _tiles_equal(i, image_portion, tile.image): + cells[i].index = j + break + + +## Resizes the [member cells] array based on [param new_size]. +func _resize_cells(new_size: Vector2i, reset_indices := true) -> void: + horizontal_cells = ceili(float(new_size.x) / tileset.tile_size.x) + vertical_cells = ceili(float(new_size.y) / tileset.tile_size.y) + cells.resize(horizontal_cells * vertical_cells) + for i in cells.size(): + if reset_indices: + cells[i] = Cell.new() + else: + if not is_instance_valid(cells[i]): + cells[i] = Cell.new() + + +## Returns [code]true[/code] if the user just did a Redo. +func _is_redo() -> bool: + return Global.control.redone + + +## If the tileset has been modified by another [param cel], +## make sure to also update it here. +## If [param replace_index] is larger than -1, it means that manual mode +## has been used to replace a tile in the tileset in another cel, +## so call [method update_cel_portions] to update it in this cel as well. +## Otherwise, call [method _re_index_all_cells] to ensure that the cells have correct indices. +func _on_tileset_updated(cel: CelTileMap, replace_index: int) -> void: + if cel == self or not is_instance_valid(cel): + return + if link_set != null and cel in link_set["cels"]: + return + if replace_index > -1: # Manual mode + update_cel_portions() + else: + _re_index_all_cells() + Global.canvas.update_all_layers = true + Global.canvas.queue_redraw() + + +func _deserialize_cell_data(cell_indices: Array, resize: bool) -> void: + if resize: + _resize_cells(image.get_size()) + for i in cell_indices.size(): + var cell_data: Dictionary = cell_indices[i] + cells[i].deserialize(cell_data) + + +# Overridden Methods: +func set_content(content, texture: ImageTexture = null) -> void: + super.set_content(content, texture) + _resize_cells(image.get_size()) + _re_index_all_cells() + + +func update_texture(undo := false) -> void: + var tile_editing_mode := TileSetPanel.tile_editing_mode + if undo or _is_redo() or tile_editing_mode != TileSetPanel.TileEditingMode.MANUAL: + super.update_texture(undo) + editing_images.clear() + return + + for i in cells.size(): + var cell_data := cells[i] + var index := cell_data.index + if index >= tileset.tiles.size(): + index = 0 + var coords := get_cell_coords_in_image(i) + var rect := Rect2i(coords, tileset.tile_size) + var image_portion := image.get_region(rect) + var current_tile := tileset.tiles[index] + if index == 0: + if tileset.tiles.size() > 1: + # Prevent from drawing on empty image portions. + var tile_size := current_tile.image.get_size() + image.blit_rect(current_tile.image, Rect2i(Vector2i.ZERO, tile_size), coords) + continue + if editing_images.has(index): + var editing_portion := editing_images[index][0] as int + if i == editing_portion: + var transformed_image := transform_tile( + image_portion, cell_data.flip_h, cell_data.flip_v, cell_data.transpose, true + ) + editing_images[index] = [i, transformed_image] + var editing_image := editing_images[index][1] as Image + var transformed_editing_image := transform_tile( + editing_image, cell_data.flip_h, cell_data.flip_v, cell_data.transpose + ) + if not image_portion.get_data() == transformed_editing_image.get_data(): + var tile_size := image_portion.get_size() + image.blit_rect(transformed_editing_image, Rect2i(Vector2i.ZERO, tile_size), coords) + else: + if not _tiles_equal(i, image_portion, current_tile.image): + var transformed_image := transform_tile( + image_portion, cell_data.flip_h, cell_data.flip_v, cell_data.transpose, true + ) + editing_images[index] = [i, transformed_image] + super.update_texture(undo) + + +func serialize() -> Dictionary: + var dict := super.serialize() + var cell_indices := [] + cell_indices.resize(cells.size()) + for i in cell_indices.size(): + cell_indices[i] = cells[i].serialize() + dict["cell_indices"] = cell_indices + return dict + + +func deserialize(dict: Dictionary) -> void: + super.deserialize(dict) + var cell_indices = dict.get("cell_indices") + for i in cell_indices.size(): + cells[i].deserialize(cell_indices[i]) + + +func get_class_name() -> String: + return "CelTileMap" diff --git a/src/Classes/Cels/PixelCel.gd b/src/Classes/Cels/PixelCel.gd index dc2bb3aa7..82c09bf61 100644 --- a/src/Classes/Cels/PixelCel.gd +++ b/src/Classes/Cels/PixelCel.gd @@ -54,9 +54,9 @@ func get_image() -> ImageExtended: return image -func update_texture() -> void: +func update_texture(undo := false) -> void: image_texture.set_image(image) - super.update_texture() + super.update_texture(undo) func get_class_name() -> String: diff --git a/src/Classes/ImageEffect.gd b/src/Classes/ImageEffect.gd index f50aef337..5efec7fe0 100644 --- a/src/Classes/ImageEffect.gd +++ b/src/Classes/ImageEffect.gd @@ -157,10 +157,11 @@ func display_animate_dialog() -> void: func _commit_undo(action: String, undo_data: Dictionary, project: Project) -> void: + project.update_tilemaps(undo_data) var redo_data := _get_undo_data(project) project.undos += 1 project.undo_redo.create_action(action) - Global.undo_redo_compress_images(redo_data, undo_data, project) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false, -1, -1, project)) project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true, -1, -1, project)) project.undo_redo.commit_action() @@ -168,24 +169,22 @@ func _commit_undo(action: String, undo_data: Dictionary, project: Project) -> vo func _get_undo_data(project: Project) -> Dictionary: var data := {} - var images := _get_selected_draw_images(project) - for image in images: - image.add_data_to_dictionary(data) + project.serialize_cel_undo_data(_get_selected_draw_cels(project), data) return data -func _get_selected_draw_images(project: Project) -> Array[ImageExtended]: - var images: Array[ImageExtended] = [] +func _get_selected_draw_cels(project: Project) -> Array[BaseCel]: + var images: Array[BaseCel] = [] if affect == SELECTED_CELS: for cel_index in project.selected_cels: var cel: BaseCel = project.frames[cel_index[0]].cels[cel_index[1]] if cel is PixelCel: - images.append(cel.get_image()) + images.append(cel) else: for frame in project.frames: for cel in frame.cels: if cel is PixelCel: - images.append(cel.get_image()) + images.append(cel) return images diff --git a/src/Classes/Layers/LayerTileMap.gd b/src/Classes/Layers/LayerTileMap.gd new file mode 100644 index 000000000..c70ba91af --- /dev/null +++ b/src/Classes/Layers/LayerTileMap.gd @@ -0,0 +1,57 @@ +class_name LayerTileMap +extends PixelLayer + +## A layer type for 2D tile-based maps. +## A LayerTileMap uses a [TileSetCustom], which is then by all of its [CelTileMap]s. +## This class doesn't hold any actual tilemap data, as they are different in each cel. +## For this reason, that data is being handled by the [CelTileMap] class. +## Not to be confused with [TileMapLayer], which is a Godot node. + +## The [TileSetCustom] that this layer uses. +## Internally, this class doesn't make much use of this. +## It's mostly only used to be passed down to the layer's [CelTileMap]s. +var tileset: TileSetCustom + + +func _init(_project: Project, _tileset: TileSetCustom, _name := "") -> void: + super._init(_project, _name) + tileset = _tileset + if not project.tilesets.has(tileset): + project.add_tileset(tileset) + + +# Overridden Methods: +func serialize() -> Dictionary: + var dict := super.serialize() + dict["tileset_index"] = project.tilesets.find(tileset) + return dict + + +func deserialize(dict: Dictionary) -> void: + super.deserialize(dict) + new_cels_linked = dict.new_cels_linked + var tileset_index = dict.get("tileset_index") + tileset = project.tilesets[tileset_index] + + +func get_layer_type() -> int: + return Global.LayerTypes.TILEMAP + + +func new_empty_cel() -> BaseCel: + var format := project.get_image_format() + var is_indexed := project.is_indexed() + var image := ImageExtended.create_custom( + project.size.x, project.size.y, false, format, is_indexed + ) + return CelTileMap.new(tileset, image) + + +func new_cel_from_image(image: Image) -> PixelCel: + var image_extended := ImageExtended.new() + image_extended.copy_from_custom(image, project.is_indexed()) + return CelTileMap.new(tileset, image_extended) + + +func set_name_to_default(number: int) -> void: + name = tr("Tilemap") + " %s" % number diff --git a/src/Classes/Project.gd b/src/Classes/Project.gd index 30baf79a1..387883113 100644 --- a/src/Classes/Project.gd +++ b/src/Classes/Project.gd @@ -85,6 +85,7 @@ var selection_offset := Vector2i.ZERO: selection_offset = value Global.canvas.selection.marching_ants_outline.offset = selection_offset var has_selection := false +var tilesets: Array[TileSetCustom] ## For every camera (currently there are 3) var cameras_rotation: PackedFloat32Array = [0.0, 0.0, 0.0] @@ -295,6 +296,9 @@ func serialize() -> Dictionary: var reference_image_data := [] for reference_image in reference_images: reference_image_data.append(reference_image.serialize()) + var tileset_data := [] + for tileset in tilesets: + tileset_data.append(tileset.serialize()) var metadata := _serialize_metadata(self) @@ -315,6 +319,7 @@ func serialize() -> Dictionary: "frames": frame_data, "brushes": brush_data, "reference_images": reference_image_data, + "tilesets": tileset_data, "vanishing_points": vanishing_points, "export_file_name": file_name, "export_file_format": file_format, @@ -344,6 +349,12 @@ func deserialize(dict: Dictionary, zip_reader: ZIPReader = null, file: FileAcces if dict.has("tile_mode_y_basis_x") and dict.has("tile_mode_y_basis_y"): tiles.y_basis.x = dict.tile_mode_y_basis_x tiles.y_basis.y = dict.tile_mode_y_basis_y + if dict.has("tilesets"): + for saved_tileset in dict["tilesets"]: + var tile_size = str_to_var("Vector2i" + saved_tileset.get("tile_size")) + var tileset := TileSetCustom.new(tile_size) + tileset.deserialize(saved_tileset) + tilesets.append(tileset) if dict.has("frames") and dict.has("layers"): for saved_layer in dict.layers: match int(saved_layer.get("type", Global.LayerTypes.PIXEL)): @@ -353,63 +364,8 @@ func deserialize(dict: Dictionary, zip_reader: ZIPReader = null, file: FileAcces layers.append(GroupLayer.new(self)) Global.LayerTypes.THREE_D: layers.append(Layer3D.new(self)) - - var frame_i := 0 - for frame in dict.frames: - var cels: Array[BaseCel] = [] - var cel_i := 0 - for cel in frame.cels: - match int(dict.layers[cel_i].get("type", Global.LayerTypes.PIXEL)): - Global.LayerTypes.PIXEL: - var image: Image - var indices_data := PackedByteArray() - if is_instance_valid(zip_reader): # For pxo files saved in 1.0+ - var path := "image_data/frames/%s/layer_%s" % [frame_i + 1, cel_i + 1] - var image_data := zip_reader.read_file(path) - image = Image.create_from_data( - size.x, size.y, false, get_image_format(), image_data - ) - var indices_path := ( - "image_data/frames/%s/indices_layer_%s" % [frame_i + 1, cel_i + 1] - ) - if zip_reader.file_exists(indices_path): - indices_data = zip_reader.read_file(indices_path) - elif is_instance_valid(file): # For pxo files saved in 0.x - var buffer := file.get_buffer(size.x * size.y * 4) - image = Image.create_from_data( - size.x, size.y, false, get_image_format(), buffer - ) - var pixelorama_image := ImageExtended.new() - pixelorama_image.is_indexed = is_indexed() - if not indices_data.is_empty() and is_indexed(): - pixelorama_image.indices_image = Image.create_from_data( - size.x, size.y, false, Image.FORMAT_R8, indices_data - ) - pixelorama_image.copy_from(image) - pixelorama_image.select_palette("", true) - cels.append(PixelCel.new(pixelorama_image)) - Global.LayerTypes.GROUP: - cels.append(GroupCel.new()) - Global.LayerTypes.THREE_D: - if is_instance_valid(file): # For pxo files saved in 0.x - # Don't do anything with it, just read it so that the file can move on - file.get_buffer(size.x * size.y * 4) - cels.append(Cel3D.new(size, true)) - cel["pxo_version"] = pxo_version - cels[cel_i].deserialize(cel) - _deserialize_metadata(cels[cel_i], cel) - cel_i += 1 - var duration := 1.0 - if frame.has("duration"): - duration = frame.duration - elif dict.has("frame_duration"): - duration = dict.frame_duration[frame_i] - - var frame_class := Frame.new(cels, duration) - frame_class.user_data = frame.get("user_data", "") - _deserialize_metadata(frame_class, frame) - frames.append(frame_class) - frame_i += 1 + Global.LayerTypes.TILEMAP: + layers.append(LayerTileMap.new(self, null)) # Parent references to other layers are created when deserializing # a layer, so loop again after creating them: @@ -425,6 +381,43 @@ func deserialize(dict: Dictionary, zip_reader: ZIPReader = null, file: FileAcces layer_dict["blend_mode"] = blend_mode layers[layer_i].deserialize(layer_dict) _deserialize_metadata(layers[layer_i], dict.layers[layer_i]) + + var frame_i := 0 + for frame in dict.frames: + var cels: Array[BaseCel] = [] + var cel_i := 0 + for cel in frame.cels: + var layer := layers[cel_i] + match layer.get_layer_type(): + Global.LayerTypes.PIXEL: + var image := _load_image_from_pxo(frame_i, cel_i, zip_reader, file) + cels.append(PixelCel.new(image)) + Global.LayerTypes.GROUP: + cels.append(GroupCel.new()) + Global.LayerTypes.THREE_D: + if is_instance_valid(file): # For pxo files saved in 0.x + # Don't do anything with it, just read it so that the file can move on + file.get_buffer(size.x * size.y * 4) + cels.append(Cel3D.new(size, true)) + Global.LayerTypes.TILEMAP: + var image := _load_image_from_pxo(frame_i, cel_i, zip_reader, file) + var new_cel := (layer as LayerTileMap).new_cel_from_image(image) + cels.append(new_cel) + cel["pxo_version"] = pxo_version + cels[cel_i].deserialize(cel) + _deserialize_metadata(cels[cel_i], cel) + cel_i += 1 + var duration := 1.0 + if frame.has("duration"): + duration = frame.duration + elif dict.has("frame_duration"): + duration = dict.frame_duration[frame_i] + + var frame_class := Frame.new(cels, duration) + frame_class.user_data = frame.get("user_data", "") + _deserialize_metadata(frame_class, frame) + frames.append(frame_class) + frame_i += 1 if dict.has("tags"): for tag in dict.tags: var new_tag := AnimationTag.new(tag.name, Color(tag.color), tag.from, tag.to) @@ -483,6 +476,37 @@ func _deserialize_metadata(object: Object, dict: Dictionary) -> void: object.set_meta(meta, metadata[meta]) +## Called by [method deserialize], this method loads an image at +## a given [param frame_i] frame index and a [param cel_i] cel index from a pxo file, +## and returns it as an [ImageExtended]. +## If the pxo file is saved with Pixelorama version 1.0 and on, +## the [param zip_reader] is used to load the image. Otherwise, [param file] is used. +func _load_image_from_pxo( + frame_i: int, cel_i: int, zip_reader: ZIPReader, file: FileAccess +) -> ImageExtended: + var image: Image + var indices_data := PackedByteArray() + if is_instance_valid(zip_reader): # For pxo files saved in 1.0+ + var path := "image_data/frames/%s/layer_%s" % [frame_i + 1, cel_i + 1] + var image_data := zip_reader.read_file(path) + image = Image.create_from_data(size.x, size.y, false, get_image_format(), image_data) + var indices_path := "image_data/frames/%s/indices_layer_%s" % [frame_i + 1, cel_i + 1] + if zip_reader.file_exists(indices_path): + indices_data = zip_reader.read_file(indices_path) + elif is_instance_valid(file): # For pxo files saved in 0.x + var buffer := file.get_buffer(size.x * size.y * 4) + image = Image.create_from_data(size.x, size.y, false, get_image_format(), buffer) + var pixelorama_image := ImageExtended.new() + pixelorama_image.is_indexed = is_indexed() + if not indices_data.is_empty() and is_indexed(): + pixelorama_image.indices_image = Image.create_from_data( + size.x, size.y, false, Image.FORMAT_R8, indices_data + ) + pixelorama_image.copy_from(image) + pixelorama_image.select_palette("", true) + return pixelorama_image + + func _size_changed(value: Vector2i) -> void: if not is_instance_valid(tiles): size = value @@ -632,6 +656,57 @@ func get_all_pixel_cels() -> Array[PixelCel]: return cels +## Reads data from [param cels] and appends them to [param data], +## to be used for the undo/redo system. +## It adds data such as the images of [PixelCel]s, +## and calls [method CelTileMap.serialize_undo_data] for [CelTileMap]s. +func serialize_cel_undo_data(cels: Array[BaseCel], data: Dictionary) -> void: + var cels_to_serialize := cels + if not TileSetPanel.placing_tiles: + cels_to_serialize = find_same_tileset_tilemap_cels(cels) + for cel in cels_to_serialize: + if not cel is PixelCel: + continue + var image := (cel as PixelCel).get_image() + image.add_data_to_dictionary(data) + if cel is CelTileMap: + data[cel] = (cel as CelTileMap).serialize_undo_data() + + +## Loads data from [param redo_data] and param [undo_data], +## to be used for the undo/redo system. +## It calls [method Global.undo_redo_compress_images], and +## [method CelTileMap.deserialize_undo_data] for [CelTileMap]s. +func deserialize_cel_undo_data(redo_data: Dictionary, undo_data: Dictionary) -> void: + Global.undo_redo_compress_images(redo_data, undo_data, self) + for cel in redo_data: + if cel is CelTileMap: + (cel as CelTileMap).deserialize_undo_data(redo_data[cel], undo_redo, false) + for cel in undo_data: + if cel is CelTileMap: + (cel as CelTileMap).deserialize_undo_data(undo_data[cel], undo_redo, true) + + +## Returns all [BaseCel]s in [param cels], and for every [CelTileMap], +## this methods finds all other [CelTileMap]s that share the same [TileSetCustom], +## and appends them in the array that is being returned by this method. +func find_same_tileset_tilemap_cels(cels: Array[BaseCel]) -> Array[BaseCel]: + var tilemap_cels: Array[BaseCel] + var current_tilesets: Array[TileSetCustom] + for cel in cels: + tilemap_cels.append(cel) + if cel is not CelTileMap: + continue + current_tilesets.append((cel as CelTileMap).tileset) + for cel in get_all_pixel_cels(): + if cel is not CelTileMap: + continue + if (cel as CelTileMap).tileset in current_tilesets: + if cel not in cels: + tilemap_cels.append(cel) + return tilemap_cels + + ## Re-order layers to take each cel's z-index into account. If all z-indexes are 0, ## then the order of drawing is the same as the order of the layers itself. func order_layers(frame_index := current_frame) -> void: @@ -931,3 +1006,16 @@ func reorder_reference_image(from: int, to: int) -> void: var ri: ReferenceImage = reference_images.pop_at(from) reference_images.insert(to, ri) Global.canvas.reference_image_container.move_child(ri, to) + + +## Adds a new [param tileset] to [member tilesets]. +func add_tileset(tileset: TileSetCustom) -> void: + tilesets.append(tileset) + + +## Loops through all cels in [param cel_dictionary], and for [CelTileMap]s, +## it calls [method CelTileMap.update_tilemap]. +func update_tilemaps(cel_dictionary: Dictionary) -> void: + for cel in cel_dictionary: + if cel is CelTileMap: + (cel as CelTileMap).update_tilemap() diff --git a/src/Classes/SelectionMap.gd b/src/Classes/SelectionMap.gd index abdc3329c..ee9932d55 100644 --- a/src/Classes/SelectionMap.gd +++ b/src/Classes/SelectionMap.gd @@ -77,6 +77,13 @@ func select_pixel(pixel: Vector2i, select := true) -> void: set_pixelv(pixel, Color(0)) +func select_rect(rect: Rect2i, select := true) -> void: + if select: + fill_rect(rect, Color(1, 1, 1, 1)) + else: + fill_rect(rect, Color(0)) + + func select_all() -> void: fill(Color(1, 1, 1, 1)) diff --git a/src/Classes/TileSetCustom.gd b/src/Classes/TileSetCustom.gd new file mode 100644 index 000000000..8870629f6 --- /dev/null +++ b/src/Classes/TileSetCustom.gd @@ -0,0 +1,185 @@ +class_name TileSetCustom +extends RefCounted + +## A Tileset is a collection of tiles, used by [LayerTileMap]s and [CelTileMap]s. +## The tileset contains its [member name], the size of each individual tile, +## and the collection of [TileSetCustom.Tile]s itself. +## Not to be confused with [TileSet], which is a Godot class. + +## Emitted every time the tileset changes, such as when a tile is added, removed or replaced. +## The [CelTileMap] that the changes are coming from is referenced in the [param cel] parameter. +signal updated(cel: CelTileMap, replace_index: int) + +## The tileset's name. +var name := "" +## The size of each individual tile. +var tile_size: Vector2i +## The collection of tiles in the form of an [Array] of type [TileSetCustom.Tile]. +var tiles: Array[Tile] = [] +## If [code]true[/code], the code in [method clear_tileset] does not execute. +## This variable is used to prevent multiple cels from clearing the tileset at the same time. +## In [method clear_tileset], the variable is set to [code]true[/code], and then +## immediately set to [code]false[/code] in the next frame using [method Object.set_deferred]. +var _tileset_has_been_cleared := false + + +## An internal class of [TileSetCustom], which contains data used by individual tiles of a tileset. +class Tile: + ## The [Image] tile itself. + var image: Image + ## The amount of tiles this tile is being used in tilemaps. + var times_used := 1 + + func _init(_image: Image) -> void: + image = _image + + ## A method that checks if the tile should be removed from the tileset. + ## Returns [code]true[/code] if the amount of [member times_used] is 0. + func can_be_removed() -> bool: + return times_used <= 0 + + +func _init(_tile_size: Vector2i, _name := "") -> void: + tile_size = _tile_size + name = _name + var empty_image := Image.create_empty(tile_size.x, tile_size.y, false, Image.FORMAT_RGBA8) + tiles.append(Tile.new(empty_image)) + + +## Adds a new [param image] as a tile to the tileset. +## The [param cel] parameter references the [CelTileMap] that this change is coming from, +## and the [param edit_mode] parameter contains the tile editing mode at the time of this change. +func add_tile(image: Image, cel: CelTileMap) -> void: + var tile := Tile.new(image) + tiles.append(tile) + updated.emit(cel, -1) + + +## Adds a new [param image] as a tile in a given [param position] in the tileset. +## The [param cel] parameter references the [CelTileMap] that this change is coming from, +## and the [param edit_mode] parameter contains the tile editing mode at the time of this change. +func insert_tile(image: Image, position: int, cel: CelTileMap) -> void: + var tile := Tile.new(image) + tiles.insert(position, tile) + updated.emit(cel, -1) + + +## Reduces a tile's [member TileSetCustom.Tile.times_used] by one, +## in a given [param index] in the tileset. +## If the times that tile is used reaches 0 and it can be removed, +## it is being removed from the tileset by calling [method remove_tile_at_index]. +## Returns [code]true[/code] if the tile has been removed. +## The [param cel] parameter references the [CelTileMap] that this change is coming from. +func unuse_tile_at_index(index: int, cel: CelTileMap) -> bool: + tiles[index].times_used -= 1 + if tiles[index].can_be_removed(): + remove_tile_at_index(index, cel) + return true + return false + + +## Removes a tile in a given [param index] from the tileset. +## The [param cel] parameter references the [CelTileMap] that this change is coming from. +func remove_tile_at_index(index: int, cel: CelTileMap) -> void: + tiles.remove_at(index) + updated.emit(cel, -1) + + +## Replaces a tile in a given [param index] in the tileset with a [param new_tile]. +## The [param cel] parameter references the [CelTileMap] that this change is coming from. +func replace_tile_at(new_tile: Image, index: int, cel: CelTileMap) -> void: + tiles[index].image.copy_from(new_tile) + updated.emit(cel, index) + + +## Finds and returns the position of a tile [param image] inside the tileset. +func find_tile(image: Image) -> int: + for i in tiles.size(): + var tile := tiles[i] + if image.get_data() == tile.image.get_data(): + return i + return -1 + + +## Loops through the array of tiles, and automatically removes any tile that can be removed. +## Returns [code]true[/code] if at least one tile has been removed. +## The [param cel] parameter references the [CelTileMap] that this change is coming from. +func remove_unused_tiles(cel: CelTileMap) -> bool: + var tile_removed := false + for i in range(tiles.size() - 1, 0, -1): + var tile := tiles[i] + if tile.can_be_removed(): + remove_tile_at_index(i, cel) + tile_removed = true + return tile_removed + + +## Clears the tileset. Usually called when the project gets resized, +## and tilemap cels are updating their size and clearing the tileset to re-create it. +func clear_tileset(cel: CelTileMap) -> void: + if _tileset_has_been_cleared: + return + tiles.clear() + var empty_image := Image.create_empty(tile_size.x, tile_size.y, false, Image.FORMAT_RGBA8) + tiles.append(Tile.new(empty_image)) + updated.emit(cel, -1) + _tileset_has_been_cleared = true + set_deferred("_tileset_has_been_cleared", false) + + +## Returns the tilemap's info, such as its name and tile size and with a given +## [param tile_index], in the form of text. +func get_text_info(tile_index: int) -> String: + var item_string := " %s (%s×%s)" % [tile_index, tile_size.x, tile_size.y] + if not name.is_empty(): + item_string += ": " + name + return tr("Tileset") + item_string + + +## Finds and returns all of the [LayerTileMap]s that use this tileset. +func find_using_layers(project: Project) -> Array[LayerTileMap]: + var tilemaps: Array[LayerTileMap] + for layer in project.layers: + if layer is not LayerTileMap: + continue + if layer.tileset == self: + tilemaps.append(layer) + return tilemaps + + +## Serializes the data of this class into the form of a [Dictionary], +## which is used so the data can be stored in pxo files. +func serialize() -> Dictionary: + return {"name": name, "tile_size": tile_size, "tile_amount": tiles.size()} + + +## Deserializes the data of a given [member dict] [Dictionary] into class data, +## which is used so data can be loaded from pxo files. +func deserialize(dict: Dictionary) -> void: + name = dict.get("name", name) + tile_size = str_to_var("Vector2i" + dict.get("tile_size")) + + +## Serializes the data of each tile in [member tiles] into the form of a [Dictionary], +## which is used by the undo/redo system. +func serialize_undo_data() -> Dictionary: + var dict := {} + for tile in tiles: + var image_data := tile.image.get_data() + dict[tile.image] = [image_data.compress(), image_data.size(), tile.times_used] + return dict + + +## Deserializes the data of each tile in [param dict], which is used by the undo/redo system. +func deserialize_undo_data(dict: Dictionary, cel: CelTileMap) -> void: + tiles.resize(dict.size()) + var i := 0 + for image: Image in dict: + var tile_data = dict[image] + var buffer_size := tile_data[1] as int + var image_data := (tile_data[0] as PackedByteArray).decompress(buffer_size) + image.set_data(tile_size.x, tile_size.y, false, image.get_format(), image_data) + tiles[i] = Tile.new(image) + tiles[i].times_used = tile_data[2] + i += 1 + updated.emit(cel, -1) diff --git a/src/Tools/BaseDraw.gd b/src/Tools/BaseDraw.gd index 2a1e74f01..e01088a0a 100644 --- a/src/Tools/BaseDraw.gd +++ b/src/Tools/BaseDraw.gd @@ -1,3 +1,4 @@ +class_name BaseDrawTool extends BaseTool const IMAGE_BRUSHES := [Brushes.FILE, Brushes.RANDOM_FILE, Brushes.CUSTOM] @@ -17,6 +18,7 @@ var _brush_image := Image.new() var _orignal_brush_image := Image.new() ## Contains the original _brush_image, without resizing var _brush_texture := ImageTexture.new() var _strength := 1.0 +var _is_eraser := false @warning_ignore("unused_private_class_variable") var _picking_color := false @@ -42,6 +44,7 @@ var _circle_tool_shortcut: Array[Vector2i] func _ready() -> void: super._ready() + Global.cel_switched.connect(update_brush) Global.global_tool_options.dynamics_panel.dynamics_changed.connect(_reset_dynamics) Tools.color_changed.connect(_on_Color_changed) Global.brushes_popup.brush_removed.connect(_on_Brush_removed) @@ -160,34 +163,48 @@ func update_config() -> void: func update_brush() -> void: $Brush/BrushSize.suffix = "px" # Assume we are using default brushes - match _brush.type: - Brushes.PIXEL: - _brush_texture = ImageTexture.create_from_image( - load("res://assets/graphics/pixel_image.png") - ) - _stroke_dimensions = Vector2.ONE * _brush_size - Brushes.CIRCLE: - _brush_texture = ImageTexture.create_from_image( - load("res://assets/graphics/circle_9x9.png") - ) - _stroke_dimensions = Vector2.ONE * _brush_size - Brushes.FILLED_CIRCLE: - _brush_texture = ImageTexture.create_from_image( - load("res://assets/graphics/circle_filled_9x9.png") - ) - _stroke_dimensions = Vector2.ONE * _brush_size - Brushes.FILE, Brushes.RANDOM_FILE, Brushes.CUSTOM: - $Brush/BrushSize.suffix = "00 %" # Use a different size convention on images - if _brush.random.size() <= 1: - _orignal_brush_image = _brush.image - else: - var random := randi() % _brush.random.size() - _orignal_brush_image = _brush.random[random] - _brush_image = _create_blended_brush_image(_orignal_brush_image) - update_brush_image_flip_and_rotate() - _brush_texture = ImageTexture.create_from_image(_brush_image) - update_mirror_brush() - _stroke_dimensions = _brush_image.get_size() + if Tools.is_placing_tiles(): + var tilemap_cel := Global.current_project.get_current_cel() as CelTileMap + var tileset := tilemap_cel.tileset + var tile_index := clampi(TileSetPanel.selected_tile_index, 0, tileset.tiles.size() - 1) + var tile_image := tileset.tiles[tile_index].image + tile_image = tilemap_cel.transform_tile( + tile_image, + TileSetPanel.is_flipped_h, + TileSetPanel.is_flipped_v, + TileSetPanel.is_transposed + ) + _brush_image.copy_from(tile_image) + _brush_texture = ImageTexture.create_from_image(_brush_image) + else: + match _brush.type: + Brushes.PIXEL: + _brush_texture = ImageTexture.create_from_image( + load("res://assets/graphics/pixel_image.png") + ) + _stroke_dimensions = Vector2.ONE * _brush_size + Brushes.CIRCLE: + _brush_texture = ImageTexture.create_from_image( + load("res://assets/graphics/circle_9x9.png") + ) + _stroke_dimensions = Vector2.ONE * _brush_size + Brushes.FILLED_CIRCLE: + _brush_texture = ImageTexture.create_from_image( + load("res://assets/graphics/circle_filled_9x9.png") + ) + _stroke_dimensions = Vector2.ONE * _brush_size + Brushes.FILE, Brushes.RANDOM_FILE, Brushes.CUSTOM: + $Brush/BrushSize.suffix = "00 %" # Use a different size convention on images + if _brush.random.size() <= 1: + _orignal_brush_image = _brush.image + else: + var random := randi() % _brush.random.size() + _orignal_brush_image = _brush.random[random] + _brush_image = _create_blended_brush_image(_orignal_brush_image) + update_brush_image_flip_and_rotate() + _brush_texture = ImageTexture.create_from_image(_brush_image) + update_mirror_brush() + _stroke_dimensions = _brush_image.get_size() _circle_tool_shortcut = [] _indicator = _create_brush_indicator() _polylines = _create_polylines(_indicator) @@ -256,8 +273,9 @@ func prepare_undo(action: String) -> void: func commit_undo() -> void: - var redo_data := _get_undo_data() var project := Global.current_project + project.update_tilemaps(_undo_data) + var redo_data := _get_undo_data() var frame := -1 var layer := -1 if Global.animation_timeline.animation_timer.is_stopped() and project.selected_cels.size() == 1: @@ -265,7 +283,7 @@ func commit_undo() -> void: layer = project.current_layer project.undos += 1 - Global.undo_redo_compress_images(redo_data, _undo_data, project) + project.deserialize_cel_undo_data(redo_data, _undo_data) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false, frame, layer)) project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true, frame, layer)) project.undo_redo.commit_action() @@ -303,6 +321,22 @@ func draw_end(pos: Vector2i) -> void: _polylines = _create_polylines(_indicator) +func draw_tile(pos: Vector2i) -> void: + var tile_index := 0 if _is_eraser else TileSetPanel.selected_tile_index + var mirrored_positions := Tools.get_mirrored_positions(pos, Global.current_project) + var tile_positions := PackedInt32Array() + tile_positions.resize(mirrored_positions.size() + 1) + tile_positions[0] = get_cell_position(pos) + for i in mirrored_positions.size(): + var mirrored_position := mirrored_positions[i] + tile_positions[i + 1] = get_cell_position(mirrored_position) + for cel in _get_selected_draw_cels(): + if cel is not CelTileMap: + return + for tile_position in tile_positions: + (cel as CelTileMap).set_index(tile_position, tile_index) + + func _prepare_tool() -> void: if !Global.current_project.layers[Global.current_project.current_layer].can_layer_get_drawn(): return @@ -482,7 +516,14 @@ func remove_unselected_parts_of_brush(brush: Image, dst: Vector2i) -> Image: func draw_indicator(left: bool) -> void: var color := Global.left_tool_color if left else Global.right_tool_color - draw_indicator_at(snap_position(_cursor), Vector2i.ZERO, color) + var snapped_position := snap_position(_cursor) + if Tools.is_placing_tiles(): + var tileset := (Global.current_project.get_current_cel() as CelTileMap).tileset + var grid_size := tileset.tile_size + snapped_position = _snap_to_rectangular_grid_center( + snapped_position, grid_size, Vector2i.ZERO, -1 + ) + draw_indicator_at(snapped_position, Vector2i.ZERO, color) if ( Global.current_project.has_selection and Global.current_project.tiles.mode == Tiles.MODE.NONE @@ -491,7 +532,7 @@ func draw_indicator(left: bool) -> void: var nearest_pos := Global.current_project.selection_map.get_nearest_position(pos) if nearest_pos != Vector2i.ZERO: var offset := nearest_pos - draw_indicator_at(snap_position(_cursor), offset, Color.GREEN) + draw_indicator_at(snapped_position, offset, Color.GREEN) return if Global.current_project.tiles.mode and Global.current_project.tiles.has_point(_cursor): @@ -499,12 +540,12 @@ func draw_indicator(left: bool) -> void: var nearest_tile := Global.current_project.tiles.get_nearest_tile(pos) if nearest_tile.position != Vector2i.ZERO: var offset := nearest_tile.position - draw_indicator_at(snap_position(_cursor), offset, Color.GREEN) + draw_indicator_at(snapped_position, offset, Color.GREEN) func draw_indicator_at(pos: Vector2i, offset: Vector2i, color: Color) -> void: var canvas: Node2D = Global.canvas.indicators - if _brush.type in IMAGE_BRUSHES and not _draw_line: + if _brush.type in IMAGE_BRUSHES and not _draw_line or Tools.is_placing_tiles(): pos -= _brush_image.get_size() / 2 pos -= offset canvas.draw_texture(_brush_texture, pos) @@ -539,6 +580,9 @@ func _set_pixel_no_cache(pos: Vector2i, ignore_mirroring := false) -> void: pos = _stroke_project.tiles.get_canon_position(pos) if Global.current_project.has_selection: pos = Global.current_project.selection_map.get_canon_position(pos) + if Tools.is_placing_tiles(): + draw_tile(pos) + return if !_stroke_project.can_pixel_get_drawn(pos): return @@ -727,11 +771,7 @@ func _get_undo_data() -> Dictionary: if not cel is PixelCel: continue cels.append(cel) - for cel in cels: - if not cel is PixelCel: - continue - var image := (cel as PixelCel).get_image() - image.add_data_to_dictionary(data) + project.serialize_cel_undo_data(cels, data) return data diff --git a/src/Tools/BaseSelectionTool.gd b/src/Tools/BaseSelectionTool.gd index 8ed365050..26cb48d55 100644 --- a/src/Tools/BaseSelectionTool.gd +++ b/src/Tools/BaseSelectionTool.gd @@ -152,6 +152,10 @@ func draw_move(pos: Vector2i) -> void: if not _move: return + if Tools.is_placing_tiles(): + var tileset := (Global.current_project.get_current_cel() as CelTileMap).tileset + var grid_size := tileset.tile_size + pos = Tools.snap_to_rectangular_grid_boundary(pos, grid_size) if Input.is_action_pressed("transform_snap_axis"): # Snap to axis var angle := Vector2(pos).angle_to_point(_start_pos) if absf(angle) <= PI / 4 or absf(angle) >= 3 * PI / 4: @@ -211,6 +215,13 @@ func apply_selection(_position: Vector2i) -> void: _intersect = true +func select_tilemap_cell( + cel: CelTileMap, cell_position: int, selection: SelectionMap, select: bool +) -> void: + var rect := Rect2i(cel.get_cell_coords_in_image(cell_position), cel.tileset.tile_size) + selection.select_rect(rect, select) + + func _on_confirm_button_pressed() -> void: if selection_node.is_moving_content: selection_node.transform_content_confirm() diff --git a/src/Tools/BaseShapeDrawer.gd b/src/Tools/BaseShapeDrawer.gd index 4af1a156f..5111b626f 100644 --- a/src/Tools/BaseShapeDrawer.gd +++ b/src/Tools/BaseShapeDrawer.gd @@ -1,4 +1,4 @@ -extends "res://src/Tools/BaseDraw.gd" +extends BaseDrawTool var _start := Vector2i.ZERO var _offset := Vector2i.ZERO @@ -128,8 +128,8 @@ func draw_move(pos: Vector2i) -> void: func draw_end(pos: Vector2i) -> void: pos = snap_position(pos) - super.draw_end(pos) if _picking_color: + super.draw_end(pos) return if _drawing: @@ -150,6 +150,7 @@ func draw_end(pos: Vector2i) -> void: _drawing = false _displace_origin = false cursor_text = "" + super.draw_end(pos) func draw_preview() -> void: @@ -188,9 +189,12 @@ func _draw_shape(origin: Vector2i, dest: Vector2i) -> void: _drawer.reset() # Draw each point offsetted based on the shape's thickness var draw_pos := point + thickness_vector - if Global.current_project.can_pixel_get_drawn(draw_pos): - for image in images: - _drawer.set_pixel(image, draw_pos, tool_slot.color) + if Tools.is_placing_tiles(): + draw_tile(draw_pos) + else: + if Global.current_project.can_pixel_get_drawn(draw_pos): + for image in images: + _drawer.set_pixel(image, draw_pos, tool_slot.color) commit_undo() diff --git a/src/Tools/BaseTool.gd b/src/Tools/BaseTool.gd index 73baaac0c..cdc14345d 100644 --- a/src/Tools/BaseTool.gd +++ b/src/Tools/BaseTool.gd @@ -75,7 +75,17 @@ func draw_move(pos: Vector2i) -> void: func draw_end(_pos: Vector2i) -> void: is_moving = false _draw_cache = [] - Global.current_project.can_undo = true + var project := Global.current_project + project.can_undo = true + + +func get_cell_position(pos: Vector2i) -> int: + var tile_pos := 0 + if Global.current_project.get_current_cel() is not CelTileMap: + return tile_pos + var cel := Global.current_project.get_current_cel() as CelTileMap + tile_pos = cel.get_cell_position(pos) + return tile_pos func cursor_move(pos: Vector2i) -> void: @@ -129,52 +139,14 @@ func draw_preview() -> void: func snap_position(pos: Vector2) -> Vector2: var snapping_distance := Global.snapping_distance / Global.camera.zoom.x if Global.snap_to_rectangular_grid_boundary: - var grid_pos := pos.snapped(Global.grids[0].grid_size) - grid_pos += Vector2(Global.grids[0].grid_offset) - # keeping grid_pos as is would have been fine but this adds extra accuracy as to - # which snap point (from the list below) is closest to mouse and occupy THAT point - # t_l is for "top left" and so on - var t_l := grid_pos + Vector2(-Global.grids[0].grid_size.x, -Global.grids[0].grid_size.y) - var t_c := grid_pos + Vector2(0, -Global.grids[0].grid_size.y) - var t_r := grid_pos + Vector2(Global.grids[0].grid_size.x, -Global.grids[0].grid_size.y) - var m_l := grid_pos + Vector2(-Global.grids[0].grid_size.x, 0) - var m_c := grid_pos - var m_r := grid_pos + Vector2(Global.grids[0].grid_size.x, 0) - var b_l := grid_pos + Vector2(-Global.grids[0].grid_size.x, Global.grids[0].grid_size.y) - var b_c := grid_pos + Vector2(0, Global.grids[0].grid_size.y) - var b_r := grid_pos + Vector2(Global.grids[0].grid_size) - var vec_arr: PackedVector2Array = [t_l, t_c, t_r, m_l, m_c, m_r, b_l, b_c, b_r] - for vec in vec_arr: - if vec.distance_to(pos) < grid_pos.distance_to(pos): - grid_pos = vec - - var grid_point := _get_closest_point_to_grid(pos, snapping_distance, grid_pos) - if grid_point != Vector2.INF: - pos = grid_point.floor() + pos = Tools.snap_to_rectangular_grid_boundary( + pos, Global.grids[0].grid_size, Global.grids[0].grid_offset, snapping_distance + ) if Global.snap_to_rectangular_grid_center: - var grid_center := ( - pos.snapped(Global.grids[0].grid_size) + Vector2(Global.grids[0].grid_size / 2) + pos = _snap_to_rectangular_grid_center( + pos, Global.grids[0].grid_size, Global.grids[0].grid_offset, snapping_distance ) - grid_center += Vector2(Global.grids[0].grid_offset) - # keeping grid_center as is would have been fine but this adds extra accuracy as to - # which snap point (from the list below) is closest to mouse and occupy THAT point - # t_l is for "top left" and so on - var t_l := grid_center + Vector2(-Global.grids[0].grid_size.x, -Global.grids[0].grid_size.y) - var t_c := grid_center + Vector2(0, -Global.grids[0].grid_size.y) - var t_r := grid_center + Vector2(Global.grids[0].grid_size.x, -Global.grids[0].grid_size.y) - var m_l := grid_center + Vector2(-Global.grids[0].grid_size.x, 0) - var m_c := grid_center - var m_r := grid_center + Vector2(Global.grids[0].grid_size.x, 0) - var b_l := grid_center + Vector2(-Global.grids[0].grid_size.x, Global.grids[0].grid_size.y) - var b_c := grid_center + Vector2(0, Global.grids[0].grid_size.y) - var b_r := grid_center + Vector2(Global.grids[0].grid_size) - var vec_arr := [t_l, t_c, t_r, m_l, m_c, m_r, b_l, b_c, b_r] - for vec in vec_arr: - if vec.distance_to(pos) < grid_center.distance_to(pos): - grid_center = vec - if grid_center.distance_to(pos) <= snapping_distance: - pos = grid_center.floor() var snap_to := Vector2.INF if Global.snap_to_guides: @@ -240,57 +212,39 @@ func mirror_array(array: Array[Vector2i], callable := func(_array): pass) -> Arr return new_array -func _get_closest_point_to_grid(pos: Vector2, distance: float, grid_pos: Vector2) -> Vector2: - # If the cursor is close to the start/origin of a grid cell, snap to that - var snap_distance := distance * Vector2.ONE - var closest_point := Vector2.INF - var rect := Rect2() - rect.position = pos - (snap_distance / 4.0) - rect.end = pos + (snap_distance / 4.0) - if rect.has_point(grid_pos): - closest_point = grid_pos - return closest_point - # If the cursor is far from the grid cell origin but still close to a grid line - # Look for a point close to a horizontal grid line - var grid_start_hor := Vector2(0, grid_pos.y) - var grid_end_hor := Vector2(Global.current_project.size.x, grid_pos.y) - var closest_point_hor := _get_closest_point_to_segment( - pos, distance, grid_start_hor, grid_end_hor - ) - # Look for a point close to a vertical grid line - var grid_start_ver := Vector2(grid_pos.x, 0) - var grid_end_ver := Vector2(grid_pos.x, Global.current_project.size.y) - var closest_point_ver := _get_closest_point_to_segment( - pos, distance, grid_start_ver, grid_end_ver - ) - # Snap to the closest point to the closest grid line - var horizontal_distance := (closest_point_hor - pos).length() - var vertical_distance := (closest_point_ver - pos).length() - if horizontal_distance < vertical_distance: - closest_point = closest_point_hor - elif horizontal_distance > vertical_distance: - closest_point = closest_point_ver - elif horizontal_distance == vertical_distance and closest_point_hor != Vector2.INF: - closest_point = grid_pos - return closest_point - - -func _get_closest_point_to_segment( - pos: Vector2, distance: float, s1: Vector2, s2: Vector2 +func _snap_to_rectangular_grid_center( + pos: Vector2, grid_size: Vector2i, grid_offset: Vector2i, snapping_distance: float ) -> Vector2: - var test_line := (s2 - s1).rotated(deg_to_rad(90)).normalized() - var from_a := pos - test_line * distance - var from_b := pos + test_line * distance - var closest_point := Vector2.INF - if Geometry2D.segment_intersects_segment(from_a, from_b, s1, s2): - closest_point = Geometry2D.get_closest_point_to_segment(pos, s1, s2) - return closest_point + var grid_center := pos.snapped(grid_size) + Vector2(grid_size / 2) + grid_center += Vector2(grid_offset) + # keeping grid_center as is would have been fine but this adds extra accuracy as to + # which snap point (from the list below) is closest to mouse and occupy THAT point + # t_l is for "top left" and so on + var t_l := grid_center + Vector2(-grid_size.x, -grid_size.y) + var t_c := grid_center + Vector2(0, -grid_size.y) + var t_r := grid_center + Vector2(grid_size.x, -grid_size.y) + var m_l := grid_center + Vector2(-grid_size.x, 0) + var m_c := grid_center + var m_r := grid_center + Vector2(grid_size.x, 0) + var b_l := grid_center + Vector2(-grid_size.x, grid_size.y) + var b_c := grid_center + Vector2(0, grid_size.y) + var b_r := grid_center + Vector2(grid_size) + var vec_arr := [t_l, t_c, t_r, m_l, m_c, m_r, b_l, b_c, b_r] + for vec in vec_arr: + if vec.distance_to(pos) < grid_center.distance_to(pos): + grid_center = vec + if snapping_distance < 0: + pos = grid_center.floor() + else: + if grid_center.distance_to(pos) <= snapping_distance: + pos = grid_center.floor() + return pos func _snap_to_guide( snap_to: Vector2, pos: Vector2, distance: float, s1: Vector2, s2: Vector2 ) -> Vector2: - var closest_point := _get_closest_point_to_segment(pos, distance, s1, s2) + var closest_point := Tools.get_closest_point_to_segment(pos, distance, s1, s2) if closest_point == Vector2.INF: # Is not close to a guide return Vector2.INF # Snap to the closest guide @@ -322,6 +276,17 @@ func _get_draw_image() -> ImageExtended: return Global.current_project.get_current_cel().get_image() +func _get_selected_draw_cels() -> Array[BaseCel]: + var cels: Array[BaseCel] + var project := Global.current_project + for cel_index in project.selected_cels: + var cel: BaseCel = project.frames[cel_index[0]].cels[cel_index[1]] + if not cel is PixelCel: + continue + cels.append(cel) + return cels + + func _get_selected_draw_images() -> Array[ImageExtended]: var images: Array[ImageExtended] = [] var project := Global.current_project @@ -340,7 +305,10 @@ func _pick_color(pos: Vector2i) -> void: if pos.x < 0 or pos.y < 0: return - + if Tools.is_placing_tiles(): + var cel := Global.current_project.get_current_cel() as CelTileMap + Tools.selected_tile_index_changed.emit(cel.get_cell_index_at_coords(pos)) + return var image := Image.new() image.copy_from(_get_draw_image()) if pos.x > image.get_width() - 1 or pos.y > image.get_height() - 1: diff --git a/src/Tools/DesignTools/Bucket.gd b/src/Tools/DesignTools/Bucket.gd index 801b4e634..e46149ae3 100644 --- a/src/Tools/DesignTools/Bucket.gd +++ b/src/Tools/DesignTools/Bucket.gd @@ -186,6 +186,11 @@ func draw_end(pos: Vector2i) -> void: commit_undo() +func draw_tile(pos: Vector2i, cel: CelTileMap) -> void: + var tile_position := get_cell_position(pos) + cel.set_index(tile_position, TileSetPanel.selected_tile_index) + + func fill(pos: Vector2i) -> void: match _fill_area: FillArea.AREA: @@ -199,6 +204,17 @@ func fill(pos: Vector2i) -> void: func fill_in_color(pos: Vector2i) -> void: var project := Global.current_project + if Tools.is_placing_tiles(): + for cel in _get_selected_draw_cels(): + if cel is not CelTileMap: + continue + var tilemap_cel := cel as CelTileMap + var tile_index := tilemap_cel.get_cell_index_at_coords(pos) + for i in tilemap_cel.cells.size(): + var cell := tilemap_cel.cells[i] + if cell.index == tile_index: + tilemap_cel.set_index(i, TileSetPanel.selected_tile_index) + return var color := project.get_current_cel().get_image().get_pixelv(pos) var images := _get_selected_draw_images() for image in images: @@ -311,6 +327,74 @@ func fill_in_selection() -> void: gen.generate_image(image, PATTERN_FILL_SHADER, params, project.size) +func _flood_fill(pos: Vector2i) -> void: + # implements the floodfill routine by Shawn Hargreaves + # from https://www1.udel.edu/CIS/software/dist/allegro-4.2.1/src/flood.c + var project := Global.current_project + if Tools.is_placing_tiles(): + for cel in _get_selected_draw_cels(): + if cel is not CelTileMap: + continue + var tile_index := (cel as CelTileMap).get_cell_index_at_coords(pos) + # init flood data structures + _allegro_flood_segments = [] + _allegro_image_segments = [] + _compute_segments_for_tilemap(pos, cel, tile_index) + _color_segments_tilemap(cel) + return + + var images := _get_selected_draw_images() + for image in images: + if Tools.check_alpha_lock(image, pos): + continue + var color: Color = image.get_pixelv(pos) + if _fill_with == FillWith.COLOR or _pattern == null: + # end early if we are filling with the same color + if tool_slot.color.is_equal_approx(color): + continue + else: + # end early if we are filling with an empty pattern + var pattern_size := _pattern.image.get_size() + if pattern_size.x == 0 or pattern_size.y == 0: + return + # init flood data structures + _allegro_flood_segments = [] + _allegro_image_segments = [] + _compute_segments_for_image(pos, project, image, color) + # now actually color the image: since we have already checked a few things for the points + # we'll process here, we're going to skip a bunch of safety checks to speed things up. + _color_segments(image) + + +func _compute_segments_for_image( + pos: Vector2i, project: Project, image: Image, src_color: Color +) -> void: + # initially allocate at least 1 segment per line of image + for j in image.get_height(): + _add_new_segment(j) + # start flood algorithm + _flood_line_around_point(pos, project, image, src_color) + # test all segments while also discovering more + var done := false + while not done: + done = true + var max_index := _allegro_flood_segments.size() + for c in max_index: + var p := _allegro_flood_segments[c] + if p.todo_below: # check below the segment? + p.todo_below = false + if _check_flooded_segment( + p.y + 1, p.left_position, p.right_position, project, image, src_color + ): + done = false + if p.todo_above: # check above the segment? + p.todo_above = false + if _check_flooded_segment( + p.y - 1, p.left_position, p.right_position, project, image, src_color + ): + done = false + + ## Add a new segment to the array func _add_new_segment(y := 0) -> void: _allegro_flood_segments.append(Segment.new(y)) @@ -407,62 +491,6 @@ func _check_flooded_segment( return ret -func _flood_fill(pos: Vector2i) -> void: - # implements the floodfill routine by Shawn Hargreaves - # from https://www1.udel.edu/CIS/software/dist/allegro-4.2.1/src/flood.c - var project := Global.current_project - var images := _get_selected_draw_images() - for image in images: - if Tools.check_alpha_lock(image, pos): - continue - var color: Color = image.get_pixelv(pos) - if _fill_with == FillWith.COLOR or _pattern == null: - # end early if we are filling with the same color - if tool_slot.color.is_equal_approx(color): - continue - else: - # end early if we are filling with an empty pattern - var pattern_size := _pattern.image.get_size() - if pattern_size.x == 0 or pattern_size.y == 0: - return - # init flood data structures - _allegro_flood_segments = [] - _allegro_image_segments = [] - _compute_segments_for_image(pos, project, image, color) - # now actually color the image: since we have already checked a few things for the points - # we'll process here, we're going to skip a bunch of safety checks to speed things up. - _color_segments(image) - - -func _compute_segments_for_image( - pos: Vector2i, project: Project, image: Image, src_color: Color -) -> void: - # initially allocate at least 1 segment per line of image - for j in image.get_height(): - _add_new_segment(j) - # start flood algorithm - _flood_line_around_point(pos, project, image, src_color) - # test all segments while also discovering more - var done := false - while not done: - done = true - var max_index := _allegro_flood_segments.size() - for c in max_index: - var p := _allegro_flood_segments[c] - if p.todo_below: # check below the segment? - p.todo_below = false - if _check_flooded_segment( - p.y + 1, p.left_position, p.right_position, project, image, src_color - ): - done = false - if p.todo_above: # check above the segment? - p.todo_above = false - if _check_flooded_segment( - p.y - 1, p.left_position, p.right_position, project, image, src_color - ): - done = false - - func _color_segments(image: ImageExtended) -> void: if _fill_with == FillWith.COLOR or _pattern == null: # This is needed to ensure that the color used to fill is not wrong, due to float @@ -493,9 +521,119 @@ func _set_pixel_pattern(image: ImageExtended, x: int, y: int, pattern_size: Vect image.set_pixel_custom(x, y, pc) +func _compute_segments_for_tilemap(pos: Vector2i, cel: CelTileMap, src_index: int) -> void: + # initially allocate at least 1 segment per line of the tilemap + for j in cel.vertical_cells: + _add_new_segment(j) + pos /= cel.tileset.tile_size + # start flood algorithm + _flood_line_around_point_tilemap(pos, cel, src_index) + # test all segments while also discovering more + var done := false + while not done: + done = true + var max_index := _allegro_flood_segments.size() + for c in max_index: + var p := _allegro_flood_segments[c] + if p.todo_below: # check below the segment? + p.todo_below = false + if _check_flooded_segment_tilemap( + p.y + 1, p.left_position, p.right_position, cel, src_index + ): + done = false + if p.todo_above: # check above the segment? + p.todo_above = false + if _check_flooded_segment_tilemap( + p.y - 1, p.left_position, p.right_position, cel, src_index + ): + done = false + + +## Fill an horizontal segment around the specified position, and adds it to the +## list of segments filled. Returns the first x coordinate after the part of the +## line that has been filled. +## Τhis method is called by [method _flood_fill] after the required data structures +## have been initialized. +func _flood_line_around_point_tilemap(pos: Vector2i, cel: CelTileMap, src_index: int) -> int: + if cel.get_cell_index_at_coords_in_tilemap_space(pos) != src_index: + return pos.x + 1 + var west := pos + var east := pos + while west.x >= 0 && cel.get_cell_index_at_coords_in_tilemap_space(west) == src_index: + west += Vector2i.LEFT + while ( + east.x < cel.horizontal_cells + && cel.get_cell_index_at_coords_in_tilemap_space(east) == src_index + ): + east += Vector2i.RIGHT + # Make a note of the stuff we processed + var c := pos.y + var segment := _allegro_flood_segments[c] + # we may have already processed some segments on this y coordinate + if segment.flooding: + while segment.next > 0: + c = segment.next # index of next segment in this line of image + segment = _allegro_flood_segments[c] + # found last current segment on this line + c = _allegro_flood_segments.size() + segment.next = c + _add_new_segment(pos.y) + segment = _allegro_flood_segments[c] + # set the values for the current segment + segment.flooding = true + segment.left_position = west.x + 1 + segment.right_position = east.x - 1 + segment.y = pos.y + segment.next = 0 + # Should we process segments above or below this one? + # when there is a selected area, the pixels above and below the one we started creating this + # segment from may be outside it. It's easier to assume we should be checking for segments + # above and below this one than to specifically check every single pixel in it, because that + # test will be performed later anyway. + # On the other hand, this test we described is the same `project.can_pixel_get_drawn` does if + # there is no selection, so we don't need branching here. + segment.todo_above = pos.y > 0 + segment.todo_below = pos.y < cel.vertical_cells - 1 + # this is an actual segment we should be coloring, so we add it to the results for the + # current image + if segment.right_position >= segment.left_position: + _allegro_image_segments.append(segment) + # we know the point just east of the segment is not part of a segment that should be + # processed, else it would be part of this segment + return east.x + 1 + + +func _check_flooded_segment_tilemap( + y: int, left: int, right: int, cel: CelTileMap, src_index: int +) -> bool: + var ret := false + var c := 0 + while left <= right: + c = y + while true: + var segment := _allegro_flood_segments[c] + if left >= segment.left_position and left <= segment.right_position: + left = segment.right_position + 2 + break + c = segment.next + if c == 0: # couldn't find a valid segment, so we draw a new one + left = _flood_line_around_point_tilemap(Vector2i(left, y), cel, src_index) + ret = true + break + return ret + + +func _color_segments_tilemap(cel: CelTileMap) -> void: + for c in _allegro_image_segments.size(): + var p := _allegro_image_segments[c] + for px in range(p.left_position, p.right_position + 1): + draw_tile(Vector2i(px, p.y) * cel.tileset.tile_size, cel) + + func commit_undo() -> void: - var redo_data := _get_undo_data() var project := Global.current_project + project.update_tilemaps(_undo_data) + var redo_data := _get_undo_data() var frame := -1 var layer := -1 if Global.animation_timeline.animation_timer.is_stopped() and project.selected_cels.size() == 1: @@ -504,7 +642,7 @@ func commit_undo() -> void: project.undos += 1 project.undo_redo.create_action("Draw") - Global.undo_redo_compress_images(redo_data, _undo_data, project) + project.deserialize_cel_undo_data(redo_data, _undo_data) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false, frame, layer)) project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true, frame, layer)) project.undo_redo.commit_action() @@ -514,14 +652,13 @@ func commit_undo() -> void: func _get_undo_data() -> Dictionary: var data := {} if Global.animation_timeline.animation_timer.is_stopped(): - var images := _get_selected_draw_images() - for image in images: - image.add_data_to_dictionary(data) + Global.current_project.serialize_cel_undo_data(_get_selected_draw_cels(), data) else: + var cels: Array[BaseCel] for frame in Global.current_project.frames: var cel := frame.cels[Global.current_project.current_layer] if not cel is PixelCel: continue - var image := (cel as PixelCel).get_image() - image.add_data_to_dictionary(data) + cels.append(cel) + Global.current_project.serialize_cel_undo_data(cels, data) return data diff --git a/src/Tools/DesignTools/CurveTool.gd b/src/Tools/DesignTools/CurveTool.gd index c326d5b43..c14684534 100644 --- a/src/Tools/DesignTools/CurveTool.gd +++ b/src/Tools/DesignTools/CurveTool.gd @@ -1,4 +1,4 @@ -extends "res://src/Tools/BaseDraw.gd" +extends BaseDrawTool var _curve := Curve2D.new() ## The [Curve2D] responsible for the shape of the curve being drawn. var _drawing := false ## Set to true when a curve is being drawn. @@ -195,9 +195,12 @@ func _draw_shape() -> void: func _draw_pixel(point: Vector2i, images: Array[ImageExtended]) -> void: - if Global.current_project.can_pixel_get_drawn(point): - for image in images: - _drawer.set_pixel(image, point, tool_slot.color) + if Tools.is_placing_tiles(): + draw_tile(point) + else: + if Global.current_project.can_pixel_get_drawn(point): + for image in images: + _drawer.set_pixel(image, point, tool_slot.color) func _clear() -> void: diff --git a/src/Tools/DesignTools/Eraser.gd b/src/Tools/DesignTools/Eraser.gd index 8f9d15b9f..e28868d87 100644 --- a/src/Tools/DesignTools/Eraser.gd +++ b/src/Tools/DesignTools/Eraser.gd @@ -1,4 +1,4 @@ -extends "res://src/Tools/BaseDraw.gd" +extends BaseDrawTool var _last_position := Vector2.INF var _clear_image: Image @@ -19,6 +19,7 @@ class EraseOp: func _init() -> void: _drawer.color_op = EraseOp.new() + _is_eraser = true _clear_image = Image.create(1, 1, false, Image.FORMAT_RGBA8) _clear_image.fill(Color(0, 0, 0, 0)) @@ -42,13 +43,11 @@ func draw_start(pos: Vector2i) -> void: _pick_color(pos) return _picking_color = false - Global.canvas.selection.transform_content_confirm() + prepare_undo("Draw") update_mask(_strength == 1) _changed = false _drawer.color_op.changed = false - - prepare_undo("Draw") _drawer.reset() _draw_line = Input.is_action_pressed("draw_create_line") diff --git a/src/Tools/DesignTools/LineTool.gd b/src/Tools/DesignTools/LineTool.gd index 3f2f858b2..5e8917f0d 100644 --- a/src/Tools/DesignTools/LineTool.gd +++ b/src/Tools/DesignTools/LineTool.gd @@ -1,4 +1,4 @@ -extends "res://src/Tools/BaseDraw.gd" +extends BaseDrawTool var _original_pos := Vector2i.ZERO var _start := Vector2i.ZERO @@ -120,8 +120,8 @@ func draw_move(pos: Vector2i) -> void: func draw_end(pos: Vector2i) -> void: pos = snap_position(pos) - super.draw_end(pos) if _picking_color: + super.draw_end(pos) return if _drawing: @@ -144,6 +144,7 @@ func draw_end(pos: Vector2i) -> void: _drawing = false _displace_origin = false cursor_text = "" + super.draw_end(pos) func draw_preview() -> void: @@ -173,10 +174,13 @@ func _draw_shape() -> void: for point in points: # Reset drawer every time because pixel perfect sometimes breaks the tool _drawer.reset() - # Draw each point offsetted based on the shape's thickness - if Global.current_project.can_pixel_get_drawn(point): - for image in images: - _drawer.set_pixel(image, point, tool_slot.color) + if Tools.is_placing_tiles(): + draw_tile(point) + else: + # Draw each point offsetted based on the shape's thickness + if Global.current_project.can_pixel_get_drawn(point): + for image in images: + _drawer.set_pixel(image, point, tool_slot.color) commit_undo() diff --git a/src/Tools/DesignTools/Pencil.gd b/src/Tools/DesignTools/Pencil.gd index 520ab865a..1817d8db1 100644 --- a/src/Tools/DesignTools/Pencil.gd +++ b/src/Tools/DesignTools/Pencil.gd @@ -1,4 +1,4 @@ -extends "res://src/Tools/BaseDraw.gd" +extends BaseDrawTool var _prev_mode := false var _last_position := Vector2i(Vector2.INF) @@ -103,6 +103,7 @@ func draw_start(pos: Vector2i) -> void: _picking_color = false Global.canvas.selection.transform_content_confirm() + prepare_undo("Draw") var can_skip_mask := true if tool_slot.color.a < 1 and !_overwrite: can_skip_mask = false @@ -112,7 +113,6 @@ func draw_start(pos: Vector2i) -> void: _drawer.color_op.overwrite = _overwrite _draw_points = [] - prepare_undo("Draw") _drawer.reset() _draw_line = Input.is_action_pressed("draw_create_line") diff --git a/src/Tools/DesignTools/Shading.gd b/src/Tools/DesignTools/Shading.gd index 74faea35a..2413158ed 100644 --- a/src/Tools/DesignTools/Shading.gd +++ b/src/Tools/DesignTools/Shading.gd @@ -1,4 +1,4 @@ -extends "res://src/Tools/BaseDraw.gd" +extends BaseDrawTool enum ShadingMode { SIMPLE, HUE_SHIFTING, COLOR_REPLACE } enum LightenDarken { LIGHTEN, DARKEN } diff --git a/src/Tools/SelectionTools/ColorSelect.gd b/src/Tools/SelectionTools/ColorSelect.gd index 1e172929f..c1606db06 100644 --- a/src/Tools/SelectionTools/ColorSelect.gd +++ b/src/Tools/SelectionTools/ColorSelect.gd @@ -32,23 +32,46 @@ func apply_selection(pos: Vector2i) -> void: if pos.x > project.size.x - 1 or pos.y > project.size.y - 1: return - var cel_image := Image.new() - cel_image.copy_from(_get_draw_image()) - var color := cel_image.get_pixelv(pos) var operation := 0 if _subtract: operation = 1 elif _intersect: operation = 2 - var params := {"color": color, "tolerance": _tolerance, "operation": operation} - if _add or _subtract or _intersect: - var selection_tex := ImageTexture.create_from_image(project.selection_map) - params["selection"] = selection_tex - var gen := ShaderImageEffect.new() - gen.generate_image(cel_image, shader, params, project.size) - cel_image.convert(Image.FORMAT_LA8) + if Tools.is_placing_tiles(): + var prev_selection_map := SelectionMap.new() # Used for intersect + prev_selection_map.copy_from(project.selection_map) + if !_add and !_subtract and !_intersect: + Global.canvas.selection.clear_selection() + if _intersect: + project.selection_map.clear() + for cel in _get_selected_draw_cels(): + if cel is not CelTileMap: + continue + var tilemap_cel := cel as CelTileMap + var tile_index := tilemap_cel.get_cell_index_at_coords(pos) + for i in tilemap_cel.cells.size(): + var cell := tilemap_cel.cells[i] + if cell.index == tile_index: + if _intersect: + var p := (cel as CelTileMap).get_cell_coords_in_image(i) + select_tilemap_cell( + cel, i, project.selection_map, prev_selection_map.is_pixel_selected(p) + ) + else: + select_tilemap_cell(cel, i, project.selection_map, !_subtract) + else: + var cel_image := Image.new() + cel_image.copy_from(_get_draw_image()) + var color := cel_image.get_pixelv(pos) + var params := {"color": color, "tolerance": _tolerance, "operation": operation} + if _add or _subtract or _intersect: + var selection_tex := ImageTexture.create_from_image(project.selection_map) + params["selection"] = selection_tex + var gen := ShaderImageEffect.new() + gen.generate_image(cel_image, shader, params, project.size) + cel_image.convert(Image.FORMAT_LA8) - project.selection_map.copy_from(cel_image) + project.selection_map.copy_from(cel_image) Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() Global.canvas.selection.commit_undo("Select", undo_data) diff --git a/src/Tools/SelectionTools/EllipseSelect.gd b/src/Tools/SelectionTools/EllipseSelect.gd index 80539a797..1d4234d4d 100644 --- a/src/Tools/SelectionTools/EllipseSelect.gd +++ b/src/Tools/SelectionTools/EllipseSelect.gd @@ -81,18 +81,36 @@ func apply_selection(_position: Vector2i) -> void: Global.canvas.selection.commit_undo("Select", undo_data) if _rect.size == Vector2i.ZERO: return - set_ellipse(project.selection_map, _rect.position) - # Handle mirroring - var mirror_positions := Tools.get_mirrored_positions(_rect.position, project, 1) - var mirror_ends := Tools.get_mirrored_positions(_rect.end, project, 1) - for i in mirror_positions.size(): - var mirror_rect := Rect2i() - mirror_rect.position = mirror_positions[i] - mirror_rect.end = mirror_ends[i] - set_ellipse(project.selection_map, mirror_rect.abs().position) + if Tools.is_placing_tiles(): + var operation := 0 + if _subtract: + operation = 1 + elif _intersect: + operation = 2 + Global.canvas.selection.select_rect(_rect, operation) + # Handle mirroring + var mirror_positions := Tools.get_mirrored_positions(_rect.position, project, 1) + var mirror_ends := Tools.get_mirrored_positions(_rect.end, project, 1) + for i in mirror_positions.size(): + var mirror_rect := Rect2i() + mirror_rect.position = mirror_positions[i] + mirror_rect.end = mirror_ends[i] + Global.canvas.selection.select_rect(mirror_rect.abs(), operation) - Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() - Global.canvas.selection.commit_undo("Select", undo_data) + Global.canvas.selection.commit_undo("Select", undo_data) + else: + set_ellipse(project.selection_map, _rect.position) + # Handle mirroring + var mirror_positions := Tools.get_mirrored_positions(_rect.position, project, 1) + var mirror_ends := Tools.get_mirrored_positions(_rect.end, project, 1) + for i in mirror_positions.size(): + var mirror_rect := Rect2i() + mirror_rect.position = mirror_positions[i] + mirror_rect.end = mirror_ends[i] + set_ellipse(project.selection_map, mirror_rect.abs().position) + + Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() + Global.canvas.selection.commit_undo("Select", undo_data) func set_ellipse(selection_map: SelectionMap, pos: Vector2i) -> void: @@ -116,8 +134,12 @@ func set_ellipse(selection_map: SelectionMap, pos: Vector2i) -> void: # Given an origin point and destination point, returns a rect representing # where the shape will be drawn and what is its size func _get_result_rect(origin: Vector2i, dest: Vector2i) -> Rect2i: + if Tools.is_placing_tiles(): + var tileset := (Global.current_project.get_current_cel() as CelTileMap).tileset + var grid_size := tileset.tile_size + origin = Tools.snap_to_rectangular_grid_boundary(origin, grid_size) + dest = Tools.snap_to_rectangular_grid_boundary(dest, grid_size) var rect := Rect2i() - # Center the rect on the mouse if _expand_from_center: var new_size := dest - origin @@ -140,6 +162,7 @@ func _get_result_rect(origin: Vector2i, dest: Vector2i) -> Rect2i: rect.position = Vector2i(mini(origin.x, dest.x), mini(origin.y, dest.y)) rect.size = (origin - dest).abs() - rect.size += Vector2i.ONE + if not Tools.is_placing_tiles(): + rect.size += Vector2i.ONE return rect diff --git a/src/Tools/SelectionTools/Lasso.gd b/src/Tools/SelectionTools/Lasso.gd index 1f73eb9b1..0797eea6e 100644 --- a/src/Tools/SelectionTools/Lasso.gd +++ b/src/Tools/SelectionTools/Lasso.gd @@ -70,9 +70,9 @@ func apply_selection(_position) -> void: if _draw_points.size() > 3: if _intersect: project.selection_map.clear() - lasso_selection(_draw_points, project.selection_map, previous_selection_map) + lasso_selection(_draw_points, project, previous_selection_map) # Handle mirroring - var callable := lasso_selection.bind(project.selection_map, previous_selection_map) + var callable := lasso_selection.bind(project, previous_selection_map) mirror_array(_draw_points, callable) Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() else: @@ -85,8 +85,9 @@ func apply_selection(_position) -> void: func lasso_selection( - points: Array[Vector2i], selection_map: SelectionMap, previous_selection_map: SelectionMap + points: Array[Vector2i], project: Project, previous_selection_map: SelectionMap ) -> void: + var selection_map := project.selection_map var selection_size := selection_map.get_size() var bounding_rect := Rect2i(points[0], Vector2i.ZERO) for point in points: @@ -95,9 +96,9 @@ func lasso_selection( bounding_rect = bounding_rect.expand(point) if _intersect: if previous_selection_map.is_pixel_selected(point): - selection_map.select_pixel(point, true) + select_pixel(point, project, true) else: - selection_map.select_pixel(point, !_subtract) + select_pixel(point, project, !_subtract) var v := Vector2i() for x in bounding_rect.size.x: @@ -107,9 +108,17 @@ func lasso_selection( if Geometry2D.is_point_in_polygon(v, points): if _intersect: if previous_selection_map.is_pixel_selected(v): - selection_map.select_pixel(v, true) + select_pixel(v, project, true) else: - selection_map.select_pixel(v, !_subtract) + select_pixel(v, project, !_subtract) + + +func select_pixel(point: Vector2i, project: Project, select: bool) -> void: + if Tools.is_placing_tiles(): + var tilemap := project.get_current_cel() as CelTileMap + var cell_position := tilemap.get_cell_position(point) + select_tilemap_cell(tilemap, cell_position, project.selection_map, select) + project.selection_map.select_pixel(point, select) # Bresenham's Algorithm diff --git a/src/Tools/SelectionTools/MagicWand.gd b/src/Tools/SelectionTools/MagicWand.gd index 9e9d6d230..2547c438d 100644 --- a/src/Tools/SelectionTools/MagicWand.gd +++ b/src/Tools/SelectionTools/MagicWand.gd @@ -34,10 +34,10 @@ func apply_selection(pos: Vector2i) -> void: var cel_image := Image.new() cel_image.copy_from(_get_draw_image()) - _flood_fill(pos, cel_image, project.selection_map, previous_selection_map) + _flood_fill(pos, cel_image, project, previous_selection_map) # Handle mirroring for mirror_pos in Tools.get_mirrored_positions(pos): - _flood_fill(mirror_pos, cel_image, project.selection_map, previous_selection_map) + _flood_fill(mirror_pos, cel_image, project, previous_selection_map) Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() Global.canvas.selection.commit_undo("Select", undo_data) @@ -59,6 +59,39 @@ func update_config() -> void: $ToleranceSlider.value = _tolerance * 255.0 +func _on_tolerance_slider_value_changed(value: float) -> void: + _tolerance = value / 255.0 + update_config() + save_config() + + +func _flood_fill( + pos: Vector2i, image: Image, project: Project, previous_selection_map: SelectionMap +) -> void: + # implements the floodfill routine by Shawn Hargreaves + # from https://www1.udel.edu/CIS/software/dist/allegro-4.2.1/src/flood.c + var selection_map := project.selection_map + if Tools.is_placing_tiles(): + for cel in _get_selected_draw_cels(): + if cel is not CelTileMap: + continue + var tile_index := (cel as CelTileMap).get_cell_index_at_coords(pos) + # init flood data structures + _allegro_flood_segments = [] + _allegro_image_segments = [] + _compute_segments_for_tilemap(pos, cel, tile_index) + _select_segments_tilemap(project, previous_selection_map) + return + var color := image.get_pixelv(pos) + # init flood data structures + _allegro_flood_segments = [] + _allegro_image_segments = [] + _compute_segments_for_image(pos, project, image, color) + # now actually color the image: since we have already checked a few things for the points + # we'll process here, we're going to skip a bunch of safety checks to speed things up. + _select_segments(selection_map, previous_selection_map) + + # Add a new segment to the array func _add_new_segment(y := 0) -> void: _allegro_flood_segments.append(Segment.new(y)) @@ -140,22 +173,6 @@ func _check_flooded_segment( return ret -func _flood_fill( - pos: Vector2i, image: Image, selection_map: SelectionMap, previous_selection_map: SelectionMap -) -> void: - # implements the floodfill routine by Shawn Hargreaves - # from https://www1.udel.edu/CIS/software/dist/allegro-4.2.1/src/flood.c - var project := Global.current_project - var color := image.get_pixelv(pos) - # init flood data structures - _allegro_flood_segments = [] - _allegro_image_segments = [] - _compute_segments_for_image(pos, project, image, color) - # now actually color the image: since we have already checked a few things for the points - # we'll process here, we're going to skip a bunch of safety checks to speed things up. - _select_segments(selection_map, previous_selection_map) - - func _compute_segments_for_image( pos: Vector2i, project: Project, image: Image, src_color: Color ) -> void: @@ -201,7 +218,128 @@ func _set_bit(p: Vector2i, selection_map: SelectionMap, prev_selection_map: Sele selection_map.select_pixel(p, !_subtract) -func _on_tolerance_slider_value_changed(value: float) -> void: - _tolerance = value / 255.0 - update_config() - save_config() +func _compute_segments_for_tilemap(pos: Vector2i, cel: CelTileMap, src_index: int) -> void: + # initially allocate at least 1 segment per line of the tilemap + for j in cel.vertical_cells: + _add_new_segment(j) + pos /= cel.tileset.tile_size + # start flood algorithm + _flood_line_around_point_tilemap(pos, cel, src_index) + # test all segments while also discovering more + var done := false + while not done: + done = true + var max_index := _allegro_flood_segments.size() + for c in max_index: + var p := _allegro_flood_segments[c] + if p.todo_below: # check below the segment? + p.todo_below = false + if _check_flooded_segment_tilemap( + p.y + 1, p.left_position, p.right_position, cel, src_index + ): + done = false + if p.todo_above: # check above the segment? + p.todo_above = false + if _check_flooded_segment_tilemap( + p.y - 1, p.left_position, p.right_position, cel, src_index + ): + done = false + + +## Fill an horizontal segment around the specified position, and adds it to the +## list of segments filled. Returns the first x coordinate after the part of the +## line that has been filled. +## Τhis method is called by [method _flood_fill] after the required data structures +## have been initialized. +func _flood_line_around_point_tilemap(pos: Vector2i, cel: CelTileMap, src_index: int) -> int: + if cel.get_cell_index_at_coords_in_tilemap_space(pos) != src_index: + return pos.x + 1 + var west := pos + var east := pos + while west.x >= 0 && cel.get_cell_index_at_coords_in_tilemap_space(west) == src_index: + west += Vector2i.LEFT + while ( + east.x < cel.horizontal_cells + && cel.get_cell_index_at_coords_in_tilemap_space(east) == src_index + ): + east += Vector2i.RIGHT + # Make a note of the stuff we processed + var c := pos.y + var segment := _allegro_flood_segments[c] + # we may have already processed some segments on this y coordinate + if segment.flooding: + while segment.next > 0: + c = segment.next # index of next segment in this line of image + segment = _allegro_flood_segments[c] + # found last current segment on this line + c = _allegro_flood_segments.size() + segment.next = c + _add_new_segment(pos.y) + segment = _allegro_flood_segments[c] + # set the values for the current segment + segment.flooding = true + segment.left_position = west.x + 1 + segment.right_position = east.x - 1 + segment.y = pos.y + segment.next = 0 + # Should we process segments above or below this one? + # when there is a selected area, the pixels above and below the one we started creating this + # segment from may be outside it. It's easier to assume we should be checking for segments + # above and below this one than to specifically check every single pixel in it, because that + # test will be performed later anyway. + # On the other hand, this test we described is the same `project.can_pixel_get_drawn` does if + # there is no selection, so we don't need branching here. + segment.todo_above = pos.y > 0 + segment.todo_below = pos.y < cel.vertical_cells - 1 + # this is an actual segment we should be coloring, so we add it to the results for the + # current image + if segment.right_position >= segment.left_position: + _allegro_image_segments.append(segment) + # we know the point just east of the segment is not part of a segment that should be + # processed, else it would be part of this segment + return east.x + 1 + + +func _check_flooded_segment_tilemap( + y: int, left: int, right: int, cel: CelTileMap, src_index: int +) -> bool: + var ret := false + var c := 0 + while left <= right: + c = y + while true: + var segment := _allegro_flood_segments[c] + if left >= segment.left_position and left <= segment.right_position: + left = segment.right_position + 2 + break + c = segment.next + if c == 0: # couldn't find a valid segment, so we draw a new one + left = _flood_line_around_point_tilemap(Vector2i(left, y), cel, src_index) + ret = true + break + return ret + + +func _select_segments_tilemap(project: Project, previous_selection_map: SelectionMap) -> void: + # short circuit for flat colors + for c in _allegro_image_segments.size(): + var p := _allegro_image_segments[c] + for px in range(p.left_position, p.right_position + 1): + # We don't have to check again whether the point being processed is within the bounds + _set_bit_rect(Vector2i(px, p.y), project, previous_selection_map) + + +func _set_bit_rect(p: Vector2i, project: Project, prev_selection_map: SelectionMap) -> void: + var selection_map := project.selection_map + var tilemap := project.get_current_cel() as CelTileMap + var cell_position := tilemap.get_cell_position_in_tilemap_space(p) + if _intersect: + var image_coords := tilemap.get_cell_coords_in_image(cell_position) + select_tilemap_cell( + tilemap, + cell_position, + project.selection_map, + prev_selection_map.is_pixel_selected(image_coords) + ) + else: + select_tilemap_cell(tilemap, cell_position, project.selection_map, !_subtract) diff --git a/src/Tools/SelectionTools/PaintSelect.gd b/src/Tools/SelectionTools/PaintSelect.gd index ddee4e5a7..d692964b9 100644 --- a/src/Tools/SelectionTools/PaintSelect.gd +++ b/src/Tools/SelectionTools/PaintSelect.gd @@ -99,10 +99,10 @@ func apply_selection(pos: Vector2i) -> void: if _draw_points.size() >= 1: if _intersect: project.selection_map.clear() - paint_selection(project.selection_map, previous_selection_map, _draw_points) + paint_selection(project, previous_selection_map, _draw_points) # Handle mirroring var mirror := mirror_array(_draw_points) - paint_selection(project.selection_map, previous_selection_map, mirror) + paint_selection(project, previous_selection_map, mirror) Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() else: if !cleared: @@ -114,17 +114,26 @@ func apply_selection(pos: Vector2i) -> void: func paint_selection( - selection_map: SelectionMap, previous_selection_map: SelectionMap, points: Array[Vector2i] + project: Project, previous_selection_map: SelectionMap, points: Array[Vector2i] ) -> void: + var selection_map := project.selection_map var selection_size := selection_map.get_size() for point in points: if point.x < 0 or point.y < 0 or point.x >= selection_size.x or point.y >= selection_size.y: continue if _intersect: if previous_selection_map.is_pixel_selected(point): - selection_map.select_pixel(point, true) + select_pixel(point, project, true) else: - selection_map.select_pixel(point, !_subtract) + select_pixel(point, project, !_subtract) + + +func select_pixel(point: Vector2i, project: Project, select: bool) -> void: + if Tools.is_placing_tiles(): + var tilemap := project.get_current_cel() as CelTileMap + var cell_position := tilemap.get_cell_position(point) + select_tilemap_cell(tilemap, cell_position, project.selection_map, select) + project.selection_map.select_pixel(point, select) # Bresenham's Algorithm diff --git a/src/Tools/SelectionTools/PolygonSelect.gd b/src/Tools/SelectionTools/PolygonSelect.gd index 3c8776a75..0103fb449 100644 --- a/src/Tools/SelectionTools/PolygonSelect.gd +++ b/src/Tools/SelectionTools/PolygonSelect.gd @@ -107,9 +107,9 @@ func apply_selection(pos: Vector2i) -> void: if _draw_points.size() > 3: if _intersect: project.selection_map.clear() - lasso_selection(_draw_points, project.selection_map, previous_selection_map) + lasso_selection(_draw_points, project, previous_selection_map) # Handle mirroring - var callable := lasso_selection.bind(project.selection_map, previous_selection_map) + var callable := lasso_selection.bind(project, previous_selection_map) mirror_array(_draw_points, callable) Global.canvas.selection.big_bounding_rectangle = project.selection_map.get_used_rect() else: @@ -128,8 +128,9 @@ func _clear() -> void: func lasso_selection( - points: Array[Vector2i], selection_map: SelectionMap, previous_selection_map: SelectionMap + points: Array[Vector2i], project: Project, previous_selection_map: SelectionMap ) -> void: + var selection_map := project.selection_map var selection_size := selection_map.get_size() var bounding_rect := Rect2i(points[0], Vector2i.ZERO) for point in points: @@ -138,9 +139,9 @@ func lasso_selection( bounding_rect = bounding_rect.expand(point) if _intersect: if previous_selection_map.is_pixel_selected(point): - selection_map.select_pixel(point, true) + select_pixel(point, project, true) else: - selection_map.select_pixel(point, !_subtract) + select_pixel(point, project, !_subtract) var v := Vector2i() for x in bounding_rect.size.x: @@ -150,9 +151,17 @@ func lasso_selection( if Geometry2D.is_point_in_polygon(v, points): if _intersect: if previous_selection_map.is_pixel_selected(v): - selection_map.select_pixel(v, true) + select_pixel(v, project, true) else: - selection_map.select_pixel(v, !_subtract) + select_pixel(v, project, !_subtract) + + +func select_pixel(point: Vector2i, project: Project, select: bool) -> void: + if Tools.is_placing_tiles(): + var tilemap := project.get_current_cel() as CelTileMap + var cell_position := tilemap.get_cell_position(point) + select_tilemap_cell(tilemap, cell_position, project.selection_map, select) + project.selection_map.select_pixel(point, select) # Bresenham's Algorithm diff --git a/src/Tools/SelectionTools/RectSelect.gd b/src/Tools/SelectionTools/RectSelect.gd index 1e3cb7cf1..690379cb7 100644 --- a/src/Tools/SelectionTools/RectSelect.gd +++ b/src/Tools/SelectionTools/RectSelect.gd @@ -101,6 +101,11 @@ func apply_selection(pos: Vector2i) -> void: ## Given an origin point and destination point, returns a rect representing ## where the shape will be drawn and what is its size func _get_result_rect(origin: Vector2i, dest: Vector2i) -> Rect2i: + if Tools.is_placing_tiles(): + var tileset := (Global.current_project.get_current_cel() as CelTileMap).tileset + var grid_size := tileset.tile_size + origin = Tools.snap_to_rectangular_grid_boundary(origin, grid_size) + dest = Tools.snap_to_rectangular_grid_boundary(dest, grid_size) var rect := Rect2i() # Center the rect on the mouse @@ -125,6 +130,7 @@ func _get_result_rect(origin: Vector2i, dest: Vector2i) -> Rect2i: rect.position = Vector2i(mini(origin.x, dest.x), mini(origin.y, dest.y)) rect.size = (origin - dest).abs() - rect.size += Vector2i.ONE + if not Tools.is_placing_tiles(): + rect.size += Vector2i.ONE return rect diff --git a/src/Tools/UtilityTools/ColorPicker.gd b/src/Tools/UtilityTools/ColorPicker.gd index d04f70502..8e207edc7 100644 --- a/src/Tools/UtilityTools/ColorPicker.gd +++ b/src/Tools/UtilityTools/ColorPicker.gd @@ -63,10 +63,12 @@ func draw_end(pos: Vector2i) -> void: func _pick_color(pos: Vector2i) -> void: var project := Global.current_project pos = project.tiles.get_canon_position(pos) - if pos.x < 0 or pos.y < 0: return - + if Tools.is_placing_tiles(): + var cel := Global.current_project.get_current_cel() as CelTileMap + Tools.selected_tile_index_changed.emit(cel.get_cell_index_at_coords(pos)) + return var image := Image.new() image.copy_from(_get_draw_image()) if pos.x > image.get_width() - 1 or pos.y > image.get_height() - 1: diff --git a/src/Tools/UtilityTools/Move.gd b/src/Tools/UtilityTools/Move.gd index 247ab85de..437c2484a 100644 --- a/src/Tools/UtilityTools/Move.gd +++ b/src/Tools/UtilityTools/Move.gd @@ -70,8 +70,8 @@ func draw_move(pos: Vector2i) -> void: func draw_end(pos: Vector2i) -> void: - super.draw_end(pos) if !Global.current_project.layers[Global.current_project.current_layer].can_layer_get_drawn(): + super.draw_end(pos) return if ( _start_pos != Vector2i(Vector2.INF) @@ -93,6 +93,7 @@ func draw_end(pos: Vector2i) -> void: _snap_to_grid = false Global.canvas.sprite_changed_this_frame = true Global.canvas.measurements.update_measurement(Global.MeasurementMode.NONE) + super.draw_end(pos) func _move_image(image: Image, pixel_diff: Vector2i) -> void: @@ -129,8 +130,9 @@ func _snap_position(pos: Vector2) -> Vector2: func _commit_undo(action: String) -> void: - var redo_data := _get_undo_data() var project := Global.current_project + project.update_tilemaps(_undo_data) + var redo_data := _get_undo_data() var frame := -1 var layer := -1 if Global.animation_timeline.animation_timer.is_stopped() and project.selected_cels.size() == 1: @@ -139,7 +141,7 @@ func _commit_undo(action: String) -> void: project.undos += 1 project.undo_redo.create_action(action) - Global.undo_redo_compress_images(redo_data, _undo_data, project) + project.deserialize_cel_undo_data(redo_data, _undo_data) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false, frame, layer)) project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true, frame, layer)) project.undo_redo.commit_action() @@ -157,9 +159,5 @@ func _get_undo_data() -> Dictionary: for frame in project.frames: var cel := frame.cels[project.current_layer] cels.append(cel) - for cel in cels: - if not cel is PixelCel: - continue - var image := (cel as PixelCel).get_image() - image.add_data_to_dictionary(data) + project.serialize_cel_undo_data(cels, data) return data diff --git a/src/Tools/UtilityTools/Text.gd b/src/Tools/UtilityTools/Text.gd index b882d9ee8..20fa26783 100644 --- a/src/Tools/UtilityTools/Text.gd +++ b/src/Tools/UtilityTools/Text.gd @@ -104,8 +104,8 @@ func draw_move(pos: Vector2i) -> void: _offset = pos -func draw_end(_position: Vector2i) -> void: - pass +func draw_end(pos: Vector2i) -> void: + super.draw_end(pos) func text_to_pixels() -> void: @@ -162,8 +162,9 @@ func text_to_pixels() -> void: func commit_undo(action: String, undo_data: Dictionary) -> void: - var redo_data := _get_undo_data() var project := Global.current_project + project.update_tilemaps(undo_data) + var redo_data := _get_undo_data() var frame := -1 var layer := -1 if Global.animation_timeline.animation_timer.is_stopped() and project.selected_cels.size() == 1: @@ -172,7 +173,7 @@ func commit_undo(action: String, undo_data: Dictionary) -> void: project.undos += 1 project.undo_redo.create_action(action) - Global.undo_redo_compress_images(redo_data, undo_data, project) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false, frame, layer)) project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true, frame, layer)) project.undo_redo.commit_action() @@ -180,9 +181,7 @@ func commit_undo(action: String, undo_data: Dictionary) -> void: func _get_undo_data() -> Dictionary: var data := {} - var images := _get_selected_draw_images() - for image in images: - image.add_data_to_dictionary(data) + Global.current_project.serialize_cel_undo_data(_get_selected_draw_cels(), data) return data diff --git a/src/UI/Canvas/Canvas.gd b/src/UI/Canvas/Canvas.gd index 62518ffd5..bf0aadc99 100644 --- a/src/UI/Canvas/Canvas.gd +++ b/src/UI/Canvas/Canvas.gd @@ -110,13 +110,15 @@ func camera_zoom() -> void: Global.transparent_checker.update_rect() -func update_texture(layer_i: int, frame_i := -1, project := Global.current_project) -> void: +func update_texture( + layer_i: int, frame_i := -1, project := Global.current_project, undo := false +) -> void: if frame_i == -1: frame_i = project.current_frame if frame_i < project.frames.size() and layer_i < project.layers.size(): var current_cel := project.frames[frame_i].cels[layer_i] - current_cel.update_texture() + current_cel.update_texture(undo) # Needed so that changes happening to the non-selected layer(s) are also visible # e.g. when undoing/redoing, when applying image effects to the entire frame, etc if frame_i != project.current_frame: diff --git a/src/UI/Canvas/Canvas.tscn b/src/UI/Canvas/Canvas.tscn index 339f87727..ac4c51920 100644 --- a/src/UI/Canvas/Canvas.tscn +++ b/src/UI/Canvas/Canvas.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=24 format=3 uid="uid://ba24iuv55m4l3"] +[gd_scene load_steps=25 format=3 uid="uid://ba24iuv55m4l3"] [ext_resource type="Script" path="res://src/UI/Canvas/Canvas.gd" id="1"] [ext_resource type="Shader" path="res://src/Shaders/BlendLayers.gdshader" id="1_253dh"] @@ -18,6 +18,7 @@ [ext_resource type="Shader" path="res://src/Shaders/AutoInvertColors.gdshader" id="17_lowhf"] [ext_resource type="Script" path="res://src/UI/Canvas/ReferenceImages.gd" id="17_qfjb4"] [ext_resource type="Script" path="res://src/UI/Canvas/color_index.gd" id="18_o3xx2"] +[ext_resource type="Script" path="res://src/UI/Canvas/TileModeIndices.gd" id="19_7a6wb"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_6b0ox"] shader = ExtResource("1_253dh") @@ -113,3 +114,7 @@ script = ExtResource("16_nxilb") [node name="ReferenceImages" type="Node2D" parent="."] script = ExtResource("17_qfjb4") + +[node name="TileModeIndices" type="Node2D" parent="."] +material = SubResource("ShaderMaterial_ascg6") +script = ExtResource("19_7a6wb") diff --git a/src/UI/Canvas/Grid.gd b/src/UI/Canvas/Grid.gd index 7090bbd60..b38bde401 100644 --- a/src/UI/Canvas/Grid.gd +++ b/src/UI/Canvas/Grid.gd @@ -6,6 +6,7 @@ var unique_iso_lines := PackedVector2Array() func _ready() -> void: Global.project_switched.connect(queue_redraw) + Global.cel_switched.connect(queue_redraw) func _draw() -> void: @@ -32,28 +33,32 @@ func _draw() -> void: func _draw_cartesian_grid(grid_index: int, target_rect: Rect2i) -> void: - var grid = Global.grids[grid_index] + var grid := Global.grids[grid_index] + var grid_size := grid.grid_size + var grid_offset := grid.grid_offset + var cel := Global.current_project.get_current_cel() + if cel is CelTileMap and grid_index == 0: + grid_size = (cel as CelTileMap).tileset.tile_size + grid_offset = Vector2i.ZERO var grid_multiline_points := PackedVector2Array() var x: float = ( - target_rect.position.x - + fposmod(grid.grid_offset.x - target_rect.position.x, grid.grid_size.x) + target_rect.position.x + fposmod(grid_offset.x - target_rect.position.x, grid_size.x) ) while x <= target_rect.end.x: if not Vector2(x, target_rect.position.y) in unique_rect_lines: grid_multiline_points.push_back(Vector2(x, target_rect.position.y)) grid_multiline_points.push_back(Vector2(x, target_rect.end.y)) - x += grid.grid_size.x + x += grid_size.x var y: float = ( - target_rect.position.y - + fposmod(grid.grid_offset.y - target_rect.position.y, grid.grid_size.y) + target_rect.position.y + fposmod(grid_offset.y - target_rect.position.y, grid_size.y) ) while y <= target_rect.end.y: if not Vector2(target_rect.position.x, y) in unique_rect_lines: grid_multiline_points.push_back(Vector2(target_rect.position.x, y)) grid_multiline_points.push_back(Vector2(target_rect.end.x, y)) - y += grid.grid_size.y + y += grid_size.y unique_rect_lines.append_array(grid_multiline_points) if not grid_multiline_points.is_empty(): @@ -61,7 +66,7 @@ func _draw_cartesian_grid(grid_index: int, target_rect: Rect2i) -> void: func _draw_isometric_grid(grid_index: int, target_rect: Rect2i) -> void: - var grid = Global.grids[grid_index] + var grid := Global.grids[grid_index] var grid_multiline_points := PackedVector2Array() var cell_size: Vector2 = grid.isometric_grid_size diff --git a/src/UI/Canvas/Selection.gd b/src/UI/Canvas/Selection.gd index f28fd7cd7..e602ffaad 100644 --- a/src/UI/Canvas/Selection.gd +++ b/src/UI/Canvas/Selection.gd @@ -28,7 +28,7 @@ var big_bounding_rectangle := Rect2i(): if slot.tool_node is BaseSelectionTool: slot.tool_node.set_spinbox_values() _update_gizmos() -var image_current_pixel := Vector2.ZERO ## The ACTUAL pixel coordinate of image +var image_current_pixel := Vector2.ZERO ## The pixel coordinates of the cursor var temp_rect := Rect2() var rect_aspect_ratio := 0.0 @@ -38,6 +38,7 @@ var original_big_bounding_rectangle := Rect2i() var original_preview_image := Image.new() var original_bitmap := SelectionMap.new() var original_offset := Vector2.ZERO +var original_selected_tilemap_cells: Array[Array] var preview_image := Image.new() var preview_image_texture := ImageTexture.new() @@ -224,6 +225,10 @@ func _move_with_arrow_keys(event: InputEvent) -> void: if is_zero_approx(absf(move.y)): move.y = 0 var final_direction := (move * step).round() + if Tools.is_placing_tiles(): + var tileset := (Global.current_project.get_current_cel() as CelTileMap).tileset + var grid_size := tileset.tile_size + final_direction *= Vector2(grid_size) move_content(final_direction) @@ -314,17 +319,21 @@ func _update_on_zoom() -> void: func _gizmo_resize() -> void: var dir := dragged_gizmo.direction + var mouse_pos := image_current_pixel + if Tools.is_placing_tiles(): + var tilemap := Global.current_project.get_current_cel() as CelTileMap + mouse_pos = mouse_pos.snapped(tilemap.tileset.tile_size) if Input.is_action_pressed("shape_center"): # Code inspired from https://github.com/GDQuest/godot-open-rpg if dir.x != 0 and dir.y != 0: # Border gizmos - temp_rect.size = ((image_current_pixel - temp_rect_pivot) * 2.0 * Vector2(dir)) + temp_rect.size = ((mouse_pos - temp_rect_pivot) * 2.0 * Vector2(dir)) elif dir.y == 0: # Center left and right gizmos - temp_rect.size.x = (image_current_pixel.x - temp_rect_pivot.x) * 2.0 * dir.x + temp_rect.size.x = (mouse_pos.x - temp_rect_pivot.x) * 2.0 * dir.x elif dir.x == 0: # Center top and bottom gizmos - temp_rect.size.y = (image_current_pixel.y - temp_rect_pivot.y) * 2.0 * dir.y + temp_rect.size.y = (mouse_pos.y - temp_rect_pivot.y) * 2.0 * dir.y temp_rect = Rect2(-1.0 * temp_rect.size / 2 + temp_rect_pivot, temp_rect.size) else: - _resize_rect(image_current_pixel, dir) + _resize_rect(mouse_pos, dir) if Input.is_action_pressed("shape_perfect") or resize_keep_ratio: # Maintain aspect ratio var end_y := temp_rect.end.y @@ -379,14 +388,29 @@ func resize_selection() -> void: else: Global.current_project.selection_map.copy_from(original_bitmap) if is_moving_content: - content_pivot = original_big_bounding_rectangle.size / 2.0 preview_image.copy_from(original_preview_image) - DrawingAlgos.nn_rotate(preview_image, angle, content_pivot) - preview_image.resize(size.x, size.y, Image.INTERPOLATE_NEAREST) - if temp_rect.size.x < 0: - preview_image.flip_x() - if temp_rect.size.y < 0: - preview_image.flip_y() + if Tools.is_placing_tiles(): + for cel in _get_selected_draw_cels(): + if cel is not CelTileMap: + continue + var tilemap := cel as CelTileMap + var horizontal_size := size.x / tilemap.tileset.tile_size.x + var vertical_size := size.y / tilemap.tileset.tile_size.y + var selected_cells := tilemap.resize_selection( + original_selected_tilemap_cells, horizontal_size, vertical_size + ) + preview_image.crop(size.x, size.y) + tilemap.apply_resizing_to_image( + preview_image, selected_cells, big_bounding_rectangle + ) + else: + content_pivot = original_big_bounding_rectangle.size / 2.0 + DrawingAlgos.nn_rotate(preview_image, angle, content_pivot) + preview_image.resize(size.x, size.y, Image.INTERPOLATE_NEAREST) + if temp_rect.size.x < 0: + preview_image.flip_x() + if temp_rect.size.y < 0: + preview_image.flip_y() preview_image_texture = ImageTexture.create_from_image(preview_image) Global.current_project.selection_map.copy_from(original_bitmap) @@ -456,6 +480,15 @@ func move_borders(move: Vector2i) -> void: return marching_ants_outline.offset += Vector2(move) big_bounding_rectangle.position += move + if Tools.is_placing_tiles(): + var tileset := (Global.current_project.get_current_cel() as CelTileMap).tileset + var grid_size := tileset.tile_size + marching_ants_outline.offset = Tools.snap_to_rectangular_grid_boundary( + marching_ants_outline.offset, grid_size + ) + big_bounding_rectangle.position = Vector2i( + Tools.snap_to_rectangular_grid_boundary(big_bounding_rectangle.position, grid_size) + ) queue_redraw() @@ -479,9 +512,15 @@ func transform_content_start() -> void: undo_data = get_undo_data(false) return is_moving_content = true - original_bitmap.copy_from(Global.current_project.selection_map) + var project := Global.current_project + original_bitmap.copy_from(project.selection_map) original_big_bounding_rectangle = big_bounding_rectangle - original_offset = Global.current_project.selection_offset + original_offset = project.selection_offset + var current_cel := project.get_current_cel() + if current_cel is CelTileMap: + original_selected_tilemap_cells = (current_cel as CelTileMap).get_selected_cells( + project.selection_map, big_bounding_rectangle + ) queue_redraw() canvas.queue_redraw() @@ -501,21 +540,40 @@ func transform_content_confirm() -> void: if not is_pasting: src.copy_from(cel.transformed_content) cel.transformed_content = null - DrawingAlgos.nn_rotate(src, angle, content_pivot) - src.resize( - preview_image.get_width(), preview_image.get_height(), Image.INTERPOLATE_NEAREST - ) - if temp_rect.size.x < 0: - src.flip_x() - if temp_rect.size.y < 0: - src.flip_y() + if Tools.is_placing_tiles(): + if cel is not CelTileMap: + continue + var tilemap := cel as CelTileMap + var horizontal_size := preview_image.get_width() / tilemap.tileset.tile_size.x + var vertical_size := preview_image.get_height() / tilemap.tileset.tile_size.y + var selected_cells := tilemap.resize_selection( + original_selected_tilemap_cells, horizontal_size, vertical_size + ) + src.crop(preview_image.get_width(), preview_image.get_height()) + tilemap.apply_resizing_to_image(src, selected_cells, big_bounding_rectangle) + else: + DrawingAlgos.nn_rotate(src, angle, content_pivot) + src.resize( + preview_image.get_width(), preview_image.get_height(), Image.INTERPOLATE_NEAREST + ) + if temp_rect.size.x < 0: + src.flip_x() + if temp_rect.size.y < 0: + src.flip_y() - cel_image.blit_rect_mask( - src, - src, - Rect2i(Vector2i.ZERO, project.selection_map.get_size()), - big_bounding_rectangle.position - ) + if Tools.is_placing_tiles(): + cel_image.blit_rect( + src, + Rect2i(Vector2i.ZERO, project.selection_map.get_size()), + big_bounding_rectangle.position + ) + else: + cel_image.blit_rect_mask( + src, + src, + Rect2i(Vector2i.ZERO, project.selection_map.get_size()), + big_bounding_rectangle.position + ) cel_image.convert_rgb_to_indexed() project.selection_map.move_bitmap_values(project) commit_undo("Move Selection", undo_data) @@ -523,6 +581,7 @@ func transform_content_confirm() -> void: original_preview_image = Image.new() preview_image = Image.new() original_bitmap = SelectionMap.new() + original_selected_tilemap_cells.clear() is_moving_content = false is_pasting = false angle = 0.0 @@ -557,6 +616,7 @@ func transform_content_cancel() -> void: original_preview_image = Image.new() preview_image = Image.new() original_bitmap = SelectionMap.new() + original_selected_tilemap_cells.clear() is_pasting = false angle = 0.0 content_pivot = Vector2.ZERO @@ -568,12 +628,12 @@ func commit_undo(action: String, undo_data_tmp: Dictionary) -> void: if !undo_data_tmp: print("No undo data found!") return - var redo_data := get_undo_data(undo_data_tmp["undo_image"]) var project := Global.current_project - + project.update_tilemaps(undo_data_tmp) + var redo_data := get_undo_data(undo_data_tmp["undo_image"]) project.undos += 1 project.undo_redo.create_action(action) - Global.undo_redo_compress_images(redo_data, undo_data_tmp, project) + project.deserialize_cel_undo_data(redo_data, undo_data_tmp) project.undo_redo.add_do_property( self, "big_bounding_rectangle", redo_data["big_bounding_rectangle"] ) @@ -604,15 +664,14 @@ func get_undo_data(undo_image: bool) -> Dictionary: data["undo_image"] = undo_image if undo_image: - var images := _get_selected_draw_images() - for image in images: - image.add_data_to_dictionary(data) - + Global.current_project.serialize_cel_undo_data(_get_selected_draw_cels(), data) return data -func _get_selected_draw_cels() -> Array[PixelCel]: - var cels: Array[PixelCel] = [] +# TODO: Change BaseCel to PixelCel if Godot ever fixes issues +# with typed arrays being cast into other types. +func _get_selected_draw_cels() -> Array[BaseCel]: + var cels: Array[BaseCel] = [] var project := Global.current_project for cel_index in project.selected_cels: var cel: BaseCel = project.frames[cel_index[0]].cels[cel_index[1]] diff --git a/src/UI/Canvas/TileModeIndices.gd b/src/UI/Canvas/TileModeIndices.gd new file mode 100644 index 000000000..46048187d --- /dev/null +++ b/src/UI/Canvas/TileModeIndices.gd @@ -0,0 +1,26 @@ +extends Node2D + +const FONT_SIZE := 16 + + +func _input(event: InputEvent) -> void: + if event.is_action("ctrl"): + queue_redraw() + + +func _draw() -> void: + var current_cel := Global.current_project.get_current_cel() + draw_set_transform(position, rotation, Vector2(0.5, 0.5)) + if current_cel is CelTileMap and Input.is_action_pressed("ctrl"): + var tilemap_cel := current_cel as CelTileMap + for i in tilemap_cel.cells.size(): + var tile_data := tilemap_cel.cells[i] + if tile_data.index == 0: + continue + var pos := tilemap_cel.get_cell_coords_in_image(i) + pos.y += tilemap_cel.tileset.tile_size.y + var text := tile_data.to_string() + draw_multiline_string( + Themes.get_font(), pos * 2, text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + ) + draw_set_transform(position, rotation, scale) diff --git a/src/UI/Dialogs/ExportDialog.gd b/src/UI/Dialogs/ExportDialog.gd index 7f952fee7..c6f27be69 100644 --- a/src/UI/Dialogs/ExportDialog.gd +++ b/src/UI/Dialogs/ExportDialog.gd @@ -231,6 +231,8 @@ func create_layer_list() -> void: layer_name = tr("Group layer:") elif layer is Layer3D: layer_name = tr("3D layer:") + elif layer is LayerTileMap: + layer_name = tr("Tilemap layer:") layer_name += " %s" % layer.get_layer_path() layers_option_button.add_item(layer_name) diff --git a/src/UI/Dialogs/ImageEffects/FlipImageDialog.gd b/src/UI/Dialogs/ImageEffects/FlipImageDialog.gd index aa4f81248..51aee6e2e 100644 --- a/src/UI/Dialogs/ImageEffects/FlipImageDialog.gd +++ b/src/UI/Dialogs/ImageEffects/FlipImageDialog.gd @@ -47,11 +47,11 @@ func _flip_image(cel: Image, affect_selection: bool, project: Project) -> void: func _commit_undo(action: String, undo_data: Dictionary, project: Project) -> void: _flip_selection(project) - + project.update_tilemaps(undo_data) var redo_data := _get_undo_data(project) project.undos += 1 project.undo_redo.create_action(action) - Global.undo_redo_compress_images(redo_data, undo_data, project) + project.deserialize_cel_undo_data(redo_data, undo_data) if redo_data.has("outline_offset"): project.undo_redo.add_do_property(project, "selection_offset", redo_data["outline_offset"]) project.undo_redo.add_undo_property( @@ -66,14 +66,10 @@ func _commit_undo(action: String, undo_data: Dictionary, project: Project) -> vo func _get_undo_data(project: Project) -> Dictionary: var affect_selection := selection_checkbox.button_pressed and project.has_selection - var data := {} + var data := super._get_undo_data(project) if affect_selection: data[project.selection_map] = project.selection_map.data data["outline_offset"] = project.selection_offset - - var images := _get_selected_draw_images(project) - for image in images: - data[image] = image.data return data diff --git a/src/UI/Dialogs/ImportPreviewDialog.gd b/src/UI/Dialogs/ImportPreviewDialog.gd index eeb60f354..5f4155900 100644 --- a/src/UI/Dialogs/ImportPreviewDialog.gd +++ b/src/UI/Dialogs/ImportPreviewDialog.gd @@ -11,7 +11,8 @@ enum ImageImportOptions { NEW_REFERENCE_IMAGE, PALETTE, BRUSH, - PATTERN + PATTERN, + TILESET } enum BrushTypes { FILE, PROJECT, RANDOM } @@ -75,6 +76,7 @@ func _on_ImportPreviewDialog_about_to_show() -> void: import_option_button.add_item("New palette") import_option_button.add_item("New brush") import_option_button.add_item("New pattern") + import_option_button.add_item("Tileset") # adding custom importers for id in custom_importers.keys(): @@ -207,6 +209,17 @@ func _on_ImportPreviewDialog_confirmed() -> void: var location := "Patterns".path_join(file_name_ext) var dir := DirAccess.open(path.get_base_dir()) dir.copy(path, Global.home_data_directory.path_join(location)) + elif current_import_option == ImageImportOptions.TILESET: + if smart_slice: + if !recycle_last_slice_result: + obtain_sliced_data() + OpenSave.open_image_as_tileset_smart( + path, image, sliced_rects.rects, sliced_rects.frame_size + ) + else: + OpenSave.open_image_as_tileset( + path, image, spritesheet_horizontal, spritesheet_vertical + ) else: if current_import_option in custom_importers.keys(): @@ -250,7 +263,11 @@ func synchronize() -> void: dialog.at_layer_option.get_node("AtLayerOption") as OptionButton ) # Sync properties (if any) - if id == ImageImportOptions.SPRITESHEET_TAB or id == ImageImportOptions.SPRITESHEET_LAYER: + if ( + id == ImageImportOptions.SPRITESHEET_TAB + or id == ImageImportOptions.SPRITESHEET_LAYER + or id == ImageImportOptions.TILESET + ): var h_frames := spritesheet_options.find_child("HorizontalFrames") as SpinBox var v_frames := spritesheet_options.find_child("VerticalFrames") as SpinBox var d_h_frames := dialog.spritesheet_options.find_child("HorizontalFrames") as SpinBox @@ -298,7 +315,7 @@ func _on_ImportOption_item_selected(id: ImageImportOptions) -> void: _hide_all_options() import_options.get_parent().visible = true - if id == ImageImportOptions.SPRITESHEET_TAB: + if id == ImageImportOptions.SPRITESHEET_TAB or id == ImageImportOptions.TILESET: frame_size_label.visible = true spritesheet_options.visible = true texture_rect.get_child(0).visible = true @@ -505,6 +522,7 @@ func _call_queue_redraw() -> void: if ( current_import_option == ImageImportOptions.SPRITESHEET_TAB or current_import_option == ImageImportOptions.SPRITESHEET_LAYER + or current_import_option == ImageImportOptions.TILESET ): if smart_slice: if is_instance_valid(sliced_rects) and not sliced_rects.rects.is_empty(): diff --git a/src/UI/Dialogs/ImportPreviewDialog.tscn b/src/UI/Dialogs/ImportPreviewDialog.tscn index 9ac4d01bb..327f60a7c 100644 --- a/src/UI/Dialogs/ImportPreviewDialog.tscn +++ b/src/UI/Dialogs/ImportPreviewDialog.tscn @@ -223,10 +223,9 @@ text = "Brush type:" [node name="BrushTypeOption" type="OptionButton" parent="VBoxContainer/ImportOptionsContainer/ImportOptions/NewBrushOptions/Type"] layout_mode = 2 mouse_default_cursor_shape = 2 -item_count = 3 selected = 0 +item_count = 3 popup/item_0/text = "File brush" -popup/item_0/id = 0 popup/item_1/text = "Project brush" popup/item_1/id = 1 popup/item_2/text = "Random brush" diff --git a/src/UI/Dialogs/ProjectProperties.gd b/src/UI/Dialogs/ProjectProperties.gd index 9380e6229..c2c4bc29c 100644 --- a/src/UI/Dialogs/ProjectProperties.gd +++ b/src/UI/Dialogs/ProjectProperties.gd @@ -1,11 +1,16 @@ extends AcceptDialog -@onready var size_value_label := $GridContainer/SizeValueLabel as Label -@onready var color_mode_value_label := $GridContainer/ColorModeValueLabel as Label -@onready var frames_value_label := $GridContainer/FramesValueLabel as Label -@onready var layers_value_label := $GridContainer/LayersValueLabel as Label -@onready var name_line_edit := $GridContainer/NameLineEdit as LineEdit -@onready var user_data_text_edit := $GridContainer/UserDataTextEdit as TextEdit +const DUPLICATE_TEXTURE := preload("res://assets/graphics/timeline/copy_frame.png") +const REMOVE_TEXTURE := preload("res://assets/graphics/misc/close.png") + +@onready var size_value_label := $VBoxContainer/GridContainer/SizeValueLabel as Label +@onready var color_mode_value_label := $VBoxContainer/GridContainer/ColorModeValueLabel as Label +@onready var frames_value_label := $VBoxContainer/GridContainer/FramesValueLabel as Label +@onready var layers_value_label := $VBoxContainer/GridContainer/LayersValueLabel as Label +@onready var name_line_edit := $VBoxContainer/GridContainer/NameLineEdit as LineEdit +@onready var user_data_text_edit := $VBoxContainer/GridContainer/UserDataTextEdit as TextEdit +@onready var tilesets_container := $VBoxContainer/TilesetsContainer as VBoxContainer +@onready var tilesets_list := $VBoxContainer/TilesetsContainer/TilesetsList as Tree func _on_visibility_changed() -> void: @@ -21,6 +26,30 @@ func _on_visibility_changed() -> void: layers_value_label.text = str(Global.current_project.layers.size()) name_line_edit.text = Global.current_project.name user_data_text_edit.text = Global.current_project.user_data + tilesets_container.visible = Global.current_project.tilesets.size() > 0 + tilesets_list.clear() + var root_item := tilesets_list.create_item() + for i in Global.current_project.tilesets.size(): + _create_tileset_tree_item(i, root_item) + + +func _create_tileset_tree_item(i: int, root_item: TreeItem) -> void: + var tileset := Global.current_project.tilesets[i] + var tree_item := tilesets_list.create_item(root_item) + var item_text := tileset.get_text_info(i) + var using_layers := tileset.find_using_layers(Global.current_project) + for j in using_layers.size(): + if j == 0: + item_text += " (" + item_text += using_layers[j].name + if j == using_layers.size() - 1: + item_text += ")" + else: + item_text += ", " + tree_item.set_text(0, item_text) + tree_item.set_metadata(0, i) + tree_item.add_button(0, DUPLICATE_TEXTURE, -1, false, "Duplicate") + tree_item.add_button(0, REMOVE_TEXTURE, -1, using_layers.size() > 0, "Delete") func _on_name_line_edit_text_changed(new_text: String) -> void: @@ -29,3 +58,35 @@ func _on_name_line_edit_text_changed(new_text: String) -> void: func _on_user_data_text_edit_text_changed() -> void: Global.current_project.user_data = user_data_text_edit.text + + +func _on_tilesets_list_button_clicked(item: TreeItem, column: int, id: int, _mbi: int) -> void: + var tileset_index: int = item.get_metadata(column) + var project := Global.current_project + var tileset := project.tilesets[tileset_index] + if id == 0: # Duplicate + var new_tileset := TileSetCustom.new(tileset.tile_size, tileset.name) + for i in range(1, tileset.tiles.size()): + var tile := tileset.tiles[i] + var new_image := Image.new() + new_image.copy_from(tile.image) + new_tileset.add_tile(new_image, null) + project.undos += 1 + project.undo_redo.create_action("Duplicate tileset") + project.undo_redo.add_do_method(func(): project.tilesets.append(new_tileset)) + project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) + project.undo_redo.add_undo_method(func(): project.tilesets.erase(new_tileset)) + project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true)) + project.undo_redo.commit_action() + _create_tileset_tree_item(item.get_parent().get_child_count(), item.get_parent()) + if id == 1: # Delete + if tileset.find_using_layers(project).size() > 0: + return + project.undos += 1 + project.undo_redo.create_action("Delete tileset") + project.undo_redo.add_do_method(func(): project.tilesets.erase(tileset)) + project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) + project.undo_redo.add_undo_method(func(): project.tilesets.insert(tileset_index, tileset)) + project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true)) + project.undo_redo.commit_action() + item.free() diff --git a/src/UI/Dialogs/ProjectProperties.tscn b/src/UI/Dialogs/ProjectProperties.tscn index 72c90b7c6..48e87a479 100644 --- a/src/UI/Dialogs/ProjectProperties.tscn +++ b/src/UI/Dialogs/ProjectProperties.tscn @@ -4,75 +4,103 @@ [node name="ProjectProperties" type="AcceptDialog"] title = "Project Properties" -size = Vector2i(197, 235) +position = Vector2i(0, 36) +size = Vector2i(300, 288) script = ExtResource("1_0n4uc") -[node name="GridContainer" type="GridContainer" parent="."] +[node name="VBoxContainer" type="VBoxContainer" parent="."] offset_left = 8.0 offset_top = 8.0 -offset_right = 189.0 -offset_bottom = 186.0 +offset_right = 292.0 +offset_bottom = 239.0 + +[node name="GridContainer" type="GridContainer" parent="VBoxContainer"] +layout_mode = 2 columns = 2 -[node name="SizeLabel" type="Label" parent="GridContainer"] +[node name="SizeLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "Size:" -[node name="SizeValueLabel" type="Label" parent="GridContainer"] +[node name="SizeValueLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "64x64" -[node name="ColorModeLabel" type="Label" parent="GridContainer"] +[node name="ColorModeLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "Color mode:" -[node name="ColorModeValueLabel" type="Label" parent="GridContainer"] +[node name="ColorModeValueLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "RGBA8" -[node name="FramesLabel" type="Label" parent="GridContainer"] +[node name="FramesLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "Frames:" -[node name="FramesValueLabel" type="Label" parent="GridContainer"] +[node name="FramesValueLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "1" -[node name="LayersLabel" type="Label" parent="GridContainer"] +[node name="LayersLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "Layers:" -[node name="LayersValueLabel" type="Label" parent="GridContainer"] +[node name="LayersValueLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "1" -[node name="NameLabel" type="Label" parent="GridContainer"] +[node name="NameLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 text = "Name:" -[node name="NameLineEdit" type="LineEdit" parent="GridContainer"] +[node name="NameLineEdit" type="LineEdit" parent="VBoxContainer/GridContainer"] layout_mode = 2 -[node name="UserDataLabel" type="Label" parent="GridContainer"] +[node name="UserDataLabel" type="Label" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 0 text = "User data:" -[node name="UserDataTextEdit" type="TextEdit" parent="GridContainer"] +[node name="UserDataTextEdit" type="TextEdit" parent="VBoxContainer/GridContainer"] layout_mode = 2 size_flags_horizontal = 3 scroll_fit_content_height = true +[node name="TilesetsContainer" type="VBoxContainer" parent="VBoxContainer"] +visible = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="TilesetsHeader" type="HBoxContainer" parent="VBoxContainer/TilesetsContainer"] +layout_mode = 2 +theme_override_constants/separation = 0 + +[node name="Label" type="Label" parent="VBoxContainer/TilesetsContainer/TilesetsHeader"] +layout_mode = 2 +theme_type_variation = &"HeaderSmall" +text = "Tilesets" + +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/TilesetsContainer/TilesetsHeader"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="TilesetsList" type="Tree" parent="VBoxContainer/TilesetsContainer"] +layout_mode = 2 +size_flags_vertical = 3 +hide_root = true + [connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"] -[connection signal="text_changed" from="GridContainer/NameLineEdit" to="." method="_on_name_line_edit_text_changed"] -[connection signal="text_changed" from="GridContainer/UserDataTextEdit" to="." method="_on_user_data_text_edit_text_changed"] +[connection signal="text_changed" from="VBoxContainer/GridContainer/NameLineEdit" to="." method="_on_name_line_edit_text_changed"] +[connection signal="text_changed" from="VBoxContainer/GridContainer/UserDataTextEdit" to="." method="_on_user_data_text_edit_text_changed"] +[connection signal="button_clicked" from="VBoxContainer/TilesetsContainer/TilesetsList" to="." method="_on_tilesets_list_button_clicked"] diff --git a/src/UI/TilesPanel.gd b/src/UI/TilesPanel.gd new file mode 100644 index 000000000..330a8c628 --- /dev/null +++ b/src/UI/TilesPanel.gd @@ -0,0 +1,207 @@ +class_name TileSetPanel +extends PanelContainer + +enum TileEditingMode { MANUAL, AUTO, STACK } + +const TRANSPARENT_CHECKER := preload("res://src/UI/Nodes/TransparentChecker.tscn") +const MIN_BUTTON_SIZE := 36 +const MAX_BUTTON_SIZE := 144 +## A matrix with every possible flip/transpose combination, +## sorted by what comes next when you rotate. +## Taken from Godot's rotation matrix found in: +## https://github.com/godotengine/godot/blob/master/editor/plugins/tiles/tile_map_layer_editor.cpp +const ROTATION_MATRIX: Array[bool] = [ + 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1 +] + +static var placing_tiles := false: + set(value): + placing_tiles = value + _call_update_brushes() +static var tile_editing_mode := TileEditingMode.AUTO +static var selected_tile_index := 0: + set(value): + selected_tile_index = value + _call_update_brushes() +static var is_flipped_h := false: + set(value): + is_flipped_h = value + _call_update_brushes() +static var is_flipped_v := false: + set(value): + is_flipped_v = value + _call_update_brushes() +static var is_transposed := false: + set(value): + is_transposed = value + _call_update_brushes() +var current_tileset: TileSetCustom +var button_size := 36: + set(value): + if button_size == value: + return + button_size = clampi(value, MIN_BUTTON_SIZE, MAX_BUTTON_SIZE) + update_minimum_size() + Global.config_cache.set_value("tileset_panel", "button_size", button_size) + for button: Control in tile_button_container.get_children(): + button.custom_minimum_size = Vector2(button_size, button_size) + button.size = Vector2(button_size, button_size) + +@onready var place_tiles: CheckBox = $VBoxContainer/PlaceTiles +@onready var transform_buttons_container: HFlowContainer = $VBoxContainer/TransformButtonsContainer +@onready var tile_button_container: HFlowContainer = %TileButtonContainer + + +func _ready() -> void: + Tools.selected_tile_index_changed.connect(select_tile) + Global.cel_switched.connect(_on_cel_switched) + for child: Button in transform_buttons_container.get_children(): + Global.disable_button(child, true) + + +func _gui_input(event: InputEvent) -> void: + if Input.is_key_pressed(KEY_CTRL): + var zoom := 2 * int(event.is_action("zoom_in")) - 2 * int(event.is_action("zoom_out")) + button_size += zoom + if zoom != 0: + get_viewport().set_input_as_handled() + + +func set_tileset(tileset: TileSetCustom) -> void: + if tileset == current_tileset: + return + if is_instance_valid(current_tileset) and current_tileset.updated.is_connected(_update_tileset): + current_tileset.updated.disconnect(_update_tileset) + current_tileset = tileset + if ( + is_instance_valid(current_tileset) + and not current_tileset.updated.is_connected(_update_tileset) + ): + current_tileset.updated.connect(_update_tileset) + + +func _on_cel_switched() -> void: + if Global.current_project.get_current_cel() is not CelTileMap: + set_tileset(null) + _clear_tile_buttons() + return + var cel := Global.current_project.get_current_cel() as CelTileMap + set_tileset(cel.tileset) + _update_tileset(cel, -1) + + +func _update_tileset(cel: BaseCel, _replace_index: int) -> void: + _clear_tile_buttons() + if cel is not CelTileMap: + return + var tilemap_cel := cel as CelTileMap + var tileset := tilemap_cel.tileset + var button_group := ButtonGroup.new() + if selected_tile_index >= tileset.tiles.size(): + selected_tile_index = 0 + for i in tileset.tiles.size(): + var tile := tileset.tiles[i] + var texture := ImageTexture.create_from_image(tile.image) + var button := _create_tile_button(texture, i, button_group) + if i == selected_tile_index: + button.set_pressed_no_signal(true) + tile_button_container.add_child(button) + + +func _create_tile_button(texture: Texture2D, index: int, button_group: ButtonGroup) -> Button: + var button := Button.new() + button.button_group = button_group + button.toggle_mode = true + button.custom_minimum_size = Vector2(button_size, button_size) + button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + var texture_rect := TextureRect.new() + texture_rect.texture = texture + texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture_rect.set_anchor_and_offset(SIDE_LEFT, 0, 6) + texture_rect.set_anchor_and_offset(SIDE_RIGHT, 1, -6) + texture_rect.set_anchor_and_offset(SIDE_TOP, 0, 6) + texture_rect.set_anchor_and_offset(SIDE_BOTTOM, 1, -6) + texture_rect.grow_horizontal = Control.GROW_DIRECTION_BOTH + texture_rect.grow_vertical = Control.GROW_DIRECTION_BOTH + var transparent_checker := TRANSPARENT_CHECKER.instantiate() as ColorRect + transparent_checker.set_anchors_preset(Control.PRESET_FULL_RECT) + transparent_checker.show_behind_parent = true + texture_rect.add_child(transparent_checker) + button.add_child(texture_rect) + button.tooltip_text = str(index) + button.toggled.connect(_on_tile_button_toggled.bind(index)) + return button + + +func select_tile(tile_index: int) -> void: + tile_button_container.get_child(tile_index).button_pressed = true + + +static func _call_update_brushes() -> void: + for slot in Tools._slots.values(): + if slot.tool_node is BaseDrawTool: + slot.tool_node.update_brush() + + +func _on_tile_button_toggled(toggled_on: bool, index: int) -> void: + if toggled_on: + selected_tile_index = index + place_tiles.button_pressed = true + + +func _clear_tile_buttons() -> void: + for child in tile_button_container.get_children(): + child.queue_free() + + +func _on_place_tiles_toggled(toggled_on: bool) -> void: + placing_tiles = toggled_on + for child: Button in transform_buttons_container.get_children(): + Global.disable_button(child, not toggled_on) + + +func _on_manual_toggled(toggled_on: bool) -> void: + place_tiles.button_pressed = false + if toggled_on: + tile_editing_mode = TileEditingMode.MANUAL + + +func _on_auto_toggled(toggled_on: bool) -> void: + place_tiles.button_pressed = false + if toggled_on: + tile_editing_mode = TileEditingMode.AUTO + + +func _on_stack_toggled(toggled_on: bool) -> void: + place_tiles.button_pressed = false + if toggled_on: + tile_editing_mode = TileEditingMode.STACK + + +func _on_flip_horizontal_button_pressed() -> void: + is_flipped_h = not is_flipped_h + + +func _on_flip_vertical_button_pressed() -> void: + is_flipped_v = not is_flipped_v + + +func _on_rotate_pressed(clockwise: bool) -> void: + for i in ROTATION_MATRIX.size(): + var final_i := i + if ( + is_flipped_h == ROTATION_MATRIX[i * 3] + && is_flipped_v == ROTATION_MATRIX[i * 3 + 1] + && is_transposed == ROTATION_MATRIX[i * 3 + 2] + ): + if clockwise: + @warning_ignore("integer_division") + final_i = i / 4 * 4 + posmod(i - 1, 4) + else: + @warning_ignore("integer_division") + final_i = i / 4 * 4 + (i + 1) % 4 + is_flipped_h = ROTATION_MATRIX[final_i * 3] + is_flipped_v = ROTATION_MATRIX[final_i * 3 + 1] + is_transposed = ROTATION_MATRIX[final_i * 3 + 2] + break diff --git a/src/UI/TilesPanel.tscn b/src/UI/TilesPanel.tscn new file mode 100644 index 000000000..a247c87f6 --- /dev/null +++ b/src/UI/TilesPanel.tscn @@ -0,0 +1,189 @@ +[gd_scene load_steps=22 format=3 uid="uid://bfbragmmdwfbl"] + +[ext_resource type="Script" path="res://src/UI/TilesPanel.gd" id="1_d2oc5"] +[ext_resource type="Texture2D" uid="uid://bv7ldl8obhawm" path="res://assets/graphics/misc/icon_reload.png" id="2_r1kie"] +[ext_resource type="Texture2D" uid="uid://bpsfilx47bw3r" path="res://assets/graphics/misc/mirror_x.svg" id="3_5o62r"] +[ext_resource type="Texture2D" uid="uid://bk6iaxiyl74ih" path="res://assets/graphics/misc/mirror_y.svg" id="4_2xhnr"] + +[sub_resource type="InputEventAction" id="InputEventAction_klv67"] +action = &"toggle_draw_tiles_mode" + +[sub_resource type="Shortcut" id="Shortcut_6ebuw"] +events = [SubResource("InputEventAction_klv67")] + +[sub_resource type="InputEventAction" id="InputEventAction_yr0lx"] +action = &"tile_rotate_left" + +[sub_resource type="Shortcut" id="Shortcut_yas23"] +events = [SubResource("InputEventAction_yr0lx")] + +[sub_resource type="InputEventAction" id="InputEventAction_g6d5p"] +action = &"tile_rotate_right" + +[sub_resource type="Shortcut" id="Shortcut_cmy2w"] +events = [SubResource("InputEventAction_g6d5p")] + +[sub_resource type="InputEventAction" id="InputEventAction_yh67l"] +action = &"tile_flip_horizontal" + +[sub_resource type="Shortcut" id="Shortcut_ouoxo"] +events = [SubResource("InputEventAction_yh67l")] + +[sub_resource type="InputEventAction" id="InputEventAction_18g3a"] +action = &"tile_flip_vertical" + +[sub_resource type="Shortcut" id="Shortcut_jj4yy"] +events = [SubResource("InputEventAction_18g3a")] + +[sub_resource type="ButtonGroup" id="ButtonGroup_uxnt0"] + +[sub_resource type="InputEventAction" id="InputEventAction_mhgo3"] +action = &"tile_edit_mode_manual" + +[sub_resource type="Shortcut" id="Shortcut_pgg48"] +events = [SubResource("InputEventAction_mhgo3")] + +[sub_resource type="InputEventAction" id="InputEventAction_h1wos"] +action = &"tile_edit_mode_auto" + +[sub_resource type="Shortcut" id="Shortcut_a0fx5"] +events = [SubResource("InputEventAction_h1wos")] + +[sub_resource type="InputEventAction" id="InputEventAction_i4ufh"] +action = &"tile_edit_mode_stack" + +[sub_resource type="Shortcut" id="Shortcut_ysxej"] +events = [SubResource("InputEventAction_i4ufh")] + +[node name="Tiles" type="PanelContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_d2oc5") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 2 + +[node name="PlaceTiles" type="CheckBox" parent="VBoxContainer"] +layout_mode = 2 +mouse_default_cursor_shape = 2 +shortcut = SubResource("Shortcut_6ebuw") +text = "Draw tiles" + +[node name="TransformButtonsContainer" type="HFlowContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="RotateLeftButton" type="Button" parent="VBoxContainer/TransformButtonsContainer" groups=["UIButtons"]] +custom_minimum_size = Vector2(24, 24) +layout_mode = 2 +tooltip_text = "Rotate tile left (counterclockwise)" +mouse_default_cursor_shape = 2 +shortcut = SubResource("Shortcut_yas23") + +[node name="TextureRect" type="TextureRect" parent="VBoxContainer/TransformButtonsContainer/RotateLeftButton"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture = ExtResource("2_r1kie") +stretch_mode = 3 + +[node name="RotateRightButton" type="Button" parent="VBoxContainer/TransformButtonsContainer" groups=["UIButtons"]] +custom_minimum_size = Vector2(24, 24) +layout_mode = 2 +tooltip_text = "Rotate tile right (clockwise)" +mouse_default_cursor_shape = 2 +shortcut = SubResource("Shortcut_cmy2w") + +[node name="TextureRect" type="TextureRect" parent="VBoxContainer/TransformButtonsContainer/RotateRightButton"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture = ExtResource("2_r1kie") +stretch_mode = 3 +flip_h = true + +[node name="FlipHorizontalButton" type="Button" parent="VBoxContainer/TransformButtonsContainer" groups=["UIButtons"]] +custom_minimum_size = Vector2(24, 24) +layout_mode = 2 +tooltip_text = "Flip tile horizontally" +mouse_default_cursor_shape = 2 +shortcut = SubResource("Shortcut_ouoxo") + +[node name="TextureRect" type="TextureRect" parent="VBoxContainer/TransformButtonsContainer/FlipHorizontalButton"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture = ExtResource("3_5o62r") +stretch_mode = 3 + +[node name="FlipVerticalButton" type="Button" parent="VBoxContainer/TransformButtonsContainer" groups=["UIButtons"]] +custom_minimum_size = Vector2(24, 24) +layout_mode = 2 +tooltip_text = "Flip tile vertically" +mouse_default_cursor_shape = 2 +shortcut = SubResource("Shortcut_jj4yy") + +[node name="TextureRect" type="TextureRect" parent="VBoxContainer/TransformButtonsContainer/FlipVerticalButton"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture = ExtResource("4_2xhnr") +stretch_mode = 3 + +[node name="ModeButtonsContainer" type="HFlowContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="Manual" type="CheckBox" parent="VBoxContainer/ModeButtonsContainer"] +layout_mode = 2 +mouse_default_cursor_shape = 2 +button_group = SubResource("ButtonGroup_uxnt0") +shortcut = SubResource("Shortcut_pgg48") +text = "Manual" + +[node name="Auto" type="CheckBox" parent="VBoxContainer/ModeButtonsContainer"] +layout_mode = 2 +mouse_default_cursor_shape = 2 +button_pressed = true +button_group = SubResource("ButtonGroup_uxnt0") +shortcut = SubResource("Shortcut_a0fx5") +text = "Auto" + +[node name="Stack" type="CheckBox" parent="VBoxContainer/ModeButtonsContainer"] +layout_mode = 2 +mouse_default_cursor_shape = 2 +button_group = SubResource("ButtonGroup_uxnt0") +shortcut = SubResource("Shortcut_ysxej") +text = "Stack" + +[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="TileButtonContainer" type="HFlowContainer" parent="VBoxContainer/ScrollContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[connection signal="toggled" from="VBoxContainer/PlaceTiles" to="." method="_on_place_tiles_toggled"] +[connection signal="pressed" from="VBoxContainer/TransformButtonsContainer/RotateLeftButton" to="." method="_on_rotate_pressed" binds= [false]] +[connection signal="pressed" from="VBoxContainer/TransformButtonsContainer/RotateRightButton" to="." method="_on_rotate_pressed" binds= [true]] +[connection signal="pressed" from="VBoxContainer/TransformButtonsContainer/FlipHorizontalButton" to="." method="_on_flip_horizontal_button_pressed"] +[connection signal="pressed" from="VBoxContainer/TransformButtonsContainer/FlipVerticalButton" to="." method="_on_flip_vertical_button_pressed"] +[connection signal="toggled" from="VBoxContainer/ModeButtonsContainer/Manual" to="." method="_on_manual_toggled"] +[connection signal="toggled" from="VBoxContainer/ModeButtonsContainer/Auto" to="." method="_on_auto_toggled"] +[connection signal="toggled" from="VBoxContainer/ModeButtonsContainer/Stack" to="." method="_on_stack_toggled"] diff --git a/src/UI/Timeline/AnimationTimeline.gd b/src/UI/Timeline/AnimationTimeline.gd index 5173865a1..2cf58ce6c 100644 --- a/src/UI/Timeline/AnimationTimeline.gd +++ b/src/UI/Timeline/AnimationTimeline.gd @@ -55,9 +55,10 @@ var global_layer_expand := true @onready var play_forward := %PlayForward as Button @onready var fps_spinbox := %FPSValue as ValueSlider @onready var onion_skinning_button := %OnionSkinning as BaseButton -@onready var timeline_settings := $TimelineSettings as Popup @onready var cel_size_slider := %CelSizeSlider as ValueSlider @onready var loop_animation_button := %LoopAnim as BaseButton +@onready var timeline_settings := $TimelineSettings as Popup +@onready var new_tile_map_layer_dialog := $NewTileMapLayerDialog as ConfirmationDialog @onready var drag_highlight := $DragHighlight as ColorRect @@ -70,7 +71,7 @@ func _ready() -> void: cel_size_slider.min_value = min_cel_size cel_size_slider.max_value = max_cel_size cel_size_slider.value = cel_size - add_layer_list.get_popup().id_pressed.connect(add_layer) + add_layer_list.get_popup().id_pressed.connect(_on_add_layer_list_id_pressed) frame_scroll_bar.value_changed.connect(_frame_scroll_changed) animation_timer.wait_time = 1 / Global.current_project.fps fps_spinbox.value = Global.current_project.fps @@ -475,6 +476,8 @@ func copy_frames( ) if src_cel.selected != null: selected_id = src_cel.selected.id + elif src_cel is CelTileMap: + new_cel = CelTileMap.new(src_cel.tileset) else: new_cel = src_cel.get_script().new() @@ -832,24 +835,34 @@ func _on_FuturePlacement_item_selected(index: int) -> void: # Layer buttons - - -func add_layer(type := 0) -> void: +func _on_add_layer_pressed() -> void: var project := Global.current_project - var current_layer := project.layers[project.current_layer] - var l: BaseLayer - match type: - Global.LayerTypes.PIXEL: - l = PixelLayer.new(project) - Global.LayerTypes.GROUP: - l = GroupLayer.new(project) - Global.LayerTypes.THREE_D: - l = Layer3D.new(project) - SteamManager.set_achievement("ACH_3D_LAYER") + var layer := PixelLayer.new(project) + add_layer(layer, project) + +func _on_add_layer_list_id_pressed(id: int) -> void: + if id == Global.LayerTypes.TILEMAP: + new_tile_map_layer_dialog.popup_centered() + else: + var project := Global.current_project + var layer: BaseLayer + match id: + Global.LayerTypes.PIXEL: + layer = PixelLayer.new(project) + Global.LayerTypes.GROUP: + layer = GroupLayer.new(project) + Global.LayerTypes.THREE_D: + layer = Layer3D.new(project) + SteamManager.set_achievement("ACH_3D_LAYER") + add_layer(layer, project) + + +func add_layer(layer: BaseLayer, project: Project) -> void: + var current_layer := project.layers[project.current_layer] var cels := [] for f in project.frames: - cels.append(l.new_empty_cel()) + cels.append(layer.new_empty_cel()) var new_layer_idx := project.current_layer + 1 if current_layer is GroupLayer: @@ -862,14 +875,14 @@ func add_layer(type := 0) -> void: layer_button.visible = expanded Global.cel_vbox.get_child(layer_button.get_index()).visible = expanded # make layer child of group - l.parent = Global.current_project.layers[project.current_layer] + layer.parent = Global.current_project.layers[project.current_layer] else: # set the parent of layer to be the same as the layer below it - l.parent = Global.current_project.layers[project.current_layer].parent + layer.parent = Global.current_project.layers[project.current_layer].parent project.undos += 1 project.undo_redo.create_action("Add Layer") - project.undo_redo.add_do_method(project.add_layers.bind([l], [new_layer_idx], [cels])) + project.undo_redo.add_do_method(project.add_layers.bind([layer], [new_layer_idx], [cels])) project.undo_redo.add_undo_method(project.remove_layers.bind([new_layer_idx])) project.undo_redo.add_do_method(project.change_cel.bind(-1, new_layer_idx)) project.undo_redo.add_undo_method(project.change_cel.bind(-1, project.current_layer)) @@ -886,7 +899,11 @@ func _on_CloneLayer_pressed() -> void: var clones: Array[BaseLayer] = [] var cels := [] # 2D Array of Cels for src_layer in source_layers: - var cl_layer: BaseLayer = src_layer.get_script().new(project) + var cl_layer: BaseLayer + if src_layer is LayerTileMap: + cl_layer = LayerTileMap.new(project, src_layer.tileset) + else: + cl_layer = src_layer.get_script().new(project) cl_layer.project = project cl_layer.index = src_layer.index var src_layer_data: Dictionary = src_layer.serialize() @@ -904,6 +921,8 @@ func _on_CloneLayer_pressed() -> void: new_cel = Cel3D.new( src_cel.size, false, src_cel.object_properties, src_cel.scene_properties ) + elif src_cel is CelTileMap: + new_cel = CelTileMap.new(src_cel.tileset) else: new_cel = src_cel.get_script().new() @@ -1085,11 +1104,15 @@ func _on_MergeDownLayer_pressed() -> void: project.undo_redo.add_do_property(bottom_cel, "image", new_bottom_image) project.undo_redo.add_undo_property(bottom_cel, "image", bottom_cel.image) else: - var redo_data := {} var undo_data := {} + var redo_data := {} + if bottom_cel is CelTileMap: + (bottom_cel as CelTileMap).serialize_undo_data_source_image( + new_bottom_image, redo_data, undo_data + ) new_bottom_image.add_data_to_dictionary(redo_data, bottom_image) bottom_image.add_data_to_dictionary(undo_data) - Global.undo_redo_compress_images(redo_data, undo_data, project) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_do_method(project.remove_layers.bind([top_layer.index])) project.undo_redo.add_undo_method( diff --git a/src/UI/Timeline/AnimationTimeline.tscn b/src/UI/Timeline/AnimationTimeline.tscn index efb04ba70..622135afa 100644 --- a/src/UI/Timeline/AnimationTimeline.tscn +++ b/src/UI/Timeline/AnimationTimeline.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=76 format=3 uid="uid://dbr6mulku2qju"] +[gd_scene load_steps=77 format=3 uid="uid://dbr6mulku2qju"] [ext_resource type="Script" path="res://src/UI/Timeline/AnimationTimeline.gd" id="1"] [ext_resource type="Texture2D" uid="uid://d36mlbmq06q4e" path="res://assets/graphics/layers/new.png" id="2"] @@ -26,6 +26,7 @@ [ext_resource type="Texture2D" uid="uid://cerkv5yx4cqeh" path="res://assets/graphics/timeline/copy_frame.png" id="27"] [ext_resource type="Texture2D" uid="uid://dndlglvqc7v6a" path="res://assets/graphics/layers/group_expanded.png" id="27_lrc8y"] [ext_resource type="Texture2D" uid="uid://dukip7mvotxsp" path="res://assets/graphics/timeline/onion_skinning_off.png" id="29"] +[ext_resource type="PackedScene" uid="uid://hbgwxlin4jun" path="res://src/UI/Timeline/NewTileMapLayerDialog.tscn" id="29_t0mtf"] [ext_resource type="Texture2D" uid="uid://dinubfua8gqhw" path="res://assets/graphics/timeline/expandable.png" id="30"] [ext_resource type="Texture2D" uid="uid://fbwld5ofmocm" path="res://assets/graphics/timeline/loop.png" id="31"] @@ -239,12 +240,14 @@ offset_left = -22.0 offset_top = -10.0 offset_bottom = 10.0 mouse_default_cursor_shape = 2 -item_count = 3 +item_count = 4 popup/item_0/text = "Add Pixel Layer" popup/item_1/text = "Add Group Layer" popup/item_1/id = 1 popup/item_2/text = "Add 3D Layer" popup/item_2/id = 2 +popup/item_3/text = "Add Tilemap Layer" +popup/item_3/id = 3 [node name="TextureRect" type="TextureRect" parent="TimelineContainer/TimelineButtons/LayerTools/MarginContainer/LayerSettingsContainer/LayerButtons/AddLayer/AddLayerList"] layout_mode = 0 @@ -1114,6 +1117,8 @@ size_flags_horizontal = 0 mouse_default_cursor_shape = 2 text = "Color mode" +[node name="NewTileMapLayerDialog" parent="." instance=ExtResource("29_t0mtf")] + [node name="DragHighlight" type="ColorRect" parent="."] visible = false z_index = 2 @@ -1123,7 +1128,7 @@ offset_bottom = 40.0 mouse_filter = 2 color = Color(0, 0.741176, 1, 0.501961) -[connection signal="pressed" from="TimelineContainer/TimelineButtons/LayerTools/MarginContainer/LayerSettingsContainer/LayerButtons/AddLayer" to="." method="add_layer"] +[connection signal="pressed" from="TimelineContainer/TimelineButtons/LayerTools/MarginContainer/LayerSettingsContainer/LayerButtons/AddLayer" to="." method="_on_add_layer_pressed"] [connection signal="pressed" from="TimelineContainer/TimelineButtons/LayerTools/MarginContainer/LayerSettingsContainer/LayerButtons/RemoveLayer" to="." method="_on_RemoveLayer_pressed"] [connection signal="pressed" from="TimelineContainer/TimelineButtons/LayerTools/MarginContainer/LayerSettingsContainer/LayerButtons/MoveUpLayer" to="." method="change_layer_order" binds= [true]] [connection signal="pressed" from="TimelineContainer/TimelineButtons/LayerTools/MarginContainer/LayerSettingsContainer/LayerButtons/MoveDownLayer" to="." method="change_layer_order" binds= [false]] diff --git a/src/UI/Timeline/LayerEffects/LayerEffectsSettings.gd b/src/UI/Timeline/LayerEffects/LayerEffectsSettings.gd index 09c9ff5b4..802509522 100644 --- a/src/UI/Timeline/LayerEffects/LayerEffectsSettings.gd +++ b/src/UI/Timeline/LayerEffects/LayerEffectsSettings.gd @@ -149,34 +149,41 @@ func _delete_effect(effect: LayerEffect) -> void: func _apply_effect(layer: BaseLayer, effect: LayerEffect) -> void: + var project := Global.current_project var index := layer.effects.find(effect) var redo_data := {} var undo_data := {} - for frame in Global.current_project.frames: + for frame in project.frames: var cel := frame.cels[layer.index] - var new_image := ImageExtended.new() var cel_image := cel.get_image() + if cel is CelTileMap: + undo_data[cel] = (cel as CelTileMap).serialize_undo_data() if cel_image is ImageExtended: - new_image.is_indexed = cel_image.is_indexed - new_image.copy_from_custom(cel_image) - var image_size := new_image.get_size() - var shader_image_effect := ShaderImageEffect.new() - shader_image_effect.generate_image(new_image, effect.shader, effect.params, image_size) - if cel_image is ImageExtended: - redo_data[cel_image.indices_image] = new_image.indices_image.data undo_data[cel_image.indices_image] = cel_image.indices_image.data - redo_data[cel_image] = new_image.data undo_data[cel_image] = cel_image.data - Global.current_project.undos += 1 - Global.current_project.undo_redo.create_action("Apply layer effect") - Global.undo_redo_compress_images(redo_data, undo_data) - Global.current_project.undo_redo.add_do_method(func(): layer.effects.erase(effect)) - Global.current_project.undo_redo.add_do_method(Global.canvas.queue_redraw) - Global.current_project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) - Global.current_project.undo_redo.add_undo_method(func(): layer.effects.insert(index, effect)) - Global.current_project.undo_redo.add_undo_method(Global.canvas.queue_redraw) - Global.current_project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true)) - Global.current_project.undo_redo.commit_action() + var image_size := cel_image.get_size() + var shader_image_effect := ShaderImageEffect.new() + shader_image_effect.generate_image(cel_image, effect.shader, effect.params, image_size) + + project.update_tilemaps(undo_data) + for frame in project.frames: + var cel := frame.cels[layer.index] + var cel_image := cel.get_image() + if cel is CelTileMap: + redo_data[cel] = (cel as CelTileMap).serialize_undo_data() + if cel_image is ImageExtended: + redo_data[cel_image.indices_image] = cel_image.indices_image.data + redo_data[cel_image] = cel_image.data + project.undos += 1 + project.undo_redo.create_action("Apply layer effect") + project.deserialize_cel_undo_data(redo_data, undo_data) + project.undo_redo.add_do_method(func(): layer.effects.erase(effect)) + project.undo_redo.add_do_method(Global.canvas.queue_redraw) + project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) + project.undo_redo.add_undo_method(func(): layer.effects.insert(index, effect)) + project.undo_redo.add_undo_method(Global.canvas.queue_redraw) + project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true)) + project.undo_redo.commit_action() effect_container.get_child(index).queue_free() diff --git a/src/UI/Timeline/LayerProperties.gd b/src/UI/Timeline/LayerProperties.gd index f8753ac85..fd4dfd229 100644 --- a/src/UI/Timeline/LayerProperties.gd +++ b/src/UI/Timeline/LayerProperties.gd @@ -8,13 +8,15 @@ var layer_indices: PackedInt32Array @onready var opacity_slider := $GridContainer/OpacitySlider as ValueSlider @onready var blend_modes_button := $GridContainer/BlendModeOptionButton as OptionButton @onready var user_data_text_edit := $GridContainer/UserDataTextEdit as TextEdit +@onready var tileset_option_button := $GridContainer/TilesetOptionButton as OptionButton func _on_visibility_changed() -> void: if layer_indices.size() == 0: return Global.dialog_open(visible) - var first_layer := Global.current_project.layers[layer_indices[0]] + var project := Global.current_project + var first_layer := project.layers[layer_indices[0]] if visible: _fill_blend_modes_option_button() name_line_edit.text = first_layer.name @@ -22,6 +24,14 @@ func _on_visibility_changed() -> void: var blend_mode_index := blend_modes_button.get_item_index(first_layer.blend_mode) blend_modes_button.selected = blend_mode_index user_data_text_edit.text = first_layer.user_data + get_tree().set_group(&"TilemapLayers", "visible", first_layer is LayerTileMap) + tileset_option_button.clear() + if first_layer is LayerTileMap: + for i in project.tilesets.size(): + var tileset := project.tilesets[i] + tileset_option_button.add_item(tileset.get_text_info(i)) + if tileset == first_layer.tileset: + tileset_option_button.select(i) else: layer_indices = [] @@ -86,6 +96,7 @@ func _on_blend_mode_option_button_item_selected(index: BaseLayer.BlendModes) -> Global.canvas.update_all_layers = true var project := Global.current_project var current_mode := blend_modes_button.get_item_id(index) + project.undos += 1 project.undo_redo.create_action("Set Blend Mode") for layer_index in layer_indices: var layer := project.layers[layer_index] @@ -109,3 +120,32 @@ func _on_user_data_text_edit_text_changed() -> void: func _emit_layer_property_signal() -> void: layer_property_changed.emit() + + +func _on_tileset_option_button_item_selected(index: int) -> void: + var project := Global.current_project + var new_tileset := project.tilesets[index] + project.undos += 1 + project.undo_redo.create_action("Set Tileset") + for layer_index in layer_indices: + var layer := project.layers[layer_index] + if layer is not LayerTileMap: + continue + var previous_tileset := (layer as LayerTileMap).tileset + project.undo_redo.add_do_property(layer, "tileset", new_tileset) + project.undo_redo.add_undo_property(layer, "tileset", previous_tileset) + for frame in project.frames: + for i in frame.cels.size(): + var cel := frame.cels[i] + if cel is CelTileMap and i == layer_index: + project.undo_redo.add_do_method(cel.set_tileset.bind(new_tileset, false)) + project.undo_redo.add_do_method(cel.update_cel_portions) + project.undo_redo.add_undo_method(cel.set_tileset.bind(previous_tileset, false)) + project.undo_redo.add_undo_method(cel.update_cel_portions) + project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) + project.undo_redo.add_do_method(Global.canvas.draw_layers) + project.undo_redo.add_do_method(func(): Global.cel_switched.emit()) + project.undo_redo.add_undo_method(Global.undo_or_redo.bind(true)) + project.undo_redo.add_undo_method(Global.canvas.draw_layers) + project.undo_redo.add_undo_method(func(): Global.cel_switched.emit()) + project.undo_redo.commit_action() diff --git a/src/UI/Timeline/LayerProperties.tscn b/src/UI/Timeline/LayerProperties.tscn index 7979e7eb4..74ac0b682 100644 --- a/src/UI/Timeline/LayerProperties.tscn +++ b/src/UI/Timeline/LayerProperties.tscn @@ -5,11 +5,14 @@ [node name="LayerProperties" type="AcceptDialog"] title = "Layer properties" +size = Vector2i(300, 208) script = ExtResource("1_54q1t") [node name="GridContainer" type="GridContainer" parent="."] -offset_right = 40.0 -offset_bottom = 40.0 +offset_left = 8.0 +offset_top = 8.0 +offset_right = 292.0 +offset_bottom = 159.0 columns = 2 [node name="NameLabel" type="Label" parent="GridContainer"] @@ -60,8 +63,19 @@ layout_mode = 2 size_flags_horizontal = 3 scroll_fit_content_height = true +[node name="TilesetLabel" type="Label" parent="GridContainer" groups=["TilemapLayers"]] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 0 +text = "Tileset:" + +[node name="TilesetOptionButton" type="OptionButton" parent="GridContainer" groups=["TilemapLayers"]] +layout_mode = 2 +mouse_default_cursor_shape = 2 + [connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"] [connection signal="text_changed" from="GridContainer/NameLineEdit" to="." method="_on_name_line_edit_text_changed"] [connection signal="value_changed" from="GridContainer/OpacitySlider" to="." method="_on_opacity_slider_value_changed"] [connection signal="item_selected" from="GridContainer/BlendModeOptionButton" to="." method="_on_blend_mode_option_button_item_selected"] [connection signal="text_changed" from="GridContainer/UserDataTextEdit" to="." method="_on_user_data_text_edit_text_changed"] +[connection signal="item_selected" from="GridContainer/TilesetOptionButton" to="." method="_on_tileset_option_button_item_selected"] diff --git a/src/UI/Timeline/NewTileMapLayerDialog.gd b/src/UI/Timeline/NewTileMapLayerDialog.gd new file mode 100644 index 000000000..6fe79e281 --- /dev/null +++ b/src/UI/Timeline/NewTileMapLayerDialog.gd @@ -0,0 +1,46 @@ +extends ConfirmationDialog + +@onready var animation_timeline := get_parent() as Control +@onready var name_line_edit: LineEdit = $GridContainer/NameLineEdit +@onready var tileset_option_button: OptionButton = $GridContainer/TilesetOptionButton +@onready var tileset_name_line_edit: LineEdit = $GridContainer/TilesetNameLineEdit +@onready var tile_size_slider: ValueSliderV2 = $GridContainer/TileSizeSlider + + +func _on_confirmed() -> void: + var project := Global.current_project + var layer_name := name_line_edit.text + var tileset_name := tileset_name_line_edit.text + var tile_size := tile_size_slider.value + var tileset: TileSetCustom + if tileset_option_button.selected == 0: + tileset = TileSetCustom.new(tile_size, tileset_name) + else: + tileset = project.tilesets[tileset_option_button.selected - 1] + var layer := LayerTileMap.new(project, tileset, layer_name) + animation_timeline.add_layer(layer, project) + + +func _on_visibility_changed() -> void: + Global.dialog_open(visible) + + +func _on_about_to_popup() -> void: + var project := Global.current_project + var default_name := tr("Tilemap") + " %s" % (project.layers.size() + 1) + name_line_edit.text = default_name + tileset_option_button.clear() + tileset_option_button.add_item("New tileset") + for i in project.tilesets.size(): + var tileset := project.tilesets[i] + tileset_option_button.add_item(tileset.get_text_info(i)) + _on_tileset_option_button_item_selected(tileset_option_button.selected) + + +func _on_tileset_option_button_item_selected(index: int) -> void: + if index > 0: + var tileset := Global.current_project.tilesets[index - 1] + tileset_name_line_edit.text = tileset.name + tile_size_slider.value = tileset.tile_size + tileset_name_line_edit.editable = index == 0 + tile_size_slider.editable = tileset_name_line_edit.editable diff --git a/src/UI/Timeline/NewTileMapLayerDialog.tscn b/src/UI/Timeline/NewTileMapLayerDialog.tscn new file mode 100644 index 000000000..2d20c8d36 --- /dev/null +++ b/src/UI/Timeline/NewTileMapLayerDialog.tscn @@ -0,0 +1,70 @@ +[gd_scene load_steps=3 format=3 uid="uid://hbgwxlin4jun"] + +[ext_resource type="PackedScene" path="res://src/UI/Nodes/ValueSliderV2.tscn" id="1_uvdem"] +[ext_resource type="Script" path="res://src/UI/Timeline/NewTileMapLayerDialog.gd" id="1_y2r5h"] + +[node name="NewTileMapLayerDialog" type="ConfirmationDialog"] +title = "New layer" +position = Vector2i(0, 36) +size = Vector2i(300, 230) +script = ExtResource("1_y2r5h") + +[node name="GridContainer" type="GridContainer" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 292.0 +offset_bottom = 181.0 +columns = 2 + +[node name="NameLabel" type="Label" parent="GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Name:" + +[node name="NameLineEdit" type="LineEdit" parent="GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Tilemap 1" + +[node name="TilesetLabel" type="Label" parent="GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Tileset:" + +[node name="TilesetOptionButton" type="OptionButton" parent="GridContainer"] +layout_mode = 2 +mouse_default_cursor_shape = 2 +selected = 0 +item_count = 1 +popup/item_0/text = "New tileset" + +[node name="TilesetNameLabel" type="Label" parent="GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Tileset name:" + +[node name="TilesetNameLineEdit" type="LineEdit" parent="GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="TileSizeLabel" type="Label" parent="GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Tile size:" + +[node name="TileSizeSlider" parent="GridContainer" instance=ExtResource("1_uvdem")] +layout_mode = 2 +value = Vector2(16, 16) +min_value = Vector2(1, 1) +max_value = Vector2(128, 128) +allow_greater = true +show_ratio = true +prefix_x = "Width:" +prefix_y = "Height:" +suffix_x = "px" +suffix_y = "px" + +[connection signal="about_to_popup" from="." to="." method="_on_about_to_popup"] +[connection signal="confirmed" from="." to="." method="_on_confirmed"] +[connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"] +[connection signal="item_selected" from="GridContainer/TilesetOptionButton" to="." method="_on_tileset_option_button_item_selected"] diff --git a/src/UI/TopMenuContainer/TopMenuContainer.gd b/src/UI/TopMenuContainer/TopMenuContainer.gd index de29ebf89..7f78eba23 100644 --- a/src/UI/TopMenuContainer/TopMenuContainer.gd +++ b/src/UI/TopMenuContainer/TopMenuContainer.gd @@ -372,9 +372,13 @@ func _setup_panels_submenu(item: String) -> void: panels_submenu.set_name("panels_submenu") panels_submenu.hide_on_checkable_item_selection = false for element in ui_elements: - panels_submenu.add_check_item(element.name) + if element.name == "Tiles": + continue + var id := ui_elements.find(element) + panels_submenu.add_check_item(element.name, id) var is_hidden: bool = main_ui.is_control_hidden(element) - panels_submenu.set_item_checked(ui_elements.find(element), !is_hidden) + var index := panels_submenu.get_item_index(id) + panels_submenu.set_item_checked(index, !is_hidden) panels_submenu.id_pressed.connect(_panels_submenu_id_pressed) window_menu.add_child(panels_submenu) @@ -718,21 +722,25 @@ func _color_mode_submenu_id_pressed(id: ColorModes) -> void: var old_color_mode := project.color_mode var redo_data := {} var undo_data := {} + var pixel_cels: Array[BaseCel] + # We need to do it this way because Godot + # doesn't like casting typed arrays into other types. for cel in project.get_all_pixel_cels(): - cel.get_image().add_data_to_dictionary(undo_data) + pixel_cels.append(cel) + project.serialize_cel_undo_data(pixel_cels, undo_data) # Change the color mode directly before undo/redo in order to affect the images, # so we can store them as redo data. if id == ColorModes.RGBA: project.color_mode = Image.FORMAT_RGBA8 else: project.color_mode = Project.INDEXED_MODE - for cel in project.get_all_pixel_cels(): - cel.get_image().add_data_to_dictionary(redo_data) + project.update_tilemaps(undo_data) + project.serialize_cel_undo_data(pixel_cels, redo_data) project.undo_redo.create_action("Change color mode") project.undos += 1 project.undo_redo.add_do_property(project, "color_mode", project.color_mode) project.undo_redo.add_undo_property(project, "color_mode", old_color_mode) - Global.undo_redo_compress_images(redo_data, undo_data, project) + project.deserialize_cel_undo_data(redo_data, undo_data) project.undo_redo.add_do_method(_check_color_mode_submenu_item.bind(project)) project.undo_redo.add_undo_method(_check_color_mode_submenu_item.bind(project)) project.undo_redo.add_do_method(Global.undo_or_redo.bind(false)) @@ -763,9 +771,10 @@ func _snap_to_submenu_id_pressed(id: int) -> void: func _panels_submenu_id_pressed(id: int) -> void: if zen_mode: return - var element_visible := panels_submenu.is_item_checked(id) + var index := panels_submenu.get_item_index(id) + var element_visible := panels_submenu.is_item_checked(index) main_ui.set_control_hidden(ui_elements[id], element_visible) - panels_submenu.set_item_checked(id, !element_visible) + panels_submenu.set_item_checked(index, !element_visible) func _layouts_submenu_id_pressed(id: int) -> void: @@ -787,8 +796,9 @@ func set_layout(id: int) -> void: layouts_submenu.set_item_checked(offset, offset == (id + 1)) for i in ui_elements.size(): + var index := panels_submenu.get_item_index(i) var is_hidden := main_ui.is_control_hidden(ui_elements[i]) - panels_submenu.set_item_checked(i, !is_hidden) + panels_submenu.set_item_checked(index, !is_hidden) if zen_mode: # Turn zen mode off Global.control.find_child("TabsContainer").visible = true @@ -866,9 +876,11 @@ func _toggle_show_mouse_guides() -> void: func _toggle_zen_mode() -> void: for i in ui_elements.size(): - if ui_elements[i].name == "Main Canvas": + var index := panels_submenu.get_item_index(i) + var panel_name := ui_elements[i].name + if panel_name == "Main Canvas" or panel_name == "Tiles": continue - if !panels_submenu.is_item_checked(i): + if !panels_submenu.is_item_checked(index): continue main_ui.set_control_hidden(ui_elements[i], !zen_mode) Global.control.find_child("TabsContainer").visible = zen_mode diff --git a/src/UI/UI.gd b/src/UI/UI.gd index c95234f6d..7ba4a9c9e 100644 --- a/src/UI/UI.gd +++ b/src/UI/UI.gd @@ -3,16 +3,25 @@ extends Panel var shader_disabled := false var transparency_material: ShaderMaterial +@onready var dockable_container: DockableContainer = $DockableContainer @onready var main_canvas_container := find_child("Main Canvas") as Container +@onready var tiles: TileSetPanel = $DockableContainer/Tiles func _ready() -> void: + Global.cel_switched.connect(_on_cel_switched) transparency_material = material main_canvas_container.property_list_changed.connect(_re_configure_shader) update_transparent_shader() + dockable_container.set_control_hidden.call_deferred(tiles, true) -func _re_configure_shader(): +func _on_cel_switched() -> void: + var cel := Global.current_project.get_current_cel() + dockable_container.set_control_hidden(tiles, cel is not CelTileMap) + + +func _re_configure_shader() -> void: await get_tree().process_frame if get_window() != main_canvas_container.get_window(): material = null diff --git a/src/UI/UI.tscn b/src/UI/UI.tscn index 1700b0868..b42265da8 100644 --- a/src/UI/UI.tscn +++ b/src/UI/UI.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=54 format=3 uid="uid://c8dsi6ggkqa7a"] +[gd_scene load_steps=55 format=3 uid="uid://c8dsi6ggkqa7a"] [ext_resource type="PackedScene" uid="uid://byu3rtoipuvoc" path="res://src/UI/ToolsPanel/Tools.tscn" id="1"] [ext_resource type="PackedScene" uid="uid://c546tskdu53j1" path="res://src/UI/Canvas/CanvasPreview.tscn" id="2"] @@ -20,6 +20,7 @@ [ext_resource type="PackedScene" uid="uid://ba24iuv55m4l3" path="res://src/UI/Canvas/Canvas.tscn" id="19"] [ext_resource type="PackedScene" uid="uid://wplk62pbgih4" path="res://src/Palette/PalettePanel.tscn" id="20"] [ext_resource type="Script" path="res://src/UI/ViewportContainer.gd" id="23"] +[ext_resource type="PackedScene" uid="uid://bfbragmmdwfbl" path="res://src/UI/TilesPanel.tscn" id="23_wyr78"] [ext_resource type="Script" path="res://addons/dockable_container/layout_split.gd" id="27"] [ext_resource type="Script" path="res://addons/dockable_container/dockable_container.gd" id="35"] [ext_resource type="Script" path="res://addons/dockable_container/layout_panel.gd" id="36"] @@ -36,7 +37,7 @@ shader_parameter/size = Vector2(100, 100) [sub_resource type="Resource" id="Resource_xnnnd"] resource_name = "Tabs" script = ExtResource("36") -names = PackedStringArray("Tools", "Reference Images") +names = PackedStringArray("Tools", "Reference Images", "Tiles") current_tab = 0 [sub_resource type="Resource" id="Resource_34hle"] @@ -401,6 +402,10 @@ size_flags_vertical = 3 [node name="Palettes" parent="DockableContainer" instance=ExtResource("20")] layout_mode = 2 +[node name="Tiles" parent="DockableContainer" instance=ExtResource("23_wyr78")] +visible = false +layout_mode = 2 + [node name="Reference Images" parent="DockableContainer" instance=ExtResource("11")] visible = false layout_mode = 2