Эх сурвалжийг харах

Add multipart boss spine pipeline

bang 2 долоо хоног өмнө
parent
commit
aec8697288
5 өөрчлөгдсөн 208 нэмэгдсэн , 19 устгасан
  1. 3 0
      exporter.py
  2. 41 15
      pipeline.py
  3. 15 2
      slot_workflow.py
  4. 146 0
      spine_builder.py
  5. 3 2
      templates/SlotGame.ts

+ 3 - 0
exporter.py

@@ -26,8 +26,11 @@ def _symbol_fit_from_library(lib):
     default_s = float(symbol_cfg.get("defaultScalePerCell", 0.00093) or 0.00093)
     default_oyf = float(symbol_cfg.get("defaultOriginYOffsetPerCell", 0.5) or 0.5)
     fits = {}
+    symbol_ids = {s.get("id") for s in (slot_config.get("symbols") or [])}
     for item in lib.get("characters", []):
         cid = item.get("id")
+        if symbol_ids and cid not in symbol_ids:
+            continue
         w = float(item.get("w") or 0)
         h = float(item.get("h") or 0)
         if not cid or w <= 0 or h <= 0:

+ 41 - 15
pipeline.py

@@ -61,26 +61,52 @@ def run(manifest, out_root, creds=None, log=print):
             log(f"⚠️  未填 key,跳过角色 {cid}")
             continue
         try:
-            full_prompt = ", ".join(x for x in [
-                c.get("prompt", ""), style,
-                transparent_prompt("single game icon character, centered, full body in frame, no text"),
-            ] if x)
-            log(f"🎨 [{cid}] 生成角色图…")
-            img = providers.generate(creds["provider"], full_prompt, creds["api_key"],
-                                     creds.get("base_url", "https://api.openai.com/v1"),
-                                     creds.get("model", "gpt-image-2"),
-                                     c.get("size", creds.get("size", "1024x1024")))
-            img = background_remover.remove_background(img, log=log, label=cid, enabled=remove_bg_enabled,
-                                                       image_url=img.info.get("source_url"))
-            spine_builder.build_character(cid, img, chars_out, anims)
-            w, h = spine_builder.trim_to_content(img).size
+            if c.get("type") == "spine_parts":
+                part_images = {}
+                parts = c.get("parts") or []
+                for part in parts:
+                    part_id = part["id"]
+                    part_prompt = ", ".join(x for x in [
+                        c.get("prompt", ""),
+                        part.get("prompt", ""),
+                        style,
+                        transparent_prompt("single separated rigging part only, centered, no text, no other body parts")
+                    ] if x)
+                    log(f"🎨 [{cid}/{part_id}] 生成 Boss 拆件…")
+                    pimg = providers.generate(creds["provider"], part_prompt, creds["api_key"],
+                                             creds.get("base_url", "https://api.openai.com/v1"),
+                                             creds.get("model", "gpt-image-2"),
+                                             part.get("size", c.get("size", creds.get("size", "1024x1024"))))
+                    pimg = background_remover.remove_background(pimg, log=log, label=f"{cid}/{part_id}",
+                                                               enabled=remove_bg_enabled,
+                                                               image_url=pimg.info.get("source_url"))
+                    part_images[part_id] = pimg
+                spine_builder.build_parts_character(cid, part_images, chars_out, anims, parts)
+                w, h = 1000, 1000
+                files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
+            else:
+                full_prompt = ", ".join(x for x in [
+                    c.get("prompt", ""), style,
+                    transparent_prompt("single game icon character, centered, full body in frame, no text"),
+                ] if x)
+                log(f"🎨 [{cid}] 生成角色图…")
+                img = providers.generate(creds["provider"], full_prompt, creds["api_key"],
+                                         creds.get("base_url", "https://api.openai.com/v1"),
+                                         creds.get("model", "gpt-image-2"),
+                                         c.get("size", creds.get("size", "1024x1024")))
+                img = background_remover.remove_background(img, log=log, label=cid, enabled=remove_bg_enabled,
+                                                           image_url=img.info.get("source_url"))
+                spine_builder.build_character(cid, img, chars_out, anims)
+                w, h = spine_builder.trim_to_content(img).size
+                files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
             library["characters"].append({
                 "id": cid,
                 "png": f"characters/{cid}.png",
                 "w": w, "h": h,
+                "type": c.get("type", "spine"),
+                "role": c.get("role", ""),
                 "animations": spine_builder.anim_data(anims),
-                "files": [f"characters/{cid}.json", f"characters/{cid}.atlas",
-                          f"characters/{cid}.png"],
+                "files": files,
             })
             log(f"✅ [{cid}] 完成 ({anims})")
             progress(f"{cid}")

