feat(assets): add asset manager for Workcat sprites, animations, and coats

This commit is contained in:
2026-09-08 23:02:50 +02:00
parent 44d6f3b1c5
commit eaae0b96aa
+520
View File
@@ -0,0 +1,520 @@
"""
Asset Manager for Catser.
Handles downloading, caching, rasterizing, and color-tinting all sprite assets
from Workcat (https://workcat.app).
Assets managed:
- Walk frames: 30 SVG polygon paths rasterized with antialiasing.
- Paw frames: 17 WebP images for the strike animation.
- Scruff frame: WebP image used when cat is grabbed and dragged.
- Tail frames: 37 WebP images for idle tail flick.
- Stretch frames: 37 WebP images for idle stretch.
- Poses & Faces: Sit, sleep, open eyes, blink, happy face, mouth, alert '!', and heart ''.
"""
import os
import re
import json
import math
import logging
import urllib.request
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor
from .config import (
GAIT_FRAME_COUNT,
PAW_FRAME_COUNT,
LIFE_MOTION_FRAME_COUNT,
CAT_BOX_WIDTH,
CAT_BOX_HEIGHT,
Coat,
COATS,
)
logger = logging.getLogger("catser.assets")
ASSET_BASE_URL = "https://workcat.app/assets/cat"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
def _download_file(url: str, dest_path: Path) -> bool:
"""
Downloads a file with custom User-Agent to avoid Cloudflare 403 blocks.
Returns True on success, False otherwise.
"""
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and dest_path.stat().st_size > 0:
return True
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=15) as resp:
content = resp.read()
with open(dest_path, "wb") as f:
f.write(content)
logger.info(f"Downloaded {dest_path.name} ({len(content)} bytes)")
return True
except Exception as e:
logger.error(f"Failed to download {url}: {e}")
return False
def _parse_svg_polygon_points(path_str: str) -> List[Tuple[float, float]]:
"""
Extracts (x, y) coordinate pairs from an SVG path consisting of M and L commands.
Used for walk frame silhouettes.
"""
coords = re.findall(r"[-+]?\d*\.?\d+", path_str)
points = []
for i in range(0, len(coords) - 1, 2):
points.append((float(coords[i]), float(coords[i + 1])))
return points
class AssetsManager:
"""
Central asset pipeline for Catser.
Manages downloading, caching, rasterizing, and scaling of all cat sprites.
"""
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144):
self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
self.cat_width = cat_width
self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_WIDTH)))
self.scale = self.cat_width / CAT_BOX_WIDTH
# Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left)
self._cache: Dict[str, Image.Image] = {}
# Raw data loaded from disk
self.walk_path_strings: List[str] = []
self.paw_images: List[Image.Image] = []
self.tail_images: List[Image.Image] = []
self.stretch_images: List[Image.Image] = []
self.scruff_image: Optional[Image.Image] = None
def initialize(self) -> None:
"""Downloads all missing assets and prepares raw frames."""
self.assets_dir.mkdir(parents=True, exist_ok=True)
self._ensure_assets_downloaded()
self._load_raw_assets()
def _ensure_assets_downloaded(self) -> None:
"""Verifies and downloads all animation assets from workcat.app."""
# 1. Walk frames JSON
walk_json_path = self.assets_dir / "walk-frames.json"
_download_file(f"{ASSET_BASE_URL}/walk-frames.json", walk_json_path)
# 2. Paw frames (f001.webp - f017.webp)
paw_dir = self.assets_dir / "paw"
for i in range(1, PAW_FRAME_COUNT + 1):
name = f"f{i:03d}.webp"
_download_file(f"{ASSET_BASE_URL}/paw/{name}", paw_dir / name)
# 3. Scruff frame
_download_file(f"{ASSET_BASE_URL}/scruff.webp", self.assets_dir / "scruff.webp")
# 4. Tail frames (f001.webp - f037.webp)
tail_dir = self.assets_dir / "tail"
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
name = f"f{i:03d}.webp"
_download_file(f"{ASSET_BASE_URL}/tail/{name}", tail_dir / name)
# 5. Stretch frames (f001.webp - f037.webp)
stretch_dir = self.assets_dir / "stretch"
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
name = f"f{i:03d}.webp"
_download_file(f"{ASSET_BASE_URL}/stretch/{name}", stretch_dir / name)
def _load_raw_assets(self) -> None:
"""Loads JSON and WebP images into memory."""
walk_json_path = self.assets_dir / "walk-frames.json"
if walk_json_path.exists():
try:
with open(walk_json_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.walk_path_strings = data.get("frames", [])
except Exception as e:
logger.error(f"Failed to parse walk-frames.json: {e}")
# Paw
paw_dir = self.assets_dir / "paw"
self.paw_images = []
for i in range(1, PAW_FRAME_COUNT + 1):
p = paw_dir / f"f{i:03d}.webp"
if p.exists():
try:
self.paw_images.append(Image.open(p).convert("RGBA"))
except Exception as e:
logger.warning(f"Error opening {p}: {e}")
# Scruff
scruff_path = self.assets_dir / "scruff.webp"
if scruff_path.exists():
try:
self.scruff_image = Image.open(scruff_path).convert("RGBA")
except Exception as e:
logger.warning(f"Error opening scruff: {e}")
# Tail
tail_dir = self.assets_dir / "tail"
self.tail_images = []
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
p = tail_dir / f"f{i:03d}.webp"
if p.exists():
try:
self.tail_images.append(Image.open(p).convert("RGBA"))
except Exception as e:
logger.warning(f"Error opening {p}: {e}")
# Stretch
stretch_dir = self.assets_dir / "stretch"
self.stretch_images = []
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
p = stretch_dir / f"f{i:03d}.webp"
if p.exists():
try:
self.stretch_images.append(Image.open(p).convert("RGBA"))
except Exception as e:
logger.warning(f"Error opening {p}: {e}")
# ==========================================================================
# Frame Rendering & Raster Generation
# ==========================================================================
def get_walk_frame(
self,
frame_index: int,
coat: Coat,
facing_left: bool = False,
face_type: str = "open", # "open", "blink", "happy"
) -> Image.Image:
"""
Renders a walk cycle frame with antialiased polygon vector and face features.
Face types: 'open' (default eyes), 'blink' (lines), 'happy' (upward curves).
"""
frame_index = frame_index % max(len(self.walk_path_strings), 1)
cache_key = f"walk_{frame_index}_{coat.name}_{facing_left}_{face_type}"
if cache_key in self._cache:
return self._cache[cache_key]
target_w = self.cat_width
target_h = self.cat_height
# 4x Supersampling for clean anti-aliased polygon rasterization
SS = 4
canvas_w = target_w * SS
canvas_h = target_h * SS
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Scale from SVG viewBox (150x120) to supersampled canvas
# Note: In cat.js viewBox is 0 0 150 120 and CAT_BOX is 121.2x92.4 with shift
# Shift in cat.js: translate(GAIT_SHIFT_X * unit, GAIT_SHIFT_Y * unit)
unit = (target_w / CAT_BOX_WIDTH) * SS
shift_x = -6.383 * unit
shift_y = 3.76 * unit
scale_x = (target_w / 150.0) * SS * 1.25 # Aspect ratio fitting
scale_y = (target_h / 120.0) * SS * 1.25
if self.walk_path_strings:
pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index])
ss_points = [(p[0] * scale_x + shift_x, p[1] * scale_y + shift_y) for p in pts]
fur_rgba = (*coat.fur_color, 255)
draw.polygon(ss_points, fill=fur_rgba)
# Draw Face Features
# Face coordinates in 150x120 viewBox:
# Eye L: cx=120.4, cy=46.2, r=2.2
# Eye R: cx=140.3, cy=46.2, r=2.2
# Mouth: Q curves around x=127-135, y=50-52
ink_rgba = (*coat.ink_color, 255)
eye_l_cx = 120.4 * scale_x + shift_x
eye_l_cy = 46.2 * scale_y + shift_y
eye_r_cx = 140.3 * scale_x + shift_x
eye_r_cy = 46.2 * scale_y + shift_y
eye_r = 2.2 * scale_x
if face_type == "open":
draw.ellipse(
[eye_l_cx - eye_r, eye_l_cy - eye_r, eye_l_cx + eye_r, eye_l_cy + eye_r],
fill=ink_rgba,
)
draw.ellipse(
[eye_r_cx - eye_r, eye_r_cy - eye_r, eye_r_cx + eye_r, eye_r_cy + eye_r],
fill=ink_rgba,
)
elif face_type == "blink":
# Downward blink lines
w_eye = eye_r * 1.5
draw.line([(eye_l_cx - w_eye, eye_l_cy), (eye_l_cx + w_eye, eye_l_cy)], fill=ink_rgba, width=int(2 * SS))
draw.line([(eye_r_cx - w_eye, eye_r_cy), (eye_r_cx + w_eye, eye_r_cy)], fill=ink_rgba, width=int(2 * SS))
elif face_type == "happy":
# Upward arc happy eyes (^^)
w_eye = eye_r * 1.6
h_eye = eye_r * 1.3
draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
# Mouth (w shape)
mouth_cx = 131.6 * scale_x + shift_x
mouth_cy = 51.0 * scale_y + shift_y
mw = 4.0 * scale_x
mh = 2.5 * scale_y
draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
# Downsample back to target dimensions with high-quality Lanczos resampling
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
if facing_left:
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
self._cache[cache_key] = final_img
return final_img
def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Retrieves and tints a frame from the paw strike animation (f001-f017).
Applies Workcat paw crop geometry:
left: 2.265%, top: -3.083%, width: 106.72%, height: 96.833%
"""
frame_index = max(0, min(frame_index, len(self.paw_images) - 1))
cache_key = f"paw_{frame_index}_{coat.name}_{facing_left}"
if cache_key in self._cache:
return self._cache[cache_key]
raw = self.paw_images[frame_index] if self.paw_images else self._get_fallback_cat(coat)
tinted = self._tint_raster(raw, coat)
# Scale according to paw geometry
pw = int(round(self.cat_width * 1.0672))
ph = int(round(self.cat_height * 0.96833))
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
# Composite onto standard cat canvas with offsets
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
offset_x = int(round(self.cat_width * 0.02265))
offset_y = int(round(self.cat_height * -0.03083))
canvas.paste(resized, (offset_x, offset_y), resized)
if facing_left:
canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
self._cache[cache_key] = canvas
return canvas
def get_scruff_frame(self, coat: Coat) -> Image.Image:
"""
Frame displayed when the cat is dragged by the neck scruff.
Scale factor is 1.29 (Workcat standard).
"""
cache_key = f"scruff_{coat.name}"
if cache_key in self._cache:
return self._cache[cache_key]
raw = self.scruff_image if self.scruff_image else self._get_fallback_cat(coat)
tinted = self._tint_raster(raw, coat)
sw = int(round(self.cat_width * 1.29))
sh = int(round(self.cat_height * 1.29))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
# Center in canvas
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = (self.cat_width - sw) // 2
oy = (self.cat_height - sh) // 2
canvas.paste(resized, (ox, oy), resized)
self._cache[cache_key] = canvas
return canvas
def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Frame from the 37-frame tail flick life motion.
Geometry: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
"""
frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0
cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}"
if cache_key in self._cache:
return self._cache[cache_key]
raw = self.tail_images[frame_index] if self.tail_images else self._get_fallback_cat(coat)
tinted = self._tint_raster(raw, coat)
tw = int(round(self.cat_width * 1.175657))
th = int(round(self.cat_height * 1.153692))
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.150564))
oy = int(round(self.cat_height * -0.142679))
canvas.paste(resized, (ox, oy), resized)
if facing_left:
canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
self._cache[cache_key] = canvas
return canvas
def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Frame from the 37-frame stretch life motion.
Geometry: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
"""
frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0
cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}"
if cache_key in self._cache:
return self._cache[cache_key]
raw = self.stretch_images[frame_index] if self.stretch_images else self._get_fallback_cat(coat)
tinted = self._tint_raster(raw, coat)
sw = int(round(self.cat_width * 1.070263))
sh = int(round(self.cat_height * 1.008865))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.045169))
oy = int(round(self.cat_height * 0.00215))
canvas.paste(resized, (ox, oy), resized)
if facing_left:
canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
self._cache[cache_key] = canvas
return canvas
# ==========================================================================
# Overlays & Indicators
# ==========================================================================
def get_alert_bubble(self, size: int = 36) -> Image.Image:
"""Draws the red '!' alert exclamation indicator with drop shadow."""
cache_key = f"alert_{size}"
if cache_key in self._cache:
return self._cache[cache_key]
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Red alert circle
cx, cy = size // 2, size // 2
r = size // 2 - 2
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 69, 58, 240), outline=(255, 255, 255, 255), width=2)
# White '!' in center
bar_w = max(2, size // 10)
draw.rounded_rectangle([cx - bar_w // 2, cy - r // 2, cx + bar_w // 2, cy + r // 5], radius=2, fill=(255, 255, 255, 255))
dot_r = max(2, size // 12)
draw.ellipse([cx - dot_r, cy + r // 3, cx + dot_r, cy + r // 3 + dot_r * 2], fill=(255, 255, 255, 255))
self._cache[cache_key] = img
return img
def get_heart_particle(self, size: int = 32, alpha: float = 1.0) -> Image.Image:
"""Draws a floating pink heart particle for the petting reaction."""
cache_key = f"heart_{size}_{int(alpha * 100)}"
if cache_key in self._cache:
return self._cache[cache_key]
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
a = int(round(255 * max(0.0, min(1.0, alpha))))
fill_color = (240, 100, 130, a)
# Mathematical heart curve:
# x = 16 sin^3(t)
# y = 13 cos(t) - 5 cos(2t) - 2 cos(3t) - cos(4t)
pts = []
steps = 60
scale = size / 38.0
cx, cy = size / 2.0, size / 2.0 - 2.0 * scale
for s in range(steps):
t = (math.pi * 2 * s) / steps
x = 16 * (math.sin(t) ** 3)
y = -(13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t))
pts.append((cx + x * scale, cy + y * scale))
draw.polygon(pts, fill=fill_color)
self._cache[cache_key] = img
return img
def get_zzz_indicator(self, size: int = 32, step: int = 0) -> Image.Image:
"""Draws floating 'z' snoring indicators."""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
gold = (122, 165, 214, 220)
# Draw a stylish 'z'
margin = size // 5
draw.line([(margin, margin), (size - margin, margin)], fill=gold, width=3)
draw.line([(size - margin, margin), (margin, size - margin)], fill=gold, width=3)
draw.line([(margin, size - margin), (size - margin, size - margin)], fill=gold, width=3)
return img
def get_shadow(self, width: int, lift_ratio: float = 0.0) -> Image.Image:
"""Draws a realistic floor contact shadow that fades and shrinks when lifted."""
sh_w = int(round(width * max(0.3, 0.8 - lift_ratio * 0.3)))
sh_h = max(4, int(round(sh_w * 0.16)))
opacity = max(0.0, min(0.7, 0.7 * (1.0 - lift_ratio * 0.85)))
img = Image.new("RGBA", (sh_w, sh_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
alpha = int(255 * opacity)
draw.ellipse([0, 0, sh_w, sh_h], fill=(0, 0, 0, alpha))
return img.filter(ImageFilter.GaussianBlur(radius=2))
# ==========================================================================
# Private Helpers
# ==========================================================================
def _tint_raster(self, img: Image.Image, coat: Coat) -> Image.Image:
"""
Applies color shifting to raw WebP assets to match the selected coat color.
Maps closely to Workcat's CSS rasterFilter:
- ivory: original
- charcoal: dark grey
- grey: medium neutral grey
- apricot: warm golden amber
- sage: soft muted green
- plum: purple-rose tint
"""
if coat.name == "ivory":
return img
# Split RGBA channels
r, g, b, a = img.split()
target_r, target_g, target_b = coat.fur_color
# Calculate luminance
# L = 0.299 R + 0.587 G + 0.114 B
def tint_pixel(val: int, factor: float) -> int:
return int(round(val * factor))
# Color tint multiplier relative to ivory (232, 229, 218)
rf = target_r / 232.0
gf = target_g / 229.0
bf = target_b / 218.0
r_tint = r.point(lambda v: min(255, int(v * rf)))
g_tint = g.point(lambda v: min(255, int(v * gf)))
b_tint = b.point(lambda v: min(255, int(v * bf)))
return Image.merge("RGBA", (r_tint, g_tint, b_tint, a))
def _get_fallback_cat(self, coat: Coat) -> Image.Image:
"""Fallback simple silhouette if WebP files are not yet downloaded."""
img = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Simple rounded cat shape
draw.rounded_rectangle([10, 20, self.cat_width - 10, self.cat_height - 10], radius=16, fill=(*coat.fur_color, 255))
# Ears
draw.polygon([(20, 25), (35, 5), (50, 25)], fill=(*coat.fur_color, 255))
draw.polygon([(self.cat_width - 50, 25), (self.cat_width - 35, 5), (self.cat_width - 20, 25)], fill=(*coat.fur_color, 255))
return img