Files
discordbot/blue_archive/gacha_renderer.py
T
2026-07-11 21:40:18 +03:00

324 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 io
import logging
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,
FONT_PATHS,
)
log = logging.getLogger(__name__)
# ── Font helpers ───────────────────────────────────────────────────────────
def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
"""Try to load a nice font; fall back to default."""
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=10)) 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=10)) 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.
"""
import math
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)
# ── 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
if BG_PATH.exists():
canvas = Image.open(BG_PATH).convert("RGBA")
else:
canvas = Image.new("RGBA", (CANVAS_W, CANVAS_H), (180, 226, 245))
draw = ImageDraw.Draw(canvas)
# ── Header ──────────────────────────────────────────────────────────
_draw_header(draw, banner_name)
# ── Footer / spark ──────────────────────────────────────────────────
_draw_footer(draw, spark_count)
# ── Fetch all portraits ─────────────────────────────────────────────
timeout = aiohttp.ClientTimeout(total=60, connect=10)
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 ──────────────────────────────────────────────────────────
output = io.BytesIO()
canvas.convert("RGB").save(output, format="PNG", optimize=True)
output.seek(0)
return output
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} / 200"
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,
)