+ 15 - 2
slot_workflow.py

@@ -361,15 +361,28 @@ def build_manifest(slot_config):
         boss_id = slot_config["boss"]["id"]
         characters.append({
             "id": boss_id,
-            "type": "spine",
+            "type": "spine_parts",
             "role": "boss",
-            "animations": ["idle", "hurt", "attack", "win"],
+            "animations": ["idle", "taunt", "attack", "hurt", "explode"],
             "prompt": (
                 "a cute stylized mobile game demon lord boss character, full body, oversized dark armor, "
                 "horned crown, glowing eyes, holding a huge greatsword, triumphant villain pose, one boot "
                 "planted forward on a simple defeated hero silhouette, no gore, no blood, clean mobile slot "
                 "game boss asset, transparent background"
             ),
+            "parts": [
+                {"id": "torso", "parent": "root", "x": 0, "y": 180, "prompt": "torso armor chest of a cute demon lord boss, cracked dark armor, front view"},
+                {"id": "head", "parent": "torso", "x": 0, "y": 260, "prompt": "horned demon lord head with glowing eyes and crown, cute villain mobile game style"},
+                {"id": "left_arm", "parent": "torso", "x": -170, "y": 160, "prompt": "left armored arm of demon lord boss with clawed gauntlet"},
+                {"id": "right_arm", "parent": "torso", "x": 170, "y": 170, "prompt": "right armored arm of demon lord boss gripping a sword handle"},
+                {"id": "greatsword", "parent": "right_arm", "x": 160, "y": 170, "prompt": "oversized dark fantasy greatsword, mobile game boss weapon, clean icon asset"},
+                {"id": "left_leg", "parent": "torso", "x": -85, "y": -80, "prompt": "left armored boot leg of demon lord boss, heavy boot"},
+                {"id": "right_leg", "parent": "torso", "x": 90, "y": -80, "prompt": "right armored boot leg of demon lord boss stepping forward"},
+                {"id": "cape", "parent": "torso", "x": -40, "y": 120, "prompt": "torn purple villain cape piece for demon lord boss"},
+                {"id": "horn_left", "parent": "head", "x": -70, "y": 70, "prompt": "left curved horn of demon lord boss"},
+                {"id": "horn_right", "parent": "head", "x": 70, "y": 70, "prompt": "right curved horn of demon lord boss"},
+                {"id": "crack_core", "parent": "torso", "x": 0, "y": 160, "prompt": "glowing purple gold magical crack core explosion light for boss armor"},
+            ],
         })
 
     return {

+ 146 - 0
spine_builder.py

@@ -73,12 +73,28 @@ def bounce_in():
     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,
 }
 
 
@@ -185,6 +201,29 @@ def write_atlas(atlas_path, png_name, region_name, w, h):
         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 = {}
@@ -265,3 +304,110 @@ def build_character(char_id, image, out_dir, animations):
         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 "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 "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(remove_bg(part_images[pid]), 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

+ 3 - 2
templates/SlotGame.ts

@@ -367,7 +367,8 @@ export class SlotGame extends Component {
   private bossDefeated() {
     if (!this.bossNode || !this.bossSk) return;
     this.bossSk.setAnimation(0, 'hurt', false);
-    this.bossSk.addAnimation(0, 'idle', true, 0.2);
+    this.bossSk.addAnimation(0, 'explode', false, 0.08);
+    this.bossSk.addAnimation(0, 'idle', true, 0.3);
     this.playParticle('boss_explosion');
     const n = this.bossNode;
     const base = n.scale.x;
@@ -379,7 +380,7 @@ export class SlotGame extends Component {
 
   private bossTaunt() {
     if (!this.bossNode || !this.bossSk) return;
-    this.bossSk.setAnimation(0, 'attack', false);
+    this.bossSk.setAnimation(0, 'taunt', false);
     this.bossSk.addAnimation(0, 'idle', true, 0);
     const n = this.bossNode;
     const x = n.position.x, y = n.position.y;