返回小岛
技能市场/程序·美术/vfx-sprite-frame-craft

vfx-sprite-frame-craft

宇昂出品v1.0.1暂无评价6次安装

小 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,天然无缝

模板scripts/pulse_breath.py

阶梯 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) 让辉光柔和

模板scripts/rotating_glow.py


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(含真实参数值,对照学):

范例阶梯关键参数脚本路径
蓝色保护罩 - 脉动版A12 帧, sin² 钟形, α=120 → 200 → 120references/01_pulse_breath/generate.py
蓝色保护罩 - 涟漪 v8B20 帧, 单道, 内径 1.00→1.45, 外径 1.10→1.65references/02_ripple_expanding/generate.py
登仙之途 - 传送门C18 帧, 旋转 -360°, 青光 + 金光双相位错相references/03_rotating_glow/generate.py

改顶部 SRC / OUT 路径 + 跑 python generate.py 即可复现 4 件套(frames + spritesheet + 两个 GIF)。


红线(违反 = 返工)

  1. 不问 9 问就开工 → 80% 概率做完发现尺寸 / 配色 / 节奏不对,重做
  2. paste 而不是 alpha_composite → alpha 直接被吃掉,背景变黑
  3. 首末帧 α ≠ 0 → 循环点有"卡一下"
  4. 不输出 preview_checker.gif → 漏检 alpha 黑边 / 灰边
  5. 只交 GIF 不交 frames/ → 游戏引擎用不了
  6. frames 命名不齐位frame_1.png 而不是 frame_01.png)→ 字母序错乱
  7. 画布尺寸 = 源图尺寸(不留边距)→ 辉光 / 涟漪扩散被裁
<!-- @@BUNDLED_FILE: references/01_pulse_breath/generate.py -->

""" 范例 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 -->