Files
2026-02-13 21:26:23 +01:00

364 lines
13 KiB
Python

#!/usr/bin/env python3
"""
sprite2c.py - Convert PNG sprites to RGB565 C arrays for TamaTac
Usage:
# Single sprite (24x24 PNG):
python sprite2c.py egg_idle.png --name egg_idle
# Spritesheet (multiple 24x24 frames in a horizontal strip):
python sprite2c.py egg_spritesheet.png --name egg_idle --cols 2
# Custom sprite size:
python sprite2c.py big_sprite.png --name adult_idle --width 32 --height 32
# Custom transparent color (default: uses alpha channel):
python sprite2c.py sprite.png --name test --transparent 255,0,255
# Batch convert all PNGs in a directory:
python sprite2c.py sprites_dir/ --batch
# Generate complete SpriteData.h from a directory of PNGs:
python sprite2c.py sprites_dir/ --spritedata -o SpriteData.h
(See --spritedata section below for naming conventions)
Output:
Generates a .h file with constexpr uint16_t arrays ready for inclusion.
Transparent pixels become 0xF81F (magenta in RGB565).
SpriteData mode (--spritedata):
Expects PNGs named: <sprite_name>.png (single frame) or <sprite_name>.png
with multiple columns for spritesheets.
Sprite names must match SpriteId enum order in Sprites.h:
egg_idle, baby_idle, teen_idle, adult_idle, elder_idle,
ghost, sick, happy, sad, eating, playing, sleeping
Each PNG can be a single frame or a horizontal spritesheet.
The script auto-detects frame count from image width.
Animation config (frameDelayMs, loop) is specified via a config file
or defaults. Place a sprite_config.txt in the sprite directory:
egg_idle,800,true
baby_idle,600,true
adult_idle,400,true
eating,300,false
playing,300,false
...
"""
import argparse
import os
import sys
try:
from PIL import Image
except ImportError:
print("Error: Pillow is required. Install with: pip install Pillow")
sys.exit(1)
TRANSPARENT_RGB565 = 0xF81F # Magenta in RGB565
# SpriteId enum order (must match Sprites.h)
SPRITE_NAMES = [
"egg_idle", "baby_idle", "teen_idle", "adult_idle", "elder_idle",
"ghost", "sick", "happy", "sad", "eating", "playing", "sleeping",
]
# Default animation config: (frameDelayMs, loop)
DEFAULT_ANIM_CONFIG = {
"egg_idle": (800, True),
"baby_idle": (600, True),
"teen_idle": (500, True),
"adult_idle": (400, True),
"elder_idle": (700, True),
"ghost": (500, True),
"sick": (1000, True),
"happy": (400, True),
"sad": (800, True),
"eating": (300, False),
"playing": (300, False),
"sleeping": (1000, True),
}
def rgb_to_rgb565(r, g, b):
"""Convert 8-bit RGB to RGB565."""
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
def convert_sprite(image, frame_width, frame_height, transparent_color=None, cols=None):
"""Convert a PIL Image to a list of RGB565 frame arrays.
Args:
image: PIL Image (RGBA or RGB)
frame_width: Width of each frame
frame_height: Height of each frame
transparent_color: Optional (R,G,B) tuple for color-key transparency
cols: Number of columns to extract (auto-detected if None)
Returns:
List of lists of uint16_t values (one list per frame)
"""
img = image.convert("RGBA")
img_width, img_height = img.size
max_cols = img_width // frame_width
cols = cols if cols is not None else max_cols
rows = img_height // frame_height
if cols == 0 or rows == 0:
print(f"Error: Image {img_width}x{img_height} is smaller than frame size {frame_width}x{frame_height}")
sys.exit(1)
frames = []
for row in range(rows):
for col in range(cols):
frame = []
for y in range(frame_height):
for x in range(frame_width):
px = img.getpixel((col * frame_width + x, row * frame_height + y))
r, g, b, a = px
if transparent_color and (r, g, b) == transparent_color:
frame.append(TRANSPARENT_RGB565)
elif a < 128:
frame.append(TRANSPARENT_RGB565)
else:
val = rgb_to_rgb565(r, g, b)
# Avoid accidental transparency
if val == TRANSPARENT_RGB565:
val = 0xF81E # Slightly different magenta
frame.append(val)
frames.append(frame)
return frames
def format_array(name, frame_idx, data, width):
"""Format a single frame as a C constexpr array."""
lines = []
lines.append(f"constexpr uint16_t sprite_{name}_frame{frame_idx}[{len(data)}] = {{")
for row_start in range(0, len(data), width):
row = data[row_start:row_start + width]
hex_vals = ", ".join(f"0x{v:04X}" for v in row)
lines.append(f" {hex_vals},")
lines.append("};")
return "\n".join(lines)
def generate_header(name, frames, width, height):
"""Generate complete .h file content for a sprite."""
lines = []
lines.append(f"// Auto-generated by sprite2c.py - {name}")
lines.append(f"// {len(frames)} frame(s), {width}x{height} RGB565")
lines.append(f"// Transparent color key: 0x{TRANSPARENT_RGB565:04X} (magenta)")
lines.append("")
lines.append("#pragma once")
lines.append("#include <cstdint>")
lines.append("")
for i, frame in enumerate(frames):
lines.append(format_array(name, i, frame, width))
lines.append("")
return "\n".join(lines)
def load_anim_config(sprite_dir):
"""Load animation config from sprite_config.txt if it exists.
Format: name,delayMs,loop (one per line)
Returns dict of { name: (delayMs, loop) }
"""
config = dict(DEFAULT_ANIM_CONFIG)
config_path = os.path.join(sprite_dir, "sprite_config.txt")
if os.path.isfile(config_path):
with open(config_path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(",")
if len(parts) == 3:
try:
name = parts[0].strip()
delay = int(parts[1].strip())
loop = parts[2].strip().lower() == "true"
config[name] = (delay, loop)
except ValueError as e:
print(f" WARNING: Invalid config line '{line}': {e}")
else:
print(f" WARNING: Malformed config line '{line}'")
return config
def generate_spritedata(sprite_dir, frame_width, frame_height, transparent_color):
"""Generate complete SpriteData.h from a directory of sprite PNGs.
Expects PNGs named to match SPRITE_NAMES entries.
"""
anim_config = load_anim_config(sprite_dir)
lines = []
lines.append("/**")
lines.append(" * @file SpriteData.h")
lines.append(f" * @brief {frame_width}x{frame_height} RGB565 sprite data for TamaTac")
lines.append(" *")
lines.append(" * Auto-generated by sprite2c.py --spritedata")
lines.append(" * Re-generate with: python sprite2c.py <sprites_dir> --spritedata -o SpriteData.h")
lines.append(" */")
lines.append("#pragma once")
lines.append("")
lines.append('#include "Sprites.h"')
lines.append("")
# Track frames per sprite for the animation table
sprite_frame_counts = {}
total_frames = 0
# Process each sprite
for sprite_name in SPRITE_NAMES:
png_path = os.path.join(sprite_dir, f"{sprite_name}.png")
if not os.path.isfile(png_path):
print(f" WARNING: {sprite_name}.png not found, skipping")
sprite_frame_counts[sprite_name] = 0
continue
with Image.open(png_path) as img:
frames = convert_sprite(img, frame_width, frame_height, transparent_color)
sprite_frame_counts[sprite_name] = len(frames)
total_frames += len(frames)
print(f" {sprite_name}: {len(frames)} frame(s) from {sprite_name}.png")
# Write pixel arrays
for i, frame in enumerate(frames):
lines.append(format_array(sprite_name, i, frame, frame_width))
lines.append("")
# Write AnimFrame arrays
for sprite_name in SPRITE_NAMES:
count = sprite_frame_counts.get(sprite_name, 0)
if count == 0:
continue
frame_refs = ", ".join(
f"{{sprite_{sprite_name}_frame{i}}}" for i in range(count)
)
lines.append(f"constexpr AnimFrame frames_{sprite_name}[] = {{ {frame_refs} }};")
lines.append("")
# Write animatedSprites table
lines.append("const AnimatedSprite animatedSprites[PET_SPRITE_COUNT] = {")
for sprite_name in SPRITE_NAMES:
count = sprite_frame_counts.get(sprite_name, 0)
if count == 0:
# Fallback: empty entry (shouldn't happen with complete assets)
lines.append(f" {{nullptr, 0, 0, false}}, // {sprite_name} MISSING")
continue
delay, loop = anim_config.get(sprite_name, (500, True))
loop_str = "true" if loop else "false"
lines.append(f" {{frames_{sprite_name}, {count}, {delay}, {loop_str}}},")
lines.append("};")
lines.append("")
processed_count = sum(1 for c in sprite_frame_counts.values() if c > 0)
print(f"\nTotal: {processed_count} sprites processed, {total_frames} frames")
return "\n".join(lines)
def process_file(filepath, name, frame_width, frame_height, transparent_color, cols=None):
"""Process a single PNG file and return header content."""
with Image.open(filepath) as img:
frames = convert_sprite(img, frame_width, frame_height, transparent_color, cols)
print(f" {name}: {len(frames)} frame(s) from {os.path.basename(filepath)}")
return generate_header(name, frames, frame_width, frame_height)
def main():
parser = argparse.ArgumentParser(description="Convert PNG sprites to RGB565 C arrays")
parser.add_argument("input", help="Input PNG file or directory (with --batch or --spritedata)")
parser.add_argument("--name", "-n", help="Sprite name for output (default: filename without extension)")
parser.add_argument("--width", "-W", type=int, default=24, help="Frame width in pixels (default: 24)")
parser.add_argument("--height", "-H", type=int, default=24, help="Frame height in pixels (default: 24)")
parser.add_argument("--cols", "-c", type=int, help="Number of columns in spritesheet (auto-detected if omitted)")
parser.add_argument("--transparent", "-t", help="Transparent color as R,G,B (default: use alpha channel)")
parser.add_argument("--output", "-o", help="Output file path (default: <name>.h)")
parser.add_argument("--batch", "-b", action="store_true", help="Process all PNGs in directory")
parser.add_argument("--spritedata", "-S", action="store_true",
help="Generate complete SpriteData.h from directory of sprite PNGs")
args = parser.parse_args()
transparent_color = None
if args.transparent:
parts = args.transparent.split(",")
if len(parts) != 3:
print("Error: --transparent must be R,G,B (e.g., 255,0,255)")
sys.exit(1)
try:
transparent_color = tuple(int(p.strip()) for p in parts)
if not all(0 <= c <= 255 for c in transparent_color):
raise ValueError("RGB values must be 0-255")
except ValueError as e:
print(f"Error: --transparent must be R,G,B with values 0-255 (e.g., 255,0,255): {e}")
sys.exit(1)
if args.spritedata:
if not os.path.isdir(args.input):
print(f"Error: {args.input} is not a directory")
sys.exit(1)
print(f"Generating SpriteData.h from {args.input}")
print(f"Frame size: {args.width}x{args.height}")
print()
content = generate_spritedata(args.input, args.width, args.height, transparent_color)
output_path = args.output or "SpriteData.h"
with open(output_path, "w") as f:
f.write(content)
print(f"\nOutput: {output_path}")
elif args.batch:
if not os.path.isdir(args.input):
print(f"Error: {args.input} is not a directory")
sys.exit(1)
png_files = sorted(f for f in os.listdir(args.input) if f.lower().endswith(".png"))
if not png_files:
print(f"No PNG files found in {args.input}")
sys.exit(1)
print(f"Processing {len(png_files)} files...")
for png_file in png_files:
filepath = os.path.join(args.input, png_file)
name = os.path.splitext(png_file)[0]
content = process_file(filepath, name, args.width, args.height, transparent_color, args.cols)
output_path = os.path.join(args.input, f"{name}.h")
with open(output_path, "w") as f:
f.write(content)
print(f" -> {output_path}")
else:
if not os.path.isfile(args.input):
print(f"Error: {args.input} not found")
sys.exit(1)
name = args.name or os.path.splitext(os.path.basename(args.input))[0]
content = process_file(args.input, name, args.width, args.height, transparent_color, args.cols)
output_path = args.output or f"{name}.h"
with open(output_path, "w") as f:
f.write(content)
print(f"Output: {output_path}")
if __name__ == "__main__":
main()