vfx-sprite-frame-craft
小 UI 动效卡在美术排期里——一个高亮、一个光环呼吸、一个按钮反馈,往往要三五天起步。这条 skill 教你拿一张 icon PNG,30 分钟用 PIL 程序化产出 12-20 帧 UI 序列帧(脉动 / 涟漪 / 旋转辉光三大套路),
VFX Sprite Frame Craft — UI 序列帧动画程序化生成
基于一张 PNG 源图,用 PIL 程序化生成 12-20 帧 UI/VFX 序列帧。最适合"按钮高亮"、"光环呼吸"、"传送门旋转"、"灵气涟漪"这类游戏 UI 装饰性动效。
核心哲学:先用 9 问澄清效果预期 → 再选三阶梯之一 → 最后产出标准 4 件套。
🚨 Step 0 · 开工 9 问协议(强制,缺一不可往下走)
铁律:用户说"做个动效"时,先把 9 个问题问清楚再动手。每个问题给推荐答案(带理由),用户用 1 句话回 "OK / 调成 X" 即可。不要无脑开工——做完才发现尺寸不对、配色不对、节奏不对,是最大的浪费。
| # | 问题 | 推荐答案模板 | 决定什么 |
|---|---|---|---|
| 1 | 用途场景 | 例:"UI 按钮高亮提示玩家可点击" | 时长、循环要求、是否抢主体 |
| 2 | 源图路径 + 主体形态 | 例:"~/Desktop/btn_xxx.png,实心 icon / 半透明 / 镂空 / 复杂剪影" | mask 策略、alpha_composite 顺序 |
| 3 | 源图 px / 输出画布 px | 推荐:源图 64×64 → 画布 128×128(留 1× 边距给辉光扩散) | 画布尺寸、扩散半径上限 |
| 4 | 效果原型 | 三选一:A 脉动呼吸(α 整体呼吸)/ B 涟漪扩散(环带向外扩)/ C 旋转辉光(自旋 + 双色相位错相) | 技术路径选型 |
| 5 | 配色 | 主色 HEX + 辅色 HEX。常用预设:青 #8CE6FF(清冷)/ 金 #FFDC8C(高级)/ 红 #FF6B6B(警示)/ 紫 #B984FF(神秘)/ 白 #FFFFFF(中性) | RGBA tuple |
| 6 | 循环节奏 | 推荐 2 秒一周期,无缝 loop(首末帧 α=0) | duration ms / FPS |
| 7 | 帧数 | 推荐 12(轻量)/ 18(中等)/ 20-24(流畅)。帧多 = sheet 大 + 文件体积 | FRAMES 常量 |
| 8 | 强度 | 克制(UI 装饰,α≤180)/ 显眼(关键交互,α≤220)/ 炸场(释放反馈,α=240+) | alpha 上限 |
| 9 | 背景需求 | 透明 alpha(贴游戏) + 深色 GIF 预览(验收用)+ 棋盘格 GIF(透明视角) | 输出件数 |
问完直接列一份 "效果设计书"(不超过 10 行)让用户 confirm:
效果设计书:
- 用途:UI 按钮高亮
- 源图:~/Desktop/btn_xxx.png (64×64, 半透明镂空)
- 画布:128×128 (留 22% 边距)
- 原型:B 涟漪扩散(双道错相 0.5 周期)
- 配色:主色 #8CE6FF(灵气青),无辅色
- 节奏:2.4 秒一周期,无缝 loop
- 帧数:12 帧 @5fps
- 强度:克制 (alpha ≤ 200)
- 输出:frames/*.png + sheet_4x3.png + preview.gif + preview_checker.gif
用户回 "OK" → 进 Step 1。回 "调成 X" → 改 1 行重新 confirm。
Step 1 · 三阶梯效果选型
每个原型对应一份完整模板脚本(scripts/ 目录),90% 场景拷贝即用,调参数即可。
阶梯 A · 脉动呼吸(最简,5 分钟出活)
视觉:主体 α 整体随 sin 呼吸(弱→强→弱),适合"待机闪烁"、"被选中高亮"。
核心公式:
t = i / FRAMES # 0.0 ~ 1.0 归一时间
alpha_factor = 0.5 + 0.5 * math.sin(2 * math.pi * t) # 0~1 sin 钟形
# 或更平滑的循环钟形:
alpha_factor = math.sin(math.pi * t) ** 2 # sin² 首末为 0,天然无缝
阶梯 B · 涟漪扩散(中等,15 分钟出活)
视觉:环带从主体外缘向外扩散,alpha 钟形 + 半径线性扩张,可叠多道错相涟漪。
核心技术:
- 内圈 + 外圈双 polygon,用
ImageChops.multiply挖空中心 → 环带 - 多频率 sin 噪声让环带"非圆形"(火焰起伏感)
- 双道涟漪 0.5 周期错相 → "永远有灵气在涌出"
- 按钮反相蒙版(
ImageChops.invert(button_mask))剪去主体覆盖区
模板:scripts/ripple_expanding.py
阶梯 C · 旋转辉光(复杂,30 分钟出活)
视觉:主体自旋 + 径向辉光层 + 双色相位错相(青光实时 / 金光错 1/4 周期 → "阴阳相生"感)。
核心技术:
src.rotate(angle, expand=False)帧间累积旋转- 双层径向渐变:
for r in range(R, 0, -1): ellipse fill (color, alpha * (1-r/R)^n) - 双色错相:青光
sin(2πt),金光sin(2πt - π/2)→ 此消彼长 GaussianBlur(radius=8)让辉光柔和
Step 2 · PIL 7 件套核心 API(不背就报错)
| API | 用途 | 速记 |
|---|---|---|
Image.new('RGBA', (w,h), (0,0,0,0)) | 创建透明画布 | 永远从透明开始 |
canvas.alpha_composite(layer, (x,y)) | 透明合成 | 不要用 paste,paste 不处理 alpha |
ImageFilter.GaussianBlur(radius=N) | 柔化棱角 | radius=4 轻柔 / radius=8 显眼辉光 |
ImageDraw.polygon(points, fill=(R,G,B,A)) | 画不规则形 | 涟漪 / 火焰起伏 |
ImageChops.multiply(a, b) / ImageChops.invert(m) | 蒙版运算 | 挖空中心、反相剪按钮 |
src.rotate(angle, resample=Image.BICUBIC, expand=False) | 帧间旋转 | expand=False 保画布尺寸 |
src.resize((w,h), Image.LANCZOS) | 缩放 | LANCZOS 最好,BICUBIC 次之 |
常见坑:
- ❌
canvas.paste(layer, (x,y))→ 直接覆盖,吃掉透明度 - ✅
canvas.alpha_composite(layer, (x,y))→ 正确的 over 合成 - ❌
Image.open(src)→ 默认 P 模式或 RGB,没 alpha - ✅
Image.open(src).convert('RGBA')→ 强制 RGBA - ❌
polygon锯齿明显 - ✅ 配
GaussianBlur(radius=4)后处理
Step 3 · 无缝循环铁律
没循环好的序列帧 = 玩家眼里的"卡一下"。最常见的失败原因。
铁律 1:alpha 用 sin²(π·t) 钟形而不是 0.5 + 0.5·sin(2πt)。前者首末为 0(天然循环),后者首末为 0.5(接帧瞬间会闪烁)。
铁律 2:旋转 / 平移参数 t=0 和 t=1.0 必须重合。例:18 帧旋转一圈 → angle = -360 * (i / FRAMES),i=0 时 0°,i=18 时 360° = 0°(不画 i=18 那帧,FRAMES=18 已经覆盖完整)。
铁律 3:duration 设定要算清楚。duration=110ms × 18 帧 ≈ 2 秒一周期。氛围向(待机闪烁 / 神秘旋转)推荐 2-2.4 秒,UI 反馈向(点击 / 提示)推荐 1-1.5 秒。
Step 4 · 标准 4 件套输出
<出参目录>/
├── frames/ # 独立 RGBA 帧 PNG,引擎可直接用
│ ├── frame_00.png
│ ├── frame_01.png
│ └── ...
├── sheet_4x3.png # 拼合验证图,给美术 / TA review
├── preview.gif # 深色背景预览,给策划 / PM 看
└── preview_checker.gif # 棋盘格透明视角,验证 alpha 是否干净
为什么要两个 GIF:
preview.gif(深色底)→ 模拟游戏内观感preview_checker.gif(棋盘格)→ 验证透明通道有没有漏底色 / 黑边
Step 5 · 生死状态遍历法自检(团队铁律)
每个完成的动效必须答清 5 段,任一答不上 = 没完成:
| 段 | 自检题 |
|---|---|
| 1. 出现条件 | 何时出现?玩家进入界面 / 点击按钮 / 满足条件? |
| 2. 出现态 | frame_00 长什么样?是 α=0(淡入)还是直接最浓(突现)? |
| 3. 多种共存 | 同屏多个实例?两两位置 / 节奏会撞吗? |
| 4. 消失条件 | 玩家离开界面 / 按钮被按 / 条件失效 → 怎么消失? |
| 5. 消失态 | 消失瞬间用户看到什么?淡出 / 突切 / 缩放消失? |
配 GIF 自检的最快方法:在 GIF 第一帧前后各插一帧空白底色 → 如果接帧处有"突亮 / 突暗" → 循环没收好。
Step 6 · 完成后的后续
单次产出:把 4 件套交给用户 + 贴 GIF preview 确认。
美术接力:spritesheet PNG + sheet_4x3.png 给美术 / TA,他们可以基于这套程序化结果做精修(手 K 关键帧 / Spine 替换 / 加粒子)。
进游戏:如果是 Unity / Unreal,frames/*.png 直接导入;如果是 Cocos / Egret 小游戏,建议用 spritesheet PNG(节省 draw call)。
实战范例脚本(references/<阶梯>/generate.py 可直读)
每个 references 子目录有一份针对该范例的 generate.py(含真实参数值,对照学):
| 范例 | 阶梯 | 关键参数 | 脚本路径 |
|---|---|---|---|
| 蓝色保护罩 - 脉动版 | A | 12 帧, sin² 钟形, α=120 → 200 → 120 | references/01_pulse_breath/generate.py |
| 蓝色保护罩 - 涟漪 v8 | B | 20 帧, 单道, 内径 1.00→1.45, 外径 1.10→1.65 | references/02_ripple_expanding/generate.py |
| 登仙之途 - 传送门 | C | 18 帧, 旋转 -360°, 青光 + 金光双相位错相 | references/03_rotating_glow/generate.py |
改顶部
SRC/OUT路径 + 跑python generate.py即可复现 4 件套(frames + spritesheet + 两个 GIF)。
红线(违反 = 返工)
- ❌ 不问 9 问就开工 → 80% 概率做完发现尺寸 / 配色 / 节奏不对,重做
- ❌ 用
paste而不是alpha_composite→ alpha 直接被吃掉,背景变黑 - ❌ 首末帧 α ≠ 0 → 循环点有"卡一下"
- ❌ 不输出 preview_checker.gif → 漏检 alpha 黑边 / 灰边
- ❌ 只交 GIF 不交 frames/ → 游戏引擎用不了
- ❌ frames 命名不齐位(
frame_1.png而不是frame_01.png)→ 字母序错乱 - ❌ 画布尺寸 = 源图尺寸(不留边距)→ 辉光 / 涟漪扩散被裁
""" 范例 1 · 蓝色保护罩-脉动版(重建脚本)
基于观察到的输出:12 帧,主体 α 呼吸(弱→强→弱),首末帧低 alpha 实现循环。
源图:~/Desktop/btn_baohuzhao_blue.png(蓝色保护罩 icon,半透明镂空铃铛)
重建依据:
- frame_01 = frame_12(钟形循环)
- 文件体积 113kb → 124kb → 113kb 印证 alpha 钟形(中段最浓)
- 整体辉光保持稳定,仅主体 alpha 呼吸 """ import os, math from PIL import Image, ImageDraw, ImageFilter
SRC = r'C:\Users\qianyuang1\Desktop\btn_baohuzhao_blue.png' OUT = r'C:\Users\qianyuang1\策划技能研究局\特效输出\蓝色保护罩_脉动版' FRAMES = 12 DURATION_MS = 110 ALPHA_MIN, ALPHA_MAX = 120, 220 GLOW_COLOR = (120, 180, 230) # 柔蓝辉光 PAD_FACTOR = 0.25
def main(): os.makedirs(OUT, exist_ok=True) src = Image.open(SRC).convert('RGBA') sw, sh = src.size pad = int(max(sw, sh) * PAD_FACTOR) cw, ch = sw + pad * 2, sh + pad * 2
frames_out = []
for i in range(FRAMES):
t = i / FRAMES
# 0.5 + 0.5*sin → 起点中等 → 升到最浓 → 回到中等(带 baseline 不全消失)
alpha_factor = 0.5 + 0.5 * math.sin(2 * math.pi * t - math.pi / 2)
# 主体 alpha 系数:0.7 baseline + 0.3 呼吸(让保护罩始终可见)
body_factor = 0.7 + 0.3 * alpha_factor
glow_alpha = int(ALPHA_MIN + (ALPHA_MAX - ALPHA_MIN) * alpha_factor)
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
# L1 径向辉光(呼吸最显眼的部分)
glow_d = int(max(sw, sh) * 1.4)
glow = Image.new('RGBA', (glow_d, glow_d), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
R = glow_d // 2
for r in range(R, 0, -1):
a = int(glow_alpha * (1 - r / R) ** 1.8)
gd.ellipse((R - r, R - r, R + r, R + r), fill=(*GLOW_COLOR, a))
glow = glow.filter(ImageFilter.GaussianBlur(radius=10))
canvas.alpha_composite(glow, ((cw - glow_d) // 2, (ch - glow_d) // 2))
# L2 主体(alpha 也轻微呼吸)
body = src.copy()
body_alpha = body.split()[3].point(lambda a: int(a * body_factor))
body = Image.merge('RGBA', (*body.split()[:3], body_alpha))
canvas.alpha_composite(body, (pad, pad))
canvas.save(os.path.join(OUT, f'frame_{i + 1:02d}.png'))
frames_out.append(canvas)
# spritesheet 横排
sheet = Image.new('RGBA', (cw * FRAMES, ch), (0, 0, 0, 0))
for i, f in enumerate(frames_out):
sheet.paste(f, (i * cw, 0), f)
sheet.save(os.path.join(OUT, '_clean_spritesheet.png'))
# GIF (深色底)
bg = Image.new('RGBA', (cw, ch), (8, 12, 24, 255))
gif = []
for f in frames_out:
b = bg.copy(); b.alpha_composite(f); gif.append(b.convert('RGB'))
gif[0].save(os.path.join(OUT, '_preview.gif'),
save_all=True, append_images=gif[1:],
duration=DURATION_MS, loop=0, optimize=False)
print(f'OK → {OUT} ({FRAMES} 帧, {cw}x{ch})')
if name == 'main': main()
<!-- @@END_BUNDLED_FILE --> <!-- @@BUNDLED_FILE: references/02_ripple_expanding/generate.py -->""" 范例 2 · 蓝色保护罩-涟漪版 v8(重建脚本)
基于观察到的输出:20 帧,单道涟漪环带向外扩散,文件体积 190kb → 370kb → 195kb 印证 alpha 钟形 + 半径线性扩张。frame_19/20 体积突增 = 末段环带宽度变宽。
源图:~/Desktop/btn_baohuzhao_blue.png(同脉动版同一保护罩)
重建依据:
- 帧数 = 20(蓝色保护罩_脉动版 12 帧 vs 涟漪版 20 帧,涟漪需要更密的帧表现扩散)
- 环带技术与 _button_highlight_generator.py 同源
- 单道涟漪(RIPPLE_COUNT=1)+ sin² 钟形 alpha """ import os, math from PIL import Image, ImageDraw, ImageFilter, ImageChops
SRC = r'C:\Users\qianyuang1\Desktop\btn_baohuzhao_blue.png' OUT = r'C:\Users\qianyuang1\策划技能研究局\特效输出\蓝色保护罩_涟漪版v8' FRAMES = 20 DURATION_MS = 120 RIPPLE_COUNT = 1 NUM_PTS = 60 RIPPLE_COLOR = (140, 200, 240) # 蓝灵气 ALPHA_PEAK = 230 INNER_START, INNER_END = 1.00, 1.45 OUTER_START, OUTER_END = 1.10, 1.65 # v8 末段环带稍宽(解释 frame 19/20 体积突增) PAD_FACTOR = 0.30 # v8 画布更大(解释更大体积)
def main(): os.makedirs(OUT, exist_ok=True) src = Image.open(SRC).convert('RGBA') bw, bh = src.size pad = max(40, int(min(bw, bh) * PAD_FACTOR)) cw, ch = bw + pad * 2, bh + pad * 2 cx, cy = cw / 2, ch / 2 a0, b0 = bw / 2 + 1, bh / 2 R, G, B = RIPPLE_COLOR
button_mask = Image.new('L', (cw, ch), 0)
button_mask.paste(src.split()[3], (pad, pad))
inverse_button = ImageChops.invert(button_mask)
frames_out = []
for i in range(FRAMES):
t = i / FRAMES
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
for k in range(RIPPLE_COUNT):
p = (t + k / RIPPLE_COUNT) % 1.0
alpha_factor = math.sin(math.pi * p) ** 2
alpha = int(ALPHA_PEAK * alpha_factor)
if alpha < 4:
continue
inner_r = INNER_START + (INNER_END - INNER_START) * p
outer_r = OUTER_START + (OUTER_END - OUTER_START) * p
aspect_a = 1.0 + 0.07 * math.sin(2 * math.pi * p)
aspect_b = 1.0 - 0.07 * math.sin(2 * math.pi * p + math.pi / 3)
noise_amp = 0.05 + 0.12 * p
outer_pts, inner_pts = [], []
for j in range(NUM_PTS):
theta = 2 * math.pi * j / NUM_PTS
n_o = (math.sin(theta * 5 + p * 2 * math.pi) * noise_amp
+ math.sin(theta * 11 - p * 2 * math.pi * 1.6) * noise_amp * 0.6
+ math.sin(theta * 19 + p * 2 * math.pi * 2.3) * noise_amp * 0.35)
n_i = (math.sin(theta * 7 + p * 2 * math.pi) * 0.04
+ math.sin(theta * 13 - p * 2 * math.pi * 1.2) * 0.025)
or_, ir_ = outer_r + n_o, inner_r + n_i
outer_pts.append((cx + a0 * aspect_a * or_ * math.cos(theta),
cy + b0 * aspect_b * or_ * math.sin(theta)))
inner_pts.append((cx + a0 * aspect_a * ir_ * math.cos(theta),
cy + b0 * aspect_b * ir_ * math.sin(theta)))
ripple = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
ImageDraw.Draw(ripple).polygon(outer_pts, fill=(R, G, B, alpha))
inner_mask = Image.new('L', (cw, ch), 0)
ImageDraw.Draw(inner_mask).polygon(inner_pts, fill=255)
ripple_alpha = ImageChops.multiply(ripple.split()[3], ImageChops.invert(inner_mask))
ripple = Image.merge('RGBA', (*ripple.split()[:3], ripple_alpha))
ripple = ripple.filter(ImageFilter.GaussianBlur(radius=5))
ripple_alpha2 = ImageChops.multiply(ripple.split()[3], inverse_button)
ripple = Image.merge('RGBA', (*ripple.split()[:3], ripple_alpha2))
canvas.alpha_composite(ripple)
canvas.alpha_composite(src, (pad, pad))
canvas.save(os.path.join(OUT, f'frame_{i + 1:02d}.png'))
frames_out.append(canvas)
# spritesheet 5x4
cols, rows = 5, 4
sheet = Image.new('RGBA', (cw * cols, ch * rows), (0, 0, 0, 0))
for i, f in enumerate(frames_out):
sheet.paste(f, ((i % cols) * cw, (i // cols) * ch), f)
sheet.save(os.path.join(OUT, '_clean_spritesheet.png'))
bg = Image.new('RGBA', (cw, ch), (8, 12, 24, 255))
gif = []
for f in frames_out:
b = bg.copy(); b.alpha_composite(f); gif.append(b.convert('RGB'))
gif[0].save(os.path.join(OUT, '_preview.gif'),
save_all=True, append_images=gif[1:],
duration=DURATION_MS, loop=0, optimize=False)
print(f'OK → {OUT} ({FRAMES} 帧, {cw}x{ch})')
if name == 'main': main()
<!-- @@END_BUNDLED_FILE --> <!-- @@BUNDLED_FILE: references/03_rotating_glow/generate.py -->""" 登仙之途·传送门高亮特效序列帧生成
- 源图: btn_dengxianzhitu_chuansong.png (64x64 青色螺旋旋涡)
- 用途: 地图节点"可挑战"提示
- 输出: 12帧 128x128 透明PNG + 4x3拼合图 + GIF预览 """ import os, math from PIL import Image, ImageDraw, ImageFilter
SRC = r'C:\Users\qianyuang1\Desktop\btn_dengxianzhitu_chuansong.png' OUT = r'C:\Users\qianyuang1\策划技能研究局\特效输出\dengxianzhitu_chuansong_highlight' FRAMES = 18 # 18帧/圈 → @10fps约2秒一圈,修仙神秘感 SIZE = 128 SRC_BASE = 64
CYAN_GLOW = (140, 230, 255) # 灵气青 GOLD_GLOW = (255, 220, 140) # 仙气金(暖金,略偏冷避免俗气)
src = Image.open(SRC).convert('RGBA') frames_out = []
for i in range(FRAMES):
t = i / FRAMES
angle = -360 * t # 18帧一圈,每帧-20°,顺时针慢转
scale = 1.0 # 主体不缩放(用户决定)
glow_scale = 1.0 # 辉光直径不变
cyan_norm = 0.5 + 0.5 * math.sin(2 * math.pi * t) # 青光相位
gold_norm = 0.5 + 0.5 * math.sin(2 * math.pi * t - math.pi / 2) # 金光错相 1/4 周期(阴阳相生)
cyan_a = int(70 + 150 * cyan_norm) # 青光 70220
gold_a = int(60 + 90 * gold_norm) # 金光 60150(透过旋涡间隙的损耗后视觉≈"略微")
canvas = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
# ---- L1 外辉光:径向青色灵气(最大、最柔) ----
glow_d = min(SIZE - 4, int(SRC_BASE * glow_scale * 1.4))
glow = Image.new('RGBA', (glow_d, glow_d), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
cx = cy = glow_d // 2
R = glow_d // 2
for r in range(R, 0, -1):
a = int(cyan_a * (1 - r / R) ** 1.8)
gd.ellipse((cx - r, cy - r, cx + r, cy + r), fill=(*CYAN_GLOW, a))
glow = glow.filter(ImageFilter.GaussianBlur(radius=8))
canvas.alpha_composite(glow, ((SIZE - glow_d) // 2, (SIZE - glow_d) // 2))
# ---- L1.5 金色仙气:径向渐变铺底(中心实→边缘淡),叠于旋涡之下 ----
# → 旋涡螺旋臂之间的透明间隙会让金光"透出",仿佛门后金光透漏
gold_d = int(SRC_BASE * 0.95) # 略小于旋涡,金光集中在门内
gold = Image.new('RGBA', (gold_d, gold_d), (0, 0, 0, 0))
ggd = ImageDraw.Draw(gold)
gcx = gcy = gold_d // 2
gR = gold_d // 2
for r in range(gR, 0, -1):
pos = r / gR
# 中心强、边缘弱:(1-pos)^1.4 让中心金光更实,但不至于硬边
a = int(gold_a * (1 - pos) ** 1.4)
ggd.ellipse((gcx - r, gcy - r, gcx + r, gcy + r), fill=(*GOLD_GLOW, a))
gold = gold.filter(ImageFilter.GaussianBlur(radius=4)) # 轻模糊,保留"透出"的具体形状
canvas.alpha_composite(gold, ((SIZE - gold_d) // 2, (SIZE - gold_d) // 2))
# ---- L2 旋转旋涡 ----
rotated = src.rotate(angle, resample=Image.BICUBIC, expand=False)
sd = max(2, int(SRC_BASE * scale))
rotated = rotated.resize((sd, sd), Image.LANCZOS)
canvas.alpha_composite(rotated, ((SIZE - sd) // 2, (SIZE - sd) // 2))
frames_out.append(canvas)
canvas.save(os.path.join(OUT, 'frames', f'frame_{i:02d}.png'))
---- 拼合图 6x3(18帧) ----
COLS, ROWS = 6, 3 sheet = Image.new('RGBA', (SIZE * COLS, SIZE * ROWS), (0, 0, 0, 0)) for i, f in enumerate(frames_out): sheet.paste(f, ((i % COLS) * SIZE, (i // COLS) * SIZE), f) sheet.save(os.path.join(OUT, f'sheet_{COLS}x{ROWS}.png'))
---- GIF 预览(合成深色背景便于观察) ----
bg = Image.new('RGBA', (SIZE, SIZE), (28, 38, 56, 255)) gif_frames = [] for f in frames_out: b = bg.copy() b.alpha_composite(f) gif_frames.append(b.convert('RGB')) gif_frames[0].save( os.path.join(OUT, 'preview.gif'), save_all=True, append_images=gif_frames[1:], duration=110, # ~9fps,18帧一圈≈2秒,神秘感 loop=0, optimize=False )
---- GIF 透明版(贴图视角,棋盘格) ----
checker = Image.new('RGBA', (SIZE, SIZE), (255, 255, 255, 255)) cd = ImageDraw.Draw(checker) for x in range(0, SIZE, 8): for y in range(0, SIZE, 8): if (x // 8 + y // 8) % 2 == 0: cd.rectangle((x, y, x + 8, y + 8), fill=(220, 220, 220, 255)) gif_t = [] for f in frames_out: b = checker.copy() b.alpha_composite(f) gif_t.append(b.convert('RGB')) gif_t[0].save( os.path.join(OUT, 'preview_checker.gif'), save_all=True, append_images=gif_t[1:], duration=110, loop=0, optimize=False )
print(f'OK -> {OUT}') print(f' frames: {FRAMES} x {SIZE}x{SIZE} PNG (透明)') print(f' sheet: sheet_{COLS}x{ROWS}.png') print(f' gif: preview.gif (深色底), preview_checker.gif (透明视角)')
<!-- @@END_BUNDLED_FILE --> <!-- @@BUNDLED_FILE: scripts/pulse_breath.py -->""" 阶梯 A · 脉动呼吸(最简)
主体 α 整体 sin² 钟形呼吸,首末帧 α=0 天然无缝循环。
适用场景:UI 按钮高亮 / 选中态闪烁 / 被动技能光环 / 可交互提示
参数说明(拷贝后改这一段就行):
- SRC: 源 PNG 路径(必须 RGBA 透明背景)
- OUT: 输出目录
- FRAMES: 帧数(12 / 18 / 24)
- DURATION_MS: GIF 单帧时长,决定循环节奏(110 → 18帧≈2秒)
- ALPHA_MIN / ALPHA_MAX: 呼吸强度范围(克制 60-180 / 显眼 80-220 / 炸场 100-255)
- GLOW_COLOR: 主色 RGB(None = 不加辉光,纯呼吸;给颜色 = 加一层径向辉光)
- GLOW_RADIUS_FACTOR: 辉光直径相对源图的倍数(1.2 = 略大)
输出 4 件套:
- frames/frame_NN.png 独立帧
- sheet_4x3.png 拼合图
- preview.gif 深色底预览
- preview_checker.gif 棋盘格透明视角预览 """ import os, math from PIL import Image, ImageDraw, ImageFilter
============ 调参区(开工 9 问对齐后填这里)============
SRC = r'C:\path\to\your\source.png' # ← Q2 源图路径 OUT = r'C:\path\to\output\dir' # ← 输出目录 FRAMES = 12 # ← Q7 帧数 DURATION_MS = 110 # ← Q6 节奏:110ms × 12帧 ≈ 1.3秒一周期 ALPHA_MIN, ALPHA_MAX = 100, 220 # ← Q8 强度 GLOW_COLOR = (140, 230, 255) # ← Q5 主色(None = 不加辉光层) GLOW_RADIUS_FACTOR = 1.3 # 辉光相对源图大小 CANVAS_PAD = 0.25 # ← Q3 画布留 25% 边距给辉光
=========================================================
def main(): os.makedirs(os.path.join(OUT, 'frames'), exist_ok=True) src = Image.open(SRC).convert('RGBA') sw, sh = src.size
pad = int(max(sw, sh) * CANVAS_PAD)
cw, ch = sw + pad * 2, sh + pad * 2
frames_out = []
for i in range(FRAMES):
t = i / FRAMES
# sin² 钟形:t=0 → 0, t=0.5 → 1, t=1.0 → 0(首末为 0 天然循环)
alpha_factor = math.sin(math.pi * t) ** 2
alpha = int(ALPHA_MIN + (ALPHA_MAX - ALPHA_MIN) * alpha_factor)
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
# 可选:径向辉光层(在主体下面,营造发光感)
if GLOW_COLOR is not None:
glow_d = int(max(sw, sh) * GLOW_RADIUS_FACTOR)
glow = Image.new('RGBA', (glow_d, glow_d), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
R = glow_d // 2
for r in range(R, 0, -1):
# (1 - r/R)^1.8 → 中心实、边缘柔
a = int(alpha * (1 - r / R) ** 1.8)
gd.ellipse((R - r, R - r, R + r, R + r), fill=(*GLOW_COLOR, a))
glow = glow.filter(ImageFilter.GaussianBlur(radius=8))
canvas.alpha_composite(glow, ((cw - glow_d) // 2, (ch - glow_d) // 2))
# 主体(alpha 同步呼吸)
body = src.copy()
# 把源图 alpha 乘以 alpha_factor 实现"主体也在呼吸"
body_alpha = body.split()[3].point(lambda a: int(a * (0.6 + 0.4 * alpha_factor)))
body = Image.merge('RGBA', (*body.split()[:3], body_alpha))
canvas.alpha_composite(body, (pad, pad))
canvas.save(os.path.join(OUT, 'frames', f'frame_{i:02d}.png'))
frames_out.append(canvas)
# ---- 拼合图 ----
cols = 4 if FRAMES <= 12 else 6
rows = (FRAMES + cols - 1) // cols
sheet = Image.new('RGBA', (cw * cols, ch * rows), (0, 0, 0, 0))
for i, f in enumerate(frames_out):
sheet.paste(f, ((i % cols) * cw, (i // cols) * ch), f)
sheet.save(os.path.join(OUT, f'sheet_{cols}x{rows}.png'))
# ---- GIF (深色底) ----
_save_gifs(frames_out, cw, ch)
print(f'OK → {OUT} ({FRAMES} 帧, {cw}x{ch})')
def _save_gifs(frames, cw, ch): # 深色底 GIF bg_dark = Image.new('RGBA', (cw, ch), (28, 38, 56, 255)) gif_dark = [bg_dark.copy() for _ in frames] for b, f in zip(gif_dark, frames): b.alpha_composite(f) [b.convert('RGB') for b in gif_dark][0].save( os.path.join(OUT, 'preview.gif'), save_all=True, append_images=[b.convert('RGB') for b in gif_dark[1:]], duration=DURATION_MS, loop=0, optimize=False, ) # 棋盘格透明视角 checker = Image.new('RGBA', (cw, ch), (255, 255, 255, 255)) cd = ImageDraw.Draw(checker) for x in range(0, cw, 8): for y in range(0, ch, 8): if (x // 8 + y // 8) % 2 == 0: cd.rectangle((x, y, x + 8, y + 8), fill=(220, 220, 220, 255)) gif_chk = [checker.copy() for _ in frames] for b, f in zip(gif_chk, frames): b.alpha_composite(f) [b.convert('RGB') for b in gif_chk][0].save( os.path.join(OUT, 'preview_checker.gif'), save_all=True, append_images=[b.convert('RGB') for b in gif_chk[1:]], duration=DURATION_MS, loop=0, optimize=False, )
if name == 'main': main()
<!-- @@END_BUNDLED_FILE --> <!-- @@BUNDLED_FILE: scripts/ripple_expanding.py -->""" 阶梯 B · 涟漪扩散(中等)
环带从主体外缘向外扩散,alpha 钟形 + 半径线性扩张。可叠多道错相涟漪。
适用场景:UI 按钮"可点击"提示 / 灵气环绕 / 涟漪触发反馈 / 法宝光环
核心技术:
- 内圈 + 外圈双 polygon,ImageChops.multiply 挖空中心 → 环带
- 多频率 sin 噪声让环带"非圆形"(火焰起伏感)
- 双道涟漪 0.5 周期错相 → 永远有灵气在涌出
- 按钮反相蒙版剪去主体覆盖区 """ import os, math from PIL import Image, ImageDraw, ImageFilter, ImageChops
============ 调参区 ============
SRC = r'C:\path\to\your\source.png' OUT = r'C:\path\to\output\dir' FRAMES = 12 DURATION_MS = 200 # 200ms × 12 = 2.4 秒一道涟漪生命周期 RIPPLE_COUNT = 1 # 涟漪道数(2 道更连续,但帧密度需 20+) NUM_PTS = 60 # 环带多边形顶点数(越多越圆滑) RIPPLE_COLOR = (140, 220, 255) # 主色(青灵气 / 改 (255,215,110) = 金仙气) ALPHA_PEAK = 240 # 涟漪峰值 alpha INNER_START, INNER_END = 1.00, 1.45 # 内径扩散 1.0 → 1.45(相对主体半径) OUTER_START, OUTER_END = 1.10, 1.60 # 外径扩散 1.10 → 1.60(环带初始 0.10 末段 0.15) PAD_FACTOR = 0.22 # 画布留 22% 边距给最大扩散
================================
def main(): os.makedirs(os.path.join(OUT, 'frames'), exist_ok=True) src = Image.open(SRC).convert('RGBA') bw, bh = src.size pad = max(22, int(min(bw, bh) * PAD_FACTOR)) cw, ch = bw + pad * 2, bh + pad * 2 cx, cy = cw / 2, ch / 2 a0, b0 = bw / 2 + 1, bh / 2 # 椭圆半轴基准(贴合主体外缘) R, G, B = RIPPLE_COLOR
# 按钮 alpha 反相蒙版(涟漪不覆盖主体)
button_mask = Image.new('L', (cw, ch), 0)
button_mask.paste(src.split()[3], (pad, pad))
inverse_button = ImageChops.invert(button_mask)
frames_out = []
for i in range(FRAMES):
t = i / FRAMES
canvas = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
for k in range(RIPPLE_COUNT):
p = (t + k / RIPPLE_COUNT) % 1.0 # 该涟漪寿命 0~1
alpha_factor = math.sin(math.pi * p) ** 2 # 钟形 alpha,首末为 0
alpha = int(ALPHA_PEAK * alpha_factor)
if alpha < 4:
continue
inner_r = INNER_START + (INNER_END - INNER_START) * p
outer_r = OUTER_START + (OUTER_END - OUTER_START) * p
# 非圆形:纵横比异步漂移 + 噪声振幅随 p 增大(早期细微、末期狂野)
aspect_a = 1.0 + 0.07 * math.sin(2 * math.pi * p + k * 1.7)
aspect_b = 1.0 - 0.07 * math.sin(2 * math.pi * p + k * 1.7 + math.pi / 3)
noise_amp = 0.05 + 0.10 * p
phase = k * 0.7
outer_pts, inner_pts = [], []
for j in range(NUM_PTS):
theta = 2 * math.pi * j / NUM_PTS
# 多频率 sin 叠加 → 火焰般非规则边缘
n_o = (math.sin(theta * 5 + p * 2 * math.pi + phase) * noise_amp
+ math.sin(theta * 11 - p * 2 * math.pi * 1.6 + phase * 2) * noise_amp * 0.6
+ math.sin(theta * 19 + p * 2 * math.pi * 2.3) * noise_amp * 0.35)
n_i = (math.sin(theta * 7 + p * 2 * math.pi + phase * 1.3) * 0.04
+ math.sin(theta * 13 - p * 2 * math.pi * 1.2) * 0.025)
or_, ir_ = outer_r + n_o, inner_r + n_i
outer_pts.append((cx + a0 * aspect_a * or_ * math.cos(theta),
cy + b0 * aspect_b * or_ * math.sin(theta)))
inner_pts.append((cx + a0 * aspect_a * ir_ * math.cos(theta),
cy + b0 * aspect_b * ir_ * math.sin(theta)))
# 外圈实心 polygon
ripple = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
ImageDraw.Draw(ripple).polygon(outer_pts, fill=(R, G, B, alpha))
# 内圈 mask 挖空中心
inner_mask = Image.new('L', (cw, ch), 0)
ImageDraw.Draw(inner_mask).polygon(inner_pts, fill=255)
ripple_alpha = ImageChops.multiply(ripple.split()[3], ImageChops.invert(inner_mask))
ripple = Image.merge('RGBA', (*ripple.split()[:3], ripple_alpha))
# 柔化棱角
ripple = ripple.filter(ImageFilter.GaussianBlur(radius=4))
# 剪去主体覆盖区
ripple_alpha2 = ImageChops.multiply(ripple.split()[3], inverse_button)
ripple = Image.merge('RGBA', (*ripple.split()[:3], ripple_alpha2))
canvas.alpha_composite(ripple)
# 主体(始终最顶层)
canvas.alpha_composite(src, (pad, pad))
canvas.save(os.path.join(OUT, 'frames', f'frame_{i:02d}.png'))
frames_out.append(canvas)
# 拼合图
cols = 4 if FRAMES <= 12 else 6
rows = (FRAMES + cols - 1) // cols
sheet = Image.new('RGBA', (cw * cols, ch * rows), (0, 0, 0, 0))
for i, f in enumerate(frames_out):
sheet.paste(f, ((i % cols) * cw, (i // cols) * ch), f)
sheet.save(os.path.join(OUT, f'sheet_{cols}x{rows}.png'))
_save_gifs(frames_out, cw, ch)
print(f'OK → {OUT} ({FRAMES} 帧, {cw}x{ch})')
def _save_gifs(frames, cw, ch): bg = Image.new('RGBA', (cw, ch), (38, 46, 64, 255)) g1 = [] for f in frames: b = bg.copy(); b.alpha_composite(f); g1.append(b.convert('RGB')) g1[0].save(os.path.join(OUT, 'preview.gif'), save_all=True, append_images=g1[1:], duration=DURATION_MS, loop=0, optimize=False)
checker = Image.new('RGBA', (cw, ch), (255, 255, 255, 255))
cd = ImageDraw.Draw(checker)
for x in range(0, cw, 8):
for y in range(0, ch, 8):
if (x // 8 + y // 8) % 2 == 0:
cd.rectangle((x, y, x + 8, y + 8), fill=(220, 220, 220, 255))
g2 = []
for f in frames:
b = checker.copy(); b.alpha_composite(f); g2.append(b.convert('RGB'))
g2[0].save(os.path.join(OUT, 'preview_checker.gif'),
save_all=True, append_images=g2[1:],
duration=DURATION_MS, loop=0, optimize=False)
if name == 'main': main()
<!-- @@END_BUNDLED_FILE --> <!-- @@BUNDLED_FILE: scripts/rotating_glow.py -->""" 阶梯 C · 旋转辉光(复杂)
主体自旋 + 径向辉光层 + 双色相位错相(青光实时 / 金光错 1/4 周期 → 阴阳相生)。
适用场景:传送门 / 仙阵 / 召唤法阵 / 神秘旋涡 / 高级解锁提示
核心技术:
- src.rotate(angle, expand=False) 帧间累积旋转
- 双层径向渐变:for r in range(R, 0, -1): ellipse fill (color, alpha * (1-r/R)^n)
- 双色错相:青光 sin(2πt),金光 sin(2πt - π/2) → 此消彼长
- GaussianBlur(radius=8) 让辉光柔和 """ import os, math from PIL import Image, ImageDraw, ImageFilter
============ 调参区 ============
SRC = r'C:\path\to\your\source.png' OUT = r'C:\path\to\output\dir' FRAMES = 18 # 18 帧 @110ms ≈ 2 秒一圈 DURATION_MS = 110 ROTATION_DEGREES = -360 # 一圈:360 顺时针 / -360 逆时针 / 0 不旋转 CANVAS_SIZE = 128 # 输出画布尺寸(源图建议 64,留 1× 边距) SRC_BASE = 64 # 源图基准尺寸(按比例 resize)
双色辉光配置(None 关闭某层)
PRIMARY_COLOR = (140, 230, 255) # 青灵气(外层柔光) PRIMARY_ALPHA_RANGE = (70, 220) # 青光 alpha 范围 PRIMARY_RADIUS_FACTOR = 1.4 # 外层辉光半径相对源图
SECONDARY_COLOR = (255, 220, 140) # 金仙气(内层透漏) SECONDARY_ALPHA_RANGE = (60, 150) # 金光 alpha 范围 SECONDARY_RADIUS_FACTOR = 0.95 # 内层辉光半径(略小于主体) SECONDARY_PHASE_OFFSET = -math.pi / 2 # 金光错 1/4 周期(阴阳相生)
================================
def main(): os.makedirs(os.path.join(OUT, 'frames'), exist_ok=True) src = Image.open(SRC).convert('RGBA') SIZE = CANVAS_SIZE
frames_out = []
for i in range(FRAMES):
t = i / FRAMES
angle = ROTATION_DEGREES * t
# 双色相位
p1_norm = 0.5 + 0.5 * math.sin(2 * math.pi * t)
p2_norm = 0.5 + 0.5 * math.sin(2 * math.pi * t + SECONDARY_PHASE_OFFSET)
a1 = int(PRIMARY_ALPHA_RANGE[0] + (PRIMARY_ALPHA_RANGE[1] - PRIMARY_ALPHA_RANGE[0]) * p1_norm)
a2 = int(SECONDARY_ALPHA_RANGE[0] + (SECONDARY_ALPHA_RANGE[1] - SECONDARY_ALPHA_RANGE[0]) * p2_norm)
canvas = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
# ---- L1 外层辉光(最大、最柔) ----
if PRIMARY_COLOR is not None:
d1 = min(SIZE - 4, int(SRC_BASE * PRIMARY_RADIUS_FACTOR))
glow = Image.new('RGBA', (d1, d1), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
R = d1 // 2
for r in range(R, 0, -1):
a = int(a1 * (1 - r / R) ** 1.8)
gd.ellipse((R - r, R - r, R + r, R + r), fill=(*PRIMARY_COLOR, a))
glow = glow.filter(ImageFilter.GaussianBlur(radius=8))
canvas.alpha_composite(glow, ((SIZE - d1) // 2, (SIZE - d1) // 2))
# ---- L1.5 内层辉光(铺底,主体之下,透过主体间隙透漏) ----
if SECONDARY_COLOR is not None:
d2 = int(SRC_BASE * SECONDARY_RADIUS_FACTOR)
inner = Image.new('RGBA', (d2, d2), (0, 0, 0, 0))
gid = ImageDraw.Draw(inner)
R2 = d2 // 2
for r in range(R2, 0, -1):
pos = r / R2
a = int(a2 * (1 - pos) ** 1.4)
gid.ellipse((R2 - r, R2 - r, R2 + r, R2 + r), fill=(*SECONDARY_COLOR, a))
inner = inner.filter(ImageFilter.GaussianBlur(radius=4))
canvas.alpha_composite(inner, ((SIZE - d2) // 2, (SIZE - d2) // 2))
# ---- L2 旋转主体 ----
rotated = src.rotate(angle, resample=Image.BICUBIC, expand=False)
rotated = rotated.resize((SRC_BASE, SRC_BASE), Image.LANCZOS)
canvas.alpha_composite(rotated, ((SIZE - SRC_BASE) // 2, (SIZE - SRC_BASE) // 2))
canvas.save(os.path.join(OUT, 'frames', f'frame_{i:02d}.png'))
frames_out.append(canvas)
# 拼合图
cols = 6 if FRAMES >= 18 else 4
rows = (FRAMES + cols - 1) // cols
sheet = Image.new('RGBA', (SIZE * cols, SIZE * rows), (0, 0, 0, 0))
for i, f in enumerate(frames_out):
sheet.paste(f, ((i % cols) * SIZE, (i // cols) * SIZE), f)
sheet.save(os.path.join(OUT, f'sheet_{cols}x{rows}.png'))
_save_gifs(frames_out, SIZE, SIZE)
print(f'OK → {OUT} ({FRAMES} 帧, {SIZE}x{SIZE})')
def _save_gifs(frames, cw, ch): bg = Image.new('RGBA', (cw, ch), (28, 38, 56, 255)) g1 = [] for f in frames: b = bg.copy(); b.alpha_composite(f); g1.append(b.convert('RGB')) g1[0].save(os.path.join(OUT, 'preview.gif'), save_all=True, append_images=g1[1:], duration=DURATION_MS, loop=0, optimize=False)
checker = Image.new('RGBA', (cw, ch), (255, 255, 255, 255))
cd = ImageDraw.Draw(checker)
for x in range(0, cw, 8):
for y in range(0, ch, 8):
if (x // 8 + y // 8) % 2 == 0:
cd.rectangle((x, y, x + 8, y + 8), fill=(220, 220, 220, 255))
g2 = []
for f in frames:
b = checker.copy(); b.alpha_composite(f); g2.append(b.convert('RGB'))
g2[0].save(os.path.join(OUT, 'preview_checker.gif'),
save_all=True, append_images=g2[1:],
duration=DURATION_MS, loop=0, optimize=False)
if name == 'main': main()
<!-- @@END_BUNDLED_FILE -->⚡ 一键安装
复制给智能体安装:
npx clawgamers install vfx-sprite-frame-craft把上面的命令丢给智能体 (Claude Code / Cursor / Codex 任一), ta 会装到当前工作目录的 skills/ 文件夹