b121bbba17
petctl gains screen verbs: `jump` (1-based number, name, next/prev/ primary/other, or a direction resolved from real geometry), `monitors`, and `read` for OCR of a monitor's contents. - monitors.py: pure layout model + jump-target resolution. The monitor list is published by PetWindow from QGuiApplication.screens() over a queued signal, so the controller and window agree on what "monitor 2" means; xrandr and Qt order screens differently on the same machine. - screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR). Nothing captures unless the server asks, and the text rides back up the tool-result relay so Bolt can read a screen mid-turn. Both deps optional, soft-failing with a reason. SCREEN_TEXT=false removes it. - Query verbs are answered in controller._handle_command rather than pet_actions.describe(), because their output is the point. - scripts/generate_bolt_sprites.py draws every frame; walk/ is a side-view cycle stepped by distance travelled, not by the animation timer, so the planted paw tracks the window exactly. sprite.py loads it via EXTRA_ANIMATIONS keyed by name, with has() so callers can decline a placeholder blob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
780 lines
26 KiB
Python
780 lines
26 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":
|
|
out = []
|
|
for i in range(8):
|
|
t = i / 8
|
|
br = math.sin(t * 2 * math.pi)
|
|
out.append(
|
|
default_pose(
|
|
breathe=br,
|
|
head_dy=-0.006 * br,
|
|
tail=math.sin(t * 4 * math.pi),
|
|
blink=1.0 if i == 6 else 0.0,
|
|
)
|
|
)
|
|
return out
|
|
if state == "listening":
|
|
out = []
|
|
for i in range(4):
|
|
t = i / 4
|
|
out.append(
|
|
default_pose(
|
|
ear=1.0,
|
|
ear_twitch=0.35 * math.sin(t * 2 * math.pi),
|
|
tilt=-7 + 2.0 * math.sin(t * 2 * math.pi),
|
|
brow=1.0,
|
|
tail=0.5 * math.sin(t * 2 * math.pi),
|
|
head_dy=-0.008,
|
|
tag_glow=True,
|
|
extras="listen",
|
|
phase=i,
|
|
)
|
|
)
|
|
return out
|
|
if state == "thinking":
|
|
out = []
|
|
for i in range(6):
|
|
t = i / 6
|
|
out.append(
|
|
default_pose(
|
|
ear=0.25,
|
|
tilt=6.0,
|
|
look=(0.022, -0.026),
|
|
brow=0.5,
|
|
breathe=0.4 * math.sin(t * 2 * math.pi),
|
|
tail=0.2 * math.sin(t * 2 * math.pi),
|
|
extras="think",
|
|
phase=i // 2,
|
|
)
|
|
)
|
|
return out
|
|
if state == "talking":
|
|
out = []
|
|
for i in range(4):
|
|
t = i / 4
|
|
open_ = (math.sin(t * 2 * math.pi) + 1) / 2
|
|
out.append(
|
|
default_pose(
|
|
mouth=0.25 + 0.75 * open_,
|
|
ear=0.6,
|
|
head_dy=-0.010 * open_,
|
|
breathe=open_,
|
|
tail=math.sin(t * 2 * math.pi + 1.0),
|
|
brow=0.35,
|
|
)
|
|
)
|
|
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()
|