3a0959f55d
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>
808 lines
28 KiB
Python
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()
|