spine_builder.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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 trim_to_content(img, pad=4):
  78. """裁掉透明边,留一点 padding。返回裁好的 RGBA。"""
  79. bbox = img.getbbox()
  80. if bbox:
  81. img = img.crop(bbox)
  82. if pad:
  83. w, h = img.size
  84. canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
  85. canvas.paste(img, (pad, pad))
  86. img = canvas
  87. return img
  88. # ---------- 三件套生成 ----------
  89. def write_atlas(atlas_path, png_name, region_name, w, h):
  90. """libgdx 传统格式 atlas,单区域,兼容 Cocos spine 运行时。"""
  91. lines = [
  92. png_name,
  93. f"size: {w},{h}",
  94. "format: RGBA8888",
  95. "filter: Linear,Linear",
  96. "repeat: none",
  97. region_name,
  98. " rotate: false",
  99. " xy: 0, 0",
  100. f" size: {w}, {h}",
  101. f" orig: {w}, {h}",
  102. " offset: 0, 0",
  103. " index: -1",
  104. "",
  105. ]
  106. with open(atlas_path, "w", encoding="utf-8") as f:
  107. f.write("\n".join(lines))
  108. def write_multi_atlas(atlas_path, png_name, atlas_w, atlas_h, regions):
  109. lines = [
  110. png_name,
  111. f"size: {atlas_w},{atlas_h}",
  112. "format: RGBA8888",
  113. "filter: Linear,Linear",
  114. "repeat: none",
  115. ]
  116. for r in regions:
  117. lines.extend([
  118. r["name"],
  119. " rotate: false",
  120. f" xy: {r['x']}, {r['y']}",
  121. f" size: {r['w']}, {r['h']}",
  122. f" orig: {r['w']}, {r['h']}",
  123. " offset: 0, 0",
  124. " index: -1",
  125. ])
  126. lines.append("")
  127. with open(atlas_path, "w", encoding="utf-8") as f:
  128. f.write("\n".join(lines))
  129. def build_skeleton_json(char_id, w, h, animations):
  130. """单骨骼 region 绑定 + 程序化动画。骨骼原点在底部中心,便于"果冻不离地"挤压。"""
  131. anims = {}
  132. for name in animations:
  133. factory = ANIM_FACTORY.get(name)
  134. if factory is None:
  135. continue
  136. anims[name] = {"bones": {"body": factory()}}
  137. if not anims:
  138. anims["idle"] = {"bones": {"body": jiggle_idle()}}
  139. return {
  140. "skeleton": {
  141. "hash": char_id,
  142. "spine": "4.0.00",
  143. "x": -w / 2.0, "y": 0, "width": float(w), "height": float(h),
  144. "images": "./", "audio": "",
  145. },
  146. "bones": [
  147. {"name": "root"},
  148. {"name": "body", "parent": "root"},
  149. ],
  150. "slots": [
  151. {"name": "body", "bone": "body", "attachment": "body"},
  152. ],
  153. "skins": [
  154. {
  155. "name": "default",
  156. "attachments": {
  157. "body": {
  158. "body": {"x": 0, "y": h / 2.0, "width": w, "height": h}
  159. }
  160. },
  161. }
  162. ],
  163. "animations": anims,
  164. }
  165. def anim_data(animations):
  166. """返回给网页预览用的动画关键帧(与 skeleton 里同一套数据)。
  167. 结构: { name: {"duration": t, "scale": [{time,x,y}...], "rotate": [{time,value}...]} }
  168. """
  169. out = {}
  170. for name in animations:
  171. factory = ANIM_FACTORY.get(name)
  172. if factory is None:
  173. continue
  174. d = factory()
  175. scale = d.get("scale", [])
  176. rotate = d.get("rotate", [])
  177. times = [k["time"] for k in scale] + [k["time"] for k in rotate]
  178. out[name] = {
  179. "duration": max(times) if times else 1.0,
  180. "scale": scale,
  181. "rotate": rotate,
  182. }
  183. if not out:
  184. d = jiggle_idle()
  185. out["idle"] = {"duration": d["scale"][-1]["time"], "scale": d["scale"], "rotate": []}
  186. return out
  187. def build_character(char_id, image, out_dir, animations):
  188. """主入口:image(PIL RGBA) -> out_dir/<id>.{json,atlas,png}。返回 png 路径。"""
  189. os.makedirs(out_dir, exist_ok=True)
  190. img = trim_to_content(image.convert("RGBA"))
  191. w, h = img.size
  192. png_name = f"{char_id}.png"
  193. png_path = os.path.join(out_dir, png_name)
  194. img.save(png_path)
  195. write_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, "body", w, h)
  196. skel = build_skeleton_json(char_id, w, h, animations)
  197. with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f:
  198. json.dump(skel, f, ensure_ascii=False, indent=2)
  199. return png_path
  200. def build_parts_skeleton_json(char_id, parts, atlas_w, atlas_h, animations):
  201. bones = [{"name": "root"}]
  202. slots = []
  203. attachments = {}
  204. for p in parts:
  205. name = p["id"]
  206. parent = p.get("parent", "root")
  207. bone = {"name": name, "parent": parent, "x": p.get("x", 0), "y": p.get("y", 0)}
  208. bones.append(bone)
  209. slots.append({"name": name, "bone": name, "attachment": name})
  210. attachments[name] = {
  211. name: {"x": 0, "y": 0, "width": p["w"], "height": p["h"]}
  212. }
  213. anims = {}
  214. if "idle" in animations:
  215. anims["idle"] = {"bones": {
  216. "torso": {"scale": _scale_keys([(0, 1, 1), (0.6, 1.03, 0.97), (1.2, 1, 1)])},
  217. "cape": {"rotate": _rotate_keys([(0, -2), (0.6, 3), (1.2, -2)])},
  218. "head": {"rotate": _rotate_keys([(0, 0), (0.6, -2), (1.2, 0)])},
  219. }}
  220. if "watch" in animations:
  221. anims["watch"] = {"bones": {
  222. "torso": {"rotate": _rotate_keys([(0, 0), (0.25, -3), (0.55, 3), (0.9, 0)])},
  223. "head": {"rotate": _rotate_keys([(0, 0), (0.25, -12), (0.55, 12), (0.9, 0)])},
  224. "horn_left": {"rotate": _rotate_keys([(0, 0), (0.55, -6), (0.9, 0)])},
  225. "horn_right": {"rotate": _rotate_keys([(0, 0), (0.55, 6), (0.9, 0)])},
  226. }}
  227. if "charge" in animations:
  228. anims["charge"] = {"bones": {
  229. "torso": {"scale": _scale_keys([(0, 1, 1), (0.35, 1.08, 0.92), (0.7, 1, 1)])},
  230. "crack_core": {"scale": _scale_keys([(0, 0.75, 0.75), (0.35, 1.6, 1.6), (0.7, 0.9, 0.9)])},
  231. "greatsword": {"rotate": _rotate_keys([(0, 0), (0.35, -20), (0.7, 0)])},
  232. }}
  233. if "coin_throw" in animations:
  234. anims["coin_throw"] = {"bones": {
  235. "left_arm": {"rotate": _rotate_keys([(0, 0), (0.2, 42), (0.45, -18), (0.75, 0)])},
  236. "coin_bag": {"rotate": _rotate_keys([(0, 0), (0.2, 28), (0.45, -22), (0.75, 0)])},
  237. "coin_splash": {
  238. "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": -170, "y": 120}, {"time": 0.75, "x": -40, "y": 20}],
  239. "scale": _scale_keys([(0, 0.35, 0.35), (0.25, 1.35, 1.35), (0.75, 0.75, 0.75)]),
  240. },
  241. "head": {"rotate": _rotate_keys([(0, 0), (0.35, -7), (0.75, 0)])},
  242. }}
  243. if "taunt" in animations or "attack" in animations:
  244. anims["taunt"] = {"bones": {
  245. "torso": {"rotate": _rotate_keys([(0, 0), (0.18, -7), (0.45, 4), (0.7, 0)])},
  246. "right_arm": {"rotate": _rotate_keys([(0, 0), (0.18, -38), (0.45, -18), (0.7, 0)])},
  247. "greatsword": {"rotate": _rotate_keys([(0, 0), (0.18, -46), (0.45, -22), (0.7, 0)])},
  248. "left_leg": {"scale": _scale_keys([(0, 1, 1), (0.25, 1.08, 0.92), (0.7, 1, 1)])},
  249. }}
  250. anims["attack"] = {"bones": {
  251. "torso": {"rotate": _rotate_keys([(0, -4), (0.16, 9), (0.34, -3), (0.55, 0)])},
  252. "right_arm": {"rotate": _rotate_keys([(0, -18), (0.16, 38), (0.34, -12), (0.55, 0)])},
  253. "greatsword": {"rotate": _rotate_keys([(0, -28), (0.16, 60), (0.34, -20), (0.55, 0)])},
  254. }}
  255. if "stomp" in animations:
  256. anims["stomp"] = {"bones": {
  257. "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}]},
  258. "right_leg": {"rotate": _rotate_keys([(0, 0), (0.16, -32), (0.32, 18), (0.55, 0)])},
  259. "defeated_hero_shadow": {"scale": _scale_keys([(0, 1, 1), (0.32, 1.35, 0.55), (0.55, 1, 1)])},
  260. "greatsword": {"rotate": _rotate_keys([(0, 0), (0.32, -18), (0.55, 0)])},
  261. }}
  262. if "hurt" in animations:
  263. anims["hurt"] = {"bones": {
  264. "torso": {"rotate": _rotate_keys([(0, 0), (0.08, 12), (0.22, -8), (0.42, 0)])},
  265. "head": {"rotate": _rotate_keys([(0, 0), (0.08, 18), (0.22, -10), (0.42, 0)])},
  266. "crack_core": {"scale": _scale_keys([(0, 0.8, 0.8), (0.12, 1.45, 1.45), (0.42, 1, 1)])},
  267. }}
  268. if "explode" in animations:
  269. dirs = {
  270. "head": (0, 260, 28), "torso": (0, 70, 0), "left_arm": (-260, 130, -80),
  271. "right_arm": (260, 160, 85), "greatsword": (340, 240, 120),
  272. "left_leg": (-170, -170, -45), "right_leg": (180, -160, 48),
  273. "cape": (-280, 220, -120), "horn_left": (-170, 280, -70),
  274. "horn_right": (170, 280, 70), "crack_core": (0, 30, 0),
  275. }
  276. bones_anim = {}
  277. for p in parts:
  278. dx, dy, rr = dirs.get(p["id"], (0, 160, 0))
  279. bones_anim[p["id"]] = {
  280. "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": dx, "y": dy}],
  281. "rotate": _rotate_keys([(0, 0), (0.45, rr)]),
  282. "scale": _scale_keys([(0, 1, 1), (0.18, 1.08, 1.08), (0.45, 0.35, 0.35)]),
  283. }
  284. anims["explode"] = {"bones": bones_anim}
  285. if "win" in animations and "hurt" in anims:
  286. anims["win"] = anims["hurt"]
  287. if not anims:
  288. anims["idle"] = {"bones": {"torso": jiggle_idle()}}
  289. return {
  290. "skeleton": {
  291. "hash": char_id,
  292. "spine": "4.0.00",
  293. "x": -atlas_w / 2.0, "y": -atlas_h / 2.0,
  294. "width": float(atlas_w), "height": float(atlas_h),
  295. "images": "./", "audio": "",
  296. },
  297. "bones": bones,
  298. "slots": slots,
  299. "skins": [{"name": "default", "attachments": attachments}],
  300. "animations": anims,
  301. }
  302. def build_parts_character(char_id, part_images, out_dir, animations, layout_parts):
  303. """多部件 Boss:part_images {part_id: PIL RGBA} -> 单 atlas + 多骨骼 JSON。"""
  304. os.makedirs(out_dir, exist_ok=True)
  305. packed = []
  306. x = 0
  307. atlas_h = 0
  308. for part in layout_parts:
  309. pid = part["id"]
  310. img = trim_to_content(part_images[pid].convert("RGBA"), pad=2)
  311. w, h = img.size
  312. packed.append({**part, "img": img, "x_atlas": x, "y_atlas": 0, "w": w, "h": h})
  313. x += w + 2
  314. atlas_h = max(atlas_h, h)
  315. atlas_w = max(1, x)
  316. atlas = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0))
  317. regions = []
  318. for p in packed:
  319. atlas.paste(p["img"], (p["x_atlas"], 0), p["img"])
  320. regions.append({"name": p["id"], "x": p["x_atlas"], "y": 0, "w": p["w"], "h": p["h"]})
  321. png_name = f"{char_id}.png"
  322. png_path = os.path.join(out_dir, png_name)
  323. atlas.save(png_path)
  324. write_multi_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, atlas_w, atlas_h, regions)
  325. skel = build_parts_skeleton_json(char_id, packed, atlas_w, atlas_h, animations)
  326. with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f:
  327. json.dump(skel, f, ensure_ascii=False, indent=2)
  328. return png_path