TamaTac & SfxEngine + Sprite tools (#24)
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate placeholder 24x24 RGB565 sprite data for TamaTac.
|
||||
|
||||
Each sprite is a colored version of the original monochrome design,
|
||||
upscaled from 16x16 to 24x24 with 2 animation frames (slight bounce).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
T = 0xF81F # Transparent (magenta)
|
||||
|
||||
def rgb565(r, g, b):
|
||||
"""Convert 8-bit RGB to RGB565, avoiding transparent color."""
|
||||
val = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
|
||||
if val == T:
|
||||
val = 0xF81E
|
||||
return val
|
||||
|
||||
# Color palette
|
||||
WHITE = rgb565(255, 255, 255)
|
||||
CREAM = rgb565(255, 240, 220)
|
||||
LGRAY = rgb565(200, 200, 200)
|
||||
GRAY = rgb565(150, 150, 150)
|
||||
DGRAY = rgb565(80, 80, 80)
|
||||
BLACK = rgb565(20, 20, 20)
|
||||
RED = rgb565(255, 60, 60)
|
||||
DRED = rgb565(180, 30, 30)
|
||||
GREEN = rgb565(60, 200, 60)
|
||||
BLUE = rgb565(80, 120, 255)
|
||||
LBLUE = rgb565(150, 200, 255)
|
||||
YELLOW = rgb565(255, 220, 50)
|
||||
ORANGE = rgb565(255, 160, 40)
|
||||
PINK = rgb565(255, 150, 180)
|
||||
LPINK = rgb565(255, 200, 220)
|
||||
PURPLE = rgb565(180, 100, 255)
|
||||
LPURPLE = rgb565(220, 180, 255)
|
||||
CYAN = rgb565(80, 220, 220)
|
||||
BROWN = rgb565(160, 100, 40)
|
||||
LBROWN = rgb565(200, 140, 80)
|
||||
TEAL = rgb565(50, 180, 160)
|
||||
|
||||
# Eye patterns (2x2 blocks)
|
||||
def draw_eyes(grid, cx, cy, color=BLACK, highlight=WHITE):
|
||||
"""Draw simple 2x2 eyes at left and right positions."""
|
||||
lx, rx = cx - 4, cx + 3
|
||||
ey = cy - 2
|
||||
for dy in range(2):
|
||||
for dx in range(2):
|
||||
grid[ey + dy][lx + dx] = color
|
||||
grid[ey + dy][rx + dx] = color
|
||||
# Highlight
|
||||
grid[ey][lx] = highlight
|
||||
grid[ey][rx] = highlight
|
||||
|
||||
def draw_mouth_smile(grid, cx, cy):
|
||||
"""Draw a small smile."""
|
||||
y = cy + 2
|
||||
for dx in range(-2, 3):
|
||||
grid[y][cx + dx] = BLACK
|
||||
grid[y - 1][cx - 3] = BLACK
|
||||
grid[y - 1][cx + 3] = BLACK
|
||||
|
||||
def draw_mouth_frown(grid, cx, cy):
|
||||
"""Draw a small frown."""
|
||||
y = cy + 3
|
||||
for dx in range(-2, 3):
|
||||
grid[y][cx + dx] = BLACK
|
||||
grid[y + 1][cx - 3] = BLACK
|
||||
grid[y + 1][cx + 3] = BLACK
|
||||
|
||||
def draw_mouth_open(grid, cx, cy):
|
||||
"""Draw an open mouth (eating/surprised)."""
|
||||
y = cy + 2
|
||||
for dy in range(3):
|
||||
for dx in range(-2, 3):
|
||||
grid[y + dy][cx + dx] = BLACK
|
||||
# Inner mouth
|
||||
for dy in range(1, 2):
|
||||
for dx in range(-1, 2):
|
||||
grid[y + dy][cx + dx] = RED
|
||||
|
||||
def draw_x_eyes(grid, cx, cy):
|
||||
"""Draw X-shaped eyes (sick)."""
|
||||
lx, rx = cx - 5, cx + 2
|
||||
ey = cy - 2
|
||||
for i in range(3):
|
||||
grid[ey + i][lx + i] = BLACK
|
||||
grid[ey + i][lx + 2 - i] = BLACK
|
||||
grid[ey + i][rx + i] = BLACK
|
||||
grid[ey + i][rx + 2 - i] = BLACK
|
||||
|
||||
def draw_closed_eyes(grid, cx, cy):
|
||||
"""Draw closed eyes (sleeping)."""
|
||||
lx, rx = cx - 5, cx + 2
|
||||
ey = cy - 1
|
||||
for dx in range(3):
|
||||
grid[ey][lx + dx] = BLACK
|
||||
grid[ey][rx + dx] = BLACK
|
||||
|
||||
def make_oval(w, h, cx, cy, radius_x, radius_y, body_color, outline_color):
|
||||
"""Create an oval shape with outline."""
|
||||
grid = [[T] * w for _ in range(h)]
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
dx = (x - cx) / radius_x
|
||||
dy = (y - cy) / radius_y
|
||||
dist = dx * dx + dy * dy
|
||||
if dist <= 0.85:
|
||||
grid[y][x] = body_color
|
||||
elif dist <= 1.0:
|
||||
grid[y][x] = outline_color
|
||||
return grid
|
||||
|
||||
def flatten(grid):
|
||||
"""Flatten 2D grid to 1D array."""
|
||||
return [pixel for row in grid for pixel in row]
|
||||
|
||||
def shift_down(grid, pixels):
|
||||
"""Shift grid contents down by N pixels (for bounce animation)."""
|
||||
h = len(grid)
|
||||
w = len(grid[0])
|
||||
new_grid = [[T] * w for _ in range(h)]
|
||||
for y in range(h - pixels):
|
||||
for x in range(w):
|
||||
new_grid[y + pixels][x] = grid[y][x]
|
||||
return new_grid
|
||||
|
||||
# ── Sprite Generators ─────────────────────────────────────────────────────────
|
||||
|
||||
def make_egg():
|
||||
"""Egg: cream oval with cracks."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 12, 8, 10, CREAM, LBROWN)
|
||||
# Crack pattern
|
||||
for x, y in [(10, 5), (11, 6), (12, 5), (13, 6), (14, 5)]:
|
||||
g[y + bounce][x] = BROWN
|
||||
# Spots
|
||||
g[10 + bounce][8] = LBROWN
|
||||
g[14 + bounce][15] = LBROWN
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_baby():
|
||||
"""Baby: small pink blob with big eyes."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 13 - bounce, 7, 7, PINK, DRED)
|
||||
draw_eyes(g, 12, 12 - bounce, BLACK, WHITE)
|
||||
# Tiny smile
|
||||
g[15 - bounce][11] = BLACK
|
||||
g[15 - bounce][12] = BLACK
|
||||
g[15 - bounce][13] = BLACK
|
||||
# Cheek blush
|
||||
g[14 - bounce][7] = LPINK
|
||||
g[14 - bounce][16] = LPINK
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_teen():
|
||||
"""Teen: blue rounded creature with spiky top."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 13 - bounce, 8, 8, LBLUE, BLUE)
|
||||
draw_eyes(g, 12, 12 - bounce, BLACK, WHITE)
|
||||
draw_mouth_smile(g, 12, 12 - bounce)
|
||||
# Spiky hair
|
||||
for x in [9, 12, 15]:
|
||||
g[4 - bounce][x] = BLUE
|
||||
g[3 - bounce][x] = BLUE
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_adult():
|
||||
"""Adult: larger green creature with distinct features."""
|
||||
frames = []
|
||||
for bounce in [0, 1, 0]:
|
||||
g = make_oval(24, 24, 12, 12 - bounce, 9, 9, GREEN, TEAL)
|
||||
draw_eyes(g, 12, 11 - bounce, BLACK, WHITE)
|
||||
draw_mouth_smile(g, 12, 11 - bounce)
|
||||
# Ears/horns
|
||||
g[2 - bounce][7] = TEAL
|
||||
g[2 - bounce][16] = TEAL
|
||||
g[3 - bounce][7] = GREEN
|
||||
g[3 - bounce][16] = GREEN
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_elder():
|
||||
"""Elder: purple creature with wrinkles."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 12 - bounce, 9, 9, LPURPLE, PURPLE)
|
||||
draw_eyes(g, 12, 11 - bounce, BLACK, WHITE)
|
||||
# Wrinkle lines under eyes
|
||||
y = 14 - bounce
|
||||
g[y][7] = PURPLE
|
||||
g[y][8] = PURPLE
|
||||
g[y][15] = PURPLE
|
||||
g[y][16] = PURPLE
|
||||
# Small smile
|
||||
g[15 - bounce][11] = BLACK
|
||||
g[15 - bounce][12] = BLACK
|
||||
g[15 - bounce][13] = BLACK
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_ghost():
|
||||
"""Ghost: white translucent with wavy bottom."""
|
||||
frames = []
|
||||
for phase in [0, 1, 2]:
|
||||
g = [[T] * 24 for _ in range(24)]
|
||||
# Ghost body (top half oval, bottom wavy)
|
||||
for y in range(4, 18):
|
||||
for x in range(4, 20):
|
||||
dx = (x - 12) / 8
|
||||
dy = (y - 10) / 8
|
||||
if dx * dx + dy * dy <= 1.0:
|
||||
g[y][x] = WHITE
|
||||
# Wavy bottom
|
||||
for x in range(4, 20):
|
||||
wave = int(math.sin((x + phase) * 1.2) * 1.5)
|
||||
base_y = 17
|
||||
for dy in range(3):
|
||||
yy = base_y + dy + wave
|
||||
if 0 <= yy < 24 and 4 <= x < 20:
|
||||
g[yy][x] = WHITE if dy < 2 else LGRAY
|
||||
# Eyes
|
||||
for dy in range(2):
|
||||
for dx in range(2):
|
||||
g[10 + dy][8 + dx] = BLACK
|
||||
g[10 + dy][13 + dx] = BLACK
|
||||
# Open mouth
|
||||
g[14][11] = DGRAY
|
||||
g[14][12] = DGRAY
|
||||
g[15][11] = DGRAY
|
||||
g[15][12] = DGRAY
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_sick():
|
||||
"""Sick: greenish with X eyes and sweat drop."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 12, 9, 9, rgb565(180, 220, 150), rgb565(100, 150, 80))
|
||||
draw_x_eyes(g, 12, 12)
|
||||
# Green-tinged mouth (wavy)
|
||||
for dx in range(-2, 3):
|
||||
y = 16 + (1 if dx % 2 == 0 else 0)
|
||||
g[y][12 + dx] = BLACK
|
||||
# Sweat drop
|
||||
if bounce == 0:
|
||||
g[5][18] = LBLUE
|
||||
g[6][18] = BLUE
|
||||
else:
|
||||
g[6][18] = LBLUE
|
||||
g[7][18] = BLUE
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_happy():
|
||||
"""Happy: bright yellow with big smile and sparkles."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 12 - bounce, 9, 9, YELLOW, ORANGE)
|
||||
draw_eyes(g, 12, 11 - bounce, BLACK, WHITE)
|
||||
draw_mouth_smile(g, 12, 11 - bounce)
|
||||
# Blush
|
||||
g[14 - bounce][6] = ORANGE
|
||||
g[14 - bounce][17] = ORANGE
|
||||
# Sparkle
|
||||
if bounce == 0:
|
||||
g[3][4] = WHITE
|
||||
g[3][19] = WHITE
|
||||
else:
|
||||
g[4][3] = WHITE
|
||||
g[4][20] = WHITE
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_sad():
|
||||
"""Sad: dark blue with frown and tear."""
|
||||
frames = []
|
||||
for bounce in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 12, 9, 9, LBLUE, BLUE)
|
||||
draw_eyes(g, 12, 11, BLACK, WHITE)
|
||||
draw_mouth_frown(g, 12, 11)
|
||||
# Tear
|
||||
ty = 14 + bounce
|
||||
g[ty][7] = CYAN
|
||||
g[ty + 1][7] = BLUE
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_eating():
|
||||
"""Eating: orange-tinted with open mouth, food particle."""
|
||||
frames = []
|
||||
for phase in [0, 1, 2]:
|
||||
g = make_oval(24, 24, 12, 12, 9, 9, ORANGE, BROWN)
|
||||
draw_eyes(g, 12, 11, BLACK, WHITE)
|
||||
if phase == 1:
|
||||
# Mouth closed (chewing)
|
||||
g[15][11] = BLACK
|
||||
g[15][12] = BLACK
|
||||
g[15][13] = BLACK
|
||||
else:
|
||||
draw_mouth_open(g, 12, 11)
|
||||
# Food particle
|
||||
if phase == 0:
|
||||
g[10][4] = GREEN
|
||||
g[11][4] = GREEN
|
||||
g[10][5] = GREEN
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_playing():
|
||||
"""Playing: bouncing with star eyes."""
|
||||
frames = []
|
||||
for phase in [0, 1, 2]:
|
||||
offset = [0, -2, -1][phase]
|
||||
g = make_oval(24, 24, 12, 12 - offset, 9, 8, CYAN, TEAL)
|
||||
# Star-shaped eyes
|
||||
draw_eyes(g, 12, 11 - offset, BLACK, YELLOW)
|
||||
# Big grin
|
||||
y = 14 - offset
|
||||
for dx in range(-3, 4):
|
||||
g[y][12 + dx] = BLACK
|
||||
g[y - 1][12 - 4] = BLACK
|
||||
g[y - 1][12 + 4] = BLACK
|
||||
# Motion lines
|
||||
if phase == 1:
|
||||
g[6][3] = LGRAY
|
||||
g[7][2] = LGRAY
|
||||
g[6][20] = LGRAY
|
||||
g[7][21] = LGRAY
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
def make_sleeping():
|
||||
"""Sleeping: curled up with closed eyes and Z's."""
|
||||
frames = []
|
||||
for phase in [0, 1]:
|
||||
g = make_oval(24, 24, 12, 14, 9, 8, LPURPLE, PURPLE)
|
||||
draw_closed_eyes(g, 12, 13)
|
||||
# Slight smile
|
||||
g[16][11] = BLACK
|
||||
g[16][12] = BLACK
|
||||
g[16][13] = BLACK
|
||||
# Z's floating
|
||||
zx = 18 + phase
|
||||
zy = 5 - phase
|
||||
if 0 <= zx < 24 and 0 <= zy < 24:
|
||||
g[zy][zx] = WHITE
|
||||
if zx + 1 < 24:
|
||||
g[zy][zx + 1] = WHITE
|
||||
if 0 <= zy + 1 < 24:
|
||||
g[zy + 1][zx] = WHITE
|
||||
# Smaller Z
|
||||
sz_x = 16
|
||||
sz_y = 3
|
||||
if 0 <= sz_y < 24 and 0 <= sz_x < 24:
|
||||
g[sz_y][sz_x] = LGRAY
|
||||
frames.append(flatten(g))
|
||||
return frames
|
||||
|
||||
|
||||
def format_array(name, idx, data):
|
||||
"""Format pixel data as C array."""
|
||||
lines = [f"constexpr uint16_t sprite_{name}_frame{idx}[{len(data)}] = {{"]
|
||||
for row_start in range(0, len(data), 24):
|
||||
row = data[row_start:row_start + 24]
|
||||
lines.append(" " + ", ".join(f"0x{v:04X}" for v in row) + ",")
|
||||
lines.append("};")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
sprites = [
|
||||
("egg_idle", make_egg(), 800, True),
|
||||
("baby_idle", make_baby(), 600, True),
|
||||
("teen_idle", make_teen(), 500, True),
|
||||
("adult_idle", make_adult(), 400, True),
|
||||
("elder_idle", make_elder(), 700, True),
|
||||
("ghost", make_ghost(), 500, True),
|
||||
("sick", make_sick(), 1000, True),
|
||||
("happy", make_happy(), 400, True),
|
||||
("sad", make_sad(), 800, True),
|
||||
("eating", make_eating(), 300, False),
|
||||
("playing", make_playing(), 300, False),
|
||||
("sleeping", make_sleeping(),1000, True),
|
||||
]
|
||||
|
||||
lines = []
|
||||
lines.append("/**")
|
||||
lines.append(" * @file SpriteData.h")
|
||||
lines.append(" * @brief Placeholder 24x24 RGB565 sprite data for TamaTac")
|
||||
lines.append(" *")
|
||||
lines.append(" * Auto-generated by generate_placeholders.py")
|
||||
lines.append(" * Replace with real pixel art via sprite2c.py")
|
||||
lines.append(" */")
|
||||
lines.append("#pragma once")
|
||||
lines.append("")
|
||||
lines.append('#include "Sprites.h"')
|
||||
lines.append("")
|
||||
|
||||
# Frame data arrays
|
||||
for name, frames, delay, loop in sprites:
|
||||
for i, frame in enumerate(frames):
|
||||
lines.append(format_array(name, i, frame))
|
||||
lines.append("")
|
||||
|
||||
# AnimFrame arrays
|
||||
for name, frames, delay, loop in sprites:
|
||||
frame_refs = ", ".join(f"{{sprite_{name}_frame{i}}}" for i in range(len(frames)))
|
||||
lines.append(f"constexpr AnimFrame frames_{name}[] = {{ {frame_refs} }};")
|
||||
lines.append("")
|
||||
|
||||
# AnimatedSprites table
|
||||
lines.append("const AnimatedSprite animatedSprites[PET_SPRITE_COUNT] = {")
|
||||
for name, frames, delay, loop in sprites:
|
||||
loop_str = "true" if loop else "false"
|
||||
lines.append(f" {{frames_{name}, {len(frames)}, {delay}, {loop_str}}},")
|
||||
lines.append("};")
|
||||
|
||||
output = "\n".join(lines) + "\n"
|
||||
|
||||
# Write to SpriteData.h
|
||||
import os
|
||||
output_path = os.path.join(os.path.dirname(__file__), "..", "..",
|
||||
"Apps", "TamaTac", "main", "Source", "SpriteData.h")
|
||||
output_path = os.path.normpath(output_path)
|
||||
with open(output_path, "w") as f:
|
||||
f.write(output)
|
||||
print(f"Generated {output_path}")
|
||||
print(f" {len(sprites)} sprites, {sum(len(f) for _, f, _, _ in sprites)} total frames")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
spritedata2png.py - Extract RGB565 sprite data from SpriteData.h to PNG files
|
||||
|
||||
Usage:
|
||||
# Export all sprites as spritesheets (frames side-by-side):
|
||||
python spritedata2png.py ../../Apps/TamaTac/main/Source/SpriteData.h -o sprites/
|
||||
|
||||
# Scale up for easier viewing/editing (4x):
|
||||
python spritedata2png.py SpriteData.h -o sprites/ --scale 4
|
||||
|
||||
# Export individual frames instead of spritesheets:
|
||||
python spritedata2png.py SpriteData.h -o sprites/ --individual
|
||||
|
||||
Output:
|
||||
Default: One PNG per sprite as a horizontal spritesheet.
|
||||
egg_idle.png (48x24 = 2 frames), adult_idle.png (72x24 = 3 frames), etc.
|
||||
With --individual: One PNG per frame.
|
||||
egg_idle_frame0.png, egg_idle_frame1.png, etc.
|
||||
|
||||
Transparent pixels (0xF81F) become fully transparent in the PNG.
|
||||
Output is compatible with sprite2c.py --spritedata for round-trip conversion.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("Error: Pillow is required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
TRANSPARENT_RGB565 = 0xF81F
|
||||
|
||||
|
||||
def rgb565_to_rgb(val):
|
||||
"""Convert RGB565 to 8-bit RGB tuple."""
|
||||
r = ((val >> 11) & 0x1F) << 3
|
||||
g = ((val >> 5) & 0x3F) << 2
|
||||
b = (val & 0x1F) << 3
|
||||
# Fill in lower bits for better color accuracy
|
||||
r |= r >> 5
|
||||
g |= g >> 6
|
||||
b |= b >> 5
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def parse_sprite_arrays(header_text):
|
||||
"""Parse all sprite_*_frame* arrays from SpriteData.h content.
|
||||
|
||||
Returns:
|
||||
OrderedDict of { "sprite_name_frameN": [uint16 values, ...], ... }
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r'constexpr\s+uint16_t\s+(sprite_\w+)\s*\[\d+\]\s*=\s*\{([^}]+)\}\s*;',
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
arrays = OrderedDict()
|
||||
hex_pattern = re.compile(r'0x([0-9A-Fa-f]+)')
|
||||
for match in pattern.finditer(header_text):
|
||||
name = match.group(1)
|
||||
data_str = match.group(2)
|
||||
|
||||
values = [int(h.group(1), 16) for h in hex_pattern.finditer(data_str)]
|
||||
arrays[name] = values
|
||||
|
||||
return arrays
|
||||
|
||||
|
||||
def group_frames(arrays):
|
||||
"""Group individual frame arrays by sprite name.
|
||||
|
||||
Input keys like "sprite_egg_idle_frame0", "sprite_egg_idle_frame1"
|
||||
become grouped under "egg_idle" with ordered frame data.
|
||||
|
||||
Returns:
|
||||
OrderedDict of { "egg_idle": [ [pixels0], [pixels1], ... ], ... }
|
||||
"""
|
||||
grouped = OrderedDict()
|
||||
frame_pattern = re.compile(r'^sprite_(.+)_frame(\d+)$')
|
||||
|
||||
for array_name, pixels in arrays.items():
|
||||
match = frame_pattern.match(array_name)
|
||||
if not match:
|
||||
continue
|
||||
sprite_name = match.group(1)
|
||||
frame_idx = int(match.group(2))
|
||||
|
||||
if sprite_name not in grouped:
|
||||
grouped[sprite_name] = []
|
||||
|
||||
# Ensure list is large enough
|
||||
while len(grouped[sprite_name]) <= frame_idx:
|
||||
grouped[sprite_name].append(None)
|
||||
grouped[sprite_name][frame_idx] = pixels
|
||||
|
||||
# Remove any None gaps
|
||||
for name in grouped:
|
||||
grouped[name] = [f for f in grouped[name] if f is not None]
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def create_frame_png(pixels, width, height, scale=1):
|
||||
"""Create a PIL Image from a single frame of RGB565 pixel data."""
|
||||
img = Image.new("RGBA", (width, height))
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
idx = y * width + x
|
||||
if idx >= len(pixels):
|
||||
break
|
||||
val = pixels[idx]
|
||||
|
||||
if val == TRANSPARENT_RGB565:
|
||||
img.putpixel((x, y), (0, 0, 0, 0))
|
||||
else:
|
||||
r, g, b = rgb565_to_rgb(val)
|
||||
img.putpixel((x, y), (r, g, b, 255))
|
||||
|
||||
if scale > 1:
|
||||
img = img.resize((width * scale, height * scale), Image.NEAREST)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_spritesheet(frame_list, width, height, scale=1):
|
||||
"""Create a horizontal spritesheet from multiple frames."""
|
||||
num_frames = len(frame_list)
|
||||
sheet_width = width * num_frames
|
||||
sheet = Image.new("RGBA", (sheet_width, height))
|
||||
|
||||
for i, pixels in enumerate(frame_list):
|
||||
frame_img = create_frame_png(pixels, width, height, scale=1)
|
||||
sheet.paste(frame_img, (i * width, 0))
|
||||
|
||||
if scale > 1:
|
||||
sheet = sheet.resize((sheet_width * scale, height * scale), Image.NEAREST)
|
||||
|
||||
return sheet
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Extract RGB565 sprites from SpriteData.h to PNG")
|
||||
parser.add_argument("input", help="Path to SpriteData.h")
|
||||
parser.add_argument("--output", "-o", default=".", help="Output directory (default: current dir)")
|
||||
parser.add_argument("--width", "-W", type=int, default=24, help="Sprite width (default: 24)")
|
||||
parser.add_argument("--height", "-H", type=int, default=24, help="Sprite height (default: 24)")
|
||||
parser.add_argument("--scale", "-s", type=int, default=1, help="Scale factor for output PNGs (default: 1)")
|
||||
parser.add_argument("--individual", "-i", action="store_true",
|
||||
help="Export individual frame PNGs instead of spritesheets")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isfile(args.input):
|
||||
print(f"Error: {args.input} not found")
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
arrays = parse_sprite_arrays(content)
|
||||
|
||||
if not arrays:
|
||||
print("No sprite arrays found in input file.")
|
||||
sys.exit(1)
|
||||
|
||||
expected_pixels = args.width * args.height
|
||||
print(f"Found {len(arrays)} sprite frame(s) in {os.path.basename(args.input)}")
|
||||
print(f"Sprite size: {args.width}x{args.height}, output scale: {args.scale}x")
|
||||
print()
|
||||
|
||||
if args.individual:
|
||||
# Export one PNG per frame
|
||||
for name, pixels in arrays.items():
|
||||
if len(pixels) != expected_pixels:
|
||||
print(f" SKIP {name}: {len(pixels)} pixels (expected {expected_pixels})")
|
||||
continue
|
||||
|
||||
img = create_frame_png(pixels, args.width, args.height, args.scale)
|
||||
output_path = os.path.join(args.output, f"{name}.png")
|
||||
img.save(output_path)
|
||||
print(f" {name}.png")
|
||||
else:
|
||||
# Export spritesheets (one PNG per sprite, frames side-by-side)
|
||||
grouped = group_frames(arrays)
|
||||
|
||||
for sprite_name, frame_list in grouped.items():
|
||||
# Validate all frames
|
||||
valid_frames = [f for f in frame_list if len(f) == expected_pixels]
|
||||
if not valid_frames:
|
||||
print(f" SKIP {sprite_name}: no valid frames")
|
||||
continue
|
||||
|
||||
if len(valid_frames) == 1:
|
||||
# Single frame: just save as-is
|
||||
img = create_frame_png(valid_frames[0], args.width, args.height, args.scale)
|
||||
else:
|
||||
# Multiple frames: create horizontal spritesheet
|
||||
img = create_spritesheet(valid_frames, args.width, args.height, args.scale)
|
||||
|
||||
output_path = os.path.join(args.output, f"{sprite_name}.png")
|
||||
img.save(output_path)
|
||||
frame_desc = f"{len(valid_frames)} frame(s)"
|
||||
if len(valid_frames) > 1:
|
||||
frame_desc += f", {args.width * len(valid_frames)}x{args.height}"
|
||||
print(f" {sprite_name}.png ({frame_desc})")
|
||||
|
||||
print(f"\nExported to {os.path.abspath(args.output)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user