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.
@@ -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