godot-universal-spritepacker 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Donitzo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: godot-universal-spritepacker
3
+ Version: 1.1.1
4
+ Summary: A smart, folder-aware sprite packer for Godot and PixiJS. Split, pack, and convert multi-res tilesets and SVG layers into spritesheets and native Godot sprites.
5
+ Author: Donitz
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Donitzo/godot-universal-spritepacker
8
+ Classifier: Programming Language :: Python :: 3.9
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Multimedia :: Graphics
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: Pillow
17
+ Requires-Dist: rectpack
18
+ Dynamic: license-file
19
+
20
+ ![Logo](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/logo.png)
21
+
22
+ ## Table of Contents
23
+
24
+ * [Description](#description)
25
+ * [Why?](#why)
26
+ * [How it works](#how-it-works)
27
+ * [File naming convention](#file-naming-convention)
28
+ * [Animation metadata (CSV)](#animation-metadata-csv)
29
+ * [Requirements](#requirements)
30
+ * [Installation](#installation)
31
+ * [Demo](#demo)
32
+ * [Usage](#usage)
33
+ * [Issues](#issues)
34
+
35
+ ---
36
+
37
+ ## Description
38
+
39
+ **Godot Universal SpritePacker** is a Python tool that converts individual images, tilesets, and SVG layers into optimized spritesheets and ready-to-use Godot `.tres` resources:
40
+ * [AtlasTextures](https://docs.godotengine.org/en/stable/classes/class_atlastexture.html) for single sprites
41
+ * [SpriteFrames](https://docs.godotengine.org/en/stable/classes/class_spriteframes.html) for animations
42
+
43
+ It intelligently splits, trims and packs images or vector layers into one or more texture atlases, preserving your folder structure and optionally generating [PixiJS-compatible](https://github.com/pixijs/pixijs/blob/main/packages/spritesheet/src/Spritesheet.ts) `.json` files or native Godot resources. Duplicate sprites are automatically merged to save space, unless disabled. [View PixiJS example spritesheet](sample/output/spritesheet.json)
44
+
45
+ ![Example](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/example.png)
46
+
47
+ ### Why?
48
+
49
+ I've used [TexturePacker](https://www.codeandweb.com/texturepacker) for years and still think it’s a great option if you need the full range of features it provides. But over time, I realized I didn’t actually need most of them. I had already written custom importers for its metadata formats in my own [retro JavaScript games](https://donitz.itch.io/sweet-hell), and the actual task of packing sprites turned out to be straightforward thanks to the excellent [rectpack](https://github.com/secnot/rectpack) module.
50
+
51
+ This led me to build the first version of this tool (originally called `smart_splitter`), focused on a workflow where sprites and tilesets are kept as individual files and automatically packed into spritesheets based on their filename and folder structure. That setup made iteration super convenient. Later, as I started using Godot more, I extended the tool to export native `.tres` resources as well.
52
+
53
+ I also occasionally needed to export UI icons directly from vector files, making it easy to go from vector artwork to in-game assets. For example, I used a single SVG with this packer when creating the icons for [Canvas Draw 2D](https://donitz.itch.io/canvas-draw-2d), turning updates into a single-click task.
54
+
55
+ I hope this tool helps simplify your 2D asset workflow too!
56
+
57
+ ---
58
+
59
+ ## How it works
60
+
61
+ The tool follows these steps:
62
+
63
+ 1. Scans the specified `source_directory` (and subdirectories) for images files (`'.png'`, `'.bmp'`, `'.jpg'`, `'.jpeg'`) and vector files (`.svg`).
64
+ 2. Parses filenames to detect optional suffixes indicating tile size, frame rate, padding, etc.
65
+ 3. Splits images into individual sprites based on the specified tile size.
66
+ - If the file is an SVG, each **layer** is exported as a separate sprite using [Inkscape](https://inkscape.org/).
67
+ 4. Optionally checks for the existence of a `.csv` file with multiple animation definitions for a single image.
68
+ 5. Optionally saves individual sprite images to the `image_directory`.
69
+ 6. Optionally trims transparent spaces from the sprites.
70
+ 7. Optionally merges duplicate sprites.
71
+ 8. Packs sprites into one or more optimized spritesheets (texture atlases).
72
+ 9. Generates metadata files:
73
+ - (a) [PixiJS-compatible](https://github.com/pixijs/pixijs/blob/main/packages/spritesheet/src/Spritesheet.ts) `.json`
74
+ - (b) Godot 4 AtlasTexture `.tres` files
75
+ - (c) Godot 4 SpriteFrames `.tres` files (for animations)
76
+
77
+ ---
78
+
79
+ ## File naming convention
80
+
81
+ Files should be named according to this pattern:
82
+
83
+ ```
84
+ boss_turret__WxH[pN][fpsN][loop]
85
+ ```
86
+
87
+ ### Examples:
88
+
89
+ - `background.png` - Treats the entire image as one sprite.
90
+ - `hero__32x32.png` - Splits the image into multiple 32x32 sprites.
91
+ - `Small Spider__64x64p2fps12loop.png` - 64×64 sprite with 2px padding, 12 FPS animation, looping
92
+
93
+ | Field | Description |
94
+ |:--------|:------------------------------------------------------------|
95
+ | `name` | Sprite or animation name. |
96
+ | `W x H` | Tile width × height in pixels. |
97
+ | `pN` | (optional) Padding between tiles (e.g., `p2` = 2px). |
98
+ | `fpsN` | (optional) Frame rate in frames per second (e.g., `fps12`). |
99
+ | `loop` | (optional) If present, marks the animation as looping. |
100
+
101
+ If the source file is located inside a subfolder, the relative path is included in the sprite's name (e.g., `enemies/boss__64x64.png`).
102
+
103
+ Sprites are named using the format `subfolder_a/subfolder_b/original_name__RxC`, where `R` is the row index and `C` is the column index. Rows and columns are intentionally inverted from standard grid notation so that alphabetical sorting orders sprites left to right.
104
+
105
+ > **Note:** SVG files are always exported as individual sprites based on layer and the pixel resolution defined in the file. The filename is ignored.
106
+
107
+ ---
108
+
109
+ ## Animation metadata (CSV)
110
+
111
+ You can define multiple animations using a `.csv` file with the same base name as the image. When present, the CSV defines all frames for the corresponding `SpriteFrames` resource, and any unused sprites from the original image are discarded.
112
+
113
+ Animation data is also saved in the `.json` spritesheet file as both `animations` (recognised by `PixiJS`) and in the `meta` field. The animation name is `image_name:animation_name`.
114
+
115
+ ### Example: `hero.csv`
116
+ ```
117
+ name; start_x; start_y; count_x; count_y; fps; loop
118
+ Walk; 0; 0; 4; 1; 12; true
119
+ Jump; 0; 1; 4; 1; 12; false
120
+ ```
121
+
122
+ | Field | Description |
123
+ |:----------|:----------------------------------------------------|
124
+ | `name` | Animation name (as seen in Godot). |
125
+ | `start_x` | Starting tile column. |
126
+ | `start_y` | Starting tile row. |
127
+ | `count_x` | Number of columns in the animation. |
128
+ | `count_y` | Number of rows in the animation. |
129
+ | `fps` | Playback speed (frames per second). |
130
+ | `loop` | Whether the animation should loop (`true`/`false`). |
131
+
132
+ ---
133
+
134
+ ## Requirements
135
+
136
+ - Python 3.9+
137
+ - [Inkscape](https://inkscape.org/) (optional, for SVG layer processing)
138
+ If Inkscape is not installed at the default path, set it manually using `--inkscape_path`.
139
+ - Requires the Pillow and [rectpack](https://github.com/secnot/rectpack) modules.
140
+
141
+ ## Installation
142
+
143
+ You can install the tool directly from GitHub. This installs a command-line tool called `godot_universal_spritepacker`:
144
+
145
+ ```bash
146
+ pip install git+https://github.com/Donitzo/godot-universal-spritepacker.git
147
+ ```
148
+
149
+ Alternatively, you can simply download the script and run it directly:
150
+
151
+ ```bash
152
+ python src/godot_universal_spritepacker/godot_universal_spritepacker.py
153
+ ```
154
+
155
+ ## Demo
156
+
157
+ There is a set of sprites in the `sample` directory. Try converting them using one of the two `.bat` files to get an idea how the tool works. One of the files generates multiple spritesheets.
158
+
159
+ Inside the pre-made `./sample/output` directory, there is a simple `PixiJS` demo page for rendering the contents of the pre-compiled spritesheet. To test the PixiJS demo locally, serve it using a simple HTTP server.
160
+
161
+ ![PixiJS Demo](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/pixijs_demo.png)
162
+
163
+ ## Usage
164
+
165
+ Run the tool with:
166
+
167
+ ```bash
168
+ godot_universal_spritepacker --source_directory <source_dir> --spritesheet_path <output_path> [options]
169
+ ```
170
+
171
+ ### Required Arguments
172
+ | Argument | Description |
173
+ |:---------------------|:--------------------------------------------------------|
174
+ | `--source_directory` | Directory containing source images. |
175
+ | `--spritesheet_path` | Output path (without extension) for the spritesheet(s). |
176
+
177
+ ### Optional Arguments
178
+ | Argument | Description |
179
+ |:-----------------------------|:---------------------------------------------------------------------------------|
180
+ | `--save_json` | If set, saves metadata `.json` files with sprite frames. |
181
+ | `--image_directory` | Directory to save individual sprite images before packing. |
182
+ | `--godot_sprites_directory` | Directory to output Godot `.tres` resource files. |
183
+ | `--godot_resource_directory` | Internal Godot resource directory for spritesheets (default: `res://textures/`). |
184
+ | `--inkscape_path` | Custom path to the Inkscape executable for SVG processing. |
185
+ | `--max_spritesheet_size` | Maximum width/height for each spritesheet (default: `4096`). |
186
+ | `--sprite_padding` | Transparent pixels around each sprite. Default is `1` = 2 pixel total gap. |
187
+ | `--disable_trimming` | If set, disables sprite transparency trimming. |
188
+ | `--disable_duplicate_removal`| If set, disables automatic merging of visually identical sprites. |
189
+ | `--default_framerate` | If set, treats all regular sprites as animations with this framerate. |
190
+
191
+ ---
192
+
193
+ ## Issues
194
+
195
+ Please report any bugs, feature requests, or questions in the [Issues section](https://github.com/Donitzo/godot-universal-spritepacker/issues).
@@ -0,0 +1,176 @@
1
+ ![Logo](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/logo.png)
2
+
3
+ ## Table of Contents
4
+
5
+ * [Description](#description)
6
+ * [Why?](#why)
7
+ * [How it works](#how-it-works)
8
+ * [File naming convention](#file-naming-convention)
9
+ * [Animation metadata (CSV)](#animation-metadata-csv)
10
+ * [Requirements](#requirements)
11
+ * [Installation](#installation)
12
+ * [Demo](#demo)
13
+ * [Usage](#usage)
14
+ * [Issues](#issues)
15
+
16
+ ---
17
+
18
+ ## Description
19
+
20
+ **Godot Universal SpritePacker** is a Python tool that converts individual images, tilesets, and SVG layers into optimized spritesheets and ready-to-use Godot `.tres` resources:
21
+ * [AtlasTextures](https://docs.godotengine.org/en/stable/classes/class_atlastexture.html) for single sprites
22
+ * [SpriteFrames](https://docs.godotengine.org/en/stable/classes/class_spriteframes.html) for animations
23
+
24
+ It intelligently splits, trims and packs images or vector layers into one or more texture atlases, preserving your folder structure and optionally generating [PixiJS-compatible](https://github.com/pixijs/pixijs/blob/main/packages/spritesheet/src/Spritesheet.ts) `.json` files or native Godot resources. Duplicate sprites are automatically merged to save space, unless disabled. [View PixiJS example spritesheet](sample/output/spritesheet.json)
25
+
26
+ ![Example](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/example.png)
27
+
28
+ ### Why?
29
+
30
+ I've used [TexturePacker](https://www.codeandweb.com/texturepacker) for years and still think it’s a great option if you need the full range of features it provides. But over time, I realized I didn’t actually need most of them. I had already written custom importers for its metadata formats in my own [retro JavaScript games](https://donitz.itch.io/sweet-hell), and the actual task of packing sprites turned out to be straightforward thanks to the excellent [rectpack](https://github.com/secnot/rectpack) module.
31
+
32
+ This led me to build the first version of this tool (originally called `smart_splitter`), focused on a workflow where sprites and tilesets are kept as individual files and automatically packed into spritesheets based on their filename and folder structure. That setup made iteration super convenient. Later, as I started using Godot more, I extended the tool to export native `.tres` resources as well.
33
+
34
+ I also occasionally needed to export UI icons directly from vector files, making it easy to go from vector artwork to in-game assets. For example, I used a single SVG with this packer when creating the icons for [Canvas Draw 2D](https://donitz.itch.io/canvas-draw-2d), turning updates into a single-click task.
35
+
36
+ I hope this tool helps simplify your 2D asset workflow too!
37
+
38
+ ---
39
+
40
+ ## How it works
41
+
42
+ The tool follows these steps:
43
+
44
+ 1. Scans the specified `source_directory` (and subdirectories) for images files (`'.png'`, `'.bmp'`, `'.jpg'`, `'.jpeg'`) and vector files (`.svg`).
45
+ 2. Parses filenames to detect optional suffixes indicating tile size, frame rate, padding, etc.
46
+ 3. Splits images into individual sprites based on the specified tile size.
47
+ - If the file is an SVG, each **layer** is exported as a separate sprite using [Inkscape](https://inkscape.org/).
48
+ 4. Optionally checks for the existence of a `.csv` file with multiple animation definitions for a single image.
49
+ 5. Optionally saves individual sprite images to the `image_directory`.
50
+ 6. Optionally trims transparent spaces from the sprites.
51
+ 7. Optionally merges duplicate sprites.
52
+ 8. Packs sprites into one or more optimized spritesheets (texture atlases).
53
+ 9. Generates metadata files:
54
+ - (a) [PixiJS-compatible](https://github.com/pixijs/pixijs/blob/main/packages/spritesheet/src/Spritesheet.ts) `.json`
55
+ - (b) Godot 4 AtlasTexture `.tres` files
56
+ - (c) Godot 4 SpriteFrames `.tres` files (for animations)
57
+
58
+ ---
59
+
60
+ ## File naming convention
61
+
62
+ Files should be named according to this pattern:
63
+
64
+ ```
65
+ boss_turret__WxH[pN][fpsN][loop]
66
+ ```
67
+
68
+ ### Examples:
69
+
70
+ - `background.png` - Treats the entire image as one sprite.
71
+ - `hero__32x32.png` - Splits the image into multiple 32x32 sprites.
72
+ - `Small Spider__64x64p2fps12loop.png` - 64×64 sprite with 2px padding, 12 FPS animation, looping
73
+
74
+ | Field | Description |
75
+ |:--------|:------------------------------------------------------------|
76
+ | `name` | Sprite or animation name. |
77
+ | `W x H` | Tile width × height in pixels. |
78
+ | `pN` | (optional) Padding between tiles (e.g., `p2` = 2px). |
79
+ | `fpsN` | (optional) Frame rate in frames per second (e.g., `fps12`). |
80
+ | `loop` | (optional) If present, marks the animation as looping. |
81
+
82
+ If the source file is located inside a subfolder, the relative path is included in the sprite's name (e.g., `enemies/boss__64x64.png`).
83
+
84
+ Sprites are named using the format `subfolder_a/subfolder_b/original_name__RxC`, where `R` is the row index and `C` is the column index. Rows and columns are intentionally inverted from standard grid notation so that alphabetical sorting orders sprites left to right.
85
+
86
+ > **Note:** SVG files are always exported as individual sprites based on layer and the pixel resolution defined in the file. The filename is ignored.
87
+
88
+ ---
89
+
90
+ ## Animation metadata (CSV)
91
+
92
+ You can define multiple animations using a `.csv` file with the same base name as the image. When present, the CSV defines all frames for the corresponding `SpriteFrames` resource, and any unused sprites from the original image are discarded.
93
+
94
+ Animation data is also saved in the `.json` spritesheet file as both `animations` (recognised by `PixiJS`) and in the `meta` field. The animation name is `image_name:animation_name`.
95
+
96
+ ### Example: `hero.csv`
97
+ ```
98
+ name; start_x; start_y; count_x; count_y; fps; loop
99
+ Walk; 0; 0; 4; 1; 12; true
100
+ Jump; 0; 1; 4; 1; 12; false
101
+ ```
102
+
103
+ | Field | Description |
104
+ |:----------|:----------------------------------------------------|
105
+ | `name` | Animation name (as seen in Godot). |
106
+ | `start_x` | Starting tile column. |
107
+ | `start_y` | Starting tile row. |
108
+ | `count_x` | Number of columns in the animation. |
109
+ | `count_y` | Number of rows in the animation. |
110
+ | `fps` | Playback speed (frames per second). |
111
+ | `loop` | Whether the animation should loop (`true`/`false`). |
112
+
113
+ ---
114
+
115
+ ## Requirements
116
+
117
+ - Python 3.9+
118
+ - [Inkscape](https://inkscape.org/) (optional, for SVG layer processing)
119
+ If Inkscape is not installed at the default path, set it manually using `--inkscape_path`.
120
+ - Requires the Pillow and [rectpack](https://github.com/secnot/rectpack) modules.
121
+
122
+ ## Installation
123
+
124
+ You can install the tool directly from GitHub. This installs a command-line tool called `godot_universal_spritepacker`:
125
+
126
+ ```bash
127
+ pip install git+https://github.com/Donitzo/godot-universal-spritepacker.git
128
+ ```
129
+
130
+ Alternatively, you can simply download the script and run it directly:
131
+
132
+ ```bash
133
+ python src/godot_universal_spritepacker/godot_universal_spritepacker.py
134
+ ```
135
+
136
+ ## Demo
137
+
138
+ There is a set of sprites in the `sample` directory. Try converting them using one of the two `.bat` files to get an idea how the tool works. One of the files generates multiple spritesheets.
139
+
140
+ Inside the pre-made `./sample/output` directory, there is a simple `PixiJS` demo page for rendering the contents of the pre-compiled spritesheet. To test the PixiJS demo locally, serve it using a simple HTTP server.
141
+
142
+ ![PixiJS Demo](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/pixijs_demo.png)
143
+
144
+ ## Usage
145
+
146
+ Run the tool with:
147
+
148
+ ```bash
149
+ godot_universal_spritepacker --source_directory <source_dir> --spritesheet_path <output_path> [options]
150
+ ```
151
+
152
+ ### Required Arguments
153
+ | Argument | Description |
154
+ |:---------------------|:--------------------------------------------------------|
155
+ | `--source_directory` | Directory containing source images. |
156
+ | `--spritesheet_path` | Output path (without extension) for the spritesheet(s). |
157
+
158
+ ### Optional Arguments
159
+ | Argument | Description |
160
+ |:-----------------------------|:---------------------------------------------------------------------------------|
161
+ | `--save_json` | If set, saves metadata `.json` files with sprite frames. |
162
+ | `--image_directory` | Directory to save individual sprite images before packing. |
163
+ | `--godot_sprites_directory` | Directory to output Godot `.tres` resource files. |
164
+ | `--godot_resource_directory` | Internal Godot resource directory for spritesheets (default: `res://textures/`). |
165
+ | `--inkscape_path` | Custom path to the Inkscape executable for SVG processing. |
166
+ | `--max_spritesheet_size` | Maximum width/height for each spritesheet (default: `4096`). |
167
+ | `--sprite_padding` | Transparent pixels around each sprite. Default is `1` = 2 pixel total gap. |
168
+ | `--disable_trimming` | If set, disables sprite transparency trimming. |
169
+ | `--disable_duplicate_removal`| If set, disables automatic merging of visually identical sprites. |
170
+ | `--default_framerate` | If set, treats all regular sprites as animations with this framerate. |
171
+
172
+ ---
173
+
174
+ ## Issues
175
+
176
+ Please report any bugs, feature requests, or questions in the [Issues section](https://github.com/Donitzo/godot-universal-spritepacker/issues).
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "godot-universal-spritepacker"
7
+ version = "1.1.1"
8
+ description = "A smart, folder-aware sprite packer for Godot and PixiJS. Split, pack, and convert multi-res tilesets and SVG layers into spritesheets and native Godot sprites."
9
+ authors = [
10
+ { name = "Donitz" }
11
+ ]
12
+ license = "MIT"
13
+ readme = "README.md"
14
+ requires-python = ">=3.9"
15
+ dependencies = [
16
+ "Pillow",
17
+ "rectpack"
18
+ ]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Operating System :: OS Independent",
24
+ "Topic :: Multimedia :: Graphics"
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Donitzo/godot-universal-spritepacker"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+ include = ["godot_universal_spritepacker"]
33
+
34
+ [project.scripts]
35
+ godot_universal_spritepacker = "godot_universal_spritepacker.godot_universal_spritepacker:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,679 @@
1
+ __version__ = '1.1.1'
2
+ __author__ = 'Donitz'
3
+ __license__ = 'MIT'
4
+ __repository__ = 'https://github.com/Donitzo/godot-universal-spritepacker'
5
+
6
+ # --------------------------------------------------------------------------------------------------
7
+ # Imports and type definitions
8
+ # --------------------------------------------------------------------------------------------------
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import time
19
+ import xml.etree.ElementTree as ET
20
+
21
+ from PIL import Image
22
+ from rectpack import newPacker, PackerBFF
23
+ from typing import cast, Dict, List, Optional, Tuple, TypedDict
24
+
25
+ # Minimum and maximum supported Python versions
26
+ class UnsupportedVersion(Exception):
27
+ pass
28
+
29
+ MIN_VERSION, VERSION_LESS_THAN = (3, 9), (4, 0)
30
+ if sys.version_info < MIN_VERSION or sys.version_info >= VERSION_LESS_THAN:
31
+ raise UnsupportedVersion('requires Python %s,<%s' %
32
+ ('.'.join(map(str, MIN_VERSION)), '.'.join(map(str, VERSION_LESS_THAN))))
33
+
34
+ # TypedDict definitions for strong typing
35
+ class SizeDict(TypedDict, total=True):
36
+ w: int
37
+ h: int
38
+
39
+ class RectDict(TypedDict, total=True):
40
+ x: int
41
+ y: int
42
+ w: int
43
+ h: int
44
+
45
+ class SpriteDict(TypedDict, total=True):
46
+ animated: bool
47
+ duplicate: Optional['SpriteDict']
48
+ frame: RectDict
49
+ image: Image.Image
50
+ name: str
51
+ margin: RectDict
52
+ remove: bool
53
+ resource_path: str
54
+ trimmed: bool
55
+
56
+ class AnimationDict(TypedDict, total=True):
57
+ framerate: int
58
+ loop: bool
59
+ name: str
60
+ sprites: List[SpriteDict]
61
+
62
+ class SpriteFrameDict(TypedDict, total=True):
63
+ animations: List[AnimationDict]
64
+ name: str
65
+
66
+ class FrameEntryDict(TypedDict, total=True):
67
+ frame: RectDict
68
+ rotated: bool
69
+ sourceSize: SizeDict
70
+ spriteSourceSize: RectDict
71
+ trimmed: bool
72
+
73
+ class AtlasAnimationDict(TypedDict, total=True):
74
+ loop: bool
75
+ framerate: int
76
+
77
+ class MetaDict(TypedDict, total=True):
78
+ animation_info: Dict[str, AtlasAnimationDict]
79
+ app: str
80
+ format: str
81
+ image: str
82
+ scale: int
83
+ size: SizeDict
84
+ version: str
85
+
86
+ class AtlasDict(TypedDict, total=True):
87
+ animations: Dict[str, List[str]]
88
+ frames: Dict[str, FrameEntryDict]
89
+ meta: MetaDict
90
+
91
+ RectTuple = Tuple[int, int, int, int, SpriteDict]
92
+
93
+ # --------------------------------------------------------------------------------------------------
94
+ # Command-line argument parsing
95
+ # --------------------------------------------------------------------------------------------------
96
+
97
+ def main() -> None:
98
+ parser: argparse.ArgumentParser = argparse.ArgumentParser(
99
+ description=
100
+ 'Godot Universal SpritePacker — split, pack, and convert spritesheets' +
101
+ ' or SVGs into optimized atlases and SpriteFrames for Godot or other engines.'
102
+ )
103
+ parser.add_argument('--source_directory', required=True,
104
+ help='Directory containing source images, SVGs or tilesets to be split and packed.')
105
+ parser.add_argument('--spritesheet_path', required=True,
106
+ help='Path (without extension) where the final packed spritesheet will be saved.')
107
+ parser.add_argument('--save_json', action='store_true',
108
+ help='Whether to create metadata .json files together with the spritesheet.')
109
+ parser.add_argument('--image_directory',
110
+ help='Optional directory in which to export individual sprite images before packing.')
111
+ parser.add_argument('--godot_sprites_directory',
112
+ help='If set, outputs Godot 4 AtlasTextures and SpriteFrames to this directory.')
113
+ parser.add_argument('--godot_resource_directory', default='res://textures/',
114
+ help='Godot resource directory containing spritesheet images. Default is "res://textures/"')
115
+ parser.add_argument('--inkscape_path', default='C:/Program Files/Inkscape/bin/inkscape',
116
+ help='Path to the Inkscape executable. Used for extracting layers from SVG files.')
117
+ parser.add_argument('--max_spritesheet_size', type=int, default=4096,
118
+ help='Maximum width or height (in pixels) for the generated spritesheet. Default is 4096.')
119
+ parser.add_argument('--sprite_padding', type=int, default=1,
120
+ help='Number of transparent pixels to pad around each sprite. Default is 1 = 2 px gap.')
121
+ parser.add_argument('--disable_trimming', action='store_true',
122
+ help='If set, disables transparency trimming.')
123
+ parser.add_argument('--disable_duplicate_removal', action='store_true',
124
+ help='If set, disables duplicate frame detection and removal.')
125
+ parser.add_argument('--default_framerate', type=int,
126
+ help='If set, treats all regular sprites as animations with this framerate.')
127
+
128
+ args: argparse.Namespace = parser.parse_args()
129
+
130
+ print('Godot Universal SpritePacker %s\n' % __version__)
131
+
132
+ # ----------------------------------------------------------------------------------------------
133
+ # Prepare output directory
134
+ # ----------------------------------------------------------------------------------------------
135
+
136
+ spritesheet_dir: str = os.path.dirname(args.spritesheet_path)
137
+ if spritesheet_dir != '':
138
+ os.makedirs(spritesheet_dir, exist_ok=True)
139
+
140
+ # ----------------------------------------------------------------------------------------------
141
+ # Create all individual sprites
142
+ # ----------------------------------------------------------------------------------------------
143
+
144
+ sprites: List[SpriteDict] = []
145
+ sprite_frames: List[SpriteFrameDict] = []
146
+
147
+ for root, dirs, filenames in os.walk(args.source_directory):
148
+ for filename in filenames:
149
+ source_path: str = os.path.join(root, filename)
150
+ source_directory: str = os.path.dirname(source_path)
151
+ rel_path: str = os.path.relpath(source_path, args.source_directory)
152
+ prefix: str
153
+ extension: str
154
+ prefix, extension = os.path.splitext(rel_path)
155
+
156
+ name: str = prefix.replace('\\', '/')
157
+ if name.startswith('./'):
158
+ name = name[2:]
159
+
160
+ # SVG: split into layers via Inkscape
161
+ if extension.lower() == '.svg':
162
+ print('Splitting vector file "%s"' % source_path)
163
+
164
+ tree: ET.ElementTree = ET.parse(source_path)
165
+ layers: List[ET.Element] = tree.findall("./{http://www.w3.org/2000/svg}" +
166
+ "g[@{http://www.inkscape.org/namespaces/inkscape}groupmode='layer']")
167
+
168
+ for layer in layers:
169
+ layer_id: str = layer.attrib['id']
170
+ label: str = layer.attrib['{http://www.inkscape.org/namespaces/inkscape}label']
171
+
172
+ print('-> Exporting layer "%s"' % label)
173
+
174
+ image_path: str = os.path.join(tempfile.gettempdir(),
175
+ 'gus_%s.png' % os.urandom(12).hex())
176
+
177
+ # call Inkscape to export one layer as PNG
178
+ try:
179
+ result: subprocess.CompletedProcess = subprocess.run([
180
+ args.inkscape_path,
181
+ source_path,
182
+ '--export-area-drawing',
183
+ '--export-type=png',
184
+ '--export-id-only',
185
+ '--export-id=%s' % layer_id,
186
+ '--export-filename=%s' % image_path,
187
+ ])
188
+ except FileNotFoundError:
189
+ print('Unable to find Inkscape. Skipping vector conversion.')
190
+ continue
191
+
192
+ if result.returncode != 0:
193
+ sys.exit('Error exporting SVG layer "%s" from "%s"' % (label, source_path))
194
+
195
+ # Wait for output file to appear
196
+ for attempt in range(10):
197
+ if os.path.exists(image_path):
198
+ sprites.append(cast(SpriteDict, {
199
+ 'animated': False,
200
+ 'duplicate': None,
201
+ 'frame': { 'x': 0, 'y': 0, 'w': 0, 'h': 0 },
202
+ 'image': Image.open(image_path).convert('RGBA'),
203
+ 'margin': { 'x': 0, 'y': 0, 'w': 0, 'h': 0 },
204
+ 'name': '%s/%s' % (name, re.sub('[^a-zA-Z0-9_ -]+', '', label)),
205
+ 'remove': False,
206
+ 'resource_path': '',
207
+ 'trimmed': False,
208
+ }))
209
+
210
+ break
211
+
212
+ if attempt == 9:
213
+ sys.exit('Error exporting SVG layer "%s" from "%s"'
214
+ % (label, source_path))
215
+ else:
216
+ time.sleep(1)
217
+
218
+ # Clean up temp file
219
+ while os.path.exists(image_path):
220
+ try:
221
+ os.remove(image_path)
222
+ except OSError:
223
+ time.sleep(1)
224
+
225
+ print('Failed to delete temporary file')
226
+
227
+ continue
228
+
229
+ # CSV files are always handled through images
230
+ if extension.lower() == '.csv':
231
+ continue
232
+
233
+ if not extension.lower() in ['.png', '.bmp', '.jpg', '.jpeg']:
234
+ print('Ignoring file "%s"' % source_path)
235
+
236
+ continue
237
+
238
+ # Static image vs tileset detection
239
+ match: Optional[re.Match[str]] = re.search(
240
+ r'^(.*?)__(\d+)x(\d+)(?:p(\d+))?(?:fps(\d+))?(loop)?$', name)
241
+
242
+ if not match:
243
+ # Single image sprite
244
+
245
+ print('Using single image "%s"' % source_path)
246
+
247
+ sprites.append(cast(SpriteDict, {
248
+ 'animated': False,
249
+ 'duplicate': None,
250
+ 'frame': { 'x': 0, 'y': 0, 'w': 0, 'h': 0 },
251
+ 'image': Image.open(source_path).convert('RGBA'),
252
+ 'margin': { 'x': 0, 'y': 0, 'w': 0, 'h': 0 },
253
+ 'name': name,
254
+ 'remove': False,
255
+ 'resource_path': '',
256
+ 'trimmed': False,
257
+ }))
258
+
259
+ continue
260
+
261
+ # Splitting image into a grid of sprites
262
+
263
+ print('Splitting tileset "%s"' % source_path)
264
+
265
+ groups: Tuple[Optional[str], ...] = match.groups()
266
+
267
+ image_name: str = groups[0] # type: ignore[assignment]
268
+
269
+ im: Image.Image = Image.open(source_path).convert('RGBA')
270
+
271
+ full_width, full_height = im.size
272
+
273
+ tile_width: int = int(groups[1]) # type: ignore[arg-type]
274
+ tile_height: int = int(groups[2]) # type: ignore[arg-type]
275
+
276
+ tile_padding: int = 0 if groups[3] is None else int(groups[3])
277
+
278
+ start_x: List[int] = list(range(0, full_width, tile_width + tile_padding))
279
+ start_y: List[int] = list(range(0, full_height, tile_height + tile_padding))
280
+
281
+ tileset_sprites: List[SpriteDict] = []
282
+ tileset_grid: List[list[SpriteDict]] = [[] for _ in start_x]
283
+
284
+ # Crop out each sprite
285
+ for y_i, y in enumerate(start_y):
286
+ y_s: str = str(y_i).zfill(len(str(len(start_y) - 1)))
287
+
288
+ for x_i, x in enumerate(start_x):
289
+ x_s: str = str(x_i).zfill(len(str(len(start_x) - 1)))
290
+
291
+ sprite: SpriteDict = {
292
+ 'animated': False,
293
+ 'duplicate': None,
294
+ 'frame': { 'x': 0, 'y': 0, 'w': 0, 'h': 0 },
295
+ 'image': im.crop((x, y, x + tile_width, y + tile_height)),
296
+ 'margin': { 'x': 0, 'y': 0, 'w': 0, 'h': 0 },
297
+ 'name': '%s__%sx%s' % (image_name, y_s, x_s),
298
+ 'remove': False,
299
+ 'resource_path': '',
300
+ 'trimmed': False,
301
+ }
302
+
303
+ sprites.append(sprite)
304
+ tileset_sprites.append(sprite)
305
+ tileset_grid[x_i].append(sprite)
306
+
307
+ sprite_frame: SpriteFrameDict = {
308
+ 'animations': [],
309
+ 'name': image_name,
310
+ }
311
+
312
+ csv_path: str = os.path.join(source_directory, '%s.csv' % os.path.basename(image_name))
313
+
314
+ if os.path.exists(csv_path):
315
+ # Collect animation definitions if .csv present
316
+
317
+ print('Reading animations from "%s"' % csv_path)
318
+
319
+ for sprite in tileset_sprites:
320
+ sprite['remove'] = True
321
+
322
+ with open(csv_path) as f:
323
+ lines: List[List[str]] = list(csv.reader(f, delimiter=';'))[1:]
324
+
325
+ for line in lines:
326
+ line = [cell.strip() for cell in line]
327
+
328
+ animation_sprites: List[SpriteDict] = []
329
+
330
+ x0: int = int(line[1])
331
+ y0: int = int(line[2])
332
+ cx: int = int(line[3])
333
+ cy: int = int(line[4])
334
+
335
+ for y_i in range(y0, y0 + cy):
336
+ for x_i in range(x0, x0 + cx):
337
+ sprite = tileset_grid[x_i][y_i]
338
+ sprite['remove'] = False
339
+
340
+ animation_sprites.append(sprite)
341
+
342
+ sprite_frame['animations'].append(cast(AnimationDict, {
343
+ 'framerate': int(line[5]),
344
+ 'loop': line[6].lower().strip() != 'false',
345
+ 'name': '%s:%s' % (image_name, line[0]),
346
+ 'sprites': animation_sprites,
347
+ }))
348
+
349
+ print('-> Found animation "%s"' % line[0])
350
+
351
+ for sprite in animation_sprites:
352
+ sprite['animated'] = True
353
+ elif not groups[4] is None or not args.default_framerate is None:
354
+ # Default animation from filename
355
+
356
+ sprite_frame['animations'].append(cast(AnimationDict, {
357
+ 'framerate': args.default_framerate if groups[4] is None else int(groups[4]),
358
+ 'loop': groups[5] is not None,
359
+ 'name': '%s:default' % image_name,
360
+ 'sprites': tileset_sprites,
361
+ }))
362
+
363
+ for sprite in tileset_sprites:
364
+ sprite['animated'] = True
365
+
366
+ else:
367
+ # Not an animation, treat as multiple sprites
368
+
369
+ continue
370
+
371
+ sprite_frames.append(sprite_frame)
372
+
373
+ # ----------------------------------------------------------------------------------------------
374
+ # Filter out any sprites marked for removal (due to .csv animations)
375
+ # ----------------------------------------------------------------------------------------------
376
+
377
+ sprites = list(filter(lambda sprite:
378
+ not 'remove' in sprite or not sprite['remove'], sprites))
379
+
380
+ if len(sprites) == 0:
381
+ sys.exit('\nNo sprites found')
382
+
383
+ # ----------------------------------------------------------------------------------------------
384
+ # Export each sprite as its own image
385
+ # ----------------------------------------------------------------------------------------------
386
+
387
+ if not args.image_directory is None:
388
+ print('\nSaving sprite images in "%s"' % args.image_directory)
389
+
390
+ for sprite in sprites:
391
+ image_path = os.path.join(args.image_directory, '%s.png' % sprite['name'])
392
+ image_directory: str = os.path.dirname(image_path)
393
+
394
+ os.makedirs(image_directory, exist_ok=True)
395
+
396
+ sprite['image'].save(image_path)
397
+
398
+ # ----------------------------------------------------------------------------------------------
399
+ # Trimming
400
+ # ----------------------------------------------------------------------------------------------
401
+
402
+ if not args.disable_trimming:
403
+ print('\nTrimming sprites...')
404
+
405
+ trimmed_count: int = 0
406
+ trimmed_pixels: int = 0
407
+
408
+ for sprite in sprites:
409
+ w, h = sprite['image'].size
410
+
411
+ bbox: Optional[Tuple[int, int, int, int]] = sprite['image'].getbbox()
412
+ if bbox is None:
413
+ bbox = (0, 0, 1, 1)
414
+
415
+ if bbox != (0, 0, w, h):
416
+ sprite['trimmed'] = True
417
+ sprite['image'] = sprite['image'].crop(bbox)
418
+ sprite['margin'] = {
419
+ 'x': bbox[0],
420
+ 'y': bbox[1],
421
+ 'w': w - (bbox[2] - bbox[0]),
422
+ 'h': h - (bbox[3] - bbox[1]),
423
+ }
424
+ trimmed_count += 1
425
+ trimmed_pixels += (w * h) - (sprite['image'].size[0] * sprite['image'].size[1])
426
+
427
+ print('Trimmed %i sprites for %i pixels' % (trimmed_count, trimmed_pixels))
428
+
429
+ # ----------------------------------------------------------------------------------------------
430
+ # Duplicate sprite marking
431
+ # ----------------------------------------------------------------------------------------------
432
+
433
+ if not args.disable_duplicate_removal:
434
+ print('\nChecking for duplicate sprites...')
435
+
436
+ duplicates: int = 0
437
+
438
+ for i, sprite in enumerate(sprites):
439
+ if sprite['duplicate'] is not None:
440
+ continue
441
+
442
+ data: List[Tuple[int, int, int, int]] = list(sprite['image'].getdata())
443
+
444
+ for other in sprites[i + 1:]:
445
+ if other['duplicate'] is not None:
446
+ continue
447
+
448
+ other_data = list(other['image'].getdata())
449
+ if len(data) == len(other_data) and data == other_data:
450
+ other['duplicate'] = sprite
451
+ duplicates += 1
452
+
453
+ print('Found %i duplicate sprites' % duplicates)
454
+
455
+ # ----------------------------------------------------------------------------------------------
456
+ # Pack all sprites into one or more atlases via rectpack
457
+ # ----------------------------------------------------------------------------------------------
458
+
459
+ print('\nPacking %i sprites...' % len(sprites))
460
+
461
+ bin_size: int = 32
462
+ bin_count: int = 1
463
+ max_side: int = args.max_spritesheet_size
464
+ padding: int = args.sprite_padding
465
+
466
+ while True:
467
+ packer: PackerBFF = newPacker(rotation=False)
468
+ for _ in range(bin_count):
469
+ packer.add_bin(bin_size, bin_size)
470
+
471
+ non_duplicate_count: int = 0
472
+
473
+ for sprite in sprites:
474
+ if not sprite['duplicate'] is None:
475
+ continue
476
+ non_duplicate_count += 1
477
+
478
+ w, h = sprite['image'].size
479
+
480
+ if w + padding * 2 > max_side or h + padding * 2 > max_side:
481
+ sys.exit('Sprite "%s" is too large' % sprite["name"])
482
+
483
+ packer.add_rect(w + padding * 2, h + padding * 2, sprite)
484
+
485
+ packer.pack()
486
+
487
+ if len(packer) > 0 and len(packer.rect_list()) == non_duplicate_count:
488
+ break
489
+
490
+ if bin_size * 2 <= max_side:
491
+ bin_size *= 2
492
+ else:
493
+ bin_count += 1
494
+
495
+ print('Packed %i non-duplicate sprites (out of %i total) into %i spritesheets of size %ix%i\n'
496
+ % (non_duplicate_count, len(sprites), bin_count, bin_size, bin_size))
497
+
498
+ # ----------------------------------------------------------------------------------------------
499
+ # Write out each packed atlas: PNG + JSON + Godot .tres files
500
+ # ----------------------------------------------------------------------------------------------
501
+
502
+ for b_i in range(bin_count):
503
+ path_prefix: str = '%s%s' % (args.spritesheet_path, '' if bin_count == 1 else '_%i' % b_i)
504
+ png_path: str = '%s.png' % path_prefix
505
+
506
+ # Create an animation info and animations dictionary
507
+ animation_info: Dict[str, AtlasAnimationDict] = {}
508
+ animations: Dict[str, List[str]] = {}
509
+
510
+ for sprite_frame in sprite_frames:
511
+ for animation in sprite_frame['animations']:
512
+ animations[animation['name']] = [sprite['name'] for sprite in animation['sprites']]
513
+ animation_info[animation['name']] = {
514
+ 'loop': animation['loop'],
515
+ 'framerate': animation['framerate']
516
+ }
517
+
518
+ # Create blank atlas image
519
+ atlas_image: Image.Image = Image.new('RGBA', (bin_size, bin_size), (0, 0, 0, 0))
520
+ atlas_data: AtlasDict = {
521
+ 'animations': animations,
522
+ 'frames': {},
523
+ 'meta': {
524
+ 'animation_info': animation_info,
525
+ 'app': 'Godot Universal SpritePacker',
526
+ 'format': 'RGBA8888',
527
+ 'image': os.path.basename('%s.png' % path_prefix),
528
+ 'scale': 1,
529
+ 'size': { 'w': bin_size, 'h': bin_size },
530
+ 'version': __version__,
531
+ },
532
+ }
533
+
534
+ bin_sprites: List[SpriteDict] = []
535
+
536
+ rect: RectTuple
537
+ for rect in packer[b_i].rect_list():
538
+ x, y, w, h, sprite = rect
539
+
540
+ bin_sprites.append(sprite)
541
+
542
+ sw, sh = sprite['image'].size
543
+
544
+ sprite['resource_path'] = \
545
+ args.godot_resource_directory.strip('/') + '/' + os.path.basename(png_path)
546
+ sprite['frame'] = { 'x': x + padding, 'y': y + padding, 'w': sw, 'h': sh }
547
+
548
+ atlas_image.paste(sprite['image'], (x + padding, y + padding))
549
+
550
+ # Build JSON frame entries
551
+ for sprite in sprites:
552
+ if not sprite in bin_sprites and not sprite['duplicate'] in bin_sprites:
553
+ continue
554
+
555
+ if not sprite['duplicate'] is None:
556
+ sprite['resource_path'] = sprite['duplicate']['resource_path']
557
+ sprite['frame'] = sprite['duplicate']['frame']
558
+
559
+ sw, sh = sprite['image'].size
560
+
561
+ margin: RectDict = sprite['margin']
562
+
563
+ ow: int = sw + margin['w']
564
+ oh: int = sh + margin['h']
565
+
566
+ atlas_data['frames'][sprite['name']] = {
567
+ 'frame': sprite['frame'],
568
+ 'rotated': False,
569
+ 'sourceSize': { 'w': ow, 'h': oh },
570
+ 'spriteSourceSize': { 'x': margin['x'], 'y': margin['y'], 'w': sw, 'h': sh },
571
+ 'trimmed': sprite['trimmed'],
572
+ }
573
+
574
+ # Save a standalone AtlasTexture for Godot
575
+ if not args.godot_sprites_directory is None and not sprite['animated']:
576
+ tres_path: str = os.path.join(
577
+ args.godot_sprites_directory,
578
+ '%s.tres' % sprite['name']
579
+ )
580
+ tres_directory: str = os.path.dirname(tres_path)
581
+
582
+ os.makedirs(tres_directory, exist_ok=True)
583
+
584
+ resource_path: str = sprite['resource_path']
585
+ frame: RectDict = sprite['frame']
586
+
587
+ with open(tres_path, 'w') as f:
588
+ f.write('''[gd_resource type="AtlasTexture" format=2]
589
+
590
+ [ext_resource path="%s" type="Texture" id=1]
591
+
592
+ [resource]
593
+ atlas = ExtResource(1)
594
+ region = Rect2(%i, %i, %i, %i)
595
+ margin = Rect2(%i, %i, %i, %i)''' % (
596
+ resource_path,
597
+ frame['x'], frame['y'], frame['w'], frame['h'],
598
+ margin['x'], margin['y'], margin['w'], margin['h']
599
+ ))
600
+
601
+ atlas_image.save(png_path)
602
+
603
+ if args.save_json:
604
+ with open('%s.json' % path_prefix, 'w', encoding='utf-8') as f:
605
+ json.dump(atlas_data, f, indent=4, sort_keys=True)
606
+
607
+ print('Spritesheet %i created at "%s.png"' % (b_i, path_prefix))
608
+
609
+ # ----------------------------------------------------------------------------------------------
610
+ # Save Godot SpriteFrames resources
611
+ # ----------------------------------------------------------------------------------------------
612
+
613
+ if not args.godot_sprites_directory is None:
614
+ print('\nCreating Godot sprite frames in "%s"' % args.godot_sprites_directory)
615
+
616
+ for sprite_frame in sprite_frames:
617
+ tres_path = os.path.join(args.godot_sprites_directory, f'{sprite_frame["name"]}.tres')
618
+ tres_directory = os.path.dirname(tres_path)
619
+
620
+ os.makedirs(tres_directory, exist_ok=True)
621
+
622
+ sprite_frames_string: str = '[gd_resource type="SpriteFrames" format=3]\n\n'
623
+
624
+ resource_paths: List[str] = []
625
+ for animation in sprite_frame['animations']:
626
+ for sprite in animation['sprites']:
627
+ if not sprite['resource_path'] in resource_paths:
628
+ resource_paths.append(sprite['resource_path'])
629
+ sprite_frames_string += '[ext_resource path="%s" type="Texture" id=%i]\n' %\
630
+ (sprite['resource_path'], len(resource_paths))
631
+
632
+ sprite_frames_string += '\n'
633
+
634
+ sub_id: int = 1
635
+
636
+ animation_strings: List[str] = []
637
+
638
+ for animation in sprite_frame['animations']:
639
+ frame_strings: List[str] = []
640
+
641
+ for sprite in animation['sprites']:
642
+ frame = sprite['frame']
643
+ margin = sprite['margin']
644
+
645
+ resource_id = resource_paths.index(sprite['resource_path']) + 1
646
+
647
+ sprite_frames_string += '''[sub_resource type="AtlasTexture" id=%i]
648
+ atlas = ExtResource(%i)
649
+ region = Rect2(%i, %i, %i, %i)
650
+ margin = Rect2(%i, %i, %i, %i)
651
+
652
+ ''' % (sub_id, resource_id, frame['x'], frame['y'], frame['w'], frame['h'],
653
+ margin['x'], margin['y'], margin['w'], margin['h'])
654
+
655
+ frame_strings.append('{"duration": 1.0, "texture": SubResource(%i)}' % sub_id)
656
+
657
+ sub_id += 1
658
+
659
+ animation_strings.append('''{
660
+ "frames": [
661
+ %s
662
+ ],
663
+ "loop": %s,
664
+ "name": &"%s",
665
+ "speed": %.1f
666
+ }''' % (',\n '.join(frame_strings),
667
+ str(animation['loop']).lower(), animation['name'], animation['framerate']))
668
+
669
+ sprite_frames_string += '''[resource]
670
+ animations = [%s]
671
+ ''' % ', '.join(animation_strings)
672
+
673
+ with open(tres_path, 'w') as f:
674
+ f.write(sprite_frames_string)
675
+
676
+ print('\nCompleted\n')
677
+
678
+ if __name__ == '__main__':
679
+ main()
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: godot-universal-spritepacker
3
+ Version: 1.1.1
4
+ Summary: A smart, folder-aware sprite packer for Godot and PixiJS. Split, pack, and convert multi-res tilesets and SVG layers into spritesheets and native Godot sprites.
5
+ Author: Donitz
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Donitzo/godot-universal-spritepacker
8
+ Classifier: Programming Language :: Python :: 3.9
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Multimedia :: Graphics
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: Pillow
17
+ Requires-Dist: rectpack
18
+ Dynamic: license-file
19
+
20
+ ![Logo](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/logo.png)
21
+
22
+ ## Table of Contents
23
+
24
+ * [Description](#description)
25
+ * [Why?](#why)
26
+ * [How it works](#how-it-works)
27
+ * [File naming convention](#file-naming-convention)
28
+ * [Animation metadata (CSV)](#animation-metadata-csv)
29
+ * [Requirements](#requirements)
30
+ * [Installation](#installation)
31
+ * [Demo](#demo)
32
+ * [Usage](#usage)
33
+ * [Issues](#issues)
34
+
35
+ ---
36
+
37
+ ## Description
38
+
39
+ **Godot Universal SpritePacker** is a Python tool that converts individual images, tilesets, and SVG layers into optimized spritesheets and ready-to-use Godot `.tres` resources:
40
+ * [AtlasTextures](https://docs.godotengine.org/en/stable/classes/class_atlastexture.html) for single sprites
41
+ * [SpriteFrames](https://docs.godotengine.org/en/stable/classes/class_spriteframes.html) for animations
42
+
43
+ It intelligently splits, trims and packs images or vector layers into one or more texture atlases, preserving your folder structure and optionally generating [PixiJS-compatible](https://github.com/pixijs/pixijs/blob/main/packages/spritesheet/src/Spritesheet.ts) `.json` files or native Godot resources. Duplicate sprites are automatically merged to save space, unless disabled. [View PixiJS example spritesheet](sample/output/spritesheet.json)
44
+
45
+ ![Example](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/example.png)
46
+
47
+ ### Why?
48
+
49
+ I've used [TexturePacker](https://www.codeandweb.com/texturepacker) for years and still think it’s a great option if you need the full range of features it provides. But over time, I realized I didn’t actually need most of them. I had already written custom importers for its metadata formats in my own [retro JavaScript games](https://donitz.itch.io/sweet-hell), and the actual task of packing sprites turned out to be straightforward thanks to the excellent [rectpack](https://github.com/secnot/rectpack) module.
50
+
51
+ This led me to build the first version of this tool (originally called `smart_splitter`), focused on a workflow where sprites and tilesets are kept as individual files and automatically packed into spritesheets based on their filename and folder structure. That setup made iteration super convenient. Later, as I started using Godot more, I extended the tool to export native `.tres` resources as well.
52
+
53
+ I also occasionally needed to export UI icons directly from vector files, making it easy to go from vector artwork to in-game assets. For example, I used a single SVG with this packer when creating the icons for [Canvas Draw 2D](https://donitz.itch.io/canvas-draw-2d), turning updates into a single-click task.
54
+
55
+ I hope this tool helps simplify your 2D asset workflow too!
56
+
57
+ ---
58
+
59
+ ## How it works
60
+
61
+ The tool follows these steps:
62
+
63
+ 1. Scans the specified `source_directory` (and subdirectories) for images files (`'.png'`, `'.bmp'`, `'.jpg'`, `'.jpeg'`) and vector files (`.svg`).
64
+ 2. Parses filenames to detect optional suffixes indicating tile size, frame rate, padding, etc.
65
+ 3. Splits images into individual sprites based on the specified tile size.
66
+ - If the file is an SVG, each **layer** is exported as a separate sprite using [Inkscape](https://inkscape.org/).
67
+ 4. Optionally checks for the existence of a `.csv` file with multiple animation definitions for a single image.
68
+ 5. Optionally saves individual sprite images to the `image_directory`.
69
+ 6. Optionally trims transparent spaces from the sprites.
70
+ 7. Optionally merges duplicate sprites.
71
+ 8. Packs sprites into one or more optimized spritesheets (texture atlases).
72
+ 9. Generates metadata files:
73
+ - (a) [PixiJS-compatible](https://github.com/pixijs/pixijs/blob/main/packages/spritesheet/src/Spritesheet.ts) `.json`
74
+ - (b) Godot 4 AtlasTexture `.tres` files
75
+ - (c) Godot 4 SpriteFrames `.tres` files (for animations)
76
+
77
+ ---
78
+
79
+ ## File naming convention
80
+
81
+ Files should be named according to this pattern:
82
+
83
+ ```
84
+ boss_turret__WxH[pN][fpsN][loop]
85
+ ```
86
+
87
+ ### Examples:
88
+
89
+ - `background.png` - Treats the entire image as one sprite.
90
+ - `hero__32x32.png` - Splits the image into multiple 32x32 sprites.
91
+ - `Small Spider__64x64p2fps12loop.png` - 64×64 sprite with 2px padding, 12 FPS animation, looping
92
+
93
+ | Field | Description |
94
+ |:--------|:------------------------------------------------------------|
95
+ | `name` | Sprite or animation name. |
96
+ | `W x H` | Tile width × height in pixels. |
97
+ | `pN` | (optional) Padding between tiles (e.g., `p2` = 2px). |
98
+ | `fpsN` | (optional) Frame rate in frames per second (e.g., `fps12`). |
99
+ | `loop` | (optional) If present, marks the animation as looping. |
100
+
101
+ If the source file is located inside a subfolder, the relative path is included in the sprite's name (e.g., `enemies/boss__64x64.png`).
102
+
103
+ Sprites are named using the format `subfolder_a/subfolder_b/original_name__RxC`, where `R` is the row index and `C` is the column index. Rows and columns are intentionally inverted from standard grid notation so that alphabetical sorting orders sprites left to right.
104
+
105
+ > **Note:** SVG files are always exported as individual sprites based on layer and the pixel resolution defined in the file. The filename is ignored.
106
+
107
+ ---
108
+
109
+ ## Animation metadata (CSV)
110
+
111
+ You can define multiple animations using a `.csv` file with the same base name as the image. When present, the CSV defines all frames for the corresponding `SpriteFrames` resource, and any unused sprites from the original image are discarded.
112
+
113
+ Animation data is also saved in the `.json` spritesheet file as both `animations` (recognised by `PixiJS`) and in the `meta` field. The animation name is `image_name:animation_name`.
114
+
115
+ ### Example: `hero.csv`
116
+ ```
117
+ name; start_x; start_y; count_x; count_y; fps; loop
118
+ Walk; 0; 0; 4; 1; 12; true
119
+ Jump; 0; 1; 4; 1; 12; false
120
+ ```
121
+
122
+ | Field | Description |
123
+ |:----------|:----------------------------------------------------|
124
+ | `name` | Animation name (as seen in Godot). |
125
+ | `start_x` | Starting tile column. |
126
+ | `start_y` | Starting tile row. |
127
+ | `count_x` | Number of columns in the animation. |
128
+ | `count_y` | Number of rows in the animation. |
129
+ | `fps` | Playback speed (frames per second). |
130
+ | `loop` | Whether the animation should loop (`true`/`false`). |
131
+
132
+ ---
133
+
134
+ ## Requirements
135
+
136
+ - Python 3.9+
137
+ - [Inkscape](https://inkscape.org/) (optional, for SVG layer processing)
138
+ If Inkscape is not installed at the default path, set it manually using `--inkscape_path`.
139
+ - Requires the Pillow and [rectpack](https://github.com/secnot/rectpack) modules.
140
+
141
+ ## Installation
142
+
143
+ You can install the tool directly from GitHub. This installs a command-line tool called `godot_universal_spritepacker`:
144
+
145
+ ```bash
146
+ pip install git+https://github.com/Donitzo/godot-universal-spritepacker.git
147
+ ```
148
+
149
+ Alternatively, you can simply download the script and run it directly:
150
+
151
+ ```bash
152
+ python src/godot_universal_spritepacker/godot_universal_spritepacker.py
153
+ ```
154
+
155
+ ## Demo
156
+
157
+ There is a set of sprites in the `sample` directory. Try converting them using one of the two `.bat` files to get an idea how the tool works. One of the files generates multiple spritesheets.
158
+
159
+ Inside the pre-made `./sample/output` directory, there is a simple `PixiJS` demo page for rendering the contents of the pre-compiled spritesheet. To test the PixiJS demo locally, serve it using a simple HTTP server.
160
+
161
+ ![PixiJS Demo](https://raw.githubusercontent.com/Donitzo/godot-universal-spritepacker/main/pixijs_demo.png)
162
+
163
+ ## Usage
164
+
165
+ Run the tool with:
166
+
167
+ ```bash
168
+ godot_universal_spritepacker --source_directory <source_dir> --spritesheet_path <output_path> [options]
169
+ ```
170
+
171
+ ### Required Arguments
172
+ | Argument | Description |
173
+ |:---------------------|:--------------------------------------------------------|
174
+ | `--source_directory` | Directory containing source images. |
175
+ | `--spritesheet_path` | Output path (without extension) for the spritesheet(s). |
176
+
177
+ ### Optional Arguments
178
+ | Argument | Description |
179
+ |:-----------------------------|:---------------------------------------------------------------------------------|
180
+ | `--save_json` | If set, saves metadata `.json` files with sprite frames. |
181
+ | `--image_directory` | Directory to save individual sprite images before packing. |
182
+ | `--godot_sprites_directory` | Directory to output Godot `.tres` resource files. |
183
+ | `--godot_resource_directory` | Internal Godot resource directory for spritesheets (default: `res://textures/`). |
184
+ | `--inkscape_path` | Custom path to the Inkscape executable for SVG processing. |
185
+ | `--max_spritesheet_size` | Maximum width/height for each spritesheet (default: `4096`). |
186
+ | `--sprite_padding` | Transparent pixels around each sprite. Default is `1` = 2 pixel total gap. |
187
+ | `--disable_trimming` | If set, disables sprite transparency trimming. |
188
+ | `--disable_duplicate_removal`| If set, disables automatic merging of visually identical sprites. |
189
+ | `--default_framerate` | If set, treats all regular sprites as animations with this framerate. |
190
+
191
+ ---
192
+
193
+ ## Issues
194
+
195
+ Please report any bugs, feature requests, or questions in the [Issues section](https://github.com/Donitzo/godot-universal-spritepacker/issues).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/godot_universal_spritepacker/__init__.py
5
+ src/godot_universal_spritepacker/godot_universal_spritepacker.py
6
+ src/godot_universal_spritepacker.egg-info/PKG-INFO
7
+ src/godot_universal_spritepacker.egg-info/SOURCES.txt
8
+ src/godot_universal_spritepacker.egg-info/dependency_links.txt
9
+ src/godot_universal_spritepacker.egg-info/entry_points.txt
10
+ src/godot_universal_spritepacker.egg-info/requires.txt
11
+ src/godot_universal_spritepacker.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ godot_universal_spritepacker = godot_universal_spritepacker.godot_universal_spritepacker:main