spine_builder.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. def taunt():
  58. """Boss 嘲讽:上身前压,举剑炫耀。"""
  59. scale = [(0, 1, 1), (0.14, 1.08, 0.94), (0.32, 0.98, 1.04), (0.55, 1, 1)]
  60. rot = [(0, 0), (0.16, -6), (0.34, 5), (0.55, 0)]
  61. return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)}
  62. def explode_anim():
  63. """网页预览兜底;真正多部件爆开在 build_parts_skeleton_json 内定义。"""
  64. scale = [(0, 1, 1), (0.12, 1.2, 0.82), (0.35, 0.2, 0.2)]
  65. rot = [(0, 0), (0.16, 12), (0.35, -24)]
  66. return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)}
  67. ANIM_FACTORY = {
  68. "idle": jiggle_idle,
  69. "win": jiggle_win,
  70. "attack": attack,
  71. "hurt": hurt,
  72. "bounce_in": bounce_in,
  73. "taunt": taunt,
  74. "explode": explode_anim,
  75. }
  76. # ---------- 图片处理 ----------
  77. def remove_bg(img, bright=200, sat=38):
  78. """自动去背:把"亮且低饱和(白/浅灰/奶油)且与四边连通"的像素抠成透明。
  79. 用边界连通传播,所以果冻身上被颜色包住的白高光/白眼睛不会被误删。
  80. 纯 numpy 实现,不依赖 scipy;没装 numpy 时退回 PIL 泛洪。"""
  81. img = img.convert("RGBA")
  82. try:
  83. import numpy as np
  84. arr = np.array(img)
  85. rgb = arr[:, :, :3].astype(np.int16)
  86. mx = rgb.max(axis=2); mn = rgb.min(axis=2)
  87. bgcand = (mn >= bright) & ((mx - mn) <= sat) # 背景候选:亮 + 低饱和
  88. # 从靠近四边的一圈带里取种子(最外 1~2px 常是透明黑边,要往里一点)
  89. H, W = bgcand.shape
  90. band = max(10, H // 80)
  91. seed = np.zeros_like(bgcand)
  92. seed[:band, :] = True; seed[-band:, :] = True
  93. seed[:, :band] = True; seed[:, -band:] = True
  94. bg = bgcand & seed
  95. for _ in range(2000): # 从四边向内连通传播
  96. prev = int(bg.sum())
  97. new = bg.copy()
  98. new[1:, :] |= bg[:-1, :]; new[:-1, :] |= bg[1:, :]
  99. new[:, 1:] |= bg[:, :-1]; new[:, :-1] |= bg[:, 1:]
  100. new &= bgcand
  101. bg = new
  102. if int(bg.sum()) == prev:
  103. break
  104. # 羽化一圈,柔化果冻边缘的白色硬边
  105. edge = bg.copy()
  106. edge[1:, :] |= bg[:-1, :]; edge[:-1, :] |= bg[1:, :]
  107. edge[:, 1:] |= bg[:, :-1]; edge[:, :-1] |= bg[:, 1:]
  108. fringe = edge & ~bg & (mn >= bright - 30)
  109. arr[bg, 3] = 0
  110. arr[fringe, 3] = (arr[fringe, 3] * 0.35).astype(arr.dtype)
  111. return Image.fromarray(arr, "RGBA")
  112. except Exception:
  113. return _floodfill_bg(img, 236)
  114. def _floodfill_bg(img, thresh):
  115. """纯 PIL 兜底:先把透明区垫白(否则透明边会被当种子),再从四边泛洪近白背景。"""
  116. from PIL import ImageDraw
  117. w, h = img.size
  118. rgb = Image.new("RGB", (w, h), (255, 255, 255))
  119. rgb.paste(img.convert("RGB"), mask=img.split()[3]) # 用 alpha 贴,透明处保持白
  120. tol = 255 - thresh + 10
  121. seeds = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
  122. (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2)]
  123. for s in seeds:
  124. ImageDraw.floodfill(rgb, s, (255, 0, 255), thresh=tol)
  125. try:
  126. import numpy as np
  127. mask = (np.array(rgb) == (255, 0, 255)).all(axis=2)
  128. arr = np.array(img.convert("RGBA"))
  129. arr[mask, 3] = 0
  130. return Image.fromarray(arr, "RGBA")
  131. except Exception:
  132. out = img.convert("RGBA"); po, pr = out.load(), rgb.load()
  133. for y in range(h):
  134. for x in range(w):
  135. if pr[x, y] == (255, 0, 255):
  136. r, g, b, _ = po[x, y]; po[x, y] = (r, g, b, 0)
  137. return out
  138. def trim_to_content(img, pad=4):
  139. """裁掉透明边,留一点 padding。返回裁好的 RGBA。"""
  140. bbox = img.getbbox()
  141. if bbox:
  142. img = img.crop(bbox)
  143. if pad:
  144. w, h = img.size
  145. canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
  146. canvas.paste(img, (pad, pad))
  147. img = canvas
  148. return img
  149. # ---------- 三件套生成 ----------
  150. def write_atlas(atlas_path, png_name, region_name, w, h):
  151. """libgdx 传统格式 atlas,单区域,兼容 Cocos spine 运行时。"""
  152. lines = [
  153. png_name,
  154. f"size: {w},{h}",
  155. "format: RGBA8888",
  156. "filter: Linear,Linear",
  157. "repeat: none",
  158. region_name,
  159. " rotate: false",
  160. " xy: 0, 0",
  161. f" size: {w}, {h}",
  162. f" orig: {w}, {h}",
  163. " offset: 0, 0",
  164. " index: -1",
  165. "",
  166. ]
  167. with open(atlas_path, "w", encoding="utf-8") as f:
  168. f.write("\n".join(lines))
  169. def write_multi_atlas(atlas_path, png_name, atlas_w, atlas_h, regions):
  170. lines = [
  171. png_name,
  172. f"size: {atlas_w},{atlas_h}",
  173. "format: RGBA8888",
  174. "filter: Linear,Linear",
  175. "repeat: none",
  176. ]
  177. for r in regions:
  178. lines.extend([
  179. r["name"],
  180. " rotate: false",
  181. f" xy: {r['x']}, {r['y']}",
  182. f" size: {r['w']}, {r['h']}",
  183. f" orig: {r['w']}, {r['h']}",
  184. " offset: 0, 0",
  185. " index: -1",
  186. ])
  187. lines.append("")
  188. with open(atlas_path, "w", encoding="utf-8") as f:
  189. f.write("\n".join(lines))
  190. def build_skeleton_json(char_id, w, h, animations):
  191. """单骨骼 region 绑定 + 程序化动画。骨骼原点在底部中心,便于"果冻不离地"挤压。"""
  192. anims = {}
  193. for name in animations:
  194. factory = ANIM_FACTORY.get(name)
  195. if factory is None:
  196. continue
  197. anims[name] = {"bones": {"body": factory()}}
  198. if not anims:
  199. anims["idle"] = {"bones": {"body": jiggle_idle()}}
  200. return {
  201. "skeleton": {
  202. "hash": char_id,
  203. "spine": "4.0.00",
  204. "x": -w / 2.0, "y": 0, "width": float(w), "height": float(h),
  205. "images": "./", "audio": "",
  206. },
  207. "bones": [
  208. {"name": "root"},
  209. {"name": "body", "parent": "root"},
  210. ],
  211. "slots": [
  212. {"name": "body", "bone": "body", "attachment": "body"},
  213. ],
  214. "skins": [
  215. {
  216. "name": "default",
  217. "attachments": {
  218. "body": {
  219. "body": {"x": 0, "y": h / 2.0, "width": w, "height": h}
  220. }
  221. },
  222. }
  223. ],
  224. "animations": anims,
  225. }
  226. def anim_data(animations):
  227. """返回给网页预览用的动画关键帧(与 skeleton 里同一套数据)。
  228. 结构: { name: {"duration": t, "scale": [{time,x,y}...], "rotate": [{time,value}...]} }
  229. """
  230. out = {}
  231. for name in animations:
  232. factory = ANIM_FACTORY.get(name)
  233. if factory is None:
  234. continue
  235. d = factory()
  236. scale = d.get("scale", [])
  237. rotate = d.get("rotate", [])
  238. times = [k["time"] for k in scale] + [k["time"] for k in rotate]
  239. out[name] = {
  240. "duration": max(times) if times else 1.0,
  241. "scale": scale,
  242. "rotate": rotate,
  243. }
  244. if not out:
  245. d = jiggle_idle()
  246. out["idle"] = {"duration": d["scale"][-1]["time"], "scale": d["scale"], "rotate": []}
  247. return out
  248. def build_character(char_id, image, out_dir, animations):
  249. """主入口:image(PIL RGBA) -> out_dir/<id>.{json,atlas,png}。返回 png 路径。"""
  250. os.makedirs(out_dir, exist_ok=True)
  251. img = trim_to_content(remove_bg(image)) # 先自动去白底,再裁透明边
  252. w, h = img.size
  253. png_name = f"{char_id}.png"
  254. png_path = os.path.join(out_dir, png_name)
  255. img.save(png_path)
  256. write_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, "body", w, h)
  257. skel = build_skeleton_json(char_id, w, h, animations)
  258. with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f:
  259. json.dump(skel, f, ensure_ascii=False, indent=2)
  260. return png_path
  261. def build_parts_skeleton_json(char_id, parts, atlas_w, atlas_h, animations):
  262. bones = [{"name": "root"}]
  263. slots = []
  264. attachments = {}
  265. for p in parts:
  266. name = p["id"]
  267. parent = p.get("parent", "root")
  268. bone = {"name": name, "parent": parent, "x": p.get("x", 0), "y": p.get("y", 0)}
  269. bones.append(bone)
  270. slots.append({"name": name, "bone": name, "attachment": name})
  271. attachments[name] = {
  272. name: {"x": 0, "y": 0, "width": p["w"], "height": p["h"]}
  273. }
  274. anims = {}
  275. if "idle" in animations:
  276. anims["idle"] = {"bones": {
  277. "torso": {"scale": _scale_keys([(0, 1, 1), (0.6, 1.03, 0.97), (1.2, 1, 1)])},
  278. "cape": {"rotate": _rotate_keys([(0, -2), (0.6, 3), (1.2, -2)])},
  279. "head": {"rotate": _rotate_keys([(0, 0), (0.6, -2), (1.2, 0)])},
  280. }}
  281. if "watch" in animations:
  282. anims["watch"] = {"bones": {
  283. "torso": {"rotate": _rotate_keys([(0, 0), (0.25, -3), (0.55, 3), (0.9, 0)])},
  284. "head": {"rotate": _rotate_keys([(0, 0), (0.25, -12), (0.55, 12), (0.9, 0)])},
  285. "horn_left": {"rotate": _rotate_keys([(0, 0), (0.55, -6), (0.9, 0)])},
  286. "horn_right": {"rotate": _rotate_keys([(0, 0), (0.55, 6), (0.9, 0)])},
  287. }}
  288. if "charge" in animations:
  289. anims["charge"] = {"bones": {
  290. "torso": {"scale": _scale_keys([(0, 1, 1), (0.35, 1.08, 0.92), (0.7, 1, 1)])},
  291. "crack_core": {"scale": _scale_keys([(0, 0.75, 0.75), (0.35, 1.6, 1.6), (0.7, 0.9, 0.9)])},
  292. "greatsword": {"rotate": _rotate_keys([(0, 0), (0.35, -20), (0.7, 0)])},
  293. }}
  294. if "coin_throw" in animations:
  295. anims["coin_throw"] = {"bones": {
  296. "left_arm": {"rotate": _rotate_keys([(0, 0), (0.2, 42), (0.45, -18), (0.75, 0)])},
  297. "coin_bag": {"rotate": _rotate_keys([(0, 0), (0.2, 28), (0.45, -22), (0.75, 0)])},
  298. "coin_splash": {
  299. "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": -170, "y": 120}, {"time": 0.75, "x": -40, "y": 20}],
  300. "scale": _scale_keys([(0, 0.35, 0.35), (0.25, 1.35, 1.35), (0.75, 0.75, 0.75)]),
  301. },
  302. "head": {"rotate": _rotate_keys([(0, 0), (0.35, -7), (0.75, 0)])},
  303. }}
  304. if "taunt" in animations or "attack" in animations:
  305. anims["taunt"] = {"bones": {
  306. "torso": {"rotate": _rotate_keys([(0, 0), (0.18, -7), (0.45, 4), (0.7, 0)])},
  307. "right_arm": {"rotate": _rotate_keys([(0, 0), (0.18, -38), (0.45, -18), (0.7, 0)])},
  308. "greatsword": {"rotate": _rotate_keys([(0, 0), (0.18, -46), (0.45, -22), (0.7, 0)])},
  309. "left_leg": {"scale": _scale_keys([(0, 1, 1), (0.25, 1.08, 0.92), (0.7, 1, 1)])},
  310. }}
  311. anims["attack"] = {"bones": {
  312. "torso": {"rotate": _rotate_keys([(0, -4), (0.16, 9), (0.34, -3), (0.55, 0)])},
  313. "right_arm": {"rotate": _rotate_keys([(0, -18), (0.16, 38), (0.34, -12), (0.55, 0)])},
  314. "greatsword": {"rotate": _rotate_keys([(0, -28), (0.16, 60), (0.34, -20), (0.55, 0)])},
  315. }}
  316. if "stomp" in animations:
  317. anims["stomp"] = {"bones": {
  318. "torso": {"translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.16, "x": 0, "y": 34}, {"time": 0.32, "x": 0, "y": -16}, {"time": 0.55, "x": 0, "y": 0}]},
  319. "right_leg": {"rotate": _rotate_keys([(0, 0), (0.16, -32), (0.32, 18), (0.55, 0)])},
  320. "defeated_hero_shadow": {"scale": _scale_keys([(0, 1, 1), (0.32, 1.35, 0.55), (0.55, 1, 1)])},
  321. "greatsword": {"rotate": _rotate_keys([(0, 0), (0.32, -18), (0.55, 0)])},
  322. }}
  323. if "hurt" in animations:
  324. anims["hurt"] = {"bones": {
  325. "torso": {"rotate": _rotate_keys([(0, 0), (0.08, 12), (0.22, -8), (0.42, 0)])},
  326. "head": {"rotate": _rotate_keys([(0, 0), (0.08, 18), (0.22, -10), (0.42, 0)])},
  327. "crack_core": {"scale": _scale_keys([(0, 0.8, 0.8), (0.12, 1.45, 1.45), (0.42, 1, 1)])},
  328. }}
  329. if "explode" in animations:
  330. dirs = {
  331. "head": (0, 260, 28), "torso": (0, 70, 0), "left_arm": (-260, 130, -80),
  332. "right_arm": (260, 160, 85), "greatsword": (340, 240, 120),
  333. "left_leg": (-170, -170, -45), "right_leg": (180, -160, 48),
  334. "cape": (-280, 220, -120), "horn_left": (-170, 280, -70),
  335. "horn_right": (170, 280, 70), "crack_core": (0, 30, 0),
  336. }
  337. bones_anim = {}
  338. for p in parts:
  339. dx, dy, rr = dirs.get(p["id"], (0, 160, 0))
  340. bones_anim[p["id"]] = {
  341. "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": dx, "y": dy}],
  342. "rotate": _rotate_keys([(0, 0), (0.45, rr)]),
  343. "scale": _scale_keys([(0, 1, 1), (0.18, 1.08, 1.08), (0.45, 0.35, 0.35)]),
  344. }
  345. anims["explode"] = {"bones": bones_anim}
  346. if "win" in animations and "hurt" in anims:
  347. anims["win"] = anims["hurt"]
  348. if not anims:
  349. anims["idle"] = {"bones": {"torso": jiggle_idle()}}
  350. return {
  351. "skeleton": {
  352. "hash": char_id,
  353. "spine": "4.0.00",
  354. "x": -atlas_w / 2.0, "y": -atlas_h / 2.0,
  355. "width": float(atlas_w), "height": float(atlas_h),
  356. "images": "./", "audio": "",
  357. },
  358. "bones": bones,
  359. "slots": slots,
  360. "skins": [{"name": "default", "attachments": attachments}],
  361. "animations": anims,
  362. }
  363. def build_parts_character(char_id, part_images, out_dir, animations, layout_parts):
  364. """多部件 Boss:part_images {part_id: PIL RGBA} -> 单 atlas + 多骨骼 JSON。"""
  365. os.makedirs(out_dir, exist_ok=True)
  366. packed = []
  367. x = 0
  368. atlas_h = 0
  369. for part in layout_parts:
  370. pid = part["id"]
  371. img = trim_to_content(remove_bg(part_images[pid]), pad=2)
  372. w, h = img.size
  373. packed.append({**part, "img": img, "x_atlas": x, "y_atlas": 0, "w": w, "h": h})
  374. x += w + 2
  375. atlas_h = max(atlas_h, h)
  376. atlas_w = max(1, x)
  377. atlas = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0))
  378. regions = []
  379. for p in packed:
  380. atlas.paste(p["img"], (p["x_atlas"], 0), p["img"])
  381. regions.append({"name": p["id"], "x": p["x_atlas"], "y": 0, "w": p["w"], "h": p["h"]})
  382. png_name = f"{char_id}.png"
  383. png_path = os.path.join(out_dir, png_name)
  384. atlas.save(png_path)
  385. write_multi_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, atlas_w, atlas_h, regions)
  386. skel = build_parts_skeleton_json(char_id, packed, atlas_w, atlas_h, animations)
  387. with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f:
  388. json.dump(skel, f, ensure_ascii=False, indent=2)
  389. return png_path