#!/usr/bin/env python3 """ sprites_gen.py -> generates sprites.h for the Panagotchi firmware. All artwork is defined here as math/ASCII so it stays human-editable. Output is packed 1-bpp, row-major, MSB-first -- exactly the format ch32fun's ssd1306_drawImage() expects (width must be a multiple of 8). Run: python3 sprites_gen.py > sprites.h """ # ---------------------------------------------------------------- grid helpers def grid(w, h): return [[0] * w for _ in range(h)] def px(g, x, y, v=1): if 0 <= y < len(g) and 0 <= x < len(g[0]): g[y][x] = v def fill_ellipse(g, cx, cy, rx, ry, v=1): for y in range(len(g)): for x in range(len(g[0])): dx = (x - cx) / rx dy = (y - cy) / ry if dx * dx + dy * dy <= 1.0: g[y][x] = v def fillrect(g, x0, y0, x1, y1, v=1): for y in range(y0, y1 + 1): for x in range(x0, x1 + 1): px(g, x, y, v) def hline(g, x0, x1, y, v=1): if x0 > x1: x0, x1 = x1, x0 for x in range(x0, x1 + 1): px(g, x, y, v) def vline(g, x, y0, y1, v=1): if y0 > y1: y0, y1 = y1, y0 for y in range(y0, y1 + 1): px(g, x, y, v) def from_art(art): """art = list of strings, '#'/'X'/'1' -> 1, anything else -> 0. Rows are padded to a common width (rounded up to a multiple of 8).""" w = max(len(r) for r in art) w = (w + 7) // 8 * 8 g = [] for row in art: g.append([1 if c in "#X1" else 0 for c in row.ljust(w)]) return g def from_png(path, threshold=128): """Load a PNG and convert to a 1-bpp grid (white/bright=lit, dark=off). Width is padded to the nearest multiple of 8. Requires Pillow: pip install pillow""" try: from PIL import Image except ImportError: raise SystemExit("from_png() needs Pillow: pip install pillow") img = Image.open(path).convert("L") w, h = img.size wp = (w + 7) // 8 * 8 g = [] for y in range(h): row = [1 if x < w and img.getpixel((x, y)) > threshold else 0 for x in range(wp)] g.append(row) return g def load_species_pngs(prefix, aliases=None): """Load the 7 expression PNGs for a species from _.png files. Missing files fall back automatically (blink->idle, eat->happy, dead->sad). Pass aliases={"blink": "idle"} to force a specific reuse. Only custom_idle.png is strictly required; others are optional.""" import os EXPRS = ["idle", "blink", "happy", "sad", "eat", "sleep", "dead"] FALLBACK = { "blink": "idle", "eat": "happy", "dead": "sad"} if aliases: FALLBACK.update(aliases) cache = {} result = [] for name in EXPRS: src = FALLBACK.get(name, name) if src not in cache: path = f"{prefix}_{src}.png" if not os.path.exists(path): path = f"{prefix}_idle.png" cache[src] = from_png(path) cache[name] = cache[src] result.append(cache[name]) return result def pack(g): """Pack to bytes, MSB first, width padded to multiple of 8.""" h = len(g) w = len(g[0]) wbytes = (w + 7) // 8 out = [] for y in range(h): for b in range(wbytes): byte = 0 for bit in range(8): x = b * 8 + bit if x < w and g[y][x]: byte |= 0x80 >> bit out.append(byte) return out def emit(name, g): data = pack(g) w = len(g[0]) wbytes = (w + 7) // 8 print(f"// {name}: {w}x{len(g)}") print(f"static const unsigned char {name}[] = {{") for i in range(0, len(data), wbytes): row = data[i:i + wbytes] print(" " + ", ".join(f"0x{v:02x}" for v in row) + ",") print("};") print() # ---------------------------------------------------------------- the panda # 32x32 base body: round head/body + two ears. Features carved per expression. def base_panda(): g = grid(32, 32) fill_ellipse(g, 16, 19, 13, 12) # head/body fill_ellipse(g, 7, 7, 5, 5) # left ear fill_ellipse(g, 24, 7, 5, 5) # right ear return g def carve_eye(g, cx, cy): # dark socket + lit pupil fill_ellipse(g, cx, cy, 3, 3, 0) px(g, cx, cy, 1) px(g, cx, cy - 1, 1) def panda_idle(): g = base_panda() carve_eye(g, 11, 17) carve_eye(g, 21, 17) fill_ellipse(g, 16, 21, 2, 1, 0) # nose hline(g, 13, 19, 24, 0) # gentle mouth return g def panda_blink(): g = base_panda() hline(g, 8, 14, 17, 0) # closed eyes hline(g, 18, 24, 17, 0) fill_ellipse(g, 16, 21, 2, 1, 0) hline(g, 13, 19, 24, 0) return g def panda_happy(): g = base_panda() # ^ ^ eyes px(g, 10, 17, 0); px(g, 11, 16, 0); px(g, 12, 17, 0) px(g, 20, 17, 0); px(g, 21, 16, 0); px(g, 22, 17, 0) # big smile for x in range(12, 21): y = 22 + (1 if x in (12, 20) else (2 if 14 <= x <= 18 else 0)) px(g, x, y, 0) hline(g, 13, 19, 21, 0) return g def panda_sad(): g = base_panda() carve_eye(g, 11, 18) carve_eye(g, 21, 18) fill_ellipse(g, 16, 21, 2, 1, 0) # frown for x in range(12, 21): y = 25 - (1 if x in (12, 20) else (2 if 14 <= x <= 18 else 0)) px(g, x, y, 0) px(g, 8, 22, 1) # tear (lit dot below eye) px(g, 8, 23, 1) return g def panda_eat(): g = base_panda() carve_eye(g, 11, 16) carve_eye(g, 21, 16) fill_ellipse(g, 16, 23, 3, 3, 0) # wide open mouth return g def panda_sleep(): g = base_panda() hline(g, 8, 14, 18, 0) # closed, relaxed hline(g, 18, 24, 18, 0) fill_ellipse(g, 16, 22, 2, 1, 0) return g def panda_dead(): g = base_panda() # X X eyes for d in range(-2, 3): px(g, 11 + d, 17 + d, 0); px(g, 11 + d, 17 - d, 0) px(g, 21 + d, 17 + d, 0); px(g, 21 + d, 17 - d, 0) hline(g, 12, 20, 23, 0) # flat mouth return g # ---------------------------------------------------------------- species: SHELL # A friendly CRT terminal. Its mouth is a command prompt "> _". def shell_base(): g = grid(32, 32) fillrect(g, 4, 3, 27, 25) # monitor screen (lit) for cx, cy in [(4, 3), (27, 3), (4, 25), (27, 25)]: px(g, cx, cy, 0) # round the corners fillrect(g, 13, 26, 18, 28) # neck fillrect(g, 9, 29, 22, 31) # base return g def shell_prompt(g): # the "> _" signature, dark on screen px(g, 8, 18, 0); px(g, 9, 19, 0); px(g, 10, 20, 0) px(g, 9, 21, 0); px(g, 8, 22, 0) hline(g, 13, 19, 22, 0) def shell_idle(): g = shell_base() fill_ellipse(g, 11, 11, 2, 2, 0) fill_ellipse(g, 20, 11, 2, 2, 0) shell_prompt(g) return g def shell_happy(): g = shell_base() px(g, 9, 11, 0); px(g, 10, 10, 0); px(g, 11, 11, 0) # ^ ^ px(g, 18, 11, 0); px(g, 19, 10, 0); px(g, 20, 11, 0) for x in range(10, 22): y = 19 + (1 if 13 <= x <= 18 else 0) px(g, x, y, 0) return g def shell_sad(): g = shell_base() fill_ellipse(g, 11, 12, 2, 2, 0) fill_ellipse(g, 20, 12, 2, 2, 0) for x in range(10, 22): y = 23 - (1 if 13 <= x <= 18 else 0) px(g, x, y, 0) return g def shell_sleep(): g = shell_base() hline(g, 8, 14, 12, 0) hline(g, 17, 23, 12, 0) hline(g, 13, 19, 20, 0) return g # ---------------------------------------------------------------- species: TMUX # A split terminal window: a central pane divider and a status bar of tabs. def tmux_base(): g = grid(32, 32) fillrect(g, 2, 3, 29, 28) for cx, cy in [(2, 3), (29, 3), (2, 28), (29, 28)]: px(g, cx, cy, 0) vline(g, 16, 5, 22, 0) # pane divider fillrect(g, 3, 24, 28, 27, 0) # dark status strip fillrect(g, 5, 25, 9, 26, 1) # window tab 0 fillrect(g, 11, 25, 15, 26, 1) # window tab 1 return g def tmux_idle(): g = tmux_base() fill_ellipse(g, 9, 12, 2, 2, 0) # one eye per pane fill_ellipse(g, 23, 12, 2, 2, 0) hline(g, 6, 12, 18, 0) # mouth, split by divider hline(g, 20, 26, 18, 0) return g def tmux_happy(): g = tmux_base() px(g, 7, 12, 0); px(g, 8, 11, 0); px(g, 9, 12, 0); px(g, 10, 11, 0); px(g, 11, 12, 0) px(g, 21, 12, 0); px(g, 22, 11, 0); px(g, 23, 12, 0); px(g, 24, 11, 0); px(g, 25, 12, 0) hline(g, 6, 13, 19, 0); hline(g, 19, 26, 19, 0) return g def tmux_sad(): g = tmux_base() fill_ellipse(g, 9, 13, 2, 2, 0) fill_ellipse(g, 23, 13, 2, 2, 0) hline(g, 6, 12, 20, 0); hline(g, 20, 26, 20, 0) px(g, 9, 19, 0); px(g, 23, 19, 0) return g def tmux_sleep(): g = tmux_base() hline(g, 6, 12, 13, 0) hline(g, 20, 26, 13, 0) return g # ---------------------------------------------------------------- props def grave(): art = [ " ", " ", " ############## ", " ################## ", " #################### ", " ## ######## ## ", " ## ## ## ## ", " ## ## ## ## ", " ## ######## ## ", " ## ## ## ## ", " ## ## ## ## ", " ## ## ## ## ", " ## ## ", " ## #### ## ", " #################### ", " ###################### ", " ######################## ", " ########################## ", " ############################ ", "############################## ", "############################## ", " #### #### ", " ############## ", " ################## ", " ", " ", " ", " ", " ", " ", " ", " ", ] return from_art(art) def poop(): art = [ " ", " ## ", " #### ", " ## ## ", " ###### ", "########", "########", " ", ] return from_art(art) # ---------------------------------------------------------------- evolved forms # The pet "ascends" once fully grown. Good care -> Claude (a serene sparkle # being); neglect -> a corrupted glitch. Both are 32x32 like the panda frames. def claude(): g = grid(32, 32) fill_ellipse(g, 16, 19, 11, 11) # round, friendly body fill_ellipse(g, 12, 18, 1, 2, 0) # calm eyes fill_ellipse(g, 20, 18, 1, 2, 0) for x in range(11, 22): # serene smile y = 23 + (1 if 13 <= x <= 19 else 0) px(g, x, y, 0) # signature 4-point sparkle, upper-right sx, sy = 26, 6 vline(g, sx, sy - 4, sy + 4) hline(g, sx - 4, sx + 4, sy) px(g, sx - 2, sy - 2); px(g, sx + 2, sy - 2) px(g, sx - 2, sy + 2); px(g, sx + 2, sy + 2) # tiny twinkle, upper-left px(g, 5, 5); px(g, 5, 4); px(g, 5, 6); px(g, 4, 5); px(g, 6, 5) return g def glitch(): g = grid(32, 32) fill_ellipse(g, 16, 19, 12, 11) for d in range(-2, 3): # X_X eyes px(g, 11 + d, 16 + d, 0); px(g, 11 + d, 16 - d, 0) px(g, 21 + d, 16 + d, 0); px(g, 21 + d, 16 - d, 0) for x in range(10, 23): # jagged mouth px(g, x, 22 + (x % 2), 0) for y in range(10, 31, 4): # scanline corruption for x in range(2, 30, 2): px(g, x, y, 0) for (x, y) in [(0, 8), (2, 30), (31, 14), (29, 29), (1, 24), (30, 4)]: px(g, x, y, 1) # stray noise pixels return g # ---------------------------------------------------------------- 16x16 icons def icon_feed(): # apple g = grid(16, 16) fill_ellipse(g, 7, 9, 6, 6) vline(g, 8, 2, 4) # stem px(g, 10, 2); px(g, 11, 3) # leaf fill_ellipse(g, 5, 7, 1, 2, 0) # shine return g def icon_play(): # ball g = grid(16, 16) fill_ellipse(g, 8, 8, 7, 7) hline(g, 1, 15, 8, 0) vline(g, 8, 1, 15, 0) for d in range(-5, 6): px(g, 8 + d, 3, 0); px(g, 8 + d, 13, 0) return g def icon_sleep(): # moon g = grid(16, 16) fill_ellipse(g, 8, 8, 6, 6) fill_ellipse(g, 11, 6, 6, 6, 0) # crescent cut return g def icon_clean(): # water drop g = grid(16, 16) fill_ellipse(g, 8, 10, 5, 5) for y in range(2, 10): half = (y - 2) // 2 hline(g, 8 - half, 8 + half, y) fill_ellipse(g, 6, 9, 1, 2, 0) return g def icon_heal(): # plus / cross g = grid(16, 16) fill_ellipse(g, 8, 8, 7, 7) for t in range(4, 12): hline(g, 6, 9, t, 0) vline(g, t, 6, 9, 0) return g def icon_stats(): # bar chart g = grid(16, 16) for i, h in enumerate((4, 8, 11, 6)): x = 2 + i * 3 for yy in range(14 - h, 14): hline(g, x, x + 1, yy) return g def icon_settings(): # gear g = grid(16, 16) fill_ellipse(g, 8, 8, 6, 6) # four teeth for x in range(6, 10): vline(g, x, 0, 2); vline(g, x, 13, 15) for y in range(6, 10): hline(g, 0, 2, y); hline(g, 13, 15, y) fill_ellipse(g, 8, 8, 2, 2, 0) # hub hole return g def icon_ir(): # wifi-style arcs = IR signal g = grid(16, 16) vline(g, 8, 9, 14) # antenna stem px(g, 7, 14); px(g, 9, 14) # base feet # inner arc px(g, 6, 8); px(g, 10, 8) px(g, 7, 7); px(g, 9, 7) px(g, 8, 6) # outer arc px(g, 3, 9); px(g, 13, 9) px(g, 4, 7); px(g, 12, 7) px(g, 5, 5); px(g, 11, 5) px(g, 6, 4); px(g, 10, 4) px(g, 7, 3); px(g, 9, 3) px(g, 8, 2) return g # ---------------------------------------------------------------- main def main(): print("// Auto-generated by sprites_gen.py -- do not edit by hand.") print("#ifndef _SPRITES_H") print("#define _SPRITES_H") print() print("#define PANA_W 32") print("#define PANA_H 32") print("#define ICON_W 16") print("#define ICON_H 16") print() frames = [ ("pana_idle", panda_idle()), ("pana_blink", panda_blink()), ("pana_happy", panda_happy()), ("pana_sad", panda_sad()), ("pana_eat", panda_eat()), ("pana_sleep", panda_sleep()), ("pana_dead", panda_dead()), ] for n, g in frames: emit(n, g) # expression index enum (matches frames[] order) print("enum { EXP_IDLE, EXP_BLINK, EXP_HAPPY, EXP_SAD, EXP_EAT, EXP_SLEEP, EXP_DEAD, EXP_COUNT };") print("static const unsigned char *const pana_frames[EXP_COUNT] = {") print(" pana_idle, pana_blink, pana_happy, pana_sad, pana_eat, pana_sleep, pana_dead,") print("};") print() # other starter species (Panko = the panda frames above) species_extra = [ ("shell_idle", shell_idle()), ("shell_happy", shell_happy()), ("shell_sad", shell_sad()), ("shell_sleep", shell_sleep()), ("tmux_idle", tmux_idle()), ("tmux_happy", tmux_happy()), ("tmux_sad", tmux_sad()), ("tmux_sleep", tmux_sleep()), ] for n, g in species_extra: emit(n, g) # species frame tables, indexed [species][EXP_*]. Shell/Tmux reuse their # four frames across the seven expression slots. # # Custom species: drop PNG files named custom_.png (32x32, white=lit) # next to this script and re-run it. Required: custom_idle.png. # Optional (fall back automatically): custom_blink, custom_happy, custom_sad, # custom_eat, custom_sleep, custom_dead. import os has_custom = os.path.exists("custom_idle.png") if has_custom: EXPRS = ["idle", "blink", "happy", "sad", "eat", "sleep", "dead"] custom_frames = load_species_pngs("custom") for name, g in zip(EXPRS, custom_frames): emit(f"custom_{name}", g) species_count = 4 if has_custom else 3 print(f"#define SPECIES_COUNT {species_count}") enum_vals = "SP_SHELL, SP_PANKO, SP_TMUX" + (", SP_CUSTOM" if has_custom else "") print(f"enum {{ {enum_vals} }};") print("static const char *const species_names[SPECIES_COUNT] = {") if has_custom: print(' "Shell", "Panko", "Tmux", "Custom",') else: print(' "Shell", "Panko", "Tmux",') print("};") print("static const unsigned char *const species_frames[SPECIES_COUNT][EXP_COUNT] = {") print(" { shell_idle, shell_idle, shell_happy, shell_sad, shell_happy, shell_sleep, shell_sad },") print(" { pana_idle, pana_blink, pana_happy, pana_sad, pana_eat, pana_sleep, pana_dead },") print(" { tmux_idle, tmux_idle, tmux_happy, tmux_sad, tmux_happy, tmux_sleep, tmux_sad },") if has_custom: print(" { custom_idle, custom_blink, custom_happy, custom_sad, custom_eat, custom_sleep, custom_dead },") print("};") print() if has_custom: # Emit eye geometry as defines so tama.c can extend its arrays. # Edit these pixel coords to match where your character's eyes sit. print("// Eye centre coords for custom species accessories (shades at user, headset at root).") print("// Pixel positions inside the 32x32 sprite -- adjust to match your art.") print("#define CUSTOM_EYE_CY 17 // eye centre row") print("#define CUSTOM_EYE_LX 11 // left eye centre column") print("#define CUSTOM_EYE_RX 21 // right eye centre column") print() emit("spr_grave", grave()) emit("spr_poop", poop()) emit("spr_claude", claude()) emit("spr_glitch", glitch()) icons = [ ("icon_feed", icon_feed()), ("icon_play", icon_play()), ("icon_sleep", icon_sleep()), ("icon_clean", icon_clean()), ("icon_heal", icon_heal()), ("icon_stats", icon_stats()), ("icon_settings", icon_settings()), ("icon_ir", icon_ir()), ] for n, g in icons: emit(n, g) print("#define ICON_COUNT 8") print("static const unsigned char *const menu_icons[ICON_COUNT] = {") print(" icon_feed, icon_play, icon_sleep, icon_clean, icon_heal, icon_stats, icon_settings, icon_ir,") print("};") print('static const char *const menu_labels[ICON_COUNT] = {') print(' "FEED", "PLAY", "SLEEP", "CLEAN", "HEAL", "STATS", "SETTINGS", "IR BEAM",') print("};") print() print("#endif // _SPRITES_H") if __name__ == "__main__": main()