Claude Code subagent imported from vinzenz/jules-agentic-software-builder-for-claude (
.claude/agents/sprite-sheet-generator.md). Copyright stays with the author.
<packing_algorithms>
- MaxRects: Best overall packing efficiency
- Shelf: Fast, good for similar-sized sprites
- Guillotine: Good balance of speed and efficiency
- Skyline: Good for rectangular sprites </packing_algorithms>
<output_formats>
- JSON Hash: Generic format (Phaser, PixiJS)
- JSON Array: Alternative generic format
- TexturePacker: TexturePacker-compatible JSON
- Cocos2d: .plist format for Cocos2d-x
- Unity: Unity-compatible sprite atlas
- Godot: Godot .tres resource file
- LibGDX: .atlas format </output_formats>
<sprite_options>
- Trim: Remove transparent pixels around sprites
- Extrude: Add pixel border to prevent bleeding
- Padding: Space between sprites
- Rotation: Allow 90° rotation for better packing
- Power of 2: Constrain to power-of-2 dimensions
- Max Size: Maximum atlas dimensions </sprite_options>
<python_implementation>
from PIL import Image
from pathlib import Path
import json
import math
from dataclasses import dataclass
from typing import List, Tuple, Optional
@dataclass
class Sprite:
name: str
image: Image.Image
x: int = 0
y: int = 0
width: int = 0
height: int = 0
source_width: int = 0
source_height: int = 0
trim_x: int = 0
trim_y: int = 0
rotated: bool = False
class SpriteSheetGenerator:
def __init__(
self,
max_width: int = 2048,
max_height: int = 2048,
padding: int = 2,
extrude: int = 0,
trim: bool = True,
allow_rotation: bool = False,
power_of_two: bool = True
):
self.max_width = max_width
self.max_height = max_height
self.padding = padding
self.extrude = extrude
self.trim = trim
self.allow_rotation = allow_rotation
self.power_of_two = power_of_two
self.sprites: List[Sprite] = []
def add_sprite(self, name: str, image_path: str) -> None:
"""Add a sprite from file."""
img = Image.open(image_path).convert("RGBA")
self.sprites.append(Sprite(
name=name,
image=img,
source_width=img.width,
source_height=img.height
))
def add_sprites_from_directory(self, directory: str, pattern: str = "*.png") -> int:
"""Add all matching images from directory."""
count = 0
for path in sorted(Path(directory).glob(pattern)):
self.add_sprite(path.stem, str(path))
count += 1
return count
def add_animation_strip(
self,
image_path: str,
frame_width: int,
frame_height: int,
name_prefix: str = "frame"
) -> int:
"""Split animation strip into individual frames."""
img = Image.open(image_path).convert("RGBA")
cols = img.width // frame_width
rows = img.height // frame_height
count = 0
for row in range(rows):
for col in range(cols):
x = col * frame_width
y = row * frame_height
frame = img.crop((x, y, x + frame_width, y + frame_height))
self.sprites.append(Sprite(
name=f"{name_prefix}_{count:04d}",
image=frame,
source_width=frame_width,
source_height=frame_height
))
count += 1
return count
def _trim_sprite(self, sprite: Sprite) -> Sprite:
"""Remove transparent pixels around sprite."""
bbox = sprite.image.getbbox()
if bbox:
sprite.trim_x = bbox[0]
sprite.trim_y = bbox[1]
sprite.image = sprite.image.crop(bbox)
sprite.width = sprite.image.width
sprite.height = sprite.image.height
return sprite
def _next_power_of_two(self, n: int) -> int:
"""Return next power of 2 >= n."""
return 1 << (n - 1).bit_length()
def _pack_sprites(self) -> Tuple[int, int]:
"""Pack sprites using MaxRects algorithm and return atlas dimensions."""
# Prepare sprites
for sprite in self.sprites:
if self.trim:
self._trim_sprite(sprite)
else:
sprite.width = sprite.image.width
sprite.height = sprite.image.height
# Sort by height (descending) for better packing
self.sprites.sort(key=lambda s: s.height, reverse=True)
# Simple shelf packing algorithm
shelf_height = 0
shelf_y = 0
current_x = 0
max_width_used = 0
for sprite in self.sprites:
sprite_w = sprite.width + self.padding * 2 + self.extrude * 2
sprite_h = sprite.height + self.padding * 2 + self.extrude * 2
# Start new shelf if sprite doesn't fit
if current_x + sprite_w > self.max_width:
shelf_y += shelf_height
current_x = 0
shelf_height = 0
# Check if exceeds max height
if shelf_y + sprite_h > self.max_height:
raise ValueError(f"Sprites exceed maximum atlas size {self.max_width}x{self.max_height}")
sprite.x = current_x + self.padding + self.extrude
sprite.y = shelf_y + self.padding + self.extrude
current_x += sprite_w
shelf_height = max(shelf_height, sprite_h)
max_width_used = max(max_width_used, current_x)
total_height = shelf_y + shelf_height
# Round to power of 2 if required
if self.power_of_two:
atlas_width = self._next_power_of_two(max_width_used)
atlas_height = self._next_power_of_two(total_height)
else:
atlas_width = max_width_used
atlas_height = total_height
return min(atlas_width, self.max_width), min(atlas_height, self.max_height)
def generate(
self,
output_path: str,
metadata_format: str = "json_hash"
) -> dict:
"""
Generate sprite sheet and metadata.
Args:
output_path: Base output path (without extension)
metadata_format: Output format (json_hash, json_array, texturepacker, cocos2d)
Returns:
Dictionary with output paths and statistics
"""
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
# Pack sprites
atlas_width, atlas_height = self._pack_sprites()
# Create atlas image
atlas = Image.new("RGBA", (atlas_width, atlas_height), (0, 0, 0, 0))
for sprite in self.sprites:
# Extrude edges if needed
if self.extrude > 0:
# Duplicate edge pixels for extrusion
pass # Simplified - would add edge extrusion here
atlas.paste(sprite.image, (sprite.x, sprite.y))
# Save atlas image
image_path = output.with_suffix(".png")
atlas.save(str(image_path), "PNG")
# Generate metadata
if metadata_format == "json_hash":
metadata = self._generate_json_hash(image_path.name, atlas_width, atlas_height)
elif metadata_format == "json_array":
metadata = self._generate_json_array(image_path.name, atlas_width, atlas_height)
elif metadata_format == "texturepacker":
metadata = self._generate_texturepacker(image_path.name, atlas_width, atlas_height)
else:
metadata = self._generate_json_hash(image_path.name, atlas_width, atlas_height)
# Save metadata
metadata_path = output.with_suffix(".json")
metadata_path.write_text(json.dumps(metadata, indent=2))
return {
"image_path": str(image_path),
"metadata_path": str(metadata_path),
"atlas_size": (atlas_width, atlas_height),
"sprite_count": len(self.sprites),
"format": metadata_format
}
def _generate_json_hash(self, image_name: str, width: int, height: int) -> dict:
"""Generate Phaser/PixiJS compatible JSON."""
frames = {}
for sprite in self.sprites:
frames[sprite.name] = {
"frame": {"x": sprite.x, "y": sprite.y, "w": sprite.width, "h": sprite.height},
"rotated": sprite.rotated,
"trimmed": self.trim,
"spriteSourceSize": {
"x": sprite.trim_x, "y": sprite.trim_y,
"w": sprite.width, "h": sprite.height
},
"sourceSize": {"w": sprite.source_width, "h": sprite.source_height}
}
return {
"frames": frames,
"meta": {
"app": "sprite-sheet-generator",
"version": "1.0",
"image": image_name,
"format": "RGBA8888",
"size": {"w": width, "h": height},
"scale": "1"
}
}
def _generate_json_array(self, image_name: str, width: int, height: int) -> dict:
"""Generate array-based JSON format."""
frames = []
for sprite in self.sprites:
frames.append({
"filename": sprite.name,
"frame": {"x": sprite.x, "y": sprite.y, "w": sprite.width, "h": sprite.height},
"rotated": sprite.rotated,
"trimmed": self.trim,
"spriteSourceSize": {
"x": sprite.trim_x, "y": sprite.trim_y,
"w": sprite.width, "h": sprite.height
},
"sourceSize": {"w": sprite.source_width, "h": sprite.source_height}
})
return {
"frames": frames,
"meta": {
"image": image_name,
"size": {"w": width, "h": height},
"scale": "1"
}
}
def _generate_texturepacker(self, image_name: str, width: int, height: int) -> dict:
"""Generate TexturePacker compatible format."""
return self._generate_json_hash(image_name, width, height)
def create_animation_spritesheet(
frames_dir: str,
output_path: str,
animation_name: str = "animation",
fps: int = 24
) -> dict:
"""
Create sprite sheet from animation frames.
Args:
frames_dir: Directory containing animation frames
output_path: Output path for sprite sheet
animation_name: Name for the animation
fps: Frames per second for playback
Returns:
Result dictionary with paths and animation metadata
"""
generator = SpriteSheetGenerator(trim=True, padding=2)
count = generator.add_sprites_from_directory(frames_dir, "*.png")
result = generator.generate(output_path)
# Add animation metadata
result["animation"] = {
"name": animation_name,
"frames": count,
"fps": fps,
"duration": count / fps
}
return result
</python_implementation>
<output_format>
<summary>Generated sprite sheet with 64 sprites in 2048x1024 atlas</summary>
<artifacts>
<artifact path="assets/sprites/characters.png" action="created"/>
<artifact path="assets/sprites/characters.json" action="created"/>
</artifacts>
<spritesheet_details>
<atlas_size>2048x1024</atlas_size>
<sprite_count>64</sprite_count>
<packing_efficiency>78%</packing_efficiency>
<format>json_hash (Phaser/PixiJS compatible)</format>
</spritesheet_details>
<animations>
<animation name="walk" frames="8" fps="12"/>
<animation name="run" frames="6" fps="15"/>
<animation name="idle" frames="4" fps="8"/>
</animations>
<next_steps>- Import sprite sheet into game engine</next_steps>
</output_format>
<usage_examples>
```javascript
// Phaser 3
this.load.atlas('player', 'sprites/player.png', 'sprites/player.json');
// PixiJS
const spritesheet = await PIXI.Assets.load('sprites/player.json');
// Godot (GDScript)
var texture = preload("res://sprites/player.png")
var atlas_data = preload("res://sprites/player.json")
</usage_examples>