From f91bb18fb2268b1257a664a52daf2a77e8051f6d Mon Sep 17 00:00:00 2001
From: Emmanouil Papadeas <35376950+OverloadedOrama@users.noreply.github.com>
Date: Thu, 5 Dec 2024 03:57:44 +0200
Subject: [PATCH] Implement tilemap layers (#1146)
---
Translations/Translations.pot | 70 ++
assets/graphics/misc/mirror_x.svg | 1 +
assets/graphics/misc/mirror_x.svg.import | 37 +
assets/graphics/misc/mirror_y.svg | 1 +
assets/graphics/misc/mirror_y.svg.import | 37 +
assets/layouts/Default.tres | 81 +-
assets/layouts/Tallscreen.tres | 67 +-
project.godot | 42 +-
src/Autoload/DrawingAlgos.gd | 53 +-
src/Autoload/Global.gd | 27 +-
src/Autoload/OpenSave.gd | 82 +-
src/Autoload/Tools.gd | 109 ++-
src/Classes/Cels/BaseCel.gd | 6 +-
src/Classes/Cels/CelTileMap.gd | 700 ++++++++++++++++++
src/Classes/Cels/PixelCel.gd | 4 +-
src/Classes/ImageEffect.gd | 15 +-
src/Classes/Layers/LayerTileMap.gd | 57 ++
src/Classes/Project.gd | 202 +++--
src/Classes/SelectionMap.gd | 7 +
src/Classes/TileSetCustom.gd | 185 +++++
src/Tools/BaseDraw.gd | 118 ++-
src/Tools/BaseSelectionTool.gd | 11 +
src/Tools/BaseShapeDrawer.gd | 14 +-
src/Tools/BaseTool.gd | 148 ++--
src/Tools/DesignTools/Bucket.gd | 263 +++++--
src/Tools/DesignTools/CurveTool.gd | 11 +-
src/Tools/DesignTools/Eraser.gd | 7 +-
src/Tools/DesignTools/LineTool.gd | 16 +-
src/Tools/DesignTools/Pencil.gd | 4 +-
src/Tools/DesignTools/Shading.gd | 2 +-
src/Tools/SelectionTools/ColorSelect.gd | 45 +-
src/Tools/SelectionTools/EllipseSelect.gd | 49 +-
src/Tools/SelectionTools/Lasso.gd | 23 +-
src/Tools/SelectionTools/MagicWand.gd | 182 ++++-
src/Tools/SelectionTools/PaintSelect.gd | 19 +-
src/Tools/SelectionTools/PolygonSelect.gd | 23 +-
src/Tools/SelectionTools/RectSelect.gd | 8 +-
src/Tools/UtilityTools/ColorPicker.gd | 6 +-
src/Tools/UtilityTools/Move.gd | 14 +-
src/Tools/UtilityTools/Text.gd | 13 +-
src/UI/Canvas/Canvas.gd | 6 +-
src/UI/Canvas/Canvas.tscn | 7 +-
src/UI/Canvas/Grid.gd | 21 +-
src/UI/Canvas/Selection.gd | 133 +++-
src/UI/Canvas/TileModeIndices.gd | 26 +
src/UI/Dialogs/ExportDialog.gd | 2 +
.../Dialogs/ImageEffects/FlipImageDialog.gd | 10 +-
src/UI/Dialogs/ImportPreviewDialog.gd | 24 +-
src/UI/Dialogs/ImportPreviewDialog.tscn | 3 +-
src/UI/Dialogs/ProjectProperties.gd | 73 +-
src/UI/Dialogs/ProjectProperties.tscn | 64 +-
src/UI/TilesPanel.gd | 207 ++++++
src/UI/TilesPanel.tscn | 189 +++++
src/UI/Timeline/AnimationTimeline.gd | 67 +-
src/UI/Timeline/AnimationTimeline.tscn | 11 +-
.../LayerEffects/LayerEffectsSettings.gd | 47 +-
src/UI/Timeline/LayerProperties.gd | 42 +-
src/UI/Timeline/LayerProperties.tscn | 18 +-
src/UI/Timeline/NewTileMapLayerDialog.gd | 46 ++
src/UI/Timeline/NewTileMapLayerDialog.tscn | 70 ++
src/UI/TopMenuContainer/TopMenuContainer.gd | 34 +-
src/UI/UI.gd | 11 +-
src/UI/UI.tscn | 9 +-
63 files changed, 3257 insertions(+), 622 deletions(-)
create mode 100644 assets/graphics/misc/mirror_x.svg
create mode 100644 assets/graphics/misc/mirror_x.svg.import
create mode 100644 assets/graphics/misc/mirror_y.svg
create mode 100644 assets/graphics/misc/mirror_y.svg.import
create mode 100644 src/Classes/Cels/CelTileMap.gd
create mode 100644 src/Classes/Layers/LayerTileMap.gd
create mode 100644 src/Classes/TileSetCustom.gd
create mode 100644 src/UI/Canvas/TileModeIndices.gd
create mode 100644 src/UI/TilesPanel.gd
create mode 100644 src/UI/TilesPanel.tscn
create mode 100644 src/UI/Timeline/NewTileMapLayerDialog.gd
create mode 100644 src/UI/Timeline/NewTileMapLayerDialog.tscn
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