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.
- godot_universal_spritepacker-1.1.1/LICENSE +21 -0
- godot_universal_spritepacker-1.1.1/PKG-INFO +195 -0
- godot_universal_spritepacker-1.1.1/README.md +176 -0
- godot_universal_spritepacker-1.1.1/pyproject.toml +35 -0
- godot_universal_spritepacker-1.1.1/setup.cfg +4 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker/__init__.py +0 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker/godot_universal_spritepacker.py +679 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/PKG-INFO +195 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/SOURCES.txt +11 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/dependency_links.txt +1 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/entry_points.txt +2 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/requires.txt +2 -0
- godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/top_level.txt +1 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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"
|
|
File without changes
|
godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker/godot_universal_spritepacker.py
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
godot_universal_spritepacker-1.1.1/src/godot_universal_spritepacker.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
godot_universal_spritepacker
|