LazyHuman10 commited on
Commit
148b3df
·
1 Parent(s): c9ed0b5

Add NPCverse share card renderer

Browse files
Files changed (1) hide show
  1. share_card.py +312 -0
share_card.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PIL-only share card renderer for NPCverse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import textwrap
7
+ from typing import Any
8
+
9
+ from PIL import Image, ImageDraw, ImageFont
10
+
11
+ CARD_W = 820
12
+ CARD_H = 480
13
+ PADDING = 34
14
+
15
+ BG_VOID = "#0d0d1a"
16
+ BG_DARK = "#1a1a2e"
17
+ BG_CARD = "#16213e"
18
+ GOLD = "#c9a227"
19
+ GOLD_DARK = "#8b6914"
20
+ TEXT_GOLD = "#e8d5b7"
21
+ TEXT_MUTED = "#8a7565"
22
+ BORDER_DIM = "#2a2a4a"
23
+
24
+ RARITY_COLORS = {
25
+ "Common": "#9d9d9d",
26
+ "Uncommon": "#1eff00",
27
+ "Rare": "#0070dd",
28
+ "Epic": "#a335ee",
29
+ "Legendary": "#ff8000",
30
+ }
31
+
32
+ STAT_COLORS = {
33
+ "high": "#ff8000",
34
+ "good": "#c9a227",
35
+ "mid": "#4488cc",
36
+ "low": "#777777",
37
+ }
38
+
39
+
40
+ def _load_font(size: int, bold: bool = False, italic: bool = False) -> ImageFont.ImageFont:
41
+ """Load a readable local font, falling back silently to PIL's default."""
42
+ candidates = []
43
+ if bold:
44
+ candidates.extend(["arialbd.ttf", "DejaVuSans-Bold.ttf"])
45
+ elif italic:
46
+ candidates.extend(["ariali.ttf", "DejaVuSerif-Italic.ttf"])
47
+ else:
48
+ candidates.extend(["arial.ttf", "DejaVuSans.ttf"])
49
+
50
+ for candidate in candidates:
51
+ try:
52
+ return ImageFont.truetype(candidate, size)
53
+ except OSError:
54
+ continue
55
+ return ImageFont.load_default()
56
+
57
+
58
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
59
+ """Convert #rrggbb into an RGB tuple."""
60
+ value = hex_color.lstrip("#")
61
+ return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4))
62
+
63
+
64
+ def _truncate(text: Any, limit: int) -> str:
65
+ """Convert to text and truncate with an ellipsis when needed."""
66
+ safe_text = str(text or "").strip()
67
+ if len(safe_text) <= limit:
68
+ return safe_text
69
+ return f"{safe_text[: max(0, limit - 1)].rstrip()}..."
70
+
71
+
72
+ def _stat_color(value: int) -> str:
73
+ """Return the display color for a stat value."""
74
+ if value >= 80:
75
+ return STAT_COLORS["high"]
76
+ if value >= 65:
77
+ return STAT_COLORS["good"]
78
+ if value >= 45:
79
+ return STAT_COLORS["mid"]
80
+ return STAT_COLORS["low"]
81
+
82
+
83
+ def _safe_int(value: Any, default: int = 0) -> int:
84
+ """Convert arbitrary values to ints without raising."""
85
+ try:
86
+ return int(value)
87
+ except (TypeError, ValueError):
88
+ return default
89
+
90
+
91
+ def _draw_decorative_border(draw: ImageDraw.ImageDraw) -> None:
92
+ """Draw the ornamental frame around the card."""
93
+ draw.rectangle(
94
+ [12, 12, CARD_W - 12, CARD_H - 12],
95
+ outline=GOLD_DARK,
96
+ width=2,
97
+ )
98
+ draw.rectangle(
99
+ [20, 20, CARD_W - 20, CARD_H - 20],
100
+ outline=BORDER_DIM,
101
+ width=1,
102
+ )
103
+
104
+ corner = 42
105
+ for x, y, sx, sy in [
106
+ (22, 22, 1, 1),
107
+ (CARD_W - 22, 22, -1, 1),
108
+ (22, CARD_H - 22, 1, -1),
109
+ (CARD_W - 22, CARD_H - 22, -1, -1),
110
+ ]:
111
+ draw.line([(x, y), (x + sx * corner, y)], fill=GOLD, width=2)
112
+ draw.line([(x, y), (x, y + sy * corner)], fill=GOLD, width=2)
113
+ draw.ellipse(
114
+ [x - 3, y - 3, x + 3, y + 3],
115
+ fill=GOLD,
116
+ )
117
+
118
+
119
+ def _draw_rarity_glow(draw: ImageDraw.ImageDraw, rarity: str) -> None:
120
+ """Simulate rarity glow with colored corner triangles."""
121
+ if rarity == "Epic":
122
+ draw.polygon(
123
+ [(CARD_W, 0), (CARD_W - 145, 0), (CARD_W, 145)],
124
+ fill=(163, 53, 238, 48),
125
+ )
126
+ elif rarity == "Legendary":
127
+ color = (255, 128, 0, 62)
128
+ size = 125
129
+ draw.polygon([(0, 0), (size, 0), (0, size)], fill=color)
130
+ draw.polygon([(CARD_W, 0), (CARD_W - size, 0), (CARD_W, size)], fill=color)
131
+ draw.polygon([(0, CARD_H), (size, CARD_H), (0, CARD_H - size)], fill=color)
132
+ draw.polygon(
133
+ [(CARD_W, CARD_H), (CARD_W - size, CARD_H), (CARD_W, CARD_H - size)],
134
+ fill=color,
135
+ )
136
+
137
+
138
+ def _draw_stat_bars(
139
+ draw: ImageDraw.ImageDraw,
140
+ stats: dict[str, Any],
141
+ font: ImageFont.ImageFont,
142
+ x: int,
143
+ y: int,
144
+ ) -> int:
145
+ """Draw six NPC stat bars and return the next y coordinate."""
146
+ labels = [
147
+ ("strength", "STR"),
148
+ ("intelligence", "INT"),
149
+ ("charisma", "CHA"),
150
+ ("luck", "LCK"),
151
+ ("stealth", "STL"),
152
+ ("chaos", "CHS"),
153
+ ]
154
+ bar_w = 185
155
+ bar_h = 10
156
+ row_h = 24
157
+
158
+ for index, (key, label) in enumerate(labels):
159
+ row_y = y + index * row_h
160
+ value = max(1, min(100, _safe_int(stats.get(key), 50)))
161
+ fill_w = int(bar_w * (value / 100))
162
+ color = _stat_color(value)
163
+
164
+ draw.text((x, row_y - 4), label, fill=TEXT_MUTED, font=font)
165
+ draw.rectangle([x + 42, row_y, x + 42 + bar_w, row_y + bar_h], fill=BG_VOID)
166
+ draw.rectangle(
167
+ [x + 42, row_y, x + 42 + fill_w, row_y + bar_h],
168
+ fill=color,
169
+ )
170
+ draw.rectangle(
171
+ [x + 42, row_y, x + 42 + bar_w, row_y + bar_h],
172
+ outline=BORDER_DIM,
173
+ width=1,
174
+ )
175
+ draw.text((x + 238, row_y - 4), str(value), fill=TEXT_GOLD, font=font)
176
+
177
+ return y + len(labels) * row_h
178
+
179
+
180
+ def _draw_divider(draw: ImageDraw.ImageDraw, center_x: int, y: int, max_radius: int = 240) -> None:
181
+ """Draw a dotted horizontal rule that fades outward from center."""
182
+ gold_rgb = _hex_to_rgb(GOLD)
183
+ for offset in range(0, max_radius, 8):
184
+ alpha_scale = 1 - (offset / max_radius)
185
+ color = (*gold_rgb, int(190 * alpha_scale))
186
+ radius = 2 if offset < 70 else 1
187
+ draw.ellipse(
188
+ [center_x + offset - radius, y - radius, center_x + offset + radius, y + radius],
189
+ fill=color,
190
+ )
191
+ if offset:
192
+ draw.ellipse(
193
+ [center_x - offset - radius, y - radius, center_x - offset + radius, y + radius],
194
+ fill=color,
195
+ )
196
+
197
+
198
+ def _wrap_text(text: str, width: int) -> str:
199
+ """Wrap text for PIL drawing."""
200
+ return "\n".join(textwrap.wrap(str(text or ""), width=width))
201
+
202
+
203
+ def generate_share_card(npc: dict) -> Image.Image:
204
+ """Generate a PNG-ready NPCverse share card for an NPC dictionary."""
205
+ npc = npc or {}
206
+ stats = npc.get("stats") or {}
207
+ passive = npc.get("passive_ability") or {}
208
+ ultimate = npc.get("ultimate") or {}
209
+
210
+ rarity = str(npc.get("rarity", "Common") or "Common")
211
+ rarity_color = RARITY_COLORS.get(rarity, RARITY_COLORS["Common"])
212
+
213
+ img = Image.new("RGBA", (CARD_W, CARD_H), BG_VOID)
214
+ draw = ImageDraw.Draw(img, "RGBA")
215
+
216
+ draw.rectangle([18, 18, CARD_W - 18, CARD_H - 18], fill=BG_CARD)
217
+ _draw_rarity_glow(draw, rarity)
218
+ _draw_decorative_border(draw)
219
+
220
+ title_font = _load_font(34, bold=True)
221
+ subtitle_font = _load_font(18)
222
+ label_font = _load_font(13, bold=True)
223
+ body_font = _load_font(16)
224
+ small_font = _load_font(12)
225
+ quote_font = _load_font(15, italic=True)
226
+
227
+ name = _truncate(npc.get("name", "Unknown Hero"), 28)
228
+ npc_class = _truncate(npc.get("class", "Adventurer"), 24)
229
+ level = _safe_int(npc.get("level"), 1)
230
+ title = _truncate(npc.get("title", "Wanderer"), 40)
231
+ alignment = _truncate(npc.get("alignment", "Unaligned"), 26)
232
+ lore = _wrap_text(_truncate(npc.get("lore", "A mysterious figure steps into legend."), 210), 58)
233
+ faction = _truncate(npc.get("faction", "Unaffiliated"), 28)
234
+ world = _truncate(npc.get("world", "Unknown Realm"), 28)
235
+ opening_line = _truncate(npc.get("opening_line", ""), 80)
236
+
237
+ left_x = PADDING + 8
238
+ right_x = 472
239
+
240
+ draw.text((left_x, 48), name, fill=GOLD, font=title_font)
241
+ draw.text((left_x, 92), title, fill=TEXT_GOLD, font=subtitle_font)
242
+ draw.text((left_x, 122), f"{npc_class} - Lv.{level}", fill=TEXT_MUTED, font=body_font)
243
+
244
+ badge_x = left_x
245
+ badge_y = 154
246
+ draw.rounded_rectangle(
247
+ [badge_x, badge_y, badge_x + 146, badge_y + 30],
248
+ radius=6,
249
+ fill=BG_DARK,
250
+ outline=rarity_color,
251
+ width=2,
252
+ )
253
+ draw.text((badge_x + 12, badge_y + 7), rarity.upper(), fill=rarity_color, font=label_font)
254
+ draw.text((badge_x + 164, badge_y + 7), alignment, fill=TEXT_MUTED, font=label_font)
255
+
256
+ stats_bottom = _draw_stat_bars(draw, stats, small_font, left_x, 214)
257
+ _draw_divider(draw, center_x=left_x + 150, y=stats_bottom + 12, max_radius=150)
258
+
259
+ draw.text((right_x, 58), "PASSIVE", fill=GOLD, font=label_font)
260
+ draw.text(
261
+ (right_x, 78),
262
+ _wrap_text(
263
+ f"{passive.get('name', 'Hidden Spark')}: "
264
+ f"{passive.get('description', 'A quiet power waits beneath the surface.')}",
265
+ 36,
266
+ ),
267
+ fill=TEXT_GOLD,
268
+ font=body_font,
269
+ spacing=4,
270
+ )
271
+
272
+ draw.text((right_x, 164), "ULTIMATE", fill=GOLD, font=label_font)
273
+ draw.text(
274
+ (right_x, 184),
275
+ _wrap_text(
276
+ f"{ultimate.get('name', 'Final Gambit')}: "
277
+ f"{ultimate.get('description', 'Turns desperation into one decisive moment.')}",
278
+ 36,
279
+ ),
280
+ fill=TEXT_GOLD,
281
+ font=body_font,
282
+ spacing=4,
283
+ )
284
+
285
+ draw.text((left_x, 336), "LORE", fill=GOLD, font=label_font)
286
+ draw.text((left_x, 356), lore, fill=TEXT_GOLD, font=body_font, spacing=4)
287
+
288
+ meta_y = 414
289
+ draw.text((left_x, meta_y), f"Faction: {faction}", fill=TEXT_MUTED, font=small_font)
290
+ draw.text((left_x + 250, meta_y), f"World: {world}", fill=TEXT_MUTED, font=small_font)
291
+
292
+ if opening_line:
293
+ draw.text((left_x, meta_y + 22), f"❝ {_truncate(opening_line, 80)}", fill=TEXT_GOLD, font=quote_font)
294
+
295
+ draw.text((right_x, 414), "NPCverse", fill=GOLD, font=subtitle_font)
296
+ draw.text((right_x, 442), "Summoned from a photo", fill=TEXT_MUTED, font=small_font)
297
+
298
+ return img.convert("RGB")
299
+
300
+
301
+ def card_to_bytes(img: Image.Image) -> bytes:
302
+ """Convert a PIL image to PNG bytes."""
303
+ buffer = io.BytesIO()
304
+ img.save(buffer, format="PNG")
305
+ return buffer.getvalue()
306
+
307
+
308
+ # Test with: python share_card.py
309
+ # if __name__ == "__main__":
310
+ # test_npc = {"name": "Test Hero", "class": "Mage", "level": 42, ...}
311
+ # img = generate_share_card(test_npc)
312
+ # img.save("test_card.png")