TamaTac & SfxEngine + Sprite tools (#24)
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user