Files
discordbot/blue_archive/gacha_renderer.py
T

337 lines
12 KiB
Python

#this renders the gacha recruitment result screen using pillow, mimics the in-game look##
"""Pillow-based compositor: renders an in-game-style gacha recruitment result screen."""
######################################################################
import asyncio
import functools
import io
import logging
import math
from pathlib import Path
from typing import Optional
import aiohttp
from PIL import Image, ImageDraw, ImageFont
from .data import db
from .constants import (
CANVAS_W, CANVAS_H,
CARD_W, CARD_H, CARD_RADIUS,
GAP_X, GAP_Y, COLS, ROWS,
GRID_W, GRID_H, GRID_X, GRID_Y,
HEADER_Y, FOOTER_Y,
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
RARITY_COLOR, RARITY_BG,
BG_PATH, SPARK_TARGET,
FONT_PATHS,
)
log = logging.getLogger(__name__)
# ── Font helpers ───────────────────────────────────────────────────────────
@functools.lru_cache(maxsize=16)
def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
"""Try to load a nice font; fall back to default. Cached per (size, bold)."""
for fp in FONT_PATHS:
if Path(fp).exists():
try:
return ImageFont.truetype(fp, size)
except Exception:
continue
return ImageFont.load_default()
HEADER_FONT = _get_font(52, bold=True)
FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ─────────────────────────────────────────────────────────
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
"""Download a student's face icon from the joexyz CDN.
Uses the student icon CDN (face/upper-body crop). Falls back to the
skill-portrait (full-body art) if the icon is unavailable.
"""
# 1. Try the face icon
icon_url = db.cdn_icon(student)
try:
async with session.get(icon_url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.read()
return Image.open(io.BytesIO(data)).convert("RGBA")
except Exception:
log.debug("CDN icon failed for %s, trying skill portrait fallback", student["Name"])
# 2. Fallback: skill portrait (full-body character art)
sp_url = db.cdn_skill_portrait(student)
try:
async with session.get(sp_url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGBA")
# Skill portraits are full-body; crop upper portion for card fit
p_w, p_h = img.size
crop_h = int(p_w * 1.20)
if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h))
return img
except Exception:
log.exception("Failed to fetch any portrait for %s", student["Name"])
return None
# ── Drawing helpers ────────────────────────────────────────────────────────
def _star_polygon(cx: int, cy: int, outer_r: int, inner_r: int) -> list[tuple[int, int]]:
"""Generate vertex list for a 5-pointed star centered at (cx, cy).
Uses 10 alternating outer/inner radius points, rotated by pi/5 each step.
"""
points = []
for i in range(10):
angle = math.pi / 2 + i * math.pi / 5 # start from top
r = outer_r if i % 2 == 0 else inner_r
x = cx + r * math.cos(angle)
y = cy - r * math.sin(angle)
points.append((int(x), int(y)))
return points
def _draw_star_icons(
draw: ImageDraw.Draw,
cx: int,
cy: int,
count: int,
color: tuple,
size: int = 7,
) -> None:
"""Draw <count> gold 5-pointed star icons horizontally centered at (cx, cy)."""
outer = size
inner = int(size * 0.38)
spacing = int(size * 2.4)
total_w = (count - 1) * spacing
start_x = cx - total_w // 2
for i in range(count):
pts = _star_polygon(start_x + i * spacing, cy, outer, inner)
# Shadow
draw.polygon([(p[0] + 1, p[1] + 1) for p in pts], fill=(0, 0, 0, 100))
draw.polygon(pts, fill=color)
# ── Sync render helpers (run in thread) ────────────────────────────────────
def _load_background() -> Image.Image:
"""Load or create the render canvas background."""
if BG_PATH.exists():
return Image.open(BG_PATH).convert("RGBA")
return Image.new("RGBA", (CANVAS_W, CANVAS_H), (180, 226, 245))
def _encode_png(canvas: Image.Image) -> io.BytesIO:
"""Encode a Pillow image to optimized PNG bytes."""
output = io.BytesIO()
canvas.convert("RGB").save(output, format="PNG", optimize=True)
output.seek(0)
return output
# ── Main render ────────────────────────────────────────────────────────────
async def render_pull(
pulls: list[dict],
banner_name: str = "Regular Recruitment",
spark_count: int = 0,
) -> io.BytesIO:
"""Render a 10-pull (or single-pull) result as a PNG image.
Args:
pulls: List of student dicts from StudentDB (must have Id, Name, StarGrade).
banner_name: Display name for the banner header.
spark_count: Current spark/pity count to show in the footer.
Returns:
BytesIO containing the rendered PNG image.
"""
# Load background (file I/O off the event loop)
canvas = await asyncio.to_thread(_load_background)
draw = ImageDraw.Draw(canvas)
# ── Header ──────────────────────────────────────────────────────────
_draw_header(draw, banner_name)
# ── Footer / spark ──────────────────────────────────────────────────
_draw_footer(draw, spark_count)
# ── Fetch all portraits ─────────────────────────────────────────────
timeout = aiohttp.ClientTimeout(total=30, connect=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
portrait_tasks = [_fetch_portrait(session, p) for p in pulls]
portraits = await asyncio.gather(*portrait_tasks, return_exceptions=True)
# Unwrap exceptions: failed fetches become None
for i, result in enumerate(portraits):
if isinstance(result, Exception):
log.warning("Portrait %d (%s) failed: %s", pulls[i]["Id"], pulls[i]["Name"], result)
portraits[i] = None
# ── Draw cards in grid ──────────────────────────────────────────────
for idx, (student, portrait) in enumerate(zip(pulls, portraits)):
col = idx % COLS
row = idx // COLS
cx = GRID_X + col * (CARD_W + GAP_X)
cy = GRID_Y + row * (CARD_H + GAP_Y)
_draw_card(canvas, draw, cx, cy, student, portrait)
# ── Encode (CPU-bound — off the event loop) ─────────────────────────
return await asyncio.to_thread(_encode_png, canvas)
def _draw_header(draw: ImageDraw.Draw, banner_name: str) -> None:
"""Draw the 'Recruitment Results' header at the top."""
title = "Recruitment Results"
tw = draw.textlength(title, font=HEADER_FONT)
# Banner name subtitle
subtitle = banner_name
sub_font = _get_font(26, bold=False)
# Shadow + text
draw.text(((CANVAS_W - tw) / 2 + 2, HEADER_Y + 2), title, fill=(0, 0, 0, 100), font=HEADER_FONT)
draw.text(((CANVAS_W - tw) / 2, HEADER_Y), title, fill=(255, 255, 255, 240), font=HEADER_FONT)
# Subtitle
sw = draw.textlength(subtitle, font=sub_font)
draw.text(((CANVAS_W - sw) / 2, HEADER_Y + 60), subtitle, fill=(255, 255, 255, 200), font=sub_font)
# Decorative line under header
line_y = HEADER_Y + 100
draw.line(
[(GRID_X, line_y), (GRID_X + GRID_W, line_y)],
fill=(255, 255, 255, 120),
width=2,
)
def _draw_footer(draw: ImageDraw.Draw, spark_count: int) -> None:
"""Draw spark counter at the bottom."""
text = f"Recruitment Points: {spark_count} / {SPARK_TARGET}"
tw = draw.textlength(text, font=FOOTER_FONT)
draw.text(
((CANVAS_W - tw) / 2 + 1, FOOTER_Y + 1),
text,
fill=(0, 0, 0, 80),
font=FOOTER_FONT,
)
draw.text(
((CANVAS_W - tw) / 2, FOOTER_Y),
text,
fill=(255, 255, 255, 200),
font=FOOTER_FONT,
)
def _draw_card(
canvas: Image.Image,
draw: ImageDraw.Draw,
x: int,
y: int,
student: dict,
portrait: Optional[Image.Image],
) -> None:
"""Draw a single recruitment result card at (x, y)."""
rarity = student["StarGrade"]
color = RARITY_COLOR.get(rarity, (150, 150, 150))
bg = RARITY_BG.get(rarity, (20, 20, 20))
# Card background with rounded corners
draw.rounded_rectangle((x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,))
# Card border
draw.rounded_rectangle(
(x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
)
# ── Star banner strip at top ────────────────────────────────────────
banner_top = y + 1
banner_bottom = y + STAR_BANNER_H
# Semi-transparent dark strip
draw.rounded_rectangle(
(x + 3, banner_top, x + CARD_W - 3, banner_bottom),
radius=CARD_RADIUS - 2,
fill=(0, 0, 0, 80),
)
# Draw star icons centered in the banner
_draw_star_icons(
draw,
x + CARD_W // 2,
y + STAR_BANNER_H // 2,
rarity,
color,
size=8,
)
# ── Portrait area ───────────────────────────────────────────────────
px = x + 6
py = y + PORTRAIT_TOP
pw = CARD_W - 12
ph = PORTRAIT_H
if portrait:
portrait = portrait.copy()
p_w, p_h = portrait.size
scale = min(pw / p_w, ph / p_h)
new_w, new_h = int(p_w * scale), int(p_h * scale)
portrait = portrait.resize((new_w, new_h), Image.LANCZOS)
pox = px + (pw - new_w) // 2
poy = py + (ph - new_h) // 2
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
else:
draw.rounded_rectangle((px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180))
ph_font = _get_font(16)
msg = "No Image"
mw = draw.textlength(msg, font=ph_font)
draw.text((px + (pw - mw) / 2, py + ph / 2 - 10), msg, fill=(150, 150, 150), font=ph_font)
# ── Name strip at bottom ────────────────────────────────────────────
name_y = y + CARD_H - NAME_STRIP_H + 2
draw.rounded_rectangle(
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
radius=CARD_RADIUS - 3,
fill=(0, 0, 0, 150),
)
# Student name
name = student["Name"]
name_font = _get_font(20, bold=True)
nw = draw.textlength(name, font=name_font)
draw.text(
(x + (CARD_W - nw) / 2 + 1, name_y + 2),
name,
fill=(0, 0, 0),
font=name_font,
)
draw.text(
(x + (CARD_W - nw) / 2, name_y + 1),
name,
fill=(255, 255, 255),
font=name_font,
)
# School label
school = student.get("School", "")
if school:
sch_font = _get_font(14)
sw2 = draw.textlength(school, font=sch_font)
draw.text(
(x + (CARD_W - sw2) / 2 + 1, name_y + 23),
school,
fill=(0, 0, 0),
font=sch_font,
)
draw.text(
(x + (CARD_W - sw2) / 2, name_y + 22),
school,
fill=color + (200,),
font=sch_font,
)