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,207 @@
1
+ #!/usr/bin/env python3
2
+ """CodePet 终端动画 — 眨眼、动嘴、摆手"""
3
+
4
+ import os, sys, time, copy
5
+
6
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
7
+ sys.path.insert(0, SCRIPT_DIR)
8
+
9
+ from img2terminal import render_image
10
+ from PIL import Image, ImageDraw
11
+
12
+ SPRITE_DIR = os.path.join(SCRIPT_DIR, "..", "sprites")
13
+ RESET = "\033[0m"
14
+ BOLD = "\033[1m"
15
+ HIDE_CURSOR = "\033[?25l"
16
+ SHOW_CURSOR = "\033[?25h"
17
+
18
+ # ── 每个角色的关键区域(基于裁切后的图片坐标)──
19
+ # 格式: { 'eyes': (x1,y1,x2,y2), 'mouth': (x1,y1,x2,y2), 'arm': (x1,y1,x2,y2) }
20
+ # 用 None 表示没有该部位动画
21
+
22
+ REGIONS = {
23
+ 'bibilabu': {
24
+ 'eyes': (105, 90, 160, 110), # 猫眼睛区域
25
+ 'mouth': (115, 110, 150, 130), # 猫嘴区域
26
+ },
27
+ 'bagayalu': {
28
+ 'eyes': (100, 100, 200, 130), # 水豚眼
29
+ 'mouth': (120, 140, 200, 175), # 水豚嘴
30
+ },
31
+ 'wodedaodun': {
32
+ 'eyes': (60, 80, 140, 110), # 柴犬眼
33
+ 'mouth': (80, 115, 150, 145), # 柴犬嘴
34
+ },
35
+ 'bababoyi': {
36
+ 'eyes': (60, 80, 220, 130), # 猫球大眼
37
+ 'mouth': (110, 130, 170, 155), # 猫球嘴
38
+ },
39
+ 'waibibabu': {
40
+ 'eyes': (100, 75, 190, 100), # 壮汉眼
41
+ 'mouth': (95, 115, 195, 155), # 壮汉嘴/胡子
42
+ 'arm': (50, 170, 250, 250), # 壮汉手臂
43
+ },
44
+ 'gugugaga': {
45
+ 'eyes': (80, 120, 210, 155), # 刘海猫眼
46
+ 'mouth': (120, 160, 185, 185), # 刘海猫嘴
47
+ },
48
+ }
49
+
50
+ def make_blink_frame(img, eye_region):
51
+ """眨眼:用眼睛区域的平均肤色画一条横线盖住眼睛"""
52
+ frame = img.copy()
53
+ x1, y1, x2, y2 = eye_region
54
+ # 取眼睛周围的肤色(眼睛上方几像素)
55
+ sample_y = max(0, y1 - 5)
56
+ colors = []
57
+ for x in range(x1, min(x2, frame.width)):
58
+ px = frame.getpixel((x, sample_y))
59
+ if len(px) >= 3:
60
+ colors.append(px[:3])
61
+ if not colors:
62
+ return frame
63
+ avg = tuple(sum(c[i] for c in colors) // len(colors) for i in range(3))
64
+
65
+ draw = ImageDraw.Draw(frame)
66
+ # 画一条略粗的线代表闭眼
67
+ mid_y = (y1 + y2) // 2
68
+ for dy in range(-1, 2):
69
+ draw.line([(x1 + 3, mid_y + dy), (x2 - 3, mid_y + dy)], fill=avg + (255,), width=1)
70
+ return frame
71
+
72
+ def make_mouth_open(img, mouth_region):
73
+ """张嘴:把嘴巴区域往下移 2px,露出深色缝隙"""
74
+ frame = img.copy()
75
+ x1, y1, x2, y2 = mouth_region
76
+ # 拷贝嘴巴区域
77
+ mouth = frame.crop((x1, y1, x2, y2))
78
+ # 用嘴巴上方的颜色填充原位置(模拟张开)
79
+ sample_y = max(0, y1 - 2)
80
+ for x in range(x1, min(x2, frame.width)):
81
+ px = frame.getpixel((x, sample_y))
82
+ for y in range(y1, min(y1 + 3, y2)):
83
+ frame.putpixel((x, y), px)
84
+ # 嘴巴下移
85
+ frame.paste(mouth, (x1, y1 + 2))
86
+ return frame
87
+
88
+ def make_arm_move(img, arm_region):
89
+ """摆手:把手臂区域往左/右移 3px"""
90
+ frame = img.copy()
91
+ x1, y1, x2, y2 = arm_region
92
+ arm = frame.crop((x1, y1, x2, y2))
93
+ # 用背景色填原位
94
+ bg_color = (255, 255, 255, 0)
95
+ draw = ImageDraw.Draw(frame)
96
+ draw.rectangle([x1, y1, x2, y2], fill=bg_color)
97
+ # 手臂左移
98
+ frame.paste(arm, (x1 - 3, y1))
99
+ return frame
100
+
101
+ def generate_character_frames(name, img):
102
+ """为每个角色生成动画序列"""
103
+ regions = REGIONS.get(name, {})
104
+ frames = [img] # 帧0: 原图
105
+
106
+ eye_r = regions.get('eyes')
107
+ mouth_r = regions.get('mouth')
108
+ arm_r = regions.get('arm')
109
+
110
+ if name == 'waibibabu':
111
+ # 歪比巴卜: 原图 → 摆手 → 原图 → 眨眼张嘴
112
+ if arm_r:
113
+ frames.append(make_arm_move(img, arm_r))
114
+ frames.append(img.copy())
115
+ if eye_r and mouth_r:
116
+ f = make_blink_frame(img, eye_r)
117
+ f = make_mouth_open(f, mouth_r)
118
+ frames.append(f)
119
+ elif name == 'bababoyi':
120
+ # 巴巴博一: 原图 → 原图 → 原图 → 眨眼(猫头鹰眨眼慢)
121
+ frames.append(img.copy())
122
+ frames.append(img.copy())
123
+ if eye_r:
124
+ frames.append(make_blink_frame(img, eye_r))
125
+ elif name == 'wodedaodun':
126
+ # 我的刀盾: 基本不动,偶尔张嘴(打哈欠)
127
+ frames.append(img.copy())
128
+ frames.append(img.copy())
129
+ if mouth_r:
130
+ frames.append(make_mouth_open(img, mouth_r))
131
+ else:
132
+ # 其他角色: 原图 → 原图 → 眨眼 → 张嘴
133
+ frames.append(img.copy())
134
+ if eye_r:
135
+ frames.append(make_blink_frame(img, eye_r))
136
+ if mouth_r:
137
+ frames.append(make_mouth_open(img, mouth_r))
138
+
139
+ return frames
140
+
141
+ def animate(name, width=45, fps=2, duration=0):
142
+ img_path = os.path.join(SPRITE_DIR, f"{name}.png")
143
+ if not os.path.exists(img_path):
144
+ print(f"图片不存在: {img_path}")
145
+ return
146
+
147
+ names_zh = {
148
+ 'bibilabu': '比比拉布', 'bagayalu': '八嘎呀路',
149
+ 'wodedaodun': '我的刀盾', 'bababoyi': '巴巴博一',
150
+ 'waibibabu': '歪比巴卜', 'gugugaga': '咕咕嘎嘎',
151
+ }
152
+
153
+ img = Image.open(img_path).convert("RGBA")
154
+
155
+ # 生成动画帧(图片级别)
156
+ img_frames = generate_character_frames(name, img)
157
+
158
+ # 预渲染所有帧为终端字符串
159
+ rendered = []
160
+ for f in img_frames:
161
+ rendered.append(render_image(f, width))
162
+
163
+ delay = 1.0 / fps
164
+ frame_count = len(rendered)
165
+ zh = names_zh.get(name, name)
166
+ line_count = len(rendered[0].splitlines())
167
+
168
+ print(HIDE_CURSOR, end="")
169
+ print(f"\n {BOLD}{zh}{RESET}(Ctrl+C 退出)\n")
170
+
171
+ try:
172
+ i = 0
173
+ while True:
174
+ frame = rendered[i % frame_count]
175
+ for line in frame.splitlines():
176
+ print(f" {line}")
177
+ sys.stdout.flush()
178
+ time.sleep(delay)
179
+ print(f"\033[{line_count}A", end="")
180
+ i += 1
181
+ if duration > 0 and i > duration * fps:
182
+ break
183
+ except KeyboardInterrupt:
184
+ pass
185
+ finally:
186
+ for line in rendered[0].splitlines():
187
+ print(f" {line}")
188
+ print(SHOW_CURSOR)
189
+ print()
190
+
191
+ if __name__ == "__main__":
192
+ name_map = {
193
+ '比比拉布': 'bibilabu', '八嘎呀路': 'bagayalu',
194
+ '我的刀盾': 'wodedaodun', '巴巴博一': 'bababoyi',
195
+ '歪比巴卜': 'waibibabu', '咕咕嘎嘎': 'gugugaga',
196
+ }
197
+
198
+ if len(sys.argv) < 2:
199
+ print("用法: python3 animate.py <角色名> [宽度] [fps]")
200
+ print("例: python3 animate.py 歪比巴卜 45 2")
201
+ sys.exit(1)
202
+
203
+ name = name_map.get(sys.argv[1], sys.argv[1])
204
+ width = int(sys.argv[2]) if len(sys.argv) > 2 else 45
205
+ fps = float(sys.argv[3]) if len(sys.argv) > 3 else 1.5
206
+
207
+ animate(name, width, fps)
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * auto_exp.js — PostToolUse hook for Write|Edit events
4
+ *
5
+ * 从 stdin 读取 JSON(Claude hook 传入的 tool_input),
6
+ * 为宠物增加经验值:
7
+ * - 普通代码写入 +5 exp
8
+ * - 包含错误修复关键词 +10 exp
9
+ * 同时更新 lastInteraction 时间戳。
10
+ *
11
+ * Hook 配置示例(settings.json):
12
+ * {
13
+ * "hooks": {
14
+ * "PostToolUse": [
15
+ * {
16
+ * "matcher": "Write|Edit",
17
+ * "command": "cat /dev/stdin | node auto_exp.js"
18
+ * }
19
+ * ]
20
+ * }
21
+ * }
22
+ */
23
+
24
+ const { loadPet, addExp, savePet } = require('../core/index.js');
25
+
26
+ // 错误修复关键词
27
+ const FIX_KEYWORDS = [
28
+ 'fix', 'bug', 'error', 'patch', 'hotfix', 'repair', 'resolve',
29
+ 'crash', 'issue', 'broken', 'typo', 'workaround', 'correc',
30
+ '修复', '修正', '错误', 'debug',
31
+ ];
32
+
33
+ function readStdin() {
34
+ return new Promise((resolve) => {
35
+ let data = '';
36
+ process.stdin.setEncoding('utf-8');
37
+ process.stdin.on('data', (chunk) => { data += chunk; });
38
+ process.stdin.on('end', () => resolve(data));
39
+ // 如果 stdin 不是管道(无数据),500ms 后超时
40
+ if (process.stdin.isTTY) {
41
+ resolve('');
42
+ }
43
+ setTimeout(() => resolve(data), 500);
44
+ });
45
+ }
46
+
47
+ async function main() {
48
+ const pet = loadPet();
49
+ if (!pet) process.exit(0);
50
+
51
+ const raw = await readStdin();
52
+
53
+ let toolInput = {};
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ // hook JSON 可能在 tool_input 字段或顶层
57
+ toolInput = parsed.tool_input || parsed;
58
+ } catch {
59
+ // JSON 解析失败,仍然给基础经验
60
+ }
61
+
62
+ // 判断是否为错误修复
63
+ const content = [
64
+ toolInput.new_string || '',
65
+ toolInput.content || '',
66
+ toolInput.old_string || '',
67
+ toolInput.file_path || '',
68
+ ].join(' ').toLowerCase();
69
+
70
+ const isFix = FIX_KEYWORDS.some((kw) => content.includes(kw));
71
+ const expAmount = isFix ? 10 : 5;
72
+
73
+ // 更新 lastInteraction
74
+ pet.lastInteraction = new Date().toISOString();
75
+
76
+ // 如果宠物在 sleep 或 worry,互动后恢复清醒
77
+ if (pet.mood === 'sleep' || pet.mood === 'worry') {
78
+ pet.mood = '清醒';
79
+ }
80
+
81
+ // 加经验(addExp 内部会 savePet)
82
+ const result = addExp(pet, expAmount);
83
+
84
+ // 升级时输出提示到 stderr
85
+ if (result.leveledUp) {
86
+ const name = pet.nickname || pet.name;
87
+ console.error(`\n🎉 ${name} 升级了!现在是 Lv.${result.newLevel}!`);
88
+ }
89
+ }
90
+
91
+ main().catch(() => process.exit(0));
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """从原图裁切6个角色并转为终端像素画,同时保存单独的PNG"""
3
+
4
+ import os
5
+ from PIL import Image
6
+
7
+ SRC = "/Users/akane/Downloads/20260401160756.jpg"
8
+ OUT_DIR = "/Users/akane/Projects/codepet/sprites"
9
+ os.makedirs(OUT_DIR, exist_ok=True)
10
+
11
+ img = Image.open(SRC)
12
+ W, H = img.size # 1056 x 840
13
+
14
+ # 原图布局: 2行3列 + 底部有文字
15
+ # 第一行: 比比拉布, 八嘎呀路, 我的刀盾 (y: 0~400)
16
+ # 第二行: 巴巴博一, 歪比巴卜, 咕咕嘎嘎 (y: 400~800)
17
+ # 每列约 352px 宽
18
+
19
+ crops = {
20
+ 'bibilabu': (30, 10, 290, 310), # 比比拉布 — 去掉底部文字
21
+ 'bagayalu': (350, 10, 680, 320), # 八嘎呀路
22
+ 'wodedaodun': (720, 50, 1040, 300), # 我的刀盾
23
+ 'bababoyi': (20, 410, 300, 670), # 巴巴博一
24
+ 'waibibabu': (360, 400, 660, 700), # 歪比巴卜
25
+ 'gugugaga': (720, 410, 1040, 690), # 咕咕嘎嘎
26
+ }
27
+
28
+ names_zh = {
29
+ 'bibilabu': '比比拉布',
30
+ 'bagayalu': '八嘎呀路',
31
+ 'wodedaodun': '我的刀盾',
32
+ 'bababoyi': '巴巴博一',
33
+ 'waibibabu': '歪比巴卜',
34
+ 'gugugaga': '咕咕嘎嘎',
35
+ }
36
+
37
+ for name, (x1, y1, x2, y2) in crops.items():
38
+ cropped = img.crop((x1, y1, x2, y2))
39
+ # 保存 PNG
40
+ out_path = os.path.join(OUT_DIR, f"{name}.png")
41
+ cropped.save(out_path)
42
+ print(f"[OK] {names_zh[name]} -> {out_path} ({cropped.size[0]}x{cropped.size[1]})")
43
+
44
+ print(f"\n全部保存到 {OUT_DIR}/")
45
+ print("接下来运行:")
46
+ print(" python3 scripts/img2terminal.py sprites/bibilabu.png 25")
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """从 6 张表情表裁切出 36 张单图,避开方格线和文字"""
3
+
4
+ import os
5
+ from PIL import Image
6
+
7
+ SRC_DIR = "/Users/akane/Projects/codepet/sprites/6"
8
+ OUT_DIR = "/Users/akane/Projects/codepet/sprites"
9
+
10
+ # 文件 → 角色映射
11
+ FILES = {
12
+ "1.png": "bibilabu",
13
+ "2.png": "bagayalu",
14
+ "3.png": "wodedaodun",
15
+ "4.png": "bababoyi",
16
+ "5.png": "waibibabu",
17
+ "6.png": "gugugaga",
18
+ }
19
+
20
+ # 表情顺序:2行3列
21
+ EXPRESSIONS = [
22
+ ["normal", "happy", "sleep"],
23
+ ["eat", "pet", "worry"],
24
+ ]
25
+
26
+ def crop_sheet(src_path, char_id):
27
+ """从一张 2x3 表情表裁切出 6 张单图"""
28
+ img = Image.open(src_path)
29
+ W, H = img.size
30
+
31
+ # 每个格子的大小
32
+ col_w = W // 3
33
+ row_h = H // 2
34
+
35
+ # 大幅内缩:避开边框线和底部英文文字
36
+ # 顶部缩 5%,底部缩 28%(文字+边框),左右各缩 6%
37
+ pad_top = int(row_h * 0.05)
38
+ pad_bottom = int(row_h * 0.28)
39
+ pad_left = int(col_w * 0.06)
40
+ pad_right = int(col_w * 0.06)
41
+
42
+ char_dir = os.path.join(OUT_DIR, char_id)
43
+ os.makedirs(char_dir, exist_ok=True)
44
+
45
+ for row_idx, row_exprs in enumerate(EXPRESSIONS):
46
+ for col_idx, expr in enumerate(row_exprs):
47
+ x1 = col_idx * col_w + pad_left
48
+ y1 = row_idx * row_h + pad_top
49
+ x2 = (col_idx + 1) * col_w - pad_right
50
+ y2 = (row_idx + 1) * row_h - pad_bottom
51
+
52
+ cropped = img.crop((x1, y1, x2, y2))
53
+ out_path = os.path.join(char_dir, f"{expr}.png")
54
+ cropped.save(out_path)
55
+ print(f" ✓ {char_id}/{expr}.png ({cropped.size[0]}x{cropped.size[1]})")
56
+
57
+ print()
58
+
59
+ def main():
60
+ print("\n 🔪 裁切表情表...\n")
61
+ for filename, char_id in FILES.items():
62
+ src = os.path.join(SRC_DIR, filename)
63
+ if not os.path.exists(src):
64
+ print(f" ✗ {filename} 不存在,跳过")
65
+ continue
66
+ print(f" [{char_id}] ← {filename}")
67
+ crop_sheet(src, char_id)
68
+
69
+ print(" ✅ 36 张裁切完成!")
70
+ print(" 查看: ! open ~/Projects/codepet/sprites/\n")
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """用豆包 Seedream API 批量生成 6 角色 × 6 表情 = 36 张图"""
3
+
4
+ import os, json, time, urllib.request, urllib.error
5
+
6
+ ARK_API_KEY = os.environ.get("ARK_API_KEY", "YOUR_API_KEY_HERE")
7
+ API_URL = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
8
+ MODEL = "doubao-seedream-5-0-260128"
9
+ OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "sprites")
10
+
11
+ CHARACTERS = {
12
+ "bibilabu": "比比拉布, a cute cat wearing a yellow banana costume, cat face peeking out from banana peel, small grey paws, meme style",
13
+ "bagayalu": "八嘎呀路, a shiba inu dog sitting upright, golden brown fur, melancholic zen expression, meme painting style",
14
+ "wodedaodun": "我的刀盾, a shiba inu dog lying completely flat on belly like a loaf, brown fur, side view, very flat, meme painting style",
15
+ "bababoyi": "巴巴博一, an extremely round fat cat shaped like a ball, huge dark owl-like eyes, pink nose, grey and cream colored, meme style",
16
+ "waibibabu": "歪比巴卜, a cartoon muscular man with grey baseball cap, thick brown beard, white polo shirt, cartoon illustration style",
17
+ "gugugaga": "咕咕嘎嘎, a round chubby cat with thick dark grey bangs hair covering forehead, white face below bangs, small pink mouth, meme style",
18
+ }
19
+
20
+ EXPRESSIONS = {
21
+ "normal": "calm default expression, looking forward",
22
+ "happy": "very happy, big smile, eyes squinting with joy, sparkles around",
23
+ "sleep": "eyes peacefully closed, sleeping, zzZ floating above head, peaceful",
24
+ "eat": "mouth wide open eating food, cheeks puffed, holding food, enjoying",
25
+ "pet": "being gently petted on head, eyes half-closed in pleasure, blushing cheeks, content",
26
+ "worry": "anxious worried expression, sweat drop on forehead, nervous wide eyes, stressed",
27
+ }
28
+
29
+ def generate_image(prompt, output_path):
30
+ """调用豆包 API 生成图片"""
31
+ data = json.dumps({
32
+ "model": MODEL,
33
+ "prompt": prompt + ", white background, high quality, detailed",
34
+ "response_format": "url",
35
+ "size": "2K",
36
+ "stream": False,
37
+ }).encode('utf-8')
38
+
39
+ req = urllib.request.Request(API_URL, data=data, headers={
40
+ "Content-Type": "application/json",
41
+ "Authorization": f"Bearer {ARK_API_KEY}",
42
+ })
43
+
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=120) as resp:
46
+ result = json.loads(resp.read())
47
+ url = result["data"][0]["url"]
48
+ # 下载图片
49
+ urllib.request.urlretrieve(url, output_path)
50
+ return True
51
+ except Exception as e:
52
+ print(f" ✗ 失败: {e}")
53
+ return False
54
+
55
+ def main():
56
+ total = 0
57
+ success = 0
58
+ failed = []
59
+
60
+ print("\n 🎨 开始生成 36 张角色表情图...\n")
61
+
62
+ for char_id, char_desc in CHARACTERS.items():
63
+ char_dir = os.path.join(OUT_DIR, char_id)
64
+ os.makedirs(char_dir, exist_ok=True)
65
+
66
+ print(f" [{char_id}]")
67
+ for expr_id, expr_desc in EXPRESSIONS.items():
68
+ total += 1
69
+ output_path = os.path.join(char_dir, f"{expr_id}.png")
70
+
71
+ prompt = f"{char_desc}, {expr_desc}"
72
+ print(f" {expr_id}...", end=" ", flush=True)
73
+
74
+ if generate_image(prompt, output_path):
75
+ size_kb = os.path.getsize(output_path) // 1024
76
+ print(f"✓ ({size_kb}KB)")
77
+ success += 1
78
+ else:
79
+ failed.append(f"{char_id}/{expr_id}")
80
+
81
+ # 间隔避免限流
82
+ time.sleep(2)
83
+
84
+ print()
85
+
86
+ print(f" ──────────────────────")
87
+ print(f" 完成: {success}/{total} 成功")
88
+ if failed:
89
+ print(f" 失败: {', '.join(failed)}")
90
+ print(f"\n 查看结果:")
91
+ print(f" ! open {OUT_DIR}")
92
+ print()
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """从基础图片自动生成 6 个场景变体"""
3
+
4
+ import os, sys
5
+ from PIL import Image, ImageEnhance, ImageFilter, ImageDraw, ImageFont
6
+
7
+ SPRITE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "sprites")
8
+
9
+ CHARACTERS = ['bibilabu', 'bagayalu', 'wodedaodun', 'bababoyi', 'waibibabu', 'gugugaga']
10
+
11
+ # 6 个场景
12
+ VARIANTS = {
13
+ 'normal': '普通',
14
+ 'happy': '开心',
15
+ 'sleep': '睡觉',
16
+ 'eat': '吃东西',
17
+ 'pet': '被撸',
18
+ 'worry': '焦虑',
19
+ }
20
+
21
+ def make_happy(img):
22
+ """开心:提亮 + 暖色调"""
23
+ enhancer = ImageEnhance.Brightness(img)
24
+ bright = enhancer.enhance(1.15)
25
+ # 暖色:增加红黄
26
+ r, g, b, a = bright.split()
27
+ r = r.point(lambda x: min(255, int(x * 1.05)))
28
+ g = g.point(lambda x: min(255, int(x * 1.02)))
29
+ return Image.merge('RGBA', (r, g, b, a))
30
+
31
+ def make_sleep(img):
32
+ """睡觉:变暗 + 轻微模糊"""
33
+ enhancer = ImageEnhance.Brightness(img)
34
+ dark = enhancer.enhance(0.8)
35
+ enhancer2 = ImageEnhance.Color(dark)
36
+ desat = enhancer2.enhance(0.7)
37
+ return desat.filter(ImageFilter.GaussianBlur(radius=0.5))
38
+
39
+ def make_eat(img):
40
+ """吃东西:微微歪头(旋转 3 度)"""
41
+ rotated = img.rotate(-3, expand=False, fillcolor=(255, 255, 255, 0))
42
+ # 稍微提亮(吃东西开心)
43
+ enhancer = ImageEnhance.Brightness(rotated)
44
+ return enhancer.enhance(1.08)
45
+
46
+ def make_pet(img):
47
+ """被撸:温暖色调 + 轻微放大"""
48
+ w, h = img.size
49
+ # 轻微放大 5%
50
+ new_w, new_h = int(w * 1.05), int(h * 1.05)
51
+ big = img.resize((new_w, new_h), Image.LANCZOS)
52
+ # 裁回原尺寸(居中)
53
+ left = (new_w - w) // 2
54
+ top = (new_h - h) // 2
55
+ cropped = big.crop((left, top, left + w, top + h))
56
+ # 暖色
57
+ enhancer = ImageEnhance.Color(cropped)
58
+ warm = enhancer.enhance(1.1)
59
+ enhancer2 = ImageEnhance.Brightness(warm)
60
+ return enhancer2.enhance(1.1)
61
+
62
+ def make_worry(img):
63
+ """焦虑:冷色调 + 轻微缩小"""
64
+ # 冷色:增蓝减红
65
+ r, g, b, a = img.split()
66
+ r = r.point(lambda x: int(x * 0.92))
67
+ b = b.point(lambda x: min(255, int(x * 1.08)))
68
+ cold = Image.merge('RGBA', (r, g, b, a))
69
+ # 轻微降低饱和
70
+ enhancer = ImageEnhance.Color(cold)
71
+ return enhancer.enhance(0.85)
72
+
73
+ GENERATORS = {
74
+ 'normal': lambda img: img.copy(),
75
+ 'happy': make_happy,
76
+ 'sleep': make_sleep,
77
+ 'eat': make_eat,
78
+ 'pet': make_pet,
79
+ 'worry': make_worry,
80
+ }
81
+
82
+ def generate_all():
83
+ total = 0
84
+ for char in CHARACTERS:
85
+ base_path = os.path.join(SPRITE_DIR, f"{char}.png")
86
+ if not os.path.exists(base_path):
87
+ print(f" [!] 跳过 {char}:基础图片不存在")
88
+ continue
89
+
90
+ base = Image.open(base_path).convert("RGBA")
91
+ var_dir = os.path.join(SPRITE_DIR, char)
92
+ os.makedirs(var_dir, exist_ok=True)
93
+
94
+ for variant, zh_name in VARIANTS.items():
95
+ gen = GENERATORS[variant]
96
+ result = gen(base)
97
+ out_path = os.path.join(var_dir, f"{variant}.png")
98
+ result.save(out_path)
99
+ total += 1
100
+
101
+ print(f" ✓ {char} — {len(VARIANTS)} 个变体")
102
+
103
+ print(f"\n 共生成 {total} 张图片,保存到 {SPRITE_DIR}/[角色名]/")
104
+
105
+ if __name__ == '__main__':
106
+ print("\n 🎨 生成角色场景变体...\n")
107
+ generate_all()
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ """将角色图片转为小尺寸 ASCII 字符画 — 保证与角色一致"""
3
+
4
+ import sys
5
+ from PIL import Image
6
+
7
+ # 亮度到字符映射(从亮到暗)
8
+ CHARS = " .:-=+*#%@█"
9
+
10
+ def render(img_path, width=20):
11
+ img = Image.open(img_path).convert("RGBA")
12
+ aspect = img.height / img.width
13
+ height = int(width * aspect * 0.5) # 字符高宽比补偿
14
+ img = img.resize((width, height), Image.LANCZOS)
15
+
16
+ lines = []
17
+ for y in range(height):
18
+ line = ""
19
+ for x in range(width):
20
+ r, g, b, a = img.getpixel((x, y))
21
+ if a < 128 or (r > 245 and g > 245 and b > 245):
22
+ line += " "
23
+ else:
24
+ brightness = (r * 299 + g * 587 + b * 114) / 1000
25
+ idx = int(brightness / 255 * (len(CHARS) - 1))
26
+ line += CHARS[len(CHARS) - 1 - idx] # 反转:暗=密字符
27
+
28
+ lines.append(line.rstrip())
29
+
30
+ # 去首尾空行
31
+ while lines and not lines[0].strip():
32
+ lines.pop(0)
33
+ while lines and not lines[-1].strip():
34
+ lines.pop()
35
+
36
+ return '\n'.join(lines)
37
+
38
+ if __name__ == '__main__':
39
+ path = sys.argv[1]
40
+ width = int(sys.argv[2]) if len(sys.argv) > 2 else 20
41
+ print(render(path, width))