"""Draw Bolt — the pet — as per-state PNG frame sequences. Produces the `assets/sprites//frame_NN.png` convention that `bolt_pet/ui/sprite.py` loads (see `assets/sprites/README.md`). The art is generated rather than sourced so it stays editable: tweak a colour or a pose parameter here and re-run, instead of hand-editing 24 PNGs. python scripts/generate_bolt_sprites.py # write into the real asset dir python scripts/generate_bolt_sprites.py --out /tmp/prev # preview somewhere else Everything is drawn in normalised 0..1 coordinates on a square canvas and super-sampled `SS`x before being downscaled, because PIL's draw primitives have no antialiasing of their own. """ from __future__ import annotations import argparse import math from pathlib import Path from PIL import Image, ImageDraw SS = 4 # supersampling factor OUT = 320 # final frame size (2x the default PET_SIZE of 160) S = OUT * SS # --- palette --------------------------------------------------------------- # A cream shepherd-ish pup with a slate cap, amber eyes and a lightning blaze. C_OUTLINE = (34, 42, 58, 255) C_FUR = (246, 244, 238, 255) C_FUR_SHADE = (214, 210, 200, 255) C_DARK = (78, 92, 122, 255) C_DARK2 = (58, 70, 96, 255) C_INNER_EAR = (226, 154, 158, 255) C_BROW = (206, 166, 118, 255) # The far side of the walking pose. Distinctly darker than C_FUR_SHADE, which # is too close to the cream to read as "behind the dog" at 160px. C_FUR_FAR = (168, 176, 192, 255) C_NOSE = (40, 48, 66, 255) C_IRIS = (196, 128, 50, 255) C_PUPIL = (30, 36, 50, 255) C_WHITE = (255, 255, 255, 255) C_BOLT = (255, 206, 61, 255) C_COLLAR = (222, 84, 46, 255) C_TAG = (255, 198, 68, 255) C_TONGUE = (230, 116, 128, 255) C_GLOW = (92, 214, 244, 255) # --- layout constants (normalised) ----------------------------------------- HEAD_CX, HEAD_CY = 0.50, 0.375 HEAD_W, HEAD_H = 0.50, 0.44 NECK_Y = 0.565 # head layer rotates about here so tilts pivot at the neck EAR_PIVOT = 0.335, 0.275 OW = 0.0105 # outline width, normalised def px(v: float) -> float: return v * S def _w(width: float) -> int: return max(1, int(round(px(width)))) def ell(d, cx, cy, w, h, fill, outline=C_OUTLINE, ow=OW): d.ellipse( [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], fill=fill, outline=outline, width=_w(ow) if outline else 0, ) def rrect(d, cx, cy, w, h, r, fill, outline=C_OUTLINE, ow=OW): d.rounded_rectangle( [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], radius=px(r), fill=fill, outline=outline, width=_w(ow) if outline else 0, ) def poly(d, pts, fill, outline=C_OUTLINE, ow=OW): d.polygon( [(px(x), px(y)) for x, y in pts], fill=fill, outline=outline, width=_w(ow) if outline else 0, ) def rotate_pts(pts, pivot, deg): a = math.radians(deg) ca, sa = math.cos(a), math.sin(a) ox, oy = pivot out = [] for x, y in pts: dx, dy = x - ox, y - oy out.append((ox + dx * ca - dy * sa, oy + dx * sa + dy * ca)) return out def lerp(a, b, t): return a + (b - a) * t def bolt_shape(cx, cy, w, h): """A lightning bolt polygon in a (w x h) box centred on (cx, cy).""" unit = [ (0.62, 0.00), (0.10, 0.56), (0.44, 0.56), (0.28, 1.00), (0.90, 0.40), (0.55, 0.40), (0.80, 0.00), ] return [(cx + (u - 0.5) * w, cy + (v - 0.5) * h) for u, v in unit] # --- body ------------------------------------------------------------------ def _tail_points(p, steps=26): """Quadratic-bezier spine of the tail as (x, y, radius) samples. Shared by the fill and outline passes so a wag can't move one and not the other. The base sits deep inside the haunch, which is drawn over it, so the tail reads as growing out of the body rather than floating beside it. """ wag = p["tail"] base = (0.620, 0.845) ctrl = (0.955, 0.870 - 0.025 * wag) end = (0.905, 0.605 - 0.065 * wag) pts = [] for i in range(steps + 1): t = i / steps x = (1 - t) ** 2 * base[0] + 2 * (1 - t) * t * ctrl[0] + t**2 * end[0] y = (1 - t) ** 2 * base[1] + 2 * (1 - t) * t * ctrl[1] + t**2 * end[1] pts.append((x, y, lerp(0.080, 0.042, t))) return pts def draw_tapered(d, pts, color_at): """Draw a tapered limb from (x, y, radius) samples. Two passes: circles along the spine for the fill, then the two silhouette edges, so it reads as one solid shape instead of a string of beads. *color_at* takes 0..1 along the length, which is how the tail gets its cream tip. """ last = len(pts) - 1 for i, (x, y, r) in enumerate(pts): ell(d, x, y, r * 2, r * 2, color_at(i / last), outline=None) for side in (1, -1): edge = [] for i, (x, y, r) in enumerate(pts): j = min(i + 1, last) k = max(i - 1, 0) tx, ty = pts[j][0] - pts[k][0], pts[j][1] - pts[k][1] n = math.hypot(tx, ty) or 1e-6 nx, ny = -ty / n, tx / n edge.append((x + nx * r * side, y + ny * r * side)) d.line([(px(x), px(y)) for x, y in edge], fill=C_OUTLINE, width=_w(OW), joint="curve") x, y, r = pts[last] ell(d, x, y, r * 2, r * 2, color_at(1.0)) def draw_tail(d, p): # Only the last stretch is the cream tip. The haunch hides the first ~half # of the tail, so a generous tip leaves the visible part looking like a # pale blob floating next to the dog rather than its tail. draw_tapered(d, _tail_points(p), lambda t: C_DARK if t < 0.84 else C_FUR) def draw_body(d, p): br = p["breathe"] # haunches (sitting) ell(d, 0.285, 0.795, 0.235, 0.275, C_DARK) ell(d, 0.715, 0.795, 0.235, 0.275, C_DARK) # torso ell(d, 0.50, 0.745 - 0.004 * br, 0.455 + 0.012 * br, 0.395 + 0.014 * br, C_DARK) # front legs for cx in (0.415, 0.585): rrect(d, cx, 0.845, 0.125, 0.215, 0.062, C_FUR) ell(d, cx, 0.925, 0.155, 0.095, C_FUR) # chest / belly blaze ell(d, 0.50, 0.735 - 0.004 * br, 0.275 + 0.008 * br, 0.315 + 0.012 * br, C_FUR) # toes for cx in (0.415, 0.585): for off in (-0.035, 0.0, 0.035): d.arc( [px(cx + off - 0.017), px(0.902), px(cx + off + 0.017), px(0.945)], start=250, end=290, fill=C_FUR_SHADE, width=_w(0.007), ) def draw_collar(d, p): rrect(d, 0.50, 0.585, 0.315, 0.062, 0.031, C_COLLAR) tag = C_GLOW if p.get("tag_glow") else C_TAG ell(d, 0.50, 0.638, 0.082, 0.082, tag) poly(d, bolt_shape(0.50, 0.638, 0.030, 0.052), C_OUTLINE, outline=None) # --- head ------------------------------------------------------------------ # Ear outline in a *local* frame: origin at the base on the skull, +x points # outward (away from the muzzle), +y points up. Keeping it side-agnostic here # and mirroring at draw time avoids sign confusion — an earlier version mixed # the conventions and the ears flattened into a brim whenever they rotated. _EAR_LOCAL = [ (-0.058, -0.038), (0.078, -0.038), (0.092, 0.140), (0.030, 0.248), (-0.038, 0.122), ] def _ear_polygon(side, lean_deg): """Mirror + lean the local ear, returning canvas-space points. *lean_deg* tips the ear away from vertical: 0 is fully perked, larger values relax and eventually droop it out sideways. """ a = math.radians(lean_deg) ca, sa = math.cos(a), math.sin(a) pivot_x = 0.5 + side * (0.5 - EAR_PIVOT[0]) pts = [] for x, y in _EAR_LOCAL: rx = x * ca + y * sa ry = -x * sa + y * ca pts.append((pivot_x + side * rx, EAR_PIVOT[1] - ry)) return pts def draw_ears(d, p): perk = p["ear"] twitch = p.get("ear_twitch", 0.0) for side in (-1, 1): lean = 18.0 * (1.0 - perk) + 44.0 * max(0.0, -perk) if side == 1: lean -= twitch * 12.0 pts = _ear_polygon(side, lean) poly(d, pts, C_DARK) base_mid = ( (pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2, ) inner = [(lerp(base_mid[0], x, 0.60), lerp(base_mid[1], y, 0.64)) for x, y in pts] poly(d, inner, C_INNER_EAR, outline=None) def draw_cap(layer, p): """Slate cap over the top of the head, clipped to the head silhouette.""" mask = Image.new("L", (S, S), 0) ImageDraw.Draw(mask).ellipse( [ px(HEAD_CX - HEAD_W / 2), px(HEAD_CY - HEAD_H / 2), px(HEAD_CX + HEAD_W / 2), px(HEAD_CY + HEAD_H / 2), ], fill=255, ) cap = Image.new("RGBA", (S, S), (0, 0, 0, 0)) dc = ImageDraw.Draw(cap) ell(dc, HEAD_CX, 0.245, 0.54, 0.30, C_DARK, outline=None) # brow dip between the eyes, so the cap reads as a marking not a helmet ell(dc, HEAD_CX, 0.352, 0.155, 0.115, C_FUR, outline=None) cap.putalpha(Image.composite(cap.getchannel("A"), Image.new("L", (S, S), 0), mask)) layer.alpha_composite(cap) def draw_eyes(d, p): blink = p["blink"] lx, ly = 0.383, 0.372 rx, ry = 0.617, 0.372 dx, dy = p.get("look", (0.0, 0.0)) for cx, cy in ((lx, ly), (rx, ry)): if p.get("cross"): for ang in (45, -45): a = math.radians(ang) hx, hy = 0.042 * math.cos(a), 0.042 * math.sin(a) d.line( [px(cx - hx), px(cy - hy), px(cx + hx), px(cy + hy)], fill=C_OUTLINE, width=_w(0.014), ) continue if blink > 0.55: d.arc( [px(cx - 0.052), px(cy - 0.030), px(cx + 0.052), px(cy + 0.040)], start=200, end=340, fill=C_OUTLINE, width=_w(0.013), ) continue h = lerp(0.118, 0.030, blink) ell(d, cx, cy, 0.106, h, C_WHITE) if h > 0.06: ell(d, cx + dx, cy + dy * 0.6, 0.082, min(h - 0.022, 0.092), C_IRIS, outline=None) ell(d, cx + dx, cy + dy * 0.6, 0.046, min(h - 0.045, 0.056), C_PUPIL, outline=None) ell(d, cx + dx - 0.020, cy + dy * 0.6 - 0.024, 0.030, 0.026, C_WHITE, outline=None) # Tan brow dots on the slate cap (the shepherd/doberman marking) rather # than dashes — as lines above the eyes they read as heavy eyelids and # make an idle pet look permanently fed up. raise_ = p.get("brow", 0.0) angry = p.get("brow_angle", 0.0) for side, cx in ((-1, lx), (1, rx)): by = 0.291 - 0.020 * raise_ ell( d, cx + side * 0.006, by + side * angry * 0.020, 0.062, 0.040, C_BROW, outline=None, ) def draw_muzzle(d, p): mouth = p["mouth"] ell(d, 0.50, 0.487, 0.285, 0.195, C_FUR) # nose ell(d, 0.50, 0.440, 0.105, 0.078, C_NOSE, outline=None) ell(d, 0.478, 0.428, 0.030, 0.020, (92, 102, 124, 255), outline=None) if mouth > 0.02: h = 0.030 + 0.085 * mouth w = 0.105 + 0.055 * mouth ell(d, 0.50, 0.500 + h / 2 - 0.008, w, h, C_NOSE) ell(d, 0.50, 0.500 + h * 0.72, w * 0.60, h * 0.52, C_TONGUE, outline=None) else: # closed muzzle: a short philtrum down from the nose into two # downward-bulging curves (PIL arcs run clockwise from 3 o'clock with # y down, so 0->180 is the lower half — the smiling side). d.line([px(0.50), px(0.470), px(0.50), px(0.508)], fill=C_OUTLINE, width=_w(0.011)) for side in (-1, 1): cx = 0.50 + side * 0.032 d.arc( [px(cx - 0.032), px(0.492), px(cx + 0.032), px(0.536)], start=0, end=180, fill=C_OUTLINE, width=_w(0.011), ) def draw_head(layer, p): d = ImageDraw.Draw(layer) draw_ears(d, p) ell(d, HEAD_CX, HEAD_CY, HEAD_W, HEAD_H, C_FUR) draw_cap(layer, p) # blaze poly(d, bolt_shape(0.50, 0.243, 0.088, 0.150), C_BOLT, outline=None) draw_muzzle(d, p) draw_eyes(d, p) # --- extras ---------------------------------------------------------------- def draw_extras(layer, p): d = ImageDraw.Draw(layer) kind = p.get("extras") if kind == "listen": for i in range(3): r = 0.045 + i * 0.036 alpha = int(210 - i * 55) phase = p.get("phase", 0) if (phase + i) % 3 == 0: alpha = min(255, alpha + 45) d.arc( [px(0.845 - r), px(0.235 - r), px(0.845 + r), px(0.235 + r)], start=200, end=340, fill=C_GLOW[:3] + (alpha,), width=_w(0.014), ) elif kind == "think": phase = p.get("phase", 0) for i in range(3): grow = 1.0 if i == phase % 3 else 0.62 ell( layer_d := d, 0.735 + i * 0.072, 0.145 - i * 0.030, 0.040 * grow, 0.040 * grow, C_GLOW, outline=C_OUTLINE, ow=0.008, ) elif kind == "error": poly(d, bolt_shape(0.815, 0.185, 0.070, 0.120), (235, 92, 74, 255)) # --- side view: the walk cycle --------------------------------------------- # The pose above is a front-facing sit, which is right for standing around but # slides like a chess piece the moment the pet actually moves. Walking gets its # own construction: a profile torso, four legs following a paw path, and a head # side-on. Drawn facing RIGHT — ui/pet_window.py mirrors it when he walks left. _GROUND = 0.930 # paw centre while a foot is planted _STRIDE = 0.088 # how far ahead of / behind the pivot a paw reaches _LIFT = 0.080 # peak height of a paw mid-swing _STANCE = 0.62 # fraction of the cycle a foot spends on the ground FRONT_PIVOT = (0.650, 0.620) HIND_PIVOT = (0.315, 0.640) WALK_HEAD = (0.780, 0.370, 0.260, 0.250) # cx, cy, w, h def paw_position(pivot, phase): """Where one paw is at *phase* (0..1) of the cycle. Stance is the half that matters: the foot is planted and travels backwards under the dog at a constant rate. The window advances this cycle by distance travelled rather than by clock, so that backwards travel cancels the forward motion and the feet don't skate. """ phase %= 1.0 if phase < _STANCE: t = phase / _STANCE return pivot[0] + _STRIDE - 2 * _STRIDE * t, _GROUND t = (phase - _STANCE) / (1.0 - _STANCE) return ( pivot[0] - _STRIDE + 2 * _STRIDE * t, _GROUND - _LIFT * math.sin(math.pi * t), ) def draw_leg(d, pivot, paw, fill, bend=0.032, top=0.052, toe=0.030): """A limb from pivot to paw: a bezier through a displaced knee, tapered from thigh to ankle. Tapering matters more than it sounds — a constant-width limb reads as a length of white pipe, and four of them make the dog look like furniture. """ vx, vy = paw[0] - pivot[0], paw[1] - pivot[1] length = math.hypot(vx, vy) or 1e-6 nx, ny = -vy / length, vx / length # perpendicular; points backwards knee = ( (pivot[0] + paw[0]) / 2 + nx * bend, (pivot[1] + paw[1]) / 2 + ny * bend, ) pts = [] for i in range(13): t = i / 12 x = (1 - t) ** 2 * pivot[0] + 2 * (1 - t) * t * knee[0] + t**2 * paw[0] y = (1 - t) ** 2 * pivot[1] + 2 * (1 - t) * t * knee[1] + t**2 * paw[1] pts.append((x, y, lerp(top, toe, t))) draw_tapered(d, pts, lambda _t: fill) ell(d, paw[0], paw[1] + 0.008, 0.098, 0.056, fill) def draw_walk_tail(d, p, dy): """A curled plume over the back. Cubic rather than quadratic: a single control point can only bend one way, which gives a straight tapered tube — a club with a white ball on the end, not a tail. The curl back over the spine is what makes it read. """ wag = p["tail"] base = (0.250, 0.575 + dy) c1 = (0.075, 0.545 + dy - 0.030 * wag) c2 = (0.070, 0.300 + dy - 0.040 * wag) end = (0.215, 0.290 + dy - 0.020 * wag) pts = [] for i in range(29): t = i / 28 u = 1 - t x = u**3 * base[0] + 3 * u**2 * t * c1[0] + 3 * u * t**2 * c2[0] + t**3 * end[0] y = u**3 * base[1] + 3 * u**2 * t * c1[1] + 3 * u * t**2 * c2[1] + t**3 * end[1] pts.append((x, y, lerp(0.076, 0.028, t))) draw_tapered(d, pts, lambda t: C_DARK if t < 0.90 else C_FUR) def draw_torso(d, dy): """Rump + barrel + chest as one silhouette. Drawn in two passes — every shape swollen by the stroke width in the outline colour, then every shape again at true size in the fill. Outlining each piece individually instead leaves the construction arcs showing across the body, which looks like the dog has panel lines. """ shapes = [ ("ell", 0.300, 0.600 + dy, 0.290, 0.300, 0.0), ("rrect", 0.480, 0.585 + dy, 0.520, 0.265, 0.130), ("ell", 0.650, 0.590 + dy, 0.250, 0.280, 0.0), ] grow = 2 * OW for colour, pad in ((C_OUTLINE, grow), (C_DARK, 0.0)): for shape in shapes: kind, cx, cy, w, h, extra = shape if kind == "ell": ell(d, cx, cy, w + pad, h + pad, colour, outline=None) else: rrect(d, cx, cy, w + pad, h + pad, extra + pad / 2, colour, outline=None) # Belly kept small and low: any bigger and it merges with the cream legs # into one white mass with a slate lid. ell(d, 0.490, 0.672 + dy, 0.350, 0.098, C_FUR, outline=None) def draw_walk_head(layer, p, dy): d = ImageDraw.Draw(layer) cx, cy, w, h = WALK_HEAD[0], WALK_HEAD[1] + dy, WALK_HEAD[2], WALK_HEAD[3] # ear first, so the head covers its base bounce = p.get("ear_bounce", 0.0) ear = [ (0.690, cy - 0.030), (0.700, cy - 0.150 - 0.012 * bounce), (0.752, cy - 0.205 - 0.018 * bounce), (0.788, cy - 0.090), ] poly(d, ear, C_DARK) inner = [(lerp(0.735, x, 0.58), lerp(cy - 0.040, y, 0.62)) for x, y in ear] poly(d, inner, C_INNER_EAR, outline=None) # neck into the chest d.line( [px(0.660), px(cy + 0.190), px(0.735), px(cy + 0.080)], fill=C_OUTLINE, width=_w(0.215), joint="curve", ) d.line( [px(0.660), px(cy + 0.190), px(0.735), px(cy + 0.080)], fill=C_DARK, width=_w(0.190), joint="curve", ) ell(d, cx, cy, w, h, C_FUR) # slate cap, clipped to the skull mask = Image.new("L", (S, S), 0) ImageDraw.Draw(mask).ellipse( [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], fill=255 ) cap = Image.new("RGBA", (S, S), (0, 0, 0, 0)) dc = ImageDraw.Draw(cap) ell(dc, cx - 0.010, cy - 0.070, w * 1.02, h * 0.72, C_DARK, outline=None) cap.putalpha(Image.composite(cap.getchannel("A"), Image.new("L", (S, S), 0), mask)) layer.alpha_composite(cap) poly(d, bolt_shape(0.762, cy - 0.088, 0.062, 0.108), C_BOLT, outline=None) # muzzle, nose, mouth ell(d, 0.880, cy + 0.048, 0.145, 0.108, C_FUR) ell(d, 0.950, cy + 0.018, 0.058, 0.048, C_NOSE, outline=None) d.arc( [px(0.885), px(cy + 0.058), px(0.945), px(cy + 0.100)], start=0, end=150, fill=C_OUTLINE, width=_w(0.010), ) # one eye in profile, plus the brow marking ell(d, 0.812, cy - 0.020, 0.092, 0.100, C_WHITE) ell(d, 0.820, cy - 0.020, 0.062, 0.070, C_IRIS, outline=None) ell(d, 0.824, cy - 0.020, 0.036, 0.042, C_PUPIL, outline=None) ell(d, 0.812, cy - 0.040, 0.026, 0.022, C_WHITE, outline=None) ell(d, 0.795, cy - 0.088, 0.055, 0.034, C_BROW, outline=None) # Collar: a band *across* the neck, so it has to run perpendicular to it. # Along the neck it just reads as an orange brick stuck to his chest. collar = [ (px(0.648), px(cy + 0.098)), (px(0.762), px(cy + 0.196)), ] d.line(collar, fill=C_OUTLINE, width=_w(0.070), joint="curve") d.line(collar, fill=C_COLLAR, width=_w(0.050), joint="curve") ell(d, 0.712, cy + 0.196, 0.070, 0.070, C_TAG) poly(d, bolt_shape(0.712, cy + 0.196, 0.025, 0.044), C_OUTLINE, outline=None) def render_walk_frame(p) -> Image.Image: base = Image.new("RGBA", (S, S), (0, 0, 0, 0)) d = ImageDraw.Draw(base) phase = p["phase"] # Two contacts per cycle, so the body dips twice — the give-away that a # walk cycle is weight-bearing rather than a slide. dy = -0.011 * abs(math.sin(2 * math.pi * phase)) head_dy = dy * 0.6 - 0.006 * math.sin(2 * math.pi * phase + 0.7) # Diagonal pairs (a trot): each front leg moves with the opposite hind. far_front = paw_position(FRONT_PIVOT, phase + 0.5) far_hind = paw_position(HIND_PIVOT, phase) near_front = paw_position(FRONT_PIVOT, phase) near_hind = paw_position(HIND_PIVOT, phase + 0.5) draw_walk_tail(d, p, dy) # far side first, in the shade colour, so the near legs read as in front draw_leg(d, (HIND_PIVOT[0], HIND_PIVOT[1] + dy), far_hind, C_FUR_FAR, bend=0.046) draw_leg(d, (FRONT_PIVOT[0], FRONT_PIVOT[1] + dy), far_front, C_FUR_FAR) draw_torso(d, dy) draw_leg(d, (HIND_PIVOT[0], HIND_PIVOT[1] + dy), near_hind, C_FUR, bend=0.046) draw_leg(d, (FRONT_PIVOT[0], FRONT_PIVOT[1] + dy), near_front, C_FUR) draw_walk_head(base, p, head_dy) return base.resize((OUT, OUT), Image.LANCZOS) # --- frame assembly -------------------------------------------------------- def default_pose(**over): p = dict( breathe=0.0, tail=0.0, ear=0.0, ear_twitch=0.0, blink=0.0, mouth=0.0, tilt=0.0, head_dy=0.0, look=(0.0, 0.0), brow=0.0, brow_angle=0.0, cross=False, tag_glow=False, extras=None, phase=0, ) p.update(over) return p def render_frame(p) -> Image.Image: if p.get("pose") == "walk": return render_walk_frame(p) base = Image.new("RGBA", (S, S), (0, 0, 0, 0)) body = Image.new("RGBA", (S, S), (0, 0, 0, 0)) db = ImageDraw.Draw(body) draw_tail(db, p) draw_body(db, p) draw_collar(db, p) base.alpha_composite(body) head = Image.new("RGBA", (S, S), (0, 0, 0, 0)) draw_head(head, p) if p["tilt"]: head = head.rotate( p["tilt"], resample=Image.BICUBIC, center=(px(HEAD_CX), px(NECK_Y)) ) dy = int(px(p["head_dy"])) if dy: shifted = Image.new("RGBA", (S, S), (0, 0, 0, 0)) shifted.alpha_composite(head, (0, dy)) head = shifted base.alpha_composite(head) draw_extras(base, p) return base.resize((OUT, OUT), Image.LANCZOS) def frames_for(state: str) -> list[dict]: if state == "idle": # Eight frames, all distinct. The old version drove breathing on # sin(2*pi*t) and the tail on sin(4*pi*t), which both cross zero at # i=0 and i=4 — so frame 4 was byte-identical to frame 0 and the loop # was really four frames stored twice. out = [] for i in range(8): t = i / 8 br = math.sin(t * 2 * math.pi + math.pi / 7) out.append( default_pose( breathe=br, head_dy=-0.006 * br, # Three-halves harmonic: never in phase with the breath, so # no two frames of the cycle can coincide. tail=math.sin(t * 3 * math.pi + 0.6), ear_twitch=0.30 if i == 3 else 0.0, # a flick, once a loop blink=1.0 if i == 6 else 0.0, ) ) return out if state == "listening": # Six frames of *orienting*, not idling: ears up, head turning toward # whoever is talking, then settling. The old four had frames 0 and 2 # differing by 0.09 mean pixels — a two-pose animation wearing four. out = [] for i in range(6): t = i / 6 lean = math.sin(t * 2 * math.pi + math.pi / 5) out.append( default_pose( ear=1.0, ear_twitch=0.45 * math.sin(t * 4 * math.pi), tilt=-9 + 4.0 * lean, look=(0.010 * lean, -0.004), brow=1.0, tail=0.7 * math.sin(t * 2 * math.pi + 1.1), head_dy=-0.010 - 0.004 * lean, tag_glow=True, extras="listen", phase=i, ) ) return out if state == "thinking": # The old six moved by a mean of ~1.3 pixels — effectively a still # image. Thinking should *look* like thinking: the head tilts, the eyes # travel as if following a thought, and one ear rotates independently. out = [] for i in range(6): t = i / 6 sway = math.sin(t * 2 * math.pi) out.append( default_pose( ear=0.25 + 0.35 * abs(sway), ear_twitch=0.5 * math.cos(t * 2 * math.pi), tilt=4.0 + 7.0 * sway, # Eyes wander a small circle: the cheapest possible read of # "working something out" and the thing most obviously # missing before. look=(0.026 * math.cos(t * 2 * math.pi), -0.020 + 0.014 * math.sin(t * 2 * math.pi)), brow=0.5 + 0.4 * abs(sway), breathe=0.5 * math.sin(t * 2 * math.pi + 0.9), head_dy=-0.008 * sway, tail=0.35 * math.sin(t * 3 * math.pi), blink=1.0 if i == 4 else 0.0, extras="think", phase=i // 2, ) ) return out if state == "talking": # Ordered by mouth openness — closed at frame 0, widest at the last — # because the window indexes these by the loudness of the audio that is # actually playing (see audio/tts.level_of and PetWindow.set_mouth). # A time-ordered loop cannot be indexed that way, and a mouth that # flaps on a timer is what makes a talking sprite look dubbed. out = [] count = 6 for i in range(count): open_ = i / (count - 1) out.append( default_pose( mouth=0.06 + 0.94 * open_, ear=0.6, head_dy=-0.010 * open_, breathe=open_, tail=0.45 * math.sin(i * 0.9), brow=0.35 * open_, ) ) return out if state == "walk": # 8 frames: two full strides, so the loop lands back on the pose it # started from and the cycle is seamless however it's entered. out = [] for i in range(8): phase = i / 8 out.append( default_pose( pose="walk", phase=phase, tail=math.sin(2 * math.pi * phase), ear_bounce=math.sin(2 * math.pi * phase + 0.9), ) ) return out if state == "error": return [ default_pose(ear=-1.0, cross=True, brow_angle=1.0, mouth=0.35, tail=-0.6, extras="error"), default_pose(ear=-0.85, cross=True, brow_angle=1.0, mouth=0.15, tail=-0.4, head_dy=0.008), ] raise ValueError(state) STATES = ["idle", "listening", "thinking", "talking", "error", "walk"] def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( "--out", type=Path, default=Path(__file__).resolve().parent.parent / "bolt_pet" / "assets" / "sprites", ) ap.add_argument("--states", nargs="*", default=STATES) args = ap.parse_args() for state in args.states: d = args.out / state d.mkdir(parents=True, exist_ok=True) for old in d.glob("*.png"): old.unlink() for i, pose in enumerate(frames_for(state)): render_frame(pose).save(d / f"frame_{i:02d}.png") print(f"{state}: {len(frames_for(state))} frames -> {d}") if __name__ == "__main__": main()