Explorar o código

Add export QA report and tighten slot layout

bang hai 2 semanas
pai
achega
8dae3966e5
Modificáronse 2 ficheiros con 147 adicións e 11 borrados
  1. 130 0
      exporter.py
  2. 17 11
      templates/SlotGame.ts

+ 130 - 0
exporter.py

@@ -91,6 +91,124 @@ def _math_report_md(slot_config):
     return "\n".join(lines) + "\n"
 
 
+def _image_alpha_stats(path):
+    try:
+        from PIL import Image
+        with Image.open(path) as img:
+            rgba = img.convert("RGBA")
+            alpha = rgba.getchannel("A")
+            bbox = alpha.getbbox()
+            if not bbox:
+                return {"ok": False, "reason": "empty_alpha", "size": rgba.size}
+            pix = list(alpha.getdata())
+            transparent = sum(1 for v in pix if v == 0)
+            opaque = sum(1 for v in pix if v > 16)
+            area = rgba.width * rgba.height
+            return {
+                "ok": True,
+                "size": rgba.size,
+                "bbox": bbox,
+                "transparentRatio": round(transparent / max(1, area), 4),
+                "opaqueRatio": round(opaque / max(1, area), 4),
+                "bboxRatio": round(((bbox[2] - bbox[0]) * (bbox[3] - bbox[1])) / max(1, area), 4),
+            }
+    except Exception as e:
+        return {"ok": False, "reason": str(e), "size": [0, 0]}
+
+
+def _qa_add(report, level, code, message, target="", suggestion=""):
+    report["items"].append({
+        "level": level,
+        "code": code,
+        "target": target,
+        "message": message,
+        "suggestion": suggestion,
+    })
+
+
+def _qa_report(lib, base, slot_src):
+    slot_config = lib.get("slot_config") or {}
+    report = {
+        "game": lib.get("game"),
+        "summary": {"error": 0, "warning": 0, "info": 0},
+        "items": [],
+    }
+    chars = {c.get("id"): c for c in lib.get("characters", [])}
+    ui_art = {a.get("id"): a for a in lib.get("ui_art", [])}
+    required_art = ["bg_main", "logo", "reel_frame", "btn_spin", "hud_pill"]
+    for aid in required_art:
+        path = os.path.join(base, "ui_art", f"{aid}.png")
+        if aid not in ui_art or not os.path.isfile(path):
+            _qa_add(report, "error", "missing_ui_art", f"关键 UI 资源缺失:{aid}", aid, "在 UI 美术任务卡片里补生成或重新生成。")
+
+    logo_path = os.path.join(base, "ui_art", "logo.png")
+    if os.path.isfile(logo_path):
+        st = _image_alpha_stats(logo_path)
+        if not st.get("ok"):
+            _qa_add(report, "error", "bad_logo_alpha", "Logo 图片没有有效 Alpha 内容", "logo", "重新生成 logo。")
+        elif st["opaqueRatio"] < 0.03:
+            _qa_add(report, "warning", "logo_too_empty", "Logo 有效像素很少,可能是空框或文字缺失。", "logo", "重新生成 logo,并要求真实可读标题文字。")
+
+    frame_path = os.path.join(base, "ui_art", "reel_frame.png")
+    if os.path.isfile(frame_path):
+        st = _image_alpha_stats(frame_path)
+        if st.get("ok") and st["transparentRatio"] < 0.25:
+            _qa_add(report, "warning", "reel_frame_not_hollow", "卷轴框透明区域偏少,可能不是空心框。", "reel_frame", "重新生成空心透明中心的 reel_frame。")
+
+    boss = slot_config.get("boss") or {}
+    if boss.get("enabled"):
+        boss_id = boss.get("id") or "boss_demon_lord"
+        row = chars.get(boss_id)
+        if not row:
+            _qa_add(report, "error", "missing_boss", f"关主资源缺失:{boss_id}", boss_id, "在角色库补生成关主。")
+        else:
+            preview = row.get("preview")
+            if not preview or not os.path.isfile(os.path.join(base, preview)):
+                _qa_add(report, "warning", "missing_boss_preview", "关主缺少完整预览图,页面会误把 atlas 当角色。", boss_id, "重新生成关主,或让系统补完整 preview。")
+            if row.get("type") != "spine_parts":
+                _qa_add(report, "warning", "boss_not_parts", "关主不是 spine_parts,爆炸拆件动作会受限。", boss_id, "重新生成关主拆件。")
+            theme = (slot_config.get("theme") or {}).get("key", "")
+            title = boss.get("title", "")
+            if theme in ("jelly", "pirate_jelly") and ("恶魔" in title or "魔王" in title):
+                _qa_add(report, "warning", "boss_theme_mismatch", "关主名称/设定仍偏暗黑,可能不贴合当前糖果主题。", boss_id, "点击重新生成,使用主题化 boss prompt。")
+
+    layout = slot_config.get("layout") or {}
+    reel = layout.get("reel") or {}
+    rows = int(reel.get("rows") or 0)
+    cols = int(reel.get("cols") or 0)
+    if rows and cols and rows <= 3:
+        _qa_add(report, "info", "compact_reel_layout", "低行数盘面已使用紧凑布局,避免中间空行过大。", "SlotGame.ts")
+    if "fillColor = new Color(255, 255, 255, 0)" not in slot_src:
+        _qa_add(report, "warning", "symbol_card_background", "符号格子仍可能有白色底板。", "SlotGame.ts", "检查 buildGrid,格子底板应为透明或极弱描边。")
+    if "BOSS_CONFIG.logoSide" not in slot_src:
+        _qa_add(report, "warning", "boss_logo_position", "关主没有按 logo 旁边定位,可能挤进盘面。", "SlotGame.ts", "检查 buildBoss 的位置。")
+
+    for item in report["items"]:
+        report["summary"][item["level"]] += 1
+    return report
+
+
+def _qa_report_md(report):
+    lines = [
+        "# Asset QA Report",
+        "",
+        f"- Game: `{report.get('game')}`",
+        f"- Errors: `{report['summary']['error']}`",
+        f"- Warnings: `{report['summary']['warning']}`",
+        f"- Info: `{report['summary']['info']}`",
+        "",
+        "## Findings",
+    ]
+    if not report["items"]:
+        lines.append("- No QA findings.")
+    for item in report["items"]:
+        lines.append(
+            f"- **{item['level'].upper()}** `{item['code']}` `{item.get('target','')}`: "
+            f"{item['message']}" + (f" 建议:{item['suggestion']}" if item.get("suggestion") else "")
+        )
+    return "\n".join(lines) + "\n"
+
+
 # ---------------------------------------------------------------- 粒子贴图
 def _write_particle_png(path):
     """生成一张柔光圆点透明 PNG(粒子配置引用的 particle.png)。"""
