codepet 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/adapters/skill/SKILL.md +697 -0
- package/bin/codepet.js +384 -0
- package/bin/pet-render.sh +43 -0
- package/core/index.js +206 -0
- package/package.json +45 -0
- package/scripts/animate.py +207 -0
- package/scripts/auto_exp.js +91 -0
- package/scripts/crop_all.py +46 -0
- package/scripts/crop_expressions.py +73 -0
- package/scripts/gen_expressions.py +95 -0
- package/scripts/gen_variants.py +107 -0
- package/scripts/img2ascii.py +41 -0
- package/scripts/img2emoji.py +61 -0
- package/scripts/img2terminal.py +88 -0
- package/scripts/pet_bubble.js +136 -0
- package/scripts/pet_card.py +472 -0
- package/scripts/polaroid.py +103 -0
- package/scripts/popup_pet.sh +29 -0
- package/scripts/render_sprite.py +558 -0
- package/scripts/render_to_image.py +44 -0
- package/scripts/show_all.py +45 -0
- package/scripts/show_all_expressions.py +41 -0
- package/scripts/sprites.sh +184 -0
- package/sprites/bababoyi.png +0 -0
- package/sprites/bagayalu.png +0 -0
- package/sprites/bibilabu.png +0 -0
- package/sprites/gugugaga.png +0 -0
- package/sprites/waibibabu.png +0 -0
- package/sprites/wodedaodun.png +0 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CodePet 宠物卡片生成器
|
|
4
|
+
生成一张精美的宠物分享卡片 (PNG 600x800)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
11
|
+
|
|
12
|
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
SPRITE_DIR = os.path.join(SCRIPT_DIR, "..", "sprites")
|
|
16
|
+
PET_JSON = os.path.join(os.path.expanduser("~"), ".codepet", "pet.json")
|
|
17
|
+
OUTPUT_PATH = os.path.join(os.path.expanduser("~"), ".codepet", "card.png")
|
|
18
|
+
|
|
19
|
+
# ─── Design tokens ───────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
CARD_W, CARD_H = 600, 800
|
|
22
|
+
|
|
23
|
+
# ─── Theme system ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
THEMES = {
|
|
26
|
+
"default": {
|
|
27
|
+
"BG_COLOR": (252, 250, 245),
|
|
28
|
+
"ACCENT": (88, 86, 214),
|
|
29
|
+
"ACCENT_LIGHT": (118, 116, 234),
|
|
30
|
+
"BAR_TRACK": (240, 238, 232),
|
|
31
|
+
"TEXT_PRIMARY": (40, 40, 45),
|
|
32
|
+
"TEXT_SECONDARY": (120, 118, 115),
|
|
33
|
+
"TEXT_MUTED": (170, 168, 165),
|
|
34
|
+
"GOLD_STAR": (255, 195, 0),
|
|
35
|
+
"EMPTY_STAR": (210, 208, 204),
|
|
36
|
+
"WATERMARK_COLOR": (200, 198, 194),
|
|
37
|
+
"SPRITE_BG": (255, 255, 255, 255),
|
|
38
|
+
"SPRITE_OUTLINE": (235, 233, 228),
|
|
39
|
+
"DIVIDER": (235, 233, 228),
|
|
40
|
+
"EQUIP_BG": (245, 243, 238),
|
|
41
|
+
"EQUIP_OUTLINE": (230, 228, 222),
|
|
42
|
+
"RARITY_COLORS": {
|
|
43
|
+
"普通": (160, 160, 160),
|
|
44
|
+
"优秀": (72, 180, 97),
|
|
45
|
+
"稀有": (66, 135, 245),
|
|
46
|
+
"史诗": (168, 85, 247),
|
|
47
|
+
"传说": (255, 160, 30),
|
|
48
|
+
},
|
|
49
|
+
"STAT_COLORS": {
|
|
50
|
+
"调试力": (66, 135, 245),
|
|
51
|
+
"耐心值": (72, 180, 97),
|
|
52
|
+
"混沌值": (247, 85, 85),
|
|
53
|
+
"智慧值": (168, 85, 247),
|
|
54
|
+
"毒舌值": (255, 160, 30),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"dark": {
|
|
58
|
+
"BG_COLOR": (18, 18, 24),
|
|
59
|
+
"ACCENT": (160, 90, 255),
|
|
60
|
+
"ACCENT_LIGHT": (190, 130, 255),
|
|
61
|
+
"BAR_TRACK": (40, 40, 52),
|
|
62
|
+
"TEXT_PRIMARY": (230, 228, 235),
|
|
63
|
+
"TEXT_SECONDARY": (150, 148, 160),
|
|
64
|
+
"TEXT_MUTED": (90, 88, 100),
|
|
65
|
+
"GOLD_STAR": (255, 210, 50),
|
|
66
|
+
"EMPTY_STAR": (55, 55, 65),
|
|
67
|
+
"WATERMARK_COLOR": (65, 63, 75),
|
|
68
|
+
"SPRITE_BG": (28, 28, 38, 255),
|
|
69
|
+
"SPRITE_OUTLINE": (50, 48, 62),
|
|
70
|
+
"DIVIDER": (50, 48, 62),
|
|
71
|
+
"EQUIP_BG": (30, 30, 42),
|
|
72
|
+
"EQUIP_OUTLINE": (55, 53, 68),
|
|
73
|
+
"RARITY_COLORS": {
|
|
74
|
+
"普通": (120, 120, 130),
|
|
75
|
+
"优秀": (60, 210, 100),
|
|
76
|
+
"稀有": (80, 160, 255),
|
|
77
|
+
"史诗": (190, 100, 255),
|
|
78
|
+
"传说": (255, 180, 40),
|
|
79
|
+
},
|
|
80
|
+
"STAT_COLORS": {
|
|
81
|
+
"调试力": (80, 160, 255),
|
|
82
|
+
"耐心值": (60, 210, 100),
|
|
83
|
+
"混沌值": (255, 70, 90),
|
|
84
|
+
"智慧值": (190, 100, 255),
|
|
85
|
+
"毒舌值": (255, 180, 40),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def get_theme(style_name):
|
|
91
|
+
return THEMES.get(style_name, THEMES["default"])
|
|
92
|
+
|
|
93
|
+
# Default values (overridden per-call in generate_card)
|
|
94
|
+
BG_COLOR = (252, 250, 245)
|
|
95
|
+
ACCENT = (88, 86, 214)
|
|
96
|
+
ACCENT_LIGHT = (118, 116, 234)
|
|
97
|
+
BAR_TRACK = (240, 238, 232)
|
|
98
|
+
TEXT_PRIMARY = (40, 40, 45)
|
|
99
|
+
TEXT_SECONDARY = (120, 118, 115)
|
|
100
|
+
TEXT_MUTED = (170, 168, 165)
|
|
101
|
+
GOLD_STAR = (255, 195, 0)
|
|
102
|
+
EMPTY_STAR = (210, 208, 204)
|
|
103
|
+
WATERMARK_COLOR = (200, 198, 194)
|
|
104
|
+
|
|
105
|
+
RARITY_COLORS = {
|
|
106
|
+
"普通": (160, 160, 160),
|
|
107
|
+
"优秀": (72, 180, 97),
|
|
108
|
+
"稀有": (66, 135, 245),
|
|
109
|
+
"史诗": (168, 85, 247),
|
|
110
|
+
"传说": (255, 160, 30),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
STAT_COLORS = {
|
|
114
|
+
"调试力": (66, 135, 245),
|
|
115
|
+
"耐心值": (72, 180, 97),
|
|
116
|
+
"混沌值": (247, 85, 85),
|
|
117
|
+
"智慧值": (168, 85, 247),
|
|
118
|
+
"毒舌值": (255, 160, 30),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ─── Font loading ────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def load_font(size, bold=False):
|
|
124
|
+
"""Try macOS system fonts with CJK support, fallback gracefully."""
|
|
125
|
+
candidates = [
|
|
126
|
+
"/System/Library/Fonts/PingFang.ttc",
|
|
127
|
+
"/System/Library/Fonts/STHeiti Light.ttc",
|
|
128
|
+
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
129
|
+
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
|
130
|
+
"/System/Library/Fonts/Helvetica.ttc",
|
|
131
|
+
]
|
|
132
|
+
# For bold, try index 1 in TTC (usually medium/bold weight)
|
|
133
|
+
font_index = 1 if bold else 0
|
|
134
|
+
for path in candidates:
|
|
135
|
+
if os.path.exists(path):
|
|
136
|
+
try:
|
|
137
|
+
return ImageFont.truetype(path, size, index=font_index)
|
|
138
|
+
except Exception:
|
|
139
|
+
try:
|
|
140
|
+
return ImageFont.truetype(path, size, index=0)
|
|
141
|
+
except Exception:
|
|
142
|
+
continue
|
|
143
|
+
return ImageFont.load_default()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ─── Drawing helpers ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):
|
|
149
|
+
"""Draw a rounded rectangle."""
|
|
150
|
+
x0, y0, x1, y1 = xy
|
|
151
|
+
draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=outline, width=width)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def draw_stat_bar(draw, x, y, w, h, value, max_val, color, label, font_sm, font_xs,
|
|
155
|
+
text_primary=None, text_secondary=None, bar_track=None):
|
|
156
|
+
"""Draw a single stat bar with label and value."""
|
|
157
|
+
_tp = text_primary or TEXT_PRIMARY
|
|
158
|
+
_ts = text_secondary or TEXT_SECONDARY
|
|
159
|
+
_bt = bar_track or BAR_TRACK
|
|
160
|
+
draw.text((x, y), label, fill=_tp, font=font_sm)
|
|
161
|
+
val_text = str(value)
|
|
162
|
+
val_bbox = font_xs.getbbox(val_text)
|
|
163
|
+
val_w = val_bbox[2] - val_bbox[0]
|
|
164
|
+
draw.text((x + w - val_w, y), val_text, fill=_ts, font=font_xs)
|
|
165
|
+
bar_y = y + 26
|
|
166
|
+
draw_rounded_rect(draw, (x, bar_y, x + w, bar_y + h), radius=h // 2, fill=_bt)
|
|
167
|
+
fill_w = max(h, int(w * value / max_val))
|
|
168
|
+
draw_rounded_rect(draw, (x, bar_y, x + fill_w, bar_y + h), radius=h // 2, fill=color)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def draw_exp_bar(draw, x, y, w, h, exp, level, font_xs,
|
|
172
|
+
accent=None, bar_track=None, text_muted=None):
|
|
173
|
+
"""Draw the experience bar."""
|
|
174
|
+
_ac = accent or ACCENT
|
|
175
|
+
_bt = bar_track or BAR_TRACK
|
|
176
|
+
_tm = text_muted or TEXT_MUTED
|
|
177
|
+
exp_needed = 100 * level
|
|
178
|
+
ratio = min(exp / max(exp_needed, 1), 1.0)
|
|
179
|
+
draw_rounded_rect(draw, (x, y, x + w, y + h), radius=h // 2, fill=_bt)
|
|
180
|
+
fill_w = max(h, int(w * ratio))
|
|
181
|
+
draw_rounded_rect(draw, (x, y, x + fill_w, y + h), radius=h // 2, fill=_ac)
|
|
182
|
+
exp_text = f"EXP {exp}/{exp_needed}"
|
|
183
|
+
exp_bbox = font_xs.getbbox(exp_text)
|
|
184
|
+
exp_w = exp_bbox[2] - exp_bbox[0]
|
|
185
|
+
draw.text((x + (w - exp_w) // 2, y + h + 4), exp_text, fill=_tm, font=font_xs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ─── Main card generation ────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def generate_card(style="default"):
|
|
191
|
+
# Load theme
|
|
192
|
+
T = get_theme(style)
|
|
193
|
+
_BG = T["BG_COLOR"]
|
|
194
|
+
_ACCENT = T["ACCENT"]
|
|
195
|
+
_BAR_TRACK = T["BAR_TRACK"]
|
|
196
|
+
_TEXT_PRIMARY = T["TEXT_PRIMARY"]
|
|
197
|
+
_TEXT_SECONDARY = T["TEXT_SECONDARY"]
|
|
198
|
+
_TEXT_MUTED = T["TEXT_MUTED"]
|
|
199
|
+
_GOLD_STAR = T["GOLD_STAR"]
|
|
200
|
+
_EMPTY_STAR = T["EMPTY_STAR"]
|
|
201
|
+
_WATERMARK = T["WATERMARK_COLOR"]
|
|
202
|
+
_SPRITE_BG = T["SPRITE_BG"]
|
|
203
|
+
_SPRITE_OUTLINE = T["SPRITE_OUTLINE"]
|
|
204
|
+
_DIVIDER = T["DIVIDER"]
|
|
205
|
+
_EQUIP_BG = T["EQUIP_BG"]
|
|
206
|
+
_EQUIP_OUTLINE = T["EQUIP_OUTLINE"]
|
|
207
|
+
_RARITY_COLORS = T["RARITY_COLORS"]
|
|
208
|
+
_STAT_COLORS = T["STAT_COLORS"]
|
|
209
|
+
|
|
210
|
+
# Load pet data
|
|
211
|
+
if not os.path.exists(PET_JSON):
|
|
212
|
+
print(f"Error: {PET_JSON} not found. Hatch a pet first!")
|
|
213
|
+
sys.exit(1)
|
|
214
|
+
|
|
215
|
+
with open(PET_JSON, "r", encoding="utf-8") as f:
|
|
216
|
+
pet = json.load(f)
|
|
217
|
+
|
|
218
|
+
name = pet.get("nickname") or pet.get("name", "???")
|
|
219
|
+
character = pet.get("character", "bagayalu")
|
|
220
|
+
rarity = pet.get("rarity", "普通")
|
|
221
|
+
stars = pet.get("stars", 1)
|
|
222
|
+
level = pet.get("level", 1)
|
|
223
|
+
exp = pet.get("exp", 0)
|
|
224
|
+
stats = pet.get("stats", {})
|
|
225
|
+
hat = pet.get("hat")
|
|
226
|
+
accessory = pet.get("accessory")
|
|
227
|
+
|
|
228
|
+
# Load sprite
|
|
229
|
+
sprite_path = os.path.join(SPRITE_DIR, character, "normal.png")
|
|
230
|
+
if not os.path.exists(sprite_path):
|
|
231
|
+
sprite_path = os.path.join(SPRITE_DIR, f"{character}.png")
|
|
232
|
+
if not os.path.exists(sprite_path):
|
|
233
|
+
print(f"Error: Sprite not found for '{character}'")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
sprite = Image.open(sprite_path).convert("RGBA")
|
|
237
|
+
|
|
238
|
+
# Load fonts
|
|
239
|
+
font_name = load_font(36, bold=True)
|
|
240
|
+
font_rarity = load_font(18)
|
|
241
|
+
font_sm = load_font(16)
|
|
242
|
+
font_xs = load_font(13)
|
|
243
|
+
font_level = load_font(14, bold=True)
|
|
244
|
+
font_watermark = load_font(13)
|
|
245
|
+
font_star = load_font(22)
|
|
246
|
+
font_equip = load_font(14)
|
|
247
|
+
|
|
248
|
+
# ─── Create canvas ───────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
card = Image.new("RGBA", (CARD_W, CARD_H), _BG + (255,))
|
|
251
|
+
draw = ImageDraw.Draw(card)
|
|
252
|
+
|
|
253
|
+
# ─── Dark theme: subtle vignette overlay ─────────────────────────────
|
|
254
|
+
|
|
255
|
+
if style == "dark":
|
|
256
|
+
vignette = Image.new("RGBA", (CARD_W, CARD_H), (0, 0, 0, 0))
|
|
257
|
+
vig_draw = ImageDraw.Draw(vignette)
|
|
258
|
+
# Corner darkening
|
|
259
|
+
for i in range(80):
|
|
260
|
+
alpha = int(30 * (1 - i / 80))
|
|
261
|
+
vig_draw.rectangle((0, i, CARD_W, i + 1), fill=(0, 0, 0, alpha))
|
|
262
|
+
vig_draw.rectangle((0, CARD_H - 1 - i, CARD_W, CARD_H - i), fill=(0, 0, 0, alpha))
|
|
263
|
+
card = Image.alpha_composite(card, vignette)
|
|
264
|
+
draw = ImageDraw.Draw(card)
|
|
265
|
+
|
|
266
|
+
# ─── Decorative top band ─────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
for i in range(6):
|
|
269
|
+
alpha = int(180 - i * 30)
|
|
270
|
+
r, g, b = _ACCENT
|
|
271
|
+
draw.rectangle((0, i, CARD_W, i + 1), fill=(r, g, b, alpha))
|
|
272
|
+
|
|
273
|
+
# ─── Dark theme: glow effect behind sprite ───────────────────────────
|
|
274
|
+
|
|
275
|
+
sprite_target_w = 280
|
|
276
|
+
scale = sprite_target_w / sprite.width
|
|
277
|
+
sprite_target_h = int(sprite.height * scale)
|
|
278
|
+
sprite_resized = sprite.resize(
|
|
279
|
+
(sprite_target_w, sprite_target_h), Image.Resampling.NEAREST
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
sprite_x = (CARD_W - sprite_target_w) // 2
|
|
283
|
+
sprite_y = 40
|
|
284
|
+
|
|
285
|
+
if style == "dark":
|
|
286
|
+
# Purple glow behind sprite
|
|
287
|
+
glow = Image.new("RGBA", (sprite_target_w + 80, sprite_target_h + 80), (0, 0, 0, 0))
|
|
288
|
+
glow_draw = ImageDraw.Draw(glow)
|
|
289
|
+
glow_draw.rounded_rectangle(
|
|
290
|
+
(0, 0, sprite_target_w + 79, sprite_target_h + 79),
|
|
291
|
+
radius=30, fill=_ACCENT + (35,)
|
|
292
|
+
)
|
|
293
|
+
glow = glow.filter(ImageFilter.GaussianBlur(radius=20))
|
|
294
|
+
card.paste(glow, (sprite_x - 40, sprite_y - 30), glow)
|
|
295
|
+
draw = ImageDraw.Draw(card)
|
|
296
|
+
else:
|
|
297
|
+
# Light theme shadow
|
|
298
|
+
shadow = Image.new("RGBA", (sprite_target_w + 20, sprite_target_h + 20), (0, 0, 0, 0))
|
|
299
|
+
shadow_draw = ImageDraw.Draw(shadow)
|
|
300
|
+
shadow_draw.rounded_rectangle(
|
|
301
|
+
(0, 0, sprite_target_w + 19, sprite_target_h + 19),
|
|
302
|
+
radius=20, fill=(0, 0, 0, 40)
|
|
303
|
+
)
|
|
304
|
+
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=10))
|
|
305
|
+
card.paste(shadow, (sprite_x - 10, sprite_y + 5), shadow)
|
|
306
|
+
|
|
307
|
+
# Sprite background card
|
|
308
|
+
sprite_bg_pad = 24
|
|
309
|
+
draw_rounded_rect(
|
|
310
|
+
draw,
|
|
311
|
+
(
|
|
312
|
+
sprite_x - sprite_bg_pad,
|
|
313
|
+
sprite_y - sprite_bg_pad + 10,
|
|
314
|
+
sprite_x + sprite_target_w + sprite_bg_pad,
|
|
315
|
+
sprite_y + sprite_target_h + sprite_bg_pad - 5,
|
|
316
|
+
),
|
|
317
|
+
radius=20,
|
|
318
|
+
fill=_SPRITE_BG,
|
|
319
|
+
outline=_SPRITE_OUTLINE,
|
|
320
|
+
width=1,
|
|
321
|
+
)
|
|
322
|
+
card.paste(sprite_resized, (sprite_x, sprite_y), sprite_resized)
|
|
323
|
+
|
|
324
|
+
# ─── Name ────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
y_cursor = sprite_y + sprite_target_h + sprite_bg_pad + 12
|
|
327
|
+
|
|
328
|
+
name_bbox = font_name.getbbox(name)
|
|
329
|
+
name_w = name_bbox[2] - name_bbox[0]
|
|
330
|
+
draw.text(((CARD_W - name_w) // 2, y_cursor), name, fill=_TEXT_PRIMARY, font=font_name)
|
|
331
|
+
y_cursor += 46
|
|
332
|
+
|
|
333
|
+
# ─── Stars & Rarity ──────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
max_stars = 5
|
|
336
|
+
star_str = "★" * stars + "☆" * (max_stars - stars)
|
|
337
|
+
star_bbox = font_star.getbbox(star_str)
|
|
338
|
+
star_w = star_bbox[2] - star_bbox[0]
|
|
339
|
+
|
|
340
|
+
rarity_color = _RARITY_COLORS.get(rarity, _TEXT_SECONDARY)
|
|
341
|
+
rarity_bbox = font_rarity.getbbox(rarity)
|
|
342
|
+
rarity_w = rarity_bbox[2] - rarity_bbox[0]
|
|
343
|
+
|
|
344
|
+
total_w = star_w + 12 + rarity_w
|
|
345
|
+
start_x = (CARD_W - total_w) // 2
|
|
346
|
+
|
|
347
|
+
sx = start_x
|
|
348
|
+
for i, ch in enumerate(star_str):
|
|
349
|
+
color = _GOLD_STAR if i < stars else _EMPTY_STAR
|
|
350
|
+
draw.text((sx, y_cursor), ch, fill=color, font=font_star)
|
|
351
|
+
ch_bbox = font_star.getbbox(ch)
|
|
352
|
+
sx += ch_bbox[2] - ch_bbox[0] + 1
|
|
353
|
+
|
|
354
|
+
# Rarity badge
|
|
355
|
+
badge_x = sx + 12
|
|
356
|
+
badge_y = y_cursor + 2
|
|
357
|
+
badge_pad_x, badge_pad_y = 10, 3
|
|
358
|
+
draw_rounded_rect(
|
|
359
|
+
draw,
|
|
360
|
+
(
|
|
361
|
+
badge_x - badge_pad_x,
|
|
362
|
+
badge_y - badge_pad_y,
|
|
363
|
+
badge_x + rarity_w + badge_pad_x,
|
|
364
|
+
badge_y + (rarity_bbox[3] - rarity_bbox[1]) + badge_pad_y + 2,
|
|
365
|
+
),
|
|
366
|
+
radius=10,
|
|
367
|
+
fill=rarity_color + (30,),
|
|
368
|
+
outline=rarity_color + (80,),
|
|
369
|
+
width=1,
|
|
370
|
+
)
|
|
371
|
+
draw.text((badge_x, badge_y), rarity, fill=rarity_color, font=font_rarity)
|
|
372
|
+
y_cursor += 36
|
|
373
|
+
|
|
374
|
+
# ─── Level & EXP ─────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
level_text = f"Lv.{level}"
|
|
377
|
+
level_bbox = font_level.getbbox(level_text)
|
|
378
|
+
level_w = level_bbox[2] - level_bbox[0]
|
|
379
|
+
|
|
380
|
+
bar_margin = 60
|
|
381
|
+
bar_w = CARD_W - bar_margin * 2 - level_w - 16
|
|
382
|
+
|
|
383
|
+
draw.text((bar_margin, y_cursor), level_text, fill=_ACCENT, font=font_level)
|
|
384
|
+
draw_exp_bar(draw, bar_margin + level_w + 16, y_cursor + 2, bar_w, 12, exp, level, font_xs,
|
|
385
|
+
accent=_ACCENT, bar_track=_BAR_TRACK, text_muted=_TEXT_MUTED)
|
|
386
|
+
y_cursor += 42
|
|
387
|
+
|
|
388
|
+
# ─── Divider ─────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
div_margin = 50
|
|
391
|
+
draw.line(
|
|
392
|
+
(div_margin, y_cursor, CARD_W - div_margin, y_cursor),
|
|
393
|
+
fill=_DIVIDER, width=1,
|
|
394
|
+
)
|
|
395
|
+
y_cursor += 16
|
|
396
|
+
|
|
397
|
+
# ─── Stats ───────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
stat_margin = 50
|
|
400
|
+
stat_w = CARD_W - stat_margin * 2
|
|
401
|
+
stat_order = ["调试力", "耐心值", "混沌值", "智慧值", "毒舌值"]
|
|
402
|
+
|
|
403
|
+
for stat_name in stat_order:
|
|
404
|
+
val = stats.get(stat_name, 0)
|
|
405
|
+
color = _STAT_COLORS.get(stat_name, _ACCENT)
|
|
406
|
+
draw_stat_bar(draw, stat_margin, y_cursor, stat_w, 10, val, 100, color, stat_name, font_sm, font_xs,
|
|
407
|
+
text_primary=_TEXT_PRIMARY, text_secondary=_TEXT_SECONDARY, bar_track=_BAR_TRACK)
|
|
408
|
+
y_cursor += 46
|
|
409
|
+
|
|
410
|
+
# ─── Equipment info ──────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
equip_parts = []
|
|
413
|
+
if hat:
|
|
414
|
+
equip_parts.append(f"🎩 {hat}")
|
|
415
|
+
if accessory:
|
|
416
|
+
equip_parts.append(f"✨ {accessory}")
|
|
417
|
+
|
|
418
|
+
if equip_parts:
|
|
419
|
+
y_cursor += 4
|
|
420
|
+
equip_text = " ".join(equip_parts)
|
|
421
|
+
equip_bbox = font_equip.getbbox(equip_text)
|
|
422
|
+
equip_w = equip_bbox[2] - equip_bbox[0]
|
|
423
|
+
eq_x = (CARD_W - equip_w) // 2
|
|
424
|
+
eq_y = y_cursor
|
|
425
|
+
|
|
426
|
+
draw_rounded_rect(
|
|
427
|
+
draw,
|
|
428
|
+
(eq_x - 16, eq_y - 6, eq_x + equip_w + 16, eq_y + 22),
|
|
429
|
+
radius=12,
|
|
430
|
+
fill=_EQUIP_BG,
|
|
431
|
+
outline=_EQUIP_OUTLINE,
|
|
432
|
+
width=1,
|
|
433
|
+
)
|
|
434
|
+
draw.text((eq_x, eq_y), equip_text, fill=_TEXT_SECONDARY, font=font_equip)
|
|
435
|
+
y_cursor += 36
|
|
436
|
+
|
|
437
|
+
# ─── Watermark ───────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
watermark = "Ai小蓝鲸"
|
|
440
|
+
wm_bbox = font_watermark.getbbox(watermark)
|
|
441
|
+
wm_w = wm_bbox[2] - wm_bbox[0]
|
|
442
|
+
draw.text(
|
|
443
|
+
((CARD_W - wm_w) // 2, CARD_H - 36),
|
|
444
|
+
watermark,
|
|
445
|
+
fill=_WATERMARK,
|
|
446
|
+
font=font_watermark,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# ─── Bottom accent line ──────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
for i in range(4):
|
|
452
|
+
alpha = int(120 - i * 30)
|
|
453
|
+
r, g, b = _ACCENT
|
|
454
|
+
draw.rectangle((0, CARD_H - 1 - i, CARD_W, CARD_H - i), fill=(r, g, b, alpha))
|
|
455
|
+
|
|
456
|
+
# ─── Save & open ─────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
suffix = f"-{style}" if style != "default" else ""
|
|
459
|
+
out_path = os.path.join(os.path.expanduser("~"), ".codepet", f"card{suffix}.png")
|
|
460
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
461
|
+
card_rgb = Image.new("RGB", card.size, _BG)
|
|
462
|
+
card_rgb.paste(card, mask=card.split()[3])
|
|
463
|
+
card_rgb.save(out_path, "PNG", quality=95)
|
|
464
|
+
print(f"Card saved to {out_path}")
|
|
465
|
+
|
|
466
|
+
if sys.platform == "darwin":
|
|
467
|
+
os.system(f'open "{out_path}"')
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
if __name__ == "__main__":
|
|
471
|
+
style = sys.argv[1] if len(sys.argv) > 1 else "default"
|
|
472
|
+
generate_card(style)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""拍立得相框效果 — 逐行慢显示,静态,边框对齐"""
|
|
3
|
+
|
|
4
|
+
import sys, os, json, time, re, wcwidth
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
7
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
8
|
+
from img2terminal import render_image
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
SPRITE_DIR = os.path.join(SCRIPT_DIR, "..", "sprites")
|
|
12
|
+
PET_JSON = os.path.join(os.path.expanduser("~"), ".codepet", "pet.json")
|
|
13
|
+
|
|
14
|
+
SCENE_ZH = {
|
|
15
|
+
'normal': '日常', 'happy': '开心的瞬间', 'sleep': '睡着了',
|
|
16
|
+
'eat': '在吃东西', 'pet': '被摸摸的样子', 'worry': '有点紧张',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_ANSI_RE = re.compile(r'\033\[[0-9;]*m')
|
|
20
|
+
|
|
21
|
+
def display_width(s):
|
|
22
|
+
"""Return the visible column width of *s* after stripping ANSI escapes.
|
|
23
|
+
|
|
24
|
+
Uses wcwidth for accurate terminal column widths:
|
|
25
|
+
- half-block chars (▀▄█) and box-drawing (─│┌┐└┘) = 1 column
|
|
26
|
+
- CJK / fullwidth chars = 2 columns
|
|
27
|
+
- emoji (📸🐋 etc.) = 2 columns
|
|
28
|
+
- control / zero-width chars = 0 columns
|
|
29
|
+
"""
|
|
30
|
+
clean = _ANSI_RE.sub('', s)
|
|
31
|
+
w = 0
|
|
32
|
+
for ch in clean:
|
|
33
|
+
cw = wcwidth.wcwidth(ch)
|
|
34
|
+
if cw < 0: # non-printable / control char
|
|
35
|
+
cw = 0
|
|
36
|
+
w += cw
|
|
37
|
+
return w
|
|
38
|
+
|
|
39
|
+
def pad_to(content, target_width):
|
|
40
|
+
"""Pad *content* with trailing spaces so its visible width equals *target_width*."""
|
|
41
|
+
dw = display_width(content)
|
|
42
|
+
diff = target_width - dw
|
|
43
|
+
if diff > 0:
|
|
44
|
+
return content + ' ' * diff
|
|
45
|
+
return content
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
character = sys.argv[1] if len(sys.argv) > 1 else 'bagayalu'
|
|
49
|
+
scene = sys.argv[2] if len(sys.argv) > 2 else 'normal'
|
|
50
|
+
width = int(sys.argv[3]) if len(sys.argv) > 3 else 40
|
|
51
|
+
|
|
52
|
+
img_path = os.path.join(SPRITE_DIR, character, f"{scene}.png")
|
|
53
|
+
if not os.path.exists(img_path):
|
|
54
|
+
img_path = os.path.join(SPRITE_DIR, f"{character}.png")
|
|
55
|
+
|
|
56
|
+
name = character
|
|
57
|
+
try:
|
|
58
|
+
with open(PET_JSON) as f:
|
|
59
|
+
pet = json.load(f)
|
|
60
|
+
name = pet.get('nickname', pet.get('name', character))
|
|
61
|
+
except:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
scene_zh = SCENE_ZH.get(scene, '一个瞬间')
|
|
65
|
+
img = Image.open(img_path).convert("RGBA")
|
|
66
|
+
pixel_lines = render_image(img, width).splitlines()
|
|
67
|
+
|
|
68
|
+
R = "\033[0m"
|
|
69
|
+
W = "\033[38;2;120;120;120m"
|
|
70
|
+
|
|
71
|
+
max_pw = max(display_width(l) for l in pixel_lines) if pixel_lines else width
|
|
72
|
+
caption = f"📸 刚才抓拍到了{name}{scene_zh}..."
|
|
73
|
+
studio = "🐋 Ai小蓝鲸照相馆"
|
|
74
|
+
inner_w = max(max_pw + 6, display_width(caption) + 6, display_width(studio) + 6)
|
|
75
|
+
border = "─" * inner_w
|
|
76
|
+
|
|
77
|
+
def fl(content=""):
|
|
78
|
+
return f" {W}│{R}{pad_to(content, inner_w)}{W}│{R}"
|
|
79
|
+
|
|
80
|
+
total = len(pixel_lines) + 10
|
|
81
|
+
delay = 7.0 / max(total, 1)
|
|
82
|
+
|
|
83
|
+
# 输出尺寸
|
|
84
|
+
print(f"ROWS:{total + 4} COLS:{inner_w + 8}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
print()
|
|
87
|
+
print(f" {W}┌{border}┐{R}"); sys.stdout.flush(); time.sleep(delay)
|
|
88
|
+
print(fl()); sys.stdout.flush(); time.sleep(delay)
|
|
89
|
+
|
|
90
|
+
for line in pixel_lines:
|
|
91
|
+
print(fl(" " + line)); sys.stdout.flush(); time.sleep(delay)
|
|
92
|
+
|
|
93
|
+
print(fl()); sys.stdout.flush(); time.sleep(delay)
|
|
94
|
+
print(fl()); sys.stdout.flush(); time.sleep(delay)
|
|
95
|
+
print(fl(" " + caption)); sys.stdout.flush(); time.sleep(delay)
|
|
96
|
+
print(fl()); sys.stdout.flush(); time.sleep(delay)
|
|
97
|
+
print(fl(" " + studio)); sys.stdout.flush(); time.sleep(0.3)
|
|
98
|
+
print(fl()); sys.stdout.flush(); time.sleep(0.2)
|
|
99
|
+
print(f" {W}└{border}┘{R}"); sys.stdout.flush()
|
|
100
|
+
print()
|
|
101
|
+
|
|
102
|
+
if __name__ == '__main__':
|
|
103
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 弹出新终端窗口显示拍立得宠物 — 后台运行不阻塞
|
|
3
|
+
CHARACTER=${1:-bagayalu}
|
|
4
|
+
SCENE=${2:-normal}
|
|
5
|
+
POLAROID="$HOME/Projects/codepet/scripts/polaroid.py"
|
|
6
|
+
|
|
7
|
+
# 预计算窗口尺寸
|
|
8
|
+
SIZES=$(python3 "$POLAROID" "$CHARACTER" "$SCENE" 40 2>&1 1>/dev/null | grep "ROWS:")
|
|
9
|
+
ROWS=$(echo "$SIZES" | sed 's/ROWS:\([0-9]*\).*/\1/')
|
|
10
|
+
COLS=$(echo "$SIZES" | sed 's/.*COLS:\([0-9]*\)/\1/')
|
|
11
|
+
ROWS=${ROWS:-35}
|
|
12
|
+
COLS=${COLS:-55}
|
|
13
|
+
ROWS=$((ROWS + 4))
|
|
14
|
+
COLS=$((COLS + 8))
|
|
15
|
+
|
|
16
|
+
# 后台启动,不阻塞调用者
|
|
17
|
+
osascript << EOF &
|
|
18
|
+
tell application "Terminal"
|
|
19
|
+
activate
|
|
20
|
+
do script "export HISTFILE=/dev/null && export BASH_SILENCE_DEPRECATION_WARNING=1 && export PS1='' && clear && python3 '$POLAROID' '$CHARACTER' '$SCENE' 40 2>/dev/null && printf '\\033[?25l' && cat > /dev/null"
|
|
21
|
+
delay 0.3
|
|
22
|
+
set number of rows of front window to $ROWS
|
|
23
|
+
set number of columns of front window to $COLS
|
|
24
|
+
set bounds of front window to {400, 150, $((400 + COLS * 8)), $((150 + ROWS * 16))}
|
|
25
|
+
end tell
|
|
26
|
+
EOF
|
|
27
|
+
|
|
28
|
+
# 立即返回
|
|
29
|
+
exit 0
|