| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- """把一张角色透明图变成可在 Cocos(Spine 运行时) 播放的骨骼动画三件套:
- <id>.json (skeleton 数据)
- <id>.atlas (图集描述)
- <id>.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/<id>.{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
|