@@ -699,6 +817,18 @@ def export(game, out_root, log=print):
             .replace("__SYMBOL_FIT__", json.dumps(symbol_fit, ensure_ascii=False))
             .replace("__SYMBOL_FIT_DEFAULT__", json.dumps(symbol_fit_default, ensure_ascii=False)))
     open(os.path.join(scripts, "SlotGame.ts"), "w", encoding="utf-8").write(slot)
+    qa = _qa_report(lib, base, slot_src)
+    open(os.path.join(pack, "QAReport.json"), "w", encoding="utf-8").write(
+        json.dumps(qa, ensure_ascii=False, indent=2)
+    )
+    open(os.path.join(pack, "QAReport.md"), "w", encoding="utf-8").write(_qa_report_md(qa))
+    if qa["summary"]["error"] or qa["summary"]["warning"]:
+        log(f"🧪 QA:{qa['summary']['error']} 个错误,{qa['summary']['warning']} 个警告;详见 QAReport.md")
+        for item in qa["items"][:6]:
+            if item["level"] in ("error", "warning"):
+                log(f"🧪 [{item['level']}] {item['target']} · {item['message']}")
+    else:
+        log("🧪 QA:通过,未发现错误或警告")
     src_tween = os.path.join(base, "ui", "TweenPresets.ts")
     if os.path.isfile(src_tween):
         shutil.copy2(src_tween, os.path.join(scripts, "TweenPresets.ts"))

+ 17 - 11
templates/SlotGame.ts

@@ -3,7 +3,7 @@
 //  by anim_studio
 //  v4:先预加载全部 UI 美术,再搭界面——HUD 胶囊用 hud_pill、
 //      圆按钮用 btn_round、SPIN 用 btn_spin;隐藏预览性能面板;
