NPCverse / share_card.py
LazyHuman10
Add NPCverse share card renderer
148b3df
Raw
History Blame Contribute Delete
9.92 kB
"""PIL-only share card renderer for NPCverse."""
from __future__ import annotations
import io
import textwrap
from typing import Any
from PIL import Image, ImageDraw, ImageFont
CARD_W = 820
CARD_H = 480
PADDING = 34
BG_VOID = "#0d0d1a"
BG_DARK = "#1a1a2e"
BG_CARD = "#16213e"
GOLD = "#c9a227"
GOLD_DARK = "#8b6914"
TEXT_GOLD = "#e8d5b7"
TEXT_MUTED = "#8a7565"
BORDER_DIM = "#2a2a4a"
RARITY_COLORS = {
"Common": "#9d9d9d",
"Uncommon": "#1eff00",
"Rare": "#0070dd",
"Epic": "#a335ee",
"Legendary": "#ff8000",
}
STAT_COLORS = {
"high": "#ff8000",
"good": "#c9a227",
"mid": "#4488cc",
"low": "#777777",
}
def _load_font(size: int, bold: bool = False, italic: bool = False) -> ImageFont.ImageFont:
"""Load a readable local font, falling back silently to PIL's default."""
candidates = []
if bold:
candidates.extend(["arialbd.ttf", "DejaVuSans-Bold.ttf"])
elif italic:
candidates.extend(["ariali.ttf", "DejaVuSerif-Italic.ttf"])
else:
candidates.extend(["arial.ttf", "DejaVuSans.ttf"])
for candidate in candidates:
try:
return ImageFont.truetype(candidate, size)
except OSError:
continue
return ImageFont.load_default()
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert #rrggbb into an RGB tuple."""
value = hex_color.lstrip("#")
return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4))
def _truncate(text: Any, limit: int) -> str:
"""Convert to text and truncate with an ellipsis when needed."""
safe_text = str(text or "").strip()
if len(safe_text) <= limit:
return safe_text
return f"{safe_text[: max(0, limit - 1)].rstrip()}..."
def _stat_color(value: int) -> str:
"""Return the display color for a stat value."""
if value >= 80:
return STAT_COLORS["high"]
if value >= 65:
return STAT_COLORS["good"]
if value >= 45:
return STAT_COLORS["mid"]
return STAT_COLORS["low"]
def _safe_int(value: Any, default: int = 0) -> int:
"""Convert arbitrary values to ints without raising."""
try:
return int(value)
except (TypeError, ValueError):
return default
def _draw_decorative_border(draw: ImageDraw.ImageDraw) -> None:
"""Draw the ornamental frame around the card."""
draw.rectangle(
[12, 12, CARD_W - 12, CARD_H - 12],
outline=GOLD_DARK,
width=2,
)
draw.rectangle(
[20, 20, CARD_W - 20, CARD_H - 20],
outline=BORDER_DIM,
width=1,
)
corner = 42
for x, y, sx, sy in [
(22, 22, 1, 1),
(CARD_W - 22, 22, -1, 1),
(22, CARD_H - 22, 1, -1),
(CARD_W - 22, CARD_H - 22, -1, -1),
]:
draw.line([(x, y), (x + sx * corner, y)], fill=GOLD, width=2)
draw.line([(x, y), (x, y + sy * corner)], fill=GOLD, width=2)
draw.ellipse(
[x - 3, y - 3, x + 3, y + 3],
fill=GOLD,
)
def _draw_rarity_glow(draw: ImageDraw.ImageDraw, rarity: str) -> None:
"""Simulate rarity glow with colored corner triangles."""
if rarity == "Epic":
draw.polygon(
[(CARD_W, 0), (CARD_W - 145, 0), (CARD_W, 145)],
fill=(163, 53, 238, 48),
)
elif rarity == "Legendary":
color = (255, 128, 0, 62)
size = 125
draw.polygon([(0, 0), (size, 0), (0, size)], fill=color)
draw.polygon([(CARD_W, 0), (CARD_W - size, 0), (CARD_W, size)], fill=color)
draw.polygon([(0, CARD_H), (size, CARD_H), (0, CARD_H - size)], fill=color)
draw.polygon(
[(CARD_W, CARD_H), (CARD_W - size, CARD_H), (CARD_W, CARD_H - size)],
fill=color,
)
def _draw_stat_bars(
draw: ImageDraw.ImageDraw,
stats: dict[str, Any],
font: ImageFont.ImageFont,
x: int,
y: int,
) -> int:
"""Draw six NPC stat bars and return the next y coordinate."""
labels = [
("strength", "STR"),
("intelligence", "INT"),
("charisma", "CHA"),
("luck", "LCK"),
("stealth", "STL"),
("chaos", "CHS"),
]
bar_w = 185
bar_h = 10
row_h = 24
for index, (key, label) in enumerate(labels):
row_y = y + index * row_h
value = max(1, min(100, _safe_int(stats.get(key), 50)))
fill_w = int(bar_w * (value / 100))
color = _stat_color(value)
draw.text((x, row_y - 4), label, fill=TEXT_MUTED, font=font)
draw.rectangle([x + 42, row_y, x + 42 + bar_w, row_y + bar_h], fill=BG_VOID)
draw.rectangle(
[x + 42, row_y, x + 42 + fill_w, row_y + bar_h],
fill=color,
)
draw.rectangle(
[x + 42, row_y, x + 42 + bar_w, row_y + bar_h],
outline=BORDER_DIM,
width=1,
)
draw.text((x + 238, row_y - 4), str(value), fill=TEXT_GOLD, font=font)
return y + len(labels) * row_h
def _draw_divider(draw: ImageDraw.ImageDraw, center_x: int, y: int, max_radius: int = 240) -> None:
"""Draw a dotted horizontal rule that fades outward from center."""
gold_rgb = _hex_to_rgb(GOLD)
for offset in range(0, max_radius, 8):
alpha_scale = 1 - (offset / max_radius)
color = (*gold_rgb, int(190 * alpha_scale))
radius = 2 if offset < 70 else 1
draw.ellipse(
[center_x + offset - radius, y - radius, center_x + offset + radius, y + radius],
fill=color,
)
if offset:
draw.ellipse(
[center_x - offset - radius, y - radius, center_x - offset + radius, y + radius],
fill=color,
)
def _wrap_text(text: str, width: int) -> str:
"""Wrap text for PIL drawing."""
return "\n".join(textwrap.wrap(str(text or ""), width=width))
def generate_share_card(npc: dict) -> Image.Image:
"""Generate a PNG-ready NPCverse share card for an NPC dictionary."""
npc = npc or {}
stats = npc.get("stats") or {}
passive = npc.get("passive_ability") or {}
ultimate = npc.get("ultimate") or {}
rarity = str(npc.get("rarity", "Common") or "Common")
rarity_color = RARITY_COLORS.get(rarity, RARITY_COLORS["Common"])
img = Image.new("RGBA", (CARD_W, CARD_H), BG_VOID)
draw = ImageDraw.Draw(img, "RGBA")
draw.rectangle([18, 18, CARD_W - 18, CARD_H - 18], fill=BG_CARD)
_draw_rarity_glow(draw, rarity)
_draw_decorative_border(draw)
title_font = _load_font(34, bold=True)
subtitle_font = _load_font(18)
label_font = _load_font(13, bold=True)
body_font = _load_font(16)
small_font = _load_font(12)
quote_font = _load_font(15, italic=True)
name = _truncate(npc.get("name", "Unknown Hero"), 28)
npc_class = _truncate(npc.get("class", "Adventurer"), 24)
level = _safe_int(npc.get("level"), 1)
title = _truncate(npc.get("title", "Wanderer"), 40)
alignment = _truncate(npc.get("alignment", "Unaligned"), 26)
lore = _wrap_text(_truncate(npc.get("lore", "A mysterious figure steps into legend."), 210), 58)
faction = _truncate(npc.get("faction", "Unaffiliated"), 28)
world = _truncate(npc.get("world", "Unknown Realm"), 28)
opening_line = _truncate(npc.get("opening_line", ""), 80)
left_x = PADDING + 8
right_x = 472
draw.text((left_x, 48), name, fill=GOLD, font=title_font)
draw.text((left_x, 92), title, fill=TEXT_GOLD, font=subtitle_font)
draw.text((left_x, 122), f"{npc_class} - Lv.{level}", fill=TEXT_MUTED, font=body_font)
badge_x = left_x
badge_y = 154
draw.rounded_rectangle(
[badge_x, badge_y, badge_x + 146, badge_y + 30],
radius=6,
fill=BG_DARK,
outline=rarity_color,
width=2,
)
draw.text((badge_x + 12, badge_y + 7), rarity.upper(), fill=rarity_color, font=label_font)
draw.text((badge_x + 164, badge_y + 7), alignment, fill=TEXT_MUTED, font=label_font)
stats_bottom = _draw_stat_bars(draw, stats, small_font, left_x, 214)
_draw_divider(draw, center_x=left_x + 150, y=stats_bottom + 12, max_radius=150)
draw.text((right_x, 58), "PASSIVE", fill=GOLD, font=label_font)
draw.text(
(right_x, 78),
_wrap_text(
f"{passive.get('name', 'Hidden Spark')}: "
f"{passive.get('description', 'A quiet power waits beneath the surface.')}",
36,
),
fill=TEXT_GOLD,
font=body_font,
spacing=4,
)
draw.text((right_x, 164), "ULTIMATE", fill=GOLD, font=label_font)
draw.text(
(right_x, 184),
_wrap_text(
f"{ultimate.get('name', 'Final Gambit')}: "
f"{ultimate.get('description', 'Turns desperation into one decisive moment.')}",
36,
),
fill=TEXT_GOLD,
font=body_font,
spacing=4,
)
draw.text((left_x, 336), "LORE", fill=GOLD, font=label_font)
draw.text((left_x, 356), lore, fill=TEXT_GOLD, font=body_font, spacing=4)
meta_y = 414
draw.text((left_x, meta_y), f"Faction: {faction}", fill=TEXT_MUTED, font=small_font)
draw.text((left_x + 250, meta_y), f"World: {world}", fill=TEXT_MUTED, font=small_font)
if opening_line:
draw.text((left_x, meta_y + 22), f"❝ {_truncate(opening_line, 80)}", fill=TEXT_GOLD, font=quote_font)
draw.text((right_x, 414), "NPCverse", fill=GOLD, font=subtitle_font)
draw.text((right_x, 442), "Summoned from a photo", fill=TEXT_MUTED, font=small_font)
return img.convert("RGB")
def card_to_bytes(img: Image.Image) -> bytes:
"""Convert a PIL image to PNG bytes."""
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()
# Test with: python share_card.py
# if __name__ == "__main__":
# test_npc = {"name": "Test Hero", "class": "Mage", "level": 42, ...}
# img = generate_share_card(test_npc)
# img.save("test_card.png")