"""把一张角色透明图变成可在 Cocos(Spine 运行时) 播放的骨骼动画三件套: .json (skeleton 数据) .atlas (图集描述) .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/.{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, write_preview=True): """多部件 Boss:part_images {part_id: PIL RGBA} -> 单 atlas + 多骨骼 JSON。""" os.makedirs(out_dir, exist_ok=True) parts_dir = os.path.join(out_dir, f"{char_id}_parts") os.makedirs(parts_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) img.save(os.path.join(parts_dir, f"{pid}.png")) 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) if write_preview: write_parts_preview(char_id, packed, out_dir) return png_path def write_part_pngs_from_files(char_id, out_dir): """Export individual part PNGs from an existing atlas.""" png_path = os.path.join(out_dir, f"{char_id}.png") atlas_path = os.path.join(out_dir, f"{char_id}.atlas") if not (os.path.isfile(png_path) and os.path.isfile(atlas_path)): return [] atlas = Image.open(png_path).convert("RGBA") regions = _read_atlas_regions(atlas_path) if not regions: return [] parts_dir = os.path.join(out_dir, f"{char_id}_parts") os.makedirs(parts_dir, exist_ok=True) files = [] for name, r in regions.items(): img = atlas.crop((r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"])) img.save(os.path.join(parts_dir, f"{name}.png")) files.append({ "id": name, "file": f"characters/{char_id}_parts/{name}.png", "w": r["w"], "h": r["h"], }) return files def write_parts_preview(char_id, packed, out_dir): """Write a human-readable assembled preview for multi-part characters.""" canvas = Image.new("RGBA", (1000, 1000), (0, 0, 0, 0)) by_id = {p["id"]: p for p in packed} world_cache = {} def world_pos(pid): if pid in world_cache: return world_cache[pid] p = by_id.get(pid) if not p: return 0, 0 px, py = p.get("x", 0), p.get("y", 0) parent = p.get("parent") if parent and parent != "root": ox, oy = world_pos(parent) px += ox py += oy world_cache[pid] = (px, py) return px, py order = [ "cape", "defeated_hero_shadow", "left_leg", "right_leg", "torso", "left_arm", "right_arm", "greatsword", "coin_bag", "head", "horn_left", "horn_right", "crack_core", "coin_splash", ] for pid in order + [p["id"] for p in packed if p["id"] not in order]: p = by_id.get(pid) if not p: continue img = p["img"] wx, wy = world_pos(pid) x = int(500 + wx - img.width / 2) y = int(760 - wy - img.height / 2) canvas.paste(img, (x, y), img) preview = trim_to_content(canvas, pad=16) preview.save(os.path.join(out_dir, f"{char_id}_preview.png")) def _read_atlas_regions(atlas_path): lines = [x.rstrip("\n") for x in open(atlas_path, encoding="utf-8")] regions = {} i = 5 while i < len(lines): name = lines[i].strip() if not name: i += 1 continue xy = size = None j = i + 1 while j < min(i + 8, len(lines)): s = lines[j].strip() if s.startswith("xy:"): xy = [int(x.strip()) for x in s.split(":", 1)[1].split(",")] if s.startswith("size:"): size = [int(x.strip()) for x in s.split(":", 1)[1].split(",")] j += 1 if xy and size: regions[name] = {"x": xy[0], "y": xy[1], "w": size[0], "h": size[1]} i += 7 return regions def write_parts_preview_from_files(char_id, out_dir): """Rebuild preview from existing png/atlas/json files.""" png_path = os.path.join(out_dir, f"{char_id}.png") atlas_path = os.path.join(out_dir, f"{char_id}.atlas") json_path = os.path.join(out_dir, f"{char_id}.json") if not (os.path.isfile(png_path) and os.path.isfile(atlas_path) and os.path.isfile(json_path)): return "" atlas = Image.open(png_path).convert("RGBA") regions = _read_atlas_regions(atlas_path) skel = json.load(open(json_path, encoding="utf-8")) bones = {b["name"]: b for b in skel.get("bones", [])} packed = [] for slot in skel.get("slots", []): name = slot.get("attachment") or slot.get("name") r = regions.get(name) b = bones.get(slot.get("bone"), {}) if not r: continue img = atlas.crop((r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"])) packed.append({ "id": name, "img": img, "x": b.get("x", 0), "y": b.get("y", 0), "parent": b.get("parent", "root"), }) if not packed: return "" write_parts_preview(char_id, packed, out_dir) return os.path.join(out_dir, f"{char_id}_preview.png")