-//      4×5 大符号 + 每格圆角卡片
+//      大符号透明盘面;Boss 贴近顶部 Logo 做常规动作
 // =============================================================
 import {
   _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, Sprite,
@@ -174,10 +174,13 @@ export class SlotGame extends Component {
     this.frameCY = H * R.cy;
     const innerW = fw * R.holeW, innerH = fh * R.holeH;
     this.cellW = innerW / COLS;
-    this.cellH = innerH / ROWS;
+    // 3 行时不要把符号拉满整个高内孔,否则中间会空得很厉害。
+    this.cellH = Math.min(innerH / ROWS, this.cellW * 1.04);
     this.cell = Math.min(this.cellW, this.cellH);
     this.gridX0 = -innerW / 2 + this.cellW / 2;
-    this.gridY0 = this.frameCY + innerH / 2 - this.cellH / 2;
+    // The reel interior is a window/mirror. Symbols should start near its top,
+    // not float around the visual center with large empty bands between rows.
+    this.gridY0 = this.frameCY + innerH / 2 - this.cellH * 0.62;
   }
   private cellPos(c: number, r: number): [number, number] {
     return [this.gridX0 + c * this.cellW, this.gridY0 - r * this.cellH];
@@ -216,14 +219,17 @@ export class SlotGame extends Component {
 
   private buildBoss() {
     if (!BOSS_CONFIG.enabled || !this.dataMap[BOSS_ID]) return;
-    const node = new Node('boss'); node.parent = this.node; node.setSiblingIndex(8);
+    const node = new Node('boss'); node.parent = this.node; node.setSiblingIndex(22);
     const sk = node.addComponent(sp.Skeleton);
     sk.skeletonData = this.dataMap[BOSS_ID];
     sk.premultipliedAlpha = false;
     sk.setAnimation(0, 'idle', true);
-    const scale = Math.min(0.22, this.W / 3900);
+    const scale = Math.min(0.18, this.W / 4800);
     node.setScale(scale, scale, 1);
-    node.setPosition(this.frameW * 0.40, this.frameCY + this.frameH * 0.32, 0);
+    const logoCy = this.H * LAYOUT.logo.cy;
+    const side = BOSS_CONFIG.logoSide === 'left' ? -1 : 1;
+    const x = side * Math.min(this.W * 0.34, this.frameW * 0.46);
+    node.setPosition(x, logoCy - this.H * 0.015, 0);
     this.bossNode = node; this.bossSk = sk;
     this.schedule(() => this.bossIdleBeat(), 3.2);
   }
@@ -455,12 +461,12 @@ export class SlotGame extends Component {
     return n;
   }
 
-  // ---------------- 网格:圆角卡片 + 大符号 ----------------
+  // ---------------- 网格:透明盘面 + 大符号 ----------------
   private fit(id: string) { return SYMFIT[id] || SYMFIT_DEFAULT; }
 
   private buildGrid() {
-    const cardW = this.cellW * 0.90;
-    const cardH = this.cellH * 0.90;
+    const cardW = this.cellW * 0.92;
+    const cardH = this.cellH * 0.92;
     const cardR = Math.min(cardW, cardH) * 0.18;
     for (let c = 0; c < COLS; c++) {
       this.cells[c] = []; this.ids[c] = []; this.symBaseY[c] = [];
@@ -469,9 +475,9 @@ export class SlotGame extends Component {
         const [x, y] = this.cellPos(c, r);
         const card = new Node(`card_${c}_${r}`); card.parent = this.node; card.setPosition(x, y, 0); card.setSiblingIndex(5);
         const cg = card.addComponent(Graphics);
-        cg.fillColor = new Color(255, 255, 255, 215);
+        cg.fillColor = new Color(255, 255, 255, 0);
         this.roundRect(cg, -cardW / 2, -cardH / 2, cardW, cardH, cardR); cg.fill();
-        cg.lineWidth = 3; cg.strokeColor = new Color(120, 175, 255, 240);
+        cg.lineWidth = 1.5; cg.strokeColor = new Color(255, 255, 255, 38);
         this.roundRect(cg, -cardW / 2, -cardH / 2, cardW, cardH, cardR); cg.stroke();
         const f = this.fit(id);
         const scale = Math.min(this.cellW, this.cellH) * f.s;