"""把一张角色透明图变成可在 Cocos(Spine 运行时) 播放的骨骼动画三件套: .json (skeleton 数据) .atlas (图集描述) .png (贴图) v1 策略:单骨骼 + 程序化 squash/stretch 抖动(果冻 jiggle)。 不需要拆件,纯靠对整张图做挤压/拉伸/旋转关键帧,即可得到真实可见的动画。 后续要做多部件骨骼,在 build_skeleton_json 里扩展 bones/slots/skins 即可。 """ import json import math import os from PIL import Image # ---------- 程序化动画生成 ---------- def _scale_keys(samples): return [{"time": round(t, 4), "x": round(x, 4), "y": round(y, 4)} for (t, x, y) in samples] def _rotate_keys(samples): return [{"time": round(t, 4), "value": round(v, 4)} for (t, v) in samples] def jiggle_idle(period=1.2, amp=0.06): """轻微呼吸式果冻抖动,循环。""" samples = [] steps = 8 for i in range(steps + 1): t = period * i / steps phase = 2 * math.pi * i / steps x = 1 + amp * math.sin(phase) y = 1 - amp * math.sin(phase) # x 胖 y 就矮,保持体积感 samples.append((t, x, y)) return {"scale": _scale_keys(samples)} def jiggle_win(period=0.9, amp=0.16): """中奖:更大幅度弹跳 + 轻微摇摆。""" scale_samples, rot_samples = [], [] steps = 6 for i in range(steps + 1): t = period * i / steps phase = 2 * math.pi * i / steps decay = 1 - (i / steps) * 0.3 x = 1 + amp * decay * math.cos(phase) y = 1 - amp * decay * math.cos(phase) scale_samples.append((t, x, y)) rot_samples.append((t, 6 * decay * math.sin(phase))) return {"scale": _scale_keys(scale_samples), "rotate": _rotate_keys(rot_samples)} def attack(): """攻击:先蓄力后猛冲再回正(不循环)。""" scale = [(0, 1, 1), (0.12, 0.85, 1.12), (0.26, 1.22, 0.85), (0.5, 1, 1)] rot = [(0, 0), (0.12, -8), (0.26, 13), (0.5, 0)] return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)} def hurt(): """受击:快速回缩 + 衰减摇摆(不循环)。""" scale = [(0, 1, 1), (0.06, 1.14, 0.88), (0.4, 1, 1)] rot = [(0, 0), (0.08, -11), (0.18, 8), (0.28, -4), (0.4, 0)] return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)} def bounce_in(): """入场:从无到有弹入,带过冲(不循环)。""" scale = [(0, 0, 0), (0.28, 1.15, 0.9), (0.4, 0.95, 1.08), (0.5, 1, 1)] rot = [(0, -12), (0.28, 4), (0.5, 0)] return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)} ANIM_FACTORY = { "idle": jiggle_idle, "win": jiggle_win, "attack": attack, "hurt": hurt, "bounce_in": bounce_in, } # ---------- 图片处理 ---------- def remove_bg(img, bright=200, sat=38): """自动去背:把"亮且低饱和(白/浅灰/奶油)且与四边连通"的像素抠成透明。 用边界连通传播,所以果冻身上被颜色包住的白高光/白眼睛不会被误删。 纯 numpy 实现,不依赖 scipy;没装 numpy 时退回 PIL 泛洪。""" img = img.convert("RGBA") try: import numpy as np arr = np.array(img) rgb = arr[:, :, :3].astype(np.int16) mx = rgb.max(axis=2); mn = rgb.min(axis=2) bgcand = (mn >= bright) & ((mx - mn) <= sat) # 背景候选:亮 + 低饱和 # 从靠近四边的一圈带里取种子(最外 1~2px 常是透明黑边,要往里一点) H, W = bgcand.shape band = max(10, H // 80) seed = np.zeros_like(bgcand) seed[:band, :] = True; seed[-band:, :] = True seed[:, :band] = True; seed[:, -band:] = True bg = bgcand & seed for _ in range(2000): # 从四边向内连通传播 prev = int(bg.sum()) new = bg.copy() new[1:, :] |= bg[:-1, :]; new[:-1, :] |= bg[1:, :] new[:, 1:] |= bg[:, :-1]; new[:, :-1] |= bg[:, 1:] new &= bgcand bg = new if int(bg.sum()) == prev: break # 羽化一圈,柔化果冻边缘的白色硬边 edge = bg.copy() edge[1:, :] |= bg[:-1, :]; edge[:-1, :] |= bg[1:, :] edge[:, 1:] |= bg[:, :-1]; edge[:, :-1] |= bg[:, 1:] fringe = edge & ~bg & (mn >= bright - 30) arr[bg, 3] = 0 arr[fringe, 3] = (arr[fringe, 3] * 0.35).astype(arr.dtype) return Image.fromarray(arr, "RGBA") except Exception: return _floodfill_bg(img, 236) def _floodfill_bg(img, thresh): """纯 PIL 兜底:先把透明区垫白(否则透明边会被当种子),再从四边泛洪近白背景。""" from PIL import ImageDraw w, h = img.size rgb = Image.new("RGB", (w, h), (255, 255, 255)) rgb.paste(img.convert("RGB"), mask=img.split()[3]) # 用 alpha 贴,透明处保持白 tol = 255 - thresh + 10 seeds = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1), (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2)] for s in seeds: ImageDraw.floodfill(rgb, s, (255, 0, 255), thresh=tol) try: import numpy as np mask = (np.array(rgb) == (255, 0, 255)).all(axis=2) arr = np.array(img.convert("RGBA")) arr[mask, 3] = 0 return Image.fromarray(arr, "RGBA") except Exception: out = img.convert("RGBA"); po, pr = out.load(), rgb.load() for y in range(h): for x in range(w): if pr[x, y] == (255, 0, 255): r, g, b, _ = po[x, y]; po[x, y] = (r, g, b, 0) return out def trim_to_content(img, pad=4): """裁掉透明边,留一点 padding。返回裁好的 RGBA。""" bbox = img.getbbox() if bbox: img = img.crop(bbox) if pad: w, h = img.size canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0)) canvas.paste(img, (pad, pad)) img = canvas return img # ---------- 三件套生成 ---------- def write_atlas(atlas_path, png_name, region_name, w, h): """libgdx 传统格式 atlas,单区域,兼容 Cocos spine 运行时。""" lines = [ png_name, f"size: {w},{h}", "format: RGBA8888", "filter: Linear,Linear", "repeat: none", region_name, " rotate: false", " xy: 0, 0", f" size: {w}, {h}", f" orig: {w}, {h}", " offset: 0, 0", " index: -1", "", ] with open(atlas_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) def build_skeleton_json(char_id, w, h, animations): """单骨骼 region 绑定 + 程序化动画。骨骼原点在底部中心,便于"果冻不离地"挤压。""" anims = {} for name in animations: factory = ANIM_FACTORY.get(name) if factory is None: continue anims[name] = {"bones": {"body": factory()}} if not anims: anims["idle"] = {"bones": {"body": jiggle_idle()}} return { "skeleton": { "hash": char_id, "spine": "4.0.00", "x": -w / 2.0, "y": 0, "width": float(w), "height": float(h), "images": "./", "audio": "", }, "bones": [ {"name": "root"}, {"name": "body", "parent": "root"}, ], "slots": [ {"name": "body", "bone": "body", "attachment": "body"}, ], "skins": [ { "name": "default", "attachments": { "body": { "body": {"x": 0, "y": h / 2.0, "width": w, "height": h} } }, } ], "animations": anims, } def anim_data(animations): """返回给网页预览用的动画关键帧(与 skeleton 里同一套数据)。 结构: { name: {"duration": t, "scale": [{time,x,y}...], "rotate": [{time,value}...]} } """ out = {} for name in animations: factory = ANIM_FACTORY.get(name) if factory is None: continue d = factory() scale = d.get("scale", []) rotate = d.get("rotate", []) times = [k["time"] for k in scale] + [k["time"] for k in rotate] out[name] = { "duration": max(times) if times else 1.0, "scale": scale, "rotate": rotate, } if not out: d = jiggle_idle() out["idle"] = {"duration": d["scale"][-1]["time"], "scale": d["scale"], "rotate": []} return out def build_character(char_id, image, out_dir, animations): """主入口:image(PIL RGBA) -> out_dir/.{json,atlas,png}。返回 png 路径。""" os.makedirs(out_dir, exist_ok=True) img = trim_to_content(remove_bg(image)) # 先自动去白底,再裁透明边 w, h = img.size png_name = f"{char_id}.png" png_path = os.path.join(out_dir, png_name) img.save(png_path) write_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, "body", w, h) skel = build_skeleton_json(char_id, w, h, animations) with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f: json.dump(skel, f, ensure_ascii=False, indent=2) return png_path