Files
Bolt-Pet/scripts/generate_bolt_sprites.py
themajesticmagician 3a0959f55d Streaming replies and STT, amplitude lip-sync, one place for speaking
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON
endpoint, so the wait is time-to-first-sentence rather than the whole model
call, and Deepgram's live websocket transcribes while you're still talking
instead of uploading the WAV afterwards. Both fall back invisibly — a stream
that fails before anything was said drops to converse(), and a socket that
never opens just means the old one-shot path.

Speaking lived in four near-copies in the controller (a reply, a holding line,
a streamed sentence, a dialogue scene) that had already drifted: one didn't arm
barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an
Utterance describing the policy differences, with collaborators injected so the
whole of it tests without Qt or audio.

The mouth follows the audio rather than a timer: tts.level_of reduces each PCM
frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a
linear map leaves the mouth barely open during normal talking) and that indexes
the talking frames, which the sprite script now draws as an openness ramp.
Offline pyttsx3 has no waveform, so stale levels hand control back to the timed
loop instead of freezing the mouth mid-syllable.

Also: the pet starts where you left it (ignoring positions on monitors that are
no longer connected, since restoring those faithfully is how it ends up
somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that
says what to do about each problem rather than only what's wrong.

tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit
test passed all week while notifications sat unspoken for minutes, the pet said
things twice and [laughing] got read aloud — each an interaction between two
individually-correct units. It drives whole turns against a real HTTP server on
a loopback port, faking only the mic and the speakers. It found a NameError in
the paint path that would have fired on every repaint while talking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:01:06 -06:00

808 lines
28 KiB
Python

"""Draw Bolt — the pet — as per-state PNG frame sequences.
Produces the `assets/sprites/<state>/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()