PanBadge/sprites_gen.py

572 lines
19 KiB
Python

#!/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 <prefix>_<expr>.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_<expr>.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()