134 lines
5.1 KiB
Python
134 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import struct
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
WIDTH = 256
|
|
HEIGHT = 256
|
|
STATES = ("idle", "listening", "recording", "thinking", "speaking", "error")
|
|
|
|
|
|
def blend(dst: tuple[int, int, int, int], src: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
|
sr, sg, sb, sa = src
|
|
dr, dg, db, da = dst
|
|
alpha = sa / 255
|
|
out_a = int(sa + da * (1 - alpha))
|
|
if out_a == 0:
|
|
return 0, 0, 0, 0
|
|
return (
|
|
int((sr * alpha + dr * (da / 255) * (1 - alpha)) / (out_a / 255)),
|
|
int((sg * alpha + dg * (da / 255) * (1 - alpha)) / (out_a / 255)),
|
|
int((sb * alpha + db * (da / 255) * (1 - alpha)) / (out_a / 255)),
|
|
out_a,
|
|
)
|
|
|
|
|
|
def circle(pixels: list[list[tuple[int, int, int, int]]], cx: int, cy: int, radius: int, color: tuple[int, int, int, int]) -> None:
|
|
for y in range(max(0, cy - radius), min(HEIGHT, cy + radius + 1)):
|
|
for x in range(max(0, cx - radius), min(WIDTH, cx + radius + 1)):
|
|
dist = math.hypot(x - cx, y - cy)
|
|
if dist <= radius:
|
|
shade = max(0.72, 1 - dist / (radius * 2.8))
|
|
shaded = (int(color[0] * shade), int(color[1] * shade), int(color[2] * shade), color[3])
|
|
pixels[y][x] = blend(pixels[y][x], shaded)
|
|
|
|
|
|
def line_circle(pixels: list[list[tuple[int, int, int, int]]], cx: int, cy: int, radius: int, color: tuple[int, int, int, int]) -> None:
|
|
for y in range(max(0, cy - radius - 2), min(HEIGHT, cy + radius + 3)):
|
|
for x in range(max(0, cx - radius - 2), min(WIDTH, cx + radius + 3)):
|
|
dist = math.hypot(x - cx, y - cy)
|
|
if radius - 2 <= dist <= radius + 2:
|
|
pixels[y][x] = blend(pixels[y][x], color)
|
|
|
|
|
|
def rect(pixels: list[list[tuple[int, int, int, int]]], x0: int, y0: int, x1: int, y1: int, color: tuple[int, int, int, int]) -> None:
|
|
for y in range(max(0, y0), min(HEIGHT, y1)):
|
|
for x in range(max(0, x0), min(WIDTH, x1)):
|
|
pixels[y][x] = blend(pixels[y][x], color)
|
|
|
|
|
|
def draw_pet(state: str) -> list[list[tuple[int, int, int, int]]]:
|
|
pixels = [[(0, 0, 0, 0) for _ in range(WIDTH)] for _ in range(HEIGHT)]
|
|
accent = {
|
|
"idle": (78, 142, 154, 255),
|
|
"listening": (60, 122, 214, 255),
|
|
"recording": (224, 90, 64, 255),
|
|
"thinking": (142, 96, 210, 255),
|
|
"speaking": (44, 165, 108, 255),
|
|
"error": (220, 70, 70, 255),
|
|
}[state]
|
|
body = (236, 230, 210, 255)
|
|
shadow = (70, 82, 86, 90)
|
|
circle(pixels, 128, 142, 74, shadow)
|
|
circle(pixels, 128, 132, 70, body)
|
|
circle(pixels, 86, 112, 20, accent)
|
|
circle(pixels, 170, 112, 20, accent)
|
|
circle(pixels, 103, 132, 9, (35, 44, 48, 255))
|
|
circle(pixels, 153, 132, 9, (35, 44, 48, 255))
|
|
circle(pixels, 100, 129, 3, (255, 255, 255, 220))
|
|
circle(pixels, 150, 129, 3, (255, 255, 255, 220))
|
|
rect(pixels, 92, 190, 111, 207, accent)
|
|
rect(pixels, 145, 190, 164, 207, accent)
|
|
if state == "speaking":
|
|
circle(pixels, 128, 158, 15, (45, 48, 52, 255))
|
|
circle(pixels, 128, 163, 7, (255, 130, 150, 255))
|
|
line_circle(pixels, 198, 132, 18, accent)
|
|
line_circle(pixels, 205, 132, 30, accent)
|
|
elif state == "thinking":
|
|
circle(pixels, 128, 158, 12, (45, 48, 52, 255))
|
|
rect(pixels, 116, 153, 140, 160, body)
|
|
circle(pixels, 180, 70, 7, accent)
|
|
circle(pixels, 197, 52, 11, accent)
|
|
circle(pixels, 216, 35, 15, accent)
|
|
elif state == "listening":
|
|
rect(pixels, 116, 157, 140, 163, (45, 48, 52, 255))
|
|
line_circle(pixels, 58, 132, 18, accent)
|
|
line_circle(pixels, 51, 132, 30, accent)
|
|
elif state == "recording":
|
|
circle(pixels, 128, 160, 11, (45, 48, 52, 255))
|
|
circle(pixels, 197, 58, 13, accent)
|
|
elif state == "error":
|
|
rect(pixels, 124, 149, 132, 168, (45, 48, 52, 255))
|
|
circle(pixels, 128, 178, 4, (45, 48, 52, 255))
|
|
rect(pixels, 190, 50, 198, 84, accent)
|
|
circle(pixels, 194, 96, 5, accent)
|
|
else:
|
|
rect(pixels, 114, 157, 142, 162, (45, 48, 52, 255))
|
|
return pixels
|
|
|
|
|
|
def chunk(kind: bytes, data: bytes) -> bytes:
|
|
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
|
|
|
|
|
|
def save_png(path: Path, pixels: list[list[tuple[int, int, int, int]]]) -> None:
|
|
raw = bytearray()
|
|
for row in pixels:
|
|
raw.append(0)
|
|
for rgba in row:
|
|
raw.extend(bytes(rgba))
|
|
data = b"\x89PNG\r\n\x1a\n"
|
|
data += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 6, 0, 0, 0))
|
|
data += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
|
data += chunk(b"IEND", b"")
|
|
path.write_bytes(data)
|
|
|
|
|
|
def main() -> int:
|
|
root = Path("assets/pet")
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
manifest = {"generated_by": "scripts/generate_pet_assets.py", "states": {}}
|
|
for state in STATES:
|
|
name = f"pet-{state}.png"
|
|
save_png(root / name, draw_pet(state))
|
|
manifest["states"][state] = name
|
|
(root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|