spine_builder.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """把一张角色透明图变成可在 Cocos(Spine 运行时) 播放的骨骼动画三件套:
  2. <id>.json (skeleton 数据)
  3. <id>.atlas (图集描述)
  4. <id>.png (贴图)
  5. v1 策略:单骨骼 + 程序化 squash/stretch 抖动(果冻 jiggle)。
  6. 不需要拆件,纯靠对整张图做挤压/拉伸/旋转关键帧,即可得到真实可见的动画。
  7. 后续要做多部件骨骼,在 build_skeleton_json 里扩展 bones/slots/skins 即可。
  8. """
  9. import json
  10. import math
  11. import os
  12. from PIL import Image
  13. # ---------- 程序化动画生成 ----------
  14. def _scale_keys(samples):
  15. return [{"time": round(t, 4), "x": round(x, 4), "y": round(y, 4)} for (t, x, y) in samples]
  16. def _rotate_keys(samples):
  17. return [{"time": round(t, 4), "value": round(v, 4)} for (t, v) in samples]
  18. def jiggle_idle(period=1.2, amp=0.06):
  19. """轻微呼吸式果冻抖动,循环。"""
  20. samples = []
  21. steps = 8
  22. for i in range(steps + 1):
  23. t = period * i / steps
  24. phase = 2 * math.pi * i / steps
  25. x = 1 + amp * math.sin(phase)
  26. y = 1 - amp * math.sin(phase) # x 胖 y 就矮,保持体积感
  27. samples.append((t, x, y))
  28. return {"scale": _scale_keys(samples)}
  29. def jiggle_win(period=0.9, amp=0.16):
  30. """中奖:更大幅度弹跳 + 轻微摇摆。"""
  31. scale_samples, rot_samples = [], []
  32. steps = 6
  33. for i in range(steps + 1):
  34. t = period * i / steps
  35. phase = 2 * math.pi * i / steps
  36. decay = 1 - (i / steps) * 0.3
  37. x = 1 + amp * decay * math.cos(phase)
  38. y = 1 - amp * decay * math.cos(phase)
  39. scale_samples.append((t, x, y))
  40. rot_samples.append((t, 6 * decay * math.sin(phase)))
  41. return {"scale": _scale_keys(scale_samples), "rotate": _rotate_keys(rot_samples)}
  42. def attack():
  43. """攻击:先蓄力后猛冲再回正(不循环)。"""
  44. scale = [(0, 1, 1), (0.12, 0.85, 1.12), (0.26, 1.22, 0.85), (0.5, 1, 1)]
  45. rot = [(0, 0), (0.12, -8), (0.26, 13), (0.5, 0)]
  46. return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)}
  47. def hurt():
  48. """受击:快速回缩 + 衰减摇摆(不循环)。"""
  49. scale = [(0, 1, 1), (0.06, 1.14, 0.88), (0.4, 1, 1)]
  50. rot = [(0, 0), (0.08, -11), (0.18, 8), (0.28, -4), (0.4, 0)]
  51. return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)}
  52. def bounce_in():
  53. """入场:从无到有弹入,带过冲(不循环)。"""
  54. scale = [(0, 0, 0), (0.28, 1.15, 0.9), (0.4, 0.95, 1.08), (0.5, 1, 1)]
  55. rot = [(0, -12), (0.28, 4), (0.5, 0)]
  56. return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)}
  57. ANIM_FACTORY = {
  58. "idle": jiggle_idle,
  59. "win": jiggle_win,
  60. "attack": attack,
  61. "hurt": hurt,
  62. "bounce_in": bounce_in,
  63. }
  64. # ---------- 图片处理 ----------
  65. def remove_bg(img, bright=200, sat=38):
  66. """自动去背:把"亮且低饱和(白/浅灰/奶油)且与四边连通"的像素抠成透明。
  67. 用边界连通传播,所以果冻身上被颜色包住的白高光/白眼睛不会被误删。
  68. 纯 numpy 实现,不依赖 scipy;没装 numpy 时退回 PIL 泛洪。"""
  69. img = img.convert("RGBA")
  70. try:
  71. import numpy as np
  72. arr = np.array(img)
  73. rgb = arr[:, :, :3].astype(np.int16)
  74. mx = rgb.max(axis=2); mn = rgb.min(axis=2)
  75. bgcand = (mn >= bright) & ((mx - mn) <= sat) # 背景候选:亮 + 低饱和
  76. # 从靠近四边的一圈带里取种子(最外 1~2px 常是透明黑边,要往里一点)
  77. H, W = bgcand.shape
  78. band = max(10, H // 80)
  79. seed = np.zeros_like(bgcand)
  80. seed[:band, :] = True; seed[-band:, :] = True
  81. seed[:, :band] = True; seed[:, -band:] = True
  82. bg = bgcand & seed
  83. for _ in range(2000): # 从四边向内连通传播
  84. prev = int(bg.sum())
  85. new = bg.copy()
  86. new[1:, :] |= bg[:-1, :]; new[:-1, :] |= bg[1:, :]
  87. new[:, 1:] |= bg[:, :-1]; new[:, :-1] |= bg[:, 1:]
  88. new &= bgcand
  89. bg = new
  90. if int(bg.sum()) == prev:
  91. break
  92. # 羽化一圈,柔化果冻边缘的白色硬边
  93. edge = bg.copy()
  94. edge[1:, :] |= bg[:-1, :]; edge[:-1, :] |= bg[1:, :]
  95. edge[:, 1:] |= bg[:, :-1]; edge[:, :-1] |= bg[:, 1:]
  96. fringe = edge & ~bg & (mn >= bright - 30)
  97. arr[bg, 3] = 0
  98. arr[fringe, 3] = (arr[fringe, 3] * 0.35).astype(arr.dtype)
  99. return Image.fromarray(arr, "RGBA")
  100. except Exception:
  101. return _floodfill_bg(img, 236)
  102. def _floodfill_bg(img, thresh):
  103. """纯 PIL 兜底:先把透明区垫白(否则透明边会被当种子),再从四边泛洪近白背景。"""
  104. from PIL import ImageDraw
  105. w, h = img.size
  106. rgb = Image.new("RGB", (w, h), (255, 255, 255))
  107. rgb.paste(img.convert("RGB"), mask=img.split()[3]) # 用 alpha 贴,透明处保持白
  108. tol = 255 - thresh + 10
  109. seeds = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
  110. (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2)]
  111. for s in seeds:
  112. ImageDraw.floodfill(rgb, s, (255, 0, 255), thresh=tol)
  113. try:
  114. import numpy as np
  115. mask = (np.array(rgb) == (255, 0, 255)).all(axis=2)
  116. arr = np.array(img.convert("RGBA"))
  117. arr[mask, 3] = 0
  118. return Image.fromarray(arr, "RGBA")
  119. except Exception:
  120. out = img.convert("RGBA"); po, pr = out.load(), rgb.load()
  121. for y in range(h):
  122. for x in range(w):
  123. if pr[x, y] == (255, 0, 255):
  124. r, g, b, _ = po[x, y]; po[x, y] = (r, g, b, 0)
  125. return out
  126. def trim_to_content(img, pad=4):
  127. """裁掉透明边,留一点 padding。返回裁好的 RGBA。"""
  128. bbox = img.getbbox()
  129. if bbox:
  130. img = img.crop(bbox)
  131. if pad:
  132. w, h = img.size
  133. canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
  134. canvas.paste(img, (pad, pad))
  135. img = canvas
  136. return img
  137. # ---------- 三件套生成 ----------
  138. def write_atlas(atlas_path, png_name, region_name, w, h):
  139. """libgdx 传统格式 atlas,单区域,兼容 Cocos spine 运行时。"""
  140. lines = [
  141. png_name,
  142. f"size: {w},{h}",
  143. "format: RGBA8888",
  144. "filter: Linear,Linear",
  145. "repeat: none",
  146. region_name,
  147. " rotate: false",
  148. " xy: 0, 0",
  149. f" size: {w}, {h}",
  150. f" orig: {w}, {h}",
  151. " offset: 0, 0",
  152. " index: -1",
  153. "",
  154. ]
  155. with open(atlas_path, "w", encoding="utf-8") as f:
  156. f.write("\n".join(lines))
  157. def build_skeleton_json(char_id, w, h, animations):
  158. """单骨骼 region 绑定 + 程序化动画。骨骼原点在底部中心,便于"果冻不离地"挤压。"""
  159. anims = {}
  160. for name in animations:
  161. factory = ANIM_FACTORY.get(name)
  162. if factory is None:
  163. continue
  164. anims[name] = {"bones": {"body": factory()}}
  165. if not anims:
  166. anims["idle"] = {"bones": {"body": jiggle_idle()}}
  167. return {
  168. "skeleton": {
  169. "hash": char_id,
  170. "spine": "4.0.00",
  171. "x": -w / 2.0, "y": 0, "width": float(w), "height": float(h),
  172. "images": "./", "audio": "",
  173. },
  174. "bones": [
  175. {"name": "root"},
  176. {"name": "body", "parent": "root"},
  177. ],
  178. "slots": [
  179. {"name": "body", "bone": "body", "attachment": "body"},
  180. ],
  181. "skins": [
  182. {
  183. "name": "default",
  184. "attachments": {
  185. "body": {
  186. "body": {"x": 0, "y": h / 2.0, "width": w, "height": h}
  187. }
  188. },
  189. }
  190. ],
  191. "animations": anims,
  192. }
  193. def anim_data(animations):
  194. """返回给网页预览用的动画关键帧(与 skeleton 里同一套数据)。
  195. 结构: { name: {"duration": t, "scale": [{time,x,y}...], "rotate": [{time,value}...]} }
  196. """
  197. out = {}
  198. for name in animations:
  199. factory = ANIM_FACTORY.get(name)
  200. if factory is None:
  201. continue
  202. d = factory()
  203. scale = d.get("scale", [])
  204. rotate = d.get("rotate", [])
  205. times = [k["time"] for k in scale] + [k["time"] for k in rotate]
  206. out[name] = {
  207. "duration": max(times) if times else 1.0,
  208. "scale": scale,
  209. "rotate": rotate,
  210. }
  211. if not out:
  212. d = jiggle_idle()
  213. out["idle"] = {"duration": d["scale"][-1]["time"], "scale": d["scale"], "rotate": []}
  214. return out
  215. def build_character(char_id, image, out_dir, animations):
  216. """主入口:image(PIL RGBA) -> out_dir/<id>.{json,atlas,png}。返回 png 路径。"""
  217. os.makedirs(out_dir, exist_ok=True)
  218. img = trim_to_content(remove_bg(image)) # 先自动去白底,再裁透明边
  219. w, h = img.size
  220. png_name = f"{char_id}.png"
  221. png_path = os.path.join(out_dir, png_name)
  222. img.save(png_path)
  223. write_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, "body", w, h)
  224. skel = build_skeleton_json(char_id, w, h, animations)
  225. with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f:
  226. json.dump(skel, f, ensure_ascii=False, indent=2)
  227. return png_path