| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- """把一张角色透明图变成可在 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)}
- def taunt():
- """Boss 嘲讽:上身前压,举剑炫耀。"""
- scale = [(0, 1, 1), (0.14, 1.08, 0.94), (0.32, 0.98, 1.04), (0.55, 1, 1)]
- rot = [(0, 0), (0.16, -6), (0.34, 5), (0.55, 0)]
- return {"scale": _scale_keys(scale), "rotate": _rotate_keys(rot)}
- def explode_anim():
- """网页预览兜底;真正多部件爆开在 build_parts_skeleton_json 内定义。"""
- scale = [(0, 1, 1), (0.12, 1.2, 0.82), (0.35, 0.2, 0.2)]
- rot = [(0, 0), (0.16, 12), (0.35, -24)]
- 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,
- "taunt": taunt,
- "explode": explode_anim,
- }
- # ---------- 图片处理 ----------
- 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 write_multi_atlas(atlas_path, png_name, atlas_w, atlas_h, regions):
- lines = [
- png_name,
- f"size: {atlas_w},{atlas_h}",
- "format: RGBA8888",
- "filter: Linear,Linear",
- "repeat: none",
- ]
- for r in regions:
- lines.extend([
- r["name"],
- " rotate: false",
- f" xy: {r['x']}, {r['y']}",
- f" size: {r['w']}, {r['h']}",
- f" orig: {r['w']}, {r['h']}",
- " offset: 0, 0",
- " index: -1",
- ])
- lines.append("")
- 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(image.convert("RGBA"))
- 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
- def build_parts_skeleton_json(char_id, parts, atlas_w, atlas_h, animations):
- bones = [{"name": "root"}]
- slots = []
- attachments = {}
- for p in parts:
- name = p["id"]
- parent = p.get("parent", "root")
- bone = {"name": name, "parent": parent, "x": p.get("x", 0), "y": p.get("y", 0)}
- bones.append(bone)
- slots.append({"name": name, "bone": name, "attachment": name})
- attachments[name] = {
- name: {"x": 0, "y": 0, "width": p["w"], "height": p["h"]}
- }
- anims = {}
- if "idle" in animations:
- anims["idle"] = {"bones": {
- "torso": {"scale": _scale_keys([(0, 1, 1), (0.6, 1.03, 0.97), (1.2, 1, 1)])},
- "cape": {"rotate": _rotate_keys([(0, -2), (0.6, 3), (1.2, -2)])},
- "head": {"rotate": _rotate_keys([(0, 0), (0.6, -2), (1.2, 0)])},
- }}
- if "watch" in animations:
- anims["watch"] = {"bones": {
- "torso": {"rotate": _rotate_keys([(0, 0), (0.25, -3), (0.55, 3), (0.9, 0)])},
- "head": {"rotate": _rotate_keys([(0, 0), (0.25, -12), (0.55, 12), (0.9, 0)])},
- "horn_left": {"rotate": _rotate_keys([(0, 0), (0.55, -6), (0.9, 0)])},
- "horn_right": {"rotate": _rotate_keys([(0, 0), (0.55, 6), (0.9, 0)])},
- }}
- if "charge" in animations:
- anims["charge"] = {"bones": {
- "torso": {"scale": _scale_keys([(0, 1, 1), (0.35, 1.08, 0.92), (0.7, 1, 1)])},
- "crack_core": {"scale": _scale_keys([(0, 0.75, 0.75), (0.35, 1.6, 1.6), (0.7, 0.9, 0.9)])},
- "greatsword": {"rotate": _rotate_keys([(0, 0), (0.35, -20), (0.7, 0)])},
- }}
- if "coin_throw" in animations:
- anims["coin_throw"] = {"bones": {
- "left_arm": {"rotate": _rotate_keys([(0, 0), (0.2, 42), (0.45, -18), (0.75, 0)])},
- "coin_bag": {"rotate": _rotate_keys([(0, 0), (0.2, 28), (0.45, -22), (0.75, 0)])},
- "coin_splash": {
- "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": -170, "y": 120}, {"time": 0.75, "x": -40, "y": 20}],
- "scale": _scale_keys([(0, 0.35, 0.35), (0.25, 1.35, 1.35), (0.75, 0.75, 0.75)]),
- },
- "head": {"rotate": _rotate_keys([(0, 0), (0.35, -7), (0.75, 0)])},
- }}
- if "taunt" in animations or "attack" in animations:
- anims["taunt"] = {"bones": {
- "torso": {"rotate": _rotate_keys([(0, 0), (0.18, -7), (0.45, 4), (0.7, 0)])},
- "right_arm": {"rotate": _rotate_keys([(0, 0), (0.18, -38), (0.45, -18), (0.7, 0)])},
- "greatsword": {"rotate": _rotate_keys([(0, 0), (0.18, -46), (0.45, -22), (0.7, 0)])},
- "left_leg": {"scale": _scale_keys([(0, 1, 1), (0.25, 1.08, 0.92), (0.7, 1, 1)])},
- }}
- anims["attack"] = {"bones": {
- "torso": {"rotate": _rotate_keys([(0, -4), (0.16, 9), (0.34, -3), (0.55, 0)])},
- "right_arm": {"rotate": _rotate_keys([(0, -18), (0.16, 38), (0.34, -12), (0.55, 0)])},
- "greatsword": {"rotate": _rotate_keys([(0, -28), (0.16, 60), (0.34, -20), (0.55, 0)])},
- }}
- if "stomp" in animations:
- anims["stomp"] = {"bones": {
- "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}]},
- "right_leg": {"rotate": _rotate_keys([(0, 0), (0.16, -32), (0.32, 18), (0.55, 0)])},
- "defeated_hero_shadow": {"scale": _scale_keys([(0, 1, 1), (0.32, 1.35, 0.55), (0.55, 1, 1)])},
- "greatsword": {"rotate": _rotate_keys([(0, 0), (0.32, -18), (0.55, 0)])},
- }}
- if "hurt" in animations:
- anims["hurt"] = {"bones": {
- "torso": {"rotate": _rotate_keys([(0, 0), (0.08, 12), (0.22, -8), (0.42, 0)])},
- "head": {"rotate": _rotate_keys([(0, 0), (0.08, 18), (0.22, -10), (0.42, 0)])},
- "crack_core": {"scale": _scale_keys([(0, 0.8, 0.8), (0.12, 1.45, 1.45), (0.42, 1, 1)])},
- }}
- if "explode" in animations:
- dirs = {
- "head": (0, 260, 28), "torso": (0, 70, 0), "left_arm": (-260, 130, -80),
- "right_arm": (260, 160, 85), "greatsword": (340, 240, 120),
- "left_leg": (-170, -170, -45), "right_leg": (180, -160, 48),
- "cape": (-280, 220, -120), "horn_left": (-170, 280, -70),
- "horn_right": (170, 280, 70), "crack_core": (0, 30, 0),
- }
- bones_anim = {}
- for p in parts:
- dx, dy, rr = dirs.get(p["id"], (0, 160, 0))
- bones_anim[p["id"]] = {
- "translate": [{"time": 0, "x": 0, "y": 0}, {"time": 0.45, "x": dx, "y": dy}],
- "rotate": _rotate_keys([(0, 0), (0.45, rr)]),
- "scale": _scale_keys([(0, 1, 1), (0.18, 1.08, 1.08), (0.45, 0.35, 0.35)]),
- }
- anims["explode"] = {"bones": bones_anim}
- if "win" in animations and "hurt" in anims:
- anims["win"] = anims["hurt"]
- if not anims:
- anims["idle"] = {"bones": {"torso": jiggle_idle()}}
- return {
- "skeleton": {
- "hash": char_id,
- "spine": "4.0.00",
- "x": -atlas_w / 2.0, "y": -atlas_h / 2.0,
- "width": float(atlas_w), "height": float(atlas_h),
- "images": "./", "audio": "",
- },
- "bones": bones,
- "slots": slots,
- "skins": [{"name": "default", "attachments": attachments}],
- "animations": anims,
- }
- def build_parts_character(char_id, part_images, out_dir, animations, layout_parts):
- """多部件 Boss:part_images {part_id: PIL RGBA} -> 单 atlas + 多骨骼 JSON。"""
- os.makedirs(out_dir, exist_ok=True)
- packed = []
- x = 0
- atlas_h = 0
- for part in layout_parts:
- pid = part["id"]
- img = trim_to_content(part_images[pid].convert("RGBA"), pad=2)
- w, h = img.size
- packed.append({**part, "img": img, "x_atlas": x, "y_atlas": 0, "w": w, "h": h})
- x += w + 2
- atlas_h = max(atlas_h, h)
- atlas_w = max(1, x)
- atlas = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0))
- regions = []
- for p in packed:
- atlas.paste(p["img"], (p["x_atlas"], 0), p["img"])
- regions.append({"name": p["id"], "x": p["x_atlas"], "y": 0, "w": p["w"], "h": p["h"]})
- png_name = f"{char_id}.png"
- png_path = os.path.join(out_dir, png_name)
- atlas.save(png_path)
- write_multi_atlas(os.path.join(out_dir, f"{char_id}.atlas"), png_name, atlas_w, atlas_h, regions)
- skel = build_parts_skeleton_json(char_id, packed, atlas_w, atlas_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
|